diff --git a/.github/workflows/server-parity.yml b/.github/workflows/server-parity.yml index 38b7bef31..03215cfe1 100644 --- a/.github/workflows/server-parity.yml +++ b/.github/workflows/server-parity.yml @@ -26,7 +26,7 @@ on: - tests/Unit/ServerParityWorkerDeregistrationContractTest.mjs - tests/Unit/ServerParityLateWorkflowCompletionContractTest.mjs - tests/Unit/ServerParitySearchAttributeContractTest.mjs - - tests/Unit/ServerParityStorageTestContractTest.mjs + - tests/Unit/ServerParityStorageTestContractTest.mjs tests/Unit/ServerParitySignalsBeforeWorkerContractTest.mjs - tests/Unit/ServerParityWorkerListingContractTest.mjs - tests/Unit/ServerParityWorkerReplacementContractTest.mjs - tests/Unit/ServerParityWaitingHistoryContractTest.mjs @@ -68,7 +68,7 @@ on: - tests/Unit/ServerParityWorkerDeregistrationContractTest.mjs - tests/Unit/ServerParityLateWorkflowCompletionContractTest.mjs - tests/Unit/ServerParitySearchAttributeContractTest.mjs - - tests/Unit/ServerParityStorageTestContractTest.mjs + - tests/Unit/ServerParityStorageTestContractTest.mjs tests/Unit/ServerParitySignalsBeforeWorkerContractTest.mjs - tests/Unit/ServerParityWorkerListingContractTest.mjs - tests/Unit/ServerParityWorkerReplacementContractTest.mjs - tests/Unit/ServerParityWaitingHistoryContractTest.mjs @@ -1460,7 +1460,7 @@ jobs: -e COMPOSER_HOME=/tmp/composer -v "$PWD:/source" -w /source "$image" \ 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/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/ServerParityLateWorkflowCompletionContractTest.mjs tests/Unit/ServerParityStorageTestContractTest.mjs > parity-evidence/comparator-tests.txt + "$image" --test tests/Unit/ServerParityRunnerTest.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/ServerParityLateWorkflowCompletionContractTest.mjs tests/Unit/ServerParityStorageTestContractTest.mjs tests/Unit/ServerParitySignalsBeforeWorkerContractTest.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.md b/docs/rust-server-port.md index f2d59565b..79207ddde 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -229,6 +229,38 @@ support is not evidence of multi-node safety. Newest checkpoints first. Each entry is exact to the source, tree and runs it names, including unsuccessful observations. +October 21, signals before any worker registers: + +- **Found by.** Waterline 2.12.0 service mode was compared against + published PHP 2.5.14 and against native with identical seeds. A signal + sent to a run that no worker had picked up was accepted by PHP and + rejected by native with 404 `unknown_signal`, so the signal was lost. +- **Cause.** Native records a run's command contract at start only when a + worker for the type has registered. It then treated a missing contract as + one that declares nothing, and its recorded contracts lacked PHP's + `declared_entry_*` fields. +- **Source.** Admission now follows PHP's `signalAdmissionForRun`: + - A signal is unknown only under a complete durable contract. + - A run without one admits every signal (`external_contract_unavailable`). + - A declared signal without a parameter contract takes its arguments as + given. + - The runtime message-stream signal name is refused with 422 + `runtime_reserved_signal` before admission, whatever the run's state, + and no command is recorded. + - A signal without `input`, or with null input, has no arguments. +- **Contracts.** Recorded contracts now carry PHP's entry defaults (`handle`, + `canonical`, `external:`), so they are complete. +- **Responses.** Signal responses carry `workflow_type`. The `control_plane` + envelope projects exactly PHP's `PROJECTED_FIELDS`, and cancel and + terminate responses now carry it. +- **Evidence.** The new `signals-before-worker` fixture sends five signals, + the reserved name before and after termination, and a missing workflow to + a run on an unserved queue. Its responses match a reviewed copy of + published PHP's, and its history holds the same events. Published PHP, + embedded and native passed it locally; native from main failed it. +- **Next.** Durable rejected updates and PHP's query routing refusals, which + the same comparison found. + October 21, takeover refuses schedules native cannot run: - **Found by.** A review of takeover against native's schedule support. diff --git a/rust/src/runtime/children.rs b/rust/src/runtime/children.rs index c6d194fe2..eba379b8a 100644 --- a/rust/src/runtime/children.rs +++ b/rust/src/runtime/children.rs @@ -173,6 +173,11 @@ where started[target] = value.clone(); } } + // WorkflowStartService's defaults for an external worker's + // contract, which make it a complete durable contract. + started["declared_entry_method"] = json!("handle"); + started["declared_entry_mode"] = json!("canonical"); + started["declared_entry_declaring_class"] = json!(format!("external:{workflow_type}")); break; } } diff --git a/rust/src/runtime/control_plane.rs b/rust/src/runtime/control_plane.rs index 48d1aa449..50d55e71e 100644 --- a/rust/src/runtime/control_plane.rs +++ b/rust/src/runtime/control_plane.rs @@ -226,12 +226,10 @@ impl Operation<'_> { "backend_lock_pressure", "control_plane_internal_error" ]); + // PHP's ControlPlaneResponseContract::PROJECTED_FIELDS for a signal. for field in [ "command_id", "target_scope", - "resolved_run_id", - "requested_run_id", - "signal_name", "command_contract_source", "command_contract_backfill_needed", "command_contract_backfill_available", @@ -243,7 +241,7 @@ impl Operation<'_> { } } } - // Copy scalar read identities/state, never input/output/event payloads. + // ControlPlaneResponseContract::PROJECTED_FIELDS: each present field. for field in [ "run_id", "workflow_type", @@ -251,10 +249,12 @@ impl Operation<'_> { "business_key", "status", "status_bucket", + "run_status", "is_terminal", "task_queue", "run_number", "run_count", + "workflow_count", "is_current_run", "compatibility", "started_at", @@ -262,16 +262,39 @@ impl Operation<'_> { "last_progress_at", "wait_kind", "wait_reason", - "workflow_count", "next_page_token", + "command_id", "command_status", "command_source", + "target_scope", + "query_name", "outcome", "reason", "rejection_reason", + "rejection_category", + "validation_errors", + "result", + "result_envelope", + "update_id", + "update_status", + "principal", + "wait_for", + "wait_timed_out", + "wait_timeout_seconds", + "blocked_reason", "message", "remediation", - "validation_errors", + "execution_owner", + "identity", + "command_contract_source", + "command_contract_backfill_needed", + "command_contract_backfill_available", + "declared_signals", + "signal_admission", + "diagnostic_status", + "retryable", + "error_id", + "exception", ] { if let Some(value) = body.get(field) { metadata[field] = value.clone(); diff --git a/rust/src/runtime/execution.rs b/rust/src/runtime/execution.rs index 8081ef4fb..137061e44 100644 --- a/rust/src/runtime/execution.rs +++ b/rust/src/runtime/execution.rs @@ -388,7 +388,12 @@ impl Runtime { name: &str, body: Value, ) -> Result { - let arguments = super::envelope(&body, "input")?; + // PHP's `input` is nullable: an absent or null input is no arguments. + let arguments = if body.get("input").is_none_or(Value::is_null) { + super::queries::empty_arguments(self.signal_codec.clone()).await? + } else { + super::envelope(&body, "input")? + }; let prepared = super::store::prepare_control_arguments( self.signal_codec.clone(), name.to_owned(), diff --git a/rust/src/runtime/http.rs b/rust/src/runtime/http.rs index 5a6a78653..99f5d03ab 100644 --- a/rust/src/runtime/http.rs +++ b/rust/src/runtime/http.rs @@ -701,7 +701,7 @@ async fn close( .close_workflow(command, workflow_id, run_id, body) .await .map_err(|error| operation().refusal(error, workflow_id, run_id))?; - Ok((status, Json(response))) + Ok((status, Json(operation().response(response)))) } async fn cancel_current( diff --git a/rust/src/runtime/store.rs b/rust/src/runtime/store.rs index 3c1499129..034f123ed 100644 --- a/rust/src/runtime/store.rs +++ b/rust/src/runtime/store.rs @@ -15,6 +15,8 @@ pub(super) struct PreparedControlArguments { pub(super) value: String, pub(super) command: String, } +/// The runtime-owned message-stream delivery signal (`WorkflowStub::MESSAGE_STREAM_RUNTIME_SIGNAL`). +const MESSAGE_STREAM_SIGNAL: &str = "__durable_workflow_message_stream"; pub(super) const TASK_CANDIDATES_SQL: &str = "SELECT t.*,r.workflow_type FROM workflow_tasks t JOIN workflow_runs r ON r.id=t.workflow_run_id WHERE t.namespace=$1 AND t.queue=$2 AND t.task_type=$3 AND r.status IN ('pending','running','waiting') AND (t.status='ready' OR (t.status='leased' AND t.lease_expires_at<=$4)) AND (t.available_at IS NULL OR t.available_at<=$5) ORDER BY t.available_at,t.id LIMIT 100"; pub(super) const POLL_RECEIPT_CLEANUP_SQL: &str = "DELETE FROM dw_poll_receipts WHERE expires_at<=$1 AND task_id IN (SELECT id FROM workflow_tasks WHERE status!='leased' OR lease_expires_at<=$2)"; pub(super) const WORKER_REGISTRATION_SQL: &str = "INSERT INTO workflow_worker_registrations(namespace,worker_id,task_queue,runtime,sdk_version,build_id,supported_workflow_types,supported_activity_types,capabilities,capability_manifest,last_heartbeat_at,created_at,updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) ON CONFLICT(worker_id,namespace) DO UPDATE SET task_queue=excluded.task_queue,runtime=excluded.runtime,sdk_version=excluded.sdk_version,build_id=excluded.build_id,supported_workflow_types=excluded.supported_workflow_types,supported_activity_types=excluded.supported_activity_types,capabilities=excluded.capabilities,capability_manifest=excluded.capability_manifest,last_heartbeat_at=excluded.last_heartbeat_at,updated_at=excluded.updated_at"; @@ -453,6 +455,16 @@ where "historical_run_command_rejected", )); } + // PHP's control plane refuses the runtime's own message-stream signal + // before admission, whatever the run's state, and records nothing. + if name == MESSAGE_STREAM_SIGNAL { + let message = "This signal name is reserved for the durable runtime transport."; + return Err(super::RuntimeError::Protocol { + status: StatusCode::UNPROCESSABLE_ENTITY, + response: json!({"workflow_id":workflow_id,"signal_name":name,"outcome":"rejected", + "reason":"runtime_reserved_signal","message":message,"accepted":false}), + }); + } let run = Self::query("SELECT * FROM workflow_runs WHERE id=$1") .bind(¤t_run) .fetch_one(&mut *tx) @@ -472,6 +484,15 @@ where let declared = contract["declared_signals"] .as_array() .is_some_and(|names| names.iter().any(|value| value == name)); + // PHP's signalAdmissionForRun: only a complete durable contract can + // make a signal unknown. A run started before any worker declared its + // contract admits every signal (external_contract_unavailable), and a + // declared signal without a parameter contract takes its arguments + // as given. + let durable = super::run_observation::command_contract(Some(&contract))["source"] == "durable_history"; + let has_parameters = contract["declared_signal_contracts"] + .as_array() + .is_some_and(|contracts| contracts.iter().any(|contract| contract["name"] == name)); let mut validation_errors = json!([]); let rejection = if !active { Some(( @@ -479,12 +500,14 @@ where "run_not_active", "rejected_not_active", )) - } else if !declared { + } else if !declared && durable { Some(( StatusCode::NOT_FOUND, "unknown_signal", "rejected_unknown_signal", )) + } else if !has_parameters { + None } else { match validate_signal_arguments(&contract, name, &prepared.values) { Ok(()) => None, @@ -601,7 +624,7 @@ where tx.commit().await?; self.wake.notify_waiters(); Ok( - json!({"workflow_id":workflow_id,"run_id":current_run,"resolved_run_id":current_run,"requested_run_id":null,"signal_name":name,"command_id":command_id,"command_sequence":command_sequence,"command_status":"accepted","command_source":"control_plane","target_scope":"instance","outcome":"signal_received","reason":null,"rejection_reason":null,"validation_errors":[]}), + json!({"workflow_id":workflow_id,"run_id":current_run,"resolved_run_id":current_run,"requested_run_id":null,"signal_name":name,"command_id":command_id,"command_sequence":command_sequence,"workflow_type":DB::string(&run, "workflow_type")?,"command_status":"accepted","command_source":"control_plane","target_scope":"instance","outcome":"signal_received","reason":null,"rejection_reason":null,"validation_errors":[]}), ) } diff --git a/rust/tests/execution/namespaces.rs b/rust/tests/execution/namespaces.rs index 986aa32f3..fc4a1ff75 100644 --- a/rust/tests/execution/namespaces.rs +++ b/rust/tests/execution/namespaces.rs @@ -114,10 +114,9 @@ async fn named_signal_waits_keep_both_original_runs_and_foreign_polls_isolated() accepted.1["control_plane"]["command_id"], accepted.1["command_id"] ); - assert_eq!( - accepted.1["control_plane"]["resolved_run_id"], - starts[index]["run_id"] - ); + // PHP projects only its PROJECTED_FIELDS into the envelope. + assert_eq!(accepted.1["resolved_run_id"], starts[index]["run_id"]); + assert!(accepted.1["control_plane"].get("resolved_run_id").is_none()); assert_eq!(accepted.1["control_plane"]["operation_name"], "data"); command_ids.push(accepted.1["command_id"].as_str().unwrap().to_owned()); let ready = scoped(&app, namespaces[index], "GET", &history_path, Value::Null).await; diff --git a/rust/tests/execution/selected_run_signals.rs b/rust/tests/execution/selected_run_signals.rs index 6398e751f..6cbf420d6 100644 --- a/rust/tests/execution/selected_run_signals.rs +++ b/rust/tests/execution/selected_run_signals.rs @@ -244,7 +244,8 @@ async fn selected_run_guards_and_durable_rejections_preserve_original_execution( let accepted = scoped(&app, "alpha", "POST", &path, json!({"input":input})).await; assert_eq!(accepted.0, StatusCode::ACCEPTED, "{}", accepted.1); assert_eq!(accepted.1["command_sequence"], 4); - assert_eq!(accepted.1["control_plane"]["resolved_run_id"], run); + assert_eq!(accepted.1["resolved_run_id"], run); + assert!(accepted.1["control_plane"].get("resolved_run_id").is_none()); let resumed = scoped( &app, "alpha", diff --git a/rust/tests/execution/worker_contracts.rs b/rust/tests/execution/worker_contracts.rs index 9dac8ad68..483c23b35 100644 --- a/rust/tests/execution/worker_contracts.rs +++ b/rust/tests/execution/worker_contracts.rs @@ -171,3 +171,45 @@ async fn invalid_declarations_refuse_before_registration_rotation_or_lease_relea drop(app); database.remove().await; } + +async fn signal(app: &Router, workflow: &Value, name: &str, body: Value) -> (StatusCode, Value) { + let path = format!("/api/workflows/{}/signal/{name}", workflow["workflow_id"].as_str().unwrap()); + request(app, "POST", &path, body).await +} + +#[tokio::test] +async fn signals_follow_php_admission_with_and_without_a_recorded_contract() { + let database = TestDatabase::new().await; + let app = router(database.open().await.unwrap()); + let input = json!({"input":envelope(Payload::Array(vec![Payload::String("x".into())]))}); + // No worker has declared a contract: PHP admits every signal. + let unregistered = start(&app, "before-any-worker").await; + for body in [input.clone(), json!({}), json!({"input":null})] { + let admitted = signal(&app, &unregistered, "anything", body).await; + assert_eq!((admitted.0, admitted.1["outcome"].clone()), (StatusCode::ACCEPTED, json!("signal_received")), "{}", admitted.1); + assert_eq!(admitted.1["workflow_type"], "echo"); + } + let reserved = signal(&app, &unregistered, "__durable_workflow_message_stream", input.clone()).await; + assert_eq!(reserved.0, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!((reserved.1["reason"].clone(), reserved.1["accepted"].clone()), (json!("runtime_reserved_signal"), json!(false))); + assert!(reserved.1.get("command_id").is_none(), "{}", reserved.1); + let history = run_history(&app, &unregistered["workflow_id"], &unregistered["run_id"]).await; + let received = history.as_array().unwrap().iter().filter(|event| event["event_type"] == "SignalReceived").count(); + assert_eq!(received, 3); + // A registered contract is complete and durable, as PHP records it. + let registered = register_contract(&app, Some(json!({"echo":{"signals":["a"]}}))).await; + assert_eq!(registered.0, StatusCode::CREATED, "{}", registered.1); + let declared = start(&app, "after-a-worker").await; + let started = snapshot(&app, &declared).await; + assert_eq!( + (started["declared_entry_method"].clone(), started["declared_entry_mode"].clone(), started["declared_entry_declaring_class"].clone()), + (json!("handle"), json!("canonical"), json!("external:echo")) + ); + let unknown = signal(&app, &declared, "b", input.clone()).await; + assert_eq!((unknown.0, unknown.1["reason"].clone()), (StatusCode::NOT_FOUND, json!("unknown_signal")), "{}", unknown.1); + // A declared signal without a parameter contract takes its arguments as given. + let untyped = signal(&app, &declared, "a", input).await; + assert_eq!(untyped.0, StatusCode::ACCEPTED, "{}", untyped.1); + drop(app); + database.remove().await; +} diff --git a/scripts/conformance/server-parity/contract.mjs b/scripts/conformance/server-parity/contract.mjs index ea2ab4287..0269b3c24 100644 --- a/scripts/conformance/server-parity/contract.mjs +++ b/scripts/conformance/server-parity/contract.mjs @@ -12,6 +12,7 @@ import {checkWorkerDeregistration} from './worker-deregistration-contract.mjs'; import {checkLateWorkflowCompletion} from './late-workflow-completion-contract.mjs'; import {checkSearchAttributes} from './search-attribute-contract.mjs'; import {checkStorageTest} from './storage-test-contract.mjs'; +import {checkSignalsBeforeWorker} from './signals-before-worker-contract.mjs'; import {checkWorkerListing} from './worker-listing-contract.mjs'; import {checkWorkerReplacement} from './worker-replacement-contract.mjs'; import {checkPatchDeployment, checkPublishedPatchArtifacts, checkPatchPackageObservation} from './patch-deployment-contract.mjs'; @@ -137,6 +138,7 @@ export function checkObservation(fixture, observation, workflowId, artifacts) { const lateWorkflowCompletion = fixture.late_workflow_completion ? checkLateWorkflowCompletion(fixture, observation, workflowId) : null; const searchAttributes = fixture.search_attributes ? checkSearchAttributes(fixture, observation, workflowId) : null; const storageTest = fixture.storage_test ? checkStorageTest(fixture, observation, workflowId) : null; + const signalsBeforeWorker = fixture.signals_before_worker ? checkSignalsBeforeWorker(fixture, observation, workflowId) : null; const workerListing = fixture.worker_listing ? checkWorkerListing(fixture, observation, workflowId) : null; const workerReplacement = fixture.worker_replacement ? checkWorkerReplacement(fixture, observation, workflowId) : null; const patchDeployment = fixture.patch_deployment ? (fixture.patch_deployment.legacy_marker_history @@ -679,6 +681,7 @@ export function checkObservation(fixture, observation, workflowId, artifacts) { ...(lateWorkflowCompletion ? {late_workflow_completion: lateWorkflowCompletion} : {}), ...(searchAttributes ? {search_attributes: searchAttributes} : {}), ...(storageTest ? {storage_test: storageTest} : {}), + ...(signalsBeforeWorker ? {signals_before_worker: signalsBeforeWorker} : {}), ...(workerListing ? {worker_listing: workerListing} : {}), ...(workerReplacement ? {worker_replacement: workerReplacement} : {}), ...(patchDeployment ? {patch_deployment: patchDeployment} : {}), diff --git a/scripts/conformance/server-parity/probe.php b/scripts/conformance/server-parity/probe.php index b69395bb3..1506e353b 100644 --- a/scripts/conformance/server-parity/probe.php +++ b/scripts/conformance/server-parity/probe.php @@ -430,6 +430,9 @@ function httpObservation(array $fixture, string $workflowId, string $namespace, $lateWorkflowCompletion = isset($fixture['late_workflow_completion']) ? finishHttpLateWorkflowCompletion($client, $fixture, $workflowId) : null; $searchAttributes = isset($fixture['search_attributes']) ? finishHttpSearchAttributes($client, $fixture, $workflowId, $url) : null; $storageTest = isset($fixture['storage_test']) ? finishHttpStorageTest($client, $fixture, $workflowId, $url) : null; + $signalsBeforeWorker = isset($fixture['signals_before_worker']) + ? finishHttpSignalsBeforeWorker($client, $fixture, $workflowId, $url) + : null; $workerListing = isset($fixture['worker_listing']) ? finishHttpWorkerListing($client, $fixture, $workflowId) : null; $workerReplacement = isset($fixture['worker_replacement']) ? finishHttpWorkerReplacement($client, $fixture, $workflowId) : null; $waitingHistory = isset($fixture['waiting_for_history']) ? finishHttpWaitingHistory($client, $fixture, $workflowId, $queue, $url) : null; @@ -521,6 +524,7 @@ function httpObservation(array $fixture, string $workflowId, string $namespace, 'late_workflow_completion' => $lateWorkflowCompletion, 'search_attributes' => $searchAttributes, 'storage_test' => $storageTest, + 'signals_before_worker' => $signalsBeforeWorker, 'worker_listing' => $workerListing, 'worker_replacement' => $workerReplacement, 'waiting_for_history' => $waitingHistory, @@ -820,6 +824,7 @@ function embeddedObservation(array $fixture, string $workflowId, string $namespa require __DIR__.'/namespace-probe.php'; require __DIR__.'/search-attribute-probe.php'; require __DIR__.'/storage-test-probe.php'; + require __DIR__.'/signals-before-worker-probe.php'; require __DIR__.'/worker-deregistration-probe.php'; require __DIR__.'/late-workflow-completion-probe.php'; require __DIR__.'/worker-listing-probe.php'; @@ -972,6 +977,9 @@ function embeddedObservation(array $fixture, string $workflowId, string $namespa if ($mode === 'embedded' && isset($fixture['storage_test'])) { $observation['storage_test'] = ['applicable' => false, 'reason' => 'embedded_has_no_http_storage_diagnostic']; } + if ($mode === 'embedded' && isset($fixture['signals_before_worker'])) { + $observation['signals_before_worker'] = ['applicable' => false, 'reason' => 'embedded_runs_have_a_loadable_contract']; + } $observation['mode'] = $mode; $observation['typed_output'] = typedValue($observation['output']); if ($mode === 'embedded' && isset($fixture['waiting_for_history'])) { diff --git a/scripts/conformance/server-parity/signals-before-worker-contract.mjs b/scripts/conformance/server-parity/signals-before-worker-contract.mjs new file mode 100644 index 000000000..a27235d75 --- /dev/null +++ b/scripts/conformance/server-parity/signals-before-worker-contract.mjs @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; + +// Published PHP's reviewed, normalized responses to every step. +const EXPECTED = JSON.parse(readFileSync(new URL('../../../tests/Fixtures/ServerParityExpected/signals-before-worker.json', import.meta.url))); + +// Generated identities, times, audit fingerprints, route names and peer +// addresses differ between servers and runs. +const VOLATILE = [/^\d{4}-\d{2}-\d{2}T/, /^[0-9A-Za-z]{26}$/, /^sha256:[0-9a-f]{64}$/, /^generated::/, /^\d+\.\d+\.\d+\.\d+$/]; + +export function normalized(value, names = {}) { + if (Array.isArray(value)) return value.map(item => normalized(item, names)); + if (value && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([field, item]) => [field, normalized(item, names)])); + } + if (typeof value !== 'string') return value; + if (VOLATILE.some(pattern => pattern.test(value))) return '@v'; + return Object.entries(names).reduce((text, [name, placeholder]) => text.replaceAll(name, placeholder), value); +} + +export const STATUSES = [201, 202, 202, 202, 202, 422, 200, 409, 422, 404, 200]; +const EVENTS = ['StartAccepted', 'WorkflowStarted', 'SignalReceived', 'SignalReceived', 'SignalReceived', 'SignalReceived', + 'TerminateRequested', 'WorkflowTerminated']; + +export function checkSignalsBeforeWorker(fixture, observation, workflowId) { + const state = observation.signals_before_worker; + assert.ok(state && typeof state === 'object', 'actual signals-before-worker observation'); + if (observation.mode === 'embedded') { + assert.deepStrictEqual(state, {applicable: false, reason: 'embedded_runs_have_a_loadable_contract'}, + 'embedded runs explicitly inapplicable'); + return {scope: 'signals_before_worker'}; + } + const namespace = `${workflowId}-unregistered`.toLowerCase(); + assert.deepStrictEqual([state.applicable, state.namespace, state.workflow_id], + [true, namespace, `${workflowId}-before-worker`.toLowerCase()], 'fresh namespace and run'); + const names = {[state.workflow_id]: '@workflow', [namespace]: '@namespace', [state.queue]: '@queue'}; + const steps = state.steps.map(step => ({request: step.request, status: step.status, response: normalized(step.response, names)})); + assert.deepStrictEqual(steps.map(step => step.status), STATUSES, 'step statuses'); + const signals = steps.slice(1, 5).map(step => step.response); + assert.ok(signals.every(signal => signal.outcome === 'signal_received' && signal.command_status === 'accepted'), + 'every signal is admitted without a contract'); + assert.deepStrictEqual([steps[5].response.reason, steps[5].response.accepted, 'command_id' in steps[5].response], + ['runtime_reserved_signal', false, false], 'reserved runtime signal refused without a command'); + assert.deepStrictEqual([steps[7].response.reason, steps[8].response.reason, steps[9].response.reason], + ['run_not_active', 'runtime_reserved_signal', 'instance_not_found'], 'after termination and for a missing workflow'); + assert.deepStrictEqual(steps[10].response.events.map(event => event.event_type), EVENTS, 'durable history'); + // This fixture covers signal admission: the start response and history + // payloads are compared by their own fixtures. + const compared = steps.slice(1, 10); + assert.deepStrictEqual(compared, EXPECTED, 'every response matches the reviewed published PHP responses'); + return {scope: 'signals_before_worker'}; +} diff --git a/scripts/conformance/server-parity/signals-before-worker-probe.php b/scripts/conformance/server-parity/signals-before-worker-probe.php new file mode 100644 index 000000000..16e14a750 --- /dev/null +++ b/scripts/conformance/server-parity/signals-before-worker-probe.php @@ -0,0 +1,55 @@ +createNamespace($namespace, 'signals before a worker λ', 30); + $queue = strtolower($workflowId.'-unserved'); + $workflow = strtolower($workflowId.'-before-worker'); + $input = ['codec' => 'avro', 'blob' => $definition['input_blob']]; + $token = getenv('DW_PARITY_TOKEN') ?: null; + $http = new SignalsBeforeWorkerHttp(['base_uri' => rtrim($url, '/').'/api/', 'http_errors' => false, + 'connect_timeout' => 1, 'timeout' => 30]); + $run = ''; + $fill = static function (mixed $value) use (&$fill, &$run, $queue, $input, $workflow): mixed { + if (is_string($value)) { + return match ($value) { + '@queue' => $queue, + '@input' => $input, + default => str_replace(['@workflow', '@run'], [$workflow, $run], $value), + }; + } + + return is_array($value) ? array_map($fill, $value) : $value; + }; + $steps = []; + foreach ($definition['steps'] as $step) { + $path = $fill($step[1]); + $body = array_key_exists(2, $step) ? $fill($step[2]) : null; + $response = $http->request($step[0], $path, ['headers' => array_filter([ + 'Authorization' => $token === null ? null : 'Bearer '.$token, + 'Accept' => 'application/json', 'X-Namespace' => $namespace, + 'X-Durable-Workflow-Control-Plane-Version' => '2', + ]), ...($body === null ? [] : ['json' => $body === [] ? (object) [] : $body])]); + $decoded = json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR); + if ($run === '' && is_string($decoded['run_id'] ?? null)) { + $run = $decoded['run_id']; + } + $steps[] = ['request' => $step[0].' '.$step[1], 'status' => $response->getStatusCode(), 'response' => $decoded]; + } + + return ['applicable' => true, 'namespace' => $namespace, 'queue' => $queue, 'workflow_id' => $workflow, + 'run_id' => $run, 'steps' => $steps]; +} diff --git a/tests/Fixtures/ServerParity/signals-before-worker.json b/tests/Fixtures/ServerParity/signals-before-worker.json new file mode 100644 index 000000000..47f080934 --- /dev/null +++ b/tests/Fixtures/ServerParity/signals-before-worker.json @@ -0,0 +1,27 @@ +{ + "schema": "durable-workflow.server-parity-fixture/v1", + "id": "signals-before-worker", + "review_state": "reviewed", + "workflow_type": "parity.v1.echo", + "input": {"message": "signals before a worker λ", "count": 9007199254740993, "optional": null}, + "typed_value": {"type": "map", "value": {"message": {"type": "string", "value": "signals before a worker λ"}, "count": {"type": "int64", "value": "9007199254740993"}, "optional": {"type": "null", "value": null}}}, + "activity": false, + "expected_events": ["StartAccepted", "WorkflowStarted", "WorkflowCompleted"], + "signals_before_worker": { + "input_blob": "wwHioz3/VYAiNwwCBIKAgICAgIAgAA==", + "steps": [ + ["POST", "workflows", {"workflow_id": "@workflow", "workflow_type": "parity.v1.echo", "task_queue": "@queue", "input": "@input"}], + ["POST", "workflows/@workflow/signal/approve", {"input": "@input", "request_id": "before-worker-1"}], + ["POST", "workflows/@workflow/signal/approve", {}], + ["POST", "workflows/@workflow/signal/approve", {"input": null}], + ["POST", "workflows/@workflow/runs/@run/signal/remember", {"input": "@input"}], + ["POST", "workflows/@workflow/signal/__durable_workflow_message_stream", {"input": "@input"}], + ["POST", "workflows/@workflow/terminate", {"reason": "before worker"}], + ["POST", "workflows/@workflow/signal/approve", {"input": "@input"}], + ["POST", "workflows/@workflow/signal/__durable_workflow_message_stream", {"input": "@input"}], + ["POST", "workflows/missing-before-worker/signal/__durable_workflow_message_stream", {"input": "@input"}], + ["GET", "workflows/@workflow/runs/@run/history"] + ] + }, + "contract": "Signals to a run on a task queue that no worker has registered, so the run has no recorded command contract. Published PHP admits every signal then (external_contract_unavailable), with an envelope, without input or with null input, to the instance or the current run, and records each SignalReceived; the runtime message-stream signal name is refused with 422 runtime_reserved_signal whatever the run state, without a durable command; a terminated run rejects a signal with 409 run_not_active; a missing workflow is 404. Every response matches published PHP's apart from generated identities and times, and the run history holds the same events. Embedded applications run loadable workflow classes, whose contract is always available." +} diff --git a/tests/Fixtures/ServerParityExpected/signals-before-worker.json b/tests/Fixtures/ServerParityExpected/signals-before-worker.json new file mode 100644 index 000000000..c07a6372d --- /dev/null +++ b/tests/Fixtures/ServerParityExpected/signals-before-worker.json @@ -0,0 +1,742 @@ +[ + { + "request": "POST workflows/@workflow/signal/approve", + "status": 202, + "response": { + "outcome": "signal_received", + "workflow_id": "@workflow", + "run_id": "@v", + "requested_run_id": null, + "resolved_run_id": "@v", + "command_id": "@v", + "command_sequence": 2, + "target_scope": "instance", + "workflow_type": "parity.v1.echo", + "command_status": "accepted", + "command_source": "control_plane", + "reason": null, + "rejection_reason": null, + "validation_errors": [], + "signal_name": "approve", + "control_plane": { + "schema": "durable-workflow.v2.control-plane-response", + "version": 1, + "operation": "signal", + "workflow_id": "@workflow", + "contract": { + "schema": "durable-workflow.v2.control-plane-response.contract", + "version": 1, + "legacy_field_policy": "reject_non_canonical", + "legacy_fields": { + "query": "query_name", + "signal": "signal_name", + "update": "update_name", + "wait_policy": "wait_for" + }, + "required_fields": [ + "workflow_id", + "operation_name", + "operation_name_field" + ], + "success_fields": [ + "outcome" + ], + "rejection_fields": [ + "workflow_id", + "run_id", + "signal_name", + "command_status", + "command_source", + "target_scope", + "outcome", + "reason", + "rejection_reason", + "rejection_category", + "message", + "command_contract_source", + "command_contract_backfill_needed", + "command_contract_backfill_available", + "declared_signals", + "signal_admission", + "retryable", + "error_id" + ], + "rejection_reasons": [ + "instance_not_found", + "historical_run_command_rejected", + "unknown_signal", + "invalid_signal_arguments", + "run_not_active", + "instance_not_started", + "selected_run_not_current", + "configured_workflow_type_invalid", + "v1_projection_read_only", + "backend_lock_pressure", + "control_plane_internal_error" + ] + }, + "operation_name": "approve", + "operation_name_field": "signal_name", + "run_id": "@v", + "workflow_type": "parity.v1.echo", + "command_id": "@v", + "command_status": "accepted", + "command_source": "control_plane", + "target_scope": "instance", + "outcome": "signal_received", + "reason": null, + "rejection_reason": null, + "validation_errors": [] + } + } + }, + { + "request": "POST workflows/@workflow/signal/approve", + "status": 202, + "response": { + "outcome": "signal_received", + "workflow_id": "@workflow", + "run_id": "@v", + "requested_run_id": null, + "resolved_run_id": "@v", + "command_id": "@v", + "command_sequence": 3, + "target_scope": "instance", + "workflow_type": "parity.v1.echo", + "command_status": "accepted", + "command_source": "control_plane", + "reason": null, + "rejection_reason": null, + "validation_errors": [], + "signal_name": "approve", + "control_plane": { + "schema": "durable-workflow.v2.control-plane-response", + "version": 1, + "operation": "signal", + "workflow_id": "@workflow", + "contract": { + "schema": "durable-workflow.v2.control-plane-response.contract", + "version": 1, + "legacy_field_policy": "reject_non_canonical", + "legacy_fields": { + "query": "query_name", + "signal": "signal_name", + "update": "update_name", + "wait_policy": "wait_for" + }, + "required_fields": [ + "workflow_id", + "operation_name", + "operation_name_field" + ], + "success_fields": [ + "outcome" + ], + "rejection_fields": [ + "workflow_id", + "run_id", + "signal_name", + "command_status", + "command_source", + "target_scope", + "outcome", + "reason", + "rejection_reason", + "rejection_category", + "message", + "command_contract_source", + "command_contract_backfill_needed", + "command_contract_backfill_available", + "declared_signals", + "signal_admission", + "retryable", + "error_id" + ], + "rejection_reasons": [ + "instance_not_found", + "historical_run_command_rejected", + "unknown_signal", + "invalid_signal_arguments", + "run_not_active", + "instance_not_started", + "selected_run_not_current", + "configured_workflow_type_invalid", + "v1_projection_read_only", + "backend_lock_pressure", + "control_plane_internal_error" + ] + }, + "operation_name": "approve", + "operation_name_field": "signal_name", + "run_id": "@v", + "workflow_type": "parity.v1.echo", + "command_id": "@v", + "command_status": "accepted", + "command_source": "control_plane", + "target_scope": "instance", + "outcome": "signal_received", + "reason": null, + "rejection_reason": null, + "validation_errors": [] + } + } + }, + { + "request": "POST workflows/@workflow/signal/approve", + "status": 202, + "response": { + "outcome": "signal_received", + "workflow_id": "@workflow", + "run_id": "@v", + "requested_run_id": null, + "resolved_run_id": "@v", + "command_id": "@v", + "command_sequence": 4, + "target_scope": "instance", + "workflow_type": "parity.v1.echo", + "command_status": "accepted", + "command_source": "control_plane", + "reason": null, + "rejection_reason": null, + "validation_errors": [], + "signal_name": "approve", + "control_plane": { + "schema": "durable-workflow.v2.control-plane-response", + "version": 1, + "operation": "signal", + "workflow_id": "@workflow", + "contract": { + "schema": "durable-workflow.v2.control-plane-response.contract", + "version": 1, + "legacy_field_policy": "reject_non_canonical", + "legacy_fields": { + "query": "query_name", + "signal": "signal_name", + "update": "update_name", + "wait_policy": "wait_for" + }, + "required_fields": [ + "workflow_id", + "operation_name", + "operation_name_field" + ], + "success_fields": [ + "outcome" + ], + "rejection_fields": [ + "workflow_id", + "run_id", + "signal_name", + "command_status", + "command_source", + "target_scope", + "outcome", + "reason", + "rejection_reason", + "rejection_category", + "message", + "command_contract_source", + "command_contract_backfill_needed", + "command_contract_backfill_available", + "declared_signals", + "signal_admission", + "retryable", + "error_id" + ], + "rejection_reasons": [ + "instance_not_found", + "historical_run_command_rejected", + "unknown_signal", + "invalid_signal_arguments", + "run_not_active", + "instance_not_started", + "selected_run_not_current", + "configured_workflow_type_invalid", + "v1_projection_read_only", + "backend_lock_pressure", + "control_plane_internal_error" + ] + }, + "operation_name": "approve", + "operation_name_field": "signal_name", + "run_id": "@v", + "workflow_type": "parity.v1.echo", + "command_id": "@v", + "command_status": "accepted", + "command_source": "control_plane", + "target_scope": "instance", + "outcome": "signal_received", + "reason": null, + "rejection_reason": null, + "validation_errors": [] + } + } + }, + { + "request": "POST workflows/@workflow/runs/@run/signal/remember", + "status": 202, + "response": { + "outcome": "signal_received", + "workflow_id": "@workflow", + "run_id": "@v", + "requested_run_id": null, + "resolved_run_id": "@v", + "command_id": "@v", + "command_sequence": 5, + "target_scope": "instance", + "workflow_type": "parity.v1.echo", + "command_status": "accepted", + "command_source": "control_plane", + "reason": null, + "rejection_reason": null, + "validation_errors": [], + "signal_name": "remember", + "control_plane": { + "schema": "durable-workflow.v2.control-plane-response", + "version": 1, + "operation": "signal", + "workflow_id": "@workflow", + "contract": { + "schema": "durable-workflow.v2.control-plane-response.contract", + "version": 1, + "legacy_field_policy": "reject_non_canonical", + "legacy_fields": { + "query": "query_name", + "signal": "signal_name", + "update": "update_name", + "wait_policy": "wait_for" + }, + "required_fields": [ + "workflow_id", + "operation_name", + "operation_name_field" + ], + "success_fields": [ + "outcome" + ], + "rejection_fields": [ + "workflow_id", + "run_id", + "signal_name", + "command_status", + "command_source", + "target_scope", + "outcome", + "reason", + "rejection_reason", + "rejection_category", + "message", + "command_contract_source", + "command_contract_backfill_needed", + "command_contract_backfill_available", + "declared_signals", + "signal_admission", + "retryable", + "error_id" + ], + "rejection_reasons": [ + "instance_not_found", + "historical_run_command_rejected", + "unknown_signal", + "invalid_signal_arguments", + "run_not_active", + "instance_not_started", + "selected_run_not_current", + "configured_workflow_type_invalid", + "v1_projection_read_only", + "backend_lock_pressure", + "control_plane_internal_error" + ] + }, + "operation_name": "remember", + "operation_name_field": "signal_name", + "run_id": "@v", + "workflow_type": "parity.v1.echo", + "command_id": "@v", + "command_status": "accepted", + "command_source": "control_plane", + "target_scope": "instance", + "outcome": "signal_received", + "reason": null, + "rejection_reason": null, + "validation_errors": [] + } + } + }, + { + "request": "POST workflows/@workflow/signal/__durable_workflow_message_stream", + "status": 422, + "response": { + "workflow_id": "@workflow", + "signal_name": "__durable_workflow_message_stream", + "outcome": "rejected", + "reason": "runtime_reserved_signal", + "message": "This signal name is reserved for the durable runtime transport.", + "accepted": false, + "control_plane": { + "schema": "durable-workflow.v2.control-plane-response", + "version": 1, + "operation": "signal", + "workflow_id": "@workflow", + "contract": { + "schema": "durable-workflow.v2.control-plane-response.contract", + "version": 1, + "legacy_field_policy": "reject_non_canonical", + "legacy_fields": { + "query": "query_name", + "signal": "signal_name", + "update": "update_name", + "wait_policy": "wait_for" + }, + "required_fields": [ + "workflow_id", + "operation_name", + "operation_name_field" + ], + "success_fields": [ + "outcome" + ], + "rejection_fields": [ + "workflow_id", + "run_id", + "signal_name", + "command_status", + "command_source", + "target_scope", + "outcome", + "reason", + "rejection_reason", + "rejection_category", + "message", + "command_contract_source", + "command_contract_backfill_needed", + "command_contract_backfill_available", + "declared_signals", + "signal_admission", + "retryable", + "error_id" + ], + "rejection_reasons": [ + "instance_not_found", + "historical_run_command_rejected", + "unknown_signal", + "invalid_signal_arguments", + "run_not_active", + "instance_not_started", + "selected_run_not_current", + "configured_workflow_type_invalid", + "v1_projection_read_only", + "backend_lock_pressure", + "control_plane_internal_error" + ] + }, + "operation_name": "__durable_workflow_message_stream", + "operation_name_field": "signal_name", + "outcome": "rejected", + "reason": "runtime_reserved_signal", + "message": "This signal name is reserved for the durable runtime transport." + } + } + }, + { + "request": "POST workflows/@workflow/terminate", + "status": 200, + "response": { + "outcome": "terminated", + "workflow_id": "@workflow", + "run_id": "@v", + "requested_run_id": null, + "resolved_run_id": "@v", + "command_id": "@v", + "command_sequence": 6, + "target_scope": "instance", + "workflow_type": "parity.v1.echo", + "command_status": "accepted", + "command_source": "control_plane", + "reason": "before worker", + "rejection_reason": null, + "validation_errors": [], + "control_plane": { + "schema": "durable-workflow.v2.control-plane-response", + "version": 1, + "operation": "terminate", + "workflow_id": "@workflow", + "contract": { + "schema": "durable-workflow.v2.control-plane-response.contract", + "version": 1, + "legacy_field_policy": "reject_non_canonical", + "legacy_fields": { + "query": "query_name", + "signal": "signal_name", + "update": "update_name", + "wait_policy": "wait_for" + }, + "required_fields": [ + "workflow_id" + ], + "success_fields": [ + "outcome" + ], + "rejection_fields": [ + "workflow_id", + "run_id", + "reason", + "message", + "remediation" + ], + "rejection_reasons": [ + "v1_projection_read_only" + ] + }, + "run_id": "@v", + "workflow_type": "parity.v1.echo", + "command_id": "@v", + "command_status": "accepted", + "command_source": "control_plane", + "target_scope": "instance", + "outcome": "terminated", + "reason": "before worker", + "rejection_reason": null, + "validation_errors": [] + } + } + }, + { + "request": "POST workflows/@workflow/signal/approve", + "status": 409, + "response": { + "outcome": "rejected_not_active", + "workflow_id": "@workflow", + "run_id": "@v", + "requested_run_id": null, + "resolved_run_id": "@v", + "command_id": "@v", + "command_sequence": 7, + "target_scope": "instance", + "workflow_type": "parity.v1.echo", + "command_status": "rejected", + "command_source": "control_plane", + "reason": "run_not_active", + "rejection_reason": "run_not_active", + "validation_errors": [], + "signal_name": "approve", + "accepted": false, + "control_plane": { + "schema": "durable-workflow.v2.control-plane-response", + "version": 1, + "operation": "signal", + "workflow_id": "@workflow", + "contract": { + "schema": "durable-workflow.v2.control-plane-response.contract", + "version": 1, + "legacy_field_policy": "reject_non_canonical", + "legacy_fields": { + "query": "query_name", + "signal": "signal_name", + "update": "update_name", + "wait_policy": "wait_for" + }, + "required_fields": [ + "workflow_id", + "operation_name", + "operation_name_field" + ], + "success_fields": [ + "outcome" + ], + "rejection_fields": [ + "workflow_id", + "run_id", + "signal_name", + "command_status", + "command_source", + "target_scope", + "outcome", + "reason", + "rejection_reason", + "rejection_category", + "message", + "command_contract_source", + "command_contract_backfill_needed", + "command_contract_backfill_available", + "declared_signals", + "signal_admission", + "retryable", + "error_id" + ], + "rejection_reasons": [ + "instance_not_found", + "historical_run_command_rejected", + "unknown_signal", + "invalid_signal_arguments", + "run_not_active", + "instance_not_started", + "selected_run_not_current", + "configured_workflow_type_invalid", + "v1_projection_read_only", + "backend_lock_pressure", + "control_plane_internal_error" + ] + }, + "operation_name": "approve", + "operation_name_field": "signal_name", + "run_id": "@v", + "workflow_type": "parity.v1.echo", + "command_id": "@v", + "command_status": "rejected", + "command_source": "control_plane", + "target_scope": "instance", + "outcome": "rejected_not_active", + "reason": "run_not_active", + "rejection_reason": "run_not_active", + "validation_errors": [] + } + } + }, + { + "request": "POST workflows/@workflow/signal/__durable_workflow_message_stream", + "status": 422, + "response": { + "workflow_id": "@workflow", + "signal_name": "__durable_workflow_message_stream", + "outcome": "rejected", + "reason": "runtime_reserved_signal", + "message": "This signal name is reserved for the durable runtime transport.", + "accepted": false, + "control_plane": { + "schema": "durable-workflow.v2.control-plane-response", + "version": 1, + "operation": "signal", + "workflow_id": "@workflow", + "contract": { + "schema": "durable-workflow.v2.control-plane-response.contract", + "version": 1, + "legacy_field_policy": "reject_non_canonical", + "legacy_fields": { + "query": "query_name", + "signal": "signal_name", + "update": "update_name", + "wait_policy": "wait_for" + }, + "required_fields": [ + "workflow_id", + "operation_name", + "operation_name_field" + ], + "success_fields": [ + "outcome" + ], + "rejection_fields": [ + "workflow_id", + "run_id", + "signal_name", + "command_status", + "command_source", + "target_scope", + "outcome", + "reason", + "rejection_reason", + "rejection_category", + "message", + "command_contract_source", + "command_contract_backfill_needed", + "command_contract_backfill_available", + "declared_signals", + "signal_admission", + "retryable", + "error_id" + ], + "rejection_reasons": [ + "instance_not_found", + "historical_run_command_rejected", + "unknown_signal", + "invalid_signal_arguments", + "run_not_active", + "instance_not_started", + "selected_run_not_current", + "configured_workflow_type_invalid", + "v1_projection_read_only", + "backend_lock_pressure", + "control_plane_internal_error" + ] + }, + "operation_name": "__durable_workflow_message_stream", + "operation_name_field": "signal_name", + "outcome": "rejected", + "reason": "runtime_reserved_signal", + "message": "This signal name is reserved for the durable runtime transport." + } + } + }, + { + "request": "POST workflows/missing-before-worker/signal/__durable_workflow_message_stream", + "status": 404, + "response": { + "message": "Workflow not found.", + "reason": "instance_not_found", + "workflow_id": "missing-before-worker", + "control_plane": { + "schema": "durable-workflow.v2.control-plane-response", + "version": 1, + "operation": "signal", + "workflow_id": "missing-before-worker", + "contract": { + "schema": "durable-workflow.v2.control-plane-response.contract", + "version": 1, + "legacy_field_policy": "reject_non_canonical", + "legacy_fields": { + "query": "query_name", + "signal": "signal_name", + "update": "update_name", + "wait_policy": "wait_for" + }, + "required_fields": [ + "workflow_id", + "operation_name", + "operation_name_field" + ], + "success_fields": [ + "outcome" + ], + "rejection_fields": [ + "workflow_id", + "run_id", + "signal_name", + "command_status", + "command_source", + "target_scope", + "outcome", + "reason", + "rejection_reason", + "rejection_category", + "message", + "command_contract_source", + "command_contract_backfill_needed", + "command_contract_backfill_available", + "declared_signals", + "signal_admission", + "retryable", + "error_id" + ], + "rejection_reasons": [ + "instance_not_found", + "historical_run_command_rejected", + "unknown_signal", + "invalid_signal_arguments", + "run_not_active", + "instance_not_started", + "selected_run_not_current", + "configured_workflow_type_invalid", + "v1_projection_read_only", + "backend_lock_pressure", + "control_plane_internal_error" + ] + }, + "operation_name": "__durable_workflow_message_stream", + "operation_name_field": "signal_name", + "reason": "instance_not_found", + "message": "Workflow not found." + } + } + } +] diff --git a/tests/Unit/ServerParitySignalsBeforeWorkerContractTest.mjs b/tests/Unit/ServerParitySignalsBeforeWorkerContractTest.mjs new file mode 100644 index 000000000..7880c7321 --- /dev/null +++ b/tests/Unit/ServerParitySignalsBeforeWorkerContractTest.mjs @@ -0,0 +1,48 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import {checkSignalsBeforeWorker} from '../../scripts/conformance/server-parity/signals-before-worker-contract.mjs'; + +const fixture = JSON.parse(readFileSync(new URL('../Fixtures/ServerParity/signals-before-worker.json', import.meta.url))); +const expected = JSON.parse(readFileSync(new URL('../Fixtures/ServerParityExpected/signals-before-worker.json', import.meta.url))); +const workflowId = 'TEST-signals-before-worker'; +const workflow = 'test-signals-before-worker-before-worker'; +const namespace = 'test-signals-before-worker-unregistered'; +const queue = 'test-signals-before-worker-unserved'; +const EVENTS = ['StartAccepted', 'WorkflowStarted', 'SignalReceived', 'SignalReceived', 'SignalReceived', 'SignalReceived', + 'TerminateRequested', 'WorkflowTerminated']; +function model() { + // Requests are recorded as the fixture's templates; responses carry the run's names. + const steps = expected.map(step => ({...step, response: JSON.parse(JSON.stringify(step.response) + .replaceAll('@workflow', workflow).replaceAll('@namespace', namespace).replaceAll('@queue', queue))})); + return {mode: 'http', signals_before_worker: {applicable: true, namespace, queue, workflow_id: workflow, + run_id: '01M5EJXRUNIDENTIFIER00000A', steps: [ + {request: 'POST workflows', status: 201, response: {workflow_id: workflow, outcome: 'started_new'}}, + ...steps, + {request: 'GET workflows/@workflow/runs/@run/history', status: 200, + response: {events: EVENTS.map(event_type => ({event_type}))}}, + ]}}; +} +const projection = {scope: 'signals_before_worker'}; + +test('published PHP responses pass', () => assert.deepStrictEqual(checkSignalsBeforeWorker(fixture, model(), workflowId), projection)); +test('embedded explicitly declares the case inapplicable', () => assert.deepStrictEqual(checkSignalsBeforeWorker(fixture, + {mode: 'embedded', signals_before_worker: {applicable: false, reason: 'embedded_runs_have_a_loadable_contract'}}, workflowId), + projection)); +const step = (o, index) => o.signals_before_worker.steps[index]; +for (const [label, mutate] of [ + ['another namespace', o => { o.signals_before_worker.namespace = 'default'; }], + ['a signal rejected for an unrecorded contract', o => { step(o, 1).status = 404; step(o, 1).response.reason = 'unknown_signal'; }], + ['a signal without input refused', o => { step(o, 2).status = 422; }], + ['a reserved signal recorded as a command', o => { step(o, 5).response.command_id = '01M5EJXCOMMANDIDENTIFIER0A'; }], + ['a reserved signal admitted', o => { step(o, 5).status = 202; }], + ['a terminated run that admits a signal', o => { step(o, 7).status = 202; }], + ['a lost signal in history', o => { step(o, 10).response.events.splice(2, 1); }], + ['a terminate response without its envelope', o => { delete step(o, 6).response.control_plane; }], +]) { + test(`rejects ${label}`, () => { + const observation = model(); + mutate(observation); + assert.throws(() => checkSignalsBeforeWorker(fixture, observation, workflowId)); + }); +}