fix(playlists): a push stops at a list deleted mid-way, and a rename made during it stays unsynced

A delete landing while a push read YouTube left a plan over no items, so the push emptied the
YouTube playlist a local-only delete keeps: the push now stops at a vanished list (PlaylistGone,
404), and a delete is refused while a sync of the list runs. The closing baseline fingerprints the
title YouTube got, not the one its lock re-reads, so a rename made during the writes stays dirty.
The other-tab test helper bounds its lock wait, so a lock-order regression fails instead of hanging.
This commit is contained in:
2026-09-27 22:34:14 +02:00
parent 3a9ea9e330
commit 528774d640
4 changed files with 142 additions and 41 deletions
@@ -67,7 +67,12 @@ def test_push_and_revert_refuse_while_the_list_is_claimed(
r = c.post(f"/api/playlists/{pl.id}/{path}")
assert r.status_code == 409
assert r.json()["detail"] == {"code": "playlist", "reason": "sync_busy"}
# a delete too: a push mid-way would plan the deleted list as emptied (E28 round 4)
r = c.delete(f"/api/playlists/{pl.id}")
assert r.status_code == 409
assert r.json()["detail"] == {"code": "playlist", "reason": "sync_busy"}
assert ran == []
assert db.get(Playlist, pl.id) is not None
# 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
+53 -3
View File
@@ -10,11 +10,12 @@ during its YouTube writes. The "other tab" here is a second session that commits
talking to YouTube — when the push holds no lock, as a real one does not.
"""
import json
import re
from datetime import datetime, timezone
import pytest
from sqlalchemy import event
from sqlalchemy import delete, event, text
from app.db import SessionLocal
from app.models import Channel, Playlist, PlaylistItem, Video
@@ -151,9 +152,16 @@ def _playlist(db, user, local, *, source="local", baseline=None):
return pl
def _other_tab(pl_id, *, add=None, remove=None):
"""A second session's edit, committed at once — what another tab's request does."""
def _other_tab(pl_id, *, add=None, remove=None, rename=None, drop=False):
"""A second session's edit, committed at once — what another tab's request does. It runs on
the push's own thread: a lock the push wrongly held here would be waited on forever, so the
wait is bounded and a regression fails instead of hanging the gate."""
with SessionLocal() as s:
s.execute(text("SET LOCAL lock_timeout = '2s'"))
if drop:
s.execute(delete(Playlist).where(Playlist.id == pl_id))
if rename:
s.get(Playlist, pl_id).name = rename
if add:
pos = len(s.query(PlaylistItem).filter_by(playlist_id=pl_id).all())
s.add(PlaylistItem(playlist_id=pl_id, video_id=add, position=pos))
@@ -227,6 +235,48 @@ def test_an_add_made_while_a_push_reads_youtube_survives_it(db, make_user, fake_
assert "x" in [v for _, v in fake.items] # and it reached YouTube as a local add
@pytest.mark.parametrize("linked", [True, False], ids=["linked", "first-push"])
def test_a_rename_made_during_a_push_s_youtube_writes_stays_dirty(db, make_user, fake_yt, linked):
"""The closing baseline is YouTube's title, not the one the lock re-reads: over the new name it
matched the rename, and the list read synced with YouTube still on the old title."""
user = make_user()
_seed(db, ["a", "b"])
pl = _playlist(db, user, ["a", "b"], baseline=["a"] if linked else None)
if not linked:
pl.yt_playlist_id = None
db.commit()
fake = fake_yt(
FakeYT(["a"] if linked else [], on_add=lambda: _other_tab(pl.id, rename="Mix 2"))
)
pl_mod.merge_playlist(db, user, pl)
db.refresh(pl)
assert (pl.name, fake.title) == ("Mix 2", "Mix")
assert pl.dirty is True # the rename has not reached YouTube; the list must say so
def test_a_push_stops_at_a_list_deleted_while_it_read_youtube(db, make_user, fake_yt):
"""A delete landing mid-push left a plan over no items: every item "removed here", so the push
emptied the YouTube playlist the delete meant to keep."""
user = make_user()
_seed(db, ["a", "b"])
pl = _playlist(db, user, ["a", "b"])
# a video YouTube would not return last time, retried now — the read the delete lands in
pl.synced_video_ids = json.dumps({"ids": ["a", "b"], "unavailable": ["ghost"]})
db.commit()
fake = fake_yt(
FakeYT(
["a", "b", "ghost"], unavailable={"ghost"}, on_get=lambda: _other_tab(pl.id, drop=True)
)
)
raised = None
try:
pl_mod.merge_playlist(db, user, pl)
except Exception as exc: # noqa: BLE001 — the regression raises too, AFTER its YouTube writes
raised = exc
assert [v for _, v in fake.items] == ["a", "b", "ghost"] # YouTube untouched
assert isinstance(raised, pl_mod.PlaylistGone) # which the route answers with a 404
@pytest.mark.parametrize("linked", [True, False], ids=["linked", "first-push"])
def test_an_edit_made_during_a_push_s_youtube_writes_stays_dirty(db, make_user, fake_yt, linked):
user = make_user()