fix(snapshot): bound repository maintenance
- commit
- b1b3fa9f0359cfd78f4eb6173d14e19a4055f74f
- author
- brice · · 2026-07-21 20:47 UTC
- committer
- brice · · 2026-07-21 20:48 UTC
- parent
- b5f28c1
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +426 -43
M internal/adapters/snapshot/legacy.go +48 -16
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | 6 | "fmt" | |
| 7 | + | "io" | |
| 7 | 8 | "os" | |
| 8 | 9 | "path/filepath" | |
| 9 | 10 | "sort" | |
| @@ -19,32 +20,60 @@ func (r *Repository) LoadLegacy(ctx context.Context) ([]ports.LegacySnapshot, er | |||
| 19 | 20 | if err := ctx.Err(); err != nil { | |
| 20 | 21 | return nil, err | |
| 21 | 22 | } | |
| 22 | - | entries, err := os.ReadDir(r.dir) | |
| 23 | + | f, err := os.Open(r.dir) | |
| 23 | 24 | if errors.Is(err, os.ErrNotExist) { | |
| 24 | 25 | return nil, nil | |
| 25 | 26 | } | |
| 26 | 27 | if err != nil { | |
| 27 | 28 | return nil, fmt.Errorf("read legacy snapshot directory: %w", safeFilesystemError(err)) | |
| 28 | 29 | } | |
| 29 | - | sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) | |
| 30 | - | out := make([]ports.LegacySnapshot, 0, len(entries)) | |
| 31 | - | for _, entry := range entries { | |
| 32 | - | if err := ctx.Err(); err != nil { | |
| 33 | - | return nil, err | |
| 34 | - | } | |
| 35 | - | if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".snap") { | |
| 36 | - | continue | |
| 30 | + | defer f.Close() | |
| 31 | + | ||
| 32 | + | out := make([]ports.LegacySnapshot, 0, maxLegacySnapshotFiles) | |
| 33 | + | files := 0 | |
| 34 | + | total := 0 | |
| 35 | + | for { | |
| 36 | + | entries, readErr := f.ReadDir(maintenanceBatch) | |
| 37 | + | for _, entry := range entries { | |
| 38 | + | if err := ctx.Err(); err != nil { | |
| 39 | + | return nil, err | |
| 40 | + | } | |
| 41 | + | if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".snap") { | |
| 42 | + | continue | |
| 43 | + | } | |
| 44 | + | files++ | |
| 45 | + | if files > maxLegacySnapshotFiles { | |
| 46 | + | return nil, fmt.Errorf("legacy snapshot import has too many files (maximum %d)", maxLegacySnapshotFiles) | |
| 47 | + | } | |
| 48 | + | path := filepath.Join(r.dir, entry.Name()) | |
| 49 | + | // Check the prospective size before readBounded allocates a buffer. | |
| 50 | + | // readBounded repeats the security validation and the actual length is | |
| 51 | + | // checked below to close the metadata/read race. | |
| 52 | + | if info, statErr := os.Lstat(path); statErr == nil && info.Mode().IsRegular() && info.Size() > int64(maxLegacySnapshotBytes-total) { | |
| 53 | + | return nil, fmt.Errorf("legacy snapshot import exceeds aggregate byte limit (%d bytes)", maxLegacySnapshotBytes) | |
| 54 | + | } | |
| 55 | + | data, dataErr := readBounded(path) | |
| 56 | + | if dataErr != nil { | |
| 57 | + | continue | |
| 58 | + | } | |
| 59 | + | if len(data) > maxLegacySnapshotBytes-total { | |
| 60 | + | return nil, fmt.Errorf("legacy snapshot import exceeds aggregate byte limit (%d bytes)", maxLegacySnapshotBytes) | |
| 61 | + | } | |
| 62 | + | total += len(data) | |
| 63 | + | name := strings.TrimSuffix(entry.Name(), ".snap") | |
| 64 | + | if strings.HasPrefix(name, "@") || !safeNameRE.MatchString(name) { | |
| 65 | + | name = "" | |
| 66 | + | } | |
| 67 | + | out = append(out, ports.LegacySnapshot{Name: name, Data: data}) | |
| 37 | 68 | } | |
| 38 | - | data, err := readBounded(filepath.Join(r.dir, entry.Name())) | |
| 39 | - | if err != nil { | |
| 40 | - | continue | |
| 69 | + | if errors.Is(readErr, io.EOF) { | |
| 70 | + | break | |
| 41 | 71 | } | |
| 42 | - | name := strings.TrimSuffix(entry.Name(), ".snap") | |
| 43 | - | if strings.HasPrefix(name, "@") || !safeNameRE.MatchString(name) { | |
| 44 | - | name = "" | |
| 72 | + | if readErr != nil { | |
| 73 | + | return nil, fmt.Errorf("read legacy snapshot directory: %w", safeFilesystemError(readErr)) | |
| 45 | 74 | } | |
| 46 | - | out = append(out, ports.LegacySnapshot{Name: name, Data: data}) | |
| 47 | 75 | } | |
| 76 | + | sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) | |
| 48 | 77 | return out, nil | |
| 49 | 78 | } | |
| 50 | 79 | ||
| @@ -61,6 +90,9 @@ func (r *Repository) DeleteLegacy(ctx context.Context, name string) error { | |||
| 61 | 90 | } else if err != nil { | |
| 62 | 91 | return fmt.Errorf("delete legacy snapshot: %w", safeFilesystemError(err)) | |
| 63 | 92 | } | |
| 93 | + | if err := ctx.Err(); err != nil { | |
| 94 | + | return err | |
| 95 | + | } | |
| 64 | 96 | if err := r.syncDirectory(r.dir); err != nil { | |
| 65 | 97 | return fmt.Errorf("sync legacy snapshot directory: %w", safeFilesystemError(err)) | |
| 66 | 98 | } | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | + | "io" | |||
| 7 | "os" | 8 | "os" | ||
| 8 | "path/filepath" | 9 | "path/filepath" | ||
| 9 | "sort" | 10 | "sort" | ||
| @@ -19,32 +20,60 @@ func (r *Repository) LoadLegacy(ctx context.Context) ([]ports.LegacySnapshot, er | |||||
| 19 | if err := ctx.Err(); err != nil { | 20 | if err := ctx.Err(); err != nil { | ||
| 20 | return nil, err | 21 | return nil, err | ||
| 21 | } | 22 | } | ||
| 22 | - | entries, err := os.ReadDir(r.dir) | 23 | + | f, err := os.Open(r.dir) |
| 23 | if errors.Is(err, os.ErrNotExist) { | 24 | if errors.Is(err, os.ErrNotExist) { | ||
| 24 | return nil, nil | 25 | return nil, nil | ||
| 25 | } | 26 | } | ||
| 26 | if err != nil { | 27 | if err != nil { | ||
| 27 | return nil, fmt.Errorf("read legacy snapshot directory: %w", safeFilesystemError(err)) | 28 | return nil, fmt.Errorf("read legacy snapshot directory: %w", safeFilesystemError(err)) | ||
| 28 | } | 29 | } | ||
| 29 | - | sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) | 30 | + | defer f.Close() |
| 30 | - | out := make([]ports.LegacySnapshot, 0, len(entries)) | 31 | + | |
| 31 | - | for _, entry := range entries { | 32 | + | out := make([]ports.LegacySnapshot, 0, maxLegacySnapshotFiles) |
| 32 | - | if err := ctx.Err(); err != nil { | 33 | + | files := 0 |
| 33 | - | return nil, err | 34 | + | total := 0 |
| 34 | - | } | 35 | + | for { |
| 35 | - | if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".snap") { | 36 | + | entries, readErr := f.ReadDir(maintenanceBatch) |
| 36 | - | continue | 37 | + | for _, entry := range entries { |
| 38 | + | if err := ctx.Err(); err != nil { | |||
| 39 | + | return nil, err | |||
| 40 | + | } | |||
| 41 | + | if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".snap") { | |||
| 42 | + | continue | |||
| 43 | + | } | |||
| 44 | + | files++ | |||
| 45 | + | if files > maxLegacySnapshotFiles { | |||
| 46 | + | return nil, fmt.Errorf("legacy snapshot import has too many files (maximum %d)", maxLegacySnapshotFiles) | |||
| 47 | + | } | |||
| 48 | + | path := filepath.Join(r.dir, entry.Name()) | |||
| 49 | + | // Check the prospective size before readBounded allocates a buffer. | |||
| 50 | + | // readBounded repeats the security validation and the actual length is | |||
| 51 | + | // checked below to close the metadata/read race. | |||
| 52 | + | if info, statErr := os.Lstat(path); statErr == nil && info.Mode().IsRegular() && info.Size() > int64(maxLegacySnapshotBytes-total) { | |||
| 53 | + | return nil, fmt.Errorf("legacy snapshot import exceeds aggregate byte limit (%d bytes)", maxLegacySnapshotBytes) | |||
| 54 | + | } | |||
| 55 | + | data, dataErr := readBounded(path) | |||
| 56 | + | if dataErr != nil { | |||
| 57 | + | continue | |||
| 58 | + | } | |||
| 59 | + | if len(data) > maxLegacySnapshotBytes-total { | |||
| 60 | + | return nil, fmt.Errorf("legacy snapshot import exceeds aggregate byte limit (%d bytes)", maxLegacySnapshotBytes) | |||
| 61 | + | } | |||
| 62 | + | total += len(data) | |||
| 63 | + | name := strings.TrimSuffix(entry.Name(), ".snap") | |||
| 64 | + | if strings.HasPrefix(name, "@") || !safeNameRE.MatchString(name) { | |||
| 65 | + | name = "" | |||
| 66 | + | } | |||
| 67 | + | out = append(out, ports.LegacySnapshot{Name: name, Data: data}) | |||
| 37 | } | 68 | } | ||
| 38 | - | data, err := readBounded(filepath.Join(r.dir, entry.Name())) | 69 | + | if errors.Is(readErr, io.EOF) { |
| 39 | - | if err != nil { | 70 | + | break |
| 40 | - | continue | |||
| 41 | } | 71 | } | ||
| 42 | - | name := strings.TrimSuffix(entry.Name(), ".snap") | 72 | + | if readErr != nil { |
| 43 | - | if strings.HasPrefix(name, "@") || !safeNameRE.MatchString(name) { | 73 | + | return nil, fmt.Errorf("read legacy snapshot directory: %w", safeFilesystemError(readErr)) |
| 44 | - | name = "" | |||
| 45 | } | 74 | } | ||
| 46 | - | out = append(out, ports.LegacySnapshot{Name: name, Data: data}) | |||
| 47 | } | 75 | } | ||
| 76 | + | sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) | |||
| 48 | return out, nil | 77 | return out, nil | ||
| 49 | } | 78 | } | ||
| 50 | 79 | ||||
| @@ -61,6 +90,9 @@ func (r *Repository) DeleteLegacy(ctx context.Context, name string) error { | |||||
| 61 | } else if err != nil { | 90 | } else if err != nil { | ||
| 62 | return fmt.Errorf("delete legacy snapshot: %w", safeFilesystemError(err)) | 91 | return fmt.Errorf("delete legacy snapshot: %w", safeFilesystemError(err)) | ||
| 63 | } | 92 | } | ||
| 93 | + | if err := ctx.Err(); err != nil { | |||
| 94 | + | return err | |||
| 95 | + | } | |||
| 64 | if err := r.syncDirectory(r.dir); err != nil { | 96 | if err := r.syncDirectory(r.dir); err != nil { | ||
| 65 | return fmt.Errorf("sync legacy snapshot directory: %w", safeFilesystemError(err)) | 97 | return fmt.Errorf("sync legacy snapshot directory: %w", safeFilesystemError(err)) | ||
| 66 | } | 98 | } | ||
M internal/adapters/snapshot/legacy_test.go +33 -0
| @@ -3,8 +3,10 @@ package snapshot | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | + | "fmt" | |
| 6 | 7 | "os" | |
| 7 | 8 | "path/filepath" | |
| 9 | + | "strings" | |
| 8 | 10 | "testing" | |
| 9 | 11 | ) | |
| 10 | 12 | ||
| @@ -32,6 +34,37 @@ func TestRepositoryLegacyReadsOnlyBoundedRootSnapshots(t *testing.T) { | |||
| 32 | 34 | } | |
| 33 | 35 | } | |
| 34 | 36 | ||
| 37 | + | func TestRepositoryLegacyRejectsTooManyAndTooLargeAggregateSnapshots(t *testing.T) { | |
| 38 | + | t.Run("count", func(t *testing.T) { | |
| 39 | + | repo := NewRepository(privateDir(t)) | |
| 40 | + | if err := os.Mkdir(repo.dir, 0o700); err != nil { | |
| 41 | + | t.Fatal(err) | |
| 42 | + | } | |
| 43 | + | for i := 0; i < maxLegacySnapshotFiles+1; i++ { | |
| 44 | + | if err := os.WriteFile(filepath.Join(repo.dir, fmt.Sprintf("%03d.snap", i)), []byte("legacy"), 0o600); err != nil { | |
| 45 | + | t.Fatal(err) | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | if _, err := repo.LoadLegacy(context.Background()); err == nil || !strings.Contains(err.Error(), "too many") { | |
| 49 | + | t.Fatalf("LoadLegacy error = %v, want actionable count limit", err) | |
| 50 | + | } | |
| 51 | + | }) | |
| 52 | + | t.Run("aggregate bytes", func(t *testing.T) { | |
| 53 | + | repo := NewRepository(privateDir(t)) | |
| 54 | + | if err := os.Mkdir(repo.dir, 0o700); err != nil { | |
| 55 | + | t.Fatal(err) | |
| 56 | + | } | |
| 57 | + | for i, size := range []int{maxLegacySnapshotBytes / 2, maxLegacySnapshotBytes/2 + 1} { | |
| 58 | + | if err := os.WriteFile(filepath.Join(repo.dir, fmt.Sprintf("%d.snap", i)), make([]byte, size), 0o600); err != nil { | |
| 59 | + | t.Fatal(err) | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | if _, err := repo.LoadLegacy(context.Background()); err == nil || !strings.Contains(err.Error(), "aggregate") { | |
| 63 | + | t.Fatalf("LoadLegacy error = %v, want actionable aggregate limit", err) | |
| 64 | + | } | |
| 65 | + | }) | |
| 66 | + | } | |
| 67 | + | ||
| 35 | 68 | func TestRepositoryDeleteLegacyMissingFileDoesNotRequireRoot(t *testing.T) { | |
| 36 | 69 | repo := NewRepository(privateDir(t)) | |
| 37 | 70 | if err := repo.DeleteLegacy(context.Background(), "missing"); err != nil { | |
| @@ -3,8 +3,10 @@ package snapshot | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | + | "fmt" | |||
| 6 | "os" | 7 | "os" | ||
| 7 | "path/filepath" | 8 | "path/filepath" | ||
| 9 | + | "strings" | |||
| 8 | "testing" | 10 | "testing" | ||
| 9 | ) | 11 | ) | ||
| 10 | 12 | ||||
| @@ -32,6 +34,37 @@ func TestRepositoryLegacyReadsOnlyBoundedRootSnapshots(t *testing.T) { | |||||
| 32 | } | 34 | } | ||
| 33 | } | 35 | } | ||
| 34 | 36 | ||||
| 37 | + | func TestRepositoryLegacyRejectsTooManyAndTooLargeAggregateSnapshots(t *testing.T) { | |||
| 38 | + | t.Run("count", func(t *testing.T) { | |||
| 39 | + | repo := NewRepository(privateDir(t)) | |||
| 40 | + | if err := os.Mkdir(repo.dir, 0o700); err != nil { | |||
| 41 | + | t.Fatal(err) | |||
| 42 | + | } | |||
| 43 | + | for i := 0; i < maxLegacySnapshotFiles+1; i++ { | |||
| 44 | + | if err := os.WriteFile(filepath.Join(repo.dir, fmt.Sprintf("%03d.snap", i)), []byte("legacy"), 0o600); err != nil { | |||
| 45 | + | t.Fatal(err) | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | if _, err := repo.LoadLegacy(context.Background()); err == nil || !strings.Contains(err.Error(), "too many") { | |||
| 49 | + | t.Fatalf("LoadLegacy error = %v, want actionable count limit", err) | |||
| 50 | + | } | |||
| 51 | + | }) | |||
| 52 | + | t.Run("aggregate bytes", func(t *testing.T) { | |||
| 53 | + | repo := NewRepository(privateDir(t)) | |||
| 54 | + | if err := os.Mkdir(repo.dir, 0o700); err != nil { | |||
| 55 | + | t.Fatal(err) | |||
| 56 | + | } | |||
| 57 | + | for i, size := range []int{maxLegacySnapshotBytes / 2, maxLegacySnapshotBytes/2 + 1} { | |||
| 58 | + | if err := os.WriteFile(filepath.Join(repo.dir, fmt.Sprintf("%d.snap", i)), make([]byte, size), 0o600); err != nil { | |||
| 59 | + | t.Fatal(err) | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | if _, err := repo.LoadLegacy(context.Background()); err == nil || !strings.Contains(err.Error(), "aggregate") { | |||
| 63 | + | t.Fatalf("LoadLegacy error = %v, want actionable aggregate limit", err) | |||
| 64 | + | } | |||
| 65 | + | }) | |||
| 66 | + | } | |||
| 67 | + | ||||
| 35 | func TestRepositoryDeleteLegacyMissingFileDoesNotRequireRoot(t *testing.T) { | 68 | func TestRepositoryDeleteLegacyMissingFileDoesNotRequireRoot(t *testing.T) { | ||
| 36 | repo := NewRepository(privateDir(t)) | 69 | repo := NewRepository(privateDir(t)) | ||
| 37 | if err := repo.DeleteLegacy(context.Background(), "missing"); err != nil { | 70 | if err := repo.DeleteLegacy(context.Background(), "missing"); err != nil { | ||
M internal/adapters/snapshot/load.go +3 -0
| @@ -65,6 +65,9 @@ func (r *Repository) Load(ctx context.Context, name string) (ports.SnapshotGener | |||
| 65 | 65 | lock := r.sessionLock(key) | |
| 66 | 66 | lock.Lock() | |
| 67 | 67 | defer lock.Unlock() | |
| 68 | + | if err := ctx.Err(); err != nil { | |
| 69 | + | return ports.SnapshotGeneration{}, err | |
| 70 | + | } | |
| 68 | 71 | return r.load(ctx, name, key) | |
| 69 | 72 | } | |
| 70 | 73 | ||
| @@ -65,6 +65,9 @@ func (r *Repository) Load(ctx context.Context, name string) (ports.SnapshotGener | |||||
| 65 | lock := r.sessionLock(key) | 65 | lock := r.sessionLock(key) | ||
| 66 | lock.Lock() | 66 | lock.Lock() | ||
| 67 | defer lock.Unlock() | 67 | defer lock.Unlock() | ||
| 68 | + | if err := ctx.Err(); err != nil { | |||
| 69 | + | return ports.SnapshotGeneration{}, err | |||
| 70 | + | } | |||
| 68 | return r.load(ctx, name, key) | 71 | return r.load(ctx, name, key) | ||
| 69 | } | 72 | } | ||
| 70 | 73 | ||||
M internal/adapters/snapshot/maintenance.go +195 -27
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | 6 | "fmt" | |
| 7 | + | "io" | |
| 7 | 8 | "os" | |
| 8 | 9 | "path/filepath" | |
| 9 | 10 | "strings" | |
| @@ -13,6 +14,9 @@ import ( | |||
| 13 | 14 | codec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 14 | 15 | ) | |
| 15 | 16 | ||
| 17 | + | // maintenanceBatch bounds both a single directory read and the number of | |
| 18 | + | // namespace entries removed by one maintenance pass. A pass never materializes | |
| 19 | + | // an unbounded directory in memory. | |
| 16 | 20 | const maintenanceBatch = 64 | |
| 17 | 21 | ||
| 18 | 22 | // Delete makes a session unavailable by durably moving it out of the canonical | |
| @@ -26,34 +30,86 @@ func (r *Repository) Delete(ctx context.Context, name string) error { | |||
| 26 | 30 | lock := r.sessionLock(key) | |
| 27 | 31 | lock.Lock() | |
| 28 | 32 | defer lock.Unlock() | |
| 33 | + | if err := ctx.Err(); err != nil { | |
| 34 | + | return err | |
| 35 | + | } | |
| 29 | 36 | ||
| 30 | 37 | canonical := r.sessionPath(key) | |
| 38 | + | sessions := filepath.Dir(canonical) | |
| 39 | + | // A prior rename may have succeeded while its parent sync failed. Complete | |
| 40 | + | // that durability boundary before considering a canonical directory: this | |
| 41 | + | // also leaves a newly recreated session untouched. | |
| 42 | + | pending, err := pendingQuarantine(sessions, key) | |
| 43 | + | if err != nil { | |
| 44 | + | return fmt.Errorf("read deleting snapshot session %q: %w", key, safeFilesystemError(err)) | |
| 45 | + | } | |
| 46 | + | if pending { | |
| 47 | + | if err := ctx.Err(); err != nil { | |
| 48 | + | return err | |
| 49 | + | } | |
| 50 | + | if err := r.syncDirectory(sessions); err != nil { | |
| 51 | + | return fmt.Errorf("sync deleted snapshot session directory %q: %w", key, safeFilesystemError(err)) | |
| 52 | + | } | |
| 53 | + | return nil | |
| 54 | + | } | |
| 31 | 55 | if _, err := os.Lstat(canonical); errors.Is(err, os.ErrNotExist) { | |
| 32 | 56 | return nil | |
| 33 | 57 | } else if err != nil { | |
| 34 | 58 | return fmt.Errorf("stat snapshot session %q: %w", key, safeFilesystemError(err)) | |
| 35 | 59 | } | |
| 36 | - | quarantine := filepath.Join(filepath.Dir(canonical), ".deleting-"+key+"-"+fmt.Sprintf("%d", time.Now().UnixNano())) | |
| 60 | + | quarantine := filepath.Join(sessions, ".deleting-"+key+"-"+fmt.Sprintf("%d", time.Now().UnixNano())) | |
| 37 | 61 | for attempt := 0; ; attempt++ { | |
| 38 | 62 | if _, err := os.Lstat(quarantine); errors.Is(err, os.ErrNotExist) { | |
| 39 | 63 | break | |
| 40 | 64 | } else if err != nil { | |
| 41 | 65 | return fmt.Errorf("stat deleting snapshot session %q: %w", key, safeFilesystemError(err)) | |
| 42 | 66 | } | |
| 43 | - | quarantine = filepath.Join(filepath.Dir(canonical), ".deleting-"+key+"-"+fmt.Sprintf("%d-%d", time.Now().UnixNano(), attempt)) | |
| 67 | + | quarantine = filepath.Join(sessions, ".deleting-"+key+"-"+fmt.Sprintf("%d-%d", time.Now().UnixNano(), attempt)) | |
| 68 | + | } | |
| 69 | + | if err := ctx.Err(); err != nil { | |
| 70 | + | return err | |
| 44 | 71 | } | |
| 45 | 72 | if err := r.rename(canonical, quarantine); err != nil { | |
| 46 | 73 | return fmt.Errorf("quarantine snapshot session %q: %w", key, safeFilesystemError(err)) | |
| 47 | 74 | } | |
| 48 | - | if err := r.syncDirectory(filepath.Dir(canonical)); err != nil { | |
| 75 | + | if err := ctx.Err(); err != nil { | |
| 76 | + | return err | |
| 77 | + | } | |
| 78 | + | if err := r.syncDirectory(sessions); err != nil { | |
| 49 | 79 | return fmt.Errorf("sync deleted snapshot session directory %q: %w", key, safeFilesystemError(err)) | |
| 50 | 80 | } | |
| 51 | 81 | return nil | |
| 52 | 82 | } | |
| 53 | 83 | ||
| 54 | - | // Maintain reaps a fixed number of stale entries. It serializes each session | |
| 55 | - | // with publication and loading so objects written by an in-flight publication | |
| 56 | - | // cannot be collected. | |
| 84 | + | func pendingQuarantine(dir, key string) (bool, error) { | |
| 85 | + | f, err := os.Open(dir) | |
| 86 | + | if errors.Is(err, os.ErrNotExist) { | |
| 87 | + | return false, nil | |
| 88 | + | } | |
| 89 | + | if err != nil { | |
| 90 | + | return false, err | |
| 91 | + | } | |
| 92 | + | defer f.Close() | |
| 93 | + | prefix := ".deleting-" + key + "-" | |
| 94 | + | for { | |
| 95 | + | entries, err := f.ReadDir(maintenanceBatch) | |
| 96 | + | for _, entry := range entries { | |
| 97 | + | if entry.IsDir() && strings.HasPrefix(entry.Name(), prefix) { | |
| 98 | + | return true, nil | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | if errors.Is(err, io.EOF) { | |
| 102 | + | return false, nil | |
| 103 | + | } | |
| 104 | + | if err != nil { | |
| 105 | + | return false, err | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // Maintain reaps a bounded amount of stale state. Every scan uses ReadDir with | |
| 111 | + | // a fixed batch, and a quarantine tree is peeled one entry at a time so an | |
| 112 | + | // interrupted pass can continue after another call or restart. | |
| 57 | 113 | func (r *Repository) Maintain(ctx context.Context) error { | |
| 58 | 114 | if err := ctx.Err(); err != nil { | |
| 59 | 115 | return err | |
| @@ -62,8 +118,11 @@ func (r *Repository) Maintain(ctx context.Context) error { | |||
| 62 | 118 | if err := r.removeTemps(ctx, r.dir, &budget); err != nil { | |
| 63 | 119 | return err | |
| 64 | 120 | } | |
| 121 | + | if budget == 0 { | |
| 122 | + | return nil | |
| 123 | + | } | |
| 65 | 124 | sessions := filepath.Join(r.dir, repositorySessionsDir) | |
| 66 | - | entries, err := os.ReadDir(sessions) | |
| 125 | + | entries, err := readDirBatch(sessions, maintenanceBatch) | |
| 67 | 126 | if errors.Is(err, os.ErrNotExist) { | |
| 68 | 127 | return nil | |
| 69 | 128 | } | |
| @@ -79,13 +138,18 @@ func (r *Repository) Maintain(ctx context.Context) error { | |||
| 79 | 138 | } | |
| 80 | 139 | path := filepath.Join(sessions, entry.Name()) | |
| 81 | 140 | if isQuarantine(entry.Name()) { | |
| 82 | - | if err := r.removeTree(path); err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 141 | + | changed, err := r.removeTreeBatch(ctx, path, &budget) | |
| 142 | + | if err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 83 | 143 | return fmt.Errorf("remove snapshot quarantine %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | |
| 84 | 144 | } | |
| 85 | - | if err := r.syncDirectory(sessions); err != nil { | |
| 86 | - | return fmt.Errorf("sync snapshot maintenance directory for %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | |
| 145 | + | if changed { | |
| 146 | + | if err := ctx.Err(); err != nil { | |
| 147 | + | return err | |
| 148 | + | } | |
| 149 | + | if err := r.syncDirectory(sessions); err != nil { | |
| 150 | + | return fmt.Errorf("sync snapshot maintenance directory for %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | |
| 151 | + | } | |
| 87 | 152 | } | |
| 88 | - | budget-- | |
| 89 | 153 | continue | |
| 90 | 154 | } | |
| 91 | 155 | if !entry.IsDir() || !canonicalSessionKey(entry.Name()) { | |
| @@ -93,6 +157,10 @@ func (r *Repository) Maintain(ctx context.Context) error { | |||
| 93 | 157 | } | |
| 94 | 158 | lock := r.sessionLock(entry.Name()) | |
| 95 | 159 | lock.Lock() | |
| 160 | + | if err := ctx.Err(); err != nil { | |
| 161 | + | lock.Unlock() | |
| 162 | + | return err | |
| 163 | + | } | |
| 96 | 164 | err := r.maintainSession(ctx, entry.Name(), &budget) | |
| 97 | 165 | lock.Unlock() | |
| 98 | 166 | if err != nil { | |
| @@ -104,12 +172,81 @@ func (r *Repository) Maintain(ctx context.Context) error { | |||
| 104 | 172 | ||
| 105 | 173 | func isQuarantine(name string) bool { return strings.HasPrefix(name, ".deleting-") } | |
| 106 | 174 | ||
| 175 | + | func readDirBatch(dir string, n int) ([]os.DirEntry, error) { | |
| 176 | + | f, err := os.Open(dir) | |
| 177 | + | if err != nil { | |
| 178 | + | return nil, err | |
| 179 | + | } | |
| 180 | + | entries, readErr := f.ReadDir(n) | |
| 181 | + | closeErr := f.Close() | |
| 182 | + | if readErr != nil && !errors.Is(readErr, io.EOF) { | |
| 183 | + | return nil, readErr | |
| 184 | + | } | |
| 185 | + | if closeErr != nil { | |
| 186 | + | return nil, closeErr | |
| 187 | + | } | |
| 188 | + | return entries, nil | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | func (r *Repository) removeTreeBatch(ctx context.Context, path string, budget *int) (bool, error) { | |
| 192 | + | changed := false | |
| 193 | + | for *budget > 0 { | |
| 194 | + | did, err := r.removeTreeStep(ctx, path, budget) | |
| 195 | + | if err != nil || !did { | |
| 196 | + | return changed, err | |
| 197 | + | } | |
| 198 | + | changed = true | |
| 199 | + | } | |
| 200 | + | return changed, nil | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func (r *Repository) removeTreeStep(ctx context.Context, path string, budget *int) (bool, error) { | |
| 204 | + | if *budget == 0 { | |
| 205 | + | return false, nil | |
| 206 | + | } | |
| 207 | + | info, err := os.Lstat(path) | |
| 208 | + | if errors.Is(err, os.ErrNotExist) { | |
| 209 | + | return false, nil | |
| 210 | + | } | |
| 211 | + | if err != nil { | |
| 212 | + | return false, err | |
| 213 | + | } | |
| 214 | + | if !info.IsDir() { | |
| 215 | + | if err := ctx.Err(); err != nil { | |
| 216 | + | return false, err | |
| 217 | + | } | |
| 218 | + | if err := r.remove(path); err != nil { | |
| 219 | + | return false, err | |
| 220 | + | } | |
| 221 | + | *budget-- | |
| 222 | + | return true, nil | |
| 223 | + | } | |
| 224 | + | entries, err := readDirBatch(path, 1) | |
| 225 | + | if err != nil { | |
| 226 | + | return false, err | |
| 227 | + | } | |
| 228 | + | if len(entries) == 0 { | |
| 229 | + | if err := ctx.Err(); err != nil { | |
| 230 | + | return false, err | |
| 231 | + | } | |
| 232 | + | if err := r.remove(path); err != nil { | |
| 233 | + | return false, err | |
| 234 | + | } | |
| 235 | + | *budget-- | |
| 236 | + | return true, nil | |
| 237 | + | } | |
| 238 | + | return r.removeTreeStep(ctx, filepath.Join(path, entries[0].Name()), budget) | |
| 239 | + | } | |
| 240 | + | ||
| 107 | 241 | func (r *Repository) maintainSession(ctx context.Context, key string, budget *int) error { | |
| 242 | + | if err := ctx.Err(); err != nil { | |
| 243 | + | return err | |
| 244 | + | } | |
| 108 | 245 | if err := r.removeTemps(ctx, filepath.Join(r.sessionPath(key), repositoryGenerations), budget); err != nil { | |
| 109 | 246 | return err | |
| 110 | 247 | } | |
| 111 | 248 | objectRoot := filepath.Join(r.sessionPath(key), repositoryObjectsDir) | |
| 112 | - | shards, err := os.ReadDir(objectRoot) | |
| 249 | + | shards, err := readDirBatch(objectRoot, maintenanceBatch) | |
| 113 | 250 | if err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 114 | 251 | return fmt.Errorf("read snapshot object shards: %w", safeFilesystemError(err)) | |
| 115 | 252 | } | |
| @@ -124,7 +261,7 @@ func (r *Repository) maintainSession(ctx context.Context, key string, budget *in | |||
| 124 | 261 | } | |
| 125 | 262 | } | |
| 126 | 263 | ||
| 127 | - | generations, err := r.generationNumbers(key) | |
| 264 | + | generations, err := r.generationNumbersBatch(key) | |
| 128 | 265 | if err != nil { | |
| 129 | 266 | return fmt.Errorf("read snapshot generations: %w", safeFilesystemError(err)) | |
| 130 | 267 | } | |
| @@ -155,7 +292,7 @@ func (r *Repository) maintainSession(ctx context.Context, key string, budget *in | |||
| 155 | 292 | } | |
| 156 | 293 | ||
| 157 | 294 | retained := make(map[uint64]bool, 2) | |
| 158 | - | for _, state := range states { // generationNumbers is newest first. | |
| 295 | + | for _, state := range states { | |
| 159 | 296 | if state.complete && complete < 2 { | |
| 160 | 297 | retained[state.generation] = true | |
| 161 | 298 | complete++ | |
| @@ -166,25 +303,59 @@ func (r *Repository) maintainSession(ctx context.Context, key string, budget *in | |||
| 166 | 303 | keep := retained[state.generation] || !state.complete | |
| 167 | 304 | if !keep && *budget > 0 { | |
| 168 | 305 | path := r.manifestPath(key, state.generation) | |
| 306 | + | if err := ctx.Err(); err != nil { | |
| 307 | + | return err | |
| 308 | + | } | |
| 169 | 309 | if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 170 | 310 | return fmt.Errorf("remove snapshot manifest %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | |
| 171 | 311 | } | |
| 312 | + | if err := ctx.Err(); err != nil { | |
| 313 | + | return err | |
| 314 | + | } | |
| 172 | 315 | if err := r.syncDirectory(filepath.Dir(path)); err != nil { | |
| 173 | 316 | return fmt.Errorf("sync snapshot maintenance directory for %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | |
| 174 | 317 | } | |
| 175 | 318 | *budget-- | |
| 176 | 319 | continue | |
| 177 | 320 | } | |
| 178 | - | for digest := range state.refs { // incomplete manifests protect in-flight objects. | |
| 321 | + | for digest := range state.refs { | |
| 179 | 322 | referenced[digest] = struct{}{} | |
| 180 | 323 | } | |
| 181 | 324 | } | |
| 182 | 325 | return r.removeUnreferencedObjects(ctx, key, referenced, budget) | |
| 183 | 326 | } | |
| 184 | 327 | ||
| 328 | + | func (r *Repository) generationNumbersBatch(key string) ([]uint64, error) { | |
| 329 | + | entries, err := readDirBatch(filepath.Join(r.sessionPath(key), repositoryGenerations), maintenanceBatch) | |
| 330 | + | if errors.Is(err, os.ErrNotExist) { | |
| 331 | + | return nil, nil | |
| 332 | + | } | |
| 333 | + | if err != nil { | |
| 334 | + | return nil, err | |
| 335 | + | } | |
| 336 | + | numbers := make([]uint64, 0, len(entries)) | |
| 337 | + | for _, entry := range entries { | |
| 338 | + | if !entry.IsDir() { | |
| 339 | + | if generation, ok := parseGenerationFilename(entry.Name()); ok { | |
| 340 | + | numbers = append(numbers, generation) | |
| 341 | + | } | |
| 342 | + | } | |
| 343 | + | } | |
| 344 | + | // Generation file names are zero-padded, but keep the explicit comparison | |
| 345 | + | // in case the representation changes. | |
| 346 | + | for i := range numbers { | |
| 347 | + | for j := i + 1; j < len(numbers); j++ { | |
| 348 | + | if numbers[j] > numbers[i] { | |
| 349 | + | numbers[i], numbers[j] = numbers[j], numbers[i] | |
| 350 | + | } | |
| 351 | + | } | |
| 352 | + | } | |
| 353 | + | return numbers, nil | |
| 354 | + | } | |
| 355 | + | ||
| 185 | 356 | func (r *Repository) removeUnreferencedObjects(ctx context.Context, key string, referenced map[ports.SnapshotDigest]struct{}, budget *int) error { | |
| 186 | 357 | root := filepath.Join(r.sessionPath(key), repositoryObjectsDir) | |
| 187 | - | shards, err := os.ReadDir(root) | |
| 358 | + | shards, err := readDirBatch(root, maintenanceBatch) | |
| 188 | 359 | if errors.Is(err, os.ErrNotExist) { | |
| 189 | 360 | return nil | |
| 190 | 361 | } | |
| @@ -199,7 +370,7 @@ func (r *Repository) removeUnreferencedObjects(ctx context.Context, key string, | |||
| 199 | 370 | continue | |
| 200 | 371 | } | |
| 201 | 372 | dir := filepath.Join(root, shard.Name()) | |
| 202 | - | objects, err := os.ReadDir(dir) | |
| 373 | + | objects, err := readDirBatch(dir, maintenanceBatch) | |
| 203 | 374 | if err != nil { | |
| 204 | 375 | return fmt.Errorf("read snapshot object shard: %w", safeFilesystemError(err)) | |
| 205 | 376 | } | |
| @@ -221,6 +392,9 @@ func (r *Repository) removeUnreferencedObjects(ctx context.Context, key string, | |||
| 221 | 392 | if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 222 | 393 | return fmt.Errorf("remove snapshot object %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | |
| 223 | 394 | } | |
| 395 | + | if err := ctx.Err(); err != nil { | |
| 396 | + | return err | |
| 397 | + | } | |
| 224 | 398 | if err := r.syncDirectory(dir); err != nil { | |
| 225 | 399 | return fmt.Errorf("sync snapshot maintenance directory for %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | |
| 226 | 400 | } | |
| @@ -234,7 +408,7 @@ func (r *Repository) removeTemps(ctx context.Context, dir string, budget *int) e | |||
| 234 | 408 | if *budget == 0 { | |
| 235 | 409 | return nil | |
| 236 | 410 | } | |
| 237 | - | entries, err := os.ReadDir(dir) | |
| 411 | + | entries, err := readDirBatch(dir, maintenanceBatch) | |
| 238 | 412 | if errors.Is(err, os.ErrNotExist) { | |
| 239 | 413 | return nil | |
| 240 | 414 | } | |
| @@ -255,6 +429,9 @@ func (r *Repository) removeTemps(ctx context.Context, dir string, budget *int) e | |||
| 255 | 429 | if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 256 | 430 | return fmt.Errorf("remove snapshot maintenance file %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | |
| 257 | 431 | } | |
| 432 | + | if err := ctx.Err(); err != nil { | |
| 433 | + | return err | |
| 434 | + | } | |
| 258 | 435 | if err := r.syncDirectory(dir); err != nil { | |
| 259 | 436 | return fmt.Errorf("sync snapshot maintenance directory for %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | |
| 260 | 437 | } | |
| @@ -263,15 +440,6 @@ func (r *Repository) removeTemps(ctx context.Context, dir string, budget *int) e | |||
| 263 | 440 | return nil | |
| 264 | 441 | } | |
| 265 | 442 | ||
| 266 | - | func (r *Repository) removeTree(path string) error { | |
| 267 | - | if r.hooks.remove != nil { | |
| 268 | - | if err := r.hooks.remove(path); err != nil { | |
| 269 | - | return err | |
| 270 | - | } | |
| 271 | - | } | |
| 272 | - | return os.RemoveAll(path) | |
| 273 | - | } | |
| 274 | - | ||
| 275 | 443 | // maintenancePath avoids exposing the repository root while retaining a | |
| 276 | 444 | // path-safe, actionable location in errors. | |
| 277 | 445 | func maintenancePath(root, path string) string { | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | + | "io" | |||
| 7 | "os" | 8 | "os" | ||
| 8 | "path/filepath" | 9 | "path/filepath" | ||
| 9 | "strings" | 10 | "strings" | ||
| @@ -13,6 +14,9 @@ import ( | |||||
| 13 | codec "github.com/bnema/vev/internal/usecase/snapshot" | 14 | codec "github.com/bnema/vev/internal/usecase/snapshot" | ||
| 14 | ) | 15 | ) | ||
| 15 | 16 | ||||
| 17 | + | // maintenanceBatch bounds both a single directory read and the number of | |||
| 18 | + | // namespace entries removed by one maintenance pass. A pass never materializes | |||
| 19 | + | // an unbounded directory in memory. | |||
| 16 | const maintenanceBatch = 64 | 20 | const maintenanceBatch = 64 | ||
| 17 | 21 | ||||
| 18 | // Delete makes a session unavailable by durably moving it out of the canonical | 22 | // Delete makes a session unavailable by durably moving it out of the canonical | ||
| @@ -26,34 +30,86 @@ func (r *Repository) Delete(ctx context.Context, name string) error { | |||||
| 26 | lock := r.sessionLock(key) | 30 | lock := r.sessionLock(key) | ||
| 27 | lock.Lock() | 31 | lock.Lock() | ||
| 28 | defer lock.Unlock() | 32 | defer lock.Unlock() | ||
| 33 | + | if err := ctx.Err(); err != nil { | |||
| 34 | + | return err | |||
| 35 | + | } | |||
| 29 | 36 | ||||
| 30 | canonical := r.sessionPath(key) | 37 | canonical := r.sessionPath(key) | ||
| 38 | + | sessions := filepath.Dir(canonical) | |||
| 39 | + | // A prior rename may have succeeded while its parent sync failed. Complete | |||
| 40 | + | // that durability boundary before considering a canonical directory: this | |||
| 41 | + | // also leaves a newly recreated session untouched. | |||
| 42 | + | pending, err := pendingQuarantine(sessions, key) | |||
| 43 | + | if err != nil { | |||
| 44 | + | return fmt.Errorf("read deleting snapshot session %q: %w", key, safeFilesystemError(err)) | |||
| 45 | + | } | |||
| 46 | + | if pending { | |||
| 47 | + | if err := ctx.Err(); err != nil { | |||
| 48 | + | return err | |||
| 49 | + | } | |||
| 50 | + | if err := r.syncDirectory(sessions); err != nil { | |||
| 51 | + | return fmt.Errorf("sync deleted snapshot session directory %q: %w", key, safeFilesystemError(err)) | |||
| 52 | + | } | |||
| 53 | + | return nil | |||
| 54 | + | } | |||
| 31 | if _, err := os.Lstat(canonical); errors.Is(err, os.ErrNotExist) { | 55 | if _, err := os.Lstat(canonical); errors.Is(err, os.ErrNotExist) { | ||
| 32 | return nil | 56 | return nil | ||
| 33 | } else if err != nil { | 57 | } else if err != nil { | ||
| 34 | return fmt.Errorf("stat snapshot session %q: %w", key, safeFilesystemError(err)) | 58 | return fmt.Errorf("stat snapshot session %q: %w", key, safeFilesystemError(err)) | ||
| 35 | } | 59 | } | ||
| 36 | - | quarantine := filepath.Join(filepath.Dir(canonical), ".deleting-"+key+"-"+fmt.Sprintf("%d", time.Now().UnixNano())) | 60 | + | quarantine := filepath.Join(sessions, ".deleting-"+key+"-"+fmt.Sprintf("%d", time.Now().UnixNano())) |
| 37 | for attempt := 0; ; attempt++ { | 61 | for attempt := 0; ; attempt++ { | ||
| 38 | if _, err := os.Lstat(quarantine); errors.Is(err, os.ErrNotExist) { | 62 | if _, err := os.Lstat(quarantine); errors.Is(err, os.ErrNotExist) { | ||
| 39 | break | 63 | break | ||
| 40 | } else if err != nil { | 64 | } else if err != nil { | ||
| 41 | return fmt.Errorf("stat deleting snapshot session %q: %w", key, safeFilesystemError(err)) | 65 | return fmt.Errorf("stat deleting snapshot session %q: %w", key, safeFilesystemError(err)) | ||
| 42 | } | 66 | } | ||
| 43 | - | quarantine = filepath.Join(filepath.Dir(canonical), ".deleting-"+key+"-"+fmt.Sprintf("%d-%d", time.Now().UnixNano(), attempt)) | 67 | + | quarantine = filepath.Join(sessions, ".deleting-"+key+"-"+fmt.Sprintf("%d-%d", time.Now().UnixNano(), attempt)) |
| 68 | + | } | |||
| 69 | + | if err := ctx.Err(); err != nil { | |||
| 70 | + | return err | |||
| 44 | } | 71 | } | ||
| 45 | if err := r.rename(canonical, quarantine); err != nil { | 72 | if err := r.rename(canonical, quarantine); err != nil { | ||
| 46 | return fmt.Errorf("quarantine snapshot session %q: %w", key, safeFilesystemError(err)) | 73 | return fmt.Errorf("quarantine snapshot session %q: %w", key, safeFilesystemError(err)) | ||
| 47 | } | 74 | } | ||
| 48 | - | if err := r.syncDirectory(filepath.Dir(canonical)); err != nil { | 75 | + | if err := ctx.Err(); err != nil { |
| 76 | + | return err | |||
| 77 | + | } | |||
| 78 | + | if err := r.syncDirectory(sessions); err != nil { | |||
| 49 | return fmt.Errorf("sync deleted snapshot session directory %q: %w", key, safeFilesystemError(err)) | 79 | return fmt.Errorf("sync deleted snapshot session directory %q: %w", key, safeFilesystemError(err)) | ||
| 50 | } | 80 | } | ||
| 51 | return nil | 81 | return nil | ||
| 52 | } | 82 | } | ||
| 53 | 83 | ||||
| 54 | - | // Maintain reaps a fixed number of stale entries. It serializes each session | 84 | + | func pendingQuarantine(dir, key string) (bool, error) { |
| 55 | - | // with publication and loading so objects written by an in-flight publication | 85 | + | f, err := os.Open(dir) |
| 56 | - | // cannot be collected. | 86 | + | if errors.Is(err, os.ErrNotExist) { |
| 87 | + | return false, nil | |||
| 88 | + | } | |||
| 89 | + | if err != nil { | |||
| 90 | + | return false, err | |||
| 91 | + | } | |||
| 92 | + | defer f.Close() | |||
| 93 | + | prefix := ".deleting-" + key + "-" | |||
| 94 | + | for { | |||
| 95 | + | entries, err := f.ReadDir(maintenanceBatch) | |||
| 96 | + | for _, entry := range entries { | |||
| 97 | + | if entry.IsDir() && strings.HasPrefix(entry.Name(), prefix) { | |||
| 98 | + | return true, nil | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | if errors.Is(err, io.EOF) { | |||
| 102 | + | return false, nil | |||
| 103 | + | } | |||
| 104 | + | if err != nil { | |||
| 105 | + | return false, err | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | // Maintain reaps a bounded amount of stale state. Every scan uses ReadDir with | |||
| 111 | + | // a fixed batch, and a quarantine tree is peeled one entry at a time so an | |||
| 112 | + | // interrupted pass can continue after another call or restart. | |||
| 57 | func (r *Repository) Maintain(ctx context.Context) error { | 113 | func (r *Repository) Maintain(ctx context.Context) error { | ||
| 58 | if err := ctx.Err(); err != nil { | 114 | if err := ctx.Err(); err != nil { | ||
| 59 | return err | 115 | return err | ||
| @@ -62,8 +118,11 @@ func (r *Repository) Maintain(ctx context.Context) error { | |||||
| 62 | if err := r.removeTemps(ctx, r.dir, &budget); err != nil { | 118 | if err := r.removeTemps(ctx, r.dir, &budget); err != nil { | ||
| 63 | return err | 119 | return err | ||
| 64 | } | 120 | } | ||
| 121 | + | if budget == 0 { | |||
| 122 | + | return nil | |||
| 123 | + | } | |||
| 65 | sessions := filepath.Join(r.dir, repositorySessionsDir) | 124 | sessions := filepath.Join(r.dir, repositorySessionsDir) | ||
| 66 | - | entries, err := os.ReadDir(sessions) | 125 | + | entries, err := readDirBatch(sessions, maintenanceBatch) |
| 67 | if errors.Is(err, os.ErrNotExist) { | 126 | if errors.Is(err, os.ErrNotExist) { | ||
| 68 | return nil | 127 | return nil | ||
| 69 | } | 128 | } | ||
| @@ -79,13 +138,18 @@ func (r *Repository) Maintain(ctx context.Context) error { | |||||
| 79 | } | 138 | } | ||
| 80 | path := filepath.Join(sessions, entry.Name()) | 139 | path := filepath.Join(sessions, entry.Name()) | ||
| 81 | if isQuarantine(entry.Name()) { | 140 | if isQuarantine(entry.Name()) { | ||
| 82 | - | if err := r.removeTree(path); err != nil && !errors.Is(err, os.ErrNotExist) { | 141 | + | changed, err := r.removeTreeBatch(ctx, path, &budget) |
| 142 | + | if err != nil && !errors.Is(err, os.ErrNotExist) { | |||
| 83 | return fmt.Errorf("remove snapshot quarantine %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | 143 | return fmt.Errorf("remove snapshot quarantine %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | ||
| 84 | } | 144 | } | ||
| 85 | - | if err := r.syncDirectory(sessions); err != nil { | 145 | + | if changed { |
| 86 | - | return fmt.Errorf("sync snapshot maintenance directory for %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | 146 | + | if err := ctx.Err(); err != nil { |
| 147 | + | return err | |||
| 148 | + | } | |||
| 149 | + | if err := r.syncDirectory(sessions); err != nil { | |||
| 150 | + | return fmt.Errorf("sync snapshot maintenance directory for %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | |||
| 151 | + | } | |||
| 87 | } | 152 | } | ||
| 88 | - | budget-- | |||
| 89 | continue | 153 | continue | ||
| 90 | } | 154 | } | ||
| 91 | if !entry.IsDir() || !canonicalSessionKey(entry.Name()) { | 155 | if !entry.IsDir() || !canonicalSessionKey(entry.Name()) { | ||
| @@ -93,6 +157,10 @@ func (r *Repository) Maintain(ctx context.Context) error { | |||||
| 93 | } | 157 | } | ||
| 94 | lock := r.sessionLock(entry.Name()) | 158 | lock := r.sessionLock(entry.Name()) | ||
| 95 | lock.Lock() | 159 | lock.Lock() | ||
| 160 | + | if err := ctx.Err(); err != nil { | |||
| 161 | + | lock.Unlock() | |||
| 162 | + | return err | |||
| 163 | + | } | |||
| 96 | err := r.maintainSession(ctx, entry.Name(), &budget) | 164 | err := r.maintainSession(ctx, entry.Name(), &budget) | ||
| 97 | lock.Unlock() | 165 | lock.Unlock() | ||
| 98 | if err != nil { | 166 | if err != nil { | ||
| @@ -104,12 +172,81 @@ func (r *Repository) Maintain(ctx context.Context) error { | |||||
| 104 | 172 | ||||
| 105 | func isQuarantine(name string) bool { return strings.HasPrefix(name, ".deleting-") } | 173 | func isQuarantine(name string) bool { return strings.HasPrefix(name, ".deleting-") } | ||
| 106 | 174 | ||||
| 175 | + | func readDirBatch(dir string, n int) ([]os.DirEntry, error) { | |||
| 176 | + | f, err := os.Open(dir) | |||
| 177 | + | if err != nil { | |||
| 178 | + | return nil, err | |||
| 179 | + | } | |||
| 180 | + | entries, readErr := f.ReadDir(n) | |||
| 181 | + | closeErr := f.Close() | |||
| 182 | + | if readErr != nil && !errors.Is(readErr, io.EOF) { | |||
| 183 | + | return nil, readErr | |||
| 184 | + | } | |||
| 185 | + | if closeErr != nil { | |||
| 186 | + | return nil, closeErr | |||
| 187 | + | } | |||
| 188 | + | return entries, nil | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | func (r *Repository) removeTreeBatch(ctx context.Context, path string, budget *int) (bool, error) { | |||
| 192 | + | changed := false | |||
| 193 | + | for *budget > 0 { | |||
| 194 | + | did, err := r.removeTreeStep(ctx, path, budget) | |||
| 195 | + | if err != nil || !did { | |||
| 196 | + | return changed, err | |||
| 197 | + | } | |||
| 198 | + | changed = true | |||
| 199 | + | } | |||
| 200 | + | return changed, nil | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | func (r *Repository) removeTreeStep(ctx context.Context, path string, budget *int) (bool, error) { | |||
| 204 | + | if *budget == 0 { | |||
| 205 | + | return false, nil | |||
| 206 | + | } | |||
| 207 | + | info, err := os.Lstat(path) | |||
| 208 | + | if errors.Is(err, os.ErrNotExist) { | |||
| 209 | + | return false, nil | |||
| 210 | + | } | |||
| 211 | + | if err != nil { | |||
| 212 | + | return false, err | |||
| 213 | + | } | |||
| 214 | + | if !info.IsDir() { | |||
| 215 | + | if err := ctx.Err(); err != nil { | |||
| 216 | + | return false, err | |||
| 217 | + | } | |||
| 218 | + | if err := r.remove(path); err != nil { | |||
| 219 | + | return false, err | |||
| 220 | + | } | |||
| 221 | + | *budget-- | |||
| 222 | + | return true, nil | |||
| 223 | + | } | |||
| 224 | + | entries, err := readDirBatch(path, 1) | |||
| 225 | + | if err != nil { | |||
| 226 | + | return false, err | |||
| 227 | + | } | |||
| 228 | + | if len(entries) == 0 { | |||
| 229 | + | if err := ctx.Err(); err != nil { | |||
| 230 | + | return false, err | |||
| 231 | + | } | |||
| 232 | + | if err := r.remove(path); err != nil { | |||
| 233 | + | return false, err | |||
| 234 | + | } | |||
| 235 | + | *budget-- | |||
| 236 | + | return true, nil | |||
| 237 | + | } | |||
| 238 | + | return r.removeTreeStep(ctx, filepath.Join(path, entries[0].Name()), budget) | |||
| 239 | + | } | |||
| 240 | + | ||||
| 107 | func (r *Repository) maintainSession(ctx context.Context, key string, budget *int) error { | 241 | func (r *Repository) maintainSession(ctx context.Context, key string, budget *int) error { | ||
| 242 | + | if err := ctx.Err(); err != nil { | |||
| 243 | + | return err | |||
| 244 | + | } | |||
| 108 | if err := r.removeTemps(ctx, filepath.Join(r.sessionPath(key), repositoryGenerations), budget); err != nil { | 245 | if err := r.removeTemps(ctx, filepath.Join(r.sessionPath(key), repositoryGenerations), budget); err != nil { | ||
| 109 | return err | 246 | return err | ||
| 110 | } | 247 | } | ||
| 111 | objectRoot := filepath.Join(r.sessionPath(key), repositoryObjectsDir) | 248 | objectRoot := filepath.Join(r.sessionPath(key), repositoryObjectsDir) | ||
| 112 | - | shards, err := os.ReadDir(objectRoot) | 249 | + | shards, err := readDirBatch(objectRoot, maintenanceBatch) |
| 113 | if err != nil && !errors.Is(err, os.ErrNotExist) { | 250 | if err != nil && !errors.Is(err, os.ErrNotExist) { | ||
| 114 | return fmt.Errorf("read snapshot object shards: %w", safeFilesystemError(err)) | 251 | return fmt.Errorf("read snapshot object shards: %w", safeFilesystemError(err)) | ||
| 115 | } | 252 | } | ||
| @@ -124,7 +261,7 @@ func (r *Repository) maintainSession(ctx context.Context, key string, budget *in | |||||
| 124 | } | 261 | } | ||
| 125 | } | 262 | } | ||
| 126 | 263 | ||||
| 127 | - | generations, err := r.generationNumbers(key) | 264 | + | generations, err := r.generationNumbersBatch(key) |
| 128 | if err != nil { | 265 | if err != nil { | ||
| 129 | return fmt.Errorf("read snapshot generations: %w", safeFilesystemError(err)) | 266 | return fmt.Errorf("read snapshot generations: %w", safeFilesystemError(err)) | ||
| 130 | } | 267 | } | ||
| @@ -155,7 +292,7 @@ func (r *Repository) maintainSession(ctx context.Context, key string, budget *in | |||||
| 155 | } | 292 | } | ||
| 156 | 293 | ||||
| 157 | retained := make(map[uint64]bool, 2) | 294 | retained := make(map[uint64]bool, 2) | ||
| 158 | - | for _, state := range states { // generationNumbers is newest first. | 295 | + | for _, state := range states { |
| 159 | if state.complete && complete < 2 { | 296 | if state.complete && complete < 2 { | ||
| 160 | retained[state.generation] = true | 297 | retained[state.generation] = true | ||
| 161 | complete++ | 298 | complete++ | ||
| @@ -166,25 +303,59 @@ func (r *Repository) maintainSession(ctx context.Context, key string, budget *in | |||||
| 166 | keep := retained[state.generation] || !state.complete | 303 | keep := retained[state.generation] || !state.complete | ||
| 167 | if !keep && *budget > 0 { | 304 | if !keep && *budget > 0 { | ||
| 168 | path := r.manifestPath(key, state.generation) | 305 | path := r.manifestPath(key, state.generation) | ||
| 306 | + | if err := ctx.Err(); err != nil { | |||
| 307 | + | return err | |||
| 308 | + | } | |||
| 169 | if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { | 309 | if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { | ||
| 170 | return fmt.Errorf("remove snapshot manifest %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | 310 | return fmt.Errorf("remove snapshot manifest %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | ||
| 171 | } | 311 | } | ||
| 312 | + | if err := ctx.Err(); err != nil { | |||
| 313 | + | return err | |||
| 314 | + | } | |||
| 172 | if err := r.syncDirectory(filepath.Dir(path)); err != nil { | 315 | if err := r.syncDirectory(filepath.Dir(path)); err != nil { | ||
| 173 | return fmt.Errorf("sync snapshot maintenance directory for %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | 316 | return fmt.Errorf("sync snapshot maintenance directory for %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | ||
| 174 | } | 317 | } | ||
| 175 | *budget-- | 318 | *budget-- | ||
| 176 | continue | 319 | continue | ||
| 177 | } | 320 | } | ||
| 178 | - | for digest := range state.refs { // incomplete manifests protect in-flight objects. | 321 | + | for digest := range state.refs { |
| 179 | referenced[digest] = struct{}{} | 322 | referenced[digest] = struct{}{} | ||
| 180 | } | 323 | } | ||
| 181 | } | 324 | } | ||
| 182 | return r.removeUnreferencedObjects(ctx, key, referenced, budget) | 325 | return r.removeUnreferencedObjects(ctx, key, referenced, budget) | ||
| 183 | } | 326 | } | ||
| 184 | 327 | ||||
| 328 | + | func (r *Repository) generationNumbersBatch(key string) ([]uint64, error) { | |||
| 329 | + | entries, err := readDirBatch(filepath.Join(r.sessionPath(key), repositoryGenerations), maintenanceBatch) | |||
| 330 | + | if errors.Is(err, os.ErrNotExist) { | |||
| 331 | + | return nil, nil | |||
| 332 | + | } | |||
| 333 | + | if err != nil { | |||
| 334 | + | return nil, err | |||
| 335 | + | } | |||
| 336 | + | numbers := make([]uint64, 0, len(entries)) | |||
| 337 | + | for _, entry := range entries { | |||
| 338 | + | if !entry.IsDir() { | |||
| 339 | + | if generation, ok := parseGenerationFilename(entry.Name()); ok { | |||
| 340 | + | numbers = append(numbers, generation) | |||
| 341 | + | } | |||
| 342 | + | } | |||
| 343 | + | } | |||
| 344 | + | // Generation file names are zero-padded, but keep the explicit comparison | |||
| 345 | + | // in case the representation changes. | |||
| 346 | + | for i := range numbers { | |||
| 347 | + | for j := i + 1; j < len(numbers); j++ { | |||
| 348 | + | if numbers[j] > numbers[i] { | |||
| 349 | + | numbers[i], numbers[j] = numbers[j], numbers[i] | |||
| 350 | + | } | |||
| 351 | + | } | |||
| 352 | + | } | |||
| 353 | + | return numbers, nil | |||
| 354 | + | } | |||
| 355 | + | ||||
| 185 | func (r *Repository) removeUnreferencedObjects(ctx context.Context, key string, referenced map[ports.SnapshotDigest]struct{}, budget *int) error { | 356 | func (r *Repository) removeUnreferencedObjects(ctx context.Context, key string, referenced map[ports.SnapshotDigest]struct{}, budget *int) error { | ||
| 186 | root := filepath.Join(r.sessionPath(key), repositoryObjectsDir) | 357 | root := filepath.Join(r.sessionPath(key), repositoryObjectsDir) | ||
| 187 | - | shards, err := os.ReadDir(root) | 358 | + | shards, err := readDirBatch(root, maintenanceBatch) |
| 188 | if errors.Is(err, os.ErrNotExist) { | 359 | if errors.Is(err, os.ErrNotExist) { | ||
| 189 | return nil | 360 | return nil | ||
| 190 | } | 361 | } | ||
| @@ -199,7 +370,7 @@ func (r *Repository) removeUnreferencedObjects(ctx context.Context, key string, | |||||
| 199 | continue | 370 | continue | ||
| 200 | } | 371 | } | ||
| 201 | dir := filepath.Join(root, shard.Name()) | 372 | dir := filepath.Join(root, shard.Name()) | ||
| 202 | - | objects, err := os.ReadDir(dir) | 373 | + | objects, err := readDirBatch(dir, maintenanceBatch) |
| 203 | if err != nil { | 374 | if err != nil { | ||
| 204 | return fmt.Errorf("read snapshot object shard: %w", safeFilesystemError(err)) | 375 | return fmt.Errorf("read snapshot object shard: %w", safeFilesystemError(err)) | ||
| 205 | } | 376 | } | ||
| @@ -221,6 +392,9 @@ func (r *Repository) removeUnreferencedObjects(ctx context.Context, key string, | |||||
| 221 | if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { | 392 | if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { | ||
| 222 | return fmt.Errorf("remove snapshot object %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | 393 | return fmt.Errorf("remove snapshot object %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | ||
| 223 | } | 394 | } | ||
| 395 | + | if err := ctx.Err(); err != nil { | |||
| 396 | + | return err | |||
| 397 | + | } | |||
| 224 | if err := r.syncDirectory(dir); err != nil { | 398 | if err := r.syncDirectory(dir); err != nil { | ||
| 225 | return fmt.Errorf("sync snapshot maintenance directory for %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | 399 | return fmt.Errorf("sync snapshot maintenance directory for %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | ||
| 226 | } | 400 | } | ||
| @@ -234,7 +408,7 @@ func (r *Repository) removeTemps(ctx context.Context, dir string, budget *int) e | |||||
| 234 | if *budget == 0 { | 408 | if *budget == 0 { | ||
| 235 | return nil | 409 | return nil | ||
| 236 | } | 410 | } | ||
| 237 | - | entries, err := os.ReadDir(dir) | 411 | + | entries, err := readDirBatch(dir, maintenanceBatch) |
| 238 | if errors.Is(err, os.ErrNotExist) { | 412 | if errors.Is(err, os.ErrNotExist) { | ||
| 239 | return nil | 413 | return nil | ||
| 240 | } | 414 | } | ||
| @@ -255,6 +429,9 @@ func (r *Repository) removeTemps(ctx context.Context, dir string, budget *int) e | |||||
| 255 | if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { | 429 | if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { | ||
| 256 | return fmt.Errorf("remove snapshot maintenance file %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | 430 | return fmt.Errorf("remove snapshot maintenance file %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | ||
| 257 | } | 431 | } | ||
| 432 | + | if err := ctx.Err(); err != nil { | |||
| 433 | + | return err | |||
| 434 | + | } | |||
| 258 | if err := r.syncDirectory(dir); err != nil { | 435 | if err := r.syncDirectory(dir); err != nil { | ||
| 259 | return fmt.Errorf("sync snapshot maintenance directory for %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | 436 | return fmt.Errorf("sync snapshot maintenance directory for %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | ||
| 260 | } | 437 | } | ||
| @@ -263,15 +440,6 @@ func (r *Repository) removeTemps(ctx context.Context, dir string, budget *int) e | |||||
| 263 | return nil | 440 | return nil | ||
| 264 | } | 441 | } | ||
| 265 | 442 | ||||
| 266 | - | func (r *Repository) removeTree(path string) error { | |||
| 267 | - | if r.hooks.remove != nil { | |||
| 268 | - | if err := r.hooks.remove(path); err != nil { | |||
| 269 | - | return err | |||
| 270 | - | } | |||
| 271 | - | } | |||
| 272 | - | return os.RemoveAll(path) | |||
| 273 | - | } | |||
| 274 | - | ||||
| 275 | // maintenancePath avoids exposing the repository root while retaining a | 443 | // maintenancePath avoids exposing the repository root while retaining a | ||
| 276 | // path-safe, actionable location in errors. | 444 | // path-safe, actionable location in errors. | ||
| 277 | func maintenancePath(root, path string) string { | 445 | func maintenancePath(root, path string) string { | ||
M internal/adapters/snapshot/maintenance_test.go +128 -0
| @@ -140,6 +140,134 @@ func TestRepositoryMaintainPreservesIncompleteManifestReferences(t *testing.T) { | |||
| 140 | 140 | } | |
| 141 | 141 | } | |
| 142 | 142 | ||
| 143 | + | func TestRepositoryCancellationAfterSessionLockWaitPreventsMutations(t *testing.T) { | |
| 144 | + | t.Run("publish", func(t *testing.T) { | |
| 145 | + | repo := NewRepository(privateDir(t)) | |
| 146 | + | key := sessionKey("named") | |
| 147 | + | lock := repo.sessionLock(key) | |
| 148 | + | lock.Lock() | |
| 149 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 150 | + | started := make(chan struct{}) | |
| 151 | + | done := make(chan error, 1) | |
| 152 | + | go func() { | |
| 153 | + | close(started) | |
| 154 | + | done <- repo.Publish(ctx, repositoryPublication(t, "named", 1, []byte("state"))) | |
| 155 | + | }() | |
| 156 | + | <-started | |
| 157 | + | cancel() | |
| 158 | + | lock.Unlock() | |
| 159 | + | if err := <-done; !errors.Is(err, context.Canceled) { | |
| 160 | + | t.Fatalf("Publish error = %v, want canceled", err) | |
| 161 | + | } | |
| 162 | + | if _, err := os.Lstat(repo.dir); !errors.Is(err, os.ErrNotExist) { | |
| 163 | + | t.Fatalf("Publish mutated canceled repository: %v", err) | |
| 164 | + | } | |
| 165 | + | }) | |
| 166 | + | t.Run("load and delete", func(t *testing.T) { | |
| 167 | + | repo := NewRepository(privateDir(t)) | |
| 168 | + | if err := repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))); err != nil { | |
| 169 | + | t.Fatal(err) | |
| 170 | + | } | |
| 171 | + | key := sessionKey("named") | |
| 172 | + | for _, operation := range []struct { | |
| 173 | + | name string | |
| 174 | + | run func(context.Context) error | |
| 175 | + | }{ | |
| 176 | + | {"load", func(ctx context.Context) error { _, err := repo.Load(ctx, "named"); return err }}, | |
| 177 | + | {"delete", func(ctx context.Context) error { return repo.Delete(ctx, "named") }}, | |
| 178 | + | } { | |
| 179 | + | t.Run(operation.name, func(t *testing.T) { | |
| 180 | + | lock := repo.sessionLock(key) | |
| 181 | + | lock.Lock() | |
| 182 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 183 | + | started := make(chan struct{}) | |
| 184 | + | done := make(chan error, 1) | |
| 185 | + | go func() { close(started); done <- operation.run(ctx) }() | |
| 186 | + | <-started | |
| 187 | + | cancel() | |
| 188 | + | lock.Unlock() | |
| 189 | + | if err := <-done; !errors.Is(err, context.Canceled) { | |
| 190 | + | t.Fatalf("%s error = %v, want canceled", operation.name, err) | |
| 191 | + | } | |
| 192 | + | if _, err := repo.Load(context.Background(), "named"); err != nil { | |
| 193 | + | t.Fatalf("%s mutated session: %v", operation.name, err) | |
| 194 | + | } | |
| 195 | + | }) | |
| 196 | + | } | |
| 197 | + | }) | |
| 198 | + | t.Run("maintain", func(t *testing.T) { | |
| 199 | + | repo := NewRepository(privateDir(t)) | |
| 200 | + | if err := repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))); err != nil { | |
| 201 | + | t.Fatal(err) | |
| 202 | + | } | |
| 203 | + | lock := repo.sessionLock(sessionKey("named")) | |
| 204 | + | lock.Lock() | |
| 205 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 206 | + | started := make(chan struct{}) | |
| 207 | + | done := make(chan error, 1) | |
| 208 | + | go func() { close(started); done <- repo.Maintain(ctx) }() | |
| 209 | + | <-started | |
| 210 | + | cancel() | |
| 211 | + | lock.Unlock() | |
| 212 | + | if err := <-done; !errors.Is(err, context.Canceled) { | |
| 213 | + | t.Fatalf("Maintain error = %v, want canceled", err) | |
| 214 | + | } | |
| 215 | + | if _, err := repo.Load(context.Background(), "named"); err != nil { | |
| 216 | + | t.Fatalf("Maintain mutated session: %v", err) | |
| 217 | + | } | |
| 218 | + | }) | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | func TestRepositoryDeleteRetriesPendingQuarantineSyncWithoutDeletingRecreatedSession(t *testing.T) { | |
| 222 | + | repo := NewRepository(privateDir(t)) | |
| 223 | + | if err := repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("old"))); err != nil { | |
| 224 | + | t.Fatal(err) | |
| 225 | + | } | |
| 226 | + | injected := errors.New("sessions sync") | |
| 227 | + | repo.hooks.syncDirectory = func(string) error { return injected } | |
| 228 | + | if err := repo.Delete(context.Background(), "named"); !errors.Is(err, injected) { | |
| 229 | + | t.Fatalf("Delete error = %v, want sync failure", err) | |
| 230 | + | } | |
| 231 | + | repo.hooks.syncDirectory = nil | |
| 232 | + | if err := repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("new"))); err != nil { | |
| 233 | + | t.Fatal(err) | |
| 234 | + | } | |
| 235 | + | if err := repo.Delete(context.Background(), "named"); err != nil { | |
| 236 | + | t.Fatalf("retry Delete: %v", err) | |
| 237 | + | } | |
| 238 | + | if _, err := repo.Load(context.Background(), "named"); err != nil { | |
| 239 | + | t.Fatalf("recreated session was deleted: %v", err) | |
| 240 | + | } | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | func TestRepositoryMaintainResumesNestedQuarantineWithinBatch(t *testing.T) { | |
| 244 | + | repo := NewRepository(privateDir(t)) | |
| 245 | + | quarantine := filepath.Join(repo.dir, repositorySessionsDir, ".deleting-named-test") | |
| 246 | + | for i := 0; i < maintenanceBatch+1; i++ { | |
| 247 | + | path := filepath.Join(quarantine, "nested", string(rune('a'+i))) | |
| 248 | + | if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { | |
| 249 | + | t.Fatal(err) | |
| 250 | + | } | |
| 251 | + | if err := os.WriteFile(path, []byte("stale"), 0o600); err != nil { | |
| 252 | + | t.Fatal(err) | |
| 253 | + | } | |
| 254 | + | } | |
| 255 | + | if err := repo.Maintain(context.Background()); err != nil { | |
| 256 | + | t.Fatal(err) | |
| 257 | + | } | |
| 258 | + | if _, err := os.Lstat(quarantine); errors.Is(err, os.ErrNotExist) { | |
| 259 | + | t.Fatalf("quarantine after one bounded call = %v, want remaining tree", err) | |
| 260 | + | } | |
| 261 | + | for i := 0; i < 4; i++ { | |
| 262 | + | if err := repo.Maintain(context.Background()); err != nil { | |
| 263 | + | t.Fatal(err) | |
| 264 | + | } | |
| 265 | + | } | |
| 266 | + | if _, err := os.Lstat(quarantine); !errors.Is(err, os.ErrNotExist) { | |
| 267 | + | t.Fatalf("quarantine after resumed maintenance = %v, want removed", err) | |
| 268 | + | } | |
| 269 | + | } | |
| 270 | + | ||
| 143 | 271 | func TestRepositoryMaintainUsesFixedRemovalBatch(t *testing.T) { | |
| 144 | 272 | repo := NewRepository(privateDir(t)) | |
| 145 | 273 | if err := os.Mkdir(repo.dir, 0o700); err != nil { | |
| @@ -140,6 +140,134 @@ func TestRepositoryMaintainPreservesIncompleteManifestReferences(t *testing.T) { | |||||
| 140 | } | 140 | } | ||
| 141 | } | 141 | } | ||
| 142 | 142 | ||||
| 143 | + | func TestRepositoryCancellationAfterSessionLockWaitPreventsMutations(t *testing.T) { | |||
| 144 | + | t.Run("publish", func(t *testing.T) { | |||
| 145 | + | repo := NewRepository(privateDir(t)) | |||
| 146 | + | key := sessionKey("named") | |||
| 147 | + | lock := repo.sessionLock(key) | |||
| 148 | + | lock.Lock() | |||
| 149 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 150 | + | started := make(chan struct{}) | |||
| 151 | + | done := make(chan error, 1) | |||
| 152 | + | go func() { | |||
| 153 | + | close(started) | |||
| 154 | + | done <- repo.Publish(ctx, repositoryPublication(t, "named", 1, []byte("state"))) | |||
| 155 | + | }() | |||
| 156 | + | <-started | |||
| 157 | + | cancel() | |||
| 158 | + | lock.Unlock() | |||
| 159 | + | if err := <-done; !errors.Is(err, context.Canceled) { | |||
| 160 | + | t.Fatalf("Publish error = %v, want canceled", err) | |||
| 161 | + | } | |||
| 162 | + | if _, err := os.Lstat(repo.dir); !errors.Is(err, os.ErrNotExist) { | |||
| 163 | + | t.Fatalf("Publish mutated canceled repository: %v", err) | |||
| 164 | + | } | |||
| 165 | + | }) | |||
| 166 | + | t.Run("load and delete", func(t *testing.T) { | |||
| 167 | + | repo := NewRepository(privateDir(t)) | |||
| 168 | + | if err := repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))); err != nil { | |||
| 169 | + | t.Fatal(err) | |||
| 170 | + | } | |||
| 171 | + | key := sessionKey("named") | |||
| 172 | + | for _, operation := range []struct { | |||
| 173 | + | name string | |||
| 174 | + | run func(context.Context) error | |||
| 175 | + | }{ | |||
| 176 | + | {"load", func(ctx context.Context) error { _, err := repo.Load(ctx, "named"); return err }}, | |||
| 177 | + | {"delete", func(ctx context.Context) error { return repo.Delete(ctx, "named") }}, | |||
| 178 | + | } { | |||
| 179 | + | t.Run(operation.name, func(t *testing.T) { | |||
| 180 | + | lock := repo.sessionLock(key) | |||
| 181 | + | lock.Lock() | |||
| 182 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 183 | + | started := make(chan struct{}) | |||
| 184 | + | done := make(chan error, 1) | |||
| 185 | + | go func() { close(started); done <- operation.run(ctx) }() | |||
| 186 | + | <-started | |||
| 187 | + | cancel() | |||
| 188 | + | lock.Unlock() | |||
| 189 | + | if err := <-done; !errors.Is(err, context.Canceled) { | |||
| 190 | + | t.Fatalf("%s error = %v, want canceled", operation.name, err) | |||
| 191 | + | } | |||
| 192 | + | if _, err := repo.Load(context.Background(), "named"); err != nil { | |||
| 193 | + | t.Fatalf("%s mutated session: %v", operation.name, err) | |||
| 194 | + | } | |||
| 195 | + | }) | |||
| 196 | + | } | |||
| 197 | + | }) | |||
| 198 | + | t.Run("maintain", func(t *testing.T) { | |||
| 199 | + | repo := NewRepository(privateDir(t)) | |||
| 200 | + | if err := repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))); err != nil { | |||
| 201 | + | t.Fatal(err) | |||
| 202 | + | } | |||
| 203 | + | lock := repo.sessionLock(sessionKey("named")) | |||
| 204 | + | lock.Lock() | |||
| 205 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 206 | + | started := make(chan struct{}) | |||
| 207 | + | done := make(chan error, 1) | |||
| 208 | + | go func() { close(started); done <- repo.Maintain(ctx) }() | |||
| 209 | + | <-started | |||
| 210 | + | cancel() | |||
| 211 | + | lock.Unlock() | |||
| 212 | + | if err := <-done; !errors.Is(err, context.Canceled) { | |||
| 213 | + | t.Fatalf("Maintain error = %v, want canceled", err) | |||
| 214 | + | } | |||
| 215 | + | if _, err := repo.Load(context.Background(), "named"); err != nil { | |||
| 216 | + | t.Fatalf("Maintain mutated session: %v", err) | |||
| 217 | + | } | |||
| 218 | + | }) | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | func TestRepositoryDeleteRetriesPendingQuarantineSyncWithoutDeletingRecreatedSession(t *testing.T) { | |||
| 222 | + | repo := NewRepository(privateDir(t)) | |||
| 223 | + | if err := repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("old"))); err != nil { | |||
| 224 | + | t.Fatal(err) | |||
| 225 | + | } | |||
| 226 | + | injected := errors.New("sessions sync") | |||
| 227 | + | repo.hooks.syncDirectory = func(string) error { return injected } | |||
| 228 | + | if err := repo.Delete(context.Background(), "named"); !errors.Is(err, injected) { | |||
| 229 | + | t.Fatalf("Delete error = %v, want sync failure", err) | |||
| 230 | + | } | |||
| 231 | + | repo.hooks.syncDirectory = nil | |||
| 232 | + | if err := repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("new"))); err != nil { | |||
| 233 | + | t.Fatal(err) | |||
| 234 | + | } | |||
| 235 | + | if err := repo.Delete(context.Background(), "named"); err != nil { | |||
| 236 | + | t.Fatalf("retry Delete: %v", err) | |||
| 237 | + | } | |||
| 238 | + | if _, err := repo.Load(context.Background(), "named"); err != nil { | |||
| 239 | + | t.Fatalf("recreated session was deleted: %v", err) | |||
| 240 | + | } | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | func TestRepositoryMaintainResumesNestedQuarantineWithinBatch(t *testing.T) { | |||
| 244 | + | repo := NewRepository(privateDir(t)) | |||
| 245 | + | quarantine := filepath.Join(repo.dir, repositorySessionsDir, ".deleting-named-test") | |||
| 246 | + | for i := 0; i < maintenanceBatch+1; i++ { | |||
| 247 | + | path := filepath.Join(quarantine, "nested", string(rune('a'+i))) | |||
| 248 | + | if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { | |||
| 249 | + | t.Fatal(err) | |||
| 250 | + | } | |||
| 251 | + | if err := os.WriteFile(path, []byte("stale"), 0o600); err != nil { | |||
| 252 | + | t.Fatal(err) | |||
| 253 | + | } | |||
| 254 | + | } | |||
| 255 | + | if err := repo.Maintain(context.Background()); err != nil { | |||
| 256 | + | t.Fatal(err) | |||
| 257 | + | } | |||
| 258 | + | if _, err := os.Lstat(quarantine); errors.Is(err, os.ErrNotExist) { | |||
| 259 | + | t.Fatalf("quarantine after one bounded call = %v, want remaining tree", err) | |||
| 260 | + | } | |||
| 261 | + | for i := 0; i < 4; i++ { | |||
| 262 | + | if err := repo.Maintain(context.Background()); err != nil { | |||
| 263 | + | t.Fatal(err) | |||
| 264 | + | } | |||
| 265 | + | } | |||
| 266 | + | if _, err := os.Lstat(quarantine); !errors.Is(err, os.ErrNotExist) { | |||
| 267 | + | t.Fatalf("quarantine after resumed maintenance = %v, want removed", err) | |||
| 268 | + | } | |||
| 269 | + | } | |||
| 270 | + | ||||
| 143 | func TestRepositoryMaintainUsesFixedRemovalBatch(t *testing.T) { | 271 | func TestRepositoryMaintainUsesFixedRemovalBatch(t *testing.T) { | ||
| 144 | repo := NewRepository(privateDir(t)) | 272 | repo := NewRepository(privateDir(t)) | ||
| 145 | if err := os.Mkdir(repo.dir, 0o700); err != nil { | 273 | if err := os.Mkdir(repo.dir, 0o700); err != nil { | ||
M internal/adapters/snapshot/publish.go +15 -0
| @@ -17,11 +17,17 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl | |||
| 17 | 17 | lock := r.sessionLock(key) | |
| 18 | 18 | lock.Lock() | |
| 19 | 19 | defer lock.Unlock() | |
| 20 | + | if err := ctx.Err(); err != nil { | |
| 21 | + | return err | |
| 22 | + | } | |
| 20 | 23 | ||
| 21 | 24 | refs, supplied, err := validatePublication(publication) | |
| 22 | 25 | if err != nil { | |
| 23 | 26 | return err | |
| 24 | 27 | } | |
| 28 | + | if err := ctx.Err(); err != nil { | |
| 29 | + | return err | |
| 30 | + | } | |
| 25 | 31 | if err := r.ensureSession(key); err != nil { | |
| 26 | 32 | return err | |
| 27 | 33 | } | |
| @@ -61,6 +67,9 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl | |||
| 61 | 67 | return err | |
| 62 | 68 | } | |
| 63 | 69 | } | |
| 70 | + | if err := ctx.Err(); err != nil { | |
| 71 | + | return err | |
| 72 | + | } | |
| 64 | 73 | if err := r.writeImmutable(path, object.Data, func(existing []byte) error { | |
| 65 | 74 | if sha256.Sum256(existing) != digest || !validObject(existing, ref) { | |
| 66 | 75 | return fmt.Errorf("existing immutable object is invalid") | |
| @@ -86,6 +95,9 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl | |||
| 86 | 95 | return err | |
| 87 | 96 | } | |
| 88 | 97 | } | |
| 98 | + | if err := ctx.Err(); err != nil { | |
| 99 | + | return err | |
| 100 | + | } | |
| 89 | 101 | if err := r.writeImmutable(manifestPath, publication.Manifest, func(existing []byte) error { | |
| 90 | 102 | if !equalBytes(existing, publication.Manifest) { | |
| 91 | 103 | return fmt.Errorf("manifest generation %d: immutable conflict", publication.Generation) | |
| @@ -100,6 +112,9 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl | |||
| 100 | 112 | return err | |
| 101 | 113 | } | |
| 102 | 114 | } | |
| 115 | + | if err := ctx.Err(); err != nil { | |
| 116 | + | return err | |
| 117 | + | } | |
| 103 | 118 | if err := r.writeMutable(r.headPath(key), marshalHead(publication.Generation, sha256.Sum256(publication.Manifest))); err != nil { | |
| 104 | 119 | return fmt.Errorf("write HEAD: %w", err) | |
| 105 | 120 | } | |
| @@ -17,11 +17,17 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl | |||||
| 17 | lock := r.sessionLock(key) | 17 | lock := r.sessionLock(key) | ||
| 18 | lock.Lock() | 18 | lock.Lock() | ||
| 19 | defer lock.Unlock() | 19 | defer lock.Unlock() | ||
| 20 | + | if err := ctx.Err(); err != nil { | |||
| 21 | + | return err | |||
| 22 | + | } | |||
| 20 | 23 | ||||
| 21 | refs, supplied, err := validatePublication(publication) | 24 | refs, supplied, err := validatePublication(publication) | ||
| 22 | if err != nil { | 25 | if err != nil { | ||
| 23 | return err | 26 | return err | ||
| 24 | } | 27 | } | ||
| 28 | + | if err := ctx.Err(); err != nil { | |||
| 29 | + | return err | |||
| 30 | + | } | |||
| 25 | if err := r.ensureSession(key); err != nil { | 31 | if err := r.ensureSession(key); err != nil { | ||
| 26 | return err | 32 | return err | ||
| 27 | } | 33 | } | ||
| @@ -61,6 +67,9 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl | |||||
| 61 | return err | 67 | return err | ||
| 62 | } | 68 | } | ||
| 63 | } | 69 | } | ||
| 70 | + | if err := ctx.Err(); err != nil { | |||
| 71 | + | return err | |||
| 72 | + | } | |||
| 64 | if err := r.writeImmutable(path, object.Data, func(existing []byte) error { | 73 | if err := r.writeImmutable(path, object.Data, func(existing []byte) error { | ||
| 65 | if sha256.Sum256(existing) != digest || !validObject(existing, ref) { | 74 | if sha256.Sum256(existing) != digest || !validObject(existing, ref) { | ||
| 66 | return fmt.Errorf("existing immutable object is invalid") | 75 | return fmt.Errorf("existing immutable object is invalid") | ||
| @@ -86,6 +95,9 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl | |||||
| 86 | return err | 95 | return err | ||
| 87 | } | 96 | } | ||
| 88 | } | 97 | } | ||
| 98 | + | if err := ctx.Err(); err != nil { | |||
| 99 | + | return err | |||
| 100 | + | } | |||
| 89 | if err := r.writeImmutable(manifestPath, publication.Manifest, func(existing []byte) error { | 101 | if err := r.writeImmutable(manifestPath, publication.Manifest, func(existing []byte) error { | ||
| 90 | if !equalBytes(existing, publication.Manifest) { | 102 | if !equalBytes(existing, publication.Manifest) { | ||
| 91 | return fmt.Errorf("manifest generation %d: immutable conflict", publication.Generation) | 103 | return fmt.Errorf("manifest generation %d: immutable conflict", publication.Generation) | ||
| @@ -100,6 +112,9 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl | |||||
| 100 | return err | 112 | return err | ||
| 101 | } | 113 | } | ||
| 102 | } | 114 | } | ||
| 115 | + | if err := ctx.Err(); err != nil { | |||
| 116 | + | return err | |||
| 117 | + | } | |||
| 103 | if err := r.writeMutable(r.headPath(key), marshalHead(publication.Generation, sha256.Sum256(publication.Manifest))); err != nil { | 118 | if err := r.writeMutable(r.headPath(key), marshalHead(publication.Generation, sha256.Sum256(publication.Manifest))); err != nil { | ||
| 104 | return fmt.Errorf("write HEAD: %w", err) | 119 | return fmt.Errorf("write HEAD: %w", err) | ||
| 105 | } | 120 | } | ||
M internal/adapters/snapshot/repository.go +4 -0
| @@ -13,6 +13,10 @@ const ( | |||
| 13 | 13 | repositoryHead = "HEAD" | |
| 14 | 14 | generationWidth = 20 | |
| 15 | 15 | maxRepositoryRead = maxSnapshotFileSize | |
| 16 | + | // Legacy import is deliberately smaller than the per-file parser ceiling: | |
| 17 | + | // importing old snapshots must not accumulate an unbounded startup payload. | |
| 18 | + | maxLegacySnapshotFiles = 64 | |
| 19 | + | maxLegacySnapshotBytes = 8 << 20 | |
| 16 | 20 | ) | |
| 17 | 21 | ||
| 18 | 22 | // Repository is the crash-safe, content-addressed session snapshot store. | |
| @@ -13,6 +13,10 @@ const ( | |||||
| 13 | repositoryHead = "HEAD" | 13 | repositoryHead = "HEAD" | ||
| 14 | generationWidth = 20 | 14 | generationWidth = 20 | ||
| 15 | maxRepositoryRead = maxSnapshotFileSize | 15 | maxRepositoryRead = maxSnapshotFileSize | ||
| 16 | + | // Legacy import is deliberately smaller than the per-file parser ceiling: | |||
| 17 | + | // importing old snapshots must not accumulate an unbounded startup payload. | |||
| 18 | + | maxLegacySnapshotFiles = 64 | |||
| 19 | + | maxLegacySnapshotBytes = 8 << 20 | |||
| 16 | ) | 20 | ) | ||
| 17 | 21 | ||||
| 18 | // Repository is the crash-safe, content-addressed session snapshot store. | 22 | // Repository is the crash-safe, content-addressed session snapshot store. | ||