Skip to content

Commit 5cccb31

Browse files
GeigerJ2elinscott
andauthored
🐛 Map: fix gather race for async sources (#776)
`update_map_task_state` gathered from per-item clones of the pass-through `gather_item`. For async process-type sources (CalcJob, WorkChain, @task.graph) the awaitable cascade could reach the gather before `continue_workgraph` had scheduled those clones, raising `KeyError` or leaving them PLANNED and hanging the finalize path. - `generate_mapped_tasks` no longer clones `gather_item`; the gather reads each value directly from the mapped source task's clones, which is race-free (source results are recorded before the cascade reaches the zone). - Failure handling is fail-fast and keyed on the gather sources: the zone fails only when a gather source produced no result (its clone FAILED, or was SKIPPED in an iteration that errored), then skips the zone's downstream tasks. A deliberately-untaken `If` branch gathers `None` and finishes; an unrelated body-task failure leaves the gather complete and surfaces via the ordinary exit 302. - `Map.gather` rejects a source produced outside the zone, validating all sources before mutating so a rejected build stays rebuildable. - `map_info` keeps the source -> gather_item edges (as template edges) so the GUI can still draw the gather. Also drop a stale commented-out gather block from `update_template_task_state`, and add tests for the async source, the failure / skip / If-in-Map paths, the out-of-zone reject, and map_info. --- Co-authored-by: Edward Linscott <edwardlinscott@gmail.com>
1 parent 4a48919 commit 5cccb31

4 files changed

Lines changed: 332 additions & 29 deletions

File tree

src/aiida_workgraph/engine/task_manager.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,8 @@ def execute_map_task(self, task, kwargs):
305305
for prefix, value in source.items():
306306
new_tasks, new_links = self.generate_mapped_tasks(task, prefix=prefix)
307307
self.update_map_item_task_state(item_task, prefix, value)
308-
map_info['children'] = list(new_tasks.keys())
308+
# include the (uncloned) gather_item so its restored edges are not dangling
309+
map_info['children'] = list(new_tasks.keys()) + [task.gather_item_task.name]
309310
map_info['links'] = new_links
310311
self.state_manager.set_task_runtime_info(name, 'map_info', map_info)
311312
# gather task finishes immediately
@@ -472,6 +473,15 @@ def generate_mapped_tasks(self, zone_task: Task, prefix: str) -> None:
472473
all_links = []
473474
child_tasks = self.get_all_children(zone_task.name)
474475
for child_task in child_tasks:
476+
# The gather_item task is a pure pass-through aggregator
477+
# (executor=return_input); the map zone reads directly from the
478+
# mapped source tasks in `update_map_task_state`, so cloning
479+
# gather_item would just create unused clones. Skipping the
480+
# clone also avoids a race where, for async process-type source
481+
# tasks (CalcJob, WorkChain, @task.graph), the gather_item
482+
# clones stay PLANNED and hang the engine's finalize path.
483+
if self.process.wg.tasks[child_task].identifier == 'workgraph.gather_item':
484+
continue
475485
# since the child task is mapped, it should be skipped
476486
self.state_manager.set_task_runtime_info(child_task, 'state', TaskState.MAPPED)
477487
task = self.copy_task(child_task, prefix)
@@ -482,6 +492,15 @@ def generate_mapped_tasks(self, zone_task: Task, prefix: str) -> None:
482492
new_links = self._patch_cloned_tasks(new_tasks, all_links)
483493
# update process.wg.connectivity so the new tasks are recognized in child_node, zone references, etc.
484494
self._patch_connectivity(new_tasks)
495+
# gather_item is intentionally not cloned, so `_patch_cloned_tasks` drops
496+
# the source -> gather_item edges (its `to_task` is not in new_tasks) and
497+
# the GUI loses them. Re-add each as its template edge, the same shape the
498+
# other map_info links use (the GUI expands them per prefix); this is
499+
# display-only, no engine link is created.
500+
gather_item = zone_task.gather_item_task
501+
for link in gather_item.inputs._all_links:
502+
if link.from_task.name in new_tasks:
503+
new_links.append(link.to_dict())
485504
return new_tasks, new_links
486505

487506
def update_map_item_task_state(self, item_task, prefix, value: Any):

src/aiida_workgraph/engine/task_state.py

Lines changed: 86 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
from __future__ import annotations
2-
from typing import Optional, Tuple, List, Any
2+
from typing import Optional, Tuple, List, Any, Iterator, TYPE_CHECKING
33
from typing_extensions import assert_never
44
from aiida.orm.utils.serialize import serialize
55
from aiida_workgraph.orm.utils import deserialize_safe
66
from aiida.orm import ProcessNode, Data
77
from aiida_workgraph.enums import TERMINAL_TASK_STATES, RuntimeInfoKey, TaskState
88
from node_graph.socket import BaseSocket, TaskSocketNamespace
99

10+
if TYPE_CHECKING:
11+
from aiida_workgraph.task import Task
12+
1013

1114
class TaskStateManager:
1215
"""
@@ -291,49 +294,104 @@ def update_zone_task_state(self, name: str) -> None:
291294
self.process.report(f'Task: {name} finished.')
292295
self.update_parent_task_state(name)
293296

297+
def _iter_zone_clones(self, zone: 'Task') -> Iterator[Tuple[str, 'Task']]:
298+
"""Yield ``(prefix, clone)`` for every mapped clone within a Map zone.
299+
300+
Clones live under each body template's ``mapped_tasks``, keyed by prefix
301+
(the zone's child list holds templates, one clone per prefix), so walking
302+
the template tree reaches all of them, including those in a nested `If`.
303+
"""
304+
stack = list(getattr(zone, 'children', []))
305+
while stack:
306+
template = stack.pop()
307+
yield from (template.mapped_tasks or {}).items()
308+
stack.extend(getattr(template, 'children', []))
309+
294310
def update_map_task_state(self, name: str) -> None:
295311
"""Update the map task state.
296312
1) check if all child tasks are finished.
297313
2) gather the results of all the mapped tasks.
298314
3) update the parent task state.
299315
"""
316+
from aiida_workgraph.utils import get_nested_dict
317+
300318
finished, _ = self.are_childen_finished(name)
301-
if finished:
302-
map_zone = self.process.wg.tasks[name]
303-
# gather the results of all the mapped tasks
304-
gather_task = map_zone.gather_item_task
305-
for input in gather_task.inputs:
306-
if input._name.startswith('_'):
307-
continue
308-
results = {}
309-
link = input._links[0]
310-
for prefix, mapped_task in self.process.wg.tasks[gather_task.name].mapped_tasks.items():
311-
results[prefix] = self.ctx._task_results[mapped_task.name][link.to_socket._name]
312-
self.ctx._task_results[name][link.to_socket._name] = results
313-
self.set_task_runtime_info(name, 'state', TaskState.FINISHED)
314-
# self.update_meta_tasks(name)
315-
self.process.report(f'Task: {name} finished.')
316-
self.update_meta_tasks(name)
319+
if not finished:
320+
return
321+
map_zone = self.process.wg.tasks[name]
322+
# Gather the results of all the mapped tasks.
323+
#
324+
# We aggregate directly from each mapped SOURCE task (the task whose
325+
# output is linked into the template gather_item), not via the
326+
# gather_item itself. The gather_item template is a pure pass-through
327+
# aggregator (executor=return_input) and is intentionally not cloned
328+
# per item in `generate_mapped_tasks`, so there are no gather_item
329+
# clones to read from. Reading directly from the source's
330+
# `_task_results` is also race-free: the source's results are
331+
# populated by `update_task_state` before any cascade can reach here,
332+
# which matters when the source is an async process-type task
333+
# (CalcJob, WorkChain, or a @task.graph sub-workflow).
334+
gather_task = map_zone.gather_item_task
335+
gather_links = [
336+
input_socket._links[0]
337+
for input_socket in gather_task.inputs
338+
if not input_socket._name.startswith('_') and input_socket._links
339+
]
340+
# Fail the zone (fail-fast) only when a GATHER SOURCE produced no result:
341+
# its clone FAILED, or was SKIPPED in an iteration that errored (something
342+
# it depended on FAILED). This is keyed on the gather sources, not every
343+
# clone, so a false `If` branch gathers None (SKIPPED with no FAILED clone
344+
# in its iteration), and an unrelated body-task failure is left to the
345+
# ordinary 302 path rather than discarding an otherwise-complete gather.
346+
failed_prefixes = {
347+
prefix
348+
for prefix, clone in self._iter_zone_clones(map_zone)
349+
if self.get_task_runtime_info(clone.name, 'state') == TaskState.FAILED
350+
}
351+
missing_prefixes = set()
352+
for link in gather_links:
353+
for prefix, clone in (self.process.wg.tasks[link.from_task.name].mapped_tasks or {}).items():
354+
clone_state = self.get_task_runtime_info(clone.name, 'state')
355+
if clone_state == TaskState.FAILED or (clone_state == TaskState.SKIPPED and prefix in failed_prefixes):
356+
missing_prefixes.add(prefix)
357+
if missing_prefixes:
358+
self.set_task_runtime_info(name, 'state', TaskState.FAILED)
359+
# Skip the zone's downstream tasks, as `on_task_failed` does for an
360+
# ordinary failure; otherwise they run on the missing gather output
361+
# and pollute the report with their own consequent failures.
362+
self.set_tasks_state(self.process.wg.connectivity['child_node'][name], TaskState.SKIPPED)
363+
self.process.report(
364+
f'Task: {name} failed, no result from mapped item(s): {", ".join(sorted(missing_prefixes))}.'
365+
)
317366
self.update_parent_task_state(name)
367+
return
368+
for link in gather_links:
369+
source_clones = self.process.wg.tasks[link.from_task.name].mapped_tasks or {}
370+
results = {}
371+
for prefix, clone in source_clones.items():
372+
# A source with no recorded value for this socket gathers None: a
373+
# false `If` branch (SKIPPED), or a FINISHED source that returned
374+
# no value for it. They read the same here; telling them apart is
375+
# the resilient-Map follow-up.
376+
results[prefix] = get_nested_dict(
377+
self.ctx._task_results[clone.name],
378+
link.from_socket._scoped_name,
379+
default=None,
380+
)
381+
self.ctx._task_results[name][link.to_socket._name] = results
382+
self.set_task_runtime_info(name, 'state', TaskState.FINISHED)
383+
self.process.report(f'Task: {name} finished.')
384+
self.update_meta_tasks(name)
385+
self.update_parent_task_state(name)
318386

319387
def update_template_task_state(self, name: str) -> None:
320388
"""Update the template task state.
321389
1) check if all child tasks are finished.
322-
2) gather the results of all the mapped tasks.
323-
3) update the parent task state.
390+
2) update the parent task state.
324391
"""
325392
finished, _ = self.are_childen_finished(name)
326393
if finished:
327-
# # gather the results of all the mapped tasks
328-
# results = {}
329-
# for prefix, mapped_task in self.process.wg.tasks[name].mapped_tasks.items():
330-
# for output in mapped_task.outputs:
331-
# if output._name in self.ctx._task_results[mapped_task.name]:
332-
# results.setdefault(output._name, {})
333-
# results[output._name][prefix] = self.ctx._task_results[mapped_task.name][output._name]
334-
# self.ctx._task_results[name] = results
335394
self.set_task_runtime_info(name, 'state', TaskState.FINISHED)
336-
# self.update_meta_tasks(name)
337395
self.process.report(f'Task: {name} finished.')
338396
self.update_parent_task_state(name)
339397

src/aiida_workgraph/tasks/builtins.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,8 +133,29 @@ def gather_item_task(self) -> Task:
133133
return cast(Task, child)
134134
return self.add_task('workgraph.gather_item')
135135

136+
def _contains_task(self, target: Task) -> bool:
137+
"""True if ``target`` is a descendant of this zone (recursing nested zones)."""
138+
stack = list(self.children)
139+
while stack:
140+
task = stack.pop()
141+
if task.name == target.name:
142+
return True
143+
stack.extend(getattr(task, 'children', []))
144+
return False
145+
136146
def gather(self, sockets: Dict[str, BaseSocket]) -> BaseSocket:
137147
"""Collect per-entry results into the zone outputs, one namespace per name."""
148+
# Validate every source before mutating anything (the loop below and even
149+
# `self.gather_item_task` add state), so a rejection leaves the zone
150+
# unchanged and rebuildable.
151+
for name, socket in sockets.items():
152+
source = socket._task
153+
if not self._contains_task(source):
154+
msg = (
155+
f"Map.gather() source '{name}' ('{source.name}') is outside the Map zone (one value, not "
156+
f'per-iteration). Move a per-iteration task inside the zone, or use a shared value (graph input) directly.'
157+
)
158+
raise ValueError(msg)
138159
gather_item = self.gather_item_task
139160
for name in sockets:
140161
gather_item.add_input_spec('workgraph.any', name=name)

0 commit comments

Comments
 (0)