headroom/crates
Zhenjia ZHOU e825588bfb
fix(ccr): sliding idle-window TTL with max-lifetime ceiling in the Rust core backends (#2604) (#2631)
## Description

Rust-core counterpart of the CCR mid-session expiry fix. #2604 (and its
duplicate #2616) report that the 30-minute wall-clock TTL kills entries
in the middle of a normal multi-agent burst: the clock starts at
compression time and never refreshes, so an entry the session keeps
touching still dies.

#2607 fixes this on the Python side by turning the TTL into an idle
window that restarts on every successful retrieval, bounded by an
absolute max lifetime (8x the idle TTL) — but it explicitly notes the
caveat that the Rust core still measures TTL from insertion. This PR
closes that gap: all three Rust CCR backends (`InMemoryCcrStore`,
`SqliteCcrStore`, `RedisCcrStore`) now use the same sliding idle-window
+ max-lifetime-ceiling semantics as the Python `CompressionStore`.

Scoped to the Rust core only; it deliberately does not touch
`DEFAULT_TTL`'s value (1800), which #2607 bumps to 3600 — happy to
rebase in lockstep whichever lands first.

Refs #2604, #2616. Complements #2607.

## Type of Change

- [x] 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)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- `crates/headroom-core/src/ccr/mod.rs`:
`DEFAULT_MAX_LIFETIME_MULTIPLIER = 8` + `max_lifetime_for()` helper;
documents the idle-window semantics.
- `in_memory.rs`: entries track `last_accessed`; a hit refreshes it
under the shard write lock (`get_mut`), expiry checks idle window OR max
lifetime, and the existing `remove_if` TOCTOU protection now uses the
same predicate. New `with_capacity_and_ttls` constructor for independent
control of window and ceiling.
- `sqlite.rs`: new `last_accessed` column (legacy DBs migrated in place
via `ALTER TABLE`, backfilled from `created_at` so old rows keep their
original expiry baseline); lazy purge and the lookup honour both bounds;
a hit touches the row under the same connection mutex as the read. New
`open_with_ttls` constructor.
- `redis.rs`: a hit re-arms the key's expiry, capped by a companion
`{prefix}:{hash}:born` key whose remaining TTL marks the absolute
ceiling; entries written by pre-sliding builds (no born key) are
backfilled rather than dropped.
- `tests/ccr_backends.rs`: 6 new tests — sliding-window survival and
max-lifetime cap for in-memory and SQLite, legacy-schema migration, and
a gated Redis sliding test.

No public API is broken: existing constructors keep their signatures and
derive the ceiling as 8x the idle TTL.

## Testing

- [x] Unit tests pass (`cargo test -p headroom-core`)
- [x] Linting passes (`cargo clippy -p headroom-core --all-features`,
`cargo fmt --check`)
- [ ] Type checking passes (`mypy headroom`) — N/A, no Python files
touched
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ cargo test -p headroom-core --test ccr_backends
test result: ok. 12 passed; 0 failed; 0 ignored (8.12s)

$ cargo test -p headroom-core ccr          # all ccr-named tests across suites
38 passed, 949 filtered out (13 suites)

$ cargo test -p headroom-core --test ccr_roundtrip --test live_zone_ccr
18 passed (2 suites)

$ cargo check -p headroom-core --features redis   # cfg-gated backend compiles
Finished `dev` profile in 25.09s

$ cargo clippy -p headroom-core --all-features
No issues found
```

## Real Behavior Proof

- Environment: macOS (Darwin 25.5), local checkout at upstream `main`
(57bf720d), `cargo test`.
- Exact command / steps: dropped a proof test file
(`ccr_sliding_ttl_proof.rs`, uses only APIs present on both main and
this branch) into `crates/headroom-core/tests/`, ran it against
unpatched `main` src, then against this branch. The in-memory case
touches an entry every 60ms with a 120ms TTL; the SQLite case touches at
t+2s with a 3s TTL and reads again at t+4s — i.e. the issue's "session
keeps using the entry" timeline scaled down.
- Observed result: on unpatched `main` both proof tests fail (in-memory:
"entry vanished on touch #2 despite constant access"; SQLite: "entry
expired at t+4s even though the session touched it at t+2s"); on this
branch the same tests pass 2/2. Full output:

  Before (main, wall-clock TTL):

  ```text
---- proof_in_memory_entry_survives_while_session_keeps_touching_it
stdout ----
  panicked: entry vanished on touch #2 despite constant access

---- proof_sqlite_entry_survives_while_session_keeps_touching_it stdout
----
panicked: entry expired at t+4s even though the session touched it at
t+2s (wall-clock TTL)

  test result: FAILED. 0 passed; 2 failed
  ```

  After (this branch, sliding idle window):

  ```text
  test result: ok. 2 passed; 0 failed (4.01s)
  ```

- Not tested: the Redis backend against a live Redis (the new
`redis_get_refreshes_idle_ttl` test self-skips without
`HEADROOM_TEST_REDIS_URL`, same as the existing gated tests; it compiles
under `--features redis` and runs in the CI redis matrix).

## 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
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] 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
- [x] I did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)

## Additional Notes

- Docs: `docs/content/docs/ccr.mdx` TTL wording is being updated by
#2607; not duplicated here to avoid conflicting hunks.
- The SQLite migration is intentionally in-place and idempotent
(`pragma_table_info` check → `ALTER TABLE ADD COLUMN` → backfill), so a
proxy restarting onto an existing `ccr.sqlite` keeps its rows.
- If #2607 lands first I will rebase; the only expected overlap is the
doc comment around `DEFAULT_TTL`.
2026-07-29 09:14:58 -07:00
..
headroom-core fix(ccr): sliding idle-window TTL with max-lifetime ceiling in the Rust core backends (#2604) (#2631) 2026-07-29 09:14:58 -07:00
headroom-parity feat(rust): port CodeCompressor AST compressor to Rust (parity-only) (#1154) 2026-07-27 09:21:57 -07:00
headroom-proxy fix(proxy): key drift detector on conversations, not credentials; canonicalize drift hashes (#2301) 2026-07-16 14:34:26 -07:00
headroom-py feat(transforms): language-aware stack-trace collapse for Go/Rust/.NET/Java/Node (#1791) 2026-07-15 19:58:30 +00:00
headroom-simulators feat(simulators): add provider simulator service (#2014) 2026-07-11 09:41:49 -07:00