fix(playlists): item writes lock the list first; one bulk pull per user; the claim on its own connection
- Every playlist item write (add, remove, move, reorder, rename; Plex remove and remove-bulk) row-locks the playlist before it touches an item, a move both lists in id order: remove beside reorder was a deadlock, and two adds of one video a unique-key 500. - The scheduled mirror refresh re-reads dirty under that lock, so an edit made during its YouTube calls is not replaced by YouTube's order. - A user's bulk pull holds a per-user advisory claim: two at once created duplicate mirrors. The manual sync answers 409 sync_all_busy instead of '0 synced', and like the re-pull spends its cooldown only on a pull that runs. - Deleted mirrors are committed at once, not only when a later list commits. - The claims open an unpooled connection of their own instead of a third pooled one per push.
This commit is contained in:
@@ -9,13 +9,14 @@ ingested (with a stub channel) so the mirror is faithful."""
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Callable, Iterator
|
||||
from contextlib import contextmanager
|
||||
from contextlib import AbstractContextManager, contextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import TypeVar
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy import Engine, create_engine, delete, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app import logs, progress, quota
|
||||
from app.auth import has_read_scope
|
||||
@@ -28,38 +29,55 @@ log = logs.get_logger(__name__)
|
||||
# Cost of a single YouTube write op (playlists/playlistItems insert/update/delete).
|
||||
WRITE_UNIT_COST = 50
|
||||
|
||||
# The advisory-lock namespace of `sync_claim` ("PL"), so its per-playlist key cannot meet another
|
||||
# The advisory-lock namespaces ("PL" per playlist, "PU" per user), so a key cannot meet another
|
||||
# feature's lock on the same number (Postgres keeps the two-int keys apart from the one-bigint ones).
|
||||
_SYNC_LOCK_NS = 0x504C
|
||||
_USER_SYNC_LOCK_NS = 0x5055
|
||||
|
||||
# The claims' own connections, outside the app pool: a claim is held for a whole push (seconds of
|
||||
# YouTube calls) beside the request's pooled session and quota's, so a burst of pushes drawing a
|
||||
# third pooled connection each ran the pool dry and stalled every other request for pool_timeout.
|
||||
# NullPool opens one per claim and closes it after, so a lock can never ride back into a pool either.
|
||||
_claim_engines: dict[str, Engine] = {}
|
||||
|
||||
|
||||
def _claim_engine(db: Session) -> Engine:
|
||||
url = db.get_bind().engine.url
|
||||
key = url.render_as_string(hide_password=False)
|
||||
eng = _claim_engines.get(key)
|
||||
if eng is None:
|
||||
eng = _claim_engines[key] = create_engine(url, poolclass=NullPool, hide_parameters=True)
|
||||
return eng
|
||||
|
||||
|
||||
@contextmanager
|
||||
def sync_claim(db: Session, playlist_id: int) -> Iterator[bool]:
|
||||
"""Claim one playlist's YouTube sync (push, re-pull, the scheduled mirror refresh) for the block;
|
||||
yields whether it was free. A push reads YouTube, then writes both sides for seconds and commits
|
||||
step by step, so a row lock ends at its first commit and two tabs (or a push and the scheduler)
|
||||
interleave into a doubled YouTube item or a baseline recorded over the other's writes. A
|
||||
session-level advisory lock on a connection of its own survives those commits, and a crashed
|
||||
worker's lock dies with its connection — no claim column that a crash leaves stuck."""
|
||||
conn = db.get_bind().engine.connect()
|
||||
def _advisory_claim(db: Session, ns: int, key: int) -> Iterator[bool]:
|
||||
"""Try a session-level advisory lock for the block on a connection of its own; yields whether it
|
||||
was free. It survives the block's commits, and a crashed worker's lock dies with its connection
|
||||
— no claim column that a crash leaves stuck."""
|
||||
conn = _claim_engine(db).connect()
|
||||
held = False
|
||||
try:
|
||||
held = bool(conn.scalar(select(func.pg_try_advisory_lock(_SYNC_LOCK_NS, playlist_id))))
|
||||
held = bool(conn.scalar(select(func.pg_try_advisory_lock(ns, key))))
|
||||
conn.commit()
|
||||
yield held
|
||||
finally:
|
||||
try:
|
||||
if held:
|
||||
conn.scalar(select(func.pg_advisory_unlock(_SYNC_LOCK_NS, playlist_id)))
|
||||
conn.scalar(select(func.pg_advisory_unlock(ns, key)))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
# A lock whose unlock failed must not ride back into the pool on this connection.
|
||||
conn.invalidate()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def sync_claim(db: Session, playlist_id: int) -> AbstractContextManager[bool]:
|
||||
"""Claim one playlist's YouTube sync (push, re-pull, the scheduled mirror refresh) for the block;
|
||||
yields whether it was free. A push reads YouTube, then writes both sides for seconds and commits
|
||||
step by step, so a row lock ends at its first commit and two tabs (or a push and the scheduler)
|
||||
interleave into a doubled YouTube item or a baseline recorded over the other's writes."""
|
||||
return _advisory_claim(db, _SYNC_LOCK_NS, playlist_id)
|
||||
|
||||
|
||||
def fingerprint(name: str, ids: list[str]) -> str:
|
||||
"""A stable checksum of a playlist's name + ordered video ids. Stored as the synced
|
||||
baseline; the current fingerprint is compared to it to derive `dirty`."""
|
||||
@@ -228,6 +246,21 @@ def sync_user_playlists(db: Session, user: User) -> dict:
|
||||
the user's local playlists and the built-in Watch later untouched."""
|
||||
if not has_read_scope(user):
|
||||
return {"synced": 0, "reason": "no read scope"}
|
||||
with user_sync_claim(db, user.id) as free:
|
||||
if not free:
|
||||
return {"synced": 0, "reason": "busy"}
|
||||
return pull_user_playlists(db, user)
|
||||
|
||||
|
||||
def user_sync_claim(db: Session, user_id: int) -> AbstractContextManager[bool]:
|
||||
"""Claim a user's bulk mirror pull for the block; yields whether it was free. Two runs at once
|
||||
(the scheduler beside a manual "sync now", two tabs) both saw a YouTube list unmirrored and
|
||||
each created its mirror — a duplicate row."""
|
||||
return _advisory_claim(db, _USER_SYNC_LOCK_NS, user_id)
|
||||
|
||||
|
||||
def pull_user_playlists(db: Session, user: User) -> dict:
|
||||
"""The mirror pull itself; the caller holds `user_sync_claim`."""
|
||||
synced = 0
|
||||
with YouTubeClient(db, user) as yt:
|
||||
yt_playlists = list(yt.iter_my_playlists())
|
||||
@@ -255,7 +288,9 @@ def sync_user_playlists(db: Session, user: User) -> dict:
|
||||
for ytid, pl in existing.items():
|
||||
if ytid not in yt_ids:
|
||||
db.delete(pl)
|
||||
db.flush()
|
||||
# Committed now: a run where no list below commits (all dirty, or all claimed) would
|
||||
# otherwise drop the deletions with the session.
|
||||
db.commit()
|
||||
|
||||
for idx, p in enumerate(yt_playlists):
|
||||
ytid = p.get("id")
|
||||
@@ -282,9 +317,25 @@ def sync_user_playlists(db: Session, user: User) -> dict:
|
||||
# The user is pushing or re-pulling this very list: it writes the same rows.
|
||||
# The next scheduled run refreshes it.
|
||||
continue
|
||||
ids = list(yt.iter_my_playlist_video_ids(ytid))
|
||||
# Ingest first — it commits — so the row lock below is held from the dirty check
|
||||
# to the commit that replaces the items.
|
||||
_ensure_videos(db, yt, ids)
|
||||
# `dirty` read above is a snapshot from before the YouTube calls (sessions keep
|
||||
# loaded rows past a commit): a remove or reorder made meanwhile must not be
|
||||
# replaced by YouTube's order. Item writes lock the playlist row, so this waits for
|
||||
# one in flight and sees what it committed.
|
||||
db.scalars(
|
||||
select(Playlist)
|
||||
.where(Playlist.id == pl.id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
).all() # consumed: rows fetched are what refreshes the loaded objects
|
||||
if pl.dirty:
|
||||
db.rollback()
|
||||
continue
|
||||
pl.name = p.get("title") or pl.name
|
||||
# Mirror is authoritative: replace the items to match YouTube's order.
|
||||
ids = list(yt.iter_my_playlist_video_ids(ytid))
|
||||
_replace_items_from_youtube(db, yt, pl, ids)
|
||||
db.commit()
|
||||
synced += 1
|
||||
|
||||
Reference in New Issue
Block a user