Pregel algorithm layer: interrupt, write-back, scheduling
Responsibilities
_algo.py is the "mathematical kernel" of PregelLoop and PregelRunner. The loop is responsible for "how many iterations to run", the runner for "how to execute concurrently", and algo for "the semantics of each step": when to interrupt, how to merge node writes back into channels, and which nodes to trigger next. It has no state and no IO — it is all pure functions, taking checkpoint + channels + tasks as input and producing a new channel version table / updated_channels set / a new task dict.
The three core functions correspond exactly to the three calls in PregelLoop.tick + after_tick: should_interrupt (should_interrupt:155) is called mid-tick to decide whether to raise GraphInterrupt before execution; apply_writes (apply_writes:232) is called at the start of after_tick to merge all task writes from this step into channels and return updated_channels; prepare_next_tasks (prepare_next_tasks:392) is called at the start of tick to compute which tasks to execute this step from the current checkpoint.
In other words, these three functions decide "what the BSP loop sees at each step, what it does, and what the channels look like after wrap-up". Understanding them means understanding Pregel's "state transition rules".
Design motivation
Why split the algorithmic logic into pure functions?
- Testability:
should_interrupt/apply_writes/prepare_next_tasksare all pure functions, taking a checkpoint dict + channel mapping as input and producing data structures. They can be unit-tested in isolation from Pregel's IO, submit, and checkpointer — which is exactly what most tests inlibs/langgraph/tests/unit/look like. - Sync/async sharing:
PregelLoophas bothSyncPregelLoopandAsyncPregelLoopsubclasses, but the algo layer is completely side-effect free, so both subclasses share the same logic (tick calls prepare_next_tasks:612). Otherwise the algorithm would have to be written twice for sync and async, with bugs fixed on both sides. updated_channelsoptimization:apply_writesreturns the updated_channels set, whichprepare_next_tasksaccepts as a hint to skip "scanning all nodes to check whether their triggers fire" (updated_channels hint:475-486). On large graphs this reduces an O(N×M) trigger scan to O(updated×triggered) — the single most important performance optimization.should_interruptdedupes viaversions_seen: theversions_seenof theINTERRUPTchannel (versions_seen INTERRUPT:163) records the channel versions seen at the last interrupt. Only "channels with new updates since the last interrupt" are considered for another interrupt — preventing a death loop of trigger-interrupt-resume-interrupt.- The finish signal at the end of
apply_writes: whenbump_step and updated_channels.isdisjoint(trigger_to_nodes),channels[chan].finish()is called (finish:336-342) to send all channels the "this is the last superstep" signal. Permanent channels (e.g.LastValue) use this opportunity to mark themselves unavailable soprepare_next_taskstriggers no further nodes — this is the graph's "natural death".
Key files
should_interrupt:155-185— checks whether the graph should interrupt; first looks atversions_seen[INTERRUPT]to decide whether there are new updates, then whether the triggered tasks fall in theinterrupt_nodeslist.any_updates_since_prev_interrupt:161-168— compares channel versions to decide "whether there have been updates since the last interrupt"; the core of interrupt deduplication.interrupt_nodes filter:170-184—interrupt_nodes == "*"means all non-hidden tasks count; otherwise filter bytask.name in interrupt_nodes.apply_writes:232-345— merges this step's task writes into channels and returns theupdated_channelsset.bump_step:256-259— any task withtriggerssetsbump_step=True, meaning this step consumes channels and advances the step counter.update seen versions:262-269— each task records the version of the trigger channels it read intoversions_seen, so the next_triggerscheck becomes "has there been a new update since the last execution".consume channels:284-292— callschannels[chan].consume()to mark channels read this step as consumed; the implementation of the PULL channel "read once then clear" semantics.apply writes to channels:315-323—channels[chan].update(vals)actually merges writes into the channel; onlyis_available()channels are added toupdated_channels.bump_step notify:326-333— whenbump_step=True, available channels not updated this step also getupdate(EMPTY_SEQ), advancing their version — so nodes subscribed to those channels but not triggered this step will not be erroneously fired on the next tick.finish signal:336-342— when no node is triggered by this step's updates, all channels getfinish(); permanent channels become unavailable and the graph ends naturally.prepare_next_tasks:392-513— computes which tasks to run next; both PUSH (fromSendfan-out) and PULL (from edge triggers) originate here.updated_channels optimization:475-486— uses the previous step'supdated_channels+trigger_to_nodesto reverse-lookup the triggered node set, skipping the full scan.prepare_single_task:524— single task construction; PUSH goes throughprepare_push_task_functional/prepare_push_task_send, PULL goes through the_triggerscheck +_proc_inputto fetch input._triggers:1260-1277— checks whether a node's trigger channels have "an unread new version"; the core condition for PULL scheduling.local_read:188-224— entry point for node functions reading channel values; supportsfresh=Trueto read a local view of "what I just wrote but has not been merged into the channel", for the write-then-read semantics of conditional edges.
Data flow
should_interrupt is the simplest of the three; its logic is "there are new updates + the current task hits the list":
def should_interrupt(
checkpoint: Checkpoint,
interrupt_nodes: All | Sequence[str],
tasks: Iterable[PregelExecutableTask],
) -> list[PregelExecutableTask]:
"""Check if the graph should be interrupted based on current state."""
version_type = type(next(iter(checkpoint["channel_versions"].values()), None))
null_version = version_type() # type: ignore[misc]
seen = checkpoint["versions_seen"].get(INTERRUPT, {})
# interrupt if any channel has been updated since last interrupt
any_updates_since_prev_interrupt = any(
version > seen.get(chan, null_version) # type: ignore[operator]
for chan, version in checkpoint["channel_versions"].items()
)
# and any triggered node is in interrupt_nodes list
return (
[task for task in tasks if ...]
if any_updates_since_prev_interrupt
else []
)This comes from should_interrupt:155-185. The key is the versions_seen[INTERRUPT] record — it is not the version a node has read, but "the version snapshot of all channels at the time of the last interrupt". any_updates_since_prev_interrupt compares this snapshot with the current channel_versions; as long as any channel has a higher version, it counts as "there is something new", and only then is the interrupt check entered. This way, even if a resume triggers a node with the same name on the next tick, it will not immediately interrupt again — it must wait until a channel genuinely has a new write.
apply_writes is the core of the wrap-up phase: first update versions_seen, then consume the channels that were read, and finally write the new values:
# update seen versions
for task in tasks:
checkpoint["versions_seen"].setdefault(task.name, {}).update(
{
chan: checkpoint["channel_versions"][chan]
for chan in task.triggers
if chan in checkpoint["channel_versions"]
}
)
# Consume all channels that were read
for chan in {
chan
for task in tasks
for chan in task.triggers
if chan not in RESERVED and chan in channels
}:
if channels[chan].consume() and next_version is not None:
checkpoint["channel_versions"][chan] = next_version
# Apply writes to channels
updated_channels: set[str] = set()
for chan, vals in pending_writes_by_channel.items():
if chan in channels:
if channels[chan].update(vals) and next_version is not None:
checkpoint["channel_versions"][chan] = next_version
if channels[chan].is_available():
updated_channels.add(chan)This comes from apply_writes core:262-323. The three phases have clear semantics:
- Update
versions_seen— record each task's current trigger channel versions as evidence that "this node has seen this version of the channel". The next_triggerscheck compares against future versions; only an update will trigger again. consume()channels that were read — channels likeTopicwith "read once then clear" semantics empty their internal buffer inconsume(), keeping only the version advance. This is why aSend-fanned-out message does not trigger other nodes after being consumed.update(vals)writes new values — all writes from all tasks this step are collected per channel and merged in at once, invoking the merge logic of reducers /LastValueand similar channels. Onlyis_available()channels (with values and not finished) end up inupdated_channels— channels afterfinish()advance their version but trigger no nodes.
prepare_next_tasks is called at the start of tick and decides which tasks to run this step:
# Consume pending tasks
tasks_channel = cast(Topic[Send] | None, channels.get(TASKS))
if tasks_channel and tasks_channel.is_available():
for idx, _ in enumerate(tasks_channel.get()):
if task := prepare_single_task(
(PUSH, idx), None, ..., for_execution=for_execution, ...
):
tasks.append(task)
# This section is an optimization that allows which nodes will be active
# during the next step.
if updated_channels and trigger_to_nodes:
triggered_nodes: set[str] = set()
for channel in updated_channels:
if node_ids := trigger_to_nodes.get(channel):
triggered_nodes.update(node_ids)
candidate_nodes: Iterable[str] = sorted(triggered_nodes)
elif not checkpoint["channel_versions"]:
candidate_nodes = ()
else:
candidate_nodes = processes.keys()
for name in candidate_nodes:
if task := prepare_single_task((PULL, name), None, ..., for_execution=for_execution, ...):
tasks.append(task)
return {t.id: t for t in tasks}This comes from prepare_next_tasks core:441-513. Two kinds of tasks converge here:
- PUSH task: pulls
Sendobjects from theTASKSchannel — these were fanned out by some nodereturn Send(...)in the previous step; eachSendcorresponds to a PUSH task. - PULL task: reverse-looks-up
updated_channelsthroughtrigger_to_nodes— this mapping is computed at compile time as "channel -> list of subscribed nodes", and taking the union gives the candidate nodes to trigger this step.sortedensures deterministic task order, which does not affect execution results but does affect checkpoint replay.
Each candidate node then goes through _triggers for a second confirmation (_triggers call:606-612) — "the channel has a new version and the node has not seen this version" — before a task is actually generated. This is the key to PULL node deduplication: even if updated_channels contains a node's trigger channel, if versions_seen[name] has already recorded this version, the task will not be rescheduled.
The position of the algo layer within the Pregel loop:
Boundaries and failure modes
versions_seen[INTERRUPT]not updating causes a death loop:should_interruptonly returns a non-empty list whenany_updates_since_prev_interruptis true, and the update ofversions_seen[INTERRUPT]happens in the implicit logic of_put_checkpointafterapply_writes. If the INTERRUPT channel's seen version is not correctly advanced on resume, the same update is repeatedly judged as "new", causing an interrupt-resume-interrupt death loop (versions_seen INTERRUPT:163-168).bump_stepis the only signal for step advancement: whenany(t.triggers for t in tasks)is false, no bump happens — this is the "null task writes channels but does not advance the step" semantics (bump_step:259). Null tasks are used to inject input writes before tick; they should not bump the step counter.finish()is called only when "no node is triggered":bump_step and updated_channels.isdisjoint(trigger_to_nodes)(finish condition:336) — as long as any channel inupdated_channelsis subscribed to by a node, do not finish. This means finish is only sent "when the graph dies on the last step", not on every step.- Unknown channel writes only warn, not error: when
pending_writes_by_channelcontains a channel name not inchannels, the code justlogger.warning(unknown channel warn:310-313). This is for fault tolerance — e.g. a node writing to a channel that has already been finished should not crash the whole graph. local_readwithfresh=Truecopies the channel:freshreads dochannels[k].copy()per channel (fresh read copy:213-219), applying this task's writes on the copy — so conditional edge nodes can read their own just-written local view beforeapply_writeswithout affecting the global state seen by other tasks.- The task id hash algorithm in
prepare_single_task: whencheckpoint["v"] > 1,_xxhash_stris used; otherwise_uuid5_str(task_id_func:550) — this is checkpoint-version compatibility: old checkpoints use task ids generated by uuid5 and must be replayable.
Summary
_algo.py's three pure functions — should_interrupt / apply_writes / prepare_next_tasks — cleanly abstract Pregel's semantic layer: when to interrupt, how to merge writes back, and who to run next. They are stateless and IO-free, driven by PregelLoop.tick + after_tick to power the entire BSP loop. See /pregel/loop for the loop driver layer, /pregel/runner for the execution layer, and /pregel/pregel for the overall assembly. The semantics of the channel methods update / consume / finish / is_available are in /channel/base-channel.
See official docs: LangGraph docs · README.