mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 12:28:22 +00:00
fix: make storage retries idempotent
This commit is contained in:
+29
-6
@@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import hashlib
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import sqlite3
|
import sqlite3
|
||||||
@@ -11,7 +12,7 @@ from alembic import command
|
|||||||
from alembic.config import Config
|
from alembic.config import Config
|
||||||
from alembic.util.exc import CommandError
|
from alembic.util.exc import CommandError
|
||||||
from sqlalchemy import UniqueConstraint, delete
|
from sqlalchemy import UniqueConstraint, delete
|
||||||
from sqlalchemy.exc import OperationalError
|
from sqlalchemy.exc import IntegrityError, OperationalError
|
||||||
from sqlalchemy.ext.asyncio.engine import create_async_engine
|
from sqlalchemy.ext.asyncio.engine import create_async_engine
|
||||||
from sqlalchemy.orm import aliased
|
from sqlalchemy.orm import aliased
|
||||||
from sqlmodel import Field, Relationship, SQLModel, col, func, select, update
|
from sqlmodel import Field, Relationship, SQLModel, col, func, select, update
|
||||||
@@ -288,10 +289,13 @@ async def store_cashu_transaction(
|
|||||||
created_at: int | None = None,
|
created_at: int | None = None,
|
||||||
source: str = "x-cashu",
|
source: str = "x-cashu",
|
||||||
api_key_hashed_key: str | None = None,
|
api_key_hashed_key: str | None = None,
|
||||||
|
transaction_id: str | None = None,
|
||||||
|
log_failure: bool = True,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
try:
|
try:
|
||||||
async with create_session() as session:
|
async with create_session() as session:
|
||||||
tx = CashuTransaction(
|
tx = CashuTransaction(
|
||||||
|
id=transaction_id or uuid.uuid4().hex,
|
||||||
token=token,
|
token=token,
|
||||||
amount=amount,
|
amount=amount,
|
||||||
unit=unit,
|
unit=unit,
|
||||||
@@ -306,15 +310,21 @@ async def store_cashu_transaction(
|
|||||||
session.add(tx)
|
session.add(tx)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.critical(
|
if log_failure:
|
||||||
"Failed to store Cashu transaction",
|
logger.critical(
|
||||||
extra={"type": typ, "request_id": request_id, "source": source},
|
"Failed to store Cashu transaction",
|
||||||
exc_info=True,
|
extra={"type": typ, "request_id": request_id, "source": source},
|
||||||
)
|
exc_info=True,
|
||||||
|
)
|
||||||
raise
|
raise
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def _cashu_transaction_exists(transaction_id: str) -> bool:
|
||||||
|
async with create_session() as session:
|
||||||
|
return await session.get(CashuTransaction, transaction_id) is not None
|
||||||
|
|
||||||
|
|
||||||
async def store_cashu_transaction_with_retry(
|
async def store_cashu_transaction_with_retry(
|
||||||
token: str,
|
token: str,
|
||||||
amount: int,
|
amount: int,
|
||||||
@@ -329,6 +339,7 @@ async def store_cashu_transaction_with_retry(
|
|||||||
max_attempts: int = 3,
|
max_attempts: int = 3,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Retry a critical Cashu transaction write with bounded backoff."""
|
"""Retry a critical Cashu transaction write with bounded backoff."""
|
||||||
|
transaction_id = hashlib.sha256(f"{typ}\0{token}".encode()).hexdigest()
|
||||||
last_error: Exception | None = None
|
last_error: Exception | None = None
|
||||||
for attempt in range(1, max_attempts + 1):
|
for attempt in range(1, max_attempts + 1):
|
||||||
try:
|
try:
|
||||||
@@ -343,9 +354,21 @@ async def store_cashu_transaction_with_retry(
|
|||||||
created_at=created_at,
|
created_at=created_at,
|
||||||
source=source,
|
source=source,
|
||||||
api_key_hashed_key=api_key_hashed_key,
|
api_key_hashed_key=api_key_hashed_key,
|
||||||
|
transaction_id=transaction_id,
|
||||||
|
log_failure=False,
|
||||||
)
|
)
|
||||||
|
except IntegrityError as error:
|
||||||
|
try:
|
||||||
|
if await _cashu_transaction_exists(transaction_id):
|
||||||
|
return True
|
||||||
|
except Exception as lookup_error:
|
||||||
|
last_error = lookup_error
|
||||||
|
else:
|
||||||
|
last_error = error
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
last_error = error
|
last_error = error
|
||||||
|
|
||||||
|
if last_error is not None:
|
||||||
if attempt == max_attempts:
|
if attempt == max_attempts:
|
||||||
break
|
break
|
||||||
delay = 0.25 * (2 ** (attempt - 1))
|
delay = 0.25 * (2 ** (attempt - 1))
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
from sqlmodel import SQLModel, select
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
from routstr.core import db
|
from routstr.core import db
|
||||||
|
|
||||||
@@ -25,6 +29,44 @@ async def test_cashu_transaction_storage_retries_then_succeeds() -> None:
|
|||||||
sleep.assert_awaited_once_with(0.25)
|
sleep.assert_awaited_once_with(0.25)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cashu_transaction_retry_is_idempotent_after_ambiguous_commit() -> None:
|
||||||
|
engine = create_async_engine("sqlite+aiosqlite://")
|
||||||
|
async with engine.begin() as connection:
|
||||||
|
await connection.run_sync(SQLModel.metadata.create_all)
|
||||||
|
|
||||||
|
original_store = db.store_cashu_transaction
|
||||||
|
attempts = 0
|
||||||
|
|
||||||
|
async def ambiguous_store(**kwargs: Any) -> bool:
|
||||||
|
nonlocal attempts
|
||||||
|
attempts += 1
|
||||||
|
stored = await original_store(**kwargs)
|
||||||
|
if attempts == 1:
|
||||||
|
raise OSError("connection dropped after commit")
|
||||||
|
return stored
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(db, "engine", engine),
|
||||||
|
patch("routstr.core.db.store_cashu_transaction", ambiguous_store),
|
||||||
|
patch("routstr.core.db.asyncio.sleep", AsyncMock()),
|
||||||
|
):
|
||||||
|
stored = await db.store_cashu_transaction_with_retry(
|
||||||
|
token="cashuAambiguous",
|
||||||
|
amount=100,
|
||||||
|
unit="sat",
|
||||||
|
)
|
||||||
|
|
||||||
|
async with AsyncSession(engine) as session:
|
||||||
|
result = await session.exec(select(db.CashuTransaction))
|
||||||
|
transactions = result.all()
|
||||||
|
|
||||||
|
assert stored is True
|
||||||
|
assert attempts == 2
|
||||||
|
assert len(transactions) == 1
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_cashu_transaction_storage_raises_after_bounded_retries() -> None:
|
async def test_cashu_transaction_storage_raises_after_bounded_retries() -> None:
|
||||||
error = OSError("database unavailable")
|
error = OSError("database unavailable")
|
||||||
|
|||||||
Reference in New Issue
Block a user