Artifact 95d26ad2ab957626196687c7fcdc0f275634de7bc2818c36e8d21488ae03d3f1:
- File
internal/shun/purge.go
— part of check-in
[1fd12b3ed3]
at
2026-10-28 21:53:09
on branch feat/chunked-blobs
— Store blobs over a hosted value limit across blobpart rows
A hosted SQLite that caps value size (a Durable Object's 2 MB) cannot hold a large file's stored blob in one blob.content value. With the new db.DriverConfig.MaxValueBytes set, a stored value over the limit is written in 1,000,000-byte parts to blobpart(rid, seq, content), created on first use, leaving blob.content NULL and blob.size the expanded length, so phantoms (size < 0) stay unambiguous. Chunking sits below compression: hashes, deltas and the wire format are unchanged.
internal/blob owns the seam (ReadStored, WriteStored, InsertStored, StoredHead, DropStored, SweepParts and two scan expressions), and every direct reader or writer of blob.content now goes through it: delta rewrite and undelta, receive, clone send and walk, compaction, sparse demotion, seed drop, shun purge, horizon cost and cone preparation.
Zero, the default, keeps every value inline and creates no table, so local stores stay byte-identical. A store with blobpart rows is not readable by stock fossil; enable it only for hosted backends.
Closes #459 (user: [email protected], size: 3690) [annotate] [blame] [check-ins using]
package shun import ( "fmt" "github.com/danmestas/go-libfossil/db" "github.com/danmestas/go-libfossil/internal/blob" "github.com/danmestas/go-libfossil/internal/content" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" "github.com/danmestas/go-libfossil/internal/seedcheck" ) // PurgeResult summarizes what Purge deleted. type PurgeResult struct { BlobsDeleted int DeltasExpanded int PrivateCleaned int } // Purge physically removes all shunned blobs from the repo. // Follows Fossil's shun_artifacts() algorithm from shun.c. // Requires *db.DB (not Querier) because it runs in a transaction. func Purge(d *db.DB) (PurgeResult, error) { if d == nil { panic("shun.Purge: d must not be nil") } var result PurgeResult err := d.WithTx(func(tx *db.Tx) error { // Step 1: Find rids to delete. _, err := tx.Exec(`CREATE TEMP TABLE toshun(rid INTEGER PRIMARY KEY)`) if err != nil { return fmt.Errorf("create toshun: %w", err) } _, err = tx.Exec(`INSERT INTO toshun SELECT rid FROM blob WHERE uuid IN (SELECT uuid FROM shun)`) if err != nil { return fmt.Errorf("populate toshun: %w", err) } // Step 2: Undelta dependents — expand blobs whose delta source is being shunned. rows, err := tx.Query(`SELECT rid FROM delta WHERE srcid IN (SELECT rid FROM toshun)`) if err != nil { return fmt.Errorf("query dependents: %w", err) } var dependents []libfossil.FslID for rows.Next() { var rid libfossil.FslID if err := rows.Scan(&rid); err != nil { rows.Close() return fmt.Errorf("scan dependent: %w", err) } dependents = append(dependents, rid) } rows.Close() // Bare Expand: each dependent of a shunned base is expanded once to be // rewritten as whole content. Distinct rids, single pass -- and the // deltas are being deleted, so there is no chain left to cache against. for _, rid := range dependents { expanded, err := content.Expand(tx, rid) if err != nil { return fmt.Errorf("expand rid %d: %w", rid, err) } compressed, err := blob.Compress(expanded) if err != nil { return fmt.Errorf("compress rid %d: %w", rid, err) } if err := blob.WriteStored(tx, rid, compressed); err != nil { return fmt.Errorf("update blob rid %d: %w", rid, err) } _, err = tx.Exec("UPDATE blob SET size=? WHERE rid=?", len(expanded), rid) if err != nil { return fmt.Errorf("update blob size rid %d: %w", rid, err) } _, err = tx.Exec("DELETE FROM delta WHERE rid=?", rid) if err != nil { return fmt.Errorf("delete delta rid %d: %w", rid, err) } result.DeltasExpanded++ } // Step 3: Delete shunned blobs. _, err = tx.Exec("DELETE FROM delta WHERE rid IN (SELECT rid FROM toshun)") if err != nil { return fmt.Errorf("delete shunned deltas: %w", err) } res, err := tx.Exec("DELETE FROM blob WHERE rid IN (SELECT rid FROM toshun)") if err != nil { return fmt.Errorf("delete shunned blobs: %w", err) } n, _ := res.RowsAffected() result.BlobsDeleted = int(n) if err := blob.SweepParts(tx); err != nil { return fmt.Errorf("delete shunned parts: %w", err) } _, err = tx.Exec("DROP TABLE toshun") if err != nil { return fmt.Errorf("drop toshun: %w", err) } // Step 4: Orphan cleanup. A seed verdict on a deleted rid would pass // to the next blob stored under it. if err := seedcheck.ForgetDeleted(tx); err != nil { return err } res, err = tx.Exec("DELETE FROM private WHERE NOT EXISTS (SELECT 1 FROM blob WHERE rid=private.rid)") if err != nil { return fmt.Errorf("cleanup private: %w", err) } n, _ = res.RowsAffected() result.PrivateCleaned = int(n) return nil }) return result, err }