UNPKG

redis-expiry

Version:

Use redis to expire your keys and handling the value

495 lines (458 loc) 16.3 kB
const Promise = require("bluebird"); const shortid = require("shortid"); const cronParser = require("cron-parser"); const RegexParser = require("regex-parser"); const Queue = require("promise-queue"); Queue.configure(Promise); const jsonParse = str => { try { return str && JSON.parse(str); } catch (e) { // eslint-disable-next-line no-console console.error(`Redis-expiry: ${e.stack} during JSON.parse(${str})`); } return undefined; }; const getRegex = index => typeof index === "string" ? RegexParser(index) : index; module.exports = (redisSetter, redisGetter) => { this.redisSetter = Promise.promisifyAll(redisSetter); this.redisGetter = Promise.promisifyAll(redisGetter); const scheduleEvent = ( expirationType, expirationExpression, expirationExtra ) => async (key, value, timeout) => { if (expirationType === "CRON") { const item = await this.get(key, value); if (item) { return item; } } const currentDate = new Date().getTime(); const guuid = shortid.generate().replace(/_/g, "-"); await Promise.all([ timeout ? this.redisSetter.setAsync(`${key}_${guuid}`, "", "PX", timeout) : this.redisSetter.setAsync(`${key}_${guuid}`, ""), this.redisSetter.setAsync(`${key}_${guuid}_value`, value || ""), this.redisSetter.setAsync(`${key}_${guuid}_guuid`, guuid), this.redisSetter.setAsync(`${key}_${guuid}_key`, key), this.redisSetter.setAsync( `${key}_${guuid}_expiration_type`, expirationType ), this.redisSetter.setAsync( `${key}_${guuid}_expiration_value`, timeout || "" ), this.redisSetter.setAsync( `${key}_${guuid}_expiration_extra`, JSON.stringify(expirationExtra) || "" ), this.redisSetter.setAsync( `${key}_${guuid}_expiration_expression`, expirationExpression || "" ), this.redisSetter.setAsync(`${key}_${guuid}_created_at`, currentDate), this.redisSetter.setAsync( `${key}_${guuid}_expiration_at`, timeout ? currentDate + timeout : "" ) ]); return { guuid, value, key, expiration_type: expirationType, expiration_value: timeout, expiration_extra: expirationExtra, expiration_expression: expirationExpression, created_at: currentDate, expiration_at: timeout && currentDate + timeout }; }; this.set = (key, value) => ({ infinit: () => scheduleEvent("INFINIT")(key, value), timeout: time => scheduleEvent("TIMEOUT", time)(key, value, time || 1), now: () => scheduleEvent("NOW", 1)(key, value, 1), at: date => { const currentDate = new Date().getTime(); const ndate = date ? new Date(date).getTime() : currentDate; const calculTimeout = ndate - currentDate <= 0 ? 1 : ndate - currentDate; return scheduleEvent("AT", date)(key, value, calculTimeout); }, cron: (expression, options) => { const date = cronParser .parseExpression(expression, options) .next() .toString(); const currentDate = new Date().getTime(); const ndate = date ? new Date(date).getTime() : currentDate; const calculTimeout = ndate - currentDate <= 0 ? 1 : ndate - currentDate; return scheduleEvent("CRON", expression, options)( key, value, calculTimeout ); } }); const getAllByRegexp = async (regexp, value) => Promise.reduce( (await new Promise((resolve, reject) => this.redisSetter .multi() .keys(`*_guuid`) .exec((err, result) => (err ? reject(err) : resolve(result))) )).shift(), async (accumulator, currentValue) => { const cObject = accumulator; const guuid = currentValue .substring(0, currentValue.length - "_guuid".length) .split("_") .pop(); const key = currentValue.substring( 0, currentValue.length - "_guuid".length - guuid.length - 1 ); if (!guuid || cObject[guuid]) return accumulator; const redisValue = await this.redisSetter.getAsync( `${key}_${guuid}_value` ); if ( (typeof regexp === "string" && !RegExp(getRegex(regexp)).test(key)) || (typeof regexp !== "string" && !regexp.test(key)) || (value && value !== redisValue) ) { return accumulator; } cObject[guuid] = key; return cObject; }, {} ); const getAllGuuidByKey = async (key, value) => Object.keys( await Promise.reduce( (await new Promise((resolve, reject) => this.redisSetter .multi() .keys(`${key}_*_guuid`) .exec((err, result) => (err ? reject(err) : resolve(result))) )).shift(), async (accumulator, currentValue) => { const cObject = accumulator; const guuid = currentValue .substring(key.length + 1) .split("_") .shift(); if (!guuid || cObject[guuid]) return accumulator; const redisValue = await this.redisSetter.getAsync( `${key}_${guuid}_value` ); if (value && value !== redisValue) { return accumulator; } cObject[guuid] = key; return cObject; }, {} ) ); const getAllKeysByGuuid = async guuid => Object.values( await Promise.reduce( (await new Promise((resolve, reject) => this.redisSetter .multi() .keys(`*_${guuid}_guuid`) .exec((err, result) => (err ? reject(err) : resolve(result))) )).shift(), async (accumulator, currentValue) => { const cObject = accumulator; const key = currentValue.substring( 0, currentValue.indexOf(guuid) - 1 ); if (!key || cObject[key]) return accumulator; cObject[guuid] = key; return cObject; }, {} ) ); this.getByKeyGuuid = async (key, guuid) => { const redisValue = await this.redisSetter.getAsync(`${key}_${guuid}_value`); const redisGuuid = await this.redisSetter.getAsync(`${key}_${guuid}_guuid`); const redisKey = await this.redisSetter.getAsync(`${key}_${guuid}_key`); const redisExpirationType = await this.redisSetter.getAsync( `${key}_${guuid}_expiration_type` ); const redisExpirationValue = await this.redisSetter.getAsync( `${key}_${guuid}_expiration_value` ); const redisExpirationExtra = await this.redisSetter.getAsync( `${key}_${guuid}_expiration_extra` ); const redisExpirationExpression = await this.redisSetter.getAsync( `${key}_${guuid}_expiration_expression` ); const redisCreatedAt = await this.redisSetter.getAsync( `${key}_${guuid}_created_at` ); const redisExpirationAt = await this.redisSetter.getAsync( `${key}_${guuid}_expiration_at` ); return ( redisGuuid && { guuid: redisGuuid, value: redisValue, key: redisKey, expiration_type: redisExpirationType, expiration_value: redisExpirationValue && parseInt(redisExpirationValue, 10), expiration_extra: ["CRON"].includes(redisExpirationType) ? jsonParse(redisExpirationExtra) : redisExpirationExtra, expiration_expression: ["TIMEOUT", "NOW"].includes(redisExpirationType) ? parseInt(redisExpirationExpression, 10) : redisExpirationExpression, created_at: parseInt(redisCreatedAt, 10), expiration_at: redisExpirationAt && parseInt(redisExpirationAt, 10) } ); }; this.getByGuuid = async guuid => (await Promise.map(await getAllKeysByGuuid(guuid), async key => this.getByKeyGuuid(key, guuid) )).shift(); const getByRegexp = async (regexp, value) => { const result = await getAllByRegexp(regexp, value); return (await Promise.map(Object.keys(result), async guuid => this.getByKeyGuuid(result[guuid], guuid) )) .filter(elem => elem) .shift(); }; this.get = async (key, value) => typeof key !== "string" ? getByRegexp(key, value) : (await Promise.map(await getAllGuuidByKey(key, value), async guuid => this.getByKeyGuuid(key, guuid) )) .filter(elem => elem) .shift(); this.delByKeyGuuid = (key, guuid) => Promise.all([ this.redisSetter.del(`${key}_${guuid}`), this.redisSetter.del(`${key}_${guuid}_value`), this.redisSetter.del(`${key}_${guuid}_guuid`), this.redisSetter.del(`${key}_${guuid}_key`), this.redisSetter.del(`${key}_${guuid}_expiration_type`), this.redisSetter.del(`${key}_${guuid}_expiration_value`), this.redisSetter.del(`${key}_${guuid}_expiration_extra`), this.redisSetter.del(`${key}_${guuid}_expiration_expression`), this.redisSetter.del(`${key}_${guuid}_created_at`), this.redisSetter.del(`${key}_${guuid}_expiration_at`) ]); this.delByGuuid = async guuid => Promise.map(await getAllKeysByGuuid(guuid), async key => this.delByKeyGuuid(key, guuid) ); const delByRegexp = async (regexp, value) => { const result = await getAllByRegexp(regexp, value); return Promise.map(Object.keys(result), async guuid => this.delByKeyGuuid(result[guuid], guuid) ); }; this.del = async (key, value) => typeof key !== "string" ? delByRegexp(key, value) : Promise.map(await getAllGuuidByKey(key, value), async guuid => this.delByKeyGuuid(key, guuid) ); this.updateByKeyGuuid = (key, guuid) => toUpdate => this.redisSetter.set(`${key}_${guuid}_value`, toUpdate || ""); this.updateByGuuid = guuid => async toUpdate => Promise.map(await getAllKeysByGuuid(guuid), async key => this.updateByKeyGuuid(key, guuid)(toUpdate) ); const updateByRegexp = (regexp, value) => async toUpdate => { const result = await getAllByRegexp(regexp, value); return Promise.map(Object.keys(result), async guuid => this.updateByKeyGuuid(result[guuid], guuid)(toUpdate) ); }; this.update = (key, value) => async toUpdate => typeof key !== "string" ? updateByRegexp(key, value)(toUpdate) : Promise.map(await getAllGuuidByKey(key, value), async guuid => this.updateByKeyGuuid(key, guuid)(toUpdate) ); const getAvailableScheduler = (callback, moreFunctions) => Object.keys(Object.assign(moreFunctions || {}, this.set())).reduce( (accumulator, currentValue) => Object.assign(accumulator, { [currentValue]: (expression, options) => callback(currentValue, expression, options) }), {} ); const andUpdateValue = (asyncKeys, asyncGuuids, asyncRegexps) => toUpdate => getAvailableScheduler(async (type, expression, options) => { const regexps = await asyncRegexps; const keys = regexps ? Object.keys(regexps) : await asyncKeys; const guuids = regexps ? Object.values(regexps) : await asyncGuuids; return (elements => (Array.isArray(keys) ? elements : elements.shift()))( await Promise.map(Array.isArray(keys) ? keys : [keys], async key => (elements => (Array.isArray(guuids) ? elements : elements.shift()))( await Promise.map( Array.isArray(guuids) ? guuids : [guuids], async guuid => { await this.delByKeyGuuid(key, guuid); return this.set(key, toUpdate)[type](expression, options); } ) ) ) ); }); this.rescheduleByKeyGuuid = (key, guuid) => getAvailableScheduler( (type, expression, options) => type === "andUpdateValue" ? andUpdateValue(key, guuid)(expression) : (async () => { const value = await this.redisSetter.getAsync( `${key}_${guuid}_value` ); await this.delByKeyGuuid(key, guuid); return this.set(key, value)[type](expression, options); })(), { andUpdateValue } ); this.rescheduleByGuuid = guuid => getAvailableScheduler( (type, expression, options) => type === "andUpdateValue" ? andUpdateValue(getAllKeysByGuuid(guuid), guuid)(expression) : (async () => Promise.map(await getAllKeysByGuuid(guuid), async key => this.rescheduleByKeyGuuid(key, guuid)[type](expression, options) ))(), { andUpdateValue } ); const rescheduleByRegexp = async (regexp, value) => getAvailableScheduler( (type, expression, options) => type === "andUpdateValue" ? andUpdateValue(null, null, getAllByRegexp(regexp, value))( expression ) : (async () => { const result = await getAllByRegexp(regexp, value); return Promise.map(Object.keys(result), async guuid => this.rescheduleByKeyGuuid(result[guuid], guuid)[type]( expression, options ) ); })(), { andUpdateValue } ); this.reschedule = (key, value) => typeof key !== "string" ? rescheduleByRegexp(key, value) : getAvailableScheduler( (type, expression, options) => type === "andUpdateValue" ? andUpdateValue(key, getAllGuuidByKey(key, value))(expression) : (async () => Promise.map(await getAllGuuidByKey(key, value), async guuid => this.rescheduleByKeyGuuid(key, guuid)[type]( expression, options ) ))(), { andUpdateValue } ); const listQueues = []; const executeEvents = (key, value, guuid) => async ( expirationType, expirationExpression, expirationExtra ) => { let isInterval = expirationType === "CRON"; let queueFound = false; listQueues.map(element => { if ( typeof element.key === "string" ? element.key === key : RegExp(getRegex(element.key)).test(key) ) { queueFound = true; element.queue.add(() => element.cb(value, key, () => { isInterval = false; }) ); } return null; }); if (queueFound) { await this.delByKeyGuuid(key, guuid); } if (isInterval) { const extra = jsonParse(expirationExtra); await this.set(key, value).cron(expirationExpression, extra); } }; const verifyKeyExpired = async key => { const element = await this.get(key); const currentDate = new Date(); if ( element.expiration_at && parseInt(element.expiration_at, 10) <= currentDate.getTime() ) { await executeEvents(element.key, element.value, element.guuid)( element.expiration_type, element.expiration_expression, element.expiration_extra ); } }; this.on = async (key, cb, options) => { listQueues.push({ key, cb, queue: new Queue((options && options.maxConcurrent) || Infinity, Infinity) }); await verifyKeyExpired(key); // TODO: move it somewhere else ? }; // --- this.redisGetter.config("set", "notify-keyspace-events", "Ex"); this.redisGetter.psubscribe("__keyevent@0__:expired"); this.redisGetter.on("pmessage", async (_, __, expiredKey) => { const keys = expiredKey.split("_"); const guuid = keys.pop(); const key = keys.join("_"); // --- const value = await this.redisSetter.getAsync(`${key}_${guuid}_value`); const expirationType = await this.redisSetter.getAsync( `${key}_${guuid}_expiration_type` ); const expirationExpression = await this.redisSetter.getAsync( `${key}_${guuid}_expiration_expression` ); const expirationExtra = await this.redisSetter.getAsync( `${key}_${guuid}_expiration_extra` ); // --- await executeEvents(key, value, guuid)( expirationType, expirationExpression, expirationExtra ); }); return this; };