Skip to main content

vector/sinks/clickhouse/
config.rs

1//! Configuration for the `Clickhouse` sink.
2
3use std::fmt;
4
5use http::{Request, StatusCode, Uri};
6use hyper::Body;
7use vector_lib::codecs::encoding::ArrowStreamSerializerConfig;
8use vector_lib::codecs::encoding::format::SchemaProvider;
9
10use super::{
11    request_builder::ClickhouseRequestBuilder,
12    service::{ClickhouseRetryLogic, ClickhouseServiceRequestBuilder},
13    sink::{ClickhouseSink, PartitionKey},
14};
15use crate::{
16    http::{Auth, HttpClient, MaybeAuth},
17    sinks::{
18        prelude::*,
19        util::{RealtimeSizeBasedDefaultBatchSettings, UriSerde, http::HttpService},
20    },
21    template::ConfinementConfig,
22};
23
24/// Data format.
25///
26/// The format used to parse input/output data.
27///
28/// [formats]: https://clickhouse.com/docs/en/interfaces/formats
29#[configurable_component]
30#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Hash)]
31#[serde(rename_all = "snake_case")]
32#[allow(clippy::enum_variant_names)]
33pub enum Format {
34    #[default]
35    /// JSONEachRow.
36    JsonEachRow,
37
38    /// JSONAsObject.
39    JsonAsObject,
40
41    /// JSONAsString.
42    JsonAsString,
43
44    /// ArrowStream (beta).
45    #[configurable(metadata(status = "beta"))]
46    ArrowStream,
47}
48
49/// Batch encoding configuration for the `clickhouse` sink.
50#[configurable_component]
51#[derive(Clone, Debug)]
52#[serde(tag = "codec", rename_all = "snake_case")]
53#[configurable(metadata(
54    docs::enum_tag_description = "The codec to use for batch encoding events."
55))]
56pub enum ClickhouseBatchEncoding {
57    /// Encodes events in [Apache Arrow][apache_arrow] IPC streaming format.
58    ///
59    /// This is the streaming variant of the Arrow IPC format, which writes
60    /// a continuous stream of record batches.
61    ///
62    /// [apache_arrow]: https://arrow.apache.org/
63    ArrowStream(ArrowStreamSerializerConfig),
64}
65
66impl fmt::Display for Format {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        match self {
69            Format::JsonEachRow => write!(f, "JSONEachRow"),
70            Format::JsonAsObject => write!(f, "JSONAsObject"),
71            Format::JsonAsString => write!(f, "JSONAsString"),
72            Format::ArrowStream => write!(f, "ArrowStream"),
73        }
74    }
75}
76
77/// Configuration for the `clickhouse` sink.
78#[configurable_component(sink("clickhouse", "Deliver log data to a ClickHouse database."))]
79#[derive(Clone, Debug, Default)]
80#[serde(deny_unknown_fields)]
81pub struct ClickhouseConfig {
82    /// The endpoint of the ClickHouse server.
83    #[serde(alias = "host")]
84    #[configurable(metadata(docs::examples = "http://localhost:8123"))]
85    pub endpoint: UriSerde,
86
87    /// The table that data is inserted into.
88    #[configurable(metadata(docs::examples = "mytable"))]
89    pub table: Template,
90
91    /// The database that contains the table that data is inserted into.
92    #[configurable(metadata(docs::examples = "mydatabase"))]
93    pub database: Option<Template>,
94
95    /// The format to parse input data.
96    #[serde(default)]
97    pub format: Format,
98
99    /// Sets `input_format_skip_unknown_fields`, allowing ClickHouse to discard fields not present in the table schema.
100    ///
101    /// If left unspecified, use the default provided by the `ClickHouse` server.
102    #[serde(default)]
103    pub skip_unknown_fields: Option<bool>,
104
105    /// Sets `date_time_input_format` to `best_effort`, allowing ClickHouse to properly parse RFC3339/ISO 8601.
106    #[serde(default)]
107    pub date_time_best_effort: bool,
108
109    /// Sets `insert_distributed_one_random_shard`, allowing ClickHouse to insert data into a random shard when using Distributed Table Engine.
110    #[serde(default)]
111    pub insert_random_shard: bool,
112
113    #[configurable(derived)]
114    #[serde(default = "Compression::gzip_default")]
115    pub compression: Compression,
116
117    #[configurable(derived)]
118    #[serde(default, skip_serializing_if = "crate::serde::is_default")]
119    pub encoding: Transformer,
120
121    /// The batch encoding configuration for encoding events in batches.
122    ///
123    /// When specified, events are encoded together as a single batch.
124    /// This is mutually exclusive with per-event encoding based on the `format` field.
125    #[configurable(derived)]
126    #[serde(default)]
127    pub batch_encoding: Option<ClickhouseBatchEncoding>,
128
129    #[configurable(derived)]
130    #[serde(default)]
131    pub batch: BatchConfig<RealtimeSizeBasedDefaultBatchSettings>,
132
133    #[configurable(derived)]
134    pub auth: Option<Auth>,
135
136    #[configurable(derived)]
137    #[serde(default)]
138    pub request: TowerRequestConfig,
139
140    #[configurable(derived)]
141    pub tls: Option<TlsConfig>,
142
143    #[configurable(derived)]
144    #[serde(
145        default,
146        deserialize_with = "crate::serde::bool_or_struct",
147        skip_serializing_if = "crate::serde::is_default"
148    )]
149    pub acknowledgements: AcknowledgementsConfig,
150
151    #[configurable(derived)]
152    #[serde(default)]
153    pub query_settings: QuerySettingsConfig,
154
155    #[configurable(derived)]
156    #[serde(flatten)]
157    pub confinement: ConfinementConfig,
158}
159
160/// Query settings for the `clickhouse` sink.
161#[configurable_component]
162#[derive(Clone, Copy, Debug, Default)]
163#[serde(deny_unknown_fields)]
164pub struct QuerySettingsConfig {
165    /// Async insert-related settings.
166    #[serde(default)]
167    pub async_insert_settings: AsyncInsertSettingsConfig,
168}
169
170/// Async insert related settings for the `clickhouse` sink.
171#[configurable_component]
172#[derive(Clone, Copy, Debug, Default)]
173#[serde(deny_unknown_fields)]
174pub struct AsyncInsertSettingsConfig {
175    /// Sets `async_insert`, allowing ClickHouse to queue the inserted data and later flush to table in the background.
176    ///
177    /// If left unspecified, use the default provided by the `ClickHouse` server.
178    #[serde(default)]
179    pub enabled: Option<bool>,
180
181    /// Sets `wait_for`, allowing ClickHouse to wait for processing of asynchronous insertion.
182    ///
183    /// If left unspecified, use the default provided by the `ClickHouse` server.
184    #[serde(default)]
185    pub wait_for_processing: Option<bool>,
186
187    /// Sets 'wait_for_processing_timeout`, to control the timeout for waiting for processing asynchronous insertion.
188    ///
189    /// If left unspecified, use the default provided by the `ClickHouse` server.
190    #[serde(default)]
191    pub wait_for_processing_timeout: Option<u64>,
192
193    /// Sets `async_insert_deduplicate`, allowing ClickHouse to perform deduplication when inserting blocks in the replicated table.
194    ///
195    /// If left unspecified, use the default provided by the `ClickHouse` server.
196    #[serde(default)]
197    pub deduplicate: Option<bool>,
198
199    /// Sets `async_insert_max_data_size`, the maximum size in bytes of unparsed data collected per query before being inserted.
200    ///
201    /// If left unspecified, use the default provided by the `ClickHouse` server.
202    #[serde(default)]
203    pub max_data_size: Option<u64>,
204
205    /// Sets `async_insert_max_query_number`, the maximum number of insert queries before being inserted
206    ///
207    /// If left unspecified, use the default provided by the `ClickHouse` server.
208    #[serde(default)]
209    pub max_query_number: Option<u64>,
210}
211
212impl_generate_config_from_default!(ClickhouseConfig);
213
214#[async_trait::async_trait]
215#[typetag::serde(name = "clickhouse")]
216impl SinkConfig for ClickhouseConfig {
217    async fn build(&self, cx: SinkContext) -> crate::Result<(VectorSink, Healthcheck)> {
218        let mut config = self.clone();
219        config.table = config
220            .table
221            .confine(&self.confinement, Self::NAME, "table")?;
222        config.database = config
223            .database
224            .map(|t| t.confine(&self.confinement, Self::NAME, "database"))
225            .transpose()?;
226        let this = &config;
227
228        let endpoint = this.endpoint.with_default_parts().uri;
229
230        let auth = this.auth.choose_one(&this.endpoint.auth)?;
231
232        let tls_settings = TlsSettings::from_options(this.tls.as_ref())?;
233
234        let client = HttpClient::new(tls_settings, &cx.proxy)?;
235
236        let clickhouse_service_request_builder = ClickhouseServiceRequestBuilder {
237            auth: auth.clone(),
238            endpoint: endpoint.clone(),
239            skip_unknown_fields: this.skip_unknown_fields,
240            date_time_best_effort: this.date_time_best_effort,
241            insert_random_shard: this.insert_random_shard,
242            compression: this.compression,
243            query_settings: this.query_settings,
244        };
245
246        let service: HttpService<ClickhouseServiceRequestBuilder, PartitionKey> =
247            HttpService::new(client.clone(), clickhouse_service_request_builder);
248
249        let request_limits = this.request.into_settings();
250
251        let service = ServiceBuilder::new()
252            .settings(request_limits, ClickhouseRetryLogic::default())
253            .service(service);
254
255        let batch_settings = this.batch.into_batcher_settings()?;
256
257        let database = this.database.clone().unwrap_or_else(|| {
258            "default"
259                .try_into()
260                .expect("'default' should be a valid template")
261        });
262
263        // Resolve the encoding strategy (format + encoder) based on configuration
264        let (format, encoder_kind) = this
265            .resolve_strategy(&client, &endpoint, &database, auth.as_ref())
266            .await?;
267
268        let request_builder = ClickhouseRequestBuilder {
269            compression: this.compression,
270            encoder: (this.encoding.clone(), encoder_kind),
271        };
272
273        let sink = ClickhouseSink::new(
274            batch_settings,
275            service,
276            database,
277            this.table.clone(),
278            format,
279            request_builder,
280        );
281
282        let healthcheck = Box::pin(healthcheck(client, endpoint, auth));
283
284        self.confinement.set_confinement_gauge("sink", Self::NAME);
285        Ok((VectorSink::from_event_streamsink(sink), healthcheck))
286    }
287
288    fn input(&self) -> Input {
289        Input::log()
290    }
291
292    fn acknowledgements(&self) -> &AcknowledgementsConfig {
293        &self.acknowledgements
294    }
295}
296
297impl ClickhouseConfig {
298    /// Resolves the encoding strategy (format + encoder) based on configuration.
299    ///
300    /// This method determines the appropriate ClickHouse format and Vector encoder
301    /// based on the user's configuration, ensuring they are consistent.
302    async fn resolve_strategy(
303        &self,
304        client: &HttpClient,
305        endpoint: &Uri,
306        database: &Template,
307        auth: Option<&Auth>,
308    ) -> crate::Result<(Format, vector_lib::codecs::EncoderKind)> {
309        use vector_lib::codecs::EncoderKind;
310        use vector_lib::codecs::{
311            JsonSerializerConfig, NewlineDelimitedEncoderConfig, encoding::Framer,
312        };
313
314        if let Some(batch_encoding) = &self.batch_encoding {
315            use vector_lib::codecs::BatchEncoder;
316            use vector_lib::codecs::encoding::BatchSerializerConfig;
317
318            // Validate that batch_encoding is only compatible with ArrowStream format
319            if self.format != Format::ArrowStream {
320                return Err(format!(
321                    "'batch_encoding' is only compatible with 'format: arrow_stream'. Found 'format: {}'.",
322                    self.format
323                )
324                .into());
325            }
326
327            let ClickhouseBatchEncoding::ArrowStream(arrow_config) = batch_encoding;
328            let mut arrow_config = arrow_config.clone();
329
330            self.resolve_arrow_schema(
331                client,
332                endpoint.to_string(),
333                database,
334                auth,
335                &mut arrow_config,
336            )
337            .await?;
338
339            let resolved_batch_config = BatchSerializerConfig::ArrowStream(arrow_config);
340            let batch_serializer = resolved_batch_config.build_batch_serializer()?;
341            let encoder = EncoderKind::Batch(BatchEncoder::new(batch_serializer));
342
343            return Ok((Format::ArrowStream, encoder));
344        }
345
346        let encoder = EncoderKind::Framed(Box::new(Encoder::<Framer>::new(
347            NewlineDelimitedEncoderConfig.build().into(),
348            JsonSerializerConfig::default().build().into(),
349        )));
350
351        Ok((self.format, encoder))
352    }
353
354    async fn resolve_arrow_schema(
355        &self,
356        client: &HttpClient,
357        endpoint: String,
358        database: &Template,
359        auth: Option<&Auth>,
360        config: &mut ArrowStreamSerializerConfig,
361    ) -> crate::Result<()> {
362        use super::arrow;
363
364        if self.table.is_dynamic() || database.is_dynamic() {
365            return Err(
366                "Arrow codec requires a static table and database. Dynamic schema inference is not supported."
367                    .into(),
368            );
369        }
370
371        let table_str = self.table.get_ref();
372        let database_str = database.get_ref();
373
374        debug!(
375            "Fetching schema for table {}.{} at startup.",
376            database_str, table_str
377        );
378
379        let provider = arrow::ClickHouseSchemaProvider::new(
380            client.clone(),
381            endpoint,
382            database_str.to_string(),
383            table_str.to_string(),
384            auth.cloned(),
385        );
386
387        let schema = provider.get_schema().await.map_err(|e| {
388            format!(
389                "Failed to fetch schema for {}.{}: {}.",
390                database_str, table_str, e
391            )
392        })?;
393
394        config.schema = Some(schema);
395
396        debug!(
397            "Successfully fetched Arrow schema with {} fields.",
398            config
399                .schema
400                .as_ref()
401                .map(|s| s.fields().len())
402                .unwrap_or(0)
403        );
404
405        Ok(())
406    }
407}
408
409fn get_healthcheck_uri(endpoint: &Uri) -> String {
410    let mut uri = endpoint.to_string();
411    if !uri.ends_with('/') {
412        uri.push('/');
413    }
414    uri.push_str("?query=SELECT%201");
415    uri
416}
417
418async fn healthcheck(client: HttpClient, endpoint: Uri, auth: Option<Auth>) -> crate::Result<()> {
419    let uri = get_healthcheck_uri(&endpoint);
420    let mut request = Request::get(uri).body(Body::empty()).unwrap();
421
422    if let Some(auth) = auth {
423        auth.apply(&mut request);
424    }
425
426    let response = client.send(request).await?;
427
428    match response.status() {
429        StatusCode::OK => Ok(()),
430        status => Err(HealthcheckError::UnexpectedStatus { status }.into()),
431    }
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437    use crate::template::{ConfinementConfig, Template};
438    use vector_lib::codecs::encoding::ArrowStreamSerializerConfig;
439
440    #[test]
441    fn generate_config() {
442        crate::test_util::test_generate_config::<ClickhouseConfig>();
443    }
444
445    #[test]
446    fn confinement_rejects_unconfined_table() {
447        let template = Template::try_from("{{ table }}").unwrap();
448        let config = ConfinementConfig::default();
449        let result = template.confine(&config, "clickhouse", "table");
450        assert!(result.is_err());
451    }
452
453    #[test]
454    fn confinement_opt_out_allows_unconfined_table() {
455        let template = Template::try_from("{{ table }}").unwrap();
456        let config = ConfinementConfig {
457            dangerously_allow_unconfined_template_resolution: true,
458        };
459        let result = template.confine(&config, "clickhouse", "table");
460        assert!(result.is_ok());
461    }
462
463    #[test]
464    fn confinement_allows_prefixed_table() {
465        let template = Template::try_from("events-{{ env }}").unwrap();
466        let config = ConfinementConfig::default();
467        let result = template.confine(&config, "clickhouse", "table");
468        assert!(result.is_ok());
469    }
470
471    #[test]
472    fn test_get_healthcheck_uri() {
473        assert_eq!(
474            get_healthcheck_uri(&"http://localhost:8123".parse().unwrap()),
475            "http://localhost:8123/?query=SELECT%201"
476        );
477        assert_eq!(
478            get_healthcheck_uri(&"http://localhost:8123/".parse().unwrap()),
479            "http://localhost:8123/?query=SELECT%201"
480        );
481        assert_eq!(
482            get_healthcheck_uri(&"http://localhost:8123/path/".parse().unwrap()),
483            "http://localhost:8123/path/?query=SELECT%201"
484        );
485    }
486
487    /// Codecs other than `arrow_stream` must be rejected at parse time, since
488    /// `ClickhouseBatchEncoding` only exposes the `arrow_stream` variant.
489    #[cfg(feature = "codecs-parquet")]
490    #[test]
491    fn batch_encoding_rejects_unsupported_codec() {
492        let err = serde_yaml::from_str::<ClickhouseConfig>(
493            r#"
494            endpoint: http://localhost:8123
495            table: test_table
496            batch_encoding:
497              codec: parquet
498            "#,
499        )
500        .unwrap_err();
501
502        assert!(
503            err.to_string().contains("parquet"),
504            "expected error to mention the offending codec, got: {err}"
505        );
506    }
507
508    /// Helper to create a minimal ClickhouseConfig for testing
509    fn create_test_config(
510        format: Format,
511        batch_encoding: Option<ClickhouseBatchEncoding>,
512    ) -> ClickhouseConfig {
513        ClickhouseConfig {
514            endpoint: "http://localhost:8123".parse::<http::Uri>().unwrap().into(),
515            table: "test_table".try_into().unwrap(),
516            database: Some("test_db".try_into().unwrap()),
517            format,
518            batch_encoding,
519            ..Default::default()
520        }
521    }
522
523    #[tokio::test]
524    async fn test_format_selection_with_batch_encoding() {
525        use crate::http::HttpClient;
526        use crate::tls::TlsSettings;
527
528        // Create minimal dependencies for resolve_strategy
529        let tls = TlsSettings::default();
530        let client = HttpClient::new(tls, &Default::default()).unwrap();
531        let endpoint: http::Uri = "http://localhost:8123".parse().unwrap();
532        let database: Template = "test_db".try_into().unwrap();
533
534        // Test incompatible formats - should all return errors
535        let incompatible_formats = vec![
536            (Format::JsonEachRow, "json_each_row"),
537            (Format::JsonAsObject, "json_as_object"),
538            (Format::JsonAsString, "json_as_string"),
539        ];
540
541        for (format, format_name) in incompatible_formats {
542            let config = create_test_config(
543                format,
544                Some(ClickhouseBatchEncoding::ArrowStream(
545                    ArrowStreamSerializerConfig::default(),
546                )),
547            );
548
549            let result = config
550                .resolve_strategy(&client, &endpoint, &database, None)
551                .await;
552
553            assert!(
554                result.is_err(),
555                "Expected error for format {} with batch_encoding, but got success",
556                format_name
557            );
558        }
559    }
560
561    #[test]
562    fn test_format_selection_without_batch_encoding() {
563        // When batch_encoding is None, the configured format should be used
564        let configs = vec![
565            Format::JsonEachRow,
566            Format::JsonAsObject,
567            Format::JsonAsString,
568            Format::ArrowStream,
569        ];
570
571        for format in configs {
572            let config = create_test_config(format, None);
573
574            assert!(
575                config.batch_encoding.is_none(),
576                "batch_encoding should be None for format {:?}",
577                format
578            );
579            assert_eq!(
580                config.format, format,
581                "format should match configured value"
582            );
583        }
584    }
585}