clickhouse: tenant data can no longer forge an error code (#412) - #436
Merged
Conversation
`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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 rowsandCode: 210. DB::Exception: …. The 210 is not the server's — it is tenant data, read out of a log line.How
extract_exceptionruns 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 forCode:over bytes that are tenant data. A row ending))\nat a chunk end was enough.Consequences, all measured:
mark_unhealthy— tenant data could demote a healthy endpoint.traces/sql.rsprojectspayloadlast, so one span payload ending))\nneeds 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:
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: Nat byte 0.extract_exception_oldstays, because a tag-absent server is still reachable whenPULSUS_SKIP_DDLbypasses the version gate.Faster, too: the anchor scan costs 44.7 µs per MiB against the 223.4 µs
rfindit 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
<data><frame opening>in one chunk — AC14 gates the parser against a constructed shape and is labelled a client-parser gate.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.