Table of Contents

Class DbContextRetryExtensions

Namespace
Trellis.EntityFrameworkCore
Assembly
Trellis.EntityFrameworkCore.dll
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

db DbContext

The DbContext to save changes on.

shouldRetry Func<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.

regenerate Func<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.

maxAttempts int

Maximum total attempts including the initial save. Must be at least 1. Defaults to 3 (initial + 2 retries).

cancellationToken CancellationToken

A 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, or regenerate is null.

ArgumentOutOfRangeException

Thrown when maxAttempts is less than 1.

InvalidOperationException

Thrown when retry would proceed but Entries contains an entry not in the Added state.