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:
2026-07-23 22:36:33 +02:00
parent 2f46947dcf
commit 4209f6bb24
6 changed files with 150 additions and 18 deletions
+57
View File
@@ -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,
}
+16 -2
View File
@@ -25,7 +25,7 @@ from app.sync.videos import (
run_shorts_classification,
)
from app.youtube.client import YouTubeClient
from app.youtube.rss import fetch_channel_feed
from app.youtube.rss import RssError, fetch_channel_feed
# RSS feeds are free and network-bound, so fetch many at once; DB writes stay single-threaded.
RSS_POLL_WORKERS = 16
@@ -54,6 +54,7 @@ def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> int:
total = len(channels)
new = 0
done = 0
failed = 0
# Fetch feeds concurrently (network-bound, no DB), then apply inserts on this thread as
# each fetch returns — a SQLAlchemy session isn't thread-safe. Pass the channel id (a
# plain str) to the workers; the ORM instance is touched only here on the main thread.
@@ -65,9 +66,20 @@ def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> int:
channel = futures[fut]
try:
entries = fut.result()
except RssError as exc:
# A failed poll is NOT an empty feed: skip the apply so `last_rss_at` stays where
# it was. Stamping it here made a permanently-broken feed look freshly polled.
log.warning("RSS poll skipped for channel %s: %s", channel.id, exc)
failed += 1
done += 1
progress.report(done, total, "rss_poll")
continue
except Exception:
log.exception("RSS fetch failed for channel %s", channel.id)
entries = []
failed += 1
done += 1
progress.report(done, total, "rss_poll")
continue
try:
new += apply_rss_feed(db, channel, entries)
except Exception:
@@ -75,6 +87,8 @@ def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> int:
log.exception("RSS apply failed for channel %s", channel.id)
done += 1
progress.report(done, total, "rss_poll")
if failed:
log.warning("RSS poll: %d/%d channel feeds could not be read", failed, total)
return new
+18 -5
View File
@@ -108,17 +108,29 @@ def import_subscriptions(db: Session, user: User) -> dict:
sub_row.yt_subscription_id = sub.get("yt_subscription_id")
db.flush()
# Prune subscriptions removed on YouTube.
# Prune subscriptions removed on YouTube — but NEVER on an empty fetch. The API can answer
# 200 with no items (a transient blip, a scope/token edge), and pruning against that wipes
# every subscription the user has, taking each row's `deep_requested` flag with it, in one
# run. Someone who genuinely unsubscribed from everything simply keeps their rows until a
# sync that returns something.
removed = 0
existing = (
db.execute(select(Subscription).where(Subscription.user_id == user.id))
.scalars()
.all()
)
for sub_row in existing:
if sub_row.channel_id not in fetched:
db.delete(sub_row)
removed += 1
prune_skipped = bool(existing) and not fetched
if prune_skipped:
log.warning(
"Subscription import (user %s): YouTube returned 0 subscriptions while %d exist "
"locally — skipping the prune (treating it as a transient empty response)",
user.id, len(existing),
)
else:
for sub_row in existing:
if sub_row.channel_id not in fetched:
db.delete(sub_row)
removed += 1
db.commit()
# Fetch details for channels that don't have them yet.
@@ -144,6 +156,7 @@ def import_subscriptions(db: Session, user: User) -> dict:
"channels_new": new_channels,
"channels_detailed": detailed,
"removed_stale": removed,
"prune_skipped": prune_skipped,
}
log.info("Subscription import (user %s): %s", user.id, result)
return result