Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 35 additions & 4 deletions lib/lang_ex/graph/compiled_graph.ex
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,11 @@ defmodule LangEx.Graph.Compiled do
opts
)
when cp != nil and resume_val != nil do
config = Keyword.get(opts, :config, [])

cp
|> load_checkpoint(Keyword.get(opts, :config, []))
|> resume_from_checkpoint(graph, resume_val, opts)
|> load_checkpoint(config)
|> resume_from_checkpoint(graph, resume_val, opts, cp, config)
end

def invoke(%__MODULE__{} = graph, input, opts) when is_map(input) do
Expand All @@ -63,7 +65,9 @@ defmodule LangEx.Graph.Compiled do
{:ok, %Checkpoint{pending_interrupts: [%{node: node} | _]} = saved},
graph,
resume_val,
opts
opts,
_cp,
_config
) do
Pregel.run(
graph,
Expand All @@ -72,7 +76,34 @@ defmodule LangEx.Graph.Compiled do
)
end

defp resume_from_checkpoint(_, _graph, _resume_val, _opts),
# When the latest checkpoint has no pending interrupts, search through
# recent checkpoints for the most recent one that does. This handles
# the case where a resumed continuation saves intermediate checkpoints
# that bury the next interrupt checkpoint.
defp resume_from_checkpoint(
{:ok, %Checkpoint{pending_interrupts: nil}},
graph,
resume_val,
opts,
cp,
config
) do
cp.list(config, limit: 20)
|> Enum.find(&(is_list(&1.pending_interrupts) and &1.pending_interrupts !== []))
|> case do
%Checkpoint{pending_interrupts: [%{node: node} | _]} = saved ->
Pregel.run(
graph,
saved.state,
build_run_opts(opts, graph, resume: %{node: node, value: resume_val}, step: saved.step)
)

_ ->
{:error, :no_pending_interrupt}
end
end

defp resume_from_checkpoint(_, _graph, _resume_val, _opts, _cp, _config),
do: {:error, :no_pending_interrupt}

defp resolve_initial_state(graph, input, opts) do
Expand Down
55 changes: 55 additions & 0 deletions test/lang_ex/checkpoint/interrupt_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,61 @@ defmodule LangEx.Checkpoint.InterruptTest do
assert %{approved: true, value: 420} = result
end

test "resume finds interrupt checkpoint even when later non-interrupt checkpoints exist" do
# This tests the scenario where:
# 1. Graph hits interrupt at node A, checkpoint saved with pending_interrupts
# 2. Resume resolves node A, graph continues to node B, C
# 3. Node B saves a checkpoint WITHOUT pending_interrupts (now the latest)
# 4. Node C hits another interrupt
# 5. A second resume call should find the interrupt from step 4,
# even though the latest checkpoint (from step 3) has no interrupts
graph =
Graph.new(value: 0, stage: "init")
|> Graph.add_node(:first_pause, fn state ->
result = LangEx.Interrupt.interrupt("First pause")
%{stage: "after_first", value: state.value + result}
end)
|> Graph.add_node(:middle, fn state ->
%{stage: "middle_done", value: state.value + 100}
end)
|> Graph.add_node(:second_pause, fn state ->
result = LangEx.Interrupt.interrupt("Second pause")
%{stage: "after_second", value: state.value + result}
end)
|> Graph.add_node(:finalize, fn state ->
%{stage: "complete", value: state.value * 2}
end)
|> Graph.add_edge(:__start__, :first_pause)
|> Graph.add_edge(:first_pause, :middle)
|> Graph.add_edge(:middle, :second_pause)
|> Graph.add_edge(:second_pause, :finalize)
|> Graph.add_edge(:finalize, :__end__)
|> Graph.compile(checkpointer: LangEx.Checkpointer.Mock)

thread = "test-multi-interrupt-#{System.unique_integer([:positive])}"

# First invoke hits first_pause interrupt
{:interrupt, "First pause", _} =
LangEx.invoke(graph, %{value: 1}, config: [thread_id: thread])

# Resume with value 10, graph continues through middle -> second_pause interrupt
# The middle node saves a checkpoint that becomes the latest (no interrupt)
{:interrupt, "Second pause", state_at_second} =
LangEx.invoke(graph, %Command{resume: 10}, config: [thread_id: thread])

# State should reflect first_pause (1+10=11) and middle (+100=111)
assert state_at_second.value === 111

# Resume the second interrupt — this is the critical test.
# The latest checkpoint is from :middle (no interrupt), but the
# second_pause interrupt checkpoint should still be found.
{:ok, final} =
LangEx.invoke(graph, %Command{resume: 5}, config: [thread_id: thread])

assert final.stage === "complete"
assert final.value === (111 + 5) * 2
end

test "interrupt without checkpointer returns interrupt tuple" do
graph =
Graph.new(x: 0)
Expand Down