1use 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#[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,
37
38 JsonAsObject,
40
41 JsonAsString,
43
44 #[configurable(metadata(status = "beta"))]
46 ArrowStream,
47}
48
49#[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 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#[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 #[serde(alias = "host")]
84 #[configurable(metadata(docs::examples = "http://localhost:8123"))]
85 pub endpoint: UriSerde,
86
87 #[configurable(metadata(docs::examples = "mytable"))]
89 pub table: Template,
90
91 #[configurable(metadata(docs::examples = "mydatabase"))]
93 pub database: Option<Template>,
94
95 #[serde(default)]
97 pub format: Format,
98
99 #[serde(default)]
103 pub skip_unknown_fields: Option<bool>,
104
105 #[serde(default)]
107 pub date_time_best_effort: bool,
108
109 #[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 #[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#[configurable_component]
162#[derive(Clone, Copy, Debug, Default)]
163#[serde(deny_unknown_fields)]
164pub struct QuerySettingsConfig {
165 #[serde(default)]
167 pub async_insert_settings: AsyncInsertSettingsConfig,
168}
169
170#[configurable_component]
172#[derive(Clone, Copy, Debug, Default)]
173#[serde(deny_unknown_fields)]
174pub struct AsyncInsertSettingsConfig {
175 #[serde(default)]
179 pub enabled: Option<bool>,
180
181 #[serde(default)]
185 pub wait_for_processing: Option<bool>,
186
187 #[serde(default)]
191 pub wait_for_processing_timeout: Option<u64>,
192
193 #[serde(default)]
197 pub deduplicate: Option<bool>,
198
199 #[serde(default)]
203 pub max_data_size: Option<u64>,
204
205 #[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 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 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 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 #[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 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 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 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 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}