Skip to content

Commit 4013df1

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 5cccb31 commit 4013df1

4 files changed

Lines changed: 95 additions & 2 deletions

File tree

src/aiida_workgraph/engine/task_state.py

Lines changed: 26 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

@@ -379,6 +379,31 @@ def update_map_task_state(self, name: str) -> None:
379379
default=None,
380380
)
381381
self.ctx._task_results[name][link.to_socket._name] = results
382+
# Persist the gathered-result PKs on the process node so the client
383+
# can reconstruct zone outputs after wg.update(). Zone tasks have no
384+
# process node of their own, so without this the client cannot recover
385+
# the per-prefix result nodes.
386+
#
387+
# Only prefixes whose gathered value is a single stored node are
388+
# persisted. A prefix that gathered None (an untaken `If` branch, or a
389+
# source that produced no value for this socket) has no node to
390+
# reference; one whose source output is itself a namespace gathers a
391+
# nested dict rather than a leaf node. Both are absent from the client
392+
# namespace, so a structured-output gather still reads back empty. How
393+
# to reconstruct those is the same resilient-Map follow-up flagged in
394+
# the gather loop above.
395+
result_pks: dict[str, dict[str, int]] = {}
396+
for socket_name, val in self.ctx._task_results[name].items():
397+
if socket_name.startswith('_'):
398+
continue
399+
if isinstance(val, dict):
400+
result_pks[socket_name] = {
401+
prefix: node.pk for prefix, node in val.items() if isinstance(node, Node) and node.pk is not None
402+
}
403+
if result_pks:
404+
map_info = self.process.node.get_task_map_info(name) or {}
405+
map_info['result_pks'] = result_pks
406+
self.set_task_runtime_info(name, 'map_info', map_info)
382407
self.set_task_runtime_info(name, 'state', TaskState.FINISHED)
383408
self.process.report(f'Task: {name} finished.')
384409
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
@@ -159,7 +159,13 @@ def gather(self, sockets: Dict[str, BaseSocket]) -> BaseSocket:
159159
gather_item = self.gather_item_task
160160
for name in sockets:
161161
gather_item.add_input_spec('workgraph.any', name=name)
162-
self.add_output_spec('workgraph.namespace', name=name)
162+
# The gathered output namespace must be dynamic so the client
163+
# can populate it with per-prefix keys after the run completes.
164+
self.add_output_spec(
165+
'workgraph.namespace',
166+
name=name,
167+
meta=SocketMeta(dynamic=True),
168+
)
163169
gather_item.set_inputs(sockets)
164170
return gather_item.outputs
165171

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: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,8 @@ def test_map_zone_failed_iteration_fails_the_zone():
141141
assert map_zone.state == 'FAILED'
142142
assert wg.process.exit_status == 302
143143
assert 'key_1_maybe_fail' in wg.process.exit_message
144+
# A failed zone must expose nothing rather than a short namespace.
145+
assert map_zone.outputs.sum1._value == {}
144146

145147

146148
def test_map_zone_failed_iteration_skips_downstream():
@@ -276,3 +278,31 @@ def test_map_gather_rejects_atomically():
276278
out = calc_sum(data=map_zone.outputs.a).result
277279
wg.run()
278280
assert out.value == 1 # (0+0) + (1+0)
281+
282+
283+
def test_map_zone_outputs_visible_to_client():
284+
"""Gathered zone outputs are readable from the client after the run.
285+
286+
A Map zone has no process node of its own, so ``Task.update_state`` cannot
287+
populate its outputs and ``map_zone.outputs.<name>`` used to come back empty
288+
even though the engine had gathered the results. The engine now persists the
289+
gathered node PKs in ``task_map_info``, which the client reads back. The
290+
in-session check confirms the outputs are readable straight after ``run()``;
291+
the ``WorkGraph.load`` check confirms they survive a fresh reload from the
292+
database. Both go through the same ``result_pks`` reconstruction, so the
293+
reload is the stronger of the two.
294+
"""
295+
n = 3
296+
with WorkGraph('map_zone_outputs') as wg:
297+
data = generate_data(n=n).data
298+
with Map(data) as map_zone:
299+
out1 = add(x=map_zone.value, y=1).result
300+
map_zone.gather({'sum1': out1})
301+
wg.run()
302+
303+
expected = {'key_0': 1, 'key_1': 2, 'key_2': 3}
304+
gathered = map_zone.outputs.sum1._value
305+
assert {prefix: node.value for prefix, node in gathered.items()} == expected
306+
307+
reloaded = WorkGraph.load(wg.process.pk).tasks[map_zone.name].outputs.sum1._value
308+
assert {prefix: node.value for prefix, node in reloaded.items()} == expected

0 commit comments

Comments
 (0)