mirror of
https://github.com/hanzoai/replicate.git
synced 2026-10-30 18:23:20 +00:00
A version written into a manifest is written into every manifest that wants it, which is how eslint came to exist in a hundred and four forms across this estate and how a package came to resolve twice at run time. The catalog moves the number out of the manifest: a manifest names the package, the workspace names the version. Four are held below their latest because the newer release cannot be used, and the reason is recorded rather than the version alone: @noble/hashes 2 renamed every subpath the web3 stack imports, @splinetool/runtime 2 depends on a package that was never published, @dynamic-labs 5.4.1 the same, and react-resizable-panels 3 points its types condition at the bundle.
2425 lines
71 KiB
Go
2425 lines
71 KiB
Go
//go:build vfs
|
|
// +build vfs
|
|
|
|
package main_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"math/rand"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
// The engine, not the hanzoai/sqlite facade: isBusyError matches concrete
|
|
// SQLITE_BUSY/LOCKED codes, which the facade does not re-export.
|
|
// Package name is csqlite, so the alias keeps the call sites reading sqlite3.X.
|
|
sqlite3 "github.com/hanzoai/csqlite"
|
|
"github.com/psanford/sqlite3vfs"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/hanzoai/ltx"
|
|
|
|
"github.com/hanzoai/replicate"
|
|
"github.com/hanzoai/replicate/file"
|
|
"github.com/hanzoai/replicate/internal/testingutil"
|
|
)
|
|
|
|
func TestVFS_Simple(t *testing.T) {
|
|
client := file.NewReplicaClient(t.TempDir())
|
|
vfs := newVFS(t, client)
|
|
if err := sqlite3vfs.RegisterVFS("replicate", vfs); err != nil {
|
|
t.Fatalf("failed to register replicate vfs: %v", err)
|
|
}
|
|
|
|
db := testingutil.NewDB(t, filepath.Join(t.TempDir(), "db"))
|
|
db.MonitorInterval = 100 * time.Millisecond
|
|
db.Replica = replicate.NewReplica(db)
|
|
db.Replica.Client = client
|
|
if err := db.Open(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sqldb0 := testingutil.MustOpenSQLDB(t, db.Path())
|
|
defer testingutil.MustCloseSQLDB(t, sqldb0)
|
|
|
|
if _, err := sqldb0.Exec("CREATE TABLE t (x)"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := sqldb0.Exec("INSERT INTO t (x) VALUES (100)"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
waitForLTXFiles(t, client, 10*time.Second, db.MonitorInterval)
|
|
|
|
sqldb1, err := sql.Open("sqlite", "file:/tmp/test.db?vfs=replicate")
|
|
if err != nil {
|
|
t.Fatalf("failed to open database: %v", err)
|
|
}
|
|
defer sqldb1.Close()
|
|
|
|
// Execute query - wait for value to be replicated
|
|
waitForReplicaValue(t, sqldb1, "SELECT * FROM t", 100, 10*time.Second, db.MonitorInterval)
|
|
}
|
|
|
|
func TestVFS_Updating(t *testing.T) {
|
|
client := file.NewReplicaClient(t.TempDir())
|
|
vfs := newVFS(t, client)
|
|
if err := sqlite3vfs.RegisterVFS("replicate", vfs); err != nil {
|
|
t.Fatalf("failed to register replicate vfs: %v", err)
|
|
}
|
|
|
|
db := testingutil.NewDB(t, filepath.Join(t.TempDir(), "db"))
|
|
db.MonitorInterval = 100 * time.Millisecond
|
|
db.Replica = replicate.NewReplica(db)
|
|
db.Replica.Client = client
|
|
db.Replica.SyncInterval = 100 * time.Millisecond
|
|
if err := db.Open(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sqldb0 := testingutil.MustOpenSQLDB(t, db.Path())
|
|
defer testingutil.MustCloseSQLDB(t, sqldb0)
|
|
|
|
t.Log("creating table")
|
|
if _, err := sqldb0.Exec("CREATE TABLE t (x)"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := sqldb0.Exec("INSERT INTO t (x) VALUES (100)"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
waitForLTXFiles(t, client, 10*time.Second, db.MonitorInterval)
|
|
|
|
t.Log("opening vfs")
|
|
sqldb1, err := sql.Open("sqlite", "file:/tmp/test.db?vfs=replicate")
|
|
if err != nil {
|
|
t.Fatalf("failed to open database: %v", err)
|
|
}
|
|
defer sqldb1.Close()
|
|
|
|
// Wait for initial value to replicate
|
|
waitForReplicaValue(t, sqldb1, "SELECT * FROM t", 100, 10*time.Second, db.MonitorInterval)
|
|
|
|
t.Log("updating source database")
|
|
// Update the value from the source database.
|
|
if _, err := sqldb0.Exec("UPDATE t SET x = 200"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Ensure replica has updated itself.
|
|
t.Log("ensuring replica has updated")
|
|
waitForReplicaValue(t, sqldb1, "SELECT * FROM t", 200, 10*time.Second, db.MonitorInterval)
|
|
|
|
if err := db.Replica.Stop(false); err != nil {
|
|
t.Fatalf("stop replica: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestVFS_ActiveReadTransaction(t *testing.T) {
|
|
client := file.NewReplicaClient(t.TempDir())
|
|
vfs := newVFS(t, client)
|
|
if err := sqlite3vfs.RegisterVFS("replicate-txn", vfs); err != nil {
|
|
t.Fatalf("failed to register replicate vfs: %v", err)
|
|
}
|
|
|
|
db := testingutil.NewDB(t, filepath.Join(t.TempDir(), "db"))
|
|
db.MonitorInterval = 100 * time.Millisecond
|
|
db.Replica = replicate.NewReplica(db)
|
|
db.Replica.Client = client
|
|
db.Replica.SyncInterval = 100 * time.Millisecond
|
|
if err := db.Open(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sqldb0 := testingutil.MustOpenSQLDB(t, db.Path())
|
|
defer testingutil.MustCloseSQLDB(t, sqldb0)
|
|
|
|
// Create a table with many rows to ensure we span multiple pages
|
|
// With 4KB page size, we want to ensure we're using hundreds of pages
|
|
t.Log("creating table with many rows")
|
|
if _, err := sqldb0.Exec("CREATE TABLE t (id INTEGER PRIMARY KEY, data TEXT)"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Insert ~10000 rows, each with substantial data to span many pages
|
|
// This should occupy at least 200+ pages (assuming ~200 bytes per row, ~20 rows per 4KB page)
|
|
if _, err := sqldb0.Exec("BEGIN"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for i := 0; i < 10000; i++ {
|
|
data := fmt.Sprintf("initial_data_%d_padding_%s", i, string(make([]byte, 100)))
|
|
if _, err := sqldb0.Exec("INSERT INTO t (id, data) VALUES (?, ?)", i, data); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if _, err := sqldb0.Exec("COMMIT"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Wait for replication to sync
|
|
waitForLTXFiles(t, client, 10*time.Second, db.MonitorInterval)
|
|
|
|
t.Log("opening vfs replica")
|
|
sqldb1, err := sql.Open("sqlite", "file:/tmp/test-txn.db?vfs=replicate-txn")
|
|
if err != nil {
|
|
t.Fatalf("failed to open database: %v", err)
|
|
}
|
|
defer sqldb1.Close()
|
|
|
|
// Start a read transaction on the replica
|
|
t.Log("starting read transaction on replica")
|
|
tx, err := sqldb1.Begin()
|
|
if err != nil {
|
|
t.Fatalf("failed to begin transaction: %v", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
// Verify we can read initial data from within the transaction
|
|
var initialData string
|
|
if err := tx.QueryRow("SELECT data FROM t WHERE id = 5000").Scan(&initialData); err != nil {
|
|
t.Fatalf("failed to query initial data: %v", err)
|
|
}
|
|
if !strings.HasPrefix(initialData, "initial_data_5000") {
|
|
t.Fatalf("unexpected initial data: %s", initialData)
|
|
}
|
|
|
|
t.Log("updating source database with many affected pages")
|
|
// Update many rows in the source database to affect many pages
|
|
if _, err := sqldb0.Exec("BEGIN"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for i := 0; i < 10000; i++ {
|
|
data := fmt.Sprintf("updated_data_%d_padding_%s", i, string(make([]byte, 100)))
|
|
if _, err := sqldb0.Exec("UPDATE t SET data = ? WHERE id = ?", data, i); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if _, err := sqldb0.Exec("COMMIT"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Wait for replication to sync the updates - verify new data is available
|
|
// by checking from a fresh connection (not the transaction's snapshot)
|
|
t.Log("waiting for replication sync")
|
|
require.Eventually(t, func() bool {
|
|
var data string
|
|
// Use a new query to check if updates have replicated
|
|
if err := sqldb1.QueryRow("SELECT data FROM t WHERE id = 5000").Scan(&data); err != nil {
|
|
return false
|
|
}
|
|
return strings.HasPrefix(data, "updated_data_5000")
|
|
}, 10*time.Second, db.MonitorInterval, "updates should replicate")
|
|
|
|
// The active read transaction should still see old data (snapshot isolation)
|
|
t.Log("verifying read transaction still sees old data")
|
|
var txData string
|
|
if err := tx.QueryRow("SELECT data FROM t WHERE id = 5000").Scan(&txData); err != nil {
|
|
t.Fatalf("failed to query within transaction: %v", err)
|
|
}
|
|
if !strings.HasPrefix(txData, "initial_data_5000") {
|
|
t.Fatalf("transaction should see old data, got: %s", txData)
|
|
}
|
|
|
|
// Commit the read transaction
|
|
t.Log("committing read transaction")
|
|
if err := tx.Commit(); err != nil {
|
|
t.Fatalf("failed to commit transaction: %v", err)
|
|
}
|
|
|
|
// Verify multiple rows across different pages
|
|
t.Log("verifying multiple rows across pages")
|
|
for _, id := range []int{0, 2500, 5000, 7500, 9999} {
|
|
var data string
|
|
if err := sqldb1.QueryRow("SELECT data FROM t WHERE id = ?", id).Scan(&data); err != nil {
|
|
t.Fatalf("failed to query id %d: %v", id, err)
|
|
}
|
|
expected := fmt.Sprintf("updated_data_%d", id)
|
|
if !strings.HasPrefix(data, expected) {
|
|
t.Fatalf("id %d: expected prefix %s, got: %s", id, expected, data)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestVFS_PollsL1Files(t *testing.T) {
|
|
ctx := context.Background()
|
|
client := file.NewReplicaClient(t.TempDir())
|
|
|
|
// Create and populate source database
|
|
db := testingutil.NewDB(t, filepath.Join(t.TempDir(), "db"))
|
|
db.MonitorInterval = 100 * time.Millisecond
|
|
db.Replica = replicate.NewReplica(db)
|
|
db.Replica.Client = client
|
|
db.Replica.SyncInterval = 100 * time.Millisecond
|
|
db.Replica.MonitorEnabled = false
|
|
|
|
// Create a store to handle compaction
|
|
levels := replicate.CompactionLevels{
|
|
{Level: 0},
|
|
{Level: 1, Interval: 1 * time.Second},
|
|
}
|
|
store := replicate.NewStore([]*replicate.DB{db}, levels)
|
|
store.CompactionMonitorEnabled = false
|
|
|
|
if err := store.Open(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer store.Close(ctx)
|
|
|
|
sqldb0 := testingutil.MustOpenSQLDB(t, db.Path())
|
|
defer testingutil.MustCloseSQLDB(t, sqldb0)
|
|
|
|
// Create table and insert data
|
|
t.Log("creating table with data")
|
|
if _, err := sqldb0.Exec("CREATE TABLE t (id INTEGER PRIMARY KEY, data TEXT)"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Insert multiple transactions to create several L0 files
|
|
for i := 0; i < 5; i++ {
|
|
if _, err := sqldb0.Exec("INSERT INTO t (data) VALUES (?)", fmt.Sprintf("value-%d", i)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Sync(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Replica.Sync(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
time.Sleep(50 * time.Millisecond) // Small delay between transactions
|
|
}
|
|
|
|
t.Log("compacting to L1")
|
|
// Compact L0 files to L1
|
|
if _, err := store.CompactDB(ctx, db, levels[1]); err != nil {
|
|
t.Fatalf("failed to compact to L1: %v", err)
|
|
}
|
|
|
|
// Verify L1 files exist
|
|
itr, err := client.LTXFiles(ctx, 1, 0, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var l1Count int
|
|
for itr.Next() {
|
|
l1Count++
|
|
}
|
|
itr.Close()
|
|
|
|
if l1Count == 0 {
|
|
t.Fatal("expected L1 files to exist after compaction")
|
|
}
|
|
t.Logf("found %d L1 file(s)", l1Count)
|
|
|
|
// Register VFS
|
|
vfs := newVFS(t, client)
|
|
if err := sqlite3vfs.RegisterVFS("replicate-l1", vfs); err != nil {
|
|
t.Fatalf("failed to register replicate vfs: %v", err)
|
|
}
|
|
|
|
// Open database through VFS
|
|
t.Log("opening vfs")
|
|
sqldb1, err := sql.Open("sqlite", "file:/tmp/test-l1.db?vfs=replicate-l1")
|
|
if err != nil {
|
|
t.Fatalf("failed to open database: %v", err)
|
|
}
|
|
defer sqldb1.Close()
|
|
|
|
// Query to ensure data is readable
|
|
var count int
|
|
if err := sqldb1.QueryRow("SELECT COUNT(*) FROM t").Scan(&count); err != nil {
|
|
t.Fatalf("failed to query database: %v", err)
|
|
} else if got, want := count, 5; got != want {
|
|
t.Fatalf("got %d rows, want %d", got, want)
|
|
}
|
|
|
|
// Get the VFS file to check maxTXID1
|
|
// The VFS creates the file when opened, we need to access it
|
|
// Since VFS.Open returns the file, we need to track it
|
|
// For now, let's add more data and wait for polling
|
|
|
|
t.Log("adding more data to source")
|
|
// Add more data to L0 to trigger polling
|
|
for i := 5; i < 10; i++ {
|
|
if _, err := sqldb0.Exec("INSERT INTO t (data) VALUES (?)", fmt.Sprintf("value-%d", i)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Sync(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Replica.Sync(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// Close and reopen the VFS connection to see updates
|
|
// (VFS is designed for read replicas where clients open new connections)
|
|
sqldb1.Close()
|
|
|
|
t.Log("reopening vfs to see updates")
|
|
sqldb1, err = sql.Open("sqlite", "file:/tmp/test-l1.db?vfs=replicate-l1")
|
|
if err != nil {
|
|
t.Fatalf("failed to reopen database: %v", err)
|
|
}
|
|
defer sqldb1.Close()
|
|
|
|
// Wait for VFS to poll new files
|
|
t.Log("waiting for VFS to poll")
|
|
require.Eventually(t, func() bool {
|
|
if err := sqldb1.QueryRow("SELECT COUNT(*) FROM t").Scan(&count); err != nil {
|
|
return false
|
|
}
|
|
return count == 10
|
|
}, 10*time.Second, vfs.PollInterval, "VFS should poll and see 10 rows")
|
|
|
|
// Compact the new L0 files to L1
|
|
// Use Eventually since compaction has a 1-second interval and first compaction just completed
|
|
t.Log("compacting new data to L1")
|
|
require.Eventually(t, func() bool {
|
|
_, err := store.CompactDB(ctx, db, levels[1])
|
|
return err == nil
|
|
}, 5*time.Second, 200*time.Millisecond, "second compaction should succeed after interval passes")
|
|
|
|
// At this point, the VFS should have polled L1 files
|
|
// We can't directly access the VFSFile from here without modifying VFS.Open
|
|
// But we can verify the data is readable, which proves L1 files are being used
|
|
|
|
// Query a specific value to ensure L1 data is accessible
|
|
var data string
|
|
if err := sqldb1.QueryRow("SELECT data FROM t WHERE id = 7").Scan(&data); err != nil {
|
|
t.Fatalf("failed to query specific row: %v", err)
|
|
} else if got, want := data, "value-6"; got != want {
|
|
t.Fatalf("got data %q, want %q", got, want)
|
|
}
|
|
|
|
t.Log("L1 file polling verified successfully")
|
|
}
|
|
|
|
func TestVFS_LongRunningTxnStress(t *testing.T) {
|
|
client := file.NewReplicaClient(t.TempDir())
|
|
vfs := newVFS(t, client)
|
|
vfs.PollInterval = 25 * time.Millisecond
|
|
vfsName := registerTestVFS(t, vfs)
|
|
|
|
_, primary := openReplicatedPrimary(t, client, 25*time.Millisecond, 25*time.Millisecond)
|
|
defer testingutil.MustCloseSQLDB(t, primary)
|
|
|
|
if _, err := primary.Exec("CREATE TABLE metrics (id INTEGER PRIMARY KEY, value INTEGER)"); err != nil {
|
|
t.Fatalf("create table: %v", err)
|
|
}
|
|
if _, err := primary.Exec("INSERT INTO metrics (id, value) VALUES (1, 0)"); err != nil {
|
|
t.Fatalf("insert row: %v", err)
|
|
}
|
|
|
|
replica := openVFSReplicaDB(t, vfsName)
|
|
defer replica.Close()
|
|
require.Eventually(t, func() bool {
|
|
var tmp int
|
|
return replica.QueryRow("SELECT value FROM metrics WHERE id = 1").Scan(&tmp) == nil
|
|
}, 30*time.Second, 50*time.Millisecond, "replica should observe metrics row")
|
|
|
|
tx, err := replica.Begin()
|
|
if err != nil {
|
|
t.Fatalf("begin replica txn: %v", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
var initialValue int
|
|
if err := tx.QueryRow("SELECT value FROM metrics WHERE id = 1").Scan(&initialValue); err != nil {
|
|
t.Fatalf("initial read: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
|
|
writerDone := make(chan error, 1)
|
|
go func() {
|
|
defer close(writerDone)
|
|
value := 0
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
default:
|
|
}
|
|
value++
|
|
if _, err := primary.Exec("UPDATE metrics SET value = ? WHERE id = 1", value); err != nil {
|
|
writerDone <- err
|
|
return
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
if err := <-writerDone; err != nil && !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("writer error: %v", err)
|
|
}
|
|
goto done
|
|
case <-time.After(50 * time.Millisecond):
|
|
var v int
|
|
if err := tx.QueryRow("SELECT value FROM metrics WHERE id = 1").Scan(&v); err != nil {
|
|
t.Fatalf("read during txn: %v", err)
|
|
}
|
|
if v != initialValue {
|
|
t.Fatalf("long-running txn observed change: got %d want %d", v, initialValue)
|
|
}
|
|
}
|
|
}
|
|
|
|
done:
|
|
if err := tx.Commit(); err != nil {
|
|
t.Fatalf("commit: %v", err)
|
|
}
|
|
|
|
var finalValue int
|
|
if err := replica.QueryRow("SELECT value FROM metrics WHERE id = 1").Scan(&finalValue); err != nil {
|
|
t.Fatalf("post-commit read: %v", err)
|
|
}
|
|
if finalValue == initialValue {
|
|
t.Fatalf("expected updated value after commit")
|
|
}
|
|
}
|
|
|
|
func TestVFS_HighLoadConcurrentReads(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping high-load test in short mode")
|
|
}
|
|
client := file.NewReplicaClient(t.TempDir())
|
|
vfs := newVFS(t, client)
|
|
vfs.PollInterval = 50 * time.Millisecond
|
|
vfsName := registerTestVFS(t, vfs)
|
|
|
|
_, primary := openReplicatedPrimary(t, client, 50*time.Millisecond, 50*time.Millisecond)
|
|
defer testingutil.MustCloseSQLDB(t, primary)
|
|
|
|
if _, err := primary.Exec(`CREATE TABLE t (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
value TEXT,
|
|
updated_at INTEGER
|
|
)`); err != nil {
|
|
t.Fatalf("create table: %v", err)
|
|
}
|
|
|
|
seedLargeTable(t, primary, 2000)
|
|
|
|
replica := openVFSReplicaDB(t, vfsName)
|
|
defer replica.Close()
|
|
if _, err := replica.Exec("PRAGMA temp_store = MEMORY"); err != nil {
|
|
t.Fatalf("set temp_store: %v", err)
|
|
}
|
|
|
|
waitForReplicaRowCount(t, primary, replica, 30*time.Second)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
var writerOps atomic.Int64
|
|
writerErr := make(chan error, 1)
|
|
go func() {
|
|
defer close(writerErr)
|
|
rnd := rand.New(rand.NewSource(time.Now().UnixNano()))
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
writerErr <- nil
|
|
return
|
|
default:
|
|
}
|
|
|
|
switch rnd.Intn(3) {
|
|
case 0:
|
|
if _, err := primary.Exec("INSERT INTO t (value, updated_at) VALUES (?, strftime('%s','now'))", fmt.Sprintf("value-%d", rnd.Int())); err != nil {
|
|
writerErr <- err
|
|
return
|
|
}
|
|
case 1:
|
|
if _, err := primary.Exec("UPDATE t SET value = value || '-u' WHERE id IN (SELECT id FROM t ORDER BY RANDOM() LIMIT 1)"); err != nil {
|
|
writerErr <- err
|
|
return
|
|
}
|
|
default:
|
|
if _, err := primary.Exec("DELETE FROM t WHERE id IN (SELECT id FROM t ORDER BY RANDOM() LIMIT 1)"); err != nil {
|
|
writerErr <- err
|
|
return
|
|
}
|
|
}
|
|
|
|
writerOps.Add(1)
|
|
time.Sleep(time.Duration(rnd.Intn(5)+1) * time.Millisecond)
|
|
}
|
|
}()
|
|
|
|
readerErrCh := make(chan error, 1)
|
|
var readerWg sync.WaitGroup
|
|
for i := 0; i < 8; i++ {
|
|
readerWg.Add(1)
|
|
go func(id int) {
|
|
defer readerWg.Done()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
default:
|
|
}
|
|
|
|
var count int
|
|
var totalBytes int
|
|
if err := replica.QueryRow("SELECT COUNT(*), IFNULL(SUM(LENGTH(value)), 0) FROM t").Scan(&count, &totalBytes); err != nil {
|
|
readerErrCh <- fmt.Errorf("reader %d query: %w", id, err)
|
|
return
|
|
}
|
|
if count < 0 || totalBytes < 0 {
|
|
readerErrCh <- fmt.Errorf("reader %d observed invalid stats", id)
|
|
return
|
|
}
|
|
}
|
|
}(i)
|
|
}
|
|
|
|
<-ctx.Done()
|
|
readerWg.Wait()
|
|
|
|
if err := <-writerErr; err != nil && !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("writer error: %v", err)
|
|
}
|
|
select {
|
|
case err := <-readerErrCh:
|
|
if err != nil {
|
|
t.Fatalf("reader error: %v", err)
|
|
}
|
|
default:
|
|
}
|
|
|
|
if ops := writerOps.Load(); ops < 100 {
|
|
t.Fatalf("expected high write volume, got %d ops", ops)
|
|
}
|
|
|
|
waitForReplicaRowCount(t, primary, replica, 30*time.Second)
|
|
|
|
var primaryCount, replicaCount int
|
|
if err := primary.QueryRow("SELECT COUNT(*) FROM t").Scan(&primaryCount); err != nil {
|
|
t.Fatalf("primary count: %v", err)
|
|
}
|
|
if err := replica.QueryRow("SELECT COUNT(*) FROM t").Scan(&replicaCount); err != nil {
|
|
t.Fatalf("replica count: %v", err)
|
|
}
|
|
if primaryCount != replicaCount {
|
|
t.Fatalf("replica lagging: primary=%d replica=%d", primaryCount, replicaCount)
|
|
}
|
|
}
|
|
|
|
// TestVFS_OverlappingTransactionCommitStorm tests that the VFS can handle
|
|
// concurrent read operations while writes are happening on the primary.
|
|
// The test verifies that the replica eventually catches up with the primary.
|
|
func TestVFS_OverlappingTransactionCommitStorm(t *testing.T) {
|
|
client := file.NewReplicaClient(t.TempDir())
|
|
const interval = 25 * time.Millisecond
|
|
db, primary := openReplicatedPrimary(t, client, interval, interval)
|
|
defer testingutil.MustCloseSQLDB(t, primary)
|
|
|
|
if _, err := primary.Exec("CREATE TABLE ledger (id INTEGER PRIMARY KEY AUTOINCREMENT, account INTEGER, amount INTEGER, created_at INTEGER)"); err != nil {
|
|
t.Fatalf("create table: %v", err)
|
|
}
|
|
if _, err := primary.Exec("INSERT INTO ledger (account, amount, created_at) VALUES (1, 0, strftime('%s','now'))"); err != nil {
|
|
t.Fatalf("seed ledger: %v", err)
|
|
}
|
|
|
|
// Wait for LTX files to be created before opening VFS replica
|
|
require.Eventually(t, func() bool {
|
|
itr, err := client.LTXFiles(context.Background(), 0, 0, false)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
defer itr.Close()
|
|
return itr.Next()
|
|
}, 5*time.Second, db.MonitorInterval, "LTX files should be created")
|
|
forceReplicaSync(t, db)
|
|
|
|
vfs := newVFS(t, client)
|
|
vfs.PollInterval = interval
|
|
vfsName := registerTestVFS(t, vfs)
|
|
replica := openVFSReplicaDB(t, vfsName)
|
|
defer replica.Close()
|
|
|
|
// Verify initial sync
|
|
require.Eventually(t, func() bool {
|
|
var primaryCount int
|
|
if err := primary.QueryRow("SELECT COUNT(*) FROM ledger").Scan(&primaryCount); err != nil {
|
|
return false
|
|
}
|
|
var replicaCount int
|
|
if err := replica.QueryRow("SELECT COUNT(*) FROM ledger").Scan(&replicaCount); err != nil {
|
|
return false
|
|
}
|
|
return primaryCount == replicaCount
|
|
}, time.Minute, 25*time.Millisecond, "ledger counts should match initially")
|
|
|
|
// Run concurrent writers for a short period (reduced from 10s to 3s)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
var writerWG sync.WaitGroup
|
|
writer := func(account int) {
|
|
defer writerWG.Done()
|
|
rnd := rand.New(rand.NewSource(time.Now().UnixNano() + int64(account)))
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
default:
|
|
}
|
|
amount := rnd.Intn(200) - 100
|
|
if _, err := primary.Exec("BEGIN IMMEDIATE"); err != nil {
|
|
continue
|
|
}
|
|
if _, err := primary.Exec("INSERT INTO ledger (account, amount, created_at) VALUES (?, ?, strftime('%s','now'))", account, amount); err != nil {
|
|
primary.Exec("ROLLBACK")
|
|
continue
|
|
}
|
|
if _, err := primary.Exec("COMMIT"); err != nil {
|
|
primary.Exec("ROLLBACK")
|
|
continue
|
|
}
|
|
// Slow down writes to allow background monitor to keep up
|
|
time.Sleep(time.Duration(rnd.Intn(20)+10) * time.Millisecond)
|
|
}
|
|
}
|
|
writerWG.Add(2)
|
|
go writer(1)
|
|
go writer(2)
|
|
|
|
// Run concurrent reader that verifies count never goes to zero
|
|
readerCtx, readerCancel := context.WithCancel(ctx)
|
|
readerErr := make(chan error, 1)
|
|
var readerWG sync.WaitGroup
|
|
readerWG.Add(1)
|
|
go func() {
|
|
defer readerWG.Done()
|
|
for {
|
|
select {
|
|
case <-readerCtx.Done():
|
|
return
|
|
default:
|
|
}
|
|
var count int
|
|
if err := replica.QueryRow("SELECT COUNT(*) FROM ledger").Scan(&count); err != nil {
|
|
readerErr <- err
|
|
return
|
|
}
|
|
if count == 0 {
|
|
readerErr <- fmt.Errorf("ledger count went to zero")
|
|
return
|
|
}
|
|
time.Sleep(25 * time.Millisecond)
|
|
}
|
|
}()
|
|
|
|
<-ctx.Done()
|
|
readerCancel()
|
|
writerWG.Wait()
|
|
readerWG.Wait()
|
|
|
|
// Check for reader errors
|
|
select {
|
|
case err := <-readerErr:
|
|
if err != nil {
|
|
t.Fatalf("reader error: %v", err)
|
|
}
|
|
default:
|
|
}
|
|
|
|
// Force final sync and wait for replica to catch up
|
|
forceReplicaSync(t, db)
|
|
|
|
require.Eventually(t, func() bool {
|
|
var primaryCount int
|
|
if err := primary.QueryRow("SELECT COUNT(*) FROM ledger").Scan(&primaryCount); err != nil {
|
|
return false
|
|
}
|
|
var replicaCount int
|
|
if err := replica.QueryRow("SELECT COUNT(*) FROM ledger").Scan(&replicaCount); err != nil {
|
|
return false
|
|
}
|
|
return primaryCount == replicaCount
|
|
}, 30*time.Second, 100*time.Millisecond, "ledger counts should match after writer done")
|
|
}
|
|
|
|
func TestVFS_CacheMissStorm(t *testing.T) {
|
|
client := file.NewReplicaClient(t.TempDir())
|
|
const interval = 20 * time.Millisecond
|
|
_, primary := openReplicatedPrimary(t, client, interval, interval)
|
|
defer testingutil.MustCloseSQLDB(t, primary)
|
|
|
|
if _, err := primary.Exec("CREATE TABLE stats (id INTEGER PRIMARY KEY, payload TEXT)"); err != nil {
|
|
t.Fatalf("create table: %v", err)
|
|
}
|
|
for i := 0; i < 1000; i++ {
|
|
if _, err := primary.Exec("INSERT INTO stats (payload) VALUES (?)", fmt.Sprintf("row-%d", i)); err != nil {
|
|
t.Fatalf("insert payload: %v", err)
|
|
}
|
|
}
|
|
|
|
vfs := newVFS(t, client)
|
|
vfs.PollInterval = interval
|
|
vfsName := registerTestVFS(t, vfs)
|
|
replica := openVFSReplicaDB(t, vfsName)
|
|
defer replica.Close()
|
|
|
|
waitForTableRowCount(t, primary, replica, "stats", 30*time.Second)
|
|
|
|
if _, err := replica.Exec("PRAGMA cache_size = -64"); err != nil {
|
|
t.Fatalf("set cache_size: %v", err)
|
|
}
|
|
if _, err := replica.Exec("PRAGMA cache_spill = ON"); err != nil {
|
|
t.Fatalf("enable cache_spill: %v", err)
|
|
}
|
|
|
|
for i := 0; i < 100; i++ {
|
|
var maxID int
|
|
if err := replica.QueryRow("SELECT MAX(id) FROM stats").Scan(&maxID); err != nil {
|
|
t.Fatalf("cache-miss query: %v", err)
|
|
}
|
|
if maxID == 0 {
|
|
t.Fatalf("unexpected empty stats table")
|
|
}
|
|
}
|
|
}
|
|
|
|
func BenchmarkVFS_LargeDatabase(b *testing.B) {
|
|
if testing.Short() {
|
|
b.Skip("skipping large benchmark in short mode")
|
|
}
|
|
client := file.NewReplicaClient(b.TempDir())
|
|
db, primary := openReplicatedPrimary(b, client, 25*time.Millisecond, 25*time.Millisecond)
|
|
b.Cleanup(func() { testingutil.MustCloseSQLDB(b, primary) })
|
|
|
|
if _, err := primary.Exec("CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, value TEXT, updated_at INTEGER)"); err != nil {
|
|
b.Fatalf("create table: %v", err)
|
|
}
|
|
seedLargeTable(b, primary, 20000)
|
|
forceReplicaSync(b, db)
|
|
if err := db.Replica.Stop(false); err != nil {
|
|
b.Fatalf("stop replica: %v", err)
|
|
}
|
|
|
|
vfs := newVFS(b, client)
|
|
vfs.PollInterval = 25 * time.Millisecond
|
|
vfsName := registerTestVFS(b, vfs)
|
|
replica := openVFSReplicaDB(b, vfsName)
|
|
b.Cleanup(func() { replica.Close() })
|
|
waitForReplicaRowCount(b, primary, replica, 30*time.Second)
|
|
|
|
b.ReportAllocs()
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
var count, totalBytes int
|
|
if err := replica.QueryRow("SELECT COUNT(*), IFNULL(SUM(LENGTH(value)), 0) FROM t").Scan(&count, &totalBytes); err != nil {
|
|
b.Fatalf("benchmark query: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestVFS_NetworkLatencySensitivity(t *testing.T) {
|
|
client := &latencyReplicaClient{ReplicaClient: file.NewReplicaClient(t.TempDir()), delay: 10 * time.Millisecond}
|
|
vfs := newVFS(t, client)
|
|
vfs.PollInterval = 25 * time.Millisecond
|
|
vfsName := registerTestVFS(t, vfs)
|
|
|
|
_, primary := openReplicatedPrimary(t, client, 25*time.Millisecond, 25*time.Millisecond)
|
|
defer testingutil.MustCloseSQLDB(t, primary)
|
|
|
|
if _, err := primary.Exec("CREATE TABLE logs (id INTEGER PRIMARY KEY, value TEXT)"); err != nil {
|
|
t.Fatalf("create table: %v", err)
|
|
}
|
|
if _, err := primary.Exec("INSERT INTO logs (value) VALUES ('ok')"); err != nil {
|
|
t.Fatalf("insert row: %v", err)
|
|
}
|
|
|
|
replica := openVFSReplicaDB(t, vfsName)
|
|
defer replica.Close()
|
|
|
|
require.Eventually(t, func() bool {
|
|
var count int
|
|
if err := replica.QueryRow("SELECT COUNT(*) FROM logs").Scan(&count); err != nil {
|
|
return false
|
|
}
|
|
return count == 1
|
|
}, 10*time.Second, 50*time.Millisecond, "replica should observe log row under injected latency")
|
|
}
|
|
|
|
func TestVFS_ConcurrentConnectionScaling(t *testing.T) {
|
|
client := file.NewReplicaClient(t.TempDir())
|
|
vfs := newVFS(t, client)
|
|
vfs.PollInterval = 25 * time.Millisecond
|
|
vfsName := registerTestVFS(t, vfs)
|
|
|
|
db, primary := openReplicatedPrimary(t, client, 25*time.Millisecond, 25*time.Millisecond)
|
|
defer testingutil.MustCloseSQLDB(t, primary)
|
|
|
|
if _, err := primary.Exec("CREATE TABLE metrics (id INTEGER PRIMARY KEY AUTOINCREMENT, value INTEGER)"); err != nil {
|
|
t.Fatalf("create table: %v", err)
|
|
}
|
|
for i := 0; i < 1000; i++ {
|
|
if _, err := primary.Exec("INSERT INTO metrics (value) VALUES (?)", i); err != nil {
|
|
t.Fatalf("insert row: %v", err)
|
|
}
|
|
}
|
|
|
|
// Wait for LTX files to be created before forceReplicaSync
|
|
require.Eventually(t, func() bool {
|
|
itr, err := client.LTXFiles(context.Background(), 0, 0, false)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
defer itr.Close()
|
|
return itr.Next()
|
|
}, 5*time.Second, db.MonitorInterval, "LTX files should be created")
|
|
forceReplicaSync(t, db)
|
|
|
|
const connCount = 32
|
|
conns := make([]*sql.DB, connCount)
|
|
for i := 0; i < connCount; i++ {
|
|
conns[i] = openVFSReplicaDB(t, vfsName)
|
|
}
|
|
defer func() {
|
|
for _, c := range conns {
|
|
c.Close()
|
|
}
|
|
}()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
var wg sync.WaitGroup
|
|
for idx := range conns {
|
|
wg.Add(1)
|
|
go func(id int, dbConn *sql.DB) {
|
|
defer wg.Done()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
default:
|
|
}
|
|
var min, max int
|
|
if err := dbConn.QueryRow("SELECT MIN(value), MAX(value) FROM metrics").Scan(&min, &max); err != nil {
|
|
t.Errorf("conn %d query: %v", id, err)
|
|
return
|
|
}
|
|
}
|
|
}(idx, conns[idx])
|
|
}
|
|
|
|
wg.Wait()
|
|
if err := ctx.Err(); err != context.Canceled && err != context.DeadlineExceeded {
|
|
t.Fatalf("unexpected context err: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestVFS_PRAGMAQueryBehavior(t *testing.T) {
|
|
client := file.NewReplicaClient(t.TempDir())
|
|
vfs := newVFS(t, client)
|
|
vfs.PollInterval = 25 * time.Millisecond
|
|
vfsName := registerTestVFS(t, vfs)
|
|
|
|
db, primary := openReplicatedPrimary(t, client, 25*time.Millisecond, 25*time.Millisecond)
|
|
defer testingutil.MustCloseSQLDB(t, primary)
|
|
|
|
if _, err := primary.Exec("CREATE TABLE configs (id INTEGER PRIMARY KEY, name TEXT)"); err != nil {
|
|
t.Fatalf("create table: %v", err)
|
|
}
|
|
if _, err := primary.Exec("INSERT INTO configs (name) VALUES ('ok')"); err != nil {
|
|
t.Fatalf("insert row: %v", err)
|
|
}
|
|
if _, err := primary.Exec("CREATE TABLE t (id INTEGER PRIMARY KEY, value TEXT)"); err != nil {
|
|
t.Fatalf("create table t: %v", err)
|
|
}
|
|
if _, err := primary.Exec("INSERT INTO t (value) VALUES ('seed')"); err != nil {
|
|
t.Fatalf("seed t: %v", err)
|
|
}
|
|
|
|
// Wait for LTX files to be created before forceReplicaSync
|
|
require.Eventually(t, func() bool {
|
|
itr, err := client.LTXFiles(context.Background(), 0, 0, false)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
defer itr.Close()
|
|
return itr.Next()
|
|
}, 5*time.Second, db.MonitorInterval, "LTX files should be created")
|
|
forceReplicaSync(t, db)
|
|
|
|
replica := openVFSReplicaDB(t, vfsName)
|
|
defer replica.Close()
|
|
|
|
waitForReplicaRowCount(t, primary, replica, 30*time.Second)
|
|
|
|
var journalMode string
|
|
if err := replica.QueryRow("PRAGMA journal_mode").Scan(&journalMode); err != nil {
|
|
t.Fatalf("read journal_mode: %v", err)
|
|
}
|
|
if strings.ToLower(journalMode) != "delete" {
|
|
t.Fatalf("expected journal_mode delete, got %s", journalMode)
|
|
}
|
|
|
|
if _, err := replica.Exec("PRAGMA cache_size = -2048"); err != nil {
|
|
t.Fatalf("set cache_size: %v", err)
|
|
}
|
|
var cacheSize int
|
|
if err := replica.QueryRow("PRAGMA cache_size").Scan(&cacheSize); err != nil {
|
|
t.Fatalf("read cache_size: %v", err)
|
|
}
|
|
if cacheSize != -2048 {
|
|
t.Fatalf("unexpected cache_size: %d", cacheSize)
|
|
}
|
|
|
|
var pageSize int
|
|
if err := replica.QueryRow("PRAGMA page_size").Scan(&pageSize); err != nil {
|
|
t.Fatalf("read page_size: %v", err)
|
|
}
|
|
if pageSize != 4096 {
|
|
t.Fatalf("unexpected page_size: %d", pageSize)
|
|
}
|
|
}
|
|
|
|
func TestVFS_SortingLargeResultSet(t *testing.T) {
|
|
client := file.NewReplicaClient(t.TempDir())
|
|
vfs := newVFS(t, client)
|
|
vfs.PollInterval = 50 * time.Millisecond
|
|
vfsName := registerTestVFS(t, vfs)
|
|
|
|
db, primary := openReplicatedPrimary(t, client, 50*time.Millisecond, 50*time.Millisecond)
|
|
defer testingutil.MustCloseSQLDB(t, primary)
|
|
|
|
if _, err := primary.Exec(`CREATE TABLE t (
|
|
id INTEGER PRIMARY KEY,
|
|
payload TEXT NOT NULL,
|
|
grp INTEGER NOT NULL
|
|
)`); err != nil {
|
|
t.Fatalf("create table: %v", err)
|
|
}
|
|
|
|
seedSortedDataset(t, primary, 25000)
|
|
if err := db.Replica.Stop(false); err != nil {
|
|
t.Fatalf("stop replica: %v", err)
|
|
}
|
|
|
|
replica := openVFSReplicaDB(t, vfsName)
|
|
defer replica.Close()
|
|
if _, err := replica.Exec("PRAGMA temp_store = FILE"); err != nil {
|
|
t.Fatalf("set temp_store: %v", err)
|
|
}
|
|
if _, err := replica.Exec("PRAGMA cache_size = -2048"); err != nil {
|
|
t.Fatalf("set cache_size: %v", err)
|
|
}
|
|
|
|
waitForReplicaRowCount(t, primary, replica, time.Minute)
|
|
|
|
expected := fetchOrderedPayloads(t, primary, 500, "payload DESC, id DESC")
|
|
got := fetchOrderedPayloads(t, replica, 500, "payload DESC, id DESC")
|
|
|
|
if len(expected) != len(got) {
|
|
t.Fatalf("unexpected result size: expected=%d got=%d", len(expected), len(got))
|
|
}
|
|
for i := range expected {
|
|
if expected[i] != got[i] {
|
|
t.Fatalf("mismatched payload at %d: expected=%q got=%q", i, expected[i], got[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestVFS_ConcurrentIndexAccessRaces(t *testing.T) {
|
|
client := file.NewReplicaClient(t.TempDir())
|
|
const monitorInterval = 10 * time.Millisecond
|
|
_, primary := openReplicatedPrimary(t, client, monitorInterval, 10*time.Millisecond)
|
|
defer testingutil.MustCloseSQLDB(t, primary)
|
|
|
|
if _, err := primary.Exec("CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, value TEXT, updated_at INTEGER)"); err != nil {
|
|
t.Fatalf("create table: %v", err)
|
|
}
|
|
seedLargeTable(t, primary, 10000)
|
|
|
|
vfs := newVFS(t, client)
|
|
vfs.PollInterval = 15 * time.Millisecond
|
|
vfsName := registerTestVFS(t, vfs)
|
|
dsn := fmt.Sprintf("file:%s?vfs=%s", filepath.ToSlash(filepath.Join(t.TempDir(), "fail.db")), vfsName)
|
|
replica, err := sql.Open("sqlite", dsn)
|
|
if err != nil {
|
|
t.Fatalf("open replica db: %v", err)
|
|
}
|
|
defer replica.Close()
|
|
replica.SetMaxOpenConns(4)
|
|
replica.SetMaxIdleConns(4)
|
|
replica.SetConnMaxIdleTime(30 * time.Second)
|
|
|
|
waitForReplicaRowCount(t, primary, replica, 30*time.Second)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
readerErrCh := make(chan error, 1)
|
|
var readerWG sync.WaitGroup
|
|
for i := 0; i < 100; i++ {
|
|
readerWG.Add(1)
|
|
go func(id int) {
|
|
defer readerWG.Done()
|
|
rnd := rand.New(rand.NewSource(int64(id) + time.Now().UnixNano()))
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
default:
|
|
}
|
|
|
|
var count int
|
|
var totalBytes int
|
|
if err := replica.QueryRow("SELECT COUNT(*), IFNULL(SUM(LENGTH(value)), 0) FROM t").Scan(&count, &totalBytes); err != nil {
|
|
select {
|
|
case readerErrCh <- fmt.Errorf("reader %d: %w", id, err):
|
|
default:
|
|
}
|
|
cancel()
|
|
return
|
|
}
|
|
if count < 0 || totalBytes < 0 {
|
|
select {
|
|
case readerErrCh <- fmt.Errorf("reader %d observed invalid stats", id):
|
|
default:
|
|
}
|
|
cancel()
|
|
return
|
|
}
|
|
_ = rnd.Int() // exercise RNG to vary workload
|
|
}
|
|
}(i)
|
|
}
|
|
|
|
var writerOps atomic.Int64
|
|
writerErrCh := make(chan error, 1)
|
|
go func() {
|
|
defer close(writerErrCh)
|
|
rnd := rand.New(rand.NewSource(time.Now().UnixNano()))
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
default:
|
|
}
|
|
|
|
switch rnd.Intn(3) {
|
|
case 0:
|
|
_, err := primary.Exec("INSERT INTO t (value, updated_at) VALUES (?, strftime('%s','now'))", fmt.Sprintf("writer-%d", rnd.Int()))
|
|
if err != nil {
|
|
if isBusyError(err) {
|
|
continue
|
|
}
|
|
writerErrCh <- err
|
|
cancel()
|
|
return
|
|
}
|
|
case 1:
|
|
_, err := primary.Exec("UPDATE t SET value = value || '-u', updated_at = strftime('%s','now') WHERE id IN (SELECT id FROM t ORDER BY RANDOM() LIMIT 1)")
|
|
if err != nil {
|
|
if isBusyError(err) {
|
|
continue
|
|
}
|
|
writerErrCh <- err
|
|
cancel()
|
|
return
|
|
}
|
|
default:
|
|
_, err := primary.Exec("DELETE FROM t WHERE id IN (SELECT id FROM t ORDER BY RANDOM() LIMIT 1)")
|
|
if err != nil {
|
|
if isBusyError(err) {
|
|
continue
|
|
}
|
|
writerErrCh <- err
|
|
cancel()
|
|
return
|
|
}
|
|
}
|
|
writerOps.Add(1)
|
|
time.Sleep(time.Duration(rnd.Intn(5)+1) * time.Millisecond)
|
|
}
|
|
}()
|
|
|
|
<-ctx.Done()
|
|
readerWG.Wait()
|
|
if err := <-writerErrCh; err != nil && !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("writer error: %v", err)
|
|
}
|
|
select {
|
|
case err := <-readerErrCh:
|
|
if err != nil {
|
|
t.Fatalf("reader error: %v", err)
|
|
}
|
|
default:
|
|
}
|
|
|
|
if ops := writerOps.Load(); ops == 0 {
|
|
t.Fatalf("writer did not perform any operations")
|
|
}
|
|
}
|
|
|
|
func TestVFS_MultiplePageSizes(t *testing.T) {
|
|
pageSizes := []int{512, 1024, 2048, 4096, 8192, 16384, 32768, 65536}
|
|
for _, pageSize := range pageSizes {
|
|
pageSize := pageSize
|
|
const monitorInterval = 50 * time.Millisecond
|
|
t.Run(fmt.Sprintf("page_%d", pageSize), func(t *testing.T) {
|
|
client := file.NewReplicaClient(t.TempDir())
|
|
_, primary := openReplicatedPrimary(t, client, monitorInterval, 50*time.Millisecond)
|
|
defer testingutil.MustCloseSQLDB(t, primary)
|
|
|
|
if _, err := primary.Exec("PRAGMA journal_mode=DELETE"); err != nil {
|
|
t.Fatalf("disable wal: %v", err)
|
|
}
|
|
if _, err := primary.Exec(fmt.Sprintf("PRAGMA page_size = %d", pageSize)); err != nil {
|
|
t.Fatalf("set page size: %v", err)
|
|
}
|
|
if _, err := primary.Exec("VACUUM"); err != nil {
|
|
t.Fatalf("vacuum: %v", err)
|
|
}
|
|
if _, err := primary.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
|
t.Fatalf("enable wal: %v", err)
|
|
}
|
|
|
|
if _, err := primary.Exec("CREATE TABLE t (id INTEGER PRIMARY KEY, payload TEXT)"); err != nil {
|
|
t.Fatalf("create table: %v", err)
|
|
}
|
|
|
|
const totalRows = 200
|
|
if _, err := primary.Exec("BEGIN"); err != nil {
|
|
t.Fatalf("begin tx: %v", err)
|
|
}
|
|
for i := 0; i < totalRows; i++ {
|
|
payload := pageSizedPayload(pageSize, i)
|
|
if _, err := primary.Exec("INSERT INTO t (payload) VALUES (?)", payload); err != nil {
|
|
primary.Exec("ROLLBACK")
|
|
t.Fatalf("insert row %d: %v", i, err)
|
|
}
|
|
}
|
|
if _, err := primary.Exec("COMMIT"); err != nil {
|
|
t.Fatalf("commit: %v", err)
|
|
}
|
|
|
|
vfs := newVFS(t, client)
|
|
vfs.PollInterval = 50 * time.Millisecond
|
|
vfsName := registerTestVFS(t, vfs)
|
|
replica := openVFSReplicaDB(t, vfsName)
|
|
defer replica.Close()
|
|
|
|
waitForReplicaRowCount(t, primary, replica, 30*time.Second)
|
|
|
|
var replicaPageSize int
|
|
if err := replica.QueryRow("PRAGMA page_size").Scan(&replicaPageSize); err != nil {
|
|
t.Fatalf("read replica page size: %v", err)
|
|
}
|
|
if replicaPageSize != pageSize {
|
|
t.Fatalf("unexpected page size: got %d want %d", replicaPageSize, pageSize)
|
|
}
|
|
|
|
rows, err := replica.Query("SELECT id, payload FROM t ORDER BY id")
|
|
if err != nil {
|
|
t.Fatalf("select rows: %v", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
count := 0
|
|
for rows.Next() {
|
|
var id int
|
|
var payload string
|
|
if err := rows.Scan(&id, &payload); err != nil {
|
|
t.Fatalf("scan row: %v", err)
|
|
}
|
|
expected := pageSizedPayload(pageSize, id-1)
|
|
if payload != expected {
|
|
t.Fatalf("row %d mismatch: got %q want %q", id, payload, expected)
|
|
}
|
|
count++
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
t.Fatalf("rows err: %v", err)
|
|
}
|
|
if count != totalRows {
|
|
t.Fatalf("unexpected row count: got %d want %d", count, totalRows)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestVFS_WaitsForInitialSnapshot(t *testing.T) {
|
|
t.Run("BlocksUntilSnapshot", func(t *testing.T) {
|
|
client := file.NewReplicaClient(t.TempDir())
|
|
vfs := newVFS(t, client)
|
|
vfs.PollInterval = 50 * time.Millisecond
|
|
vfsName := registerTestVFS(t, vfs)
|
|
dsn := fmt.Sprintf("file:%s?vfs=%s", filepath.ToSlash(filepath.Join(t.TempDir(), "wait.db")), vfsName)
|
|
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
sqldb, err := sql.Open("sqlite", dsn)
|
|
if err != nil {
|
|
errCh <- fmt.Errorf("open replica: %w", err)
|
|
return
|
|
}
|
|
defer sqldb.Close()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
var count int
|
|
if err := sqldb.QueryRowContext(ctx, "SELECT COUNT(*) FROM sqlite_master").Scan(&count); err != nil {
|
|
errCh <- err
|
|
return
|
|
}
|
|
errCh <- nil
|
|
}()
|
|
|
|
select {
|
|
case err := <-errCh:
|
|
t.Fatalf("replica should block until snapshot is available, got %v", err)
|
|
case <-time.After(200 * time.Millisecond):
|
|
}
|
|
|
|
_, primary := openReplicatedPrimary(t, client, 25*time.Millisecond, 25*time.Millisecond)
|
|
defer testingutil.MustCloseSQLDB(t, primary)
|
|
|
|
if _, err := primary.Exec("CREATE TABLE t (id INTEGER PRIMARY KEY)"); err != nil {
|
|
t.Fatalf("create table: %v", err)
|
|
}
|
|
if _, err := primary.Exec("INSERT INTO t (id) VALUES (1)"); err != nil {
|
|
t.Fatalf("insert row: %v", err)
|
|
}
|
|
|
|
select {
|
|
case err := <-errCh:
|
|
if err != nil {
|
|
t.Fatalf("replica query failed: %v", err)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("timed out waiting for replica to observe initial snapshot")
|
|
}
|
|
})
|
|
|
|
}
|
|
|
|
func TestVFS_StorageFailureInjection(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
mode string
|
|
}{
|
|
{"timeout", "timeout"},
|
|
{"server_error", "server"},
|
|
{"partial_read", "partial"},
|
|
{"corrupt_data", "corrupt"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
client := file.NewReplicaClient(t.TempDir())
|
|
db, primary := openReplicatedPrimary(t, client, 50*time.Millisecond, 50*time.Millisecond)
|
|
defer testingutil.MustCloseSQLDB(t, primary)
|
|
|
|
if _, err := primary.Exec("CREATE TABLE t (id INTEGER PRIMARY KEY, value TEXT)"); err != nil {
|
|
t.Fatalf("create table: %v", err)
|
|
}
|
|
if _, err := primary.Exec("INSERT INTO t (value) VALUES ('ok')"); err != nil {
|
|
t.Fatalf("insert row: %v", err)
|
|
}
|
|
// Wait for LTX files to be written by background monitor
|
|
require.Eventually(t, func() bool {
|
|
itr, err := client.LTXFiles(context.Background(), 0, 0, false)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
defer itr.Close()
|
|
return itr.Next()
|
|
}, 5*time.Second, db.MonitorInterval, "LTX files should be created by background monitor")
|
|
forceReplicaSync(t, db)
|
|
if err := db.Replica.Stop(false); err != nil {
|
|
t.Fatalf("stop replica: %v", err)
|
|
}
|
|
|
|
vfs := newVFS(t, client)
|
|
vfs.PollInterval = time.Hour
|
|
vfsName := registerTestVFS(t, vfs)
|
|
replicaPath := filepath.Join(t.TempDir(), fmt.Sprintf("storage-failure-%s.db", tt.name))
|
|
dsn := fmt.Sprintf("file:%s?vfs=%s", filepath.ToSlash(replicaPath), vfsName)
|
|
replica, err := sql.Open("sqlite", dsn)
|
|
if err != nil {
|
|
t.Fatalf("open replica db: %v", err)
|
|
}
|
|
defer replica.Close()
|
|
replica.SetMaxOpenConns(4)
|
|
replica.SetMaxIdleConns(4)
|
|
replica.SetConnMaxIdleTime(30 * time.Second)
|
|
if _, err := replica.Exec("PRAGMA busy_timeout = 2000"); err != nil {
|
|
t.Fatalf("set busy timeout: %v", err)
|
|
}
|
|
|
|
injectFailure := func() {
|
|
var err error
|
|
switch tt.mode {
|
|
case "timeout":
|
|
err = context.DeadlineExceeded
|
|
case "server":
|
|
err = fmt.Errorf("storage error: 500 Internal Server Error")
|
|
case "partial":
|
|
err = io.ErrUnexpectedEOF
|
|
case "corrupt":
|
|
err = fmt.Errorf("corrupt data")
|
|
default:
|
|
err = fmt.Errorf("injected failure")
|
|
}
|
|
vfs.Inject(replicaPath, err)
|
|
}
|
|
|
|
injectFailure()
|
|
var val string
|
|
if err := replica.QueryRow("SELECT value FROM t").Scan(&val); err == nil {
|
|
t.Fatalf("expected failure due to injected storage error")
|
|
}
|
|
|
|
if err := replica.QueryRow("SELECT value FROM t").Scan(&val); err != nil {
|
|
t.Fatalf("second read failed: %v", err)
|
|
}
|
|
if val != "ok" {
|
|
t.Fatalf("unexpected row value: %q", val)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestVFS_PartialLTXUpload(t *testing.T) {
|
|
client := file.NewReplicaClient(t.TempDir())
|
|
db, primary := openReplicatedPrimary(t, client, 25*time.Millisecond, 25*time.Millisecond)
|
|
defer testingutil.MustCloseSQLDB(t, primary)
|
|
|
|
if _, err := primary.Exec("CREATE TABLE logs (id INTEGER PRIMARY KEY, value TEXT)"); err != nil {
|
|
t.Fatalf("create table: %v", err)
|
|
}
|
|
if _, err := primary.Exec("INSERT INTO logs (value) VALUES ('ok')"); err != nil {
|
|
t.Fatalf("insert row: %v", err)
|
|
}
|
|
|
|
// Wait for LTX files to be created before forceReplicaSync
|
|
require.Eventually(t, func() bool {
|
|
itr, err := client.LTXFiles(context.Background(), 0, 0, false)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
defer itr.Close()
|
|
return itr.Next()
|
|
}, 5*time.Second, db.MonitorInterval, "LTX files should be created")
|
|
forceReplicaSync(t, db)
|
|
|
|
vfs := newVFS(t, client)
|
|
vfs.PollInterval = time.Hour
|
|
vfsName := registerTestVFS(t, vfs)
|
|
replicaPath := filepath.Join(t.TempDir(), "partial.db")
|
|
dsn := fmt.Sprintf("file:%s?vfs=%s", filepath.ToSlash(replicaPath), vfsName)
|
|
replica, err := sql.Open("sqlite", dsn)
|
|
if err != nil {
|
|
t.Fatalf("open replica db: %v", err)
|
|
}
|
|
defer replica.Close()
|
|
replica.SetMaxOpenConns(8)
|
|
replica.SetMaxIdleConns(8)
|
|
replica.SetConnMaxIdleTime(30 * time.Second)
|
|
if _, err := replica.Exec("PRAGMA busy_timeout = 2000"); err != nil {
|
|
t.Fatalf("set busy timeout: %v", err)
|
|
}
|
|
|
|
vfs.Inject(replicaPath, io.ErrUnexpectedEOF)
|
|
var val string
|
|
if err := replica.QueryRow("SELECT value FROM logs").Scan(&val); err == nil {
|
|
t.Fatalf("expected failure due to partial upload")
|
|
}
|
|
|
|
if err := replica.QueryRow("SELECT value FROM logs").Scan(&val); err != nil {
|
|
t.Fatalf("second attempt should succeed: %v", err)
|
|
}
|
|
if val != "ok" {
|
|
t.Fatalf("unexpected row value: %q", val)
|
|
}
|
|
}
|
|
|
|
func TestVFS_S3EventualConsistency(t *testing.T) {
|
|
client := file.NewReplicaClient(t.TempDir())
|
|
_, primary := openReplicatedPrimary(t, client, 25*time.Millisecond, 25*time.Millisecond)
|
|
defer testingutil.MustCloseSQLDB(t, primary)
|
|
|
|
if _, err := primary.Exec("CREATE TABLE t (id INTEGER PRIMARY KEY, value TEXT)"); err != nil {
|
|
t.Fatalf("create table: %v", err)
|
|
}
|
|
if _, err := primary.Exec("INSERT INTO t (value) VALUES ('visible')"); err != nil {
|
|
t.Fatalf("insert row: %v", err)
|
|
}
|
|
|
|
eventualClient := &eventualConsistencyClient{ReplicaClient: client}
|
|
vfs := newVFS(t, eventualClient)
|
|
vfs.PollInterval = 25 * time.Millisecond
|
|
vfsName := registerTestVFS(t, vfs)
|
|
replica := openVFSReplicaDB(t, vfsName)
|
|
defer replica.Close()
|
|
|
|
waitForReplicaRowCount(t, primary, replica, 5*time.Second)
|
|
|
|
if calls := eventualClient.calls.Load(); calls < 2 {
|
|
t.Fatalf("expected multiple polls under eventual consistency, got %d", calls)
|
|
}
|
|
}
|
|
|
|
func TestVFS_FileDescriptorBudget(t *testing.T) {
|
|
client := file.NewReplicaClient(t.TempDir())
|
|
_, primary := openReplicatedPrimary(t, client, 25*time.Millisecond, 25*time.Millisecond)
|
|
defer testingutil.MustCloseSQLDB(t, primary)
|
|
|
|
if _, err := primary.Exec("CREATE TABLE t (id INTEGER PRIMARY KEY, value TEXT)"); err != nil {
|
|
t.Fatalf("create table: %v", err)
|
|
}
|
|
if _, err := primary.Exec("INSERT INTO t (value) VALUES ('seed')"); err != nil {
|
|
t.Fatalf("insert seed: %v", err)
|
|
}
|
|
|
|
limited := &fdLimitedReplicaClient{ReplicaClient: client, limit: 64}
|
|
vfs := newVFS(t, limited)
|
|
vfs.PollInterval = 10 * time.Millisecond
|
|
vfsName := registerTestVFS(t, vfs)
|
|
replica := openVFSReplicaDB(t, vfsName)
|
|
defer replica.Close()
|
|
|
|
waitForReplicaRowCount(t, primary, replica, 5*time.Second)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond)
|
|
defer cancel()
|
|
|
|
writerDone := make(chan error, 1)
|
|
go func() {
|
|
defer close(writerDone)
|
|
rnd := rand.New(rand.NewSource(time.Now().UnixNano()))
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
default:
|
|
}
|
|
if _, err := primary.Exec("INSERT INTO t (value) VALUES (?)", fmt.Sprintf("v-%d", rnd.Int())); err != nil {
|
|
if isBusyError(err) {
|
|
time.Sleep(2 * time.Millisecond)
|
|
continue
|
|
}
|
|
writerDone <- err
|
|
return
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
}()
|
|
|
|
const readers = 8
|
|
errs := make(chan error, readers)
|
|
for i := 0; i < readers; i++ {
|
|
go func() {
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
errs <- nil
|
|
return
|
|
default:
|
|
}
|
|
var count int
|
|
if err := replica.QueryRow("SELECT COUNT(*) FROM t").Scan(&count); err != nil {
|
|
if isBusyError(err) {
|
|
time.Sleep(2 * time.Millisecond)
|
|
continue
|
|
}
|
|
errs <- err
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
<-ctx.Done()
|
|
for i := 0; i < readers; i++ {
|
|
if err := <-errs; err != nil {
|
|
t.Fatalf("reader %d error: %T %v", i, err, err)
|
|
}
|
|
}
|
|
if err := <-writerDone; err != nil && !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("writer error: %v", err)
|
|
}
|
|
|
|
deadline := time.After(250 * time.Millisecond)
|
|
for limited.open.Load() != 0 {
|
|
select {
|
|
case <-deadline:
|
|
t.Fatalf("descriptor leak: %d handles still open", limited.open.Load())
|
|
case <-time.After(10 * time.Millisecond):
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestVFS_PageIndexOOM(t *testing.T) {
|
|
client := file.NewReplicaClient(t.TempDir())
|
|
_, primary := openReplicatedPrimary(t, client, 25*time.Millisecond, 25*time.Millisecond)
|
|
defer testingutil.MustCloseSQLDB(t, primary)
|
|
|
|
if _, err := primary.Exec("CREATE TABLE t (id INTEGER PRIMARY KEY, value TEXT)"); err != nil {
|
|
t.Fatalf("create table: %v", err)
|
|
}
|
|
if _, err := primary.Exec("INSERT INTO t (value) VALUES ('ok')"); err != nil {
|
|
t.Fatalf("insert row: %v", err)
|
|
}
|
|
for i := 0; i < 64; i++ {
|
|
payload := strings.Repeat("p", 3500)
|
|
if _, err := primary.Exec("INSERT INTO t (value) VALUES (?)", payload); err != nil {
|
|
t.Fatalf("bulk insert: %v", err)
|
|
}
|
|
}
|
|
|
|
oomClient := &oomPageIndexClient{ReplicaClient: client}
|
|
vfs := newVFS(t, oomClient)
|
|
vfs.PollInterval = 20 * time.Millisecond
|
|
vfsName := registerTestVFS(t, vfs)
|
|
dsn := fmt.Sprintf("file:%s?vfs=%s", filepath.ToSlash(filepath.Join(t.TempDir(), "oom.db")), vfsName)
|
|
failing, err := sql.Open("sqlite", dsn)
|
|
if err != nil {
|
|
t.Fatalf("open replica db: %v", err)
|
|
}
|
|
defer failing.Close()
|
|
failing.SetMaxOpenConns(4)
|
|
failing.SetMaxIdleConns(4)
|
|
|
|
oomClient.failNext.Store(true)
|
|
var count int
|
|
if err := failing.QueryRow("SELECT COUNT(*) FROM t").Scan(&count); err == nil {
|
|
t.Fatalf("expected query to fail due to page index OOM")
|
|
}
|
|
if !oomClient.triggered.Load() {
|
|
t.Fatalf("page index client never triggered")
|
|
}
|
|
|
|
oomClient.failNext.Store(false)
|
|
replica := openVFSReplicaDB(t, vfsName)
|
|
defer replica.Close()
|
|
waitForReplicaRowCount(t, primary, replica, 5*time.Second)
|
|
|
|
if err := replica.QueryRow("SELECT COUNT(*) FROM t").Scan(&count); err != nil {
|
|
t.Fatalf("post-oom read failed: %v", err)
|
|
}
|
|
var expected int
|
|
if err := primary.QueryRow("SELECT COUNT(*) FROM t").Scan(&expected); err != nil {
|
|
t.Fatalf("primary count: %v", err)
|
|
}
|
|
if count != expected {
|
|
t.Fatalf("unexpected row count: got %d want %d", count, expected)
|
|
}
|
|
}
|
|
|
|
func TestVFS_PageIndexCorruptionRecovery(t *testing.T) {
|
|
client := file.NewReplicaClient(t.TempDir())
|
|
_, primary := openReplicatedPrimary(t, client, 25*time.Millisecond, 25*time.Millisecond)
|
|
defer testingutil.MustCloseSQLDB(t, primary)
|
|
|
|
if _, err := primary.Exec("CREATE TABLE t (id INTEGER PRIMARY KEY, value TEXT)"); err != nil {
|
|
t.Fatalf("create table: %v", err)
|
|
}
|
|
if _, err := primary.Exec("INSERT INTO t (value) VALUES ('ok')"); err != nil {
|
|
t.Fatalf("insert row: %v", err)
|
|
}
|
|
|
|
corruptClient := &corruptingPageIndexClient{ReplicaClient: client}
|
|
vfs := newVFS(t, corruptClient)
|
|
vfs.PollInterval = 20 * time.Millisecond
|
|
vfsName := registerTestVFS(t, vfs)
|
|
dsn := fmt.Sprintf("file:%s?vfs=%s", filepath.ToSlash(filepath.Join(t.TempDir(), "corrupt.db")), vfsName)
|
|
|
|
corruptClient.corruptNext.Store(true)
|
|
badConn, err := sql.Open("sqlite", dsn)
|
|
if err != nil {
|
|
t.Fatalf("open corrupt replica: %v", err)
|
|
}
|
|
badConn.SetMaxOpenConns(8)
|
|
badConn.SetMaxIdleConns(8)
|
|
badConn.SetConnMaxIdleTime(30 * time.Second)
|
|
var count int
|
|
if err := badConn.QueryRow("SELECT COUNT(*) FROM t").Scan(&count); err == nil {
|
|
badConn.Close()
|
|
t.Fatalf("expected corruption failure")
|
|
}
|
|
badConn.Close()
|
|
if !corruptClient.triggered.Load() {
|
|
t.Fatalf("corruption hook never triggered")
|
|
}
|
|
|
|
goodConn := openVFSReplicaDB(t, vfsName)
|
|
defer goodConn.Close()
|
|
if err := goodConn.QueryRow("SELECT COUNT(*) FROM t").Scan(&count); err != nil {
|
|
t.Fatalf("post-corruption read failed: %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Fatalf("unexpected row count after recovery: %d", count)
|
|
}
|
|
}
|
|
|
|
func TestVFS_RapidUpdateCoalescing(t *testing.T) {
|
|
client := file.NewReplicaClient(t.TempDir())
|
|
const interval = 5 * time.Millisecond
|
|
_, primary := openReplicatedPrimary(t, client, interval, interval)
|
|
defer testingutil.MustCloseSQLDB(t, primary)
|
|
|
|
if _, err := primary.Exec("CREATE TABLE metrics (id INTEGER PRIMARY KEY, value INTEGER)"); err != nil {
|
|
t.Fatalf("create table: %v", err)
|
|
}
|
|
if _, err := primary.Exec("INSERT INTO metrics (id, value) VALUES (1, 0)"); err != nil {
|
|
t.Fatalf("insert row: %v", err)
|
|
}
|
|
|
|
vfs := newVFS(t, client)
|
|
vfs.PollInterval = interval
|
|
vfsName := registerTestVFS(t, vfs)
|
|
replica := openVFSReplicaDB(t, vfsName)
|
|
defer replica.Close()
|
|
|
|
const updates = 200
|
|
writerDone := make(chan struct{})
|
|
go func() {
|
|
defer close(writerDone)
|
|
for i := 1; i <= updates; i++ {
|
|
if _, err := primary.Exec("UPDATE metrics SET value = ? WHERE id = 1", i); err != nil {
|
|
return
|
|
}
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
}()
|
|
|
|
require.Eventually(t, func() bool {
|
|
var value int
|
|
if err := replica.QueryRow("SELECT value FROM metrics WHERE id = 1").Scan(&value); err != nil {
|
|
return false
|
|
}
|
|
return value == updates
|
|
}, 3*time.Second, 5*time.Millisecond, "replica should observe final value")
|
|
<-writerDone
|
|
|
|
var value int
|
|
if err := replica.QueryRow("SELECT value FROM metrics WHERE id = 1").Scan(&value); err != nil {
|
|
t.Fatalf("final read: %v", err)
|
|
}
|
|
if value != updates {
|
|
t.Fatalf("unexpected final value: got %d want %d", value, updates)
|
|
}
|
|
}
|
|
|
|
func TestVFS_NonContiguousTXIDGapFailsOnOpen(t *testing.T) {
|
|
client := file.NewReplicaClient(t.TempDir())
|
|
for txID := ltx.TXID(1); txID <= 4; txID++ {
|
|
writeSinglePageLTXFile(t, client, txID, byte('a'+int(txID)))
|
|
}
|
|
|
|
missing := client.LTXFilePath(0, 2, 2)
|
|
if err := os.Remove(missing); err != nil {
|
|
t.Fatalf("remove ltx file: %v", err)
|
|
}
|
|
|
|
fileLogger := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError}))
|
|
f := replicate.NewVFSFile(client, "gap.db", fileLogger)
|
|
f.PollInterval = 25 * time.Millisecond
|
|
|
|
if err := f.Open(); err == nil {
|
|
t.Fatalf("expected open to fail after removing %s", filepath.Base(missing))
|
|
} else if errMsg := err.Error(); !strings.Contains(errMsg, "non-contiguous") {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestVFS_PollingThreadRecoversFromLTXListFailure(t *testing.T) {
|
|
client := file.NewReplicaClient(t.TempDir())
|
|
flakyClient := &flakyLTXClient{ReplicaClient: client}
|
|
const monitorInterval = 25 * time.Millisecond
|
|
_, primary := openReplicatedPrimary(t, client, monitorInterval, monitorInterval)
|
|
defer testingutil.MustCloseSQLDB(t, primary)
|
|
|
|
if _, err := primary.Exec("CREATE TABLE t (id INTEGER PRIMARY KEY, value TEXT)"); err != nil {
|
|
t.Fatalf("create table: %v", err)
|
|
}
|
|
if _, err := primary.Exec("INSERT INTO t (value) VALUES ('seed')"); err != nil {
|
|
t.Fatalf("insert seed: %v", err)
|
|
}
|
|
|
|
vfs := newVFS(t, flakyClient)
|
|
vfs.PollInterval = 25 * time.Millisecond
|
|
vfsName := registerTestVFS(t, vfs)
|
|
replica := openVFSReplicaDB(t, vfsName)
|
|
defer replica.Close()
|
|
|
|
waitForReplicaRowCount(t, primary, replica, 10*time.Second)
|
|
|
|
flakyClient.failNext.Store(true)
|
|
if _, err := primary.Exec("INSERT INTO t (value) VALUES ('after-failure')"); err != nil {
|
|
t.Fatalf("insert post-failure: %v", err)
|
|
}
|
|
|
|
waitForReplicaRowCount(t, primary, replica, 10*time.Second)
|
|
|
|
if flakyClient.failures.Load() == 0 {
|
|
t.Fatalf("expected at least one LTXFiles failure")
|
|
}
|
|
|
|
var primaryCount, replicaCount int
|
|
if err := primary.QueryRow("SELECT COUNT(*) FROM t").Scan(&primaryCount); err != nil {
|
|
t.Fatalf("primary count: %v", err)
|
|
}
|
|
if err := replica.QueryRow("SELECT COUNT(*) FROM t").Scan(&replicaCount); err != nil {
|
|
t.Fatalf("replica count: %v", err)
|
|
}
|
|
if primaryCount != replicaCount {
|
|
t.Fatalf("replica did not catch up after failure: primary=%d replica=%d", primaryCount, replicaCount)
|
|
}
|
|
}
|
|
|
|
func TestVFS_PollIntervalEdgeCases(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
interval time.Duration
|
|
minCalls int64
|
|
maxCalls int64
|
|
}{
|
|
{"fast", 5 * time.Millisecond, 10, 500},
|
|
{"slow", 200 * time.Millisecond, 1, 10},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
client := file.NewReplicaClient(t.TempDir())
|
|
obs := &observingReplicaClient{ReplicaClient: client}
|
|
_, primary := openReplicatedPrimary(t, obs, tt.interval, tt.interval)
|
|
defer testingutil.MustCloseSQLDB(t, primary)
|
|
|
|
if _, err := primary.Exec("CREATE TABLE t (id INTEGER PRIMARY KEY, value INTEGER)"); err != nil {
|
|
t.Fatalf("create table: %v", err)
|
|
}
|
|
|
|
vfs := newVFS(t, obs)
|
|
vfs.PollInterval = tt.interval
|
|
vfsName := registerTestVFS(t, vfs)
|
|
replica := openVFSReplicaDB(t, vfsName)
|
|
defer replica.Close()
|
|
|
|
start := obs.ltxCalls.Load()
|
|
time.Sleep(750 * time.Millisecond)
|
|
delta := obs.ltxCalls.Load() - start
|
|
if delta < tt.minCalls {
|
|
t.Fatalf("expected at least %d polls, got %d", tt.minCalls, delta)
|
|
}
|
|
if tt.maxCalls > 0 && delta > tt.maxCalls {
|
|
t.Fatalf("expected at most %d polls, got %d", tt.maxCalls, delta)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestVFS_PooledWriteNoFalseConflict(t *testing.T) {
|
|
client := file.NewReplicaClient(t.TempDir())
|
|
|
|
db, primary := openReplicatedPrimary(t, client, 25*time.Millisecond, 25*time.Millisecond)
|
|
defer testingutil.MustCloseSQLDB(t, primary)
|
|
|
|
if _, err := primary.Exec("CREATE TABLE seed (id INTEGER PRIMARY KEY)"); err != nil {
|
|
t.Fatalf("create seed table: %v", err)
|
|
}
|
|
if _, err := primary.Exec("INSERT INTO seed (id) VALUES (1)"); err != nil {
|
|
t.Fatalf("insert seed: %v", err)
|
|
}
|
|
|
|
require.Eventually(t, func() bool {
|
|
itr, err := client.LTXFiles(context.Background(), 0, 0, false)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
defer itr.Close()
|
|
return itr.Next()
|
|
}, 5*time.Second, db.MonitorInterval, "LTX files should be created")
|
|
forceReplicaSync(t, db)
|
|
|
|
vfs := newVFS(t, client)
|
|
vfs.WriteEnabled = true
|
|
vfs.WriteSyncInterval = 0
|
|
vfs.PollInterval = 25 * time.Millisecond
|
|
vfsName := registerTestVFS(t, vfs)
|
|
|
|
dsn := fmt.Sprintf("file:%s?vfs=%s", filepath.ToSlash(filepath.Join(t.TempDir(), "pooled-write.db")), vfsName)
|
|
sqldb, err := sql.Open("sqlite", dsn)
|
|
if err != nil {
|
|
t.Fatalf("open write db: %v", err)
|
|
}
|
|
defer sqldb.Close()
|
|
sqldb.SetMaxOpenConns(2)
|
|
sqldb.SetMaxIdleConns(2)
|
|
|
|
if _, err := sqldb.Exec("PRAGMA busy_timeout = 5000"); err != nil {
|
|
t.Fatalf("set busy timeout: %v", err)
|
|
}
|
|
if _, err := sqldb.Exec("CREATE TABLE t (id INTEGER PRIMARY KEY, value TEXT)"); err != nil {
|
|
t.Fatalf("create table: %v", err)
|
|
}
|
|
|
|
const totalWrites = 20
|
|
for i := 1; i <= totalWrites; i++ {
|
|
if _, err := sqldb.Exec("INSERT INTO t (id, value) VALUES (?, ?)", i, fmt.Sprintf("row-%d", i)); err != nil {
|
|
if strings.Contains(err.Error(), "conflict") || errors.Is(err, replicate.ErrConflict) {
|
|
t.Fatalf("false ErrConflict on write %d: %v", i, err)
|
|
}
|
|
t.Fatalf("write %d failed: %v", i, err)
|
|
}
|
|
}
|
|
|
|
var count int
|
|
if err := sqldb.QueryRow("SELECT COUNT(*) FROM t").Scan(&count); err != nil {
|
|
t.Fatalf("count rows: %v", err)
|
|
}
|
|
if count != totalWrites {
|
|
t.Fatalf("expected %d rows, got %d", totalWrites, count)
|
|
}
|
|
}
|
|
|
|
func TestVFS_PooledWriteStress(t *testing.T) {
|
|
client := file.NewReplicaClient(t.TempDir())
|
|
|
|
db, primary := openReplicatedPrimary(t, client, 25*time.Millisecond, 25*time.Millisecond)
|
|
defer testingutil.MustCloseSQLDB(t, primary)
|
|
|
|
if _, err := primary.Exec("CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, value TEXT)"); err != nil {
|
|
t.Fatalf("create table: %v", err)
|
|
}
|
|
if _, err := primary.Exec("INSERT INTO t (value) VALUES ('seed')"); err != nil {
|
|
t.Fatalf("insert seed: %v", err)
|
|
}
|
|
|
|
require.Eventually(t, func() bool {
|
|
itr, err := client.LTXFiles(context.Background(), 0, 0, false)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
defer itr.Close()
|
|
return itr.Next()
|
|
}, 5*time.Second, db.MonitorInterval, "LTX files should be created")
|
|
forceReplicaSync(t, db)
|
|
|
|
vfs := newVFS(t, client)
|
|
vfs.WriteEnabled = true
|
|
vfs.WriteSyncInterval = 10 * time.Millisecond
|
|
vfs.PollInterval = 25 * time.Millisecond
|
|
vfsName := registerTestVFS(t, vfs)
|
|
|
|
dsn := fmt.Sprintf("file:%s?vfs=%s&_busy_timeout=5000", filepath.ToSlash(filepath.Join(t.TempDir(), "stress-write.db")), vfsName)
|
|
sqldb, err := sql.Open("sqlite", dsn)
|
|
if err != nil {
|
|
t.Fatalf("open write db: %v", err)
|
|
}
|
|
defer sqldb.Close()
|
|
sqldb.SetMaxOpenConns(4)
|
|
sqldb.SetMaxIdleConns(4)
|
|
|
|
const totalWrites = 50
|
|
for i := 0; i < totalWrites; i++ {
|
|
if _, err := sqldb.Exec("INSERT INTO t (value) VALUES (?)", fmt.Sprintf("row-%d", i)); err != nil {
|
|
if strings.Contains(err.Error(), "conflict") || errors.Is(err, replicate.ErrConflict) {
|
|
t.Fatalf("false ErrConflict on write %d: %v", i, err)
|
|
}
|
|
t.Fatalf("write %d failed: %v", i, err)
|
|
}
|
|
// Brief pause every 5 writes to allow sync ticker to fire,
|
|
// which causes TXID advancement and exercises the coordination logic
|
|
if (i+1)%5 == 0 {
|
|
time.Sleep(25 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
var count int
|
|
if err := sqldb.QueryRow("SELECT COUNT(*) FROM t").Scan(&count); err != nil {
|
|
t.Fatalf("count rows: %v", err)
|
|
}
|
|
expected := totalWrites + 1 // +1 for seed row
|
|
if count != expected {
|
|
t.Fatalf("expected %d rows, got %d", expected, count)
|
|
}
|
|
}
|
|
|
|
func newVFS(tb testing.TB, client replicate.ReplicaClient) *testVFS {
|
|
tb.Helper()
|
|
|
|
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
|
Level: slog.LevelDebug,
|
|
}))
|
|
|
|
base := replicate.NewVFS(client, logger)
|
|
base.PollInterval = 100 * time.Millisecond
|
|
return &testVFS{
|
|
VFS: base,
|
|
failures: make(map[string][]error),
|
|
}
|
|
}
|
|
|
|
type testVFS struct {
|
|
*replicate.VFS
|
|
|
|
mu sync.Mutex
|
|
failures map[string][]error
|
|
}
|
|
|
|
func (v *testVFS) Open(name string, flags sqlite3vfs.OpenFlag) (sqlite3vfs.File, sqlite3vfs.OpenFlag, error) {
|
|
f, flags, err := v.VFS.Open(name, flags)
|
|
if err != nil {
|
|
return nil, flags, err
|
|
}
|
|
return &injectingFile{File: f, vfs: v, name: name}, flags, nil
|
|
}
|
|
|
|
func (v *testVFS) Inject(path string, err error) {
|
|
v.mu.Lock()
|
|
v.failures[path] = append(v.failures[path], err)
|
|
v.mu.Unlock()
|
|
}
|
|
|
|
func (v *testVFS) popFailure(path string) error {
|
|
v.mu.Lock()
|
|
defer v.mu.Unlock()
|
|
queue := v.failures[path]
|
|
if len(queue) == 0 {
|
|
return nil
|
|
}
|
|
err := queue[0]
|
|
if len(queue) == 1 {
|
|
delete(v.failures, path)
|
|
} else {
|
|
v.failures[path] = queue[1:]
|
|
}
|
|
if err == nil {
|
|
return errors.New("vfs page read error")
|
|
}
|
|
return err
|
|
}
|
|
|
|
type injectingFile struct {
|
|
sqlite3vfs.File
|
|
|
|
vfs *testVFS
|
|
name string
|
|
}
|
|
|
|
func (f *injectingFile) ReadAt(p []byte, off int64) (int, error) {
|
|
if err := f.vfs.popFailure(f.name); err != nil {
|
|
return 0, err
|
|
}
|
|
return f.File.ReadAt(p, off)
|
|
}
|
|
|
|
func (f *injectingFile) FileControl(op int, pragmaName string, pragmaValue *string) (*string, error) {
|
|
if fc, ok := f.File.(sqlite3vfs.FileController); ok {
|
|
return fc.FileControl(op, pragmaName, pragmaValue)
|
|
}
|
|
return nil, sqlite3vfs.NotFoundError
|
|
}
|
|
|
|
func registerTestVFS(tb testing.TB, vfs sqlite3vfs.VFS) string {
|
|
tb.Helper()
|
|
name := fmt.Sprintf("replicate-%s-%d", strings.ToLower(tb.Name()), time.Now().UnixNano())
|
|
if err := sqlite3vfs.RegisterVFS(name, vfs); err != nil {
|
|
tb.Fatalf("failed to register replicate vfs %s: %v", name, err)
|
|
}
|
|
return name
|
|
}
|
|
|
|
func openReplicatedPrimary(tb testing.TB, client replicate.ReplicaClient, monitorInterval, syncInterval time.Duration) (*replicate.DB, *sql.DB) {
|
|
tb.Helper()
|
|
db := testingutil.NewDB(tb, filepath.Join(tb.TempDir(), "primary.db"))
|
|
db.MonitorInterval = monitorInterval
|
|
db.Replica = replicate.NewReplica(db)
|
|
db.Replica.Client = client
|
|
db.Replica.SyncInterval = syncInterval
|
|
if err := db.Open(); err != nil {
|
|
tb.Fatalf("open db: %v", err)
|
|
}
|
|
sqldb := testingutil.MustOpenSQLDB(tb, db.Path())
|
|
tb.Cleanup(func() { _ = db.Close(context.Background()) })
|
|
return db, sqldb
|
|
}
|
|
|
|
func forceReplicaSync(tb testing.TB, db *replicate.DB) {
|
|
tb.Helper()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if err := db.Sync(ctx); err != nil {
|
|
tb.Fatalf("force sync: %v", err)
|
|
}
|
|
if db.Replica != nil {
|
|
if err := db.Replica.Sync(ctx); err != nil {
|
|
tb.Fatalf("replica sync: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func openVFSReplicaDB(tb testing.TB, vfsName string) *sql.DB {
|
|
tb.Helper()
|
|
dsn := fmt.Sprintf("file:%s?vfs=%s", filepath.ToSlash(filepath.Join(tb.TempDir(), vfsName+".db")), vfsName)
|
|
sqldb, err := sql.Open("sqlite", dsn)
|
|
if err != nil {
|
|
tb.Fatalf("open replica db: %v", err)
|
|
}
|
|
sqldb.SetMaxOpenConns(32)
|
|
sqldb.SetMaxIdleConns(32)
|
|
sqldb.SetConnMaxIdleTime(30 * time.Second)
|
|
if _, err := sqldb.Exec("PRAGMA busy_timeout = 2000"); err != nil {
|
|
tb.Fatalf("set busy timeout: %v", err)
|
|
}
|
|
return sqldb
|
|
}
|
|
|
|
func waitForReplicaRowCount(tb testing.TB, primary, replica *sql.DB, timeout time.Duration) {
|
|
tb.Helper()
|
|
require.Eventually(tb, func() bool {
|
|
var primaryCount int
|
|
if err := primary.QueryRow("SELECT COUNT(*) FROM t").Scan(&primaryCount); err != nil {
|
|
return false
|
|
}
|
|
var replicaCount int
|
|
if err := replica.QueryRow("SELECT COUNT(*) FROM t").Scan(&replicaCount); err != nil {
|
|
return false
|
|
}
|
|
return primaryCount == replicaCount
|
|
}, timeout, 50*time.Millisecond, "replica row count should match primary")
|
|
}
|
|
|
|
func waitForTableRowCount(tb testing.TB, primary, replica *sql.DB, table string, timeout time.Duration) {
|
|
tb.Helper()
|
|
query := fmt.Sprintf("SELECT COUNT(*) FROM %s", table)
|
|
require.Eventually(tb, func() bool {
|
|
var primaryCount int
|
|
if err := primary.QueryRow(query).Scan(&primaryCount); err != nil {
|
|
return false
|
|
}
|
|
var replicaCount int
|
|
if err := replica.QueryRow(query).Scan(&replicaCount); err != nil {
|
|
return false
|
|
}
|
|
return primaryCount == replicaCount
|
|
}, timeout, 50*time.Millisecond, "replica row count for %s should match primary", table)
|
|
}
|
|
|
|
func fetchOrderedPayloads(tb testing.TB, db *sql.DB, limit int, orderBy string) []string {
|
|
tb.Helper()
|
|
query := fmt.Sprintf("SELECT payload FROM t ORDER BY %s LIMIT %d", orderBy, limit)
|
|
rows, err := db.Query(query)
|
|
if err != nil {
|
|
tb.Fatalf("query payloads: %v", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []string
|
|
for rows.Next() {
|
|
var payload string
|
|
if err := rows.Scan(&payload); err != nil {
|
|
tb.Fatalf("scan payload: %v", err)
|
|
}
|
|
out = append(out, payload)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
tb.Fatalf("rows err: %v", err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func seedLargeTable(tb testing.TB, db *sql.DB, n int) {
|
|
tb.Helper()
|
|
trx, err := db.Begin()
|
|
if err != nil {
|
|
tb.Fatalf("begin seed: %v", err)
|
|
}
|
|
stmt, err := trx.Prepare("INSERT INTO t (value, updated_at) VALUES (?, strftime('%s','now'))")
|
|
if err != nil {
|
|
_ = trx.Rollback()
|
|
tb.Fatalf("prepare seed: %v", err)
|
|
}
|
|
defer stmt.Close()
|
|
rnd := rand.New(rand.NewSource(42))
|
|
for i := 0; i < n; i++ {
|
|
if _, err := stmt.Exec(fmt.Sprintf("seed-%d-%d", i, rnd.Int())); err != nil {
|
|
_ = trx.Rollback()
|
|
tb.Fatalf("seed exec: %v", err)
|
|
}
|
|
}
|
|
if err := trx.Commit(); err != nil {
|
|
tb.Fatalf("commit seed: %v", err)
|
|
}
|
|
}
|
|
|
|
func seedSortedDataset(tb testing.TB, db *sql.DB, n int) {
|
|
tb.Helper()
|
|
trx, err := db.Begin()
|
|
if err != nil {
|
|
tb.Fatalf("begin sorted seed: %v", err)
|
|
}
|
|
stmt, err := trx.Prepare("INSERT INTO t (id, payload, grp) VALUES (?, ?, ?)")
|
|
if err != nil {
|
|
_ = trx.Rollback()
|
|
tb.Fatalf("prepare sorted seed: %v", err)
|
|
}
|
|
defer stmt.Close()
|
|
rnd := rand.New(rand.NewSource(time.Now().UnixNano()))
|
|
for i := 0; i < n; i++ {
|
|
if _, err := stmt.Exec(i+1, randomPayload(rnd, 256), rnd.Intn(1024)); err != nil {
|
|
_ = trx.Rollback()
|
|
tb.Fatalf("sorted seed exec: %v", err)
|
|
}
|
|
}
|
|
if err := trx.Commit(); err != nil {
|
|
tb.Fatalf("commit sorted seed: %v", err)
|
|
}
|
|
}
|
|
|
|
func randomPayload(r *rand.Rand, n int) string {
|
|
const letters = "abcdefghijklmnopqrstuvwxyz0123456789"
|
|
b := make([]byte, n)
|
|
for i := range b {
|
|
b[i] = letters[r.Intn(len(letters))]
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
func pageSizedPayload(pageSize int, row int) string {
|
|
base := fmt.Sprintf("row_%05d_", row)
|
|
maxPayload := pageSize / 4
|
|
if maxPayload < len(base)+1 {
|
|
maxPayload = len(base) + 1
|
|
}
|
|
if maxPayload > 4096 {
|
|
maxPayload = 4096
|
|
}
|
|
fillerLen := maxPayload - len(base)
|
|
if fillerLen < 0 {
|
|
fillerLen = 0
|
|
}
|
|
return base + strings.Repeat("x", fillerLen)
|
|
}
|
|
|
|
func isBusyError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
if e, ok := err.(sqlite3.Error); ok {
|
|
if e.Code == sqlite3.ErrBusy || e.Code == sqlite3.ErrLocked {
|
|
return true
|
|
}
|
|
// Under heavy churn, go-sqlite3 can surface ErrError with the
|
|
// generic "SQL logic error" message while the VFS swaps databases.
|
|
if e.Code == sqlite3.ErrError && strings.Contains(e.Error(), "SQL logic error") {
|
|
return true
|
|
}
|
|
}
|
|
msg := err.Error()
|
|
if strings.Contains(msg, "database is locked") || strings.Contains(msg, "database is busy") {
|
|
return true
|
|
}
|
|
return strings.Contains(msg, "converting NULL to int")
|
|
}
|
|
|
|
func writeSinglePageLTXFile(tb testing.TB, client *file.ReplicaClient, txid ltx.TXID, fill byte) {
|
|
tb.Helper()
|
|
page := bytes.Repeat([]byte{fill}, 4096)
|
|
var buf bytes.Buffer
|
|
enc, err := ltx.NewEncoder(&buf)
|
|
if err != nil {
|
|
tb.Fatalf("new encoder: %v", err)
|
|
}
|
|
hdr := ltx.Header{
|
|
Version: ltx.Version,
|
|
PageSize: 4096,
|
|
Commit: 1,
|
|
MinTXID: txid,
|
|
MaxTXID: txid,
|
|
Timestamp: time.Now().UnixMilli(),
|
|
Flags: ltx.HeaderFlagNoChecksum,
|
|
}
|
|
if err := enc.EncodeHeader(hdr); err != nil {
|
|
tb.Fatalf("encode header: %v", err)
|
|
}
|
|
if err := enc.EncodePage(ltx.PageHeader{Pgno: 1}, page); err != nil {
|
|
tb.Fatalf("encode page: %v", err)
|
|
}
|
|
if err := enc.Close(); err != nil {
|
|
tb.Fatalf("close encoder: %v", err)
|
|
}
|
|
|
|
if _, err := client.WriteLTXFile(context.Background(), 0, txid, txid, bytes.NewReader(buf.Bytes())); err != nil {
|
|
tb.Fatalf("write ltx file: %v", err)
|
|
}
|
|
}
|
|
|
|
type latencyReplicaClient struct {
|
|
replicate.ReplicaClient
|
|
delay time.Duration
|
|
}
|
|
|
|
func (c *latencyReplicaClient) OpenLTXFile(ctx context.Context, level int, minTXID, maxTXID ltx.TXID, offset, size int64) (io.ReadCloser, error) {
|
|
time.Sleep(c.delay)
|
|
return c.ReplicaClient.OpenLTXFile(ctx, level, minTXID, maxTXID, offset, size)
|
|
}
|
|
|
|
func (c *latencyReplicaClient) LTXFiles(ctx context.Context, level int, seek ltx.TXID, useMetadata bool) (ltx.FileIterator, error) {
|
|
time.Sleep(c.delay)
|
|
return c.ReplicaClient.LTXFiles(ctx, level, seek, useMetadata)
|
|
}
|
|
|
|
type eventualConsistencyClient struct {
|
|
replicate.ReplicaClient
|
|
calls atomic.Int32
|
|
}
|
|
|
|
func (c *eventualConsistencyClient) LTXFiles(ctx context.Context, level int, seek ltx.TXID, useMetadata bool) (ltx.FileIterator, error) {
|
|
if c.calls.Add(1) == 1 {
|
|
return ltx.NewFileInfoSliceIterator(nil), nil
|
|
}
|
|
return c.ReplicaClient.LTXFiles(ctx, level, seek, useMetadata)
|
|
}
|
|
|
|
type observingReplicaClient struct {
|
|
replicate.ReplicaClient
|
|
ltxCalls atomic.Int64
|
|
}
|
|
|
|
type fdLimitedReplicaClient struct {
|
|
replicate.ReplicaClient
|
|
limit int32
|
|
open atomic.Int32
|
|
maxOpen atomic.Int32
|
|
}
|
|
|
|
func (c *fdLimitedReplicaClient) OpenLTXFile(ctx context.Context, level int, minTXID, maxTXID ltx.TXID, offset, size int64) (io.ReadCloser, error) {
|
|
current := c.open.Add(1)
|
|
for {
|
|
max := c.maxOpen.Load()
|
|
if current <= max || c.maxOpen.CompareAndSwap(max, current) {
|
|
break
|
|
}
|
|
}
|
|
if current > c.limit {
|
|
c.open.Add(-1)
|
|
return nil, fmt.Errorf("fd limit exceeded: %d/%d", current, c.limit)
|
|
}
|
|
rc, err := c.ReplicaClient.OpenLTXFile(ctx, level, minTXID, maxTXID, offset, size)
|
|
if err != nil {
|
|
c.open.Add(-1)
|
|
return nil, err
|
|
}
|
|
return &hookedReadCloser{ReadCloser: rc, hook: func() { c.open.Add(-1) }}, nil
|
|
}
|
|
|
|
func (c *observingReplicaClient) LTXFiles(ctx context.Context, level int, seek ltx.TXID, useMetadata bool) (ltx.FileIterator, error) {
|
|
c.ltxCalls.Add(1)
|
|
return c.ReplicaClient.LTXFiles(ctx, level, seek, useMetadata)
|
|
}
|
|
|
|
type flakyLTXClient struct {
|
|
replicate.ReplicaClient
|
|
failNext atomic.Bool
|
|
failures atomic.Int64
|
|
}
|
|
|
|
func (c *flakyLTXClient) LTXFiles(ctx context.Context, level int, seek ltx.TXID, useMetadata bool) (ltx.FileIterator, error) {
|
|
if c.failNext.CompareAndSwap(true, false) {
|
|
c.failures.Add(1)
|
|
return nil, fmt.Errorf("ltx list unavailable")
|
|
}
|
|
return c.ReplicaClient.LTXFiles(ctx, level, seek, useMetadata)
|
|
}
|
|
|
|
type oomPageIndexClient struct {
|
|
replicate.ReplicaClient
|
|
failNext atomic.Bool
|
|
triggered atomic.Bool
|
|
}
|
|
|
|
func (c *oomPageIndexClient) OpenLTXFile(ctx context.Context, level int, minTXID, maxTXID ltx.TXID, offset, size int64) (io.ReadCloser, error) {
|
|
if offset > 0 && c.failNext.CompareAndSwap(true, false) {
|
|
c.triggered.Store(true)
|
|
return nil, fmt.Errorf("simulated page index OOM")
|
|
}
|
|
return c.ReplicaClient.OpenLTXFile(ctx, level, minTXID, maxTXID, offset, size)
|
|
}
|
|
|
|
type corruptingPageIndexClient struct {
|
|
replicate.ReplicaClient
|
|
corruptNext atomic.Bool
|
|
triggered atomic.Bool
|
|
}
|
|
|
|
func (c *corruptingPageIndexClient) OpenLTXFile(ctx context.Context, level int, minTXID, maxTXID ltx.TXID, offset, size int64) (io.ReadCloser, error) {
|
|
rc, err := c.ReplicaClient.OpenLTXFile(ctx, level, minTXID, maxTXID, offset, size)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if c.corruptNext.CompareAndSwap(true, false) {
|
|
c.triggered.Store(true)
|
|
data, readErr := io.ReadAll(rc)
|
|
rc.Close()
|
|
if readErr != nil {
|
|
return nil, readErr
|
|
}
|
|
if len(data) > 0 {
|
|
data[0] ^= 0xFF
|
|
}
|
|
return io.NopCloser(bytes.NewReader(data)), nil
|
|
}
|
|
return rc, nil
|
|
}
|
|
|
|
type hookedReadCloser struct {
|
|
io.ReadCloser
|
|
once sync.Once
|
|
hook func()
|
|
}
|
|
|
|
func (h *hookedReadCloser) Close() error {
|
|
var err error
|
|
h.once.Do(func() {
|
|
err = h.ReadCloser.Close()
|
|
if h.hook != nil {
|
|
h.hook()
|
|
}
|
|
})
|
|
return err
|
|
}
|
|
|
|
// waitForLTXFiles waits until at least one LTX file is available in the replica client.
|
|
func waitForLTXFiles(t *testing.T, client replicate.ReplicaClient, timeout, tick time.Duration) {
|
|
t.Helper()
|
|
require.Eventually(t, func() bool {
|
|
itr, err := client.LTXFiles(context.Background(), 0, 0, false)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
defer itr.Close()
|
|
return itr.Next()
|
|
}, timeout, tick, "LTX files should be available")
|
|
}
|
|
|
|
// waitForReplicaValue waits until the replica database returns the expected int value.
|
|
func waitForReplicaValue(t *testing.T, db *sql.DB, query string, expected int, timeout, tick time.Duration) {
|
|
t.Helper()
|
|
require.Eventually(t, func() bool {
|
|
var got int
|
|
if err := db.QueryRow(query).Scan(&got); err != nil {
|
|
return false
|
|
}
|
|
return got == expected
|
|
}, timeout, tick, "replica should return expected value")
|
|
}
|