You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 

229 lines
6.0 KiB

'use strict';
function asPromise (fn) {
return new Promise((resolve, reject) => {
const args = [...arguments].slice(1);
args.push((err, res) => {
if (err) return reject(err);
resolve(res);
});
fn.apply(null, args);
});
}
function buildCache (client, opts) {
if (!client) {
throw Error('redis client is required.');
}
if (typeof opts === 'number') {
opts = {max: opts};
}
opts = Object.assign({
namespace: 'LRU-CACHE!',
score: () => new Date().getTime(),
increment: false
}, opts);
if (!opts.max) {
throw Error('max number of items in cache must be specified.');
}
const ZSET_KEY = `${opts.namespace}-i`;
function namedKey (key) {
if (!typeof key === 'string') {
return Promise.reject(Error('key should be a string.'));
}
return `${opts.namespace}-k-${key}`;
}
/*
* Remove a set of keys from the cache and the index, in a single transaction,
* to avoid orphan indexes or cache values.
*/
const safeDelete = (keys) => {
if (keys.length) {
const multi = client.multi()
.zrem(ZSET_KEY, keys)
.del(keys);
return asPromise(multi.exec.bind(multi));
}
return Promise.resolve();
};
/*
* Gets the value for the given key and updates its timestamp score, only if
* already present in the zset. The result is JSON.parsed before returned.
*/
const get = (key) => {
const score = -1 * opts.score(key);
key = namedKey(key);
const multi = client.multi()
.get(key);
if (opts.increment) {
multi.zadd(ZSET_KEY, 'XX', 'CH', 'INCR', score, key);
} else {
multi.zadd(ZSET_KEY, 'XX', 'CH', score, key);
}
return asPromise(multi.exec.bind(multi))
.then((results) => {
if (results[0] === null && results[1]) {
// value has been expired, remove from zset
return asPromise(client.zrem.bind(client), ZSET_KEY, key)
.then(() => null);
}
return JSON.parse(results[0]);
});
};
/*
* Save (add/update) the new value for the given key, and update its timestamp
* score. The value is JSON.stringified before saving.
*
* If there are more than opts.max items in the cache after the operation
* then remove each exceeded key from the zset index and its value from the
* cache (in a single transaction).
*/
const set = (key, value, maxAge) => {
const score = -1 * opts.score(key);
key = namedKey(key);
maxAge = maxAge || opts.maxAge;
const multi = client.multi();
if (maxAge) {
multi.set(key, JSON.stringify(value), 'PX', maxAge);
} else {
multi.set(key, JSON.stringify(value));
}
if (opts.increment) {
multi.zadd(ZSET_KEY, 'INCR', score, key);
} else {
multi.zadd(ZSET_KEY, score, key);
}
// we get zrange first then safe delete instead of just zremrange,
// that way we guarantee that zset is always in sync with available data in the cache
// also, include the last item inside the cache size, because we always want to
// preserve the one that was just set, even if it has same or less score than other.
multi.zrange(ZSET_KEY, opts.max - 1, -1);
return asPromise(multi.exec.bind(multi))
.then((results) => {
if (results[2].length > 1) { // the first one is inside the limit
let toDelete = results[2].slice(1);
if (toDelete.indexOf(key) !== -1) {
toDelete = results[2].slice(0, 1).concat(results[2].slice(2));
}
return safeDelete(toDelete);
}
})
.then(() => value);
};
/*
* Try to get the value of key from the cache. If missing, call function and store
* the result.
*/
const getOrSet = (key, fn, maxAge) => get(key)
.then((result) => {
if (result === null) {
return Promise.resolve()
.then(fn)
.then((result) => set(key, result, maxAge));
}
return result;
});
/*
* Retrieve the value for key in the cache (if present), without updating the
* timestamp score. The result is JSON.parsed before returned.
*/
const peek = (key) => {
key = namedKey(key);
return asPromise(client.get.bind(client), key)
.then((result) => {
if (result === null) {
// value may have been expired, remove from zset
return asPromise(client.zrem.bind(client), ZSET_KEY, key)
.then(() => null);
}
return JSON.parse(result);
});
};
/*
* Remove the value of key from the cache (and the zset index).
*/
const del = (key) => {
// TODO safeDelete?
key = namedKey(key);
const multi = client.multi()
.del(key)
.zrem(ZSET_KEY, key);
return asPromise(multi.exec.bind(multi))
.then((results) => results[1]);
};
/*
* Remove all items from cache and the zset index.
*/
const reset = () => asPromise(client.zrange.bind(client), ZSET_KEY, 0, -1)
.then(safeDelete);
/*
* Return true if the given key is in the cache
*/
const has = (key) => asPromise(client.get.bind(client), namedKey(key))
.then((result) => (!!result));
/*
* Return an array of the keys currently in the cache, most reacently accessed
* first.
*/
const keys = () => asPromise(client.zrange.bind(client), ZSET_KEY, 0, opts.max - 1)
.then((results) => results.map((key) => key.slice(`${opts.namespace}-k-`.length)));
/*
* Return an array of the values currently in the cache, most reacently accessed
* first.
*/
const values = () => asPromise(client.zrange.bind(client), ZSET_KEY, 0, opts.max - 1)
.then((results) => {
const multi = client.multi();
results.forEach((key) => multi.get(key));
return asPromise(multi.exec.bind(multi));
})
.then((results) => results.map(JSON.parse));
/*
* Return the amount of items currently in the cache.
*/
const count = () => asPromise(client.zcard.bind(client), ZSET_KEY);
return {
get: get,
set: set,
getOrSet: getOrSet,
peek: peek,
del: del,
reset: reset,
has: has,
keys: keys,
values: values,
count: count
};
}
module.exports = buildCache;