Cache an expensive lookup
Cache a note summary, keep account keys separate and invalidate it after a write.
On this page
Source-backed MarkdownStart with a memory cache
Complete Installation and copy this lab. It uses the real bounded memory driver; its counted lookup stands in for expensive application work without requiring a database.
Use app().cache in an application. The standalone lab constructs and closes Cache so its ownership is visible.
mkdir -p examples
cp -R node_modules/@db3.ai/app/src/cache/examples/. examples/Run and inspect the lookup count
Expect firstLoads: 1 for two concurrent reads, refreshed: { count: 2 } after invalidation, an unchanged second-owner result and recovered: "ready" after a failed factory. cleared is true.
The lab closes its cache in finally. It only clears the store it created, not a shared application or Redis store.
npx tsx examples/runNoteCache.tsRead through the cache
getOrSet(key, factory, { ttl }) computes only on a miss. Concurrent misses share one in-flight computation within the same cache instance/process. This is not a cross-process lock or exactly-once business operation.
Build keys from the authorized owner, operation, inputs and a format version. A key is not an authorization check; authorize before looking up private data. Use plain serializable values, not a live ActiveRecord instance or open connection.
import { pathToFileURL } from 'node:url';
import { Cache } from '@db3.ai/app/cache';
/**
* Runs cache-through reads, owner-key isolation, invalidation and failure recovery.
*
* The counted factory represents an expensive lookup without needing a database.
* It is deterministic and uses the real bounded memory driver.
*
* @returns Observable cache outcomes, after closing the owned store.
*/
export async function runNoteCache() {
const cache = new Cache({ default: 'memory', stores: { memory: { driver: 'memory', ttl: 60_000, maxEntries: 100 } } });
let loads = 0;
/** Computes one example summary when the cache has no value. */
const loadSummary = async () => ({ count: ++loads });
try {
const key = 'notes:v1:owner:ada:summary';
const concurrent = await Promise.all([cache.getOrSet(key, loadSummary), cache.getOrSet(key, loadSummary)]);
const firstLoads = loads;
await cache.set('notes:v1:owner:grace:summary', { count: 9 });
await cache.forget(key);
const refreshed = await cache.getOrSet(key, loadSummary);
let rejectedUndefined = false;
try { await cache.set('invalid', undefined); } catch { rejectedUndefined = true; }
let rejectedFactory = false;
try { await cache.getOrSet('recover', () => { throw new Error('Lookup failed'); }); } catch { rejectedFactory = true; }
const recovered = await cache.getOrSet('recover', () => 'ready');
const otherOwner = await cache.get('notes:v1:owner:grace:summary');
await cache.clear();
return { concurrent, firstLoads, refreshed, otherOwner, rejectedUndefined, rejectedFactory, recovered, cleared: await cache.get(key) === undefined };
} finally {
await cache.close();
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) console.log(JSON.stringify(await runNoteCache(), null, 2));
Invalidate after changing the source
After a successful database write/commit, call forget() for affected cache keys. Deleting a summary does not change the source data; the next lookup recomputes it. TTL is a backstop, not a consistency guarantee.
forget() reports acceptance of deletion, not whether a row or value existed. clear() removes every key owned by the selected store. Avoid using it for routine single-record changes.
TTL, refresh and values
TTL is in milliseconds. Configure a positive store default or supply a per-write TTL. Omitting a default leaves no default expiry; the bounded memory store can still evict entries.
refreshThreshold returns the current value while refreshing near expiry. Only use this when serving stale data is acceptable. The factory can derive TTL and threshold from its result; those signatures are in the reference.
undefined means a miss and cannot be stored. null, false, zero and empty strings are valid cached values. Generic types do not validate deserialized values.
Choose one named store
Pass config: { cache: { default, stores } } to App. Cache selects one named store; it is not a tiered or automatically failing-over collection. Memory defaults to a maximum of 1,000 entries and cloned values.
Separate API/worker processes do not share memory entries. For shared data configure a Redis store with driver, url and a nonempty namespace; isolate app and environment, for example notes:staging:cache.
Redis options include connection timeout, clear batch size and strict error flags. Namespaced clear scans/unlinks that namespace, never flushes the entire database. Keep credentials server-side. This lab does not exercise a live Redis connection.
Handle misses and failures deliberately
A rejected factory is not cached and later callers can retry. An undefined result rejects. Blank keys reject; surrounding whitespace is trimmed.
Cache is an optimization, never the only copy of required state. Decide which operations may fall back to the source when storage is unavailable, and monitor that path. Read/refresh errors follow the selected driver behavior; do not assume every outage throws or silently recovers.
App.close() closes its cache; a manually created cache needs close(). Do not close the shared app cache at the end of every request.
Testing
Create a tests/cache directory and save the test below as runNoteCache.test.ts. It imports the example you copied into examples/. Keep the same folder layout so the relative import resolves.
Run from the application root with the development dependencies from Installation. These are consumer tests, not commands that assume a framework checkout.
import { afterEach, expect, it, vi } from 'vitest';
import { Cache } from '@db3.ai/app/cache';
import { runNoteCache } from '../../examples/runNoteCache';
afterEach(() => { vi.useRealTimers(); });
it('coalesces reads, invalidates one owner, rejects bad values and recovers failed lookups', async () => {
expect(await runNoteCache()).toEqual({ concurrent: [{ count: 1 }, { count: 1 }], firstLoads: 1, refreshed: { count: 2 }, otherOwner: { count: 9 }, rejectedUndefined: true, rejectedFactory: true, recovered: 'ready', cleared: true });
});
it('expires values in milliseconds and keeps independent memory stores isolated', async () => {
vi.useFakeTimers();
const first = new Cache();
const second = new Cache();
try {
await first.set('same-key', false, { ttl: 100 });
expect(await first.get('same-key')).toBe(false);
expect(await second.get('same-key')).toBeUndefined();
await vi.advanceTimersByTimeAsync(101);
expect(await first.get('same-key')).toBeUndefined();
await expect(first.set(' ', 1)).rejects.toThrow('non-empty');
await first.set('null-value', null);
expect(await first.get('null-value')).toBeNull();
} finally {
await first.close();
await second.close();
}
});
Test cache behavior without sleeping
The copied tests use real stores. They cover concurrent reads, owner keys, invalidation, recovery, valid falsy values, store isolation and millisecond expiry. Only the clock is controlled for the expiry assertion.
For your feature, count source reads and prove a write invalidates the right owner/key. Add live Redis conformance before choosing Redis for a deployment.
npx vitest run tests/cache/runNoteCache.test.ts
npx tsc --noEmit --target ES2022 --module ESNext --moduleResolution Bundler --types node --skipLibCheck examples/*.tsCoverage and limits
Taught/tested: memory read/write, get-or-set, same-instance coalescing, invalidation, clear, TTL, failed factory recovery, key/value validation, independent stores and close. Configuration, LRU bounds, cloning and background refresh are explained/reference-covered; background refresh and live Redis are not proved by this lab.
There is no cache-tag API, distributed mutex, multi-store failover or durable transaction. Use database/Queue primitives for durable work.
Coalesces real memory-cache reads, isolates owner keys, invalidates, expires and recovers a failed lookup.
The guide test passes against the real framework components.packages/app/src/cache/tests/examples/runNoteCache.test.tsThis test command requires the framework repository. Use the walkthrough commands in an installed application.
Environment: Node.js 24. Real bounded memory store; tests advance time. No Redis or SQL claim.