API: compteur de consommation SQLite asynchrone (#78)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -18,3 +18,9 @@ def init_api(server) -> None:
|
|||||||
|
|
||||||
api = Api(server)
|
api = Api(server)
|
||||||
api.register_blueprint(routes.bp)
|
api.register_blueprint(routes.bp)
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from src.api import tracking
|
||||||
|
|
||||||
|
tracking.start_worker(os.environ["USERS_DB_PATH"])
|
||||||
|
|||||||
+10
-1
@@ -1,6 +1,7 @@
|
|||||||
from flask import request
|
from flask import g, request
|
||||||
from flask_smorest import Blueprint, abort
|
from flask_smorest import Blueprint, abort
|
||||||
|
|
||||||
|
from src.api import tracking
|
||||||
from src.api.auth import require_token
|
from src.api.auth import require_token
|
||||||
from src.api.filters import FilterError, build_where
|
from src.api.filters import FilterError, build_where
|
||||||
from src.db import count_marches, query_marches
|
from src.db import count_marches, query_marches
|
||||||
@@ -62,6 +63,14 @@ def _build_links(page, page_size, total):
|
|||||||
return {"prev": prev_url, "next": next_url}
|
return {"prev": prev_url, "next": next_url}
|
||||||
|
|
||||||
|
|
||||||
|
@bp.after_request
|
||||||
|
def _track_consumption(response):
|
||||||
|
token_id = getattr(g, "token_id", None)
|
||||||
|
if token_id is not None:
|
||||||
|
tracking.enqueue_counter_update(token_id)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/health")
|
@bp.route("/health")
|
||||||
def health():
|
def health():
|
||||||
"""Sonde de santé, sans authentification."""
|
"""Sonde de santé, sans authentification."""
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from src.api import tokens_db
|
||||||
|
from src.utils import logger
|
||||||
|
|
||||||
|
_STOP_SENTINEL = object()
|
||||||
|
_queue: Optional[queue.Queue] = None
|
||||||
|
_worker_thread: Optional[threading.Thread] = None
|
||||||
|
_db_path: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _worker_loop(q: queue.Queue, db_path: str) -> None:
|
||||||
|
while True:
|
||||||
|
item = q.get()
|
||||||
|
try:
|
||||||
|
if item is _STOP_SENTINEL:
|
||||||
|
return
|
||||||
|
kind, payload = item
|
||||||
|
if kind == "counter":
|
||||||
|
token_id = payload
|
||||||
|
try:
|
||||||
|
tokens_db.increment_usage(db_path, token_id)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.warning(
|
||||||
|
"tracking: échec increment_usage token_id=%s",
|
||||||
|
token_id,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
q.task_done()
|
||||||
|
|
||||||
|
|
||||||
|
def start_worker(db_path: str) -> None:
|
||||||
|
global _queue, _worker_thread, _db_path
|
||||||
|
if _worker_thread is not None and _worker_thread.is_alive():
|
||||||
|
return
|
||||||
|
_db_path = db_path
|
||||||
|
_queue = queue.Queue()
|
||||||
|
_worker_thread = threading.Thread(
|
||||||
|
target=_worker_loop, args=(_queue, db_path), daemon=True
|
||||||
|
)
|
||||||
|
_worker_thread.start()
|
||||||
|
|
||||||
|
|
||||||
|
def stop_worker() -> None:
|
||||||
|
global _worker_thread, _queue
|
||||||
|
if _worker_thread is None:
|
||||||
|
return
|
||||||
|
_queue.put(_STOP_SENTINEL)
|
||||||
|
_worker_thread.join(timeout=2.0)
|
||||||
|
_worker_thread = None
|
||||||
|
_queue = None
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue_counter_update(token_id: int) -> None:
|
||||||
|
if _queue is None:
|
||||||
|
return # tracking désactivé (tests par ex.)
|
||||||
|
_queue.put(("counter", token_id))
|
||||||
|
|
||||||
|
|
||||||
|
def flush(timeout: float = 2.0) -> None:
|
||||||
|
"""Attend que la queue soit drainée. Utile en test."""
|
||||||
|
if _queue is None:
|
||||||
|
return
|
||||||
|
_queue.join()
|
||||||
@@ -19,12 +19,13 @@ def api_client(monkeypatch, tmp_path):
|
|||||||
monkeypatch.setenv("USERS_DB_PATH", str(db_path))
|
monkeypatch.setenv("USERS_DB_PATH", str(db_path))
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
|
||||||
from src.api import init_api, tokens_db
|
from src.api import init_api, tokens_db, tracking
|
||||||
|
|
||||||
tokens_db.init_schema(db_path)
|
tokens_db.init_schema(db_path)
|
||||||
server = Flask(__name__)
|
server = Flask(__name__)
|
||||||
init_api(server)
|
init_api(server)
|
||||||
return server.test_client(), db_path
|
yield server.test_client(), db_path
|
||||||
|
tracking.stop_worker()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
from src.api import tokens_db, tracking
|
||||||
|
|
||||||
|
|
||||||
|
def test_counter_worker_increments_count(temp_db):
|
||||||
|
_, token_id = tokens_db.create_token(temp_db, "x")
|
||||||
|
|
||||||
|
tracking.start_worker(str(temp_db))
|
||||||
|
try:
|
||||||
|
tracking.enqueue_counter_update(token_id)
|
||||||
|
tracking.enqueue_counter_update(token_id)
|
||||||
|
# Laisser le worker drainer la queue
|
||||||
|
tracking.flush(timeout=2.0)
|
||||||
|
finally:
|
||||||
|
tracking.stop_worker()
|
||||||
|
|
||||||
|
rows = tokens_db.list_tokens(temp_db)
|
||||||
|
assert rows[0]["count_total"] == 2
|
||||||
|
assert rows[0]["last_used_at"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_after_request_hook_increments_counter_async(api_client, valid_token_header):
|
||||||
|
client, db_path = api_client
|
||||||
|
# Récupérer le token_id du token créé par la fixture
|
||||||
|
from src.api import tokens_db, tracking
|
||||||
|
|
||||||
|
rows = tokens_db.list_tokens(db_path)
|
||||||
|
assert len(rows) == 1 # vérification du token créé par la fixture
|
||||||
|
|
||||||
|
# Faire une requête (qui doit déclencher l'incrément)
|
||||||
|
client.get("/api/v1/health") # pas authentifiée → ne compte pas
|
||||||
|
client.get("/api/v1/schema", headers=valid_token_header)
|
||||||
|
|
||||||
|
tracking.flush(timeout=2.0)
|
||||||
|
|
||||||
|
rows = tokens_db.list_tokens(db_path)
|
||||||
|
assert rows[0]["count_total"] == 1
|
||||||
Reference in New Issue
Block a user