From 83f41d3baea579bbd6be2cc82f606d148c721138 Mon Sep 17 00:00:00 2001 From: Alec Thomas Date: Fri, 26 Jun 2026 11:38:55 +1000 Subject: [PATCH] fix(git): stage parallel snapshot temp file on target filesystem Parallel restore wrote the whole compressed snapshot to the default temp dir, which can fail with ENOSPC on hosts where /tmp is a small or separate tmpfs even when the target directory has room. Stage it under the target directory's parent (same filesystem, created if missing) instead. Add an end-to-end parallel restore test covering this. --- Procfile | 2 +- ....proctor-0.9.3.pkg => .proctor-0.10.0.pkg} | 0 bin/proctor | 2 +- cmd/cachew/git.go | 11 ++++- cmd/cachew/git_test.go | 48 +++++++++++++++++++ 5 files changed, 60 insertions(+), 3 deletions(-) rename bin/{.proctor-0.9.3.pkg => .proctor-0.10.0.pkg} (100%) diff --git a/Procfile b/Procfile index b85bdddd..de03f3f6 100644 --- a/Procfile +++ b/Procfile @@ -1 +1 @@ -cachewd cachew.hcl **/*.go !**/*_test.go !state/**/* debounce=2s ready=http:8080/_readiness=200: CACHEW_URL=http://localhost:8080 CACHEW_LOG_LEVEL=debug cachewd +cachewd cachew.hcl **/*.go !**/*_test.go !state/**/* debounce=2s ready=http:8080/_readiness=200 timeout=600s: CACHEW_URL=http://localhost:8080 CACHEW_LOG_LEVEL=debug cachewd diff --git a/bin/.proctor-0.9.3.pkg b/bin/.proctor-0.10.0.pkg similarity index 100% rename from bin/.proctor-0.9.3.pkg rename to bin/.proctor-0.10.0.pkg diff --git a/bin/proctor b/bin/proctor index 0355ae95..5ed93692 120000 --- a/bin/proctor +++ b/bin/proctor @@ -1 +1 @@ -.proctor-0.9.3.pkg \ No newline at end of file +.proctor-0.10.0.pkg \ No newline at end of file diff --git a/cmd/cachew/git.go b/cmd/cachew/git.go index af3285cd..0774314c 100644 --- a/cmd/cachew/git.go +++ b/cmd/cachew/git.go @@ -6,6 +6,7 @@ import ( "io" "os" "os/exec" + "path/filepath" "strings" "time" @@ -162,7 +163,15 @@ func (c *GitRestoreCmd) streamFetchAndExtract(ctx context.Context, api *client.C // WriteAt so it cannot stream into extraction; the temp file is removed on // return. func (c *GitRestoreCmd) parallelFetchAndExtract(ctx context.Context, api *client.Client) (string, string, error) { - tmp, err := os.CreateTemp("", "cachew-snapshot-*.tar.zst") + // Stage the temp snapshot on the same filesystem as the restore target so a + // small or separate /tmp can't fail a restore the target directory has room + // for. The parent of c.Directory shares its filesystem and is created by + // extraction anyway. + tmpDir := filepath.Dir(c.Directory) + if err := os.MkdirAll(tmpDir, 0o750); err != nil { + return "", "", errors.Wrap(err, "create snapshot temp dir") + } + tmp, err := os.CreateTemp(tmpDir, ".cachew-snapshot-*.tar.zst") if err != nil { return "", "", errors.Wrap(err, "create snapshot temp file") } diff --git a/cmd/cachew/git_test.go b/cmd/cachew/git_test.go index ec8b8dbd..4eaeeaaa 100644 --- a/cmd/cachew/git_test.go +++ b/cmd/cachew/git_test.go @@ -129,6 +129,54 @@ func TestGitRestoreSnapshot(t *testing.T) { assert.Equal(t, "nested content", string(content)) } +func TestGitRestoreSnapshotParallel(t *testing.T) { + srcDir := t.TempDir() + initGitRepo(t, srcDir, map[string]string{ + "hello.txt": "hello world", + "subdir/nested.txt": "nested content", + }) + snapshotData := createTarZst(t, srcDir) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/snapshot.tar.zst") { + w.Header().Set("Content-Type", "application/zstd") + w.Header().Set("ETag", `"snap-v1"`) + // ServeContent honours Range/If-Range against the ETag, so ParallelGet + // fetches the snapshot in concurrent chunks. + http.ServeContent(w, r, "snapshot.tar.zst", time.Time{}, bytes.NewReader(snapshotData)) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + // A nested, not-yet-existing target exercises temp-dir creation on the + // target filesystem. + dstDir := filepath.Join(t.TempDir(), "nested", "restored") + cmd := &GitRestoreCmd{ + RepoURL: "https://github.com/test/repo", + Directory: dstDir, + DownloadConcurrency: 4, + DownloadChunkSizeMB: 8, + } + api := client.NewWithHTTPClient(srv.URL, srv.Client()) + assert.NoError(t, cmd.Run(context.Background(), api)) + + content, err := os.ReadFile(filepath.Join(dstDir, "hello.txt")) + assert.NoError(t, err) + assert.Equal(t, "hello world", string(content)) + content, err = os.ReadFile(filepath.Join(dstDir, "subdir", "nested.txt")) + assert.NoError(t, err) + assert.Equal(t, "nested content", string(content)) + + // The temp snapshot is staged on the target filesystem and cleaned up. + entries, err := os.ReadDir(filepath.Dir(dstDir)) + assert.NoError(t, err) + for _, e := range entries { + assert.False(t, strings.HasPrefix(e.Name(), ".cachew-snapshot-"), "temp snapshot left behind: %s", e.Name()) + } +} + func TestGitRestoreWithBundle(t *testing.T) { srcDir := t.TempDir() initGitRepo(t, srcDir, map[string]string{"file.txt": "v1"})