swecrets

Known Failure Modes and Their Signatures

Internals & Failure — Rung 532 min read

Learning Objectives

  • Triage an unfamiliar ClickHouse failure using system.errors' error code and count as the first signal, narrowing which system table to check next before committing to a specific hypothesis
  • Diagnose a stuck read-only replica by combining system.replicas (is_readonly, is_session_expired, readonly_start_time) and system.replication_queue (last_exception, num_tries) into one narrative, distinguishing a self-healing Keeper session blip from a genuinely stuck replica
  • Distinguish replication lag (absolute_delay climbing, is_readonly = 0), a stuck replica (is_readonly = 1), and permanent data loss (lost_part_count increasing) using system.replicas alone, without conflating three genuinely different failures
  • Recognize the error-code signature of every major failure mode this course has covered, and name which system table or source file resolves it first, using ErrorCodes.cpp and system.errors as the canonical anchor rather than a memorized table of error text

Why This Matters

Every lesson in this module before this one handed you a symptom already labeled: "here's a stuck mutation," "here's a checksum mismatch," "here's a query that hit a memory limit." A real incident doesn't come pre-labeled. It comes as a Slack message — "the dashboard is showing stale numbers again" — and the first job isn't applying debugging-with-system-tables' mutation-vs-merge diagnosis or mergetree-on-disk-format's checksum walkthrough. It's figuring out which of those this even is, fast, before burning twenty minutes down the wrong path. That's a genuinely different skill from any single deep dive, and it's the one this course has been building toward the whole time: pattern recognition across failure categories, not mastery of one. This lesson is the last one in the course for a reason. It's the synthesis — a triage layer over everything Module 7 already taught, plus the one failure category the course has discussed only conceptually until now: replication, and what it looks like when it breaks.

The mental model: a failure has a signature before it has a diagnosis

Every failure mode this course has covered eventually resolves to the same shape: a specific named exception, a specific timing pattern, and a specific system table that already knows something is wrong. mergetree-on-disk-format and codebase-tour-clickhouse-source both hit DB::Exception: Too many parts (300) — that's a named exception, TOO_MANY_PARTS, with a hard-threshold timing pattern (writes work fine, then abruptly fail past a count). debugging-with-system-tables covered MEMORY_LIMIT_EXCEEDED — a different named exception, a different timing pattern (fails immediately, on the query that caused it, not gradually). The skill this lesson adds isn't a ninth failure to memorize. It's the move that happens before any of those specific diagnoses: reading the signature first, so you know which lesson's playbook even applies.

system.errors is the fastest read that exists for this, and most people never open it. It aggregates, server-wide, every named exception ClickHouse has thrown since startup — one row per error code that has fired at least once, with a running count (value), the timestamp and text of the most recent occurrence (last_error_time, last_error_message), and whether it came from a local query or a remote one in a distributed query (remote). Before writing a single hypothesis-specific query against system.mutations, system.merges, or system.replicas, one query against system.errors — filtered to the last hour, sorted by count — tells you what kind of thing has actually been failing, by name, without guessing:

The fastest first query for an unlabeled symptomsql
SELECT name, code, value, last_error_time, last_error_message
FROM system.errors
WHERE last_error_time > now() - INTERVAL 1 HOUR
ORDER BY value DESC;

That single query is doing the work three separate lessons' worth of hypothesis-forming used to require doing by hand: if TOO_MANY_PARTS shows up, you're in mergetree-on-disk-format and codebase-tour-clickhouse-source territory. If MEMORY_LIMIT_EXCEEDED shows up, you're in debugging-with-system-tables territory. If TABLE_IS_READ_ONLY or KEEPER_EXCEPTION shows up — names this course hasn't used yet — you're in new territory this lesson covers directly.

The error code number, not the error text, is the durable identifier. Error text can be reworded across ClickHouse versions; a blog post from three years ago describing "Table is in readonly mode" might not match this server's exact wording. The number doesn't move. system.errors' code column and every Code: N. DB::Exception: ... line ClickHouse ever prints both refer back to one canonical source of truth: a single registry file mapping every code to one name, checked directly against the current master branch for this lesson.

