ClickHouse Query Execution#
Overview#
Langfuse executes ClickHouse queries through a thin abstraction layer in packages/shared/src/server/repositories/clickhouse.ts and packages/shared/src/server/clickhouse/client.ts. These two files define all query primitives and the client lifecycle.
Execution Primitives#
Four main functions handle query execution :
| Function | Use case |
|---|---|
queryClickhouse<T>(opts) | Standard SELECT — returns T[], with automatic exponential-backoff retry on network errors |
queryClickhouseStream<T>(opts) | Streaming SELECT — yields rows via AsyncGenerator<T> |
queryClickhouseWithProgress<T>(opts) | SELECT with JSONEachRowWithProgress format — useful for long-running queries that need incremental progress |
commandClickhouse(opts) | DDL / DML commands (INSERT-SELECT, ALTER, etc.) — supports an abortSignal and explicit query_id |
upsertClickhouse(opts) | Writes records to scores, traces, or observations via JSONEachRow INSERT |
All SELECT functions guard against reads from the legacy events table at the call site via assertNoLegacyEventsRead.
queryClickhouse includes retry logic for transient network errors . Retryable patterns include socket hang up, connection reset, ECONNRESET, ETIMEDOUT, etc., with up to LANGFUSE_CLICKHOUSE_QUERY_MAX_ATTEMPTS attempts at a fixed 100 ms delay.
Client Management#
ClickHouseClientManager is a singleton that pools clients by configuration hash. A new @clickhouse/client instance is created only when the config key changes . Key behaviors:
- Service routing: Three service tiers —
ReadWrite,ReadOnly,EventsReadOnly— resolved from env varsCLICKHOUSE_URL,CLICKHOUSE_READ_ONLY_URL,CLICKHOUSE_EVENTS_READ_ONLY_URL. - Async inserts: All clients enable
async_insert: 1andwait_for_async_insert: 1by default . - Progress headers: When
request_timeout > 30 s, the client enablessend_progress_in_http_headerswith a 10-second interval . - OTel propagation: Active span context is injected into HTTP headers so ClickHouse queries appear in traces .
The clickhouseClient(opts?, preferredService?) helper is the standard entry point .
Server-Side Timeout Enforcement (PR #14730)#
PR #14730 added automatic derivation of max_execution_time from the client request_timeout. When a client is created with a request_timeout, the ClickHouse server setting is set to ⌈request_timeout_ms / 1000⌉ + 5 seconds via a getRequestTimeoutClickHouseSettings helper .
This means ClickHouse will kill the query ~5 seconds after the client gives up, preventing orphaned queries from running indefinitely. Key details:
- When no
request_timeoutis configured, the default 30 s timeout is used, resulting inmax_execution_time: 35. timeout_before_checking_execution_speedis set to0so the limit is checked immediately.- Callers can override the derived value by passing explicit
clickhouse_settings— those are merged after the derived settings . request_timeoutis part of the client config hash, so different timeout values get separate cached client instances.
Fire-and-Poll Pattern#
For long-running queries (e.g., background migrations, bulk exports), Langfuse uses a fire-and-poll pattern:
- Fire: Call
commandClickhouse()with anAbortControllersignal and a pre-generatedqueryIdpassed intags. The query is dispatched but the HTTP connection will be severed intentionally. - Confirm start: After 5 seconds, call
pollQueryStatus(queryId)to verify the query registered on the server . - Abort the HTTP connection: Once confirmed running, call
abortController.abort(). The ClickHouse query continues server-side . - Poll for completion: A
ConcurrentQueryManagerperiodically callspollQueryStatusfor all active queryIds (default 30-second interval, up to 4 concurrent queries) .
pollQueryStatus internals#
pollQueryStatus performs a two-stage lookup with 60 s timeouts each:
SELECT query_id FROM clusterAllReplicas('default', 'system.processes')— returns"running"if found .SELECT type, exception_code FROM clusterAllReplicas('default', 'system.query_log')— mapsQueryFinish→"completed",ExceptionWhileProcessing→"failed", empty →"not_found".
getQueryError(queryId) retrieves the exception message from system.query_log for failed queries.
Both functions use skip_unavailable_shards: 1 to tolerate partial shard availability.
The generateQueryId helper produces IDs in the format backfill-{chunkId}-{8-char uuid}, making them human-readable in ClickHouse system tables.
Error Handling#
ClickHouseResourceError is a typed wrapper for server-side resource failures. It detects three classes by substring-matching the error message :
MEMORY_LIMIT—"memory limit exceeded"OVERCOMMIT—"OvercommitTracker"TIMEOUT—"Timeout","timeout","timed out"
All query functions call wrapIfResourceError in their catch blocks, so callers can instanceof ClickHouseResourceError to distinguish resource failures from other errors.
ClickHouse also has a quirk where exceptions mid-stream are emitted as rows with a single exception key rather than as HTTP errors. handleExceptionRow detects and re-throws these.
Key Source Files#
| File | Purpose |
|---|---|
packages/shared/src/server/repositories/clickhouse.ts | All query/command execution functions and error classes |
packages/shared/src/server/clickhouse/client.ts | ClickHouseClientManager singleton, client configuration |
packages/shared/src/server/clickhouse/queryTracking.ts | pollQueryStatus, getQueryError, sleep utilities |
worker/src/backgroundMigrations/backfillEventsHistoric.ts | Reference implementation of the fire-and-poll pattern with ConcurrentQueryManager |