Shared EF Core building blocks — entity, mapping, DbContext, and the concurrency-safe
reserve/check/complete flow — that every relational idempotency store for
DKNet.AspCore.Idempotency derives from.
Not for app authors. This package has no
AddIdempotency...extension of its own and nothing in it ispublic. If you are wiring idempotency into an app, useDKNet.AspCore.Idempotency— its Choosing a store section covers when a relational store is the right call — plus a concrete provider package (MsSqlStore / NpgsqlStore). Read on only if you are adding support for a new relational database (MySQL, SQLite, …) to the DKNet idempotency family.
DbContext, and the same
“has this key been seen, and if not, claim it” operation. Only the duplicate-key error the
provider raises, the response-body column type, and the check-constraint quoting differ.MsSqlStore and
NpgsqlStore are each about four small types on
top of this base — copy either one.dotnet add package DKNet.AspCore.Idempotency.Relational
A new provider package supplies exactly four pieces, following the pattern
IdempotencySqlServerStore / IdempotencyPostgresStore already establish.
1. A closed DbContext — a one-line internal sealed subclass of IdempotencyDbContext
that pins the provider’s own closed DbContextOptions<TContext>:
internal sealed class IdempotencyDbContext(DbContextOptions<IdempotencyDbContext> options)
: DKNet.AspCore.Idempotency.Relational.Data.IdempotencyDbContext(options);
2. A concrete entity configuration — subclass IdempotencyKeyConfiguration and override the
two provider-specific members:
internal sealed class IdempotencyKeyConfiguration
: DKNet.AspCore.Idempotency.Relational.Data.Configurations.IdempotencyKeyConfiguration
{
protected override string BodyColumnType => "nvarchar(max)"; // "text" for Npgsql
protected override string StatusCodeCheckConstraintSql => "[StatusCode] BETWEEN 100 AND 599";
}
3. A concrete store — subclass IdempotencyRelationalStore<TContext> and override
IsProviderUniqueViolation:
internal sealed class IdempotencySqlServerStore(
IServiceProvider serviceProvider,
IOptions<IdempotencyOptions> options,
ILogger<IdempotencySqlServerStore> logger)
: IdempotencyRelationalStore<IdempotencyDbContext>(serviceProvider, options, logger)
{
protected override bool IsProviderUniqueViolation(DbUpdateException ex) =>
ex.InnerException is SqlException { Number: 2601 or 2627 };
}
4. A DI registration extension — register the closed DbContext and its factory, register the
shared IdempotencyMigrationHostedService<TContext> to migrate at startup, then hand the store to
AddIdempotentKey<TStore> from the core package:
public static IServiceCollection AddIdempotencyMsSqlStore(this IServiceCollection services, string connectionString)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentException.ThrowIfNullOrWhiteSpace(connectionString);
if (services.IsRegistered<IdempotencyDbContext>())
return services;
services.AddDbContext<IdempotencyDbContext>(options =>
{
options.UseSqlServer(connectionString, sqlOptions => sqlOptions
.MigrationsAssembly(typeof(IdempotencyMsSqlSetup).Assembly)
.MigrationsHistoryTable(nameof(IdempotencyDbContext), "migrate"));
}, optionsLifetime: ServiceLifetime.Singleton)
.AddDbContextFactory<IdempotencyDbContext>();
// Migrate once at startup rather than on the request path.
services.AddHostedService<IdempotencyMigrationHostedService<IdempotencyDbContext>>();
return services;
}
public static IServiceCollection AddIdempotencyWithMsSqlStore(
this IServiceCollection services, string connectionString, Action<IdempotencyOptions>? config = null)
{
services.AddIdempotencyMsSqlStore(connectionString);
return services.AddIdempotentKey<IdempotencySqlServerStore>(config);
}
IsRegistered<TService>() is the first-wins guard from DKNet.Fw.Extensions (namespace
Microsoft.Extensions.DependencyInjection). AddDbContextFactory<TContext> is required — IdempotencyRelationalStore<TContext> resolves
IDbContextFactory<TContext> per operation rather than injecting the context directly, so each
reserve/check/complete call gets its own short-lived context instance instead of sharing one
across a longer-lived scope. The AddHostedService<IdempotencyMigrationHostedService<TContext>>() call is what
moves migration off the request path — see
IdempotencyMigrationHostedService<TContext>
below.
Own the migrations too: they live in the derived provider project (a Migrations/ folder, its own
MigrationsAssembly), never in this base package — see
Gotchas & limits.
IdempotencyKeyEntity — the shared row shapeConstructed from an IdempotentKeyInfo plus a CachedResponse, with private setters so state only
changes through its own methods:
SanitizeKey(string key) — hashes the raw composite key to a fixed 64-character uppercase
hex SHA-256 digest before it is ever stored or queried, so structurally distinct keys never
collapse onto the same database value and no caller-supplied content lands in a column verbatim.
Throws ArgumentException on a null/whitespace key.Complete(CachedResponse response) — overwrites an in-flight reservation row with the finished
response’s status code, body, content type, and expiry.IsExpired — true once a non-null ExpiresAt has passed. Not mapped to a column.IdempotencyKeyConfiguration — the shared mappingThe IEntityTypeConfiguration<IdempotencyKeyEntity> base every provider’s own configuration
derives from. It owns every mapping detail that is identical across providers — key, lengths,
unicode flags, the ExpiresAt index, the unique UX_CompositeKey index — and defers exactly two
protected abstract members to the derived type (see
Configuration reference).
IdempotencyDbContext — mapping discovery from the derived assemblyIts OnModelCreating calls modelBuilder.ApplyConfigurationsFromAssembly(GetType().Assembly).
Because that scans the derived type’s assembly, each provider only has to place its own
IdempotencyKeyConfiguration next to its DbContext subclass; nothing needs registering
explicitly. The base constructor accepts the non-generic DbContextOptions, so a single base
works regardless of which closed DbContextOptions<TContext> the derived context declares.
IdempotencyRelationalStore<TContext> — reserve, check, completeThe IIdempotencyKeyStore implementation itself:
IsKeyProcessedAsync — looks up the sanitized composite key among unexpired rows; if absent
or expired, delegates to the reservation path below; if present with the sentinel 102
(Processing) status code, reports the key as still in-flight ((true, null)); otherwise returns
the winner’s cached CachedResponse.ReserveKeyAsync inserts a placeholder row with
StatusCode == 102 and relies on the UX_CompositeKey unique index to make exactly one
concurrent insert succeed. The loser’s DbUpdateException is caught via the abstract
IsProviderUniqueViolation hook, and the loser then either reports the winner’s
in-flight/completed state, or — if the row it collided with is itself expired — atomically
reclaims it with a conditional ExecuteUpdateAsync (only rows still matching the expired
predicate are updated), giving the same single-winner guarantee the unique index gives the
fresh-insert path.MarkKeyAsProcessedAsync — turns the reservation row into the durable cached result via
entity.Complete(...). If no reservation row is found it defensively inserts one, and a
unique-violation on that fallback insert is logged and swallowed rather than thrown.EnsureDatabaseCreatedAsync applies any pending EF Core
migrations once per distinct connection string (tracked in a static ConcurrentDictionary<string, bool>, guarded
by a SemaphoreSlim), so a process that targets more than one database (per-tenant databases, shared test hosts)
migrates each one rather than skipping every database after the first. This used to be the only migration path,
running from the first store call after startup while holding a process-wide lock — which could block every
concurrent request behind a schema migration. It is now a cheap fallback for a host that skips or reorders hosted
services; the primary mechanism is IdempotencyMigrationHostedService<TContext> below.IDbContextFactory<TContext> from an
AsyncServiceScope created once per store instance, and creates a fresh, short-lived
DbContext per call via CreateDbContextAsync(). The store is IAsyncDisposable and disposes
that scope.IdempotencyMigrationHostedService<TContext> — migrate once at startupinternal sealed class IdempotencyMigrationHostedService<TContext>(IDbContextFactory<TContext> dbContextFactory)
: IHostedLifecycleService
where TContext : DbContext
An IHostedLifecycleService that applies any pending migrations for TContext once, in StartAsync, before the
host begins serving requests. The migration still runs in that single start moment — the other lifecycle moments
(starting, started, stopping, stop, stopped) do no work. Both AddIdempotencyMsSqlStore and
AddIdempotencyNpgsqlStore register it
(services.AddHostedService<IdempotencyMigrationHostedService<IdempotencyDbContext>>()) as part of their DbContext
registration — an app that calls either extension gets it automatically, with nothing further to wire up.
This requires the host to actually run hosted services — a normal ASP.NET Core WebApplication.RunAsync() (or
Run()) does this as part of its lifecycle, so most apps need not think about it. It matters if you host
IdempotencyDbContext some other way (a worker service with a custom IHost build, a test harness that never
calls RunAsync/StartAsync on the host) — in that case the migration never runs at startup, and the store falls
back to the per-request guard described above, which self-heals the schema on the first request instead of
failing outright.
This package has no public API surface at all, so it has no customisation reference in the
sense the other pages on this site do: IdempotencyRelationalStore<TContext>,
IdempotencyDbContext, IdempotencyKeyEntity and IdempotencyKeyConfiguration are every type it
declares, and all four are internal. There is no options class, no Add… extension, and nothing
a consuming application can name, configure, or subclass. Runtime behaviour is driven entirely by
the core package’s IdempotencyOptions — see the
core configuration reference.
What follows is the internal implementation contract for the in-repo provider packages that
this package’s InternalsVisibleTo list already names, reproduced here because that is the only
audience this page has. None of it is available outside those assemblies.
What a derived configuration must supply:
| Member | Type | Purpose |
|---|---|---|
BodyColumnType |
protected abstract string |
Provider column type for the response body — nvarchar(max) on SQL Server, text on PostgreSQL. |
StatusCodeCheckConstraintSql |
protected abstract string |
CK_StatusCode_Valid SQL, differing only in identifier quoting — [StatusCode] BETWEEN 100 AND 599 vs "StatusCode" BETWEEN 100 AND 599. |
Everything else is fixed by the shared mapping, and a derived provider cannot change it:
| Column | Type / constraint | Notes |
|---|---|---|
Id |
Guid (PK) |
Guid.CreateVersion7(), time-ordered |
IdempotentKey |
max 150, required, non-Unicode | The raw caller-supplied key |
Endpoint |
max 250, required, Unicode | Route template, upper-invariant |
Method |
max 20, required, non-Unicode | HTTP method |
CompositeKey |
max 128, required, Unicode, unique (UX_CompositeKey) |
Uppercase hex SHA-256 of the raw composite key |
StatusCode |
int, required, check CK_StatusCode_Valid |
100–599; provider supplies the constraint SQL |
Body |
provider column type, max 1,048,576 chars | Unicode; null while reserved |
ContentType |
max 256, non-Unicode | MIME type, nullable |
CreatedAt / ExpiresAt |
DateTimeOffset / DateTimeOffset? |
ExpiresAt is indexed (IX_IdempotencyKeys_ExpiresAt) for cleanup queries |
Of the core package’s options, the shared store itself reads only InFlightReservationTimeout
(the reservation window on both the fresh-insert and reclaim paths). Expiration, the status-code
window and every other option are applied by the endpoint filter before the store is ever called.
The two “Proceed” exits on the right are the only paths that let a caller run the protected
handler, and each is reached through exactly one atomic step — the unique-index insert, or the
conditional UPDATE whose affected-row count is 1. Everything else converges on a duplicate
answer.
DKNet.AspCore.Idempotency core: IIdempotencyKeyStore, IdempotencyOptions, AddIdempotentKey<TStore>
▲
│ implements
DKNet.AspCore.Idempotency.Relational shared base: entity, mapping, DbContext, reserve/check/complete
▲ ▲
│ derives │ derives
DKNet.AspCore.Idempotency DKNet.AspCore.Idempotency
.MsSqlStore .NpgsqlStore
App code never references this package directly — it depends on DKNet.AspCore.Idempotency for
IIdempotencyKeyStore/AddIdempotentKey<TStore> and on a concrete provider package
(MsSqlStore/NpgsqlStore) for the AddIdempotencyWithXxxStore(...) registration that wires a
store built on this base into DI.
internal. A new provider project must be added to this package’s
InternalsVisibleTo list before it can derive from any of the base types described here — the
.csproj currently lists DKNet.AspCore.Idempotency.MsSqlStore,
DKNet.AspCore.Idempotency.NpgsqlStore, and their two test projects. This is not a publicly
extensible base for out-of-repo consumers.UX_CompositeKey being a real, enforced unique index in the database. A new
provider’s IdempotencyKeyConfiguration inherits the index declaration for free, but only a
database that actually creates it — via a migration that has been applied — makes the guarantee
hold; without it, concurrent duplicate requests can both “win” the reservation.Migrations/ folder — IdempotencyDbContext here is abstract, so it cannot be a design-time
migrations target. Each provider project owns its own migrations, its own
MigrationsAssembly(...), and its own IDesignTimeDbContextFactory<TContext> (see
MsSqlStore/NpgsqlStore’s Data/DbContextFactory.cs).IsProviderUniqueViolation must never match on message text. Error messages are localized by
the server’s login language; match the provider’s own structured error (SQL Server error numbers
2601/2627, Postgres SQL state 23505), never a substring.102 sentinel is deliberate, not a placeholder. It is a legal value under
CK_StatusCode_Valid (100–599) that no real completed HTTP response will ever use, so a
reservation row and a completed row are always distinguishable by StatusCode alone.IdempotencyOptions.InFlightReservationTimeout (default 30
seconds), not Expiration. A handler that runs longer than this timeout without completing
leaves its reservation reclaimable by the next caller for the same key.IdempotencyMigrationHostedService<TContext> is what the MsSql/Npgsql setup extensions register for this; a host
that never runs its IHostedServices (e.g. a manually-driven IHost that skips StartAsync) never gets that
startup migration, and instead depends on the per-request EnsureDatabaseCreatedAsync fallback in
IdempotencyRelationalStore<TContext> to self-heal the schema on first use.ExpiresAt until a colliding request
reclaims them; IX_IdempotencyKeys_ExpiresAt exists so a provider or an application can add its
own sweep, but the base ships none.IIdempotencyKeyStore
directly and does not use this base.