refactor(quota): canonical <entity>_<action> taxonomy for quota events

The quota-attribution action keys were inconsistent (mixed verbs, ad-hoc names,
English-only labels). Introduce a QuotaAction constants holder (one source of
truth), rename every attribution call site to it, and add migration 0020 to
rename historical quota_events.action values. The display label now resolves
from i18n (quotaActions.<key>, EN/HU/DE) instead of a hard-coded English map.
Scheduler job ids and progress phase labels are a separate namespace and are
left untouched.
This commit is contained in:
npeter83 2026-06-19 11:48:11 +02:00
parent e3d3596c7c
commit dc70a978ed
12 changed files with 151 additions and 33 deletions

View file

@ -17,6 +17,32 @@ from app.models import ApiQuotaUsage, QuotaEvent
_PACIFIC = ZoneInfo("America/Los_Angeles")
class QuotaAction:
"""Canonical quota-attribution action keys (one source of truth).
Naming scheme: ``<entity>_<action>``, all snake_case, explicit pull/push direction.
The stored value is this machine key; the user-facing label is resolved from i18n
(frontend ``quotaActions.<key>``). Distinct from scheduler job ids and progress phase
labels do not conflate.
"""
SUBSCRIPTIONS_PULL = "subscriptions_pull"
SUBSCRIPTIONS_RESYNC = "subscriptions_resync"
PLAYLISTS_PULL = "playlists_pull"
PLAYLISTS_PUSH = "playlists_push"
PLAYLISTS_DELETE = "playlists_delete"
VIDEOS_BACKFILL_RECENT = "videos_backfill_recent"
VIDEOS_BACKFILL_FULL = "videos_backfill_full"
VIDEOS_ENRICH = "videos_enrich"
VIDEOS_LOOKUP = "videos_lookup"
CHANNELS_DISCOVER = "channels_discover"
CHANNELS_SUBSCRIBE = "channels_subscribe"
CHANNELS_UNSUBSCRIBE = "channels_unsubscribe"
MAINTENANCE_REVALIDATE = "maintenance_revalidate"
OTHER = "other"
# Request/job-scoped attribution for quota spend: who triggered it and what kind of work.
# Set at entry points (route handlers, scheduler jobs) via attribute(); read by
# record_usage. Default = background/system, generic action.
@ -24,7 +50,7 @@ _actor_id: contextvars.ContextVar[int | None] = contextvars.ContextVar(
"quota_actor_id", default=None
)
_action: contextvars.ContextVar[str] = contextvars.ContextVar(
"quota_action", default="api"
"quota_action", default=QuotaAction.OTHER
)

View file

@ -166,7 +166,7 @@ def discover_channels(
# to yourself. Lazy + cached; needs OAuth, so demo / token-less users just skip it.
if user.yt_channel_id is None and user.token is not None and not user.is_demo:
try:
with quota.attribute(user.id, "discovery"), YouTubeClient(db, user) as yt:
with quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER), YouTubeClient(db, user) as yt:
own = yt.get_my_channel_id()
if own:
user.yt_channel_id = own
@ -181,7 +181,7 @@ def discover_channels(
need = [ch.id for ch, _, _ in rows if ch.details_synced_at is None][:DISCOVERY_ENRICH_CAP]
if need:
try:
with quota.attribute(user.id, "discovery"), YouTubeClient(db, user) as yt:
with quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER), YouTubeClient(db, user) as yt:
apply_channel_details(db, yt.get_channels(need))
db.commit()
rows = _discovery_rows(db, user)
@ -243,7 +243,7 @@ def update_channel(
# fetched its recent uploads yet, do that one channel now (cheap) instead of waiting for
# the scheduler. Deep paging still follows on the scheduler's next run (recent-then-deep).
if deep_turned_on and channel is not None and channel.recent_synced_at is None:
with quota.attribute(user.id, "backfill_recent"):
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT):
run_recent_backfill(db, [channel], max_channels=1)
return {
@ -274,7 +274,7 @@ def reset_backfill(
channel.recent_synced_at = None
sub.deep_requested = True
db.commit()
with quota.attribute(user.id, "backfill_recent"):
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT):
run_recent_backfill(db, [channel], max_channels=1)
return {"id": channel_id, "reset": True}
@ -306,7 +306,7 @@ def subscribe(
if existing is not None:
raise HTTPException(status_code=409, detail="Already subscribed to this channel.")
try:
with quota.attribute(user.id, "subscribe"), YouTubeClient(db, user) as yt:
with quota.attribute(user.id, quota.QuotaAction.CHANNELS_SUBSCRIBE), YouTubeClient(db, user) as yt:
yt_sub_id = yt.insert_subscription(channel_id)
# Enrich a stub channel (title/thumbnail/subscriber count) so it shows up
# properly right away; no video pull here.
@ -356,7 +356,7 @@ def unsubscribe(
detail="No YouTube subscription id on record — sync subscriptions first.",
)
try:
with quota.attribute(user.id, "unsubscribe"), YouTubeClient(db, user) as yt:
with quota.attribute(user.id, quota.QuotaAction.CHANNELS_UNSUBSCRIBE), YouTubeClient(db, user) as yt:
yt.delete_subscription(sub.yt_subscription_id)
except YouTubeError as exc:
# 422 (a real, explainable error → speaking modal), not 502 which the client treats as

View file

@ -549,7 +549,7 @@ def get_video_detail(
}
try:
with quota.attribute(user.id, "video_lookup"), YouTubeClient(db, user) as yt:
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_LOOKUP), YouTubeClient(db, user) as yt:
items = yt.get_videos([video_id])
except YouTubeError as exc:
raise HTTPException(status_code=422, detail=f"YouTube lookup failed: {exc}")

View file

@ -245,7 +245,7 @@ def sync_youtube(
status_code=403,
detail="Connect YouTube (read access) to sync your playlists.",
)
with quota.attribute(user.id, "playlist_sync"):
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
return sync_user_playlists(db, user)
@ -266,7 +266,7 @@ def revert_youtube(
status_code=403, detail="Connect YouTube (read access) to sync your playlists."
)
try:
with quota.attribute(user.id, "playlist_sync"):
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
return repull_playlist(db, user, pl)
except YouTubeError:
db.rollback()
@ -298,7 +298,7 @@ def push_plan(
status_code=403, detail="Enable YouTube editing in Settings first."
)
try:
with quota.attribute(user.id, "playlist_push"):
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH):
plan = plan_push(db, user, pl)
except YouTubeError:
raise HTTPException(status_code=422, detail="Couldn't read the playlist on YouTube.")
@ -322,7 +322,7 @@ def push(
status_code=403, detail="Enable YouTube editing in Settings first."
)
try:
with quota.attribute(user.id, "playlist_push"):
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH):
plan = plan_push(db, user, pl)
if plan["units_estimate"] > quota.remaining_today(db):
raise HTTPException(
@ -418,7 +418,7 @@ def delete_playlist(
status_code=403, detail="Enable YouTube editing in Settings first."
)
try:
with quota.attribute(user.id, "playlist_delete"):
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_DELETE):
with YouTubeClient(db, user) as yt:
yt.delete_playlist(pl.yt_playlist_id)
except YouTubeError:

