Resume a round in drawing from the database after an exception or a restart (B-81, RND-01, RND-15, RND-36, ARC-27, OPS-09, OPS-13, LIM-01)

`_tick` skipped `drawing`: the wait for the draw block lived only inside the
`_close_and_draw` call that entered it, so any exception (a locked database writing an
audit row, a malformed header) or a restart left the round in `drawing` for ever with the
pool inside and no new round able to open.

Since B-73 everything the draw needs is committed on entering `drawing` (encrypted secret,
ordered list with `draw_position`, `participants_hash`, `draw_height`, `draw_confirmations`),
so `_tick` now calls `_complete_draw` for a round in `drawing`. `_complete_draw` rereads that
state, is idempotent (a round no longer `drawing`, or with a winner, is left alone; the
winner write is re-checked under BEGIN IMMEDIATE) and runs inside the single scheduler loop,
so the inline call from `_close_and_draw` and the tick can never run concurrently.

Tests (test_draw_proof.py): an exception in the quorum query, then a tick, completes with the
same height, list, fingerprint and the winner the public proof recomputes; a brand-new
scheduler (restart) resumes and a second tick draws and triggers nothing again; a failure in
the very transaction that stores the winner leaves no winner and no revealed secret, and the
next tick completes. Mutation-checked: without the `_tick` branch all three fail.

Docs: the B-81 `*Stato:*`/`*Oggi:*` lines are gone (RND-01, RND-15, RND-36, ARC-27, OPS-09,
ADR-12, ADR-25), OPS-13 no longer prescribes the manual `closing` workaround, LIM-01 is
struck, storico-bug.md and BUGS.md updated. No existing test changed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-10-16 13:34:43 +02:00
1 parent db24287ea5
commit 473d541ab5
12 files changed
+230 -126

No files matched your search

