Skip to content

fix(dedupe)!: reserve/commit ids, windowed ingest, retention, dynamodb - #625

Merged
EricAndrechek merged 153 commits into
mainfrom
feat/dedupe-reserve
Sep 26, 2026
Merged

EricAndrechek merged 153 commits into
mainfrom
feat/dedupe-reserve

Conversation

@EricAndrechek

@EricAndrechek EricAndrechek commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

Part of #613. This PR carries the whole remote-dedupe stack: the reserve/commit contract, windowed ingest, retention, and the DynamoDB backend with its boot wiring. #629, #633, #628 and #635 were reviewed on their own and merged into this branch, and #667's test fix came in with them.

Summary

  • Reserve, Commit, Release (fixes bug(dedupe): CheckAndMark is not atomic — concurrent same-id requests both pass #390, Per-table dedupe id_field (+ fix cross-table dedupe keyspace collision) #222, bug(ingest): explicit null id_field slips past require_id and dedupes every row onto "<nil>" #370). Deduplicator.CheckAndMark is replaced by a two-phase contract that every backend implements:
  • Windowed ingest (fixes bug(ingest): dedupe marks the event id before the NATS publish — a failed publish + client retry permanently drops the event #384). Ingest runs in windows of up to 256 records. Each window makes one Reserve, publishes in record order, then makes one Commit.
    • A deduped record is published under a Nats-Msg-Id idempotency key derived from its tenant, table and id. The embedded ingest stream sets its duplicate window to 2 minutes explicitly.
    • Only a definite publish failure (the queue refused it) releases the claim. After an uncertain failure, the claim lapses with the lease, and a retry is dropped by the stream's duplicate window. The event is stored once and never lost.
    • A dedupe store that cannot answer (dedupe.ErrUnavailable, now "dedupe store unavailable") answers 503 {"error":"dedupe store unavailable"} with Retry-After: 5. It used to answer 500 dedupe failed.
    • On Pebble, a 1,000-record batch now costs 4 fsyncs instead of 1,000.
  • Per-table retention (fixes Optional TTL/size bound on the dedupe store + durability docs #220). dedupe.retention in config.json, with a per-table override in dedupe.tables.<table>.retention, sets how long a committed id stays a duplicate. The default is "0", which keeps ids forever, so an existing config.json needs no change.
    • A finite retention below 2 minutes (the queue's duplicate window) is refused, not clamped.
    • A background sweep on the Pebble instance deletes expired ids and the old-format keys. It runs a minute after open and then hourly, and it never deletes a key that was committed again after the sweep read it. wavehouse_dedupe_swept_keys_total{reason} counts what it deletes.
  • DynamoDB backend, selected by dedupe.backend: dynamodb. Every tenant and every process share one table, so an id ingested through one pod is a duplicate through every other.
    • Reserve is a conditional PutItem per key. Commit is BatchWriteItem with retries. Release is a conditional DeleteItem. Expiry is the native TTL attribute ex, and correctness never waits on TTL.
    • Throttling, timeouts and connection failures wrap ErrUnavailable and answer 503. A circuit breaker short-circuits Reserve for a second after five unavailable claims in a row.
    • New boot keys: dedupe.lease (the lease is now configurable, 30 s by default, at most 59 s with the embedded queue), dedupe.reserve_concurrency, and the dedupe.dynamodb.* block. create_table is refused unless endpoint is set, so WaveHouse never creates a table in AWS.
    • Boot rule: boot checks the table whether or not any tenant has dedupe on. A misconfigured table (missing, the wrong key schema, access denied) refuses boot only with a flat settings directory whose tenant has dedupe on. In every other case, including transient failures, nested directories, and no tenant with dedupe on yet, the process boots, and every tenant with dedupe on fails closed with the 503. The check is retried in the background and again right after every reload.
  • Caller-cancel fix (addresses flake: Dynamo failed Reserve can leave a cancelled put's claim behind #648). When a caller cancels mid-Reserve, the puts not yet sent are skipped. A put already sent runs to its answer before it is released. Only its own call deadline can cut it off, and then it holds its key at most until the lease ends, as a crashed request's claim does.
  • Test teardown (absorbs fix(test): remove the broker store after late consumer writes land #667). Tests that start the embedded broker no longer fail in t.TempDir cleanup when the broker's consumer-state flusher writes after Close. The new internal/testutil/storedir retries the removal.

Behaviour and compatibility notes

  • Old-format dedupe keys are swept, not migrated. An id seen before the upgrade is accepted once more after it. The retention sweep deletes the old keys on its first pass. Nothing released depends on them.
  • A dedupe backend that cannot answer returns 503 + Retry-After: 5 where it used to return 500. The SDK already retries a 503.
  • A mid-body read error or a prepare failure now drops the open window unpublished. Before, the records ahead of it were published.
  • The in-flight 503 sends the lease as Retry-After. That is 30 s by default, as before.

Known follow-ups

Tests

  • Conformance: dedupetest.Run runs against Pebble twice (on an injected clock and on the real clock) and against amazon/dynamodb-local:3.3.1. It covers claim, duplicate and in-flight, release then re-claim, lease lapse, retention expiry, a 64-way concurrent Reserve, a Reserve racing a Commit, key isolation per tenant and table, hashed long ids, and all-or-nothing on a mid-call failure.
  • Ingest (internal/api/ingest_window_test.go, ingest_retention_test.go):
  • Pebble sweep (internal/dedupe/sweep_test.go): chunk boundaries, expired and old-format keys, and a Commit racing a chunk. Each case is mutation-checked.
  • DynamoDB: unit tests against a fake API cover error classification, Reserve cleanup, a Reserve cancelled by its caller leaving nothing claimed, a retried put keeping its own claim, Commit retries, the breaker, and Check. Integration tests against dynamodb-local cover the conformance suite, 32 clients racing one id, throttling, an unreachable endpoint, TTL and expiry, and two app.New instances sharing seen ids through one table.
  • Boot wiring (internal/app/dedupe_dynamodb_test.go, internal/config/backends_test.go): the table check in both directory shapes, the background retry, reloads that make no table call, and every new config key and refusal.
  • Pinning tests: the retention floor is at least the queue's duplicate window, and config.embeddedDuplicateWindow equals mq.EmbeddedDuplicateWindow.
  • make ci passes on the merged stack: every coverage gate passed. Unit 94.0%, integration 52.4%, e2e 60.2% (60% floor), Go total 95.0%.

Fixes #390. Fixes #222. Fixes #370. Fixes #384. Fixes #220. Closes #442. Closes #648. Part of #613.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FyrXjhR7iDg33paioLQHFq

taitelee and others added 21 commits September 24, 2026 17:49
mq.backend, cache.backend, dedupe.backend and coord.backend select each
layer's implementation; only today's in-process one exists per layer and
it is the default. Validate refuses an unknown value, internal/app picks
the implementation in one switch per layer, data_dir is probed only when
a selected backend keeps state there, and boot logs Config.Warnings.

Part of #613.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
…ENTS.md

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
Replace CheckAndMark with a two-phase Reserve -> Commit | Release contract
with a lease on the pending claim, keyed by (tenant, table, id) under a
versioned layout, and add dedupetest, the conformance suite every backend
runs.

- Pebble claims under a sharded in-memory lock, so concurrent requests with
  one id publish it once (#390).
- Ingest reserves after encoding, publishes, then commits, releasing the id
  when the publish fails, so a retried 503 is published, not dropped (#384's
  loss; F2 closes the uncertain-publish window). An id held by another
  request answers 503 with the lease as Retry-After.
- The same id in two tables is two ids (#222); an explicit null id is a
  missing id (#370).

BREAKING: the key layout changes, so ids seen before the upgrade are
accepted once more.

Part of #613.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
…lease

Also document the upgrade, the SDK's new 503 cause, and the release on a
failed publish.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
Also rewrap Managed's doc comments, point the NUL-table check at AppendKey,
and describe the two-phase dedupe in architecture's request flow.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
…upe-reserve

# Conflicts:
#	docs/src/content/docs/api.md
#	docs/src/content/docs/architecture.md
#	docs/src/content/docs/deployment.md
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
@coderabbitai

coderabbitai Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 4d68f443-9186-4299-9c5b-83020f13d64e

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added documentation Improvements or additions to documentation go Pull requests that update go code area/api HTTP handlers, routing, middleware area/dedupe Deduplication (Pebble, ScyllaDB) area/docs Documentation, site/, README area/app Process wiring (internal/app): component build, run, release labels Sep 25, 2026
@github-actions github-actions Bot added the area/infra CI, build, deploy, Docker, release label Sep 26, 2026
EricAndrechek and others added 17 commits September 26, 2026 08:49
Takes the base's AGENTS.md tree line and #667's teardown-flake fix.
CHANGELOG: both new entries kept. The stale-duplicate-window mq test
now takes its store from storedir.New, the helper #667 replaced
storeDir with.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G6Cz4H5k1spJAPk5CZeGds
TestEmbedded_SweepChunkOverTombstonesDoesNotHoldCommits claimed more
than it checked. sweepTouchHook fired once per element of `candidates`,
and the fixture has exactly one visible key, so `touched <= 1` could
never fail: a full keyspace walk added inside the locked phase would
still pass (R4). The TryLock ran in sweepScanHook, which fires AFTER
sweepCandidates returns, so a regression that read under commitMu and
released the lock just before that hook still passed (R3) — only
"the whole chunk under one lock" was actually caught (R5). The
300k-tombstone fixture changed no verdict either way (a handful of
tombstones behaves identically) while costing 1.65s alone / 3.1s under
package load in a 15s-timeout package, and the comment recorded this
test's own history (citing a commit that a squash would erase) instead
of what it asserts.

Drop sweepTouchHook (field, wiring, assertion). To pin "the read runs
unlocked" for real, sweepCandidates now takes an onKey hook called
once per key from inside its own loop, before evaluating it — wired
through sweepChunk as e.sweepReadHook. Renamed
TestEmbedded_SweepChunkOverTombstonesDoesNotHoldCommits to
TestEmbedded_SweepReadRunsUnlocked: TryLock/Unlock from inside that
hook, while the read is still running, so a lock held anywhere during
the read is caught in the act rather than inferred from whether it was
released before some later checkpoint. The fixture shrinks to a
handful of tombstones ahead of the one live key, since the verdict
never depended on the count. Comment cut to the one thing the test
asserts; the old wall-clock/hook history belongs here instead.

Verified by mutation, each applied then reverted (`git diff
internal/dedupe/sweep.go` clean before the real change was made):
- R3 (only the read under the lock, released right after): wrapped
  just the sweepCandidates call in sweepChunk with
  commitMu.Lock()/Unlock() — TestEmbedded_SweepReadRunsUnlocked failed
  ("commitMu must be free while sweepCandidates' read is running").
- R5 (the whole chunk under one lock): wrapped sweepChunk's body in
  commitMu.Lock()/defer Unlock() and dropped deleteSweepable's own
  lock to avoid a self-deadlock — ran ONLY the target test (not the
  package: TestEmbedded_SweepNeverDeletesACommitLandingMidChunk
  self-deadlocks under this mutation, racing a Commit against a sweep
  that never releases the lock) — failed with the same assertion.

GOTOOLCHAIN=go1.26.6 go test -race -count=5 -run Sweep
./internal/dedupe/ passes (5/5, including
TestEmbedded_SweepReadRunsUnlocked and every other Sweep-prefixed
test); the full package (go test -race -count=1 ./internal/dedupe/...)
passes too, now in ~5s rather than the prior fixture's ~57s at
-count=20.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G6Cz4H5k1spJAPk5CZeGds
TestEmbeddedNATS_Publish_IdempotencyKeyDropsARepeat and the api
package's realPipeline opened the embedded broker on a bare
t.TempDir(). Both replay through a disk-backed consumer, so they were
exposed to the late consumer-state write (#442) that storedir absorbs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G6Cz4H5k1spJAPk5CZeGds
Takes the base's log-level test, its storedir moves and #667's
teardown-flake fix (via #625). No conflicts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G6Cz4H5k1spJAPk5CZeGds
TestEmbedded_SweepReadRunsUnlocked asserted only that the hook never
saw commitMu held, which also holds if the hook never fires: passing
nil for the hook left it green. It now counts the visits and requires
one; with the hook unwired it fails.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G6Cz4H5k1spJAPk5CZeGds
make ci's e2e coverage measured 59.7% against a 60% floor: the e2e
binary always boots with dedupe.backend: pebble, so wireDynamoDedupe,
errDynamoUnchecked and the table-check retry component never ran
there, same shape as #628's internal/dedupe/dynamodb.go exclusion.

Pure move, no behavior change: wireDynamoDedupe and errDynamoUnchecked
move verbatim into the new internal/app/wire_dynamodb.go; wireDedupe's
switch stays in wire.go untouched. Adds a matching e2e-only exclusion
for the new file in .testcoverage.yml, next to dynamodb.go's, so unit
and integration keep covering it and the merged total still counts it
-- wire.go itself stays out of the exclude list. Updates
architecture.md's wire.go bullet (the dynamodb case now points at the
new file's own bullet) and the CHANGELOG's file-provenance list for
the dedupe.backend: dynamodb entry.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G6Cz4H5k1spJAPk5CZeGds
Puts already sent, and the release after them, run on
context.WithoutCancel(ctx), each call still bounded by its Timeout. A
client that disconnects mid-Reserve no longer leaves an abandoned put to
land after its release and hold the id InFlight for the lease; a Reserve
whose caller cancelled after every put answered also releases them.

The breaker's exemption for cancelled puts is gone: a sent put can no
longer be cancelled by its caller, so its answer is always the table's.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyrXjhR7iDg33paioLQHFq
A throttle wraps it too, and logged as "dedupe store is not open". The
classify comment now says what ingest answers today: 500 for both, until
#629 maps ErrUnavailable to a 503.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyrXjhR7iDg33paioLQHFq
Over a flat settings directory, boot was refused on any failed table
check. Now it is refused only when the failure is a misconfiguration
(not ErrUnavailable: a missing table, the wrong key schema, access
denied) and a tenant has dedupe on. A transient failure, or a
misconfigured table no tenant uses yet, boots with the switched-on
stores closed and the check retried in the background, as a nested
directory already did; a tenant a reload switches on fails closed until
it passes. The misconfiguration is logged at ERROR.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyrXjhR7iDg33paioLQHFq
No put applies before the caller cancels, so neither subtest depends on
scheduling. The CHANGELOG line now names what a cancel can still leave
held: a put cut off by its own timeout, or a failed release.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyrXjhR7iDg33paioLQHFq
Brings in the caller-cancel fix for DynamoDB Reserve and the renamed
ErrUnavailable message. The CHANGELOG and architecture conflicts keep
this branch's entries with the cancel wording carried onto them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyrXjhR7iDg33paioLQHFq
CreateTable returned its errors unclassified, so with create_table on a
dynamodb-local not listening yet read as a misconfiguration and refused
boot over a flat directory. Its errors now go through classify, and a
test pins that a transient create failure boots and is retried.

settings-directory.mdx still said any failed open refuses boot; that is
now scoped to Pebble, with the DynamoDB rule stated beside it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyrXjhR7iDg33paioLQHFq
…n rule

The table section still said boot refuses a table whose key schema does
not match, in every case. Drop a sentence configuration.mdx said twice.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyrXjhR7iDg33paioLQHFq
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyrXjhR7iDg33paioLQHFq
Docs conflicts only: both sides' dedupe text kept, the windowed-ingest
503 for an unavailable store alongside the DynamoDB backend and the
configurable dedupe.lease.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyrXjhR7iDg33paioLQHFq
The DynamoDB backend and windowed ingest each described the other's
absence: ingest now answers an unavailable dedupe store 503, one Reserve
carries a window of up to 256 ids, and the lease is dedupe.lease rather
than a fixed 30 seconds. State the lease/window rule once, as the boot
check applies it, and pin config's copy of the embedded duplicate window
to mq's.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyrXjhR7iDg33paioLQHFq
Committed items carry ex once a finite dedupe.retention applies, the
Pebble sweep does not run for the DynamoDB backend, and a throttled or
unreachable table answers the same 503 as a store that is not open.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyrXjhR7iDg33paioLQHFq
@EricAndrechek EricAndrechek changed the title fix(dedupe)!: reserve, commit or release ids keyed by tenant and table fix(dedupe)!: reserve/commit ids, windowed ingest, retention, dynamodb Sep 26, 2026
@github-actions github-actions Bot added the dependencies Pull requests that update a dependency file label Sep 26, 2026
@EricAndrechek
EricAndrechek marked this pull request as ready for review September 26, 2026 20:39
@EricAndrechek
EricAndrechek requested review from a team and taitelee September 26, 2026 20:39
EricAndrechek and others added 2 commits September 26, 2026 17:23
Keeps both sides: the redis cache backend and the dynamodb dedupe
backend sit side by side in defaults(), the backend validation test,
the coverage excludes, the changelog and the docs. The app test helper
for a redis cache config now carries the dedupe lease and concurrency
defaults, since Validate refuses them at zero.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyrXjhR7iDg33paioLQHFq
The multiple-instances section from #614 said dedupe is always per
instance, and the boot-config list named only cache.redis. Both now
name dedupe.dynamodb. The integration setup also starts dynamodb-local.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyrXjhR7iDg33paioLQHFq
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/api HTTP handlers, routing, middleware area/app Process wiring (internal/app): component build, run, release area/dedupe Deduplication (Pebble, ScyllaDB) area/docs Documentation, site/, README area/infra CI, build, deploy, Docker, release area/ingest Ingest pipeline (Bento, batching, DLQ) dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation go Pull requests that update go code

Projects

Status: Done

2 participants