# Architecture Patterns: Rust-Native ClickHouse Writer

**Domain:** Rust BarSink ClickHouse writer integration into existing streaming pipeline
**Researched:** 2026-03-28

## Recommended Architecture

### Design Decision: BarSink Consumes CompletedBars, Not Arrow

The ClickHouseWriter implements `BarSink` and receives `CompletedBar` structs via `on_bar()`. It does NOT consume Arrow batches. Rationale:

1. **BarSink trait is synchronous and per-bar.** The trait signature is `on_bar(&mut self, bar: &CompletedBar) -> Result<(), SinkError>`. Arrow batches are a drain-time concern, not a per-bar concern.
2. **Batch buffering belongs inside the sink.** The sink accumulates `CompletedBar` structs internally, then serializes to ClickHouse's expected format on flush. This matches the existing ChannelSink pattern (receives per-bar, buffers in channel).
3. **Arrow is an export format, not an internal transport.** The existing `drain_bars_arrow()` path exists for Python interop (zero-copy FFI). Inside pure Rust, there is no GIL to cross, so Arrow adds overhead without benefit.

### Data Flow: Current vs Proposed

```
CURRENT (Python write path):
  LiveBarEngine
    -> ring buffer (CompletedBar)
    -> StreamManager.next_bar() [Python polls via PyO3]
    -> Python main loop: burst drain, pop _symbol/_threshold
    -> bar_batch_buffer[key].append(bar_dict)
    -> _write_batch_with_retry() [stamina retry]
    -> _write_bars_batch_to_clickhouse() [Polars DataFrame]
    -> OpenDeviationBarCache.store_bars_batch()
    -> clickhouse-connect client.insert() [HTTP POST]

PROPOSED (Rust write path, feature-flagged):
  LiveBarEngine
    -> fan-out to sinks:
       1. ChannelSink (existing, feeds Python for orchestration/health)
       2. ClickHouseWriterSink (NEW, writes to CH directly)
          -> internal Vec<CompletedBar> buffer
          -> flush on: N bars OR T ms elapsed OR shutdown
          -> serialize to JSONEachRow
          -> reqwest POST to CH HTTP interface (port 8123)
          -> on failure: Niffler dead-letter to Parquet
    -> Python still polls ChannelSink for:
       - Health metrics, Argus liveness, Lethe overflow
       - SSE broadcasting, forming bar push
       - Checkpoint saves, min-TID guard
       - BUT: skips _write_batch_with_retry() entirely
```

### Component Boundaries

| Component                               | Responsibility                                                                       | Communicates With                                                                     |
| --------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------- |
| `ClickHouseWriterSink` (NEW)            | Buffered BarSink impl: accumulate bars, flush to CH via HTTP, dead-letter on failure | LiveBarEngine (receives bars), ClickHouse (HTTP POST), Parquet dead-letter (fallback) |
| `ClickHouseWriterConfig` (NEW)          | CH connection params, buffer thresholds, dedup settings                              | Created by Python, passed to Rust via PyO3                                            |
| `WriterMetrics` (NEW)                   | Counters: bars_written, bars_failed, flushes, dead_letters, latency_us               | Read by Python for heartbeat/telemetry                                                |
| `LiveBarEngine` (MODIFIED)              | Fan-out to multiple sinks (currently single ChannelSink)                             | ChannelSink + ClickHouseWriterSink                                                    |
| `StreamManager` (MODIFIED)              | Expose writer metrics, start/stop writer                                             | Python orchestrator reads metrics                                                     |
| Python sidecar `__init__.py` (MODIFIED) | Feature-flag dispatch: Rust writer vs Python writer                                  | StreamManager (metrics), ClickHouse (health checks only)                              |
| Python `clickhouse_write.py` (KEPT)     | Fallback writer when Rust feature flag disabled                                      | OpenDeviationBarCache                                                                 |

### New Component: ClickHouseWriterSink

