Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
37 changes: 28 additions & 9 deletions rust/src/runtime/external_wait_summary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<i64>,
Expand All @@ -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") {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -272,7 +291,7 @@ where
}
fn external_wait_matches(row: &DB::Row, expected: &ExpectedWait) -> Result<bool> {
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()
Expand Down
24 changes: 14 additions & 10 deletions rust/src/runtime/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"])?;
Expand Down Expand Up @@ -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?;
Expand Down
3 changes: 2 additions & 1 deletion rust/src/runtime/wait_projection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions rust/tests/execution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
63 changes: 63 additions & 0 deletions rust/tests/execution/keyless_condition_waits.rs
Original file line number Diff line number Diff line change
@@ -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}");
}
Loading