fix(workbench): serialize events and target work order sync

This commit is contained in:
2026-10-08 15:41:49 +08:00
parent b70283c2ff
commit 4a63fcd7ff
20 changed files with 673 additions and 90 deletions
+22 -3
View File
@@ -42,12 +42,25 @@ class PushStream(httpx.AsyncByteStream):
)
def test_large_source_burst_coalesces_to_full_reconciliation_with_bounded_ids():
settings = WorkerSettings("http://server.test", "http://agent.test", str(uuid4()), bearer="TEST_ONLY")
synchronizer = PeripheralWorkOrderSynchronizer(None, settings, "http://sample.test", CredentialProvider(settings))
order_id = str(uuid4())
for _ in range(1000):
synchronizer._queue_order(order_id)
assert synchronizer.pending_order_ids == {order_id}
for _ in range(1000):
synchronizer._queue_order(str(uuid4()))
assert synchronizer.full_reconcile
assert synchronizer.pending_order_ids == set()
@pytest.mark.anyio
async def test_source_push_synchronizes_new_and_updated_orders_without_waiting_for_reconciliation():
project_id, order_id, task_id = map(str, (uuid4(), uuid4(), uuid4()))
settings = WorkerSettings("http://server.test", "http://agent.test", project_id, bearer="TEST_ONLY")
stream = PushStream()
orders, tasks, cursors = [], [], []
orders, tasks, cursors, task_queries = [], [], [], []
initial_scan, projected = asyncio.Event(), asyncio.Event()
scan_count = 0
@@ -63,8 +76,11 @@ async def test_source_push_synchronizes_new_and_updated_orders_without_waiting_f
if request.url.host == "sample.test":
scan_count += 1
initial_scan.set()
if request.url.path.endswith(f"/work-orders/{order_id}"):
return httpx.Response(200, json=orders[0])
return httpx.Response(200, json={"items": orders, "next_cursor": None})
if request.method == "GET":
task_queries.append(dict(request.url.params))
return httpx.Response(200, json={"items": tasks, "next_offset": None})
body = json.loads(request.content)
if request.url.path.endswith("external-feedback"):
@@ -100,6 +116,8 @@ async def test_source_push_synchronizes_new_and_updated_orders_without_waiting_f
assert tasks[0]["source"]["external_revision"] == 2
assert tasks[0]["status"] == "in_progress"
assert scan_count == 3 # Startup plus two pushed source changes.
assert task_queries[0] == {"limit": "100", "offset": "0"}
assert all(query["peripheral_work_order_id"] == order_id for query in task_queries[1:])
assert cursors == ["20"]
finally:
runner.cancel()
@@ -221,7 +239,7 @@ async def test_failed_scan_retries_without_requiring_another_source_message(monk
return httpx.Response(200, json={"event_cursor": 20})
return httpx.Response(200, stream=stream)
async def sync_once(*_args):
async def sync_once(*_args, **_kwargs):
nonlocal attempts
attempts += 1
if attempts == 1:
@@ -256,13 +274,14 @@ async def test_source_event_during_scan_schedules_another_pass(monkeypatch):
return httpx.Response(200, json={"event_cursor": 20})
return httpx.Response(200, stream=stream)
async def sync_once(*_args):
async def sync_once(*_args, **kwargs):
nonlocal attempts
attempts += 1
if attempts == 1:
scanning.set()
await release.wait()
else:
assert kwargs["work_order_ids"] == {order_id}
caught_up.set()
return 0
+12 -3
View File
@@ -240,6 +240,7 @@ async def test_sample_sync_rejects_cross_project_data_before_writing():
@pytest.mark.anyio
@pytest.mark.parametrize("targeted", [False, True])
@pytest.mark.parametrize(
("investigation_status", "expected_feedback_count", "routes_to_investigation"),
[
@@ -252,19 +253,24 @@ async def test_created_work_order_receipt_preserves_projection_and_routes_active
investigation_status,
expected_feedback_count,
routes_to_investigation,
targeted,
):
project_id, order_id, investigation_id, projection_id = map(str, (uuid4(), uuid4(), uuid4(), uuid4()))
received = []
def handler(request):
if request.url.host == "sample.test":
return httpx.Response(200, json={"items": [{
order = {
"project_id": project_id, "work_order_id": order_id, "origin": "synthetic",
"created_by": str(uuid4()),
"revision": 2, "summary": "采样已完成", "status": "completed", "updated_at": "2026-09-23T09:00:00Z",
"title": "合成采样工单", "priority": "normal",
"work_order_type": "general", "created_at": "2026-09-23T08:00:00Z",
"scheduled_at": None,
}], "next_cursor": None})
}
if targeted:
assert request.url.path.endswith(f"/work-orders/{order_id}")
return httpx.Response(200, json=order)
return httpx.Response(200, json={"items": [order], "next_cursor": None})
if request.method == "POST":
assert request.url.path.endswith("external-feedback"), "Linked child orders must not start another investigation"
received.append(request.url.path)
@@ -276,6 +282,8 @@ async def test_created_work_order_receipt_preserves_projection_and_routes_active
return httpx.Response(200, json={"actions": [{"kind": "create_work_order", "receipt": {
"adapter": "peripheral_sample", "receipt_id": order_id,
}}]})
if targeted:
assert request.url.params["peripheral_work_order_id"] == order_id
return httpx.Response(200, json={"items": [
{"task_id": investigation_id, "task_type": "event_investigation", "status": investigation_status},
{"task_id": projection_id, "task_type": "external_work_order", "source": {
@@ -285,7 +293,8 @@ async def test_created_work_order_receipt_preserves_projection_and_routes_active
settings = WorkerSettings("http://server.test", "http://agent.test", project_id, bearer="TEST_ONLY")
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
assert await sync_once(
client, settings, "http://sample.test", CredentialProvider(settings)
client, settings, "http://sample.test", CredentialProvider(settings),
**({"work_order_ids": {order_id}} if targeted else {}),
) == expected_feedback_count
assert any(investigation_id in path for path in received) is routes_to_investigation
assert any(projection_id in path for path in received)
+59
View File
@@ -1,10 +1,12 @@
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock
from uuid import uuid4
import pytest
from app.infra.db.postgresql.workbench_event_hub import WorkbenchEventHub, WorkbenchEventHubManager
from app.infra.db.postgresql import workbench_event_hub as hub_module
@pytest.fixture
@@ -30,3 +32,60 @@ async def test_project_subscribers_share_one_hub_and_idle_close(monkeypatch):
await asyncio.sleep(0.03)
assert len(manager._hubs) == 0
close.assert_awaited_once()
@pytest.mark.anyio
async def test_idle_subscribers_share_cursor_reads_and_notification_invalidates_the_cache(monkeypatch):
reads = 0
class Repository:
def __init__(self, *_args):
pass
async def read_after(self, _after):
nonlocal reads
reads += 1
return []
monkeypatch.setattr(hub_module, "WorkbenchEventRepository", Repository)
hub = WorkbenchEventHub(uuid4(), "postgresql://test/db")
hub._connection = SimpleNamespace(closed=False)
monkeypatch.setattr(hub, "start", AsyncMock())
await asyncio.gather(*(hub.read_after(0) for _ in range(20)))
assert reads == 1
await hub._wake()
await asyncio.gather(*(hub.read_after(0) for _ in range(20)))
assert reads == 2
@pytest.mark.anyio
async def test_notification_during_read_does_not_cache_stale_results(monkeypatch):
entered, release = asyncio.Event(), asyncio.Event()
reads = 0
class Repository:
def __init__(self, *_args):
pass
async def read_after(self, _after):
nonlocal reads
reads += 1
if reads == 1:
entered.set()
await release.wait()
return []
monkeypatch.setattr(hub_module, "WorkbenchEventRepository", Repository)
hub = WorkbenchEventHub(uuid4(), "postgresql://test/db")
hub._connection = SimpleNamespace(closed=False)
monkeypatch.setattr(hub, "start", AsyncMock())
first = asyncio.create_task(hub.read_after(0))
await entered.wait()
await hub._wake()
release.set()
await first
await hub.read_after(0)
assert reads == 2
for cursor in range(100):
await hub.read_after(cursor)
assert len(hub._reads) == 32
+49
View File
@@ -0,0 +1,49 @@
from contextlib import asynccontextmanager
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
from uuid import uuid4
import pytest
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
from app.infra.db.postgresql.task_alerts import TaskAlertRepository
from app.infra.db.postgresql.workbench_events import (
EVENT_ORDER_LOCK, lock_event_transaction_sync,
)
@pytest.fixture
def anyio_backend():
return "asyncio"
@pytest.mark.anyio
@pytest.mark.parametrize("repository", [OperationalTaskRepository, TaskAlertRepository])
@pytest.mark.parametrize("database", ["tjwater_v2", "other_project"])
async def test_event_order_lock_precedes_business_writes_and_is_v2_only(repository, database):
@asynccontextmanager
async def transaction():
yield
connection = SimpleNamespace(
info=SimpleNamespace(dbname=database), execute=AsyncMock(), transaction=transaction,
)
async with repository(connection, uuid4()).transaction():
if database == "tjwater_v2":
connection.execute.assert_awaited_once_with(
"SELECT pg_advisory_xact_lock(hashtextextended(%s,0))", (EVENT_ORDER_LOCK,),
)
else:
connection.execute.assert_not_awaited()
def test_sync_producers_use_same_database_scoped_lock():
connection = SimpleNamespace(info=SimpleNamespace(dbname="tjwater_v2"), execute=Mock())
lock_event_transaction_sync(connection)
connection.execute.assert_called_once_with(
"SELECT pg_advisory_xact_lock(hashtextextended(%s,0))", (EVENT_ORDER_LOCK,),
)
connection.info.dbname = "other_project"
connection.execute.reset_mock()
lock_event_transaction_sync(connection)
connection.execute.assert_not_called()