use spate_core::error::{ErrorClass, SinkError};
use spate_core::sink::{SealedBatch, ShardWriter};
use std::fmt;
use std::time::Duration;
pub struct ClickHouseEndpoint {
client: clickhouse::Client,
url: String,
}
impl ClickHouseEndpoint {
pub(crate) fn new(client: clickhouse::Client, url: String) -> Self {
ClickHouseEndpoint { client, url }
}
#[must_use]
pub fn url(&self) -> &str {
&self.url
}
pub(crate) fn client(&self) -> &clickhouse::Client {
&self.client
}
}
impl fmt::Debug for ClickHouseEndpoint {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ClickHouseEndpoint")
.field("url", &self.url)
.finish_non_exhaustive()
}
}
#[derive(Clone, Debug)]
pub struct ClickHouseWriter {
insert_sql: String,
settings: Vec<(String, String)>,
send_timeout: Option<Duration>,
end_timeout: Option<Duration>,
}
impl ClickHouseWriter {
pub(crate) fn new(
insert_sql: String,
settings: Vec<(String, String)>,
send_timeout: Option<Duration>,
end_timeout: Option<Duration>,
) -> Self {
ClickHouseWriter {
insert_sql,
settings,
send_timeout,
end_timeout,
}
}
#[must_use]
pub fn insert_sql(&self) -> &str {
&self.insert_sql
}
}
impl ShardWriter for ClickHouseWriter {
type Endpoint = ClickHouseEndpoint;
async fn write_batch(
&self,
endpoint: &ClickHouseEndpoint,
batch: &SealedBatch,
) -> Result<(), SinkError> {
let mut insert = endpoint
.client
.insert_formatted_with(self.insert_sql.clone())
.with_setting("insert_deduplicate", "1")
.with_setting("wait_end_of_query", "1")
.with_setting("insert_deduplication_token", batch.dedup_token.clone())
.with_timeouts(self.send_timeout, self.end_timeout);
for (name, value) in &self.settings {
insert = insert.with_setting(name.clone(), value.clone());
}
for frame in &batch.frames {
insert.send(frame.clone()).await.map_err(classify)?;
}
insert.end().await.map_err(classify)
}
async fn probe(&self, endpoint: &ClickHouseEndpoint) -> Result<(), SinkError> {
endpoint
.client
.query("SELECT 1")
.fetch_one::<u8>()
.await
.map(drop)
.map_err(classify)
}
}
const FATAL_EXCEPTION_CODES: &[u32] = &[
6, 16, 20, 27, 53, 60, 73, 81, 117, 192, 497, 516, ];
fn classify(err: clickhouse::error::Error) -> SinkError {
use clickhouse::error::Error as ChError;
let class = match &err {
ChError::Network(_)
| ChError::TimedOut
| ChError::Compression(_)
| ChError::Decompression(_)
| ChError::Other(_) => ErrorClass::Retryable,
ChError::BadResponse(reason) => {
if exception_code(reason).is_some_and(|c| FATAL_EXCEPTION_CODES.contains(&c)) {
ErrorClass::Fatal
} else {
ErrorClass::Retryable
}
}
ChError::InvalidParams(_) | ChError::SchemaMismatch(_) | ChError::Unsupported(_) => {
ErrorClass::Fatal
}
_ => ErrorClass::Retryable,
};
SinkError::Client {
class,
reason: err.to_string(),
}
}
fn exception_code(reason: &str) -> Option<u32> {
let digits_at = |s: &str| -> Option<u32> {
let digits: String = s.chars().take_while(char::is_ascii_digit).collect();
digits.parse().ok()
};
match reason.find("Code: ") {
Some(at) => digits_at(&reason[at + 6..]),
None => digits_at(reason.trim()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exception_codes_are_extracted() {
assert_eq!(
exception_code("Code: 60. DB::Exception: Table default.x does not exist"),
Some(60)
);
assert_eq!(
exception_code("bad response: Code: 252. DB::Exception: Too many parts"),
Some(252)
);
assert_eq!(exception_code("no code here"), None);
assert_eq!(exception_code("60"), Some(60));
assert_eq!(exception_code(" 252 "), Some(252));
}
#[test]
fn classification_matches_the_documented_taxonomy() {
let fatal = classify(clickhouse::error::Error::BadResponse(
"Code: 60. DB::Exception: Table default.orders does not exist".into(),
));
assert!(matches!(
fatal,
SinkError::Client {
class: ErrorClass::Fatal,
..
}
));
let retryable = classify(clickhouse::error::Error::BadResponse(
"Code: 252. DB::Exception: Too many parts".into(),
));
assert!(matches!(
retryable,
SinkError::Client {
class: ErrorClass::Retryable,
..
}
));
let timeout = classify(clickhouse::error::Error::TimedOut);
assert!(matches!(
timeout,
SinkError::Client {
class: ErrorClass::Retryable,
..
}
));
let schema = classify(clickhouse::error::Error::SchemaMismatch("boom".into()));
assert!(matches!(
schema,
SinkError::Client {
class: ErrorClass::Fatal,
..
}
));
}
}