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

2
go.mod
View File

@@ -13,6 +13,7 @@ require (
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/joho/godotenv v1.5.1
github.com/mattn/go-sqlite3 v1.14.48
github.com/mymmrac/telego v1.11.1
github.com/nicksnyder/go-i18n/v2 v2.6.1
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
@@ -68,7 +69,6 @@ require (
github.com/leodido/go-urn v1.5.0 // indirect
github.com/lufia/plan9stats v0.0.0-20260627054121-477a66015f15 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-sqlite3 v1.14.48 // indirect
github.com/miekg/dns v1.1.72 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect

View File

@@ -0,0 +1,186 @@
package database
import (
"context"
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func TestBackupSQLiteProducesValidSnapshotDuringWrites(t *testing.T) {
t.Setenv("XUI_DB_JOURNAL_MODE", "")
dbPath := filepath.Join(t.TempDir(), "x-ui.db")
if err := InitDB(dbPath); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
seed := make([]model.Setting, 128)
value := strings.Repeat("x", 1024)
for i := range seed {
seed[i] = model.Setting{Key: fmt.Sprintf("backup-seed-%d", i), Value: value}
}
if err := db.Create(&seed).Error; err != nil {
t.Fatalf("seed database: %v", err)
}
stop := make(chan struct{})
firstWrite := make(chan error, 1)
writesDone := make(chan error, 1)
go func() {
for i := 0; i < 128; i++ {
if err := db.Create(&model.Setting{Key: fmt.Sprintf("backup-write-%d", i), Value: value}).Error; err != nil {
if i == 0 {
firstWrite <- err
}
writesDone <- err
return
}
if i == 0 {
firstWrite <- nil
}
select {
case <-stop:
writesDone <- nil
return
default:
}
}
writesDone <- nil
}()
if err := <-firstWrite; err != nil {
t.Fatalf("first concurrent write: %v", err)
}
backupPath := filepath.Join(t.TempDir(), "backup.db")
if err := BackupSQLite(backupPath); err != nil {
close(stop)
<-writesDone
t.Fatalf("BackupSQLite: %v", err)
}
close(stop)
if err := <-writesDone; err != nil {
t.Fatalf("concurrent write: %v", err)
}
if err := ValidateSQLiteDB(backupPath); err != nil {
t.Fatalf("validate backup: %v", err)
}
backup, err := sql.Open("sqlite3", backupPath)
if err != nil {
t.Fatalf("open backup: %v", err)
}
defer backup.Close()
var seedCount int
if err := backup.QueryRow("SELECT count(*) FROM settings WHERE key LIKE 'backup-seed-%'").Scan(&seedCount); err != nil {
t.Fatalf("count seeded rows: %v", err)
}
if seedCount != 128 {
t.Fatalf("seeded row count = %d, want 128", seedCount)
}
var firstWriteCount int
if err := backup.QueryRow("SELECT count(*) FROM settings WHERE key = 'backup-write-0'").Scan(&firstWriteCount); err != nil {
t.Fatalf("count first concurrent write: %v", err)
}
if firstWriteCount != 1 {
t.Fatalf("first concurrent write count = %d, want 1", firstWriteCount)
}
}
func TestBackupSQLiteTimesOutWaitingForSourceConnection(t *testing.T) {
t.Setenv("XUI_DB_JOURNAL_MODE", "")
if err := InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("get database connection pool: %v", err)
}
sqlDB.SetMaxOpenConns(1)
held, err := sqlDB.Conn(context.Background())
if err != nil {
t.Fatalf("hold source connection: %v", err)
}
defer held.Close()
previousTimeout := backupSQLiteTimeout
backupSQLiteTimeout = 20 * time.Millisecond
t.Cleanup(func() { backupSQLiteTimeout = previousTimeout })
err = BackupSQLite(filepath.Join(t.TempDir(), "backup.db"))
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("BackupSQLite error = %v, want context deadline exceeded", err)
}
}
func TestBackupSQLiteRefusesExistingDestination(t *testing.T) {
t.Setenv("XUI_DB_JOURNAL_MODE", "")
if err := InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
backupPath := filepath.Join(t.TempDir(), "backup.db")
if err := os.WriteFile(backupPath, []byte("existing backup"), 0o600); err != nil {
t.Fatalf("create existing destination: %v", err)
}
err := BackupSQLite(backupPath)
want := fmt.Sprintf("sqlite backup destination already exists: %s", backupPath)
if err == nil || err.Error() != want {
t.Fatalf("BackupSQLite error = %v, want %q", err, want)
}
data, err := os.ReadFile(backupPath)
if err != nil {
t.Fatalf("read existing destination: %v", err)
}
if string(data) != "existing backup" {
t.Fatalf("existing destination = %q, want %q", data, "existing backup")
}
}
func TestBackupSQLiteStepPages(t *testing.T) {
t.Setenv("XUI_DB_JOURNAL_MODE", "")
if got := backupSQLiteStepPages(); got != -1 {
t.Fatalf("WAL backup step pages = %d, want -1", got)
}
t.Setenv("XUI_DB_JOURNAL_MODE", "DELETE")
if got := backupSQLiteStepPages(); got != 128 {
t.Fatalf("DELETE backup step pages = %d, want 128", got)
}
}
func TestInitDBCleansBackupDirectories(t *testing.T) {
t.Setenv("XUI_DB_JOURNAL_MODE", "")
dbDir := t.TempDir()
orphanDir := filepath.Join(dbDir, sqliteBackupDirPrefix+"orphan")
if err := os.Mkdir(orphanDir, 0o700); err != nil {
t.Fatalf("create orphan backup directory: %v", err)
}
if err := os.WriteFile(filepath.Join(orphanDir, "backup.db"), []byte("backup"), 0o600); err != nil {
t.Fatalf("write orphan backup: %v", err)
}
regularDir := filepath.Join(dbDir, ".x-ui-keep")
if err := os.Mkdir(regularDir, 0o700); err != nil {
t.Fatalf("create regular directory: %v", err)
}
if err := InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
if _, err := os.Stat(orphanDir); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("orphan backup directory error = %v, want not exist", err)
}
if info, err := os.Stat(regularDir); err != nil || !info.IsDir() {
t.Fatalf("regular directory info = %v, %v; want existing directory", info, err)
}
}

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 {

View File

@@ -1,12 +1,8 @@
package database
import (
"bytes"
"os"
"path/filepath"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func journalModeOf(t *testing.T) string {
@@ -43,40 +39,3 @@ func TestSqliteJournalModeEnvOverrideDelete(t *testing.T) {
t.Fatalf("journal_mode = %q, want delete", got)
}
}
func TestWalCheckpointMakesRawFileBackupComplete(t *testing.T) {
t.Setenv("XUI_DB_JOURNAL_MODE", "")
dbDir := t.TempDir()
dbPath := filepath.Join(dbDir, "x-ui.db")
if err := InitDB(dbPath); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
if err := db.Create(&model.Setting{Key: "walBackupProbe", Value: "42"}).Error; err != nil {
t.Fatalf("write setting: %v", err)
}
if err := Checkpoint(); err != nil {
t.Fatalf("Checkpoint: %v", err)
}
raw, err := os.ReadFile(dbPath)
if err != nil {
t.Fatalf("read db file: %v", err)
}
copyPath := filepath.Join(t.TempDir(), "copy.db")
if err := os.WriteFile(copyPath, raw, 0o600); err != nil {
t.Fatalf("write copy: %v", err)
}
if err := ValidateSQLiteDB(copyPath); err != nil {
t.Fatalf("checkpointed raw copy must be a valid sqlite db: %v", err)
}
dump, err := DumpSQLiteToBytes(copyPath)
if err != nil {
t.Fatalf("dump copy: %v", err)
}
if !bytes.Contains(dump, []byte("walBackupProbe")) {
t.Fatal("raw-file backup taken after Checkpoint must contain the latest write")
}
}

View File

@@ -1303,25 +1303,26 @@ func (s *ServerService) GetDb() ([]byte, error) {
if database.IsPostgres() {
return s.exportPostgresDB()
}
// Update by manually trigger a checkpoint operation
err := database.Checkpoint()
backupPath, cleanup, err := s.backupSQLite()
if err != nil {
return nil, err
}
// Open the file for reading
file, err := os.Open(config.GetDBPath())
if err != nil {
return nil, err
}
defer file.Close()
defer cleanup()
return os.ReadFile(backupPath)
}
// Read the file contents
fileContents, err := io.ReadAll(file)
func (s *ServerService) backupSQLite() (string, func(), error) {
backupDir, err := os.MkdirTemp(filepath.Dir(config.GetDBPath()), ".x-ui-backup-")
if err != nil {
return nil, err
return "", nil, err
}
return fileContents, nil
cleanup := func() { _ = os.RemoveAll(backupDir) }
backupPath := filepath.Join(backupDir, "backup.db")
if err := database.BackupSQLite(backupPath); err != nil {
cleanup()
return "", nil, err
}
return backupPath, cleanup, nil
}
// BackupFilename returns the filename for a database backup, named after the
@@ -1421,11 +1422,12 @@ func (s *ServerService) GetMigration() ([]byte, string, error) {
return data, "x-ui.db", nil
}
// SQLite panel: checkpoint so the .db reflects the latest writes, then dump.
if err := database.Checkpoint(); err != nil {
backupPath, cleanup, err := s.backupSQLite()
if err != nil {
return nil, "", err
}
data, err := database.DumpSQLiteToBytes(config.GetDBPath())
defer cleanup()
data, err := database.DumpSQLiteToBytes(backupPath)
if err != nil {
return nil, "", err
}

View File

@@ -48,8 +48,13 @@ func (t *Tgbot) SendBackupToAdmins() {
if !t.IsRunning() {
return
}
dbData, err := t.serverService.GetDb()
if err != nil {
logger.Error("Error in getting db backup: ", err)
}
dbFilename := t.serverService.BackupFilename("")
for i, adminId := range adminIds {
t.sendBackup(adminId)
t.sendBackupData(adminId, dbData, dbFilename)
// Add delay between sends to avoid Telegram rate limits
if i < len(adminIds)-1 {
time.Sleep(1 * time.Second)
@@ -404,26 +409,30 @@ func (t *Tgbot) onlineClients(chatId int64, messageID ...int) {
// sendBackup sends a backup of the database and configuration files.
func (t *Tgbot) sendBackup(chatId int64) {
dbData, err := t.serverService.GetDb()
if err != nil {
logger.Error("Error in getting db backup: ", err)
}
t.sendBackupData(chatId, dbData, t.serverService.BackupFilename(""))
}
func (t *Tgbot) sendBackupData(chatId int64, dbData []byte, dbFilename string) {
output := t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
output += t.I18nBot("tgbot.messages.backupTime", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
t.SendMsgToTgbot(chatId, output)
// Send database backup (SQLite file, or a pg_dump archive on PostgreSQL)
dbData, err := t.serverService.GetDb()
if err == nil {
dbFilename := t.serverService.BackupFilename("")
if dbData != nil {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
document := tu.Document(
tu.ID(chatId),
tu.FileFromBytes(dbData, dbFilename),
)
_, err = bot.SendDocument(ctx, document)
_, err := bot.SendDocument(ctx, document)
cancel()
if err != nil {
logger.Error("Error in uploading backup: ", err)
}
} else {
logger.Error("Error in getting db backup: ", err)
}
// Small delay between file sends