Skip to content

feat: write the upstream before this layer, and guard backfills with a write generation - #12

Merged
molon merged 8 commits into
molon/get-manyfrom
molon/write-order
Oct 8, 2026
Merged

molon merged 8 commits into
molon/get-manyfrom
molon/write-order

Conversation

@molon

@molon molon commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

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:

  1. 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.
  2. 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 (1024 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); gives up with a ctx error, writing nothing, if its ctx ends first
a Set/Del that has to wait for another write to its stripe ran concurrently waits; gives up with a ctx error, writing nothing, if its ctx ends first
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 1024 stripes).

  • An upstream Cache[T] whose Set/Del calls back into the same Client 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.)
  • All existing tests pass unchanged, including feat: add Client.GetMany with batch upstream and batch backends #11's get_compat_test.go (single-key behavior pinned to main) and TestGetCompatWritePropagation.

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.

@molon
molon requested a balanced review from Copilot October 8, 2026 07:15

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Comment thread batch.go Outdated
Comment thread client.go Outdated
Comment thread client.go Outdated
Comment thread README.md Outdated
Comment thread README_ZH.md Outdated
@molon
molon force-pushed the molon/write-order branch from a7f9341 to 34e4208 Compare October 8, 2026 07:33
@molon
molon requested a balanced review from Copilot October 8, 2026 07:41

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Comment thread client.go
Comment thread README.md Outdated
Comment thread README_ZH.md Outdated
Comment thread client.go Outdated
@molon
molon force-pushed the molon/write-order branch 2 times, most recently from 59b0b18 to f1b5136 Compare October 8, 2026 08:14
@molon
molon requested a balanced review from Copilot October 8, 2026 08:16

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@molon
molon force-pushed the molon/write-order branch 4 times, most recently from 02d90cf to eb6c4f2 Compare October 8, 2026 10:33
molon added 8 commits October 8, 2026 19:16
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

molon commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Merged into #11 so both are reviewed and released together: molon/get-many now points at the same commits, and #11's description includes this one's under Write order. Closing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants