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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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': '这是你自己的充值地址 — 请提现到外部钱包。',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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