mirror of
https://github.com/hanzoai/replicate.git
synced 2026-10-30 18:23:20 +00:00
* 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.
1990 lines
48 KiB
Go
1990 lines
48 KiB
Go
//go:build vfs
|
|
// +build vfs
|
|
|
|
package replicate
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/hanzoai/ltx"
|
|
"github.com/psanford/sqlite3vfs"
|
|
)
|
|
|
|
// writeTestReplicaClient is a mock ReplicaClient for testing write functionality.
|
|
type writeTestReplicaClient struct {
|
|
mu sync.Mutex
|
|
ltxFiles map[int][]*ltx.FileInfo // level -> files
|
|
ltxData map[string][]byte // "level/minTXID-maxTXID" -> data
|
|
}
|
|
|
|
func newWriteTestReplicaClient() *writeTestReplicaClient {
|
|
return &writeTestReplicaClient{
|
|
ltxFiles: make(map[int][]*ltx.FileInfo),
|
|
ltxData: make(map[string][]byte),
|
|
}
|
|
}
|
|
|
|
func (c *writeTestReplicaClient) Type() string { return "test" }
|
|
|
|
func (c *writeTestReplicaClient) Init(ctx context.Context) error { return nil }
|
|
|
|
func (c *writeTestReplicaClient) LTXFiles(ctx context.Context, level int, seek ltx.TXID, useMetadata bool) (ltx.FileIterator, error) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
var files []*ltx.FileInfo
|
|
for _, f := range c.ltxFiles[level] {
|
|
if f.MinTXID >= seek {
|
|
files = append(files, f)
|
|
}
|
|
}
|
|
return &writeTestFileIterator{files: files}, nil
|
|
}
|
|
|
|
func (c *writeTestReplicaClient) OpenLTXFile(ctx context.Context, level int, minTXID, maxTXID ltx.TXID, offset, size int64) (io.ReadCloser, error) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
key := ltxKey(level, minTXID, maxTXID)
|
|
data, ok := c.ltxData[key]
|
|
if !ok {
|
|
return nil, io.EOF
|
|
}
|
|
|
|
if offset > 0 || size > 0 {
|
|
end := int64(len(data))
|
|
if size > 0 && offset+size < end {
|
|
end = offset + size
|
|
}
|
|
data = data[offset:end]
|
|
}
|
|
|
|
return io.NopCloser(bytes.NewReader(data)), nil
|
|
}
|
|
|
|
func (c *writeTestReplicaClient) WriteLTXFile(ctx context.Context, level int, minTXID, maxTXID ltx.TXID, r io.Reader) (*ltx.FileInfo, error) {
|
|
data, err := io.ReadAll(r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
key := ltxKey(level, minTXID, maxTXID)
|
|
c.ltxData[key] = data
|
|
|
|
info := <x.FileInfo{
|
|
Level: level,
|
|
MinTXID: minTXID,
|
|
MaxTXID: maxTXID,
|
|
CreatedAt: time.Now(),
|
|
Size: int64(len(data)),
|
|
}
|
|
c.ltxFiles[level] = append(c.ltxFiles[level], info)
|
|
|
|
return info, nil
|
|
}
|
|
|
|
func (c *writeTestReplicaClient) DeleteLTXFiles(ctx context.Context, a []*ltx.FileInfo) error {
|
|
return nil
|
|
}
|
|
|
|
func (c *writeTestReplicaClient) DeleteAll(ctx context.Context) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.ltxFiles = make(map[int][]*ltx.FileInfo)
|
|
c.ltxData = make(map[string][]byte)
|
|
return nil
|
|
}
|
|
|
|
func ltxKey(level int, minTXID, maxTXID ltx.TXID) string {
|
|
return string(rune(level)) + "/" + minTXID.String() + "-" + maxTXID.String()
|
|
}
|
|
|
|
// writeTestFileIterator implements ltx.FileIterator for testing.
|
|
type writeTestFileIterator struct {
|
|
files []*ltx.FileInfo
|
|
index int
|
|
}
|
|
|
|
func (itr *writeTestFileIterator) Next() bool {
|
|
if itr.index >= len(itr.files) {
|
|
return false
|
|
}
|
|
itr.index++
|
|
return true
|
|
}
|
|
|
|
func (itr *writeTestFileIterator) Item() *ltx.FileInfo {
|
|
if itr.index == 0 || itr.index > len(itr.files) {
|
|
return nil
|
|
}
|
|
return itr.files[itr.index-1]
|
|
}
|
|
|
|
func (itr *writeTestFileIterator) Close() error {
|
|
return nil
|
|
}
|
|
|
|
func (itr *writeTestFileIterator) Err() error {
|
|
return nil
|
|
}
|
|
|
|
// createTestLTXFile creates an LTX file with initial data for testing.
|
|
func createTestLTXFile(t *testing.T, client *writeTestReplicaClient, txid ltx.TXID, pageSize uint32, commit uint32, pages map[uint32][]byte) {
|
|
t.Helper()
|
|
|
|
var buf bytes.Buffer
|
|
enc, err := ltx.NewEncoder(&buf)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := enc.EncodeHeader(ltx.Header{
|
|
Version: ltx.Version,
|
|
Flags: ltx.HeaderFlagNoChecksum,
|
|
PageSize: pageSize,
|
|
Commit: commit,
|
|
MinTXID: txid,
|
|
MaxTXID: txid,
|
|
Timestamp: time.Now().UnixMilli(),
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Sort page numbers to ensure proper encoding order (page 1 must be first for snapshots)
|
|
pgnos := make([]uint32, 0, len(pages))
|
|
for pgno := range pages {
|
|
pgnos = append(pgnos, pgno)
|
|
}
|
|
sort.Slice(pgnos, func(i, j int) bool { return pgnos[i] < pgnos[j] })
|
|
|
|
for _, pgno := range pgnos {
|
|
if err := enc.EncodePage(ltx.PageHeader{Pgno: pgno}, pages[pgno]); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
if err := enc.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
client.mu.Lock()
|
|
key := ltxKey(0, txid, txid)
|
|
client.ltxData[key] = buf.Bytes()
|
|
client.ltxFiles[0] = append(client.ltxFiles[0], <x.FileInfo{
|
|
Level: 0,
|
|
MinTXID: txid,
|
|
MaxTXID: txid,
|
|
CreatedAt: time.Now(),
|
|
Size: int64(buf.Len()),
|
|
})
|
|
client.mu.Unlock()
|
|
}
|
|
|
|
// setupWriteableVFSFile creates a VFSFile with write support enabled and a buffer file.
|
|
func setupWriteableVFSFile(t *testing.T, client *writeTestReplicaClient) *VFSFile {
|
|
t.Helper()
|
|
|
|
logger := slog.Default()
|
|
f := NewVFSFile(client, "test.db", logger)
|
|
f.writeEnabled = true
|
|
f.dirty = make(map[uint32]int64)
|
|
f.syncInterval = 0
|
|
|
|
// Create a temporary buffer file
|
|
tmpFile, err := os.CreateTemp("", "replicate-test-buffer-*")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f.bufferFile = tmpFile
|
|
f.bufferPath = tmpFile.Name()
|
|
f.bufferNextOff = 0
|
|
|
|
t.Cleanup(func() {
|
|
if f.bufferFile != nil {
|
|
f.bufferFile.Close()
|
|
}
|
|
os.Remove(f.bufferPath)
|
|
})
|
|
|
|
return f
|
|
}
|
|
|
|
func TestVFSFile_WriteEnabled(t *testing.T) {
|
|
client := newWriteTestReplicaClient()
|
|
|
|
// Create initial LTX file with page 1
|
|
pageSize := uint32(4096)
|
|
initialPage := make([]byte, pageSize)
|
|
copy(initialPage, "initial data")
|
|
createTestLTXFile(t, client, 1, pageSize, 1, map[uint32][]byte{1: initialPage})
|
|
|
|
// Create VFSFile directly with write enabled
|
|
tmpDir := t.TempDir()
|
|
bufferPath := tmpDir + "/write-buffer"
|
|
|
|
logger := slog.Default()
|
|
f := NewVFSFile(client, "test.db", logger)
|
|
f.writeEnabled = true
|
|
f.dirty = make(map[uint32]int64)
|
|
f.syncInterval = 0
|
|
f.bufferPath = bufferPath
|
|
|
|
if err := f.Open(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer f.Close()
|
|
|
|
if !f.writeEnabled {
|
|
t.Error("expected writeEnabled to be true")
|
|
}
|
|
|
|
if f.dirty == nil {
|
|
t.Error("expected dirty map to be initialized")
|
|
}
|
|
}
|
|
|
|
func TestVFSFile_WriteAt(t *testing.T) {
|
|
client := newWriteTestReplicaClient()
|
|
|
|
// Create initial LTX file
|
|
pageSize := uint32(4096)
|
|
initialPage := make([]byte, pageSize)
|
|
copy(initialPage, "initial data")
|
|
createTestLTXFile(t, client, 1, pageSize, 1, map[uint32][]byte{1: initialPage})
|
|
|
|
// Create VFSFile with write support
|
|
f := setupWriteableVFSFile(t, client)
|
|
|
|
if err := f.Open(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer f.Close()
|
|
|
|
// Write some data at offset 100 (within page 1)
|
|
writeData := []byte("hello world")
|
|
n, err := f.WriteAt(writeData, 100)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n != len(writeData) {
|
|
t.Errorf("expected %d bytes written, got %d", len(writeData), n)
|
|
}
|
|
|
|
// Check dirty page exists
|
|
if len(f.dirty) != 1 {
|
|
t.Errorf("expected 1 dirty page, got %d", len(f.dirty))
|
|
}
|
|
if _, ok := f.dirty[1]; !ok {
|
|
t.Error("expected page 1 to be dirty")
|
|
}
|
|
|
|
// Read back the written data
|
|
readBuf := make([]byte, len(writeData))
|
|
n, err = f.ReadAt(readBuf, 100)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(readBuf) != string(writeData) {
|
|
t.Errorf("expected %q, got %q", writeData, readBuf)
|
|
}
|
|
}
|
|
|
|
func TestVFSFile_SyncToRemote(t *testing.T) {
|
|
client := newWriteTestReplicaClient()
|
|
|
|
// Create initial LTX file
|
|
pageSize := uint32(4096)
|
|
initialPage := make([]byte, pageSize)
|
|
createTestLTXFile(t, client, 1, pageSize, 1, map[uint32][]byte{1: initialPage})
|
|
|
|
// Create VFSFile with write support
|
|
f := setupWriteableVFSFile(t, client)
|
|
|
|
if err := f.Open(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer f.Close()
|
|
|
|
// Write data
|
|
writeData := []byte("synced data")
|
|
if _, err := f.WriteAt(writeData, 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Sync to remote
|
|
if err := f.Sync(0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Check dirty pages are cleared
|
|
if len(f.dirty) != 0 {
|
|
t.Errorf("expected 0 dirty pages after sync, got %d", len(f.dirty))
|
|
}
|
|
|
|
// Check TXID advanced
|
|
if f.expectedTXID != 2 {
|
|
t.Errorf("expected TXID 2, got %d", f.expectedTXID)
|
|
}
|
|
|
|
// Check LTX file was written to client
|
|
client.mu.Lock()
|
|
if len(client.ltxFiles[0]) != 2 {
|
|
t.Errorf("expected 2 LTX files, got %d", len(client.ltxFiles[0]))
|
|
}
|
|
client.mu.Unlock()
|
|
}
|
|
|
|
func TestVFSFile_ConflictDetection(t *testing.T) {
|
|
client := newWriteTestReplicaClient()
|
|
|
|
// Create initial LTX file
|
|
pageSize := uint32(4096)
|
|
initialPage := make([]byte, pageSize)
|
|
createTestLTXFile(t, client, 1, pageSize, 1, map[uint32][]byte{1: initialPage})
|
|
|
|
// Create VFSFile with write support
|
|
f := setupWriteableVFSFile(t, client)
|
|
|
|
if err := f.Open(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer f.Close()
|
|
|
|
// Write data
|
|
if _, err := f.WriteAt([]byte("data"), 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Simulate remote advancement (another writer)
|
|
createTestLTXFile(t, client, 2, pageSize, 1, map[uint32][]byte{1: initialPage})
|
|
|
|
// Try to sync - should fail with conflict
|
|
err := f.Sync(0)
|
|
if err == nil {
|
|
t.Fatal("expected conflict error")
|
|
}
|
|
if err.Error() != "remote has newer transactions than expected: expected TXID 1 but remote has 2" {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestVFSFile_TransactionTracking(t *testing.T) {
|
|
client := newWriteTestReplicaClient()
|
|
|
|
// Create initial LTX file
|
|
pageSize := uint32(4096)
|
|
initialPage := make([]byte, pageSize)
|
|
createTestLTXFile(t, client, 1, pageSize, 1, map[uint32][]byte{1: initialPage})
|
|
|
|
// Create VFSFile with write support
|
|
f := setupWriteableVFSFile(t, client)
|
|
|
|
if err := f.Open(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer f.Close()
|
|
|
|
// Acquire RESERVED lock (start transaction)
|
|
if err := f.Lock(2); err != nil { // sqlite3vfs.LockReserved = 2
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if !f.inTransaction {
|
|
t.Error("expected inTransaction to be true after RESERVED lock")
|
|
}
|
|
|
|
// Write data
|
|
if _, err := f.WriteAt([]byte("tx data"), 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Sync should be skipped during transaction
|
|
if err := f.Sync(0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(f.dirty) == 0 {
|
|
t.Error("expected dirty pages to remain during transaction")
|
|
}
|
|
|
|
// Release lock (end transaction)
|
|
if err := f.Unlock(1); err != nil { // sqlite3vfs.LockShared = 1
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if f.inTransaction {
|
|
t.Error("expected inTransaction to be false after unlock")
|
|
}
|
|
|
|
// Now sync should work
|
|
if err := f.Sync(0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(f.dirty) != 0 {
|
|
t.Error("expected dirty pages to be cleared after sync")
|
|
}
|
|
}
|
|
|
|
func TestVFSFile_Truncate(t *testing.T) {
|
|
client := newWriteTestReplicaClient()
|
|
|
|
// Create initial LTX files with 2 pages
|
|
pageSize := uint32(4096)
|
|
page1 := make([]byte, pageSize)
|
|
page2 := make([]byte, pageSize)
|
|
createTestLTXFile(t, client, 1, pageSize, 2, map[uint32][]byte{1: page1, 2: page2})
|
|
|
|
// Create VFSFile with write support
|
|
f := setupWriteableVFSFile(t, client)
|
|
|
|
if err := f.Open(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer f.Close()
|
|
|
|
// Write to page 2
|
|
if _, err := f.WriteAt([]byte("page2 data"), int64(pageSize)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Truncate to 1 page
|
|
if err := f.Truncate(int64(pageSize)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Page 2 should no longer be dirty
|
|
if _, ok := f.dirty[2]; ok {
|
|
t.Error("expected page 2 to be removed from dirty pages")
|
|
}
|
|
|
|
// Commit should be 1
|
|
if f.commit != 1 {
|
|
t.Errorf("expected commit 1, got %d", f.commit)
|
|
}
|
|
}
|
|
|
|
func TestVFSFile_WriteBuffer(t *testing.T) {
|
|
client := newWriteTestReplicaClient()
|
|
|
|
// Create initial LTX file
|
|
pageSize := uint32(4096)
|
|
initialPage := make([]byte, pageSize)
|
|
copy(initialPage, "initial data")
|
|
createTestLTXFile(t, client, 1, pageSize, 1, map[uint32][]byte{1: initialPage})
|
|
|
|
// Create temp directory for buffer
|
|
tmpDir := t.TempDir()
|
|
bufferPath := tmpDir + "/.replicate-write-buffer"
|
|
|
|
// Create VFSFile with write buffer
|
|
logger := slog.Default()
|
|
f := NewVFSFile(client, "test.db", logger)
|
|
f.writeEnabled = true
|
|
f.dirty = make(map[uint32]int64)
|
|
f.syncInterval = 0
|
|
f.bufferPath = bufferPath
|
|
|
|
if err := f.Open(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Write some data
|
|
writeData := []byte("buffered data")
|
|
if _, err := f.WriteAt(writeData, 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Check buffer file exists and has content
|
|
stat, err := os.Stat(bufferPath)
|
|
if err != nil {
|
|
t.Fatalf("buffer file should exist: %v", err)
|
|
}
|
|
if stat.Size() == 0 {
|
|
t.Error("buffer file should not be empty")
|
|
}
|
|
|
|
// Don't call f.Close() - simulate a crash by just abandoning the file handle
|
|
// Close just the buffer file directly to release the handle
|
|
if f.bufferFile != nil {
|
|
f.bufferFile.Close()
|
|
}
|
|
f.cancel() // Stop any goroutines
|
|
|
|
// Verify buffer file still has content (simulating crash before sync)
|
|
stat, err = os.Stat(bufferPath)
|
|
if err != nil {
|
|
t.Fatalf("buffer file should still exist after crash: %v", err)
|
|
}
|
|
if stat.Size() == 0 {
|
|
t.Error("buffer file should still have content after crash")
|
|
}
|
|
}
|
|
|
|
func TestVFSFile_WriteBufferDiscardedOnOpen(t *testing.T) {
|
|
// Test that unsync'd buffer contents are discarded on open (no recovery)
|
|
client := newWriteTestReplicaClient()
|
|
|
|
// Create initial LTX file
|
|
pageSize := uint32(4096)
|
|
initialPage := make([]byte, pageSize)
|
|
copy(initialPage, "initial data")
|
|
createTestLTXFile(t, client, 1, pageSize, 1, map[uint32][]byte{1: initialPage})
|
|
|
|
// Create temp directory for buffer
|
|
tmpDir := t.TempDir()
|
|
bufferPath := tmpDir + "/.replicate-write-buffer"
|
|
|
|
// First: create a VFSFile and write some data
|
|
logger := slog.Default()
|
|
f1 := NewVFSFile(client, "test.db", logger)
|
|
f1.writeEnabled = true
|
|
f1.dirty = make(map[uint32]int64)
|
|
f1.syncInterval = 0
|
|
f1.bufferPath = bufferPath
|
|
|
|
if err := f1.Open(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Write data (will be written to buffer)
|
|
writeData := make([]byte, pageSize)
|
|
copy(writeData, "unsync'd data that should be lost")
|
|
if _, err := f1.WriteAt(writeData, 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Simulate crash by abandoning the file handle without syncing
|
|
if f1.bufferFile != nil {
|
|
f1.bufferFile.Close()
|
|
}
|
|
f1.cancel()
|
|
|
|
// Second: create a new VFSFile - buffer should be discarded
|
|
f2 := NewVFSFile(client, "test.db", logger)
|
|
f2.writeEnabled = true
|
|
f2.dirty = make(map[uint32]int64)
|
|
f2.syncInterval = 0
|
|
f2.bufferPath = bufferPath
|
|
|
|
if err := f2.Open(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer f2.Close()
|
|
|
|
// Dirty pages should NOT be recovered - buffer is discarded on open
|
|
if len(f2.dirty) != 0 {
|
|
t.Errorf("expected 0 dirty pages (buffer should be discarded), got %d", len(f2.dirty))
|
|
}
|
|
|
|
// Reading should return original data from replica, not unsync'd data
|
|
readBuf := make([]byte, pageSize)
|
|
if _, err := f2.ReadAt(readBuf, 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(readBuf[:12]) != "initial data" {
|
|
t.Errorf("expected 'initial data' (from replica), got %q", string(readBuf[:12]))
|
|
}
|
|
}
|
|
|
|
func TestVFSFile_WriteBufferClearAfterSync(t *testing.T) {
|
|
client := newWriteTestReplicaClient()
|
|
|
|
// Create initial LTX file
|
|
pageSize := uint32(4096)
|
|
initialPage := make([]byte, pageSize)
|
|
createTestLTXFile(t, client, 1, pageSize, 1, map[uint32][]byte{1: initialPage})
|
|
|
|
// Create temp directory for buffer
|
|
tmpDir := t.TempDir()
|
|
bufferPath := tmpDir + "/.replicate-write-buffer"
|
|
|
|
// Create VFSFile with write buffer
|
|
logger := slog.Default()
|
|
f := NewVFSFile(client, "test.db", logger)
|
|
f.writeEnabled = true
|
|
f.dirty = make(map[uint32]int64)
|
|
f.syncInterval = 0
|
|
f.bufferPath = bufferPath
|
|
|
|
if err := f.Open(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer f.Close()
|
|
|
|
// Write data
|
|
if _, err := f.WriteAt([]byte("sync test"), 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Check buffer has content before sync
|
|
stat, _ := os.Stat(bufferPath)
|
|
if stat.Size() == 0 {
|
|
t.Error("buffer should have content before sync")
|
|
}
|
|
|
|
// Sync to remote
|
|
if err := f.Sync(0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Check buffer is cleared after sync
|
|
stat, _ = os.Stat(bufferPath)
|
|
if stat.Size() != 0 {
|
|
t.Errorf("buffer should be empty after sync, got size %d", stat.Size())
|
|
}
|
|
}
|
|
|
|
func TestVFSFile_OpenFailsWithInvalidBufferPath(t *testing.T) {
|
|
client := newWriteTestReplicaClient()
|
|
|
|
pageSize := uint32(4096)
|
|
initialPage := make([]byte, pageSize)
|
|
createTestLTXFile(t, client, 1, pageSize, 1, map[uint32][]byte{1: initialPage})
|
|
|
|
logger := slog.Default()
|
|
f := NewVFSFile(client, "test.db", logger)
|
|
f.writeEnabled = true
|
|
f.dirty = make(map[uint32]int64)
|
|
f.syncInterval = 0
|
|
f.bufferPath = "/nonexistent/path/that/cannot/be/created/buffer"
|
|
|
|
err := f.Open()
|
|
if err == nil {
|
|
f.Close()
|
|
t.Fatal("expected Open to fail with invalid buffer path")
|
|
}
|
|
}
|
|
|
|
func TestVFSFile_BufferFileAlwaysCreatedWhenWriteEnabled(t *testing.T) {
|
|
client := newWriteTestReplicaClient()
|
|
|
|
pageSize := uint32(4096)
|
|
initialPage := make([]byte, pageSize)
|
|
createTestLTXFile(t, client, 1, pageSize, 1, map[uint32][]byte{1: initialPage})
|
|
|
|
tmpDir := t.TempDir()
|
|
bufferPath := tmpDir + "/write-buffer"
|
|
|
|
logger := slog.Default()
|
|
f := NewVFSFile(client, "test.db", logger)
|
|
f.writeEnabled = true
|
|
f.dirty = make(map[uint32]int64)
|
|
f.syncInterval = 0
|
|
f.bufferPath = bufferPath
|
|
|
|
if err := f.Open(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer f.Close()
|
|
|
|
if f.bufferFile == nil {
|
|
t.Fatal("bufferFile should never be nil when writeEnabled is true")
|
|
}
|
|
}
|
|
|
|
func TestVFSFile_OpenNewDatabase(t *testing.T) {
|
|
// Test opening a VFSFile with write mode enabled when no LTX files exist (new database)
|
|
client := newWriteTestReplicaClient()
|
|
// Note: No LTX files created - simulating a brand new database
|
|
|
|
// Create temp directory for buffer
|
|
tmpDir := t.TempDir()
|
|
bufferPath := tmpDir + "/.replicate-write-buffer"
|
|
|
|
// Create VFSFile with write support - no existing data
|
|
logger := slog.Default()
|
|
f := NewVFSFile(client, "new.db", logger)
|
|
f.writeEnabled = true
|
|
f.dirty = make(map[uint32]int64)
|
|
f.syncInterval = 0
|
|
f.bufferPath = bufferPath
|
|
|
|
if err := f.Open(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer f.Close()
|
|
|
|
// Verify it opened successfully as a new database
|
|
if f.pageSize != DefaultPageSize {
|
|
t.Errorf("expected page size %d, got %d", DefaultPageSize, f.pageSize)
|
|
}
|
|
|
|
if f.pos.TXID != 0 {
|
|
t.Errorf("expected TXID 0 for new database, got %d", f.pos.TXID)
|
|
}
|
|
|
|
if f.expectedTXID != 0 {
|
|
t.Errorf("expected expectedTXID 0, got %d", f.expectedTXID)
|
|
}
|
|
|
|
if f.pendingTXID != 1 {
|
|
t.Errorf("expected pendingTXID 1, got %d", f.pendingTXID)
|
|
}
|
|
|
|
if f.commit != 0 {
|
|
t.Errorf("expected commit 0 for new database, got %d", f.commit)
|
|
}
|
|
}
|
|
|
|
func TestVFSFile_NewDatabase_ReadReturnsZeros(t *testing.T) {
|
|
// Test that reading from a new database returns zeros
|
|
client := newWriteTestReplicaClient()
|
|
|
|
tmpDir := t.TempDir()
|
|
bufferPath := tmpDir + "/.replicate-write-buffer"
|
|
|
|
logger := slog.Default()
|
|
f := NewVFSFile(client, "new.db", logger)
|
|
f.writeEnabled = true
|
|
f.dirty = make(map[uint32]int64)
|
|
f.syncInterval = 0
|
|
f.bufferPath = bufferPath
|
|
|
|
if err := f.Open(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer f.Close()
|
|
|
|
// Read page 1 - should return zeros for new database
|
|
readBuf := make([]byte, 100)
|
|
n, err := f.ReadAt(readBuf, 0)
|
|
if err != nil {
|
|
t.Fatalf("expected no error reading from new database, got: %v", err)
|
|
}
|
|
if n != len(readBuf) {
|
|
t.Errorf("expected %d bytes, got %d", len(readBuf), n)
|
|
}
|
|
|
|
// Verify all zeros
|
|
for i, b := range readBuf {
|
|
if b != 0 {
|
|
t.Errorf("expected zero at position %d, got %d", i, b)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestVFSFile_NewDatabase_WriteAndSync(t *testing.T) {
|
|
// Test writing to a new database and syncing to remote
|
|
client := newWriteTestReplicaClient()
|
|
|
|
tmpDir := t.TempDir()
|
|
bufferPath := tmpDir + "/.replicate-write-buffer"
|
|
|
|
logger := slog.Default()
|
|
f := NewVFSFile(client, "new.db", logger)
|
|
f.writeEnabled = true
|
|
f.dirty = make(map[uint32]int64)
|
|
f.syncInterval = 0
|
|
f.bufferPath = bufferPath
|
|
|
|
if err := f.Open(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer f.Close()
|
|
|
|
// Write data to page 1
|
|
writeData := []byte("new database content")
|
|
n, err := f.WriteAt(writeData, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n != len(writeData) {
|
|
t.Errorf("expected %d bytes written, got %d", len(writeData), n)
|
|
}
|
|
|
|
// Verify dirty page exists
|
|
if len(f.dirty) != 1 {
|
|
t.Errorf("expected 1 dirty page, got %d", len(f.dirty))
|
|
}
|
|
|
|
// Sync to remote
|
|
if err := f.Sync(0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Verify TXID advanced
|
|
if f.expectedTXID != 1 {
|
|
t.Errorf("expected expectedTXID 1 after sync, got %d", f.expectedTXID)
|
|
}
|
|
if f.pendingTXID != 2 {
|
|
t.Errorf("expected pendingTXID 2 after sync, got %d", f.pendingTXID)
|
|
}
|
|
|
|
// Verify LTX file was written
|
|
client.mu.Lock()
|
|
if len(client.ltxFiles[0]) != 1 {
|
|
t.Errorf("expected 1 LTX file after sync, got %d", len(client.ltxFiles[0]))
|
|
}
|
|
if len(client.ltxFiles[0]) > 0 {
|
|
info := client.ltxFiles[0][0]
|
|
if info.MinTXID != 1 || info.MaxTXID != 1 {
|
|
t.Errorf("expected TXID 1, got min=%d max=%d", info.MinTXID, info.MaxTXID)
|
|
}
|
|
}
|
|
client.mu.Unlock()
|
|
}
|
|
|
|
func TestVFSFile_NewDatabase_FileSize(t *testing.T) {
|
|
// Test that FileSize returns 0 for a new empty database
|
|
client := newWriteTestReplicaClient()
|
|
|
|
tmpDir := t.TempDir()
|
|
bufferPath := tmpDir + "/.replicate-write-buffer"
|
|
|
|
logger := slog.Default()
|
|
f := NewVFSFile(client, "new.db", logger)
|
|
f.writeEnabled = true
|
|
f.dirty = make(map[uint32]int64)
|
|
f.syncInterval = 0
|
|
f.bufferPath = bufferPath
|
|
|
|
if err := f.Open(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer f.Close()
|
|
|
|
// FileSize should be 0 for empty database
|
|
size, err := f.FileSize()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if size != 0 {
|
|
t.Errorf("expected size 0 for new database, got %d", size)
|
|
}
|
|
|
|
// Write a page
|
|
data := make([]byte, DefaultPageSize)
|
|
if _, err := f.WriteAt(data, 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// FileSize should now reflect the dirty page
|
|
size, err = f.FileSize()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if size != int64(DefaultPageSize) {
|
|
t.Errorf("expected size %d after write, got %d", DefaultPageSize, size)
|
|
}
|
|
}
|
|
|
|
func TestSetWriteEnabled_ReadValue(t *testing.T) {
|
|
client := newWriteTestReplicaClient()
|
|
|
|
pageSize := uint32(4096)
|
|
initialPage := make([]byte, pageSize)
|
|
createTestLTXFile(t, client, 1, pageSize, 1, map[uint32][]byte{1: initialPage})
|
|
|
|
// Test with write disabled
|
|
logger := slog.Default()
|
|
f := NewVFSFile(client, "test.db", logger)
|
|
f.writeEnabled = false
|
|
|
|
if err := f.Open(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer f.Close()
|
|
|
|
// Read via FileControl (simulates PRAGMA replicate_write_enabled)
|
|
result, err := f.FileControl(14, "replicate_write_enabled", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result == nil || *result != "0" {
|
|
t.Errorf("expected '0' for disabled write support, got %v", result)
|
|
}
|
|
}
|
|
|
|
func TestSetWriteEnabled_ReadValueEnabled(t *testing.T) {
|
|
client := newWriteTestReplicaClient()
|
|
|
|
pageSize := uint32(4096)
|
|
initialPage := make([]byte, pageSize)
|
|
createTestLTXFile(t, client, 1, pageSize, 1, map[uint32][]byte{1: initialPage})
|
|
|
|
// Test with write enabled
|
|
f := setupWriteableVFSFile(t, client)
|
|
|
|
if err := f.Open(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer f.Close()
|
|
|
|
// Read via FileControl
|
|
result, err := f.FileControl(14, "replicate_write_enabled", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result == nil || *result != "1" {
|
|
t.Errorf("expected '1' for enabled write support, got %v", result)
|
|
}
|
|
}
|
|
|
|
func TestSetWriteEnabled_DisableSyncsDirtyPages(t *testing.T) {
|
|
client := newWriteTestReplicaClient()
|
|
|
|
pageSize := uint32(4096)
|
|
initialPage := make([]byte, pageSize)
|
|
createTestLTXFile(t, client, 1, pageSize, 1, map[uint32][]byte{1: initialPage})
|
|
|
|
f := setupWriteableVFSFile(t, client)
|
|
|
|
if err := f.Open(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer f.Close()
|
|
|
|
// Write data to create dirty pages
|
|
writeData := []byte("dirty data")
|
|
if _, err := f.WriteAt(writeData, 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if len(f.dirty) == 0 {
|
|
t.Fatal("expected dirty pages")
|
|
}
|
|
|
|
// Disable writes via SetWriteEnabled
|
|
if err := f.SetWriteEnabled(false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Dirty pages should be synced
|
|
if len(f.dirty) != 0 {
|
|
t.Errorf("expected 0 dirty pages after disable, got %d", len(f.dirty))
|
|
}
|
|
|
|
// Write support should be disabled
|
|
if f.writeEnabled {
|
|
t.Error("expected writeEnabled to be false")
|
|
}
|
|
|
|
// LTX file should have been written
|
|
client.mu.Lock()
|
|
if len(client.ltxFiles[0]) != 2 {
|
|
t.Errorf("expected 2 LTX files (initial + synced), got %d", len(client.ltxFiles[0]))
|
|
}
|
|
client.mu.Unlock()
|
|
}
|
|
|
|
func TestSetWriteEnabled_DisableWaitsForTransaction(t *testing.T) {
|
|
client := newWriteTestReplicaClient()
|
|
|
|
pageSize := uint32(4096)
|
|
initialPage := make([]byte, pageSize)
|
|
createTestLTXFile(t, client, 1, pageSize, 1, map[uint32][]byte{1: initialPage})
|
|
|
|
f := setupWriteableVFSFile(t, client)
|
|
|
|
if err := f.Open(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer f.Close()
|
|
|
|
// Start a transaction (acquire RESERVED lock)
|
|
if err := f.Lock(2); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Write some data
|
|
if _, err := f.WriteAt([]byte("tx data"), 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Start disable in a goroutine (it should wait for transaction)
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- f.SetWriteEnabled(false)
|
|
}()
|
|
|
|
// Wait for SetWriteEnabled to set the disabling flag
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for {
|
|
f.mu.Lock()
|
|
disabling := f.disabling
|
|
f.mu.Unlock()
|
|
if disabling {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("timed out waiting for disabling flag")
|
|
}
|
|
time.Sleep(1 * time.Millisecond)
|
|
}
|
|
|
|
// Write should still be enabled (waiting for transaction)
|
|
f.mu.Lock()
|
|
stillEnabled := f.writeEnabled
|
|
f.mu.Unlock()
|
|
if !stillEnabled {
|
|
t.Error("expected writeEnabled to still be true while in transaction")
|
|
}
|
|
|
|
// End transaction (release lock)
|
|
if err := f.Unlock(1); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Wait for disable to complete
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatalf("SetWriteEnabled failed: %v", err)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("SetWriteEnabled timed out")
|
|
}
|
|
|
|
// Write should now be disabled
|
|
f.mu.Lock()
|
|
enabled := f.writeEnabled
|
|
f.mu.Unlock()
|
|
if enabled {
|
|
t.Error("expected writeEnabled to be false after transaction ended")
|
|
}
|
|
}
|
|
|
|
func TestSetWriteEnabled_EnableAfterDisable(t *testing.T) {
|
|
client := newWriteTestReplicaClient()
|
|
|
|
pageSize := uint32(4096)
|
|
initialPage := make([]byte, pageSize)
|
|
createTestLTXFile(t, client, 1, pageSize, 1, map[uint32][]byte{1: initialPage})
|
|
|
|
f := setupWriteableVFSFile(t, client)
|
|
|
|
if err := f.Open(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer f.Close()
|
|
|
|
// Disable writes
|
|
if err := f.SetWriteEnabled(false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if f.writeEnabled {
|
|
t.Error("expected writeEnabled to be false")
|
|
}
|
|
|
|
// Re-enable writes
|
|
if err := f.SetWriteEnabled(true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if !f.writeEnabled {
|
|
t.Error("expected writeEnabled to be true")
|
|
}
|
|
|
|
// Verify we can write again
|
|
writeData := []byte("after re-enable")
|
|
if _, err := f.WriteAt(writeData, 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if len(f.dirty) == 0 {
|
|
t.Error("expected dirty pages after write")
|
|
}
|
|
}
|
|
|
|
func TestSetWriteEnabled_DisableWithTimeout(t *testing.T) {
|
|
client := newWriteTestReplicaClient()
|
|
|
|
pageSize := uint32(4096)
|
|
initialPage := make([]byte, pageSize)
|
|
createTestLTXFile(t, client, 1, pageSize, 1, map[uint32][]byte{1: initialPage})
|
|
|
|
f := setupWriteableVFSFile(t, client)
|
|
|
|
if err := f.Open(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer f.Close()
|
|
|
|
// Start a transaction (acquire RESERVED lock)
|
|
if err := f.Lock(2); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Write some data
|
|
if _, err := f.WriteAt([]byte("tx data"), 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Try to disable with a short timeout - should fail
|
|
err := f.SetWriteEnabledWithTimeout(false, 50*time.Millisecond)
|
|
if err == nil {
|
|
t.Fatal("expected timeout error")
|
|
}
|
|
if !strings.Contains(err.Error(), "timeout waiting for transaction") {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
|
|
// Write should still be enabled
|
|
if !f.writeEnabled {
|
|
t.Error("expected writeEnabled to still be true after timeout")
|
|
}
|
|
|
|
// End transaction
|
|
if err := f.Unlock(1); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Now disable should succeed (with or without timeout)
|
|
if err := f.SetWriteEnabledWithTimeout(false, 1*time.Second); err != nil {
|
|
t.Fatalf("SetWriteEnabledWithTimeout failed: %v", err)
|
|
}
|
|
|
|
if f.writeEnabled {
|
|
t.Error("expected writeEnabled to be false")
|
|
}
|
|
}
|
|
|
|
func TestSetWriteEnabled_ColdEnable(t *testing.T) {
|
|
client := newWriteTestReplicaClient()
|
|
|
|
pageSize := uint32(4096)
|
|
initialPage := make([]byte, pageSize)
|
|
createTestLTXFile(t, client, 1, pageSize, 1, map[uint32][]byte{1: initialPage})
|
|
|
|
// Create VFSFile WITHOUT write enabled initially
|
|
logger := slog.Default()
|
|
f := NewVFSFile(client, "test.db", logger)
|
|
f.writeEnabled = false
|
|
// Note: dirty, bufferPath, etc. are NOT set - simulating cold start
|
|
|
|
if err := f.Open(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer f.Close()
|
|
|
|
// Verify writes are disabled
|
|
if f.writeEnabled {
|
|
t.Error("expected writeEnabled to be false initially")
|
|
}
|
|
|
|
// Enable writes via SetWriteEnabled (cold enable)
|
|
if err := f.SetWriteEnabled(true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Verify writes are now enabled
|
|
if !f.writeEnabled {
|
|
t.Error("expected writeEnabled to be true after cold enable")
|
|
}
|
|
|
|
// Verify buffer was initialized
|
|
if f.bufferFile == nil {
|
|
t.Error("expected bufferFile to be initialized")
|
|
}
|
|
|
|
// Verify dirty map was initialized
|
|
if f.dirty == nil {
|
|
t.Error("expected dirty map to be initialized")
|
|
}
|
|
|
|
// Verify TXID state was initialized
|
|
if f.pendingTXID == 0 {
|
|
t.Error("expected pendingTXID to be initialized")
|
|
}
|
|
|
|
// Verify we can write
|
|
writeData := []byte("cold enable test")
|
|
if _, err := f.WriteAt(writeData, 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if len(f.dirty) == 0 {
|
|
t.Error("expected dirty pages after write")
|
|
}
|
|
}
|
|
|
|
func TestSetWriteEnabled_NoOpWhenAlreadyInState(t *testing.T) {
|
|
client := newWriteTestReplicaClient()
|
|
|
|
pageSize := uint32(4096)
|
|
initialPage := make([]byte, pageSize)
|
|
createTestLTXFile(t, client, 1, pageSize, 1, map[uint32][]byte{1: initialPage})
|
|
|
|
f := setupWriteableVFSFile(t, client)
|
|
|
|
if err := f.Open(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer f.Close()
|
|
|
|
// Enable when already enabled should be no-op
|
|
if err := f.SetWriteEnabled(true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if !f.writeEnabled {
|
|
t.Error("expected writeEnabled to remain true")
|
|
}
|
|
|
|
// Disable
|
|
if err := f.SetWriteEnabled(false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Disable when already disabled should be no-op
|
|
if err := f.SetWriteEnabled(false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if f.writeEnabled {
|
|
t.Error("expected writeEnabled to remain false")
|
|
}
|
|
}
|
|
|
|
func TestSetWriteEnabled_FileControlWrite(t *testing.T) {
|
|
client := newWriteTestReplicaClient()
|
|
|
|
pageSize := uint32(4096)
|
|
initialPage := make([]byte, pageSize)
|
|
createTestLTXFile(t, client, 1, pageSize, 1, map[uint32][]byte{1: initialPage})
|
|
|
|
f := setupWriteableVFSFile(t, client)
|
|
|
|
if err := f.Open(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer f.Close()
|
|
|
|
// Disable via FileControl (PRAGMA replicate_write_enabled = 0)
|
|
value := "0"
|
|
_, err := f.FileControl(14, "replicate_write_enabled", &value)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if f.writeEnabled {
|
|
t.Error("expected writeEnabled to be false after PRAGMA = 0")
|
|
}
|
|
|
|
// Enable via FileControl (PRAGMA replicate_write_enabled = 1)
|
|
value = "1"
|
|
_, err = f.FileControl(14, "replicate_write_enabled", &value)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if !f.writeEnabled {
|
|
t.Error("expected writeEnabled to be true after PRAGMA = 1")
|
|
}
|
|
|
|
// Test alternate values
|
|
value = "off"
|
|
_, err = f.FileControl(14, "replicate_write_enabled", &value)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if f.writeEnabled {
|
|
t.Error("expected writeEnabled to be false after PRAGMA = off")
|
|
}
|
|
|
|
value = "on"
|
|
_, err = f.FileControl(14, "replicate_write_enabled", &value)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !f.writeEnabled {
|
|
t.Error("expected writeEnabled to be true after PRAGMA = on")
|
|
}
|
|
|
|
value = "false"
|
|
_, err = f.FileControl(14, "replicate_write_enabled", &value)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if f.writeEnabled {
|
|
t.Error("expected writeEnabled to be false after PRAGMA = false")
|
|
}
|
|
|
|
value = "true"
|
|
_, err = f.FileControl(14, "replicate_write_enabled", &value)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !f.writeEnabled {
|
|
t.Error("expected writeEnabled to be true after PRAGMA = true")
|
|
}
|
|
}
|
|
|
|
func TestSetWriteEnabled_InvalidValue(t *testing.T) {
|
|
client := newWriteTestReplicaClient()
|
|
|
|
pageSize := uint32(4096)
|
|
initialPage := make([]byte, pageSize)
|
|
createTestLTXFile(t, client, 1, pageSize, 1, map[uint32][]byte{1: initialPage})
|
|
|
|
f := setupWriteableVFSFile(t, client)
|
|
|
|
if err := f.Open(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer f.Close()
|
|
|
|
// Invalid value should return error
|
|
value := "invalid"
|
|
_, err := f.FileControl(14, "replicate_write_enabled", &value)
|
|
if err == nil {
|
|
t.Error("expected error for invalid value")
|
|
}
|
|
if err.Error() != "invalid value for replicate_write_enabled: invalid (use 0 or 1)" {
|
|
t.Errorf("unexpected error message: %v", err)
|
|
}
|
|
}
|
|
|
|
// failingWriteClient wraps writeTestReplicaClient to fail writes after a certain count.
|
|
type failingWriteClient struct {
|
|
*writeTestReplicaClient
|
|
failAfter int
|
|
writeCount int
|
|
}
|
|
|
|
func newFailingWriteClient(failAfter int) *failingWriteClient {
|
|
return &failingWriteClient{
|
|
writeTestReplicaClient: newWriteTestReplicaClient(),
|
|
failAfter: failAfter,
|
|
}
|
|
}
|
|
|
|
func (c *failingWriteClient) WriteLTXFile(ctx context.Context, level int, minTXID, maxTXID ltx.TXID, r io.Reader) (*ltx.FileInfo, error) {
|
|
c.mu.Lock()
|
|
c.writeCount++
|
|
count := c.writeCount
|
|
c.mu.Unlock()
|
|
|
|
if count > c.failAfter {
|
|
return nil, errors.New("simulated write failure")
|
|
}
|
|
return c.writeTestReplicaClient.WriteLTXFile(ctx, level, minTXID, maxTXID, r)
|
|
}
|
|
|
|
func TestSetWriteEnabled_SyncFailureKeepsWritesEnabled(t *testing.T) {
|
|
// Use a client that fails on the second write attempt (first is from setup/initial sync)
|
|
client := newFailingWriteClient(0) // Fail on first write
|
|
|
|
pageSize := uint32(4096)
|
|
initialPage := make([]byte, pageSize)
|
|
createTestLTXFile(t, client.writeTestReplicaClient, 1, pageSize, 1, map[uint32][]byte{1: initialPage})
|
|
|
|
logger := slog.Default()
|
|
f := NewVFSFile(client, "test.db", logger)
|
|
f.writeEnabled = true
|
|
f.dirty = make(map[uint32]int64)
|
|
f.syncInterval = 0
|
|
|
|
// Create a temporary buffer file
|
|
tmpFile, err := os.CreateTemp("", "replicate-test-buffer-*")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f.bufferFile = tmpFile
|
|
f.bufferPath = tmpFile.Name()
|
|
f.bufferNextOff = 0
|
|
|
|
t.Cleanup(func() {
|
|
if f.bufferFile != nil {
|
|
f.bufferFile.Close()
|
|
}
|
|
os.Remove(f.bufferPath)
|
|
})
|
|
|
|
if err := f.Open(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer f.Close()
|
|
|
|
// Write data to create dirty pages
|
|
writeData := []byte("dirty data")
|
|
if _, err := f.WriteAt(writeData, 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if len(f.dirty) == 0 {
|
|
t.Fatal("expected dirty pages")
|
|
}
|
|
|
|
// Try to disable writes - should fail because sync fails
|
|
err = f.SetWriteEnabled(false)
|
|
if err == nil {
|
|
t.Fatal("expected error from sync failure")
|
|
}
|
|
if !strings.Contains(err.Error(), "sync before disable") {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
|
|
// Write support should still be enabled because sync failed
|
|
if !f.writeEnabled {
|
|
t.Error("expected writeEnabled to remain true after sync failure")
|
|
}
|
|
|
|
// Dirty pages should still exist
|
|
if len(f.dirty) == 0 {
|
|
t.Error("expected dirty pages to remain after sync failure")
|
|
}
|
|
}
|
|
|
|
func TestSetWriteEnabled_DisablingPreventsNewTransactions(t *testing.T) {
|
|
client := newWriteTestReplicaClient()
|
|
|
|
pageSize := uint32(4096)
|
|
initialPage := make([]byte, pageSize)
|
|
createTestLTXFile(t, client, 1, pageSize, 1, map[uint32][]byte{1: initialPage})
|
|
|
|
f := setupWriteableVFSFile(t, client)
|
|
|
|
if err := f.Open(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer f.Close()
|
|
|
|
// Start a transaction (acquire RESERVED lock)
|
|
if err := f.Lock(2); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Start disable in a goroutine
|
|
disableDone := make(chan error, 1)
|
|
go func() {
|
|
disableDone <- f.SetWriteEnabledWithTimeout(false, 2*time.Second)
|
|
}()
|
|
|
|
// Wait for SetWriteEnabled to set the disabling flag
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for {
|
|
f.mu.Lock()
|
|
disabling := f.disabling
|
|
f.mu.Unlock()
|
|
if disabling {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("timed out waiting for disabling flag")
|
|
}
|
|
time.Sleep(1 * time.Millisecond)
|
|
}
|
|
|
|
// End the first transaction
|
|
if err := f.Unlock(1); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Wait for disable to complete
|
|
select {
|
|
case err := <-disableDone:
|
|
if err != nil {
|
|
t.Fatalf("SetWriteEnabled failed: %v", err)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("SetWriteEnabled timed out")
|
|
}
|
|
|
|
// Verify disabling flag is cleared
|
|
f.mu.Lock()
|
|
disabling := f.disabling
|
|
f.mu.Unlock()
|
|
|
|
if disabling {
|
|
t.Error("expected disabling flag to be false after completion")
|
|
}
|
|
|
|
// Verify writes are disabled
|
|
f.mu.Lock()
|
|
enabled := f.writeEnabled
|
|
f.mu.Unlock()
|
|
if enabled {
|
|
t.Error("expected writeEnabled to be false")
|
|
}
|
|
}
|
|
|
|
func TestSetWriteEnabled_ConcurrentEnableDisable(t *testing.T) {
|
|
client := newWriteTestReplicaClient()
|
|
|
|
pageSize := uint32(4096)
|
|
initialPage := make([]byte, pageSize)
|
|
createTestLTXFile(t, client, 1, pageSize, 1, map[uint32][]byte{1: initialPage})
|
|
|
|
f := setupWriteableVFSFile(t, client)
|
|
|
|
if err := f.Open(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer f.Close()
|
|
|
|
// Run multiple concurrent enable/disable operations
|
|
var wg sync.WaitGroup
|
|
errCh := make(chan error, 20)
|
|
|
|
for i := 0; i < 10; i++ {
|
|
wg.Add(2)
|
|
go func() {
|
|
defer wg.Done()
|
|
if err := f.SetWriteEnabled(true); err != nil {
|
|
errCh <- err
|
|
}
|
|
}()
|
|
go func() {
|
|
defer wg.Done()
|
|
if err := f.SetWriteEnabled(false); err != nil {
|
|
errCh <- err
|
|
}
|
|
}()
|
|
}
|
|
|
|
wg.Wait()
|
|
close(errCh)
|
|
|
|
// Check for errors
|
|
for err := range errCh {
|
|
t.Errorf("concurrent operation failed: %v", err)
|
|
}
|
|
|
|
// The final state should be valid (either enabled or disabled)
|
|
f.mu.Lock()
|
|
enabled := f.writeEnabled
|
|
disabling := f.disabling
|
|
f.mu.Unlock()
|
|
|
|
// disabling should always be false when no operation is in progress
|
|
if disabling {
|
|
t.Error("expected disabling to be false after all operations complete")
|
|
}
|
|
|
|
t.Logf("Final writeEnabled state: %v", enabled)
|
|
}
|
|
|
|
func TestLock_BlocksDuringDisable(t *testing.T) {
|
|
client := newWriteTestReplicaClient()
|
|
|
|
pageSize := uint32(4096)
|
|
initialPage := make([]byte, pageSize)
|
|
createTestLTXFile(t, client, 1, pageSize, 1, map[uint32][]byte{1: initialPage})
|
|
|
|
f := setupWriteableVFSFile(t, client)
|
|
|
|
if err := f.Open(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer f.Close()
|
|
|
|
// Start a transaction (acquire RESERVED lock)
|
|
if err := f.Lock(2); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Write some data so there's something to sync
|
|
if _, err := f.WriteAt([]byte("tx data"), 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Start disable in a goroutine - it will wait for the transaction
|
|
disableDone := make(chan error, 1)
|
|
go func() {
|
|
disableDone <- f.SetWriteEnabled(false)
|
|
}()
|
|
|
|
// Wait for SetWriteEnabled to set the disabling flag
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for {
|
|
f.mu.Lock()
|
|
disabling := f.disabling
|
|
f.mu.Unlock()
|
|
if disabling {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("timed out waiting for disabling flag")
|
|
}
|
|
time.Sleep(1 * time.Millisecond)
|
|
}
|
|
|
|
// End transaction to let disable proceed, then immediately try to
|
|
// acquire RESERVED lock again - it should block until disable completes
|
|
lockErrCh := make(chan error, 1)
|
|
lockDone := make(chan struct{})
|
|
go func() {
|
|
defer close(lockDone)
|
|
if err := f.Unlock(1); err != nil {
|
|
lockErrCh <- fmt.Errorf("unlock: %w", err)
|
|
return
|
|
}
|
|
// Lock() should block while disabling is true, then fail because
|
|
// writeEnabled will be false after disable completes
|
|
lockErrCh <- f.Lock(2)
|
|
}()
|
|
|
|
// Wait for disable to complete
|
|
select {
|
|
case err := <-disableDone:
|
|
if err != nil {
|
|
t.Fatalf("SetWriteEnabled failed: %v", err)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("SetWriteEnabled timed out")
|
|
}
|
|
|
|
select {
|
|
case <-lockDone:
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("Lock() timed out")
|
|
}
|
|
|
|
// Lock should have returned an error since writes are now disabled
|
|
if err := <-lockErrCh; err == nil {
|
|
t.Error("expected Lock(RESERVED) to fail when writes are disabled")
|
|
}
|
|
|
|
// Verify writeEnabled is now false
|
|
f.mu.Lock()
|
|
enabled := f.writeEnabled
|
|
inTx := f.inTransaction
|
|
f.mu.Unlock()
|
|
if enabled {
|
|
t.Error("expected writeEnabled to be false after disable completed")
|
|
}
|
|
if inTx {
|
|
t.Error("expected inTransaction to be false when writeEnabled is false")
|
|
}
|
|
}
|
|
|
|
func TestLock_BlocksDuringDisable_MultipleWaiters(t *testing.T) {
|
|
client := newWriteTestReplicaClient()
|
|
|
|
pageSize := uint32(4096)
|
|
initialPage := make([]byte, pageSize)
|
|
createTestLTXFile(t, client, 1, pageSize, 1, map[uint32][]byte{1: initialPage})
|
|
|
|
f := setupWriteableVFSFile(t, client)
|
|
|
|
if err := f.Open(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer f.Close()
|
|
|
|
// Start a transaction (acquire RESERVED lock)
|
|
if err := f.Lock(2); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Write some data
|
|
if _, err := f.WriteAt([]byte("tx data"), 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Start disable in a goroutine
|
|
disableDone := make(chan error, 1)
|
|
go func() {
|
|
disableDone <- f.SetWriteEnabled(false)
|
|
}()
|
|
|
|
// Wait for SetWriteEnabled to set the disabling flag
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for {
|
|
f.mu.Lock()
|
|
disabling := f.disabling
|
|
f.mu.Unlock()
|
|
if disabling {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("timed out waiting for disabling flag")
|
|
}
|
|
time.Sleep(1 * time.Millisecond)
|
|
}
|
|
|
|
// Simulate multiple waiters trying to acquire RESERVED lock.
|
|
// They will block on cond.Wait() while disabling is true, then
|
|
// fail with read-only error once disable completes.
|
|
const numWaiters = 3
|
|
var wg sync.WaitGroup
|
|
errCh := make(chan error, numWaiters)
|
|
started := make(chan struct{}, numWaiters)
|
|
|
|
for i := 0; i < numWaiters; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
started <- struct{}{}
|
|
errCh <- f.Lock(2)
|
|
}()
|
|
}
|
|
|
|
// Wait for all goroutines to start, then verify none have completed yet
|
|
// (they should be blocked in cond.Wait() while disabling is true).
|
|
for i := 0; i < numWaiters; i++ {
|
|
<-started
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
if len(errCh) > 0 {
|
|
t.Fatal("expected all Lock() calls to be blocked during disable, but some completed early")
|
|
}
|
|
|
|
// End the original transaction - this will trigger the disable to complete
|
|
if err := f.Unlock(1); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Wait for disable to complete
|
|
select {
|
|
case err := <-disableDone:
|
|
if err != nil {
|
|
t.Fatalf("SetWriteEnabled failed: %v", err)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("SetWriteEnabled timed out")
|
|
}
|
|
|
|
// Wait for all Lock() calls to complete
|
|
wg.Wait()
|
|
close(errCh)
|
|
|
|
// All Lock() calls should have returned errors (writes now disabled)
|
|
for err := range errCh {
|
|
if err == nil {
|
|
t.Error("expected Lock(RESERVED) to fail when writes are disabled")
|
|
}
|
|
}
|
|
|
|
// Verify writeEnabled is now false
|
|
f.mu.Lock()
|
|
enabled := f.writeEnabled
|
|
f.mu.Unlock()
|
|
if enabled {
|
|
t.Error("expected writeEnabled to be false")
|
|
}
|
|
}
|
|
|
|
func openWriteVFSFile(t *testing.T, vfs *VFS) *VFSFile {
|
|
t.Helper()
|
|
file, _, err := vfs.openMainDB("test.db", sqlite3vfs.OpenMainDB|sqlite3vfs.OpenReadWrite)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f := file.(*VFSFile)
|
|
t.Cleanup(func() { f.Close() })
|
|
return f
|
|
}
|
|
|
|
func TestVFS_MultipleConnections_NoFalseConflict(t *testing.T) {
|
|
client := newWriteTestReplicaClient()
|
|
pageSize := uint32(4096)
|
|
initialPage := make([]byte, pageSize)
|
|
createTestLTXFile(t, client, 1, pageSize, 1, map[uint32][]byte{1: initialPage})
|
|
|
|
v := NewVFS(client, slog.Default())
|
|
v.WriteEnabled = true
|
|
v.WriteSyncInterval = 0
|
|
|
|
f1 := openWriteVFSFile(t, v)
|
|
f2 := openWriteVFSFile(t, v)
|
|
|
|
// Connection 1: acquire RESERVED, write, sync
|
|
if err := f1.Lock(sqlite3vfs.LockReserved); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := f1.WriteAt([]byte("data1"), 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := f1.Unlock(sqlite3vfs.LockShared); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := f1.Sync(0); err != nil {
|
|
t.Fatalf("connection 1 sync failed: %v", err)
|
|
}
|
|
if f1.expectedTXID != 2 {
|
|
t.Fatalf("expected f1.expectedTXID=2, got %d", f1.expectedTXID)
|
|
}
|
|
|
|
// Connection 2: acquire RESERVED (should refresh TXID), write, sync
|
|
if err := f2.Lock(sqlite3vfs.LockReserved); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if f2.expectedTXID != 2 {
|
|
t.Fatalf("expected f2.expectedTXID=2 after RESERVED lock refresh, got %d", f2.expectedTXID)
|
|
}
|
|
if _, err := f2.WriteAt([]byte("data2"), 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := f2.Unlock(sqlite3vfs.LockShared); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := f2.Sync(0); err != nil {
|
|
t.Fatalf("connection 2 sync failed (false conflict): %v", err)
|
|
}
|
|
if f2.expectedTXID != 3 {
|
|
t.Fatalf("expected f2.expectedTXID=3, got %d", f2.expectedTXID)
|
|
}
|
|
}
|
|
|
|
func TestVFS_WriteLockBlocksConcurrentWriters(t *testing.T) {
|
|
client := newWriteTestReplicaClient()
|
|
pageSize := uint32(4096)
|
|
initialPage := make([]byte, pageSize)
|
|
createTestLTXFile(t, client, 1, pageSize, 1, map[uint32][]byte{1: initialPage})
|
|
|
|
v := NewVFS(client, slog.Default())
|
|
v.WriteEnabled = true
|
|
|
|
f1 := openWriteVFSFile(t, v)
|
|
f2 := openWriteVFSFile(t, v)
|
|
|
|
// f1 acquires RESERVED
|
|
if err := f1.Lock(sqlite3vfs.LockReserved); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// f2 attempts RESERVED - should get BusyError
|
|
err := f2.Lock(sqlite3vfs.LockReserved)
|
|
if !errors.Is(err, sqlite3vfs.BusyError) {
|
|
t.Fatalf("expected BusyError, got %v", err)
|
|
}
|
|
|
|
// f1 releases
|
|
if err := f1.Unlock(sqlite3vfs.LockShared); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// f2 should now succeed
|
|
if err := f2.Lock(sqlite3vfs.LockReserved); err != nil {
|
|
t.Fatalf("expected f2 to acquire RESERVED after f1 released, got %v", err)
|
|
}
|
|
if err := f2.Unlock(sqlite3vfs.LockShared); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestVFS_ConcurrentOpenAllSucceed(t *testing.T) {
|
|
client := newWriteTestReplicaClient()
|
|
pageSize := uint32(4096)
|
|
initialPage := make([]byte, pageSize)
|
|
createTestLTXFile(t, client, 1, pageSize, 1, map[uint32][]byte{1: initialPage})
|
|
|
|
v := NewVFS(client, slog.Default())
|
|
v.WriteEnabled = true
|
|
|
|
const n = 10
|
|
var wg sync.WaitGroup
|
|
errs := make([]error, n)
|
|
files := make([]sqlite3vfs.File, n)
|
|
|
|
for i := range n {
|
|
wg.Add(1)
|
|
go func(idx int) {
|
|
defer wg.Done()
|
|
f, _, err := v.openMainDB("test.db", sqlite3vfs.OpenMainDB|sqlite3vfs.OpenReadWrite)
|
|
errs[idx] = err
|
|
files[idx] = f
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
|
|
var opened int
|
|
for i, err := range errs {
|
|
if err != nil {
|
|
t.Errorf("connection %d failed to open: %v", i, err)
|
|
} else {
|
|
opened++
|
|
files[i].(io.Closer).Close()
|
|
}
|
|
}
|
|
if opened != n {
|
|
t.Errorf("expected all %d connections to open, got %d", n, opened)
|
|
}
|
|
}
|
|
|
|
func TestVFS_UniqueBufferPaths(t *testing.T) {
|
|
client := newWriteTestReplicaClient()
|
|
pageSize := uint32(4096)
|
|
initialPage := make([]byte, pageSize)
|
|
createTestLTXFile(t, client, 1, pageSize, 1, map[uint32][]byte{1: initialPage})
|
|
|
|
v := NewVFS(client, slog.Default())
|
|
v.WriteEnabled = true
|
|
|
|
f1 := openWriteVFSFile(t, v)
|
|
f2 := openWriteVFSFile(t, v)
|
|
|
|
if f1.bufferPath == f2.bufferPath {
|
|
t.Errorf("buffer paths should be unique: both are %q", f1.bufferPath)
|
|
}
|
|
}
|
|
|
|
func TestVFS_RealConflict_StillDetected(t *testing.T) {
|
|
client := newWriteTestReplicaClient()
|
|
pageSize := uint32(4096)
|
|
initialPage := make([]byte, pageSize)
|
|
createTestLTXFile(t, client, 1, pageSize, 1, map[uint32][]byte{1: initialPage})
|
|
|
|
v := NewVFS(client, slog.Default())
|
|
v.WriteEnabled = true
|
|
v.WriteSyncInterval = 0
|
|
|
|
f1 := openWriteVFSFile(t, v)
|
|
|
|
// Write dirty data
|
|
if _, err := f1.WriteAt([]byte("data"), 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Simulate external writer advancing remote
|
|
createTestLTXFile(t, client, 2, pageSize, 1, map[uint32][]byte{1: initialPage})
|
|
|
|
// Sync should fail with real conflict
|
|
err := f1.Sync(0)
|
|
if err == nil {
|
|
t.Fatal("expected conflict error from external writer")
|
|
}
|
|
if !errors.Is(err, ErrConflict) {
|
|
t.Fatalf("expected ErrConflict, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestVFS_CloseReleasesWriteSlot(t *testing.T) {
|
|
client := newWriteTestReplicaClient()
|
|
pageSize := uint32(4096)
|
|
initialPage := make([]byte, pageSize)
|
|
createTestLTXFile(t, client, 1, pageSize, 1, map[uint32][]byte{1: initialPage})
|
|
|
|
v := NewVFS(client, slog.Default())
|
|
v.WriteEnabled = true
|
|
|
|
// Open and acquire RESERVED
|
|
file1, _, err := v.openMainDB("test.db", sqlite3vfs.OpenMainDB|sqlite3vfs.OpenReadWrite)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f1 := file1.(*VFSFile)
|
|
if err := f1.Lock(sqlite3vfs.LockReserved); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Close f1 (should release write slot)
|
|
f1.Close()
|
|
|
|
// New connection should be able to acquire RESERVED
|
|
f2 := openWriteVFSFile(t, v)
|
|
if err := f2.Lock(sqlite3vfs.LockReserved); err != nil {
|
|
t.Fatalf("expected f2 to acquire RESERVED after f1 closed, got %v", err)
|
|
}
|
|
if err := f2.Unlock(sqlite3vfs.LockShared); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|