Skip to content

fix(redis): reserve idempotency keys atomically with SET NX - #906

Merged
samtrion merged 9 commits into
mainfrom
fix/816-redis-atomic-set-nx
Sep 29, 2026
Merged

samtrion merged 9 commits into
mainfrom
fix/816-redis-atomic-set-nx

Conversation

@samtrion

@samtrion samtrion commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Summary

AddRedisIdempotencyStore was documented as atomic SET NX, but reservation used to go through the non-atomic ExistsAsync + StoreAsync default. Concurrent submissions of the same key all got true from TryReserveAsync, so the handler ran once per submission.

The production fix for this is now on main: #909 added IIdempotencyKeyRepository.TryReserveAsync, and the Redis implementation of it is a single atomic server operation. It uses SET NX when no TimeToLive is set, which keeps the #790 behavior (null TTL means never expire). When a TimeToLive is set, it uses one Lua script that reserves an absent key or refreshes an expired one. After the merge with main, this PR adds the concurrency regression tests that prove #816 is fixed, plus one doc correction.

Closes #816

Changes

Bug hunt

Suspicion Confirmed? Test
Parallel TryReserveAsync calls for the same key all return true on Redis Yes before #909/this PR: 20 of 20 returned true RedisIdempotencyTests.Should_Reserve_Once_When_Reserving_Same_Key_Concurrently
Parallel SendAsync calls of the same idempotent command run the handler more than once Yes before: the handler ran 20 times RedisIdempotencyTests.Should_Run_Handler_Once_When_Sending_Same_Command_Concurrently
Several parallel callers can each win the re-reservation of a logically expired key No with the Lua script: it runs atomically on the server RedisIdempotencyTests.Should_Reserve_Expired_Key_Once_When_Reserving_Concurrently
With TimeToLive = null, an existing key becomes reservable again No *IdempotencyTests.Should_Never_Reserve_Existing_Key_Again_When_TimeToLive_Is_Null

Impact

Test evidence

  • dotnet build Pulse.slnx -c Release: 0 errors, 0 warnings. csharpier check . is clean.
  • Unit tests, all TFMs: 7449 passed, 0 failed.
  • Integration tests, local, net10.0:
    • SQLite*Idempotency*: 30 passed, 6 skipped. The skipped ones are the gated concurrency tests.
    • InMemory*Idempotency*: 15 passed, 3 skipped.
  • The Docker-backed suites (Redis, SQL Server, PostgreSQL, MySQL) could not run locally, because the Docker Desktop engine returned HTTP 500. CI is the reference for them.

… reserve keys through it

IdempotencyStore.TryReserveAsync now delegates to the repository, passing the
current timestamp and the logical-expiry cutoff. All first-party providers
implement the new member by composing ExistsAsync and StoreAsync, which keeps
their current non-atomic behaviour.

Refs #816
…re-and-set

RedisIdempotencyKeyRepository.TryStoreAsync now returns the SET NX result
instead of composing ExistsAsync and StoreAsync. A logically expired key that
is still physically present is replaced through a WATCH/MULTI transaction
conditioned on the observed value, so exactly one concurrent reservation wins
and the refreshed timestamp rejects later duplicates. Without a TimeToLive an
existing key is never reserved again.

Closes #816
…he shared base

Reservation and TTL tests now live in IdempotencyTestsBase. The concurrency
tests and the refresh assertion only run for providers that reserve keys
atomically (currently Redis); other providers keep the non-atomic behaviour
tracked in #814.
@samtrion
samtrion requested a review from a team as a code owner September 28, 2026 23:22
@samtrion
samtrion requested a review from Hnogared September 28, 2026 23:22
@codecov

codecov Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.84%. Comparing base (0a4c857) to head (132ba9a).

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #906      +/-   ##
==========================================
+ Coverage   95.81%   95.84%   +0.02%     
==========================================
  Files         275      275              
  Lines       11879    11879              
  Branches     1122     1122              
==========================================
+ Hits        11382    11385       +3     
+ Misses        278      275       -3     
  Partials      219      219              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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.
@samtrion
samtrion merged commit b90cf55 into main Sep 29, 2026
14 checks passed
@samtrion
samtrion deleted the fix/816-redis-atomic-set-nx branch September 29, 2026 01:40
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.

fix: The Redis idempotency store is documented as atomic SET NX but reserves keys with the non-atomic Exists+Store default in NetEvolve.Pulse.Redis

1 participant