Artifact 970a6c064b7234d6fb00298c8ce16087ef4e62c93a89e664141a4762ec514d32:
- File
chunked_blob_test.go
— part of check-in
[0fc4d176c3]
at
2026-10-28 22:52:43
on branch feat/chunked-blobs
— Record chunked length in blob.content and verify parts on read
Review of the chunked-blob change found a missing last part read back short with no error, and a chunked write outside a transaction could leave a torn row that looked held.
A chunked row now keeps its stored length in blob.content as an INTEGER. Reads check the parts run unbroken from seq 0, are full-sized but for the last, and add up to that length; anything else is ErrPartsCorrupt, and a NULL content is no longer mistaken for a chunked one. StoredLenSQL reads the length off the row and needs no schema probe.
Chunked writes, and the phantom fill around them, run in a savepoint of the caller's transaction or a transaction of their own, so a failed part leaves nothing behind. A value needing more than 4096 parts is an error, not a panic, since stored values can come from peers. The cone and sparse local payload reads use ReadStoredExact, which loads a value only at the length already budgeted for.
MaxValueBytes is documented as process-wide, and unversioned content as not chunked. (user: [email protected], size: 5565) [annotate] [blame] [check-ins using]
package libfossil import ( "bytes" "context" "math/rand" "path/filepath" "testing" "github.com/danmestas/go-libfossil/db" "github.com/danmestas/go-libfossil/internal/blob" "github.com/danmestas/go-libfossil/internal/verify" ) // hostedValueLimit is what html-artifact-publisher configures, below the // 2,000,000-byte ceiling the triggers in rejectHostedValues enforce. const hostedValueLimit = 1_990_000 // rejectHostedValues makes d refuse any blob.content over 2,000,000 bytes, // as a hosted SQLite with that value limit would. func rejectHostedValues(t *testing.T, d *db.DB) { t.Helper() for _, op := range []string{"INSERT", "UPDATE"} { if _, err := d.Exec(`CREATE TRIGGER hosted_blob_` + op + ` BEFORE ` + op + ` ON blob WHEN length(NEW.content)>2000000 BEGIN SELECT RAISE(ABORT, 'hosted SQLite value too large'); END`); err != nil { t.Fatal(err) } } } // largestValue is the largest value d holds in blob or blobpart. func largestValue(t *testing.T, d *db.DB) int64 { t.Helper() var n int64 if err := d.QueryRow("SELECT coalesce(max(length(content)), 0) FROM blob").Scan(&n); err != nil { t.Fatal(err) } if hasBlobParts(t, d) { var p int64 if err := d.QueryRow("SELECT coalesce(max(length(content)), 0) FROM blobpart").Scan(&p); err != nil { t.Fatal(err) } n = max(n, p) } return n } func hasBlobParts(t *testing.T, d *db.DB) bool { t.Helper() var n int if err := d.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='blobpart'"). Scan(&n); err != nil { t.Fatal(err) } return n == 1 } // wantChunked checks d holds n chunked rows, and parts for no other row. func wantChunked(t *testing.T, d *db.DB, n int) { t.Helper() var rows, orphans int if err := d.QueryRow(`SELECT (SELECT count(*) FROM blob WHERE typeof(content)='integer'), (SELECT count(*) FROM blobpart WHERE rid NOT IN (SELECT rid FROM blob WHERE typeof(content)='integer'))`).Scan(&rows, &orphans); err != nil { t.Fatal(err) } if rows != n || orphans != 0 { t.Fatalf("chunked rows = %d, orphan parts = %d; want %d and 0", rows, orphans, n) } } func randomBytes(n int, seed int64) []byte { b := make([]byte, n) _, _ = rand.New(rand.NewSource(seed)).Read(b) return b } // commitBig commits content as big.bin and returns the check-in's rid and // name; a clone numbers rids its own way, so other stores look it up by name. func commitBig(t *testing.T, r *Repo, parent int64, content []byte) (int64, string) { t.Helper() rid, uuid, err := r.Commit(CommitOpts{User: "test", Comment: "big", ParentID: parent, Files: []FileToCommit{{Name: "big.bin", Content: content}}}) if err != nil { t.Fatalf("Commit: %v", err) } return rid, uuid } func wantFile(t *testing.T, r *Repo, version string, want []byte) { t.Helper() got, err := r.ReadFileAt(version, "big.bin") if err != nil { t.Fatalf("ReadFileAt %s: %v", version, err) } if !bytes.Equal(got, want) { t.Fatalf("ReadFileAt %s: %d bytes differ from the %d committed", version, len(got), len(want)) } } // A file larger than a hosted backend's value limit commits, keeps its old // version, clones to a store with no limit, and comes back by push, with no // value in the limited store ever over the limit (issue #459). func TestChunkedBlobsCloneAndPush(t *testing.T) { restore := blob.SetMaxValueBytes(hostedValueLimit) t.Cleanup(func() { restore() }) ctx := context.Background() hosted, err := Create(filepath.Join(t.TempDir(), "hosted.fossil"), CreateOpts{User: "test"}) if err != nil { t.Fatal(err) } defer hosted.Close() rejectHostedValues(t, hosted.DB()) if err := hosted.SetPassword("test", "secret"); err != nil { t.Fatal(err) } v1 := randomBytes(2_500_000, 1) v2 := bytes.Clone(v1) copy(v2[1_000_000:], "second version") r1, c1 := commitBig(t, hosted, 0, v1) _, c2 := commitBig(t, hosted, r1, v2) wantFile(t, hosted, c1, v1) wantFile(t, hosted, c2, v2) if err := hosted.Verify(); err != nil { t.Fatalf("hosted Verify: %v", err) } // v1 was rewritten as a delta against v2, which dropped its parts. wantChunked(t, hosted.DB(), 1) // The local store has no limit: what it clones is stored inline. blob.SetMaxValueBytes(0) peer := TransportFunc(func(ctx context.Context, payload []byte) ([]byte, error) { return hosted.HandleSync(ctx, payload) }) local, _, err := Clone(ctx, filepath.Join(t.TempDir(), "local.fossil"), peer, CloneOpts{}) if err != nil { t.Fatalf("Clone: %v", err) } defer local.Close() if hasBlobParts(t, local.DB()) { t.Fatal("an unlimited clone created blobpart") } wantFile(t, local, c1, v1) wantFile(t, local, c2, v2) if err := local.Verify(); err != nil { t.Fatalf("local Verify: %v", err) } v3 := randomBytes(2_600_000, 3) parent, err := local.ResolveVersion(c2) if err != nil { t.Fatal(err) } _, c3 := commitBig(t, local, parent, v3) blob.SetMaxValueBytes(hostedValueLimit) if _, err := local.Sync(ctx, peer, SyncOpts{Push: true, Pull: true, User: "test", Password: "secret"}); err != nil { t.Fatalf("push: %v", err) } wantFile(t, hosted, c3, v3) if err := hosted.Verify(); err != nil { t.Fatalf("hosted Verify after push: %v", err) } if _, err := verify.Rebuild(hosted.inner); err != nil { t.Fatalf("Rebuild: %v", err) } if err := hosted.Verify(); err != nil { t.Fatalf("hosted Verify after rebuild: %v", err) } wantFile(t, hosted, c1, v1) wantChunked(t, hosted.DB(), 2) if n := largestValue(t, hosted.DB()); n > hostedValueLimit { t.Fatalf("hosted store holds a %d-byte value, over %d", n, hostedValueLimit) } }