Fabriq
1.x
Docs/Fabriq/Caching
Open

Reading6 min
Updated2 Aug 2026
Sourcev1/(concepts)/caching.mdx

Fabriq can cache reads transparently. You opt an entity in, and the relational read port and the typed Repo[T] start serving cached rows and cached query result-sets — with no change to call sites. There is deliberately no f.Cache() port: caching decorates the ports you already use, so turning it on is a registration flag, not a code rewrite.

The cache is correct by construction. Every committed write invalidates exactly what it touched through the same post-commit seam the audit and projection planes use — so a cached read never silently goes stale against the source of truth.

Note

Caching is off by default and opt-in per entity. An entity with no cache policy reads exactly as before — same path, same latency, zero cache code in the way. You choose what is cacheable and its staleness budget.

Opting in: CacheSpec01

An entity opts into caching by declaring a CacheSpec (nil = disabled), mirroring how SearchSpec and LiveSpec opt into their planes:

r.MustRegister(registry.EntitySpec{
	Name:  "asset",
	Model: (*domain.Asset)(nil),
	Cache: &registry.CacheSpec{
		TTL:    5 * time.Minute, // per-entry expiry + cross-entity staleness bound
		Scoped: true,            // partition by tenant + scope_id (false = tenant only)
	},
})

Scoped picks the cache partition: false keys entries by tenant, true by tenant and scope_id (the native-scope axis), so a scoped reader never sees another scope's cached result. Tenant and scope come exclusively from the context — never from a caller-supplied key — the same structural-tenancy property the rest of fabriq holds.

Two levels: rows and result-sets02

Caching is two-level (the Russian-doll pattern). The two layers cache different things and are invalidated differently:

LayerCachesKeyed byInvalidated by
Entity rowsone aggregate rowidper-id eviction on that row's write
Result setsthe ordered id-list of a querya fingerprint of the querythe entity's generation, bumped on any write to the entity

A query (List, Traverse, Out/In/Reachable, Search/SearchWith, Similar) caches only its id-list, then hydrates each id through the row cache:

Two-level cacheA List call resolves a cached id-list keyed by a query fingerprint (the result-set layer), then hydrates each id through the per-row cache (the row layer).repo.List(ctx, q)RESULT-SET LAYERid-list cachefingerprint(q) → [ids]ROW LAYER · WARMGetMany(ids)id → row, per idA miss at either layer falls through to Postgres and back-fills on the way out.

This is what makes the design pay off: when one row changes, its entity's generation bumps so every cached id-list over that entity re-resolves — but every unchanged row stays warm, so the re-resolved lists hydrate from the row cache without touching the database. A pure re-read with no intervening write is a total cache hit.

Note

The row layer hydrates through GetMany, which every typed read funnels through — Get, and the batched hydration behind Traverse, Search, Similar, and the self-edge walks. So opting an entity in warms its rows across all of those paths, not just direct id lookups.

Invalidation: write-driven, read-your-writes03

There is no manual cache-busting. The command plane runs a post-commit hook (the same seam the chronicle and projection appliers use) that, for every committed change, fires two invalidations against the cache:

Write-driven cache invalidationOn commit, a post-commit hook bumps the entity's cache generation (busting its cached id-lists) and evicts just the changed row — so a read right after a write never sees stale data.f.Exec(…)commitpost-commit hookbump generationbusts cached id-listsevict row(aggID)drops the changed row

Because the hook runs after the transaction commits, on the writing request's goroutine, the writing node sees its own change immediately — read-your-writes, with no before-commit race (the cache is busted only once the data is durable). The generation counter lives in shared Redis, so a bump on one node is visible to all nodes at once.

The per-entity generation bump is coarse on purpose: a write to any row of an entity re-resolves all of that entity's cached lists. That is always correct (the lists rebuild from Postgres) and needs no per-query bookkeeping. For the eventually-consistent projection reads (Traverse/Search/Similar, served from the graph/search/vector projections that already lag writes), the CacheSpec.TTL is the staleness bound for changes a single entity's generation can't capture — consistent with those projections being eventual anyway.

Warning

The raw-SQL escape hatch (f.Relational().Query(...)) is never cached — fabriq can't infer which entities an arbitrary query depends on. Reach for it only for reads the structured filter can't express.

The backend04

The shared cache (L2) is grove kv over Redis — the place grove kv "earns its keep" (ADR 0003), while the event-stream adapter keeps using go-redis directly. It is wired in Open() when Redis is configured; the relational port is wrapped with the cache decorator and Repo[T] gets the result-set cache, both only for opted-in entities. The whole thing is one conformance suite that gates the in-memory fake and the real adapter, so the two can never drift.

In-process L1 tier05

For the hottest reads you can add a per-node in-process L1 in front of the shared Redis L2, so a hit skips the Redis round-trip entirely. It is opt-in via config:

f, stores, err := fabriq.Open(ctx, reg, fabriq.Config{
	Postgres: fabriq.PostgresConfig{DSN: dsn},
	Redis:    fabriq.RedisConfig{Addr: redisAddr},
	Cache: fabriq.CacheConfig{
		L1Enabled: true,
		L1Size:    10_000,          // bounded LRU (default 10k when enabled)
		L1TTL:     5 * time.Minute, // backstop (default 5m when enabled)
	},
})

The L1 wraps the L2 transparently (it implements the same cache port), so rows and result-sets both gain a local tier with no further code. Coherence is the interesting part:

  • Local generation, never a Redis read. Reading the L2 generation on every access would defeat the L1, so the L1 mirrors the generation scheme with an in-process counter. Cached id-lists orphan when the local generation bumps; rows evict per-id — so a sibling write never busts a warm row locally either.

  • Writing node, synchronously. The post-commit hook hits the L1-wrapped cache, so the node that wrote clears its own L1 immediately (read-your-writes holds with the L1 on).

  • Other nodes, by broadcast. Each node runs a small tailer that reads the main event stream from "now" (a broadcast fan-out — every node sees every committed event, not a partitioned consumer group) and evicts its own L1 per change. The tailer is cancelled cleanly on shutdown.

Warning

L1 trades a little staleness for the round-trip saved. Cross-node eviction is bounded by stream-propagation latency, and a freshly-opened node has a brief cold-start window (commits between Open() returning and the tailer attaching) bounded by L1TTL. Set a sensible L1TTL; an L1 with no TTL has no backstop. Leave L1 off until a profile shows a hot read path — the shared L2 already survives restarts and is shared across nodes.

What's not built (and why)06

Invalidation is per-entity-generation coarse, not per-query precise. Precise list invalidation was scoped and intentionally not built. Evicting only the lists a write actually affects needs a predicate index to find matches — but a predicate index catches only rows whose new state matches a filter (an "enter"), not rows that were in a list and left (a delete or change-out). Catching leaves requires tracking each cached list's membership, which essentially reinvents the live-query engine's maintained-result-set bookkeeping. The coarse generation bump is correct and simple; when you genuinely need precise, maintained, ordered results, that is a live query — reach for f.LiveQuery, not a cache.

Where to go next07