Skip to content

Commit 15c7298

Browse files
graph: spec v0.4 middleware (proposal 0004) — phase 2
Implements pipeline-utilities §2-§7: middleware infrastructure plus the two canonical middleware classes (Retry and Timing). Lands 16 of the 18 PU middleware fixtures (001-016); fixtures 020-021 remain skipped because they compose middleware with fan-out which arrives in Phase 3. Engine plumbing: - middleware/_core.py: Middleware Protocol, NextCall alias, compose_chain helper assembling outer-to-inner chains. - nodes.py / subgraph.py: each node carries a per-node middleware tuple; SubgraphNode treats its tuple as wrapping the dispatch as a single atomic call per §4 isolation. - builder.py: GraphBuilder.add_middleware for per-graph; per-node middleware via add_node(..., middleware=[...]) and add_subgraph_node(..., middleware=[...]). - compiled.py: _step_function_node + _step_subgraph_node build the runtime chain, inject the per-attempt event-dispatch innermost, and let raw exceptions propagate through the chain so middleware classifiers see the original exception's `category` attribute. The engine wraps any exception that escapes the chain as NodeException per §4. Canonical middleware (middleware/retry.py, middleware/timing.py): - RetryMiddleware with configurable max_attempts, classifier (default matches `provider_rate_limit`, `provider_unavailable`, `provider_model_not_loaded` — hardcoded canonical strings, loose-coupled to llm-provider §7), backoff (exponential with full jitter; deterministic_backoff factory for tests), on_retry callback. CancelledError propagates via `except Exception` (Python's CancelledError extends BaseException). - TimingMiddleware with monotonic-clock duration measurement, per-node node_name binding, on_complete callback that receives a TimingRecord. Per-instance clock injection (defaulting to time.monotonic) lets test fixtures supply a deterministic stub without globally patching time.monotonic. Conformance harness: - middleware_seam.py: test-only middleware (TraceRecorder, ShortCircuit, ErrorRecovery, ErrorRaiser, StateInspector) used by the basic firing / composition / short-circuit / error- recovery fixtures. - test_pipeline_utilities.py: loads PU fixtures, translates middleware blocks into instances, supports cases-shape fixtures (014, 016), determinism re-runs (011), state-aware classifiers (016), and the deterministic-monotonic clock_stub. - adapter.py: extends build_graph with graph_middleware and node_middleware kwargs and adds the `flaky` node directive. Unit tests (test_middleware.py): chain composition + ordering, short-circuit, pre/post phase symmetry, retry counting + classifier, retry cancellation propagation, general error recovery, timing on success and failure, subgraph isolation, default classifier behavior across direct / via-cause / non-transient cases. Total tests: 246 passing, 1 skipped (017 fan-out, Phase 3).
1 parent fa74c5a commit 15c7298

13 files changed

Lines changed: 1788 additions & 37 deletions

File tree

src/openarmature/graph/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,14 @@
2626
UnreachableNode,
2727
)
2828
from .events import NodeEvent
29+
from .middleware import (
30+
Middleware,
31+
RetryMiddleware,
32+
TimingMiddleware,
33+
TimingRecord,
34+
deterministic_backoff,
35+
exponential_jitter_backoff,
36+
)
2937
from .nodes import FunctionNode, Node
3038
from .observer import Observer, RemoveHandle, SubscribedObserver
3139
from .projection import ExplicitMapping, FieldNameMatching, ProjectionStrategy
@@ -48,6 +56,7 @@
4856
"GraphBuilder",
4957
"GraphError",
5058
"MappingReferencesUndeclaredField",
59+
"Middleware",
5160
"MultipleOutgoingEdges",
5261
"Node",
5362
"NodeEvent",
@@ -58,15 +67,20 @@
5867
"Reducer",
5968
"ReducerError",
6069
"RemoveHandle",
70+
"RetryMiddleware",
6171
"RoutingError",
6272
"RuntimeGraphError",
6373
"State",
6474
"StateValidationError",
6575
"StaticEdge",
6676
"SubgraphNode",
6777
"SubscribedObserver",
78+
"TimingMiddleware",
79+
"TimingRecord",
6880
"UnreachableNode",
6981
"append",
82+
"deterministic_backoff",
83+
"exponential_jitter_backoff",
7084
"last_write_wins",
7185
"merge",
7286
]

