Skip to content

Commit ea7295b

Browse files
committed
fix(content/oci): reclaim the temporary files of interrupted writes
`ingest` names its return values so that the deferred cleanup can remove the ingest file when a write fails, but its error paths return with `return "", err`, which clears the named return `path` before the deferred function runs. The cleanup therefore calls `os.Remove("")` and the ingest file is left behind, so every failed push costs the disk space of the content written before the failure. Set the named error and return, as `ioutil.Ingest` has done since #1185. Nothing reclaimed those files afterwards either. `GC` sweeps `blobs/` only, so the residue of interrupted blob and metadata writes accumulates for the life of the store, which makes the store a contributor to the full file system that produced the residue in the first place. Remove them in `GC`, where the other unreachable content of the store is already collected. Only entries that have not been modified for an hour are reclaimed. A write removes its own temporary file when it fails, so anything still present is either the residue of a process that died mid-write or a write that another `Store` on the same directory is performing right now, which the lock of this `Store` does not serialize against. The modification time of a write in progress keeps advancing, so a live write is never eligible while the residue of a dead one always becomes eligible. The two directories are otherwise treated differently on purpose. `ingest/` is created and owned by this package, so any stale entry in it is residue. The root of the store may hold files that belong to whoever put them there, which the image layout specification permits, so only the names a temporary file of this package can actually have are considered there. Signed-off-by: Chris Wedgwood <cw@f00f.org>
1 parent ede7712 commit ea7295b

4 files changed

Lines changed: 252 additions & 2 deletions

File tree

content/oci/oci.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,13 @@ import (
2323
"errors"
2424
"fmt"
2525
"io"
26+
"io/fs"
2627
"maps"
2728
"os"
2829
"path"
2930
"path/filepath"
3031
"sync"
32+
"time"
3133

3234
"github.com/opencontainers/go-digest"
3335
specs "github.com/opencontainers/image-spec/specs-go"
@@ -530,6 +532,7 @@ func (s *Store) writeIndexFile() error {
530532
// The garbage to be cleaned are:
531533
// - unreferenced (dangling) blobs in Store which have no predecessors
532534
// - garbage blobs in the storage whose metadata is not stored in Store
535+
// - temporary files left behind by interrupted blob and metadata writes
533536
func (s *Store) GC(ctx context.Context) error {
534537
s.sync.Lock()
535538
defer s.sync.Unlock()
@@ -580,6 +583,80 @@ func (s *Store) GC(ctx context.Context) error {
580583
}
581584
}
582585
}
586+
587+
// clean up the temporary files left behind by interrupted writes
588+
if err := s.gcLeftovers(); err != nil {
589+
return fmt.Errorf("unable to remove leftover temporary files: %w", err)
590+
}
591+
return nil
592+
}
593+
594+
// leftoverExpiry is how long a temporary file must have been untouched before
595+
// GC reclaims it, so that a write in progress -- possibly by another Store on
596+
// the same directory -- is never reclaimed from underneath.
597+
const leftoverExpiry = time.Hour
598+
599+
// gcLeftovers removes the temporary files that interrupted writes leave
600+
// behind: the ingest files of blob writes that did not complete, and the
601+
// temporary files of metadata writes that were never renamed into place.
602+
// Nothing else refers to them, and no other code path removes them.
603+
func (s *Store) gcLeftovers() error {
604+
// A write removes its own temporary file when it fails, so anything still
605+
// here is the residue of a process that died mid-write -- or a write that
606+
// another Store on the same directory is performing right now, which the
607+
// lock of this Store does not serialize against. Only entries that have
608+
// not been touched for leftoverExpiry are reclaimed, since the modification
609+
// time of a write in progress keeps advancing.
610+
stale := olderThan(leftoverExpiry)
611+
612+
if err := removeFiles(s.storage.ingestRoot, stale); err != nil {
613+
return err
614+
}
615+
// the temporary files of metadata writes are created next to the file that
616+
// they replace, in the root of the store.
617+
return removeFiles(s.root, func(entry fs.DirEntry) bool {
618+
name := entry.Name()
619+
if !isTempFileOf(name, ocispec.ImageIndexFile) && !isTempFileOf(name, ocispec.ImageLayoutFile) {
620+
return false
621+
}
622+
return stale(entry)
623+
})
624+
}
625+
626+
// olderThan returns a matcher accepting the directory entries that have not
627+
// been modified for at least d. An entry whose information cannot be read is
628+
// not matched: it has either just been removed by someone else, or it is not
629+
// ours to reason about.
630+
func olderThan(d time.Duration) func(entry fs.DirEntry) bool {
631+
return func(entry fs.DirEntry) bool {
632+
fi, err := entry.Info()
633+
if err != nil {
634+
return false
635+
}
636+
return time.Since(fi.ModTime()) >= d
637+
}
638+
}
639+
640+
// removeFiles removes the files in dir that are matched by match. Directories
641+
// and entries that are not matched are left alone, and a directory that does
642+
// not exist is not an error.
643+
func removeFiles(dir string, match func(entry fs.DirEntry) bool) error {
644+
entries, err := os.ReadDir(dir)
645+
if err != nil {
646+
if errors.Is(err, fs.ErrNotExist) {
647+
return nil
648+
}
649+
return err
650+
}
651+
652+
for _, entry := range entries {
653+
if entry.IsDir() || !match(entry) {
654+
continue
655+
}
656+
if err := os.Remove(filepath.Join(dir, entry.Name())); err != nil && !errors.Is(err, fs.ErrNotExist) {
657+
return err
658+
}
659+
}
583660
return nil
584661
}
585662

