Re-execute / restart from a specific task in a functional API

Functional API is awesome - the code is easy to read, parallelisation can be achieved with asyncio.gather() etc. Branching is intuitive and “right there” instead of having to constantly jump between what was done in add_edge in another part of code vs what the node is returning and if it’s all still reasonable/expected etc.

Also while rearranging things, type checkers and static analysis immediately help. For graph API if node-x expects foo to be computed/populated we must ensure that some node that emits foo executes somewhere in the ancestral chain before x, but this is not statically enforceable by graph API. Functional API makes this a breeze since the value to be passed to the task must exist and be collected by entrypoint by running the appropriate task already.

One thing I cannot figure out however is restarts from particular task. Say I have Entrypoint -> { task-0 -> task-1 -> task-2 -> ... } I might have reached task-4 which has either failed, interrupted or it was the last task and everything’s completed and entrypoint has returned. I now want to restart from say task-2 - so don’t want to redo what task-0 and 1 did but redo task-2. Then everything that was scheduled after task-2 basically re-executes.

The thread-id is the same, it’s a new run-id. How to do this? Is it possible?

hi @ustulation

imo there’s no built-in “restart from task-N” in the Functional API, and it’s structural rather than a missing toggle.

On resume the Functional API replays the entrypoint from the top and restores every completed task’s result from the checkpoint instead of recomputing it (docs: Determinism). So re-running the same thread_id would restore task-2, not re-execute it. The model is deliberately built to avoid re-running completed work - the opposite of a partial restart.

The Graph API writes a new checkpoint after every superstep; the Functional API saves all task results into a single checkpoint per entrypoint invocation (docs: key differences). There’s simply no “checkpoint just before task-2” to jump back to - which is also why node-level time travel (replay/fork) is a Graph-API-only feature.

Three ways to get what you want:

  1. stay functional, make the restart point explicit. Since you’re already using a new run anyway, pass the upstream outputs in as input on a fresh thread and let plain Python skip them:

    @entrypoint(checkpointer=checkpointer)
    def workflow(inp: Input):
        r0 = inp.r0 if inp.r0 is not None else task0(inp.x).result()
        r1 = inp.r1 if inp.r1 is not None else task1(r0).result()
        r2 = task2(r1).result()   # always re-runs from here
        return task3(r2).result()
    

    The skip logic lives in typed Python - which actually leverages the static-type-checking advantage you mentioned.

  2. use the Graph API for true node-level time travel if you need arbitrary rewind on the same thread: get_state_history() → pick the checkpoint before task-2 → invoke(None, that_config). Nodes before are restored, task-2 onward re-execute (docs: Time travel). Both APIs share the same runtime, so you can mix them.

  3. Split the chain into separate entrypoints/subgraphs so each stage has its own checkpoint you can restart from.

One caveat for any of these: a task that started but didn’t finish re-runs on resume, so keep side effects idempotent (docs: Idempotency).

For your case I’d go with option 1 - it keeps the readability and type-safety you like and doesn’t fight the replay model.

hey @pawel-twardziak thanks for your help!

I noticed there’s also “Node Level Caching” using key_funcwhich can be user customised. I haven’t used it but from briefly reading it seems that a task’s execution can be bypassed at 2 levels - if the checkpointer-writer table has no entry for the task the task will be considered for evaluation otherwise the result will be returned immediately (ie. what happens during crash recovery / interrupt resumptions). Next, if the task is considered the node-level cache will be checked further - if there’s a cache hit, then the result is returned immediately and task is once again not actually executed.

I wonder if we can make use of this. Imagine we define the key-func as something that derives a stable id using { thread-id + cache-run-id + task-name }. For the 1st run it’ll be a cache miss throughout and all functions will be executed. If we want to restart from task-x, we re-invoke the entrypoint for the same thread-id and pass it similar input instead of None. This will make langgraph create a new checkpoint (because the input != None) and consider executing all tasks. However we can pass the cache-run-id as the run-id of the previous task. So in reality all that happens is that we get a cache-hit and values returned immediately. When we encounter the task-name from which we want to restart, we flip the cach-run-id to current run-id for which nothing has ever been cached so all subsequently scheduled tasks are going to be actually executed.

If this works, it seems to fit in nicely. However 3rd restart will be a problem because some values are cached against the 1st run id and some against the 2nd run-id, so it becomes messy.

Unless there’s a way to say “use key-func to check cache and return value for cache-hit, but additionally also cache it against this another key/id”. That would make this feature complete because now 3rd, 4th etc restart/reset from arbitrary task-x would just mean: invoke the entrypoint with input != None so it creates a new checkpoint and tries to execute all tasks, but look up for cache-hit for run-id of the previous run (or the run for which this is a restart/reset) and finally once you encounter task-x, just switch to current run-id so everything subsequent is actually run. In the meantime, cache-hit or not, whatever value you get, cache it against the current run-id.

