spate_clickhouse/
writer.rs1use spate_core::error::{ErrorClass, SinkError};
4use spate_core::sink::{SealedBatch, ShardWriter};
5use std::fmt;
6use std::time::Duration;
7
8pub struct ClickHouseEndpoint {
12 client: clickhouse::Client,
13 url: String,
14}
15
16impl ClickHouseEndpoint {
17 pub(crate) fn new(client: clickhouse::Client, url: String) -> Self {
18 ClickHouseEndpoint { client, url }
19 }
20
21 #[must_use]
23 pub fn url(&self) -> &str {
24 &self.url
25 }
26
27 pub(crate) fn client(&self) -> &clickhouse::Client {
30 &self.client
31 }
32}
33
34impl fmt::Debug for ClickHouseEndpoint {
35 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36 f.debug_struct("ClickHouseEndpoint")
37 .field("url", &self.url)
38 .finish_non_exhaustive()
39 }
40}
41
42#[derive(Clone, Debug)]
51pub struct ClickHouseWriter {
52 insert_sql: String,
53 settings: Vec<(String, String)>,
54 send_timeout: Option<Duration>,
55 end_timeout: Option<Duration>,
56}
57
58impl ClickHouseWriter {
59 pub(crate) fn new(
60 insert_sql: String,
61 settings: Vec<(String, String)>,
62 send_timeout: Option<Duration>,
63 end_timeout: Option<Duration>,
64 ) -> Self {
65 ClickHouseWriter {
66 insert_sql,
67 settings,
68 send_timeout,
69 end_timeout,
70 }
71 }
72
73 #[must_use]
75 pub fn insert_sql(&self) -> &str {
76 &self.insert_sql
77 }
78}
79
80impl ShardWriter for ClickHouseWriter {
81 type Endpoint = ClickHouseEndpoint;
82
83 async fn write_batch(
84 &self,
85 endpoint: &ClickHouseEndpoint,
86 batch: &SealedBatch,
87 ) -> Result<(), SinkError> {
88 let mut insert = endpoint
89 .client
90 .insert_formatted_with(self.insert_sql.clone())
91 .with_setting("insert_deduplicate", "1")
92 .with_setting("wait_end_of_query", "1")
93 .with_setting("insert_deduplication_token", batch.dedup_token.clone())
94 .with_timeouts(self.send_timeout, self.end_timeout);
95 for (name, value) in &self.settings {
96 insert = insert.with_setting(name.clone(), value.clone());
97 }
98 for frame in &batch.frames {
99 insert.send(frame.clone()).await.map_err(classify)?;
101 }
102 insert.end().await.map_err(classify)
103 }
104
105 async fn probe(&self, endpoint: &ClickHouseEndpoint) -> Result<(), SinkError> {
106 endpoint
107 .client
108 .query("SELECT 1")
109 .fetch_one::<u8>()
110 .await
111 .map(drop)
112 .map_err(classify)
113 }
114}
115
116const FATAL_EXCEPTION_CODES: &[u32] = &[
121 6, 16, 20, 27, 53, 60, 73, 81, 117, 192, 497, 516, ];
134
135fn classify(err: clickhouse::error::Error) -> SinkError {
144 use clickhouse::error::Error as ChError;
145 let class = match &err {
146 ChError::Network(_)
147 | ChError::TimedOut
148 | ChError::Compression(_)
149 | ChError::Decompression(_)
150 | ChError::Other(_) => ErrorClass::Retryable,
151 ChError::BadResponse(reason) => {
152 if exception_code(reason).is_some_and(|c| FATAL_EXCEPTION_CODES.contains(&c)) {
153 ErrorClass::Fatal
154 } else {
155 ErrorClass::Retryable
156 }
157 }
158 ChError::InvalidParams(_) | ChError::SchemaMismatch(_) | ChError::Unsupported(_) => {
159 ErrorClass::Fatal
160 }
161 _ => ErrorClass::Retryable,
164 };
165 SinkError::Client {
166 class,
167 reason: err.to_string(),
168 }
169}
170
171fn exception_code(reason: &str) -> Option<u32> {
176 let digits_at = |s: &str| -> Option<u32> {
177 let digits: String = s.chars().take_while(char::is_ascii_digit).collect();
178 digits.parse().ok()
179 };
180 match reason.find("Code: ") {
181 Some(at) => digits_at(&reason[at + 6..]),
182 None => digits_at(reason.trim()),
183 }
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189
190 #[test]
191 fn exception_codes_are_extracted() {
192 assert_eq!(
193 exception_code("Code: 60. DB::Exception: Table default.x does not exist"),
194 Some(60)
195 );
196 assert_eq!(
197 exception_code("bad response: Code: 252. DB::Exception: Too many parts"),
198 Some(252)
199 );
200 assert_eq!(exception_code("no code here"), None);
201 assert_eq!(exception_code("60"), Some(60));
203 assert_eq!(exception_code(" 252 "), Some(252));
204 }
205
206 #[test]
207 fn classification_matches_the_documented_taxonomy() {
208 let fatal = classify(clickhouse::error::Error::BadResponse(
209 "Code: 60. DB::Exception: Table default.orders does not exist".into(),
210 ));
211 assert!(matches!(
212 fatal,
213 SinkError::Client {
214 class: ErrorClass::Fatal,
215 ..
216 }
217 ));
218
219 let retryable = classify(clickhouse::error::Error::BadResponse(
220 "Code: 252. DB::Exception: Too many parts".into(),
221 ));
222 assert!(matches!(
223 retryable,
224 SinkError::Client {
225 class: ErrorClass::Retryable,
226 ..
227 }
228 ));
229
230 let timeout = classify(clickhouse::error::Error::TimedOut);
231 assert!(matches!(
232 timeout,
233 SinkError::Client {
234 class: ErrorClass::Retryable,
235 ..
236 }
237 ));
238
239 let schema = classify(clickhouse::error::Error::SchemaMismatch("boom".into()));
240 assert!(matches!(
241 schema,
242 SinkError::Client {
243 class: ErrorClass::Fatal,
244 ..
245 }
246 ));
247 }
248}