One category this course has only discussed conceptually is replication — and its signatures don't look like anything covered so far. replicated-engines, back in Module 5, built the trade-off case for ReplicatedMergeTree and named system.replicas as "Module 7 territory" without opening it. Replication failures don't throw one clean exception the way TOO_MANY_PARTS does — they show up as a state (a replica that's behind, or read-only, or has quietly lost data) that system.replicas reports directly, no exception required at all. That's the second half of this lesson's mental model: some failures announce themselves as an exception; others announce themselves only as a status column you have to know to check.

Unlabeled symptomalways check firstsystem.errorswhich named exception, by code, has fired?252 / 40TOO_MANY_PARTS /CHECKSUM mismatch-> system.parts241MEMORY_LIMIT_EXCEEDED-> query_log242 / 999TABLE_IS_READ_ONLY /KEEPER_EXCEPTION-> system.replicasno errorstale dashboard,nothing thrown-> check replicas directlyThe error code routes you to the right lesson's playbook.Some failures never throw at all — only a status column shows them,which is why the dashed path checks system.replicas directly.
Signature first, hypothesis second. A funnel diagram. At the top, a single box: 'Unlabeled symptom — dashboard is wrong, query failed, something is slow.' An arrow labeled 'always check first' points down to a wide box: 'system.errors — sorted by value, filtered to recent last_error_time. Shows which named exception, by code, has actually been firing.' Below that, the funnel splits into four narrower paths based on which error code appeared. Path 1: 'TOO_MANY_PARTS (252) or CHECKSUM_DOESNT_MATCH (40) -> system.parts, on-disk format territory (mergetree-on-disk-format).' Path 2: 'MEMORY_LIMIT_EXCEEDED (241) -> system.query_log, system.processors_profile_log territory (debugging-with-system-tables, query-to-execution-plan).' Path 3: 'TABLE_IS_READ_ONLY (242) or KEEPER_EXCEPTION (999) -> system.replicas, system.replication_queue territory (this lesson).' Path 4, dashed border to signal 'no exception at all': 'Dashboard looks stale, no error thrown -> check system.replicas' absolute_delay and lost_part_count directly, since some replication failures never throw.' A caption below all four reads: 'The error code routes you to the right lesson's playbook. Some failures never throw an error at all — those only show up as a status column, which is why system.replicas gets checked directly, not reached via system.errors.'

Codebase tour: where signatures actually come from

Codebase Tour

  1. src/Common/ErrorCodes.cpp

    The canonical registry: every error code ClickHouse can throw, mapped to its name, in one file. The header comment explains why it's a macro rather than a plain enum — "when you add a new constant, you need to recompile all translation units that use at least one constant" with a plain enum, so the codes are declared via an APPLY_FOR_BUILTIN_ERROR_CODES(M) macro instead. This is the file worth opening any time a blog post's error-code claim looks stale or version-specific — it's the one place a code number's name can't drift. This lesson's numbers were checked directly against it: M(40, CHECKSUM_DOESNT_MATCH), M(241, MEMORY_LIMIT_EXCEEDED), M(242, TABLE_IS_READ_ONLY), M(252, TOO_MANY_PARTS), M(999, KEEPER_EXCEPTION), M(225, NO_ZOOKEEPER).

  2. src/Storages/System/StorageSystemErrors.cpp

    The implementation behind system.errors: it iterates ErrorCodes::values, one entry per code, and for each one with a nonzero count (or all of them, if system_events_show_zero_values is set) emits a row with the name, code, count, and last occurrence's timestamp and message. If you ever want to know exactly what system.errors counts and when a row disappears, this is the file, not the docs page's summary of it.

  3. src/Storages/System/StorageSystemReplicas.cpp

    The implementation behind system.replicas. Rather than blocking on one table at a time, it submits an async status request per ReplicatedMergeTree table to a request pool and collects the results — worth knowing if system.replicas itself feels slow to query on a cluster with many replicated tables, since that latency is coming from real, concurrent per-table Keeper round trips, not a single slow scan.

  4. src/Storages/MergeTree/ReplicatedMergeTreeRestartingThread.h

    The class responsible for exactly the read-only transitions this lesson's walkthrough diagnoses. Its own doc comment: "Initializes ZK session. Exposes ephemeral nodes... Then monitors whether the session has expired. And if it expired, it will reinitialize it." setReadonly() and setNotReadonly() are real methods on this class — read-only mode isn't an incidental side effect, it's a state this thread explicitly manages as part of recovering from a lost Keeper session. That framing matters: a replica flipping to read-only and back on its own, without intervention, is this thread doing its job correctly, not a bug.

  5. src/Storages/MergeTree/ReplicatedMergeTreeQueue.h

    The in-memory structure behind system.replication_queue. Its own doc comment: "The queue of what you need to do on this line to catch up. It is taken from ZooKeeper (/replicas/me/queue/). In ZK records in chronological order." GET_PART, MERGE_PARTS, and MUTATE_PART entries all live here — this is the source-level answer to "what specifically is this replica waiting to do," one level below system.replication_queue's type column, which reports these exact same values.

Debugging walkthrough: "one replica keeps failing writes and won't stay healthy"

Symptom. A three-replica ReplicatedMergeTree table backs a customer-facing dashboard. Analysts querying through a load balancer that round-robins across replicas notice the numbers occasionally look stale for a few seconds, then catch up — mildly annoying, but tolerable. Then a direct write attempt against one specific replica fails outright: Code: 242. DB::Exception: Table is in readonly mode. The other two replicas are accepting writes fine.

Step 1 — don't guess the category, check system.errors first. Before assuming this is a Keeper networking blip, a disk problem, or something else entirely, the fast global read this lesson opened with:

What has actually been thrown, recently, on this replicasql
SELECT name, code, value, last_error_time, last_error_message
FROM system.errors
WHERE last_error_time > now() - INTERVAL 1 HOUR
ORDER BY value DESC
LIMIT 5;
Result — one error code dominating, illustrative of the diagnostic techniquetext
┌─name───────────────┬─code─┬─value─┬────last_error_time─┬─last_error_message──────────────────┐
│ TABLE_IS_READ_ONLY  │  242 │    47 │  2026-07-13 09:41:02 │ Table is in readonly mode            │
│ KEEPER_EXCEPTION    │  999 │     6 │  2026-07-13 09:22:15 │ Coordination::Exception: Session ex… │
└─────────────────────┴──────┴───────┴─────────────────────┴───────────────────────────────────┘

Two things this rules in and rules out in one query: TABLE_IS_READ_ONLY (code 242) confirms the write failure genuinely is the replica's read-only state, not a one-off transient error, since it fired 47 times in the last hour. KEEPER_EXCEPTION (code 999), firing earlier and far less often, is a real clue about why — this replica had a Keeper coordination problem roughly twenty minutes before the read-only errors started piling up.

Step 2 — confirm the replica's own state directly, not just the error log. system.replicas, per ReplicatedMergeTreeRestartingThread's own responsibility for this state, is the direct source of truth:

Is this replica actually read-only right now, and since when?sql
SELECT database, table, replica_name, is_readonly, readonly_start_time,
       is_session_expired, absolute_delay, queue_size, active_replicas, total_replicas
FROM system.replicas
WHERE table = 'events';
Result — one of three replicas confirmed read-onlytext
┌─replica_name─┬─is_readonly─┬─readonly_start_time─┬─is_session_expired─┬─absolute_delay─┬─queue_size─┬─active_replicas─┬─total_replicas─┐
│ replica-1     │           0 │                 NULL │                  0 │              0 │          2 │               3 │              3 │
│ replica-2     │           0 │                 NULL │                  0 │              1 │          3 │               3 │              3 │
│ replica-3     │           1 │  2026-07-13 09:22:15 │                   1 │             62 │         14 │               2 │              3 │
└───────────────┴─────────────┴──────────────────────┴─────────────────────┴────────────────┴────────────┴─────────────────┴────────────────┘

replica-3 is confirmed is_readonly = 1, and readonly_start_time lines up almost exactly with the KEEPER_EXCEPTION timestamp from Step 1 — this is is_readonly's documented trigger condition in action: "turned on... during session reinitialization in ClickHouse Keeper." active_replicas = 2 on this row (versus 3 on the healthy replicas' rows) confirms the same story from the cluster's perspective: the other two replicas can also see that replica-3 has dropped out of the active set. absolute_delay = 62 seconds on replica-3 explains the analysts' "occasionally stale" complaint precisely — this is the replica intermittently serving reads through the load balancer, 62 seconds behind, while it's stuck.

