Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

PyCache

A Redis-compatible in-memory data store, written from scratch in Python — raw TCP sockets, a hand-written protocol parser, and asyncio. No redis-py, no third-party RESP library, no framework.

Python Tests Runtime deps License

Point a real redis-cli at it and it works:

$ redis-cli -p 6380
127.0.0.1:6380> SET user:42 "Goutam" EX 60
OK
127.0.0.1:6380> TTL user:42
(integer) 60
127.0.0.1:6380> RPUSH langs python go rust
(integer) 3
127.0.0.1:6380> LRANGE langs 0 -1
1) "python"
2) "go"
3) "rust"

Why this project exists

Redis is the piece of infrastructure most backend engineers use daily and fewest have looked inside. This is a working re-implementation of its core: the wire protocol, the keyspace, TTL expiry, durability, replication, and pub/sub — each built from the primitive underneath it rather than pulled from a library.

The interesting part isn't that it stores keys. It's the set of problems you can't avoid once you commit to speaking a real protocol over a real socket:

Problem Where it's solved
A command arrives split across three recv() calls Incremental parser
Three commands arrive in a single recv() (pipelining) Incremental parser
Two clients mutate the same key concurrently Single-threaded event loop
A key with a TTL is set and never read again Active + passive expiry
The process is killed mid-write AOF fsync policies
A crash leaves a half-written command on disk Truncated-tail recovery
Replacing an open file on Windows vs. POSIX BGREWRITEAOF
A replica needs current state, not a full write log Snapshot handshake

Each is written up below with the reasoning, including the tradeoffs that were taken deliberately and the ones that are genuine limitations.

What's implemented

  • RESP2 wire protocol — hand-written incremental encoder/decoder, binary-safe, compatible with redis-cli and any RESP2 client
  • Five data types — strings, lists, hashes, sets, plus TTL metadata, with real WRONGTYPE enforcement
  • 35+ commands — including INCR/DECR, LRANGE with negative indices, HGETALL, SCARD, EXPIRE/TTL/PERSIST
  • TTL expiry — lazy (on access) and an active background sampling sweep, mirroring Redis's own two-pronged approach
  • AOF persistence — three fsync policies, startup replay, corrupted-tail recovery, and BGREWRITEAOF compaction
  • Pub/Sub — channel registry, subscribe-mode command restrictions, multi-subscriber fan-out
  • Leader–follower replication — snapshot handshake, live command streaming, read-only replicas
  • Async benchmark harness — throughput and p50/p95/p99 latency, pointable at a real redis-server for comparison
  • A rate limiter built on top of it — demonstrating the store solving an actual problem
  • 147 tests covering the protocol, every command, expiry timing, crash recovery, pub/sub, and replication

Quickstart

git clone https://github.com/IITBGoutam/pycache.git
cd pycache
python -m pycache.server
PyCache listening on ('127.0.0.1', 6380) (appendfsync=everysec, standalone/leader)

No installation, no virtualenv, no dependencies — the server is standard library only. To run the tests you'll want pytest:

pip install -r requirements.txt
pytest                      # 147 passed

Talking to it

Any RESP2 client works. If you have redis-cli installed:

redis-cli -p 6380

If you don't, the project's own encoder is a fine client:

import socket
from pycache.resp import RESPParser, encode, NEED_MORE

sock = socket.create_connection(("127.0.0.1", 6380))
parser = RESPParser()

def command(*args):
    sock.sendall(encode(list(args)))
    while (reply := parser.next_command()) is NEED_MORE:
        parser.feed(sock.recv(65536))
    return reply

print(command("SET", "greeting", "hello"))   # OK
print(command("GET", "greeting"))            # hello

Server options

python -m pycache.server --port 6380 --appendfsync everysec
python -m pycache.server --port 6381 --replicaof 127.0.0.1 6380   # run as a replica
Flag Values Default
--host bind address 127.0.0.1
--port bind port 6380
--appendfsync always | everysec | no everysec
--replicaof HOST PORT follow a leader (standalone)

Architecture

flowchart TD
    C1["Client"] --> S
    C2["redis-cli"] --> S

    S["server.py<br/>asyncio connection loop"] --> P["resp.py<br/>RESPParser: incremental decode"]
    P --> D["commands.py<br/>name to handler dispatch"]
    D --> ST["store.py<br/>DataStore: the keyspace"]

    ST -. "TTL sweep" .-> EX["expiry.py<br/>background task"]
    D -. "writes" .-> AOF["persistence/aof.py<br/>append + fsync"]
    D -. "writes" .-> REP["replication.py<br/>stream to followers"]
    D -. "PUBLISH" .-> PS["pubsub.py<br/>channel fan-out"]

    AOF --> DISK[("appendonly.aof")]
    REP --> F["Replica<br/>read-only"]
    PS --> SUBS["Subscribed clients"]