Is there something like that?

Other than that, I see that checkpoint-writes are keyed against task-name. So during replay I can dynamically change the task name of tasks to be invalidated (eg. taskname__{some-uuid-suffix}) which will force the task to be freshly run but that seems hacky (relying on laggraph’s impl details which might change in the future).

What do you think?

PS: You solution (option-1) should work too, just trying to see if there are alternatives that can be leveraged to automatically capture the task outputs instead of having to manually capture every task’s output into a permanent/persisted state and then applying checks, all at the application level.

hi @ustulation

great analysis @ustulation - I traced it through the runtime source (pregel/_loop.py, _algo.py, cache/base) and your model is mostly right, but the missing piece you want turns out to be structural. Point by point:

GOOD: Your 2-level model is correct, and level-1 wins over level-2. Replay re-attaches a completed task’s writes and the runner only executes tasks where not t.writes; the node cache (match_cached_writes) is consulted only for tasks still empty after replay:

# pregel/_loop.py
cached = {(t.cache_key.ns, t.cache_key.key): t
          for t in self.tasks.values()
          if t.cache_key and not t.writes}   # ← cache only for non-replayed tasks

GOOD: The new-checkpoint trick works. Passing non-None input takes the loop’s “fresh input” branch (discard any unfinished tasks from previous checkpoint), so the new checkpoint has no pending writes → every task is “considered” → the cache decides. Sound.

CAUTION: But key_func only sees the task’s input args - no thread_id/run_id:

# pregel/_algo.py
args_key = cache_policy.key_func(*call.input[0], call.input[1])

So {thread-id + cache-run-id + task-name} has to be passed in as task arguments, and “flip the run-id at task-x” is a per-task decision your entrypoint makes - i.e. still app-level control flow.

WARNING: The “also cache under another key” feature isn’t expressible today - and not just unimplemented. The loop computes one cache_key per task and uses it for both cache.get and cache.set (write happens only on a real miss - put_writes), so there’s no copy-forward. Worse, the stored key is an opaque hash (xxh3_128_hexdigest(key_func(input))), and BaseCache.clear() deletes by namespace = function identity only - so even a custom BaseCache can’t decompose keys to alias old→new run-ids, and you can’t surgically evict one (thread, run, task) entry. The node cache is a pure input-keyed memoizer, not a run-versioned store - your messiness on the 3rd restart is fundamental, not a bug. Worth filing as a feature request, but it’d need a core change (decouple read-key from write-key).

WARNING: The rename hack: task_id = hash(checkpoint_id, ns, step, node, PUSH, idx) - name is a component so renaming does invalidate, but so do step/idx/checkpoint_id. It’s an internal id contract; your “might change in future” instinct is right. Skip it.

GOOD: What actually hits your PS goal (auto-capture, no manual per-task state, composes over N restarts): use entrypoint.final + previous to persist a {task_name: output} map inside the checkpoint itself:

@entrypoint(checkpointer=checkpointer)
def workflow(inp, *, previous: dict | None = None):
    done = dict(previous or {})
    def step(name, fn, *a):
        if inp.restart_from and _at_or_after(name, inp.restart_from):
            done.pop(name, None)
        if name not in done:
            done[name] = fn(*a).result()
        return done[name]
    r0 = step("task0", task0, inp.x)
    r1 = step("task1", task1, r0)
    r2 = step("task2", task2, r1)
    return entrypoint.final(value=step("task3", task3, r2), save=done)

One logical slot per task → no run-id sprawl, no external store, no impl-detail reliance, and it stays type-checkable. If you’d rather use cache_policy, use it as a plain memoizer with a stable key and task_x.clear_cache(cache) to force re-runs (works cleanest single-thread, since clear is per-function-namespace).

(Refs: docs - Functional API for entrypoint.final/previous/determinism; source - pregel/_loop.py, pregel/_algo.py, cache/base.)

@pawel-twardziak nicely explained, thank you!

One last side question - just to consult what you think is a better design. Say if we go with for graph API, is it OK to derive data by querying langgraph or is it better to cache what we need application side (in our own db) ?

