[DRK-175] Atomic idempotency key reservation (PostgreSQL store) - #341
Merged
Conversation
- IsKeyProcessedAsync now atomically reserves the composite key by inserting a StatusCode=102 placeholder row, relying on the existing UX_CompositeKey unique index to serialize concurrent callers (only the winner proceeds to run the protected handler). - On insert collision, the blocking row is re-queried: an unexpired completed row replays its cached response, an unexpired reservation returns the existing 409/conflict path, and an expired row (stale reservation or completed entry nothing purges) lets the request proceed as new instead of permanently blocking that key. - MarkKeyAsProcessedAsync now completes the tracked reservation row in place via the new IdempotencyKeyEntity.Complete(...) instead of blindly inserting a second row. - Add IdempotencyOptions.InFlightReservationTimeout (default 30s) controlling how long a reservation is honoured before being treated as abandoned (R1). - Tighten the concurrency integration test to assert the handler ran exactly once (identical Id across every 201), and add a new test proving an expired in-flight reservation does not permanently block retries. DRK-175 / IDEM-CONCURRENCY-001 Co-authored-by: multica-agent <github@multica.ai>
… an expired row Adds CreateItem_ConcurrentRequestsAgainstExpiredReservation_OnlyOneProcessed. Seeds an already-expired StatusCode=102 reservation, then fires 5 concurrent requests for the same key. All 5 requests miss the unexpired-row filter and collide on the same stale row's unique-index INSERT; ReserveKeyAsync's collision branch returns (false, null) for every one of them when the blocking row is expired, so all 5 proceed to run the handler. FAILS on 4802a1b: 5 distinct handler executions observed instead of 1. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
…e residual race ReserveKeyAsync's expired-collision branch returned (false, null) to every concurrent caller that collided with the same expired row, so none of them actually won the reservation - reopening the exact double-execution race DRK-175 closed for the fresh-key path. Replace the unconditional return with a conditional UPDATE (ExecuteUpdateAsync) that only matches while the row is still expired, giving the same single-winner guarantee the unique index gives the fresh-insert path. A caller whose UPDATE affects zero rows lost the race and re-reads the row to branch like the unexpired collision path. Fixes DRK-182. Co-authored-by: multica-agent <github@multica.ai>
📊 Code Coverage Report |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## dev #341 +/- ##
==========================================
+ Coverage 78.99% 79.14% +0.15%
==========================================
Files 169 169
Lines 4142 4196 +54
Branches 608 613 +5
==========================================
+ Hits 3272 3321 +49
- Misses 689 694 +5
Partials 181 181
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
baoduy
commented
Aug 5, 2026
baoduy
left a comment
Owner
Author
There was a problem hiding this comment.
PR Review: #341 — Atomic idempotency key reservation (PostgreSQL store)
Verdict: APPROVED (score 10.0/10) — merging into dev. Review vote skipped (self-authored PR; GitHub rejects self-approvals).
Score
| Category | Score | Weight |
|---|---|---|
| Correctness & logic | 10 | 25% |
| Security | 10 | 20% |
| Testing & coverage | 10 | 20% |
| Maintainability & design | 10 | 15% |
| Spec conformance | 10 | 10% |
| Style & conventions | 9.5 | 5% |
| AI-slop gate | 10 | 5% |
Weighted: 9.975 → 10.0. No hard caps triggered.
Top findings
- [praise]
IdempotencyPostgresStore.cs:163–171— The atomicExecuteUpdateAsyncreclaim elegantly solves the expired-row concurrency race without a schema change, using affected-row count for the same single-winner guarantee the unique index gives the fresh-insert path. - [praise]
IdempotencyIntegrationTests.cs:75–93— The tightened concurrency test now asserts all 201 responses carry the identical handler-generatedId, proving single-execution rather than the old lenient check. - [praise]
IdempotencyKeyEntity.cs:111—Complete()reuses the existing private-setter infrastructure rather than adding new public setters. - [nit]
IdempotencyPostgresStore.cs:83—DateTime.UtcNowvsDateTimeOffset?ExpiresAttype mismatch in the LINQ expression. Works correctly in EF Core; pre-existing pattern. - [suggestion]
IdempotencyPostgresStore.cs:238— The defensivecatchblock inMarkKeyAsProcessedAsynclogs atInformationlevel; considerWarningso unexpected triggering is observable.
Preconditions
- CI: 6/6 passed (build-test-coverage green)
- Coverage: ≥97.7% (QC-verified via
[D175-2]) - Base:
dev, not draft, no protected paths - No secrets, no new dependencies, no scope creep
Out-of-scope
IdempotencySqlServerStore.cs:60–86 still uses the non-atomic check-then-act pattern — that's DRK-75's scope (parallel cycle), not introduced here.
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.
Problem
IdempotencyPostgresStorehad the same non-atomic check-then-act race DRK-75 fixed inIdempotencySqlServerStore:IsKeyProcessedAsyncwas a pure read, andMarkKeyAsProcessedAsynconly wrote afterward, so two concurrent requests with the same idempotency key could both execute the protected handler.Fix
IdempotencyPostgresStore.IsKeyProcessedAsyncnow atomically inserts aStatusCode=102reservation placeholder (leveraging the existingUX_CompositeKeyunique index) before returning "not processed" — only one concurrent caller per key proceeds to run the handler.IdempotencyKeyEntitygained an internalComplete(CachedResponse)mutator soMarkKeyAsProcessedAsynccompletes the reservation row in place instead of blind-inserting.IdempotencyOptions.InFlightReservationTimeout(default 30s) bounds how long a reservation is honoured before being treated as expired/abandoned.ExecuteUpdateAsync(only one racer's update affects a row) — an intermediate build had a residual race here (an unconditional "proceed as new" let every racer through), closed during this cycle's QC pass.Tests
CreateItem_ConcurrentRequestsWithSameKey_OnlyOneProcessedto assert every concurrentCreatedresponse carries the identical item id (proves the handler ran once, not just that the DB has one row).CreateItem_WithExpiredInFlightReservation_ProcessesAsNewRequest— an expired reservation doesn't permanently block retries.CreateItem_ConcurrentRequestsAgainstExpiredReservation_OnlyOneProcessed— concurrent requests racing an expired reservation still execute the handler exactly once.AspCore.Idempotency.NpgsqlStore.Tests: 19/19, repeated across 5 full runs for determinism.AspCore.Idempotency.Tests: 57/57. Coverage on touched files ≥ 97.7%. Cleandotnet buildanddotnet pack.Scope
src/AspNet/DKNet.AspCore.Idempotency.NpgsqlStore/Store/IdempotencyPostgresStore.cs,.../Data/IdempotencyKeyEntity.cs,src/AspNet/DKNet.AspCore.Idempotency/IdempotencyOptions.cs,src/AspNet/AspCore.Idempotency.NpgsqlStore.Tests/Integration/IdempotencyIntegrationTests.cs— no other files touched.Addresses finding
IDEM-CONCURRENCY-001(DRK-175).