Loading

A command's path through the system: bytes land in RESPParser, which yields complete command arrays; dispatch() looks the name up in a decorator-populated table and runs the handler against the shared DataStore; the reply is encoded back to RESP. If it was a write, it is appended to the AOF and streamed to replicas before the client is told it succeeded — so an acknowledged write is never one the server could lose.

Module Lines Responsibility
pycache/resp.py 256 RESP2 encoder + resumable incremental decoder
pycache/commands.py 422 Dispatch table and every command handler
pycache/store.py 390 Keyspace, type enforcement, TTL bookkeeping
pycache/server.py 233 Connection loop, write-path ordering, lifecycle
pycache/persistence/aof.py 229 Append path, replay, compaction
pycache/replication.py 115 Follower registry, handshake, stream application
pycache/pubsub.py 92 Channel registry and fan-out
pycache/expiry.py 31 Active-expiry background sweep

Engineering decisions

1. The protocol parser is a resumable state machine

TCP is a byte stream, not a message stream. It gives you no framing guarantees at all: a single SET key value can arrive as three separate recv() calls, and three pipelined commands can arrive as one. A parser that assumes one read equals one command is subtly broken under exactly the load you'd want a cache to handle.

RESPParser separates buffering from parsing. feed() appends bytes; next_command() attempts to parse one complete value from the front of the buffer and only consumes those bytes once the value is fully parsed — otherwise it returns a NEED_MORE sentinel and leaves the buffer untouched.

That single invariant handles both hard cases at once:

parser.feed(b"*3\r\n$3\r\nSET")     # partial command
parser.next_command()                # NEED_MORE — nothing consumed
parser.feed(b"\r\n$3\r\nfoo\r\n$3\r\nbar\r\n*1\r\n$4\r\nPING\r\n")
parser.next_command()                # ['SET', 'foo', 'bar']
parser.next_command()                # ['PING']   ← pipelined, no re-feed
parser.next_command()                # NEED_MORE

Bulk strings are decoded via latin-1, which maps bytes 0–255 onto code points one-to-one. The round trip bytes → str → bytes is therefore lossless for arbitrary binary payloads, which is how the store stays binary-safe while the rest of the codebase works with plain str.

2. Concurrency: single-threaded, on purpose

All connections share one DataStore with no locks anywhere. That's not an oversight — it's the design, and it's the same one real Redis uses.

Command handlers are ordinary synchronous functions containing no await. Because an asyncio coroutine can only yield control at an await point, a handler that has none runs to completion before any other connection's handler can start. Every command is therefore atomic with respect to every other command, for free.

Thread-per-connection was rejected deliberately: it would require locking every keyspace access (or one global lock, which defeats the point) for no throughput gain on a workload that is network-I/O bound with microsecond-scale in-memory operations.

This isn't just an internal detail — it's a guarantee the rate limiter below is built on.

3. Expiry runs two ways, because one isn't enough

Passive expiry runs at the top of every keyed access: if the TTL has passed, the key is deleted and the access proceeds as though it never existed. This guarantees an expired key is never returned, without ever scanning the keyspace.

But passive expiry alone leaks. A key that is given a TTL and then never touched again is never checked, so it occupies memory forever. So there's also an active sweep (expiry.py) — a background task that every 100ms samples up to 20 keys carrying a TTL and reaps whichever have expired. Sampling rather than scanning keeps the tick cost constant regardless of keyspace size.

DBSIZE exists specifically to make this observable: it's a raw len(keyspace) with no expiry side effects of its own, so it can report on the sweep without a read triggering passive expiry and masking the result.

4. Durability is a dial, not a boolean

Every write command is appended to appendonly.aof in the same RESP format clients use — which means recovery is not special-purpose code. Startup replay just feeds the file back through the ordinary parser and dispatcher:

replay_bytes(data, store, dispatch)   # the same dispatch() serving live traffic

The --appendfsync policy controls how hard the server pushes those bytes toward physical disk. Two failure modes matter and they are not the same:

  • Process crash (kill -9): Python's buffer is flushed into the OS page cache after every append, under all three policies. No policy loses data here.
  • Machine crash (power loss, kernel panic): only an fsync guarantees the page cache reached the platter. This is what the policy actually trades.
Policy Worst-case loss on power failure Measured throughput p50 latency
always at most the single in-flight write 743 ops/sec 66.7 ms
everysec (default) ~1 second of writes 3,143 ops/sec 14.6 ms
no whatever the OS hadn't flushed 6,601 ops/sec 5.9 ms

An 8.9× throughput spread across one flag. Ordering matters as much as the policy: the append happens before the reply is sent, so under always a client is never told a write succeeded before it is on disk.

