Skip to content
adamerikoffPublic

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

rediz

A Redis-like key-value server in C99. Single-threaded, poll-based, no dependencies beyond libc and POSIX.

Four commands — PING, SET, GET, DEL — over a length-prefixed binary protocol with a shared-secret auth field. Keys and values are arbitrary bytes: any byte value including zero, up to one megabyte.


Build and run

make          # build _build/release/rediz
make run      # build and run it
make test     # run the suite against a sanitized build
make asan     # build _build/asan/rediz with AddressSanitizer + UBSan
make clean

Then, with a server running:

ruby example.rb   # every client feature, annotated
ruby test.rb      # the suite
Makefile      build
README.md     this file
rediz.rb      the Ruby client library
example.rb    every client feature, runnable
test.rb       the test suite
src/          the server

Requires a C99 compiler and Ruby (for the tests and client). Builds clean under -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror and about ten more, all listed in the Makefile.

$ ./_build/release/rediz --help
usage: rediz [options]

  --host <ipv4>      address to bind        (default 127.0.0.1)
  --port <1-65535>   port to bind           (default 1234)
  --auth <key>       shared key, max 16 bytes (default ror2004)
  --log <level>      error|warn|info|trace  (default info)
  --help             this message

--log trace prints every system call with its arguments and result, which is the fastest way to see what the event loop is actually doing.

The default auth key is in the source, so it is not a secret. The server warns about it at startup, and warns louder if you also bind somewhere other than loopback.


Using it from Ruby

require_relative 'rediz'

Rediz.open(host: '127.0.0.1', port: 1234, key: 'ror2004') do |db|
  db.ping                          # => "PONG"

  db.set('name', 'Alice')          # => true
  db.get('name')                   # => "Alice"
  db.get('nope')                   # => nil

  db.set('page', html, ttl: 900)   # expires in 900 seconds
  db.del('name')                   # => 1  (0 if it was not there)
  db.key?('name')                  # => false
end

Without a block, Rediz::Client.new(...) returns a client you close yourself.

nil means the key is absent. A key holding an empty string comes back as "", not nil — those are genuinely different, and the protocol keeps them apart. See The reply kind byte below.

Values are returned as ASCII-8BIT strings, because that is what they are. Call .force_encoding('UTF-8') if you know better.

Errors

Class Means
Rediz::CommandError The server understood and said no — unknown command, wrong arity, bad TTL.
Rediz::ConnectionError The socket broke or the server hung up. A wrong auth key lands here: the server closes without replying.
Rediz::ValueTooLarge Caught client-side before sending, so you get a useful message instead of a dropped connection.
Rediz::ProtocolError The server sent something the client cannot parse.

Anything else

call sends one command and returns its decoded reply. Every method above is a thin wrapper over it:

db.call('SET', 'k', 'v', '900')

The protocol

Two framing layers. Every integer is 32-bit little-endian.

Request

 4 bytes      16 bytes             declared - 16
+-----------+-------------------+---------------------+
| declared  | auth, zero-padded | payload             |
+-----------+-------------------+---------------------+
             \___ declared counts these two, not itself ___/

The length comes first, because a receiver cannot know where a message ends until it has read to the end — a trailing length would be circular.

The auth field is fixed width, not length-prefixed, so there is no second attacker-controlled length to validate. It is compared in constant time.

Payload

[ count : u32 ] [ length : u32 ][ bytes ] [ length : u32 ][ bytes ] ...

Argument 0 is the command name, as in Redis, so the count is the real count.

Length-prefixed rather than split on spaces, because a value is arbitrary bytes. A cached web page contains spaces, newlines, quotes and possibly zero bytes, and no quoting scheme survives all of those. With a length on every argument, no byte is reserved and nothing needs escaping.

SET name Alice is 48 bytes on the wire:

2C 00 00 00                          declared = 44
72 6F 72 32 30 30 34 00 00 ...       "ror2004" + 9 zero bytes
03 00 00 00                          3 arguments
03 00 00 00  53 45 54                "SET"
04 00 00 00  6E 61 6D 65             "name"
05 00 00 00  41 6C 69 63 65          "Alice"

Reply

