Artifact 179f584cebc9f9fe5850dcecb35db3cd03427010f4c74fad774ef0b279ac6a0e:
- File
internal/content/chain_bound_test.go
— part of check-in
[8b946de415]
at
2026-10-24 11:10:13
on branch main
— fix(sync): an unresolvable pending chain no longer blocks a fill (#471)
Review of a067c6e. JudgeFilled treated content.Expand's chain-bound error as a store fault and returned it, and DeltaStored failed storeDeltaContent with it.
A pusher could plant a dangling chain longer than maxDeltaChainDepth under a basis R, building it by re-rooting. After that, every honest push, pull or clone that supplied R as a delta failed for good.
Fix: - content now wraps its cycle and depth errors in ErrChainUnresolvable, and exports MaxDeltaChainDepth. - seedcheck counts ErrChainUnresolvable as unreadable, alongside a hash mismatch and a delta that does not apply or fails its checksum. Such a row is skipped and stays pending, so it stays withheld. - Real faults are still returned, such as content that does not decompress. - The classification uses errors.Is on the sentinel, so the expansion JudgeFilled already performs is not repeated.
Tests: - TestHorizonOverDeepPendingChainDoesNotBlockFill plants a pending row one node past the bound under R, then pushes R as a delta on a held basis. The push succeeds, R is stored and readable, and the over-deep row stays withheld. - TestUnresolvableChainSentinel pins the sentinel for a cycle and for a chain past the bound. - Dropping the classification, or either wrap, fails a test.
A cycle never reaches seedcheck: the receive pass's own basis-chain walk (receive_pass.go chainEnd) refuses it first. (user: [email protected], size: 3957) [annotate] [blame] [check-ins using] [more...]
package content import ( "errors" "fmt" "testing" "github.com/danmestas/go-libfossil/db" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" _ "github.com/danmestas/go-libfossil/internal/testdriver" ) // maxDeltaChainDepth's doc says both read-path walks stop after visiting that // many nodes. They reached the bound a node apart until the loop in // walkDeltaChain was rewritten to count nodes rather than iterations, which // meant a chain of exactly maxDeltaChainDepth+1 was unavailable to one walk // and complete to the other. Nothing else pins the two together: each has its // own loop, and a future edit to either can reintroduce the skew silently. func TestChainBoundBitesAtSameLength(t *testing.T) { for _, n := range []int{maxDeltaChainDepth, maxDeltaChainDepth + 1} { d := setupTestDB(t) // Build the whole chain under one commit. This fixture is incidental // to what the test asserts, but at maxDeltaChainDepth it is ~65k rows // across the two arms; a commit per row makes the runtime durability- // bound and pushed the package past its CI timeout. rids := make([]libfossil.FslID, n) if err := d.WithTx(func(tx *db.Tx) error { for i := 0; i < n; i++ { res, err := tx.Exec( "INSERT INTO blob(uuid, size, content, rcvid) VALUES(printf('%040d', ?), 42, x'00', 1)", i) if err != nil { return fmt.Errorf("insert blob %d: %w", i, err) } id, err := res.LastInsertId() if err != nil { return fmt.Errorf("LastInsertId for blob %d: %w", i, err) } rids[i] = libfossil.FslID(id) } // An acyclic chain grounded at its far end, so only the bound can // decide the outcome. for i := 0; i < n-1; i++ { if _, err := tx.Exec("INSERT INTO delta(rid, srcid) VALUES(?, ?)", rids[i], rids[i+1]); err != nil { return fmt.Errorf("insert delta %d: %w", i, err) } } return nil }); err != nil { t.Fatalf("build chain of %d nodes: %v", n, err) } available := IsAvailable(d, rids[0]) _, _, err := walkDeltaChain(d, rids[0], nil) walked := err == nil if available != walked { t.Errorf("chain of %d nodes: IsAvailable=%v but walkDeltaChain succeeded=%v; "+ "the two walks must reach maxDeltaChainDepth (%d) at the same length", n, available, walked, maxDeltaChainDepth) } if n == maxDeltaChainDepth && !available { t.Errorf("chain of exactly maxDeltaChainDepth (%d) nodes was rejected", n) } if n == maxDeltaChainDepth+1 && available { t.Errorf("chain of maxDeltaChainDepth+1 (%d) nodes was accepted", n) } } } // A chain no basis can resolve, a cycle or one past maxDeltaChainDepth, is // reported as ErrChainUnresolvable, which a caller tells apart from a fault // in the store. func TestUnresolvableChainSentinel(t *testing.T) { build := func(t *testing.T, n int, cycle bool) (*db.DB, libfossil.FslID) { t.Helper() d := setupTestDB(t) rids := make([]libfossil.FslID, n) if err := d.WithTx(func(tx *db.Tx) error { for i := range n { res, err := tx.Exec( "INSERT INTO blob(uuid, size, content, rcvid) VALUES(printf('%040d', ?), 42, x'00', 1)", i) if err != nil { return err } id, err := res.LastInsertId() if err != nil { return err } rids[i] = libfossil.FslID(id) } for i := 0; i < n-1; i++ { if _, err := tx.Exec("INSERT INTO delta(rid, srcid) VALUES(?, ?)", rids[i], rids[i+1]); err != nil { return err } } if cycle { _, err := tx.Exec("INSERT INTO delta(rid, srcid) VALUES(?, ?)", rids[n-1], rids[0]) return err } return nil }); err != nil { t.Fatal(err) } return d, rids[0] } for _, c := range []struct { name string n int cycle bool }{{"cycle", 3, true}, {"too deep", maxDeltaChainDepth + 1, false}} { d, top := build(t, c.n, c.cycle) if _, err := Expand(d, top); !errors.Is(err, ErrChainUnresolvable) { t.Errorf("%s: Expand = %v, want ErrChainUnresolvable", c.name, err) } } }