Crash recovery also handles the damage a crash actually causes. A power failure mid-append leaves a truncated trailing command — so replay keeps everything it parsed successfully, logs a warning, and discards the incomplete tail rather than refusing to start:

WARNING AOF: discarding incomplete trailing command (7 trailing byte(s)) - likely a crash mid-write

5. Compaction has to survive Windows

An append-only log grows forever. SET k 1 a million times and you have a million entries reconstructing one key. BGREWRITEAOF replaces the log with the minimal command set that rebuilds current state — one SET per string, one RPUSH per list, one HSET per hash, one SADD per set, plus a PEXPIRE for each key still carrying a TTL.

It writes to a temp file and os.replace()s it over the live one, which is atomic on both platforms. But there's a real portability trap here, hit during development:

POSIX lets you rename over a file that's still open elsewhere — the open descriptor keeps referencing the old inode. Windows refuses, raising PermissionError: [WinError 5] Access is denied — including when the conflicting handle belongs to the very same process.

So the writer's handle is closed before the replace and reopened after, on every platform. Which is also why rewrite() takes the live AOFWriter object rather than just a path — it has to be able to close and reopen it.

6. Replication: snapshot, then stream

A joining replica needs the leader's current state, not its entire write history — replaying a million-command log to reconstruct ten keys is absurd. So the handshake reuses the compaction machinery:

  1. Follower connects and sends REPLCONF SYNC
  2. Leader replies with a snapshot — the same minimal command set BGREWRITEAOF produces
  3. Follower replays it through replay_bytes() — again, the same dispatcher
  4. Leader holds the connection open and streams every subsequent write to it

Verified end-to-end:

leader   SET city Mumbai        -> OK
leader   RPUSH langs py go rust -> 3
leader   HSET user name Goutam  -> 1

follower GET city               -> Mumbai
follower LRANGE langs 0 -1      -> ['py', 'go', 'rust']
follower HGETALL user           -> ['name', 'Goutam']
follower SET x 1                -> READONLY You can't write against a read only replica.

Replicas reject writes, so the leader stays the single source of truth. Failure is best-effort by design: an unreachable leader or a dropped connection is logged and ends the replication task, but the follower keeps serving reads rather than crashing.

7. Three commands break the dispatch pattern — and that's documented

commands.py gives every handler the uniform signature handler(store, args) -> reply. Three commands genuinely cannot fit it, and they're special-cased in server.py rather than contorting the abstraction:

  • SUBSCRIBE / UNSUBSCRIBE — one call produces several independent top-level replies (one per channel), which a single-return contract can't express
  • REPLCONF — needs the raw writer, to send a snapshot and then keep pushing to that same connection indefinitely

PUBLISH has no such need — it only touches shared state and returns one ordinary reply — so it stays a normal handler. Knowing where an abstraction should stop is part of designing one.


Performance

python -m pycache.server &
python benchmark/bench.py --connections 50 --ops 1000
connections:   50
total ops:     50000 (alternating SET/GET)
throughput:    3142.5 ops/sec
latency p50:   14.593 ms
latency p95:   29.419 ms
latency p99:   47.777 ms

Measured on Windows 11, Python 3.13, 50 concurrent connections, default appendfsync=everysec. See the durability table for how much the fsync policy moves this.

Reading these numbers honestly: the harness is closed-loop — each of the 50 connections blocks for a reply before sending again — so throughput here is bounded by round-trip latency, not by the store. The in-memory operations are microseconds; the milliseconds are socket round trips, the per-append flush() syscall, and CPython overhead. A C implementation like real Redis is orders of magnitude faster, and the gap is mostly the runtime, not the algorithms. --host/--port accept any RESP2 server, so you can point the same harness at a real redis-server and compare directly.

Rate limiter — the store solving a real problem

tools/rate_limiter.py implements per-key rate limiting against a running PyCache instance, connecting over a raw socket with the project's own codec.

with RateLimiter(limit=5, window_seconds=10) as limiter:
    for i in range(1, 9):
        print(i, "ALLOWED" if limiter.is_allowed("user:42") else "DENIED")
1 ALLOWED   2 ALLOWED   3 ALLOWED   4 ALLOWED   5 ALLOWED
6 DENIED    7 DENIED    8 DENIED

It's a fixed-window counter, not a token bucket — and that's a reasoned choice, not a shortcut. A token bucket stores a count and a last-refill timestamp, requiring a read-then-write across two values. Without transactions or server-side scripting (neither of which this project implements), that's two network round trips and therefore racy: two clients can both read the same count, both decide there's room, and both proceed.

INCR is a single command, and per the concurrency model it completes atomically before any other client's command interleaves. So a fixed-window counter cannot race. The tradeoff is accepted knowingly: traffic can burst to limit at the end of one window and again at the start of the next. For capping load — as opposed to smoothing it — that's the standard real-world Redis pattern.

