Systems Engineering | Tiny KV

Nov 4, 2025

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
defmodule TinyKV.Application do
  @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()
  def start do
    port = Application.get_env(:tiny_kv, :port, 4040)

    children = [
      TinyKV.Store,
      {Task.Supervisor, name: TinyKV.ConnectionSupervisor},
      {TinyKV.TCPServer, port}
    ]

    opts = [strategy: :one_for_one, name: TinyKV.Supervisor]
    Supervisor.start_link(children, opts)
  end
end

# lib/tiny_kv/store.ex
defmodule TinyKV.Store do
  @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 :: %{optional(key()) => value()}

  @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()
  def start_link(opts \\ []),
    do: 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
  def set(key, value), do: GenServer.call(Store, {:set, key, value})

  @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
  def get(key), do: GenServer.call(Store, {:get, key})

  @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
  def del(key), do: GenServer.call(Store, {:del, key})

  @impl true
  @spec init(state()) :: {:ok, state()}
  def init(state), do: {:ok, state}

  @impl true
  @spec handle_call(
          {:set, key(), value()} | {:get, key()} | {:del, key()},
          GenServer.from(),
          state()
        ) ::
          {:reply, :ok | value() | :nil, state()}
  def handle_call({:set, key, value}, _from, state),
    do: {:reply, :ok, Map.put(state, key, value)}

  def handle_call({:get, key}, _from, state),
    do: {:reply, Map.get(state, key, :nil), state}

  def handle_call({:del, key}, _from, state) do
    {value, new_state} = Map.pop(state, key, :nil)
    {:reply, value, new_state}
  end
end

# lib/tiny_kv/tcp_server.ex
defmodule TinyKV.TCPServer do
  @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 :: %{socket: listen_socket()}

  @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()
  def start_link(port), do: 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()) :: {:ok, state()}
  def init(port) do
    {:ok, listen_socket} =
      :gen_tcp.listen(port, [:binary, packet: :line, active: false, reuseaddr: true])

    Logger.info("TinyKV listening on port #{port}")

    # Start the accept loop in a separate task so the GenServer can finish initialization.
    Task.start_link(fn -> accept_loop(listen_socket) end)

    {:ok, %{socket: listen_socket}}
  end

  # Private: Accepts incoming client sockets in a loop and spins up a Task per client.
  @spec accept_loop(listen_socket()) :: :ok | :error
  defp accept_loop(listen_socket) do
    case :gen_tcp.accept(listen_socket) do
      {:ok, socket} ->
        Task.Supervisor.start_child(TinyKV.ConnectionSupervisor, fn ->
          TinyKV.Connection.handle_client(socket)
        end)

        accept_loop(listen_socket)

      {:error, reason} ->
        Logger.error("accept failed: #{inspect(reason)}")
        :error
    end
  end
end

# lib/tiny_kv/connection.ex
defmodule TinyKV.Connection do
  @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
  def handle_client(socket) do
    :gen_tcp.send(socket, "+TinyKV ready\r\n")
    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
  defp loop(socket) do
    case :gen_tcp.recv(socket, 0) do
      {:ok, data} ->
        response = handle_command(String.trim(data))
        :gen_tcp.send(socket, response)
        loop(socket)

      {:error, :closed} ->
        :ok

      {:error, reason} ->
        Logger.error("socket error: #{inspect(reason)}")
        :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()
  defp handle_command(line) do
    parts = String.split(line, " ", parts: 3)

    case parts do
      ["SET", key, value] ->
        :ok = TinyKV.Store.set(key, value)
        "+OK\r\n"

      ["GET", key] ->
        case TinyKV.Store.get(key) do
          :nil -> "$-1\r\n"
          value -> "$#{byte_size(value)}\r\n#{value}\r\n"
        end

      ["DEL", key] ->
        case TinyKV.Store.del(key) do
          :nil -> ":0\r\n"
          _ -> ":1\r\n"
        end

      _ ->
        "-ERR unknown command\r\n"
    end
  end
end

{:ok, _} = Supervisor.start_link([%{
      id: TinyKV.Application,
      start: {TinyKV.Application, :start, []}
    }], strategy: :one_for_one)

Process.sleep(:infinity)

