fix(sync): R5 S2 — sync guards
- never prune subscriptions on an empty fetch: one anomalous empty-but-200 response wiped every subscription (and its deep_requested flag) in one run - push_playlist: stop after 5 consecutive write failures — a quota-dead or scope-revoked account otherwise burns 50 units per remaining item - RSS: raise RssError instead of returning [] on a transport/HTTP failure, so a failed poll no longer stamps last_rss_at and a 404 feed stops looking healthy - worker: exit when the schema never appears instead of "continuing" into a crash one call later
This commit is contained in:
@@ -263,6 +263,40 @@ def plan_push(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
|
||||
}
|
||||
|
||||
|
||||
# After this many consecutive write failures we stop calling YouTube for the rest of the push.
|
||||
# The failure modes that matter here are all account-wide, not per-video: quota exhausted, the
|
||||
# write scope revoked, the token dead. Every remaining write then fails identically — but each
|
||||
# one still SPENDS 50 units, so a long playlist burns through the whole (already dead) budget,
|
||||
# and tomorrow's too, to produce a failure list we knew after the fifth item.
|
||||
_MAX_CONSECUTIVE_FAILURES = 5
|
||||
|
||||
|
||||
class _Breaker:
|
||||
"""Consecutive-failure circuit breaker for a single push run. Any success re-arms it."""
|
||||
|
||||
def __init__(self, limit: int = _MAX_CONSECUTIVE_FAILURES) -> None:
|
||||
self.limit = limit
|
||||
self.streak = 0
|
||||
self.tripped = False
|
||||
|
||||
def open(self) -> bool:
|
||||
"""True while writes may still be attempted."""
|
||||
return not self.tripped
|
||||
|
||||
def hit(self, ok: bool) -> None:
|
||||
if ok:
|
||||
self.streak = 0
|
||||
return
|
||||
self.streak += 1
|
||||
if self.streak >= self.limit and not self.tripped:
|
||||
self.tripped = True
|
||||
log.warning(
|
||||
"playlist push: %d consecutive YouTube write failures — stopping this run "
|
||||
"(quota, scope or token); the remaining items are reported as failures",
|
||||
self.limit,
|
||||
)
|
||||
|
||||
|
||||
def push_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
|
||||
"""Push a local playlist to YouTube: create it if needed, then reconcile membership
|
||||
and order so YouTube matches local (local wins). Marks the playlist clean on success.
|
||||
@@ -271,6 +305,7 @@ def push_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
|
||||
desired = _local_video_ids(db, pl)
|
||||
inserted = deleted = reordered = created = 0
|
||||
failures: list[str] = []
|
||||
breaker = _Breaker()
|
||||
with YouTubeClient(db, user) as yt:
|
||||
if not pl.yt_playlist_id:
|
||||
pl.yt_playlist_id = yt.create_playlist(pl.name)
|
||||
@@ -278,11 +313,16 @@ def push_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
|
||||
created = 1
|
||||
# Brand-new playlist: just append every video in order.
|
||||
for vid in desired:
|
||||
if not breaker.open():
|
||||
failures.append(vid)
|
||||
continue
|
||||
try:
|
||||
yt.add_playlist_item(pl.yt_playlist_id, vid)
|
||||
inserted += 1
|
||||
breaker.hit(True)
|
||||
except YouTubeError:
|
||||
failures.append(vid)
|
||||
breaker.hit(False)
|
||||
if not failures:
|
||||
pl.synced_fingerprint = fingerprint(pl.name, desired)
|
||||
pl.dirty = False
|
||||
@@ -293,6 +333,7 @@ def push_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
|
||||
"deleted": deleted,
|
||||
"reordered": reordered,
|
||||
"failures": failures,
|
||||
"aborted": breaker.tripped,
|
||||
"yt_playlist_id": pl.yt_playlist_id,
|
||||
}
|
||||
|
||||
@@ -313,20 +354,30 @@ def push_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
|
||||
# Delete extras (local is authoritative).
|
||||
for it in current:
|
||||
if it["video_id"] not in desired_set:
|
||||
if not breaker.open():
|
||||
failures.append(it["video_id"])
|
||||
continue
|
||||
try:
|
||||
yt.delete_playlist_item(it["item_id"])
|
||||
deleted += 1
|
||||
breaker.hit(True)
|
||||
except YouTubeError:
|
||||
failures.append(it["video_id"])
|
||||
breaker.hit(False)
|
||||
# Insert missing (append; the reorder pass below fixes positions).
|
||||
cur_set = set(cur_vids)
|
||||
for vid in desired:
|
||||
if vid not in cur_set:
|
||||
if not breaker.open():
|
||||
failures.append(vid)
|
||||
continue
|
||||
try:
|
||||
item_id_by_vid[vid] = yt.add_playlist_item(pl.yt_playlist_id, vid)
|
||||
inserted += 1
|
||||
breaker.hit(True)
|
||||
except YouTubeError:
|
||||
failures.append(vid)
|
||||
breaker.hit(False)
|
||||
# Reorder to match `desired` via insertion-sort moves over a local model.
|
||||
model = [v for v in cur_vids if v in desired_set] + [
|
||||
v for v in desired if v not in cur_set and v in item_id_by_vid
|
||||
@@ -336,11 +387,16 @@ def push_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
|
||||
continue # failed insert; skip
|
||||
if i < len(model) and model[i] == want:
|
||||
continue
|
||||
if not breaker.open():
|
||||
failures.append(want)
|
||||
continue
|
||||
try:
|
||||
yt.move_playlist_item(item_id_by_vid[want], pl.yt_playlist_id, want, i)
|
||||
reordered += 1
|
||||
breaker.hit(True)
|
||||
except YouTubeError:
|
||||
failures.append(want)
|
||||
breaker.hit(False)
|
||||
continue
|
||||
j = model.index(want)
|
||||
model.pop(j)
|
||||
@@ -355,6 +411,7 @@ def push_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
|
||||
"deleted": deleted,
|
||||
"reordered": reordered,
|
||||
"failures": failures,
|
||||
"aborted": breaker.tripped,
|
||||
"yt_playlist_id": pl.yt_playlist_id,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user