Re-add point-in-time restore

This commit is contained in:
Ben Johnson
2022-04-14 19:17:44 -06:00
parent 80f8de4d9e
commit ca07137d32
31 changed files with 318 additions and 10 deletions

View File

@@ -9,6 +9,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"time"
"github.com/benbjohnson/litestream" "github.com/benbjohnson/litestream"
) )
@@ -28,6 +29,7 @@ type RestoreCommand struct {
replicaName string // optional, name of replica to restore from replicaName string // optional, name of replica to restore from
generation string // optional, generation to restore generation string // optional, generation to restore
targetIndex int // optional, last WAL index to replay targetIndex int // optional, last WAL index to replay
timestamp time.Time // optional, restore to point-in-time (ISO 8601)
ifDBNotExists bool // if true, skips restore if output path already exists ifDBNotExists bool // if true, skips restore if output path already exists
ifReplicaExists bool // if true, skips if no backups exist ifReplicaExists bool // if true, skips if no backups exist
opt litestream.RestoreOptions opt litestream.RestoreOptions
@@ -53,6 +55,7 @@ func (c *RestoreCommand) Run(ctx context.Context, args []string) (err error) {
fs.StringVar(&c.replicaName, "replica", "", "replica name") fs.StringVar(&c.replicaName, "replica", "", "replica name")
fs.StringVar(&c.generation, "generation", "", "generation name") fs.StringVar(&c.generation, "generation", "", "generation name")
fs.Var((*indexVar)(&c.targetIndex), "index", "wal index") fs.Var((*indexVar)(&c.targetIndex), "index", "wal index")
timestampStr := fs.String("timestamp", "", "point-in-time restore (ISO 8601)")
fs.IntVar(&c.opt.Parallelism, "parallelism", c.opt.Parallelism, "parallelism") fs.IntVar(&c.opt.Parallelism, "parallelism", c.opt.Parallelism, "parallelism")
fs.BoolVar(&c.ifDBNotExists, "if-db-not-exists", false, "") fs.BoolVar(&c.ifDBNotExists, "if-db-not-exists", false, "")
fs.BoolVar(&c.ifReplicaExists, "if-replica-exists", false, "") fs.BoolVar(&c.ifReplicaExists, "if-replica-exists", false, "")
@@ -66,9 +69,20 @@ func (c *RestoreCommand) Run(ctx context.Context, args []string) (err error) {
} }
pathOrURL := fs.Arg(0) pathOrURL := fs.Arg(0)
// Parse timestamp.
if *timestampStr != "" {
if c.timestamp, err = time.Parse(time.RFC3339Nano, *timestampStr); err != nil {
return fmt.Errorf("invalid -timestamp, expected ISO 8601: %w", err)
}
}
// Ensure a generation is specified if target index is specified. // Ensure a generation is specified if target index is specified.
if c.targetIndex != -1 && c.generation == "" { if c.targetIndex != -1 && !c.timestamp.IsZero() {
return fmt.Errorf("cannot specify both -index flag and -timestamp flag")
} else if c.targetIndex != -1 && c.generation == "" {
return fmt.Errorf("must specify -generation flag when using -index flag") return fmt.Errorf("must specify -generation flag when using -index flag")
} else if !c.timestamp.IsZero() && c.generation == "" {
return fmt.Errorf("must specify -generation flag when using -timestamp flag")
} }
// Default to original database path if output path not specified. // Default to original database path if output path not specified.
@@ -117,7 +131,11 @@ func (c *RestoreCommand) Run(ctx context.Context, args []string) (err error) {
} }
// Determine the maximum available index for the generation if one is not specified. // Determine the maximum available index for the generation if one is not specified.
if c.targetIndex == -1 { if !c.timestamp.IsZero() {
if c.targetIndex, err = litestream.FindIndexByTimestamp(ctx, r.Client(), c.generation, c.timestamp); err != nil {
return fmt.Errorf("cannot find index for timestamp in generation %q: %w", c.generation, err)
}
} else if c.targetIndex == -1 {
if c.targetIndex, err = litestream.FindMaxIndexByGeneration(ctx, r.Client(), c.generation); err != nil { if c.targetIndex, err = litestream.FindMaxIndexByGeneration(ctx, r.Client(), c.generation); err != nil {
return fmt.Errorf("cannot determine latest index in generation %q: %w", c.generation, err) return fmt.Errorf("cannot determine latest index in generation %q: %w", c.generation, err)
} }
@@ -239,6 +257,10 @@ Arguments:
Restore up to a specific hex-encoded WAL index (inclusive). Restore up to a specific hex-encoded WAL index (inclusive).
Defaults to use the highest available index. Defaults to use the highest available index.
-timestamp DATETIME
Restore up to a specific point-in-time. Must be ISO 8601.
Cannot be specified with -index flag.
-o PATH -o PATH
Output path of the restored database. Output path of the restored database.
Defaults to original DB path. Defaults to original DB path.
@@ -271,6 +293,9 @@ Examples:
# Restore database from specific generation on S3. # Restore database from specific generation on S3.
$ litestream restore -replica s3 -generation xxxxxxxx /path/to/db $ litestream restore -replica s3 -generation xxxxxxxx /path/to/db
# Restore database to a specific point in time.
$ litestream restore -generation xxxxxxxx -timestamp 2000-01-01T00:00:00Z /path/to/db
`[1:], `[1:],
DefaultConfigPath(), DefaultConfigPath(),
) )

