Skip to content

feat(langchain): deterministic durability for parallel StateGraph nodes (auto-enabled)#220

Draft
jgrier wants to merge 5 commits into
restatedev:mainfrom
jgrier:langgraph-stategraph-determinism
Draft

feat(langchain): deterministic durability for parallel StateGraph nodes (auto-enabled)#220
jgrier wants to merge 5 commits into
restatedev:mainfrom
jgrier:langgraph-stategraph-determinism

Conversation

@jgrier

@jgrier jgrier commented Jul 25, 2026

Copy link
Copy Markdown

Problem

RestateMiddleware makes a create_agent agent durable, but a StateGraph with parallel nodes (fan-out, Send/map-reduce, nested subgraphs) breaks. LangGraph runs a superstep's nodes concurrently on asyncio, and ctx.run_typed creates a journal command synchronously at call time, so parallel nodes create commands in I/O-completion order — nondeterministic, and different on replay (journaled results resolve instantly). Restate matches the journal by position, so replay mismatches and the invocation poisons with VMException (570) Journal mismatch.

Reported with a minimal repro: plan → [branch, branch, subgraph, subgraph, deepagents] → join, every node journaled by RestateMiddleware.

Fix

Importing restate.ext.langchain enables it automatically — no wrappers, no config, no changes to graph/agent/handler code (opt out with RESTATE_STATEGRAPH_DETERMINISM=0). __init__.py calls enable() on import, which installs a per-invocation coordinator:

  • ServerInvocationContext.run_typed is patched so restate_context()/middleware route parallel durable ops through the coordinator. It batches ops and creates their journal commands in a stable, hierarchical-key sorted order (LangGraph task name|path, composed for nested subgraphs), awaiting the I/O concurrently via restate.gather. The context stays a real ServerInvocationContext, so extension_data etc. are unaffected.
  • langgraph.pregel._runner.arun_with_retry is patched to tag each task with its replay-stable key.
  • The SDK's invoke_handler is wrapped so every invocation gets a fresh coordinator.

Two middleware changes come with it:

  1. Parallel-safe turnstile. The middleware stored its tool-call turnstile under one shared ctx.extension_data slot, which parallel agents clobbered (KeyError). It's now keyed by tool_call_id, backward compatible for the single-agent tool batch.
  2. No turnstile under the coordinator. A node with multiple tool calls (deepagents) deadlocked: the tool calls are separate Pregel tasks registered as active leaves, and the turnstile blocks calls 2..N before they submit, so the coordinator's flush (which waits for every active leaf to be pending) never fires. awrap_tool_call now skips the turnstile when the coordinator is active — the coordinator already orders the tool-call tasks by key; the stock path keeps the turnstile.

Validation

Reproduced and resolved against the reported repro, end-to-end against restate-server with a stubbed model. inactivity_timeout=0s (suspend/replay on every await) surfaces the 570 on a single invocation.

  • Baseline (fix off): ~11 × 570, poison (reproduced 8/8).
  • Fix: 0 × 570, completes.
  • Multi-tool-call (deep node emits 3 tool calls): fix completes with no deadlock; stock completes (turnstile serializes); baseline still 570s; fix → 0 × 570.

Unit tests in tests/langchain_stategraph.py cover the coordinator's sorted-key ordering (fan-out, concurrent tool-call tasks, multiple rounds) and the coordinator_active() gate; guarded by importorskip("langgraph").

jgrier and others added 4 commits July 23, 2026 17:36
RestateMiddleware linearizes create_agent's parallel tool-call batch. This adds
`durable_scope`, which generalizes the same guarantee to an arbitrary StateGraph:
parallel branches, Send/map-reduce, and nested parallel subgraphs.

LangGraph runs a superstep's nodes concurrently on asyncio; `ctx.run_typed`
creates a journal command synchronously at call time, so parallel nodes create
commands in I/O-completion order -- non-deterministic, and different on replay
(journaled results resolve instantly). Restate matches the journal by position,
so replay mismatches and the invocation cannot recover.

`durable_scope(ctx)` yields a context wrapper; nodes call `dctx.run_typed(...)`
unchanged (no node wrappers, no config threading). A per-invocation coordinator
batches durable ops and creates each round's commands in stable sorted order
(deterministic journal) while awaiting the I/O concurrently via `restate.gather`.
Task identity uses LangGraph's replay-stable name/path metadata, composed
hierarchically for nesting.