```rust
// crates/opendeviationbar-streaming/src/engine/sink_clickhouse.rs

pub struct ClickHouseWriterConfig {
    pub host: String,              // e.g. "localhost" or "bigblack"
    pub port: u16,                 // 8123
    pub database: String,          // "opendeviationbar_cache"
    pub table: String,             // "open_deviation_bars"
    pub batch_max_bars: usize,     // e.g. 50, matches Python batch_max_size
    pub flush_interval_ms: u64,    // e.g. 5000, time-based flush
    pub max_retries: u32,          // e.g. 3, matches stamina retries
    pub retry_base_delay_ms: u64,  // e.g. 1000
    pub dead_letter_dir: PathBuf,  // /tmp/opendeviationbar-dead-letter/
    pub version_tag: String,       // e.g. "14.0.0+sidecar"
    pub ouroboros_mode: String,    // "aion" (hardcoded for now, all symbols)
}

pub struct ClickHouseWriterSink {
    config: ClickHouseWriterConfig,
    flush_tx: std::sync::mpsc::SyncSender<FlushBatch>,
    buffer: Vec<CompletedBar>,
    last_flush: Instant,
    metrics: Arc<WriterMetrics>,
}

impl BarSink for ClickHouseWriterSink {
    fn on_bar(&mut self, bar: &CompletedBar) -> Result<(), SinkError> {
        self.buffer.push(bar.clone());
        if self.buffer.len() >= self.config.batch_max_bars
            || self.last_flush.elapsed().as_millis() as u64 >= self.config.flush_interval_ms
        {
            self.flush()?;
        }
        Ok(())
    }

    fn flush(&mut self) -> Result<(), SinkError> {
        if self.buffer.is_empty() { return Ok(()); }
        let bars = std::mem::take(&mut self.buffer);
        match self.flush_tx.try_send(FlushBatch { bars }) {
            Ok(()) => {
                self.last_flush = Instant::now();
                Ok(())
            }
            Err(TrySendError::Full(batch)) => {
                self.niffler_dead_letter_sync(&batch.bars);
                Err(SinkError::Recoverable("flush channel full".into()))
            }
            Err(TrySendError::Disconnected(_)) => {
                Err(SinkError::Unrecoverable("flush thread died".into()))
            }
        }
    }

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

### ClickHouse Write Protocol

The Rust writer must exactly replicate the Python write protocol to maintain INSERT dedup compatibility:

**1. INSERT format:** HTTP POST to `http://{host}:{port}/` with query parameter:

```
POST /?query=INSERT+INTO+opendeviationbar_cache.open_deviation_bars+FORMAT+JSONEachRow
   &insert_deduplicate=1
   &insert_deduplication_token={md5_token}
```

**2. Dedup token generation (must match Python exactly):**

```rust
// Python: f"{symbol}_{threshold_decimal_bps}_{start_ts}_{end_ts}_{ouroboros_mode}"
// Then: hashlib.md5(key_str.encode()).hexdigest()
let key_str = format!(
    "{}_{}_{}_{}_{}",
    symbol, threshold_decimal_bps, min_close_time_us, max_close_time_us, ouroboros_mode
);
let token = format!("{:x}", md5::compute(key_str.as_bytes()));
```

**3. Metadata columns (computed in Rust, not by CH defaults):**

- `symbol`: from `CompletedBar.symbol`
- `threshold_decimal_bps`: from `CompletedBar.threshold_decimal_bps`
- `ouroboros_mode`: from config (all "aion")
- `cache_key`: MD5 of `{symbol}_{threshold}_{start_ts}_{end_ts}_{ouroboros_mode}`
- `opendeviationbar_version`: from config version_tag
- `source_start_ts`: `min(open_time_us)` in batch
- `source_end_ts`: `max(close_time_us)` in batch
- `computed_at`: omitted (CH DEFAULT now64(3, 'UTC') handles it)

**4. Body format:** JSONEachRow (one JSON object per line, no array wrapper). ClickHouse natively parses JSONEachRow at ~100MB/s which far exceeds the ~50KB/s sustained write rate. Simpler than RowBinary and debuggable.

**5. Per-partition INSERT:** Group bars by `(symbol, threshold_decimal_bps)` before INSERT. Each HTTP POST writes to a single partition, matching CH PARTITION BY key. Mixing partitions creates more small parts and slower merges.

### Fan-Out Architecture

The LiveBarEngine currently pushes to a ring buffer that Python polls via `next_bar()`. The proposed change adds multi-sink fan-out:

```rust
// Current: single output path
// symbol_task() -> ring_buffer.push(bar) -> next_bar() poll

// Proposed: fan-out in symbol_task()
// symbol_task() -> for each completed bar:
//   1. ring_buffer.push(bar)  // for Python ChannelSink (existing)
//   2. ch_sink.on_bar(&bar)   // for ClickHouse direct (NEW)
```

