From 1414996fab53155cf324fa6300f1875c69e53c20 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Fri, 23 Oct 2026 20:40:20 +0000 Subject: [PATCH] Accept condition waits without a key or fingerprint like PHP Published PHP treats an open_condition_wait's condition_key and condition_definition_fingerprint as optional strings of any form, trimmed and left out when empty. Native required both and a sha256 fingerprint. The wait projection and run summary now read a keyless wait as PHP does. --- .../2026-10-23-condition-wait-fields.md | 21 +++++++ rust/src/runtime/external_wait_summary.rs | 37 ++++++++--- rust/src/runtime/store.rs | 24 ++++--- rust/src/runtime/wait_projection.rs | 3 +- rust/tests/execution.rs | 2 + .../execution/keyless_condition_waits.rs | 63 +++++++++++++++++++ 6 files changed, 130 insertions(+), 20 deletions(-) create mode 100644 docs/rust-server-port-checkpoints/2026-10-23-condition-wait-fields.md create mode 100644 rust/tests/execution/keyless_condition_waits.rs diff --git a/docs/rust-server-port-checkpoints/2026-10-23-condition-wait-fields.md b/docs/rust-server-port-checkpoints/2026-10-23-condition-wait-fields.md new file mode 100644 index 000000000..b96d9b562 --- /dev/null +++ b/docs/rust-server-port-checkpoints/2026-10-23-condition-wait-fields.md @@ -0,0 +1,21 @@ +# October 23, optional condition wait fields + +- **The gap.** Native refused an `open_condition_wait` command without a + `condition_key`, or without a `condition_definition_fingerprint` of the form + `sha256:` followed by 64 hex digits. Published PHP accepts both. +- **How PHP 2.11.1 does it.** `normalizeOpenConditionWaitCommand` treats the key + and the fingerprint as optional strings of any form. It trims them and leaves + them out when empty. `applyOpenConditionWait` records only the fields that + remain, on `ConditionWaitOpened` and on a condition timeout's + `TimerScheduled`. Run summaries name a keyless wait "Waiting for condition", + without a key. Observed live against the published image: a bare + `open_condition_wait`, a key of `" ready "` with the fingerprint + `"any form"`, and blank values all behave this way. +- **What native does now.** The same. The open wait projection stores no + target name for a keyless wait, and the run summary reads such a wait as + PHP does. A key or fingerprint that is not a string is still refused by the + completion transport rules. +- **How it was checked.** A new integration test covers a bare wait, a padded + key with an arbitrary fingerprint, blank values with a timeout, and a key + that is not a string. The 63 existing condition, timed-condition and + observation tests still pass. diff --git a/rust/src/runtime/external_wait_summary.rs b/rust/src/runtime/external_wait_summary.rs index 0f9a959a1..06abf03fa 100644 --- a/rust/src/runtime/external_wait_summary.rs +++ b/rust/src/runtime/external_wait_summary.rs @@ -19,6 +19,8 @@ pub(super) struct ExternalWaitHistory { struct ExpectedWait { kind: &'static str, + /// The signal name, or the condition key; PHP allows a condition + /// without one, which reads as empty. name: String, sequence: i64, timeout: Option, @@ -27,6 +29,26 @@ struct ExpectedWait { identity: Value, } +/// A wait's name: a signal always has one; a condition's key is optional. +fn wait_name<'a>(kind: &str, value: &'a Value) -> Option<&'a str> { + match value.as_str().filter(|name| !name.is_empty()) { + Some(name) => Some(name), + None if kind == "condition" && value.is_null() => Some(""), + None => None, + } +} + +impl ExpectedWait { + /// `conditionLabel`: the kind, then the name when there is one. + fn label(&self) -> String { + if self.name.is_empty() { + self.kind.to_owned() + } else { + format!("{} {}", self.kind, self.name) + } + } +} + impl ExternalWaitHistory { pub(super) fn apply(&mut self, kind: &str, payload: &Value) -> bool { if matches!(kind, "TimerScheduled" | "TimerFired" | "TimerCancelled") { @@ -70,7 +92,7 @@ impl ExternalWaitHistory { } let (Some(id), Some(name), Some(sequence)) = ( payload[id_key].as_str().filter(|v| !v.is_empty()), - payload[name_key].as_str().filter(|v| !v.is_empty()), + wait_name(wait_kind, &payload[name_key]), payload["sequence"].as_i64().filter(|v| *v > 0), ) else { return false; @@ -102,7 +124,7 @@ impl ExternalWaitHistory { let (Some(timer_id), Some(wait_id), Some(name), Some(sequence), Some(delay)) = ( payload["timer_id"].as_str().filter(|v| !v.is_empty()), payload[id_key].as_str().filter(|v| !v.is_empty()), - payload[name_key].as_str().filter(|v| !v.is_empty()), + wait_name(wait_kind, &payload[name_key]), payload["sequence"].as_i64(), payload["delay_seconds"].as_i64(), ) else { @@ -230,14 +252,11 @@ where kind: expected.kind, id: DB::string(row, "wait_id")?, reason: if expected.fired { - format!( - "Waiting to apply {} {} timeout", - expected.kind, expected.name - ) + format!("Waiting to apply {} timeout", expected.label()) } else if expected.timeout.is_some() { - format!("Waiting for {} {} or timeout", expected.kind, expected.name) + format!("Waiting for {} or timeout", expected.label()) } else { - format!("Waiting for {} {}", expected.kind, expected.name) + format!("Waiting for {}", expected.label()) }, started_at: DB::optional_instant(row, "opened_at")?, deadline_at: expected.timer.as_ref().map(|(_, at)| *at), @@ -272,7 +291,7 @@ where } fn external_wait_matches(row: &DB::Row, expected: &ExpectedWait) -> Result { if DB::string(row, "kind")? != expected.kind - || DB::optional_string(row, "target_name")?.as_deref() != Some(expected.name.as_str()) + || DB::optional_string(row, "target_name")?.unwrap_or_default() != expected.name || DB::optional_number(row, "sequence")? != Some(expected.sequence) || DB::optional_string(row, "history_authority")?.as_deref() != Some("typed_history") || DB::optional_string(row, "source_status")?.as_deref() diff --git a/rust/src/runtime/store.rs b/rust/src/runtime/store.rs index 93c299d12..e86601249 100644 --- a/rust/src/runtime/store.rs +++ b/rust/src/runtime/store.rs @@ -2199,17 +2199,9 @@ where { return Err(refuse(StatusCode::UNPROCESSABLE_ENTITY, "invalid_request")); } - text(command, "condition_key")?; + // `normalizeOpenConditionWaitCommand`: the key and the + // definition fingerprint are optional strings, of any form. super::wait_timeouts::timeout_for(command, "invalid_condition_timeout")?; - let fingerprint = text(command, "condition_definition_fingerprint")?; - if !fingerprint.strip_prefix("sha256:").is_some_and(|hash| { - hash.len() == 64 && hash.bytes().all(|byte| byte.is_ascii_hexdigit()) - }) { - return Err(refuse( - StatusCode::UNPROCESSABLE_ENTITY, - "invalid_condition_fingerprint", - )); - } } "open_signal_wait" => { reject_fields(command, &["type", "signal_name", "timeout_seconds"])?; @@ -2555,6 +2547,18 @@ where .unwrap() .remove("condition_wait_occurrence_id"); } + // `normalizeOptionalString`: PHP trims the condition's key and + // definition fingerprint and drops them when empty. + if !direct { + for field in ["condition_key", "condition_definition_fingerprint"] { + match payload[field].as_str().map(str::trim).filter(|value| !value.is_empty()) { + Some(value) => payload[field] = json!(value), + None => { + payload.as_object_mut().unwrap().remove(field); + } + } + } + } let pending_wait_id = if direct { let pending = Self::query("SELECT signal_wait_id FROM workflow_signal_records WHERE workflow_run_id=$1 AND signal_name=$2 AND status='received' ORDER BY command_sequence,id LIMIT 1") .bind(&run_id).bind(text(command, "signal_name")?).fetch_optional(&mut *tx).await?; diff --git a/rust/src/runtime/wait_projection.rs b/rust/src/runtime/wait_projection.rs index 6467aa436..6dfdfc2ba 100644 --- a/rust/src/runtime/wait_projection.rs +++ b/rust/src/runtime/wait_projection.rs @@ -267,7 +267,8 @@ where ) -> Result<()> { if event_type == "ConditionWaitOpened" { let wait_id = super::text(payload, "condition_wait_id")?; - let key = super::text(payload, "condition_key")?; + // PHP records a condition wait without a key when none was given. + let key = payload["condition_key"].as_str(); let sequence = wait_sequence(payload)?; let projection_id = projection_id(run_id, wait_id); let summary = payload["timeout_seconds"].as_i64().map_or_else( diff --git a/rust/tests/execution.rs b/rust/tests/execution.rs index c3ce551e4..55a6b36ff 100644 --- a/rust/tests/execution.rs +++ b/rust/tests/execution.rs @@ -87,6 +87,8 @@ mod rejected_updates; mod continue_messages; #[path = "execution/update_wait.rs"] mod update_wait; +#[path = "execution/keyless_condition_waits.rs"] +mod keyless_condition_waits; #[path = "execution/payload_limits.rs"] mod payload_limits; #[path = "execution/start_definition.rs"] diff --git a/rust/tests/execution/keyless_condition_waits.rs b/rust/tests/execution/keyless_condition_waits.rs new file mode 100644 index 000000000..2d66bfa6a --- /dev/null +++ b/rust/tests/execution/keyless_condition_waits.rs @@ -0,0 +1,63 @@ +use super::*; + +// Workflow 2.9's `normalizeOpenConditionWaitCommand`: a condition wait's key +// and definition fingerprint are optional strings of any form. PHP trims them +// and leaves them out when empty. + +fn opened(history: &Value) -> Value { + history.as_array().unwrap().iter().rev().find(|event| event["event_type"] == "ConditionWaitOpened") + .map(|event| event["payload"].clone()).unwrap() +} + +#[tokio::test] +async fn condition_waits_need_neither_a_key_nor_a_fingerprint() { + let database = TestDatabase::new().await; + let app = router(database.open().await.unwrap()); + let started = start(&app, "keyless-condition").await; + register(&app, "keyless", json!(["echo"]), json!([])).await; + let task = poll(&app, "keyless", "workflow").await; + let (status, body) = finish_task(&app, &task, json!([{"type":"open_condition_wait"}])).await; + assert_eq!(status, StatusCode::OK, "{body}"); + let history = run_history(&app, &started["workflow_id"], &started["run_id"]).await; + let payload = opened(&history); + assert!(payload.get("condition_key").is_none() && payload.get("condition_definition_fingerprint").is_none(), "{payload}"); + assert_eq!(task_retry::rows(&database, + "SELECT COUNT(*) FROM workflow_run_summaries WHERE wait_kind='condition' AND wait_reason='Waiting for condition'").await, 1); + assert_eq!(task_retry::rows(&database, + "SELECT COUNT(*) FROM workflow_run_waits WHERE kind='condition' AND status='open' AND target_name IS NULL").await, 1); + + // A signal wakes the wait; the next one keeps a trimmed key and any + // fingerprint, and drops a blank one. + let (status, body) = request(&app, "POST", "/api/workflows/keyless-condition/signal/go", json!([])).await; + assert_eq!(status, StatusCode::ACCEPTED, "{body}"); + let task = poll(&app, "keyless", "workflow").await; + let (status, body) = finish_task(&app, &task, json!([{"type":"open_condition_wait","condition_key":" ready ", + "condition_definition_fingerprint":"any form"}])).await; + assert_eq!(status, StatusCode::OK, "{body}"); + let payload = opened(&run_history(&app, &started["workflow_id"], &started["run_id"]).await); + assert_eq!((payload["condition_key"].clone(), payload["condition_definition_fingerprint"].clone()), + (json!("ready"), json!("any form")), "{payload}"); + assert_eq!(task_retry::rows(&database, + "SELECT COUNT(*) FROM workflow_run_summaries WHERE wait_reason='Waiting for condition ready'").await, 1); + + let (status, body) = request(&app, "POST", "/api/workflows/keyless-condition/signal/again", json!([])).await; + assert_eq!(status, StatusCode::ACCEPTED, "{body}"); + let task = poll(&app, "keyless", "workflow").await; + let (status, body) = finish_task(&app, &task, json!([{"type":"open_condition_wait","condition_key":" ", + "condition_definition_fingerprint":"","timeout_seconds":60}])).await; + assert_eq!(status, StatusCode::OK, "{body}"); + let history = run_history(&app, &started["workflow_id"], &started["run_id"]).await; + let payload = opened(&history); + assert!(payload.get("condition_key").is_none() && payload.get("condition_definition_fingerprint").is_none(), "{payload}"); + let scheduled = history.as_array().unwrap().iter().rev().find(|event| event["event_type"] == "TimerScheduled").unwrap(); + assert!(scheduled["payload"].get("condition_key").is_none(), "{scheduled}"); + assert_eq!(task_retry::rows(&database, + "SELECT COUNT(*) FROM workflow_run_summaries WHERE wait_reason='Waiting for condition or timeout'").await, 1); + + // A key that is not a string is still refused. + let (status, body) = request(&app, "POST", "/api/workflows/keyless-condition/signal/third", json!([])).await; + assert_eq!(status, StatusCode::ACCEPTED, "{body}"); + let task = poll(&app, "keyless", "workflow").await; + let (status, body) = finish_task(&app, &task, json!([{"type":"open_condition_wait","condition_key":7}])).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}"); +}