Eg., we need to show our workflow progress to a user - what (super)step has been run so far and what output it has produced etc. With Graph API, one way to do it is to just parse langgraph structure into our structure on the fly and send/display it: iterate over get_state_history to get snapshots and for each snapshot:

  1. get checkpoint-id as snapshot.config.get("configurable", {}).get("checkpoint_id")
  2. tasks in that super step with snapshot.tasks
  3. for each task get the results with task.result and parse that accordingly, name with task.nameand so on.
  4. Use checkpoint-id to paginate (eg. process 10 super-steps at a time) - next time pass before_config to get_state_history to get the next/previous 10 and so on.

Is this OK (not just for POC but proper production usage at scale) or this is more like langgraph internals which langgraph uses for its own purposes and the application should rather use their own storage and logic and handle things their side?

Both methods will work today - having the application store/handle it gives more control (eg. before each node returns, capture whatever we want into our db - using a decorator for ease) while just deriving it from langgraph allows to take advantage of “langgraph is storing it anyway” but not sure if we are relying on something that wasn’t built for this purpose and can change in the future (ie how langgraph stores and deals with snapshots, history and schema).

Which one would you choose/recommend or you don’t mind either way?

hi @ustulation

sorry for late follow-up - was busy. Will continue today on this thread :slight_smile:

@pawel-twardziak nw , thanks !

The main reason for that final question is I’m still evaluating graph-vs-functional API. While functional-API is a clear winner when it comes to complex branching, big graphs etc because the workflow just reads like normal python code, it’s also a bit lacking in langgraph.

Unlike graph API I’m unable to get data out of functional API. There’s no get_state_history equivalent to get per task result/interrupt, so I’ve store my task’s output in my own table - keyed by deterministic task-id (say uuid5 of run-id + task-order). This is fairly easy. It might even be for the best because I can store data whatever way I want, optimised for my retrieval and unlike snapshot history (get_state_snapshot) I can avoid compounding data - every snapshot given has full cumulative data from all previous snapshots + the current - which is a huge bloat if constantly calling it on the fly in a critical path (say some HTTP GET /steps API)

But OTOH, it is a downside of functional API that I cannot get data out and have to store it myself.

Hence the question: Is get_state_history meant to be used in API hot paths for retrieving workflow steps + associated data or is it best recommended to store application data in application controlled tables? If it’s the latter then the disadvantage of functional API disappears wrt to this (though we have to add some bloat - eg. to manage our step-data table), otherwise it can be noted as a disadvantage (can’t get data/interrupts out of state-history).

@ustulation good questions, and your instincts are right on all three. I checked the source and:

On “Functional API can’t give per-task results/interrupts”: mostly true. get_state and get_state_history do work on an @entrypoint (the decorator returns a Pregel), but an entrypoint compiles to a single-node graph - one node, channels START/END/PREVIOUS. So snapshots are at entrypoint-invocation granularity, not per @task. The individual task outputs are in the checkpoint as pending writes (keyed by a deterministic task_id, that’s how resume restores them), but they’re not surfaced through the public StateSnapshot. Interrupts are visible, but at the entrypoint level, not attributed to a specific inner task. This falls straight out of “one checkpoint per entrypoint invocation” vs Graph API’s “one checkpoint per super-step”.

On storing task output yourself keyed by uuid5(run_id, task_order): that’s the recommended pattern, not a workaround. Keep using your own key (don’t reuse LangGraph’s internal task_id, it’s tied to the checkpoint/run, not a stable business key). And you can skip the per-node decorator by streaming the run instead: stream_mode=[“updates”] for per-node output, “tasks” for task start/result/error, or custom via get_stream_writer(). One sink, no boilerplate.

On the snapshot bloat: confirmed. get_state_history is thin over checkpointer.list and rebuilds a full snapshot per checkpoint via _prepare_state_snapshot (it re-runs prepare_next_tasks and re-applies pending writes, not a cheap delta), and each snapshot carries the full channel values. With accumulating state every checkpoint stores the whole cumulative blob, so paging history is roughly O(checkpoints x state size). Fine for time-travel, wrong shape for a GET /steps.

So your actual question, is get_state_history meant for API hot paths: no. It’s for debugging, time-travel and human-in-the-loop resume. Its only query surface is filter/before/limit, newest-first, snapshots are full-state and reconstructed, and it’s thread-scoped (you can’t ask “all runs where step X failed”). For production progress you want your own table, written as the run executes (stream into it), keyed by your own id and optimised for your reads. Keep the checkpointer for crash recovery/resume, and LangSmith for eng observability.

Which means the “can’t get data out of Functional API” downside mostly dissolves: even with Graph API you shouldn’t serve user-facing progress from get_state_history on a hot path, so both API styles end up at the same design. The one genuine, narrow limitation to note is that Functional API’s built-in introspection is entrypoint-grained, so unlike Graph API you can’t lean on the framework for per-task history while debugging - you rely on your own store or streaming for that. For production serving that’s where you’d keep the data anyway, so it’s not really a disadvantage for the decision. Given that, I’d keep Functional API for the readability win on your branching workflow and back it with a thin step table fed by streaming.

