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:
BiFangKNT
2026-10-20 06:12:00 +08:00
parent 62b647761f
commit 810d053df9
33 changed files with 4227 additions and 466 deletions
+103
View File
@@ -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))
+28
View File
@@ -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)
+14
View File
@@ -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',