mirror of
https://github.com/luxfi/database.git
synced 2026-10-30 15:01:18 +00:00
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]>
132 lines
3.6 KiB
Go
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
|
|
}
|