Librarian: simple result cache for repeated queries
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

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>
This commit was merged in pull request #19.
This commit is contained in:
2026-08-03 20:07:38 +02:00
parent d4c6d78c2e
commit f4dea53502
2 changed files with 118 additions and 0 deletions
+48
View File
@@ -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===============================