Repository navigation
fix(ingest): retry ClickHouse outages instead of dead-lettering - #619
Conversation
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>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
|
📚 Docs preview is live → https://65a8004d-wavehouse-docs.wave-rf.workers.dev
|
Code Coverage OverviewLanguages: Go GoThe overall line coverage in commit d1ef130 in the Show a line coverage summary of the most impacted files.
Updated |
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
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
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>
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>
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
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:
chconn.Rejected): unchanged. Row-by-row isolation, the rows rejected again go to the DLQ, or stay unacked withdlq.enabledoff.TOO_MANY_PARTS,MEMORY_LIMIT_EXCEEDED:chconn.Splittable): the same row-by-row isolation first.TOO_MANY_PARTSis also what ClickHouse answers for one INSERT spanning more thanmax_partitions_per_insert_blockpartitions: 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 butRejected(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).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'smaxAckPending(see below), and their successes don't close its backoff. The retries are counted bywavehouse_ingest_retries_total{table, reason}. An outage is logged atWARNwhen it starts, at most every 30 s while it lasts, and atINFOwhen ClickHouse takes inserts again.The classifier lives in
internal/chconn/errclass.go:Classify(err) Class,ClassOfCode,ExceptionCode, andHTTPError/NewHTTPErrorfor the HTTP interface. It reads*clickhouse.Exceptionand*clickhouse.HTTPErrorfrom the driver, the HTTP interface'sX-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.
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 ofmaxAckPendingfor every table behind it, and nothing would clear it. A code added by a future ClickHouse falls into the same bucket.Unknown(retried). Examples: a bare500from 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.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_PopulatedOnIngestWorkerFailurestill pins this.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 correctedclickhouse.username, orWH_CH_PASSWORDand a restart makes the whole batch insert as it is.ACCESS_DENIEDis 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.Unavailable(names checked witherrorCodeToNameon 26.8, and againstsystem.errorson 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'smax_waiting_queries.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 tomq.max_bytes_gb, and ingest then answers503: backpressure, with nothing lost and nothing parked. The DLQ no longer fills up.NakWithDelaykeeps the message on the consumer's pending list (verified in nats-serverprocessNak), 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 to503as in an outage. A retry also does not keep arrival order; that matters only to aReplacingMergeTreewithout a version column or aCollapsingMergeTree, 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 throughnet/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, andNewHTTPErrorparsing.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:TOO_MANY_PARTSas too many partitions,MEMORY_LIMIT_EXCEEDED) is split, every row inserts, and no backoff opens; a lone row is not split again;CANNOT_PARSE_NUMBERrow is still isolated and parked;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 oldworker.go, it fails on both assertions.Review
The
pre-push-revieweranddocs-reviewersubagents (opus) ran in fresh context against this worktree, four rounds:Round 1 (
a131fb95): bothiterate. 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 useClassify,NakWithDelaymissing from themqsurface list, an incompleteDeniedlist. Fixed in8ae9614b.Round 2 (
8ae9614b): bothiterate. Code review:ACCESS_DENIEDis 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 inconfig.json(it isWH_CH_PASSWORD, boot config), and overstated the pool key. Fixed in356125f3.Round 3 (
356125f3): codeship_it; docsiterate, because the README, landing page, why page and architecture diagram still said failed inserts go to the DLQ. Fixed ine97edc8b.Round 4 (
e97edc8b): bothship_it.Round 5 (
c70d3324..d1ef1302): the size split forTOO_MANY_PARTS/MEMORY_LIMIT_EXCEEDED,SERVER_OVERLOADED, and doc corrections. Code review ofc70d3324:ship_it, no findings. Docs review took nine passes to reachship_itond1ef1302. Along the way it fixed:dedupe.enabledcatches;FINALadvice where a user chooses an engine;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 withscripts/skip-pre-push-review.sh, and each reason names the run.docs-reviewerran on HEAD.pre-push-reviewerran onc70d3324; everything after it is docs prose and two doc-comment rewordings.Review markers (#454): the
review-marker.shSubagentStop hook writes to$CLAUDE_PROJECT_DIR/tmp, keyed to that checkout's HEAD (the main checkout, onmain), so notmp/<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)
/v1/queryand/v1/ops/queryfailures throughchconn.ClassifyandExceptionCode.Rejected→ 4xx (ACCESS_DENIED→ 403),Unavailable→ 503/502 withretryable: true. The classifier is exported for that.Seams left for workstream C
IngestWorker.backoffs, keyed bychconn.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).flushTablehands back exactly the unsettled rows throughretryLater. Any batching/claiming scheme that owns acks can keep that contract.tableBatcher.addasks the backoff before buffering. A per-tenant consumer (feat(mq): give every tenant a queue of its own #612) could pause that tenant'sConsumehere instead of nak'ing arrivals.🤖 Generated with Claude Code
https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
https://claude.ai/code/session_01LYbFK7Na4RTNZxzL3h9Hs9