feat(langgraph): opt-in inclusive release for waiting edges - #2699
Open
Serhiy Bzhezytskyy (serhiy-bzhezytskyy) wants to merge 12 commits into
Open
feat(langgraph): opt-in inclusive release for waiting edges#2699Serhiy Bzhezytskyy (serhiy-bzhezytskyy) wants to merge 12 commits into
Serhiy Bzhezytskyy (serhiy-bzhezytskyy) wants to merge 12 commits into
Conversation
`StateGraph.addEdge`'s array overload had no JSDoc, so the only statement of its contract was Python's `add_edge` docstring, and when a listed node never runs the discarded writes leave no trace: no error is raised and `getState()` reports `next: []` with no tasks, while the checkpoint holds `join:a+b:merge: ["a"]` under an internal channel name. Document what the overload guarantees and how it differs from separate edges into the same target, and expose the unreleased edges as `waitingEdges` on the snapshot, naming the target, the nodes that completed and the nodes that never ran. Read the sets off the barrier rather than tracking anything new, and omit the key when every edge released so healthy snapshots keep their shape.
getState describes one checkpoint namespace, so an edge left waiting inside a subgraph was invisible from the parent even though the child's checkpoint holds it. Collect those under subgraphs: true, tagged with the stable node path and the exact namespace; missing is derived from the channel name, and a looped subgraph reports once per edge rather than once per invocation. A graph with no subgraph node skips the search entirely. Also guards a nested edge that holds every write — it waits for its target, nothing was dropped — and pins defer, dynamic interrupt, stream and non-memory savers.
stateSnapshotToThreadState maps snapshots with an explicit field list, so waitingEdges stopped at the process boundary and client.threads.getState() could not see it. Map it as waiting_edges and add the field to the SDK's ThreadState; the key is omitted when every edge released, so an existing payload keeps its shape. Verified over real HTTP: the integration suite boots the server and reads the wire through the SDK.
…its own Separate edges fire a fan-in once per superstep in which any listed node completes; a waiting edge fires once or never. Between them lies the semantics every report reaches for — run once, with whichever nodes arrived — and it is spellable today as separate edges plus defer: true on the target, measured identical on Python 1.2.11. Name it in the addEdge JSDoc with the two costs found by attacking it, and add the StateSnapshot bullet to the persistence page.
…t arrived
A waiting edge whose listed node is never selected holds its writes forever,
so the target is silently skipped while the run resolves. Add
addEdge([...], target, { inclusive: true }): at quiescence — the last
superstep's writes are applied and task derivation produced nothing, so
nothing is running and nothing is scheduled — the loop feeds the barrier its
missing names and the target runs once with the writes that arrived.
Quiescence is what the two earlier release designs lacked: both released at
the finish() hook, which fires under the '(tentatively) last superstep'
predicate and knows nothing about pending PUSH tasks, so a Send to a listed
node double-fired the target. Here a Send in flight is a pending task, so
there is no quiescence to release at and the barrier completes normally.
The release goes through the barrier's own completeness rule, so consume()
clears it as usual: single-shot, re-arms in loops. Opt-in only — the default
barrier is untouched, an inclusive edge nobody wrote to stays silent, and
combining with defer: true throws at compile. Twelve tests: the reported
shape, the Send hold, uneven depths at three selection mixes, a cascade, a
loop's final incomplete pass, interrupt/resume, a subgraph, and both
rejections.
…un does not read as finished Found by a 2,400-seed property sweep over random graph shapes (conditional entry subsets, chains at different depths, bounded loops, Send dispatchers, one or two joins), with every write into an inclusive barrier trapped: interruptAfter can land on the quiescent superstep, where nothing is scheduled and the edge is armed. getState() then reported next: [] — the documented end-of-run signal — so a client following it never resumed, and the release never happened. Report the armed edge's target in next when nothing else is scheduled: it will run, at latest when the resumed run settles. Two more properties the sweep surfaced, pinned and documented rather than changed: a listed node that completes again after a release re-arms the edge and runs the target once per arming, the default barrier's own accumulation rule; and two inclusive edges listing each other's targets never settle — the OR-join vicious circle — ending the run at the recursion limit where the default shape stalls silently. The seeded fuzz stays in the suite at 300+60 seeds; invariants: a completed run leaves no inclusive edge holding writes, single-arrival joins run exactly once, and interrupt+resume reproduces the uninterrupted result through next alone.
…gainst the inclusive edge The remaining unprobed seams, each measured before being pinned. A fork from the pre-release checkpoint releases once in the fork, and the armed history row carries next=["join"]. updateState as the missing node completes the barrier and the join runs once — the manual write and the quiescence release do not stack. An unrelated updateState keeps the edge armed and next naming the target through the update-created checkpoint. The fuzz's single loop shape generalizes to a random re-entry point and 2-3 passes; 2,000 invariant seeds and 400 interrupt/resume seeds pass at that shape too, committed at 300+60. Also measured, recorded as a probe rather than a test: a child process killed -9 mid-superstep with SqliteSaver and durability sync recovers on resume — the sibling's persisted write survives, the unfinished task re-runs, the release happens exactly once.
AC4.4 was reinterpreted rather than met: the release fed the barrier its missing names, so a checkpoint claimed nodes wrote that never ran and the released target had no way to tell a partial run from a full one — a consumer wanting 'mark this document partially indexed' had no hook. Release by flag instead: seen keeps only the nodes that actually wrote, so persistence stays truthful, and consume() clears the flag with the writes, keeping the edge single-shot. Surface the record to the target through the task scratchpad, read with waitingEdgeRelease(): the arrived and missing names on a quiescence release, undefined on an ordinary completeness release. The record is derived from the barrier at task preparation, not captured at release, so it survives an interruptBefore on the target and a resume from a checkpoint written in between — pinned by test. A released-but-unconsumed barrier is skipped by the snapshot collectors: its target is a scheduled task, not a stall.
… a finished run Measured before believed: without this, a drain landing on the quiescent superstep resolved the run with the join silently dropped — the original disease through the drain door, and inconsistent with a drain that lands while tasks remain, which throws 'Graph drained'. An armed inclusive edge is remaining work — it releases once the run truly settles — so the loop now reports draining instead of done. The state was already honest (next names the target, waitingEdges shows the armed edge); resume without the control releases once. Pinned.
…ds, stop scanning every superstep
The four-hats red team (red team / reviewer / architect / user), all findings
measured before fixed. Removing { inclusive: true } from an edge and resuming
an armed thread fed the [seen, released] tuple to the default barrier, whose
seen became a two-element garbage set — unreleasable and invisible to
waitingEdges at once; the same hazard pre-existed for the defer flag in both
directions, so the fix is restore tolerance in all three barrier classes, and
both flag flips now behave honestly (pinned both ways). Two inclusive edges
into one target release as one task, but the release record kept only the
first edge's arrivals — now the union. A Send straight at the armed target
runs it separately (edges, not sends) — measured, documented, pinned. And the
per-tick release-record scan walked every channel of every graph whether or
not an inclusive edge existed — gated behind a flag computed once at loop
construction, with the five string-literal class checks replaced by one
exported guard.
…uiescence and armed Comment-and-JSDoc-only pass: 'quiescence' and 'armed/arming' appear nowhere in this codebase's existing text, while 'settle' and holding writes are its native register; 're-arm' stays, timeout.ts uses it.
🦋 Changeset detectedLatest commit: 4728159 The changes in this PR will be included in the next version bump. This PR includes changesets to release 9 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
@langchain/langgraph-checkpoint
@langchain/langgraph-checkpoint-mongodb
@langchain/langgraph-checkpoint-postgres
@langchain/langgraph-checkpoint-redis
@langchain/langgraph-checkpoint-sqlite
@langchain/langgraph-checkpoint-validation
create-langgraph
@langchain/langgraph-api
@langchain/langgraph-cli
@langchain/langgraph
@langchain/langgraph-cua
@langchain/langgraph-supervisor
@langchain/langgraph-swarm
@langchain/langgraph-ui
@langchain/langgraph-sdk
@langchain/angular
@langchain/react
@langchain/svelte
@langchain/vue
commit: |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements #2695. Builds on #2685 — the three commits at the base are that PR, and this diff includes them until it lands.
Summary
addEdge([...], target, { inclusive: true })— the edge waits while anything in the graph is running or scheduled; once the run settles it releases with the listed nodes that completed, running the target exactly once. ASendin flight is a scheduled task, so it holds the release. Defaults are untouched: edges without the option keep the documented wait-for-all contract.Changes
channels/named_barrier_value.ts—InclusiveNamedBarrierValue: release is a flag the loop may set once the run settles; the checkpoint carries[seen, released]and restore tolerates every earlier shape, so toggling the option on an existing thread is safe in both directions.pregel/loop.ts— the release hook at the settle point (nothing running, nothing scheduled); a drain still stops the run without releasing.pregel/index.ts— a paused run holding writes reports the edge's target innext, so it does not read as a finished one.waiting_edge_release.ts—waitingEdgeRelease()inside the target names the listed nodes that arrived and the ones that never ran.graph/state.ts— theinclusiveoption, its JSDoc, and a compile-time error when combined withdefer(which already postpones the node to the end of the run).Test plan
inclusive_waiting_edges.test.ts— each boundary Feature: opt-in inclusive release for waiting edges — run the target once with the nodes that arrived #2695 lists, pinned: theSendhold; one run per release cycle; two mutual inclusive edges ending at the recursion limit; interrupt at the release point keepingnexthonest; both toggle directions on an existing thread;waitingEdgeRelease()naming arrived and missinginclusive_waiting_edges.fuzz.test.ts: 2,400 seeds over random shapes — loops, uneven chains,Senddispatchers), re-run after the rebase onto currentmainlanggraph-coresuite: 1,712 passed, typecheck clean; changeset for@langchain/langgraphRelated