Wire Protocol
VeltrixDB speaks two protocols over the same TCP port: a human-readable text protocol for ad-hoc use with nc or telnet, and a compact binary protocol that every official SDK uses by default. The server auto-detects which one a connection is speaking from the first byte.
Text protocol
The text protocol is line-oriented — one command per line, a plain-text response per line — which makes it usable directly from nc, telnet, or any tool that can write to a TCP socket. It's the fastest way to sanity-check a running server.
| Command | Response |
|---|---|
PUT key value | OK |
GET key | value (or an ERR ... line if not found) |
DEL key | OK |
PING | PONG |
INFO | keys=N writes=N reads=N ... |
AUTH user pass | OK |
QUIT | BYE |
SET is an alias of PUT, DELETE of DEL, EXIT of QUIT, and CLUSTER of TOPOLOGY. Commands are case-insensitive. A malformed command answers ERR usage: ...; an unknown one answers ERR unknown command: ... with the supported list.
Text commands added in v1.1.0
v1.1.0 brings the text protocol to parity with operations that were previously binary-only (TTL writes, atomics, batch get) and adds the new list and set data types. The usage lines below are exactly what the server prints on a malformed invocation (ERR usage: ...):
| Command | Response |
|---|---|
PUTEX <key> <ttl-seconds> <value> | OK — a PUT with expiration. The binary PUT frame (0x01) has no TTL field; binary clients send a TTL in MPUT’s per-entry header ([2B keyLen][4B valLen][4B ttl]). TTL must be ≥ 1; ERR bad ttl otherwise. |
INCR <key> [delta] / DECR <key> [delta] | The new integer value, as a decimal line. delta defaults to 1; a missing key counts from zero. |
SETNX <key> <value> | 1 (created) or 0 (key already exists). |
CAS <key> <expected> <new> | OK (swapped), MISMATCH (current value differed), or NOTFOUND (key absent). |
MGET <key> [key ...] | One <key> <value> line per found key (missing keys are silently omitted), terminated by END. |
LPUSH <key> <value> / RPUSH <key> <value> | The new list length. |
LPOP <key> / RPOP <key> | The popped value, or NIL when the list is empty. |
LLEN <key> | The element count. |
LRANGE <key> <start> <stop> | One element per line, then END. Redis semantics: stop inclusive, negative indices count from the end. |
SADD <key> <member> / SREM <key> <member> | 1 when added/removed, 0 otherwise. |
SISMEMBER <key> <member> | 1 or 0. |
SMEMBERS <key> | One member per line (unordered), then END. |
SCARD <key> | The member count. |
CAS parses <expected> as a single space-delimited token — use the binary protocol for values containing spaces. Semantics of every command (per-key TTL, atomic RMW guarantees, list/set encoding) are covered in the Data Model, and search semantics are on Vector & Hybrid Search.Search, index and query commands
Search commands answer id score lines then END, or one ERR ... line. What they do — ranking, filters, memory layouts, cluster fan-out — is on Vector & Hybrid Search.
| Command | Response |
|---|---|
VCREATE <ns> <dim> [QUANT none|int8|pq] [PQM <m>] [PQTRAIN <n>] [GRAPH memory|disk] New in v1.11 | OK. PQM/PQTRAIN/GRAPH and QUANT pq are new in v1.12. |
VSET <key> [NS <ns>] <f1> <f2> ... | OK. NS is new in v1.11 (default namespace default). |
VSEARCH <k> [NS <ns>] [EF <n>] [FILTER <field> <op> <value>] <f1> <f2> ... | id score lines (cosine), END. k = 0 returns every match. NS/EF/FILTER are new in v1.11. |
VDEL <key> [NS <ns>] New in v1.11 | OK |
TSET <id> [NS <ns>] TEXT <text> New in v1.11 | OK. The text is the rest of the line (at most 1 MiB). |
TDEL <id> [NS <ns>] New in v1.11 | OK |
TSEARCH <k> [NS <ns>] [FILTER <field> <op> <value>] QUERY <text> New in v1.11 | id score lines (BM25), END. k = 0 returns every match. |
HSEARCH <k> [NS <ns>] [EF <n>] [ALPHA <a>] [CAND <n>] [FILTER <field> <op> <value>] [VEC <f1> ... <fn>] QUERY <text> New in v1.11 | id score lines (RRF score), END. k > 0; needs VEC, QUERY or both. |
IDXCREATE <name> <field> / IDXDROP <name> | OK. Indexes <field> of every value (JSON object or k=v pairs); backfills existing keys. |
IDXQUERY <name> <value> [LIMIT n] | One key per line, END. |
QUERY <namespace> WHERE <field> <op> <value> [LIMIT n] | key value lines, END. Ops: = != > < >= <= contains; uses a secondary index for = on an indexed field, else scans the namespace. |
Option clauses may come in any order, each at most once; the floats or the QUERY text come last. Because the text protocol is whitespace-split, filter values cannot contain spaces and TSET text cannot contain newlines — use the binary search opcodes for those.
Other text commands
| Command | Response |
|---|---|
NSPUT <ns> <key> <value> / NSGET <ns> <key> / NSDEL <ns> <key> / NSDROP <ns> | Namespaced KV (see Data Model). |
NSSCAN <ns> [prefix] [limit] / NSLIST | key value lines / namespace count lines, then END. |
HSET <key> <field> <value> [ttl], HGET, HDEL, HGETALL, HKEYS, HLEN, HEXPIRE <key> <field> <ttl>, HTTL | Hash fields with per-field TTL. HGET of a missing field answers NOT_FOUND. |
RANGE <start> <end> [LIMIT n] [REV] | Ordered key value lines, END (local node only in cluster modes). |
SCANCUR <cursor> <limit> | key value lines, then CURSOR <next>; cursor - starts from the beginning. |
TXN <numOps> then that many lines of SET <key> <value> / SETIF <key> <expectedVersion> <value> / DEL <key> | OK, or CONFLICT when a SETIF version no longer matches. |
VER <key> | The key's version (the token for SETIF); 0 when absent. |
TOPOLOGY / CLUSTER | One JSON line with the cluster topology, for client-side routing. |
Worked example
Start a server and drive it with nc:
$ docker run -p 9000:9000 ghcr.io/veltrixdb/veltrixdb:latest
$ nc localhost 9000
PUT hello world
OK
GET hello
world
PUT counter 41
OK
GET counter
41
DEL hello
OK
GET hello
ERR
PING
PONG
INFO
keys=1 writes=3 reads=3 ...
QUIT
BYE
Or pipe a whole session in one shot, as shown in the project's own quick-start:
echo -e "PUT hello world\nGET hello\nPING" | nc localhost 9000
Binary protocol
The binary protocol is what every official SDK (Go, Python, Node.js, Java, Rust, C++) speaks by default (only the Go client implements the search opcodes), and it's what the server assumes once it sees a leading byte that matches a known opcode rather than ASCII command text — no handshake or negotiation is required, the server simply auto-detects the framing per connection.
Frame layout
Request: [1B cmd][2B keyLen][4B valLen][key][value]
Response: [1B status][4B payloadLen][payload]
Opcodes
Every opcode below is defined in cmd/server/main.go (handlers for 0x20–0x29, 0x08, 0x1C, 0x1E and 0x1F in cmd/server/ext_ops.go). 0x1D is unused. New opcodes take free slots at or below 0x29, because a first byte above it is read as text.
| Category | Opcode | Command |
|---|---|---|
| Single-key | 0x01 | PUT |
0x02 | GET | |
0x03 | DEL | |
0x04 | PING | |
0x05 | INFO | |
| Batch | 0x06 | MPUT (vectorized multi-put) |
0x07 | MGET (vectorized multi-get) | |
| Auth | 0x09 | AUTH — [1B 0x09][2B userLen][4B passLen][user][pass] |
| Namespace | 0x0A | NSPUT |
0x0B | NSGET | |
0x0C | NSDEL | |
0x0D | NSDROP | |
0x0E | NSSCAN | |
0x0F | NSLIST | |
| Hash | 0x10 | HSET |
0x11 | HGET | |
0x12 | HDEL | |
0x13 | HGETALL | |
0x14 | HKEYS | |
0x15 | HLEN | |
0x16 | HEXPIRE | |
0x17 | HTTL | |
| Atomic | 0x18 | CAS (compare-and-swap) |
0x19 | INCR | |
0x1A | DECR | |
0x1B | SETNX | |
| Extended | 0x20 | RANGE |
0x21 | SCANCUR | |
0x22 | TXN (one-shot optimistic transaction) | |
0x23 | IDXCREATE | |
0x24 | IDXDROP | |
0x25 | IDXQUERY | |
0x28 | QUERY | |
0x29 | GETVER (key version for TXN SETIF) | |
| Vector | 0x26 | VSET (namespace default) |
0x27 | VSEARCH (namespace default) | |
0x08 | VSETNS New in v1.11 | |
0x1E | VSEARCHX — namespace, ef, filter New in v1.11 | |
0x1F | VDEL New in v1.11 | |
| Search | 0x1C | SEARCH, sub-op in the 2-byte field: 1 VCREATE, 2 TSET, 3 TDEL, 4 TSEARCH, 5 HSEARCH New in v1.11 |
The atomic opcodes 0x18–0x1B use extended frames that carry the extra operands (expected value, delta, TTL) — as of v1.1.0 the same four operations are also available as text commands (CAS/INCR/DECR/SETNX, above) and in every official SDK:
CAS: [1B 0x18][2B keyLen][4B expectedLen] + [4B newLen][4B ttl] + key + expected + new
INCR: [1B 0x19][2B keyLen][4B 0] + [8B delta int64] + [4B ttl] + key
DECR: [1B 0x1A][2B keyLen][4B 0] + [8B delta int64] + [4B ttl] + key
SETNX: [1B 0x1B][2B keyLen][4B valLen] + [4B ttl] + key + value
Vector and search frames
Each frame starts with the standard 7-byte header [1B cmd][2B][4B], whose two fields are reinterpreted per opcode; all integers are little-endian, floats are IEEE-754 float32 LE. Writes answer the standard OK/ERR frame.
VSET 0x26: [1B][2B keyLen][4B dim] + key + dim×4B
VSEARCH 0x27: [1B][2B k][4B dim] + dim×4B
VSETNS 0x08: [1B][2B idLen][4B dim] + [2B nsLen] + ns + id + dim×4B
VSEARCHX 0x1E: [1B][2B k][4B dim] + [2B nsLen][2B ef][2B fieldLen][2B opLen][2B valLen]
+ ns + field + op + value + dim×4B (fieldLen 0 = no filter, ef 0 = default)
VDEL 0x1F: [1B][2B idLen][4B nsLen] + ns + id
SEARCH 0x1C: [1B][2B subop][4B payloadLen] + payload of [4B len][bytes] fields
1 VCREATE: ns, dim u32, quant ("" | none | int8 | pq)
[, pqM u32, pqTrainAt u32, graph ("" | memory | disk)] → OK
2 TSET: ns, id, text → OK
3 TDEL: ns, id → OK
4 TSEARCH: ns, k u32, query, filterField, filterOp, filterValue → matches
5 HSEARCH: ns, k u32, ef u32, alpha f32 (NaN = default 0.5), candidates u32,
vec (dim×4B, may be empty), query, filterField, filterOp, filterValue → matches
(filterField "" = no filter)
Matches (VSEARCH, VSEARCHX, TSEARCH, HSEARCH):
[1B OK][4B count] + count × [2B idLen][4B score float32][id]
The trailing VCREATE fields (pqM, pqTrainAt, graph) are new in v1.12 and optional, so older clients that send only the first three fields keep working. The Go client is the only official SDK that implements these frames (VSetNS, VSearchWithOptions, VDel, VCreateWithOptions, TSet, TDel, TSearch, HSearch).
Status codes
| Code | Status | Meaning |
|---|---|---|
0x00 | OK | Request succeeded; payload holds the result, if any. |
0x01 | ERR | Request failed; payload holds an error message. |
0x02 | NOT_FOUND | Key does not exist or is expired/tombstoned. |
0x03 | EXISTS | SETNX: the key already existed; nothing was written. |
0x04 | MISMATCH | CAS: the expected value did not match the current value; nothing was written. |
0x05 | CONFLICT | TXN: an optimistic version check failed — re-read and retry. |
MPUT/MGET batch pays one round trip (and, for writes, one group commit) for many keys instead of one per key, so it is much faster than the same number of individual PUT/GET calls; the gain depends on batch size and network latency. All official SDKs expose batch methods (for example, db.mput(...) / db.mget(...)) — reach for them whenever you're writing or reading more than a handful of keys at once.Protocol auto-detection
Both protocols share the same TCP listener (-addr, default :9000). A single connection is either all-text or all-binary — the server determines this from the leading byte of the first request — 0x01–0x29 selects binary, anything else is read as a text line — and does not switch mid-connection. You never need to configure which protocol to use; connecting with nc/telnet naturally speaks text, and every official SDK's connection pool speaks binary. This applies to the default --net=go front-end; see below for the opt-in C++ front-end.
Which front-end serves which commands New in v1.7
The --net server flag selects the network front-end that listens on -addr:
--net | Implementation | Serves |
|---|---|---|
go default | Go server, one goroutine per connection | Everything on this page: the text protocol, every binary opcode (including the extended and atomic frames), AUTH, TLS on --tls-addr, and all --mode values. |
cpp / uring / poll | C++ event loops in netfront/, one per core (--net-threads); cpp uses io_uring when available and poll() otherwise, uring requires io_uring, poll forces poll() | Only the binary protocol's PUT, GET, DEL, PING, MPUT and MGET. No text protocol, no AUTH, no extended binary commands. |
--mode=standalone and the server refuses it with --auth-config; TLS stays on the Go listener. Each connection stays strictly ordered, and reads that need a disk read are finished on a goroutine rather than the event loop (VELTRIXDB_NET_DEFER_READS=0 disables this for A/B runs). It is not faster overall: on a 4-CPU CI runner it lowered read P50 against --net=go but had a worse read P99 and ~12% lower batch-write throughput. To load-test it, use loadtest --proto=binary.