**Critical:** The fan-out happens inside `symbol_task()` in the Tokio task. Since `BarSink` is synchronous, the ClickHouseWriterSink's `on_bar()` must be non-blocking. The flush thread pattern solves this:

```
symbol_task() --on_bar()--> ClickHouseWriterSink
  -> push to internal Vec<CompletedBar> (sync, <1us)
  -> when buffer full: send batch to flush_thread via SyncSender
flush_thread (dedicated std::thread):
  -> recv batch from SyncSender
  -> group by (symbol, threshold)
  -> serialize to JSONEachRow
  -> reqwest::blocking POST to CH
  -> on failure: exponential backoff retry
  -> on all retries exhausted: write Parquet dead-letter
  -> update WriterMetrics atomics
```

This keeps the hot path (WS trade -> processor -> on_bar) fully synchronous and non-blocking.

### Configuration Flow: Python -> Rust

Python owns environment variables and settings. Configuration flows one-way:

```python
# Python sidecar/__init__.py (in _create_engine):
if os.environ.get("OPENDEVIATIONBAR_USE_RUST_CH_WRITER") == "1":
    from opendeviationbar.config import Settings
    settings = Settings.get()

    writer_config = {
        "host": settings.ch_host,        # from OPENDEVIATIONBAR_CH_HOSTS
        "port": settings.ch_port,        # 8123
        "database": "opendeviationbar_cache",
        "table": "open_deviation_bars",
        "batch_max_bars": 50,
        "flush_interval_ms": 5000,
        "max_retries": 3,
        "dead_letter_dir": str(DEAD_LETTER_DIR),
        "version_tag": f"{__version__}+sidecar",
        "ouroboros_mode": "aion",
    }
    engine.configure_ch_writer(writer_config)  # PyO3 method
```

The Rust side receives this as a Python dict and constructs `ClickHouseWriterConfig`. No Rust code reads environment variables directly -- Python remains the config SSoT.

### Metrics Bridge: Rust -> Python

WriterMetrics are exposed via `StreamManager.get_combined_metrics()` (existing pattern):

```rust
pub struct WriterMetrics {
    pub bars_written: AtomicU64,
    pub bars_failed: AtomicU64,
    pub flushes: AtomicU64,
    pub dead_letters: AtomicU64,
    pub last_flush_latency_us: AtomicU64,
    pub last_flush_timestamp_ms: AtomicU64,
}
```

Python heartbeat reads these via existing `engine.get_metrics()` dict:

```python
metrics = engine.get_metrics()
# Existing keys: trades_received, bars_emitted, reconnections, ...
# New keys: ch_bars_written, ch_bars_failed, ch_flushes, ch_dead_letters,
#           ch_last_flush_latency_us, ch_last_flush_timestamp_ms
```

Heartbeat stathera check can validate: `ch_bars_written` is incrementing, `ch_dead_letters` is 0.

### Feature Flag and Fallback

```
OPENDEVIATIONBAR_USE_RUST_CH_WRITER=1  -> Rust writes, Python skips _write_batch_with_retry
OPENDEVIATIONBAR_USE_RUST_CH_WRITER=0  -> Python writes (current behavior, default)
```

When Rust writer is active, the Python main loop changes:

```python
# Current:
if len(bar_batch_buffer[batch_key]) >= batch_max_size:
    _write_batch_with_retry(symbol, threshold, batch, ...)

# With Rust writer:
if use_rust_writer:
    pass  # Rust ClickHouseWriterSink already wrote via on_bar() fan-out
else:
    if len(bar_batch_buffer[batch_key]) >= batch_max_size:
        _write_batch_with_retry(symbol, threshold, batch, ...)
```

Python still receives bars via ChannelSink for: valid_pairs filtering, min-TID guard, SSE broadcast, checkpoint saves, Argus/Lethe monitoring. These are lightweight operations.

## Patterns to Follow

### Pattern 1: Flush Thread (Non-Blocking Writes)

**What:** Dedicate a std::thread for HTTP writes, communicate via bounded SyncSender channel.
**When:** Always. The BarSink.on_bar() is called in the Tokio symbol_task(). Blocking HTTP calls would stall trade processing.
**Why:** The existing ChannelSink uses try_send() (non-blocking) for the same reason.

