Skip to content

Add clickhouse-wsproxy: standalone WebSocket proxy (prototype)#4

Draft
peter-leonov-ch wants to merge 46 commits into
masterfrom
wsproxy-skeleton
Draft

Add clickhouse-wsproxy: standalone WebSocket proxy (prototype)#4
peter-leonov-ch wants to merge 46 commits into
masterfrom
wsproxy-skeleton

Conversation

@peter-leonov-ch

Copy link
Copy Markdown
Owner

Prototype of a standalone WebSocket proxy built on the ClickHouse native-protocol client layer.

clickhouse-wsproxy is a separate binary, deliberately kept out of the multi-call clickhouse dispatch (its own clickhouse_add_executable, no mainEntryClickHouseWsProxy, no clickhouse_program_install line). It reuses Connection and FormatFactory while dropping the CLI/ClientBase layer, and serves a WebSocket endpoint through ClickHouse's own HTTPServer.

Motivation is deployment velocity: run format conversion and mid-query push (Progress/ProfileEvents/Log) at an edge sidecar instead of requiring managed-cloud changes.

Scope so far (steps 1-2): the standalone build wiring is proven, the global Context and registerFormats are bootstrapped, the RFC 6455 handshake and framing are factored into a reusable module (adapted from WebTerminalRequestHandler), and SIGINT/SIGTERM give a clean shutdown. The WebSocket loop currently echoes frames; the next step replaces it with a native-protocol Connection to a backend server plus edge format conversion.

Note: this targets my fork, not upstream — intentionally kept off the upstream CI and review burden while it is still a prototype.

Changelog category (leave one):

  • Experimental Feature

Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):

Added clickhouse-wsproxy, an experimental standalone WebSocket-to-native-protocol proxy (prototype).

peter-leonov-ch and others added 11 commits July 8, 2026 20:11
Document the plan for `clickhouse-wsproxy`: a standalone WebSocket-to-native-protocol proxy that reuses `Connection` and `FormatFactory` but drops the CLI/`ClientBase` layer, deployed as an edge sidecar for format conversion and mid-query push. Records the design decisions, the build-wiring approach, measured binary size, and the step-by-step plan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Factor the WebSocket frame primitives into a standalone module, adapted from `WebTerminalRequestHandler`: the handshake accept-key (`computeWebSocketAccept`), masked-read / unmasked-write framing (`readWebSocketFrame`, `sendWebSocketFrame`), fragmentation, control frames, and a payload-size cap. The proxy session loop will drive these primitives directly, so they live apart from the HTTP handler.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduce `clickhouse-wsproxy`, a separate executable deliberately kept out of the multi-call `clickhouse` dispatch: it has its own `clickhouse_add_executable`, no `mainEntryClickHouseWsProxy` in `programs/main.cpp`, and no `clickhouse_program_install` line. This keeps the diff to shared dispatch code at zero (mirrors the `BUILD_STANDALONE_KEEPER` pattern). Linking `dbms` is unavoidable, so the binary is roughly the size of the full `clickhouse` binary.

At startup it creates the global `Context` and calls `registerFormats`, then serves a WebSocket endpoint through ClickHouse's own `HTTPServer`: non-upgrade requests get an info page; a WebSocket upgrade completes the RFC 6455 handshake and then echoes frames (a placeholder for the native-protocol session loop added later).

`SIGINT`/`SIGTERM` are blocked in the main thread before the server spawns worker threads, so the workers inherit the mask and Poco's `waitForTerminationRequest` consumes the signal via `sigwait`, giving a clean shutdown instead of the default terminate disposition firing on a worker thread.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Local scratch space (build/test logs, throwaway scripts) lives in `tmp/` per the project conventions; keep it out of version control.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the echo loop with `ProxySession`, which opens one `Connection` per WebSocket session (1:1) to a backend server (from `WSPROXY_BACKEND_*` env vars, default `localhost:9000`). A query arrives as a text frame; the result streams back as binary frames encoded with the output format from the URL `?format=` parameter (default `JSONEachRow`), one flush per block via `FormatFactory::getOutputFormat` and `IOutputFormat::write`/`flush`. Completion, backend exceptions, and cancellation are reported as JSON control frames (`end` / `error` / `cancelled`).

