From c6edb2e9d00e0fd78e903f2a3eb54c69b0d2f75a Mon Sep 17 00:00:00 2001 From: Gulum Date: Sat, 6 Jun 2026 22:47:18 +0200 Subject: [PATCH] =?UTF-8?q?FIX-IMPORT-CYCLE:=20break=20Gerbil=E2=86=94Litt?= =?UTF-8?q?er=20circular=20FK=20dependency=20in=20ImportService?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- GerbilManager.Tests/ImportServiceTests.cs | 48 +++++++++++++++++++++ GerbilManagerWebAPI/Import/ImportService.cs | 35 ++++++++++++--- 2 files changed, 77 insertions(+), 6 deletions(-) diff --git a/GerbilManager.Tests/ImportServiceTests.cs b/GerbilManager.Tests/ImportServiceTests.cs index a90869d..5af5e2d 100644 --- a/GerbilManager.Tests/ImportServiceTests.cs +++ b/GerbilManager.Tests/ImportServiceTests.cs @@ -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().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() { diff --git a/GerbilManagerWebAPI/Import/ImportService.cs b/GerbilManagerWebAPI/Import/ImportService.cs index 366bef4..0b4bbb6 100644 --- a/GerbilManagerWebAPI/Import/ImportService.cs +++ b/GerbilManagerWebAPI/Import/ImportService.cs @@ -289,10 +289,15 @@ namespace GerbilManagerWebAPI.Import litterParents[lid] = (f, m); } - // PASS 2: stage synthesized litters (parents already guarded above). DO NOT save them - // before the animals — the offspring AND the parent gerbils are created in the loop - // below, so a single SaveChanges at the end lets EF order parents→litters→offspring - // (all FKs are nullable). Saving litters first is exactly what caused the FK fault. + // FIX-IMPORT-CYCLE: track deferred synth litter parent FKs (populated in PASS 2 below). + // Synth litters are added with null FatherId/MotherId to break the Gerbil↔Litter cycle; + // the actual FKs are applied AFTER SaveChanges once all gerbils are persisted. + var synthLitterPendingParents = new Dictionary(); + + // PASS 2: stage synthesized litters (parents already guarded above). Litters are added + // with FatherId/MotherId = null (deferred) so that the SaveChanges below has only a + // one-directional Gerbil→Litter dependency — no Litter→Gerbil FKs in the same batch, + // which would cause EF's topo-sort to throw "circular dependency detected". if (execute) { // reuse an existing litter with the same parents+date instead of duplicating. @@ -316,13 +321,17 @@ namespace GerbilManagerWebAPI.Import derivedLitters--; continue; } + // Defer FatherId/MotherId: both parent gerbils and offspring gerbils may be [Added] + // in this same batch. Setting them now causes EF circular dependency + // (Gerbil[Added] ← Litter.MotherId [Added] ← Gerbil.LitterId [Added]). + synthLitterPendingParents[sl.Id] = (sl.Father, sl.Mother); _db.Litters.Add(new Litter { Id = sl.Id, Name = $"Wurf (aus Diagramm) {sl.Date:yyyy-MM-dd}".Trim(), Date = sl.Date ?? default, - FatherId = sl.Father, - MotherId = sl.Mother, + FatherId = null, // deferred — applied after gerbils SaveChanges + MotherId = null, // deferred — applied after gerbils SaveChanges Notes = $"aus Stammbaum-Diagramm abgeleitet (Konfidenz: {sl.Confidence})", }); } @@ -437,6 +446,20 @@ namespace GerbilManagerWebAPI.Import } if (execute) await _db.SaveChangesAsync(); + // Apply deferred synth litter parent FKs — all new gerbils are now persisted in the DB, + // so no cycle. FK guard already applied above (persisted set); values in the dict are safe. + if (execute && synthLitterPendingParents.Count > 0) + { + foreach (var (litId, (f, m)) in synthLitterPendingParents) + { + var row = await _db.Litters.FindAsync(litId); + if (row is null) continue; + if (f is not null) row.FatherId = f; + if (m is not null) row.MotherId = m; + } + await _db.SaveChangesAsync(); + } + // ---- back-link Wurfchronik litter parents by name (best effort) ---- if (execute) {