// Package assetcache is a bounded disk store for cached asset responses. // // Each entry is a .meta file (JSON) naming its versioned body files, // ..body and optionally ..gz. The index lives in memory and // is rebuilt from the .meta files by Open. package assetcache import ( "container/list" "errors" "fmt" "math" "os" "path/filepath" "sort" "strings" "sync" "time" ) // Config configures a Store. type Config struct { Dir string // the cache directory; created 0700 Budget int64 // bytes of entries (bodies and .meta files) the store may hold FS FS // free-space source; may be called with the store's lock held Now func() time.Time // defaults to time.Now } // Stats is a snapshot of the store's accounting. type Stats struct { Entries int // Bytes is the disk the store holds: every indexed entry's bodies and // .meta file, and the retired bodies counted in Retired. Bytes int64 Reserved int64 // Retired is the bytes of replaced, expired, evicted or swept bodies // that still hold disk: open Readers keep them, an unlink failed and // will be retried, or a replacement could not be made durable and they // are kept for the next Open. Retired int64 Apps int } // entry is one indexed cache entry. type entry struct { id string meta Meta metaSize int64 // bytes of the .meta file ver *version // the body files meta names used uint64 // the store's use counter when indexed or last returned by Get elem *list.Element // its place in its app's LRU list, front most recent; guarded by s.mu } // appIndex is one app's indexed entries, most recently used first. type appIndex struct { lru *list.List // of *entry bytes int64 // the sum of its entries' bytes() } func newEntry(id string, m Meta, metaSize int64) *entry { return &entry{id: id, meta: m, metaSize: metaSize, ver: &version{id: id, v: m.Version, bytes: m.Size + m.GzipSize}} } // bytes is the disk the entry's files hold. func (e *entry) bytes() int64 { return e.metaSize + e.ver.bytes } // version is one committed version's body files, ..body and // ..gz. Its bytes stay counted in Store.used while any of indexed, // leases, unlinking, stuck or kept holds: the disk is not free until the files are // unlinked and the last Reader has closed them. Fields are guarded by s.mu. type version struct { id, v string bytes int64 indexed bool // an indexed entry names this version leases int // open Readers unlinking bool // an unlink is in progress stuck bool // an unlink failed; retryStuck tries again kept bool // left on disk for the next Open: the meta that replaced it may not be durable counted bool // bytes are in s.used } // idLock serializes the changes to one id's .meta file and its index slot. type idLock struct { mu sync.Mutex refs int // guarded by Store.mu } // Store is a bounded disk cache over one directory. type Store struct { cfg Config mu sync.Mutex entries map[string]*entry // by id apps map[string]*appIndex // by AppScope, for apps with entries used int64 // bytes held by entries and retired versions retired int64 // the part of used no indexed entry names reserved int64 // bytes promised to fills in flight releases uint64 // ticks whenever reserved bytes are released; see admit fills int // fills in flight on this box fillsByApp map[string]int // fills in flight by AppScope useCount uint64 // ticks once per index and per Get hit idLocks map[string]*idLock // by id, while held or awaited stuck map[*version]struct{} // retired versions whose unlink failed afterSample func() // tests only: called between admit's unlocked free-space sample and its decision } // Open creates the directory if needed and rebuilds the index from it. // It removes whatever a crash can leave behind: temporaries, body files no // parsed .meta names (orphans and superseded versions), and a .meta that // does not parse or whose files are incomplete, together with its files. // // Recency starts from each entry's StoredAt, the oldest least recently used. // If what it finds is more than cfg.Budget (the budget was lowered since the // files were written), Open evicts by the fair-share rule until it fits. If // files it must delete for that cannot be deleted, Open still succeeds, over // its budget: it serves the entries it holds and refuses every fill with // ErrNoRoom until it is back under, each Reserve retrying the deletions. func Open(cfg Config) (*Store, error) { if cfg.Dir == "" { return nil, errors.New("assetcache: no directory") } if cfg.Budget <= 0 { return nil, errors.New("assetcache: budget must be positive") } if cfg.Now == nil { cfg.Now = time.Now } if cfg.FS == nil { cfg.FS = Statfs } if err := os.MkdirAll(cfg.Dir, 0o700); err != nil { return nil, fmt.Errorf("assetcache: %w", err) } s := &Store{ cfg: cfg, entries: make(map[string]*entry), apps: make(map[string]*appIndex), fillsByApp: make(map[string]int), idLocks: make(map[string]*idLock), stuck: make(map[*version]struct{}), } if err := s.scan(); err != nil { return nil, err } s.makeRoom(0) return s, nil } func (s *Store) scan() error { ents, err := os.ReadDir(s.cfg.Dir) if err != nil { return fmt.Errorf("assetcache: %w", err) } var metas, files []string for _, de := range ents { if de.IsDir() { continue } name := de.Name() switch { case strings.Contains(name, ".tmp-"): if err := s.remove(name); err != nil { return err } case strings.HasSuffix(name, ".meta"): metas = append(metas, name) case strings.HasSuffix(name, ".body"), strings.HasSuffix(name, ".gz"): files = append(files, name) } } keep := make(map[string]bool) // body file names a parsed meta names var found []*entry var sum int64 // the bytes of found for _, name := range metas { id := strings.TrimSuffix(name, ".meta") m, metaSize, valid, err := s.loadMeta(id) if err != nil { return err } if !valid { if err := s.remove(name); err != nil { return err } continue } keep[bodyName(id, m.Version)] = true if m.GzipSize > 0 { keep[gzipName(id, m.Version)] = true } e := newEntry(id, m, metaSize) if e.ver.bytes > math.MaxInt64-metaSize || sum > math.MaxInt64-e.bytes() { return errors.New("assetcache: indexed bytes overflow") } sum += e.bytes() found = append(found, e) } // Index the oldest stored first, so it starts as the least recently used. sort.Slice(found, func(i, j int) bool { a, b := found[i], found[j] if !a.meta.StoredAt.Equal(b.meta.StoredAt) { return a.meta.StoredAt.Before(b.meta.StoredAt) } return a.id < b.id }) for _, e := range found { s.index(e) } for _, name := range files { if !keep[name] { if err := s.remove(name); err != nil { return err } } } return nil } // loadMeta reads and checks .meta. It reports valid=false for an entry // that is certainly broken: a missing or malformed meta, one that belongs to // another key, or one whose body files are missing or not the size it // records. Any other failure to read the disk is returned as an error, so a // permission or I/O fault never leads to deleting a good entry. metaSize is // the .meta file's length. func (s *Store) loadMeta(id string) (m Meta, metaSize int64, valid bool, err error) { raw, err := os.ReadFile(filepath.Join(s.cfg.Dir, metaName(id))) if errors.Is(err, os.ErrNotExist) { return Meta{}, 0, false, nil } if err != nil { return Meta{}, 0, false, fmt.Errorf("assetcache: %w", err) } m, err = decodeMeta(raw) if err != nil || m.Key.ID() != id { return Meta{}, 0, false, nil } ok, err := s.bodyMatches(bodyName(id, m.Version), m.Size) if err != nil || !ok { return Meta{}, 0, false, err } if m.GzipSize > 0 { ok, err = s.bodyMatches(gzipName(id, m.Version), m.GzipSize) if err != nil || !ok { return Meta{}, 0, false, err } } return m, int64(len(raw)), true, nil } // bodyMatches reports whether the named file is a regular file of exactly // size bytes. A missing file is false with no error. func (s *Store) bodyMatches(name string, size int64) (bool, error) { fi, err := os.Stat(filepath.Join(s.cfg.Dir, name)) if errors.Is(err, os.ErrNotExist) { return false, nil } if err != nil { return false, fmt.Errorf("assetcache: %w", err) } return fi.Mode().IsRegular() && fi.Size() == size, nil } // remove deletes one file of the store's directory by name. func (s *Store) remove(name string) error { if err := os.Remove(filepath.Join(s.cfg.Dir, name)); err != nil && !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("assetcache: %w", err) } return nil } // index adds e, whose id has no indexed entry, to the in-memory index and // counts its files. The caller holds s.mu or has not yet shared s. func (s *Store) index(e *entry) { s.entries[e.id] = e scope := e.meta.Key.AppScope() app := s.apps[scope] if app == nil { app = &appIndex{lru: list.New()} s.apps[scope] = app } s.useCount++ e.used = s.useCount e.elem = app.lru.PushFront(e) app.bytes += e.bytes() e.ver.indexed = true e.ver.counted = true s.used += e.bytes() } // unindexLocked takes e out of the index and retires its version: the .meta // bytes stop counting (the caller has removed or replaced the file), the body // bytes stay counted until unlink has removed them and the last lease has // closed. The caller holds s.mu and, once it has released s.mu, must call // s.unlink(e.ver). func (s *Store) unindexLocked(e *entry) { delete(s.entries, e.id) scope := e.meta.Key.AppScope() if app := s.apps[scope]; app != nil { app.lru.Remove(e.elem) app.bytes -= e.bytes() if app.lru.Len() == 0 { delete(s.apps, scope) } } e.elem = nil s.used -= e.metaSize v := e.ver v.indexed = false v.unlinking = true s.retired += v.bytes } // unlink removes a retired version's body files at once, whether or not a // Reader still holds them (an unlinked open file stays readable), then // settles its accounting. A failed removal leaves the version stuck: its bytes // stay counted and retryStuck tries again. It is called without s.mu held. func (s *Store) unlink(v *version) { err := errors.Join(s.remove(bodyName(v.id, v.v)), s.remove(gzipName(v.id, v.v))) s.mu.Lock() defer s.mu.Unlock() v.unlinking = false if err != nil { v.stuck = true s.stuck[v] = struct{}{} } else if v.stuck { v.stuck = false delete(s.stuck, v) } s.settleLocked(v) } // retryStuck retries the removal of versions whose unlink failed, and // reports whether there were any to retry. It is called without s.mu held. func (s *Store) retryStuck() bool { s.mu.Lock() var vs []*version for v := range s.stuck { if !v.unlinking { v.unlinking = true vs = append(vs, v) } } s.mu.Unlock() for _, v := range vs { s.unlink(v) } return len(vs) > 0 } // settleLocked stops counting v's bytes once nothing holds them. The caller // holds s.mu. func (s *Store) settleLocked(v *version) { if v.counted && !v.indexed && v.leases == 0 && !v.unlinking && !v.stuck && !v.kept { v.counted = false s.used -= v.bytes s.retired -= v.bytes } } // lockID takes the lock for one id's .meta file and index slot, and returns // the function that releases it. Lock order: a Fill's mu, then an id lock, // then s.mu. It is called without s.mu held. func (s *Store) lockID(id string) (unlock func()) { s.mu.Lock() l := s.idLocks[id] if l == nil { l = &idLock{} s.idLocks[id] = l } l.refs++ s.mu.Unlock() l.mu.Lock() return func() { l.mu.Unlock() s.mu.Lock() if l.refs--; l.refs == 0 { delete(s.idLocks, id) } s.mu.Unlock() } } // drop deletes the indexed entry e: its .meta first, so a crash part way // leaves only orphan bodies that Open removes, then its bodies, subject to // leases. It deletes nothing and reports false when e is no longer the // indexed entry for its id, or when its .meta cannot be removed (the entry // then stays indexed and counted). Get's expiry, eviction and Retain use it, and // the sweep is to. It is called without s.mu held. func (s *Store) drop(e *entry) bool { unlock := s.lockID(e.id) s.mu.Lock() current := s.entries[e.id] == e s.mu.Unlock() if !current { unlock() return false } if err := s.remove(metaName(e.id)); err != nil { unlock() return false } s.mu.Lock() s.unindexLocked(e) s.mu.Unlock() unlock() s.unlink(e.ver) s.retryStuck() return true } // Entry is a cache hit: the metadata as stored, and the means to open its // bodies. Its Header is the caller's own copy. type Entry struct { Meta Meta s *Store ver *version } // Get returns the entry for k and marks it recently used. An entry whose // expiry has come (Now at or past Meta.Expires) is not returned: it is // deleted. func (s *Store) Get(k Key) (*Entry, bool) { id := k.ID() now := s.cfg.Now() s.mu.Lock() e := s.entries[id] if e == nil || e.meta.Key != k { s.mu.Unlock() return nil, false } if !now.Before(e.meta.Expires) { s.mu.Unlock() s.drop(e) return nil, false } s.useCount++ e.used = s.useCount s.apps[e.meta.Key.AppScope()].lru.MoveToFront(e.elem) out := &Entry{Meta: e.meta, s: s, ver: e.ver} out.Meta.Header = e.meta.Header.Clone() s.mu.Unlock() return out, true } // Open opens the identity body, or the gzip copy when gzip is true. The // Reader holds a lease on the entry's version: if the entry is replaced, // evicted or expired while it is open, its files are unlinked at once but // their bytes stay counted until the Reader closes. Open fails once the // version has been retired, and for gzip when the entry has no gzip copy. func (e *Entry) Open(gzip bool) (*Reader, error) { if e.s == nil { return nil, errors.New("assetcache: entry not from a store") } name := bodyName(e.ver.id, e.ver.v) if gzip { if e.Meta.GzipSize == 0 { return nil, fmt.Errorf("assetcache: no gzip copy: %w", os.ErrNotExist) } name = gzipName(e.ver.id, e.ver.v) } s := e.s s.mu.Lock() if !e.ver.indexed { s.mu.Unlock() return nil, fmt.Errorf("assetcache: entry replaced or deleted: %w", os.ErrNotExist) } e.ver.leases++ // before the open, so a retirement from here on keeps the bytes counted s.mu.Unlock() f, err := os.Open(filepath.Join(s.cfg.Dir, name)) if err != nil { s.release(e.ver) return nil, fmt.Errorf("assetcache: %w", err) } return &Reader{f: f, s: s, ver: e.ver}, nil } // release returns one lease on v. func (s *Store) release(v *version) { s.mu.Lock() defer s.mu.Unlock() v.leases-- s.settleLocked(v) } // Reader reads one body file of an entry. It is an io.ReadSeeker, as // http.ServeContent needs, and an io.ReaderAt. Close releases its lease. type Reader struct { f *os.File s *Store ver *version once sync.Once } func (r *Reader) Read(p []byte) (int, error) { return r.f.Read(p) } func (r *Reader) ReadAt(p []byte, off int64) (int, error) { return r.f.ReadAt(p, off) } func (r *Reader) Seek(offset int64, whence int) (int64, error) { return r.f.Seek(offset, whence) } func (r *Reader) Stat() (os.FileInfo, error) { return r.f.Stat() } // Close closes the file and then releases the lease, exactly once however // often it is called. func (r *Reader) Close() error { err := r.f.Close() r.once.Do(func() { r.s.release(r.ver) }) return err } // Stats reports the store's current accounting. func (s *Store) Stats() Stats { s.mu.Lock() defer s.mu.Unlock() return Stats{ Entries: len(s.entries), Bytes: s.used, Reserved: s.reserved, Retired: s.retired, Apps: len(s.apps), } }