# Phase 34: Core ClickHouse Writer - Research

**Researched:** 2026-03-28
**Domain:** Rust ClickHouse HTTP writer implementing BarSink trait
**Confidence:** HIGH

## Summary

Phase 34 implements a `ClickHouseWriterSink` in Rust that serializes `CompletedBar` structs to RowBinary format and POSTs them to ClickHouse via HTTP, with batch buffering, MD5 dedup token parity with Python, retry/backoff, and `X-ClickHouse-Summary` response validation. The implementation lives in `opendeviationbar-streaming` behind a `clickhouse-sink` feature gate.

The key risk is the 60+ column mapping from `CompletedBar.bar` (an `OpenDeviationBar` struct) to the ClickHouse `open_deviation_bars` table, including 16 Nullable inter-bar features and 22 Nullable intra-bar features. The `clickhouse` crate v0.14.2 handles Nullable via `Option<T>` in `#[derive(Row)]` structs, making this mechanical but requiring careful 1:1 field-to-column mapping.

The dedup token formula is: `MD5(f"{symbol}_{threshold_decimal_bps}_{start_ts}_{end_ts}_{ouroboros_mode}")` where `start_ts` = min open_time_us across the batch, `end_ts` = max close_time_us across the batch. This is passed as `insert_deduplication_token` setting via `.with_option()`.

**Primary recommendation:** Use the `clickhouse` crate v0.14.2 `Inserter` API (requires `inserter` feature) for batch buffering with `max_rows`/`period`/`max_bytes`, and raw `reqwest` only as fallback if `Inserter` lacks per-commit dedup token support. The flush thread pattern uses `std::thread` + `SyncSender` + `reqwest::blocking` to keep `on_bar()` non-blocking.

<user_constraints>

## User Constraints (from CONTEXT.md)

### Locked Decisions

None -- all implementation choices at Claude's discretion (infrastructure phase).

### Claude's Discretion

All implementation choices are at Claude's discretion. Key guidance from research:

- Use `clickhouse` crate v0.14.2 (official Rust client, RowBinary over HTTP)
- Use `md5` crate 0.8 for dedup token parity with Python
- Use `parquet` crate 58 for dead-letter (Phase 35, but dep added now)
- Reuse existing `backon` crate for retry/backoff
- Flush thread pattern: `std::thread` with `reqwest::blocking` and bounded `SyncSender`
- Feature flag behind `clickhouse-sink` Cargo feature gate
- Plugin columns discovered via `system.columns` query, not hardcoded

### Deferred Ideas (OUT OF SCOPE)

None.
</user_constraints>

<phase_requirements>

## Phase Requirements

| ID        | Description                                                        | Research Support                                                                                                                                                                                             |
| --------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| WRITER-01 | ClickHouseWriterSink implements BarSink trait                      | BarSink trait has 3 methods: `on_bar(&CompletedBar)`, `flush()`, `name()`. ChannelSink is reference impl. SinkError has Recoverable/Unrecoverable variants.                                                  |
| WRITER-02 | Serialize CompletedBar to RowBinary matching schema.sql 60+ fields | OpenDeviationBar struct has ~70 fields. ClickHouse schema has ~67 columns. `clickhouse` crate Row derive maps Rust types to RowBinary. FixedPoint uses `to_f64()`, i128 volumes use `as f64 / SCALE as f64`. |
| WRITER-03 | Batch buffering: N bars (500), T ms (100), max bytes (5MB)         | `clickhouse` Inserter supports `with_max_rows()`, `with_period()`, `with_max_bytes()`. Alternatively, manual buffering on flush thread with timer.                                                           |
| WRITER-04 | MD5 dedup token parity with Python                                 | Python formula: `hashlib.md5(f"{symbol}_{threshold}_{start_ts}_{end_ts}_{ouroboros_mode}".encode()).hexdigest()`. Use `md5` crate 0.8 with identical string formatting.                                      |
| WRITER-05 | Retry with backoff on transient errors (429, 503, timeout)         | `backon` crate v1.6 already in workspace. ExponentialBuilder pattern used in providers crate.                                                                                                                |
| WRITER-06 | Non-blocking on_bar() via flush thread                             | `std::sync::mpsc::SyncSender` bounded channel to dedicated `std::thread` with `reqwest::blocking` POST. Pattern: on_bar() sends to channel, flush thread drains and POSTs.                                   |
| WRITER-07 | X-ClickHouse-Summary header parsed, verify written_rows            | JSON header with `written_rows` field. Requires `wait_end_of_query=1` URL param for accurate counts. Parse with serde_json.                                                                                  |
| WRITER-08 | Computed columns (is_liquidation_cascade) never sent               | `is_liquidation_cascade` has DEFAULT expression in schema. Omit from Row struct. Also omit `computed_at` (has DEFAULT now64()).                                                                              |
| WRITER-09 | Plugin column discovery via system.columns at startup              | Query `SELECT name, type FROM system.columns WHERE table='open_deviation_bars' AND database='opendeviationbar_cache'` at startup. Compare against core column set to find plugin-added columns.              |

