feat: add duplicate-safe workflow shadow dispatch
This commit is contained in:
@@ -170,6 +170,7 @@ async def test_dispatch_render_with_workflow_creates_run_and_node_results_for_pr
|
||||
assert result["backend"] == "workflow"
|
||||
assert result["workflow_type"] == "still"
|
||||
assert result["celery_task_id"] == "canvas-123"
|
||||
assert run.execution_mode == "legacy"
|
||||
assert run.workflow_def_id == seeded["workflow_definition"].id
|
||||
assert run.order_line_id == seeded["order_line"].id
|
||||
assert run.celery_task_id == "canvas-123"
|
||||
@@ -274,6 +275,7 @@ async def test_dispatch_render_with_workflow_graph_mode_dispatches_supported_cus
|
||||
assert result["backend"] == "workflow_graph"
|
||||
assert result["execution_mode"] == "graph"
|
||||
assert result["task_ids"] == ["graph-task-1"]
|
||||
assert run.execution_mode == "graph"
|
||||
assert run.status == "pending"
|
||||
assert node_results["setup"].status == "completed"
|
||||
assert node_results["template"].status == "completed"
|
||||
@@ -339,10 +341,144 @@ async def test_dispatch_render_with_workflow_graph_mode_falls_back_to_legacy_on_
|
||||
assert result["backend"] == "legacy"
|
||||
assert result["fallback_from"] == "workflow_graph"
|
||||
assert result["workflow_run_id"] == str(run.id)
|
||||
assert run.execution_mode == "graph"
|
||||
assert run.status == "failed"
|
||||
assert run.error_message == "graph dispatch exploded"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_render_with_workflow_shadow_mode_keeps_legacy_authoritative_and_dispatches_graph_observer(
|
||||
db,
|
||||
admin_user,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
_use_test_database(monkeypatch)
|
||||
order_line = await _seed_renderable_order_line(db, admin_user, tmp_path)
|
||||
workflow_definition = WorkflowDefinition(
|
||||
name=f"Shadow Workflow {uuid.uuid4().hex[:8]}",
|
||||
output_type_id=order_line.output_type_id,
|
||||
config={
|
||||
"version": 1,
|
||||
"ui": {"preset": "custom", "execution_mode": "shadow"},
|
||||
"nodes": [
|
||||
{"id": "setup", "step": "order_line_setup", "params": {}},
|
||||
{"id": "template", "step": "resolve_template", "params": {}},
|
||||
{"id": "render", "step": "blender_still", "params": {"width": 1024, "height": 768}},
|
||||
],
|
||||
"edges": [
|
||||
{"from": "setup", "to": "template"},
|
||||
{"from": "template", "to": "render"},
|
||||
],
|
||||
},
|
||||
is_active=True,
|
||||
)
|
||||
db.add(workflow_definition)
|
||||
await db.flush()
|
||||
output_type = await db.get(OutputType, order_line.output_type_id)
|
||||
assert output_type is not None
|
||||
output_type.workflow_definition_id = workflow_definition.id
|
||||
await db.commit()
|
||||
|
||||
calls: list[tuple[str, list[str], dict]] = []
|
||||
|
||||
def _fake_send_task(task_name: str, args: list[str], kwargs: dict):
|
||||
calls.append((task_name, args, kwargs))
|
||||
return type("Result", (), {"id": "shadow-task-1"})()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.domains.rendering.dispatch_service._legacy_dispatch",
|
||||
lambda order_line_id: {"backend": "legacy", "order_line_id": order_line_id},
|
||||
)
|
||||
monkeypatch.setattr("app.tasks.celery_app.celery_app.send_task", _fake_send_task)
|
||||
|
||||
result = dispatch_render_with_workflow(str(order_line.id))
|
||||
|
||||
await db.rollback()
|
||||
|
||||
run_result = await db.execute(
|
||||
select(WorkflowRun)
|
||||
.where(WorkflowRun.id == uuid.UUID(result["shadow_workflow_run_id"]))
|
||||
.options(selectinload(WorkflowRun.node_results))
|
||||
)
|
||||
run = run_result.scalar_one()
|
||||
render_call = calls[0]
|
||||
|
||||
assert result["backend"] == "legacy"
|
||||
assert result["execution_mode"] == "shadow"
|
||||
assert result["shadow_status"] == "dispatched"
|
||||
assert result["shadow_task_ids"] == ["shadow-task-1"]
|
||||
assert run.execution_mode == "shadow"
|
||||
assert run.status == "pending"
|
||||
assert render_call[0] == "app.domains.rendering.tasks.render_order_line_still_task"
|
||||
assert render_call[1] == [str(order_line.id)]
|
||||
assert render_call[2]["publish_asset_enabled"] is False
|
||||
assert render_call[2]["emit_events"] is False
|
||||
assert render_call[2]["job_document_enabled"] is False
|
||||
assert render_call[2]["output_name_suffix"].startswith("shadow-")
|
||||
assert render_call[2]["workflow_run_id"] == str(run.id)
|
||||
assert render_call[2]["workflow_node_id"] == "render"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_render_with_workflow_shadow_mode_ignores_graph_failures_after_legacy_dispatch(
|
||||
db,
|
||||
admin_user,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
_use_test_database(monkeypatch)
|
||||
order_line = await _seed_renderable_order_line(db, admin_user, tmp_path)
|
||||
workflow_definition = WorkflowDefinition(
|
||||
name=f"Shadow Workflow {uuid.uuid4().hex[:8]}",
|
||||
output_type_id=order_line.output_type_id,
|
||||
config={
|
||||
"version": 1,
|
||||
"ui": {"preset": "custom", "execution_mode": "shadow"},
|
||||
"nodes": [
|
||||
{"id": "setup", "step": "order_line_setup", "params": {}},
|
||||
{"id": "render", "step": "blender_still", "params": {"width": 1024, "height": 768}},
|
||||
],
|
||||
"edges": [
|
||||
{"from": "setup", "to": "render"},
|
||||
],
|
||||
},
|
||||
is_active=True,
|
||||
)
|
||||
db.add(workflow_definition)
|
||||
await db.flush()
|
||||
output_type = await db.get(OutputType, order_line.output_type_id)
|
||||
assert output_type is not None
|
||||
output_type.workflow_definition_id = workflow_definition.id
|
||||
await db.commit()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.domains.rendering.dispatch_service._legacy_dispatch",
|
||||
lambda order_line_id: {"backend": "legacy", "order_line_id": order_line_id},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.domains.rendering.workflow_graph_runtime.execute_graph_workflow",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("shadow graph exploded")),
|
||||
)
|
||||
|
||||
result = dispatch_render_with_workflow(str(order_line.id))
|
||||
|
||||
await db.rollback()
|
||||
|
||||
run = (
|
||||
await db.execute(select(WorkflowRun).order_by(WorkflowRun.created_at.desc()))
|
||||
).scalars().first()
|
||||
|
||||
assert result["backend"] == "legacy"
|
||||
assert result["execution_mode"] == "shadow"
|
||||
assert result["shadow_status"] == "failed"
|
||||
assert result["shadow_error"] == "shadow graph exploded"
|
||||
assert result["shadow_workflow_run_id"] == str(run.id)
|
||||
assert run.execution_mode == "shadow"
|
||||
assert run.status == "failed"
|
||||
assert run.error_message == "shadow graph exploded"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workflow_dispatch_endpoint_returns_workflow_run_with_node_results(
|
||||
client,
|
||||
@@ -384,21 +520,21 @@ async def test_workflow_dispatch_endpoint_returns_workflow_run_with_node_results
|
||||
assert body["execution_mode"] == "graph"
|
||||
assert body["dispatched"] == 2
|
||||
assert body["task_ids"] == ["task-1", "task-2"]
|
||||
assert calls == [
|
||||
(
|
||||
"app.domains.rendering.tasks.render_order_line_still_task",
|
||||
[context_id],
|
||||
{"width": 640, "height": 640},
|
||||
),
|
||||
(
|
||||
"app.domains.rendering.tasks.export_blend_for_order_line_task",
|
||||
[context_id],
|
||||
{},
|
||||
),
|
||||
assert [call[0] for call in calls] == [
|
||||
"app.domains.rendering.tasks.render_order_line_still_task",
|
||||
"app.domains.rendering.tasks.export_blend_for_order_line_task",
|
||||
]
|
||||
assert [call[1] for call in calls] == [[context_id], [context_id]]
|
||||
assert calls[0][2]["width"] == 640
|
||||
assert calls[0][2]["height"] == 640
|
||||
assert calls[0][2]["workflow_node_id"] == "render"
|
||||
assert calls[1][2]["workflow_node_id"] == "blend"
|
||||
assert "workflow_run_id" in calls[0][2]
|
||||
assert calls[0][2]["workflow_run_id"] == calls[1][2]["workflow_run_id"]
|
||||
|
||||
node_results = {node["node_name"]: node for node in body["workflow_run"]["node_results"]}
|
||||
assert body["workflow_run"]["status"] == "pending"
|
||||
assert body["workflow_run"]["execution_mode"] == "graph"
|
||||
assert body["workflow_run"]["celery_task_id"] == "task-1"
|
||||
assert node_results["render"]["status"] == "queued"
|
||||
assert node_results["render"]["output"]["task_id"] == "task-1"
|
||||
|
||||
Reference in New Issue
Block a user