Systems Engineering | Tiny KV
For the time being, working on The World of Noumenon requires time and thought. All of that is good, but I also want other things in life. One of the things I like is systems thinking. So I thought that perhaps I could build a few projects with Elixir and Rust to cover systems thinking within the context of software engineering.
Today, we will work on a simple key–value store: TinyKV – an in-memory key–value store over TCP
- Concepts to cover: sockets, protocols, concurrency, state, back-pressure.
- Elixir: GenServer + Task.Supervisor + :gen_tcp
- Rust: std::net + std::thread + std::sync
Let’s dive in.
We can execute all the following code by using the raw-dogged technique (if we need libraries).
# lib/tiny_kv/application.ex
@moduledoc """
OTP application entrypoint for TinyKV.
Starts the supervision tree:
* `TinyKV.Store` – in-memory key–value store
* `TinyKV.ConnectionSupervisor` – `Task.Supervisor` for per-connection processes
* `TinyKV.TCPServer` – TCP listener that accepts and delegates client connections
"""
use Application
@impl true
@spec start :: Supervisor.on_start()
port = Application.get_env(:tiny_kv, :port, 4040)
children = [
TinyKV.Store,
,
]
opts = [strategy: :one_for_one, name: TinyKV.Supervisor]
Supervisor.start_link(children, opts)
end
end
# lib/tiny_kv/store.ex
@moduledoc """
In-memory key–value store backed by a `GenServer`.
Keys and values are stored in a map. All operations are synchronous:
* `set/2` – store a key–value pair
* `get/1` – fetch the value for a key
* `del/1` – delete a key and return its previous value
Missing keys are represented as the atom `:nil`.
"""
use GenServer
alias __MODULE__, as: Store
@typedoc "Key used in the store. For the TCP protocol we expect strings."
@type key :: String.t()
@typedoc "Value stored in the store. Protocol currently treats these as strings."
@type value :: String.t()
@typedoc "Internal state of the store: a map of keys to values."
@type state :: %
@doc """
Starts the `TinyKV.Store` GenServer.
By default, the server is registered under the `TinyKV.Store` name
so that the public API functions can call it directly.
Accepts standard `GenServer` options, such as `:name`.
"""
@spec start_link(GenServer.options()) :: GenServer.on_start()
GenServer.start_link(Store, %, Keyword.put_new(opts, :name, Store))
@doc """
Sets a key to a given value.
Returns `:ok` once the value has been stored.
"""
@spec set(key(), value()) :: :ok
GenServer.call(Store, )
@doc """
Gets the value associated with the given key.
Returns the stored value when present, or `:nil` if the key is missing.
"""
@spec get(key()) :: value() | :nil
GenServer.call(Store, )
@doc """
Deletes the given key from the store.
Returns the previous value if the key existed, or `:nil` if it did not.
"""
@spec del(key()) :: value() | :nil
GenServer.call(Store, )
@impl true
@spec init(state()) ::
@impl true
@spec handle_call(
| | ,
GenServer.from(),
state()
) ::
= Map.pop(state, key, :nil)
end
end
# lib/tiny_kv/tcp_server.ex
@moduledoc """
TCP listener process for TinyKV.
This GenServer:
* Listens on the configured TCP port
* Accepts incoming client connections
* Spawns a supervised task for each client, delegating to `TinyKV.Connection`
Each client connection is handled by `TinyKV.Connection.handle_client/1`
under the `TinyKV.ConnectionSupervisor` `Task.Supervisor`.
"""
use GenServer
alias __MODULE__, as: TCPServer
require Logger
@typedoc "Underlying socket used for listening for new connections."
@type listen_socket :: port()
@typedoc "State for the TCP server process."
@type state :: %
@doc """
Starts the TCP server GenServer.
The `port` argument is the TCP port on which the server will listen.
The server is registered under the `TinyKV.TCPServer` name.
"""
@spec start_link(non_neg_integer()) :: GenServer.on_start()
GenServer.start_link(TCPServer, port, name: TCPServer)
@impl true
@doc """
Initializes the TCP server.
This:
* Binds a listening socket on the provided `port`
* Logs the listening information
* Spawns a background task to accept incoming connections in a loop
The GenServer's state holds the listening socket.
"""
@spec init(non_neg_integer()) ::
=
:gen_tcp.listen(port, [:binary, packet: :line, active: false, reuseaddr: true])
Logger.info()
# Start the accept loop in a separate task so the GenServer can finish initialization.
Task.start_link(fn -> accept_loop(listen_socket) end)
end
# Private: Accepts incoming client sockets in a loop and spins up a Task per client.
@spec accept_loop(listen_socket()) :: :ok | :error
case :gen_tcp.accept(listen_socket) do
->
Task.Supervisor.start_child(TinyKV.ConnectionSupervisor, fn ->
TinyKV.Connection.handle_client(socket)
end)
accept_loop(listen_socket)
->
Logger.error()
:error
end
end
end
# lib/tiny_kv/connection.ex
@moduledoc """
Handles a single client connection to TinyKV.
Implements a minimal text-based protocol over TCP:
SET <key> <value>\\r\\n
GET <key>\\r\\n
DEL <key>\\r\\n
Responses roughly mimic Redis-style conventions:
* `+OK\\r\\n` – successful `SET`
* `$<len>\\r\\n<value>\\r\\n` – successful `GET`
* `$-1\\r\\n` – missing key on `GET`
* `:1\\r\\n` – key deleted on `DEL`
* `:0\\r\\n` – key not found on `DEL`
* `-ERR unknown command\\r\\n` – unsupported input
Each TCP client is handled in its own process, typically started by
`TinyKV.TCPServer` under `TinyKV.ConnectionSupervisor`.
"""
require Logger
@typedoc "TCP socket for the current client connection."
@type socket :: port()
@typedoc "Raw command line received from the client."
@type raw_command :: String.t()
@typedoc "Wire-format response string to send back to the client."
@type response :: String.t()
@doc """
Handles the lifecycle of a single TCP client.
Sends an initial greeting and then enters the receive loop. The loop
continues until the client closes the connection or an error occurs.
Returns `:ok` when the connection handling is finished.
"""
@spec handle_client(socket()) :: :ok
:gen_tcp.send(socket, )
loop(socket)
end
# Private: main receive loop.
#
# Reads a line from the client, parses and executes the command via
# `handle_command/1`, sends the response, then recurses. If the client
# closes the connection or an error occurs, the loop terminates.
@spec loop(socket()) :: :ok
case :gen_tcp.recv(socket, 0) do
->
response = handle_command(String.trim(data))
:gen_tcp.send(socket, response)
loop(socket)
->
:ok
->
Logger.error()
:ok
end
end
# Private: parses a single command line and performs the corresponding
# store operation, returning a protocol-level response string.
@spec handle_command(raw_command()) :: response()
parts = String.split(line, , parts: 3)
case parts do
[, key, value] ->
:ok = TinyKV.Store.set(key, value)
[, key] ->
case TinyKV.Store.get(key) do
:nil ->
value ->
end
[, key] ->
case TinyKV.Store.del(key) do
:nil ->
_ ->
end
_ ->
end
end
end
= Supervisor.start_link([%], strategy: :one_for_one)
Process.sleep(:infinity)
We can have all that in one file, say tiny.exs, and execute it:
We can then run another terminal session for a telnet client to interact with the server:
Now, for the rust version, we have:
//! TinyKV: a tiny in-memory key-value store over TCP using only the Rust standard library.
//!
//! Protocol (very Redis-ish but simplified):
//! - `SET key value` -> `+OK\r\n`
//! - `GET key` -> `$<len>\r\n<value>\r\n` or `$-1\r\n` if missing
//! - `DEL key` -> `:1\r\n` if deleted, `:0\r\n` if not found
use ;
/// Shared key-value store type.
///
/// This is:
/// - `HashMap<String, String>` to hold keys and values in memory.
/// - Wrapped in `RwLock` so multiple threads can read concurrently but writes are exclusive.
/// - Wrapped in `Arc` so it can be shared across threads safely and cheaply cloned.
type Store = ;
/// Entry point for the TinyKV server.
///
/// # Behavior
///
/// - Binds a TCP listener to `127.0.0.1:4040`.
/// - Creates a shared, in-memory key-value store.
/// - For each incoming TCP connection:
/// - Clones a handle to the store.
/// - Spawns a new thread to handle that client.
///
/// The server runs indefinitely until the process is terminated.
///
/// # Errors
///
/// Returns an `std::io::Result<()>` which propagates any I/O errors that
/// occur when binding the port or accepting incoming connections.
/// Handles a single client connection.
///
/// # Parameters
///
/// - `socket`: The TCP stream representing the client connection.
/// - `store`: Shared reference to the in-memory key-value store.
///
/// # Behavior
///
/// - Sends a greeting line to the client: `+TinyKV ready\r\n`.
/// - Reads lines from the client in a loop.
/// - For each non-empty line:
/// - Parses and executes a command via [`handle_command`].
/// - Writes the response back to the client.
///
/// When the client closes the connection (EOF), the function returns.
///
/// # Errors
///
/// Any I/O error that occurs when reading from or writing to the socket
/// is returned as `std::io::Error`.
/// Parses and executes a TinyKV command.
///
/// # Supported commands
///
/// - `SET key value`
/// - Stores `value` under `key`.
/// - Response: `+OK\r\n`
///
/// - `GET key`
/// - Looks up the value for `key`.
/// - If found: `$<len>\r\n<value>\r\n`
/// - If not found: `$-1\r\n`
///
/// - `DEL key`
/// - Deletes the given key from the store.
/// - If a key was deleted: `:1\r\n`
/// - If key did not exist: `:0\r\n`
///
/// Any other command returns:
/// - `-ERR unknown command\r\n`
///
/// # Parameters
///
/// - `cmd`: A single line command string without trailing newlines.
/// - `store`: Shared reference to the key-value store.
///
/// # Returns
///
/// A `String` containing the full protocol response (including `\r\n`).
I dunno how to raw-dog code in rust. So, we compile it as follow:
and now we start the server:
)
in a differen terminal session execute telnet: