Files
replicate/file/replica_client_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

822 lines
22 KiB
Go

package file_test
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/hanzoai/ltx"
"github.com/hanzoai/lz4/v4"
"github.com/hanzoai/replicate"
"github.com/hanzoai/replicate/file"
)
func TestReplicaClient_Path(t *testing.T) {
c := file.NewReplicaClient("/foo/bar")
if got, want := c.Path(), "/foo/bar"; got != want {
t.Fatalf("Path()=%v, want %v", got, want)
}
}
func TestReplicaClient_Type(t *testing.T) {
if got, want := file.NewReplicaClient("").Type(), "file"; got != want {
t.Fatalf("Type()=%v, want %v", got, want)
}
}
// TestReplicaClient_WriteLTXFile_ErrorCleanup verifies temp files are cleaned up on errors
func TestReplicaClient_WriteLTXFile_ErrorCleanup(t *testing.T) {
t.Run("DiskFull", func(t *testing.T) {
tmpDir := t.TempDir()
c := file.NewReplicaClient(tmpDir)
// Create a reader that fails after 50 bytes to simulate disk full
failReader := &failAfterReader{
data: createLTXHeader(1, 2),
n: 50,
err: fmt.Errorf("no space left on device"),
}
_, err := c.WriteLTXFile(context.Background(), 0, 1, 2, failReader)
if err == nil {
t.Fatal("expected error from failReader")
}
if !strings.Contains(err.Error(), "no space left on device") {
t.Fatalf("expected disk full error, got: %v", err)
}
// Verify no .tmp files remain
tmpFiles := findTmpFiles(t, tmpDir)
if len(tmpFiles) > 0 {
t.Fatalf("found %d .tmp files after error: %v", len(tmpFiles), tmpFiles)
}
})
t.Run("SuccessNoLeaks", func(t *testing.T) {
tmpDir := t.TempDir()
c := file.NewReplicaClient(tmpDir)
ltxData := createLTXData(1, 2, []byte("test data"))
info, err := c.WriteLTXFile(context.Background(), 0, 1, 2, bytes.NewReader(ltxData))
if err != nil {
t.Fatal(err)
}
if info == nil {
t.Fatal("expected FileInfo")
}
// Verify no .tmp files remain
tmpFiles := findTmpFiles(t, tmpDir)
if len(tmpFiles) > 0 {
t.Fatalf("found %d .tmp files after successful write: %v", len(tmpFiles), tmpFiles)
}
// Verify final file exists
finalPath := c.LTXFilePath(0, 1, 2)
if _, err := os.Stat(finalPath); err != nil {
t.Fatalf("final file missing: %v", err)
}
})
t.Run("MultipleErrors", func(t *testing.T) {
tmpDir := t.TempDir()
c := file.NewReplicaClient(tmpDir)
// Simulate multiple failed writes
for i := 0; i < 5; i++ {
failReader := &failAfterReader{
data: createLTXHeader(ltx.TXID(i+1), ltx.TXID(i+1)),
n: 30,
err: fmt.Errorf("write error %d", i),
}
_, err := c.WriteLTXFile(context.Background(), 0, ltx.TXID(i+1), ltx.TXID(i+1), failReader)
if err == nil {
t.Fatalf("iteration %d: expected error from failReader", i)
}
}
// Verify no .tmp files accumulated
tmpFiles := findTmpFiles(t, tmpDir)
if len(tmpFiles) > 0 {
t.Fatalf("found %d .tmp files after multiple errors: %v", len(tmpFiles), tmpFiles)
}
})
}
// failAfterReader simulates io.Copy failure after reading n bytes
type failAfterReader struct {
data []byte
n int // fail after n bytes
pos int
err error
}
func (r *failAfterReader) Read(p []byte) (n int, err error) {
if r.pos >= r.n {
return 0, r.err
}
remaining := r.n - r.pos
toRead := len(p)
if toRead > remaining {
toRead = remaining
}
if toRead > len(r.data)-r.pos {
toRead = len(r.data) - r.pos
}
if toRead == 0 {
return 0, r.err
}
n = copy(p, r.data[r.pos:r.pos+toRead])
r.pos += n
return n, nil
}
// findTmpFiles recursively finds all .tmp files in the directory
func findTmpFiles(t *testing.T, root string) []string {
t.Helper()
var tmpFiles []string
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if !info.IsDir() && strings.HasSuffix(path, ".tmp") {
tmpFiles = append(tmpFiles, path)
}
return nil
})
if err != nil {
t.Fatalf("walk error: %v", err)
}
return tmpFiles
}
// createLTXData creates a minimal valid LTX file with a header for testing
func createLTXData(minTXID, maxTXID ltx.TXID, data []byte) []byte {
hdr := ltx.Header{
Version: ltx.Version,
PageSize: 4096,
Commit: 1,
MinTXID: minTXID,
MaxTXID: maxTXID,
Timestamp: time.Now().UnixMilli(),
}
if minTXID == 1 {
hdr.PreApplyChecksum = 0
} else {
hdr.PreApplyChecksum = ltx.ChecksumFlag
}
headerBytes, _ := hdr.MarshalBinary()
return append(headerBytes, data...)
}
// createLTXHeader creates minimal LTX header for testing
func createLTXHeader(minTXID, maxTXID ltx.TXID) []byte {
return createLTXData(minTXID, maxTXID, nil)
}
func TestReplicaClient_GenerationsV3(t *testing.T) {
t.Run("NoGenerationsDir", func(t *testing.T) {
tmpDir := t.TempDir()
c := file.NewReplicaClient(tmpDir)
generations, err := c.GenerationsV3(context.Background())
if err != nil {
t.Fatal(err)
}
if len(generations) != 0 {
t.Fatalf("expected no generations, got %v", generations)
}
})
t.Run("EmptyGenerationsDir", func(t *testing.T) {
tmpDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(tmpDir, "generations"), 0755); err != nil {
t.Fatal(err)
}
c := file.NewReplicaClient(tmpDir)
generations, err := c.GenerationsV3(context.Background())
if err != nil {
t.Fatal(err)
}
if len(generations) != 0 {
t.Fatalf("expected no generations, got %v", generations)
}
})
t.Run("MultipleGenerationsSorted", func(t *testing.T) {
tmpDir := t.TempDir()
genDir := filepath.Join(tmpDir, "generations")
// Create in non-sorted order
for _, gen := range []string{"ffffffffffffffff", "0000000000000001", "aaaaaaaaaaaaaaaa"} {
if err := os.MkdirAll(filepath.Join(genDir, gen), 0755); err != nil {
t.Fatal(err)
}
}
c := file.NewReplicaClient(tmpDir)
generations, err := c.GenerationsV3(context.Background())
if err != nil {
t.Fatal(err)
}
want := []string{"0000000000000001", "aaaaaaaaaaaaaaaa", "ffffffffffffffff"}
if len(generations) != len(want) {
t.Fatalf("got %d generations, want %d", len(generations), len(want))
}
for i, g := range generations {
if g != want[i] {
t.Errorf("generations[%d] = %q, want %q", i, g, want[i])
}
}
})
t.Run("SkipsInvalidIDs", func(t *testing.T) {
tmpDir := t.TempDir()
genDir := filepath.Join(tmpDir, "generations")
// Create mix of valid and invalid
for _, name := range []string{
"0000000000000001", // valid
"invalid", // invalid - not hex
"0123456789abcde", // invalid - 15 chars
"0123456789ABCDEF", // invalid - uppercase
"aaaaaaaaaaaaaaaa", // valid
} {
if err := os.MkdirAll(filepath.Join(genDir, name), 0755); err != nil {
t.Fatal(err)
}
}
c := file.NewReplicaClient(tmpDir)
generations, err := c.GenerationsV3(context.Background())
if err != nil {
t.Fatal(err)
}
want := []string{"0000000000000001", "aaaaaaaaaaaaaaaa"}
if len(generations) != len(want) {
t.Fatalf("got %d generations, want %d: %v", len(generations), len(want), generations)
}
for i, g := range generations {
if g != want[i] {
t.Errorf("generations[%d] = %q, want %q", i, g, want[i])
}
}
})
t.Run("SkipsFiles", func(t *testing.T) {
tmpDir := t.TempDir()
genDir := filepath.Join(tmpDir, "generations")
if err := os.MkdirAll(genDir, 0755); err != nil {
t.Fatal(err)
}
// Create a file with valid generation name (should be skipped)
if err := os.WriteFile(filepath.Join(genDir, "0000000000000001"), []byte("test"), 0644); err != nil {
t.Fatal(err)
}
// Create a valid directory
if err := os.MkdirAll(filepath.Join(genDir, "aaaaaaaaaaaaaaaa"), 0755); err != nil {
t.Fatal(err)
}
c := file.NewReplicaClient(tmpDir)
generations, err := c.GenerationsV3(context.Background())
if err != nil {
t.Fatal(err)
}
if len(generations) != 1 || generations[0] != "aaaaaaaaaaaaaaaa" {
t.Fatalf("expected only valid directory, got %v", generations)
}
})
}
func TestReplicaClient_SnapshotsV3(t *testing.T) {
gen := "0123456789abcdef"
t.Run("NoSnapshotsDir", func(t *testing.T) {
tmpDir := t.TempDir()
c := file.NewReplicaClient(tmpDir)
snapshots, err := c.SnapshotsV3(context.Background(), gen)
if err != nil {
t.Fatal(err)
}
if len(snapshots) != 0 {
t.Fatalf("expected no snapshots, got %v", snapshots)
}
})
t.Run("EmptySnapshotsDir", func(t *testing.T) {
tmpDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(tmpDir, "generations", gen, "snapshots"), 0755); err != nil {
t.Fatal(err)
}
c := file.NewReplicaClient(tmpDir)
snapshots, err := c.SnapshotsV3(context.Background(), gen)
if err != nil {
t.Fatal(err)
}
if len(snapshots) != 0 {
t.Fatalf("expected no snapshots, got %v", snapshots)
}
})
t.Run("SingleSnapshot", func(t *testing.T) {
tmpDir := t.TempDir()
snapshotsDir := filepath.Join(tmpDir, "generations", gen, "snapshots")
if err := os.MkdirAll(snapshotsDir, 0755); err != nil {
t.Fatal(err)
}
// Create snapshot file
path := filepath.Join(snapshotsDir, "00000001.snapshot.lz4")
if err := os.WriteFile(path, []byte("test data"), 0644); err != nil {
t.Fatal(err)
}
c := file.NewReplicaClient(tmpDir)
snapshots, err := c.SnapshotsV3(context.Background(), gen)
if err != nil {
t.Fatal(err)
}
if len(snapshots) != 1 {
t.Fatalf("expected 1 snapshot, got %d", len(snapshots))
}
if snapshots[0].Generation != gen {
t.Errorf("Generation = %q, want %q", snapshots[0].Generation, gen)
}
if snapshots[0].Index != 1 {
t.Errorf("Index = %d, want 1", snapshots[0].Index)
}
if snapshots[0].Size != 9 { // len("test data")
t.Errorf("Size = %d, want 9", snapshots[0].Size)
}
})
t.Run("MultipleSnapshotsSorted", func(t *testing.T) {
tmpDir := t.TempDir()
snapshotsDir := filepath.Join(tmpDir, "generations", gen, "snapshots")
if err := os.MkdirAll(snapshotsDir, 0755); err != nil {
t.Fatal(err)
}
// Create snapshots in non-sorted order
for _, idx := range []int{0xff, 0x01, 0x10} {
filename := fmt.Sprintf("%08x.snapshot.lz4", idx)
if err := os.WriteFile(filepath.Join(snapshotsDir, filename), []byte("x"), 0644); err != nil {
t.Fatal(err)
}
}
c := file.NewReplicaClient(tmpDir)
snapshots, err := c.SnapshotsV3(context.Background(), gen)
if err != nil {
t.Fatal(err)
}
if len(snapshots) != 3 {
t.Fatalf("expected 3 snapshots, got %d", len(snapshots))
}
wantIndices := []int{0x01, 0x10, 0xff}
for i, s := range snapshots {
if s.Index != wantIndices[i] {
t.Errorf("snapshots[%d].Index = %d, want %d", i, s.Index, wantIndices[i])
}
}
})
t.Run("SkipsInvalidFilenames", func(t *testing.T) {
tmpDir := t.TempDir()
snapshotsDir := filepath.Join(tmpDir, "generations", gen, "snapshots")
if err := os.MkdirAll(snapshotsDir, 0755); err != nil {
t.Fatal(err)
}
// Create mix of valid and invalid
files := map[string]bool{
"00000001.snapshot.lz4": true, // valid
"invalid.snapshot.lz4": false, // invalid
"00000002.snapshot": false, // missing .lz4
"00000003.wal.lz4": false, // wrong type
"00000004.snapshot.lz4": true, // valid
}
for name := range files {
if err := os.WriteFile(filepath.Join(snapshotsDir, name), []byte("x"), 0644); err != nil {
t.Fatal(err)
}
}
c := file.NewReplicaClient(tmpDir)
snapshots, err := c.SnapshotsV3(context.Background(), gen)
if err != nil {
t.Fatal(err)
}
if len(snapshots) != 2 {
t.Fatalf("expected 2 valid snapshots, got %d", len(snapshots))
}
})
}
func TestReplicaClient_WALSegmentsV3(t *testing.T) {
gen := "0123456789abcdef"
t.Run("NoWALDir", func(t *testing.T) {
tmpDir := t.TempDir()
c := file.NewReplicaClient(tmpDir)
segments, err := c.WALSegmentsV3(context.Background(), gen)
if err != nil {
t.Fatal(err)
}
if len(segments) != 0 {
t.Fatalf("expected no segments, got %v", segments)
}
})
t.Run("EmptyWALDir", func(t *testing.T) {
tmpDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(tmpDir, "generations", gen, "wal"), 0755); err != nil {
t.Fatal(err)
}
c := file.NewReplicaClient(tmpDir)
segments, err := c.WALSegmentsV3(context.Background(), gen)
if err != nil {
t.Fatal(err)
}
if len(segments) != 0 {
t.Fatalf("expected no segments, got %v", segments)
}
})
t.Run("SingleSegment", func(t *testing.T) {
tmpDir := t.TempDir()
walDir := filepath.Join(tmpDir, "generations", gen, "wal")
if err := os.MkdirAll(walDir, 0755); err != nil {
t.Fatal(err)
}
// Create WAL segment file
path := filepath.Join(walDir, "00000001_00001000.wal.lz4")
if err := os.WriteFile(path, []byte("wal data"), 0644); err != nil {
t.Fatal(err)
}
c := file.NewReplicaClient(tmpDir)
segments, err := c.WALSegmentsV3(context.Background(), gen)
if err != nil {
t.Fatal(err)
}
if len(segments) != 1 {
t.Fatalf("expected 1 segment, got %d", len(segments))
}
if segments[0].Generation != gen {
t.Errorf("Generation = %q, want %q", segments[0].Generation, gen)
}
if segments[0].Index != 1 {
t.Errorf("Index = %d, want 1", segments[0].Index)
}
if segments[0].Offset != 0x1000 {
t.Errorf("Offset = %d, want %d", segments[0].Offset, 0x1000)
}
if segments[0].Size != 8 { // len("wal data")
t.Errorf("Size = %d, want 8", segments[0].Size)
}
})
t.Run("MultipleSortedByIndexThenOffset", func(t *testing.T) {
tmpDir := t.TempDir()
walDir := filepath.Join(tmpDir, "generations", gen, "wal")
if err := os.MkdirAll(walDir, 0755); err != nil {
t.Fatal(err)
}
// Create WAL segments in non-sorted order
segments := []struct {
index int
offset int64
}{
{2, 0x2000},
{1, 0x1000},
{1, 0x0000},
{2, 0x0000},
}
for _, s := range segments {
filename := fmt.Sprintf("%08x_%08x.wal.lz4", s.index, s.offset)
if err := os.WriteFile(filepath.Join(walDir, filename), []byte("x"), 0644); err != nil {
t.Fatal(err)
}
}
c := file.NewReplicaClient(tmpDir)
result, err := c.WALSegmentsV3(context.Background(), gen)
if err != nil {
t.Fatal(err)
}
if len(result) != 4 {
t.Fatalf("expected 4 segments, got %d", len(result))
}
// Verify sorted order: index 1 offset 0, index 1 offset 0x1000, index 2 offset 0, index 2 offset 0x2000
expected := []struct {
index int
offset int64
}{
{1, 0x0000},
{1, 0x1000},
{2, 0x0000},
{2, 0x2000},
}
for i, s := range result {
if s.Index != expected[i].index || s.Offset != expected[i].offset {
t.Errorf("segments[%d] = (%d, %d), want (%d, %d)",
i, s.Index, s.Offset, expected[i].index, expected[i].offset)
}
}
})
t.Run("SkipsInvalidFilenames", func(t *testing.T) {
tmpDir := t.TempDir()
walDir := filepath.Join(tmpDir, "generations", gen, "wal")
if err := os.MkdirAll(walDir, 0755); err != nil {
t.Fatal(err)
}
// Create mix of valid and invalid
files := []string{
"00000001_00000000.wal.lz4", // valid
"invalid.wal.lz4", // invalid
"00000002.wal.lz4", // missing offset
"00000003_00001000.wal", // missing .lz4
"00000004_00002000.wal.lz4", // valid
}
for _, name := range files {
if err := os.WriteFile(filepath.Join(walDir, name), []byte("x"), 0644); err != nil {
t.Fatal(err)
}
}
c := file.NewReplicaClient(tmpDir)
result, err := c.WALSegmentsV3(context.Background(), gen)
if err != nil {
t.Fatal(err)
}
if len(result) != 2 {
t.Fatalf("expected 2 valid segments, got %d", len(result))
}
})
}
func TestReplicaClient_OpenSnapshotV3(t *testing.T) {
gen := "0123456789abcdef"
t.Run("NotFound", func(t *testing.T) {
tmpDir := t.TempDir()
c := file.NewReplicaClient(tmpDir)
_, err := c.OpenSnapshotV3(context.Background(), gen, 0)
if !os.IsNotExist(err) {
t.Errorf("expected not exist error, got %v", err)
}
})
t.Run("ReadDecompressed", func(t *testing.T) {
tmpDir := t.TempDir()
snapshotsDir := filepath.Join(tmpDir, "generations", gen, "snapshots")
if err := os.MkdirAll(snapshotsDir, 0755); err != nil {
t.Fatal(err)
}
// Create LZ4-compressed test data
original := []byte("test snapshot data for decompression")
var buf bytes.Buffer
w := lz4.NewWriter(&buf)
if _, err := w.Write(original); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
// Write compressed file
path := filepath.Join(snapshotsDir, "00000000.snapshot.lz4")
if err := os.WriteFile(path, buf.Bytes(), 0644); err != nil {
t.Fatal(err)
}
c := file.NewReplicaClient(tmpDir)
r, err := c.OpenSnapshotV3(context.Background(), gen, 0)
if err != nil {
t.Fatal(err)
}
defer r.Close()
data, err := io.ReadAll(r)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(data, original) {
t.Errorf("decompressed data mismatch: got %q, want %q", data, original)
}
})
}
func TestReplicaClient_OpenWALSegmentV3(t *testing.T) {
gen := "0123456789abcdef"
t.Run("NotFound", func(t *testing.T) {
tmpDir := t.TempDir()
c := file.NewReplicaClient(tmpDir)
_, err := c.OpenWALSegmentV3(context.Background(), gen, 0, 0)
if !os.IsNotExist(err) {
t.Errorf("expected not exist error, got %v", err)
}
})
t.Run("ReadDecompressed", func(t *testing.T) {
tmpDir := t.TempDir()
walDir := filepath.Join(tmpDir, "generations", gen, "wal")
if err := os.MkdirAll(walDir, 0755); err != nil {
t.Fatal(err)
}
// Create LZ4-compressed test data
original := []byte("test WAL segment data")
var buf bytes.Buffer
w := lz4.NewWriter(&buf)
if _, err := w.Write(original); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
// Write compressed file
path := filepath.Join(walDir, "00000001_00001000.wal.lz4")
if err := os.WriteFile(path, buf.Bytes(), 0644); err != nil {
t.Fatal(err)
}
c := file.NewReplicaClient(tmpDir)
r, err := c.OpenWALSegmentV3(context.Background(), gen, 1, 4096)
if err != nil {
t.Fatal(err)
}
defer r.Close()
data, err := io.ReadAll(r)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(data, original) {
t.Errorf("decompressed data mismatch: got %q, want %q", data, original)
}
})
}
/*
func TestReplica_Sync(t *testing.T) {
// Ensure replica can successfully sync after DB has sync'd.
t.Run("InitialSync", func(t *testing.T) {
db, sqldb := MustOpenDBs(t)
defer MustCloseDBs(t, db, sqldb)
r := replicate.NewReplica(db, "", file.NewReplicaClient(t.TempDir()))
r.MonitorEnabled = false
db.Replicas = []*replicate.Replica{r}
// Sync database & then sync replica.
if err := db.Sync(context.Background()); err != nil {
t.Fatal(err)
} else if err := r.Sync(context.Background()); err != nil {
t.Fatal(err)
}
// Ensure posistions match.
if want, err := db.Pos(); err != nil {
t.Fatal(err)
} else if got, err := r.Pos(context.Background()); err != nil {
t.Fatal(err)
} else if got != want {
t.Fatalf("Pos()=%v, want %v", got, want)
}
})
// Ensure replica can successfully sync multiple times.
t.Run("MultiSync", func(t *testing.T) {
db, sqldb := MustOpenDBs(t)
defer MustCloseDBs(t, db, sqldb)
r := replicate.NewReplica(db, "", file.NewReplicaClient(t.TempDir()))
r.MonitorEnabled = false
db.Replicas = []*replicate.Replica{r}
if _, err := sqldb.Exec(`CREATE TABLE foo (bar TEXT);`); err != nil {
t.Fatal(err)
}
// Write to the database multiple times and sync after each write.
for i, n := 0, db.MinCheckpointPageN*2; i < n; i++ {
if _, err := sqldb.Exec(`INSERT INTO foo (bar) VALUES ('baz')`); err != nil {
t.Fatal(err)
}
// Sync periodically.
if i%100 == 0 || i == n-1 {
if err := db.Sync(context.Background()); err != nil {
t.Fatal(err)
} else if err := r.Sync(context.Background()); err != nil {
t.Fatal(err)
}
}
}
// Ensure posistions match.
pos, err := db.Pos()
if err != nil {
t.Fatal(err)
} else if got, want := pos.Index, 2; got != want {
t.Fatalf("Index=%v, want %v", got, want)
}
if want, err := r.Pos(context.Background()); err != nil {
t.Fatal(err)
} else if got := pos; got != want {
t.Fatalf("Pos()=%v, want %v", got, want)
}
})
// Ensure replica returns an error if there is no generation available from the DB.
t.Run("ErrNoGeneration", func(t *testing.T) {
db, sqldb := MustOpenDBs(t)
defer MustCloseDBs(t, db, sqldb)
r := replicate.NewReplica(db, "", file.NewReplicaClient(t.TempDir()))
r.MonitorEnabled = false
db.Replicas = []*replicate.Replica{r}
if err := r.Sync(context.Background()); err == nil || err.Error() != `no generation, waiting for data` {
t.Fatal(err)
}
})
}
*/
func TestReplicaClient_OpenLTXFile_OpenErrorReturnsLTXError(t *testing.T) {
t.Run("MissingFile", func(t *testing.T) {
c := file.NewReplicaClient(t.TempDir())
_, err := c.OpenLTXFile(context.Background(), 0, 1, 1, 0, 0)
if err == nil {
t.Fatal("expected error for missing LTX file")
}
var ltxErr *replicate.LTXError
if !errors.As(err, &ltxErr) {
t.Fatalf("expected *LTXError, got %T: %v", err, err)
}
if ltxErr.Op != "open" {
t.Fatalf("expected op=open, got %q", ltxErr.Op)
}
if !ltxErr.IsAutoRecoverable() {
t.Fatal("missing file error should be auto-recoverable")
}
})
t.Run("PermissionDenied", func(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("test requires non-root (chmod 000 has no effect as root)")
}
dir := t.TempDir()
c := file.NewReplicaClient(dir)
path := c.LTXFilePath(0, 1, 1)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("data"), 0o000); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.Chmod(path, 0o644) })
_, err := c.OpenLTXFile(context.Background(), 0, 1, 1, 0, 0)
if err == nil {
t.Fatal("expected error for unreadable LTX file")
}
var ltxErr *replicate.LTXError
if !errors.As(err, &ltxErr) {
t.Fatalf("expected *LTXError, got %T: %v", err, err)
}
if ltxErr.Op != "open" {
t.Fatalf("expected op=open, got %q", ltxErr.Op)
}
if ltxErr.IsAutoRecoverable() {
t.Fatal("permission-denied error should not be auto-recoverable")
}
})
}