Artifact 00d81960706e9328858b5be014eddb5db240080a01af1b5451d7770649f28b4f:
- File
internal/content/cache.go
— part of check-in
[48ff1f5084]
at
2026-10-22 21:17:12
on branch feat/horizon-cones
— perf(manifest): stop re-walking delta chains and re-parsing manifests in a rebuild
Rebuilding a clone of the Fossil SCM repository (67,894 artifacts, 21,008 check-ins) took about 3 minutes of which 104 s was parentTreeGap asking content.AvailableByUUID, an uncached delta-chain walk with one SQL statement per node, for every parent of every check-in. It now uses the sweep's availability cache like the rest of the guard.
The same sweep parsed each check-in three times (as a child, as its child's parent, as a delta baseline) and rebuilt its file maps. The sweep's content cache now keeps the parsed deck, file views and baseline rid of its few most recent check-ins (Cache.Derived), dropped with the cached bytes on Invalidate and Clear. Derived hits count as reuse in the cascade stats, so the shared-cache regression guard keeps measuring it.
The rebuilt event, mlink, plink, tagxref, leaf, filename, cherrypick, orphan, unclustered, unsent, forumpost, attachment, phantom, blob and delta tables are row-for-row identical to the previous code's. (user: [email protected], size: 9087) [annotate] [blame] [check-ins using] [more...]
package content import ( "container/list" "sync" "github.com/danmestas/go-libfossil/db" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" ) // Cache is a concurrency-safe LRU cache for expanded blob content. // It reduces redundant delta-chain walks by caching the fully-expanded // result of [Expand], keyed by rid. // // A nil *Cache is valid and acts as a passthrough to [Expand]. type Cache struct { mu sync.Mutex items map[libfossil.FslID]*list.Element order *list.List curSize int64 maxSize int64 maxEntries int hits int64 misses int64 // derived holds the few most recent values callers computed from cached // content; derivedOrder is its insertion order, for eviction. derived map[any]any derivedOrder []any derivedHits int64 } type cacheEntry struct { rid libfossil.FslID data []byte } // CacheStats reports cache hit/miss statistics and current memory usage. type CacheStats struct { Hits int64 Misses int64 // DerivedHits counts Derived lookups that found a value: reads that // reused work done on cached content rather than expanding it again. DerivedHits int64 Size int64 MaxSize int64 Entries int } // maxCacheEntries is a fixed hard bound on cache metadata. It is deliberately // independent of maxSize: content byte accounting remains exact, while empty // or tiny entries cannot grow the map and LRU list without bound. The limit // exceeds the 76,700-blob corpus clone and ordinary cache use, so it does not // perturb their LRU behavior. const maxCacheEntries = 100_000 // NewCache creates a cache bounded by maxBytes of expanded content and by // maxCacheEntries metadata entries. func NewCache(maxBytes int64) *Cache { if maxBytes <= 0 { panic("content.NewCache: maxBytes must be > 0") } return &Cache{ items: make(map[libfossil.FslID]*list.Element), order: list.New(), maxSize: maxBytes, maxEntries: maxCacheEntries, } } // Expand returns the expanded content for rid, serving from cache when possible. // // On a miss it walks rid's delta chain only as far back as the deepest // ancestor the cache already holds, and caches every node it materializes on // the way forward. That is what makes a sweep over a whole repository — every // blob of a chain expanded once, in some order — cost one delta application // per blob rather than one per (blob, chain-depth) pair. // // A nil receiver falls through to [Expand] directly. func (c *Cache) Expand(q db.Querier, rid libfossil.FslID) ([]byte, error) { if c == nil { return Expand(q, rid) } if q == nil { panic("content.Cache.Expand: q must not be nil") } if rid <= 0 { panic("content.Cache.Expand: rid must be > 0") } c.mu.Lock() if elem, ok := c.items[rid]; ok { c.order.MoveToFront(elem) data := elem.Value.(*cacheEntry).data c.hits++ c.mu.Unlock() out := make([]byte, len(data)) copy(out, data) return out, nil } c.misses++ c.mu.Unlock() // have hands out the cache's own buffer; expandChain never writes // through it, and the copy below is what the caller gets. have := func(id libfossil.FslID) ([]byte, bool) { c.mu.Lock() defer c.mu.Unlock() elem, ok := c.items[id] if !ok { return nil, false } c.order.MoveToFront(elem) return elem.Value.(*cacheEntry).data, true } data, err := expandChain(q, rid, have, c.store, true) if err != nil { return nil, err } out := make([]byte, len(data)) copy(out, data) return out, nil } // store takes ownership of data as the cached content for rid. expandChain // hands over buffers it will not touch again. func (c *Cache) store(rid libfossil.FslID, data []byte) { c.storeOwned(rid, data) } // Remember retains a copy of already-expanded content. It is for receive paths // that have just hash-verified full bytes and must examine those supplied bytes // before permitting normal cache eviction; callers retain ownership of data. func (c *Cache) Remember(rid libfossil.FslID, data []byte) { if c == nil { return } if rid <= 0 { panic("content.Cache.Remember: rid must be > 0") } c.mu.Lock() defer c.mu.Unlock() if elem, ok := c.items[rid]; ok { c.order.MoveToFront(elem) return } if int64(len(data)) > c.maxSize { return } copied := make([]byte, len(data)) copy(copied, data) c.storeOwnedLocked(rid, copied) } func (c *Cache) storeOwned(rid libfossil.FslID, data []byte) { c.mu.Lock() defer c.mu.Unlock() c.storeOwnedLocked(rid, data) } // storeOwnedLocked takes ownership of data as the cached content for rid. // c.mu must be held. func (c *Cache) storeOwnedLocked(rid libfossil.FslID, data []byte) { if elem, ok := c.items[rid]; ok { c.order.MoveToFront(elem) return } data = clip(data) elem := c.order.PushFront(&cacheEntry{rid: rid, data: data}) c.items[rid] = elem c.curSize += entrySize(data) for (c.curSize > c.maxSize || c.order.Len() > c.maxEntries) && c.order.Len() > 0 { c.evictOldest() } } // clip returns data in a buffer with no spare capacity, copying only when // there is slack to reclaim. // // Expanded content arrives from delta.Apply's append growth, whose capacity // is rounded up to a size class. Accounting a budget in lengths while // retaining capacities lets maxSize drift from the bytes actually held, by an // amount that depends on the allocator rather than on anything the caller // controls. Clipping makes curSize exact instead of approximate. // // It is an accounting fix, not a memory saving, and it was measured: against // the Fossil SCM repository the cache holds the same 543 entries and the same // 255 MiB at a 256 MiB budget with and without it, because at those sizes the // buffers already arrive tightly sized. The slack is real for small entries -- // it is what the eviction tests exercise -- and negligible for large ones. // Peak process RSS did not move either (1067 MB against 1050 MB); see // manifest.crosslinkCacheBytes for where that number actually comes from. func clip(data []byte) []byte { if cap(data) == len(data) { return data } out := make([]byte, len(data)) copy(out, data) return out } // entrySize is the memory an entry holds, capacity included. Entries are // clipped on the way in, so this equals their length -- it is written against // capacity so that stops being true loudly rather than silently. func entrySize(data []byte) int64 { return int64(cap(data)) } func (c *Cache) evictOldest() { back := c.order.Back() if back == nil { return } e := back.Value.(*cacheEntry) c.order.Remove(back) delete(c.items, e.rid) c.curSize -= entrySize(e.data) } // maxDerived bounds the derived-value memo. A sweep reads each check-in // beside its parent and baseline, so a handful of recent values is the whole // working set; the values (parsed manifests) are large, so the bound is small. const maxDerived = 32 // Derived returns the value SetDerived last stored under key, if it is still // held. It lets a caller that parses or indexes cached content keep the result // next to the bytes rather than redo the work for each neighbour that reads // the same artifact. The value is shared: callers must treat it as read-only. // A nil Cache holds nothing. func (c *Cache) Derived(key any) (any, bool) { if c == nil { return nil, false } c.mu.Lock() defer c.mu.Unlock() v, ok := c.derived[key] if ok { c.derivedHits++ } return v, ok } // SetDerived stores v under key, evicting the oldest value past maxDerived. // key must be comparable. Because Invalidate and Clear drop every derived // value, v may be computed from content that is immutable only while cached. func (c *Cache) SetDerived(key, v any) { if c == nil { return } c.mu.Lock() defer c.mu.Unlock() if c.derived == nil { c.derived = make(map[any]any, maxDerived) } if _, held := c.derived[key]; !held { if len(c.derivedOrder) == maxDerived { delete(c.derived, c.derivedOrder[0]) c.derivedOrder = append(c.derivedOrder[:0], c.derivedOrder[1:]...) } c.derivedOrder = append(c.derivedOrder, key) } c.derived[key] = v } func (c *Cache) dropDerivedLocked() { c.derived = nil c.derivedOrder = c.derivedOrder[:0] } // Invalidate removes a single rid from the cache. func (c *Cache) Invalidate(rid libfossil.FslID) { if c == nil { return } c.mu.Lock() defer c.mu.Unlock() c.dropDerivedLocked() if elem, ok := c.items[rid]; ok { e := elem.Value.(*cacheEntry) c.curSize -= entrySize(e.data) c.order.Remove(elem) delete(c.items, rid) } } // Clear removes all entries from the cache. func (c *Cache) Clear() { if c == nil { return } c.mu.Lock() defer c.mu.Unlock() c.items = make(map[libfossil.FslID]*list.Element) c.order.Init() c.curSize = 0 c.dropDerivedLocked() } // Stats returns a snapshot of cache statistics. func (c *Cache) Stats() CacheStats { if c == nil { return CacheStats{} } c.mu.Lock() defer c.mu.Unlock() return CacheStats{ Hits: c.hits, Misses: c.misses, DerivedHits: c.derivedHits, Size: c.curSize, MaxSize: c.maxSize, Entries: c.order.Len(), } }