Summary
In the HTTP driver, a fully materialized query response (ch_http_response_t, raw malloc/calloc memory from the curl layer) has exactly one deallocation path: ch_http_response_free(), invoked either inline on explicit error branches or via a MemoryContextResetCallback once the response is attached to a cursor. In http_simple_query() there is a window — after ch_http_simple_query() returns and before MemoryContextRegisterResetCallback() runs — in which any ereport(ERROR) longjmps past every free. Because the response is plain malloc memory owned by no memory context, it is leaked for the lifetime of the backend. All throw points in this window are allocation failures, so the bug triggers precisely under memory pressure and compounds it. The explicit error branches have the same defect in miniature: each performs a throwing palloc before its ch_http_response_free() call.
The streaming path (http_streaming_query) is not affected — it wraps the equivalent window in PG_TRY/PG_CATCH and its error-reporting helper uses PG_FINALLY to end the stream. The binary driver is also not affected as a leak: its response memory is palloc-backed in a dedicated context (src/binary/select.c, pg_chc_alloc in src/binary/binary.c).
All references below are pinned to commit 94256f0.
Where the memory comes from and how it is freed
src/http.c:205 — resp = calloc(1, sizeof(*resp));
src/http.c:216 — ch_http_stream_take_body(stream, &resp->data, &resp->datasize);
src/http_streaming.c:543-554 — take_body contract: "On return, *out_data is a malloc()'d buffer the caller must free()." The buffer is malloc'd at src/http_streaming.c:422 and grown with realloc at :200.
src/http.c:244-250 — ch_http_response_free() is the sole free path: free(resp->data); free(resp);
- Because
ch_http_simple_query buffers the whole result (fetch_size = INT32_MAX, src/http.c:201), resp->data is the entire result set, i.e. arbitrarily large.
The code is explicit that context callbacks are the intended cleanup mechanism (src/pglink.c:307-309):
/*
* we could not control properly deallocation of libclickhouse memory, so
* we use memory context callbacks for that
*/
The leak window in http_simple_query (src/pglink.c:249-331)
resp exists as untracked malloc memory from line 261 (resp = ch_http_simple_query(conn, query);) until line 327 (MemoryContextRegisterResetCallback(tempcxt, &cursor->callback);), which is the first point at which an abort would free it (via http_cursor_free, src/pglink.c:368-372). There is no PG_TRY in this function. Every statement below can raise ERROR (out of memory) while resp is unowned:
Happy path, lines 311-322:
| Line |
Call |
Throws on |
| 311 |
AllocSetContextCreate(PortalContext, "pg_clickhouse cursor", ...) |
OOM |
| 315 |
cursor = palloc0(sizeof(ch_cursor)); |
OOM |
| 318 |
cursor->read_state = palloc0(sizeof(ch_http_read_state)); |
OOM |
| 319 |
cursor->query = pstrdup(query->sql); |
OOM |
| 322 |
ch_http_read_state_init(...) → initStringInfo (src/parser.c:44-45), pallocs 1 KB |
OOM |
A throw at any of these leaks resp (struct + full response body) permanently.
Explicit error branches — free happens after a throwing palloc:
- Transport error, lines 272-274:
char *error = pnstrdup(resp->data, resp->datasize); runs before ch_http_response_free(resp);. This duplicates the entire buffered body (for a mid-transfer failure, potentially a large partial result) into palloc memory first; if pnstrdup throws, resp leaks. This branch is retried up to 3 times per query.
- Canceled query, lines 285-286:
kill_query(conn, resp->query_id); runs before the free; kill_query (src/pglink.c:189-200) allocates via psprintf/ch_quote_literal and can throw.
- Non-200 status, lines 294-297: same
pnstrdup-before-free pattern.
- Same pattern in
http_simple_insert, src/pglink.c:352-355.
Observable impact
- Each occurrence permanently leaks a complete materialized ClickHouse response body (unbounded size) plus the response struct from the backend's address space; nothing reclaims it short of backend exit.
- The trigger condition for the happy-path window is an allocation failure, so the leak fires exactly under memory exhaustion and makes it worse — a feedback loop.
- The transport-error branch additionally doubles peak memory (
pnstrdup of the full buffered body) before freeing, on every retry, under exactly the conditions where responses are failing.
- In production, this class of behavior was observed during a committed-memory runaway while the remote ClickHouse endpoint was degraded (socket read timeouts and TLS errors), which repeatedly exercises the transport-error branch.
Summary
In the HTTP driver, a fully materialized query response (
ch_http_response_t, rawmalloc/callocmemory from the curl layer) has exactly one deallocation path:ch_http_response_free(), invoked either inline on explicit error branches or via aMemoryContextResetCallbackonce the response is attached to a cursor. Inhttp_simple_query()there is a window — afterch_http_simple_query()returns and beforeMemoryContextRegisterResetCallback()runs — in which anyereport(ERROR)longjmps past every free. Because the response is plain malloc memory owned by no memory context, it is leaked for the lifetime of the backend. All throw points in this window are allocation failures, so the bug triggers precisely under memory pressure and compounds it. The explicit error branches have the same defect in miniature: each performs a throwingpallocbefore itsch_http_response_free()call.The streaming path (
http_streaming_query) is not affected — it wraps the equivalent window inPG_TRY/PG_CATCHand its error-reporting helper usesPG_FINALLYto end the stream. The binary driver is also not affected as a leak: its response memory is palloc-backed in a dedicated context (src/binary/select.c,pg_chc_allocinsrc/binary/binary.c).All references below are pinned to commit
94256f0.Where the memory comes from and how it is freed
src/http.c:205—resp = calloc(1, sizeof(*resp));src/http.c:216—ch_http_stream_take_body(stream, &resp->data, &resp->datasize);src/http_streaming.c:543-554— take_body contract: "On return, *out_data is a malloc()'d buffer the caller must free()." The buffer ismalloc'd atsrc/http_streaming.c:422and grown withreallocat:200.src/http.c:244-250—ch_http_response_free()is the sole free path:free(resp->data); free(resp);ch_http_simple_querybuffers the whole result (fetch_size = INT32_MAX,src/http.c:201),resp->datais the entire result set, i.e. arbitrarily large.The code is explicit that context callbacks are the intended cleanup mechanism (
src/pglink.c:307-309):The leak window in
http_simple_query(src/pglink.c:249-331)respexists as untracked malloc memory from line 261 (resp = ch_http_simple_query(conn, query);) until line 327 (MemoryContextRegisterResetCallback(tempcxt, &cursor->callback);), which is the first point at which an abort would free it (viahttp_cursor_free,src/pglink.c:368-372). There is noPG_TRYin this function. Every statement below can raiseERROR(out of memory) whilerespis unowned:Happy path, lines 311-322:
AllocSetContextCreate(PortalContext, "pg_clickhouse cursor", ...)cursor = palloc0(sizeof(ch_cursor));cursor->read_state = palloc0(sizeof(ch_http_read_state));cursor->query = pstrdup(query->sql);ch_http_read_state_init(...)→initStringInfo(src/parser.c:44-45), pallocs 1 KBA throw at any of these leaks
resp(struct + full response body) permanently.Explicit error branches — free happens after a throwing palloc:
char *error = pnstrdup(resp->data, resp->datasize);runs beforech_http_response_free(resp);. This duplicates the entire buffered body (for a mid-transfer failure, potentially a large partial result) into palloc memory first; ifpnstrdupthrows,respleaks. This branch is retried up to 3 times per query.kill_query(conn, resp->query_id);runs before the free;kill_query(src/pglink.c:189-200) allocates viapsprintf/ch_quote_literaland can throw.pnstrdup-before-free pattern.http_simple_insert,src/pglink.c:352-355.Observable impact
pnstrdupof the full buffered body) before freeing, on every retry, under exactly the conditions where responses are failing.