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
+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`,
|
||||
|
||||
Reference in new issue
Block a user