fix(playlists): item writes lock the list first; one bulk pull per user; the claim on its own connection

- Every playlist item write (add, remove, move, reorder, rename; Plex remove and remove-bulk) row-locks
  the playlist before it touches an item, a move both lists in id order: remove beside reorder was a
  deadlock, and two adds of one video a unique-key 500.
- The scheduled mirror refresh re-reads dirty under that lock, so an edit made during its YouTube calls
  is not replaced by YouTube's order.
- A user's bulk pull holds a per-user advisory claim: two at once created duplicate mirrors. The manual
  sync answers 409 sync_all_busy instead of '0 synced', and like the re-pull spends its cooldown only
  on a pull that runs.
- Deleted mirrors are committed at once, not only when a later list commits.
- The claims open an unpooled connection of their own instead of a third pooled one per push.
This commit is contained in:
2026-09-27 19:42:53 +02:00
parent 52fb999af6
commit b7ce1d467c
11 changed files with 346 additions and 71 deletions
+45 -26
View File
@@ -6,7 +6,6 @@ out (`/{id}/push-plan`, `/{id}/revert-youtube`), behind the optional write scope
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import func, select, and_
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, aliased
from app import quota
@@ -18,9 +17,10 @@ from app.sync.playlists import (
merge_playlist,
plan_merge,
recompute_dirty,
pull_user_playlists,
repull_playlist,
sync_claim,
sync_user_playlists,
user_sync_claim,
)
from app.ratelimit import RateLimiter
from app.routes._common import get_or_404, owned_or_404, youtube_refusal
@@ -79,11 +79,25 @@ def _next_playlist_position(db: Session, user_id: int) -> int:
)
def _lock_playlists(db: Session, *playlist_ids: int) -> None:
"""Row-lock playlists before touching their items, several in id order. Every item write takes
the PLAYLIST first: a writer that locked an item row first and the playlist second (a remove
beside a reorder) deadlocks against one that went the other way, and the loser is a 500. The
rows are re-read, so `dirty` and the sync baseline are the ones a finished sync committed."""
db.scalars(
select(Playlist)
.where(Playlist.id.in_(sorted(set(playlist_ids))))
.order_by(Playlist.id)
.with_for_update()
.execution_options(populate_existing=True)
).all() # consumed: rows fetched are what refreshes the loaded objects
def _next_item_position(db: Session, playlist_id: int) -> int:
"""Append position for a new item at the end of a playlist. Locks the playlist row first: two
adds (two tabs, the rail and the page) otherwise both read the same max and land on one
position, and the list's order between them is then arbitrary."""
db.execute(select(Playlist.id).where(Playlist.id == playlist_id).with_for_update())
_lock_playlists(db, playlist_id)
return (
db.scalar(
select(func.coalesce(func.max(PlaylistItem.position), -1)).where(
@@ -96,7 +110,10 @@ def _next_item_position(db: Session, playlist_id: int) -> int:
def _add_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) -> bool:
"""Append a video to a playlist if not already present (idempotent). Returns whether it
was added. `mark_dirty` recomputes the YouTube-sync dirty flag (skip for Watch later)."""
was added. `mark_dirty` recomputes the YouTube-sync dirty flag (skip for Watch later).
Locked BEFORE the presence check: two adds of one video both saw "absent" and the second one's
insert hit uq_playlist_video as a 500."""
_lock_playlists(db, pl.id)
exists = db.scalar(
select(PlaylistItem).where(
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
@@ -117,6 +134,7 @@ def _add_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) ->
def _remove_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) -> bool:
"""Remove a video from a playlist if present (idempotent). Returns whether it was removed.
`mark_dirty` recomputes the YouTube-sync dirty flag (skip for Watch later)."""
_lock_playlists(db, pl.id)
item = db.scalar(
select(PlaylistItem).where(
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
@@ -325,13 +343,18 @@ def sync_youtube(
Also runs automatically on the scheduler; this is the manual 'sync now'."""
if not has_read_scope(user):
raise _refused(403, "youtube_read_scope")
_check_pull_cooldown(user)
try:
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
return {"ok": True, **sync_user_playlists(db, user)}
except YouTubeError as exc:
db.rollback()
raise youtube_refusal(exc)
with user_sync_claim(db, user.id) as free:
if not free:
# Another run (the scheduler, another tab) is pulling right now: "0 synced" would lie.
raise _refused(409, "sync_all_busy")
# After the claim, like the re-pull's: a refused attempt must not spend one of the pulls.
_check_pull_cooldown(user)
try:
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
return {"ok": True, **pull_user_playlists(db, user)}
except YouTubeError as exc:
db.rollback()
raise youtube_refusal(exc)
@router.post("/{playlist_id}/revert-youtube")
@@ -351,6 +374,8 @@ def revert_youtube(
with sync_claim(db, pl.id) as free:
if not free:
raise _refused(409, "sync_busy")
# The cooldown is spent only by a pull that runs: checked after the claim, so clicking
# Revert while a push is in flight does not use up the three pulls per ten minutes.
_check_pull_cooldown(user)
try:
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
@@ -464,7 +489,8 @@ def rename_playlist(
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
# Locked: the new name moves `dirty`, which the scheduled mirror refresh reads under this lock.
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist", lock=True)
if "name" in body.model_fields_set:
name = (body.name or "").strip()
if not name:
@@ -598,6 +624,8 @@ def move_item(
raise HTTPException(
status_code=400, detail={"code": "playlist_move", "reason": "same_playlist"}
)
# Both lists, in id order: a move A→B beside a move B→A would otherwise lock crosswise.
_lock_playlists(db, src.id, dst.id)
item = db.scalar(
select(PlaylistItem).where(
PlaylistItem.playlist_id == src.id, PlaylistItem.video_id == video_id
@@ -617,20 +645,11 @@ def move_item(
if already is None:
# Re-parent the existing row rather than delete+insert: `added_at` ("when I saved this")
# survives the move, and there is no window where the video belongs to neither list. The
# check above is not a lock: a second tab's add or the scheduler's YouTube merge can insert
# the same (playlist, video) in between, and uq_playlist_video then refuses the re-parent.
# That is the already-present outcome arriving late, not a failure — answer it the same way
# instead of a raw 500.
try:
with db.begin_nested():
item.playlist_id = dst.id
item.position = _next_item_position(db, dst.id)
db.flush()
dst_changed = True
# silent-ok: the conflict IS the answer — the target already holds the video, which is the
# idempotent outcome; the savepoint put the row back in the source list, dropped below.
except IntegrityError:
pass
# check above holds: both lists are locked, and an insert into the target (another tab's
# add, a YouTube merge) waits for the lock — its foreign key needs the target row.
item.playlist_id = dst.id
item.position = _next_item_position(db, dst.id)
dst_changed = True
if not dst_changed:
db.delete(item)
# The source always changed; the target only when the row landed in it. `_touch` on a target
+4 -2
View File
@@ -252,7 +252,9 @@ def playlist_remove_item(
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
# Locked like the adds and the reorder: the list row first, then its items — a remove that
# locked an item row before a reorder locked the list deadlocked against it.
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist", lock=True)
it = by_rating_key(db, PlexItem, item_rating_key)
if it is not None:
db.execute(
@@ -399,7 +401,7 @@ def playlist_remove_bulk(
) -> dict:
"""Remove many items at once (a whole season/show, or a multi-select). Returns how many were
removed + the new total."""
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist", lock=True)
ids = [it.id for it in _items_by_rks(db, body.item_rating_keys)]
removed = 0
if ids: