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

+49 -5
View File
@@ -1,3 +1,4 @@
import logging
from datetime import datetime, timezone
from embit import script
@@ -7,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.errors import ApiError
from app.audit.log import write_audit_log
from app.db.base import begin_immediate
from app.db.models import PendingTransaction, Round, RoundParticipant, User, UtxoEvent
from app.electrum.client import ElectrumClient
from app.rounds.config import get_round_config
@@ -23,6 +25,9 @@ from app.wallet.psbt_builder import (
)
logger = logging.getLogger(__name__)
class BetError(ApiError):
pass
@@ -33,6 +38,17 @@ class _RoundClosedDuringBuild(Exception):
async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -> RoundParticipant:
# B-88 (DAT-74, BET-11): take SQLite's write lock before the first read, so the
# balance, the free UTXOs, the participants and the round's status read below are
# the state the phase-1 write lands on — a plain BEGIN only starts at the first
# write, leaving every read before it outside any transaction. Failing to get the
# lock within busy_timeout is not a conflict about this bet: nothing has been
# built or broadcast yet, so the player is simply asked to retry.
try:
await begin_immediate(session)
except OperationalError as exc:
raise _service_busy() from exc
round_ = await open_new_round_if_needed(session)
if round_ is None:
raise BetError("no_round_open", "no round open right now, please try again shortly")
@@ -148,15 +164,19 @@ async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -
if guard.rowcount != 1:
raise _RoundClosedDuringBuild
await session.commit()
except (_RoundClosedDuringBuild, OperationalError) as exc:
# OperationalError here is SQLite's write-snapshot conflict: the round row
# changed under us, which is the same situation as the guard matching nothing.
except _RoundClosedDuringBuild as exc:
# Nothing has been broadcast yet, so the rollback undoes phase 1 entirely —
# the UTXOs stay unspent and no participant row survives.
await session.rollback()
raise BetError(
"round_closing", "the current round is closing, please try again shortly"
) from exc
except OperationalError as exc:
# B-88: SQLite has no write-snapshot conflicts — an OperationalError here is
# the lock wait (busy_timeout) expiring, which says nothing about the round.
# Same rollback, but the honest answer is "busy", not "closing".
await session.rollback()
raise _service_busy() from exc
# --- Phase 2: broadcast, then promote both rows to their live state ---------
try:
@@ -165,9 +185,26 @@ async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -
# The node refused it (fee too low, dust, mempool conflict, or simply an
# unreachable server) — nothing is on-chain, so undo phase 1 completely and
# give the user a translatable failure instead of a bare 500 (B-07).
await _release_failed_bet(session, participant, pending, built, user.id, str(exc))
try:
await _release_failed_bet(session, participant, pending, built, user.id, str(exc))
except OperationalError:
# B-88: the database is busy, so the release could not be written. The
# rows stay "building" — the state phase 1 committed — and the
# reconciler resolves them against the chain.
await session.rollback()
logger.error("could not release the failed bet %s: database busy, left to the reconciler", built.txid)
raise BetError("broadcast_failed", f"the network refused the transaction: {exc}") from exc
# B-88: after the broadcast nothing may be undone. If the lock cannot be had the
# rows stay "building" (the tx is on the network) and the reconciler promotes
# them; the caller still gets the participant, with the status the DB holds.
try:
await begin_immediate(session)
except OperationalError:
await session.rollback()
logger.error("bet %s broadcast but the database is busy: left 'building' for the reconciler", built.txid)
await session.refresh(participant) # reports the status the DB really holds
return participant
participant.status = "broadcast"
pending.status = "pending"
await write_audit_log(
@@ -177,7 +214,6 @@ async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -
user_id=user.id,
round_id=round_.id,
)
await session.commit()
await session.refresh(participant)
broadcaster.publish() # participant_count/jackpot changed — nudge every dashboard to refetch
@@ -195,6 +231,7 @@ async def _release_failed_bet(
"""Undo phase 1 after a failed broadcast: free the UTXOs the build reserved, drop
the two rows, and restore the balance. Same shape as what the reconciler does for
a tx that turns out never to have made it onto the chain."""
await begin_immediate(session) # B-88
for spent in built.spent_utxos:
row = await session.scalar(
select(UtxoEvent).where(UtxoEvent.txid == spent.txid, UtxoEvent.vout == spent.vout)
@@ -218,6 +255,13 @@ async def _release_failed_bet(
broadcaster.publish()
def _service_busy() -> BetError:
return BetError(
"service_busy",
"the service is busy right now, nothing was sent — please try again",
)
def _pending_transaction(
round_id: int, user_id: int, built: BuiltTransaction, fee_rate_sat_vb: int
) -> PendingTransaction: