Skip to content

fix(ingest): retry ClickHouse outages instead of dead-lettering - #619

Merged
EricAndrechek merged 17 commits into
mainfrom
feat/ch-error-classes
Sep 26, 2026
Merged

EricAndrechek merged 17 commits into
mainfrom
feat/ch-error-classes

Conversation

@EricAndrechek

@EricAndrechek EricAndrechek commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

Part of #613 (workstream A).

What changes

A failed batch insert used to go through row-by-row isolation whatever the failure, so a ClickHouse that was down, overloaded or read-only failed every row a second time and parked the whole batch on the DLQ. Now the worker asks what the failure was first:

  • ClickHouse rejected the row (chconn.Rejected): unchanged. Row-by-row isolation, the rows rejected again go to the DLQ, or stay unacked with dlq.enabled off.
  • ClickHouse refused a multi-row batch for its size (TOO_MANY_PARTS, MEMORY_LIMIT_EXCEEDED: chconn.Splittable): the same row-by-row isolation first. TOO_MANY_PARTS is also what ClickHouse answers for one INSERT spanning more than max_partitions_per_insert_block partitions: measured on 26.6.3.62, 150 rows over 150 day-partitions fail with 252, and each row inserts on its own. Retried whole, such a batch re-forms the same way on redelivery and never lands. If a row fails any way but Rejected (typically the first row failing the same way, because the server is the problem after all), isolation stops there and that row and the rest are retried under the backoff below; in the typical case that costs one extra request. ClickHouse's own Distributed async inserts split a batch on the same codes (isSplittableErrorCode).
  • ClickHouse could not take it (Unavailable, Denied, Unknown): no isolation, no DLQ. The batch goes back to the queue with a delayed nak (mq.Message.NakWithDelay, new), under a backoff per ClickHouse pool (URL + user + database). The backoff starts at 1 s, doubles to a 30 s cap, and each delay is jittered down to as little as half. While the window runs, every table on that pool is turned away with no request, and a row that arrives is handed straight back rather than buffered. When the window ends, one flush probes. Any answer that is not an outage closes the backoff, and that includes a rejected row. While a probe is out, arriving rows are handed back with a delay of at least 0.5 s, so a slow probe does not cycle the backlog. A failure of one table (TABLE_IS_READ_ONLY, TABLE_IS_PERMANENTLY_READ_ONLY, TOO_MANY_PARTS, TOO_MANY_MUTATIONS, ACCESS_DENIED: chconn.TableScoped) backs off that table alone: its neighbours keep inserting until its waiting rows fill the tenant's maxAckPending (see below), and their successes don't close its backoff. The retries are counted by wavehouse_ingest_retries_total{table, reason}. An outage is logged at WARN when it starts, at most every 30 s while it lasts, and at INFO when ClickHouse takes inserts again.
  • ClickHouse goes away mid-isolation: isolation stops at that row. The rows already inserted stay acked and the rows already rejected stay parked. The row that hit the outage, and every row after it, go back to the queue.

The classifier lives in internal/chconn/errclass.go: Classify(err) Class, ClassOfCode, ExceptionCode, and HTTPError/NewHTTPError for the HTTP interface. It reads *clickhouse.Exception and *clickhouse.HTTPError from the driver, the HTTP interface's X-ClickHouse-Exception-Code / Code: NNN. body, the clickhouse-go sentinels (ErrAcquireConnTimeout, ErrConnectionClosed, driver.ErrBadConn) and the network errors under them. So the query path can reuse it.

How the ambiguous cases are classed, and why

