Skip to content

Commit 4e4c6d7

Browse files
rgarciaclaude
andcommitted
Compress vz machine state and overlap quiesce with disk copy
Implements the two remaining sections of the faster-standby-restore RFC (Section C / APFS clonefile already shipped in #276). Section A.1 — teach the snapshot-compression subsystem about the vz machine-state file. Add machine-state.vzm to snapshotMemoryFileCandidates at both the standby-latest level and the stored-payload-nested level, so the existing async job compresses it and ensureSnapshotMemoryReady decompresses it before restore. The filename constant moves to a build-tag-free file in shimconfig (mirroring RosettaMountTag) so the host-portable compression code can name it without pulling the darwin-only package. Section A.2 — optional synchronous compression in the shim. Add a darwin/arm64 compressMachineStateFile helper (klauspost zstd; the vz Save API is path-based and cannot stream-compress, so it is a post-write pass) with a matching error stub off arm64 so the shim never references zstd there. Gated behind a new CompressMachineState flag on the shim snapshot request; the manifest still names the raw file and the .zst is discovered by suffix. Section B — overlap quiesce with the disk copy. After the VM is paused, standbyInstance now runs the machine-state save and the guest-disk CoW-clone concurrently under errgroup instead of serially, via an optional overlap the running-source snapshot path supplies (vz only; other hypervisors and auto-standby keep the serial path, byte-identical). The overlapped disk copy prunes the snapshots subtree the save is writing and copies it afterward, so the two legs never race; on either leg failing the source is resumed and the partial snapshot dir is cleaned, matching the serial path. forkvm's SkipRelativePaths now prunes a matched directory's whole subtree (files still drop individually). Plumbs CompressMachineState (+ optional level) through hypervisor.SnapshotOptions -> vz Client.Snapshot -> shim request -> handleSnapshot. When false (the default and the only value non-vz hypervisors ever see) behavior is byte-identical to before. Tests: machine-state participates in the compression candidate set and .zst discovery; compress/decompress round-trips a synthetic .vzm; the errgroup cancels its sibling on failure; standbyInstance resumes the source when the disk leg fails; forkvm prunes a skipped directory subtree; and a darwin/arm64 round-trip for compressMachineStateFile. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent b7ad4e5 commit 4e4c6d7

17 files changed

Lines changed: 574 additions & 20 deletions

cmd/vz-shim/save_restore_arm64.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@ package main
44

55
import (
66
"fmt"
7+
"io"
8+
"os"
79

810
"github.com/Code-Hex/vz/v3"
11+
"github.com/klauspost/compress/zstd"
912
)
1013

1114
func validateSaveRestoreSupport(vmConfig *vz.VirtualMachineConfiguration) error {
@@ -27,3 +30,55 @@ func restoreMachineState(vm *vz.VirtualMachine, snapshotPath string) error {
2730
// The vz wrapper accepts a filesystem path and constructs a file URL internally.
2831
return vm.RestoreMachineStateFromURL(snapshotPath)
2932
}
33+
34+
// compressMachineStateFile zstd-compresses statePath in place, producing
35+
// statePath+".zst" and removing the raw file on success. The vz framework
36+
// writes the whole machine-state file itself (SaveMachineStateToPath exposes no
37+
// io.Writer), so compression is a post-write pass over the complete file. Used
38+
// when the control plane overlaps the disk copy and wants the state file already
39+
// small before it reads it back. The control plane decompresses .zst -> raw in
40+
// ensureSnapshotMemoryReady before restore, so the discovered-by-suffix
41+
// convention matches the async compression job.
42+
func compressMachineStateFile(statePath string, level int) (string, error) {
43+
src, err := os.Open(statePath)
44+
if err != nil {
45+
return "", err
46+
}
47+
defer src.Close()
48+
49+
// .zst.tmp is already skipped by the forkvm copy walk, so a concurrent disk
50+
// clone never picks up the partial file.
51+
tmp := statePath + ".zst.tmp"
52+
dst, err := os.Create(tmp)
53+
if err != nil {
54+
return "", err
55+
}
56+
enc, err := zstd.NewWriter(dst, zstd.WithEncoderLevel(zstd.EncoderLevelFromZstd(level)))
57+
if err != nil {
58+
dst.Close()
59+
_ = os.Remove(tmp)
60+
return "", err
61+
}
62+
if _, err := io.Copy(enc, src); err != nil {
63+
enc.Close()
64+
dst.Close()
65+
_ = os.Remove(tmp)
66+
return "", err
67+
}
68+
if err := enc.Close(); err != nil {
69+
dst.Close()
70+
_ = os.Remove(tmp)
71+
return "", err
72+
}
73+
if err := dst.Close(); err != nil {
74+
_ = os.Remove(tmp)
75+
return "", err
76+
}
77+
final := statePath + ".zst"
78+
if err := os.Rename(tmp, final); err != nil {
79+
_ = os.Remove(tmp)
80+
return "", err
81+
}
82+
_ = os.Remove(statePath)
83+
return final, nil
84+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
//go:build darwin && arm64
2+
3+
package main
4+
5+
import (
6+
"io"
7+
"os"
8+
"path/filepath"
9+
"testing"
10+
11+
snapshotstore "github.com/kernel/hypeman/lib/snapshot"
12+
"github.com/klauspost/compress/zstd"
13+
"github.com/stretchr/testify/assert"
14+
"github.com/stretchr/testify/require"
15+
)
16+
17+
// TestCompressMachineStateFileRoundTrip asserts compressMachineStateFile
18+
// produces machine-state.vzm.zst, removes the raw file, and that the compressed
19+
// bytes decompress back to the original (Section A.2).
20+
func TestCompressMachineStateFileRoundTrip(t *testing.T) {
21+
dir := t.TempDir()
22+
statePath := filepath.Join(dir, "machine-state.vzm")
23+
payload := make([]byte, 1<<20)
24+
for i := range payload {
25+
payload[i] = byte(i*17 + i/5)
26+
}
27+
require.NoError(t, os.WriteFile(statePath, payload, 0o644))
28+
29+
out, err := compressMachineStateFile(statePath, snapshotstore.DefaultSnapshotCompressionZstdLevel)
30+
require.NoError(t, err)
31+
assert.Equal(t, statePath+".zst", out)
32+
33+
_, err = os.Stat(statePath)
34+
assert.True(t, os.IsNotExist(err), "raw machine state should be removed after compression")
35+
_, err = os.Stat(statePath + ".zst.tmp")
36+
assert.True(t, os.IsNotExist(err), "temp file should not linger")
37+
38+
f, err := os.Open(out)
39+
require.NoError(t, err)
40+
defer f.Close()
41+
dec, err := zstd.NewReader(f)
42+
require.NoError(t, err)
43+
defer dec.Close()
44+
got, err := io.ReadAll(dec)
45+
require.NoError(t, err)
46+
assert.Equal(t, payload, got)
47+
}
48+
49+
func TestCompressMachineStateFileMissingSource(t *testing.T) {
50+
_, err := compressMachineStateFile(filepath.Join(t.TempDir(), "absent.vzm"), 0)
51+
require.Error(t, err)
52+
}
53+
54+
func TestResolveSnapshotCompressionLevel(t *testing.T) {
55+
assert.Equal(t, snapshotstore.DefaultSnapshotCompressionZstdLevel, resolveSnapshotCompressionLevel(0))
56+
assert.Equal(t, snapshotstore.DefaultSnapshotCompressionZstdLevel, resolveSnapshotCompressionLevel(-3))
57+
assert.Equal(t, 5, resolveSnapshotCompressionLevel(5))
58+
assert.Equal(t, snapshotstore.MaxSnapshotCompressionZstdLevel, resolveSnapshotCompressionLevel(1000))
59+
}

cmd/vz-shim/save_restore_unsupported.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,7 @@ func saveMachineState(vm *vz.VirtualMachine, snapshotPath string) error {
2020
func restoreMachineState(vm *vz.VirtualMachine, snapshotPath string) error {
2121
return fmt.Errorf("save/restore is only supported on darwin/arm64 (current arch: %s)", runtime.GOARCH)
2222
}
23+
24+
func compressMachineStateFile(statePath string, level int) (string, error) {
25+
return "", fmt.Errorf("save/restore is only supported on darwin/arm64 (current arch: %s)", runtime.GOARCH)
26+
}

cmd/vz-shim/server.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616

1717
"github.com/Code-Hex/vz/v3"
1818
"github.com/kernel/hypeman/lib/hypervisor/vz/shimconfig"
19+
snapshotstore "github.com/kernel/hypeman/lib/snapshot"
1920
)
2021

2122
// ShimServer handles control API and vsock proxy for a vz VM.
@@ -43,6 +44,13 @@ type VMInfoResponse struct {
4344

4445
type snapshotRequest struct {
4546
DestinationPath string `json:"destination_path"`
47+
// CompressMachineState, when set, makes the shim zstd-compress
48+
// machine-state.vzm before returning success. Used when the control plane
49+
// overlaps the disk copy and wants the state file already small.
50+
CompressMachineState bool `json:"compress_machine_state,omitempty"`
51+
// CompressionLevel is the zstd level used when CompressMachineState is set.
52+
// Zero falls back to the snapshot default.
53+
CompressionLevel int `json:"compression_level,omitempty"`
4654
}
4755

4856
type balloonRequest struct {
@@ -181,6 +189,13 @@ func (s *ShimServer) handleSnapshot(w http.ResponseWriter, r *http.Request) {
181189
return
182190
}
183191

192+
if req.CompressMachineState {
193+
if _, err := compressMachineStateFile(machineStatePath, resolveSnapshotCompressionLevel(req.CompressionLevel)); err != nil {
194+
http.Error(w, fmt.Sprintf("compress machine state failed: %v", err), http.StatusInternalServerError)
195+
return
196+
}
197+
}
198+
184199
manifestPath := filepath.Join(req.DestinationPath, shimconfig.SnapshotManifestFile)
185200
tmpManifestPath := manifestPath + ".tmp"
186201
manifest := shimconfig.SnapshotManifest{
@@ -295,6 +310,18 @@ func (s *ShimServer) handleVMMShutdown(w http.ResponseWriter, r *http.Request) {
295310
}()
296311
}
297312

313+
// resolveSnapshotCompressionLevel maps the request's zstd level onto the valid
314+
// range, treating zero (the JSON-omitted default) as the snapshot default.
315+
func resolveSnapshotCompressionLevel(level int) int {
316+
if level <= 0 {
317+
return snapshotstore.DefaultSnapshotCompressionZstdLevel
318+
}
319+
if level > snapshotstore.MaxSnapshotCompressionZstdLevel {
320+
return snapshotstore.MaxSnapshotCompressionZstdLevel
321+
}
322+
return level
323+
}
324+
298325
func vzStateToString(state vz.VirtualMachineState) string {
299326
switch state {
300327
case vz.VirtualMachineStateStopped:

lib/forkvm/copy.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,11 @@ func CopyGuestDirectoryWithOptions(srcDir, dstDir string, opts CopyOptions) erro
7979
return nil
8080
}
8181
if _, ok := opts.SkipRelativePaths[filepath.Clean(relPath)]; ok {
82+
// A skipped directory prunes its whole subtree; a skipped file is
83+
// dropped on its own.
84+
if d.IsDir() {
85+
return filepath.SkipDir
86+
}
8287
return nil
8388
}
8489
if d.IsDir() && shouldSkipDirectory(relPath) {

lib/forkvm/copy_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,24 @@ func TestCopyGuestDirectoryWithOptionsSkipsRelativePaths(t *testing.T) {
6464
assert.FileExists(t, filepath.Join(dst, "overlay.raw"))
6565
}
6666

67+
func TestCopyGuestDirectoryWithOptionsSkipsDirectorySubtree(t *testing.T) {
68+
src := filepath.Join(t.TempDir(), "src")
69+
dst := filepath.Join(t.TempDir(), "dst")
70+
71+
require.NoError(t, os.MkdirAll(filepath.Join(src, "snapshots", "snapshot-latest"), 0755))
72+
require.NoError(t, os.WriteFile(filepath.Join(src, "snapshots", "snapshot-latest", "machine-state.vzm"), []byte("state"), 0644))
73+
require.NoError(t, os.WriteFile(filepath.Join(src, "overlay.raw"), []byte("overlay"), 0644))
74+
75+
require.NoError(t, CopyGuestDirectoryWithOptions(src, dst, CopyOptions{
76+
SkipRelativePaths: map[string]struct{}{
77+
"snapshots": {},
78+
},
79+
}))
80+
81+
assert.NoDirExists(t, filepath.Join(dst, "snapshots"))
82+
assert.FileExists(t, filepath.Join(dst, "overlay.raw"))
83+
}
84+
6785
func TestCopyRegularFile(t *testing.T) {
6886
src := filepath.Join(t.TempDir(), "src", "memory")
6987
dst := filepath.Join(t.TempDir(), "dst", "snapshots", "snapshot-latest", "memory")

lib/hypervisor/hypervisor.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,15 @@ type RestoreOptions struct {
140140

141141
type SnapshotOptions struct {
142142
DeferredMemoryBackingPath string
143+
144+
// CompressMachineState requests that the hypervisor compress the saved
145+
// machine state before reporting the snapshot complete. Only the vz shim
146+
// honors it; every other hypervisor ignores it, so the default (false)
147+
// keeps behavior byte-identical to before this option existed.
148+
CompressMachineState bool
149+
// CompressionLevel is the optional zstd level for CompressMachineState.
150+
// Zero means the snapshot default.
151+
CompressionLevel int
143152
}
144153

145154
// ForkNetworkConfig contains network identity fields for fork preparation.

lib/hypervisor/vz/client.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,9 @@ type vmInfoResponse struct {
7171
}
7272

7373
type snapshotRequest struct {
74-
DestinationPath string `json:"destination_path"`
74+
DestinationPath string `json:"destination_path"`
75+
CompressMachineState bool `json:"compress_machine_state,omitempty"`
76+
CompressionLevel int `json:"compression_level,omitempty"`
7577
}
7678

7779
type balloonResponse struct {
@@ -249,8 +251,12 @@ func (c *Client) Resume(ctx context.Context) error {
249251
return c.doPut(ctx, "/api/v1/vm.resume", nil)
250252
}
251253

252-
func (c *Client) Snapshot(ctx context.Context, destPath string, _ hypervisor.SnapshotOptions) error {
253-
req := snapshotRequest{DestinationPath: destPath}
254+
func (c *Client) Snapshot(ctx context.Context, destPath string, opts hypervisor.SnapshotOptions) error {
255+
req := snapshotRequest{
256+
DestinationPath: destPath,
257+
CompressMachineState: opts.CompressMachineState,
258+
CompressionLevel: opts.CompressionLevel,
259+
}
254260
body, err := json.Marshal(req)
255261
if err != nil {
256262
return fmt.Errorf("marshal snapshot request: %w", err)

lib/hypervisor/vz/shimconfig/config.go

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,6 @@
44
// the hypeman API server and the vz-shim subprocess.
55
package shimconfig
66

7-
const (
8-
// SnapshotManifestFile is the metadata file stored in snapshot directories.
9-
// Kept as config.json to match existing snapshot path conventions.
10-
SnapshotManifestFile = "config.json"
11-
// SnapshotMachineStateFile is the serialized VM machine state filename.
12-
SnapshotMachineStateFile = "machine-state.vzm"
13-
)
14-
157
// ShimConfig is the configuration passed from hypeman to the shim.
168
type ShimConfig struct {
179
// Compute resources
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package shimconfig
2+
3+
// These snapshot path constants carry no build constraint so the host-portable
4+
// snapshot-compression subsystem (built on Linux) can name the vz machine-state
5+
// file when deciding what to compress/decompress, while the rest of the package
6+
// stays darwin-only.
7+
const (
8+
// SnapshotManifestFile is the metadata file stored in snapshot directories.
9+
// Kept as config.json to match existing snapshot path conventions.
10+
SnapshotManifestFile = "config.json"
11+
// SnapshotMachineStateFile is the serialized VM machine state filename.
12+
SnapshotMachineStateFile = "machine-state.vzm"
13+
)

0 commit comments

Comments
 (0)