Skip to content

Commit cb9c52b

Browse files
committed
Map: expose gathered zone outputs to the client
Zone/Map tasks have no AiiDA process node (`pk=None`), so `Task.update_state` could never populate their outputs on the client side. After `wg.run()`, `map_zone.outputs.<name>` returned an empty dict even though the engine had the correct gathered results. Three changes: - `gather()` creates dynamic output namespaces so the client can assign per-prefix keys after the run. - `update_map_task_state` persists the gathered result node PKs in `task_map_info[name]['result_pks']` on the process node. - `WorkGraph.update()` reads those PKs back and populates the zone task's output sockets via `_populate_zone_outputs`. The regression test checks the gathered outputs both in-session and after a fresh `WorkGraph.load`, since only the latter goes purely through the persisted state.
1 parent aafd9c9 commit cb9c52b

4 files changed

Lines changed: 86 additions & 2 deletions

File tree

src/aiida_workgraph/engine/task_state.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from typing_extensions import assert_never
44
from aiida.orm.utils.serialize import serialize
55
from aiida_workgraph.orm.utils import deserialize_safe
6-
from aiida.orm import ProcessNode, Data
6+
from aiida.orm import Data, Node, ProcessNode
77
from aiida_workgraph.enums import TERMINAL_TASK_STATES, RuntimeInfoKey, TaskState
88
from node_graph.socket import BaseSocket, TaskSocketNamespace
99

@@ -350,6 +350,24 @@ def update_map_task_state(self, name: str) -> None:
350350
default=None,
351351
)
352352
self.ctx._task_results[name][link.to_socket._name] = results
353+
# Persist the gathered-result PKs on the process node so the client
354+
# can reconstruct zone outputs after wg.update(). Zone tasks have no
355+
# process node of their own, so without this the client cannot recover
356+
# the per-prefix result nodes.
357+
#
358+
# Unstored nodes have no pk, so they are skipped rather than persisted.
359+
result_pks: dict[str, dict[str, int]] = {}
360+
for socket_name, val in self.ctx._task_results[name].items():
361+
if socket_name.startswith('_'):
362+
continue
363+
if isinstance(val, dict):
364+
result_pks[socket_name] = {
365+
prefix: node.pk for prefix, node in val.items() if isinstance(node, Node) and node.pk is not None
366+
}
367+
if result_pks:
368+
map_info = self.process.node.get_task_map_info(name) or {}
369+
map_info['result_pks'] = result_pks
370+
self.set_task_runtime_info(name, 'map_info', map_info)
353371
self.set_task_runtime_info(name, 'state', TaskState.FINISHED)
354372
self.process.report(f'Task: {name} finished.')
355373
self.update_meta_tasks(name)

src/aiida_workgraph/tasks/builtins.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,13 @@ def gather(self, sockets: Dict[str, BaseSocket]) -> BaseSocket:
138138
gather_item = self.gather_item_task
139139
for name in sockets:
140140
gather_item.add_input_spec('workgraph.any', name=name)
141-
self.add_output_spec('workgraph.namespace', name=name)
141+
# The gathered output namespace must be dynamic so the client
142+
# can populate it with per-prefix keys after the run completes.
143+
self.add_output_spec(
144+
'workgraph.namespace',
145+
name=name,
146+
meta=SocketMeta(dynamic=True),
147+
)
142148
gather_item.set_inputs(sockets)
143149
return gather_item.outputs
144150

src/aiida_workgraph/workgraph.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,13 +323,45 @@ def update(self) -> None:
323323
continue
324324
self.tasks[name].update_state(data)
325325

326+
# Zone/Map tasks have no process node (pk=None), so update_state
327+
# cannot populate their outputs. The engine persists the gathered
328+
# result PKs in task_map_info; reconstruct outputs from those.
329+
self._populate_zone_outputs(processes_data)
330+
326331
if self.widget is not None:
327332
states = {name: data['state'] for name, data in processes_data.items()}
328333
self.widget.states = states
329334

330335
if self.process.is_finished_ok:
331336
self.outputs._set_socket_value(resolve_node_link_managers(self.process.outputs))
332337

338+
def _populate_zone_outputs(self, processes_data: Dict[str, Any]) -> None:
339+
"""Populate outputs for zone/Map tasks from persisted result PKs.
340+
341+
Zone tasks have no AiiDA process node, so ``Task.update_state`` cannot
342+
load their outputs. The engine persists the gathered result node PKs in
343+
``task_map_info[name]['result_pks']``; this method loads those nodes and
344+
sets them on the corresponding output sockets.
345+
"""
346+
import aiida.orm
347+
348+
for name, data in processes_data.items():
349+
if name not in self.tasks:
350+
continue
351+
# Only handle tasks with no process node that are finished.
352+
if data['pk'] is not None or data['state'] != TaskState.FINISHED:
353+
continue
354+
map_info = self.process.get_task_map_info(name)
355+
if not map_info:
356+
continue
357+
result_pks = map_info.get('result_pks', {})
358+
task = self.tasks[name]
359+
for socket_name, pk_map in result_pks.items():
360+
if socket_name not in task.outputs._sockets:
361+
continue
362+
values = {prefix: aiida.orm.load_node(pk) for prefix, pk in pk_map.items()}
363+
task.outputs[socket_name]._value = values
364+
333365
@property
334366
def pk(self) -> Optional[int]:
335367
return self.process.pk if self.process else None

tests/test_map.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,3 +127,31 @@ def test_map_zone_failed_iteration_fails_the_zone():
127127
assert map_zone.state == 'FAILED'
128128
assert wg.process.exit_status == 302
129129
assert 'key_1_maybe_fail' in wg.process.exit_message
130+
# A failed zone must expose nothing rather than a short namespace.
131+
assert map_zone.outputs.sum1._value == {}
132+
133+
134+
def test_map_zone_outputs_visible_to_client():
135+
"""Gathered zone outputs are readable from the client after the run.
136+
137+
A Map zone has no process node of its own, so ``Task.update_state`` cannot
138+
populate its outputs and ``map_zone.outputs.<name>`` used to come back empty
139+
even though the engine had gathered the results. The engine now persists the
140+
gathered node PKs in ``task_map_info``, which the client reads back. Checked
141+
both in-session and via a fresh ``WorkGraph.load``, since only the latter
142+
goes purely through the persisted state.
143+
"""
144+
n = 3
145+
with WorkGraph('map_zone_outputs') as wg:
146+
data = generate_data(n=n).data
147+
with Map(data) as map_zone:
148+
out1 = add(x=map_zone.value, y=1).result
149+
map_zone.gather({'sum1': out1})
150+
wg.run()
151+
152+
expected = {'key_0': 1, 'key_1': 2, 'key_2': 3}
153+
gathered = map_zone.outputs.sum1._value
154+
assert {prefix: node.value for prefix, node in gathered.items()} == expected
155+
156+
reloaded = WorkGraph.load(wg.process.pk).tasks[map_zone.name].outputs.sum1._value
157+
assert {prefix: node.value for prefix, node in reloaded.items()} == expected

0 commit comments

Comments
 (0)