Files
talk/services/cache.js
T
2018-01-11 20:00:34 -07:00

340 lines
9.5 KiB
JavaScript

const redis = require('./redis');
const debug = require('debug')('talk:services:cache');
const cache = (module.exports = {});
/**
* This collects a key that may either be an array or a string and creates a
* unified key out of it.
* @param {Mixed} key Either an array of items composing a key or a string
* @return {String} A string that represents a key
*/
const keyfunc = key => {
if (Array.isArray(key)) {
return `cache[${key.join(':')}]`;
}
return `cache[${key}]`;
};
/**
* This wraps a complicated function with a cache, in the event that the item is
* not inside the cache, it will perform the work to get it and then set it
* followed by returning the value.
* @param {Mixed} key Either an array of items or string representing this
* work
* @param {Integer} expiry Time in seconds for the cache entry to live for
* @param {Function} work A function that returns a promise that can be
* resolved as the value to cache.
* @return {Promise} Resolves to the value either retrieved from cache
*/
cache.wrap = async (key, expiry, work, kf = keyfunc) => {
let value = await cache.get(key, kf);
if (typeof value !== 'undefined' && value !== null) {
debug('wrap: hit', kf(key));
return value;
}
debug('wrap: miss', kf(key));
value = await work();
process.nextTick(async () => {
try {
await cache.set(key, value, expiry, kf);
debug('wrap: set complete');
} catch (err) {
console.error(err);
}
});
return value;
};
/**
* Init sets up the scripts used in Redis with the incr/decr commands.
*/
cache.init = async () => {
// Create the redis instance.
cache.client = redis.createClient();
// This is designed to increment a key and add an expiry iff the key already
// exists.
cache.client.defineCommand('increx', {
numberOfKeys: 1,
lua: `
if redis.call('GET', KEYS[1]) ~= false then
redis.call('INCR', KEYS[1])
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
`,
});
// This is designed to decrement a key and add an expiry iff the key already
// exists.
cache.client.defineCommand('decrex', {
numberOfKeys: 1,
lua: `
if redis.call('GET', KEYS[1]) ~= false then
redis.call('DECR', KEYS[1])
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
`,
});
cache.client.defineCommand('hincrbyex', {
numberOfKeys: 2,
lua: `
if redis.call('HGET', KEYS[1], KEYS[2]) ~= false then
redis.call('HINCRBY', KEYS[1], KEYS[2], ARGV[1])
redis.call('EXPIRE', KEYS[1], ARGV[2])
end
`,
});
};
/**
* This will increment a key in redis and update the expiry iff it already
* exists, otherwise it will do nothing.
*/
cache.incr = async (key, expiry, kf = keyfunc) =>
cache.client.increx(kf(key), expiry);
/**
* This will decrement a key in redis and update the expiry iff it already
* exists, otherwise it will do nothing.
*/
cache.decr = async (key, expiry, kf = keyfunc) =>
cache.client.decrex(kf(key, expiry));
/**
* This will increment many keys in redis and update the expiry iff it already
* exists, otherwise it will do nothing.
*/
cache.incrMany = async (keys, expiry, kf = keyfunc) => {
let multi = cache.client.multi();
for (const key of keys) {
// Queue up the evalsha command.
multi.increx(kf(key), expiry);
}
return multi.exec();
};
/**
* This will decrement many keys in redis and update the expiry iff it already
* exists, otherwise it will do nothing.
*/
cache.decrMany = async (keys, expiry, kf = keyfunc) => {
let multi = cache.client.multi();
for (const key of keys) {
// Queue up the evalsha command.
multi.decrex(kf(key), expiry);
}
return multi.exec();
};
/**
* [wrapMany description]
* @param {Array<String>} keys Either an array of objects represening
* this work
* @param {Integer} expiry Time in seconds for the cache entry to live for
* @param {Function} work A function that returns a promise that can be
* resolved as the value to cache.
* @param {Function} [kf=keyfunc] optional key function to use to turn the
* provided key into a string for the cache.
* @return {Promise} resovles to the values for the keys
*/
cache.wrapMany = async (keys, expiry, work, kf = keyfunc) => {
let values = await cache.getMany(keys, kf);
// find any of the null valued items by collecting the work
let workRefs = values
.map((value, index) => ({ value, index, key: keys[index] }))
.filter(({ value }) => value === null);
let workKeys = workRefs.map(({ key }) => key);
debug(`wrapMany: hit ratio: ${keys.length - workKeys.length}/${keys.length}`);
if (workKeys.length > 0) {
const workedValues = await work(workKeys);
// Set the items in the cache that we needed to retrive after the
// next process tick.
process.nextTick(() => {
cache
.setMany(workKeys, workedValues, expiry, kf)
.then(() => {
debug('wrapMany: setMany complete');
})
.catch(err => {
console.error(err);
});
});
// Walk over the worked keys to merge them with the existing values.
for (let i = 0; i < workRefs.length; i++) {
values[workRefs[i].index] = workedValues[i];
}
}
return values;
};
/**
* This returns a promise that returns a promise that resolves with the value
* from the cache or null if it does not exist in the cache.
* @param {Mixed} key Either an array of items composing a key or a string
* @return {Promise}
*/
cache.get = async (key, kf = keyfunc) =>
cache.client.get(kf(key)).then(reply => {
if (typeof reply !== 'undefined' && reply !== null) {
// Parse the stored cache value from JSON.
return JSON.parse(reply);
}
return null;
});
/**
* Returns many replies.
* @param {Array<String>} keys Either an array of objects represening
* this work
* @param {Function} [kf=keyfunc] optional key function to use to turn the
* provided key into a string for the cache.
*/
cache.getMany = async (keys, kf = keyfunc) =>
cache.client.mget(keys.map(kf)).then(replies => {
// Parse the replies.
for (let i = 0; i < replies.length; i++) {
let value = null;
if (typeof replies[i] !== 'undefined' && replies[i] !== null) {
// Parse the stored cache value from JSON.
value = JSON.parse(replies[i]);
}
replies[i] = value;
}
return replies;
});
/**
* Sets many entries in the cache.
* @param {Array<String>} keys array of keys
* @param {Array} values array of values to set
* @param {Function} [kf=keyfunc] optional key function to use to turn the
* provided key into a string for the cache.
*/
cache.setMany = async (keys, values, expiry, kf = keyfunc) => {
let multi = cache.client.multi();
keys.forEach((key, index) => {
// Serialize the value as JSON.
let reply = JSON.stringify(values[index]);
// Queue up the set command.
multi.set(kf(key), reply, 'EX', expiry);
});
return multi.exec();
};
/**
* This invalidates a cached entry in the cache.
* @param {Mixed} key Either an array of items composing a key or a string
* @return {Promise}
*/
cache.invalidate = async (key, kf = keyfunc) => {
debug(`invalidate: ${kf(key)}`);
return cache.client.del(kf(key));
};
/**
* This sets a value on the key with the expiry and then resolves once it is
* done.
* @param {Mixed} key Either an array of items composing a key or a string
* @param {Mixed} value Object to be serialized and set to the cache
* @param {Integer} expiry Time in seconds for the cache entry to live for
* @return {Promise}
*/
cache.set = async (key, value, expiry, kf = keyfunc) => {
// Serialize the value as JSON.
let reply = JSON.stringify(value);
return cache.client.set(kf(key), reply, 'EX', expiry);
};
/**
* h is the hash form of the cache.
*/
cache.h = {};
cache.h.get = async (key, field = '__default__') => {
// Get the current value from redis.
const reply = await cache.client.hget(keyfunc(key), field);
if (typeof reply !== 'undefined' && reply !== null) {
return JSON.parse(reply);
}
return null;
};
cache.h.set = async (key, field = '__default__', value, expiry = 60) => {
// Serialize the value as JSON.
let reply = JSON.stringify(value);
return cache.client
.pipeline()
.hset(keyfunc(key), field, reply)
.expire(keyfunc(key), expiry)
.exec();
};
cache.h.invalidate = async (key, field = null) => {
if (field === null) {
return cache.invalidate(key);
}
debug(`invalidate: ${keyfunc(key)} ${field}`);
return cache.client.hdel(keyfunc(key), field);
};
cache.h.wrap = async (key, field, expiry, work) => {
let value = await cache.h.get(key, field);
if (value !== null) {
debug('wrap: hit', keyfunc(key));
return value;
}
debug('wrap: miss', keyfunc(key));
value = await work();
process.nextTick(async () => {
try {
await cache.h.set(key, field, value, expiry);
debug('wrap: set complete');
} catch (err) {
console.error(err);
}
});
return value;
};
cache.h.incr = async (key, field = '__default__', expiry) =>
cache.client.hincrbyex(keyfunc(key), field, 1, expiry);
cache.h.decr = async (key, field = '__default__', expiry) =>
cache.client.hincrbyex(keyfunc(key), field, -1, expiry);