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:
@@ -48,10 +48,33 @@ func NewBackupRunner(api BackupAPI, target string, mode proxmox.BackupMode, note
|
||||
return &BackupRunner{api: api, target: target, mode: mode, notes: notes, logger: logger, now: func() time.Time { return time.Now().UTC() }}
|
||||
}
|
||||
|
||||
// Backup runs one vzdump of vmid to the local target and returns the report record. A
|
||||
// failure is returned BOTH as an error and as a Backup{Success:false,...} so the caller can
|
||||
// record the failed attempt.
|
||||
// snapshotMarker is the vzdump task-log line that signals the storage snapshot has been created
|
||||
// and the backup is now reading from it — the point after which resuming the guest's app cannot
|
||||
// affect the backup (slice 8B.2; validated on PVE 9.2.2: `INFO: create storage snapshot 'vzdump'`).
|
||||
// It only appears in snapshot mode (stop mode takes no storage snapshot), so its presence ⟹
|
||||
// snapshot mode — the basis for the controller's early resume.
|
||||
const snapshotMarker = "create storage snapshot"
|
||||
|
||||
// snapshotWatchInterval is how often watchForSnapshot polls the task log. A package var so tests
|
||||
// can shrink it (production: poll once a second — the marker appears in the first ~1s, §0).
|
||||
var snapshotWatchInterval = time.Second
|
||||
|
||||
// Backup runs one vzdump of vmid to the local target and returns the report record.
|
||||
func (r *BackupRunner) Backup(ctx context.Context, vmid int) (hub.Backup, error) {
|
||||
return r.backup(ctx, vmid, nil)
|
||||
}
|
||||
|
||||
// BackupWithSnapshotHook is Backup plus an onSnapshot callback invoked ONCE, mid-backup, when the
|
||||
// storage snapshot has been taken (snapshot mode only) — the 8B.2 early-resume signal. In
|
||||
// stop/downgraded mode the marker never appears, so onSnapshot is never called (the caller then
|
||||
// resumes at completion). onSnapshot must be cheap + non-blocking (it runs on a watcher goroutine).
|
||||
func (r *BackupRunner) BackupWithSnapshotHook(ctx context.Context, vmid int, onSnapshot func()) (hub.Backup, error) {
|
||||
return r.backup(ctx, vmid, onSnapshot)
|
||||
}
|
||||
|
||||
// backup is the shared body. A failure is returned BOTH as an error and as a
|
||||
// Backup{Success:false,...} so the caller can record the failed attempt.
|
||||
func (r *BackupRunner) backup(ctx context.Context, vmid int, onSnapshot func()) (hub.Backup, error) {
|
||||
start := r.now()
|
||||
rec := hub.Backup{
|
||||
TargetID: r.target,
|
||||
@@ -83,6 +106,13 @@ func (r *BackupRunner) Backup(ctx context.Context, vmid int) (hub.Backup, error)
|
||||
return rec, fmt.Errorf("backup: vzdump vmid %d: %w", vmid, err)
|
||||
}
|
||||
if upid != "" {
|
||||
// 8B.2: while the backup runs, watch the task log for the storage-snapshot marker and
|
||||
// fire onSnapshot once (snapshot mode only) so the controller can resume its app early.
|
||||
if onSnapshot != nil {
|
||||
watchCtx, stopWatch := context.WithCancel(ctx)
|
||||
defer stopWatch()
|
||||
go r.watchForSnapshot(watchCtx, upid, onSnapshot)
|
||||
}
|
||||
if _, err := r.api.WaitTask(ctx, upid, proxmox.WaitOptions{Timeout: 30 * time.Minute}); err != nil {
|
||||
rec.Error = err.Error()
|
||||
rec.DurationSeconds = time.Since(start).Seconds()
|
||||
@@ -113,6 +143,36 @@ func (r *BackupRunner) Backup(ctx context.Context, vmid int) (hub.Backup, error)
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
// watchForSnapshot polls the running backup's task log until it sees the storage-snapshot marker
|
||||
// (→ onSnapshot once) or the requested mode is reported as `stop` (→ downgraded; the marker will
|
||||
// never come, so stop watching) or ctx is cancelled (backup finished). Best-effort: a log-read
|
||||
// error is retried on the next tick; onSnapshot fires at most once.
|
||||
func (r *BackupRunner) watchForSnapshot(ctx context.Context, upid string, onSnapshot func()) {
|
||||
ticker := time.NewTicker(snapshotWatchInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
lines, err := r.api.TaskLogTail(ctx, upid, 200)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// A stop-mode (or downgraded) backup never creates a storage snapshot → never resume early.
|
||||
if m := parseBackupMode(lines); m != "" && m != string(proxmox.ModeSnapshot) {
|
||||
return
|
||||
}
|
||||
for _, ln := range lines {
|
||||
if strings.Contains(ln, snapshotMarker) {
|
||||
onSnapshot()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PickRestoreCandidate returns the newest backup archive on the target (any guest), or ""
|
||||
// when there is none — the restore-test then no-ops cleanly.
|
||||
func (r *BackupRunner) PickRestoreCandidate(ctx context.Context) (string, error) {
|
||||
|
||||
Reference in New Issue
Block a user