</phase_requirements>

## Project Constraints (from CLAUDE.md)

- **Python 3.13 only** -- not directly relevant to Rust phase but cross-language tests must use 3.13
- **Leverage Rust** -- this phase IS the Rust leverage; Python handles I/O only
- **Aion-only** -- ouroboros_mode hardcoded to "aion" (day-mode fully deprecated)
- **Spot-only** -- all data from Binance spot
- **ClickHouse DELETE strategy** -- lightweight `DELETE FROM` permanently banned; use `ALTER TABLE DELETE IN PARTITION`
- **`mutations_sync=1`** required on all DELETE operations
- **Thread-local connection reuse** -- relevant for flush thread pattern
- **ClickHouse COMMENT = SSoT** -- table/column COMMENTs are single source of truth

## Standard Stack

### Core

| Library      | Version | Purpose                                                                  | Why Standard                                                                          |
| ------------ | ------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------- |
| `clickhouse` | 0.14.2  | Official Rust ClickHouse client, RowBinary serialization, HTTP transport | Official client, Row derive macro, Nullable support via Option<T>, Inserter batch API |
| `md5`        | 0.8.0   | MD5 hash for dedup token parity with Python hashlib.md5                  | Lightweight, hex output matches Python hexdigest()                                    |
| `backon`     | 1.6     | Exponential backoff retry                                                | Already in workspace, used by providers crate                                         |
| `reqwest`    | 0.12    | HTTP client for flush thread (blocking mode)                             | Already in workspace with rustls-tls                                                  |
| `serde`      | 1.0     | Row derive serialization                                                 | Already in workspace                                                                  |
| `serde_json` | 1.0     | Parse X-ClickHouse-Summary JSON header                                   | Already in workspace                                                                  |
| `thiserror`  | 2.0     | Error types                                                              | Already in workspace                                                                  |
| `tracing`    | 0.1     | Structured logging for flush thread                                      | Already in workspace                                                                  |

### Supporting

| Library    | Version | Purpose                                  | When to Use                                   |
| ---------- | ------- | ---------------------------------------- | --------------------------------------------- |
| `wiremock` | 0.6     | Mock HTTP server for ClickHouse in tests | Already used by opendeviationbar-client crate |

### Alternatives Considered

| Instead of                  | Could Use                                          | Tradeoff                                                                                                                                                                           |
| --------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `clickhouse` crate Inserter | Raw `reqwest::blocking` POST with manual RowBinary | More control over dedup tokens, but must implement RowBinary serialization manually. Use `clickhouse` crate first -- fall back only if Inserter can't pass per-INSERT dedup tokens |
| `md5` crate                 | `md-5` (RustCrypto)                                | `md5` is simpler API, `md-5` is part of RustCrypto ecosystem. Either works. `md5` v0.8 provides `md5::compute()` returning `Digest` with hex display                               |

**Installation (Cargo.toml workspace deps to add):**

```toml
# In [workspace.dependencies]
clickhouse = { version = "0.14", features = ["inserter", "lz4", "rustls-tls-native-roots"] }
md5 = "0.8"
wiremock = "0.6"
```

## Architecture Patterns

### Recommended Module Structure

```
crates/opendeviationbar-streaming/src/
├── engine/
│   ├── mod.rs              # existing
│   ├── traits.rs           # existing BarSink trait
│   ├── sink_channel.rs     # existing ChannelSink
│   └── sink_clickhouse.rs  # NEW: ClickHouseWriterSink
├── clickhouse_writer/
│   ├── mod.rs              # Re-exports, ClickHouseWriterConfig
│   ├── row.rs              # #[derive(Row)] ClickHouseBarRow struct (60+ fields)
│   ├── dedup.rs            # MD5 dedup token generation
│   ├── flush_thread.rs     # std::thread flush loop with reqwest::blocking
│   ├── schema.rs           # system.columns query, plugin column discovery
│   └── summary.rs          # X-ClickHouse-Summary parsing
└── ...
```

### Pattern 1: BarSink Implementation with Flush Thread

**What:** `ClickHouseWriterSink` implements `BarSink` trait. `on_bar()` is non-blocking -- it converts `CompletedBar` to `ClickHouseBarRow` and sends via bounded `SyncSender`. A dedicated `std::thread` receives rows, buffers them, and POSTs to ClickHouse.

**When to use:** Always -- this is the core pattern.

