Release dedup claims when a batched flush fails - #90
Merged
Conversation
3 tasks
… write MergeMany learned to release a claim when its merge fails; the two batching paths never did. Both claim the EventID at buffer time — a whole batch (Coalescer.AddMany) or a whole flush window (aggregator.accept) before the durable write — and then drop the buffer on the floor when the write fails, claim still standing. The redelivery each of those failures is explicitly asking for arrives to a dedup_skip: no error, no metric, and for Sum / HLL / TopK the counts are simply gone. The aggregator path is worse than it looks, because it acks the poison batch, so the DLQ replay the deadLetter callback exists to enable is the only way back and it was the thing being swallowed. Ownership has to be tracked per EventID, not per batch. MarkSeen is fail-open — a dedup-table outage buffers the event unclaimed — so a blanket release over the batch would delete rows other workers won and let a third delivery re-apply those events on top of the winner's write. pendingEntry and batch now carry the subset of EventIDs whose claim we actually hold. Releases go out on a context detached from cancellation, matching MergeMany: the likeliest reason a flush fails is SIGTERM cancelling ctx, and releasing with that same context fails instantly, so the claim would survive exactly the shutdown it must not survive. The release logic is now one exported ReleaseClaims helper the three call sites share, with a single time budget across the slice so a 10k-key batch can't stall shutdown for minutes. Coalescer.Flush's cancel branch stops returning early — it finishes walking the pending map so the entries it never attempted give their claims back too, then returns the cancellation error as before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X2YMxeLgyRc9i5XPyEV75S
…al batches The detached-context release was untested in both batching paths: every deduper fake ignored ctx on Release, so mutating context.WithoutCancel(ctx) back to ctx in ReleaseClaims left the whole suite green — including the test that exists to cover it. All four fakes now honour ctx exactly as the real dynamodb.Deduper does (a DeleteItem on a cancelled context fails without touching the table), and the mutation now fails the coalescer test, a direct ReleaseClaims test, and a new aggregator test where cancellation kills an in-flight flush. The budget was also wrong at scale. claimedIDs holds one entry per EVENT and DefaultMaxKeys is 10,000, so one failed flush of a hot key hands ReleaseClaims thousands of IDs — and a fixed 5s spent serially covered fewer than half of them, stranding the rest silently. The budget now scales with the claim count (base + one worker-pool round per 16 claims, capped at 30s) and the releases run across a bounded pool of 16. The streaming aggregator called ReleaseClaims once per BATCH inside flushOne, so a shutdown drain over K failed batches paid K independent budgets. flushAll now gathers every failed batch's claims and makes one call. Also covers the mixed-outcome flush — one key lands, a sibling fails, disjoint events — which nothing tested: a mutant that released the whole pending map regardless of per-key outcome passed the entire suite, and now double-counts the surviving key on replay. STABILITY.md now states the over-count exposure plainly instead of gesturing at it: in the Coalescer a PARTIAL flush failure releases claims for events that also landed on sibling keys that SUCCEEDED, so redelivery re-applies to those keys too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X2YMxeLgyRc9i5XPyEV75S
Kept out of the implementing commits so parallel branches did not all conflict on the [Unreleased] heading. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X2YMxeLgyRc9i5XPyEV75S
pequalsnp
force-pushed
the
fix/dedup-claim-leaks
branch
from
August 28, 2026 14:56
24b42a5 to
2c5480e
Compare
This was referenced Aug 28, 2026
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.
The batching paths leaked what #87 fixed in
processor.MergeMany. A leaked claim means the redelivery the code explicitly asks for is silently swallowed:dedup_skipfires and the event is gone.coalesce.goandaggregator.goboth take the claim atAddMany/accepttime — before the durable write — and released it on no failure path at all.The fix
Claims are released when a flush fails, on a context detached from cancellation (matching
processor.go, since the most common cause of a failed flush is the context being cancelled at shutdown — releasing with that same context fails instantly).It needs a per-EventID claim-ownership flag, because
MarkSeenis fail-open: an error means "proceed unclaimed", and releasing a claim you did not win drops the winner's row and lets a third delivery re-apply the event.What review caught, and why it mattered
The first attempt's own regression test was worthless. The reviewer mutated
context.WithoutCancel(ctx)back toctxinprocessor.ReleaseClaimsand the entire suite stayed green — includingTestCoalescer_CancelledFlushReleasesDedupClaims, which exists specifically to cover that line.The cause: the deduper fake didn't honour its context on
Release, so a cancelled context released just fine in the test and failed only in production. The fake now behaves like the real DynamoDBDeduper— returnsctx.Err()when the context is done — and the mutation now fails the test.That is the exact shape of this repo's dominant defect class, reproduced inside a test written to prevent it. Worth stating plainly rather than quietly fixing.
Also addressed from review:
claimedIDsholds one entry per event andDefaultMaxKeysis 10,000, so a 1sFlushTickat a few thousand events/sec exhausted the budget partway and leaked the remainder.ReleaseClaimsonce per batch insideflushOne, so a shutdownflushAllover K failed batches cost up to K × budget.The trade this makes, stated plainly
In the coalescer, a partial flush failure releases claims for events that also landed on sibling keys that succeeded, so redelivery re-applies to those keys. That is the correct direction — at-least-once permits re-application but never permits loss — but it is a real over-count exposure and the STABILITY text now says so instead of understating it.
Test plan
TestCoalescer_FlushFailureReleasesDedupClaims— 100 EventIDs on one key, assert 100 releases, then replay against a healthy store and assert the total is 100 (today: 0 and 0)TestBatchWindow_DeadLetteredFlushReleasesDedupClaimsRelated: #87 fixed the same class in the non-batched path.
🤖 Generated with Claude Code
https://claude.ai/code/session_01X2YMxeLgyRc9i5XPyEV75S