UNPKG

@soundxyz/response-cache

Version:

Heavily inspired by @envelop/response-cache

353 lines (348 loc) 12.7 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); const promises = require('timers/promises'); const utils = require('./utils.js'); const Events = { REDIS_GET: "REDIS_GET", REDIS_GET_TIMED_OUT: "REDIS_GET_TIMED_OUT", REDIS_SET: "REDIS_SET", INVALIDATE_KEY_SCAN: "INVALIDATE_KEY_SCAN", INVALIDATED_KEYS: "INVALIDATED_KEYS", CONCURRENT_CACHED_CALL_HIT: "CONCURRENT_CACHED_CALL_HIT", REDLOCK_ACQUIRED: "REDLOCK_ACQUIRED", REDLOCK_RELEASED: "REDLOCK_RELEASED", REDLOCK_GET_AFTER_ACQUIRE: "REDLOCK_GET_AFTER_ACQUIRE" }; function defaultLog({ message }) { console.log(message); } const createRedisCache = ({ redis: store, redlock, debugTtl = false, logEvents, buildRedisEntityId = defaultBuildRedisEntityId, buildRedisOperationResultCacheKey = defaultBuildRedisOperationResultCacheKey, GETRedisTimeout, onError = console.error, concurrencyLimit = 20 }) => { const redLock = redlock?.client; const lockSettings = redlock?.settings; const lockDuration = redlock?.duration ?? 5e3; const lockRetryDelay = redlock?.settings?.retryDelay ?? 250; const lockRetryCount = lockSettings?.retryCount ?? lockDuration / lockRetryDelay * 2; function gracefullyFail(err) { onError(err); return null; } function getTracing() { const start = performance.now(); return () => `${(performance.now() - start).toFixed()}ms`; } const enabledLogEvents = logEvents?.events; const logMessage = logEvents ? function logMessage2(code, params) { const eventValue = logEvents.events[code]; if (!eventValue) return; const log = typeof eventValue === "function" ? eventValue : logEvents.log || defaultLog; const codeMessageValue = typeof eventValue === "string" ? eventValue : code; let paramsString = ""; for (const key in params) { let value = params[key]; if (value === void 0) continue; if (value === "") value = "null"; paramsString += " " + key + "=" + value; } log({ code, message: `[${codeMessageValue}]${paramsString}`, params }); } : () => void 0; async function buildEntityInvalidationsKeys(entity) { const keysToInvalidate = [entity]; const responseIds = await store.smembers(entity).catch(gracefullyFail); for (const responseId of responseIds || []) { keysToInvalidate.push(responseId); keysToInvalidate.push(buildRedisOperationResultCacheKey(responseId)); } if (!entity.includes(":")) { const tracing = enabledLogEvents?.INVALIDATE_KEY_SCAN ? getTracing() : null; const key = `${entity}:*`; const entityKeys = await store.keys(key).catch(gracefullyFail); if (tracing && entityKeys) { logMessage("INVALIDATE_KEY_SCAN", { key, entityKeys: entityKeys.join(",") || "null", time: tracing() }); } await Promise.all( (entityKeys || []).map(async (entityKey) => { const entityResponseIds = await store.smembers(entityKey).catch(gracefullyFail); for (const responseId of entityResponseIds || []) { keysToInvalidate.push(responseId); keysToInvalidate.push(buildRedisOperationResultCacheKey(responseId)); } keysToInvalidate.push(entityKey); }) ); } return keysToInvalidate; } const responseIdLocks = {}; const ConcurrentLoadingCache = {}; function ConcurrentCachedCall(key, cb) { const concurrentLoadingValueCache = ConcurrentLoadingCache[key]; if (concurrentLoadingValueCache) { if (enabledLogEvents?.CONCURRENT_CACHED_CALL_HIT) { logMessage("CONCURRENT_CACHED_CALL_HIT", { key }); } return concurrentLoadingValueCache; } return (ConcurrentLoadingCache[key] = cb()).finally(() => { ConcurrentLoadingCache[key] = null; }); } function getFromRedis(responseId) { return ConcurrentCachedCall(responseId, async () => { let ok = true; let timedOut; const tracing = enabledLogEvents?.REDIS_GET || (enabledLogEvents?.REDIS_GET_TIMED_OUT ?? true) ? getTracing() : null; if (debugTtl) { const redisGetPromise2 = store.pipeline().get(responseId).ttl(responseId).exec().then((value) => { if (enabledLogEvents?.REDIS_GET) { const ttl2 = value?.[1]?.[1]; logMessage("REDIS_GET", { key: responseId, cache: value?.[0]?.[1] == null ? "MISS" : "HIT", timedOut, remainingTtl: typeof ttl2 === "number" ? ttl2 : "null", time: tracing?.() }); } return value; }).catch(gracefullyFail); const resultWithTtl = await (GETRedisTimeout != null ? Promise.race([redisGetPromise2, promises.setTimeout(GETRedisTimeout, void 0)]) : redisGetPromise2); if (resultWithTtl === void 0) { timedOut = true; if (enabledLogEvents?.REDIS_GET_TIMED_OUT ?? true) { logMessage("REDIS_GET_TIMED_OUT", { key: responseId, timeout: GETRedisTimeout, time: tracing?.() }); } return [null, { ok: false, ttl: void 0 }]; } if (!resultWithTtl || !resultWithTtl[0] || !resultWithTtl[1]) { return [null, { ok: false, ttl: void 0 }]; } const [[resultError, result2], [ttlError, ttlRedis]] = resultWithTtl; const ttl = typeof ttlRedis === "number" ? ttlRedis : void 0; if (resultError) { gracefullyFail(resultError); ok = false; } if (ttlError) { gracefullyFail(ttlError); ok = false; } if (result2 != null && typeof result2 === "string") { return [JSON.parse(result2), { ok, ttl }]; } return [null, { ok, ttl }]; } const redisGetPromise = store.get(responseId).then((value) => { if (enabledLogEvents?.REDIS_GET) { logMessage("REDIS_GET", { key: responseId, cache: value == null ? "MISS" : "HIT", timedOut, time: tracing?.() }); } return value; }).catch((err) => { ok = false; return gracefullyFail(err); }); const result = await (GETRedisTimeout != null ? Promise.race([redisGetPromise, promises.setTimeout(GETRedisTimeout, void 0)]) : redisGetPromise); if (result === void 0) { timedOut = true; if (enabledLogEvents?.REDIS_GET_TIMED_OUT) { logMessage("REDIS_GET_TIMED_OUT", { key: responseId, timeout: GETRedisTimeout, time: tracing?.() }); } return [null, { ok: false }]; } if (result != null) return [JSON.parse(result), { ok }]; return [null, { ok }]; }); } return { onSkipCache(responseId) { const lock = responseIdLocks[responseId]; if (lock) { const tracing = enabledLogEvents?.REDLOCK_RELEASED ? getTracing() : null; lock.release().then(({ attempts }) => { if (tracing) { logMessage("REDLOCK_RELEASED", { key: responseId, attempts: attempts.length, time: tracing() }); } }).catch(() => null); responseIdLocks[responseId] = null; } }, async set(responseId, result, collectedEntities, ttl) { try { const tracing2 = enabledLogEvents?.REDIS_SET ? getTracing() : null; const pipeline = store.pipeline(); if (ttl === Infinity) { pipeline.set(responseId, JSON.stringify(result)); } else { pipeline.set(responseId, JSON.stringify(result), "PX", ttl); } const responseKey = buildRedisOperationResultCacheKey(responseId); for (const { typename, id } of collectedEntities) { pipeline.sadd(typename, responseId); pipeline.sadd(responseKey, typename); if (id) { const entityId = buildRedisEntityId(typename, id); pipeline.sadd(entityId, responseId); pipeline.sadd(responseKey, entityId); } } await pipeline.exec().catch(gracefullyFail); if (tracing2) { logMessage("REDIS_SET", { responseKey, collectedEntities: Array.from(collectedEntities).map(({ typename, id }) => id != null ? buildRedisEntityId(typename, id) : typename).join(","), ttl }); } } catch (err) { onError(err); } const lock = responseIdLocks[responseId]; if (!lock) return; const tracing = enabledLogEvents?.REDLOCK_RELEASED ? getTracing() : null; lock.release().then(({ attempts }) => { if (tracing) { logMessage("REDLOCK_RELEASED", { key: responseId, attempts: attempts.length, time: tracing() }); } }).catch(() => null); responseIdLocks[responseId] = null; }, async get(responseId) { const [firstTry, { ok, ttl }] = await getFromRedis(responseId); if (!ok) return [null]; if (firstTry) return [firstTry, { ttl }]; if (!redLock) return [null]; const tracing = enabledLogEvents?.REDLOCK_ACQUIRED ? getTracing() : null; const lock = await redLock.acquire(["lock:" + responseId], lockDuration, { ...lockSettings, retryCount: lockRetryCount, retryDelay: lockRetryDelay }).then( (lock2) => { if (tracing) { logMessage("REDLOCK_ACQUIRED", { key: responseId, attempts: lock2.attempts.length, time: tracing() }); } if (lock2.attempts.length === 1) { return responseIdLocks[responseId] = lock2; } return lock2; }, () => null ); if (lock && lock.attempts.length > 1) { const tracing2 = enabledLogEvents?.REDLOCK_RELEASED ? getTracing() : null; lock.release().then(({ attempts }) => { if (tracing2) { logMessage("REDLOCK_RELEASED", { key: responseId, attempts: attempts.length, time: tracing2() }); } }).catch(() => null); } else if (lock?.attempts.length === 1) return [null]; { const tracing2 = enabledLogEvents?.REDLOCK_GET_AFTER_ACQUIRE ? getTracing() : null; const getAfterLock = await getFromRedis(responseId).then( (v) => [v[0], { ttl: v[1].ttl }] ); if (tracing2) { logMessage("REDLOCK_GET_AFTER_ACQUIRE", { key: responseId, cache: getAfterLock[0] != null ? "HIT" : "MISS", time: tracing2() }); } return getAfterLock; } }, async invalidate(entitiesToRemove) { const entitiesToRemoveList = Array.from(entitiesToRemove); if (!entitiesToRemoveList.length) return; const tracing = enabledLogEvents?.INVALIDATED_KEYS ? getTracing() : null; const invalidationEntitiesKey = entitiesToRemoveList.map( ({ id, typename }) => id != null ? buildRedisEntityId(typename, id) : typename ); const chunksInvalidationEntities = utils.chunk(invalidationEntitiesKey, concurrencyLimit); let invalidationKeysSize = 0; for (const invalidationEntities of chunksInvalidationEntities) { await Promise.all( invalidationEntities.map(async (key) => { const keys = await buildEntityInvalidationsKeys(key); const chunkedKeys = utils.chunk(keys, concurrencyLimit); for (const invalidationChunk of chunkedKeys) { invalidationKeysSize += invalidationChunk.length; await store.del(invalidationChunk).catch(gracefullyFail); } }) ); } if (tracing) { logMessage("INVALIDATED_KEYS", { invalidatedEntitiesKeys: entitiesToRemoveList.map((v) => v.id != null ? v.typename = ":" + v.id : v.typename).join(","), invalidationKeysSize, time: tracing() }); } } }; }; const defaultBuildRedisEntityId = (typename, id) => `${typename}:${id}`; const defaultBuildRedisOperationResultCacheKey = (responseId) => `operations:${responseId}`; exports.Events = Events; exports.createRedisCache = createRedisCache; exports.defaultBuildRedisEntityId = defaultBuildRedisEntityId; exports.defaultBuildRedisOperationResultCacheKey = defaultBuildRedisOperationResultCacheKey;