Files
LangBot/tests/integration/persistence/test_pipeline_admission_postgres.py
T

227 lines
9.2 KiB
Python

"""Disposable PostgreSQL only: real row-lock admission, rollback, and FORCE RLS."""
import asyncio
import os
import copy
import pytest
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import create_async_engine
from tests.unit_tests.api.service import test_pipeline_migration as base
from langbot.pkg.agent.runner.interaction_store import InteractionStore, InteractionScopeError
from langbot.pkg.persistence.pipeline_admission import lock_pipeline_admission
from langbot.pkg.entity.persistence.agent_interaction import AgentInteraction
URL = os.environ.get('LANGBOT_ADMISSION_TEST_URL')
pytestmark = [pytest.mark.asyncio, pytest.mark.skipif(not URL, reason='disposable PostgreSQL URL required')]
@pytest.fixture
async def env(tmp_path, monkeypatch):
admin = create_async_engine(URL)
async with admin.begin() as conn:
await conn.execute(sa.text('DROP SCHEMA public CASCADE'))
await conn.execute(sa.text('CREATE SCHEMA public'))
await conn.execute(
sa.text(
'DO $$ BEGIN CREATE ROLE admission_runtime LOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$'
)
)
monkeypatch.setattr(base, 'create_async_engine', lambda *a, **kw: admin)
async for env in base.env.__wrapped__(tmp_path, monkeypatch):
async with admin.begin() as conn:
for table in ['legacy_pipelines', 'pipeline_migration_snapshots']:
await conn.execute(sa.text(f'ALTER TABLE {table} ENABLE ROW LEVEL SECURITY'))
await conn.execute(sa.text(f'ALTER TABLE {table} FORCE ROW LEVEL SECURITY'))
await conn.execute(
sa.text(
f"CREATE POLICY isolation ON {table} USING (workspace_uuid = current_setting('langbot.workspace_uuid', true)) WITH CHECK (workspace_uuid = current_setting('langbot.workspace_uuid', true))"
)
)
await conn.execute(sa.text('GRANT USAGE ON SCHEMA public TO admission_runtime'))
await conn.execute(
sa.text('GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO admission_runtime')
)
await conn.execute(sa.text('GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO admission_runtime'))
runtime = create_async_engine(URL.replace('postgres@', 'admission_runtime@'))
env.pm.db = base.NS(get_engine=lambda: runtime)
env.runtime = runtime
yield env
await runtime.dispose()
async def write(env, workspace=base.WS, pipeline='one', expected=None, check=lambda: True):
return await InteractionStore(env.runtime).create_request(
interaction_id='form',
run_id='run',
binding_id='binding',
runner_id=base.RID,
processor_type='pipeline',
processor_id=pipeline,
workspace_id=workspace,
request={},
delivery_target={},
expected_config=copy.deepcopy(base.SOURCE if expected is None else expected),
authority_check=check,
)
async def blocked(env):
# Observe PostgreSQL's lock waiter, not an arbitrary scheduling sleep.
async with asyncio.timeout(5):
while True:
async with env.engine.connect() as conn:
n = (
await conn.execute(
sa.text(
"SELECT count(*) FROM pg_stat_activity WHERE usename='admission_runtime' AND wait_event_type='Lock'"
)
)
).scalar()
if n:
return
await asyncio.sleep(0)
@pytest.mark.parametrize('outcome', ['commit', 'rollback', 'cancel'])
async def test_real_writer_waits_and_revalidates_after_transaction(env, outcome):
writer = None
try:
async with env.pm.tenant_uow(base.WS) as uow:
await lock_pipeline_admission(uow.session, base.WS, 'one')
writer = asyncio.create_task(write(env))
await blocked(env)
assert not writer.done()
async with env.engine.connect() as conn:
assert not (await conn.execute(sa.select(AgentInteraction))).all()
await uow.session.execute(
sa.update(base.LegacyPipeline)
.where(base.LegacyPipeline.uuid == 'one')
.values(config=base.planner(base.SOURCE)['config'])
)
if outcome == 'rollback':
raise RuntimeError('rollback')
if outcome == 'cancel':
raise asyncio.CancelledError()
except (RuntimeError, asyncio.CancelledError):
assert outcome != 'commit'
if outcome == 'commit':
with pytest.raises(InteractionScopeError):
await writer
else:
assert (await writer)[0]['status'] == 'pending'
async def test_real_writer_first_blocks_migration_and_other_tenant_isolated(env):
await write(env)
async with env.pm.tenant_uow(base.WS) as uow:
await lock_pipeline_admission(uow.session, base.WS, 'one')
assert (
await uow.session.execute(
sa.select(AgentInteraction.status).where(AgentInteraction.workspace_id == base.WS)
)
).scalar_one() == 'pending'
with pytest.raises(InteractionScopeError):
await write(env, workspace=base.OTHER)
async with env.runtime.connect() as conn:
assert not (await conn.execute(sa.select(base.LegacyPipeline))).all()
async with env.pm.tenant_uow(base.OTHER) as uow:
assert (await uow.session.execute(sa.select(base.LegacyPipeline.uuid))).scalars().all() == ['foreign']
async def test_real_migration_commit_activation_and_stale_writer(env):
original = env.svc._activate
async def activate(*args):
with pytest.raises(InteractionScopeError):
await write(env)
return await original(*args)
env.svc._activate = activate
task = await base.execute(env)
assert task.task_context.metadata['results'][0]['state'] == 'migrated'
with pytest.raises(InteractionScopeError):
await write(env)
_, snapshots = await base.rows(env)
assert snapshots[0]['state'] == 'active'
@pytest.mark.parametrize('boundary', ['cas', 'activation'])
async def test_real_no_phantom_between_check_and_publication(env, monkeypatch, boundary):
writer = None
sql = env.pm.execute_async
state = env.svc._interaction_state
entered_activation = False
activate = env.svc._activate
async def activation(*args):
nonlocal entered_activation
entered_activation = True
return await activate(*args)
async def start_waiter():
nonlocal writer
writer = asyncio.create_task(write(env))
await blocked(env)
assert not writer.done()
async def execute_sql(statement, *args, **kwargs):
result = await sql(statement, *args, **kwargs)
if (
boundary == 'cas'
and isinstance(statement, sa.sql.dml.Insert)
and statement.table.name == 'pipeline_migration_snapshots'
):
await start_waiter()
return result
async def interaction_state(*args):
result = await state(*args)
if boundary == 'activation' and entered_activation:
await start_waiter()
return result
monkeypatch.setattr(env.pm, 'execute_async', execute_sql)
monkeypatch.setattr(env.svc, '_activate', activation)
monkeypatch.setattr(env.svc, '_interaction_state', interaction_state)
task = await base.execute(env)
assert task.task_context.metadata['results'][0]['state'] == 'migrated'
with pytest.raises(InteractionScopeError):
await writer
async with env.engine.connect() as conn:
assert not (await conn.execute(sa.select(AgentInteraction))).all()
async def test_real_conversation_revoked_while_waiting(env):
live = True
async with env.pm.tenant_uow(base.WS) as uow:
await lock_pipeline_admission(uow.session, base.WS, 'one')
writer = asyncio.create_task(write(env, check=lambda: live))
await blocked(env)
live = False
with pytest.raises(InteractionScopeError):
await writer
async def test_real_other_tenant_writer_is_not_serialized(env):
async with env.pm.tenant_uow(base.WS) as uow:
await lock_pipeline_admission(uow.session, base.WS, 'one')
record, _ = await asyncio.wait_for(write(env, workspace=base.OTHER, pipeline='foreign'), 3)
assert record['workspace_id'] == base.OTHER
async def test_real_pending_before_commit_prevents_migration(env):
body = await base.selection(env)
await write(env)
with pytest.raises(env.m.MigrationError, match='preview_stale'):
await base.execute(env, body)
configs, snapshots = await base.rows(env)
assert configs['one'] == base.SOURCE and not snapshots
env.ap.pipeline_mgr.publish_pipeline.assert_not_called()
# Run the unchanged cancellation/ambiguous-acknowledgement contracts on real PG.
test_real_cancel_commit_outcome = base.test_cancel_commit_reports_durable_outcome_and_stops_batch
test_real_cancel_activation_retry = base.test_cancel_activation_retry_reconciles_original_snapshot
test_real_unavailable_reconciliation = base.test_unavailable_commit_reconciliation_is_conservative
test_real_cancel_prepare = base.test_cancel_during_prepare_keeps_original_and_stops_batch