Artifact ed98ff1ce36356ae7f034d326b24aba632af1becaa950f2be54a62a8b44cfb0f:
- File
internal/content/available.go
— part of check-in
[8b16c91f5c]
at
2026-07-28 04:03:58
on branch trunk
— perf: stop clone re-seeking blob.uuid per F-card and over-allocating delta output (#187) (#212)
Cloning the Fossil SCM corpus (67.6k blobs) took 293-302 s at 1251-1287 MB against canonical fossil's 20.8 s at 205 MB. A CPU and allocation profile of one clone found the cost was not delta expansion or hash verification.
27.2% of all CPU was a single un-memoized statement. content.AvailabilityCache memoizes the delta-chain walk but not the "SELECT rid FROM blob WHERE uuid=?" that starts it, so manifest.missingCheckinRefs re-seeked the blob.uuid index once per F-card of every check-in -- the same file hash resolved again for every revision that did not change it. That index is far larger than SQLite's page cache, so each seek was three page reads: pread syscalls were 34.7% of total samples. The walk it guards was under 1% of its own cost.
Separately, materializing 8.27 GB of content allocated 360 GB. delta.ApplyInto starts at a 64 KiB cap deliberately -- the target length is a wire claim and a few bytes can declare 4 GiB -- and then climbs to the real size through append's ~1.25x large-slice growth, ~3.4x the target in discarded intermediates, leaving slack that content.Cache.clip copied away again (203.9 GB and 59.8 GB respectively).
Three changes:
- Memoize uuid -> rid in AvailabilityCache. Hits only. Within one crosslink run a hit cannot go stale, because a sweep and a cascade only ever add blob rows; a miss can, because ridOrPhantom and blob.StorePhantom create rows mid-run, and remembering an absence would leave an artifact deferred after the content it waits on arrived. The scope that makes this sound is now stated on the type, and TestAvailabilityCacheMustNotSpanAPurge pins its one boundary: shun.Purge deletes blob rows, so a cache must not span one.
- Size delta.ApplyInto's output exactly. scanOutputSize walks the command stream first without allocating, checking every copy against bytes the source holds and every insert against bytes the delta carries, so the size it returns is a measurement rather than the header's claim. Any stream it cannot walk to a clean terminator falls back to the existing growth path, so malformed and hostile deltas allocate and fail exactly as before. The buffer now arrives tightly sized, which also makes clip's copy a no-op.
- Cut crosslinkCacheBytes from 256 to 128 MiB. Each byte of that budget costs roughly five bytes of peak RSS -- GOGC doubles it into the heap goal, and the scavenger is paced off GC cycles, of which a faster sweep runs fewer. At 256 MiB the first two changes gave 205.7 s but 1473 MB, faster at the cost of memory. The measured curve (32/64/128/256 MiB: 369.6 s/318 MB, 292.3 s/447 MB, 249.4 s/812 MB, 205.7 s/1473 MB) puts 128 MiB where both axes improve.
Result on the same corpus: 245.4 s at 780 MB, from 293-302 s at 1251-1287 MB -- 17% less wall time and 39% less peak RSS, with instructions retired down from 3.37T to 2.48T. The gap to canonical narrows from 14.1x/6.3x to 11.8x/3.8x.
Derived tables are unchanged against an unmodified build cloning the same server: blob 67530, event 27099 (identical per type), mlink 104919, plink 23741, tagxref 51766, leaf 1426, filename 1496. The corpus parity gate passes.
What remains is structural rather than a missing optimization: a clone expands every blob twice, once in the receive-time phantom-fill cascade and once in the final whole-repository sweep, where canonical crosslinks each artifact from content it already holds in hand at receive time. Cache.Expand splits 45.4 s / 35.3 s across those two passes. Collapsing them is a change to the receive path and belongs in its own issue. (user: [email protected], size: 11799) [annotate] [blame] [check-ins using]
package content import ( "database/sql" "github.com/danmestas/go-libfossil/db" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" ) // maxDeltaChainDepth bounds every delta-chain walk in the package: the two // read-path walks, IsAvailable here and walkDeltaChain in content.go, and the // write-path walk deltifyBreaksLoop in deltify.go. Each stops after visiting // this many nodes. One concept, one bound: the rationale below is the same for // all three, since they walk the same delta table over the same chains. // // A chain cannot be longer than the number of stored versions of one file. // The deepest chain in the Fossil SCM repository, the largest corpus this // package is measured against, is 2,546 nodes; the average is 469. This bound // is roughly six times the observed maximum, so a chain that reaches it is // corrupt or hostile rather than merely large. Reaching it is a safe failure: // IsAvailable reports the content unavailable, which defers the artifact, and // walkDeltaChain returns an error rather than a partial chain. // // The bound is a backstop, not the defence. Termination on a cyclic delta // table — reachable from a corrupt repository, or from a peer that plants one // during clone — comes from the visited-set check in each walk, which stops at // the first repeat. Without that, this constant is the number of queries an // attacker buys per call, and both walks are wire-reachable: IsAvailable runs // once per F-card of every check-in being crosslinked. const maxDeltaChainDepth = 16_384 // IsAvailable reports whether the content of rid can actually be read. // // This is the availability predicate, distinct from blob.Exists, which // answers only whether a row is present. It returns false for a phantom // (blob.size < 0), false for an unknown rid, and — transitively — false for a // delta whose chain bottoms out in a phantom, even though every blob row in // that chain exists. It returns true only when the full chain is grounded in // readable full-text content. // // Ported from Fossil's content_is_available (src/content.c:163). Callers must // never walk the delta table themselves; that logic lives here alone. func IsAvailable(q db.Querier, rid libfossil.FslID) bool { if q == nil { panic("content.IsAvailable: q must not be nil") } if rid <= 0 { return false } // One statement per chain node, not two. Every SQL round trip here pays // a WAL read-lock acquisition, and this walk runs once per F-card of // every check-in being crosslinked, over chains that are thousands of // nodes deep in a real repository — the round trips, not the work they // do, are the cost. const step = `SELECT b.size, d.rid IS NOT NULL, d.srcid FROM blob b LEFT JOIN delta d ON d.rid = b.rid WHERE b.rid = ?` // A cycle ends the walk at the first repeat rather than at // maxDeltaChainDepth. The difference is what a peer who plants a // two-node cycle costs us: two queries instead of the bound. seen := make(map[libfossil.FslID]struct{}) current := rid for depth := 0; depth < maxDeltaChainDepth; depth++ { if _, repeat := seen[current]; repeat { return false // cycle — the chain is not grounded in anything } seen[current] = struct{}{} var size int64 var hasDelta bool var srcid sql.NullInt64 if err := q.QueryRow(step, current).Scan(&size, &hasDelta, &srcid); err != nil { return false // unknown rid } if size < 0 { return false // phantom } if !hasDelta { return true // chain is grounded in full-text content } // delta.srcid is NOT NULL in the schema, so Valid is always true // here. Checking it costs nothing and keeps a schema change from // turning into a NULL scanned as zero, which reads as "grounded". if !srcid.Valid { return false } if srcid.Int64 == 0 { return true } current = libfossil.FslID(srcid.Int64) } return false // bound exceeded — pathological chain } // AvailableByUUID resolves uuid to its rid and reports whether that // artifact's content is available, per IsAvailable. // // Use this in place of blob.Exists wherever the resolved rid is about to be // passed to Expand: blob.Exists answers true for a phantom, whose content // cannot be read. func AvailableByUUID(q db.Querier, uuid string) (libfossil.FslID, bool) { if q == nil { panic("content.AvailableByUUID: q must not be nil") } if uuid == "" { panic("content.AvailableByUUID: uuid must not be empty") } var rid int64 if err := q.QueryRow("SELECT rid FROM blob WHERE uuid=?", uuid).Scan(&rid); err != nil { return 0, false } if !IsAvailable(q, libfossil.FslID(rid)) { return 0, false } return libfossil.FslID(rid), true } // AvailabilityCache memoizes IsAvailable across many calls that share delta // chains, for a caller that knows blob content is immutable for the cache's // lifetime -- a single crosslink sweep. IsAvailable walks a blob's whole delta // chain on every call, and crosslink calls it once per F-card of every // check-in, so the same file blobs -- and the same chain suffixes -- are walked // again and again; on the Fossil SCM corpus that walk was ~40% of the sweep. // // A blob's availability is a property of its chain, and every node above a // given node in the same chain shares that node's grounding or phantom, so one // walk decides every node it passes through. The cache records that verdict for // each rid, and a later walk that reaches an already-decided rid stops there. // Total walk work over a sweep is then bounded by the number of distinct blobs, // not by the number of F-card references. // // ByUUID additionally memoizes the uuid->rid resolution the walk starts from. // That lookup, not the walk, is what a sweep repeats: every F-card of every // check-in names a file by hash, the same file hash appears in every revision // that did not change it, and each name costs an index seek into a blob.uuid // index far larger than SQLite's page cache. On the Fossil SCM repository that // one statement was 27% of a whole clone's CPU -- more than delta expansion, // hash verification and the receive path combined -- while the chain walk it // guards was under 1%. // // # Scope // // Not safe for concurrent use. Both memos below are sound because of where // this type is used, not because the facts they hold are permanent, so the // scope is the invariant and it is worth stating exactly. // // A cache belongs to ONE crosslink run: one whole-repository sweep // (manifest.linkCandidatesInOrder) or one phantom-fill cascade // (manifest.newCascadeLinker). Both build it as a local, neither hands it to a // *repo.Repo or keeps it past the call, and nothing else constructs one. Over // that window blob rows are only ever added -- crosslinking creates phantoms, // fills them, and rewrites how content is stored, but removes nothing. // // The one operation in the tree that removes a blob row is shun.Purge, which // DELETEs from blob and delta. It has no production caller today; it runs as // its own top-level operation, not from inside a sweep or a sync round. If // that ever changes -- a sync learning to process shun cards mid-round is the // plausible way -- a cache must not span it, because a memoized rid would then // name a row that no longer exists. See // TestAvailabilityCacheMustNotSpanAPurge, which pins that consequence so it is // discovered by a failing test rather than by a wrong derivation. // // Widening the lifetime past one crosslink run -- caching one per repository, // or per process -- reopens exactly that question and is not a free change. type AvailabilityCache struct { avail map[libfossil.FslID]bool // rids memoizes uuid -> rid for hashes that resolved to a blob row. // // Only successful resolutions are recorded, and that asymmetry is // load-bearing rather than an omission. Within the scope above a uuid that // names a row keeps naming the same row: blob.uuid is UNIQUE, rids are // never reassigned, and nothing a crosslink run does deletes the row. A // uuid that names NO row can gain one at any moment -- ridOrPhantom and // blob.StorePhantom both create rows mid-run -- so caching the absence // would answer a later question with an earlier repository, and an // artifact would stay deferred after the content it waits on arrived. The // negative case is also the rare one: it means an artifact references // content that has not arrived, which is the exception a sweep defers on, // not the rule it runs on. rids map[string]libfossil.FslID } // NewAvailabilityCache returns an empty availability cache. func NewAvailabilityCache() *AvailabilityCache { return &AvailabilityCache{ avail: make(map[libfossil.FslID]bool), rids: make(map[string]libfossil.FslID), } } // ByUUID resolves uuid to its rid and reports whether that blob's content is // available, memoizing the chain walk. Semantics match AvailableByUUID. func (a *AvailabilityCache) ByUUID(q db.Querier, uuid string) (libfossil.FslID, bool) { if a == nil { return AvailableByUUID(q, uuid) } if q == nil { panic("content.AvailabilityCache.ByUUID: q must not be nil") } if uuid == "" { panic("content.AvailabilityCache.ByUUID: uuid must not be empty") } rid, ok := a.ridForUUID(q, uuid) if !ok { return 0, false } if !a.isAvailable(q, rid) { return 0, false } return rid, true } // ridForUUID resolves uuid to its blob rid, memoizing hits. See the note on // AvailabilityCache.rids for why misses are deliberately not memoized. func (a *AvailabilityCache) ridForUUID(q db.Querier, uuid string) (libfossil.FslID, bool) { if rid, ok := a.rids[uuid]; ok { return rid, true } var rid int64 if err := q.QueryRow("SELECT rid FROM blob WHERE uuid=?", uuid).Scan(&rid); err != nil { return 0, false } a.rids[uuid] = libfossil.FslID(rid) return libfossil.FslID(rid), true } // isAvailable is IsAvailable with memoization. It walks rid's chain until it // reaches a node whose verdict is already cached, a phantom or unknown rid // (unavailable), a grounded full-text root (available), a cycle (unavailable), // or the depth bound (unavailable) -- then records that one verdict for every // node it walked, since they all share it. func (a *AvailabilityCache) isAvailable(q db.Querier, rid libfossil.FslID) bool { if rid <= 0 { return false } if v, ok := a.avail[rid]; ok { return v } const step = `SELECT b.size, d.rid IS NOT NULL, d.srcid FROM blob b LEFT JOIN delta d ON d.rid = b.rid WHERE b.rid = ?` // walked holds every node whose verdict this call decides; result is the // verdict they share. seen catches a cycle within this walk (nodes already // cached are handled by the memo check and never re-entered). var walked []libfossil.FslID seen := make(map[libfossil.FslID]struct{}) result := false current := rid for depth := 0; depth < maxDeltaChainDepth; depth++ { if v, ok := a.avail[current]; ok { result = v break } if _, repeat := seen[current]; repeat { result = false // cycle — not grounded in anything break } seen[current] = struct{}{} walked = append(walked, current) var size int64 var hasDelta bool var srcid sql.NullInt64 if err := q.QueryRow(step, current).Scan(&size, &hasDelta, &srcid); err != nil { result = false // unknown rid break } if size < 0 { result = false // phantom break } if !hasDelta { result = true // chain is grounded in full-text content break } if !srcid.Valid { result = false // schema guard: a NULL srcid must not read as grounded break } if srcid.Int64 == 0 { result = true // grounded break } current = libfossil.FslID(srcid.Int64) } for _, n := range walked { a.avail[n] = result } return result }