Caching
createCachedStore's in-process LRU read cache — options, staleness guarantees, and when to reach for each.
createCachedStore wraps any ObjectStore with an in-process LRU. Git
objects are content-addressed, so a hit is always correct data forever —
refs and other mutable paths need a much shorter TTL instead, via
ttlForKey (below).
createCachedStorecreateCachedStore(store: ObjectStore, options?: CacheOptions): CachedObjectStore| Parameter | Type | Description |
|---|---|---|
store | ObjectStore | The store to wrap. |
options? | CacheOptions | See below. |
Returns: CachedObjectStore
A CachedObjectStore is a normal ObjectStore plus one extra method:
invalidate(prefix).
Options
| Option | Default | What it does |
|---|---|---|
maxBytes | 50 MiB | Total cache budget. |
maxEntryBytes | maxBytes / 10 | Largest single entry admitted — keeps one huge packfile from evicting the whole working set. |
ttlMs | 60 000 | Entry time-to-live. |
ttlForKey | — | Per-key/prefix TTL override — see below. |
cacheMisses | false | Also cache "not found" results. |
cacheLists | false | Also cache list() results (directory listings, existence probes). |
coalesce | true | Collapse concurrent identical requests into one backend call. |
copy | true | Return a fresh copy per hit so callers can't mutate cached bytes. Set false when readers never mutate (isomorphic-git doesn't) — otherwise every hit copies the whole entry, megabytes for a packfile. |
onHit / onMiss | — | Instrumentation hooks, called with the key. |
ttlForKey
(key: string) => number | undefined — called for every get/head
admission and every list() result, keyed by the object's key (for
get/head) or the listed prefix (for list()). Return a number to
override ttlMs for that specific entry; return undefined to fall back
to the default.
A single ttlMs tuned for object reads is right for object reads — the
whole point of content addressing is that a hit is correct forever — but it's
wrong for the handful of keys in a gitdir that are mutable:
- Refs (
refs/heads/<branch>,HEAD) — the same key's value changes on every push. - Directory listings under
objects/(particularlyobjects/pack/) — the set of packs grows on every push, even though no individual pack's bytes ever change.
This cache is in-process with no cross-instance invalidation broadcast —
invalidate() only clears the instance that calls it (typically the one
that just handled a push). A different warm instance that had already
cached a ref, or a pack directory's listing, keeps serving that pre-push
view for the rest of ttlMs — not because anything is wrong with its own
invalidation logic, but because it was never told to re-check. For a pack
listing specifically, this doesn't just mean a stale ref: it means a
prefetch/pack-lookup pass can fail to discover a freshly pushed pack
exists at all, and misreport its objects as missing from storage rather
than simply not-yet-listed.
classifyGitdirPath turns a gitdir-relative path into a typed entry (head,
refs, pack-dir, pack-file, loose-object, …) and
isMutableGitdirEntry is true exactly for the mutable ones above.
A ref is one small object and a pack listing is one bounded list() call,
so re-checking either far more often than ttlMs costs little. Everything
downstream of a fresh ref or listing — the tree, commit, and blob reads
keyed by the sha it resolves to, or the specific pack name a fresh listing
named — still gets the full ttlMs cache benefit once the structure itself
is current:
import { classifyGitdirPath, isMutableGitdirEntry } from 'git-fs-s3';
// Keys here are `repos/<owner>/<repo>/git/<path inside the gitdir>`.
const pathInGitdir = (key: string) => key.split('/').slice(4).join('/');
const cached = createCachedStore(store, {
ttlMs: 3_600_000, // 1h — safe for object reads
ttlForKey: (key) =>
isMutableGitdirEntry(classifyGitdirPath(pathInGitdir(key)))
? 5_000
: undefined,
});cacheMisses
Loose-object probes on a packed repository are always misses — every one of them is otherwise a wasted round trip on every single request. Turning this on is close to free performance, with one condition:
Only safe when this process is the sole writer to the backend. If another
process (or another instance) writes a key this process just cached as
"missing," that write is invisible here until ttlMs expires or someone
calls invalidate().
cacheLists
Caches list() results — which backs both readdir() and existence
probes. Writes made through this same cached store keep listings
consistent automatically. There's one deliberate asymmetry worth knowing:
A non-empty limit: 1 probe (the shape of "does this directory have
anything in it") survives writes underneath it — a write can add entries to
a directory, but it can't make a directory that already has something in it
stop existing, so that specific cached answer stays valid regardless.
After a write that happens outside this cached store (another process, a
bulk sync), call invalidate(prefix) — see
the production stack guide.
coalesce
Multiple concurrent callers requesting the same key collapse into one backend request, sharing the result. On by default — a page render that fans out N requests for the same object (e.g. N callers all resolving the same ref) pays for one network round trip, not N.
The staleness contract, plainly
cacheMisses and cacheLists trade read traffic for a window — bounded by
ttlMs — in which another process's writes are invisible here. This is
fine in exactly two situations: each repo is served by one process at a
time, or every external write path calls invalidate(). Outside those two,
don't turn them on.