diff --git a/conjurer_librarian/conjurer_librarian.py b/conjurer_librarian/conjurer_librarian.py index 002a31c..f080020 100644 --- a/conjurer_librarian/conjurer_librarian.py +++ b/conjurer_librarian/conjurer_librarian.py @@ -14,6 +14,7 @@ Functions: """ import asyncio +import hashlib import json import logging import os @@ -135,6 +136,42 @@ CHECKPOINT_DIR = _env("CONJURER_LIBRARIAN_CHECKPOINTS", os.path.join(lib_paths.S _requests = DiskQueue(REQUESTS_DIR) _checkpoints = DiskQueue(CHECKPOINT_DIR) +# Simple result cache: a repeat of the same query (normalised) skips the whole +# Crossref + DB-scan and returns the stored hits. Disk-backed so it survives a +# restart, TTL'd, and size-bounded. Set CONJURER_LIBRARIAN_CACHE_TTL=0 to disable. +CACHE_DIR = _env("CONJURER_LIBRARIAN_CACHE", os.path.join(lib_paths.STATE_DIR, "cache")) +CACHE_TTL_SECONDS = int(_env("CONJURER_LIBRARIAN_CACHE_TTL", str(7 * 24 * 3600))) +CACHE_MAX_ENTRIES = int(_env("CONJURER_LIBRARIAN_CACHE_MAX", "500")) +_cache = DiskQueue(CACHE_DIR) + + +def _cache_key(query, deep_search) -> str: + """Stable key for a query: whitespace-normalised, case-insensitive, and + scoped by deep vs shallow (they return different result sets).""" + normalised = " ".join(str(query).lower().split()) + return hashlib.sha256(f"{int(bool(deep_search))}:{normalised}".encode("utf-8")).hexdigest() + + +def _cache_get(query, deep_search): + """Return the cached final_result for this query, or None on miss/expiry.""" + if CACHE_TTL_SECONDS <= 0: + return None + entry = _cache.get(_cache_key(query, deep_search)) + if not entry or entry.get("expires", 0) < time.time(): + return None + return entry.get("final_result") + + +def _cache_put(query, deep_search, final_result) -> None: + """Store a completed search's hits, with a TTL, and bound the cache size.""" + if CACHE_TTL_SECONDS <= 0: + return + _cache.put( + _cache_key(query, deep_search), + {"query": str(query), "final_result": final_result, "expires": time.time() + CACHE_TTL_SECONDS}, + ) + _cache.prune(CACHE_MAX_ENTRIES) + def _forget_search(uuid) -> None: """A search is fully done (or abandoned): drop its persisted request and any @@ -536,6 +573,15 @@ class Librarian(object): "positions": checkpoint.get("positions", {}), } else: + # Cache hit: an identical (normalised) query ran recently - return its + # stored hits and skip Crossref + the whole DB scan entirely. + cached = _cache_get(self.query, deep_search) + if cached is not None: + self.final_result = cached + self.app.logger.info( + "Cache HIT for %s (%d hits): %s", self.uuid, len(cached), self.query + ) + return self.final_result self.app.logger.info(f"Search started {self.uuid}") cr_result = await self.search_crossref(query=self.query, deep_search=deep_search) refined_result = await self.refine_search(cr_result) @@ -552,6 +598,8 @@ class Librarian(object): for item in negative_answer: self.not_in_db[item["DOI"]] = {"Title": item["data"]["title"], "type": item["data"]["type"]} self.app.logger.info("Search %s produced %d hits", self.uuid, len(self.final_result)) + # Cache the completed result so a repeat of this query is instant. + _cache_put(self.query, deep_search, self.final_result) return self.final_result # ============================= FLASK INTERNALS=============================== diff --git a/tests/integration/test_librarian_cache.py b/tests/integration/test_librarian_cache.py new file mode 100644 index 0000000..82a03cb --- /dev/null +++ b/tests/integration/test_librarian_cache.py @@ -0,0 +1,70 @@ +"""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