8adef09be8
Review fixes for #94618 (all three blockers reproduced by the reviewer through the real web_extract_tool): 1. Cache lookup moved AFTER provider resolution and strict-selection validation, and gated per-URL on the website blocklist policy — a blocklist-blocked or misconfigured-backend call now behaves exactly as it would without a cache instead of serving cached content. 2. Rescue-served extract batches are never cached (mirrors the search memo's exclusion), keeping one-shot rescue one-shot. 3. Cache entries now get dedicated per-(url, format, provider) files instead of sharing the URL-keyed truncate-store file — html and markdown (or two backends') copies of one URL no longer overwrite each other, and switching extract backends within the TTL never serves the old backend's rendering. Also from review: per-process index tmp filename (cross-process writers can no longer truncate each other mid-write) and held flight locks are never evicted from the bounded lock table (eviction could have allowed a duplicate paid request). New regression tests for formats/provider keying; E2E harness extended with policy-block, strict-selection, rescue-two-call, and dual-format scenarios — 6/6 pass; original 13/13 still pass.
259 lines
9.8 KiB
Python
259 lines
9.8 KiB
Python
"""Tests for tools/web_result_cache.py — TTL memo for web_search and the
|
|
disk-backed extract cache, plus their wiring into web_tools.
|
|
|
|
The cache sits AFTER safety gates and around the paid vendor call only, so
|
|
these tests focus on: hit/miss semantics, TTL expiry, limit bucketing +
|
|
slicing, single-flight coalescing, error non-caching, the disable flag, and
|
|
extract index integrity (tamper = miss, oversized = not indexed).
|
|
"""
|
|
|
|
import json
|
|
import threading
|
|
import time
|
|
|
|
import pytest
|
|
|
|
import tools.web_result_cache as wrc
|
|
from tools.web_result_cache import (
|
|
SearchMemo,
|
|
bucket_limit,
|
|
extract_cache_get,
|
|
extract_cache_put,
|
|
normalize_query,
|
|
slice_search_response,
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolated_cache(tmp_path, monkeypatch):
|
|
"""Point the extract cache at a temp dir and force cache-on defaults."""
|
|
cache_dir = tmp_path / "cache" / "web"
|
|
cache_dir.mkdir(parents=True)
|
|
monkeypatch.setattr(wrc, "_cache_dir", lambda: cache_dir)
|
|
monkeypatch.setattr(wrc, "_web_config", lambda: {})
|
|
yield cache_dir
|
|
|
|
|
|
def _ok_response(n=10):
|
|
return {
|
|
"success": True,
|
|
"data": {"web": [
|
|
{"title": f"t{i}", "url": f"https://e.com/{i}", "description": "d"}
|
|
for i in range(n)
|
|
]},
|
|
}
|
|
|
|
|
|
# ── bucketing / normalization ────────────────────────────────────────────
|
|
|
|
def test_bucket_limit_rounds_up():
|
|
assert bucket_limit(1) == 10
|
|
assert bucket_limit(10) == 10
|
|
assert bucket_limit(11) == 20
|
|
assert bucket_limit(50) == 50
|
|
assert bucket_limit(99) == 100
|
|
assert bucket_limit(500) == 100
|
|
|
|
|
|
def test_normalize_query_folds_case_and_whitespace():
|
|
assert normalize_query(" Weather in\tVegas ") == "weather in vegas"
|
|
|
|
|
|
def test_slice_search_response_trims_to_requested_limit():
|
|
sliced = slice_search_response(_ok_response(10), 3)
|
|
assert len(sliced["data"]["web"]) == 3
|
|
# original untouched (defensive copy)
|
|
assert len(_ok_response(10)["data"]["web"]) == 10
|
|
|
|
|
|
# ── search memo ──────────────────────────────────────────────────────────
|
|
|
|
def test_search_memo_hit_within_ttl():
|
|
memo = SearchMemo()
|
|
memo.store("firecrawl", "weather in vegas", 5, _ok_response())
|
|
hit = memo.lookup("firecrawl", "Weather In Vegas", 8) # same bucket (10)
|
|
assert hit is not None and hit["success"]
|
|
|
|
|
|
def test_search_memo_miss_across_providers_and_buckets():
|
|
memo = SearchMemo()
|
|
memo.store("firecrawl", "q", 5, _ok_response())
|
|
assert memo.lookup("tavily", "q", 5) is None # different provider
|
|
assert memo.lookup("firecrawl", "q", 15) is None # different bucket
|
|
assert memo.lookup("firecrawl", "other", 5) is None # different query
|
|
|
|
|
|
def test_search_memo_expires_after_ttl(monkeypatch):
|
|
memo = SearchMemo()
|
|
memo.store("firecrawl", "q", 5, _ok_response())
|
|
monkeypatch.setattr(wrc, "ttl_seconds", lambda: 0.0)
|
|
# store used the old TTL; force expiry by faking monotonic forward
|
|
real = time.monotonic
|
|
monkeypatch.setattr(time, "monotonic", lambda: real() + 100 * 3600)
|
|
assert memo.lookup("firecrawl", "q", 5) is None
|
|
|
|
|
|
def test_search_memo_never_caches_failures():
|
|
memo = SearchMemo()
|
|
memo.store("firecrawl", "q", 5, {"success": False, "error": "boom"})
|
|
assert memo.lookup("firecrawl", "q", 5) is None
|
|
|
|
|
|
def test_search_memo_disabled_by_config(monkeypatch):
|
|
monkeypatch.setattr(wrc, "_web_config", lambda: {"cache_enabled": False})
|
|
memo = SearchMemo()
|
|
memo.store("firecrawl", "q", 5, _ok_response())
|
|
assert memo.lookup("firecrawl", "q", 5) is None
|
|
|
|
|
|
def test_search_memo_hit_returns_copy():
|
|
memo = SearchMemo()
|
|
memo.store("firecrawl", "q", 5, _ok_response())
|
|
first = memo.lookup("firecrawl", "q", 5)
|
|
first["data"]["web"].clear()
|
|
second = memo.lookup("firecrawl", "q", 5)
|
|
assert len(second["data"]["web"]) == 10
|
|
|
|
|
|
def test_single_flight_coalesces_concurrent_identical_queries():
|
|
"""Two threads race the same query: exactly one paid call happens."""
|
|
memo = SearchMemo()
|
|
calls = []
|
|
barrier = threading.Barrier(2)
|
|
results = []
|
|
|
|
def worker():
|
|
barrier.wait()
|
|
resp = memo.lookup("p", "q", 5)
|
|
if resp is None:
|
|
with memo.flight_lock("p", "q", 5):
|
|
resp = memo.lookup("p", "q", 5)
|
|
if resp is None:
|
|
calls.append(1) # the "paid" request
|
|
time.sleep(0.05) # widen the race window
|
|
resp = _ok_response()
|
|
memo.store("p", "q", 5, resp)
|
|
results.append(resp)
|
|
|
|
threads = [threading.Thread(target=worker) for _ in range(2)]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join()
|
|
|
|
assert len(calls) == 1, "concurrent identical queries must share one request"
|
|
assert len(results) == 2 and all(r["success"] for r in results)
|
|
|
|
|
|
# ── extract cache ────────────────────────────────────────────────────────
|
|
|
|
def test_extract_cache_roundtrip(_isolated_cache):
|
|
extract_cache_put("https://example.com/a", "hello world", title="T")
|
|
hit = extract_cache_get("https://example.com/a")
|
|
assert hit is not None
|
|
assert hit["content"] == "hello world"
|
|
assert hit["title"] == "T"
|
|
assert hit["cached"] is True
|
|
|
|
|
|
def test_extract_cache_expired_entry_is_miss(monkeypatch, _isolated_cache):
|
|
extract_cache_put("https://e.com", "x")
|
|
monkeypatch.setattr(wrc, "ttl_seconds", lambda: 0.0)
|
|
assert extract_cache_get("https://e.com") is None
|
|
|
|
|
|
def test_extract_cache_format_participates_in_key(_isolated_cache):
|
|
extract_cache_put("https://e.com", "md content", format="markdown")
|
|
assert extract_cache_get("https://e.com", format="html") is None
|
|
assert extract_cache_get("https://e.com", format="markdown") is not None
|
|
|
|
|
|
def test_extract_cache_formats_do_not_overwrite_each_other(_isolated_cache):
|
|
"""Regression (#94618 review finding 3): html and markdown copies of one
|
|
URL must be stored independently — the original implementation shared a
|
|
URL-keyed backing file, so the later write clobbered the earlier one."""
|
|
extract_cache_put("https://e.com/page", "# MARKDOWN VERSION", format="markdown")
|
|
extract_cache_put("https://e.com/page", "<h1>HTML VERSION</h1>", format="html")
|
|
md = extract_cache_get("https://e.com/page", format="markdown")
|
|
html = extract_cache_get("https://e.com/page", format="html")
|
|
assert md is not None and md["content"] == "# MARKDOWN VERSION"
|
|
assert html is not None and html["content"] == "<h1>HTML VERSION</h1>"
|
|
|
|
|
|
def test_extract_cache_provider_participates_in_key(_isolated_cache):
|
|
"""Switching extract backends within the TTL must not serve the old
|
|
backend's rendering (#94618 review, additional risk 3)."""
|
|
extract_cache_put("https://e.com/p", "firecrawl version", provider="firecrawl")
|
|
assert extract_cache_get("https://e.com/p", provider="tavily") is None
|
|
hit = extract_cache_get("https://e.com/p", provider="firecrawl")
|
|
assert hit is not None and hit["content"] == "firecrawl version"
|
|
|
|
|
|
def test_extract_cache_oversized_page_not_indexed(_isolated_cache):
|
|
import tools.web_tools as wt
|
|
big = "x" * (wt.MAX_STORED_TEXT_CHARS + 1)
|
|
extract_cache_put("https://big.com", big)
|
|
assert extract_cache_get("https://big.com") is None
|
|
|
|
|
|
def test_extract_cache_tampered_index_path_is_miss(_isolated_cache, tmp_path):
|
|
"""An index entry pointing outside cache/web must never be read."""
|
|
outside = tmp_path / "outside.md"
|
|
outside.write_text("secret", encoding="utf-8")
|
|
index = {
|
|
wrc._url_digest("https://evil.com", None): {
|
|
"url": "https://evil.com",
|
|
"file": str(outside),
|
|
"title": "",
|
|
"fetched_at": time.time(),
|
|
}
|
|
}
|
|
(_isolated_cache / wrc._INDEX_FILENAME).write_text(json.dumps(index))
|
|
assert extract_cache_get("https://evil.com") is None
|
|
|
|
|
|
def test_extract_cache_missing_file_is_miss(_isolated_cache):
|
|
index = {
|
|
wrc._url_digest("https://gone.com", None): {
|
|
"url": "https://gone.com",
|
|
"file": str(_isolated_cache / "pruned.md"),
|
|
"title": "",
|
|
"fetched_at": time.time(),
|
|
}
|
|
}
|
|
(_isolated_cache / wrc._INDEX_FILENAME).write_text(json.dumps(index))
|
|
assert extract_cache_get("https://gone.com") is None
|
|
|
|
|
|
def test_extract_cache_corrupt_index_is_empty(_isolated_cache):
|
|
(_isolated_cache / wrc._INDEX_FILENAME).write_text("{not json")
|
|
assert extract_cache_get("https://any.com") is None
|
|
|
|
|
|
def test_extract_cache_disabled_by_config(monkeypatch, _isolated_cache):
|
|
extract_cache_put("https://e.com", "x")
|
|
monkeypatch.setattr(wrc, "_web_config", lambda: {"cache_enabled": False})
|
|
assert extract_cache_get("https://e.com") is None
|
|
|
|
|
|
def test_index_eviction_keeps_newest(monkeypatch, _isolated_cache):
|
|
monkeypatch.setattr(wrc, "_INDEX_MAX_ENTRIES", 3)
|
|
now = time.time()
|
|
index = {
|
|
f"digest{i}": {"url": f"u{i}", "file": "f", "fetched_at": now + i}
|
|
for i in range(6)
|
|
}
|
|
wrc._save_index(index)
|
|
saved = json.loads((_isolated_cache / wrc._INDEX_FILENAME).read_text())
|
|
assert len(saved) == 3
|
|
assert set(saved) == {"digest3", "digest4", "digest5"}
|
|
|
|
|
|
def test_ttl_clamping(monkeypatch):
|
|
monkeypatch.setattr(wrc, "_web_config", lambda: {"cache_ttl_minutes": 0})
|
|
assert wrc.ttl_seconds() == 60.0 # floor 1 minute
|
|
monkeypatch.setattr(wrc, "_web_config", lambda: {"cache_ttl_minutes": 99999})
|
|
assert wrc.ttl_seconds() == 1440 * 60.0 # ceiling 24h
|
|
monkeypatch.setattr(wrc, "_web_config", lambda: {"cache_ttl_minutes": "bogus"})
|
|
assert wrc.ttl_seconds() == 20 * 60.0 # default on garbage
|