FIX-IMPORT-CYCLE: break Gerbil↔Litter circular FK dependency in ImportService

Root cause: synth litters (from chart parentRefs) were added with FatherId/MotherId pointing
to gerbils that were also [Added] in the same SaveChanges batch. With 337 derived litters from
41 pedigrees, multi-generational references created a true dependency cycle:
  Gerbil [Added] ← FK{MotherId} Litter [Added] ← FK{LitterId} Gerbil [Added]
EF's topo-sort threw InvalidOperationException; smaller datasets (REIMPORT-1/2) didn't
trigger it because they had fewer chart-derived litters.

Fix: add synth litters with FatherId=null/MotherId=null (deferred), track intended FKs
in synthLitterPendingParents, apply them in a separate SaveChanges after all gerbils are
persisted. Single-direction Gerbil→Litter dependency in the main save; no cycle possible.
No schema change, no migration.

Regression test: artificial A←B←A cycle in SQLite (FK-enforced) must not throw.
Also verifies deferred FK update sets litter parent correctly after execute.

GATE: 158/158 C# tests, has-pending-model-changes=No

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 22:47:18 +02:00
parent 8e475dff6a
commit c6edb2e9d0
2 changed files with 77 additions and 6 deletions

View File

@@ -261,6 +261,54 @@ namespace GerbilManager.Tests
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
}
[Fact]
public async Task Execute_circular_gerbil_litter_dependency_does_not_throw()
{
// Regression for "Unable to save changes because a circular dependency was detected":
// Gerbil [Added] ← FK{MotherId} Litter [Added] ← FK{LitterId} Gerbil [Added].
// Triggered when a gerbil is both parent (in one synth litter) and offspring
// (LitterId → another synth litter) in the SAME SaveChanges batch — forms a cycle EF
// topo-sort cannot resolve. This test encodes the minimal reproducer: A's mother is B,
// B's mother is A (artificial genealogical cycle, but triggers the EF cycle reliably).
var dir = Path.Combine(Path.GetTempPath(), "cycle-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
using var conn = new SqliteConnection("DataSource=:memory:");
conn.Open();
try
{
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
File.WriteAllText(Path.Combine(dir, "animals.json"), """
[
{"id":"alpha","name":"Alpha","dob":"01.01.2020","death":"","farbschlag":"",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
"parentRefs":[{"name":"Beta","dob":"01.01.2019","roleGuess":"mother","method":"chart-position","confidence":"medium"}]},
{"id":"beta","name":"Beta","dob":"01.01.2019","death":"","farbschlag":"",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
"parentRefs":[{"name":"Alpha","dob":"01.01.2020","roleGuess":"mother","method":"chart-position","confidence":"medium"}]}
]
""");
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
using var db = new ApplicationContext(opts);
await db.Database.EnsureCreatedAsync();
// must NOT throw InvalidOperationException (circular dependency)
var report = await new ImportService(db, dir, dir).RunAsync(execute: true);
Assert.Equal(2, await db.Gerbils.CountAsync());
var alpha = await db.Gerbils.SingleAsync(g => g.ExternalRef == "alpha");
var beta = await db.Gerbils.SingleAsync(g => g.ExternalRef == "beta");
// both should be litter-linked
Assert.NotNull(alpha.LitterId);
Assert.NotNull(beta.LitterId);
// deferred FK update must have set the litter parents correctly
var alphaLitter = await db.Litters.SingleAsync(l => l.Id == alpha.LitterId);
Assert.Equal(beta.Id, alphaLitter.MotherId);
var betaLitter = await db.Litters.SingleAsync(l => l.Id == beta.LitterId);
Assert.Equal(alpha.Id, betaLitter.MotherId);
}
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
}
[Fact]
public async Task Derived_litter_with_quarantined_parent_leaves_FK_null_no_throw()
{