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:
1 parent
4be58709d1
commit
7ba8e22bb4
25 files changed
+1165
-82
No files matched your search
+10
-7
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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`,
|
||||
|
||||
@@ -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
@@ -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
@@ -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`.
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user