@@ -1,6 +1,6 @@
# ADR-12 — Il server gira solo in Docker, in sviluppo come in produzione
- **Stato:** Accettata — implementazione da completare (B-81)
- **Stato:** Accettata
- **Area:** Configurazione e ambiente
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
@@ -19,5 +19,3 @@ script della chiave.
*Codice:* `Dockerfile` · `docker-compose.yml` · `Caddyfile` ·
*Test:* `tests/unit/test_docs_no_bare_uvicorn.py`
*Stato:* da implementare (B-81) · *Oggi:* dopo un crash `closing` e
`paying_out` riprendono da soli, `drawing` no ([LIM-01]).
@@ -1,6 +1,6 @@
# ADR-25 — Il blocco di estrazione è fissato per altezza, è successivo alla chiusura, e si usa solo quando è confermato e corroborato
- **Stato:** Accettata — implementazione da completare (B-81)
- **Stato:** Accettata
- **Area:** Round ed estrazione
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
@@ -32,6 +32,3 @@ chiusura significa «sconosciuta»: la prima altezza appresa fa da base.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._wait_for_draw_block` · `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · `app/rounds/scheduler.py:RoundScheduler._tick` ·
*Test:* `tests/unit/test_scheduler.py::test_a_block_relayer_cannot_choose_the_block_that_seeds_the_draw`, `tests/unit/test_scheduler.py::test_a_disagreement_asks_the_same_height_again_and_is_audited_once_per_hash`
*Stato:* da implementare (B-81) · *Oggi:* `draw_height`, `participants_hash` e le
posizioni sono salvati, ma `_tick` salta i round in `drawing`: dopo un errore
o un riavvio il round non riparte.
+1 -6
View File
@@ -554,12 +554,7 @@ implementare» non esistono ancora nel codice (vedi [ARC-60] e [ARC-22]).
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` ·
`app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
`app/rounds/scheduler.py:RoundScheduler._wait_for_draw_block` ·
*Test:* `tests/unit/test_scheduler.py`
*Stato:* da implementare (B-81) · *Oggi:* `_tick` salta i round in
`drawing`; `tip_at_close` e i partecipanti vivono solo nella chiamata a
`_close_and_draw`, quindi un'eccezione o un riavvio lasciano il round in
`drawing` per sempre
([LIM-01](limiti-noti.md#robustezza-del-ciclo-di-vita)).
*Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_draw_proof.py`
- **[ARC-28]** **`ConfirmationPoller`** (10s), **`RbfBumper`** (30s) e
**`PendingTransactionReconciler`** (al primo giro subito, poi ogni 120s)
lavorano sulla tabella `pending_transactions` (e sulla tabella
+5 -16
View File
@@ -23,10 +23,7 @@ adesso; l'ordine di implementazione è in
il passo successivo riparte dallo stato salvato nel database, **in
qualunque stato sia il round**, `drawing` compreso ([RND-36]).
*Codice:* `app/rounds/scheduler.py:RoundScheduler.run`, `app/rounds/scheduler.py:RoundScheduler._tick` ·
*Test:* `tests/unit/test_scheduler.py`
*Stato:* da implementare (B-81) · *Oggi:* un errore durante l'attesa del
blocco lascia il round in `drawing` per sempre, perché `_tick` non fa nulla
sui round in quello stato.
*Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_draw_proof.py::test_a_tick_resumes_a_drawing_round_after_an_exception_with_the_same_draw`
## Gli stati
@@ -281,11 +278,8 @@ flowchart TB
`draw_height + draw_confirmations − 1`. La condizione si controlla a ogni
passo dello scheduler, rileggendo dal database tutto ciò che serve
([RND-36]).
*Codice:* `app/rounds/scheduler.py:RoundScheduler._wait_for_draw_block` ·
*Test:* `tests/unit/test_scheduler.py`
*Stato:* da implementare (B-81) · *Oggi:* la condizione si controlla dentro
`_close_and_draw`, che resta in attesa per tutta la durata senza che
`_tick` la riprenda: dopo un errore o un riavvio non riparte.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._wait_for_draw_block`, `app/rounds/scheduler.py:RoundScheduler._tick` ·
*Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_draw_proof.py::test_a_restarted_scheduler_resumes_a_drawing_round_and_a_second_tick_draws_nothing`
- **[RND-16]** **Header concordato**: raggiunte le conferme, l'header
all'altezza `draw_height` (e non la punta corrente) viene chiesto a tutti i
server configurati, attivo compreso, e lo si usa solo se il quorum concorda
@@ -354,10 +348,7 @@ flowchart TB
ripresa dà sempre lo stesso vincitore, e un round già passato a
`paying_out` non viene estratto di nuovo.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick`, `app/rounds/scheduler.py:RoundScheduler._complete_draw` ·
*Test:* `tests/unit/test_draw_proof.py` (solo `_complete_draw`)
*Stato:* da implementare (B-81) · *Oggi:* tutto ciò che serve è nel database
e `_complete_draw` è idempotente, ma `_tick` salta i round in `drawing`:
dopo un errore o un riavvio il round resta in `drawing` per sempre.
*Test:* `tests/unit/test_draw_proof.py::test_a_drawing_round_is_completed_by_a_second_pass_with_the_same_winner`, `tests/unit/test_draw_proof.py::test_a_tick_resumes_a_drawing_round_after_an_exception_with_the_same_draw`, `tests/unit/test_draw_proof.py::test_a_restarted_scheduler_resumes_a_drawing_round_and_a_second_tick_draws_nothing`, `tests/unit/test_draw_proof.py::test_resuming_after_a_failure_inside_the_draw_transaction_leaves_no_half_state`
- **[RND-37]** **Prova pubblica**: `GET /rounds/{id}/proof`, senza
autenticazione, restituisce per un round: `server_seed_hash` e
`pool_address` (dall'apertura); dall'ingresso in `drawing` l'elenco dei
@@ -625,6 +616,4 @@ Due livelli indipendenti:
## Limite noto
- ~~[RND-32]~~ Assorbito da [RND-36]: nel comportamento atteso un round in
`drawing` riprende da solo dopo un riavvio o un errore. Finché B-81 non è
implementato il limite resta: lo scheduler riprende i round in `closing` e
`paying_out`, ma su un round in `drawing` non fa nulla.
`drawing` riprende da solo dopo un riavvio o un errore (B-81).
+4 -26
View File
@@ -32,7 +32,7 @@ implementato il [piano delle correzioni](piano-correzioni.md).
| Limite | Blocca il non presidiato oggi? | Dopo il piano |
|---|---|---|
| [LIM-01] `drawing` non riprende dopo un errore o un riavvio | **Sì** — è lo stato che resta fermo con denaro in gioco | Chiuso da B-81 |
| ~~[LIM-01]~~ `drawing` non riprende dopo un errore o un riavvio | Non più (chiuso da B-81) | — |
| [LIM-02] RBF di una sola forma (e pagamento senza resto) | **Sì, in caso di congestione della rete** | Chiuso da B-79 e B-87 |
| [LIM-03] Attesa in `closing` senza limite di tempo | **Sì, in caso di congestione** | Resta senza limite di tempo per scelta ([ADR-51](adr/ADR-51-dubbio-liberano-mai-fondi.md)); con B-78 e B-87 finisce quando la rete decide |
| [LIM-04] Prelievo e RBF mai provati in rete reale | **Sì, come verifica** | Da chiudere con la prova su rete reale ([operativita.md](operativita.md#prova-su-rete-reale)) prima di aprire al pubblico |
@@ -45,31 +45,9 @@ implementato il [piano delle correzioni](piano-correzioni.md).
### Robustezza del ciclo di vita
- **[LIM-01]** **Lo stato `drawing` non riprende dopo un errore o un
riavvio.** Lo scheduler gestisce `open`, `closing` e `paying_out` a ogni giro
e salta `drawing`; l'attesa del blocco (`_wait_for_draw_block`) vive solo
dentro la chiamata a `_close_and_draw` che l'ha avviata (altezza, elenco e
segreto sono ormai salvati nel database, ma nulla li rilegge). Non serve un riavvio: **qualunque
eccezione** durante l'attesa (un `database is locked` scrivendo una riga di
audit, un header malformato) la interrompe, e nessuno la riprende. Il terzo
audit (2026-08-03) lo ha ribadito come problema **critico**, la revisione
del 2026-09-28 lo ha numerato (B-81).
- *Impatto:* il round resta in `drawing` per sempre con il montepremi dentro;
poiché un nuovo round si apre solo a round chiuso, **l'intera lotteria si
ferma**. Con `restart: unless-stopped` un crash in quella finestra è
esattamente il caso che non si ripara da solo.
- *Mitigazione attuale:* l'attesa è osservabile — log periodici, evento
`draw_stalled` dopo 360s (e poi a ogni intervallo), `draw_waiting_since` su
`GET /rounds/current`. Il recupero è manuale (vedi
[operativita.md](operativita.md)).
- *Stato:* da chiudere con B-81.
- *Per chiuderlo:* al passaggio in `drawing` si salvano `draw_height`,
`participants_hash` e la posizione di estrazione di ogni partecipante;
`_tick` rientra nell'attesa per un round in `drawing`, in modo idempotente.
Con l'altezza fissa e il seme impegnato di [ADR-24](adr/ADR-24-l-estrazione-commit-reveal-fra-server.md)/[ADR-25](adr/ADR-25-blocco-estrazione-fissato-altezza-successivo-chiusura.md), un'estrazione
ripresa dà sempre lo stesso vincitore.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` · `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · `app/rounds/scheduler.py:RoundScheduler._wait_for_draw_block`
- ~~[LIM-01]~~ Rimosso: chiuso da B-81. Un round in `drawing` riprende da solo
dopo un errore o un riavvio, con lo stesso blocco, gli stessi partecipanti e lo
stesso vincitore ([RND-36](ciclo-di-vita-del-round.md)).
- **[LIM-02]** **L'RBF gestisce una sola forma di transazione**: un'unica uscita
di resto verso il mittente, abbastanza grande da assorbire l'aumento. Non c'è
+21 -50
View File
@@ -200,8 +200,8 @@ denaro di oggi.
*dopo* il backup non sono tra i partecipanti, ma i loro soldi sono nel
montepremi: annotarli per un rimborso manuale (nessuna procedura
supportata, vedi [OPS-24]);
- un round in `drawing` **non va mai sbloccato con [OPS-13]** prima di
questo controllo: se è già stato pagato, va chiuso come sopra.
- un round in `drawing` va controllato allo stesso modo prima di far
ripartire l'app: se è già stato pagato, va chiuso come sopra.
Annotare ogni modifica (data, id, motivo): nessuna lascia una riga di
audit.
7. **Controllare gli indirizzi riassegnabili** con [OPS-28], sempre ad app
@@ -320,10 +320,7 @@ modifica di `.env` e da solo dopo un crash (`restart: unless-stopped`).
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick`,
`app/rounds/scheduler.py:RoundScheduler._retry_payout_if_due`,
`app/tx/reconcile.py:reconcile_once` ·
*Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_reconcile.py`
*Stato:* da implementare (B-81) · *Oggi:* un round in `drawing`
**non riprende**: resta in `drawing` per sempre e nessun nuovo round si apre
(serve [OPS-13]).
*Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_reconcile.py`, `tests/unit/test_draw_proof.py`
- **[OPS-10]** **Riavvio sicuro e deploy** (nuova versione, modifica di
`.env`, rotazione di un segreto):
@@ -353,7 +350,7 @@ modifica di `.env` e da solo dopo un crash (`restart: unless-stopped`).
deploy è quindi, per il codice, un crash: durante `drawing` blocca il round
([OPS-13]), durante un invio lascia una riga `building`. Dopo B-93 l'arresto
chiude gli stream e le attività entro 30 secondi; il riavvio
durante `drawing` diventa sicuro con B-81 ([OPS-09]).
durante `drawing` è sicuro dopo B-81 ([OPS-09]).
La pausa non blocca scommesse e prelievi sul round in corso, né
registrazioni e depositi.
@@ -444,8 +441,7 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09).
4. Se i server configurati sono davvero tutti inutilizzabili, aggiungerne
altri in `ELECTRUM_FALLBACK_SERVERS` (elenco di riferimento:
`ChainProfiles.Mainnet.BootstrapServers` del repository PalladiumWallet) e
riavviare. **Se un round è in `drawing`, oggi il riavvio lo blocca**:
seguire poi [OPS-13].
riavviare. Un round in `drawing` riprende da solo dopo il riavvio ([OPS-13]).
*Codice:* `app/electrum/listener.py:ElectrumListener.run`,
`app/electrum/listener.py:ElectrumListener._keepalive`,
`app/api/routes/bets.py`, `app/api/routes/withdrawals.py` ·
@@ -477,50 +473,25 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09).
sale (Electrum, vedi [OPS-11]);
- righe `draw_header_corroboration_failed`, una per blocco: i blocchi
arrivano ma gli altri server non li confermano (vedi [OPS-12]);
- **nessuna riga nuova e nessun messaggio `still waiting for a block` nel
log**: l'attesa non è più in corso. Succede dopo un riavvio del processo,
oppure dopo un'eccezione durante l'attesa (messaggio
`round scheduler tick failed`, per esempio un database bloccato mentre si
scriveva `draw_stalled`). In questo caso il round non si sbloccherà mai da
solo.
- nessuna riga nuova per più di qualche minuto, con `round scheduler tick
failed` nel log: un'eccezione durante l'attesa (per esempio un database
bloccato mentre si scriveva `draw_stalled`) ha interrotto il passo. Non
richiede un intervento: dal passo successivo (5 secondi) lo scheduler
riprende l'estrazione dello stesso blocco ([RND-36]); se l'eccezione si
ripete, la causa è nella riga di log che la accompagna.
**Causa.** L'attesa del blocco di estrazione vive solo dentro la chiamata
dello scheduler che ha portato il round in `drawing`; a ogni ciclo successivo
lo scheduler vede `drawing` e non fa nulla. Nessun codice riprende l'attesa.
Dopo B-81 lo scheduler riprende da solo un round in `drawing` e il terzo
caso scompare.
**Causa.** L'attesa del blocco di estrazione è un passo dello scheduler che
rilegge tutto dal database. Dopo un riavvio o un'eccezione riparte da sola:
`draw_height`, elenco, `participants_hash` e segreto sono già salvati dal
passaggio in `drawing`, quindi l'estrazione ripresa dà lo stesso vincitore
(B-81).
**Cosa fare.** Nei primi due casi basta risolvere la causa (Electrum,
server di riserva): l'estrazione avviene da sola al primo blocco valido.
Nel terzo caso **non esiste una procedura supportata dal codice**. L'unico
intervento possibile è una modifica manuale del database che riporti il round
a `closing`, lo stato da cui lo scheduler rifà l'estrazione da capo.
**Vale solo per un round bloccato nell'istanza in esercizio: un round in
`drawing` arrivato da un backup ripristinato si tratta con [OPS-07],
passo 6**, perché potrebbe essere già stato pagato.
1. Annotare l'id del round (dal pannello, sezione Round) e controllare che
sia davvero in `drawing` e che `winner_user_id` sia vuoto.
2. `docker compose stop app`.
3. Aggiornare lo stato:
```bash
docker compose run --rm --no-deps app python -c "import sqlite3; c=sqlite3.connect('/app/db_data/plm_lottery.db'); n=c.execute(\"UPDATE rounds SET status='closing' WHERE id=<ID> AND status='drawing' AND winner_user_id IS NULL\").rowcount; c.commit(); print(n, 'riga aggiornata')"
```
Deve stampare `1 riga aggiornata`.
4. `docker compose start app`.
5. Al primo ciclo lo scheduler rilegge i partecipanti confermati, riporta il
round in `drawing` con un nuovo `drawing_started_at` e aspetta un blocco
successivo all'altezza *attuale*; poi estrae e paga normalmente
(`winner_drawn`, `payout_sent`).
Conseguenze da sapere: l'estrazione usa un blocco successivo al momento
dello sblocco invece del primo blocco dopo la chiusura, quindi quale blocco
decide il vincitore dipende da quando l'operatore interviene; la modifica non
lascia alcuna riga nel registro di audit, quindi va annotata altrove, con
data, id del round e motivo. I partecipanti non cambiano: il round è fuori da
`open`, quindi nessuna scommessa nuova può entrare.
**Cosa fare.** Risolvere la causa visibile (Electrum, server di riserva):
l'estrazione avviene da sola al primo blocco valido. **Non** riportare il
round a `closing` a mano: cambierebbe il blocco che decide il vincitore, e
non serve più.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick`,
`app/rounds/scheduler.py:RoundScheduler._close_and_draw`,
`app/rounds/scheduler.py:RoundScheduler._complete_draw`,
`app/rounds/scheduler.py:RoundScheduler._wait_for_draw_block`
### Round bloccato in `closing`
+3 -3
View File
@@ -229,7 +229,7 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)).
| B-78 | Una transazione viene abbandonata sulla base del silenzio di un solo server | Critica | *da registrare* | `test_reconcile_proof.py`, `test_reconcile.py`, `test_pause_reason.py`, `test_migrations.py` | TX, ELC |
| B-79 | Il pagamento di norma non ha resto, quindi non si può accelerare e può bloccare la lotteria | Critica | **aperto** | — | RND, TX |
| B-80 | Un solo indirizzo del montepremi per tutti i round | Critica | *da registrare* | `test_round_pool_address.py`, `test_pending_address.py`, `test_scheduler.py` | RND, BET, WDR |
| B-81 | `drawing` resta bloccato dopo qualsiasi eccezione, non solo dopo un riavvio | Critica | **aperto** | — | RND |
| B-81 | `drawing` resta bloccato dopo qualsiasi eccezione, non solo dopo un riavvio | Critica | *da registrare* | `test_draw_proof.py` | RND |
| B-82 | Un deposito annullato da una riorganizzazione dopo la scommessa rende il round impagabile; coinbase accreditate a 1 conferma | Critica | **aperto** | — | DEP, RND |
| B-83 | Conferme e reintegri si basano solo sul server attivo | Alta | *da registrare* | `test_confirmation_proof.py`, `test_confirmation.py` | ELC, TX, DEP |
| B-84 | Il quorum può ridursi a un solo server o a nessuno senza avvisi; certificati TLS non verificati | Alta | *da registrare* | `test_electrum_client.py`, `test_electrum_listener.py` | ELC, SEC |
@@ -252,9 +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; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80, B-84, B-76, B-77, B-78, B-83, B-74, B-85, B-86, B-73.
come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80, B-84, B-76, B-77, B-78, B-83, B-74, B-85, B-86, B-73, B-81.
- **Aperti:** B-71, B-72 (gravità bassa, operativi o di documentazione) e
B-73 … B-98 dalla revisione del 2026-09-28 tranne i corretti (3 critici,
B-73 … B-98 dalla revisione del 2026-09-28 tranne i corretti (2 critici,
4 alti, 6 medi), da implementare nell'ordine di
[piano-correzioni.md](piano-correzioni.md).
- **Prossimo numero libero:** `B-99`.