Skip to content

Commit 807be96

Browse files
authored
Add failure-isolated loop iterations (#377)
* Add opt-in loop failure continuation * Isolate opted-in loop iterations * Test resilient scheduled loop iterations * Harden failure-isolated loop execution Distinguish body activity failures from child runtime and protocol faults so continue-on-failure loops only consume expected application failures. Reject conditional loops combined with failure continuation, document the new loop policy and lifetime, and pin deterministic child input serialization. * Unify the loop SQL API * Support resilient conditional loops * Unify conditional loop failure handling * Correct the pg_textsearch compactor example * Clarify the loop API test name * Prioritize fatal JOIN branch failures * Harden failure-isolated loop conditions * Mark continue_on_failure syntax experimental --------- Co-authored-by: tjgreen42 <1738591+tjgreen42@users.noreply.github.com>
1 parent fe797ac commit 807be96

17 files changed

Lines changed: 1780 additions & 193 deletions

.agents/skills/pg-durable-sql/SKILL.md

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,18 @@ df.if(condition TEXT, then_branch TEXT, else_branch TEXT) → TEXT
9393
-- result_name is a capture from |=> earlier in the graph.
9494
df.if_rows(result_name TEXT, then_branch TEXT, else_branch TEXT) → TEXT
9595

96-
-- Loop — infinite or while-condition
97-
df.loop(body TEXT) → TEXT -- Infinite loop
98-
df.loop(body TEXT, condition TEXT) → TEXT -- While-loop: repeats while condition is truthy
96+
-- Loop — one unified signature
97+
df.loop(
98+
body TEXT,
99+
condition TEXT DEFAULT NULL,
100+
continue_on_failure BOOLEAN DEFAULT false
101+
) → TEXT
102+
-- Experimental: continue_on_failure syntax may change in future releases.
103+
-- NULL condition: infinite loop.
104+
-- Non-NULL condition: do-while semantics; evaluate it after each successful body.
105+
-- With continue_on_failure => true, a consumed body activity failure skips the
106+
-- condition and starts the next iteration. The loop's condition and
107+
-- orchestration/runtime failures remain fatal.
99108

100109
-- Break from enclosing loop
101110
df.break() → TEXT -- Exit with NULL
@@ -227,7 +236,7 @@ Automatically available during execution:
227236

228237
## Condition Evaluation (Truthiness)
229238

230-
Used by: `?>`, `!>`, `df.if()`, `df.loop(body, condition)`
239+
Used by: `?>`, `!>`, `df.if()`, and the optional condition in `df.loop()`
231240

232241
The first column of the first row is evaluated:
233242

@@ -345,7 +354,7 @@ SELECT df.start(
345354
-- Cancel with: SELECT df.cancel('instance_id', 'Stopping heartbeat');
346355
```
347356

348-
### While-Loop with Break
357+
### Loop with Break
349358

350359
```sql
351360
SELECT df.start(
@@ -361,6 +370,27 @@ SELECT df.start(
361370
);
362371
```
363372

373+
### Conditional Loop with Failure Continuation
374+
375+
```sql
376+
SELECT df.start(
377+
df.loop(
378+
'SELECT process_next_item()',
379+
'SELECT EXISTS (
380+
SELECT 1 FROM work_queue WHERE status = ''pending''
381+
)',
382+
continue_on_failure => true
383+
),
384+
'resilient-worker'
385+
);
386+
```
387+
388+
This is a do-while loop: after a successful body execution, the condition is
389+
evaluated and the loop continues while it is truthy. After a consumed body
390+
activity failure, the condition is skipped and the body starts again.
391+
Errors returned by body SQL, HTTP, and multipart activities are consumable.
392+
Condition and orchestration/runtime failures remain fatal.
393+
364394
### Cron Scheduled Job
365395

366396
```sql

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,25 @@ Pre-1.0 note: while `pg_durable` is in major version `0`, minor releases may inc
66

77
## [0.2.8] - Unreleased
88

9+
### Added
10+
11+
- **Failure-isolated loops:** the unified
12+
`df.loop(body, condition DEFAULT NULL, continue_on_failure DEFAULT false)`
13+
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+
928
### Fixed
1029

1130
- **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.

USER_GUIDE.md

Lines changed: 87 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -264,8 +264,7 @@ df.sql('SELECT 1') ~> df.sql('SELECT 2')
264264
| `df.join3(a, b, c)` | Three in parallel | `df.join3(a, b, c)` |
265265
| `df.race(a, b)` | Execute in parallel, first wins | `df.race(fast_query, slow_query)` |
266266
| `df.if(cond, then, else)` | Conditional branch | `df.if('SELECT true', a, b)` |
267-
| `df.loop(body)` | Repeat forever | `df.loop(body)` |
268-
| `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)` |
269268
| `df.break()` | Exit enclosing loop | `df.break()` |
270269
| `df.break(value)` | Exit loop with **literal** return value (not auto-wrapped as SQL) | `df.break('{"done": true}')` |
271270
| `df.start(func, label, database)` | Start function (optionally in another database) | `df.start('SELECT 1', 'job')` |
@@ -531,7 +530,8 @@ SELECT df.start(
531530

532531
### Loop Condition Example
533532

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
534+
successful body execution:
535535

536536
```sql
537537
-- Loop while there are pending items
@@ -1214,6 +1214,75 @@ SELECT df.start(
12141214
);
12151215
```
12161216

1217+
The unified signature is
1218+
`df.loop(body, condition DEFAULT NULL, continue_on_failure DEFAULT false)`.
1219+
1220+
> **Experimental:** The `continue_on_failure` syntax is subject to change in
1221+
> future releases.
1222+
1223+
It supports four call shapes:
1224+
1225+
```sql
1226+
df.loop(body)
1227+
df.loop(body, condition)
1228+
df.loop(body, continue_on_failure => true)
1229+
df.loop(body, condition, continue_on_failure => true)
1230+
```
1231+
1232+
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+
SELECT df.start(
1250+
df.loop(
1251+
df.wait_for_schedule('*/5 * * * *')
1252+
~> df.loop(
1253+
$$SELECT public.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+
12171286
### Cron-Style Scheduling
12181287

12191288
Use `df.wait_for_schedule()` with a cron expression:
@@ -1319,10 +1388,21 @@ SELECT df.start(
13191388
13201389
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:
13211390
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
1405+
one-second minimum.
13261406
13271407
### Stopping a Loop Externally
13281408

docs/ARCHITECTURE.md

Lines changed: 55 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -477,31 +477,36 @@ of every handler having to recognise an in-band JSON break sentinel:
477477
pub enum NodeError {
478478
/// df.break() fired. Carries the break value. Caught only by execute_loop_node.
479479
Break(String),
480-
/// A genuine failure. Surfaces as a failed instance.
480+
/// An expected workflow activity failure.
481+
Application(String),
482+
/// A graph, protocol, configuration, or runtime failure.
481483
Failure(String),
482484
}
483485

484486
pub type NodeResult = Result<String, NodeError>;
485487

486-
// Any `?` on an existing Result<_, String> auto-converts the error to Failure, so
487-
// activity calls and helpers need no per-call changes.
488+
// Structural/configuration helpers still convert String errors to Failure.
488489
impl From<String> for NodeError {
489490
fn from(e: String) -> Self {
490491
NodeError::Failure(e)
491492
}
492493
}
493494
```
494495

495-
`execute_loop_node` is the only handler that catches `NodeError::Break` (turning it into the
496-
loop's `Ok` result); `NodeError::Failure` keeps propagating. The orchestration boundary
497-
functions (`execute` / `execute_subtree`) still return `Result<String, String>` because they
498-
are registered with duroxide:
496+
SQL, HTTP, and multipart activity scheduling boundaries explicitly map activity
497+
errors to `NodeError::Application`; structural/configuration helper errors use
498+
`NodeError::Failure`. `execute_loop_node` is the only handler that catches
499+
`NodeError::Break` (turning it into the loop's `Ok` result). The orchestration
500+
boundary functions (`execute` / `execute_subtree`) still return
501+
`Result<String, String>` because they are registered with duroxide:
499502

500503
- `execute`: an uncaught top-level `Break` becomes a clear `Err` ("df.break() was called
501504
outside of a loop"), so the instance fails instead of completing with a sentinel value.
502-
- `execute_subtree` (used by JOIN/RACE branches): a `Break` is carried out-of-band in the
503-
subtree envelope's `control` field and re-raised as `NodeError::Break` by
504-
`parse_subtree_envelope` in the parent orchestration.
505+
- `execute_subtree`: a `Break` is carried out-of-band in the subtree envelope's
506+
`control` field. `NodeError::Application` is encoded as a namespaced,
507+
serde-tagged subtree failure so application classification survives nested
508+
JOIN, RACE, and LOOP boundaries. `NodeError::Failure` remains an unrecognized
509+
child error and propagates fatally.
505510

506511
The orchestration walks the graph recursively:
507512

@@ -696,34 +701,25 @@ pub fn is_truthy(value: &serde_json::Value) -> bool {
696701

697702
### Parallel Execution (JOIN/RACE)
698703

699-
JOIN and RACE use duroxide's sub-orchestration support:
700-
701-
```rust
702-
async fn execute_join_node(...) -> Result<String, String> {
703-
let left_id = node.left_node.as_ref().ok_or("JOIN missing left")?;
704-
let right_id = node.right_node.as_ref().ok_or("JOIN missing right")?;
705-
706-
// Create sub-orchestration inputs
707-
let left_input = create_subtree_input(graph, left_id, results);
708-
let right_input = create_subtree_input(graph, right_id, results);
709-
710-
// Schedule parallel sub-orchestrations
711-
let left_handle = ctx.schedule_orchestration(SUBTREE_NAME, &left_id, left_input);
712-
let right_handle = ctx.schedule_orchestration(SUBTREE_NAME, &right_id, right_input);
713-
714-
// 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-
let results = 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.
727723

728724
### Loops and Continue-As-New
729725

@@ -734,7 +730,27 @@ Loops use duroxide's `continue_as_new` to avoid unbounded history growth. Their
734730

735731
`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.
736732

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.
738754

739755
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.
740756

0 commit comments

Comments
 (0)