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

+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)