# Technology Stack: Rust-Native ClickHouse Writer

**Project:** opendeviationbar-py v9.0
**Researched:** 2026-03-28
**Confidence:** HIGH (official crate docs verified, workspace deps audited)

## Recommended Stack

### Core: ClickHouse Client

| Technology           | Version | Purpose                      | Why                                                                                                                                                                                                                        |
| -------------------- | ------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `clickhouse` (crate) | 0.14.2  | Typed ClickHouse HTTP client | Official ClickHouse Rust client. Uses RowBinary over HTTP, `#[derive(Row)]` macro for zero-boilerplate serialization, built-in `Inserter` for batch buffering with time/row/byte thresholds. Maintained by ClickHouse Inc. |

**Why `clickhouse` crate over raw `reqwest` POST:**

1. **RowBinary serialization** -- The `clickhouse` crate handles RowBinary encoding via `#[derive(Row, Serialize)]`. Raw reqwest would require manually encoding TabSeparated or JSONEachRow strings, which is slower and error-prone for 60+ columns.
2. **Built-in Inserter** -- `client.inserter()` provides exactly the batch buffering needed (N rows OR T ms threshold), matching the v9.0 requirement. Building this on raw reqwest means reimplementing connection management, streaming writes, and flush coordination.
3. **Type safety** -- Row derive macro catches schema mismatches at compile time (with `RowBinaryWithNamesAndTypes` default) or at negligible runtime cost (with `RowBinary`).
4. **HTTP transport already** -- Uses hyper (not reqwest), but connects to the same CH HTTP interface on port 8123. No protocol mismatch.

**Why NOT raw reqwest:**

- reqwest 0.12 is already in the workspace, but using it for CH writes means: manual RowBinary/TSV encoding, manual connection pooling to CH, manual retry logic for INSERT, manual dedup token injection as query params. The `clickhouse` crate handles all of this.

### Format: RowBinary (not Native, not TSV, not JSONEachRow)

| Format                     | Verdict             | Why                                                                                                                                                                               |
| -------------------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **RowBinary**              | USE THIS            | Default for `clickhouse` crate. Binary, compact, fast serialization via serde. 1.1-3x faster than RowBinaryWithNamesAndTypes depending on row shape.                              |
| RowBinaryWithNamesAndTypes | Acceptable fallback | Default since clickhouse 0.14.0. Adds schema validation at cost of ~10% overhead. Use during development, switch to RowBinary for production via `client.with_validation(false)`. |
| JSONEachRow                | DO NOT USE          | Text-based, 5-10x slower than RowBinary for numeric-heavy schemas like ODB bars (60+ Float64/Int64 columns).                                                                      |
| TabSeparated               | DO NOT USE          | Text-based, same performance issue. What clickhouse-connect (Python) uses internally -- we're replacing this.                                                                     |
| Native                     | NOT AVAILABLE       | TCP-only format. The `clickhouse` crate is HTTP-only (plans for Native over TCP exist but not shipped).                                                                           |

**Production toggle:** Use `.with_validation(false)` on the Client to switch from RowBinaryWithNamesAndTypes to plain RowBinary. Do this after schema is stable.

### Retry/Backoff

| Technology | Version                    | Purpose                         | Why                                                                                                                           |
| ---------- | -------------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `backon`   | 1.6 (already in workspace) | Exponential backoff with jitter | Already used by opendeviationbar-providers for Binance REST retry (Antaeus pattern). Same crate, same pattern, zero new deps. |

**Why NOT a new retry crate:** `backon` 1.6 is already a workspace dependency. It provides `ExponentialBuilder` with jitter, max retries, and timeout -- exactly what `stamina` does in the Python path. Adding `retry` or `backoff` crates would be redundant.

### Dead-Letter Parquet Fallback

| Technology | Version                    | Purpose                         | Why                                                                                                                                                             |
| ---------- | -------------------------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `parquet`  | 58 (match arrow ecosystem) | Write dead-letter Parquet files | Already have `arrow` 58 in workspace. `parquet` is the same arrow-rs ecosystem, version-locked. ArrowWriter can serialize a RecordBatch to Parquet in ~5 lines. |

**Note:** The workspace already uses `arrow` 58 / `arrow-array` 58 / `arrow-schema` 58. The `parquet` crate from arrow-rs must match: use version 58. This is a new workspace dep but shares the same arrow-rs release train -- no version conflict risk.

### Dedup Token (MD5)

| Technology | Version | Purpose                         | Why                                                                                                                                                                                                                 |
| ---------- | ------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `md5`      | 0.7     | MD5 hash for INSERT dedup token | Matches Python's `hashlib.md5(cache_key.encode()).hexdigest()` exactly. Tiny crate, single purpose. Using SHA-256 (already in workspace via `sha2`) would break dedup continuity with existing Python-written bars. |

**Critical:** The dedup token MUST produce identical MD5 hex digests as Python's `hashlib.md5()`. The `md5` crate's `compute()` returns the same bytes. Format with `format!("{:x}", digest)` to match Python's `.hexdigest()`.

### Existing Workspace Dependencies (reuse, do NOT add)

| Already Available                        | Version | Used For                                         |
| ---------------------------------------- | ------- | ------------------------------------------------ |
| `reqwest`                                | 0.12    | NOT for CH writes -- keep for Binance REST only  |
| `tokio`                                  | 1.x     | Async runtime (inserter needs tokio)             |
| `serde`                                  | 1.0     | Row serialization (clickhouse crate requires it) |
| `chrono`                                 | 0.4     | Timestamp handling                               |
| `thiserror`                              | 2.0     | Error types                                      |
| `tracing`                                | 0.1     | Observability                                    |
| `backon`                                 | 1.6     | Retry with backoff                               |
| `arrow` / `arrow-array` / `arrow-schema` | 58      | Arrow ecosystem for RecordBatch                  |
| `parking_lot`                            | 0.12    | Synchronization primitives                       |

## Alternatives Considered

| Category  | Recommended         | Alternative                     | Why Not                                                                                               |
| --------- | ------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------- |
| CH client | `clickhouse` 0.14.2 | Raw `reqwest` POST              | Manual RowBinary encoding, no Inserter, no Row derive, more code to maintain                          |
| CH client | `clickhouse` 0.14.2 | `clickhouse-rs` (suharev7)      | Community crate, uses TCP Native protocol (not HTTP), less maintained, different API                  |
| CH client | `clickhouse` 0.14.2 | `clickhouse-arrow`              | Arrow-format INSERTs -- interesting but immature (0.1.x), unnecessary complexity when RowBinary works |
| Retry     | `backon` 1.6        | `retry` crate                   | New dep when backon already in workspace                                                              |
| Retry     | `backon` 1.6        | `backoff` crate                 | New dep when backon already in workspace                                                              |
| MD5       | `md5` 0.7           | `sha2` (already in workspace)   | Would break dedup token continuity with existing Python bars                                          |
| Parquet   | `parquet` 58        | `polars` (already in workspace) | Polars is a full DataFrame engine -- overkill for writing a few dead-letter rows to a file            |

## New Dependencies to Add

```toml
# In [workspace.dependencies]
clickhouse = { version = "0.14", features = ["inserter", "rustls-tls"] }
parquet = { version = "58", features = ["snap", "zstd"] }  # match arrow 58
md5 = "0.7"
```

```toml
# In opendeviationbar-streaming/Cargo.toml [dependencies]
clickhouse = { workspace = true, optional = true }
parquet = { workspace = true, optional = true }
md5 = { workspace = true, optional = true }
backon = { workspace = true, optional = true }

# New feature flag
[features]
clickhouse-sink = ["clickhouse", "parquet", "md5", "backon"]
```

**Total new crates: 3** (`clickhouse`, `parquet`, `md5`). Transitive deps from `clickhouse`: hyper 1.4, hyper-rustls 0.27, rustls 0.23, lz4, quanta (for inserter timing). The `parquet` crate shares the arrow-rs dep tree already in the workspace.

## Connection Pooling Strategy

The `clickhouse` crate uses hyper's built-in connection pool internally. Configuration:

```rust
let client = clickhouse::Client::default()
    .with_url("http://localhost:8123")
    .with_database("opendeviationbar_cache")
    .with_validation(false);  // RowBinary mode (production)
```

- **One Client instance** shared across all symbol/threshold pairs (clone is cheap -- shares the hyper pool).
- **No separate pool manager needed** -- hyper handles keep-alive and connection reuse.
- The Python path uses `urllib3.PoolManager(maxsize=20)` via clickhouse-connect. Hyper's default pool is comparable.

## Inserter Configuration

```rust
let mut inserter = client
    .inserter::<BarRow>("opendeviationbar_cache.open_deviation_bars")
    .with_max_rows(500)           // Match Python batch size
    .with_period(Duration::from_millis(100))  // 100ms flush interval
    .with_max_bytes(5_000_000);   // 5MB safety cap
```

**Why NOT use raw `client.insert()`:** The `insert()` API streams rows to the server progressively and commits on `.end()`. This is fine for bulk loads but wrong for the sidecar hot path where bars arrive one at a time. The `Inserter` accumulates locally and flushes at thresholds -- exactly matching the Python `_flush_all_batches()` pattern.

## Dedup Token Integration

```rust
// Per-INSERT dedup token (matches Python's MD5 cache_key pattern)
let key_str = format!("{symbol}_{threshold}_{start_ts}_{end_ts}_{ouroboros_mode}");
let digest = md5::compute(key_str.as_bytes());
let token = format!("{:x}", digest);

let mut insert = client
    .insert::<BarRow>("opendeviationbar_cache.open_deviation_bars")
    .with_option("insert_deduplicate", "1")
    .with_option("insert_deduplication_token", &token)
    .await?;
```

**Note:** The `Inserter` calls `insert()` internally on `commit()`. Per-insert options need to be set on the inserter or injected into each commit cycle. This may require using raw `insert()` with manual batching instead of `Inserter` if per-batch dedup tokens are needed. Research flag: verify `Inserter` supports per-commit options.

## TLS Compatibility

The `clickhouse` crate offers `rustls-tls` feature (uses `hyper-rustls`). This aligns with the workspace's rustls-only policy (no OpenSSL, enables zig cross-compilation). The workspace already uses `rustls-tls` for reqwest and tokio-tungstenite.

**Feature selection:** `clickhouse = { version = "0.14", features = ["inserter", "rustls-tls"] }` -- do NOT enable `native-tls`.

## Rust Edition Compatibility

The `clickhouse` 0.14.2 crate requires Rust edition 2024 and `rust-version = "1.93"`. The workspace already uses `edition = "2024"` and `rust-version = "1.93"` -- no conflict.

## What NOT to Add

| Crate                      | Why Avoid                                                                                   |
| -------------------------- | ------------------------------------------------------------------------------------------- |
| `clickhouse-rs` (suharev7) | TCP/Native protocol, not HTTP. Different from official `clickhouse` crate.                  |
| `clickhouse-arrow`         | Immature, unnecessary -- RowBinary is faster for known schemas                              |
| `hyper` (direct)           | `clickhouse` crate wraps hyper internally -- don't add another hyper dep                    |
| `retry` / `backoff`        | `backon` already in workspace                                                               |
| `sha2` for dedup           | Would break compatibility with Python's MD5 tokens                                          |
| `polars` for dead-letter   | Overkill -- `parquet` crate + ArrowWriter is sufficient                                     |
| `serde_json` for CH writes | RowBinary is binary, not JSON -- serde_json already in workspace but not needed for CH path |

## Row Struct Design

```rust
use clickhouse::Row;
use serde::Serialize;

#[derive(Row, Serialize)]
struct BarRow {
    symbol: String,
    threshold_decimal_bps: u32,
    close_time_us: i64,
    open_time_us: i64,
    open: f64,
    high: f64,
    low: f64,
    close: f64,
    volume: f64,
    // ... (60+ columns matching schema.sql)
    cache_key: String,
    opendeviationbar_version: String,
    source_start_ts: i64,
    source_end_ts: i64,
}
```

The `BarRow` struct maps 1:1 to the ClickHouse schema columns. The `#[derive(Row, Serialize)]` macro generates RowBinary serialization. Fields must match the INSERT column order exactly.

**Conversion path:** `CompletedBar` -> `BarRow` is a straightforward field-by-field mapping. `OpenDeviationBar` fields use `FixedPoint` (8-decimal fixed-point) which converts to `f64` via `.to_f64()`. Volume fields are `i128` accumulators that convert to `f64` for CH storage.

## Sources

- [Official clickhouse-rs GitHub](https://github.com/ClickHouse/clickhouse-rs) -- README, Cargo.toml, insert.rs source
- [ClickHouse Rust client docs](https://clickhouse.com/docs/integrations/rust) -- Official integration guide
- [clickhouse crate API docs](https://docs.rs/clickhouse/latest/clickhouse/) -- Insert, Inserter, Client APIs
- [Apache Arrow Rust 57.0.0 release](https://arrow.apache.org/blog/2025/10/30/arrow-rs-57.0.0/) -- parquet crate version alignment
- Workspace Cargo.toml -- Existing deps audit (reqwest 0.12, backon 1.6, arrow 58, serde 1.0)
- `python/opendeviationbar/clickhouse/schema.sql` -- 60+ column schema for BarRow mapping
- `python/opendeviationbar/clickhouse/bulk_operations.py` -- Python dedup token generation (MD5 of cache_key)
- `crates/opendeviationbar-streaming/src/engine/traits.rs` -- BarSink trait interface