The line is drawn at the exception code.

  • Unlisted exception code → Rejected (DLQ). A code means ClickHouse was up and read the request. Nearly all of its several hundred codes are verdicts on what it read, so the availability codes are the enumerated exception. The cost of each wrong guess settles it. If an availability code is missing from the list, its rows get parked: nothing is lost, and they wait on the DLQ (reading them back is feat(api): admin DLQ replay endpoint (re-validate + re-publish dead-lettered events) #237). If a genuine bad row were retried forever, it would pin the ingest stream's ack floor and a share of maxAckPending for every table behind it, and nothing would clear it. A code added by a future ClickHouse falls into the same bucket.
  • No code, and not a recognisable transport failure → Unknown (retried). Examples: a bare 500 from something that is not ClickHouse, or a TLS setup error. Nothing says a row was judged, so isolating the batch would only multiply the requests, and dead-lettering it would park good rows.
  • Schema drift: UNKNOWN_TABLE, NO_SUCH_COLUMN_IN_TABLE, UNKNOWN_DATABASE → Rejected. The row cannot insert into the table as it now is, and the DLQ keeps it rather than retrying it forever. TestDLQ_PopulatedOnIngestWorkerFailure still pins this.
  • Auth: AUTHENTICATION_FAILED, ACCESS_DENIED, UNKNOWN_USER, WRONG_PASSWORD, REQUIRED_PASSWORD, IP_ADDRESS_NOT_ALLOWED, DATABASE_ACCESS_DENIED, USER_EXPIRED → Denied (retried). These are the connection's configuration, not the rows. A grant, a corrected clickhouse.username, or WH_CH_PASSWORD and a restart makes the whole batch insert as it is. ACCESS_DENIED is usually a grant missing on one table, so it backs off that table alone (see below). Parking would move every row of every table on that pool to the DLQ.
  • Codes classed Unavailable (names checked with errorCodeToName on 26.8, and against system.errors on 26.6.3.62, the pinned test image): 95, 96, 159, 160, 164, 173, 201, 202, 203, 209, 210, 225, 236, 241, 242, 243, 244, 252, 254, 265, 279, 285, 286, 289, 297, 319, 364, 369, 394, 415, 425, 439, 499, 574, 692, 700, 735, 745, 749, 762, 774, 776, 777, 778, 904, 999, 1000. TABLE_IS_PERMANENTLY_READ_ONLY (774) is here even though it does not clear by itself: its rows are not at fault, and an operator fixes it the way one fixes a grant. UNKNOWN_STATUS_OF_INSERT (319) is retried: a duplicate is the lesser harm. ClickHouse's insert deduplication will not catch it, because the retried rows come back regrouped with newer ones; the docs now say delivery after an ambiguous failure is at-least-once. SERVER_OVERLOADED (745) was missing from the first cut: the CPU-overload check at the top of every query throws it, and so does the workload scheduler's max_waiting_queries.
  • HTTP status with no code (a proxy answering for ClickHouse): 408/429/502/503/504 → Unavailable. 401/403/407 → Denied. 413 → Rejected, because isolation shrinks the body.

Unchanged on purpose: a batch whose tenant has no ClickHouse connection (parkBatch) still meets its DLQ switch whole. That rule exists so that a tenant no longer served does not pin the shared ingest queue.

What an operator sees

Rows waiting out an outage stay unacked in the ingest stream. They count toward maxAckPending, and the sweeper cannot purge past them. A long outage therefore fills the stream to mq.max_bytes_gb, and ingest then answers 503: backpressure, with nothing lost and nothing parked. The DLQ no longer fills up. NakWithDelay keeps the message on the consumer's pending list (verified in nats-server processNak), so the queue holds the backlog, not the worker.

A failure of one table counts toward the same budget. One that lasts (a missing grant, a permanently read-only table) suspends delivery for all of that tenant's tables once its waiting rows reach maxAckPending, and the tenant's ingest backs up to 503 as in an outage. A retry also does not keep arrival order; that matters only to a ReplacingMergeTree without a version column or a CollapsingMergeTree, and the docs say so.

Tests

  • internal/chconn/errclass_test.go: a table of classifier cases covering a real refused dial and a real client timeout through net/http, *clickhouse.Exception, *clickhouse.HTTPError, the driver sentinels, header- and body-coded HTTP answers, and codeless proxy statuses. Also pins that the two code lists are disjoint, that every splittable code is a retried one, and NewHTTPError parsing.
  • internal/ingest/backoff_test.go: escalation to the cap, jitter bounds, no escalation from late reports, one probe at a time, rate-limited outage logging, and one backoff per pool.
  • internal/ingest/worker_test.go:
    • (a) an availability failure, in eleven shapes, never dead-letters and naks with a delay: one request, or two for a splittable code (the split's first row fails the same way);
    • a batch refused for its size (TOO_MANY_PARTS as too many partitions, MEMORY_LIMIT_EXCEEDED) is split, every row inserts, and no backoff opens; a lone row is not split again;
    • (b) a CANNOT_PARSE_NUMBER row is still isolated and parked;
    • (c) ClickHouse going down mid-isolation stops isolation and retries the unsettled rows;
    • an outage stops later column groups;
    • tables on a down pool back off together, and one probe closes the backoff;
    • another pool is unaffected;
    • the batcher buffers nothing while its pool backs off, or while a probe is out;
    • a read-only table backs off alone, and a healthy neighbour's success does not close its backoff.
  • tests/integration/ingest_outage_test.go: stops a real ClickHouse container under a running worker and publishes three rows. It checks that none is parked for 12 s, then restarts ClickHouse and waits until all three land, still with none parked. Run against the old worker.go, it fails on both assertions.

Review

The pre-push-reviewer and docs-reviewer subagents (opus) ran in fresh context against this worktree, four rounds:

  • Round 1 (a131fb95): both iterate. Code review: table-scoped codes flapped the shared pool breaker, and the delay while a probe was out was near zero. Docs review: an overclaim that the query handlers already use Classify, NakWithDelay missing from the mq surface list, an incomplete Denied list. Fixed in 8ae9614b.

  • Round 2 (8ae9614b): both iterate. Code review: ACCESS_DENIED is usually one table's grant, so it should be table-scoped; a stale bound in the backoff map comment. Docs review: the docs pointed operators at a ClickHouse password in config.json (it is WH_CH_PASSWORD, boot config), and overstated the pool key. Fixed in 356125f3.

  • Round 3 (356125f3): code ship_it; docs iterate, because the README, landing page, why page and architecture diagram still said failed inserts go to the DLQ. Fixed in e97edc8b.

  • Round 4 (e97edc8b): both ship_it.

  • Round 5 (c70d3324..d1ef1302): the size split for TOO_MANY_PARTS/MEMORY_LIMIT_EXCEEDED, SERVER_OVERLOADED, and doc corrections. Code review of c70d3324: ship_it, no findings. Docs review took nine passes to reach ship_it on d1ef1302. Along the way it fixed:

    • a drain runbook that described this build's retry signals for a drain run on the earlier build (which dead-letters an outage, so ClickHouse must stay healthy for the whole drain);
    • the split exception missing from two summaries;
    • a lock-free claim the shared backoffs broke;
    • duplicates after an ambiguous insert failure, which neither ClickHouse's insert deduplication nor dedupe.enabled catches;
    • version-column and FINAL advice where a user chooses an engine;
    • a set of missed copies of "a failed insert goes to the DLQ".

    Markers for round 5: the SubagentStop hook wrote none. The reports came back through the subagent hand-back, so the payload it reads evidently carried no VERDICT: line; fed the report text, the hook writes a marker (tested in a scratch repo). Both verdicts are recorded with scripts/skip-pre-push-review.sh, and each reason names the run. docs-reviewer ran on HEAD. pre-push-reviewer ran on c70d3324; everything after it is docs prose and two doc-comment rewordings.

Review markers (#454): the review-marker.sh SubagentStop hook writes to $CLAUDE_PROJECT_DIR/tmp, keyed to that checkout's HEAD (the main checkout, on main), so no tmp/<reviewer>-passed-e97edc8b… marker exists for this branch. The verdicts above are the record; no marker was hand-written or skipped.

Follow-ups (not in this PR)

Seams left for workstream C

  • The backoff state is in-process: IngestWorker.backoffs, keyed by chconn.Target (URL, user, database), plus the table for table-scoped failures. With several worker processes, each one backs off on its own. That is harmless, because each probes at most once per window, but a shared backoff could live behind the coordination primitive (B).
  • flushTable hands back exactly the unsettled rows through retryLater. Any batching/claiming scheme that owns acks can keep that contract.
  • tableBatcher.add asks the backoff before buffering. A per-tenant consumer (feat(mq): give every tenant a queue of its own #612) could pause that tenant's Consume here instead of nak'ing arrivals.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
https://claude.ai/code/session_01LYbFK7Na4RTNZxzL3h9Hs9

EricAndrechek and others added 4 commits September 24, 2026 23:43
A failed batch insert went through row-by-row isolation whatever the
failure, so a down, overloaded or read-only ClickHouse parked every row
on the DLQ. chconn.Classify now classes the failure first: only a row
ClickHouse rejects is isolated and dead-lettered; an unavailable, denied
or unjudged failure is handed back with a delayed nak under a per-pool
backoff, including when ClickHouse goes away mid-isolation.

Part of #613.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review round 1. A read-only table (or one with too many parts or
mutations) tripped the whole pool's breaker, and a healthy neighbour's
success reopened it on every flush — the backoff never escalated and
the logs flapped. chconn.TableScoped now routes those codes to a
per-(pool, table) backoff. While a probe is out, arriving rows are
handed back with a floored delay instead of cycling through the worker.
Docs: the query handlers do not use Classify yet; list NakWithDelay in
the mq surface; complete the Denied list.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review round 2. ACCESS_DENIED is usually a grant missing on one table,
so it joins the table-scoped codes instead of flapping the pool's
backoff. Docs: the ClickHouse password is WH_CH_PASSWORD (boot config),
not config.json; the pool backoff is per URL, user and database, not
per server; the backoffs map comment states its real bound.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review round 3: the README feature list, the landing page, the why page
and the architecture diagram still said failed inserts go to the DLQ;
an outage is now retried with backoff instead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 2b41763a-c287-464c-8a84-c7d35ca75994

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added documentation Improvements or additions to documentation go Pull requests that update go code area/ingest Ingest pipeline (Bento, batching, DLQ) area/docs Documentation, site/, README labels Sep 25, 2026
@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

📚 Docs preview is live → https://65a8004d-wavehouse-docs.wave-rf.workers.dev

  • Commit — d1ef130: docs(ingest): split overview names what goes back; flow in order
  • Author — @EricAndrechek, Claude Opus 5.5 (1M context)
  • Committed — 2026-09-26 01:27 (UTC-04:00)
  • Deployed — 2026-09-26 01:42 EDT

@github-code-quality

github-code-quality Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Code Coverage Overview

Languages: Go

Go

The overall line coverage in commit d1ef130 in the feat/ch-error-classe... branch remains at 94%, unchanged from commit 77cf4a7 in the main branch.

Show a line coverage summary of the most impacted files.
File main 77cf4a7 feat/ch-error-classe... d1ef130 +/-
internal/mq/mq.go 100% 98% -2%
internal/ingest/worker.go 98% 97% -1%
internal/chconn/errclass.go 0% 98% +98%
internal/ingest/backoff.go 0% 98% +98%

Updated September 26, 2026 05:42 UTC

EricAndrechek and others added 3 commits September 25, 2026 11:18
Absorbs #612 (2a2b886), which gave every tenant a queue of its own.
Doc conflicts resolved onto #612's per-tenant wording; the outage test
opens its tenant's queue with SetMaxBytes and names the tenant in
DeadLetterCounts, and the outage docs scope the ack floor, maxAckPending
and the byte budget to the tenant.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
waiting takes the target lazily, so while no backoff is open a row costs
one atomic load; lookups of an existing key take a read lock, since a
table that fails and is never written again keeps the open count up.
Docs: the HTTP status does not tell a rejected row from an outage (400/404
vs 500); the drain step names the retry metric and the outage WARN; the
changelog lists every doc the entry touches.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
EricAndrechek and others added 10 commits September 25, 2026 21:24
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
TOO_MANY_PARTS is also what ClickHouse answers for one INSERT spanning
more than max_partitions_per_insert_block partitions (measured on
26.6.3.62: 150 rows over 150 day-partitions fail with 252, each row
alone inserts). Classed Unavailable, such a batch was handed back whole
and re-formed the same way on redelivery, never landing. A multi-row
batch refused with TOO_MANY_PARTS or MEMORY_LIMIT_EXCEEDED
(chconn.Splittable, the codes ClickHouse's own Distributed async inserts
split on) now goes through row-by-row isolation first; if the first row
fails the same way, isolation stops there and the batch is retried
under the backoff, at the cost of one extra request.

SERVER_OVERLOADED (745: the CPU-overload check at the top of every
query, and the workload scheduler's max_waiting_queries) was unlisted,
so Rejected: an overloaded server got every row isolated and parked.

Docs: a failure of one table holds back its tenant's other tables once
its waiting rows reach maxAckPending, and a retry does not keep arrival
order (ReplacingMergeTree without a version column, CollapsingMergeTree).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LYbFK7Na4RTNZxzL3h9Hs9
…aims

- deployment.md drain step 3 described this build's retry signals for a
  drain that runs on the earlier build, which has none of them; restore
  "a row that fails its retry", name the v0.1.0 log lines, and warn that
  the drained build dead-letters an outage, so ClickHouse must stay
  healthy for the whole drain. "A failed row is parked" -> "a rejected row".
- The overview and the architecture diagram now carry the split
  exception (a multi-row batch refused for its size is split first).
- The DLQ keeps a schema-drift row rather than retrying it forever;
  there is no replay path yet (#237).
- The worker is single-owner except for the shared backoffs, which take
  locks; say so, and list them among the concurrency-safe collaborators.
- architecture.md gains its missing backoff.go bullet.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LYbFK7Na4RTNZxzL3h9Hs9
- The Rejected row read "any code not listed below" over an abridged
  Unavailable row, so it dead-lettered codes the worker retries; point
  at the full lists instead, and name QUOTA_EXCEEDED.
- Move "keep ClickHouse healthy for the whole drain" ahead of step 1,
  where an operator reads it before an outage can park rows.
- Two missed copies of "the rows that fail again go to the DLQ" now say
  "rejects again", and access-control no longer says a batch error
  always ends in the DLQ.
- "Do not reopen the failing table" -> "do not close its backoff".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LYbFK7Na4RTNZxzL3h9Hs9
- A retried failure can follow an INSERT ClickHouse did commit (the
  client timeout, a reset after the body was sent,
  UNKNOWN_STATUS_OF_INSERT), and the regrouped rows defeat ClickHouse's
  insert dedup, so say delivery is at-least-once; the "single instance
  hides this" line under the multi-instance notes no longer held.
- The version-column advice now says the producer must set it: an
  insert-time DEFAULT now64() stamps a delayed retry later than the
  rows that overtook it, so the stale row would win.
- "Skips that retry" read as the new backoff retry after the outage
  sentence moved in front of it; say "the row-by-row retry".
- Drain step 3: the poison counter is its own case again, ahead of the
  twice-failed-row case, and says which builds have it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LYbFK7Na4RTNZxzL3h9Hs9
…osen

- The ClickHouse Schema section gains a fourth consequence: retried rows
  arrive out of ingest order and may be inserted twice, so an
  insert-order engine needs a producer-set version column, not an
  insert-time DEFAULT now64() like the example's received_timestamp.
- wavehouse_ingest_retries_total counts one per row per hand-back, not
  per batch.
- Isolation stops at any row whose failure is not Rejected, not only the
  first row or an unanswered request.
- CHANGELOG lists access-control.mdx; a stray "— (" in AGENTS.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LYbFK7Na4RTNZxzL3h9Hs9
The schema note let "so" hang both halves on the version-column
engines, but they only fix ordering: a twice-landed row is a duplicate
on any engine, and VersionedCollapsingMergeTree keeps a doubled state
row its one cancel cannot remove. Split it, and name the duplicate's own
remedy. The overview no longer reads as though a split batch is always
requeued: only the rows from the first one that cannot insert go back.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LYbFK7Na4RTNZxzL3h9Hs9
…tries

- ReplacingMergeTree removes a duplicate only when parts merge, and a
  structured query never adds FINAL (a pipe can), so say read it with
  FINAL or count with uniqExact.
- dedupe.enabled drops a repeated publish at the HTTP edge, so it cannot
  see a duplicate the worker makes after the queue; say so in both
  at-least-once passages.
- The schema lead-in counts five consequences; the DLQ stats example no
  longer implies any failure parks a row; "exceed the memory limit"
  instead of "pass MEMORY_LIMIT_EXCEEDED".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LYbFK7Na4RTNZxzL3h9Hs9
The worker bullet's "Only when ClickHouse rejects the batch … except a
batch whose tenant has no ClickHouse connection" filed parkBatch as an
exception to the row-by-row rule, but that batch is never tried or
classed. Give it its own sentence ahead of the classification, and its
own arrow in the Ingest Path block. Two doc comments (DLQConfig,
IngestWorker.dlqEnabled) now say a row ClickHouse still rejects is
parked, not one that still fails.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LYbFK7Na4RTNZxzL3h9Hs9
The overview said "the rows from the first one that still cannot insert
go back", but a row rejected during the split is parked, not requeued;
say a row failing any way but a rejection sends itself and the rest
back. The Ingest Path block put the flush-time connection check above
the batching it follows; batch first, then flush.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LYbFK7Na4RTNZxzL3h9Hs9
@EricAndrechek
EricAndrechek marked this pull request as ready for review September 26, 2026 05:38
@EricAndrechek
EricAndrechek requested review from a team and taitelee September 26, 2026 05:38
@EricAndrechek
EricAndrechek merged commit 9aacb9c into main Sep 26, 2026
23 of 33 checks passed
@EricAndrechek
EricAndrechek deleted the feat/ch-error-classes branch September 26, 2026 05:50
EricAndrechek added a commit that referenced this pull request Sep 26, 2026
Brings in #618 (squash-merged parent), #619, #632, #616, #655 and #647.
Conflicts resolved by keeping main's content plus this branch's coord
changes; the AGENTS.md package count is now twenty (keyenc + coord).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
EricAndrechek added a commit that referenced this pull request Sep 26, 2026
Brings in main via feat/coord-leases: #618's squash, #619, #632, #616,
#655 and #647. Per #632, the roles default moves from its env-default tag
into defaults(): an explicit `roles: []` now reaches Validate (a
refusedZeros entry pins it), and the doc-defaults test parses the roles
cell as a comma-separated list. instance_id's documented default is
*(empty)*, the value in defaults(); Load resolves it to
<hostname>-<8 hex>.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
EricAndrechek added a commit that referenced this pull request Sep 26, 2026
Fixes #403. Fixes #271. Part of #613 (workstream A2). Stacked on #619
(`feat/ch-error-classes`), which adds the classifier this uses.

## What changes

ClickHouse answers a syntax error, a missing grant and an overloaded
server alike with **HTTP 500**. Before this PR:

- `/v1/ops/query` keyed on that status, so a bad statement came back as
`502` (#403).
- `/v1/query` and pipes returned a flat `500`, which the SDK retried
(#271).

All the query paths now class the failure with `chconn.Classify` through
one helper, `writeCHError` (`internal/api/ch_errors.go`), so they cannot
drift apart again:

| Class | Status | `code` | `retryable` |
|---|---|---|---|
| Rejected (syntax, unknown table/column/identifier, type mismatch, any
unlisted code) | 400 | `clickhouse.rejected` | false |
| Rows/bytes limits (158, 307, 396); the role's own time cap (159/160)
or memory cap (241) on `/v1/query` | 400 | `clickhouse.limit_exceeded` |
false |
| Denied: `ACCESS_DENIED` (497) | 403 | `clickhouse.access_denied` |
false |
| Denied: anything else (credentials, user, database, a proxy's codeless
401/403) | 502 | `clickhouse.misconfigured` | false |
| Unavailable | 503 + `Retry-After: 5` | `clickhouse.unavailable` | true
|
| Unknown | 500 (native) / 502 (proxy) | `clickhouse.unknown` | true |

Also in this PR:

- **Error envelope.** The existing `{"error": …}` envelope
(`internal/api/errors.go`) gains `code` and `retryable` on these
responses. It is additive: no new envelope, and the `code` names are
namespaced as #539 proposes.
- **SDK.** `clients/ts/src/errors.ts` takes the server's `code` and
`retryable` when present, and falls back to `HTTP_<status>` and "5xx
retries" otherwise. There is no `clients/go` on this base.
- **Other paths.**
- The raw-SQL proxy's 64 MiB overflow stays `502` but is now `retryable:
false`, since the same query overflows again.
- `POST /v1/ops/schema/refresh` against an unreachable ClickHouse
answers `503` + `Retry-After` instead of `500`.
- **Role time cap.** A capped query now runs with no context deadline,
and WaveHouse cancels it two seconds past the cap. Why: clickhouse-go
v2.48.0 (`context.go:238-241`) overwrites `max_execution_time` with
deadline+5s for any deadline over 1s. As it was, a cap overrun came back
as a bare `DeadlineExceeded`, which looks the same as a wait for a
pooled connection or a dial timeout, both of which are outages. Now
ClickHouse enforces the cap itself and reports `TIMEOUT_EXCEEDED`.
Anything else, including the backstop cancel, is `503`.
`TestQueryErrors_TimeCapReachesClickHouse` pins both sides of that
driver behaviour against a real ClickHouse.

## Why `ACCESS_DENIED` is a 403, and credential failures a 502

The query paths run as the ClickHouse user in the tenant's settings, not
as the caller. So `ACCESS_DENIED` is WaveHouse's configuration in one
sense. It is still a verdict on *this statement*:

- ClickHouse understood the statement and refused it;
- the same statement is refused every time;
- other statements from the same caller succeed.

That is a 403, and it matches #403's repro exactly: a `CREATE USER`
through `/v1/ops/query` with a user that lacks the grant. The
integration test runs that statement against the test container, and the
answer is `403 clickhouse.access_denied`. A 5xx would tell clients and
monitors that ClickHouse is down, and would invite retries of a request
that can never pass. The operator still hears about it, because
`writeCHError` logs every denial at `WARN`.

Denials that refuse **every** query rather than one statement are
different: a wrong password, an unknown or expired user,
`DATABASE_ACCESS_DENIED`, or a proxy's 401/403. Those are an operator
fix the caller cannot act on, so they are `502
clickhouse.misconfigured`. They are not retryable, because a retry a few
seconds later changes nothing.

## Deliberately left for later

- **#620 (filed):** on pipes, on `/v1/ops/query`, and on `/v1/query` for
a role with no cap, a `TIMEOUT_EXCEEDED` / `MEMORY_LIMIT_EXCEEDED` /
`query_timeout` expiry is `503` and gets retried. The same codes also
come from a busy server, so there I kept the classifier's verdict.
- **Memory cap over-attribution:** when the role sets
`max_memory_usage`, a server-total `MEMORY_LIMIT_EXCEEDED` is also
answered `400`. This is documented in api.md.
- **Singleflight:** waiters that share one flight each class the shared
error by their own role's caps. This comes from the existing
role-insensitive flight key (#120 territory), not from this delta.
- **Out of scope:** #539's wider `code` catalogue (discovery / policy /
ingest validation). This PR only adds `clickhouse.*`.

## Tests

- **Unit, `internal/api/ch_errors_test.go` (new):** a table of real
clickhouse-go errors, run through both `/v1/query` and pipes:
  - `*clickhouse.Exception` codes 47/53/60/62/158/159/202/241/497/516;
  - a real refused dial;
  - `clickhouse.ErrAcquireConnTimeout`;
  - pool-wait and backstop-cancel cases under a time cap;
  - an error with no verdict.

It also pins that a time-capped query context carries no deadline, and
covers the schema-refresh outage.
- **Unit, `query_test.go`:** the proxy table covers header codes and
body-only codes, a proxy with no code, the #403 repro, the credential
cases, and a real refused connection.
- **Integration, `tests/integration/query_errors_test.go` (new):**
  - Against the shared live ClickHouse:
    - raw SQL `SELEC 1` → `400 clickhouse.rejected`, not retryable;
    - `CREATE USER` → `403 clickhouse.access_denied`;
- a structured query on a column dropped behind the registry → `400`
(code 47).
- Against its own container and app: stopping ClickHouse makes both
`/v1/ops/query` and `/v1/query` answer `503 clickhouse.unavailable`,
`retryable: true`, `Retry-After: 5`.
  - The driver-contract test described under "Role time cap".
- **Updated for the new contract:**
- `query_limits_test.go`: the role caps are `400 limit_exceeded`, not
`500`;
- `app_test.go`: a verified token is told apart by the ClickHouse error
body, because a closed ClickHouse is now `503`;
  - `tenant_clickhouse_test.go`;
- e2e `tests/e2e/sdk/query.test.ts` (role caps → 400) and
`admin.test.ts` (syntax error through `wh.sql` → 400,
`clickhouse.rejected`, not retried).
- **SDK:** `errors.test.ts` and `http.test.ts`, including a 502 with
`retryable: false` that is not retried.
- **`make ci`:** passed at `f485505c`: unit, integration and e2e, all
coverage gates.

## Review

The `pre-push-reviewer` and `docs-reviewer` (opus) ran in fresh context
against the delta vs `origin/feat/ch-error-classes`.

- **Round 1 (`01ea6902`):**
- Code: `iterate`, one MUST. A bare `DeadlineExceeded` under a role time
cap was read as the cap, so a pool wait or dial timeout became a
non-retryable 400. There was also a stale AGENTS.md line.
- Docs: `iterate`, five SHOULDs: `configuration.mdx` limits, api.md cap
wording, access-control naming the three caps, the SDK table missing
`clickhouse.unknown`, and the `maxRetries` wording.
- Fixed in `4ff30f31`. The reviewer's suggested fix (slack on the
deadline) would have been overridden by the driver, so the context now
carries no deadline instead.
- **Round 2 (`4ff30f31`):** both `ship_it`.
- **Rounds 3–5 (`148a160b`, `3a314afe`, `f485505c`, test and CHANGELOG
only, after `make ci` caught stale 503/500 expectations in `app_test.go`
and `query_limits_test.go`):**
- Code: `iterate` once, because `app_test.go:908` could no longer fail.
Then `ship_it` at `f485505c`, after a full sweep of the test files.
- Docs reviewer: not re-run for these commits; they touch no docs prose
beyond the CHANGELOG file list.

**Review markers (#454):** in a `wt` worktree, the SubagentStop hook
writes markers into the main checkout's `tmp/`, keyed to its HEAD. So no
`tmp/<reviewer>-passed-f485505c…` exists for this branch. The verdicts
above are the record. No marker was hand-written or skipped.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
EricAndrechek added a commit that referenced this pull request Sep 26, 2026
main now carries #612 and #618 as squashes, plus #632, #622, #627,
#615, #619, #647, #616, #655 and #623. The merge was resolved against the
pre-squash #618 head (f129d57) as its base, so main's version wins for
everything this stack does not own and only the cache stack's changes
(#614, #621, #626 as merged here, and this PR) are re-applied on top.

Warnings keeps main's api-role gate for the cache.redis warnings too: a
split's Deployments differ only in roles, so the API's cover the others'.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aS7rLrH1RKkUMem7X4ckd
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/docs Documentation, site/, README area/ingest Ingest pipeline (Bento, batching, DLQ) documentation Improvements or additions to documentation go Pull requests that update go code

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

1 participant