mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-23 08:55:44 +08:00
fix(monitoring): persist session summaries to avoid history scans
- Maintain counts and display snapshots in source transactions with replay-safe deduplication. - Read session lists exclusively from summaries and remove execution-only filters. - Automatically initialize historical summaries in bounded, resumable batches during OSS startup. - Add behavioral contracts covering accounting, retention, query cost and PostgreSQL concurrency. Verified with the required session_summary suite and real container upgrade startup.
This commit is contained in:
@@ -300,6 +300,17 @@ class TestMonitoringSessionsEndpoint:
|
||||
assert kwargs['limit'] == 20
|
||||
assert kwargs['offset'] == 40
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.session_summary
|
||||
@pytest.mark.parametrize('query', ['mode=real', 'mode=debug', 'status=failed'])
|
||||
async def test_session_list_rejects_execution_filters(self, quart_test_client, fake_monitoring_app, query):
|
||||
response = await quart_test_client.get(
|
||||
'/api/v1/monitoring/sessions?' + query,
|
||||
headers={'Authorization': 'Bearer test_token'},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
fake_monitoring_app.monitoring_service.get_sessions.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||
class TestMonitoringErrorsEndpoint:
|
||||
@@ -364,6 +375,98 @@ class TestMonitoringAllDataEndpoint:
|
||||
'errors': 2,
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.session_summary
|
||||
@pytest.mark.contract('reader_http')
|
||||
@pytest.mark.parametrize(
|
||||
'query,mode,statuses',
|
||||
[
|
||||
('/data?mode=all', 'all', None),
|
||||
('/data?mode=real&status=failed', 'real', ['failed']),
|
||||
('/data?mode=debug&status=running', 'debug', ['running']),
|
||||
('/data?status=completed&status=failed', 'all', ['completed', 'failed']),
|
||||
('/sessions?mode=all&userQuery=Alice', None, None),
|
||||
],
|
||||
)
|
||||
async def test_http_session_queries_only_read_summaries(
|
||||
self,
|
||||
quart_test_client,
|
||||
fake_monitoring_app,
|
||||
monkeypatch,
|
||||
query,
|
||||
mode,
|
||||
statuses,
|
||||
):
|
||||
"""Other /data records may read history; its session query may not."""
|
||||
import datetime
|
||||
import sqlite3
|
||||
from contextlib import asynccontextmanager
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from langbot.pkg.api.http.service.monitoring import MonitoringService
|
||||
from langbot.pkg.entity.persistence.monitoring import MonitoringSession
|
||||
from langbot.pkg.persistence.session_summary_reader import SqlSessionSummaryReader
|
||||
|
||||
engine = create_async_engine('sqlite+aiosqlite:///:memory:')
|
||||
try:
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(MonitoringSession.__table__.create)
|
||||
await connection.execute(
|
||||
sa.insert(MonitoringSession).values(
|
||||
workspace_uuid='00000000-0000-0000-0000-00000000000a',
|
||||
bot_id='bot-a',
|
||||
session_id='summary',
|
||||
bot_name='Bot',
|
||||
pipeline_id='pipeline-a',
|
||||
pipeline_name='Pipeline',
|
||||
message_count=7,
|
||||
start_time=datetime.datetime(2026, 1, 2),
|
||||
last_activity=datetime.datetime(2026, 1, 2),
|
||||
user_name='Alice',
|
||||
)
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
async def scope(workspace):
|
||||
assert workspace == '00000000-0000-0000-0000-00000000000a'
|
||||
async with AsyncSession(engine) as session:
|
||||
yield session
|
||||
|
||||
service = MonitoringService(fake_monitoring_app)
|
||||
service._session_list = SqlSessionSummaryReader(scope)
|
||||
monkeypatch.setattr(fake_monitoring_app.monitoring_service, 'get_sessions', service.get_sessions)
|
||||
|
||||
def authorize(action, table, column, database, trigger):
|
||||
if action == sqlite3.SQLITE_READ and table != 'monitoring_sessions':
|
||||
return sqlite3.SQLITE_DENY
|
||||
return sqlite3.SQLITE_OK
|
||||
|
||||
async with engine.connect() as connection:
|
||||
await connection.run_sync(
|
||||
lambda sync: sync.connection.dbapi_connection.run_async(
|
||||
lambda raw: raw._execute(raw._conn.set_authorizer, authorize)
|
||||
)
|
||||
)
|
||||
with patch(
|
||||
'langbot.pkg.api.http.controller.groups.monitoring.get_traffic_series',
|
||||
new=AsyncMock(return_value={}),
|
||||
):
|
||||
response = await quart_test_client.get(
|
||||
'/api/v1/monitoring' + query + '&botId=bot-a&pipelineId=pipeline-a'
|
||||
'&startTime=2026-01-01T00:00:00&endTime=2026-01-03T00:00:00',
|
||||
headers={'Authorization': 'Bearer test_token'},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
result = (await response.get_json())['data']
|
||||
assert [row['session_id'] for row in result['sessions']] == ['summary']
|
||||
assert result['sessions'][0]['message_count'] == 7
|
||||
assert (result['totalCount']['sessions'] if mode is not None else result['total']) == 1
|
||||
if mode is not None:
|
||||
kwargs = fake_monitoring_app.monitoring_service.get_messages.await_args.kwargs
|
||||
assert kwargs['mode'] == mode and kwargs['execution_statuses'] == statuses
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||
class TestMonitoringDetailsEndpoints:
|
||||
|
||||
@@ -224,7 +224,7 @@ def _query(context, sender_id):
|
||||
|
||||
|
||||
@pytest.mark.parametrize('user_id', [123456789, -100123456789, 0, None, '', '00123', ' opaque用户 '])
|
||||
@pytest.mark.parametrize('record_type', ['message', 'session', 'feedback'])
|
||||
@pytest.mark.parametrize('record_type', ['message', 'feedback'])
|
||||
async def test_optional_user_ids_round_trip_through_asyncpg(service, user_id, record_type):
|
||||
context = _context(WORKSPACE_A)
|
||||
expected = str(user_id) if isinstance(user_id, int) else user_id
|
||||
@@ -238,8 +238,6 @@ async def test_optional_user_ids_round_trip_through_asyncpg(service, user_id, re
|
||||
)
|
||||
details = await _read(service, 'get_message_details', context, record_id)
|
||||
assert details['message']['user_id'] == expected
|
||||
elif record_type == 'session':
|
||||
await service.record_session_start(context, **RESOURCE, session_id='same-session', user_id=user_id)
|
||||
rows, total = await _read(service, 'get_sessions', context)
|
||||
assert total == 1
|
||||
assert rows[0]['user_id'] == expected
|
||||
@@ -302,7 +300,7 @@ async def test_query_error_persists_error_message_and_linked_log(service, user_i
|
||||
|
||||
|
||||
@pytest.mark.parametrize('user_id', [True, 1.5, b'123', ['123']])
|
||||
@pytest.mark.parametrize('record_type', ['message', 'session', 'feedback'])
|
||||
@pytest.mark.parametrize('record_type', ['message', 'feedback'])
|
||||
async def test_unsupported_user_ids_fail_at_the_write_boundary(service, user_id, record_type):
|
||||
context = _context(WORKSPACE_A)
|
||||
with pytest.raises(TypeError, match='user_id must be a string, integer, or None'):
|
||||
@@ -314,8 +312,6 @@ async def test_unsupported_user_ids_fail_at_the_write_boundary(service, user_id,
|
||||
session_id='session',
|
||||
user_id=user_id,
|
||||
)
|
||||
elif record_type == 'session':
|
||||
await service.record_session_start(context, **RESOURCE, session_id='session', user_id=user_id)
|
||||
else:
|
||||
await service.record_feedback(context, feedback_id='feedback', feedback_type=1, user_id=user_id)
|
||||
async with service.ap.persistence_mgr.tenant_scope(WORKSPACE_A):
|
||||
@@ -326,7 +322,6 @@ async def test_unsupported_user_ids_fail_at_the_write_boundary(service, user_id,
|
||||
|
||||
async def test_session_analysis_aggregates_under_cloud_sql_guard(service):
|
||||
context = _context(WORKSPACE_A)
|
||||
await service.record_session_start(context, **RESOURCE, session_id='same-session')
|
||||
await service.record_message(context, **RESOURCE, session_id='same-session', message_content='hello')
|
||||
result = await _read(service, 'get_session_analysis', context, 'same-session')
|
||||
assert result['found'] is True
|
||||
|
||||
@@ -107,7 +107,10 @@ async def test_legacy_sqlite_resources_are_backfilled_and_contracted(tmp_path):
|
||||
)
|
||||
assert (
|
||||
await conn.scalar(
|
||||
sa.text('SELECT COUNT(*) FROM workspace_metadata WHERE workspace_uuid = :workspace_uuid'),
|
||||
sa.text(
|
||||
'SELECT COUNT(*) FROM workspace_metadata WHERE workspace_uuid = :workspace_uuid '
|
||||
"AND key IN ('wizard_status', 'wizard_progress', 'rag_plugin_migration_needed')"
|
||||
),
|
||||
{'workspace_uuid': workspace_uuid},
|
||||
)
|
||||
== 3
|
||||
@@ -296,7 +299,15 @@ async def test_fresh_sqlite_schema_matches_resource_tenancy_contract(tmp_path):
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "fresh-resources.db"}')
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
# Match PersistenceManager: 0040 creates the receipt FK after older PK migrations.
|
||||
await conn.run_sync(
|
||||
lambda sync: Base.metadata.create_all(
|
||||
sync,
|
||||
tables=[
|
||||
table for table in Base.metadata.sorted_tables if table.name != 'monitoring_session_receipts'
|
||||
],
|
||||
)
|
||||
)
|
||||
await run_alembic_stamp(engine, '0001_baseline')
|
||||
await run_alembic_upgrade(engine, 'head')
|
||||
|
||||
@@ -312,3 +323,76 @@ async def test_fresh_sqlite_schema_matches_resource_tenancy_contract(tmp_path):
|
||||
assert columns['legacy_vector_collection']['nullable'] is False
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.session_summary
|
||||
@pytest.mark.contract('summary_schema')
|
||||
@pytest.mark.parametrize('has_history', [False, True])
|
||||
async def test_legacy_sqlite_without_monitoring_tables_initializes_summaries(tmp_path, monkeypatch, has_history):
|
||||
import datetime
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.api.http.service.monitoring import MonitoringService
|
||||
from langbot.pkg.entity.persistence.event_log import EventLog
|
||||
from langbot.pkg.persistence.mgr import PersistenceManager
|
||||
from langbot.pkg.persistence.session_summary_backfill import SessionSummaryBackfill, require_initialized
|
||||
from langbot.pkg.utils import constants
|
||||
|
||||
instance = str(uuid.uuid4())
|
||||
monkeypatch.setattr(constants, 'instance_id', instance)
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "legacy-without-monitoring.db"}')
|
||||
try:
|
||||
await create_legacy_resource_schema(engine, instance_uuid=instance)
|
||||
async with engine.begin() as conn:
|
||||
for name in TENANT_TABLES:
|
||||
if name.startswith('monitoring_'):
|
||||
await conn.exec_driver_sql(f'DROP TABLE {name}')
|
||||
if has_history:
|
||||
await conn.run_sync(EventLog.__table__.create)
|
||||
await conn.execute(
|
||||
sa.insert(EventLog).values(
|
||||
event_id='legacy-event',
|
||||
event_type='message.received',
|
||||
source='platform',
|
||||
workspace_id=instance,
|
||||
bot_id='bot-1',
|
||||
conversation_id='group_legacy',
|
||||
created_at=datetime.datetime(2026, 1, 1),
|
||||
)
|
||||
)
|
||||
await run_alembic_stamp(engine, '0008_mcp_resource_prefs')
|
||||
ap = SimpleNamespace(logger=logging.getLogger(__name__))
|
||||
ap.persistence_mgr = PersistenceManager(ap)
|
||||
ap.persistence_mgr.db = SimpleNamespace(get_engine=lambda: engine)
|
||||
await ap.persistence_mgr._initialize_managed_schema()
|
||||
await ap.persistence_mgr._enable_sqlite_foreign_keys_after_migration()
|
||||
tables = await _inspect(engine, lambda conn: set(sa.inspect(conn).get_table_names()))
|
||||
assert {'monitoring_sessions', 'monitoring_session_receipts'} <= tables
|
||||
service = MonitoringService(ap)
|
||||
if has_history:
|
||||
with pytest.raises(RuntimeError, match='offline initialization'):
|
||||
async with service._summary_session(instance) as session:
|
||||
await require_initialized(session, instance)
|
||||
worker = SessionSummaryBackfill(service._summary_session, 'sqlite')
|
||||
while not await worker.run_batch(instance):
|
||||
pass
|
||||
ctx = ExecutionContext(instance_uuid=instance, workspace_uuid=instance, placement_generation=1)
|
||||
await service.record_message(
|
||||
ctx,
|
||||
bot_id='bot-1',
|
||||
bot_name='Bot',
|
||||
pipeline_id='pipeline-1',
|
||||
pipeline_name='Pipeline',
|
||||
session_id='group_live',
|
||||
message_content='hello',
|
||||
)
|
||||
await ap.persistence_mgr._initialize_managed_schema()
|
||||
rows, total = await service.get_sessions(ctx)
|
||||
assert total == 1 + int(has_history)
|
||||
assert all(row['message_count'] == 1 for row in rows)
|
||||
async with engine.connect() as conn:
|
||||
assert (await conn.exec_driver_sql('PRAGMA foreign_key_check')).all() == []
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
@@ -78,7 +78,8 @@ class TestEventLogStore:
|
||||
store = EventLogStore(mock_db_engine)
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.add = MagicMock()
|
||||
mock_db_engine.dialect.name = 'sqlite'
|
||||
mock_session.execute.return_value = MagicMock(rowcount=1)
|
||||
mock_session.commit = AsyncMock()
|
||||
|
||||
with patch.object(store, '_session_factory') as mock_factory:
|
||||
@@ -98,8 +99,8 @@ class TestEventLogStore:
|
||||
)
|
||||
|
||||
assert event_id == 'evt_1'
|
||||
stored_event = mock_session.add.call_args.args[0]
|
||||
assert stored_event.metadata_json is None
|
||||
stored_event = mock_session.execute.call_args.args[0].compile().params
|
||||
assert stored_event['metadata_json'] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_event_stores_metadata_json(self, mock_db_engine):
|
||||
@@ -109,7 +110,8 @@ class TestEventLogStore:
|
||||
store = EventLogStore(mock_db_engine)
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.add = MagicMock()
|
||||
mock_db_engine.dialect.name = 'sqlite'
|
||||
mock_session.execute.return_value = MagicMock(rowcount=1)
|
||||
mock_session.commit = AsyncMock()
|
||||
|
||||
with patch.object(store, '_session_factory') as mock_factory:
|
||||
@@ -130,9 +132,9 @@ class TestEventLogStore:
|
||||
)
|
||||
|
||||
assert event_id == 'evt_steering'
|
||||
stored_event = mock_session.add.call_args.args[0]
|
||||
assert '"status": "queued"' in stored_event.metadata_json
|
||||
assert '"claimed_by_run_id": "run_1"' in stored_event.metadata_json
|
||||
stored_event = mock_session.execute.call_args.args[0].compile().params
|
||||
assert '"status": "queued"' in stored_event['metadata_json']
|
||||
assert '"claimed_by_run_id": "run_1"' in stored_event['metadata_json']
|
||||
|
||||
|
||||
class TestTranscriptStore:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import contextvars
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
@@ -35,6 +36,12 @@ def _context(workspace_uuid: str) -> ExecutionContext:
|
||||
class _PersistenceManager:
|
||||
def __init__(self, engine):
|
||||
self.engine = engine
|
||||
self.active_transaction = contextvars.ContextVar('monitoring-test-transaction', default=None)
|
||||
|
||||
def tenant_uow(self, workspace):
|
||||
from langbot.pkg.persistence.tenant_uow import TenantUnitOfWork
|
||||
|
||||
return TenantUnitOfWork(self.engine, workspace, active_transaction=self.active_transaction)
|
||||
|
||||
async def execute_async(self, *args, **kwargs):
|
||||
async with self.engine.connect() as connection:
|
||||
@@ -112,22 +119,6 @@ async def test_same_session_and_resource_ids_do_not_collide(service):
|
||||
context_b = _context(WORKSPACE_B)
|
||||
message_a = await _record_message(service, context_a, 'tenant-a')
|
||||
message_b = await _record_message(service, context_b, 'tenant-b')
|
||||
await service.record_session_start(
|
||||
context_a,
|
||||
session_id='same-session',
|
||||
bot_id='same-bot',
|
||||
bot_name='Same Bot',
|
||||
pipeline_id='same-pipeline',
|
||||
pipeline_name='Same Pipeline',
|
||||
)
|
||||
await service.record_session_start(
|
||||
context_b,
|
||||
session_id='same-session',
|
||||
bot_id='same-bot',
|
||||
bot_name='Same Bot',
|
||||
pipeline_id='same-pipeline',
|
||||
pipeline_name='Same Pipeline',
|
||||
)
|
||||
|
||||
messages_a, total_a = await service.get_messages(context_a)
|
||||
messages_b, total_b = await service.get_messages(context_b)
|
||||
@@ -149,8 +140,9 @@ async def test_session_search_matches_user_id_or_name_within_workspace(service,
|
||||
(context_b, 'session-other-workspace', 'customer-42', 'Alice'),
|
||||
]
|
||||
for context, session_id, user_id, user_name in fixtures:
|
||||
await service.record_session_start(
|
||||
await service.record_message(
|
||||
context,
|
||||
message_content='hello',
|
||||
session_id=session_id,
|
||||
bot_id='same-bot',
|
||||
bot_name='Same Bot',
|
||||
@@ -221,14 +213,6 @@ async def test_monitoring_queries_and_detail_views_are_strictly_bounded(service)
|
||||
'max_offset': 10,
|
||||
}
|
||||
}
|
||||
await service.record_session_start(
|
||||
context,
|
||||
session_id='same-session',
|
||||
bot_id='same-bot',
|
||||
bot_name='Same Bot',
|
||||
pipeline_id='same-pipeline',
|
||||
pipeline_name='Same Pipeline',
|
||||
)
|
||||
message_ids = [await _record_message(service, context, f'message-{index}') for index in range(4)]
|
||||
for index in range(3):
|
||||
await service.record_llm_call(
|
||||
@@ -430,14 +414,26 @@ async def test_cloud_cleanup_uses_plan_retention_and_isolates_workspaces(service
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
service.ap.deployment = SimpleNamespace(mode='cloud')
|
||||
service.ap.entitlement_resolver = SimpleNamespace(resolve=AsyncMock(return_value=SimpleNamespace(limits={'chat_history.retention_days': 7})))
|
||||
service.ap.entitlement_resolver = SimpleNamespace(
|
||||
resolve=AsyncMock(return_value=SimpleNamespace(limits={'chat_history.retention_days': 7}))
|
||||
)
|
||||
old = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) - datetime.timedelta(days=10)
|
||||
for workspace in (WORKSPACE_A, WORKSPACE_B):
|
||||
await service.ap.persistence_mgr.execute_async(sqlalchemy.insert(MonitoringMessage).values(
|
||||
id=f'policy-{workspace}', workspace_uuid=workspace, timestamp=old,
|
||||
bot_id='bot', bot_name='Bot', pipeline_id='pipeline', pipeline_name='Pipeline',
|
||||
message_content='history', session_id='session', status='success', level='info',
|
||||
))
|
||||
await service.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(MonitoringMessage).values(
|
||||
id=f'policy-{workspace}',
|
||||
workspace_uuid=workspace,
|
||||
timestamp=old,
|
||||
bot_id='bot',
|
||||
bot_name='Bot',
|
||||
pipeline_id='pipeline',
|
||||
pipeline_name='Pipeline',
|
||||
message_content='history',
|
||||
session_id='session',
|
||||
status='success',
|
||||
level='info',
|
||||
)
|
||||
)
|
||||
deleted = await service.cleanup_expired_records(_context(WORKSPACE_A), retention_days=90)
|
||||
assert deleted['monitoring_messages'] == 1
|
||||
rows = await service.ap.persistence_mgr.execute_async(sqlalchemy.select(MonitoringMessage.workspace_uuid))
|
||||
|
||||
@@ -4,7 +4,9 @@ from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from mcp.server.fastmcp.exceptions import ToolError
|
||||
|
||||
from langbot.pkg.api.mcp import server as mcp_server
|
||||
from langbot.pkg.api.mcp.server import LangBotMCPServer
|
||||
|
||||
|
||||
@@ -77,3 +79,29 @@ async def test_mcp_server_exposes_bot_event_route_tools():
|
||||
'th_TH',
|
||||
'vi_VN',
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
'filters', [{}, {'bot_ids': ['bot-1'], 'user_query': 'Alice'}, {'mode': 'real'}, {'execution_statuses': ['failed']}]
|
||||
)
|
||||
async def test_monitoring_sessions_accept_only_conversation_filters(monkeypatch, filters):
|
||||
app = _make_app()
|
||||
app.monitoring_service = SimpleNamespace(
|
||||
normalize_page_window=lambda limit, offset: (limit, offset),
|
||||
get_sessions=AsyncMock(return_value=([], 0)),
|
||||
)
|
||||
context = SimpleNamespace(workspace_uuid='workspace-1')
|
||||
monkeypatch.setattr(mcp_server, 'get_request_context', lambda: context)
|
||||
monkeypatch.setattr(mcp_server, '_authorized', lambda permission: context)
|
||||
monkeypatch.setattr(mcp_server, 'get_workspace_language', AsyncMock(return_value='en_US'))
|
||||
server = LangBotMCPServer(app)
|
||||
|
||||
arguments = {'kind': 'sessions', 'filters': filters}
|
||||
if set(filters) & {'mode', 'execution_statuses'}:
|
||||
with pytest.raises(ToolError, match='Unsupported monitoring filter'):
|
||||
await server.mcp.call_tool('get_monitoring_records', arguments)
|
||||
app.monitoring_service.get_sessions.assert_not_awaited()
|
||||
else:
|
||||
await server.mcp.call_tool('get_monitoring_records', arguments)
|
||||
app.monitoring_service.get_sessions.assert_awaited_once_with(context, limit=100, offset=0, **filters)
|
||||
|
||||
@@ -32,11 +32,33 @@ async def _wait_forever() -> None:
|
||||
await asyncio.Event().wait()
|
||||
|
||||
|
||||
@pytest.mark.session_summary
|
||||
@pytest.mark.contract('summary_backfill')
|
||||
async def test_failed_summary_initialization_prevents_producer_startup(monkeypatch):
|
||||
async def failed(_ap):
|
||||
raise RuntimeError('summary initialization failed')
|
||||
|
||||
monkeypatch.setattr('langbot.pkg.persistence.session_summary_backfill.initialize_application_summaries', failed)
|
||||
application = Application()
|
||||
started = []
|
||||
application.event_loop_monitor = SimpleNamespace(start=lambda: started.append(True))
|
||||
with pytest.raises(RuntimeError, match='summary initialization failed'):
|
||||
await application.run()
|
||||
assert started == []
|
||||
|
||||
|
||||
async def test_resource_maintenance_waits_and_shares_workspace_discovery(monkeypatch) -> None:
|
||||
async def idle_leases(_ap):
|
||||
await _wait_forever()
|
||||
|
||||
monkeypatch.setattr('langbot.pkg.utils.execution_lease.maintain_execution_leases', idle_leases)
|
||||
|
||||
async def initialized(_ap):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
'langbot.pkg.persistence.session_summary_backfill.initialize_application_summaries', initialized
|
||||
)
|
||||
stop = asyncio.Event()
|
||||
completed = asyncio.Event()
|
||||
discovery_calls = 0
|
||||
@@ -67,7 +89,7 @@ async def test_resource_maintenance_waits_and_shares_workspace_discovery(monkeyp
|
||||
application.event_loop = asyncio.get_running_loop()
|
||||
application.event_loop_monitor = SimpleNamespace(start=lambda: None)
|
||||
application.task_mgr = _TaskManager(stop)
|
||||
application.plugin_connector = SimpleNamespace(initialize_plugins=lambda: asyncio.sleep(0))
|
||||
application.plugin_connector = SimpleNamespace(initialize=lambda: asyncio.sleep(0), schedule_reconnect=lambda: None)
|
||||
application.platform_mgr = SimpleNamespace(run=_wait_forever)
|
||||
application.ctrl = SimpleNamespace(run=_wait_forever)
|
||||
application.http_ctrl = SimpleNamespace(run=_wait_forever)
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Enforce the read boundary and the schema needed for bounded repair."""
|
||||
|
||||
import ast
|
||||
import datetime
|
||||
import importlib
|
||||
import importlib.util
|
||||
import inspect
|
||||
|
||||
import pytest
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
|
||||
from langbot.pkg.entity.persistence.base import Base
|
||||
from langbot.pkg.entity.persistence import monitoring as monitoring_models
|
||||
from langbot.pkg.entity.persistence.monitoring import MonitoringSession, MonitoringSessionReceipt
|
||||
from langbot.pkg.entity.persistence.workspace import Workspace
|
||||
from langbot.pkg.entity.persistence.metadata import WorkspaceMetadata
|
||||
from langbot.pkg.persistence import session_summary_reader, session_summary_contract
|
||||
from langbot.pkg.persistence.tenant_uow import TENANT_TABLE_COLUMNS
|
||||
|
||||
|
||||
pytestmark = pytest.mark.session_summary
|
||||
|
||||
|
||||
def assert_summary_dependencies(source, package):
|
||||
"""Restrict historical and application capabilities, not utility/type imports."""
|
||||
forbidden = (
|
||||
'langbot.pkg.agent',
|
||||
'langbot.pkg.api',
|
||||
'langbot.pkg.core',
|
||||
'langbot.pkg.pipeline',
|
||||
'langbot.pkg.persistence.mgr',
|
||||
'langbot.pkg.persistence.session_summary_backfill',
|
||||
'langbot.pkg.persistence.session_summary_writer',
|
||||
'langbot.pkg.entity.persistence',
|
||||
)
|
||||
for node in ast.walk(ast.parse(source)):
|
||||
if isinstance(node, ast.ImportFrom):
|
||||
target = importlib.util.resolve_name('.' * node.level + (node.module or ''), package)
|
||||
imports = [target + '.' + alias.name for alias in node.names]
|
||||
if target == monitoring_models.__name__:
|
||||
for alias in node.names:
|
||||
model = getattr(monitoring_models, alias.name, None)
|
||||
table = getattr(model, '__table__', None)
|
||||
assert alias.name != '*' and (table is None or table.name == 'monitoring_sessions')
|
||||
continue
|
||||
elif isinstance(node, ast.Import):
|
||||
imports = [alias.name for alias in node.names]
|
||||
else:
|
||||
continue
|
||||
for target in imports:
|
||||
assert not any(target == prefix or target.startswith(prefix + '.') for prefix in forbidden), target
|
||||
assert target != monitoring_models.__name__, 'Import the summary model without the history namespace'
|
||||
|
||||
|
||||
@pytest.mark.contract('reader_dependencies')
|
||||
def test_list_modules_have_no_history_or_application_capabilities():
|
||||
for module in (session_summary_contract, session_summary_reader):
|
||||
assert_summary_dependencies(inspect.getsource(module), module.__package__)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'extra_import', ['from typing import Any', 'import functools', 'from datetime import timezone']
|
||||
)
|
||||
def test_summary_dependency_check_allows_utility_and_type_imports(extra_import):
|
||||
assert_summary_dependencies(extra_import, session_summary_reader.__package__)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'extra_import',
|
||||
[
|
||||
'from ..entity.persistence.monitoring import MonitoringMessage',
|
||||
'from .session_summary_backfill import SessionSummaryBackfill',
|
||||
'from ..entity.persistence import event_log',
|
||||
'from ..entity.persistence.agent_run import AgentRun',
|
||||
],
|
||||
)
|
||||
def test_summary_dependency_check_rejects_history_and_repair(extra_import):
|
||||
with pytest.raises(AssertionError):
|
||||
assert_summary_dependencies(extra_import, session_summary_reader.__package__)
|
||||
|
||||
|
||||
@pytest.mark.contract('summary_schema')
|
||||
def test_summary_migration_is_idempotent_and_preserves_legacy_counts():
|
||||
migration = importlib.import_module('langbot.pkg.persistence.alembic.versions.0040_session_summaries')
|
||||
engine = sa.create_engine('sqlite:///:memory:')
|
||||
|
||||
@sa.event.listens_for(engine, 'connect')
|
||||
def foreign_keys(connection, _record):
|
||||
connection.execute('PRAGMA foreign_keys=ON')
|
||||
|
||||
try:
|
||||
with engine.begin() as connection:
|
||||
Base.metadata.create_all(connection)
|
||||
with Operations.context(MigrationContext.configure(connection)):
|
||||
migration.downgrade()
|
||||
connection.execute(sa.insert(Workspace).values(uuid='w', instance_uuid='i', name='W', slug='w'))
|
||||
connection.execute(
|
||||
sa.text(
|
||||
'INSERT INTO monitoring_sessions (workspace_uuid,bot_id,session_id,bot_name,pipeline_id,pipeline_name,'
|
||||
'message_count,start_time,last_activity,is_active,user_id,user_name,platform) '
|
||||
"VALUES ('w','b','s','Bot','legacy','Legacy Pipeline',99,:now,:now,1,'legacy-user','Legacy User','legacy')"
|
||||
),
|
||||
{'now': datetime.datetime(2026, 1, 1)},
|
||||
)
|
||||
with Operations.context(MigrationContext.configure(connection)):
|
||||
migration.upgrade()
|
||||
migration.upgrade()
|
||||
row = connection.execute(sa.select(MonitoringSession)).mappings().one()
|
||||
assert row['message_count'] == 99 and row['source_count'] == 0
|
||||
assert row['metadata_role'] is None and row['metadata_timestamp'] is None
|
||||
assert row['metadata_source_key'] is None and row['base_snapshot'] is None
|
||||
assert (row['user_id'], row['user_name'], row['platform']) == ('legacy-user', 'Legacy User', 'legacy')
|
||||
assert (row['pipeline_id'], row['pipeline_name']) == ('legacy', 'Legacy Pipeline')
|
||||
assert connection.execute(sa.select(WorkspaceMetadata.value)).scalar_one() == '{"source": "messages"}'
|
||||
inspector = sa.inspect(connection)
|
||||
assert inspector.get_pk_constraint('monitoring_session_receipts')['constrained_columns'] == [
|
||||
'workspace_uuid',
|
||||
'bot_id',
|
||||
'source_key',
|
||||
]
|
||||
assert inspector.get_foreign_keys('monitoring_session_receipts')[0]['options']['ondelete'] == 'CASCADE'
|
||||
assert TENANT_TABLE_COLUMNS['monitoring_session_receipts'] == 'workspace_uuid'
|
||||
for table, index, fields in migration.INDEXES:
|
||||
indexes = {item['name']: item['column_names'] for item in inspector.get_indexes(table)}
|
||||
assert indexes[index] == fields
|
||||
connection.execute(
|
||||
sa.insert(MonitoringSessionReceipt).values(
|
||||
workspace_uuid='w',
|
||||
bot_id='b',
|
||||
source_key='event:detached',
|
||||
role='event',
|
||||
covered=True,
|
||||
timestamp=datetime.datetime(2026, 1, 1),
|
||||
)
|
||||
)
|
||||
connection.execute(sa.delete(Workspace).where(Workspace.uuid == 'w'))
|
||||
assert (
|
||||
connection.execute(sa.select(sa.func.count()).select_from(MonitoringSessionReceipt)).scalar_one() == 0
|
||||
)
|
||||
finally:
|
||||
engine.dispose()
|
||||
@@ -0,0 +1,553 @@
|
||||
"""Whole-source metadata, reversible ingress, and bounded candidate selection."""
|
||||
|
||||
import datetime as dt
|
||||
import json
|
||||
import random
|
||||
from collections import defaultdict
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
|
||||
from langbot.pkg.entity.persistence import monitoring as models
|
||||
from langbot.pkg.entity.persistence.event_log import EventLog
|
||||
from langbot.pkg.persistence.session_summary_writer import SessionSummaryWriter
|
||||
from tests.integration.persistence.test_monitoring_postgres import cloud_database as postgres_fixture
|
||||
from tests.unit_tests.api.service.test_monitoring_sessions import (
|
||||
context,
|
||||
rebuild_summaries,
|
||||
replay_summary_message,
|
||||
service as summary_service_fixture,
|
||||
session_counts_from_facts,
|
||||
)
|
||||
|
||||
|
||||
pytestmark = [pytest.mark.asyncio, pytest.mark.session_summary]
|
||||
service = summary_service_fixture
|
||||
cloud_database = postgres_fixture
|
||||
FIELDS = ('bot_name', 'platform', 'user_id', 'user_name', 'pipeline_id', 'pipeline_name')
|
||||
|
||||
|
||||
async def ingress(service, identity, user, pipeline, *, bot='a', owner='group'):
|
||||
await service.record_ingress_event(
|
||||
context(bot),
|
||||
event_id=identity,
|
||||
event_type='message.received',
|
||||
bot_id=bot,
|
||||
bot_name=user,
|
||||
platform=user,
|
||||
payload={},
|
||||
conversation_id=owner,
|
||||
actor_id=user,
|
||||
actor_name=user,
|
||||
routes=[{'target_uuid': pipeline, 'target_type': pipeline}] if pipeline else [],
|
||||
)
|
||||
|
||||
|
||||
async def message(service, user, pipeline, *, bot='a', owner='topic', event=None, role='user'):
|
||||
return await service.record_message(
|
||||
context(bot),
|
||||
bot_id=bot,
|
||||
bot_name=user or '',
|
||||
platform=user,
|
||||
pipeline_id=pipeline,
|
||||
pipeline_name=pipeline,
|
||||
user_id=user,
|
||||
user_name=None,
|
||||
session_id=owner,
|
||||
message_content='hello',
|
||||
event_id=event,
|
||||
role=role,
|
||||
)
|
||||
|
||||
|
||||
async def projection(service):
|
||||
return {
|
||||
(row['bot_id'], row['session_id']): (row['message_count'], *(row[field] for field in FIELDS))
|
||||
for row in (await service.get_sessions(context()))[0]
|
||||
}
|
||||
|
||||
|
||||
async def facts(service):
|
||||
"""Calculate independently from retained sources, never receipts or Writer state."""
|
||||
async with service.ap.persistence_mgr.get_db_engine().connect() as connection:
|
||||
messages = (await connection.execute(sa.select(models.MonitoringMessage))).mappings().all()
|
||||
events = (await connection.execute(sa.select(EventLog))).mappings().all()
|
||||
messages = {row['id']: dict(row) for row in messages}
|
||||
ingresses = {}
|
||||
for row in events:
|
||||
metadata = json.loads(row['metadata_json'] or '{}')
|
||||
route = next((route for route in metadata.get('routes', []) if route.get('target_uuid')), {})
|
||||
ingresses[row['event_id']] = dict(
|
||||
bot_id=row['bot_id'],
|
||||
session_id=row['conversation_id'],
|
||||
timestamp=row['event_time'] or row['created_at'],
|
||||
role='user',
|
||||
bot_name=metadata.get('bot_name') or '',
|
||||
platform=metadata.get('platform'),
|
||||
user_id=row['actor_id'],
|
||||
user_name=row['actor_name'],
|
||||
pipeline_id=route.get('target_uuid') or '',
|
||||
pipeline_name=route.get('target_type') or '',
|
||||
)
|
||||
counts = session_counts_from_facts(messages, ingresses)
|
||||
covered = {
|
||||
(row['bot_id'], alias)
|
||||
for identity, row in messages.items()
|
||||
if row['role'] != 'assistant'
|
||||
for alias in (identity, row['event_id'])
|
||||
if alias is not None
|
||||
}
|
||||
sources = defaultdict(list)
|
||||
for identity, row in messages.items():
|
||||
sources[(row['bot_id'], row['session_id'])].append(('message:' + identity, row))
|
||||
for identity, row in ingresses.items():
|
||||
if (row['bot_id'], identity) not in covered:
|
||||
sources[(row['bot_id'], row['session_id'])].append(('event:' + identity, row))
|
||||
expected = {}
|
||||
for key, entries in sources.items():
|
||||
_, selected = max(
|
||||
entries,
|
||||
key=lambda entry: (
|
||||
entry[1]['role'] != 'assistant',
|
||||
entry[1]['timestamp'],
|
||||
entry[0].encode('utf-8'),
|
||||
),
|
||||
)
|
||||
expected[key] = (counts[key], *(selected[field] for field in FIELDS))
|
||||
return expected
|
||||
|
||||
|
||||
@pytest.mark.contract('summary_fact_equivalence')
|
||||
@pytest.mark.contract('summary_snapshot')
|
||||
@pytest.mark.parametrize('transfer', ['message', 'late_link'])
|
||||
@pytest.mark.parametrize('base', ['message', 'event', 'expired_event', 'legacy'])
|
||||
async def test_transfer_restores_the_remaining_whole_snapshot(service, transfer, base):
|
||||
receipt = models.MonitoringSessionReceipt
|
||||
engine = service.ap.persistence_mgr.get_db_engine()
|
||||
if base == 'legacy':
|
||||
async with engine.begin() as connection:
|
||||
await connection.execute(
|
||||
sa.insert(models.MonitoringSession).values(
|
||||
workspace_uuid='workspace',
|
||||
bot_id='a',
|
||||
session_id='group',
|
||||
bot_name='Alice',
|
||||
platform='Alice',
|
||||
user_id='Alice',
|
||||
user_name='Alice',
|
||||
pipeline_id='A',
|
||||
pipeline_name='A',
|
||||
message_count=99,
|
||||
start_time=dt.datetime(2026, 1, 1),
|
||||
last_activity=dt.datetime(2026, 1, 1),
|
||||
)
|
||||
)
|
||||
elif base == 'message':
|
||||
await message(service, 'Alice', 'A', owner='group')
|
||||
else:
|
||||
await ingress(service, 'alice-event', 'Alice', 'A')
|
||||
if base == 'expired_event':
|
||||
assert (
|
||||
await service._delete_expired_in_batches(
|
||||
context(),
|
||||
receipt,
|
||||
receipt.timestamp,
|
||||
receipt.source_key,
|
||||
dt.datetime(2100, 1, 1),
|
||||
1,
|
||||
1,
|
||||
)
|
||||
== 1
|
||||
)
|
||||
baseline = await projection(service)
|
||||
await ingress(service, 'bob-event', 'Bob', 'B')
|
||||
identity = await message(service, 'Bob', 'B', event='bob-event' if transfer == 'message' else None)
|
||||
if transfer == 'late_link':
|
||||
await service.link_execution_message(context(), identity, 'run', 'bob-event')
|
||||
await service.link_execution_message(context(), identity, 'run', 'bob-event')
|
||||
projected = await projection(service)
|
||||
assert projected['a', 'group'] == baseline['a', 'group']
|
||||
assert projected['a', 'topic'][0] == 1
|
||||
if base in ('message', 'event'):
|
||||
assert projected == await facts(service)
|
||||
await rebuild_summaries(service, batch_size=1)
|
||||
assert await projection(service) == projected
|
||||
|
||||
|
||||
@pytest.mark.contract('summary_retention')
|
||||
@pytest.mark.contract('summary_atomicity')
|
||||
@pytest.mark.contract('summary_snapshot')
|
||||
@pytest.mark.parametrize('rollback', [False, True])
|
||||
async def test_expiry_seals_the_selected_candidate_atomically(service, monkeypatch, rollback):
|
||||
await ingress(service, 'alice-event', 'Alice', 'A')
|
||||
before = await projection(service)
|
||||
receipt = models.MonitoringSessionReceipt
|
||||
async with service.ap.persistence_mgr.get_db_engine().begin() as connection:
|
||||
await connection.execute(
|
||||
sa.update(receipt)
|
||||
.where(receipt.source_key == 'event:alice-event')
|
||||
.values(
|
||||
timestamp=dt.datetime(2000, 1, 1),
|
||||
)
|
||||
)
|
||||
if rollback:
|
||||
original = SessionSummaryWriter._merge_base
|
||||
|
||||
async def abort_after_seal(self, *args):
|
||||
await original(self, *args)
|
||||
raise RuntimeError('abort seal')
|
||||
|
||||
with monkeypatch.context() as patch:
|
||||
patch.setattr(SessionSummaryWriter, '_merge_base', abort_after_seal)
|
||||
with pytest.raises(RuntimeError, match='abort seal'):
|
||||
await service.cleanup_expired_records(
|
||||
context(), retention_days=1, batch_size=1, max_batches_per_table=1
|
||||
)
|
||||
assert await projection(service) == before
|
||||
async with service.ap.persistence_mgr.get_db_engine().connect() as connection:
|
||||
assert (await connection.execute(sa.select(sa.func.count()).select_from(receipt))).scalar_one() == 1
|
||||
deleted = await service.cleanup_expired_records(context(), retention_days=1, batch_size=1, max_batches_per_table=1)
|
||||
assert deleted['monitoring_session_receipts'] == 1
|
||||
assert await projection(service) == before
|
||||
await ingress(service, 'bob-event', 'Bob', 'B')
|
||||
identity = await message(service, 'Bob', 'B')
|
||||
await service.link_execution_message(context(), identity, 'run', 'bob-event')
|
||||
assert (await projection(service))['a', 'group'] == before['a', 'group']
|
||||
|
||||
|
||||
@pytest.mark.contract('summary_fact_equivalence')
|
||||
@pytest.mark.contract('summary_snapshot')
|
||||
@pytest.mark.parametrize('seed', [17, 43, 97])
|
||||
async def test_every_randomized_write_replay_and_link_matches_source_facts(service, seed):
|
||||
rng = random.Random(seed)
|
||||
events, messages = {}, []
|
||||
for index in range(45):
|
||||
operation = rng.choice(('ingress', 'ingress', 'message', 'message', 'replay', 'link'))
|
||||
bot = rng.choice(('a', 'b'))
|
||||
if operation == 'ingress':
|
||||
if events and rng.randrange(3) == 0:
|
||||
event = rng.choice(list(events))
|
||||
bot, owner, user, pipeline = events[event]
|
||||
else:
|
||||
event, owner = f'event-{index}', rng.choice(('group', 'topic'))
|
||||
user, pipeline = rng.choice(('Alice', 'Bob', 'Carol')), rng.choice(('A', 'B', '', '__discard__'))
|
||||
events[event] = bot, owner, user, pipeline
|
||||
await ingress(service, event, user, pipeline, bot=bot, owner=owner)
|
||||
elif operation == 'message':
|
||||
event = rng.choice(list(events)) if events and rng.randrange(2) else None
|
||||
if event:
|
||||
bot = events[event][0]
|
||||
identity = await message(
|
||||
service,
|
||||
rng.choice(('Alice', None)),
|
||||
rng.choice(('A', 'B', '', '__discard__')),
|
||||
bot=bot,
|
||||
owner=rng.choice(('group', 'topic')),
|
||||
event=event,
|
||||
role=rng.choice(('user', 'assistant')),
|
||||
)
|
||||
messages.append(identity)
|
||||
elif operation == 'replay' and messages:
|
||||
await replay_summary_message(service, 'workspace', rng.choice(messages))
|
||||
elif operation == 'link' and events and messages:
|
||||
async with service.ap.persistence_mgr.get_db_engine().connect() as connection:
|
||||
unlinked = (
|
||||
await connection.execute(
|
||||
sa.select(models.MonitoringMessage.id, models.MonitoringMessage.bot_id).where(
|
||||
models.MonitoringMessage.event_id.is_(None),
|
||||
models.MonitoringMessage.role != 'assistant',
|
||||
)
|
||||
)
|
||||
).all()
|
||||
if unlinked:
|
||||
identity, bot = rng.choice(unlinked)
|
||||
matching = [key for key, value in events.items() if value[0] == bot]
|
||||
if matching:
|
||||
await service.link_execution_message(context(), identity, 'run', rng.choice(matching))
|
||||
assert await projection(service) == await facts(service), (seed, index, operation)
|
||||
expected = await projection(service)
|
||||
await rebuild_summaries(service, batch_size=1)
|
||||
assert await projection(service) == expected
|
||||
|
||||
|
||||
@pytest.mark.contract('summary_retention')
|
||||
@pytest.mark.contract('summary_snapshot')
|
||||
async def test_receipt_expiry_uses_source_identity_instead_of_message_role(service):
|
||||
await message(service, 'Alice', 'A', owner='group', role='event')
|
||||
before = await projection(service)
|
||||
receipt = models.MonitoringSessionReceipt
|
||||
async with service.ap.persistence_mgr.get_db_engine().begin() as connection:
|
||||
await connection.execute(
|
||||
sa.update(receipt)
|
||||
.where(receipt.source_key.startswith('message:'))
|
||||
.values(timestamp=dt.datetime(2000, 1, 1))
|
||||
)
|
||||
deleted = await service.cleanup_expired_records(context(), 1, batch_size=1, max_batches_per_table=1)
|
||||
assert deleted['monitoring_session_receipts'] == 1
|
||||
assert await projection(service) == before
|
||||
|
||||
|
||||
@pytest.mark.contract('summary_retention')
|
||||
@pytest.mark.contract('summary_snapshot')
|
||||
async def test_candidate_selection_work_does_not_grow_with_ingress_history(service):
|
||||
receipt = models.MonitoringSessionReceipt
|
||||
engine = service.ap.persistence_mgr.get_db_engine()
|
||||
await ingress(service, 'seed', 'Alice', 'A')
|
||||
measurements = []
|
||||
for size in (100, 10000):
|
||||
timestamp = dt.datetime(2026, 1, 1)
|
||||
async with engine.begin() as connection:
|
||||
await connection.execute(sa.delete(receipt))
|
||||
await connection.execute(
|
||||
sa.insert(receipt),
|
||||
[
|
||||
dict(
|
||||
workspace_uuid='workspace',
|
||||
bot_id='a',
|
||||
session_id='group',
|
||||
source_key=f'event:{index:08}',
|
||||
role='event',
|
||||
covered=False,
|
||||
timestamp=timestamp,
|
||||
source_timestamp=timestamp,
|
||||
candidate_snapshot=json.dumps(
|
||||
dict(
|
||||
source_key=f'event:{index:08}',
|
||||
timestamp=timestamp.isoformat(),
|
||||
role='user',
|
||||
bot_name='Alice',
|
||||
platform=None,
|
||||
user_id=str(index),
|
||||
user_name=None,
|
||||
pipeline_id='',
|
||||
pipeline_name='',
|
||||
)
|
||||
),
|
||||
)
|
||||
for index in range(size)
|
||||
],
|
||||
)
|
||||
steps = [0]
|
||||
|
||||
def tick():
|
||||
steps[0] += 1
|
||||
return 0
|
||||
|
||||
async def handler(callback):
|
||||
async with engine.connect() as connection:
|
||||
await connection.run_sync(
|
||||
lambda sync: sync.connection.dbapi_connection.run_async(
|
||||
lambda raw: raw._execute(raw._conn.set_progress_handler, callback, 1),
|
||||
)
|
||||
)
|
||||
|
||||
await handler(tick)
|
||||
try:
|
||||
async with service._summary_session('workspace') as session:
|
||||
await SessionSummaryWriter('sqlite')._refresh(session, 'workspace', 'a', 'group')
|
||||
finally:
|
||||
await handler(None)
|
||||
measurements.append(steps[0])
|
||||
assert (await service.get_sessions(context()))[0][0]['user_id'] == str(size - 1)
|
||||
assert measurements[1] <= measurements[0] + 30, measurements
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope='module')
|
||||
@pytest.mark.postgres
|
||||
@pytest.mark.contract('summary_postgres')
|
||||
@pytest.mark.contract('summary_retention')
|
||||
@pytest.mark.contract('summary_snapshot')
|
||||
async def test_postgres_expiry_and_late_link_restore_the_same_snapshot(cloud_database, monkeypatch):
|
||||
"""Force a late link to wait on a summary locked by ingress expiry."""
|
||||
import asyncio
|
||||
from tests.integration.persistence.test_monitoring_postgres import WORKSPACE_A, _context, _read
|
||||
|
||||
ap, admin = cloud_database
|
||||
monitoring = ap.monitoring_service
|
||||
ctx = _context(WORKSPACE_A)
|
||||
bot = 'snapshot-expiry-race'
|
||||
for user, pipeline in [('Alice', 'A'), ('Bob', 'B')]:
|
||||
await monitoring.record_ingress_event(
|
||||
ctx,
|
||||
event_id=bot + user,
|
||||
event_type='message.received',
|
||||
bot_id=bot,
|
||||
bot_name=user,
|
||||
platform=user,
|
||||
payload={},
|
||||
conversation_id='group',
|
||||
actor_id=user,
|
||||
actor_name=user,
|
||||
routes=[{'target_uuid': pipeline, 'target_type': pipeline}],
|
||||
)
|
||||
identity = await monitoring.record_message(
|
||||
ctx,
|
||||
bot_id=bot,
|
||||
bot_name='Bob',
|
||||
pipeline_id='B',
|
||||
pipeline_name='B',
|
||||
platform='Bob',
|
||||
user_id='Bob',
|
||||
user_name='Bob',
|
||||
session_id='topic',
|
||||
message_content='hello',
|
||||
)
|
||||
receipt = models.MonitoringSessionReceipt
|
||||
async with admin.begin() as connection:
|
||||
await connection.execute(
|
||||
sa.update(receipt)
|
||||
.where(
|
||||
receipt.workspace_uuid == WORKSPACE_A,
|
||||
receipt.bot_id == bot,
|
||||
receipt.source_key == 'event:' + bot + 'Alice',
|
||||
)
|
||||
.values(timestamp=dt.datetime(2000, 1, 1))
|
||||
)
|
||||
sealed, release, attempted = (asyncio.Event() for _ in range(3))
|
||||
original = SessionSummaryWriter._merge_base
|
||||
cover_task = None
|
||||
cover_pid = None
|
||||
|
||||
async def pause_seal(self, session, workspace, bot_id, owner, snapshot):
|
||||
await original(self, session, workspace, bot_id, owner, snapshot)
|
||||
if bot_id == bot and snapshot['source_key'] == 'event:' + bot + 'Alice':
|
||||
sealed.set()
|
||||
await asyncio.wait_for(release.wait(), 5)
|
||||
|
||||
def observe_sql(connection, cursor, sql, parameters, execution, executemany):
|
||||
nonlocal cover_pid
|
||||
statement = getattr(execution.compiled, 'statement', None)
|
||||
if asyncio.current_task() is cover_task and getattr(statement, '_for_update_arg', None) is not None:
|
||||
cover_pid = connection.connection.driver_connection.get_server_pid()
|
||||
attempted.set()
|
||||
|
||||
monkeypatch.setattr(SessionSummaryWriter, '_merge_base', pause_seal)
|
||||
engine = ap.persistence_mgr.get_db_engine()
|
||||
sa.event.listen(engine.sync_engine, 'before_cursor_execute', observe_sql)
|
||||
expiry_task = asyncio.create_task(monitoring.cleanup_expired_records(ctx, 1, batch_size=1, max_batches_per_table=1))
|
||||
try:
|
||||
await asyncio.wait_for(sealed.wait(), 5)
|
||||
cover_task = asyncio.create_task(monitoring.link_execution_message(ctx, identity, 'run', bot + 'Bob'))
|
||||
await asyncio.wait_for(attempted.wait(), 5)
|
||||
async with asyncio.timeout(5):
|
||||
while True:
|
||||
async with admin.connect() as connection:
|
||||
if await connection.scalar(
|
||||
sa.text("SELECT wait_event_type='Lock' FROM pg_stat_activity WHERE pid=:pid"),
|
||||
{'pid': cover_pid},
|
||||
):
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
release.set()
|
||||
deleted, _ = await asyncio.wait_for(asyncio.gather(expiry_task, cover_task), 10)
|
||||
assert deleted['monitoring_session_receipts'] == 1
|
||||
finally:
|
||||
release.set()
|
||||
sa.event.remove(engine.sync_engine, 'before_cursor_execute', observe_sql)
|
||||
tasks = [task for task in (expiry_task, cover_task) if task is not None]
|
||||
for task in tasks:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
rows, _ = await _read(monitoring, 'get_sessions', ctx, bot_ids=[bot])
|
||||
rows = {row['session_id']: row for row in rows}
|
||||
assert rows['group']['message_count'] == rows['topic']['message_count'] == 1
|
||||
assert tuple(rows['group'][field] for field in FIELDS) == ('Alice', 'Alice', 'Alice', 'Alice', 'A', 'A')
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope='module')
|
||||
@pytest.mark.postgres
|
||||
@pytest.mark.contract('summary_postgres')
|
||||
@pytest.mark.contract('summary_snapshot')
|
||||
async def test_postgres_candidate_query_uses_a_bounded_index_scan(cloud_database):
|
||||
"""Explain the Writer's actual candidate query, rather than a test-only query."""
|
||||
ap, admin = cloud_database
|
||||
from tests.integration.persistence.test_monitoring_postgres import WORKSPACE_A, _context
|
||||
|
||||
monitoring = ap.monitoring_service
|
||||
ctx = _context(WORKSPACE_A)
|
||||
bot = 'snapshot-scale'
|
||||
await monitoring.record_ingress_event(
|
||||
ctx,
|
||||
event_id=bot,
|
||||
event_type='message.received',
|
||||
bot_id=bot,
|
||||
bot_name='Bot',
|
||||
platform=None,
|
||||
payload={},
|
||||
conversation_id='group',
|
||||
actor_id='Alice',
|
||||
)
|
||||
receipt = models.MonitoringSessionReceipt
|
||||
captured = []
|
||||
|
||||
def observe_sql(connection, statement, multiparams, params, execution_options):
|
||||
# before_cursor_execute can expose an older statement's bound values
|
||||
# from the compilation cache. Explain this invocation's statement.
|
||||
if getattr(statement, 'is_select', False) and 'candidate_snapshot' in statement.selected_columns:
|
||||
captured.append(statement)
|
||||
|
||||
engine = ap.persistence_mgr.get_db_engine()
|
||||
sa.event.listen(engine.sync_engine, 'before_execute', observe_sql)
|
||||
try:
|
||||
for size in (100, 10000):
|
||||
timestamp = dt.datetime(2026, 1, 1)
|
||||
async with admin.begin() as connection:
|
||||
await connection.execute(
|
||||
sa.delete(receipt).where(receipt.workspace_uuid == WORKSPACE_A, receipt.bot_id == bot)
|
||||
)
|
||||
await connection.execute(
|
||||
sa.insert(receipt),
|
||||
[
|
||||
dict(
|
||||
workspace_uuid=WORKSPACE_A,
|
||||
bot_id=bot,
|
||||
session_id='group',
|
||||
source_key=f'event:{i:08}',
|
||||
role='event',
|
||||
covered=False,
|
||||
timestamp=timestamp,
|
||||
source_timestamp=timestamp,
|
||||
candidate_snapshot=json.dumps(
|
||||
dict(
|
||||
source_key=f'event:{i:08}',
|
||||
timestamp=timestamp.isoformat(),
|
||||
role='user',
|
||||
bot_name='Bot',
|
||||
platform=None,
|
||||
user_id='Alice',
|
||||
user_name=None,
|
||||
pipeline_id='',
|
||||
pipeline_name='',
|
||||
)
|
||||
),
|
||||
)
|
||||
for i in range(size)
|
||||
],
|
||||
)
|
||||
await connection.execute(sa.text('ANALYZE monitoring_session_receipts'))
|
||||
captured.clear()
|
||||
async with monitoring._summary_session(WORKSPACE_A) as session:
|
||||
await monitoring._summary_writer()._refresh(session, WORKSPACE_A, bot, 'group')
|
||||
# The summary read also selects base_snapshot; only receipt candidate
|
||||
# queries have this selected column. A full candidate scan must fail.
|
||||
assert len(captured) == 1
|
||||
sql = str(captured[0].compile(dialect=admin.dialect, compile_kwargs={'literal_binds': True}))
|
||||
async with admin.connect() as connection:
|
||||
explain = (
|
||||
await connection.execute(sa.text('EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ' + sql))
|
||||
).scalar_one()
|
||||
plan = (json.loads(explain) if isinstance(explain, str) else explain)[0]['Plan']
|
||||
|
||||
def walk(node):
|
||||
yield node
|
||||
for child in node.get('Plans', []):
|
||||
yield from walk(child)
|
||||
|
||||
nodes = list(walk(plan))
|
||||
assert any(node.get('Index Name') == 'ix_session_receipts_candidate' for node in nodes), plan
|
||||
assert not any(node['Node Type'] in ('Seq Scan', 'Sort') for node in nodes), plan
|
||||
assert plan['Actual Rows'] == 1
|
||||
assert plan.get('Shared Hit Blocks', 0) + plan.get('Shared Read Blocks', 0) < 25, plan
|
||||
finally:
|
||||
sa.event.remove(engine.sync_engine, 'before_execute', observe_sql)
|
||||
@@ -5,6 +5,20 @@ import pytest
|
||||
|
||||
# These are behavior obligations, independent of file names and parametrization.
|
||||
REQUIRED_CONTRACTS = {
|
||||
'session_summary': {
|
||||
'reader_dependencies',
|
||||
'reader_sql',
|
||||
'reader_http',
|
||||
'reader_scale',
|
||||
'summary_accounting',
|
||||
'summary_fact_equivalence',
|
||||
'summary_snapshot',
|
||||
'summary_atomicity',
|
||||
'summary_backfill',
|
||||
'summary_retention',
|
||||
'summary_postgres',
|
||||
'summary_schema',
|
||||
},
|
||||
'postgres': {
|
||||
'admission_isolation',
|
||||
'rag_identity',
|
||||
|
||||
Reference in New Issue
Block a user