feat(backup): async restore family — no proxy-timeout error page on a succeeding restore (v0.102.0)

Re-adjudicates F4: /backup/restore, /backup/tier2/restore, /backup/offbox/restore
blocked the HTTP request until completion, so through cloudflared's 100s cap a
customer got an error page while the restore succeeded (offbox worse — bounded
on r.Context(), canceling the SFTP restore mid-flight). Convert all three to the
offboxRun async shape: fast-path IsRunning refuse, background goroutine
(offbox ctx off r.Context() -> Background+30m), instant redirect. Add mutex-
guarded op-status (opstatus.go) + GET /api/backup/restore-status + a 3s-polling
backups.html banner (neutral running, red on failure). Restore single-flight
unchanged. Tests + red-proof (sync handler blocks indefinitely vs <500ms async).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-06 20:23:49 +02:00
parent 9d5a588ca3
commit c529a455af
9 changed files with 429 additions and 41 deletions
+7
View File
@@ -63,6 +63,13 @@ type Manager struct {
lastDBDump *DBDumpStatus
running bool
// Restore op-status (Part B, opstatus.go) — display-only async-restore progress, under `mu`.
opRunning bool
opName string
opStack string
opStartedAt time.Time
opLast *RestoreOpResult
// Cached status for page rendering (refreshed periodically)
cachedStatus *FullBackupStatus
cacheTime time.Time
+71
View File
@@ -0,0 +1,71 @@
package backup
import "time"
// Restore op-status (Part B): a lightweight, in-memory surface for the ASYNC restore family so the
// backups page can show a progress banner (running → success/failure) instead of blocking the HTTP
// request until the restore completes. It is display-only and mutex-guarded on the Manager's `mu`;
// it does NOT gate concurrency (that stays the restore functions' internal single-flight acquire).
// In-memory only — lost on a controller restart (same precedent as notification cooldowns); a page
// load mid-op after a restart simply shows no banner.
// RestoreOpResult is the terminal record of the most recent restore op.
type RestoreOpResult struct {
Op string `json:"op"` // "restore" | "tier2-restore" | "offbox-restore"
Stack string `json:"stack"`
OK bool `json:"ok"`
Message string `json:"message"`
FinishedAt time.Time `json:"finished_at"`
}
// RestoreOpStatus is the shape served at GET /api/backup/restore-status.
type RestoreOpStatus struct {
Running bool `json:"running"`
Op string `json:"op,omitempty"`
Stack string `json:"stack,omitempty"`
StartedAt time.Time `json:"started_at,omitempty"`
Last *RestoreOpResult `json:"last,omitempty"`
}
// BeginRestoreOp marks a restore op in flight (called by the handler just before launching the
// background goroutine). Idempotent enough for display; concurrency is enforced elsewhere.
func (m *Manager) BeginRestoreOp(op, stack string) {
m.mu.Lock()
defer m.mu.Unlock()
m.opRunning = true
m.opName = op
m.opStack = stack
m.opStartedAt = time.Now()
}
// EndRestoreOp records the terminal result (called from the goroutine on completion, success or
// failure). Message carries the error (failure) or a human note like the scratch path (offbox).
func (m *Manager) EndRestoreOp(ok bool, message string) {
m.mu.Lock()
defer m.mu.Unlock()
m.opLast = &RestoreOpResult{
Op: m.opName,
Stack: m.opStack,
OK: ok,
Message: message,
FinishedAt: time.Now(),
}
m.opRunning = false
}
// RestoreStatus returns a deep copy of the current restore op-status for the page/API.
func (m *Manager) RestoreStatus() RestoreOpStatus {
m.mu.Lock()
defer m.mu.Unlock()
st := RestoreOpStatus{
Running: m.opRunning,
Op: m.opName,
Stack: m.opStack,
StartedAt: m.opStartedAt,
}
if m.opLast != nil {
cp := *m.opLast
st.Last = &cp
}
return st
}
@@ -0,0 +1,55 @@
package backup
import "testing"
// TestRestoreOpStatus covers the async restore op-status surface (Part B): begin → running,
// end(success/failure) → terminal record, and the deep-copy getter (mutating the returned value must
// not corrupt the Manager's state).
func TestRestoreOpStatus(t *testing.T) {
m := &Manager{}
// idle
if st := m.RestoreStatus(); st.Running || st.Last != nil {
t.Fatalf("idle status should be empty: %+v", st)
}
// begin → running with op/stack
m.BeginRestoreOp("restore", "vaultwarden")
st := m.RestoreStatus()
if !st.Running || st.Op != "restore" || st.Stack != "vaultwarden" {
t.Fatalf("running status = %+v, want running restore/vaultwarden", st)
}
if st.StartedAt.IsZero() {
t.Error("StartedAt not stamped")
}
// end(success) → not running, terminal Last carries the message + op + stack
m.EndRestoreOp(true, "kész")
st = m.RestoreStatus()
if st.Running {
t.Error("still running after EndRestoreOp")
}
if st.Last == nil || !st.Last.OK || st.Last.Message != "kész" || st.Last.Op != "restore" || st.Last.Stack != "vaultwarden" {
t.Fatalf("terminal = %+v, want ok restore/vaultwarden 'kész'", st.Last)
}
// deep copy: mutating the returned Last must NOT change the Manager's stored record.
st.Last.Message = "MUTATED"
if again := m.RestoreStatus(); again.Last.Message != "kész" {
t.Fatalf("RestoreStatus is not deep-copied: internal message = %q", again.Last.Message)
}
// failure path → terminal Last.OK false with the error message and the new op.
m.BeginRestoreOp("tier2-restore", "paperless")
if !m.RestoreStatus().Running {
t.Error("second op not running")
}
m.EndRestoreOp(false, "Fájl-visszaállítás sikertelen: boom")
st = m.RestoreStatus()
if st.Running || st.Last.OK || st.Last.Op != "tier2-restore" || st.Last.Stack != "paperless" {
t.Fatalf("failure terminal = %+v", st.Last)
}
if st.Last.Message != "Fájl-visszaállítás sikertelen: boom" {
t.Errorf("failure message = %q", st.Last.Message)
}
}