```rust
struct ClickHouseWriterSink {
    flush_tx: std::sync::mpsc::SyncSender<FlushBatch>,
    buffer: Vec<CompletedBar>,
    metrics: Arc<WriterMetrics>,
}

impl BarSink for ClickHouseWriterSink {
    fn on_bar(&mut self, bar: &CompletedBar) -> Result<(), SinkError> {
        self.buffer.push(bar.clone());
        if self.should_flush() {
            let batch = std::mem::take(&mut self.buffer);
            match self.flush_tx.try_send(FlushBatch { bars: batch }) {
                Ok(()) => {},
                Err(TrySendError::Full(batch)) => {
                    self.niffler_dead_letter_sync(&batch.bars);
                    return Err(SinkError::Recoverable("flush channel full".into()));
                }
                Err(TrySendError::Disconnected(_)) => {
                    return Err(SinkError::Unrecoverable("flush thread died".into()));
                }
            }
        }
        Ok(())
    }
}
```

### Pattern 2: Per-Partition Batching

**What:** Group bars by (symbol, threshold) before INSERT, matching the ClickHouse PARTITION BY key.
**When:** Always. ClickHouse INSERT performance is best when rows go to a single partition.
**Why:** The existing Python path does this (per-pair write via `_write_batch_with_retry`). Mixing partitions in one INSERT creates more small parts.

```rust
fn write_batch(&self, bars: &[CompletedBar]) -> Result<(), WriteError> {
    let mut by_partition: HashMap<(Arc<str>, u32), Vec<&CompletedBar>> = HashMap::new();
    for bar in bars {
        by_partition.entry((bar.symbol.clone(), bar.threshold_decimal_bps))
            .or_default()
            .push(bar);
    }
    for ((sym, thr), partition_bars) in &by_partition {
        self.insert_partition(sym, *thr, partition_bars)?;
    }
    Ok(())
}
```

### Pattern 3: Stamina-Equivalent Retry in Rust

**What:** Exponential backoff with max retries on transient errors, matching Python stamina behavior.
**When:** Every HTTP POST to ClickHouse.

```rust
fn insert_with_retry(&self, url: &str, body: &[u8]) -> Result<(), WriteError> {
    let mut delay = Duration::from_millis(self.config.retry_base_delay_ms);
    for attempt in 0..self.config.max_retries {
        match self.client.post(url).body(body.to_vec()).send() {
            Ok(resp) if resp.status().is_success() => return Ok(()),
            Ok(resp) if resp.status() == 429 || resp.status().is_server_error() => {
                std::thread::sleep(delay);
                delay = (delay * 2).min(Duration::from_secs(16));
            }
            Ok(resp) => return Err(WriteError::Http(resp.status())),
            Err(e) if e.is_timeout() || e.is_connect() => {
                std::thread::sleep(delay);
                delay = (delay * 2).min(Duration::from_secs(16));
            }
            Err(e) => return Err(WriteError::Network(e)),
        }
    }
    Err(WriteError::RetriesExhausted)
}
```

## Anti-Patterns to Avoid

### Anti-Pattern 1: Async HTTP in on_bar()

**What:** Using async reqwest in the synchronous `on_bar()` method.
**Why bad:** BarSink is synchronous by design. Calling `block_on()` inside a Tokio runtime panics. Using `.await` requires making the trait async, breaking all existing sinks.
**Instead:** Buffer in `on_bar()`, flush via dedicated std::thread with reqwest::blocking.

### Anti-Pattern 2: Arrow as Internal Transport

**What:** Converting CompletedBar -> Arrow RecordBatch -> serialize for CH.
**Why bad:** Arrow adds allocation overhead for in-process Rust-to-Rust data flow. Arrow exists for cross-language (Rust->Python) zero-copy FFI.
**Instead:** Serialize CompletedBar fields directly to JSONEachRow strings. Avoid intermediate formats.

### Anti-Pattern 3: Bypassing Python for Configuration

**What:** Having Rust read OPENDEVIATIONBAR_CH_HOSTS directly from env vars.
**Why bad:** Python Settings (pydantic-settings) has complex resolution: env vars, .mise.toml, SSH tunnel detection, mode switching. Duplicating this in Rust creates config divergence.
**Instead:** Python resolves the final CH endpoint, passes it to Rust via config dict.

