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:
=
=
=
break # client hung up
=
=
== b and == 2:
=
== b and == 2:
=
= True
= True
The load-bearing pieces:
ThreadingTCPServerspawns one thread per client connection. Each connected client gets its ownHandlerrunninghandle()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.StreamRequestHandlergives yourfileandwfile- 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 inSET user:1 Ada Lovelacethe value keeps its space: everything after the second space is value bytes.allow_reuse_addressspares 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_threadslets 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 latergetreturns 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:
=
return
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
- A database becomes a server when strangers can reach it through a socket and a protocol - the engine didn't change, its audience did.
- 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. - 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.
- 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?