Updated Jul 6, 2026

A TCP Server - Speaking to Other Programs

Everything so far lives inside one Python process - to use the store, you have to be the store. Real databases are servers: a separate long-running process that any program can talk to over a socket, in any language, using an agreed protocol. That separation is the difference between a library and a database. This phase builds it, and in doing so runs into the problem that shapes every database server's architecture: two clients writing at once.

Design the protocol

A wire protocol is a contract about bytes: what a request looks like, what a response looks like, and how everyone knows where messages end. Ours is line-based - one request per line, one response per line:

Request Response Meaning
SET <key> <value> OK Store the value under the key
GET <key> VALUE <bytes> or NOT_FOUND Fetch the current value
DEL <key> DELETED or NOT_FOUND Remove the key
anything else ERR unknown command You made a typo; the server survives it

If that shape looks familiar, it should - it's a deliberate cousin of Redis's original inline protocol and memcached's text protocol. Text protocols win for a first version because you can debug them with your eyes.

The framing rule - "a message ends at \n" - has the exact flaw you identified in phase 2: a value containing a newline breaks the protocol. We accept the limitation knowingly for the wire (our record format on disk still handles arbitrary bytes fine), and the recap names how grown-up protocols fix it. Protocol design is trade-off design; the important thing is to know which corner you cut.

The server

Create server.py next to kv.py:

import socketserver
import threading

from kv import KV

store = KV("data.log")
lock = threading.Lock()


class Handler(socketserver.StreamRequestHandler):
    def handle(self):
        while True:
            line = self.rfile.readline()
            if not line:
                break                      # client hung up
            parts = line.strip().split(b" ", 2)
            cmd = parts[0].upper()

            if cmd == b"SET" and len(parts) == 3:
                with lock:
                    store.set(parts[1], parts[2])
                self.wfile.write(b"OK\n")
            elif cmd == b"GET" and len(parts) == 2:
                with lock:
                    value = store.get(parts[1])
                if value is None:
                    self.wfile.write(b"NOT_FOUND\n")
                else:
                    self.wfile.write(b"VALUE " + value + b"\n")
            elif cmd == b"DEL" and len(parts) == 2:
                with lock:
                    found = store.delete(parts[1])
                self.wfile.write(b"DELETED\n" if found else b"NOT_FOUND\n")
            else:
                self.wfile.write(b"ERR unknown command\n")


class Server(socketserver.ThreadingTCPServer):
    allow_reuse_address = True
    daemon_threads = True


if __name__ == "__main__":
    with Server(("127.0.0.1", 5555), Handler) as server:
        print("kv server listening on 127.0.0.1:5555")
        server.serve_forever()

The load-bearing pieces:

  • ThreadingTCPServer spawns one thread per client connection. Each connected client gets its own Handler running handle() in its own thread, looping over that client's lines until it disconnects. The stdlib does the accept-loop and thread plumbing; you write only the conversation.
  • StreamRequestHandler gives you rfile and wfile - the socket dressed up as file objects. rfile.readline() blocks until a full line arrives (the framing rule, implemented for free); an empty result means the client closed the connection.
  • split(b" ", 2) splits at most twice, so in SET user:1 Ada Lovelace the value keeps its space: everything after the second space is value bytes.
  • allow_reuse_address spares you the classic dev-loop pain: without it, restarting the server within a minute of stopping it can fail with "address already in use" while the OS holds the old socket in a cool-down state.
  • daemon_threads lets Ctrl+C actually stop the process instead of waiting on every open client connection.

The lock is not optional

The line that deserves the most respect in that file is lock = threading.Lock(), so let's earn it. With one thread per client, two clients can call store.set at the same time. Trace what's inside set: append to the log, then update the index and self._offset. Interleave two of those and you get real corruption, not theoretical ickiness:

  • Thread A computes its record's offset from self._offset. Before A finishes, thread B does the same - the same offset. Both index entries are wrong; one points into the middle of the other's record. A later get returns spliced garbage - which the CRC won't catch, because CRCs are checked on replay, not on reads through the index.
  • The interleaved _offset += len(record) updates can lose one of the additions entirely, shifting every future offset in the index.

The lock makes each command a critical section: one thread at a time touches the store, everyone else queues for microseconds. Simple, correct, and honest about its ceiling - under heavy parallel load, one global lock becomes the bottleneck. It's the right first answer anyway, and it's in good company: Redis processes commands one at a time by design (an event loop rather than a lock, but the same serial guarantee) and derives famous simplicity from it. When people say "Redis is single-threaded," this is the problem being dodged.

⚠️ Gotcha: the lock protects you only because every client goes through this one server process. If a second process opened data.log in append mode, the OS would happily interleave records from both - the lock never sees it. One writer process per log file is a rule, not a suggestion. (Real engines enforce it with a lock file on disk; SQLite's famous "database is locked" is that enforcement talking.)

A client

The server speaks a protocol, so prove it with a client that isn't you-in-a-REPL. Create client.py:

import socket
import sys


def send(command: str) -> str:
    with socket.create_connection(("127.0.0.1", 5555)) as sock:
        sock.sendall(command.encode() + b"\n")
        response = sock.makefile("rb").readline()
    return response.decode().rstrip("\n")


if __name__ == "__main__":
    print(send(" ".join(sys.argv[1:])))

Twelve lines, and note what they contain: connect, send a line, read a line back. Any language with sockets can implement this in the same twelve lines - Node, Go, Rust, a shell script with netcat. That's what "wire protocol" buys: your Python engine is now usable from software that has never heard of Python.

Run it

Terminal 1 - the server:

$ python server.py
kv server listening on 127.0.0.1:5555

Terminal 2 - the conversation:

$ python client.py SET name Ada Lovelace
OK
$ python client.py GET name
VALUE Ada Lovelace
$ python client.py DEL name
DELETED
$ python client.py GET name
NOT_FOUND
$ python client.py SET counter 42
OK

Now the moment the whole guide has been building toward. In terminal 1, kill the server with Ctrl+C. Start it again. Then:

$ python client.py GET counter
VALUE 42

What just happened: The server process died and took every in-memory structure with it - the index, the offsets, all of it. On restart, KV("data.log") replayed the log and rebuilt the world, and a client on the other side of a socket can't even tell it happened. Log, replay, index, recovery - every phase of this project fired in that one unremarkable VALUE 42. Unremarkable is the achievement.

Recap

  1. A database becomes a server when strangers can reach it through a socket and a protocol - the engine didn't change, its audience did.
  2. Line-based text protocols are debuggable by eye and fine for v1; their framing bans newlines in values. The grown-up fix is length-prefixing - exactly what your on-disk record format already does, and what Redis's RESP protocol ($5\r\nhello) does on the wire.
  3. One thread per client means concurrent access to shared state; the global lock serializes commands so log appends and index updates can't interleave into corruption.
  4. One writer process per log file - a lock inside one process can't referee two processes.
[
  {
    "q": "Two client threads call store.set() at the same instant with no lock. What's the realistic worst case?",
    "choices": [
      "One request is refused with an error by the OS",
      "Both writes compute offsets from the same _offset value, corrupting the index so reads return spliced bytes from two different records",
      "Nothing - Python's GIL makes the whole set() call atomic"
    ],
    "answer": 1,
    "explain": "The GIL makes single bytecode operations atomic, not multi-step methods - threads can interleave between the append and the bookkeeping. Both threads reading the same _offset leaves index entries pointing at wrong bytes."
  },
  {
    "q": "Why can't a value contain a newline in this wire protocol, and what's the standard fix?",
    "choices": [
      "TCP can't transmit newline bytes; the fix is base64-encoding everything",
      "The protocol frames messages by newline, so a newline inside a value would end the message early; the fix is length-prefixed framing, as in RESP or our own on-disk record format",
      "Python's readline() strips newlines; the fix is using read() instead"
    ],
    "answer": 1,
    "explain": "Delimiter-based framing always bans the delimiter from the payload. Length prefixes ('the next 5 bytes are the value') let payloads contain anything - the same reasoning that shaped the record format in phase 2."
  },
  {
    "q": "You restart the server process. Why do clients still see all their data?",
    "choices": [
      "The OS preserves the process's memory across restarts",
      "The clients cache their own writes locally",
      "KV's constructor replays the log on startup, rebuilding the index - the socket layer sits on top of the same crash-recovery machinery from phase 3"
    ],
    "answer": 2,
    "explain": "Nothing about the server is special: it constructs a KV, which replays data.log and rebuilds the index. Restarting a database is a controlled crash, and recovery is the same code path."
  }
]

← Phase 4: Compaction · Guide overview · Phase 6: Benchmarks, and What Redis Does Differently →

Check your understanding 3 questions

1. Two client threads call store.set() at the same instant with no lock. What's the realistic worst case?

2. Why can't a value contain a newline in this wire protocol, and what's the standard fix?

3. You restart the server process. Why do clients still see all their data?