Files
replicate/replica_test.go
zeekayandGitHub 17eab43002 file: file:// Leaser; dblock: byte-range lock; MaxLeaseTTL on interface (#3)
* file: add file:// Leaser for filesystem-backed CAS

Mirrors the S3 Leaser CAS semantics using POSIX O_EXCL for initial creation
and content-hash ETag matching for renew/release. Atomic rename for the
cutover. Same Leaser interface contract — callers swap backends without code
changes.

Use cases: per-process / single-node leases for dev + tests, container-local
coordination where S3 is overkill, and cross-process tests of leader/standby
patterns without spinning up MinIO.

Tests cover: fresh acquire, expired-lease takeover, contended acquire
(LeaseExistsError), renew bumps expiry + etag without touching generation,
renew loses race to a stealer (ErrLeaseNotHeld), release roundtrip, release
after steal (ErrLeaseNotHeld), and concurrent acquire with exactly one
winner across 32 goroutines.

* file: harden Leaser — MaxTTL clamp + foreign-owner renew guard

Closes red findings #1 (HIGH) and #4 (MEDIUM) on the per-market shard
layer's lease coordinator.

#1 — Server-side MaxTTL clamp:
  - Add Leaser.MaxTTL (default 30s when zero).
  - Acquire + Renew reject TTL > MaxTTL with ErrTTLExceedsMax. Without
    this, one malicious or buggy caller pins the lease for an arbitrary
    duration regardless of liveness.
  - readLease rejects an on-disk ExpiresAt > now + MaxTTL with
    ErrLeaseCorrupt — defense in depth against a writer that bypassed
    the API.

#4 — Owner check in RenewLease:
  - ETag CAS alone permits a caller who read another Leaser's ETag from
    disk to craft a renew call carrying any Owner string. We now read
    the on-disk record first and reject when existing.Owner != l.Owner
    with ErrLeaseNotHeld. ETag CAS remains primary; Owner check is
    belt-and-suspenders.

Tests:
  - TestLeaser_RejectsTTLBeyondMax (acquire, renew, zero MaxTTL falls
    back to default)
  - TestLeaser_ReadRejectsCorruptedTTL (hand-write 1h ExpiresAt,
    Acquire rejects)
  - TestLeaser_RejectsRenewByForeignOwner (foreign Owner with stolen
    ETag → ErrLeaseNotHeld)

All existing leaser tests continue to pass.

* leaser: surface MaxLeaseTTL() on the interface

Heartbeat schedulers depend on the leaser's TTL ceiling to validate
that their renew cadence is at least 2x tighter than the
server-side bound. Without surfacing it, callers either hardcode
the bound or compute it from the leaser type — both brittle.

The struct field stays MaxTTL (configuration knob); the interface
method is MaxLeaseTTL() to avoid the Go name-collision rule
("field and method with the same name"). file:// and s3:// both
default to DefaultLeaseMaxTTL (30s) when unset.

* dblock: expose SQLite-compatible byte-range lock as a public package

External callers that mirror Replica.applyLTXFile (e.g. shard-layer
exclusive-lock primitive so their apply pipeline interlocks with
SQLite's own locking and with the upstream applier.

dblock.LockExclusive / dblock.Unlock target the SQLite pending-byte
(0x40000000) and shared-region addresses, matching the existing
internal.LockFileExclusive contract used by Replica.applyLTXFile.
The internal/lock_*.go shims now forward to dblock so there is one
canonical implementation across the module.

Behavior identical to the previous internal/ implementation —
file-level tests in file/ pass.

* chore: bump go to 1.26.4

Picks up upstream security fixes for crypto/x509, mime, net/textproto,
crypto/fips140. No dependency bumps — only the toolchain directive.

go test -race ./... is green.

* chore: fix gofmt + goimports drift across repo

Pre-existing formatting drift on main exposed by go-imports-repo
pre-commit hook. Pure whitespace alignment + import ordering — no
logic changes. Touches 16 files unrelated to the file:// Leaser PR.
2026-06-07 07:55:05 -07:00

2380 lines
70 KiB
Go

package replicate_test
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/hanzoai/ltx"
"github.com/hanzoai/lz4/v4"
"github.com/hanzoai/replicate"
"github.com/hanzoai/replicate/file"
"github.com/hanzoai/replicate/internal/testingutil"
"github.com/hanzoai/replicate/mock"
)
func TestReplica_Sync(t *testing.T) {
db, sqldb := testingutil.MustOpenDBs(t)
defer testingutil.MustCloseDBs(t, db, sqldb)
t.Log("initial sync")
// Issue initial database sync.
if err := db.Sync(context.Background()); err != nil {
t.Fatal(err)
}
// Fetch current database position.
dpos, err := db.Pos()
if err != nil {
t.Fatal(err)
}
t.Logf("position after sync: %s", dpos.String())
c := file.NewReplicaClient(t.TempDir())
r := replicate.NewReplicaWithClient(db, c)
if err := r.Sync(context.Background()); err != nil {
t.Fatal(err)
}
t.Logf("second sync")
// Verify we synced checkpoint page to WAL.
rd, err := c.OpenLTXFile(context.Background(), 0, dpos.TXID, dpos.TXID, 0, 0)
if err != nil {
t.Fatal(err)
}
defer func() { _ = rd.Close() }()
dec := ltx.NewDecoder(rd)
if err := dec.Verify(); err != nil {
t.Fatal(err)
} else if err := rd.Close(); err != nil {
t.Fatal(err)
} else if got, want := int(dec.Header().PageSize), db.PageSize(); got != want {
t.Fatalf("page size: %d, want %d", got, want)
}
// Reset WAL so the next write will only write out the segment we are checking.
if err := db.Checkpoint(context.Background(), replicate.CheckpointModeTruncate); err != nil {
t.Fatal(err)
}
// Execute a query to write something into the truncated WAL.
if _, err := sqldb.ExecContext(t.Context(), `CREATE TABLE foo (bar TEXT);`); err != nil {
t.Fatal(err)
}
// Sync database to catch up the shadow WAL.
if err := db.Sync(context.Background()); err != nil {
t.Fatal(err)
}
// Save position after sync, it should be after our write.
_, err = db.Pos()
if err != nil {
t.Fatal(err)
}
// Sync WAL segment out to replica.
if err := r.Sync(context.Background()); err != nil {
t.Fatal(err)
}
// TODO(ltx): Restore snapshot and verify
}
// TestReplica_RestoreAndReplicateAfterDataLoss tests the scenario described in issue #781
// where a database is restored to an earlier state (with lower TXID) but the replica has
// a higher TXID, causing new writes to not be replicated.
//
// The fix detects this condition in DB.init() by comparing database position vs replica
// position. When database is behind, it fetches the latest L0 file from the replica and
// triggers a snapshot on the next sync.
//
// This test follows the reproduction steps from issue #781:
// 1. Create DB and replicate data
// 2. Restore from backup (simulating hard recovery)
// 3. Insert new data and replicate
// 4. Restore again and verify new data exists
func TestReplica_RestoreAndReplicateAfterDataLoss(t *testing.T) {
ctx := context.Background()
// Create a temporary directory for replica storage
replicaDir := t.TempDir()
replicaClient := file.NewReplicaClient(replicaDir)
// Create database with initial data
dbDir := t.TempDir()
dbPath := dbDir + "/db.sqlite"
// Step 1: Create initial data and replicate
sqldb := testingutil.MustOpenSQLDB(t, dbPath)
if _, err := sqldb.ExecContext(ctx, `CREATE TABLE test(col1 INTEGER);`); err != nil {
t.Fatal(err)
}
if _, err := sqldb.ExecContext(ctx, `INSERT INTO test VALUES (1);`); err != nil {
t.Fatal(err)
}
if err := sqldb.Close(); err != nil {
t.Fatal(err)
}
// Start replicate replication
db1 := testingutil.NewDB(t, dbPath)
db1.MonitorInterval = 0
db1.Replica = replicate.NewReplicaWithClient(db1, replicaClient)
db1.Replica.MonitorEnabled = false
if err := db1.Open(); err != nil {
t.Fatal(err)
}
if err := db1.Sync(ctx); err != nil {
t.Fatal(err)
}
if err := db1.Replica.Sync(ctx); err != nil {
t.Fatal(err)
}
if err := db1.Close(ctx); err != nil {
t.Fatal(err)
}
t.Log("Step 1 complete: Initial data replicated")
// Step 2: Simulate hard recovery - remove database and .replicate directory, then restore
if err := os.Remove(dbPath); err != nil {
t.Fatal(err)
}
if err := os.Remove(dbPath + "-wal"); os.IsExist(err) {
t.Fatal(err)
}
if err := os.Remove(dbPath + "-shm"); os.IsExist(err) {
t.Fatal(err)
}
metaPath := db1.MetaPath()
if err := os.RemoveAll(metaPath); err != nil {
t.Fatal(err)
}
// Restore from backup
restoreOpt := replicate.RestoreOptions{
OutputPath: dbPath,
}
if err := db1.Replica.Restore(ctx, restoreOpt); err != nil {
t.Fatal(err)
}
t.Log("Step 2 complete: Database restored from backup")
// Step 3: Start replication and insert new data
db2 := testingutil.NewDB(t, dbPath)
db2.MonitorInterval = 0
db2.Replica = replicate.NewReplicaWithClient(db2, replicaClient)
db2.Replica.MonitorEnabled = false
if err := db2.Open(); err != nil {
t.Fatal(err)
}
sqldb2 := testingutil.MustOpenSQLDB(t, dbPath)
if _, err := sqldb2.ExecContext(ctx, `INSERT INTO test VALUES (2);`); err != nil {
t.Fatal(err)
}
if err := sqldb2.Close(); err != nil {
t.Fatal(err)
}
t.Log("Step 3: Inserted new data (value=2) after restore")
// Sync new data
if err := db2.Sync(ctx); err != nil {
t.Fatal(err)
}
if err := db2.Replica.Sync(ctx); err != nil {
t.Fatal(err)
}
if err := db2.Close(ctx); err != nil {
t.Fatal(err)
}
t.Log("Step 3 complete: New data synced")
// Step 4: Simulate second hard recovery and restore again
if err := os.Remove(dbPath); err != nil {
t.Fatal(err)
}
if err := os.Remove(dbPath + "-wal"); os.IsExist(err) {
t.Fatal(err)
}
if err := os.Remove(dbPath + "-shm"); os.IsExist(err) {
t.Fatal(err)
}
if err := os.RemoveAll(db2.MetaPath()); err != nil {
t.Fatal(err)
}
// Restore to a path with non-existent parent directory to verify it gets created
restoredPath := dbDir + "/restored/db.sqlite"
restoreOpt.OutputPath = restoredPath
if err := db2.Replica.Restore(ctx, restoreOpt); err != nil {
t.Fatal(err)
}
t.Log("Step 4 complete: Second restore from backup to path with non-existent parent")
// Step 5: Verify the new data (value=2) exists in restored database
sqldb3 := testingutil.MustOpenSQLDB(t, restoredPath)
defer sqldb3.Close()
var count int
if err := sqldb3.QueryRowContext(ctx, `SELECT COUNT(*) FROM test;`).Scan(&count); err != nil {
t.Fatal(err)
}
// Should have 2 rows (1 and 2)
if count != 2 {
t.Fatalf("expected 2 rows in restored database, got %d", count)
}
// Verify the new row (value=2) exists
var exists bool
if err := sqldb3.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM test WHERE col1 = 2);`).Scan(&exists); err != nil {
t.Fatal(err)
}
if !exists {
t.Fatal("new data (value=2) was not replicated - this is the bug in issue #781")
}
t.Log("Test passed: New data after restore was successfully replicated")
}
func TestReplica_CalcRestorePlan(t *testing.T) {
db, sqldb := testingutil.MustOpenDBs(t)
defer testingutil.MustCloseDBs(t, db, sqldb)
t.Run("SnapshotOnly", func(t *testing.T) {
var c mock.ReplicaClient
r := replicate.NewReplicaWithClient(db, &c)
c.LTXFilesFunc = func(ctx context.Context, level int, seek ltx.TXID, useMetadata bool) (ltx.FileIterator, error) {
if level == replicate.SnapshotLevel {
return ltx.NewFileInfoSliceIterator([]*ltx.FileInfo{{
Level: replicate.SnapshotLevel,
MinTXID: 1,
MaxTXID: 10,
Size: 1024,
CreatedAt: time.Now(),
}}), nil
}
return ltx.NewFileInfoSliceIterator(nil), nil
}
plan, err := replicate.CalcRestorePlan(context.Background(), r.Client, 10, time.Time{}, r.Logger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got, want := len(plan), 1; got != want {
t.Fatalf("n=%d, want %d", got, want)
}
if plan[0].MaxTXID != 10 {
t.Fatalf("expected MaxTXID 10, got %d", plan[0].MaxTXID)
}
})
t.Run("SnapshotAndIncremental", func(t *testing.T) {
var c mock.ReplicaClient
r := replicate.NewReplicaWithClient(db, &c)
c.LTXFilesFunc = func(ctx context.Context, level int, seek ltx.TXID, useMetadata bool) (ltx.FileIterator, error) {
switch level {
case replicate.SnapshotLevel:
return ltx.NewFileInfoSliceIterator([]*ltx.FileInfo{
{Level: replicate.SnapshotLevel, MinTXID: 1, MaxTXID: 5},
{Level: replicate.SnapshotLevel, MinTXID: 1, MaxTXID: 15},
}), nil
case 1:
return ltx.NewFileInfoSliceIterator([]*ltx.FileInfo{
{Level: 1, MinTXID: 6, MaxTXID: 7},
{Level: 1, MinTXID: 8, MaxTXID: 9},
{Level: 1, MinTXID: 10, MaxTXID: 12},
}), nil
case 0:
return ltx.NewFileInfoSliceIterator([]*ltx.FileInfo{
{Level: 0, MinTXID: 7, MaxTXID: 7},
{Level: 0, MinTXID: 8, MaxTXID: 8},
{Level: 0, MinTXID: 9, MaxTXID: 9},
{Level: 0, MinTXID: 10, MaxTXID: 10},
{Level: 0, MinTXID: 11, MaxTXID: 11},
}), nil
default:
return ltx.NewFileInfoSliceIterator(nil), nil
}
}
plan, err := replicate.CalcRestorePlan(context.Background(), r.Client, 10, time.Time{}, r.Logger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got, want := len(plan), 4; got != want {
t.Fatalf("n=%v, want %v", got, want)
}
if got, want := *plan[0], (ltx.FileInfo{Level: replicate.SnapshotLevel, MinTXID: 1, MaxTXID: 5}); got != want {
t.Fatalf("plan[0]=%#v, want %#v", got, want)
}
if got, want := *plan[1], (ltx.FileInfo{Level: 1, MinTXID: 6, MaxTXID: 7}); got != want {
t.Fatalf("plan[1]=%#v, want %#v", got, want)
}
if got, want := *plan[2], (ltx.FileInfo{Level: 1, MinTXID: 8, MaxTXID: 9}); got != want {
t.Fatalf("plan[2]=%#v, want %#v", got, want)
}
if got, want := *plan[3], (ltx.FileInfo{Level: 0, MinTXID: 10, MaxTXID: 10}); got != want {
t.Fatalf("plan[2]=%#v, want %#v", got, want)
}
})
t.Run("SelectLongestAcrossLevels", func(t *testing.T) {
var c mock.ReplicaClient
r := replicate.NewReplicaWithClient(db, &c)
c.LTXFilesFunc = func(ctx context.Context, level int, seek ltx.TXID, useMetadata bool) (ltx.FileIterator, error) {
switch level {
case replicate.SnapshotLevel:
return ltx.NewFileInfoSliceIterator([]*ltx.FileInfo{
{Level: replicate.SnapshotLevel, MinTXID: 1, MaxTXID: 5},
}), nil
case 2:
return ltx.NewFileInfoSliceIterator([]*ltx.FileInfo{
{Level: 2, MinTXID: 6, MaxTXID: 12},
}), nil
case 0:
return ltx.NewFileInfoSliceIterator([]*ltx.FileInfo{
{Level: 0, MinTXID: 6, MaxTXID: 20},
}), nil
default:
return ltx.NewFileInfoSliceIterator(nil), nil
}
}
plan, err := replicate.CalcRestorePlan(context.Background(), r.Client, 20, time.Time{}, r.Logger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got, want := len(plan), 2; got != want {
t.Fatalf("n=%v, want %v", got, want)
}
if got, want := *plan[0], (ltx.FileInfo{Level: replicate.SnapshotLevel, MinTXID: 1, MaxTXID: 5}); got != want {
t.Fatalf("plan[0]=%#v, want %#v", got, want)
}
if got, want := *plan[1], (ltx.FileInfo{Level: 0, MinTXID: 6, MaxTXID: 20}); got != want {
t.Fatalf("plan[1]=%#v, want %#v", got, want)
}
})
t.Run("GapInLevelResolvedByLowerLevel", func(t *testing.T) {
var c mock.ReplicaClient
r := replicate.NewReplicaWithClient(db, &c)
c.LTXFilesFunc = func(ctx context.Context, level int, seek ltx.TXID, useMetadata bool) (ltx.FileIterator, error) {
switch level {
case replicate.SnapshotLevel:
return ltx.NewFileInfoSliceIterator([]*ltx.FileInfo{
{Level: replicate.SnapshotLevel, MinTXID: 1, MaxTXID: 5},
}), nil
case 1:
return ltx.NewFileInfoSliceIterator([]*ltx.FileInfo{
{Level: 1, MinTXID: 6, MaxTXID: 7},
{Level: 1, MinTXID: 9, MaxTXID: 10},
}), nil
case 0:
return ltx.NewFileInfoSliceIterator([]*ltx.FileInfo{
{Level: 0, MinTXID: 8, MaxTXID: 8},
{Level: 0, MinTXID: 9, MaxTXID: 9},
{Level: 0, MinTXID: 10, MaxTXID: 10},
}), nil
default:
return ltx.NewFileInfoSliceIterator(nil), nil
}
}
plan, err := replicate.CalcRestorePlan(context.Background(), r.Client, 10, time.Time{}, r.Logger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got, want := len(plan), 4; got != want {
t.Fatalf("n=%v, want %v", got, want)
}
if got, want := *plan[0], (ltx.FileInfo{Level: replicate.SnapshotLevel, MinTXID: 1, MaxTXID: 5}); got != want {
t.Fatalf("plan[0]=%#v, want %#v", got, want)
}
if got, want := *plan[1], (ltx.FileInfo{Level: 1, MinTXID: 6, MaxTXID: 7}); got != want {
t.Fatalf("plan[1]=%#v, want %#v", got, want)
}
if got, want := *plan[2], (ltx.FileInfo{Level: 0, MinTXID: 8, MaxTXID: 8}); got != want {
t.Fatalf("plan[2]=%#v, want %#v", got, want)
}
if got, want := *plan[3], (ltx.FileInfo{Level: 1, MinTXID: 9, MaxTXID: 10}); got != want {
t.Fatalf("plan[3]=%#v, want %#v", got, want)
}
})
t.Run("SkipsDuplicateRangesAcrossLevels", func(t *testing.T) {
var c mock.ReplicaClient
r := replicate.NewReplicaWithClient(db, &c)
c.LTXFilesFunc = func(ctx context.Context, level int, seek ltx.TXID, useMetadata bool) (ltx.FileIterator, error) {
switch level {
case replicate.SnapshotLevel:
return ltx.NewFileInfoSliceIterator([]*ltx.FileInfo{
{Level: replicate.SnapshotLevel, MinTXID: 1, MaxTXID: 1},
}), nil
case 1:
return ltx.NewFileInfoSliceIterator([]*ltx.FileInfo{
{Level: 1, MinTXID: 1, MaxTXID: 1},
}), nil
case 0:
return ltx.NewFileInfoSliceIterator([]*ltx.FileInfo{
{Level: 0, MinTXID: 1, MaxTXID: 1},
}), nil
default:
return ltx.NewFileInfoSliceIterator(nil), nil
}
}
plan, err := replicate.CalcRestorePlan(context.Background(), r.Client, 1, time.Time{}, r.Logger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got, want := len(plan), 1; got != want {
t.Fatalf("n=%v, want %v", got, want)
}
if got, want := *plan[0], (ltx.FileInfo{Level: replicate.SnapshotLevel, MinTXID: 1, MaxTXID: 1}); got != want {
t.Fatalf("plan[0]=%#v, want %#v", got, want)
}
})
// Issue #847: When a level has overlapping files where a larger compacted file
// covers a smaller file's entire range, the smaller file should be skipped
// rather than causing a non-contiguous error.
t.Run("OverlappingFilesWithinLevel", func(t *testing.T) {
var c mock.ReplicaClient
r := replicate.NewReplicaWithClient(db, &c)
c.LTXFilesFunc = func(ctx context.Context, level int, seek ltx.TXID, useMetadata bool) (ltx.FileIterator, error) {
switch level {
case replicate.SnapshotLevel:
return ltx.NewFileInfoSliceIterator([]*ltx.FileInfo{
{Level: replicate.SnapshotLevel, MinTXID: 1, MaxTXID: 5},
}), nil
case 2:
// Simulates issue #847: Files are sorted by MinTXID (filename order).
// File 1 is a large compacted file covering 1-100.
// File 2 is a smaller file covering 50-60, which is fully within file 1's range.
// Before the fix, file 2 would pass the filter (MaxTXID 60 > infos.MaxTXID() 5)
// but then fail the contiguity check after file 1 is added.
return ltx.NewFileInfoSliceIterator([]*ltx.FileInfo{
{Level: 2, MinTXID: 1, MaxTXID: 100},
{Level: 2, MinTXID: 50, MaxTXID: 60},
}), nil
default:
return ltx.NewFileInfoSliceIterator(nil), nil
}
}
plan, err := replicate.CalcRestorePlan(context.Background(), r.Client, 100, time.Time{}, r.Logger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Plan should contain only snapshot and the large file, not the smaller overlapping file
if got, want := len(plan), 2; got != want {
t.Fatalf("n=%d, want %d", got, want)
}
if got, want := *plan[0], (ltx.FileInfo{Level: replicate.SnapshotLevel, MinTXID: 1, MaxTXID: 5}); got != want {
t.Fatalf("plan[0]=%#v, want %#v", got, want)
}
if got, want := *plan[1], (ltx.FileInfo{Level: 2, MinTXID: 1, MaxTXID: 100}); got != want {
t.Fatalf("plan[1]=%#v, want %#v", got, want)
}
})
t.Run("ErrTxNotAvailable", func(t *testing.T) {
var c mock.ReplicaClient
r := replicate.NewReplicaWithClient(db, &c)
c.LTXFilesFunc = func(ctx context.Context, level int, seek ltx.TXID, useMetadata bool) (ltx.FileIterator, error) {
switch level {
case replicate.SnapshotLevel:
return ltx.NewFileInfoSliceIterator([]*ltx.FileInfo{
{Level: replicate.SnapshotLevel, MinTXID: 1, MaxTXID: 10},
}), nil
default:
return ltx.NewFileInfoSliceIterator(nil), nil
}
}
_, err := replicate.CalcRestorePlan(context.Background(), r.Client, 5, time.Time{}, r.Logger())
if !errors.Is(err, replicate.ErrTxNotAvailable) {
t.Fatalf("expected ErrTxNotAvailable, got %v", err)
}
})
t.Run("ErrNoFiles", func(t *testing.T) {
var c mock.ReplicaClient
c.LTXFilesFunc = func(ctx context.Context, level int, seek ltx.TXID, useMetadata bool) (ltx.FileIterator, error) {
return ltx.NewFileInfoSliceIterator(nil), nil
}
r := replicate.NewReplicaWithClient(db, &c)
_, err := replicate.CalcRestorePlan(context.Background(), r.Client, 5, time.Time{}, r.Logger())
if !errors.Is(err, replicate.ErrTxNotAvailable) {
t.Fatalf("expected ErrTxNotAvailable, got %v", err)
}
})
}
func TestReplica_TimeBounds(t *testing.T) {
db, sqldb := testingutil.MustOpenDBs(t)
defer testingutil.MustCloseDBs(t, db, sqldb)
t.Run("Level0Only", func(t *testing.T) {
now := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
var c mock.ReplicaClient
r := replicate.NewReplicaWithClient(db, &c)
c.LTXFilesFunc = func(ctx context.Context, level int, seek ltx.TXID, useMetadata bool) (ltx.FileIterator, error) {
if level == 0 {
return ltx.NewFileInfoSliceIterator([]*ltx.FileInfo{
{Level: 0, MinTXID: 1, MaxTXID: 1, CreatedAt: now},
{Level: 0, MinTXID: 2, MaxTXID: 2, CreatedAt: now.Add(time.Hour)},
{Level: 0, MinTXID: 3, MaxTXID: 3, CreatedAt: now.Add(2 * time.Hour)},
}), nil
}
return ltx.NewFileInfoSliceIterator(nil), nil
}
createdAt, updatedAt, err := r.TimeBounds(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !createdAt.Equal(now) {
t.Fatalf("createdAt=%v, want %v", createdAt, now)
}
if want := now.Add(2 * time.Hour); !updatedAt.Equal(want) {
t.Fatalf("updatedAt=%v, want %v", updatedAt, want)
}
})
t.Run("SnapshotOnly", func(t *testing.T) {
now := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
var c mock.ReplicaClient
r := replicate.NewReplicaWithClient(db, &c)
c.LTXFilesFunc = func(ctx context.Context, level int, seek ltx.TXID, useMetadata bool) (ltx.FileIterator, error) {
if level == replicate.SnapshotLevel {
return ltx.NewFileInfoSliceIterator([]*ltx.FileInfo{
{Level: replicate.SnapshotLevel, MinTXID: 1, MaxTXID: 10, CreatedAt: now},
}), nil
}
return ltx.NewFileInfoSliceIterator(nil), nil
}
createdAt, updatedAt, err := r.TimeBounds(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !createdAt.Equal(now) {
t.Fatalf("createdAt=%v, want %v", createdAt, now)
}
if !updatedAt.Equal(now) {
t.Fatalf("updatedAt=%v, want %v", updatedAt, now)
}
})
t.Run("SnapshotAndLevel0", func(t *testing.T) {
snapshotTime := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
l0Time := time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC)
var c mock.ReplicaClient
r := replicate.NewReplicaWithClient(db, &c)
c.LTXFilesFunc = func(ctx context.Context, level int, seek ltx.TXID, useMetadata bool) (ltx.FileIterator, error) {
switch level {
case replicate.SnapshotLevel:
return ltx.NewFileInfoSliceIterator([]*ltx.FileInfo{
{Level: replicate.SnapshotLevel, MinTXID: 1, MaxTXID: 10, CreatedAt: snapshotTime},
}), nil
case 0:
return ltx.NewFileInfoSliceIterator([]*ltx.FileInfo{
{Level: 0, MinTXID: 11, MaxTXID: 11, CreatedAt: l0Time},
}), nil
default:
return ltx.NewFileInfoSliceIterator(nil), nil
}
}
createdAt, updatedAt, err := r.TimeBounds(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !createdAt.Equal(snapshotTime) {
t.Fatalf("createdAt=%v, want %v", createdAt, snapshotTime)
}
if !updatedAt.Equal(l0Time) {
t.Fatalf("updatedAt=%v, want %v", updatedAt, l0Time)
}
})
t.Run("MultipleCompactionLevels", func(t *testing.T) {
snapshotTime := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
l2Time := time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC)
l0Time := time.Date(2024, 1, 3, 0, 0, 0, 0, time.UTC)
var c mock.ReplicaClient
r := replicate.NewReplicaWithClient(db, &c)
c.LTXFilesFunc = func(ctx context.Context, level int, seek ltx.TXID, useMetadata bool) (ltx.FileIterator, error) {
switch level {
case replicate.SnapshotLevel:
return ltx.NewFileInfoSliceIterator([]*ltx.FileInfo{
{Level: replicate.SnapshotLevel, MinTXID: 1, MaxTXID: 5, CreatedAt: snapshotTime},
}), nil
case 2:
return ltx.NewFileInfoSliceIterator([]*ltx.FileInfo{
{Level: 2, MinTXID: 6, MaxTXID: 8, CreatedAt: l2Time},
}), nil
case 0:
return ltx.NewFileInfoSliceIterator([]*ltx.FileInfo{
{Level: 0, MinTXID: 9, MaxTXID: 9, CreatedAt: l0Time},
}), nil
default:
return ltx.NewFileInfoSliceIterator(nil), nil
}
}
createdAt, updatedAt, err := r.TimeBounds(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !createdAt.Equal(snapshotTime) {
t.Fatalf("createdAt=%v, want %v", createdAt, snapshotTime)
}
if !updatedAt.Equal(l0Time) {
t.Fatalf("updatedAt=%v, want %v", updatedAt, l0Time)
}
})
t.Run("NoFiles", func(t *testing.T) {
var c mock.ReplicaClient
r := replicate.NewReplicaWithClient(db, &c)
c.LTXFilesFunc = func(ctx context.Context, level int, seek ltx.TXID, useMetadata bool) (ltx.FileIterator, error) {
return ltx.NewFileInfoSliceIterator(nil), nil
}
createdAt, updatedAt, err := r.TimeBounds(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !createdAt.IsZero() {
t.Fatalf("createdAt=%v, want zero", createdAt)
}
if !updatedAt.IsZero() {
t.Fatalf("updatedAt=%v, want zero", updatedAt)
}
})
t.Run("ErrorOnLevel", func(t *testing.T) {
var c mock.ReplicaClient
r := replicate.NewReplicaWithClient(db, &c)
errTest := errors.New("test error")
c.LTXFilesFunc = func(ctx context.Context, level int, seek ltx.TXID, useMetadata bool) (ltx.FileIterator, error) {
if level == 3 {
return nil, errTest
}
return ltx.NewFileInfoSliceIterator(nil), nil
}
_, _, err := r.TimeBounds(context.Background())
if !errors.Is(err, errTest) {
t.Fatalf("expected test error, got %v", err)
}
})
}
func TestReplica_CalcRestoreTarget(t *testing.T) {
db, sqldb := testingutil.MustOpenDBs(t)
defer testingutil.MustCloseDBs(t, db, sqldb)
t.Run("TimestampInSnapshotRange", func(t *testing.T) {
snapshotTime := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
l0Time := time.Date(2024, 1, 10, 0, 0, 0, 0, time.UTC)
var c mock.ReplicaClient
r := replicate.NewReplicaWithClient(db, &c)
c.LTXFilesFunc = func(ctx context.Context, level int, seek ltx.TXID, useMetadata bool) (ltx.FileIterator, error) {
switch level {
case replicate.SnapshotLevel:
return ltx.NewFileInfoSliceIterator([]*ltx.FileInfo{
{Level: replicate.SnapshotLevel, MinTXID: 1, MaxTXID: 10, CreatedAt: snapshotTime},
}), nil
case 0:
return ltx.NewFileInfoSliceIterator([]*ltx.FileInfo{
{Level: 0, MinTXID: 11, MaxTXID: 11, CreatedAt: l0Time},
}), nil
default:
return ltx.NewFileInfoSliceIterator(nil), nil
}
}
ts := time.Date(2024, 1, 5, 0, 0, 0, 0, time.UTC)
updatedAt, err := r.CalcRestoreTarget(context.Background(), replicate.RestoreOptions{Timestamp: ts})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !updatedAt.Equal(l0Time) {
t.Fatalf("updatedAt=%v, want %v", updatedAt, l0Time)
}
})
t.Run("TimestampBeforeAllFiles", func(t *testing.T) {
snapshotTime := time.Date(2024, 1, 5, 0, 0, 0, 0, time.UTC)
var c mock.ReplicaClient
r := replicate.NewReplicaWithClient(db, &c)
c.LTXFilesFunc = func(ctx context.Context, level int, seek ltx.TXID, useMetadata bool) (ltx.FileIterator, error) {
switch level {
case replicate.SnapshotLevel:
return ltx.NewFileInfoSliceIterator([]*ltx.FileInfo{
{Level: replicate.SnapshotLevel, MinTXID: 1, MaxTXID: 10, CreatedAt: snapshotTime},
}), nil
default:
return ltx.NewFileInfoSliceIterator(nil), nil
}
}
ts := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
_, err := r.CalcRestoreTarget(context.Background(), replicate.RestoreOptions{Timestamp: ts})
if err == nil || err.Error() != "timestamp does not exist" {
t.Fatalf("expected 'timestamp does not exist', got %v", err)
}
})
t.Run("TimestampAfterAllFiles", func(t *testing.T) {
snapshotTime := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
var c mock.ReplicaClient
r := replicate.NewReplicaWithClient(db, &c)
c.LTXFilesFunc = func(ctx context.Context, level int, seek ltx.TXID, useMetadata bool) (ltx.FileIterator, error) {
switch level {
case replicate.SnapshotLevel:
return ltx.NewFileInfoSliceIterator([]*ltx.FileInfo{
{Level: replicate.SnapshotLevel, MinTXID: 1, MaxTXID: 10, CreatedAt: snapshotTime},
}), nil
default:
return ltx.NewFileInfoSliceIterator(nil), nil
}
}
ts := time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC)
_, err := r.CalcRestoreTarget(context.Background(), replicate.RestoreOptions{Timestamp: ts})
if err == nil || err.Error() != "timestamp does not exist" {
t.Fatalf("expected 'timestamp does not exist', got %v", err)
}
})
t.Run("NoTimestamp", func(t *testing.T) {
now := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
var c mock.ReplicaClient
r := replicate.NewReplicaWithClient(db, &c)
c.LTXFilesFunc = func(ctx context.Context, level int, seek ltx.TXID, useMetadata bool) (ltx.FileIterator, error) {
if level == 0 {
return ltx.NewFileInfoSliceIterator([]*ltx.FileInfo{
{Level: 0, MinTXID: 1, MaxTXID: 1, CreatedAt: now},
{Level: 0, MinTXID: 2, MaxTXID: 2, CreatedAt: now.Add(time.Hour)},
}), nil
}
return ltx.NewFileInfoSliceIterator(nil), nil
}
updatedAt, err := r.CalcRestoreTarget(context.Background(), replicate.RestoreOptions{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if want := now.Add(time.Hour); !updatedAt.Equal(want) {
t.Fatalf("updatedAt=%v, want %v", updatedAt, want)
}
})
}
func TestReplica_Restore_InvalidFileSize(t *testing.T) {
db, sqldb := testingutil.MustOpenDBs(t)
defer testingutil.MustCloseDBs(t, db, sqldb)
t.Run("EmptyFile", func(t *testing.T) {
var c mock.ReplicaClient
c.LTXFilesFunc = func(ctx context.Context, level int, seek ltx.TXID, useMetadata bool) (ltx.FileIterator, error) {
if level == replicate.SnapshotLevel {
return ltx.NewFileInfoSliceIterator([]*ltx.FileInfo{{
Level: replicate.SnapshotLevel,
MinTXID: 1,
MaxTXID: 10,
Size: 0, // Empty file - this should cause an error
CreatedAt: time.Now(),
}}), nil
}
return ltx.NewFileInfoSliceIterator(nil), nil
}
r := replicate.NewReplicaWithClient(db, &c)
outputPath := t.TempDir() + "/restored.db"
err := r.Restore(context.Background(), replicate.RestoreOptions{
OutputPath: outputPath,
})
if err == nil {
t.Fatal("expected error for empty file, got nil")
}
if !strings.Contains(err.Error(), "invalid ltx file") {
t.Fatalf("expected 'invalid ltx file' error, got: %v", err)
}
})
t.Run("TruncatedFile", func(t *testing.T) {
var c mock.ReplicaClient
c.LTXFilesFunc = func(ctx context.Context, level int, seek ltx.TXID, useMetadata bool) (ltx.FileIterator, error) {
if level == replicate.SnapshotLevel {
return ltx.NewFileInfoSliceIterator([]*ltx.FileInfo{{
Level: replicate.SnapshotLevel,
MinTXID: 1,
MaxTXID: 10,
Size: 50, // Less than ltx.HeaderSize (100) - should cause an error
CreatedAt: time.Now(),
}}), nil
}
return ltx.NewFileInfoSliceIterator(nil), nil
}
r := replicate.NewReplicaWithClient(db, &c)
outputPath := t.TempDir() + "/restored.db"
err := r.Restore(context.Background(), replicate.RestoreOptions{
OutputPath: outputPath,
})
if err == nil {
t.Fatal("expected error for truncated file, got nil")
}
if !strings.Contains(err.Error(), "invalid ltx file") {
t.Fatalf("expected 'invalid ltx file' error, got: %v", err)
}
})
}
func TestReplica_ContextCancellationNoLogs(t *testing.T) {
// This test verifies that context cancellation errors are not logged during shutdown.
// The fix for issue #235 ensures that context.Canceled and context.DeadlineExceeded
// errors are filtered out in monitor functions to avoid spurious log messages.
db, sqldb := testingutil.MustOpenDBs(t)
defer testingutil.MustCloseDBs(t, db, sqldb)
// Create a buffer to capture log output
var logBuffer bytes.Buffer
// Create a custom logger that writes to our buffer
db.Logger = slog.New(slog.NewTextHandler(&logBuffer, &slog.HandlerOptions{
Level: slog.LevelDebug,
}))
// First, let's trigger a normal sync to ensure the DB is initialized
if err := db.Sync(context.Background()); err != nil {
t.Fatal(err)
}
// Create a replica with a mock client that simulates context cancellation during Sync
syncCount := 0
mockClient := &mock.ReplicaClient{
LTXFilesFunc: func(ctx context.Context, level int, seek ltx.TXID, useMetadata bool) (ltx.FileIterator, error) {
syncCount++
// First few calls succeed, then return context.Canceled
if syncCount <= 2 {
// Return an empty iterator
return ltx.NewFileInfoSliceIterator(nil), nil
}
// After initial syncs, return context.Canceled to simulate shutdown
return nil, context.Canceled
},
WriteLTXFileFunc: func(ctx context.Context, level int, minTXID, maxTXID ltx.TXID, r io.Reader) (*ltx.FileInfo, error) {
// Always succeed for writes to allow normal operation
return &ltx.FileInfo{
Level: level,
MinTXID: minTXID,
MaxTXID: maxTXID,
CreatedAt: time.Now(),
}, nil
},
}
r := replicate.NewReplicaWithClient(db, mockClient)
r.SyncInterval = 50 * time.Millisecond // Short interval for testing
// Start the replica monitoring in a goroutine
ctx, cancel := context.WithCancel(context.Background())
if err := r.Start(ctx); err != nil {
t.Fatalf("failed to start replica: %v", err)
}
// Give the monitor time to run several sync cycles
// This ensures we get both successful syncs and context cancellation errors
time.Sleep(200 * time.Millisecond)
// Cancel the context to trigger shutdown
cancel()
// Stop the replica and wait for it to finish
if err := r.Stop(true); err != nil {
t.Fatalf("failed to stop replica: %v", err)
}
// Check the logs
logs := logBuffer.String()
// We should have some debug logs from successful operations
if !strings.Contains(logs, "replica sync") {
t.Errorf("expected 'replica sync' in logs but didn't find it; logs:\n%s", logs)
}
// But we should NOT have "monitor error" with "context canceled"
if strings.Contains(logs, "monitor error") && strings.Contains(logs, "context canceled") {
t.Errorf("found 'monitor error' with 'context canceled' in logs when it should be filtered:\n%s", logs)
}
// The test passes if context.Canceled errors were properly filtered
}
func TestReplica_ValidateLevel(t *testing.T) {
t.Run("ValidContiguousFiles", func(t *testing.T) {
client := file.NewReplicaClient(t.TempDir())
replica := replicate.NewReplicaWithClient(nil, client)
// Create contiguous files: 1-2, 3-5, 6-10
createTestLTXFile(t, client, 1, 1, 2)
createTestLTXFile(t, client, 1, 3, 5)
createTestLTXFile(t, client, 1, 6, 10)
errs, err := replica.ValidateLevel(context.Background(), 1)
if err != nil {
t.Fatal(err)
}
if len(errs) != 0 {
t.Errorf("expected no errors for contiguous files, got %d: %v", len(errs), errs)
}
})
t.Run("EmptyLevel", func(t *testing.T) {
client := file.NewReplicaClient(t.TempDir())
replica := replicate.NewReplicaWithClient(nil, client)
errs, err := replica.ValidateLevel(context.Background(), 1)
if err != nil {
t.Fatal(err)
}
if len(errs) != 0 {
t.Errorf("expected no errors for empty level, got %d", len(errs))
}
})
t.Run("SingleFile", func(t *testing.T) {
client := file.NewReplicaClient(t.TempDir())
replica := replicate.NewReplicaWithClient(nil, client)
createTestLTXFile(t, client, 1, 1, 5)
errs, err := replica.ValidateLevel(context.Background(), 1)
if err != nil {
t.Fatal(err)
}
if len(errs) != 0 {
t.Errorf("expected no errors for single file, got %d", len(errs))
}
})
t.Run("GapDetected", func(t *testing.T) {
client := file.NewReplicaClient(t.TempDir())
replica := replicate.NewReplicaWithClient(nil, client)
// Create files with a gap (missing TXID 3-4)
createTestLTXFile(t, client, 1, 1, 2)
createTestLTXFile(t, client, 1, 5, 7)
errs, err := replica.ValidateLevel(context.Background(), 1)
if err != nil {
t.Fatal(err)
}
if len(errs) != 1 {
t.Fatalf("expected 1 error, got %d", len(errs))
}
if errs[0].Type != "gap" {
t.Errorf("expected gap error, got %q", errs[0].Type)
}
if errs[0].Level != 1 {
t.Errorf("expected level 1, got %d", errs[0].Level)
}
})
t.Run("OverlapDetected", func(t *testing.T) {
client := file.NewReplicaClient(t.TempDir())
replica := replicate.NewReplicaWithClient(nil, client)
// Create overlapping files
createTestLTXFile(t, client, 1, 1, 5)
createTestLTXFile(t, client, 1, 3, 7)
errs, err := replica.ValidateLevel(context.Background(), 1)
if err != nil {
t.Fatal(err)
}
if len(errs) != 1 {
t.Fatalf("expected 1 error, got %d", len(errs))
}
if errs[0].Type != "overlap" {
t.Errorf("expected overlap error, got %q", errs[0].Type)
}
})
t.Run("MultipleErrors", func(t *testing.T) {
client := file.NewReplicaClient(t.TempDir())
replica := replicate.NewReplicaWithClient(nil, client)
// Create files with multiple issues: gap then overlap
createTestLTXFile(t, client, 1, 1, 2)
createTestLTXFile(t, client, 1, 5, 10) // gap at 3-4
createTestLTXFile(t, client, 1, 8, 12) // overlap at 8-10
errs, err := replica.ValidateLevel(context.Background(), 1)
if err != nil {
t.Fatal(err)
}
if len(errs) != 2 {
t.Fatalf("expected 2 errors, got %d", len(errs))
}
if errs[0].Type != "gap" {
t.Errorf("expected first error to be gap, got %q", errs[0].Type)
}
if errs[1].Type != "overlap" {
t.Errorf("expected second error to be overlap, got %q", errs[1].Type)
}
})
}
func TestReplica_RestoreV3(t *testing.T) {
t.Run("SnapshotOnly", func(t *testing.T) {
ctx := context.Background()
tmpDir := t.TempDir()
replicaDir := t.TempDir()
// Create a v0.3.x backup structure with a snapshot
gen := "0123456789abcdef"
createV3Backup(t, replicaDir, gen, []v3SnapshotData{
{index: 0, data: createTestSQLiteDB(t)},
}, nil)
// Create replica client and replica
c := file.NewReplicaClient(replicaDir)
r := replicate.NewReplicaWithClient(nil, c)
// Restore
outputPath := tmpDir + "/restored.db"
err := r.RestoreV3(ctx, replicate.RestoreOptions{
OutputPath: outputPath,
})
if err != nil {
t.Fatalf("RestoreV3 failed: %v", err)
}
// Verify restored database
verifyRestoredDB(t, outputPath)
})
t.Run("SnapshotWithWAL", func(t *testing.T) {
ctx := context.Background()
tmpDir := t.TempDir()
replicaDir := t.TempDir()
// Create a v0.3.x backup with snapshot and WAL segments
gen := "0123456789abcdef"
dbData := createTestSQLiteDB(t)
walData := createTestWALData(t, dbData)
createV3Backup(t, replicaDir, gen, []v3SnapshotData{
{index: 0, data: dbData},
}, []v3WALSegmentData{
{index: 0, offset: 0, data: walData},
})
// Create replica and restore
c := file.NewReplicaClient(replicaDir)
r := replicate.NewReplicaWithClient(nil, c)
outputPath := tmpDir + "/restored.db"
err := r.RestoreV3(ctx, replicate.RestoreOptions{
OutputPath: outputPath,
})
if err != nil {
t.Fatalf("RestoreV3 failed: %v", err)
}
// Verify restored database
verifyRestoredDB(t, outputPath)
})
t.Run("TimestampRestore", func(t *testing.T) {
ctx := context.Background()
tmpDir := t.TempDir()
replicaDir := t.TempDir()
// Create a v0.3.x backup with multiple snapshots at different times
gen := "0123456789abcdef"
snapshotsDir := filepath.Join(replicaDir, "generations", gen, "snapshots")
if err := os.MkdirAll(snapshotsDir, 0755); err != nil {
t.Fatal(err)
}
// Create first snapshot (older)
dbData1 := createTestSQLiteDB(t)
writeV3Snapshot(t, snapshotsDir, 0, dbData1)
// Set older mod time
oldTime := time.Now().Add(-2 * time.Hour)
if err := os.Chtimes(filepath.Join(snapshotsDir, "00000000.snapshot.lz4"), oldTime, oldTime); err != nil {
t.Fatal(err)
}
// Create second snapshot (newer) - sleep briefly to ensure different times
time.Sleep(10 * time.Millisecond)
dbData2 := createTestSQLiteDB(t)
writeV3Snapshot(t, snapshotsDir, 1, dbData2)
// Set newer mod time
newTime := time.Now().Add(-1 * time.Hour)
if err := os.Chtimes(filepath.Join(snapshotsDir, "00000001.snapshot.lz4"), newTime, newTime); err != nil {
t.Fatal(err)
}
// Create replica and restore to a timestamp between the two snapshots
c := file.NewReplicaClient(replicaDir)
r := replicate.NewReplicaWithClient(nil, c)
outputPath := tmpDir + "/restored.db"
restoreTime := time.Now().Add(-90 * time.Minute) // Between old and new
err := r.RestoreV3(ctx, replicate.RestoreOptions{
OutputPath: outputPath,
Timestamp: restoreTime,
})
if err != nil {
t.Fatalf("RestoreV3 failed: %v", err)
}
// Verify restored database (should be the older one)
verifyRestoredDB(t, outputPath)
})
t.Run("NoSnapshots", func(t *testing.T) {
ctx := context.Background()
tmpDir := t.TempDir()
replicaDir := t.TempDir()
// Create empty generations directory
gen := "0123456789abcdef"
if err := os.MkdirAll(filepath.Join(replicaDir, "generations", gen), 0755); err != nil {
t.Fatal(err)
}
c := file.NewReplicaClient(replicaDir)
r := replicate.NewReplicaWithClient(nil, c)
outputPath := tmpDir + "/restored.db"
err := r.RestoreV3(ctx, replicate.RestoreOptions{
OutputPath: outputPath,
})
if !errors.Is(err, replicate.ErrNoSnapshots) {
t.Fatalf("expected ErrNoSnapshots, got %v", err)
}
})
t.Run("NoGenerations", func(t *testing.T) {
ctx := context.Background()
tmpDir := t.TempDir()
replicaDir := t.TempDir()
// Empty replica directory
c := file.NewReplicaClient(replicaDir)
r := replicate.NewReplicaWithClient(nil, c)
outputPath := tmpDir + "/restored.db"
err := r.RestoreV3(ctx, replicate.RestoreOptions{
OutputPath: outputPath,
})
if !errors.Is(err, replicate.ErrNoSnapshots) {
t.Fatalf("expected ErrNoSnapshots, got %v", err)
}
})
t.Run("OutputPathExists", func(t *testing.T) {
ctx := context.Background()
replicaDir := t.TempDir()
// Create a v0.3.x backup
gen := "0123456789abcdef"
createV3Backup(t, replicaDir, gen, []v3SnapshotData{
{index: 0, data: createTestSQLiteDB(t)},
}, nil)
c := file.NewReplicaClient(replicaDir)
r := replicate.NewReplicaWithClient(nil, c)
// Create output file that already exists
outputPath := t.TempDir() + "/existing.db"
if err := os.WriteFile(outputPath, []byte("existing"), 0644); err != nil {
t.Fatal(err)
}
err := r.RestoreV3(ctx, replicate.RestoreOptions{
OutputPath: outputPath,
})
if err == nil || !strings.Contains(err.Error(), "already exists") {
t.Fatalf("expected 'already exists' error, got %v", err)
}
})
t.Run("ClientDoesNotSupportV3", func(t *testing.T) {
ctx := context.Background()
// Use mock client that doesn't implement ReplicaClientV3
var c mock.ReplicaClient
r := replicate.NewReplicaWithClient(nil, &c)
err := r.RestoreV3(ctx, replicate.RestoreOptions{
OutputPath: t.TempDir() + "/restored.db",
})
if err == nil || !strings.Contains(err.Error(), "does not support v0.3.x") {
t.Fatalf("expected 'does not support v0.3.x' error, got %v", err)
}
})
t.Run("MultipleGenerations", func(t *testing.T) {
ctx := context.Background()
tmpDir := t.TempDir()
replicaDir := t.TempDir()
// Create snapshots in two different generations
gen1 := "0000000000000001"
gen2 := "0000000000000002"
// Older snapshot in gen1
snapshotsDir1 := filepath.Join(replicaDir, "generations", gen1, "snapshots")
if err := os.MkdirAll(snapshotsDir1, 0755); err != nil {
t.Fatal(err)
}
dbData1 := createTestSQLiteDB(t)
writeV3Snapshot(t, snapshotsDir1, 0, dbData1)
oldTime := time.Now().Add(-2 * time.Hour)
if err := os.Chtimes(filepath.Join(snapshotsDir1, "00000000.snapshot.lz4"), oldTime, oldTime); err != nil {
t.Fatal(err)
}
// Newer snapshot in gen2
snapshotsDir2 := filepath.Join(replicaDir, "generations", gen2, "snapshots")
if err := os.MkdirAll(snapshotsDir2, 0755); err != nil {
t.Fatal(err)
}
dbData2 := createTestSQLiteDB(t)
writeV3Snapshot(t, snapshotsDir2, 0, dbData2)
newTime := time.Now().Add(-1 * time.Hour)
if err := os.Chtimes(filepath.Join(snapshotsDir2, "00000000.snapshot.lz4"), newTime, newTime); err != nil {
t.Fatal(err)
}
// Restore without timestamp should pick the newest
c := file.NewReplicaClient(replicaDir)
r := replicate.NewReplicaWithClient(nil, c)
outputPath := tmpDir + "/restored.db"
err := r.RestoreV3(ctx, replicate.RestoreOptions{
OutputPath: outputPath,
})
if err != nil {
t.Fatalf("RestoreV3 failed: %v", err)
}
verifyRestoredDB(t, outputPath)
})
}
func TestReplica_Restore_BothFormats(t *testing.T) {
t.Run("V3OnlyWithTimestamp", func(t *testing.T) {
ctx := context.Background()
tmpDir := t.TempDir()
replicaDir := t.TempDir()
// Create a v0.3.x backup only
gen := "0123456789abcdef"
snapshotsDir := filepath.Join(replicaDir, "generations", gen, "snapshots")
if err := os.MkdirAll(snapshotsDir, 0755); err != nil {
t.Fatal(err)
}
dbData := createTestSQLiteDB(t)
writeV3Snapshot(t, snapshotsDir, 0, dbData)
// Set snapshot time to 1 hour ago
snapshotTime := time.Now().Add(-1 * time.Hour)
if err := os.Chtimes(filepath.Join(snapshotsDir, "00000000.snapshot.lz4"), snapshotTime, snapshotTime); err != nil {
t.Fatal(err)
}
// Create replica and restore with timestamp
c := file.NewReplicaClient(replicaDir)
r := replicate.NewReplicaWithClient(nil, c)
outputPath := tmpDir + "/restored.db"
err := r.Restore(ctx, replicate.RestoreOptions{
OutputPath: outputPath,
Timestamp: time.Now(), // Any time after snapshot
})
if err != nil {
t.Fatalf("Restore failed: %v", err)
}
// Verify restored database
verifyRestoredDB(t, outputPath)
})
t.Run("V3OnlyWithoutTimestamp", func(t *testing.T) {
ctx := context.Background()
tmpDir := t.TempDir()
replicaDir := t.TempDir()
// Create a v0.3.x backup only (no LTX files)
gen := "0123456789abcdef"
snapshotsDir := filepath.Join(replicaDir, "generations", gen, "snapshots")
if err := os.MkdirAll(snapshotsDir, 0755); err != nil {
t.Fatal(err)
}
dbData := createTestSQLiteDB(t)
writeV3Snapshot(t, snapshotsDir, 0, dbData)
// Create replica and restore WITHOUT timestamp - should still use v0.3.x
c := file.NewReplicaClient(replicaDir)
r := replicate.NewReplicaWithClient(nil, c)
outputPath := tmpDir + "/restored.db"
err := r.Restore(ctx, replicate.RestoreOptions{
OutputPath: outputPath,
// No timestamp specified
})
if err != nil {
t.Fatalf("Restore failed: %v", err)
}
// Verify restored database
verifyRestoredDB(t, outputPath)
})
t.Run("LTXOnlyWithTimestamp", func(t *testing.T) {
db, sqldb := testingutil.MustOpenDBs(t)
defer testingutil.MustCloseDBs(t, db, sqldb)
replicaDir := t.TempDir()
c := file.NewReplicaClient(replicaDir)
r := replicate.NewReplicaWithClient(db, c)
// Sync to create LTX files
if err := db.Sync(context.Background()); err != nil {
t.Fatal(err)
}
if err := r.Sync(context.Background()); err != nil {
t.Fatal(err)
}
// Create a snapshot
if _, err := db.Snapshot(context.Background()); err != nil {
t.Fatal(err)
}
if err := r.Sync(context.Background()); err != nil {
t.Fatal(err)
}
// Wait a bit to ensure distinct timestamps
time.Sleep(10 * time.Millisecond)
// Restore with timestamp
outputPath := t.TempDir() + "/restored.db"
err := r.Restore(context.Background(), replicate.RestoreOptions{
OutputPath: outputPath,
Timestamp: time.Now(),
})
if err != nil {
t.Fatalf("Restore failed: %v", err)
}
verifyRestoredDB(t, outputPath)
})
t.Run("BothFormats_V3Better", func(t *testing.T) {
ctx := context.Background()
tmpDir := t.TempDir()
replicaDir := t.TempDir()
// Create v0.3.x snapshot at time T-30min (closer to restore time)
gen := "0123456789abcdef"
snapshotsDir := filepath.Join(replicaDir, "generations", gen, "snapshots")
if err := os.MkdirAll(snapshotsDir, 0755); err != nil {
t.Fatal(err)
}
dbData := createTestSQLiteDB(t)
writeV3Snapshot(t, snapshotsDir, 0, dbData)
v3Time := time.Now().Add(-30 * time.Minute)
if err := os.Chtimes(filepath.Join(snapshotsDir, "00000000.snapshot.lz4"), v3Time, v3Time); err != nil {
t.Fatal(err)
}
// Create LTX snapshot at time T-2h (older)
ltxDir := filepath.Join(replicaDir, "ltx", "9") // Snapshot level
if err := os.MkdirAll(ltxDir, 0755); err != nil {
t.Fatal(err)
}
ltxData := createTestLTXSnapshot(t)
ltxPath := filepath.Join(ltxDir, "0000000000000001-0000000000000001.ltx")
if err := os.WriteFile(ltxPath, ltxData, 0644); err != nil {
t.Fatal(err)
}
ltxTime := time.Now().Add(-2 * time.Hour)
if err := os.Chtimes(ltxPath, ltxTime, ltxTime); err != nil {
t.Fatal(err)
}
// Restore with timestamp - should use V3 (more recent)
c := file.NewReplicaClient(replicaDir)
r := replicate.NewReplicaWithClient(nil, c)
outputPath := tmpDir + "/restored.db"
err := r.Restore(ctx, replicate.RestoreOptions{
OutputPath: outputPath,
Timestamp: time.Now(),
})
if err != nil {
t.Fatalf("Restore failed: %v", err)
}
verifyRestoredDB(t, outputPath)
})
t.Run("BothFormats_LTXBetter", func(t *testing.T) {
db, sqldb := testingutil.MustOpenDBs(t)
defer testingutil.MustCloseDBs(t, db, sqldb)
replicaDir := t.TempDir()
// Create v0.3.x snapshot at time T-2h (older)
gen := "0123456789abcdef"
snapshotsDir := filepath.Join(replicaDir, "generations", gen, "snapshots")
if err := os.MkdirAll(snapshotsDir, 0755); err != nil {
t.Fatal(err)
}
v3Data := createTestSQLiteDB(t)
writeV3Snapshot(t, snapshotsDir, 0, v3Data)
v3Time := time.Now().Add(-2 * time.Hour)
if err := os.Chtimes(filepath.Join(snapshotsDir, "00000000.snapshot.lz4"), v3Time, v3Time); err != nil {
t.Fatal(err)
}
// Create LTX backup (more recent)
c := file.NewReplicaClient(replicaDir)
r := replicate.NewReplicaWithClient(db, c)
if err := db.Sync(context.Background()); err != nil {
t.Fatal(err)
}
if err := r.Sync(context.Background()); err != nil {
t.Fatal(err)
}
// Create a snapshot
if _, err := db.Snapshot(context.Background()); err != nil {
t.Fatal(err)
}
if err := r.Sync(context.Background()); err != nil {
t.Fatal(err)
}
// Wait a bit
time.Sleep(10 * time.Millisecond)
// Restore with timestamp - should use LTX (more recent)
outputPath := t.TempDir() + "/restored.db"
err := r.Restore(context.Background(), replicate.RestoreOptions{
OutputPath: outputPath,
Timestamp: time.Now(),
})
if err != nil {
t.Fatalf("Restore failed: %v", err)
}
verifyRestoredDB(t, outputPath)
})
t.Run("NoTimestamp_UsesLTX", func(t *testing.T) {
db, sqldb := testingutil.MustOpenDBs(t)
defer testingutil.MustCloseDBs(t, db, sqldb)
replicaDir := t.TempDir()
// Create v0.3.x snapshot
gen := "0123456789abcdef"
snapshotsDir := filepath.Join(replicaDir, "generations", gen, "snapshots")
if err := os.MkdirAll(snapshotsDir, 0755); err != nil {
t.Fatal(err)
}
v3Data := createTestSQLiteDB(t)
writeV3Snapshot(t, snapshotsDir, 0, v3Data)
// Create LTX backup
c := file.NewReplicaClient(replicaDir)
r := replicate.NewReplicaWithClient(db, c)
if err := db.Sync(context.Background()); err != nil {
t.Fatal(err)
}
if err := r.Sync(context.Background()); err != nil {
t.Fatal(err)
}
// Restore without timestamp - should use LTX (default behavior)
outputPath := t.TempDir() + "/restored.db"
err := r.Restore(context.Background(), replicate.RestoreOptions{
OutputPath: outputPath,
})
if err != nil {
t.Fatalf("Restore failed: %v", err)
}
verifyRestoredDB(t, outputPath)
})
}
// createTestLTXSnapshot creates a minimal LTX snapshot for testing.
func createTestLTXSnapshot(t *testing.T) []byte {
t.Helper()
// Create a temporary database and generate a real LTX snapshot
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
replicaDir := filepath.Join(tmpDir, "replica")
db := testingutil.NewDB(t, dbPath)
// Set up a replica client so we can create snapshots
c := file.NewReplicaClient(replicaDir)
db.Replica = replicate.NewReplicaWithClient(db, c)
db.Replica.MonitorEnabled = false
if err := db.Open(); err != nil {
t.Fatal(err)
}
// Create some data
sqldb := testingutil.MustOpenSQLDB(t, dbPath)
if _, err := sqldb.Exec(`CREATE TABLE test (id INTEGER PRIMARY KEY)`); err != nil {
t.Fatal(err)
}
testingutil.MustCloseSQLDB(t, sqldb)
// Sync to create LTX file
if err := db.Sync(context.Background()); err != nil {
t.Fatal(err)
}
if err := db.Replica.Sync(context.Background()); err != nil {
t.Fatal(err)
}
// Create snapshot
if _, err := db.Snapshot(context.Background()); err != nil {
t.Fatal(err)
}
if err := db.Close(context.Background()); err != nil {
t.Fatal(err)
}
// Read the snapshot file from replica directory
ltxPath := filepath.Join(replicaDir, "ltx", fmt.Sprintf("%d", replicate.SnapshotLevel), "0000000000000001-0000000000000001.ltx")
data, err := os.ReadFile(ltxPath)
if err != nil {
t.Fatal(err)
}
return data
}
// v3SnapshotData holds test data for creating v0.3.x snapshots.
type v3SnapshotData struct {
index int
data []byte
}
// v3WALSegmentData holds test data for creating v0.3.x WAL segments.
type v3WALSegmentData struct {
index int
offset int64
data []byte
}
// createV3Backup creates a v0.3.x backup structure for testing.
func createV3Backup(t *testing.T, replicaDir, generation string, snapshots []v3SnapshotData, walSegments []v3WALSegmentData) {
t.Helper()
// Create snapshots directory and files
if len(snapshots) > 0 {
snapshotsDir := filepath.Join(replicaDir, "generations", generation, "snapshots")
if err := os.MkdirAll(snapshotsDir, 0755); err != nil {
t.Fatal(err)
}
for _, s := range snapshots {
writeV3Snapshot(t, snapshotsDir, s.index, s.data)
}
}
// Create WAL directory and files
if len(walSegments) > 0 {
walDir := filepath.Join(replicaDir, "generations", generation, "wal")
if err := os.MkdirAll(walDir, 0755); err != nil {
t.Fatal(err)
}
for _, w := range walSegments {
writeV3WALSegment(t, walDir, w.index, w.offset, w.data)
}
}
}
// writeV3Snapshot writes an LZ4-compressed snapshot file.
func writeV3Snapshot(t *testing.T, dir string, index int, data []byte) {
t.Helper()
var buf bytes.Buffer
w := lz4.NewWriter(&buf)
if _, err := w.Write(data); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
filename := fmt.Sprintf("%08x.snapshot.lz4", index)
if err := os.WriteFile(filepath.Join(dir, filename), buf.Bytes(), 0644); err != nil {
t.Fatal(err)
}
}
// writeV3WALSegment writes an LZ4-compressed WAL segment file.
func writeV3WALSegment(t *testing.T, dir string, index int, offset int64, data []byte) {
t.Helper()
var buf bytes.Buffer
w := lz4.NewWriter(&buf)
if _, err := w.Write(data); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
filename := fmt.Sprintf("%08x-%016x.wal.lz4", index, offset)
if err := os.WriteFile(filepath.Join(dir, filename), buf.Bytes(), 0644); err != nil {
t.Fatal(err)
}
}
// createTestSQLiteDB creates a minimal valid SQLite database for testing.
func createTestSQLiteDB(t *testing.T) []byte {
t.Helper()
tmpPath := t.TempDir() + "/test.db"
sqldb := testingutil.MustOpenSQLDB(t, tmpPath)
if _, err := sqldb.Exec(`CREATE TABLE test (id INTEGER PRIMARY KEY, value TEXT)`); err != nil {
t.Fatal(err)
}
if _, err := sqldb.Exec(`INSERT INTO test (value) VALUES ('hello')`); err != nil {
t.Fatal(err)
}
testingutil.MustCloseSQLDB(t, sqldb)
data, err := os.ReadFile(tmpPath)
if err != nil {
t.Fatal(err)
}
return data
}
// createTestWALData creates minimal valid WAL data for testing.
// For simplicity, returns an empty WAL header (32 bytes) which is valid.
func createTestWALData(t *testing.T, dbData []byte) []byte {
t.Helper()
// Create a minimal WAL header
// WAL header is 32 bytes:
// - magic number (4 bytes): 0x377f0683 (big-endian) or 0x377f0682 (little-endian)
// - file format version (4 bytes): 3007000
// - page size (4 bytes)
// - checkpoint sequence (4 bytes)
// - salt-1 (4 bytes)
// - salt-2 (4 bytes)
// - checksum-1 (4 bytes)
// - checksum-2 (4 bytes)
// For testing, we'll create an empty WAL that doesn't need frames applied
// This is sufficient for testing the restore mechanism
return make([]byte, 32) // Empty WAL header placeholder
}
func TestWriteTXIDFile(t *testing.T) {
t.Run("WritesCorrectFormat", func(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
if err := os.WriteFile(dbPath, []byte("db"), 0644); err != nil {
t.Fatal(err)
}
if err := replicate.WriteTXIDFile(dbPath, 42); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(dbPath + "-txid")
if err != nil {
t.Fatal(err)
}
if got, want := strings.TrimSpace(string(data)), "000000000000002a"; got != want {
t.Fatalf("got %q, want %q", got, want)
}
})
t.Run("AtomicOverwrite", func(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
if err := replicate.WriteTXIDFile(dbPath, 10); err != nil {
t.Fatal(err)
}
if err := replicate.WriteTXIDFile(dbPath, 20); err != nil {
t.Fatal(err)
}
txid, err := replicate.ReadTXIDFile(dbPath)
if err != nil {
t.Fatal(err)
}
if txid != 20 {
t.Fatalf("got %d, want 20", txid)
}
})
}
func TestReadTXIDFile(t *testing.T) {
t.Run("MissingFile", func(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "nonexistent.db")
txid, err := replicate.ReadTXIDFile(dbPath)
if err != nil {
t.Fatal(err)
}
if txid != 0 {
t.Fatalf("got %d, want 0", txid)
}
})
t.Run("ValidFile", func(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
if err := os.WriteFile(dbPath+"-txid", []byte("00000000000000ff\n"), 0644); err != nil {
t.Fatal(err)
}
txid, err := replicate.ReadTXIDFile(dbPath)
if err != nil {
t.Fatal(err)
}
if txid != 255 {
t.Fatalf("got %d, want 255", txid)
}
})
t.Run("MalformedFile", func(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
if err := os.WriteFile(dbPath+"-txid", []byte("not-a-hex-value\n"), 0644); err != nil {
t.Fatal(err)
}
_, err := replicate.ReadTXIDFile(dbPath)
if err == nil {
t.Fatal("expected error for malformed file")
}
})
t.Run("EmptyFile", func(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
if err := os.WriteFile(dbPath+"-txid", []byte(""), 0644); err != nil {
t.Fatal(err)
}
_, err := replicate.ReadTXIDFile(dbPath)
if err == nil {
t.Fatal("expected error for empty file")
}
})
}
func TestReplica_Restore_Follow_IncompatibleFlags(t *testing.T) {
db, sqldb := testingutil.MustOpenDBs(t)
defer testingutil.MustCloseDBs(t, db, sqldb)
c := file.NewReplicaClient(t.TempDir())
r := replicate.NewReplicaWithClient(db, c)
t.Run("FollowWithTXID", func(t *testing.T) {
err := r.Restore(context.Background(), replicate.RestoreOptions{
OutputPath: t.TempDir() + "/db",
Follow: true,
TXID: 1,
})
if err == nil || err.Error() != "cannot use follow mode with -txid" {
t.Fatalf("expected 'cannot use follow mode with -txid' error, got: %v", err)
}
})
t.Run("FollowWithTimestamp", func(t *testing.T) {
err := r.Restore(context.Background(), replicate.RestoreOptions{
OutputPath: t.TempDir() + "/db",
Follow: true,
Timestamp: time.Now(),
})
if err == nil || err.Error() != "cannot use follow mode with -timestamp" {
t.Fatalf("expected 'cannot use follow mode with -timestamp' error, got: %v", err)
}
})
}
func TestReplica_Restore_Follow(t *testing.T) {
ctx := context.Background()
// Create source database with initial data.
db, sqldb := testingutil.MustOpenDBs(t)
defer testingutil.MustCloseDBs(t, db, sqldb)
if _, err := sqldb.ExecContext(ctx, `CREATE TABLE test(id INTEGER PRIMARY KEY, value TEXT)`); err != nil {
t.Fatal(err)
}
if _, err := sqldb.ExecContext(ctx, `INSERT INTO test VALUES (1, 'initial')`); err != nil {
t.Fatal(err)
}
// Sync and replicate to file replica.
if err := db.Sync(ctx); err != nil {
t.Fatal(err)
}
replicaDir := t.TempDir()
c := file.NewReplicaClient(replicaDir)
r := replicate.NewReplicaWithClient(db, c)
if err := r.Sync(ctx); err != nil {
t.Fatal(err)
}
// Create a snapshot so restore has something to work with.
if _, err := db.Snapshot(ctx); err != nil {
t.Fatal(err)
}
if err := r.Sync(ctx); err != nil {
t.Fatal(err)
}
// Start follow mode in a goroutine.
outputPath := t.TempDir() + "/follower.db"
followCtx, followCancel := context.WithCancel(ctx)
defer followCancel()
errCh := make(chan error, 1)
go func() {
errCh <- r.Restore(followCtx, replicate.RestoreOptions{
OutputPath: outputPath,
Follow: true,
FollowInterval: 50 * time.Millisecond,
})
}()
// Wait for initial restore to complete (file should appear).
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if _, err := os.Stat(outputPath); err == nil {
break
}
time.Sleep(10 * time.Millisecond)
}
if _, err := os.Stat(outputPath); err != nil {
t.Fatalf("restored file not found after waiting: %v", err)
}
// Insert more data into source and replicate.
if _, err := sqldb.ExecContext(ctx, `INSERT INTO test VALUES (2, 'follow-update')`); err != nil {
t.Fatal(err)
}
if err := db.Sync(ctx); err != nil {
t.Fatal(err)
}
if err := r.Sync(ctx); err != nil {
t.Fatal(err)
}
// Wait for follow mode to apply the new data.
deadline = time.Now().Add(5 * time.Second)
var found bool
for time.Now().Before(deadline) {
// Open the follower database read-only and check for new data.
followerDB := testingutil.MustOpenSQLDB(t, outputPath)
var count int
if err := followerDB.QueryRowContext(ctx, `SELECT COUNT(*) FROM test WHERE value = 'follow-update'`).Scan(&count); err == nil && count > 0 {
found = true
followerDB.Close()
break
}
followerDB.Close()
time.Sleep(100 * time.Millisecond)
}
if !found {
t.Fatal("follow mode did not apply new data within timeout")
}
// Cancel follow and verify clean shutdown.
followCancel()
select {
case err := <-errCh:
if err != nil {
t.Fatalf("follow returned error: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("follow did not shut down within timeout")
}
}
func TestReplica_Restore_Follow_ContextCancellation(t *testing.T) {
db, sqldb := testingutil.MustOpenDBs(t)
defer testingutil.MustCloseDBs(t, db, sqldb)
// Create initial data and replicate.
if _, err := sqldb.ExecContext(context.Background(), `CREATE TABLE test(id INTEGER PRIMARY KEY)`); err != nil {
t.Fatal(err)
}
if err := db.Sync(context.Background()); err != nil {
t.Fatal(err)
}
replicaDir := t.TempDir()
c := file.NewReplicaClient(replicaDir)
r := replicate.NewReplicaWithClient(db, c)
if err := r.Sync(context.Background()); err != nil {
t.Fatal(err)
}
if _, err := db.Snapshot(context.Background()); err != nil {
t.Fatal(err)
}
if err := r.Sync(context.Background()); err != nil {
t.Fatal(err)
}
outputPath := t.TempDir() + "/follower.db"
followCtx, followCancel := context.WithCancel(context.Background())
errCh := make(chan error, 1)
go func() {
errCh <- r.Restore(followCtx, replicate.RestoreOptions{
OutputPath: outputPath,
Follow: true,
FollowInterval: 50 * time.Millisecond,
})
}()
// Wait for restore to complete.
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if _, err := os.Stat(outputPath); err == nil {
break
}
time.Sleep(10 * time.Millisecond)
}
// Cancel immediately and verify clean return (nil error).
followCancel()
select {
case err := <-errCh:
if err != nil {
t.Fatalf("expected nil error on context cancellation, got: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("follow did not shut down within timeout")
}
}
func TestReplica_Restore_Follow_WriteTXIDFile(t *testing.T) {
ctx := context.Background()
db, sqldb := testingutil.MustOpenDBs(t)
defer testingutil.MustCloseDBs(t, db, sqldb)
if _, err := sqldb.ExecContext(ctx, `CREATE TABLE test(id INTEGER PRIMARY KEY, value TEXT)`); err != nil {
t.Fatal(err)
}
if _, err := sqldb.ExecContext(ctx, `INSERT INTO test VALUES (1, 'initial')`); err != nil {
t.Fatal(err)
}
if err := db.Sync(ctx); err != nil {
t.Fatal(err)
}
replicaDir := t.TempDir()
c := file.NewReplicaClient(replicaDir)
r := replicate.NewReplicaWithClient(db, c)
if err := r.Sync(ctx); err != nil {
t.Fatal(err)
}
if _, err := db.Snapshot(ctx); err != nil {
t.Fatal(err)
}
if err := r.Sync(ctx); err != nil {
t.Fatal(err)
}
outputPath := t.TempDir() + "/follower.db"
followCtx, followCancel := context.WithCancel(ctx)
defer followCancel()
errCh := make(chan error, 1)
go func() {
errCh <- r.Restore(followCtx, replicate.RestoreOptions{
OutputPath: outputPath,
Follow: true,
FollowInterval: 50 * time.Millisecond,
})
}()
// Wait for initial restore.
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if _, err := os.Stat(outputPath); err == nil {
break
}
time.Sleep(10 * time.Millisecond)
}
// Verify -txid file was created after initial restore.
txidPath := outputPath + "-txid"
deadline = time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if _, err := os.Stat(txidPath); err == nil {
break
}
time.Sleep(10 * time.Millisecond)
}
if _, err := os.Stat(txidPath); err != nil {
t.Fatalf("txid file not created after initial restore: %v", err)
}
initialTXID, err := replicate.ReadTXIDFile(outputPath)
if err != nil {
t.Fatalf("failed to read initial txid: %v", err)
}
if initialTXID == 0 {
t.Fatal("initial txid should be non-zero")
}
// Insert more data and sync.
if _, err := sqldb.ExecContext(ctx, `INSERT INTO test VALUES (2, 'update')`); err != nil {
t.Fatal(err)
}
if err := db.Sync(ctx); err != nil {
t.Fatal(err)
}
if err := r.Sync(ctx); err != nil {
t.Fatal(err)
}
// Wait for follow to apply and update the TXID file.
deadline = time.Now().Add(5 * time.Second)
var updatedTXID ltx.TXID
for time.Now().Before(deadline) {
txid, err := replicate.ReadTXIDFile(outputPath)
if err == nil && txid > initialTXID {
updatedTXID = txid
break
}
time.Sleep(100 * time.Millisecond)
}
if updatedTXID <= initialTXID {
t.Fatalf("txid file not updated after follow apply: initial=%d, current=%d", initialTXID, updatedTXID)
}
followCancel()
select {
case err := <-errCh:
if err != nil {
t.Fatalf("follow returned error: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("follow did not shut down within timeout")
}
}
func TestReplica_Restore_Follow_CrashRecovery(t *testing.T) {
ctx := context.Background()
db, sqldb := testingutil.MustOpenDBs(t)
defer testingutil.MustCloseDBs(t, db, sqldb)
if _, err := sqldb.ExecContext(ctx, `CREATE TABLE test(id INTEGER PRIMARY KEY, value TEXT)`); err != nil {
t.Fatal(err)
}
if _, err := sqldb.ExecContext(ctx, `INSERT INTO test VALUES (1, 'initial')`); err != nil {
t.Fatal(err)
}
if err := db.Sync(ctx); err != nil {
t.Fatal(err)
}
replicaDir := t.TempDir()
c := file.NewReplicaClient(replicaDir)
r := replicate.NewReplicaWithClient(db, c)
if err := r.Sync(ctx); err != nil {
t.Fatal(err)
}
if _, err := db.Snapshot(ctx); err != nil {
t.Fatal(err)
}
if err := r.Sync(ctx); err != nil {
t.Fatal(err)
}
outputPath := t.TempDir() + "/follower.db"
followCtx, followCancel := context.WithCancel(ctx)
errCh := make(chan error, 1)
go func() {
errCh <- r.Restore(followCtx, replicate.RestoreOptions{
OutputPath: outputPath,
Follow: true,
FollowInterval: 50 * time.Millisecond,
})
}()
// Wait for initial restore and -txid file.
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if _, err := os.Stat(outputPath + "-txid"); err == nil {
break
}
time.Sleep(10 * time.Millisecond)
}
savedTXID, err := replicate.ReadTXIDFile(outputPath)
if err != nil {
t.Fatalf("failed to read txid file: %v", err)
}
if savedTXID == 0 {
t.Fatal("saved txid should be non-zero")
}
// Simulate crash by cancelling follow mode.
followCancel()
select {
case err := <-errCh:
if err != nil {
t.Fatalf("follow returned error: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("follow did not shut down within timeout")
}
// Verify DB and -txid file still exist.
if _, err := os.Stat(outputPath); err != nil {
t.Fatalf("database file missing after crash: %v", err)
}
if _, err := os.Stat(outputPath + "-txid"); err != nil {
t.Fatalf("txid file missing after crash: %v", err)
}
// Add more data to source while follow was down.
if _, err := sqldb.ExecContext(ctx, `INSERT INTO test VALUES (2, 'post-crash')`); err != nil {
t.Fatal(err)
}
if err := db.Sync(ctx); err != nil {
t.Fatal(err)
}
if err := r.Sync(ctx); err != nil {
t.Fatal(err)
}
// Restart follow mode — should resume from saved TXID (crash recovery).
followCtx2, followCancel2 := context.WithCancel(ctx)
defer followCancel2()
errCh2 := make(chan error, 1)
go func() {
errCh2 <- r.Restore(followCtx2, replicate.RestoreOptions{
OutputPath: outputPath,
Follow: true,
FollowInterval: 50 * time.Millisecond,
})
}()
// Wait for follow mode to pick up new data.
deadline = time.Now().Add(5 * time.Second)
var found bool
for time.Now().Before(deadline) {
followerDB := testingutil.MustOpenSQLDB(t, outputPath)
var count int
if err := followerDB.QueryRowContext(ctx, `SELECT COUNT(*) FROM test WHERE value = 'post-crash'`).Scan(&count); err == nil && count > 0 {
found = true
followerDB.Close()
break
}
followerDB.Close()
time.Sleep(100 * time.Millisecond)
}
if !found {
t.Fatal("crash recovery did not apply new data within timeout")
}
followCancel2()
select {
case err := <-errCh2:
if err != nil {
t.Fatalf("follow returned error after recovery: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("follow did not shut down within timeout after recovery")
}
}
func TestReplica_Restore_Follow_NoTXIDFile(t *testing.T) {
db, sqldb := testingutil.MustOpenDBs(t)
defer testingutil.MustCloseDBs(t, db, sqldb)
c := file.NewReplicaClient(t.TempDir())
r := replicate.NewReplicaWithClient(db, c)
// Create a database file but no -txid sidecar.
outputPath := t.TempDir() + "/existing.db"
if err := os.WriteFile(outputPath, []byte("fake-db"), 0644); err != nil {
t.Fatal(err)
}
err := r.Restore(context.Background(), replicate.RestoreOptions{
OutputPath: outputPath,
Follow: true,
FollowInterval: 50 * time.Millisecond,
})
if err == nil {
t.Fatal("expected error when DB exists but no -txid file")
}
if !strings.Contains(err.Error(), "no -txid file found") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestReplica_Restore_Follow_StaleTXID(t *testing.T) {
ctx := context.Background()
db, sqldb := testingutil.MustOpenDBs(t)
defer testingutil.MustCloseDBs(t, db, sqldb)
if _, err := sqldb.ExecContext(ctx, `CREATE TABLE test(id INTEGER PRIMARY KEY, value TEXT)`); err != nil {
t.Fatal(err)
}
if err := db.Sync(ctx); err != nil {
t.Fatal(err)
}
replicaDir := t.TempDir()
c := file.NewReplicaClient(replicaDir)
r := replicate.NewReplicaWithClient(db, c)
if err := r.Sync(ctx); err != nil {
t.Fatal(err)
}
if _, err := db.Snapshot(ctx); err != nil {
t.Fatal(err)
}
if err := r.Sync(ctx); err != nil {
t.Fatal(err)
}
outputPath := filepath.Join(t.TempDir(), "follower.db")
// Create a fake database and a TXID file with a low TXID (1).
if err := os.WriteFile(outputPath, []byte("fake-db-content"), 0644); err != nil {
t.Fatal(err)
}
if err := replicate.WriteTXIDFile(outputPath, 1); err != nil {
t.Fatal(err)
}
// Simulate retention pruning: remove all level 0 files and replace the
// snapshot with one whose MinTXID is far ahead of our saved TXID.
level0Dir := c.LTXLevelDir(0)
if entries, err := os.ReadDir(level0Dir); err == nil {
for _, e := range entries {
os.Remove(filepath.Join(level0Dir, e.Name()))
}
}
snapshotDir := c.LTXLevelDir(9)
if entries, err := os.ReadDir(snapshotDir); err == nil {
for _, e := range entries {
os.Remove(filepath.Join(snapshotDir, e.Name()))
}
}
// Create a dummy snapshot file with MinTXID=10000 (far ahead of saved TXID=1).
if err := os.MkdirAll(snapshotDir, 0755); err != nil {
t.Fatal(err)
}
dummySnapshotPath := filepath.Join(snapshotDir, "0000000000002710-0000000000002710.ltx")
if err := os.WriteFile(dummySnapshotPath, []byte("dummy"), 0644); err != nil {
t.Fatal(err)
}
err := r.Restore(ctx, replicate.RestoreOptions{
OutputPath: outputPath,
Follow: true,
FollowInterval: 50 * time.Millisecond,
})
if err == nil {
t.Fatal("expected error for stale TXID")
}
if !strings.Contains(err.Error(), "replica history has been pruned") {
t.Fatalf("unexpected error: %v", err)
}
}
// verifyRestoredDB verifies that the restored database is valid.
func verifyRestoredDB(t *testing.T, path string) {
t.Helper()
// Check file exists
info, err := os.Stat(path)
if err != nil {
t.Fatalf("restored file not found: %v", err)
}
if info.Size() == 0 {
t.Fatal("restored file is empty")
}
// Try to open with SQLite to verify it's valid
sqldb := testingutil.MustOpenSQLDB(t, path)
defer testingutil.MustCloseSQLDB(t, sqldb)
// Run integrity check
var result string
if err := sqldb.QueryRow("PRAGMA integrity_check").Scan(&result); err != nil {
t.Fatalf("integrity check failed: %v", err)
}
if result != "ok" {
t.Fatalf("integrity check returned: %s", result)
}
}