View File

@@ -234,6 +234,88 @@ func ReplicaClientTimeBounds(ctx context.Context, client ReplicaClient) (min, ma
return min, max, nil return min, max, nil
} }
// FindIndexByTimestamp returns the highest index before a given point-in-time
// within a generation. Returns ErrNoSnapshots if no index exists on the replica
// for the generation.
func FindIndexByTimestamp(ctx context.Context, client ReplicaClient, generation string, timestamp time.Time) (index int, err error) {
snapshotIndex, err := FindSnapshotIndexByTimestamp(ctx, client, generation, timestamp)
if err == ErrNoSnapshots {
return 0, err
} else if err != nil {
return 0, fmt.Errorf("max snapshot index: %w", err)
}
// Determine the highest available WAL index.
walIndex, err := FindWALIndexByTimestamp(ctx, client, generation, timestamp)
if err != nil && err != ErrNoWALSegments {
return 0, fmt.Errorf("max wal index: %w", err)
}
// Use snapshot index if it's after the last WAL index.
if snapshotIndex > walIndex {
return snapshotIndex, nil
}
return walIndex, nil
}
// FindSnapshotIndexByTimestamp returns the highest snapshot index before timestamp.
// Returns ErrNoSnapshots if no snapshots exist for the generation on the replica.
func FindSnapshotIndexByTimestamp(ctx context.Context, client ReplicaClient, generation string, timestamp time.Time) (index int, err error) {
itr, err := client.Snapshots(ctx, generation)
if err != nil {
return 0, fmt.Errorf("snapshots: %w", err)
}
defer func() { _ = itr.Close() }()
// Iterate over snapshots to find the highest index.
var n int
for ; itr.Next(); n++ {
if info := itr.Snapshot(); info.CreatedAt.After(timestamp) {
continue
} else if info.Index > index {
index = info.Index
}
}
if err := itr.Close(); err != nil {
return 0, fmt.Errorf("snapshot iteration: %w", err)
}
// Return an error if no snapshots were found.
if n == 0 {
return 0, ErrNoSnapshots
}
return index, nil
}
// FindWALIndexByTimestamp returns the highest WAL index before timestamp.
// Returns ErrNoWALSegments if no segments exist for the generation on the replica.
func FindWALIndexByTimestamp(ctx context.Context, client ReplicaClient, generation string, timestamp time.Time) (index int, err error) {
itr, err := client.WALSegments(ctx, generation)
if err != nil {
return 0, fmt.Errorf("wal segments: %w", err)
}
defer func() { _ = itr.Close() }()
// Iterate over WAL segments to find the highest index.
var n int
for ; itr.Next(); n++ {
if info := itr.WALSegment(); info.CreatedAt.After(timestamp) {
continue
} else if info.Index > index {
index = info.Index
}
}
if err := itr.Close(); err != nil {
return 0, fmt.Errorf("wal segment iteration: %w", err)
}
// Return an error if no WAL segments were found.
if n == 0 {
return 0, ErrNoWALSegments
}
return index, nil
}
// FindMaxIndexByGeneration returns the last index within a generation. // FindMaxIndexByGeneration returns the last index within a generation.
// Returns ErrNoSnapshots if no index exists on the replica for the generation. // Returns ErrNoSnapshots if no index exists on the replica for the generation.
func FindMaxIndexByGeneration(ctx context.Context, client ReplicaClient, generation string) (index int, err error) { func FindMaxIndexByGeneration(ctx context.Context, client ReplicaClient, generation string) (index int, err error) {

View File

@@ -489,6 +489,167 @@ func TestFindMaxIndexByGeneration(t *testing.T) {
}) })
} }
func TestFindSnapshotIndexByTimestamp(t *testing.T) {
t.Run("OK", func(t *testing.T) {
client := litestream.NewFileReplicaClient(filepath.Join("testdata", "snapshot-index-by-timestamp", "ok"))
if index, err := litestream.FindSnapshotIndexByTimestamp(context.Background(), client, "0000000000000000", time.Date(2000, 1, 3, 0, 0, 0, 0, time.UTC)); err != nil {
t.Fatal(err)
} else if got, want := index, 0x000007d0; got != want {
t.Fatalf("index=%d, want %d", got, want)
}
})
t.Run("ErrNoSnapshots", func(t *testing.T) {
client := litestream.NewFileReplicaClient(filepath.Join("testdata", "snapshot-index-by-timestamp", "no-snapshots"))
_, err := litestream.FindSnapshotIndexByTimestamp(context.Background(), client, "0000000000000000", time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC))
if err != litestream.ErrNoSnapshots {
t.Fatalf("unexpected error: %s", err)
}
})
t.Run("ErrSnapshots", func(t *testing.T) {
var client mock.ReplicaClient
client.SnapshotsFunc = func(ctx context.Context, generation string) (litestream.SnapshotIterator, error) {
return nil, fmt.Errorf("marker")
}
_, err := litestream.FindSnapshotIndexByTimestamp(context.Background(), &client, "0000000000000000", time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC))
if err == nil || err.Error() != `snapshots: marker` {
t.Fatalf("unexpected error: %s", err)
}
})
t.Run("ErrSnapshotIteration", func(t *testing.T) {
var itr mock.SnapshotIterator
itr.NextFunc = func() bool { return false }
itr.CloseFunc = func() error { return fmt.Errorf("marker") }
var client mock.ReplicaClient
client.SnapshotsFunc = func(ctx context.Context, generation string) (litestream.SnapshotIterator, error) {
return &itr, nil
}
_, err := litestream.FindSnapshotIndexByTimestamp(context.Background(), &client, "0000000000000000", time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC))
if err == nil || err.Error() != `snapshot iteration: marker` {
t.Fatalf("unexpected error: %s", err)
}
})
}
func TestFindWALIndexByTimestamp(t *testing.T) {
t.Run("OK", func(t *testing.T) {
client := litestream.NewFileReplicaClient(filepath.Join("testdata", "wal-index-by-timestamp", "ok"))
if index, err := litestream.FindWALIndexByTimestamp(context.Background(), client, "0000000000000000", time.Date(2000, 1, 3, 0, 0, 0, 0, time.UTC)); err != nil {
t.Fatal(err)
} else if got, want := index, 1; got != want {
t.Fatalf("index=%d, want %d", got, want)
}
})
t.Run("ErrNoWALSegments", func(t *testing.T) {
client := litestream.NewFileReplicaClient(filepath.Join("testdata", "wal-index-by-timestamp", "no-wal"))
_, err := litestream.FindWALIndexByTimestamp(context.Background(), client, "0000000000000000", time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC))
if err != litestream.ErrNoWALSegments {
t.Fatalf("unexpected error: %s", err)
}
})
t.Run("ErrWALSegments", func(t *testing.T) {
var client mock.ReplicaClient
client.WALSegmentsFunc = func(ctx context.Context, generation string) (litestream.WALSegmentIterator, error) {
return nil, fmt.Errorf("marker")
}
_, err := litestream.FindWALIndexByTimestamp(context.Background(), &client, "0000000000000000", time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC))
if err == nil || err.Error() != `wal segments: marker` {
t.Fatalf("unexpected error: %s", err)
}
})
t.Run("ErrWALSegmentIteration", func(t *testing.T) {
var itr mock.WALSegmentIterator
itr.NextFunc = func() bool { return false }
itr.CloseFunc = func() error { return fmt.Errorf("marker") }
var client mock.ReplicaClient
client.WALSegmentsFunc = func(ctx context.Context, generation string) (litestream.WALSegmentIterator, error) {
return &itr, nil
}
_, err := litestream.FindWALIndexByTimestamp(context.Background(), &client, "0000000000000000", time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC))
if err == nil || err.Error() != `wal segment iteration: marker` {
t.Fatalf("unexpected error: %s", err)
}
})
}
func TestFindIndexByTimestamp(t *testing.T) {
t.Run("OK", func(t *testing.T) {
client := litestream.NewFileReplicaClient(filepath.Join("testdata", "index-by-timestamp", "ok"))
if index, err := litestream.FindIndexByTimestamp(context.Background(), client, "0000000000000000", time.Date(2000, 1, 4, 0, 0, 0, 0, time.UTC)); err != nil {
t.Fatal(err)
} else if got, want := index, 0x00000002; got != want {
t.Fatalf("index=%d, want %d", got, want)
}
})
t.Run("NoWAL", func(t *testing.T) {
client := litestream.NewFileReplicaClient(filepath.Join("testdata", "index-by-timestamp", "no-wal"))
if index, err := litestream.FindIndexByTimestamp(context.Background(), client, "0000000000000000", time.Date(2000, 1, 2, 0, 0, 0, 0, time.UTC)); err != nil {
t.Fatal(err)
} else if got, want := index, 0x00000001; got != want {
t.Fatalf("index=%d, want %d", got, want)
}
})
t.Run("SnapshotLaterThanWAL", func(t *testing.T) {
client := litestream.NewFileReplicaClient(filepath.Join("testdata", "index-by-timestamp", "snapshot-later-than-wal"))
if index, err := litestream.FindIndexByTimestamp(context.Background(), client, "0000000000000000", time.Date(2000, 1, 3, 0, 0, 0, 0, time.UTC)); err != nil {
t.Fatal(err)
} else if got, want := index, 0x00000001; got != want {
t.Fatalf("index=%d, want %d", got, want)
}
})
t.Run("ErrNoSnapshots", func(t *testing.T) {
client := litestream.NewFileReplicaClient(filepath.Join("testdata", "index-by-timestamp", "no-snapshots"))
_, err := litestream.FindIndexByTimestamp(context.Background(), client, "0000000000000000", time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC))
if err != litestream.ErrNoSnapshots {
t.Fatalf("unexpected error: %s", err)
}
})
t.Run("ErrSnapshots", func(t *testing.T) {
var client mock.ReplicaClient
client.SnapshotsFunc = func(ctx context.Context, generation string) (litestream.SnapshotIterator, error) {
return nil, fmt.Errorf("marker")
}
_, err := litestream.FindIndexByTimestamp(context.Background(), &client, "0000000000000000", time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC))
if err == nil || err.Error() != `max snapshot index: snapshots: marker` {
t.Fatalf("unexpected error: %s", err)
}
})
t.Run("ErrWALSegments", func(t *testing.T) {
var client mock.ReplicaClient
client.SnapshotsFunc = func(ctx context.Context, generation string) (litestream.SnapshotIterator, error) {
return litestream.NewSnapshotInfoSliceIterator([]litestream.SnapshotInfo{{Index: 0x00000001}}), nil
}
client.WALSegmentsFunc = func(ctx context.Context, generation string) (litestream.WALSegmentIterator, error) {
return nil, fmt.Errorf("marker")
}
_, err := litestream.FindIndexByTimestamp(context.Background(), &client, "0000000000000000", time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC))
if err == nil || err.Error() != `max wal index: wal segments: marker` {
t.Fatalf("unexpected error: %s", err)
}
})
}
func TestRestore(t *testing.T) { func TestRestore(t *testing.T) {
t.Run("OK", func(t *testing.T) { t.Run("OK", func(t *testing.T) {
testDir := filepath.Join("testdata", "restore", "ok") testDir := filepath.Join("testdata", "restore", "ok")

5
testdata/Makefile vendored
View File

@@ -1,8 +1,13 @@
.PHONY: default .PHONY: default
default: default:
make -C find-latest-generation/ok make -C find-latest-generation/ok
make -C index-by-timestamp/no-wal
make -C index-by-timestamp/ok
make -C index-by-timestamp/snapshot-later-than-wal
make -C generation-time-bounds/ok make -C generation-time-bounds/ok
make -C generation-time-bounds/snapshots-only make -C generation-time-bounds/snapshots-only
make -C replica-client-time-bounds/ok make -C replica-client-time-bounds/ok
make -C snapshot-time-bounds/ok make -C snapshot-time-bounds/ok
make -C snapshot-index-by-timestamp/ok
make -C wal-time-bounds/ok make -C wal-time-bounds/ok
make -C wal-index-by-timestamp/ok

View File

@@ -0,0 +1,6 @@
.PHONY: default
default:
TZ=UTC touch -ct 200001010000 generations/0000000000000000/snapshots/0000000000000000.snapshot.lz4
TZ=UTC touch -ct 200001020000 generations/0000000000000000/snapshots/0000000000000001.snapshot.lz4
TZ=UTC touch -ct 200001030000 generations/0000000000000000/snapshots/0000000000000002.snapshot.lz4

11
testdata/index-by-timestamp/ok/Makefile vendored Normal file
View File

@@ -0,0 +1,11 @@
.PHONY: default
default:
TZ=UTC touch -ct 200001010000 generations/0000000000000000/snapshots/0000000000000000.snapshot.lz4
TZ=UTC touch -ct 200001030000 generations/0000000000000000/snapshots/0000000000000001.snapshot.lz4
TZ=UTC touch -ct 200001010000 generations/0000000000000000/wal/0000000000000000/0000000000000000.wal.lz4
TZ=UTC touch -ct 200001020000 generations/0000000000000000/wal/0000000000000000/0000000000001234.wal.lz4
TZ=UTC touch -ct 200001030000 generations/0000000000000000/wal/0000000000000001/0000000000000000.wal.lz4
TZ=UTC touch -ct 200001040000 generations/0000000000000000/wal/0000000000000002/0000000000000000.wal.lz4
TZ=UTC touch -ct 200001050000 generations/0000000000000000/wal/0000000000000003/0000000000000000.wal.lz4

View File

@@ -0,0 +1,7 @@
.PHONY: default
default:
TZ=UTC touch -ct 200001010000 generations/0000000000000000/snapshots/0000000000000000.snapshot.lz4
TZ=UTC touch -ct 200001030000 generations/0000000000000000/snapshots/0000000000000001.snapshot.lz4
TZ=UTC touch -ct 200001020000 generations/0000000000000000/wal/0000000000000000/0000000000000000.wal.lz4
TZ=UTC touch -ct 200001020000 generations/0000000000000000/wal/0000000000000000/0000000000001234.wal.lz4

View File

@@ -0,0 +1,5 @@
.PHONY: default
default:
TZ=UTC touch -ct 200001010000 generations/0000000000000000/snapshots/0000000000000000.snapshot.lz4
TZ=UTC touch -ct 200001020000 generations/0000000000000000/snapshots/00000000000003e8.snapshot.lz4
TZ=UTC touch -ct 200001030000 generations/0000000000000000/snapshots/00000000000007d0.snapshot.lz4

View File

@@ -0,0 +1,6 @@
.PHONY: default
default:
TZ=UTC touch -ct 200001010000 generations/0000000000000000/wal/0000000000000000/0000000000000000.wal.lz4
TZ=UTC touch -ct 200001020000 generations/0000000000000000/wal/0000000000000000/0000000000001234.wal.lz4
TZ=UTC touch -ct 200001030000 generations/0000000000000000/wal/0000000000000001/0000000000000000.wal.lz4