← All selected work

Project 01 / Concurrent systems

Meridian.

An in-memory cache server that makes its data structures, locking, and network behavior explicit. Built from scratch in C++20.

C++20 kqueue / epoll CMake Concurrency
Explore the repository

The problem

A live-service game needs fast access to session state, player ratings, and leaderboards. Meridian is my from-scratch exploration of that backend: a C++20 in-memory cache server with a text protocol, concurrent cache access, and explicit control over the networking layer.

The interesting part is the interaction between small decisions. A cache eviction policy changes the locking requirements. A sharding strategy changes which keys move. A write-ahead log becomes the basis for replication.

Follow a request

TCP client newline-delimited
commands
Event loop kqueue / epoll
non-blocking I/O
Command handler parse + dispatch
cache / leaderboard
Shard router consistent hash ring
striped LRU caches

Non-blocking sockets are driven by kqueue on macOS and epoll on Linux. The server handles commands split across packets, pipelined commands, backpressure, and TCP half-close. The event-loop interface keeps readiness handling separate from the protocol.

The cache path routes a key through a consistent hash ring with 128 virtual nodes per shard. Within the selected shard, the key chooses a cache stripe. Leaderboard commands take a separate path to a skip list owned by the event-loop thread.

Why an LRU read needs a lock

The LRU cache combines a hash map with an intrusive doubly linked list. The map finds an entry; the list tracks recency. A successful get moves an entry toward the most-recently-used end. A read changes the data structure.

That makes reader-writer locking less useful here: cache hits still mutate shared state. Meridian partitions the keyspace into independent LRU caches, each protected by its own mutex.

FIG. 02 / Divide the contention Meridian
GET player:99
SET session:42
GET player:07
stripe 00
stripe 01
stripe 02
stripe 03
hash(key) → independent mutex + LRU
A smaller lock for each part of the keyspace. Requests to different stripes can proceed independently. Four stripes shown schematically.

A hash detail that matters

Stripe selection remixes the key hash before taking the remainder. The inner unordered_map also buckets by hash; reusing the same modular pattern could concentrate a stripe’s keys into too few map buckets.

// src/cache/striped_cache.cpp
return mix64(std::hash<std::string>{}(key))
       % stripes_.size();

TTL expiration is lazy: entries are checked on access. The clock is injectable, allowing expiry tests to advance time deterministically instead of sleeping.

Measure the cache, specifically

Cache throughput ops / sec
1 stripe
838K
16 stripes
5.3M
64 stripes
10.3M

Repository benchmark · Apple M3 · 8 threads · 4M operations.
In-process hot-key workload; not network throughput.

The repository reports 838K → 10.3M operations per second when moving from one global lock to 64 stripes. All three runs use an Apple M3, eight threads, four million operations, and an in-process hot-key workload. The reported hit rate is 92.39% for each configuration.

These are cache-operation measurements. They do not establish TCP throughput, HTTP throughput, or production latency. Hardware, key distribution, stripe count, and workload all affect the result.

To reproduce the workload after a Release build, vary N:

./build-release/load_gen --threads 8 --stripes N

Other decisions worth opening

Consistent hashing makes movement predictable

When a shard leaves the ring, keys that belonged to surviving shards keep their assignments. The hash-ring tests assert that invariant directly. The wire command SHARD <key> exposes the routing decision.

Rank queries need more than a sorted list

The leaderboard uses a hand-built skip list with span-annotated links. Spans make rank queries possible without walking every lower-ranked entry. The repository describes a randomized comparison against a brute-force reference model.

One mutation log serves recovery and replication

Successful mutations are appended as readable protocol lines. Recovery replays them through the same command handler. A follower requests the backlog, then receives the live mutation stream and rejects client writes. A torn final log line is detected and truncated on reopen.

This is leader/follower replication without consensus or automatic failover. HTTP/JSON APIs, durable TTLs, and snapshots are still listed as planned work in the repository’s v1 roadmap.

Inspect the implementation

Next project

SageRec