We can have all that in one file, say tiny.exs, and execute it:

$ elixir tiny.exs

We can then run another terminal session for a telnet client to interact with the server:

$ telnet localhost 4040

Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
+TinyKV ready
SET foo bar
+OK
GET foo
$3
bar
DEL foo
:1
GET foo
$-1

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 std::{
    collections::HashMap,
    io::{BufRead, BufReader, Write},
    net::{TcpListener, TcpStream},
    sync::{Arc, RwLock},
    thread,
};

/// 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 = Arc<RwLock<HashMap<String, String>>>;

/// 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.
fn main() -> std::io::Result<()> {
    let addr = "127.0.0.1:4040";
    let listener = TcpListener::bind(addr)?;
    println!("TinyKV (std) listening on {}", addr);

    // Create the shared, thread-safe store.
    let store: Store = Arc::new(RwLock::new(HashMap::new()));

    // Accept incoming TCP connections in a loop.
    for stream in listener.incoming() {
        let stream = stream?;
        let store = Arc::clone(&store);

        // Spawn a new OS thread for each client connection.
        thread::spawn(move || {
            if let Err(e) = handle_client(stream, store) {
                eprintln!("client error: {}", e);
            }
        });
    }

    Ok(())
}

/// 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`.
fn handle_client(socket: TcpStream, store: Store) -> std::io::Result<()> {
    // We need a writer and a reader:
    // - `writer` writes responses to the client.
    // - `reader` reads line-based commands from the same socket.
    let mut writer = socket.try_clone()?;
    let mut reader = BufReader::new(socket);

    // Initial greeting so the client knows the server is ready.
    writer.write_all(b"+TinyKV ready\r\n")?;

    let mut line = String::new();

    loop {
        line.clear();

        // Read a single line (blocking). `read_line` includes the trailing newline.
        let n = reader.read_line(&mut line)?;
        if n == 0 {
            // `n == 0` means EOF: client closed the connection.
            break;
        }

        // Trim whitespace and newlines to get the raw command.
        let response = handle_command(line.trim(), &store);

        // Write the protocol-formatted response back to the client.
        writer.write_all(response.as_bytes())?;
        writer.flush()?;
    }

    Ok(())
}

/// 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`).
fn handle_command(cmd: &str, store: &Store) -> String {
    // Split the input into at most 3 parts:
    //   1. command name (e.g., "SET")
    //   2. key
    //   3. value (which may contain spaces)
    let parts: Vec<&str> = cmd.splitn(3, ' ').collect();

    match parts.as_slice() {
        // SET key value
        ["SET", key, value] => {
            // Obtain a write lock because we're modifying the map.
            let mut map = store.write().unwrap();
            map.insert((*key).to_string(), (*value).to_string());
            "+OK\r\n".to_string()
        }

        // GET key
        ["GET", key] => {
            // Read lock: multiple clients can GET concurrently.
            let map = store.read().unwrap();
            match map.get(*key) {
                Some(v) => {
                    // Return length-prefixed bulk string: `$<len>\r\n<value>\r\n`
                    format!("${}\r\n{}\r\n", v.len(), v)
                }
                None => {
                    // `$-1\r\n` indicates a null / missing value.
                    "$-1\r\n".to_string()
                }
            }
        }

        // DEL key
        ["DEL", key] => {
            // Write lock: we might remove an entry.
            let mut map = store.write().unwrap();
            let existed = map.remove(*key).is_some();

            if existed {
                // `:1` means one key was deleted.
                ":1\r\n".to_string()
            } else {
                // `:0` means nothing was deleted.
                ":0\r\n".to_string()
            }
        }

        // Anything else: unknown command.
        _ => "-ERR unknown command\r\n".to_string(),
    }
}

I dunno how to raw-dog code in rust. So, we compile it as follow:

$ rustc tiny_kv.rs

and now we start the server:

$ ./tiny_kv

TinyKV (std) listening on 127.0.0.1:4040

in a differen terminal session execute telnet:

$ telnet 127.0.0.1 4040

Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
+TinyKV ready
SET foo bar
+OK
GET foo
$3
bar
DEL foo
:1
GET foo
$-1

RSS
https://idunnowhatiamdoing.engineering/posts/feed.xml