Artifact bb19f202b8dd652e2d1fc0b7e28428e287acfb21b19570f7103d3eb2db5bbb81:
- File
internal/manifest/dephantomize_defer_test.go
— part of check-in
[628f932301]
at
2026-10-13 16:51:51
on branch main
— fix(manifest): maintain the leaf table incrementally (RS-7.3-3) (#434)
* fix(manifest): maintain the leaf table incrementally (RS-7.3-3)
The leaf table was recomputed from scratch at the end of every crosslink batch, so a check-in lost its leaf verdict whenever a later batch linked an untagged child to it. repo-state-01 Section 7.3, as corrected, and fossil keep that verdict: a check-in and its plink parents are rechecked only when a branch tag is written to it or propagation updates or deletes its branch row (src/tag.c:195, :100-102), and the checks run when the batch ends (src/leaf.c leaf_do_pending_checks).
The rule now lives in a new internal/leaf package: Check (leaf_check), Rebuild (the explicit full rebuild) and Pending (the deferred schedule). internal/tag schedules a recheck at exactly the branch-row writes; the crosslink sweep, receive linker and repair gate apply a batch's schedule at the gate, each phantom-fill cascade transaction and each commit or tag transaction apply their own before commit, and verify.Rebuild keeps the whole-table recompute. Reparenting schedules nothing, as in fossil, so the reparent case is split into a one-batch case and a per-artifact case.
Tracked in #426.
* fix(manifest): recheck leaves a cancel reaches; drop rechecks of a failed receive tx (user: [email protected], size: 5642) [annotate] [blame] [check-ins using]
package manifest import ( "context" "testing" "time" "github.com/danmestas/go-libfossil/internal/blob" "github.com/danmestas/go-libfossil/internal/deck" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" _ "github.com/danmestas/go-libfossil/internal/testdriver" ) // TestAfterDephantomizeCascadeDefersCheckinWithMissingFileBlob (issue #180): // the mid-round phantom-fill cascade must hold back a Checkin manifest whose // referenced file blob has not arrived -- more blobs are still on the way -- // so a later Crosslink sweep can still rediscover and complete it. Unlike the // cascade, that sweep never waits on files (#406; see // TestCrosslink_LinksCheckinWithMissingFileBlob). // // Before the fix, afterDephantomize called linkArtifact for a Checkin // unconditionally: it wrote the event row even though insertCheckinMlinks // silently skipped the missing-blob F-card. Because // collectCrosslinkCandidates only reselects a rid with NO event row, that // half-linked checkin was permanently stranded -- no future sweep, no matter // how many rounds ran or when the blob arrived, would ever revisit it. A // downstream Checkout.Update walking to that checkin would hit "blob not // found" with no path to recover. func TestAfterDephantomizeCascadeDefersCheckinWithMissingFileBlob(t *testing.T) { r := setupTestRepo(t) fileContent := []byte("cascade race: file content arrives later") fileUUID := repoHash(r, fileContent) // A Checkin manifest referencing fileUUID, stored as full (non-delta) // content -- what storeResolvedContent produces for a "file" card, and // exactly the dephantomizedRid that storeReceivedFile then hands to // manifest.AfterDephantomize. The file blob is deliberately never stored. d := &deck.Deck{ Type: deck.Checkin, C: "cascade race with missing file blob", U: deck.User("tester"), D: time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC), F: []deck.FileCard{{Name: "race/file.txt", UUID: fileUUID}}, // A root check-in declares its branch -- both fossil's own `init` and // this package's Checkin do -- and leaf only ever considers a check-in // that carries a branch tag. See leaf.Rebuild. T: []deck.TagCard{ {Type: deck.TagPropagating, Name: "branch", UUID: "*", Value: "trunk"}, {Type: deck.TagSingleton, Name: "sym-trunk", UUID: "*"}, }, } manifestBytes, err := d.Marshal() if err != nil { t.Fatalf("Marshal: %v", err) } manifestRID, _, err := blob.Store(r.DB(), manifestBytes) if err != nil { t.Fatalf("blob.Store(manifest): %v", err) } // Drive the manifest through the cascade exactly as // storeReceivedFile -> manifest.AfterDephantomize does for a just-stored // full-content blob. The file blob it references is missing, so this must // defer: no event/leaf/mlink rows at all. AfterDephantomize(context.Background(), r, manifestRID) assertCounts(t, r, manifestRID, 0, 0, 0, "after cascade, blob missing") // The missing file blob arrives (a later round). The next Crosslink sweep // -- HandleSync's post-round trigger, or sync.Clone's terminal sweep -- // must rediscover the manifest (no event row exists yet) and link it // fully. This half is what proves recovery is actually restored: a defer // that never gets picked back up would still pass the assertion above. if _, _, err := blob.Store(r.DB(), fileContent); err != nil { t.Fatalf("blob.Store(file): %v", err) } linked, err := Crosslink(r) if err != nil { t.Fatalf("Crosslink (post-arrival phase): %v", err) } if linked != 1 { t.Errorf("Crosslink (post-arrival phase): linked = %d, want 1", linked) } assertCounts(t, r, manifestRID, 1, 1, 1, "after blob arrival") } // TestAfterDephantomizeLinksOrphanWhenBaselineArrives: a delta manifest whose // baseline is absent is refused and filed in orphan under the baseline's // phantom, as fossil's fetch_baseline files it; when the baseline arrives, the // phantom-fill cascade finds it there, links it and clears the row. func TestAfterDephantomizeLinksOrphanWhenBaselineArrives(t *testing.T) { r := setupTestRepo(t) baseline := &deck.Deck{ Type: deck.Checkin, C: "baseline arrives later", D: time.Date(2026, 7, 27, 11, 0, 0, 0, time.UTC), U: deck.User("tester"), T: []deck.TagCard{ {Type: deck.TagPropagating, Name: "branch", UUID: "*", Value: "trunk"}, {Type: deck.TagSingleton, Name: "sym-trunk", UUID: "*"}, }, } baselineBytes, err := baseline.Marshal() if err != nil { t.Fatalf("Marshal(baseline): %v", err) } baselineUUID := repoHash(r, baselineBytes) delta := &deck.Deck{ Type: deck.Checkin, B: baselineUUID, C: "delta before its baseline", D: time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC), P: []string{baselineUUID}, U: deck.User("tester"), } deltaBytes, err := delta.Marshal() if err != nil { t.Fatalf("Marshal(delta): %v", err) } deltaRID, _, err := blob.Store(r.DB(), deltaBytes) if err != nil { t.Fatalf("blob.Store(delta): %v", err) } AfterDephantomize(context.Background(), r, deltaRID) assertCounts(t, r, deltaRID, 0, 0, 0, "refused without its baseline") baseRID := assertPhantom(t, r, baselineUUID, true) assertOrphan(t, r, int64(deltaRID), baseRID) if _, _, err := blob.Store(r.DB(), baselineBytes); err != nil { t.Fatalf("blob.Store(baseline): %v", err) } AfterDephantomize(context.Background(), r, libfossil.FslID(baseRID)) assertCounts(t, r, deltaRID, 1, 0, 0, "linked once the baseline arrived") var orphans int if err := r.DB().QueryRow("SELECT count(*) FROM orphan").Scan(&orphans); err != nil || orphans != 0 { t.Errorf("orphan rows = %d, %v; want 0", orphans, err) } }