Compare commits

...
1 Commits
4 changed files with 110 additions and 88 deletions
+17 -15
View File
@@ -153,7 +153,7 @@ class LockMode(str, Enum):
@asynccontextmanager @asynccontextmanager
async def lock_tables( async def lock_tables(
session: AsyncSession, session_maker: async_sessionmaker[AsyncSession],
tables: list[type[DeclarativeBase]], tables: list[type[DeclarativeBase]],
*, *,
mode: LockMode = LockMode.SHARE_UPDATE_EXCLUSIVE, mode: LockMode = LockMode.SHARE_UPDATE_EXCLUSIVE,
@@ -161,42 +161,44 @@ async def lock_tables(
) -> AsyncGenerator[AsyncSession, None]: ) -> AsyncGenerator[AsyncSession, None]:
"""Lock PostgreSQL tables for the duration of a transaction. """Lock PostgreSQL tables for the duration of a transaction.
Acquires table-level locks that are held until the transaction ends.
Useful for preventing concurrent modifications during critical operations.
Args: Args:
session: AsyncSession instance session_maker: Async session factory used to create the dedicated
tables: List of SQLAlchemy model classes to lock session.
mode: Lock mode (default: SHARE UPDATE EXCLUSIVE) tables: List of SQLAlchemy model classes to lock.
timeout: Lock timeout (default: "5s") mode: Lock mode (default: SHARE UPDATE EXCLUSIVE).
timeout: Lock timeout (default: "5s").
Yields: Yields:
The session with locked tables The dedicated session, open within the locked transaction.
Raises: Raises:
SQLAlchemyError: If lock cannot be acquired within timeout SQLAlchemyError: If the lock cannot be acquired within *timeout*.
Example: Example:
```python ```python
from fastapi_toolsets.db import lock_tables, LockMode from fastapi_toolsets.db import lock_tables, LockMode
async with lock_tables(session, [User, Account]): async with lock_tables(session_maker, [User, Account]) as session:
# Tables are locked with SHARE UPDATE EXCLUSIVE mode # Tables are locked; changes are committed when the context exits.
user = await UserCrud.get(session, [User.id == 1]) user = await UserCrud.get(session, [User.id == 1])
user.balance += 100 user.balance += 100
# With custom lock mode # With custom lock mode
async with lock_tables(session, [Order], mode=LockMode.EXCLUSIVE): async with lock_tables(session_maker, [Order], mode=LockMode.EXCLUSIVE) as session:
# Exclusive lock - no other transactions can access
await process_order(session, order_id) await process_order(session, order_id)
``` ```
""" """
table_names = ",".join(table.__tablename__ for table in tables) table_names = ",".join(table.__tablename__ for table in tables)
async with get_transaction(session): async with session_maker() as session:
try:
await session.execute(text(f"SET LOCAL lock_timeout='{timeout}'")) await session.execute(text(f"SET LOCAL lock_timeout='{timeout}'"))
await session.execute(text(f"LOCK {table_names} IN {mode.value} MODE")) await session.execute(text(f"LOCK {table_names} IN {mode.value} MODE"))
yield session yield session
await session.commit()
except BaseException:
await session.rollback()
raise
async def create_database( async def create_database(
+15
View File
@@ -439,6 +439,21 @@ async def engine():
await engine.dispose() await engine.dispose()
@pytest.fixture(scope="function")
async def session_maker(engine):
"""Provide a session factory with tables created and dropped around the test."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
factory = async_sessionmaker(engine, expire_on_commit=False)
try:
yield factory
finally:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
@pytest.fixture(scope="function") @pytest.fixture(scope="function")
async def db_session(engine): async def db_session(engine):
"""Create a test database session with tables. """Create a test database session with tables.
+41 -53
View File
@@ -116,13 +116,8 @@ class TestCreateDbDependency:
await engine.dispose() await engine.dispose()
@pytest.mark.anyio @pytest.mark.anyio
async def test_update_after_lock_tables_is_persisted(self): async def test_data_inside_lock_is_committed(self):
"""Changes made after lock_tables exits (before endpoint returns) are committed. """Changes made inside lock_tables are committed when the context exits."""
Regression: without the auto-begin fix, lock_tables would start and commit a
real outer transaction, leaving the session idle. Any modifications after that
point were silently dropped.
"""
engine = create_async_engine(DATABASE_URL, echo=False) engine = create_async_engine(DATABASE_URL, echo=False)
session_factory = async_sessionmaker(engine, expire_on_commit=False) session_factory = async_sessionmaker(engine, expire_on_commit=False)
@@ -130,21 +125,12 @@ class TestCreateDbDependency:
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
try: try:
get_db = create_db_dependency(session_factory) async with lock_tables(session_factory, [Role]) as session:
role = Role(name="lock_committed")
async for session in get_db():
async with lock_tables(session, [Role]):
role = Role(name="lock_then_update")
session.add(role) session.add(role)
await session.flush()
# lock_tables has exited — outer transaction must still be open
assert session.in_transaction()
role.name = "updated_after_lock"
async with session_factory() as verify: async with session_factory() as verify:
result = await RoleCrud.first( result = await RoleCrud.first(verify, [Role.name == "lock_committed"])
verify, [Role.name == "updated_after_lock"]
)
assert result is not None assert result is not None
finally: finally:
async with engine.begin() as conn: async with engine.begin() as conn:
@@ -287,53 +273,54 @@ class TestLockTables:
"""Tests for lock_tables context manager (PostgreSQL-specific).""" """Tests for lock_tables context manager (PostgreSQL-specific)."""
@pytest.mark.anyio @pytest.mark.anyio
async def test_lock_single_table(self, db_session: AsyncSession): async def test_lock_single_table(self, session_maker):
"""Lock a single table.""" """Lock a single table; changes inside are committed on context exit."""
async with lock_tables(db_session, [Role]): async with lock_tables(session_maker, [Role]) as session:
# Inside the lock, we can still perform operations
role = Role(name="locked_role") role = Role(name="locked_role")
db_session.add(role) session.add(role)
await db_session.flush()
# After lock is released, verify the data was committed async with session_maker() as verify:
result = await RoleCrud.first(db_session, [Role.name == "locked_role"]) result = await RoleCrud.first(verify, [Role.name == "locked_role"])
assert result is not None assert result is not None
@pytest.mark.anyio @pytest.mark.anyio
async def test_lock_multiple_tables(self, db_session: AsyncSession): async def test_lock_multiple_tables(self, session_maker):
"""Lock multiple tables.""" """Lock multiple tables."""
async with lock_tables(db_session, [Role, User]): async with lock_tables(session_maker, [Role, User]) as session:
role = Role(name="multi_lock_role") role = Role(name="multi_lock_role")
db_session.add(role) session.add(role)
await db_session.flush()
result = await RoleCrud.first(db_session, [Role.name == "multi_lock_role"]) async with session_maker() as verify:
result = await RoleCrud.first(verify, [Role.name == "multi_lock_role"])
assert result is not None assert result is not None
@pytest.mark.anyio @pytest.mark.anyio
async def test_lock_with_custom_mode(self, db_session: AsyncSession): async def test_lock_with_custom_mode(self, session_maker):
"""Lock with custom lock mode.""" """Lock with custom lock mode."""
async with lock_tables(db_session, [Role], mode=LockMode.EXCLUSIVE): async with lock_tables(
session_maker, [Role], mode=LockMode.EXCLUSIVE
) as session:
role = Role(name="exclusive_lock_role") role = Role(name="exclusive_lock_role")
db_session.add(role) session.add(role)
await db_session.flush()
result = await RoleCrud.first(db_session, [Role.name == "exclusive_lock_role"]) async with session_maker() as verify:
result = await RoleCrud.first(verify, [Role.name == "exclusive_lock_role"])
assert result is not None assert result is not None
@pytest.mark.anyio @pytest.mark.anyio
async def test_lock_rollback_on_exception(self, db_session: AsyncSession): async def test_lock_rollback_on_exception(self, session_maker):
"""Lock context rolls back on exception.""" """Lock context rolls back on exception."""
try: try:
async with lock_tables(db_session, [Role]): async with lock_tables(session_maker, [Role]) as session:
role = Role(name="lock_rollback_role") role = Role(name="lock_rollback_role")
db_session.add(role) session.add(role)
await db_session.flush() await session.flush()
raise ValueError("Simulated error") raise ValueError("Simulated error")
except ValueError: except ValueError:
pass pass
result = await RoleCrud.first(db_session, [Role.name == "lock_rollback_role"]) async with session_maker() as verify:
result = await RoleCrud.first(verify, [Role.name == "lock_rollback_role"])
assert result is None assert result is None
@@ -643,24 +630,25 @@ class TestM2MAdd:
await m2m_add(db_session, user, User.role, role) await m2m_add(db_session, user, User.role, role)
@pytest.mark.anyio @pytest.mark.anyio
async def test_works_inside_lock_tables(self, db_session: AsyncSession): async def test_works_inside_lock_tables(self, session_maker):
"""m2m_add works correctly inside a lock_tables nested transaction.""" """m2m_add works correctly inside a lock_tables context."""
async with lock_tables(session_maker, [Tag]) as session:
user = User(username="m2m_lock_author", email="m2m_lock@test.com") user = User(username="m2m_lock_author", email="m2m_lock@test.com")
db_session.add(user) session.add(user)
await db_session.flush() await session.flush()
async with lock_tables(db_session, [Tag]):
tag = Tag(name="locked_tag") tag = Tag(name="locked_tag")
db_session.add(tag) session.add(tag)
await db_session.flush() await session.flush()
post = Post(title="Post Lock", author_id=user.id) post = Post(title="Post Lock", author_id=user.id)
db_session.add(post) session.add(post)
await db_session.flush() await session.flush()
await m2m_add(db_session, post, Post.tags, tag) await m2m_add(session, post, Post.tags, tag)
result = await db_session.execute( async with session_maker() as verify:
result = await verify.execute(
select(Post).where(Post.id == post.id).options(selectinload(Post.tags)) select(Post).where(Post.id == post.id).options(selectinload(Post.tags))
) )
loaded = result.scalar_one() loaded = result.scalar_one()
+23 -6
View File
@@ -7,6 +7,7 @@ from unittest.mock import patch
import pytest import pytest
from sqlalchemy import String from sqlalchemy import String
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
import fastapi_toolsets.models.watched as _watched_module import fastapi_toolsets.models.watched as _watched_module
@@ -20,6 +21,7 @@ from fastapi_toolsets.models import (
listens_for, listens_for,
) )
from fastapi_toolsets.models.watched import ( from fastapi_toolsets.models.watched import (
EventSession,
_EVENT_HANDLERS, _EVENT_HANDLERS,
_SESSION_CREATES, _SESSION_CREATES,
_SESSION_DELETES, _SESSION_DELETES,
@@ -338,6 +340,23 @@ async def mixin_session_expire():
yield session yield session
@pytest.fixture(scope="function")
async def mixin_session_maker():
"""Provide an EventSession-backed session factory with MixinBase tables."""
engine = create_async_engine(DATABASE_URL, echo=False)
async with engine.begin() as conn:
await conn.run_sync(MixinBase.metadata.create_all)
factory = async_sessionmaker(engine, expire_on_commit=False, class_=EventSession)
try:
yield factory
finally:
async with engine.begin() as conn:
await conn.run_sync(MixinBase.metadata.drop_all)
await engine.dispose()
class TestUUIDMixin: class TestUUIDMixin:
@pytest.mark.anyio @pytest.mark.anyio
async def test_uuid_generated_by_db(self, mixin_session): async def test_uuid_generated_by_db(self, mixin_session):
@@ -1559,15 +1578,13 @@ class TestEventSessionWithGetTransaction:
assert creates[0]["obj_id"] == survivor.id assert creates[0]["obj_id"] == survivor.id
@pytest.mark.anyio @pytest.mark.anyio
async def test_lock_tables_with_events(self, mixin_session): async def test_lock_tables_with_events(self, mixin_session_maker):
"""Events fire correctly after lock_tables context.""" """Events fire correctly when lock_tables commits on context exit."""
from fastapi_toolsets.db import lock_tables from fastapi_toolsets.db import lock_tables
async with lock_tables(mixin_session, [WatchedModel]): async with lock_tables(mixin_session_maker, [WatchedModel]) as session:
obj = WatchedModel(status="locked", other="x") obj = WatchedModel(status="locked", other="x")
mixin_session.add(obj) session.add(obj)
await mixin_session.commit()
creates = [e for e in _test_events if e["event"] == "create"] creates = [e for e in _test_events if e["event"] == "create"]
assert len(creates) == 1 assert len(creates) == 1