src/openarmature/graph/builder.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
a type-checked `state` parameter on every callback — without `cast(...)` calls.
1111
"""
1212

13-
from collections.abc import Awaitable, Callable, Mapping
13+
from collections.abc import Awaitable, Callable, Iterable, Mapping
1414
from typing import Any, Self, cast
1515

1616
from .compiled import CompiledGraph
@@ -22,6 +22,7 @@
2222
NoDeclaredEntry,
2323
UnreachableNode,
2424
)
25+
from .middleware import Middleware
2526
from .nodes import FunctionNode, Node
2627
from .projection import FieldNameMatching, ProjectionStrategy
2728
from .reducers import Reducer
@@ -37,29 +38,56 @@ def __init__(self, state_cls: type[StateT]) -> None:
3738
self._nodes: dict[str, Node[StateT]] = {}
3839
self._edges: list[StaticEdge | ConditionalEdge[StateT]] = []
3940
self._entry: str | None = None
41+
# Per-graph middleware in registration order (outer-to-inner).
42+
# Composed OUTSIDE per-node middleware at runtime per spec §3.
43+
self._middleware: list[Middleware] = []
4044

4145
def add_node(
4246
self,
4347
name: str,
4448
fn: Callable[[StateT], Awaitable[Mapping[str, Any]]],
49+
*,
50+
middleware: Iterable[Middleware] | None = None,
4551
) -> Self:
4652
if name in self._nodes:
4753
raise ValueError(f"node {name!r} already declared")
48-
self._nodes[name] = FunctionNode[StateT](name=name, fn=fn)
54+
self._nodes[name] = FunctionNode[StateT](
55+
name=name,
56+
fn=fn,
57+
middleware=tuple(middleware) if middleware is not None else (),
58+
)
4959
return self
5060

5161
def add_subgraph_node[ChildT: State](
5262
self,
5363
name: str,
5464
compiled: CompiledGraph[ChildT],
5565
projection: ProjectionStrategy[StateT, ChildT] | None = None,
66+
*,
67+
middleware: Iterable[Middleware] | None = None,
5668
) -> Self:
5769
if name in self._nodes:
5870
raise ValueError(f"node {name!r} already declared")
5971
proj: ProjectionStrategy[StateT, ChildT] = (
6072
projection if projection is not None else FieldNameMatching[StateT, ChildT]()
6173
)
62-
self._nodes[name] = SubgraphNode[StateT, ChildT](name=name, compiled=compiled, projection=proj)
74+
self._nodes[name] = SubgraphNode[StateT, ChildT](
75+
name=name,
76+
compiled=compiled,
77+
projection=proj,
78+
middleware=tuple(middleware) if middleware is not None else (),
79+
)
80+
return self
81+
82+
def add_middleware(self, middleware: Middleware) -> Self:
83+
"""Register a per-graph middleware applied to every node in this graph.
84+
85+
Per spec pipeline-utilities §3: per-graph middleware composes
86+
OUTSIDE per-node middleware. Calling order is preserved
87+
(outer-to-inner) — earlier ``add_middleware`` calls produce
88+
outer layers in the runtime chain.
89+
"""
90+
self._middleware.append(middleware)
6391
return self
6492

6593
def add_edge(self, source: str, target: str | EndSentinel) -> Self:
@@ -142,6 +170,7 @@ def compile(self) -> CompiledGraph[StateT]:
142170
nodes=dict(self._nodes),
143171
edges=edges_by_source,
144172
reducers=resolved,
173+
middleware=tuple(self._middleware),
145174
)
146175

147176
def _reachable_nodes(

src/openarmature/graph/compiled.py

Lines changed: 152 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
import asyncio
2525
from collections.abc import Iterable, Mapping
2626
from dataclasses import dataclass, field
27-
from typing import Any
27+
from typing import Any, cast
2828

2929
from pydantic import ValidationError
3030

@@ -38,6 +38,7 @@
3838
StateValidationError,
3939
)
4040
from .events import NodeEvent
41+
from .middleware import ChainCall, Middleware, compose_chain
4142
from .nodes import Node
4243
from .observer import (
4344
_DRAIN_SENTINEL,
@@ -113,6 +114,9 @@ class CompiledGraph[StateT: State]:
113114
nodes: Mapping[str, Node[StateT]]
114115
edges: Mapping[str, StaticEdge | ConditionalEdge[StateT]]
115116
reducers: Mapping[str, Reducer]
117+
# Per-graph middleware in registration order (outer-to-inner). Composes
118+
# OUTSIDE per-node middleware at runtime per pipeline-utilities §3.
119+
middleware: tuple[Middleware, ...] = ()
116120
# Observer plumbing — see attach_observer/drain. Mutable on a frozen
117121
# dataclass: the list reference is fixed but its contents change.
118122
# Parameterized factories so pyright infers the element types.
@@ -263,13 +267,17 @@ async def _invoke(
263267
# subgraph state-class init errors) escape the spec §4
264268
# categories, so we wrap them as NodeException tagged with
265269
# the wrapper's name.
266-
try:
267-
partial = await node.run(state, context=context)
268-
except RuntimeGraphError:
269-
raise
270-
except Exception as e:
271-
raise NodeException(node_name=current, cause=e, recoverable_state=state) from e
272-
state = _merge_partial(state, partial, self.reducers, current)
270+
#
271+
# Per pipeline-utilities §4: the parent's middleware wraps
272+
# the subgraph dispatch as a single atomic call. Subgraph-
273+
# internal nodes have their own middleware (from the
274+
# subgraph's own CompiledGraph.middleware tuple) and do
275+
# NOT see the parent's middleware. Cast erases ChildT
276+
# because the dispatcher only needs to invoke `node.run`
277+
# and pass the parent's chain — the inner state class
278+
# lives on the subgraph's own CompiledGraph.
279+
sub = cast("SubgraphNode[StateT, State]", node)
280+
state = await self._step_subgraph_node(sub, current, state, context)
273281
else:
274282
state = await self._step_function_node(node, current, state, context)
275283

@@ -297,36 +305,146 @@ async def _step_function_node(
297305
state: StateT,
298306
context: _InvocationContext,
299307
) -> StateT:
300-
"""Run one function-node step: take a step, dispatch started, run,
301-
merge, dispatch completed.
302-
303-
Per spec v0.6.0 §6: each attempt produces a started/completed pair.
304-
Both events share the same `step`. The completed event carries
305-
`post_state` on success, or `error` on failure (one of run, reducer,
306-
or state-validation). The completed event is dispatched before the
307-
failure propagates.
308+
"""Run one function-node step through the middleware chain.
309+
310+
Per pipeline-utilities §3, the runtime chain composes:
311+
312+
[per_graph...] -> [per_node...] -> innermost
313+
314+
where ``innermost`` is the per-attempt dispatch wrapper around
315+
``node.run`` + reducer merge + observer event dispatch. Each call
316+
to ``innermost`` is one attempt; middleware that calls ``next``
317+
repeatedly (e.g., retry) produces multiple attempts and therefore
318+
multiple started/completed event pairs from the engine, each
319+
tagged with an incrementing ``attempt_index`` (graph-engine §6).
320+
321+
The chain is built fresh per dispatch so each step has its own
322+
attempt counter. Subgraph isolation per pipeline-utilities §4 is
323+
achieved by NOT including the parent's per-graph middleware when
324+
the subgraph's own ``_step_function_node`` runs — each
325+
CompiledGraph carries its own ``middleware`` tuple.
308326
"""
309327
step = context.take_step()
310328
namespace = context.namespace_prefix + (current,)
311-
pre_state = state
312329