View file

@ -41,7 +41,7 @@ def sync_subscriptions(
detail="Connect your YouTube account (read access) to import subscriptions.",
)
before = quota.units_used_today(db)
with quota.attribute(user.id, "sync_subscriptions"):
with quota.attribute(user.id, quota.QuotaAction.SUBSCRIPTIONS_PULL):
result = import_subscriptions(db, user)
result["quota_used_estimate"] = quota.units_used_today(db) - before
result["quota_remaining_today"] = quota.remaining_today(db)
@ -63,7 +63,7 @@ def sync_backfill(
max_channels: int = 25,
) -> dict:
before = quota.units_used_today(db)
with quota.attribute(user.id, "backfill_recent"):
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT):
result = run_recent_backfill(db, _user_channels(db, user), max_channels=max_channels)
result["quota_used_estimate"] = quota.units_used_today(db) - before
return result
@ -74,7 +74,7 @@ def sync_enrich(
user: User = Depends(require_human), db: Session = Depends(get_db)
) -> dict:
before = quota.units_used_today(db)
with quota.attribute(user.id, "enrich"):
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_ENRICH):
enriched = run_enrich(db)
return {
"enriched": enriched,

View file

@ -204,7 +204,7 @@ def _rss_job() -> None:
def _enrich_job() -> None:
def work(db):
with quota.attribute(None, "enrich"):
with quota.attribute(None, quota.QuotaAction.VIDEOS_ENRICH):
return run_enrich(db)
_job("enrich", work)
@ -214,9 +214,9 @@ def _backfill_job() -> None:
# Recent-first for not-yet-synced channels, then deep backfill for the rest. All
# background spend is attributed to the system (no actor), split by action.
def work(db):
with quota.attribute(None, "backfill_recent"):
with quota.attribute(None, quota.QuotaAction.VIDEOS_BACKFILL_RECENT):
recent = run_recent_backfill(db, max_channels=25)
with quota.attribute(None, "backfill_deep"):
with quota.attribute(None, quota.QuotaAction.VIDEOS_BACKFILL_FULL):
deep = run_deep_backfill(db, max_channels=10)
return {"recent": recent, "deep": deep}
@ -233,7 +233,7 @@ def _shorts_job() -> None:
def _subscriptions_job() -> None:
def work(db):
with quota.attribute(None, "subscription_resync"):
with quota.attribute(None, quota.QuotaAction.SUBSCRIPTIONS_RESYNC):
return run_subscription_resync(db)
_job("subscriptions", work)
@ -247,7 +247,7 @@ def _playlist_sync_job() -> None:
def _maintenance_job() -> None:
def work(db):
with quota.attribute(None, "maintenance"):
with quota.attribute(None, quota.QuotaAction.MAINTENANCE_REVALIDATE):
return run_maintenance(db)
_job("maintenance", work)

View file

@ -387,7 +387,7 @@ def sync_all_playlists(db: Session) -> dict:
progress.report(i, len(users), "playlist_sync")
continue
try:
with quota.attribute(user.id, "playlist_sync"):
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
total += sync_user_playlists(db, user).get("synced", 0)
except YouTubeError:
db.rollback()