From ed06bc2bfceee1381ab93e25c030ee94ddbd4400 Mon Sep 17 00:00:00 2001 From: Huarch Date: Thu, 24 Sep 2026 09:49:01 +0800 Subject: [PATCH] fix(workbench): configure psycopg snapshot transaction Open the psycopg transaction without unsupported SQLAlchemy-style options, then set the PostgreSQL isolation and read-only characteristics before the first query. Cover the exact snapshot endpoint path with a regression test. --- app/api/v1/endpoints/workbench_events.py | 5 +- tests/unit/test_workbench_events.py | 72 ++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/app/api/v1/endpoints/workbench_events.py b/app/api/v1/endpoints/workbench_events.py index ee4f44b..23755f4 100644 --- a/app/api/v1/endpoints/workbench_events.py +++ b/app/api/v1/endpoints/workbench_events.py @@ -44,7 +44,10 @@ async def workbench_sync_snapshot( ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)), conn: AsyncConnection = Depends(get_project_pg_connection), ): - async with conn.transaction(isolation_level="repeatable read", read_only=True): + async with conn.transaction(): + await conn.execute( + "SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY" + ) cursor = await WorkbenchEventRepository(conn, ctx.project_id).current_cursor() runtime = await workbench_runtime(ctx=ctx, conn=conn) tasks, _ = await _service(ctx, conn).list( diff --git a/tests/unit/test_workbench_events.py b/tests/unit/test_workbench_events.py index a7da870..ffb402b 100644 --- a/tests/unit/test_workbench_events.py +++ b/tests/unit/test_workbench_events.py @@ -1,4 +1,5 @@ from contextlib import asynccontextmanager +from datetime import datetime, timezone from types import SimpleNamespace from uuid import uuid4 @@ -43,6 +44,77 @@ class FakeMetadataRepository: return self.routings +class FakeSnapshotConnection: + def __init__(self): + self.statements = [] + + @asynccontextmanager + async def transaction(self): + yield + + async def execute(self, statement): + self.statements.append(statement) + + +class FakeEventRepository: + def __init__(self, _conn, _project_id): + pass + + async def current_cursor(self): + return 12 + + +class FakeOperationalTaskService: + async def list(self, **_kwargs): + return [], None + + async def list_notifications(self, **_kwargs): + return [], None + + +@pytest.mark.anyio +async def test_sync_snapshot_starts_psycopg_read_only_repeatable_read_transaction(monkeypatch): + project_id = uuid4() + conn = FakeSnapshotConnection() + now = datetime.now(timezone.utc) + + async def fake_runtime(**_kwargs): + return { + "availability": "not_configured", + "source": "none", + "scenario_availability": "not_configured", + "artifact_availability": "not_configured", + "schedule_availability": "not_configured", + "work_order_availability": "not_configured", + "alert_availability": "not_configured", + "generated_at": now, + "conditions": [], + "alerts": [], + "scenarios": [], + "latest_analysis": None, + "work_orders": [], + } + + monkeypatch.setattr(endpoint, "WorkbenchEventRepository", FakeEventRepository) + monkeypatch.setattr(endpoint, "workbench_runtime", fake_runtime) + monkeypatch.setattr(endpoint, "_service", lambda _ctx, _conn: FakeOperationalTaskService()) + + snapshot = await endpoint.workbench_sync_snapshot( + ctx=ProjectContext( + project_id=project_id, + project_code="project-a", + user_id=uuid4(), + project_role="member", + ), + conn=conn, + ) + + assert snapshot.event_cursor == 12 + assert conn.statements == [ + "SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY" + ] + + @pytest.mark.anyio async def test_expired_event_cursor_requires_snapshot_reset(monkeypatch): project_id = uuid4()