feat(idempotency): refresh expired idempotency keys atomically on reserve and store - #909
Merged
Merged
Conversation
Store a key, advance the fake clock past the TTL and reserve or store it again: the key must be present for the new window, and a second reservation must be rejected. A reservation of a key that has not expired must leave its CreatedAt unchanged.
…pository IIdempotencyKeyRepository.TryReserveAsync(key, createdAt, validFrom) inserts an absent key or refreshes the timestamp of a key created before validFrom, in one atomic operation, and leaves a key that has not expired untouched. - SQL Server: usp_ReserveIdempotencyKey with MERGE ... WITH (HOLDLOCK) - PostgreSQL: fn_reserve_idempotency_key with ON CONFLICT ... DO UPDATE ... WHERE - SQLite: ON CONFLICT ... DO UPDATE ... WHERE - MySQL: INSERT IGNORE followed by a conditional UPDATE - Entity Framework Core: delete the expired row, then insert - Redis: SET NX without a cutoff, Lua script with a cutoff Re-run IdempotencyKey.sql for SQL Server and PostgreSQL together with the upgrade.
…ository TryReserveAsync and StoreAsync must call IIdempotencyKeyRepository.TryReserveAsync with the TTL cutoff instead of composing ExistsAsync and StoreAsync, which never refreshes an expired key.
…ed again IdempotencyStore used the default TryReserveAsync (ExistsAsync, then StoreAsync), and every backend stored keys insert-if-absent. An expired key therefore kept its old CreatedAt, and every later duplicate ran the handler again. TryReserveAsync and StoreAsync now go through the atomic IIdempotencyKeyRepository.TryReserveAsync with the TTL cutoff. Closes #814
# Conflicts: # src/NetEvolve.Pulse.PostgreSql/README.md
…erveIdempotencyKey
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #909 +/- ##
==========================================
- Coverage 96.09% 95.77% -0.33%
==========================================
Files 274 274
Lines 11511 11700 +189
Branches 1069 1097 +28
==========================================
+ Hits 11062 11206 +144
- Misses 232 274 +42
- Partials 217 220 +3 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
samtrion
added a commit
that referenced
this pull request
Sep 29, 2026
Resolve the conflicts with #909 in favour of its IIdempotencyKeyRepository.TryReserveAsync: drop TryStoreAsync and the WATCH/MULTI compare-and-set, since the Redis provider on main already reserves atomically with SET NX (no TTL) or a single Lua script that refreshes expired keys. Keep the concurrency tests, drop the reservation test duplicated by #909, and point the interceptor remarks at IIdempotencyKeyRepository.TryReserveAsync.
This was referenced Sep 29, 2026
This was referenced Sep 29, 2026
Merged
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.
Summary
With
IdempotencyKeyOptions.TimeToLiveset, a key that had expired and was then reserved again kept its oldCreatedAt.ExistsAsynckept reporting it as absent, so every later duplicate of that command ran the handler again. This happened in every provider.Reserving and storing now go through one atomic repository operation. It inserts an absent key or refreshes the timestamp of an expired one, and it leaves a key that has not expired untouched.
Closes #814
Changes
IIdempotencyKeyRepository.TryReserveAsync(key, createdAt, validFrom, ct), with no default implementation. It returnstruewhen the key was inserted or an expired key was refreshed.IdempotencyStore: overridesTryReserveAsyncand routesStoreAsyncthrough it with the TTL cutoff. WhenTimeToLiveisnull, the cutoff isnulland an existing key is never modified.usp_ReserveIdempotencyKey, usingMERGE ... WITH (HOLDLOCK)withWHEN MATCHED AND CreatedAt < @validFrom THEN UPDATEand returning@@ROWCOUNT > 0. It has a documented header, a NULL guard for@idempotencyKey/@createdAt(THROW 50000) and aTRY/CATCHthat rethrows.fn_reserve_idempotency_key(...) RETURNS BOOLEAN, usingON CONFLICT ("idempotency_key") DO UPDATE ... WHERE created_at < p_valid_from. It is a new function becauseCREATE OR REPLACEcannot change theVOIDreturn type offn_insert_idempotency_key.ON CONFLICT ... DO UPDATE ... WHERE. The result is the affected-row count.INSERT IGNORE, then a conditionalUPDATE ... WHERE CreatedAt < @validFromTicks.ON DUPLICATE KEY UPDATEwas rejected on purpose: with MySql.Data's default found-rows mode it reports 1 for both an insert and an untouched duplicate, so the result cannot be told apart. When theUPDATEmatches no row,INSERT IGNOREruns once more, because a cleanup job (for exampleusp_DeleteExpiredIdempotencyKeys-style maintenance) can delete the expired row between the two statements.ExecuteDeleteAsync, then the existing insert path runs. A primary key conflict returnsfalse.ExecuteUpdateAsyncis not used, because the Oracle MySQL provider cannot bind convertedDateTimeOffsetsetters (seeMySqlOutboxRepositoryExecutor).SET NXas before, so fix: Honor nullTimeToLive("never expire") instead of a 24h physical expiry inNetEvolve.Pulse.Redis#790 behaviour is kept: anullTTL means no expiry.PX. It compares UTC round-trip timestamps as text. Values that are not timestamps, or that carry a non-UTC offset (only possible from earlier versions through directStoreAsynccalls), are treated as present, so a live key is never overwritten.SET NX/ Lua split and the scripting requirement.IdempotencyKey.sqlstay re-runnable. Upgrade notes are in each provider README.IdempotencyKeyOptions.TimeToLiveremarks describe the refresh on store/reserve;IIdempotencyKeyRepository.StoreAsyncremarks point implementers toTryReserveAsync.decisions/2026-09-28-idempotency-refresh-expired-keys-on-reserve.md, stateproposed.Bug hunt
CreatedAt, so a second reservation is not rejected (#814)IdempotencyTestsBase.Should_Reject_Duplicate_After_Expired_ReserveShould_Reject_Expired_Reserve_Dup_Across_ScopesStoreAsyncon an expired key leavesExistsAsyncfalse, which breaks theStoreAsynccontractShould_Report_Key_Present_After_Expired_StoreCreatedAtand extend its windowShould_Keep_CreatedAt_When_Reserving_Live_KeyINSERT IGNOREand the refreshUPDATEmakesTryReserveAsyncreturnfalsefor an absent key (false duplicate)IdempotencyTestsBaseTTL tests (MySQL, CI)08:00-05:00= 13:00Z against a 12:00Z cutoff) is overwritten and a real duplicate is accepted"08" < "12"); the test was committed before the fix but could only run in CI (no local Docker)RedisIdempotencyTests.Should_Keep_Live_Non_Utc_Value_On_Reserve(negative and positive offset cases)usp_ReserveIdempotencyKeyfails with a raw constraint error for NULL parametersArgumentExceptionguards stop NULL keys before the procedure; hardened pertsql.instructions.mdIdempotencyStorecomposesExistsAsync+StoreAsync(not atomic, no refresh) instead of an atomic repository callIdempotencyStoreTests.TryReserveAsync_*,StoreAsync_WithTtl_RefreshesExpiredKeyThroughReserveThe integration test names are at most 51 characters, because the test table name is the method name and MySQL limits
IX_<table>_CreatedAtto 64 characters.Impact
IIdempotencyKeyRepository.TryReserveAsync(string, DateTimeOffset, DateTimeOffset?, CancellationToken)is a new member without a default implementation, per the accepted pre-1.0 interface evolution ADR. Custom repositories must implement it atomically.IIdempotencyKeyRepository.StoreAsyncstays on the interface but is no longer called byIdempotencyStore.TimeToLiveis set, reservation runsEVAL, so the Redis user needs the scripting commands (+@scripting). Values with a non-UTC offset written by earlier versions through directStoreAsynccalls stay unreservable until their physical expiry (keys stored without a TTL have none and must be deleted manually).IdempotencyKey.sqltogether with the package upgrade, because the new stored procedure/function is required. The scripts are idempotent and keep existing data. MySQL, SQLite, EF Core and Redis need no schema change.TimeToLive = nullis unchanged.Test evidence
dotnet build Pulse.slnx -c Release: 0 errors, 0 warnings.--treenode-filter "/*/*/*Idempotency*/*":IdempotencyTestsBaseand cover them; the fix: Refresh expired idempotency keys on re-store so duplicates are rejected again in all SQL/EF providers andNetEvolve.Pulse.Redis#814 acceptance criterion for these providers is met only once the CI integration run is green.csharpier check .: clean.