ClickHouse Backfill#
ClickHouse backfill refers to a family of long-running background migrations that copy historic observations and traces data into the new events_full table as part of Langfuse's V4 events-based architecture . Two generations of implementation exist:
- Early ad-hoc scripts (
backfillEventsHistoric.ts,backfillEventsHistoricFromParts.ts) — shipped to handle specific time windows before the production chain was built. These are now deleted as part of the V4 chain merges . - V4 chain (M1–M5) — a structured five-step migration introduced in PR #14206, built on the
ChunkedClickhouseBackfillMigrationbase class. All steps are dormant by default and activated via env gates (LANGFUSE_BACKGROUND_MIGRATION_V4_ENABLE_HISTORIC_BACKFILL,LANGFUSE_BACKGROUND_MIGRATION_V4_DROP_PID_TID_SORTING_TABLES).
The chain steps execute sequentially, each guarded by a checkPredecessorMigrationFinalized dependency check :
| Step | Migration | What it does |
|---|---|---|
| M1 | CreateRootSpansFromTraces | Writes virtual root spans from traces → events_full |
| M2 | RewriteObservationsToPidTidSorting | Copies observations into the scratch table observations_pid_tid_sorting with a reordered sort key; freezes parts via SYSTEM STOP MERGES |
| M3 | BackfillEventsFullFromObservations | LEFT ANY JOIN scratch table with traces, writes child spans → events_full |
| M4 | BackfillEventsFullFromDatasetRunItems | Cursor-paginated DRI enrichment → events_full |
| M5 | DropPidTidSortingTables | Drops the scratch table once the V4 read path is confirmed healthy |
Fire-and-Poll Pattern#
Both the old scripts and the V4 chain use the same fundamental mechanism for long-running ClickHouse INSERT … SELECT queries. This pattern is documented with a reference to the ClickHouse JS examples.
Sequence :
- Fire: Call
commandClickhouse()with anAbortControllersignal and a pre-generatedqueryIdpassed asqueryIdon the request. The query is dispatched and the rejection handler is attached in the same tick (to avoid unhandled-rejection crashes on fast errors — a reliability fix in the V4 base class). - Confirm start: After an initial wait (default 10 s), check
system.processesviapollQueryStatus(queryId). Ifnot_found, wait an additional 30 s and check again (ClickHouse'squery_logflush interval is ~7.5 s plus replication lag). - Abort HTTP connection: Once confirmed running, call
abortController.abort(). The query continues executing server-side. - Poll for completion: The scheduler loop calls
pollQueryStatus()on each activequeryIdat everypollIntervalMs(default 30 s), up toconcurrencyqueries in parallel (default 1 in V4 base class, 4 in old scripts).
Query ID format: generateQueryId produces backfill-{chunkId}-{8-char-uuid}, making active queries human-readable in system.processes.
Timeout bypass: The base class sets max_execution_time: 0 and timeout_before_checking_execution_speed: 0 on each fire-and-poll query so the shared client's derived server-side cap (~35 s) does not kill long chunk queries after the abort . The old scripts left this unset, meaning they relied on the client's default request_timeout not applying after abort.
Retry settings: On retry attempts, max_block_size, max_threads, and max_insert_threads are progressively reduced to avoid OOM on large partitions .
Scheduler differences: The old scripts used a ConcurrentQueryManager with setInterval, creating potential data races (three concurrent paths writing background_migrations.state). The V4 base class replaced this with a single sequential poll loop that is the sole writer of state, eliminating the race .
Status Tracking via System Tables#
All status tracking is centralized in packages/shared/src/server/clickhouse/queryTracking.ts.
pollQueryStatus(queryId) performs a two-stage lookup :
SELECT query_id FROM clusterAllReplicas('default', 'system.processes') WHERE query_id = {queryId}— returns"running"if found. Uses a 60 srequest_timeoutwithskip_unavailable_shards: 1.SELECT type, exception_code FROM clusterAllReplicas('default', 'system.query_log') WHERE query_id = {queryId}— maps:QueryFinish→"completed"ExceptionBeforeStart/ExceptionWhileProcessing/ non-zeroexception_code→"failed"QueryStart→"running"(still flushing)- empty result →
"not_found"
getQueryError(queryId) retrieves the full exception text from system.query_log for failed queries . Used to populate todo.error before marking a chunk as permanently failed.
Recovery on restart: On worker restart, recoverInProgressTodos iterates persisted in-progress chunks and calls pollQueryStatus to classify each: re-attach "running" queries to the active map, mark "completed" ones done, reset "failed" and "not_found" to pending .
Cluster note: Both
system.processesandsystem.query_logqueries useclusterAllReplicas('default', ...). The cluster name is hardcoded as'default'inqueryTracking.ts, independent of theCLICKHOUSE_CLUSTER_NAMEenv var used for DDL by the V4 chain.
V4 Chain: ChunkedClickhouseBackfillMigration Base Class#
The ChunkedClickhouseBackfillMigration<T> abstract class is the production foundation. Subclasses implement:
enumerateChunks(config)— returns the list ofChunkTodos (partitions or parts discovered fromsystem.parts)buildChunkQuery(todo)— returns{ query, params }for the per-chunkINSERTafterTablesValidated()(optional hook) — runs after required tables exist; M2 uses this for lazy scratch-table DDL:CREATE TABLE IF NOT EXISTS observations_pid_tid_sorting [ON CLUSTER ...]verifyCompletedChunk(todo)(optional hook) — post-INSERT verification; the parts-based variant checks the source part still exists insystem.partsafter processingonBackfillSucceeded(state)(optional hook) — M2 issuesSYSTEM STOP MERGESthenSYSTEM SYNC REPLICA STRICThere to freeze and converge the scratch table's part layout before M3 readssystem.partsonBackfillFailed(state)(optional hook) — cleanup on permanent failure
Database qualification: loadPartitionsFromClickhouse() and detectTableEngine() use database = currentDatabase() rather than hardcoded 'default' , making the queries portable across database names.
Cluster awareness: onClusterClause() returns ON CLUSTER <name> when CLICKHOUSE_CLUSTER_ENABLED=true; detectTableEngine() inspects system.tables to branch between SharedMergeTree (ClickHouse Cloud), ReplicatedMergeTree (self-hosted HA), and plain MergeTree (single-node), adapting DDL and SYSTEM commands accordingly .
State persistence: Phase (init → loading_chunks → backfill → completed), todos, and active query IDs are serialized as JSON into background_migrations.state (Postgres) on every transition, enabling crash recovery .
Known Gaps in the Early Scripts#
The early scripts (backfillEventsHistoric.ts, backfillEventsHistoricFromParts.ts) have several gaps relative to the V4 chain. These files were deleted in PR #13623 but may appear in git history or serve as reference for the pattern.
Missing database-qualified table names: Query validation checks in the early scripts use bare table names (e.g., SELECT count() as count FROM ${table} LIMIT 1) without database = currentDatabase() scoping . If ClickHouse has multiple databases, this relies on the connection's default database being correct. The V4 base class's loadPartitionsFromClickhouse and detectTableEngine use currentDatabase() consistently.
No table lifecycle management: The early scripts have no CREATE TABLE, DROP TABLE, SYSTEM STOP MERGES, or SYSTEM SYNC REPLICA. The V4 chain's M2 manages the scratch table observations_pid_tid_sorting end-to-end (lazy DDL via afterTablesValidated, freeze via onBackfillSucceeded, cleanup via M5). The early scripts assumed all tables pre-existed and never froze the source layout.
ConcurrentQueryManager data race: The old setInterval-based manager and scheduleNext/onComplete closures created three concurrent writers of background_migrations.state (load-modify-save paths). The V4 base class eliminates this with a single sequential while loop .
recoverInProgressTodos maxRetries gap (early V4): A Greptile review on PR #14206 flagged that when a recovered query is found in "failed" state, retryCount is incremented and the chunk is reset to "pending" without checking whether the new count exceeds config.maxRetries — allowing one extra attempt beyond the configured limit .
m.partition_id vs _partition_id (early V4 M3): PR #13623's Greptile review identified that the M3 traces subquery used t.partition_id (not a real column) instead of the virtual column t._partition_id, which would cause ClickHouse to throw "Missing columns" on every chunk . This was fixed before the PR landed.
Key Source Files#
| File | Role |
|---|---|
worker/src/backgroundMigrations/utils/backfillBase.ts | ChunkedClickhouseBackfillMigration base class, fireQuery, recoverInProgressTodos, loadPartitionsFromClickhouse, onClusterClause, detectTableEngine, runBackfillMigrationCli |
packages/shared/src/server/clickhouse/queryTracking.ts | pollQueryStatus, getQueryError, sleep, QueryStatus type |
worker/src/backgroundMigrations/backfillEventsHistoric.ts | Early script (deleted): reference implementation of fire-and-poll with ConcurrentQueryManager and chunk-boundary-based splitting |
worker/src/backgroundMigrations/backfillEventsHistoricFromParts.ts | Early script (deleted): parts-based variant with system.parts discovery and per-part verification |
worker/src/backgroundMigrations/rewriteObservationsToPidTidSorting.ts | M2: scratch table DDL, SYSTEM STOP MERGES, SYSTEM SYNC REPLICA |
worker/src/backgroundMigrations/backfillEventsFullFromObservations.ts | M3: per-part INSERT INTO events_full … SELECT … LEFT ANY JOIN traces |
worker/src/backgroundMigrations/createRootSpansFromTraces.ts | M1: virtual root spans |
Related PRs: