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

+10 -7
View File
@@ -205,8 +205,11 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
`app/api/errors.py:from_api_error`
- **[API-13]** Gli errori di `POST /bets` e `POST /withdrawals` sollevati dal
servizio rispondono `400`, **tranne** `broadcast_failed`, che risponde `502`:
è la rete ad aver rifiutato la transazione, non il chiamante.
servizio rispondono `400`, **tranne** `broadcast_failed`, che risponde `502`
(è la rete ad aver rifiutato la transazione, non il chiamante), e
`service_busy`, che risponde `503` (il database non ha concesso il lock di
scrittura entro il timeout, [DAT-74](modello-dati.md); nulla è stato
costruito né trasmesso).
`broadcast_failed` si usa **solo** per un rifiuto esplicito del server
(un errore JSON-RPC in risposta al broadcast), e significa che tutto è stato
annullato. Un esito **ambiguo** del broadcast (timeout, connessione caduta
@@ -453,8 +456,8 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
`insufficient_balance` (`params.required_sats`), `too_many_inputs`
(`params.max_inputs`), `balance_leaves_no_change`
(`params.required_extra_sats`), `amount_below_network_fee`,
`amount_below_dust_limit`, `502 broadcast_failed` (solo rifiuto
esplicito). Il significato di ciascuno è nel
`amount_below_dust_limit`, `503 service_busy`, `502 broadcast_failed`
(solo rifiuto esplicito). Il significato di ciascuno è nel
[catalogo](#catalogo-dei-codici-di-errore).
- **Serializzazione:** la richiesta tiene il blocco per-utente condiviso con
`POST /withdrawals` per tutta la costruzione e la trasmissione.
@@ -506,8 +509,8 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
prelievo dell'intero saldo, B-96), `balance_pending_confirmation`
(`params.pending_sats`), `insufficient_balance` (`params.required_sats`),
`too_many_inputs` (`params.max_inputs`), `amount_below_network_fee`,
`amount_below_dust_limit`; `502 broadcast_failed` (solo rifiuto
esplicito).
`amount_below_dust_limit`; `503 service_busy`; `502 broadcast_failed`
(solo rifiuto esplicito).
- **Serializzazione:** stesso blocco per-utente di `POST /bets`.
- **Effetti:** segna le monete spese, crea prelievo e transazione in
sospeso, trasmette al server attivo e poi, *best effort*, a tutti i
@@ -1063,7 +1066,7 @@ dei parametri operativi in
| `amount_below_minimum` | 400 | `app/withdrawals/service.py:request_withdrawal` | `minimum_sats` | prelievo sotto il costo attuale della scommessa | sì |
| `internal_error` | 500 | `app/main.py:log_unhandled_exception` | — | eccezione non gestita | sì |
| `withdrawal_to_platform_address` | 400 | `app/withdrawals/service.py:request_withdrawal` | — | destinazione uguale a un indirizzo di montepremi, di riserva o delle commissioni — *da implementare (B-80)* | no — da aggiungere |
| `service_busy` | 503 | `app/bets/service.py:place_bet` | — | il database non ha concesso il lock di scrittura entro il timeout; nulla è stato trasmesso, si può riprovare — *da implementare (B-88)* | no — da aggiungere |
| `service_busy` | 503 | `app/bets/service.py:place_bet`, `app/withdrawals/service.py:request_withdrawal`, `app/auth/routes.py:register` | — | il database non ha concesso il lock di scrittura entro il timeout; nulla è stato trasmesso, si può riprovare | sì |
| `registration_unavailable` | 503 | `app/auth/routes.py:register` | — | registrazione sospesa finché il controllo degli indici di derivazione dopo un ripristino non è concluso o risolto ([registrazione-e-depositi.md](registrazione-e-depositi.md), B-90) — *da implementare* | no — da aggiungere |
| `not_authenticated` | 401 | dipendenza Bearer di FastAPI | — | header `Authorization` assente — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere |
| `invalid_admin_token` | 403 | `app/api/routes/admin.py:require_admin` | — | token admin assente o errato — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere |
+2 -5
View File
@@ -121,11 +121,8 @@ flowchart LR
(la riga resta al riconciliatore). Con un dialetto diverso da SQLite le
pragma non vengono applicate.
*Codice:* `app/db/base.py:_register_sqlite_pragmas` ·
*Test:* `tests/unit/test_db_base.py`
*Stato:* da implementare (B-88) · *Oggi:* `synchronous=NORMAL`, e pysqlite
in modalità legacy emette `BEGIN` solo alla prima scrittura, quindi le
letture precedenti non sono isolate e l'attesa del lock può scattare in
punti qualsiasi.
`app/db/base.py:begin_immediate` ·
*Test:* `tests/unit/test_db_base.py` · `tests/unit/test_sqlite_transactions.py`
- **[ARC-08]** Lo schema è definito in `app/db/models.py` ed evolve solo
tramite migrazioni Alembic in `migrations/versions/` (generate con
`alembic revision --autogenerate`); `migrations/env.py` prende l'URL del
+2 -4
View File
@@ -354,10 +354,8 @@ Formato di ogni voce: **decisione** in grassetto, poi *Motivazione*,
invariante che non dipende dal processo singolo è «al più un round
attivo», garantito da un indice ([DEC-22]).
*Codice:* `app/db/base.py:_register_sqlite_pragmas` · `app/tx/locks.py:UserLocks` · `app/rounds/events.py:RoundEventBroadcaster` ·
*Test:* `tests/unit/test_db_base.py`
*Stato:* da implementare (B-88) · *Oggi:* `synchronous=NORMAL` e `BEGIN`
emesso solo alla prima scrittura (modalità legacy di pysqlite).
*Codice:* `app/db/base.py:_register_sqlite_pragmas` · `app/db/base.py:begin_immediate` · `app/tx/locks.py:UserLocks` · `app/rounds/events.py:RoundEventBroadcaster` ·
*Test:* `tests/unit/test_db_base.py` · `tests/unit/test_sqlite_transactions.py`
### Economia del gioco
+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`,
+1 -4
View File
@@ -108,10 +108,7 @@ ancora `open` oltre la scadenza.
`service_busy` (HTTP 503), perché il round non è necessariamente in
chiusura.
*Codice:* `app/bets/service.py:place_bet` ·
*Test:* `tests/unit/test_bets.py`
*Stato:* da implementare (B-88) · *Oggi:* la transazione si apre con la
prima scrittura, non con `BEGIN IMMEDIATE`; un `OperationalError` viene
trattato come un conflitto di scrittura e risponde `round_closing`.
*Test:* `tests/unit/test_bets.py` · `tests/unit/test_sqlite_transactions.py`
- **[BET-22]** `MAX_PARTICIPANTS_PER_ROUND` è minore di
`MAX_PAYOUT_TX_INPUTS`. Il pagamento spende una moneta del montepremi per
ogni scommessa, e una singola transazione di pagamento può spenderne al
+1 -2
View File
@@ -818,8 +818,7 @@ sicurezza.
- **Commit persi.** Con `synchronous=FULL` un commit confermato al chiamante
è sul disco ([DAT-52](modello-dati.md), B-88): un'interruzione di corrente
non può più perdere una registrazione e far riassegnare il suo indice di
derivazione. *Oggi:* `synchronous=NORMAL`, con cui in modalità WAL gli
ultimi commit possono andare persi.
derivazione.
## Dati conservati e log
+5 -4
View File
@@ -236,7 +236,7 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)).
| B-85 | Le riorganizzazioni della catena non vengono né seguite né segnalate | Alta | **aperto** | — | ELC, RND |
| B-86 | Un server connesso ma fermo non viene mai sostituito | Alta | **aperto** | — | ELC, OPS |
| B-87 | L'RBF può lasciare un resto sotto la soglia di dust | Alta | **aperto** | — | TX |
| B-88 | Le transazioni SQLite non sono quelle che il codice presuppone; `synchronous=NORMAL` può perdere gli ultimi commit | Alta | **aperto** | — | DAT, ARC |
| B-88 | Le transazioni SQLite non sono quelle che il codice presuppone; `synchronous=NORMAL` può perdere gli ultimi commit | Alta | *da registrare* | `test_sqlite_transactions.py`, `test_db_base.py` | DAT, ARC |
| B-89 | Problemi della chiave rilevati solo al primo uso; gli script accettano qualunque xprv e scrivono file leggibili da tutti | Alta | **aperto** | — | SEC, OPS |
| B-90 | Il ripristino di un backup riapre round già pagati e riassegna indici di derivazione | Alta | **aperto** | — | OPS, REG |
| B-91 | Container eseguito come root, permessi larghi, dipendenze e immagini non fissate | Alta | **aperto** | — | SEC, OPS |
@@ -252,8 +252,9 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)).
- **Corretti:** B-01 … B-70, tranne B-16, che è chiuso nella sostanza (il
crash è eliminato e `/guida` serve un segnaposto) ma lascia la guida vera
come limite accettato.
come limite accettato; dalla revisione del 2026-09-28: B-88.
- **Aperti:** B-71, B-72 (gravità bassa, operativi o di documentazione) e
B-73 … B-98 dalla revisione del 2026-09-28 (10 critici, 9 alti, 7 medi),
da implementare nell'ordine di [piano-correzioni.md](piano-correzioni.md).
B-73 … B-98 dalla revisione del 2026-09-28 tranne i corretti (10 critici,
8 alti, 7 medi), da implementare nell'ordine di
[piano-correzioni.md](piano-correzioni.md).
- **Prossimo numero libero:** `B-99`.
+2 -6
View File
@@ -86,12 +86,8 @@ stateDiagram-v2
riuscito le righe restano `building` e vale [TX-20]. La durabilità dei
commit (`synchronous=FULL`) è descritta in
[architettura.md](architettura.md) (B-88).
*Codice:* `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 apre
la transazione solo alla prima scrittura, quindi le letture precedenti non
sono isolate; un'attesa del blocco oltre 5 secondi emerge come
`OperationalError` in un punto qualunque, anche dopo un invio riuscito.
*Codice:* `app/db/base.py:begin_immediate`, `app/bets/service.py:place_bet`, `app/withdrawals/service.py:request_withdrawal`, `app/rounds/scheduler.py:RoundScheduler._trigger_payout`, `app/tx/broadcast.py:bump_fee`, `app/tx/reconcile.py:reconcile_once` ·
*Test:* `tests/unit/test_sqlite_transactions.py`
### Invio rifiutato