media: make retention and the disk budget true
Put wrote the blob and then the sidecar. A full disk or a crash between the two left bytes on disk with no sidecar, and List walks sidecars, so Prune could never see them: Put returned an error and an image nobody knew about became permanent. The sidecar goes first, a failed write is rolled back, and Prune also collects blob files that have no readable sidecar and are past retention, which picks up whatever an older build leaked. The per-blob cap bounds one call and nothing bounded their sum. Content addressing does not help, because one flipped pixel is a different digest, so 64 MiB per call and an unlimited number of calls fills the disk mavend's database lives on. The store now carries a whole-store budget, seeded from disk at open so a restart does not begin at zero. Found in review of #72.
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -212,3 +214,129 @@ func TestOpenRejectsEmptyDir(t *testing.T) {
|
||||
t.Error("empty dir accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// A blob whose sidecar is missing was invisible to List, so Prune never saw it
|
||||
// and the bytes stayed on disk forever. Put produced exactly that state, by
|
||||
// writing the blob first and the sidecar second.
|
||||
func TestPruneCollectsASidecarlessBlob(t *testing.T) {
|
||||
s := testStore(t)
|
||||
b, err := s.Put(KindImage, "image/png", "web:upload", []byte("orphan"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta := filepath.Join(s.dir, string(KindImage), b.ID[:2], b.ID+".json")
|
||||
if err := os.Remove(meta); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Age the file past retention, the same way a real orphan gets there.
|
||||
old := time.Now().Add(-2 * DefaultRetention)
|
||||
if err := os.Chtimes(b.Path, old, old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n, err := s.Prune()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("pruned %d, want the orphan collected", n)
|
||||
}
|
||||
if _, err := os.Stat(b.Path); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("the orphaned bytes are still on disk: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A young orphan is left alone: a Put racing a Prune must not lose its bytes.
|
||||
func TestPruneLeavesAYoungOrphan(t *testing.T) {
|
||||
s := testStore(t)
|
||||
b, err := s.Put(KindImage, "image/png", "web:upload", []byte("fresh"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(filepath.Join(s.dir, string(KindImage), b.ID[:2], b.ID+".json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, err := s.Prune(); err != nil || n != 0 {
|
||||
t.Fatalf("prune = %d, %v; want the fresh orphan kept", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Put writes the sidecar first, so a failure writing the bytes leaves nothing
|
||||
// at all rather than an uncollectable blob.
|
||||
func TestPutLeavesNothingWhenTheBytesCannotBeWritten(t *testing.T) {
|
||||
s := testStore(t)
|
||||
data := []byte("will not land")
|
||||
sum := sha256.Sum256(data)
|
||||
id := hex.EncodeToString(sum[:])
|
||||
bucket := filepath.Join(s.dir, string(KindImage), id[:2])
|
||||
if err := os.MkdirAll(bucket, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A directory where the blob file needs to be: rename onto it fails, which
|
||||
// is the same shape as a full disk one step later.
|
||||
if err := os.Mkdir(filepath.Join(bucket, id+".png"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Put(KindImage, "image/png", "web:upload", data); err == nil {
|
||||
t.Fatal("put must fail")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(bucket, id+".json")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("a sidecar was left behind claiming a blob that does not exist: %v", err)
|
||||
}
|
||||
if s.Total() != 0 {
|
||||
t.Errorf("total = %d, want the failed put not counted", s.Total())
|
||||
}
|
||||
}
|
||||
|
||||
// The per-blob cap bounds one call and nothing bounded their sum. 64 MiB per
|
||||
// call times unlimited calls inside a seven-day window fills the disk mavend's
|
||||
// database lives on.
|
||||
func TestPutRefusesPastTheStoreBudget(t *testing.T) {
|
||||
s, err := OpenWithBudget(t.TempDir(), 16, 48, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, want := range []bool{true, true, true, false} {
|
||||
data := []byte(strings.Repeat(string(rune('a'+i)), 16))
|
||||
_, err := s.Put(KindImage, "image/png", "web:upload", data)
|
||||
if ok := err == nil; ok != want {
|
||||
t.Fatalf("put %d: err = %v, want ok=%v", i, err, want)
|
||||
}
|
||||
if !want && !errors.Is(err, ErrStoreFull) {
|
||||
t.Fatalf("put %d: err = %v, want ErrStoreFull", i, err)
|
||||
}
|
||||
}
|
||||
// The same bytes again cost nothing, so they are not refused.
|
||||
if _, err := s.Put(KindImage, "image/png", "web:upload", []byte(strings.Repeat("a", 16))); err != nil {
|
||||
t.Fatalf("a re-send of stored bytes was refused: %v", err)
|
||||
}
|
||||
// Deleting frees the budget again.
|
||||
blobs, err := s.List(KindImage)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Delete(blobs[0].ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Put(KindImage, "image/png", "web:upload", []byte(strings.Repeat("z", 16))); err != nil {
|
||||
t.Fatalf("budget was not released on delete: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A restart must not start the budget over at zero.
|
||||
func TestOpenSeedsTheBudgetFromDisk(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, err := OpenWithBudget(dir, 16, 48, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Put(KindImage, "image/png", "web:upload", []byte(strings.Repeat("a", 16))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, err := OpenWithBudget(dir, 16, 48, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if again.Total() != 16 {
|
||||
t.Fatalf("total after reopen = %d, want 16", again.Total())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user