headroom/RUST_DEV.md

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

361 lines
18 KiB
Markdown
Raw Permalink Normal View History

# Headroom Rust Rewrite — Developer Guide
This document covers the Rust port of Headroom. It is the only new top-level
doc created in Phase 0; longer-form design/plan writeups live elsewhere and
are not versioned in this repo.
## Workspace layout
```
Cargo.toml # workspace root
rust-toolchain.toml # pins stable rustc with rustfmt+clippy
crates/
headroom-core/ # library: shared types + transform trait surface
headroom-proxy/ # binary: axum /healthz (Phase 2 grows this)
headroom-py/ # PyO3 cdylib exposing `headroom._core`
headroom-parity/ # lib + `parity-run` CLI for Python parity tests
tests/parity/
fixtures/<transform>/*.json # recorded Python outputs (Phase 1 ports match)
recorder.py # Python-side fixture recorder
scripts/record_fixtures.py # entry point for running the recorder
```
`cargo build --workspace` builds every crate. `default-members` drops
`headroom-py` from `cargo run`/bare-`cargo test` flows so that `cargo test
--workspace` does not try to execute the PyO3 cdylib standalone (it can't
find `libpython` without a Python interpreter hosting it).
## Common commands
`just` is not installed on dev boxes here; a `Makefile` at the repo root
exposes the same targets:
| Target | What it does |
| --- | --- |
| `make test` | `cargo test --workspace` |
| `make test-parity` | Builds `headroom-py` via maturin, runs `parity-run run` |
| `make bench` | `cargo bench --workspace` |
| `make build-proxy` | Release-builds `headroom-proxy`, strips, prints size |
| `make build-wheel` | `maturin build --release -m crates/headroom-py/pyproject.toml` |
| `make fmt` | `cargo fmt --all` |
| `make lint` | `cargo fmt --check` + `cargo clippy --workspace -- -D warnings` |
## Running the proxy
`headroom-proxy` is a transparent reverse proxy. Phase 1 forwards HTTP/1.1,
HTTP/2, SSE, and WebSocket traffic verbatim to a configured upstream — no
provider logic yet. The intent is that operators run the existing Python
proxy on a private port and put `headroom-proxy` on the public port pointed
at it; end users notice nothing.
```bash
# Build
make build-proxy
./target/release/headroom-proxy --help
# Run against a local upstream
./target/release/headroom-proxy \
--listen 0.0.0.0:8787 \
--upstream http://127.0.0.1:8788
# Health checks
curl -s http://127.0.0.1:8787/healthz # => {"ok":true,...}
curl -s http://127.0.0.1:8787/healthz/upstream # => 200 if upstream reachable
```
### Operator runbook (Phase 1 cutover)
```bash
# 1. Move the Python proxy to a private port (e.g. 8788)
HEADROOM_HOST=127.0.0.1 HEADROOM_PORT=8788 python -m headroom.proxy & # or your existing launcher
# 2. Run the Rust proxy on the previously-public port (8787) pointing at it
./target/release/headroom-proxy --listen 0.0.0.0:8787 --upstream http://127.0.0.1:8788 &
# 3. End users keep hitting :8787 unchanged.
# 4. Confirm passthrough:
curl -si http://127.0.0.1:8787/v1/models
# 5. Rollback = stop the Rust proxy and rebind Python back to 8787.
```
### Configuration flags
| Flag | Env var | Default | Notes |
| --- | --- | --- | --- |
| `--listen` | `HEADROOM_PROXY_LISTEN` | `0.0.0.0:8787` | bind address |
| `--upstream` | `HEADROOM_PROXY_UPSTREAM` | (required) | base URL the proxy forwards to |
| `--upstream-timeout` | | `600s` | end-to-end request timeout (long for streams) |
| `--upstream-connect-timeout` | | `10s` | TCP/TLS connect timeout |
| `--max-body-bytes` | | `100MB` | for buffered cases; streams bypass |
| `--log-level` | | `info` | `RUST_LOG`-style filter |
| `--rewrite-host` / `--no-rewrite-host` | | rewrite | rewrite Host to upstream (default) |
| `--graceful-shutdown-timeout` | | `30s` | wait for in-flight on SIGTERM/SIGINT |
fix(rust): audit cleanup — DiffCompressor CCR leak, CCR TOCTOU race, clippy debt, dep dedup Closes findings from the post-Phase-3g audit. Five surgical fixes plus telemetry-discoverability docs. PyO3 0.22 → 0.24 security upgrade is its own PR (issue #335). 1. DiffCompressor cache_key persistence (production bug) --------------------------------------------------------- Pre-fix: `RustDiffCompressor.compress()` minted a `cache_key`, embedded `[... hash=abc123]` in the wire marker, and returned without storing the original anywhere. Python ContentRouter then returned the compressed text with a dangling marker — every retrieval tool call from the LLM 404'd. Sibling compressors (LogCompressor, SearchCompressor) already had the right pattern: Rust mints the key, Python's `_persist_to_python_ccr` writes the original to the production `CompressionStore`. DiffCompressor was the asymmetric one. Fix: - Rust: add `DiffCompressor::compress_with_store(content, context, Option<&dyn CcrStore>)` mirroring siblings. Calls `store.put` when a key is minted; legacy `compress()` and `compress_with_stats()` delegate with `None` for parity. - Python: add `_persist_to_python_ccr` helper to `headroom/transforms/diff_compressor.py.compress()` mirroring `log_compressor.py` and `search_compressor.py`. - Pipeline `DiffOffload`: switch to `compress_with_store(Some(store))` and drop the post-hoc double-store hack that papered over this bug at the orchestrator boundary. 2. CCR store TOCTOU race in `get()` ----------------------------------- `InMemoryCcrStore::get()` checked TTL under a read lock, dropped the lock, then called `remove()`. Between drop and remove a concurrent `put()` of the same hash with fresh data could land — and our `remove` would then wipe that fresh entry. Under multi-worker proxy load this manifested as "I just stored it; why is it gone?" Fix: use `DashMap::remove_if`. Predicate runs under the shard write lock so check-and-remove is atomic. New regression test exercises a tight contention loop between writer and reader on the same key. 3. Pre-existing clippy debt in smart_crusher -------------------------------------------- - 3× `field_reassign_with_default` in `crusher.rs` test setup — switch to struct-update syntax `Config { field: x, ..Default }`. - `hash_array_for_ccr` was `#[cfg(test)]` but unused; deleted with a comment so a future test can reintroduce it as a one-liner. `cargo clippy --workspace --all-targets -- -D warnings` is now clean across the whole workspace; previous CI patches that allowed these warnings can be removed in a follow-up. 4. Tokenizers dependency dedup ------------------------------ `tokenizers 0.21` (direct dep) + `tokenizers 0.22` (transitive via fastembed) compiled twice into the binary. Bumped direct dep to `0.22` to align; API is compatible (verified by full tokenizer test suite). Saves compile time + binary bloat. 5. Telemetry-discoverability doc (no new code) ---------------------------------------------- The audit recommended a per-transform invocation counter to inform the next Python → Rust port. Discovered the infrastructure already exists at `/stats`: - `compressions_by_strategy` — invocation count per strategy - `pipeline_timing` — count + avg/max ms per transform name - `tokens_saved_by_strategy` — savings attribution Added a section to `RUST_DEV.md` showing the `curl + jq` recipes to read this data, with example output highlighting how to spot zero-invocation deferral candidates (e.g. `code_compressor`). Verification: workspace tests 734 + 14 + 5 + 4 + 6 + 5 + 2 + 2 + 3 + 4 + 2 + 2 + 1 = all green; cargo fmt clean; cargo clippy --all-targets clean; Python tests 185 pass; commitlint clean.
2026-04-30 20:54:22 -07:00
### Picking the next port: invocation telemetry
Before porting another Python compressor to Rust, check what's actually
running. The Python proxy already exposes per-transform telemetry on
`/stats` (`headroom.proxy.prometheus_metrics`):
```bash
# Top compressors by invocation count (last process lifetime)
curl -s http://127.0.0.1:8788/stats | jq '.compressions_by_strategy'
# {
# "intelligent_context": 12453,
# "smart_crusher": 487,
# "search": 312,
# "diff": 28,
# "code": 0, # ← never fires; safe to defer porting
# ...
# }
# Per-transform timing (avg/max/count by transform name)
curl -s http://127.0.0.1:8788/stats | jq '.pipeline_timing'
# Token savings attributable to each strategy
curl -s http://127.0.0.1:8788/stats | jq '.tokens_saved_by_strategy'
```
This is the data the audit-cleanup PR (2026-04-30) recommended for
prioritizing the next Python → Rust port. Strategies with zero or
near-zero invocations are deferral candidates; strategies on the hot
path are porting candidates regardless of LOC count.
### Reserved paths
`/healthz` and `/healthz/upstream` are intercepted by the Rust proxy and
**not** forwarded. Operators must not name a real upstream route either of
these. Everything else is a catch-all forward.
## Maturin + Python wiring
`headroom-py` is a PyO3 cdylib that exposes `headroom._core` in Python. The
`extension-module` feature is opt-in so plain `cargo build --workspace` does
not try to link against `libpython` on systems that don't have it.
### First-time setup (clean venv recommended)
```bash
python3.11 -m venv /tmp/hr-rust-venv
source /tmp/hr-rust-venv/bin/activate
pip install maturin
cd crates/headroom-py
maturin develop # editable dev build, installs headroom._core
cd /tmp # IMPORTANT: step out of the repo root first
python -c "from headroom._core import hello; print(hello())"
# => headroom-core
```
> Why `cd /tmp`? The repo root also contains the Python `headroom/` package.
> Running the smoke import from the repo root makes Python resolve `headroom`
> to `./headroom/__init__.py` (the full SDK, which pulls in heavy deps) instead
> of the lightweight namespace package installed by maturin. Tests should
> either run outside the repo root, or ensure `headroom` is installed into
> the same venv (then the maturin-installed `_core.so` lands alongside it and
> both imports resolve).
### Release wheels
```bash
make build-wheel
# wheels land under target/wheels/
```
CI (`.github/workflows/rust.yml`) builds linux-x86_64, macos-arm64, and
macos-x86_64 wheels via `PyO3/maturin-action` and uploads them as artifacts.
## Parity harness
`crates/headroom-parity` owns the Rust-vs-Python oracle:
- JSON fixtures under `tests/parity/fixtures/<transform>/` (schema:
`{ transform, input, config, output, recorded_at, input_sha256 }`).
- `TransformComparator` trait — one impl per transform. Phase 0 stubs return
`Err(...)`; the harness flags those as `Skipped`, not panics.
- `parity-run` CLI: `cargo run -p headroom-parity -- run [--only TRANSFORM]`.
- Unit tests in `crates/headroom-parity/src/lib.rs` include a **negative
test** (`harness_reports_diff_for_divergent_comparator`) proving the
harness detects mismatched output before any real port lands.
### Recording fresh fixtures
```bash
source .venv/bin/activate # the main Python SDK venv
python scripts/record_fixtures.py # uses tests/parity/recorder.py
ls tests/parity/fixtures/*/ | sort | uniq -c
```
The recorder monkey-patches the in-process transform classes (see
`record_all()` in `tests/parity/recorder.py`). It does **not** modify any
file under `headroom/`.
fix(smart_crusher): re-attach TOIN learning loop + audit known regressions The Stage 3c.1b retirement of the Python SmartCrusher silently disconnected three subsystems. The audit on 2026-04-28 caught them; this commit fixes what's fixable today and labels the rest visibly. ## TOIN learning loop — fixed Before: `ContentRouter._record_to_toin` skipped SmartCrusher on the assumption SmartCrusher recorded its own TOIN events. The retired Python class did. The Rust port doesn't know about TOIN. Net result: the highest-traffic compression strategy stopped fueling the learning loop, silently. Fix: shim's `crush()` and `_smart_crush_content()` now call `toin.record_compression()` after a real compression. Filtered on `strategy != "passthrough"` because the Rust port flips `was_modified=True` from JSON whitespace re-canonicalization. Best effort: TOIN failures are logged at debug level and never break compression. Token estimates use `len(json) // 4` (the rule the retired Python used) because the router doesn't pass a tokenizer down to this layer and re-tokenizing here would dominate the recording cost. 7 tests in `tests/test_smart_crusher_toin_attachment.py`: - crush() records on real compression, doesn't on passthrough - structurally-similar inputs land on the same pattern - _smart_crush_content() records (legacy apply() path) - TOIN errors don't break compression - non-JSON input doesn't record - inject_retrieval_marker=False emits a WARNING ## CCR marker emission knob — labelled, not yet fixed `ccr_config.inject_retrieval_marker=False` is not honored — the Rust port emits `<<ccr:HASH N_rows_offloaded>>` markers in `dropped_summary` unconditionally. Today the production default has the flag True so no one is hitting the gap, but the silent-disconnect was a real regression. Shim now logs a WARNING when callers pass `False` so the mismatch is visible. Fix needs a Rust-side gate; tracked in `RUST_DEV.md`. ## Custom relevance scorer — labelled, not yet fixed `relevance_config` and `scorer` constructor args are accepted for source compatibility but the Rust default `HybridScorer` always runs. Shim was logging this at debug; bumped to WARNING. Tracked. ## RUST_DEV.md New "Known regressions in retired-Python components" section with a table per retired component. The intent is that this section gets updated whenever a regression closes (or a new one is found), so the duplicate-codebase tax stays visible instead of decaying into folklore.
2026-04-28 11:28:25 -07:00
## Known regressions in retired-Python components
The Stage 3b/3c.1b retirements deleted Python source for `DiffCompressor`
and `SmartCrusher` and replaced them with PyO3-delegating shims. The
2026-04-28 audit found that the retirements shipped with subsystems
silently disconnected. This section tracks each gap and its disposition
so they don't regress further or get forgotten.
### SmartCrusher
| Subsystem | State | Tracked by |
|---|---|---|
| TOIN learning loop | **Re-attached 2026-04-28.** Shim's `crush()` and `_smart_crush_content()` now call `toin.record_compression()` after a real compression. Filtered on `strategy != "passthrough"` to ignore JSON re-canonicalization. Best-effort: TOIN failures are logged at debug level and don't break compression. | `tests/test_smart_crusher_toin_attachment.py` |
fix(smart_crusher): re-land orphaned audit close-out — CCR knob + scorer fail-loud Re-lands two audit fixes that were marked "merged" on GitHub but never reached main: squash-merging the parent stack changed its commit SHA, which silently dropped the contents of the stacked PRs (#301, #305). Single PR this time — no stacking risk. What lands: 1. **`enable_ccr_marker` Rust gate** — new field on `SmartCrusherConfig` (default `true`). `crush_array` checks it before emitting the `<<ccr:HASH>>` marker text and the CCR store write. PyO3 surface + parity-fixture tolerance updated; recorded fixtures predate the field and inherit the `true` default. 2. **Python shim collapses both flags to the gate** — both `ccr_config.enabled=False` and `ccr_config.inject_retrieval_marker =False` now flip the Rust gate off. Storing a payload nothing in the prompt can reference is pointless, and storing under `enabled=False` would be a surprise side effect the user explicitly opted out of. 3. **Custom `scorer` / `relevance_config` fails loud** — replaces the prior WARNING-and-drop. Silently dropping a user-supplied scorer is a textbook silent fallback. `NotImplementedError` instead. Verified zero production callers pass these args; full plumbing arrives with Stage-3c.2's relevance-crate Python bridge. Tests: - 2 new Rust unit tests in `crusher.rs::tests` - 6 new Python tests in `test_smart_crusher_toin_attachment.py` (3 CCR marker-knob behaviors + 3 scorer fail-loud) - Removed `test_ccr_inject_marker_false_logs_warning` (the WARNING is gone now that the flag is honored) - `make ci-precheck` green; eval suite + observability tests run twice consecutively to verify no TOIN file pollution leaks into the regular+coverage double-run on Python 3.11 RUST_DEV.md audit table reflects both gaps closed.
2026-04-28 18:37:52 -07:00
| CCR marker emission knob | **Honored end-to-end 2026-04-29.** New `enable_ccr_marker: bool` field on Rust `SmartCrusherConfig`; `crush_array` checks it before emitting the `<<ccr:HASH>>` marker text and the CCR store write. Python shim flips it from `ccr_config.enabled and ccr_config.inject_retrieval_marker` — both flags collapse to the same Rust gate, since storing payloads under either off-switch makes no sense. Scope: gates only the row-drop sentinel path; Stage-3c.2 opaque-string CCR substitutions still emit always (no Python equivalent, no production caller asks for suppression). | `tests/test_smart_crusher_toin_attachment.py` + `crates/headroom-core/.../crusher.rs::tests::enable_ccr_marker_*` |
| Custom relevance scorer | **Closed (fail-loud) 2026-04-29.** `relevance_config` and `scorer` constructor args remain in the signature for source compat, but the shim raises `NotImplementedError` when either is non-None — silently dropping a user-supplied scorer is a textbook silent-fallback bug. Full plumbing waits on Stage-3c.2's relevance-crate Python bridge. | `tests/test_smart_crusher_toin_attachment.py::test_custom_*_arg_raises_not_implemented` |
fix(smart_crusher): re-attach TOIN learning loop + audit known regressions The Stage 3c.1b retirement of the Python SmartCrusher silently disconnected three subsystems. The audit on 2026-04-28 caught them; this commit fixes what's fixable today and labels the rest visibly. ## TOIN learning loop — fixed Before: `ContentRouter._record_to_toin` skipped SmartCrusher on the assumption SmartCrusher recorded its own TOIN events. The retired Python class did. The Rust port doesn't know about TOIN. Net result: the highest-traffic compression strategy stopped fueling the learning loop, silently. Fix: shim's `crush()` and `_smart_crush_content()` now call `toin.record_compression()` after a real compression. Filtered on `strategy != "passthrough"` because the Rust port flips `was_modified=True` from JSON whitespace re-canonicalization. Best effort: TOIN failures are logged at debug level and never break compression. Token estimates use `len(json) // 4` (the rule the retired Python used) because the router doesn't pass a tokenizer down to this layer and re-tokenizing here would dominate the recording cost. 7 tests in `tests/test_smart_crusher_toin_attachment.py`: - crush() records on real compression, doesn't on passthrough - structurally-similar inputs land on the same pattern - _smart_crush_content() records (legacy apply() path) - TOIN errors don't break compression - non-JSON input doesn't record - inject_retrieval_marker=False emits a WARNING ## CCR marker emission knob — labelled, not yet fixed `ccr_config.inject_retrieval_marker=False` is not honored — the Rust port emits `<<ccr:HASH N_rows_offloaded>>` markers in `dropped_summary` unconditionally. Today the production default has the flag True so no one is hitting the gap, but the silent-disconnect was a real regression. Shim now logs a WARNING when callers pass `False` so the mismatch is visible. Fix needs a Rust-side gate; tracked in `RUST_DEV.md`. ## Custom relevance scorer — labelled, not yet fixed `relevance_config` and `scorer` constructor args are accepted for source compatibility but the Rust default `HybridScorer` always runs. Shim was logging this at debug; bumped to WARNING. Tracked. ## RUST_DEV.md New "Known regressions in retired-Python components" section with a table per retired component. The intent is that this section gets updated whenever a regression closes (or a new one is found), so the duplicate-codebase tax stays visible instead of decaying into folklore.
2026-04-28 11:28:25 -07:00
| Per-tool TOIN learning hook | **Re-attached partially.** `_smart_crush_content` accepts `tool_name` and now threads it into the TOIN record. The hook is best-effort — it improves `query_context` aggregation but doesn't drive per-tool overrides yet. | `tests/test_smart_crusher_toin_attachment.py::test_smart_crush_content_records_to_toin` |
### DiffCompressor
| Subsystem | State |
|---|---|
| Adaptive context windows | Honored byte-for-byte (parity fixture-locked). |
| TOIN integration | Never had one — DiffCompressor records via `_record_to_toin` in ContentRouter, which already runs for non-SmartCrusher strategies. No regression. |
feat(rust): signals trait module + KeywordDetector (Phase 3e.1) Establish `crates/headroom-core/src/signals/` as a top-level module holding cross-cutting detection traits. Phase 3e.1 ports `error_detection.py` to a `LineImportanceDetector` trait + a `Tiered<T>` combinator + a single concrete `KeywordDetector` impl backed by aho-corasick. Three traits at three granularities are sketched (line / blob / item); only line-importance is implemented today. Two bug fixes from the Python source bake into both the Rust impl and the Python regex shim: 1. `ERROR_KEYWORDS` listed `timeout|abort|denied|rejected` but `ERROR_PATTERN` regex omitted them. Lines like `"Connection timeout"` were silently neutral despite the keyword being canonical. Both surfaces now flag them. 2. `SECURITY_KEYWORDS` carried `token`, which false-positived on every reference to LLM tokens (`input_tokens`, `tokens_saved`, ...) in our own product. Dropped from the security set. The Python `error_detection.py` shim now reflects keyword data out of Rust via `keyword_registry_snapshot()` and recompiles the legacy `re.Pattern` objects on the fly. Existing callers (text_compressor, search_compressor, intelligent_context) continue to import the same names with no source changes; caller migration to the trait API happens in their own port PRs. The trait architecture is the seam where a future ML detector slots in without touching `KeywordDetector` or any caller. The canonical extension is documented in `signals/README.md` as a classifier head on the existing `bge-small-en-v1.5` embedder loaded by `relevance::EmbeddingScorer` -- 384-dim -> 4-class softmax, ~1.5 KB head, ~1 ms inference, no extra model file. Two alternatives (distilled tinyBERT in ONNX, logistic regression on lexical features) are kept open in case BGE-head underfits. Per the no-silent-fallbacks rule: only `KeywordDetector` lands as a concrete impl. No NoOp, no MockDetector, no stub-ML -- those will arrive with their real implementations. Phase 3g (Compression Pipeline Formalization, issue #315) is queued as the cross-cutting follow-up that will make lossless-then-lossy- then-CCR ordering an explicit, observable architecture rather than implicit per-compressor logic. Trait shapes there will reuse the signals primitive landed in this PR.
2026-04-29 15:55:13 -07:00
### Phase 3e.1 — `signals/` trait module + KeywordDetector (2026-04-29)
The Python `error_detection.py` regex registry was retired and reborn as a
trait + tier system in `crates/headroom-core/src/signals/`. See
`signals/README.md` for the full architecture; the highlights:
- **Per-granularity traits.** `LineImportanceDetector` ships today; future
`ContentTypeDetector` and `ItemImportanceDetector<I>` will follow as their
consumers get touched.
- **`Tiered<T>` combinator.** Composition, not inheritance. Future ML
detectors slot in as new tiers without changes to `KeywordDetector` or
any caller.
- **One concrete impl.** `KeywordDetector` (aho-corasick) is the only tier
registered today. **No NoOp/stub impls** — per project no-silent-fallbacks
rule, future tiers land with their real implementations.
- **Bug fixes baked in.** `ERROR_KEYWORDS` regex now includes
`timeout|abort|denied|rejected` (previously drifted from the keyword set);
`token` dropped from `SECURITY_KEYWORDS` (false-positived on every LLM
metric reference). Both fixed in the Python regex too via the shim that
recompiles patterns from the Rust-exposed keyword tables.
- **Companion canonical extension path.** `signals/README.md` documents
the BGE classifier head — a 384-dim → 4-class softmax on top of the
already-loaded `bge-small-en-v1.5` embedder — as the natural ML tier.
Two alternatives kept open: distilled tinyBERT in ONNX, logistic
regression on lexical features.
### Phase 3g (queued) — Compression Pipeline Formalization (issue #315)
Strategic decision 2026-04-29: after Phase 3e (compressor ports) and
Phase 3f (Rust MCP scaffold) wrap, formalize the lossless-then-lossy-
then-CCR ordering as a cross-cutting `CompressionPipeline` orchestrator
+ `LosslessTransform` / `LossyTransform` traits in
`crates/headroom-core/src/pipeline/`. Existing compressors get
refactored as compositions of pluggable transforms. The crucial design
choice — **parsers for structure, models at the prose/structure
boundary** — is captured in issue #315 and
`memory/project_lossless_first_pipeline.md`. Do NOT start coding before
3e/3f finish.
fix(smart_crusher): re-attach TOIN learning loop + audit known regressions The Stage 3c.1b retirement of the Python SmartCrusher silently disconnected three subsystems. The audit on 2026-04-28 caught them; this commit fixes what's fixable today and labels the rest visibly. ## TOIN learning loop — fixed Before: `ContentRouter._record_to_toin` skipped SmartCrusher on the assumption SmartCrusher recorded its own TOIN events. The retired Python class did. The Rust port doesn't know about TOIN. Net result: the highest-traffic compression strategy stopped fueling the learning loop, silently. Fix: shim's `crush()` and `_smart_crush_content()` now call `toin.record_compression()` after a real compression. Filtered on `strategy != "passthrough"` because the Rust port flips `was_modified=True` from JSON whitespace re-canonicalization. Best effort: TOIN failures are logged at debug level and never break compression. Token estimates use `len(json) // 4` (the rule the retired Python used) because the router doesn't pass a tokenizer down to this layer and re-tokenizing here would dominate the recording cost. 7 tests in `tests/test_smart_crusher_toin_attachment.py`: - crush() records on real compression, doesn't on passthrough - structurally-similar inputs land on the same pattern - _smart_crush_content() records (legacy apply() path) - TOIN errors don't break compression - non-JSON input doesn't record - inject_retrieval_marker=False emits a WARNING ## CCR marker emission knob — labelled, not yet fixed `ccr_config.inject_retrieval_marker=False` is not honored — the Rust port emits `<<ccr:HASH N_rows_offloaded>>` markers in `dropped_summary` unconditionally. Today the production default has the flag True so no one is hitting the gap, but the silent-disconnect was a real regression. Shim now logs a WARNING when callers pass `False` so the mismatch is visible. Fix needs a Rust-side gate; tracked in `RUST_DEV.md`. ## Custom relevance scorer — labelled, not yet fixed `relevance_config` and `scorer` constructor args are accepted for source compatibility but the Rust default `HybridScorer` always runs. Shim was logging this at debug; bumped to WARNING. Tracked. ## RUST_DEV.md New "Known regressions in retired-Python components" section with a table per retired component. The intent is that this section gets updated whenever a regression closes (or a new one is found), so the duplicate-codebase tax stays visible instead of decaying into folklore.
2026-04-28 11:28:25 -07:00
### Watch list (potential regressions, not yet audited)
fix(smart_crusher): re-land orphaned audit close-out — CCR knob + scorer fail-loud Re-lands two audit fixes that were marked "merged" on GitHub but never reached main: squash-merging the parent stack changed its commit SHA, which silently dropped the contents of the stacked PRs (#301, #305). Single PR this time — no stacking risk. What lands: 1. **`enable_ccr_marker` Rust gate** — new field on `SmartCrusherConfig` (default `true`). `crush_array` checks it before emitting the `<<ccr:HASH>>` marker text and the CCR store write. PyO3 surface + parity-fixture tolerance updated; recorded fixtures predate the field and inherit the `true` default. 2. **Python shim collapses both flags to the gate** — both `ccr_config.enabled=False` and `ccr_config.inject_retrieval_marker =False` now flip the Rust gate off. Storing a payload nothing in the prompt can reference is pointless, and storing under `enabled=False` would be a surprise side effect the user explicitly opted out of. 3. **Custom `scorer` / `relevance_config` fails loud** — replaces the prior WARNING-and-drop. Silently dropping a user-supplied scorer is a textbook silent fallback. `NotImplementedError` instead. Verified zero production callers pass these args; full plumbing arrives with Stage-3c.2's relevance-crate Python bridge. Tests: - 2 new Rust unit tests in `crusher.rs::tests` - 6 new Python tests in `test_smart_crusher_toin_attachment.py` (3 CCR marker-knob behaviors + 3 scorer fail-loud) - Removed `test_ccr_inject_marker_false_logs_warning` (the WARNING is gone now that the flag is honored) - `make ci-precheck` green; eval suite + observability tests run twice consecutively to verify no TOIN file pollution leaks into the regular+coverage double-run on Python 3.11 RUST_DEV.md audit table reflects both gaps closed.
2026-04-28 18:37:52 -07:00
- `CCRConfig.enabled=False` end-to-end — **closed 2026-04-29**. Both `enabled=False` and `inject_retrieval_marker=False` collapse to the same Rust `enable_ccr_marker=False` gate (no marker, no store write). See the SmartCrusher table above.
fix(smart_crusher): re-attach TOIN learning loop + audit known regressions The Stage 3c.1b retirement of the Python SmartCrusher silently disconnected three subsystems. The audit on 2026-04-28 caught them; this commit fixes what's fixable today and labels the rest visibly. ## TOIN learning loop — fixed Before: `ContentRouter._record_to_toin` skipped SmartCrusher on the assumption SmartCrusher recorded its own TOIN events. The retired Python class did. The Rust port doesn't know about TOIN. Net result: the highest-traffic compression strategy stopped fueling the learning loop, silently. Fix: shim's `crush()` and `_smart_crush_content()` now call `toin.record_compression()` after a real compression. Filtered on `strategy != "passthrough"` because the Rust port flips `was_modified=True` from JSON whitespace re-canonicalization. Best effort: TOIN failures are logged at debug level and never break compression. Token estimates use `len(json) // 4` (the rule the retired Python used) because the router doesn't pass a tokenizer down to this layer and re-tokenizing here would dominate the recording cost. 7 tests in `tests/test_smart_crusher_toin_attachment.py`: - crush() records on real compression, doesn't on passthrough - structurally-similar inputs land on the same pattern - _smart_crush_content() records (legacy apply() path) - TOIN errors don't break compression - non-JSON input doesn't record - inject_retrieval_marker=False emits a WARNING ## CCR marker emission knob — labelled, not yet fixed `ccr_config.inject_retrieval_marker=False` is not honored — the Rust port emits `<<ccr:HASH N_rows_offloaded>>` markers in `dropped_summary` unconditionally. Today the production default has the flag True so no one is hitting the gap, but the silent-disconnect was a real regression. Shim now logs a WARNING when callers pass `False` so the mismatch is visible. Fix needs a Rust-side gate; tracked in `RUST_DEV.md`. ## Custom relevance scorer — labelled, not yet fixed `relevance_config` and `scorer` constructor args are accepted for source compatibility but the Rust default `HybridScorer` always runs. Shim was logging this at debug; bumped to WARNING. Tracked. ## RUST_DEV.md New "Known regressions in retired-Python components" section with a table per retired component. The intent is that this section gets updated whenever a regression closes (or a new one is found), so the duplicate-codebase tax stays visible instead of decaying into folklore.
2026-04-28 11:28:25 -07:00
- `SmartCrusherConfig.use_feedback_hints=False` — config field is forwarded to Rust but its honoring inside the Rust crusher hasn't been verified against a parity fixture for the disabled path.
When any item above changes, update both this section and the test file. The shim's docstring also references this section — keep them aligned.
## Phase 0 Blockers
These are known limitations for Phase 0. They are tracked here so Phase 1
doesn't rediscover them.
- **`cache_aligner` fixtures**: `CacheAligner.apply()` takes
`(messages, tokenizer, **kwargs)` — a `Tokenizer` is provider-specific and
its cheapest `NoopTokenCounter` / `TiktokenTokenCounter` construction still
requires pulling `headroom.providers.*` which imports the full observability
stack (opentelemetry, etc). The recorder records `cache_aligner` only if a
usable tokenizer is cheaply available; otherwise it logs a blocker and
skips. See `recorder.py::_build_cache_aligner_tokenizer`.
- **`ccr` is not a single class**: The repo has `CCRToolInjector`,
`CCRResponseHandler`, `CCRToolCall`, `CCRToolResult` etc. rather than a
single `CCR` class. The recorder targets the encoder-style entry point
most analogous to the Rust port (`CCRToolInjector.inject_tool` and
`CCRResponseHandler.parse_response`). If Phase 1 wants a different split
it should update `recorder.py::record_all` accordingly.
- **Pre-commit hook noise**: `scripts/sync-plugin-versions.py` mutates
`.claude-plugin/marketplace.json`, `.github/plugin/marketplace.json`, and
`plugins/headroom-agent-hooks/**/plugin.json` on every commit. Those
changes are harmless but each commit in Phase 0 picks them up. Phase 1
does not need to do anything special — just let the hook run.
- **`rust-toolchain.toml`** pins `channel = "stable"` rather than a specific
version so CI picks up the same toolchain the local box uses. Tighten to a
pinned version (e.g. `1.78`) once the port stabilizes.
fix(proxy): cache concurrency lock, multi-worker docs, bounded compression executor Three audit follow-ups from issue #327's deep-dive review. C1 — CompressionCache concurrency lock ====================================== `CompressionCache` instances are shared per `session_id` and accessed from async-dispatched threadpool workers. Pre-fix, concurrent requests for the same session raced on `_cache`, `_stable_hashes`, `_first_seen`, and `_total_tokens_saved` with no synchronization. Observable failures: * Lost-update on `_total_tokens_saved` (read-modify-write). * `RuntimeError: OrderedDict mutated during iteration` from `apply_cached` when a concurrent `store_compressed` evicts during the walk. * Lost stable-hash records — next-turn compute_frozen_count reads inconsistent state. May also explain part of SvenMeyer's `_cache: 0 entries / 1003 misses` observation: the cache was being clobbered concurrently. Added `threading.RLock` guarding all mutating methods. `RLock` (not `Lock`) so future code can call locked methods from inside another locked method without self-deadlock. Also locked `HeadroomProxy._compression_caches` dict-of-caches access via a separate `_compression_caches_lock` so two concurrent calls for the same session_id can't each create distinct CompressionCache objects (which would split the cache state between them). The `/stats` endpoint snapshots the cache list under the dict lock before iterating to avoid eviction-during-iteration. C2 — Multi-worker CCR fragmentation: documented + startup warning ================================================================= The in-memory `InMemoryCcrStore` (Rust), `_compression_caches` (Python), `session_tracker_store` (Python), and TOIN learner state are ALL per-process. Multi-worker uvicorn round-robins requests across workers, so a session whose turn-1 lands on worker A may have turn-2 land on worker B. Worker B has zero knowledge of A's CCR markers, replay cache, or prefix-cache state. Result: `Retrieve original: hash=X` markers stay in-context as opaque directives, every fresh tool_result is recompressed from scratch, and `frozen_message_count=0` causes Anthropic prefix-cache busts on every cross-worker turn. Added a "Multi-worker deployment — CCR fragmentation" section in `RUST_DEV.md` documenting the failure modes, the supported configuration (`--workers 1`), and the sticky-session workaround for horizontal scale. The proxy emits a `WARNING`-level log line on startup if `workers > 1` is detected, pointing at the doc section. C3 — Bounded compression executor with cancel-aware metrics =========================================================== `asyncio.wait_for(asyncio.to_thread(pipeline.apply), timeout=...)` cancellation does NOT propagate into the threadpool worker that's running Rust code. Once the worker has picked up the task, `concurrent.futures.Future.cancel()` returns False and the thread runs to completion. Stuck threads accumulated invisibly on asyncio's default executor, contending with unrelated `to_thread` callers (file IO, etc.). Replaced all 7 `asyncio.to_thread` call sites for `pipeline.apply()` across `proxy/handlers/anthropic.py` (3) and `proxy/handlers/openai.py` (4) with a new `HeadroomProxy._run_compression_in_executor(fn, *, timeout)` helper that: 1. Submits to a dedicated bounded `ThreadPoolExecutor` named `headroom-compress` (configurable via `ProxyConfig.compression_max_workers`; defaults to `min(32, (cpu_count or 1) * 4)`). 2. Increments `_compression_in_flight` (gauge) when work starts and decrements when work completes; tracks `_compression_in_flight_max` as a high-water mark. 3. Detects "leaked threads" by comparing wall-clock elapsed against the timeout in the worker's `finally` block. Increments `_compression_leaked_threads` when a worker finishes after its asyncio future was cancelled. Operators can see the leaked-thread rate climbing in `/stats runtime.compression_executor` BEFORE the pool fills up. Tests ===== * `TestCompressionCacheConcurrency` (3 tests) — many threads store_compressed / apply_cached / update_from_result on a single CompressionCache; assert no exceptions, no lost updates, no partial state. * `test_get_compression_cache_returns_same_instance_under_contention` — 32 concurrent `_get_compression_cache(same_id)` calls return the identical instance (would split pre-lock). * `test_proxy_compression_executor.py` (8 tests) — pool size respects config, in-flight gauge tracks running compressions, high-water mark is monotonic, timeout propagates to awaiter, leaked-thread counter increments on post-deadline completion, `/stats` surfaces all three gauges. Verification ============ * All 123 targeted regression tests pass. * `make ci-precheck` clean. * No `Co-Authored-By` trailer; conventional `fix:` prefix; no `--no-verify`.
2026-05-01 15:25:18 -07:00
## Multi-worker deployment — CCR fragmentation
chore: remove committed node_modules + stray/internal markdown (repo hygiene) (#1528) ## Description Repo hygiene for a public OSS project: removes committed `node_modules`, stray/internal/draft markdown, and commercial-surface references — keeping every real doc (the published docs site, the wiki guides, and all component READMEs) intact. Every file was content-audited before removal, and load-bearing files were verified against the code/CI and kept. Net: **1,695 files changed, +23 / −266,409** (the deletions are dominated by a committed `node_modules` tree). Closes # (no tracking issue) ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [x] Documentation update - [x] Code refactoring (no functional changes) ## Changes Made **Removed (verified to have no code/CI dependencies):** - `examples/vercel-ai-sdk-pr/` — 1,649 committed `node_modules` files (zero example source); `node_modules/` added to `.gitignore`. - `docs/spec/` (23 draft "Living Specification" files — orphaned, `1.0.0-draft`, drifted from the code), `docs/superpowers/` (2 agent plans), `docs/proposals/` (2 internal/commercial memos). - 6 orphan `docs/*.md` (auth-modes, bedrock, claude-code-vertex-headroom, cortex-code, output-token-reduction-guide, rtk-loop-weighting). - `PR.md` (committed PR draft), `ENTERPRISE.md`, `.github/FUNDING.yml`. **Content scrubs:** - Removed unreleased "Headroom Cloud" / `api.headroom.ai` / `hr_` references from `configuration.mdx`, `wiki/configuration.md`, `wiki/typescript-sdk.md`, `sdk/typescript/README.md` (reworded to neutral, accurate phrasing). - Dropped a stale "awaiting maintainer before merge" line from `plugins/headroom-oauth2/SPEC.md`; tidied `.gitignore` comments (kept the protective `headroom-managed/` ignore rule). - Fixed the now-dangling links into removed files (README nav/`output-token-reduction` link, `scripts/README`, `wiki/vertex`). **Explicitly KEPT (load-bearing — would orphan in-code citations if removed):** - `.changelog.md` — consumed by `.github/workflows/release.yml` (read as the release-notes file). - `REALIGNMENT/`, `docs/observability.md`, `docs/rtk-architecture.md`, `wiki/plans/`, `TESTING-copilot-subscription.md` — referenced by the Rust core / Python / tests as design docs. ## Testing - [ ] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check`) - [ ] Type checking passes (`mypy`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text # Docs/markdown + .gitignore only — no Python/Rust source changed, so the # behavioral test suite is unaffected. Verified the cleanup did not orphan # references or break the published docs site: $ git ls-files 'docs/content/docs/*.mdx' | wc -l # published site intact 42 $ # meta.json nav unchanged; no published page removed. $ grep -rnI "Headroom Cloud|api.headroom.ai|'hr_" $(git ls-files '*.md' '*.mdx') >>> none $ # dangling refs to removed files (excl pre-existing P0/P2 spec stubs that $ # never existed in git): none remaining. ``` ## Real Behavior Proof - Environment: macOS, local git clone of the repo (markdown/.gitignore changes only — no runtime). - Exact command / steps: 4 read-only content-audit agents classified every `.md`/`.mdx` file; each removal candidate was cross-checked against the codebase (`grep` for citations in `.rs`/`.py`/tests, workflows, and configs); only files with no dependents were removed; the tree was re-grepped after removal to confirm no new dangling references; verified the published docs site page count (`git ls-files 'docs/content/docs/*.mdx' | wc -l` = 42, unchanged). - Observed result: the 42-page published docs site and all wiki guides are untouched; no source or workflow references a removed file; `.changelog.md` (consumed by release.yml) and the code-cited design docs were detected as dependencies and kept; the committed `node_modules` tree is removed and `node_modules/` is gitignored so it can't be re-committed; zero "Headroom Cloud"/`headroom.dev` references remain. - Not tested: N/A — no executable code changed (only markdown, `.mdx`, and `.gitignore`), so the behavioral test suite is unaffected. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes - This branch deletes `.github/FUNDING.yml` while PR #1526 edits it — the two will be sequenced at merge (delete wins). - A follow-up option (not in this PR): also remove the internal design docs that are currently cited by the code (`REALIGNMENT/`, `docs/observability.md`, `docs/rtk-architecture.md`, `wiki/plans/`) — that requires scrubbing ~15–20 in-code citations so nothing dangles, so it's deliberately deferred. - Untracked local working files (`benchmarks/hf_pilot/`, `tools/copilot-test/`) are intentionally left out of git (not committed).
2026-06-27 23:32:54 -07:00
**Status:** two persistent CCR backends are available. The single-`--workers`
recommendation no longer applies once you select a persistent backend.
fix: B7 — CCR hardening: persistent backends + always-on tool P2-25, P2-26: CCR (Compress-Cache-Retrieve) used an in-memory store that fragmented across uvicorn workers and was wiped on restart, and the `headroom_retrieve` tool was registered/unregistered per-request based on whether the latest body happened to contain compression markers — every flip busted the prompt cache. Both are sticky side-channels: once a session has done CCR, the tool list bytes and the retrieval store must stay stable. This PR fixes both. Rust: * Split `ccr.rs` into `ccr/` with `backends/` submodule (`in_memory.rs`, `sqlite.rs`, `redis.rs` cfg-gated). * `SqliteCcrStore` (production default): WAL mode, prepared upsert, lazy TTL purge on read, persistent across worker restarts and shareable across workers on the same host via SQLite file locking. * `RedisCcrStore` (cfg-gated behind `feature = "redis"`): SETEX with startup PING smoke-test, no key-prefix collision risk, no sticky session required at the LB. * `CcrBackendConfig::{InMemory, Sqlite, Redis}` + `from_config(...)` factory — every init failure surfaces (no silent fallback per `feedback_no_silent_fallbacks.md`). * `ccr::compute_key` (BLAKE3 → first 24 hex chars) and `ccr::marker_for("HASH") -> "<<ccr:HASH>>"` centralize the hash + marker format; one definition for the live-zone dispatcher and the Python regex (`headroom/ccr/tool_injection.py:211`). * `compress_anthropic_live_zone_with_ccr` accepts `Option<&dyn CcrStore>`. When wired, every accepted compression puts the original bytes into the backend and appends `<<ccr:HASH>>` to the compressed string. The token-validation gate runs on the marker-augmented string so the `compressed_tokens >= original_tokens` rejection stays honest. Python: * `SessionCcrTracker` + `apply_session_sticky_ccr_tool` mirror the PR-A7 `SessionToolTracker` / `apply_session_sticky_memory_tools` pattern: once a session has done CCR, every subsequent request injects the recorded golden tool-definition bytes. Tool list bytes are byte-stable across turns (snapshot test pins them). * `headroom/ccr/tool_injection.py::inject_tool_definition` accepts a new `session_has_done_ccr` kwarg per the PR-B7 spec change at line 302-328. The legacy per-request path stays intact for callers that don't yet thread a session id (e.g. Google handler). * Anthropic + OpenAI handlers route their CCR tool-list updates through `apply_session_sticky_ccr_tool`, keyed off the existing `session_tracker_store.compute_session_id(...)` plumbing. Backend selection model: `CcrBackendConfig::Sqlite { path }` is the production default — single host, persistent, multi-worker safe with sticky session. `CcrBackendConfig::Redis { url }` is the multi-host scale-out option — no stickiness needed. `InMemory` is for tests and single-worker dev only. RUST_DEV.md "Multi-worker deployment — CCR fragmentation" rewritten around this matrix. Tests: * `crates/headroom-core/tests/ccr_backends.rs` — 7 tests covering SQLite round-trip, TTL purge, proxy-restart survival, cross-backend byte-equal keys, `from_config` paths, and the no-redis-feature loud-failure check (+ 2 redis tests gated behind the feature). * `crates/headroom-core/tests/live_zone_ccr.rs` — confirms `<<ccr:HASH>>` marker injection, store population, and no-marker-when-no-store invariants end-to-end. * `tests/test_ccr_tool_always_on.py` — 12 tests pinning the always-on behaviour, session/provider isolation, LRU bound, no- session-id fallback, and (per-acceptance-criterion) the byte-stable tool-definition snapshot. Per-PR-B7 plan: REALIGNMENT/04-phase-B-live-zone.md.
2026-05-02 16:49:08 -07:00
### Backend selection
`crates/headroom-core/src/ccr/backends/` ships three implementations of
the `CcrStore` trait:
| Backend | When to use | Persistence | Multi-worker safe |
| ---------------------- | ------------------------------------------- | ----------- | -------------------------- |
| `InMemoryCcrStore` | Tests, single-worker prototyping | No | No |
| `SqliteCcrStore` (default) | Single-instance prod / single-host fleet | Yes (file) | Yes (sticky session) |
| `RedisCcrStore` (opt-in) | Multi-host / horizontally-scaled prod | Yes (Redis) | Yes (no stickiness needed) |
`backends::from_config` picks one at startup from the operator's
`CcrBackendConfig`. **Init failures surface to the caller**
(`feedback_no_silent_fallbacks.md`) — a misconfigured DB path or
unreachable Redis URL aborts startup rather than silently degrading to
in-memory.
### When does what work?
- **`SqliteCcrStore`** is the default for new deploys. The DB file lives
on the local disk; multiple workers on the **same host** share it via
SQLite's WAL-mode locking, so `--workers N` works as long as a sticky
load balancer routes each session to the same host. Survives proxy
restarts: a new worker that opens the same DB file recovers every
in-flight `<<ccr:HASH>>` marker.
- **`RedisCcrStore`** (cfg-gated behind the `redis` feature) is the
drop-in for **horizontally-scaled** deployments. Every worker on
every host hits the same Redis instance; no sticky session is
required at any layer of the LB. Enable with `--features redis` in
the proxy crate's Cargo build.
- **`InMemoryCcrStore`** is fine for tests and single-worker
development. Production deployments using it lose every
`<<ccr:HASH>>` marker on restart and fragment across workers — keep
it confined to local boxes.
### What goes wrong with the in-memory backend on `--workers N > 1`
fix(proxy): make CCR multi-worker warning conditional on backend (#770) ## Problem The multi-worker startup warning always mentioned CCR retrieval failures, even when the operator had already configured a cross-worker backend via `HEADROOM_CCR_BACKEND`. That's noise — if they've set `HEADROOM_CCR_BACKEND=sqlite` or `redis`, CCR fragmentation is already resolved. This was surfaced during review of #628 (now closed): the reviewer correctly noted that Python `CompressionStore` defaults to `InMemoryBackend`, which is per-process — each uvicorn worker has its own singleton, so CCR markers written on worker A are invisible to worker B unless a shared backend is configured. ## Changes ### `headroom/proxy/server.py` The `workers > 1` warning is now conditional on `HEADROOM_CCR_BACKEND`: - **Backend unset (default `InMemoryBackend`, per-process):** warning includes CCR retrieval failures and suggests `HEADROOM_CCR_BACKEND=sqlite` to use a shared cross-worker store. - **Backend configured (`sqlite`/`redis`):** warning covers only the remaining per-worker stores (compression cache, prefix tracker, TOIN, CostTracker) — CCR fragmentation is already resolved. ### `RUST_DEV.md` Updated the multi-worker fragmentation section: - Removed the incorrect parenthetical claiming this only applies when the operator *explicitly* chooses `CcrBackendConfig::InMemory` (Python defaults to InMemory) - Added Python `CompressionStore` as item 1 in the fragmented-state list, with a note that setting `HEADROOM_CCR_BACKEND=sqlite` resolves it - Restored TOIN to the fragmented list with a note that its file-backed snapshots do not make it coherently shared across workers - Updated "Detecting it in the wild" to document the conditional warning behaviour ## Files changed | File | Change | |---|---| | `headroom/proxy/server.py` | Conditional two-branch warning based on `HEADROOM_CCR_BACKEND` | | `RUST_DEV.md` | Accurate per-process description of Python `CompressionStore`; restored TOIN | | `CHANGELOG.md` | Entry under `[Unreleased]` | Co-authored-by: JD Davis <mxjerrett@gmail.com>
2026-06-11 19:59:11 -04:00
Each uvicorn worker is a separate Python process. The following state is
fragmented across workers:
1. **Python `CompressionStore`** — defaults to `InMemoryBackend` (per-process)
when `HEADROOM_CCR_BACKEND` is unset. Each worker has its own singleton; CCR
markers written on worker A are invisible to worker B. Set
`HEADROOM_CCR_BACKEND=sqlite` to use a shared cross-worker store.
2. **`HeadroomProxy._compression_caches`** (`headroom/proxy/server.py`)
— per-session `CompressionCache` dict (instance var, always per-worker).
fix(proxy): cache concurrency lock, multi-worker docs, bounded compression executor Three audit follow-ups from issue #327's deep-dive review. C1 — CompressionCache concurrency lock ====================================== `CompressionCache` instances are shared per `session_id` and accessed from async-dispatched threadpool workers. Pre-fix, concurrent requests for the same session raced on `_cache`, `_stable_hashes`, `_first_seen`, and `_total_tokens_saved` with no synchronization. Observable failures: * Lost-update on `_total_tokens_saved` (read-modify-write). * `RuntimeError: OrderedDict mutated during iteration` from `apply_cached` when a concurrent `store_compressed` evicts during the walk. * Lost stable-hash records — next-turn compute_frozen_count reads inconsistent state. May also explain part of SvenMeyer's `_cache: 0 entries / 1003 misses` observation: the cache was being clobbered concurrently. Added `threading.RLock` guarding all mutating methods. `RLock` (not `Lock`) so future code can call locked methods from inside another locked method without self-deadlock. Also locked `HeadroomProxy._compression_caches` dict-of-caches access via a separate `_compression_caches_lock` so two concurrent calls for the same session_id can't each create distinct CompressionCache objects (which would split the cache state between them). The `/stats` endpoint snapshots the cache list under the dict lock before iterating to avoid eviction-during-iteration. C2 — Multi-worker CCR fragmentation: documented + startup warning ================================================================= The in-memory `InMemoryCcrStore` (Rust), `_compression_caches` (Python), `session_tracker_store` (Python), and TOIN learner state are ALL per-process. Multi-worker uvicorn round-robins requests across workers, so a session whose turn-1 lands on worker A may have turn-2 land on worker B. Worker B has zero knowledge of A's CCR markers, replay cache, or prefix-cache state. Result: `Retrieve original: hash=X` markers stay in-context as opaque directives, every fresh tool_result is recompressed from scratch, and `frozen_message_count=0` causes Anthropic prefix-cache busts on every cross-worker turn. Added a "Multi-worker deployment — CCR fragmentation" section in `RUST_DEV.md` documenting the failure modes, the supported configuration (`--workers 1`), and the sticky-session workaround for horizontal scale. The proxy emits a `WARNING`-level log line on startup if `workers > 1` is detected, pointing at the doc section. C3 — Bounded compression executor with cancel-aware metrics =========================================================== `asyncio.wait_for(asyncio.to_thread(pipeline.apply), timeout=...)` cancellation does NOT propagate into the threadpool worker that's running Rust code. Once the worker has picked up the task, `concurrent.futures.Future.cancel()` returns False and the thread runs to completion. Stuck threads accumulated invisibly on asyncio's default executor, contending with unrelated `to_thread` callers (file IO, etc.). Replaced all 7 `asyncio.to_thread` call sites for `pipeline.apply()` across `proxy/handlers/anthropic.py` (3) and `proxy/handlers/openai.py` (4) with a new `HeadroomProxy._run_compression_in_executor(fn, *, timeout)` helper that: 1. Submits to a dedicated bounded `ThreadPoolExecutor` named `headroom-compress` (configurable via `ProxyConfig.compression_max_workers`; defaults to `min(32, (cpu_count or 1) * 4)`). 2. Increments `_compression_in_flight` (gauge) when work starts and decrements when work completes; tracks `_compression_in_flight_max` as a high-water mark. 3. Detects "leaked threads" by comparing wall-clock elapsed against the timeout in the worker's `finally` block. Increments `_compression_leaked_threads` when a worker finishes after its asyncio future was cancelled. Operators can see the leaked-thread rate climbing in `/stats runtime.compression_executor` BEFORE the pool fills up. Tests ===== * `TestCompressionCacheConcurrency` (3 tests) — many threads store_compressed / apply_cached / update_from_result on a single CompressionCache; assert no exceptions, no lost updates, no partial state. * `test_get_compression_cache_returns_same_instance_under_contention` — 32 concurrent `_get_compression_cache(same_id)` calls return the identical instance (would split pre-lock). * `test_proxy_compression_executor.py` (8 tests) — pool size respects config, in-flight gauge tracks running compressions, high-water mark is monotonic, timeout propagates to awaiter, leaked-thread counter increments on post-deadline completion, `/stats` surfaces all three gauges. Verification ============ * All 123 targeted regression tests pass. * `make ci-precheck` clean. * No `Co-Authored-By` trailer; conventional `fix:` prefix; no `--no-verify`.
2026-05-01 15:25:18 -07:00
3. **`HeadroomProxy.session_tracker_store`** — per-session prefix-tracker
fix(proxy): make CCR multi-worker warning conditional on backend (#770) ## Problem The multi-worker startup warning always mentioned CCR retrieval failures, even when the operator had already configured a cross-worker backend via `HEADROOM_CCR_BACKEND`. That's noise — if they've set `HEADROOM_CCR_BACKEND=sqlite` or `redis`, CCR fragmentation is already resolved. This was surfaced during review of #628 (now closed): the reviewer correctly noted that Python `CompressionStore` defaults to `InMemoryBackend`, which is per-process — each uvicorn worker has its own singleton, so CCR markers written on worker A are invisible to worker B unless a shared backend is configured. ## Changes ### `headroom/proxy/server.py` The `workers > 1` warning is now conditional on `HEADROOM_CCR_BACKEND`: - **Backend unset (default `InMemoryBackend`, per-process):** warning includes CCR retrieval failures and suggests `HEADROOM_CCR_BACKEND=sqlite` to use a shared cross-worker store. - **Backend configured (`sqlite`/`redis`):** warning covers only the remaining per-worker stores (compression cache, prefix tracker, TOIN, CostTracker) — CCR fragmentation is already resolved. ### `RUST_DEV.md` Updated the multi-worker fragmentation section: - Removed the incorrect parenthetical claiming this only applies when the operator *explicitly* chooses `CcrBackendConfig::InMemory` (Python defaults to InMemory) - Added Python `CompressionStore` as item 1 in the fragmented-state list, with a note that setting `HEADROOM_CCR_BACKEND=sqlite` resolves it - Restored TOIN to the fragmented list with a note that its file-backed snapshots do not make it coherently shared across workers - Updated "Detecting it in the wild" to document the conditional warning behaviour ## Files changed | File | Change | |---|---| | `headroom/proxy/server.py` | Conditional two-branch warning based on `HEADROOM_CCR_BACKEND` | | `RUST_DEV.md` | Accurate per-process description of Python `CompressionStore`; restored TOIN | | `CHANGELOG.md` | Entry under `[Unreleased]` | Co-authored-by: JD Davis <mxjerrett@gmail.com>
2026-06-11 19:59:11 -04:00
state derived from Anthropic's `cache_read_input_tokens` responses
(instance var, always per-worker).
4. **TOIN learner state** — writes snapshots to `~/.headroom/toin.json` but
keeps per-process in-memory state; pattern statistics on one worker are not
visible to others until the next disk flush.
fix(proxy): cache concurrency lock, multi-worker docs, bounded compression executor Three audit follow-ups from issue #327's deep-dive review. C1 — CompressionCache concurrency lock ====================================== `CompressionCache` instances are shared per `session_id` and accessed from async-dispatched threadpool workers. Pre-fix, concurrent requests for the same session raced on `_cache`, `_stable_hashes`, `_first_seen`, and `_total_tokens_saved` with no synchronization. Observable failures: * Lost-update on `_total_tokens_saved` (read-modify-write). * `RuntimeError: OrderedDict mutated during iteration` from `apply_cached` when a concurrent `store_compressed` evicts during the walk. * Lost stable-hash records — next-turn compute_frozen_count reads inconsistent state. May also explain part of SvenMeyer's `_cache: 0 entries / 1003 misses` observation: the cache was being clobbered concurrently. Added `threading.RLock` guarding all mutating methods. `RLock` (not `Lock`) so future code can call locked methods from inside another locked method without self-deadlock. Also locked `HeadroomProxy._compression_caches` dict-of-caches access via a separate `_compression_caches_lock` so two concurrent calls for the same session_id can't each create distinct CompressionCache objects (which would split the cache state between them). The `/stats` endpoint snapshots the cache list under the dict lock before iterating to avoid eviction-during-iteration. C2 — Multi-worker CCR fragmentation: documented + startup warning ================================================================= The in-memory `InMemoryCcrStore` (Rust), `_compression_caches` (Python), `session_tracker_store` (Python), and TOIN learner state are ALL per-process. Multi-worker uvicorn round-robins requests across workers, so a session whose turn-1 lands on worker A may have turn-2 land on worker B. Worker B has zero knowledge of A's CCR markers, replay cache, or prefix-cache state. Result: `Retrieve original: hash=X` markers stay in-context as opaque directives, every fresh tool_result is recompressed from scratch, and `frozen_message_count=0` causes Anthropic prefix-cache busts on every cross-worker turn. Added a "Multi-worker deployment — CCR fragmentation" section in `RUST_DEV.md` documenting the failure modes, the supported configuration (`--workers 1`), and the sticky-session workaround for horizontal scale. The proxy emits a `WARNING`-level log line on startup if `workers > 1` is detected, pointing at the doc section. C3 — Bounded compression executor with cancel-aware metrics =========================================================== `asyncio.wait_for(asyncio.to_thread(pipeline.apply), timeout=...)` cancellation does NOT propagate into the threadpool worker that's running Rust code. Once the worker has picked up the task, `concurrent.futures.Future.cancel()` returns False and the thread runs to completion. Stuck threads accumulated invisibly on asyncio's default executor, contending with unrelated `to_thread` callers (file IO, etc.). Replaced all 7 `asyncio.to_thread` call sites for `pipeline.apply()` across `proxy/handlers/anthropic.py` (3) and `proxy/handlers/openai.py` (4) with a new `HeadroomProxy._run_compression_in_executor(fn, *, timeout)` helper that: 1. Submits to a dedicated bounded `ThreadPoolExecutor` named `headroom-compress` (configurable via `ProxyConfig.compression_max_workers`; defaults to `min(32, (cpu_count or 1) * 4)`). 2. Increments `_compression_in_flight` (gauge) when work starts and decrements when work completes; tracks `_compression_in_flight_max` as a high-water mark. 3. Detects "leaked threads" by comparing wall-clock elapsed against the timeout in the worker's `finally` block. Increments `_compression_leaked_threads` when a worker finishes after its asyncio future was cancelled. Operators can see the leaked-thread rate climbing in `/stats runtime.compression_executor` BEFORE the pool fills up. Tests ===== * `TestCompressionCacheConcurrency` (3 tests) — many threads store_compressed / apply_cached / update_from_result on a single CompressionCache; assert no exceptions, no lost updates, no partial state. * `test_get_compression_cache_returns_same_instance_under_contention` — 32 concurrent `_get_compression_cache(same_id)` calls return the identical instance (would split pre-lock). * `test_proxy_compression_executor.py` (8 tests) — pool size respects config, in-flight gauge tracks running compressions, high-water mark is monotonic, timeout propagates to awaiter, leaked-thread counter increments on post-deadline completion, `/stats` surfaces all three gauges. Verification ============ * All 123 targeted regression tests pass. * `make ci-precheck` clean. * No `Co-Authored-By` trailer; conventional `fix:` prefix; no `--no-verify`.
2026-05-01 15:25:18 -07:00
fix: B7 — CCR hardening: persistent backends + always-on tool P2-25, P2-26: CCR (Compress-Cache-Retrieve) used an in-memory store that fragmented across uvicorn workers and was wiped on restart, and the `headroom_retrieve` tool was registered/unregistered per-request based on whether the latest body happened to contain compression markers — every flip busted the prompt cache. Both are sticky side-channels: once a session has done CCR, the tool list bytes and the retrieval store must stay stable. This PR fixes both. Rust: * Split `ccr.rs` into `ccr/` with `backends/` submodule (`in_memory.rs`, `sqlite.rs`, `redis.rs` cfg-gated). * `SqliteCcrStore` (production default): WAL mode, prepared upsert, lazy TTL purge on read, persistent across worker restarts and shareable across workers on the same host via SQLite file locking. * `RedisCcrStore` (cfg-gated behind `feature = "redis"`): SETEX with startup PING smoke-test, no key-prefix collision risk, no sticky session required at the LB. * `CcrBackendConfig::{InMemory, Sqlite, Redis}` + `from_config(...)` factory — every init failure surfaces (no silent fallback per `feedback_no_silent_fallbacks.md`). * `ccr::compute_key` (BLAKE3 → first 24 hex chars) and `ccr::marker_for("HASH") -> "<<ccr:HASH>>"` centralize the hash + marker format; one definition for the live-zone dispatcher and the Python regex (`headroom/ccr/tool_injection.py:211`). * `compress_anthropic_live_zone_with_ccr` accepts `Option<&dyn CcrStore>`. When wired, every accepted compression puts the original bytes into the backend and appends `<<ccr:HASH>>` to the compressed string. The token-validation gate runs on the marker-augmented string so the `compressed_tokens >= original_tokens` rejection stays honest. Python: * `SessionCcrTracker` + `apply_session_sticky_ccr_tool` mirror the PR-A7 `SessionToolTracker` / `apply_session_sticky_memory_tools` pattern: once a session has done CCR, every subsequent request injects the recorded golden tool-definition bytes. Tool list bytes are byte-stable across turns (snapshot test pins them). * `headroom/ccr/tool_injection.py::inject_tool_definition` accepts a new `session_has_done_ccr` kwarg per the PR-B7 spec change at line 302-328. The legacy per-request path stays intact for callers that don't yet thread a session id (e.g. Google handler). * Anthropic + OpenAI handlers route their CCR tool-list updates through `apply_session_sticky_ccr_tool`, keyed off the existing `session_tracker_store.compute_session_id(...)` plumbing. Backend selection model: `CcrBackendConfig::Sqlite { path }` is the production default — single host, persistent, multi-worker safe with sticky session. `CcrBackendConfig::Redis { url }` is the multi-host scale-out option — no stickiness needed. `InMemory` is for tests and single-worker dev only. RUST_DEV.md "Multi-worker deployment — CCR fragmentation" rewritten around this matrix. Tests: * `crates/headroom-core/tests/ccr_backends.rs` — 7 tests covering SQLite round-trip, TTL purge, proxy-restart survival, cross-backend byte-equal keys, `from_config` paths, and the no-redis-feature loud-failure check (+ 2 redis tests gated behind the feature). * `crates/headroom-core/tests/live_zone_ccr.rs` — confirms `<<ccr:HASH>>` marker injection, store population, and no-marker-when-no-store invariants end-to-end. * `tests/test_ccr_tool_always_on.py` — 12 tests pinning the always-on behaviour, session/provider isolation, LRU bound, no- session-id fallback, and (per-acceptance-criterion) the byte-stable tool-definition snapshot. Per-PR-B7 plan: REALIGNMENT/04-phase-B-live-zone.md.
2026-05-02 16:49:08 -07:00
When uvicorn round-robins requests across workers, a session whose
turn-1 landed on worker A may have turn-2 land on worker B. Worker B has
zero knowledge of what worker A did, the `<<ccr:HASH>>` marker resolves
to `None`, and the model sees an opaque directive it can't act on.
Switching to `SqliteCcrStore` (default) or `RedisCcrStore` resolves the
fix(proxy): make CCR multi-worker warning conditional on backend (#770) ## Problem The multi-worker startup warning always mentioned CCR retrieval failures, even when the operator had already configured a cross-worker backend via `HEADROOM_CCR_BACKEND`. That's noise — if they've set `HEADROOM_CCR_BACKEND=sqlite` or `redis`, CCR fragmentation is already resolved. This was surfaced during review of #628 (now closed): the reviewer correctly noted that Python `CompressionStore` defaults to `InMemoryBackend`, which is per-process — each uvicorn worker has its own singleton, so CCR markers written on worker A are invisible to worker B unless a shared backend is configured. ## Changes ### `headroom/proxy/server.py` The `workers > 1` warning is now conditional on `HEADROOM_CCR_BACKEND`: - **Backend unset (default `InMemoryBackend`, per-process):** warning includes CCR retrieval failures and suggests `HEADROOM_CCR_BACKEND=sqlite` to use a shared cross-worker store. - **Backend configured (`sqlite`/`redis`):** warning covers only the remaining per-worker stores (compression cache, prefix tracker, TOIN, CostTracker) — CCR fragmentation is already resolved. ### `RUST_DEV.md` Updated the multi-worker fragmentation section: - Removed the incorrect parenthetical claiming this only applies when the operator *explicitly* chooses `CcrBackendConfig::InMemory` (Python defaults to InMemory) - Added Python `CompressionStore` as item 1 in the fragmented-state list, with a note that setting `HEADROOM_CCR_BACKEND=sqlite` resolves it - Restored TOIN to the fragmented list with a note that its file-backed snapshots do not make it coherently shared across workers - Updated "Detecting it in the wild" to document the conditional warning behaviour ## Files changed | File | Change | |---|---| | `headroom/proxy/server.py` | Conditional two-branch warning based on `HEADROOM_CCR_BACKEND` | | `RUST_DEV.md` | Accurate per-process description of Python `CompressionStore`; restored TOIN | | `CHANGELOG.md` | Entry under `[Unreleased]` | Co-authored-by: JD Davis <mxjerrett@gmail.com>
2026-06-11 19:59:11 -04:00
CCR fragmentation; a sticky-session load balancer resolves all of them.
fix(proxy): cache concurrency lock, multi-worker docs, bounded compression executor Three audit follow-ups from issue #327's deep-dive review. C1 — CompressionCache concurrency lock ====================================== `CompressionCache` instances are shared per `session_id` and accessed from async-dispatched threadpool workers. Pre-fix, concurrent requests for the same session raced on `_cache`, `_stable_hashes`, `_first_seen`, and `_total_tokens_saved` with no synchronization. Observable failures: * Lost-update on `_total_tokens_saved` (read-modify-write). * `RuntimeError: OrderedDict mutated during iteration` from `apply_cached` when a concurrent `store_compressed` evicts during the walk. * Lost stable-hash records — next-turn compute_frozen_count reads inconsistent state. May also explain part of SvenMeyer's `_cache: 0 entries / 1003 misses` observation: the cache was being clobbered concurrently. Added `threading.RLock` guarding all mutating methods. `RLock` (not `Lock`) so future code can call locked methods from inside another locked method without self-deadlock. Also locked `HeadroomProxy._compression_caches` dict-of-caches access via a separate `_compression_caches_lock` so two concurrent calls for the same session_id can't each create distinct CompressionCache objects (which would split the cache state between them). The `/stats` endpoint snapshots the cache list under the dict lock before iterating to avoid eviction-during-iteration. C2 — Multi-worker CCR fragmentation: documented + startup warning ================================================================= The in-memory `InMemoryCcrStore` (Rust), `_compression_caches` (Python), `session_tracker_store` (Python), and TOIN learner state are ALL per-process. Multi-worker uvicorn round-robins requests across workers, so a session whose turn-1 lands on worker A may have turn-2 land on worker B. Worker B has zero knowledge of A's CCR markers, replay cache, or prefix-cache state. Result: `Retrieve original: hash=X` markers stay in-context as opaque directives, every fresh tool_result is recompressed from scratch, and `frozen_message_count=0` causes Anthropic prefix-cache busts on every cross-worker turn. Added a "Multi-worker deployment — CCR fragmentation" section in `RUST_DEV.md` documenting the failure modes, the supported configuration (`--workers 1`), and the sticky-session workaround for horizontal scale. The proxy emits a `WARNING`-level log line on startup if `workers > 1` is detected, pointing at the doc section. C3 — Bounded compression executor with cancel-aware metrics =========================================================== `asyncio.wait_for(asyncio.to_thread(pipeline.apply), timeout=...)` cancellation does NOT propagate into the threadpool worker that's running Rust code. Once the worker has picked up the task, `concurrent.futures.Future.cancel()` returns False and the thread runs to completion. Stuck threads accumulated invisibly on asyncio's default executor, contending with unrelated `to_thread` callers (file IO, etc.). Replaced all 7 `asyncio.to_thread` call sites for `pipeline.apply()` across `proxy/handlers/anthropic.py` (3) and `proxy/handlers/openai.py` (4) with a new `HeadroomProxy._run_compression_in_executor(fn, *, timeout)` helper that: 1. Submits to a dedicated bounded `ThreadPoolExecutor` named `headroom-compress` (configurable via `ProxyConfig.compression_max_workers`; defaults to `min(32, (cpu_count or 1) * 4)`). 2. Increments `_compression_in_flight` (gauge) when work starts and decrements when work completes; tracks `_compression_in_flight_max` as a high-water mark. 3. Detects "leaked threads" by comparing wall-clock elapsed against the timeout in the worker's `finally` block. Increments `_compression_leaked_threads` when a worker finishes after its asyncio future was cancelled. Operators can see the leaked-thread rate climbing in `/stats runtime.compression_executor` BEFORE the pool fills up. Tests ===== * `TestCompressionCacheConcurrency` (3 tests) — many threads store_compressed / apply_cached / update_from_result on a single CompressionCache; assert no exceptions, no lost updates, no partial state. * `test_get_compression_cache_returns_same_instance_under_contention` — 32 concurrent `_get_compression_cache(same_id)` calls return the identical instance (would split pre-lock). * `test_proxy_compression_executor.py` (8 tests) — pool size respects config, in-flight gauge tracks running compressions, high-water mark is monotonic, timeout propagates to awaiter, leaked-thread counter increments on post-deadline completion, `/stats` surfaces all three gauges. Verification ============ * All 123 targeted regression tests pass. * `make ci-precheck` clean. * No `Co-Authored-By` trailer; conventional `fix:` prefix; no `--no-verify`.
2026-05-01 15:25:18 -07:00
### Detecting it in the wild
fix(proxy): make CCR multi-worker warning conditional on backend (#770) ## Problem The multi-worker startup warning always mentioned CCR retrieval failures, even when the operator had already configured a cross-worker backend via `HEADROOM_CCR_BACKEND`. That's noise — if they've set `HEADROOM_CCR_BACKEND=sqlite` or `redis`, CCR fragmentation is already resolved. This was surfaced during review of #628 (now closed): the reviewer correctly noted that Python `CompressionStore` defaults to `InMemoryBackend`, which is per-process — each uvicorn worker has its own singleton, so CCR markers written on worker A are invisible to worker B unless a shared backend is configured. ## Changes ### `headroom/proxy/server.py` The `workers > 1` warning is now conditional on `HEADROOM_CCR_BACKEND`: - **Backend unset (default `InMemoryBackend`, per-process):** warning includes CCR retrieval failures and suggests `HEADROOM_CCR_BACKEND=sqlite` to use a shared cross-worker store. - **Backend configured (`sqlite`/`redis`):** warning covers only the remaining per-worker stores (compression cache, prefix tracker, TOIN, CostTracker) — CCR fragmentation is already resolved. ### `RUST_DEV.md` Updated the multi-worker fragmentation section: - Removed the incorrect parenthetical claiming this only applies when the operator *explicitly* chooses `CcrBackendConfig::InMemory` (Python defaults to InMemory) - Added Python `CompressionStore` as item 1 in the fragmented-state list, with a note that setting `HEADROOM_CCR_BACKEND=sqlite` resolves it - Restored TOIN to the fragmented list with a note that its file-backed snapshots do not make it coherently shared across workers - Updated "Detecting it in the wild" to document the conditional warning behaviour ## Files changed | File | Change | |---|---| | `headroom/proxy/server.py` | Conditional two-branch warning based on `HEADROOM_CCR_BACKEND` | | `RUST_DEV.md` | Accurate per-process description of Python `CompressionStore`; restored TOIN | | `CHANGELOG.md` | Entry under `[Unreleased]` | Co-authored-by: JD Davis <mxjerrett@gmail.com>
2026-06-11 19:59:11 -04:00
The proxy emits a `WARNING`-level log line on startup when `--workers N > 1`.
When `HEADROOM_CCR_BACKEND` is unset (default InMemoryBackend), the warning
includes CCR retrieval failures and suggests setting `HEADROOM_CCR_BACKEND=sqlite`.
When a cross-worker backend is already configured, the warning covers only the
remaining per-worker stores (compression cache, prefix tracker, TOIN, CostTracker).