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:
@@ -28,10 +28,12 @@ type GuestAPI interface {
|
||||
WaitTask(ctx context.Context, upid string, opts proxmox.WaitOptions) (proxmox.TaskStatus, error)
|
||||
}
|
||||
|
||||
// BackupService enqueues a (crash-consistent) vzdump/PBS backup of a guest. Satisfied by
|
||||
// *backup.BackupRunner. The app-consistent quiesce path is 8B.
|
||||
// BackupService enqueues a vzdump/PBS backup of a guest. Satisfied by *backup.BackupRunner.
|
||||
// BackupWithSnapshotHook (8B.2) invokes onSnapshot once mid-backup when the storage snapshot is
|
||||
// taken (snapshot mode only) so the controller can resume its app early; in stop mode it is never
|
||||
// called.
|
||||
type BackupService interface {
|
||||
Backup(ctx context.Context, vmid int) (hub.Backup, error)
|
||||
BackupWithSnapshotHook(ctx context.Context, vmid int, onSnapshot func()) (hub.Backup, error)
|
||||
}
|
||||
|
||||
// BackupStore records + reads the latest backup/restore-test state. Satisfied by *backup.Store.
|
||||
@@ -80,10 +82,11 @@ const defaultBackupCadence = 24 * time.Hour
|
||||
// Backup phase vocabulary reported by GET /backup/status (slice 8B). The 8B.2 fast-follow adds a
|
||||
// `snapshotted` phase (vzdump --mode snapshot) so the controller can unquiesce at snapshot-taken.
|
||||
const (
|
||||
PhaseIdle = "idle"
|
||||
PhaseRunning = "running"
|
||||
PhaseDone = "done"
|
||||
PhaseFailed = "failed"
|
||||
PhaseIdle = "idle"
|
||||
PhaseRunning = "running"
|
||||
PhaseSnapshotted = "snapshotted" // 8B.2: storage snapshot taken — app may resume; backup continues
|
||||
PhaseDone = "done"
|
||||
PhaseFailed = "failed"
|
||||
)
|
||||
|
||||
// backupJob is the in-flight/last backup job for one guest (drives /backup/status phases).
|
||||
@@ -413,7 +416,9 @@ func (s *Server) handleBackup(w http.ResponseWriter, r *http.Request, vmid int)
|
||||
go func() {
|
||||
bctx, cancel := context.WithTimeout(base, 2*time.Hour)
|
||||
defer cancel()
|
||||
b, err := s.backups.Backup(bctx, vmid)
|
||||
// 8B.2: flip the job to `snapshotted` when the storage snapshot is taken, so the
|
||||
// controller resumes its app early (snapshot mode only; in stop mode this never fires).
|
||||
b, err := s.backups.BackupWithSnapshotHook(bctx, vmid, func() { s.markSnapshotted(vmid, jobID) })
|
||||
if err != nil {
|
||||
b.VMID = vmid
|
||||
b.Success = false
|
||||
@@ -430,6 +435,19 @@ func (s *Server) handleBackup(w http.ResponseWriter, r *http.Request, vmid int)
|
||||
writeStatus(w, http.StatusAccepted, true, BackupResponse{VMID: vmid, JobID: jobID, Phase: PhaseRunning}, "")
|
||||
}
|
||||
|
||||
// markSnapshotted flips the guest's running job to the `snapshotted` phase (8B.2) — only if it is
|
||||
// still the current job and still running (don't regress done/failed, and don't touch a newer job).
|
||||
func (s *Server) markSnapshotted(vmid int, jobID string) {
|
||||
s.jobsMu.Lock()
|
||||
defer s.jobsMu.Unlock()
|
||||
cur := s.jobs[vmid]
|
||||
if cur == nil || cur.JobID != jobID || cur.Phase != PhaseRunning {
|
||||
return
|
||||
}
|
||||
cur.Phase = PhaseSnapshotted
|
||||
s.logger.Info("local-api: backup reached snapshotted (app may resume)", "vmid", vmid, "job", jobID)
|
||||
}
|
||||
|
||||
// finishJob transitions the guest's job to done/failed (only if it is still the current job — a
|
||||
// later job started after a single-flight gap must not be overwritten by an older one's result).
|
||||
func (s *Server) finishJob(vmid int, jobID string, b hub.Backup) {
|
||||
|
||||
@@ -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"},
|
||||
|
||||
Reference in New Issue
Block a user