Step 3 — is this self-healing, or genuinely stuck? ReplicatedMergeTreeRestartingThread's own doc comment already told you the design intent: it monitors the Keeper session and "if it expired, it will reinitialize it" — meaning a KEEPER_EXCEPTION followed by a brief is_readonly = 1 window that clears on its own, without intervention, is the expected, correctly-functioning behavior of a coordination-layer blip, not a bug. The way to tell "still recovering" from "genuinely stuck" is system.replication_queue, checked for this specific replica:

What is this replica's queue actually stuck on?sql
SELECT type, num_tries, last_exception, num_postponed, postpone_reason
FROM system.replication_queue
WHERE replica_name = 'replica-3'
ORDER BY num_tries DESC
LIMIT 3;
Result — a specific, repeatedly-failing task, illustrative of the diagnostic techniquetext
┌─type────────┬─num_tries─┬─last_exception──────────────────────────────────────────────┬─num_postponed─┬─postpone_reason─┐
│ GET_PART    │        36 │ DB::Exception: Directory .../detached/covered-by-broken_20260… │             0 │                 │
│ MERGE_PARTS │         2 │                                                                │             1 │ Not executing…  │
└─────────────┴───────────┴────────────────────────────────────────────────────────────────┴───────────────┴─────────────────┘

num_tries = 36 on a single GET_PART task, with a real, repeating last_exception, is the signature that distinguishes this from a simple session blip: this isn't "still reconnecting," it's failing the same specific operation over and over. The DIRECTORY_ALREADY_EXISTS-style conflict in that message ("directory already exists and is not empty") is a real, documented shape of this failure — a genuine, publicly filed ClickHouse issue shows an almost identical symptom: a replica stuck read-only after a restart, repeatedly logging attempts to detach the same part to a directory that already exists, cycling through _try1 through _try9 suffixes without ever resolving on its own, on a replica where the other replicas stayed healthy the whole time. That issue's own resolution path required manual intervention — detach, drop, re-attach, and restore — specifically because a genuinely conflicting on-disk directory isn't something ReplicatedMergeTreeRestartingThread's automatic session-reinitialization logic is designed to resolve; it resolves Keeper session state, not on-disk directory collisions.

