@soundxyz/response-cache
Version:
Heavily inspired by @envelop/response-cache
346 lines (343 loc) • 12.5 kB
JavaScript
import { setTimeout } from 'timers/promises';
import { chunk } from './utils.mjs';
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, 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, 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 = 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 = 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}`;
export { Events, createRedisCache, defaultBuildRedisEntityId, defaultBuildRedisOperationResultCacheKey };