slice 8B.2 (agent): emit snapshotted phase at storage-snapshot moment (v0.13.0)

BackupRunner.BackupWithSnapshotHook tails the task log for the 'create storage
snapshot' marker (snapshot mode only) and fires onSnapshot once; localapi flips
/backup/status to 'snapshotted' before 'done' so the controller resumes early.
Phase 0 validated on PVE 9.2.2: marker confirmed, downtime ~24s->~1s (934MB).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 14:54:18 +02:00
parent fe7b3c4ab7
commit 570410cd1a
8 changed files with 248 additions and 18 deletions
+53 -5
View File
@@ -64,18 +64,23 @@ func (f *fakeGuests) WaitTask(_ context.Context, _ string, _ proxmox.WaitOptions
}
type fakeBackups struct {
mu sync.Mutex
vmids []int
gate chan struct{} // if non-nil, Backup blocks until it is closed (observe the running phase)
failErr string // if set, Backup returns this error (drives the failed phase)
mu sync.Mutex
vmids []int
gate chan struct{} // if non-nil, the backup blocks until it is closed (observe phases)
failErr string // if set, the backup returns this error (drives the failed phase)
fireSnapshot bool // if true, invoke onSnapshot (simulates snapshot-mode detection, 8B.2)
}
func (f *fakeBackups) Backup(_ context.Context, vmid int) (hub.Backup, error) {
func (f *fakeBackups) BackupWithSnapshotHook(_ context.Context, vmid int, onSnapshot func()) (hub.Backup, error) {
f.mu.Lock()
f.vmids = append(f.vmids, vmid)
gate := f.gate
failErr := f.failErr
fire := f.fireSnapshot
f.mu.Unlock()
if fire && onSnapshot != nil {
onSnapshot() // snapshot taken (mid-backup) → phase snapshotted
}
if gate != nil {
<-gate
}
@@ -446,6 +451,49 @@ func statusPhase(t *testing.T, h http.Handler) string {
return resp.Data.Phase
}
func waitPhase(t *testing.T, h http.Handler, want string) {
t.Helper()
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
if statusPhase(t, h) == want {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("phase never reached %q (last=%q)", want, statusPhase(t, h))
}
// 8B.2: snapshot mode → the job flips to `snapshotted` mid-backup (before done), so the controller
// can resume its app early. The gated fake holds the backup open after firing onSnapshot.
func TestBackupStatus_Snapshotted_8B2(t *testing.T) {
b := &fakeBackups{gate: make(chan struct{}), fireSnapshot: true}
srv := newTestServerS(t, &fakeGuests{}, b, &fakeStore{}, nil)
h := srv.Handler()
if do(t, h, "POST", "/backup", "A", "").Code != http.StatusAccepted {
t.Fatal("POST /backup not accepted")
}
waitPhase(t, h, PhaseSnapshotted) // onSnapshot fired → snapshotted, while the backup is still open
close(b.gate) // let the backup complete
waitPhase(t, h, PhaseDone) // and it still proceeds to done
}
// 8B.2 fallback: stop/downgraded mode → onSnapshot never fires → phase never becomes `snapshotted`
// (the controller then resumes at done, exactly 8B).
func TestBackup_StopMode_NeverSnapshotted_8B2(t *testing.T) {
b := &fakeBackups{gate: make(chan struct{}), fireSnapshot: false}
srv := newTestServerS(t, &fakeGuests{}, b, &fakeStore{}, nil)
h := srv.Handler()
do(t, h, "POST", "/backup", "A", "")
time.Sleep(40 * time.Millisecond) // give the goroutine time; it must NOT snapshot
if ph := statusPhase(t, h); ph != PhaseRunning {
t.Fatalf("stop mode: phase %q, want running (snapshotted must not be emitted)", ph)
}
close(b.gate)
waitPhase(t, h, PhaseDone)
}
func TestBackupStatus_FiltersToThisGuest(t *testing.T) {
st := &fakeStore{backups: []hub.Backup{
{VMID: 9300, Success: true, StartedAt: "2026-06-10T10:00:00Z", Archive: "other"},