fix(backup): snapshot the live DB with VACUUM INTO instead of a racy file copy (#92)

The in-app "Download backup" button exported the SQLite file with a plain
`os.Open` + `io.Copy` while the panel's GORM pool was still open and writing.
That copy races with concurrent writes (torn pages) and, even after the prior
WAL checkpoint, could leave committed frames in -wal that the copy never
replayed — an inconsistent or partially-lost backup.

Replace the checkpoint + file copy with SQLite's online backup primitive
VACUUM INTO, which reads through the WAL and in-flight transactions to produce
a self-contained, fully-replayed snapshot without stopping the panel.

Also stop bundling 3m-ui.db-wal / 3m-ui.db-shm into the zip. The VACUUM INTO
result is already complete (WAL merged in), so the sidecars were dead weight —
and they were never restored on import anyway, which silently dropped
un-checkpointed writes on restore.

Tests:
- TestWriteZipSnapshotIsConsistentAndSelfContained: a WAL-mode db with 200 rows
  held open by a live connection; WriteZip produces a zip whose 3m-ui.db opens
  standalone with all 200 rows and contains no -wal/-shm entries.
- go vet + go test -tags sqlite_modernc ./... green.

Co-authored-by: 風雪 <kazeyukiro@users.noreply.github.com>
This commit is contained in:
風雪and風雪 authored and GitHub committed 2026-10-05 17:17:27 +08:00
1 parent f18419da8b
commit d73613387d
2 files changed
+162 -13

No files matched your search

+54 -13
View File
@@ -8,6 +8,7 @@ import (
"io"
"os"
"path/filepath"
"strings"
"time"
_ "modernc.org/sqlite"
@@ -22,24 +23,63 @@ type BackupPaths struct {
// WriteZip creates a zip archive containing the SQLite database and Mihomo config
// when present. The caller owns closing the writer.
// flushSQLiteWAL checkpoints the WAL into the main database file so a plain
// file copy of dbPath contains recent writes (listener↔user bindings, etc.).
// Without this, ExportBackup can miss rows that only lived in the -wal file.
func flushSQLiteWAL(dbPath string) {
// snapshotSQLiteDB produces a crash-consistent copy of the live SQLite database
// at dbPath into destPath without stopping the running panel.
//
// The panel's own GORM connection pool keeps the database open and may write to
// it at any moment. A raw file copy of dbPath (the old approach) races with
// those writes and can capture a torn page, and a WAL checkpoint alone is not
// enough — under a busy writer the checkpoint can leave frames in -wal that a
// plain copy never replays. Instead we use SQLite's online backup primitive
// VACUUM INTO, which reads through the WAL and any in-flight transactions to
// produce a self-contained, fully-replayed snapshot. The destination is a
// complete database file, so the backup no longer needs (and must not carry)
// the -wal/-shm sidecars.
func snapshotSQLiteDB(dbPath, destPath string) error {
if dbPath == "" {
return
return fmt.Errorf("database path is empty")
}
// VACUUM INTO requires the destination to not already exist.
if err := os.Remove(destPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("prepare snapshot destination: %w", err)
}
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return
return fmt.Errorf("open live database: %w", err)
}
defer db.Close()
_, _ = db.Exec(`PRAGMA busy_timeout=5000`)
_, _ = db.Exec(`PRAGMA wal_checkpoint(TRUNCATE)`)
if _, err := db.Exec(`PRAGMA busy_timeout=5000`); err != nil {
return fmt.Errorf("set busy_timeout: %w", err)
}
// VACUUM INTO takes a filename literal, not a bind parameter.
if _, err := db.Exec(fmt.Sprintf("VACUUM INTO %s", sqliteQuotePath(destPath))); err != nil {
return fmt.Errorf("vacuum into %s: %w", destPath, err)
}
return nil
}
// sqliteQuotePath quotes a filesystem path as a single-quoted SQLite string
// literal, escaping embedded single quotes per SQLite string rules.
func sqliteQuotePath(p string) string {
return "'" + strings.ReplaceAll(p, "'", "''") + "'"
}
func WriteZip(w io.Writer, paths BackupPaths) error {
flushSQLiteWAL(paths.DatabasePath)
dbPath := paths.DatabasePath
// Snapshot the live database into a temp file so the zip carries a
// crash-consistent copy instead of whatever bytes the open file happened to
// expose mid-write. If the db file is absent we keep dbPath as-is (addFile
// skips a missing file), preserving the previous best-effort behavior.
if dbPath != "" {
if _, err := os.Stat(dbPath); err == nil {
snap := dbPath + ".backup-snapshot"
if serr := snapshotSQLiteDB(dbPath, snap); serr != nil {
return fmt.Errorf("snapshot database: %w", serr)
}
defer os.Remove(snap)
dbPath = snap
}
}
zw := zip.NewWriter(w)
defer zw.Close()
@@ -76,12 +116,13 @@ func WriteZip(w io.Writer, paths BackupPaths) error {
return err
}
if err := addFile("3m-ui.db", paths.DatabasePath); err != nil {
if err := addFile("3m-ui.db", dbPath); err != nil {
return fmt.Errorf("database: %w", err)
}
// Best-effort: if checkpoint could not run, keep sidecars so restore can replay.
_ = addFile("3m-ui.db-wal", paths.DatabasePath+"-wal")
_ = addFile("3m-ui.db-shm", paths.DatabasePath+"-shm")
// No longer bundling 3m-ui.db-wal / 3m-ui.db-shm: the VACUUM INTO snapshot is
// already self-contained (WAL replayed into the main file), so the sidecars
// would be dead weight. They were previously bundled but never restored,
// which silently dropped any un-checkpointed writes on restore.
if err := addFile("mihomo-config.yaml", paths.MihomoConfig); err != nil {
return fmt.Errorf("mihomo config: %w", err)
}
@@ -0,0 +1,108 @@
package system
import (
"archive/zip"
"bytes"
"database/sql"
"io"
"os"
"path/filepath"
"testing"
_ "modernc.org/sqlite"
)
// TestWriteZipSnapshotIsConsistentAndSelfContained verifies that WriteZip copies
// a live, WAL-mode database into a crash-consistent snapshot and no longer
// bundles the -wal/-shm sidecars.
//
// Regression for two issues:
// - A raw file copy of an open, actively-written database can capture a torn
// page; VACUUM INTO must instead produce a fully-replayed copy.
// - The previous code bundled 3m-ui.db-wal / 3m-ui.db-shm but RestoreDatabase
// never read them, so un-checkpointed writes were silently lost on restore.
func TestWriteZipSnapshotIsConsistentAndSelfContained(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "3m-ui.db")
// Open a real database and keep the handle open to simulate the running
// GORM pool writing concurrently while a backup is taken.
src, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatalf("open source db: %v", err)
}
defer src.Close()
if _, err := src.Exec(`PRAGMA busy_timeout=5000`); err != nil {
t.Fatalf("busy_timeout: %v", err)
}
if _, err := src.Exec(`PRAGMA journal_mode=WAL`); err != nil {
t.Fatalf("set WAL: %v", err)
}
if _, err := src.Exec(`CREATE TABLE listeners (id INTEGER PRIMARY KEY, name TEXT)`); err != nil {
t.Fatalf("create table: %v", err)
}
// Insert enough rows that some land in the -wal file (not yet checkpointed).
for i := 0; i < 200; i++ {
if _, err := src.Exec(`INSERT INTO listeners (id, name) VALUES (?, ?)`, i, "listener-"+string(rune('a'+i%26))); err != nil {
t.Fatalf("insert row %d: %v", i, err)
}
}
// Snapshot while the source connection is still open and dirty.
var buf bytes.Buffer
if err := WriteZip(&buf, BackupPaths{DatabasePath: dbPath}); err != nil {
t.Fatalf("WriteZip: %v", err)
}
// Read the archive back.
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
if err != nil {
t.Fatalf("open zip: %v", err)
}
var dbBytes []byte
sawWal := false
sawShm := false
for _, f := range zr.File {
switch f.Name {
case "3m-ui.db":
rc, e := f.Open()
if e != nil {
t.Fatalf("open 3m-ui.db in zip: %v", e)
}
dbBytes, e = io.ReadAll(rc)
rc.Close()
if e != nil {
t.Fatalf("read 3m-ui.db in zip: %v", e)
}
case "3m-ui.db-wal":
sawWal = true
case "3m-ui.db-shm":
sawShm = true
}
}
if sawWal || sawShm {
t.Fatalf("backup still bundles sidecars: wal=%v shm=%v", sawWal, sawShm)
}
if len(dbBytes) == 0 {
t.Fatal("zip contains no 3m-ui.db")
}
// The snapshot must be a complete, replayed database: open it standalone
// (no source connection, no -wal present) and confirm every row survived.
restored := filepath.Join(dir, "restored.db")
if err := os.WriteFile(restored, dbBytes, 0o600); err != nil {
t.Fatalf("write restored db: %v", err)
}
rdb, err := sql.Open("sqlite", restored)
if err != nil {
t.Fatalf("open restored db: %v", err)
}
defer rdb.Close()
var n int
if err := rdb.QueryRow(`SELECT COUNT(*) FROM listeners`).Scan(&n); err != nil {
t.Fatalf("count restored rows: %v", err)
}
if n != 200 {
t.Fatalf("restored db missing rows: got %d want 200", n)
}
}