A Close frame observed in the interleaved `Connection::poll` / socket poll loop triggers `Connection::sendCancel` and drains the backend to `EndOfStream`. `WriteBufferToWebSocket` latches a `broken` flag instead of throwing from `nextImpl` when the client is gone, so the buffer is always finalized (clean result) or canceled (error/aborted) with no teardown warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rmat

`ProxySession::executeQuery` now best-effort parses the query with `ParserQuery` and dispatches: an `ASTInsertQuery` goes to `executeInsert`, everything else to the existing SELECT path. For an INSERT the app sends `INSERT INTO t [FORMAT X]` as a text frame and then streams data as binary frames terminated by a zero-length binary frame; the proxy parses it with the input format (`FORMAT` clause, else `Values` for inline data, else the URL `?format=`) over a `ReadBufferFromWebSocket`, pulls blocks with a `PullingPipelineExecutor`, and `sendData`s them, then sends an empty block to finish. Inline `INSERT ... VALUES (...)` data is handled from the query text.

The native INSERT handshake requires `with_pending_data=true` on `sendQuery` and an (empty) `sendExternalTablesData` before the server returns the sample block; omitting either deadlocks. A Close frame mid-stream aborts via `sendCancel` instead of committing partial data. `ReadBufferFromWebSocket` latches a `finished` flag because `ReadBuffer::next` may call `nextImpl` again after EOF, which would otherwise block on a frame that never arrives.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Each server `Progress` packet in the SELECT path is forwarded as a JSON text frame (`{"event":"progress","read_rows":...,"read_bytes":...,"total_rows_to_read":...}`). Server progress is incremental, so reads are accumulated and the latest total estimate is tracked, and running totals are pushed. Text frames do not disturb the binary result stream, so clients skip `progress` events until the terminal `end`/`error`/`cancelled`. This delivers the mid-query push that motivates the proxy over the plain HTTP interface.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A self-contained JavaScript test suite (target audience: JS engineers consuming the WebSocket API) under `programs/wsproxy/tests`, using Node's built-in global `WebSocket` and Vitest. `test/globalSetup.mjs` launches the backend and the proxy if not already running, creates the shared `default.wsp_test` table, and tears down what it started; `test/helpers.mjs` wraps the WS protocol as a small `Session` client.

Covers SELECT (formats, errors, large streamed results, multi-query sessions), INSERT (streamed JSONEachRow/TSV, inline VALUES, format inference, errors, verified against server-side counts), mid-query progress push, and cancellation (closing the socket stops the query server-side). 15 tests across 4 files. `node_modules` is git-ignored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@peter-leonov-ch

Copy link
Copy Markdown
Owner Author

Using the proxy from JavaScript

The test suite ships a tiny WebSocket client that doubles as a usage reference — Session, runQuery, and backendScalar are defined in programs/wsproxy/tests/test/helpers.mjs, added in commit 2e7522149b0. They use Node's built-in global WebSocket (Node ≥ 22), so there are no runtime dependencies.

The proxy listens on ws://127.0.0.1:9010. A query is sent as a text frame; results stream back as binary frames in the output format chosen via the URL ?format= parameter; progress and the terminal outcome arrive as JSON text frames ({"event":"end"|"error"|"cancelled"|"progress", ...}).

SELECT

import { runQuery } from "./helpers.mjs";

const { text, control } = await runQuery("SELECT number FROM numbers(5)", {
  format: "JSONEachRow",
});

console.log(control.event); // "end"
console.log(text);          // 5 lines of JSONEachRow

runQuery returns { data: Buffer, text: string, progress: object[], control: { event, message? } }.

Output-format conversion at the edge

(await runQuery("SELECT 42, 'x'", { format: "TSV" })).text; // "42\tx\n"
(await runQuery("SELECT 42, 'x'", { format: "CSV" })).text; // "42,\"x\"\n"
(await runQuery("SELECT 42 AS n", { format: "JSONEachRow" })).text; // {"n":42}

Mid-query progress push

const { progress, control } = await runQuery(
  "SELECT sleepEachRow(0.1), number FROM numbers(30) SETTINGS max_block_size = 1",
);

console.log(control.event);      // "end"
console.log(progress.length);    // several updates pushed while the query ran
console.log(progress.at(-1));    // { event: "progress", read_rows: 30, read_bytes: …, total_rows_to_read: 30 }

INSERT (streaming data to the backend)

import { Session } from "./helpers.mjs";

const s = new Session("JSONEachRow");
const { control } = await s.insert(
  "INSERT INTO default.events FORMAT JSONEachRow",
  ['{"a":1,"b":"x"}\n', '{"a":2,"b":"y"}\n'], // streamed as binary frames
);
console.log(control.event); // "end"
s.close();

Inline VALUES works too (the Values input format is inferred):

await new Session().insert("INSERT INTO default.events VALUES (10, 'v'), (11, 'w')", []);

Cancellation

Closing the socket mid-query cancels the running query server-side:

const s = new Session("JSONEachRow");
await s.ready();
s.sendQuery("SELECT sleepEachRow(0.2), number FROM numbers(1000) SETTINGS max_block_size = 1");

// read a frame or two…
await s.nextFrame();

s.close(); // -> proxy calls Connection::sendCancel; the backend stops the query

Error handling

Backend errors are delivered as a terminal control frame rather than a broken stream:

const { control } = await runQuery("SELECT * FROM default.no_such_table");
// control.event === "error", control.message === "DB::Exception: … Unknown table …"

Errors / verifying server state

backendScalar(sql) runs a query as TSV and returns the trimmed text — handy for assertions:

import { backendScalar } from "./helpers.mjs";
await backendScalar("SELECT count() FROM default.events"); // "2"

The Session/runQuery/backendScalar API above is defined in commit 2e7522149b0; run the suite with cd programs/wsproxy/tests && npm install && npm test.

peter-leonov-ch and others added 18 commits July 9, 2026 18:00
`executeSelect` forwards `Log` and `ProfileEvents` packets via `sendBlockEvent`, which serializes the block to JSONEachRow and splices the rows into `{"event":"log"|"profile_events","rows":[...]}`. Reusing the output format keeps this correct for whatever columns/types the server sends.

`ProfileEvents` arrive automatically; `Log` delivery needs `send_logs_level`, which the backend reads from the query packet's settings (a query-text SETTINGS clause is too late). So a new session parameter `?logs=<level>` is threaded through the handler into `ProxySession` and applied to a mutable `Settings` copy in `sendBackendQuery`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…elper

The helper's `collect()` now returns { data, text, progress, logs, profileEvents, control } and treats only end/error/cancelled as terminal; `Session`/`runQuery`/`urlFor` accept a `logs` option (`?logs=`). New `logs.test.mjs` covers log push via `?logs=trace`, no logs at the default level, and profile events during a slow query.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Integration tests over the type matrix revealed that `AggregateFunction`-typed columns fail with "Unknown aggregate function … while receiving packet": the proxy called only `registerFormats()`, but deserializing an `AggregateFunction` off the native wire needs the aggregate-function registry. Call `registerFunctions()` and `registerAggregateFunctions()` at startup (as `clickhouse-client` does) and link `clickhouse_aggregate_functions`.

Also make the listen port configurable via `WSPROXY_PORT` (default 9010) so multiple proxy instances can run, e.g. to point one at a dead backend in resilience tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds vitest suites covering the data-type matrix (Nullable/LowCardinality/Array/Map/Tuple/Decimal/DateTime64/Enum/UUID/IP/JSON/Dynamic/Variant/AggregateFunction, SELECT + INSERT round-trips), the output-format matrix (RowBinary exact bytes, JSON/JSONCompact, Values, Pretty, Native, Parquet/Arrow magic, unknown-format error), concurrency (40 parallel sessions, slow-query fan-out, 100-query session longevity, 1M-row streaming), and resilience (session reuse after error, health after cancel, unreachable-backend error via a second proxy instance). 59 tests total, all green. Helpers gain a `baseUrl` option and a `spawnProxy` utility (test/proc.mjs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
adversarial.test.mjs uses a raw-TCP WebSocket client (test/raw.mjs) — the native global WebSocket only emits well-formed frames — to verify the proxy rejects unmasked frames, reserved opcodes, RSV bits, and oversized-advertised frames with a clean close, answers pings with pongs, and survives malformed input (fresh clients keep working).

backend-failure.test.mjs spawns its own backend+proxy (test/proc.mjs `spawnBackend`) and kills the backend mid-query: the proxy delivers a prompt error/close (not a 20s hang) and the process survives. large-insert.test.mjs round-trips 500k rows (JSONEachRow, plus single-frame and TSV) against server-side count/sum.

Killing clickhouse-server in tests requires `lsof -ti tcp:<port>` — its watchdog fork survives signalling the spawned pid, the process group, and pkill on the config path. 72 tests total, all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Covers the error paths not previously exercised: an exception delivered AFTER partial results have streamed (rows 0-4 then the error), session reuse after a mid-stream exception, malformed and mistyped INSERT data (aborts via sendCancel with zero rows committed), faithful ClickHouse message/code preservation, and session health after an INSERT error. All pass — the proxy's exception handling is correct; no code changes needed.

Notes a feature gap for later: INSERT-side progress (written_rows) is not forwarded (executeInsert ignores Progress packets). 78 tests total, all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
edge.test.mjs: empty result sets, LIMIT 0, DDL, many-columns-with-NULLs/arrays, and empty-string/unicode INSERT round-trips. insert-flow.test.mjs: closing the socket mid-INSERT aborts via sendCancel with zero rows committed, and the proxy stays healthy for a subsequent clean insert.

Also investigated INSERT written-rows progress: the native protocol emits no Progress packets for client-data INSERTs (0 events even for 1M rows), so the forwarding was reverted rather than shipped untested. Noted a separate limitation: no-client-data INSERTs (INSERT ... SELECT / FROM INFILE) would make executeInsert wait for data that never arrives. 85 tests total, all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The proxy resolves per-session backend credentials from the WebSocket upgrade request and opens the backend Connection as that user, delegating authN/authZ to ClickHouse. Priority: Authorization: Basic, then X-ClickHouse-User/-Key headers, then ?user=/?password= URL params, else the configured WSPROXY_BACKEND_* defaults. A per-session BackendParams copy is used (the shared default is never mutated); bad credentials surface as the backend's auth error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
auth.test.mjs covers default-user, ?user=/?password= URL params, Authorization: Basic, and X-ClickHouse-User/-Key headers (header cases via the raw-TCP client, since native/browser WebSocket cannot set request headers), plus wrong-password rejection. Helpers gain user/password URL options and raw handshake header support.

globalSetup now generates its own backend config (default + a password-protected wsp_user) via spawnBackend/spawnProxy instead of depending on the gitignored tmp/ch config, so the suite is reproducible from a clean checkout. 91 tests total, all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ProxySession::run now calls Connection::forceConnected before the query loop, so authentication (performed during the native handshake) is validated up front. On failure the client gets an error control frame plus a 1008 (policy violation) WebSocket close before sending any query, instead of the error surfacing only on the first query. Added an auth test asserting the rejection arrives with no query sent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Client->proxy TLS is deprioritized for now: the proxy is a sidecar next to the app, so that leg is typically loopback / in-pod or TLS-terminated at the ingress. proxy->backend TLS stays the priority leg since it crosses the network to the (remote/managed) ClickHouse.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WSPROXY_BACKEND_SECURE=1 opens the backend Connection over the native secure protocol (Protocol::Secure::Enable). For self-signed / dev certs, WSPROXY_BACKEND_ACCEPT_INVALID_CERT=1 sets openSSL.client.invalidCertificateHandler=AcceptCertificateHandler and verificationMode=none in the app config, which Poco's SSLManager reads when lazily building the default client context (mirrors clickhouse-client --accept-invalid-certificate). BackendParams gains a per-session secure flag.

This encrypts the leg that crosses the network to the (remote/managed) ClickHouse. Production follow-up: CA-based verification instead of accept-invalid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
tls.test.mjs stands up its own TLS-enabled ClickHouse (spawnBackend({securePort}) generates a self-signed cert and <openSSL><server> config) and a secure proxy (spawnProxy secure/acceptInvalidCert), verifying a TLS round-trip plus credential pass-through and wrong-password rejection over TLS.

globalSetup now uses a high private backend port (19000) instead of 9000, so the suite no longer accidentally reuses an unrelated ClickHouse a developer is running (e.g. an OrbStack container on 9000/9100). 94 tests total, all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Backpressure is already correct and bounded (blocking write -> session stops reading the backend -> server throttles via TCP; per-session memory is one block + the 1MB buffer + kernel socket buffers). The gap was liveness: the handler set only a receive timeout, so a client that stopped reading entirely blocked sendBytes forever and pinned a handler thread (pool of 16). Add setSendTimeout (default 30s, env WSPROXY_CLIENT_SEND_TIMEOUT_SEC); a zero-progress write then throws and routes through WriteBufferToWebSocket (broken -> sendCancel) to a clean teardown. It is per-write, so a slow-but-progressing client is unaffected.

Tested in backpressure.test.mjs: a client that pauses reading a large result is dropped ~1s later and the proxy stays healthy for the next client (RawClient gains pause/resume; spawnProxy gains a sendTimeoutSec option). 95 tests total, all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The proxy's TCP backpressure does not protect event-based WebSocket clients: browser/Node WebSocket eagerly drains the socket into the JS heap with no receive-backpressure API, so streaming a large result to a slow consumer can OOM the client. Decision: keep the proxy push-only (no opt-in windowing for now) and address it client-side. Documented guidance in the tests README: prefer WebSocketStream (real transport backpressure), pause the underlying ws socket in Node, or keep results bounded for plain browser WebSocket. Recorded the analysis and the (unimplemented) opt-in credit/window design in TODO.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
?flow=N enables frame-based flow control with N frames of initial credit; without it, push mode is unchanged (unbounded). The client grants credit with {"cmd":"next","n":N} and can {"cmd":"pause"}/{"cmd":"resume"}; the proxy sends a binary data frame only while not paused and credit>0. The gate lives in WriteBufferToWebSocket::nextImpl, which blocks reading the client's control frames when starved -> it also stops reading the backend, so backpressure propagates to the server via TCP, paced by client consumption.

A frame is one WS binary message (one block flush), so credit needs no byte repacking. Only binary data frames are gated; progress/log/profile_events/end/error text frames flow freely. One applyControlFrame parses control frames for both the between-packets poll and the gate. Bounded by the existing receive timeout (no new hang). Lets event-based JS clients (which cannot apply receive backpressure) bound their buffering; INSERT direction is client-handled.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Session gains next(n)/pause()/resume() and a flow option (?flow=N); nextFrame gains an optional timeout (no frame lost on timeout). flow.test.mjs verifies the credit window (exactly N frames then a stall, resumed by next), pause/resume, and that push mode without ?flow stays unbounded. README documents ?flow=N usage alongside the WebSocketStream / ws-socket-pause alternatives. 98 tests total, all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…idance

Forwarding control frames while data is paused was considered and rejected: the backend is a single ordered stream with control interleaved behind data, so forwarding control past a gated data frame needs (bounded) read-ahead buffering of the data, for a narrow payoff — and it is largely moot since control already flows during credit throttling and the backend produces ~none during a full stall. Documented the finding in TODO.md and added client guidance in the tests README: throttle with small next() credits rather than an indefinite pause(); a fully-paused client won't see progress/errors/end until it resumes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
peter-leonov-ch and others added 17 commits July 10, 2026 21:19
Investigated the claimed INSERT ... SELECT hang: it is NOT a bug. INSERT-SELECT, inline INSERT ... VALUES, and DDL all return end and the proxy stays healthy; the only hang is a client that sends INSERT ... FORMAT X and never streams the promised data (incomplete client protocol). Documented the firm principle that the proxy must never parse SQL (the ParserQuery-based INSERT/SELECT routing is to be removed), why the native protocol cannot signal query kind pre-send (with_pending_data precedes any response; the first Data packet is ambiguous), and the planned parser-free redesign: default with_pending_data=false for all queries, with streamed inserts opting in via an explicit client control message.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove the ParserQuery/ASTInsertQuery-based routing (the proxy must never parse SQL). run() now dispatches by message envelope: a raw text frame is a plain query -> executeSelect with with_pending_data=false (handles SELECT / INSERT-SELECT / inline INSERT / DDL, and never waits for client data, so the bare-FORMAT-insert hang is gone); a top-level {"cmd":"insert","query":...,"format":...} control message opts into executeInsert (streamed data phase). parseInsertCommand inspects only the JSON envelope (cmd), never SQL. executeInsert simplified: no inline-data handling, always reads the streamed WS frames, input format from the command (or the session ?format=).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…se routing

Session.insert now declares a streamed insert with a {"cmd":"insert",...} control message (added Session.beginInsert(query,{format}); insert() takes an optional input format). Tests updated: streamed inserts pass an explicit format where it differs from the session (TSV), the mid-stream cancel test uses beginInsert, and new cases assert INSERT-SELECT and inline VALUES run as plain queries. README documents the message-kind routing and the streamed-insert control message. 100 tests, all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
By default the proxy still never parses SQL. With ?parse=1 the client asks the proxy to parse each query in one place (classifyQuery): it pushes a non-terminal {"event":"query","kind":"insert"|"query","verb":"<LEADING KEYWORD>"} frame (clients frequently need the verb and the routing decision) and auto-routes a streamed-data INSERT (INSERT ... [FORMAT X] with no SELECT source, no INFILE and no inline data) so the client need not send the {"cmd":"insert"} envelope. verb comes from the SQL Lexer (skips leading comments); kind is the routing decision. An explicit {"cmd":"insert"} message still wins over parsing, and a parse failure degrades to the plain-query path (the backend reports real errors) so a parser quirk can never wedge a statement.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
helpers.mjs: ?parse=1 session option; collect() captures the {"event":"query"} frame as queryInfo. parse.test.mjs: 9 cases — SELECT/INSERT-SELECT/inline-VALUES/DDL report the right verb+kind, a bare INSERT ... FORMAT X auto-routes as a streamed insert (with the FORMAT clause as the input format), the leading verb survives leading comments, unparseable SQL degrades to the plain path, and parse-off sends no query frame. README documents ?parse=1. 109 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Harness under programs/wsproxy/bench/: bench.mjs drives the proxy over WebSocket and reports min/median wall time + throughput; baselines.sh fetches byte-identical output directly (native client, HTTP plain, HTTP gzip) with output_format_json_quote_64bit_integers=0 so the proxy's unquoted UInt64 matches; tcp_ceiling.mjs measures Node's raw TCP loopback receive ceiling. README documents how to run against a cloud or local backend and how to read WAN- vs CPU-bound results. Kept so the comparison can be re-run against upcoming changes (e.g. parallel output formatting).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
executeSelect uses FormatFactory::getOutputFormatParallelIfPossible when ?parallel=1 and flow control is off, spreading output formatting across a thread pool. The parallel formatter runs a collector thread that writes result frames concurrently with the session thread's progress/log/control frames, so all whole-frame sends are serialized behind a new ProxySession::ws_write_mutex (passed to WriteBufferToWebSocket and to the cancel-poll applyControlFrame; sendControlEvent/sendBlockEvent take it too; WriteBufferToWebSocket::broken is now atomic). Mutually exclusive with ?flow, whose credit gate reads the client socket on the session thread (flow wins when both are set). Trade-off: coarser (batched) result frames; delivered bytes are unchanged.

Also drop a per-frame alloc+copy in sendWebSocketFrame: with whole-frame sends serialized by the caller, the header and payload can be written directly (two sendAllBytes) instead of copied into a combined buffer, which for large result frames was a significant memcpy.

Loopback JSONCompactEachRow benchmarks: ~1.6-1.7x (10M rows 531->333ms, 30M 1658->972ms), reaching the cheap-format floor; the remaining serial native-receive/decompress and WS-send path is now the ceiling (~990 MB/s). Network-bound over a WAN, so no change there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
helpers.mjs: parallel session/url option. parallel.test.mjs: parallel output is byte-identical to the default path, delivers all rows, and propagates backend exceptions. bench.mjs: PARALLEL=1 env opts into ?parallel=1 and the summary now reports throughput (MB/s + rows/s). README/TODO document the opt-in and the benchmark findings. 112 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…OMPRESSION)

