mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-21 20:06:06 +00:00
feat(tenancy): harden shared cloud runtime boundaries
This commit is contained in:
@@ -11,9 +11,19 @@ Note: Uses import isolation to break circular import chains.
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from unittest.mock import Mock, MagicMock
|
||||
from contextlib import contextmanager
|
||||
from typing import Generator
|
||||
from unittest.mock import MagicMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_database_manager_registry(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Keep decorator tests from mutating the process-wide manager registry."""
|
||||
from langbot.pkg.persistence import database
|
||||
|
||||
monkeypatch.setattr(database, 'preregistered_managers', list(database.preregistered_managers))
|
||||
|
||||
|
||||
@contextmanager
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
# Persistence manager performs the package's database-manager registration;
|
||||
# importing a concrete manager first would enter the historical app/mgr cycle.
|
||||
from langbot.pkg.persistence import mgr as _persistence_mgr # noqa: F401
|
||||
from langbot.pkg.persistence.databases import postgresql
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_postgresql_manager_parses_explicit_url_without_string_reassembly(monkeypatch) -> None:
|
||||
captured = None
|
||||
sentinel_engine = object()
|
||||
|
||||
def create_engine(url):
|
||||
nonlocal captured
|
||||
captured = url
|
||||
return sentinel_engine
|
||||
|
||||
monkeypatch.setattr(postgresql.sqlalchemy_asyncio, 'create_async_engine', create_engine)
|
||||
ap = SimpleNamespace(
|
||||
instance_config=SimpleNamespace(
|
||||
data={
|
||||
'database': {
|
||||
'postgresql': {
|
||||
'url': 'postgresql://runtime:p%40ss@db.internal:5432/langbot?sslmode=require',
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
manager = postgresql.PostgreSQLDatabaseManager(ap)
|
||||
await manager.initialize()
|
||||
|
||||
assert captured.drivername == 'postgresql+asyncpg'
|
||||
assert captured.password == 'p@ss'
|
||||
assert captured.query['ssl'] == 'require'
|
||||
assert 'sslmode' not in captured.query
|
||||
assert manager.engine is sentinel_engine
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_postgresql_manager_builds_structured_url_with_special_password(monkeypatch) -> None:
|
||||
captured = None
|
||||
|
||||
def create_engine(url):
|
||||
nonlocal captured
|
||||
captured = url
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(postgresql.sqlalchemy_asyncio, 'create_async_engine', create_engine)
|
||||
ap = SimpleNamespace(
|
||||
instance_config=SimpleNamespace(
|
||||
data={
|
||||
'database': {
|
||||
'postgresql': {
|
||||
'host': 'db.internal',
|
||||
'port': 5432,
|
||||
'user': 'runtime',
|
||||
'password': 'p@ss:/?#word',
|
||||
'database': 'langbot',
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
await postgresql.PostgreSQLDatabaseManager(ap).initialize()
|
||||
|
||||
assert captured.password == 'p@ss:/?#word'
|
||||
assert captured.host == 'db.internal'
|
||||
assert captured.database == 'langbot'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_postgresql_manager_rejects_non_postgresql_url_without_echoing_secret() -> None:
|
||||
ap = SimpleNamespace(
|
||||
instance_config=SimpleNamespace(
|
||||
data={'database': {'postgresql': {'url': 'sqlite:///operator-super-secret.db'}}}
|
||||
)
|
||||
)
|
||||
|
||||
manager = postgresql.PostgreSQLDatabaseManager(ap)
|
||||
with pytest.raises(ValueError, match='valid PostgreSQL') as exc_info:
|
||||
await manager.initialize()
|
||||
assert 'operator-super-secret' not in str(exc_info.value)
|
||||
@@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
import sqlalchemy
|
||||
|
||||
from langbot.__main__ import _build_parser
|
||||
from langbot.pkg.persistence import release_migration
|
||||
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
|
||||
|
||||
|
||||
def _cloud_config(*, database_use: str = 'postgresql', runtime_user: str = 'langbot_runtime') -> dict:
|
||||
return {
|
||||
'database': {
|
||||
'use': database_use,
|
||||
'postgresql': {
|
||||
'host': 'runtime-db',
|
||||
'port': 5432,
|
||||
'user': runtime_user,
|
||||
'password': 'runtime-secret',
|
||||
'database': 'langbot',
|
||||
},
|
||||
'cloud_migration': {
|
||||
'operator_dsn_env': 'TEST_LANGBOT_OPERATOR_DSN',
|
||||
},
|
||||
},
|
||||
'vdb': {
|
||||
'use': 'pgvector',
|
||||
'pgvector': {
|
||||
'use_business_database': True,
|
||||
'allowed_dimensions': [384, 1536],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _operator_environ(
|
||||
*,
|
||||
user: str = 'langbot_migrator',
|
||||
database: str = 'langbot',
|
||||
host: str = 'runtime-db',
|
||||
port: int = 5432,
|
||||
) -> dict[str, str]:
|
||||
return {
|
||||
'TEST_LANGBOT_OPERATOR_DSN': (f'postgresql://{user}:operator%40secret@{host}:{port}/{database}?sslmode=require')
|
||||
}
|
||||
|
||||
|
||||
def test_cloud_migration_cli_is_explicit() -> None:
|
||||
args = _build_parser().parse_args(['migrate', '--cloud'])
|
||||
assert args.command == 'migrate'
|
||||
assert args.cloud is True
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
_build_parser().parse_args(['migrate'])
|
||||
assert exc_info.value.code == 2
|
||||
|
||||
|
||||
def test_operator_url_is_separate_and_preserves_escaped_secret() -> None:
|
||||
url = release_migration._operator_database_url(
|
||||
_cloud_config(),
|
||||
environ=_operator_environ(),
|
||||
)
|
||||
|
||||
assert url.drivername == 'postgresql+asyncpg'
|
||||
assert url.username == 'langbot_migrator'
|
||||
assert url.password == 'operator@secret'
|
||||
assert url.host == 'runtime-db'
|
||||
assert url.port == 5432
|
||||
assert url.database == 'langbot'
|
||||
assert url.query['ssl'] == 'require'
|
||||
assert 'sslmode' not in url.query
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('config', 'environ', 'message'),
|
||||
[
|
||||
(_cloud_config(database_use='sqlite'), _operator_environ(), 'SQLite fallback is forbidden'),
|
||||
(_cloud_config(), {}, 'requires the operator DSN'),
|
||||
(_cloud_config(), {'TEST_LANGBOT_OPERATOR_DSN': 'not a secret://operator-password'}, 'DSN is invalid'),
|
||||
(
|
||||
_cloud_config(),
|
||||
{'TEST_LANGBOT_OPERATOR_DSN': 'postgresql://operator:secret@runtime-db:not-a-port/langbot'},
|
||||
'DSN is invalid',
|
||||
),
|
||||
(_cloud_config(), _operator_environ(user='langbot_runtime'), 'distinct operator role'),
|
||||
(_cloud_config(), _operator_environ(database='another_database'), 'configured runtime database'),
|
||||
(_cloud_config(), _operator_environ(host='other-cluster'), 'runtime PostgreSQL endpoint'),
|
||||
(_cloud_config(), _operator_environ(port=6432), 'runtime PostgreSQL endpoint'),
|
||||
],
|
||||
)
|
||||
def test_operator_url_rejects_unsafe_configuration(config: dict, environ: dict[str, str], message: str) -> None:
|
||||
with pytest.raises(release_migration.CloudReleaseMigrationConfigurationError, match=message) as exc_info:
|
||||
release_migration._operator_database_url(config, environ=environ)
|
||||
assert 'operator-password' not in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_release_migration_disposes_operator_engine_on_failure(monkeypatch) -> None:
|
||||
engine = SimpleNamespace(dispose=AsyncMock())
|
||||
manager = SimpleNamespace(
|
||||
db=SimpleNamespace(engine=engine),
|
||||
initialize=AsyncMock(side_effect=RuntimeError('migration failed')),
|
||||
)
|
||||
|
||||
def manager_factory(*args, **kwargs):
|
||||
del args, kwargs
|
||||
return manager
|
||||
|
||||
monkeypatch.setattr(release_migration, 'PersistenceManager', manager_factory)
|
||||
ap = SimpleNamespace(
|
||||
instance_config=SimpleNamespace(data=_cloud_config()),
|
||||
logger=logging.getLogger('release-migration-disposal-test'),
|
||||
persistence_mgr=None,
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match='migration failed'):
|
||||
await release_migration.run_cloud_release_migration(ap, environ=_operator_environ())
|
||||
|
||||
assert ap.persistence_mgr is manager
|
||||
engine.dispose.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_release_mode_rejects_sqlite_before_schema_changes(tmp_path, monkeypatch) -> None:
|
||||
from langbot.pkg.persistence import mgr as persistence_mgr_module
|
||||
from langbot.pkg.persistence.databases.sqlite import SQLiteDatabaseManager
|
||||
|
||||
monkeypatch.setattr(persistence_mgr_module.database, 'preregistered_managers', [SQLiteDatabaseManager])
|
||||
ap = SimpleNamespace(
|
||||
instance_config=SimpleNamespace(
|
||||
data={
|
||||
'database': {
|
||||
'use': 'sqlite',
|
||||
'sqlite': {'path': str(tmp_path / 'must-not-migrate.db')},
|
||||
}
|
||||
}
|
||||
),
|
||||
logger=logging.getLogger('release-migration-sqlite-rejection-test'),
|
||||
)
|
||||
manager = PersistenceManager(ap, mode=PersistenceMode.RELEASE_MIGRATION)
|
||||
with pytest.raises(RuntimeError, match='requires PostgreSQL'):
|
||||
await manager.initialize()
|
||||
await manager.get_db_engine().dispose()
|
||||
|
||||
engine = sqlalchemy.create_engine(f'sqlite:///{tmp_path / "must-not-migrate.db"}')
|
||||
try:
|
||||
assert sqlalchemy.inspect(engine).get_table_names() == []
|
||||
finally:
|
||||
engine.dispose()
|
||||
@@ -1,11 +1,23 @@
|
||||
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 TenantUnitOfWork
|
||||
from langbot.pkg.persistence.tenant_uow import (
|
||||
CrossScopeTransactionError,
|
||||
PersistenceScopeKind,
|
||||
TenantScopeRequiredError,
|
||||
TenantUnitOfWork,
|
||||
TransactionRollbackOnlyError,
|
||||
)
|
||||
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
@@ -61,3 +73,340 @@ def test_persistence_mode_must_be_a_trusted_enum() -> None:
|
||||
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user