refactor(quota): #160 relocate _build_quota_tracker to v2_quota.py

The /api/query cost guardrail (next phase) needs the same singleton
QuotaTracker so its daily-byte and concurrent-slot caps accumulate
across both /api/v2/scan and /api/query BQ-touching paths.

Move `_build_quota_tracker`, `_quota_singleton`, and `_quota_init_lock`
from app/api/v2_scan.py to app/api/v2_quota.py (the natural home; the
factory uses QuotaTracker which already lives there). Re-export the
function from v2_scan.py so the 7 test sites at tests/test_v2_scan.py
(lines 77, 118, 143, 160, 186, 208, 250) keep working without edits.

Crucially do NOT re-export `_quota_singleton` from v2_scan.py — Python
`from X import var` copies the binding at import time, so a re-exported
singleton would freeze at the initial None and never observe the
in-place mutation done inside `_build_quota_tracker()`. Re-export only
the function (which always reads the live module-global through `global`).

Mechanical refactor; no behavior change. 30 quota-related tests pass.
This commit is contained in:
ZdenekSrotyr 2026-05-03 16:35:26 +02:00
parent 74c4047567
commit e44d2280e5
2 changed files with 51 additions and 22 deletions

View file

@ -118,3 +118,45 @@ class QuotaTracker:
def bytes_used_today(self, user: str) -> int:
with self._lock:
return self._ensure_bucket(user)["bytes"]
# Module-level singleton (process-local quota state per spec §3.8). FastAPI
# dispatches sync handlers via a thread pool, so two concurrent first-time
# requests can both observe `_quota_singleton is None` and each construct a
# separate tracker; the second assignment wins and the first reference leaks
# split-brain quota state. Guard with an init lock + double-check.
#
# Note: `_quota_singleton` and `_quota_init_lock` are intentionally
# module-private. Callers MUST go through `_build_quota_tracker()` so the
# singleton stays single. Re-exporting `_quota_singleton` from another
# module via `from app.api.v2_quota import _quota_singleton` would copy the
# initial-None binding at import time and never see subsequent updates —
# that's a footgun. The function re-export is safe (it always reads the
# live module-global).
_quota_init_lock = threading.Lock()
_quota_singleton: "QuotaTracker | None" = None
def _build_quota_tracker() -> QuotaTracker:
"""Returns or constructs the process-local quota tracker (thread-safe).
Shared across `/api/v2/scan` (the original caller) and `/api/query`
(issue #160 cost guardrail) so the per-user daily byte cap accumulates
across both BQ-touching paths.
"""
from app.instance_config import get_value
global _quota_singleton
if _quota_singleton is not None:
return _quota_singleton
with _quota_init_lock:
if _quota_singleton is None:
_quota_singleton = QuotaTracker(
max_concurrent_per_user=int(
get_value("api", "scan", "max_concurrent_per_user", default=5) or 5
),
max_daily_bytes_per_user=int(
get_value("api", "scan", "max_daily_bytes_per_user", default=53687091200)
or 53687091200
),
)
return _quota_singleton

View file

@ -244,28 +244,15 @@ async def scan_estimate_endpoint(
)
# Module-level singleton (process-local quota state per spec §3.8). FastAPI
# dispatches sync handlers via a thread pool, so two concurrent first-time
# requests can both observe `_quota_singleton is None` and each construct a
# separate tracker; the second assignment wins and the first reference leaks
# split-brain quota state. Guard with an init lock + double-check.
import threading as _threading
_quota_init_lock = _threading.Lock()
_quota_singleton: QuotaTracker | None = None
def _build_quota_tracker() -> QuotaTracker:
"""Returns or constructs the process-local quota tracker (thread-safe)."""
global _quota_singleton
if _quota_singleton is not None:
return _quota_singleton
with _quota_init_lock:
if _quota_singleton is None:
_quota_singleton = QuotaTracker(
max_concurrent_per_user=int(get_value("api", "scan", "max_concurrent_per_user", default=5) or 5),
max_daily_bytes_per_user=int(get_value("api", "scan", "max_daily_bytes_per_user", default=53687091200) or 53687091200),
)
return _quota_singleton
# `_build_quota_tracker` lives in `app.api.v2_quota` so /api/query (issue #160)
# can share the same singleton without inverting the dep direction
# (api/query → api/v2/scan would couple a high-level endpoint to a sibling).
# Re-exported here so existing test sites that call
# `v2_scan._build_quota_tracker()` (7 in tests/test_v2_scan.py) keep working.
# Do NOT re-export `_quota_singleton` — `from X import var` copies the
# binding at import time, so a re-exported singleton would never see the
# initialized value (#160 review caveat).
from app.api.v2_quota import _build_quota_tracker # re-export
def _max_result_bytes() -> int: