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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user