Skip to content

feat(api): purge aged idempotency_records on the durable-job worker (#259) - #422

Merged
mforce merged 7 commits into
mainfrom
feat/259-idempotency-purge
Aug 5, 2026
Merged

mforce merged 7 commits into
mainfrom
feat/259-idempotency-purge

Conversation

@mforce

@mforce mforce commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Closes #259.

idempotency_records grew without bound — IdempotencyMiddleware inserts a claim per idempotent write and marks it Completed with the replay payload, but nothing ever deleted an old row. This adds IdempotencyRecordPurgeSweep, a DurableJobWorker sweep modeled on RefreshTokenPurgeSweep (#270), deleting rows past a 48h retention window.

Design (and why)

The purge is two deletes, split by Status, because the two states need different concurrency and retention guarantees (this evolved across review — see the resolved threads):

  • Completed rows (the unbounded bulk) key retention on CompletedAt, not CreatedAt. A result's retry window starts when the response was produced; a claim created >48h ago can be stolen and only now publish Completed, and keying on CreatedAt would purge that fresh response and let a retry re-run the mutation. Completed is terminal (nothing updates the row after publish), so the batched OrderBy/Take form is race-free even though EF lowers it to DELETE ... WHERE Id IN (subquery).
  • InProgress rows are a single unbatched, predicated DELETE (Status = InProgress AND LeaseExpiresAt < now AND CreatedAt < cutoff). The steal path renews LeaseExpiresAt without touching CreatedAt, so an aged claim can hold a live, just-stolen lease; deleting it mid-flight makes the guarded publish match zero rows → rolled-back mutation → 409. Keeping the lease predicate on the DELETE target (no subquery) makes READ COMMITTED re-check it against a concurrently-renewed row (EvalPlanQual) and skip it. The batched subquery form would delete by id after the renewal — which is why InProgress is unbatched (and its eligible set is inherently tiny).
  • One global delete, no tenant loop — idempotency_records carries AccountId but is not tenant-query-filtered, so a single delete prunes every account uniformly.
  • Batched Completed (500 × 20 per tick) so the first post-ship sweep of a months-long backlog can't monopolise the worker or spike the WAL; failures are contained and retried next poll.
  • Retention is a compile-time constant, no index, no migration — per the measured analysis on the issue (unindexed scan ~17ms at the synthetic ceiling vs. taxing every write on the hot insert path). (codex's index suggestion declined per that measurement.)

Tests (IdempotencyRecordPurgeSweepTests)

Aged collection (Completed + abandoned InProgress), fresh/boundary retention, tenant-safety + idempotency, backlog drain across the batch boundary, replay-after-purge re-executes, a stolen-then-completed claim (old CreatedAt, fresh CompletedAt) is retained (mutation-first guard for the CompletedAt keying), and a coordinated renewal/delete race guard that holds a steal's row lock, waits via pg_stat_activity until the sweep's DELETE is actually blocked, then commits — proving the target-level lease predicate skips the now-live row (reverting to the batched form turns it red).

Verification

No .NET SDK in my environment, so I could not build/run locally — CI's Build and test is the authority, and it is green (including the raw-SQL concurrency guard against real Postgres via Testcontainers).

claude added 7 commits August 4, 2026 13:19
…259)

idempotency_records grew without bound: IdempotencyMiddleware inserts a claim
per idempotent write and marks it Completed with the replay payload, but nothing
ever deleted an old row. Add IdempotencyRecordPurgeSweep, a DurableJobWorker
sweep modeled on RefreshTokenPurgeSweep (#270), deleting rows whose CreatedAt is
older than a 48h retention window.

- Keyed on CreatedAt (present on every row; CompletedAt is null while
  InProgress), with one flat window across all statuses. A row's only post-hoc
  value is replaying its Completed response to a client retry, and 48h clears any
  retry horizon by orders of magnitude; an InProgress row that old is an
  abandoned 30s lease the atomic protocol already treats as stealable, so
  deleting it just lets the next same-key request INSERT fresh.
- One global batched delete, no tenant loop: idempotency_records carries
  AccountId but is not tenant-query-filtered, so a single delete prunes every
  account uniformly — the tenant-safe shape is the absence of scoping (same as
  RefreshTokenPurgeSweep).
- Batched (500 x 20 per tick) so the first post-ship sweep of a months-long
  backlog can't monopolise the worker or spike the WAL; failures are contained
  and retried next poll, like the sibling sweeps.
- Retention is a compile-time constant, not config: matches
  RefreshTokenPurgeSweep.PurgeGrace, Infrastructure does not reference the Api
  project where IdempotencyOptions lives, and 48h needs no per-deploy tuning.
  No index and no migration — per the measured #259 analysis, an unindexed
  batched scan is ~17ms at the synthetic 48h ceiling and the index would tax
  every write on the hot insert path.

Integration tests cover aged (Completed and InProgress) collection, fresh/
boundary retention, tenant-safety + idempotency, backlog drain across the batch
boundary, and that a replay after purge re-executes rather than wedging the key.

Closes #259.
…421 review)

The first cut purged every row with CreatedAt older than the window regardless
of Status, on the reasoning that an aged InProgress row is an abandoned lease.
That misses the steal path: IdempotencyMiddleware renews LeaseExpiresAt on an
existing row WITHOUT touching CreatedAt, so a retry can hold a live lease over a
claim created more than 48h ago. Purging it mid-flight would make the handler's
guarded publish (UPDATE ... WHERE LeaseOwner = ours) match zero rows, roll back
the business mutation, and return 409.

Gate InProgress deletion on an expired lease: delete a row when
CreatedAt < cutoff AND (Status = Completed OR LeaseExpiresAt < now). Completed
rows still purge freely (their publish already committed). Race-safe under READ
COMMITTED — a concurrent steal that renews the lease makes this DELETE
re-evaluate and skip the row; a DELETE that wins the row lock first leaves the
steal's conditional UPDATE matching nothing, so it falls back to a fresh INSERT.
An abandoned InProgress row (expired lease) is still collected.

Adds Sweep_LeavesAgedInProgress_WithALiveRenewedLease as the regression test.
 review)

Round-2 review caught that the single batched delete does not actually protect a
renewed lease. EF lowers `Where(...).OrderBy(CreatedAt).Take(n).ExecuteDelete()`
to `DELETE ... WHERE Id IN (SELECT ... ORDER BY CreatedAt LIMIT n)`, whose outer
target is matched by id only. Under READ COMMITTED a steal that renews an
expired aged claim's lease AFTER the subquery selects it but BEFORE the delete
locks the row is not re-checked (the outer qual is the id, not the lease), so the
now-live row is deleted and its handler's guarded publish then 409s and rolls
back the business mutation.

Split the purge by Status, because the two need different guarantees:

- Completed rows (the unbounded bulk) are TERMINAL — nothing updates a row after
  publish — so no concurrent write can move one out of eligibility, and the
  batched OrderBy/Take form is safe. Kept batched.
- InProgress rows are a single UNBATCHED, predicated DELETE with
  `LeaseExpiresAt < now` ON THE DELETE TARGET. With the guard on the target (no
  subquery), READ COMMITTED re-evaluates it against a concurrently-renewed row
  (EvalPlanQual) and skips it; a delete that wins the row lock first leaves the
  steal's conditional UPDATE matching nothing, so it INSERTs fresh. This set is
  inherently tiny (an InProgress row completes within its own request; only
  crashed-and-never-retried keys persist), so it needs no batching — and batching
  it would reopen the very race.

Tests unchanged and still valid: aged Completed collected (batched), aged
expired-lease InProgress collected (unbatched), aged live-lease InProgress
retained, plus tenant-safety, backlog drain, and replay-after-purge.
#421 review)

The prior InProgress test seeded an already-live lease, which the broken batched
delete also passed (the row is excluded at selection time in both forms), so it
did not actually guard the race. Add a test that exercises the real failure
window: the row starts aged AND lease-expired so the sweep's InProgress DELETE
selects it, a concurrent steal (held on its own connection with an open
transaction) renews the lease and locks the row, and the DELETE blocks on that
lock. The unbatched target-predicated DELETE re-checks LeaseExpiresAt via READ
COMMITTED EvalPlanQual and skips the now-live row; the old `Id IN (subquery)`
form re-checked only the id and would delete it, so reverting turns this test
red.

On the fixed code the row survives under every interleaving, so the 1s
coordination delay never makes correct code flake — it only governs whether the
broken form would be caught.
…reatedAt (#421 review)

Round-4 review caught a purge that could delete a just-published response. The
steal path renews a lease without touching CreatedAt, so a claim created >48h ago
can be stolen and only now publish Completed. The Completed purge keyed on
CreatedAt would then collect that row on the very next poll — potentially the same
poll that just skipped its live InProgress lease — and a client retry after the
deletion re-runs the business mutation.

Key the Completed purge on CompletedAt instead: a result's retry window starts
when the response was produced, not when the claim was created. CompletedAt is
non-null exactly when Status is Completed (#307) and is immutable after publish,
so the batched form stays terminal-safe. InProgress keeps CreatedAt (its
CompletedAt is null) plus the expired-lease guard. Normal rows are unaffected
(a claim completes within its own request, so CompletedAt ~= CreatedAt); only
stolen-then-completed rows change, and they now stay replayable for the full
window past completion.

Updates the replay test to age CompletedAt (the new key) as well as CreatedAt.
…ted claim (#421 review)

Every existing Completed test has CreatedAt == CompletedAt (NewRecord sets them
equal; the replay test ages both), so reverting the Completed purge to the broken
CreatedAt predicate left the whole suite green — the CompletedAt fix was
unguarded. Add a Completed row created >48h ago but completed just now, and
assert the sweep retains it: keying on CreatedAt would delete it, so this test
goes red on that regression.
…d delay (#421 review)

The coordinated race test used Task.Delay(1s) before committing the steal. On a
slow/loaded host the sweep's DELETE might not have selected the row and blocked
within that second, so committing early lets even the broken batched form see
only the renewed lease — a false pass that silently stops guarding the
regression. Replace the delay with a deterministic wait: poll pg_stat_activity
until a session is blocked on a Lock while running a statement against
idempotency_records (the sweep's DELETE waiting on the steal's row lock), then
commit. Fails loud if the DELETE never blocks within the timeout.

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3f66fb76a5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +130 to +132
var deleted = await db.IdempotencyRecords
.Where(r => r.Status == IdempotencyStatus.Completed && r.CompletedAt < cutoff)
.OrderBy(r => r.CompletedAt)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Add a purge index before enabling the sweep

On installations where idempotency_records has accumulated the months-long backlog this sweep is meant to drain, this Status/CompletedAt filter plus ORDER BY CompletedAt has no supporting index (the model still only indexes (AccountId, EndpointHash, IdempotencyKeyHash)), so every poll can seq-scan/sort the whole table up to 20 times before deleting 500-row batches. Please add a purge index, such as (Status, CompletedAt) or a partial completed-row index, and ship it as its own migration rather than leaving the new worker to scan the hot idempotency table.

AGENTS.md reference: AGENTS.md:L50-L50

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Already measured and declined, not overlooked — same suggestion was raised on #421 (r3712923694) and settled with data, not arithmetic: issue #259 comment synthesized the 48h steady-state backlog (105k rows / 33 MB, from a measured 0.607 rows/s k6 ceiling) and ran the batched delete both ways — unindexed 16.9 ms (full seq scan, worst case: no row old enough yet, so LIMIT never short-circuits) vs. indexed 0.062 ms, at a cost of 2.3 MB + write amplification on every idempotent write on the hot insert path. 17 ms once per poll interval on a background worker is free; taxing every write to buy that back is backwards.

This PR's description restates the same conclusion under "Retention is a compile-time constant, no index, no migration." No code change here — this is the same declined tradeoff, not a new finding.

@mforce

mforce commented Aug 5, 2026

Copy link
Copy Markdown
Owner Author

@codex the purge-index P2 is addressed — replied on the thread (r3718154037): same suggestion already measured and declined on #421 (r3712923694), with data from #259 — unindexed 16.9 ms worst-case scan vs. indexed 0.062 ms at the cost of taxing every idempotent write on the hot insert path. No code change; please re-review.

@mforce
mforce merged commit 0966f11 into main Aug 5, 2026
16 checks passed
@mforce
mforce deleted the feat/259-idempotency-purge branch August 5, 2026 05:24
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Swish!

Reviewed commit: 3f66fb76a5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

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.

idempotency_records grows unbounded: full response body stored per successful write, no purge job

2 participants