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:
parent
74c4047567
commit
e44d2280e5
2 changed files with 51 additions and 22 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue