Artifact 0cdad788561ebf976d1dc319a2d2903f702680ff25d5aa785ad7e3efb097d398:
- File
internal/manifest/reparent_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: 8031) [annotate] [blame] [check-ins using]
package manifest import ( "path/filepath" "sort" "strings" "testing" "time" "github.com/danmestas/go-libfossil/db" "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/leaf" "github.com/danmestas/go-libfossil/internal/repo" "github.com/danmestas/go-libfossil/simio" ) // uuidOfRid is a check-in's blob hash. func uuidOfRid(t *testing.T, q db.Querier, rid libfossil.FslID) string { t.Helper() var u string if err := q.QueryRow("SELECT uuid FROM blob WHERE rid=?", rid).Scan(&u); err != nil { t.Fatalf("uuid of rid=%d: %v", rid, err) } return u } // storeParentTag stores a control artifact carrying a singleton `parent` tag // that reparents the check-in named by child to the hashes in parents. func storeParentTag(t *testing.T, r *repo.Repo, child string, parents []string, at time.Time) { t.Helper() d := &deck.Deck{ Type: deck.Control, D: at, T: []deck.TagCard{{ Type: deck.TagSingleton, Name: "parent", UUID: child, Value: strings.Join(parents, " "), }}, U: deck.User("testuser"), } data, err := d.Marshal() if err != nil { t.Fatalf("marshal parent tag: %v", err) } if _, _, err := blob.Store(r.DB(), data); err != nil { t.Fatalf("store parent tag: %v", err) } } // plinkParents returns the pids of a check-in, sorted, as uuids. func plinkParents(t *testing.T, q db.Querier, cid libfossil.FslID) []string { t.Helper() rows, err := q.Query(`SELECT b.uuid FROM plink JOIN blob b ON b.rid=plink.pid WHERE plink.cid=?`, cid) if err != nil { t.Fatalf("plink parents of %d: %v", cid, err) } defer rows.Close() var out []string for rows.Next() { var u string if err := rows.Scan(&u); err != nil { t.Fatalf("scan: %v", err) } out = append(out, u) } sort.Strings(out) return out } // leafUUIDs returns the leaf rids as uuids, sorted. func leafUUIDs(t *testing.T, q db.Querier) []string { t.Helper() rows, err := q.Query("SELECT b.uuid FROM leaf JOIN blob b ON b.rid=leaf.rid") if err != nil { t.Fatalf("leaf: %v", err) } defer rows.Close() var out []string for rows.Next() { var u string if err := rows.Scan(&u); err != nil { t.Fatalf("scan: %v", err) } out = append(out, u) } sort.Strings(out) return out } func mlinkPrimaryParent(t *testing.T, q db.Querier, mid libfossil.FslID) int64 { t.Helper() var pmid int64 if err := q.QueryRow( "SELECT pmid FROM mlink WHERE mid=? AND NOT isaux LIMIT 1", mid).Scan(&pmid); err != nil { t.Fatalf("primary mlink of %d: %v", mid, err) } return pmid } // relinkDerived clears the marks a crosslink reads as "already linked" and // crosslinks again, as the receive/relink path does. func relinkDerived(t *testing.T, path string) { t.Helper() r, err := repo.Open(path) if err != nil { t.Fatalf("repo.Open: %v", err) } defer r.Close() for _, clear := range []string{"DELETE FROM event", "DELETE FROM tagxref WHERE srcid > 0"} { if _, err := r.DB().Exec(clear); err != nil { t.Fatalf("clear: %v", err) } } if _, err := Crosslink(r); err != nil { t.Fatalf("relink Crosslink: %v", err) } } // TestReparent is the issue #286 regression. A singleton parent tag moves c3 // off c2 and onto c1: its plink and mlink are rewritten. The rewrite must be // idempotent across a second crosslink of the same artifacts. // // c2, left without a child, keeps the verdict it had: the parent tag is no // branch tag and rewriting plink schedules no leaf recheck // (draft-fossil-repo-state-01 Section 7.3), as in fossil's own crosslink of a // parent tag. An explicit full rebuild, which is the single-batch result, // makes it a leaf. func TestReparent(t *testing.T) { path := filepath.Join(t.TempDir(), "reparent.fossil") r, err := repo.Create(path, "testuser", simio.CryptoRand{}, "") if err != nil { t.Fatalf("repo.Create: %v", err) } base := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) commit := func(label, content string, parent libfossil.FslID, hour int) libfossil.FslID { t.Helper() rid, _, err := Checkin(r, CheckinOpts{ Files: []File{{Name: "f.txt", Content: []byte(content)}}, Comment: label, User: "testuser", Parent: parent, Time: base.Add(time.Duration(hour) * time.Hour), }) if err != nil { t.Fatalf("Checkin(%s): %v", label, err) } return rid } c1 := commit("c1", "v1\n", 0, 0) c2 := commit("c2", "v2\n", c1, 1) c3 := commit("c3", "v3\n", c2, 2) u1, u2, u3 := uuidOfRid(t, r.DB(), c1), uuidOfRid(t, r.DB(), c2), uuidOfRid(t, r.DB(), c3) // Before: c3's parent is c2; c3 is the only leaf; c3's mlink diffs c2. if got := plinkParents(t, r.DB(), c3); len(got) != 1 || got[0] != u2 { t.Fatalf("before: plink parents of c3 = %v, want [%s]", got, u2) } if got := leafUUIDs(t, r.DB()); len(got) != 1 || got[0] != u3 { t.Fatalf("before: leaves = %v, want [%s]", got, u3) } if got := mlinkPrimaryParent(t, r.DB(), c3); got != int64(c2) { t.Fatalf("before: c3 primary mlink pmid = %d, want %d (c2)", got, c2) } storeParentTag(t, r, u3, []string{u1}, base.Add(9*time.Hour)) if _, err := Crosslink(r); err != nil { t.Fatalf("Crosslink after parent tag: %v", err) } if err := r.Close(); err != nil { t.Fatalf("repo.Close: %v", err) } assertAfter := func(stage string, rebuild bool, wantLeaves ...string) { rr, err := repo.Open(path) if err != nil { t.Fatalf("repo.Open: %v", err) } defer rr.Close() if rebuild { if err := leaf.Rebuild(rr.DB()); err != nil { t.Fatalf("leaf.Rebuild: %v", err) } } if got := plinkParents(t, rr.DB(), c3); len(got) != 1 || got[0] != u1 { t.Errorf("%s: plink parents of c3 = %v, want [%s] (c1)", stage, got, u1) } sort.Strings(wantLeaves) if got := leafUUIDs(t, rr.DB()); strings.Join(got, ",") != strings.Join(wantLeaves, ",") { t.Errorf("%s: leaves = %v, want %v", stage, got, wantLeaves) } if got := mlinkPrimaryParent(t, rr.DB(), c3); got != int64(c1) { t.Errorf("%s: c3 primary mlink pmid = %d, want %d (c1)", stage, got, c1) } } assertAfter("after reparent", false, u3) relinkDerived(t, path) assertAfter("after relink (idempotent)", false, u3) assertAfter("after leaf.Rebuild", true, u2, u3) } // TestReparentPhantomTarget covers a parent tag that names a parent not yet in // the repository: the reparent reserves a phantom edge for it through the same // ridOrPhantom path the normal plink derivation uses, so the ancestry is not // silently dropped. (Dephantomizing on the parent's later arrival is a // separate blob.Store concern tracked in #278, so it is not asserted here.) func TestReparentPhantomTarget(t *testing.T) { // A valid-looking hash for an artifact the repository does not hold. const absentParent = "0123456789abcdef0123456789abcdef01234567" path := filepath.Join(t.TempDir(), "phantom.fossil") r, err := repo.Create(path, "testuser", simio.CryptoRand{}, "") if err != nil { t.Fatalf("repo.Create: %v", err) } defer r.Close() base := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) c1, _, err := Checkin(r, CheckinOpts{ Files: []File{{Name: "g.txt", Content: []byte("x\n")}}, Comment: "c1", User: "testuser", Time: base, }) if err != nil { t.Fatalf("Checkin c1: %v", err) } u1 := uuidOfRid(t, r.DB(), c1) // Reparent c1 onto a parent that has not arrived: the edge points at a // phantom reserved for it. storeParentTag(t, r, u1, []string{absentParent}, base.Add(9*time.Hour)) if _, err := Crosslink(r); err != nil { t.Fatalf("Crosslink: %v", err) } var phantomSize int64 if err := r.DB().QueryRow( "SELECT size FROM blob WHERE uuid=?", absentParent).Scan(&phantomSize); err != nil { t.Fatalf("phantom lookup: %v", err) } if phantomSize >= 0 { t.Fatalf("absent parent blob size = %d, want < 0 (a phantom)", phantomSize) } if got := plinkParents(t, r.DB(), c1); len(got) != 1 || got[0] != absentParent { t.Fatalf("plink parents of c1 = %v, want [%s] (phantom)", got, absentParent) } }