313-
self._dispatch_started(context, current, namespace, step, pre_state)
330+
# The innermost layer dispatches the per-attempt event pair around a
331+
# single call to ``node.run``. Each call to this inner increments
332+
# the attempt counter; middleware composes around it.
333+
attempt_counter = [0]
334+
335+
async def innermost(s: Any) -> Mapping[str, Any]:
336+
# Per pipeline-utilities §5 + graph-engine §6: per-attempt
337+
# events use the wrapped §4 error type (NodeException etc.)
338+
# for the observer's `error` field, but the RAW exception
339+
# propagates up the chain so middleware classifiers can read
340+
# the original `category` attribute (timing's
341+
# exception_category, retry's classifier). The engine wraps
342+
# any exception that escapes the chain, OUTSIDE this layer.
343+
attempt_index = attempt_counter[0]
344+
attempt_counter[0] = attempt_index + 1
345+
346+
self._dispatch_started(context, current, namespace, step, s, attempt_index=attempt_index)
347+
348+
try:
349+
partial = await node.run(s)
350+
except Exception as e:
351+
wrapped = NodeException(node_name=current, cause=e, recoverable_state=s)
352+
self._dispatch_completed(
353+
context,
354+
current,
355+
namespace,
356+
step,
357+
s,
358+
error=wrapped,
359+
attempt_index=attempt_index,
360+
)
361+
raise
362+
363+
try:
364+
merged = _merge_partial(s, partial, self.reducers, current)
365+
except (ReducerError, StateValidationError) as e:
366+
self._dispatch_completed(
367+
context,
368+
current,
369+
namespace,
370+
step,
371+
s,
372+
error=e,
373+
attempt_index=attempt_index,
374+
)
375+
raise
376+
377+
self._dispatch_completed(
378+
context,
379+
current,
380+
namespace,
381+
step,
382+
s,
383+
post_state=merged,
384+
attempt_index=attempt_index,
385+
)
386+
# Return the partial (not the merged state) so middleware sees
387+
# the partial-update shape per pipeline-utilities §2. The
388+
# engine's canonical merge against the original state happens
389+
# below, after the chain returns.
390+
return partial
391+
392+
chain: ChainCall = compose_chain(
393+
list(self.middleware) + list(node.middleware),
394+
innermost,
395+
)
314396