```rust
// ClickHouseWriterSink owns the channel sender
pub struct ClickHouseWriterSink {
    tx: std::sync::mpsc::SyncSender<FlushCommand>,
    flush_thread: Option<std::thread::JoinHandle<()>>,
    bars_sent: AtomicU64,
    bars_dropped: AtomicU64,
}

enum FlushCommand {
    Bar(ClickHouseBarRow),
    Flush,       // Trigger immediate flush
    Shutdown,    // Graceful shutdown
}

impl BarSink for ClickHouseWriterSink {
    fn on_bar(&mut self, bar: &CompletedBar) -> Result<(), SinkError> {
        let row = ClickHouseBarRow::from_completed_bar(bar);
        match self.tx.try_send(FlushCommand::Bar(row)) {
            Ok(()) => { self.bars_sent.fetch_add(1, Ordering::Relaxed); Ok(()) }
            Err(TrySendError::Full(_)) => {
                self.bars_dropped.fetch_add(1, Ordering::Relaxed);
                Err(SinkError::Recoverable("clickhouse flush thread backpressure".into()))
            }
            Err(TrySendError::Disconnected(_)) => {
                Err(SinkError::Unrecoverable("clickhouse flush thread died".into()))
            }
        }
    }

    fn flush(&mut self) -> Result<(), SinkError> {
        let _ = self.tx.send(FlushCommand::Flush);
        Ok(())
    }

    fn name(&self) -> &str { "clickhouse" }
}
```

### Pattern 2: ClickHouseBarRow (Row Derive)

**What:** A flat struct with `#[derive(Row, Serialize)]` matching the ClickHouse schema column-by-column. FixedPoint fields convert to f64, i128 volumes convert via `as f64 / SCALE as f64`.

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

#[derive(Row, Serialize)]
pub struct ClickHouseBarRow {
    // Cache key components
    pub symbol: String,            // LowCardinality(String) in CH
    pub threshold_decimal_bps: u32, // UInt32

    // OHLCV (FixedPoint -> f64)
    pub close_time_us: i64,        // Int64
    pub open_time_us: i64,         // Int64
    pub open: f64,                 // Float64
    pub high: f64,                 // Float64
    pub low: f64,                  // Float64
    pub close: f64,                // Float64
    pub volume: f64,               // Float64 (i128 / SCALE)

    // Market microstructure
    pub vwap: f64,
    pub buy_volume: f64,           // i128 / SCALE
    pub sell_volume: f64,          // i128 / SCALE
    pub individual_trade_count: u32,
    pub agg_record_count: u32,

    // Microstructure features (10 features)
    pub duration_us: i64,
    pub ofi: f64,
    pub vwap_close_deviation: f64,
    pub price_impact: f64,
    pub kyle_lambda_proxy: f64,
    pub trade_intensity: f64,
    pub volume_per_trade: f64,
    pub aggression_ratio: f64,
    pub aggregation_density: f64,  // NOTE: Rust field is aggregation_density_f64
    pub turnover_imbalance: f64,

    // Ouroboros
    pub ouroboros_mode: String,    // LowCardinality(String), hardcode "aion"

    // Exchange sessions (all default 0 for crypto)
    pub exchange_session_sydney: u8,
    pub exchange_session_tokyo: u8,
    pub exchange_session_london: u8,
    pub exchange_session_newyork: u8,

    // Inter-bar features (16 Nullable)
    pub lookback_trade_count: Option<u32>,
    pub lookback_ofi: Option<f64>,
    pub lookback_duration_us: Option<i64>,
    pub lookback_intensity: Option<f64>,
    pub lookback_vwap_raw: Option<f64>,     // NOTE: Rust type is Option<i64>, convert
    pub lookback_vwap_position: Option<f64>,
    pub lookback_count_imbalance: Option<f64>,
    pub lookback_kyle_lambda: Option<f64>,
    pub lookback_burstiness: Option<f64>,
    pub lookback_volume_skew: Option<f64>,
    pub lookback_volume_kurt: Option<f64>,
    pub lookback_price_range: Option<f64>,
    pub lookback_kaufman_er: Option<f64>,
    pub lookback_garman_klass_vol: Option<f64>,
    pub lookback_hurst: Option<f64>,
    pub lookback_permutation_entropy: Option<f64>,

    // Intra-bar features (22 Nullable)
    pub intra_bull_epoch_density: Option<f64>,
    pub intra_bear_epoch_density: Option<f64>,
    pub intra_bull_excess_gain: Option<f64>,
    pub intra_bear_excess_gain: Option<f64>,
    pub intra_bull_cv: Option<f64>,
    pub intra_bear_cv: Option<f64>,
    pub intra_max_drawdown: Option<f64>,
    pub intra_max_runup: Option<f64>,
    pub intra_trade_count: Option<u32>,
    pub intra_ofi: Option<f64>,
    pub intra_duration_us: Option<i64>,
    pub intra_intensity: Option<f64>,
    pub intra_vwap_position: Option<f64>,
    pub intra_count_imbalance: Option<f64>,
    pub intra_kyle_lambda: Option<f64>,
    pub intra_burstiness: Option<f64>,
    pub intra_volume_skew: Option<f64>,
    pub intra_volume_kurt: Option<f64>,
    pub intra_kaufman_er: Option<f64>,
    pub intra_garman_klass_vol: Option<f64>,
    pub intra_hurst: Option<f64>,
    pub intra_permutation_entropy: Option<f64>,

