Skip to content

Commit 3bad91f

Browse files
authored
Merge pull request #926 from durable-workflow/rust-325-waiting-ack-deadline
Rust #325: close a run whose deadline passed when a task failure is reported
2 parents f9add9e + 262792a commit 3bad91f

3 files changed

Lines changed: 149 additions & 10 deletions

File tree

‎docs/rust-server-port.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,27 @@ contract fixture before its correction.
501501
task, oldest first, as PHP does behind a leased task. PHP's merge is
502502
reported as
503503
[server#902](https://github.com/durable-workflow/server/issues/902).
504+
- **A failure or history wait reported after the run's deadline**
505+
(November 1, decided: native differs from published 2.11.8 on purpose).
506+
Published 2.11.8 acknowledges a `WorkflowTaskWaitingForHistory` failure
507+
after the run or execution deadline and leaves the run `waiting`, so a
508+
deadline probe never closes it
509+
([server#794 finding](https://github.com/durable-workflow/server/issues/794#issuecomment-6562291610)).
510+
Native closes the run when a workflow task failure, waiting or not, is
511+
reported after either deadline, through the same timeout a completion
512+
uses, as General's correction does (server `c2b18fad` and its follow-up
513+
on #794, not yet published). The worker gets the completion's receipt
514+
for a timed-out run: `409`, `outcome` `completed`, `recorded` false,
515+
`run_status` `failed`, `reason` `run_timed_out`, with the original
516+
`task_id`, `workflow_task_attempt` and `run_id`. PHP SDK 3.6.1 recognises
517+
that receipt, and does not recognise a timeout refusal shaped as
518+
`waiting_for_history`. The run gets one
519+
`WorkflowTimedOut` with the deadline's `timeout_kind`, and no workflow
520+
task stays open. A stale attempt still changes nothing, and a replay adds
521+
no second timeout
522+
(`run_deadlines::reports_after_a_deadline_time_out_the_run_with_the_completion_receipt`).
523+
Record a parity fixture once a PHP release with the correction is
524+
published.
504525
- **Timer firing under the CPU deadline profile on slower hosts.** In local
505526
MySQL 8.0 cells, where eight dense readers saturate the one-CPU database and
506527
the one-CPU native server, one of six runs fired the three-second timer

‎rust/src/runtime/store.rs‎

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2618,6 +2618,34 @@ where
26182618
Ok(response)
26192619
}
26202620

2621+
/// `timeoutIfDeadlineExpired`: a workflow task report on a run whose
2622+
/// execution or run deadline has passed closes the run as timed out and
2623+
/// refuses the report with the completion's receipt (`409`, outcome
2624+
/// `completed`, `run_status` `failed`, reason `run_timed_out`). PHP SDK
2625+
/// 3.6.1 recognises that receipt, and does not recognise a timeout refusal
2626+
/// shaped as `waiting_for_history` (Server #794). Completions, failures
2627+
/// and history waits all use it. A run still inside its deadlines gets its
2628+
/// transaction back unchanged.
2629+
async fn close_expired_run(
2630+
&self,
2631+
mut tx: Transaction<'static, DB>,
2632+
run: &DB::Row,
2633+
task: &DB::Row,
2634+
attempt: &Value,
2635+
) -> Result<Transaction<'static, DB>> {
2636+
let expired = [DB::optional_instant(run, "execution_deadline_at")?, DB::optional_instant(run, "run_deadline_at")?]
2637+
.into_iter()
2638+
.flatten()
2639+
.any(|deadline| deadline <= now());
2640+
if !expired {
2641+
return Ok(tx);
2642+
}
2643+
let response = Self::time_out_run(&mut tx, run, task, attempt).await?;
2644+
tx.commit().await?;
2645+
self.wake.notify_waiters();
2646+
Err(super::RuntimeError::Protocol { status: StatusCode::CONFLICT, response })
2647+
}
2648+
26212649
pub(crate) async fn fail_workflow_task(
26222650
&self,
26232651
task_id: &str,
@@ -2704,6 +2732,10 @@ where
27042732
Self::workflow_task_guard(&mut tx, &task, &body, self.worker_stale_after_seconds).await?;
27052733
let run_id = DB::string(&task, "workflow_run_id")?;
27062734
let run = Self::active_run(&mut tx, &run_id).await?;
2735+
// Server #794: a failure or history wait reported after the run's
2736+
// deadline closes the run as timed out, with the receipt a completion
2737+
// gets. Published 2.11.8 acknowledged the wait and left the run waiting.
2738+
let mut tx = self.close_expired_run(tx, &run, &task, &body["workflow_task_attempt"]).await?;
27072739
let mut payload = DB::document_row(&task, "payload")?;
27082740
// Published PHP retries a failed task as it is, with any signal or
27092741
// update it carries. A waiting acknowledgement of a signal task
@@ -3103,16 +3135,7 @@ where
31033135
let run = Self::active_run(&mut tx, &run_id).await?;
31043136
// `timeoutIfDeadlineExpired`: published PHP applies a run's deadlines
31053137
// when one of its workflow tasks completes, and closes the run.
3106-
if [DB::optional_instant(&run, "execution_deadline_at")?, DB::optional_instant(&run, "run_deadline_at")?]
3107-
.into_iter()
3108-
.flatten()
3109-
.any(|deadline| deadline <= now())
3110-
{
3111-
let response = Self::time_out_run(&mut tx, &run, &task, &body["workflow_task_attempt"]).await?;
3112-
tx.commit().await?;
3113-
self.wake.notify_waiters();
3114-
return Err(super::RuntimeError::Protocol { status: StatusCode::CONFLICT, response });
3115-
}
3138+
let mut tx = self.close_expired_run(tx, &run, &task, &body["workflow_task_attempt"]).await?;
31163139
// Workflow #930: the deadline close above is never metered. Before
31173140
// the worker's first write, its local activities' exact events and
31183141
// rows (other commands at one event) must fit the limits; then the

‎rust/tests/execution/run_deadlines.rs‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,98 @@ async fn workflow_task_before_the_deadline_completes_normally() {
7777
assert!(events_of(&history, "WorkflowTimedOut").is_empty());
7878
assert_eq!(events_of(&history, "WorkflowCompleted").len(), 1);
7979
}
80+
81+
// Server #794: a failure or history wait reported after the run's deadline
82+
// closes the run as timed out, and the worker gets the same receipt a
83+
// completion gets (PHP SDK 3.6.1 does not recognise a timeout refusal shaped
84+
// as `waiting_for_history`). Published 2.11.8
85+
// acknowledged the wait and left the run waiting.
86+
#[tokio::test]
87+
async fn reports_after_a_deadline_time_out_the_run_with_the_completion_receipt() {
88+
let waiting = json!({"type":"WorkflowTaskWaitingForHistory","message":"history is not ready yet"});
89+
let failure = json!({"type":"RuntimeException","message":"worker failed"});
90+
for kind in ["run", "execution"] {
91+
let mut receipts = Vec::new();
92+
for report in ["waiting", "failure", "completion"] {
93+
let database = TestDatabase::new().await;
94+
let app = router(database.open().await.unwrap());
95+
register(&app, "worker", json!(["echo"]), json!([])).await;
96+
let workflow_id = format!("deadline-{kind}-{report}");
97+
let (status, started) = request(&app, "POST", "/api/workflows", json!({"workflow_id":workflow_id,"workflow_type":"echo",
98+
"task_queue":"test","input":envelope(Payload::Array(vec![])),format!("{kind}_timeout_seconds"):600})).await;
99+
assert_eq!(status, StatusCode::CREATED, "{started}");
100+
let task = poll(&app, "worker", "workflow").await;
101+
if kind == "run" {
102+
database.execute("UPDATE workflow_runs SET run_deadline_at='2000-01-01 00:00:00'").await;
103+
} else {
104+
database.execute("UPDATE workflow_runs SET execution_deadline_at='2000-01-01 00:00:00'").await;
105+
}
106+
let task_id = task["task_id"].as_str().unwrap();
107+
let fail_path = format!("/api/worker/workflow-tasks/{task_id}/fail");
108+
let fail_body = |failure: &Value| json!({"lease_owner":task["lease_owner"],"workflow_task_attempt":task["workflow_task_attempt"],"failure":failure});
109+
let history_before = run_history(&app, &json!(workflow_id), &started["run_id"]).await;
110+
let mut stale = fail_body(&waiting);
111+
stale["workflow_task_attempt"] = json!(task["workflow_task_attempt"].as_i64().unwrap() + 1);
112+
let (status, refused) = request(&app, "POST", &fail_path, stale).await;
113+
assert_eq!(status, StatusCode::CONFLICT, "{kind} {report}: {refused}");
114+
assert_eq!(run_history(&app, &json!(workflow_id), &started["run_id"]).await, history_before, "a stale attempt changes nothing");
115+
116+
let (status, mut receipt) = match report {
117+
"waiting" => request(&app, "POST", &fail_path, fail_body(&waiting)).await,
118+
"failure" => request(&app, "POST", &fail_path, fail_body(&failure)).await,
119+
_ => finish_task(&app, &task, json!([{"type":"complete_workflow","result":envelope(Payload::Long(2))}])).await,
120+
};
121+
assert_eq!(status, StatusCode::CONFLICT, "{kind} {report}: {receipt}");
122+
assert_eq!(receipt["task_id"], task["task_id"], "{receipt}");
123+
assert_eq!(receipt["workflow_task_attempt"], task["workflow_task_attempt"], "{receipt}");
124+
assert_eq!(receipt["run_id"], started["run_id"], "{receipt}");
125+
assert_eq!(receipt["outcome"], "completed", "{receipt}");
126+
assert_eq!(receipt["recorded"], false, "{receipt}");
127+
assert_eq!(receipt["run_status"], "failed", "{receipt}");
128+
assert_eq!(receipt["reason"], "run_timed_out", "{receipt}");
129+
assert_eq!(receipt["created_task_ids"], json!([]), "{receipt}");
130+
assert!(receipt["protocol_version"].is_string() && receipt["server_capabilities"].is_object(), "{receipt}");
131+
let (_, described) = request(&app, "GET", &format!("/api/workflows/{workflow_id}"), Value::Null).await;
132+
assert_eq!((described["status"].as_str(), described["closed_reason"].as_str()), (Some("failed"), Some("timed_out")), "{described}");
133+
let history = run_history(&app, &json!(workflow_id), &started["run_id"]).await;
134+
let timed_out = events_of(&history, "WorkflowTimedOut");
135+
assert_eq!(timed_out.len(), 1, "{history}");
136+
assert_eq!(timed_out[0]["payload"]["timeout_kind"], format!("{kind}_timeout"));
137+
assert!(poll(&app, "worker", "workflow").await.is_null(), "no workflow task stays open");
138+
let (status, replay) = request(&app, "POST", &fail_path, fail_body(&waiting)).await;
139+
assert_eq!(status, StatusCode::CONFLICT, "{replay}");
140+
let history = run_history(&app, &json!(workflow_id), &started["run_id"]).await;
141+
assert_eq!(events_of(&history, "WorkflowTimedOut").len(), 1, "a replay adds no second timeout");
142+
for field in ["task_id", "run_id"] {
143+
receipt[field] = Value::Null;
144+
}
145+
receipts.push(receipt);
146+
}
147+
assert_eq!(receipts[0], receipts[2], "{kind}: a history wait gets the completion's receipt");
148+
assert_eq!(receipts[1], receipts[2], "{kind}: a failure gets the completion's receipt");
149+
}
150+
}
151+
152+
// Before the deadline a history wait and a failure are acknowledged as usual.
153+
#[tokio::test]
154+
async fn reports_before_a_deadline_are_acknowledged() {
155+
for (kind, failure, outcome) in [
156+
("run", json!({"type":"WorkflowTaskWaitingForHistory","message":"history is not ready yet"}), "waiting_for_history"),
157+
("execution", json!({"type":"RuntimeException","message":"worker failed"}), "failed"),
158+
] {
159+
let database = TestDatabase::new().await;
160+
let app = router(database.open().await.unwrap());
161+
register(&app, "worker", json!(["echo"]), json!([])).await;
162+
let workflow_id = format!("in-time-{kind}");
163+
let (status, started) = request(&app, "POST", "/api/workflows", json!({"workflow_id":workflow_id,"workflow_type":"echo",
164+
"task_queue":"test","input":envelope(Payload::Array(vec![])),format!("{kind}_timeout_seconds"):600})).await;
165+
assert_eq!(status, StatusCode::CREATED, "{started}");
166+
let task = poll(&app, "worker", "workflow").await;
167+
let (status, answer) = request(&app, "POST", &format!("/api/worker/workflow-tasks/{}/fail", task["task_id"].as_str().unwrap()),
168+
json!({"lease_owner":task["lease_owner"],"workflow_task_attempt":task["workflow_task_attempt"],"failure":failure})).await;
169+
assert_eq!(status, StatusCode::OK, "{kind}: {answer}");
170+
assert_eq!((answer["outcome"].as_str(), answer["recorded"].as_bool()), (Some(outcome), Some(true)), "{answer}");
171+
let history = run_history(&app, &json!(workflow_id), &started["run_id"]).await;
172+
assert!(events_of(&history, "WorkflowTimedOut").is_empty(), "{history}");
173+
}
174+
}

0 commit comments

Comments
 (0)