### Anti-Pattern 4: Removing Python ChannelSink

**What:** Removing the Python poll path entirely when Rust writer is active.
**Why bad:** Python still needs bars for: SSE broadcasting, checkpoint saves, min-TID guard, Argus liveness, Lethe monitoring, junction telemetry, valid_pairs filtering. These are non-trivial orchestration concerns.
**Instead:** Fan-out to both sinks. Python receives bars but skips the CH write.

### Anti-Pattern 5: Lightweight DELETE FROM in Rust

**What:** Using `DELETE FROM` SQL syntax in the Rust writer.
**Why bad:** Banned since v13.49.0. Creates `_row_exists=0` ghost rows causing false Stathera gaps.
**Instead:** The Rust writer NEVER deletes. Deletions are startup-only operations handled by Python `_aion_delete_after_resume()`.

### Anti-Pattern 6: Skipping valid_pairs Filtering in Rust

**What:** Having the Rust writer accept all bars without valid_pairs filtering.
**Why bad:** The Python main loop currently filters bars by valid_pairs AND applies min_tid_guard. Without these, stale/duplicate bars reach CH.
**Instead:** Pass valid_pairs to the Rust sink at construction time. The sink filters in on_bar() before buffering.

## What Python Code Can Be Removed vs Must Be Kept

### Can Be Removed (When Rust Writer Enabled)

| Code                                               | File                     | Why Removable                          |
| -------------------------------------------------- | ------------------------ | -------------------------------------- |
| `_write_batch_with_retry()` calls in main loop     | `__init__.py:1253-1274`  | Rust writer handles CH writes          |
| `bar_batch_buffer` accumulation                    | `__init__.py:1244`       | Rust sink buffers internally           |
| `_write_bars_batch_to_clickhouse()` hot-path calls | `clickhouse_write.py`    | File becomes fallback-only             |
| `_sync_flush_rest_bars_arrow()` CH write section   | `arrow_flush.py:127-180` | Rust writer handles REST bar flush too |

### Must Be Kept

| Code                                | File                    | Why Required                              |
| ----------------------------------- | ----------------------- | ----------------------------------------- |
| `engine.next_bar()` poll loop       | `__init__.py:935`       | Drives watchdog, Argus, Lethe, SSE        |
| `burst_bars` drain                  | `__init__.py:1188-1193` | Processes multiple bars per iteration     |
| `valid_pairs` filtering             | `__init__.py:1222-1223` | Python knows which pairs are valid        |
| `min_tid_guard`                     | `__init__.py:1225-1238` | Prevents duplicate bars on restart        |
| `_save_checkpoint()`                | `__init__.py:1250-1251` | Checkpoint persistence                    |
| `_broadcast_bar_event()` SSE        | `__init__.py:1240-1241` | SSE consumers need bar events             |
| Argus, Lethe, watchdog logic        | `__init__.py:935-1093`  | Health monitoring stays in Python         |
| `_create_engine()` startup          | `__init__.py:174-519`   | CH resume TIDs, DELETE, fill_from_rest    |
| Dead-letter replay (Charon)         | `__init__.py:1169-1184` | Replays both Python and Rust dead-letters |
| `clickhouse_write.py` (entire file) | `clickhouse_write.py`   | Fallback when Rust writer disabled        |

### Structural Implication

The Python main loop becomes: "receive bar from ChannelSink, do orchestration (health/SSE/checkpoint), skip CH write." The loop structure is preserved; only the write call is gated by feature flag.

## Build Order

Based on dependency analysis, build in this order:

### Phase 1: ClickHouseWriterSink (Rust, isolated)

New files:

- `crates/opendeviationbar-streaming/src/engine/sink_clickhouse.rs`
- `crates/opendeviationbar-streaming/src/engine/ch_serializer.rs`

Work:

1. `ClickHouseWriterConfig` struct
2. `WriterMetrics` struct (AtomicU64 counters)
3. Flush thread: std::thread with reqwest::blocking::Client
4. JSONEachRow serializer for CompletedBar + metadata columns
5. Dedup token generation (MD5, must match Python exactly)
6. Retry logic with exponential backoff
7. Niffler dead-letter Parquet serialization
8. `ClickHouseWriterSink` implementing `BarSink`
9. Unit tests with mock HTTP server (wiremock or similar)

