mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-22 12:26:08 +00:00
413 lines
17 KiB
Python
413 lines
17 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextvars
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
import sqlalchemy as sa
|
|
from sqlalchemy.ext.asyncio import create_async_engine
|
|
|
|
from langbot.pkg.core.task_boundary import create_detached_task
|
|
from langbot.pkg.entity.persistence.user import User
|
|
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
|
|
from langbot.pkg.persistence.tenant_uow import (
|
|
CrossScopeTransactionError,
|
|
PersistenceScopeKind,
|
|
TenantScopeRequiredError,
|
|
TenantUnitOfWork,
|
|
TransactionRollbackOnlyError,
|
|
)
|
|
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
|
|
async def test_sqlite_tenant_uow_commits_and_rolls_back() -> None:
|
|
engine = create_async_engine('sqlite+aiosqlite:///:memory:')
|
|
table = sa.Table(
|
|
'uow_rows',
|
|
sa.MetaData(),
|
|
sa.Column('id', sa.Integer, primary_key=True),
|
|
sa.Column('workspace_uuid', sa.String(36), nullable=False),
|
|
)
|
|
try:
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(table.metadata.create_all)
|
|
|
|
async with TenantUnitOfWork(engine, 'workspace-a') as uow:
|
|
await uow.execute(sa.insert(table).values(id=1, workspace_uuid='workspace-a'))
|
|
|
|
with pytest.raises(RuntimeError, match='roll back'):
|
|
async with TenantUnitOfWork(engine, 'workspace-a') as uow:
|
|
await uow.execute(sa.insert(table).values(id=2, workspace_uuid='workspace-a'))
|
|
raise RuntimeError('roll back this transaction')
|
|
|
|
async with engine.connect() as conn:
|
|
rows = (await conn.execute(sa.select(table.c.id).order_by(table.c.id))).scalars().all()
|
|
assert rows == [1]
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
async def test_tenant_uow_is_single_use_and_requires_an_active_scope() -> None:
|
|
engine = create_async_engine('sqlite+aiosqlite:///:memory:')
|
|
uow = TenantUnitOfWork(engine, 'workspace-a')
|
|
try:
|
|
with pytest.raises(RuntimeError, match='not active'):
|
|
_ = uow.session
|
|
|
|
async with uow:
|
|
assert uow.session.in_transaction()
|
|
|
|
with pytest.raises(RuntimeError, match='cannot be reused'):
|
|
async with uow:
|
|
pass
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
def test_persistence_mode_must_be_a_trusted_enum() -> None:
|
|
with pytest.raises(TypeError, match='trusted PersistenceMode'):
|
|
PersistenceManager(object(), mode='cloud_runtime') # type: ignore[arg-type]
|
|
|
|
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
|
|
assert manager.mode is PersistenceMode.CLOUD_RUNTIME
|
|
|
|
|
|
async def test_manager_reuses_one_session_and_rejects_cross_workspace(tmp_path) -> None:
|
|
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "manager-uow.db"}')
|
|
table = sa.Table(
|
|
'manager_rows',
|
|
sa.MetaData(),
|
|
sa.Column('id', sa.Integer, primary_key=True),
|
|
sa.Column('workspace_uuid', sa.String(36), nullable=False),
|
|
)
|
|
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
|
|
manager.db = SimpleNamespace(get_engine=lambda: engine)
|
|
try:
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(table.metadata.create_all)
|
|
|
|
with pytest.raises(TenantScopeRequiredError, match='explicit Workspace or discovery'):
|
|
await manager.execute_async(sa.select(table))
|
|
|
|
async with manager.tenant_uow('workspace-a') as outer:
|
|
await manager.execute_async(sa.insert(table).values(id=1, workspace_uuid='workspace-a'))
|
|
async with manager.tenant_uow('workspace-a') as inner:
|
|
assert inner.session is outer.session
|
|
assert manager.current_session() is outer.session
|
|
assert (await manager.execute_async(sa.select(table.c.id))).scalar_one() == 1
|
|
with pytest.raises(CrossScopeTransactionError, match='while workspace scope is active'):
|
|
async with manager.tenant_uow('workspace-b'):
|
|
pass
|
|
|
|
async with engine.connect() as conn:
|
|
assert (await conn.execute(sa.select(table.c.id))).scalars().all() == [1]
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
async def test_manager_scoped_execute_preserves_core_row_and_scalar_contract(tmp_path) -> None:
|
|
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "manager-result-contract.db"}')
|
|
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
|
|
manager.db = SimpleNamespace(get_engine=lambda: engine)
|
|
try:
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(User.__table__.create)
|
|
await conn.execute(
|
|
sa.insert(User).values(
|
|
uuid='account-a',
|
|
user='owner@example.com',
|
|
normalized_email='owner@example.com',
|
|
password='hashed-password',
|
|
)
|
|
)
|
|
|
|
async with manager.tenant_uow('workspace-a') as uow:
|
|
result = await manager.execute_async(sa.select(User))
|
|
row = result.first()
|
|
assert row is not None
|
|
assert row.uuid == 'account-a'
|
|
assert row.user == 'owner@example.com'
|
|
|
|
scalar_result = await manager.execute_async(sa.select(User.uuid))
|
|
assert scalar_result.scalar_one() == 'account-a'
|
|
|
|
list_result = await manager.execute_async(sa.select(User.user))
|
|
assert list_result.scalars().all() == ['owner@example.com']
|
|
|
|
# Direct UoW execution remains an ORM API for code that opts into it.
|
|
orm_result = await uow.execute(sa.select(User))
|
|
assert orm_result.scalars().one().uuid == 'account-a'
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
async def test_transaction_free_tenant_scope_opens_one_short_uow_per_database_call(tmp_path) -> None:
|
|
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "short-scope.db"}')
|
|
table = sa.Table(
|
|
'short_scope_rows',
|
|
sa.MetaData(),
|
|
sa.Column('id', sa.Integer, primary_key=True),
|
|
sa.Column('workspace_uuid', sa.String(36), nullable=False),
|
|
)
|
|
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
|
|
manager.db = SimpleNamespace(get_engine=lambda: engine)
|
|
try:
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(table.metadata.create_all)
|
|
|
|
async with manager.tenant_scope('workspace-a'):
|
|
assert manager.current_scope() is not None
|
|
assert manager.current_scope().kind is PersistenceScopeKind.WORKSPACE
|
|
assert manager.current_scope().settings == (('langbot.workspace_uuid', 'workspace-a'),)
|
|
assert manager.current_session() is None
|
|
|
|
await manager.execute_async(sa.insert(table).values(id=1, workspace_uuid='workspace-a'))
|
|
assert manager.current_session() is None
|
|
|
|
# The first statement has already committed. Long external waits
|
|
# inside this boundary retain only identity, never a DB session.
|
|
await asyncio.sleep(0)
|
|
assert manager.current_session() is None
|
|
|
|
assert (await manager.execute_async(sa.select(table.c.id))).scalar_one() == 1
|
|
assert manager.current_session() is None
|
|
|
|
async with manager.tenant_scope('workspace-a'):
|
|
assert manager.current_session() is None
|
|
with pytest.raises(CrossScopeTransactionError, match='while workspace scope is active'):
|
|
async with manager.tenant_scope('workspace-b'):
|
|
pass
|
|
with pytest.raises(CrossScopeTransactionError, match='while workspace scope is active'):
|
|
async with manager.tenant_uow('workspace-b'):
|
|
pass
|
|
|
|
assert manager.current_scope() is None
|
|
with pytest.raises(TenantScopeRequiredError, match='explicit Workspace'):
|
|
await manager.execute_async(sa.select(table))
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
async def test_transaction_free_scope_requires_explicit_child_task_scope(tmp_path) -> None:
|
|
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "short-scope-child.db"}')
|
|
table = sa.Table('child_scope_rows', sa.MetaData(), sa.Column('id', sa.Integer, primary_key=True))
|
|
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
|
|
manager.db = SimpleNamespace(get_engine=lambda: engine)
|
|
try:
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(table.metadata.create_all)
|
|
|
|
async with manager.tenant_scope('workspace-a'):
|
|
|
|
async def inherited_access() -> None:
|
|
await manager.execute_async(sa.select(table))
|
|
|
|
with pytest.raises(CrossScopeTransactionError, match='cannot be inherited by child tasks'):
|
|
await asyncio.create_task(inherited_access())
|
|
|
|
async def explicitly_scoped_access() -> None:
|
|
async with manager.tenant_scope('workspace-a'):
|
|
await manager.execute_async(sa.insert(table).values(id=1))
|
|
assert manager.current_session() is None
|
|
|
|
await asyncio.create_task(explicitly_scoped_access())
|
|
assert manager.current_session() is None
|
|
|
|
async with engine.connect() as conn:
|
|
assert (await conn.execute(sa.select(table.c.id))).scalars().all() == [1]
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
async def test_caught_nested_failure_marks_outer_transaction_rollback_only(tmp_path) -> None:
|
|
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "rollback-only.db"}')
|
|
table = sa.Table('rollback_rows', sa.MetaData(), sa.Column('id', sa.Integer, primary_key=True))
|
|
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
|
|
manager.db = SimpleNamespace(get_engine=lambda: engine)
|
|
try:
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(table.metadata.create_all)
|
|
|
|
with pytest.raises(TransactionRollbackOnlyError, match='transaction was rolled back'):
|
|
async with manager.tenant_uow('workspace-a'):
|
|
await manager.execute_async(sa.insert(table).values(id=1))
|
|
try:
|
|
async with manager.tenant_uow('workspace-a'):
|
|
raise ValueError('caught nested failure')
|
|
except ValueError:
|
|
pass
|
|
|
|
async with engine.connect() as conn:
|
|
assert (await conn.execute(sa.select(table))).all() == []
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
@pytest.mark.parametrize('executor_kind', ['manager', 'uow', 'session'])
|
|
async def test_caught_database_error_rolls_back_and_cancels_after_commit_gate(tmp_path, executor_kind: str) -> None:
|
|
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / f"db-error-{executor_kind}.db"}')
|
|
table = sa.Table('unique_rows', sa.MetaData(), sa.Column('id', sa.Integer, primary_key=True))
|
|
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
|
|
manager.db = SimpleNamespace(get_engine=lambda: engine)
|
|
try:
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(table.metadata.create_all)
|
|
|
|
with pytest.raises(TransactionRollbackOnlyError, match='after-commit work was cancelled'):
|
|
async with manager.tenant_uow('workspace-a') as uow:
|
|
statement = sa.insert(table).values(id=1)
|
|
if executor_kind == 'manager':
|
|
await manager.execute_async(statement)
|
|
elif executor_kind == 'uow':
|
|
await uow.execute(statement)
|
|
else:
|
|
await uow.session.execute(statement)
|
|
|
|
gate = manager.create_after_commit_gate()
|
|
assert gate is not None
|
|
try:
|
|
if executor_kind == 'manager':
|
|
await manager.execute_async(statement)
|
|
elif executor_kind == 'uow':
|
|
await uow.execute(statement)
|
|
else:
|
|
await uow.session.execute(statement)
|
|
except sa.exc.IntegrityError:
|
|
pass
|
|
|
|
assert gate.cancelled()
|
|
async with engine.connect() as conn:
|
|
assert (await conn.execute(sa.select(table))).all() == []
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
async def test_child_task_must_open_its_own_explicit_uow(tmp_path) -> None:
|
|
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "child-task.db"}')
|
|
table = sa.Table(
|
|
'child_rows',
|
|
sa.MetaData(),
|
|
sa.Column('id', sa.Integer, primary_key=True),
|
|
sa.Column('workspace_uuid', sa.String(36), nullable=False),
|
|
)
|
|
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
|
|
manager.db = SimpleNamespace(get_engine=lambda: engine)
|
|
try:
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(table.metadata.create_all)
|
|
|
|
async with manager.tenant_uow('workspace-a'):
|
|
|
|
async def inherited_access() -> None:
|
|
await manager.execute_async(sa.select(table))
|
|
|
|
with pytest.raises(CrossScopeTransactionError, match='cannot be inherited by child tasks'):
|
|
await asyncio.create_task(inherited_access())
|
|
|
|
async def explicitly_scoped_access() -> None:
|
|
async with manager.tenant_uow('workspace-a'):
|
|
await manager.execute_async(sa.insert(table).values(id=2, workspace_uuid='workspace-a'))
|
|
|
|
await asyncio.create_task(explicitly_scoped_access())
|
|
|
|
async with engine.connect() as conn:
|
|
assert (await conn.execute(sa.select(table.c.id))).scalars().all() == [2]
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
async def test_detached_task_starts_without_parent_scope_and_rolls_back_its_uow(tmp_path) -> None:
|
|
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "detached-task.db"}')
|
|
table = sa.Table(
|
|
'detached_rows',
|
|
sa.MetaData(),
|
|
sa.Column('id', sa.Integer, primary_key=True),
|
|
sa.Column('workspace_uuid', sa.String(36), nullable=False),
|
|
)
|
|
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
|
|
manager.db = SimpleNamespace(get_engine=lambda: engine)
|
|
try:
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(table.metadata.create_all)
|
|
|
|
async with manager.tenant_uow('workspace-a'):
|
|
|
|
async def detached_write() -> None:
|
|
# Before the detached boundary this access raises
|
|
# CrossScopeTransactionError because asyncio copies the
|
|
# parent's ActiveScopedTransaction into this child task.
|
|
assert manager.current_scope() is None
|
|
async with manager.tenant_uow('workspace-a'):
|
|
await manager.execute_async(sa.insert(table).values(id=2, workspace_uuid='workspace-a'))
|
|
raise RuntimeError('roll back detached write')
|
|
|
|
task = create_detached_task(detached_write())
|
|
with pytest.raises(RuntimeError, match='roll back detached write'):
|
|
await task
|
|
|
|
await manager.execute_async(sa.insert(table).values(id=1, workspace_uuid='workspace-a'))
|
|
|
|
async with engine.connect() as conn:
|
|
assert (await conn.execute(sa.select(table.c.id))).scalars().all() == [1]
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
async def test_after_commit_task_waits_for_commit_and_starts_with_empty_context(tmp_path) -> None:
|
|
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "after-commit.db"}')
|
|
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
|
|
manager.db = SimpleNamespace(get_engine=lambda: engine)
|
|
request_value = contextvars.ContextVar('after_commit_request_value', default=None)
|
|
observed = []
|
|
try:
|
|
async with manager.tenant_uow('workspace-a'):
|
|
token = request_value.set('request-scope')
|
|
|
|
async def after_commit_work() -> None:
|
|
observed.append((request_value.get(), manager.current_scope()))
|
|
|
|
try:
|
|
task = create_detached_task(
|
|
after_commit_work(),
|
|
after_commit_manager=manager,
|
|
)
|
|
await asyncio.sleep(0)
|
|
assert observed == []
|
|
finally:
|
|
request_value.reset(token)
|
|
|
|
await task
|
|
assert observed == [(None, None)]
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
async def test_after_commit_task_is_cancelled_and_coroutine_closed_on_rollback(tmp_path) -> None:
|
|
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "after-rollback.db"}')
|
|
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
|
|
manager.db = SimpleNamespace(get_engine=lambda: engine)
|
|
started = False
|
|
|
|
async def should_not_start() -> None:
|
|
nonlocal started
|
|
started = True
|
|
|
|
coro = should_not_start()
|
|
try:
|
|
with pytest.raises(RuntimeError, match='rollback request'):
|
|
async with manager.tenant_uow('workspace-a'):
|
|
task = create_detached_task(coro, after_commit_manager=manager)
|
|
await asyncio.sleep(0)
|
|
assert not task.done()
|
|
raise RuntimeError('rollback request')
|
|
|
|
await asyncio.sleep(0)
|
|
assert task.cancelled()
|
|
assert not started
|
|
assert coro.cr_frame is None
|
|
finally:
|
|
await engine.dispose()
|