diff --git a/.github/workflows/phpunit-feature.yml b/.github/workflows/phpunit-feature.yml index ccfc38983..913da8e59 100644 --- a/.github/workflows/phpunit-feature.yml +++ b/.github/workflows/phpunit-feature.yml @@ -353,7 +353,7 @@ jobs: -w /app \ --entrypoint sh \ durable-workflow-server-corpus-validator \ - -lc 'set -eu; tar --no-same-owner -xf - -C /app; cp -a /app/workflow-package /workflow; php scripts/ci/prepare-release-workflow-composer-metadata.php; composer update durable-workflow/workflow --with-dependencies --minimal-changes --no-install --no-scripts --no-autoloader --no-interaction --no-progress --prefer-dist; composer install --no-interaction --no-progress --prefer-dist; git --version; vendor/bin/phpunit --version; git rev-parse --verify "$CORPUS_BASE_REF^{commit}"; php scripts/ci/check-worker-openapi-evolution.php "$CORPUS_BASE_REF"; vendor/bin/phpunit tests/Feature tests/Unit/NexusContractTest.php tests/Unit/CodecRegressionCorpusTest.php tests/Unit/PhpunitFeatureWorkflowContractTest.php tests/Unit/OpenApiDocumentEvolutionTest.php tests/Unit/NamespaceConformanceRunnerContractTest.php tests/Unit/NamespaceRuntimeContractTest.php tests/Unit/SchedulesConformanceRunnerContractTest.php tests/Unit/SchedulesRuntimeContractTest.php tests/Unit/ServerParitySdkDirectionResponseSummaryTest.php tests/Unit/ServerParitySdkQueryTransportTest.php tests/Unit/ServerParitySdkUpdateTransportTest.php tests/Unit/ServerParityLockPressureRetryTest.php --no-progress --colors=never; vendor/bin/phpunit tests/Unit/ReleaseImageImmutabilityGuardTest.php --no-progress --colors=never; vendor/bin/phpunit tests/Unit/ReleaseImagePublishWorkflowContractTest.php --filter test_php_security_audits_fail_before_release_publication --no-progress --colors=never; python3 scripts/ci/validate-regression-corpus.py --base-ref "$CORPUS_BASE_REF" --verify-counterfactual' + -lc 'set -eu; tar --no-same-owner -xf - -C /app; cp -a /app/workflow-package /workflow; php scripts/ci/prepare-release-workflow-composer-metadata.php; composer update durable-workflow/workflow --with-dependencies --minimal-changes --no-install --no-scripts --no-autoloader --no-interaction --no-progress --prefer-dist; composer install --no-interaction --no-progress --prefer-dist; git --version; vendor/bin/phpunit --version; git rev-parse --verify "$CORPUS_BASE_REF^{commit}"; php scripts/ci/check-worker-openapi-evolution.php "$CORPUS_BASE_REF"; vendor/bin/phpunit tests/Feature tests/Unit/NexusContractTest.php tests/Unit/CodecRegressionCorpusTest.php tests/Unit/PhpunitFeatureWorkflowContractTest.php tests/Unit/OpenApiDocumentEvolutionTest.php tests/Unit/NamespaceConformanceRunnerContractTest.php tests/Unit/NamespaceRuntimeContractTest.php tests/Unit/SchedulesConformanceRunnerContractTest.php tests/Unit/SchedulesRuntimeContractTest.php tests/Unit/ServerParitySdkDirectionResponseSummaryTest.php tests/Unit/ServerParitySdkQueryTransportTest.php tests/Unit/ServerParitySdkUpdateTransportTest.php tests/Unit/ServerParityLockPressureRetryTest.php tests/Unit/ServerParityManyUpdatesRetryTest.php --no-progress --colors=never; vendor/bin/phpunit tests/Unit/ReleaseImageImmutabilityGuardTest.php --no-progress --colors=never; vendor/bin/phpunit tests/Unit/ReleaseImagePublishWorkflowContractTest.php --filter test_php_security_audits_fail_before_release_publication --no-progress --colors=never; python3 scripts/ci/validate-regression-corpus.py --base-ref "$CORPUS_BASE_REF" --verify-counterfactual' - name: Select production image qualification for changed build inputs id: image_inputs diff --git a/.github/workflows/server-parity.yml b/.github/workflows/server-parity.yml index b992a9218..076be942a 100644 --- a/.github/workflows/server-parity.yml +++ b/.github/workflows/server-parity.yml @@ -31,6 +31,7 @@ on: - tests/Fixtures/ServerParityNormalization/** - tests/Unit/ServerParityRunnerTest.mjs - tests/Unit/ServerParityRunnerBlockedExcerptTest.mjs + - tests/Unit/ServerParityProbeNotesTest.mjs - tests/Unit/ServerParitySdkDirectionLanesTest.mjs - tests/Unit/ServerParityCodecDeadlineCellsTest.mjs - tests/Unit/ServerParityArtifactSummaryTest.mjs @@ -61,6 +62,7 @@ on: - tests/Unit/ServerParityConcurrentWaitsContractTest.mjs - tests/Unit/ServerParityContinueTransfersContractTest.mjs - tests/Unit/ServerParityLargeCommandBatchesContractTest.mjs + - tests/Unit/ServerParityManyUpdatesContractTest.mjs - tests/Unit/ServerParityStartSearchAttributesContractTest.mjs - tests/Unit/ServerParityQueryArgumentsContractTest.mjs - tests/Unit/ServerParityQueryContractArgumentsContractTest.mjs @@ -1987,7 +1989,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/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/ServerParityLargeCommandBatchesContractTest.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/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-many-updates.md b/docs/rust-server-port-checkpoints/2026-10-29-many-updates.md new file mode 100644 index 000000000..0cf7dc484 --- /dev/null +++ b/docs/rust-server-port-checkpoints/2026-10-29-many-updates.md @@ -0,0 +1,34 @@ +# October 29, descriptions after more than 64 updates + +- **Before.** The October 23 change removed native's 64-update admission + cap ([update lifetime cap](2026-10-23-update-lifetime-cap.md)). The + update summary that describes a run's waits still stopped at 64 updates + (`update_summary.rs`): it read every update wait over the run's life, + `LIMIT 65`, and gave up past 64. A run that had taken 65 updates was + described with no wait (`wait_kind` null), though its signal wait was + still open. +- **Contract** (published 2.11.5; `WorkflowController::show`, Workflow 2.15.1 + `RunWaitView`, `StructuralLimits::pendingUpdateCountLimit`): + - only pending updates are bounded (500 by default); + - a run is described by its open waits, however many updates it has + taken; + - an accepted update that is still open is the run's wait + ("Waiting for update set"). +- **Fixture.** `many-updates`, raw worker HTTP on protocol 1.20 in a fresh + namespace. A run opens a signal wait and takes 65 updates. Each is + accepted (202), delivered on its own workflow task and completed (200, + run waiting). The probe records: + - the description before and after the updates (`signal`); + - with a 66th update left accepted (`update`); + - the history counts (66 `UpdateAccepted`, 65 `UpdateCompleted`). + + The worker heartbeats every 20 seconds, since the loop outlasts the + worker staleness window. The run is terminated after recording, so its + never-polled update task does not stay for later fixtures. The probe declares a 180-second budget; locally it took + about 80 seconds against published PHP. +- **Native.** The summary keeps every accepted update and reads every + update wait. Only one open update at a time is described, as before. +- **Not covered here.** The child summary still stops at 100 children + over a run's life (`child_summary.rs`); that needs its own fixture. +- **Tests.** `many_updates.rs` fails on the previous source (`wait_kind` + null after 65 updates) and passes now. diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index 7c045ddcd..fc0a49070 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -578,6 +578,16 @@ contract fixture before its correction. 150 timers, 150 activities, a 150-member group and a 1,001-member group against published 2.11.5 ([checkpoint](rust-server-port-checkpoints/2026-10-29-large-command-batches.md)). +- **Descriptions after more than 64 updates** (October 29, closed). + Published PHP bounds only pending updates, not how many a run takes over + its life. The October 23 change removed native's 64-update admission cap, + but its update summary still gave up past 64 updates. A run that had + taken 65 was described with no wait. The bound is removed. The + `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. - **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 @@ -667,9 +677,6 @@ contract fixture before its correction. `none` driver or database runtime credentials, which published PHP does not. Without a tenant-bound administrator, native does not read a namespace route's target from a JSON body `name`. -- **Described command previews.** Published PHP's run description gives - each command `payload_preview_omitted` and, for a cancel or terminate, its - decoded `reason`. Native's described commands carry neither. - **Command route names.** Published PHP records each request's route name in its command context, and its unnamed workflow routes get a name generated per deployment. Native records names only for the named schedule routes. diff --git a/rust/src/runtime/update_summary.rs b/rust/src/runtime/update_summary.rs index fe001f9fc..691b86d81 100644 --- a/rust/src/runtime/update_summary.rs +++ b/rust/src/runtime/update_summary.rs @@ -54,8 +54,9 @@ impl UpdateSummaryHistory { return false; } if kind == "UpdateAccepted" { - if self.updates.len() >= 64 - || self.updates.contains_key(id) + // Published PHP bounds only pending updates, not how many a + // run takes over its life, so every accepted update is kept. + if self.updates.contains_key(id) // A run continued from a predecessor takes its pending update // without an ordering state, ready for delivery. || payload.get("ordering_state").is_some_and(|state| state != "ready") @@ -132,9 +133,9 @@ where history: &UpdateSummaryHistory, summary: &mut ActiveSummary, ) -> Result { - let rows = Self::query("SELECT w.*,u.id AS update_id,u.workflow_command_id,u.workflow_instance_id AS update_instance,u.update_name,u.status AS update_status,u.command_sequence AS update_command_sequence,u.workflow_sequence,c.id AS matched_command_id,c.command_sequence AS matched_command_sequence FROM workflow_run_waits w LEFT JOIN workflow_updates u ON u.id=w.resume_source_id AND u.workflow_run_id=w.workflow_run_id LEFT JOIN workflow_commands c ON c.id=u.workflow_command_id AND c.workflow_run_id=w.workflow_run_id WHERE w.workflow_run_id=$1 AND w.kind='update' ORDER BY w.command_sequence,w.wait_id LIMIT 65") + let rows = Self::query("SELECT w.*,u.id AS update_id,u.workflow_command_id,u.workflow_instance_id AS update_instance,u.update_name,u.status AS update_status,u.command_sequence AS update_command_sequence,u.workflow_sequence,c.id AS matched_command_id,c.command_sequence AS matched_command_sequence FROM workflow_run_waits w LEFT JOIN workflow_updates u ON u.id=w.resume_source_id AND u.workflow_run_id=w.workflow_run_id LEFT JOIN workflow_commands c ON c.id=u.workflow_command_id AND c.workflow_run_id=w.workflow_run_id WHERE w.workflow_run_id=$1 AND w.kind='update' ORDER BY w.command_sequence,w.wait_id") .bind(run).fetch_all(&mut **tx).await?; - if rows.len() != history.updates.len() || rows.len() > 64 { + if rows.len() != history.updates.len() { return Ok(false); } let mut open = None; diff --git a/rust/tests/execution.rs b/rust/tests/execution.rs index 769a92b15..482ece65b 100644 --- a/rust/tests/execution.rs +++ b/rust/tests/execution.rs @@ -336,6 +336,8 @@ mod workflow_import_fences; #[path = "execution/large_command_batches.rs"] mod large_command_batches; +#[path = "execution/many_updates.rs"] +mod many_updates; #[path = "execution/query_argument_routing.rs"] mod query_argument_routing; diff --git a/rust/tests/execution/many_updates.rs b/rust/tests/execution/many_updates.rs new file mode 100644 index 000000000..81bcea0a3 --- /dev/null +++ b/rust/tests/execution/many_updates.rs @@ -0,0 +1,43 @@ +use super::*; + +// Published Server 2.11.5 bounds only pending updates +// (DW_V2_LIMIT_PENDING_UPDATES), not how many a run takes over its life, and +// describes a run by its open waits. The many-updates fixture records a run +// waiting for a signal through 65 completed updates, then a 66th left +// accepted. + +async fn describe(app: &Router) -> (Value, Value) { + let (_, described) = request(app, "GET", "/api/workflows/many-updates", Value::Null).await; + (described["wait_kind"].clone(), described["wait_reason"].clone()) +} + +#[tokio::test] +async fn a_run_is_described_by_its_waits_after_more_than_64_updates() { + let database = TestDatabase::new().await; + let runtime = database.open().await.unwrap(); + let app = router(runtime.clone()); + update_worker(&app, "update-worker").await; + start(&app, "many-updates").await; + open_update_wait(&app, "update-worker").await; + let path = "/api/workflows/many-updates/update/set"; + for index in 0..65 { + let (status, beat) = request(&app, "POST", "/api/worker/heartbeat", json!({"worker_id":"update-worker"})).await; + assert_eq!(status, StatusCode::OK, "{beat}"); + let receipt = request(&app, "POST", path, json!({"input":envelope(Payload::Array(vec![Payload::Long(index)])), + "request_id":format!("many:{index}"),"wait_for":"accepted"})).await; + assert_eq!(receipt.0, StatusCode::ACCEPTED, "{}", receipt.1); + let task = poll(&app, "update-worker", "workflow").await; + assert_eq!(task["workflow_update_id"], receipt.1["update_id"]); + let result = request(&app, "POST", &format!("/api/worker/workflow-tasks/{}/complete", task["task_id"].as_str().unwrap()), + completion(&task, json!([{"type":"complete_update","update_id":receipt.1["update_id"], + "result":envelope(Payload::Long(index))}]))).await; + assert_eq!(result.0, StatusCode::OK, "{}", result.1); + } + assert_eq!(describe(&app).await, (json!("signal"), json!("Waiting for signal finish"))); + let pending = request(&app, "POST", path, json!({"input":envelope(Payload::Array(vec![Payload::Long(65)])), + "request_id":"many:65","wait_for":"accepted"})).await; + assert_eq!(pending.0, StatusCode::ACCEPTED, "{}", pending.1); + assert_eq!(describe(&app).await, (json!("update"), json!("Waiting for update set"))); + runtime.close().await; + database.remove().await; +} diff --git a/scripts/conformance/server-parity.mjs b/scripts/conformance/server-parity.mjs index baa25fbd8..72929465a 100644 --- a/scripts/conformance/server-parity.mjs +++ b/scripts/conformance/server-parity.mjs @@ -13,7 +13,7 @@ import {parseDatabaseResourceLimits} from './server-parity/database-resource-con import {checkSdkChildDirectionArtifacts, checkSdkChildDirectionPackages} from './server-parity/sdk-child-direction-contract.mjs'; import {checkSdkTimerAuthorArtifacts, checkSdkTimerAuthorPackages} from './server-parity/sdk-timer-author-contract.mjs'; import {checkSdkSignalDirectionArtifacts, checkSdkSignalDirectionPackages} from './server-parity/sdk-signal-direction-contract.mjs'; -import {captureProbe} from './server-parity/probe-capture.mjs'; +import {captureProbe, probeNotes} from './server-parity/probe-capture.mjs'; import {selectFixturePaths} from './server-parity/fixture-selection.mjs'; import {writeRecord} from './server-parity/record-file.mjs'; import {checkCorrectedMarkerArtifacts, checkCorrectedMarkerPackages} from './server-parity/corrected-marker-contract.mjs'; @@ -105,10 +105,16 @@ try { for (const flag of ['url', 'application-root']) { if (values[flag]) arguments_.push(`--${flag}`, values[flag]); } - const probe = captureProbe('php', arguments_, {directory: dirname(resolve(values.output)), timeout: fixture.memo_replay ? fixture.memo_replay.overall_budget_seconds * 1000 + 5000 : fixture.corrected_markers ? fixture.corrected_markers.overall_budget_seconds * 1000 + 5000 : fixture.namespace_signal_pair || fixture.selected_run_signal_pair || fixture.cancellation_scopes ? 75000 : fixture.update_validation ? 90000 : fixture.worker_poll_fence ? fixture.worker_poll_fence.overall_budget_seconds * 1000 + 5000 : fixture.worker_sessions ? fixture.worker_sessions.overall_budget_seconds * 1000 + 5000 : fixture.worker_session_activities ? fixture.worker_session_activities.overall_budget_seconds * 1000 + 5000 : fixture.large_command_batches ? fixture.large_command_batches.overall_budget_seconds * 1000 + 5000 : 45000, + const probe = captureProbe('php', arguments_, {directory: dirname(resolve(values.output)), timeout: fixture.memo_replay ? fixture.memo_replay.overall_budget_seconds * 1000 + 5000 : fixture.corrected_markers ? fixture.corrected_markers.overall_budget_seconds * 1000 + 5000 : fixture.namespace_signal_pair || fixture.selected_run_signal_pair || fixture.cancellation_scopes ? 75000 : fixture.update_validation ? 90000 : fixture.worker_poll_fence ? fixture.worker_poll_fence.overall_budget_seconds * 1000 + 5000 : fixture.worker_sessions ? fixture.worker_sessions.overall_budget_seconds * 1000 + 5000 : fixture.worker_session_activities ? fixture.worker_session_activities.overall_budget_seconds * 1000 + 5000 : fixture.large_command_batches ? fixture.large_command_batches.overall_budget_seconds * 1000 + 5000 : fixture.many_updates ? fixture.many_updates.overall_budget_seconds * 1000 + 5000 : 45000, env: {...process.env, DW_PARITY_FAILURE_EVIDENCE_DIR: dirname(resolve(values.output))}, preserveStdout: join(sourceDirectory, name)}); const item = {fixture_id: fixture.id, fixture, outcome: 'runner-blocked'}; + // Recognized diagnostics are kept even when the probe succeeds. + const notes = probeNotes(probe.stderr); + if (notes.length > 0) { + item.probe_notes = notes; + for (const note of notes) console.error(`${fixture.id}: ${note}`); + } originals.set(item, {fixture: path, observation: probe.stdoutPath}); try { if (probe.error || probe.status !== 0) throw new Error(probe.error?.message ?? probe.stderr.trim()); diff --git a/scripts/conformance/server-parity/contract.mjs b/scripts/conformance/server-parity/contract.mjs index e3d86efa5..bd1985d57 100644 --- a/scripts/conformance/server-parity/contract.mjs +++ b/scripts/conformance/server-parity/contract.mjs @@ -16,6 +16,7 @@ import {checkActivityLeaseRenewal} from './activity-lease-renewal-contract.mjs'; import {checkConcurrentWaits} from './concurrent-waits-contract.mjs'; import {checkContinueTransfers} from './continue-transfers-contract.mjs'; import {checkLargeCommandBatches} from './large-command-batches-contract.mjs'; +import {checkManyUpdates} from './many-updates-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'; @@ -177,6 +178,7 @@ export function checkObservation(fixture, observation, workflowId, artifacts) { const concurrentWaits = fixture.concurrent_waits ? checkConcurrentWaits(fixture, observation) : null; const continueTransfers = fixture.continue_transfers ? checkContinueTransfers(fixture, observation) : null; const largeCommandBatches = fixture.large_command_batches ? checkLargeCommandBatches(fixture, observation) : null; + const manyUpdates = fixture.many_updates ? checkManyUpdates(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; @@ -756,6 +758,7 @@ export function checkObservation(fixture, observation, workflowId, artifacts) { ...(concurrentWaits ? {concurrent_waits: concurrentWaits} : {}), ...(continueTransfers ? {continue_transfers: continueTransfers} : {}), ...(largeCommandBatches ? {large_command_batches: largeCommandBatches} : {}), + ...(manyUpdates ? {many_updates: manyUpdates} : {}), ...(startSearchAttributes ? {start_search_attributes: startSearchAttributes} : {}), ...(queryArguments ? {query_argument_routing: queryArguments} : {}), ...(queryContractArguments ? {query_contract_arguments: queryContractArguments} : {}), diff --git a/scripts/conformance/server-parity/failure-excerpt.mjs b/scripts/conformance/server-parity/failure-excerpt.mjs index be561541b..dd8f8d737 100644 --- a/scripts/conformance/server-parity/failure-excerpt.mjs +++ b/scripts/conformance/server-parity/failure-excerpt.mjs @@ -45,6 +45,10 @@ function walk(value, path, out, limit) { } if (typeof value.method === 'string' && typeof value.path === 'string' && Object.hasOwn(value, 'status')) { out.push(line(path, value)); + } else if (typeof value.request === 'string' && /^[A-Z]+ \S+$/.test(value.request) && Number.isInteger(value.status)) { + // Older probes record a step as {request: 'METHOD path', status, response}; + // the label and status are kept, the response body is not. + out.push(`${path} ${value.request.slice(0, 200)} ${value.status}`); } const tasks = value.tables?.workflow_tasks?.rows; if (Array.isArray(tasks)) { diff --git a/scripts/conformance/server-parity/many-updates-contract.mjs b/scripts/conformance/server-parity/many-updates-contract.mjs new file mode 100644 index 000000000..f8f591966 --- /dev/null +++ b/scripts/conformance/server-parity/many-updates-contract.mjs @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; + +// Reviewed descriptions after more than 64 updates (many-updates-probe.php), +// recorded against published Server 2.11.5: a run waiting for a signal takes +// 65 updates, each accepted and completed by its own workflow task, and is +// still described as waiting for the signal; one more update left accepted +// is described as the run's open update. The fixture's `expected` holds the +// whole observation. +export function checkManyUpdates(fixture, observation) { + const definition = fixture.many_updates; + const state = observation.many_updates; + const projection = {scope: 'many_updates', steps: Object.keys(definition.expected).length}; + assert.ok(state && typeof state === 'object', 'actual many updates observation'); + if (observation.mode === 'embedded') { + assert.deepStrictEqual(state, {applicable: false, reason: 'embedded_has_no_worker_protocol'}, + 'embedded many updates explicitly inapplicable'); + return projection; + } + assert.equal(state.applicable, true, 'HTTP targets record many updates'); + for (const [step, expected] of Object.entries(definition.expected)) { + assert.deepStrictEqual(state[step], expected, `many updates ${step}`); + } + assert.deepStrictEqual(Object.keys(state).sort(), ['applicable', ...Object.keys(definition.expected)].sort(), + 'many updates steps'); + return projection; +} diff --git a/scripts/conformance/server-parity/many-updates-probe.php b/scripts/conformance/server-parity/many-updates-probe.php new file mode 100644 index 000000000..baa183edb --- /dev/null +++ b/scripts/conformance/server-parity/many-updates-probe.php @@ -0,0 +1,144 @@ +createNamespace($namespace, 'many updates λ', 30); + $token = getenv('DW_PARITY_TOKEN') ?: null; + $stack = HandlerStack::create(); + $stack->push(LockPressurePollRetries::middleware(), 'lock_pressure_poll_retry'); + $http = new ManyUpdatesHttp(['base_uri' => rtrim($url, '/').'/api/', 'http_errors' => false, + 'connect_timeout' => 1, 'timeout' => 60, '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)]; + }; + $type = $definition['workflow_type']; + $update = $definition['update']; + $empty = ['codec' => 'avro', 'blob' => $definition['empty_blob']]; + $id = $workflowId.'-mu'; + $queue = $id.'-queue'; + $worker = $id.'-worker'; + $capabilities = ['query_tasks', 'workflow_updates']; + $registered = $raw('POST', 'worker/register', ['worker_id' => $worker, 'task_queue' => $queue, 'runtime' => 'php', + 'sdk_version' => 'raw-http', 'supported_workflow_types' => [$type], 'supported_activity_types' => [], + 'workflow_command_contracts' => [$type => ['signals' => [$definition['signal']], 'queries' => [], 'updates' => [$update], 'update_validators' => [], + 'update_contracts' => [['name' => $update, 'parameters' => [['name' => 'value', 'required' => true, 'type' => 'array']]]]]], + 'capabilities' => $capabilities, 'capability_manifest' => CapabilityManifest::forCapabilities($capabilities)]); + $started = $raw('POST', 'workflows', ['workflow_id' => $id, 'workflow_type' => $type, 'task_queue' => $queue, + 'input' => $empty]); + if ($registered['status'] !== 201 || $started['status'] !== 201) { + throw new RuntimeException('Many updates setup: '.json_encode([$registered, $started])); + } + $poll = static function (mixed $context = null) use ($raw, $worker, $queue): array { + $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('Many updates: no workflow task was ready after '.json_encode($context)); + } + + return $task; + }; + $complete = static fn (array $task, array $commands): array => $raw('POST', + 'worker/workflow-tasks/'.$task['task_id'].'/complete', ['lease_owner' => $task['lease_owner'], + 'workflow_task_attempt' => $task['workflow_task_attempt'], 'commands' => $commands]); + $describe = static fn (): array => array_intersect_key($raw('GET', 'workflows/'.rawurlencode($id))['response'], + array_flip($definition['described_fields'])); + $submit = static fn (int $index): array => manyUpdatesSubmit($raw, 'workflows/'.rawurlencode($id).'/update/'.$update, + ['input' => [['v' => $index]], 'request_id' => 'many-'.$index, 'wait_for' => 'accepted'], $index); + + $opened = $complete($poll(), [['type' => 'open_signal_wait', 'signal_name' => $definition['signal']]]); + $state = ['applicable' => true, 'opened' => $opened['status'], 'before' => $describe()]; + $answers = []; + $lastBeat = microtime(true); + for ($index = 0; $index < $definition['completed_updates']; $index++) { + // The worker stays active across the loop, which can outlast its + // staleness window; a heartbeat every 20 seconds stays well inside it + // however slow each round trip is. + if (microtime(true) - $lastBeat >= 20) { + $beat = $raw('POST', 'worker/heartbeat', ['worker_id' => $worker]); + if ($beat['status'] !== 200) { + throw new RuntimeException('Many updates heartbeat: '.json_encode($beat)); + } + $lastBeat = microtime(true); + } + $accepted = $submit($index); + $task = $poll([$index, $accepted]); + $completed = $complete($task, [['type' => 'complete_update', 'update_id' => $accepted['response']['update_id'] ?? null, + 'result' => $empty]]); + $answer = implode(' ', [$accepted['status'], $accepted['response']['update_status'] ?? 'none', + ($task['workflow_update_id'] ?? null) === ($accepted['response']['update_id'] ?? false) ? 'own_task' : 'other_task', + $completed['status'], $completed['response']['run_status'] ?? 'none']); + $answers[$answer] = ($answers[$answer] ?? 0) + 1; + } + $state['updates'] = $answers; + $state['after'] = $describe(); + $pending = $submit($definition['completed_updates']); + $state['pending'] = ['status' => $pending['status'], 'update_status' => $pending['response']['update_status'] ?? null, + 'described' => $describe()]; + $counts = []; + $page = $raw('GET', 'workflows/'.rawurlencode($id).'/runs/'.$started['response']['run_id'].'/history?page_size=1000')['response']; + foreach ($page['events'] ?? [] as $event) { + $counts[$event['event_type']] = ($counts[$event['event_type']] ?? 0) + 1; + } + ksort($counts); + $state['events'] = $counts; + // The accepted update's workflow task is never polled; end the run so it + // does not stay for the maintenance passes of later fixtures. + $terminated = $raw('POST', 'workflows/'.rawurlencode($id).'/terminate', ['reason' => 'many updates recorded']); + if (! in_array($terminated['status'], [200, 202], true)) { + throw new RuntimeException('Many updates cleanup: '.json_encode($terminated)); + } + + return $state; +} + +/** + * Sends one update submission. On MySQL-family databases published PHP's + * update admission can deadlock against its own maintenance pass and answer + * an uncaught 500. The body carries a request id, so the identical + * submission is sent again, at most three times in all. Each retry is noted + * on stderr by index, attempt and status only; a third 500 is returned. + * + * @param callable(string, string, array): array{status: int, response: mixed} $raw + * @param array $body + * @param (callable(int): void)|null $sleep + * @return array{status: int, response: mixed} + */ +function manyUpdatesSubmit(callable $raw, string $path, array $body, int $index, ?callable $sleep = null): array +{ + $sleep ??= static fn (int $microseconds) => usleep($microseconds); + for ($attempt = 1; ; $attempt++) { + $answer = $raw('POST', $path, $body); + if ($answer['status'] !== 500 || $attempt === 3) { + return $answer; + } + fwrite(STDERR, json_encode(['many_updates_submit_retry' => ['index' => $index, 'attempt' => $attempt, + 'status' => $answer['status']]], JSON_THROW_ON_ERROR).PHP_EOL); + $sleep(200000 * $attempt); + } +} diff --git a/scripts/conformance/server-parity/probe-capture.mjs b/scripts/conformance/server-parity/probe-capture.mjs index 0d516e6b3..8830852fa 100644 --- a/scripts/conformance/server-parity/probe-capture.mjs +++ b/scripts/conformance/server-parity/probe-capture.mjs @@ -29,3 +29,12 @@ export function captureProbe(command, args, {directory, env, timeout = 45000, pr rmSync(capture, {recursive: true, force: true}); } } + +// The probe diagnostics a record keeps whatever the probe's exit status: +// only lines of these exact, value-free shapes. Other stderr is not kept. +const NOTE_SHAPES = [/^\{"many_updates_submit_retry":\{"index":\d{1,6},"attempt":\d,"status":\d{3}\}\}$/]; + +export function probeNotes(stderr, limit = 20) { + return String(stderr ?? '').split('\n').map(line => line.trim()) + .filter(line => NOTE_SHAPES.some(shape => shape.test(line))).slice(0, limit); +} diff --git a/scripts/conformance/server-parity/probe.php b/scripts/conformance/server-parity/probe.php index 4fbbaa6fc..2659a4b5c 100644 --- a/scripts/conformance/server-parity/probe.php +++ b/scripts/conformance/server-parity/probe.php @@ -434,6 +434,7 @@ function httpObservation(array $fixture, string $workflowId, string $namespace, $concurrentWaits = isset($fixture['concurrent_waits']) ? finishHttpConcurrentWaits($client, $fixture, $workflowId, $url) : null; $continueTransfers = isset($fixture['continue_transfers']) ? finishHttpContinueTransfers($client, $fixture, $workflowId, $url) : null; $largeCommandBatches = isset($fixture['large_command_batches']) ? finishHttpLargeCommandBatches($client, $fixture, $workflowId, $url) : null; + $manyUpdates = isset($fixture['many_updates']) ? finishHttpManyUpdates($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; @@ -566,6 +567,7 @@ function httpObservation(array $fixture, string $workflowId, string $namespace, 'concurrent_waits' => $concurrentWaits, 'continue_transfers' => $continueTransfers, 'large_command_batches' => $largeCommandBatches, + 'many_updates' => $manyUpdates, 'start_search_attributes' => $startSearchAttributes, 'query_argument_routing' => $queryArgumentRouting, 'query_contract_arguments' => $queryContractArguments, @@ -901,6 +903,7 @@ function embeddedObservation(array $fixture, string $workflowId, string $namespa require __DIR__.'/concurrent-waits-probe.php'; require __DIR__.'/continue-transfers-probe.php'; require __DIR__.'/large-command-batches-probe.php'; + require __DIR__.'/many-updates-probe.php'; require __DIR__.'/start-search-attributes-probe.php'; require __DIR__.'/query-arguments-probe.php'; require __DIR__.'/query-contract-arguments-probe.php'; @@ -1096,6 +1099,9 @@ function embeddedObservation(array $fixture, string $workflowId, string $namespa if ($mode === 'embedded' && isset($fixture['large_command_batches'])) { $observation['large_command_batches'] = ['applicable' => false, 'reason' => 'embedded_has_no_worker_protocol']; } + if ($mode === 'embedded' && isset($fixture['many_updates'])) { + $observation['many_updates'] = ['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/many-updates.json b/tests/Fixtures/ServerParity/many-updates.json new file mode 100644 index 000000000..4828a48a4 --- /dev/null +++ b/tests/Fixtures/ServerParity/many-updates.json @@ -0,0 +1,98 @@ +{ + "schema": "durable-workflow.server-parity-fixture/v1", + "id": "many-updates", + "review_state": "reviewed", + "contract": "A run's description after more than 64 updates (published Server 2.11.5 WorkflowController describe with Workflow 2.15.1 RunWaitView), over raw worker HTTP on protocol 1.20 in a fresh namespace. Published PHP bounds only pending updates (DW_V2_LIMIT_PENDING_UPDATES, 500 by default), not how many a run takes over its life, and describes a run by its open waits. A run waiting for a signal takes 65 updates, each accepted and completed by its workflow task, and is still described as waiting for that signal. A 66th update left accepted is described as the run's open update. Embedded applications have no worker protocol.", + "workflow_type": "parity.v1.echo", + "input": { + "message": "large command batches λ", + "count": 9007199254740993, + "optional": null + }, + "typed_value": { + "type": "map", + "value": { + "message": { + "type": "string", + "value": "large command batches λ" + }, + "count": { + "type": "int64", + "value": "9007199254740993" + }, + "optional": { + "type": "null", + "value": null + } + } + }, + "activity": false, + "expected_events": [ + "StartAccepted", + "WorkflowStarted", + "WorkflowCompleted" + ], + "many_updates": { + "workflow_type": "parity.v1.many_updates", + "update": "set", + "signal": "finish", + "completed_updates": 65, + "empty_blob": "wwHioz3/VYAiNwwA", + "described_fields": [ + "status", + "status_bucket", + "wait_kind", + "wait_reason", + "is_terminal", + "resume_step_sequence", + "recovery_kind" + ], + "expected": { + "opened": 200, + "before": { + "status": "waiting", + "status_bucket": "running", + "is_terminal": false, + "resume_step_sequence": null, + "recovery_kind": null, + "wait_kind": "signal", + "wait_reason": "Waiting for signal finish" + }, + "updates": { + "202 accepted own_task 200 waiting": 65 + }, + "after": { + "status": "waiting", + "status_bucket": "running", + "is_terminal": false, + "resume_step_sequence": null, + "recovery_kind": null, + "wait_kind": "signal", + "wait_reason": "Waiting for signal finish" + }, + "pending": { + "status": 202, + "update_status": "accepted", + "described": { + "status": "waiting", + "status_bucket": "running", + "is_terminal": false, + "resume_step_sequence": null, + "recovery_kind": null, + "wait_kind": "update", + "wait_reason": "Waiting for update set" + } + }, + "events": { + "MessageCursorAdvanced": 65, + "SignalWaitOpened": 1, + "StartAccepted": 1, + "UpdateAccepted": 66, + "UpdateApplied": 65, + "UpdateCompleted": 65, + "WorkflowStarted": 1 + } + }, + "overall_budget_seconds": 180 + } +} diff --git a/tests/Unit/ServerParityFailureExcerptTest.mjs b/tests/Unit/ServerParityFailureExcerptTest.mjs index 5fbe8cce1..e2a5abd07 100644 --- a/tests/Unit/ServerParityFailureExcerptTest.mjs +++ b/tests/Unit/ServerParityFailureExcerptTest.mjs @@ -51,3 +51,17 @@ test('stored workflow tasks are listed by counters and clocks, never payloads or ''].join('\n')); assert(!text.includes('payload-bytes') && !text.includes('error-text') && !text.includes('owner-name') && !text.includes('/not-a-request')); }); + +test('labelled steps keep their request label and status, never their responses', () => { + const text = failureExcerpt([{fixture_id: 'signals-before-worker', outcome: 'product-fail', error: 'step statuses', + observation: {signals_before_worker: {steps: [ + {request: 'POST /api/workflows', status: 201, response: {workflow_id: 'secret-run'}}, + {request: 'POST /api/workflows/w/signal/s', status: 500, response: {message: 'Server Error', token: 'secret-token'}}, + {request: 'not a label', status: 200}, {request: 'GET /api/x', status: '200'}]}}}]); + assert.equal(text, [ + '== signals-before-worker (product-fail): step statuses', + 'observation.signals_before_worker.steps[0] POST /api/workflows 201', + 'observation.signals_before_worker.steps[1] POST /api/workflows/w/signal/s 500', + ''].join('\n')); + assert(!text.includes('secret-run') && !text.includes('secret-token') && !text.includes('Server Error')); +}); diff --git a/tests/Unit/ServerParityManyUpdatesContractTest.mjs b/tests/Unit/ServerParityManyUpdatesContractTest.mjs new file mode 100644 index 000000000..8046e3607 --- /dev/null +++ b/tests/Unit/ServerParityManyUpdatesContractTest.mjs @@ -0,0 +1,40 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import {checkManyUpdates} from '../../scripts/conformance/server-parity/many-updates-contract.mjs'; + +const fixture = JSON.parse(readFileSync(new URL('../Fixtures/ServerParity/many-updates.json', import.meta.url), 'utf8')); +const expected = fixture.many_updates.expected; +const observation = () => structuredClone({mode: 'http', many_updates: {applicable: true, ...expected}}); +const steps = Object.keys(expected).length; + +test('the published answers pass', () => { + assert.deepStrictEqual(checkManyUpdates(fixture, observation()), {scope: 'many_updates', steps}); +}); + +test('embedded applications have no worker protocol', () => { + assert.deepStrictEqual(checkManyUpdates(fixture, + {mode: 'embedded', many_updates: {applicable: false, reason: 'embedded_has_no_worker_protocol'}}), + {scope: 'many_updates', steps}); + assert.throws(() => checkManyUpdates(fixture, {mode: 'embedded', many_updates: {applicable: true}})); +}); + +test('65 completed updates leave the signal wait, and a 66th is the open update', () => { + assert.deepStrictEqual(expected.updates, {'202 accepted own_task 200 waiting': 65}); + assert.deepStrictEqual([expected.after.wait_kind, expected.pending.described.wait_kind], ['signal', 'update']); + assert.deepStrictEqual([expected.events.UpdateAccepted, expected.events.UpdateCompleted], [66, 65]); +}); + +for (const [name, mutate] of [ + ['a run described without its wait after 64 updates', state => { + state.after.wait_kind = null; state.after.wait_reason = null; + }], + ['a pending update that is not the open wait', state => { state.pending.described.wait_kind = 'signal'; }], + ['a refused 65th update', state => { + state.updates = {'202 accepted own_task 200 waiting': 64, '503 none other_task 404 none': 1}; + }], + ['an unexpected step', state => { state.extra = {}; }], +]) test(`the contract rejects ${name}`, () => { + const raw = observation(); mutate(raw.many_updates); + assert.throws(() => checkManyUpdates(fixture, raw)); +}); diff --git a/tests/Unit/ServerParityManyUpdatesRetryTest.php b/tests/Unit/ServerParityManyUpdatesRetryTest.php new file mode 100644 index 000000000..b26da6881 --- /dev/null +++ b/tests/Unit/ServerParityManyUpdatesRetryTest.php @@ -0,0 +1,64 @@ + [['v' => 7]], 'request_id' => 'many-7', 'wait_for' => 'accepted']; + + /** + * Sends with answers in order; returns the answer and every request sent. + * + * @param list $statuses + * @return array{0: array{status: int, response: mixed}, 1: list}>, 2: list} + */ + private function submit(array $statuses): array + { + $sent = []; + $slept = []; + $raw = function (string $method, string $path, array $body) use (&$sent, &$statuses): array { + $sent[] = [$method, $path, $body]; + + return ['status' => array_shift($statuses), 'response' => ['message' => 'Server Error']]; + }; + $answer = \manyUpdatesSubmit($raw, 'workflows/w/update/set', self::BODY, 7, + function (int $microseconds) use (&$slept): void { + $slept[] = $microseconds; + }); + + return [$answer, $sent, $slept]; + } + + public function test_a_500_is_sent_again_with_the_same_request_id(): void + { + [$answer, $sent, $slept] = $this->submit([500, 500, 202]); + + self::assertSame(202, $answer['status']); + self::assertCount(3, $sent); + foreach ($sent as $request) { + self::assertSame(['POST', 'workflows/w/update/set', self::BODY], $request); + } + self::assertSame([200000, 400000], $slept); + } + + public function test_other_statuses_return_at_once(): void + { + foreach ([202, 409, 422, 503] as $status) { + [$answer, $sent, $slept] = $this->submit([$status, 202]); + self::assertSame([$status, 1, []], [$answer['status'], count($sent), $slept], "status {$status}"); + } + } + + public function test_a_third_500_is_returned(): void + { + [$answer, $sent] = $this->submit([500, 500, 500, 202]); + + self::assertSame([500, 3], [$answer['status'], count($sent)]); + } +} diff --git a/tests/Unit/ServerParityProbeNotesTest.mjs b/tests/Unit/ServerParityProbeNotesTest.mjs new file mode 100644 index 000000000..fc8139a49 --- /dev/null +++ b/tests/Unit/ServerParityProbeNotesTest.mjs @@ -0,0 +1,41 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {spawnSync} from 'node:child_process'; +import {chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {delimiter, join} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {probeNotes} from '../../scripts/conformance/server-parity/probe-capture.mjs'; + +const runner = fileURLToPath(new URL('../../scripts/conformance/server-parity.mjs', import.meta.url)); +const fixture = fileURLToPath(new URL('../Fixtures/ServerParity/many-updates.json', import.meta.url)); +const note = '{"many_updates_submit_retry":{"index":15,"attempt":1,"status":500}}'; + +test('only the recognized value-free notes are kept', () => { + assert.deepStrictEqual(probeNotes(`noise\n${note}\n{"many_updates_submit_retry":{"index":1,"attempt":1,"status":500,"body":"x"}}\n` + + `{"other":1}\n ${note} \n`), [note, note]); + assert.deepStrictEqual(probeNotes(Array(30).fill(note).join('\n')).length, 20); + assert.deepStrictEqual(probeNotes(undefined), []); +}); + +// A probe that exits 0 after a recovered retry: the recorder keeps its note +// on the case and in its own stderr, and nothing else from the probe's. +test('a successful probe keeps its retry note in the record and the recorder log', () => { + const directory = mkdtempSync(join(tmpdir(), 'dw-probe-notes-')); + try { + writeFileSync(join(directory, 'php'), + `#!/bin/sh\necho '${note}' >&2\necho 'private stderr text' >&2\necho '{"mode":"http"}'\nexit 0\n`); + chmodSync(join(directory, 'php'), 0o755); + const output = join(directory, 'php-record.json'); + const run = spawnSync(process.execPath, [runner, 'record', '--mode', 'http', '--url', 'http://127.0.0.1:9', + '--target', 'php', '--output', output, '--runner-revision', '0'.repeat(40), '--fixture', fixture], + {encoding: 'utf8', env: {...process.env, PATH: `${directory}${delimiter}${process.env.PATH}`}}); + assert.ok(run.stderr.includes(`many-updates: ${note}`), `recorder stderr keeps the note:\n${run.stderr}`); + assert.doesNotMatch(run.stderr, /private stderr text/); + const record = JSON.parse(readFileSync(output, 'utf8')); + assert.deepStrictEqual(record.cases[0].probe_notes, [note]); + assert.doesNotMatch(readFileSync(output, 'utf8'), /private stderr text/); + } finally { + rmSync(directory, {recursive: true, force: true}); + } +}); diff --git a/tests/Unit/Support/composer-sinks.json b/tests/Unit/Support/composer-sinks.json index 2207dfa5d..77e1d92c4 100644 --- a/tests/Unit/Support/composer-sinks.json +++ b/tests/Unit/Support/composer-sinks.json @@ -19,7 +19,7 @@ }, { "path": ".github/workflows/phpunit-feature.yml", - "line": "-lc 'set -eu; tar --no-same-owner -xf - -C /app; cp -a /app/workflow-package /workflow; php scripts/ci/prepare-release-workflow-composer-metadata.php; composer update durable-workflow/workflow --with-dependencies --minimal-changes --no-install --no-scripts --no-autoloader --no-interaction --no-progress --prefer-dist; composer install --no-interaction --no-progress --prefer-dist; git --version; vendor/bin/phpunit --version; git rev-parse --verify \"$CORPUS_BASE_REF^{commit}\"; php scripts/ci/check-worker-openapi-evolution.php \"$CORPUS_BASE_REF\"; vendor/bin/phpunit tests/Feature tests/Unit/NexusContractTest.php tests/Unit/CodecRegressionCorpusTest.php tests/Unit/PhpunitFeatureWorkflowContractTest.php tests/Unit/OpenApiDocumentEvolutionTest.php tests/Unit/NamespaceConformanceRunnerContractTest.php tests/Unit/NamespaceRuntimeContractTest.php tests/Unit/SchedulesConformanceRunnerContractTest.php tests/Unit/SchedulesRuntimeContractTest.php tests/Unit/ServerParitySdkDirectionResponseSummaryTest.php tests/Unit/ServerParitySdkQueryTransportTest.php tests/Unit/ServerParitySdkUpdateTransportTest.php tests/Unit/ServerParityLockPressureRetryTest.php --no-progress --colors=never; vendor/bin/phpunit tests/Unit/ReleaseImageImmutabilityGuardTest.php --no-progress --colors=never; vendor/bin/phpunit tests/Unit/ReleaseImagePublishWorkflowContractTest.php --filter test_php_security_audits_fail_before_release_publication --no-progress --colors=never; python3 scripts/ci/validate-regression-corpus.py --base-ref \"$CORPUS_BASE_REF\" --verify-counterfactual'", + "line": "-lc 'set -eu; tar --no-same-owner -xf - -C /app; cp -a /app/workflow-package /workflow; php scripts/ci/prepare-release-workflow-composer-metadata.php; composer update durable-workflow/workflow --with-dependencies --minimal-changes --no-install --no-scripts --no-autoloader --no-interaction --no-progress --prefer-dist; composer install --no-interaction --no-progress --prefer-dist; git --version; vendor/bin/phpunit --version; git rev-parse --verify \"$CORPUS_BASE_REF^{commit}\"; php scripts/ci/check-worker-openapi-evolution.php \"$CORPUS_BASE_REF\"; vendor/bin/phpunit tests/Feature tests/Unit/NexusContractTest.php tests/Unit/CodecRegressionCorpusTest.php tests/Unit/PhpunitFeatureWorkflowContractTest.php tests/Unit/OpenApiDocumentEvolutionTest.php tests/Unit/NamespaceConformanceRunnerContractTest.php tests/Unit/NamespaceRuntimeContractTest.php tests/Unit/SchedulesConformanceRunnerContractTest.php tests/Unit/SchedulesRuntimeContractTest.php tests/Unit/ServerParitySdkDirectionResponseSummaryTest.php tests/Unit/ServerParitySdkQueryTransportTest.php tests/Unit/ServerParitySdkUpdateTransportTest.php tests/Unit/ServerParityLockPressureRetryTest.php tests/Unit/ServerParityManyUpdatesRetryTest.php --no-progress --colors=never; vendor/bin/phpunit tests/Unit/ReleaseImageImmutabilityGuardTest.php --no-progress --colors=never; vendor/bin/phpunit tests/Unit/ReleaseImagePublishWorkflowContractTest.php --filter test_php_security_audits_fail_before_release_publication --no-progress --colors=never; python3 scripts/ci/validate-regression-corpus.py --base-ref \"$CORPUS_BASE_REF\" --verify-counterfactual'", "count": 2, "confinement": "Runs in the source-free corpus tool image. The feature step checks its system bundle, hashed directory and Composer selection before fresh source and dependency installation, on cold builds and cache hits.", "guards": [