langchain-ai / langchain-ai/langgraph

RFC: Cross-node write-intent registry for parallel graph execution

Open
#7,907 0 comments 0 reactions 0 assignees View on GitHub
external
Dominant language
Python
Stars
41.8k
Forks
7.1k
Avg merge
23h 7m
Merged PRs (30d)
30

Description

> **Update 2026-05-25**: I've edited this issue to clarify the framing. The original wording made stronger empirical claims ("I've been running an experimental decorator", "~30% of multi-reviewer runs silently dropping 3 of 4 reviews", "we discovered a week later") than I can actually support — the design is from reading the Pregel runtime + `Send` / channel reducer code paths, not from a measured production deployment. The design discussion stands; the specific numbers and personal use-case framing have been removed.

---

## Summary

I'd like to gauge interest in a **cross-node write-intent registry** for parallel graph execution that detects *semantic* conflicts between concurrently-scheduled nodes at `Send` dispatch time, rather than relying entirely on channel reducers to merge results after the fact.

The core idea: when a graph schedules multiple branches via `Send` (or any superstep with >1 active node), each node optionally declares which channels / state keys it intends to write. The Pregel runtime checks the in-flight set against pending sends *before* admitting them, surfacing a structured conflict that the graph author can route (refuse / queue / warn) — instead of discovering the conflict only after both nodes return and the reducer silently picks one (LastValue) or interleaves nonsensically (binary aggregator on logically-exclusive updates).

This is complementary to channel reducers — they protect the byte-level merge; this protects the *task-level* invariant.

## Why this complements channel reducers

Reducers are correct for *commutative* merges (append to a list, take last value, max of numbers). But many real graphs have **logically-exclusive** parallel updates that reducers can't catch:

- Two branches each set `state["assignee"] = self.name` — LastValue picks one silently, the other branch's downstream work is now orphaned
- Branch A appends `{tool: "search"}` to `state["history"]`, branch B appends `{tool: "search"}` — both succeed; deduplication has to happen post-hoc
- Branch A writes `state["plan"]`, branch B reads `state["plan"]` in the same superstep expecting branch A's value — race on superstep boundary

The pattern of using `interrupt()` / human-in-the-loop to serialize doesn't scale; using `Send` with explicit fanout requires the author to predict conflicts manually.

## Design sketch

```
@node(writes=["status", "assignee"])
def branch_a(state): ...

@node(writes=["history.append", "metrics.update"])
def branch_b(state): ...

# Pregel superstep admission:
#
# pending_sends = [branch_a, branch_b, branch_c]
# │
# ▼
# in_flight_intent_set: { "status": branch_a,
# "assignee": branch_a,
# "history.append": branch_b,
# ... }
# │
# ▼
# for each send: check intent vs in_flight
# conflict → policy: refuse | queue-next-superstep | warn+admit
```

**Channel-key syntax** (mirroring existing channel ops):
- bare key (`"status"`) — exclusive write, conflicts with any other write to same key
- `"history.append"` — additive intent, multiple sends OK (matches reducer semantics)
- `"plan.read_then_write"` — read-modify-write, conflicts with any other write to `plan`

**Policies** (graph-level config):
- `refuse` (default for production graphs): hard error, send is dropped, graph state records conflict for the parent
- `queue`: defer conflicting send to next superstep
- `warn`: admit anyway, log the overlap (migration mode)

**Auto-release**: intent entries cleared at superstep completion.

## What this is NOT

- **Not** a replacement for channel reducers — they still handle the actual merge; this prevents the conflict from arising in the first place
- **Not** a distributed lock — single-process Pregel runtime; for `langgraph-cloud` / multi-worker deployments, the registry would need a backend (out of scope for this RFC)
- **Not** a transactional store — no rollback; refuse semantics simply drop the send
- **Not** opinionated on node author: `writes=` is **optional**; absent declaration = current behavior (full reducer-based merge, no admission check)

## Why this matters in practice

A representative shape (code-reading argument, not measured production data): a planning graph with N parallel reviewer branches, each writing to `state["review"]` via `LastValue`. The semantics are silent — `LastValue` picks one, the other branches' writes are dropped, downstream consumers can't tell the difference between "1 review" and "1 review out of N". The correct reducer (`BinaryOperatorAggregate(operator.add)` or `add_messages`) exists, but the cliff is invisible until a downstream node misbehaves with partial data.

A `writes=["review.append"]` declaration at the decorator layer would refuse the first run — surfacing the schema/reducer mismatch at admission time rather than at downstream consumer time. This pattern (silent partial-state-loss in parallel writes) is the failure class the registry targets.

Would be very interested in whether you've seen this class of report in user issues, and whether there's already an in-flight design here.

## Questions before I open anything

1. **Roadmap conflict?** Is there an existing plan for parallel-node admission control that I should align with? I checked recent issues and didn't find one, but happy to defer if there's an in-flight design.
2. **Scope preference** — if there's interest, would you prefer:
- **(a)** a minimal demo PR (just the decorator + in-process registry + `refuse` policy, no queue/warn)
- **(b)** the full version with all three policies + structured conflict events on the graph stream
- **(c)** keep it as a userland decorator pattern + document the integration recipe
3. **`langgraph-cloud` compatibility** — should the in-process registry be designed against an interface (so cloud can plug in a Redis-backed impl), or focus only on single-process for v1?
4. **Channel-key syntax** — is the `"key.op"` notation (mirroring reducer semantics) the right surface, or would a separate `writes=` / `appends=` / `reads=` triple be cleaner?

Not opening a PR yet — purely gauging interest before sinking design time into the wrong shape.

Thanks!

Contributor guide

Open the contributing guide

Research direction

No files or tests are named in the RFC. Start by reading the Pregel runtime and Send/channel-reducer code paths cited in the issue, then establish whether maintainers want a minimal in-process registry or a userland/documentation approach; done requires an agreed scope and implementation plan.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
backend
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Needs clarification
Newbie friendliness
30/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.