RFC/draft: membership + per-task keying come from lazily monkeypatching a private
LangGraph function (langgraph.pregel._runner.arun_with_retry). This is brittle
across LangGraph versions; a merge-ready version wants a supported LangGraph
runner/executor hook. Details and the crash-recovery verification are in the PR.

Test is guarded by importorskip('langgraph') since langgraph is an optional
`langchain` extra, not a default test dependency.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…turnstile

Two changes that together make Rushabh's parallel-node/subgraph repro
deterministic with zero user code (validated: baseline 570-poison -> fixed).

1) StateGraph determinism coordinator (_stategraph.py), rewritten from the
   earlier node-wrapper prototype:
   - Patches ServerInvocationContext.run_typed at the class level so
     restate_context()/RestateMiddleware route parallel durable ops through a
     per-invocation coordinator, which creates their journal commands in a
     stable, hierarchical-key sorted order (langgraph task name|path, composed
     for nested subgraphs) and awaits the I/O concurrently via restate.gather.
     The ctx stays a real ServerInvocationContext (extension_data still works).
   - Patches langgraph.pregel._runner.arun_with_retry to tag each task with its
     replay-stable key.
   - enable() auto-activates a coordinator per invocation by wrapping the SDK's
     invoke_handler; __init__ calls enable() on import so merely importing the
     integration turns it on. Opt out with RESTATE_STATEGRAPH_DETERMINISM=0.

2) Parallel-safe middleware turnstile (_state.py, _middleware.py):
   - _State now holds turnstiles keyed by tool_call_id instead of one shared
     slot. Parallel tool-using agents in concurrent LangGraph nodes were
     clobbering the single slot (KeyError) once the coordinator synchronized
     their model calls; keying by the unique tool_call_id fixes it and is
     backward-compatible for the single-agent tool batch.

RFC/draft: still monkeypatches private langgraph internals (arun_with_retry);
a merge-ready version wants a supported runner hook. Determinism validated
end-to-end against Rushabh's exact repro with a stubbed model; see notes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…dinator

Adds a coordinator test modeling a node's concurrent tool calls as separate
Pregel push tasks with distinct hierarchical keys (verified against LangGraph:
.../tools|('__pregel_push', N, False)), alongside sibling single-op nodes.
Asserts they flush in stable sorted-key order with no same-key collision.

This documents the actual mechanism for multi-tool-call parallel nodes: the
coordinator orders the distinct-key tool-call tasks directly; the middleware
turnstile is not what serializes them. (Empirically: create_agent v1 doesn't
invoke the turnstile for tool exec; deepagents does, trivially for a single
call. The per-tool_call_id turnstile keying still matters to prevent the
cross-node clobber KeyError when parallel nodes share the middleware state.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…lock)

A node with multiple tool calls that invokes the turnstile (e.g. deepagents)
DEADLOCKED under the StateGraph coordinator: the tool calls run as separate
Pregel tasks (distinct keys), all registered as active leaves; the turnstile
blocks tool calls 2..N in wait_for BEFORE they submit, so they never become
pending, so the coordinator's "flush when every active leaf is pending" barrier
never fires, so tool call 1 never completes, so the turnstile never releases the
rest. Circular deadlock (confirmed by tracing).

Fix: awrap_tool_call skips the turnstile when a coordinator is active
(coordinator_active()). The coordinator already orders the tool-call tasks by
key, so the turnstile is redundant there; the stock (no-coordinator) path keeps
using it. Verified: deepagents x3 tool calls completes under the fix (turnstile
skipped) and under stock (turnstile serializes); his full graph with a 3-tool-call
deep node reproduces the 570 in baseline and is 0x570 with the fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown


Thank you for your submission, we really appreciate it. Like many open-source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution. You can sign the CLA by just posting a Pull Request Comment same as the below format.


I have read the CLA Document and I hereby sign the CLA


You can retrigger this bot by commenting recheck in this Pull Request. Posted by the CLA Assistant Lite bot.

…ht import)

- mypy runs on python/ (not just tests/): the class-level run_typed patch needs
  type: ignore[method-assign, assignment], not just [method-assign].
- invoke_handler is imported but not re-exported by server_context; use
  getattr/setattr so pyright doesn't flag reportPrivateImportUsage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant