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:
2026-09-19 19:30:27 +02:00
parent cf0c55ba80
commit 0ca0cac534
7 changed files with 100 additions and 29 deletions
+21 -8
View File
@@ -86,8 +86,18 @@ def reorder(db: Session, user: User, ids: list[int]) -> None:
ignored rather than failing a stale replay wholesale. Flushes; the route commits."""
# Locked: the rows it leaves alone are part of the answer too. Two overlapping reorders each
# skipped the rows that already matched in their own snapshot and interleaved into two pins on
# one slot; the second now waits and deals from the first one's result.
current = list_pins(db, user, lock=True)
# one slot; the second now waits and deals from the first one's result. Locked in id order and
# sorted HERE: with `ORDER BY position … FOR UPDATE` Postgres sorts before it locks, so the one
# that waited got the fresh rows in its stale order and dealt the pins it left out back to their
# old slots — and one global lock order means two reorders cannot deadlock.
locked = db.execute(
select(ComfortPin)
.where(ComfortPin.user_id == user.id)
.order_by(ComfortPin.id)
.with_for_update()
.execution_options(populate_existing=True)
).scalars()
current = sorted(locked, key=lambda p: (p.position, p.id))
owned = {p.id for p in current}
listed: list[int] = []
for i in ids:
@@ -103,13 +113,16 @@ def reorder(db: Session, user: User, ids: list[int]) -> None:
db.flush()
def list_pins(db: Session, user: User, *, lock: bool = False) -> list[ComfortPin]:
stmt = (
select(ComfortPin)
.where(ComfortPin.user_id == user.id)
.order_by(ComfortPin.position.asc(), ComfortPin.id.asc())
def list_pins(db: Session, user: User) -> list[ComfortPin]:
return list(
db.execute(
select(ComfortPin)
.where(ComfortPin.user_id == user.id)
.order_by(ComfortPin.position.asc(), ComfortPin.id.asc())
)
.scalars()
.all()
)
return list(db.execute(stmt.with_for_update() if lock else stmt).scalars().all())
def next_position(db: Session, user: User) -> int:
+3 -3
View File
@@ -324,7 +324,7 @@ def sync_youtube(
_check_pull_cooldown(user)
try:
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
return sync_user_playlists(db, user)
return {"ok": True, **sync_user_playlists(db, user)}
except YouTubeError as exc:
db.rollback()
raise youtube_refusal(exc)
@@ -347,7 +347,7 @@ def revert_youtube(
_check_pull_cooldown(user)
try:
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
return repull_playlist(db, user, pl)
return {"ok": True, **repull_playlist(db, user, pl)}
except YouTubeError as exc:
db.rollback()
raise youtube_refusal(exc)
@@ -407,7 +407,7 @@ def push(
plan = plan_merge(db, user, pl, live=live)
if plan["units_estimate"] > quota.remaining_today(db):
raise _refused(429, "quota_exceeded")
return merge_playlist(db, user, pl, live=live)
return {"ok": True, **merge_playlist(db, user, pl, live=live)}
except YouTubeError as exc:
db.rollback()
raise youtube_refusal(exc)
+1 -1
View File
@@ -103,7 +103,7 @@ def _media_check(db: Session, plex: PlexClient, sections: list[dict]) -> dict:
def trigger_sync(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
"""Admin: mirror the enabled Plex sections into the local catalog. Synchronous (can take a
while on a large library); a background/scheduled sync also runs periodically."""
return plex_sync.sync(db)
return {"ok": True, **plex_sync.sync(db)}
def _link_dict(link: PlexLink | None) -> dict: