Artifact f185fe1d327a11de94674076b6bb3f6962ae8291083cc24e4eceb6227de4731f:
- File
internal/manifest/crosslink_leaf_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: 10628) [annotate] [blame] [check-ins using]
package manifest import ( "context" "errors" "fmt" "sort" "testing" "time" "github.com/danmestas/go-libfossil/db" "github.com/danmestas/go-libfossil/internal/blob" "github.com/danmestas/go-libfossil/internal/deck" "github.com/danmestas/go-libfossil/internal/repo" ) // storeCheckin builds a check-in manifest with the given parents and branch // declaration and stores it as a blob without crosslinking it, mirroring the // receiver state after a sync round delivers manifests but before the sweep // runs. branch == "" declares no branch tag, so the check-in inherits its // primary parent's branch through tag propagation the way a plain commit does. func storeCheckin(t *testing.T, r *repo.Repo, comment, branch string, when time.Time, parents ...string) string { t.Helper() content := []byte("content for " + comment) fileUUID := repoHash(r, content) if _, _, err := blob.Store(r.DB(), content); err != nil { t.Fatalf("blob.Store(file for %s): %v", comment, err) } d := &deck.Deck{ Type: deck.Checkin, C: comment, D: when, F: []deck.FileCard{{Name: "f.txt", UUID: fileUUID}}, P: parents, U: deck.User("tester"), } if branch != "" { d.T = []deck.TagCard{ {Type: deck.TagPropagating, Name: "branch", UUID: "*", Value: branch}, {Type: deck.TagSingleton, Name: "sym-" + branch, UUID: "*"}, } } rHash, err := d.ComputeR(func(uuid string) ([]byte, error) { if uuid == fileUUID { return content, nil } return nil, fmt.Errorf("unknown uuid: %s", uuid) }) if err != nil { t.Fatalf("ComputeR(%s): %v", comment, err) } d.R = rHash manifestBytes, err := d.Marshal() if err != nil { t.Fatalf("Marshal(%s): %v", comment, err) } _, uuid, err := blob.Store(r.DB(), manifestBytes) if err != nil { t.Fatalf("blob.Store(manifest %s): %v", comment, err) } return uuid } // leafComments returns the comment of every check-in in the leaf table, so an // assertion failure names the check-ins rather than opaque rids. func leafComments(t *testing.T, r *repo.Repo) []string { t.Helper() rows, err := r.DB().Query(` SELECT e.comment FROM leaf l JOIN event e ON e.objid = l.rid WHERE e.type = 'ci'`) if err != nil { t.Fatalf("query leaf: %v", err) } defer rows.Close() var got []string for rows.Next() { var c string if err := rows.Scan(&c); err != nil { t.Fatalf("scan leaf: %v", err) } got = append(got, c) } if err := rows.Err(); err != nil { t.Fatalf("rows: %v", err) } sort.Strings(got) return got } func assertLeaves(t *testing.T, r *repo.Repo, where string, want []string) { t.Helper() got := leafComments(t, r) if len(got) != len(want) { t.Fatalf("%s: leaf = %v, want %v", where, got, want) } for i := range want { if got[i] != want[i] { t.Fatalf("%s: leaf = %v, want %v", where, got, want) } } } // TestCrosslinkLeafMatchesCanonicalBranchRule pins the leaf rule canonical // fossil implements in src/leaf.c: a check-in is a leaf when no plink child of // it carries the *same* branch name, where a check-in with no branch tagxref // value counts as "trunk". // // leaf_check(): SELECT 1 FROM plink WHERE pid=:rid // AND coalesce((SELECT value FROM tagxref WHERE tagid=TAG_BRANCH // AND rid=:rid),'trunk') // == coalesce((SELECT value FROM tagxref WHERE tagid=TAG_BRANCH // AND rid=plink.cid),'trunk') // // Neither "has any child" nor "has a primary child" is the rule, and each // disagrees with fossil on one arm of the fixture below (issue #189): // // c1 trunk ─┬─> c3 trunk ─┐ // │ ├─> c4 trunk(merge) ──> c5 bar ──> c6 (inherits bar) // └─> c2 foo ───┘ // // - c2 is the tip of branch foo, merged into trunk at c4. It has a child, so // an any-child rule drops it; fossil keeps it because c4 is on trunk. This // is the class that lost 939 of 1,426 leaves on the real corpus. // - c4's only child c5 is its *primary* child, so an isprim rule drops it; // fossil keeps it because c5 is on bar. // - c5 is not a leaf even though c6 declares no branch of its own: c6 // inherits bar by tag propagation, which is why the leaf recompute has to // run after propagation rather than before it. // // The fixture is built from stored manifests and linked by Crosslink, so it // runs in the default suite with no fossil binary. func TestCrosslinkLeafMatchesCanonicalBranchRule(t *testing.T) { r := setupTestRepo(t) base := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) c1 := storeCheckin(t, r, "c1-trunk-root", "trunk", base) c2 := storeCheckin(t, r, "c2-foo-tip", "foo", base.Add(time.Minute), c1) c3 := storeCheckin(t, r, "c3-trunk", "", base.Add(2*time.Minute), c1) c4 := storeCheckin(t, r, "c4-trunk-merge", "", base.Add(3*time.Minute), c3, c2) c5 := storeCheckin(t, r, "c5-bar-root", "bar", base.Add(4*time.Minute), c4) storeCheckin(t, r, "c6-bar-tip", "", base.Add(5*time.Minute), c5) linked, err := Crosslink(r) if err != nil { t.Fatalf("Crosslink: %v", err) } if linked != 6 { t.Fatalf("Crosslink linked %d checkins, want 6", linked) } assertLeaves(t, r, "after Crosslink", []string{ "c2-foo-tip", // merged branch tip: child c4 is on trunk "c4-trunk-merge", // branch point: primary child c5 is on bar "c6-bar-tip", // ordinary tip }) } // TestCheckinLeafMatchesCanonicalBranchRule is the same rule on the local // commit path. manifest.Checkin maintained leaf by deleting every parent of the // new check-in, which is the any-child rule, so committing onto a new branch // silently retired the branch point (issue #189). func TestCheckinLeafMatchesCanonicalBranchRule(t *testing.T) { r := setupTestRepo(t) base := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) trunkTip, _, err := Checkin(r, CheckinOpts{ Comment: "c1-trunk-root", User: "tester", Time: base, Files: []File{{Name: "f.txt", Content: []byte("one")}}, }) if err != nil { t.Fatalf("Checkin c1: %v", err) } assertLeaves(t, r, "after root checkin", []string{"c1-trunk-root"}) if _, _, err := Checkin(r, CheckinOpts{ Comment: "c2-foo-root", User: "tester", Time: base.Add(time.Minute), Parent: trunkTip, Files: []File{{Name: "f.txt", Content: []byte("two")}}, Tags: []deck.TagCard{ {Type: deck.TagPropagating, Name: "branch", UUID: "*", Value: "foo"}, {Type: deck.TagSingleton, Name: "sym-foo", UUID: "*"}, }, }); err != nil { t.Fatalf("Checkin c2: %v", err) } // The branch point keeps its leaf row: its only child is on branch foo. assertLeaves(t, r, "after branch checkin", []string{"c1-trunk-root", "c2-foo-root"}) if _, _, err := Checkin(r, CheckinOpts{ Comment: "c3-trunk", User: "tester", Time: base.Add(2 * time.Minute), Parent: trunkTip, Files: []File{{Name: "f.txt", Content: []byte("three")}}, }); err != nil { t.Fatalf("Checkin c3: %v", err) } // c3 is on trunk, so now the branch point does lose its leaf row. assertLeaves(t, r, "after trunk checkin", []string{"c2-foo-root", "c3-trunk"}) } // TestRepairLeafTableKeepsParentlessCheckin pins the one place our recompute // deliberately differs from fossil's leaf_rebuild(), whose candidate set is // `SELECT cid FROM plink` and so cannot return a check-in that has no parent. // Fossil reaches that check-in through leaf_check() instead -- a fresh // repository holding only its initial empty check-in has one leaf row -- and // selecting candidates from event reproduces that without a second pass. func TestRepairLeafTableKeepsParentlessCheckin(t *testing.T) { r := setupTestRepo(t) storeCheckin(t, r, "c1-only", "trunk", time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC)) if _, err := Crosslink(r); err != nil { t.Fatalf("Crosslink: %v", err) } assertLeaves(t, r, "single parentless checkin", []string{"c1-only"}) var plinks int if err := r.DB().QueryRow("SELECT count(*) FROM plink").Scan(&plinks); err != nil { t.Fatalf("count plink: %v", err) } if plinks != 0 { t.Fatalf("plink rows = %d, want 0 (fixture must have no parent edges)", plinks) } } // TestCheckinOntoSingletonBranchKeepsVerdicts pins draft-fossil-repo-state-01 // Section 7.3 on the local commit path: a commit is rechecked, with its // parents, only when a branch row lands on it. c1's branch row is a // singleton, so c2 inherits none and nothing is scheduled: c1 keeps its leaf // row although c2 is on the default branch with it, and c2 is no leaf, as in // fossil's commit (issue #426). func TestCheckinOntoSingletonBranchKeepsVerdicts(t *testing.T) { r := setupTestRepo(t) base := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC) c1, _, err := Checkin(r, CheckinOpts{ Comment: "c1-singleton-root", User: "tester", Time: base, Files: []File{{Name: "f.txt", Content: []byte("one")}}, Tags: []deck.TagCard{ {Type: deck.TagSingleton, Name: "branch", UUID: "*", Value: "trunk"}, }, }) if err != nil { t.Fatalf("Checkin c1: %v", err) } assertLeaves(t, r, "after c1", []string{"c1-singleton-root"}) if _, _, err := Checkin(r, CheckinOpts{ Comment: "c2-untagged", User: "tester", Time: base.Add(time.Minute), Parent: c1, Files: []File{{Name: "f.txt", Content: []byte("two")}}, }); err != nil { t.Fatalf("Checkin c2: %v", err) } assertLeaves(t, r, "after c2", []string{"c1-singleton-root"}) } // A recheck LinkStored schedules inside a transaction that then fails is // dropped with it: the rolled-back rid may be reused by an unrelated blob. func TestReceiveLinkerWithTxDropsRechecksOnFailure(t *testing.T) { r := setupTestRepo(t) d := &deck.Deck{ Type: deck.Checkin, C: "branch root", U: deck.User("testuser"), D: time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC), T: []deck.TagCard{{ Type: deck.TagPropagating, Name: "branch", UUID: "*", Value: "trunk", }}, } body, err := d.Marshal() if err != nil { t.Fatalf("Marshal: %v", err) } rid, uuid, err := blob.Store(r.DB(), body) if err != nil { t.Fatalf("Store: %v", err) } linker, err := NewReceiveLinker(r) if err != nil { t.Fatalf("NewReceiveLinker: %v", err) } boom := errors.New("later step failed") err = linker.WithTx(r, func(tx *db.Tx) error { if err := linker.LinkStored(context.Background(), tx, rid, uuid, body); err != nil { return err } if linker.state.leaves.Len() == 0 { t.Fatal("LinkStored scheduled no recheck for a branch tag") } return boom }) if !errors.Is(err, boom) { t.Fatalf("WithTx error = %v, want %v", err, boom) } if got := linker.state.leaves.Len(); got != 0 { t.Fatalf("schedule after a rolled-back transaction has %d entries, want 0", got) } }