package assetcache import ( "compress/gzip" "crypto/rand" "encoding/hex" "errors" "fmt" "io" "os" "path/filepath" "runtime" "sync" ) const ( // MaxBody is the largest identity body the store will hold. MaxBody = 32 << 20 // metaReserve is the disk a reservation sets aside for the .meta file. metaReserve = 64 << 10 // MaxMeta is the largest encoded .meta file Commit writes. MaxMeta = metaReserve // maxFillsPerBox and maxFillsPerApp bound the fills in flight. maxFillsPerBox = 16 maxFillsPerApp = 4 // floorPercent is the share of the filesystem that must stay free. floorPercent = 15 // sampleTries is how many free-space samples admit takes without the // store's lock before it takes one with the lock held. sampleTries = 4 ) var ( // ErrNoRoom means the budget has no room for the fill, even after eviction. ErrNoRoom = errors.New("assetcache: no room in the budget") // ErrBelowFloor means admitting the fill could take the disk below its // free-space floor. ErrBelowFloor = errors.New("assetcache: free space would fall below the floor") // ErrBusy means the box or the app already has as many fills in flight // as it may. ErrBusy = errors.New("assetcache: too many fills in flight") // ErrTooLarge means the body is, or has grown, past what the fill reserved. ErrTooLarge = errors.New("assetcache: body too large") ) // reservation is the disk a fill has set aside: room for the identity body, // for its gzip copy, and for the metadata file. Commit holds each to its own // half. type reservation struct { identity int64 gzip int64 meta int64 } func (r reservation) total() int64 { return r.identity + r.gzip + r.meta } // Fill is one fill in flight. Write the identity body into it. It holds a // reservation and one fill slot until it is committed or aborted. type Fill struct { s *Store key Key scope string id string res reservation mu sync.Mutex tmp *os.File // identity body temporary gz *os.File // gzip copy temporary, while Commit writes it bodyPath string // where the identity body is: its temporary, then its versioned name gzPath string // likewise for the gzip copy token string // names every temporary of this fill written int64 failed error // set by the first failed write done bool // committed or aborted: the fill slot is released held int64 // bytes of the reservation still counted in s.reserved left []leftover // temporaries an Abort could not remove yet crashAt string // tests only: the Commit stage at which to stop as a crash would failAt string // tests only: the Commit stage whose I/O fails with errInjected } // leftover is a temporary still on disk and the bytes it holds. type leftover struct { path string size int64 } // Reserve admits a fill for k, or says why not. contentLength is the length // the origin announced; negative means unknown. It sets aside twice the body // (identity plus gzip copy) and metaReserve, taking the body as MaxBody when // the length is unknown. // // A reservation that eviction cannot help is refused before anything is // evicted for it: one larger than the budget, one that does not fit beside // the other fills' reservations and the retired bytes no eviction frees, and // one the free-space floor refuses. Only a reservation that the budget alone // stands in the way of evicts, and then every condition is checked again on // a fresh free-space sample. Before it refuses for want of room, Reserve // retries the unlinks that failed earlier, whose bytes are still counted. func (s *Store) Reserve(k Key, contentLength int64) (*Fill, error) { if contentLength > MaxBody { return nil, ErrTooLarge } n := contentLength if n < 0 { n = MaxBody } res := reservation{identity: n, gzip: n, meta: metaReserve} scope := k.AppScope() r := res.total() s.mu.Lock() err := s.busyLocked(scope) s.mu.Unlock() if err != nil { return nil, err } retried, evicted := false, false for { err := s.admit(scope, r) if err == nil { break } if !errors.Is(err, ErrNoRoom) && !errors.Is(err, errShort) { return nil, err } if !retried { retried = true if s.retryStuck() { continue } } if errors.Is(err, ErrNoRoom) || evicted { return nil, ErrNoRoom } s.makeRoom(r) evicted = true } f := &Fill{s: s, key: k, scope: scope, id: k.ID(), res: res, held: r} if err := f.open(); err != nil { f.finish() return nil, err } return f, nil } // busyLocked reports ErrBusy when a new fill for scope would pass a bound. func (s *Store) busyLocked(scope string) error { if s.fills >= maxFillsPerBox || s.fillsByApp[scope] >= maxFillsPerApp { return ErrBusy } return nil } // errShort is admit's answer when only the budget stands in the way, and // evicting entries can make the room. var errShort = errors.New("assetcache: no room in the budget until entries are evicted") // admit takes a free-space sample and decides on it whether a reservation of // r bytes for scope may start. If it may, admit claims the reservation and a // fill slot and returns nil; otherwise it returns the refusal, or errShort. // // The sample and the reservation counter must agree. A fill that commits // between the sample and the decision releases its reservation, and the // bytes it wrote after the sample are then in neither, so a decision that // combined the two would count them nowhere. Every release ticks // s.releases, and admit samples again when the count moved; after // sampleTries unlocked samples it samples with s.mu held, where nothing can // release. A reservation claimed meanwhile only raises the counter, and a // fill still in flight writes inside a reservation the counter still holds, // so neither calls for a new sample. func (s *Store) admit(scope string, r int64) error { for try := 1; ; try++ { locked := try > sampleTries s.mu.Lock() releases := s.releases if !locked { s.mu.Unlock() } total, free, err := s.cfg.FS.Space(s.cfg.Dir) if !locked { if s.afterSample != nil { s.afterSample() } s.mu.Lock() } if err != nil { s.mu.Unlock() return fmt.Errorf("assetcache: free space: %w", err) } if s.releases != releases { s.mu.Unlock() continue } err = s.decideLocked(scope, r, total, free) s.mu.Unlock() return err } } // decideLocked applies the admission rules to one free-space sample, total // and free, in an order that keeps a reservation eviction cannot help from // evicting anything. If every rule holds it claims the reservation and a // fill slot. The caller holds s.mu. func (s *Store) decideLocked(scope string, r int64, total, free uint64) error { if err := s.busyLocked(scope); err != nil { return err } // The room eviction can make at most: the budget less the other fills' // reservations and the retired bytes (held by Readers, by unlinks that // failed, or kept for the next Open), none of which eviction frees. if r > s.cfg.Budget || s.retired > s.cfg.Budget-s.reserved-r { return ErrNoRoom } // Free space after every outstanding reservation and this one must stay // at or above the floor. Reservations may already be partly written, so // this counts some bytes twice: it errs towards refusing. The floor is // judged on the disk as sampled: eviction makes room in the budget, so a // fill the floor refuses evicts nothing. if need := uint64(s.reserved) + uint64(r) + total*floorPercent/100; free < need { return ErrBelowFloor } if s.used > s.cfg.Budget-s.reserved-r { // used + reserved + r > Budget, without overflow return errShort } s.reserved += r s.fills++ s.fillsByApp[scope]++ return nil } // open creates the identity temporary. Its name carries ".tmp-", so a crash // leaves something Open removes. func (f *Fill) open() error { var b [8]byte if _, err := rand.Read(b[:]); err != nil { return fmt.Errorf("assetcache: %w", err) } f.token = hex.EncodeToString(b[:]) f.bodyPath = f.tempName("body") f.gzPath = f.tempName("gz") // Read and write: Commit reads the body back to compress it. tmp, err := os.OpenFile(f.bodyPath, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o600) if err != nil { return fmt.Errorf("assetcache: %w", err) } f.tmp = tmp return nil } // tempName is the path of this fill's temporary for part ("body", "gz" or // "meta"). func (f *Fill) tempName(part string) string { return filepath.Join(f.s.cfg.Dir, f.id+".tmp-"+f.token+"."+part) } // Write appends to the identity body. A write that would pass the // reservation's identity half, or MaxBody, writes nothing, returns // ErrTooLarge and fails the fill for good. func (f *Fill) Write(p []byte) (int, error) { f.mu.Lock() defer f.mu.Unlock() if f.done { return 0, os.ErrClosed } if f.failed != nil { return 0, f.failed } if int64(len(p)) > f.res.identity-f.written || int64(len(p)) > MaxBody-f.written { f.failed = ErrTooLarge return 0, f.failed } n, err := f.tmp.Write(p) f.written += int64(n) if err != nil { f.failed = fmt.Errorf("assetcache: %w", err) return n, f.failed } return n, nil } // Abort deletes the fill's temporaries and releases its reservation and fill // slot. It is safe to call twice and after Commit. If a temporary cannot be // removed, the fill slot is still released but the bytes of the files that // remain stay counted as reserved, so the budget and the floor see them; a // later Abort retries the removal, and Open removes them at the next start. func (f *Fill) Abort() { f.mu.Lock() defer f.mu.Unlock() f.finish() } // finish closes the fill and removes its temporaries. The first call // releases the fill slot. Reserved bytes are released once the files they // cover are gone: if a removal fails, the bytes of the files that remain stay // reserved and a later call retries just those. The caller holds f.mu, or has // not shared f. func (f *Fill) finish() { if !f.done { f.done = true f.closeFiles() if f.token != "" { f.left = []leftover{ {path: f.bodyPath, size: f.res.identity}, {path: f.gzPath, size: f.res.gzip}, {path: f.tempName("meta"), size: f.res.meta}, } } f.s.releaseSlot(f.scope) } var remaining int64 kept := f.left[:0] for _, l := range f.left { fi, err := os.Lstat(l.path) if errors.Is(err, os.ErrNotExist) { continue } if err == nil { l.size = fi.Size() // what the file really holds } if err := os.Remove(l.path); err == nil || errors.Is(err, os.ErrNotExist) { continue } kept = append(kept, l) remaining += l.size } f.left = kept f.s.releaseReserved(f.held - remaining) f.held = remaining } // closeFiles closes the fill's open temporaries. The caller holds f.mu. func (f *Fill) closeFiles() { if f.tmp != nil { f.tmp.Close() f.tmp = nil } if f.gz != nil { f.gz.Close() f.gz = nil } } // closeBodies closes the body temporaries before they are published and // reports the first failure, so a body the kernel could not finish writing // is never renamed into place. The caller holds f.mu. func (f *Fill) closeBodies() error { var err error if f.tmp != nil { err = f.tmp.Close() f.tmp = nil } if f.gz != nil { if cerr := f.gz.Close(); err == nil { err = cerr } f.gz = nil } if err != nil { err = fmt.Errorf("assetcache: %w", err) } return f.fault(faultBodyClose, err) } // releaseSlot returns a fill slot. func (s *Store) releaseSlot(scope string) { s.mu.Lock() defer s.mu.Unlock() s.releaseSlotLocked(scope) } // releaseSlotLocked returns a fill slot. The caller holds s.mu. func (s *Store) releaseSlotLocked(scope string) { s.fills-- if s.fillsByApp[scope]--; s.fillsByApp[scope] <= 0 { delete(s.fillsByApp, scope) } } // releaseReserved returns n reserved bytes. func (s *Store) releaseReserved(n int64) { if n == 0 { return } s.mu.Lock() defer s.mu.Unlock() s.releaseReservedLocked(n) } // releaseReservedLocked returns n reserved bytes and ticks s.releases, so an // admission that sampled free space before this release samples again (see // admit). The caller holds s.mu. func (s *Store) releaseReservedLocked(n int64) { s.reserved -= n s.releases++ } // crashBeforeMetaRename is the Commit stage, for tests, after the meta // temporary is written and before it is renamed over .meta. const crashBeforeMetaRename = "before meta rename" // errSimulatedCrash is what Commit returns when it stops at f.crashAt. It // leaves the files as a crash would and cleans up nothing. var errSimulatedCrash = errors.New("assetcache: simulated crash") // Commit stages at which a test can inject an I/O failure through f.failAt. const ( faultBodyClose = "body close" faultMetaDirSync = "meta dir sync" ) // errInjected is the failure a test injects through f.failAt. var errInjected = errors.New("assetcache: injected failure") // fault is err, or errInjected when a test has asked stage to fail. func (f *Fill) fault(stage string, err error) error { if err == nil && f.failAt == stage { return errInjected } return err } // errGzipTooLarge stops the gzip copy once it would pass its limit. var errGzipTooLarge = errors.New("assetcache: gzip copy larger than its limit") // Commit stores what was written as the entry for the fill's key, replacing // any entry it already has. It sets m's Key, Version, Size, GzipSize and // StoredAt itself; the caller supplies Header, Lifetime and Expires. With // gzipCopy it also keeps a gzip copy of the body, unless that would be larger // than the body or the reservation's gzip half. // // The order makes a crash leave either the old entry or the new one, never // new bytes under old metadata: (1) gzip the body into a temporary; // (2) draw a fresh version; (3) fill in the meta; (4) encode it, refusing with // ErrTooLarge when it passes MaxMeta, before anything is renamed; (5) fsync // the bodies and rename them to their versioned names; (6) write, fsync and // rename the meta over .meta; (7) retire the previous version, whose // files are unlinked at once but stay counted while a Reader holds them. The // meta's bytes are then counted with the entry and the reservation released. // // Any error before the meta rename behaves as Abort. If the directory sync // after the meta rename fails, Commit returns that error but the new entry // stays published (see commit). Commit after Commit or Abort returns // os.ErrClosed and changes nothing. func (f *Fill) Commit(m Meta, gzipCopy bool) error { f.mu.Lock() defer f.mu.Unlock() if f.done { return os.ErrClosed } if f.failed != nil { f.finish() return f.failed } err := f.commit(m, gzipCopy) if errors.Is(err, errSimulatedCrash) { return err } if err != nil { f.finish() } return err } // commit does Commit's work. Until the meta rename every error returns with // the fill's files where finish can find and remove them; after it, the fill // is done whatever happens. The caller holds f.mu. func (f *Fill) commit(m Meta, gzipCopy bool) error { s := f.s // (1) The gzip copy, held to the identity size and the gzip half. var gzSize int64 if gzipCopy { n, kept, err := f.writeGzip() if err != nil { return err } if kept { gzSize = n } } // (2) A fresh version, so no file an older meta names is overwritten. var b [8]byte if _, err := rand.Read(b[:]); err != nil { return fmt.Errorf("assetcache: %w", err) } version := hex.EncodeToString(b[:]) // (3) The fields the store owns. m.Key = f.key m.Header = m.Header.Clone() // the store's own: the caller may reuse its map m.Version = version m.Size = f.written m.GzipSize = gzSize m.StoredAt = s.cfg.Now() // (4) The final meta must fit its reservation before anything is renamed. raw, err := encodeMeta(m) if err != nil { return fmt.Errorf("assetcache: %w", err) } if int64(len(raw)) > MaxMeta || int64(len(raw)) > f.res.meta { return ErrTooLarge } // (5) The bodies, durable under their versioned names. Nothing names // them until the meta rename, so a crash here leaves orphans Open removes. if err := f.tmp.Sync(); err != nil { return fmt.Errorf("assetcache: %w", err) } if f.gz != nil { if err := f.gz.Sync(); err != nil { return fmt.Errorf("assetcache: %w", err) } } if err := f.closeBodies(); err != nil { return err } dir := s.cfg.Dir if err := renameTo(&f.bodyPath, filepath.Join(dir, bodyName(f.id, version))); err != nil { return err } if gzSize > 0 { if err := renameTo(&f.gzPath, filepath.Join(dir, gzipName(f.id, version))); err != nil { return err } } if err := syncDir(dir); err != nil { return err } // (6) The meta, under the id lock so that the order of meta renames for // one id is the order its index slot changes in. unlock := s.lockID(f.id) metaTmp := f.tempName("meta") if err := writeSynced(metaTmp, raw); err != nil { unlock() return err } if f.crashAt == crashBeforeMetaRename { unlock() return errSimulatedCrash } if err := os.Rename(metaTmp, filepath.Join(dir, metaName(f.id))); err != nil { unlock() return fmt.Errorf("assetcache: %w", err) } // The new entry is published: its meta and bodies are on disk. If the // directory sync fails, the rename may not be durable and a crash could // bring back the old meta, so the old bodies must stay. The new entry is // still indexed and served (it is what the directory holds), the old // version is kept on disk and counted until the next Open (whose scan // removes whichever bodies no meta names), and Commit returns the error. syncErr := f.fault(faultMetaDirSync, syncDir(dir)) // (7) Swap the index slot, count the new entry and release the // reservation and the fill slot in one step, then retire the old // version. Its bytes stay counted until its files are gone. ne := newEntry(f.id, m, int64(len(raw))) s.mu.Lock() old := s.entries[f.id] if old != nil { s.unindexLocked(old) if syncErr != nil { old.ver.unlinking = false old.ver.kept = true } } s.index(ne) s.releaseReservedLocked(f.held) s.releaseSlotLocked(f.scope) s.mu.Unlock() unlock() f.held = 0 f.left = nil f.done = true if syncErr != nil { return fmt.Errorf("assetcache: committed, but the meta rename may not be durable: %w", syncErr) } if old != nil { s.unlink(old.ver) } s.retryStuck() return nil } // writeGzip compresses the identity body into the gzip temporary, which it // leaves open in f.gz. It reports kept=false, with the temporary removed, // when the copy would be larger than the identity body or the reservation's // gzip half; no write ever passes that limit. The caller holds f.mu. func (f *Fill) writeGzip() (n int64, kept bool, err error) { out, err := os.OpenFile(f.gzPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) if err != nil { return 0, false, fmt.Errorf("assetcache: %w", err) } f.gz = out lw := &limitWriter{w: out, left: min(f.written, f.res.gzip)} zw := gzip.NewWriter(lw) _, err = io.Copy(zw, io.NewSectionReader(f.tmp, 0, f.written)) if err == nil { err = zw.Close() } if errors.Is(err, errGzipTooLarge) { f.gz.Close() f.gz = nil if err := os.Remove(f.gzPath); err != nil && !errors.Is(err, os.ErrNotExist) { return 0, false, fmt.Errorf("assetcache: %w", err) } return 0, false, nil } if err != nil { return 0, false, fmt.Errorf("assetcache: gzip copy: %w", err) } return lw.n, true, nil } // limitWriter passes writes through until one would take the total past // left; that write writes nothing and returns errGzipTooLarge. type limitWriter struct { w io.Writer left int64 n int64 } func (l *limitWriter) Write(p []byte) (int, error) { if int64(len(p)) > l.left { return 0, errGzipTooLarge } n, err := l.w.Write(p) l.left -= int64(n) l.n += int64(n) return n, err } // renameTo renames *from to to and, once it has, records the new name in // *from, so an abort removes the file wherever it is. func renameTo(from *string, to string) error { if err := os.Rename(*from, to); err != nil { return fmt.Errorf("assetcache: %w", err) } *from = to return nil } // writeSynced writes data to a new file at path and fsyncs it. func writeSynced(path string, data []byte) error { out, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) if err != nil { return fmt.Errorf("assetcache: %w", err) } _, err = out.Write(data) if err == nil { err = out.Sync() } if cerr := out.Close(); err == nil { err = cerr } if err != nil { return fmt.Errorf("assetcache: %w", err) } return nil } // syncDir fsyncs the directory, so the renames in it are durable. Windows // cannot sync a directory handle; the store does not run there. func syncDir(dir string) error { if runtime.GOOS == "windows" { return nil } d, err := os.Open(dir) if err != nil { return fmt.Errorf("assetcache: %w", err) } err = d.Sync() if cerr := d.Close(); err == nil { err = cerr } if err != nil { return fmt.Errorf("assetcache: %w", err) } return nil }