Skip to content

Commit 2ca7ffc

Browse files
committed
Support resilient conditional loops
1 parent 29ecac9 commit 2ca7ffc

2 files changed

Lines changed: 261 additions & 50 deletions

File tree

src/orchestrations/execute_function_graph.rs

Lines changed: 141 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -344,15 +344,26 @@ fn contextualize_subtree_failure(context: &str, error: String) -> NodeError {
344344
}
345345

346346
#[derive(Debug, PartialEq, Eq)]
347-
enum IsolatedIterationFailure {
348-
Continue(String),
349-
Propagate(String),
347+
enum FailureIsolatedBodyOutcome {
348+
Succeeded(String),
349+
ApplicationFailed,
350+
Break(String),
350351
}
351352

352-
fn classify_isolated_iteration_failure(error: String) -> IsolatedIterationFailure {
353-
match decode_subtree_application_failure(&error) {
354-
Some(message) => IsolatedIterationFailure::Continue(message),
355-
None => IsolatedIterationFailure::Propagate(error),
353+
fn classify_isolated_body_result(
354+
result: Result<String, String>,
355+
results: &mut HashMap<String, String>,
356+
) -> Result<FailureIsolatedBodyOutcome, NodeError> {
357+
match result {
358+
Ok(raw) => match parse_subtree_envelope(&raw, "LOOP iteration", results) {
359+
Ok(body_result) => Ok(FailureIsolatedBodyOutcome::Succeeded(body_result)),
360+
Err(NodeError::Break(value)) => Ok(FailureIsolatedBodyOutcome::Break(value)),
361+
Err(error) => Err(error),
362+
},
363+
Err(error) => match decode_subtree_application_failure(&error) {
364+
Some(_) => Ok(FailureIsolatedBodyOutcome::ApplicationFailed),
365+
None => Err(NodeError::Failure(error)),
366+
},
356367
}
357368
}
358369

@@ -1120,7 +1131,27 @@ async fn run_loop_iteration(
11201131
Err(NodeError::Failure(error)) => return Err(NodeError::Failure(error)),
11211132
};
11221133

1123-
// While-condition: if present and false, exit the loop.
1134+
Box::pin(evaluate_loop_condition(
1135+
ctx,
1136+
graph,
1137+
node,
1138+
condition_node,
1139+
body_result,
1140+
results,
1141+
exec_ctx,
1142+
))
1143+
.await
1144+
}
1145+
1146+
async fn evaluate_loop_condition(
1147+
ctx: &OrchestrationContext,
1148+
graph: &FunctionGraph,
1149+
node: &FunctionNode,
1150+
condition_node: Option<&str>,
1151+
body_result: String,
1152+
results: &mut HashMap<String, String>,
1153+
exec_ctx: &ExecutionContext,
1154+
) -> Result<Option<String>, NodeError> {
11241155
if let Some(condition_node_id) = condition_node {
11251156
ctx.trace_info("Evaluating loop condition");
11261157
let condition_result =
@@ -1152,48 +1183,32 @@ async fn run_loop_iteration(
11521183
Ok(None)
11531184
}
11541185

1155-
async fn run_failure_isolated_iteration(
1186+
async fn run_failure_isolated_body(
11561187
ctx: &OrchestrationContext,
11571188
graph: &FunctionGraph,
1158-
node: &FunctionNode,
11591189
loop_node_id: &str,
11601190
body_id: &str,
11611191
results: &mut HashMap<String, String>,
11621192
exec_ctx: &ExecutionContext,
1163-
) -> Result<Option<String>, NodeError> {
1193+
) -> Result<FailureIsolatedBodyOutcome, NodeError> {
11641194
let child_input = build_subtree_input(graph, body_id, results, exec_ctx)?;
11651195
let child_id = subtree_instance_id(ctx, body_id);
11661196

11671197
ctx.trace_info(format!(
11681198
"Starting failure-isolated iteration for LOOP node {loop_node_id} as child {child_id}"
11691199
));
11701200

1171-
let raw = match ctx
1201+
let child_result = ctx
11721202
.schedule_sub_orchestration_with_id(SUBTREE_NAME, child_id, child_input)
1173-
.await
1174-
{
1175-
Ok(raw) => raw,
1176-
Err(error) => match classify_isolated_iteration_failure(error) {
1177-
IsolatedIterationFailure::Continue(error) => {
1178-
ctx.trace_warn(format!(
1179-
"LOOP node {loop_node_id} iteration application failure; continuing: {error}"
1180-
));
1181-
return Ok(None);
1182-
}
1183-
IsolatedIterationFailure::Propagate(error) => {
1184-
return Err(NodeError::Failure(error));
1185-
}
1186-
},
1187-
};
1203+
.await;
11881204

1189-
match parse_subtree_envelope(&raw, "LOOP iteration", results) {
1190-
Ok(_) => Ok(None),
1191-
Err(NodeError::Break(value)) => {
1192-
store_named_result(ctx, node, &value, results, "LOOP");
1193-
Ok(Some(value))
1194-
}
1195-
Err(error) => Err(error),
1205+
let outcome = classify_isolated_body_result(child_result, results)?;
1206+
if outcome == FailureIsolatedBodyOutcome::ApplicationFailed {
1207+
ctx.trace_warn(format!(
1208+
"LOOP node {loop_node_id} iteration application failure; continuing"
1209+
));
11961210
}
1211+
Ok(outcome)
11971212
}
11981213

11991214
/// Execute a loop node inline, driving the *current* orchestration's `continue_as_new`.
@@ -1237,10 +1252,29 @@ async fn execute_loop_node(
12371252
ctx.trace_info("Executing loop iteration");
12381253

12391254
let final_result = if config.continue_on_failure {
1240-
Box::pin(run_failure_isolated_iteration(
1241-
ctx, graph, node, node_id, body_id, results, exec_ctx,
1255+
match Box::pin(run_failure_isolated_body(
1256+
ctx, graph, node_id, body_id, results, exec_ctx,
12421257
))
1243-
.await
1258+
.await?
1259+
{
1260+
FailureIsolatedBodyOutcome::Succeeded(body_result) => {
1261+
Box::pin(evaluate_loop_condition(
1262+
ctx,
1263+
graph,
1264+
node,
1265+
config.condition_node.as_deref(),
1266+
body_result,
1267+
results,
1268+
exec_ctx,
1269+
))
1270+
.await?
1271+
}
1272+
FailureIsolatedBodyOutcome::ApplicationFailed => None,
1273+
FailureIsolatedBodyOutcome::Break(value) => {
1274+
store_named_result(ctx, node, &value, results, "LOOP");
1275+
Some(value)
1276+
}
1277+
}
12441278
} else {
12451279
Box::pin(run_loop_iteration(
12461280
ctx,
@@ -1251,10 +1285,10 @@ async fn execute_loop_node(
12511285
results,
12521286
exec_ctx,
12531287
))
1254-
.await
1288+
.await?
12551289
};
12561290

1257-
if let Some(final_result) = final_result? {
1291+
if let Some(final_result) = final_result {
12581292
return Ok(final_result);
12591293
}
12601294

@@ -2161,6 +2195,31 @@ mod tests {
21612195
assert!(MAX_LOOP_ITERATIONS.is_power_of_two());
21622196
}
21632197

2198+
#[test]
2199+
fn isolated_iteration_continues_typed_application_failure() {
2200+
let encoded = encode_subtree_application_failure("division by zero");
2201+
assert_eq!(
2202+
encoded,
2203+
r#"{"pg_durable_subtree_failure":"application","message":"division by zero"}"#
2204+
);
2205+
let mut results = HashMap::new();
2206+
assert_eq!(
2207+
classify_isolated_body_result(Err(encoded), &mut results).unwrap(),
2208+
FailureIsolatedBodyOutcome::ApplicationFailed
2209+
);
2210+
}
2211+
2212+
#[test]
2213+
fn isolated_iteration_propagates_unrecognized_runtime_failure() {
2214+
let runtime_error =
2215+
"sub-orchestration instance id 'child' already exists and is terminal".to_string();
2216+
let mut results = HashMap::new();
2217+
match classify_isolated_body_result(Err(runtime_error.clone()), &mut results) {
2218+
Err(NodeError::Failure(error)) => assert_eq!(error, runtime_error),
2219+
other => panic!("expected runtime failure, got {other:?}"),
2220+
}
2221+
}
2222+
21642223
#[test]
21652224
fn subtree_instance_ids_are_stable_and_generation_scoped() {
21662225
assert_eq!(
@@ -2174,25 +2233,57 @@ mod tests {
21742233
}
21752234

21762235
#[test]
2177-
fn isolated_iteration_continues_typed_application_failure() {
2178-
let encoded = encode_subtree_application_failure("division by zero");
2236+
fn isolated_body_success_returns_result_and_merges_results() {
2237+
let raw = envelope_json(
2238+
Some("Normal"),
2239+
"42",
2240+
serde_json::json!({"body_result": "stored"}),
2241+
);
2242+
let mut results = HashMap::new();
2243+
21792244
assert_eq!(
2180-
encoded,
2181-
r#"{"pg_durable_subtree_failure":"application","message":"division by zero"}"#
2245+
classify_isolated_body_result(Ok(raw), &mut results).unwrap(),
2246+
FailureIsolatedBodyOutcome::Succeeded("42".to_string())
21822247
);
21832248
assert_eq!(
2184-
classify_isolated_iteration_failure(encoded),
2185-
IsolatedIterationFailure::Continue("division by zero".to_string())
2249+
results.get("body_result").map(String::as_str),
2250+
Some("stored")
21862251
);
21872252
}
21882253

21892254
#[test]
2190-
fn isolated_iteration_propagates_unrecognized_runtime_failure() {
2191-
let runtime_error =
2192-
"sub-orchestration instance id 'child' already exists and is terminal".to_string();
2255+
fn isolated_body_consumes_typed_application_failure() {
2256+
let mut results = HashMap::new();
2257+
2258+
assert_eq!(
2259+
classify_isolated_body_result(
2260+
Err(encode_subtree_application_failure("boom")),
2261+
&mut results,
2262+
)
2263+
.unwrap(),
2264+
FailureIsolatedBodyOutcome::ApplicationFailed
2265+
);
2266+
}
2267+
2268+
#[test]
2269+
fn isolated_body_propagates_unrecognized_runtime_failure() {
2270+
let mut results = HashMap::new();
2271+
2272+
match classify_isolated_body_result(Err("instance id collision".to_string()), &mut results)
2273+
{
2274+
Err(NodeError::Failure(error)) => assert_eq!(error, "instance id collision"),
2275+
other => panic!("expected runtime failure, got {other:?}"),
2276+
}
2277+
}
2278+
2279+
#[test]
2280+
fn isolated_body_preserves_break_value() {
2281+
let raw = envelope_json(Some("Break"), r#"{"status":"done"}"#, serde_json::json!({}));
2282+
let mut results = HashMap::new();
2283+
21932284
assert_eq!(
2194-
classify_isolated_iteration_failure(runtime_error.clone()),
2195-
IsolatedIterationFailure::Propagate(runtime_error)
2285+
classify_isolated_body_result(Ok(raw), &mut results).unwrap(),
2286+
FailureIsolatedBodyOutcome::Break(r#"{"status":"done"}"#.to_string())
21962287
);
21972288
}
21982289

tests/e2e/sql/70_loop_continue_on_failure.sql

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,126 @@ END $$;
183183
DROP TABLE _nested_continue_instance;
184184
DROP TABLE test_nested_continue;
185185

186+
DROP FUNCTION IF EXISTS test_conditional_continue_body();
187+
DROP FUNCTION IF EXISTS test_conditional_continue_condition();
188+
DROP FUNCTION IF EXISTS test_conditional_continue_failing_condition();
189+
DROP SEQUENCE IF EXISTS test_conditional_continue_attempt_seq;
190+
DROP TABLE IF EXISTS test_conditional_continue_state;
191+
192+
CREATE TABLE test_conditional_continue_state (
193+
body_attempts INT NOT NULL DEFAULT 0,
194+
condition_checks INT NOT NULL DEFAULT 0
195+
);
196+
INSERT INTO test_conditional_continue_state DEFAULT VALUES;
197+
198+
-- Sequence values are not rolled back when the first function call raises, allowing the
199+
-- second successful call to persist the total attempt count in the state table.
200+
CREATE SEQUENCE test_conditional_continue_attempt_seq;
201+
202+
CREATE FUNCTION test_conditional_continue_body() RETURNS INT
203+
LANGUAGE plpgsql AS $$
204+
DECLARE
205+
attempts INT;
206+
BEGIN
207+
attempts := nextval('test_conditional_continue_attempt_seq');
208+
UPDATE test_conditional_continue_state
209+
SET body_attempts = attempts;
210+
211+
IF attempts = 1 THEN
212+
RAISE EXCEPTION 'transient body failure';
213+
END IF;
214+
RETURN attempts;
215+
END
216+
$$;
217+
218+
CREATE FUNCTION test_conditional_continue_condition() RETURNS BOOLEAN
219+
LANGUAGE plpgsql AS $$
220+
BEGIN
221+
UPDATE test_conditional_continue_state
222+
SET condition_checks = condition_checks + 1;
223+
RETURN false;
224+
END
225+
$$;
226+
227+
CREATE FUNCTION test_conditional_continue_failing_condition() RETURNS BOOLEAN
228+
LANGUAGE plpgsql AS $$
229+
BEGIN
230+
RAISE EXCEPTION 'fatal condition failure';
231+
END
232+
$$;
233+
234+
CREATE TEMP TABLE _conditional_continue_instances (
235+
scenario TEXT PRIMARY KEY,
236+
instance_id TEXT NOT NULL
237+
);
238+
239+
INSERT INTO _conditional_continue_instances
240+
SELECT 'body-recovery',
241+
df.start(
242+
df.loop(
243+
'SELECT test_conditional_continue_body()',
244+
'SELECT test_conditional_continue_condition()',
245+
continue_on_failure => true
246+
),
247+
'test-conditional-loop-continues-after-body-failure'
248+
);
249+
250+
INSERT INTO _conditional_continue_instances
251+
SELECT 'condition-failure',
252+
df.start(
253+
df.loop(
254+
'SELECT 42',
255+
'SELECT test_conditional_continue_failing_condition()',
256+
continue_on_failure => true
257+
),
258+
'test-conditional-loop-condition-failure-is-fatal'
259+
);
260+
261+
DO $$
262+
DECLARE
263+
recovered_status TEXT;
264+
condition_failure_status TEXT;
265+
body_attempts INT;
266+
condition_checks INT;
267+
BEGIN
268+
SELECT df.await_instance(instance_id, 30)
269+
INTO recovered_status
270+
FROM _conditional_continue_instances
271+
WHERE scenario = 'body-recovery';
272+
273+
SELECT df.await_instance(instance_id, 30)
274+
INTO condition_failure_status
275+
FROM _conditional_continue_instances
276+
WHERE scenario = 'condition-failure';
277+
278+
SELECT s.body_attempts, s.condition_checks
279+
INTO body_attempts, condition_checks
280+
FROM test_conditional_continue_state s;
281+
282+
IF recovered_status IS DISTINCT FROM 'completed' THEN
283+
RAISE EXCEPTION
284+
'TEST FAILED [conditional continue]: expected completed, got %',
285+
recovered_status;
286+
END IF;
287+
IF body_attempts <> 2 OR condition_checks <> 1 THEN
288+
RAISE EXCEPTION
289+
'TEST FAILED [conditional continue]: expected body/condition = 2/1, got %/%',
290+
body_attempts, condition_checks;
291+
END IF;
292+
IF condition_failure_status IS DISTINCT FROM 'failed' THEN
293+
RAISE EXCEPTION
294+
'TEST FAILED [condition failure]: expected failed, got %',
295+
condition_failure_status;
296+
END IF;
297+
END $$;
298+
299+
DROP TABLE _conditional_continue_instances;
300+
DROP FUNCTION test_conditional_continue_body();
301+
DROP FUNCTION test_conditional_continue_condition();
302+
DROP FUNCTION test_conditional_continue_failing_condition();
303+
DROP SEQUENCE test_conditional_continue_attempt_seq;
304+
DROP TABLE test_conditional_continue_state;
305+
186306
CREATE TEMP TABLE _cancel_continue_instance (instance_id TEXT);
187307

188308
INSERT INTO _cancel_continue_instance(instance_id)

0 commit comments

Comments
 (0)