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
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:
@@ -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===============================
|
||||
|
||||
Reference in New Issue
Block a user