Files
database/zapdb/replicate_cdc.go
zeekayandHanzo Dev fb16ece6a3 feat(zapdb): native ZAP replication — CDC, physical snapshots, PQ, streaming
Replicates a zapdb to S3 via hanzoai/vfs with no keyspace scan in steady state
and post-quantum encryption out of the box.

- CDC incrementals via db.Subscribe (the WAL tail), not Backup(since)'s O(total)
  scan — a 100-key delta out of 200k keys: 2.76us/19KB vs 20ms/1GB (~7000x).
- Physical SST-copy snapshots (sparse, hole-aware): 134ms/25MB vs logical
  158ms/1.25GB/2.2M allocs. Hydrated before Badger opens; incrementals replay on top.
- Streaming codec: Backup -> zstd -> age into a pooled buffer (peak mem = compressed).
- Post-quantum (ML-KEM-768 + X25519 via luxfi/age v1.5.0), client-side before S3.
  Out of the box via REPLICATE_AGE_KEY_FILE (generate+persist, recipient derived).
- Per-DB streams (REPLICATE_AUTO_ON_OPEN) for subprocess VM plugins; backer/restorer
  split (REPLICATE_BACKUP / REPLICATE_SOURCE_ORDINAL) for multi-node.
- Public imports only (hanzoai/vfs v0.4.1, no replace). Verified live on an EVM
  C-chain end-to-end incl. disaster-recovery exact-match. Canonical KV schema
  (zapdb/zapdb.zap) + zero-copy codec (zapkv.go). Renames engine alias badger->zdb.

Co-authored-by: Hanzo Dev <[email protected]>
2026-06-16 02:00:03 +00:00

132 lines
3.6 KiB
Go

// Copyright (C) 2020-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package zapdb
// Change-data-capture incrementals.
//
// db.Backup(since) finds a delta by SCANNING the whole keyspace — O(total), so a
// 100-key change out of a 200k-key DB costs ~20ms and ~1GB of allocations. ZapDB
// already records every write in its WAL, and db.Subscribe taps that write path
// and PUSHES each committed batch. Draining that feed ships the same delta in
// ~3µs / ~19KB — O(change), not O(DB size). The bytes are framed exactly like
// db.Backup, so Restore/Load consume a CDC incremental with no special case.
import (
"bytes"
"context"
"encoding/binary"
"fmt"
"io"
"github.com/klauspost/compress/zstd"
"github.com/luxfi/age"
log "github.com/luxfi/log"
"github.com/luxfi/zapdb/pb"
)
// runChangeFeed subscribes to every committed write for the life of ctx and
// appends it to the buffer the incremental tick drains. Blocks; run in a
// goroutine. An empty-prefix match means "all keys".
func (r *vfsReplicator) runChangeFeed(ctx context.Context) {
err := r.db.Subscribe(ctx, func(kvs *pb.KVList) error {
r.cdcMu.Lock()
r.cdcBuf = append(r.cdcBuf, kvs.Kv...)
r.cdcMu.Unlock()
return nil
}, []pb.Match{{Prefix: []byte{}}})
if err != nil && ctx.Err() == nil {
log.Warn(fmt.Sprintf("[zapdb] replicate: change feed ended: %v", err))
}
}
// incrementalCDC ships the buffered change feed as one inc/<version> object. No
// scan: the cost is proportional to what changed, not to the database size.
func (r *vfsReplicator) incrementalCDC(ctx context.Context) error {
r.mu.Lock()
defer r.mu.Unlock()
r.cdcMu.Lock()
kvs := r.cdcBuf
r.cdcBuf = nil
r.cdcMu.Unlock()
if len(kvs) == 0 {
return nil
}
var maxVer uint64
for _, kv := range kvs {
if kv.Version > maxVer {
maxVer = kv.Version
}
}
// Frame as [u64-LE len][marshaled KVList] — one db.Backup batch — then run it
// through the shared zstd→age pipeline.
body, err := pb.Marshal(&pb.KVList{Kv: kvs})
if err != nil {
return fmt.Errorf("marshal changes: %w", err)
}
payload, raw, err := r.encodeBlob(func(w io.Writer) error {
var hdr [8]byte
binary.LittleEndian.PutUint64(hdr[:], uint64(len(body)))
if _, err := w.Write(hdr[:]); err != nil {
return err
}
_, err := w.Write(body)
return err
})
if err != nil {
return err
}
key := r.incKey(maxVer)
if err := r.be.Put(ctx, key, payload); err != nil {
return fmt.Errorf("upload %s: %w", key, err)
}
r.sinceVersion = maxVer
r.incsSinceSnap++
log.Info(fmt.Sprintf("[zapdb] replicate: cdc incremental %s (%d changes, %d→%d bytes, version %d)",
key, len(kvs), raw, len(payload), maxVer))
return nil
}
// encodeBlob runs produce through the [zstd]→[age] codec chain and returns the
// encoded bytes plus the uncompressed byte count. The CDC delta is small, so this
// uses a plain buffer (the large backup/snapshot paths stream into a pooled one).
func (r *vfsReplicator) encodeBlob(produce func(io.Writer) error) ([]byte, int64, error) {
var out bytes.Buffer
var sink io.Writer = &out
var ageW io.WriteCloser
if r.recipient != nil {
w, err := age.Encrypt(&out, r.recipient)
if err != nil {
return nil, 0, fmt.Errorf("encrypt: %w", err)
}
ageW, sink = w, w
}
var zw *zstd.Encoder
if r.compress {
zw = zstdWriterPool.Get().(*zstd.Encoder)
zw.Reset(sink)
sink = zw
}
cnt := &countWriter{w: sink}
err := produce(cnt)
if zw != nil {
if e := zw.Close(); err == nil {
err = e
}
zstdWriterPool.Put(zw)
}
if ageW != nil {
if e := ageW.Close(); err == nil {
err = e
}
}
if err != nil {
return nil, 0, err
}
return out.Bytes(), cnt.n, nil
}