Skip to content

Release dedup claims when a batched flush fails - #90

Merged
pequalsnp merged 3 commits into
mainfrom
fix/dedup-claim-leaks
Aug 28, 2026
Merged

Release dedup claims when a batched flush fails#90
pequalsnp merged 3 commits into
mainfrom
fix/dedup-claim-leaks

Conversation

@pequalsnp

Copy link
Copy Markdown
Contributor

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_skip fires and the event is gone.

coalesce.go and aggregator.go both take the claim at AddMany/accept time — 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 MarkSeen is 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 to ctx in processor.ReleaseClaims and the entire suite stayed green — including TestCoalescer_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 DynamoDB Deduper — returns ctx.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:

  • The 5s release budget was shared across a whole failed flush. claimedIDs holds one entry per event and DefaultMaxKeys is 10,000, so a 1s FlushTick at a few thousand events/sec exhausted the budget partway and leaked the remainder.
  • The aggregator called ReleaseClaims once per batch inside flushOne, so a shutdown flushAll over K failed batches cost up to K × budget.
  • No test covered a mixed-outcome flush — one key succeeds, one fails, disjoint events. A mutant that released the whole pending map regardless of per-key outcome would have passed everything.

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_DeadLetteredFlushReleasesDedupClaims
  • A claim that was not won is never released
  • Mixed-outcome flush
  • Large-claim-count budget case
  • All verified to fail pre-fix, including under the reviewer's context mutation
  • CI green

Related: #87 fixed the same class in the non-batched path.

🤖 Generated with Claude Code

https://claude.ai/code/session_01X2YMxeLgyRc9i5XPyEV75S

Kyle Galloway and others added 3 commits August 28, 2026 11:56
… 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
pequalsnp force-pushed the fix/dedup-claim-leaks branch from 24b42a5 to 2c5480e Compare August 28, 2026 14:56
@pequalsnp
pequalsnp merged commit f17caa2 into main Aug 28, 2026
6 checks passed
@pequalsnp
pequalsnp deleted the fix/dedup-claim-leaks branch August 28, 2026 15:02
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.

1 participant