Step 4 — apply the right remediation for which specific failure this is, not a generic restart. ClickHouse ships two different SYSTEM statements for two genuinely different replica problems, and picking the wrong one wastes an incident's worth of time: SYSTEM RESTART REPLICA "reinitialize[s the] Zookeeper session's state for ReplicatedMergeTree table, will compare current state with Zookeeper as source of truth and add tasks to Zookeeper queue if needed" — the right tool when Keeper is the source of truth and the replica's local queue just needs to resync against it. SYSTEM RESTORE REPLICA is a different, narrower tool: it "restores a replica if data is possibly present but Zookeeper metadata is lost" — the opposite direction, for when local data exists but Keeper's record of it doesn't, and it only operates on a replica already in read-only mode. Given a repeatedly-failing GET_PART with a real on-disk conflict — not a missing-metadata problem — RESTART REPLICA is the correct first attempt, since Keeper's queue is intact and the goal is getting replica-3 to re-attempt its pending tasks cleanly against that source of truth. But the cited issue above is a real caution against expecting that alone to resolve every case: its own reported resolution needed manual intervention (detach the conflicting part, drop it, re-attach, and restore) precisely because the conflict was a genuine collision already sitting on disk — the kind of state a session resync doesn't remove on its own. The working sequence this evidence supports: try RESTART REPLICA, then check system.replication_queue again a few cycles later; if the same task is still failing with the same last_exception, that repetition itself is the signal to stop retrying the same fix and move to manual removal of the conflicting on-disk state instead of escalating straight to RESTORE REPLICA, which addresses missing Keeper metadata, not an on-disk collision.

