opendeviationbar-streaming 13.70.3

Real-time streaming engine for open deviation bar processing
Documentation
//! X-ClickHouse-Summary JSON response parser.
//!
//! Issue #318: Rust-native ClickHouse writer — summary parsing.
//!
//! ClickHouse returns an `X-ClickHouse-Summary` header with JSON like:
//! `{"read_rows":"0","written_rows":"500","written_bytes":"12345","elapsed_ns":"1000000"}`
//!
//! All values are string-encoded integers (not raw numbers), so we need
//! a custom deserializer.

use serde::Deserialize;

/// The HTTP header name ClickHouse uses for summary information.
pub const SUMMARY_HEADER: &str = "x-clickhouse-summary";

/// Custom deserializer for string-encoded u64 values.
///
/// ClickHouse encodes summary values as `"500"` not `500`.
fn deserialize_string_u64<'de, D>(deserializer: D) -> Result<u64, D::Error>
where
    D: serde::Deserializer<'de>,
{
    let s = String::deserialize(deserializer)?;
    s.parse::<u64>().map_err(serde::de::Error::custom)
}

/// Parsed X-ClickHouse-Summary response header.
#[derive(Debug, Deserialize)]
pub struct ClickHouseSummary {
    /// Number of rows read (typically 0 for INSERT).
    #[serde(deserialize_with = "deserialize_string_u64")]
    pub read_rows: u64,
    /// Number of rows written.
    #[serde(deserialize_with = "deserialize_string_u64")]
    pub written_rows: u64,
    /// Number of bytes written.
    #[serde(deserialize_with = "deserialize_string_u64")]
    pub written_bytes: u64,
    /// Elapsed time in nanoseconds.
    #[serde(deserialize_with = "deserialize_string_u64")]
    pub elapsed_ns: u64,
}

/// Parse the X-ClickHouse-Summary header value into a `ClickHouseSummary`.
///
/// Returns `None` if the JSON is malformed or missing required fields.
pub fn parse_summary(header_value: &str) -> Option<ClickHouseSummary> {
    serde_json::from_str(header_value).ok()
}

/// Verify that the number of written rows matches the expected count.
///
/// Returns `true` if `written_rows == expected`. Logs a warning via `tracing`
/// if there is a mismatch.
pub fn verify_written_rows(summary: &ClickHouseSummary, expected: u64) -> bool {
    if summary.written_rows == expected {
        true
    } else {
        tracing::warn!(
            expected = expected,
            actual = summary.written_rows,
            "ClickHouse written_rows mismatch: expected {}, got {}",
            expected,
            summary.written_rows,
        );
        false
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_summary_valid() {
        let json = r#"{"read_rows":"0","written_rows":"500","written_bytes":"12345","elapsed_ns":"1000000"}"#;
        let summary = parse_summary(json).expect("should parse valid JSON");
        assert_eq!(summary.read_rows, 0);
        assert_eq!(summary.written_rows, 500);
        assert_eq!(summary.written_bytes, 12345);
        assert_eq!(summary.elapsed_ns, 1_000_000);
    }

    #[test]
    fn test_parse_summary_invalid() {
        assert!(parse_summary("not json at all").is_none());
        assert!(parse_summary("{}").is_none()); // missing required fields
        assert!(parse_summary("").is_none());
    }

    #[test]
    fn test_verify_written_rows_match() {
        let summary = ClickHouseSummary {
            read_rows: 0,
            written_rows: 500,
            written_bytes: 12345,
            elapsed_ns: 1_000_000,
        };
        assert!(verify_written_rows(&summary, 500));
    }

    #[test]
    fn test_verify_written_rows_mismatch() {
        let summary = ClickHouseSummary {
            read_rows: 0,
            written_rows: 499,
            written_bytes: 12345,
            elapsed_ns: 1_000_000,
        };
        assert!(!verify_written_rows(&summary, 500));
    }
}