fix(database): create SQLite backup snapshots online (#6137)

* fix(database): snapshot SQLite backups online

Use SQLite's online backup API for downloadable backups and SQLite migration exports instead of checkpointing then reading the live database file. The regression test validates a backup made while writes continue.

* style(database): group SQLite driver imports

* fix(database): bound online backup retries

Use a single backup step and a bounded connection-acquisition/retry context. Tighten temporary-file cleanup and regression assertions while removing the unused checkpoint helper.

* test(database): cover existing backup destinations

* fix(database): harden SQLite snapshot lifecycle

Sweep interrupted snapshot directories at SQLite startup, keep rollback-journal backups incremental, and make caller-owned cleanup explicit. Reuse one scheduled Telegram snapshot across administrators and make the direct SQLite driver dependency explicit.

---------

Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local>
This commit is contained in:
PathGao
2026-07-30 03:04:55 +08:00
committed by GitHub
parent ad288a7ecc
commit af5a8e5d40
6 changed files with 334 additions and 70 deletions
+113 -5
View File
@@ -3,6 +3,7 @@ package database
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
@@ -12,6 +13,7 @@ import (
"os"
"os/exec"
"path"
"path/filepath"
"runtime"
"slices"
"strconv"
@@ -24,6 +26,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/util/random"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"github.com/mattn/go-sqlite3"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
@@ -32,6 +35,8 @@ import (
var db *gorm.DB
var backupSQLiteTimeout = 2 * time.Minute
const (
DialectSQLite = "sqlite"
DialectPostgres = "postgres"
@@ -52,8 +57,9 @@ func Dialect() string {
}
const (
defaultUsername = "admin"
defaultPassword = "admin"
defaultUsername = "admin"
defaultPassword = "admin"
sqliteBackupDirPrefix = ".x-ui-backup-"
)
func allModels() []any {
@@ -1944,6 +1950,9 @@ func InitDB(dbPath string) error {
if err = os.MkdirAll(dir, 0o755); err != nil {
return err
}
if err = cleanupSQLiteBackupDirs(filepath.Dir(dbPath)); err != nil {
log.Printf("clean SQLite backup directories: %v", err)
}
sync := sqliteSynchronous()
journal := sqliteJournalMode()
@@ -2045,6 +2054,31 @@ func sqliteJournalMode() string {
}
}
func backupSQLiteStepPages() int {
if sqliteJournalMode() == "DELETE" {
return 128
}
return -1
}
func cleanupSQLiteBackupDirs(dir string) error {
entries, err := os.ReadDir(dir)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
for _, entry := range entries {
if entry.IsDir() && strings.HasPrefix(entry.Name(), sqliteBackupDirPrefix) {
if err := os.RemoveAll(filepath.Join(dir, entry.Name())); err != nil {
return err
}
}
}
return nil
}
func sqliteSynchronous() string {
switch strings.ToUpper(strings.TrimSpace(os.Getenv("XUI_DB_SYNCHRONOUS"))) {
case "OFF":
@@ -2099,11 +2133,85 @@ func IsSQLiteDB(file io.ReaderAt) (bool, error) {
return bytes.Equal(buf, signature), nil
}
func Checkpoint() error {
func BackupSQLite(dstPath string) (err error) {
if IsPostgres() {
return nil
return errors.New("sqlite backup is unavailable for PostgreSQL")
}
return db.Exec("PRAGMA wal_checkpoint(TRUNCATE);").Error
if db == nil {
return errors.New("database is not initialized")
}
if _, err := os.Lstat(dstPath); err == nil {
return fmt.Errorf("sqlite backup destination already exists: %s", dstPath)
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
defer func() {
if err != nil {
_ = os.Remove(dstPath)
}
}()
ctx, cancel := context.WithTimeout(context.Background(), backupSQLiteTimeout)
defer cancel()
sourceDB, err := db.DB()
if err != nil {
return err
}
sourceConn, err := sourceDB.Conn(ctx)
if err != nil {
return err
}
defer sourceConn.Close()
destinationDB, err := sql.Open("sqlite3", dstPath)
if err != nil {
return err
}
defer destinationDB.Close()
destinationConn, err := destinationDB.Conn(ctx)
if err != nil {
return err
}
defer destinationConn.Close()
return sourceConn.Raw(func(sourceDriver any) error {
source, ok := sourceDriver.(*sqlite3.SQLiteConn)
if !ok {
return fmt.Errorf("unexpected SQLite source connection type %T", sourceDriver)
}
return destinationConn.Raw(func(destinationDriver any) error {
destination, ok := destinationDriver.(*sqlite3.SQLiteConn)
if !ok {
return fmt.Errorf("unexpected SQLite destination connection type %T", destinationDriver)
}
backup, err := destination.Backup("main", source, "main")
if err != nil {
return err
}
finished := false
defer func() {
if !finished {
_ = backup.Finish()
}
}()
for {
done, err := backup.Step(backupSQLiteStepPages())
if err != nil {
return err
}
if done {
finished = true
return backup.Finish()
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(10 * time.Millisecond):
}
}
})
})
}
func ValidateSQLiteDB(dbPath string) error {