content/oci/oci_test.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
"errors"
2626
"fmt"
2727
"io"
28+
"io/fs"
2829
"os"
2930
"path"
3031
"path/filepath"
@@ -33,6 +34,7 @@ import (
3334
"strings"
3435
"sync/atomic"
3536
"testing"
37+
"time"
3638

3739
"github.com/opencontainers/go-digest"
3840
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
@@ -3187,3 +3189,141 @@ func TestStore_TrailingBytesInMetadataFile(t *testing.T) {
31873189
t.Fatal("New() error =", err)
31883190
}
31893191
}
3192+
3193+
// TestStore_GC_Leftovers ensures that the temporary files of writes that were
3194+
// interrupted are reclaimed by GC, since nothing else removes them, and that
3195+
// the files GC does not own -- including the temporary files of a write that
3196+
// may still be in progress -- are left alone.
3197+
func TestStore_GC_Leftovers(t *testing.T) {
3198+
tempDir := t.TempDir()
3199+
s, err := New(tempDir)
3200+
if err != nil {
3201+
t.Fatal("New() error =", err)
3202+
}
3203+
ctx := context.Background()
3204+
3205+
ingestRoot := s.storage.ingestRoot
3206+
if err := os.MkdirAll(ingestRoot, 0777); err != nil {
3207+
t.Fatal("error calling MkdirAll(), error =", err)
3208+
}
3209+
write := func(path string) {
3210+
t.Helper()
3211+
if err := os.WriteFile(path, []byte("whatever"), 0666); err != nil {
3212+
t.Fatal("error calling WriteFile(), error =", err)
3213+
}
3214+
}
3215+
// residue of writes interrupted long enough ago that no process can still
3216+
// be working on them
3217+
stale := []string{
3218+
filepath.Join(ingestRoot, digest.FromString("whatever").Encoded()+"_1525580788"),
3219+
filepath.Join(ingestRoot, "not-an-ingest-file"),
3220+
filepath.Join(tempDir, ocispec.ImageIndexFile+"_"+rand.Text()),
3221+
filepath.Join(tempDir, ocispec.ImageLayoutFile+"_"+rand.Text()),
3222+
}
3223+
for _, path := range stale {
3224+
write(path)
3225+
old := time.Now().Add(-2 * leftoverExpiry)
3226+
if err := os.Chtimes(path, old, old); err != nil {
3227+
t.Fatal("error calling Chtimes(), error =", err)
3228+
}
3229+
}
3230+
// files that must survive: temporary files recent enough to belong to a
3231+
// write in progress, and files that only share the prefix of one
3232+
keep := []string{
3233+
filepath.Join(ingestRoot, digest.FromString("in progress").Encoded()+"_1525580789"),
3234+
filepath.Join(tempDir, ocispec.ImageIndexFile+"_"+rand.Text()),
3235+
filepath.Join(tempDir, ocispec.ImageIndexFile+"_backup"),
3236+
filepath.Join(tempDir, ocispec.ImageIndexFile+"_"+strings.ToLower(rand.Text())),
3237+
}
3238+
for _, path := range keep {
3239+
write(path)
3240+
}
3241+
keep = append(keep,
3242+
filepath.Join(tempDir, ocispec.ImageIndexFile),
3243+
filepath.Join(tempDir, ocispec.ImageLayoutFile))
3244+
3245+
if err := s.GC(ctx); err != nil {
3246+
t.Fatal("Store.GC() error =", err)
3247+
}
3248+
3249+
for _, path := range stale {
3250+
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
3251+
t.Errorf("%s still exists after GC(), error = %v", path, err)
3252+
}
3253+
}
3254+
for _, path := range keep {
3255+
if _, err := os.Stat(path); err != nil {
3256+
t.Errorf("error calling Stat() on %s, error = %v", path, err)
3257+
}
3258+
}
3259+
}
3260+
3261+
func Test_olderThan(t *testing.T) {
3262+
tempDir := t.TempDir()
3263+
path := filepath.Join(tempDir, "file")
3264+
if err := os.WriteFile(path, []byte("whatever"), 0666); err != nil {
3265+
t.Fatal("error calling WriteFile(), error =", err)
3266+
}
3267+
entry := func() fs.DirEntry {
3268+
t.Helper()
3269+
entries, err := os.ReadDir(tempDir)
3270+
if err != nil {
3271+
t.Fatal("error calling ReadDir(), error =", err)
3272+
}
3273+
return entries[0]
3274+
}
3275+
3276+
// a file just written belongs to a write that may still be in progress
3277+
if olderThan(time.Hour)(entry()) {
3278+
t.Error("olderThan() = true for a file just written, want false")
3279+
}
3280+
old := time.Now().Add(-2 * time.Hour)
3281+
if err := os.Chtimes(path, old, old); err != nil {
3282+
t.Fatal("error calling Chtimes(), error =", err)
3283+
}
3284+
if !olderThan(time.Hour)(entry()) {
3285+
t.Error("olderThan() = false for an untouched file, want true")
3286+
}
3287+
}
3288+
3289+
func Test_removeFiles(t *testing.T) {
3290+
tempDir := t.TempDir()
3291+
notADir := filepath.Join(tempDir, "file")
3292+
if err := os.WriteFile(notADir, []byte("whatever"), 0666); err != nil {
3293+
t.Fatal("error calling WriteFile(), error =", err)
3294+
}
3295+
subDir := filepath.Join(tempDir, "dir")
3296+
if err := os.Mkdir(subDir, 0777); err != nil {
3297+
t.Fatal("error calling Mkdir(), error =", err)
3298+
}
3299+
3300+
all := func(fs.DirEntry) bool { return true }
3301+
named := func(want string) func(fs.DirEntry) bool {
3302+
return func(entry fs.DirEntry) bool { return entry.Name() == want }
3303+
}
3304+
3305+
// a directory that does not exist holds no leftovers
3306+
if err := removeFiles(filepath.Join(tempDir, "missing"), all); err != nil {
3307+
t.Error("removeFiles() error =", err)
3308+
}
3309+
// a path that is not a directory cannot be listed
3310+
if err := removeFiles(notADir, all); err == nil {
3311+
t.Error("removeFiles() error = nil, wantErr = true")
3312+
}
3313+
// directories and unmatched files are left alone
3314+
if err := removeFiles(tempDir, named("dir")); err != nil {
3315+
t.Fatal("removeFiles() error =", err)
3316+
}
3317+
for _, path := range []string{notADir, subDir} {
3318+
if _, err := os.Stat(path); err != nil {
3319+
t.Errorf("error calling Stat() on %s, error = %v", path, err)
3320+
}
3321+
}
3322+
// matched files are removed
3323+
if err := removeFiles(tempDir, named("file")); err != nil {
3324+
t.Fatal("removeFiles() error =", err)
3325+
}
3326+
if _, err := os.Stat(notADir); !errors.Is(err, os.ErrNotExist) {
3327+
t.Errorf("Stat() error = %v, want %v", err, os.ErrNotExist)
3328+
}
3329+
}