New Cargo deps: `reqwest` (blocking), `md5`, `parquet` (for dead-letter)
No Python changes needed. Testable in isolation.

### Phase 2: Fan-Out in LiveBarEngine

Modified files:

- `crates/opendeviationbar-streaming/src/live_engine/mod.rs` (or engine.rs)
- `crates/opendeviationbar-streaming/src/stream_manager.rs`

Work:

1. Add optional `Vec<Box<dyn BarSink>>` to LiveBarEngine
2. Fan-out in `symbol_task()`: ring_buffer.push() + for sink in sinks { sink.on_bar() }
3. Graceful shutdown: flush all sinks in stop()
4. Wire ClickHouseWriterSink into StreamManager construction (behind Cargo feature)
5. Expose WriterMetrics in get_combined_metrics()

Depends on: Phase 1 complete.

### Phase 3: PyO3 Bridge

Modified files:

- `src/stream_manager_bindings.rs`

Work:

1. `configure_ch_writer(config_dict: &Bound<PyDict>)` method on PyStreamManager
2. Writer metrics keys in `get_metrics()` response
3. `writer_flush()` method for Python-triggered flush (graceful shutdown)
4. `writer_health() -> bool` (flush thread alive check)

Depends on: Phase 2 complete.

### Phase 4: Python Integration

Modified files:

- `python/opendeviationbar/sidecar/__init__.py`
- `python/opendeviationbar/sidecar/config_helpers.py`

Work:

1. Feature flag: `OPENDEVIATIONBAR_USE_RUST_CH_WRITER` env var
2. Config dict construction from Python Settings in `_create_engine()`
3. Gate `_write_batch_with_retry()` calls behind flag in main loop
4. Heartbeat integration: monitor `ch_bars_written`, alert on `ch_dead_letters > 0`
5. Integration test: sidecar with Rust writer against real CH

Depends on: Phase 3 complete.

### Phase 5: REST Bar Flush via Rust Writer

Modified files:

- `python/opendeviationbar/sidecar/arrow_flush.py`
- `python/opendeviationbar/sidecar/midnight.py`

Work:

1. Wire `_sync_flush_rest_bars()` startup path to drain through Rust writer
2. REST bars from fill_from_rest -> drain_bars() -> Rust sink write
3. Eliminates the last Python CH write on the hot path

Depends on: Phase 4 validated in staging.

## Scalability Considerations

| Concern           | Current (16 symbols) | At 32 symbols          | At 64 symbols           |
| ----------------- | -------------------- | ---------------------- | ----------------------- |
| Bars/sec          | ~50                  | ~100                   | ~200                    |
| Batch frequency   | Every 50 bars (~1/s) | Every 50 bars (~0.5/s) | Every 50 bars (~0.25/s) |
| HTTP POST size    | ~5-25KB              | ~5-25KB                | ~5-25KB                 |
| Flush thread load | <1% CPU              | <2% CPU                | <5% CPU                 |
| Memory (buffer)   | ~200KB               | ~400KB                 | ~800KB                  |
| Dead-letter risk  | Negligible           | Low                    | Low                     |

The bottleneck is ClickHouse INSERT throughput (~10K rows/s for this schema), which is 50-200x above our sustained write rate. The Rust writer's primary benefit is latency reduction (5-50ms Python httpx -> <1ms Rust reqwest) and eliminating GIL contention during batch serialization.

## Sources

- BarSink trait: `crates/opendeviationbar-streaming/src/engine/traits.rs`
- ChannelSink reference: `crates/opendeviationbar-streaming/src/engine/sink_channel.rs`
- CompletedBar struct: `crates/opendeviationbar-streaming/src/live_engine/types.rs`
- StreamManager: `crates/opendeviationbar-streaming/src/stream_manager.rs`
- Python sidecar main loop: `python/opendeviationbar/sidecar/__init__.py`
- Python CH write path: `python/opendeviationbar/sidecar/clickhouse_write.py`
- Arrow flush path: `python/opendeviationbar/sidecar/arrow_flush.py`
- CH schema: `python/opendeviationbar/clickhouse/schema.sql`
- Dedup token: `python/opendeviationbar/clickhouse/bulk_operations.py` (store_bars_batch, \_build_insert_settings)
