Repository navigation
feat: write the upstream before this layer, and guard backfills with a write generation - #12
Merged
Merged
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Backfills can still expose stale values briefly, and failed cleanup can retain stale entries when its context is canceled.
5 open findings
What changed in this PR
Adds upstream-first write ordering and generation-guarded backfills to improve consistency across layered caches.
Changes:
- Serializes writes using hashed mutex stripes.
- Guards single and batch backfills against concurrent writes.
- Adds concurrency tests and documents consistency semantics.
| File | Description |
|---|---|
client.go |
Implements ordered writes, invalidation, and guarded backfills. |
batch.go |
Adds generation checks to batch backfills. |
write_order_test.go |
Tests write ordering and concurrent backfills. |
README.md |
Documents the new consistency behavior. |
README_ZH.md |
Updates Chinese documentation. |
🧠 Review effort: Balanced
Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.
molon
force-pushed
the
molon/write-order
branch
from
October 8, 2026 07:33
a7f9341 to
34e4208
Compare
There was a problem hiding this comment.
🟡 Changes recommended
The local-layer failure contract lacks direct tests, and the documentation incorrectly describes striped generations as per-key state.
4 open findings
5 resolved since last review
🧠 Review effort: Balanced
Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.
molon
force-pushed
the
molon/write-order
branch
2 times, most recently
from
October 8, 2026 08:14
59b0b18 to
f1b5136
Compare
molon
force-pushed
the
molon/write-order
branch
4 times, most recently
from
October 8, 2026 10:33
02d90cf to
eb6c4f2
Compare
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.
molon
force-pushed
the
molon/write-order
branch
from
October 8, 2026 11:25
eb6c4f2 to
ee66cc1
Compare
Collaborator
Author
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.



Stacked on #11 (
molon/get-many); review that first.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 (1024 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 stripeSet/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 1024 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.