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
+18 -16
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
}
+16 -7
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