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
+28 -16
View File
@@ -14,6 +14,7 @@ from app.db import get_db
from app.feedquery import feed_columns, serialize from app.feedquery import feed_columns, serialize
from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState
from app.sync.playlists import ( from app.sync.playlists import (
PlaylistGone,
lock_playlists, lock_playlists,
merge_playlist, merge_playlist,
plan_merge, plan_merge,
@@ -24,7 +25,7 @@ from app.sync.playlists import (
user_sync_claim, user_sync_claim,
) )
from app.ratelimit import RateLimiter from app.ratelimit import RateLimiter
from app.routes._common import get_or_404, owned_or_404, youtube_refusal from app.routes._common import get_or_404, not_found, owned_or_404, youtube_refusal
from app.youtube.client import YouTubeClient, YouTubeError from app.youtube.client import YouTubeClient, YouTubeError
router = APIRouter(prefix="/api/playlists", tags=["playlists"]) router = APIRouter(prefix="/api/playlists", tags=["playlists"])
@@ -367,6 +368,8 @@ def revert_youtube(
try: try:
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL): with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
return {"ok": True, **repull_playlist(db, user, pl)} return {"ok": True, **repull_playlist(db, user, pl)}
except PlaylistGone:
raise not_found("playlist")
except YouTubeError as exc: except YouTubeError as exc:
db.rollback() db.rollback()
raise youtube_refusal(exc) raise youtube_refusal(exc)
@@ -432,6 +435,8 @@ def push(
if plan["units_estimate"] > quota.remaining_today(db): if plan["units_estimate"] > quota.remaining_today(db):
raise _refused(429, "quota_exceeded") raise _refused(429, "quota_exceeded")
return {"ok": True, **merge_playlist(db, user, pl, live=live)} return {"ok": True, **merge_playlist(db, user, pl, live=live)}
except PlaylistGone:
raise not_found("playlist")
except YouTubeError as exc: except YouTubeError as exc:
db.rollback() db.rollback()
raise youtube_refusal(exc) raise youtube_refusal(exc)
@@ -517,21 +522,28 @@ def delete_playlist(
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist") pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
if pl.kind == "watch_later": if pl.kind == "watch_later":
raise _refused(400, "watch_later_undeletable") raise _refused(400, "watch_later_undeletable")
if on_youtube: # Not in the middle of a sync of it (`sync_claim`): a push that read the list before the delete
if not pl.yt_playlist_id: # and wrote YouTube after it planned from no items at all, so it emptied the YouTube playlist a
raise _refused(400, "not_on_youtube") # local-only delete means to keep. The push checks for a vanished list too (`PlaylistGone`);
if not has_write_scope(user): # this refuses the delete while a sync runs rather than race it.
raise _refused(403, "youtube_editing_off") with sync_claim(db, pl.id) as free:
try: if not free:
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_DELETE): raise _refused(409, "sync_busy")
with YouTubeClient(db, user) as yt: if on_youtube:
yt.delete_playlist(pl.yt_playlist_id) if not pl.yt_playlist_id:
except YouTubeError as exc: raise _refused(400, "not_on_youtube")
raise youtube_refusal(exc) if not has_write_scope(user):
# The playlist first, as every item write: the cascade deletes the items before the list row. raise _refused(403, "youtube_editing_off")
lock_playlists(db, pl.id) try:
db.delete(pl) # items cascade with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_DELETE):
db.commit() with YouTubeClient(db, user) as yt:
yt.delete_playlist(pl.yt_playlist_id)
except YouTubeError as exc:
raise youtube_refusal(exc)
# The playlist first, as every item write: the cascade deletes the items before the list row.
lock_playlists(db, pl.id)
db.delete(pl) # items cascade
db.commit()
return {"deleted": playlist_id} return {"deleted": playlist_id}
+56 -22
View File
@@ -78,21 +78,37 @@ def sync_claim(db: Session, playlist_id: int) -> AbstractContextManager[bool]:
return _advisory_claim(db, _SYNC_LOCK_NS, playlist_id) return _advisory_claim(db, _SYNC_LOCK_NS, playlist_id)
def lock_playlists(db: Session, *playlist_ids: int) -> None: class PlaylistGone(Exception):
"""The list a sync was working on was deleted meanwhile (another tab, the mirror drop). Its
items went with it, so a plan made now reads "every item removed here" — and would delete them
all from the YouTube playlist the reader chose to keep. The route answers 404."""
def lock_playlists(db: Session, *playlist_ids: int) -> set[int]:
"""Row-lock playlists before touching their items, several in id order. Every item write takes """Row-lock playlists before touching their items, several in id order. Every item write takes
the PLAYLIST first — the route edits, a push, a re-pull, the scheduled mirror refresh and a the PLAYLIST first — the route edits, a push, a re-pull, the scheduled mirror refresh and a
delete alike: a writer that locked an item row first and the playlist second (a push's item delete alike: a writer that locked an item row first and the playlist second (a push's item
replace beside a remove) deadlocks against one that went the other way, and the loser is a replace beside a remove) deadlocks against one that went the other way, and the loser is a
500. The rows are re-read, so `dirty`, the name and the sync baseline are the ones a finished 500. The rows are re-read, so `dirty`, the name and the sync baseline are the ones a finished
writer committed. Held to the caller's next commit — so no commit may come between this and writer committed. Held to the caller's next commit — so no commit may come between this and
the write it guards.""" the write it guards. Returns the ids it found: a list deleted meanwhile is not among them."""
db.scalars( rows = db.scalars(
select(Playlist) select(Playlist)
.where(Playlist.id.in_(sorted(set(playlist_ids)))) .where(Playlist.id.in_(sorted(set(playlist_ids))))
.order_by(Playlist.id) .order_by(Playlist.id)
.with_for_update() .with_for_update()
.execution_options(populate_existing=True) .execution_options(populate_existing=True)
).all() # consumed: rows fetched are what refreshes the loaded objects ).all() # consumed: rows fetched are what refreshes the loaded objects
return {pl.id for pl in rows}
def _lock_or_gone(db: Session, pl: Playlist) -> None:
"""`lock_playlists` for a sync that goes on to write YouTube or the items: a list deleted since
it was read raises `PlaylistGone` (rolled back) instead of being planned as an empty one."""
pl_id = pl.id # read first: the rollback expires `pl`, and its row is gone
if pl_id not in lock_playlists(db, pl_id):
db.rollback()
raise PlaylistGone(pl_id)
def fingerprint(name: str, ids: list[str]) -> str: def fingerprint(name: str, ids: list[str]) -> str:
@@ -158,11 +174,18 @@ def parse_unavailable(pl: Playlist) -> set[str]:
return _load_baseline(pl)[1] return _load_baseline(pl)[1]
def _set_baseline(pl: Playlist, ids: list[str], unavailable: list[str] | None = None) -> None: def _set_baseline(
pl: Playlist,
ids: list[str],
unavailable: list[str] | None = None,
*,
name: str | None = None,
) -> None:
"""Record `ids` as both the hash (drives `dirty`, over the local ids only) and the raw ordered """Record `ids` as both the hash (drives `dirty`, over the local ids only) and the raw ordered
set (drives merge). `unavailable` = un-ingestable-but-live YouTube ids to carry forward so the set (drives merge). `unavailable` = un-ingestable-but-live YouTube ids to carry forward so the
next merge skips them; stored only when non-empty (keeps the common case a plain list).""" next merge skips them; stored only when non-empty (keeps the common case a plain list). `name`:
pl.synced_fingerprint = fingerprint(pl.name, ids) the title YouTube now has, when that may differ from the list's own (see the push)."""
pl.synced_fingerprint = fingerprint(pl.name if name is None else name, ids)
if unavailable: if unavailable:
pl.synced_video_ids = json.dumps({"ids": ids, "unavailable": list(unavailable)}) pl.synced_video_ids = json.dumps({"ids": ids, "unavailable": list(unavailable)})
else: else:
@@ -171,14 +194,21 @@ def _set_baseline(pl: Playlist, ids: list[str], unavailable: list[str] | None =
def _set_baseline_under_lock( def _set_baseline_under_lock(
db: Session, pl: Playlist, ids: list[str], unavailable: list[str] | None = None db: Session,
pl: Playlist,
ids: list[str],
unavailable: list[str] | None = None,
*,
name: str,
) -> None: ) -> None:
"""A push's closing baseline: `ids` is what YouTube now holds. The push spent seconds on """A push's closing baseline: `ids` and `name` are what YouTube now holds. The push spent
YouTube writes since it replaced the local items, so an edit may have landed meanwhile — lock seconds on YouTube writes since it replaced the local items, so an edit may have landed
the list and derive `dirty` from what is there, or the edit would read as synced and never meanwhile — lock the list and derive `dirty` from what is there, or the edit would read as
reach YouTube.""" synced and never reach YouTube. The name too: the lock re-reads a rename made meanwhile, and a
lock_playlists(db, pl.id) baseline taken over THAT name matched it, so the list read clean with YouTube's title still the
_set_baseline(pl, ids, unavailable) old one (E28 review round 4)."""
_lock_or_gone(db, pl)
_set_baseline(pl, ids, unavailable, name=name)
recompute_dirty(db, pl) recompute_dirty(db, pl)
@@ -599,7 +629,8 @@ def merge_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
breaker = _Breaker(playlist_id=pl.id) breaker = _Breaker(playlist_id=pl.id)
with YouTubeClient(db, user) as yt: with YouTubeClient(db, user) as yt:
if not pl.yt_playlist_id: if not pl.yt_playlist_id:
pl.yt_playlist_id = yt.create_playlist(pl.name) pushed_name = pl.name
pl.yt_playlist_id = yt.create_playlist(pushed_name)
db.commit() db.commit()
created = 1 created = 1
# Brand-new playlist: just append every local video in order. # Brand-new playlist: just append every local video in order.
@@ -612,7 +643,7 @@ def merge_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
if not isinstance(added, _Failed): if not isinstance(added, _Failed):
inserted += 1 inserted += 1
if not failures: if not failures:
_set_baseline_under_lock(db, pl, local) _set_baseline_under_lock(db, pl, local, name=pushed_name)
db.commit() db.commit()
return { return {
"created": created, "created": created,
@@ -656,8 +687,9 @@ def merge_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
# `local` was read before the YouTube calls above: an add or remove committed meanwhile # `local` was read before the YouTube calls above: an add or remove committed meanwhile
# (another tab) would be undone by the replace below. Lock the list — the ingest's commit is # (another tab) would be undone by the replace below. Lock the list — the ingest's commit is
# behind us — and plan again from what is there now. Nothing new needs ingesting: a video # behind us — and plan again from what is there now. Nothing new needs ingesting: a video
# the re-read adds is already a local row. # the re-read adds is already a local row. A list deleted meanwhile stops here, before a plan
lock_playlists(db, pl.id) # over its now-empty items removes them all from YouTube.
_lock_or_gone(db, pl)
local = _local_video_ids(db, pl) local = _local_video_ids(db, pl)
mp = merge_plan(parse_baseline(pl), local, yt_ids, effective_unavailable) mp = merge_plan(parse_baseline(pl), local, yt_ids, effective_unavailable)
# Only the ids that actually exist in the catalog can become local rows (a private/deleted # Only the ids that actually exist in the catalog can become local rows (a private/deleted
@@ -673,10 +705,12 @@ def merge_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
_set_local_items(db, pl, final_local) _set_local_items(db, pl, final_local)
db.commit() db.commit()
# --- YouTube side (metered, breaker-guarded). # --- YouTube side (metered, breaker-guarded). The name as of the lock above: the closing
if snippet is not None and (snippet.get("title") or "") != pl.name: # baseline records THIS one, whatever a rename made during the writes below says.
pushed_name = pl.name
if snippet is not None and (snippet.get("title") or "") != pushed_name:
try: try:
yt.update_playlist_title(pl.yt_playlist_id, pl.name) yt.update_playlist_title(pl.yt_playlist_id, pushed_name)
except YouTubeError as exc: except YouTubeError as exc:
# All the caller passes on is the word "title", which says the rename did not # All the caller passes on is the word "title", which says the rename did not
# happen but not that YouTube refused it, or why. # happen but not that YouTube refused it, or why.
@@ -744,7 +778,7 @@ def merge_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
(effective_unavailable | {v for v in mp.add_to_local if v not in present}) (effective_unavailable | {v for v in mp.add_to_local if v not in present})
& yt_id_set & yt_id_set
) )
_set_baseline_under_lock(db, pl, final_local, unavailable) _set_baseline_under_lock(db, pl, final_local, unavailable, name=pushed_name)
db.commit() db.commit()
return { return {
"created": created, "created": created,
@@ -772,7 +806,7 @@ def repull_playlist(db: Session, user: User, pl: Playlist) -> dict:
raise YouTubeError("Playlist no longer exists on YouTube") raise YouTubeError("Playlist no longer exists on YouTube")
ids = list(yt.iter_my_playlist_video_ids(pl.yt_playlist_id)) ids = list(yt.iter_my_playlist_video_ids(pl.yt_playlist_id))
_ensure_videos(db, yt, ids) _ensure_videos(db, yt, ids)
lock_playlists(db, pl.id) _lock_or_gone(db, pl)
pl.name = snippet.get("title") or pl.name pl.name = snippet.get("title") or pl.name
ordered = _replace_items_from_youtube(db, pl, ids) ordered = _replace_items_from_youtube(db, pl, ids)
db.commit() db.commit()
@@ -67,7 +67,12 @@ def test_push_and_revert_refuse_while_the_list_is_claimed(
r = c.post(f"/api/playlists/{pl.id}/{path}") r = c.post(f"/api/playlists/{pl.id}/{path}")
assert r.status_code == 409 assert r.status_code == 409
assert r.json()["detail"] == {"code": "playlist", "reason": "sync_busy"} 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 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. # 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}/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. talking to YouTube — when the push holds no lock, as a real one does not.
""" """
import json
import re import re
from datetime import datetime, timezone from datetime import datetime, timezone
import pytest import pytest
from sqlalchemy import event from sqlalchemy import delete, event, text
from app.db import SessionLocal from app.db import SessionLocal
from app.models import Channel, Playlist, PlaylistItem, Video from app.models import Channel, Playlist, PlaylistItem, Video
@@ -151,9 +152,16 @@ def _playlist(db, user, local, *, source="local", baseline=None):
return pl return pl
def _other_tab(pl_id, *, add=None, remove=None): 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.""" """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: 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: if add:
pos = len(s.query(PlaylistItem).filter_by(playlist_id=pl_id).all()) pos = len(s.query(PlaylistItem).filter_by(playlist_id=pl_id).all())
s.add(PlaylistItem(playlist_id=pl_id, video_id=add, position=pos)) 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 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"]) @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): def test_an_edit_made_during_a_push_s_youtube_writes_stays_dirty(db, make_user, fake_yt, linked):
user = make_user() user = make_user()