Adds WSPROXY_BACKEND_COMPRESSION = lz4 (default) | zstd | none, applied via the query's network_compression_method (the server compresses result blocks with it) and, for none, the connection compression flag. The proxy's Connection decompresses whatever codec the server sends (self-describing), so this only trades wire bytes for backend/proxy CPU.

Motivation: over a bandwidth-limited WAN, gzipped HTTP beat the proxy on highly compressible data because the proxy's default lz4 trades ratio for speed (36.5 MB on the wire for the 3M-row benchmark vs gzip's 21.3 MB). With zstd, columnar native compresses far better than row JSON (~7-10 MB, 2-3x smaller than gzip), and the end-to-end cloud fetch drops ~5.0s -> ~2.5s (2x), beating gzip-HTTP (~3.5s) while keeping edge conversion and native signalling. On realistic/high-entropy data columnar native beats row JSON even with lz4. compression.test.mjs verifies lz4/zstd/none deliver byte-identical output. 113 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a 'Current status' section (delivered features, key findings, ordered next steps, lowlights/risks) to the top of the design doc, and mark the original open questions resolved. No code change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add programs/wsproxy/CLOUDFLARE.md: feasibility verdict, the stateless-Worker + stateful-container architecture, the verified 'a Container is a Durable Object' relationship, multi-container scaling via getRandom/getContainer (and the no-autoscaling-yet caveat), concrete work items (amd64 Linux build is the long pole), Cloudflare-specific frictions (thread-per-session memory, vCPU limits, egress IP allowlisting, cold starts, placement), the 1:1-sidecar -> shared-multi-tenant model shift, and why a pure-Worker approach is rejected. Linked from TODO.md next-steps and the tests/ and bench/ READMEs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add programs/wsproxy/IN_SERVER.md: feasibility of a ws_port inside clickhouse-server. Verdict is technically moderate (mostly a port) because the server already ships WS framing + HTTP-upgrade + the port/handler-factory pattern, and LocalConnection shares the IServerConnection interface ProxySession already drives, so the handler is ProxySession against a LocalConnection. Covers what's reused vs new, two shapes (dedicated ws_port vs endpoint on http_port), a concrete file-touch list, and the strategic catch: it's a core diff on the cloud-release cycle and loses the edge/compression value, so it's a complementary upstream play, not a faster sidecar. Cross-linked from TODO.md and CLOUDFLARE.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move WebSocketFrames.{h,cpp} and ProxySession.{h,cpp} from programs/wsproxy into src/Server (compiled into dbms), renaming ProxySession -> WebSocketSession. The bridge no longer builds its own remote Connection: run() now takes an already-connected IServerConnection&, and the remote-Connection creation + eager-connect/auth-error handling moved to WsProxyHandler. A new compression_method option replaces the BackendParams dependency (BackendParams moved to WsProxyHandler.h, proxy-side). This lets the same tested bridge serve both the standalone edge proxy (remote Connection) and an upcoming in-server WebSocket endpoint (in-process LocalConnection). Proxy links dbms so it picks up the relocated code; 113 tests still green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A first-class WebSocket interface inside clickhouse-server, next to tcp_port/http_port, reusing the shared WebSocketSession bridge against an in-process LocalConnection. New WSHandler (src/Server/WSHandler.{h,cpp}) does the RFC 6455 upgrade, authenticates via the server's own session auth (Basic / X-ClickHouse headers / ?user=&password= / default), and runs the bridge; registered as a WSHandler-factory in HTTPHandlerFactory and wired as ws_port in Server.cpp with a new ServerType::WS. Same wire protocol as clickhouse-wsproxy (query text frame -> binary result frames in ?format=, JSON control frames, mid-query progress/logs/profile-events, cancel-by-close, ?logs/?flow/?parse/?parallel).

