Data Model
VeltrixDB is a byte-oriented key-value store: keys and values are opaque byte strings, and every higher-level feature — TTLs, hash fields, namespaces, atomic counters, transactions — is layered on top of that single primitive.
Keys and values
A key is an arbitrary byte string and a value is an arbitrary byte string. VeltrixDB does not interpret or type-check value contents at the storage layer — encoding (JSON, protobuf, msgpack, raw binary) is entirely up to the application. Internally, every key maps to exactly one 64-byte IndexEntry in the in-memory index (the "Index Vault"), which stores the disk offset, segment ID, value size, write timestamp, TTL deadline, and a version counter. The value itself lives in the per-disk append-only Value Log (VLog); the index never holds value bytes, which is what keeps random-access lookups to a single cache-line fetch plus, at worst, one NVMe read.
New in v1.6 With the binary WAL format (the default since v1.6), keys containing | or \n are fully crash-safe. With the text WAL of v1.5 and earlier — and with --wal-format=text — such a key stops crash replay at its record, so later acknowledged writes can be lost on a crash restart; avoid those bytes in keys on v1.5 and earlier. See Storage Engine.
Per-key TTL
Every PUT accepts an optional TTL in seconds. A key written with a TTL carries an absolute expiry timestamp (TTLExpiryUs) in its index entry; a TTL of 0 or a negative value means the key is immortal (no expiry). Expiry is checked lazily on read (a GET against an expired key returns not-found) and is also swept by a background TTL scanner so expired entries don't linger indefinitely and get reclaimed by VLog garbage collection.
v1.13 and earlier caveats: the WAL does not carry the TTL, so after any restart (crash replay or clean-shutdown checkpoint) a TTL’d key comes back with no expiry, and one that had already expired comes back; and a key still in the cache can be served after it expires. Fixed in v1.14 every WAL record of a TTL’d key carries its absolute expiry and replay restores it (keys already past it stay deleted); checkpoints, backups and PITR archives carry it too, and an expired key is no longer served from the cache. Before rolling back from that release to an older build, run once with --wal-format=text-legacy and stop cleanly (see WAL rollback).
Hash fields with per-field TTL
A hash key groups multiple named fields under one logical key, and — unlike a single flat value — each field carries its own independent TTL. VeltrixDB implements this without a separate on-disk structure: a hash field is stored as a normal index entry whose internal key is key + "\x01" + field (a reserved separator byte distinct from the namespace separator). Because each field is just another entry in the same sharded index, per-field expiry, per-field overwrite, and per-field storage cost all fall out of the existing PUT/GET/DELETE machinery — no new WAL format or index type was needed.
The operations are:
| Operation | Behavior |
|---|---|
HSET key field value ttl | Stores field → value under key with an independent per-field TTL. |
HGET key field | Returns the field's value, or not-found if the field is absent or expired. |
HDEL key field | Removes a single field. |
HGETALL key | Returns every non-expired field and value under key. |
HKEYS key / HLEN key | Lists field names, or counts non-expired fields. |
HEXPIRE key field ttl | Updates the TTL on an existing field. |
HTTL key field | Returns remaining TTL in seconds: -1 immortal, -2 not found/expired, or the remaining seconds. |
Namespaces
A namespace isolates a slice of the keyspace under a prefix. Internally, a namespaced key is stored as namespace + "\x00" + key — the null byte is a safe separator because it never appears in normal UTF-8 keys — so namespaces reuse the same PUT/GET/DELETE paths and on-disk formats as plain keys; there is no separate namespace table. This makes namespaces a convenient way to give each tenant (or each logical dataset) its own key-count quota and write-rate limit without any additional storage engine.
Namespace operations:
| Operation | Behavior |
|---|---|
PutNS(ns, key, value, ttl) | Writes a key scoped to ns. Enforced against the namespace's rate limit and key-count quota before the write happens. |
GetNS(ns, key) / DeleteNS(ns, key) | Reads or deletes a namespaced key. |
DropNamespace(ns) | Scans all index shards in parallel and deletes every key belonging to ns. Returns the count of keys removed. |
ScanNamespace(ns, prefix, limit) | Returns up to limit key-value pairs from ns whose key starts with prefix (the NSSCAN wire command). |
ListNamespaces() | Returns every namespace with at least one live key, plus its key count. |
Namespace quotas are enforced by a QuotaManager: a token-bucket rate limit covers PUT/DEL/CAS/INCR/SETNX (reads are never rate-limited), and a separate key-count cap rejects new-key writes once a namespace is full — overwrites of existing keys and deletes still succeed over quota, so a tenant can always delete its way back under the limit. Quota state is in-memory; key counts are rebuilt from the index on WAL replay after a restart.
Lists & deques
A list (added in v1.1.0) is a double-ended queue with Redis-style semantics. Like hash fields and namespaces, lists introduce no new on-disk format: each element is a regular KV entry whose internal key is key + "\x02" + seq, where seq is an 8-byte big-endian sequence number (big-endian so lexicographic scans return list order), and a meta entry at key + "\x02!" stores the list's [8B head][8B tail] bounds. A push allocates the next sequence outward from the appropriate end; a pop consumes inward. Multi-key push/pop operations (element + meta) serialize on a striped per-list mutex, and the element is written before the meta commits the new bounds — a crash in between leaves an orphan element outside the committed range, which is invisible to reads and overwritten by the next push at that sequence.
| Operation | Behavior |
|---|---|
LPUSH key value / RPUSH key value | Prepends / appends value; returns the new list length. |
LPOP key / RPOP key | Removes and returns the first / last element, or NIL when the list is empty. |
LLEN key | Returns the number of elements (0 for a missing list). |
LRANGE key start stop | Returns elements in [start, stop] with Redis semantics: stop is inclusive, negative indices count from the end (-1 = last element), and out-of-range indices are clamped. One element per line, terminated by END. |
$ nc localhost 9000
RPUSH jobs render-frame-1
1
RPUSH jobs render-frame-2
2
LPUSH jobs urgent-fix
3
LRANGE jobs 0 -1
urgent-fix
render-frame-1
render-frame-2
END
LPOP jobs
urgent-fix
LLEN jobs
2
Sets
A set (added in v1.1.0) stores unique string members under one key. Each member is a regular KV entry whose internal key is key + "\x03" + member with an empty value — membership is simply the existence of the entry, so SISMEMBER is a single index lookup and SMEMBERS/SCARD are prefix scans. As with lists, there is no new WAL format or index type.
| Operation | Behavior |
|---|---|
SADD key member | Inserts member; returns 1 if it was added, 0 if it already existed. |
SREM key member | Removes member; returns 1 if it was removed, 0 if it was absent. |
SISMEMBER key member | Returns 1 if member is in the set, else 0. |
SMEMBERS key | Returns all members, one per line (unordered), terminated by END. |
SCARD key | Returns the member count. |
$ nc localhost 9000
SADD tags urgent
1
SADD tags billing
1
SADD tags urgent
0
SISMEMBER tags urgent
1
SCARD tags
2
SREM tags billing
1
SMEMBERS tags
urgent
END
Vectors and text documents
Vectors and text documents are ordinary keys on reserved prefixes, keyed by the same id as the KV record they describe: a vector is a little-endian float32 blob (L2-normalized) under @vec/<ns>/<id>, a text document lives under @txt/<ns>/<id>, and namespace settings under @vecns/<ns>. They are written, made durable, replicated and Raft-applied exactly like any other key. The per-namespace HNSW graph (cosine) and BM25 inverted index are derived from those keys in RAM — not persisted — and rebuilt at startup.
PUT doc-1 {"lang":"en","tier":"gold"} # the record (filters read it)
VSET doc-1 NS docs 0.9 0.1 0.0 0.2 # its vector → @vec/docs/doc-1
TSET doc-1 NS docs TEXT how to rotate api keys safely # → @txt/docs/doc-1
On v1.13 and earlier, deleting the record with DEL does not delete its vector or document — use VDEL / TDEL as well. Fixed in v1.14 DEL of a record also deletes its vectors and documents in every namespace; ones whose id never had a record are left alone. Commands, memory layouts (float32, int8, PQ, disk graph), filters, hybrid ranking, cluster fan-out and measured recall/latency/RAM are on Vector & Hybrid Search.
Atomic operations
VeltrixDB provides four single-key atomic primitives: CAS (compare-and-swap), INCR / DECR (integer increment/decrement), and SETNX (set-if-not-exists). All four are implemented as a shard-locked read-modify-write: the operation holds the write lock on the key's index shard for the full duration of the read, compare, and durable persist (WAL + VLog fdatasync), so no interleaved write from another client can observe or clobber an in-flight atomic operation. Because the index is split across shards, a lock held during one key's atomic RMW only blocks the other keys that happen to hash to the same shard — not the whole keyspace.
| Operation | Semantics |
|---|---|
CAS key expected new ttl | Replaces the value iff the current value byte-equals expected. Outcomes: swapped, mismatch (value differed, nothing written), or key-not-found (nothing written). |
INCR key delta ttl | Parses the current value as an int64, adds delta, and writes the result back as a decimal string. A missing key is treated as zero. Arithmetic saturates at MaxInt64/MinInt64 instead of silently overflowing. |
DECR key delta ttl | Sugar for INCR with a negated delta. |
SETNX key value ttl | Writes only if the key does not currently exist (or is a tombstone/expired). Outcomes: created, or already-exists (nothing written). |
Safe under concurrency: because the shard lock spans the entire compare-then-write sequence, two concurrent CAS calls (or an INCR racing a CAS) on the same key cannot interleave — one completes fully before the other's read is even taken. The trade-off is that the shard is briefly unreadable for other keys on that shard during the group-commit window (typically single-digit milliseconds); this is an intentional correctness-over-latency choice for atomic ops specifically, not for plain PUT/GET.
Transactions
VeltrixDB exposes optimistic, multi-key transactions through a Txn builder: stage a set of writes and deletes, optionally guard individual writes with an expected version, then call Commit.
| Method | Behavior |
|---|---|
BeginTxn() | Starts a new transaction builder (not safe to share across goroutines). |
Txn.Set(key, value, ttl) | Stages an unconditional write. Nothing is visible until Commit. |
Txn.SetIf(key, value, ttl, expectedVersion) | Stages a write guarded by the key's version at read time — the version token is the key's write timestamp, obtained via KeyVersion(key). |
Txn.Delete(key) | Stages a tombstone. |
Txn.Read(key) | Reads the currently committed value — reads inside a transaction do not see the transaction's own pending writes. |
Txn.Commit() | Validates every SetIf guard, then applies all staged writes via a batched MultiPut and deletes via Delete. Returns ErrTxnConflict if any guard's version no longer matches. |
Each SetIf guard captures the version of the key it expects to overwrite. At Commit time, VeltrixDB re-checks every guarded key under its shard lock; if any key was written by someone else in the meantime, the whole transaction aborts with ErrTxnConflict and nothing is applied — the caller is expected to re-read and retry. This is optimistic concurrency control: conflicts are detected at commit time rather than prevented up front with locks, so transactions never block each other while staging.
Concretely, this gives you conflict detection, not automatic conflict resolution — VeltrixDB tells you a concurrent writer got there first and lets your application decide how to retry or merge, rather than silently picking a winner. Replicas do not resolve conflicts either: in replicated mode a replica applies each received write in arrival order, and although the replication code defines version vectors, the server never sets or compares them (see Replication & CDC).
Within a single disk, a transaction's batched writes are durably applied as one atomic unit (all or nothing); across disks, a transaction is not strictly serializable — each disk independently guarantees its own batch is atomic, but there is no two-phase commit tying the disks together. Read isolation is read-committed, not snapshot isolation: a Txn.Read always sees the latest committed state, never a stale snapshot.
raft replays them deterministically via dedicated FSM ops, and replicated mode ships their KV effects as ordinary replication traffic — including secondary-index definitions (@idxdef/), which reach every replica since v1.11. Vector / text / hybrid searches, QUERY and IDXQUERY fan out to every non-failed node and are merged (v1.11, --search-fanout); RANGE and SCANCUR are still served from the local node only. See Deployment Modes for the full picture.