77"""
88
99import logging
10+ from time import monotonic , sleep
1011from typing import Any
1112
1213from fastapi import BackgroundTasks
14+ from sqlalchemy import text
15+ from sqlalchemy .exc import IntegrityError
1316from sqlalchemy .sql import func
1417from sqlmodel import Session , select
1518
2629
2730logger = logging .getLogger (__name__ )
2831
32+ EVAL_LOCK_POLL_SECONDS = 1.0
33+ EVAL_LOCK_WAIT_SECONDS = 120.0
2934
30- def update_or_select_document_evaluation (
31- background_tasks : BackgroundTasks ,
32- session : Session ,
33- document : Document ,
34- ) -> MetricsEnvelope :
35- """Return the document's metrics, recomputing on cache miss or stale row.
3635
37- A cached `Evaluation` row is considered fresh iff its `payload_version`
38- matches `CURRENT_PAYLOAD_VERSION` (registry hasn't changed) and its
39- `updated_at` is at least as new as the document's. Otherwise the metrics
40- are recomputed via `compute_metrics` and the row is upserted .
36+ def _cached_envelope (
37+ evaluation : Evaluation | None , document : Document
38+ ) -> MetricsEnvelope | None :
39+ """Return the cached envelope iff the row is fresh, else None .
4140
42- Failures are never cached — a cache hit always returns `failed=[]`.
41+ Fresh iff `payload_version` matches `CURRENT_PAYLOAD_VERSION` (registry
42+ hasn't changed) and `updated_at` is at least as new as the document's.
4343 """
44- evaluation = session .exec (
45- select (Evaluation ).where (Evaluation .document_id == document .document_id )
46- ).one_or_none ()
4744 if (
4845 evaluation
4946 and evaluation .payload_version == CURRENT_PAYLOAD_VERSION
@@ -56,6 +53,62 @@ def update_or_select_document_evaluation(
5653 metrics = evaluation .metrics ,
5754 failed = [],
5855 )
56+ return None
57+
58+
59+ def update_or_select_document_evaluation (
60+ background_tasks : BackgroundTasks ,
61+ session : Session ,
62+ document : Document ,
63+ ) -> MetricsEnvelope :
64+ """Return the document's metrics, recomputing on cache miss or stale row.
65+
66+ On a miss, a per-document Postgres advisory lock serializes computes
67+ across all backend tasks so a thundering herd of cache-cold requests runs
68+ one compute instead of N. Losers poll instead of blocking on the lock:
69+ a parked waiter holds its pooled connection for the whole compute, so
70+ ~15 cache-cold requests on one document would exhaust a task's pool
71+ (5 + 10 overflow) and starve unrelated endpoints.
72+
73+ Failures are never cached — a cache hit always returns `failed=[]`.
74+ """
75+ evaluation = session .exec (
76+ select (Evaluation ).where (Evaluation .document_id == document .document_id )
77+ ).one_or_none ()
78+ if cached := _cached_envelope (evaluation , document ):
79+ return cached
80+
81+ deadline = monotonic () + EVAL_LOCK_WAIT_SECONDS
82+ while not session .execute (
83+ text ("SELECT pg_try_advisory_xact_lock(hashtextextended(:doc, 0))" ),
84+ {"doc" : str (document .document_id )},
85+ ).scalar_one ():
86+ # End the transaction so the pool reclaims our connection while we sleep.
87+ session .rollback ()
88+ sleep (EVAL_LOCK_POLL_SECONDS )
89+ evaluation = session .exec (
90+ select (Evaluation ).where (Evaluation .document_id == document .document_id )
91+ ).one_or_none ()
92+ if cached := _cached_envelope (evaluation , document ):
93+ return cached
94+ if monotonic () >= deadline :
95+ # Winner is wedged; compute without the lock rather than fail —
96+ # the IntegrityError path below tolerates concurrent commits.
97+ logger .warning (
98+ "evaluation lock wait timed out for %s; computing without it" ,
99+ document .document_id ,
100+ )
101+ break
102+ else :
103+ # A winner may have committed between our cache check and the
104+ # acquire; expire so the re-read isn't served from the identity map.
105+ session .expire_all ()
106+ evaluation = session .exec (
107+ select (Evaluation ).where (Evaluation .document_id == document .document_id )
108+ ).one_or_none ()
109+ if cached := _cached_envelope (evaluation , document ):
110+ return cached
111+
59112 envelope = compute_metrics (background_tasks , session , document .document_id )
60113
61114 if evaluation :
@@ -70,7 +123,12 @@ def update_or_select_document_evaluation(
70123 payload_version = envelope ["payload_version" ],
71124 )
72125 )
73- session .commit ()
126+ try :
127+ session .commit ()
128+ except IntegrityError :
129+ # A concurrent request computed and cached the same document first
130+ # (both saw a cold cache); our freshly computed envelope is still valid.
131+ session .rollback ()
74132 return envelope
75133
76134
0 commit comments