src/cache provides a two-tier cache: L1 is a bounded in-process LRU map,
L2 is a Redis cluster shared by every process. RedisClusterCache is the
shipped implementation of the CacheBackend contract; AbstractCacheManager is
the base class to extend if you want a different L2 (Memcached, DynamoDB, an
HTTP cache — anything).Like the query optimizers, this module is opt-in
and not wired into the query pipeline. Nothing is cached until you cache it,
and nothing is invalidated until you invalidate it. That keeps correctness in
your hands: the ORM never serves you a row it did not just read.Setting it up#
import { createRedisClusterCache } from 'ts-prorm-orm';
export const shopCache = createRedisClusterCache(
[
{ host: 'redis-1', port: 6379 },
{ host: 'redis-2', port: 6379 },
],
{
key: { prefix: 'shop:' },
l1: { enabled: true, maxEntries: 1000, ttl: 60 },
l2: { enabled: true, defaultTtl: 3600 }, // seconds
},
);
Type names at the root. Five of this module's type names already existed
at the package root with different meanings, so the cache module's versions are
aliased there: CacheEntry → CacheManagerEntry, CacheManagerOptions →
CacheManagerConfig, L1CacheOptions → CacheManagerL1Options,
RedisClusterCacheOptions → RedisClusterCacheConfig, RedisClusterNode →
RedisClusterCacheNode. To import them under their real names, use the
ts-prorm-orm/cache subpath instead of the root. (ts-prorm-orm/audit,
/graph and /prisma-migrate exist for the same reason.)
ioredis is loaded lazily and is not a dependency of the package — install
it yourself (npm i ioredis) if you want L2. With no cluster nodes the cache
logs RedisClusterCache: No cluster nodes provided, running in L1-only mode
and works as a single-process cache, which is also the easiest way to try it:import { createRedisClusterCache } from 'ts-prorm-orm';
const local = createRedisClusterCache([], { key: { prefix: 'shop:' }, l1: { maxEntries: 2 } });
await local.set('product:1', '{"id":1}', 300); // true
await local.set('product:2', '{"id":2}');
await local.get('product:1'); // '{"id":1}'
await local.set('product:3', '{"id":3}'); // capacity 2: evicts product:2, the least recently used
await local.get('product:2'); // null
await local.mget(['product:1', 'nope']); // ['{"id":1}', <empty slot>]
local.getStats();
// { hits: 2, misses: 1, l1Hits: 2, l2Hits: 0, keys: 2, hitRate: 0.6666666666666666 }
await local.invalidatePattern('product:*'); // 2
Options#
| Option | Default | Meaning |
|---|
l1.enabled | true | In-process LRU layer. |
l1.maxEntries | 1000 | LRU capacity. |
l1.ttl | 60 | Seconds an L1 entry lives. A shorter set() TTL bounds it further. |
l2.enabled | true | Redis layer. |
l2.defaultTtl | 3600 | Redis SETEX lifetime, seconds, when set() gets no TTL. |
key.prefix | 'orm:cache:' | Prepended to every key. |
key.separator | ':' | Separator for composed keys. |
readFromReplicas | false | Send reads (get, exists, mget) to a replica; writes stay on the cluster. |
maxConnections | 10 | Connection pool size. |
connectTimeout / commandTimeout | 10000 / 5000 | Milliseconds. |
db | 0 | Redis database index. |
retryStrategy | backoff to 3 s, give up after 10 tries | Cluster reconnect policy. |
An L1 entry expires after l1.ttl seconds, or sooner when set() was given a
shorter TTL; an expired entry reads as a miss and exists() answers false.
The L2 copy expires by SETEX. With readFromReplicas: true (and
clusterOptions.enableReadFromReplicas), get, exists and mget go to a
replica, and set, mset, del, invalidatePattern and clear go to the
cluster.[!NOTE]
Before 4.9.2, L1 entries never expired (l1.ttl was stored but not
checked), and readFromReplicas: true sent every command, writes and
invalidations included, to a replica, which rejected them silently.
Read and write paths#
The cache is cache-aside: your code reads the cache, falls back to the database,
and writes the result back. get() looks in L1, then L2, and promotes an L2
hit into L1:// shopCache is the cache exported by src/cache.ts
export async function loadProduct(id: number) {
const key = `product:${id}`;
const hit = await shopCache.get(key);
if (hit) return JSON.parse(hit);
const row = await Product.findByPk(id);
if (row) await shopCache.set(key, JSON.stringify(row), 300);
return row;
}
export async function listProducts(page: number) {
const key = `product:list:${page}`;
const hit = await shopCache.get(key);
if (hit) return JSON.parse(hit);
const rows = await Product.findAll({ order: [['id', 'ASC']], limit: 20, offset: (page - 1) * 20 });
await shopCache.set(key, JSON.stringify(rows), 60);
return rows;
}
Values are strings, so serialise structured data yourself. A cache hit returns
the parsed JSON, not a model instance.L2 failures never throw. A Redis error is logged and treated as a miss, so a
cache outage degrades to going to the database rather than taking your
application down.The API#
Every backend implements the same ten methods:| Method | Behaviour |
|---|
get(key) | L1 first; on an L1 miss, read L2 and promote the value into L1. |
set(key, value, ttl?) | Writes both layers. L2 uses SETEX key ttl value. |
del(key) | Removes from both. |
exists(key) | Presence check across both. |
mget(keys) / mset(entries) | Batched forms. With L2 off, mget leaves an empty slot (not null) for a key L1 lacks, and does not count it as a miss. |
invalidatePattern(pattern) | Glob-style invalidation across both layers; returns how many keys went. |
clear() | Everything under the prefix, and resets stats. |
disconnect() / isConnected() | Lifecycle. |
Invalidation#
There is no automatic invalidation — the cache does not know when you write.
The usual pattern is to invalidate from model hooks, so every write path is
covered whether or not the caller remembered.Choose the hooks carefully. A set-based Product.update(values, { where })
passes afterUpdate the values, not a row, so instance.id is undefined
there, and bulkCreate never fires afterCreate. The bulk hooks fire for
instance writes and set-based writes alike, and receive the where; see the
hook table. Invalidate there:import { DataTypes } from 'ts-prorm-orm';
// shopCache is the cache exported by src/cache.ts
function invalidate(options: any) {
const id = options?.where?.id;
const one = typeof id === 'number' ? shopCache.del(`product:${id}`) : shopCache.invalidatePattern('product:*');
return Promise.all([one, shopCache.invalidatePattern('product:list:*')]);
}
export const Product = prorm.define('Product', {
id: { type: DataTypes.INTEGER(), primaryKey: true, autoIncrement: true },
sku: { type: DataTypes.STRING(32) },
price: { type: DataTypes.DECIMAL(10, 2) },
}, {
tableName: 'products',
timestamps: false,
hooks: {
afterBulkUpdate: (options: any) => invalidate(options), // instance.save(), Product.update()
afterBulkDestroy: (options: any) => invalidate(options), // instance.destroy(), Product.destroy()
afterBulkCreate: () => shopCache.invalidatePattern('product:list:*'),
afterCreate: () => shopCache.invalidatePattern('product:list:*'),
},
});
product.save() fires afterBulkUpdate with where: { id }, so it deletes one
key. Product.update({ price: 40 }, { where: { sku: 'KB-01' } }) has no id
in its where, so it clears every cached product.Invalidation reaches L2 and the current process's L1 only. Other processes
keep their L1 copy:[!CAUTION]
Because L1 entries do not expire, process B serves the old price until the
entry is evicted. For data that must be fresh across processes, disable L1
(l1: { enabled: false }) or broadcast invalidations yourself, for example
over Redis pub/sub, and call del() in every process.
[!WARNING]
invalidatePattern and clear use Redis KEYS, which scans the whole
keyspace: fine for occasional invalidation, but avoid it on a hot path against
a large Redis. On a Redis Cluster, a keyless command such as KEYS runs on a
single node, and a multi-key DEL fails with CROSSSLOT when the keys hash
to different slots, so pattern invalidation can miss keys. Prefer targeted
del() calls where you can name the key.
Design your keys so a pattern can express what a write invalidates:shop:product:42 -> one product
shop:product:list:1 -> a page of the product listing
shop:user:7:orders:page:1 -> one user's order history
Statistics#
const s = shopCache.getStats();
// { hits, misses, l1Hits, l2Hits, keys, hitRate }
shopCache.resetStats();
keys is the number of L1 entries. l1Hits versus l2Hits is the number to
watch: a low l1Hits share with a healthy overall hitRate means your L1 is
too small for the access pattern.clearL1() empties just the in-process layer — useful in tests, and after a
deploy that changes the shape of a cached value.Writing another backend#
Extend AbstractCacheManager, which gives you option normalisation, key
prefixing and stats bookkeeping, and implement the ten abstract methods:import { AbstractCacheManager } from 'ts-prorm-orm';
class MemcachedCache extends AbstractCacheManager {
async get(key: string): Promise<string | null> { /* … */ return null; }
async set(key: string, value: string, ttl?: number): Promise<boolean> { /* … */ return true; }
async del(key: string): Promise<boolean> { /* … */ return true; }
async exists(key: string): Promise<boolean> { /* … */ return false; }
async invalidatePattern(pattern: string): Promise<number> { /* … */ return 0; }
async clear(): Promise<boolean> { /* … */ return true; }
async mget(keys: string[]): Promise<(string | null)[]> { /* … */ return keys.map(() => null); }
async mset(entries: Array<{ key: string; value: string; ttl?: number }>): Promise<boolean> { /* … */ return true; }
async disconnect(): Promise<void> { /* … */ }
isConnected(): boolean { return true; }
}
Anything satisfying CacheBackend is interchangeable at the call site.Which cache do I want?#
| Need | Use |
|---|
| Share cached values across processes or machines | RedisClusterCache (this guide) |
| Cache result sets inside one process, briefly | QueryResultCache — see Query optimization |
| Find the statements worth caching at all | SlowQueryLogger + analyzeQuery() |
| Avoid repeat queries within one request | Load once and pass it down; no cache needed |
Hooks — where invalidation belongs Redis store — using Redis as a data store rather than a cache Modified at 2026-10-04 00:40:16