mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
Three things, all the same shape: SQLite's guarantees were being assumed on an engine that does not make them. A backup archives the data directory, which is where the database lives on SQLite and not on Postgres. There it returned 204 and produced an archive with no database in it, and restore then failed inside a goroutine after the 204 had gone out — so an operator saw successful backups, a populated list, plausible sizes, and had nothing recoverable. It refuses on an engine whose database is not in that directory, before extracting anything, and says where to take the backup instead. Refusing beats shelling out to pg_dump: that adds a binary that has to out-version the server, and its output cannot be restored by the path we have, so it would be a second restore mechanism. What stopped a restore is now retrievable. It is recorded in core, so a CLI or a hook caller sees it too, and GET on the restore address renders it: 202 while running, 200 and the reason when stopped, 404 when this process has not tried. POST still answers 204, because the admin client reads a body on any other 2xx. A serialization failure or a deadlock is a transient and is retried at the transaction boundary, which is the only place a retry means anything — the second statement of a deadlocked transaction reports 25P02, so retrying a statement spends the attempt and loses the evidence. The transaction watches its own statements for the two SQLSTATEs rather than reading the returned error, which by then has been wrapped down to a message string. Batch had to become re-runnable for this: it accumulated results outside the transaction. Retrying turns a transient into a success and does not pretend to fix a convoy: with a few concurrent opposing batches the loss goes to zero, and with thirty pairs over six rows every pair collides and no budget clears it. SQLite keeps the same executions at the same intervals. Co-authored-by: Hanzo Dev <[email protected]>
193 lines
4.9 KiB
Go
193 lines
4.9 KiB
Go
package core_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/hanzoai/base/core"
|
|
"github.com/hanzoai/base/tests"
|
|
"github.com/hanzoai/base/tools/archive"
|
|
"github.com/hanzoai/base/tools/list"
|
|
)
|
|
|
|
func TestCreateBackup(t *testing.T) {
|
|
app, _ := tests.NewTestApp()
|
|
defer app.Cleanup()
|
|
|
|
// set some long app name with spaces and special characters
|
|
app.Settings().Meta.AppName = "test @! " + strings.Repeat("a", 100)
|
|
|
|
expectedAppNamePrefix := "test_" + strings.Repeat("a", 45)
|
|
|
|
// test pending error
|
|
app.Store().Set(core.StoreKeyActiveBackup, "")
|
|
if err := app.CreateBackup(context.Background(), "test.zip"); err == nil {
|
|
t.Fatal("Expected pending error, got nil")
|
|
}
|
|
app.Store().Remove(core.StoreKeyActiveBackup)
|
|
|
|
// create with auto generated name
|
|
if err := app.CreateBackup(context.Background(), ""); err != nil {
|
|
t.Fatal("Failed to create a backup with autogenerated name")
|
|
}
|
|
|
|
// create with custom name
|
|
if err := app.CreateBackup(context.Background(), "custom"); err != nil {
|
|
t.Fatal("Failed to create a backup with custom name")
|
|
}
|
|
|
|
// create new with the same name (aka. replace)
|
|
if err := app.CreateBackup(context.Background(), "custom"); err != nil {
|
|
t.Fatal("Failed to create and replace a backup with the same name")
|
|
}
|
|
|
|
backupsDir := filepath.Join(app.DataDir(), core.LocalBackupsDirName)
|
|
|
|
entries, err := os.ReadDir(backupsDir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
expectedFiles := []string{
|
|
`^hz_backup_` + expectedAppNamePrefix + `_\w+\.zip$`,
|
|
`^hz_backup_` + expectedAppNamePrefix + `_\w+\.zip.attrs$`,
|
|
"custom",
|
|
"custom.attrs",
|
|
}
|
|
|
|
if len(entries) != len(expectedFiles) {
|
|
names := getEntryNames(entries)
|
|
t.Fatalf("Expected %d backup files, got %d: \n%v", len(expectedFiles), len(entries), names)
|
|
}
|
|
|
|
for i, entry := range entries {
|
|
if !list.ExistInSliceWithRegex(entry.Name(), expectedFiles) {
|
|
t.Fatalf("[%d] Missing backup file %q", i, entry.Name())
|
|
}
|
|
|
|
if strings.HasSuffix(entry.Name(), ".attrs") {
|
|
continue
|
|
}
|
|
|
|
path := filepath.Join(backupsDir, entry.Name())
|
|
|
|
if err := verifyBackupContent(app, path); err != nil {
|
|
t.Fatalf("[%d] Failed to verify backup content: %v", i, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRestoreBackup(t *testing.T) {
|
|
app, _ := tests.NewTestApp()
|
|
defer app.Cleanup()
|
|
|
|
// create a initial test backup to ensure that there are at least 1
|
|
// backup file and that the generated zip doesn't contain the backups dir
|
|
if err := app.CreateBackup(context.Background(), "initial"); err != nil {
|
|
t.Fatal("Failed to create test initial backup")
|
|
}
|
|
|
|
// create test backup
|
|
if err := app.CreateBackup(context.Background(), "test"); err != nil {
|
|
t.Fatal("Failed to create test backup")
|
|
}
|
|
|
|
// test pending error
|
|
app.Store().Set(core.StoreKeyActiveBackup, "")
|
|
if err := app.RestoreBackup(context.Background(), "test"); err == nil {
|
|
t.Fatal("Expected pending error, got nil")
|
|
}
|
|
app.Store().Remove(core.StoreKeyActiveBackup)
|
|
|
|
// missing backup
|
|
if err := app.RestoreBackup(context.Background(), "missing"); err == nil {
|
|
t.Fatal("Expected missing error, got nil")
|
|
}
|
|
}
|
|
|
|
// A restore runs past the reply that starts it, so what stopped it is recorded
|
|
// where it can be asked for afterwards rather than only said once.
|
|
func TestRestoreBackupRecordsWhatStopped(t *testing.T) {
|
|
app, _ := tests.NewTestApp()
|
|
defer app.Cleanup()
|
|
|
|
if err := app.RestoreBackup(context.Background(), "missing.zip"); err == nil {
|
|
t.Fatal("Expected missing error, got nil")
|
|
}
|
|
|
|
failure, ok := app.Store().Get(core.StoreKeyRestoreFailure).(core.RestoreFailure)
|
|
if !ok {
|
|
t.Fatal("Expected the stopped restore to be recorded")
|
|
}
|
|
|
|
if failure.Name != "missing.zip" {
|
|
t.Fatalf("Expected the record to name missing.zip, got %q", failure.Name)
|
|
}
|
|
|
|
if failure.Error == "" {
|
|
t.Fatal("Expected the record to carry the reason")
|
|
}
|
|
|
|
if failure.Time.IsZero() {
|
|
t.Fatal("Expected the record to carry the time")
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
|
|
func verifyBackupContent(app core.App, path string) error {
|
|
dir, err := os.MkdirTemp("", "backup_test")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.RemoveAll(dir)
|
|
|
|
if err := archive.Extract(path, dir); err != nil {
|
|
return err
|
|
}
|
|
|
|
expectedRootEntries := []string{
|
|
"storage",
|
|
"data.db",
|
|
"data.db-shm",
|
|
"data.db-wal",
|
|
"auxiliary.db",
|
|
"auxiliary.db-shm",
|
|
"auxiliary.db-wal",
|
|
".gitignore",
|
|
}
|
|
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(entries) != len(expectedRootEntries) {
|
|
names := getEntryNames(entries)
|
|
return fmt.Errorf("Expected %d backup files, got %d: \n%v", len(expectedRootEntries), len(entries), names)
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if !list.ExistInSliceWithRegex(entry.Name(), expectedRootEntries) {
|
|
return fmt.Errorf("Didn't expect %q entry", entry.Name())
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func getEntryNames(entries []fs.DirEntry) []string {
|
|
names := make([]string, len(entries))
|
|
|
|
for i, entry := range entries {
|
|
names[i] = entry.Name()
|
|
}
|
|
|
|
return names
|
|
}
|