mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
## Description CCR's default SQLite backend ignores `HEADROOM_WORKSPACE_DIR`. When users relocate Headroom's read-write state with the canonical workspace env var, the CCR store still wrote `ccr_store.db` under `~/.headroom` unless they also set `HEADROOM_CCR_SQLITE_PATH`. This change keeps `HEADROOM_CCR_SQLITE_PATH` as the strongest per-store override, then resolves the default SQLite database as `workspace_dir() / "ccr_store.db"` from `headroom.paths.workspace_dir()`. With no env vars set, `workspace_dir()` still falls back to `~/.headroom`, so the effective default remains unchanged. The default backend stays SQLite, preserving restart survival and multi-worker sharing. Closes #1558 ## 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 - Route `headroom.cache.backends.sqlite.default_db_path()` through `headroom.paths.workspace_dir()` when `HEADROOM_CCR_SQLITE_PATH` is unset. - Preserve `HEADROOM_CCR_SQLITE_PATH` as the strongest override. - Keep the no-env effective fallback at `~/.headroom/ccr_store.db` through `workspace_dir()` resolution. - Update default-path wording in SQLite/compression-store/backends docs to remove unconditional fallback claims. - Add focused regression and preservation tests in `tests/test_ccr_sqlite_backend.py` for: - workspace override when `HEADROOM_WORKSPACE_DIR` is set and `HEADROOM_CCR_SQLITE_PATH` is unset. - env path override still winning. - no-env fallback to `~/.headroom`. - explicit `SQLiteBackend(db_path=...)` authority. - existing restart and two-connection behavior. ## Testing - [x] Unit tests pass (`uv run pytest tests/test_ccr_sqlite_backend.py -k "workspace_dir or sqlite_path_env_wins or home_fallback or explicit_db_path or default_backend_is_sqlite or survives_reopen or two_connections_share_data" -v`) - [x] Linting passes (`uv run ruff check headroom/cache/backends/sqlite.py headroom/cache/compression_store.py headroom/cache/backends/__init__.py tests/test_ccr_sqlite_backend.py`) - [ ] Type checking not run (`uv run mypy headroom`) - [x] New tests added for new functionality when applicable - [x] Manual testing performed ### Test Output ```text Base proof before the production fix: uv run pytest tests/test_ccr_sqlite_backend.py -k "workspace_dir" -v FAILED tests/test_ccr_sqlite_backend.py::TestDefaults::test_workspace_dir - AssertionError: assert 'C:\Users\Rod\AppData\Local\Temp\pytest-of-Rod\pytest-484\test_workspace_dir0\fake_home\.headroom\ccr_store.db' == 'C:\Users\Rod\AppData\Local\Temp\pytest-of-Rod\pytest-484\test_workspace_dir0\workspace\ccr_store.db' 1 failed, 20 deselected Focused validation after the fix: uv run pytest tests/test_ccr_sqlite_backend.py -k "workspace_dir or sqlite_path_env_wins or home_fallback or explicit_db_path or default_backend_is_sqlite or survives_reopen or two_connections_share_data" -v 7 passed, 14 deselected, 1 warning in 0.20s uv run ruff check headroom/cache/backends/sqlite.py headroom/cache/compression_store.py headroom/cache/backends/__init__.py tests/test_ccr_sqlite_backend.py All checks passed! ``` ## Real Behavior Proof - Environment: local pytest filesystem-path regression tests with temporary home and workspace directories. - Exact command / steps: run `uv run pytest tests/test_ccr_sqlite_backend.py -k "workspace_dir" -v` on base with the new regression test present, then run `uv run pytest tests/test_ccr_sqlite_backend.py -k "workspace_dir or sqlite_path_env_wins or home_fallback or explicit_db_path or default_backend_is_sqlite or survives_reopen or two_connections_share_data" -v` and `uv run ruff check headroom/cache/backends/sqlite.py headroom/cache/compression_store.py headroom/cache/backends/__init__.py tests/test_ccr_sqlite_backend.py` on the patched branch. - Observed result: the base proof fails because the default backend path resolves to `fake_home\\.headroom\\ccr_store.db` instead of `workspace\\ccr_store.db`; after the fix, the focused pytest selection passes, `HEADROOM_CCR_SQLITE_PATH` still wins, the no-env fallback still resolves through `~/.headroom`, explicit `db_path` remains authoritative, and `ruff check` passes. - Not tested: live proxy traffic with real CCR compression/retrieve requests, because the changed surface is the deterministic default path resolver and default backend construction. ## 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 - [ ] 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 - [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 have updated the CHANGELOG.md if applicable ## Additional Notes `CHANGELOG.md` should remain unchanged because the repo's release automation derives changelog entries from conventional commits.
279 lines
10 KiB
Python
279 lines
10 KiB
Python
"""Tests for the SQLite CCR backend and session-scale TTL defaults.
|
|
|
|
The SQLite backend is the default for `get_compression_store()` because the
|
|
30-minute TTL assumes entries survive proxy restarts and are visible across
|
|
worker processes — neither holds for the in-memory dict.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sqlite3
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from headroom.cache.backends.sqlite import SQLiteBackend
|
|
from headroom.cache.compression_store import CompressionEntry, CompressionStore
|
|
|
|
|
|
def make_entry(hash_key: str = "h1", content: str = "x" * 600, ttl: int = 1800) -> CompressionEntry:
|
|
return CompressionEntry(
|
|
hash=hash_key,
|
|
original_content=content,
|
|
compressed_content="c",
|
|
original_tokens=100,
|
|
compressed_tokens=10,
|
|
original_item_count=50,
|
|
compressed_item_count=5,
|
|
tool_name="Read",
|
|
tool_call_id="t1",
|
|
query_context=None,
|
|
created_at=time.time(),
|
|
ttl=ttl,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def db_path(tmp_path):
|
|
return tmp_path / "ccr_test.db"
|
|
|
|
|
|
class TestSQLiteBackend:
|
|
def test_crud_roundtrip(self, db_path):
|
|
b = SQLiteBackend(db_path)
|
|
entry = make_entry()
|
|
b.set("h1", entry)
|
|
|
|
got = b.get("h1")
|
|
assert got is not None
|
|
assert got.original_content == entry.original_content
|
|
assert got.tool_name == "Read"
|
|
assert got.ttl == 1800
|
|
|
|
assert b.exists("h1")
|
|
assert b.count() == 1
|
|
assert b.keys() == ["h1"]
|
|
assert b.delete("h1")
|
|
assert not b.exists("h1")
|
|
assert not b.delete("h1")
|
|
|
|
def test_survives_reopen(self, db_path):
|
|
"""The restart-survival property the default flip exists for."""
|
|
SQLiteBackend(db_path).set("h1", make_entry())
|
|
|
|
reopened = SQLiteBackend(db_path)
|
|
got = reopened.get("h1")
|
|
assert got is not None
|
|
assert got.original_content == "x" * 600
|
|
|
|
def test_two_connections_share_data(self, db_path):
|
|
"""Multi-worker property: a second live connection sees writes."""
|
|
writer = SQLiteBackend(db_path)
|
|
reader = SQLiteBackend(db_path)
|
|
writer.set("h1", make_entry())
|
|
assert reader.get("h1") is not None
|
|
|
|
def test_items_and_stats(self, db_path):
|
|
b = SQLiteBackend(db_path)
|
|
b.set("h1", make_entry("h1"))
|
|
b.set("h2", make_entry("h2"))
|
|
|
|
items = dict(b.items())
|
|
assert set(items) == {"h1", "h2"}
|
|
stats = b.get_stats()
|
|
assert stats["backend_type"] == "sqlite"
|
|
assert stats["entry_count"] == 2
|
|
assert stats["bytes_used"] > 0
|
|
|
|
def test_clear(self, db_path):
|
|
b = SQLiteBackend(db_path)
|
|
b.set("h1", make_entry())
|
|
b.clear()
|
|
assert b.count() == 0
|
|
|
|
def test_unknown_json_fields_tolerated(self, db_path):
|
|
"""Forward-compat: entries written by a newer headroom version
|
|
(extra fields) must still load."""
|
|
b = SQLiteBackend(db_path)
|
|
b.set("h1", make_entry())
|
|
with b._lock:
|
|
row = b._conn.execute("SELECT entry_json FROM ccr_entries WHERE hash='h1'").fetchone()
|
|
doctored = row[0][:-1] + ', "field_from_the_future": 7}'
|
|
b._conn.execute("UPDATE ccr_entries SET entry_json=? WHERE hash='h1'", (doctored,))
|
|
b._conn.commit()
|
|
|
|
got = b.get("h1")
|
|
assert got is not None
|
|
assert got.original_content == "x" * 600
|
|
|
|
def test_store_ttl_enforcement_via_compression_store(self, db_path):
|
|
"""TTL checks stay in CompressionStore; expired entries miss."""
|
|
store = CompressionStore(backend=SQLiteBackend(db_path))
|
|
expired = make_entry(ttl=1)
|
|
expired.created_at = time.time() - 10
|
|
store._backend.set("h1", expired)
|
|
|
|
assert store.retrieve("h1") is None
|
|
|
|
def test_retrieval_count_persists(self, db_path):
|
|
"""record_access mutations are re-persisted (store re-sets the
|
|
entry after mutating), so feedback counts survive reopen."""
|
|
store = CompressionStore(backend=SQLiteBackend(db_path))
|
|
store._backend.set("h1", make_entry())
|
|
store.retrieve("h1", query="foo")
|
|
|
|
reopened = SQLiteBackend(db_path)
|
|
got = reopened.get("h1")
|
|
assert got is not None
|
|
assert got.retrieval_count == 1
|
|
|
|
|
|
class TestMultiWorkerSafety:
|
|
def test_busy_error_does_not_delete_database(self, db_path):
|
|
"""SQLITE_BUSY (OperationalError, a DatabaseError subclass) under
|
|
multi-worker write contention must be treated as transient — NOT
|
|
as corruption that deletes every stored original."""
|
|
b = SQLiteBackend(db_path)
|
|
b.set("h1", make_entry())
|
|
|
|
class BusyOnceConn:
|
|
"""Delegating wrapper; first SELECT raises 'database is locked'."""
|
|
|
|
def __init__(self, real):
|
|
self._real = real
|
|
self.raised = False
|
|
|
|
def execute(self, *args, **kwargs):
|
|
if not self.raised and args and "SELECT" in args[0]:
|
|
self.raised = True
|
|
raise sqlite3.OperationalError("database is locked")
|
|
return self._real.execute(*args, **kwargs)
|
|
|
|
def __getattr__(self, name):
|
|
return getattr(self._real, name)
|
|
|
|
real = b._conn
|
|
b._conn = BusyOnceConn(real) # type: ignore[assignment]
|
|
assert b.get("h1") is None # transient miss, not a crash
|
|
|
|
b._conn = real
|
|
# The data and the database file both survived.
|
|
assert db_path.exists()
|
|
assert b.get("h1") is not None
|
|
|
|
def test_corruption_message_triggers_reset(self, db_path):
|
|
b = SQLiteBackend(db_path)
|
|
b.set("h1", make_entry())
|
|
b._handle_db_error(sqlite3.DatabaseError("database disk image is malformed"), "get")
|
|
# Database recreated: empty but functional.
|
|
assert b.count() == 0
|
|
b.set("h2", make_entry("h2"))
|
|
assert b.exists("h2")
|
|
|
|
def test_busy_timeout_configured(self, db_path):
|
|
b = SQLiteBackend(db_path)
|
|
timeout = b._conn.execute("PRAGMA busy_timeout").fetchone()[0]
|
|
assert timeout >= 5000
|
|
|
|
def test_expired_rows_purged_on_open(self, db_path):
|
|
b = SQLiteBackend(db_path)
|
|
expired = make_entry(ttl=1)
|
|
expired.created_at = time.time() - 10
|
|
b.set("old", expired)
|
|
b.set("fresh", make_entry("fresh"))
|
|
|
|
reopened = SQLiteBackend(db_path)
|
|
assert not reopened.exists("old") # swept at open
|
|
assert reopened.exists("fresh")
|
|
|
|
@pytest.mark.skipif(os.name != "posix", reason="POSIX permissions")
|
|
def test_database_file_is_private(self, db_path):
|
|
SQLiteBackend(db_path)
|
|
mode = db_path.stat().st_mode & 0o777
|
|
assert mode == 0o600
|
|
|
|
|
|
class TestDefaults:
|
|
def test_session_scale_ttl_lockstep(self):
|
|
"""CCRConfig, CompressionEntry, and CompressionStore must agree."""
|
|
from headroom.config import CCRConfig
|
|
|
|
assert CCRConfig().store_ttl_seconds == 1800
|
|
assert CompressionEntry.__dataclass_fields__["ttl"].default == 1800
|
|
assert CompressionStore()._default_ttl == 1800
|
|
|
|
def test_default_backend_is_sqlite(self, monkeypatch, tmp_path):
|
|
from headroom.cache.compression_store import _create_default_ccr_backend
|
|
|
|
monkeypatch.delenv("HEADROOM_CCR_BACKEND", raising=False)
|
|
monkeypatch.setenv("HEADROOM_CCR_SQLITE_PATH", str(tmp_path / "d.db"))
|
|
backend = _create_default_ccr_backend()
|
|
assert backend is not None
|
|
assert backend.get_stats()["backend_type"] == "sqlite"
|
|
|
|
def test_workspace_dir(self, monkeypatch, tmp_path):
|
|
from headroom.cache.compression_store import _create_default_ccr_backend
|
|
|
|
workspace = tmp_path / "workspace"
|
|
fake_home = tmp_path / "fake_home"
|
|
|
|
monkeypatch.delenv("HEADROOM_CCR_BACKEND", raising=False)
|
|
monkeypatch.delenv("HEADROOM_CCR_SQLITE_PATH", raising=False)
|
|
monkeypatch.setenv("HEADROOM_WORKSPACE_DIR", str(workspace))
|
|
monkeypatch.setenv("HOME", str(fake_home))
|
|
monkeypatch.setenv("USERPROFILE", str(fake_home))
|
|
|
|
backend = _create_default_ccr_backend()
|
|
assert backend is not None
|
|
assert str(backend._path) == str(workspace / "ccr_store.db")
|
|
|
|
def test_sqlite_path_env_wins(self, monkeypatch, tmp_path):
|
|
from headroom.cache.compression_store import _create_default_ccr_backend
|
|
|
|
workspace = tmp_path / "workspace"
|
|
sqlite_path = tmp_path / "sqlite_override.db"
|
|
|
|
monkeypatch.delenv("HEADROOM_CCR_BACKEND", raising=False)
|
|
monkeypatch.setenv("HEADROOM_WORKSPACE_DIR", str(workspace))
|
|
monkeypatch.setenv("HEADROOM_CCR_SQLITE_PATH", str(sqlite_path))
|
|
backend = _create_default_ccr_backend()
|
|
|
|
assert backend is not None
|
|
assert str(backend._path) == str(sqlite_path)
|
|
|
|
def test_home_fallback(self, monkeypatch, tmp_path):
|
|
from headroom.cache.compression_store import _create_default_ccr_backend
|
|
|
|
fake_home = tmp_path / "fake_home"
|
|
|
|
monkeypatch.delenv("HEADROOM_CCR_BACKEND", raising=False)
|
|
monkeypatch.delenv("HEADROOM_CCR_SQLITE_PATH", raising=False)
|
|
monkeypatch.delenv("HEADROOM_WORKSPACE_DIR", raising=False)
|
|
monkeypatch.setenv("HOME", str(fake_home))
|
|
monkeypatch.setenv("USERPROFILE", str(fake_home))
|
|
|
|
backend = _create_default_ccr_backend()
|
|
assert backend is not None
|
|
assert str(backend._path) == str(fake_home / ".headroom" / "ccr_store.db")
|
|
|
|
def test_explicit_db_path(self, tmp_path):
|
|
explicit = tmp_path / "explicit.db"
|
|
backend = SQLiteBackend(explicit)
|
|
assert backend._path == explicit
|
|
|
|
def test_memory_opt_out(self, monkeypatch):
|
|
from headroom.cache.compression_store import _create_default_ccr_backend
|
|
|
|
monkeypatch.setenv("HEADROOM_CCR_BACKEND", "memory")
|
|
assert _create_default_ccr_backend() is None
|
|
|
|
def test_miss_message_is_actionable(self):
|
|
from headroom.cache.compression_store import CCR_MISS_MESSAGE
|
|
|
|
assert "re-read" in CCR_MISS_MESSAGE
|
|
assert "re-run" in CCR_MISS_MESSAGE
|
|
|
|
|
|
if __name__ == "__main__":
|
|
pytest.main([__file__, "-v"])
|