Files
siftlode/backend/tests/test_playlist_sync_claim.py
T
peter e0453fa07d fix(playlists): one YouTube sync of a list at a time, across tabs and the scheduler
The client's in-flight flag covers the menus of one page only. A push reads YouTube, then writes
both sides for seconds and commits step by step, so a row lock ends at its first commit: two tabs,
or a push and the scheduled mirror refresh, could interleave into a doubled YouTube item or a
baseline recorded over the other's writes. `sync_claim` takes a session-level advisory lock on a
connection of its own (it survives those commits; a crashed worker's lock dies with its
connection, so no claim column is left stuck). Push and revert answer 409 `sync_busy`; the
scheduled refresh skips a list being synced and picks it up next run.
2026-09-27 17:47:01 +02:00

112 lines
3.8 KiB
Python

"""E28 review C1 — one YouTube sync of a playlist at a time, whoever starts it.
A push reads YouTube and then writes both sides for seconds, committing step by step, so the
client's in-flight flag (one page's menus) and a row lock (gone at the first commit) both leave two
tabs, or a push and the scheduled mirror refresh, free to interleave. The claim is an advisory lock
on a connection of its own; the test holds it from outside, the way a second request would.
"""
from sqlalchemy import text
from app.auth import require_write_scope
from app.models import Playlist
from app.routes import playlists as plroutes
from app.sync import playlists as plsync
def _linked(db, user, *, source="local"):
pl = Playlist(user_id=user.id, name="L", source=source, yt_playlist_id="PL123")
db.add(pl)
db.commit()
return pl
class FakeYT:
"""YouTube as far as these routes read it: two playlists, none with items."""
def __init__(self, *_):
pass
def __enter__(self):
return self
def __exit__(self, *_):
return False
def iter_my_playlists(self):
return [{"id": "PL123", "title": "Renamed"}, {"id": "PL999", "title": "New"}]
def iter_my_playlist_video_ids(self, _ytid):
return []
def iter_playlist_items_with_ids(self, _ytid):
return []
def get_playlist_snippet(self, _ytid):
return {}
def test_push_and_revert_refuse_while_the_list_is_claimed(
db, as_user, make_user, override_dep, monkeypatch
):
user = make_user()
pl = _linked(db, user)
override_dep(require_write_scope, lambda: user)
monkeypatch.setattr(plroutes, "has_read_scope", lambda u: True)
ran: list[str] = []
monkeypatch.setattr(plroutes, "YouTubeClient", FakeYT)
monkeypatch.setattr(plroutes, "plan_merge", lambda d, u, p, live: {"units_estimate": 0})
monkeypatch.setattr(plroutes, "merge_playlist", lambda d, u, p, live: ran.append("push") or {})
monkeypatch.setattr(plroutes, "repull_playlist", lambda d, u, p: ran.append("revert") or {})
c = as_user(user)
with plsync.sync_claim(db, pl.id) as free:
assert free
for path in ("push", "revert-youtube"):
r = c.post(f"/api/playlists/{pl.id}/{path}")
assert r.status_code == 409
assert r.json()["detail"] == {"code": "playlist", "reason": "sync_busy"}
assert ran == []
# Released on the way out: the same list syncs again, and a claim is per list.
assert c.post(f"/api/playlists/{pl.id}/push").status_code == 200
assert c.post(f"/api/playlists/{pl.id}/revert-youtube").status_code == 200
assert ran == ["push", "revert"]
def test_a_claim_is_released_when_its_block_raises(db, make_user):
pl = _linked(db, make_user())
try:
with plsync.sync_claim(db, pl.id) as free:
assert free
raise RuntimeError("boom")
except RuntimeError:
pass
# Asked of pg_locks, not by claiming again: a lock leaked on a pooled connection would be granted
# again to that same connection (advisory locks are re-entrant per session).
held = db.scalar(
text("SELECT count(*) FROM pg_locks WHERE locktype = 'advisory' AND objid = :id"),
{"id": pl.id},
)
assert held == 0
def test_the_scheduled_refresh_skips_a_mirror_being_synced(db, make_user, monkeypatch):
user = make_user()
busy = _linked(db, user, source="youtube")
replaced: list[int] = []
monkeypatch.setattr(plsync, "has_read_scope", lambda u: True)
monkeypatch.setattr(plsync, "YouTubeClient", FakeYT)
monkeypatch.setattr(
plsync, "_replace_items_from_youtube", lambda d, yt, p, ids: replaced.append(p.id)
)
with plsync.sync_claim(db, busy.id):
out = plsync.sync_user_playlists(db, user)
assert out == {"synced": 1}
assert busy.id not in replaced and len(replaced) == 1
db.refresh(busy)
assert busy.name == "L"