Artifact 80436327a643d74a6259d915d5958cae644c7cf3b2dcfbc6276887b778c9929d:
- File
internal/seedcheck/seedcheck_test.go
— part of check-in
[584c55d1fd]
at
2026-10-24 06:59:39
on branch main
— fix(sync): judge pending rows when a received delta fills their basis (#471)
Two review nits.
Liveness. A pushed artifact pending on a basis P stayed withheld for good when a pull supplied P as a delta: - Only MarkPending re-rooted, and only for a pushed delta. - Filling a phantom with a delta runs no cascade, so nothing judged the waiting rows if the delta was readable, or moved them to P's own root if it was not. A later fill of that root never reached them.
The fix is one hook at the choke point every received delta passes, storeDeltaContent: seedcheck.DeltaStored. - If the stored delta is readable, the rows pending on it are judged. - If not, they move to its chain root, so whatever finally resolves the chain judges them. - It replaces MarkPending's push-only re-root.
Error surfacing. JudgeFilled still skips a row whose own bytes cannot be read (a delta that does not apply or fails its checksum, or bytes that are not the artifact named) and judges the rest of the batch. Any other failure is now returned, a broken chain to the filled root included: check-in and tag return it, and the cascade logs it, as it already did for JudgeFilled errors. The separate availability skip went with it.
Tests: - A pushed plain file and control artifact pending on a basis that a pull supplies as a delta, on a held basis or on one that arrives in the next round. Both become readable and stop being withheld, are announced on a push-on, and the control artifact is linked. - A row whose content does not decompress is reported. - A checksum-failing row is skipped like an invalid one. - Each hook branch and each error class fails a test when changed. (user: [email protected], size: 6303) [annotate] [blame] [check-ins using] [more...]
package seedcheck_test import ( "bytes" "fmt" "path/filepath" "slices" "testing" "time" "github.com/danmestas/go-libfossil/internal/blob" "github.com/danmestas/go-libfossil/internal/deck" "github.com/danmestas/go-libfossil/internal/delta" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" "github.com/danmestas/go-libfossil/internal/hash" "github.com/danmestas/go-libfossil/internal/repo" "github.com/danmestas/go-libfossil/internal/seedcheck" _ "github.com/danmestas/go-libfossil/internal/testdriver" "github.com/danmestas/go-libfossil/simio" ) // pending stores target as a delta on base, which the store lacks, and // records it pending, as the push handler does. func pending(t *testing.T, r *repo.Repo, base, target []byte) libfossil.FslID { t.Helper() src, err := blob.StorePhantom(r.DB(), hash.SHA3(base)) if err != nil { t.Fatal(err) } d, err := delta.Create(base, target) if err != nil { t.Fatal(err) } rid, err := blob.StoreDeltaRaw(r.DB(), hash.SHA1(target), d, src, nil) if err != nil { t.Fatal(err) } if err := seedcheck.MarkPending(r.DB(), rid); err != nil { t.Fatal(err) } return rid } // Filling one basis judges the rows pending on it and no others, however // many are pending elsewhere: a push cannot make every later fill pay for // the deltas it planted. func TestJudgeFilledExaminesOnlyItsSubtree(t *testing.T) { r, err := repo.Create(filepath.Join(t.TempDir(), "r.fossil"), "u", simio.CryptoRand{}, "") if err != nil { t.Fatal(err) } defer r.Close() const planted = 200 for i := range planted { pending(t, r, fmt.Appendf(nil, "unrelated basis %d\n", i), fmt.Appendf(nil, "unrelated %d\n", i)) } base := []byte("the basis this fill supplies\n") sd := &deck.Deck{Type: deck.Control, D: time.Date(2026, 10, 1, 16, 0, 0, 0, time.UTC), U: deck.User("zit-seed"), T: []deck.TagCard{{Type: deck.TagSingleton, Name: seedcheck.Tag, UUID: "0123456789abcdef0123456789abcdef01234567"}}} seed, err := sd.Marshal() if err != nil { t.Fatal(err) } mine := pending(t, r, base, seed) chained := pending(t, r, seed, append([]byte("on the seed\n"), seed...)) filled, _, err := blob.Store(r.DB(), base) if err != nil { t.Fatal(err) } examined, err := seedcheck.JudgeFilled(r.DB(), []libfossil.FslID{filled}) if err != nil { t.Fatal(err) } if examined != 1 { t.Fatalf("examined %d pending rows, want the 1 pending on the filled basis", examined) } if refused, err := seedcheck.IsRefusedUUID(r.DB(), hash.SHA1(seed)); err != nil || !refused { t.Fatalf("the readable seed was not refused (%v)", err) } withheld, err := seedcheck.Withheld(r.DB()) if err != nil { t.Fatal(err) } if !withheld[mine] || !withheld[chained] { t.Fatal("the refused seed, or the delta on it, is not withheld") } if len(withheld) != planted+2 { t.Fatalf("%d rows withheld, want the %d planted rows still pending and the seed and its dependent", len(withheld), planted) } } // A row that cannot be expanded does not end the batch: the fills after it // are still judged. func TestJudgeFilledContinuesPastUnreadableRow(t *testing.T) { r, err := repo.Create(filepath.Join(t.TempDir(), "r.fossil"), "u", simio.CryptoRand{}, "") if err != nil { t.Fatal(err) } defer r.Close() broken := []byte("a basis whose dependent is corrupt\n") src, err := blob.StorePhantom(r.DB(), hash.SHA3(broken)) if err != nil { t.Fatal(err) } // A delta made against other bytes: well formed, but its checksum fails // when applied to the basis that arrives. long := bytes.Repeat([]byte("a much longer basis than the one that arrives\n"), 8) target := append(slices.Clone(long), []byte("and one line more\n")...) wrong, err := delta.Create(long, target) if err != nil { t.Fatal(err) } bad, err := blob.StoreDeltaRaw(r.DB(), hash.SHA1(target), wrong, src, nil) if err != nil { t.Fatal(err) } if err := seedcheck.MarkPending(r.DB(), bad); err != nil { t.Fatal(err) } // And one whose delta applies but fails its checksum: made against bytes // of the arriving basis's length that differ from it. twin := bytes.Repeat([]byte{'x'}, len(broken)) twinTarget := append(slices.Clone(twin), []byte("a tail\n")...) checksummed, err := delta.Create(twin, twinTarget) if err != nil { t.Fatal(err) } failing, err := blob.StoreDeltaRaw(r.DB(), hash.SHA1(twinTarget), checksummed, src, nil) if err != nil { t.Fatal(err) } if err := seedcheck.MarkPending(r.DB(), failing); err != nil { t.Fatal(err) } base := []byte("the second fill's basis\n") sd := &deck.Deck{Type: deck.Control, D: time.Date(2026, 10, 1, 16, 0, 0, 0, time.UTC), U: deck.User(seedcheck.Label), T: []deck.TagCard{{Type: deck.TagSingleton, Name: "x", UUID: "0123456789abcdef0123456789abcdef01234567"}}} seed, err := sd.Marshal() if err != nil { t.Fatal(err) } pending(t, r, base, seed) first, _, err := blob.Store(r.DB(), broken) if err != nil { t.Fatal(err) } second, _, err := blob.Store(r.DB(), base) if err != nil { t.Fatal(err) } if _, err := seedcheck.JudgeFilled(r.DB(), []libfossil.FslID{first, second}); err != nil { t.Fatalf("JudgeFilled: %v", err) } if refused, err := seedcheck.IsRefusedUUID(r.DB(), hash.SHA1(seed)); err != nil || !refused { t.Fatalf("the second fill's seed was not judged (%v)", err) } if withheld, err := seedcheck.IsWithheld(r.DB(), bad); err != nil || !withheld { t.Fatalf("the unreadable row is no longer withheld (%v)", err) } } // A row that fails for a reason other than an unresolvable or mismatched // chain, a store fault such as content that does not decompress, is not // skipped silently: JudgeFilled reports it. func TestJudgeFilledSurfacesStoreFaults(t *testing.T) { r, err := repo.Create(filepath.Join(t.TempDir(), "r.fossil"), "u", simio.CryptoRand{}, "") if err != nil { t.Fatal(err) } defer r.Close() base := []byte("a basis\n") rid := pending(t, r, base, []byte("a basis\nand a dependent\n")) if _, err := r.DB().Exec("UPDATE blob SET content=x'00000010deadbeef' WHERE rid=?", rid); err != nil { t.Fatal(err) } filled, _, err := blob.Store(r.DB(), base) if err != nil { t.Fatal(err) } if _, err := seedcheck.JudgeFilled(r.DB(), []libfossil.FslID{filled}); err == nil { t.Fatal("JudgeFilled skipped a row whose content does not decompress") } }