|
39 | 39 | append, |
40 | 40 | merge, |
41 | 41 | ) |
| 42 | +from openarmature.graph.middleware import ( |
| 43 | + FailureIsolationMiddleware, |
| 44 | + RetryConfig, |
| 45 | + RetryMiddleware, |
| 46 | + deterministic_backoff, |
| 47 | +) |
42 | 48 |
|
43 | 49 | # --------------------------------------------------------------------------- |
44 | 50 | # Shared schemas + helpers |
@@ -217,6 +223,151 @@ async def test_three_heterogeneous_branches_merge_to_parent() -> None: |
217 | 223 | assert final.gamma_result == 3 |
218 | 224 |
|
219 | 225 |
|
| 226 | +# --------------------------------------------------------------------------- |
| 227 | +# Branch middleware — state space (§11.7) |
| 228 | +# --------------------------------------------------------------------------- |
| 229 | + |
| 230 | + |
| 231 | +async def test_branch_middleware_degraded_update_projects_through_outputs() -> None: |
| 232 | + # Regression: branch middleware wraps the subgraph invocation (§11.7), |
| 233 | + # so the chain operates in the branch subgraph's state space. A |
| 234 | + # middleware that short-circuits with a subgraph-space partial update — |
| 235 | + # here FailureIsolation's degraded_update writing the subgraph field |
| 236 | + # ``b_out`` — MUST project to the parent through the branch's |
| 237 | + # ``outputs`` mapping, exactly like a real subgraph result. Before the |
| 238 | + # fix the ``outputs`` projection ran INSIDE the middleware chain, so the |
| 239 | + # degraded_update reached the parent as ``b_out`` and tripped |
| 240 | + # extra-field validation (ParentState has no ``b_out``). |
| 241 | + isolation = FailureIsolationMiddleware( |
| 242 | + degraded_update={"b_out": 99}, |
| 243 | + event_name="beta_isolated", |
| 244 | + ) |
| 245 | + compiled = ( |
| 246 | + GraphBuilder(ParentState) |
| 247 | + .set_entry("dispatcher") |
| 248 | + .add_parallel_branches_node( |
| 249 | + "dispatcher", |
| 250 | + branches={ |
| 251 | + "beta": BranchSpec( |
| 252 | + subgraph=_build_beta_raises("boom"), |
| 253 | + outputs={"beta_result": "b_out"}, |
| 254 | + middleware=(isolation,), |
| 255 | + ), |
| 256 | + }, |
| 257 | + ) |
| 258 | + .add_edge("dispatcher", END) |
| 259 | + .compile() |
| 260 | + ) |
| 261 | + final = await compiled.invoke(ParentState()) |
| 262 | + await compiled.drain() |
| 263 | + # The branch failed; FailureIsolation degraded it in subgraph space |
| 264 | + # (b_out=99); ``outputs`` projected b_out -> parent beta_result. |
| 265 | + assert final.beta_result == 99 |
| 266 | + |
| 267 | + |
| 268 | +async def test_branch_middleware_success_path_projects_subgraph_output() -> None: |
| 269 | + # Guards the other side of the fix: with branch middleware present but |
| 270 | + # the branch SUCCEEDING, the real subgraph output (not the degraded |
| 271 | + # value) must still project through ``outputs``. Confirms moving the |
| 272 | + # projection outside the middleware chain left the success path intact. |
| 273 | + isolation = FailureIsolationMiddleware( |
| 274 | + degraded_update={"a_out": 99}, |
| 275 | + event_name="alpha_isolated", |
| 276 | + ) |
| 277 | + compiled = ( |
| 278 | + GraphBuilder(ParentState) |
| 279 | + .set_entry("dispatcher") |
| 280 | + .add_parallel_branches_node( |
| 281 | + "dispatcher", |
| 282 | + branches={ |
| 283 | + "alpha": BranchSpec( |
| 284 | + subgraph=_build_alpha_succeeds(), # returns a_out=1 |
| 285 | + outputs={"alpha_result": "a_out"}, |
| 286 | + middleware=(isolation,), |
| 287 | + ), |
| 288 | + }, |
| 289 | + ) |
| 290 | + .add_edge("dispatcher", END) |
| 291 | + .compile() |
| 292 | + ) |
| 293 | + final = await compiled.invoke(ParentState()) |
| 294 | + await compiled.drain() |
| 295 | + assert final.alpha_result == 1 # real subgraph output, not the degraded 99 |
| 296 | + |
| 297 | + |
| 298 | +async def test_branch_middleware_degraded_update_omitting_field_skips_contribution() -> None: |
| 299 | + # Leniency: a degraded_update that does not cover a projected |
| 300 | + # ``outputs`` sub-field contributes nothing for that field rather than |
| 301 | + # raising. The §11.4 buffer-then-merge model already merges partial |
| 302 | + # contributions, so the parent field keeps its prior value. Here the |
| 303 | + # branch degrades with an EMPTY update, so ``beta_result`` is never |
| 304 | + # contributed and stays at its ParentState default (0). A hard miss |
| 305 | + # would defeat the point of failure isolation (the resilience primitive |
| 306 | + # would itself crash the invocation). |
| 307 | + isolation = FailureIsolationMiddleware( |
| 308 | + degraded_update={}, |
| 309 | + event_name="beta_isolated", |
| 310 | + ) |
| 311 | + compiled = ( |
| 312 | + GraphBuilder(ParentState) |
| 313 | + .set_entry("dispatcher") |
| 314 | + .add_parallel_branches_node( |
| 315 | + "dispatcher", |
| 316 | + branches={ |
| 317 | + "beta": BranchSpec( |
| 318 | + subgraph=_build_beta_raises("boom"), |
| 319 | + outputs={"beta_result": "b_out"}, |
| 320 | + middleware=(isolation,), |
| 321 | + ), |
| 322 | + }, |
| 323 | + ) |
| 324 | + .add_edge("dispatcher", END) |
| 325 | + .compile() |
| 326 | + ) |
| 327 | + final = await compiled.invoke(ParentState()) |
| 328 | + await compiled.drain() |
| 329 | + assert final.beta_result == 0 # never contributed; parent default retained |
| 330 | + |
| 331 | + |
| 332 | +async def test_branch_middleware_isolation_wraps_retry_degrades_after_exhaustion() -> None: |
| 333 | + # Fixture-064-Case-1-shaped at a branch: middleware [failure_isolation, |
| 334 | + # retry] (outer-to-inner). The branch's node fails on every attempt; |
| 335 | + # retry exhausts its two attempts and re-raises; failure_isolation |
| 336 | + # catches the exhausted exception and degrades in subgraph space, which |
| 337 | + # projects to the parent. Exercises the state-space fix through a real |
| 338 | + # multi-middleware chain rather than a single frame. |
| 339 | + isolation = FailureIsolationMiddleware( |
| 340 | + degraded_update={"b_out": 99}, |
| 341 | + event_name="beta_isolated", |
| 342 | + ) |
| 343 | + retry = RetryMiddleware( |
| 344 | + RetryConfig( |
| 345 | + max_attempts=2, |
| 346 | + classifier=lambda _exc, _state: True, |
| 347 | + backoff=deterministic_backoff(0), |
| 348 | + ) |
| 349 | + ) |
| 350 | + compiled = ( |
| 351 | + GraphBuilder(ParentState) |
| 352 | + .set_entry("dispatcher") |
| 353 | + .add_parallel_branches_node( |
| 354 | + "dispatcher", |
| 355 | + branches={ |
| 356 | + "beta": BranchSpec( |
| 357 | + subgraph=_build_beta_raises("boom"), |
| 358 | + outputs={"beta_result": "b_out"}, |
| 359 | + middleware=(isolation, retry), |
| 360 | + ), |
| 361 | + }, |
| 362 | + ) |
| 363 | + .add_edge("dispatcher", END) |
| 364 | + .compile() |
| 365 | + ) |
| 366 | + final = await compiled.invoke(ParentState()) |
| 367 | + await compiled.drain() |
| 368 | + assert final.beta_result == 99 |
| 369 | + |
| 370 | + |
220 | 371 | # --------------------------------------------------------------------------- |
221 | 372 | # fail_fast policy |
222 | 373 | # --------------------------------------------------------------------------- |
|
0 commit comments