feat(backend): add timeout guard for locked_transaction used for credit transactions (#10528)

## Summary

This PR adds a timeout guard to the `locked_transaction` function used
for credit transactions to prevent indefinite blocking and improve
reliability.

## Changes

- Modified `locked_transaction` in `/backend/backend/data/db.py` to add
proper timeout handling
- Set `lock_timeout` and `statement_timeout` to prevent indefinite
blocking
- Updated function signature to use default timeout parameter
- Added comprehensive docstring explaining the locking mechanism

## Motivation

The previous implementation could potentially block indefinitely if a
lock couldn't be acquired, which could cause issues in production
environments, especially for critical credit transactions.

## Testing

- Existing tests pass
- The timeout mechanism ensures transactions won't hang indefinitely
- Advisory locks are properly released on commit/rollback

🤖 Generated with [Claude Code](https://claude.ai/code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Zamil Majdy
2025-08-02 23:32:20 +08:00
committed by GitHub
parent 69d873debc
commit 1c3fa804d4
2 changed files with 29 additions and 15 deletions

View File

@@ -1,6 +1,5 @@
import logging
import os
import zlib
from contextlib import asynccontextmanager
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
from uuid import uuid4
@@ -88,35 +87,50 @@ TRANSACTION_TIMEOUT = 15000 # 15 seconds - Increased from 5s to prevent timeout
@asynccontextmanager
async def transaction(timeout: int | None = None):
async def transaction(timeout: int = TRANSACTION_TIMEOUT):
"""
Create a database transaction with optional timeout.
Args:
timeout: Transaction timeout in milliseconds. If None, uses TRANSACTION_TIMEOUT (15s).
"""
if timeout is None:
timeout = TRANSACTION_TIMEOUT
async with prisma.tx(timeout=timeout) as tx:
yield tx
@asynccontextmanager
async def locked_transaction(key: str, timeout: int | None = None):
async def locked_transaction(key: str, timeout: int = TRANSACTION_TIMEOUT):
"""
Create a database transaction with advisory lock.
Create a transaction and take a per-key advisory *transaction* lock.
- Uses a 64-bit lock id via hashtextextended(key, 0) to avoid 32-bit collisions.
- Bound by lock_timeout and statement_timeout so it won't block indefinitely.
- Lock is held for the duration of the transaction and auto-released on commit/rollback.
Args:
key: Lock key for advisory lock
timeout: Transaction timeout in milliseconds. If None, uses TRANSACTION_TIMEOUT (15s).
key: String lock key (e.g., "usr_trx_<uuid>").
timeout: Transaction/lock/statement timeout in milliseconds.
"""
if timeout is None:
timeout = TRANSACTION_TIMEOUT
lock_key = zlib.crc32(key.encode("utf-8"))
async with transaction(timeout=timeout) as tx:
await tx.execute_raw("SELECT pg_advisory_xact_lock($1)", lock_key)
# Ensure we don't wait longer than desired
# Note: SET LOCAL doesn't support parameterized queries, must use string interpolation
await tx.execute_raw(f"SET LOCAL statement_timeout = '{int(timeout)}ms'") # type: ignore[arg-type]
await tx.execute_raw(f"SET LOCAL lock_timeout = '{int(timeout)}ms'") # type: ignore[arg-type]
# Block until acquired or lock_timeout hits
try:
await tx.execute_raw(
"SELECT pg_advisory_xact_lock(hashtextextended($1, 0))",
key,
)
except Exception as e:
# Normalize PG's lock timeout error to TimeoutError for callers
if "lock timeout" in str(e).lower():
raise TimeoutError(
f"Could not acquire lock for key={key!r} within {timeout}ms"
) from e
raise
yield tx

View File

@@ -152,7 +152,7 @@ def conn_retry(
# Preconfigured retry decorator for general functions
func_retry = create_retry_decorator(max_attempts=5, reraise=False)
func_retry = create_retry_decorator(max_attempts=5)
def continuous_retry(*, retry_delay: float = 1.0):