fix(sync): read a dark RSS feed through the API instead
YouTube's RSS goes dark per channel for hours (prod 2026-09-18/19: 393 of 396 feeds answered 404 for four hours, sticky per channel, so a retry does not help), and nothing else read a followed channel's new uploads. A channel whose feed has not answered for `rss_fallback_hours` (admin knob, default 2, 0 = off) now gets one page of its uploads playlist through the Data API (1 unit), at most once per window, yielding to the backfill reserve. Spend is attributed to its own quota action.
This commit is contained in:
@@ -9,8 +9,9 @@ import logging
|
||||
import threading
|
||||
from collections import Counter
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import logs, progress, quota, sysconfig
|
||||
@@ -25,11 +26,13 @@ from app.sync.videos import (
|
||||
backfill_channel_deep,
|
||||
backfill_channel_recent,
|
||||
enrich_pending,
|
||||
insert_stubs,
|
||||
reconcile_full_history,
|
||||
refresh_live,
|
||||
run_shorts_classification,
|
||||
stub_from_playlist_item,
|
||||
)
|
||||
from app.youtube.client import YouTubeClient
|
||||
from app.youtube.client import YouTubeClient, YouTubeError
|
||||
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.
|
||||
@@ -85,6 +88,9 @@ def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> dict[str
|
||||
# of 2026-09-16 into 11k warnings in four hours; each channel's own line is DEBUG now.
|
||||
reasons: Counter[str] = Counter()
|
||||
examples: list[str] = []
|
||||
# Channels whose FEED would not answer (not those whose rows failed to apply: the API would
|
||||
# hand the same rows to the same broken write) — the feed fallback's candidates.
|
||||
unread: list[str] = []
|
||||
|
||||
def fail(reason: str, detail: str, *, unexpected: str | None = None) -> None:
|
||||
"""Count a channel that did not get polled. `unexpected` is the message for a failure that
|
||||
@@ -120,6 +126,7 @@ def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> dict[str
|
||||
except RssError as exc:
|
||||
log.debug("RSS poll skipped for channel %s: %s", channel.id, exc)
|
||||
fail(exc.reason, f"{channel.id}: {exc}")
|
||||
unread.append(channel.id)
|
||||
# silent-ok: `fail(unexpected=…)` is the report — it logs at ERROR with the
|
||||
# traceback, once per reason per poll, which is the flood control this loop needs.
|
||||
except Exception as exc: # noqa: BLE001 — one channel must not stop the poll
|
||||
@@ -143,6 +150,7 @@ def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> dict[str
|
||||
# One place that advances the counter, whatever happened above.
|
||||
done += 1
|
||||
progress.report(done, total, "rss_poll")
|
||||
fallback = run_feed_fallback(db, unread)
|
||||
if failed:
|
||||
log.warning(
|
||||
"RSS poll: %d/%d channels could not be polled",
|
||||
@@ -164,8 +172,90 @@ def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> dict[str
|
||||
# subscriptions "all channels failed" is also what ONE dead feed id looks like, and a red
|
||||
# card the user can't act on is its own kind of dishonest. `channel: HTTP 404` says
|
||||
# "fix this subscription"; `Connection refused` says "the egress is down".
|
||||
raise RuntimeError(f"RSS poll failed for all {total} channels — first: {first_error}")
|
||||
return {"new": new, "failed": failed}
|
||||
# The fallback's count rides along: a red card during an RSS outage should still say
|
||||
# whether the uploads are arriving another way.
|
||||
raise RuntimeError(
|
||||
f"RSS poll failed for all {total} channels — first: {first_error}"
|
||||
f" (read through the API instead: {fallback['read']})"
|
||||
)
|
||||
return {"new": new + fallback["new"], "failed": failed, "fallback": fallback["read"]}
|
||||
|
||||
|
||||
def run_feed_fallback(db: Session, channel_ids: list[str]) -> dict[str, int]:
|
||||
"""Read the newest uploads through the Data API for the channels among `channel_ids` whose feed
|
||||
has not answered for `rss_fallback_hours` — and that were not read this way within as long.
|
||||
|
||||
YouTube's RSS goes dark per channel for hours at a time (2026-09-18/19: 393 of 396 feeds answered
|
||||
404 for four hours, and a retry of the same channel gets the same 404), and nothing else reads a
|
||||
followed channel's new uploads: recent backfill runs once per channel, ever. One page of the
|
||||
uploads playlist is 1 unit, so a whole day of total outage costs 12 units per channel at the
|
||||
default 2 hours; it yields to the backfill reserve like every other background read. The clock is
|
||||
`last_rss_at` or `feed_fallback_at`, whichever is later, so a channel is read at most once per
|
||||
window whether its feed came back in between or not."""
|
||||
hours = sysconfig.get_int(db, "rss_fallback_hours")
|
||||
if hours <= 0 or not channel_ids:
|
||||
return {"read": 0, "new": 0, "failed": 0}
|
||||
now = datetime.now(timezone.utc)
|
||||
last_read = func.greatest(Channel.last_rss_at, Channel.feed_fallback_at)
|
||||
due = (
|
||||
db.execute(
|
||||
select(Channel)
|
||||
.where(
|
||||
Channel.id.in_(channel_ids),
|
||||
Channel.uploads_playlist_id.is_not(None),
|
||||
or_(last_read.is_(None), last_read < now - timedelta(hours=hours)),
|
||||
)
|
||||
.order_by(Channel.id)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
user = get_service_user(db) if due else None
|
||||
if user is None:
|
||||
return {"read": 0, "new": 0, "failed": 0}
|
||||
read = new = failed = 0
|
||||
with (
|
||||
quota.attribute(None, quota.QuotaAction.VIDEOS_FEED_FALLBACK),
|
||||
YouTubeClient(db, user) as yt,
|
||||
):
|
||||
for channel in due:
|
||||
if quota.backfill_reserve_reached():
|
||||
break
|
||||
try:
|
||||
data = yt.get_playlist_items(channel.uploads_playlist_id, None)
|
||||
# silent-ok: counted, and the one summary line below names the count — the client
|
||||
# already logged the call's own failure (`youtube.api.error`).
|
||||
except YouTubeError:
|
||||
failed += 1
|
||||
continue
|
||||
rows = [
|
||||
s
|
||||
for s in (stub_from_playlist_item(it, channel.id) for it in data.get("items", []))
|
||||
if s is not None
|
||||
]
|
||||
try:
|
||||
new += insert_stubs(db, rows)
|
||||
channel.feed_fallback_at = datetime.now(timezone.utc)
|
||||
db.add(channel)
|
||||
db.commit()
|
||||
except Exception: # noqa: BLE001 — one channel must not stop the rest
|
||||
db.rollback()
|
||||
log.exception(
|
||||
"Feed fallback could not store channel %s",
|
||||
channel.id,
|
||||
extra=logs.event("sync.rss.fallback_failed"),
|
||||
)
|
||||
failed += 1
|
||||
continue
|
||||
read += 1
|
||||
log.info(
|
||||
"Feed fallback: read %d of %d channels through the API, %d new videos",
|
||||
read,
|
||||
len(due),
|
||||
new,
|
||||
extra=logs.event("sync.rss.fallback", read=read, due=len(due), new=new, failed=failed),
|
||||
)
|
||||
return {"read": read, "new": new, "failed": failed}
|
||||
|
||||
|
||||
def run_enrich(db: Session, max_batches: int = 200, floor: int = 200) -> int:
|
||||
|
||||
Reference in New Issue
Block a user