diff --git a/docs/rust-server-port-checkpoints/2026-10-29-child-descriptions.md b/docs/rust-server-port-checkpoints/2026-10-29-child-descriptions.md index c8ad1a56b..5693f463d 100644 --- a/docs/rust-server-port-checkpoints/2026-10-29-child-descriptions.md +++ b/docs/rust-server-port-checkpoints/2026-10-29-child-descriptions.md @@ -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. diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index fc033800f..63cd604eb 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -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 diff --git a/rust/src/runtime/child_summary.rs b/rust/src/runtime/child_summary.rs index 4fca09954..284e092d3 100644 --- a/rust/src/runtime/child_summary.rs +++ b/rust/src/runtime/child_summary.rs @@ -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); } diff --git a/rust/tests/execution/child_descriptions.rs b/rust/tests/execution/child_descriptions.rs index 10cb8e39a..0979e22d7 100644 --- a/rust/tests/execution/child_descriptions.rs +++ b/rust/tests/execution/child_descriptions.rs @@ -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; diff --git a/scripts/conformance/server-parity/child-descriptions-probe.php b/scripts/conformance/server-parity/child-descriptions-probe.php index eda1cc58b..969d12fc3 100644 --- a/scripts/conformance/server-parity/child-descriptions-probe.php +++ b/scripts/conformance/server-parity/child-descriptions-probe.php @@ -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'], diff --git a/tests/Fixtures/ServerParity/child-descriptions.json b/tests/Fixtures/ServerParity/child-descriptions.json index 465af5c15..0730d85d3 100644 --- a/tests/Fixtures/ServerParity/child-descriptions.json +++ b/tests/Fixtures/ServerParity/child-descriptions.json @@ -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 λ", @@ -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": { @@ -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" } } diff --git a/tests/Unit/ServerParityChildDescriptionsContractTest.mjs b/tests/Unit/ServerParityChildDescriptionsContractTest.mjs index 56651b0cd..c718e353b 100644 --- a/tests/Unit/ServerParityChildDescriptionsContractTest.mjs +++ b/tests/Unit/ServerParityChildDescriptionsContractTest.mjs @@ -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);