Artifact e30bdb3f62e7a6cd887f1601528ae43f6dd742602a56c135e48493b72bc91455:
- File
horizon_cost.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: 2935) [annotate] [blame] [check-ins using]
package libfossil import ( "context" "database/sql" "errors" "fmt" "github.com/danmestas/go-libfossil/internal/blob" "github.com/danmestas/go-libfossil/internal/cone" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" "github.com/danmestas/go-libfossil/internal/horizon" "github.com/danmestas/go-libfossil/internal/manifest" ) // HorizonRuleCost is what one rule would hold for a horizon at a check-in // (draft-fossil-horizon-00 §3.4): the check-ins of the held set K and the // blobs those check-ins' F cards name, the contents a clone at that horizon // keeps. type HorizonRuleCost struct { Rule HorizonRule Checkins int // check-ins in K Blobs int // distinct blobs K's check-ins name, held here StoredBytes int64 // what those blobs occupy in the repository, compressed and delta-encoded ExpandedBytes int64 // their sizes once expanded } // HorizonCost reports, for each rule in order (descendants, leaves), what a // horizon at checkin would hold, so the choice of rule is made with its cost // in view (§3.4). checkin is any name ResolveVersion accepts. The figures are // read from this repository, which must hold the history: a blob it does not // hold is left out of the byte counts, and a sub-manifest it lacks is an // error. func (r *Repo) HorizonCost(checkin string) ([]HorizonRuleCost, error) { rid, err := r.ResolveVersion(checkin) if err != nil { return nil, err } ctx := context.Background() rules := []HorizonRule{HorizonDescendants, HorizonLeaves} out := make([]HorizonRuleCost, 0, len(rules)) for _, rule := range rules { k, err := horizon.HeldSet(ctx, r.inner.DB(), libfossil.FslID(rid), cone.HorizonRule(rule)) if err != nil { return nil, fmt.Errorf("libfossil: horizon cost: %w", err) } cost := HorizonRuleCost{Rule: rule, Checkins: len(k)} seen := map[string]bool{} for _, c := range k { files, err := manifest.ListFiles(r.inner, c) if err != nil { return nil, fmt.Errorf("libfossil: horizon cost: %w", err) } for _, f := range files { if seen[f.UUID] { continue } seen[f.UUID] = true var rid, size int64 err := r.inner.DB().QueryRow( "SELECT rid, size FROM blob WHERE uuid=? AND size>=0", f.UUID, ).Scan(&rid, &size) if errors.Is(err, sql.ErrNoRows) { continue // a phantom or an elided blob: not held, so not counted } if err != nil { return nil, fmt.Errorf("libfossil: horizon cost: blob %s: %w", f.UUID, err) } stored, _, err := blob.StoredHead(r.inner.DB(), libfossil.FslID(rid), 0) if errors.Is(err, blob.ErrNotStored) { stored = 0 // a row without content holds no stored bytes } else if err != nil { return nil, fmt.Errorf("libfossil: horizon cost: blob %s: %w", f.UUID, err) } cost.Blobs++ cost.ExpandedBytes += size cost.StoredBytes += stored } } out = append(out, cost) } return out, nil }