Skip to content

Commit 1c0eea1

Browse files
committed
Fix snapshot compression cleanup races
1 parent 2eef65d commit 1c0eea1

6 files changed

Lines changed: 144 additions & 2 deletions

File tree

lib/forkvm/copy.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ func CopyGuestDirectory(srcDir, dstDir string) error {
4242
if d.IsDir() && shouldSkipDirectory(relPath) {
4343
return filepath.SkipDir
4444
}
45-
if shouldSkipRegularFile(relPath) {
45+
if !d.IsDir() && shouldSkipRegularFile(relPath) {
4646
return nil
4747
}
4848

lib/forkvm/copy_test.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,17 @@ func TestCopyGuestDirectory(t *testing.T) {
4343
require.NoError(t, err)
4444
assert.Equal(t, "metadata.json", linkTarget)
4545
}
46+
47+
func TestCopyGuestDirectory_DoesNotSkipTmpSuffixedDirectories(t *testing.T) {
48+
src := filepath.Join(t.TempDir(), "src")
49+
dst := filepath.Join(t.TempDir(), "dst")
50+
51+
tmpDir := filepath.Join(src, "snapshots", "snapshot-latest", "memory-ranges.lz4.tmp")
52+
require.NoError(t, os.MkdirAll(tmpDir, 0755))
53+
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "nested.txt"), []byte("nested"), 0644))
54+
55+
require.NoError(t, CopyGuestDirectory(src, dst))
56+
57+
assert.DirExists(t, filepath.Join(dst, "snapshots", "snapshot-latest", "memory-ranges.lz4.tmp"))
58+
assert.FileExists(t, filepath.Join(dst, "snapshots", "snapshot-latest", "memory-ranges.lz4.tmp", "nested.txt"))
59+
}