+-----------+----------+-------------------+
| length    | kind : u8| bytes             |
+-----------+----------+-------------------+

No auth field — the server does not authenticate itself to the client.

The reply kind byte

Kind Meaning
0 STATUS "OK", "PONG" — nothing to parse
1 VALUE stored bytes; zero length is a stored empty value
2 MISSING no such key, or its TTL passed. No bytes follow
3 NUMBER u32 little-endian — how many keys DEL removed
4 ERROR text, for a human

This byte exists for one reason. GET on a missing key used to answer the five characters (nil) — indistinguishable from a stored value whose bytes happen to be (nil). A client caching serialised data will eventually store exactly that. An untyped reply makes absence a value, and the moment absence is a value it collides with real ones.

GET absent  ->  02                    MISSING, no bytes
GET empty   ->  01                    VALUE, 0 bytes
GET trap    ->  01 28 6E 69 6C 29     VALUE, "(nil)"

Redis solves this the same way — RESP distinguishes $-1 from $0.

Branch on the kind, never on the text.

Commands

Arguments Reply
PING 1 STATUS "PONG"
GET key 2 VALUE or MISSING
DEL key 2 NUMBER — 1 or 0
SET key value 3 STATUS "OK"
SET key value ttl 4 STATUS "OK"

Command names are case-insensitive. There is no type argument: the command determines the type, so a client cannot lie about it. ttl is in seconds; 0 means never.

Limits

Max payload 1 MiB
Max arguments 8
Auth key exactly 16 bytes on the wire, zero-padded
Max connections 256

How it works

src/main.c                    wiring: parse the command line, listen, run the loop

src/core/log.{h,c}            timestamped, level-filtered, format-checked output
src/core/system.{h,c}         every system call, wrapped

src/config/config.{h,c}       the command line

src/message/message.{h,c}     finding one message in a byte stream

src/command/arguments.{h,c}   bytes  -> values     the payload's own framing
src/command/command.{h,c}     what they mean       names, arity, dispatch, handlers
src/command/answer.{h,c}      values -> bytes      the typed reply

src/store/entry.{h,c}         one key/value pair, and its memory layout
src/store/store.{h,c}         the hashtable

src/server/connection.{h,c}   one client, and the table of them
src/server/session.{h,c}      the request lifecycle
src/server/server.{h,c}       the poll form, accepting, and the loop

Dependencies run one way, with no cycles. The store is reachable only through command — never from the loop, the framing, or the connection code. Configuration never travels downward: server_run receives an auth key as sixteen bytes, not a Config, so nothing at or below that layer knows configuration exists.

The event loop

for (;;) {
    poll_form_build(&form, table, listening_file_descriptor);   /* describe every fd */
    system_wait_for_events(form.slots, form.slot_count, -1);    /* sleep until one can proceed */

    for (row = 1; row < form.slot_count; row++) { ... }         /* serve whoever can */

    if (form.slots[0].revents != 0) accept_new_client(...);     /* then take new clients */
}

One thread, many clients, and it never waits on any single one. poll is the only call in the program that blocks.

The poll array is rebuilt from nothing every pass, because poll is stateless — the kernel remembers nothing between calls, so each call has to restate the whole situation. That is the reason epoll and kqueue exist.

POLLOUT is requested only while bytes are genuinely waiting to go out. A send buffer is almost always writable, so asking unconditionally makes poll return instantly, forever — a busy loop pinning a core. That is the most common bug in a hand-written event loop.

One request at a time, per connection

A Connection is a stack frame written down by hand. A blocking server keeps half-finished work in local variables and the program counter; an event loop cannot park a call stack per client, so that state moves into a struct:

uint8_t read_buffer[...];   size_t bytes_received;   /* accumulates across reads */
uint8_t write_buffer[...];  size_t bytes_to_send;  size_t bytes_sent;

session_step does one system call per call — one read, or one write, never both, never a loop. Which one is not stored anywhere; it is read off the cursors:

wants_to_write == (bytes_sent < bytes_to_send)
wants_to_read  == !wants_to_write

The cursors are the state, so there is no second source of truth to forget to update, and no third state to fall into. Alternating read and write also gives backpressure for free: a client that stops reading has its own requests back up in the kernel and TCP's receive window closes, with no work from us.

