---
phase: 01-mutation-safety
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - python/opendeviationbar/clickhouse/maintenance.py
  - python/opendeviationbar/sidecar/__init__.py
  - tests/test_mutation_safety.py
autonomous: true
requirements:
  - MUT-01
  - MUT-02

must_haves:
  truths:
    - "Per-partition lock allows concurrent kintsugi workers on different (symbol, threshold) pairs"
    - "Same-partition mutations remain serialized to prevent ClickHouse deadlocks"
    - "Sidecar pre-fill uses DELETE IN PARTITION per pair instead of compound ALTER TABLE DELETE"
    - "Dead code path (if False branch) in _do_replace is removed"
  artifacts:
    - path: "python/opendeviationbar/clickhouse/maintenance.py"
      provides: "Per-partition lock dict + _get_partition_lock() function"
      contains: "_get_partition_lock"
    - path: "python/opendeviationbar/sidecar/__init__.py"
      provides: "Partition-scoped DELETE loop for pre-fill clean-slate"
      contains: "DELETE IN PARTITION"
    - path: "tests/test_mutation_safety.py"
      provides: "Lock concurrency and SQL syntax assertions"
      contains: "test_per_partition_lock"
  key_links:
    - from: "python/opendeviationbar/clickhouse/maintenance.py"
      to: "_do_replace() callers"
      via: "_get_partition_lock(symbol, threshold_decimal_bps)"
      pattern: "_get_partition_lock"
    - from: "python/opendeviationbar/sidecar/__init__.py"
      to: "ClickHouse open_deviation_bars table"
      via: "DELETE IN PARTITION per (symbol, threshold) pair"
      pattern: "DELETE IN PARTITION"
---

<objective>
Replace global `_replace_lock` with per-partition lock dict and migrate sidecar compound DELETE to partition-scoped loop.

Purpose: Enable concurrent kintsugi workers to mutate different partitions simultaneously (MUT-02) and ensure sidecar pre-fill DELETE scans O(1 partition) not O(all parts) (MUT-01).
Output: Updated maintenance.py with per-partition locking, updated sidecar with partition-scoped DELETEs, test file validating both behaviors.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/01-mutation-safety/01-CONTEXT.md
@.planning/phases/01-mutation-safety/01-RESEARCH.md

