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
+1 -1
View File
@@ -100,7 +100,7 @@ def test_the_single_playlist_repull_shares_the_pull_cooldown(db, as_user, make_u
db.commit()
monkeypatch.setattr(plroutes, "has_read_scope", lambda u: True)
monkeypatch.setattr(plroutes, "repull_playlist", lambda d, u, p: {"ok": True})
monkeypatch.setattr(plroutes, "sync_user_playlists", lambda d, u: {"ok": True})
monkeypatch.setattr(plroutes, "pull_user_playlists", lambda d, u: {"ok": True})
c = as_user(user)
codes = [c.post(f"/api/playlists/{pl.id}/revert-youtube").status_code for _ in range(4)]
+16 -19
View File
@@ -13,6 +13,9 @@ Two properties are load-bearing and neither is visible from the happy path alone
from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy.exc import OperationalError
from app.models import Channel, Playlist, PlaylistItem, Video
@@ -269,12 +272,12 @@ def test_a_target_that_already_had_it_is_not_recently_changed(db, make_user, as_
assert db.get(Playlist, dst.id).updated_at == old
def test_a_concurrent_add_to_the_target_lands_as_the_already_present_move(
db, make_user, as_user, monkeypatch
):
"""The already-in-target check is a read, not a lock: another tab's add (or the scheduler's
YouTube merge) can commit the same video to the target in between, and the re-parent then
hits uq_playlist_video. That is the idempotent outcome arriving late — not a raw 500."""
def test_no_add_can_land_in_the_target_while_a_move_holds_it(db, make_user):
"""The already-in-target check is a read; it holds because the move locks both lists first and
an insert into the target — another tab's add, a YouTube merge — needs the target row for its
foreign key, so it waits for the move's commit instead of hitting uq_playlist_video under it."""
from sqlalchemy import text
from app.db import SessionLocal
from app.routes import playlists as plroutes
@@ -282,16 +285,10 @@ def test_a_concurrent_add_to_the_target_lands_as_the_already_present_move(
_seed_videos(db, ["v1"])
src = _playlist(db, user, "Source", ["v1"])
dst = _playlist(db, user, "Target", [])
real = plroutes._next_item_position
def add_first(session, playlist_id):
with SessionLocal() as other: # the other tab, committing between check and re-parent
other.add(PlaylistItem(playlist_id=dst.id, video_id="v1", position=0))
other.commit()
return real(session, playlist_id)
monkeypatch.setattr(plroutes, "_next_item_position", add_first)
assert _move(as_user(user), src.id, "v1", dst.id).status_code == 200
db.expire_all()
assert _ids(db, src.id) == []
assert _ids(db, dst.id) == ["v1"]
plroutes._lock_playlists(db, src.id, dst.id) # what the move holds until its commit
with SessionLocal() as other:
other.execute(text("SET LOCAL lock_timeout = '300ms'"))
other.add(PlaylistItem(playlist_id=dst.id, video_id="v1", position=0))
with pytest.raises(OperationalError, match="lock timeout"):
other.flush()
db.rollback()
@@ -6,6 +6,11 @@ on one position, and a reorder racing an add collides with it — the list's ord
rows is then whatever the planner returns. A true two-connection race is not reproducible on
demand, so the test holds the mechanism: the parent row is taken `FOR UPDATE` by each such route.
A Plex reorder that omits a row (added in another tab meanwhile) must not collide with it either.
Round 2: the lock also comes FIRST — before the request reads or writes a single item row. A remove
that deleted an item and then touched the playlist beside a reorder that went the other way was a
deadlock (a 500 for one of them), and an add that checked "already there?" before the lock let two
adds of one video both insert (a 500 on the unique key).
"""
import re
@@ -41,6 +46,32 @@ def locks(db):
event.remove(engine, "before_cursor_execute", _record)
@pytest.fixture
def statements(db):
"""Every statement a request ran, in order."""
seen: list[str] = []
def _record(conn, cursor, statement, *_):
seen.append(" ".join(statement.split()))
engine = db.get_bind()
event.listen(engine, "before_cursor_execute", _record)
yield seen
event.remove(engine, "before_cursor_execute", _record)
def _locked_before_items(seen: list[str], parent: str, items: str) -> str | None:
"""The first statement touching `items` must come after a `FOR UPDATE` of `parent`; returns
that lock statement (None when it came too late or never)."""
lock = None
for st in seen:
if lock is None and "FOR UPDATE" in st and re.search(rf"FROM {parent}\b", st):
lock = st
if re.search(rf"\b{items}\b", st):
return lock
return lock
def _yt(db, user):
db.add(Channel(id="UCtest", title="Test"))
now = datetime.now(timezone.utc)
@@ -134,3 +165,92 @@ def test_a_plex_reorder_that_omits_a_row_does_not_collide_with_it(db, make_user,
by_id = {it.id: it.rating_key for it in db.query(PlexItem).all()}
got = sorted((r.position, by_id[r.item_id]) for r in rows)
assert got == [(0, "c"), (1, "b"), (2, "a")]
YT_ITEMS = PlaylistItem.__table__.name
PLEX_ITEMS = PlexPlaylistItem.__table__.name
@pytest.mark.parametrize(
"call",
[
lambda c, pid, other: c.post(f"/api/playlists/{pid}/items", json={"video_id": "v1"}),
lambda c, pid, other: c.post(
f"/api/playlists/{pid}/items", json={"video_id": "v2", "position": 0}
),
lambda c, pid, other: c.delete(f"/api/playlists/{pid}/items/v1"),
lambda c, pid, other: c.post(
f"/api/playlists/{pid}/items/v1/move", json={"target_playlist_id": other}
),
lambda c, pid, other: c.put(f"/api/playlists/{pid}/order", json={"video_ids": ["v1"]}),
],
ids=["add-present", "add-at", "remove", "move", "reorder"],
)
def test_a_youtube_item_write_locks_the_playlist_before_any_item(
db, make_user, as_user, statements, call
):
user = make_user()
pid, other = _yt(db, user)
c = as_user(user)
statements.clear()
r = call(c, pid, other)
assert r.status_code == 200, r.text
assert _locked_before_items(statements, YT, YT_ITEMS), statements
def test_a_move_locks_both_lists_in_one_ordered_statement(db, make_user, as_user, statements):
"""Two moves A→B and B→A each locking "its" list first would lock crosswise."""
user = make_user()
pid, other = _yt(db, user)
statements.clear()
r = as_user(user).post(
f"/api/playlists/{pid}/items/v1/move", json={"target_playlist_id": other}
)
assert r.status_code == 200, r.text
lock = _locked_before_items(statements, YT, YT_ITEMS)
assert lock and " IN (" in lock and "ORDER BY" in lock, lock
def test_watch_later_add_and_remove_lock_it_before_any_item(db, make_user, as_user, statements):
user = make_user()
_yt(db, user)
c = as_user(user)
assert c.post("/api/playlists/watch-later", json={"video_id": "v2"}).status_code == 200
statements.clear()
assert c.post("/api/playlists/watch-later", json={"video_id": "v2"}).status_code == 200
assert _locked_before_items(statements, YT, YT_ITEMS), statements
statements.clear()
assert c.delete("/api/playlists/watch-later/v2").status_code == 200
assert _locked_before_items(statements, YT, YT_ITEMS), statements
def test_a_rename_locks_the_playlist(db, make_user, as_user, locks):
"""A rename moves `dirty`, which the scheduled mirror refresh re-reads under this lock."""
user = make_user()
pid, _ = _yt(db, user)
r = as_user(user).patch(f"/api/playlists/{pid}", json={"name": "New"})
assert r.status_code == 200, r.text
assert YT in locks
@pytest.mark.parametrize(
"call",
[
lambda c, pid: c.delete(f"/api/plex/playlists/{pid}/items/a"),
lambda c, pid: c.post(
f"/api/plex/playlists/{pid}/items/remove-bulk", json={"item_rating_keys": ["a", "b"]}
),
lambda c, pid: c.post(f"/api/plex/playlists/{pid}/items", json={"item_rating_key": "a"}),
],
ids=["remove", "remove-bulk", "add-present"],
)
def test_a_plex_item_write_locks_the_playlist_before_any_item(
db, make_user, as_user, statements, call
):
user = make_user()
pid = _plex(db, user)
c = as_user(user)
statements.clear()
r = call(c, pid)
assert r.status_code == 200, r.text
assert _locked_before_items(statements, PLEX, PLEX_ITEMS), statements
+82
View File
@@ -9,6 +9,7 @@ on a connection of its own; the test holds it from outside, the way a second req
from sqlalchemy import text
from app.auth import require_write_scope
from app.db import SessionLocal
from app.models import Playlist
from app.routes import playlists as plroutes
from app.sync import playlists as plsync
@@ -109,3 +110,84 @@ def test_the_scheduled_refresh_skips_a_mirror_being_synced(db, make_user, monkey
assert busy.id not in replaced and len(replaced) == 1
db.refresh(busy)
assert busy.name == "L"
def test_refused_reverts_do_not_spend_the_pull_cooldown(
db, as_user, make_user, override_dep, monkeypatch
):
"""Round 2: the cooldown (three pulls per ten minutes) is checked after the claim, so clicking
Revert while a push runs is refused as busy without using up a pull."""
user = make_user()
pl = _linked(db, user)
monkeypatch.setattr(plroutes, "has_read_scope", lambda u: True)
monkeypatch.setattr(plroutes, "repull_playlist", lambda d, u, p: {})
c = as_user(user)
with plsync.sync_claim(db, pl.id):
for _ in range(4):
assert c.post(f"/api/playlists/{pl.id}/revert-youtube").status_code == 409
assert c.post(f"/api/playlists/{pl.id}/revert-youtube").status_code == 200
def test_a_bulk_pull_runs_once_per_user_at_a_time(db, as_user, make_user, monkeypatch):
"""Two bulk pulls at once both saw a YouTube list unmirrored and each created its mirror."""
user = make_user()
monkeypatch.setattr(plsync, "has_read_scope", lambda u: True)
monkeypatch.setattr(plroutes, "has_read_scope", lambda u: True)
monkeypatch.setattr(plsync, "YouTubeClient", FakeYT)
monkeypatch.setattr(plsync, "_replace_items_from_youtube", lambda d, yt, p, ids: None)
c = as_user(user)
with plsync.user_sync_claim(db, user.id):
assert plsync.sync_user_playlists(db, user) == {"synced": 0, "reason": "busy"}
for _ in range(4):
r = c.post("/api/playlists/sync-youtube")
assert r.status_code == 409
assert r.json()["detail"] == {"code": "playlist", "reason": "sync_all_busy"}
assert c.post("/api/playlists/sync-youtube").json() == {"ok": True, "synced": 2}
def test_a_gone_mirror_is_deleted_even_when_no_list_refreshes(db, make_user, monkeypatch):
"""Every remaining mirror has unpushed edits, so nothing below the deletion commits."""
user = make_user()
gone = Playlist(user_id=user.id, name="Gone", source="youtube", yt_playlist_id="PLgone")
kept = Playlist(user_id=user.id, name="K", source="youtube", yt_playlist_id="PL123", dirty=True)
other = Playlist(
user_id=user.id, name="O", source="youtube", yt_playlist_id="PL999", dirty=True
)
db.add_all([gone, kept, other])
db.commit()
gone_id = gone.id
monkeypatch.setattr(plsync, "has_read_scope", lambda u: True)
monkeypatch.setattr(plsync, "YouTubeClient", FakeYT)
assert plsync.sync_user_playlists(db, user) == {"synced": 0}
db.rollback() # what the request's session close does to an uncommitted deletion
assert db.get(Playlist, gone_id) is None
def test_a_mirror_edited_during_the_pull_is_not_replaced(db, make_user, monkeypatch):
"""The loop's `dirty` is a snapshot from before the YouTube calls; a remove committed by another
request meanwhile must win over YouTube's order."""
user = make_user()
pl = _linked(db, user, source="youtube")
replaced: list[int] = []
class EditedMeanwhile(FakeYT):
def iter_my_playlists(self):
return [{"id": "PL123", "title": "Renamed"}]
def iter_my_playlist_video_ids(self, _ytid):
with SessionLocal() as other:
other.get(Playlist, pl.id).dirty = True
other.commit()
return []
monkeypatch.setattr(plsync, "has_read_scope", lambda u: True)
monkeypatch.setattr(plsync, "YouTubeClient", EditedMeanwhile)
monkeypatch.setattr(
plsync, "_replace_items_from_youtube", lambda d, yt, p, ids: replaced.append(p.id)
)
assert plsync.sync_user_playlists(db, user) == {"synced": 0}
assert replaced == []
db.expire_all()
assert db.get(Playlist, pl.id).name == "L"
+1 -1
View File
@@ -37,7 +37,7 @@ def test_a_failure_is_sorted_by_what_the_user_can_do(exc, reason):
("route", "target"),
[
("/api/sync/subscriptions", "app.routes.sync.import_subscriptions"),
("/api/playlists/sync-youtube", "app.routes.playlists.sync_user_playlists"),
("/api/playlists/sync-youtube", "app.routes.playlists.pull_user_playlists"),
],
)
def test_a_manual_sync_youtube_refuses_is_a_422_not_a_500(