fix(clickhouse): cancel reads the client has abandoned - #33
fix(clickhouse): cancel reads the client has abandoned#33lohanidamodar wants to merge 3 commits into
Conversation
Reads carried neither `max_execution_time` nor a `query_id`, so when the transport socket timed out the ClickHouse query kept running to completion and every user retry stacked another copy of it onto the cluster. Reads now run under a server-side execution cap (`readTimeout`, default 25s, below the 30s socket timeout of both bundled utopia-php/client adapters) and carry a query_id, and a read that still fails is reaped with `KILL QUERY ... ASYNC`. The write path is left uncapped: starving ingest is worse than a slow read. A server that refuses the settings (`readonly = 1`, older builds) degrades to an uncapped read, and a failed KILL never masks the error the caller is already surfacing.
Greptile SummaryThe PR adds server-side execution limits and query IDs to ClickHouse reads, then attempts asynchronous server-side cancellation when a transport abandons a query.
Confidence Score: 5/5The PR appears safe to merge because the previously reported query-ID ownership and unnecessary outage-cleanup issues are resolved. No blocking failure remains. Important Files Changed
Reviews (3): Last reviewed commit: "docs(readme): document the ClickHouse re..." | Re-trigger Greptile |
Threading the generated id through the adapter-wide `nextQueryId` slot left it readable across a request, so under the documented concurrent-coroutine sharing model another read could adopt it and be cancelled in this read's place. The id is now taken from the slot once and passed to `query()` explicitly, and the KILL carries its own id so it never consumes one pinned for another read. Also skip the KILL when the connection never opened: there is no query to reap, and the request would only burn a second socket timeout against a host that is already down.
|
Closing in favour of handling this server-side. |
Problem
In nyc3 prod on 2026-08-30,
/v1/usage/eventsbreakdown requests for one largetenant returned 500 after exactly ~30.0s, ~495/hour:
30.0s is
utopia-php/client'sDEFAULT_TIMEOUT, which is the socket timeouton both bundled adapters.
The amplifier is what this PR fixes: the read path sent no
max_execution_timeand no
query_id.setNextQueryId()existed but was only ever called frombenchmarks and routing tests, and no
SETTINGSclause was emitted on reads. Sowhen the socket died at 30s, the ClickHouse query kept running to completion,
and each user reload stacked another one. Measured blast radius during the burst
(Prometheus): 23.0M rows/s, 1.33 GB/s, 0.91 normalised CPU — 90% of all cores —
~14.6B rows read in 35 minutes, with no comparable excursion in the prior 14 days.
Change
Reads only:
max_execution_timeplustimeout_overflow_mode = throw. The cap defaults to 25s — deliberatelybelow the 30s client socket timeout, so ClickHouse aborts the query itself
and returns a clean
TIMEOUT_EXCEEDEDinstead of the socket dying with thequery still live. Configurable via the new
readTimeoutconstructor argument;nulldisables it.throwis pinned because a profile defaulting tobreakwould hand back a silently truncated result set, and these are usage totals.
query_idper read, reusing the existingsetNextQueryId(). Its contractis single-use-then-cleared, and benchmarks/routing tests pin an id to correlate
against
system.query_log— so a caller-pinned id is honoured and only anunpinned read gets a generated one.
KILL QUERY WHERE query_id = {queryId:String} ASYNC. ASYNC so it returnswithout waiting for the query to stop; every failure from it is swallowed so a
missing KILL privilege can't mask the real error.
Deliberately not applied to the write/ingest path — starving inserts is worse
than a slow read.
purge()and all DDL keep going through the plainquery().Degradation: a server that refuses the settings (
readonly = 1user →Code: 164,or an older build →
Code: 115) retries the read once without them and stopstrying for the life of that adapter instance.
Verification
Run against a real ClickHouse 26.9.1 through the actual adapter.
(a) The cap aborts the read server-side (
readTimeout: 2):(b)
KILL QUERYreaps a query the client abandoned. Control first — cap andquery_id disabled, client socket timeout 3s (today's behaviour):
Same 3s socket death with the fix:
(c) Settings and query_id actually reach ClickHouse on the public read path,
and the write path stays uncapped (
system.query_logafterfind()+addBatch()):(
timeout_overflow_modedoes not appear inSettingsbecausethrowis theserver default and query_log only records settings that differ from it. That it
is honoured at all is shown by
?max_execution_time=1&timeout_overflow_mode=breakreturning HTTP 200 where
throwreturns HTTP 408, and bytimeout_overflow_mode=nonsensefailing withCode: 145 ... Must be one of ['break', 'throw'].)(d)
readonly = 1user degrades instead of breaking:Tests
ClickHouseReadTimeoutTest(integration): the cap and a generated 32-hexquery_id land in
system.query_log; a caller-pinned id survives;readTimeout: nullsends no cap; the insert path is not capped; a slow read is aborted bythe cap; a read abandoned by a 1s client socket leaves nothing in
system.processes; a non-positivereadTimeoutis rejected.ClickHouseReadCancellationTest(hermetic, scripted PSR-18 transport): the KILLis issued for the right query_id; a failing KILL does not mask the read error;
an uncapped read is never cancelled; a
Code: 164rejection falls back to anuncapped read and stops re-trying the settings.
Review follow-ups
query_idownership (b5f08f8). The generated id was staged in theadapter-wide
nextQueryIdslot and read back byquery(), so it stayedvisible across
random_bytes()and the multipart build — both hookable I/Ounder Swoole. A concurrent read could adopt it and be cancelled in this read's
place. The id is now taken from the slot in one read-and-clear step
(
consumeNextQueryId()), passed toquery()explicitly, and theKILLgetsits own id so it never consumes one pinned for another read.
KILLwhen the connection never opened (b5f08f8). A dead host wouldotherwise cost a second socket timeout on top of the first. Both bundled
transports classify that as
ConnectionException/DnsException, distinct fromthe
TimeoutExceptionthis PR targets, so cancellation is skipped there.A
KILLis still sent after a server-returned error: it costs one cheapmetadata request and covers a gateway timing out in front of ClickHouse while
the query is still live.