refactor(quota): own the quota session; Plex per-section commit (R6 S2)
Transaction ownership (C-A5): - quota.record_usage / log_action write in their OWN SessionLocal(), committed immediately, and no longer take/commit the caller's db. The spend stays DURABLE regardless of the caller (read-only YouTube paths never commit; a failed job rolls back) — under-counting the shared daily budget would risk overspending the real YouTube quota — AND quota stops flushing a job's partial state mid-run behind its own `except: db.rollback()`. units_used_today switches to a scalar SELECT so measured()'s before/after diff sees the separate session's commits (READ COMMITTED). Call sites updated (youtube/client.py ×3, routes/search.py ×1). - plex sync commits PER SECTION (moved the end-of-loop commit inside), so a PlexError on a later library keeps the ones already mirrored (idempotent; next run reconciles). The sync jobs did NOT rely on record_usage's incidental commit for persistence — their per-item functions (apply_rss_feed, backfill_channel_recent, import_subscriptions, sync_user_playlists, apply_channel_autotags) all commit internally, so the in-loop rollback handlers already isolate a failed item. No loop restructuring needed. DB-lane tests (test_quota_ownership, 4): spend survives a caller rollback while the caller's uncommitted row does NOT leak; accumulation; measured-style mid-txn read. Gate: ruff clean; DB lane 180 green.
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
"""DB-backed tests for R6 S2 quota transaction ownership: quota accounting writes in its OWN
|
||||
session, so a spend is DURABLE even when the calling job rolls back (the units were really consumed
|
||||
at YouTube), and it NEVER commits the caller's session (the old behaviour flushed a job's partial
|
||||
state mid-run behind its own rollback). Uses the DB lane (`db` fixture) — skipped without Postgres.
|
||||
"""
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app import quota
|
||||
from app.models import QuotaEvent, User
|
||||
|
||||
|
||||
def test_record_usage_survives_caller_rollback_and_leaves_caller_uncommitted(db):
|
||||
# The caller has uncommitted work of its own; then a YouTube call spends quota; then the caller
|
||||
# rolls back (a failed job).
|
||||
db.add(User(email="pending@example.com")) # caller's own, never committed
|
||||
quota.record_usage(10) # dedicated session — commits independently
|
||||
db.rollback() # the job fails and rolls back
|
||||
|
||||
# Quota spend PERSISTED (real units were consumed) — the guard stays accurate ...
|
||||
assert quota.units_used_today(db) == 10
|
||||
assert db.scalar(select(func.count()).select_from(QuotaEvent)) == 1
|
||||
# ... but the caller's own partial work did NOT leak (quota never committed the caller's session).
|
||||
assert db.scalar(select(func.count()).select_from(User).where(User.email == "pending@example.com")) == 0
|
||||
|
||||
|
||||
def test_record_usage_accumulates(db):
|
||||
quota.record_usage(10)
|
||||
quota.record_usage(5)
|
||||
assert quota.units_used_today(db) == 15 # atomic upsert: +10 then +5
|
||||
|
||||
|
||||
def test_reads_see_separate_session_spend_mid_transaction(db):
|
||||
# Mimics measured()'s before/after diff: a read, a spend (in quota's SEPARATE session), a read.
|
||||
# The second read must reflect the spend even though the caller's own transaction is still open —
|
||||
# units_used_today is a scalar SELECT (READ COMMITTED sees other sessions' commits), NOT a cached
|
||||
# ORM row that db.get would return stale.
|
||||
before = quota.units_used_today(db)
|
||||
quota.record_usage(7)
|
||||
after = quota.units_used_today(db)
|
||||
assert after - before == 7
|
||||
|
||||
|
||||
def test_log_action_survives_caller_rollback(db):
|
||||
user = User(email="searcher@example.com")
|
||||
db.add(user)
|
||||
db.commit() # a committed user to attribute the (free) action to
|
||||
db.add(User(email="pending@example.com")) # uncommitted caller work
|
||||
quota.log_action(user.id, quota.QuotaAction.VIDEOS_SEARCH)
|
||||
db.rollback()
|
||||
|
||||
# The zero-cost action event survived (per-user daily caps must still count it) ...
|
||||
events = db.execute(select(QuotaEvent)).scalars().all()
|
||||
assert len(events) == 1
|
||||
assert events[0].user_id == user.id
|
||||
assert events[0].units == 0
|
||||
# ... and the caller's uncommitted user is gone.
|
||||
assert db.scalar(select(func.count()).select_from(User)) == 1
|
||||
Reference in New Issue
Block a user