fix(api): a waiting reorder deals from the fresh order; the commit check sees staged writes
Round 2 of the deep review, on round 1's own fixes:
- `ORDER BY position … FOR UPDATE` sorts before it locks, so the reorder that waited got the
fresh rows in its STALE order and dealt the pins it left out (a tab with Plex off) back to their
old slots. It locks in id order — one global order, so two reorders cannot deadlock — and sorts
the fresh rows itself. The race test now sends that partial list and fails unless tab B really
blocked.
- The request check saw only writes the handler flushed: a `db.delete` left staged, or a flush
followed by a released savepoint, passed. Staged changes count at the end of the request, and
only the ROOT transaction's end clears it (a savepoint test drives the dependency directly).
- The `ok` test exempted every call as a serializer, hiding four domain REPORTS (YouTube sync,
revert, push; Plex sync) without `ok`. They are spread into `{"ok": True, …}`; the exemption is a
named list of real serializers.
This commit is contained in:
@@ -1074,8 +1074,10 @@ def test_a_hero_from_a_show_pin_names_the_pin_it_came_from(db, as_user):
|
||||
def test_two_overlapping_reorders_never_share_a_slot(db, as_user):
|
||||
"""`reorder` writes only the rows that change. Unlocked, two tabs reordering from the same
|
||||
snapshot each skipped the rows that already matched in THEIR snapshot and interleaved row by
|
||||
row: p1=1, p2=2, p3=1 — two pins on one slot (deep review, debt burn). Locked, the second waits
|
||||
for the first and deals from its result."""
|
||||
row — two pins on one slot (deep review, debt burn). Locked, the second waits for the first and
|
||||
deals from its result: here a partial list (a tab with Plex off, one pin hidden), whose left-out
|
||||
pin must stay where the FIRST reorder put it — a lock taken in position order handed the waiter
|
||||
fresh rows in its stale order, and it dealt that pin back to its old slot (round 2)."""
|
||||
import threading
|
||||
|
||||
from app.db import SessionLocal
|
||||
@@ -1088,12 +1090,12 @@ def test_two_overlapping_reorders_never_share_a_slot(db, as_user):
|
||||
uid = user.id
|
||||
|
||||
with SessionLocal() as tab_a, SessionLocal() as tab_b, SessionLocal() as probe:
|
||||
pins.reorder(tab_a, tab_a.get(User, uid), [p2, p1, p3]) # written, not yet committed
|
||||
pins.reorder(tab_a, tab_a.get(User, uid), [p2, p3, p1]) # written, not yet committed
|
||||
errors: list[BaseException] = []
|
||||
|
||||
def second_tab():
|
||||
try:
|
||||
pins.reorder(tab_b, tab_b.get(User, uid), [p1, p3, p2])
|
||||
pins.reorder(tab_b, tab_b.get(User, uid), [p3, p2])
|
||||
tab_b.commit()
|
||||
except BaseException as exc: # noqa: BLE001 — re-raised in the test thread below
|
||||
errors.append(exc)
|
||||
@@ -1101,12 +1103,14 @@ def test_two_overlapping_reorders_never_share_a_slot(db, as_user):
|
||||
worker = threading.Thread(target=second_tab)
|
||||
worker.start()
|
||||
# Commit A only once B is blocked on a row lock, so B's read is the stale snapshot.
|
||||
for _ in range(100):
|
||||
waiting = 0
|
||||
for _ in range(200):
|
||||
waiting = probe.scalar(text("SELECT count(*) FROM pg_locks WHERE NOT granted"))
|
||||
probe.rollback()
|
||||
if waiting:
|
||||
break
|
||||
worker.join(0.05)
|
||||
assert waiting, "tab B never blocked — the race was not exercised"
|
||||
tab_a.commit()
|
||||
worker.join(10)
|
||||
assert not worker.is_alive() and errors == []
|
||||
@@ -1114,4 +1118,4 @@ def test_two_overlapping_reorders_never_share_a_slot(db, as_user):
|
||||
db.expire_all()
|
||||
positions = db.scalars(select(ComfortPin.position).where(ComfortPin.user_id == uid)).all()
|
||||
assert sorted(positions) == [0, 1, 2]
|
||||
assert [p["pin_id"] for p in c.get("/api/discover/pins").json()["items"]] == [p1, p3, p2]
|
||||
assert [p["pin_id"] for p in c.get("/api/discover/pins").json()["items"]] == [p3, p2, p1]
|
||||
|
||||
Reference in New Issue
Block a user