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
Expand Up @@ -44,7 +44,6 @@
- **Tests.** In `workflow_repair.rs`:
- `repair_creates_a_missing_workflow_activity_or_timer_task_like_php`;
- `repair_settles_an_expired_activity_lease_by_its_retry_policy_like_php`.
- **Not covered.** The missing task of an update, signal or child wait, or
of a condition or signal wait's timeout timer. PHP builds those tasks'
payloads from the wait (`forMissing…Task`), and native still refuses
them.
- **Not covered here.** The missing task of an update, signal or child
wait, or of a condition or signal wait's timeout timer. Closed on
November 1 ([checkpoint](2026-11-01-repair-missing-wait-tasks.md)).
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# November 1, repair of the remaining missing wait tasks

- **Before.** Native's repair command refused with
`422 development_workflow_task_repair_unqualified` when the missing task
belonged to an accepted update, a resolved child, or a condition or signal
wait's timeout timer. The repair pass reported those runs as failures
(207). A run with a received signal and no open task had no summary, so
the command answered `repair_not_needed`, and the pass never selected it.
- **Contract** (published Server 2.11.7, `TaskRepair::createMissingTask`
with `WorkflowTaskPayload::forMissingWorkflowTask`). Recorded over HTTP
with the run's open task row deleted from the server's own SQLite
database and the maintenance loop stopped. Every case answers
`repair_dispatched`, with a `RepairRequested` event (`liveness_state`
`repair_needed`) naming a ready task with `repair_count` 1:

| Case | Summary wait kind | Task created |
| --- | --- | --- |
| Received signal | `signal` | workflow task, `forSignal`: `{workflow_wait_kind, open_wait_id: "signal-application:<signal>", resume_source_kind: workflow_signal, resume_source_id, workflow_signal_id, signal_name, signal_wait_id, workflow_command_id}` |
| Accepted update | `update` | workflow task, `forUpdate`: `{workflow_wait_kind, open_wait_id: "update:<update>", resume_source_kind: workflow_update, resume_source_id, workflow_update_id, workflow_command_id}` |
| Resolved child | `child` | workflow task `{workflow_wait_kind, open_wait_id: "child:<call>", resume_source_kind: child_workflow_run, resume_source_id, child_call_id, child_workflow_run_id}` |
| Pending signal timeout | `signal` | timer task `{timer_id, signal_wait_id, signal_name}`, available at the deadline (now when it has passed) |
| Pending condition timeout | `condition` | timer task `{timer_id, condition_wait_id, condition_key}` (and the definition fingerprint when there is one) |
| Fired signal timeout | `signal` | workflow task `{workflow_wait_kind, open_wait_id: <wait>, resume_source_kind: timer, resume_source_id, timer_id, signal_wait_id, signal_name, workflow_sequence}`, available when the timer fired |
| Fired condition timeout | `condition` | the same with `condition_wait_id` and `condition_key` |

