ClickHouse¶
Connects to a ClickHouse server over the native (TCP) protocol. A source (a bounded
SELECT) and two sinks: a text sink that sends rows as TSV or JSONEachRow, and a native sink that sends typed Native blocks for high-volume ingest from SQL.
Overview¶
The ClickHouse connector integrates a ClickHouse server through the clickhouse-cpp native client.
- The native sink,
clickhouse_native_sink, takes SQL Rows, converts them into typed columns that match the target table, and streams them as compressed Native blocks through the client'sBeginInsert/SendInsertBlock/EndInsertcalls, with a dedicated writer thread per subtask, retries inside the INSERT and bounded backpressure. SQL tables choose it withinsert_format='native', which is the recommended choice for a new table; see Choosing a sink. - The text sink,
clickhouse_sink, batches incomingstd::stringrecords and sends each batch as oneINSERTstatement with its rows inline, each record one row of TSV or JSONEachRow. It remains the default for SQL tables, so a script keeps the sink it was written for. - The source executes a single
SELECTstatement and drains the result blocks. It emits either typedClickHouseRowrecords (column names plus stringified cell values) or, on the string channel, each row flattened to a delimiter-joined string or to a JSON object keyed by column name. Cell values are kept as text so the source stays schema-agnostic, with downstream operators parsing to concrete types.
Both sinks are at-least-once, and each must be the only sink on its chain, which Dag::add_sink enforces. The native sink checks the server and the target table before it writes, and refuses configurations under which that guarantee would not hold. Between the declared column type and the ClickHouse column it never truncates, floors or silently casts a value: a value the target column cannot hold fails the row with the column named. How a Row cell becomes a value of its declared type follows the engine's own rules (see Type mapping).
Choosing a sink¶
Use the native sink, insert_format='native', for a new workload. It is the sink the hardening has gone into: it checks the server and the target table before it writes and refuses the configurations under which at-least-once would not hold, it converts every value into its column's type without a silent cast, it retries inside the INSERT, and it is exercised against real servers on every tested line, including a kill matrix with no loss as the pass mark. What it does not change:
- It appends, at least once. Exactly-once delivery,
mode='upsert'andchangelog='true'are refused. AReplacingMergeTreekeyed by the row's natural key collapses repeats on the ClickHouse side. - Rows after the last completed checkpoint are replayed after a recovery, and may appear twice.
- An INSERT resent after a failure that left it in doubt is deduplicated only by a target that keeps a deduplication log (Replicated* by default, or MergeTree with
non_replicated_deduplication_windowabove 0), and only on 26.3 and 26.8, where that server behaviour is pinned. Into a Distributed or Null target, or on another line, a resend may land twice. - Server lines: 26.3 and 26.8 are tested, and another line that has the settings the sink sends is accepted with a warning. Targets: MergeTree and ReplicatedMergeTree family tables, Distributed and Null; SharedMergeTree and asynchronous-insert targets are refused (What it checks at open). Types: every column is declared, and binds to its target column as Type mapping sets out.
The text sink stays available for what the native sink refuses or does not do: a target engine outside those families, a table read as one text column, TSV, or the json_non_finite option. It is at-least-once on the same terms, without the checks at open, the retries or the type conversion: ClickHouse parses each JSON row itself. Its round trip is tested against 25.3, 26.3 and 26.8.
The SQL default is still the text sink. Making the native sink the default is a separate compatibility decision, because a script that runs on the text sink today can be refused by the native sink. Before such a change, check every existing ClickHouse sink table for what the native sink refuses and the text sink accepts: a table without declared columns, or read as one text column; json_non_finite; a target engine outside the MergeTree, ReplicatedMergeTree, Distributed and Null families, or a SharedMergeTree one; async_insert=1 on the table or as the server default; a server line lacking a setting the native sink sends; a declared column the target lacks, or names with different case; and a value its column cannot hold exactly, which the text sink leaves to ClickHouse's JSON parsing and the native sink fails with the column named.
Dependency and version¶
| Component | Provenance | Version |
|---|---|---|
clickhouse-cpp |
Built from source by scripts/build-clickhouse-cpp.sh, on the host into CLINK_DEPS_PREFIX/clickhouse-cpp and in the image into /usr/local: static, Release whatever the build type, with TLS (OpenSSL). Homebrew's or the system's copy is the fallback, and the configure log names the version it took. The Linux wheels build it in bundled-deps mode (CLICKHOUSE_CPP_BUNDLED_DEPS=1) |
2.6.2 (CLICKHOUSE_CPP_VERSION in scripts/versions.env, checksum-pinned) |
| cityhash | Bundled with the pinned client | the client's own |
| lz4, zstd, abseil | System packages, the copies the rest of the binary links. In the Linux wheels, abseil's int128 is the client's own, and the client's lz4 and zstd archives are deleted after install, so its references resolve against the one copy of each the rest of the library links (today the lz4 and zstd librdkafka builds in, which Arrow's codecs also take) | not pinned by clink (the wheels: librdkafka's, pinned with librdkafka) |
| OpenSSL | System. Required by the pinned client: without it CMake skips the pinned copy and falls back to the system's client. The Linux wheels link the static OpenSSL that scripts/build-librdkafka.sh builds with librdkafka, one copy shared with the Kafka connector |
not pinned by clink (the wheels: librdkafka's, pinned with librdkafka) |
The text sink and the source do not use Arrow; records cross the connector boundary as std::string (or typed ClickHouseRow). The native sink converts each Row batch into an Arrow chunk on the task thread and each chunk into Native columns on its writer thread.
Enabling it¶
Controlled by the CLINK_WITH_CLICKHOUSE CMake option (AUTO / ON / OFF, default AUTO). Under AUTO the clink::clickhouse target is defined only if clickhouse-cpp is found (via find_package(clickhouse-cpp CONFIG) or by locating the headers and clickhouse-cpp-lib); with ON a missing client is a fatal configure error; OFF skips the target entirely. When the target is built, it compiles with CLINK_HAS_CLICKHOUSE defined; without the client the text sink and source throw on construction.
The pinned client is built by scripts/build-clickhouse-cpp.sh on both the host and the Debian image (scripts/install-system-deps.sh calls it). It is built in Release even when the rest of the toolchain is Debug, because the encoding cost of a Debug client would be in every throughput figure.
cmake -S . -B build -DCLINK_WITH_CLICKHOUSE=ON
Two further conditions apply to the native sink, both decided at configure time:
- It needs clickhouse-cpp 2.6.2 or later. Against an older client, or one whose version the configure step cannot read, only its registration builds, and the factory refuses every job with
clickhouse.native_unavailable. The capability record says so in its limitations. - Its TLS path needs a client built with OpenSSL. The configure step compiles and links a small program against the chosen client to find out; when that fails,
secure='true'refusesclickhouse.tls_unavailable. The pinned client always passes.
By distribution: a host build with the pinned client and the runtime image (built on the toolchain image, which installs the pinned client) carry the native sink with TLS. So do the Linux pyclink wheels (manylinux_2_28, x86_64 and aarch64) from the release after v0.10.0; v0.10.0 and earlier wheels do not include the ClickHouse connector. scripts/build-libclink-wheel.sh with CLINK_WHEEL_CLICKHOUSE=1 refuses to build unless the TLS probe linked against the static OpenSSL it stages, and the wheels workflow runs a live TLS round trip through each wheel before it publishes any. The macOS wheel does not include the ClickHouse connector.
Factories¶
| Factory name | Direction | Record type |
|---|---|---|
clickhouse_sink |
sink | std::string |
clickhouse_native_sink |
sink | SQL Row (Native blocks; capability record clickhouse_native) |
clickhouse_native_sink_<channel> |
sink | a CLINK_FIELDS struct, when a job registers it with register_clickhouse_native_sink<T> (see C++: typed structs) |
clickhouse_row_source |
source | ClickHouseRow |
clickhouse_text_source |
source | std::string (columns joined by delim) |
clickhouse_source |
source | std::string (JSON object keyed by column name) |
The connector also registers the ClickHouseRow typed channel (kChannelClickHouseRow) with its codec so rows can travel end to end across the cluster without flattening.
The clickhouse_native_sink factory is built for SQL: it needs the declared SQL type of every column, which the planner passes as an internal parameter, sql_column_types, and without it the factory refuses clickhouse.option_invalid with "the native sink is built from SQL; a Dag-direct job must pass sql_column_types". A C++ job whose records are a struct uses the typed helper instead, which takes the column types from the struct's CLINK_FIELDS declaration: see C++: typed structs.
Configuration¶
Text sink (clickhouse_sink)¶
| Option | Required | Default | Description |
|---|---|---|---|
table |
yes | (none) | Target table name, quoted as an identifier in the INSERT. Construction fails if empty. |
host |
no | localhost |
ClickHouse server host. |
port |
no | 9000 |
Native protocol port, an integer from 1 to 65535. |
database |
no | default |
Target database, quoted as an identifier in the INSERT. |
user |
no | default |
Auth: username. |
password |
no | `` (empty) | Auth: password. |
format |
no | tsv |
Row encoding, matched without regard to case: tsv for TSV, json or jsoneachrow for JSONEachRow. Any other value is sent as TSV, and the sink logs a warning naming it at open. |
batch_rows |
no | 1000 |
Buffered rows that force a flush. A positive integer. |
batch_interval_ms |
no | 1000 |
A batch that arrives at least this many milliseconds after the last flush triggers one. There is no timer, so rows wait for the next batch, barrier or close. A positive integer. |
connect_timeout_ms |
no | 5000 |
Connection timeout in milliseconds, from 1 to 600000. |
send_timeout_ms |
no | 30000 |
Socket send timeout in milliseconds, from 1 to 600000. |
receive_timeout_ms |
no | 30000 |
Socket receive timeout in milliseconds, from 1 to 600000. A flush waiting on a server that has stopped answering fails after this long instead of holding the checkpoint. |
The integer options are parsed strictly. The whole value must be the number, and a value that is malformed or out of range refuses the job at deploy with a message naming the key; it does not fall back to the default.
Every INSERT names its own settings instead of taking them from the user's profile:
INSERT INTO `analytics`.`events`
SETTINGS async_insert=0, wait_for_async_insert=1, insert_deduplication_token='...'
FORMAT JSONEachRow
async_insert=0 and wait_for_async_insert=1 make the INSERT synchronous, so a flush returns only once its rows are in the table. The deduplication token is fresh for every statement, a resend after a failed flush included, and its random part is drawn again at each open, so a token never repeats one from an earlier run. On a replicated table, two batches with identical rows therefore both land; without a token the server would drop the second as a duplicate of the first. The same token makes a replayed batch land again (see Delivery semantics).
Native sink (clickhouse_native_sink)¶
Every option is parsed strictly at deploy. Integers must be the whole value, booleans are exactly true or false, and a malformed or out-of-range value refuses the job with clickhouse.option_invalid, naming the key. Any value may be written as env://VAR, which is read from the worker's environment; a variable that is unset or empty refuses clickhouse.secret_unset, and a refusal of a value read that way names the reference, never its contents.
| Option | Required | Default | Accepted values |
|---|---|---|---|
table |
yes | (none) | Target table name, not empty. The table must already exist; the sink does not create tables. |
host |
no | localhost |
Server host, not empty. |
port |
no | 9000, or 9440 when secure='true' |
Native protocol port, 1 to 65535. |
endpoints |
no | (none) | Comma-separated host:port list of replicas of one shard, at most 16 entries, each port 1 to 65535. Write an IPv6 address in brackets, [addr]:port. Setting it together with host or port refuses clickhouse.option_conflict. |
database |
no | default |
Target database, not empty. |
user |
no | default |
Username. |
password |
no | (empty) | Password. Never logged; the options line shows only set or unset. |
insert_format |
no | (none) | Absent or native. SQL sets it to choose this sink. |
secure |
no | false |
true or false. TLS on the native port. |
tls_ca_file |
no | (none) | Path of a CA certificate file, not empty. Only that CA is trusted. Needs secure='true', or refuses clickhouse.option_conflict. |
tls_ca_dir |
no | (none) | Path of a directory of hashed CA certificates, not empty. Only those CAs are trusted. Needs secure='true'. |
tls_verify |
no | true |
true or false. false skips certificate verification. Needs secure='true'. |
batch_rows |
no | 1048449 |
Rows at which an INSERT closes, 1 to 2147483647. The default equals ClickHouse's default min_insert_block_size_rows; the sink does not read it from the server. |
batch_bytes |
no | 67108864 (64 MiB) |
Converted payload at which an INSERT closes, 1048576 (1 MiB) to 1099511627776 (1 TiB). Reduced at open to fit a memory budget (see Sizing). |
batch_interval_ms |
no | 1000 |
Milliseconds after an INSERT's first row at which it closes, 1 to 3600000. A timer, so an idle stream still flushes. |
compression |
no | lz4 |
lz4, zstd or none, for the blocks on the wire. |
connect_timeout_ms |
no | 5000 |
TCP connect timeout, 1 to 600000. |
send_timeout_ms |
no | 30000 |
Socket send timeout, 1 to 600000. |
receive_timeout_ms |
no | 30000 |
Socket receive timeout, 1 to 600000. Also bounds each metadata read at open, rounded up to whole seconds and held between 1 s and 10 s. A server silent for longer than this during an INSERT fails the attempt in doubt (see Retries, timeouts and cancel). |
retry_window_ms |
no | 600000 (10 min) |
How long one INSERT, or the open, keeps retrying a transient failure, 1000 to 86400000. |
tls_server_name is not accepted. Any key outside this table and the tolerated keys below refuses clickhouse.unknown_option, listing the accepted options.
Tolerated keys are accepted and ignored, because the SQL planner, the materialised-view code or the ClickHouse source puts them on the sink's operator: batch_size, bounded, changelog, columnar_decode, commit_group, decimal_columns, definition_sql, delim, delivery_guarantee, forced_singleton, freshness, freshness_ms, mode, partition_by, primary_key, query, refresh_arm, schema_columns, state_ttl, state_ttl_domain, view_kind, watermark_delay_ms, write_mode. Four of them are read, because a value would change the guarantee, and are refused at the factory as well as by the planner: mode other than append, a delivery_guarantee stronger than at_least_once, changelog='true' and write_mode='overwrite' all refuse clickhouse.delivery_unsupported. An unrecognised delivery_guarantee, or a changelog other than true or false, refuses clickhouse.option_invalid.
Sources (clickhouse_row_source, clickhouse_text_source, clickhouse_source)¶
All three sources share the same option parser.
| Option | Required | Default | Description |
|---|---|---|---|
query |
yes | (none) | Single SELECT statement, no trailing semicolon. Construction fails if empty. |
host |
no | localhost |
ClickHouse server host. |
port |
no | 9000 |
Native protocol port. |
database |
no | default |
Database to connect to. |
user |
no | default |
Auth: username. |
password |
no | `` (empty) | Auth: password. |
batch_size |
no | 1024 |
Rows emitted per produce() call. A single server block may straddle multiple calls. |
delim |
no | \| |
clickhouse_text_source only: delimiter joining a row's column values into one string. Ignored by the other source factories. |
Sources expose host, port, database, user, password, query and batch_size; the source Options struct has no table field (the table comes from the query).
SQL usage¶
Mapped in src/sql/physical_plan.cpp as connector='clickhouse' for both a sink and a source.
The SQL source binds to the clickhouse_source factory (each row arrives as a JSON object keyed by column name and is bridged to a Row table via json_string_to_row).
A SQL sink table chooses its sink with insert_format:
- absent or
insert_format='jsoneachrow'(the default):clickhouse_sink, withformat=jsoneachrowforced by the binding, since each Row is serialised to a JSON object before insertion; insert_format='native':clickhouse_native_sink, which takes the Rows directly. The planner passes the declared type of every column with them. Prefer it for a new table (Choosing a sink).
Any other value is refused at CREATE TABLE, and so is insert_format on a connector other than clickhouse ("insert_format applies to connector='clickhouse' only; this table's connector is '...'"), because there it would do nothing.
-- Source: read a bounded SELECT into a Row table
CREATE TABLE events_in (
id BIGINT,
name VARCHAR
) WITH (
connector = 'clickhouse',
host = 'clickhouse.internal',
port = '9000',
database = 'analytics',
user = 'reader',
password = 'secret',
query = 'SELECT id, name FROM analytics.events'
);
-- Text sink: INSERT rows as JSONEachRow
CREATE TABLE events_out (
id BIGINT,
name VARCHAR
) WITH (
connector = 'clickhouse',
host = 'clickhouse.internal',
port = '9000',
database = 'analytics',
table = 'events_copy',
user = 'writer',
password = 'secret',
format = 'json'
);
-- Native sink: typed Native blocks
CREATE TABLE events_native (
id BIGINT,
name VARCHAR,
ts TIMESTAMP(3),
amount DECIMAL(18, 2)
) WITH (
connector = 'clickhouse',
insert_format = 'native',
endpoints = 'ch-1.internal:9000,ch-2.internal:9000',
database = 'analytics',
table = 'events',
user = 'writer',
password = 'env://CH_PASSWORD'
);
For the native sink the SELECT's columns bind by position to the table's declared columns, and the declared columns bind by exact, case-sensitive name to the ClickHouse table's columns (see Column matching). The native sink writes typed columns, so the table must declare them; a table read as one text column refuses with "connector='clickhouse' insert_format='native' writes typed columns: declare the table's columns, or format='json'".
A script with two INSERTs from one source runs them as two jobs, each reading the source itself, so one source table feeding a native ClickHouse table and another sink never puts the two sinks on one chain. The source is read once per job, with its own connection and its own checkpoints.
Both sinks append only. mode='upsert' and exactly-once delivery are rejected at planning time for either. A materialised view over a keyed GROUP BY gets mode='upsert' automatically when it sets no mode, so a ClickHouse backing table for such a view must set mode='append' itself. Every updated aggregate then lands as a new row, and collapsing them to the latest per key is left to ClickHouse (a ReplacingMergeTree keyed on the group columns, for example). A full-refresh materialised view cannot use the native sink: each refresh would append the whole result again, and the planner refuses it. The planner also refuses changelog='true' on a native table.
With insert_format='jsoneachrow' a NaN or infinite double reaches ClickHouse through the row_to_json_string bridge as null, or as "NaN", "Infinity" or "-Infinity" when the sink table sets json_non_finite='string'. The native sink writes typed columns, which hold the value itself, and refuses the option. See NaN and infinity in JSON output.
Native sink¶
What it guarantees¶
At-least-once, under the conditions below:
- Every row covered by a completed checkpoint is in the target at least once after automatic recovery. The barrier returns only after every INSERT of the interval has been acknowledged.
- Rows after the last completed checkpoint are replayed, and may appear twice.
- An in-doubt INSERT resent with a fresh token may appear twice. One resent with its own token is deduplicated only on targets that keep a deduplication log (Replicated* by default, or MergeTree with
non_replicated_deduplication_window> 0) and on the lines where pin P4 passes, 26.3 and 26.8.
The conditions, each enforced rather than assumed:
- A consistent cut at every checkpoint. The engine aligns every barrier at a fan-in whatever the job's alignment mode, so unaligned and adaptive checkpoints cut where aligned ones do (see Topology and checkpoint limits).
- The sink alone on its chain.
Dag::add_sinkrefuses another sink beside it. - A synchronous INSERT. Every INSERT sends
async_insert=0, wait_for_async_insert=1, and the sink refuses a target whose table or server settings would make it asynchronous anyway. - For a Distributed target, a synchronous write to the shards. Every INSERT sends
distributed_foreground_insert=1, so the rows are on the shards when the INSERT is acknowledged. A Distributed table does not deduplicate, so every resend into one counts as possibly duplicated.
A token is kept on a resend only when the server taking the resend is on 26.3 or 26.8 and is the same server (endpoint, name and version) that took the previous attempt. Otherwise the resend takes a fresh token, which can duplicate but never lose. The close summary counts both cases: rows_resent_with_token for rows resent under their own token on a target that keeps a log, and rows_maybe_duplicated for every other row resent after it was sent. A ReplacingMergeTree keyed by the row's natural key absorbs duplicates on the ClickHouse side.
Server lines: 26.3 and 26.8 are tested. Any other line that has the settings the sink sends is accepted with a warning at open, and on it a resent INSERT always takes a fresh token.
What it checks at open¶
All of this happens before the first INSERT:
- The checkpoint mode, and the memory budget against the batch size. Neither needs a server, so neither waits out an outage.
- The server's settings: every setting the sink sends with an INSERT must exist on the server, and
min_insert_block_size_rowsandmin_insert_block_size_bytesmust read as whole numbers, because the sink pins them. - The target table: it must exist, and its engine must be a MergeTree or ReplicatedMergeTree family table, Distributed, or Null.
- The effective
async_insert: the table's own setting if it has one, otherwise the server-wide default in<merge_tree>(or<replicated_merge_tree>for a Replicated table, read fromsystem.replicated_merge_tree_settings). Behind a Distributed table this is checked for the local table on every replica of the cluster, each replica named ashost:port. A cluster argument written with macros, such as'{cluster}', is expanded fromsystem.macrosfirst, as the server expands it. - The deduplication window, reported, never required.
- The column plan: every declared column against the target's columns and types.
Steps 2 to 5 run again on every new client the writer builds after a failure, because a reconnect may reach another replica or a server restarted into another version. The columns are not planned again; instead every INSERT's header is checked against the plan made at open (clickhouse.header_drift).
Every metadata read carries max_execution_time with timeout_overflow_mode='throw', so a slow system table cannot hold the open. The limit is receive_timeout_ms rounded up to whole seconds, held between 1 s and 10 s. The reads across a Distributed table's cluster also send skip_unavailable_shards=0, so an unreachable replica fails the read, and is retried, rather than being left out of it.
The open runs inside the retry window, which starts when the open does. A connection failure, a timeout or an unreachable replica behind a Distributed target is retried with backoff, rotating through endpoints on a connect failure, until retry_window_ms runs out, so an outage at open shorter than the window costs nothing. An unknown server code is retried three times and fails on its fourth failure, as during an INSERT. A refusal is never retried: it rests on what a server said or on the options, which a retry does not change. A refusal fails the subtask, which spends a restart; the restarted subtask refuses again, so the job fails once its restart budget is spent. Each refusal counts in clink_clickhouse_refusals_total{reason=<code>}.
Refusals and how to fix them¶
Every refusal and permanent failure is a NativeSinkError whose message starts with its code in brackets, for example [clickhouse.target_async_insert].
At deploy, from the options:
| Code | Cause | Fix |
|---|---|---|
clickhouse.unknown_option |
A key the sink neither reads nor tolerates, such as tls_server_name or a misspelling. |
Correct or remove the key. The message lists the accepted options. |
clickhouse.option_invalid |
A value that is malformed or out of range; a missing table; an insert_format other than native; a missing or unparsable sql_column_types. |
Use a value from the option table. |
clickhouse.option_conflict |
endpoints together with host or port; tls_ca_file, tls_ca_dir or tls_verify without secure='true'. |
List every server in endpoints, or turn TLS on. |
clickhouse.secret_unset |
An env://VAR value whose variable is unset or empty on the worker. |
Set the variable in every worker's environment. |
clickhouse.delivery_unsupported |
mode other than append, a delivery_guarantee above at_least_once, changelog='true' or write_mode='overwrite'. |
Remove the setting; the sink appends at least once. For a keyed view, set mode='append'. |
clickhouse.tls_unavailable |
secure='true' on a build whose client has no TLS socket factory. |
Build against the pinned client, or turn TLS off. |
clickhouse.native_unavailable |
A build whose client is older than 2.6.2, or of a version it could not read. | Rebuild against the pinned client, or use insert_format='jsoneachrow'. |
At open, before any network work:
| Code | Cause | Fix |
|---|---|---|
clickhouse.memory_budget_too_small |
The subtask's memory budget leaves less than 1 MiB of batch_bytes once the sink plans its buffers into half of it. |
Give each subtask a budget of at least 37748736 bytes (36 MiB), or none. |
At open, from the server and the table. A table-level or configuration change fixes each of these; the sink does not change the table itself.
| Code | Cause | Fix |
|---|---|---|
clickhouse.access_denied |
Authentication failed, the user is unknown, access is denied, or the user is read-only (codes 164 outside quorum inserts, 192, 291, 497, 516). Also raised at run time. | Check the user and password. The user needs INSERT on the table and must be able to read the system tables the open checks use: system.settings, system.tables, system.columns, system.merge_tree_settings and system.replicated_merge_tree_settings. |
clickhouse.tls_verify_failed |
The server's certificate did not verify. | Point tls_ca_file or tls_ca_dir at the CA that signed it, or check the host name in host or endpoints. For a publicly signed server, check that the system has a CA bundle: the open line names the one taken when OpenSSL's own locations are missing (see TLS and failover), and SSL_CERT_FILE names another. A slim container image (debian:bookworm-slim, for one) may have no bundle until the ca-certificates package is installed. tls_verify='false' skips the check. |
clickhouse.option_invalid |
tls_ca_file or tls_ca_dir does not exist, is the wrong kind of path, cannot be read or searched, or is a file OpenSSL cannot load; or, with neither set, OpenSSL cannot set up its default CA locations. |
Fix the path or the certificate. |
clickhouse.server_settings_unsupported |
The server lacks a setting the sink sends with every INSERT (including both deduplicate_insert and insert_deduplicate), or reports min_insert_block_size_rows or min_insert_block_size_bytes as something other than a whole number. |
Use a server line that has them; 26.3 and 26.8 are tested. |
clickhouse.target_missing |
The table does not exist, or behind a Distributed table the local table is missing on some replicas (the message names those that have it). | Create the table first, on every replica. |
clickhouse.target_engine_unsupported |
A SharedMergeTree family table, the target or a replica's local table behind a Distributed target ("SharedMergeTree targets are not supported yet, because nothing tests their deduplication"), or an engine other than MergeTree, ReplicatedMergeTree, Distributed and Null (Buffer, Memory, the Log family, Kafka, View, MaterializedView and the rest). | Write to a MergeTree, ReplicatedMergeTree, Distributed or Null table. A Null table with materialised views is accepted. |
clickhouse.target_async_insert |
The table takes inserts asynchronously, which the sink refuses while upstream issue #121174 can lose acknowledged rows on that path. Also raised when the table setting or the server default cannot be read as 0 or 1, when the table's definition cannot be scanned for its settings, and when a Replicated table sets no async_insert and its <replicated_merge_tree> default cannot be read (a replica of the cluster lacks system.replicated_merge_tree_settings). Behind a Distributed table, also when a replica's local table is not MergeTree family or the cluster read was denied (codes 291, 497). |
Run the ALTER the message gives, ALTER TABLE <db>.<table> MODIFY SETTING async_insert = 0 (on the named host:port, for a Distributed target). For a server-wide default, set <merge_tree><async_insert>0</async_insert></merge_tree>, or the same under <replicated_merge_tree> when the message names that section. For a denied cluster read, the message lists the grants the check needs: REMOTE ON *.*, and SELECT on system.clusters, system.tables, system.merge_tree_settings and system.replicated_merge_tree_settings, plus system.macros when the cluster argument uses macros. |
clickhouse.target_unreadable |
The sink cannot read what it must check: system.columns lists no columns for the table (most likely a missing SHOW COLUMNS grant); a column has a default kind or position it does not know; a Distributed table's engine arguments are not a cluster, a database and a table; the cluster is not in system.clusters; the cluster argument uses a macro system.macros does not define ({server_uuid} among them), leaves a { unclosed or nests macros more than ten deep; or a replica is missing from a settings read across the cluster, or gives one setting two values in it. |
Grant SHOW COLUMNS on the table; write the Distributed engine's first three arguments as strings or identifiers; check the cluster name, or name the cluster in the table definition instead of through a macro. |
clickhouse.column_plan |
One or more declared columns cannot be written into the target. Every problem is listed in one message (see Type mapping). | Change the clink table's declared types, widen the target, or CAST in the SELECT, as each line says. |
clickhouse.retry_window_exhausted |
The server stayed unreachable, or kept failing transiently, for the whole retry_window_ms. The message names the last failure and the attempt count. |
Restore the server or the network; raise retry_window_ms for longer outages. |
A column-plan refusal looks like this:
[clickhouse.column_plan] `analytics`.`events` cannot take this table's rows:
- target column `tenant` has no default and the query does not produce it
- column `amount`: DECIMAL(18, 2) into Decimal(18, 4) leaves 14 integer digits for 16; widen the target or declare the clink column DECIMAL(16, 2)
- column `score`: DOUBLE into Float32 narrows; use REAL in the clink table or CAST in the SELECT
At run time, after open. Each fails the task, and the job's restart replays from the last completed checkpoint:
| Code | Cause | Fix |
|---|---|---|
clickhouse.header_drift |
The table changed since open: the INSERT's header no longer matches the column plan. | Restart the job so that the sink plans against the table as it is now. |
clickhouse.conversion_failed |
A value the target column cannot hold (see Type mapping). The error line names the column and shows the row with its values redacted. | Fix the data, or the declared type or the target type. A replay meets the same row, so this fails again until it is fixed. |
clickhouse.too_many_partitions |
One INSERT touches more partitions than the server's max_partitions_per_insert_block allows. |
Make the table's PARTITION BY coarser, or lower batch_rows so each INSERT spans fewer partitions. |
clickhouse.insert_failed |
A permanent server error (unknown table, database, setting, column or identifier, a syntax, type or parse error, and the others ClickHouse reports as permanent); an unknown server code on its fourth failure, after three retries; a client state error that a fresh client did not clear; or an unexpected exception. Also raised at open. The message carries the server's text. | Act on the server's text. |
clickhouse.retry_window_exhausted |
One INSERT stayed unacknowledged for the whole retry_window_ms. |
As at open. |
clickhouse.cancelled |
The task was cancelled while the sink was opening or writing. Not a fault of the sink. | None. |
The server checks a new client runs after a failure can raise any of the open-time refusals at run time as well, for example when a reconnect reaches a replica whose table takes inserts asynchronously. The INSERT then fails with that code.
A permanent failure logs one error line on sink.clickhouse before it throws. A conversion failure's line shows every column with its target type and, for the offending column alone, its value when that is a number, a boolean, a decimal, a date or a timestamp, or only its length when it is text or a composite value (email=String(len 23)), because row data may be confidential. A cell that fails on the way to its declared type is described the same way, by its kind, a number shown and text only by its length.
Column matching¶
- Columns match by exact, case-sensitive name. A declared column that differs from a target column only in case is reported as missing, and the message says so.
- A target column with no default that the clink table does not produce is a problem. One with
DEFAULTorEPHEMERALis left out of the INSERT and takes its default. - A declared column that matches a
MATERIALIZEDorALIAScolumn is a problem: the server computes it, so drop it from the SELECT. A declared column the target lacks is a problem too, and so is one declared twice. - Each matched pair must be one of the pairs below, and the target type must be one clickhouse-cpp can build a column for.
Type mapping¶
The sink converts in two steps, and the rules differ by step.
The declared types are the table's SQL column types as the planner renders them. TIMESTAMP(0), TIMESTAMPTZ(0) and scale-0 decimals such as DECIMAL(10, 0) can be declared; a TIMESTAMP precision is rendered as 0, 3, 6 or 9, the next of those at or above the one declared, and TIMESTAMP alone as TIMESTAMP(6). TINYINT has no DDL spelling, so a SQL table cannot declare it; it is listed below for completeness.
A batch can reach the sink as rows or as a columnar batch, an Arrow sidecar that a columnar operator passed on: in a SQL plan, after the columnar Kafka JSON decode or a join or window that emits columnar output. The sink converts a columnar batch from its arrays without building a row, cell by cell through the same rules as the rows that batch would turn into, so either form lands the same values and fails on the same cell with the same error. It takes the batch through its rows instead, and counts why in clink_clickhouse_columnar_declined_total, when the batch's event-time column is not int64 (event_time), when a value column has a type the Row sidecar does not carry (unsupported_type), or when two value columns carry one declared column's name (duplicate_name). The rules below are written for Row values, which is what each cell of either form is read as. A TIMESTAMP or TIMESTAMPTZ column reaches the sink in a columnar batch in one of two forms: as a millisecond timestamp array holding the epoch milliseconds, which the in-engine carriers send when every worker hosting the job reads it (cluster protocol v3), or as the digit text of the epoch milliseconds, the earlier form, which a deployment with an older worker still carries. Both land the same block as the rows would. ISO-8601 text in a timestamp column fails the row on either form, as it does for a Row value.
Some columns of a columnar batch are taken as they are rather than converted cell by cell, because the declared type holds their values unchanged:
| Batch array | Declared column | Taken as it is when |
|---|---|---|
| int64, int32, float, double, boolean | BIGINT, INTEGER, REAL, DOUBLE, BOOLEAN | always |
| int64, timestamp(ms) with or without a zone | TIMESTAMP(p), with or without time zone | always |
| int32 | DATE | always |
| utf8 | VARCHAR | no value in the batch begins with the byte \x01, the engine's decimal marker, which the cell-by-cell path strips |
| decimal128(p, s) | DECIMAL(p, s), the same precision and scale | every value in the batch fits p digits; the columnar JSON decode stores a value with more digits as written, and the cell-by-cell path lands it as NULL |
A column that fails its condition is converted cell by cell for that batch, so the same values land either way. Only an array whose buffers the batch owns outright is taken this way, as the columnar JSON decode and the columnar joins and windows build theirs. A column read back from an Arrow IPC frame, or imported through the Arrow C Data Interface as the Iceberg source's batches are, is converted cell by cell instead, because its buffers keep alive memory that the sink's memory charge would not count. A queued chunk that shares arrays with its input batch is charged for those arrays in full, and is freed on the task thread rather than the writer thread. It stays charged until the task thread frees it, which the task thread does at its next call into the sink, or while it waits for room in the queue. A chunk whose VARCHAR values go to a String or LowCardinality(String) column, at any depth inside an ARRAY, MAP or ROW too, and whether the column is taken as it is or not, is held by every INSERT that sends its rows, and by the halves of an INSERT that is split, until that INSERT is acknowledged or given up. It counts towards the INSERT's charge limit of twice batch_bytes. A VARCHAR converted into any other ClickHouse type, such as FixedString, an Enum or UUID, is copied and does not hold the chunk.
From the Row value to the declared type. A JSON null, or an absent field of a ROW, is NULL for every type. For the types the engine's own Row batcher handles, a top-level cell of the wrong JSON kind, or out of range for its type, becomes NULL, exactly as it does on every other columnar path: it then lands as NULL in a Nullable target and fails the row in a non-Nullable one. Every other type, and every element inside an ARRAY, MAP or ROW, fails the row instead.
| Declared type | Accepted Row values | Otherwise |
|---|---|---|
BIGINT, INTEGER |
an integer in range, read exactly; a number with a fraction is truncated | NULL |
REAL, DOUBLE |
a number (for REAL, within float's range) | NULL |
BOOLEAN |
true or false |
NULL |
VARCHAR |
any value; numbers and booleans are written as their text, a double in its shortest round-trip form, an array or object as its JSON text | (never) |
DECIMAL(p, s) |
a decimal, or a number with no fraction, rounded to scale s half away from zero, with at most p digits |
NULL |
SMALLINT, TINYINT |
an integer, a number with no fraction, or digit text, within the type's range | fails the row |
TIMESTAMP(p), TIMESTAMP(p) WITH TIME ZONE |
epoch milliseconds, whatever p: an integer, a number with no fraction, or digit text with an optional - |
fails the row; ISO-8601 text is not epoch milliseconds |
DATE |
days since 1970-01-01 as an integer, a number with no fraction or digit text, or text exactly YYYY-MM-DD (an optional leading -) naming a real date |
fails the row |
T ARRAY |
a JSON array, or its JSON text, each element by T's rule as an element | fails the row |
MAP<K, V> |
a JSON object, or its JSON text; each key is read from its text by K's rule, and two keys that convert to the same value fail | fails the row |
ROW<f T, ...> |
a JSON object, or its JSON text, fields by name | fails the row |
Inside a composite the shared types fail rather than becoming NULL. BIGINT and INTEGER there take an integer or a number with no fraction (a fraction fails rather than being truncated, and digit text fails), and DECIMAL takes any number, read through its shortest round-trip text.
The engine represents every TIMESTAMP at run time as epoch milliseconds, so the declared precision says nothing about the value. A timestamp therefore goes into any DateTime64 precision, and a value finer than the target fails the row: it is never floored.
From the declared type to the ClickHouse column:
| Declared type | ClickHouse targets | Conversion |
|---|---|---|
BIGINT |
Int64, Int128; Int8, Int16, Int32, UInt8 to UInt64 |
exact; a narrower or unsigned target checks each value's range |
INTEGER |
Int32, Int64, Int128; Int8, Int16, UInt8 to UInt64 |
as above |
SMALLINT, TINYINT |
any Int8 to Int128 and UInt8 to UInt64 |
as above |
REAL |
Float32, Float64 |
exact or widened |
DOUBLE |
Float64 |
exact. Float32 is refused: use REAL or CAST |
BOOLEAN |
Bool, UInt8 |
|
VARCHAR |
String |
zero-copy |
VARCHAR |
FixedString(N) |
at most N bytes, zero-padded; longer fails |
VARCHAR |
Enum8, Enum16 |
the exact item name; any other text fails |
VARCHAR |
UUID |
canonical 8-4-4-4-12 hexadecimal text |
VARCHAR |
IPv4 |
dotted quad |
VARCHAR |
IPv6 |
IPv6 text, or a dotted IPv4 address, written as ::ffff:a.b.c.d |
DECIMAL(p, s) |
Decimal(P, S), Decimal32/64/128(S) |
needs S >= s, as many integer digits (P - S >= p - s), P <= 38 and S - s <= 18, so that every declared value fits. P >= p alone is not enough: DECIMAL(10, 2) into Decimal(10, 4) leaves 6 integer digits for 8 |
TIMESTAMP(p) |
DateTime64(P[, tz]), any P from 0 to 9 |
milliseconds scaled exactly to the target's unit; a value with digits the target cannot hold fails ("1700000000123 ms has sub-second digits DateTime64(0) cannot hold"). Range 1900-01-01 00:00:00 to 2299-12-31 23:59:59 |
TIMESTAMP(p) |
DateTime[(tz)] |
whole seconds only; range 1970-01-01 00:00:00 to 2106-02-07 06:28:15 |
DATE |
Date32 |
range 1900-01-01 to 2299-12-31 |
DATE |
Date |
range 1970-01-01 to 2149-06-06 |
T ARRAY |
Array(T'), for any accepted pair T into T' |
a NULL array fails |
MAP<K, V> |
Map(K', V'), for accepted key and value pairs |
a NULL map fails |
ROW<...> |
Tuple(...) with the same number of elements, matched by position; a named Tuple's names must equal the field names |
a NULL ROW fails |
A target time zone is display metadata, since the stored value is the UTC epoch, so any tz is accepted.
Nullability and LowCardinality apply to every row above. Nullable(T) takes NULLs; a NULL into a non-Nullable column fails the row ("null in non-Nullable column x"). LowCardinality(String), LowCardinality(FixedString(N)) and LowCardinality(Nullable(String)) are written as their inner type.
Refused at open, by name, in the column-plan message:
| Type | Why |
|---|---|
Int256, UInt256, BFloat16, Variant, Dynamic, JSON, Object, Time, Time64, Point, Ring, Polygon, MultiPolygon, AggregateFunction, SimpleAggregateFunction, Nothing, and any type name the sink does not know |
not supported by the native sink |
Decimal(P, S) with P above 38, Decimal256 |
clickhouse-cpp holds a decimal in at most 128 bits |
LowCardinality of anything other than String, FixedString(N) or Nullable(String) |
clickhouse-cpp builds LowCardinality only over those |
a declared BYTEA or TIME column |
no mapping in the native sink; leave the column out or declare another type |
any other pair, UInt128 among them |
"<SQL> into <ClickHouse> is not supported; CAST in the SELECT" |
What each INSERT sends¶
INSERT INTO `db`.`t` (`c1`, `c2`) SETTINGS async_insert=0, wait_for_async_insert=1, deduplicate_insert='enable', insert_deduplication_token='clink1-<nonce>-<seq>', input_format_native_allow_types_conversion=0, input_format_null_as_default=0, throw_on_max_partitions_per_insert_block=1, distributed_foreground_insert=1, min_insert_block_size_rows=<R>, min_insert_block_size_bytes=<B>, use_strict_insert_block_limits=0, log_comment='clink:<sink>:sub<K>:<seq>' VALUES
deduplicate_insert='enable'where the server has that setting,insert_deduplicate=1otherwise.use_strict_insert_block_limits=0only where the server has it (26.8 does, 26.3 does not).- The token's 128-bit nonce is drawn at every open on every subtask, and
<seq>counts the subtask's INSERTs, so two distinct INSERTs never share a token. A retry before anything was sent keeps its token. min_insert_block_size_rowsandmin_insert_block_size_bytesare the values the server reported at open, pinned for the writer's life, so that a resend under the same token is squashed into the same parts as the first attempt whatever profile or replica it meets. Pin P17 shows why: without them a kept-token resend under a different profile duplicated a block, and once lost one.log_commentcarries the sink's operator name, the subtask and the token's sequence, so an INSERT can be found insystem.query_log.
Retries, timeouts and cancel¶
Each INSERT has its own retry window, retry_window_ms, which starts at its first failure. Backoff starts at 100 ms and grows to at most 10 s, with full jitter. After any failure the client is discarded, its socket cut first so that a half-sent INSERT can never be committed by accident, and the next attempt builds a new client (and re-runs the server checks). A failure before anything was sent resends under the same token. One after the blocks were sent is in doubt: the server may have written them, so the resend follows the token rule in What it guarantees.
Retried: connection and socket errors, timeouts, protocol and TLS errors other than a verify failure; server codes for network errors, timeouts, an unreachable shard, too many simultaneous queries, an overloaded server, a read-only replica, a lost Keeper session, an aborted or cancelled query and an unknown insert status; "too many parts" merge back-pressure (counted in clink_clickhouse_parts_backoff_total); and quorum failures on a server with insert_quorum set. A server memory-limit error (241) on an INSERT of more than 1000 rows splits it into two INSERTs, each under a fresh token and within the same window; a smaller one is retried as it is. An unknown server code is retried three times and fails on its fourth failure. Permanent errors fail at once.
A retrying INSERT holds the sink, so the checkpoint barrier waits, upstream operators on the chain wait with it, and the sink's bounded queue fills and backpressures the job. On the periodic checkpoint path a held barrier stalls the checkpoint without failing it, so an outage shorter than the window costs no restart. Meanwhile the coordinator keeps triggering checkpoints at its interval: they queue behind the held one and complete in turn once the INSERT is acknowledged. Two engine paths bound that wait below the default window; see Topology and checkpoint limits.
The calls that wait for a server reply (BeginInsert and EndInsert) run under a deadline, the end of the INSERT's window, so a server that keeps a call busy with progress packets cannot hold an attempt without a total bound. The case that matters in practice is the opposite one. On 26.3 and 26.8 the server sends nothing at all while dependent materialised views run (pin P20), so a target whose views take longer than receive_timeout_ms (30 s by default) fails the attempt in doubt; the INSERT is resent, and the target and its views can receive duplicates. For such targets, set receive_timeout_ms above the slowest INSERT's processing time, views included.
A cancel is seen within about 50 ms. A writer or opener blocked in a socket call is woken by shutting its socket down. One still inside DNS resolution, the TCP connect or the TLS handshake, where no socket can be cut, is left behind after 5 s, and its client can never commit. Either way the sink's open and cancelled close return within about 5 s.
A refusal at run time, an exhausted window or a permanent failure fails the subtask and spends a restart. With a checkpoint directory set, a job has 10 restarts by default (max_restarts_on_worker_loss), and the budget is forgiven once a checkpoint completes at least 10 minutes after the last restart (restart_budget_reset_after), so occasional outages longer than the window do not add up to a failed job. See fault tolerance.
Topology and checkpoint limits¶
The sink must be the only sink on its chain. Its guarantee rests on writing out the interval inside the barrier, before the chain's checkpoint is acknowledged, which holds only while the sink owns that checkpoint, and a second sink on the chain would take ownership away. Dag::add_sink refuses the combination in either order, with a message that names the sink and says it must be the only sink on its chain; give the other sink its own subtask. See checkpointing.
Unaligned and adaptive checkpoints are accepted, and cut where aligned ones do. A fan-in upstream of the sink (a cluster sink subtask has one whenever the upstream parallelism is above 1) aligns every barrier whatever its stamp, so a barrier stamped unaligned reaches the sink only along a single in-order path, where no row can overtake it. The sink used to refuse both modes, because a fan-in forwarded such a barrier at once, ahead of the rows still in flight on its other inputs, and a crash would have lost them.
Two engine paths bound how long a barrier may be held, and both bounds are below the default retry_window_ms of 600000:
- the final checkpoint at the end of a bounded job, and the commit wait of a hot cutover, wait at most
CLINK_EOS_FINAL_CKPT_TIMEOUT_MS(default 30000); - a split stage's cutover hold waits at most
CLINK_CUTOVER_HOLD_TIMEOUT_MS(default 300000).
A retry that holds the barrier past either bound fails that path and spends a restart; the replay from the last completed checkpoint loses nothing. For bounded jobs and jobs you rescale, set both variables on the workers at or above retry_window_ms, or lower retry_window_ms to fit. The open line reports the bound the worker read as eos_bound_ms.
Sizing¶
An INSERT closes at the first of: batch_rows rows; batch_bytes of converted payload; batch_interval_ms after its first row; a checkpoint barrier or the end of input; its first failure, which freezes its content for the retries; or its memory charge reaching twice batch_bytes. Within an INSERT the rows go as blocks of at most 16 MiB of payload (a single larger row goes alone). While an INSERT stays under the server's squash thresholds, min_insert_block_size_rows and min_insert_block_size_bytes, its blocks squash into one part per partition on the server. The defaults aim at large INSERTs: batch_rows equals ClickHouse's default row threshold and batch_bytes is 64 MiB, so at volume the one-second interval closes most INSERTs.
Parts. On a steady stream each subtask closes about one INSERT per batch_interval_ms, plus one per checkpoint, so the job sends about parallelism × 1000 / batch_interval_ms INSERTs a second, and each makes at least one part per partition it touches. That is the inserts_per_s_est figure in the open line, a floor. Many small INSERTs slow the table's merges; a few large ones do not. The sink warns at open when batch_rows is below 10000. At run time each subtask warns, at most once every 10 minutes, when over a minute its own INSERT rate times the parallelism comes to more than one INSERT a second and its INSERTs averaged fewer than 10000 rows. The remedy is a larger batch_interval_ms or a lower sink parallelism. Keep the table's PARTITION BY coarse: an INSERT that touches more partitions than max_partitions_per_insert_block fails with clickhouse.too_many_partitions.
Memory, per subtask. The queue between the task thread and the writer holds up to 16 MiB or 256 chunks; when it is full the task thread waits, which backpressures the job (clink_clickhouse_backpressure_blocked_ns). The writer holds the open INSERT's blocks until the server acknowledges them, for a resend. At the defaults that is about 80 MiB for a table of fixed-width columns, and up to about 144 MiB for one with String columns, whose converted blocks refer to the queued chunks rather than copying them (16 bytes per string value on top). A memory-limit split adds the copied halves while they are in flight.
- With a memory budget whose limit is L, the sink plans its buffers into half of it. If 16 MiB plus twice
batch_bytesexceeds L / 2, it reducesbatch_bytesto (L / 2 - 16 MiB) / 2 and logs a warning; the open line shows the reduced value. At the defaults the limit must be at least 301989888 bytes (288 MiB) to keepbatch_bytesunreduced. Below 37748736 bytes (36 MiB) open refusesclickhouse.memory_budget_too_small. For SQL and the embedded runtime,CLINK_EXECUTION_MEMORY_LIMIT_BYTESsets the budget, one per local subtask execution (see memory management). - The cap is advisory against a parent budget: it plans against the limit of the budget the sink sees, which may be shared with state and channels, and does not know how much of a parent's headroom other operators use. A reservation that would exceed the budget fails the task rather than waiting, as everywhere else in the engine.
- Without a budget the sink's buffers are unaccounted.
Compression: lz4, the default, is cheap on CPU. zstd sends fewer bytes for more CPU on both ends. none suits a fast local link.
TLS and failover¶
With secure='true' the sink connects to port 9440 unless port or endpoints says otherwise, sends the host name for SNI, and verifies the server's certificate and host name. tls_ca_file or tls_ca_dir replaces the default CA locations, so only the named CAs are trusted. Without either, the sink trusts OpenSSL's default CA locations, or SSL_CERT_FILE and SSL_CERT_DIR when the environment sets them. An OpenSSL linked statically may have been built to look where this system keeps no CAs. When its default file and directory are both missing and the environment names neither, the sink also trusts the first CA bundle or directory it finds among the standard locations, in the order the Kafka connector probes them: /etc/pki/tls/certs/ca-bundle.crt (RHEL, AlmaLinux, Rocky) comes first and /etc/ssl/certs/ca-certificates.crt (Debian, Ubuntu) later. The open line then shows tls=on (system CAs from <path>). The runtime image and a host build find OpenSSL's own defaults, so the lookup never applies to them. The Linux pyclink wheels (from the release after v0.10.0) are where it does: their static OpenSSL looks in /usr/lib/ssl, which Debian and Ubuntu provide through the openssl package and AlmaLinux does not, so on AlmaLinux 8, and on Debian or Ubuntu without that package, the sink trusts the distribution's bundle through this lookup. The wheels workflow checks both inside almalinux:8 and debian:bookworm-slim and publishes no wheel unless both pass. tls_verify='false' skips verification; the open line then shows tls=on (verify off). The CA is loaded when the client is built, and a CA that cannot be loaded refuses clickhouse.option_invalid at open rather than being retried.
endpoints names replicas of one shard; any replica accepts an INSERT. The sink connects to one endpoint at a time, moves to the next after a connect failure, and stays on one that works. Every new client re-runs the server checks before its first INSERT and counts in clink_clickhouse_reconnects_total. A client that reaches a different server (name, version or endpoint) logs one Warn line with the old and new identity.
A Distributed target is accepted, and the sink checks the local table on every replica of its cluster at open through clusterAllReplicas, so the sink's user needs the REMOTE grant and read access to those system tables (see the clickhouse.target_async_insert row). Replicas are named in reports and remedies as host:port, with the server's UUID added when two share both. A replica that is down at open is retried within the window, not refused.
Metrics and logs¶
The cross-connector series carry connector="clickhouse_native", separate from the text sink's clickhouse: clink_connector_records_total{direction="sink"} counts rows when their INSERT is acknowledged, never when buffered; clink_connector_bytes_total{direction="sink"} counts the bytes the writer puts on the socket, compressed blocks and protocol framing together; clink_connector_errors_total{direction="sink"} counts failed attempts, at open and in the writer, conversion failures included.
The sink's own series are tagged with its op_id:
| Name | Type | Extra tag | Meaning |
|---|---|---|---|
clink_clickhouse_inserts_total |
counter | outcome = ok, retried_ok, failed, abandoned |
INSERTs by final outcome |
clink_clickhouse_rows_total |
counter | rows acknowledged | |
clink_clickhouse_insert_latency_ns |
histogram | first BeginInsert to acknowledgement, retries included |
|
clink_clickhouse_block_rows |
histogram | rows per sent block | |
clink_clickhouse_retries_total |
counter | class = transient, in_doubt, merge_backpressure, resource, client_defect, unclassified |
retry attempts, while opening and during INSERTs |
clink_clickhouse_in_doubt_total |
counter | failures after the blocks were sent | |
clink_clickhouse_retry_wait_ns |
histogram | each backoff wait between attempts, while opening and during INSERTs | |
clink_clickhouse_queue_bytes |
gauge | bytes queued for the writer | |
clink_clickhouse_backpressure_blocked_ns |
histogram | time the task thread waited for queue space | |
clink_clickhouse_barrier_flush_ns |
histogram | time a checkpoint barrier waited for the INSERTs before it | |
clink_clickhouse_parts_backoff_total |
counter | "too many parts" retries | |
clink_clickhouse_refusals_total |
counter | reason = the refusal code |
refusals at deploy, open and run time. A deploy refusal comes before any operator id exists, so it carries reason only |
clink_clickhouse_reconnects_total |
counter | clients built after a failure, at open or by the writer | |
clink_clickhouse_rows_maybe_duplicated_total |
counter | rows resent after they were sent, other than under their own token to a target that keeps a log | |
clink_clickhouse_input_batches_total |
counter | carrier = columnar, row |
non-empty batches taken, columnar for a batch converted from its arrays, row for one read through its rows |
clink_clickhouse_columnar_declined_total |
counter | reason = event_time, unsupported_type, duplicate_name |
columnar batches handed back to the row path, before anything of them was queued |
The _ns histograms have buckets from 1e5 to 1.2e11 ns, and block_rows from 1 to 2e6 rows.
Retries while the sink opens move the same series as retries during INSERTs: retries_total, retry_wait_ns and, for merge back-pressure, parts_backoff_total, each counted once the retry window allows the retry. Each failed attempt also counts in clink_connector_errors_total and logs a Warn line, and reconnects_total rises once a later attempt connects, so an outage that keeps a restarted job from opening is visible on the same alerts.
Log lines go to the sink.clickhouse source. Under clink run and pyclink, which have no metrics listener, they are the sink's report. Each subtask logs one line at open, at Info:
clickhouse native sink open: subtask=3/8 factory=clickhouse_native_sink mode=append delivery=at_least_once table=`analytics`.`events` engine=ReplicatedMergeTree server=26.8.15 (tested) endpoint=ch-1:9000 tls=off compression=lz4 dedup_setting=deduplicate_insert strict_limits=sent token_on_resend=kept dedup_window=replicated_deduplication_window=10000 (server default) async_insert=0 (table unset, server default 0) columns=12 omitted=2 batch_rows=1048449 batch_bytes=67108864 batch_interval_ms=1000 retry_window_ms=600000 inserts_per_s_est=8.0+ckpt eos_bound_ms=30000
serveris(tested)on 26.3 and 26.8 and(accepted, untested)otherwise.tlsisoff,onoron (verify off);dedup_settingisdeduplicate_insertorinsert_deduplicate;strict_limitsissentornot_sent;token_on_resendiskeptorfresh.dedup_windowandasync_insertsay where each value came from (the table, the server default, or, for Distributed, each replica byhost:port). A Null target reportsasync_insert=0 (Null table, the query's own setting)anddedup_window=none (Null table).batch_bytescarries(reduced from N for the memory budget)when the budget capped it.eos_bound_msisCLINK_EOS_FINAL_CKPT_TIMEOUT_MSas this worker read it, reported whether or not the job is bounded.
Warnings may follow it: the server is on an untested line; the target keeps no deduplication log (also true of every Null and Distributed target), so an INSERT resent after a failure that left it in doubt may land twice; batch_rows is below 10000; batch_bytes was reduced for the memory budget. Subtask 0 also logs the column plan, one line per column with its declared type (sql= and the SQL type, or arrow= and the Arrow type for a typed struct's field), target type and conversion, and the effective options, with the password shown as set or unset. A typed sink's open line shows factory=make_clickhouse_native_sink input=typed, and its options line columns=<n> from the batcher schema where a SQL sink's shows sql_column_types.
Each retry logs a Warn line with the attempt, the phase, the class, the error and the wait; a retry at open also names the endpoint. A clean close logs a summary at Info, and a cancelled or failed task logs the same fields at Warn with the prefix clickhouse native sink cancelled::
clickhouse native sink closed: subtask=3/8 rows_acknowledged=10485760 inserts=11 retries=transient:2,in_doubt:1,merge_backpressure:0,resource:0,client_defect:0,unclassified:0 in_doubt=1 rows_resent_with_token=953211 rows_maybe_duplicated=0 abandoned_rows=0 wire_bytes=391002113 elapsed_ms=61210 columnar_batches=160 row_batches=0
inserts counts the INSERTs the server acknowledged, both halves of a split included. abandoned_rows counts every submitted row the server had not acknowledged when the sink stopped: the INSERT in flight, the rest of a chunk the writer was part-way through, and the rows still waiting in its queue. With rows_acknowledged it accounts for every row the sink took. Either way they were after the last completed checkpoint, so the restart replays them; an abandoned INSERT larger than the server's squash threshold may already have written some parts, so duplicates are possible and loss is not.
columnar_batches and row_batches count the batches the sink queued by how they arrived, as clink_clickhouse_input_batches_total does; they are how a clink run or pyclink job shows which path its batches took. The first time a subtask hands back batches of a given schema it logs one Info line, takes columnar batches of this schema through their rows (<reason>), with the schema's column names and types.
Setting CLINK_DISABLE_COLUMNAR=1 in a worker's environment sends every batch through its rows, at the sink as at every columnar operator, which is the A/B for a suspected difference between the two paths: the landed values must be the same, and row_batches takes the place of columnar_batches. The text sink (format='json') is row-only and stays so: it writes one JSON text per row, so a columnar batch is turned into rows before it, and no columnar path for it is planned.
C++: typed structs¶
clink/clickhouse/native_sink.hpp writes a C++ struct to ClickHouse through the same native sink, with no SQL. Declare the struct's fields with CLINK_FIELDS and the sink takes its column types from the Arrow schema the declaration derives, so there is no sql_column_types and no encoder to write. The header is installed with clink::clickhouse and is Internal: the helpers and their options may still change between releases.
#include <clink/clickhouse/native_sink.hpp>
struct Trade {
std::int64_t id;
std::uint32_t qty;
std::optional<std::uint64_t> volume;
double px;
std::string venue;
};
CLINK_FIELDS(Trade, id, qty, volume, px, venue);
// A Dag-direct job.
auto sink = clink::clickhouse::make_clickhouse_native_sink<Trade>(
{{"host", "clickhouse.internal"}, {"database", "markets"}, {"table", "trades"}});
dag.add_sink<Trade>(handle, sink);
// A plugin job: after register_type<Trade>(), the sink is the factory
// clickhouse_native_sink_Trade, which a SinkDescriptor names with its options.
clink::clickhouse::register_clickhouse_native_sink<Trade>(pipeline.registry());
make_clickhouse_native_sink<T>(options)builds the sink for aCLINK_FIELDSstruct throughmake_columnar_arrow_batcher<T>().make_clickhouse_native_sink<T>(batcher, options, subtask_idx, parallelism)takes anyArrowBatcher<T>instead, and the subtask for the open report and the INSERT'slog_comment; a batcher with no schema or no build function is refused withstd::invalid_argument. Both return astd::shared_ptr<Sink<T>>, which is whatDag::add_sinktakes.register_clickhouse_native_sink<T>(registry, op_type)registers the sink as a factory,clickhouse_native_sink_factory<T>(), whose sinks take the op's params as their options and its subtask and parallelism. The defaultop_typeisclickhouse_native_sink_<channel>, where<channel>is the nameTwas registered under (the declared type name, forregister_type<T>()). The delivery-guarantee gate finds the sink's at-least-once record,clickhouse_native, by that prefix, so keep it first on any other name:<channel>_clickhouse_native_sinkwould leave the sink undeclared. The helper does not registerT; callregister_type<T>()first.- The options are the native sink's, with the same parsing,
env://resolution, tolerated keys and refusals as in the options table, and the typed sink refusessql_column_typeswithclickhouse.option_invalid("the typed sink takes its column types from the ArrowBatcher schema; remove sql_column_types"). An option is refused when the sink is made, before anything connects, and counted inclink_clickhouse_refusals_total{reason}in the process-wide registry of the code that made it. A job module carries its own copy of that registry, so a refusal raised while a worker builds the module's sink fails the task with the coded message but is not counted in the worker's registry. - The engine's
event_timecolumn is dropped: field 0 of the batcher's schema, whenever it is an int64 namedevent_time, is taken for it. TheCLINK_FIELDSbatcher puts the engine's column there, so a struct field of the same name is an ordinary column. A hand-written batcher must put the engine's column first, or give its own field 0 another name: an int64event_timeof its own in that place is dropped too, and a target column with aDEFAULTtakes the default with no error. - The guarantee and its conditions are the SQL sink's: at-least-once, every checkpoint barrier mode, and the only sink on its chain, each as What it guarantees describes. The sink holds no state, so it needs no uid.
- Batches of
Tare turned into Arrow chunks on the task thread by the batcher. The typed sink takes no columnar batch: typed batches reach it as records. A batcher that returns no chunk fails the task with "clickhouse native sink: the ArrowBatcher could not build a chunk", and one whose build throws fails it with its own error, in both cases after stopping the writer. - A job module that uses the helper links
clink::clickhouse, which carries the sink: the worker builds it with the module's own code and reports its series in the worker's registry. A consumer project asks for the component,find_package(clink REQUIRED COMPONENTS core clickhouse), which also finds the OpenSSL and abseil the installed client links.docs/consumer-examples/12_clickhouse_native_typed.cppis a complete program. - On a build without the native sink the helpers still compile and link, and making the sink refuses
clickhouse.native_unavailable.
Each field maps to a declared kind, which binds to ClickHouse columns as Type mapping and Column matching describe for the SQL type named here, and a refusal names a change to the struct or its CLINK_FIELDS declaration rather than to a SELECT:
| Struct field | Arrow type | Binds as | ClickHouse targets |
|---|---|---|---|
std::int8_t to std::int64_t |
int8 to int64 | TINYINT, SMALLINT, INTEGER, BIGINT |
any Int8 to Int128 and UInt8 to UInt64; a narrower or unsigned target checks each value |
std::uint8_t to std::uint64_t |
uint8 to uint64 | unsigned of that width | the same width unsigned exactly; a wider unsigned, a wider signed or Int128 widened; a narrower unsigned, or a signed one of the same or a narrower width, checked per value. Bool, Float* and String are refused |
float |
float | REAL |
Float32, Float64 |
double |
double | DOUBLE |
Float64 |
bool |
bool | BOOLEAN |
Bool, UInt8 |
std::string |
utf8 | VARCHAR |
String (zero-copy), FixedString(N), Enum8, Enum16, UUID, IPv4, IPv6 |
std::optional<E> |
E's type, nullable |
as E |
a Nullable target takes the nulls; a null into a non-Nullable column fails the row |
std::vector<E> |
list | E ARRAY |
Array(...) of a target E binds to |
std::map<K, V> |
map | MAP<K, V> |
Map(...) of targets K and V bind to |
a nested CLINK_FIELDS struct |
struct | ROW |
a Tuple with the same number of elements, matched by position; a named Tuple's names must equal the field names |
A field whose ArrowColumnTraits specialisation produces decimal128(p, s), date32 or timestamp(unit[, tz]) binds as DECIMAL(p, s), DATE or TIMESTAMP (WITH TIME ZONE when the type carries one). A timestamp is scaled from its own unit, seconds, milliseconds, microseconds or nanoseconds, exactly to the target's, and a value with digits the target cannot hold fails the row rather than being floored. Any other Arrow type, binary, large_utf8, date64 and decimal256 among them, is refused at open in the one clickhouse.column_plan message that lists every problem.
Example¶
Programmatic use through the fluent builder for the text sink (clink/api/clickhouse_builders.hpp), which produces a SinkDescriptor for the clickhouse_sink factory:
#include "clink/api/clickhouse_builders.hpp"
auto sink = clink::api::ClickHouseSink::builder()
.host("clickhouse.internal")
.port(9000)
.database("analytics")
.table("events")
.user("writer")
.password("secret")
.format("jsoneachrow")
.batch_rows(5000)
.batch_interval_ms(2000)
.build();
// `sink` is a SinkDescriptor (op_type = "clickhouse_sink", channel = "string").
Using the typed connector class directly:
#include "clink/connectors/clickhouse_sink.hpp"
clink::ClickHouseSink::Options opts;
opts.host = "clickhouse.internal";
opts.table = "events";
opts.format = clink::ClickHouseSink::Format::JSONEachRow;
clink::ClickHouseSink sink(std::move(opts));
sink.open();
// sink.on_data(batch); sink.flush(); sink.close();
SQL reaches the native sink through insert_format='native', and C++ through the typed helper in C++: typed structs. The source side is reached through the registered factories (clickhouse_row_source, clickhouse_text_source, clickhouse_source), each requiring a query.
Delivery semantics¶
Native sink: at-least-once, as set out in What it guarantees.
Text sink: at-least-once, as the only sink on its chain. Records are buffered and flushed by row count (batch_rows), by time (batch_interval_ms), at every checkpoint barrier, and at close(). The barrier flush runs in on_barrier, before the runner snapshots and acks, and the INSERT is synchronous, so no row consumed before a completed checkpoint can still be sitting in the buffer when the process dies. A flush that fails at the barrier throws, which fails that checkpoint. Before the barrier flush existed (up to and including v0.8.0) a row buffered across a checkpoint was lost if the process died before the next size or time flush, which batch_rows='1' avoids on those versions.
That ordering needs the text sink to own its chain's checkpoint, which it does only while it is the only sink on the chain. A second sink on the same chain would hand the checkpoint back to the operator in front of the sinks, which acknowledges it as the barrier passes, before either sink has written what it holds: the checkpoint would complete while this sink still held rows from before the barrier, and a restore from it would never replay them. So Dag::add_sink refuses another sink beside the text sink, whichever of the two is added first, with a message that names the sink and says it must be the only sink on its chain; give the other sink its own subtask, as for the native sink. A job graph, SQL included, plans every sink as a subtask of its own, and the opt-in par-1 fusion does not fold a sink into a chain that also sends a side output to another subtask, so neither builds that arrangement.
There is no two-phase commit, and a replay is not deduplicated. After a failure the job resumes from the last completed checkpoint and the rows since then are inserted again. Because every INSERT carries a fresh deduplication token, a replayed batch lands again on a replicated table even when its rows match a batch that landed before the restart; without a token the server's content hashing could absorb such an identical replay. A ReplacingMergeTree keyed by the row's natural key absorbs the duplicates on the ClickHouse side. The SQL planner reflects this: it rejects exactly_once and mode='upsert'.
Source: a SELECT materialises a finite (bounded) result set. The source persists a cursor (the row index into the materialised snapshot) and can resume mid result-set after a restart; open() clamps a restored cursor to the re-materialised row count. Exactly-once at the source boundary holds only for a deterministically ordered query (an explicit ORDER BY) over data unchanged between runs, because row index N is "the same row" only under those conditions. The SQL source binding treats it as a bounded query with no cursor checkpoint.
Limitations¶
- Text sink input is a single
std::stringper record, interpreted as one row (TSV or JSONEachRow). It is not a multi-column typed insert at the C++ sink layer; multi-column Rows are serialised to a JSON object string upstream (the SQL path) before the sink sees them. - Text sink batches are concatenated in memory and inserted with
client.Execute(); there is no streaming insert, no 2PC, no upsert, and no deduplication of a replayed batch. A failed flush at a barrier throws, which fails that checkpoint rather than completing it over rows ClickHouse never received. - The text sink must be the only sink on its chain;
Dag::add_sinkrefuses another sink beside it. - The native sink appends only: no exactly-once, no
mode='upsert', nochangelog='true', no full-refresh materialised views. - The native sink refuses SharedMergeTree targets and asynchronous-insert targets, and must be the only sink on its chain.
- A resent INSERT is deduplicated only on targets that keep a deduplication log, and only on 26.3 and 26.8.
- A retry that holds the barrier beyond
CLINK_EOS_FINAL_CKPT_TIMEOUT_MS(default 30 s) at the end of a bounded job or at a hot cutover spends a restart; nothing is lost. - The native sink converts a columnar batch from its arrays only when its event-time column is int64 and every value column has a type the Row sidecar carries; any other columnar batch goes through its row accessors, and
clink_clickhouse_columnar_declined_totalsays why. The text sink takes rows only. - The native sink does not accept
tls_server_name, does not pin a certificate, and does not create or alter tables. It authenticates by user and password only. - The typed helper (
clink/clickhouse/native_sink.hpp) is Internal, so its signatures and options may change in a minor release. It takes records only, never a columnar batch, and has no option to keep the engine'sevent_timecolumn. - A plugin job that registers the typed sink is proven on a cluster whose worker also links the ClickHouse impl, as the runtime image's does. A worker without it is untested.
- Source is a one-shot bounded
SELECT, not a streaming tail or CDC feed. Result-row order is arbitrary without an explicitORDER BY. - The
clickhouse_source(string-channel JSON) requires column names from the server; if they are absent it fails loudly rather than emit positional keys. - The text sink and the source are not Arrow-native; records cross the boundary as
std::stringor typedClickHouseRowtext values, with type coercion left to downstream operators.
Testing¶
The text sink and source tests run in process. They do not stand up a ClickHouse server; they gate on whether the build linked clickhouse-cpp. ClickHouseSinkChain.* (test_clickhouse_sink_chain.cpp) runs the text sink in real Dags under a LocalExecutor, with a stand-in for the server that counts the rows it takes and can hold an INSERT: a second sink is refused on its chain in either order, with or without an operator in front; as the only sink, a checkpoint is not acknowledged while the barrier's INSERT is held, and is acknowledged with every row from before the barrier taken; a refused barrier flush fails the job and acknowledges no checkpoint. The case that holds the INSERT and builds the text sink beside another sink saw checkpoint 1 acknowledged with none of its 10 rows written before the refusal existed. scripts/clickhouse-live.sh runs the SQL-linked ClickHouseLegacySqlLive.* against real servers on 25.3, 26.3 and 26.8: through an ordinary user with only INSERT on the table, format='json' and batch_rows='1' land every row once, and system.query_log shows each INSERT carried async_insert=0, wait_for_async_insert=1 and its own deduplication token; and a statement set feeding the text sink and a collect sink from one source plans the two sinks into tasks of their own and lands every row in each. The in-process tests:
- If
clickhouse-cppis not linked,ClickHouseSink::is_real_implementation()/ClickHouseSource::is_real_implementation()return false and the real-impl testsGTEST_SKIP(). - When linked, the tests exercise the constructor and the lifecycle (
open()against an unreachable port should throw cleanly;flush()/close()beforeopen()must be safe), plus factory registration and the fluent builder.
The native sink's tests, built when the client is 2.6.2 or later:
- Unit and in-process suites in
clink_clickhouse_tests(theNative*suites,--gtest_filter='Native*'), labelclickhouse. They cover options, statements, error classes, retry pacing, the type parser and column plan, both converters, the target probe and the writer and sink against an in-process fake server (impls/clickhouse/tests/fake_transport.hpp), with no ClickHouse needed. - The typed helper's suite,
impls/clickhouse/tests/test_native_typed_sink.cpp(NativeTypedSink*): aCLINK_FIELDSstruct throughmake_clickhouse_native_sink<T>and through the factoryregister_clickhouse_native_sink<T>registers, against the fake server, with every value checked against the struct, the barrier contract, the chain rule, the barrier modes, the option refusals, the typed open report, and a batcher that builds no chunk or whose build throws.test_factory_registration.cppchecks the registration on every build, and on a build without the native sink that making the sink refusesclickhouse.native_unavailable. - SQL-linked suite,
clink_clickhouse_sql_tests(tests/test_clickhouse_native_sql.cpp), built with the SQL frontend, labelclickhouse: the sink driven through SQL against the fake server, including a differential against the collect sink, the keys the planner puts on the op, held barriers on the periodic and bounded paths, and two INSERTs from one source. A q0-shaped pipeline, a Kafka JSON table straight into the native sink, proves that no row is built on the way: the process-wide materialisation count stays at 0 while the sink'scolumnarcount equals the batches it received, against a collect-sink control that does materialise; a second registration runs it withCLINK_DISABLE_COLUMNAR=1. It runs withCLINK_EOS_FINAL_CKPT_TIMEOUT_MS=3000set by CTest. The plugin route is here too: a job module,tests/plugin_examples/clickhouse_typed_job.cpp, registers the typed sink for its own struct, and aTestClusterloads it and runs it against a closed port, where the coordinator reports the at-least-once guarantee forclickhouse_native_sink_Tradeand the task fails with the sink's own connection error. - Live suites,
impls/clickhouse/tests/test_native_live.cppinclink_clickhouse_tests(ClickHouseNativeLive,ClickHouseNativeLiveReplicated,ClickHouseNativeLiveTls), andClickHouseNativeSqlLiveinclink_clickhouse_sql_tests, which writes DATE, every TIMESTAMP precision, TIMESTAMPTZ, a BIGINT past 2^53, a decimal and nullable columns through SQL into a real table and compares what ClickHouse holds with what the collect sink saw.ClickHouseNativeLive.ATypedStructWithEveryLeafRoundTripswrites a struct with every leafCLINK_FIELDSemits, unsigned values past the signed range included, and each composite through the typed helper, andClickHouseNativeSqlLive.TheTypedJobModulesSinkLandsEveryRowAndCountsInTheWorkersRegistryruns the job module on aTestClusteragainst the server and finds the sink's series in the worker's registry. All skip unlessCLINK_CLICKHOUSE_TEST_HOSTnames a server. - Kill matrix,
tests/integration/test_clickhouse_native_recovery.cppinclink_integration_tests: a coordinator and two workers against real servers, with faults at the sink's own points, in the network and in the server. Nine cells, each on both lines and into both a ReplicatedMergeTree and a plain MergeTree, with no loss as the pass mark in every cell.
Run the in-process suites with:
cmake -S . -B build -DCLINK_WITH_CLICKHOUSE=ON -DCLINK_BUILD_TESTS=ON
cmake --build build --parallel 10 --target clink_clickhouse_tests clink_clickhouse_sql_tests
ctest --test-dir build -L clickhouse
Live suites and the kill matrix¶
scripts/clickhouse-live.sh runs the live suites against real servers, which it starts from docker/integration-services.yml (pinned by digest) one profile at a time and removes, with their data volumes, as soon as each profile is done and again on exit. It needs Docker and the built test binaries.
scripts/clickhouse-live.sh # 26.3 and 26.8, binaries under build/
CLICKHOUSE_LINES="26.8" scripts/clickhouse-live.sh # one line
For each line it runs, in order:
| Profile | Services | Host ports (26.3 / 26.8) | What runs |
|---|---|---|---|
| Plain | the line's server and its async-default twin, whose <merge_tree> sets async_insert=1 |
19103 and 19113 / 19108 and 19118 | ClickHouseNativeLive.*, then the SQL-linked ClickHouseNativeSqlLive.*, then ClickHouseLegacySqlLive.* on its own |
| Replicated | two replicas sharing one Keeper, whose <replicated_merge_tree> sets async_insert=1 |
19133 and 19143 / 19138 and 19148 | pin P18, then ClickHouseNativeLiveReplicated.*, whose failover case shuts the first replica down |
| TLS, 26.8 only | a server with the native port over TLS; the script generates a CA, a certificate for localhost and an unrelated CA |
19158 plain, 19458 TLS | ClickHouseNativeLiveTls.* |
Last, it runs ClickHouseLegacySqlLive.* against a 25.3 server on port 19203, for the legacy sink's round trip. A failing case fails the run. Each ClickHouseLegacySqlLive.* run writes a googletest report under REPORT_DIR (default <BUILD_DIR>/clickhouse-live-reports), and scripts/check-gtest-reports.py fails the run unless every one of its cases passed: a case that skips, because its server variable never reached it, fails the run rather than reading as a pass.
It sets these for the test binaries, per profile: CLINK_CLICKHOUSE_TEST_HOST, CLINK_CLICKHOUSE_TEST_PORT, CLINK_CLICKHOUSE_TEST_ASYNC_DEFAULT_PORT, CLINK_CLICKHOUSE_TEST_REPLICA_PORTS (<r1>,<r2>), CLINK_CLICKHOUSE_TEST_RMT_ASYNC_DEFAULT=1, CLINK_CLICKHOUSE_TEST_TLS_PORT, CLINK_CLICKHOUSE_TEST_TLS_CA, CLINK_CLICKHOUSE_TEST_TLS_WRONG_CA and CLINK_CLICKHOUSE_TEST_LINE. CLINK_CLICKHOUSE_TEST_USER and CLINK_CLICKHOUSE_TEST_PASSWORD pass through when set. Its own knobs:
| Variable | Effect |
|---|---|
CLICKHOUSE_LINES |
the lines to run, default 26.3 26.8 |
BUILD_DIR |
the build to take the binaries from, default build/ |
BIN |
the clink_clickhouse_tests binary to run, overriding the one in BUILD_DIR |
SQL_BIN |
the clink_clickhouse_sql_tests binary, likewise |
CLICKHOUSE_LIVE_SQL=0 |
skip the SQL-linked steps, for a build without that binary |
TYPED_JOB |
the typed job module, clickhouse_typed_job.so, found under BUILD_DIR by default and handed to the SQL-linked binary as CLINK_CLICKHOUSE_TYPED_JOB; the plugin-route case skips without it |
PIN_RUNNER |
a prefix for every test command; CI runs the binaries inside the toolchain image this way |
CLICKHOUSE_LIVE_TMPDIR |
where the TLS material is generated, default TMPDIR |
scripts/clickhouse-live.sh and scripts/clickhouse-pins.sh use the same host ports, so run one at a time.
The kill matrix needs -DCLINK_INTEGRATION_TESTS=ON, Docker, and the build's clink_node and clink_submit_sql with fault injection, which a test build compiles in by default. It starts its own uniquely named ClickHouse and Keeper containers on free ports, from the images and configuration the compose file pins, so it does not collide with a compose stack, plus a Kafka broker of its own. It skips when Docker or the binaries are missing. A full pass of both lines takes about 45 minutes, so CI runs it nightly rather than on every push: .github/workflows/clickhouse-recovery.yml, also started by hand from the Actions tab, fails if any cell fails or if the matrix ran fewer than its 36 cells, so a run that skipped for want of Docker cannot read as green. Locally:
cmake -S . -B build -DCLINK_WITH_CLICKHOUSE=ON -DCLINK_BUILD_TESTS=ON -DCLINK_INTEGRATION_TESTS=ON
cmake --build build --parallel 10 --target clink_integration_tests clink_node clink_submit_sql
build/tests/clink_integration_tests --gtest_filter='BothLines/ClickHouseNativeRecovery.*'
Each cell prints one [kill-matrix] line with what it measured. CLINK_KILL_MATRIX_KEEP=1 keeps every cell's logs and checkpoints, passing or not, and prints where they are.
Server pins¶
impls/clickhouse/tests/test_clickhouse_pins_live.cpp holds what the native sink's design takes as given about the server, checked against a live server on every supported line. It drives the native protocol directly, with the BeginInsert / SendInsertBlock / EndInsert shape the sink uses, and skips unless CLINK_CLICKHOUSE_TEST_HOST (and optionally CLINK_CLICKHOUSE_TEST_PORT) names a server. scripts/clickhouse-pins.sh starts each line's plain and async-default servers from docker/integration-services.yml, pinned by digest and with an embedded Keeper and a one-shard cluster, runs the suite, and tears them down. P18 needs a server whose <replicated_merge_tree> sets async_insert=1, so scripts/clickhouse-live.sh runs it on its replicated profile instead. The clickhouse-pins CI job ("ClickHouse pins and live suite") runs both scripts on every push and pull request to main. A failing pin changes the design, not the expectation.
Every pin below passes on 26.3 and 26.8. P9, P16, P17 and P20 also record a measurement, given with the pin.
| Pin | What it checks |
|---|---|
| P1 | SETTINGS written into the BeginInsert text take effect (system.query_log shows them) |
| P2 | A token deduplicates its own resend; identical data under a new token lands |
| P3 | Without a token, two identical, separate blocks deduplicate on ReplicatedMergeTree, so every INSERT needs a token |
| P4 | A multi-partition INSERT whose first partitions landed, resent whole under its token, lands each partition once |
| P5 | Several SendInsertBlock calls in one INSERT squash into one part, and the INSERT resent under its token lands once |
| P6 | A table-level async_insert=1 overrides a query's async_insert=0, as does a server-wide <merge_tree> default, and behind a Distributed table the shard-local table's setting applies |
| P7 | With conversion off, a Nullable block is refused for a non-Nullable column; String is accepted into LowCardinality(String) |
| P8 | FINAL hides a key whose latest version is deleted, without the cleanup setting; merges never collapse a key across partitions |
| P9 | max(ver) is cheap: about 20 ms over ten million rows on a laptop (a measurement, not a pass mark) |
| P11 | Every setting name the sink sends exists, min_insert_block_size_bytes included, and use_strict_insert_block_limits exists from 26.8 |
| P12 | distributed_foreground_insert=1 makes an INSERT into a Distributed table synchronous |
| P13 | max_execution_time with timeout_overflow_mode='throw' ends a SELECT with TIMEOUT_EXCEEDED |
| P14 | A Bool column sent as the client's ColumnBool is accepted into a Bool target with conversion off |
| P15 | String is accepted into LowCardinality(Nullable(String)), and FixedString(N) into LowCardinality(FixedString(N)), with conversion off |
| P16 | The Date32 and DateTime64 bounds the converter enforces, 1900-01-01 and 2299-12-31 (23:59:59.999 for DateTime64(3)), round-trip exactly. One unit past them is recorded, and the lines differ: 26.8 stores and reads back 1899-12-31, 2300-01-01, 1899-12-31 23:59:59.999 and 2300-01-01 00:00:00.000 unchanged, while 26.3 clamps Date32 to 1900-01-01 and 2299-12-31 and reads DateTime64(3) back as 1900-01-01 00:00:00.999 and 2299-12-31 23:00:00.000 |
| P17 | A kept-token resend under a profile with a different min_insert_block_size_rows lands exactly once when the statement pins both squash thresholds, as the sink's does: 3000 of 3000 rows in 5 of 5 runs on each line. Without them it landed 4000 rows, 1000 duplicated, on both lines; in exploratory runs with a real connection drop mid-INSERT on 26.8, an unpinned resend duplicated a block in some runs and once lost one (2000 distinct rows of 3000) |
| P18 | A server-wide <replicated_merge_tree> async_insert=1 reaches ReplicatedMergeTree tables, which take the INSERT asynchronously despite async_insert=0 in the query; system.replicated_merge_tree_settings reports it and system.merge_tree_settings does not |
| P19 | Every metadata SELECT of the open checks, as the sink's statement builders write it, returns only String columns, and the real transport reads each one: the cluster reads with their leading host:port and serverUUID() columns, and the system.macros read (whose rows only the fake server exercises, since the pin servers define no macros) |
| P20 | While two dependent materialised views sleep, the server sends nothing until the INSERT is done: EndInsert took about 5.02 s, with 3 socket reads in all and one silence of the whole 5 s, on both lines (a measurement, not a pass mark) |
P4 reproduces the partial landing by inserting the prefix of the same block's partitions under the token first, because a real failure lands the partitions written before it. P17 reproduces the landed first part the same way, because when a part lands mid-INSERT varies from run to run. P6's Distributed case sets prefer_localhost_replica=0: its shard is the same server, and with the default the Distributed table writes the local table in-process, where the shard's own decision never runs.
What they mean for the sink. P16: on 26.3 a value past the bounds would change silently, so the converter's refusal outside 1900 to 2299 is required there; on 26.8 it is narrower than what the server keeps. P17 is why every INSERT pins the thresholds the server reported at open. P20 is why a slow view needs a receive_timeout_ms above its processing time (see Retries, timeouts and cancel).
P10 is on the engine side, in the SQL-linked suite (ClickHouseNativeSql.TheJsonDecodeHandsTimestampsAndDatesOnAsWritten): TIMESTAMP(0), TIMESTAMP(3), TIMESTAMP(6), TIMESTAMP(9), TIMESTAMP WITH TIME ZONE and DATE values pass through both JSON decodes, the row bridge and the columnar one, unscaled and exactly as written, so a Row timestamp is epoch milliseconds whatever its declared precision. The columnar decode is fed timestamps as text, which it sends to the row decode, and as integers, which it carries as millisecond timestamps.