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

+2
View File
@@ -41,6 +41,8 @@ async def create_bet(
code = (
status.HTTP_502_BAD_GATEWAY
if exc.code == "broadcast_failed"
else status.HTTP_503_SERVICE_UNAVAILABLE # B-88: write lock not granted, nothing sent
if exc.code == "service_busy"
else status.HTTP_400_BAD_REQUEST
)
raise from_api_error(code, exc) from exc
+2
View File
@@ -47,6 +47,8 @@ async def create_withdrawal(
code = (
status.HTTP_502_BAD_GATEWAY # the network refused it, not the caller (B-07)
if exc.code == "broadcast_failed"
else status.HTTP_503_SERVICE_UNAVAILABLE # B-88: write lock not granted, nothing sent
if exc.code == "service_busy"
else status.HTTP_400_BAD_REQUEST
)
raise from_api_error(code, exc) from exc
+14 -1
View File
@@ -1,7 +1,7 @@
from fastapi import APIRouter, Depends, Request, status
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.exc import IntegrityError, OperationalError
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.client_ip import client_ip as _client_ip
@@ -13,6 +13,7 @@ from app.auth.security import (
hash_password_async,
verify_password_async,
)
from app.db.base import begin_immediate
from app.db.models import User
from app.db.session import get_session
from app.wallet.hd import derive_user_address
@@ -89,6 +90,18 @@ async def register(
password_hash = await hash_password_async(body.password)
for _ in range(_MAX_REGISTER_RETRIES):
# B-88: the max(derivation_index) read and the insert that uses it are one
# transaction under the write lock — with a plain BEGIN two concurrent
# registrations both read the same maximum and one of them failed on the
# unique index. A lock that cannot be had is a "try again", not a 500.
try:
await begin_immediate(session)
except OperationalError as exc:
raise http_error(
status.HTTP_503_SERVICE_UNAVAILABLE,
"service_busy",
"the service is busy right now, nothing was sent — please try again",
) from exc
max_index = await session.scalar(select(func.max(User.derivation_index)))
next_index = 0 if max_index is None else max_index + 1
address = derive_user_address(next_index)
+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:
+55 -3
View File
@@ -1,5 +1,5 @@
from sqlalchemy import event
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
@@ -12,6 +12,11 @@ from app.config import settings
_SQLITE_BUSY_TIMEOUT_MS = 5000
# Execution option read by the `begin` event below: which BEGIN a connection's next
# transaction opens with. Set through `begin_immediate`, never by hand.
_BEGIN_MODE_OPTION = "sqlite_begin_mode"
def _register_sqlite_pragmas(target_engine: AsyncEngine) -> None:
"""Without WAL, SQLite's default (rollback-journal) mode lets a writer block
every reader for the duration of its transaction, and a second writer arriving
@@ -21,22 +26,69 @@ def _register_sqlite_pragmas(target_engine: AsyncEngine) -> None:
busy_timeout gives a second writer a real window to wait for the first
instead of an instant `OperationalError`.
B-88: two more things, both about the code's transactions being what it
assumes. `synchronous=FULL`, because with WAL `NORMAL` can lose the last
commits on power loss — a registration, and with it a derivation index that
would then be handed to the next user. And real transaction control: pysqlite's
legacy mode emits `BEGIN` only at the first INSERT/UPDATE/DELETE, so every read
before it ran outside any transaction and the lock wait surfaced as `database is
locked` at an arbitrary later statement. `isolation_level = None` turns the
driver's implicit BEGIN off and the `begin` event emits it ourselves — a plain
`BEGIN` by default, `BEGIN IMMEDIATE` for a transaction opened through
`begin_immediate`, which takes the write lock *before* the first read so that
what a money-moving transaction reads is what it then writes on top of.
No-op for any dialect other than sqlite (e.g. a future PostgreSQL
DATABASE_URL), which neither needs nor understands these pragmas.
DATABASE_URL), which neither needs nor understands any of this.
"""
if target_engine.dialect.name != "sqlite":
return
@event.listens_for(target_engine.sync_engine, "connect")
def _set_sqlite_pragmas(dbapi_connection, connection_record) -> None:
# Before the pragmas: journal_mode cannot change inside a transaction, and in
# legacy mode the driver may have one open.
dbapi_connection.isolation_level = None
cursor = dbapi_connection.cursor()
try:
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA synchronous=NORMAL")
cursor.execute("PRAGMA synchronous=FULL") # B-88
cursor.execute(f"PRAGMA busy_timeout={_SQLITE_BUSY_TIMEOUT_MS}")
finally:
cursor.close()
@event.listens_for(target_engine.sync_engine, "begin")
def _emit_begin(conn) -> None: # B-88
mode = conn.get_execution_options().get(_BEGIN_MODE_OPTION, "DEFERRED")
conn.exec_driver_sql("BEGIN IMMEDIATE" if mode == "IMMEDIATE" else "BEGIN")
async def begin_immediate(session: AsyncSession) -> None:
"""B-88: start the session's next transaction with `BEGIN IMMEDIATE`, i.e. take
SQLite's write lock now, before the first read.
Every transaction that moves money or fixes its state calls this first thing, so
that what it reads (balance, free UTXOs, participants, the round's status) cannot
change under it before its own writes, and so that the wait for the lock happens
here — at the very start, before anything has been broadcast — instead of at the
first write. Raises `sqlalchemy.exc.OperationalError` ("database is locked") when
the lock is not granted within `busy_timeout`; the caller decides whether that
means "refuse" (before a broadcast) or "leave it to the reconciler" (after).
A transaction the session already has open is committed first — a lock cannot be
upgraded in place, and the callers open the transaction with nothing but reads
behind them (typically the authentication lookup). Pending, unflushed changes
make that unsafe to do silently, so they are an error.
Harmless on a session whose engine did not go through `_register_sqlite_pragmas`
(the option is simply never read) and on a non-sqlite dialect.
"""
if session.new or session.dirty or session.deleted:
raise RuntimeError("begin_immediate called with unflushed changes on the session")
if session.in_transaction():
await session.commit()
await session.connection(execution_options={_BEGIN_MODE_OPTION: "IMMEDIATE"})
engine = create_async_engine(settings.database_url)
_register_sqlite_pragmas(engine)
+21
View File
@@ -4,6 +4,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.audit.log import write_audit_log
from app.db.base import begin_immediate
from app.db.models import UtxoEvent
from app.rounds.events import broadcaster
from app.wallet.balance import recompute_balance
@@ -45,7 +46,12 @@ async def credit_confirmed_utxos(session: AsyncSession, user_id: int, entries: l
entries: [{"tx_hash": ..., "tx_pos": ..., "height": ..., "value": ...}, ...]
height <= 0 means unconfirmed (mempool) per the Electrum protocol convention —
skipped, since the spec requires 1 confirmation before crediting.
B-88: opens with BEGIN IMMEDIATE, so the "already recorded?" read and the inserts
it guards are one transaction — the unique (txid, vout) constraint stays the
backstop, not the mechanism.
"""
await begin_immediate(session)
existing_keys = {
(txid, vout)
for txid, vout in (
@@ -98,6 +104,20 @@ async def reinstate_reappeared_utxos(session: AsyncSession, user_id: int, entrie
"""
current_keys = {(entry["tx_hash"], entry["tx_pos"]) for entry in entries}
# B-88: the lock is only worth taking when there is something to write, and this
# runs on every refresh — so look first (plain columns), then take it and read the
# rows again under it.
flagged = (
await session.execute(
select(UtxoEvent.txid, UtxoEvent.vout).where(
UtxoEvent.user_id == user_id, UtxoEvent.spent_txid == _EXTERNAL_SPEND_SENTINEL
)
)
).all()
if not any((txid, vout) in current_keys for txid, vout in flagged):
return 0
await begin_immediate(session)
marked_rows = (
await session.scalars(
select(UtxoEvent).where(
@@ -178,6 +198,7 @@ async def mark_utxos_spent_externally(session: AsyncSession, user_id: int, utxo_
may have resolved it (a legitimate platform spend, or a prior refresh) between
when the caller read the candidate list and finished corroborating it.
"""
await begin_immediate(session) # B-88: that re-check and the mark are one transaction
marked = 0
for utxo_id in utxo_ids:
row = await session.get(UtxoEvent, utxo_id)
+33
View File
@@ -5,9 +5,11 @@ from datetime import datetime, timedelta, timezone
from embit import script
from embit.transaction import Transaction
from sqlalchemy import func, select
from sqlalchemy.exc import OperationalError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.audit.log import write_audit_log
from app.db.base import begin_immediate
from app.db.models import AuditLog, PendingTransaction, Round, RoundParticipant, User
from app.electrum.listener import ElectrumListener
from app.electrum.scripthash import address_to_scripthash
@@ -63,6 +65,10 @@ class RoundScheduler:
return
async with self._session_factory() as session:
# B-88: every state transition below starts with BEGIN IMMEDIATE (DAT-74).
# A busy database raises out of the tick, which run() logs and retries at
# the next one — nothing has been written yet.
await begin_immediate(session)
round_ = await open_new_round_if_needed(session)
await session.commit()
if round_ is None:
@@ -90,6 +96,7 @@ class RoundScheduler:
return
async with self._session_factory() as session:
await begin_immediate(session) # B-88
round_ = await session.get(Round, round_id)
# No new bets from here on, regardless of how long the pending-bet
# wait below takes — flip to "closing" immediately so it's observable
@@ -120,6 +127,7 @@ class RoundScheduler:
async def _close_and_draw(self, round_id: int) -> None:
async with self._session_factory() as session:
await begin_immediate(session) # B-88
round_ = await session.get(Round, round_id)
# B-53: re-check for in-flight bets in the *same* session that snapshots
@@ -178,6 +186,7 @@ class RoundScheduler:
winner_address = draw_winner(addresses, block_hash)
async with self._session_factory() as session:
await begin_immediate(session) # B-88
round_ = await session.get(Round, round_id)
round_.draw_block_height = block_height
round_.draw_block_hash = block_hash
@@ -361,6 +370,12 @@ class RoundScheduler:
# --- Phase 1: read (session closed before any network I/O) ---------------
async with self._session_factory() as session:
try:
await begin_immediate(session) # B-88
except OperationalError:
# Nothing built or sent yet: try again at the next tick.
logger.warning("round %s payout deferred: database busy", round_id)
return
round_ = await session.get(Round, round_id)
already_in_flight = await session.scalar(
select(PendingTransaction).where(
@@ -450,6 +465,13 @@ class RoundScheduler:
# --- Phase 3: persist the intent, *then* broadcast (B-25) -----------------
async with self._session_factory() as session:
try:
await begin_immediate(session) # B-88
except OperationalError:
# Not written, not broadcast: the payout is simply rebuilt on the retry.
logger.warning("round %s payout deferred before broadcast: database busy", round_id)
await self._log_payout_failure(round_id, winner_user_id, "database busy")
return
round_ = await session.get(Round, round_id)
round_.winner_amount_sats = built.winner_sats
round_.fee_amount_sats = built.commission_sats
@@ -481,6 +503,17 @@ class RoundScheduler:
return
async with self._session_factory() as session:
try:
await begin_immediate(session) # B-88
except OperationalError:
# The tx is on the network; the row stays "building" and the
# reconciler finds it on the chain and promotes it (TX-28).
logger.error(
"round %s payout %s broadcast but the database is busy: left 'building'",
round_id,
built.txid,
)
return
pending = await session.get(PendingTransaction, pending_id)
pending.status = "pending"
await write_audit_log(
+7
View File
@@ -159,6 +159,7 @@ const TRANSLATIONS = {
'error.invalid_request': 'Invalid request, please check the entered data.',
'error.invalid_amount': 'Enter an amount greater than zero.',
'error.broadcast_failed': 'The network refused the transaction. Please try again shortly.',
'error.service_busy': 'The service is busy right now and nothing was sent. Please try again in a moment.',
'error.amount_below_dust_limit': 'The amount is too small to be sent.',
'error.too_many_inputs': 'Your balance is split across too many small deposits to be spent in a single transaction (max {max_inputs}). Please contact support to consolidate it.',
'error.withdrawal_to_own_address': 'That is your own deposit address — withdraw to an external wallet.',
@@ -324,6 +325,7 @@ const TRANSLATIONS = {
'error.invalid_request': 'Richiesta non valida, controlla i dati inseriti.',
'error.invalid_amount': 'Inserisci un importo maggiore di zero.',
'error.broadcast_failed': 'La rete ha rifiutato la transazione. Riprova tra poco.',
'error.service_busy': 'Il servizio è occupato in questo momento e non è stato inviato nulla. Riprova tra poco.',
'error.amount_below_dust_limit': "L'importo è troppo basso per essere inviato.",
'error.too_many_inputs': 'Il tuo saldo è suddiviso in troppi piccoli depositi per essere speso in una sola transazione (max {max_inputs}). Contatta l\'assistenza per consolidarlo.',
'error.withdrawal_to_own_address': 'Questo è il tuo indirizzo di deposito — preleva verso un wallet esterno.',
@@ -489,6 +491,7 @@ const TRANSLATIONS = {
'error.invalid_request': 'Solicitud no válida, revisa los datos introducidos.',
'error.invalid_amount': 'Introduce un importe mayor que cero.',
'error.broadcast_failed': 'La red rechazó la transacción. Inténtalo de nuevo en un momento.',
'error.service_busy': 'El servicio está ocupado en este momento y no se ha enviado nada. Inténtalo de nuevo en un momento.',
'error.amount_below_dust_limit': 'El importe es demasiado pequeño para enviarse.',
'error.too_many_inputs': 'Tu saldo está repartido en demasiados depósitos pequeños para gastarse en una sola transacción (máx. {max_inputs}). Contacta con soporte para consolidarlo.',
'error.withdrawal_to_own_address': 'Esa es tu propia dirección de depósito — retira a una cartera externa.',
@@ -654,6 +657,7 @@ const TRANSLATIONS = {
'error.invalid_request': 'Requête invalide, vérifiez les données saisies.',
'error.invalid_amount': 'Saisissez un montant supérieur à zéro.',
'error.broadcast_failed': 'Le réseau a refusé la transaction. Veuillez réessayer dans un instant.',
'error.service_busy': 'Le service est occupé pour le moment et rien n\'a été envoyé. Veuillez réessayer dans un instant.',
'error.amount_below_dust_limit': "Le montant est trop faible pour être envoyé.",
'error.too_many_inputs': 'Votre solde est réparti sur trop de petits dépôts pour être dépensé en une seule transaction (max {max_inputs}). Contactez le support pour le consolider.',
'error.withdrawal_to_own_address': "C'est votre propre adresse de dépôt — retirez vers un portefeuille externe.",
@@ -819,6 +823,7 @@ const TRANSLATIONS = {
'error.invalid_request': 'Ungültige Anfrage, bitte überprüfe die eingegebenen Daten.',
'error.invalid_amount': 'Gib einen Betrag größer als null ein.',
'error.broadcast_failed': 'Das Netzwerk hat die Transaktion abgelehnt. Bitte versuche es in Kürze erneut.',
'error.service_busy': 'Der Dienst ist gerade ausgelastet und es wurde nichts gesendet. Bitte versuche es gleich noch einmal.',
'error.amount_below_dust_limit': 'Der Betrag ist zu klein, um gesendet zu werden.',
'error.too_many_inputs': 'Ihr Guthaben ist auf zu viele kleine Einzahlungen verteilt, um in einer einzigen Transaktion ausgegeben zu werden (max. {max_inputs}). Bitte wenden Sie sich an den Support, um es zusammenzufassen.',
'error.withdrawal_to_own_address': 'Das ist deine eigene Einzahlungsadresse — zahle auf eine externe Wallet aus.',
@@ -984,6 +989,7 @@ const TRANSLATIONS = {
'error.invalid_request': 'Некорректный запрос, проверьте введённые данные.',
'error.invalid_amount': 'Введите сумму больше нуля.',
'error.broadcast_failed': 'Сеть отклонила транзакцию. Попробуйте ещё раз через минуту.',
'error.service_busy': 'Сервис сейчас занят, ничего не было отправлено. Попробуйте ещё раз через минуту.',
'error.amount_below_dust_limit': 'Сумма слишком мала для отправки.',
'error.too_many_inputs': 'Ваш баланс разбит на слишком много мелких депозитов, чтобы потратить его одной транзакцией (максимум {max_inputs}). Обратитесь в поддержку для консолидации.',
'error.withdrawal_to_own_address': 'Это ваш собственный адрес для депозита — выводите на внешний кошелёк.',
@@ -1149,6 +1155,7 @@ const TRANSLATIONS = {
'error.invalid_request': '请求无效,请检查填写的内容。',
'error.invalid_amount': '请输入大于零的金额。',
'error.broadcast_failed': '网络拒绝了该交易,请稍后重试。',
'error.service_busy': '服务当前繁忙,没有发送任何内容,请稍后重试。',
'error.amount_below_dust_limit': '金额过小,无法发送。',
'error.too_many_inputs': '您的余额分散在过多的小额存款中,无法在一笔交易中花费(最多 {max_inputs} 笔)。请联系客服进行归集。',
'error.withdrawal_to_own_address': '这是你自己的充值地址 — 请提现到外部钱包。',
+2
View File
@@ -9,6 +9,7 @@ from embit.finalizer import finalize_psbt
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.db.base import begin_immediate
from app.db.models import PendingTransaction, Round, RoundParticipant, User, UtxoEvent, Withdrawal
from app.electrum.client import ElectrumClient
from app.rounds.config import get_round_config
@@ -175,6 +176,7 @@ async def bump_fee(
# --- Phase 3: persist the outcome ----------------------------------------------
async with session_factory() as session:
await begin_immediate(session) # B-88
pending = await session.get(PendingTransaction, pending_id)
old_txid = pending.current_txid
pending.replaced_by_txid = old_txid # points backwards: what current_txid replaced
+2
View File
@@ -5,6 +5,7 @@ from collections.abc import Awaitable, Callable
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.db.base import begin_immediate
from app.db.models import PendingTransaction
from app.electrum.client import ElectrumClient
from app.electrum.scripthash import address_to_scripthash
@@ -80,6 +81,7 @@ async def poll_once(session_factory: async_sessionmaker, client: ElectrumClient)
continue
async with session_factory() as session:
await begin_immediate(session) # B-88: read the row and write its outcome under one lock
row = await session.get(PendingTransaction, pending_id)
if row is None or row.status != "pending":
continue
+2
View File
@@ -34,6 +34,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.audit.log import write_audit_log
from app.db.base import begin_immediate
from app.db.models import PendingTransaction, Round, RoundParticipant, UtxoEvent, Withdrawal
from app.electrum.client import ElectrumClient
from app.electrum.scripthash import address_to_scripthash
@@ -122,6 +123,7 @@ async def reconcile_once(session_factory: async_sessionmaker, client: ElectrumCl
continue
async with session_factory() as session:
await begin_immediate(session) # B-88: the re-check and the resolution under one lock
row = await session.get(PendingTransaction, row_id)
if row is None or row.status != status:
continue # something else moved it while we were asking
+41 -1
View File
@@ -1,9 +1,13 @@
import logging
from embit import script
from sqlalchemy import select
from sqlalchemy.exc import OperationalError
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, User, UtxoEvent, Withdrawal
from app.electrum.client import ElectrumClient
from app.rounds.config import get_round_config
@@ -19,6 +23,9 @@ from app.wallet.psbt_builder import (
)
logger = logging.getLogger(__name__)
class WithdrawalError(ApiError):
pass
@@ -43,6 +50,18 @@ async def request_withdrawal(
"that is your own deposit address — withdraw to an external wallet instead",
)
# B-88 (DAT-74, TX-28): the write lock before the first read that decides
# anything, so the UTXOs selected below are the ones phase 1 then marks spent.
# Taken only after the checks that need no database at all, so a malformed
# request never queues for it. No lock within busy_timeout means nothing has been
# built or broadcast: the caller is asked to retry.
try:
await begin_immediate(session)
except OperationalError as exc:
raise WithdrawalError(
"service_busy", "the service is busy right now, nothing was sent — please try again"
) from exc
config = await get_round_config(session)
if amount_sats < config.bet_amount_sats:
raise WithdrawalError(
@@ -130,11 +149,31 @@ async def request_withdrawal(
try:
await client.broadcast(built.raw_hex)
except Exception as exc:
await _release_failed_withdrawal(session, withdrawal, pending, built, user.id, str(exc))
try:
await _release_failed_withdrawal(session, withdrawal, pending, built, user.id, str(exc))
except OperationalError:
# B-88: the release could not be written (database busy). The rows stay
# "building", the state committed before the broadcast, and the
# reconciler resolves them against the chain.
await session.rollback()
logger.error(
"could not release the failed withdrawal %s: database busy, left to the reconciler", built.txid
)
raise WithdrawalError(
"broadcast_failed", f"the network refused the transaction: {exc}"
) from exc
# B-88: after the broadcast nothing is undone. Without the lock the rows stay
# "building" and the reconciler promotes them; the caller sees the real status.
try:
await begin_immediate(session)
except OperationalError:
await session.rollback()
logger.error(
"withdrawal %s broadcast but the database is busy: left 'building' for the reconciler", built.txid
)
await session.refresh(withdrawal)
return withdrawal
withdrawal.status = "broadcast"
pending.status = "pending"
await write_audit_log(
@@ -162,6 +201,7 @@ async def _release_failed_withdrawal(
The Withdrawal row is kept (marked "failed") rather than deleted: unlike a bet, a
withdrawal is an instruction the user gave, and they should be able to see that it
didn't go through."""
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)