Repository navigation
df.loop continue_as_new restarts from root when loop is not root #227
Description
Summary
df.loop uses continue_as_new after each iteration, but the continuation input only preserves the instance metadata (instance_id, label, vars). When the loop node is not the root of the function graph, the next orchestration generation reloads the graph and starts again from the instance root, re-executing any prefix nodes before the loop.
This causes side effects before the loop to run once per loop iteration instead of once per function instance.
Repro
Run this in a database where pg_durable is installed and the background worker is running:
DROP TABLE IF EXISTS table_outside_loop;
DROP TABLE IF EXISTS table_inside_loop;
CREATE TABLE table_outside_loop (
id SERIAL PRIMARY KEY,
inserted_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);
CREATE TABLE table_inside_loop (
id SERIAL PRIMARY KEY,
inserted_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);
SELECT df.start(
df.seq(
'INSERT INTO table_outside_loop DEFAULT VALUES RETURNING id, inserted_at',
df.loop(
'INSERT INTO table_inside_loop DEFAULT VALUES RETURNING id, inserted_at'
~> df.sleep(10)
)
),
'manual-nonroot-loop-prefix',
NULL
) AS instance_id;After two loop iterations, inspect the tables:
SELECT COUNT(*) AS outside_loop_rows FROM table_outside_loop;
SELECT COUNT(*) AS inside_loop_rows FROM table_inside_loop;
TABLE table_outside_loop ORDER BY id;
TABLE table_inside_loop ORDER BY id;Expected behavior
table_outside_loop should contain exactly one row for the function instance. The outer/prefix SQL step should run once before entering the loop.
table_inside_loop should grow by one row per loop iteration.
Actual behavior
table_outside_loop gets one new row per loop iteration/generation. In a local run, after the second loop generation:
outside_loop_rows = 2
inside_loop_rows = 2
The timestamped rows showed the outside-loop insert running again immediately before the second inside-loop insert.
Likely cause
execute_loop_node calls continue_as_new with only:
FunctionInput {
instance_id: graph.instance_id.clone(),
label: exec_ctx.label.clone(),
vars: exec_ctx.vars.clone(),
}The new orchestration generation then runs the top-level execute path, calls load_function_graph, and starts from graph.root_node_id again. It does not preserve the loop node id or the current execution context as the continuation start point.
This is especially problematic for finite loops when setup state appears before the loop. For example, a prefix SQL node that resets a counter can cause a loop with df.break after N iterations to never reach N, because the counter is reset on every continue_as_new generation.
Notes
This affects loop surfaces built on the LOOP node type, including:
df.loop(body)df.loop(body, condition)@> body- recurring cron-style patterns that wrap
df.wait_for_schedule(...)indf.loop/@>
Activity
pinodeca commentedon Jun 11, 2026
Additional observed output from running the repro:
This shows
table_outside_loopgrowing in lockstep withtable_inside_loop; the outside-loop insert is re-executed once per loop generation.pinodeca commentedon Jun 11, 2026
Fix sketch
At a high level,
continue_as_newneeds to resume the loop node, not restart the whole function root.Today loop continuation input is only:
Then the next generation enters the top-level execution path and does:
So it always restarts from
root_node_id.A simple-looking fix would be to extend
FunctionInputwith continuation state:Then top-level
execute()would choose:and execute from that node instead of always from the root.
For loops,
execute_loop_nodewould callcontinue_as_newwith:That would let the next generation start at the same
LOOPnode, preserving named results produced before the loop. The loop body could then run the next iteration without replaying prefix nodes.However,
start_node_idalone is probably not sufficient for the general case.Important details:
Preserve named results
Without carrying
results, a loop that depends on a named result computed before the loop would break after the firstcontinue_as_new.Status updates
The top-level
execute()currently marks the whole instancerunningeach generation. That can stay idempotent. Node status may need care because the same loop/body nodes are reused every iteration.Completion after
df.breakor while-condition falseIf the resumed start node is the loop itself and it exits, what should happen next is tricky. If the original graph was:
resuming only at
looploses the suffix after the loop.So a complete fix likely needs a continuation stack, not just
start_node_id.For a correct general solution, the continuation should preserve “where to continue after this loop exits.” Conceptually:
But because the graph is binary and currently executed recursively, “after this node” is not directly encoded in child nodes. We would need either:
continue_as_new.The robust design is the second one: represent execution progress as explicit frames and serialize that frame stack through
continue_as_new. Then a loop iteration can resume at the loop body/loop node, and when the loop eventually breaks or its while-condition turns false, execution can continue with the frame that was after the loop.A pragmatic staged approach could be:
LOOPmust be the root node. This prevents silent bad behavior.start_node_id + resultscontinuation, good for root loops and maybe simple loop-only continuations.break, while-loops, and suffix nodes all behave correctly acrosscontinue_as_new.I would avoid stopping at only
start_node_id: it fixes “prefix reruns” but risks breakingprefix ~> loop ~> suffixby never running the suffix after the loop exits.pinodeca commentedon Jun 11, 2026
Design note: execute loops as sub-orchestrations
A promising fix is to make
LOOPexecution a dedicated sub-orchestration rather than having the mainexecute-function-graphorchestration callcontinue_as_newdirectly.Today a non-root loop is executed inline by the root orchestration. When the loop reaches the next iteration, it calls
continue_as_newwith only the top-levelFunctionInput:The next generation then re-enters the root orchestration and starts from
graph.root_node_id, so any prefix before the loop runs again.If loops were child orchestrations, the parent would execute the graph around the loop and schedule the loop node as a durable child:
Then
continue_as_newwould apply only to the child loop orchestration. The parent would remain parked waiting for the child result. This gives the expected behavior for:where
prefixruns once,bodyruns once per loop generation, andsuffixruns once after the loop exits.The loop child input should carry at least:
On every
continue_as_new, the child must preserve the loop node id, named results, vars, and label. On normal exit (df.breakor a false while condition), it should return an envelope similar toexecute_subtree:The parent then parses the envelope, merges returned named results into its local
resultsmap, stores any loop result name, and continues executing the rest of the graph.This is cleaner than making the root orchestration continuation-aware with only
start_node_id, because it preserves composition forprefix ~> loop ~> suffixwithout needing the root generation to know how to resume in the middle of aTHENchain. It also matches the existingJOIN/RACEpattern where sub-orchestrations return both their result and updated named-results map.Implementation considerations:
pg_durable::orchestration::execute-loop.LOOPbranch in the parent executor to schedule the loop child and merge its envelope, instead of executingexecute_loop_nodeinline.continue_as_newshould stay scoped to its own child instance.df.breakexits the nearest loop child and returns its break value to the parent, rather than leaking as a sentinel through unrelated parents.resultsmap in the childcontinue_as_newinput.Fix df.loop with child sub-orchestrations
Fix df.loop with child sub-orchestrations (#228)