Skip to content
quarry Artifact [18a90cb083]
Login

Artifact [18a90cb083]

Artifact 18a90cb08395023160a5b6fc5263db16e71ecc492e641123b165f3b7d2f22d1a:


package zitcli

import (
	"container/list"
	"sync"
	"sync/atomic"
	"unsafe"
)

// lru is a byte-bounded, least-recently-used memo over zit reads.
//
// Bounded by BYTES, not entries, because the values are store content:
// an entry-count cap is fine for listings and meaningless for anything
// whose entries vary by orders of magnitude — one Cat can be a 30MB
// blob, and sixty-four of them is two gigabytes of resident memory. A
// diff has the same shape: a one-line fix and a thousand-file merge are
// both one entry and nothing like one cost.
//
// Per repository rather than shared across the fleet. A shared budget
// uses memory better, but it lets one busy store evict another's
// entries, so a reader on a quiet repository pays for traffic they have
// nothing to do with. Predictable beats optimal here.
//
// Per-repository budgets alone do not bound the process, though: they
// add up. The blob budget was sized as "eleven repositories at that
// budget is a ceiling the process can actually afford", and the fleet
// grew to thirty-three, two of them (netbsd-src, pkgsrc) with listings
// of 200,000 and 68,000 files. A crawler walking check-ins across the
// fleet filled every budget at once and the forge was killed for memory
// at 11.6 GB on 2026-10-15. So every cache also charges one forge-wide
// ceiling (SetCacheCeiling), and a put that takes the forge over it
// evicts the PUTTING cache's own least-recently-used entries: the
// repository being crawled pays for its crawl, and a quiet one keeps
// what it holds, which is the trade the per-repository budget exists
// for.
//
// EVERY key must be derived from an immutable version. That is not a
// property this type can check, so it is the caller's obligation and the
// reason ops resolve names to hashes before reading — see
// Deps.resolveVersion. A moving name cached here would serve stale
// content forever.
type lru[V any] struct {
	mu     sync.Mutex
	max    int64
	used   int64
	ceil   *ceiling
	ll     *list.List               // front = most recently used
	items  map[string]*list.Element // key -> element holding *entry[V]
	sizeOf func(V) int64
}

type entry[V any] struct {
	key  string
	val  V
	size int64
}

func newLRU[V any](max int64, sizeOf func(V) int64) *lru[V] {
	return &lru[V]{max: max, ceil: &forgeCeiling, ll: list.New(), items: map[string]*list.Element{}, sizeOf: sizeOf}
}

// ceiling is a byte budget shared by every cache that charges it.
// Zero means none.
type ceiling struct {
	max  atomic.Int64
	used atomic.Int64
}

// forgeCeiling is the one every cache in the process charges.
var forgeCeiling ceiling

// SetCacheCeiling bounds the bytes all repositories' caches hold
// together; 0 removes the bound. A server sets it once at startup.
func SetCacheCeiling(bytes int64) { forgeCeiling.max.Store(bytes) }

func (f *ceiling) limit() int64 { return f.max.Load() }

func (f *ceiling) over() bool {
	m := f.max.Load()
	return m > 0 && f.used.Load() > m
}

// charge moves n bytes in or out of this cache and its ceiling.
func (c *lru[V]) charge(n int64) {
	c.used += n
	c.ceil.used.Add(n)
}

// get returns a cached value and marks it recently used. The value is
// the cached one, so callers must not mutate it — every caller hands it
// straight to a reader or a parser.
func (c *lru[V]) get(key string) (V, bool) {
	var zero V
	if c == nil {
		return zero, false
	}
	c.mu.Lock()
	defer c.mu.Unlock()
	el, ok := c.items[key]
	if !ok {
		return zero, false
	}
	c.ll.MoveToFront(el)
	return el.Value.(*entry[V]).val, true
}

// put stores a value, evicting least-recently-used entries until it
// fits. A single value larger than the whole budget is NOT cached and
// not an error: admitting it would evict everything else to hold one
// item, which is worse than missing.
func (c *lru[V]) put(key string, val V) {
	if c == nil {
		return
	}
	size := c.sizeOf(val)
	if size > c.max || (c.ceil.limit() > 0 && size > c.ceil.limit()) {
		return
	}
	c.mu.Lock()
	defer c.mu.Unlock()
	var e *entry[V]
	if el, ok := c.items[key]; ok {
		e = el.Value.(*entry[V])
		c.charge(size - e.size)
		e.val, e.size = val, size
		c.ll.MoveToFront(el)
	} else {
		e = &entry[V]{key: key, val: val, size: size}
		c.items[key] = c.ll.PushFront(e)
		c.charge(e.size)
	}
	// Over this cache's budget, or over the forge's: either way it is
	// this cache's own entries that go, oldest first. If that reaches
	// the new entry itself, this cache has nothing left to give and the
	// value is simply not kept.
	for c.used > c.max || c.ceil.over() {
		back := c.ll.Back()
		if back == nil {
			return
		}
		old := c.ll.Remove(back).(*entry[V])
		delete(c.items, old.key)
		c.charge(-old.size)
		if old == e {
			return
		}
	}
}

// stats reports entries and bytes held, for tests and diagnostics.
func (c *lru[V]) stats() (entries int, bytes int64) {
	if c == nil {
		return 0, 0
	}
	c.mu.Lock()
	defer c.mu.Unlock()
	return len(c.items), c.used
}

// blobCache is the LRU over file contents.
type blobCache = lru[[]byte]

func newBlobCache(max int64) *blobCache {
	// cap, not len: a blob is the buffer zit's output was read into,
	// which grows by doubling, so its length undercounted what it held
	// by as much as half.
	return newLRU[[]byte](max, func(v []byte) int64 { return int64(cap(v)) })
}

// listingCache is the LRU over file listings. Bounded by bytes like the
// others: it was 64 entries, which is nothing on quarry's own store and
// over two gigabytes on netbsd-src, whose one listing is 200,000 files.
type listingCache = lru[[]LsEntry]

func newListingCache(max int64) *listingCache {
	return newLRU[[]LsEntry](max, func(v []LsEntry) int64 {
		n := int64(cap(v)) * int64(unsafe.Sizeof(LsEntry{}))
		for _, e := range v {
			n += int64(len(e.Blob) + len(e.Name) + len(e.Perm))
		}
		return n
	})
}

// diffCache is the LRU over check-in diffs.
//
// The pair of hashes is immutable, so a hit is permanently correct and
// this memo never needs invalidating — the reason a diff is worth
// caching at all. What it does need is a bound that reflects what a
// diff can weigh, which is why it is the same byte-budgeted structure
// as the blob cache rather than a count of entries.
type diffCache = lru[diffResult]

func newDiffCache(max int64) *diffCache {
	return newLRU[diffResult](max, func(v diffResult) int64 {
		// The unified text dominates; the entry list is names and
		// statuses. Counted rather than ignored because a merge across
		// a thousand files carries a thousand of them.
		n := int64(len(v.unified))
		for _, e := range v.entries {
			n += int64(len(e.Name) + len(e.Status))
		}
		return n
	})
}