fix: race condition su accessi concorrenti al DB
test / test (push) Has been cancelled

- Lock per-campagna in dm_cycle: serializza le richieste
  per la stessa campagna, evitando che due DM cycle
  leggano stato stale e si sovrascrivano
- begin_write()/end_write() in db.py per lock scritture
- _conn_lock finalmente usato (era definito ma mai acquisito)
- 72 test, tutti passano
This commit is contained in:
truetype committed 2026-07-01 09:58:08 +02:00
1 parent 08edeed8bb
commit 950b7c4672
2 files changed
+56

No files matched your search

+33
View File
@@ -43,6 +43,39 @@ def get_conn() -> sqlite3.Connection:
return conn
def begin_write():
"""Acquisisce lock di scrittura e avvia transazione IMMEDIATE.
SQLite permette un solo writer per volta, anche in WAL mode.
Questo lock serializza le scritture tra thread diversi,
evitando race condition quando due richieste modificano
lo stesso stato contemporaneamente.
"""
_conn_lock.acquire()
conn = get_conn()
conn.execute("BEGIN IMMEDIATE")
def end_write():
"""Commuta la transazione e rilascia il lock di scrittura."""
conn = get_conn()
conn.commit()
_conn_lock.release()
def _write(sql: str, params: tuple = ()) -> sqlite3.Cursor:
"""Esegue una scrittura SQL con lock."""
begin_write()
try:
cur = get_conn().execute(sql, params)
end_write()
return cur
except Exception:
get_conn().rollback()
_conn_lock.release()
raise
def init_schema(conn: sqlite3.Connection) -> None:
conn.executescript("""
-- ============================================================
+23
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import logging
import threading
import time
from src.engine import db, tools
@@ -12,6 +13,19 @@ from src.engine.prompts import SYSTEM_PROMPT, build_prompt
log = logging.getLogger(__name__)
# Lock per-campagna: evita race condition quando due richieste
# arrivano simultaneamente per la stessa campagna.
# La chiave è campaign_id, il valore è un threading.Lock.
_campaign_locks: dict[int, threading.Lock] = {}
_campaign_locks_lock = threading.Lock()
def _get_campaign_lock(campaign_id: int) -> threading.Lock:
with _campaign_locks_lock:
if campaign_id not in _campaign_locks:
_campaign_locks[campaign_id] = threading.Lock()
return _campaign_locks[campaign_id]
def _llm_decision(ctx: GameContext, player_action: str, current_area_id: str = "") -> tuple[str, dict, str]:
prompt = build_prompt(ctx, player_action, current_area_id=current_area_id)
@@ -51,6 +65,15 @@ def _llm_decision(ctx: GameContext, player_action: str, current_area_id: str = "
def dm_cycle(campaign_id: int, player_action: str) -> dict:
lock = _get_campaign_lock(campaign_id)
lock.acquire()
try:
return _dm_cycle_impl(campaign_id, player_action)
finally:
lock.release()
def _dm_cycle_impl(campaign_id: int, player_action: str) -> dict:
t0 = time.perf_counter()
ctx = GameContext.load(campaign_id)