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
13 changes: 13 additions & 0 deletions docs/rust-server-port-checkpoints/2026-10-29-child-descriptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,16 @@
activity, are still described without their wait, pending evidence.
- **Tests.** `child_descriptions.rs`. On the previous source, two children
and a child beside a timer were described with a null wait.

## Follow-up: activities and cancellation policies

- **Contract** (published 2.11.5): an open activity is described ahead of
open children in either order, as a timer is. A child requesting
`cancellation_policy: try_cancel` from a worker that does not declare
cooperative cancellation is refused `409
child_cancellation_policy_not_supported`; native refuses it the same way.
- **Fixture.** `child-descriptions` gains three cases: `cancelling_child`,
`child_then_activity` and `activity_then_child`.
- **Native.** An already chosen activity wait, like a timer wait, stays the
run's wait while every child is open. On the previous source a child
beside an activity was described with a null wait.
8 changes: 5 additions & 3 deletions docs/rust-server-port.md
Original file line number Diff line number Diff line change
Expand Up @@ -595,9 +595,11 @@ contract fixture before its correction.
was described with no wait. The `child-descriptions` fixture records five
cases against published 2.11.5
([checkpoint](rust-server-port-checkpoints/2026-10-29-child-descriptions.md)).
A child with a cancellation policy other than `abandon`, a retry or
timeout policy, or beside an open activity is still described without
its wait, pending its own evidence.
An open activity is likewise described ahead of open children
(October 29). A child with a retry or timeout policy, or a cancellation
policy other than `abandon` under a worker that declares cooperative
cancellation, is still described without its wait, pending its own
evidence.
- **Trace context (Core 2.10, published in Server 2.11.1).** Core adds
nullable `observability_context` columns on runs, commands and tasks.
Native has no carrier: it accepts and ignores a top-level carrier on
Expand Down
11 changes: 6 additions & 5 deletions rust/src/runtime/child_summary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,13 +169,14 @@ where
if rows.is_empty() {
return Ok(true);
}
// An open timer wait describes the run ahead of open children, as
// PHP's run description does. Other wait families, and a resolved
// child beside a timer, retain their separately qualified precedence.
// An open timer or activity wait describes the run ahead of open
// children, as PHP's run description does, whichever was scheduled
// first. Other wait families, and a resolved child beside them,
// retain their separately qualified precedence.
let all_open = rows.iter().all(|row| DB::string(row, "status").is_ok_and(|status| status == "open"));
if summary.wait.as_ref().is_some_and(|wait| wait.kind == "timer")
if let Some(kind) = summary.wait.as_ref().map(|wait| wait.kind).filter(|kind| matches!(*kind, "timer" | "activity"))
&& all_open
&& summary.task_type.as_deref().is_none_or(|kind| kind == "timer")
&& summary.task_type.as_deref().is_none_or(|task| task == kind)
{
return Ok(true);
}
Expand Down
6 changes: 5 additions & 1 deletion rust/tests/execution/child_descriptions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,21 @@ async fn a_parent_is_described_by_its_waits_with_several_children() {
let timer = json!({"type":"start_timer","delay_seconds":3600});
let child_wait = (json!("child"), json!("Waiting for child workflow echo"));
let timer_wait = (json!("timer"), json!("Waiting for timer"));
let activity = json!({"type":"schedule_activity","activity_type":"echo-activity","arguments":envelope(Payload::Array(vec![]))});
let activity_wait = (json!("activity"), json!("Waiting for activity echo-activity"));
for (id, commands, expected) in [
("one-child", json!([child(None)]), child_wait.clone()),
("two-children", json!([child(None), child(None)]), child_wait.clone()),
("child-then-timer", json!([child(None), timer.clone()]), timer_wait.clone()),
("timer-then-child", json!([timer.clone(), child(None)]), timer_wait.clone()),
("terminating-child", json!([child(Some("terminate"))]), child_wait.clone()),
("child-then-activity", json!([child(None), activity.clone()]), activity_wait.clone()),
("activity-then-child", json!([activity.clone(), child(None)]), activity_wait.clone()),
] {
let database = TestDatabase::new().await;
let runtime = database.open().await.unwrap();
let app = router(runtime.clone());
register(&app, "worker", json!(["echo"]), json!([])).await;
register(&app, "worker", json!(["echo"]), json!(["echo-activity"])).await;
start(&app, id).await;
let task = poll(&app, "worker", "workflow").await;
let (status, body) = finish_task(&app, &task, commands).await;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ function finishHttpChildDescriptions(Client $admin, array $fixture, string $work
$worker = $id.'-worker';
$registered = $raw('POST', 'worker/register', ['worker_id' => $worker, 'task_queue' => $queue, 'runtime' => 'php',
'sdk_version' => 'raw-http', 'supported_workflow_types' => [$definition['workflow_type'], $definition['child_type']],
'supported_activity_types' => [],
'supported_activity_types' => [$definition['activity_type']],
'capability_manifest' => array_fill_keys(['local_activities', 'worker_sessions', 'sticky_execution'],
['supported' => false, 'minimum_protocol_version' => '1.18', 'reason' => 'raw_http'])]);
$started = $raw('POST', 'workflows', ['workflow_id' => $id, 'workflow_type' => $definition['workflow_type'],
Expand Down
100 changes: 98 additions & 2 deletions tests/Fixtures/ServerParity/child-descriptions.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"schema": "durable-workflow.server-parity-fixture/v1",
"id": "child-descriptions",
"review_state": "reviewed",
"contract": "A parent run's description while its children are open (published Server 2.11.5 WorkflowController::show with Workflow 2.15.1 RunWaitView), over raw worker HTTP on protocol 1.20 in a fresh namespace. Each case completes the parent's first workflow task with child and timer commands and records the description. A parent with two open children is described as waiting for a child; with an open child and an open timer, in either order, as waiting for the timer; and a child under parent_close_policy terminate is described like any other. Embedded applications have no worker protocol.",
"contract": "A parent run's description while its children are open (published Server 2.11.5 WorkflowController::show with Workflow 2.15.1 RunWaitView), over raw worker HTTP on protocol 1.20 in a fresh namespace. Each case completes the parent's first workflow task with child and timer commands and records the description. A parent with two open children is described as waiting for a child; with an open child and an open timer, in either order, as waiting for the timer; and a child under parent_close_policy terminate is described like any other. An open activity is likewise described ahead of open children, in either order. A child requesting cancellation_policy try_cancel from a worker that does not declare cooperative cancellation is refused 409 child_cancellation_policy_not_supported. Embedded applications have no worker protocol.",
"workflow_type": "parity.v1.echo",
"input": {
"message": "continue transfers λ",
Expand Down Expand Up @@ -112,6 +112,53 @@
},
"parent_close_policy": "terminate"
}
],
"cancelling_child": [
{
"type": "start_child_workflow",
"workflow_type": "parity.v1.child_descriptions_child",
"arguments": {
"codec": "avro",
"blob": "wwHioz3/VYAiNwwA"
},
"cancellation_policy": "try_cancel"
}
],
"child_then_activity": [
{
"type": "start_child_workflow",
"workflow_type": "parity.v1.child_descriptions_child",
"arguments": {
"codec": "avro",
"blob": "wwHioz3/VYAiNwwA"
}
},
{
"type": "schedule_activity",
"activity_type": "parity.v1.child_descriptions_activity",
"arguments": {
"codec": "avro",
"blob": "wwHioz3/VYAiNwwA"
}
}
],
"activity_then_child": [
{
"type": "schedule_activity",
"activity_type": "parity.v1.child_descriptions_activity",
"arguments": {
"codec": "avro",
"blob": "wwHioz3/VYAiNwwA"
}
},
{
"type": "start_child_workflow",
"workflow_type": "parity.v1.child_descriptions_child",
"arguments": {
"codec": "avro",
"blob": "wwHioz3/VYAiNwwA"
}
}
]
},
"expected": {
Expand Down Expand Up @@ -194,7 +241,56 @@
"wait_kind": "child",
"wait_reason": "Waiting for child workflow parity.v1.child_descriptions_child"
}
},
"cancelling_child": {
"completed": {
"status": 409,
"reason": "child_cancellation_policy_not_supported",
"run_status": null
},
"described": {
"status": "pending",
"status_bucket": "running",
"is_terminal": false,
"resume_step_sequence": null,
"recovery_kind": null,
"wait_kind": "workflow-task",
"wait_reason": "Workflow task leased to worker"
}
},
"child_then_activity": {
"completed": {
"status": 200,
"reason": null,
"run_status": "waiting"
},
"described": {
"status": "waiting",
"status_bucket": "running",
"is_terminal": false,
"resume_step_sequence": null,
"recovery_kind": null,
"wait_kind": "activity",
"wait_reason": "Waiting for activity parity.v1.child_descriptions_activity"
}
},
"activity_then_child": {
"completed": {
"status": 200,
"reason": null,
"run_status": "waiting"
},
"described": {
"status": "waiting",
"status_bucket": "running",
"is_terminal": false,
"resume_step_sequence": null,
"recovery_kind": null,
"wait_kind": "activity",
"wait_reason": "Waiting for activity parity.v1.child_descriptions_activity"
}
}
}
},
"activity_type": "parity.v1.child_descriptions_activity"
}
}
6 changes: 5 additions & 1 deletion tests/Unit/ServerParityChildDescriptionsContractTest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,16 @@ test('embedded applications have no worker protocol', () => {

test('children and timers are described by the published precedence', () => {
assert.deepStrictEqual(Object.fromEntries(Object.entries(expected).map(([name, run]) => [name, run.described.wait_kind])),
{one_child: 'child', two_children: 'child', child_then_timer: 'timer', timer_then_child: 'timer', terminating_child: 'child'});
{one_child: 'child', two_children: 'child', child_then_timer: 'timer', timer_then_child: 'timer', terminating_child: 'child',
cancelling_child: 'workflow-task', child_then_activity: 'activity', activity_then_child: 'activity'});
assert.deepStrictEqual([expected.cancelling_child.completed.status, expected.cancelling_child.completed.reason],
[409, 'child_cancellation_policy_not_supported']);
});

for (const [name, mutate] of [
['two children described without a wait', state => { state.two_children.described.wait_kind = null; }],
['a child ahead of an open timer', state => { state.child_then_timer.described.wait_kind = 'child'; }],
['a child beside an activity described without a wait', state => { state.child_then_activity.described.wait_kind = null; }],
['an unexpected case', state => { state.extra = {}; }],
]) test(`the contract rejects ${name}`, () => {
const raw = observation(); mutate(raw.child_descriptions);
Expand Down
Loading