Trellis.Testing.Idempotency API Reference
Executable conformance suite for IIdempotencyStore implementations.
- Package:
Trellis.Testing.Idempotency - Namespace:
Trellis.Testing.Idempotency - Depends on:
Trellis.Asp(for the contract types),xunit.v3.extensibility.core4.0.1,FluentAssertions
Consuming test projects should use xunit.v3 4.0.1 or later with Microsoft.Testing.Platform 2.
The conformance library references the attributes and extensibility surface only; it does not
bring in the executable test runner.
Use this file when
- You are writing an
IIdempotencyStoreover Redis, Cosmos DB, a relational database, or anything bespoke, and need to prove it satisfies the contract. - You are reviewing an existing store and want the exact rules it must obey — atomicity of reserve, expiry ownership, conditional complete/abandon.
- A production incident suggests duplicate execution or a lost response, and you need a reproducible test rather than a theory.
Patterns Index
| Goal | Use this | See |
|---|---|---|
| Add conformance coverage to your store | Derive a test class from IdempotencyStoreConformance |
Quick start, IdempotencyStoreConformance |
| Supply your store to the suite | The single required member | Required member |
| Adapt the suite to a backend with different timing or cleanup needs | Optional overrides and the protected helpers | Optional overrides, Helpers available to derived classes |
| Learn the contract itself before implementing | The reserve / expiry / complete / abandon / concurrency rules | The rules pinned by the suite |
| Check your design against the failures that fail silently | Non-atomic reserve, server-side expiry, eviction, unconditional abandon, snapshot cost | Implementation traps the suite catches |
Why this package exists
Trellis.Asp ships exactly one IIdempotencyStore: InMemoryIdempotencyStore, which its own
documentation describes as "not safe across multiple instances or process restarts". For a
distributed production store, Trellis ships CosmosIdempotencyStore in the separate
Trellis.Asp.Idempotency.Cosmos package, registered with AddCosmosIdempotencyStore.
Applications using Redis, a relational database, or another backend can implement their own store.
Stores receive a resolved scope, parsed key, and an opaque fingerprint string, not the
fingerprint's request components. They must key entries only on (scope, key) and compare the
supplied fingerprint with the stored value, never use it as a third key component. The middleware
owns fingerprint computation and scope resolution; see
request identity and fingerprint for the
full input list and the default actor/shared-anonymous scope.
The contract those stores must satisfy is subtle, and a violation fails silently. A store that
reserves non-atomically lets two racing callers both execute the handler; a store whose
AbandonAsync deletes unconditionally destroys a response that CompleteAsync already persisted.
Neither throws. The symptom is a customer charged twice, weeks later.
This package turns each rule into a test you inherit.
Quick start
Add a single test class per store:
namespace Contoso.Billing.Tests;
using Trellis.Asp.Idempotency;
using Trellis.Testing.Idempotency;
public sealed class RedisIdempotencyStoreConformanceTests : IdempotencyStoreConformance
{
// Expiry is enforced by the Redis server, whose clock cannot be faked, so use short real
// timeouts and let AdvanceAsync keep its default real delay.
protected override TimeSpan ReservationTimeout => TimeSpan.FromSeconds(2);
protected override TimeSpan Ttl => TimeSpan.FromSeconds(4);
protected override ValueTask<IIdempotencyStore> CreateStoreAsync(IdempotencyOptions options) =>
new(new RedisIdempotencyStore(_multiplexer, options));
}
For a store that reads the clock in-process through TimeProvider, override AdvanceAsync
instead and keep the default timeouts, so the suite runs instantly:
public sealed class InMemoryIdempotencyStoreConformanceTests : IdempotencyStoreConformance
{
private readonly FakeTimeProvider _time = new();
protected override ValueTask<IIdempotencyStore> CreateStoreAsync(IdempotencyOptions options) =>
new(new InMemoryIdempotencyStore(options, _time));
protected override Task AdvanceAsync(TimeSpan duration)
{
_time.Advance(duration);
return Task.CompletedTask;
}
}
IdempotencyStoreConformance
public abstract class IdempotencyStoreConformance
Required member
| Member | Notes |
|---|---|
protected abstract ValueTask<IIdempotencyStore> CreateStoreAsync(IdempotencyOptions options) |
Called once per test. Must honour options.Ttl and options.ReservationTimeout, or the expiry tests are vacuous. |
Optional overrides
| Member | Default | Override when |
|---|---|---|
protected virtual TimeSpan ReservationTimeout |
30 s | Expiry is server-enforced — drop to a few seconds. |
protected virtual TimeSpan Ttl |
1 h | Expiry is server-enforced — drop to a few seconds. |
protected virtual Task AdvanceAsync(TimeSpan duration) |
Task.Delay(duration) |
The store reads an injectable clock — advance the fake clock and return Task.CompletedTask. |
protected virtual int ConcurrentReservationAttempts |
32 | The store is a remote service with limited throughput. |
Helpers available to derived classes
| Member | Notes |
|---|---|
protected string Scope { get; } |
Unique per test instance (xUnit constructs one per [Fact]), so a suite is safe against a shared Redis or Cosmos DB instance, in parallel. |
protected static IdempotencyResponseSnapshot SnapshotFor(string fingerprint, byte bodyMarker = 0x7B) |
Builds a minimal distinguishable snapshot. |
protected static void ShouldMatch(IdempotencyResponseSnapshot actual, IdempotencyResponseSnapshot expected) |
Field-by-field comparison. See the warning below. |
Do not assert snapshot equality with
Should().Be(...).IdempotencyResponseSnapshotis a record whoseHeadersandBodymembers compare by reference. Record equality therefore passes only for a store that returns the very instance it was handed — that is, only for an in-memory store. Every durable store serialises and returns an equal-but-distinct instance. UseShouldMatch, which compares field by field and matches header names case-insensitively, as the snapshot contract specifies.
The rules pinned by the suite
Reserving
| Test | Rule |
|---|---|
Reserve_on_a_free_key_returns_Reserved_with_a_non_empty_reservation_id |
A free key is granted with a usable token. |
Reserve_while_another_request_holds_the_key_returns_AlreadyInFlight |
Concurrent duplicate is told to retry; RetryAfter is positive and <= ReservationTimeout. |
Reserve_under_a_different_scope_does_not_collide |
Scope isolates tenants and actors. |
Reserve_after_Complete_replays_the_snapshot_for_a_matching_fingerprint |
The core replay guarantee. |
Reserve_after_Complete_with_a_different_fingerprint_returns_BodyHashMismatch |
Key reuse in the same scope with a different request fingerprint is rejected, not replayed or stored as a separate entry. StoredFingerprint carries the original. |
Reserve_while_in_flight_with_a_different_fingerprint_returns_BodyHashMismatch |
Same protection before the first request finishes. |
Expiry
| Test | Rule |
|---|---|
Reserve_after_the_reservation_timeout_takes_over_with_a_new_reservation_id |
A crashed handler cannot hold a key forever; takeover issues a new id. |
Reserve_after_the_reservation_timeout_with_a_different_fingerprint_returns_BodyHashMismatch |
Takeover is for retries of the same request only. |
Reserve_after_the_ttl_elapses_treats_a_completed_entry_as_absent |
Snapshots are retained for Ttl and no longer. |
Completing and abandoning
| Test | Rule |
|---|---|
Complete_with_a_reservation_id_that_lost_the_slot_is_ignored |
A displaced request cannot publish its response over the winner's. |
Abandon_releases_the_slot_for_an_immediate_retry |
A failed handler frees the key at once. |
Abandon_with_a_reservation_id_that_lost_the_slot_is_ignored |
A displaced request cannot free the winner's slot. |
Abandon_after_Complete_must_not_delete_the_persisted_snapshot |
The subtle one — see below. |
Complete_on_a_key_that_was_never_reserved_is_ignored |
Best-effort cleanup, no throw, no entry created. |
Abandon_on_a_key_that_was_never_reserved_is_ignored |
Best-effort cleanup, no throw. |
Concurrency
| Test | Rule |
|---|---|
Concurrent_reservations_of_one_key_grant_exactly_one_winner |
Reservation must be a single atomic operation. The load-bearing guarantee. Callers are released together from a shared gate rather than through Parallel.ForEachAsync, so the race is exercised even on a single-CPU host. |
An_in_flight_reservation_survives_traffic_on_other_keys |
Unrelated traffic must not orphan a slow handler's reservation. |
Implementation traps the suite catches
Non-atomic reserve
A read-then-write reserve (GetAsync then SetAsync) passes every single-threaded test and fails
under load, executing the handler twice. Reserve with one atomic primitive:
| Backing store | Atomic reserve |
|---|---|
| Redis | Atomic Lua script: create a non-expiring reservation containing the fingerprint and lease timestamp; for an existing reservation, compare the fingerprint before conditionally taking over an expired lease |
| Cosmos DB | CreateItemAsync — a duplicate id in the same partition returns HTTP 409 |
| Relational | INSERT against a unique index on (scope, key) — catch the duplicate-key violation |
ReservationTimeout expires the reservation's lease, not its fingerprint metadata.
Do not use SET ... NX PX <reservationTimeout> to expire the entire reservation: a different-fingerprint
retry must still return BodyHashMismatch after that timeout. Retain the fingerprint and compare it
before atomically granting a same-fingerprint takeover. Completed snapshots have a separate Ttl;
apply response expiry when completing the entry, not when reserving it.
IDistributedCache cannot back a conforming store: it has no atomic set-if-not-exists, so
TryReserveAsync is not implementable on it. Use StackExchange.Redis directly.
Trusting server-side expiry
Cosmos DB deletes expired items on a best-effort background sweep, so an item can outlive its
ttl and still be returned by a read. Record your own expiry timestamp on the item and re-check it
when reading, rather than assuming a returned item is live.
Eviction
A Redis instance configured with allkeys-lru can evict a live reservation under memory pressure,
silently permitting a double execution. Use noeviction for the idempotency database, and consider
asserting the policy at startup with CONFIG GET maxmemory-policy.
Unconditional abandon
IdempotencyMiddleware calls AbandonAsync from the failure paths around CompleteAsync. If
CompleteAsync persisted the snapshot and then threw on a later step, an AbandonAsync that
deletes unconditionally destroys a durably recorded response, and the retry re-executes the
handler. Delete only when the entry is still in the reserved state.
Snapshot size and cost
IdempotencyOptions.MaxResponseBodyBytes defaults to 1 MiB. On Cosmos DB, request-unit cost scales
with item size, so a 1 MiB snapshot costs upwards of 100 RU to write. Lower the cap, or store large
bodies in blob storage and keep a reference in the snapshot.
See also
docs/docfx_project/api_reference/trellis-api-asp.md— the idempotency middleware and theIIdempotencyStorecontract itself.docs/docfx_project/api_reference/trellis-api-testing-aspnetcore.md— ASP.NET Core integration test helpers.