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
+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"