Architecture Overview
How a write and a read actually move through VeltrixDB, from the TCP listener down to NVMe.
System diagram
The serving path is Go, with two C++ exceptions on a cgo build: the per-shard index tables live off the Go heap in C++ (default on every cgo build with Go ≥ 1.21, macOS included), and --net=cpp swaps in an opt-in C++ network front-end. Dashed boxes are optional / opt-in.
Client (any SDK or nc)
│ TCP ┆ TCP (opt-in)
▼ ▼
┌──────────────────────────────────┐ ┌┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┐
│ TCP Server (cmd/server) │ ┆ C++ network front-end (netfront/)┆
│ --net=go (default) │ ┆ --net=cpp | uring | poll ┆
│ 1 goroutine per conn │ ┆ event loop per core ┆
│ binary + text protocol │ ┆ binary PUT GET DEL PING MPUT MGET┆
│ PUT/GET coalescing, cap 256 │ ┆ disk-bound reads → goroutines ┆
│ → MultiPut / MultiGet │ ┆ standalone only ┆
└────────────────┬─────────────────┘ └┄┄┄┄┄┄┄┄┄┄┄┄┄┄┬┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┘
│ ┆ vxnfExec, once per
▼ ┆ loop iteration
┌────────────────────────────────────────────────────▼────────────┐
│ Storage Engine (storage/engine.go) │
│ Shard routing: FNV-1a(key) & 0x1FFF → 0..8191 │
│ │
│ Index Vault Bloom filter Sharded LIRS cache │
│ 8192 shards, RWMutex lock-free, up to 256 caches, │
│ 64 B entry, no values per shard keyed on high bits │
│ off-heap C++ table on cgo builds, │
│ Go map when CGO_ENABLED=0 │
│ │
│ Value transform: compress → encrypt (single chokepoint) │
└────────────────────────────────┬────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────────┐
│ Per NVMe disk — N independent sets │
│ WAL: binary records, CRC32C, one write + one fdatasync / batch │
│ VLog: append-only, lock-free reservation, │
│ one pwrite + one fdatasync per batch │
│ Segment writer (O_DIRECT) │
│ ── Defragmenter + VLog GC (3-tier) · Scrubber (CRC32C walk) │
└─────────────────────────────────────────────────────────────────┘
┌┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┐
┆ C++ storage extras — Linux + CGO_ENABLED=1 (Docker image default)┆
┆ batch engine + NUMA pinning (BatchPut / WriteBatcher only) ┆
┆ io_uring VLog write bridge — opt-in, VELTRIXDB_URING_BRIDGE ┆
┆ VELTRIXDB_DISABLE_CGO_ENGINE=1 turns these off ┆
└┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┘
New in v1.6–v1.7 The native index default, the binary WAL, the opt-in io_uring bridge and the --net C++ front-end shipped in v1.6 (native index, binary WAL) and v1.7 (opt-in io_uring bridge, --net front-end).
Sharding
All data is split across 8192 shards. Each shard has its own lock, so a read on shard 5 never blocks a read on shard 42.
key → FNV-1a hash → bottom 13 bits (& 0x1FFF) → shard ID (0–8191)
shard ID % numDisks → which disk (WAL + VLog + compaction)
With 8 disks, 1024 shards land on each disk. A failure on one disk doesn't affect the other 7. The Go numShards constant and the C++ kNumShards constant must stay equal — they are a hard cross-language invariant.
Key components
| Component | Role |
|---|---|
| In-memory index | One table per shard. On a cgo build it is an off-heap C++ table (storage/native_index.cpp) invisible to the Go GC; CGO_ENABLED=0, VELTRIXDB_INDEX=map or VELTRIXDB_DISABLE_CGO_ENGINE=1 select a Go map. Each entry is a 64-byte IndexEntry (storage/types.go): key hash, disk offset, disk index, on-disk and plaintext value sizes, key size, write timestamp, TTL deadline, plaintext CRC32C, shard ID, flags and schema version — no value bytes (the version counter goes into the WAL record, not the entry). Large engines on the native table need vm.max_map_count ≥ 262144. A separate ordered skiplist serves RANGE / SCANCUR. (The C++ ART index under cpp/ is not wired to Go.) |
| LIRS cache | Scan-resistant eviction. Small values (≤256 B) get eviction priority 2 vs. priority 1 for larger values, so a big sequential scan can't push your hot working set out of RAM. Split across up to 256 independent caches, each with its own mutex. Sized with -cache <MB> (default 256 MB). |
| WAL (Write-Ahead Log) | Group-commit: many concurrent writers share one write(2) and one fdatasync. New in v1.12 Adaptive by default (storage/group_commit.go): the 15 ms window is an upper bound — a lone writer is synced at once, concurrent writers after an idle gap equal to the EWMA fdatasync time (clamped to 20 µs–2 ms), or as soon as 4096 records are queued (--wal-max-batch); --group-commit=fixed waits the whole window. The VLog flusher uses the same pacer. One WAL file per disk. Records are binary and checksummed with CRC32C. |
| VLog (Value Log) | Append-only file per disk. Values live here; the index holds only metadata (this is the WiscKey KV-separation). Appends are lock-free via atomic offset reservation; a batch reserves its whole extent with one add and writes it with one pwrite and one fdatasync. |
| Block packing | Batched writes (MultiPut and GC relocation, through VLogBatcher) pack up to ~26 records per 4 KB VLog block. For 128 B values each record takes 152 bytes (24 B header + value) instead of a whole 4096-byte block — ~26× denser. A single Put stays on the unpacked path, one 4 KB-aligned block per record. |
Write path
What happens on PUT user:42 value:
- Hash the key → shard 450 (
FNV-1a & 0x1FFF) → disk 2 (450 % 8, with 8 disks) - Compress, then encrypt the value (
transformForWrite, the single chokepoint) - Atomically reserve a VLog offset and write the value
- Append a WAL record (key, VLog offset, on-disk length, transform flags, version)
- WAL and VLog
fdatasyncrace concurrently — the client waits for the slower of the two, not their sum - Install the shard 450 index entry (disk offset, disk index)
- Insert the value into the LIRS cache
- Return
OK
The batch path (MultiPut) keeps the same order per disk, with one pwrite for the VLog extent and one WAL item for the batch. New in v1.6 It refreshes only keys already in the cache (PutIfPresent) instead of inserting, so bulk writes do not evict the read working set; a single Put stays write-through. The ordered (range-scan) index is touched only when a key becomes live, not on overwrites.
Read path
What happens on GET user:42:
- Hash the key once → cache shard (high bits) and index shard 450 (low 13 bits)
- LIRS cache hit → return the value with no disk I/O (~92 ns in-process)
- Cache miss → Bloom filter says "definitely absent" →
NOT_FOUNDwith no index lookup - Otherwise take a read lock on shard 450 and look up the index entry; absent →
NOT_FOUND, expired TTL → expired - Found → read the value from the VLog at the recorded disk offset: a Go 4 KB-aligned
preadthat verifies magic and CRC32C - Decrypt → decompress, then insert the value into the cache
- Return the value
New in v1.7 StorageEngine.GetNoIO runs the same flow but stops before the VLog read and returns needIO=true; GetAfterNoIO finishes the key. The opt-in C++ network front-end uses it so disk reads never run on an event-loop thread.
Admission control
Background garbage collection competes with foreground reads for NVMe bandwidth. VeltrixDB tracks an EWMA of read latency and throttles GC before reads degrade:
| Read EWMA | Action |
|---|---|
| ≤ 15 ms | Normal — GC runs at full speed |
| > 15 ms | GC bandwidth capped at 60 MB/s (200 MB/s once garbage ≥ 50%) |
| > 20 ms | GC paused; each PUT sleeps 2 ms to shed load |
| < 10 ms (recovery) | Pause and write throttle cleared |
| No reads for 4 minutes | At the next GC pass the EWMA is treated as stale: reset to 0, pause and throttle cleared |
See Admission Control & GC for the full escalation ladder, including the emergency GC tier that bypasses admission pauses to prevent a garbage death-spiral.
C++ layer and network front-ends
The Go layer is fully functional on its own. C++ is optional, and less of it is wired up than the cpp/ file tree suggests:
| Component | Source | Reachable from Go? |
|---|---|---|
| Index Vault (off-heap shard tables) New in v1.6 | storage/native_index.cpp | Yes — default on every cgo build, including macOS |
| Vectorized batch put | batch_engine.cpp | Yes — only the fire-and-forget BatchPut / WriteBatcher path |
| NUMA thread pinning | numa_topology.cpp | Yes — pins the batch engine's threads |
| io_uring VLog write bridge New in v1.7 | storage_bridge.cpp | Yes, opt-in (Linux) — VELTRIXDB_URING_BRIDGE=on or sqpoll; default off |
| SQPOLL reader | uring_reader.cpp | No — compiled in, no Go call site |
| ART index, priority io_uring scheduler, C++ VLog reader, LIRS cache, defragmenter, eBPF GC throttle | cpp/ | No — compiled, no Go call site |
The VLog read path is Go (storage/vlog.go ReadValue), not io_uring. VELTRIXDB_DISABLE_CGO_ENGINE=1 turns off the batch engine and bridge and selects the Go map index.
Network front-ends (--net) New in v1.7
--net=go (default) is one goroutine per connection and serves every protocol and command. --net=cpp / uring / poll selects the C++ front-end in netfront/: one event loop per core (--net-threads, default NumCPU), each with its own SO_REUSEPORT listener, io_uring on Linux and poll() elsewhere. Each loop iteration calls Go once (vxnfExec) for all parsed requests. Reads that need no disk are answered inline via GetNoIO; reads that need a VLog read, and all writes, run on goroutines, and a connection with such a request in flight is not parsed further, so each connection stays strictly ordered. At shutdown the server logs a per-stage latency report.
--mode=standalone, and the server refuses it with --auth-config. TLS, AUTH, the text protocol and extended binary commands stay on --net=go. On a 4-CPU CI runner (measured before reads were deferred) it cut read P50 against --net=go but had a worse read P99 and ~12% lower batch-write throughput. It is not faster overall; benchmark it on your own hardware.Search subsystem New in v1.11–v1.12
Vector, full-text and hybrid search are derived state over reserved keys. A vector, a text document and the KV record that share an id describe one thing; the vector and document are stored as ordinary keys, and the in-RAM indexes are built from them:
PUT / Delete / MultiPut of a reserved key
@vec/<ns>/<id> float32 blob → HNSW index of <ns>
@txt/<ns>/<id> UTF-8 text → BM25 index of <ns>
@vecns/<ns> settings → create / re-encode <ns>
@idxdef/<name> field → create / drop a
secondary index
│
└─ storage/search_hooks.go runs after the
write commits
- Hooks, not call sites.
Put,DeleteandMultiPutcall the search hooks after the write commits, so the indexes follow the KV state whichever path wrote it — the API, Raft apply, replication apply, snapshot restore, partition transfer on rebalance, or backup restore. A hook failure is logged; the KV write has already committed. - HNSW per namespace (cosine; M = 16, 32 on layer 0, efConstruction = 200, efSearch = 64). Each namespace keeps float32 vectors in RAM by default, or int8 / product-quantized codes that re-rank candidates against the float32 vectors read back from the VLog; from v1.12 the layer-0 edges can live in a memory-mapped file (
GRAPH disk). Deletes are tombstones compacted in the background. - BM25 (k1 = 1.2, b = 0.75) over a Unicode tokenizer, and hybrid search that fuses the vector and text rankings by weighted reciprocal rank (rrfK = 60, α default 0.5).
- Rebuild on restart. The graphs and the inverted index are not persisted. After WAL replay,
RebuildSearchIndexesapplies the@vecns/settings and then loads every vector and document on all cores. Until it finishes, searches are refused (search indexes are still rebuilding after restart) unless--search-allow-partial;INFOreportssearch_ready=0 search_loaded=x/y, thensearch_ready=1. KV traffic is not affected. - Clusters. The ring routes
@vec/,@txt/and@idx/keys as the record they belong to, so filters run locally on each node, and each search fans out to every node — see Cluster & Consensus.
Commands, memory layouts, filters and measured recall are in Vector & Hybrid Search.
Cluster layer at a glance
cluster/ partition_map.go consistent-hash ring
(FNV-1a + fmix64, 64 vnodes/node),
256 partitions, RoutingKey
epoch.go epoch fencing (stale → HTTP 409)
failure_detection.go Active → Suspect (3 s)
→ Failed (10 s) → Recovering
→ Active (2 good pings; since v1.14)
gossip.go TCP gossip + digest exchange
partition_transfer.go key migration (/transfer/keys)
replication/ eventual / quorum / strong replication
anti-entropy (re-sends un-acked writes)
FAILED-replica recovery (since v1.14)
consensus/ Raft — election, log replication, snapshots,
ReadIndex, single-server membership changes
Full detail in Cluster & Consensus, including which deployment mode (standalone / raft / replicated) gives you which consistency guarantee.