|
1 | 1 | from __future__ import annotations |
2 | | -from typing import Optional, Tuple, List, Any |
| 2 | +from typing import Optional, Tuple, List, Any, Iterator, TYPE_CHECKING |
3 | 3 | from typing_extensions import assert_never |
4 | 4 | from aiida.orm.utils.serialize import serialize |
5 | 5 | from aiida_workgraph.orm.utils import deserialize_safe |
6 | 6 | from aiida.orm import ProcessNode, Data |
7 | 7 | from aiida_workgraph.enums import TERMINAL_TASK_STATES, RuntimeInfoKey, TaskState |
8 | 8 | from node_graph.socket import BaseSocket, TaskSocketNamespace |
9 | 9 |
|
| 10 | +if TYPE_CHECKING: |
| 11 | + from aiida_workgraph.task import Task |
| 12 | + |
10 | 13 |
|
11 | 14 | class TaskStateManager: |
12 | 15 | """ |
@@ -291,49 +294,104 @@ def update_zone_task_state(self, name: str) -> None: |
291 | 294 | self.process.report(f'Task: {name} finished.') |
292 | 295 | self.update_parent_task_state(name) |
293 | 296 |
|
| 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 | + |
294 | 310 | def update_map_task_state(self, name: str) -> None: |
295 | 311 | """Update the map task state. |
296 | 312 | 1) check if all child tasks are finished. |
297 | 313 | 2) gather the results of all the mapped tasks. |
298 | 314 | 3) update the parent task state. |
299 | 315 | """ |
| 316 | + from aiida_workgraph.utils import get_nested_dict |
| 317 | + |
300 | 318 | 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 | + ) |
317 | 366 | 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) |
318 | 386 |
|
319 | 387 | def update_template_task_state(self, name: str) -> None: |
320 | 388 | """Update the template task state. |
321 | 389 | 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. |
324 | 391 | """ |
325 | 392 | finished, _ = self.are_childen_finished(name) |
326 | 393 | 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 |
335 | 394 | self.set_task_runtime_info(name, 'state', TaskState.FINISHED) |
336 | | - # self.update_meta_tasks(name) |
337 | 395 | self.process.report(f'Task: {name} finished.') |
338 | 396 | self.update_parent_task_state(name) |
339 | 397 |
|
|
0 commit comments