コンテンツにスキップ
日本語

Cache overview

このコンテンツはまだ日本語訳がありません。

The Cache interface is the framework’s opportunistic key/value cache. Seven operations cover ~95 % of real cases: get, set, atomic increment, set-if-absent, delete, bulk get, bulk set.

Used by:

interface Cache {
get<V>(key: string): Promise<Option<V>>;
set<V>(key: string, value: V, ttlMs?: number): Promise<void>;
incr(key: string, ttlMs?: number): Promise<number>;
setIfAbsent<V>(key: string, value: V, ttlMs?: number): Promise<boolean>;
delete(...keys: string[]): Promise<void>;
mget<V>(keys: string[]): Promise<Map<string, V>>;
mset<V>(entries: ReadonlyMap<string, V>, ttlMs?: number): Promise<void>;
}

Small surface — no pattern scans, no pub/sub (the cluster has its own pub/sub).

BackendUse
InMemoryCacheSingle-pod / tests. In-process Map.
RedisCacheMulti-pod production. Wraps ioredis.
MemcachedCacheMulti-pod where Memcached fits. Wraps memjs.

Pick by deployment shape:

  • Single pod — InMemoryCache. Fast, no extra peer deps.
  • Multi-pod with Redis already — RedisCache.
  • Multi-pod with Memcached already — MemcachedCache.
  • Multi-pod, no preference — RedisCache. More features (pub/sub, persistence, etc.) and the wider ecosystem.

Caches are lossy by design. A get returning None means “not cached” — the caller’s job is to fall back to the source of truth:

const cached = await cache.get<User>(`user:${id}`);
if (cached.isSome()) return cached.value;
const user = await db.users.findById(id); // ← source of truth
await cache.set(`user:${id}`, user, 60_000);
return user;

If set fails (Redis down, network blip), the cache stays empty — but the call still returns the right answer (the source of truth was consulted).

Cache implementations return defaults on transient failures rather than throwing. Misuse (bad TTL, malformed value) throws.

await cache.set('key', value, 60_000); // expires in 60s
await cache.set('key', value); // no expiry
  • With ttlMs — entry expires after the window.
  • Without — entry stays until evicted (LRU on InMemoryCache; backend-policy on Redis / Memcached).

Most uses: always set a TTL. No-TTL entries grow until eviction; explicit TTLs are predictable.

const count = await cache.incr(`requests:${userId}`, 60_000);
if (count > 100) throw new Error('rate limit');

incr returns the new count after incrementing. When ttlMs is set AND the counter was just created (count === 1), the TTL is set.

Used by rate-limit middleware for fixed-window counters.

const got = await cache.setIfAbsent('lock:key', 'me', 5_000);
if (got) {
// I won the race; do the work
} else {
// Someone else has it
}

Atomic CAS-style write. Used by idempotency-key middleware to detect “I’m the first request with this key.”

Atomicity is a guarantee, not best effort. Every backend maps this onto a single native compare-and-set — Redis SET … NX, Memcached ADD, and on InMemoryCache a Map read/write pair the single-threaded event loop cannot interleave. With N concurrent callers, exactly one sees true. No backend implements it as get-then-set, which would have a window where two callers both observe the key absent and both write.

ttlMs applies only to the write that wins — a losing call leaves the incumbent entry’s expiry alone, so a retry loop can never extend someone else’s hold. Sub-second precision is backend-dependent: Memcached rounds up to whole seconds with a 1 s floor.

setIfAbsent gives you the acquire half of a lock. The release half is where hand-rolled versions go wrong, so the framework ships one:

import { acquireLock } from 'actor-ts';
const lock = await acquireLock(cache, 'lock:nightly-report', 30_000);
if (lock.isNone()) return; // someone else is on it
try {
await generateReport();
} finally {
const released = await lock.value.release();
if (!released) log.warn('report ran past its 30s lock TTL');
}

acquireLock writes a random 128-bit token as the value and release() deletes the key only if that token is still there. The naive version — cache.delete(key) in a finally — is wrong the moment a holder overruns its TTL: its entry is already gone, someone else has written their own, and the unconditional delete evicts the new owner while they are still working. That is what the false return reports: the lock had lapsed, so the section just executed may not have been exclusive after all.

ttlMs is required, deliberately. Expiry is the only recovery path from a holder that crashed or stalled — a lock with no TTL wedges forever the first time a process dies at the wrong moment.

const users = await cache.mget<User>(['user:1', 'user:2', 'user:3']);
// → Map<string, User> — missing keys absent

Round-trip optimization. Critical for shared-entity-hydration patterns after a sharding rebalance — pull every active entity’s state in one Redis call instead of N.

mset is the dual:

await cache.mset(new Map([
['user:1', user1],
['user:2', user2],
]), 60_000);

The Cache API reference covers the full interface.