diff --git a/src/engine/db.py b/src/engine/db.py index a0b2a3a..334baf1 100644 --- a/src/engine/db.py +++ b/src/engine/db.py @@ -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(""" -- ============================================================ diff --git a/src/engine/dm.py b/src/engine/dm.py index 27d3754..2da8964 100644 --- a/src/engine/dm.py +++ b/src/engine/dm.py @@ -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)