d4be9f6ff1
gates / gates (push) Failing after 14m0s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
209 lines
6.5 KiB
Go
209 lines
6.5 KiB
Go
package dbsnap
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
|
)
|
|
|
|
func newStore(t *testing.T) (*store.Store, string) {
|
|
t.Helper()
|
|
p := filepath.Join(t.TempDir(), "hub.db")
|
|
s, err := store.New(p, log.New(io.Discard, "", 0))
|
|
if err != nil {
|
|
t.Fatalf("store.New: %v", err)
|
|
}
|
|
t.Cleanup(func() { s.Close() })
|
|
return s, p
|
|
}
|
|
|
|
func openRO(t *testing.T, p string) *sql.DB {
|
|
t.Helper()
|
|
db, err := sql.Open("sqlite", "file:"+p+"?mode=ro")
|
|
if err != nil {
|
|
t.Fatalf("open %s: %v", p, err)
|
|
}
|
|
t.Cleanup(func() { db.Close() })
|
|
return db
|
|
}
|
|
|
|
// rowCounts returns count(*) of every table.
|
|
func rowCounts(t *testing.T, db *sql.DB) map[string]int {
|
|
t.Helper()
|
|
rows, err := db.Query(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name`)
|
|
if err != nil {
|
|
t.Fatalf("list tables: %v", err)
|
|
}
|
|
var names []string
|
|
for rows.Next() {
|
|
var n string
|
|
_ = rows.Scan(&n)
|
|
names = append(names, n)
|
|
}
|
|
rows.Close()
|
|
out := map[string]int{}
|
|
for _, n := range names {
|
|
var c int
|
|
if err := db.QueryRow(fmt.Sprintf(`SELECT count(*) FROM "%s"`, n)).Scan(&c); err != nil {
|
|
t.Fatalf("count %s: %v", n, err)
|
|
}
|
|
out[n] = c
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TestSnapshot_ConsistentWithLiveDBIncludingWAL is the consequence test: the snapshot passes integrity_check and has
|
|
// the live database's row count in EVERY table — including rows that so far live only in hub.db-wal. Precondition
|
|
// asserted in-test: copying hub.db alone (the naive file backup) LOSES those rows, so this test can tell the two apart.
|
|
func TestSnapshot_ConsistentWithLiveDBIncludingWAL(t *testing.T) {
|
|
s, live := newStore(t)
|
|
for i := 0; i < 150; i++ {
|
|
if err := s.UpsertHost(&store.Host{HostID: fmt.Sprintf("h%03d", i), CustomerID: "c1", APIKey: fmt.Sprintf("k%03d", i)}); err != nil {
|
|
t.Fatalf("UpsertHost: %v", err)
|
|
}
|
|
}
|
|
if fi, err := os.Stat(live + "-wal"); err != nil || fi.Size() == 0 {
|
|
t.Fatalf("precondition: want a non-empty WAL, got %v", err)
|
|
}
|
|
// Precondition: a plain copy of hub.db does not hold the WAL rows.
|
|
naive := filepath.Join(t.TempDir(), "naive.db")
|
|
b, _ := os.ReadFile(live)
|
|
_ = os.WriteFile(naive, b, 0o600)
|
|
if c := rowCounts(t, openRO(t, naive))["hosts"]; c == 150 {
|
|
t.Fatalf("precondition: a plain file copy already holds all 150 hosts — the test cannot tell a WAL-blind copy apart")
|
|
}
|
|
|
|
dir := filepath.Join(t.TempDir(), "snapshots")
|
|
m := &Maker{Store: s, Dir: dir}
|
|
res, err := m.Make(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Make: %v", err)
|
|
}
|
|
snap := openRO(t, filepath.Join(dir, res.Name))
|
|
var ic string
|
|
if err := snap.QueryRow(`PRAGMA integrity_check`).Scan(&ic); err != nil || ic != "ok" {
|
|
t.Fatalf("integrity_check = %q, %v", ic, err)
|
|
}
|
|
want, got := rowCounts(t, openRO(t, live)), rowCounts(t, snap)
|
|
if len(want) < 10 || want["hosts"] != 150 {
|
|
t.Fatalf("live counts look wrong: %d tables, hosts=%d", len(want), want["hosts"])
|
|
}
|
|
for tbl, n := range want {
|
|
if got[tbl] != n {
|
|
t.Errorf("table %s: snapshot %d rows, live %d", tbl, got[tbl], n)
|
|
}
|
|
}
|
|
if fi, _ := os.Stat(filepath.Join(dir, res.Name)); fi == nil || fi.Mode().Perm() != 0o600 || res.Bytes != fi.Size() {
|
|
t.Errorf("snapshot file mode/size wrong: %+v res.Bytes=%d", fi, res.Bytes)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, res.Name+".tmp")); !os.IsNotExist(err) {
|
|
t.Errorf("the .tmp file is left behind")
|
|
}
|
|
}
|
|
|
|
// TestSnapshot_KeepsNewestTwo pins Keep: three runs leave the two newest.
|
|
func TestSnapshot_KeepsNewestTwo(t *testing.T) {
|
|
s, _ := newStore(t)
|
|
dir := filepath.Join(t.TempDir(), "snapshots")
|
|
base := time.Date(2026, 10, 5, 0, 0, 0, 0, time.UTC)
|
|
i := 0
|
|
m := &Maker{Store: s, Dir: dir, Now: func() time.Time { i++; return base.Add(time.Duration(i) * 24 * time.Hour) }}
|
|
for k := 0; k < 3; k++ {
|
|
if _, err := m.Make(context.Background()); err != nil {
|
|
t.Fatalf("Make %d: %v", k, err)
|
|
}
|
|
}
|
|
got := List(dir)
|
|
want := []string{"hub-20261007T000000Z.db", "hub-20261008T000000Z.db"}
|
|
if strings.Join(got, ",") != strings.Join(want, ",") {
|
|
t.Fatalf("kept %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
type blockingStore struct {
|
|
in, release chan struct{}
|
|
once *sync.Once
|
|
}
|
|
|
|
func (b blockingStore) SnapshotInto(ctx context.Context, path string) error {
|
|
b.once.Do(func() { close(b.in) })
|
|
<-b.release
|
|
return os.WriteFile(path, []byte("x"), 0o600)
|
|
}
|
|
|
|
// TestSnapshot_NeverTwoAtOnce: a second Make while the first runs returns ErrBusy and writes nothing.
|
|
func TestSnapshot_NeverTwoAtOnce(t *testing.T) {
|
|
bs := blockingStore{in: make(chan struct{}), release: make(chan struct{}), once: &sync.Once{}}
|
|
dir := t.TempDir()
|
|
m := &Maker{Store: bs, Dir: dir}
|
|
done := make(chan error, 1)
|
|
go func() { _, err := m.Make(context.Background()); done <- err }()
|
|
<-bs.in
|
|
second := make(chan error, 1)
|
|
go func() { _, err := m.Make(context.Background()); second <- err }()
|
|
select {
|
|
case err := <-second:
|
|
if !errors.Is(err, ErrBusy) {
|
|
t.Fatalf("second Make = %v, want ErrBusy", err)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
close(bs.release)
|
|
t.Fatalf("second Make did not return ErrBusy at once: it ran alongside the first")
|
|
}
|
|
close(bs.release)
|
|
if err := <-done; err != nil {
|
|
t.Fatalf("first Make: %v", err)
|
|
}
|
|
if n := len(List(dir)); n != 1 {
|
|
t.Fatalf("%d snapshots, want 1", n)
|
|
}
|
|
m.Now = func() time.Time { return time.Now().Add(time.Hour) } // a distinct name
|
|
if _, err := m.Make(context.Background()); err != nil {
|
|
t.Fatalf("Make after the first ended: %v", err)
|
|
}
|
|
}
|
|
|
|
type failStore struct{}
|
|
|
|
func (failStore) SnapshotInto(ctx context.Context, path string) error {
|
|
_ = os.WriteFile(path, []byte("half"), 0o600)
|
|
return errors.New("disk full")
|
|
}
|
|
|
|
// TestSnapshot_FailureLeavesNoFile: a failed VACUUM INTO leaves neither a snapshot nor a .tmp for DooPlex to pick up.
|
|
func TestSnapshot_FailureLeavesNoFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if _, err := (&Maker{Store: failStore{}, Dir: dir}).Make(context.Background()); err == nil {
|
|
t.Fatal("Make succeeded over a failing store")
|
|
}
|
|
if e, _ := os.ReadDir(dir); len(e) != 0 {
|
|
t.Fatalf("left %d file(s) behind", len(e))
|
|
}
|
|
}
|
|
|
|
func TestNeedsCatchUp(t *testing.T) {
|
|
dir := t.TempDir()
|
|
now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
|
|
if !NeedsCatchUp(dir, now, 24*time.Hour) {
|
|
t.Error("empty dir: want catch-up")
|
|
}
|
|
_ = os.WriteFile(filepath.Join(dir, "hub-20261005T000000Z.db"), nil, 0o600)
|
|
if NeedsCatchUp(dir, now, 24*time.Hour) {
|
|
t.Error("12 h old: want no catch-up")
|
|
}
|
|
if !NeedsCatchUp(dir, now.Add(13*time.Hour), 24*time.Hour) {
|
|
t.Error("25 h old: want catch-up")
|
|
}
|
|
}
|