"""SQLite-backed result cache for searxng-cli. Avoids re-hitting the SearXNG instance for identical queries within a configurable TTL. Cache key is a SHA-256 of the normalized search params (query + engines + categories + language + time_range + safesearch + pageno + method), so different parameter combinations get separate entries. Storage location (in priority order): 1. ``$SEARXNG_CACHE_DIR`` env var (directory; ``cache.db`` is created inside) 2. ``~/.cache/searxng-cli/cache.db`` (XDG-style; on Windows this resolves to ``C:\\Users\\\\.cache\\searxng-cli\\cache.db``) Uses WAL journal mode for better read concurrency. Entries expire lazily on read; :func:`clear` removes all rows. Schema is created on first use. 大小上限与 LRU 淘汰(v2): * :class:`SearchCache` 支持 ``max_size_bytes`` 参数(默认 100 MB,0 表示 不限制,向后兼容)。put 时若总大小超限,按 ``last_accessed_at`` 升序 淘汰最旧条目,直到总大小 <= max_size_bytes。 * get 命中时更新 ``last_accessed_at``,实现 LRU 语义。 * :meth:`SearchCache.evict_expired` 可主动清理已过期条目。 * ``$SEARXNG_CACHE_MAX_SIZE_BYTES`` 环境变量可覆盖默认上限。 Design notes: * Only the search result dict is cached — fetched page content is NOT, because it is large and changes independently of the search result set. * The cache key excludes auth headers and timeouts (transient concerns) so two callers with the same query + params share an entry. * All operations swallow ``sqlite3.Error`` and degrade gracefully — a cache failure must never break a search. """ import contextlib import hashlib import json import os import sqlite3 import time from pathlib import Path DEFAULT_CACHE_DIR = Path.home() / ".cache" / "searxng-cli" # 默认缓存大小上限:100 MB;0 表示不限制(向后兼容) DEFAULT_MAX_SIZE_BYTES = 104857600 def _cache_path() -> Path: """Resolve the cache database path from env var or default location.""" env = os.environ.get("SEARXNG_CACHE_DIR") if env: return Path(env) / "cache.db" return DEFAULT_CACHE_DIR / "cache.db" def _ensure_schema(conn: sqlite3.Connection) -> None: """创建表结构并执行向后兼容的 schema 迁移。 v1 schema: key, created_at, ttl_seconds, payload v2 新增列: last_accessed_at (LRU 排序依据), size_bytes (payload 字节大小) 对已存在的旧表用 ALTER TABLE ADD COLUMN 添加新列,并回填数据, 保证升级后现有条目也能参与大小统计与 LRU 淘汰。 """ conn.execute( """ CREATE TABLE IF NOT EXISTS search_cache ( key TEXT PRIMARY KEY, created_at REAL NOT NULL, ttl_seconds INTEGER NOT NULL, payload TEXT NOT NULL ) """ ) # 检查现有列,决定是否需要迁移 cols = {row[1] for row in conn.execute("PRAGMA table_info(search_cache)").fetchall()} if "last_accessed_at" not in cols: # 新增 LRU 访问时间列,回填为 created_at(视为从未被访问过) conn.execute("ALTER TABLE search_cache ADD COLUMN last_accessed_at REAL") conn.execute( "UPDATE search_cache SET last_accessed_at = created_at " "WHERE last_accessed_at IS NULL" ) if "size_bytes" not in cols: # 新增 payload 字节大小列,回填为 payload 的 UTF-8 字节长度 conn.execute("ALTER TABLE search_cache ADD COLUMN size_bytes INTEGER") rows = conn.execute( "SELECT key, payload FROM search_cache WHERE size_bytes IS NULL" ).fetchall() for key, payload in rows: conn.execute( "UPDATE search_cache SET size_bytes = ? WHERE key = ?", (len(payload.encode("utf-8")), key), ) conn.commit() def _connect(path: Path): """Open a connection with WAL mode and ensure the schema exists. Returns a :class:`contextlib.closing` wrapper so ``with _connect(...) as conn:`` closes the connection on exit. ``sqlite3.Connection.__exit__`` only commits / rolls back the transaction — it does **not** call ``close()``, which leaks file descriptors across many cache operations (especially under ``--queries-file`` + ``--cache-ttl`` batch runs). """ path.parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(str(path), timeout=10) # WAL allows concurrent readers alongside a single writer, which matters # when --fetch spawns parallel page fetches that might also touch the cache. conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA synchronous=NORMAL") _ensure_schema(conn) return contextlib.closing(conn) def _make_key(params: dict) -> str: """Build a stable cache key from search params. Only params that affect the result set are included; transient fields (auth, timeout, format) are excluded so the same logical query hits the same cache entry regardless of output formatting. """ # Whitelist the params that actually change what SearXNG returns. # fmt: off relevant = ( "q", "categories", "language", "pageno", "time_range", "safesearch", "engines", "method", ) # fmt: on normalized = {k: params[k] for k in relevant if params.get(k)} raw = json.dumps(normalized, sort_keys=True, ensure_ascii=False) return hashlib.sha256(raw.encode("utf-8")).hexdigest() def _payload_size(payload: str) -> int: """计算 payload 序列化后的 UTF-8 字节大小。""" return len(payload.encode("utf-8")) class SearchCache: """带大小上限和 LRU 淘汰的 SQLite 缓存。 Args: path: 缓存数据库路径。None 表示使用 ``$SEARXNG_CACHE_DIR`` 或 默认路径(每次操作动态解析,便于测试 monkeypatch 环境变量)。 max_size_bytes: 缓存总大小上限(字节)。0 表示不限制(向后兼容)。 大小跟踪与 LRU 语义: * put 时计算 payload 字节大小并维护 ``_total_bytes`` 计数器。 * 若加入新条目后总大小超过 ``max_size_bytes``,按 ``last_accessed_at`` 升序淘汰最旧条目。 * get 命中时更新 ``last_accessed_at``,将条目移到"最近使用"位置。 """ def __init__(self, path: Path = None, max_size_bytes: int = DEFAULT_MAX_SIZE_BYTES): self._path_override = Path(path) if path else None self._max_size_bytes = max_size_bytes self._total_bytes = 0 self._evicted_count = 0 self._total_bytes_loaded = False # 记录上次加载 _total_bytes 时的路径,路径变化时重新加载 self._loaded_path = None def _resolve_path(self) -> Path: """解析当前应使用的缓存路径(未显式指定时动态读取环境变量)。""" return self._path_override or _cache_path() def _load_total_bytes(self, conn: sqlite3.Connection) -> None: """惰性从 DB 加载 _total_bytes;路径变化时重新加载。""" current_path = str(self._resolve_path()) if self._total_bytes_loaded and self._loaded_path == current_path: return row = conn.execute( "SELECT COALESCE(SUM(size_bytes), 0) FROM search_cache" ).fetchone() self._total_bytes = row[0] or 0 self._total_bytes_loaded = True self._loaded_path = current_path def get(self, params: dict, ttl_seconds: int): """Return cached result if within TTL, else None. ``ttl_seconds`` is the caller's current TTL setting. If the stored entry was written with a longer TTL, the caller's shorter TTL wins (so reducing --cache-ttl takes effect immediately without a clear). 命中时更新 ``last_accessed_at`` 以实现 LRU 语义。 """ if ttl_seconds <= 0: return None key = _make_key(params) try: with _connect(self._resolve_path()) as conn: self._load_total_bytes(conn) row = conn.execute( "SELECT payload, created_at, ttl_seconds FROM search_cache " "WHERE key = ?", (key,), ).fetchone() if row is None: return None payload, created_at, stored_ttl = row effective_ttl = min(ttl_seconds, stored_ttl) if time.time() - created_at > effective_ttl: return None # LRU: 命中时把条目移到"最近使用"位置 conn.execute( "UPDATE search_cache SET last_accessed_at = ? WHERE key = ?", (time.time(), key), ) conn.commit() return json.loads(payload) except sqlite3.Error: return None except (ValueError, json.JSONDecodeError): # Corrupt payload — treat as miss return None def put(self, params: dict, result: dict, ttl_seconds: int) -> None: """Store a result with the given TTL. Silently no-ops on TTL<=0 or error. 若加入新条目后总大小超过 ``max_size_bytes``,按 LRU 淘汰最旧条目。 """ if ttl_seconds <= 0: return key = _make_key(params) payload = json.dumps(result, ensure_ascii=False) new_size = _payload_size(payload) try: with _connect(self._resolve_path()) as conn: self._load_total_bytes(conn) # 若 key 已存在,先减去旧条目大小,避免重复计入 old = conn.execute( "SELECT size_bytes FROM search_cache WHERE key = ?", (key,) ).fetchone() if old is not None: self._total_bytes -= (old[0] or 0) now = time.time() conn.execute( "INSERT OR REPLACE INTO search_cache " "(key, created_at, ttl_seconds, payload, " " last_accessed_at, size_bytes) VALUES (?, ?, ?, ?, ?, ?)", (key, now, ttl_seconds, payload, now, new_size), ) self._total_bytes += new_size self._enforce_size_limit(conn) conn.commit() except sqlite3.Error: pass def _enforce_size_limit(self, conn: sqlite3.Connection) -> None: """总大小超限时按 LRU 淘汰最旧条目,直到总大小 <= max_size_bytes。 ``max_size_bytes <= 0`` 表示不限制,直接返回。当仅剩一个条目时 停止淘汰(避免 put 后立即被淘汰导致 get 不到刚写入的条目)。 """ if self._max_size_bytes <= 0: return while self._total_bytes > self._max_size_bytes: row = conn.execute( "SELECT key, size_bytes FROM search_cache " "ORDER BY last_accessed_at ASC, created_at ASC LIMIT 1" ).fetchone() if row is None: break evict_key, evict_size = row conn.execute("DELETE FROM search_cache WHERE key = ?", (evict_key,)) self._total_bytes -= (evict_size or 0) self._evicted_count += 1 # 安全阀:只剩一个条目时停止淘汰(即新插入的条目本身超限也保留) count_row = conn.execute("SELECT COUNT(*) FROM search_cache").fetchone() if count_row[0] <= 1: break def clear(self) -> int: """Remove all cache entries. Returns count deleted, or 0 on error.""" try: with _connect(self._resolve_path()) as conn: self._load_total_bytes(conn) cur = conn.execute("DELETE FROM search_cache") conn.commit() self._total_bytes = 0 return cur.rowcount except sqlite3.Error: return 0 def stats(self) -> dict: """Return cache statistics. 现有字段:entries, oldest_created_at, newest_created_at, path, size_bytes 新增字段:total_bytes, max_size_bytes, evicted_count, utilization_pct """ path = self._resolve_path() try: with _connect(path) as conn: row = conn.execute( "SELECT COUNT(*), MIN(created_at), MAX(created_at) " "FROM search_cache" ).fetchone() count, oldest, newest = row # 从 DB 校准 total_bytes,防止外部进程修改导致计数器漂移 sum_row = conn.execute( "SELECT COALESCE(SUM(size_bytes), 0) FROM search_cache" ).fetchone() self._total_bytes = sum_row[0] or 0 self._total_bytes_loaded = True self._loaded_path = str(path) utilization = ( round(self._total_bytes * 100.0 / self._max_size_bytes, 2) if self._max_size_bytes > 0 else 0 ) return { "entries": count or 0, "oldest_created_at": oldest, "newest_created_at": newest, "path": str(path), "size_bytes": path.stat().st_size if path.exists() else 0, # 新增字段:大小上限与 LRU 统计 "total_bytes": self._total_bytes, "max_size_bytes": self._max_size_bytes, "evicted_count": self._evicted_count, "utilization_pct": utilization, } except sqlite3.Error as e: return { "entries": 0, "error": str(e), "path": str(path), "size_bytes": 0, "total_bytes": 0, "max_size_bytes": self._max_size_bytes, "evicted_count": self._evicted_count, "utilization_pct": 0, } def evict_expired(self) -> int: """主动扫描并删除已过期条目,返回清理的条目数。 过期条件:``now - created_at > ttl_seconds``。清理时同步更新 ``_total_bytes`` 计数器。 """ now = time.time() try: with _connect(self._resolve_path()) as conn: self._load_total_bytes(conn) rows = conn.execute( "SELECT key, size_bytes FROM search_cache " "WHERE ? - created_at > ttl_seconds", (now,), ).fetchall() if not rows: return 0 for key, size in rows: conn.execute("DELETE FROM search_cache WHERE key = ?", (key,)) self._total_bytes -= (size or 0) conn.commit() return len(rows) except sqlite3.Error: return 0 def set_max_size_bytes(self, max_size_bytes: int) -> None: """更新大小上限并立即触发 LRU 淘汰(若当前已超限)。 供 CLI ``--cache-max-size`` 在运行时注入参数用。``max_size_bytes <= 0`` 表示不限制。 """ self._max_size_bytes = max_size_bytes if max_size_bytes <= 0: return try: with _connect(self._resolve_path()) as conn: self._load_total_bytes(conn) self._enforce_size_limit(conn) conn.commit() except sqlite3.Error: pass # ----- 模块级便捷 API(向后兼容)----- # 现有调用方(search.py、测试)使用模块级函数;这里委托给一个惰性创建的 # 全局实例。全局实例的路径动态解析,因此 monkeypatch $SEARXNG_CACHE_DIR # 能正常隔离每个测试。 _default_cache = None def _get_default_cache() -> SearchCache: """惰性创建全局默认缓存实例。 ``max_size_bytes`` 从 ``$SEARXNG_CACHE_MAX_SIZE_BYTES`` 读取(非法值回退默认)。 创建后可被 :func:`set_max_size_bytes` 覆盖(CLI ``--cache-max-size`` 优先级 高于环境变量)。 """ global _default_cache if _default_cache is None: max_size = DEFAULT_MAX_SIZE_BYTES env = os.environ.get("SEARXNG_CACHE_MAX_SIZE_BYTES") if env: try: max_size = int(env) except ValueError: pass _default_cache = SearchCache(max_size_bytes=max_size) return _default_cache def get(params: dict, ttl_seconds: int): """模块级便捷函数:委托给全局默认实例。""" return _get_default_cache().get(params, ttl_seconds) def put(params: dict, result: dict, ttl_seconds: int) -> None: """模块级便捷函数:委托给全局默认实例。""" _get_default_cache().put(params, result, ttl_seconds) def clear() -> int: """模块级便捷函数:委托给全局默认实例。""" return _get_default_cache().clear() def stats() -> dict: """模块级便捷函数:委托给全局默认实例。""" return _get_default_cache().stats() def evict_expired() -> int: """模块级便捷函数:委托给全局默认实例。""" return _get_default_cache().evict_expired() def set_max_size_bytes(max_size_bytes: int) -> None: """模块级便捷函数:更新全局默认实例的大小上限。 供 search.py 的 ``--cache-max-size`` CLI 参数在 main() 早期注入用, 优先级高于 ``$SEARXNG_CACHE_MAX_SIZE_BYTES`` 环境变量。 """ _get_default_cache().set_max_size_bytes(max_size_bytes)