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
+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)
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
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
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
writer committed. Held to the caller's next commit — so no commit may come between this and
the write it guards."""
db.scalars(
the write it guards. Returns the ids it found: a list deleted meanwhile is not among them."""
rows = db.scalars(
select(Playlist)
.where(Playlist.id.in_(sorted(set(playlist_ids))))
.order_by(Playlist.id)
.with_for_update()
.execution_options(populate_existing=True)
).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:
@@ -158,11 +174,18 @@ def parse_unavailable(pl: Playlist) -> set[str]:
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
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)."""
pl.synced_fingerprint = fingerprint(pl.name, ids)
next merge skips them; stored only when non-empty (keeps the common case a plain list). `name`:
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:
pl.synced_video_ids = json.dumps({"ids": ids, "unavailable": list(unavailable)})
else:
@@ -171,14 +194,21 @@ def _set_baseline(pl: Playlist, ids: list[str], unavailable: list[str] | None =
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:
"""A push's closing baseline: `ids` is what YouTube now holds. The push spent seconds on
YouTube writes since it replaced the local items, so an edit may have landed meanwhile — lock
the list and derive `dirty` from what is there, or the edit would read as synced and never
reach YouTube."""
lock_playlists(db, pl.id)
_set_baseline(pl, ids, unavailable)
"""A push's closing baseline: `ids` and `name` are what YouTube now holds. The push spent
seconds on YouTube writes since it replaced the local items, so an edit may have landed
meanwhile — lock the list and derive `dirty` from what is there, or the edit would read as
synced and never reach YouTube. The name too: the lock re-reads a rename made meanwhile, and a
baseline taken over THAT name matched it, so the list read clean with YouTube's title still the
old one (E28 review round 4)."""
_lock_or_gone(db, pl)
_set_baseline(pl, ids, unavailable, name=name)
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)
with YouTubeClient(db, user) as yt:
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()
created = 1
# 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):
inserted += 1
if not failures:
_set_baseline_under_lock(db, pl, local)
_set_baseline_under_lock(db, pl, local, name=pushed_name)
db.commit()
return {
"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
# (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
# the re-read adds is already a local row.
lock_playlists(db, pl.id)
# the re-read adds is already a local row. A list deleted meanwhile stops here, before a plan
# over its now-empty items removes them all from YouTube.
_lock_or_gone(db, pl)
local = _local_video_ids(db, pl)
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
@@ -673,10 +705,12 @@ def merge_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
_set_local_items(db, pl, final_local)
db.commit()
# --- YouTube side (metered, breaker-guarded).
if snippet is not None and (snippet.get("title") or "") != pl.name:
# --- YouTube side (metered, breaker-guarded). The name as of the lock above: the closing
# 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:
yt.update_playlist_title(pl.yt_playlist_id, pl.name)
yt.update_playlist_title(pl.yt_playlist_id, pushed_name)
except YouTubeError as exc:
# All the caller passes on is the word "title", which says the rename did not
# 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})
& 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()
return {
"created": created,
@@ -772,7 +806,7 @@ def repull_playlist(db: Session, user: User, pl: Playlist) -> dict:
raise YouTubeError("Playlist no longer exists on YouTube")
ids = list(yt.iter_my_playlist_video_ids(pl.yt_playlist_id))
_ensure_videos(db, yt, ids)
lock_playlists(db, pl.id)
_lock_or_gone(db, pl)
pl.name = snippet.get("title") or pl.name
ordered = _replace_items_from_youtube(db, pl, ids)
db.commit()