@chriscdn/memoize-redis
v1.0.0
Published
Memoize asynchronous functions in Redis.
Readme
@chriscdn/memoize-redis
A utility for memoizing asynchronous functions using Redis hashes.
It provides:
- Redis backed caching
- Per entry TTLs
- Custom cache key resolution
- Conditional caching via
ttl - In-flight request deduplication
- Cache inspection and deletion
- Explicit cache refresh, including background refresh
- Graceful operation when Redis is unavailable
Installation
npm install @chriscdn/memoize-redisThe package has a peer dependency on redis package v6 or greater, so be sure to install that as well.
Basic usage
Create a memoizer from an existing Redis client:
import { createClient } from "redis";
import {
createRedisMemoizer,
createRedisMemoizerNoHash,
} from "@chriscdn/memoize-redis";
const redisClient = createClient({
url: process.env.REDIS_URL,
});
// Attach an error listener before connecting. The redis client emits
// an "error" event on connection issues, and Node will crash on an
// unhandled "error" event if no listener is registered.
redisClient.on("error", (error) => {
console.error("Redis error", error);
});
await redisClient.connect();
const { MemoizeRedis } = createRedisMemoizer(redisClient);
const getUser = MemoizeRedis(
async (id: string) => {
return fetchUserFromDatabase(id);
},
{
redisKey: "users",
ttl: ({ key, value, args }) => 60_000,
},
);
const user = await getUser("123");Use createRedisMemoizerNoHash if using a version of Redis prior to 7.4, which does not support the required hash commands.
Options
redisKey (required)
The Redis hash used to store the cached values.
{
redisKey: "users";
}ttl (required)
A function that determines how long the returned value should remain cached.
The value is specified in milliseconds.
{
ttl: ({ key, value, args }) => 60_000;
}The callback receives the resolved value and cache key. Returning 0 or a negative value prevents the value from being cached.
This also makes it possible to calculate the TTL from the returned value:
{
ttl: ({ key, value, args }) => value.expiresAt - Date.now();
}resolver (optional)
By default, the cache key is generated by converting the function arguments to JSON using canonicalize and hashing the result with SHA 256.
A custom resolver can be useful when some arguments are irrelevant to the cache key, or when an argument contains a value that cannot be safely serialized.
This behaviour can be customized by providing a resolver callback:
resolver: (...args) => `MyCacheKey::${args[0]}`,The resolver must return a string, which is used as the cache key.
refreshWhen (optional)
A function that determines whether a cached value should be refreshed in the background.
The callback receives the remaining TTL in milliseconds and the cached value.
{
refreshWhen: ({ttl, value, args}) => ttl < 10_000,
}If the callback returns true, the underlying function is executed in the background.
The existing cached value continues to be returned until the refresh completes, so callers do not wait for the underlying function.
Only one refresh is executed at a time for each cache key. A refresh already in progress is reused by subsequent refresh attempts.
The refresh is best effort. Errors from the background refresh are ignored.
Cache behavior
- When the memoized function is called, it first checks Redis.
- If a cached value exists, it is deserialized and returned without calling the original function.
- If there is no cached value, the original function is called and its result may be cached according to
ttl. Return0(or a negative number) fromttlto skip caching for that value. - If Redis is unavailable, the original function is called normally and the result is returned without caching.
- If the resolved value is
undefined, it is not cached. A value ofnullis cached normally.
In-flight request deduplication
Concurrent requests for the same cache key are deduplicated within the current process.
For example:
const [a, b, c] = await Promise.all([
getUser("123"),
getUser("123"),
getUser("123"),
]);If "123" is not already cached, the underlying function is only executed once.
All three callers receive the same resulting promise.
This deduplication is local to the current process. It does not provide distributed request locking between multiple application instances.
Methods
The memoized function provides several additional methods.
clear()
Delete the Redis hash.
await getUser.clear();has(...args)
Checks whether a cache entry exists.
const exists = await getUser.has("123");Returns true or false accordingly.
Returns null if Redis is unavailable or a Redis error occurs.
delete(...args)
Deletes a single cache entry.
await getUser.delete("123");The operation can be queued by the Redis client if Redis is unavailable. The returned promise can be awaited to wait for completion, or the method can be called without await for a background operation.
ttl(...args)
Returns the remaining TTL for a cache entry in milliseconds.
const remaining = await getUser.ttl("123");The return values are:
| Value | Meaning | | ----- | -------------------------------------------------------------------------- | | >=0 | entry TTL in milliseconds | | -1 | entry exists without an expiration (should never happen with this package) | | -2 | entry does not exist | | -3 | Redis is unavailable or a Redis error occurred |
refresh(...args)
Forces the underlying function to execute instead of using the existing cached value.
await getUser.refresh("123");The resulting value is processed using the normal caching rules, including ttl.
If another refresh or cache miss for the same key is already in progress, the existing in-flight request is reused.
This can also be called without await to do a background refresh.
Detecting memoized functions
The package exports isMemoizedAsyncRedis:
import { isMemoizedAsyncRedis } from "@chriscdn/memoize-redis";
if (isMemoizedAsyncRedis(value)) {
await value.has(key, field);
}This can be useful when working with dynamically configured functions.
Redis availability
The memoized function is best effort when Redis is unavailable.
When Redis is unavailable:
- Cache reads are treated as cache misses
- Cache writes are skipped
- The underlying function still executes
- The underlying function's result is returned normally
The methods clear and delete are sent to Redis even when the client is offline and can be queued by the Redis client until the connection is restored.
The methods has and ttl return null or -3 respectively when Redis is unavailable or a Redis error occurs.
The Redis client itself is responsible for establishing and maintaining its connection, including registering an "error" listener as shown in Basic usage.
Serialization
Cached values are serialized using JSON.stringify and restored using JSON.parse.
Consequently, values should be JSON serializable.
Types such as Date, Map, Set, BigInt, class instances, and undefined do not preserve their original JavaScript representation through this serialization process.
If a cached value contains invalid JSON, the entry is removed from Redis and the underlying function is executed again.
