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