Skip to main content

spate_clickhouse/
writer.rs

1//! The I/O half of the sink: writing sealed batches to replica endpoints.
2
3use spate_core::error::{ErrorClass, SinkError};
4use spate_core::sink::{SealedBatch, ShardWriter};
5use std::fmt;
6use std::time::Duration;
7
8/// One connected ClickHouse replica. Wraps a `clickhouse::Client` (its own
9/// hyper connection pool) — the crate type stays private so its 0.x
10/// breaking releases never become ours.
11pub 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    /// The replica URL this endpoint writes to.
22    #[must_use]
23    pub fn url(&self) -> &str {
24        &self.url
25    }
26
27    /// Crate-internal access for schema validation queries; the client
28    /// type stays out of the public API.
29    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/// Writes sealed batches: one `INSERT ... FORMAT <fmt>` per batch (the
43/// format — RowBinary or Native — is baked into `insert_sql`), carrying the
44/// batch's deduplication token so retries — including on other replicas —
45/// are idempotent within the server's dedup window. The transport is
46/// format-agnostic: frames concatenate to the request body (RowBinary rows
47/// or a stream of Native blocks). `write_batch` returning `Ok` is the
48/// durable-ack point (the server confirmed the insert, materialized views
49/// included, thanks to `wait_end_of_query=1`).
50#[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    /// The exact `INSERT` statement this writer issues.
74    #[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            // `Bytes` clones are refcounted views, not copies.
100            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
116/// Server exception codes that cannot succeed on retry: schema, parse,
117/// and authentication classes. Everything else is retried — with
118/// deduplication tokens a spurious retry is idempotent, and the circuit
119/// breaker plus the stalled-watermark alert surface persistent failures.
120const FATAL_EXCEPTION_CODES: &[u32] = &[
121    6,   // CANNOT_PARSE_TEXT
122    16,  // NO_SUCH_COLUMN_IN_TABLE
123    20,  // NUMBER_OF_COLUMNS_DOESNT_MATCH
124    27,  // CANNOT_PARSE_INPUT_ASSERTION_FAILED
125    53,  // TYPE_MISMATCH
126    60,  // UNKNOWN_TABLE
127    73,  // UNKNOWN_FORMAT
128    81,  // UNKNOWN_DATABASE
129    117, // INCORRECT_DATA
130    192, // UNKNOWN_USER
131    497, // ACCESS_DENIED
132    516, // AUTHENTICATION_FAILED
133];
134
135/// Map a client error onto the framework's retryable/fatal taxonomy.
136///
137/// - transport (`Network`, `TimedOut`, compression) → `Retryable`;
138/// - server exceptions (`BadResponse`) → fatal only for the schema/parse/
139///   auth codes above, `Retryable` otherwise (e.g. `TOO_MANY_PARTS`,
140///   memory pressure, shutdown races);
141/// - client-side encoding/params problems → `Fatal` (retrying identical
142///   bytes cannot help).
143fn 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        // Anything unanticipated: retry — idempotent under dedup tokens,
162        // and visible through breaker metrics if persistent.
163        _ => ErrorClass::Retryable,
164    };
165    SinkError::Client {
166        class,
167        reason: err.to_string(),
168    }
169}
170
171/// Extract the exception code from a ClickHouse error reason. Servers send
172/// `"Code: NNN. DB::Exception: ..."` bodies; the client's standardized
173/// fallback (empty/unreadable body) is the bare code from the
174/// `X-ClickHouse-Exception-Code` header, e.g. `"60"`.
175fn 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        // Standardized fallback: the bare header value.
202        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}