Artifact 0f6676b10017e41e3fc628627de638e4531c2dec5ac51b0619591bde5d43374f:
- File
internal/manifest/dephantomize_defer_test.go
— part of check-in
[67fc6ced4b]
at
2026-10-13 11:59:57
on branch main
— fix(manifest): link check-ins with absent files; refuse absent baselines (#415)
* fix(manifest): link check-ins with absent files; refuse absent baselines
The crosslink sweep held a check-in back until every file it names was stored. A root check-in, or one on a present parent, whose files never arrived got no event, plink, mlink, leaf or tag rows. fossil links it against phantom files (add_one_mlink, uuid_to_rid(..., 1)), and repo-state-01 Section 4.2 makes an unresolved reference a phantom, not an error. The sweep -- Crosslink, the close of every sync and clone, and verify.Rebuild -- now waits only on a delta manifest's baseline. The receive-time cascade and receive linker still wait for files while blobs are still arriving; the closing sweep bounds that wait.
verify.Rebuild linked a delta manifest whose baseline was absent, with no baseline id. fossil refuses it (fetch_baseline) and files it in orphan under a phantom for the baseline; artifact-format-01 Section 8.3 calls this a soft failure retried when the baseline arrives. Every path now does the same, so a sync requests the baseline and the phantom-fill cascade links the manifest when it lands. Rebuild clears and rederives orphan.
The gap case repostate.4.2.root-absent-file passes and is unmarked; new cases cover a present parent with an absent file and an absent or late baseline, each also through verify.Rebuild where fossil rebuild agrees.
Tracked in #406.
* fix(manifest): refuse a non-manifest baseline and drop the orphan row on link
Tracked in #406. (user: [email protected], size: 5645) [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 repairLeafTable. 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) } }