315397
try:
316-
partial = await node.run(state)
398+
final_partial = await chain(state)
399+
except RuntimeGraphError:
400+
raise
317401
except Exception as e:
318-
wrapped = NodeException(node_name=current, cause=e, recoverable_state=state)
319-
self._dispatch_completed(context, current, namespace, step, pre_state, error=wrapped)
320-
raise wrapped from e
402+
# A raw exception (node-raised or middleware-raised) escaped
403+
# the chain unrecovered. Wrap as NodeException per §4.
404+
raise NodeException(node_name=current, cause=e, recoverable_state=state) from e
405+
# Engine's canonical merge uses the ORIGINAL state per §2: "the
406+
# transformed state is passed to ``next``, NOT to the engine's
407+
# merge step." If middleware transformed state mid-chain, the
408+
# per-attempt completed events showed the transformed merge for
409+
# observability, but the state advancing the graph loop is built
410+
# from the original.
411+
return _merge_partial(state, final_partial, self.reducers, current)
412+
413+
async def _step_subgraph_node(
414+
self,
415+
node: SubgraphNode[StateT, State],
416+
current: str,
417+
state: StateT,
418+
context: _InvocationContext,
419+
) -> StateT:
420+
"""Run one subgraph-as-node step through the parent's middleware chain.
321421
322-
try:
323-
new_state = _merge_partial(state, partial, self.reducers, current)
324-
except (ReducerError, StateValidationError) as e:
325-
self._dispatch_completed(context, current, namespace, step, pre_state, error=e)
326-
raise
422+
Per pipeline-utilities §4: the parent's per-graph middleware plus
423+
any per-node middleware on the SubgraphNode wraps the subgraph
424+
dispatch as a single atomic call. The subgraph's INTERNAL nodes
425+
get their own middleware via the subgraph's own CompiledGraph;
426+
parent middleware does NOT cross the boundary.
327427
328-
self._dispatch_completed(context, current, namespace, step, pre_state, post_state=new_state)
329-
return new_state
428+
No started/completed events fire for the wrapper itself; the
429+
events come from the subgraph's internal node executions (per
430+
fixture 013).
431+
"""
432+
433+
async def innermost(s: Any) -> Mapping[str, Any]:
434+
try:
435+
return await node.run(s, context=context)
436+
except RuntimeGraphError:
437+
raise
438+
except Exception as e:
439+
raise NodeException(node_name=current, cause=e, recoverable_state=s) from e
440+
441+
chain: ChainCall = compose_chain(
442+
list(self.middleware) + list(node.middleware),
443+
innermost,
444+
)
445+
446+
final_partial = await chain(state)
447+
return _merge_partial(state, final_partial, self.reducers, current)
330448

331449
@staticmethod
332450
def _dispatch_started(
@@ -335,6 +453,8 @@ def _dispatch_started(
335453
namespace: tuple[str, ...],
336454
step: int,
337455
pre_state: State,
456+
*,
457+
attempt_index: int = 0,
338458
) -> None:
339459
_dispatch(
340460
context,
@@ -347,6 +467,7 @@ def _dispatch_started(
347467
post_state=None,
348468
error=None,
349469
parent_states=context.parent_states_prefix,
470+
attempt_index=attempt_index,
350471
),
351472
)
352473

@@ -360,6 +481,7 @@ def _dispatch_completed(
360481
*,
361482
post_state: State | None = None,
362483
error: RuntimeGraphError | None = None,
484+
attempt_index: int = 0,
363485
) -> None:
364486
_dispatch(
365487
context,
@@ -372,5 +494,6 @@ def _dispatch_completed(
372494
post_state=post_state,
373495
error=error,
374496
parent_states=context.parent_states_prefix,
497+
attempt_index=attempt_index,
375498
),
376499
)

0 commit comments

Comments
 (0)