WebSocketSession fixes needed for the in-process transport (all also correct for the remote proxy): materialize const/sparse columns before output->write (LocalConnection yields e.g. ColumnConst for SELECT 1, which row formats mishandled -> was a fatal abort); pass no ClientInfo to sendQuery so LocalConnection derives the user from its authenticated session (fixes currentUser()); skip sendExternalTablesData for LOCAL connections (not implemented / not needed); check the client socket on every packet iteration in executeSelect (LocalConnection::poll returns immediately with data, so cancel-by-close must not depend on poll timing out). Logs level is applied to the session context in WSHandler because LocalConnection ignores the per-query settings.

Both suites pass with the shared bridge: standalone proxy 113/113, in-server ws_port 102/102.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add spawnServerWithWs (runs the built clickhouse binary with a ws_port), serverSetup.mjs, and vitest.server.config.mjs (npm run test:server) that runs the shared query/protocol suite against the in-server endpoint, excluding proxy-only suites. Document both targets in the tests README, mark IN_SERVER.md as prototype-built-and-tested with the LocalConnection gotchas, and update TODO.md. In-server 102/102, proxy 113/113.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a commented <ws_port> entry (with a short protocol description) next to tcp_port in programs/server/config.xml, so the in-server WebSocket interface is discoverable. Disabled unless set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Enforce origin and credential validation, bounded frame completion, RFC close handling, UTF-8 checks, and safe flow-control accounting.

Synchronize parallel client teardown, harden streamed inserts, correct `LocalConnection` polling, and expand adversarial coverage for both server and proxy paths.

Co-authored-by: OpenAI Codex <codex@openai.com>
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.

1 participant