package assetcache import ( "bytes" "compress/gzip" "crypto/rand" "errors" "fmt" "io" "net/http" "os" "path/filepath" "strings" "sync" "testing" "time" ) // clock is a settable Now for tests. type clock struct { mu sync.Mutex now time.Time } func (c *clock) Now() time.Time { c.mu.Lock() defer c.mu.Unlock() return c.now } func (c *clock) set(t time.Time) { c.mu.Lock() c.now = t c.mu.Unlock() } // entryStore opens a Store over a real temporary directory with a fake FS // that has plenty of room, the given budget and a settable clock. func entryStore(t *testing.T, budget int64) (*Store, *clock, string) { t.Helper() dir := t.TempDir() c := &clock{now: testNow} s, err := Open(Config{Dir: dir, Budget: budget, FS: &fakeFS{total: 64 << 30, free: 64 << 30}, Now: c.Now}) if err != nil { t.Fatal(err) } return s, c, dir } // reopen opens a second Store over dir, as a restarted process would. func reopen(t *testing.T, dir string) *Store { t.Helper() s, err := Open(Config{Dir: dir, Budget: 4 << 30, FS: &fakeFS{total: 64 << 30, free: 64 << 30}, Now: func() time.Time { return testNow }}) if err != nil { t.Fatalf("Open: %v", err) } return s } func cssHeader(v string) http.Header { return http.Header{"Content-Type": {"text/css"}, "X-Version": {v}} } // fill reserves for k, writes body and returns the open fill. func fill(t *testing.T, s *Store, k Key, body []byte) *Fill { t.Helper() f, err := s.Reserve(k, int64(len(body))) if err != nil { t.Fatalf("Reserve: %v", err) } if _, err := f.Write(body); err != nil { t.Fatalf("Write: %v", err) } return f } // commit stores body under k with header h, valid for an hour from testNow. func commit(t *testing.T, s *Store, k Key, body []byte, h http.Header, gz bool) { t.Helper() f := fill(t, s, k, body) if err := f.Commit(Meta{Header: h, Lifetime: time.Hour, Expires: testNow.Add(time.Hour)}, gz); err != nil { t.Fatalf("Commit: %v", err) } } func mustGet(t *testing.T, s *Store, k Key) *Entry { t.Helper() e, ok := s.Get(k) if !ok { t.Fatalf("Get(%s): no entry", k.Path) } return e } func readAll(t *testing.T, e *Entry, gz bool) []byte { t.Helper() r, err := e.Open(gz) if err != nil { t.Fatalf("Open(gzip=%v): %v", gz, err) } defer r.Close() b, err := io.ReadAll(r) if err != nil { t.Fatal(err) } return b } func gunzip(t *testing.T, b []byte) []byte { t.Helper() zr, err := gzip.NewReader(bytes.NewReader(b)) if err != nil { t.Fatalf("gzip.NewReader: %v", err) } out, err := io.ReadAll(zr) if err != nil { t.Fatalf("gunzip: %v", err) } return out } func fileSize(t *testing.T, path string) int64 { t.Helper() fi, err := os.Stat(path) if err != nil { t.Fatal(err) } return fi.Size() } func slots(s *Store) (box, apps int) { s.mu.Lock() defer s.mu.Unlock() return s.fills, len(s.fillsByApp) } var cssText = []byte(strings.Repeat("body { margin: 0; padding: 0; color: #222; }\n", 200)) // TestCommitThenGetRoundTrips is not acceptance: it uses a fake FS. func TestCommitThenGetRoundTrips(t *testing.T) { s, _, dir := entryStore(t, 4<<30) k := testKey("shop", "/a.css") f := fill(t, s, k, cssText) if err := f.Commit(Meta{Header: cssHeader("1"), Lifetime: time.Hour, Expires: testNow.Add(time.Hour)}, false); err != nil { t.Fatal(err) } f.Abort() // after Commit: a no-op e := mustGet(t, s, k) if got := e.Meta.Header; got.Get("Content-Type") != "text/css" || got.Get("X-Version") != "1" { t.Fatalf("Header = %v", got) } if e.Meta.Key != k || e.Meta.Size != int64(len(cssText)) || e.Meta.GzipSize != 0 || !e.Meta.StoredAt.Equal(testNow) || !validVersion(e.Meta.Version) { t.Fatalf("Meta = %+v", e.Meta) } if got := readAll(t, e, false); !bytes.Equal(got, cssText) { t.Fatalf("body = %d bytes, want %d", len(got), len(cssText)) } if _, err := e.Open(true); err == nil { t.Fatal("Open(gzip) succeeded with no gzip copy") } metaSize := fileSize(t, filepath.Join(dir, k.ID()+".meta")) want := Stats{Entries: 1, Bytes: int64(len(cssText)) + metaSize, Apps: 1} if st := s.Stats(); st != want { t.Fatalf("Stats = %+v, want %+v", st, want) } if box, apps := slots(s); box != 0 || apps != 0 { t.Fatalf("slots after Commit: box %d, apps %d", box, apps) } sameNames(t, listDir(t, dir), []string{k.ID() + ".meta", bodyName(k.ID(), e.Meta.Version)}) if err := f.Commit(Meta{}, false); err == nil { t.Fatal("a second Commit succeeded") } } // TestReaderServesHTTP is not acceptance: it uses a fake FS. The Reader is // what http.ServeContent takes: a ranged request reads through Seek. func TestReaderServesHTTP(t *testing.T) { s, _, _ := entryStore(t, 4<<30) k := testKey("shop", "/a.css") commit(t, s, k, cssText, cssHeader("1"), false) r, err := mustGet(t, s, k).Open(false) if err != nil { t.Fatal(err) } defer r.Close() var _ io.ReadSeeker = r var _ io.ReaderAt = r fi, err := r.Stat() if err != nil || fi.Size() != int64(len(cssText)) { t.Fatalf("Stat = %v, %v", fi, err) } buf := make([]byte, 4) if _, err := r.ReadAt(buf, 5); err != nil || string(buf) != string(cssText[5:9]) { t.Fatalf("ReadAt = %q, %v", buf, err) } if _, err := r.Seek(7, io.SeekStart); err != nil { t.Fatal(err) } if _, err := io.ReadFull(r, buf); err != nil || string(buf) != string(cssText[7:11]) { t.Fatalf("Read after Seek = %q, %v", buf, err) } if err := r.Close(); err != nil { t.Fatal(err) } r.Close() // a second Close releases nothing more s.mu.Lock() leases := s.entries[k.ID()].ver.leases s.mu.Unlock() if leases != 0 { t.Fatalf("leases = %d after Close twice, want 0", leases) } } // TestCommitThenReopenThenRead is not acceptance: it uses a fake FS. func TestCommitThenReopenThenRead(t *testing.T) { s, _, dir := entryStore(t, 4<<30) k := testKey("shop", "/a.css") commit(t, s, k, cssText, cssHeader("1"), true) committed := mustGet(t, s, k).Meta s2 := reopen(t, dir) e := mustGet(t, s2, k) if e.Meta.Version != committed.Version { t.Fatalf("Version after reopen = %q, want %q", e.Meta.Version, committed.Version) } if e.Meta.GzipSize == 0 || e.Meta.GzipSize != committed.GzipSize || e.Meta.Header.Get("X-Version") != "1" { t.Fatalf("Meta after reopen = %+v", e.Meta) } if got := readAll(t, e, false); !bytes.Equal(got, cssText) { t.Fatal("identity body differs after reopen") } if got := gunzip(t, readAll(t, e, true)); !bytes.Equal(got, cssText) { t.Fatal("gzip copy does not decode to the identity body after reopen") } if st, st2 := s.Stats(), s2.Stats(); st != st2 { t.Fatalf("Stats after reopen = %+v, before %+v", st2, st) } } // TestCommitWritesMetaLast is not acceptance: it uses a fake FS. A crash // after the bodies are renamed but before the meta rename leaves no entry. func TestCommitWritesMetaLast(t *testing.T) { s, _, dir := entryStore(t, 4<<30) k := testKey("shop", "/a.css") f := fill(t, s, k, cssText) f.crashAt = crashBeforeMetaRename if err := f.Commit(Meta{Header: cssHeader("1"), Lifetime: time.Hour, Expires: testNow.Add(time.Hour)}, true); !errors.Is(err, errSimulatedCrash) { t.Fatalf("Commit = %v, want the simulated crash", err) } var bodies int for _, name := range listDir(t, dir) { if name == k.ID()+".meta" { t.Fatalf(".meta exists before the meta rename: %v", listDir(t, dir)) } if strings.HasSuffix(name, ".body") || strings.HasSuffix(name, ".gz") { bodies++ } } if bodies != 2 { t.Fatalf("files at the crash: %v, want both versioned bodies renamed", listDir(t, dir)) } s2 := reopen(t, dir) if _, ok := s2.Get(k); ok { t.Fatal("an entry survived a crash before its meta rename") } sameNames(t, listDir(t, dir), nil) } // TestReplacementCrashKeepsTheOldEntry is not acceptance: it uses a fake FS. func TestReplacementCrashKeepsTheOldEntry(t *testing.T) { s, _, dir := entryStore(t, 4<<30) k := testKey("shop", "/a.css") commit(t, s, k, []byte("old bytes"), cssHeader("old"), false) old := mustGet(t, s, k).Meta oldFiles := listDir(t, dir) f := fill(t, s, k, cssText) f.crashAt = crashBeforeMetaRename if err := f.Commit(Meta{Header: cssHeader("new"), Lifetime: time.Hour, Expires: testNow.Add(time.Hour)}, true); !errors.Is(err, errSimulatedCrash) { t.Fatalf("Commit = %v, want the simulated crash", err) } s2 := reopen(t, dir) e := mustGet(t, s2, k) if e.Meta.Version != old.Version || e.Meta.Header.Get("X-Version") != "old" { t.Fatalf("Meta after the crash = %+v, want the old entry", e.Meta) } if got := readAll(t, e, false); string(got) != "old bytes" { t.Fatalf("body after the crash = %q, want the old bytes", got) } sameNames(t, listDir(t, dir), oldFiles) } // TestOversizedMetaIsRefused is not acceptance: it uses a fake FS. func TestOversizedMetaIsRefused(t *testing.T) { big := strings.Repeat("x", 70<<10) for _, tc := range []struct { name string key Key hdr http.Header }{ {"header", testKey("shop", "/a.css"), http.Header{"X-Big": {big}}}, {"query", Key{Account: "abc", App: "shop", Gen: "g1", Host: "shop.example.com", Path: "/a.css", RawQuery: "q=" + big}, cssHeader("1")}, } { t.Run(tc.name, func(t *testing.T) { s, _, dir := entryStore(t, 4<<30) f := fill(t, s, tc.key, cssText) err := f.Commit(Meta{Header: tc.hdr, Lifetime: time.Hour, Expires: testNow.Add(time.Hour)}, true) if !errors.Is(err, ErrTooLarge) { t.Fatalf("Commit = %v, want ErrTooLarge", err) } f.Abort() if got := listDir(t, dir); len(got) != 0 { t.Fatalf("files left: %v", got) } if st := s.Stats(); st != (Stats{}) { t.Fatalf("Stats = %+v, want nothing stored or reserved", st) } if box, apps := slots(s); box != 0 || apps != 0 { t.Fatalf("slots: box %d, apps %d", box, apps) } if _, ok := s.Get(tc.key); ok { t.Fatal("refused entry is indexed") } }) } } // TestCommitOfAFailedFillAborts is not acceptance: it uses a fake FS. func TestCommitOfAFailedFillAborts(t *testing.T) { s, _, dir := entryStore(t, 4<<30) k := testKey("shop", "/a.css") f, err := s.Reserve(k, 4) if err != nil { t.Fatal(err) } if _, err := f.Write([]byte("too long")); !errors.Is(err, ErrTooLarge) { t.Fatalf("Write = %v", err) } if err := f.Commit(Meta{Expires: testNow.Add(time.Hour)}, false); !errors.Is(err, ErrTooLarge) { t.Fatalf("Commit = %v, want ErrTooLarge", err) } if st := s.Stats(); st != (Stats{}) { t.Fatalf("Stats = %+v", st) } if got := listDir(t, dir); len(got) != 0 { t.Fatalf("files left: %v", got) } } // heldBytesRefuse checks that, with a reader holding retired bytes, a fill // that fits only by counting them as free is refused without evicting // anything (no eviction can free held bytes), and that it is admitted once // the reader closes. func heldBytesRefuse(t *testing.T, s *Store, held int64, r *Reader, wantBody []byte) { t.Helper() st := s.Stats() if st.Retired != held { t.Fatalf("Retired = %d, want %d", st.Retired, held) } // The smallest fill that does not fit while the held bytes are counted, // even after eviction has freed every other entry. n := (s.cfg.Budget-held-st.Reserved-metaReserve)/2 + 1 if n < 0 || 2*n+metaReserve > s.cfg.Budget-st.Reserved { t.Fatalf("test budget leaves no room for the probe: Stats %+v", st) } probe := testKey("blog", "/probe") if _, err := s.Reserve(probe, n); !errors.Is(err, ErrNoRoom) { t.Fatalf("Reserve(%d) with %d bytes held = %v, want ErrNoRoom", n, held, err) } // The refused fill evicted nothing; the held bytes remain. if st1 := s.Stats(); st1 != st { t.Fatalf("Stats after the refusal = %+v, want %+v unchanged", st1, st) } // The reader still reads the unlinked bytes. got, err := io.ReadAll(r) if err != nil || !bytes.Equal(got, wantBody) { t.Fatalf("held reader read %d bytes, %v", len(got), err) } if err := r.Close(); err != nil { t.Fatal(err) } if st2 := s.Stats(); st2.Retired != 0 || st2.Bytes != st.Bytes-held { t.Fatalf("Stats after Close = %+v, want nothing held", st2) } f, err := s.Reserve(probe, n) if err != nil { t.Fatalf("Reserve(%d) after the reader closed: %v", n, err) } f.Abort() } // TestEvictedBodyStaysCountedWhileAReaderHoldsIt is not acceptance: it uses a // fake FS. It drives drop, the function eviction calls. func TestEvictedBodyStaysCountedWhileAReaderHoldsIt(t *testing.T) { s, _, dir := entryStore(t, 70000) k := testKey("shop", "/a.css") body := cssText[:1000] commit(t, s, k, body, cssHeader("1"), false) e := mustGet(t, s, k) r, err := e.Open(false) if err != nil { t.Fatal(err) } s.mu.Lock() ie := s.entries[k.ID()] s.mu.Unlock() if !s.drop(ie) { t.Fatal("drop refused the current entry") } if got := listDir(t, dir); len(got) != 0 { t.Fatalf("files after eviction: %v, want all unlinked at once", got) } if st := s.Stats(); st.Entries != 0 || st.Bytes != int64(len(body)) { t.Fatalf("Stats = %+v, want no entries and the held body counted", st) } if _, err := e.Open(false); err == nil { t.Fatal("Open of an evicted entry succeeded") } heldBytesRefuse(t, s, int64(len(body)), r, body) } // TestReplacedBodyStaysCountedWhileAReaderHoldsIt is not acceptance: it uses a // fake FS. func TestReplacedBodyStaysCountedWhileAReaderHoldsIt(t *testing.T) { s, _, dir := entryStore(t, 70000) k := testKey("shop", "/a.css") old := cssText[:1000] commit(t, s, k, old, cssHeader("old"), false) oldEntry := mustGet(t, s, k) r, err := oldEntry.Open(false) if err != nil { t.Fatal(err) } commit(t, s, k, []byte("new"), cssHeader("new"), false) e := mustGet(t, s, k) if got := readAll(t, e, false); string(got) != "new" { t.Fatalf("Get after replacement = %q", got) } sameNames(t, listDir(t, dir), []string{k.ID() + ".meta", bodyName(k.ID(), e.Meta.Version)}) metaSize := fileSize(t, filepath.Join(dir, k.ID()+".meta")) if st := s.Stats(); st.Entries != 1 || st.Bytes != int64(len(old))+3+metaSize { t.Fatalf("Stats = %+v, want the new entry and the held old body", st) } heldBytesRefuse(t, s, int64(len(old)), r, old) } // TestGzipCopyIsAbandonedWhenLarger is not acceptance: it uses a fake FS. func TestGzipCopyIsAbandonedWhenLarger(t *testing.T) { s, _, dir := entryStore(t, 4<<30) k := testKey("shop", "/noise.bin") body := make([]byte, 4096) if _, err := rand.Read(body); err != nil { t.Fatal(err) } commit(t, s, k, body, cssHeader("1"), true) e := mustGet(t, s, k) if e.Meta.GzipSize != 0 { t.Fatalf("GzipSize = %d, want the copy abandoned", e.Meta.GzipSize) } if _, err := e.Open(true); err == nil { t.Fatal("Open(gzip) succeeded with the copy abandoned") } sameNames(t, listDir(t, dir), []string{k.ID() + ".meta", bodyName(k.ID(), e.Meta.Version)}) if st := s.Stats(); st.Reserved != 0 { t.Fatalf("Reserved = %d", st.Reserved) } } // TestGzipCopyIsKept is not acceptance: it uses a fake FS. func TestGzipCopyIsKept(t *testing.T) { s, _, dir := entryStore(t, 4<<30) k := testKey("shop", "/a.css") commit(t, s, k, cssText, cssHeader("1"), true) e := mustGet(t, s, k) if e.Meta.GzipSize <= 0 || e.Meta.GzipSize >= e.Meta.Size { t.Fatalf("GzipSize = %d for %d bytes of CSS", e.Meta.GzipSize, e.Meta.Size) } if got := fileSize(t, filepath.Join(dir, gzipName(k.ID(), e.Meta.Version))); got != e.Meta.GzipSize { t.Fatalf(".gz is %d bytes, meta says %d", got, e.Meta.GzipSize) } if got := gunzip(t, readAll(t, e, true)); !bytes.Equal(got, cssText) { t.Fatal("gzip copy does not decode to the identity body") } metaSize := fileSize(t, filepath.Join(dir, k.ID()+".meta")) if st := s.Stats(); st.Bytes != e.Meta.Size+e.Meta.GzipSize+metaSize { t.Fatalf("Bytes = %d, want both bodies and the meta", st.Bytes) } } // TestGetExpires is not acceptance: it uses a fake FS. func TestGetExpires(t *testing.T) { s, c, dir := entryStore(t, 4<<30) k := testKey("shop", "/a.css") commit(t, s, k, cssText, cssHeader("1"), true) c.set(testNow.Add(time.Hour - time.Nanosecond)) if _, ok := s.Get(k); !ok { t.Fatal("entry gone before it expires") } c.set(testNow.Add(time.Hour)) // exactly Expires if _, ok := s.Get(k); ok { t.Fatal("Get returned an entry at its expiry") } if got := listDir(t, dir); len(got) != 0 { t.Fatalf("files after expiry: %v", got) } if st := s.Stats(); st != (Stats{}) { t.Fatalf("Stats after expiry = %+v", st) } } // TestCommitReplacesAnEntry is not acceptance: it uses a fake FS. func TestCommitReplacesAnEntry(t *testing.T) { s, _, dir := entryStore(t, 4<<30) k := testKey("shop", "/a.css") commit(t, s, k, cssText, cssHeader("old"), true) old := mustGet(t, s, k).Meta commit(t, s, k, []byte("new body"), cssHeader("new"), false) e := mustGet(t, s, k) if e.Meta.Version == old.Version || e.Meta.Header.Get("X-Version") != "new" || e.Meta.GzipSize != 0 { t.Fatalf("Meta = %+v, want the replacement", e.Meta) } if got := readAll(t, e, false); string(got) != "new body" { t.Fatalf("body = %q", got) } sameNames(t, listDir(t, dir), []string{k.ID() + ".meta", bodyName(k.ID(), e.Meta.Version)}) metaSize := fileSize(t, filepath.Join(dir, k.ID()+".meta")) want := Stats{Entries: 1, Bytes: int64(len("new body")) + metaSize, Apps: 1} if st := s.Stats(); st != want { t.Fatalf("Stats = %+v, want %+v", st, want) } } // TestAgeNeverNegative is not acceptance: it uses a fake FS. An entry stored // "in the future" after the clock steps back stays valid until its stored // expiry and reports age zero. func TestAgeNeverNegative(t *testing.T) { m := Meta{Lifetime: time.Hour, Expires: testNow.Add(time.Hour)} for _, tc := range []struct { now time.Time want time.Duration }{ {testNow, 0}, {testNow.Add(10 * time.Minute), 10 * time.Minute}, {testNow.Add(-5 * time.Minute), 0}, {testNow.Add(-48 * time.Hour), 0}, } { if got := m.Age(tc.now); got != tc.want { t.Errorf("Age(%v) = %v, want %v", tc.now, got, tc.want) } } s, c, _ := entryStore(t, 4<<30) k := testKey("shop", "/a.css") commit(t, s, k, cssText, cssHeader("1"), false) c.set(testNow.Add(-2 * time.Hour)) // the clock steps back e, ok := s.Get(k) if !ok { t.Fatal("entry gone after the clock stepped back") } if got := e.Meta.Age(c.Now()); got != 0 { t.Fatalf("Age = %v, want 0", got) } } // TestCommitGetAndReadConcurrently is not acceptance: it uses a fake FS. Run // with -race: replacements, reads and expiry of the same keys interleave, and // every counter comes back to what the directory holds. func TestCommitGetAndReadConcurrently(t *testing.T) { s, _, dir := entryStore(t, 4<<30) keys := []Key{testKey("shop", "/a.css"), testKey("blog", "/b.css")} var wg sync.WaitGroup for g := 0; g < 6; g++ { wg.Add(1) go func(g int) { defer wg.Done() for i := 0; i < 30; i++ { k := keys[(g+i)%2] switch g % 3 { case 0: body := []byte(fmt.Sprintf("%s %d %d", cssText[:200], g, i)) f, err := s.Reserve(k, int64(len(body))) if errors.Is(err, ErrBusy) { continue } if err != nil { t.Errorf("Reserve: %v", err) return } if _, err := f.Write(body); err != nil { t.Errorf("Write: %v", err) } if err := f.Commit(Meta{Lifetime: time.Hour, Expires: testNow.Add(time.Hour)}, i%2 == 0); err != nil { t.Errorf("Commit: %v", err) } case 1: e, ok := s.Get(k) if !ok { continue } for _, gz := range []bool{false, true} { if gz && e.Meta.GzipSize == 0 { continue } r, err := e.Open(gz) if errors.Is(err, os.ErrNotExist) { continue // retired between Get and Open } if err != nil { t.Errorf("Open(gzip=%v): %v", gz, err) continue } got, err := io.ReadAll(r) r.Close() if err != nil { t.Errorf("read (gzip=%v): %v", gz, err) continue } if gz { zr, err := gzip.NewReader(bytes.NewReader(got)) if err == nil { got, err = io.ReadAll(zr) } if err != nil { t.Errorf("gunzip: %v", err) continue } } if int64(len(got)) != e.Meta.Size || !bytes.HasPrefix(got, cssText[:200]) { t.Errorf("read %d bytes (gzip=%v), want the %d-byte body of a commit", len(got), gz, e.Meta.Size) } } case 2: s.mu.Lock() e := s.entries[k.ID()] s.mu.Unlock() if e != nil { s.drop(e) } } } }(g) } wg.Wait() st := s.Stats() if st.Reserved != 0 || st.Retired != 0 { t.Fatalf("Stats = %+v, want nothing reserved or retired", st) } var onDisk int64 for _, name := range listDir(t, dir) { if strings.Contains(name, ".tmp-") { t.Fatalf("temporary left: %s", name) } onDisk += fileSize(t, filepath.Join(dir, name)) } if st.Bytes != onDisk { t.Fatalf("Bytes = %d, directory holds %d", st.Bytes, onDisk) } if st2 := reopen(t, dir).Stats(); st2.Entries != st.Entries || st2.Bytes != st.Bytes { t.Fatalf("Stats after reopen = %+v, before %+v", st2, st) } } // TestCommittedHeaderIsTheStoresOwn is not acceptance: it uses a fake FS. // Changing the caller's header after Commit changes neither later hits nor // what a restart reads. func TestCommittedHeaderIsTheStoresOwn(t *testing.T) { s, _, dir := entryStore(t, 4<<30) k := testKey("shop", "/a.css") h := cssHeader("1") commit(t, s, k, cssText, h, false) h.Set("X-Version", "changed") h["Content-Type"][0] = "text/evil" h.Add("X-New", "x") check := func(name string, e *Entry) { t.Helper() got := e.Meta.Header if got.Get("X-Version") != "1" || got.Get("Content-Type") != "text/css" || got.Get("X-New") != "" { t.Fatalf("%s: Header = %v, want the header as committed", name, got) } } check("hit", mustGet(t, s, k)) check("after reopen", mustGet(t, reopen(t, dir), k)) } // TestBodyCloseFailureAborts is not acceptance: it uses a fake FS. A body // that fails to close before publication aborts the commit, and the old entry // stays as it was. func TestBodyCloseFailureAborts(t *testing.T) { s, _, dir := entryStore(t, 4<<30) k := testKey("shop", "/a.css") commit(t, s, k, []byte("old bytes"), cssHeader("old"), false) oldFiles := listDir(t, dir) before := s.Stats() f := fill(t, s, k, cssText) f.failAt = faultBodyClose if err := f.Commit(Meta{Header: cssHeader("new"), Lifetime: time.Hour, Expires: testNow.Add(time.Hour)}, true); !errors.Is(err, errInjected) { t.Fatalf("Commit = %v, want the injected close failure", err) } f.Abort() sameNames(t, listDir(t, dir), oldFiles) if st := s.Stats(); st != before { t.Fatalf("Stats = %+v, want %+v", st, before) } if box, apps := slots(s); box != 0 || apps != 0 { t.Fatalf("slots: box %d, apps %d", box, apps) } e := mustGet(t, s, k) if e.Meta.Header.Get("X-Version") != "old" || string(readAll(t, e, false)) != "old bytes" { t.Fatalf("entry after a failed commit = %+v", e.Meta) } } // TestUnsyncedMetaRenameKeepsTheOldBodies is not acceptance: it uses a fake // FS. When the directory sync after the meta rename fails, Commit reports it, // the new entry (whose files are on disk) stays indexed, and the old bodies // are kept, still counted, for the next Open: a crash could yet bring back // the old meta, which must find its bodies. func TestUnsyncedMetaRenameKeepsTheOldBodies(t *testing.T) { s, _, dir := entryStore(t, 4<<30) k := testKey("shop", "/a.css") old := cssText[:1000] commit(t, s, k, old, cssHeader("old"), true) oldMeta := mustGet(t, s, k).Meta oldBytes := oldMeta.Size + oldMeta.GzipSize f := fill(t, s, k, []byte("new body")) f.failAt = faultMetaDirSync if err := f.Commit(Meta{Header: cssHeader("new"), Lifetime: time.Hour, Expires: testNow.Add(time.Hour)}, false); !errors.Is(err, errInjected) { t.Fatalf("Commit = %v, want the injected sync failure", err) } f.Abort() // a no-op: the entry is published e := mustGet(t, s, k) if e.Meta.Header.Get("X-Version") != "new" || string(readAll(t, e, false)) != "new body" { t.Fatalf("entry = %+v, want the new one", e.Meta) } sameNames(t, listDir(t, dir), []string{ k.ID() + ".meta", bodyName(k.ID(), e.Meta.Version), bodyName(k.ID(), oldMeta.Version), gzipName(k.ID(), oldMeta.Version), }) metaSize := fileSize(t, filepath.Join(dir, k.ID()+".meta")) want := Stats{Entries: 1, Bytes: int64(len("new body")) + metaSize + oldBytes, Retired: oldBytes, Apps: 1} if st := s.Stats(); st != want { t.Fatalf("Stats = %+v, want %+v", st, want) } if box, apps := slots(s); box != 0 || apps != 0 { t.Fatalf("slots: box %d, apps %d", box, apps) } // Later commits and drops do not delete the kept bodies either. commit(t, s, testKey("blog", "/b.css"), []byte("b"), cssHeader("b"), false) if _, err := os.Stat(filepath.Join(dir, bodyName(k.ID(), oldMeta.Version))); err != nil { t.Fatalf("old body after a later commit: %v", err) } s2 := reopen(t, dir) e2 := mustGet(t, s2, k) if e2.Meta.Version != e.Meta.Version { t.Fatalf("after reopen: version %q, want %q", e2.Meta.Version, e.Meta.Version) } if _, err := os.Stat(filepath.Join(dir, bodyName(k.ID(), oldMeta.Version))); !errors.Is(err, os.ErrNotExist) { t.Fatalf("old body after reopen: %v, want removed", err) } }