PHP projects a received signal without an open task as a `signal` wait
("Waiting to apply signal …", `open_wait_id`
`signal-application:<signal>`, resume source `workflow_signal`) with
liveness `repair_needed` and a task problem. The run is described as it
was before the task was lost.
- **Native.**
- `task_repair::create_missing_task` follows each branch, and the command
and the pass share it. It no longer refuses any case, so the
`development_workflow_task_repair_unqualified` refusal is gone.
- An accepted update's open wait, and a pending timeout's wait, are moved
onto the new task, as PHP's projection reads them.
- The pending timeout's timer task carries the wait's fields as native
schedules them, so a wait opened with a condition occurrence id keeps
it when the timer fires. PHP's repaired timer task omits that id; PHP
reads it from the wait instead.
- `active_summary` projects the received-signal case as PHP does. Native
still leaves it unprojected beside an open activity, an accepted update
or a blocked replay, where PHP's other classifiers take precedence.
- Native's own accepted-update task carries only
`{resume_source_kind, workflow_update_id, workflow_command_id}`; the
repaired task carries `forUpdate`'s full payload, as PHP's does.
- **No shared fixture.** A run without its task is reached only by editing
the server's database, as for the October 30 cases. The seven cases were
run against both servers on SQLite with an exploration script; the
created tasks' types and payload fields, and the descriptions afterwards,
match.
- **Tests.** `repair_creates_a_missing_signal_update_child_or_timeout_task_like_php`
in `workflow_repair.rs`, on SQLite, PostgreSQL and MySQL.
29 changes: 18 additions & 11 deletions docs/rust-server-port.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,8 @@ any other unknown path or method answers PHP's own 404 or 405
`prometheus-metrics` fixture, with PHP's default limit of 100 series per
family. External payload cleanup and its pass are served with the
`payload-cleanup` fixture. The task repair pass is served with the
`repair-pass` fixture; a recovery native does not perform yet is
reported as its candidate's failure, with 207.
`repair-pass` fixture; a candidate whose recovery fails is reported as
a failure, with 207.
- **Worker protocol features.**
- Prepared local activities (prepare, checkpoint, group checkpoint,
recover, heartbeat, control, outcome and cancellation acknowledgement)
Expand Down Expand Up @@ -313,22 +313,29 @@ contract fixture before its correction.
is a contract decision, recorded in
[Server #690](https://github.com/durable-workflow/server/issues/690)
(`tests/execution/update_routing.rs`).
- **Repair of a run without a recoverable task** (October 30, mostly
closed). Published PHP's repair command creates a run's missing task and
settles an expired activity-task lease through the activity's retry
policy. Native's repair command and repair pass now do too:
- **Repair of a run without a recoverable task** (October 30 and
November 1, closed). Published PHP's repair command creates a run's
missing task and settles an expired activity-task lease through the
activity's retry policy. Native's repair command and repair pass now do
too:
- a missing workflow task is recreated;
- a missing activity task is recreated for the pending execution, with
its latest retry's fields;
- a missing timer task is recreated, available at the fire time;
- an expired activity lease is settled by the activity's retry policy.
- an expired activity lease is settled by the activity's retry policy;
- a received signal, an accepted update or a resolved child gets the
workflow task that resumes the run from it;
- a condition or signal wait's pending timeout gets its timer task
again, available at the deadline, and a fired timeout gets the
workflow task that applies it.

Native's scheduler usually settles such a lease first, as PHP's repair
loop does
([checkpoint](rust-server-port-checkpoints/2026-10-30-repair-missing-tasks.md)).
Still refused with `development_workflow_task_repair_unqualified`: the
missing task of an update, signal or child wait, or of a condition or
signal wait's timeout timer.
([checkpoint](rust-server-port-checkpoints/2026-10-30-repair-missing-tasks.md),
[wait tasks](rust-server-port-checkpoints/2026-11-01-repair-missing-wait-tasks.md)).
A run with a received signal and no open task is now projected as PHP
projects it: waiting to apply the signal, and needing repair. Native no
longer refuses a repair with `development_workflow_task_repair_unqualified`.
- **Structural limits.** For external workers, published PHP enforces four
of the Workflow package's `DW_V2_LIMIT_*` limits: the memo size, the
command batch size as a bound on parallel group metadata, the pending
Expand Down
38 changes: 34 additions & 4 deletions rust/src/runtime/active_summary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,40 @@ where
summary.repair_blocked = Some("unsupported_history");
summary.problem = true;
}
// A received signal without a task has its own application-repair
// classifier. Do not mislabel it as an ordinary external wait.
if summary.task_id.is_none() && Self::scalar("SELECT COUNT(*) FROM workflow_signal_records WHERE workflow_run_id=$1 AND status='received'")
.bind(&run_id).fetch_one(&mut **tx).await? > 0 { return Ok(None); }
// `openSignalApplicationWait`: a received signal without an open task
// waits to be applied, and needs repair. It describes the run ahead of
// timers, conditions, children and signal waits.
if summary.task_id.is_none()
&& let Some(signal) = Self::query("SELECT id,signal_name,received_at FROM workflow_signal_records WHERE workflow_run_id=$1 AND status='received' ORDER BY CASE WHEN command_sequence IS NULL THEN 1 ELSE 0 END,command_sequence,CASE WHEN received_at IS NULL THEN 1 ELSE 0 END,received_at,id LIMIT 1")
.bind(&run_id).fetch_optional(&mut **tx).await?
{
// An open activity or accepted update takes precedence in PHP;
// native leaves those combinations, and a blocked replay, unprojected.
if summary.state == "workflow_replay_blocked"
|| Self::scalar("SELECT COUNT(*) FROM activity_executions WHERE workflow_run_id=$1 AND status IN ('pending','running')")
.bind(&run_id).fetch_one(&mut **tx).await? > 0
|| Self::scalar("SELECT COUNT(*) FROM workflow_updates WHERE workflow_run_id=$1 AND status='accepted'")
.bind(&run_id).fetch_one(&mut **tx).await? > 0
{
return Ok(None);
}
let id = DB::string(&signal, "id")?;
let name = DB::optional_string(&signal, "signal_name")?.filter(|name| !name.is_empty()).unwrap_or_else(|| "signal".into());
summary.state = "repair_needed".into();
summary.reason = format!("Accepted signal {name} is received without an open workflow task.");
summary.repair_blocked = None;
summary.problem = true;
summary.wait = Some(SummaryWait {
kind: "signal",
id: format!("signal-application:{id}"),
reason: format!("Waiting to apply signal {name}"),
started_at: DB::optional_instant(&signal, "received_at")?,
deadline_at: None,
source_kind: "workflow_signal",
source_id: Some(id),
});
return Ok(Some(summary));
}
if !Self::apply_external_wait_summary(tx, &run_id, external_waits, &mut summary).await? {
return Ok(None);
}
Expand Down
20 changes: 5 additions & 15 deletions rust/src/runtime/repair_pass.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,9 @@
//! as `ActivityTimeoutEnforcer::enforceLeaseExpiry` settles it.
//!
//! A missing task is created as `TaskRepair::createMissingTask` creates it
//! (`task_repair::create_missing_task`). A recovery native does not perform
//! yet is reported as its candidate's failure, so the pass answers 207 rather
//! than claiming a repair: a missing task for a run waiting on an update, a
//! signal or a child, or on a condition or signal wait's timeout timer.
//! (`task_repair::create_missing_task`). A candidate whose recovery fails is
//! reported as a failure, so the pass answers 207 rather than claiming a
//! repair.
use super::Result;
use super::laravel_validation::{Rule, failure_messages, rule_errors};
use super::operator_metrics::{REDISPATCH_AFTER_SECONDS, scope_rows, whole_second};
Expand Down Expand Up @@ -321,8 +320,6 @@ pub(super) enum Recovered {
Task,
/// The candidate needed nothing, or a closed run's task was settled.
Nothing,
/// Native cannot perform this recovery yet; nothing was changed.
Unported(String),
/// A running activity lost its lease: its retry policy settles it.
ExpireLease,
}
Expand Down Expand Up @@ -483,7 +480,6 @@ store_methods! {
continue;
}
Ok(Recovered::Nothing | Recovered::ExpireLease) => continue,
Ok(Recovered::Unported(message)) => message,
Err(error) => error.to_string(),
};
self.record_task_error(task_id, &message).await;
Expand All @@ -497,7 +493,6 @@ store_methods! {
continue;
}
Ok(Recovered::Nothing | Recovered::ExpireLease) => continue,
Ok(Recovered::Unported(message)) => message,
Err(error) => error.to_string(),
};
record_failure(&mut report, "missing_run_failures", "run_id", run_id, message);
Expand Down Expand Up @@ -585,7 +580,6 @@ store_methods! {
}
let recovered = Self::recover_task_row(&mut tx, &run, &task, at).await?;
match recovered {
Recovered::Unported(_) => return Ok(recovered),
Recovered::ExpireLease => {
drop(tx);
return self.settle_lease(task_id).await;
Expand Down Expand Up @@ -724,7 +718,7 @@ store_methods! {
return Ok(Recovered::Nothing);
};
Self::project_run_summary(&mut tx, run_id).await?;
let Some(summary) = Self::query("SELECT liveness_state,next_task_id,wait_kind,resume_source_kind,resume_source_id FROM workflow_run_summaries WHERE id=$1")
let Some(summary) = Self::query("SELECT liveness_state,next_task_id,wait_kind,open_wait_id,resume_source_kind,resume_source_id FROM workflow_run_summaries WHERE id=$1")
.bind(run_id)
.fetch_optional(&mut *tx)
.await?
Expand Down Expand Up @@ -757,13 +751,9 @@ store_methods! {
let task_id = DB::string(&task, "id")?;
(Self::recover_task_row(&mut tx, &run, &task, at).await?, task_id)
}
None => match Self::create_missing_task(&mut tx, &run, &summary).await? {
super::task_repair::Missing::Created(task_id) => (Recovered::Task, task_id),
super::task_repair::Missing::Unported(message) => return Ok(Recovered::Unported(message)),
},
None => (Recovered::Task, Self::create_missing_task(&mut tx, &run, &summary).await?),
};
match recovered {
Recovered::Unported(_) => return Ok(recovered),
Recovered::ExpireLease => {
drop(tx);
return self.settle_lease(&task_id).await;
Expand Down
Loading
Loading