Replication lag without read-only — a replica that's simply behind. Signature: is_readonly = 0, but absolute_delay is nonzero and, checked repeatedly, climbing rather than holding steady — "how big lag in seconds the current replica has." This is a fundamentally different failure from the walkthrough above: the replica is healthy and actively working, just not caught up. Root cause is almost always throughput, not correctness — and debugging-with-system-tables' central mental model applies here directly, not just by analogy: MergeTreeBackgroundExecutor, the same shared pool that lesson showed merges and mutations competing for, is documented as handling "merges, mutations, fetches and so on" — "fetches" is a replica pulling parts to catch up. A table under heavy insert or merge load on the same node can starve that same replica's fetch tasks of pool capacity, the identical shared-resource competition that lesson diagnosed for a stuck mutation, just showing up here as climbing absolute_delay instead. queue_size climbing alongside absolute_delay (rather than queue_size being small while absolute_delay is large) points specifically at "genuinely can't keep up," not "caught up but hasn't updated its delay figure yet."

A stuck read-only replica — this lesson's walkthrough. Signature: TABLE_IS_READ_ONLY (242) firing repeatedly in system.errors, is_readonly = 1 in system.replicas persisting well past a normal Keeper reconnection window, and — the detail that actually separates "stuck" from "recovering" — a system.replication_queue entry with a high num_tries and a real, repeating last_exception, rather than an empty queue quietly waiting for a session to reinitialize.

