rexis (Redis / Valkey)
A drop-in RESP server modelled on Redis, rebuilt as one process per connection with hard resource budgets.
---- The software that inspired it
- Similar, and different
- How it works
- How libxtc concepts are applied
- Advantages of building it on libxtc
- Challenges (warts and all)
- Run it
Source:
examples/05_rexis/
(~4,500 lines of C, including tests).
The software that inspired it
Redis (and its fork
Valkey) is an in-memory data-structure server
spoken over the RESP wire protocol. Its defining architectural
choice is a single-threaded event loop: one thread runs an
epoll/kqueue reactor, and every command executes to completion on
that thread. That is why Redis commands are atomic without locks – there
is only one thread. The cost is that one CPU serves all clients, and a
slow command (a big KEYS, a large sorted-set operation) stalls
everyone.
rexis is a drop-in server for a meaningful subset of Redis: you can
point redis-cli at it and run SET/GET/DEL/EXPIRE and friends.
It exists to answer a question: what does Redis’s model look like if
the runtime gives you cheap concurrency instead of forcing
single-threadedness?
Similar, and different
flowchart LR
subgraph Redis["Redis / Valkey"]
R1["one event loop thread"] --> RA["client 1"]
R1 --> RB["client 2"]
R1 --> RC["client N"]
R1 --> RS[("one shared<br/>keyspace")]
end
subgraph rexis["rexis on libxtc"]
P1["conn proc 1"] --> KS[("keyspace")]
P2["conn proc 2"] --> KS
PN["conn proc N"] --> KS
EX["one xtc_loop<br/>(one thread)"] -.->|runs| P1 & P2 & PN
end
Similar: the RESP protocol, the command semantics, the in-memory keyspace, and an optional persistent log (rexis uses a bitcask-style append store, Redis uses RDB/AOF).
Different: where Redis runs a hand-written reactor with a client
table, rexis runs one lightweight process per connection. Each
connection process parses its own commands and owns its own protocol
state and buffers – there is no shared connection table to lock. Today
all of them run on ONE xtc_loop (one OS thread), like Redis; the
per-connection-process shape is what would let them spread across an
xtc_exec executor, but that needs the keyspace’s writers serialized
across loops and is not done (--cores=N only pins the one thread to
CPUs 0..N-1).
How it works
main.cstands up anxtc_appwith a supervised TCP listener.conn.c– the listener accepts a socket and spawns one connection process per client (xtc_proc_spawn). The process loops: read from the socket (parking the fiber via the reactor when the socket is not ready), parse a RESP command, apply it, write the reply. The connection’s teardown (close the socket, free both buffers, leave its pub/sub groups) is registered ONCE up front on anxtc_scope: it runs on every exit path – a normal close and, crucially, an asynchronous kill from the supervisor while the fiber is parked inxtc_proc_wait_fd. That is the “mechanism, not manner” principle in practice: before the scope, a kill skipped the cleanup block and leaked the fd and buffers; the scope closes them no matter how the fiber leaves. See Influences and lineage.db.cholds the keyspace;expire.chandles TTLs;bitcask.cis the append-only persistence layer.metrics.cexposes counters viaxtc_stats(3).- Memory is bounded by one
xtc_res(3)budget that the keyspace AND every client’s query/output buffer growth charge, so a client flood is rejected at a cap rather than driving the server to OOM – something Redis controls withmaxmemory, an eviction policy, andclient-output-buffer-limit. - Output backpressure. A reply is built into a per-connection output
buffer that grows up to
--max-output; a reply that would pass it is replaced whole by an error (a truncated reply would desynchronize every reply after it). Once more than 1 MiB is unsent the connection stops reading and running commands until the client drains it, so a client that pipelines without reading is slowed by TCP flow control instead of growing the server. A command larger than--max-requestis refused at its header and the connection closed (Redisproto-max-bulk-len).
How libxtc concepts are applied
- Supervision. The listener runs under a supervisor; a connection proc that crashes on a malformed command is contained and reaped without touching other clients or the keyspace.
- Data sharing. Connection state (parse buffer, in-flight command) is private to each conn proc – shared-nothing. The keyspace is the one deliberately shared structure; rexis keeps its sharing model simple and explicit rather than smearing locks across the code.
- Locking. Because each connection is its own proc, there is no shared connection table to lock – the most common lock in a hand-rolled C server simply does not exist here.
- Resource limits.
xtc_rescaps bound memory so a client flood is rejected at the cap instead of OOMing – the property Redis approximates withmaxmemory+ eviction.
Advantages of building it on libxtc
- Concurrency without a lock graph. Each connection’s state is private to its process, so the parser, the buffers, and the in-flight command have exactly one owner. There is no shared connection registry to protect.
- A path to more cores without turning atomic-by-single-thread into a lock nightmare: the keyspace is the one shared structure, and it can be guarded by a single primitive (or sharded) rather than by ad-hoc locking spread across the codebase. rexis itself still runs one loop; spreading the connection processes over an executor is the step not taken.
- Backpressure and budgets are first-class (
xtc_res), not bolted on.
Challenges (warts and all)
- The shared keyspace is the real concurrency question. Redis dodges it by being single-threaded; rexis has to decide how concurrent connection processes see the keyspace. The example keeps this simple (a single owning structure) rather than pretending it solved distributed sharding – see the code for exactly where the line is drawn, and rexis vs DragonFly and Valkey for the shard-per-loop design that would take it to many cores.
- A table that does not grow is a time bomb. Until 2026-10 the
keyspace was a fixed 65,536-bucket chained hash and the persistence
index a fixed 4,096: fine in every test, 40% of pipelined throughput
lost at 1M keys, and a 5M-key load that slowed to a crawl. Both are
now linear hashes (
lhash.h) that split one bucket per insert. - RESP is deceptively fiddly (inline vs. multibulk, partial reads
across socket boundaries); the parser has to resume cleanly when a
command spans two
reads, which is exactly the kind of state that fibers keep on the stack instead of in a hand-rolled parser context. - Buffers are where a server dies. The 1.55 review found every
version of this mistake in rexis at once: a reply bigger than the
write buffer went out as a bare header, a slow reader got a corrupted
stream, LRANGE/KEYS silently stopped at 1024 elements, a client
holding a large partial command pinned a core (and kept it pinned
after hanging up), and
--max-memorydid not count client buffers at all (300 clients took a 10 MB-capped server to 314 MB). The fixes – a growable output buffer with a hard limit, read-side backpressure, a request-size cap enforced at the header, EOF tracked separately from errors, and client buffers charged to the same budget – are each a few lines; finding that they were missing took an adversarial client.test/m99/test_rexis_budgets.cnow measures each one.
Run it
cd examples/05_rexis && make XTC_BUILD=../../build
./rexis-server-xtc & # listens on the RESP port (6379)
redis-cli set hello world
redis-cli get hello # -> "world"
Next: sqlxtc (SQLite) →