lib/instances/delete.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@ func (m *manager) deleteInstance(
3535
stored := &meta.StoredMetadata
3636
log.DebugContext(ctx, "loaded instance", "instance_id", id, "state", inst.State)
3737

38+
target, err := m.cancelAndWaitCompressionJob(ctx, m.snapshotJobKeyForInstance(id))
39+
if err != nil {
40+
return fmt.Errorf("wait for instance compression to stop: %w", err)
41+
}
42+
if target != nil {
43+
m.recordSnapshotCompressionPreemption(ctx, snapshotCompressionPreemptionDeleteInstance, *target)
44+
}
45+
3846
// 2. Get network allocation BEFORE killing VMM (while we can still query it)
3947
var networkAlloc *network.Allocation
4048
if inst.NetworkEnabled {

lib/instances/metrics.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ const (
4141
snapshotCompressionPreemptionRestoreSnapshot snapshotCompressionPreemptionOperation = "restore_snapshot"
4242
snapshotCompressionPreemptionForkSnapshot snapshotCompressionPreemptionOperation = "fork_snapshot"
4343
snapshotCompressionPreemptionCreateSnapshot snapshotCompressionPreemptionOperation = "create_snapshot"
44+
snapshotCompressionPreemptionDeleteInstance snapshotCompressionPreemptionOperation = "delete_instance"
45+
snapshotCompressionPreemptionDeleteSnapshot snapshotCompressionPreemptionOperation = "delete_snapshot"
4446
)
4547

4648
// Metrics holds the metrics instruments for instance operations.

lib/instances/snapshot.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,13 @@ func (m *manager) createSnapshot(ctx context.Context, id string, req CreateSnaps
213213
}
214214

215215
func (m *manager) deleteSnapshot(ctx context.Context, snapshotID string) error {
216-
_ = ctx
216+
target, err := m.cancelAndWaitCompressionJob(ctx, m.snapshotJobKeyForSnapshot(snapshotID))
217+
if err != nil {
218+
return fmt.Errorf("wait for snapshot compression to stop: %w", err)
219+
}
220+
if target != nil {
221+
m.recordSnapshotCompressionPreemption(ctx, snapshotCompressionPreemptionDeleteSnapshot, *target)
222+
}
217223
if err := m.snapshotStore().Delete(snapshotID); err != nil {
218224
if errors.Is(err, snapshotstore.ErrNotFound) {
219225
return ErrSnapshotNotFound

lib/instances/snapshot_compression_test.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
package instances
22

33
import (
4+
"context"
45
"errors"
6+
"os"
57
"testing"
8+
"time"
69

10+
"github.com/kernel/hypeman/lib/hypervisor"
711
snapshotstore "github.com/kernel/hypeman/lib/snapshot"
812
"github.com/stretchr/testify/assert"
913
"github.com/stretchr/testify/require"
@@ -206,3 +210,111 @@ func TestValidateCreateSnapshotRequestRejectsStoppedCompression(t *testing.T) {
206210
require.Error(t, err)
207211
assert.True(t, errors.Is(err, ErrInvalidRequest))
208212
}
213+
214+
func TestDeleteInstanceCancelsCompressionJob(t *testing.T) {
215+
t.Parallel()
216+
217+
mgr, _ := setupTestManager(t)
218+
ctx := context.Background()
219+
const instanceID = "delete-instance-compression"
220+
221+
require.NoError(t, mgr.ensureDirectories(instanceID))
222+
now := time.Now()
223+
require.NoError(t, mgr.saveMetadata(&metadata{StoredMetadata: StoredMetadata{
224+
Id: instanceID,
225+
Name: instanceID,
226+
DataDir: mgr.paths.InstanceDir(instanceID),
227+
HypervisorType: hypervisor.TypeCloudHypervisor,
228+
CreatedAt: now,
229+
StoppedAt: &now,
230+
}}))
231+
232+
target := installCancelableCompressionJob(mgr, compressionTarget{
233+
Key: mgr.snapshotJobKeyForInstance(instanceID),
234+
OwnerID: instanceID,
235+
SnapshotDir: mgr.paths.InstanceSnapshotLatest(instanceID),
236+
Source: snapshotCompressionSourceStandby,
237+
Policy: snapshotstore.SnapshotCompressionConfig{
238+
Enabled: true,
239+
Algorithm: snapshotstore.SnapshotCompressionAlgorithmZstd,
240+
Level: intPtr(1),
241+
},
242+
})
243+
244+
require.NoError(t, mgr.DeleteInstance(ctx, instanceID))
245+
assertCompressionJobCanceled(t, mgr, target)
246+
_, err := os.Stat(mgr.paths.InstanceDir(instanceID))
247+
require.Error(t, err)
248+
assert.True(t, os.IsNotExist(err))
249+
}
250+
251+
func TestDeleteSnapshotCancelsCompressionJob(t *testing.T) {
252+
t.Parallel()
253+
254+
mgr, _ := setupTestManager(t)
255+
ctx := context.Background()
256+
const snapshotID = "delete-snapshot-compression"
257+
258+
snapshotDir := mgr.paths.SnapshotGuestDir(snapshotID)
259+
require.NoError(t, os.MkdirAll(snapshotDir, 0o755))
260+
require.NoError(t, mgr.saveSnapshotRecord(&snapshotRecord{
261+
Snapshot: Snapshot{
262+
Id: snapshotID,
263+
Name: snapshotID,
264+
Kind: SnapshotKindStandby,
265+
CreatedAt: time.Now(),
266+
},
267+
}))
268+
269+
target := installCancelableCompressionJob(mgr, compressionTarget{
270+
Key: mgr.snapshotJobKeyForSnapshot(snapshotID),
271+
SnapshotID: snapshotID,
272+
SnapshotDir: snapshotDir,
273+
Source: snapshotCompressionSourceSnapshot,
274+
Policy: snapshotstore.SnapshotCompressionConfig{
275+
Enabled: true,
276+
Algorithm: snapshotstore.SnapshotCompressionAlgorithmLz4,
277+
Level: intPtr(0),
278+
},
279+
})
280+
281+
require.NoError(t, mgr.DeleteSnapshot(ctx, snapshotID))
282+
assertCompressionJobCanceled(t, mgr, target)
283+
_, err := os.Stat(mgr.paths.SnapshotDir(snapshotID))
284+
require.Error(t, err)
285+
assert.True(t, os.IsNotExist(err))
286+
}
287+
288+
func installCancelableCompressionJob(mgr *manager, target compressionTarget) *compressionTarget {
289+
ctx, cancel := context.WithCancel(context.Background())
290+
done := make(chan struct{})
291+
292+
mgr.compressionMu.Lock()
293+
mgr.compressionJobs[target.Key] = &compressionJob{
294+
cancel: cancel,
295+
done: done,
296+
target: target,
297+
}
298+
mgr.compressionMu.Unlock()
299+
300+
go func() {
301+
<-ctx.Done()
302+
mgr.compressionMu.Lock()
303+
delete(mgr.compressionJobs, target.Key)
304+
mgr.compressionMu.Unlock()
305+
close(done)
306+
}()
307+
308+
return &target
309+
}
310+
311+
func assertCompressionJobCanceled(t *testing.T, mgr *manager, target *compressionTarget) {
312+
t.Helper()
313+
314+
require.Eventually(t, func() bool {
315+
mgr.compressionMu.Lock()
316+
defer mgr.compressionMu.Unlock()
317+
_, ok := mgr.compressionJobs[target.Key]
318+
return !ok
319+
}, time.Second, 10*time.Millisecond)
320+
}

0 commit comments

Comments
 (0)