Skip to content

clickhouse: tenant data can no longer forge an error code (#412) - #436

Merged
hshimizu merged 1 commit into
mainfrom
issue-412-exception-frame
Aug 12, 2026
Merged

clickhouse: tenant data can no longer forge an error code (#412)#436
hshimizu merged 1 commit into
mainfrom
issue-412-exception-frame

Conversation

@hshimizu

Copy link
Copy Markdown
Contributor

Closes #412.

A successful query could be delivered to the caller as a fabricated error, with its rows lost.

Measured: a query returning 30,000 rows, no server-side error at all, came back as got 0 of 30000 rows and Code: 210. DB::Exception: …. The 210 is not the server's — it is tenant data, read out of a log line.

How

extract_exception runs per chunk. Its length-slicing arm requires the chunk to end with the exception trailer, true only of the final chunk, so every other chunk fell through to a text search for Code: over bytes that are tenant data. A row ending ))\n at a chunk end was enough.

Consequences, all measured:

  • The rows are lost, not merely relabelled.
  • 210 is retryable and the real codes usually are not, so the fabrication was retried.
  • It reaches mark_unhealthy — tenant data could demote a healthy endpoint.
  • Both production shapes reach it: traces/sql.rs projects payload last, so one span payload ending ))\n needs no alignment at all; the LogQL row shape reaches it via a RowBinary varint length prefix.

The realistic innocent case is a log store holding ClickHouse's own error text — those messages end ) and contain the exact prefix the search looked for.

The fix

Reassemble the exception frame in the vendored client, anchored on its opening, scanning at any offset:

opening   \r\n__exception__\r\n<tag>\r\nCode: N. DB::Exception: …
closing   …\n<len> <tag>\r\n__exception__\r\n

The two ends run in opposite orders — a fact that cost this issue two wrong claims, both withdrawn on the issue, before anyone captured a frame and looked at the bytes.

Bytes before the anchor are emitted as data, so rows already produced still reach the caller. A frame that never closes surfaces as an error with the server's own Code: N at byte 0. extract_exception_old stays, because a tag-absent server is still reachable when PULSUS_SKIP_DDL bypasses the version gate.

Faster, too: the anchor scan costs 44.7 µs per MiB against the 223.4 µs rfind it removes.

What the review changed

The first design checked only the start of each chunk — the same shape as the defect it fixed, a check that inspects one position. A frame opening after result data in the same chunk was missed entirely. Now it scans anywhere, with a straddle check so the anchor cannot hide across a boundary.

Gates, and what they are worth

Ten hermetic client-parser gates plus five live. Against the old parser, 5 of 10 hermetic and 3 of 13 live fail; AC4 and AC13 pass either way, which is what makes the rest meaningful.

AC13 validates the mock against the real server on every CI run — a raw-TCP capture asserting the shared frame builder reproduces a live frame byte-for-byte. It fails rather than skips on a buffered response, and a one-byte drift in the builder fails it. That exists because a mock checked only against our own reading of our own code is one edit from being a false gate.

AC14 uses the discriminating fixture. The obvious shape — data, then a frame straddling a boundary — passes on the broken design, because the second block ends with the close marker and the existing extractor recovers from that alone.

Stated, not closed

  • Tag non-reuse is unestablished. ClickHouse documents no uniqueness contract; a 200-response census found 200 distinct tags but is observational and says so. A tenant storing a tag seen on an earlier response is the case this rests on.
  • No measurement shows ClickHouse emitting <data><frame opening> in one chunk — AC14 gates the parser against a constructed shape and is labelled a client-parser gate.
  • The cap path drops its buffer at 16 MiB.

PATCHES.md §2 records the re-vendor rule: re-check upstream's tag gating on every bump and drop this patch if upstream supplies it.

`extract_exception` ran `extract_exception_old`'s `rfind(b"Code:")` on any
chunk ending `))\n`, including on a response the server had tagged. Result
bytes are tenant data, so a SUCCESSFUL query whose last row ends `))\n` came
back as zero rows and a fabricated `Code: 210` — retryable, so the fabrication
was retried and demoted a healthy endpoint. Both production row shapes reach
it: `traces/sql.rs` projects `payload` last, and the LogQL row shape through a
RowBinary varint length prefix.

The tag now decides which channel is believed. On a tagged response a
streamed exception frame is reassembled — anchored on its OPENING
(`\r\n__exception__\r\n<tag>`, scanned for at any offset) — and sliced by the
server-declared length. The searching extractor survives only where the server
declared no tag, which is a pre-25.11 server or a header-stripping proxy;
deleting it there turns a real exception into a `Decode` misdiagnosis.

Gates: five live tests in `pulsus-clickhouse`'s `live_clickhouse` suite,
including a raw-socket capture that replays the shared `frame_bytes` builder
against a real streamed frame byte-for-byte on every CI run; ten hermetic
client-parser gates in a new `mock_clickhouse` suite whose LZ4 block
boundaries — and therefore the chunk splits the parser sees — are chosen; and
AC6's first-occurrence pin on `re2_reject_detail`.

`vendor/clickhouse/PATCHES.md` §2 carries the measurements, the two
assumptions the anchor rests on, the three withheld-bytes cases and the
re-vendor rule.

Claude-Session: https://claude.ai/code/session_012VVE7pDD97zY6TE5EqUZpd
@hshimizu
hshimizu merged commit b773bb6 into main Aug 12, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A user-supplied regex can choose the error code we report, on main today

1 participant