    // Trade ID range
    pub first_agg_trade_id: i64,
    pub last_agg_trade_id: i64,

    // Orphan flag (default 0, set by Python -- Rust always writes 0)
    pub is_orphan: u8,

    // Cache metadata
    pub cache_key: String,
    pub opendeviationbar_version: String,
    pub source_start_ts: i64,
    pub source_end_ts: i64,
    // computed_at: omitted -- ClickHouse DEFAULT now64(3, 'UTC')
    // is_liquidation_cascade: omitted -- ClickHouse DEFAULT expression
}
```

### Pattern 3: Dedup Token Generation

**What:** Exact parity with Python's `hashlib.md5(f"...".encode()).hexdigest()`.

```rust
pub fn compute_dedup_token(
    symbol: &str,
    threshold_decimal_bps: u32,
    start_ts: i64,
    end_ts: i64,
    ouroboros_mode: &str,
) -> String {
    let key_str = format!(
        "{}_{}_{}_{}_{}",
        symbol, threshold_decimal_bps, start_ts, end_ts, ouroboros_mode
    );
    let digest = md5::compute(key_str.as_bytes());
    format!("{:x}", digest)
}
```

**Cross-language parity test:**

```rust
#[test]
fn test_dedup_token_parity_with_python() {
    // Python: hashlib.md5("BTCUSDT_250_1700000000000000_1700000100000000_aion".encode()).hexdigest()
    let token = compute_dedup_token("BTCUSDT", 250, 1700000000000000, 1700000100000000, "aion");
    assert_eq!(token, EXPECTED_PYTHON_HASH); // Compute expected via: python3 -c "..."
}
```

### Pattern 4: Flush Thread with Batch Buffering

**What:** Dedicated `std::thread` receives rows via `SyncSender`, buffers until threshold hit, then POSTs to ClickHouse.

```rust
fn flush_thread_loop(
    rx: Receiver<FlushCommand>,
    config: ClickHouseWriterConfig,
) {
    let client = clickhouse::Client::default()
        .with_url(&config.url)
        .with_option("wait_end_of_query", "1");

    let mut buffer: Vec<ClickHouseBarRow> = Vec::with_capacity(config.max_rows);
    let mut last_flush = Instant::now();

    loop {
        match rx.recv_timeout(Duration::from_millis(config.flush_period_ms)) {
            Ok(FlushCommand::Bar(row)) => {
                buffer.push(row);
                if buffer.len() >= config.max_rows || estimated_bytes(&buffer) >= config.max_bytes {
                    do_flush(&client, &mut buffer, &config);
                    last_flush = Instant::now();
                }
            }
            Ok(FlushCommand::Flush) | Err(RecvTimeoutError::Timeout) => {
                if !buffer.is_empty() {
                    do_flush(&client, &mut buffer, &config);
                    last_flush = Instant::now();
                }
            }
            Ok(FlushCommand::Shutdown) => {
                if !buffer.is_empty() {
                    do_flush(&client, &mut buffer, &config);
                }
                break;
            }
            Err(RecvTimeoutError::Disconnected) => break,
        }
    }
}
```

### Anti-Patterns to Avoid

- **Async in flush thread**: The `clickhouse` crate's `insert()` is async. Using `tokio::runtime::Runtime::block_on()` inside the flush thread adds unnecessary complexity. Instead, use `reqwest::blocking` for the raw HTTP POST, or run the `clickhouse` crate's async Inserter on a dedicated tokio runtime within the flush thread.
- **Hardcoding column list**: Plugin columns are dynamic. Core columns are defined in the Row struct, but the `system.columns` query at startup discovers additional plugin columns.
- **Sending `is_liquidation_cascade`**: This is a computed column (DEFAULT expression). Including it in INSERT causes schema mismatch or overrides the computation.
- **Using lightweight `DELETE FROM`**: Permanently banned -- creates ghost rows with `_row_exists=0`.

## Don't Hand-Roll

| Problem                 | Don't Build              | Use Instead                                     | Why                                                            |
| ----------------------- | ------------------------ | ----------------------------------------------- | -------------------------------------------------------------- |
| RowBinary serialization | Custom binary serializer | `clickhouse` crate Row derive                   | 60+ columns, Nullable handling, type validation against schema |
| Exponential backoff     | Custom retry loop        | `backon` crate ExponentialBuilder               | Already proven in providers crate, jitter support              |
| HTTP mocking in tests   | Custom HTTP stub         | `wiremock` crate                                | Already in workspace, request matching, response templating    |
| MD5 hashing             | Custom implementation    | `md5` crate                                     | Single function call, hex formatting via Display trait         |
| JSON parsing            | Manual string parsing    | `serde_json::from_str` for X-ClickHouse-Summary | Structured parsing, error handling                             |

**Key insight:** The `clickhouse` crate v0.14.2 provides almost everything needed -- Row derive, RowBinary serialization, HTTP transport, Nullable via Option<T>, LowCardinality as plain String. The main gap is whether `Inserter` supports per-INSERT `insert_deduplication_token`. If not, use `client.insert()` with `.with_option()` per batch.

## Common Pitfalls

### Pitfall 1: FixedPoint to f64 Conversion

**What goes wrong:** Rust `OpenDeviationBar` stores prices as `FixedPoint(i64)` and volumes as `i128`. Direct cast produces garbage values.
**Why it happens:** FixedPoint uses SCALE = 100_000_000. Volume i128 also uses SCALE.
**How to avoid:** Prices: `bar.open.to_f64()`. Volumes: `bar.volume as f64 / SCALE as f64` where SCALE = 100_000_000.
**Warning signs:** Prices in billions or volumes as huge integers in ClickHouse.

### Pitfall 2: lookback_vwap_raw Type Mismatch

**What goes wrong:** Rust struct stores `lookback_vwap_raw: Option<i64>` (raw FixedPoint value), but ClickHouse schema expects `Nullable(Float64)`.
**Why it happens:** Inter-bar vwap is stored as FixedPoint raw value for serialization efficiency.
**How to avoid:** Convert: `bar.lookback_vwap_raw.map(|v| v as f64 / SCALE as f64)`.
**Warning signs:** VWAP values in hundreds of billions in ClickHouse.

### Pitfall 3: aggregation_density Field Name Mismatch

**What goes wrong:** Rust field is `aggregation_density_f64`, ClickHouse column is `aggregation_density`.
**Why it happens:** Historical naming divergence (Issue #32 rename).
**How to avoid:** Use `#[serde(rename = "aggregation_density")]` on the Row struct field, or name it `aggregation_density` in the Row struct and map from `bar.aggregation_density_f64`.
**Warning signs:** "column not found" error or NULL values in aggregation_density.

