Skip to content

Commit 68508de

Browse files
committed
fix(metrics): take the version advisory lock in the recompute
The recompute assumed its only caller was refresh_task_browse_summaries, which already holds a sorted advisory lock per version id. The backfill calls it directly and held no lock, so a backfill batch racing a live refresh could overwrite a fresh row with the snapshot it aggregated moments earlier -- silent staleness rather than a crash, and invisible to a single-threaded fixture test. pg_advisory_xact_lock is re-entrant within a transaction, so taking an already-held lock on the browse-summary path costs nothing, and the shared sort order keeps both callers in one global ordering. Proven by holding the lock from a second connection and asserting the recompute blocks; the test fails with DID NOT RAISE when the lock is removed.
1 parent bfbbeb1 commit 68508de

2 files changed

Lines changed: 61 additions & 5 deletions

File tree

oddish/src/oddish/core/task_version_model_metrics.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from collections.abc import Iterable
44
from typing import Any
55

6-
from sqlalchemy import Integer, case, delete, func, select
6+
from sqlalchemy import Integer, case, delete, func, select, text
77
from sqlalchemy.dialects.postgresql import insert as pg_insert
88
from sqlalchemy.ext.asyncio import AsyncSession
99

@@ -207,10 +207,22 @@ async def refresh_task_version_model_metrics(
207207
return
208208
await session.flush()
209209

210-
# No locking here: the sole caller is refresh_task_browse_summaries, which
211-
# already holds a sorted transaction-scoped advisory lock per version id.
212-
# Taking them again would be redundant; taking them in a different order
213-
# would reintroduce the deadlock those locks exist to prevent.
210+
# Lock here rather than relying on the caller. refresh_task_browse_summaries
211+
# already holds these, but the backfill calls this function directly, and an
212+
# unlocked backfill batch racing a live refresh can overwrite a fresh row
213+
# with the snapshot it aggregated moments earlier -- silent staleness, not a
214+
# crash. pg_advisory_xact_lock is re-entrant within a transaction, so taking
215+
# an already-held lock is free; the same sorted order keeps both paths in a
216+
# single global ordering and cannot deadlock against each other.
217+
for version_id in version_ids:
218+
await session.execute(
219+
text(
220+
"SELECT pg_advisory_xact_lock("
221+
"hashtextextended(CAST(:version_id AS text), 0))"
222+
),
223+
{"version_id": version_id},
224+
)
225+
214226
known = (
215227
await session.execute(
216228
select(TaskVersionModel.id)

oddish/tests/test_task_version_model_metrics.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,3 +347,47 @@ async def _count() -> int:
347347
assert await _count() == first
348348
finally:
349349
await session.rollback()
350+
351+
352+
@pytest.mark.asyncio
353+
async def test_recompute_takes_the_version_advisory_lock(session):
354+
"""The backfill calls this directly, so it cannot rely on a caller's lock.
355+
356+
Without a lock, a backfill batch racing a live refresh overwrites a fresh
357+
row with the snapshot it aggregated moments earlier. Proven by holding the
358+
lock from a second connection and asserting the recompute blocks on it.
359+
"""
360+
import asyncio
361+
362+
import oddish.db.connection as conn
363+
from sqlalchemy import text as sa_text
364+
365+
_, version_id = await _seed(session, [{"reward": 1.0, "total_steps": 12}])
366+
await session.commit()
367+
368+
holder = conn.async_session_maker()
369+
try:
370+
# Same hash expression as refresh_task_browse_summaries, so the two
371+
# paths contend on one lock rather than two.
372+
await holder.execute(
373+
sa_text(
374+
"SELECT pg_advisory_xact_lock("
375+
"hashtextextended(CAST(:v AS text), 0))"
376+
),
377+
{"v": version_id},
378+
)
379+
380+
blocked = conn.async_session_maker()
381+
try:
382+
with pytest.raises(asyncio.TimeoutError):
383+
await asyncio.wait_for(
384+
refresh_task_version_model_metrics(blocked, [version_id]),
385+
timeout=2.0,
386+
)
387+
finally:
388+
await blocked.rollback()
389+
await blocked.close()
390+
finally:
391+
await holder.rollback()
392+
await holder.close()
393+
await session.rollback()

0 commit comments

Comments
 (0)