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:
|
def bytes_used_today(self, user: str) -> int:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
return self._ensure_bucket(user)["bytes"]
|
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
|
# `_build_quota_tracker` lives in `app.api.v2_quota` so /api/query (issue #160)
|
||||||
# dispatches sync handlers via a thread pool, so two concurrent first-time
|
# can share the same singleton without inverting the dep direction
|
||||||
# requests can both observe `_quota_singleton is None` and each construct a
|
# (api/query → api/v2/scan would couple a high-level endpoint to a sibling).
|
||||||
# separate tracker; the second assignment wins and the first reference leaks
|
# Re-exported here so existing test sites that call
|
||||||
# split-brain quota state. Guard with an init lock + double-check.
|
# `v2_scan._build_quota_tracker()` (7 in tests/test_v2_scan.py) keep working.
|
||||||
import threading as _threading
|
# Do NOT re-export `_quota_singleton` — `from X import var` copies the
|
||||||
_quota_init_lock = _threading.Lock()
|
# binding at import time, so a re-exported singleton would never see the
|
||||||
_quota_singleton: QuotaTracker | None = None
|
# initialized value (#160 review caveat).
|
||||||
|
from app.api.v2_quota import _build_quota_tracker # re-export
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def _max_result_bytes() -> int:
|
def _max_result_bytes() -> int:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue