redis-lru-cache
Version:
cluster friendly cache, that relies on redis pub/sub to share data
497 lines (437 loc) • 14.3 kB
JavaScript
var EventEmitter = require('events').EventEmitter,
LRU = require('lru-cache'),
redis_pubsub = require('node-redis-pubsub'),
redis = require('redis'),
async = require('async'),
mongoFilter = require('happn-commons').mongoFilter,
clone = require('clone'),
hyperid = require('happner-hyperid').create({
urlSafe: true,
}),
util = require('util');
function CacheError(message, cause) {
this.name = 'CacheError';
this.code = 500;
this.message = message;
if (cause) this.cause = cause;
}
CacheError.prototype = Error.prototype;
function InternalCache(opts) {
try {
if (opts == null) opts = {};
if (!opts.cacheId)
throw new CacheError(
'invalid or no cache id specified - caches must be identified to ensure continuity'
);
if (!opts.redisExpire) opts.redisExpire = 1000 * 60 * 30; // redis values expire after 30 minutes
this.__redisExpire = opts.redisExpire;
this.__cacheNodeId = opts.cacheId + '_' + Date.now() + '_' + hyperid();
this.__cacheId = opts.cacheId;
this.__subscriptions = {};
this.__systemSubscriptions = {};
this.__connected = false;
this.__stats = {
memory: 0,
subscriptions: 0,
redis: 0,
};
this.__connectionQueue = async.queue((_task, callback) => {
if (this.__connected) return callback();
return this.__redisClient
.connect()
.then(() => {
this.__connected = true;
callback();
})
.catch(callback);
}, 1);
if (!opts.lru) opts.lru = {};
//handles cases where
opts.lru.dispose = function (key) {
this.__removeSubscription(key);
}.bind(this);
if (!opts.lru.max) opts.lru.max = 5000; //caching 5000 data points in memory
this.__cache = new LRU(opts.lru);
this.__eventEmitter = new EventEmitter();
this.__eventEmitter.setMaxListeners(100);
if (!opts.redis) opts.redis = {};
opts.redis.prefix = this.__cacheId;
if (!opts.redis.port) opts.redis.port = 6379;
var url = opts.redis.url || 'redis://127.0.0.1';
delete opts.redis.url;
var pubsubOpts; // for use with redis pubsub
pubsubOpts = JSON.parse(JSON.stringify(opts.redis));
if (opts.redis.password) pubsubOpts.auth = opts.redis.password;
pubsubOpts.scope = this.__cacheId + '_pubsub'; //separate data-layer for pubsub
delete pubsubOpts.password;
delete pubsubOpts.prefix;
pubsubOpts.url = url;
this.__redisClient = redis.createClient(url, opts.redis);
this.__redisPubsub = new redis_pubsub(pubsubOpts);
this.__systemSubscriptions['system/cache-reset'] = this.__redisPubsub.on(
'system/cache-reset',
function (message) {
if (message.origin !== this.cacheNodeId) this.__cache.reset();
}.bind(this)
);
this.__systemSubscriptions['system/cache-reset'] = this.__redisPubsub.on(
'system/cache-reset',
function (message) {
if (message.origin !== this.cacheNodeId) this.__cache.reset();
}.bind(this)
);
this.__systemSubscriptions['system/redis-error'] = this.__redisClient.on(
'error',
function (error) {
this.__emit('error', new CacheError('redis client error', error));
}.bind(this)
);
this.__systemSubscriptions['system/redis-error'] = this.__redisPubsub.on(
'error',
function (error) {
this.__emit('error', new CacheError('redis pubsub error', error));
}.bind(this)
);
if (opts.clear)
this.reset((e) => {
if (e) return this.__emit('error', new CacheError('cache clear on startup failed', e));
});
} catch (e) {
throw new CacheError('failed with cache initialization: ' + e.toString(), e);
}
}
InternalCache.prototype.__removeSubscription = function (key, callback) {
if (this.__subscriptions[key]) {
this.__subscriptions[key]((e) => {
if (e) {
if (callback) callback(e);
return this.__emit(
'error',
new CacheError('failure removing redis subscription, key: ' + key, e)
);
}
delete this.__subscriptions[key];
this.__stats.subscriptions--;
this.__emit('item-disposed', key);
if (callback) callback();
});
}
};
InternalCache.prototype.__updateLRUCache = function (key, item, callback) {
//update our LRU cache
this.__cache.set(key, item);
if (this.__subscriptions[key]) {
return callback(null, item); //we already have a change listener
}
//create a subscription to changes, gets whacked on the opts.dispose method
this.__subscriptions[key] = this.__redisPubsub.on(
key,
(message) => {
//item has changed on a different node, we delete it from our cache, so the latest version can be re-fetched if necessary
//origin introduced to alleviate tail chasing
if (message.origin !== this.__cacheNodeId)
return this.del(
key,
(e) => {
if (e)
this.__emit(
'error',
new CacheError(
'unable to clear cache after item was updated elsewhere, key: ' + key,
e
)
);
},
true
); //noRedis is true, as this has been removed elsewhere
},
(e) => {
if (e) return callback(e);
this.__stats.subscriptions++;
callback(null, item);
}
);
};
InternalCache.prototype.__updateRedisCache = function (key, item, callback) {
this.connect((e) => {
if (e) return callback(e);
this.__redisClient
.set(key, JSON.stringify(item))
.then(() => {
return this.__redisClient.expire(key, this.__redisExpire);
})
.then(() => {
callback();
})
.catch(callback);
});
};
InternalCache.prototype.__getFromRedisCache = function (key, callback) {
this.connect((e) => {
if (e) return callback(e);
this.__redisClient
.get(key)
.then((found) => {
if (found) return callback(null, JSON.parse(found));
callback(null, null);
})
.catch(callback);
});
};
InternalCache.prototype.__redisDel = function (key, callback) {
this.connect((e) => {
if (e) return callback(e);
this.__redisClient
.del(key)
.then(() => {
callback();
})
.catch(callback);
});
};
InternalCache.prototype.__publishChange = function (key, val, callback) {
try {
this.__redisPubsub.emit(key, { data: val, origin: this.__cacheNodeId });
callback();
} catch (e) {
callback(e);
}
};
InternalCache.prototype.get = function (key, callback) {
try {
var returnValue = this.__cache.get(key);
//found something in memory
if (returnValue) return callback(null, returnValue);
//maybe in redis, but no longer in LRU
this.__getFromRedisCache(key, (e, found) => {
if (e) return callback(e);
//exists in redis, so we update LRU
if (found) {
return this.__updateLRUCache(key, found, (e, updated) => {
if (e) return callback(e);
callback(null, updated);
});
}
return callback(null, null);
});
} catch (e) {
callback(e);
}
};
InternalCache.prototype.set = function (key, val, callback) {
this.__updateRedisCache(key, val, (e) => {
if (e) return callback(e);
this.__updateLRUCache(key, val, (e) => {
if (e) return callback(e);
this.__publishChange(key, val, callback);
});
});
};
InternalCache.prototype.values = function () {
return this.__cache.values();
};
InternalCache.prototype.del = function (key, callback, noRedis) {
if (!this.__cache.has(key)) {
return this.__redisDel(key, (e) => {
if (e) return callback(e);
this.__publishChange(key, null, callback);
});
}
var disposedTimeout;
var disposedHandler = function (disposedKey) {
if (disposedKey === key) {
clearTimeout(disposedTimeout);
this.off('item-disposed', disposedHandler);
if (noRedis) return callback();
this.__redisDel(key, callback);
}
}.bind(this);
//wait 5 seconds, then call back with a failure
disposedTimeout = setTimeout(() => {
clearTimeout(disposedTimeout);
this.off('item-disposed', disposedHandler);
callback(new CacheError('failed to remove item from the LRU cache'));
}, 5000);
this.on('item-disposed', disposedHandler);
this.__cache.del(key);
};
InternalCache.prototype.__emit = function (key, data) {
return this.__eventEmitter.emit(key, data);
};
InternalCache.prototype.on = function (key, handler) {
return this.__eventEmitter.on(key, handler);
};
InternalCache.prototype.off = InternalCache.prototype.removeListener = function (key, handler) {
return this.__eventEmitter.removeListener(key, handler);
};
InternalCache.prototype.size = function () {
return this.__cache.length;
};
InternalCache.prototype.disconnect = util.promisify(function (callback) {
this.__redisClient.quit();
this.__redisPubsub.quit();
this.__connected = false;
callback();
});
InternalCache.prototype.connect = function (callback) {
this.__connectionQueue.push(null, callback);
};
InternalCache.prototype.reset = function (callback) {
this.connect((e) => {
if (e) return callback(e);
this.__redisClient
.keys('*')
.then((keys) => {
return Promise.all(
keys.map((key) => {
return this.__redisClient.del(key);
})
);
})
.then(() => {
this.__redisPubsub.emit('system/cache-reset', { origin: this.__cacheNodeId });
this.__cache.reset();
callback();
})
.catch(callback);
});
};
function RedisLRUCache(opts) {
this.__cache = new InternalCache(opts);
this.__eventEmitter = new EventEmitter();
if (opts.clear) {
this.clear((e) => {
if (e) return this.__emit('error', new CacheError('failed clearing cache on startup', e));
this.__emit('cleared-on-startup', opts);
});
}
}
RedisLRUCache.prototype.__emit = function (key, data) {
return this.__eventEmitter.emit(key, data);
};
RedisLRUCache.prototype.on = function (key, handler) {
return this.__eventEmitter.on(key, handler);
};
RedisLRUCache.prototype.off = RedisLRUCache.prototype.removeListener = function (key, handler) {
return this.__eventEmitter.removeListener(key, handler);
};
RedisLRUCache.prototype.__tryCallback = function (callback, data, e, doClone) {
var callbackData = data;
if (data && doClone) callbackData = clone(data);
if (e) {
if (callback) return callback(e);
else throw e;
}
if (callback) return callback(null, callbackData);
return callbackData;
};
RedisLRUCache.prototype.update = util.promisify(function (key, data, callback) {
try {
var result = this.__cache.get(key);
if (result != null) {
result.data = data;
this.__cache.set(key, result, result.ttl);
return this.__tryCallback(callback, this.__cache.get(key), null);
}
this.__tryCallback(callback, null, null);
} catch (e) {
return this.__tryCallback(callback, null, e);
}
});
RedisLRUCache.prototype.increment = util.promisify(function (key, by, callback) {
try {
var result = this.__cache.get(key);
if (typeof result.data === 'number') {
result.data += by;
this.__cache.set(key, result);
return this.__tryCallback(callback, result.data, null);
}
return this.__tryCallback(callback, null, null);
} catch (e) {
return this.__tryCallback(callback, null, e);
}
});
RedisLRUCache.prototype.get = util.promisify(function (key, opts, callback) {
try {
if (key == null) return callback(new CacheError('invalid key'));
if (typeof opts === 'function') {
callback = opts;
opts = null;
}
if (!opts) opts = {};
this.__cache.get(key, (e, cached) => {
if (e) return callback(e);
if (cached) return this.__tryCallback(callback, cached.data, null, true);
else {
if (opts.retrieveMethod) {
opts.retrieveMethod.call(opts.retrieveMethod, (e, result) => {
if (e) return callback(e);
// -1 and 0 are perfectly viable things to cache
if (result == null) return this.__tryCallback(callback, null, null);
this.set(key, result, function (e, value) {
return this.__tryCallback(callback, value, e, true);
});
});
} else if (opts.default) {
this.set(key, opts.default, function (e, value) {
return this.__tryCallback(callback, value, e, true);
});
} else return this.__tryCallback(callback, null, null);
}
});
} catch (e) {
this.__tryCallback(callback, null, e);
}
});
RedisLRUCache.prototype.clear = util.promisify(function (callback) {
if (this.__cache) this.__cache.reset(callback);
else callback(new Error('no cache available for reset'));
});
RedisLRUCache.prototype.set = util.promisify(function (key, val, callback) {
try {
if (key == null) return callback(new CacheError('invalid key'));
var cacheItem = { data: clone(val), key: key };
this.__cache.set(key, cacheItem, (e) => {
if (e) return callback(e);
callback(null, cacheItem);
});
} catch (e) {
callback(e);
}
});
RedisLRUCache.prototype.remove = util.promisify(function (key, callback) {
try {
if (key == null || key === undefined) return callback(new CacheError('invalid key'));
this.__cache.del(key, callback);
} catch (e) {
callback(e);
}
});
RedisLRUCache.prototype.__all = function () {
var returnItems = [];
var values = this.__cache.values();
values.forEach(function (value) {
returnItems.push(value.data);
});
return returnItems;
};
RedisLRUCache.prototype.all = util.promisify(function (filter, callback) {
try {
if (typeof filter === 'function') {
callback = filter;
filter = null;
}
try {
if (filter) return callback(null, mongoFilter({ $and: [filter] }, this.__all()));
else return callback(null, this.__all());
} catch (e) {
return callback(e);
}
} catch (e) {
callback(e);
}
});
RedisLRUCache.prototype.disconnect = util.promisify(function (callback) {
return this.__cache.disconnect(callback);
});
module.exports = RedisLRUCache;