carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package assetcache
2
3import (
4 "compress/gzip"
5 "crypto/rand"
6 "encoding/hex"
7 "errors"
8 "fmt"
9 "io"
10 "os"
11 "path/filepath"
12 "runtime"
13 "sync"
14)
15
16const (
17 // MaxBody is the largest identity body the store will hold.
18 MaxBody = 32 << 20
19
20 // metaReserve is the disk a reservation sets aside for the .meta file.
21 metaReserve = 64 << 10
22
23 // MaxMeta is the largest encoded .meta file Commit writes.
24 MaxMeta = metaReserve
25
26 // maxFillsPerBox and maxFillsPerApp bound the fills in flight.
27 maxFillsPerBox = 16
28 maxFillsPerApp = 4
29
30 // floorPercent is the share of the filesystem that must stay free.
31 floorPercent = 15
32
33 // sampleTries is how many free-space samples admit takes without the
34 // store's lock before it takes one with the lock held.
35 sampleTries = 4
36)
37
38var (
39 // ErrNoRoom means the budget has no room for the fill, even after eviction.
40 ErrNoRoom = errors.New("assetcache: no room in the budget")
41 // ErrBelowFloor means admitting the fill could take the disk below its
42 // free-space floor.
43 ErrBelowFloor = errors.New("assetcache: free space would fall below the floor")
44 // ErrBusy means the box or the app already has as many fills in flight
45 // as it may.
46 ErrBusy = errors.New("assetcache: too many fills in flight")
47 // ErrTooLarge means the body is, or has grown, past what the fill reserved.
48 ErrTooLarge = errors.New("assetcache: body too large")
49)
50
51// reservation is the disk a fill has set aside: room for the identity body,
52// for its gzip copy, and for the metadata file. Commit holds each to its own
53// half.
54type reservation struct {
55 identity int64
56 gzip int64
57 meta int64
58}
59
60func (r reservation) total() int64 { return r.identity + r.gzip + r.meta }
61
62// Fill is one fill in flight. Write the identity body into it. It holds a
63// reservation and one fill slot until it is committed or aborted.
64type Fill struct {
65 s *Store
66 key Key
67 scope string
68 id string
69 res reservation
70
71 mu sync.Mutex
72 tmp *os.File // identity body temporary
73 gz *os.File // gzip copy temporary, while Commit writes it
74 bodyPath string // where the identity body is: its temporary, then its versioned name
75 gzPath string // likewise for the gzip copy
76 token string // names every temporary of this fill
77 written int64
78 failed error // set by the first failed write
79 done bool // committed or aborted: the fill slot is released
80 held int64 // bytes of the reservation still counted in s.reserved
81 left []leftover // temporaries an Abort could not remove yet
82
83 crashAt string // tests only: the Commit stage at which to stop as a crash would
84 failAt string // tests only: the Commit stage whose I/O fails with errInjected
85}
86
87// leftover is a temporary still on disk and the bytes it holds.
88type leftover struct {
89 path string
90 size int64
91}
92
93// Reserve admits a fill for k, or says why not. contentLength is the length
94// the origin announced; negative means unknown. It sets aside twice the body
95// (identity plus gzip copy) and metaReserve, taking the body as MaxBody when
96// the length is unknown.
97//
98// A reservation that eviction cannot help is refused before anything is
99// evicted for it: one larger than the budget, one that does not fit beside
100// the other fills' reservations and the retired bytes no eviction frees, and
101// one the free-space floor refuses. Only a reservation that the budget alone
102// stands in the way of evicts, and then every condition is checked again on
103// a fresh free-space sample. Before it refuses for want of room, Reserve
104// retries the unlinks that failed earlier, whose bytes are still counted.
105func (s *Store) Reserve(k Key, contentLength int64) (*Fill, error) {
106 if contentLength > MaxBody {
107 return nil, ErrTooLarge
108 }
109 n := contentLength
110 if n < 0 {
111 n = MaxBody
112 }
113 res := reservation{identity: n, gzip: n, meta: metaReserve}
114 scope := k.AppScope()
115 r := res.total()
116
117 s.mu.Lock()
118 err := s.busyLocked(scope)
119 s.mu.Unlock()
120 if err != nil {
121 return nil, err
122 }
123
124 retried, evicted := false, false
125 for {
126 err := s.admit(scope, r)
127 if err == nil {
128 break
129 }
130 if !errors.Is(err, ErrNoRoom) && !errors.Is(err, errShort) {
131 return nil, err
132 }
133 if !retried {
134 retried = true
135 if s.retryStuck() {
136 continue
137 }
138 }
139 if errors.Is(err, ErrNoRoom) || evicted {
140 return nil, ErrNoRoom
141 }
142 s.makeRoom(r)
143 evicted = true
144 }
145 f := &Fill{s: s, key: k, scope: scope, id: k.ID(), res: res, held: r}
146 if err := f.open(); err != nil {
147 f.finish()
148 return nil, err
149 }
150 return f, nil
151}
152
153// busyLocked reports ErrBusy when a new fill for scope would pass a bound.
154func (s *Store) busyLocked(scope string) error {
155 if s.fills >= maxFillsPerBox || s.fillsByApp[scope] >= maxFillsPerApp {
156 return ErrBusy
157 }
158 return nil
159}
160
161// errShort is admit's answer when only the budget stands in the way, and
162// evicting entries can make the room.
163var errShort = errors.New("assetcache: no room in the budget until entries are evicted")
164
165// admit takes a free-space sample and decides on it whether a reservation of
166// r bytes for scope may start. If it may, admit claims the reservation and a
167// fill slot and returns nil; otherwise it returns the refusal, or errShort.
168//
169// The sample and the reservation counter must agree. A fill that commits
170// between the sample and the decision releases its reservation, and the
171// bytes it wrote after the sample are then in neither, so a decision that
172// combined the two would count them nowhere. Every release ticks
173// s.releases, and admit samples again when the count moved; after
174// sampleTries unlocked samples it samples with s.mu held, where nothing can
175// release. A reservation claimed meanwhile only raises the counter, and a
176// fill still in flight writes inside a reservation the counter still holds,
177// so neither calls for a new sample.
178func (s *Store) admit(scope string, r int64) error {
179 for try := 1; ; try++ {
180 locked := try > sampleTries
181 s.mu.Lock()
182 releases := s.releases
183 if !locked {
184 s.mu.Unlock()
185 }
186 total, free, err := s.cfg.FS.Space(s.cfg.Dir)
187 if !locked {
188 if s.afterSample != nil {
189 s.afterSample()
190 }
191 s.mu.Lock()
192 }
193 if err != nil {
194 s.mu.Unlock()
195 return fmt.Errorf("assetcache: free space: %w", err)
196 }
197 if s.releases != releases {
198 s.mu.Unlock()
199 continue
200 }
201 err = s.decideLocked(scope, r, total, free)
202 s.mu.Unlock()
203 return err
204 }
205}
206
207// decideLocked applies the admission rules to one free-space sample, total
208// and free, in an order that keeps a reservation eviction cannot help from
209// evicting anything. If every rule holds it claims the reservation and a
210// fill slot. The caller holds s.mu.
211func (s *Store) decideLocked(scope string, r int64, total, free uint64) error {
212 if err := s.busyLocked(scope); err != nil {
213 return err
214 }
215 // The room eviction can make at most: the budget less the other fills'
216 // reservations and the retired bytes (held by Readers, by unlinks that
217 // failed, or kept for the next Open), none of which eviction frees.
218 if r > s.cfg.Budget || s.retired > s.cfg.Budget-s.reserved-r {
219 return ErrNoRoom
220 }
221 // Free space after every outstanding reservation and this one must stay
222 // at or above the floor. Reservations may already be partly written, so
223 // this counts some bytes twice: it errs towards refusing. The floor is
224 // judged on the disk as sampled: eviction makes room in the budget, so a
225 // fill the floor refuses evicts nothing.
226 if need := uint64(s.reserved) + uint64(r) + total*floorPercent/100; free < need {
227 return ErrBelowFloor
228 }
229 if s.used > s.cfg.Budget-s.reserved-r { // used + reserved + r > Budget, without overflow
230 return errShort
231 }
232 s.reserved += r
233 s.fills++
234 s.fillsByApp[scope]++
235 return nil
236}
237
238// open creates the identity temporary. Its name carries ".tmp-", so a crash
239// leaves something Open removes.
240func (f *Fill) open() error {
241 var b [8]byte
242 if _, err := rand.Read(b[:]); err != nil {
243 return fmt.Errorf("assetcache: %w", err)
244 }
245 f.token = hex.EncodeToString(b[:])
246 f.bodyPath = f.tempName("body")
247 f.gzPath = f.tempName("gz")
248 // Read and write: Commit reads the body back to compress it.
249 tmp, err := os.OpenFile(f.bodyPath, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o600)
250 if err != nil {
251 return fmt.Errorf("assetcache: %w", err)
252 }
253 f.tmp = tmp
254 return nil
255}
256
257// tempName is the path of this fill's temporary for part ("body", "gz" or
258// "meta").
259func (f *Fill) tempName(part string) string {
260 return filepath.Join(f.s.cfg.Dir, f.id+".tmp-"+f.token+"."+part)
261}
262
263// Write appends to the identity body. A write that would pass the
264// reservation's identity half, or MaxBody, writes nothing, returns
265// ErrTooLarge and fails the fill for good.
266func (f *Fill) Write(p []byte) (int, error) {
267 f.mu.Lock()
268 defer f.mu.Unlock()
269 if f.done {
270 return 0, os.ErrClosed
271 }
272 if f.failed != nil {
273 return 0, f.failed
274 }
275 if int64(len(p)) > f.res.identity-f.written || int64(len(p)) > MaxBody-f.written {
276 f.failed = ErrTooLarge
277 return 0, f.failed
278 }
279 n, err := f.tmp.Write(p)
280 f.written += int64(n)
281 if err != nil {
282 f.failed = fmt.Errorf("assetcache: %w", err)
283 return n, f.failed
284 }
285 return n, nil
286}
287
288// Abort deletes the fill's temporaries and releases its reservation and fill
289// slot. It is safe to call twice and after Commit. If a temporary cannot be
290// removed, the fill slot is still released but the bytes of the files that
291// remain stay counted as reserved, so the budget and the floor see them; a
292// later Abort retries the removal, and Open removes them at the next start.
293func (f *Fill) Abort() {
294 f.mu.Lock()
295 defer f.mu.Unlock()
296 f.finish()
297}
298
299// finish closes the fill and removes its temporaries. The first call
300// releases the fill slot. Reserved bytes are released once the files they
301// cover are gone: if a removal fails, the bytes of the files that remain stay
302// reserved and a later call retries just those. The caller holds f.mu, or has
303// not shared f.
304func (f *Fill) finish() {
305 if !f.done {
306 f.done = true
307 f.closeFiles()
308 if f.token != "" {
309 f.left = []leftover{
310 {path: f.bodyPath, size: f.res.identity},
311 {path: f.gzPath, size: f.res.gzip},
312 {path: f.tempName("meta"), size: f.res.meta},
313 }
314 }
315 f.s.releaseSlot(f.scope)
316 }
317 var remaining int64
318 kept := f.left[:0]
319 for _, l := range f.left {
320 fi, err := os.Lstat(l.path)
321 if errors.Is(err, os.ErrNotExist) {
322 continue
323 }
324 if err == nil {
325 l.size = fi.Size() // what the file really holds
326 }
327 if err := os.Remove(l.path); err == nil || errors.Is(err, os.ErrNotExist) {
328 continue
329 }
330 kept = append(kept, l)
331 remaining += l.size
332 }
333 f.left = kept
334 f.s.releaseReserved(f.held - remaining)
335 f.held = remaining
336}
337
338// closeFiles closes the fill's open temporaries. The caller holds f.mu.
339func (f *Fill) closeFiles() {
340 if f.tmp != nil {
341 f.tmp.Close()
342 f.tmp = nil
343 }
344 if f.gz != nil {
345 f.gz.Close()
346 f.gz = nil
347 }
348}
349
350// closeBodies closes the body temporaries before they are published and
351// reports the first failure, so a body the kernel could not finish writing
352// is never renamed into place. The caller holds f.mu.
353func (f *Fill) closeBodies() error {
354 var err error
355 if f.tmp != nil {
356 err = f.tmp.Close()
357 f.tmp = nil
358 }
359 if f.gz != nil {
360 if cerr := f.gz.Close(); err == nil {
361 err = cerr
362 }
363 f.gz = nil
364 }
365 if err != nil {
366 err = fmt.Errorf("assetcache: %w", err)
367 }
368 return f.fault(faultBodyClose, err)
369}
370
371// releaseSlot returns a fill slot.
372func (s *Store) releaseSlot(scope string) {
373 s.mu.Lock()
374 defer s.mu.Unlock()
375 s.releaseSlotLocked(scope)
376}
377
378// releaseSlotLocked returns a fill slot. The caller holds s.mu.
379func (s *Store) releaseSlotLocked(scope string) {
380 s.fills--
381 if s.fillsByApp[scope]--; s.fillsByApp[scope] <= 0 {
382 delete(s.fillsByApp, scope)
383 }
384}
385
386// releaseReserved returns n reserved bytes.
387func (s *Store) releaseReserved(n int64) {
388 if n == 0 {
389 return
390 }
391 s.mu.Lock()
392 defer s.mu.Unlock()
393 s.releaseReservedLocked(n)
394}
395
396// releaseReservedLocked returns n reserved bytes and ticks s.releases, so an
397// admission that sampled free space before this release samples again (see
398// admit). The caller holds s.mu.
399func (s *Store) releaseReservedLocked(n int64) {
400 s.reserved -= n
401 s.releases++
402}
403
404// crashBeforeMetaRename is the Commit stage, for tests, after the meta
405// temporary is written and before it is renamed over <id>.meta.
406const crashBeforeMetaRename = "before meta rename"
407
408// errSimulatedCrash is what Commit returns when it stops at f.crashAt. It
409// leaves the files as a crash would and cleans up nothing.
410var errSimulatedCrash = errors.New("assetcache: simulated crash")
411
412// Commit stages at which a test can inject an I/O failure through f.failAt.
413const (
414 faultBodyClose = "body close"
415 faultMetaDirSync = "meta dir sync"
416)
417
418// errInjected is the failure a test injects through f.failAt.
419var errInjected = errors.New("assetcache: injected failure")
420
421// fault is err, or errInjected when a test has asked stage to fail.
422func (f *Fill) fault(stage string, err error) error {
423 if err == nil && f.failAt == stage {
424 return errInjected
425 }
426 return err
427}
428
429// errGzipTooLarge stops the gzip copy once it would pass its limit.
430var errGzipTooLarge = errors.New("assetcache: gzip copy larger than its limit")
431
432// Commit stores what was written as the entry for the fill's key, replacing
433// any entry it already has. It sets m's Key, Version, Size, GzipSize and
434// StoredAt itself; the caller supplies Header, Lifetime and Expires. With
435// gzipCopy it also keeps a gzip copy of the body, unless that would be larger
436// than the body or the reservation's gzip half.
437//
438// The order makes a crash leave either the old entry or the new one, never
439// new bytes under old metadata: (1) gzip the body into a temporary;
440// (2) draw a fresh version; (3) fill in the meta; (4) encode it, refusing with
441// ErrTooLarge when it passes MaxMeta, before anything is renamed; (5) fsync
442// the bodies and rename them to their versioned names; (6) write, fsync and
443// rename the meta over <id>.meta; (7) retire the previous version, whose
444// files are unlinked at once but stay counted while a Reader holds them. The
445// meta's bytes are then counted with the entry and the reservation released.
446//
447// Any error before the meta rename behaves as Abort. If the directory sync
448// after the meta rename fails, Commit returns that error but the new entry
449// stays published (see commit). Commit after Commit or Abort returns
450// os.ErrClosed and changes nothing.
451func (f *Fill) Commit(m Meta, gzipCopy bool) error {
452 f.mu.Lock()
453 defer f.mu.Unlock()
454 if f.done {
455 return os.ErrClosed
456 }
457 if f.failed != nil {
458 f.finish()
459 return f.failed
460 }
461 err := f.commit(m, gzipCopy)
462 if errors.Is(err, errSimulatedCrash) {
463 return err
464 }
465 if err != nil {
466 f.finish()
467 }
468 return err
469}
470
471// commit does Commit's work. Until the meta rename every error returns with
472// the fill's files where finish can find and remove them; after it, the fill
473// is done whatever happens. The caller holds f.mu.
474func (f *Fill) commit(m Meta, gzipCopy bool) error {
475 s := f.s
476
477 // (1) The gzip copy, held to the identity size and the gzip half.
478 var gzSize int64
479 if gzipCopy {
480 n, kept, err := f.writeGzip()
481 if err != nil {
482 return err
483 }
484 if kept {
485 gzSize = n
486 }
487 }
488
489 // (2) A fresh version, so no file an older meta names is overwritten.
490 var b [8]byte
491 if _, err := rand.Read(b[:]); err != nil {
492 return fmt.Errorf("assetcache: %w", err)
493 }
494 version := hex.EncodeToString(b[:])
495
496 // (3) The fields the store owns.
497 m.Key = f.key
498 m.Header = m.Header.Clone() // the store's own: the caller may reuse its map
499 m.Version = version
500 m.Size = f.written
501 m.GzipSize = gzSize
502 m.StoredAt = s.cfg.Now()
503
504 // (4) The final meta must fit its reservation before anything is renamed.
505 raw, err := encodeMeta(m)
506 if err != nil {
507 return fmt.Errorf("assetcache: %w", err)
508 }
509 if int64(len(raw)) > MaxMeta || int64(len(raw)) > f.res.meta {
510 return ErrTooLarge
511 }
512
513 // (5) The bodies, durable under their versioned names. Nothing names
514 // them until the meta rename, so a crash here leaves orphans Open removes.
515 if err := f.tmp.Sync(); err != nil {
516 return fmt.Errorf("assetcache: %w", err)
517 }
518 if f.gz != nil {
519 if err := f.gz.Sync(); err != nil {
520 return fmt.Errorf("assetcache: %w", err)
521 }
522 }
523 if err := f.closeBodies(); err != nil {
524 return err
525 }
526 dir := s.cfg.Dir
527 if err := renameTo(&f.bodyPath, filepath.Join(dir, bodyName(f.id, version))); err != nil {
528 return err
529 }
530 if gzSize > 0 {
531 if err := renameTo(&f.gzPath, filepath.Join(dir, gzipName(f.id, version))); err != nil {
532 return err
533 }
534 }
535 if err := syncDir(dir); err != nil {
536 return err
537 }
538
539 // (6) The meta, under the id lock so that the order of meta renames for
540 // one id is the order its index slot changes in.
541 unlock := s.lockID(f.id)
542 metaTmp := f.tempName("meta")
543 if err := writeSynced(metaTmp, raw); err != nil {
544 unlock()
545 return err
546 }
547 if f.crashAt == crashBeforeMetaRename {
548 unlock()
549 return errSimulatedCrash
550 }
551 if err := os.Rename(metaTmp, filepath.Join(dir, metaName(f.id))); err != nil {
552 unlock()
553 return fmt.Errorf("assetcache: %w", err)
554 }
555 // The new entry is published: its meta and bodies are on disk. If the
556 // directory sync fails, the rename may not be durable and a crash could
557 // bring back the old meta, so the old bodies must stay. The new entry is
558 // still indexed and served (it is what the directory holds), the old
559 // version is kept on disk and counted until the next Open (whose scan
560 // removes whichever bodies no meta names), and Commit returns the error.
561 syncErr := f.fault(faultMetaDirSync, syncDir(dir))
562
563 // (7) Swap the index slot, count the new entry and release the
564 // reservation and the fill slot in one step, then retire the old
565 // version. Its bytes stay counted until its files are gone.
566 ne := newEntry(f.id, m, int64(len(raw)))
567 s.mu.Lock()
568 old := s.entries[f.id]
569 if old != nil {
570 s.unindexLocked(old)
571 if syncErr != nil {
572 old.ver.unlinking = false
573 old.ver.kept = true
574 }
575 }
576 s.index(ne)
577 s.releaseReservedLocked(f.held)
578 s.releaseSlotLocked(f.scope)
579 s.mu.Unlock()
580 unlock()
581 f.held = 0
582 f.left = nil
583 f.done = true
584
585 if syncErr != nil {
586 return fmt.Errorf("assetcache: committed, but the meta rename may not be durable: %w", syncErr)
587 }
588 if old != nil {
589 s.unlink(old.ver)
590 }
591 s.retryStuck()
592 return nil
593}
594
595// writeGzip compresses the identity body into the gzip temporary, which it
596// leaves open in f.gz. It reports kept=false, with the temporary removed,
597// when the copy would be larger than the identity body or the reservation's
598// gzip half; no write ever passes that limit. The caller holds f.mu.
599func (f *Fill) writeGzip() (n int64, kept bool, err error) {
600 out, err := os.OpenFile(f.gzPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
601 if err != nil {
602 return 0, false, fmt.Errorf("assetcache: %w", err)
603 }
604 f.gz = out
605 lw := &limitWriter{w: out, left: min(f.written, f.res.gzip)}
606 zw := gzip.NewWriter(lw)
607 _, err = io.Copy(zw, io.NewSectionReader(f.tmp, 0, f.written))
608 if err == nil {
609 err = zw.Close()
610 }
611 if errors.Is(err, errGzipTooLarge) {
612 f.gz.Close()
613 f.gz = nil
614 if err := os.Remove(f.gzPath); err != nil && !errors.Is(err, os.ErrNotExist) {
615 return 0, false, fmt.Errorf("assetcache: %w", err)
616 }
617 return 0, false, nil
618 }
619 if err != nil {
620 return 0, false, fmt.Errorf("assetcache: gzip copy: %w", err)
621 }
622 return lw.n, true, nil
623}
624
625// limitWriter passes writes through until one would take the total past
626// left; that write writes nothing and returns errGzipTooLarge.
627type limitWriter struct {
628 w io.Writer
629 left int64
630 n int64
631}
632
633func (l *limitWriter) Write(p []byte) (int, error) {
634 if int64(len(p)) > l.left {
635 return 0, errGzipTooLarge
636 }
637 n, err := l.w.Write(p)
638 l.left -= int64(n)
639 l.n += int64(n)
640 return n, err
641}
642
643// renameTo renames *from to to and, once it has, records the new name in
644// *from, so an abort removes the file wherever it is.
645func renameTo(from *string, to string) error {
646 if err := os.Rename(*from, to); err != nil {
647 return fmt.Errorf("assetcache: %w", err)
648 }
649 *from = to
650 return nil
651}
652
653// writeSynced writes data to a new file at path and fsyncs it.
654func writeSynced(path string, data []byte) error {
655 out, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
656 if err != nil {
657 return fmt.Errorf("assetcache: %w", err)
658 }
659 _, err = out.Write(data)
660 if err == nil {
661 err = out.Sync()
662 }
663 if cerr := out.Close(); err == nil {
664 err = cerr
665 }
666 if err != nil {
667 return fmt.Errorf("assetcache: %w", err)
668 }
669 return nil
670}
671
672// syncDir fsyncs the directory, so the renames in it are durable. Windows
673// cannot sync a directory handle; the store does not run there.
674func syncDir(dir string) error {
675 if runtime.GOOS == "windows" {
676 return nil
677 }
678 d, err := os.Open(dir)
679 if err != nil {
680 return fmt.Errorf("assetcache: %w", err)
681 }
682 err = d.Sync()
683 if cerr := d.Close(); err == nil {
684 err = cerr
685 }
686 if err != nil {
687 return fmt.Errorf("assetcache: %w", err)
688 }
689 return nil
690}
691