diff --git a/.github/workflows/server-parity.yml b/.github/workflows/server-parity.yml index 842c1c9c2..f334e226e 100644 --- a/.github/workflows/server-parity.yml +++ b/.github/workflows/server-parity.yml @@ -64,6 +64,7 @@ on: - tests/Unit/ServerParityLargeCommandBatchesContractTest.mjs - tests/Unit/ServerParityManyUpdatesContractTest.mjs - tests/Unit/ServerParityWorkerErrorBodiesContractTest.mjs + - tests/Unit/ServerParityChildDescriptionsContractTest.mjs - tests/Unit/ServerParityStartSearchAttributesContractTest.mjs - tests/Unit/ServerParityQueryArgumentsContractTest.mjs - tests/Unit/ServerParityQueryContractArgumentsContractTest.mjs @@ -1990,7 +1991,7 @@ jobs: bash scripts/ci/run-composer.sh --rm -v "$PWD:/source" -w /source -- \ install --working-dir=scripts/conformance/server-parity --no-interaction --no-progress docker run --rm --user=1000:1000 --entrypoint node -v "$PWD:/source:ro" -w /source \ - "$image" --test tests/Unit/ServerParityRunnerTest.mjs tests/Unit/ServerParityRunnerBlockedExcerptTest.mjs tests/Unit/ServerParityProbeNotesTest.mjs tests/Unit/ServerParitySdkDirectionLanesTest.mjs tests/Unit/ServerParityCodecDeadlineCellsTest.mjs tests/Unit/ServerParityCooperativeContractTest.mjs tests/Unit/ServerParityChildCancellationContractTest.mjs tests/Unit/ServerParityScheduleContractTest.mjs tests/Unit/ServerParityVisibilityContractTest.mjs tests/Unit/ServerParityAdmissionContractTest.mjs tests/Unit/ServerParityRoleTokenContractTest.mjs tests/Unit/ServerParityWorkerDeregistrationContractTest.mjs tests/Unit/ServerParitySearchAttributeContractTest.mjs tests/Unit/ServerParityWorkerListingContractTest.mjs tests/Unit/ServerParityWorkerReplacementContractTest.mjs tests/Unit/ServerParityPatchDeploymentContractTest.mjs tests/Unit/ServerParityWaitingHistoryContractTest.mjs tests/Unit/ServerParityTaskRetryContractTest.mjs tests/Unit/ServerParityPublishedPatchContractTest.mjs tests/Unit/ServerParityPublishedPatchTransportTest.mjs tests/Unit/ServerParityRecordFileTest.mjs tests/Unit/ServerParityFailureLocationTest.mjs tests/Unit/ServerParityFailureExcerptTest.mjs tests/Unit/ServerParityLateWorkflowCompletionContractTest.mjs tests/Unit/ServerParityStorageTestContractTest.mjs tests/Unit/ServerParitySchedulePoliciesContractTest.mjs tests/Unit/ServerParitySignalsBeforeWorkerContractTest.mjs tests/Unit/ServerParityScheduleCatalogContractTest.mjs tests/Unit/ServerParityOperatorMetricsContractTest.mjs tests/Unit/ServerParityActivityLeaseRenewalContractTest.mjs tests/Unit/ServerParityConcurrentWaitsContractTest.mjs tests/Unit/ServerParityContinueTransfersContractTest.mjs tests/Unit/ServerParityWorkerErrorBodiesContractTest.mjs tests/Unit/ServerParityLargeCommandBatchesContractTest.mjs tests/Unit/ServerParityManyUpdatesContractTest.mjs tests/Unit/ServerParityStartSearchAttributesContractTest.mjs tests/Unit/ServerParityQueryArgumentsContractTest.mjs tests/Unit/ServerParityQueryContractArgumentsContractTest.mjs tests/Unit/ServerParityParentCloseCancellationContractTest.mjs tests/Unit/ServerParityRecordLocalActivityContractTest.mjs tests/Unit/ServerParityActivityCancellationAckContractTest.mjs tests/Unit/ServerParityServiceCatalogContractTest.mjs tests/Unit/ServerParityServiceEndpointsContractTest.mjs tests/Unit/ServerParityServiceCallsContractTest.mjs tests/Unit/ServerParityWorkflowImportContractTest.mjs tests/Unit/ServerParityWaterlineImportContractTest.mjs tests/Unit/ServerParitySystemMetricsContractTest.mjs tests/Unit/ServerParityOperatorDashboardTypesContractTest.mjs tests/Unit/ServerParityPrometheusMetricsContractTest.mjs tests/Unit/ServerParityPayloadCleanupContractTest.mjs tests/Unit/ServerParityRepairPassContractTest.mjs tests/Unit/ServerParityClusterInfoContractTest.mjs tests/Unit/ServerParityNamespaceDeleteContractTest.mjs tests/Unit/ServerParityMessageStreamsContractTest.mjs tests/Unit/ServerParityCancellationScopesContractTest.mjs tests/Unit/ServerParityWorkflowPollRequestContractTest.mjs tests/Unit/ServerParityUpdateValidationContractTest.mjs tests/Unit/ServerParityFixtureSelectionTest.mjs tests/Unit/ServerParityUpdateValidationRetryTest.mjs tests/Unit/ServerParityKilledAuthorPollTest.mjs tests/Unit/ServerParityImagePullTest.mjs tests/Unit/ServerParityAdapterCacheTest.mjs tests/Unit/ServerParityWorkerSessionsContractTest.mjs tests/Unit/ServerParityWorkerSessionActivitiesContractTest.mjs tests/Unit/ServerParityNexusOperationsContractTest.mjs tests/Unit/ServerParityBridgeAdaptersContractTest.mjs tests/Unit/ServerParityWorkflowStreamWritesContractTest.mjs > parity-evidence/comparator-tests.txt + "$image" --test tests/Unit/ServerParityRunnerTest.mjs tests/Unit/ServerParityRunnerBlockedExcerptTest.mjs tests/Unit/ServerParityProbeNotesTest.mjs tests/Unit/ServerParitySdkDirectionLanesTest.mjs tests/Unit/ServerParityCodecDeadlineCellsTest.mjs tests/Unit/ServerParityCooperativeContractTest.mjs tests/Unit/ServerParityChildCancellationContractTest.mjs tests/Unit/ServerParityScheduleContractTest.mjs tests/Unit/ServerParityVisibilityContractTest.mjs tests/Unit/ServerParityAdmissionContractTest.mjs tests/Unit/ServerParityRoleTokenContractTest.mjs tests/Unit/ServerParityWorkerDeregistrationContractTest.mjs tests/Unit/ServerParitySearchAttributeContractTest.mjs tests/Unit/ServerParityWorkerListingContractTest.mjs tests/Unit/ServerParityWorkerReplacementContractTest.mjs tests/Unit/ServerParityPatchDeploymentContractTest.mjs tests/Unit/ServerParityWaitingHistoryContractTest.mjs tests/Unit/ServerParityTaskRetryContractTest.mjs tests/Unit/ServerParityPublishedPatchContractTest.mjs tests/Unit/ServerParityPublishedPatchTransportTest.mjs tests/Unit/ServerParityRecordFileTest.mjs tests/Unit/ServerParityFailureLocationTest.mjs tests/Unit/ServerParityFailureExcerptTest.mjs tests/Unit/ServerParityLateWorkflowCompletionContractTest.mjs tests/Unit/ServerParityStorageTestContractTest.mjs tests/Unit/ServerParitySchedulePoliciesContractTest.mjs tests/Unit/ServerParitySignalsBeforeWorkerContractTest.mjs tests/Unit/ServerParityScheduleCatalogContractTest.mjs tests/Unit/ServerParityOperatorMetricsContractTest.mjs tests/Unit/ServerParityActivityLeaseRenewalContractTest.mjs tests/Unit/ServerParityConcurrentWaitsContractTest.mjs tests/Unit/ServerParityContinueTransfersContractTest.mjs tests/Unit/ServerParityChildDescriptionsContractTest.mjs tests/Unit/ServerParityWorkerErrorBodiesContractTest.mjs tests/Unit/ServerParityLargeCommandBatchesContractTest.mjs tests/Unit/ServerParityManyUpdatesContractTest.mjs tests/Unit/ServerParityStartSearchAttributesContractTest.mjs tests/Unit/ServerParityQueryArgumentsContractTest.mjs tests/Unit/ServerParityQueryContractArgumentsContractTest.mjs tests/Unit/ServerParityParentCloseCancellationContractTest.mjs tests/Unit/ServerParityRecordLocalActivityContractTest.mjs tests/Unit/ServerParityActivityCancellationAckContractTest.mjs tests/Unit/ServerParityServiceCatalogContractTest.mjs tests/Unit/ServerParityServiceEndpointsContractTest.mjs tests/Unit/ServerParityServiceCallsContractTest.mjs tests/Unit/ServerParityWorkflowImportContractTest.mjs tests/Unit/ServerParityWaterlineImportContractTest.mjs tests/Unit/ServerParitySystemMetricsContractTest.mjs tests/Unit/ServerParityOperatorDashboardTypesContractTest.mjs tests/Unit/ServerParityPrometheusMetricsContractTest.mjs tests/Unit/ServerParityPayloadCleanupContractTest.mjs tests/Unit/ServerParityRepairPassContractTest.mjs tests/Unit/ServerParityClusterInfoContractTest.mjs tests/Unit/ServerParityNamespaceDeleteContractTest.mjs tests/Unit/ServerParityMessageStreamsContractTest.mjs tests/Unit/ServerParityCancellationScopesContractTest.mjs tests/Unit/ServerParityWorkflowPollRequestContractTest.mjs tests/Unit/ServerParityUpdateValidationContractTest.mjs tests/Unit/ServerParityFixtureSelectionTest.mjs tests/Unit/ServerParityUpdateValidationRetryTest.mjs tests/Unit/ServerParityKilledAuthorPollTest.mjs tests/Unit/ServerParityImagePullTest.mjs tests/Unit/ServerParityAdapterCacheTest.mjs tests/Unit/ServerParityWorkerSessionsContractTest.mjs tests/Unit/ServerParityWorkerSessionActivitiesContractTest.mjs tests/Unit/ServerParityNexusOperationsContractTest.mjs tests/Unit/ServerParityBridgeAdaptersContractTest.mjs tests/Unit/ServerParityWorkflowStreamWritesContractTest.mjs > parity-evidence/comparator-tests.txt docker run --rm --user=1000:1000 --cpus=1 --memory=256m --memory-swap=256m \ --network=none --entrypoint node -v "$PWD:/source:ro" -w /source \ "$image" --test tests/Unit/ServerParityCodecDeadlineLoadTest.mjs > parity-evidence/codec-load-tests.txt 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 new file mode 100644 index 000000000..c8ad1a56b --- /dev/null +++ b/docs/rust-server-port-checkpoints/2026-10-29-child-descriptions.md @@ -0,0 +1,30 @@ +# October 29, descriptions with several open children + +- **Before.** Native's child summary (`child_summary.rs`), which describes a + parent waiting on children, gave up when: + - a second child was open while another was still waiting; + - a child's `parent_close_policy` was not `abandon`; + - more than 100 children had been started over the run's life; + - a timer wait was also open. + + The parent was then described with no wait (`wait_kind` null). +- **Contract** (published 2.11.5; `WorkflowController::show`, Workflow + 2.15.1 `RunWaitView`): a parent is described by its open waits. With + several open children it waits for the first. An open timer is described + ahead of open children, whichever was scheduled first. The parent-close + policy does not change the description. +- **Fixture.** `child-descriptions`, raw worker HTTP on protocol 1.20 in a + fresh namespace. Each case completes the parent's first workflow task with + its commands and records the answer and the description: one child, two + children, a child then a timer, a timer then a child, and a child under + `parent_close_policy: terminate`. +- **Native.** + - The summary keeps any number of open children, with no lifetime cap. + - Any parent-close policy is allowed. + - When a timer wait is already chosen and every child is still open, the + timer stays the run's wait. +- **Not covered here.** A child with a cancellation policy other than + `abandon` or with a retry or timeout policy, and a child beside an open + 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. diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index c1bf223ee..fc033800f 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -586,8 +586,18 @@ contract fixture before its correction. `many-updates` fixture records a run waiting for a signal through 65 completed updates, then a 66th left accepted, against published 2.11.5 ([checkpoint](rust-server-port-checkpoints/2026-10-29-many-updates.md)). - The child summary still stops at 100 children over a run's life; that - needs its own fixture. +- **Descriptions with several open children** (October 29, closed). + Published PHP describes a parent by its open waits however many children + it holds: the first open child, or an open timer ahead of open children, + under any parent-close policy. Native's child summary allowed one waiting + child at a time, only the `abandon` parent-close policy, at most 100 + children over a run's life, and no timer beside a child. Such a parent + 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. - **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 c4bd8999a..4fca09954 100644 --- a/rust/src/runtime/child_summary.rs +++ b/rust/src/runtime/child_summary.rs @@ -1,4 +1,5 @@ -//! Sequential child liveness requires original typed identity and retained waits. +//! Child liveness requires original typed identity and retained waits. A run +//! may hold any number of open children, each under any parent-close policy. use super::{ Result, active_summary::{ActiveSummary, SummaryWait}, @@ -33,13 +34,10 @@ impl ChildSummaryHistory { return false; }; if event == "ChildWorkflowScheduled" { - if self.calls.len() >= 100 - || self.calls.contains_key(&sequence) - || self - .calls - .values() - .any(|child| child.status == "waiting" || child.id == identity.id) - || payload["parent_close_policy"] != "abandon" + // Any number of children may be open at once, and the parent-close + // policy only decides what happens when the parent closes. + if self.calls.contains_key(&sequence) + || self.calls.values().any(|child| child.id == identity.id) || payload["cancellation_policy"] != "abandon" || !payload["retry_policy"].is_null() || !payload["timeout_policy"].is_null() @@ -152,9 +150,9 @@ where history: &ChildSummaryHistory, summary: &mut ActiveSummary, ) -> Result { - let rows = Self::query("SELECT w.*,c.status AS call_status,c.resolved_child_run_id,c.resolved_child_instance_id,c.child_workflow_type,c.metadata AS call_metadata,l.id AS link_id,l.child_workflow_run_id AS link_run,l.child_workflow_instance_id AS link_instance FROM workflow_run_waits w LEFT JOIN workflow_child_calls c ON c.parent_workflow_run_id=w.workflow_run_id AND c.sequence=w.sequence LEFT JOIN workflow_links l ON l.parent_workflow_run_id=w.workflow_run_id AND l.sequence=w.sequence AND l.link_type='child_workflow' WHERE w.workflow_run_id=$1 AND w.kind='child' ORDER BY w.sequence LIMIT 101") + let rows = Self::query("SELECT w.*,c.status AS call_status,c.resolved_child_run_id,c.resolved_child_instance_id,c.child_workflow_type,c.metadata AS call_metadata,l.id AS link_id,l.child_workflow_run_id AS link_run,l.child_workflow_instance_id AS link_instance FROM workflow_run_waits w LEFT JOIN workflow_child_calls c ON c.parent_workflow_run_id=w.workflow_run_id AND c.sequence=w.sequence LEFT JOIN workflow_links l ON l.parent_workflow_run_id=w.workflow_run_id AND l.sequence=w.sequence AND l.link_type='child_workflow' WHERE w.workflow_run_id=$1 AND w.kind='child' ORDER BY w.sequence") .bind(run_id).fetch_all(&mut **tx).await?; - if rows.len() != history.calls.len() || rows.len() > 100 { + if rows.len() != history.calls.len() { return Ok(false); } for row in &rows { @@ -171,7 +169,16 @@ where if rows.is_empty() { return Ok(true); } - // Other wait families retain their separately qualified precedence. + // 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. + 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") + && all_open + && summary.task_type.as_deref().is_none_or(|kind| kind == "timer") + { + return Ok(true); + } if summary .wait .as_ref() diff --git a/rust/tests/execution.rs b/rust/tests/execution.rs index 5f15f7c8b..db2b7a7f8 100644 --- a/rust/tests/execution.rs +++ b/rust/tests/execution.rs @@ -352,6 +352,8 @@ mod close_inventory; mod start_search_attributes; #[path = "execution/worker_error_bodies.rs"] mod worker_error_bodies; +#[path = "execution/child_descriptions.rs"] +mod child_descriptions; enum TestDatabase { Sqlite(tempfile::TempDir), diff --git a/rust/tests/execution/child_descriptions.rs b/rust/tests/execution/child_descriptions.rs new file mode 100644 index 000000000..10cb8e39a --- /dev/null +++ b/rust/tests/execution/child_descriptions.rs @@ -0,0 +1,42 @@ +use super::*; + +// Published Server 2.11.5 describes a parent by its open waits however many +// children it holds: the first open child, or an open timer ahead of open +// children, under any parent-close policy. The child-descriptions fixture +// records the same cases. + +fn child(policy: Option<&str>) -> Value { + let mut command = json!({"type":"start_child_workflow","workflow_type":"echo","arguments":envelope(Payload::Array(vec![]))}); + if let Some(policy) = policy { + command["parent_close_policy"] = json!(policy); + } + command +} + +#[tokio::test] +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")); + 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()), + ] { + let database = TestDatabase::new().await; + let runtime = database.open().await.unwrap(); + let app = router(runtime.clone()); + register(&app, "worker", json!(["echo"]), json!([])).await; + start(&app, id).await; + let task = poll(&app, "worker", "workflow").await; + let (status, body) = finish_task(&app, &task, commands).await; + assert_eq!(status, StatusCode::OK, "{id}: {body}"); + let (_, described) = request(&app, "GET", &format!("/api/workflows/{id}"), Value::Null).await; + assert_eq!(described["status"], "waiting", "{id}: {described}"); + assert_eq!((described["wait_kind"].clone(), described["wait_reason"].clone()), expected, "{id}: {described}"); + runtime.close().await; + database.remove().await; + } +} diff --git a/scripts/conformance/server-parity/child-descriptions-contract.mjs b/scripts/conformance/server-parity/child-descriptions-contract.mjs new file mode 100644 index 000000000..fb537c566 --- /dev/null +++ b/scripts/conformance/server-parity/child-descriptions-contract.mjs @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; + +// Reviewed parent descriptions while children are open +// (child-descriptions-probe.php), recorded against published Server 2.11.5. +// The fixture's `expected` holds the whole observation. +export function checkChildDescriptions(fixture, observation) { + const definition = fixture.child_descriptions; + const state = observation.child_descriptions; + const projection = {scope: 'child_descriptions', cases: Object.keys(definition.cases).length}; + assert.ok(state && typeof state === 'object', 'actual child descriptions observation'); + if (observation.mode === 'embedded') { + assert.deepStrictEqual(state, {applicable: false, reason: 'embedded_has_no_worker_protocol'}, + 'embedded child descriptions explicitly inapplicable'); + return projection; + } + assert.equal(state.applicable, true, 'HTTP targets record child descriptions'); + for (const [run, expected] of Object.entries(definition.expected)) { + assert.deepStrictEqual(state[run], expected, `child descriptions ${run}`); + } + assert.deepStrictEqual(Object.keys(state).sort(), ['applicable', ...Object.keys(definition.expected)].sort(), + 'child descriptions cases'); + return projection; +} diff --git a/scripts/conformance/server-parity/child-descriptions-probe.php b/scripts/conformance/server-parity/child-descriptions-probe.php new file mode 100644 index 000000000..eda1cc58b --- /dev/null +++ b/scripts/conformance/server-parity/child-descriptions-probe.php @@ -0,0 +1,83 @@ +createNamespace($namespace, 'child descriptions λ', 30); + $token = getenv('DW_PARITY_TOKEN') ?: null; + $stack = HandlerStack::create(); + $stack->push(LockPressurePollRetries::middleware(), 'lock_pressure_poll_retry'); + $http = new ChildDescriptionsHttp(['base_uri' => rtrim($url, '/').'/api/', 'http_errors' => false, + 'connect_timeout' => 1, 'timeout' => 30, 'handler' => $stack]); + $headers = array_filter([ + 'Authorization' => $token === null ? null : 'Bearer '.$token, + 'Accept' => 'application/json', 'X-Namespace' => $namespace, + 'X-Durable-Workflow-Control-Plane-Version' => '2', 'X-Durable-Workflow-Protocol-Version' => '1.20', + ]); + $raw = static function (string $method, string $path, ?array $body = null) use ($http, $headers): array { + $response = $http->request($method, $path, ['headers' => $headers, + ...($body === null ? [] : ['json' => $body === [] ? (object) [] : $body])]); + + return ['status' => $response->getStatusCode(), + 'response' => json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR)]; + }; + $empty = ['codec' => 'avro', 'blob' => $definition['empty_blob']]; + $state = ['applicable' => true]; + foreach ($definition['cases'] as $case => $commands) { + $id = $workflowId.'-cd-'.$case; + $queue = $id.'-queue'; + $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' => [], + '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'], + 'task_queue' => $queue, 'input' => $empty]); + if ($registered['status'] !== 201 || $started['status'] !== 201) { + throw new RuntimeException('Child descriptions setup: '.json_encode([$registered, $started])); + } + $task = $raw('POST', 'worker/workflow-tasks/poll', ['worker_id' => $worker, 'task_queue' => $queue, + 'timeout_seconds' => 5])['response']['task'] ?? null; + if (! is_array($task)) { + throw new RuntimeException('Child descriptions: no workflow task for '.$case); + } + $completed = $raw('POST', 'worker/workflow-tasks/'.$task['task_id'].'/complete', ['lease_owner' => $task['lease_owner'], + 'workflow_task_attempt' => $task['workflow_task_attempt'], 'commands' => $commands]); + $described = $raw('GET', 'workflows/'.rawurlencode($id))['response']; + $state[$case] = [ + 'completed' => ['status' => $completed['status'], 'reason' => $completed['response']['reason'] ?? null, + 'run_status' => $completed['response']['run_status'] ?? null], + 'described' => array_intersect_key($described, array_flip($definition['described_fields'])), + ]; + // End the children and then the parent, so their never-polled tasks + // do not stay for the maintenance passes of later fixtures. + $events = $raw('GET', 'workflows/'.rawurlencode($id).'/runs/'.$started['response']['run_id'].'/history')['response']['events'] ?? []; + $ended = []; + foreach ($events as $event) { + if ($event['event_type'] === 'ChildWorkflowScheduled' && is_string($event['payload']['child_workflow_instance_id'] ?? null)) { + $ended[] = $event['payload']['child_workflow_instance_id']; + } + } + foreach ([...$ended, $id] as $instance) { + $terminated = $raw('POST', 'workflows/'.rawurlencode($instance).'/terminate', ['reason' => 'child descriptions recorded']); + if (! in_array($terminated['status'], [200, 202], true)) { + throw new RuntimeException('Child descriptions cleanup: '.json_encode($terminated)); + } + } + } + + return $state; +} diff --git a/scripts/conformance/server-parity/contract.mjs b/scripts/conformance/server-parity/contract.mjs index ac81f43c7..c43cb3587 100644 --- a/scripts/conformance/server-parity/contract.mjs +++ b/scripts/conformance/server-parity/contract.mjs @@ -18,6 +18,7 @@ import {checkContinueTransfers} from './continue-transfers-contract.mjs'; import {checkLargeCommandBatches} from './large-command-batches-contract.mjs'; import {checkManyUpdates} from './many-updates-contract.mjs'; import {checkWorkerErrorBodies} from './worker-error-bodies-contract.mjs'; +import {checkChildDescriptions} from './child-descriptions-contract.mjs'; import {checkStartSearchAttributes} from './start-search-attributes-contract.mjs'; import {checkQueryArgumentRouting} from './query-arguments-contract.mjs'; import {checkQueryContractArguments} from './query-contract-arguments-contract.mjs'; @@ -181,6 +182,7 @@ export function checkObservation(fixture, observation, workflowId, artifacts) { const largeCommandBatches = fixture.large_command_batches ? checkLargeCommandBatches(fixture, observation) : null; const manyUpdates = fixture.many_updates ? checkManyUpdates(fixture, observation) : null; const workerErrorBodies = fixture.worker_error_bodies ? checkWorkerErrorBodies(fixture, observation) : null; + const childDescriptions = fixture.child_descriptions ? checkChildDescriptions(fixture, observation) : null; const startSearchAttributes = fixture.start_search_attributes ? checkStartSearchAttributes(fixture, observation) : null; const queryArguments = fixture.query_argument_routing ? checkQueryArgumentRouting(fixture, observation) : null; const queryContractArguments = fixture.query_contract_arguments ? checkQueryContractArguments(fixture, observation) : null; @@ -762,6 +764,7 @@ export function checkObservation(fixture, observation, workflowId, artifacts) { ...(largeCommandBatches ? {large_command_batches: largeCommandBatches} : {}), ...(manyUpdates ? {many_updates: manyUpdates} : {}), ...(workerErrorBodies ? {worker_error_bodies: workerErrorBodies} : {}), + ...(childDescriptions ? {child_descriptions: childDescriptions} : {}), ...(startSearchAttributes ? {start_search_attributes: startSearchAttributes} : {}), ...(queryArguments ? {query_argument_routing: queryArguments} : {}), ...(queryContractArguments ? {query_contract_arguments: queryContractArguments} : {}), diff --git a/scripts/conformance/server-parity/probe.php b/scripts/conformance/server-parity/probe.php index 0790284be..1d2df32a6 100644 --- a/scripts/conformance/server-parity/probe.php +++ b/scripts/conformance/server-parity/probe.php @@ -436,6 +436,7 @@ function httpObservation(array $fixture, string $workflowId, string $namespace, $largeCommandBatches = isset($fixture['large_command_batches']) ? finishHttpLargeCommandBatches($client, $fixture, $workflowId, $url) : null; $manyUpdates = isset($fixture['many_updates']) ? finishHttpManyUpdates($client, $fixture, $workflowId, $url) : null; $workerErrorBodies = isset($fixture['worker_error_bodies']) ? finishHttpWorkerErrorBodies($client, $fixture, $workflowId, $url) : null; + $childDescriptions = isset($fixture['child_descriptions']) ? finishHttpChildDescriptions($client, $fixture, $workflowId, $url) : null; $startSearchAttributes = isset($fixture['start_search_attributes']) ? finishHttpStartSearchAttributes($client, $fixture, $workflowId, $url) : null; $queryArgumentRouting = isset($fixture['query_argument_routing']) ? finishHttpQueryArgumentRouting($client, $fixture, $workflowId, $url) : null; $queryContractArguments = isset($fixture['query_contract_arguments']) ? finishHttpQueryContractArguments($client, $fixture, $workflowId, $url) : null; @@ -570,6 +571,7 @@ function httpObservation(array $fixture, string $workflowId, string $namespace, 'large_command_batches' => $largeCommandBatches, 'many_updates' => $manyUpdates, 'worker_error_bodies' => $workerErrorBodies, + 'child_descriptions' => $childDescriptions, 'start_search_attributes' => $startSearchAttributes, 'query_argument_routing' => $queryArgumentRouting, 'query_contract_arguments' => $queryContractArguments, @@ -907,6 +909,7 @@ function embeddedObservation(array $fixture, string $workflowId, string $namespa require __DIR__.'/large-command-batches-probe.php'; require __DIR__.'/many-updates-probe.php'; require __DIR__.'/worker-error-bodies-probe.php'; + require __DIR__.'/child-descriptions-probe.php'; require __DIR__.'/start-search-attributes-probe.php'; require __DIR__.'/query-arguments-probe.php'; require __DIR__.'/query-contract-arguments-probe.php'; @@ -1108,6 +1111,9 @@ function embeddedObservation(array $fixture, string $workflowId, string $namespa if ($mode === 'embedded' && isset($fixture['worker_error_bodies'])) { $observation['worker_error_bodies'] = ['applicable' => false, 'reason' => 'embedded_has_no_worker_protocol']; } + if ($mode === 'embedded' && isset($fixture['child_descriptions'])) { + $observation['child_descriptions'] = ['applicable' => false, 'reason' => 'embedded_has_no_worker_protocol']; + } if ($mode === 'embedded' && isset($fixture['start_search_attributes'])) { $observation['start_search_attributes'] = ['applicable' => false, 'reason' => 'embedded_has_no_http_search_attribute_registry']; } diff --git a/tests/Fixtures/ServerParity/child-descriptions.json b/tests/Fixtures/ServerParity/child-descriptions.json new file mode 100644 index 000000000..465af5c15 --- /dev/null +++ b/tests/Fixtures/ServerParity/child-descriptions.json @@ -0,0 +1,200 @@ +{ + "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.", + "workflow_type": "parity.v1.echo", + "input": { + "message": "continue transfers λ", + "count": 9007199254740993, + "optional": null + }, + "typed_value": { + "type": "map", + "value": { + "message": { + "type": "string", + "value": "continue transfers λ" + }, + "count": { + "type": "int64", + "value": "9007199254740993" + }, + "optional": { + "type": "null", + "value": null + } + } + }, + "activity": false, + "expected_events": [ + "StartAccepted", + "WorkflowStarted", + "WorkflowCompleted" + ], + "child_descriptions": { + "workflow_type": "parity.v1.child_descriptions", + "child_type": "parity.v1.child_descriptions_child", + "empty_blob": "wwHioz3/VYAiNwwA", + "described_fields": [ + "status", + "status_bucket", + "is_terminal", + "resume_step_sequence", + "recovery_kind", + "wait_kind", + "wait_reason" + ], + "cases": { + "one_child": [ + { + "type": "start_child_workflow", + "workflow_type": "parity.v1.child_descriptions_child", + "arguments": { + "codec": "avro", + "blob": "wwHioz3/VYAiNwwA" + } + } + ], + "two_children": [ + { + "type": "start_child_workflow", + "workflow_type": "parity.v1.child_descriptions_child", + "arguments": { + "codec": "avro", + "blob": "wwHioz3/VYAiNwwA" + } + }, + { + "type": "start_child_workflow", + "workflow_type": "parity.v1.child_descriptions_child", + "arguments": { + "codec": "avro", + "blob": "wwHioz3/VYAiNwwA" + } + } + ], + "child_then_timer": [ + { + "type": "start_child_workflow", + "workflow_type": "parity.v1.child_descriptions_child", + "arguments": { + "codec": "avro", + "blob": "wwHioz3/VYAiNwwA" + } + }, + { + "type": "start_timer", + "delay_seconds": 3600 + } + ], + "timer_then_child": [ + { + "type": "start_timer", + "delay_seconds": 3600 + }, + { + "type": "start_child_workflow", + "workflow_type": "parity.v1.child_descriptions_child", + "arguments": { + "codec": "avro", + "blob": "wwHioz3/VYAiNwwA" + } + } + ], + "terminating_child": [ + { + "type": "start_child_workflow", + "workflow_type": "parity.v1.child_descriptions_child", + "arguments": { + "codec": "avro", + "blob": "wwHioz3/VYAiNwwA" + }, + "parent_close_policy": "terminate" + } + ] + }, + "expected": { + "one_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": "child", + "wait_reason": "Waiting for child workflow parity.v1.child_descriptions_child" + } + }, + "two_children": { + "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": "child", + "wait_reason": "Waiting for child workflow parity.v1.child_descriptions_child" + } + }, + "child_then_timer": { + "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": "timer", + "wait_reason": "Waiting for timer" + } + }, + "timer_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": "timer", + "wait_reason": "Waiting for timer" + } + }, + "terminating_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": "child", + "wait_reason": "Waiting for child workflow parity.v1.child_descriptions_child" + } + } + } + } +} diff --git a/tests/Unit/ServerParityChildDescriptionsContractTest.mjs b/tests/Unit/ServerParityChildDescriptionsContractTest.mjs new file mode 100644 index 000000000..56651b0cd --- /dev/null +++ b/tests/Unit/ServerParityChildDescriptionsContractTest.mjs @@ -0,0 +1,33 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import {checkChildDescriptions} from '../../scripts/conformance/server-parity/child-descriptions-contract.mjs'; + +const fixture = JSON.parse(readFileSync(new URL('../Fixtures/ServerParity/child-descriptions.json', import.meta.url), 'utf8')); +const expected = fixture.child_descriptions.expected; +const observation = () => structuredClone({mode: 'http', child_descriptions: {applicable: true, ...expected}}); +const cases = Object.keys(fixture.child_descriptions.cases).length; + +test('the published answers pass', () => { + assert.deepStrictEqual(checkChildDescriptions(fixture, observation()), {scope: 'child_descriptions', cases}); +}); + +test('embedded applications have no worker protocol', () => { + assert.deepStrictEqual(checkChildDescriptions(fixture, + {mode: 'embedded', child_descriptions: {applicable: false, reason: 'embedded_has_no_worker_protocol'}}), + {scope: 'child_descriptions', cases}); +}); + +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'}); +}); + +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'; }], + ['an unexpected case', state => { state.extra = {}; }], +]) test(`the contract rejects ${name}`, () => { + const raw = observation(); mutate(raw.child_descriptions); + assert.throws(() => checkChildDescriptions(fixture, raw)); +});