VeltrixDB Docs
v1.16 GitHub FAQ Quickstart

Cluster & Consensus

VeltrixDB distributes data across nodes with a consistent-hash ring, detects failures via gossip, fans searches out to every node, and offers three deployment modes with distinct consistency guarantees — from single-node linearizability to Raft-backed quorum writes to configurable async/quorum/strong replication.

Scope This page goes deep on partitioning, gossip failure detection, deployment-mode consistency semantics, the Raft FSM, replication ACK behavior, and the cluster-aware client. For the system diagram and single-node write/read path, see Architecture Overview.

Consistent-hash partition map

The ConsistentHashRing (cluster/partition_map.go) maps a 64-bit hash space onto physical nodes using virtual nodes:

The finalizer matters: raw FNV-1a has weak avalanche for inputs that differ only in a short suffix, so sequential key patterns (user:0001…user:0999) landed within a few multiples of the FNV prime of each other — far closer together than one vnode arc — and entire sequential runs collapsed onto one node. fmix64 makes every input bit affect every output bit, restoring uniform placement for sequential keys. Because both the server and the cluster client route through the same cluster.HashKey function, they stay mutually consistent — but changing this hash re-shards ring placement (the v1.1.0 switch was made as a pre-GA breaking change for exactly that reason).

Virtual nodes solve two problems that a naive one-hash-per-node ring has: they even out load across physical nodes (more virtual nodes per physical node means a more even share of the ring), and they make rebalancing gradual — adding one physical node moves roughly 1/N of the ring's data instead of a bulk reshuffle.

Above the ring, a PartitionMap divides the hash space into 256 fixed partitions, each with a primary node and up to ReplicationFactor - 1 replica nodes (default replication factor: 3). Every mutation to the partition map — node add, node remove, state change, rebalance — increments a monotonic Version, reported in topology and gossip but not used to reject anything. The partition table is metadata (topology views, admin); key placement and migration use the ring.

Membership and client-side routing

On v1.16 and earlier membership is static: each server registers the nodes named in --peers id@host:port,... (or --seeds) at startup. There is no command or admin endpoint that adds, drains or removes a node at runtime — to add a node, restart every node with it in --peers. New on main after v1.16 — ships in the next release nodes are added and removed online through /admin/cluster/members and veltrix cluster, and the persisted membership wins over --peers once a change was made (see Online membership changes). Gossip still does not carry membership. The server also does not forward a request to the key's owner: routing is done by the cluster-aware client (below). In raft mode the leader takes every write and followers answer MOVED.

Rack-aware replica placement (v1.1.0)

Placement is failure-domain-aware when nodes are started with --rack-id <zone> (e.g. --rack-id=asia-south1-a): partition placement during rebalance and GetReplicasForKey never put two copies of the same partition in the same rack — unless the replication factor exceeds the number of distinct racks, in which case placement falls back to filling with distinct nodes while still spanning every rack available. Rack labels propagate between members via gossip digests (a node's self-reported entry is authoritative; third-party entries only fill blanks), so each node only needs its own flag, and /admin/cluster reports a "rack" field per node. Clusters with no rack labels keep the exact previous deterministic round-robin placement.

Epoch fencing

The fencing check is a separate membership epoch (cluster/epoch.go), advanced by every AddNode / RemoveNode and by newer epochs seen in gossip. Every key-migration batch carries the sender's epoch; a receiver whose epoch is newer refuses it with HTTP 409, so a node with a stale view cannot push keys into a cluster that has moved on. No node fetches a partition map from a peer.

Gossip-based failure detection

Node liveness propagates via epidemic gossip: every second a node advances its own heartbeat counter and exchanges a JSON digest with 3 random peers over one short TCP connection each. A node whose heartbeat counter advanced in a digest counts as alive, so liveness spreads transitively. A receiver adopts gossip addresses and racks (self-reported entries win) and newer epochs, ignores nodes it does not know, and does not adopt the sender's view of node states — each node derives states locally from heartbeat ages, checked every second.

The heartbeat state machine

Each node cycles through a small set of states, driven by how long it's been since the last heartbeat was observed:

StateTriggerMeaning
ACTIVE—Fully operational
SUSPECTNo heartbeat for 3 sHealth uncertain; back to ACTIVE on the next heartbeat
FAILEDNo heartbeat for 10 sLeft out of Rebalance's partition table and of search fan-out. It stays on the ring, so keys it owns still route to it
RECOVERINGA FAILED node heartbeats again or answers the recovery pingLike FAILED, left out of Rebalance. On v1.13 and earlier nothing moves it back to ACTIVE — it stays RECOVERING (visible in /admin/cluster). Fixed in v1.14 after 2 consecutive successful recovery pings (every 5 s) with a fresh heartbeat it becomes ACTIVE and is rebalanced back into the partition table; it is searched again as soon as it is RECOVERING
DRAININGSet by the library call RemoveNodeAndRebalance just before removalDeliberate departure; the server itself never calls it

The two-stage SUSPECT → FAILED transition (3 s, then 10 s) exists to absorb transient network blips without immediately evicting a healthy node from routing — a brief GC pause or packet loss burst produces a SUSPECT flap, not a full failover. A background recovery worker pings a FAILED node (TCP PING, 2 s timeout) up to 3 times back to back; if it answers, the node becomes RECOVERING and a rebalance is triggered. If all attempts fail it stays FAILED until a heartbeat arrives.

v1.13 and earlier: no way back to ACTIVE On v1.13 and earlier a node that was marked FAILED ends up RECOVERING at best and never returns to ACTIVE, so it stays out of the partition table until the cluster restarts. Separately, in replicated mode a replica whose send failed is marked FAILED by the replication engine and is skipped by replication and anti-entropy until the primary restarts. Fixed in v1.14 RECOVERING → ACTIVE after 2 good pings (veltrixdb_failure_detector_nodes_reactivated_total), and the replication engine re-probes FAILED replicas with backoff and catches them up through SYNC_PENDING (see Replication & CDC).

Deployment modes and consistency guarantees

The distributed layer is wired into cmd/server through a write coordinator (cmd/server/coordinator.go) that fronts the storage engine. Every mutating client operation — PUT, DELETE, MultiPut, CAS, INCR, DECR, SETNX, TXN, and (since v1.1.0) namespace, hash-field, vector, secondary-index, and list/set writes, on both text and binary protocols — goes through it. In raft mode each of these is a dedicated FSM op replayed deterministically on every member; in replicated mode they decompose to plain KV writes on composite internal keys (ns\x00key, key\x01field, @vec/…, key\x02seq, key\x03member) and ship as ordinary replication traffic. The --mode flag selects one of three behaviors. Raft and replicated mode are mutually exclusive: the server builds either a Raft coordinator or a replicated one, never both, and in raft mode no replication engine exists.

--modeHow writes are handledConsistency guarantee
standalone (default)Straight to the local engine — byte-for-byte the pre-existing single-node path. No Raft, no replication, no redirects.Single-node linearizable (one writer, one copy).
raftOps are gob-encoded and submitted to a Raft log, committed by quorum, and applied on every node via a storage-backed FSM. Non-leaders reject writes with MOVED <leader-addr> <leader-id> (MOVED - (leader unknown, retry) while no leader is known).Linearizable writes (single Raft group, quorum commit). Reads default to local applied state (fast, possibly stale on followers); a new leader holds GET / MGET until its no-op applies. With --linearizable-reads, GET and MGET run the ReadIndex fence — quorum-confirmed, never stale.
replicatedThe write is applied to the local engine, then handed to the replication engine. The --consistency flag decides when the client is ACKed.Primary-copy durability across N copies; not linearizable under concurrent writers (no single-writer ordering). Reads are local.

standalone is the pre-existing single-node path with zero distributed-systems overhead. raft trades read freshness for strict write ordering and quorum durability. replicated trades strict write ordering for configurable, tunable ACK latency across copies.

New in v1.7 The opt-in C++ network front-end (--net=cpp|uring|poll) writes straight to the engine, bypassing Raft/replication routing, so the server refuses it with --mode=raft or --mode=replicated; cluster modes always run on the default --net=go.

Replication consistency levels (replicated mode)

The --consistency flag controls when the client receives its ACK:

quorum and strong surface explicit errors to the client when the required copies cannot be reached rather than silently downgrading the guarantee:

For example, a strong write attempted while a majority of replicas are down returns an error instead of ACKing with fewer copies durable than requested — the client's consistency contract is never silently weakened.

The write coordinator

The write coordinator is the single entry point every mutating operation passes through before reaching the storage engine, regardless of deployment mode. In standalone mode it is effectively a pass-through. In raft mode it submits the operation to the Raft log and blocks until the FSM has applied it. In replicated mode it performs the local write first, then hands the operation to the replication engine and blocks according to the configured consistency level.

Raft FSM: Apply, Snapshot, Restore

raftFSM (cmd/server/raft_fsm.go) implements consensus.SnapshotStateMachine:

Non-leader nodes reject writes with a MOVED <leader-addr> <leader-id> redirect rather than silently forwarding or queuing them — this keeps the write path simple and pushes leader discovery to the client (see the cluster-aware client below).

Raft log persistence and follower durability

Each node keeps its Raft state in <--data>/raft/. On v1.14 and earlier the log, term and vote are one file, raft_state.gob, and every flush gob-encodes and fsyncs the whole retained log into it (up to 8192 entries, about 8 MB with ~1 KB values), on followers even for heartbeats. A follower also writes and fsyncs each AppendEntries while holding the node lock, so heartbeats, votes and follower reads wait behind that fsync.

Fixed in v1.15 (consensus/raft_storage.go, consensus/pipeline.go):

Measured on one laptop only (3 local processes sharing one drive, go-ycsb, 20K records, 16 threads, one run each; not a cluster result): raft load went from 70 to 1,046 inserts/s and workload A from 87 to 1,212 ops/s.

Upgrade is automatic; rollback depends on the target On first start an existing raft_state.gob is migrated and renamed to raft_state.gob.migrated (the log prints [raft] migrated …). v1.14 and earlier cannot read raft_log.dat / raft_meta.dat: started on a migrated directory, an older build comes up at term 0 with an empty log and can vote twice in a term, so do not just start the old binary on it. To downgrade a node, stop it, delete its whole --data directory, start the older build with the same --node-id / --peers and let it re-sync from the leader — one node at a time, with a healthy leader and the other nodes up. Fixed on main after v1.16 — ships in the next release rolling a node of the next release back to v1.15 or v1.16: a node with no raft_snapshot.dat starts as it is; a node with one must be wiped and re-synced (v1.16 refuses it with corrupt raft log … does not follow the snapshot (index 0)); v1.14 and earlier always need the wipe. See Streaming snapshots.

Pipelined replication: --raft-pipeline

Fixed in v1.15 --raft-pipeline (default false; env VELTRIX_RAFT_PIPELINE) controls how the leader sends AppendEntries. Off: one in flight per follower, sent once per leader fsync. On: up to 8 in flight per follower, sent before the leader's own fsync. Durability and commit rules are the same either way. It is off by default because, with three nodes sharing one disk on one laptop, pipelining was slower for raft load (560 vs 1,038 inserts/s, medians of 2 runs), though faster on workload B (10,547 vs 5,630 ops/s). It has not been measured on separate hosts: turn it on only after a multi-host benchmark on your hardware shows it ahead (see Benchmarking). It has no effect outside --mode=raft.

Streaming snapshots and chunked InstallSnapshot Fixed on main after v1.16 — ships in the next release

On v1.16 and earlier the snapshot is one in-memory blob in raft_snapshot.gob: a snapshot over 256 MB is skipped (snapshot too large ... skipping compaction) and the log then grows in memory and on disk without bound; the capture holds Apply for the whole keyspace scan; TTLs are dropped; a follower's restore only writes the snapshot's pairs, so every key deleted on the leader while it lagged (with its vectors, documents and @idx/ entries) survives on it; and secondary-index definitions are not carried. On main (consensus/snapshot.go, cmd/server/raft_snapshot.go):

Laptop measurement, 1,000,000 keys: slowest write during a capture 2.76 s → 28 ms (Apply fence 0.2–0.3 ms); a follower catching up through InstallSnapshot 42 s → 2.6 s. Laptop numbers, not a cluster result.

Mixed versions during a rolling upgrade from v1.16 A v1.16 follower still gets the old single-shot InstallSnapshot when it fits that build's 256 MB limit (otherwise the leader logs NOT sending snapshot … upgrade peer); it loses every TTL in it, and v1.16's restore never deletes, so keys deleted while it lagged survive on it. A v1.16 leader's snapshot strips the TTLs of every key it carries to an upgraded follower. Upgrade followers first, one at a time, the leader last, and the next node only after the restarted one has applied a fresh write; upgrade a lagging v1.16 follower before it needs a snapshot. Rolling back to v1.15 / v1.16: a node with no raft_snapshot.dat starts as it is; a node with one must be wiped and re-synced; v1.14 and earlier always need the wipe.

Exactly-once apply across restarts Fixed on main after v1.16 — ships in the next release

Raft persists the log, term and vote, but on v1.16 and earlier not which entries the state machine applied. Every restart therefore re-applies the retained log (or the tail after the snapshot) over an engine that already holds it: INCR / DECR counters double (INCR ×10 then a clean restart reads 20), list pushes repeat, version-guarded TXNs fail their guard and relative TTLs restart; in a rolling restart each node ends with different counters.

On main the engine appends an applied-index marker to its WAL after each applied run (no extra fsync), and in --mode=raft WAL replay stops at the newest marker, so the engine holds exactly the entries up to it and raft skips them; a crash mid-entry rolls the entry back as a whole and it is applied again once. A node with a snapshot also skips the O(keys) startup restore when its engine is already past the snapshot (single node on an Apple-silicon laptop, snapshot of 1,000,000 × 128 B keys, two runs: raft start 1.62 s / 1.91 s → 78 ms / 145 ms; engine WAL replay unchanged). Upgrading a node with no marker: with a snapshot, the snapshot is restored and the tail applied; without one, the engine is reset and the whole log applied once. An older build ignores the marker; v1.16 shows it as one extra live key ("\x00veltrix/raft-applied"), and re-upgrading such a node needs no wipe.

Deterministic Apply Fixed on main after v1.16 — ships in the next release

On v1.16 and earlier the version token that VER / GETVER return and a TXN SETIF compares is each node's own write timestamp, so a guarded TXN applied through the log can hold on the node whose token the client read and fail on the others — the replicas diverge. Relative TTLs also become different deadlines on each node. On main the leader stamps every command with its clock and each node applies it at the derived raft time max(Ts, previous + 1): versions are the same on every node, monotonic per key, and survive restarts, snapshot installs and rebuilds; TTL deadlines and every expiry decision in Apply (SETNX, CAS, INCR, SETIF expecting 0, list and hash ops) use that time. Apply also waits for the engine's WAL replay, and namespace quotas are checked by the leader before it submits. Wire format unchanged.

Rolling-upgrade restriction Until every node runs the new build and a new-build leader has applied a write, SETIF / guarded TXN are as nondeterministic as on v1.16 — do not use them during the upgrade. Keys last written before the upgrade read version 1 on every node.

Online membership changes New on main after v1.16 — ships in the next release

GET / POST /admin/cluster/members and DELETE /admin/cluster/members/{id} (admin auth), or veltrix cluster members | add-node id@host:port | remove-node id [--force]. Any node forwards a change to the raft leader or the replicated-mode coordinator over the transfer listener, signed with the cluster secret. Changes serialise: a second one while the first is uncommitted gets 409. Step-by-step procedures are in the membership runbook.

Reads on a new leader: the no-op barrier New in v1.12

A newly elected leader appends a no-op entry in its own term (Raft §5.4.2). Until that no-op is applied, its state machine may not yet contain entries its predecessor committed and acknowledged, so a local GET right after failover could return "not found" for an acknowledged write. RaftNode.WaitLeaderApplied now holds reads on a leader until lastApplied reaches the no-op (bounded by 2 s); after that it is one atomic load. Followers never wait — their local reads are stale by design.

Every read that passes coordinator.readBarrier() gets this — on v1.13 and earlier, GET and every MGET path. In a unit test, a read right after failover missed the acknowledged write in 20 of 20 failovers without the barrier and 0 of 20 with it.

v1.13 and earlier: other reads skip the barrier On v1.13 and earlier, namespace, hash-field, list/set and RANGE / SCANCUR reads do not pass readBarrier(), so they get neither the new-leader barrier nor the ReadIndex fence, even with --linearizable-reads. Fixed in v1.14 every read on both protocols passes it — namespace (NSGET / NSSCAN / NSLIST), hash, list and set reads, RANGE / SCANCUR, VER / GETVER, IDXQUERY, QUERY and vector / text / hybrid search. A barrier failure answers an error (MOVED …) and keeps the connection.

Linearizable reads: the ReadIndex fence

A local read on the Raft leader can still be stale: a partitioned ex-leader may keep serving reads after a new leader has committed writes elsewhere. --linearizable-reads (v1.1.0, raft mode only) closes that hole with the Raft §6.4 ReadIndex protocol (consensus/read_index.go), which never writes to the log:

  1. Capture the fence. The leader records its current commitIndex as the read fence. The fence is only valid once the leader has committed at least one entry in its own term (otherwise commitIndex may predate a newer leader's writes) — immediately after an election the read returns a retryable ErrLeadershipUnconfirmed until the election no-op commits.
  2. Confirm leadership with a quorum heartbeat round. The leader sends a fresh AppendEntries heartbeat to every peer; any reply whose term does not exceed the leader's counts as an acknowledgement (a log-lagging follower still recognises the leader). Reaching a quorum proves no higher-term leader existed at the moment of the round. A reply with a higher term deposes the leader instead.
  3. Wait for apply catch-up, then read. The read blocks until the local state machine has applied up to the fence, then the coordinator performs the actual storage read.

Cost and routing: one heartbeat round-trip per GET or MGET (per read of any kind since v1.14) (a single-node cluster skips the round — the leader is the quorum), plus any apply catch-up, bounded by a 2-second per-read timeout (ErrReadIndexTimeout). Followers reject linearizable reads with a MOVED <leader-addr> <leader-id> redirect, exactly like writes.

Auto-rebalance and the TransferAgent

Before v1.1.0, cluster/partition_transfer.go was fully implemented but never instantiated by the server: a node join or leave updated the ring (routing) while the data stayed put. cmd/server/rebalancer.go closes that gap by subscribing to PartitionMap membership events (SubscribeMembership) and wiring them to physical key migration:

PartitionMap membership event (add / remove / state change)
     │  debounce 3 s — coalesce a burst of gossip events
     ▼
pm.Rebalance(pm.PartitionCount())   — recompute partition ownership
ta.MigrateToNewOwners()             — stream re-owned keys, delete local copies
v1.14 and earlier: a node failure can delete keys On v1.14 and earlier, with the default --auto-rebalance=true, a node marked FAILED makes every node send each key to its single ring owner and delete it locally — outside the Raft log and the replication stream, ignoring the replication factor, and in raft mode too. Killing the leader of a 3-node raft cluster loaded with 20,000 keys left 5,435 and 7,875 keys on the survivors; the new leader answered “key not found” for the rest. Run clusters with --auto-rebalance=false until you upgrade. Fixed in v1.15 migration never runs in --mode=raft (--auto-rebalance is a logged no-op and inbound /transfer/keys gets HTTP 403: every node applies the whole log), never runs on a node state change, and is RF-aware: a node keeps every key it is one of the replicas of, copies keys to replicas that newly need them, and deletes a key only after it left that key's replica set and every current replica acknowledged it.

Distributed search fan-out New in v1.11

Search writes (VSET, TSET, VCREATE, IDXCREATE, …) go through the normal raft / replicated write path. Search reads — every vector, text and hybrid search, plus QUERY and IDXQUERY — run on every non-failed node and are merged (cmd/server/search_fanout.go):

A search costs one round trip to every node, so its cost grows with the cluster. See Vector & Hybrid Search for commands and measurements.

The cluster-aware client

client.Client (client/client.go, the Go client in the server repo) is a real topology-aware client, not a dumb connection wrapper:

/admin/cluster additionally reports node role, Raft term and leader, peer list, partition epoch, and per-replica replication lag — useful both for the client's own topology refresh and for operator diagnostics.

Remaining gaps

What is still node-local or best-effort
  • RANGE / SCANCUR reads are local in cluster modes — they scan the receiving node only. (QUERY, IDXQUERY and the searches fan out; IDXCREATE / IDXDROP replicate in both modes via @idxdef/ keys.)
  • On v1.13 and earlier, only GET / MGET pass the Raft read barrier; namespace, hash, list/set and range reads skip it. Fixed in v1.14
  • Search indexes are not persisted; each node rebuilds them at startup and refuses searches until done.
  • On v1.16 and earlier membership is static (--peers) New on main after v1.16 — ships in the next release (online add / remove, above; one change at a time, and node-to-node membership traffic needs --cluster-secret-file or --cluster-mtls). ForceRemoveNode re-creates no lost copies. On v1.13 and earlier a FAILED node also never returns to ACTIVE Fixed in v1.14.
  • repl-ship has no back-pressure to the source and resolves cross-region conflicts last-write-wins only.

Namespace, hash-field, vector, text, secondary-index and list/set writes, linearizable reads and physical data rebalancing are wired end-to-end through the coordinator, ReadIndex and TransferAgent paths described above.