Class DbContextRetryExtensions
- Namespace
- Trellis.EntityFrameworkCore
- Assembly
- Trellis.EntityFrameworkCore.dll
Retry-on-collision save extensions for DbContext. Adds SaveChangesWithRetryAsync(DbContext, Func<DbUpdateException, bool>, Func<IReadOnlyList<EntityEntry>, int, CancellationToken, ValueTask<bool>>, int, CancellationToken) for the system-generated unique-key INSERT-and-regenerate pattern.
public static class DbContextRetryExtensions
- Inheritance
-
DbContextRetryExtensions
- Inherited Members
Methods
SaveChangesWithRetryAsync(DbContext, Func<DbUpdateException, bool>, Func<IReadOnlyList<EntityEntry>, int, CancellationToken, ValueTask<bool>>, int, CancellationToken)
Saves changes. On DbUpdateException classified as retryable by
shouldRetry, detaches only the entities reported by
Entries (NEVER the full change tracker), invokes
regenerate so the caller can mutate the conflicting entities'
natural keys in place, re-Adds them, and retries up to maxAttempts
total attempts. Designed for the INSERT-with-system-generated-unique-key pattern
(e.g. short codes, slugs, tokens).
public static Task<Result<Unit>> SaveChangesWithRetryAsync(this DbContext db, Func<DbUpdateException, bool> shouldRetry, Func<IReadOnlyList<EntityEntry>, int, CancellationToken, ValueTask<bool>> regenerate, int maxAttempts = 3, CancellationToken cancellationToken = default)
Parameters
dbDbContextThe DbContext to save changes on.
shouldRetryFunc<DbUpdateException, bool>Classifies a DbUpdateException. Return true to detach-regenerate-retry; return false to map to Error.Conflict and return immediately. Concurrency exceptions bypass this classifier.
regenerateFunc<IReadOnlyList<EntityEntry>, int, CancellationToken, ValueTask<bool>>Invoked with the entries that were detached and the 1-based attempt number (where
attempt=1 is the first retry preparation after the initial save failure,attempt=2 is the second, etc.). Return true to continue with the next save attempt; return false to abort retry, leave entries detached, and return Error.Conflict.maxAttemptsintMaximum total attempts including the initial save. Must be at least
1. Defaults to3(initial + 2 retries).cancellationTokenCancellationTokenA token to observe while waiting for the task to complete.
Returns
- Task<Result<Unit>>
A Result<TValue> with Unit representing success, or an Error.Conflict failure on classified collision.
Remarks
Concurrency exceptions bypass retry. DbUpdateConcurrencyException
is mapped to Error.Conflict with reason code
ConcurrentModification WITHOUT calling shouldRetry —
regenerating a natural key cannot resolve a stale rowversion conflict.
Only Added entries are retryable. If Entries contains any entry that is not in the Added state when retry would otherwise begin, this method throws InvalidOperationException. The helper does not preserve original values, per-property IsModified flags, or temporary-value metadata across the detach/re-attach cycle that Modified would require.
Detach scope. Only entries reported by Entries
are detached. Sibling aggregates pending in the change tracker (an outbox row staged
by a domain-event handler; an unrelated Added entity from
the same logical operation; an entity promoted via db.Entry(x).State = Added
from outside Add(TEntity)) are unaffected.
Detach is conditional on attempting a retry. If
shouldRetry returns false, OR if the failure
occurs on the final allowed attempt (maxAttempts exhausted), no
detach is performed and the change tracker is left untouched. Exhaustion returns
Error.Conflict with reason code RetryExhausted. If
regenerate returns false, the conflicting
entries remain detached and the method returns Error.Conflict with
reason code RetryAborted. These reason codes are distinct from the
caller-classifier-related DuplicateKey / ReferentialIntegrity
codes (which are only returned when shouldRetry returns
false for a recognised non-retryable DbUpdateException),
so a broader shouldRetry classifier (e.g. one accepting a
provider-specific transient error) does not surface a misleading
DuplicateKey reason code on exhaust/abort.
Failure mapping for non-retryable DbUpdateException. Matches SaveChangesResultAsync(DbContext, CancellationToken): foreign-key violations map to reason code ReferentialIntegrity; duplicate-key violations map to DuplicateKey; unrecognized DbUpdateException shapes (connection failures wrapped as DbUpdateException, trigger failures, etc.) are rethrown rather than silently mapped to a conflict.
Callback exceptions propagate. An exception thrown by
shouldRetry propagates out without any detach having occurred.
An exception thrown by regenerate propagates out with the
conflicting entries already detached.
Graph and owned types. Only the entries in Entries are detached and re-added. Dependent or owned entities must be reachable from a re-added aggregate root by EF's normal state-cascade rules, or be tracked by the caller before the next attempt. Test against the target provider when in doubt.
Explicit transactions. Provider behavior after a constraint violation inside
an open transaction varies. SQLite accepts further commands on the same transaction
after a unique-constraint failure; SQL Server may abort the transaction depending on
the error and isolation level. Test against the target provider before relying on
retry inside TransactionalCommandBehavior or a hand-rolled transaction scope.
Exceptions
- ArgumentNullException
Thrown when
db,shouldRetry, orregenerateis null.- ArgumentOutOfRangeException
Thrown when
maxAttemptsis less than1.- InvalidOperationException
Thrown when retry would proceed but Entries contains an entry not in the Added state.