10 · Capstone Project¶
This is the last module of the path, and it is a real service rather than an exercise: kvcache, a networked, thread-safe, LRU key-value cache with TTL expiry, a text wire protocol, a unit test suite, a CMake package, and a CI matrix. A miniature memcached.
Every level shows up. Classes and RAII from Level 1; STL containers, smart
ownership and CMake from Level 2; concurrency, sockets and Google Test from
Level 3; shared_mutex, std::optional, sanitizers, CTest and GitHub Actions
from Level 4. The architectural point of the design is one idea worth carrying
into your own work: separate the logic from the I/O, so the interesting
parts can be tested without a network.
What you'll build¶
$ kvcached 9099 1024 60 # port, capacity, TTL seconds
SET user:1 Ada Lovelace -> OK
GET user:1 -> VALUE Ada Lovelace
GET nobody -> NOT_FOUND
DEL user:1 -> DELETED
STATS -> STATS hits=1 misses=1 evictions=0 expirations=0 size=0
- O(1)
get/put/erasevia a hash map into an intrusive LRU list - Per-entry TTL; expired entries are evicted lazily on access
- Capacity-bounded with least-recently-used eviction
- Thread-safe with
std::shared_mutex - Line-oriented protocol over TCP, hardened against malformed input
- Hit/miss/eviction/expiration counters exposed via
STATS
Project layout¶
kvcache/
CMakeLists.txt
include/kvcache/
lru_cache.h # the cache -- header-only, no I/O
protocol.h # command types + parser declaration
server.h # command application + TCP server
src/
protocol.cpp
server.cpp
main.cpp
tests/
test_lru_cache.cpp
test_protocol.cpp
.github/workflows/ci.yml
include/kvcache/lru_cache.h¶
The data structure is the classic pairing: a std::list holding entries in
recency order, plus an unordered_map from key to iterator into that list.
std::list iterators are never invalidated by insertion or by erasing other
elements, which is exactly the property that makes this work — and one of the
few places where std::list is genuinely the right container.
#pragma once
#include <chrono>
#include <list>
#include <optional>
#include <shared_mutex>
#include <string>
#include <unordered_map>
namespace kvcache {
struct Stats {
std::size_t hits = 0, misses = 0, evictions = 0, expirations = 0, size = 0;
};
class LruCache {
public:
using Clock = std::chrono::steady_clock;
explicit LruCache(std::size_t capacity,
Clock::duration ttl = std::chrono::seconds(60))
: capacity_(capacity == 0 ? 1 : capacity), ttl_(ttl) {}
void put(const std::string& key, std::string value) {
std::unique_lock lock(mutex_);
auto it = index_.find(key);
if (it != index_.end()) { // update in place
it->second->value = std::move(value);
it->second->expiresAt = Clock::now() + ttl_;
order_.splice(order_.begin(), order_, it->second); // O(1) move to front
return;
}
order_.push_front(Entry{key, std::move(value), Clock::now() + ttl_});
index_[key] = order_.begin();
if (index_.size() > capacity_) evictOldest();
}
std::optional<std::string> get(const std::string& key) {
std::unique_lock lock(mutex_); // NOT shared: get() mutates order
auto it = index_.find(key);
if (it == index_.end()) { ++stats_.misses; return std::nullopt; }
if (Clock::now() >= it->second->expiresAt) { // lazy expiry
order_.erase(it->second);
index_.erase(it);
++stats_.expirations; ++stats_.misses;
return std::nullopt;
}
order_.splice(order_.begin(), order_, it->second);
++stats_.hits;
return it->second->value;
}
bool erase(const std::string& key) {
std::unique_lock lock(mutex_);
auto it = index_.find(key);
if (it == index_.end()) return false;
order_.erase(it->second);
index_.erase(it);
return true;
}
Stats stats() const {
std::shared_lock lock(mutex_); // genuinely read-only
Stats s = stats_;
s.size = index_.size();
return s;
}
std::size_t size() const {
std::shared_lock lock(mutex_);
return index_.size();
}
private:
struct Entry {
std::string key; // needed to erase from index_
std::string value;
Clock::time_point expiresAt;
};
void evictOldest() { // caller holds the lock
const Entry& oldest = order_.back();
index_.erase(oldest.key);
order_.pop_back();
++stats_.evictions;
}
mutable std::shared_mutex mutex_;
std::size_t capacity_;
Clock::duration ttl_;
std::list<Entry> order_;
std::unordered_map<std::string, std::list<Entry>::iterator> index_;
Stats stats_;
};
} // namespace kvcache
Three decisions worth defending:
get() takes a unique_lock, not a shared_lock. It looks like a read,
but it splices the entry to the front and bumps a counter — it writes. Using
shared_lock here would be a data race that passes every functional test and
fails under TSan. Only stats() and size() are true readers.
Entry stores its own key. It looks redundant against the map key, but
evictOldest() needs to erase from index_ and only has the list node.
Expiry is lazy. No background thread scans for expired entries; they are detected on access. This keeps the design simple and means an untouched expired entry occupies memory until it is either accessed or evicted by capacity pressure — a real trade-off you should be able to state out loud.
include/kvcache/protocol.h and src/protocol.cpp¶
The parser is pure: std::string_view in, std::optional<Command> out. No
sockets, no cache, no globals — so it is trivially unit-testable and trivially
fuzzable.
namespace kvcache {
enum class Verb { Get, Set, Del, Stats };
struct Command {
Verb verb;
std::string key;
std::string value;
};
// GET <key> | SET <key> <value...> | DEL <key> | STATS
std::optional<Command> parseCommand(std::string_view line);
}
std::optional<Command> parseCommand(std::string_view line) {
line = trim(line); // strips spaces, \r and \n
if (line.empty()) return std::nullopt;
const std::size_t sp1 = line.find(' ');
const std::string verb = upper(line.substr(0, sp1));
if (verb == "STATS") {
if (sp1 != std::string_view::npos) return std::nullopt; // no arguments allowed
return Command{Verb::Stats, "", ""};
}
if (sp1 == std::string_view::npos) return std::nullopt; // every other verb needs a key
std::string_view rest = trim(line.substr(sp1 + 1));
if (rest.empty()) return std::nullopt;
const std::size_t sp2 = rest.find(' ');
std::string key(rest.substr(0, sp2));
if (key.empty() || key.size() > 250) return std::nullopt; // bound it
if (verb == "GET") return sp2 == std::string_view::npos
? std::optional{Command{Verb::Get, std::move(key), ""}} : std::nullopt;
if (verb == "DEL") return sp2 == std::string_view::npos
? std::optional{Command{Verb::Del, std::move(key), ""}} : std::nullopt;
if (verb == "SET") {
if (sp2 == std::string_view::npos) return std::nullopt;
std::string value(trim(rest.substr(sp2 + 1)));
if (value.empty()) return std::nullopt;
return Command{Verb::Set, std::move(key), std::move(value)};
}
return std::nullopt; // unknown verb
}
Every failure path returns std::nullopt rather than throwing or returning a
partially-filled struct. That's the security
discipline: validate completely at the boundary, and hand the rest of the
program a type that can only hold a valid command. The 250-byte key limit is
there because "the client controls the length" is how memory exhaustion starts.
include/kvcache/server.h — logic first, sockets second¶
// Pure: applies one parsed command. No I/O -- this is what tests call.
std::string applyCommand(LruCache& cache, const Command& cmd);
// Parse + apply, including the malformed case.
std::string handleLine(LruCache& cache, std::string_view line);
class Server {
public:
Server(LruCache& cache, unsigned short port, unsigned workers);
~Server(); // calls stop()
Server(const Server&) = delete; // owns a socket: not copyable
Server& operator=(const Server&) = delete;
void run(); // blocking accept loop
void stop();
unsigned short port() const { return port_; }
private:
void handleClient(int clientFd);
LruCache& cache_;
unsigned short port_;
unsigned workers_;
int listenFd_ = -1;
std::atomic<bool> running_{false};
std::vector<std::thread> threads_;
};
handleLine is the seam. Everything a user would call the service to do is
reachable through a function taking a string_view and returning a string, so
the entire request/response behaviour is testable with no socket, no port
allocation, and no timing.
src/server.cpp — the sockets¶
std::string applyCommand(LruCache& cache, const Command& cmd) {
switch (cmd.verb) {
case Verb::Set:
cache.put(cmd.key, cmd.value);
return "OK\r\n";
case Verb::Get: {
auto v = cache.get(cmd.key);
return v ? "VALUE " + *v + "\r\n" : "NOT_FOUND\r\n";
}
case Verb::Del:
return cache.erase(cmd.key) ? "DELETED\r\n" : "NOT_FOUND\r\n";
case Verb::Stats: {
Stats s = cache.stats();
std::ostringstream os;
os << "STATS hits=" << s.hits << " misses=" << s.misses
<< " evictions=" << s.evictions << " expirations=" << s.expirations
<< " size=" << s.size << "\r\n";
return os.str();
}
}
return "ERROR unknown verb\r\n";
}
std::string handleLine(LruCache& cache, std::string_view line) {
auto cmd = parseCommand(line);
if (!cmd) return "ERROR bad command\r\n";
return applyCommand(cache, *cmd);
}
void Server::run() {
listenFd_ = ::socket(AF_INET, SOCK_STREAM, 0);
if (listenFd_ < 0) throw std::runtime_error("socket() failed");
int yes = 1;
::setsockopt(listenFd_, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof yes);
// ^ without this, restarting the server fails with EADDRINUSE for ~60s
// while the old socket sits in TIME_WAIT
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); // loopback only -- not 0.0.0.0
addr.sin_port = htons(port_);
if (::bind(listenFd_, reinterpret_cast<sockaddr*>(&addr), sizeof addr) < 0)
throw std::runtime_error("bind() failed on port " + std::to_string(port_));
if (::listen(listenFd_, 64) < 0)
throw std::runtime_error("listen() failed");
socklen_t len = sizeof addr;
::getsockname(listenFd_, reinterpret_cast<sockaddr*>(&addr), &len);
port_ = ntohs(addr.sin_port); // resolves port 0 -> the OS-assigned port
running_ = true;
while (running_) {
int client = ::accept(listenFd_, nullptr, nullptr);
if (client < 0) break;
threads_.emplace_back(&Server::handleClient, this, client);
if (threads_.size() >= workers_) {
for (auto& t : threads_) if (t.joinable()) t.join();
threads_.clear();
}
}
}
void Server::handleClient(int clientFd) {
std::string buffer;
char chunk[4096];
ssize_t n;
while ((n = ::recv(clientFd, chunk, sizeof chunk, 0)) > 0) {
buffer.append(chunk, static_cast<std::size_t>(n));
std::size_t pos;
while ((pos = buffer.find('\n')) != std::string::npos) {
std::string line = buffer.substr(0, pos);
buffer.erase(0, pos + 1);
std::string reply = handleLine(cache_, line);
::send(clientFd, reply.data(), reply.size(), 0);
}
if (buffer.size() > 64 * 1024) break; // refuse an unbounded line
}
::close(clientFd);
}
The buffering loop is the part people get wrong. TCP is a byte stream, not a
message stream: one recv may return half a command, or three commands, or
two and a half. The only correct approach is to append to a buffer and consume
complete framed units — here, everything up to a \n. The 64 KB cap is what
stops a client that never sends a newline from growing your memory without
bound.
Binding to INADDR_LOOPBACK rather than INADDR_ANY is deliberate: a cache
with no authentication must not be reachable from the network. This is the
actual cause of a long history of exposed-memcached incidents.
Building it¶
cmake_minimum_required(VERSION 3.20)
project(kvcache VERSION 1.0.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
find_package(Threads REQUIRED)
add_library(kvcache_lib src/protocol.cpp src/server.cpp)
add_library(kvcache::lib ALIAS kvcache_lib)
target_include_directories(kvcache_lib PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
$<INSTALL_INTERFACE:include>)
target_link_libraries(kvcache_lib PUBLIC Threads::Threads)
target_compile_options(kvcache_lib PRIVATE -Wall -Wextra -Wpedantic)
add_executable(kvcached src/main.cpp)
target_link_libraries(kvcached PRIVATE kvcache::lib)
option(KVCACHE_BUILD_TESTS "Build tests" ON)
if(KVCACHE_BUILD_TESTS)
enable_testing()
find_package(GTest REQUIRED)
add_executable(kvcache_tests tests/test_lru_cache.cpp tests/test_protocol.cpp)
target_link_libraries(kvcache_tests PRIVATE kvcache::lib GTest::gtest GTest::gtest_main)
include(GoogleTest)
gtest_discover_tests(kvcache_tests PROPERTIES LABELS "unit" TIMEOUT 30)
endif()
[ 12%] Building CXX object CMakeFiles/kvcache_lib.dir/src/protocol.cpp.o
[ 25%] Building CXX object CMakeFiles/kvcache_lib.dir/src/server.cpp.o
[ 37%] Linking CXX static library libkvcache_lib.a
[ 50%] Building CXX object CMakeFiles/kvcached.dir/src/main.cpp.o
[ 62%] Linking CXX executable kvcached
[ 75%] Building CXX object CMakeFiles/kvcache_tests.dir/tests/test_lru_cache.cpp.o
[ 87%] Building CXX object CMakeFiles/kvcache_tests.dir/tests/test_protocol.cpp.o
[100%] Linking CXX executable kvcache_tests
The test suite¶
TEST(LruCacheTest, EvictsLeastRecentlyUsed) {
LruCache cache(3);
cache.put("a", "1"); cache.put("b", "2"); cache.put("c", "3");
(void)cache.get("a"); // "a" is now most recent; "b" is oldest
cache.put("d", "4"); // over capacity -> evict "b"
EXPECT_TRUE(cache.get("a").has_value());
EXPECT_FALSE(cache.get("b").has_value()); // the eviction victim
EXPECT_EQ(cache.stats().evictions, 1u);
EXPECT_EQ(cache.size(), 3u);
}
TEST(LruCacheTest, ExpiredEntryIsAMiss) {
LruCache cache(4, std::chrono::milliseconds(20));
cache.put("a", "1");
std::this_thread::sleep_for(std::chrono::milliseconds(40));
EXPECT_FALSE(cache.get("a").has_value());
EXPECT_EQ(cache.stats().expirations, 1u);
EXPECT_EQ(cache.size(), 0u);
}
TEST(LruCacheTest, ConcurrentAccessIsSafe) {
LruCache cache(256);
std::vector<std::thread> ts;
for (int t = 0; t < 8; ++t)
ts.emplace_back([&, t] {
for (int i = 0; i < 5000; ++i) {
std::string k = "k" + std::to_string((t * 7 + i) % 300);
cache.put(k, std::to_string(i));
(void)cache.get(k);
}
});
for (auto& t : ts) t.join();
EXPECT_LE(cache.size(), 256u); // capacity invariant held under load
EXPECT_GT(cache.stats().hits, 0u);
}
Malformed input gets a parameterized sweep, so each bad string is its own reported case:
class MalformedTest : public ::testing::TestWithParam<const char*> {};
TEST_P(MalformedTest, RejectsGarbage) {
EXPECT_FALSE(parseCommand(GetParam()).has_value());
}
INSTANTIATE_TEST_SUITE_P(BadInputs, MalformedTest, ::testing::Values(
"", " ", "GET", "GET a b", "SET", "SET onlykey", "DEL", "STATS extra",
"BOGUS x", "\r\n"));
TEST(HandleLineTest, RoundTripsThroughCache) {
LruCache cache(8);
EXPECT_EQ(handleLine(cache, "SET name ada"), "OK\r\n");
EXPECT_EQ(handleLine(cache, "GET name"), "VALUE ada\r\n");
EXPECT_EQ(handleLine(cache, "GET missing"), "NOT_FOUND\r\n");
EXPECT_EQ(handleLine(cache, "DEL name"), "DELETED\r\n");
EXPECT_EQ(handleLine(cache, "nonsense"), "ERROR bad command\r\n");
}
...
20/21 Test #7: LruCacheTest.ConcurrentAccessIsSafe .................... Passed 0.03 sec
21/21 Test #5: LruCacheTest.ExpiredEntryIsAMiss ....................... Passed 0.05 sec
100% tests passed out of 21
Label Time Summary:
unit = 0.19 sec*proc (21 tests)
Total Test time (real) = 0.05 sec
Twenty-one cases in 50 ms, and the entire request/response surface is covered without opening a socket. That is the dividend of the logic/I/O split.
Running it¶
Start the daemon with a deliberately tiny capacity of 3 so eviction is visible:
Then drive it with nc:
printf 'SET user:1 Ada Lovelace\nGET user:1\nGET nobody\nSET user:2 Grace Hopper\nSET user:3 Alan Turing\nSET user:4 Ken Thompson\nGET user:1\nDEL user:3\nDEL user:3\nSTATS\nbogus command here\n' | nc 127.0.0.1 9099
OK
VALUE Ada Lovelace
NOT_FOUND
OK
OK
OK
NOT_FOUND
DELETED
NOT_FOUND
STATS hits=1 misses=2 evictions=1 expirations=0 size=2
ERROR bad command
Trace the seventh line, because it is the whole design working. GET user:1
succeeded at the top and fails here. Capacity is 3; after SET user:2,
user:3, user:4, the cache held {4, 3, 2} and user:1 — least recently
used despite its earlier read — was evicted. STATS confirms it:
evictions=1, and size=2 because DEL user:3 then removed another. Eleven
commands arrived in a single TCP segment and were framed correctly into eleven
responses.
Continuous integration¶
# .github/workflows/ci.yml
name: ci
on: [push, pull_request]
jobs:
test:
strategy:
fail-fast: false
matrix:
include:
- { os: ubuntu-latest, name: asan, flags: "-fsanitize=address,undefined -g -O1" }
- { os: ubuntu-latest, name: tsan, flags: "-fsanitize=thread -g -O1" }
- { os: ubuntu-latest, name: release, flags: "-O2" }
- { os: macos-latest, name: release, flags: "-O2" }
runs-on: ${{ matrix.os }}
name: ${{ matrix.os }} / ${{ matrix.name }}
steps:
- uses: actions/checkout@v4
- name: Install GoogleTest
run: |
if [ "$RUNNER_OS" = "Linux" ]; then sudo apt-get update && sudo apt-get install -y libgtest-dev cmake
else brew install googletest; fi
- run: cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo -DCMAKE_CXX_FLAGS="${{ matrix.flags }}"
- run: cmake --build build --parallel
- run: ctest --test-dir build -j4 --output-on-failure
- name: Flake check
run: ctest --test-dir build -L unit --repeat until-fail:20
The TSan job is not decoration. ConcurrentAccessIsSafe passing once proves
very little; passing twenty times under ThreadSanitizer is what justifies the
claim in the class's name. If you change get() to take a shared_lock —
which looks correct and passes every functional test — this is the job that
turns red.
How It Actually Works¶
kvcache's LRU eviction is a textbook case of choosing a data structure
for its mechanism, not just its Big-O: an LRU cache needs O(1) lookup by
key and O(1) "move this entry to most-recently-used" on every access, which
a single container can't give you alone. Pairing a std::list (Level 2
Module 3's doubly-linked list — O(1) insert/erase/splice at any position
given an iterator, with no shifting) with a std::unordered_map<Key,
list::iterator> gets both: the hash map gives O(1) lookup straight to the
node's position in the list, and list::splice/erase+push_front moves
that node to the front in O(1) without invalidating any other node's
iterator — a guarantee std::vector couldn't offer, since reallocation
or shifting there would invalidate exactly the iterators stored in the map.
Eviction is then just popping the list's tail node and erasing its key from
the map — both O(1).
The server's socket loop is where Level 3's networking and concurrency
modules combine directly: each accepted client connection is handed to a
worker (thread-per-connection or dispatched into the thread pool from
Level 3's capstone), and every access to the shared LRU cache from multiple
client-handling threads must go through the same mutex-guarded critical
section discussed in Module 2 — the cache's internal list/unordered_map
have no thread-safety of their own, so a lock around every public cache
operation is what prevents two clients' concurrent SET calls from
corrupting the linked list's internal pointers mid-splice.
TTL expiry piggybacks on the same eviction mechanism rather than needing a separate background sweep: each entry stores an expiration timestamp, and a lookup checks it before returning a hit, lazily evicting an expired entry the moment something tries to read it — trading a small per-read timestamp comparison for avoiding a second thread or timer mechanism entirely.
Stretch goals¶
- Replace thread-per-connection with the thread pool from
Level 3 Module 10, then go further
and make the accept loop event-driven with
epoll/kqueue. Measure connections-per-second before and after with a load generator; thread-per- connection collapses somewhere around a few thousand concurrent clients and you should be able to show exactly where. - Shard the cache. One
shared_mutexis a single contention point. Split into 16 independentLruCacheinstances indexed bystd::hash<std::string>{}(key) % 16and measure throughput at 1, 4, 8 and 16 threads — this is the lock-sharding technique from Module 9 applied to real code. - Fuzz the parser. Write an
LLVMFuzzerTestOneInputaroundparseCommand, build with-fsanitize=fuzzer,address, and run it for ten minutes. Commit every crashing input as a regression test. Then fuzzhandleLineso the cache itself is in the loop. - Add persistence. Append every mutating command to a log file, and replay
it at startup. Then add a periodic compaction that rewrites the log from the
current cache contents. Use
std::filesystemand binary-mode streams so it works on Windows too (Module 7). - Add
INCR,TTL <key>, andKEYS <prefix>. Each one forces a design decision:INCRneeds atomic read-modify-write inside the lock,TTLneeds remaining-lifetime arithmetic, andKEYSneeds you to decide what a scan does to LRU order (it should not touch it — write the test that proves it doesn't). - Package and install it. Add
install()rules with an export set, then build a separate consumer project that doesfind_package(kvcache REQUIRED)and linkskvcache::lib(Module 6). - Benchmark it properly. Add a Google Benchmark target measuring
put/getthroughput at various hit ratios and capacities, withDoNotOptimize. Record baselines and add a CI job that fails on a regression beyond 10%. - Harden it. Add a maximum value size, a per-connection command rate limit,
and an optional
AUTH <token>handshake using the constant-time comparison from Module 5. Then justify, in a paragraph, why binding to loopback is still the most important security control in the whole program.
You've finished the path¶
Four levels, forty modules, from std::cout << "Hello" to a concurrent network
service with a sanitizer matrix. The C++ that matters from here is not another
language feature — it is judgement: knowing which of these tools a problem
actually calls for, and being willing to measure instead of assume.
Build something with it.