fix(workbench): serialize events and target work order sync
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user