diff --git a/CHANGELOG.md b/CHANGELOG.md index 735d6490..96a89756 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ Pre-1.0 note: while `pg_durable` is in major version `0`, minor releases may inc ## [0.2.6] - Unreleased +### Fixed + +- **Deep workflow composition (#327):** workflow graphs deeper than serde_json's 127-level recursion limit no longer silently collapse into SQL text. Nested children are deserialized one graph level at a time, and `df.explain()` now enforces the configured graph-depth limit before traversal. + ## [0.2.5] - 2026-07-30 ### Added diff --git a/Cargo.toml b/Cargo.toml index 9abb86ae..6aad16e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,7 +30,7 @@ pg_test = [] [dependencies] pgrx = "=0.16.1" serde = { version = "1.0", features = ["derive"] } -serde_json = { version = "1.0", features = ["preserve_order"] } +serde_json = { version = "1.0", features = ["preserve_order", "raw_value"] } uuid = { version = "1.0", features = ["v4", "serde"] } percent-encoding = "2.3" diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 4ea62f1f..053be94f 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -309,6 +309,12 @@ SELECT df.start( ); ``` +### Graph Depth + +A workflow graph can contain at most 256 nested composition levels. `df.start()` and +`df.explain()` reject deeper graphs with a maximum-nesting-depth error. Split a larger workflow +into smaller sub-workflows when it would exceed this limit. + ### Variable Substitution Use `$name` to reference named results in subsequent steps: diff --git a/docs/upgrade-testing.md b/docs/upgrade-testing.md index 1ca87e2c..ea5f2d24 100644 --- a/docs/upgrade-testing.md +++ b/docs/upgrade-testing.md @@ -203,6 +203,14 @@ gate, so they never need to be added to the exclude list. Each schema-changing PR should add a section here documenting what changed, what the upgrade script handles, and any backward compatibility considerations. +### v0.2.5 → v0.2.6 + +#### Preserve parser resource errors in `df.ensure_durofut()` +- **DDL change (function body only):** `df.ensure_durofut(text)` no longer catches `WHEN OTHERS` while deciding whether an operand is Durofut JSON or plain SQL. It still treats `invalid_text_representation` as plain SQL and re-raises its explicit unknown-node-type error, but PostgreSQL stack/resource errors now propagate instead of silently wrapping the serialized graph as a SQL node. The signature, volatility, search path, grants, and schema shape are unchanged. +- **Upgrade script:** `sql/pg_durable--0.2.5--0.2.6.sql` uses `CREATE OR REPLACE FUNCTION` with the same body emitted for fresh installs from `src/lib.rs`. This keeps Scenario A snapshots identical without dropping the function or changing dependent operators. +- **Scenario B1 considerations:** The new `.so` works against pre-0.2.6 schemas without runtime schema detection because no Rust SQL query or C symbol changed. Rust composers receive the opaque-child deserialization fix immediately from the new binary. Until `ALTER EXTENSION UPDATE` replaces the cataloged PL/pgSQL helper, the `?>` / `!>` operator path retains its older broad exception handler and can still misclassify a graph if PostgreSQL itself raises a stack/resource error while parsing it. +- **Scenario B2 considerations:** No data migration and no durable-state or replay change. Existing serialized graphs retain the same wire format. + ### v0.2.4 → v0.2.5 #### Loop and sub-orchestration replay compatibility diff --git a/sql/pg_durable--0.2.5--0.2.6.sql b/sql/pg_durable--0.2.5--0.2.6.sql index e1ea549b..24b1ec2c 100644 --- a/sql/pg_durable--0.2.5--0.2.6.sql +++ b/sql/pg_durable--0.2.5--0.2.6.sql @@ -6,5 +6,26 @@ -- See docs/upgrade-testing.md for the upgrade-script and backward-compatibility -- requirements (Scenario A / B1 / B2). -- --- No schema changes yet for 0.2.6. Add DDL below as the 0.2.6 cycle lands --- extension-schema changes. \ No newline at end of file +-- Preserve stack/resource errors while classifying plain SQL operands. A broad +-- WHEN OTHERS handler silently wrapped over-depth Durofut JSON as SQL. +CREATE OR REPLACE FUNCTION df.ensure_durofut(val text) RETURNS text AS $$ +DECLARE + node_type_val text; +BEGIN + BEGIN + node_type_val := (val::jsonb)->>'node_type'; + IF node_type_val IS NOT NULL THEN + IF node_type_val NOT IN ('SQL', 'THEN', 'IF', 'JOIN', 'LOOP', 'BREAK', 'RACE', 'SLEEP', 'WAIT_SCHEDULE', 'HTTP', 'HTTP_MULTIPART', 'SIGNAL') THEN + RAISE EXCEPTION 'Unknown node_type ''%''. Valid types: SQL, THEN, IF, JOIN, LOOP, BREAK, RACE, SLEEP, WAIT_SCHEDULE, HTTP, HTTP_MULTIPART, SIGNAL', node_type_val; + END IF; + RETURN val; + END IF; + EXCEPTION WHEN invalid_text_representation THEN + NULL; + WHEN raise_exception THEN + RAISE; + END; + + RETURN df.sql(val); +END; +$$ LANGUAGE plpgsql IMMUTABLE SET search_path = pg_catalog, pg_temp; \ No newline at end of file diff --git a/src/dsl.rs b/src/dsl.rs index dc16a390..81f06802 100644 --- a/src/dsl.rs +++ b/src/dsl.rs @@ -235,8 +235,8 @@ pub fn then_fn(a: &str, b: &str) -> String { Durofut { node_type: "THEN".to_string(), - left_node: Some(Box::new(a_fut)), - right_node: Some(Box::new(b_fut)), + left_node: Some(a_fut.into_raw()), + right_node: Some(b_fut.into_raw()), ..Default::default() } .to_json() @@ -337,7 +337,7 @@ pub fn loop_fn(body: &str, condition: default!(Option<&str>, "NULL")) -> String Durofut { node_type: "LOOP".to_string(), - left_node: Some(Box::new(body_fut)), + left_node: Some(body_fut.into_raw()), query, ..Default::default() } @@ -390,8 +390,8 @@ pub fn if_fn(condition: &str, then_branch: &str, else_branch: &str) -> String { Durofut { node_type: "IF".to_string(), - left_node: Some(Box::new(then_fut)), - right_node: Some(Box::new(else_fut)), + left_node: Some(then_fut.into_raw()), + right_node: Some(else_fut.into_raw()), query: Some(config.to_string()), ..Default::default() } @@ -413,8 +413,8 @@ pub fn if_rows_fn(result_name: &str, then_branch: &str, else_branch: &str) -> St Durofut { node_type: "IF".to_string(), - left_node: Some(Box::new(then_fut)), - right_node: Some(Box::new(else_fut)), + left_node: Some(then_fut.into_raw()), + right_node: Some(else_fut.into_raw()), query: Some(config.to_string()), ..Default::default() } @@ -430,8 +430,8 @@ pub fn join(a: &str, b: &str) -> String { Durofut { node_type: "JOIN".to_string(), - left_node: Some(Box::new(a_fut)), - right_node: Some(Box::new(b_fut)), + left_node: Some(a_fut.into_raw()), + right_node: Some(b_fut.into_raw()), ..Default::default() } .to_json() @@ -451,8 +451,8 @@ pub fn join3(a: &str, b: &str, c: &str) -> String { Durofut { node_type: "JOIN".to_string(), - left_node: Some(Box::new(a_fut)), - right_node: Some(Box::new(b_fut)), + left_node: Some(a_fut.into_raw()), + right_node: Some(b_fut.into_raw()), query: Some(config.to_string()), ..Default::default() } @@ -468,8 +468,8 @@ pub fn race(a: &str, b: &str) -> String { Durofut { node_type: "RACE".to_string(), - left_node: Some(Box::new(a_fut)), - right_node: Some(Box::new(b_fut)), + left_node: Some(a_fut.into_raw()), + right_node: Some(b_fut.into_raw()), ..Default::default() } .to_json() @@ -1072,9 +1072,11 @@ fn start_in_caller_transaction(fut: &str, label: Option<&str>, database: Option< ); } // Recursively insert children FIRST to get their IDs - let left_id = node.left_node.as_ref().map(|n| { + let left_id = node.left_node.as_ref().map(|raw| { + let child = Durofut::child_from_raw(raw) + .unwrap_or_else(|e| pgrx::error!("Invalid left child in graph: {}", e)); insert_nodes( - n, + &child, instance_id, None, current_user_oid, @@ -1083,9 +1085,11 @@ fn start_in_caller_transaction(fut: &str, label: Option<&str>, database: Option< node_count, ) }); - let right_id = node.right_node.as_ref().map(|n| { + let right_id = node.right_node.as_ref().map(|raw| { + let child = Durofut::child_from_raw(raw) + .unwrap_or_else(|e| pgrx::error!("Invalid right child in graph: {}", e)); insert_nodes( - n, + &child, instance_id, None, current_user_oid, diff --git a/src/explain.rs b/src/explain.rs index f014171c..951718d9 100644 --- a/src/explain.rs +++ b/src/explain.rs @@ -229,6 +229,9 @@ fn explain_expression(expr: &str) -> String { // First try to parse as Durofut JSON if let Ok(root) = Durofut::try_from_json(expr) { + if let Err(e) = root.validate_recursive() { + return format!("Invalid durable function graph: {e}"); + } // Build in-memory node map from nested structure with generated IDs let mut nodes = HashMap::new(); let mut id_counter = 0; @@ -271,6 +274,9 @@ fn explain_expression(expr: &str) -> String { Ok(d) => d, Err(e) => return format!("Failed to parse Durofut JSON: {e}"), }; + if let Err(e) = root.validate_recursive() { + return format!("Invalid durable function graph: {e}"); + } // Build in-memory node map from nested structure with generated IDs let mut nodes = HashMap::new(); @@ -293,14 +299,16 @@ fn collect_nodes( let node_id = format!("N{}", id_counter); // Recursively collect children first to get their IDs - let left_id = node - .left_node - .as_ref() - .map(|n| collect_nodes(n, nodes, id_counter)); - let right_id = node - .right_node - .as_ref() - .map(|n| collect_nodes(n, nodes, id_counter)); + let left_id = node.left_node.as_ref().map(|raw| { + let child = crate::types::Durofut::child_from_raw(raw) + .expect("validated Durofut child must deserialize"); + collect_nodes(&child, nodes, id_counter) + }); + let right_id = node.right_node.as_ref().map(|raw| { + let child = crate::types::Durofut::child_from_raw(raw) + .expect("validated Durofut child must deserialize"); + collect_nodes(&child, nodes, id_counter) + }); // Process config JSON to collect embedded nodes and replace Durofuts with IDs let updated_query = diff --git a/src/lib.rs b/src/lib.rs index d305b15b..103d607e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -807,9 +807,6 @@ BEGIN WHEN raise_exception THEN -- Re-raise our validation error RAISE; - WHEN OTHERS THEN - -- Not valid JSON, treat as SQL - NULL; END; -- It's plain SQL, wrap it @@ -2212,6 +2209,34 @@ mod tests { assert_eq!(fut.node_type, "LOOP"); } + #[pg_test] + fn test_all_composers_round_trip_opaque_children() { + let sequence = crate::dsl::then_fn("SELECT 1", "SELECT 2"); + let graphs = [ + sequence.clone(), + crate::dsl::as_named(&sequence, "sequence_result"), + crate::dsl::loop_fn("SELECT 1", Some("SELECT true")), + crate::dsl::if_fn("SELECT true", "SELECT 1", "SELECT 0"), + crate::dsl::if_rows_fn("rows_result", "SELECT 1", "SELECT 0"), + crate::dsl::join("SELECT 1", "SELECT 2"), + crate::dsl::join3("SELECT 1", "SELECT 2", "SELECT 3"), + crate::dsl::race("SELECT 1", "SELECT 2"), + ]; + + for graph in graphs { + let durofut = Durofut::try_from_json(&graph).unwrap(); + assert!(durofut + .left_node + .as_ref() + .is_none_or(|child| child.get().starts_with('{'))); + assert!(durofut + .right_node + .as_ref() + .is_none_or(|child| child.get().starts_with('{'))); + assert!(durofut.validate_recursive().is_ok()); + } + } + #[pg_test] fn test_autowrap_start_plain_sql() { // Start with plain SQL - simplest possible durable function @@ -2677,16 +2702,22 @@ mod tests { // should be rejected by validate_recursive let durofut = Durofut { node_type: "IF".to_string(), - left_node: Some(Box::new(Durofut { - node_type: "SQL".to_string(), - query: Some("SELECT 'then'".to_string()), - ..Default::default() - })), - right_node: Some(Box::new(Durofut { - node_type: "SQL".to_string(), - query: Some("SELECT 'else'".to_string()), - ..Default::default() - })), + left_node: Some( + Durofut { + node_type: "SQL".to_string(), + query: Some("SELECT 'then'".to_string()), + ..Default::default() + } + .into_raw(), + ), + right_node: Some( + Durofut { + node_type: "SQL".to_string(), + query: Some("SELECT 'else'".to_string()), + ..Default::default() + } + .into_raw(), + ), query: Some(r#"{"condition_node": {"foo": "bar"}}"#.to_string()), ..Default::default() }; @@ -2706,11 +2737,14 @@ mod tests { // condition_node as a number should be rejected let durofut = Durofut { node_type: "LOOP".to_string(), - left_node: Some(Box::new(Durofut { - node_type: "SQL".to_string(), - query: Some("SELECT 1".to_string()), - ..Default::default() - })), + left_node: Some( + Durofut { + node_type: "SQL".to_string(), + query: Some("SELECT 1".to_string()), + ..Default::default() + } + .into_raw(), + ), query: Some(r#"{"condition_node": 42}"#.to_string()), ..Default::default() }; @@ -2723,16 +2757,22 @@ mod tests { // condition_node as a string ID (old format) should be rejected let durofut = Durofut { node_type: "IF".to_string(), - left_node: Some(Box::new(Durofut { - node_type: "SQL".to_string(), - query: Some("SELECT 'then'".to_string()), - ..Default::default() - })), - right_node: Some(Box::new(Durofut { - node_type: "SQL".to_string(), - query: Some("SELECT 'else'".to_string()), - ..Default::default() - })), + left_node: Some( + Durofut { + node_type: "SQL".to_string(), + query: Some("SELECT 'then'".to_string()), + ..Default::default() + } + .into_raw(), + ), + right_node: Some( + Durofut { + node_type: "SQL".to_string(), + query: Some("SELECT 'else'".to_string()), + ..Default::default() + } + .into_raw(), + ), query: Some(r#"{"condition_node": "a1b2c3d4"}"#.to_string()), ..Default::default() }; @@ -2750,22 +2790,28 @@ mod tests { }; let middle = Durofut { node_type: "THEN".to_string(), - left_node: Some(Box::new(Durofut { - node_type: "SQL".to_string(), - query: Some("SELECT 1".to_string()), - ..Default::default() - })), - right_node: Some(Box::new(inner_bad)), + left_node: Some( + Durofut { + node_type: "SQL".to_string(), + query: Some("SELECT 1".to_string()), + ..Default::default() + } + .into_raw(), + ), + right_node: Some(inner_bad.into_raw()), ..Default::default() }; let root = Durofut { node_type: "THEN".to_string(), - left_node: Some(Box::new(Durofut { - node_type: "SQL".to_string(), - query: Some("SELECT 0".to_string()), - ..Default::default() - })), - right_node: Some(Box::new(middle)), + left_node: Some( + Durofut { + node_type: "SQL".to_string(), + query: Some("SELECT 0".to_string()), + ..Default::default() + } + .into_raw(), + ), + right_node: Some(middle.into_raw()), ..Default::default() }; let result = root.validate_recursive(); @@ -2784,16 +2830,22 @@ mod tests { // An extra_nodes entry that is not a valid Durofut let durofut = Durofut { node_type: "JOIN".to_string(), - left_node: Some(Box::new(Durofut { - node_type: "SQL".to_string(), - query: Some("SELECT 1".to_string()), - ..Default::default() - })), - right_node: Some(Box::new(Durofut { - node_type: "SQL".to_string(), - query: Some("SELECT 2".to_string()), - ..Default::default() - })), + left_node: Some( + Durofut { + node_type: "SQL".to_string(), + query: Some("SELECT 1".to_string()), + ..Default::default() + } + .into_raw(), + ), + right_node: Some( + Durofut { + node_type: "SQL".to_string(), + query: Some("SELECT 2".to_string()), + ..Default::default() + } + .into_raw(), + ), query: Some(r#"{"extra_nodes": [{"not": "a durofut"}]}"#.to_string()), ..Default::default() }; @@ -2855,16 +2907,22 @@ mod tests { let config = serde_json::json!({ "condition_node": condition }); let durofut = Durofut { node_type: "IF".to_string(), - left_node: Some(Box::new(Durofut { - node_type: "SQL".to_string(), - query: Some("SELECT 'then'".to_string()), - ..Default::default() - })), - right_node: Some(Box::new(Durofut { - node_type: "SQL".to_string(), - query: Some("SELECT 'else'".to_string()), - ..Default::default() - })), + left_node: Some( + Durofut { + node_type: "SQL".to_string(), + query: Some("SELECT 'then'".to_string()), + ..Default::default() + } + .into_raw(), + ), + right_node: Some( + Durofut { + node_type: "SQL".to_string(), + query: Some("SELECT 'else'".to_string()), + ..Default::default() + } + .into_raw(), + ), query: Some(config.to_string()), ..Default::default() }; @@ -3042,12 +3100,15 @@ mod tests { for _ in 0..MAX_GRAPH_DEPTH + 1 { node = Durofut { node_type: "THEN".to_string(), - left_node: Some(Box::new(node)), - right_node: Some(Box::new(Durofut { - node_type: "SQL".to_string(), - query: Some("SELECT 1".to_string()), - ..Default::default() - })), + left_node: Some(node.into_raw()), + right_node: Some( + Durofut { + node_type: "SQL".to_string(), + query: Some("SELECT 1".to_string()), + ..Default::default() + } + .into_raw(), + ), ..Default::default() }; } @@ -3074,12 +3135,15 @@ mod tests { for _ in 0..10 { node = Durofut { node_type: "THEN".to_string(), - left_node: Some(Box::new(node)), - right_node: Some(Box::new(Durofut { - node_type: "SQL".to_string(), - query: Some("SELECT 1".to_string()), - ..Default::default() - })), + left_node: Some(node.into_raw()), + right_node: Some( + Durofut { + node_type: "SQL".to_string(), + query: Some("SELECT 1".to_string()), + ..Default::default() + } + .into_raw(), + ), ..Default::default() }; } @@ -3111,8 +3175,8 @@ mod tests { let join_node = Durofut { node_type: "JOIN".to_string(), - left_node: Some(Box::new(sql_node.clone())), - right_node: Some(Box::new(sql_node)), + left_node: Some(sql_node.clone().into_raw()), + right_node: Some(sql_node.into_raw()), query: Some(config.to_string()), ..Default::default() }; @@ -3288,11 +3352,14 @@ mod tests { // requires condition_node to deserialize as a valid Durofut. let node = Durofut { node_type: "LOOP".to_string(), - left_node: Some(Box::new(Durofut { - node_type: "SQL".to_string(), - query: Some("SELECT 1".to_string()), - ..Default::default() - })), + left_node: Some( + Durofut { + node_type: "SQL".to_string(), + query: Some("SELECT 1".to_string()), + ..Default::default() + } + .into_raw(), + ), // Malformed config: valid JSON but condition_node is a string, not a Durofut object. query: Some(r#"{"condition_node": "nonexist"}"#.to_string()), ..Default::default() @@ -3308,11 +3375,14 @@ mod tests { // (it's treated as a plain query string, not a config object). let non_json_node = Durofut { node_type: "LOOP".to_string(), - left_node: Some(Box::new(Durofut { - node_type: "SQL".to_string(), - query: Some("SELECT 1".to_string()), - ..Default::default() - })), + left_node: Some( + Durofut { + node_type: "SQL".to_string(), + query: Some("SELECT 1".to_string()), + ..Default::default() + } + .into_raw(), + ), query: Some("this is not json at all!!!".to_string()), ..Default::default() }; diff --git a/src/types.rs b/src/types.rs index 86290d09..7f1ab8fb 100644 --- a/src/types.rs +++ b/src/types.rs @@ -113,8 +113,8 @@ pub async fn is_role_superuser_name(pool: &sqlx::PgPool, role_name: &str) -> Res .and_then(|opt| opt.ok_or_else(|| format!("role '{}' not found in pg_roles", role_name))) } -/// Maximum nesting depth for workflow graphs. Prevents stack overflow from -/// deeply nested operator chains (e.g., 10,000 levels of `~>`). +/// Maximum nesting depth for workflow graphs. Bounds recursive graph walkers +/// after opaque child storage removes serde_json's incidental depth limit. pub const MAX_GRAPH_DEPTH: usize = 256; /// Maximum number of nodes allowed in a single workflow instance. Prevents @@ -1168,16 +1168,43 @@ pub fn mark_non_future_helper_call(function_name: &str) { } } +fn deserialize_raw_object<'de, D>( + deserializer: D, +) -> Result>, D::Error> +where + D: serde::Deserializer<'de>, +{ + let value = Option::>::deserialize(deserializer)?; + if value + .as_ref() + .is_some_and(|raw| !raw.get().trim_start().starts_with('{')) + { + return Err(serde::de::Error::custom( + "Durofut children must be JSON objects", + )); + } + Ok(value) +} + /// The Durofut type represents a "durable future" - a reference to a node in the function graph. -/// Children are embedded as nested structures, not stored as ID references. +/// Children are embedded as opaque JSON objects, not stored as ID references. Keeping them as +/// `RawValue` lets each graph level deserialize independently without serde_json's recursion limit. /// Node IDs are generated during insertion into df.nodes, not during graph construction. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Durofut { pub node_type: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub left_node: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub right_node: Option>, + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_raw_object", + default + )] + pub left_node: Option>, + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_raw_object", + default + )] + pub right_node: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub query: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -1185,6 +1212,15 @@ pub struct Durofut { } impl Durofut { + pub fn into_raw(self) -> Box { + serde_json::value::to_raw_value(&self).expect("failed to serialize Durofut child") + } + + pub(crate) fn child_from_raw(raw: &serde_json::value::RawValue) -> Result { + serde_json::from_str(raw.get()) + .map_err(|e| format!("failed to deserialize Durofut child: {}", e)) + } + fn same_statement_non_future_helper_name(s: &str) -> Option { // Fast path: legitimate Durofut envelopes are JSON objects starting // with '{'. Anything else (plain text such as "OK", "completed", @@ -1338,10 +1374,10 @@ impl Durofut { )); } if let Some(ref left) = self.left_node { - left.validate_recursive_inner(depth + 1, node_count)?; + Self::child_from_raw(left)?.validate_recursive_inner(depth + 1, node_count)?; } if let Some(ref right) = self.right_node { - right.validate_recursive_inner(depth + 1, node_count)?; + Self::child_from_raw(right)?.validate_recursive_inner(depth + 1, node_count)?; } // Validate config-embedded nodes (condition_node, extra_nodes) let d = depth + 1; @@ -1479,6 +1515,31 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn durofut_raw_children_preserve_wire_format() { + let json = r#"{"node_type":"THEN","left_node":{"node_type":"SQL","query":"SELECT 1"},"right_node":{"node_type":"SQL","query":"SELECT 2"}}"#; + + let durofut = Durofut::try_from_json(json).unwrap(); + + assert_eq!(durofut.to_json(), json); + } + + #[test] + fn durofut_deserializes_beyond_serde_recursion_limit() { + let mut json = r#"{"node_type":"SQL","query":"SELECT 1"}"#.to_string(); + for _ in 0..129 { + json = format!( + r#"{{"node_type":"THEN","left_node":{},"right_node":{{"node_type":"SQL","query":"SELECT 1"}}}}"#, + json + ); + } + + let durofut = Durofut::try_from_json(&json).unwrap(); + + assert!(durofut.validate_recursive().is_ok()); + assert_eq!(durofut.to_json(), json); + } + #[test] fn whole_value_reference_accepts_single_references() { for value in [ @@ -2142,12 +2203,15 @@ mod tests { for _ in 0..MAX_GRAPH_DEPTH + 1 { node = Durofut { node_type: "THEN".to_string(), - left_node: Some(Box::new(node)), - right_node: Some(Box::new(Durofut { - node_type: "SQL".to_string(), - query: Some("SELECT 1".to_string()), - ..Default::default() - })), + left_node: Some(node.into_raw()), + right_node: Some( + Durofut { + node_type: "SQL".to_string(), + query: Some("SELECT 1".to_string()), + ..Default::default() + } + .into_raw(), + ), ..Default::default() }; } @@ -2171,12 +2235,15 @@ mod tests { for _ in 0..MAX_GRAPH_DEPTH - 1 { node = Durofut { node_type: "THEN".to_string(), - left_node: Some(Box::new(node)), - right_node: Some(Box::new(Durofut { - node_type: "SQL".to_string(), - query: Some("SELECT 1".to_string()), - ..Default::default() - })), + left_node: Some(node.into_raw()), + right_node: Some( + Durofut { + node_type: "SQL".to_string(), + query: Some("SELECT 1".to_string()), + ..Default::default() + } + .into_raw(), + ), ..Default::default() }; } @@ -2198,8 +2265,8 @@ mod tests { Durofut { node_type: "JOIN".to_string(), - left_node: Some(Box::new(sql_node.clone())), - right_node: Some(Box::new(sql_node)), + left_node: Some(sql_node.clone().into_raw()), + right_node: Some(sql_node.into_raw()), query: Some(config.to_string()), ..Default::default() } diff --git a/tests/e2e/sql/09_graph_and_validation.sql b/tests/e2e/sql/09_graph_and_validation.sql index e2bdbfcf..0ef9c2d3 100644 --- a/tests/e2e/sql/09_graph_and_validation.sql +++ b/tests/e2e/sql/09_graph_and_validation.sql @@ -178,5 +178,100 @@ END $$; DROP TABLE _test_wait_blocked; +-- === Test: deep graph composition (#327) === + +DO $$ +DECLARE + graph TEXT := df.sql('SELECT 1'); + explanation TEXT; +BEGIN + FOR i IN 1..129 LOOP + graph := df.seq(graph, 'SELECT 1'); + END LOOP; + + explanation := df.explain(graph); + IF explanation LIKE 'Invalid durable function graph:%' + OR pg_catalog.regexp_count(explanation, '→') != 129 THEN + RAISE EXCEPTION 'TEST FAILED: 129-level df.seq graph was corrupted: %', explanation; + END IF; + + RAISE NOTICE 'TEST PASSED: df.seq composes beyond serde recursion limit'; +END $$; + +DO $$ +DECLARE + graph TEXT := df.sql('SELECT 1'); + explanation TEXT; +BEGIN + FOR i IN 1..129 LOOP + graph := ('SELECT true' ?> graph) !> 'SELECT 0'; + END LOOP; + + explanation := df.explain(graph); + IF explanation LIKE 'Invalid durable function graph:%' + OR pg_catalog.regexp_count(explanation, 'IF') != 129 THEN + RAISE EXCEPTION 'TEST FAILED: 129-level ?>/!> graph was corrupted: %', explanation; + END IF; + + RAISE NOTICE 'TEST PASSED: ?>/!> composes beyond parser recursion limit'; +END $$; + +DO $$ +DECLARE + graph TEXT := df.sql('SELECT 1'); + explanation TEXT; +BEGIN + FOR i IN 1..256 LOOP + graph := df.seq(graph, 'SELECT 1'); + END LOOP; + + explanation := df.explain(graph); + IF explanation LIKE 'Invalid durable function graph:%' THEN + RAISE EXCEPTION 'TEST FAILED: graph at maximum depth was rejected: %', explanation; + END IF; + + graph := df.seq(graph, 'SELECT 1'); + explanation := df.explain(graph); + IF explanation NOT LIKE '%maximum nesting depth of 256%' THEN + RAISE EXCEPTION 'TEST FAILED: graph beyond maximum depth was not rejected cleanly: %', explanation; + END IF; + + RAISE NOTICE 'TEST PASSED: df.explain enforces the configured depth boundary'; +END $$; + +CREATE TEMP TABLE _deep_graph_execution (instance_id TEXT); + +DO $$ +DECLARE + graph TEXT := df.sql('SELECT 1'); +BEGIN + FOR i IN 2..200 LOOP + graph := df.seq(graph, 'SELECT 1'); + END LOOP; + + INSERT INTO _deep_graph_execution SELECT df.start(graph, 'test-deep-graph-200'); +END $$; + +DO $$ +DECLARE + inst_id TEXT; + status TEXT; +BEGIN + SELECT instance_id INTO inst_id FROM _deep_graph_execution; + SELECT df.await_instance(inst_id, 300) INTO status; + + IF status != 'completed' THEN + RAISE EXCEPTION 'TEST FAILED: 200-step graph status = %', status; + END IF; + + IF (SELECT count(*) FROM df.nodes WHERE instance_id = inst_id) != 399 THEN + RAISE EXCEPTION 'TEST FAILED: 200-step graph did not materialize 399 nodes'; + END IF; + + RAISE NOTICE 'TEST PASSED: 200-step graph executes end to end'; +END $$; + +DROP TABLE _deep_graph_execution; + RESET SESSION AUTHORIZATION; SELECT 'TEST PASSED' AS result;