Pipelining

After answering, the session re-parses whatever is left in its buffer rather than returning to poll. This is not an optimisation — it is required.

poll reports on the kernel's buffer, not ours. A client that sends two requests in one write() gets both delivered by a single read(). Answer the first and the second is sitting in our buffer, where poll cannot see it. Waiting for POLLIN would wait forever for bytes that already arrived.

The store

A hashtable with chaining. Hash the key to a bucket; the bucket holds a short linked list of everything that landed there; the stored key picks the right one:

buckets[314] -> Entry{key="user:440"} -> Entry{key="user:13"} -> NULL

The hash is trusted for location only, never for identity — the stored key bytes are still compared.

Chaining rather than open addressing because DEL is a first-class command here. Open addressing has better cache behaviour but makes deletion hard: removing an entry breaks the probe chain for everything that collided past it, so you need tombstones, and tombstones accumulate. Redis chose chaining too.

One entry is one allocation holding the struct, the key, and the value via a C99 flexible array member:

+------+------------+--------------+------------+--------+---------+
| next | key_length | value_length | expires_at | key    | value   |
+------+------------+--------------+------------+--------+---------+
                                                 data[0]  data[key_length]

One malloc, one free, so an entry can never exist half-constructed.

Expiry is lazy: an entry dies when someone looks at it. A key nobody reads again is never reclaimed — see Known limits.


Testing

make test                    # everything, against a sanitized server
ruby test.rb                 # against a server you started yourself
ruby test.rb ttl protocol    # only those groups

55 checks in six groups. The store, values, keys, ttl and errors groups drive the client library. The protocol group opens raw sockets and sends frames the library cannot produce — a wrong auth key, a length prefix claiming four billion bytes, a length below the auth field, half a header, three pipelined requests in one write, and a request dripped one byte at a time.

Every hostile case ends by checking the server is still alive: a malformed client must cost that client its connection and nothing else.

make test runs against _build/asan/rediz, so a buffer overflow or use-after-free anywhere in the suite is a crash with a line number rather than a plausible wrong answer.

LeakSanitizer does not exist on macOS arm64, so make asan catches overflows and use-after-free but says nothing about leaks. Checking leaks there needs leaks(1) against a running process.


Known limits

Every one of these is a deliberate stopping point, not an oversight.

The hash is unkeyed. Anyone who can compute FNV-1a offline can generate keys that all land in one bucket and turn every O(1) lookup into a linear walk (Crosby & Wallach, USENIX Security 2003). Fix: a keyed hash with a per-process random key. Needed the moment this is reachable by anyone you do not control.

The table never grows. 4096 buckets, fixed. Average chain length is entry_count / 4096, so lookups stay near one comparison up to a few thousand keys and degrade linearly after that. Fix: progressive rehashing — growing it in one step would freeze every client for the duration.

Expiry is lazy only. Nothing reclaims a key that nobody reads again, so resident memory tracks the high-water mark of the keyspace rather than its live size. Fix: a sweep on a timer, which means the poll timeout stops being -1.

Nothing bounds total memory. A client with a valid key can SET until malloc fails. Fix: a byte budget and an eviction policy.

No idle timeout. A client that connects and says nothing holds its slot forever; 256 such clients fill the table. Fix: a last-activity timestamp and the same timer as above.

Buffer compaction is O(n) per request, so O(k²) across k pipelined requests. Fix: a read cursor instead of memmove.

Every request carries the 16-byte key. A session could authenticate once instead. Worth doing when small messages dominate.

poll is O(N) in total connections per pass, active or not. Fix: kqueue or epoll. Worth doing when CPU rises with idle connection count.


Credits

Built while working through Build Your Own Redis with C/C++ by James Smith. The book covers considerably more ground — an AVL-backed sorted set, a timer heap, thread pools. This is a C99 rewrite of the early chapters with a different protocol and a deliberately smaller scope, taken as far as the four commands above.

Anything here that is correct is likely from the book. Anything wrong is mine.

This project was written with extensive use of Claude.


License

Apache License 2.0. See LICENSE.

Copyright 2026 Adam Erikuly

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages