mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-10 13:21:00 +00:00
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:
+113
-5
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user