Step 09. Metrics, logging, STATS, TTL, and KEYS revisited¶
Time: ~7 hours Week: 5 (Nov 03 – Nov 09)
Read first: 04_Concurrency: 07_atomics_and_the_memory_model.md (memory_order_relaxed
and when it is enough), 10_cache_lines_false_sharing_performance.md,
04_shared_mutex_and_readers_writers.md (writer starvation, long readers).
02_Modern_CPP: 13_cpp17_features_tour.md (std::optional, structured bindings),
03_raii_the_core_idea.md (RAII for things that are not memory: a counter guard).
07_Databases_Internals: 13_caching_and_redis.md (TTL, lazy and active expiry, why KEYS is
banned in production Redis). 05_Memory_Debugging_RCA: 08_valgrind_and_sanitizers.md (UBSan).
06_Linux_Toolchain_DevOps: 07_debugging_and_profiling_tools_on_linux.md.
1. Goal¶
Make the server observable and finish the protocol.
Metrics: atomic counters for connections, commands, bytes, and a latency histogram of command execution time.STATSreturns them; the acceptor logs one throughput line every 10 seconds (--stats-interval).EXPIREandTTL: per-key time-to-live with lazy expiry on read plus a once-a-second purge.connections_activethat is correct on every exit path, via a small RAII guard.- Revisit the two pieces that already exist: the logger (step 05) and
KEYS prefix(steps 04 and 05), this time with their production costs. - The
-ERR not implemented yetbranch disappears. The protocol in00_spec.mdis now fully implemented.
In plain words: a server you cannot see into is a server you cannot run. At 3 AM the first
three questions are "is it up, how busy is it, how slow is it", and STATS plus one log line
every ten seconds answer all three without a debugger. TTL is the feature that turns a map
into a cache: "keep this for 60 seconds, then forget it".
2. Design¶
2.1 Metrics: relaxed atomics, no locks¶
worker threads (N) acceptor thread any client
++commands_total (relaxed) every 10 s: read STATS: render()
++bytes_in / bytes_out commands_total, diff, reads every counter
histogram.record(latency) log ops/s and p99 (relaxed loads)
connections_active +1 / -1 (guard)
- Every counter is independent. Nobody needs
commands_totalordered relative tobytes_out, and no other data is published through a counter. Somemory_order_relaxedis correct: eachfetch_addis still atomic (no lost increments), it just does not act as a fence. On x86-64, a relaxedfetch_addand a seq_cst one compile to the samelock xadd; the difference is what the compiler may reorder around it, and on ARM it is a real difference in barriers. - No mutex anywhere in
Metrics. That rules out a whole bug class: no lock-order inversion with the store lock, no contention betweenSTATSand workers. - A
STATSsnapshot is not atomic as a whole. Counters may move between two loads, soconnections_activeandcommands_totalin one reply are not from one instant. For monitoring that is fine; say so rather than pretending otherwise. - False sharing, honestly. All counters sit next to each other in one struct, so every
worker's
fetch_addpulls the same 64-byte cache line into its core in exclusive state. With 4 to 16 workers doing one network round trip per command, the syscalls dominate and this does not show up in a profile. If it ever did, the fixes arealignas(64)per hot counter, or per-thread counters summed on read (what the Linux kernel does with per-CPU counters). Measure first (see 04_Concurrency/10_cache_lines_false_sharing_performance.md).
2.2 The latency histogram¶
Sixteen buckets on a roughly logarithmic scale, upper bounds in microseconds:
record()finds the bucket with a linear scan of 15 constants (cheaper than it sounds: the array is 120 bytes, one or two cache lines) and does three relaxedfetch_adds.percentile_us(p)walks the cumulative counts to the bucket holding rankceil(p * count)and returns its upper bound. So "p99 = 100 us" means "99% of commands took at most 100 us". You cannot report finer than the bucket, and you should not pretend to.- Why not store every sample and sort? Memory grows without bound, and a sort under load is not free. HdrHistogram (used by many load tools) is the production-grade version of this idea with configurable precision; ours is the 60-line version.
- What is measured: server-side execution of the store operation only, from just before
execute_commandto just after. It excludes parsing, the socket write and network time. Small numbers here and large numbers inload_genmean the time is in the network, the kernel or the queue for a worker, not in the store. That split is the point of measuring both.
2.3 TTL: lazy expiry plus a periodic purge¶
struct Entry {
std::string value;
std::optional<Clock::time_point> expires_at; // nullopt = lives forever
};
steady_clock, notsystem_clock. A TTL is an interval. If NTP moves the wall clock back an hour,system_clockwould make every key live an hour longer.- Lazy expiry: every reader checks
is_expired(entry, now)and reports "not found". Readers hold only a shared lock, so they cannot erase; they just do not see the entry. - Active expiry: the acceptor calls
store.purge_expired()once a second. It takes the exclusive lock and erases every expired entry. Without it, keys that are never read again stay in memory forever. SETclears the TTL (like Redis).EXPIRE k 0or negative deletes at once.DELon an expired-but-unpurged key erases it and returns 0 (it was not live).TTLrounds up (std::chrono::ceil), so a key with 0.3 s left reports 1, not 0. 0 would read as "expires now" and is easy to confuse with "no TTL".- Clock read outside the lock.
Clock::now()is a vDSO call, cheap but not free; doing it before taking the lock keeps the critical section to the hash lookup.
The trade-off to be able to state: purge_expired() is O(total keys) under the exclusive
lock, once a second. With a million keys that is a pause of the order of milliseconds during
which no writer or reader runs (measure it on your machine before quoting a number). Two
better designs:
- Sampling (what Redis does): keep a separate set of keys that have a TTL; each cycle, test a small random sample and erase the expired ones; repeat if a large fraction was expired. Bounded work per cycle, probabilistic memory bound.
- An expiry index: a min-heap or
std::multimap<time_point, key>; purge pops from the front whilefront <= now. O(expired * log n) per cycle, at the cost of extra memory and of keeping the index in sync onSET/DEL/EXPIRE.
v1.0.0 uses the simple full scan because it is obviously correct and the key counts in the benchmarks are small. Say exactly that.
2.4 KEYS prefix, revisited¶
KEYS has been there since step 04 (store) and step 05 (command): collect matching keys
under a shared lock, release, sort. Two production problems:
- It is O(n) in the total number of keys, not in the number of matches. An
unordered_maphas no order, so a prefix query must look at every key. - It holds the shared lock for the whole scan. Other readers are unaffected, but every
writer waits for the full scan. And libstdc++'s
std::shared_mutexon Linux wrapspthread_rwlock_twith glibc's default reader-preferring policy (04_Concurrency/04_shared_mutex_and_readers_writers.md): a steady stream ofGETs andKEYSoverlapping each other can keep the reader count above zero indefinitely, and aSETwaits until there is a gap. Long readers plus reader preference is how writer starvation happens.
This is why Redis documentation warns against KEYS in production and provides SCAN. If
asked how you would fix it, in order of effort:
- Cap the reply (
KEYS prefix LIMIT n) and document it. Cheap, honest. SCAN cursor [MATCH prefix] [COUNT n]: return up to n keys and a cursor; the client loops. With anunordered_mapthe cursor can be a bucket index (iteratebucket(i)), which is stable across calls unless a rehash happens; Redis solves that with reverse-binary cursor iteration. Each call holds the lock for a bounded time.- A second, ordered index (
std::map<std::string, ...>or a trie) so a prefix query islower_bound(prefix)then walk while the prefix matches: O(log n + matches). Costs memory and a second structure to update on every write.
2.5 Logging, revisited¶
The logger arrived in step 05. What matters now that the server is concurrent and long-lived:
- One line, one
fwrite, under one mutex.write()formats the whole line first, then locks only for thefwrite+fflush. Lines from different threads never interleave, and the critical section does no formatting. - The level check happens before formatting.
log::debug(...)with the level atinfocosts one relaxed atomic load and a compare; the arguments are never streamed. That is why per-connection events are atdebug: a busy server would otherwise spend measurable time formatting and writing thousands of lines a second. localtime_r, notlocaltime.localtimereturns a pointer to a static buffer shared by all threads: a data race TSan would report.gettid()in the line: the kernel thread id, the same numbertop -H,ps -Land gdb'sinfo threadsshow. When a worker is stuck, the log tells you which LWP to attach to.- stderr, not a file. The process does not manage log files, rotation or disk-full. Under systemd, stderr goes to the journal; in a container, to the container runtime's log driver. This is the twelve-factor convention, and it is what you want in production.
- Not async-signal-safe. It allocates and locks. Never call it from a signal handler (step 08, item 6).
3. Files to create or change¶
include/tcpkv/metrics.hpp (final version)¶
Step 08 created the file with LatencyHistogram. Add #include <string> and the Metrics
struct below the histogram:
// All server counters live here. Plain atomics with relaxed ordering: each counter is
// independent and we never need one to be ordered relative to another.
struct Metrics {
std::atomic<std::uint64_t> connections_accepted{0};
std::atomic<std::uint64_t> connections_active{0};
std::atomic<std::uint64_t> connections_rejected{0}; // "server busy": queue was full
std::atomic<std::uint64_t> commands_total{0};
std::atomic<std::uint64_t> commands_failed{0}; // parse errors
std::atomic<std::uint64_t> bytes_in{0};
std::atomic<std::uint64_t> bytes_out{0};
LatencyHistogram command_latency; // execution time of the store operation, server side
std::chrono::steady_clock::time_point started_at = std::chrono::steady_clock::now();
// "name value\n" per line. This is what the STATS command returns.
std::string render() const;
};
Metrics is not copyable (atomics and the histogram are not), which is right: there is one
set of counters per server, passed by reference.
src/metrics.cpp (final version)¶
#include "tcpkv/metrics.hpp"
#include <algorithm>
#include <cmath>
#include <sstream>
namespace tcpkv {
double LatencyHistogram::Snapshot::mean_us() const noexcept {
return count == 0 ? 0.0 : static_cast<double>(total_us) / static_cast<double>(count);
}
std::uint64_t LatencyHistogram::Snapshot::percentile_us(double p) const noexcept {
if (count == 0) {
return 0;
}
const double clamped = std::min(std::max(p, 0.0), 1.0);
const auto target = std::max<std::uint64_t>(
1, static_cast<std::uint64_t>(std::ceil(clamped * static_cast<double>(count))));
std::uint64_t cumulative = 0;
for (std::size_t i = 0; i < kBucketCount; ++i) {
cumulative += buckets[i];
if (cumulative >= target) {
return i < kBoundsUs.size() ? kBoundsUs[i] : kBoundsUs.back();
}
}
return kBoundsUs.back();
}
void LatencyHistogram::Snapshot::merge(const Snapshot& other) noexcept {
count += other.count;
total_us += other.total_us;
for (std::size_t i = 0; i < kBucketCount; ++i) {
buckets[i] += other.buckets[i];
}
}
void LatencyHistogram::record(std::chrono::nanoseconds latency) noexcept {
const auto signed_us = std::chrono::duration_cast<std::chrono::microseconds>(latency).count();
const std::uint64_t us = signed_us < 0 ? 0 : static_cast<std::uint64_t>(signed_us);
std::size_t index = 0;
while (index < kBoundsUs.size() && us > kBoundsUs[index]) {
++index;
}
buckets_[index].fetch_add(1, std::memory_order_relaxed);
count_.fetch_add(1, std::memory_order_relaxed);
total_us_.fetch_add(us, std::memory_order_relaxed);
}
LatencyHistogram::Snapshot LatencyHistogram::snapshot() const noexcept {
// Not atomic as a whole: counters may move between loads. For monitoring that is fine.
Snapshot s;
s.count = count_.load(std::memory_order_relaxed);
s.total_us = total_us_.load(std::memory_order_relaxed);
for (std::size_t i = 0; i < kBucketCount; ++i) {
s.buckets[i] = buckets_[i].load(std::memory_order_relaxed);
}
return s;
}
std::string Metrics::render() const {
const auto uptime = std::chrono::steady_clock::now() - started_at;
const auto latency = command_latency.snapshot();
std::ostringstream os;
os << "uptime_seconds " << std::chrono::duration_cast<std::chrono::seconds>(uptime).count()
<< '\n';
os << "connections_accepted " << connections_accepted.load(std::memory_order_relaxed) << '\n';
os << "connections_active " << connections_active.load(std::memory_order_relaxed) << '\n';
os << "connections_rejected " << connections_rejected.load(std::memory_order_relaxed) << '\n';
os << "commands_total " << commands_total.load(std::memory_order_relaxed) << '\n';
os << "commands_failed " << commands_failed.load(std::memory_order_relaxed) << '\n';
os << "bytes_in " << bytes_in.load(std::memory_order_relaxed) << '\n';
os << "bytes_out " << bytes_out.load(std::memory_order_relaxed) << '\n';
os << "latency_count " << latency.count << '\n';
os << "latency_mean_us " << static_cast<std::uint64_t>(latency.mean_us()) << '\n';
os << "latency_p50_us " << latency.percentile_us(0.50) << '\n';
os << "latency_p99_us " << latency.percentile_us(0.99) << '\n';
os << "latency_p999_us " << latency.percentile_us(0.999) << '\n';
return os.str();
}
} // namespace tcpkv
Details worth a sentence each:
std::max<std::uint64_t>(1, ...)makespercentile_us(0.0)mean "the smallest sample" rather than rank 0, which does not exist.duration_cast<microseconds>truncates: a 49.9 us command lands in the<=50bucket. Negative durations (impossible withsteady_clock, but cheap to guard) clamp to 0.total_uscan in theory overflowuint64_t(max about 1.8 x 10^19). At one million commands per second averaging 100 us, it grows by 10^8 per second and wraps after about 5,800 years. Unsigned overflow is defined (it wraps), so even then the mean is wrong, not undefined. Do this arithmetic out loud if asked; it is the kind of check seniors make.render()usesostringstreambecauseSTATSis rare and readability wins. Format is Prometheus-like (name value) so a scraper could be written in ten lines.
include/tcpkv/store.hpp and src/store.cpp (final versions)¶
Identical to code/include/tcpkv/store.hpp and code/src/store.cpp. Compared with step 04:
the value type becomes Entry, three methods are added, and every reader checks expiry.
// ttl <= 0 deletes the key. Returns false if the key does not exist.
// Takes Clock::duration (not seconds) so tests can use short TTLs; std::chrono::seconds
// converts implicitly.
bool expire(const std::string& key, Clock::duration ttl);
// -2: key does not exist, -1: exists without expiry, otherwise seconds left (rounded up).
long long ttl(const std::string& key) const;
std::size_t size() const; // includes expired-but-not-yet-purged entries
// Erases expired entries; returns how many were removed.
std::size_t purge_expired();
private:
struct Entry {
std::string value;
std::optional<Clock::time_point> expires_at;
};
static bool is_expired(const Entry& entry, Clock::time_point now) noexcept {
return entry.expires_at.has_value() && *entry.expires_at <= now;
}
mutable std::shared_mutex mutex_;
std::unordered_map<std::string, Entry> map_;
The new and changed methods:
std::optional<std::string> Store::get(const std::string& key) const {
const auto now = Clock::now(); // read the clock outside the lock: it is not free
std::shared_lock<std::shared_mutex> lock(mutex_);
const auto it = map_.find(key);
if (it == map_.end() || is_expired(it->second, now)) {
return std::nullopt;
}
return it->second.value; // copy made under the lock; the caller owns it afterwards
}
void Store::set(std::string key, std::string value) {
std::unique_lock<std::shared_mutex> lock(mutex_);
Entry& entry = map_[std::move(key)];
entry.value = std::move(value);
entry.expires_at.reset(); // like Redis: SET makes the key persistent again
}
bool Store::del(const std::string& key) {
const auto now = Clock::now();
std::unique_lock<std::shared_mutex> lock(mutex_);
const auto it = map_.find(key);
if (it == map_.end()) {
return false;
}
const bool was_live = !is_expired(it->second, now);
map_.erase(it);
return was_live;
}
bool Store::expire(const std::string& key, Clock::duration ttl) {
const auto now = Clock::now();
std::unique_lock<std::shared_mutex> lock(mutex_);
const auto it = map_.find(key);
if (it == map_.end() || is_expired(it->second, now)) {
return false;
}
if (ttl.count() <= 0) {
map_.erase(it);
return true;
}
it->second.expires_at = now + ttl;
return true;
}
long long Store::ttl(const std::string& key) const {
const auto now = Clock::now();
std::shared_lock<std::shared_mutex> lock(mutex_);
const auto it = map_.find(key);
if (it == map_.end() || is_expired(it->second, now)) {
return -2;
}
if (!it->second.expires_at) {
return -1;
}
const auto remaining = *it->second.expires_at - now;
return std::chrono::ceil<std::chrono::seconds>(remaining).count();
}
std::size_t Store::purge_expired() {
const auto now = Clock::now();
std::unique_lock<std::shared_mutex> lock(mutex_);
std::size_t removed = 0;
for (auto it = map_.begin(); it != map_.end();) {
if (is_expired(it->second, now)) {
it = map_.erase(it); // erase() returns the next valid iterator
++removed;
} else {
++it;
}
}
return removed;
}
exists and keys gain the same is_expired check; in keys the structured binding
becomes [key, entry] and the step-04 (void)value; goes away. size() deliberately counts
expired-but-unpurged entries: it reports memory, not logical contents, and the tests rely on
that to observe the purge.
include/tcpkv/connection.hpp and src/connection.cpp (final versions)¶
Identical to the files in code/. execute_command and Connection take a Metrics&:
std::string execute_command(Command command, Store& store, Metrics& metrics);
Connection(Socket socket, Store& store, Metrics& metrics, const std::atomic<bool>& stop_flag,
ConnectionConfig config) noexcept;
...
Metrics* metrics_;
The three branches that replace -ERR not implemented yet:
case CommandType::Expire: {
const auto ttl = std::chrono::seconds(parse_seconds(command.args[1]));
return reply::integer(store.expire(command.args[0], ttl) ? 1 : 0);
}
case CommandType::Ttl:
return reply::integer(store.ttl(command.args[0]));
case CommandType::Stats:
return reply::bulk(metrics.render());
The active-connection guard, in the anonymous namespace:
// Increments connections_active for the lifetime of serve(), whatever path leaves it.
class ActiveConnectionGuard {
public:
explicit ActiveConnectionGuard(Metrics& metrics) noexcept : metrics_(metrics) {
metrics_.connections_active.fetch_add(1, std::memory_order_relaxed);
}
~ActiveConnectionGuard() { metrics_.connections_active.fetch_sub(1, std::memory_order_relaxed); }
ActiveConnectionGuard(const ActiveConnectionGuard&) = delete;
ActiveConnectionGuard& operator=(const ActiveConnectionGuard&) = delete;
private:
Metrics& metrics_;
};
serve() starts with const ActiveConnectionGuard active_guard(*metrics_);. serve() has
five ways out (EOF, QUIT, fatal parse error, idle timeout, stop flag) plus any exception.
A manual --active at the end would be wrong on at least one of them. This is RAII for a
counter instead of memory, and the ActiveConnectionsDropToZeroAfterClientsLeave test proves
it. In C you would write goto out; and hope every path reaches it.
serve() also adds bytes_in after each read and bytes_out after each write.
process_events counts parse errors and times each command:
Command& command = std::get<Command>(*event);
const bool quit = command.type == CommandType::Quit;
const auto start = Clock::now();
out += execute_command(std::move(command), *store_, *metrics_);
metrics_->command_latency.record(Clock::now() - start);
metrics_->commands_total.fetch_add(1, std::memory_order_relaxed);
Note quit is read before std::move(command). Reading a moved-from object's members is
legal but its value is unspecified for the moved parts; reading first removes the question.
include/tcpkv/server.hpp and src/server.cpp (final versions)¶
Identical to the files in code/. ServerConfig gains:
// Log a one-line throughput summary this often. 0 disables.
std::chrono::seconds stats_log_interval{10};
and the constructor becomes Server(ServerConfig config, Store& store, Metrics& metrics).
accept_one increments connections_accepted after a successful accept and
connections_rejected on the busy path, and passes metrics_ to the Connection. The run
loop gains periodic maintenance after the accept:
const auto now = Clock::now();
if (now - last_purge >= std::chrono::seconds(1)) {
const std::size_t purged = store_.purge_expired();
if (purged > 0) {
log::debug("purged ", purged, " expired keys");
}
last_purge = now;
}
if (config_.stats_log_interval.count() > 0 && now - last_stats >= config_.stats_log_interval) {
log_throughput(now, last_stats, last_commands);
}
The acceptor's poll has a 1000 ms timeout, so the loop runs at least once a second even
with no new connections; that is what makes it a usable timer without a timer thread. Under
a flood of connections it runs more often, and the >= checks keep the cadence right.
void Server::log_throughput(std::chrono::steady_clock::time_point now,
std::chrono::steady_clock::time_point& last_time,
std::uint64_t& last_commands) const {
const std::uint64_t commands = metrics_.commands_total.load(std::memory_order_relaxed);
const double seconds = std::chrono::duration<double>(now - last_time).count();
const std::uint64_t delta = commands - last_commands;
const auto rate = seconds > 0 ? static_cast<std::uint64_t>(static_cast<double>(delta) / seconds) : 0;
log::info("stats: ", delta, " commands in last ", static_cast<std::uint64_t>(seconds), " s (",
rate, " ops/s), active connections ",
metrics_.connections_active.load(std::memory_order_relaxed), ", p99 ",
metrics_.command_latency.snapshot().percentile_us(0.99), " us");
last_time = now;
last_commands = commands;
}
The rate is computed from deltas, so it is "ops/s over the last interval", not since start. The p99 is since start (the histogram is cumulative). Know that difference if asked; a windowed p99 would need a histogram per interval, swapped and reset by the acceptor.
The shutdown log line now reports active connections as well as queued ones.
apps/tcpkv_server.cpp (final version)¶
Identical to code/apps/tcpkv_server.cpp. Compared with step 06: a tcpkv::Metrics metrics;
next to the Store, Server server(options.server, store, metrics);, the
--stats-interval <s> option, and the final log line includes the command count:
Declaration order in main matters: store and metrics are declared before server, so
they are destroyed after it (reverse order), and every worker that touches them has been
joined by then. This is spec section 4.3 in three lines.
tools/valgrind_check.sh¶
Add --stats-interval 0 to the server's command line, as in code/. With Valgrind slowing
the server, the 10-second line would otherwise appear mid-run and clutter the CI log.
tests/store_test.cpp¶
Add the TTL tests from code/tests/store_test.cpp: ExpireOnMissingKeyReturnsFalse,
TtlSemantics, SetClearsTtl, ExpireWithZeroOrNegativeDeletesImmediately,
ExpiredKeyIsInvisibleUntilPurged, DelOnExpiredKeyReturnsFalseAndRemovesIt,
SetAfterExpiryRevivesKey, and the stress test StoreStress.SetDeleteAndExpireRace. The one
that pins down lazy expiry:
TEST(Store, ExpiredKeyIsInvisibleUntilPurged) {
Store store;
store.set("k", "v");
ASSERT_TRUE(store.expire("k", std::chrono::milliseconds(20)));
std::this_thread::sleep_for(std::chrono::milliseconds(60));
// Logically gone...
EXPECT_FALSE(store.get("k").has_value());
EXPECT_FALSE(store.exists("k"));
EXPECT_TRUE(store.keys().empty());
EXPECT_EQ(store.ttl("k"), -2);
EXPECT_FALSE(store.expire("k", std::chrono::seconds(5)));
// ...but physically still there until purge runs.
EXPECT_EQ(store.size(), 1u);
EXPECT_EQ(store.purge_expired(), 1u);
EXPECT_EQ(store.size(), 0u);
}
This is why expire takes Clock::duration and not seconds: a 20 ms TTL keeps the test
fast. The sleeps are three times the TTL so a slow CI runner does not make it flaky.
tests/integration_test.cpp (final version)¶
Identical to code/tests/integration_test.cpp. Changes from step 07:
#include "tcpkv/metrics.hpp", aMetrics metrics_;member in the fixture,config.stats_log_interval = std::chrono::seconds(0);andstd::make_unique<Server>(config, store_, metrics_). The twoServerShutdowntests get their ownMetricsthe same way.- Delete
NotYetImplementedCommandsReplyWithError. ManyConcurrentClientsSeeTheirOwnWritesalso assertsmetrics_.connections_accepted == kClients.- Three new tests:
TEST_F(ServerFixture, ExpireAndTtl) {
Client client = connect();
client.set("tmp", "v");
EXPECT_TRUE(client.expire("tmp", 1));
EXPECT_EQ(client.ttl("tmp"), 1);
EXPECT_EQ(client.ttl("missing"), -2);
client.set("forever", "v");
EXPECT_EQ(client.ttl("forever"), -1);
EXPECT_FALSE(client.expire("missing", 5));
std::this_thread::sleep_for(std::chrono::milliseconds(1200));
EXPECT_FALSE(client.get("tmp").has_value());
EXPECT_EQ(client.ttl("tmp"), -2);
}
TEST_F(ServerFixture, StatsReportsCounters) {
Client client = connect();
client.ping();
client.set("k", "v");
const std::string stats = client.stats();
EXPECT_NE(stats.find("commands_total"), std::string::npos);
EXPECT_NE(stats.find("connections_active 1\n"), std::string::npos);
EXPECT_GE(metrics_.commands_total.load(), 3u);
EXPECT_EQ(metrics_.connections_accepted.load(), 1u);
}
TEST_F(ServerFixture, ActiveConnectionsDropToZeroAfterClientsLeave) {
{
Client a = connect();
Client b = connect();
a.ping();
b.ping();
EXPECT_EQ(metrics_.connections_active.load(), 2u);
}
// Workers notice EOF on their next poll (<= 20 ms); give them a moment.
for (int i = 0; i < 100 && metrics_.connections_active.load() != 0; ++i) {
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
EXPECT_EQ(metrics_.connections_active.load(), 0u);
}
EXPECT_GE(commands_total, 3u), not EQ: STATS itself is counted after its reply is
rendered, so the counter may read 2 inside the reply and 3 afterwards. Asserting the exact
value would test a timing detail, not behaviour. The last test polls rather than sleeping a
fixed time: it passes in 20 ms normally and still passes on a slow TSan runner.
A real UBSan finding: EXPIRE with a huge number of seconds¶
The parser accepts any long long for seconds. execute_command builds
std::chrono::seconds(n), and Store::expire takes Clock::duration, which on Linux is
nanoseconds in a signed 64-bit integer. The implicit conversion multiplies by 10^9. Any n
above about 9.2 x 10^9 (roughly 292 years) overflows: signed overflow, undefined behaviour,
triggered by one line from any client. A minimal reproduction of the same conversion, built
with -fsanitize=undefined on GCC 13 in WSL, reports:
/usr/include/c++/13/bits/chrono.h:225:38: runtime error: signed integer overflow:
10000000000 * 1000000000 cannot be represented in type 'long long int'
#0 ... std::chrono::__duration_cast_impl<...>::__cast<long, std::ratio<1l, 1l> >(...)
#1 ... std::chrono::duration_cast<std::chrono::duration<long, std::ratio<1l, 1000000000l> >, ...>
#2 ... std::chrono::duration<long, std::ratio<1l, 1000000000l> >::duration<long, std::ratio<1l, 1l>, void>(...)
Reproduce it against the server: run ./build/ubsan/tcpkv_server --port 7400, then
./build/ubsan/tcpkv_cli -p 7400 set k v and ./build/ubsan/tcpkv_cli -p 7400 expire k 10000000000.
Negative values overflow the same way (expire k -10000000000), before the ttl <= 0
check can run, because the conversion happens at the call.
None of the tests send such a value, so every CI job is green with this bug present. That is
the lesson: sanitizers only judge the inputs you give them. If the parser in your copy of
code/ does not already reject these values, fix it in your repo:
// include/tcpkv/protocol.hpp, in namespace limits
// 100 years. Converted to steady_clock's nanoseconds this is ~3.2e18, below INT64_MAX (~9.2e18),
// and adding it to steady_clock::now() (time since boot) cannot overflow either.
inline constexpr long long kMaxExpireSeconds = 100LL * 365 * 24 * 3600;
// src/protocol.cpp, in the CommandType::Expire validation
long long seconds = 0;
if (!parse_signed(cmd.args[1], seconds) || seconds > limits::kMaxExpireSeconds ||
seconds < -limits::kMaxExpireSeconds) {
return fail("invalid expire seconds", false);
}
// tests/protocol_test.cpp
// Uses the next_error / next_command helpers already at the top of protocol_test.cpp.
TEST(Parser, ExpireSecondsOutOfRangeIsNonFatal) {
Parser parser;
parser.feed("EXPIRE k 10000000000\nEXPIRE k -10000000000\nEXPIRE k 3153600000\nPING\n");
for (int i = 0; i < 2; ++i) {
const auto error = next_error(parser);
ASSERT_TRUE(error.has_value());
EXPECT_EQ(error->message, "invalid expire seconds");
EXPECT_FALSE(error->fatal);
}
const auto at_limit = next_command(parser); // exactly 100 years: accepted
ASSERT_TRUE(at_limit.has_value());
EXPECT_EQ(at_limit->type, CommandType::Expire);
const auto ping = next_command(parser); // the stream is still in sync
ASSERT_TRUE(ping.has_value());
EXPECT_EQ(ping->type, CommandType::Ping);
}
Add a row for the limit to docs/PROTOCOL.md and the limits table in your README. Rejecting is better than
clamping: a client that sends 10^10 seconds has a bug, and an error tells it so.
Keep this story. "Our sanitizer jobs were green and the server still had UB on one input; here is how I found it and the test that pins it" is a better interview answer than "it is sanitizer-clean".
CMakeLists.txt¶
No new files (src/metrics.cpp was added in step 08). Bump nothing yet; the version becomes
1.0.0 in step 10.
4. Build and run (WSL Ubuntu 24.04)¶
cd ~/dev/tcpkv
cmake --build --preset debug && ctest --preset debug
cmake --build --preset asan && ctest --preset asan
setarch $(uname -m) -R cmake --build --preset tsan && setarch $(uname -m) -R ctest --preset tsan
cmake --build --preset ubsan && ctest --preset ubsan
cmake --build --preset relwithdebinfo && bash tools/valgrind_check.sh build/relwithdebinfo
./build/release/tcpkv_server --port 7379 --threads 8 --stats-interval 2
(Build release first if you have not: cmake --preset release && cmake --build --preset release.)
5. Test by hand¶
-
TTL from the CLI:
tcpkv> set session abc OK tcpkv> expire session 5 (integer) 1 tcpkv> ttl session (integer) 5 tcpkv> ttl nope (integer) -2Wait six seconds:
get sessionprints(nil),ttl sessionprints(integer) -2. -
SETclears TTL:expire k 100,set k again,ttl kprints(integer) -1. - Purge: start the server with
--log-level debug, runfor i in $(seq 1 1000); do echo "SET t$i 1"; echo x; echo "EXPIRE t$i 1"; done | nc -q1 127.0.0.1 7379 >/dev/null. Within two seconds the log showspurged 1000 expired keys(possibly split across two purge cycles). - Throughput log: with
--stats-interval 2, run./build/release/load_gen --port 7379 --clients 8 --duration 10. Every two seconds:stats: <n> commands in last 2 s (<rate> ops/s), active connections 8, p99 <x> us. (connections_activecounts connections being served, insideserve(). Start the server with fewer threads than clients and it reports the thread count, not the client count: the rest are queued, not active.) Afterload_genends:active connections 0. ./build/release/tcpkv_cli statsprints the counters as one quoted bulk string with\nescapes.printf 'STATS\n' | nc -q1 127.0.0.1 7379shows the raw lines. Run it before and after aload_gen --clients 8run:connections_acceptedgrows by the 8 load clients plus one for each of your ownSTATSconnections.- Busy rejection is counted: restart with
--threads 1 --max-pending 1. Open threencsessions in three terminals. The first is served, the second waits in the queue, the third gets-ERR server busy, try again laterand is closed. TypeQUITin the first; the second is now served; typeSTATSin it:connections_rejected 1. (Note that--max-pending 0would reject every connection, because the check ispending >= max_pending; that is a sharp edge worth a sentence in--help.) - The UBSan finding above: reproduce it, fix it, see the new parser test pass and the
expire k 10000000000command answer(error) invalid expire seconds.
6. Commit¶
Two commits, because they are two changes and a reviewer should be able to read each:
git add -A
git commit -m "Add Metrics, STATS, TTL and periodic maintenance
- Metrics: relaxed atomic counters (connections, commands, bytes) and a lock-free latency
histogram of command execution time; STATS renders them as 'name value' lines
- Store: per-key expiry on steady_clock; lazy expiry on read, purge_expired() once a second
from the acceptor; SET clears TTL; EXPIRE <= 0 deletes; TTL rounds up
- Connection: ActiveConnectionGuard keeps connections_active right on every exit path;
bytes in/out and per-command latency recorded
- Server: accepted/rejected counters; throughput log line every --stats-interval seconds
- tests: TTL semantics and expire/purge race; integration tests for EXPIRE/TTL, STATS and
active-connection accounting"
git add -A
git commit -m "Reject EXPIRE seconds beyond 100 years
std::chrono::seconds -> steady_clock::duration multiplies by 1e9 in int64; values above
~9.2e9 overflowed (UBSan: signed integer overflow in chrono.h). Bound the argument in the
parser so it is a non-fatal 'invalid expire seconds' error; add a parser test."
git push
7. Optional extensions (design notes only; not in v1.0.0)¶
Do none of these before v1.0.0 is tagged. If you do one after, it is a v1.1.0 with its own tests and README section. Each is a good "what would you do next" answer even if never built.
SCAN cursor [MATCH prefix] [COUNT n](section 2.4). Cursor = bucket index; hold the shared lock per call, not per scan; document that keys inserted during a scan may or may not be returned and that a rehash can cause duplicates (Redis's reverse-binary cursor avoids misses across a rehash; read about it before claiming you would implement it).maxmemorywith LRU eviction. Accountkey.size() + value.size() + overheadper entry (overhead: node, hash,std::stringheaders; estimate it as a constant and say so). Exact LRU needs an intrusive list updated on everyGET, which turns reads into writes and kills the shared lock's benefit. Redis uses approximated LRU by sampling; that is the version compatible with a reader lock.- Sharded store. N
Storeshards,shard = hash(key) % N, each with its ownshared_mutex. Writers on different shards stop contending. Costs:KEYSand purge must visit every shard (and cannot give a consistent cross-shard snapshot); multi-keyDELtakes several locks (lock in shard order to avoid deadlock). Do it only with a benchmark showing writer contention first (perf,off-cputime infutex). - Prometheus endpoint. A second listener that answers
GET /metricswith therender()text plus# TYPElines. Ten lines of HTTP, and the server plugs into every monitoring stack. - Windowed percentiles. Two histograms; the acceptor swaps them every interval and logs the old one's p99, so the log line describes the last 10 seconds, not all time.
8. What you learned, and what to say in an interview¶
- Relaxed atomics are correct for independent counters, and you can say why (no data is
published through them) and what they cost (a
lock-prefixed instruction on x86, plus cache-line traffic if they share a line). - Histograms, not averages. Means hide the tail; p99 is what users feel. Bucketed percentiles are upper bounds; say so.
- Lazy plus active expiry is the standard TTL design; a full-scan purge is the simple version; sampling or an expiry index is the scalable one.
KEYSis O(n) under a lock and stalls writers;SCANor an ordered index is the fix.- RAII for counters: the active-connection guard is correct on every exit path by construction.
- Green sanitizers prove nothing about inputs you never sent. The
EXPIREoverflow passed every CI job until one test sent a big number.
Interview sentence: "Observability is a Metrics struct of relaxed atomics, no locks, and a
16-bucket log-scale latency histogram of command execution time; STATS renders it and the
acceptor logs ops/s and p99 every ten seconds. TTL is lazy expiry on read under the shared
lock plus a once-a-second purge under the exclusive lock; I know the purge is O(n) and would
move to Redis-style sampling or an expiry heap at scale. And one honest story: UBSan in CI
was green, but EXPIRE with ten billion seconds overflowed the chrono conversion to
nanoseconds; I found it by feeding the UBSan build hostile input by hand, bounded it in the
parser, and pinned it with a test."