content/oci/storage.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,12 +155,14 @@ func (s *Storage) ingest(expected ocispec.Descriptor, content io.Reader) (path s
155155
buf := bufPool.Get().(*[]byte)
156156
defer bufPool.Put(buf)
157157
if err := ioutil.CopyBuffer(fp, content, *buf, expected); err != nil {
158-
return "", fmt.Errorf("failed to ingest: %w", err)
158+
ingestErr = fmt.Errorf("failed to ingest: %w", err)
159+
return
159160
}
160161

161162
// change to readonly
162163
if err := os.Chmod(path, 0444); err != nil {
163-
return "", fmt.Errorf("failed to make readonly: %w", err)
164+
ingestErr = fmt.Errorf("failed to make readonly: %w", err)
165+
return
164166
}
165167

166168
return

content/oci/storage_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,3 +413,34 @@ func TestStorage_Delete(t *testing.T) {
413413
t.Fatalf("got error = %v, want %v", err, errdef.ErrNotFound)
414414
}
415415
}
416+
417+
// TestStorage_BadPush_NoIngestFileLeftBehind ensures that the ingest file of a
418+
// push that failed is removed. Nothing else removes it, so a leaked ingest file
419+
// occupies its space until the store is deleted.
420+
func TestStorage_BadPush_NoIngestFileLeftBehind(t *testing.T) {
421+
content := []byte("hello world")
422+
desc := ocispec.Descriptor{
423+
MediaType: "test",
424+
Digest: digest.FromBytes(content),
425+
Size: int64(len(content)),
426+
}
427+
428+
tempDir := t.TempDir()
429+
s, err := NewStorage(tempDir)
430+
if err != nil {
431+
t.Fatal("New() error =", err)
432+
}
433+
ctx := context.Background()
434+
435+
if err := s.Push(ctx, desc, strings.NewReader("foobar")); err == nil {
436+
t.Fatal("Storage.Push() error = nil, wantErr = true")
437+
}
438+
439+
entries, err := os.ReadDir(s.ingestRoot)
440+
if err != nil {
441+
t.Fatal("error calling ReadDir(), error =", err)
442+
}
443+
if len(entries) != 0 {
444+
t.Errorf("len(ingest entries) = %v, want %v", len(entries), 0)
445+
}
446+
}

0 commit comments

Comments
 (0)