Skip to content

Commit d59ebd3

Browse files
committed
Unify conditional loop failure handling
1 parent 2ca7ffc commit d59ebd3

12 files changed

Lines changed: 208 additions & 108 deletions

File tree

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

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,16 @@ 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+
-- NULL condition: infinite loop.
103+
-- Non-NULL condition: do-while semantics; evaluate it after each successful body.
104+
-- With continue_on_failure => true, a consumed body activity failure skips the
105+
-- condition and starts the next iteration. Condition and non-application failures are fatal.
99106

100107
-- Break from enclosing loop
101108
df.break() → TEXT -- Exit with NULL
@@ -227,7 +234,7 @@ Automatically available during execution:
227234

228235
## Condition Evaluation (Truthiness)
229236

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

232239
The first column of the first row is evaluated:
233240

@@ -345,7 +352,7 @@ SELECT df.start(
345352
-- Cancel with: SELECT df.cancel('instance_id', 'Stopping heartbeat');
346353
```
347354

348-
### While-Loop with Break
355+
### Loop with Break
349356

350357
```sql
351358
SELECT df.start(
@@ -361,6 +368,26 @@ SELECT df.start(
361368
);
362369
```
363370

371+
### Conditional Loop with Failure Continuation
372+
373+
```sql
374+
SELECT df.start(
375+
df.loop(
376+
'SELECT process_next_item()',
377+
'SELECT EXISTS (
378+
SELECT 1 FROM work_queue WHERE status = ''pending''
379+
)',
380+
continue_on_failure => true
381+
),
382+
'resilient-worker'
383+
);
384+
```
385+
386+
This is a do-while loop: after a successful body execution, the condition is
387+
evaluated and the loop continues while it is truthy. After a consumed body
388+
activity failure, the condition is skipped and the body starts again.
389+
Condition failures and non-application failures remain fatal.
390+
364391
### Cron Scheduled Job
365392

366393
```sql

CHANGELOG.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@ Pre-1.0 note: while `pg_durable` is in major version `0`, minor releases may inc
88

99
### Added
1010

11-
- **Failure-isolated loops:** `df.loop(body, continue_on_failure => true)` runs
12-
each infinite-loop iteration as a child orchestration. An application failure
13-
returned by a body activity does not fail the loop parent, which proceeds to
14-
the next iteration. Graph, protocol, child-runtime, and infrastructure
15-
failures remain fatal.
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. Condition, graph, protocol,
17+
unknown child/runtime failures, and infrastructure failures remain fatal.
1618

1719
### Changed
1820

USER_GUIDE.md

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -530,7 +530,8 @@ SELECT df.start(
530530

531531
### Loop Condition Example
532532

533-
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:
534535

535536
```sql
536537
-- Loop while there are pending items
@@ -1181,13 +1182,25 @@ SELECT df.start(
11811182

11821183
The unified signature is
11831184
`df.loop(body, condition DEFAULT NULL, continue_on_failure DEFAULT false)`.
1185+
1186+
It supports four call shapes:
1187+
1188+
```sql
1189+
df.loop(body)
1190+
df.loop(body, condition)
1191+
df.loop(body, continue_on_failure => true)
1192+
df.loop(body, condition, continue_on_failure => true)
1193+
```
1194+
11841195
By default, it and `@>` are fail-fast: an iteration failure fails the loop.
11851196
Passing `continue_on_failure => false` is equivalent to the default and does
1186-
not change where the loop is hosted. When it is `true`, each iteration runs as
1187-
a child orchestration and the parent starts the next iteration after an
1188-
application failure returned by a body activity. Malformed graph or child
1189-
data, child-runtime failures, child-ID collisions, and
1190-
infrastructure/configuration/poison failures remain fatal.
1197+
not change where the loop is hosted. When it is `true`, each body iteration
1198+
runs as a child orchestration. After a successful body iteration, the child's
1199+
results are merged into the parent result map before the optional condition is
1200+
evaluated. After a consumed typed application failure from a body activity,
1201+
the condition is skipped and the parent starts the next iteration. Condition
1202+
failures, malformed graph or child data, unrecognized child errors, child-ID
1203+
collisions, and child-runtime or infrastructure failures remain fatal.
11911204

11921205
This is useful for scheduled maintenance such as a pg_textsearch-style indexer:
11931206

@@ -1324,7 +1337,7 @@ Each loop iteration advances via *continue-as-new*, which restarts the loop with
13241337
- 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.
13251338
- 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.
13261339
- 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.
1327-
- A loop with `continue_on_failure => true` runs every iteration in a child orchestration. The loop parent remains `running` after a body activity returns an application failure and proceeds to the next iteration. Unrecognized child errors and graph, protocol, runtime, or infrastructure failures remain fatal.
1340+
- 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 a consumed typed body activity failure, the condition is skipped and the loop parent proceeds to the next iteration. Condition failures and unrecognized child, graph, protocol, runtime, or infrastructure failures remain fatal.
13281341
13291342
These child sub-orchestrations are internal durable instances and do **not**
13301343
appear in `df.list_instances()`, which lists only instances started with

docs/ARCHITECTURE.md

Lines changed: 34 additions & 11 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

@@ -734,7 +739,25 @@ Loops use duroxide's `continue_as_new` to avoid unbounded history growth. Their
734739

735740
`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.
736741

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.
742+
Fail-fast loops call `run_loop_iteration`, which executes the body inline,
743+
catches `NodeError::Break`, evaluates the optional post-body condition, and
744+
propagates both application and non-application failures. A loop configured
745+
with `continue_on_failure => true` instead calls
746+
`run_failure_isolated_body`: each body iteration runs as a fresh
747+
`execute_subtree` child. On success, the subtree envelope is parsed and its
748+
named results are merged into the parent result map before the parent evaluates
749+
the optional condition. On a structurally encoded body application failure,
750+
the parent consumes the failure, skips the condition because body results may
751+
be absent, and advances to the next generation. Condition failures, malformed
752+
envelopes or graph data, child-ID collisions, and other unrecognized
753+
infrastructure/runtime failures remain fatal.
754+
755+
A child stamps its LOOP node `running` on each generation and `completed` or
756+
`failed` on exit; because `continue_as_new` returns a future that never
757+
resolves, a continuing generation never stamps a terminal status. If a live
758+
loop loses a RACE, the parent records the loop node as terminal `failed` with a
759+
cancellation reason because duroxide cancellation stops the child before it
760+
can run its own terminal stamp.
738761

739762
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.
740763

docs/api-reference.md

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -160,42 +160,55 @@ df.if_rows('data', 'SELECT $data.id', 'SELECT ''no data''')
160160

161161
---
162162

163-
### df.loop(body [, condition])
163+
### df.loop(body, condition DEFAULT NULL, continue_on_failure DEFAULT false)
164164

165-
Repeats `body` forever or while `condition` is true. This form is fail-fast,
166-
executing each iteration inline within whichever orchestration hosts the loop.
165+
Repeats `body` forever or while `condition` is true. The supported call shapes
166+
are:
167+
168+
```sql
169+
df.loop(body)
170+
df.loop(body, condition)
171+
df.loop(body, continue_on_failure => true)
172+
df.loop(body, condition, continue_on_failure => true)
173+
```
167174

168175
| Parameter | Type | Auto-wrap | Description |
169176
|-----------|------|-----------|-------------|
170177
| `body` | TEXT | ✅ Auto-wrap | Node to repeat |
171-
| `condition` | TEXT | ✅ Auto-wrap | (Optional) Continue while truthy |
178+
| `condition` | TEXT | ✅ Auto-wrap | (Optional) Evaluate after a successful body; continue while truthy |
179+
| `continue_on_failure` | BOOLEAN | ❌ Literal | (Optional) Continue after a body activity failure; default `false` |
172180

173181
```sql
174182
-- Infinite loop
175183
df.loop('SELECT process_item()' ~> df.sleep(1))
176184

177185
-- While loop
178186
df.loop('SELECT process_item()', 'SELECT count(*) > 0 FROM queue')
179-
```
180-
181-
### df.loop(body, continue_on_failure => boolean)
182-
183-
Creates an infinite loop. When `continue_on_failure` is `true`, each iteration
184-
runs in a child orchestration; an application failure returned by a body
185-
activity does not fail the parent, which starts the next iteration. Malformed
186-
graph or child data, child-runtime failures, child-ID collisions, and
187-
infrastructure/configuration/poison failures remain fatal. `false` preserves
188-
fail-fast execution in the loop's existing host orchestration; it does not force
189-
root-level execution.
190187

191-
```sql
188+
-- Infinite loop that continues after body activity failures
192189
df.loop(
193190
'SELECT process_item()' ~> df.sleep(1),
194191
continue_on_failure => true
195192
)
193+
194+
-- Conditional loop that continues after body activity failures
195+
df.loop(
196+
'SELECT process_item()',
197+
'SELECT count(*) > 0 FROM queue',
198+
continue_on_failure => true
199+
)
196200
```
197201

198-
The `@>` operator is an infinite, fail-fast loop:
202+
By default, and when `continue_on_failure` is `false`, loop execution is
203+
fail-fast. When it is `true`, each body iteration runs in a child
204+
orchestration. After a successful body iteration, the child's results are
205+
merged into the parent result map before condition evaluation. A consumed
206+
typed application failure from a body activity skips condition evaluation and
207+
starts the next iteration. Condition failures, malformed graph or child data,
208+
unrecognized child errors, child-ID collisions, and child-runtime or
209+
infrastructure failures remain fatal.
210+
211+
The `@>` operator remains an infinite, fail-fast loop:
199212

200213
```sql
201214
@> ('SELECT process_item()' ~> df.sleep(1))
@@ -663,8 +676,7 @@ SELECT df.clearvars();
663676
| `df.join3(a, b, c)` | `a`, `b`, `c` |
664677
| `df.race(a, b)` | `a`, `b` |
665678
| `df.if(cond, then, else)` | `cond`, `then`, `else` |
666-
| `df.loop(body, cond)` | `body`, `cond` |
667-
| `df.loop(body, continue_on_failure => boolean)` | `body` |
679+
| `df.loop(body, condition, continue_on_failure)` | `body`, `condition` |
668680
| `df.start(fut, label)` | `fut` |
669681
| All others | No auto-wrap (literals only) |
670682

docs/grammar.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,11 @@ node_function ::= df.sql( QUERY )
6161
| df.race( expression, expression )
6262
| df.seq( expression, expression )
6363
| df.if( condition, then_expr, else_expr )
64-
| df.loop( expression, condition DEFAULT NULL,
65-
continue_on_failure BOOLEAN DEFAULT false )
64+
| df.loop(
65+
expression
66+
[, expression]
67+
[, continue_on_failure => BOOLEAN]
68+
)
6669
| df.break( [value] )
6770
| df.as( expression, NAME )
6871
```

docs/loop_rework_problems.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,10 @@ let new_input = SubtreeInput {
5454

5555
The graph snapshot is also copied into every JOIN/RACE child input. A large
5656
graph or named result is therefore persisted repeatedly across loop generations
57-
and child histories. `MAX_LOOP_ITERATIONS` limits generations to 100,000, but
58-
that is not a meaningful byte bound: a 1 MB carried result can still produce
59-
storage measured in tens of gigabytes before the iteration guard trips.
57+
and child histories. `MAX_LOOP_ITERATIONS` limits generations to 8,388,608
58+
(`2^23`), but that is not a meaningful byte bound: a 1 MB carried result can
59+
still produce roughly 8.4 TB of payload copies (8 TiB for a 1 MiB payload)
60+
before the iteration guard trips, excluding engine-record and graph overhead.
6061

6162
Child engine records also remain until the root instance is retired. A nested
6263
loop can create a child under each outer generation, so the retained child
@@ -258,8 +259,9 @@ between parent and descendant stamps at equal generations.
258259
`MAX_LOOP_ITERATIONS` is enforced per orchestration instance. A non-root inner
259260
loop starts in its own child and receives its own counter, while an outer loop
260261
can spawn a new inner child under every outer generation. Two loops that each
261-
stay under the 100,000-iteration cap can therefore produce a combinatorial
262-
amount of work and retained child state.
262+
stay under the 8,388,608 (`2^23`)-iteration cap can therefore produce up to
263+
`2^46` (70,368,744,177,664) inner iterations, plus outer-loop work, and retain
264+
a correspondingly large child-state population.
263265

264266
The one-second floor limits the rate of each individual loop but does not place
265267
a workflow-wide bound on nested work. The existing nested-loop E2E test proves

docs/upgrade-testing.md

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -205,17 +205,26 @@ what the upgrade script handles, and any backward compatibility considerations.
205205

206206
### 0.2.8
207207

208-
- `sql/pg_durable--0.2.7--0.2.8.sql` creates `df.loop(text, boolean)` without
209-
dropping or replacing `df.loop(text, text)`.
210-
- Existing schemas do not expose the boolean overload until
211-
`ALTER EXTENSION UPDATE` runs. Until then, the new `.so` retains
212-
`loop_fn_wrapper`, so the old text overload remains callable safely.
208+
- `sql/pg_durable--0.2.7--0.2.8.sql` renames `df.loop(text, text)` to
209+
`df._loop_legacy(text, text)`, preserving its function OID and dependent
210+
objects, then creates the single public
211+
`df.loop(text, text DEFAULT NULL, boolean DEFAULT false)` signature.
212+
`_loop_legacy` is an internal upgrade-compatibility object, not a user-facing
213+
alternative.
214+
- For Scenario B1, the new `.so` retains `loop_fn_wrapper`, so every supported
215+
schema that has not run `ALTER EXTENSION UPDATE` can continue calling its
216+
cataloged `df.loop(text, text)` function safely.
217+
- For Scenario A, fresh installs and upgraded schemas both contain the unified
218+
public signature and the internal `_loop_legacy` object, so their schema
219+
snapshots remain equal while upgrade dependencies stay attached to the
220+
renamed function OID.
213221
- Existing LOOP graphs have no `continue_on_failure` key and remain inline and
214222
fail-fast. Only newly constructed opted-in loops schedule an iteration child.
215-
- Opted-in loops consume only typed application failures returned by body
216-
activities. Malformed graph/protocol data, unrecognized child errors,
217-
child-ID collisions, and infrastructure/configuration/poison failures remain
218-
fatal.
223+
- For opted-in conditional loops, a successful body is followed by condition
224+
evaluation; a consumed typed body activity failure skips the condition and
225+
starts the next iteration. Condition failures, malformed graph/protocol
226+
data, unrecognized child errors, child-ID collisions, and
227+
infrastructure/configuration/poison failures remain fatal.
219228
- Raises the loop backstop for all loops from 100,000 to 8,388,608 (`2^23`),
220229
which is about 80 years at five-minute ticks.
221230
- Replay is unchanged through iteration 100,000. At the old boundary, loops

0 commit comments

Comments
 (0)