fix(app): make offline snapshot deletion retryable
- commit
- bd4a6ac8ab944c61f021dd1b40276abd3458df3a
- author
- brice · · 2026-07-21 23:01 UTC
- parent
- 6c78be9
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +323 -11
M internal/adapters/snapshot/legacy.go +90 -1
| @@ -63,6 +63,13 @@ func (r *Repository) LoadLegacy(ctx context.Context) ([]ports.LegacySnapshot, er | |||
| 63 | 63 | if strings.HasPrefix(name, "@") || !safeNameRE.MatchString(name) { | |
| 64 | 64 | name = "" | |
| 65 | 65 | } | |
| 66 | + | killed, tombstoneErr := r.tombstoned(name) | |
| 67 | + | if tombstoneErr != nil { | |
| 68 | + | return nil, fmt.Errorf("read killed session marker: %w", safeFilesystemError(tombstoneErr)) | |
| 69 | + | } | |
| 70 | + | if killed { | |
| 71 | + | continue | |
| 72 | + | } | |
| 66 | 73 | out = append(out, ports.LegacySnapshot{Name: name, Data: data}) | |
| 67 | 74 | } | |
| 68 | 75 | if errors.Is(readErr, io.EOF) { | |
| @@ -97,7 +104,17 @@ func (r *Repository) DeleteLegacy(ctx context.Context, name string) error { | |||
| 97 | 104 | ||
| 98 | 105 | path := filepath.Join(r.dir, filename) | |
| 99 | 106 | if err := r.remove(path); errors.Is(err, os.ErrNotExist) { | |
| 100 | - | return nil | |
| 107 | + | // Tombstones make a process-restart retry distinguish an already-unlinked | |
| 108 | + | // legacy source from a source that was absent before the kill. Re-sync the | |
| 109 | + | // root to complete the original unlink's durability boundary. | |
| 110 | + | killed, tombstoneErr := r.tombstoned(name) | |
| 111 | + | if tombstoneErr != nil { | |
| 112 | + | return fmt.Errorf("read killed session marker: %w", safeFilesystemError(tombstoneErr)) | |
| 113 | + | } | |
| 114 | + | if !killed { | |
| 115 | + | return nil | |
| 116 | + | } | |
| 117 | + | return r.syncPendingLegacy(ctx, filename) | |
| 101 | 118 | } else if err != nil { | |
| 102 | 119 | return fmt.Errorf("delete legacy snapshot: %w", safeFilesystemError(err)) | |
| 103 | 120 | } | |
| @@ -105,6 +122,78 @@ func (r *Repository) DeleteLegacy(ctx context.Context, name string) error { | |||
| 105 | 122 | return r.syncPendingLegacy(ctx, filename) | |
| 106 | 123 | } | |
| 107 | 124 | ||
| 125 | + | // Tombstone durably marks name as logically killed before either snapshot | |
| 126 | + | // source is deleted. Startup consults this marker before importing legacy or | |
| 127 | + | // restoring incremental state, so a partial offline kill cannot resurrect it. | |
| 128 | + | func (r *Repository) Tombstone(ctx context.Context, name string) error { | |
| 129 | + | if err := ctx.Err(); err != nil { | |
| 130 | + | return err | |
| 131 | + | } | |
| 132 | + | key := sessionKey(name) | |
| 133 | + | lock := r.sessionLock(key) | |
| 134 | + | lock.Lock() | |
| 135 | + | defer lock.Unlock() | |
| 136 | + | if err := ctx.Err(); err != nil { | |
| 137 | + | return err | |
| 138 | + | } | |
| 139 | + | if err := r.ensurePrivateDirectory(r.dir); err != nil { | |
| 140 | + | return fmt.Errorf("create killed session marker directory: %w", safeFilesystemError(err)) | |
| 141 | + | } | |
| 142 | + | path := filepath.Join(r.dir, tombstoneFilename(key)) | |
| 143 | + | if err := r.writeImmutable(path, []byte(name), func(data []byte) error { | |
| 144 | + | if string(data) != name { | |
| 145 | + | return fmt.Errorf("killed session marker identity mismatch") | |
| 146 | + | } | |
| 147 | + | return nil | |
| 148 | + | }); err != nil { | |
| 149 | + | return fmt.Errorf("write killed session marker: %w", safeFilesystemError(err)) | |
| 150 | + | } | |
| 151 | + | return nil | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | // DeleteTombstone removes a marker only after both sources have been durably | |
| 155 | + | // deleted. A missing marker is idempotent for offline-kill retries. | |
| 156 | + | func (r *Repository) DeleteTombstone(ctx context.Context, name string) error { | |
| 157 | + | if err := ctx.Err(); err != nil { | |
| 158 | + | return err | |
| 159 | + | } | |
| 160 | + | key := sessionKey(name) | |
| 161 | + | lock := r.sessionLock(key) | |
| 162 | + | lock.Lock() | |
| 163 | + | defer lock.Unlock() | |
| 164 | + | if err := ctx.Err(); err != nil { | |
| 165 | + | return err | |
| 166 | + | } | |
| 167 | + | if err := r.remove(filepath.Join(r.dir, tombstoneFilename(key))); errors.Is(err, os.ErrNotExist) { | |
| 168 | + | return nil | |
| 169 | + | } else if err != nil { | |
| 170 | + | return fmt.Errorf("delete killed session marker: %w", safeFilesystemError(err)) | |
| 171 | + | } | |
| 172 | + | if err := r.syncDirectory(r.dir); err != nil { | |
| 173 | + | return fmt.Errorf("sync killed session marker directory: %w", safeFilesystemError(err)) | |
| 174 | + | } | |
| 175 | + | return nil | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func tombstoneFilename(key string) string { return ".killed-" + key } | |
| 179 | + | ||
| 180 | + | func (r *Repository) tombstoned(name string) (bool, error) { | |
| 181 | + | if name == "" { | |
| 182 | + | return false, nil | |
| 183 | + | } | |
| 184 | + | data, err := readBounded(filepath.Join(r.dir, tombstoneFilename(sessionKey(name)))) | |
| 185 | + | if errors.Is(err, os.ErrNotExist) { | |
| 186 | + | return false, nil | |
| 187 | + | } | |
| 188 | + | if err != nil { | |
| 189 | + | return false, err | |
| 190 | + | } | |
| 191 | + | if string(data) != name { | |
| 192 | + | return false, fmt.Errorf("killed session marker identity mismatch") | |
| 193 | + | } | |
| 194 | + | return true, nil | |
| 195 | + | } | |
| 196 | + | ||
| 108 | 197 | func (r *Repository) syncPendingLegacy(ctx context.Context, filename string) error { | |
| 109 | 198 | if err := ctx.Err(); err != nil { | |
| 110 | 199 | return err | |
| @@ -63,6 +63,13 @@ func (r *Repository) LoadLegacy(ctx context.Context) ([]ports.LegacySnapshot, er | |||||
| 63 | if strings.HasPrefix(name, "@") || !safeNameRE.MatchString(name) { | 63 | if strings.HasPrefix(name, "@") || !safeNameRE.MatchString(name) { | ||
| 64 | name = "" | 64 | name = "" | ||
| 65 | } | 65 | } | ||
| 66 | + | killed, tombstoneErr := r.tombstoned(name) | |||
| 67 | + | if tombstoneErr != nil { | |||
| 68 | + | return nil, fmt.Errorf("read killed session marker: %w", safeFilesystemError(tombstoneErr)) | |||
| 69 | + | } | |||
| 70 | + | if killed { | |||
| 71 | + | continue | |||
| 72 | + | } | |||
| 66 | out = append(out, ports.LegacySnapshot{Name: name, Data: data}) | 73 | out = append(out, ports.LegacySnapshot{Name: name, Data: data}) | ||
| 67 | } | 74 | } | ||
| 68 | if errors.Is(readErr, io.EOF) { | 75 | if errors.Is(readErr, io.EOF) { | ||
| @@ -97,7 +104,17 @@ func (r *Repository) DeleteLegacy(ctx context.Context, name string) error { | |||||
| 97 | 104 | ||||
| 98 | path := filepath.Join(r.dir, filename) | 105 | path := filepath.Join(r.dir, filename) | ||
| 99 | if err := r.remove(path); errors.Is(err, os.ErrNotExist) { | 106 | if err := r.remove(path); errors.Is(err, os.ErrNotExist) { | ||
| 100 | - | return nil | 107 | + | // Tombstones make a process-restart retry distinguish an already-unlinked |
| 108 | + | // legacy source from a source that was absent before the kill. Re-sync the | |||
| 109 | + | // root to complete the original unlink's durability boundary. | |||
| 110 | + | killed, tombstoneErr := r.tombstoned(name) | |||
| 111 | + | if tombstoneErr != nil { | |||
| 112 | + | return fmt.Errorf("read killed session marker: %w", safeFilesystemError(tombstoneErr)) | |||
| 113 | + | } | |||
| 114 | + | if !killed { | |||
| 115 | + | return nil | |||
| 116 | + | } | |||
| 117 | + | return r.syncPendingLegacy(ctx, filename) | |||
| 101 | } else if err != nil { | 118 | } else if err != nil { | ||
| 102 | return fmt.Errorf("delete legacy snapshot: %w", safeFilesystemError(err)) | 119 | return fmt.Errorf("delete legacy snapshot: %w", safeFilesystemError(err)) | ||
| 103 | } | 120 | } | ||
| @@ -105,6 +122,78 @@ func (r *Repository) DeleteLegacy(ctx context.Context, name string) error { | |||||
| 105 | return r.syncPendingLegacy(ctx, filename) | 122 | return r.syncPendingLegacy(ctx, filename) | ||
| 106 | } | 123 | } | ||
| 107 | 124 | ||||
| 125 | + | // Tombstone durably marks name as logically killed before either snapshot | |||
| 126 | + | // source is deleted. Startup consults this marker before importing legacy or | |||
| 127 | + | // restoring incremental state, so a partial offline kill cannot resurrect it. | |||
| 128 | + | func (r *Repository) Tombstone(ctx context.Context, name string) error { | |||
| 129 | + | if err := ctx.Err(); err != nil { | |||
| 130 | + | return err | |||
| 131 | + | } | |||
| 132 | + | key := sessionKey(name) | |||
| 133 | + | lock := r.sessionLock(key) | |||
| 134 | + | lock.Lock() | |||
| 135 | + | defer lock.Unlock() | |||
| 136 | + | if err := ctx.Err(); err != nil { | |||
| 137 | + | return err | |||
| 138 | + | } | |||
| 139 | + | if err := r.ensurePrivateDirectory(r.dir); err != nil { | |||
| 140 | + | return fmt.Errorf("create killed session marker directory: %w", safeFilesystemError(err)) | |||
| 141 | + | } | |||
| 142 | + | path := filepath.Join(r.dir, tombstoneFilename(key)) | |||
| 143 | + | if err := r.writeImmutable(path, []byte(name), func(data []byte) error { | |||
| 144 | + | if string(data) != name { | |||
| 145 | + | return fmt.Errorf("killed session marker identity mismatch") | |||
| 146 | + | } | |||
| 147 | + | return nil | |||
| 148 | + | }); err != nil { | |||
| 149 | + | return fmt.Errorf("write killed session marker: %w", safeFilesystemError(err)) | |||
| 150 | + | } | |||
| 151 | + | return nil | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | // DeleteTombstone removes a marker only after both sources have been durably | |||
| 155 | + | // deleted. A missing marker is idempotent for offline-kill retries. | |||
| 156 | + | func (r *Repository) DeleteTombstone(ctx context.Context, name string) error { | |||
| 157 | + | if err := ctx.Err(); err != nil { | |||
| 158 | + | return err | |||
| 159 | + | } | |||
| 160 | + | key := sessionKey(name) | |||
| 161 | + | lock := r.sessionLock(key) | |||
| 162 | + | lock.Lock() | |||
| 163 | + | defer lock.Unlock() | |||
| 164 | + | if err := ctx.Err(); err != nil { | |||
| 165 | + | return err | |||
| 166 | + | } | |||
| 167 | + | if err := r.remove(filepath.Join(r.dir, tombstoneFilename(key))); errors.Is(err, os.ErrNotExist) { | |||
| 168 | + | return nil | |||
| 169 | + | } else if err != nil { | |||
| 170 | + | return fmt.Errorf("delete killed session marker: %w", safeFilesystemError(err)) | |||
| 171 | + | } | |||
| 172 | + | if err := r.syncDirectory(r.dir); err != nil { | |||
| 173 | + | return fmt.Errorf("sync killed session marker directory: %w", safeFilesystemError(err)) | |||
| 174 | + | } | |||
| 175 | + | return nil | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | func tombstoneFilename(key string) string { return ".killed-" + key } | |||
| 179 | + | ||||
| 180 | + | func (r *Repository) tombstoned(name string) (bool, error) { | |||
| 181 | + | if name == "" { | |||
| 182 | + | return false, nil | |||
| 183 | + | } | |||
| 184 | + | data, err := readBounded(filepath.Join(r.dir, tombstoneFilename(sessionKey(name)))) | |||
| 185 | + | if errors.Is(err, os.ErrNotExist) { | |||
| 186 | + | return false, nil | |||
| 187 | + | } | |||
| 188 | + | if err != nil { | |||
| 189 | + | return false, err | |||
| 190 | + | } | |||
| 191 | + | if string(data) != name { | |||
| 192 | + | return false, fmt.Errorf("killed session marker identity mismatch") | |||
| 193 | + | } | |||
| 194 | + | return true, nil | |||
| 195 | + | } | |||
| 196 | + | ||||
| 108 | func (r *Repository) syncPendingLegacy(ctx context.Context, filename string) error { | 197 | func (r *Repository) syncPendingLegacy(ctx context.Context, filename string) error { | ||
| 109 | if err := ctx.Err(); err != nil { | 198 | if err := ctx.Err(); err != nil { | ||
| 110 | return err | 199 | return err | ||
M internal/adapters/snapshot/load.go +14 -0
| @@ -46,6 +46,13 @@ func (r *Repository) List(ctx context.Context) ([]string, error) { | |||
| 46 | 46 | if err != nil || sessionKey(manifest.Name) != entry.Name() { | |
| 47 | 47 | continue | |
| 48 | 48 | } | |
| 49 | + | killed, tombstoneErr := r.tombstoned(manifest.Name) | |
| 50 | + | if tombstoneErr != nil { | |
| 51 | + | return nil, fmt.Errorf("read killed session marker: %w", tombstoneErr) | |
| 52 | + | } | |
| 53 | + | if killed { | |
| 54 | + | break | |
| 55 | + | } | |
| 49 | 56 | generation, err := r.loadGeneration(ctx, manifest.Name, entry.Name(), number) | |
| 50 | 57 | if err == nil { | |
| 51 | 58 | out = append(out, generation.Name) | |
| @@ -62,6 +69,13 @@ func (r *Repository) Load(ctx context.Context, name string) (ports.SnapshotGener | |||
| 62 | 69 | return ports.SnapshotGeneration{}, err | |
| 63 | 70 | } | |
| 64 | 71 | key := sessionKey(name) | |
| 72 | + | killed, err := r.tombstoned(name) | |
| 73 | + | if err != nil { | |
| 74 | + | return ports.SnapshotGeneration{}, fmt.Errorf("read killed session marker: %w", err) | |
| 75 | + | } | |
| 76 | + | if killed { | |
| 77 | + | return ports.SnapshotGeneration{}, fmt.Errorf("snapshot session %q is killed", name) | |
| 78 | + | } | |
| 65 | 79 | lock := r.sessionLock(key) | |
| 66 | 80 | lock.Lock() | |
| 67 | 81 | defer lock.Unlock() | |
| @@ -46,6 +46,13 @@ func (r *Repository) List(ctx context.Context) ([]string, error) { | |||||
| 46 | if err != nil || sessionKey(manifest.Name) != entry.Name() { | 46 | if err != nil || sessionKey(manifest.Name) != entry.Name() { | ||
| 47 | continue | 47 | continue | ||
| 48 | } | 48 | } | ||
| 49 | + | killed, tombstoneErr := r.tombstoned(manifest.Name) | |||
| 50 | + | if tombstoneErr != nil { | |||
| 51 | + | return nil, fmt.Errorf("read killed session marker: %w", tombstoneErr) | |||
| 52 | + | } | |||
| 53 | + | if killed { | |||
| 54 | + | break | |||
| 55 | + | } | |||
| 49 | generation, err := r.loadGeneration(ctx, manifest.Name, entry.Name(), number) | 56 | generation, err := r.loadGeneration(ctx, manifest.Name, entry.Name(), number) | ||
| 50 | if err == nil { | 57 | if err == nil { | ||
| 51 | out = append(out, generation.Name) | 58 | out = append(out, generation.Name) | ||
| @@ -62,6 +69,13 @@ func (r *Repository) Load(ctx context.Context, name string) (ports.SnapshotGener | |||||
| 62 | return ports.SnapshotGeneration{}, err | 69 | return ports.SnapshotGeneration{}, err | ||
| 63 | } | 70 | } | ||
| 64 | key := sessionKey(name) | 71 | key := sessionKey(name) | ||
| 72 | + | killed, err := r.tombstoned(name) | |||
| 73 | + | if err != nil { | |||
| 74 | + | return ports.SnapshotGeneration{}, fmt.Errorf("read killed session marker: %w", err) | |||
| 75 | + | } | |||
| 76 | + | if killed { | |||
| 77 | + | return ports.SnapshotGeneration{}, fmt.Errorf("snapshot session %q is killed", name) | |||
| 78 | + | } | |||
| 65 | lock := r.sessionLock(key) | 79 | lock := r.sessionLock(key) | ||
| 66 | lock.Lock() | 80 | lock.Lock() | ||
| 67 | defer lock.Unlock() | 81 | defer lock.Unlock() | ||
A internal/adapters/snapshot/offline_kill_tombstone_test.go +53 -0
| @@ -0,0 +1,53 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestRepositoryTombstoneBlocksIncrementalRestoreAndLegacyImport(t *testing.T) { | |
| 13 | + | repo := NewRepository(privateDir(t)) | |
| 14 | + | require.NoError(t, repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state")))) | |
| 15 | + | require.NoError(t, os.WriteFile(filepath.Join(repo.dir, filenameForName("named")), []byte("legacy"), 0o600)) | |
| 16 | + | ||
| 17 | + | require.NoError(t, repo.Tombstone(context.Background(), "named")) | |
| 18 | + | ||
| 19 | + | names, err := repo.List(context.Background()) | |
| 20 | + | require.NoError(t, err) | |
| 21 | + | require.NotContains(t, names, "named") | |
| 22 | + | legacy, err := repo.LoadLegacy(context.Background()) | |
| 23 | + | require.NoError(t, err) | |
| 24 | + | for _, snapshot := range legacy { | |
| 25 | + | require.NotEqual(t, "named", snapshot.Name) | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func TestRepositoryDeleteLegacyRetriesDurabilityAfterTombstone(t *testing.T) { | |
| 30 | + | repo := NewRepository(privateDir(t)) | |
| 31 | + | require.NoError(t, os.Mkdir(repo.dir, 0o700)) | |
| 32 | + | require.NoError(t, os.WriteFile(filepath.Join(repo.dir, filenameForName("named")), []byte("legacy"), 0o600)) | |
| 33 | + | require.NoError(t, repo.Tombstone(context.Background(), "named")) | |
| 34 | + | ||
| 35 | + | calls := 0 | |
| 36 | + | repo.hooks.syncDirectory = func(string) error { | |
| 37 | + | calls++ | |
| 38 | + | if calls == 1 { | |
| 39 | + | return os.ErrPermission | |
| 40 | + | } | |
| 41 | + | return nil | |
| 42 | + | } | |
| 43 | + | require.ErrorIs(t, repo.DeleteLegacy(context.Background(), "named"), os.ErrPermission) | |
| 44 | + | ||
| 45 | + | // A retry after process restart has no in-memory pending-sync state. | |
| 46 | + | repo = NewRepository(repo.dir) | |
| 47 | + | repo.hooks.syncDirectory = func(string) error { | |
| 48 | + | calls++ | |
| 49 | + | return nil | |
| 50 | + | } | |
| 51 | + | require.NoError(t, repo.DeleteLegacy(context.Background(), "named")) | |
| 52 | + | require.Equal(t, 2, calls, "a tombstoned missing legacy source must resync its unlink") | |
| 53 | + | } | |
| @@ -0,0 +1,53 @@ | |||||
| 1 | + | package snapshot | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestRepositoryTombstoneBlocksIncrementalRestoreAndLegacyImport(t *testing.T) { | |||
| 13 | + | repo := NewRepository(privateDir(t)) | |||
| 14 | + | require.NoError(t, repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state")))) | |||
| 15 | + | require.NoError(t, os.WriteFile(filepath.Join(repo.dir, filenameForName("named")), []byte("legacy"), 0o600)) | |||
| 16 | + | ||||
| 17 | + | require.NoError(t, repo.Tombstone(context.Background(), "named")) | |||
| 18 | + | ||||
| 19 | + | names, err := repo.List(context.Background()) | |||
| 20 | + | require.NoError(t, err) | |||
| 21 | + | require.NotContains(t, names, "named") | |||
| 22 | + | legacy, err := repo.LoadLegacy(context.Background()) | |||
| 23 | + | require.NoError(t, err) | |||
| 24 | + | for _, snapshot := range legacy { | |||
| 25 | + | require.NotEqual(t, "named", snapshot.Name) | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func TestRepositoryDeleteLegacyRetriesDurabilityAfterTombstone(t *testing.T) { | |||
| 30 | + | repo := NewRepository(privateDir(t)) | |||
| 31 | + | require.NoError(t, os.Mkdir(repo.dir, 0o700)) | |||
| 32 | + | require.NoError(t, os.WriteFile(filepath.Join(repo.dir, filenameForName("named")), []byte("legacy"), 0o600)) | |||
| 33 | + | require.NoError(t, repo.Tombstone(context.Background(), "named")) | |||
| 34 | + | ||||
| 35 | + | calls := 0 | |||
| 36 | + | repo.hooks.syncDirectory = func(string) error { | |||
| 37 | + | calls++ | |||
| 38 | + | if calls == 1 { | |||
| 39 | + | return os.ErrPermission | |||
| 40 | + | } | |||
| 41 | + | return nil | |||
| 42 | + | } | |||
| 43 | + | require.ErrorIs(t, repo.DeleteLegacy(context.Background(), "named"), os.ErrPermission) | |||
| 44 | + | ||||
| 45 | + | // A retry after process restart has no in-memory pending-sync state. | |||
| 46 | + | repo = NewRepository(repo.dir) | |||
| 47 | + | repo.hooks.syncDirectory = func(string) error { | |||
| 48 | + | calls++ | |||
| 49 | + | return nil | |||
| 50 | + | } | |||
| 51 | + | require.NoError(t, repo.DeleteLegacy(context.Background(), "named")) | |||
| 52 | + | require.Equal(t, 2, calls, "a tombstoned missing legacy source must resync its unlink") | |||
| 53 | + | } | |||
A internal/app/offline_kill_retry_test.go +124 -0
| @@ -0,0 +1,124 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "path/filepath" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/persist" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | type offlineSnapshotFake struct { | |
| 15 | + | calls []string | |
| 16 | + | deleteErr error | |
| 17 | + | deleteLegacyErr error | |
| 18 | + | tombstoneErr error | |
| 19 | + | deleteTombstoneErr error | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func (f *offlineSnapshotFake) Tombstone(context.Context, string) error { | |
| 23 | + | f.calls = append(f.calls, "tombstone") | |
| 24 | + | return f.tombstoneErr | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func (f *offlineSnapshotFake) Delete(context.Context, string) error { | |
| 28 | + | f.calls = append(f.calls, "incremental") | |
| 29 | + | return f.deleteErr | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func (f *offlineSnapshotFake) DeleteLegacy(context.Context, string) error { | |
| 33 | + | f.calls = append(f.calls, "legacy") | |
| 34 | + | return f.deleteLegacyErr | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func (f *offlineSnapshotFake) DeleteTombstone(context.Context, string) error { | |
| 38 | + | f.calls = append(f.calls, "delete tombstone") | |
| 39 | + | return f.deleteTombstoneErr | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestRunKillOfflineRetainsTombstoneAndMetadataUntilBothSourcesAreDeleted(t *testing.T) { | |
| 43 | + | stateRoot, runtimeRoot := t.TempDir(), t.TempDir() | |
| 44 | + | t.Setenv("XDG_STATE_HOME", stateRoot) | |
| 45 | + | t.Setenv("XDG_RUNTIME_DIR", runtimeRoot) | |
| 46 | + | ||
| 47 | + | p, err := persist.Open(filepath.Join(stateRoot, "vev")) | |
| 48 | + | require.NoError(t, err) | |
| 49 | + | now := time.Now().UnixNano() | |
| 50 | + | require.NoError(t, p.Save(persist.Record{Name: "named", Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now})) | |
| 51 | + | require.NoError(t, p.Close()) | |
| 52 | + | ||
| 53 | + | injected := errors.New("legacy unlink failed") | |
| 54 | + | fake := &offlineSnapshotFake{deleteLegacyErr: injected} | |
| 55 | + | originalFactory := newOfflineSnapshotRepository | |
| 56 | + | newOfflineSnapshotRepository = func(string) offlineSnapshotSource { return fake } | |
| 57 | + | t.Cleanup(func() { newOfflineSnapshotRepository = originalFactory }) | |
| 58 | + | ||
| 59 | + | err = runKill(context.Background(), "named", false, false) | |
| 60 | + | require.ErrorIs(t, err, injected) | |
| 61 | + | require.Equal(t, []string{"tombstone", "incremental", "legacy"}, fake.calls) | |
| 62 | + | records, err := persist.LoadReadOnly(filepath.Join(stateRoot, "vev")) | |
| 63 | + | require.NoError(t, err) | |
| 64 | + | require.Len(t, records, 1, "a failed source delete must retain retry identity") | |
| 65 | + | ||
| 66 | + | fake.deleteLegacyErr = nil | |
| 67 | + | require.NoError(t, runKill(context.Background(), "named", false, false)) | |
| 68 | + | require.Equal(t, []string{"tombstone", "incremental", "legacy", "tombstone", "incremental", "legacy", "delete tombstone"}, fake.calls) | |
| 69 | + | records, err = persist.LoadReadOnly(filepath.Join(stateRoot, "vev")) | |
| 70 | + | require.NoError(t, err) | |
| 71 | + | require.Empty(t, records) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func TestRunKillOfflineRetriesAfterIncrementalDeleteFailure(t *testing.T) { | |
| 75 | + | stateRoot, runtimeRoot := t.TempDir(), t.TempDir() | |
| 76 | + | t.Setenv("XDG_STATE_HOME", stateRoot) | |
| 77 | + | t.Setenv("XDG_RUNTIME_DIR", runtimeRoot) | |
| 78 | + | ||
| 79 | + | p, err := persist.Open(filepath.Join(stateRoot, "vev")) | |
| 80 | + | require.NoError(t, err) | |
| 81 | + | now := time.Now().UnixNano() | |
| 82 | + | require.NoError(t, p.Save(persist.Record{Name: "named", Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now})) | |
| 83 | + | require.NoError(t, p.Close()) | |
| 84 | + | ||
| 85 | + | injected := errors.New("incremental quarantine failed") | |
| 86 | + | fake := &offlineSnapshotFake{deleteErr: injected} | |
| 87 | + | originalFactory := newOfflineSnapshotRepository | |
| 88 | + | newOfflineSnapshotRepository = func(string) offlineSnapshotSource { return fake } | |
| 89 | + | t.Cleanup(func() { newOfflineSnapshotRepository = originalFactory }) | |
| 90 | + | ||
| 91 | + | require.ErrorIs(t, runKill(context.Background(), "named", false, false), injected) | |
| 92 | + | require.Equal(t, []string{"tombstone", "incremental", "legacy"}, fake.calls) | |
| 93 | + | ||
| 94 | + | fake.deleteErr = nil | |
| 95 | + | require.NoError(t, runKill(context.Background(), "named", false, false)) | |
| 96 | + | require.Equal(t, []string{"tombstone", "incremental", "legacy", "tombstone", "incremental", "legacy", "delete tombstone"}, fake.calls) | |
| 97 | + | records, err := persist.LoadReadOnly(filepath.Join(stateRoot, "vev")) | |
| 98 | + | require.NoError(t, err) | |
| 99 | + | require.Empty(t, records) | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func TestRunKillOfflineReportsBothSnapshotDeletionFailures(t *testing.T) { | |
| 103 | + | stateRoot, runtimeRoot := t.TempDir(), t.TempDir() | |
| 104 | + | t.Setenv("XDG_STATE_HOME", stateRoot) | |
| 105 | + | t.Setenv("XDG_RUNTIME_DIR", runtimeRoot) | |
| 106 | + | ||
| 107 | + | p, err := persist.Open(filepath.Join(stateRoot, "vev")) | |
| 108 | + | require.NoError(t, err) | |
| 109 | + | now := time.Now().UnixNano() | |
| 110 | + | require.NoError(t, p.Save(persist.Record{Name: "named", Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now})) | |
| 111 | + | require.NoError(t, p.Close()) | |
| 112 | + | ||
| 113 | + | incrementalErr := errors.New("incremental quarantine failed") | |
| 114 | + | legacyErr := errors.New("legacy unlink failed") | |
| 115 | + | fake := &offlineSnapshotFake{deleteErr: incrementalErr, deleteLegacyErr: legacyErr} | |
| 116 | + | originalFactory := newOfflineSnapshotRepository | |
| 117 | + | newOfflineSnapshotRepository = func(string) offlineSnapshotSource { return fake } | |
| 118 | + | t.Cleanup(func() { newOfflineSnapshotRepository = originalFactory }) | |
| 119 | + | ||
| 120 | + | err = runKill(context.Background(), "named", false, false) | |
| 121 | + | require.ErrorIs(t, err, incrementalErr) | |
| 122 | + | require.ErrorIs(t, err, legacyErr) | |
| 123 | + | require.Equal(t, []string{"tombstone", "incremental", "legacy"}, fake.calls) | |
| 124 | + | } | |
| @@ -0,0 +1,124 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/vev/internal/persist" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | type offlineSnapshotFake struct { | |||
| 15 | + | calls []string | |||
| 16 | + | deleteErr error | |||
| 17 | + | deleteLegacyErr error | |||
| 18 | + | tombstoneErr error | |||
| 19 | + | deleteTombstoneErr error | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func (f *offlineSnapshotFake) Tombstone(context.Context, string) error { | |||
| 23 | + | f.calls = append(f.calls, "tombstone") | |||
| 24 | + | return f.tombstoneErr | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func (f *offlineSnapshotFake) Delete(context.Context, string) error { | |||
| 28 | + | f.calls = append(f.calls, "incremental") | |||
| 29 | + | return f.deleteErr | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func (f *offlineSnapshotFake) DeleteLegacy(context.Context, string) error { | |||
| 33 | + | f.calls = append(f.calls, "legacy") | |||
| 34 | + | return f.deleteLegacyErr | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func (f *offlineSnapshotFake) DeleteTombstone(context.Context, string) error { | |||
| 38 | + | f.calls = append(f.calls, "delete tombstone") | |||
| 39 | + | return f.deleteTombstoneErr | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func TestRunKillOfflineRetainsTombstoneAndMetadataUntilBothSourcesAreDeleted(t *testing.T) { | |||
| 43 | + | stateRoot, runtimeRoot := t.TempDir(), t.TempDir() | |||
| 44 | + | t.Setenv("XDG_STATE_HOME", stateRoot) | |||
| 45 | + | t.Setenv("XDG_RUNTIME_DIR", runtimeRoot) | |||
| 46 | + | ||||
| 47 | + | p, err := persist.Open(filepath.Join(stateRoot, "vev")) | |||
| 48 | + | require.NoError(t, err) | |||
| 49 | + | now := time.Now().UnixNano() | |||
| 50 | + | require.NoError(t, p.Save(persist.Record{Name: "named", Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now})) | |||
| 51 | + | require.NoError(t, p.Close()) | |||
| 52 | + | ||||
| 53 | + | injected := errors.New("legacy unlink failed") | |||
| 54 | + | fake := &offlineSnapshotFake{deleteLegacyErr: injected} | |||
| 55 | + | originalFactory := newOfflineSnapshotRepository | |||
| 56 | + | newOfflineSnapshotRepository = func(string) offlineSnapshotSource { return fake } | |||
| 57 | + | t.Cleanup(func() { newOfflineSnapshotRepository = originalFactory }) | |||
| 58 | + | ||||
| 59 | + | err = runKill(context.Background(), "named", false, false) | |||
| 60 | + | require.ErrorIs(t, err, injected) | |||
| 61 | + | require.Equal(t, []string{"tombstone", "incremental", "legacy"}, fake.calls) | |||
| 62 | + | records, err := persist.LoadReadOnly(filepath.Join(stateRoot, "vev")) | |||
| 63 | + | require.NoError(t, err) | |||
| 64 | + | require.Len(t, records, 1, "a failed source delete must retain retry identity") | |||
| 65 | + | ||||
| 66 | + | fake.deleteLegacyErr = nil | |||
| 67 | + | require.NoError(t, runKill(context.Background(), "named", false, false)) | |||
| 68 | + | require.Equal(t, []string{"tombstone", "incremental", "legacy", "tombstone", "incremental", "legacy", "delete tombstone"}, fake.calls) | |||
| 69 | + | records, err = persist.LoadReadOnly(filepath.Join(stateRoot, "vev")) | |||
| 70 | + | require.NoError(t, err) | |||
| 71 | + | require.Empty(t, records) | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func TestRunKillOfflineRetriesAfterIncrementalDeleteFailure(t *testing.T) { | |||
| 75 | + | stateRoot, runtimeRoot := t.TempDir(), t.TempDir() | |||
| 76 | + | t.Setenv("XDG_STATE_HOME", stateRoot) | |||
| 77 | + | t.Setenv("XDG_RUNTIME_DIR", runtimeRoot) | |||
| 78 | + | ||||
| 79 | + | p, err := persist.Open(filepath.Join(stateRoot, "vev")) | |||
| 80 | + | require.NoError(t, err) | |||
| 81 | + | now := time.Now().UnixNano() | |||
| 82 | + | require.NoError(t, p.Save(persist.Record{Name: "named", Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now})) | |||
| 83 | + | require.NoError(t, p.Close()) | |||
| 84 | + | ||||
| 85 | + | injected := errors.New("incremental quarantine failed") | |||
| 86 | + | fake := &offlineSnapshotFake{deleteErr: injected} | |||
| 87 | + | originalFactory := newOfflineSnapshotRepository | |||
| 88 | + | newOfflineSnapshotRepository = func(string) offlineSnapshotSource { return fake } | |||
| 89 | + | t.Cleanup(func() { newOfflineSnapshotRepository = originalFactory }) | |||
| 90 | + | ||||
| 91 | + | require.ErrorIs(t, runKill(context.Background(), "named", false, false), injected) | |||
| 92 | + | require.Equal(t, []string{"tombstone", "incremental", "legacy"}, fake.calls) | |||
| 93 | + | ||||
| 94 | + | fake.deleteErr = nil | |||
| 95 | + | require.NoError(t, runKill(context.Background(), "named", false, false)) | |||
| 96 | + | require.Equal(t, []string{"tombstone", "incremental", "legacy", "tombstone", "incremental", "legacy", "delete tombstone"}, fake.calls) | |||
| 97 | + | records, err := persist.LoadReadOnly(filepath.Join(stateRoot, "vev")) | |||
| 98 | + | require.NoError(t, err) | |||
| 99 | + | require.Empty(t, records) | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func TestRunKillOfflineReportsBothSnapshotDeletionFailures(t *testing.T) { | |||
| 103 | + | stateRoot, runtimeRoot := t.TempDir(), t.TempDir() | |||
| 104 | + | t.Setenv("XDG_STATE_HOME", stateRoot) | |||
| 105 | + | t.Setenv("XDG_RUNTIME_DIR", runtimeRoot) | |||
| 106 | + | ||||
| 107 | + | p, err := persist.Open(filepath.Join(stateRoot, "vev")) | |||
| 108 | + | require.NoError(t, err) | |||
| 109 | + | now := time.Now().UnixNano() | |||
| 110 | + | require.NoError(t, p.Save(persist.Record{Name: "named", Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now})) | |||
| 111 | + | require.NoError(t, p.Close()) | |||
| 112 | + | ||||
| 113 | + | incrementalErr := errors.New("incremental quarantine failed") | |||
| 114 | + | legacyErr := errors.New("legacy unlink failed") | |||
| 115 | + | fake := &offlineSnapshotFake{deleteErr: incrementalErr, deleteLegacyErr: legacyErr} | |||
| 116 | + | originalFactory := newOfflineSnapshotRepository | |||
| 117 | + | newOfflineSnapshotRepository = func(string) offlineSnapshotSource { return fake } | |||
| 118 | + | t.Cleanup(func() { newOfflineSnapshotRepository = originalFactory }) | |||
| 119 | + | ||||
| 120 | + | err = runKill(context.Background(), "named", false, false) | |||
| 121 | + | require.ErrorIs(t, err, incrementalErr) | |||
| 122 | + | require.ErrorIs(t, err, legacyErr) | |||
| 123 | + | require.Equal(t, []string{"tombstone", "incremental", "legacy"}, fake.calls) | |||
| 124 | + | } | |||
M internal/app/run.go +42 -10
| @@ -1186,6 +1186,27 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) { | |||
| 1186 | 1186 | _ = tw.Flush() | |
| 1187 | 1187 | } | |
| 1188 | 1188 | ||
| 1189 | + | // offlineSnapshotSource owns the durable source and tombstone transitions for | |
| 1190 | + | // a daemon-less kill. It is intentionally local to the composition root: the | |
| 1191 | + | // daemon uses the repository through its narrower ports. | |
| 1192 | + | type offlineSnapshotSource interface { | |
| 1193 | + | Tombstone(context.Context, string) error | |
| 1194 | + | Delete(context.Context, string) error | |
| 1195 | + | DeleteLegacy(context.Context, string) error | |
| 1196 | + | DeleteTombstone(context.Context, string) error | |
| 1197 | + | } | |
| 1198 | + | ||
| 1199 | + | var newOfflineSnapshotRepository = func(dir string) offlineSnapshotSource { | |
| 1200 | + | return snapshotadapter.NewRepository(dir) | |
| 1201 | + | } | |
| 1202 | + | ||
| 1203 | + | func phaseError(phase string, err error) error { | |
| 1204 | + | if err == nil { | |
| 1205 | + | return nil | |
| 1206 | + | } | |
| 1207 | + | return fmt.Errorf("vev: %s: %w", phase, err) | |
| 1208 | + | } | |
| 1209 | + | ||
| 1189 | 1210 | // runKill asks the daemon to terminate a named session, every session, or the daemon. | |
| 1190 | 1211 | func runKill(ctx context.Context, name string, all, daemon bool) error { | |
| 1191 | 1212 | transport, err := realDial(ctx, ipc.SocketDir()) | |
| @@ -1215,6 +1236,27 @@ func runKill(ctx context.Context, name string, all, daemon bool) error { | |||
| 1215 | 1236 | if openErr != nil { | |
| 1216 | 1237 | return fmt.Errorf("vev: opening stored sessions: %w", openErr) | |
| 1217 | 1238 | } | |
| 1239 | + | snapshots := newOfflineSnapshotRepository(snapshotDir()) | |
| 1240 | + | // The durable marker is the commit point for a logical kill. Keep it and | |
| 1241 | + | // the persisted record until both independently recoverable sources have | |
| 1242 | + | // crossed their deletion durability boundaries. | |
| 1243 | + | if tombstoneErr := snapshots.Tombstone(ctx, name); tombstoneErr != nil { | |
| 1244 | + | _ = p.Close() | |
| 1245 | + | return fmt.Errorf("vev: marking session killed: %w", tombstoneErr) | |
| 1246 | + | } | |
| 1247 | + | incrementalErr := snapshots.Delete(ctx, name) | |
| 1248 | + | legacyErr := snapshots.DeleteLegacy(ctx, name) | |
| 1249 | + | if incrementalErr != nil || legacyErr != nil { | |
| 1250 | + | _ = p.Close() | |
| 1251 | + | return errors.Join( | |
| 1252 | + | phaseError("deleting incremental session snapshot", incrementalErr), | |
| 1253 | + | phaseError("deleting legacy session snapshot", legacyErr), | |
| 1254 | + | ) | |
| 1255 | + | } | |
| 1256 | + | if tombstoneErr := snapshots.DeleteTombstone(ctx, name); tombstoneErr != nil { | |
| 1257 | + | _ = p.Close() | |
| 1258 | + | return fmt.Errorf("vev: clearing killed session marker: %w", tombstoneErr) | |
| 1259 | + | } | |
| 1218 | 1260 | if deleteErr := p.Delete(name); deleteErr != nil { | |
| 1219 | 1261 | _ = p.Close() | |
| 1220 | 1262 | return fmt.Errorf("vev: deleting stored session: %w", deleteErr) | |
| @@ -1222,16 +1264,6 @@ func runKill(ctx context.Context, name string, all, daemon bool) error { | |||
| 1222 | 1264 | if closeErr := p.Close(); closeErr != nil { | |
| 1223 | 1265 | return fmt.Errorf("vev: closing stored sessions: %w", closeErr) | |
| 1224 | 1266 | } | |
| 1225 | - | snapshots := snapshotadapter.NewRepository(snapshotDir()) | |
| 1226 | - | if deleteErr := snapshots.Delete(ctx, name); deleteErr != nil { | |
| 1227 | - | return fmt.Errorf("vev: deleting session snapshot: %w", deleteErr) | |
| 1228 | - | } | |
| 1229 | - | // A daemon-less delete may be the first command after upgrade, before | |
| 1230 | - | // startup has had a chance to import v3. Remove that one-way source as | |
| 1231 | - | // well so a later daemon cannot resurrect an explicitly killed session. | |
| 1232 | - | if deleteErr := snapshots.DeleteLegacy(ctx, name); deleteErr != nil { | |
| 1233 | - | return fmt.Errorf("vev: deleting legacy session snapshot: %w", deleteErr) | |
| 1234 | - | } | |
| 1235 | 1267 | printKillSuccess(name, all, daemon) | |
| 1236 | 1268 | return nil | |
| 1237 | 1269 | } | |
| @@ -1186,6 +1186,27 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) { | |||||
| 1186 | _ = tw.Flush() | 1186 | _ = tw.Flush() | ||
| 1187 | } | 1187 | } | ||
| 1188 | 1188 | ||||
| 1189 | + | // offlineSnapshotSource owns the durable source and tombstone transitions for | |||
| 1190 | + | // a daemon-less kill. It is intentionally local to the composition root: the | |||
| 1191 | + | // daemon uses the repository through its narrower ports. | |||
| 1192 | + | type offlineSnapshotSource interface { | |||
| 1193 | + | Tombstone(context.Context, string) error | |||
| 1194 | + | Delete(context.Context, string) error | |||
| 1195 | + | DeleteLegacy(context.Context, string) error | |||
| 1196 | + | DeleteTombstone(context.Context, string) error | |||
| 1197 | + | } | |||
| 1198 | + | ||||
| 1199 | + | var newOfflineSnapshotRepository = func(dir string) offlineSnapshotSource { | |||
| 1200 | + | return snapshotadapter.NewRepository(dir) | |||
| 1201 | + | } | |||
| 1202 | + | ||||
| 1203 | + | func phaseError(phase string, err error) error { | |||
| 1204 | + | if err == nil { | |||
| 1205 | + | return nil | |||
| 1206 | + | } | |||
| 1207 | + | return fmt.Errorf("vev: %s: %w", phase, err) | |||
| 1208 | + | } | |||
| 1209 | + | ||||
| 1189 | // runKill asks the daemon to terminate a named session, every session, or the daemon. | 1210 | // runKill asks the daemon to terminate a named session, every session, or the daemon. | ||
| 1190 | func runKill(ctx context.Context, name string, all, daemon bool) error { | 1211 | func runKill(ctx context.Context, name string, all, daemon bool) error { | ||
| 1191 | transport, err := realDial(ctx, ipc.SocketDir()) | 1212 | transport, err := realDial(ctx, ipc.SocketDir()) | ||
| @@ -1215,6 +1236,27 @@ func runKill(ctx context.Context, name string, all, daemon bool) error { | |||||
| 1215 | if openErr != nil { | 1236 | if openErr != nil { | ||
| 1216 | return fmt.Errorf("vev: opening stored sessions: %w", openErr) | 1237 | return fmt.Errorf("vev: opening stored sessions: %w", openErr) | ||
| 1217 | } | 1238 | } | ||
| 1239 | + | snapshots := newOfflineSnapshotRepository(snapshotDir()) | |||
| 1240 | + | // The durable marker is the commit point for a logical kill. Keep it and | |||
| 1241 | + | // the persisted record until both independently recoverable sources have | |||
| 1242 | + | // crossed their deletion durability boundaries. | |||
| 1243 | + | if tombstoneErr := snapshots.Tombstone(ctx, name); tombstoneErr != nil { | |||
| 1244 | + | _ = p.Close() | |||
| 1245 | + | return fmt.Errorf("vev: marking session killed: %w", tombstoneErr) | |||
| 1246 | + | } | |||
| 1247 | + | incrementalErr := snapshots.Delete(ctx, name) | |||
| 1248 | + | legacyErr := snapshots.DeleteLegacy(ctx, name) | |||
| 1249 | + | if incrementalErr != nil || legacyErr != nil { | |||
| 1250 | + | _ = p.Close() | |||
| 1251 | + | return errors.Join( | |||
| 1252 | + | phaseError("deleting incremental session snapshot", incrementalErr), | |||
| 1253 | + | phaseError("deleting legacy session snapshot", legacyErr), | |||
| 1254 | + | ) | |||
| 1255 | + | } | |||
| 1256 | + | if tombstoneErr := snapshots.DeleteTombstone(ctx, name); tombstoneErr != nil { | |||
| 1257 | + | _ = p.Close() | |||
| 1258 | + | return fmt.Errorf("vev: clearing killed session marker: %w", tombstoneErr) | |||
| 1259 | + | } | |||
| 1218 | if deleteErr := p.Delete(name); deleteErr != nil { | 1260 | if deleteErr := p.Delete(name); deleteErr != nil { | ||
| 1219 | _ = p.Close() | 1261 | _ = p.Close() | ||
| 1220 | return fmt.Errorf("vev: deleting stored session: %w", deleteErr) | 1262 | return fmt.Errorf("vev: deleting stored session: %w", deleteErr) | ||
| @@ -1222,16 +1264,6 @@ func runKill(ctx context.Context, name string, all, daemon bool) error { | |||||
| 1222 | if closeErr := p.Close(); closeErr != nil { | 1264 | if closeErr := p.Close(); closeErr != nil { | ||
| 1223 | return fmt.Errorf("vev: closing stored sessions: %w", closeErr) | 1265 | return fmt.Errorf("vev: closing stored sessions: %w", closeErr) | ||
| 1224 | } | 1266 | } | ||
| 1225 | - | snapshots := snapshotadapter.NewRepository(snapshotDir()) | |||
| 1226 | - | if deleteErr := snapshots.Delete(ctx, name); deleteErr != nil { | |||
| 1227 | - | return fmt.Errorf("vev: deleting session snapshot: %w", deleteErr) | |||
| 1228 | - | } | |||
| 1229 | - | // A daemon-less delete may be the first command after upgrade, before | |||
| 1230 | - | // startup has had a chance to import v3. Remove that one-way source as | |||
| 1231 | - | // well so a later daemon cannot resurrect an explicitly killed session. | |||
| 1232 | - | if deleteErr := snapshots.DeleteLegacy(ctx, name); deleteErr != nil { | |||
| 1233 | - | return fmt.Errorf("vev: deleting legacy session snapshot: %w", deleteErr) | |||
| 1234 | - | } | |||
| 1235 | printKillSuccess(name, all, daemon) | 1267 | printKillSuccess(name, all, daemon) | ||
| 1236 | return nil | 1268 | return nil | ||
| 1237 | } | 1269 | } | ||