Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/offline-backup-pacing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

Let the offline backup helper copy large histories without the live 100 ms pause that made a two-hour export deadline unreachable. Keep live backup yielding so charging-goal saves stay within their latency limit, and refuse to start when the destination cannot hold the raw export, compressed archive and verification extract.
7 changes: 5 additions & 2 deletions docs/backup-and-restore.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,11 @@ ftw-backup revert -data /var/lib/ftw -safety /var/lib/.ftw-pre-restore-... -yes

Stop the native FTW service before `restore` or `revert`. `create` opens the
existing database read-only and does not migrate or repair its schema.
Pass `-config` to `create` when the seed has a name other than
`<data>/config.yaml`. The config seed must be inside the data directory.
It copies without the live 100 ms yield used while Core is collecting, and
refuses to start when the destination cannot hold the raw export, compressed
archive and verification extract. Pass `-config` to `create` when the seed has
a name other than `<data>/config.yaml`. The config seed must be inside the
data directory.

## Svenska – kortversion att skicka till en användare

Expand Down
17 changes: 10 additions & 7 deletions go/internal/backup/archive.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,9 @@ func Create(ctx context.Context, opts CreateOptions) (Info, error) {
if err := os.MkdirAll(outputDir, 0o700); err != nil {
return Info{}, fmt.Errorf("backup: create output dir: %w", err)
}
if err := state.EnsureDiskSpace(outputDir, state.BackupArchiveScratch(opts.State.BackupSourceBytes(), 0)); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include non-database files in the space reservation

When dataDir contains substantial cold Parquet history or other persistent files, this preflight counts only the SQLite state/history files because extraBytes is always zero, while collectSources later adds those files to the archive. A target with room for three copies of a small database but not the cold archive therefore passes the promised refusal check and fills during creation; inventory and include the non-database source sizes before checking free space.

AGENTS.md reference: AGENTS.md:L24-L25

Useful? React with 👍 / 👎.

return Info{}, err
}
if opts.Maintenance != nil {
opts.Maintenance.Lock()
defer opts.Maintenance.Unlock()
Expand Down Expand Up @@ -245,7 +248,7 @@ func Create(ctx context.Context, opts CreateOptions) (Info, error) {
}

tmpPath := filepath.Join(outputDir, "."+id+".tmp")
if err := writeArchive(ctx, tmpPath, manifest, sources); err != nil {
if err := writeArchive(ctx, tmpPath, manifest, sources, !opts.State.OfflineBackup()); err != nil {
_ = os.Remove(tmpPath)
return Info{}, err
}
Expand Down Expand Up @@ -381,7 +384,7 @@ func describeSource(ctx context.Context, dataDir string, source sourceEntry) (Fi
return entry, err
}

func writeArchive(ctx context.Context, dst string, manifest Manifest, sources []sourceEntry) error {
func writeArchive(ctx context.Context, dst string, manifest Manifest, sources []sourceEntry, live bool) error {
f, err := os.OpenFile(dst, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
return err
Expand All @@ -393,7 +396,7 @@ func writeArchive(ctx context.Context, dst string, manifest Manifest, sources []
_ = os.Remove(dst)
}
}()
zw, err := gzip.NewWriterLevel(state.NewMaintenanceWriter(ctx, f), gzip.BestSpeed)
zw, err := gzip.NewWriterLevel(state.NewMaintenanceWriterPaced(ctx, f, live), gzip.BestSpeed)
if err != nil {
return err
}
Expand Down Expand Up @@ -491,7 +494,7 @@ func Verify(archivePath string) (Manifest, error) {
want[entry.Path] = entry
}
seen := make(map[string]bool, len(want))
tmpDir, err := os.MkdirTemp("", ".ftw-backup-verify-")
tmpDir, err := os.MkdirTemp(filepath.Dir(archivePath), ".ftw-backup-verify-")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve verification for read-only archive locations

When an archive is stored on a read-only USB mount or in a directory the invoking user cannot modify, creating this temporary directory fails even though the archive is readable and the restore destination is writable. Because both Inspect and RestoreContents call Verify, this also prevents inspecting or restoring such backups; use a caller-writable workspace or restrict same-filesystem staging to the creation path.

AGENTS.md reference: AGENTS.md:L24-L25

Useful? React with 👍 / 👎.

if err != nil {
return Manifest{}, err
}
Expand Down Expand Up @@ -569,7 +572,7 @@ func Verify(archivePath string) (Manifest, error) {
if len(seen) != len(want) {
return Manifest{}, errors.New("backup: archive is missing one or more manifest files")
}
if err := verifyCompressedDatabase(dbGzip); err != nil {
if err := verifyCompressedDatabase(dbGzip, tmpDir); err != nil {
return Manifest{}, err
}
return manifest, nil
Expand Down Expand Up @@ -1055,8 +1058,8 @@ func extractArchive(archivePath, staging string) error {
return verifyDatabase(filepath.Join(staging, filepath.FromSlash(manifest.DatabaseFile)))
}

func verifyCompressedDatabase(src string) error {
tmp, err := os.CreateTemp("", ".ftw-backup-db-*.sqlite")
func verifyCompressedDatabase(src, workDir string) error {
tmp, err := os.CreateTemp(workDir, ".ftw-backup-db-*.sqlite")
if err != nil {
return err
}
Expand Down
69 changes: 68 additions & 1 deletion go/internal/backup/archive_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ func TestBackupRejectsUnreadableParquetWithMatchingHash(t *testing.T) {
manifest.Files = append(manifest.Files, entry)
}
archive := filepath.Join(t.TempDir(), "bad.ftwbak")
if err := writeArchive(context.Background(), archive, manifest, sources); err != nil {
if err := writeArchive(context.Background(), archive, manifest, sources, true); err != nil {
t.Fatal(err)
}
if _, err := Verify(archive); err == nil {
Expand All @@ -81,6 +81,73 @@ func TestBackupRejectsUnreadableParquetWithMatchingHash(t *testing.T) {
}
}

func TestOfflineBackupCreateVerifyRestore(t *testing.T) {
root := t.TempDir()
dataDir := filepath.Join(root, "data")
if err := os.MkdirAll(dataDir, 0o700); err != nil {
t.Fatal(err)
}
statePath := filepath.Join(dataDir, "state.db")
st, err := state.Open(statePath)
if err != nil {
t.Fatal(err)
}
if err := st.SaveConfig("ev_goal", "80% by 07:00"); err != nil {
t.Fatal(err)
}
points := make([]state.HistoryPoint, 2500)
for i := range points {
points[i] = state.HistoryPoint{TsMs: int64(i + 1), GridW: float64(i)}
}
if err := st.BulkRecordHistory(points); err != nil {
t.Fatal(err)
}
if err := st.Close(); err != nil {
t.Fatal(err)
}
src, err := state.OpenBackupSource(statePath)
if err != nil {
t.Fatal(err)
}
defer src.Close()
if !src.OfflineBackup() {
t.Fatal("offline helper was treated as a live Core store")
}
info, err := Create(context.Background(), CreateOptions{
State: src, StatePath: statePath, DataDir: dataDir,
OutputDir: filepath.Join(root, "backups"),
})
if err != nil {
t.Fatal(err)
}
blocked := filepath.Join(t.TempDir(), "not-a-dir")
if err := os.WriteFile(blocked, []byte("file"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("TMPDIR", blocked)
t.Setenv("TMP", blocked)
t.Setenv("TEMP", blocked)
if _, err := Verify(info.Path); err != nil {
t.Fatal(err)
}
restoredDir := filepath.Join(root, "restored")
if _, err := Restore(info.Path, restoredDir, time.Time{}); err != nil {
t.Fatal(err)
}
restored, err := state.Open(filepath.Join(restoredDir, "state.db"))
if err != nil {
t.Fatal(err)
}
defer restored.Close()
if goal, ok := restored.LoadConfig("ev_goal"); !ok || goal != "80% by 07:00" {
t.Fatalf("restored goal = %q ok=%v", goal, ok)
}
h, err := restored.LoadHistory(0, 3000, 0)
if err != nil || len(h) != 2500 || h[0].GridW != 0 || h[2499].GridW != 2499 {
t.Fatalf("restored history = %d %v %+v", len(h), err, h)
}
}

func TestCreateVerifyAndRestoreCompleteBackup(t *testing.T) {
root := t.TempDir()
dataDir := filepath.Join(root, "data")
Expand Down
156 changes: 156 additions & 0 deletions go/internal/state/backup_pace_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package state

import (
"context"
"database/sql"
"errors"
"path/filepath"
"testing"
"time"
)

func TestBackupWorkContextOmitsDeadlineWhenOffline(t *testing.T) {
offline := &Store{offlineBackup: true}
ctx, cancel := offline.backupWorkContext()
defer cancel()
if _, ok := ctx.Deadline(); ok {
t.Fatal("offline backup must not inherit the live export deadline")
}

live := &Store{}
ctx, cancel = live.backupWorkContext()
defer cancel()
deadline, ok := ctx.Deadline()
if !ok {
t.Fatal("live backup must keep an export deadline")
}
until := time.Until(deadline)
if until > liveBackupTimeout || until < liveBackupTimeout-time.Minute {
t.Fatalf("live backup deadline = %v", until)
}
}

func TestLiveBackupCopyYieldsBetweenBatches(t *testing.T) {
s := freshStore(t)
pauses := 0
s.backupPause = func(context.Context) error { pauses++; return nil }
if err := s.BulkRecordHistory(pacedBackupPoints(2500)); err != nil {
t.Fatal(err)
}
dst := filepath.Join(t.TempDir(), "export.db")
if err := s.copyStateForBackup(context.Background(), dst); err != nil {
t.Fatal(err)
}
if err := s.exportHistoryToSQLite(context.Background(), dst); err != nil {
t.Fatal(err)
}
if pauses == 0 {
t.Fatal("live backup must yield between copy batches")
}
}

func TestOfflineBackupCopyDoesNotYieldBetweenBatches(t *testing.T) {
s := freshStore(t)
if err := s.BulkRecordHistory(pacedBackupPoints(2500)); err != nil {
t.Fatal(err)
}
path := s.mainDBPath
if err := s.Close(); err != nil {
t.Fatal(err)
}
src, err := OpenBackupSource(path)
if err != nil {
t.Fatal(err)
}
defer src.Close()
if !src.OfflineBackup() {
t.Fatal("OpenBackupSource must mark the helper offline")
}
pauses := 0
src.backupPause = func(context.Context) error {
pauses++
return nil
}
dst := filepath.Join(t.TempDir(), "export.db")
if err := src.copyStateForBackup(context.Background(), dst); err != nil {
t.Fatal(err)
}
if err := src.exportHistoryToSQLite(context.Background(), dst); err != nil {
t.Fatal(err)
}
if pauses != 0 {
t.Fatalf("offline backup paused %d times; live 100ms yield must not apply", pauses)
}
db, err := sql.Open("sqlite", dst)
if err != nil {
t.Fatal(err)
}
defer db.Close()
var n int
if err := db.QueryRow(`SELECT COUNT(*) FROM history_hot`).Scan(&n); err != nil || n != 2500 {
t.Fatalf("offline export lost rows: %d %v", n, err)
}
}

func TestBackupArchiveScratchReservesRawGzipAndVerify(t *testing.T) {
const source, extra int64 = 100, 7
got := BackupArchiveScratch(source, extra)
if got < 3*source+extra+backupScratchHeadroom {
t.Fatalf("scratch %d omits a coexistence phase", got)
}
// Issue #1259: 174,629,397 sample rows with a 100 ms pause every 1,024
// rows spend 4h44m paused, so the live 2h export deadline must fire.
const rows int64 = 174629397
livePause := time.Duration(rows/1024) * maintenancePause
if livePause <= liveBackupTimeout {
t.Fatalf("live pause floor %v no longer exceeds the %v export deadline", livePause, liveBackupTimeout)
}
if backupCopyScratch(source) < 2*source {
t.Fatal("copy scratch must hold raw export and gzip together")
}
}

func TestEnsureDiskSpaceRejectsShortFilesystem(t *testing.T) {
orig := backupDiskAvail
t.Cleanup(func() { backupDiskAvail = orig })
needed := BackupArchiveScratch(100, 0)
backupDiskAvail = func(string) (int64, error) { return needed - 1, nil }
if err := EnsureDiskSpace(t.TempDir(), needed); err == nil {
t.Fatal("accepted a filesystem smaller than the export scratch")
}
backupDiskAvail = func(string) (int64, error) { return needed, nil }
if err := EnsureDiskSpace(t.TempDir(), needed); err != nil {
t.Fatal(err)
}
}

func TestEnsureDiskSpaceSkipsUnknownProbe(t *testing.T) {
orig := backupDiskAvail
t.Cleanup(func() { backupDiskAvail = orig })
backupDiskAvail = func(string) (int64, error) { return 0, errors.New("unsupported") }
if err := EnsureDiskSpace(t.TempDir(), 1<<40); err != nil {
t.Fatal(err)
}
}

func TestBackupToCompressedPreflightUsesSourceSize(t *testing.T) {
s := freshStore(t)
if err := s.SaveConfig("goal", "80%"); err != nil {
t.Fatal(err)
}
orig := backupDiskAvail
t.Cleanup(func() { backupDiskAvail = orig })
backupDiskAvail = func(string) (int64, error) { return 1, nil }
err := s.BackupToCompressed(filepath.Join(t.TempDir(), "full.gz"))
if err == nil {
t.Fatal("published a backup without room for scratch files")
}
}

func pacedBackupPoints(n int) []HistoryPoint {
points := make([]HistoryPoint, n)
for i := range points {
points[i] = HistoryPoint{TsMs: int64(i + 1), GridW: float64(i)}
}
return points
}
Loading