mirror of
https://github.com/luxfi/zapdb.git
synced 2026-07-27 06:54:45 +00:00
Stream.SinceTs is EXCLUSIVE (entries with version <= SinceTs are ignored), but the library Replicator advanced its push cursor as sinceVersion = maxVersion+1 in Incremental and Snapshot, so the write landing at exactly maxVersion+1 was skipped by every incremental until the next full snapshot re-captured it via Backup(0). Durability gap (RPO), and for a merge-restored follower a revoked-secret-resurrects hazard when a delete tombstone lands on the boundary. Fix: resume AT maxVersion (re-including it is a no-op under the exclusive bound, so no duplication) — matching the native cmd/replicate tool, which already resumes at LastVersion=until (replica.go). Same one-off removed from the post-Restore cursor (inc.version, not inc.version+1) so a node promoted after a Restore does not skip its first boundary write. Also seed snapVersion from db.MaxVersion() after loading the snapshot so Restore skips incrementals already contained in the snapshot instead of re-applying every incremental ever on each call (was: snapVersion stayed 0). Non-breaking — snapshot key format unchanged. Regression test TestReplicatorPushCursorCapturesEveryWrite simulates the push->restore loop one write per round and asserts every boundary write reaches the follower (fails under maxVersion+1, passes under the fix).
372 lines
10 KiB
Go
372 lines
10 KiB
Go
/*
|
|
* SPDX-FileCopyrightText: © 2017-2025 Istari Digital, Inc.
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
package badger
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"path"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
s3 "github.com/hanzos3/go-sdk"
|
|
"github.com/hanzos3/go-sdk/pkg/credentials"
|
|
"github.com/luxfi/age"
|
|
)
|
|
|
|
// ReplicatorConfig configures encrypted streaming replication to S3.
|
|
type ReplicatorConfig struct {
|
|
// S3 connection.
|
|
Bucket string
|
|
Endpoint string
|
|
Region string
|
|
AccessKey string
|
|
SecretKey string
|
|
UseSSL bool
|
|
Path string // S3 key prefix (e.g. "zapdb/node-0")
|
|
|
|
// Encryption (optional — if nil, backups are unencrypted).
|
|
AgeRecipient age.Recipient // age public key for encryption
|
|
AgeIdentity age.Identity // age private key for decryption (restore only)
|
|
|
|
// Timing.
|
|
Interval time.Duration // incremental sync interval (default 1s)
|
|
SnapshotInterval time.Duration // full snapshot interval (default 1h)
|
|
|
|
// Concurrency for db.Load.
|
|
MaxPendingWrites int // default 16
|
|
|
|
// Logging — uses the DB logger if nil.
|
|
Logger Logger
|
|
}
|
|
|
|
func (c *ReplicatorConfig) interval() time.Duration {
|
|
if c.Interval > 0 {
|
|
return c.Interval
|
|
}
|
|
return time.Second
|
|
}
|
|
|
|
func (c *ReplicatorConfig) snapshotInterval() time.Duration {
|
|
if c.SnapshotInterval > 0 {
|
|
return c.SnapshotInterval
|
|
}
|
|
return time.Hour
|
|
}
|
|
|
|
func (c *ReplicatorConfig) maxPendingWrites() int {
|
|
if c.MaxPendingWrites > 0 {
|
|
return c.MaxPendingWrites
|
|
}
|
|
return 16
|
|
}
|
|
|
|
// Replicator continuously streams incremental backups to S3, encrypted with age.
|
|
type Replicator struct {
|
|
db *DB
|
|
cfg ReplicatorConfig
|
|
cli *s3.Client
|
|
log Logger
|
|
mu sync.Mutex
|
|
stop context.CancelFunc
|
|
|
|
sinceVersion uint64
|
|
lastSnapshot time.Time
|
|
}
|
|
|
|
// NewReplicator creates a Replicator. Call Start to begin replication.
|
|
func NewReplicator(db *DB, cfg ReplicatorConfig) (*Replicator, error) {
|
|
cli, err := s3.New(cfg.Endpoint, &s3.Options{
|
|
Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
|
|
Region: cfg.Region,
|
|
Secure: cfg.UseSSL,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("replicate: s3 client: %w", err)
|
|
}
|
|
|
|
log := cfg.Logger
|
|
if log == nil {
|
|
log = db.opt.Logger
|
|
}
|
|
|
|
return &Replicator{
|
|
db: db,
|
|
cfg: cfg,
|
|
cli: cli,
|
|
log: log,
|
|
}, nil
|
|
}
|
|
|
|
// Start begins the replication loop. It blocks until ctx is cancelled or Stop is called.
|
|
func (r *Replicator) Start(ctx context.Context) {
|
|
ctx, r.stop = context.WithCancel(ctx)
|
|
|
|
incTicker := time.NewTicker(r.cfg.interval())
|
|
defer incTicker.Stop()
|
|
|
|
snapTicker := time.NewTicker(r.cfg.snapshotInterval())
|
|
defer snapTicker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-incTicker.C:
|
|
if err := r.Incremental(ctx); err != nil {
|
|
r.log.Warningf("replicate: incremental: %v", err)
|
|
}
|
|
case <-snapTicker.C:
|
|
if err := r.Snapshot(ctx); err != nil {
|
|
r.log.Warningf("replicate: snapshot: %v", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Stop cancels the replication loop.
|
|
func (r *Replicator) Stop() {
|
|
if r.stop != nil {
|
|
r.stop()
|
|
}
|
|
}
|
|
|
|
// Incremental runs a single incremental backup. It backs up all changes since
|
|
// the last replicated version, encrypts if configured, and uploads to S3.
|
|
// Returns nil if there are no new changes.
|
|
func (r *Replicator) Incremental(ctx context.Context) error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
// TODO: replace bytes.Buffer with io.Pipe for streaming to avoid 3x memory overhead on large databases
|
|
var buf bytes.Buffer
|
|
maxVersion, err := r.db.Backup(&buf, r.sinceVersion)
|
|
if err != nil {
|
|
return fmt.Errorf("backup: %w", err)
|
|
}
|
|
if buf.Len() == 0 {
|
|
return nil
|
|
}
|
|
|
|
var body io.Reader = &buf
|
|
size := int64(buf.Len())
|
|
|
|
if r.cfg.AgeRecipient != nil {
|
|
encrypted, err := encrypt(&buf, r.cfg.AgeRecipient)
|
|
if err != nil {
|
|
return fmt.Errorf("encrypt: %w", err)
|
|
}
|
|
body = encrypted
|
|
size = int64(encrypted.Len())
|
|
}
|
|
|
|
key := r.incKey(maxVersion)
|
|
_, err = r.cli.PutObject(ctx, r.cfg.Bucket, key, body, size, s3.PutObjectOptions{
|
|
ContentType: "application/octet-stream",
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("upload %s: %w", key, err)
|
|
}
|
|
|
|
// Stream.SinceTs is EXCLUSIVE (entries with version <= SinceTs are
|
|
// ignored), so the NEXT backup must resume AT maxVersion, not maxVersion+1
|
|
// — otherwise the write at exactly maxVersion+1 is skipped by every
|
|
// incremental until the next full snapshot. Re-including maxVersion is a
|
|
// no-op (it is <= the exclusive bound), so there is no duplication. This
|
|
// matches the native cmd/replicate tool, which resumes at LastVersion=until.
|
|
r.sinceVersion = maxVersion
|
|
r.log.Infof("replicate: incremental uploaded %s (%d bytes, version %d)", key, size, maxVersion)
|
|
return nil
|
|
}
|
|
|
|
// Snapshot creates a full backup (sinceVersion=0), encrypts, and uploads to S3.
|
|
func (r *Replicator) Snapshot(ctx context.Context) error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
// TODO: replace bytes.Buffer with io.Pipe for streaming to avoid 3x memory overhead on large databases
|
|
var buf bytes.Buffer
|
|
maxVersion, err := r.db.Backup(&buf, 0)
|
|
if err != nil {
|
|
return fmt.Errorf("backup: %w", err)
|
|
}
|
|
if buf.Len() == 0 {
|
|
return nil
|
|
}
|
|
|
|
var body io.Reader = &buf
|
|
size := int64(buf.Len())
|
|
|
|
if r.cfg.AgeRecipient != nil {
|
|
encrypted, err := encrypt(&buf, r.cfg.AgeRecipient)
|
|
if err != nil {
|
|
return fmt.Errorf("encrypt: %w", err)
|
|
}
|
|
body = encrypted
|
|
size = int64(encrypted.Len())
|
|
}
|
|
|
|
key := r.snapKey(time.Now())
|
|
_, err = r.cli.PutObject(ctx, r.cfg.Bucket, key, body, size, s3.PutObjectOptions{
|
|
ContentType: "application/octet-stream",
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("upload %s: %w", key, err)
|
|
}
|
|
|
|
r.lastSnapshot = time.Now()
|
|
// Stream.SinceTs is EXCLUSIVE (entries with version <= SinceTs are
|
|
// ignored), so the NEXT backup must resume AT maxVersion, not maxVersion+1
|
|
// — otherwise the write at exactly maxVersion+1 is skipped by every
|
|
// incremental until the next full snapshot. Re-including maxVersion is a
|
|
// no-op (it is <= the exclusive bound), so there is no duplication. This
|
|
// matches the native cmd/replicate tool, which resumes at LastVersion=until.
|
|
r.sinceVersion = maxVersion
|
|
r.log.Infof("replicate: snapshot uploaded %s (%d bytes, version %d)", key, size, maxVersion)
|
|
return nil
|
|
}
|
|
|
|
// Restore downloads the latest snapshot from S3, decrypts if needed, and loads
|
|
// into the database. Then it applies any incremental backups that are newer than
|
|
// the snapshot. The DB should not have concurrent transactions running.
|
|
func (r *Replicator) Restore(ctx context.Context) error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
// Find latest snapshot.
|
|
snapPrefix := path.Join(r.cfg.Path, "snap") + "/"
|
|
latestSnap := ""
|
|
for obj := range r.cli.ListObjects(ctx, r.cfg.Bucket, s3.ListObjectsOptions{
|
|
Prefix: snapPrefix,
|
|
Recursive: true,
|
|
}) {
|
|
if obj.Err != nil {
|
|
return fmt.Errorf("list snapshots: %w", obj.Err)
|
|
}
|
|
if obj.Key > latestSnap {
|
|
latestSnap = obj.Key
|
|
}
|
|
}
|
|
|
|
var snapVersion uint64
|
|
|
|
if latestSnap != "" {
|
|
r.log.Infof("replicate: restoring snapshot %s", latestSnap)
|
|
if err := r.downloadAndLoad(ctx, latestSnap); err != nil {
|
|
return fmt.Errorf("restore snapshot: %w", err)
|
|
}
|
|
// Seed snapVersion from the loaded DB's max version (db.Load advances
|
|
// it), so incrementals already contained in the snapshot are SKIPPED
|
|
// below rather than re-applied on every Restore. This bounds the
|
|
// restore-replay cost (previously snapVersion stayed 0 → every
|
|
// incremental ever was re-applied each cycle). Non-breaking: the
|
|
// snapshot key format is unchanged.
|
|
snapVersion = r.db.MaxVersion()
|
|
}
|
|
|
|
// Find and apply incrementals in order.
|
|
incPrefix := path.Join(r.cfg.Path, "inc") + "/"
|
|
type incEntry struct {
|
|
key string
|
|
version uint64
|
|
}
|
|
var incs []incEntry
|
|
for obj := range r.cli.ListObjects(ctx, r.cfg.Bucket, s3.ListObjectsOptions{
|
|
Prefix: incPrefix,
|
|
Recursive: true,
|
|
}) {
|
|
if obj.Err != nil {
|
|
return fmt.Errorf("list incrementals: %w", obj.Err)
|
|
}
|
|
v := versionFromKey(obj.Key)
|
|
if v > snapVersion {
|
|
incs = append(incs, incEntry{key: obj.Key, version: v})
|
|
}
|
|
}
|
|
|
|
sort.Slice(incs, func(i, j int) bool { return incs[i].version < incs[j].version })
|
|
|
|
for _, inc := range incs {
|
|
r.log.Infof("replicate: applying incremental %s", inc.key)
|
|
if err := r.downloadAndLoad(ctx, inc.key); err != nil {
|
|
return fmt.Errorf("restore incremental %s: %w", inc.key, err)
|
|
}
|
|
// Resume the push cursor AT the last applied version (exclusive
|
|
// SinceTs), so a node promoted after a Restore does not skip the write
|
|
// at inc.version+1 on its first push.
|
|
r.sinceVersion = inc.version
|
|
}
|
|
|
|
r.log.Infof("replicate: restore complete (sinceVersion=%d)", r.sinceVersion)
|
|
return nil
|
|
}
|
|
|
|
func (r *Replicator) downloadAndLoad(ctx context.Context, key string) error {
|
|
obj, err := r.cli.GetObject(ctx, r.cfg.Bucket, key, s3.GetObjectOptions{})
|
|
if err != nil {
|
|
return fmt.Errorf("get %s: %w", key, err)
|
|
}
|
|
defer obj.Close()
|
|
|
|
var reader io.Reader = obj
|
|
if r.cfg.AgeIdentity != nil {
|
|
dec, err := age.Decrypt(obj, r.cfg.AgeIdentity)
|
|
if err != nil {
|
|
return fmt.Errorf("decrypt %s: %w", key, err)
|
|
}
|
|
reader = dec
|
|
}
|
|
|
|
return r.db.Load(reader, r.cfg.maxPendingWrites())
|
|
}
|
|
|
|
func (r *Replicator) incKey(version uint64) string {
|
|
ext := ".zap"
|
|
if r.cfg.AgeRecipient != nil {
|
|
ext = ".zap.age"
|
|
}
|
|
return path.Join(r.cfg.Path, "inc", fmt.Sprintf("%020d%s", version, ext))
|
|
}
|
|
|
|
func (r *Replicator) snapKey(t time.Time) string {
|
|
ext := ".zap"
|
|
if r.cfg.AgeRecipient != nil {
|
|
ext = ".zap.age"
|
|
}
|
|
return path.Join(r.cfg.Path, "snap", fmt.Sprintf("%d%s", t.UnixNano(), ext))
|
|
}
|
|
|
|
// versionFromKey extracts the version number from an incremental backup key.
|
|
func versionFromKey(key string) uint64 {
|
|
base := path.Base(key)
|
|
// Strip extensions (.zap, .zap.age)
|
|
base = strings.TrimSuffix(base, ".age")
|
|
base = strings.TrimSuffix(base, ".zap")
|
|
v, _ := strconv.ParseUint(base, 10, 64)
|
|
return v
|
|
}
|
|
|
|
// encrypt pipes plaintext through age encryption and returns the ciphertext buffer.
|
|
func encrypt(plaintext io.Reader, recipient age.Recipient) (*bytes.Buffer, error) {
|
|
var out bytes.Buffer
|
|
w, err := age.Encrypt(&out, recipient)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := io.Copy(w, plaintext); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := w.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|