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
+26 -8
View File
@@ -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) {