ByteCache is a Redis-inspired, in-memory data store built entirely from raw Java sockets — no Redis binary underneath, no Spring Boot, no networking library doing the hard part for you. It speaks real RESP2 well enough that an unmodified
redis-clican talk to it, runs a command-dispatcher architecture across generic, string, numeric, stream, and transaction commands, handles persistent, concurrent client connections, and implements both Redis Streams (with blocking reads) and Redis-style transactions (MULTI/EXEC/DISCARD) from first principles.
Note
This is a systems-engineering showcase, not a Redis replacement. The interesting part isn't the feature list — it's that every layer between "a client typed SET" and "a value sits in memory" was written and understood by hand: socket lifecycle, byte-stream framing, protocol parsing, command dispatch, per-connection transaction state, and concurrent shared storage.
Warning
ByteCache is actively evolving. Persistence, benchmarking, and stronger concurrency guarantees on compound operations are in progress — see Known Limitations and the Roadmap for the honest current state.
If this project helped you understand what's really happening underneath Redis — or underneath any client-server system — consider giving it a star. It genuinely helps.
- 🚀 Why This Project Exists
- ✨ Key Features
- 🏗️ High-Level Architecture
- 🔬 The Journey of a Command
- 🧩 Command Architecture
- 🔁 Transactions Engine
- 🗄️ Storage Model & State Ownership
- 🌊 Redis Streams Internals
- 📡 RESP Protocol & Wire Examples
- 🧵 Concurrency Model
⚠️ Known Concurrency Limitations- 🔌 Supported Commands
- ❌ Failure Semantics
- 🔍 Engineering Deep Dives
- 🐛 Interesting Bugs & Debugging Stories
- 🧭 Design Decisions & Trade-offs
- 📂 Project Structure
- ⚙️ How to Run
- 🧪 Testing Strategy
- 💻 Example Sessions
- ⚔️ ByteCache vs Redis
- 🕰️ Architecture Evolution
- 🧗 Learning Milestones
- 🚫 Known Limitations
- 🔮 Future Vision — Planned Architecture
- 🗺️ Roadmap
- 🏆 Resume-Worthy Engineering
- 📚 What I'm Learning
- 🤝 Contribution
⚠️ Disclaimer- 📜 License
Most developers only ever meet Redis through a client library:
redis.set("user:42", "Anuj");Clean. Convenient. And it hides almost everything interesting.
ByteCache deliberately starts several layers lower — at the point where none of that convenience exists yet:
TCP connection → raw bytes → RESP parsing → command dispatch → in-memory data structures
Building it means confronting the questions a framework usually answers for you:
- What does a Redis command actually look like on the wire?
- Why can't a single
Socket.read()call be treated as a single command? - How does one server handle hundreds of persistent client connections at once?
- What does
MULTI/EXECactually queue, and where does that queue live? - How are Redis Stream IDs ordered, validated, and auto-generated — and how does a blocking
XREADeven work? - What breaks when two threads touch the same in-memory map, or the same transaction queue, at the same time?
There's no Spring Boot here on purpose — networking, protocol handling, and command dispatch are the point, not implementation details hidden behind an annotation.
Client → TCP socket → RESP parser → command dispatcher → in-memory store, with every arrow hand-implemented
- ✅ Zero frameworks — pure
java.net, no Netty, no Spring, no Redis client library underneath - ✅ Real wire compatibility — speaks RESP2 well enough for an unmodified
redis-clisession to work against it - ✅ Command-dispatcher architecture — commands are grouped into
Generic,String,Numeric,Stream, andTransactioncategories, dispatched through aMap<String, Command>rather than a hardcoded if/else chain - ✅ Hand-rolled protocol parsing — incremental buffering across partial reads, correctly splits pipelined commands arriving in a single read
- ✅ Redis-style transactions —
MULTI, command queuing,EXEC,DISCARD, per-connection transaction state, and concurrent transactions across multiple clients - ✅ Thread-per-connection concurrency — every client gets its own thread; all threads share one
ConcurrentHashMap-backed store, while transaction state stays connection-local - ✅ TTL with lazy expiration —
SET ... PX <ms>support with expiry checked (and enforced) at read time, no background sweeper - ✅ Redis Streams, including blocking reads —
XADDwith explicit/partial/auto-generated IDs,XRANGEfor range queries, and a blockingXREADfor real-time consumption - ✅ Verified against a real client — tested via actual
redis-cliTCP sessions, including survivingredis-cli's own startup handshake commands - ✅ CodeCrafters-guided, independently built — uses the Build Your Own Redis challenge as a progression map and compatibility check, not a source of copied solutions
All client traffic flows straight into a listening ServerSocket — there's no gateway or proxy layer here, ByteCache is the server. Every accepted connection gets its own thread, its own ClientConnection, and its own ClientContext (for transaction state) — but every thread reads from and writes to the same shared RedisStore.
Full layered view — Client, Server, Connection, Protocol, Command Dispatch, and Storage layers, one arrow per responsibility handoff
┌─────────────────────────────┐
│ Client │
│ redis-cli / TCP │
└──────────────┬──────────────┘
│ TCP byte stream
▼
┌─────────────────────────────┐
│ RedisServer │
│ ServerSocket.accept() │
│ spawns one thread / client │
└──────────────┬──────────────┘
│ one Socket per client
▼
┌─────────────────────────────┐
│ ClientConnection │
│ InputStream / OutputStream │
│ owns a ClientContext │
└──────────────┬──────────────┘
│ raw RESP bytes
▼
┌─────────────────────────────┐
│ RespParser │
│ bytes → complete commands │
│ handles partial reads │
└──────────────┬──────────────┘
│ List<String> command
▼
┌─────────────────────────────┐
│ CommandDispatcher │
│ Map<String, Command> │
│ Generic/String/Numeric/ │
│ Stream/Transaction │
└──────────────┬──────────────┘
▼
┌─────────────────────────────┐
│ RedisStore │
│ ConcurrentHashMap │
│ Strings + Streams + TTL │
└─────────────────────────────┘
| Component | Responsibility |
|---|---|
Main |
Starts the server. |
RedisServer |
Owns the listening ServerSocket, accepts clients, spawns a ClientConnection thread per client. |
ClientConnection |
Owns one TCP connection — reads bytes, feeds the parser, forwards parsed commands to the dispatcher, writes RESP responses. Knows nothing about which commands exist. |
ClientContext |
Connection-local state: whether the connection is inside a transaction, and its queued commands. |
RespParser |
Knows how RESP is structured. Doesn't know what SET or XADD mean. |
CommandDispatcher |
Knows which commands exist and routes a parsed command to the right Command implementation. Returns a RESP error for anything it doesn't recognize. |
Command implementations |
One class per command (or a small family per category), each responsible for its own semantics. |
RedisStore |
Owns the actual in-memory data, shared across every client connection. |
StoredValue / RedisStream / StreamEntry / StreamId |
Model the supported data types and their internal state. |
The architecture diagram above shows layers. This section shows what actually happens, object by object, for one real command.
redis-cli
│
│ RESP bytes
▼
TCP
│
▼
ClientConnection
│
│ InputStream.read()
▼
byte[]
│
▼
RespParser
│
│ ["SET", "user:42", "Anuj"]
▼
CommandDispatcher
│
▼
SetCommand
│
▼
RedisStore
│
▼
ConcurrentHashMap
│
▼
StoredValue
StoredValue
↓
SetCommand
↓
+OK\r\n
↓
OutputStream
↓
TCP
↓
redis-cli
No step is skipped or hidden: the bytes that arrive are not the same as the object that gets parsed, which is not the same as the command that executes, which is not the same as the bytes that go back out. Making that chain explicit — rather than "the server processes the command" — is most of what this project is actually about.
Early on, ByteCache handled commands with a hardcoded chain of if/else checks inside the connection-handling code. That doesn't scale past a handful of commands, and it also means the networking layer has to know about application semantics — a violation of the separation-of-concerns principle the whole project is built around.
Commands are now organized by category, each with its own package, and routed through a single dispatcher:
Command
│
├── Generic
│ ├── PING
│ ├── ECHO
│ └── TYPE
│
├── String
│ ├── SET
│ └── GET
│
├── Numeric
│ └── INCR
│
├── Stream
│ ├── XADD
│ ├── XRANGE
│ └── XREAD
│
└── Transaction
├── MULTI
├── EXEC
└── DISCARD
CommandDispatcher
│
└── Map<String, Command>
ClientConnection no longer needs to know that SET or XADD exist — it just hands a parsed command list to the dispatcher and writes back whatever RESP response comes out. That single change is what let redis-cli interoperability actually work (see Interesting Bugs & Debugging Stories below).
Transactions are one of the largest pieces of the current implementation — MULTI, command queuing, EXEC, DISCARD, per-connection transaction state, and multiple simultaneous transactions across different clients.
Client A
│
├── MULTI
├── SET foo 41
├── INCR foo
│
▼
ClientContext A
└── queue:
[SET foo 41]
[INCR foo]
While a connection is inside MULTI, commands aren't executed immediately — they're appended to that connection's queue and answered with +QUEUED\r\n.
EXEC
↓
CommandDispatcher
↓
execute queued commands, in order
↓
capture each command's RESP response
↓
construct one RESP array of results
This is the most important thing this section teaches, and it's easy to assume otherwise if you're used to SQL transactions:
Command failure ≠ transaction rollback.
MULTI
INCR foo ← error (foo isn't an integer)
INCR bar ← succeeds
EXEC
produces:
[
(error) ERR value is not an integer or out of range
(integer) 42
]
The successful INCR bar is not undone by the failed INCR foo. EXEC guarantees the queued commands run in order without another client's commands interleaving between them — it does not guarantee all-or-nothing semantics. Modeling this correctly (rather than assuming SQL-style rollback) was one of the more interesting design decisions in the project.
RedisStore
shared state
/ \
/ \
▼ ▼
ClientContext A ClientContext B
│ │
Queue A Queue B
│ │
MULTI... MULTI...
Two clients can each be mid-transaction at the same time, with completely independent queues, while both still read and write the same underlying RedisStore once their EXEC runs. Keeping transaction bookkeeping out of RedisStore and inside per-connection ClientContext objects is one of the cleaner boundaries in the current architecture.
Everything server-global lives behind one deceptively simple structure:
Map<String, Object> data; // backed by ConcurrentHashMapRedisStore
│
└── data : Map<String, Object>
├── "name" → StoredValue { value: "Anuj", expiresAt: null }
├── "session" → StoredValue { value: "abc123", expiresAt: <timestamp> }
└── "events" → RedisStream { entries: [...] }
The Object value type is deliberate: one key namespace, many Redis data types, without over-committing to a rigid schema up front.
Strings are wrapped in StoredValue, which carries both the value and an optional expiry — so a normal key and a PX-expiring key share the exact same storage path.
SET session abc123 PX 5000
↓
"session" → StoredValue { value = "abc123", expiresAt = now + 5000 }
Note
Expiration is lazy: the timestamp is only checked when the key is next accessed, at which point an expired key is evicted on the spot. There's no background sweeper thread — the trade-off (memory isn't reclaimed until the next access) is explicit and intentional, not an oversight.
Not all state is equal — some belongs to a single connection, some is shared server-wide. Making that boundary explicit is one of the more important design decisions in ByteCache:
| State | Owner | Shared across clients? |
|---|---|---|
| TCP socket | ClientConnection |
No |
| RESP parser buffer | RespParser (per connection) |
No |
Transaction queue / in-MULTI flag |
ClientContext |
No |
| Redis key/value data | RedisStore |
Yes |
| Stream entries | RedisStream (inside RedisStore) |
Yes |
| Server listening socket | RedisServer |
Server-wide (singleton) |
Streams are modeled with a small, focused set of classes:
RedisStore → "events" → RedisStream → List<StreamEntry>
├── StreamEntry { StreamId, Map<String,String> fields }
├── StreamEntry
└── StreamEntry
Example:
XADD events 1000-0 user Anuj action login
XADD events 1001-0 user Rahul action logout
"events" → RedisStream
├── [0] ID: 1000-0 { user: Anuj, action: login }
└── [1] ID: 1001-0 { user: Rahul, action: logout }
A stream ID like 1000-7 is not stored as a raw string — it's a comparable object:
class StreamId {
long millisecondsTime;
long sequenceNumber;
}which lets the stream enforce strictly increasing order on every insert.
| Form | Meaning |
|---|---|
1000-0 |
Explicit, fully specified ID |
1000-* |
Explicit timestamp, auto-generated sequence number |
* |
Fully auto-generated timestamp + sequence number |
For entries sharing a millisecond, the sequence number preserves order:
1000-0 → 1000-1 → 1000-2 → 1000-3
Every explicit ID is validated against the current stream tail before insertion — a lower or equal ID is rejected with a RESP error.
XRANGE answers "give me the entries between these two IDs" — a straightforward scan over the ordered List<StreamEntry> with inclusive bounds.
XREAD is more interesting when used in blocking mode: a client can ask to wait until a new entry is appended rather than polling. Conceptually:
XREAD BLOCK <ms> STREAMS events $
│
▼
check: any entries after last-seen ID?
│
┌────┴────┐
│ │
yes no
│ │
▼ ▼
return wait (up to timeout) for a new XADD, then re-check
That "check, then wait, then re-check" shape is a classic source of race conditions if the underlying state can change between the check and the wait — see Known Concurrency Limitations.
ByteCache speaks the same wire format redis-cli expects. Seeing the actual bytes makes it obvious this is protocol work, not just "calling Redis commands."
PING — client to server:
*1\r\n
$4\r\n
PING\r\n
Response:
+PONG\r\n
SET name Anuj:
*3\r\n
$3\r\n
SET\r\n
$4\r\n
name\r\n
$4\r\n
Anuj\r\n
Response:
+OK\r\n
GET name — response:
$4\r\n
Anuj\r\n
GET on a missing or expired key:
$-1\r\n
INCR foo — response (RESP integer):
:42\r\n
EXEC returning two queued results (OK, then 42):
*2\r\n
+OK\r\n
:42\r\n
TCP hands you a byte stream, not a stream of commands. A single command can arrive split across reads:
read #1 → "*1\r\n$4"
read #2 → "\r\nPING\r\n"
...or several commands can arrive bundled together in one read:
read #1 → PING + SET + GET, all in one buffer
RespParser buffers until a full command is available before handing it off:
TCP
↓
byte stream
↓
RESP message framing
↓
RespParser
↓
complete commands, one at a time
This is arguably the single most important lesson the project teaches:
One
read()is not one request.
ByteCache uses a thread-per-connection model:
Main server thread
├── accept Client A → Thread A
├── accept Client B → Thread B
└── accept Client C → Thread C
Each thread owns its own socket and its own ClientContext — but every thread shares the same RedisStore:
RedisStore (ConcurrentHashMap)
/ | \
Thread A Thread B Thread C
│ │ │
ClientContext ClientContext ClientContext
(private) (private) (private)
It's a deliberately simple model: connections and threads have a 1:1 relationship, transaction state is explicitly private per connection, and shared storage is made safe via ConcurrentHashMap. That gives a clean baseline to compare against thread-pool or event-loop designs later (see Roadmap).
ConcurrentHashMap makes individual get/put operations safe — it does not automatically make multi-step (compound) operations atomic. Being explicit about that distinction matters more than claiming everything is thread-safe.
A few concrete examples in the current implementation:
INCRis check-then-act. Conceptually it'sGET → parse → +1 → SET. Two threads incrementing the same key at nearly the same instant could interleave those steps.- Streams are
ArrayList-backed. Appending to a stream while another thread is reading a range from it has synchronization considerations beyond what a singleConcurrentHashMap.get()provides. - Blocking
XREADhas a classic check-then-wait shape. If the stream changes in the gap between "check for new entries" and "start waiting," there's a possible missed-wakeup race.
The current implementation intentionally uses a simple concurrency model. Some compound operations are not yet fully atomic across client threads, and stream-level synchronization is an area flagged for future hardening rather than something already solved.
Documenting this honestly is more convincing than claiming full thread-safety and being wrong about it.
| Command | Category | Status |
|---|---|---|
PING |
Generic | ✅ |
ECHO |
Generic | ✅ |
TYPE |
Generic | ✅ |
SET (incl. PX) |
String | ✅ |
GET |
String | ✅ |
INCR |
Numeric | ✅ |
XADD |
Stream | ✅ |
XRANGE |
Stream | ✅ |
XREAD (incl. blocking) |
Stream | ✅ |
MULTI |
Transaction | ✅ |
EXEC |
Transaction | ✅ |
DISCARD |
Transaction | ✅ |
Every command not in this table is met with a RESP error rather than being silently ignored or crashing the connection — see Failure Semantics.
Successful paths are only half the story. ByteCache distinguishes several categories of failure, each with its own RESP-level behavior:
| Failure | Example | Behavior |
|---|---|---|
| Unknown command | FOOBAR |
RESP error from CommandDispatcher |
| Command-level error | INCR abc where abc isn't an integer |
RESP error, connection stays open |
| Transaction-state error | EXEC without a preceding MULTI |
RESP error |
| Wrong data type | A string operation on a stream key (or vice versa) | RESP error |
| Stream ID violation | Inserting a decreasing/duplicate stream ID | RESP error, entry rejected |
| Malformed RESP | A syntactically invalid frame | Parser-level RESP error |
1. TCP is just a byte stream. ServerSocket, Socket, InputStream, OutputStream are used directly — TCP has no idea what SET or XADD mean, it only moves bytes. RESP is what gives those bytes application meaning.
2. Parsing is separate from execution. RespParser only turns bytes into ["SET", "name", "Anuj"]. It never decides what SET does — that decoupling is what let the command-dispatcher architecture replace a hardcoded chain without touching the networking layer at all.
3. Connections are persistent. A connection isn't torn down after one command — an entire MULTI ... EXEC sequence, interleaved with PINGs and GETs from other clients, can all ride the same long-lived TCP connection.
4. Expiration is lazy, on purpose. No background thread scans for expired keys. GET checks expiresAt at read time and evicts on the spot — smaller and more explicit, at the cost of not reclaiming memory until the next access.
5. Stream ordering is structural, not textual. IDs compare as (millisecondsTime, sequenceNumber) tuples, so 1000-2 < 1000-3 < 1001-0 is enforced by the type system, not string comparison tricks.
6. Transactions queue commands, they don't queue rollback plans. EXEC runs each queued command and collects its result — there is no undo log, because that's how real Redis transactions behave too (see Transactions Engine).
"redis-cli appeared stuck on connect."
Root cause, traced at the wire level rather than in application code:
redis-cli
↓
sends COMMAND DOCS / INFO SERVER on startup
↓
ClientConnection's fixed command whitelist
↓
command silently ignored
↓
redis-cli sits waiting for a response that never comes
A real Redis client issues housekeeping commands beyond whatever subset a hobby server implements — the networking layer can't assume a fixed whitelist of "known-good" commands. The fix was architectural, not a patch: move command awareness entirely out of ClientConnection and into CommandDispatcher, which now returns a proper RESP error for anything it doesn't recognize instead of the connection silently dropping the command:
ClientConnection
↓
RespParser
↓
CommandDispatcher
↓
unknown command → -ERR (not silence)
That single fix is also what's described in Command Architecture above — the bug is what motivated the refactor.
Cross-OS TCP testing. During development the Java server runs on Windows while redis-cli runs from WSL:
Windows Java server
↑
│ TCP
↓
WSL redis-cli
localhost doesn't always resolve the way you'd expect across that boundary — the WSL-to-Windows gateway address is sometimes needed instead, depending on local network configuration. Minor in isolation, but a good reminder that "it works on my machine" has layers even within one machine.
Chosen because:
- Simple mental model — one thread, one client, one story to follow in a debugger
- Makes blocking I/O behavior visible instead of hidden behind a reactor
- Easy to reason about correctness during early development
Trade-off:
- Doesn't scale as efficiently as an event-loop or thread-pool architecture for very large connection counts — each thread carries real OS overhead. Comparing this against alternatives is explicit future work (see Roadmap).
Pros:
- Simple to implement and reason about
- No background sweeper thread, no extra scheduling complexity
- Fewer moving parts to get wrong
Cons:
- Expired-but-unaccessed keys stay in memory indefinitely — there's no proactive reclamation
Multiple client threads
↓
shared RedisStore
↓
ConcurrentHashMap
It guarantees individual get/put calls are safe under concurrent access from multiple client threads. It does not guarantee that multi-step logic built on top of those calls (like INCR) is atomic — see Known Concurrency Limitations for exactly where that matters today.
ByteCache/
├── src/main/java/
│ ├── Main.java
│ ├── server/
│ │ └── RedisServer.java
│ ├── connection/
│ │ ├── ClientConnection.java
│ │ └── ClientContext.java
│ ├── protocol/
│ │ └── RespParser.java
│ ├── command/
│ │ ├── Command.java
│ │ ├── CommandDispatcher.java
│ │ ├── generic/
│ │ ├── string/
│ │ ├── numeric/
│ │ ├── stream/
│ │ └── transaction/
│ └── storage/
│ ├── RedisStore.java
│ ├── StoredValue.java
│ ├── RedisStream.java
│ ├── StreamEntry.java
│ └── StreamId.java
├── pom.xml
└── README.md
The structure deliberately avoids both failure modes: a single 1000-line Main.java, and forty interfaces before the server can handle a second command. New classes appear when a real responsibility shows up — the command/ package and ClientContext are both examples of structure that arrived after a concrete need (a growing command list, and transaction state) rather than upfront.
Server lifecycle → Connection lifecycle → Protocol parsing → Command dispatch → Data storage
| Requirement | Notes |
|---|---|
| JDK 25+ | Or a compatible Java version |
| Maven | Used for build lifecycle |
redis-cli |
Optional, but recommended for interoperability testing |
| OS | Windows / Linux / macOS with a TCP-capable environment |
From the project root:
mvn clean compileStart the server via Main. It listens on the default Redis port:
6379
redis-cli -p 6379PING
SET name Anuj
GET name
TYPE name
SET session abc PX 5000
GET session
INCR counter
Stream commands:
XADD events 1000-0 user Anuj
XADD events 1001-0 user Rahul
XADD events 1002-* user Anuj
XADD events * user Anuj
XRANGE events 1000-0 1001-0
XREAD BLOCK 5000 STREAMS events $
TYPE events
Transaction commands:
MULTI
SET foo 41
INCR foo
EXEC
Note
If the Java server runs on Windows and redis-cli runs from WSL, you may need the WSL-to-Windows gateway address instead of localhost, depending on your network configuration.
Beyond "I ran some commands," ByteCache is exercised against a deliberate set of behavioral scenarios, grouped by subsystem:
Networking
- Partial RESP command split across reads
- Multiple commands arriving in a single read
- Persistent connection carrying many commands
- Client disconnect mid-session
Strings
GETon a missing keySETthenGETPXexpiration- Access after expiration (lazy eviction)
Streams
- Explicit stream ID
- Auto-generated stream ID (
*andms-*) - Multiple entries sharing one millisecond
- Invalid / decreasing stream ID
XRANGEover a bounded windowXREAD(non-blocking)XREAD BLOCK(blocking, with and without a timeout)
Transactions
MULTIthenEXECMULTIthenDISCARDEXECwithout a precedingMULTIDISCARDwithout a precedingMULTI- A failing command inside an otherwise-successful
EXEC - Multiple simultaneous transactions on different connections
Validation itself happens at several levels — direct compilation of the full source tree, real TCP interaction (not just direct Java method calls), actual redis-cli sessions for wire-level compatibility, and the CodeCrafters Build Your Own Redis checks as an external compatibility signal rather than a source of copied solutions.
Real terminal transcripts speak louder than a paragraph claiming "transactions work."
A successful transaction:
$ redis-cli -p 6379
> MULTI
OK
> SET foo 6
QUEUED
> INCR foo
QUEUED
> EXEC
1. OK
2. (integer) 7
> GET foo
"7"
A transaction with a failing command inside it:
> MULTI
OK
> INCR foo
QUEUED
> INCR bar
QUEUED
> EXEC
1. (error) ERR value is not an integer or out of range
2. (integer) 42
Note that the second command still ran and returned a real result — the first command's failure didn't roll anything back.
Not a competitive comparison — a way of showing that ByteCache is intentionally implementing selected mechanisms to understand them, not trying to be a smaller Redis.
| Area | ByteCache | Redis |
|---|---|---|
| Purpose | Educational implementation | Production datastore |
| Transport | TCP | TCP |
| Protocol | RESP2 subset | Full Redis protocol ecosystem |
| Storage | In-memory Java objects | Highly optimized native implementation |
| Concurrency | Thread-per-connection | Event-driven single-threaded core + background threads |
| Persistence | Planned | Mature (RDB/AOF) |
| Data types | Strings, Streams | Broad set (lists, sets, hashes, sorted sets, streams, ...) |
| Streams | Append, range, blocking read | Full Streams feature set (consumer groups, etc.) |
| Transactions | MULTI/EXEC/DISCARD, no rollback |
Mature transaction semantics, no rollback (same philosophy) |
| Scale | Learning project | Production system |
The current architecture didn't appear fully formed — it grew in response to real requirements as they showed up, which is a large part of the point of the project:
Stage 1 — TCP Server
↓
ClientConnection with hardcoded command handling
Stage 2 — Protocol Separation
↓
TCP → RespParser → CommandDispatcher → Command implementations → RedisStore
Stage 3 — Command Categories
↓
Command
├── generic
├── string
├── numeric
├── stream
└── transaction
Stage 4 — Connection-Local State
↓
Connection
├── ClientContext (transaction state)
└── shared RedisStore (server-wide data)
Each stage was a response to a concrete limitation of the previous one — Stage 2 happened because redis-cli needed graceful handling of unknown commands (see Interesting Bugs & Debugging Stories); Stage 4 happened because transactions needed somewhere to keep per-connection queue state without polluting the shared store.
01 — TCP server
↓
02 — RESP parsing
↓
03 — Persistent connections
↓
04 — In-memory storage
↓
05 — TTL / lazy expiration
↓
06 — Streams (XADD)
↓
07 — Command-dispatcher architecture
↓
08 — Stream range & blocking reads (XRANGE, XREAD)
↓
09 — Transactions (MULTI/EXEC/DISCARD)
↓
10 — Concurrent transactions across clients
↓
11 — Persistence (planned)
↓
12 — Performance benchmarking (planned)
Being transparent here makes the README more trustworthy, not weaker:
- In-memory only — all data disappears on restart
- Implements a subset of RESP2, not the full protocol
- Implements a subset of Redis commands
- Thread-per-connection concurrency model (not yet compared against alternatives)
- No authentication
- No persistence yet (no RDB/AOF equivalent)
- No replication or clustering
- No production-grade memory management
- Some compound operations (
INCR, stream reads/writes, blockingXREAD) are not yet fully atomic under concurrent access — see Known Concurrency Limitations - Limited protocol-level validation for malformed input
- No performance benchmarks yet
The current server — a real command-dispatcher architecture, streams with blocking reads, and working transactions — is the foundation. The direction below is where ByteCache is headed once the items in the Roadmap land.
The envisioned system — an event-loop or thread-pool front end, stronger compound-operation atomicity, a durability layer for recovery after restart, and a benchmarking harness for throughput/latency
| Area | Today | Planned |
|---|---|---|
| Concurrency | Thread-per-connection | Compare against thread-pool and event-loop (e.g. NIO selector-based) models under real load |
| Compound-operation safety | INCR / stream ops are check-then-act |
Explicit synchronization or CAS-based atomicity |
| Command surface | 12 commands across 5 categories | Broader RESP value types, more Redis data types |
| Durability | Pure in-memory, nothing survives restart | Write-ahead logging / append-only file, crash recovery, fsync trade-offs |
| Observability | None yet | Structured logging; commands/sec, latency, active connections, error rate |
| Performance | Undocumented | Benchmark thread-per-connection vs thread-pool vs NIO across 100 / 1,000 / 10,000+ clients, tracking throughput and p95/p99 latency |
| Testing | Manual + redis-cli sessions |
Protocol-level test client, automated integration suite |
Note
These are directions, not commitments with dates — see the Roadmap table below for the actual state of each item today.
| Feature | Status |
|---|---|
Raw TCP server (ServerSocket / Socket) |
✅ Completed |
| Persistent, multi-client connections | ✅ Completed |
| Thread-per-connection concurrency | ✅ Completed |
| RESP request parsing (partial reads + pipelining) | ✅ Completed |
| RESP simple strings / bulk strings / integers / errors | ✅ Completed |
| Command-dispatcher architecture (Generic/String/Numeric/Stream/Transaction) | ✅ Completed |
PING, ECHO, TYPE, SET (+PX), GET, INCR |
✅ Completed |
Redis Streams — XADD (explicit/partial/auto IDs), monotonic validation |
✅ Completed |
Stream range queries — XRANGE |
✅ Completed |
Stream reads — XREAD, including blocking mode |
✅ Completed |
Transactions — MULTI, EXEC, DISCARD, per-connection state |
✅ Completed |
| Concurrent transactions across multiple clients | ✅ Completed |
redis-cli interoperability (unknown-command handling) |
✅ Completed |
Atomicity for compound operations (INCR, stream ops) |
❌ Not Started |
| Thread pool / event-loop architecture comparison | ❌ Not Started |
| Throughput & latency benchmarking | ❌ Not Started |
| Persistence & recovery semantics | ❌ Not Started |
| Structured logging & metrics | ❌ Not Started |
| Protocol-level automated test client / integration suite | ❌ Not Started |
- Built a Redis-compatible TCP server in Java using raw
ServerSocket/SocketAPIs and implemented incremental RESP2 parsing that correctly handles partial reads and pipelined commands. - Designed a persistent, multi-client architecture using thread-per-connection execution over a shared, concurrency-safe in-memory store.
- Refactored a hardcoded command chain into a category-based command-dispatcher architecture (
Generic/String/Numeric/Stream/Transaction), diagnosed and fixed via a realredis-cliinteroperability bug. - Implemented Redis Streams with structured stream IDs, monotonic ordering validation, automatic ID generation, range queries (
XRANGE), and blocking reads (XREAD). - Implemented Redis-style transactions with per-connection command queues,
EXECresponse aggregation,DISCARD, and correctly modeled non-rollback failure semantics under concurrent transactions. - Identified and documented specific concurrency limitations (compound-operation atomicity, blocking-read race conditions) rather than overclaiming thread-safety.
Networking → TCP byte streams → Application protocols → RESP framing/parsing
→ Command dispatch → In-memory data structures → TTL / data lifetime
→ Concurrency and shared state → Streams and ordered identifiers
→ Blocking operations → Transactions and connection-local state
→ Performance / architecture → Persistence and durability
Building each layer directly gives hands-on experience with: TCP socket programming in Java, blocking I/O, connection lifecycle management, byte streams and message framing, protocol parsing, RESP serialization, persistent connections, multi-client servers, thread-per-connection concurrency, concurrent shared state, key-value storage, TTL and lazy expiration, Redis-style data types, ordered stream IDs, automatic identifier generation, blocking reads, transaction queuing and non-rollback failure semantics, error handling, API/data-model boundaries, incremental architecture design, and testing at the network boundary.
This project is under active development. Feel free to open an issue or submit a pull request with improvements, fixes, or ideas for new commands and data types.
ByteCache is an educational, Redis-inspired implementation. It is not Redis, doesn't attempt to reproduce Redis's full internal architecture, and is not intended for production use.
Its purpose is to build first-hand understanding of networking, protocols, concurrency, transactions, in-memory storage, and backend systems — by implementing the mechanisms, not just consuming them.
License TBD before public release.
Built with ☕ Java and a genuine curiosity about what's underneath Redis.