### Pitfall 4: Dedup Token String Formatting

**What goes wrong:** Python uses string interpolation with integer values. Rust's `format!()` must produce identical output.
**Why it happens:** Python's `f"{start_ts}"` formats i64 identically to Rust's `format!("{}", start_ts)`, but any whitespace, type coercion, or decimal formatting differences break parity.
**How to avoid:** Fixed-input cross-language parity test. Run Python hashlib.md5 and Rust md5::compute on identical inputs, compare hex output.
**Warning signs:** Dedup tokens don't match -- ClickHouse silently drops bars (they look like new inserts).

### Pitfall 5: X-ClickHouse-Summary Requires wait_end_of_query=1

**What goes wrong:** Without `wait_end_of_query=1`, the summary header may show all zeros.
**Why it happens:** ClickHouse streams response headers before query completes. The summary is only accurate when response is buffered.
**How to avoid:** Pass `wait_end_of_query=1` as URL parameter or setting.
**Warning signs:** `written_rows` always showing 0 in summary.

### Pitfall 6: Column Order in RowBinary

**What goes wrong:** RowBinary format is order-dependent. Columns must match the INSERT column list exactly.
**Why it happens:** Unlike JSONEachRow, RowBinary has no column names in the wire format.
**How to avoid:** The `clickhouse` crate v0.14.2 uses `RowBinaryWithNamesAndTypes` by default, which validates column order against the schema. This is safer but slightly slower. Use it.
**Warning signs:** "NotEnoughData" errors from ClickHouse.

### Pitfall 7: Async vs Sync in Flush Thread

**What goes wrong:** The `clickhouse` crate is async. Mixing async client with `std::thread` requires a tokio runtime.
**Why it happens:** BarSink is synchronous (`fn on_bar()`), but ClickHouse client is async.
**How to avoid:** Option A: Create a `tokio::runtime::Runtime` inside the flush thread for the `clickhouse` crate. Option B: Use `reqwest::blocking` directly with manual RowBinary (not recommended). Option A is simpler since the `clickhouse` crate handles serialization.
**Warning signs:** Panic "cannot start runtime from within a runtime" if using nested runtimes.

### Pitfall 8: Plugin Columns at INSERT Time

**What goes wrong:** Plugin columns exist in the table but not in the Row struct. INSERT fails if the Row includes columns that don't exist, or if plugin columns are NOT NULL without defaults.
**Why it happens:** Plugin columns are added via `ALTER TABLE ADD COLUMN IF NOT EXISTS` with DEFAULT NULL.
**How to avoid:** Omit plugin columns from INSERT. They all have `DEFAULT NULL`. The Row struct only includes core columns. Plugin values are computed by Python post-processing.
**Warning signs:** Schema mismatch errors mentioning unknown columns.

## Code Examples

### Verified Column Mapping (schema.sql -> Rust)

```rust
// Source: python/opendeviationbar/clickhouse/schema.sql + crates/opendeviationbar-core/src/types.rs

impl ClickHouseBarRow {
    pub fn from_completed_bar(bar: &CompletedBar) -> Self {
        const SCALE: f64 = 100_000_000.0;
        let b = &bar.bar;
        Self {
            symbol: bar.symbol.to_string(),
            threshold_decimal_bps: bar.threshold_decimal_bps,
            close_time_us: b.close_time,
            open_time_us: b.open_time,
            open: b.open.to_f64(),
            high: b.high.to_f64(),
            low: b.low.to_f64(),
            close: b.close.to_f64(),
            volume: b.volume as f64 / SCALE,
            vwap: b.vwap.to_f64(),
            buy_volume: b.buy_volume as f64 / SCALE,
            sell_volume: b.sell_volume as f64 / SCALE,
            individual_trade_count: b.individual_trade_count,
            agg_record_count: b.agg_record_count,
            duration_us: b.duration_us,
            ofi: b.ofi,
            vwap_close_deviation: b.vwap_close_deviation,
            price_impact: b.price_impact,
            kyle_lambda_proxy: b.kyle_lambda_proxy,
            trade_intensity: b.trade_intensity,
            volume_per_trade: b.volume_per_trade,
            aggression_ratio: b.aggression_ratio,
            aggregation_density: b.aggregation_density_f64,  // field name mismatch!
            turnover_imbalance: b.turnover_imbalance,
            ouroboros_mode: "aion".to_string(),  // hardcoded, day-mode removed
            exchange_session_sydney: 0,  // crypto = no sessions
            exchange_session_tokyo: 0,
            exchange_session_london: 0,
            exchange_session_newyork: 0,
            lookback_trade_count: b.lookback_trade_count,
            lookback_ofi: b.lookback_ofi,
            lookback_duration_us: b.lookback_duration_us,
            lookback_intensity: b.lookback_intensity,
            lookback_vwap_raw: b.lookback_vwap_raw.map(|v| v as f64 / SCALE),  // i64 -> f64
            lookback_vwap_position: b.lookback_vwap_position,
            lookback_count_imbalance: b.lookback_count_imbalance,
            lookback_kyle_lambda: b.lookback_kyle_lambda,
            lookback_burstiness: b.lookback_burstiness,
            lookback_volume_skew: b.lookback_volume_skew,
            lookback_volume_kurt: b.lookback_volume_kurt,
            lookback_price_range: b.lookback_price_range,
            lookback_kaufman_er: b.lookback_kaufman_er,
            lookback_garman_klass_vol: b.lookback_garman_klass_vol,
            lookback_hurst: b.lookback_hurst,
            lookback_permutation_entropy: b.lookback_permutation_entropy,
            intra_bull_epoch_density: b.intra_bull_epoch_density,
            intra_bear_epoch_density: b.intra_bear_epoch_density,
            intra_bull_excess_gain: b.intra_bull_excess_gain,
            intra_bear_excess_gain: b.intra_bear_excess_gain,
            intra_bull_cv: b.intra_bull_cv,
            intra_bear_cv: b.intra_bear_cv,
            intra_max_drawdown: b.intra_max_drawdown,
            intra_max_runup: b.intra_max_runup,
            intra_trade_count: b.intra_trade_count,
            intra_ofi: b.intra_ofi,
            intra_duration_us: b.intra_duration_us,
            intra_intensity: b.intra_intensity,
            intra_vwap_position: b.intra_vwap_position,
            intra_count_imbalance: b.intra_count_imbalance,
            intra_kyle_lambda: b.intra_kyle_lambda,
            intra_burstiness: b.intra_burstiness,
            intra_volume_skew: b.intra_volume_skew,
            intra_volume_kurt: b.intra_volume_kurt,
            intra_kaufman_er: b.intra_kaufman_er,
            intra_garman_klass_vol: b.intra_garman_klass_vol,
            intra_hurst: b.intra_hurst,
            intra_permutation_entropy: b.intra_permutation_entropy,
            first_agg_trade_id: b.first_agg_trade_id,
            last_agg_trade_id: b.last_agg_trade_id,
            is_orphan: 0,  // always 0 from Rust, Python sets it
            cache_key: String::new(),  // computed per-batch in flush thread
            opendeviationbar_version: env!("CARGO_PKG_VERSION").to_string(),
            source_start_ts: 0,  // computed per-batch
            source_end_ts: 0,    // computed per-batch
        }
    }
}
```

### Columns to OMIT from INSERT (computed/default in ClickHouse)

```
- is_liquidation_cascade  (DEFAULT toUInt8(...) expression)
- computed_at              (DEFAULT now64(3, 'UTC'))
- All plugin columns       (DEFAULT NULL, not in core schema)
```

### X-ClickHouse-Summary Parsing

```rust
// Source: ClickHouse HTTP interface docs
#[derive(Debug, Deserialize)]
struct ClickHouseSummary {
    read_rows: String,      // String in JSON, parse to u64
    written_rows: String,
    written_bytes: String,
    elapsed_ns: String,
}

fn parse_summary(header_value: &str) -> Option<ClickHouseSummary> {
    serde_json::from_str(header_value).ok()
}
```

### system.columns Discovery Query

```rust
// Source: WRITER-09 requirement
fn discover_plugin_columns(client: &Client) -> Vec<String> {
    // Query all columns from the bars table
    let all_columns: Vec<String> = client.query(
        "SELECT name FROM system.columns \
         WHERE database = 'opendeviationbar_cache' \
         AND table = 'open_deviation_bars' \
         ORDER BY position"
    ).fetch_all().await.unwrap();

    // Filter out core columns (known at compile time)
    all_columns.into_iter()
        .filter(|c| !CORE_COLUMNS.contains(&c.as_str()))
        .collect()
}
```

## State of the Art

| Old Approach                       | Current Approach              | When Changed          | Impact                                   |
| ---------------------------------- | ----------------------------- | --------------------- | ---------------------------------------- |
| `clickhouse_connect` Python client | Rust `clickhouse` crate 0.14  | Phase 34 (this phase) | Eliminates Python from hot path INSERT   |
| JSONEachRow INSERT format          | RowBinaryWithNamesAndTypes    | clickhouse-rs 0.14.0  | Schema validation, better error messages |
| `close_time_us` ORDER BY           | `first_agg_trade_id` ORDER BY | Issue #158 Phase 7    | Native ReplacingMergeTree dedup          |
| Day-mode + Aion                    | Aion-only                     | v8.0                  | ouroboros_mode hardcoded to "aion"       |

## Open Questions

1. **Inserter vs raw client.insert() for dedup tokens**
   - What we know: `Inserter` supports `with_max_rows()`, `with_period()`, `with_max_bytes()` for batch buffering. `client.insert()` supports `.with_option("insert_deduplication_token", token)`.
   - What's unclear: Whether `Inserter` supports per-commit dedup tokens (it commits internally). The dedup token needs to change per batch since it encodes timestamp ranges.
   - Recommendation: Start with `client.insert()` per batch (simpler, explicit dedup token per INSERT). The flush thread controls batching anyway. If performance is insufficient, investigate Inserter.

2. **Async clickhouse crate inside std::thread**
   - What we know: `clickhouse` crate is async-only. The flush thread is `std::thread` for isolation from the tokio runtime.
   - What's unclear: Best pattern for running async in std::thread -- `tokio::runtime::Builder::new_current_thread()` inside the thread, or share the existing tokio runtime handle.
   - Recommendation: Create a dedicated single-threaded tokio runtime inside the flush thread. This avoids coupling to the engine's runtime and is the standard pattern for sync-async bridging.

3. **Turnover columns**
   - What we know: `OpenDeviationBar` has `turnover`, `buy_turnover`, `sell_turnover` (all i128). The ClickHouse schema does NOT have these columns.
   - What's unclear: Whether these should be added later.
   - Recommendation: Do NOT include turnover columns in the Row struct. They're not in schema.sql. Turnover is used internally for VWAP computation.

## Validation Architecture

### Test Framework

| Property           | Value                                                                        |
| ------------------ | ---------------------------------------------------------------------------- |
| Framework          | cargo nextest + wiremock 0.6                                                 |
| Config file        | `.config/nextest.toml` (if exists) or default                                |
| Quick run command  | `cargo nextest run -p opendeviationbar-streaming --features clickhouse-sink` |
| Full suite command | `cargo nextest run --workspace --exclude opendeviationbar-py`                |

### Phase Requirements -> Test Map

| Req ID    | Behavior                                                          | Test Type              | Automated Command                                                                                        | File Exists? |
| --------- | ----------------------------------------------------------------- | ---------------------- | -------------------------------------------------------------------------------------------------------- | ------------ |
| WRITER-01 | BarSink trait impl compiles and basic on_bar/flush/name           | unit                   | `cargo nextest run -p opendeviationbar-streaming --features clickhouse-sink -E 'test(sink_clickhouse)'`  | Wave 0       |
| WRITER-02 | ClickHouseBarRow from_completed_bar produces correct field values | unit                   | `cargo nextest run -p opendeviationbar-streaming --features clickhouse-sink -E 'test(row_conversion)'`   | Wave 0       |
| WRITER-03 | Batch flushes at N bars, T ms, and max bytes                      | unit                   | `cargo nextest run -p opendeviationbar-streaming --features clickhouse-sink -E 'test(batch_flush)'`      | Wave 0       |
| WRITER-04 | MD5 dedup token parity with Python                                | unit                   | `cargo nextest run -p opendeviationbar-streaming --features clickhouse-sink -E 'test(dedup_parity)'`     | Wave 0       |
| WRITER-05 | Retry on 429/503, fail on 400/401                                 | integration (wiremock) | `cargo nextest run -p opendeviationbar-streaming --features clickhouse-sink -E 'test(retry)'`            | Wave 0       |
| WRITER-06 | on_bar() returns immediately, bars appear in CH after flush       | integration (wiremock) | `cargo nextest run -p opendeviationbar-streaming --features clickhouse-sink -E 'test(flush_thread)'`     | Wave 0       |
| WRITER-07 | X-ClickHouse-Summary parsed, mismatch logged                      | unit + integration     | `cargo nextest run -p opendeviationbar-streaming --features clickhouse-sink -E 'test(summary)'`          | Wave 0       |
| WRITER-08 | is_liquidation_cascade not in INSERT column list                  | unit                   | `cargo nextest run -p opendeviationbar-streaming --features clickhouse-sink -E 'test(omit_computed)'`    | Wave 0       |
| WRITER-09 | system.columns query returns plugin columns                       | integration (wiremock) | `cargo nextest run -p opendeviationbar-streaming --features clickhouse-sink -E 'test(plugin_discovery)'` | Wave 0       |

### Sampling Rate

- **Per task commit:** `cargo nextest run -p opendeviationbar-streaming --features clickhouse-sink`
- **Per wave merge:** `cargo nextest run --workspace --exclude opendeviationbar-py`
- **Phase gate:** Full suite green before `/gsd:verify-work`

### Wave 0 Gaps

- [ ] All test files are new (sink_clickhouse module does not exist yet)
- [ ] `wiremock = "0.6"` needs to be added to opendeviationbar-streaming dev-dependencies
- [ ] `clickhouse-sink` feature needs to be defined in Cargo.toml
- [ ] `tokio` test runtime needed for async wiremock tests

## Sources

### Primary (HIGH confidence)

- `crates/opendeviationbar-streaming/src/engine/traits.rs` -- BarSink trait, SinkError enum (lines 1-88)
- `crates/opendeviationbar-streaming/src/engine/sink_channel.rs` -- ChannelSink reference implementation
- `crates/opendeviationbar-streaming/src/live_engine/types.rs` -- CompletedBar struct
- `crates/opendeviationbar-core/src/types.rs` -- OpenDeviationBar struct (all 70+ fields)
- `crates/opendeviationbar-core/src/arrow_export.rs` -- Column names and type conversions (volume/SCALE)
- `python/opendeviationbar/clickhouse/schema.sql` -- ClickHouse table schema (67 columns)
- `python/opendeviationbar/clickhouse/bulk_operations.py` -- Dedup token formula (line 558-560, 880-884), INSERT column list
- `python/opendeviationbar/clickhouse/cache.py` -- CacheKey.hash_key MD5 formula (lines 100-104)
- `python/opendeviationbar/constants.py` -- MICROSTRUCTURE_COLUMNS, INTER_BAR_FEATURE_COLUMNS, INTRA_BAR_FEATURE_COLUMNS

### Secondary (MEDIUM confidence)

- [ClickHouse Rust client docs](https://clickhouse.com/docs/integrations/rust) -- Row derive, Inserter API, with_option()
- [ClickHouse HTTP interface](https://clickhouse.com/docs/interfaces/http) -- X-ClickHouse-Summary format, wait_end_of_query
- [clickhouse-rs GitHub](https://github.com/ClickHouse/clickhouse-rs) -- v0.14.2, features list, inserter cargo feature
- [ClickHouse X-ClickHouse-Summary issue #12122](https://github.com/ClickHouse/ClickHouse/issues/12122) -- Summary accuracy requires send_progress_in_http_headers or wait_end_of_query

### Tertiary (LOW confidence)

- Inserter per-commit dedup token behavior -- not documented, needs runtime verification

## Metadata

**Confidence breakdown:**

- Standard stack: HIGH -- all crates verified on crates.io, versions confirmed
- Architecture: HIGH -- BarSink trait, ChannelSink reference, CompletedBar struct all inspected
- Column mapping: HIGH -- cross-referenced OpenDeviationBar fields, arrow_export column names, and schema.sql columns
- Dedup token: HIGH -- Python source code inspected, formula verified in two locations (bulk_operations.py and cache.py)
- Pitfalls: HIGH -- based on actual code inspection of type mismatches and naming divergences
- Inserter dedup token support: LOW -- not verified in docs or source

**Research date:** 2026-03-28
**Valid until:** 2026-04-28 (stable domain -- Rust crate versions and ClickHouse schema unlikely to change)
