Skip to content

Commit c7e7486

Browse files
graph: address PR #5 review
CodeQL noise (10 threads): replace bare `...` Protocol/stub bodies with explicit forms. `Observer.__call__` gets a docstring + `raise NotImplementedError` (so pyright accepts the declared return type, same pattern as Protocol bodies fixed in #4); test stub observers get `pass`. Add an explanatory comment to `RemoveHandle.remove`'s `try/except ValueError: pass` documenting the idempotency intent the docstring already promises. Logic (2 threads): - `_active_workers` switched from list to set and each per- invocation worker now registers add_done_callback( self._active_workers.discard). Long-running services that never call drain() no longer accumulate completed Task references indefinitely. drain() simplifies to a one-line asyncio.gather over a snapshot of the set. - SubgraphNode branch in _invoke now wraps non-RuntimeGraphError exceptions from node.run(state, context=context) as NodeException tagged with the wrapper's name. Projection errors (project_in / project_out) and subgraph state-class init errors (e.g. Pydantic ValidationError) were previously propagating raw, bypassing the spec §4 error contract. Already- wrapped errors from inside the subgraph's _invoke pass through unchanged so the inner node's identity stays attached. Adds test_subgraph_projection_error_wrapped_as_node_exception covering the case.
1 parent 9d1538b commit c7e7486

4 files changed

Lines changed: 100 additions & 18 deletions

File tree

src/openarmature/graph/compiled.py

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,10 @@ class CompiledGraph[StateT: State]:
114114
# dataclass: the list reference is fixed but its contents change.
115115
# Parameterized factories so pyright infers the element types.
116116
_attached_observers: list[Observer] = field(default_factory=list[Observer])
117-
_active_workers: list[asyncio.Task[None]] = field(default_factory=list[asyncio.Task[None]])
117+
# `set` (not list) so a per-task `add_done_callback(self._active_workers.discard)`
118+
# auto-removes completed workers — long-running services that never call
119+
# drain() don't accumulate completed Task references indefinitely.
120+
_active_workers: set[asyncio.Task[None]] = field(default_factory=set[asyncio.Task[None]])
118121

119122
# ------------------------------------------------------------------
120123
# Observer registration (spec v0.3.0 §6)
@@ -151,11 +154,10 @@ async def drain(self) -> None:
151154
"""
152155
if not self._active_workers:
153156
return
154-
pending = list(self._active_workers)
155-
await asyncio.gather(*pending, return_exceptions=True)
156-
for w in pending:
157-
if w in self._active_workers:
158-
self._active_workers.remove(w)
157+
# Snapshot the set: each worker's done-callback removes itself
158+
# from `_active_workers`, so iterating it directly while gather
159+
# awaits would mutate during iteration.
160+
await asyncio.gather(*list(self._active_workers), return_exceptions=True)
159161

160162
# ------------------------------------------------------------------
161163
# Public invocation
@@ -188,7 +190,11 @@ async def invoke(
188190
invocation_scoped=invocation_scoped,
189191
)
190192
worker = asyncio.create_task(deliver_loop(queue))
191-
self._active_workers.append(worker)
193+
self._active_workers.add(worker)
194+
# Auto-prune: when the worker completes (after the sentinel is
195+
# processed), remove it from the active set so long-running
196+
# services don't leak Task references between drain() calls.
197+
worker.add_done_callback(self._active_workers.discard)
192198
try:
193199
return await self._invoke(initial_state, context)
194200
finally:
@@ -225,9 +231,18 @@ async def _invoke(
225231
# Subgraph wrappers are transparent to the observer protocol
226232
# (per fixture 013): no event is dispatched for the wrapper
227233
# itself, the step counter does not advance for it, and any
228-
# exception bubbling up from the subgraph's _invoke is
229-
# already wrapped with the inner node's identity.
230-
partial = await node.run(state, context=context)
234+
# `RuntimeGraphError` bubbling up from the subgraph's
235+
# _invoke is already wrapped with the inner node's identity
236+
# — pass it through. Other exceptions (projection errors,
237+
# subgraph state-class init errors) escape the spec §4
238+
# categories, so we wrap them as NodeException tagged with
239+
# the wrapper's name.
240+
try:
241+
partial = await node.run(state, context=context)
242+
except RuntimeGraphError:
243+
raise
244+
except Exception as e:
245+
raise NodeException(node_name=current, cause=e, recoverable_state=state) from e
231246
state = _merge_partial(state, partial, self.reducers, current)
232247
else:
233248
state = await self._step_function_node(node, current, state, context)

src/openarmature/graph/observer.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,9 @@ class Observer(Protocol):
4444
`event`, `_event`, or any other name.
4545
"""
4646

47-
async def __call__(self, event: NodeEvent, /) -> None: ...
47+
async def __call__(self, event: NodeEvent, /) -> None:
48+
"""Receive a single node-boundary event."""
49+
raise NotImplementedError
4850

4951

5052
@dataclass(frozen=True)
@@ -64,6 +66,9 @@ def remove(self) -> None:
6466
try:
6567
self._observers.remove(self._observer)
6668
except ValueError:
69+
# Idempotency: the observer is already detached. Per the
70+
# docstring, a second .remove() call is a no-op rather than
71+
# an error.
6772
pass
6873

6974

tests/unit/test_observer.py

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -174,8 +174,11 @@ async def test_dispatch_skips_when_no_observers_for_depth() -> None:
174174

175175

176176
async def test_dispatch_enqueues_with_full_observer_chain_in_order() -> None:
177-
async def graph_obs(_event: NodeEvent) -> None: ...
178-
async def invocation_obs(_event: NodeEvent) -> None: ...
177+
async def graph_obs(_event: NodeEvent) -> None:
178+
pass
179+
180+
async def invocation_obs(_event: NodeEvent) -> None:
181+
pass
179182

180183
queue: asyncio.Queue[_QueuedItem | None] = asyncio.Queue()
181184
ctx = _InvocationContext(queue=queue, graph_attached=(graph_obs,), invocation_scoped=(invocation_obs,))
@@ -192,9 +195,14 @@ async def invocation_obs(_event: NodeEvent) -> None: ...
192195

193196

194197
async def test_descend_extends_chain_namespace_and_parent_states() -> None:
195-
async def outer_obs(_event: NodeEvent) -> None: ...
196-
async def sub_obs(_event: NodeEvent) -> None: ...
197-
async def invocation_obs(_event: NodeEvent) -> None: ...
198+
async def outer_obs(_event: NodeEvent) -> None:
199+
pass
200+
201+
async def sub_obs(_event: NodeEvent) -> None:
202+
pass
203+
204+
async def invocation_obs(_event: NodeEvent) -> None:
205+
pass
198206

199207
queue: asyncio.Queue[_QueuedItem | None] = asyncio.Queue()
200208
outer = _InvocationContext(queue=queue, graph_attached=(outer_obs,), invocation_scoped=(invocation_obs,))
@@ -227,7 +235,8 @@ async def test_take_step_shares_counter_across_descended_contexts() -> None:
227235

228236

229237
def test_remove_handle_detaches_observer() -> None:
230-
async def obs(_event: NodeEvent) -> None: ...
238+
async def obs(_event: NodeEvent) -> None:
239+
pass
231240

232241
observers: list[Observer] = [obs]
233242
handle = RemoveHandle(_observers=observers, _observer=obs)
@@ -238,7 +247,8 @@ async def obs(_event: NodeEvent) -> None: ...
238247

239248

240249
def test_remove_handle_is_idempotent() -> None:
241-
async def obs(_event: NodeEvent) -> None: ...
250+
async def obs(_event: NodeEvent) -> None:
251+
pass
242252

243253
observers: list[Observer] = [obs]
244254
handle = RemoveHandle(_observers=observers, _observer=obs)

tests/unit/test_runtime_errors.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,55 @@ async def node_a(_state: Any) -> dict[str, Any]:
9696
err = excinfo.value
9797
assert err.category == "state_validation_error"
9898
assert "undeclared" in err.fields
99+
100+
101+
async def test_subgraph_projection_error_wrapped_as_node_exception() -> None:
102+
"""Errors from a subgraph's projection (project_in / project_out) are
103+
NOT spec §4 categories on their own. The engine wraps them as
104+
NodeException tagged with the subgraph wrapper's name so callers see
105+
a uniform error contract."""
106+
107+
from openarmature.graph import NodeException, ProjectionStrategy
108+
109+
class Inner(State):
110+
x: int = 0
111+
112+
async def _inner_node(_s: Inner) -> dict[str, Any]:
113+
return {}
114+
115+
inner_g = GraphBuilder(Inner).add_node("i", _inner_node).add_edge("i", END).set_entry("i").compile()
116+
117+
# Parameter names match the ProjectionStrategy Protocol exactly so
118+
# pyright's strict structural conformance check passes.
119+
class BoomProjection:
120+
def project_in(self, parent_state: S, subgraph_state_cls: type[Inner]) -> Inner:
121+
raise RuntimeError("project_in boom")
122+
123+
def project_out(
124+
self,
125+
subgraph_final_state: Inner,
126+
parent_state: S,
127+
subgraph_state_cls: type[Inner],
128+
) -> dict[str, Any]:
129+
return {}
130+
131+
_: ProjectionStrategy[S, Inner] = BoomProjection()
132+
133+
g = (
134+
GraphBuilder(S)
135+
.add_subgraph_node("sub", inner_g, projection=BoomProjection())
136+
.add_edge("sub", END)
137+
.set_entry("sub")
138+
.compile()
139+
)
140+
141+
with pytest.raises(NodeException) as excinfo:
142+
await g.invoke(S())
143+
144+
err = excinfo.value
145+
assert err.category == "node_exception"
146+
# The wrapper's name, not the inner node's — projection is at the
147+
# boundary, not inside the subgraph.
148+
assert err.node_name == "sub"
149+
assert isinstance(err.__cause__, RuntimeError)
150+
assert str(err.__cause__) == "project_in boom"

0 commit comments

Comments
 (0)