Artifact 6a29b54fb396d2b2b7c3c957c7dc8f93bae13fade184111c5d73c0953f2d18f5:
- File
internal/zitcli/cache.go
— part of check-in
[0cf9a57e88]
at
2026-08-17 21:14:09
on branch trunk
— zitcli: byte-bounded content cache for hash-versioned reads
zit cat costs ~147ms even for a small file, against a 3ms process floor, and every file view forked one. Under load that capped the forge at 32 req/s, degrading to 24 at twenty concurrent readers.
hash-versioned file page: 393ms -> 18ms 50 concurrent: 201 req/s, still climbing
Bounded by BYTES, not entries: values are file contents, and an entry-counted cache holding a 30MB blob is a memory leak with a polite name. Per repository, so a busy store cannot evict a quiet one's entries.
resolveVersion only expands hash PREFIXES. An earlier draft fell back to the index's branch table for names, which served tk a README from 2020: tk renamed its trunk to main, so a branch literally called trunk still exists and is six years stale, while zit resolves the word correctly. 0001 ยง2 makes zit the authority on names and that is not negotiable โ so a symbolic version stays uncached, and the way to fix that is to make links carry the resolved hash. (user: dmestas, size: 2828) [annotate] [blame] [check-ins using]
package zitcli import ( "container/list" "sync" ) // blobCache is a byte-bounded LRU over zit reads. // // Bounded by BYTES, not entries, because the values are file contents: // the listing memo's 64-entry cap is fine for listings but meaningless // for Cat, where one entry can be a 30MB blob and sixty-four of them is // two gigabytes of resident memory. // // 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. // // 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 blobCache struct { mu sync.Mutex max int64 used int64 ll *list.List // front = most recently used items map[string]*list.Element // key -> element holding *cacheEntry } type cacheEntry struct { key string val []byte size int64 } func newBlobCache(max int64) *blobCache { return &blobCache{max: max, ll: list.New(), items: map[string]*list.Element{}} } // get returns a cached value and marks it recently used. The returned // slice is the cached one, so callers must not mutate it โ every caller // hands it straight to a reader or a parser. func (c *blobCache) get(key string) ([]byte, bool) { if c == nil { return nil, false } c.mu.Lock() defer c.mu.Unlock() el, ok := c.items[key] if !ok { return nil, false } c.ll.MoveToFront(el) return el.Value.(*cacheEntry).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 *blobCache) put(key string, val []byte) { if c == nil || int64(len(val)) > c.max { return } c.mu.Lock() defer c.mu.Unlock() if el, ok := c.items[key]; ok { e := el.Value.(*cacheEntry) c.used -= e.size e.val, e.size = val, int64(len(val)) c.used += e.size c.ll.MoveToFront(el) return } e := &cacheEntry{key: key, val: val, size: int64(len(val))} c.items[key] = c.ll.PushFront(e) c.used += e.size for c.used > c.max { back := c.ll.Back() if back == nil { return } old := c.ll.Remove(back).(*cacheEntry) delete(c.items, old.key) c.used -= old.size } } // stats reports entries and bytes held, for tests and diagnostics. func (c *blobCache) stats() (entries int, bytes int64) { if c == nil { return 0, 0 } c.mu.Lock() defer c.mu.Unlock() return len(c.items), c.used }