perf(citation-view): cache מבוסס-טביעה — 24.6 שנ' לטעינה חוזרת → 0.01 שנ'
הרכבת תצוגת אימות-הפסיקה עולה 20+ שניות של embeds וחיפושים וקטוריים,
והתוצאה משתנה רק כשמשתנים הקלטים שלה. עד עכשיו כל פתיחת-דף שילמה את
המחיר המלא מחדש, וכל רענון תוך-כדי-טעינה הוסיף הרצה מקבילה — כך העומס
המקורי התגלגל ל-deadlocks ב-Postgres.
למה טביעה ולא TTL: יו"ר שמאמת תקדים חייב לראות זאת בטעינה **הבאה**, לא
כשיפוג טיימר. `_inputs_fingerprint` הוא שאילתה אחת זולה שמסכמת בדיוק את
מה שהתצוגה נגזרת ממנו — טיעונים · שיוכי-פסיקה · גודל-הקורפוס. השתנה
משהו → הטביעה זזה → נבנה מחדש. **הנכונות אינה תלויה בכך שמישהו יזכור
לבטל** (G1): הבדיקה יושבת במקור-האמת ולא בזיכרון של כל כותב. `invalidate()`
קיים לנוחות, לא לתקינות.
מה שונה
- cache פר-תיק, מפתח = טביעת-הקלטים. תצוגה שלמה אינה פגה בטיימר.
- **מנעול פר-תיק נגד היצף** — שש טעינות מקבילות מריצות הרכבה אחת. זה
בדיוק התרחיש שהפיל את המערכת.
- תצוגה **חלקית** (תקציב-האחזור נגמר) נשמרת ל-90 שנ' בלבד: מספיק כדי
לעצור סופת-רענונים, לא מספיק כדי שההצעות החסרות ייתקעו לנצח.
- `cached: true/false` בתשובה, ו-`use_cache=False` למי שצריך רענון כפוי.
מדידה מול הקורפוס החי (8124-09-24):
טעינה קרה ........ 24.6 שנ' cached=false
טעינה חוזרת ...... 0.01 שנ' cached=true ← פי 2,074
5 מקבילות ........ 0.02 שנ' כולן מה-cache
אחרי invalidate .. 24.8 שנ' cached=false
invariants: G1 (השער במקור-האמת, לא בכל כותב) · §6 (חלקי מסומן ופג,
לא נשמר כשלם)
טסטים: 6 חדשים (tests/test_citation_view_cache.py) — כולל היצף-מקבילי,
פקיעת תצוגה חלקית, וביטול-אוטומטי בלי קריאה מפורשת ל-invalidate.
537 עוברים.
This commit is contained in:
125
mcp-server/tests/test_citation_view_cache.py
Normal file
125
mcp-server/tests/test_citation_view_cache.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""The citation view is cached by input fingerprint, not by a guessed TTL.
|
||||
|
||||
Assembling it costs 20+ seconds of Voyage embeds and vector searches. A TTL
|
||||
would force a choice between staleness and cost: a chair who verifies a
|
||||
precedent must see it on the very next load, and a timer cannot promise that.
|
||||
Keying on a fingerprint of the inputs (arguments · attachments · corpus) makes
|
||||
freshness a property of the data rather than of the clock.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from legal_mcp.services import case_citation_verification as ccv
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_cache():
|
||||
ccv.invalidate()
|
||||
yield
|
||||
ccv.invalidate()
|
||||
|
||||
|
||||
def _stub(monkeypatch, fingerprint: str, calls: list, *, complete: bool = True):
|
||||
"""Point build_view at a fake case, a controllable fingerprint, and a
|
||||
counted assembler, so these tests never touch the corpus."""
|
||||
async def fake_case(_cn):
|
||||
return {"id": "00000000-0000-0000-0000-000000000001"}
|
||||
|
||||
async def fake_fp(_cid):
|
||||
return fingerprint
|
||||
|
||||
async def fake_build(cn):
|
||||
calls.append(cn)
|
||||
return {"status": "ok", "case_number": cn, "arguments": [],
|
||||
"retrieval_complete": complete, "summary": {}}
|
||||
|
||||
monkeypatch.setattr(ccv.db, "get_case_by_number", fake_case)
|
||||
monkeypatch.setattr(ccv, "_inputs_fingerprint", fake_fp)
|
||||
monkeypatch.setattr(ccv, "_build_view_uncached", fake_build)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_second_load_is_served_from_cache(monkeypatch):
|
||||
calls: list = []
|
||||
_stub(monkeypatch, "fp-1", calls)
|
||||
first = await ccv.build_view("1069-04-26")
|
||||
second = await ccv.build_view("1069-04-26")
|
||||
assert first["cached"] is False
|
||||
assert second["cached"] is True
|
||||
assert len(calls) == 1, "the expensive assembly must run once"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_changed_inputs_invalidate_without_anyone_calling_invalidate(monkeypatch):
|
||||
"""The chair verifies a precedent → fingerprint moves → next load rebuilds.
|
||||
Correctness must not depend on a writer remembering to clear the cache."""
|
||||
calls: list = []
|
||||
_stub(monkeypatch, "fp-before", calls)
|
||||
await ccv.build_view("1069-04-26")
|
||||
|
||||
_stub(monkeypatch, "fp-after", calls) # e.g. a new case_precedents row
|
||||
again = await ccv.build_view("1069-04-26")
|
||||
assert again["cached"] is False
|
||||
assert len(calls) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_loads_do_not_stampede(monkeypatch):
|
||||
"""Reload-during-a-slow-load is how the original outage compounded into
|
||||
Postgres deadlocks. The later readers must wait, not pile on."""
|
||||
calls: list = []
|
||||
|
||||
async def fake_case(_cn):
|
||||
return {"id": "00000000-0000-0000-0000-000000000001"}
|
||||
|
||||
async def fake_fp(_cid):
|
||||
return "fp-1"
|
||||
|
||||
async def slow_build(cn):
|
||||
calls.append(cn)
|
||||
await asyncio.sleep(0.05)
|
||||
return {"status": "ok", "case_number": cn, "arguments": [],
|
||||
"retrieval_complete": True, "summary": {}}
|
||||
|
||||
monkeypatch.setattr(ccv.db, "get_case_by_number", fake_case)
|
||||
monkeypatch.setattr(ccv, "_inputs_fingerprint", fake_fp)
|
||||
monkeypatch.setattr(ccv, "_build_view_uncached", slow_build)
|
||||
|
||||
await asyncio.gather(*(ccv.build_view("1069-04-26") for _ in range(6)))
|
||||
assert len(calls) == 1, "six concurrent loads, one assembly"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_partial_view_expires_so_missing_suggestions_get_retried(monkeypatch):
|
||||
"""A budget-exhausted view is cached to stop reload storms, but it must not
|
||||
become permanent — the arguments with no suggestions deserve another try."""
|
||||
calls: list = []
|
||||
_stub(monkeypatch, "fp-1", calls, complete=False)
|
||||
await ccv.build_view("1069-04-26")
|
||||
_fp, expires_at, _view = ccv._view_cache["1069-04-26"]
|
||||
assert expires_at is not None, "a partial view must carry an expiry"
|
||||
|
||||
monkeypatch.setattr(ccv.time, "monotonic", lambda: expires_at + 1)
|
||||
after = await ccv.build_view("1069-04-26")
|
||||
assert after["cached"] is False
|
||||
assert len(calls) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_view_does_not_expire_on_a_timer(monkeypatch):
|
||||
calls: list = []
|
||||
_stub(monkeypatch, "fp-1", calls, complete=True)
|
||||
await ccv.build_view("1069-04-26")
|
||||
_fp, expires_at, _view = ccv._view_cache["1069-04-26"]
|
||||
assert expires_at is None, "freshness comes from the fingerprint, not a clock"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_use_cache_false_always_rebuilds(monkeypatch):
|
||||
calls: list = []
|
||||
_stub(monkeypatch, "fp-1", calls)
|
||||
await ccv.build_view("1069-04-26")
|
||||
await ccv.build_view("1069-04-26", use_cache=False)
|
||||
assert len(calls) == 2
|
||||
Reference in New Issue
Block a user