feat: add duplicate-safe workflow shadow dispatch

This commit is contained in:
2026-04-07 11:35:32 +02:00
parent 26046fb2d6
commit f43f1e7420
11 changed files with 496 additions and 113 deletions
@@ -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"