fix(playlists): every sync path locks the list before its items, and holds it to the write

The mirror refresh re-ingested inside its row lock, and that ingest's commit let go of the lock
before the replace: a remove made then was undone and the list read as synced. Push, re-pull and a
delete touched items before the list row, a deadlock beside the playlist-first route edits. A push
now re-plans from the items under the lock and derives dirty after its YouTube writes.
lock_playlists moves to the sync package. Tests pin both ids of a move's lock and the claim's
own connection.
This commit is contained in:
2026-09-27 21:56:23 +02:00
parent 2abd4ffcb6
commit 26697472cf
6 changed files with 329 additions and 47 deletions
+15 -3
View File
@@ -92,6 +92,18 @@ def test_a_claim_is_released_when_its_block_raises(db, make_user):
assert held == 0
def test_a_claim_draws_no_connection_from_the_app_pool(db, make_user):
"""A claim is held for a whole push beside the request's pooled session; drawn from the pool
too, a burst of pushes ran it dry and stalled every other request for pool_timeout."""
from app.db import engine
pl = _linked(db, make_user())
before = engine.pool.checkedout()
with plsync.sync_claim(db, pl.id) as free:
assert free
assert engine.pool.checkedout() == before
def test_the_scheduled_refresh_skips_a_mirror_being_synced(db, make_user, monkeypatch):
user = make_user()
busy = _linked(db, user, source="youtube")
@@ -100,7 +112,7 @@ def test_the_scheduled_refresh_skips_a_mirror_being_synced(db, make_user, monkey
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)
plsync, "_replace_items_from_youtube", lambda d, p, ids: replaced.append(p.id)
)
with plsync.sync_claim(db, busy.id):
@@ -134,7 +146,7 @@ def test_a_bulk_pull_runs_once_per_user_at_a_time(db, as_user, make_user, monkey
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)
monkeypatch.setattr(plsync, "_replace_items_from_youtube", lambda d, 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"}
@@ -184,7 +196,7 @@ def test_a_mirror_edited_during_the_pull_is_not_replaced(db, make_user, monkeypa
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)
plsync, "_replace_items_from_youtube", lambda d, p, ids: replaced.append(p.id)
)
assert plsync.sync_user_playlists(db, user) == {"synced": 0}