You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
signature supports resilient infinite and conditional loops. With
14
+
`continue_on_failure => true`, a consumed typed body activity failure skips
15
+
the condition and starts the next iteration; after a successful body, the
16
+
condition is evaluated normally. All errors returned by body SQL, HTTP, and
17
+
multipart activities are consumable, including query, authorization,
18
+
connection, and network errors. Condition, graph, protocol, unknown child,
19
+
and orchestration/runtime failures remain fatal. The
20
+
`continue_on_failure` syntax is experimental and may change in future
21
+
releases.
22
+
23
+
### Changed
24
+
25
+
-**Loop lifetime:** raises the loop-iteration backstop from 100,000 to
26
+
8,388,608 (`2^23`), approximately 80 years at five-minute ticks.
27
+
9
28
### Fixed
10
29
11
30
-**Caller-transaction handoff:**`df.start()` now tracks the originating transaction until it commits or aborts, so legal caller transactions lasting more than five seconds no longer leave a `pending``df.instances` row paired with a failed engine execution. Graph admission uses durable backoff and bounded-history compaction rather than holding a worker connection while it waits.
|`df.loop(body, cond)`| Repeat while condition is true |`df.loop(body, 'SELECT count(*) > 0 FROM q')`|
267
+
|`df.loop(body, condition DEFAULT NULL, continue_on_failure DEFAULT false)`| Repeat forever or while a condition is true, optionally continuing after body activity failures |`df.loop(body, 'SELECT count(*) > 0 FROM q', continue_on_failure => true)`|
269
268
|`df.break()`| Exit enclosing loop |`df.break()`|
270
269
|`df.break(value)`| Exit loop with **literal** return value (not auto-wrapped as SQL) |`df.break('{"done": true}')`|
271
270
|`df.start(func, label, database)`| Start function (optionally in another database) |`df.start('SELECT 1', 'job')`|
@@ -531,7 +530,8 @@ SELECT df.start(
531
530
532
531
### Loop Condition Example
533
532
534
-
For `df.loop(body, condition)`, the condition is evaluated after each iteration:
533
+
For `df.loop(body, condition)`, the condition is evaluated after each
By default, it and `@>` are fail-fast: an iteration failure fails the loop.
1233
+
Passing `continue_on_failure => false` is equivalent to the default and does
1234
+
not change where the loop is hosted. When it is `true`, each body iteration
1235
+
runs as a child orchestration. After a successful body iteration, the child's
1236
+
results are merged into the parent result map before the optional condition is
1237
+
evaluated. After a consumed typed application failure from a body activity,
1238
+
the condition is skipped and the parent starts the next iteration. This
1239
+
includes any error returned by a body SQL, HTTP, or multipart activity, such as
1240
+
a query, authorization, connection, or network error. Condition failures,
1241
+
malformed graph or child data, unrecognized child errors, child-ID collisions,
1242
+
and orchestration/runtime failures remain fatal.
1243
+
1244
+
This is useful for scheduled maintenance such as a pg_textsearch-style
1245
+
per-index compactor. A simplified periodic backstop repeatedly runs one
1246
+
compaction step until the index has no more reducible debt:
1247
+
1248
+
```sql
1249
+
SELECTdf.start(
1250
+
df.loop(
1251
+
df.wait_for_schedule('*/5 * * * *')
1252
+
~>df.loop(
1253
+
$$SELECTpublic.bm25_compact_step(
1254
+
'documents_idx'::regclass
1255
+
) AS ran$$
1256
+
|=>'step'
1257
+
~>df.if(
1258
+
'SELECT $step.ran',
1259
+
'SELECT true',
1260
+
df.break()
1261
+
)
1262
+
),
1263
+
continue_on_failure => true
1264
+
),
1265
+
'documents-index-compaction'
1266
+
);
1267
+
```
1268
+
1269
+
The managed pg_textsearch workflow also validates the physical index identity
1270
+
before each cascade, so replacing or dropping the index stops stale work.
1271
+
1272
+
The condition and continuation policy can be combined:
1273
+
1274
+
```sql
1275
+
df.loop(
1276
+
'SELECT process_next_item()',
1277
+
'SELECT count(*) > 0 FROM task_queue',
1278
+
continue_on_failure => true
1279
+
)
1280
+
```
1281
+
1282
+
The next schedule is computed only after the prior iteration finishes. Slow or
1283
+
failed iterations therefore do not overlap, queue missed ticks, or backfill
1284
+
earlier schedule times.
1285
+
1217
1286
### Cron-Style Scheduling
1218
1287
1219
1288
Use `df.wait_for_schedule()` with a cron expression:
@@ -1319,10 +1388,21 @@ SELECT df.start(
1319
1388
1320
1389
Each loop iteration advances via *continue-as-new*, which restarts the loop with fresh state while preserving durability. Where that restart happens depends on whether the loop is the **root** of the function:
1321
1390
1322
-
- A **root loop** (the function graph's outermost node, e.g. `df.start(df.loop(...))` or `df.start(@>'SELECT work()')`) runs inline on the function's own orchestration. There is no surrounding work to preserve, so each iteration simply restarts the function. The `@>` operator does not make a loop root by itself: `df.seq('SELECT setup()', @>'SELECT work()')` is non-root because the sequence is the graph root.
1323
-
- A **non-root loop** (a loop with prefix/suffix nodes, or one nested inside a `df.if()`, JOIN (`&`), or RACE (`|`) branch) runs as its own **child sub-orchestration**. Only the loop body restarts on each iteration — any work *before* the loop runs exactly once and is never re-executed, and a loop nested in a parallel branch gets its own durable instance.
1324
-
1325
-
This is transparent to your workflow; it only affects observability. The child sub-orchestration is an internal durable instance: it does **not** appear in `df.list_instances()` (which lists only the instances you started with `df.start()`). Instead, the loop node's status in `df.instance_nodes()` / `df.explain()` reflects the child's progress, so you observe the loop through its parent instance as usual.
1391
+
- A fail-fast **root loop** (the function graph's outermost node, e.g. `df.start(df.loop(...))` or `df.start(@>'SELECT work()')`) runs inline on the function's own orchestration. There is no surrounding work to preserve, so each iteration simply restarts the function. The `@>` operator does not make a loop root by itself: `df.seq('SELECT setup()', @>'SELECT work()')` is non-root because the sequence is the graph root.
1392
+
- A fail-fast **non-root loop** (a loop with prefix/suffix nodes, or one nested inside a `df.if()`, JOIN (`&`), or RACE (`|`) branch) runs as its own **child sub-orchestration**. Only the loop body restarts on each iteration — any work *before* the loop runs exactly once and is never re-executed, and a loop nested in a parallel branch gets its own durable instance.
1393
+
- Fail-fast iterations execute inline within whichever orchestration hosts the loop. Explicit `continue_on_failure => false` keeps this placement; it does not force a non-root loop into the function's root orchestration.
1394
+
- A loop with `continue_on_failure => true` runs every body iteration in a child orchestration. After a successful body iteration, the child's results are merged into the parent result map before the optional condition is evaluated. After any error returned by a body SQL, HTTP, or multipart activity, the condition is skipped and the loop parent proceeds to the next iteration. Condition failures and unrecognized child, graph, protocol, orchestration, or runtime failures remain fatal.
1395
+
1396
+
These child sub-orchestrations are internal durable instances and do **not**
1397
+
appear in `df.list_instances()`, which lists only instances started with
1398
+
`df.start()`. `df.instance_nodes()` / `df.explain()` show the loop parent as
1399
+
running and may show a failed iteration on its body-root or descendant nodes.
1400
+
Those node statuses are not permanent per-iteration history: a later iteration
1401
+
can supersede them with newer execution status.
1402
+
1403
+
All loops have a finite backstop of 8,388,608 (`2^23`) iterations: about 80
1404
+
years at five-minute intervals, or a little over 97 days at the enforced
// Wait for all to complete (duroxide handles parallelism)
715
-
let (left_result, right_result) =tokio::join!(
716
-
left_handle.into_orchestration(),
717
-
right_handle.into_orchestration()
718
-
);
719
-
720
-
// Combine results
721
-
letresults=vec![left_result?, right_result?];
722
-
Ok(serde_json::to_string(&results)?)
723
-
}
724
-
```
725
-
726
-
For RACE, duroxide's `select` is used to return the first completed result.
704
+
Each JOIN or RACE branch runs as an explicitly named `execute_subtree` child
705
+
whose input contains the validated graph snapshot, variables, label, and
706
+
canonically serialized named results. The child returns a `SubtreeEnvelope`
707
+
containing its result, named-result updates, and optional `df.break()` control
708
+
flow.
709
+
710
+
JOIN schedules its two branches, plus any ordered `join3` extras, then waits
711
+
with `ctx.join()`. Successful envelopes are processed in branch order and
712
+
their named results are merged into the parent. Historically, JOIN returned
713
+
the first branch error in that same order. That behavior remains unchanged
714
+
outside failure-isolated loop bodies for replay compatibility. Inside a
715
+
failure-isolated body, every settled outcome is inspected so a fatal sibling
716
+
cannot be hidden by a recoverable activity failure or `df.break()`. The
717
+
deterministic priority is fatal failure, then break, then recoverable activity
718
+
failure; equal-priority outcomes keep the first branch.
719
+
720
+
RACE uses `ctx.select2()` and returns the first completed branch. The losing
721
+
branch is cancelled, and a losing loop branch receives a terminal fallback
722
+
node-status stamp because cancellation may stop it before it can stamp itself.
727
723
728
724
### Loops and Continue-As-New
729
725
@@ -734,7 +730,27 @@ Loops use duroxide's `continue_as_new` to avoid unbounded history growth. Their
734
730
735
731
`execute_subtree` is therefore structurally identical to `execute_function_graph`: both root an execution context at their own node and host an inline root loop. They differ only in the input envelope they re-enter with on `continue_as_new` (`FunctionInput` vs `SubtreeInput`), in the fact that only the root orchestration touches instance-level status, and in where their graph comes from — `execute_function_graph` loads it from `df.nodes` on its first generation, while `execute_subtree` receives it inline from its parent. The graph is loaded exactly once per instance and then carried inline through every child input and every `continue_as_new` generation, so `submitted_by` is fixed for the instance's lifetime and a post-start `df.nodes` tamper is never read. Role deletion and privilege revocation are still enforced per node execution, by connecting *as*`submitted_by` for SQL and by re-checking `EXECUTE` privilege per HTTP request.
736
732
737
-
Both paths call `run_loop_iteration`, which executes the body, catches `NodeError::Break`, evaluates the optional post-body condition, and propagates `NodeError::Failure`. A child stamps its LOOP node `running` on each generation and `completed` or `failed` on exit; because `continue_as_new` returns a future that never resolves, a continuing generation never stamps a terminal status. If a live loop loses a RACE, the parent records the loop node as terminal `failed` with a cancellation reason because duroxide cancellation stops the child before it can run its own terminal stamp.
733
+
Fail-fast loops call `run_loop_iteration`, which executes the body inline,
734
+
catches `NodeError::Break`, evaluates the optional post-body condition, and
735
+
propagates both application and non-application failures. A loop configured
736
+
with `continue_on_failure => true` instead calls
737
+
`run_failure_isolated_body`: each body iteration runs as a fresh
738
+
`execute_subtree` child. On success, the subtree envelope is parsed and its
739
+
named results are merged into the parent result map before the parent evaluates
740
+
the optional condition. On a structurally encoded body application failure,
741
+
the parent consumes the failure, skips the condition because body results may
742
+
be absent, and advances to the next generation. Every error returned by a body
743
+
SQL, HTTP, or multipart activity uses this application-failure path, including
744
+
query, authorization, connection, and network errors. Condition failures,
745
+
malformed envelopes or graph data, child-ID collisions, and unrecognized
746
+
orchestration/runtime failures remain fatal.
747
+
748
+
A child stamps its LOOP node `running` on each generation and `completed` or
749
+
`failed` on exit; because `continue_as_new` returns a future that never
750
+
resolves, a continuing generation never stamps a terminal status. If a live
751
+
loop loses a RACE, the parent records the loop node as terminal `failed` with a
752
+
cancellation reason because duroxide cancellation stops the child before it
753
+
can run its own terminal stamp.
738
754
739
755
Node status stamps contain the full composed orchestration lineage: `{root_instance}::{generation}::{child_node}::{generation}...`. Read-time inference and the write fence walk that lineage so stale writes and superseded nested branches are evaluated at every ancestor generation.
0 commit comments