Real SQLite transactions: BEGIN IMMEDIATE and synchronous=FULL (B-88, DAT-74, DAT-52, ARC-07, TX-28, BET-11, DAT-16)

pysqlite's legacy mode emitted BEGIN only at the first DML, so every read
before it ran outside any transaction and a lock wait surfaced as "database is
locked" at an arbitrary later statement; place_bet also mistook that error for
a write-snapshot conflict, which SQLite cannot produce.

- _register_sqlite_pragmas: synchronous=FULL, isolation_level=None and a
  `begin` event that emits BEGIN / BEGIN IMMEDIATE.
- begin_immediate(session): takes the write lock before the first read; called
  first thing in place_bet, request_withdrawal, the scheduler transitions,
  _trigger_payout, the confirmation poller, the reconciler, bump_fee's persist
  phase, deposit crediting / external-spend marking / reinstating, and the
  derivation-index allocation at registration.
- A lock not granted before a broadcast answers service_busy (503, all seven
  languages), nothing built or sent; after a broadcast nothing is undone, the
  rows stay `building` for the reconciler.
- Spec, storico, BUGS.md and CLAUDE.md updated; 37 new tests on a real file DB.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-09-29 16:26:01 +02:00
1 parent 4be58709d1
commit 7ba8e22bb4
25 files changed
+1165 -82

No files matched your search

+24 -25
View File
@@ -483,11 +483,8 @@ restano nella tabella come storico.
`busy_timeout` scaduto (`database is locked`), non un conflitto di snapshot:
la scommessa è rifiutata allo stesso modo, perché nulla è ancora stato
trasmesso.
*Codice:* `app/bets/service.py:place_bet` · *Test:* `tests/unit/test_bets.py`
*Stato:* da implementare (B-88) · *Oggi:* la transazione inizia solo alla
prima scrittura (modalità legacy di pysqlite), le letture precedenti non
sono isolate, e il codice tratta l'`OperationalError` come un conflitto di
snapshot, che in SQLite non può verificarsi.
*Codice:* `app/bets/service.py:place_bet` · *Test:* `tests/unit/test_bets.py` ·
`tests/unit/test_sqlite_transactions.py`
- **[DAT-17]** Al massimo **un round attivo** alla volta, come vincolo del
database: `ix_rounds_single_active` è un indice unico sull'espressione
@@ -1371,10 +1368,7 @@ letti con una scansione completa.
dialetto non viene eseguito nulla. `PRAGMA foreign_keys` non è tra questi,
da cui [DAT-04].
*Codice:* `app/db/base.py:_register_sqlite_pragmas` ·
*Test:* `tests/unit/test_db_base.py`
*Stato:* da implementare (B-88) · *Oggi:* `synchronous=NORMAL`, con cui in
modalità WAL gli ultimi commit possono andare persi in caso di interruzione
di corrente.
*Test:* `tests/unit/test_db_base.py` · `tests/unit/test_sqlite_transactions.py`
- **[DAT-74]** Ogni transazione del database che sposta denaro o ne fissa lo
stato inizia con `BEGIN IMMEDIATE`, che prende il lock di scrittura di SQLite
@@ -1383,22 +1377,28 @@ letti con una scansione completa.
`pending_transactions`) e le scritture che ne seguono vedono lo stesso
stato, senza che un altro scrittore si inserisca fra le due. Riguarda almeno
le fasi di scrittura di `place_bet`, `request_withdrawal`, `_trigger_payout`,
`bump_fee`, l'accredito dei depositi, le transizioni di stato dello
scheduler, i gestori di conferma e il riconciliatore. Un `OperationalError`
`bump_fee`, l'accredito dei depositi (accredito, marcatura come speso
all'esterno, reintegro: quest'ultimo solo quando c'è qualcosa da reintegrare,
perché gira a ogni aggiornamento), le transizioni di stato dello scheduler,
i gestori di conferma, il riconciliatore e l'allocazione dell'indice di
derivazione alla registrazione (senza il lock due registrazioni simultanee
leggevano lo stesso massimo). Si ottiene con `begin_immediate(session)`, che
chiude l'eventuale transazione di sola lettura già aperta (di solito la
ricerca dell'utente autenticato) e apre quella nuova con `BEGIN IMMEDIATE`;
le altre transazioni, in sola lettura, restano `BEGIN` semplice e in WAL non
aspettano nessuno. Un `OperationalError`
`database is locked` è il `busy_timeout` di [DAT-52] scaduto: prima di un
broadcast la transazione viene annullata e l'operazione rifiutata o
rimandata al giro successivo; **dopo** un broadcast non si annulla nulla
sulla rete, la riga resta nello stato scritto prima del broadcast
(`building`) e il riconciliatore la risolve
([transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md) B-88).
*Codice:* `app/db/base.py` · `app/db/session.py:get_session` ·
*Codice:* `app/db/base.py:begin_immediate` · `app/db/base.py:_register_sqlite_pragmas` ·
`app/bets/service.py:place_bet` · `app/withdrawals/service.py:request_withdrawal` ·
*Test:* —
*Stato:* da implementare (B-88) · *Oggi:* pysqlite in modalità legacy emette
`BEGIN` solo alla prima istruzione di scrittura, quindi le letture che la
precedono non sono nella stessa transazione delle scritture; un `database
is locked` può emergere dopo 5 s in un punto qualunque, anche dopo un
broadcast.
`app/rounds/scheduler.py:RoundScheduler._trigger_payout` · `app/tx/broadcast.py:bump_fee` ·
`app/tx/confirmation.py:poll_once` · `app/tx/reconcile.py:reconcile_once` ·
`app/deposits/service.py:credit_confirmed_utxos` · `app/auth/routes.py:register` ·
*Test:* `tests/unit/test_sqlite_transactions.py`
## Migrazioni
@@ -1537,13 +1537,12 @@ scrive la stringa vuota).
per file; un secondo scrittore aspetta il lock fino al `busy_timeout`
([DAT-52]) e poi riceve `OperationalError: database is locked`. Non esistono
conflitti fra snapshot: un `OperationalError` è sempre un'attesa scaduta.
Nello schema atteso le transazioni che spostano denaro prendono il lock con
`BEGIN IMMEDIATE` prima di leggere ([DAT-74]), quindi il compare-and-set di
[DAT-16] e le letture che lo precedono stanno nella stessa transazione.
Oggi pysqlite, in modalità legacy, emette `BEGIN` solo alla prima scrittura:
le letture precedenti non sono isolate, e il codice di `place_bet` descrive
l'`OperationalError` come un conflitto di snapshot, cosa che in SQLite non
accade (B-88).
Le transazioni che spostano denaro prendono il lock con `BEGIN IMMEDIATE`
prima di leggere ([DAT-74]), quindi il compare-and-set di [DAT-16] e le
letture che lo precedono stanno nella stessa transazione. Il driver
(pysqlite) non apre più le transazioni da solo (`isolation_level = None`):
è l'evento `begin` del motore a emettere `BEGIN` o `BEGIN IMMEDIATE`, così
ogni lettura sta dentro una transazione vera (B-88).
- **Indice parziale su espressione.** `ix_rounds_single_active` usa sia un'espressione
(`(1)`) sia una clausola `WHERE`; SQLite e PostgreSQL supportano entrambe, e il
modello dichiara la clausola per tutti e due (`sqlite_where`,