Artifact d6048e8b61150a51add78e9bec457e5a5472ed418f3839951e783048b8d58848:
- File
internal/content/memo_trees.go
— part of check-in
[c50ff7c9e2]
at
2026-10-29 14:01:29
on branch main
— fix(content): serve a kept tree only where a fresh read would give it
Review of the tree memo found it could classify differently warm than cold. A kept tree was served whenever the store held its name at its rid, even when the store could no longer expand it (a delta whose source became a phantom or lost its row), where a fresh read skips the tree. Each view now lists, once, the rids its store cannot expand: a recursive walk of the delta table seeded with the phantoms and the delta sources the store holds no row for. Those sources only appear when a row is deleted, which forces a full read, so they are found once per lineage (about 20ms on fossil-scm.org) and the per-view walk costs about 0.3ms. The phantom check folds into it.
Publish dropped the trees a view read whenever the memo had gone past that view's store, though trees do not depend on the store; they are now kept either way. A view over a store read in full after the memo's is no longer taken for a stale one, so a store whose highest rid was deleted stops being read in full on every view.
The names bound that replaced the byte cap counted pieces, not names, so one publish could pass it; it now counts the names each piece adds, on the fresh-index path too, and is passed as a parameter so it is tested.
The tests compare the names a horizon's walk produces, memo against fresh, which classification alone can hide: a tree's lost nodes also stop its files counting as files. They lose trees as phantoms, as deleted nodes, behind phantom and deleted delta sources, and by a shun and rebuild in stock fossil; removing either check fails them. (user: [email protected], size: 13918) [annotate] [blame] [check-ins using] [more...]
package content import ( "cmp" "context" "encoding/binary" "fmt" "maps" "math" "slices" "strings" "github.com/danmestas/go-libfossil/db" "github.com/danmestas/go-libfossil/internal/deck" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" ) // A Memo keeps every tree its horizons' walks read (draft-fossil-horizon-00 // ยง6.2), so no tree is read twice. The trees are kept as a treeIndex, which // names each blob by a small id and keeps a full check-in's file set as its // difference from the one kept before it: fossil-scm.org's check-ins name // about 21M F-card entries between them, mostly the same few thousand // blobs, and the index keeps every one of its trees in about 11MiB. A // piece costs a few bytes per F card its manifest spells in about 70, so // the index grows with the store, like the reference graph beside it. // // A piece is what its name's bytes say, the same in every store that // holds it, so the index outlives a full read of the store. It serves a // view only where a fresh read would give the same piece: the view's store // holds the name at the piece's rid and can expand it (treeExpandable). // memoTreeNames bounds the names a tree index interns, in place of a bound // on its bytes: one store's trees name at most its artifacts, which a // classification bounds by coneReferenceNodes, so the bound is passed only // by an index that outlived stores which renamed most of what it read. // A publish that would pass it starts the index again from the view's own // pieces, and keeps none when even those pass it. const memoTreeNames = coneReferenceNodes // treePieceBytes and treeNameBytes are what a kept piece and an interned // name are charged beyond their references and bytes: list element, map // entry, slice entry and struct headers. const ( treePieceBytes = 160 treeNameBytes = 64 ) // treeDecoded is how many decoded file sets a treeDecoder keeps, and // treeChain how many set differences a piece may lie from a piece kept // whole, so a walk that meets a chain's pieces in either order decodes // each once. const ( treeDecoded = 16 treeChain = treeDecoded - 1 ) // plainTree is what a horizon's tree walk reads of one held check-in // manifest, baseline or sub-manifest node: its own F-card targets and the // pieces it reaches through its B and S cards. A delta manifest's entry // holds only its own F cards; the baseline it shares with its siblings is // an entry of its own, kept once. type plainTree struct { rid libfossil.FslID size int64 checkin bool files []string b string s []string } // plainTreeOf is d's piece. Its names share d's bytes until an index // interns them. func plainTreeOf(rid libfossil.FslID, size int64, d *deck.Deck) *plainTree { p := &plainTree{rid: rid, size: size, checkin: d.Type == deck.Checkin, b: d.B, files: make([]string, 0, len(d.F))} for _, f := range d.F { if f.UUID != "" { p.files = append(p.files, f.UUID) } } for _, sc := range d.S { p.s = append(p.s, sc.UUID) } return p } // readTree returns the check-in or sub-manifest rid holds, or nil when its // content cannot be read or is no tree: such a piece names nothing, and // the classification after it arrives counts the blobs it names. Parse // checks the Z card. func readTree(q db.Querier, cache *Cache, rid libfossil.FslID) *deck.Deck { data, err := cache.Expand(q, rid) if err != nil { return nil } d, err := deck.Parse(data) if err != nil || d.Type != deck.Checkin && d.Type != deck.Submanifest { return nil } return d } // treeNames interns names as ids; id 0 is no name. type treeNames struct { names []string ids map[string]uint32 } func (n *treeNames) intern(name string) (id uint32, added bool) { if name == "" { return 0, false } if id, ok := n.ids[name]; ok { return id, false } // uint64: on 32-bit targets (TinyGo wasm) MaxUint32 overflows int. if uint64(len(n.names)) >= math.MaxUint32 { panic("content.treeNames.intern: ids exhausted") } id = uint32(len(n.names)) // A copy: the name may share the bytes of the whole tree it came from. n.names = append(n.names, strings.Clone(name)) n.ids[n.names[id]] = id return id, true } func (n *treeNames) name(id uint32) string { return n.names[id] } // treePiece is a plainTree in a treeIndex: names as ids, and its files as // sorted ids, delta-varint encoded (appendTreeIDs). A full check-in's // files may instead be the symmetric difference of its set and base's, // depth differences from a piece kept whole. type treePiece struct { rid libfossil.FslID size int64 b uint32 s []uint32 files []byte base *treePiece depth int full bool } // treeIndex is tree pieces of one store lineage, by name. It is immutable // once a Memo holds it; clone it to extend it. type treeIndex struct { names treeNames pieces map[uint32]*treePiece // last is the full check-in added last and lastIDs its file set, which // bytes counts: the next full check-in is kept as its difference from // them. last *treePiece lastIDs []uint32 bytes int64 } func newTreeIndex() *treeIndex { return &treeIndex{names: treeNames{names: []string{""}, ids: map[string]uint32{}}, pieces: map[uint32]*treePiece{}} } // clone returns a copy that x's readers do not see grow. A publish that // read trees clones once, under the memo's lock: for an index of // fossil-scm.org's whole tree history (45k names, 21k pieces, about 11MiB) // that costs about 0.5ms (BenchmarkTreeIndexClone), and at the bound a few // ms, against the tens of ms the walk spent reading the trees it adds. func (x *treeIndex) clone() *treeIndex { return &treeIndex{ names: treeNames{names: slices.Clip(x.names.names), ids: maps.Clone(x.names.ids)}, pieces: maps.Clone(x.pieces), last: x.last, lastIDs: x.lastIDs, bytes: x.bytes, } } // piece returns the piece name names, or nil. func (x *treeIndex) piece(name string) *treePiece { id, ok := x.names.ids[name] if !ok { return nil } return x.pieces[id] } func (x *treeIndex) intern(name string) uint32 { id, added := x.names.intern(name) if added { x.bytes += treeNameBytes + int64(len(name)) } return id } // add keeps t as the piece name names, which x must not hold. func (x *treeIndex) add(name string, t *plainTree) { id := x.intern(name) if x.pieces[id] != nil { panic("content.treeIndex.add: piece already held") } p := &treePiece{rid: t.rid, size: t.size, b: x.intern(t.b), full: t.checkin && t.b == ""} if len(t.s) > 0 { p.s = make([]uint32, len(t.s)) for i, s := range t.s { p.s[i] = x.intern(s) } } own := make([]uint32, len(t.files)) for i, f := range t.files { own[i] = x.intern(f) } slices.Sort(own) own = slices.Compact(own) p.files = appendTreeIDs(nil, own) if p.full { if x.last != nil && x.last.depth < treeChain { diff := appendTreeIDs(nil, xorTreeIDs(x.lastIDs, own)) if len(diff) < len(p.files) { p.files, p.base, p.depth = diff, x.last, x.last.depth+1 } } x.bytes += 4 * int64(len(own)-len(x.lastIDs)) x.last, x.lastIDs = p, own } p.files = slices.Clip(p.files) x.pieces[id] = p x.bytes += treePieceBytes + int64(len(p.files)+4*len(p.s)) } // appendTreeIDs appends ids, ascending and above zero, to dst as the // uvarints of their gaps. func appendTreeIDs(dst []byte, ids []uint32) []byte { var prev uint32 for _, id := range ids { if id <= prev { panic("content.appendTreeIDs: ids not ascending") } dst = binary.AppendUvarint(dst, uint64(id-prev)) prev = id } return dst } // decodeTreeIDs appends to dst the ids appendTreeIDs encoded as enc. func decodeTreeIDs(dst []uint32, enc []byte) []uint32 { var prev uint64 for len(enc) > 0 { gap, n := binary.Uvarint(enc) if n <= 0 || gap == 0 || gap > math.MaxUint32-prev { panic("content.decodeTreeIDs: malformed ids") } prev += gap dst = append(dst, uint32(prev)) enc = enc[n:] } return dst } // xorTreeIDs returns the ids in exactly one of a and b, both ascending. func xorTreeIDs(a, b []uint32) []uint32 { out := make([]uint32, 0, max(len(a), len(b))) i, j := 0, 0 for i < len(a) && j < len(b) { switch { case a[i] < b[j]: out = append(out, a[i]) i++ case a[i] > b[j]: out = append(out, b[j]) j++ default: i++ j++ } } out = append(out, a[i:]...) return append(out, b[j:]...) } // treeDecoder decodes pieces' file sets, keeping the full check-ins' most // recently decoded, which a chain of differences decodes through. It is // used by one goroutine. type treeDecoder struct { recent [treeDecoded]decodedTree // most recently used first } type decodedTree struct { piece *treePiece ids []uint32 } // files returns p's file ids, ascending. The caller must not modify them. func (d *treeDecoder) files(p *treePiece) []uint32 { for i, e := range d.recent { if e.piece == p { copy(d.recent[1:i+1], d.recent[:i]) d.recent[0] = e return e.ids } } ids := decodeTreeIDs(nil, p.files) if p.base != nil { ids = xorTreeIDs(d.files(p.base), ids) } if p.full { copy(d.recent[1:], d.recent[:treeDecoded-1]) d.recent[0] = decodedTree{piece: p, ids: ids} } return ids } // publishTrees keeps pieces, which a view read, in the memo's trees, past // limit names (memoTreeNames) starting again. The caller holds m.mu. func (m *Memo) publishTrees(pieces map[string]*plainTree, limit int) { if len(pieces) == 0 { return } names := slices.SortedFunc(maps.Keys(pieces), func(a, b string) int { return cmp.Compare(pieces[b].rid, pieces[a].rid) }) if m.trees != nil { if x := m.trees.clone(); x.addPieces(names, pieces, limit) { m.trees = x return } } m.trees = nil if x := newTreeIndex(); x.addPieces(names, pieces, limit) { m.trees = x } } // addPieces adds pieces, by names in order (newest first, as a walk reads // them), and reports whether x still holds no more than limit names, // stopping at the first piece that passes it. A piece x holds at another // rid, which a renumbered store holds, takes the new rid. func (x *treeIndex) addPieces(names []string, pieces map[string]*plainTree, limit int) bool { for _, name := range names { t := pieces[name] if held := x.piece(name); held != nil { if held.rid != t.rid { moved := *held moved.rid = t.rid x.pieces[x.names.ids[name]] = &moved } continue } x.add(name, t) if len(x.names.names) > limit { return false } } return true } // treeUnexpandableSQL lists the rids whose content a store cannot expand: // those of the JSON array ?1 (its phantoms and the delta sources it holds // no row for) and every delta whose chain of sources reaches one. const treeUnexpandableSQL = `WITH RECURSIVE bad(rid) AS ( SELECT value FROM json_each(?1) UNION SELECT delta.rid FROM delta JOIN bad ON delta.srcid=bad.rid) SELECT rid FROM bad` // sourcelessDeltas lists the delta sources the store holds no row for. // On fossil-scm.org's store it scans 59k delta rows in about 20ms, so a // memo reads it once per lineage, not once per view. func sourcelessDeltas(ctx context.Context, q db.Querier) ([]libfossil.FslID, error) { var out []libfossil.FslID err := referenceRows(ctx, q, `SELECT DISTINCT delta.srcid FROM delta LEFT JOIN blob ON blob.rid=delta.srcid WHERE blob.rid IS NULL`, func(scan func(...any) error) error { var rid libfossil.FslID err := scan(&rid) out = append(out, rid) return err }) if err != nil { return nil, fmt.Errorf("classification memo: delta sources: %w", err) } return out, nil } // treeExpandable reports whether the view's store holds name at rid and can // expand it, as a fresh read of the piece would; a phantom is among what it // cannot expand. A store goes on only by appending rows and filling // phantoms that nothing is a delta of (memoStore.settled), so the rids it // cannot expand are listed once per view, from its phantoms and the // sources its deltas lack (memoStore.sourceless): about 0.3ms on // fossil-scm.org's store. func (v *MemoView) treeExpandable(ctx context.Context, q db.Querier, name string, rid libfossil.FslID) (bool, error) { if v.store.refs.facts.names[rid] != name { return false, nil } if v.unexpandable == nil { seeds := append(slices.Collect(maps.Keys(v.store.prefix.phantoms)), v.store.sourceless...) seedsJSON, err := ridsJSON(seeds) if err != nil { return false, err } if seedsJSON == "" { seedsJSON = "[]" } v.unexpandable = map[libfossil.FslID]bool{} err = referenceRows(ctx, q, treeUnexpandableSQL, func(scan func(...any) error) error { var r libfossil.FslID err := scan(&r) v.unexpandable[r] = true return err }, seedsJSON) if err != nil { v.unexpandable = nil return false, fmt.Errorf("classification memo: unexpandable trees: %w", err) } } return !v.unexpandable[rid], nil } // expandTree has w expand the piece name names as the view's store holds // it, and reports whether the view could: read before by this view, or by // a published one. A published piece serves only where a fresh read would // give it (treeExpandable): a piece read under a later prefix, in a store // that numbered its rids otherwise, or whose content the store can no // longer expand, is read again. func (v *MemoView) expandTree(w *treeWalk, name string) (bool, error) { if p := v.trees[name]; p != nil { if err := w.charge(p.size); err != nil { return true, err } w.take(p) return true, nil } m := v.memo m.mu.Lock() x := m.trees m.mu.Unlock() if x == nil { return false, nil } p := x.piece(name) if p == nil { return false, nil } if ok, err := v.treeExpandable(w.ctx, w.q, name, p.rid); err != nil || !ok { return false, err } if err := w.charge(p.size); err != nil { return true, err } for _, id := range v.decoder.files(p) { w.files[x.names.name(id)] = true } w.push(x.names.name(p.b)) for _, s := range p.s { w.push(x.names.name(s)) } return true, nil }