fix(channels): address R14 deep-review findings
- Make the immediate-backfill guard SYMMETRIC: run_deep_backfill now also registers the channel it pages in _backfill_in_progress, so it and backfill_channel_now mutually exclude per-channel and never advance the same cursor concurrently (whichever started first). The interactive cap moves to a separate _immediate_running counter so the scheduler's channels don't false-trip it. - Attribute the immediate backfill's spend: wrap it in quota.attribute(user_id, VIDEOS_BACKFILL_FULL) (route passes user.id), so it shows under the user's full-backfill action, not the OTHER/system bucket. - Tests: the update_channel->background-task wiring + the demo guard (scheduled for a real user, NOT for demo); distinct N/S/L counts (2 short + 1 live) so a short/live swap fails; extracted channelDetailPollMs with a pure unit test of the poll-or-stop predicate. - a11y: role="img" on the fully-synced charm so screen readers announce it.
This commit is contained in:
+39
-23
@@ -231,8 +231,12 @@ def run_deep_backfill(db: Session, max_channels: int = 10, max_pages: int = 5) -
|
||||
for channel in channels:
|
||||
if quota.remaining_today(db) <= sysconfig.get_int(db, "backfill_quota_reserve"):
|
||||
break
|
||||
if channel.id in _immediate_backfill:
|
||||
continue # an interactive backfill_channel_now is paging this one — no cursor race
|
||||
# Claim the channel (or skip it if an interactive backfill already owns it) so the two
|
||||
# never advance the same cursor concurrently — symmetric with backfill_channel_now.
|
||||
with _backfill_lock:
|
||||
if channel.id in _backfill_in_progress:
|
||||
continue
|
||||
_backfill_in_progress.add(channel.id)
|
||||
try:
|
||||
videos_added += backfill_channel_deep(db, yt, channel, max_pages)
|
||||
processed += 1
|
||||
@@ -240,6 +244,9 @@ def run_deep_backfill(db: Session, max_channels: int = 10, max_pages: int = 5) -
|
||||
except Exception:
|
||||
db.rollback()
|
||||
log.exception("Deep backfill failed for channel %s", channel.id)
|
||||
finally:
|
||||
with _backfill_lock:
|
||||
_backfill_in_progress.discard(channel.id)
|
||||
reconciled = reconcile_full_history(db)
|
||||
return {
|
||||
"channels_processed": processed,
|
||||
@@ -248,27 +255,32 @@ def run_deep_backfill(db: Session, max_channels: int = 10, max_pages: int = 5) -
|
||||
}
|
||||
|
||||
|
||||
# Channels an interactive "get full history" is backfilling RIGHT NOW (backfill_channel_now runs in a
|
||||
# FastAPI BackgroundTasks threadpool thread; the scheduler runs in its own thread — hence the lock).
|
||||
# The scheduler's deep pass skips these so the two never page the same channel's cursor concurrently,
|
||||
# and the size cap bounds how much immediate backfill one burst can kick, letting the rest fall back
|
||||
# to the scheduler's fair, quota-paced runs.
|
||||
_immediate_backfill: set[str] = set()
|
||||
_immediate_lock = threading.Lock()
|
||||
# Channels being deep-backfilled RIGHT NOW by EITHER path — the interactive backfill_channel_now (a
|
||||
# FastAPI BackgroundTasks threadpool thread) OR the scheduler's run_deep_backfill (its own thread).
|
||||
# BOTH register the channel they're paging (under the lock) so the other skips it: no two runs page
|
||||
# the same channel's `backfill_cursor` concurrently, whichever started first. `_immediate_running`
|
||||
# counts only the interactive path, capped so one user's burst can't drain the shared budget (the
|
||||
# scheduler is already quota-paced). Single uvicorn process → one shared module state, lock suffices.
|
||||
_backfill_in_progress: set[str] = set()
|
||||
_immediate_running = 0
|
||||
_backfill_lock = threading.Lock()
|
||||
_MAX_CONCURRENT_IMMEDIATE = 2
|
||||
|
||||
|
||||
def backfill_channel_now(channel_id: str, max_pages: int = 60) -> None:
|
||||
def backfill_channel_now(channel_id: str, user_id: int, max_pages: int = 60) -> None:
|
||||
"""User-initiated "get full history": deep-backfill ONE channel to completion right now, in a
|
||||
background task with its OWN session, so the full catalogue lands in seconds instead of over the
|
||||
paced scheduler's 10-minute ticks (max_pages=5/channel/tick). Quota-guarded, and capped to
|
||||
_MAX_CONCURRENT_IMMEDIATE in flight so one user can't drain the shared backfill budget by opting
|
||||
many channels in at once — the overflow (and any very large channel's tail) is finished by the
|
||||
scheduler on its normal runs, since deep_requested stays set. Best-effort — logs and swallows."""
|
||||
with _immediate_lock:
|
||||
if channel_id in _immediate_backfill or len(_immediate_backfill) >= _MAX_CONCURRENT_IMMEDIATE:
|
||||
return # already running, or too many in flight — leave it to the scheduler
|
||||
_immediate_backfill.add(channel_id)
|
||||
paced scheduler's 10-minute ticks (max_pages=5/channel/tick). Quota-guarded, capped to
|
||||
_MAX_CONCURRENT_IMMEDIATE interactive runs so one user can't drain the shared backfill budget by
|
||||
opting many channels in at once (the overflow, and any very large channel's tail, is finished by
|
||||
the scheduler since deep_requested stays set), and mutually excluded per-channel with the scheduler
|
||||
via _backfill_in_progress. Best-effort — logs and swallows failures."""
|
||||
global _immediate_running
|
||||
with _backfill_lock:
|
||||
if channel_id in _backfill_in_progress or _immediate_running >= _MAX_CONCURRENT_IMMEDIATE:
|
||||
return # already backfilling this channel, or too many interactive runs — leave it to the scheduler
|
||||
_backfill_in_progress.add(channel_id)
|
||||
_immediate_running += 1
|
||||
try:
|
||||
with SessionLocal() as db:
|
||||
user = get_service_user(db)
|
||||
@@ -280,15 +292,19 @@ def backfill_channel_now(channel_id: str, max_pages: int = 60) -> None:
|
||||
if quota.remaining_today(db) <= sysconfig.get_int(db, "backfill_quota_reserve"):
|
||||
return
|
||||
try:
|
||||
with YouTubeClient(db, user) as yt:
|
||||
backfill_channel_deep(db, yt, channel, max_pages)
|
||||
reconcile_full_history(db)
|
||||
# Attribute the spend to the requesting user under the full-backfill action, so it
|
||||
# shows up correctly in the usage breakdown (not the default OTHER/system bucket).
|
||||
with quota.attribute(user_id, quota.QuotaAction.VIDEOS_BACKFILL_FULL):
|
||||
with YouTubeClient(db, user) as yt:
|
||||
backfill_channel_deep(db, yt, channel, max_pages)
|
||||
reconcile_full_history(db)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
log.exception("Immediate backfill failed for channel %s", channel_id)
|
||||
finally:
|
||||
with _immediate_lock:
|
||||
_immediate_backfill.discard(channel_id)
|
||||
with _backfill_lock:
|
||||
_backfill_in_progress.discard(channel_id)
|
||||
_immediate_running -= 1
|
||||
|
||||
|
||||
def estimate_deep_backfill(db: Session, channels: list[Channel]) -> dict:
|
||||
|
||||
Reference in New Issue
Block a user