Files
conjurer/tests/integration/test_librarian_cache.py
T
gitea f4dea53502
CI / compile (pull_request) Successful in 8s
CI / unit (pull_request) Successful in 26s
CI / integration (pull_request) Successful in 26s
build / build (push) Successful in 25s
CI / compile (push) Successful in 9s
CI / unit (push) Successful in 27s
CI / integration (push) Successful in 27s
Librarian: simple result cache for repeated queries
A repeat of the same query (whitespace/case-normalised, scoped by
deep-vs-shallow) returns the stored hits and skips the whole Crossref call
and DB scan. Disk-backed (survives restart), TTL'd
(CONJURER_LIBRARIAN_CACHE_TTL, default 7d; 0 disables) and size-bounded
(CONJURER_LIBRARIAN_CACHE_MAX, default 500). Reuses DiskQueue, so it's a
handful of lines. Nothing fancy - exact (normalised) match, not fuzzy.

Checked before Crossref only on a fresh search (a resume from checkpoint
still continues its scan), and stored after a completed search.

Tests: hit/miss, normalisation, deep/shallow separation, expiry, disable,
prune. Suite: 58 unit + 59 integration green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 20:07:38 +02:00

71 lines
2.2 KiB
Python

"""Integration: the librarian's simple result cache.
A repeat of the same query (normalised) returns stored hits and skips the whole
Crossref + DB scan. Nothing fancy: whitespace/case-insensitive exact match,
disk-backed, TTL'd, size-bounded, deep/shallow kept separate.
"""
import sys
import types
import pytest
if "habanero" not in sys.modules:
_habanero = types.ModuleType("habanero")
_habanero.Crossref = object
sys.modules["habanero"] = _habanero
import conjurer_librarian as lib # noqa: E402
from durable_queue import DiskQueue # noqa: E402
@pytest.fixture
def cache(tmp_path, monkeypatch):
box = DiskQueue(str(tmp_path / "cache"))
monkeypatch.setattr(lib, "_cache", box)
monkeypatch.setattr(lib, "CACHE_TTL_SECONDS", 3600)
monkeypatch.setattr(lib, "CACHE_MAX_ENTRIES", 500)
return box
_HITS = {"10.1000/x": {"Title": ["A Paper"], "type": "journal-article"}}
def test_put_then_get_is_a_hit(cache):
lib._cache_put("kwas foliowy", False, _HITS)
assert lib._cache_get("kwas foliowy", False) == _HITS
def test_different_query_misses(cache):
lib._cache_put("kwas foliowy", False, _HITS)
assert lib._cache_get("witamina c", False) is None
def test_normalised_case_and_whitespace_hit_same_entry(cache):
lib._cache_put(" Kwas Foliowy ", False, _HITS)
assert lib._cache_get("kwas foliowy", False) == _HITS
def test_deep_and_shallow_are_cached_separately(cache):
lib._cache_put("q", False, _HITS)
assert lib._cache_get("q", True) is None # a deep search is a different key
assert lib._cache_get("q", False) == _HITS
def test_expired_entry_is_a_miss(cache):
cache.put(lib._cache_key("q", False), {"query": "q", "final_result": _HITS, "expires": 0})
assert lib._cache_get("q", False) is None
def test_ttl_zero_disables_the_cache(cache, monkeypatch):
monkeypatch.setattr(lib, "CACHE_TTL_SECONDS", 0)
lib._cache_put("q", False, _HITS) # no-op when disabled
assert len(cache) == 0
assert lib._cache_get("q", False) is None
def test_prune_bounds_the_cache(cache, monkeypatch):
monkeypatch.setattr(lib, "CACHE_MAX_ENTRIES", 3)
for i in range(6):
lib._cache_put(f"query-{i}", False, _HITS)
assert len(cache) <= 3