Files
replicate/replica.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

1869 lines
53 KiB
Go

package replicate
import (
"context"
"crypto/rand"
"database/sql"
"errors"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/hanzoai/ltx"
"github.com/luxfi/age"
"github.com/hanzoai/replicate/internal"
)
// Default replica settings.
const (
DefaultSyncInterval = 1 * time.Second
)
// Replica connects a database to a replication destination via a ReplicaClient.
// The replica manages periodic synchronization and maintaining the current
// replica position.
type Replica struct {
db *DB
mu sync.RWMutex
pos ltx.Pos // current replicated position
syncMu sync.Mutex // protects Sync() from concurrent calls
muf sync.Mutex
f *os.File // long-running file descriptor to avoid non-OFD lock issues
wg sync.WaitGroup
cancel func()
// Client used to connect to the remote replica.
Client ReplicaClient
// Time between syncs with the shadow WAL.
SyncInterval time.Duration
// If true, replica monitors database for changes automatically.
// Set to false if replica is being used synchronously (such as in tests).
MonitorEnabled bool
// If true, automatically reset local state when LTX errors are detected.
// This allows recovery from corrupted/missing LTX files by resetting
// the position file and removing local LTX files, forcing a fresh sync.
// Disabled by default to prevent silent data loss scenarios.
AutoRecoverEnabled bool
// Encryption identities and recipients for age (X25519) E2E encryption.
// AgeRecipients: public keys used to encrypt data on write (sync, snapshot).
// AgeIdentities: private keys used to decrypt data on read (restore, calcPos).
AgeIdentities []age.Identity
AgeRecipients []age.Recipient
}
func NewReplica(db *DB) *Replica {
r := &Replica{
db: db,
cancel: func() {},
SyncInterval: DefaultSyncInterval,
MonitorEnabled: true,
}
return r
}
func NewReplicaWithClient(db *DB, client ReplicaClient) *Replica {
r := NewReplica(db)
r.Client = client
return r
}
// Logger returns the DB sub-logger for this replica.
func (r *Replica) Logger() *slog.Logger {
logger := slog.Default()
if r.db != nil {
logger = r.db.Logger
}
return logger.With("replica", r.Client.Type())
}
// DB returns a reference to the database the replica is attached to, if any.
func (r *Replica) DB() *DB { return r.db }
// Starts replicating in a background goroutine.
func (r *Replica) Start(ctx context.Context) error {
// Ignore if replica is being used sychronously.
if !r.MonitorEnabled {
return nil
}
// Stop previous replication.
r.Stop(false)
// Wrap context with cancelation.
ctx, r.cancel = context.WithCancel(ctx)
// Start goroutine to replicate data.
r.wg.Add(1)
go func() { defer r.wg.Done(); r.monitor(ctx) }()
return nil
}
// Stop cancels any outstanding replication and blocks until finished.
//
// Performing a hard stop will close the DB file descriptor which could release
// locks on per-process locks. Hard stops should only be performed when
// stopping the entire process.
func (r *Replica) Stop(hard bool) (err error) {
r.cancel()
r.wg.Wait()
r.muf.Lock()
defer r.muf.Unlock()
if hard && r.f != nil {
if e := r.f.Close(); e != nil && err == nil {
err = e
}
}
return err
}
// Sync copies new WAL frames from the shadow WAL to the replica client.
// Only one Sync can run at a time to prevent concurrent uploads of the same file.
func (r *Replica) Sync(ctx context.Context) (err error) {
r.syncMu.Lock()
defer r.syncMu.Unlock()
// Clear last position if if an error occurs during sync.
defer func() {
if err != nil {
r.mu.Lock()
r.pos = ltx.Pos{}
r.mu.Unlock()
}
}()
// Calculate current replica position, if unknown.
if r.Pos().IsZero() {
pos, err := r.calcPos(ctx)
if err != nil {
return fmt.Errorf("calc pos: %w", err)
}
r.SetPos(pos)
}
// Find current position of database.
dpos, err := r.db.Pos()
if err != nil {
return fmt.Errorf("cannot determine current position: %w", err)
} else if dpos.IsZero() {
return fmt.Errorf("no position, waiting for data")
}
r.Logger().Info("replica sync",
slog.Group("txid",
slog.String("replica", r.Pos().TXID.String()),
slog.String("db", dpos.TXID.String()),
))
// Replicate all L0 LTX files since last replica position.
for txID := r.Pos().TXID + 1; txID <= dpos.TXID; txID = r.Pos().TXID + 1 {
if err := r.uploadLTXFile(ctx, 0, txID, txID); err != nil {
return err
}
r.SetPos(ltx.Pos{TXID: txID})
}
// Record successful sync for heartbeat monitoring.
r.db.RecordSuccessfulSync()
return nil
}
func (r *Replica) uploadLTXFile(ctx context.Context, level int, minTXID, maxTXID ltx.TXID) (err error) {
filename := r.db.LTXPath(level, minTXID, maxTXID)
f, err := os.Open(filename)
if err != nil {
return NewLTXError("open", filename, level, uint64(minTXID), uint64(maxTXID), err)
}
defer func() { _ = f.Close() }()
info, err := r.WriteLTXFile(ctx, level, minTXID, maxTXID, f)
if err != nil {
return fmt.Errorf("write ltx file: %w", err)
}
r.Logger().Info("ltx file uploaded",
"level", info.Level,
"minTXID", info.MinTXID,
"maxTXID", info.MaxTXID,
"size", info.Size)
// Track current position
//replicaWALIndexGaugeVec.WithLabelValues(r.db.Path(), r.Name()).Set(float64(rd.Pos().Index))
//replicaWALOffsetGaugeVec.WithLabelValues(r.db.Path(), r.Name()).Set(float64(rd.Pos().Offset))
return nil
}
// calcPos returns the last position saved to the replica for level 0.
func (r *Replica) calcPos(ctx context.Context) (pos ltx.Pos, err error) {
info, err := r.MaxLTXFileInfo(ctx, 0)
if err != nil {
return pos, fmt.Errorf("max ltx file: %w", err)
}
return ltx.Pos{TXID: info.MaxTXID}, nil
}
// MaxLTXFileInfo returns metadata about the last LTX file for a given level.
// Returns nil if no files exist for the level.
func (r *Replica) MaxLTXFileInfo(ctx context.Context, level int) (info ltx.FileInfo, err error) {
// Normal operation - use fast timestamps
itr, err := r.Client.LTXFiles(ctx, level, 0, false)
if err != nil {
return info, err
}
defer itr.Close()
for itr.Next() {
item := itr.Item()
if item.MaxTXID > info.MaxTXID {
info = *item
}
}
return info, itr.Close()
}
// Pos returns the current replicated position.
// Returns a zero value if the current position cannot be determined.
func (r *Replica) Pos() ltx.Pos {
r.mu.RLock()
defer r.mu.RUnlock()
return r.pos
}
// SetPos sets the current replicated position.
func (r *Replica) SetPos(pos ltx.Pos) {
r.mu.Lock()
defer r.mu.Unlock()
r.pos = pos
}
// EncryptionEnabled returns true if age encryption is configured for writes.
func (r *Replica) EncryptionEnabled() bool {
return len(r.AgeRecipients) > 0
}
// DecryptionEnabled returns true if age decryption is configured for reads.
func (r *Replica) DecryptionEnabled() bool {
return len(r.AgeIdentities) > 0
}
// OpenLTXFile opens a remote LTX file, decrypting it if age identities are set.
// This should be used instead of Client.OpenLTXFile when the caller needs
// plaintext data (all Replica read paths).
func (r *Replica) OpenLTXFile(ctx context.Context, level int, minTXID, maxTXID ltx.TXID, offset, size int64) (io.ReadCloser, error) {
rc, err := r.Client.OpenLTXFile(ctx, level, minTXID, maxTXID, offset, size)
if err != nil {
return nil, err
}
if !r.DecryptionEnabled() {
return rc, nil
}
dr, err := age.Decrypt(rc, r.AgeIdentities...)
if err != nil {
rc.Close()
return nil, fmt.Errorf("age decrypt: %w", err)
}
return &decryptReadCloser{Reader: dr, closer: rc}, nil
}
// WriteLTXFile writes an LTX file to the replica, encrypting it if age
// recipients are set. This should be used instead of Client.WriteLTXFile when
// the caller provides plaintext data (all Replica write paths).
func (r *Replica) WriteLTXFile(ctx context.Context, level int, minTXID, maxTXID ltx.TXID, rd io.Reader) (*ltx.FileInfo, error) {
if !r.EncryptionEnabled() {
return r.Client.WriteLTXFile(ctx, level, minTXID, maxTXID, rd)
}
pr, pw := io.Pipe()
var encErr error
go func() {
ew, err := age.Encrypt(pw, r.AgeRecipients...)
if err != nil {
pw.CloseWithError(fmt.Errorf("age encrypt: %w", err))
return
}
if _, err := io.Copy(ew, rd); err != nil {
encErr = err
pw.CloseWithError(err)
return
}
if err := ew.Close(); err != nil {
encErr = err
pw.CloseWithError(err)
return
}
pw.Close()
}()
info, err := r.Client.WriteLTXFile(ctx, level, minTXID, maxTXID, pr)
if err != nil {
pr.Close()
if encErr != nil {
return nil, fmt.Errorf("write ltx file: %w (encryption: %v)", err, encErr)
}
return nil, err
}
return info, nil
}
// decryptReadCloser wraps an age decrypted reader with the underlying
// io.ReadCloser so that Close() releases the remote connection.
type decryptReadCloser struct {
io.Reader
closer io.Closer
}
func (d *decryptReadCloser) Close() error {
return d.closer.Close()
}
// EnforceRetention forces a new snapshot once the retention interval has passed.
// Older snapshots and WAL files are then removed.
func (r *Replica) EnforceRetention(ctx context.Context) (err error) {
return fmt.Errorf("replicate: enforce retention not yet re-implemented after multi-level compaction")
/*
// Obtain list of snapshots that are within the retention period.
snapshots, err := r.Snapshots(ctx)
if err != nil {
return fmt.Errorf("snapshots: %w", err)
}
retained := FilterSnapshotsAfter(snapshots, time.Now().Add(-r.Retention))
// If no retained snapshots exist, create a new snapshot.
if len(retained) == 0 {
snapshot, err := r.Snapshot(ctx)
if err != nil {
return fmt.Errorf("snapshot: %w", err)
}
retained = append(retained, snapshot)
}
// Delete unretained snapshots & WAL files.
snapshot := FindMinSnapshot(retained)
// Otherwise remove all earlier snapshots & WAL segments.
if err := r.deleteSnapshotsBeforeIndex(ctx, snapshot.Index); err != nil {
return fmt.Errorf("delete snapshots before index: %w", err)
} else if err := r.deleteWALSegmentsBeforeIndex(ctx, snapshot.Index); err != nil {
return fmt.Errorf("delete wal segments before index: %w", err)
}
return nil
*/
}
/*
func (r *Replica) deleteBeforeTXID(ctx context.Context, level int, txID ltx.TXID) error {
itr, err := r.Client.LTXFiles(ctx, level)
if err != nil {
return fmt.Errorf("fetch ltx files: %w", err)
}
defer itr.Close()
var a []*ltx.FileInfo
for itr.Next() {
info := itr.Item()
if info.MinTXID >= txID {
continue
}
a = append(a, info)
}
if err := itr.Close(); err != nil {
return err
}
if len(a) == 0 {
return nil
}
if err := r.Client.DeleteLTXFiles(ctx, a); err != nil {
return fmt.Errorf("delete wal segments: %w", err)
}
r.Logger().Info("ltx files deleted before",
slog.Int("level", level),
slog.String("txID", txID.String()),
slog.Int("n", len(a)))
return nil
}
*/
// monitor runs in a separate goroutine and continuously replicates the DB.
// Implements exponential backoff on repeated sync errors to prevent log spam
// and reduce load when persistent errors occur. See issue #927.
func (r *Replica) monitor(ctx context.Context) {
ticker := time.NewTicker(r.SyncInterval)
defer ticker.Stop()
// Continuously check for new data to replicate.
ch := make(chan struct{})
close(ch)
var notify <-chan struct{} = ch
var backoff time.Duration
var lastLogTime time.Time
var consecutiveErrs int
for initial := true; ; initial = false {
// Enforce a minimum time between synchronization.
if !initial {
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
// If in backoff mode, wait additional time before retrying.
if backoff > 0 {
select {
case <-ctx.Done():
return
case <-time.After(backoff):
}
}
// Wait for changes to the database.
select {
case <-ctx.Done():
return
case <-notify:
}
// Fetch new notify channel before replicating data.
notify = r.db.Notify()
// Synchronize the shadow wal into the replication directory.
if err := r.Sync(ctx); err != nil {
// Don't log context cancellation errors during shutdown
if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
consecutiveErrs++
// Exponential backoff: SyncInterval -> 2x -> 4x -> ... -> max
if backoff == 0 {
backoff = r.SyncInterval
} else {
backoff *= 2
if backoff > DefaultSyncBackoffMax {
backoff = DefaultSyncBackoffMax
}
}
// Check for LTX errors and include recovery hints
var ltxErr *LTXError
if errors.As(err, &ltxErr) {
// Log with rate limiting to avoid log spam during persistent errors.
if time.Since(lastLogTime) >= SyncErrorLogInterval {
if ltxErr.Hint != "" {
r.Logger().Error("monitor error",
"error", err,
"path", ltxErr.Path,
"hint", ltxErr.Hint,
"consecutive_errors", consecutiveErrs,
"backoff", backoff)
} else {
r.Logger().Error("monitor error",
"error", err,
"path", ltxErr.Path,
"consecutive_errors", consecutiveErrs,
"backoff", backoff)
}
lastLogTime = time.Now()
}
// Attempt auto-recovery only for missing/corrupt LTX files.
// Transient OS errors (EMFILE, EIO, EACCES) should retry
// with backoff rather than destructively resetting state.
if r.AutoRecoverEnabled && ltxErr.IsAutoRecoverable() {
r.Logger().Warn("auto-recovery enabled, resetting local state")
if resetErr := r.db.ResetLocalState(ctx); resetErr != nil {
r.Logger().Error("auto-recovery failed", "error", resetErr)
} else {
r.Logger().Info("auto-recovery complete, resuming replication")
// Reset backoff after successful recovery
backoff = 0
consecutiveErrs = 0
}
}
} else {
// Log with rate limiting to avoid log spam during persistent errors.
if time.Since(lastLogTime) >= SyncErrorLogInterval {
r.Logger().Error("monitor error",
"error", err,
"consecutive_errors", consecutiveErrs,
"backoff", backoff)
lastLogTime = time.Now()
}
}
}
continue
}
// Success - reset backoff and error counter.
if consecutiveErrs > 0 {
r.Logger().Info("replica sync recovered", "previous_errors", consecutiveErrs)
}
backoff = 0
consecutiveErrs = 0
}
}
// CreatedAt returns the earliest creation time of any LTX file.
// Returns zero time if no LTX files exist.
func (r *Replica) CreatedAt(ctx context.Context) (time.Time, error) {
var min time.Time
// Normal operation - use fast timestamps
itr, err := r.Client.LTXFiles(ctx, 0, 0, false)
if err != nil {
return min, err
}
defer itr.Close()
if itr.Next() {
min = itr.Item().CreatedAt
}
return min, itr.Close()
}
// TimeBounds returns the creation time & last updated time.
// Returns zero time if no LTX files exist.
func (r *Replica) TimeBounds(ctx context.Context) (createdAt, updatedAt time.Time, err error) {
for level := SnapshotLevel; level >= 0; level-- {
itr, err := r.Client.LTXFiles(ctx, level, 0, false)
if err != nil {
return createdAt, updatedAt, err
}
for itr.Next() {
info := itr.Item()
if createdAt.IsZero() || info.CreatedAt.Before(createdAt) {
createdAt = info.CreatedAt
}
if updatedAt.IsZero() || info.CreatedAt.After(updatedAt) {
updatedAt = info.CreatedAt
}
}
if err := itr.Close(); err != nil {
return createdAt, updatedAt, err
}
}
return createdAt, updatedAt, nil
}
// CalcRestoreTarget returns a target time restore from.
func (r *Replica) CalcRestoreTarget(ctx context.Context, opt RestoreOptions) (updatedAt time.Time, err error) {
// Determine the replicated time bounds from LTX files.
createdAt, updatedAt, err := r.TimeBounds(ctx)
if err != nil {
return time.Time{}, fmt.Errorf("created at: %w", err)
}
// Also check v0.3.x time bounds if client supports it.
if client, ok := r.Client.(ReplicaClientV3); ok {
v3CreatedAt, v3UpdatedAt, err := r.TimeBoundsV3(ctx, client)
if err != nil {
return time.Time{}, fmt.Errorf("v0.3.x time bounds: %w", err)
}
// Extend time bounds to include v0.3.x backups.
if !v3CreatedAt.IsZero() && (createdAt.IsZero() || v3CreatedAt.Before(createdAt)) {
createdAt = v3CreatedAt
}
if !v3UpdatedAt.IsZero() && (updatedAt.IsZero() || v3UpdatedAt.After(updatedAt)) {
updatedAt = v3UpdatedAt
}
}
// Skip if it does not contain timestamp.
if !opt.Timestamp.IsZero() {
if createdAt.IsZero() && updatedAt.IsZero() {
return time.Time{}, fmt.Errorf("no backups found")
}
if opt.Timestamp.Before(createdAt) || opt.Timestamp.After(updatedAt) {
return time.Time{}, fmt.Errorf("timestamp does not exist")
}
}
return updatedAt, nil
}
// Replica restores the database from a replica based on the options given.
// This method will restore into opt.OutputPath, if specified, or into the
// DB's original database path. It can optionally restore from a specific
// replica or it will automatically choose the best one. Finally,
// a timestamp can be specified to restore the database to a specific
// point-in-time.
//
// When the replica contains both v0.3.x and LTX format backups, this method
// compares snapshots from both formats and uses whichever has the better backup:
// - With timestamp: uses the format with the most recent snapshot before timestamp
// - Without timestamp: uses the format with the most recent backup overall
func (r *Replica) Restore(ctx context.Context, opt RestoreOptions) (err error) {
// Validate options.
if opt.OutputPath == "" {
return fmt.Errorf("output path required")
} else if opt.TXID != 0 && !opt.Timestamp.IsZero() {
return fmt.Errorf("cannot specify index & timestamp to restore")
} else if opt.Follow && opt.TXID != 0 {
return fmt.Errorf("cannot use follow mode with -txid")
} else if opt.Follow && !opt.Timestamp.IsZero() {
return fmt.Errorf("cannot use follow mode with -timestamp")
} else if opt.IntegrityCheck != IntegrityCheckNone && opt.IntegrityCheck != IntegrityCheckQuick && opt.IntegrityCheck != IntegrityCheckFull {
return fmt.Errorf("unsupported integrity check mode: %d", opt.IntegrityCheck)
}
// In follow mode, if the database already exists, attempt crash recovery
// by reading the last applied TXID from the sidecar file.
if opt.Follow {
if _, statErr := os.Stat(opt.OutputPath); statErr == nil {
txid, readErr := ReadTXIDFile(opt.OutputPath)
if readErr != nil {
return fmt.Errorf("read txid file for crash recovery: %w", readErr)
}
if txid == 0 {
return fmt.Errorf("cannot resume follow mode: database exists but no -txid file found; delete the database to re-restore: %s", opt.OutputPath)
}
// Validate saved TXID is still reachable. If the earliest snapshot
// starts after our saved TXID, retention has pruned the history
// and we can't catch up incrementally.
snapshotItr, itrErr := r.Client.LTXFiles(ctx, SnapshotLevel, 0, false)
if itrErr != nil {
return fmt.Errorf("cannot validate saved TXID for crash recovery: %w", itrErr)
}
var latestSnapshot *ltx.FileInfo
for snapshotItr.Next() {
latestSnapshot = snapshotItr.Item()
}
if err := snapshotItr.Err(); err != nil {
_ = snapshotItr.Close()
return fmt.Errorf("iterate snapshots for crash recovery validation: %w", err)
}
_ = snapshotItr.Close()
if latestSnapshot != nil {
if latestSnapshot.MinTXID > txid {
return fmt.Errorf("cannot resume follow mode: saved TXID %s is behind the earliest snapshot (min TXID %s); replica history has been pruned -- delete %s and %s-txid to re-restore", txid, latestSnapshot.MinTXID, opt.OutputPath, opt.OutputPath)
}
if txid > latestSnapshot.MaxTXID {
return fmt.Errorf("cannot resume follow mode: saved TXID %s is ahead of latest snapshot (max TXID %s); delete %s and %s-txid to re-restore", txid, latestSnapshot.MaxTXID, opt.OutputPath, opt.OutputPath)
}
}
r.Logger().Info("resuming follow mode from crash recovery", "txid", txid, "output", opt.OutputPath)
return r.follow(ctx, opt.OutputPath, txid, opt.FollowInterval)
}
}
// Ensure output path does not already exist.
if _, err := os.Stat(opt.OutputPath); err == nil {
return fmt.Errorf("cannot restore, output path already exists: %s", opt.OutputPath)
} else if !os.IsNotExist(err) {
return err
}
// Compare v0.3.x and LTX formats to find the best backup (unless TXID is specified).
// Skip V3 format when follow mode is enabled (V3 doesn't support incremental following).
if opt.TXID == 0 && !opt.Follow {
if client, ok := r.Client.(ReplicaClientV3); ok {
useV3, err := r.shouldUseV3Restore(ctx, client, opt.Timestamp)
if err != nil {
return err
}
if useV3 {
return r.RestoreV3(ctx, opt)
}
}
}
infos, err := CalcRestorePlan(ctx, r.Client, opt.TXID, opt.Timestamp, r.Logger())
if err != nil {
return fmt.Errorf("cannot calc restore plan: %w", err)
}
r.Logger().Debug("restore plan", "n", len(infos), "txid", infos[len(infos)-1].MaxTXID, "timestamp", infos[len(infos)-1].CreatedAt)
rdrs := make([]io.Reader, 0, len(infos))
defer func() {
for _, rd := range rdrs {
if closer, ok := rd.(io.Closer); ok {
_ = closer.Close()
}
}
}()
for _, info := range infos {
// Validate file size - must be at least header size to be readable
if info.Size < ltx.HeaderSize {
return fmt.Errorf("invalid ltx file: level=%d min=%s max=%s has size %d bytes (minimum %d)",
info.Level, info.MinTXID, info.MaxTXID, info.Size, ltx.HeaderSize)
}
r.Logger().Debug("opening ltx file for restore", "level", info.Level, "min", info.MinTXID, "max", info.MaxTXID)
// Add file to be compacted. When age decryption is enabled, byte-offset
// resumption is incompatible with stream decryption, so we skip the
// ResumableReader and use a direct decrypting reader instead.
f, err := r.OpenLTXFile(ctx, info.Level, info.MinTXID, info.MaxTXID, 0, 0)
if err != nil {
return fmt.Errorf("open ltx file: %w", err)
}
if r.DecryptionEnabled() {
rdrs = append(rdrs, f)
} else {
rdrs = append(rdrs, internal.NewResumableReader(ctx, r.Client, info.Level, info.MinTXID, info.MaxTXID, info.Size, f, r.Logger()))
}
}
if len(rdrs) == 0 {
return fmt.Errorf("no matching backup files available")
}
// Create parent directory if it doesn't exist.
var dirInfo os.FileInfo
if db := r.DB(); db != nil {
dirInfo = db.dirInfo
}
if err := internal.MkdirAll(filepath.Dir(opt.OutputPath), dirInfo); err != nil {
return fmt.Errorf("create parent directory: %w", err)
}
// Output to temp file & atomically rename.
tmpOutputPath := opt.OutputPath + ".tmp"
r.Logger().Debug("compacting into database", "path", tmpOutputPath, "n", len(rdrs))
f, err := os.Create(tmpOutputPath)
if err != nil {
return fmt.Errorf("create temp database path: %w", err)
}
defer func() { _ = f.Close() }()
pr, pw := io.Pipe()
go func() {
c, err := ltx.NewCompactor(pw, rdrs)
if err != nil {
pw.CloseWithError(fmt.Errorf("new ltx compactor: %w", err))
return
}
c.HeaderFlags = ltx.HeaderFlagNoChecksum
_ = pw.CloseWithError(c.Compact(ctx))
}()
dec := ltx.NewDecoder(pr)
if err := dec.DecodeDatabaseTo(f); err != nil {
return fmt.Errorf("decode database: %w", err)
}
if err := f.Sync(); err != nil {
return err
} else if err := f.Close(); err != nil {
return err
}
// Copy file to final location.
r.Logger().Debug("renaming database from temporary location")
if err := os.Rename(tmpOutputPath, opt.OutputPath); err != nil {
return err
}
if opt.IntegrityCheck != IntegrityCheckNone {
if err := checkIntegrity(ctx, opt.OutputPath, opt.IntegrityCheck); err != nil {
if ctx.Err() == nil {
_ = os.Remove(opt.OutputPath)
_ = os.Remove(opt.OutputPath + "-shm")
_ = os.Remove(opt.OutputPath + "-wal")
}
return fmt.Errorf("post-restore integrity check: %w", err)
}
r.Logger().Info("post-restore integrity check passed")
}
// Enter follow mode if enabled, continuously applying new LTX files.
if opt.Follow {
for _, rd := range rdrs {
if closer, ok := rd.(io.Closer); ok {
_ = closer.Close()
}
}
rdrs = nil
maxTXID := infos[len(infos)-1].MaxTXID
if err := WriteTXIDFile(opt.OutputPath, maxTXID); err != nil {
return fmt.Errorf("write initial txid file: %w", err)
}
return r.follow(ctx, opt.OutputPath, maxTXID, opt.FollowInterval)
}
return nil
}
// follow enters a continuous restore loop, polling for new LTX files and
// applying them to the restored database. It blocks until the context is
// cancelled (e.g. Ctrl+C). Returns nil on clean shutdown.
func (r *Replica) follow(ctx context.Context, outputPath string, lastTXID ltx.TXID, interval time.Duration) error {
f, err := os.OpenFile(outputPath, os.O_RDWR, 0)
if err != nil {
return fmt.Errorf("open database for follow: %w", err)
}
defer func() {
_ = f.Sync()
_ = f.Close()
}()
// Read page size from SQLite header (offset 16, 2 bytes, big-endian).
var buf [2]byte
if _, err := f.ReadAt(buf[:], 16); err != nil {
return fmt.Errorf("read page size from database header: %w", err)
}
pageSize := uint32(buf[0])<<8 | uint32(buf[1])
if pageSize == 1 {
pageSize = 65536
}
if interval <= 0 {
interval = DefaultFollowInterval
}
r.Logger().Info("entering follow mode", "output", outputPath, "txid", lastTXID, "interval", interval)
ticker := time.NewTicker(interval)
defer ticker.Stop()
var consecutiveErrors int
for {
select {
case <-ctx.Done():
r.Logger().Info("follow mode stopped")
return nil
case <-ticker.C:
newTXID, err := r.applyNewLTXFiles(ctx, f, lastTXID, pageSize)
if err != nil {
if ctx.Err() != nil {
r.Logger().Info("follow mode stopped")
return nil
}
consecutiveErrors++
r.Logger().Error("follow: error applying updates", "err", err, "consecutive_errors", consecutiveErrors)
continue
}
if newTXID > lastTXID {
if err := WriteTXIDFile(outputPath, newTXID); err != nil {
return fmt.Errorf("write txid file: %w", err)
}
r.Logger().Info("follow: applied updates", "from_txid", lastTXID, "to_txid", newTXID)
lastTXID = newTXID
consecutiveErrors = 0
}
}
}
}
// applyNewLTXFiles polls for new LTX files and applies them to the database.
// It starts from level 0 and falls back to higher levels if there are gaps
// (e.g., level 0 files were compacted away).
func (r *Replica) applyNewLTXFiles(ctx context.Context, f *os.File, afterTXID ltx.TXID, pageSize uint32) (ltx.TXID, error) {
currentTXID := afterTXID
// Poll level 0 for the most recent incremental files.
itr, err := r.Client.LTXFiles(ctx, 0, currentTXID+1, false)
if err != nil {
return currentTXID, fmt.Errorf("list level 0 ltx files: %w", err)
}
closeLevel0 := func(retErr error) (ltx.TXID, error) {
if closeErr := itr.Close(); closeErr != nil {
closeErr = fmt.Errorf("close level 0 ltx iterator: %w", closeErr)
if retErr != nil {
return currentTXID, errors.Join(retErr, closeErr)
}
return currentTXID, closeErr
}
return currentTXID, retErr
}
var sawLevel0 bool
for itr.Next() {
sawLevel0 = true
info := itr.Item()
// If there's a gap, try to fill it from higher compaction levels.
if info.MinTXID > currentTXID+1 {
bridgedTXID, err := r.fillFollowGap(ctx, f, currentTXID, info.MinTXID, pageSize)
if err != nil {
return closeLevel0(err)
}
currentTXID = bridgedTXID
// Re-check if this file is still needed after bridging.
if info.MaxTXID <= currentTXID {
continue
}
if info.MinTXID > currentTXID+1 {
return closeLevel0(nil)
}
}
// Skip if already covered by a higher-level file.
if info.MaxTXID <= currentTXID {
continue
}
if err := r.applyLTXFile(ctx, f, info, pageSize); err != nil {
return closeLevel0(fmt.Errorf(
"apply ltx file (level=%d, min=%s, max=%s): %w",
info.Level, info.MinTXID, info.MaxTXID, err,
))
}
currentTXID = info.MaxTXID
}
if iterErr := itr.Err(); iterErr != nil {
return closeLevel0(fmt.Errorf("iterate level 0 ltx files: %w", iterErr))
}
if _, err := closeLevel0(nil); err != nil {
return currentTXID, err
}
if !sawLevel0 {
bridgedTXID, err := r.fillFollowGap(ctx, f, currentTXID, currentTXID+1, pageSize)
if err != nil {
return currentTXID, err
}
currentTXID = bridgedTXID
}
return currentTXID, nil
}
// applyLTXFile applies a single LTX file's pages to the database file.
// This follows the same pattern as Hydrator.ApplyLTX (vfs.go:712-747).
//
// To prevent concurrent SQLite readers from seeing partial updates, we acquire
// an exclusive file lock before writing. We also rewrite the SQLite header
// (bytes 18-19) to indicate DELETE journal mode instead of WAL mode, and
// randomize the schema change counter (bytes 24-27) to invalidate cached
// schemas in other connections.
func (r *Replica) applyLTXFile(ctx context.Context, f *os.File, info *ltx.FileInfo, pageSize uint32) error {
rc, err := r.OpenLTXFile(ctx, info.Level, info.MinTXID, info.MaxTXID, 0, 0)
if err != nil {
return fmt.Errorf("open ltx file: %w", err)
}
defer rc.Close()
dec := ltx.NewDecoder(rc)
if err := dec.DecodeHeader(); err != nil {
return fmt.Errorf("decode header: %w", err)
}
hdr := dec.Header()
if err := internal.LockFileExclusive(f); err != nil {
return fmt.Errorf("acquire exclusive lock: %w", err)
}
defer internal.UnlockFile(f)
for {
var phdr ltx.PageHeader
data := make([]byte, pageSize)
if err := dec.DecodePage(&phdr, data); err == io.EOF {
break
} else if err != nil {
return fmt.Errorf("decode page: %w", err)
}
if phdr.Pgno == 1 && len(data) >= 28 {
data[18], data[19] = 0x01, 0x01
_, _ = rand.Read(data[24:28])
}
off := int64(phdr.Pgno-1) * int64(pageSize)
if _, err := f.WriteAt(data, off); err != nil {
return fmt.Errorf("write page %d: %w", phdr.Pgno, err)
}
}
if hdr.Commit > 0 {
newSize := int64(hdr.Commit) * int64(pageSize)
if err := f.Truncate(newSize); err != nil {
return fmt.Errorf("truncate: %w", err)
}
}
if err := dec.Close(); err != nil {
return fmt.Errorf("close decoder: %w", err)
}
return f.Sync()
}
// fillFollowGap attempts to bridge a gap in level 0 files by searching
// higher compaction levels for a file that covers the missing TXID range.
func (r *Replica) fillFollowGap(ctx context.Context, f *os.File, afterTXID ltx.TXID, gapMinTXID ltx.TXID, pageSize uint32) (ltx.TXID, error) {
currentTXID := afterTXID
for level := 1; level < SnapshotLevel; level++ {
itr, err := r.Client.LTXFiles(ctx, level, 0, false)
if err != nil {
return currentTXID, fmt.Errorf("list level %d ltx files: %w", level, err)
}
closeLevel := func(retErr error) (ltx.TXID, error) {
if closeErr := itr.Close(); closeErr != nil {
closeErr = fmt.Errorf("close level %d ltx iterator: %w", level, closeErr)
if retErr != nil {
return currentTXID, errors.Join(retErr, closeErr)
}
return currentTXID, closeErr
}
return currentTXID, retErr
}
for itr.Next() {
info := itr.Item()
// Skip if there's a gap at this level too.
if info.MinTXID > currentTXID+1 {
break
}
// Skip if already covered.
if info.MaxTXID <= currentTXID {
continue
}
if err := r.applyLTXFile(ctx, f, info, pageSize); err != nil {
return closeLevel(fmt.Errorf(
"apply gap-fill ltx file (level=%d, min=%s, max=%s): %w",
info.Level, info.MinTXID, info.MaxTXID, err,
))
}
currentTXID = info.MaxTXID
// If we've bridged past the gap, we're done.
if currentTXID+1 >= gapMinTXID {
return closeLevel(nil)
}
}
if iterErr := itr.Err(); iterErr != nil {
return closeLevel(fmt.Errorf("iterate level %d ltx files: %w", level, iterErr))
}
if _, err := closeLevel(nil); err != nil {
return currentTXID, err
}
// If we made progress at this level, restart from level 1.
if currentTXID > afterTXID {
return currentTXID, nil
}
}
return currentTXID, nil
}
// RestoreV3 restores from a v0.3.x format backup.
func (r *Replica) RestoreV3(ctx context.Context, opt RestoreOptions) error {
client, ok := r.Client.(ReplicaClientV3)
if !ok {
return fmt.Errorf("replica client does not support v0.3.x restore")
}
// Validate options.
if opt.OutputPath == "" {
return fmt.Errorf("output path required")
} else if opt.IntegrityCheck != IntegrityCheckNone && opt.IntegrityCheck != IntegrityCheckQuick && opt.IntegrityCheck != IntegrityCheckFull {
return fmt.Errorf("unsupported integrity check mode: %d", opt.IntegrityCheck)
}
// Ensure output path does not already exist.
if _, err := os.Stat(opt.OutputPath); err == nil {
return fmt.Errorf("cannot restore, output path already exists: %s", opt.OutputPath)
} else if !os.IsNotExist(err) {
return err
}
// Find all generations.
generations, err := client.GenerationsV3(ctx)
if err != nil {
return fmt.Errorf("list generations: %w", err)
}
if len(generations) == 0 {
return ErrNoSnapshots
}
// Collect all snapshots across all generations.
var allSnapshots []SnapshotInfoV3
for _, gen := range generations {
snapshots, err := client.SnapshotsV3(ctx, gen)
if err != nil {
return fmt.Errorf("list snapshots for generation %s: %w", gen, err)
}
allSnapshots = append(allSnapshots, snapshots...)
}
if len(allSnapshots) == 0 {
return ErrNoSnapshots
}
// Sort all snapshots by CreatedAt for timestamp-based selection.
sortSnapshotsV3ByCreatedAt(allSnapshots)
// Find best snapshot across all generations (latest, or before timestamp if specified).
snapshot := findBestSnapshotV3(allSnapshots, opt.Timestamp)
if snapshot == nil {
return ErrNoSnapshots
}
r.Logger().Debug("selected v0.3.x snapshot",
"generation", snapshot.Generation,
"index", snapshot.Index,
"created_at", snapshot.CreatedAt)
// Get WAL segments for the snapshot's generation.
segments, err := client.WALSegmentsV3(ctx, snapshot.Generation)
if err != nil {
return fmt.Errorf("list WAL segments: %w", err)
}
segments = filterWALSegmentsV3(segments, snapshot.Index, opt.Timestamp)
r.Logger().Debug("found v0.3.x WAL segments", "n", len(segments))
// Create parent directory if it doesn't exist.
var dirInfo os.FileInfo
if db := r.DB(); db != nil {
dirInfo = db.DirInfo()
}
if err := internal.MkdirAll(filepath.Dir(opt.OutputPath), dirInfo); err != nil {
return fmt.Errorf("create parent directory: %w", err)
}
// Create temp file for restore.
tmpPath := opt.OutputPath + ".tmp"
defer func() { _ = os.Remove(tmpPath) }()
// Download and decompress snapshot.
if err := r.downloadSnapshotV3(ctx, client, snapshot.Generation, snapshot.Index, tmpPath); err != nil {
return fmt.Errorf("download snapshot: %w", err)
}
// Apply WAL segments.
if err := r.applyWALSegmentsV3(ctx, client, snapshot.Generation, segments, tmpPath); err != nil {
return fmt.Errorf("apply WAL segments: %w", err)
}
// Rename to final path.
if err := os.Rename(tmpPath, opt.OutputPath); err != nil {
return fmt.Errorf("rename to output path: %w", err)
}
if opt.IntegrityCheck != IntegrityCheckNone {
if err := checkIntegrity(ctx, opt.OutputPath, opt.IntegrityCheck); err != nil {
if ctx.Err() == nil {
_ = os.Remove(opt.OutputPath)
_ = os.Remove(opt.OutputPath + "-shm")
_ = os.Remove(opt.OutputPath + "-wal")
}
return fmt.Errorf("post-restore integrity check: %w", err)
}
r.Logger().Info("post-restore integrity check passed")
}
return nil
}
// sortSnapshotsV3ByCreatedAt sorts snapshots by creation time in ascending order.
func sortSnapshotsV3ByCreatedAt(snapshots []SnapshotInfoV3) {
for i := 0; i < len(snapshots)-1; i++ {
for j := i + 1; j < len(snapshots); j++ {
if snapshots[i].CreatedAt.After(snapshots[j].CreatedAt) {
snapshots[i], snapshots[j] = snapshots[j], snapshots[i]
}
}
}
}
// findBestSnapshotV3 finds the best snapshot for restore.
// If timestamp is zero, returns the latest snapshot.
// Otherwise, returns the latest snapshot created before or at the timestamp.
func findBestSnapshotV3(snapshots []SnapshotInfoV3, timestamp time.Time) *SnapshotInfoV3 {
if len(snapshots) == 0 {
return nil
}
if timestamp.IsZero() {
return &snapshots[len(snapshots)-1]
}
for i := len(snapshots) - 1; i >= 0; i-- {
if !snapshots[i].CreatedAt.After(timestamp) {
return &snapshots[i]
}
}
return nil
}
// filterWALSegmentsV3 filters WAL segments to those at or after the snapshot index
// and optionally before the timestamp.
func filterWALSegmentsV3(segments []WALSegmentInfoV3, snapshotIndex int, timestamp time.Time) []WALSegmentInfoV3 {
var result []WALSegmentInfoV3
for _, seg := range segments {
if seg.Index < snapshotIndex {
continue
}
if !timestamp.IsZero() && seg.CreatedAt.After(timestamp) {
continue
}
result = append(result, seg)
}
return result
}
// downloadSnapshotV3 downloads and decompresses a v0.3.x snapshot to the destination path.
func (r *Replica) downloadSnapshotV3(ctx context.Context, client ReplicaClientV3, generation string, index int, destPath string) error {
rc, err := client.OpenSnapshotV3(ctx, generation, index)
if err != nil {
return err
}
defer func() { _ = rc.Close() }()
var rd io.Reader = rc
if r.DecryptionEnabled() {
dr, err := age.Decrypt(rc, r.AgeIdentities...)
if err != nil {
return fmt.Errorf("age decrypt snapshot: %w", err)
}
rd = dr
}
f, err := os.Create(destPath)
if err != nil {
return err
}
defer func() { _ = f.Close() }()
if _, err := io.Copy(f, rd); err != nil {
return err
}
return f.Sync()
}
// applyWALSegmentsV3 applies WAL segments to the database file.
func (r *Replica) applyWALSegmentsV3(ctx context.Context, client ReplicaClientV3, generation string, segments []WALSegmentInfoV3, dbPath string) error {
if len(segments) == 0 {
return nil
}
// Write all WAL segments to the WAL file.
walPath := dbPath + "-wal"
var f *os.File
var err error
defer func() {
if f != nil {
_ = f.Close()
}
}()
applyLastWalFile := func() error {
if f == nil {
return nil
} else if err = f.Close(); err != nil {
return err
}
f = nil
return checkpointV3(dbPath)
}
// Reconstruct each wal file, appending non-zero offsets, and apply them
// to the db.
for _, seg := range segments {
if seg.Offset == 0 {
// Apply the last WAL file, if any
if err = applyLastWalFile(); err != nil {
return err
}
// Open a new WAL file
if f, err = os.OpenFile(walPath, os.O_CREATE|os.O_WRONLY, 0644); err != nil {
return err
}
}
if err := r.appendWALSegmentV3(ctx, client, generation, seg, f); err != nil {
return fmt.Errorf("write WAL segment %d/%d: %w", seg.Index, seg.Offset, err)
}
r.Logger().Debug("wrote WAL segment", "generation", generation, "index", seg.Index, "offset", seg.Offset)
}
return applyLastWalFile()
}
// appendWALSegmentV3 appends the specified segment to an open WAL file f.
func (r *Replica) appendWALSegmentV3(ctx context.Context, client ReplicaClientV3, generation string, seg WALSegmentInfoV3, f *os.File) error {
// Download WAL segment.
rc, err := client.OpenWALSegmentV3(ctx, generation, seg.Index, seg.Offset)
if err != nil {
return err
}
defer func() { _ = rc.Close() }()
var rd io.Reader = rc
if r.DecryptionEnabled() {
dr, err := age.Decrypt(rc, r.AgeIdentities...)
if err != nil {
return fmt.Errorf("age decrypt WAL segment: %w", err)
}
rd = dr
}
_, err = io.Copy(f, rd)
return err
}
// checkpointV3 checkpoints the WAL file into the database.
func checkpointV3(dbPath string) error {
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return err
}
defer func() { _ = db.Close() }()
_, err = db.Exec("PRAGMA wal_checkpoint(TRUNCATE)")
return err
}
// checkIntegrity runs a SQLite integrity check on the database at dbPath.
func checkIntegrity(ctx context.Context, dbPath string, mode IntegrityCheckMode) error {
if mode == IntegrityCheckNone {
return nil
}
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return fmt.Errorf("open database for integrity check: %w", err)
}
defer func() { _ = db.Close() }()
var pragma string
switch mode {
case IntegrityCheckQuick:
pragma = "quick_check"
case IntegrityCheckFull:
pragma = "integrity_check"
default:
return fmt.Errorf("unsupported integrity check mode: %d", mode)
}
var result string
if err := db.QueryRowContext(ctx, "PRAGMA "+pragma).Scan(&result); err != nil {
return fmt.Errorf("integrity check: %w", err)
}
if result != "ok" {
return fmt.Errorf("integrity check failed: %s", result)
}
// Clean up -shm and -wal files that SQLite may create during the PRAGMA.
_ = os.Remove(dbPath + "-shm")
_ = os.Remove(dbPath + "-wal")
return nil
}
// findBestV3SnapshotForTimestamp returns the best v0.3.x snapshot for the given timestamp.
// Returns nil if no suitable snapshot exists.
func (r *Replica) findBestV3SnapshotForTimestamp(ctx context.Context, client ReplicaClientV3, timestamp time.Time) (*SnapshotInfoV3, error) {
generations, err := client.GenerationsV3(ctx)
if err != nil {
return nil, fmt.Errorf("list v0.3.x generations: %w", err)
}
if len(generations) == 0 {
return nil, nil
}
var allSnapshots []SnapshotInfoV3
for _, gen := range generations {
snapshots, err := client.SnapshotsV3(ctx, gen)
if err != nil {
return nil, fmt.Errorf("list v0.3.x snapshots for generation %s: %w", gen, err)
}
allSnapshots = append(allSnapshots, snapshots...)
}
if len(allSnapshots) == 0 {
return nil, nil
}
// Sort by CreatedAt for timestamp-based selection.
sortSnapshotsV3ByCreatedAt(allSnapshots)
return findBestSnapshotV3(allSnapshots, timestamp), nil
}
// shouldUseV3Restore determines whether to use v0.3.x restore instead of LTX.
// Returns true if v0.3.x has a better backup for the given options.
func (r *Replica) shouldUseV3Restore(ctx context.Context, client ReplicaClientV3, timestamp time.Time) (bool, error) {
// Get v0.3.x time bounds.
_, v3UpdatedAt, err := r.TimeBoundsV3(ctx, client)
if err != nil {
return false, fmt.Errorf("get v0.3.x time bounds: %w", err)
}
// Get LTX time bounds.
_, ltxUpdatedAt, err := r.TimeBounds(ctx)
if err != nil {
return false, fmt.Errorf("get LTX time bounds: %w", err)
}
// If no v0.3.x backups exist, use LTX.
if v3UpdatedAt.IsZero() {
return false, nil
}
// If no LTX backups exist, use v0.3.x.
if ltxUpdatedAt.IsZero() {
r.Logger().Debug("using v0.3.x restore (no LTX backups)")
return true, nil
}
// Both formats have backups - compare based on timestamp or latest.
if !timestamp.IsZero() {
// With timestamp: use format with best snapshot before timestamp.
v3Snapshot, err := r.findBestV3SnapshotForTimestamp(ctx, client, timestamp)
if err != nil {
return false, fmt.Errorf("find v0.3.x snapshot: %w", err)
}
ltxSnapshot, err := r.findBestLTXSnapshotForTimestamp(ctx, timestamp)
if err != nil {
return false, fmt.Errorf("find LTX snapshot: %w", err)
}
if v3Snapshot != nil && (ltxSnapshot == nil || v3Snapshot.CreatedAt.After(ltxSnapshot.CreatedAt)) {
r.Logger().Debug("using v0.3.x restore (better snapshot for timestamp)",
"v3_snapshot", v3Snapshot.CreatedAt,
"ltx_snapshot", ltxSnapshot)
return true, nil
}
} else {
// Without timestamp: use format with most recent backup.
if v3UpdatedAt.After(ltxUpdatedAt) {
r.Logger().Debug("using v0.3.x restore (more recent backup)",
"v3_updated_at", v3UpdatedAt,
"ltx_updated_at", ltxUpdatedAt)
return true, nil
}
}
return false, nil
}
// TimeBoundsV3 returns the time bounds of v0.3.x backups.
// Returns zero times if no v0.3.x backups exist.
func (r *Replica) TimeBoundsV3(ctx context.Context, client ReplicaClientV3) (createdAt, updatedAt time.Time, err error) {
generations, err := client.GenerationsV3(ctx)
if err != nil {
return time.Time{}, time.Time{}, err
}
for _, gen := range generations {
snapshots, err := client.SnapshotsV3(ctx, gen)
if err != nil {
return time.Time{}, time.Time{}, err
}
for _, snap := range snapshots {
if createdAt.IsZero() || snap.CreatedAt.Before(createdAt) {
createdAt = snap.CreatedAt
}
if updatedAt.IsZero() || snap.CreatedAt.After(updatedAt) {
updatedAt = snap.CreatedAt
}
}
segments, err := client.WALSegmentsV3(ctx, gen)
if err != nil {
return time.Time{}, time.Time{}, err
}
for _, seg := range segments {
if createdAt.IsZero() || seg.CreatedAt.Before(createdAt) {
createdAt = seg.CreatedAt
}
if updatedAt.IsZero() || seg.CreatedAt.After(updatedAt) {
updatedAt = seg.CreatedAt
}
}
}
return createdAt, updatedAt, nil
}
// findBestLTXSnapshotForTimestamp returns the best LTX snapshot for the given timestamp.
// Returns nil if no suitable snapshot exists.
func (r *Replica) findBestLTXSnapshotForTimestamp(ctx context.Context, timestamp time.Time) (*ltx.FileInfo, error) {
// Find snapshots at the snapshot level that are before the timestamp.
snapshots, err := FindLTXFiles(ctx, r.Client, SnapshotLevel, true, func(info *ltx.FileInfo) (bool, error) {
return info.CreatedAt.Before(timestamp), nil
})
if err != nil {
return nil, fmt.Errorf("find LTX snapshots: %w", err)
}
if len(snapshots) == 0 {
return nil, nil
}
// Return the latest snapshot before the timestamp (last in the sorted list).
return snapshots[len(snapshots)-1], nil
}
// CalcRestorePlan returns a list of storage paths to restore a snapshot at the given TXID.
func CalcRestorePlan(ctx context.Context, client ReplicaClient, txID ltx.TXID, timestamp time.Time, logger *slog.Logger) ([]*ltx.FileInfo, error) {
if txID != 0 && !timestamp.IsZero() {
return nil, fmt.Errorf("cannot specify both TXID & timestamp to restore")
}
var infos ltx.FileInfoSlice
logger = logger.With("target", txID)
// Start with latest snapshot before target TXID or timestamp.
// Pass useMetadata flag to enable accurate timestamp fetching for timestamp-based restore.
var snapshot *ltx.FileInfo
snapshotItr, err := client.LTXFiles(ctx, SnapshotLevel, 0, !timestamp.IsZero())
if err != nil {
return nil, err
}
for snapshotItr.Next() {
info := snapshotItr.Item()
logger.Debug("finding snapshot before target TXID or timestamp", "snapshot", info.MaxTXID)
if txID != 0 && info.MaxTXID > txID {
continue
}
if !timestamp.IsZero() && !info.CreatedAt.Before(timestamp) {
continue
}
snapshot = info
}
if err := snapshotItr.Close(); err != nil {
return nil, err
}
if snapshot != nil {
logger.Debug("found snapshot before target TXID or timestamp", "snapshot", snapshot.MaxTXID)
infos = append(infos, snapshot)
}
// Collect candidates across all compaction levels and pick the next file
// from any level that extends the longest contiguous TXID range.
const maxLevel = SnapshotLevel - 1
startTXID := infos.MaxTXID()
currentMax := startTXID
if txID != 0 && currentMax >= txID {
return infos, nil
}
cursors := make([]*restoreLevelCursor, 0, maxLevel+1)
for level := maxLevel; level >= 0; level-- {
logger.Debug("finding ltx files for level", "level", level)
itr, err := client.LTXFiles(ctx, level, 0, !timestamp.IsZero())
if err != nil {
return nil, err
}
cursors = append(cursors, &restoreLevelCursor{
itr: itr,
})
}
defer func() {
for _, cursor := range cursors {
if cursor != nil {
_ = cursor.itr.Close()
}
}
}()
for {
var next *restoreLevelCursor
for _, cursor := range cursors {
if err := cursor.refresh(currentMax, txID, timestamp); err != nil {
return nil, err
}
if cursor.candidate == nil {
continue
}
if next == nil || restoreCandidateBetter(next.candidate, cursor.candidate) {
next = cursor
}
}
if next == nil || next.candidate == nil {
break
}
if next.candidate.MaxTXID <= currentMax {
next.candidate = nil
continue
}
logger.Debug("matching LTX file for restore",
"filename", ltx.FormatFilename(next.candidate.MinTXID, next.candidate.MaxTXID),
"level", next.candidate.Level)
infos = append(infos, next.candidate)
currentMax = next.candidate.MaxTXID
next.candidate = nil
if txID != 0 && currentMax >= txID {
break
}
}
if len(infos) > 0 && txID == 0 && timestamp.IsZero() {
for _, cursor := range cursors {
if err := cursor.ensureCurrent(); err != nil {
return nil, err
}
if cursor.current != nil && cursor.current.MinTXID > currentMax+1 {
return nil, fmt.Errorf("non-contiguous ltx files: have up to %s but next file starts at %s", currentMax, cursor.current.MinTXID)
}
}
}
if len(infos) == 0 {
return nil, ErrTxNotAvailable
}
if txID != 0 && infos.MaxTXID() < txID {
return nil, ErrTxNotAvailable
}
return infos, nil
}
type restoreLevelCursor struct {
// itr streams LTX file infos for a single level in filename order.
itr ltx.FileIterator
// current holds the last item read from itr but not yet evaluated.
current *ltx.FileInfo
// candidate is the best eligible file at this level for the currentMax.
candidate *ltx.FileInfo
// done indicates the iterator has been exhausted or errored.
done bool
}
func (c *restoreLevelCursor) refresh(currentMax, txID ltx.TXID, timestamp time.Time) error {
// Advance the iterator until we've evaluated all files that could be
// contiguous with currentMax. Keep the best eligible candidate.
if c.done {
return nil
}
if c.candidate != nil && c.candidate.MaxTXID <= currentMax {
c.candidate = nil
}
for {
if err := c.ensureCurrent(); err != nil {
return err
}
if c.done {
return nil
}
info := c.current
if info.MinTXID > currentMax+1 {
return nil
}
c.current = nil
if info.MaxTXID <= currentMax {
continue
}
if txID != 0 && info.MaxTXID > txID {
continue
}
if !timestamp.IsZero() && !info.CreatedAt.Before(timestamp) {
continue
}
if c.candidate == nil || restoreCandidateBetter(c.candidate, info) {
c.candidate = info
}
}
}
func (c *restoreLevelCursor) ensureCurrent() error {
// Ensure current is populated with the next iterator item, or mark done.
if c.done || c.current != nil {
return nil
}
if !c.itr.Next() {
if err := c.itr.Err(); err != nil {
return err
}
c.done = true
return nil
}
c.current = c.itr.Item()
return nil
}
func restoreCandidateBetter(curr, next *ltx.FileInfo) bool {
if next.MaxTXID != curr.MaxTXID {
return next.MaxTXID > curr.MaxTXID
}
if next.MinTXID != curr.MinTXID {
return next.MinTXID < curr.MinTXID
}
if next.Level != curr.Level {
return next.Level > curr.Level
}
return next.CreatedAt.Before(curr.CreatedAt)
}
// TXIDPath returns the path to the TXID sidecar file for the given database path.
// Uses -txid suffix to match SQLite's naming convention for associated files (-wal, -shm).
func TXIDPath(outputPath string) string {
return outputPath + "-txid"
}
// WriteTXIDFile atomically writes a TXID to a sidecar file at <outputPath>-txid.
// Uses temp-file + fsync + rename for crash safety.
func WriteTXIDFile(outputPath string, txid ltx.TXID) error {
txidPath := TXIDPath(outputPath)
tmpPath := txidPath + ".tmp"
f, err := os.Create(tmpPath)
if err != nil {
return fmt.Errorf("create txid temp file: %w", err)
}
defer f.Close()
defer os.Remove(tmpPath)
if _, err := fmt.Fprintln(f, txid); err != nil {
return fmt.Errorf("write txid: %w", err)
}
if err := f.Sync(); err != nil {
return fmt.Errorf("sync txid file: %w", err)
}
if err := f.Close(); err != nil {
return fmt.Errorf("close txid file: %w", err)
}
if err := os.Rename(tmpPath, txidPath); err != nil {
return fmt.Errorf("rename txid file: %w", err)
}
dir, err := os.Open(filepath.Dir(txidPath))
if err != nil {
return fmt.Errorf("open txid dir for sync: %w", err)
}
if err := dir.Sync(); err != nil {
_ = dir.Close()
return fmt.Errorf("sync txid dir: %w", err)
}
return dir.Close()
}
// ReadTXIDFile reads the TXID from a sidecar file at <outputPath>-txid.
// Returns 0, nil if the file does not exist (first run).
func ReadTXIDFile(outputPath string) (ltx.TXID, error) {
txidPath := TXIDPath(outputPath)
data, err := os.ReadFile(txidPath)
if os.IsNotExist(err) {
return 0, nil
} else if err != nil {
return 0, fmt.Errorf("read txid file: %w", err)
}
txid, err := ltx.ParseTXID(strings.TrimSpace(string(data)))
if err != nil {
return 0, fmt.Errorf("parse txid file: %w", err)
}
return txid, nil
}
// ValidationError represents a single validation issue.
type ValidationError struct {
Level int // compaction level
Type string // "gap", "overlap", or "unsorted"
Message string // human-readable description
PrevFile *ltx.FileInfo // previous file
CurrFile *ltx.FileInfo // current file that caused error
}
// ValidateLevel checks LTX files at the given level are sorted and contiguous.
// Returns a slice of validation errors (empty if valid).
func (r *Replica) ValidateLevel(ctx context.Context, level int) ([]ValidationError, error) {
itr, err := r.Client.LTXFiles(ctx, level, 0, false)
if err != nil {
return nil, fmt.Errorf("fetch ltx files: %w", err)
}
defer itr.Close()
var errors []ValidationError
var prevInfo *ltx.FileInfo
for itr.Next() {
info := itr.Item()
// Skip first file - nothing to compare against
if prevInfo == nil {
prevInfo = info
continue
}
// Check for sort order: curr.MinTXID should be >= prev.MinTXID
if info.MinTXID < prevInfo.MinTXID {
errors = append(errors, ValidationError{
Level: level,
Type: "unsorted",
Message: fmt.Sprintf("files out of order: curr.MinTXID=%s < prev.MinTXID=%s", info.MinTXID, prevInfo.MinTXID),
PrevFile: prevInfo,
CurrFile: info,
})
prevInfo = info
continue
}
// Check for TXID contiguity: prev.MaxTXID + 1 should equal curr.MinTXID
expectedMinTXID := prevInfo.MaxTXID + 1
if info.MinTXID != expectedMinTXID {
if info.MinTXID > expectedMinTXID {
errors = append(errors, ValidationError{
Level: level,
Type: "gap",
Message: fmt.Sprintf("TXID gap: prev.MaxTXID=%s, curr.MinTXID=%s (expected %s)", prevInfo.MaxTXID, info.MinTXID, expectedMinTXID),
PrevFile: prevInfo,
CurrFile: info,
})
} else {
errors = append(errors, ValidationError{
Level: level,
Type: "overlap",
Message: fmt.Sprintf("TXID overlap: prev.MaxTXID=%s, curr.MinTXID=%s", prevInfo.MaxTXID, info.MinTXID),
PrevFile: prevInfo,
CurrFile: info,
})
}
}
prevInfo = info
}
if err := itr.Close(); err != nil {
return nil, fmt.Errorf("close iterator: %w", err)
}
return errors, nil
}