@python/opendeviationbar/clickhouse/maintenance.py
@python/opendeviationbar/sidecar/**init**.py
</context>

<tasks>

<task type="tdd">
  <name>Task 1: Per-partition lock and maintenance.py cleanup</name>
  <files>python/opendeviationbar/clickhouse/maintenance.py, tests/test_mutation_safety.py</files>
  <read_first>
    - python/opendeviationbar/clickhouse/maintenance.py (full file -- understand _replace_lock usage at line 29, _do_replace at line 295-360)
    - python/opendeviationbar/clickhouse/write_lock.py (understand cache_write_lock pattern that wraps _do_replace)
    - .planning/phases/01-mutation-safety/01-RESEARCH.md (per-partition lock pattern at "Pattern: Per-Partition Lock" section)
  </read_first>
  <behavior>
    - Test 1: test_per_partition_lock_concurrency -- Two threads acquiring locks for different partitions (BTCUSDT,250) and (ETHUSDT,500) can hold locks simultaneously (both acquire within 0.1s)
    - Test 2: test_same_partition_serialized -- Two threads acquiring lock for same partition (BTCUSDT,250) are serialized (second thread blocks until first releases)
    - Test 3: test_get_partition_lock_returns_same_instance -- Calling _get_partition_lock twice with same args returns the same Lock object
    - Test 4: test_dead_code_removed -- The `if False:` block (old fallback DELETE) no longer exists in maintenance.py source
  </behavior>
  <action>
    **Per D-02:** Replace global `_replace_lock` with per-partition lock dict.

    In `maintenance.py`:

    1. Remove line 29: `_replace_lock = threading.Lock()`

    2. Add at module level (after imports, around line 29):
    ```python
    _replace_locks: dict[tuple[str, int], threading.Lock] = {}
    _replace_locks_guard = threading.Lock()

    def _get_partition_lock(symbol: str, threshold_decimal_bps: int) -> threading.Lock:
        key = (symbol, threshold_decimal_bps)
        lock = _replace_locks.get(key)
        if lock is not None:
            return lock
        with _replace_locks_guard:
            return _replace_locks.setdefault(key, threading.Lock())
    ```

    3. In `_do_replace()` (around line 321), change:
    ```python
    # FROM:
    with _replace_lock:
    # TO:
    with _get_partition_lock(symbol, threshold_decimal_bps):
    ```

    4. Remove the dead code block (lines 333-346, the `if False:` branch with the old unscoped DELETE).

    5. Update the docstring/comments on `_do_replace()` and the module-level comment (lines 24-28) to reflect per-partition locking instead of global lock.

    **Test file** `tests/test_mutation_safety.py`:
    - Import `_get_partition_lock`, `_replace_locks` from `opendeviationbar.clickhouse.maintenance`
    - Use `threading.Thread` + `threading.Event` for concurrency tests
    - For dead code test: read maintenance.py source and assert `if False:` not in content

  </action>
  <verify>
    <automated>cd /Users/terryli/eon/opendeviationbar-py && uv run --python 3.13 pytest tests/test_mutation_safety.py -x -v 2>&1 | tail -20</automated>
  </verify>
  <acceptance_criteria>
    - maintenance.py contains `def _get_partition_lock(symbol: str, threshold_decimal_bps: int)`
    - maintenance.py contains `_replace_locks: dict[tuple[str, int], threading.Lock] = {}`
    - maintenance.py contains `_replace_locks_guard = threading.Lock()`
    - maintenance.py does NOT contain `_replace_lock = threading.Lock()` (the old global lock)
    - maintenance.py does NOT contain `if False:` (dead code removed)
    - _do_replace uses `with _get_partition_lock(symbol, threshold_decimal_bps):`
    - tests/test_mutation_safety.py exists and passes
  </acceptance_criteria>
  <done>Per-partition lock replaces global lock. Dead code removed. Tests pass proving concurrent different-partition access and same-partition serialization.</done>
</task>

<task type="auto">
  <name>Task 2: Sidecar compound DELETE to partition-scoped loop</name>
  <files>python/opendeviationbar/sidecar/__init__.py</files>
  <read_first>
    - python/opendeviationbar/sidecar/__init__.py (lines 326-359 -- the compound DELETE block in _create_engine)
    - .planning/phases/01-mutation-safety/01-RESEARCH.md (Sidecar Compound DELETE Strategy section -- Option A recommended)
    - python/opendeviationbar/clickhouse/maintenance.py (lines 322-332 -- canonical DELETE IN PARTITION pattern)
  </read_first>
  <action>
    **Per D-01:** Migrate sidecar pre-fill compound DELETE to per-pair DELETE IN PARTITION loop.

    In `sidecar/__init__.py`, replace the compound DELETE block (lines 327-350) with:

    ```python
    # Per-partition DELETE loop for clean-slate.
    # Each DELETE IN PARTITION scans only its partition (O(1))
    # vs compound ALTER TABLE DELETE which scans all parts.
    from opendeviationbar.symbol_registry import (
        get_registered_symbols,
        get_thresholds_for_symbol,
    )

    symbols = get_registered_symbols(enabled_only=True)
    pairs = []
    for sym in symbols:
        for thr in get_thresholds_for_symbol(sym):
            pairs.append((sym, thr))

    t_delete_start = time.monotonic()
    for sym, thr in pairs:
        cache.client.command(
            "ALTER TABLE opendeviationbar_cache.open_deviation_bars "
            f"DELETE IN PARTITION ('{sym}', {thr}) "
            f"WHERE ouroboros_mode = '{mode}' "
            f"AND toDate(open_time_ms / 1000000) = '{today_str}' "
            "SETTINGS mutations_sync = 1"
        )
    t_delete_elapsed = time.monotonic() - t_delete_start
    ```

    Update the log message (around line 353) to say "partition-scoped DELETE loop" instead of "single compound DELETE".

    **Note on `open_time_ms`:** The column may actually be `open_time_us` per the research pitfall #2. However, the existing code uses `open_time_ms` and divides by 1000000 -- this suggests the column name IS `open_time_ms` but stores microsecond values (legacy naming). Keep the existing column reference to avoid regression. If it breaks during testing, the column name needs verification against schema.sql.

  </action>
  <verify>
    <automated>cd /Users/terryli/eon/opendeviationbar-py && grep -n "DELETE IN PARTITION" python/opendeviationbar/sidecar/__init__.py && echo "---" && grep -c "ALTER TABLE.*DELETE WHERE" python/opendeviationbar/sidecar/__init__.py | grep -q "^0$" && echo "NO_UNSCOPED_DELETES" || echo "STILL_HAS_UNSCOPED_DELETES"</automated>
  </verify>
  <acceptance_criteria>
    - sidecar/__init__.py contains `DELETE IN PARTITION ('{sym}', {thr})`
    - sidecar/__init__.py contains `SETTINGS mutations_sync = 1` in the DELETE command
    - sidecar/__init__.py does NOT contain `DELETE WHERE (symbol, threshold_decimal_bps) IN` (the old compound pattern)
    - sidecar/__init__.py contains `for sym, thr in pairs:` loop
    - Log message updated to reference partition-scoped approach
  </acceptance_criteria>
  <done>Sidecar pre-fill uses DELETE IN PARTITION per (symbol, threshold) pair. Each DELETE scans O(1 partition) instead of O(all parts).</done>
</task>

</tasks>

<verification>
- `uv run --python 3.13 pytest tests/test_mutation_safety.py -x -v` passes
- `grep -r "DELETE IN PARTITION" python/opendeviationbar/clickhouse/maintenance.py python/opendeviationbar/sidecar/__init__.py` shows both files use partition-scoped DELETEs
- No `_replace_lock = threading.Lock()` anywhere in maintenance.py
- No `if False:` dead code in maintenance.py
</verification>

<success_criteria>

- Per-partition lock enables concurrent kintsugi workers on different partitions (MUT-02)
- Sidecar pre-fill DELETE scans O(1 partition) per pair (MUT-01)
- All tests pass
  </success_criteria>

<output>
After completion, create `.planning/phases/01-mutation-safety/01-01-SUMMARY.md`
</output>
