Skip to content

Commit 9010aa9

Browse files
committed
Implements detached workflows from a workflow contexts
Signed-off-by: Albert Callarisa <albert@diagrid.io>
1 parent 866ee5a commit 9010aa9

9 files changed

Lines changed: 572 additions & 1 deletion

File tree

dapr/ext/workflow/AGENTS.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,10 +150,22 @@ Passed to workflow functions as the first argument:
150150
- `instance_id`, `current_utc_datetime`, `is_replaying` — properties
151151
- `call_activity(activity, *, input, retry_policy, app_id)``Task`
152152
- `call_child_workflow(workflow, *, input, instance_id, retry_policy, app_id)``Task`
153+
- `schedule_new_workflow(workflow, *, input, instance_id, app_id)``str` (instance ID; fire-and-forget)
153154
- `create_timer(fire_at)``Task` (accepts `datetime` or `timedelta`)
154155
- `wait_for_external_event(name)``Task`
155156
- `set_custom_status(status)` / `continue_as_new(new_input, *, save_events)`
156157

158+
**Detached vs child workflows** — use `call_child_workflow` when the parent needs to `yield` on the result. Use `schedule_new_workflow` (detached) when the parent should spawn and move on:
159+
160+
- Fire-and-forget: no awaitable Task, returns the spawned instance ID synchronously.
161+
- No parent linkage on the spawned instance (no completion or failure flows back).
162+
- Deterministic default instance ID: derived from the parent instance ID + sequence number so replay resolves to the same history record.
163+
- Cross-app: pass `app_id=...` — the runtime evaluates `WorkflowAccessPolicy` for the target app.
164+
- Purge/terminate are not recursive across the boundary — the spawned instance manages its own lifecycle.
165+
- History propagation is intentionally not offered (detached spawns do not inherit the caller's propagated history).
166+
167+
See `examples/workflow/detached.py` for a per-tenant fan-out.
168+
157169
Module-level functions:
158170
- `when_all(tasks)``WhenAllTask` — wait for all tasks to complete
159171
- `when_any(tasks)``WhenAnyTask` — wait for first task to complete

dapr/ext/workflow/_durabletask/internal/helpers.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,44 @@ def new_create_child_workflow_action(
244244
)
245245

246246

247+
def new_create_detached_workflow_action(
248+
id: int,
249+
name: str,
250+
instance_id: str,
251+
encoded_input: Optional[str],
252+
router: Optional[pb.TaskRouter] = None,
253+
) -> pb.WorkflowAction:
254+
"""Build a WorkflowAction that spawns a detached workflow.
255+
256+
The detached workflow is fully decoupled from the caller: no parent
257+
linkage is recorded on the new instance and no completion or failure
258+
flows back. Detached spawns intentionally do not propagate the caller's
259+
history, so no historyPropagationScope is exposed here.
260+
"""
261+
return pb.WorkflowAction(
262+
id=id,
263+
createDetachedWorkflow=pb.CreateDetachedWorkflowAction(
264+
instanceId=instance_id,
265+
name=name,
266+
input=get_string_value(encoded_input),
267+
router=router,
268+
),
269+
router=router,
270+
)
271+
272+
273+
def new_detached_workflow_instance_created_event(
274+
event_id: int, instance_id: str
275+
) -> pb.HistoryEvent:
276+
return pb.HistoryEvent(
277+
eventId=event_id,
278+
timestamp=timestamp_pb2.Timestamp(),
279+
detachedWorkflowInstanceCreated=pb.DetachedWorkflowInstanceCreatedEvent(
280+
instanceId=instance_id,
281+
),
282+
)
283+
284+
247285
def is_empty(v: wrappers_pb2.StringValue):
248286
return v is None or v.value == ''
249287

dapr/ext/workflow/_durabletask/task.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,46 @@ def call_sub_orchestrator(
179179
"""
180180
pass
181181

182+
@abstractmethod
183+
def schedule_new_workflow(
184+
self,
185+
workflow: Union[Orchestrator[TInput, TOutput], str],
186+
*,
187+
input: Optional[TInput] = None,
188+
instance_id: Optional[str] = None,
189+
app_id: Optional[str] = None,
190+
) -> str:
191+
"""Spawn a detached workflow instance and return its ID synchronously.
192+
193+
Unlike ``call_sub_orchestrator``, the spawned workflow is fully
194+
decoupled from the caller: no parent linkage is recorded on the new
195+
instance and no completion or failure flows back. There is no
196+
awaitable task — the call resolves as soon as the runtime accepts the
197+
action.
198+
199+
Parameters
200+
----------
201+
workflow: Orchestrator[TInput, TOutput] | str
202+
A reference to the workflow function to spawn, or its registered
203+
name (string form is required for cross-app spawns).
204+
input: TInput | None
205+
Optional JSON-serializable input to pass to the spawned workflow.
206+
instance_id: str | None
207+
A unique instance ID for the spawned workflow. If not specified,
208+
a deterministic ID derived from the caller's instance ID and the
209+
current sequence number is used so replay resolves to the same
210+
record in history.
211+
app_id: str | None
212+
The app ID that will execute the spawned workflow. If not
213+
specified, the same app as the caller is used.
214+
215+
Returns
216+
-------
217+
str
218+
The instance ID of the spawned workflow.
219+
"""
220+
pass
221+
182222
@abstractmethod
183223
def wait_for_external_event(
184224
self, name: str, *, timeout: Optional[Union[datetime, timedelta]] = None

dapr/ext/workflow/_durabletask/worker.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1241,7 +1241,14 @@ def set_complete(
12411241

12421242
self._is_complete = True
12431243
self._completion_status = status
1244-
self._pending_actions.clear() # Cancel any pending actions
1244+
# Cancel any pending actions except detached-workflow spawns, which are
1245+
# fire-and-forget: the action is effective the moment schedule_new_workflow
1246+
# returns, so it must survive the caller's completion.
1247+
self._pending_actions = {
1248+
id_: a
1249+
for id_, a in self._pending_actions.items()
1250+
if a.HasField('createDetachedWorkflow')
1251+
}
12451252

12461253
self._result = result
12471254
result_json: Optional[str] = None
@@ -1465,6 +1472,34 @@ def call_sub_orchestrator(
14651472
)
14661473
return self._pending_tasks.get(id, task.CompletableTask())
14671474

1475+
def schedule_new_workflow(
1476+
self,
1477+
workflow: Union[task.Orchestrator[TInput, TOutput], str],
1478+
*,
1479+
input: Optional[TInput] = None,
1480+
instance_id: Optional[str] = None,
1481+
app_id: Optional[str] = None,
1482+
) -> str:
1483+
id = self.next_sequence_number()
1484+
workflow_name = workflow if isinstance(workflow, str) else task.get_name(workflow)
1485+
if instance_id is None:
1486+
instance_id = f'{self.instance_id}:detached:{id:04x}'
1487+
1488+
router: Optional[pb.TaskRouter] = None
1489+
if self._app_id is not None or app_id is not None:
1490+
router = pb.TaskRouter()
1491+
if self._app_id is not None:
1492+
router.sourceAppID = self._app_id
1493+
if app_id is not None:
1494+
router.targetAppID = app_id
1495+
1496+
encoded_input = shared.to_json(input) if input is not None else None
1497+
action = ph.new_create_detached_workflow_action(
1498+
id, workflow_name, instance_id, encoded_input, router
1499+
)
1500+
self._pending_actions[id] = action
1501+
return instance_id
1502+
14681503
def call_activity_function_helper(
14691504
self,
14701505
id: Optional[int],
@@ -1972,6 +2007,30 @@ def process_event(self, ctx: _RuntimeOrchestrationContext, event: pb.HistoryEven
19722007
expected_task_name=event.childWorkflowInstanceCreated.name,
19732008
actual_task_name=action.createChildWorkflow.name,
19742009
)
2010+
elif event.HasField('detachedWorkflowInstanceCreated'):
2011+
task_id = event.eventId
2012+
if task_id in ctx._pending_actions and ph.is_optional_timer_action(
2013+
ctx._pending_actions[task_id]
2014+
):
2015+
ctx._drop_optional_pending_at(task_id)
2016+
action = ctx._pending_actions.pop(task_id, None)
2017+
if not action:
2018+
raise _get_non_determinism_error(
2019+
task_id, task.get_name(ctx.schedule_new_workflow)
2020+
)
2021+
elif not action.HasField('createDetachedWorkflow'):
2022+
expected_method_name = task.get_name(ctx.schedule_new_workflow)
2023+
raise _get_wrong_action_type_error(task_id, expected_method_name, action)
2024+
elif (
2025+
action.createDetachedWorkflow.instanceId
2026+
!= event.detachedWorkflowInstanceCreated.instanceId
2027+
):
2028+
raise _get_wrong_action_name_error(
2029+
task_id,
2030+
method_name=task.get_name(ctx.schedule_new_workflow),
2031+
expected_task_name=event.detachedWorkflowInstanceCreated.instanceId,
2032+
actual_task_name=action.createDetachedWorkflow.instanceId,
2033+
)
19752034
elif event.HasField('childWorkflowInstanceCompleted'):
19762035
task_id = event.childWorkflowInstanceCompleted.taskScheduledId
19772036
sub_orch_task = ctx._pending_tasks.pop(task_id, None)
@@ -2239,6 +2298,8 @@ def _get_method_name_for_action(action: pb.WorkflowAction) -> str:
22392298
return task.get_name(task.OrchestrationContext.create_timer)
22402299
elif action_type == 'createChildWorkflow':
22412300
return task.get_name(task.OrchestrationContext.call_sub_orchestrator)
2301+
elif action_type == 'createDetachedWorkflow':
2302+
return task.get_name(task.OrchestrationContext.schedule_new_workflow)
22422303
# elif action_type == "sendEvent":
22432304
# return task.get_name(task.OrchestrationContext.send_event)
22442305
else:

dapr/ext/workflow/dapr_workflow_context.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,31 @@ def wf(ctx: task.OrchestrationContext, inp: TInput):
153153
def get_propagated_history(self) -> Optional[PropagatedHistory]:
154154
return self.__obj.get_propagated_history()
155155

156+
def schedule_new_workflow(
157+
self,
158+
workflow: Union[Workflow, str],
159+
*,
160+
input: Optional[TInput] = None,
161+
instance_id: Optional[str] = None,
162+
app_id: Optional[str] = None,
163+
) -> str:
164+
if isinstance(workflow, str):
165+
workflow_name = workflow
166+
elif hasattr(workflow, '_dapr_alternate_name'):
167+
workflow_name = workflow.__dict__['_dapr_alternate_name']
168+
else:
169+
workflow_name = workflow.__name__
170+
171+
if app_id is not None:
172+
self._logger.debug(
173+
f'{self.instance_id}: Spawning detached workflow {workflow_name} on app {app_id}'
174+
)
175+
else:
176+
self._logger.debug(f'{self.instance_id}: Spawning detached workflow {workflow_name}')
177+
return self.__obj.schedule_new_workflow(
178+
workflow_name, input=input, instance_id=instance_id, app_id=app_id
179+
)
180+
156181
def wait_for_external_event(
157182
self,
158183
name: str,

dapr/ext/workflow/workflow_context.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,42 @@ def get_propagated_history(self) -> Optional[PropagatedHistory]:
180180
no history was propagated."""
181181
pass
182182

183+
@abstractmethod
184+
def schedule_new_workflow(
185+
self,
186+
workflow: Union[Workflow[TOutput], str],
187+
*,
188+
input: Optional[TInput] = None,
189+
instance_id: Optional[str] = None,
190+
app_id: Optional[str] = None,
191+
) -> str:
192+
"""Spawn a detached workflow instance and return its ID synchronously.
193+
194+
Unlike ``call_child_workflow``, the spawned workflow is fully
195+
decoupled from the caller: no parent linkage is recorded, no
196+
completion or failure flows back, and there is no awaitable task.
197+
198+
Parameters
199+
----------
200+
workflow: Workflow[TOutput] | str
201+
A reference to the workflow function, or its registered name
202+
(string form is required for cross-app spawns).
203+
input: TInput | None
204+
Optional JSON-serializable input to pass to the spawned workflow.
205+
instance_id: str | None
206+
Instance ID for the spawned workflow. When omitted, the runtime
207+
derives a deterministic ID from the caller's instance ID so
208+
replay resolves to the same history record.
209+
app_id: str | None
210+
The app ID that will execute the spawned workflow.
211+
212+
Returns
213+
-------
214+
str
215+
The instance ID of the spawned workflow.
216+
"""
217+
pass
218+
183219
@abstractmethod
184220
def wait_for_external_event(
185221
self,

examples/workflow/detached.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# -*- coding: utf-8 -*-
2+
# Copyright 2026 The Dapr Authors
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
# Unless required by applicable law or agreed to in writing, software
8+
# distributed under the License is distributed on an "AS IS" BASIS,
9+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
# See the License for the specific language governing permissions and
11+
# limitations under the License.
12+
13+
"""Detached workflow fan-out example.
14+
15+
The parent workflow spawns one detached workflow per tenant. Detached spawns
16+
are fire-and-forget: the parent receives the spawned instance ID
17+
synchronously and completes without waiting for the detached workflows to
18+
finish. Each detached instance runs independently, with no parent linkage
19+
in its history.
20+
21+
This differs from ``call_child_workflow`` (see child_workflow.py), where the
22+
parent yields a Task and blocks until the child completes.
23+
"""
24+
25+
import dapr.ext.workflow as wf
26+
27+
wfr = wf.WorkflowRuntime()
28+
29+
TENANTS = ['acme', 'globex', 'initech']
30+
31+
32+
@wfr.workflow
33+
def parent_workflow(ctx: wf.DaprWorkflowContext, tenants: list[str]):
34+
spawned_ids: list[str] = []
35+
for tenant in tenants:
36+
detached_id = f'tenant-{tenant}'
37+
spawned = ctx.schedule_new_workflow(tenant_workflow, input=tenant, instance_id=detached_id)
38+
spawned_ids.append(spawned)
39+
if not ctx.is_replaying:
40+
print(f'*** Spawned detached workflow {spawned}', flush=True)
41+
return spawned_ids
42+
43+
44+
@wfr.workflow
45+
def tenant_workflow(ctx: wf.DaprWorkflowContext, tenant: str):
46+
if not ctx.is_replaying:
47+
print(f'*** Tenant workflow started for {tenant}', flush=True)
48+
yield ctx.call_activity(process_tenant, input=tenant)
49+
return f'{tenant}-done'
50+
51+
52+
@wfr.activity
53+
def process_tenant(ctx: wf.WorkflowActivityContext, tenant: str) -> str:
54+
print(f'*** Processing tenant {tenant}', flush=True)
55+
return f'processed:{tenant}'
56+
57+
58+
if __name__ == '__main__':
59+
wfr.start()
60+
61+
wf_client = wf.DaprWorkflowClient()
62+
parent_id = wf_client.schedule_new_workflow(workflow=parent_workflow, input=TENANTS)
63+
64+
parent_state = wf_client.wait_for_workflow_completion(parent_id, timeout_in_seconds=30)
65+
print(f'*** Parent workflow {parent_id} finished: {parent_state.runtime_status}', flush=True)
66+
67+
# The detached workflows continue running independently of the parent.
68+
# Poll each to confirm they eventually complete.
69+
for tenant in TENANTS:
70+
detached_id = f'tenant-{tenant}'
71+
state = wf_client.wait_for_workflow_completion(detached_id, timeout_in_seconds=30)
72+
print(
73+
f'*** Detached workflow {detached_id} finished: '
74+
f'{state.runtime_status} output={state.serialized_output}',
75+
flush=True,
76+
)
77+
78+
wfr.shutdown()

0 commit comments

Comments
 (0)