Sources:

Official docs (docs.langchain.com only):

Source code (langgraph):

  • libs/langgraph/langgraph/func/__init__.py:576 - @entrypoint builds a single-node Pregel (one PregelNode, channels START/END/PREVIOUS); this is why Functional API snapshots are entrypoint-grained, not per-@task.
  • libs/langgraph/langgraph/pregel/main.py:1479 - get_state_history: thin wrapper over checkpointer.list(config, before=, limit=, filter=), newest-first, yielding _prepare_state_snapshot per checkpoint.
  • libs/langgraph/langgraph/pregel/main.py:1144 - _prepare_state_snapshot: reconstructs each snapshot (re-runs prepare_next_tasks, re-applies pending writes) and carries full channel values - grounds the “compounding/bloat” point.
  • libs/langgraph/langgraph/pregel/_loop.py - _reapply_writes_to_succeeded_nodes: completed @task results restored from checkpoint pending writes by deterministic task_id (the resume mechanism; also why those results aren’t surfaced via StateSnapshot).
  • libs/langgraph/langgraph/pregel/_algo.py - task_id_func(checkpoint_id, ns, step, node, PUSH, idx): the internal task_id is checkpoint/run-scoped, not a stable business key (why you should use your own uuid5).
  • libs/langgraph/langgraph/types.py - StateSnapshot and PregelTask NamedTuples (snapshot shape: values, next, config, metadata, tasks, interrupts).

Nice! This really helps because that’s completely how my mental model is too. Even with graph API we have to build so much boilerplate anyway - for eg. if you restart from some prior node x0, langgraph automatically restores the state for you clearing the channles and all which is good/expected, but… it doesn’t do anything for the subgraphs that x0, x1 etc might have invoke()ed inside them (though they share the parent’s checkpointer) and we have to manually deal with all those things.

I think a task decorator is actually better - it gives us a lot of other advantages but also durability/robustness. If langgraph has checkpointed a node/task and we get into the body of the steaming for loop and crash (SIGKILL/power-cable-pulled), we won’t receive that event next time during recovery because it’s already been dealt with (langgraph will skip over everything that was processed/checkpointed) so we lose robustness.

If we (idempotently) do stuff in our decorator before handing over the controls to langgraph then we’ll have achieved full durability - if we crash before langgraph noted it, we get run again and mostly a NoOp. Once langgraph has noted it, it’s implicit that we have noted it too in our tables .

And there are many more advantages, stuff to do before the task is actually called, before the task returns to langgraph including any serde etc we want like pydantic with model-dump(alias=true/false) etc.

@ustulation thanks for closing the loop on this, and honestly thanks for the engagment throughout - it made for a good thread.

A fair caveat from my side: I don’t have the full picture of how your system is wired or the exact problems you’re hitting day to day, so take my answers as grounded in the docs and source rather than in your concrete setup. With that said, both of your closing points hold up.

On subgraphs not being reset when you restart from a prior node: thats a real edge. Time-travel/replay is scoped to the graph you call it on. A subgraph added as a managed node keeps its state under its own checkpoint_ns and is only surfaced when you ask (get_state(config, subgraphs=True)), so even then the parent snapshot doesnt fold child state in. A subgraph you call with subgraph.invoke() inside a node body shares the checkpointer but sits outside the parent’s node lifecyle, so a parent fork/replay restores the parent channels and leaves the child’s namespaced checkpoints alone. Nested reset/replay/fork is yours to orchestrate, whcih is the manual work you’re describing.

On the decorator vs streaming for durabilty: you’re right, and I’d walk back my streaming suggestion for the durable record. On resume LangGraph restores completed task results from pending writes and skips any task that aleady has writes, so a finished task is never re-run and its stream events are never re-emitted - which makes a stream consumer at-most-once across a crash. A decorator wrapping the task body puts your write inside the same unit LangGraph checkpoints, so its at-least-once plus idempotency, effectivly exactly-once. Clean split: streaming for live UX progress, the idempotent decorator keyed by your uuid5(run_id, task_order) for the recrod of record.

What I’ll say beyond the mechanics: I really apreciate the way you reason about the architecture and keep pushing for the right solution rather than the quick one. That kind of thinking - framing the tradeoffs, stress-testing the design, knowing what you want the sytem to guarantee - is exactly where a developer’s value sits now in the AI era, when generating code is the easy part. Good luck wih the build, and thanks again!