This is also why INCR deliberately does not clear a key's TTL the way SET does: the limiter sets an expiry on the first hit of a window and relies on every later increment leaving it alone.

Testing

pytest                              # 147 passed in ~5s
pytest tests/test_resp.py -v        # one module
Suite Tests cover
test_resp.py Split reads, pipelining, binary payloads, malformed input, every RESP type
test_commands.py Every command, arity errors, WRONGTYPE, negative indices, edge cases
test_expiry.py TTL accuracy, passive vs. active expiry, PERSIST, INCR preserving TTL
test_aof.py Round-trip replay, truncated tails, corrupted data, compaction correctness
test_pubsub.py Fan-out, multi-subscriber delivery, unsubscribe, disconnect cleanup
test_replication.py Handshake, snapshot correctness, live streaming, read-only enforcement

Failure paths are tested as deliberately as success paths — truncated AOFs, corrupted files, protocol violations, and type mismatches all have coverage, because those are the cases that decide whether a data store is trustworthy.

Known limitations

Stated plainly, because pretending they don't exist would be worse:

  • Relative EXPIRE in the AOF doesn't preserve the absolute deadline. The AOF stores commands literally, so replaying EXPIRE foo 100 on restart resets the TTL to 100 seconds from replay time, not from when it was originally issued. Real Redis rewrites relative expiries to absolute PEXPIREAT before persisting. BGREWRITEAOF output doesn't have this problem — it always writes the current remaining TTL — so compacting before a restart works around it.
  • Replication is best-effort. No partial resync, no replication offsets or ACKs, no automatic failover. A dropped connection ends the follower's replication task rather than reconnecting.
  • Fan-out to followers is a simple loop — no batching, pipelining, or flow control.
  • BGREWRITEAOF blocks the event loop for its duration. Offloading it to a thread would remove the block but would then require locking around the keyspace, which the single-threaded design deliberately avoids everywhere else. Blocking briefly was the better trade.

Out of scope by design: RDB snapshots, cluster mode, RESP3, AUTH/ACLs, Lua scripting, transactions (MULTI/EXEC).

Command reference

35+ supported commands — click to expand
Command Notes
Connection
PING [message] +PONG, or echoes message
ECHO message
COMMAND Stubbed empty array so redis-cli doesn't hang on connect
Generic
TYPE key string / list / hash / set / none
DEL key [key ...] Count deleted
EXISTS key [key ...] Count present (counts duplicates)
DBSIZE Raw keyspace count, no expiry side effects
Strings
SET key value [EX s | PX ms] EX/PX set a real TTL
GET key Nil if missing
INCR key / DECR key Missing key starts at 0; preserves existing TTL
Lists (deque)
LPUSH / RPUSH key value [...] Returns new length
LPOP / RPOP key [count] Bulk string or nil; array or null-array with count
LRANGE key start stop Negative indices supported
LLEN key
Hashes (dict)
HSET key field value [...] Count of new fields
HGET / HDEL / HEXISTS / HLEN
HGETALL key Flat array, alternating field/value
Sets (set)
SADD key member [...] Count of new members
SREM / SMEMBERS / SISMEMBER / SCARD
Expiry
EXPIRE key s / PEXPIRE key ms 1 if set, 0 if key missing
TTL key / PTTL key Remaining; -1 no TTL, -2 no key
PERSIST key Removes TTL
Persistence
BGREWRITEAOF Compacts the AOF; replies immediately, runs in background
Pub/Sub
SUBSCRIBE channel [...] One confirmation reply per channel
UNSUBSCRIBE [channel ...] No args = unsubscribe from all
PUBLISH channel message Returns subscriber count reached
Replication
REPLCONF SYNC Follower handshake; replies with a state snapshot

While a connection has any active subscription, only SUBSCRIBE, UNSUBSCRIBE, and PING are accepted — matching Redis's own subscribe-mode restriction.

Project layout

pycache/
├── resp.py              RESP2 encoder + incremental decoder
├── server.py            asyncio connection loop, write-path ordering
├── commands.py          Dispatch table and handlers
├── store.py             Keyspace, types, TTL bookkeeping
├── expiry.py            Active-expiry background task
├── pubsub.py            Channel registry and fan-out
├── replication.py       Leader registry / follower client
└── persistence/
    └── aof.py           Append, replay, compaction

tests/                   147 tests across 6 suites
benchmark/bench.py       Async load generator, works against real Redis too
tools/rate_limiter.py    Fixed-window rate limiter built on INCR + EXPIRE

License

MIT — see LICENSE.

About

A Redis-compatible in-memory data store built from scratch in Python — RESP2 protocol, asyncio, AOF persistence, replication, and pub/sub. Zero runtime dependencies.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages