VeltrixDB Docs
v1.16 GitHub FAQ Quickstart

Replication & CDC

VeltrixDB exposes an in-process change data capture (CDC) stream over HTTP, a durable catch-up feed, and a standalone repl-ship binary that forwards writes to a remote cluster. In-cluster replication (Raft or the replication engine) is a separate mechanism, summarised at the end.

In-process CDC

Writes and deletes are published to an in-process CDC stream (in v1.12 only single-key ones — see below), exposed as newline-delimited JSON (NDJSON) over HTTP on the admin port (loopback-only unless -admin-token is set):

curl "http://localhost:2112/admin/cdc?prefix=&buffer=1024"

The optional prefix query parameter filters the stream to keys starting with that prefix; an empty prefix streams every event. buffer sets this subscription's channel depth (default 1024). Each line is a JSON object describing the operation:

{"Op":"PUT","Key":"orders/4521","Value":"...","Timestamp":1751464805123456}
{"Op":"DEL","Key":"orders/4400","Timestamp":1751464806012345}

Timestamp is microseconds since the Unix epoch, and Value is base64 (JSON encoding of the raw bytes). This endpoint is a live subscription — the connection stays open and streams events as they happen, similar to tailing a log.

What the live stream does and does not carry. v1.12: events are emitted only by the engine's single-key PUT and DEL. Batched writes — MPUT, pipelined PUTs the server coalesces into one batch, transactions, Raft's coalesced apply — and atomic ops (CAS/INCR/DECR/SETNX) produce no live event; their final state is visible only through the /admin/changes feed. The broker never blocks the writer: when a subscriber's channel is full the event is dropped for that subscriber (cdc_dropped_total in /admin/stats, not a Prometheus metric), and after 3 drops in total over its lifetime (not consecutive) the subscription is closed. Fixed in v1.14 every write path emits one event per key — PUT, DEL, MPUT, pipelined PUTs, transactions, Raft batch apply, and the atomic ops (as PUT; a CAS mismatch or a SETNX on an existing key writes nothing and emits nothing). TTL expiry emits nothing. A subscriber is closed only after 3 consecutive drops; a successful send resets the count.

Cross-process forwarding with repl-ship

The repl-ship binary subscribes to a local node's /admin/cdc endpoint and forwards each event to a remote VeltrixDB cluster over its binary protocol — each PUT event becomes a binary PUT (0x01) and each DEL event a binary DEL (0x03), sent one at a time. It is not in the server image; build it from the main repository:

go build -o repl-ship ./cmd/repl-ship

repl-ship --src http://primary:2112 \
  --dst-tcp replica.us-east:9000 \
  --batch 64 \
  --prefix "" \
  --deadletter /var/log/repl-ship/deadletter.jsonl
FlagDefaultPurpose
--srchttp://127.0.0.1:2112Local VeltrixDB metrics/admin URL (CDC source)
--dst-tcp—Remote VeltrixDB binary-protocol address (required)
--batch64Events buffered before a flush (a flush also runs every 200 ms). The flag help says "per remote MultiPut", but events in a flush are still sent as individual binary PUT/DEL requests.
--max-retries5Retry budget per event (exponential backoff) before deadlettering
--deadletter/var/log/repl-ship/deadletter.jsonlPath for events that exhaust their retry budget
--prefixemptyCDC key-prefix filter; empty ships everything
--checkpointemptyPath to the checkpoint file (last shipped write-timestamp). Empty = no catch-up on restart
--checkpoint-every256Persist the checkpoint after this many shipped events
--src-tokenempty (falls back to VELTRIX_ADMIN_TOKEN)Admin bearer token sent to the source's /admin/cdc and /admin/changes when the source runs with -admin-token. Fixed in v1.14 --admin-token is accepted as an alias, the CDC prefix is URL-escaped and HTTP error bodies are logged

When the source node's admin API is guarded (-admin-token — see Admin API access control), pass the same token to repl-ship via --src-token or the VELTRIX_ADMIN_TOKEN environment variable so it can authenticate against /admin/cdc and /admin/changes.

repl-ship maintains one persistent TCP connection to the destination and reconnects on error. On remote-write failure it reconnects and retries with exponential backoff (100 ms, 200 ms, 400 ms, …) up to --max-retries, then appends the event to the deadletter file for manual replay. There is no conflict resolution: each event is applied at the destination as a plain PUT/DEL in arrival order (last write wins). The destination does no version-vector deduplication either — version vectors exist in the replication library but are not used.

Durable cross-region catch-up

The live CDC stream is in-memory: a subscriber that is down misses every event published while it was away. The /admin/changes feed closes that gap. It is an index-backed catch-up feed — it scans the index for entries written at or after a microsecond cursor, so a restarted shipper can replay exactly the delta it missed before rejoining the live stream:

curl "http://localhost:2112/admin/changes?since=1751464805123456&limit=10000"

The response is NDJSON: CDC-shaped events ordered by write timestamp, followed by one trailer line with the pagination cursor:

{"Op":"PUT","Key":"orders/4521","Value":"...","Timestamp":1751464805123456}
{"Op":"DEL","Key":"orders/4400","Timestamp":1751464806012345}
{"cursor":1751464806012345,"more":false}

Semantics of the feed:

To make repl-ship use the feed, run it with a checkpoint file:

repl-ship --src http://primary:2112 \
  --dst-tcp replica.us-east:9000 \
  --checkpoint /var/lib/repl-ship/ckpt \
  --checkpoint-every 256

repl-ship persists the last shipped write-timestamp (µs) to the checkpoint file — atomically, via tmp-file + rename — after every --checkpoint-every shipped events and on shutdown. On restart it first pages through /admin/changes from the checkpoint cursor, shipping the entire missed delta, and only then subscribes to the live /admin/cdc stream — no lost final states across shipper downtime, at last-write-wins semantics (intermediate versions of a key are not replayed). Without --checkpoint, no catch-up runs on restart and events published while the shipper was down are still missed.

Remaining caveats. Conflict resolution is last-write-wins only, with no deduplication at the destination. On v1.13 and earlier, while repl-ship is running it ships only what the live stream carries, so batched writes and atomic ops reach the destination only after a restart with --checkpoint replays /admin/changes (Fixed in v1.14 the live stream carries every write path). And there is no back-pressure signal to the source: local writes keep succeeding even while the destination is degraded or unreachable.

Treat repl-ship-based replication as asynchronous, eventually-consistent geo-distribution. For durability across copies within a cluster, use the Raft or replication-engine consistency modes described below, and use backup/PITR for point-in-time recoverability.

In-cluster replication modes

CDC and repl-ship are independent of in-cluster replication, which --mode selects. Raft and the replication engine are mutually exclusive: a server runs exactly one of them (in raft mode no replication engine exists). Full detail is in Cluster & Consensus.

--mode=raft--mode=replicated
WritesQuorum-committed through one Raft log; non-leaders answer MOVED <addr> <id>Any node writes locally, then ships to its peers (port client + 1)
Acknowledged afterCommit and apply on the leader--consistency: eventual (default) = local write; quorum = RF/2+1 copies; strong = RF copies (10 s timeout)
ReadsLocal applied state. GET and MGET pass a read barrier (a new leader waits for its no-op; --linearizable-reads adds the ReadIndex fence). In v1.12 namespace, hash and RANGE/SCANCUR reads skip it and can be stale; Fixed in v1.14 every read passes itAlways local
ConflictsNone (single log order). On v1.16 and earlier the SETIF version token is each node's own timestamp, so a guarded TXN can hold on one node and fail on another; Fixed on main after v1.16 — ships in the next release versions come from the leader's clock and match on every node (details)None resolved: replicas apply ops in arrival order. Version vectors are defined but never set or compared, and in v1.12 veltrixdb_replication_conflict_resolutions_total stays 0 (removed in v1.14, with vector_clock_updates_total)
Catch-upAppendEntries backfill (on v1.16 and earlier a restarted node re-applies its retained log, doubling INCRs; Fixed on main after v1.16 — ships in the next release exactly once, details); InstallSnapshot once the log passed 8192 entries (on v1.16 and earlier: snapshot capped at 256 MB, TTLs not preserved, deleted keys survive on the follower; Fixed on main after v1.16 — ships in the next release no cap, streamed in chunks, restore equals the snapshot, TTLs kept — details)Anti-entropy every 30 s re-sends un-acked ops to LAG replicas only. Fixed in v1.14 FAILED replicas are re-probed and caught up (below)
v1.12: a FAILED replica never recovers on its own. In replicated mode any failed send marks the replica FAILED; failed replicas are skipped by replication and by anti-entropy, and nothing moves them back to SYNC — in practice a replica stays failed until the primary restarts. A full replication write queue (10,000 ops) rejects the op. Watch /admin/cluster (replication[].state) and veltrixdb_replication_failures_total. Replication lag is exported as veltrixdb_replication_lag_bytes; veltrixdb_replication_lag_nanoseconds is always 0.
Fixed in v1.14 the recovery worker re-probes each FAILED replica with backoff (1 s, doubling to 30 s); one that answers goes to SYNC_PENDING, is sent every write it has not acked (or, if the in-memory retention buffer overflowed, the primary’s change feed first) and returns to SYNC. A full write queue keeps the op and lets anti-entropy deliver it. veltrixdb_replication_lag_nanoseconds becomes the age of the oldest write a replica has not acked; new gauges replicas_failed / replicas_sync_pending and counters recovery_probes_total / replica_recoveries_total. Tombstones are reaped only once every replica’s watermark has passed them (capped at 2× the grace period). Retention is in memory: on v1.16 and earlier a replica that was down when its primary restarted does not get the writes made before that restart (see below).

Replica catch-up across a primary restart Fixed on main after v1.16 — ships in the next release

On v1.16 and earlier the retention buffer and per-replica acks are in memory only, so a restarted primary takes every replica as caught up: a replica that was down while its primary restarted never receives the writes and deletes made before the restart. A clean shutdown also keeps no tombstones, and a returning node can push its stale copies of peers' keys back and undo their deletes. On main:

Still open: concurrent writers to one key on different nodes are not ordered (no conflict resolution), so the nodes can end up holding different values for it permanently. Write each key through one node if replicas must agree, or use raft mode.

The C++ network front-end is standalone-only New in v1.7 The experimental -net=cpp|uring|poll front-end writes straight to the local engine, bypassing Raft and replication routing, so the server refuses to start with it in -mode raft or -mode replicated. Clustered nodes must use the default -net=go. A standalone destination running the C++ front-end can still receive repl-ship traffic, since it serves binary PUT and DEL.