You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
feat: add Client.GetMany with batch upstream and batch backends - #11
Docs (in Chinese; README stays bilingual): docs/design.md and docs/design/ explain the read path, singleflight, write order and striped locks, batch reads, backends and cross-instance consistency, with sequence and data flow diagrams; GLOSSARY.md, eleven ADRs in docs/adr/, docs/faq.md and docs/faq_ZH.md (moved from the README, in both languages), docs/todo.md (moved from TODO.md/TODO_ZH.md), the measurements in docs/research/ with re-runnable scripts in tools/bench/, and CLAUDE.md. The singleflight registry and the striped lock live in internal/flight and internal/stripe, with their own unit tests.
This PR now also carries what was #12 (write order: upstream first, generation-guarded backfills), merged here so both are reviewed and released together. Its description follows in Write order below.
Also carries #10's README fixes (5e99f15, cherry-picked unchanged), so the README here matches the current API in full. Whichever of the two merges first, the other rebases cleanly.
Why
Callers often need dozens of keys at once (e.g. the cached verdicts for a batch of content items). Today there is only Client.Get, one key at a time:
When dozens of keys miss, the upstream is called once per key (dozens of queries or API calls), even though the upstream itself can usually answer a batch in one go (one WHERE key IN (...), or one API call for many items).
So callers end up writing the batch read, miss merging and backfill themselves outside the Client, and lose the Client's protections: singleflight, DoubleCheck, Not-Found caching, serve-stale. A real example: a service that asks a model to answer a batch at once had to write its own "if this key is already being fetched, wait for it" dedup on top.
New API (all additive)
// Optional, for upstreams that answer a batch in one call.// Keys absent from the map do not exist. A non-nil error fails the whole batch,// unless it is a *BatchError returned as is (not wrapped),// which fails only the keys it lists. A whole-batch error fails every key// even if it wraps an ErrKeyNotFound.typeBatchUpstream[Tany] interface {
GetMany(ctx context.Context, keys []string) (map[string]T, error)
}
// Optional, for backends.typeBatchCache[Tany] interface {
Cache[T]
BatchUpstream[T]
SetMany(ctx context.Context, valuesmap[string]T) errorDelMany(ctx context.Context, keys []string) error
}
func (c*Client[T]) GetMany(ctx context.Context, keys []string) (map[string]T, error)
// Per-key failures of a batch; errors.Is / errors.As see through to them.typeBatchErrorstruct{ Errorsmap[string]error }
// Bounds the upstream requests one GetMany has in flight: per-key upstream.Get// calls, or chunk calls of a BatchUpstream (default DefaultGetManyFetchConcurrency = 16).funcWithGetManyFetchConcurrency[Tany](concurrencyint) ClientOption[T]
// Splits the keys one GetMany sends to a BatchUpstream into calls of at most// size keys, run concurrently (default 0: one call).funcWithGetManyChunkSize[Tany](sizeint) ClientOption[T]
// RedisCacheConfig.ChunkSize / GORMCacheConfig.ChunkSize: keys per pipeline or// statement of the batch methods (default DefaultChunkSize = 1000).
BatchCache is implemented by RistrettoCache, SyncMap, RedisCache (pipelines of GET/SET/DEL rather than MGET, so it also works across slots on Redis Cluster) and GORMCache (WHERE key IN (...) and multi-row upserts), both split into chunks of ChunkSize keys. SetMany/DelMany are best effort on every backend: each key is tried and the failed keys are listed in a *BatchError. Backends that don't implement it (BigCache, Transform, custom ones) fall back to per-key calls.
Client implements BatchUpstream, so with layered clients a batch goes down as one call per layer: memory → Redis/DB → data source.
Semantics
GetMany behaves like calling Get for each key, only batched:
Backend: one batch read. Each value read is handled by staleness (the whole result is returned at once): fresh → into the result; stale with WithServeStale → into the result, and all stale keys of the call are refreshed together in the background (deduplicated through asyncRefreshing like Get); rotten or stale without serve-stale → refetch; miss → next step.
Not-Found cache: misses are checked against it with the same fresh/stale/rotten rules as Get. Keys that don't exist are simply absent from the result.
Singleflight: each key that needs the upstream is claimed individually in the same singleflight Get uses (same sfKey, so WithFetchConcurrency slots apply per key). Keys already in flight from a Get or another GetMany are waited for. A Get and a GetMany of the same key, or two overlapping GetManys, fetch the shared key once.
DoubleCheck: after claiming and before going upstream, claimed keys are re-checked against backend / Not-Found cache with the same rules as Get (in the default DoubleCheckAuto, only keys whose stripe this Client wrote since the lookup) (a fresh value or a fresh cached not-found settles the key; anything else, including a failed re-check, goes upstream).
Upstream: the claimed keys still missing go out as oneupstream.GetMany if the upstream implements BatchUpstream, split into calls of WithGetManyChunkSize keys if set, otherwise as upstream.Gets each run exactly as Get would; at most WithGetManyFetchConcurrency (default 16) requests at a time, each answering its keys as soon as it returns. As with Get, the fetch timeout starts after the double-check. A panic or runtime.Goexit fails the keys still being fetched (not those already answered).
Write-back: found values → backend (SetMany when supported) after deleting their Not-Found entries; not-found keys → Not-Found cache + backend delete. As with Get's doFetch, this only touches this layer, never the upstream.
Errors: the map always holds every key that succeeded. If any key failed (backend, upstream, ctx), the error is a *BatchError with the failed keys. I chose this over "one error fails everything" because a batch is usually independent items, and one bad key shouldn't throw away the rest; it also lets a lower Client report partial failures to the layer above through the same BatchUpstream contract. Corrupt entries in Redis/GORM are reported per key the same way instead of failing the whole read.
Singleflight implementation change (internal)
golang.org/x/sync/singleflight can't tell the caller synchronously whether it became the owner of a key: DoChan decides under its lock but runs fn in a goroutine. GetMany needs that answer to build its batch, and waiting for it would deadlock two batches that each own a key the other one is waiting for. So Get and GetMany now share a tiny flightGroup (claim returns (flight, isOwner); finish releases the key before publishing the result, which is the same ordering as x/sync and what the existing DoubleCheck tests rely on). Get's behavior, hooks and error messages are unchanged; all existing tests pass unmodified. (x/sync stays in go.mod for the benchmark's semaphore.)
Compatibility
Additive only: new interfaces, new methods, new error type, one new option. Get/Set/Del behavior is unchanged. RedisCache.Get/Set were refactored to share encode/decode helpers with the batch methods, with the same behavior.
Write-back and write order: GetMany's write-back goes through setManyWithoutUpstream / delManyWithoutUpstream, the batch counterparts of setWithoutUpstream / delWithoutUpstream used by doFetch; both are guarded by the write generation described under Write order below.
Tests
batch_test.go (TDD, all run under -race, stressed with -count=30):
BatchCache contract against SyncMap, Ristretto, Redis (+prefix), GORM (+prefix); a corrupt Redis entry doesn't hide the others
full hit / partial hit / all miss; upstream called once with only the misses (deduplicated)
without BatchUpstream, each key is fetched exactly as Get would (own double-check, fetch timeout and write-back, published as soon as it is fetched), claimed only when a WithGetManyFetchConcurrency slot is free; a canceled GetMany starts no more keys. Backend batch read/write is used when available
single Get and GetMany racing on the same key → one fetch; two overlapping GetManys → shared key fetched once
Not-Found cache: written by GetMany, honored by GetMany and Get, rotten entries refetched
serve-stale: stale values returned at once, refreshed in one background batch
DoubleCheck resolves a claimed key without calling the upstream
layered clients (Ristretto → GORM → batch source): one call reaches the bottom
final review round: GORMCache quotes its key column (key is reserved in MySQL, so Get/GetMany/Del/DelMany failed with a syntax error there, already on main); a failed not-found cache write no longer stops the single-key write-back either (Get kept a deleted value when the not-found cache was down); a batch refresh that does not finish within the fetch timeout is logged at Warn. Verified against real MySQL 8.4 (default *_ci collation), PostgreSQL 16 and Redis 7: the BatchCache contract, 2500 keys across statements, keys differing only in case, layered GetMany
second review round: without BatchUpstream a Get never waits for another caller's GetMany queue, and a key's fetch has its own timeout (both were per batch); a failed not-found cache write no longer skips the backend write-back of the other keys; a refresh of many keys no longer logs false failures; GORMCache never serves another key's row on a key column that is not byte-exact (MySQL's default *_ci collations made Get("abc") return ABC's value, already on main): Get/GetMany only return the row stored under exactly the requested key, and upserts rewrite the key column so a shared row belongs to the key written last. README asks for a *_bin collation on MySQL; README batch examples compile
review fixes: the per-key fallback respects WithGetManyFetchConcurrency; a whole-batch error that wraps or joins an ErrKeyNotFound fails every key and caches no not-found; a slow double-check does not eat the upstream's fetch timeout; the same whole-batch rule now also covers the backend and the not-found cache (such an error is a failure, not a batch of misses), and the per-key error keeps the original error for errors.Is/errors.As while IsErrKeyNotFound reports false for it; a double-check whose batch read fails falls through to the upstream like Get, and a Get that joins a failed batch fetch gets the failure, not a not-found
Existing tests pass unmodified with go test -short -p=1 ./... (the CI command) and without -short; golangci-lint v2.13.2: 0 issues.
Pre-existing on main: TestClientStaleHandling, TestNotFoundCacheStale and TestEntryWithClient reported data races under -race (test counters touched by background refreshes, and a refresh outliving the test's Close). A separate test-only commit fixes them (atomic counters, wait for the background refresh before reading its result or closing); no library code changes. The full suite is now clean under -race.
Backward compatibility
Existing callers only use Get/Set/Del, the backends, WithFetchConcurrency, WithDoubleCheck, Not-Found caching, serve-stale, write propagation and the test hooks. None of that changes. What I checked, line by line against main:
Single-key Get after replacing x/sync/singleflight with flightGroup
main (x/sync)
this PR
coalescing
first caller runs fn in a new goroutine, others join
same: claim under a mutex, owner fetches in a new goroutine
WithFetchConcurrency(n)
makeSFKey picks a random slot i:key
same makeSFKey, same slot keys
panic in the upstream
recovered inside fn → panic during upstream fetch: … to every waiter, logged with key and stack
same recover, same message, same log record
runtime.Goexit in the upstream
x/sync deletes the key and publishes nothing; waiters wait for their ctx (forever without a deadline; golang/go#52557)
changed: every waiter gets upstream fetch exited without returning (runtime.Goexit)
error propagation
same error value to every waiter, not cached
same
Forget
never called
n/a
release vs publish order
key deleted, then results sent
key deleted, then done closed
caller ctx cancelled
waiter returns context cancelled during fetch for key: …; fetch keeps running on WithoutCancel(ctx)
same for the waiter; changed: the whole fetch, double-check included, runs detached from the leader's cancellation (values kept, the leader's GORM transaction dropped), since it serves every waiter
fetchTimeout
WithTimeout(WithoutCancel(ctx)) around the fetch, after the double-check
same; the double-check is also bounded by fetchTimeout
DoubleCheckAuto (default)
on iff a not-found cache is configured
changed: re-checks only when this Client wrote the key's stripe since the lookup (hot key over a 1 ms cache: 19 upstream calls vs Enabled 18 / Disabled 30; 8000 cold keys: 1.004 cache reads per Get vs Enabled 2.0)
a not-found cache write fails during write-back
the backend is left as is (a deleted value stays)
changed: the backend is still written / its old value still deleted
test hooks
beforeSingleflightStart per caller, afterSingleflightStart per fetch, afterSingleflightEnd per caller that received the result
same call sites and counts
logs
panic during upstream fetch, async refresh failed, failed to set/delete cache entry
unchanged
Equivalence tests: get_compat_test.go pins the observable single-key behavior: 20 concurrent misses → 1 upstream call; hook counts; upstream error shared and not cached (exact message); upstream panic (message, log line, key released); runtime.Goexit (key released); fetchTimeout (exact message, upstream sees the deadline); caller cancellation (exact message, fetch outlives the caller and fills the backend); WithFetchConcurrency(3) (≤ 3 fetches); DoubleCheck race window (enabled → 1 fetch, disabled → 2); Not-Found fresh/stale/rotten (exact messages, Cached/CacheState, upstream counts, Set clears it, Del records it); serve-stale (20 stale reads → 1 refresh); backend error; write propagation through layers. The file uses only the pre-GetMany API and passes unchanged on main (I ran it against a main worktree, -race -count=3) and on this branch, so it is the baseline; all pre-existing tests also pass unmodified.
Other files: RedisCache.Get/Set now share encode/decode helpers; the decode paths (string, []byte, BinaryUnmarshaler, JSON) and error wrapping are the same as before. RistrettoCache, SyncMap, GORMCache only gain methods.
Interplay that is new (only reachable by calling GetMany with a BatchUpstream): a Get that joins a fetch a GetMany started gets that batch's result, so a key the batch upstream leaves out comes back as a plain ErrKeyNotFound (not the lower layer's Cached: true one), and fetchTimeout covers the whole batch. Callers that never call GetMany cannot hit this.
GORMCache deadlocks: concurrent batch write-backs from several instances used to deadlock. Measured with 16 workers mixing SetMany/DelMany over shared keys:
MySQL 8.4
PostgreSQL 16
rows in map order (before)
deadlocks
~half the operations deadlocked, each stalling deadlock_timeout
one lock order (sorted SetMany; on PostgreSQL DelMany locks rows ORDER BY key COLLATE "C" FOR UPDATE, since its index follows the column's en_US collation)
3 / 1280 deadlocks (gap locks), 949 ops/s
0, 1922 ops/s
one lock order + retry deadlock victims (at most 5 attempts; not inside a WithGORMTx transaction) — shipped
0, 840 ops/s
0, 1936 ops/s
serialize writes per table (table lock) — not shipped
0, 647 ops/s
0, 1045 ops/s
GORMCache on real databases: the key column is now quoted (it is reserved in MySQL; Get/Del on main failed there with a syntax error), reads only return the row stored under exactly the requested key, upserts rewrite the key column, and Migrate creates MySQL tables with utf8mb4_0900_bin (MySQL 8.0.17 or later is now required); on an existing table Migrate checks the key column's collation and returns an error with the ALTER statement unless it is utf8mb4_0900_bin. Only each database's default isolation level is supported. TestGORMCacheOnRealDatabases runs MySQL 8.4 and PostgreSQL 16 through testcontainers (skipped without Docker; GitHub Actions has it): the BatchCache contract, a batch over several statements, case-sensitive keys, Migrate rejecting an existing case-insensitive MySQL table until it is converted, and Client reads and writes. This adds testcontainers and the MySQL/PostgreSQL GORM drivers to go.mod; only tests import them.
Benchmark
BenchmarkGetManyVsGet: 100 keys, all missing, upstream costs 1ms per call whether it answers one key or many (Apple M-series, -benchtime 20x -count 3):
Two ways a cache layer can end up holding a value its upstream does not have, and keep it until it expires:
Set wrote this layer before the upstream. If the upstream write failed, this layer kept the new value while the layer below (Redis, a DB-backed cache) kept the old one. Every reader of this layer then saw a value that "never happened" below.
A read's backfill could overwrite a newer write. A Get reads old from the upstream; before it writes old back to this layer, a Set(new) completes on both layers; then the backfill lands and this layer holds old while the upstream holds new. Same for GetMany and the serve-stale background refresh.
There was also no ordering between two concurrent Sets of one key, so L1 and L2 could end up with different winners.
What changes
Set / Del order (Client.write):
The upstream is written first (when it implements Cache[T]), this layer only after it succeeds.
If the upstream write fails, its state is unknown (it may have been applied and only the reply lost). This layer drops its entry and any cached not-found for the key and returns the error, so the next read goes down and sees whatever the upstream really has. A failed upstream Del likewise drops this layer's entry but records no not-found (the upstream may still have the key).
If writing this layer fails after the upstream succeeded, the entry is dropped too (best effort), so this layer is never left with the old value.
Del stays idempotent. On success it caches a not-found and deletes the backend entry, exactly as before.
Not-found cache timing: Set still clears the not-found entry together with the backend write, just after the upstream write instead of before it. This is the safer order: a not-found is only cleared when there is a value below to read, and on failure it is cleared anyway so a read goes down instead of trusting a stale not-found.
Concurrent Set/Del of one key through the same Client are serialized by a striped mutex (4096 stripes, keyed by maphash), so every layer applies them in the same order.
Waiting for the stripe (another write, or a backfill writing this layer) gives up when the caller's ctx is done, having written nothing. A write that need not wait runs even with a done ctx, as before this PR, so an invalidating Del after the request was canceled still lands in in-memory layers.
After the upstream write (success or failure), the key's in-flight singleflight fetches are released without being interrupted: callers already waiting still get their result, but a read that starts after Set/Del returned starts a new fetch instead of joining one that read the upstream before the write.
Backfill guard:
Each stripe is a sync.RWMutex plus a write generation (atomic.Uint64); no per-stripe allocation, so a Client costs no more than before. Set/Del take the stripe for writing (TryLock, falling back to a ctx-aware wait) and bump the generation after the upstream write.
doFetch (single Get, and the serve-stale refresh that goes through it) and doFetchMany (GetMany and its batch refresh) take the generation before reading the upstream. To backfill they hold the stripe for reading (TryRLock), compare the generation, and fill while still holding it. So a backfill either lands before a write (which then overwrites it) or is skipped; it can never land after a Set returned. The call still returns what it fetched.
TryRLock never blocks: a backfill whose stripe has a write in progress is just skipped (one extra miss), so reads never wait on writes, and a batch can hold many stripes without a lock order. GetMany holds all its stripes across the value write and the not-found write.
Fixed size, no per-key state, nothing to clean up. Two keys sharing a stripe can serialize each other's writes or skip each other's backfill; both only cost latency or one extra miss, never correctness.
SetMany / DelMany on Client: not added. #11 does not give Client batch writes (only GetMany; the backends' SetMany/DelMany are used internally for write-back), so there is no batch write path to order. If one is added later it should go through the same write path per key.
Behavior change (intended) and impact on existing callers
before
after
Set when the upstream write fails
this layer keeps the new value, error returned
this layer has no entry (and no cached not-found), error returned
when a Set becomes visible in this layer
before the upstream write
after the upstream write succeeds (one upstream write later)
Del when the upstream delete fails
this layer deleted + not-found cached
this layer deleted, no not-found cached
Del/Set when this layer's write fails
error, upstream untouched
upstream already written; this layer's entry dropped (best effort); error returned
two concurrent Sets of one key, same Client
layers could keep different winners
serialized; layers agree
a read that fetched before a concurrent Set
could overwrite the newer value in this layer
returns what it fetched, does not write it back
a Set/Del of a key whose backfill is in progress
ran concurrently
waits for that backfill's local write (this layer only); if its ctx ends first, returns the ctx error, writes nothing upstream, and drops this layer's entry once the stripe is free
a Set/Del that has to wait for another write to its stripe
ran concurrently
waits; if its ctx ends first, same as above
GORMCache on MySQL
Get/Del failed with a syntax error (key is reserved); on a *_ci key column, keys differing in case shared a row and could read each other's value
key quoted; reads return only the row of exactly the requested key; Migrate creates new tables with utf8mb4_0900_bin (MySQL 8.0.17+, checked before creating) and returns an error for an existing table whose key column uses another collation (the error includes the ALTER)
a read that starts after Set/Del returned, while an older fetch of the key is in flight
joined the older fetch and could return the pre-write value
starts a new fetch
What callers may notice:
Code that relied on Set "succeeding locally even if the lower layer is down" now gets a cache miss for that key instead (the error was already returned before; only what is left behind changes).
Set/Del of the same key from many goroutines no longer run in parallel against the upstream. Different keys still do (unless they share one of 4096 stripes).
An upstream Cache[T] whose Set/Del calls back into the sameClient with any key of the same stripe (not only the same key) would now block until its ctx ends (it holds the stripe lock). Nothing in this repo does that; layered Clients are fine because each layer has its own stripes.
While Set/Del runs, this layer's readers still see the old value (after Del, the deleted one) until this layer's own write lands: the upstream changes first. Once the call returns, the window is closed.
A failed write's cleanup of this layer runs on a detached ctx (bounded by fetchTimeout), so it still happens when the write failed because the caller's ctx was canceled.
Single-process guarantees only: separate processes sharing a lower layer can still briefly disagree in their memory layers, as before.
A Set/Del that gives up waiting writes nothing upstream, but its lock is still taken in the background and then drops this layer's entry like any failed write, so a cache-aside Del after the request was canceled always clears this layer (at worst a moment after it returns).
Suggest releasing this as a minor version (the repo has no tags yet; the version bump is the signal), with the table above in the release notes.
Tests (write_order_test.go, TDD, -race)
TestSetWritesUpstreamBeforeThisLayer: while the upstream is being written, this layer still has the old value.
TestSetUpstreamFailureDropsThisLayersEntry: upstream Set fails → neither the new nor the old value stays, exact error message; a cached not-found is dropped too.
TestLayerWriteFailureAfterUpstreamSuccess: the upstream write succeeds but this layer's Set/Del fails → the upstream keeps the change, the error is returned, this layer's entry and cached not-found are dropped.
TestDelUpstreamFirst: order, idempotence; upstream Del fails → this layer's entry dropped, no not-found cached.
TestBackfillDoesNotOverwriteAConcurrentSet: a controllable source holds the read after it has read old; a cache-aside Set(new) completes; the read returns old but the backend keeps new. Runs for Get, GetMany, the serve-stale refresh, and a not-found backfill (no not-found cached over the new value).
TestBackfillIsSerializedWithSet: the backfill passes its check and is held just before writing old; a Set(new) starts. The Set must wait for the backfill, so no reader sees old after the Set returned, and the next read sees new. Get and GetMany.
TestFailedWriteInvalidatesWithACanceledCtx: the caller's ctx is canceled while the upstream is written; the cleanup still drops this layer's entry on a backend that refuses a done ctx.
TestReadAfterWriteDoesNotJoinAnOlderFetch: older reads hold a fetch in every slot after reading old; after Del returns, a new Get/GetMany starts its own fetch and sees the delete. WithFetchConcurrency 1 and 4.
TestWriteWaitingForTheStripeRespectsCtx / TestWriteWaitingForABackfillRespectsCtx: a Set stuck behind a hung write or a held backfill returns its ctx error and writes nothing; a free stripe is written even with a done ctx.
TestDelWithADoneCtxStillInvalidates: cache-aside Del after the request ctx was canceled still invalidates an in-memory layer.
TestGivenUpDelStillInvalidates: a canceled Del that has to wait behind another key's backfill in its stripe returns the ctx error, and the old value is dropped once the stripe is free.
TestReadAfterDelDoesNotJoinAFetchStartedDuringIt: with double-check, a fetch claimed while this layer was being deleted is released too, so a Get after Del returned sees the delete.
TestRandomInterleavingsNeverReadOlderThanACompletedWrite: one writer per key Sets/Dels increasing versions through L1 → L2 → a source with random latency while readers Get/GetMany; a read that starts after write n returned never sees anything older, and once quiet every layer agrees with the source. With and without a not-found cache and double-check; it catches a disabled flight release in most runs.
TestWriteThroughBackfillDuringUpstreamWrite: in L1 → L2, a Get that read L2 while L1's Set was writing L2 does not backfill the old value (pins where the generation is bumped).
TestInterleavedSetsKeepLayersConsistent: 20 rounds of 20 concurrent Set/Del through L1 → L2 with random write latency; L1 never holds a value L2 does not, and a read returns L2's value. (Fails consistently with the stripe lock removed.)
go test -short -race ./... and go test -p=1 ./... pass; golangci-lint v2.13.2: 0 issues. README / README_ZH: new "Write order and consistency" notes under Write Propagation.
Callers often need dozens of keys at once. With only Client.Get, every miss
hits the upstream separately, and callers who batch around the Client lose
singleflight, DoubleCheck, Not-Found caching and serve-stale.
- BatchUpstream[T] (optional): GetMany(ctx, keys) for upstreams that answer
a batch in one call
- BatchCache[T] (optional): GetMany/SetMany/DelMany for backends; implemented
by RistrettoCache, SyncMap, RedisCache (pipeline) and GORMCache (IN query,
multi-row upsert)
- Client.GetMany: same semantics as Get per key; claimed keys are fetched
with one upstream call; partial failures are reported as *BatchError
- Client implements BatchUpstream, so batches travel down layered clients
- Get and GetMany share one singleflight; the x/sync singleflight.Group is
replaced by a small flight group whose claim is synchronous, which is what
lets GetMany claim keys one by one without deadlocking overlapping batches
TestClientStaleHandling, TestNotFoundCacheStale and TestEntryWithClient
counted upstream calls in a plain int written by the background refresh
goroutine and waited for that refresh with a sleep, so they failed under
-race on main too. Count with atomic.Int64 and wait until no background
refresh is in flight before reading its result or closing the backend.
Test-only; no library change.
Review fixes for GetMany:
- flightGroup: when the upstream calls runtime.Goexit, release the claim
without publishing, as x/sync/singleflight does. Before, the claim was
never released and every later Get of that key hung until its ctx.
GetMany's batch fetch does the same; its per-key fallback reports the
key as failed instead of yielding a zero value.
- get_compat_test.go pins the observable single-key behavior (coalescing,
fetch-concurrency slots, panic and error propagation, fetch timeout,
caller cancellation, test hook counts, double-check, not-found cache,
serve-stale, write propagation). It uses only the pre-GetMany API and
passes unchanged against the x/sync/singleflight implementation on main.
- GetMany treats a backend or not-found cache that answers a whole batch
with ErrKeyNotFound as a miss instead of a final not-found.
- RedisCache.GetMany reads each pipelined command's own error, so one
failing key (e.g. WRONGTYPE) no longer fails the whole batch.
- GORMCache batch methods chunk to 1000 keys per statement, staying under
the bound parameter limits of SQLite and PostgreSQL.
- Only an unwrapped *BatchError makes a batch error partial. A wrapped or
joined one may sit next to an error that failed the whole batch; reading
it as partial cached not-found entries for keys the upstream never
answered.
- A panic in the batch upstream fails only the keys still being fetched,
not the ones the double-check already found.
- The batch double-check reads with the request ctx, like Get.
- Writing back key by key keeps going after one key fails, and
RedisCache.SetMany writes the values it can encode, like Get per key.
- A serve-stale batch refresh bounds its wait by fetchTimeout, so one
fetch that never finishes cannot pin the other keys' refresh slots.
…ut handling
- WithGetManyConcurrency (default 16) bounds the concurrent upstream.Get
calls one GetMany makes when the upstream has no batch support. Before,
every missing key got its own goroutine and its own upstream call at once.
- A whole-batch upstream error fails every key even when an ErrKeyNotFound
sits in its chain (wrapped or joined). Before, such an error was read as
"every key is missing" and cached as not-found.
- The fetch timeout starts after the double-check, like Get, so a slow
re-check no longer eats into the upstream's time.
If this condition is not reached, require.Eventually calls FailNow in this helper goroutine, so g.release is never closed and getConcurrently blocks until the global test timeout. Use the non-fatal assertion here so the gate is still released and the regression fails promptly.
This issue also appears in the following locations of the same file:
line 161
line 270
Qualify summary about single upstream call
README.md:17
This summary promises one upstream call unconditionally, but when the upstream does not implement BatchUpstream, the implementation makes one bounded Get call per miss. Qualify the claim so the feature list matches the fallback behavior documented below.
Qualify unconditional single upstream call claim
README_ZH.md:17
这里无条件写成“一次上游调用”,但上游没有实现 BatchUpstream 时会按 miss 逐个调用 Get。请注明该条件,使功能摘要与后文描述的 fallback 行为一致。
🧠 Review effort: Balanced
Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.
…ound
- A whole-batch error from the backend or the not-found cache fails
every key, like one from the upstream already did. Before, an error
with an ErrKeyNotFound in its chain (or a bare one) was read as
"every key is missing", hiding e.g. a connection error behind a fetch.
- The per-key error keeps the original error, so errors.Is/As still find
its causes; IsErrKeyNotFound reports false for it. Before, the upstream
path kept only the error's message.
- README: GetMany fetches misses in one call only with BatchUpstream.
- Tests release their gates even when the wait assertion fails.
… document the BatchCache contract
- A double-check whose batch read fails falls through to the upstream,
like Get, even if the error looks like a cached fresh not-found.
- A Get that joins a GetMany's fetch gets that batch's failure, not a
not-found, and nothing is cached as not-found.
- README and BatchCache doc: a backend's GetMany follows the
BatchUpstream error contract, and a per-key error in BatchError is
never a not-found.
- NewRedisCache and NewGORMCache take a config struct, not positional args
- Cache[T].Set has no ttl parameter; expiry belongs to the Client or the backend config
- missing keys are signalled with *cachex.ErrKeyNotFound, checked via IsErrKeyNotFound
- the string JSON transform is StringJSONTransform[T], JSONTransform takes Cache[[]byte]
- Without BatchUpstream, GetMany now runs each claimed key through the
same path as Get: its own double-check, fetch timeout and write-back,
and its waiters get it as soon as it is fetched. Before, the whole
batch shared one fetch timeout (late keys failed with deadline
exceeded) and a Get joining one key waited for every other key. A key
whose upstream calls runtime.Goexit is now released like Get.
- A failed not-found cache write no longer stops the backend write-back
of the other keys, nor the delete of keys gone upstream.
- Remove a leftover branch that could no longer run since whole-batch
errors are marked.
- README: a compilable BatchUpstream example (with Get, in the Quick
Start's type), the timeout of a batch call, and which built-in
backends fall back to per-key calls.
- Tests for WithFetchConcurrency, a stale not-found and the refresh
shared by Get and GetMany.
…ed GetMany
- Without BatchUpstream, GetMany claims each key only when a slot is free
and then fetches it exactly as Get would. Before, every key was claimed
up front, so a plain Get of a key still queued there waited for the
whole queue; and a canceled GetMany kept fetching every queued key.
- A serve-stale refresh of many keys key by key is no longer bounded as a
whole (each key has its own fetch timeout, as in Get's refresh), and a
batch refresh no longer logs keys it merely stopped waiting for as
failed.
- GORMCache.GetMany answers under the requested key when a
case-insensitive key column (e.g. MySQL's _ci collations) returns the
row under its stored key, as Get does.
- README: the error-handling example compiles, and the batch example
notes chunking very large id lists.
…ey column
Cache keys are case-sensitive, but on MySQL the key column Migrate
creates inherits the table's (usually *_ci) collation, so "abc" and
"ABC" are one row: Get("abc") returned ABC's value, and Set("abc")
wrote into ABC's row. This was already so for Get on main.
- Get and GetMany only return a row stored under exactly the requested
key (replacing the case-folding GetMany did since c4ba2d3).
- Set and SetMany rewrite the key column on conflict, so a shared row
belongs to the key written last.
- README and GORMCacheConfig: give the key column a byte-exact collation
(MySQL: *_bin); without it keys differing in case only cost misses.
"key" is a reserved word in MySQL, and the conditions were raw SQL
fragments ("key = ?", "key IN ?"), so Get, GetMany, Del and DelMany
failed with a syntax error on MySQL; this was already so on main for
Get and Del. Build them from clauses so GORM quotes the column. SQLite
accepts the bare word, so the test checks the SQL itself.
Checked against MySQL 8.4 (default *_ci collation), PostgreSQL 16 and
Redis 7: the BatchCache contract, 2500 keys across statements, keys
differing only in case, and Client.GetMany over each.
…ide a batch refresh that times out
- setWithoutUpstream/delWithoutUpstream no longer stop at a failed
not-found cache write: the backend is still written, or its old value
deleted, as the batch write-back already does. Before, Get (and
GetMany without a BatchUpstream) kept a deleted value when the
not-found cache was down.
- A batch serve-stale refresh that does not finish within the fetch
timeout is logged at Warn instead of not at all.
- Docs: the write-back is batched only with a BatchUpstream.
Set and Del now write the upstream first (when it is a Cache[T]) and this
layer only after it succeeds. If the upstream write fails its state is
unknown, so this layer drops its entry and cached not-found for the key
and returns the error; the next read goes down. A failed Del drops this
layer's entry without caching a not-found.
Concurrent Set/Del of one key through the same Client are serialized
(striped locks), so every layer applies them in the same order.
Backfills are guarded by a per-key write generation (striped atomic
counters, fixed size): Get, GetMany and the serve-stale refresh take it
before reading the upstream, skip the backfill if a Set/Del happened since,
and undo a backfill that raced one. A read that fetched an old value still
returns it but no longer overwrites a newer one.
Behavior change (intended): a Set becomes visible in this layer only after the write
below succeeds; a failed upstream write no longer leaves the new value in
this layer (it leaves no entry); a failed upstream Del no longer records a
not-found in this layer.
- A backfill (Get, GetMany, serve-stale refresh) now holds its key's write
stripe for reading while it checks the write generation and fills, and
Set/Del hold it for writing. The fill either lands before a write (which
then overwrites it) or is skipped; it can no longer write an old value
after a Set returned, so no reader sees it. The after-the-fact undo is
gone. A backfill uses TryRLock and is skipped while a write to the
stripe is in progress, so reads never wait on writes.
- GetMany holds every stripe of its batch for the whole fill, so the batch
write and its not-found write are atomic with respect to Set/Del too.
- The cleanup after a failed write runs on a detached ctx bounded by the
fetch timeout, so a write that failed because its ctx was canceled still
drops this layer's entry on ctx-aware backends (Redis, GORM).
- README: the old value of this layer stays visible to readers until this
layer's own write lands; the docs no longer claim otherwise.
- README: the write generation and lock are per stripe (1,024 per
Client), not per key, so keys sharing a stripe serialize their writes
and can skip each other's backfills.
- Test that when the upstream write succeeds but this layer's Set or Del
fails, the upstream keeps the change, the error is returned, and this
layer's entry and cached not-found are dropped.
…aiting to write respects ctx
- After the upstream write, Set/Del release the key's in-flight fetches
(without interrupting them), so a read that starts after Set/Del
returned fetches anew instead of joining a fetch that read the old
value. Before, e.g. a Get after Del (no not-found cache) could still
return the deleted value.
- A Set/Del waiting for another write to its stripe gives up when its ctx
is done, and a done ctx writes nothing. Before, the wait ignored ctx,
so one hung upstream write blocked the others past their deadlines.
- Docs: the failure cleanup outlives ctx for at most the fetch timeout.
- Test that a layered Set's generation bump comes after the upstream
write.
…espect ctx while waiting for a backfill
- Waiting for the stripe now covers both another write and a backfill
writing this layer, and gives up when ctx is done. Before, a backfill
(e.g. a GetMany holding many stripes) blocked Set/Del past their ctx.
- A Set/Del that need not wait is applied even with a done ctx, as before
this PR, so an invalidating Del after the request was canceled still
lands in in-memory layers.
- Drop the per-stripe channel: no extra allocations per Client.
- Test releasing in-flight fetches for Get and GetMany with
WithFetchConcurrency 1 and 4.
…d late fetches are released too
- A Set/Del that gave up waiting for its stripe wrote nothing, so this
layer kept the old value: a cache-aside Del after the request was
canceled could miss whenever another key of the stripe was being
written or backfilled. The lock it gave up on is still taken in the
background, and then drops this layer's entry like any failed write.
- Release the key's in-flight fetches again after this layer is written:
a fetch claimed in between may have double-checked the old value, and a
read after Set/Del returned could join it.
One writer per key Sets and Dels increasing versions through L1 -> L2 ->
a source with random latency, while readers Get and GetMany. A read that
starts after write n returned must see version n or a later one that had
started (a Del being the version "absent"), and once quiet every layer
agrees with the source. Runs with and without a not-found cache and
double-check. It catches a disabled dropFlights in most runs and a
backfill that skips the generation check occasionally.
… MySQL and PostgreSQL
- Migrate creates the table with utf8mb4_bin on MySQL, whose default
collations are case-insensitive while cache keys are not. Only a new
table gets it; an existing one is left as it is (README shows the
ALTER), and GORMCache still never serves another key's row there.
- TestGORMCacheOnRealDatabases runs MySQL 8.4 and PostgreSQL 16 in
testcontainers (skipped without Docker; CI has it): the BatchCache
contract, a batch over several statements, case-sensitive keys, an
existing case-insensitive MySQL table, and Client reads and writes.
- Fix a test that could see the stripe free just before a given-up
write's background lock took it.
…RMCache.SetMany keep the values that encode
- Over a lower Client, a GetMany's batch call is no longer cut off by one
fetch timeout: the lower Client bounds each of its own fetches, and
keys still queued there failed with the upper deadline instead.
- GORMCache.SetMany writes the values that encode and reports the others,
as RedisCache does; one bad value no longer drops the whole write-back.
- Docs: the keys of one batch call are answered together; a large
GetMany write-back holds its stripes while the backend writes; and
utf8mb4_bin ignores trailing spaces.
utf8mb4_bin pads, so "a" and "a " were one row. utf8mb4_0900_bin
compares trailing spaces too; it needs MySQL 8.0.17 or later, which is
now what GORMCache supports on MySQL.
…k, and bound a batch over a lower Client by its own budget
- SetMany inserted rows in Go map order, so two concurrent SetMany calls
with overlapping keys locked rows in opposite orders and deadlocked on
MySQL and PostgreSQL (the aborted write-back left its keys uncached).
Rows are now sorted by key; a concurrent test runs on both databases.
- A GetMany over a lower Client was not bounded at all, so a lower
backend that hung kept the keys claimed in this layer. It is now
bounded by what the lower Client's own fetches may take (a queue of
per-key fetch timeouts, a batch, or a deeper Client's budget), plus a
fetch timeout for this layer's reads and write-back.
…d are retried as deadlock victims
Measured with 16 workers mixing SetMany and DelMany over shared keys:
- PostgreSQL: DELETE locked rows in index order, which follows the
column's collation (en_US.utf8 in the official image), while SetMany
locks in byte order; about half the operations deadlocked, each
stalling for deadlock_timeout. DelMany now locks its rows in byte
order first (SELECT ... ORDER BY key COLLATE "C" FOR UPDATE).
- MySQL: with one lock order a few writes (3 in 1280) were still picked
as deadlock victims through gap locks, which InnoDB expects callers to
retry. GORMCache writes are now retried up to 5 times on a deadlock,
except inside a caller's transaction, which the deadlock rolled back.
Both gave 0 deadlocks; serializing writes per table (a table lock) also
did, at 32% (MySQL) and 46% (PostgreSQL) lower throughput.
The encode/decode refactor read GET replies with Result() and converted
the string to []byte, copying every JSON, binary and []byte value once
more. Read them with StringCmd.Bytes(), which shares the reply's memory,
as Get on main did.
…tch writes are best effort and report failed keys
- When the connection could not be made, go-redis's pipeline Exec
returned the error but left the commands it never sent without one,
and GetMany read them as empty values: an outage served "" (or a
decode error) as a hit. Pipeline errors that Redis did not reply to a
command are now set on every command without an error.
- SetMany and DelMany are best effort on every built-in backend, as the
BatchCache contract now states: each key is tried, and the keys that
failed are reported in a *BatchError. GORMCache runs one statement per
chunk instead of one transaction for the whole call, and goes on after
a failed chunk (in GetMany too). The per-key fallback for a plain
Cache reports by key as well.
- RedisCacheConfig.ChunkSize and GORMCacheConfig.ChunkSize (default
DefaultChunkSize, 1000) split large calls into pipelines or statements
sent one after another; GORMCache caps it at 10000.
…d fetch from its leader's cancellation
- A fetch whose upstream calls runtime.Goexit now publishes an error to
every waiter, single-key or batch, as a panic already did. Before,
waiters (like x/sync's DoChan, golang/go#52557) waited for their own
ctx, forever without a deadline. One helper publishes each claimed
flight exactly once, for Get and GetMany alike.
- The batch serve-stale refresh no longer bounds its wait: every fetch
now answers its waiters.
- A claimed fetch serves every waiter of the key, so it runs on the
leader's ctx without its cancellation (values kept) and without its
GORM transaction. Before, a leader that gave up failed the double-check
and fetched again although the cache had the value, and a GORM
write-back ran inside the leader's transaction. The double-check is
bounded by the fetch timeout.
…yFetchConcurrency bounds one GetMany's upstream requests
- WithGetManyChunkSize(n) sends the keys one GetMany fetches from a
BatchUpstream in calls of at most n keys, run concurrently; each
call's keys are answered as soon as it returns, and a panic or exit
fails only that call's keys. Zero (default) keeps one call.
- WithGetManyConcurrency is renamed WithGetManyFetchConcurrency (and its
default DefaultGetManyFetchConcurrency): it bounds the upstream
requests one GetMany has in flight, per-key Gets or chunk calls alike,
as opposed to WithFetchConcurrency, which bounds the fetches of one key.
- A lower Client's budget accounts for its chunking.
…stripe since the lookup; 4096 write stripes
- DoubleCheckAuto used to turn the double-check on only when a not-found
cache was configured, which has little to do with when it pays off.
Each stripe now also counts this Client's backfills; a request notes
the count (with the write generation) before reading the layer, and a
claimed fetch re-reads only if it changed. Measured with a hot key over
a 1 ms cache: 19 upstream calls (Enabled 18, Disabled 30); over 8000
cold keys: 1.004 cache reads per Get (Enabled 2.0, Disabled 1.0).
DoubleCheckEnabled and DoubleCheckDisabled keep their meaning.
- writeStripeCount goes from 1024 to 4096: a quarter of the cross-key
write waits, skipped backfills and needless re-reads, for 160 KB per
Client.
- Docs: what the double-check saves, measured, instead of "eliminates
the race window".
…table
utf8mb4_0900_bin needs MySQL 8.0.17 or later and MariaDB lacks it, so
creating the table there failed with an opaque "Unknown collation".
When the table does not exist yet, Migrate now reads VERSION() and
returns an error naming the version; an existing table is left alone,
as before.
…n, multi-instance limits, and the follow-ups
- README: a table of the three outcomes (found, possibly nil; does not
exist; failed) for Get and GetMany, and testing presence with v, ok.
- README: GetMany returns its whole result at once; the backend step
says how each value read is handled, without "returned first".
- README: the write order guarantees hold within one process; with
several instances, memory layers and a shared layer rely on TTLs,
and why.
- TODO: Redis leases against stale backfills into a shared cache, and
invalidation broadcast over Redis Pub/Sub.
…ch result shape measurements
Behind the bench build tag, so go test ./... skips them. The deadlock
script runs MySQL 8.4 and PostgreSQL 16 in testcontainers and compares
unordered writes, one lock order, GORMCache (one lock order + retry) and
a table lock.
- GLOSSARY.md: the project's terms, each with the English term used in
code.
- docs/design.md and docs/design/: the read path, singleflight, write
order and striped locks, batch reads, backends, and consistency across
instances, with sequence and data flow diagrams.
- docs/adr/: eleven decisions, including where cachex deliberately
departs from x/sync/singleflight.
- docs/faq.md replaces the README FAQ; docs/todo.md replaces TODO.md and
TODO_ZH.md, with TTL jitter first; docs/research/ holds the
measurements behind the decisions.
- README.md and README_ZH.md link to the docs; CLAUDE.md lists what to
read first, the conventions and the invariants.
…ackages
internal/flight is the singleflight shared by Get and GetMany (Claim,
Finish, Drop); internal/stripe is the striped lock with its write
generation, backfill count and ctx-aware Lock. Both get their own unit
tests. They stay internal until another package needs them; moving them
to a public path later is a change of import path only. The publish-once
layer (claimed) stays in cachex, as it logs through the Client.
…lock strictly
Each strategy now asserts what makes the run valid: the unordered writes
must reproduce deadlocks, GORMCache must let none reach the caller, and
the table lock must admit one write at a time; a sample deadlock error
shows what was counted. The MySQL table lock now holds its named lock
from before BEGIN to after COMMIT on one connection. Two full runs and
the checks of the checks are in the research report.
… utf8mb4_0900_bin
On any other collation keys differing in case or trailing spaces share a
row and rows are not locked in byte order. The error includes the ALTER
statement. Docs state that only each database's default isolation level
is supported, and why the deadlock retry stays on byte-exact tables.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Callers often need dozens of keys at once (e.g. the cached verdicts for a batch of content items). Today there is only
Client.Get, one key at a time:WHERE key IN (...), or one API call for many items).New API (all additive)
BatchCacheis implemented byRistrettoCache,SyncMap,RedisCache(pipelines ofGET/SET/DELrather thanMGET, so it also works across slots on Redis Cluster) andGORMCache(WHERE key IN (...)and multi-row upserts), both split into chunks ofChunkSizekeys.SetMany/DelManyare best effort on every backend: each key is tried and the failed keys are listed in a*BatchError. Backends that don't implement it (BigCache,Transform, custom ones) fall back to per-key calls.ClientimplementsBatchUpstream, so with layered clients a batch goes down as one call per layer: memory → Redis/DB → data source.Semantics
GetManybehaves like callingGetfor each key, only batched:WithServeStale→ into the result, and all stale keys of the call are refreshed together in the background (deduplicated throughasyncRefreshinglikeGet); rotten or stale without serve-stale → refetch; miss → next step.Get. Keys that don't exist are simply absent from the result.Getuses (samesfKey, soWithFetchConcurrencyslots apply per key). Keys already in flight from aGetor anotherGetManyare waited for. AGetand aGetManyof the same key, or two overlappingGetManys, fetch the shared key once.Get(in the defaultDoubleCheckAuto, only keys whose stripe thisClientwrote since the lookup) (a fresh value or a fresh cached not-found settles the key; anything else, including a failed re-check, goes upstream).upstream.GetManyif the upstream implementsBatchUpstream, split into calls ofWithGetManyChunkSizekeys if set, otherwise asupstream.Gets each run exactly asGetwould; at mostWithGetManyFetchConcurrency(default 16) requests at a time, each answering its keys as soon as it returns. As withGet, the fetch timeout starts after the double-check. A panic orruntime.Goexitfails the keys still being fetched (not those already answered).SetManywhen supported) after deleting their Not-Found entries; not-found keys → Not-Found cache + backend delete. As withGet'sdoFetch, this only touches this layer, never the upstream.Errors: the map always holds every key that succeeded. If any key failed (backend, upstream, ctx), the error is a
*BatchErrorwith the failed keys. I chose this over "one error fails everything" because a batch is usually independent items, and one bad key shouldn't throw away the rest; it also lets a lowerClientreport partial failures to the layer above through the sameBatchUpstreamcontract. Corrupt entries in Redis/GORM are reported per key the same way instead of failing the whole read.Singleflight implementation change (internal)
golang.org/x/sync/singleflightcan't tell the caller synchronously whether it became the owner of a key:DoChandecides under its lock but runsfnin a goroutine.GetManyneeds that answer to build its batch, and waiting for it would deadlock two batches that each own a key the other one is waiting for. SoGetandGetManynow share a tinyflightGroup(claimreturns(flight, isOwner);finishreleases the key before publishing the result, which is the same ordering as x/sync and what the existing DoubleCheck tests rely on).Get's behavior, hooks and error messages are unchanged; all existing tests pass unmodified. (x/syncstays ingo.modfor the benchmark's semaphore.)Compatibility
Additive only: new interfaces, new methods, new error type, one new option.
Get/Set/Delbehavior is unchanged.RedisCache.Get/Setwere refactored to share encode/decode helpers with the batch methods, with the same behavior.Write-back and write order:
GetMany's write-back goes throughsetManyWithoutUpstream/delManyWithoutUpstream, the batch counterparts ofsetWithoutUpstream/delWithoutUpstreamused bydoFetch; both are guarded by the write generation described under Write order below.Tests
batch_test.go(TDD, all run under-race, stressed with-count=30):BatchCachecontract against SyncMap, Ristretto, Redis (+prefix), GORM (+prefix); a corrupt Redis entry doesn't hide the othersBatchUpstream, each key is fetched exactly asGetwould (own double-check, fetch timeout and write-back, published as soon as it is fetched), claimed only when aWithGetManyFetchConcurrencyslot is free; a canceledGetManystarts no more keys. Backend batch read/write is used when availableGetandGetManyracing on the same key → one fetch; two overlappingGetManys → shared key fetched onceGetMany, honored byGetManyandGet, rotten entries refetchedGORMCachequotes itskeycolumn (keyis reserved in MySQL, soGet/GetMany/Del/DelManyfailed with a syntax error there, already onmain); a failed not-found cache write no longer stops the single-key write-back either (Getkept a deleted value when the not-found cache was down); a batch refresh that does not finish within the fetch timeout is logged at Warn. Verified against real MySQL 8.4 (default*_cicollation), PostgreSQL 16 and Redis 7: theBatchCachecontract, 2500 keys across statements, keys differing only in case, layeredGetManyBatchUpstreamaGetnever waits for another caller'sGetManyqueue, and a key's fetch has its own timeout (both were per batch); a failed not-found cache write no longer skips the backend write-back of the other keys; a refresh of many keys no longer logs false failures;GORMCachenever serves another key's row on a key column that is not byte-exact (MySQL's default*_cicollations madeGet("abc")returnABC's value, already onmain):Get/GetManyonly return the row stored under exactly the requested key, and upserts rewrite the key column so a shared row belongs to the key written last. README asks for a*_bincollation on MySQL; README batch examples compileWithGetManyFetchConcurrency; a whole-batch error that wraps or joins anErrKeyNotFoundfails every key and caches no not-found; a slow double-check does not eat the upstream's fetch timeout; the same whole-batch rule now also covers the backend and the not-found cache (such an error is a failure, not a batch of misses), and the per-key error keeps the original error forerrors.Is/errors.AswhileIsErrKeyNotFoundreports false for it; a double-check whose batch read fails falls through to the upstream likeGet, and aGetthat joins a failed batch fetch gets the failure, not a not-foundExisting tests pass unmodified with
go test -short -p=1 ./...(the CI command) and without-short; golangci-lint v2.13.2: 0 issues.Pre-existing on
main:TestClientStaleHandling,TestNotFoundCacheStaleandTestEntryWithClientreported data races under-race(test counters touched by background refreshes, and a refresh outliving the test'sClose). A separate test-only commit fixes them (atomic counters, wait for the background refresh before reading its result or closing); no library code changes. The full suite is now clean under-race.Backward compatibility
Existing callers only use
Get/Set/Del, the backends,WithFetchConcurrency,WithDoubleCheck, Not-Found caching, serve-stale, write propagation and the test hooks. None of that changes. What I checked, line by line againstmain:Single-key
Getafter replacingx/sync/singleflightwithflightGroupmain(x/sync)fnin a new goroutine, others joinclaimunder a mutex, owner fetches in a new goroutineWithFetchConcurrency(n)makeSFKeypicks a random sloti:keymakeSFKey, same slot keysfn→panic during upstream fetch: …to every waiter, logged with key and stackruntime.Goexitin the upstreamupstream fetch exited without returning (runtime.Goexit)Forgetdoneclosedcontext cancelled during fetch for key: …; fetch keeps running onWithoutCancel(ctx)fetchTimeoutWithTimeout(WithoutCancel(ctx))around the fetch, after the double-checkfetchTimeoutDoubleCheckAuto(default)Clientwrote the key's stripe since the lookup (hot key over a 1 ms cache: 19 upstream calls vs Enabled 18 / Disabled 30; 8000 cold keys: 1.004 cache reads perGetvs Enabled 2.0)beforeSingleflightStartper caller,afterSingleflightStartper fetch,afterSingleflightEndper caller that received the resultpanic during upstream fetch,async refresh failed,failed to set/delete cache entryEquivalence tests:
get_compat_test.gopins the observable single-key behavior: 20 concurrent misses → 1 upstream call; hook counts; upstream error shared and not cached (exact message); upstream panic (message, log line, key released);runtime.Goexit(key released);fetchTimeout(exact message, upstream sees the deadline); caller cancellation (exact message, fetch outlives the caller and fills the backend);WithFetchConcurrency(3)(≤ 3 fetches); DoubleCheck race window (enabled → 1 fetch, disabled → 2); Not-Found fresh/stale/rotten (exact messages,Cached/CacheState, upstream counts,Setclears it,Delrecords it); serve-stale (20 stale reads → 1 refresh); backend error; write propagation through layers. The file uses only the pre-GetManyAPI and passes unchanged onmain(I ran it against amainworktree,-race -count=3) and on this branch, so it is the baseline; all pre-existing tests also pass unmodified.Other files:
RedisCache.Get/Setnow share encode/decode helpers; the decode paths (string,[]byte,BinaryUnmarshaler, JSON) and error wrapping are the same as before.RistrettoCache,SyncMap,GORMCacheonly gain methods.Interplay that is new (only reachable by calling
GetManywith aBatchUpstream): aGetthat joins a fetch aGetManystarted gets that batch's result, so a key the batch upstream leaves out comes back as a plainErrKeyNotFound(not the lower layer'sCached: trueone), andfetchTimeoutcovers the whole batch. Callers that never callGetManycannot hit this.GORMCache deadlocks: concurrent batch write-backs from several instances used to deadlock. Measured with 16 workers mixing
SetMany/DelManyover shared keys:deadlock_timeoutSetMany; on PostgreSQLDelManylocks rowsORDER BY key COLLATE "C" FOR UPDATE, since its index follows the column'sen_UScollation)WithGORMTxtransaction) — shippedGORMCache on real databases: the
keycolumn is now quoted (it is reserved in MySQL;Get/Delonmainfailed there with a syntax error), reads only return the row stored under exactly the requested key, upserts rewrite the key column, andMigratecreates MySQL tables withutf8mb4_0900_bin(MySQL 8.0.17 or later is now required); on an existing tableMigratechecks thekeycolumn's collation and returns an error with theALTERstatement unless it isutf8mb4_0900_bin. Only each database's default isolation level is supported.TestGORMCacheOnRealDatabasesruns MySQL 8.4 and PostgreSQL 16 through testcontainers (skipped without Docker; GitHub Actions has it): theBatchCachecontract, a batch over several statements, case-sensitive keys,Migraterejecting an existing case-insensitive MySQL table until it is converted, andClientreads and writes. This adds testcontainers and the MySQL/PostgreSQL GORM drivers togo.mod; only tests import them.Benchmark
BenchmarkGetManyVsGet: 100 keys, all missing, upstream costs 1ms per call whether it answers one key or many (Apple M-series,-benchtime 20x -count 3):GetManyGetWrite order (formerly #12)
Why
Two ways a cache layer can end up holding a value its upstream does not have, and keep it until it expires:
Setwrote this layer before the upstream. If the upstream write failed, this layer kept the new value while the layer below (Redis, a DB-backed cache) kept the old one. Every reader of this layer then saw a value that "never happened" below.Getreadsoldfrom the upstream; before it writesoldback to this layer, aSet(new)completes on both layers; then the backfill lands and this layer holdsoldwhile the upstream holdsnew. Same forGetManyand the serve-stale background refresh.There was also no ordering between two concurrent
Sets of one key, so L1 and L2 could end up with different winners.What changes
Set/Delorder (Client.write):Cache[T]), this layer only after it succeeds.Dellikewise drops this layer's entry but records no not-found (the upstream may still have the key).Delstays idempotent. On success it caches a not-found and deletes the backend entry, exactly as before.Setstill clears the not-found entry together with the backend write, just after the upstream write instead of before it. This is the safer order: a not-found is only cleared when there is a value below to read, and on failure it is cleared anyway so a read goes down instead of trusting a stale not-found.Set/Delof one key through the sameClientare serialized by a striped mutex (4096 stripes, keyed bymaphash), so every layer applies them in the same order.Delafter the request was canceled still lands in in-memory layers.Set/Delreturned starts a new fetch instead of joining one that read the upstream before the write.Backfill guard:
sync.RWMutexplus a write generation (atomic.Uint64); no per-stripe allocation, so aClientcosts no more than before.Set/Deltake the stripe for writing (TryLock, falling back to a ctx-aware wait) and bump the generation after the upstream write.doFetch(singleGet, and the serve-stale refresh that goes through it) anddoFetchMany(GetManyand its batch refresh) take the generation before reading the upstream. To backfill they hold the stripe for reading (TryRLock), compare the generation, and fill while still holding it. So a backfill either lands before a write (which then overwrites it) or is skipped; it can never land after aSetreturned. The call still returns what it fetched.TryRLocknever blocks: a backfill whose stripe has a write in progress is just skipped (one extra miss), so reads never wait on writes, and a batch can hold many stripes without a lock order.GetManyholds all its stripes across the value write and the not-found write.SetMany/DelManyonClient: not added. #11 does not giveClientbatch writes (onlyGetMany; the backends'SetMany/DelManyare used internally for write-back), so there is no batch write path to order. If one is added later it should go through the samewritepath per key.Behavior change (intended) and impact on existing callers
Setwhen the upstream write failsSetbecomes visible in this layerDelwhen the upstream delete failsDel/Setwhen this layer's write failsSets of one key, sameClientSetSet/Delof a key whose backfill is in progressSet/Delthat has to wait for another write to its stripeGORMCacheon MySQLGet/Delfailed with a syntax error (keyis reserved); on a*_cikey column, keys differing in case shared a row and could read each other's valuekeyquoted; reads return only the row of exactly the requested key;Migratecreates new tables withutf8mb4_0900_bin(MySQL 8.0.17+, checked before creating) and returns an error for an existing table whosekeycolumn uses another collation (the error includes theALTER)Set/Delreturned, while an older fetch of the key is in flightWhat callers may notice:
Code that relied on
Set"succeeding locally even if the lower layer is down" now gets a cache miss for that key instead (the error was already returned before; only what is left behind changes).Set/Delof the same key from many goroutines no longer run in parallel against the upstream. Different keys still do (unless they share one of 4096 stripes).An upstream
Cache[T]whoseSet/Delcalls back into the sameClientwith any key of the same stripe (not only the same key) would now block until its ctx ends (it holds the stripe lock). Nothing in this repo does that; layeredClients are fine because each layer has its own stripes.While
Set/Delruns, this layer's readers still see the old value (afterDel, the deleted one) until this layer's own write lands: the upstream changes first. Once the call returns, the window is closed.A failed write's cleanup of this layer runs on a detached ctx (bounded by
fetchTimeout), so it still happens when the write failed because the caller's ctx was canceled.Single-process guarantees only: separate processes sharing a lower layer can still briefly disagree in their memory layers, as before.
A
Set/Delthat gives up waiting writes nothing upstream, but its lock is still taken in the background and then drops this layer's entry like any failed write, so a cache-asideDelafter the request was canceled always clears this layer (at worst a moment after it returns).Suggest releasing this as a minor version (the repo has no tags yet; the version bump is the signal), with the table above in the release notes.
Tests (
write_order_test.go, TDD,-race)TestSetWritesUpstreamBeforeThisLayer: while the upstream is being written, this layer still has the old value.TestSetUpstreamFailureDropsThisLayersEntry: upstreamSetfails → neither the new nor the old value stays, exact error message; a cached not-found is dropped too.TestLayerWriteFailureAfterUpstreamSuccess: the upstream write succeeds but this layer'sSet/Delfails → the upstream keeps the change, the error is returned, this layer's entry and cached not-found are dropped.TestDelUpstreamFirst: order, idempotence; upstreamDelfails → this layer's entry dropped, no not-found cached.TestBackfillDoesNotOverwriteAConcurrentSet: a controllable source holds the read after it has readold; a cache-asideSet(new)completes; the read returnsoldbut the backend keepsnew. Runs forGet,GetMany, the serve-stale refresh, and a not-found backfill (no not-found cached over the new value).TestBackfillIsSerializedWithSet: the backfill passes its check and is held just before writingold; aSet(new)starts. TheSetmust wait for the backfill, so no reader seesoldafter theSetreturned, and the next read seesnew.GetandGetMany.TestFailedWriteInvalidatesWithACanceledCtx: the caller's ctx is canceled while the upstream is written; the cleanup still drops this layer's entry on a backend that refuses a done ctx.TestReadAfterWriteDoesNotJoinAnOlderFetch: older reads hold a fetch in every slot after readingold; afterDelreturns, a newGet/GetManystarts its own fetch and sees the delete.WithFetchConcurrency1 and 4.TestWriteWaitingForTheStripeRespectsCtx/TestWriteWaitingForABackfillRespectsCtx: aSetstuck behind a hung write or a held backfill returns its ctx error and writes nothing; a free stripe is written even with a done ctx.TestDelWithADoneCtxStillInvalidates: cache-asideDelafter the request ctx was canceled still invalidates an in-memory layer.TestGivenUpDelStillInvalidates: a canceledDelthat has to wait behind another key's backfill in its stripe returns the ctx error, and the old value is dropped once the stripe is free.TestReadAfterDelDoesNotJoinAFetchStartedDuringIt: with double-check, a fetch claimed while this layer was being deleted is released too, so aGetafterDelreturned sees the delete.TestRandomInterleavingsNeverReadOlderThanACompletedWrite: one writer per key Sets/Dels increasing versions through L1 → L2 → a source with random latency while readersGet/GetMany; a read that starts after write n returned never sees anything older, and once quiet every layer agrees with the source. With and without a not-found cache and double-check; it catches a disabled flight release in most runs.TestWriteThroughBackfillDuringUpstreamWrite: in L1 → L2, aGetthat read L2 while L1'sSetwas writing L2 does not backfill the old value (pins where the generation is bumped).TestInterleavedSetsKeepLayersConsistent: 20 rounds of 20 concurrentSet/Delthrough L1 → L2 with random write latency; L1 never holds a value L2 does not, and a read returns L2's value. (Fails consistently with the stripe lock removed.)get_compat_test.go(single-key behavior pinned tomain) andTestGetCompatWritePropagation.go test -short -race ./...andgo test -p=1 ./...pass; golangci-lint v2.13.2: 0 issues. README / README_ZH: new "Write order and consistency" notes under Write Propagation.