Files
LangBot/tests/unit_tests/persistence/test_session_summary_contract.py
T
BiFangKNT 810d053df9 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.
2026-10-20 06:12:00 +08:00

145 lines
6.6 KiB
Python

"""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()