mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-03 22:02:10 +03:00
* 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>
187 lines
5.3 KiB
Go
187 lines
5.3 KiB
Go
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)
|
|
}
|
|
}
|