Permanently lost parts — a failure that never throws at query time at all. system.replicas' lost_part_count column is unlike almost everything else in this course's diagnostic toolkit: it isn't a live state that clears once things recover, it's "the number of data parts lost in the table by all replicas in total since table creation," a value "persisted in ClickHouse Keeper and can only increase." A nonzero, monotonically increasing lost_part_count is a genuine data-loss signal, not a transient symptom — and it's worth cross-referencing against mergetree-on-disk-format's CHECKSUM_DOESNT_MATCH failure mode, since a replica repeatedly losing parts to hardware-level corruption (that lesson's bit-flip cause) is exactly the kind of underlying condition that would show up here as a slowly climbing lost_part_count, independent of whether any single query ever surfaced a checksum error to a user.

Recognition index — every named failure this course has covered, by code. This isn't a fourth deep dive; it's the reference card this lesson exists to hand you, so a symptom's error code routes you to the right earlier lesson immediately instead of re-deriving the diagnosis from scratch:

CodeNameFirst checkCovered in depth
40CHECKSUM_DOESNT_MATCHsystem.parts, then the part's on-disk checksums.txtmergetree-on-disk-format
241MEMORY_LIMIT_EXCEEDEDsystem.query_log's exception_code and memory_usagedebugging-with-system-tables
242TABLE_IS_READ_ONLYsystem.replicas (is_readonly, readonly_start_time), then system.replication_queuethis lesson
252TOO_MANY_PARTSsystem.parts grouped by partition, then system.mergesmergetree-on-disk-format, codebase-tour-clickhouse-source
999KEEPER_EXCEPTIONsystem.errors' last_error_message for the specific Keeper failure, then system.replicasthis lesson

A slow query with no memory-limit exception and a clean-looking EXPLAIN (plan) doesn't have an error-code signature at all — that's query-to-execution-plan's system.processors_profile_log territory, the reminder that not every failure this course covers throws a named exception; some only show up as a timing pattern in a table you have to know to check.

Hands-on forensics: the triage checklist

  1. Query system.errors first, always, before forming any hypothesis. Filter to a recent time window, sort by count. This alone tells you which named exception, if any, has actually been firing.
  2. If an error code appeared, look it up against the recognition index above (or, if it's not there, ErrorCodes.cpp directly) to find which earlier lesson's playbook applies.
  3. If no error appeared but something still looks wrong (a stale dashboard, a slow-feeling query with no exception), the failure is a state, not an event — go directly to the system table that reports the relevant state: system.replicas for anything replication-shaped, system.parts for anything storage-shaped, system.processors_profile_log for anything execution-shaped.
  4. For anything replication-shaped specifically, read system.replicas for three independent signals, not one: is_readonly (stuck?), absolute_delay (behind, but healthy?), lost_part_count (permanent loss?). These are three different failures with three different remediations — conflating them is the single easiest mistake to make at this depth.
  5. Before restarting or restoring anything, confirm which specific remediation the evidence actually supportsRESTART REPLICA for a Keeper-state resync, RESTORE REPLICA only for missing Keeper metadata on an already-read-only replica — rather than reaching for whichever SYSTEM statement sounds most like "fix it."

Reasoning Prompt

A four-replica ReplicatedMergeTree table shows no rows in system.errors for the last 24 hours — no exception has fired anywhere on any replica. Yet a customer reports that a query they ran twice in a row, a few seconds apart, against what they insist was "the same connection," returned different row counts the second time. system.replicas shows all four replicas with is_readonly = 0 and lost_part_count = 0. Before reading further: is this evidence against a replication-related explanation, since nothing errored and nothing looks unhealthy? What's the specific column you'd check next, and why would this symptom produce no error at all even if replication is, in fact, the cause?

Model Answer

The absence of anything in system.errors is not evidence against replication being the cause — it's exactly what this lesson's mental model predicts for this specific replication failure. absolute_delay and lag-driven staleness are a state, not an event: a replica silently serving reads a few seconds behind another replica is normal, expected, asynchronous replication behavior, not a broken condition, and nothing about it violates any check that would throw a named exception. replicated-engines already established the mechanism this scenario is describing: replication is asynchronous by default, and a SELECT against one replica can return different results than a SELECT against another replica received moments later — especially plausible here if "the same connection" was actually going through a load balancer or connection pool that isn't pinned to one specific replica between the two queries, landing on a different one each time.

The column to check next: absolute_delay, per-replica, right now — SELECT replica_name, absolute_delay, queue_size FROM system.replicas WHERE table = '...'. If one replica shows meaningfully higher absolute_delay than the others, that's the direct confirmation: the customer's two queries almost certainly landed on two different replicas, one further behind than the other, and both results were individually correct answers from each replica's own honest, current state — just not the same state.

Why no error fired even though this is genuinely a replication effect: an exception represents ClickHouse detecting that something it expected to succeed, didn't. A replica serving a slightly stale but internally consistent read isn't a failure by that definition — it's the documented, intended behavior of asynchronous, multi-master replication working exactly as designed. The lesson worth generalizing: system.errors catches failures a query or a background process explicitly threw. It cannot catch a symptom that's actually just eventual consistency behaving normally — for those, the state-based columns (absolute_delay here; the same logic applies to a merge that's simply running slowly rather than throwing) are the only place the "failure" is visible at all, and they have to be checked directly, not discovered by scanning an error log that has nothing to show.

Teach It Back

Explain to a colleague who has finished this entire course up through this lesson except this one — comfortable with system.mutations, system.merges, system.parts, and EXPLAIN, but has never opened system.replicas or system.errors — how you'd actually approach an unfamiliar ClickHouse symptom on day one of being on-call, starting from nothing. Walk them through why you'd check system.errors before anything else, why a replication problem might show up with no error at all, and how is_readonly, absolute_delay, and lost_part_count are three different failures wearing the same "something's wrong with replication" costume. Use your own words, as if they just asked you "don't you just check whichever system table matches the symptom you're already suspecting?"

Summary

Every failure mode this course has covered reduces to the same two-part signature: a named exception (or, for some replication failures, no exception at all — only a status column) and a specific system table that already knows something is wrong. system.errors, backed directly by ErrorCodes.cpp's canonical code-to-name registry, is the fastest first read for anything that did throw — a single query, sorted by count over a recent window, that routes an unlabeled symptom to the right earlier lesson's playbook by error code rather than by guessing from error text alone. Replication failures are this course's one remaining category, and they resolve through system.replicas and system.replication_queue rather than a single clean exception: is_readonly (a replica genuinely stuck, distinguished from a self-healing Keeper blip by system.replication_queue's num_tries and last_exception, exactly as this lesson's walkthrough traced against a real, publicly filed GitHub issue), absolute_delay (a healthy replica that's simply behind, no error required), and lost_part_count (permanent, Keeper-persisted data loss that never clears on its own). RESTART REPLICA and RESTORE REPLICA are not interchangeable fixes for "something's wrong with this replica" — one resyncs a replica's queue against Keeper as the source of truth, the other rebuilds Keeper's metadata from locally present data, and picking correctly depends on having actually traced which of the three replication failures you're facing, not on which command sounds most like "fix it." That's the last mental model this course builds: a real incident doesn't arrive labeled, and the durable skill underneath every deep dive in this module is knowing how to read a symptom's signature well enough to find out which one you're actually looking at.