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:
@@ -2,6 +2,7 @@ using GerbilManagerWebAPI.Import;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
|
||||
namespace GerbilManager.Tests
|
||||
{
|
||||
@@ -226,6 +227,52 @@ namespace GerbilManager.Tests
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test 7: P0 REGRESSION — execute works under a retrying execution strategy ──
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_works_under_retrying_execution_strategy()
|
||||
{
|
||||
// Regression: NpgsqlRetryingExecutionStrategy (MaxRetryCount>0) calls
|
||||
// OnFirstExecution() at the start of ExecuteAsync, which throws
|
||||
// InvalidOperationException when it detects a user-initiated transaction
|
||||
// that was NOT opened through the strategy. This test wires the same check
|
||||
// (via FakeRetryingStrategy, MaxRetryCount=1) so the bug would surface in CI
|
||||
// without a live Npgsql instance.
|
||||
//
|
||||
// With the BUG (direct BeginTransactionAsync before strategy.ExecuteAsync):
|
||||
// → OnFirstExecution sees active user tx → InvalidOperationException
|
||||
// With the FIX (BeginTransactionAsync inside strategy.ExecuteAsync lambda):
|
||||
// → OnFirstExecution: no tx yet → OK
|
||||
var conn = new SqliteConnection("DataSource=:memory:");
|
||||
conn.Open();
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>()
|
||||
.UseSqlite(conn)
|
||||
.ReplaceService<IExecutionStrategyFactory, FakeRetryingStrategyFactory>()
|
||||
.Options;
|
||||
var db = new ApplicationContext(opts);
|
||||
db.Database.EnsureCreated();
|
||||
|
||||
await using (conn)
|
||||
await using (db)
|
||||
{
|
||||
WriteLitters(Array.Empty<object>());
|
||||
WriteAnimals(new[]
|
||||
{
|
||||
new { wsCode = "3/3", litterDob = "01.01.2023", name = "Pixie", gender = "female",
|
||||
owner = "Retry Adopter", abgabeDate = "01.03.2023",
|
||||
deathDate = "", deathCause = "", farbschlag = "" }
|
||||
});
|
||||
|
||||
// Must NOT throw InvalidOperationException (user-initiated tx rejected)
|
||||
var report = await new ImportDocxService(db, _dir).RunAsync(execute: true);
|
||||
|
||||
Assert.True(report.Executed);
|
||||
Assert.Equal(1, report.Created);
|
||||
Assert.Equal(1, await db.Gerbils.CountAsync());
|
||||
Assert.Equal(1, await db.Contacts.CountAsync());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test 6: P0 REGRESSION — same-name siblings get distinct ExternalRefs ─
|
||||
|
||||
[Fact]
|
||||
@@ -273,4 +320,24 @@ namespace GerbilManager.Tests
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers for Test 7 ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Execution strategy with MaxRetryCount=1 so that EF Core's base
|
||||
/// OnFirstExecution() throws when it detects a user-initiated transaction
|
||||
/// that was not opened through CreateExecutionStrategy().ExecuteAsync().
|
||||
/// ShouldRetryOn=false → no actual retry; the check alone is what we need.
|
||||
/// </summary>
|
||||
internal sealed class FakeRetryingStrategy(ExecutionStrategyDependencies deps)
|
||||
: ExecutionStrategy(deps, maxRetryCount: 1, maxRetryDelay: TimeSpan.Zero)
|
||||
{
|
||||
protected override bool ShouldRetryOn(Exception exception) => false;
|
||||
}
|
||||
|
||||
internal sealed class FakeRetryingStrategyFactory(ExecutionStrategyDependencies deps)
|
||||
: IExecutionStrategyFactory
|
||||
{
|
||||
public IExecutionStrategy Create() => new FakeRetryingStrategy(deps);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user