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
+49
-5
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user