FIX-8D-RETRY: wrap docx execute transaction in CreateExecutionStrategy
NpgsqlRetryingExecutionStrategy rejects user-initiated transactions: SaveChanges inside a BeginTransactionAsync block triggers OnFirstExecution which throws InvalidOperationException. Fix: CreateExecutionStrategy().ExecuteAsync wraps the entire tx block; mutable state (counters, contactByNorm, change tracker) reset at lambda top for idempotent retry. Logic extracted to RunLoopAsync local function shared by dry-run and execute paths. Regression test (Test 7): FakeRetryingStrategy with MaxRetryCount=1 reproduces the OnFirstExecution check in CI without a live Npgsql instance. 165/165 tests, ef has-pending=No, no schema change. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -22,7 +22,10 @@ namespace GerbilManagerWebAPI.Import
|
||||
/// NEVER overwrites a manually-set non-null value (fill-NULL-only for all fields).
|
||||
///
|
||||
/// Idempotent: running multiple times is safe. Re-run finds existing rows via ExternalRef.
|
||||
/// Execute wraps all writes in a single transaction (atomic: crash → full rollback).
|
||||
/// Execute wraps all writes in a single transaction via CreateExecutionStrategy() so that
|
||||
/// providers using EnableRetryOnFailure (e.g. NpgsqlRetryingExecutionStrategy) are
|
||||
/// compatible. The strategy lambda resets all mutable state at the top so it is safe
|
||||
/// to re-run on transient-failure retry.
|
||||
/// Execute is gated by the endpoint; this service only acts when asked.
|
||||
/// </summary>
|
||||
public sealed class ImportDocxService
|
||||
@@ -94,6 +97,11 @@ namespace GerbilManagerWebAPI.Import
|
||||
.GroupBy(c => NormalizeName(c.Name))
|
||||
.ToDictionary(g => g.Key, g => g.First().Id);
|
||||
|
||||
// Snapshot of DB contacts before any writes.
|
||||
// Used to reset contactByNorm on strategy retry (rolled-back contacts vanish from DB
|
||||
// but would remain in the in-memory dict without this reset).
|
||||
var contactByNormBase = new Dictionary<string, Guid>(contactByNorm);
|
||||
|
||||
// ColorVariety lookup: normalized name → Id (for CREATE path Farbschlag matching)
|
||||
var colorVarietyByName = (await _db.ColorVarieties
|
||||
.Select(cv => new { cv.Id, cv.Name })
|
||||
@@ -105,19 +113,15 @@ namespace GerbilManagerWebAPI.Import
|
||||
int ownerLinked = 0, ownerCreated = 0, skipped = 0;
|
||||
|
||||
// Ordinal counter for collision-free ExternalRef within this batch.
|
||||
// Two animals with the same base ref (same ws+name+litterDob) get -2, -3 suffixes.
|
||||
var externalRefOrdinals = new Dictionary<string, int>();
|
||||
|
||||
// Belt-and-suspenders: guard against adding the same ExternalRef twice in one run.
|
||||
var batchRefs = new HashSet<string>();
|
||||
|
||||
// --- Planning pass (dry-run counts + execute writes) ---
|
||||
// Execute path is wrapped in a single transaction for atomicity.
|
||||
Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction? tx = null;
|
||||
if (execute)
|
||||
tx = await _db.Database.BeginTransactionAsync();
|
||||
|
||||
try
|
||||
// Inner loop — shared by dry-run and execute paths.
|
||||
// All local variables above are captured by reference (C# closure), so the strategy
|
||||
// lambda can reset them before each retry and RunLoopAsync sees the fresh state.
|
||||
async Task RunLoopAsync()
|
||||
{
|
||||
foreach (var da in docxAnimals)
|
||||
{
|
||||
@@ -276,21 +280,36 @@ namespace GerbilManagerWebAPI.Import
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Flush all gerbil inserts + enrich updates in one shot (within the tx)
|
||||
if (execute && (animalsCreated + litterLinked + goHomeFilled + deathFilled + ownerCreated) > 0)
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
if (tx is not null) await tx.CommitAsync();
|
||||
}
|
||||
catch
|
||||
|
||||
if (!execute)
|
||||
{
|
||||
// tx.DisposeAsync (in finally) rolls back if not committed
|
||||
throw;
|
||||
// Dry-run: just count, no writes, no transaction needed.
|
||||
await RunLoopAsync();
|
||||
}
|
||||
finally
|
||||
else
|
||||
{
|
||||
if (tx is not null) await tx.DisposeAsync();
|
||||
// Execute: wrap the entire transaction in the execution strategy so that providers
|
||||
// with EnableRetryOnFailure (NpgsqlRetryingExecutionStrategy) are compatible.
|
||||
// The lambda resets all mutable state at the top so retries start clean.
|
||||
var strategy = _db.Database.CreateExecutionStrategy();
|
||||
await strategy.ExecuteAsync(async () =>
|
||||
{
|
||||
// Reset mutable state — idempotent on strategy retry
|
||||
_db.ChangeTracker.Clear();
|
||||
externalRefOrdinals.Clear();
|
||||
batchRefs.Clear();
|
||||
animalsCreated = 0; litterLinked = 0; goHomeFilled = 0; deathFilled = 0;
|
||||
ownerLinked = 0; ownerCreated = 0; skipped = 0;
|
||||
// Rebuild from DB snapshot: contacts added in a failed attempt were rolled back
|
||||
contactByNorm = new Dictionary<string, Guid>(contactByNormBase);
|
||||
|
||||
await using var tx = await _db.Database.BeginTransactionAsync();
|
||||
await RunLoopAsync();
|
||||
if ((animalsCreated + litterLinked + goHomeFilled + deathFilled + ownerCreated) > 0)
|
||||
await _db.SaveChangesAsync();
|
||||
await tx.CommitAsync();
|
||||
});
|
||||
}
|
||||
|
||||
notes.Add($"Quelle: {docxLitters.Count} Würfe, {docxAnimals.Count} Tier-Zeilen aus der docx.");
|
||||
|
||||
Reference in New Issue
Block a user