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