diff --git a/.github/workflows/native-image-parity.yml b/.github/workflows/native-image-parity.yml index 808b6f341..b16bee185 100644 --- a/.github/workflows/native-image-parity.yml +++ b/.github/workflows/native-image-parity.yml @@ -19,8 +19,6 @@ on: pull_request: paths: - scripts/conformance/server-parity/native-image-corpus.sh - - scripts/conformance/server-parity/update-validation-sqlite.sh - - scripts/conformance/server-parity/php-maintenance-window.sh - .github/workflows/native-image-parity.yml permissions: @@ -36,7 +34,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 75 env: - # The name the shared PHP maintenance window expects. + # The corpus part's own container and network name. RESOURCE_SCOPE: dw-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} LOCAL_IMAGE: dw-native-corpus-${{ github.run_id }}-${{ github.run_attempt }}:corpus steps: @@ -47,7 +45,7 @@ jobs: run: | set -euo pipefail bash scripts/ci/pull-images.sh \ - "$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.4.json)" \ + "$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.5.json)" \ composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee - name: Set up Docker Buildx diff --git a/.github/workflows/phpunit-feature.yml b/.github/workflows/phpunit-feature.yml index ccfc38983..670a9de07 100644 --- a/.github/workflows/phpunit-feature.yml +++ b/.github/workflows/phpunit-feature.yml @@ -80,8 +80,6 @@ jobs: - name: Require safe PHP image build context run: node --test tests/Unit/PhpImageContextTest.mjs - - name: Restore PHP's SQLite maintenance loop on every path - run: node --test tests/Unit/PhpMaintenanceWindowTest.mjs - name: Restore the patch cohort's maintenance loop on every path run: node --test tests/Unit/PatchCohortMaintenanceTest.mjs diff --git a/.github/workflows/server-parity.yml b/.github/workflows/server-parity.yml index 7a3a20418..35906b9e6 100644 --- a/.github/workflows/server-parity.yml +++ b/.github/workflows/server-parity.yml @@ -66,7 +66,6 @@ on: - tests/Unit/ServerParityWorkerSessionActivitiesContractTest.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 @@ -144,7 +143,7 @@ jobs: bash scripts/ci/pull-images.sh \ mirror.gcr.io/library/rust@sha256:300ec56abce8cc9448ddea2172747d048ed902a3090e6b57babb2bf19f754081 \ mirror.gcr.io/library/python@sha256:bf44cdfcb76cd3b41e879bc058fc37ec5872002ccfde7fcb765e218cde0cd79c \ - "$(baseline php-2.11.4)" + "$(baseline php-2.11.5)" # The adapters' pinned, --locked dependencies build the same way every # run. Restore their public Cargo registry data and build tree, keyed # by the toolchain image, the runner and every tracked adapter input. @@ -415,7 +414,7 @@ jobs: bash scripts/ci/pull-images.sh \ mirror.gcr.io/library/rust@sha256:ba81bc3eaa4422af576c0262515d96b0111a628a6ccc2c86557cf55c9a4bbee0 \ "$MYSQL_IMAGE" \ - "$(baseline php-2.11.4)" \ + "$(baseline php-2.11.5)" \ "$(baseline php-2.5.14)" \ composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee - name: Fetch this run's published SDK adapters @@ -451,7 +450,7 @@ jobs: - name: Create TLS fixture certificates run: | set -euo pipefail - image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.4.json) + image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.5.json) mkdir -p mysql-tls parity-evidence chmod 0777 parity-evidence docker run --rm --user="$(id -u):$(id -g)" --network=none \ @@ -515,7 +514,7 @@ jobs: docker run --rm --user="$(id -u):$(id -g)" --network "$RESOURCE_SCOPE" --entrypoint mysql \ "$MYSQL_IMAGE" -h parity-mysql -uroot -pparity-disposable-password \ -e 'CREATE DATABASE native_ref CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci' - image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.4.json) + image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.5.json) # The physical layout stays the frozen published 2.5.14 one; behavior # runs on the current reference after migrating that database forward. layout=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.5.14.json) @@ -564,7 +563,7 @@ jobs: bash scripts/conformance/server-parity/container-log-tails.sh } trap container_log_tails ERR - image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.4.json) + image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.5.json) # Composer runs confined in the pinned image, never in the published image under test. bash scripts/ci/run-composer.sh --rm -v "$PWD:/source" -w /source -- \ install --working-dir=scripts/conformance/server-parity --no-interaction --no-progress @@ -641,7 +640,7 @@ jobs: bash scripts/conformance/server-parity/container-log-tails.sh } trap container_log_tails ERR - image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.4.json) + image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.5.json) export DW_PARITY_RUNNER_REVISION=$(git rev-parse HEAD) mkdir -p parity-consumers curl --fail --location --retry 3 --output parity-consumers/dw.phar https://github.com/durable-workflow/cli/releases/download/2.2.0/dw.phar @@ -707,7 +706,7 @@ jobs: bash scripts/conformance/server-parity/container-log-tails.sh } trap container_log_tails ERR - image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.4.json) + image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.5.json) bash scripts/conformance/server-parity/published-sdk-patch-profile.sh mysql http://parity-php:8080 http://parity-native-a:8080 bash scripts/conformance/server-parity/repeated-patch-profile.sh mysql http://parity-php:8080 http://parity-native-a:8080 bash scripts/conformance/server-parity/legacy-marker-profile.sh mysql http://parity-php:8080 http://parity-native-a:8080 @@ -787,7 +786,7 @@ jobs: bash scripts/conformance/server-parity/container-log-tails.sh } trap container_log_tails ERR - image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.4.json) + image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.5.json) bash scripts/ci/run-composer.sh --rm -v "$PWD:/source" -w /source -- \ install --working-dir=scripts/conformance/server-parity --no-interaction --no-progress # Each profile brings its own published PHP and embedded databases and @@ -835,7 +834,7 @@ jobs: if: ${{ matrix.part == 'cohorts' }} run: | set -euo pipefail - image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.4.json) + image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.5.json) 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 -d --name "$RESOURCE_SCOPE-native-b" --network "$RESOURCE_SCOPE" --network-alias=parity-native-b \ @@ -919,7 +918,7 @@ jobs: if: ${{ matrix.part == 'corrected-markers' }} run: | set -euo pipefail - image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.4.json) + image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.5.json) 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 -d --name "$RESOURCE_SCOPE-native-b" --network "$RESOURCE_SCOPE" --network-alias=parity-native-b \ @@ -1049,7 +1048,7 @@ jobs: run: | set -euo pipefail mkdir -p parity-evidence parity-summary - image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.4.json) + image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.5.json) docker run --rm --user="$(id -u):$(id -g)" --network=none --cpus=1 --memory=256m --memory-swap=256m \ --entrypoint node -e PARITY_JOB_OUTCOME -e GITHUB_SHA \ -v "$PWD:/source:ro" -v "$PWD/parity-evidence:/evidence:ro" -v "$PWD/parity-summary:/summary" \ @@ -1101,7 +1100,7 @@ jobs: bash scripts/ci/pull-images.sh \ mirror.gcr.io/library/rust@sha256:ba81bc3eaa4422af576c0262515d96b0111a628a6ccc2c86557cf55c9a4bbee0 \ "$POSTGRES_IMAGE" \ - "$(baseline php-2.11.4)" \ + "$(baseline php-2.11.5)" \ "$(baseline php-2.5.14)" \ composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee - name: Fetch this run's published SDK adapters @@ -1176,7 +1175,7 @@ jobs: "$POSTGRES_IMAGE" --version > parity-evidence/postgres-version.txt docker run --rm --user="$(id -u):$(id -g)" --network "$RESOURCE_SCOPE" \ -e PGPASSWORD=parity-disposable-password --entrypoint createdb "$POSTGRES_IMAGE" -h parity-pg -U parity native_ref - image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.4.json) + image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.5.json) # The physical layout stays the frozen published 2.5.14 one; behavior # runs on the current reference after migrating that database forward. layout=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.5.14.json) @@ -1227,7 +1226,7 @@ jobs: bash scripts/conformance/server-parity/container-log-tails.sh } trap container_log_tails ERR - image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.4.json) + image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.5.json) # Composer runs confined in the pinned image, never in the published image under test. bash scripts/ci/run-composer.sh --rm -v "$PWD:/source" -w /source -- \ install --working-dir=scripts/conformance/server-parity --no-interaction --no-progress @@ -1305,7 +1304,7 @@ jobs: bash scripts/conformance/server-parity/container-log-tails.sh } trap container_log_tails ERR - image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.4.json) + image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.5.json) export DW_PARITY_RUNNER_REVISION=$(git rev-parse HEAD) mkdir -p parity-consumers curl --fail --location --retry 3 --output parity-consumers/dw.phar https://github.com/durable-workflow/cli/releases/download/2.2.0/dw.phar @@ -1373,7 +1372,7 @@ jobs: bash scripts/conformance/server-parity/container-log-tails.sh } trap container_log_tails ERR - image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.4.json) + image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.5.json) bash scripts/conformance/server-parity/published-sdk-patch-profile.sh pgsql http://parity-php:8080 http://parity-native-a:8080 bash scripts/conformance/server-parity/repeated-patch-profile.sh pgsql http://parity-php:8080 http://parity-native-a:8080 bash scripts/conformance/server-parity/legacy-marker-profile.sh pgsql http://parity-php:8080 http://parity-native-a:8080 @@ -1453,7 +1452,7 @@ jobs: bash scripts/conformance/server-parity/container-log-tails.sh } trap container_log_tails ERR - image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.4.json) + image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.5.json) bash scripts/ci/run-composer.sh --rm -v "$PWD:/source" -w /source -- \ install --working-dir=scripts/conformance/server-parity --no-interaction --no-progress # Each profile brings its own published PHP and embedded databases and @@ -1500,7 +1499,7 @@ jobs: if: ${{ matrix.part == 'cohorts' }} run: | set -euo pipefail - image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.4.json) + image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.5.json) 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 -d --name "$RESOURCE_SCOPE-native-b" --network "$RESOURCE_SCOPE" --network-alias=parity-native-b \ @@ -1586,7 +1585,7 @@ jobs: if: ${{ matrix.part == 'corrected-markers' }} run: | set -euo pipefail - image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.4.json) + image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.5.json) 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 -d --name "$RESOURCE_SCOPE-native-b" --network "$RESOURCE_SCOPE" --network-alias=parity-native-b \ @@ -1758,7 +1757,7 @@ jobs: run: | set -euo pipefail mkdir -p parity-evidence parity-summary - image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.4.json) + image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.5.json) docker run --rm --user="$(id -u):$(id -g)" --network=none --cpus=1 --memory=256m --memory-swap=256m \ --entrypoint node -e PARITY_JOB_OUTCOME -e GITHUB_SHA \ -v "$PWD:/source:ro" -v "$PWD/parity-evidence:/evidence:ro" -v "$PWD/parity-summary:/summary" \ @@ -1801,7 +1800,7 @@ jobs: } bash scripts/ci/pull-images.sh \ mirror.gcr.io/library/rust@sha256:ba81bc3eaa4422af576c0262515d96b0111a628a6ccc2c86557cf55c9a4bbee0 \ - "$(baseline php-2.11.4)" \ + "$(baseline php-2.11.5)" \ "$(baseline php-2.5.14)" \ composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee - name: Fetch this run's published SDK adapters @@ -1873,7 +1872,7 @@ jobs: -v "$PWD/parity-evidence:/evidence" -w /source/rust --entrypoint bash \ mirror.gcr.io/library/rust@sha256:ba81bc3eaa4422af576c0262515d96b0111a628a6ccc2c86557cf55c9a4bbee0 -c \ 'cargo test --locked --offline --all-targets > /evidence/rust-tests.txt && cargo build --locked --offline && cargo run --locked --offline --example codec-fixtures -- ../tests/Fixtures/ServerParity/Codec/v1.json > /evidence/rust-codec.json' - image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.4.json) + image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.5.json) docker run --rm --user="$(id -u):$(id -g)" --network=none --entrypoint php \ -v "$PWD:/source:ro" -v "$PWD/parity-evidence:/evidence" -w /source "$image" \ scripts/conformance/server-parity/codec.php tests/Fixtures/ServerParity/Codec/v1.json \ @@ -1897,7 +1896,7 @@ jobs: bash scripts/conformance/server-parity/container-log-tails.sh } trap container_log_tails ERR - image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.4.json) + image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.5.json) mkdir -p parity-state parity-evidence chmod 0777 parity-state parity-evidence touch parity-state/php.sqlite parity-state/embedded.sqlite parity-state/php-layout.sqlite @@ -1906,7 +1905,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/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/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/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/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/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 @@ -1968,7 +1967,7 @@ jobs: bash scripts/conformance/server-parity/container-log-tails.sh } trap container_log_tails ERR - image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.4.json) + image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.5.json) export DW_PARITY_RUNNER_REVISION=$(git rev-parse HEAD) mkdir -p parity-consumers curl --fail --location --retry 3 --output parity-consumers/dw.phar https://github.com/durable-workflow/cli/releases/download/2.2.0/dw.phar @@ -1978,8 +1977,6 @@ jobs: -v "$PWD:/source:ro" -v "$PWD/parity-evidence:/evidence" -w /source \ -e DW_PARITY_TOKEN=parity-test-token -e DW_PARITY_RUNNER_REVISION -e DW_PARITY_CLI_PHAR "$image" scripts/conformance/server-parity.mjs record \ --mode http --url http://parity-server:8080 --target php --output /evidence/php.json \ - --exclude-fixture tests/Fixtures/ServerParity/update-validation.json \ - --exclude-fixture tests/Fixtures/ServerParity/service-calls.json \ --include-fixture tests/Fixtures/ServerParityPending/patch-insertion-activity-pending.json \ --include-fixture tests/Fixtures/ServerParityPending/patch-insertion-activity-completed.json \ --include-fixture tests/Fixtures/ServerParityPending/patch-created-activity-pending.json \ @@ -1992,8 +1989,6 @@ jobs: -e DB_CONNECTION=sqlite -e DB_DATABASE=/state/embedded.sqlite -e DW_MODE=embedded \ -e CACHE_STORE=file -e QUEUE_CONNECTION=database -e DW_PARITY_RUNNER_REVISION "$image" scripts/conformance/server-parity.mjs record \ --mode embedded --application-root /app --target embedded --output /evidence/embedded.json \ - --exclude-fixture tests/Fixtures/ServerParity/update-validation.json \ - --exclude-fixture tests/Fixtures/ServerParity/service-calls.json \ --include-fixture tests/Fixtures/ServerParityPending/patch-insertion-activity-pending.json \ --include-fixture tests/Fixtures/ServerParityPending/patch-insertion-activity-completed.json \ --include-fixture tests/Fixtures/ServerParityPending/patch-created-activity-pending.json \ @@ -2006,8 +2001,6 @@ jobs: -v "$PWD:/source:ro" -v "$PWD/parity-evidence:/evidence" -w /source \ -e DW_PARITY_TOKEN=parity-test-token -e DW_PARITY_RUNNER_REVISION -e DW_PARITY_CLI_PHAR "$image" scripts/conformance/server-parity.mjs record \ --mode http --url http://parity-rust:8080 --target rust --output /evidence/rust.json \ - --exclude-fixture tests/Fixtures/ServerParity/update-validation.json \ - --exclude-fixture tests/Fixtures/ServerParity/service-calls.json \ --include-fixture tests/Fixtures/ServerParityPending/patch-insertion-activity-pending.json \ --include-fixture tests/Fixtures/ServerParityPending/patch-insertion-activity-completed.json \ --include-fixture tests/Fixtures/ServerParityPending/patch-created-activity-pending.json \ @@ -2019,8 +2012,6 @@ jobs: docker run --rm --user=1000:1000 --entrypoint node \ -v "$PWD:/source:ro" -v "$PWD/parity-evidence:/evidence" -w /source "$image" \ scripts/conformance/server-parity.mjs compare /evidence/php.json /evidence/rust.json /evidence/embedded.json \ - --exclude-fixture tests/Fixtures/ServerParity/update-validation.json \ - --exclude-fixture tests/Fixtures/ServerParity/service-calls.json \ --include-fixture tests/Fixtures/ServerParityPending/patch-insertion-activity-pending.json \ --include-fixture tests/Fixtures/ServerParityPending/patch-insertion-activity-completed.json \ --include-fixture tests/Fixtures/ServerParityPending/patch-created-activity-pending.json \ @@ -2028,21 +2019,10 @@ jobs: --include-fixture tests/Fixtures/ServerParityPending/workflow-waiting-history-type.json \ --include-fixture tests/Fixtures/ServerParityPending/workflow-waiting-history-message.json \ --include-fixture tests/Fixtures/ServerParityPending/workflow-task-transient-retry.json > parity-evidence/comparison.json || compare_status=$? - # Server #723: update-validation records apart from the main corpus, and - # its published PHP record runs with PHP's maintenance loop stopped. - # Server #796: so does service-calls until the reference carries that - # fix. Under the loop's writer contention published PHP has refused a - # limited operation's first call. - update_validation_status=0 - bash scripts/conformance/server-parity/update-validation-sqlite.sh http://parity-server:8080 http://parity-rust:8080 || update_validation_status=$? - service_calls_status=0 - bash scripts/conformance/server-parity/update-validation-sqlite.sh http://parity-server:8080 http://parity-rust:8080 service-calls || service_calls_status=$? # A native fixture difference fails the step only after the # comparison has run, so one difference cannot hide the rest. test "$native_record_status" -eq 0 test "$compare_status" -eq 0 - test "$update_validation_status" -eq 0 - test "$service_calls_status" -eq 0 - name: Compare published patch, legacy-marker and range-repair profiles if: ${{ matrix.part == 'restarts' }} env: @@ -2054,7 +2034,7 @@ jobs: bash scripts/conformance/server-parity/container-log-tails.sh } trap container_log_tails ERR - image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.4.json) + image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.5.json) bash scripts/conformance/server-parity/published-sdk-patch-profile.sh sqlite http://parity-server:8080 http://parity-rust:8080 bash scripts/conformance/server-parity/repeated-patch-profile.sh sqlite http://parity-server:8080 http://parity-rust:8080 bash scripts/conformance/server-parity/legacy-marker-profile.sh sqlite http://parity-server:8080 http://parity-rust:8080 @@ -2135,7 +2115,7 @@ jobs: bash scripts/conformance/server-parity/container-log-tails.sh } trap container_log_tails ERR - image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.4.json) + image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.5.json) bash scripts/ci/run-composer.sh --rm -v "$PWD:/source" -w /source -- \ install --working-dir=scripts/conformance/server-parity --no-interaction --no-progress mkdir -p parity-state parity-evidence @@ -2179,7 +2159,7 @@ jobs: RESOURCE_SCOPE: dw-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} run: | set -euo pipefail - image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.4.json) + image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.5.json) bash scripts/ci/run-composer.sh --rm -v "$PWD:/source" -w /source -- \ install --working-dir=scripts/conformance/server-parity --no-interaction --no-progress mkdir -p parity-state parity-evidence @@ -2282,7 +2262,7 @@ jobs: RESOURCE_SCOPE: dw-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} run: | set -euo pipefail - image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.4.json) + image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.5.json) bash scripts/ci/run-composer.sh --rm -v "$PWD:/source" -w /source -- \ install --working-dir=scripts/conformance/server-parity --no-interaction --no-progress mkdir -p parity-state parity-evidence @@ -2400,7 +2380,7 @@ jobs: run: | set -euo pipefail mkdir -p parity-evidence parity-summary - image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.4.json) + image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.5.json) docker run --rm --user="$(id -u):$(id -g)" --network=none --cpus=1 --memory=256m --memory-swap=256m \ --entrypoint node -e PARITY_JOB_OUTCOME -e GITHUB_SHA \ -v "$PWD:/source:ro" -v "$PWD/parity-evidence:/evidence:ro" -v "$PWD/parity-summary:/summary" \ diff --git a/docs/rust-server-port-checkpoints/2026-10-27-reference-2115.md b/docs/rust-server-port-checkpoints/2026-10-27-reference-2115.md new file mode 100644 index 000000000..c46eb52b4 --- /dev/null +++ b/docs/rust-server-port-checkpoints/2026-10-27-reference-2115.md @@ -0,0 +1,64 @@ +# October 27, the parity reference moves to published 2.11.5 + +- **Before.** The Shared Server fixtures compared native and embedded + against published Server 2.11.4 (Core 2.14.2), with two SQLite + workarounds: `update-validation` and `service-calls` recorded apart from + the main corpus, with PHP's maintenance loop stopped (#723, #796). +- **Reference.** Published Server 2.11.5, Core 2.15.1, as General verified + it (release run 40478372197): + - source `721b2831975cc898cc39d7528c5ba7e8441fe744`; + - index `sha256:f4f2ec64a6522ee18a31542a30554873dbaa2f5d3af3ccb52deb914591699c20`, + identical on Docker Hub and GHCR; + - linux/amd64 `sha256:bcc672859a399d1df234fc304531ab06b7bfa8acad7cb2c9da95f8e7ab5c98b4`; + - linux/arm64 `sha256:3160f4f58ab9b144705c3e4d5d97b0881b0891c1f00e9ab6b99b3245a8c3b26a`. + + `tests/Fixtures/ServerParityBaselines/php-2.11.5.json` records it. The SDK, + CLI, Waterline and sample-app pins are unchanged. The takeover cohorts are + not affected: each names its own source release and layout. +- **The SQLite maintenance pause is gone.** 2.11.5 carries the + update-validation lock fixes (#726) and atomic service admission (#796). + Cloud's installed #726 acceptance passed 15 of 15 original SQLite + records with the maintenance loop running, and General's #796 check + passed 10 of 10. So the SQLite job records both fixtures in its main + corpus, with the loop running, as the MySQL and PostgreSQL jobs always + have. `update-validation-sqlite.sh`, `php-maintenance-window.sh` and their + tests are removed. The native image corpus does the same. +- **Expectation changes**, all 2.11.5 behavior: + - `service-calls`: the "replay other caller" step returns. An idempotency + key belongs to the operation (Workflow #931): another caller namespace + reusing it gets `403 service_operation_forbidden`, and no call or audit + row is written. 91 requests. + - `repair-pass`: the report adds Core 2.15's linked service-call counts + (`service_call_linked_candidates`, `service_call_links_reconciled`, + `service_call_linked_failures`). + - `bridge-adapters`: a second bounded answer for the #777 foreign-instance + defect. Core 2.15.1 checks the namespace first, but 2.11.5's bridge + controller still echoes "cannot be reused with a different namespace" + for both foreign starts. Server #811 (merged after 2.11.5) answers the + intended "is unavailable". The intended answer is unchanged. +- **Native.** + - Refuses an idempotency-key replay from anyone but the call's owner, + matching Core 2.15.1's `ServiceCallIdempotency` and 2.11.5's 403. + - The repair report adds the three linked counts as zeros. Native binds no + call to a workflow run: workflow-bound handlers answer 501 (#794). + - Takeover now refuses a PHP database holding an open call bound to a + workflow run (`upgrade_source_service_calls_workflow_bound_open`), which + PHP closes when its run ends and native never would. The service-call + inspection reports the count as `workflow_bound_open`. Closed + workflow-bound calls and open calls of other bindings still take over. +- **Checked** (local SQLite, `native-image-corpus.sh` with a native image + built from this branch, published 2.11.5 and embedded 2.11.5): + - first run: native and embedded passed all 73 cases; 2.11.5 passed 71, + including `update-validation` and `service-calls` with the maintenance + loop running. It failed `repair-pass` (the three new counts) and + `bridge-adapters` (`start_other_type`), the two changes above; + - after them, both fixtures pass against the running 2.11.5 server; + - second full run, native image rebuilt with them (runner revision + `73d43c8c`, the same tree): published 2.11.5, native and embedded each + pass all 73 cases, and the three records compare equal; + - native: `cargo test --lib` for repair_pass, waterline_import and the + upgrade module (60 tests) pass, and the service-calls execution tests + pass 8/8 on SQLite. CI runs them on all four databases. +- **Next.** + - The canonical history-byte unit (#770) lands with the first reference + that carries #812 (`rust-325-wip/canonical-history-bytes`). diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index 70e7d4008..0d4aa3496 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -39,25 +39,40 @@ Updated October 27, 2026. - [#803](https://github.com/durable-workflow/server/pull/803): the native release-candidate image (`Dockerfile.native`), its release workflow and the takeover runbook's image commands. - - The parity reference moves to published 2.11.4 - ([checkpoint](rust-server-port-checkpoints/2026-10-27-reference-2114.md)). + - The parity reference moved to published 2.11.4 (#805, + [checkpoint](rust-server-port-checkpoints/2026-10-27-reference-2114.md)), + and moves to published 2.11.5, without the SQLite maintenance pause + ([checkpoint](rust-server-port-checkpoints/2026-10-27-reference-2115.md)). + - [#814](https://github.com/durable-workflow/server/pull/814): the shared + fixtures accept published PHP's typed `429 long_poll_capacity_exhausted` + poll refusal, and retry a locked Composer install after a transient + download failure (the two #813 merge-group failures). + - Native's v1 projection byte meter in Core's canonical unit (#770) is + prepared on `rust-325-wip/canonical-history-bytes`. It lands with the + first reference that carries #812, because `waterline-import` records + `history_size_bytes` from the reference. - **Still refused at takeover:** workflow-to-workflow messages (`workflow_messages`, written only by embedded PHP workflows) and external payload references. PHP keeps references in about 30 table and column locations in their URI form, so taking them over is its own project; the recommendation is that the release candidate keeps refusing them. -- **Shared Server fixtures.** Native, embedded and published PHP 2.11.4 are +- **Shared Server fixtures.** Native, embedded and published PHP 2.11.5 are compared on SQLite, MySQL 8.0, MariaDB 10.11 and PostgreSQL 17. Where the published reference itself misbehaves, an explicit boundary separates it from native, and the PHP owner has an issue for each case. Recent ones: - - Workflow #931, an idempotency key repeated from another caller namespace - (left out of `service-calls` until a release carries the correction); - - #796, `service-calls` under full-corpus SQLite load; + - #777, a bridge start for an instance another namespace holds: 2.11.1 + to 2.11.4 name that namespace's type, 2.11.5 the namespace difference; + #811 (after 2.11.5) answers "unavailable"; + - #747, a bridge update to an SDK-worker workflow; - #794, workflow-bound Nexus handlers. + Closed by 2.11.5: Workflow #931 (`service-calls` repeats a key from + another caller again, now refused 403), and the SQLite maintenance pause + for `update-validation` and `service-calls` (#723, #726, #796). + No expectation is edited to make native pass. - **Parity surface.** Native answers 174 of the 175 API route methods in - published 2.11.1's route table, which 2.11.4 leaves unchanged. Eight of those are the prepared + published 2.11.1's route table, which 2.11.4 and 2.11.5 leave unchanged. Eight of those are the prepared local-activity routes, which it answers with PHP's refusal for a bridge without the prepared role. Nexus workflow-bound handler kinds answer 501 until #794 settles their contract. An execution test sends each route @@ -65,7 +80,7 @@ Updated October 27, 2026. `501 rust_capability_not_implemented` must match [`unserved-php-routes.v2.11.1.txt`](../rust/tests/fixtures/unserved-php-routes.v2.11.1.txt). - **Remaining for the release candidate:** - - landing the 2.11.4 reference move; + - landing the 2.11.5 reference move; - the scope question with the operator: prepared local activities, workflow-bound Nexus handlers and external payload takeover; - publishing the native image (General runs the release); diff --git a/docs/rust-server-upgrade-takeover.md b/docs/rust-server-upgrade-takeover.md index 5d89ab2d9..447d0c077 100644 --- a/docs/rust-server-upgrade-takeover.md +++ b/docs/rust-server-upgrade-takeover.md @@ -187,6 +187,15 @@ ones, without naming calls. A pending dispatch makes the inspection `upgrade_source_service_call_dispatches_pending` before any change. Let PHP finish them before the stop. Completed dispatches do not block takeover. +The same report counts open service calls bound to a workflow run +(`workflow_bound_open`): calls a Nexus workflow-bound handler accepted that are +still `pending`, `accepted` or `started`. PHP closes such a call when its linked +run ends, and 2.11.5's maintenance pass repairs one it missed. Native serves no +workflow-bound handler yet (Server #794) and closes no linked call, so one +would stay open after takeover. Any makes the inspection `unqualified`, and +takeover refuses with `upgrade_source_service_calls_workflow_bound_open` before +any change. Let the linked runs finish, or cancel the calls, before the stop. + ## Exclusive transaction per backend The marker, the fingerprint check and the schema changes must not interleave diff --git a/rust/src/runtime/repair_pass.rs b/rust/src/runtime/repair_pass.rs index 1b7459901..cca4d7fab 100644 --- a/rust/src/runtime/repair_pass.rs +++ b/rust/src/runtime/repair_pass.rs @@ -107,6 +107,12 @@ fn empty_report(request: &PassRequest, throttled: bool) -> Map { // never has a dispatch to enqueue. "service_call_dispatch_candidates": 0, "service_call_dispatches_enqueued": 0, "service_call_dispatch_failures": [], + // Published 2.11.5 (Core 2.15) also closes open calls bound to a + // workflow run whose run has ended (LinkedServiceCallCloser). Native + // binds no call to a workflow run: it refuses workflow-bound handlers + // (Server #794). + "service_call_linked_candidates": 0, "service_call_links_reconciled": 0, + "service_call_linked_failures": [], }); match value { Value::Object(map) => map, diff --git a/rust/src/runtime/service_calls.rs b/rust/src/runtime/service_calls.rs index a5978c0e2..4d4a6e221 100644 --- a/rust/src/runtime/service_calls.rs +++ b/rust/src/runtime/service_calls.rs @@ -1069,6 +1069,26 @@ fn not_implemented() -> RuntimeError { super::refuse(StatusCode::NOT_IMPLEMENTED, "rust_capability_not_implemented") } +/// `serviceOperationForbidden`: no ownership or policy detail, no audit row. +fn service_operation_forbidden() -> RuntimeError { + RuntimeError::Protocol { + status: StatusCode::FORBIDDEN, + response: json!({"reason": "service_operation_forbidden", + "message": "The service operation is not authorized for this caller."}), + } +} + +/// The replaying request is the call's owner: the same caller namespace and +/// caller workflow instance and run, through the same catalog objects. +fn replay_owner(call: &Call, execution: &Execution, [endpoint, service, operation]: [&String; 3]) -> bool { + call.caller_namespace.as_ref() == Some(&execution.caller_namespace) + && call.caller_instance == execution.caller_instance + && call.caller_run == execution.caller_run + && call.endpoint_id.as_ref() == Some(endpoint) + && call.service_id.as_ref() == Some(service) + && call.operation_id.as_ref() == Some(operation) +} + /// `ServiceCallDeadlinePolicy` failures at execute (`assertDeadlinePolicy`). fn invalid(field: &str, message: &str) -> RuntimeError { laravel_rules::failure(vec![(field.to_owned(), vec![message.to_owned()])]).expect("a message") @@ -1466,12 +1486,19 @@ store_methods! { mode, }; let kind = binding_kind(operation.kind()); + // `ServiceCallIdempotency::find`: a key belongs to the target + // operation, not to a caller. The first call under it is found + // whoever made it, and only its owner may replay it (Workflow #931). if let Some(key) = execution.idempotency_key.as_deref().map(str::trim).filter(|key| !key.is_empty()) - && let Some(mut prior) = Self::query("SELECT c.*,d.call_deadline_at FROM workflow_service_calls c LEFT JOIN dw_service_call_deadlines d ON d.service_call_id=c.id WHERE c.namespace=$1 AND c.workflow_service_operation_id=$2 AND c.caller_namespace=$3 AND c.idempotency_key=$4 ORDER BY c.created_at,c.id LIMIT 1") - .bind(self.namespace.as_ref()).bind(operation.row.id.as_str()).bind(execution.caller_namespace.as_str()).bind(key) + && let Some(mut prior) = Self::query("SELECT c.*,d.call_deadline_at FROM workflow_service_calls c LEFT JOIN dw_service_call_deadlines d ON d.service_call_id=c.id WHERE c.target_namespace=$1 AND c.endpoint_name=$2 AND c.service_name=$3 AND c.operation_name=$4 AND c.idempotency_key=$5 ORDER BY c.created_at,c.id LIMIT 1") + .bind(self.namespace.as_ref()).bind(endpoint.row.name.as_str()).bind(service.row.name.as_str()) + .bind(operation.row.name.as_str()).bind(key) .fetch_optional(&mut *tx).await? .map(|row| Self::service_call_row(&row)).transpose()? { + if !replay_owner(&prior, &execution, [&endpoint.row.id, &service.row.id, &operation.row.id]) { + return Err(service_operation_forbidden()); + } // `authorizeReplay`: authorization and the circuit, no limits. let decision = request.base_decision(); if !decision.allowed() { diff --git a/rust/src/runtime/upgrade.rs b/rust/src/runtime/upgrade.rs index 310bd7b34..ff41551a0 100644 --- a/rust/src/runtime/upgrade.rs +++ b/rust/src/runtime/upgrade.rs @@ -289,7 +289,7 @@ fn report( report["status"] = json!("unqualified"); } report["storage_inspection"] = storage_inspection; - if dispatch_inspection["pending"] != 0 { + if dispatch_inspection["pending"] != 0 || dispatch_inspection["workflow_bound_open"] != 0 { report["status"] = json!("unqualified"); } report["service_call_dispatch_inspection"] = dispatch_inspection; diff --git a/rust/src/runtime/upgrade/dispatch_scan.rs b/rust/src/runtime/upgrade/dispatch_scan.rs index 666c8c03b..6a70cc9ab 100644 --- a/rust/src/runtime/upgrade/dispatch_scan.rs +++ b/rust/src/runtime/upgrade/dispatch_scan.rs @@ -1,14 +1,22 @@ //! PHP 2.11.3's service-call dispatch outbox. PHP schedules a call's handler //! there and a later pass dispatches it; native runs the handler when it //! accepts the call, so it cannot resume a dispatch that is still scheduled or -//! claimed. A completed dispatch is only PHP's bookkeeping. The report counts -//! rows and never names calls. +//! claimed. A completed dispatch is only PHP's bookkeeping. +//! +//! It also counts open calls bound to a workflow run (Nexus workflow-bound +//! handlers). PHP closes such a call when its linked run ends, and 2.11.5's +//! maintenance pass repairs one it missed (LinkedServiceCallCloser). Native +//! serves no workflow-bound handler (Server #794) and closes no linked call, +//! so one would stay open forever after takeover. The report counts rows and +//! never names calls. use crate::runtime::{Result, backend::Backend}; use serde_json::{Value, json}; use sqlx::{Executor, IntoArguments}; use std::collections::BTreeMap; const PENDING: &str = "SELECT COUNT(*) AS n FROM workflow_service_call_dispatches WHERE state<>'completed'"; +const WORKFLOW_BOUND_OPEN: &str = "SELECT COUNT(*) AS n FROM workflow_service_calls \ + WHERE resolved_binding_kind='workflow_run' AND status IN ('pending','accepted','started')"; pub(super) async fn inspect( connection: &mut DB::Connection, @@ -27,5 +35,14 @@ where .await?; DB::number(&row, "n")? }; - Ok(json!({"dispatches": rows, "pending": pending})) + let calls = counts.get("workflow_service_calls").copied().unwrap_or(0); + let workflow_bound_open = if calls == 0 { + 0 + } else { + let row = sqlx::query(sqlx::AssertSqlSafe(DB::statement(WORKFLOW_BOUND_OPEN))) + .fetch_one(&mut *connection) + .await?; + DB::number(&row, "n")? + }; + Ok(json!({"dispatches": rows, "pending": pending, "workflow_bound_open": workflow_bound_open})) } diff --git a/rust/src/runtime/upgrade/takeover.rs b/rust/src/runtime/upgrade/takeover.rs index 1ec5748bb..bca551c02 100644 --- a/rust/src/runtime/upgrade/takeover.rs +++ b/rust/src/runtime/upgrade/takeover.rs @@ -254,6 +254,14 @@ fn qualified(inventory: &Value) -> Result<&str> { "upgrade_source_service_call_dispatches_pending", )); } + // An open call bound to a workflow run would never close under native, + // which serves no workflow-bound handler (Server #794). + if inventory["service_call_dispatch_inspection"]["workflow_bound_open"] != 0 { + return Err(refuse( + StatusCode::CONFLICT, + "upgrade_source_service_calls_workflow_bound_open", + )); + } // A schedule native cannot run would stop firing after takeover. if inventory["schedule_inspection"]["unsupported"] != 0 { return Err(refuse( diff --git a/rust/src/runtime/upgrade_tests.rs b/rust/src/runtime/upgrade_tests.rs index db7309eb5..884e61611 100644 --- a/rust/src/runtime/upgrade_tests.rs +++ b/rust/src/runtime/upgrade_tests.rs @@ -1156,7 +1156,7 @@ async fn takeover_refuses_a_php_2113_dispatch_still_pending() { mutate(&path, "INSERT INTO workflow_service_call_dispatches(service_call_id,state,dispatch_options,available_at,created_at,updated_at) VALUES ('01m5wvy8d1j3dtr13es05kcall','scheduled','{}','2026-10-26 10:00:00','2026-10-26 10:00:00','2026-10-26 10:00:00')").await; let inspection = inspect_sqlite(&path).await.unwrap(); assert_eq!(inspection["status"], "unqualified", "{inspection}"); - assert_eq!(inspection["service_call_dispatch_inspection"], json!({"dispatches": 1, "pending": 1})); + assert_eq!(inspection["service_call_dispatch_inspection"], json!({"dispatches": 1, "pending": 1, "workflow_bound_open": 0})); let before = std::fs::read(&path).unwrap(); let fingerprint = backup_fingerprint(&path).await; reason( @@ -1166,6 +1166,31 @@ async fn takeover_refuses_a_php_2113_dispatch_still_pending() { assert_eq!(std::fs::read(&path).unwrap(), before, "the PHP database is unchanged"); } +/// An open service call bound to a workflow run would never close under +/// native, which serves no workflow-bound handler (Server #794): inspection +/// reports it and takeover refuses before any change. A closed one, or an +/// open call of another binding, takes over. +#[tokio::test] +async fn takeover_refuses_an_open_workflow_bound_service_call() { + let (_directory, path) = php_release_layout(&[PHP_280_CHANGES, TRACE_CARRIERS, QUEUE_DELIVERY, RELEASE_2113]).await; + let call = |id: &str, kind: &str, status: &str| format!("INSERT INTO workflow_service_calls(id,namespace,endpoint_name,service_name,operation_name,caller_namespace,target_namespace,status,outcome,operation_mode,resolved_binding_kind,accepted_at,created_at,updated_at) VALUES ('{id}','default','pay','billing','charge','default','default','{status}','accepted','async','{kind}','2026-10-26 10:00:00','2026-10-26 10:00:00','2026-10-26 10:00:00')"); + mutate(&path, call("01m5wvy8d1j3dtr13es05kdone", "workflow_run", "completed").leak()).await; + mutate(&path, call("01m5wvy8d1j3dtr13es05kactv", "activity_execution", "accepted").leak()).await; + let inspection = inspect_sqlite(&path).await.unwrap(); + assert_eq!(inspection["service_call_dispatch_inspection"], json!({"dispatches": 0, "pending": 0, "workflow_bound_open": 0})); + mutate(&path, call("01m5wvy8d1j3dtr13es05kopen", "workflow_run", "started").leak()).await; + let inspection = inspect_sqlite(&path).await.unwrap(); + assert_eq!(inspection["status"], "unqualified", "{inspection}"); + assert_eq!(inspection["service_call_dispatch_inspection"], json!({"dispatches": 0, "pending": 0, "workflow_bound_open": 1})); + let before = std::fs::read(&path).unwrap(); + let fingerprint = backup_fingerprint(&path).await; + reason( + takeover::take_over_sqlite(&path, &fingerprint, &queue_config()).await, + "upgrade_source_service_calls_workflow_bound_open", + ); + assert_eq!(std::fs::read(&path).unwrap(), before, "the PHP database is unchanged"); +} + #[tokio::test] #[ignore = "only invoked as a real subprocess by the takeover interruption test"] async fn sqlite_takeover_child() { diff --git a/rust/tests/execution/service_calls.rs b/rust/tests/execution/service_calls.rs index eb1da06f8..1f136a2ac 100644 --- a/rust/tests/execution/service_calls.rs +++ b/rust/tests/execution/service_calls.rs @@ -271,8 +271,26 @@ async fn cancellation_and_idempotent_replays_follow_the_control_plane() { assert_eq!((replay["accepted"].clone(), replay["idempotent_replay"].clone(), replay["reason"].clone(), replay["found"].clone(), replay["service_call_id"].clone(), replay.get("id").is_some()), (json!(true), json!(true), Value::Null, json!(true), json!(id), false)); - let (_, other) = execute(&app, "charge", json!({"idempotency_key": "k1", "caller_namespace": "other"})).await; - assert_ne!(other["id"], json!(id), "another caller namespace is another call"); + // The key belongs to the operation (Workflow #931): the catalog names in + // any case replay the call, and anyone but its owner is refused without + // a new call, an audit row or a change to the first call. + let (status, upper) = request(&app, "POST", &format!("{}/CHARGE/execute", OPERATIONS.replace("pay", "PAY").replace("billing", "BILLING")), + json!({"idempotency_key": "k1"})).await; + assert_eq!((status, upper["service_call_id"].clone(), upper["idempotent_replay"].clone()), (StatusCode::OK, json!(id), json!(true)), "{upper}"); + let (_, before) = request(&app, "GET", &format!("{OPERATIONS}/charge/service-calls/{id}"), Value::Null).await; + const CALLS: &str = "SELECT id, id FROM workflow_service_calls"; + let count = text_rows(&database, CALLS, CALLS, CALLS).await.len(); + for foreign in [json!({"caller_namespace": "other"}), json!({"caller_workflow_instance_id": "caller-wf"}), + json!({"caller_workflow_instance_id": "caller-wf", "caller_workflow_run_id": "01hcallerrun00000000000000"})] { + let mut body = foreign.clone(); + body["idempotency_key"] = json!("k1"); + let (status, refused) = execute(&app, "charge", body).await; + assert_eq!((status, refused), (StatusCode::FORBIDDEN, json!({"reason": "service_operation_forbidden", + "message": "The service operation is not authorized for this caller."})), "{foreign}"); + } + assert_eq!(text_rows(&database, CALLS, CALLS, CALLS).await.len(), count, "a refused replay writes no call or audit row"); + let (_, after) = request(&app, "GET", &format!("{OPERATIONS}/charge/service-calls/{id}"), Value::Null).await; + assert_eq!(after, before, "a refused replay leaves the first call as it was"); let cancel = format!("{OPERATIONS}/charge/service-calls/{id}/cancel"); let (status, cancelled) = request(&app, "POST", &cancel, json!({"reason": "operator stop"})).await; diff --git a/scripts/conformance/server-parity.mjs b/scripts/conformance/server-parity.mjs index b3ca96a0d..b2e7a6b28 100644 --- a/scripts/conformance/server-parity.mjs +++ b/scripts/conformance/server-parity.mjs @@ -44,7 +44,7 @@ Requires Node 20+ and PHP 8.3+ with the locked adapter installed. Each target must have its own isolated, already bootstrapped database. Set DW_PARITY_TOKEN for the HTTP target; it is never recorded. Use the same --prefix on all targets and a fresh database or prefix for a new run. --artifacts selects the exact -consumer tuple; the default is tests/Fixtures/ServerParityBaselines/php-2.11.4.json. +consumer tuple; the default is tests/Fixtures/ServerParityBaselines/php-2.11.5.json. Repeat --fixture PATH to record or compare an explicitly selected candidate set. --database-resource-limits accepts inspected memory_bytes and memory_swap_bytes as a JSON object; comparisons require equal effective @@ -81,7 +81,7 @@ try { } const record = { schema: 'durable-workflow.server-parity-record/v1', target: values.target, mode: values.mode, - runner_revision: values['runner-revision'], artifacts: json(values.artifacts ?? resolve(root, 'tests/Fixtures/ServerParityBaselines/php-2.11.4.json')), + runner_revision: values['runner-revision'], artifacts: json(values.artifacts ?? resolve(root, 'tests/Fixtures/ServerParityBaselines/php-2.11.5.json')), started_at: new Date().toISOString(), outcome: 'runner-blocked', fixture_hashes: hashes, cases: [], database_resource_limits: parseDatabaseResourceLimits(values['database-resource-limits']), }; diff --git a/scripts/conformance/server-parity/README.md b/scripts/conformance/server-parity/README.md index 0cf7c2674..27d73581b 100644 --- a/scripts/conformance/server-parity/README.md +++ b/scripts/conformance/server-parity/README.md @@ -282,7 +282,7 @@ Docker kill and verifies exit code 137 before restarting the native process. For an adapter installed elsewhere, set `DW_PARITY_SDK_AUTOLOAD` to its locked `vendor/autoload.php`. `--artifacts` selects a frozen tuple manifest. The default -is `tests/Fixtures/ServerParityBaselines/php-2.11.4.json`; its SDK version must match +is `tests/Fixtures/ServerParityBaselines/php-2.11.5.json`; its SDK version must match the installed lock. A frozen tuple does not prove every listed consumer ran. The original `tests/Fixtures/ServerParity/php-baseline.json` remains an immutable 2.5.13 / Workflow 2.5.4 reference, together with its known NUL reproducer outside diff --git a/scripts/conformance/server-parity/bridge-adapters-contract.mjs b/scripts/conformance/server-parity/bridge-adapters-contract.mjs index 6c66af15a..5cedca1f5 100644 --- a/scripts/conformance/server-parity/bridge-adapters-contract.mjs +++ b/scripts/conformance/server-parity/bridge-adapters-contract.mjs @@ -9,7 +9,9 @@ import assert from 'node:assert/strict'; // Published defect boundaries (fixture `published_defects`): a bridge update // skips external update admission, so an update to an SDK-worker workflow is // rejected workflow_definition_unavailable; and a start for an id another -// namespace holds names that namespace's type (Server #777). At each +// namespace holds names that namespace's type (published 2.11.1 to 2.11.4) +// or the namespace difference (2.11.5), not the intended unavailable +// (Server #777). At each // defect's steps the published answers or the intended ones are accepted, as // a set per defect; nothing else is relaxed. export function checkBridgeAdapters(fixture, observation, workflowId) { diff --git a/scripts/conformance/server-parity/native-image-corpus.sh b/scripts/conformance/server-parity/native-image-corpus.sh index 3880b33ab..2d8466043 100755 --- a/scripts/conformance/server-parity/native-image-corpus.sh +++ b/scripts/conformance/server-parity/native-image-corpus.sh @@ -12,11 +12,9 @@ # and its maintenance loop, with SQLite in its /app/database volume. # # Containers and the network are named from RESOURCE_SCOPE exactly as the -# corpus part names them, dw-parity---, because the shared -# PHP maintenance window finds its container by that name. So -# update-validation-sqlite.sh records the separate update-validation fixture -# unchanged. The workflow removes them in an always step. Evidence lands in -# parity-evidence/, with a receipt naming the image. +# corpus part names them, dw-parity---. The workflow +# removes them in an always step. Evidence lands in parity-evidence/, with a +# receipt naming the image. set -euo pipefail shopt -s inherit_errexit test "$#" = 1 || { echo "usage: $0 " >&2; exit 2; } @@ -25,17 +23,15 @@ native=$1 || { echo "The native image must be pinned by digest (name@sha256:...) or be this job's local build." >&2; exit 2; } scope="dw-parity-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}-${GITHUB_JOB:-parity}" [[ "${RESOURCE_SCOPE:-$scope}" == "$scope" ]] \ - || { echo "RESOURCE_SCOPE must be $scope, the name the PHP maintenance window expects." >&2; exit 2; } + || { echo "RESOURCE_SCOPE must be $scope, the corpus part's own container and network name." >&2; exit 2; } export RESOURCE_SCOPE="$scope" export DW_PARITY_RUNNER_REVISION DW_PARITY_RUNNER_REVISION=$(git rev-parse HEAD) -image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.4.json) +image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.5.json) -# CI's corpus selection: the reviewed corpus without update-validation and -# service-calls, which record separately below with PHP's maintenance loop -# stopped (Server #723, #796), plus the pending candidates it carries. -selection=(--exclude-fixture tests/Fixtures/ServerParity/update-validation.json - --exclude-fixture tests/Fixtures/ServerParity/service-calls.json) +# CI's corpus selection: the reviewed corpus plus the pending candidates it +# carries. +selection=() for pending in patch-insertion-activity-pending patch-insertion-activity-completed patch-created-activity-pending \ patch-created-activity-completed workflow-waiting-history-type workflow-waiting-history-message workflow-task-transient-retry; do selection+=(--include-fixture "tests/Fixtures/ServerParityPending/$pending.json") @@ -115,24 +111,15 @@ compare_status=0 docker run --rm --user=1000:1000 --entrypoint node -v "$PWD:/source:ro" -v "$PWD/parity-evidence:/evidence" -w /source "$image" \ scripts/conformance/server-parity.mjs compare /evidence/php.json /evidence/rust.json /evidence/embedded.json "${selection[@]}" \ > parity-evidence/comparison.json || compare_status=$? -update_validation_status=0 -bash scripts/conformance/server-parity/update-validation-sqlite.sh http://parity-server:8080 http://parity-rust:8080 || update_validation_status=$? -service_calls_status=0 -bash scripts/conformance/server-parity/update-validation-sqlite.sh http://parity-server:8080 http://parity-rust:8080 service-calls \ - || service_calls_status=$? native_id=$(docker image inspect --format '{{.Id}}' "$native") jq -n --arg native "$native" --arg id "$native_id" --arg php "$image" --arg revision "$DW_PARITY_RUNNER_REVISION" \ --argjson reference "$status" --argjson nativeStatus "$native_status" --argjson compare "$compare_status" \ - --argjson updateValidation "$update_validation_status" --argjson serviceCalls "$service_calls_status" \ '{schema:"durable-workflow.native-image-corpus.v1", native_image:$native, native_image_id:$id, php_reference:$php, runner_revision:$revision, backend:"sqlite", - status:{reference_records:$reference, native_record:$nativeStatus, compare:$compare, update_validation:$updateValidation, - service_calls:$serviceCalls}}' \ + status:{reference_records:$reference, native_record:$nativeStatus, compare:$compare}}' \ > parity-evidence/native-image-corpus.json cat parity-evidence/native-image-corpus.json test "$status" -eq 0 test "$native_status" -eq 0 test "$compare_status" -eq 0 -test "$update_validation_status" -eq 0 -test "$service_calls_status" -eq 0 diff --git a/scripts/conformance/server-parity/php-maintenance-window.sh b/scripts/conformance/server-parity/php-maintenance-window.sh deleted file mode 100644 index ec55e95a7..000000000 --- a/scripts/conformance/server-parity/php-maintenance-window.sh +++ /dev/null @@ -1,65 +0,0 @@ -# Sourced by SQLite-job scripts that record with PHP's maintenance loop stopped -# (Server #721, #723). -# -# run_without_maintenance SCHEDULE -- COMMAND... -# -# Runs COMMAND while the SQLite job's PHP maintenance container is stopped, -# then restarts it. Restoration is armed before the stop is attempted and -# stays armed until the container has stayed running for a sustained check, -# so a failed stop, a failed COMMAND or a restart that exits at once never -# leaves it stopped. SCHEDULE records the container's state before, during -# and after. - -maintenance_poll_seconds="${DW_PARITY_MAINTENANCE_POLL_SECONDS:-1}" -# The SQLite job's container, named from this job's identity. -maintenance_container="dw-parity-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}-${GITHUB_JOB:-parity}-php-maintenance" - -maintenance_state() { - docker inspect -f '{{.State.Status}}' "$maintenance_container" 2>/dev/null || echo unknown - return 0 -} - -# Running at each of three checks, one poll interval apart. -maintenance_stays_running() { - local check - for check in 1 2 3; do - [[ "$(maintenance_state)" == running ]] || return 1 - sleep "$maintenance_poll_seconds" - done - [[ "$(maintenance_state)" == running ]] -} - -# Up to three starts, each followed by the sustained check. -maintenance_restore() { - local attempt - for attempt in 1 2 3; do - docker start "$maintenance_container" >/dev/null 2>&1 || true - if maintenance_stays_running; then - return 0 - fi - done - echo "maintenance container $maintenance_container did not stay running: $(maintenance_state)" >&2 - return 1 -} - -run_without_maintenance() { - local schedule="$1" - shift - local separator="$1" - [[ "$separator" == -- ]] && shift - # The job started this container as "$RESOURCE_SCOPE-php-maintenance". - [[ "$maintenance_container" == "$RESOURCE_SCOPE-php-maintenance" ]] - echo "before: $(maintenance_state)" > "$schedule" - grep -qx 'before: running' "$schedule" - trap 'maintenance_restore || true' EXIT - docker stop --time 2 "$maintenance_container" >/dev/null - echo "during php record: $(maintenance_state)" >> "$schedule" - grep -qx 'during php record: exited' "$schedule" - "$@" - if ! maintenance_restore; then - echo "after php record: $(maintenance_state)" >> "$schedule" - return 1 - fi - trap - EXIT - echo "after php record: running for ${maintenance_poll_seconds}s x3" >> "$schedule" -} diff --git a/scripts/conformance/server-parity/role-profile.sh b/scripts/conformance/server-parity/role-profile.sh index 1681daef4..4e103df21 100644 --- a/scripts/conformance/server-parity/role-profile.sh +++ b/scripts/conformance/server-parity/role-profile.sh @@ -8,7 +8,7 @@ test -n "$RESOURCE_SCOPE" scope_identity="$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT-$GITHUB_JOB" case "$RESOURCE_SCOPE" in *"$scope_identity"*) ;; *) exit 2 ;; esac backend="$1" -image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.4.json) +image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.5.json) rust_image=mirror.gcr.io/library/rust@sha256:ba81bc3eaa4422af576c0262515d96b0111a628a6ccc2c86557cf55c9a4bbee0 fixture=tests/Fixtures/ServerParityProfiles/role-tokens/admission-role-tokens.json credentials=(-e DW_AUTH_TOKEN=parity-role-legacy -e DW_WORKER_TOKEN=parity-role-worker diff --git a/scripts/conformance/server-parity/update-validation-sqlite.sh b/scripts/conformance/server-parity/update-validation-sqlite.sh deleted file mode 100644 index c861f1bf5..000000000 --- a/scripts/conformance/server-parity/update-validation-sqlite.sh +++ /dev/null @@ -1,95 +0,0 @@ -#!/usr/bin/env bash -# update-validation-sqlite.sh [fixture name] -# -# Server #723. The SQLite job records the update-validation fixture apart from -# its main corpus, for all three targets, and compares the three records. The -# optional third argument names another fixture recorded the same way: -# service-calls, whose published PHP record under SQLite writer contention has -# refused the first call to a limited operation with 429 (Server #796). -# -# The published PHP record runs with PHP's maintenance loop stopped. On SQLite -# the API server and that loop share one database file. Under the loop's writer -# contention, published PHP 2.11.1 loses its own validation-timeout and -# lease-reclaim writes to "database is locked", and its validation poll answers -# without the typed backend_lock_pressure refusal. Locally 10 of 15 runs failed -# with the loop running and 5 of 5 passed with it stopped. This fixture tests -# update validation, not writer contention, and needs no repair pass or -# schedule. The loop restarts right after the PHP record. The MySQL and -# PostgreSQL jobs record this fixture in their main corpus with their loops -# running. -# -# Server #754. Even with the loop stopped, the probe's own concurrent updates -# can cost published PHP a write: 2.11.1 claims a validation task with a read -# and then a write in one deferred SQLite transaction, and when another writer -# commits in between, SQLite refuses the upgrade at once ("database is -# locked"), whatever the busy timeout. Current PHP retries that write; the -# published reference cannot. So when the PHP record fails and the PHP server -# logged that error during it, the PHP record runs once more under a fresh -# workflow prefix, and embedded and Rust record under that prefix too, as -# compare requires. Any other PHP failure, and any embedded or Rust failure, -# fails at once. A retry leaves a warning and the first attempt's files. -set -euo pipefail -php_url="$1" -native_url="$2" -name="${3:-update-validation}" -[[ "$name" =~ ^[a-z0-9-]+$ ]] -[[ -n "$RESOURCE_SCOPE" ]] -[[ -n "$DW_PARITY_RUNNER_REVISION" ]] -fixture="tests/Fixtures/ServerParity/$name.json" -test -f "$fixture" -image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.4.json) -. scripts/conformance/server-parity/php-maintenance-window.sh - -prefix=parity-v1 -http_record() { - local url="$1" target="$2" - docker run --rm --user=1000:1000 --entrypoint node --network "$RESOURCE_SCOPE" \ - -v "$PWD:/source:ro" -v "$PWD/parity-evidence:/evidence" -w /source \ - -e DW_PARITY_TOKEN=parity-test-token -e DW_PARITY_RUNNER_REVISION "$image" scripts/conformance/server-parity.mjs record \ - --mode http --url "$url" --target "$target" --output "/evidence/$name-$target.json" --fixture "$fixture" \ - --prefix "$prefix" -} - -# run_without_maintenance runs its command under errexit's exemption, so the -# command keeps the record's status instead of returning it. The record's own -# start and end bound the PHP log lines that can justify a retry. -php_status=0 -php_record() { - php_status=0 - record_started=$(date -u +%Y-%m-%dT%H:%M:%S.%NZ) - http_record "$php_url" php || php_status=$? - record_finished=$(date -u +%Y-%m-%dT%H:%M:%S.%NZ) -} - -run_without_maintenance "parity-evidence/$name-maintenance-schedule.txt" -- php_record -if ((php_status != 0)); then - locked="parity-evidence/$name-php.attempt-1.locked.txt" - { docker logs --since "$record_started" --until "$record_finished" "$RESOURCE_SCOPE-server" 2>&1 || true; } \ - | grep -m 5 'database is locked' | cut -c 1-400 > "$locked" || true - if [[ ! -s "$locked" ]]; then - rm -f "$locked" - exit "$php_status" - fi - for file in "$name-php.json" "$name-php.failure-excerpt.txt"; do - if [[ -e "parity-evidence/$file" ]]; then - mv "parity-evidence/$file" "parity-evidence/${file/$name-php./$name-php.attempt-1.}" - fi - done - excerpt=$(tr '\n' ' ' < "parity-evidence/$name-php.attempt-1.failure-excerpt.txt" 2>/dev/null || true) - echo "::warning title=Published PHP $name record retried::The PHP record failed (${excerpt:-no excerpt}) after published PHP logged 'database is locked'; recording all three targets under a fresh prefix (Server #754)." - prefix=parity-v1-retry - run_without_maintenance "parity-evidence/$name-maintenance-schedule-retry.txt" -- php_record - ((php_status == 0)) || exit "$php_status" -fi -docker run --rm --user=1000:1000 --entrypoint node \ - -v "$PWD:/source:ro" -v "$PWD/parity-state:/state" -v "$PWD/parity-evidence:/evidence" -w /source \ - -e DB_CONNECTION=sqlite -e DB_DATABASE=/state/embedded.sqlite -e DW_MODE=embedded \ - -e CACHE_STORE=file -e QUEUE_CONNECTION=database -e DW_PARITY_RUNNER_REVISION "$image" scripts/conformance/server-parity.mjs record \ - --mode embedded --application-root /app --target embedded --output "/evidence/$name-embedded.json" --fixture "$fixture" \ - --prefix "$prefix" -http_record "$native_url" rust -docker run --rm --user=1000:1000 --entrypoint node \ - -v "$PWD:/source:ro" -v "$PWD/parity-evidence:/evidence" -w /source "$image" \ - scripts/conformance/server-parity.mjs compare \ - "/evidence/$name-php.json" "/evidence/$name-rust.json" "/evidence/$name-embedded.json" \ - --fixture "$fixture" > "parity-evidence/$name-comparison.json" diff --git a/tests/Fixtures/ServerParity/bridge-adapters.json b/tests/Fixtures/ServerParity/bridge-adapters.json index d1377b26a..e7e66463a 100644 --- a/tests/Fixtures/ServerParity/bridge-adapters.json +++ b/tests/Fixtures/ServerParity/bridge-adapters.json @@ -136,6 +136,50 @@ } } } + }, + "foreign_instance_names_namespace": { + "issue": "https://github.com/durable-workflow/server/issues/777", + "summary": "Published 2.11.5's Core 2.15.1 checks a reserved instance's namespace before its type, but its BridgeAdapterController still echoes the control plane's message, so a bridge start for an id another namespace holds is refused with a different-namespace message whatever its type. Server #811 (after 2.11.5) refuses it as unavailable, naming neither the type nor the namespace.", + "published": { + "start_other_type": { + "status": 422, + "response": { + "schema": "durable-workflow.v2.bridge-adapter-outcome.contract", + "version": 1, + "adapter": "acme.crm:v1", + "action": "start_workflow", + "accepted": false, + "outcome": "rejected", + "reason": "unknown_target", + "idempotency_key": "k-6", + "message": "Workflow instance [{id}] cannot be reused with a different namespace.", + "target": { + "workflow_id": "{id}", + "workflow_type": "parity.v1.bridge", + "task_queue": "{id}-bra-queue" + } + } + }, + "start_other_namespace": { + "status": 422, + "response": { + "schema": "durable-workflow.v2.bridge-adapter-outcome.contract", + "version": 1, + "adapter": "acme.crm:v1", + "action": "start_workflow", + "accepted": false, + "outcome": "rejected", + "reason": "unknown_target", + "idempotency_key": "k-7", + "message": "Workflow instance [{id}] cannot be reused with a different namespace.", + "target": { + "workflow_id": "{id}", + "workflow_type": "parity.v1.echo", + "task_queue": "{id}-bra-queue" + } + } + } + } } }, "expected": { @@ -584,5 +628,5 @@ ] } }, - "contract": "Bridge adapter webhooks (BridgeAdapterController) as published Server 2.11.1 serves them, in their own namespace: the adapter name, payload and action refusals (422, the target redacted to its routing fields); start_workflow accepted (202), returned as a duplicate (200) under the default policy, rejected under reject_duplicate (422), with a workflow ID derived from the adapter and the idempotency key when none is given, and on the default queue for an unconfigured type; signal_workflow and update_workflow refused for an unknown workflow or a malformed target, accepted (202), and deduplicated (200) by adapter, action, idempotency key and target name. Run IDs are labels in order of first appearance and derived workflow IDs are checked against PHP's derivation. Two published defects are bounded: a bridge update to an SDK-worker workflow skips external update admission and is rejected workflow_definition_unavailable, and a start for an id another namespace holds names that namespace's type; for each, the published answers or the intended ones (acceptance and deduplication; a foreign instance refused as unavailable) are accepted. Embedded applications have no HTTP bridge adapters." + "contract": "Bridge adapter webhooks (BridgeAdapterController) as published Server 2.11.1 serves them, in their own namespace: the adapter name, payload and action refusals (422, the target redacted to its routing fields); start_workflow accepted (202), returned as a duplicate (200) under the default policy, rejected under reject_duplicate (422), with a workflow ID derived from the adapter and the idempotency key when none is given, and on the default queue for an unconfigured type; signal_workflow and update_workflow refused for an unknown workflow or a malformed target, accepted (202), and deduplicated (200) by adapter, action, idempotency key and target name. Run IDs are labels in order of first appearance and derived workflow IDs are checked against PHP's derivation. Two published defects are bounded: a bridge update to an SDK-worker workflow skips external update admission and is rejected workflow_definition_unavailable, and a start for an id another namespace holds names that namespace's type (published 2.11.1 to 2.11.4) or the namespace difference (2.11.5); for each, the published answers or the intended ones (acceptance and deduplication; a foreign instance refused as unavailable) are accepted. Embedded applications have no HTTP bridge adapters." } diff --git a/tests/Fixtures/ServerParity/repair-pass.json b/tests/Fixtures/ServerParity/repair-pass.json index d18609afb..e4657ac83 100644 --- a/tests/Fixtures/ServerParity/repair-pass.json +++ b/tests/Fixtures/ServerParity/repair-pass.json @@ -236,7 +236,10 @@ "service_call_timeout_failures": [], "service_call_dispatch_candidates": 0, "service_call_dispatches_enqueued": 0, - "service_call_dispatch_failures": [] + "service_call_dispatch_failures": [], + "service_call_linked_candidates": 0, + "service_call_links_reconciled": 0, + "service_call_linked_failures": [] } ], "trimmed filters": [ @@ -275,7 +278,10 @@ "service_call_timeout_failures": [], "service_call_dispatch_candidates": 0, "service_call_dispatches_enqueued": 0, - "service_call_dispatch_failures": [] + "service_call_dispatch_failures": [], + "service_call_linked_candidates": 0, + "service_call_links_reconciled": 0, + "service_call_linked_failures": [] } ], "empty instance": [ @@ -313,7 +319,10 @@ "service_call_timeout_failures": [], "service_call_dispatch_candidates": 0, "service_call_dispatches_enqueued": 0, - "service_call_dispatch_failures": [] + "service_call_dispatch_failures": [], + "service_call_linked_candidates": 0, + "service_call_links_reconciled": 0, + "service_call_linked_failures": [] } ], "run ids not a list": [ diff --git a/tests/Fixtures/ServerParity/service-calls.json b/tests/Fixtures/ServerParity/service-calls.json index 9580dc93b..9ef46ed0c 100644 --- a/tests/Fixtures/ServerParity/service-calls.json +++ b/tests/Fixtures/ServerParity/service-calls.json @@ -2,7 +2,7 @@ "schema": "durable-workflow.server-parity-fixture/v1", "id": "service-calls", "review_state": "reviewed", - "contract": "Published Server 2.11.1 Nexus service-call execution (ServiceCatalogController over Workflow 2.10.2's DefaultServiceControlPlane, operator): POST .../operations/{operation}/execute validates like Laravel (mode, wait, ids, labels, memo, search attributes, payload codec and an Avro arguments payload or {codec, blob} envelope; a provider external_storage reference is refused by the runtime transport before routing), then admits at the service boundary and writes an audit row for every decision. Required roles, caller-namespace deny and allow lists answer 403 with the target redacted (show on the audit row is not redacted), an open circuit 503 circuit_open with retry 30, an invalid shared limit 503 service_boundary_admission_unavailable. Shared admission limits count per caller boundary: a per-minute rate refuses 429 rate_limit_exceeded once the window is spent, a concurrency limit 429 concurrency_limit_exceeded while a lease is taken; an async call keeps its lease, a sync call returns it when the request ends. An activity_execution or invocable_http call stays accepted with its handler stamped (an uppercase ULID unless the binding names one); a binding without a target is a handler failure (409), retried in the request up to retry_policy.max_attempts unless its type is non-retryable. A deadline policy sets call_deadline_at; one already past is admitted and timed out (200, accepted). GET .../service-calls/{id} reads a call of the operation by trimmed id; POST .../cancel cancels an open call once (repeat or terminal 409 service_call_terminal, a policy with mode none 409 cancellation_not_allowed), validating the reason after the lookup. A repeated idempotency key replays the first call in the describe shape, re-checking authorization (a refusal writes a new audit row). Reusing a key from another caller namespace is outside this fixture until the Workflow #931 correction is published: published 2.11.3 and 2.11.4 replay the first call and rebind its handler identity, and the correction refuses the foreign caller. The catalog reads list the recorded calls by scope, status and outcome filter or bucket, newest accepted first (each list holds only accepted or only rejected calls, since a rejected call's null accepted_at sorts by the database's NULL order), and read one call's detail with its caller link; an unknown call is 404 catalog_object_not_found. Generated ids and times are placeholders; @own is the fixture's namespace. Embedded applications have no HTTP service calls.", + "contract": "Published Server 2.11.1 Nexus service-call execution (ServiceCatalogController over Workflow 2.10.2's DefaultServiceControlPlane, operator): POST .../operations/{operation}/execute validates like Laravel (mode, wait, ids, labels, memo, search attributes, payload codec and an Avro arguments payload or {codec, blob} envelope; a provider external_storage reference is refused by the runtime transport before routing), then admits at the service boundary and writes an audit row for every decision. Required roles, caller-namespace deny and allow lists answer 403 with the target redacted (show on the audit row is not redacted), an open circuit 503 circuit_open with retry 30, an invalid shared limit 503 service_boundary_admission_unavailable. Shared admission limits count per caller boundary: a per-minute rate refuses 429 rate_limit_exceeded once the window is spent, a concurrency limit 429 concurrency_limit_exceeded while a lease is taken; an async call keeps its lease, a sync call returns it when the request ends. An activity_execution or invocable_http call stays accepted with its handler stamped (an uppercase ULID unless the binding names one); a binding without a target is a handler failure (409), retried in the request up to retry_policy.max_attempts unless its type is non-retryable. A deadline policy sets call_deadline_at; one already past is admitted and timed out (200, accepted). GET .../service-calls/{id} reads a call of the operation by trimmed id; POST .../cancel cancels an open call once (repeat or terminal 409 service_call_terminal, a policy with mode none 409 cancellation_not_allowed), validating the reason after the lookup. A repeated idempotency key replays the first call in the describe shape, re-checking authorization (a refusal writes a new audit row). The key belongs to the operation (Workflow #931, published in Server 2.11.5): reusing it from another caller namespace answers 403 service_operation_forbidden and writes no call or audit row. The catalog reads list the recorded calls by scope, status and outcome filter or bucket, newest accepted first (each list holds only accepted or only rejected calls, since a rejected call's null accepted_at sorts by the database's NULL order), and read one call's detail with its caller link; an unknown call is 404 catalog_object_not_found. Generated ids and times are placeholders; @own is the fixture's namespace. Embedded applications have no HTTP service calls.", "workflow_type": "parity.v1.echo", "input": { "message": "service calls λ", @@ -727,6 +727,15 @@ "arguments": "wwHioz3/VYAiNwoIZG9uZQ==" } }, + { + "label": "replay other caller", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/replay-target/execute", + "body": { + "idempotency_key": "k1", + "caller_namespace": "other" + } + }, { "label": "cancel replayed", "method": "POST", @@ -5054,6 +5063,13 @@ "failure_message": null } ], + "replay other caller": [ + 403, + { + "reason": "service_operation_forbidden", + "message": "The service operation is not authorized for this caller." + } + ], "cancel replayed": [ 200, { diff --git a/tests/Fixtures/ServerParityBaselines/php-2.11.5.json b/tests/Fixtures/ServerParityBaselines/php-2.11.5.json new file mode 100644 index 000000000..528881b00 --- /dev/null +++ b/tests/Fixtures/ServerParityBaselines/php-2.11.5.json @@ -0,0 +1,20 @@ +{ + "schema": "durable-workflow.server-port-artifacts/v1", + "server": { + "version": "2.11.5", + "source_commit": "721b2831975cc898cc39d7528c5ba7e8441fe744", + "images": { + "linux/amd64": "durableworkflow/server@sha256:bcc672859a399d1df234fc304531ab06b7bfa8acad7cb2c9da95f8e7ab5c98b4", + "linux/arm64": "durableworkflow/server@sha256:3160f4f58ab9b144705c3e4d5d97b0881b0891c1f00e9ab6b99b3245a8c3b26a" + } + }, + "workflow": "2.15.1", + "sdk_php": "2.2.6", + "sdk_python": "2.5.0", + "sdk_rust": "3.4.1", + "cli": "2.2.0", + "waterline": "2.3.1", + "sample_app_commit": "4427a537e092f9d92c96d2a4867b9186e0e75ccd", + "capacity_suite": "1.9.0", + "performance_status": "not_measured" +} diff --git a/tests/Unit/PhpMaintenanceWindowTest.mjs b/tests/Unit/PhpMaintenanceWindowTest.mjs deleted file mode 100644 index 207dd0b1a..000000000 --- a/tests/Unit/PhpMaintenanceWindowTest.mjs +++ /dev/null @@ -1,100 +0,0 @@ -// Server #721, #723: SQLite-job records that stop PHP's maintenance loop for -// their PHP record. Every failure path must leave the loop restored or fail -// loudly, and never record a restart that did not stay running. -import test from 'node:test'; -import assert from 'node:assert/strict'; -import {chmodSync, existsSync, mkdtempSync, readFileSync, writeFileSync} from 'node:fs'; -import {tmpdir} from 'node:os'; -import {join, resolve} from 'node:path'; -import {spawnSync} from 'node:child_process'; - -const helper = resolve('scripts/conformance/server-parity/php-maintenance-window.sh'); - -// One container's state in a file; every call is logged. -// SCENARIO=stop-nonzero: the stop takes effect but exits 1. -// SCENARIO=immediate-exit: start exits 0, the container runs for one -// inspection and then exits. -const fakeDocker = `#!/usr/bin/env bash -set -u -dir=$(dirname "$0") -echo "$*" >> "$dir/calls" -test "\${!#}" = dw-parity-7-2-sqlite-php-maintenance || exit 3 -case "$1" in - inspect) - state=$(cat "$dir/state") - if test "$state" = running-once; then echo exited > "$dir/state"; state=running; fi - echo "$state" ;; - stop) echo exited > "$dir/state"; test "$SCENARIO" != stop-nonzero ;; - start) - if test "$SCENARIO" = immediate-exit; then echo running-once > "$dir/state"; else echo running > "$dir/state"; fi ;; - *) exit 2 ;; -esac -`; - -function run(scenario, command, scope = 'dw-parity-7-2-sqlite') { - const dir = mkdtempSync(join(tmpdir(), 'php-maintenance-window-')); - writeFileSync(join(dir, 'docker'), fakeDocker); - chmodSync(join(dir, 'docker'), 0o755); - writeFileSync(join(dir, 'state'), 'running\n'); - writeFileSync(join(dir, 'calls'), ''); - const result = spawnSync('bash', ['-c', - `set -euo pipefail; . "$HELPER"; run_without_maintenance "$SCHEDULE" -- ${command}`], { - encoding: 'utf8', - env: {...process.env, PATH: `${dir}:${process.env.PATH}`, HELPER: helper, - SCHEDULE: join(dir, 'schedule.txt'), SCENARIO: scenario, DW_PARITY_MAINTENANCE_POLL_SECONDS: '0.05', - GITHUB_RUN_ID: '7', GITHUB_RUN_ATTEMPT: '2', GITHUB_JOB: 'sqlite', RESOURCE_SCOPE: scope}, - }); - const read = name => (existsSync(join(dir, name)) ? readFileSync(join(dir, name), 'utf8') : ''); - const calls = read('calls').trim().split('\n'); - return { - status: result.status, - stderr: result.stderr, - schedule: read('schedule.txt').trim().split('\n'), - starts: calls.filter(call => call.startsWith('start ')).length, - stopIndex: calls.findIndex(call => call.startsWith('stop ')), - lastStart: calls.findLastIndex(call => call.startsWith('start ')), - state: read('state').trim(), - }; -} - -test('a normal window stops, runs, and restores a loop that stays running', () => { - const result = run('normal', 'true'); - assert.equal(result.status, 0, result.stderr); - assert.deepEqual(result.schedule, ['before: running', 'during php record: exited', 'after php record: running for 0.05s x3']); - assert.equal(result.starts, 1, 'restoration is disarmed after the sustained check'); - assert.equal(result.state, 'running'); -}); - -test('a stop that takes effect but exits nonzero still restores', () => { - const result = run('stop-nonzero', 'true'); - assert.notEqual(result.status, 0); - assert.ok(result.lastStart > result.stopIndex, 'a start follows the stop'); - assert.equal(result.state, 'running'); - assert.deepEqual(result.schedule, ['before: running']); -}); - -test('a failed PHP record restores the loop', () => { - const result = run('normal', 'false'); - assert.notEqual(result.status, 0); - assert.equal(result.state, 'running'); - assert.ok(result.starts >= 1); - assert.ok(!result.schedule.some(line => line.startsWith('after php record'))); -}); - -test('a restart that exits at once stays armed and fails without recording running', () => { - const result = run('immediate-exit', 'true'); - assert.notEqual(result.status, 0); - assert.ok(result.schedule.includes('after php record: exited'), result.schedule.join('\n')); - assert.ok(!result.schedule.some(line => line.startsWith('after php record: running'))); - // Three attempts after the record, then three more from the still-armed trap. - assert.equal(result.starts, 6); - assert.match(result.stderr, /did not stay running/); -}); - -test('a container outside the job scope is never touched', () => { - const result = run('normal', 'true', 'dw-parity-other-job'); - assert.notEqual(result.status, 0); - assert.equal(result.starts, 0); - assert.equal(result.stopIndex, -1); - assert.equal(result.state, 'running'); -}); diff --git a/tests/Unit/ServerParityFixtureSelectionTest.mjs b/tests/Unit/ServerParityFixtureSelectionTest.mjs index cccbfb1b8..d518cb481 100644 --- a/tests/Unit/ServerParityFixtureSelectionTest.mjs +++ b/tests/Unit/ServerParityFixtureSelectionTest.mjs @@ -1,5 +1,7 @@ -// Fixture selection for parity records and comparisons, and the SQLite job's -// separate update-validation and service-calls records (Server #723, #796). +// Fixture selection for parity records and comparisons. Every database job +// records update-validation and service-calls in its main corpus, with PHP's +// maintenance loop running: published 2.11.5 carries the #726 and #796 fixes +// that the SQLite job's former separate records worked around. import test from 'node:test'; import assert from 'node:assert/strict'; import {readFileSync, readdirSync} from 'node:fs'; @@ -56,21 +58,14 @@ function mainCorpusCommands(job) { return job.split(/(?=docker run )/).filter(command => /server-parity\.mjs (record|compare)/.test(command)); } -test('the SQLite job covers update-validation and service-calls only through their separate records', () => { +test('the SQLite job keeps update-validation and service-calls in its main corpus', () => { const sqlite = jobs()['execution-and-codec']; const commands = mainCorpusCommands(sqlite); assert.equal(commands.length, 4, 'php, embedded and rust records plus their comparison'); - for (const command of commands) { - assert.deepEqual(command.match(/--exclude-fixture \S+/g), [ - '--exclude-fixture tests/Fixtures/ServerParity/update-validation.json', - '--exclude-fixture tests/Fixtures/ServerParity/service-calls.json', - ]); - } + for (const command of commands) assert.doesNotMatch(command, /--exclude-fixture/); + assert.ok(corpus.includes('update-validation.json')); assert.ok(corpus.includes('service-calls.json')); - assert.match(sqlite, /update_validation_status=0\n\s+bash scripts\/conformance\/server-parity\/update-validation-sqlite\.sh http:\/\/parity-server:8080 http:\/\/parity-rust:8080 \|\| update_validation_status=\$\?/); - assert.match(sqlite, /service_calls_status=0\n\s+bash scripts\/conformance\/server-parity\/update-validation-sqlite\.sh http:\/\/parity-server:8080 http:\/\/parity-rust:8080 service-calls \|\| service_calls_status=\$\?/); - assert.match(sqlite, /test "\$update_validation_status" -eq 0/); - assert.match(sqlite, /test "\$service_calls_status" -eq 0/); + assert.doesNotMatch(sqlite, /update-validation-sqlite|run_without_maintenance/); }); test('the MySQL and PostgreSQL jobs keep update-validation and service-calls in their main corpus', () => { @@ -80,23 +75,3 @@ test('the MySQL and PostgreSQL jobs keep update-validation and service-calls in assert.doesNotMatch(all[name], /--exclude-fixture/, name); } }); - -test('the separate record stops maintenance for the PHP record only and compares all three targets', () => { - const script = readFileSync(resolve(root, 'scripts/conformance/server-parity/update-validation-sqlite.sh'), 'utf8'); - // update-validation by default, or the named reviewed fixture. - assert.match(script, /^name="\$\{3:-update-validation\}"\n\[\[ "\$name" =~ \^\[a-z0-9-\]\+\$ \]\]$/m); - assert.match(script, /^fixture="tests\/Fixtures\/ServerParity\/\$name\.json"\ntest -f "\$fixture"$/m); - assert.match(script, /^\. scripts\/conformance\/server-parity\/php-maintenance-window\.sh$/m); - // The PHP record, and its one retry after a lost SQLite write (#754). - const windows = script.match(/^ *run_without_maintenance .*$/gm).map(line => line.trim()); - assert.deepEqual(windows, [ - 'run_without_maintenance "parity-evidence/$name-maintenance-schedule.txt" -- php_record', - 'run_without_maintenance "parity-evidence/$name-maintenance-schedule-retry.txt" -- php_record', - ]); - assert.match(script, /^php_record\(\) \{\n php_status=0\n record_started=\$\(date -u \+%Y-%m-%dT%H:%M:%S\.%NZ\)\n http_record "\$php_url" php \|\| php_status=\$\?\n record_finished=\$\(date -u \+%Y-%m-%dT%H:%M:%S\.%NZ\)\n\}$/m); - assert.match(script, /docker logs --since "\$record_started" --until "\$record_finished" "\$RESOURCE_SCOPE-server"/); - assert.match(script, /^http_record "\$native_url" rust$/m); - assert.match(script, /--mode embedded --application-root \/app --target embedded --output "\/evidence\/\$name-embedded\.json" --fixture "\$fixture"/); - assert.match(script, /"\/evidence\/\$name-php\.json" "\/evidence\/\$name-rust\.json" "\/evidence\/\$name-embedded\.json" \\\n\s+--fixture "\$fixture" > "parity-evidence\/\$name-comparison\.json"/); - assert.match(script, /^set -euo pipefail$/m); -}); diff --git a/tests/Unit/ServerParityImagePullTest.mjs b/tests/Unit/ServerParityImagePullTest.mjs index aa5f7e552..357c0151f 100644 --- a/tests/Unit/ServerParityImagePullTest.mjs +++ b/tests/Unit/ServerParityImagePullTest.mjs @@ -32,7 +32,7 @@ test('each container-heavy fixture job pulls its pinned images first, with retri const pull = steps[1]; assert.match(pull, /- name: Pull the pinned images, retrying registry errors\n/, name); assert.match(pull, /bash scripts\/ci\/pull-images\.sh \\\n/, name); - assert.match(pull, /"\$\(baseline php-2\.11\.4\)"/, name); + assert.match(pull, /"\$\(baseline php-2\.11\.5\)"/, name); for (const image of extra) assert.ok(pull.includes(image), `${name} pulls ${image}`); // Every Rust image the job runs is in its pull list, so a digest bump // cannot leave the pull behind. diff --git a/tests/Unit/ServerParityServiceCallsContractTest.mjs b/tests/Unit/ServerParityServiceCallsContractTest.mjs index aea85a9d0..562f2980f 100644 --- a/tests/Unit/ServerParityServiceCallsContractTest.mjs +++ b/tests/Unit/ServerParityServiceCallsContractTest.mjs @@ -50,7 +50,7 @@ const observation = () => { }; test('the service calls accept the published answers', () => { - assert.deepStrictEqual(checkServiceCalls(fixture, observation(), id), {scope: 'service_calls', cases: 90}); + assert.deepStrictEqual(checkServiceCalls(fixture, observation(), id), {scope: 'service_calls', cases: 91}); }); test('the fixture covers execute, show, cancel and every reachable refusal', () => { diff --git a/tests/Unit/ServerParityUpdateValidationRetryTest.mjs b/tests/Unit/ServerParityUpdateValidationRetryTest.mjs deleted file mode 100644 index d000b4792..000000000 --- a/tests/Unit/ServerParityUpdateValidationRetryTest.mjs +++ /dev/null @@ -1,180 +0,0 @@ -// Server #754: the SQLite job's separate update-validation record retries the -// published PHP record once, and only when that record failed after PHP -// logged "database is locked". Native and embedded records never retry. The -// separate service-calls record (Server #796) runs the same script by name. -import test from 'node:test'; -import assert from 'node:assert/strict'; -import {chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync} from 'node:fs'; -import {tmpdir} from 'node:os'; -import {join, resolve} from 'node:path'; -import {spawnSync} from 'node:child_process'; - -const root = resolve('.'); - -// Records follow OUTCOMES, one "=" per record -// (default pass). fail fails without a lock error; fail-locked fails after PHP -// logs one during the record; fail-lockbefore and fail-lockafter fail with -// one logged 5 s before or after it. `docker logs` honors --since and --until. -const fakeDocker = `#!/usr/bin/env bash -set -u -dir=$(cd "$(dirname "$0")/.." && pwd) -echo "$*" >> "$dir/calls" -case "$1" in - inspect) cat "$dir/state" ;; - stop) echo exited > "$dir/state" ;; - start) echo running > "$dir/state" ;; - logs) - test "\${!#}" = dw-parity-7-2-sqlite-server || exit 3 - since=0 until=99999999999999999999 - while (($# > 1)); do - case "$1" in - --since) since=$(date -d "$2" +%s%N); shift ;; - --until) until=$(date -d "$2" +%s%N); shift ;; - esac - shift - done - touch "$dir/serverlog" - while read -r at line; do - if ((at >= since && at <= until)); then echo "$line"; fi - done < "$dir/serverlog" ;; - run) - command= target= output= prefix= - while (($#)); do - case "$1" in - record | compare) command=$1 ;; - --target) target=$2; shift ;; - --output) output=$2; shift ;; - --prefix) prefix=$2; shift ;; - esac - shift - done - if test "$command" = compare; then echo '{"outcome":"pass"}'; exit 0; fi - attempt=$(( $(cat "$dir/count-$target" 2>/dev/null || echo 0) + 1 )) - echo "$attempt" > "$dir/count-$target" - echo "$target $prefix" >> "$dir/records" - outcome=pass - for entry in \${OUTCOMES:-}; do - if test "\${entry%%=*}" = "$target$attempt"; then outcome=\${entry#*=}; fi - done - file="$dir/parity-evidence/\${output##*/}" - lock="production.ERROR: SQLSTATE[HY000]: General error: 5 database is locked" - echo "$(date +%s%N) 172.18.0.4 - - POST /api/worker/update-validation-tasks/poll 200" >> "$dir/serverlog" - case "$outcome" in - pass) echo '{"outcome":"pass"}' > "$file"; exit 0 ;; - fail-locked) echo "$(date +%s%N) $lock" >> "$dir/serverlog" ;; - fail-lockbefore) echo "$(( $(date +%s%N) - 5000000000 )) $lock" >> "$dir/serverlog" ;; - fail-lockafter) echo "$(( $(date +%s%N) + 5000000000 )) $lock" >> "$dir/serverlog" ;; - esac - echo '{"outcome":"product-fail"}' > "$file" - echo "== update-validation (product-fail): update validation lost_approve" > "\${file%.json}.failure-excerpt.txt" - exit 1 ;; - *) exit 2 ;; -esac -`; - -function run(outcomes, ...name) { - const dir = mkdtempSync(join(tmpdir(), 'update-validation-retry-')); - for (const name of ['scripts', 'tests']) symlinkSync(join(root, name), join(dir, name)); - for (const name of ['bin', 'parity-evidence', 'parity-state']) mkdirSync(join(dir, name)); - writeFileSync(join(dir, 'bin', 'docker'), fakeDocker); - writeFileSync(join(dir, 'bin', 'jq'), '#!/bin/sh\necho fake-image\n'); - for (const tool of ['docker', 'jq']) chmodSync(join(dir, 'bin', tool), 0o755); - writeFileSync(join(dir, 'state'), 'running\n'); - writeFileSync(join(dir, 'records'), ''); - const result = spawnSync('bash', ['scripts/conformance/server-parity/update-validation-sqlite.sh', 'http://parity-server:8080', 'http://parity-rust:8080', ...name], { - cwd: dir, encoding: 'utf8', - env: {...process.env, PATH: `${join(dir, 'bin')}:${process.env.PATH}`, OUTCOMES: outcomes, - RESOURCE_SCOPE: 'dw-parity-7-2-sqlite', GITHUB_RUN_ID: '7', GITHUB_RUN_ATTEMPT: '2', GITHUB_JOB: 'sqlite', - DW_PARITY_RUNNER_REVISION: 'a'.repeat(40), DW_PARITY_MAINTENANCE_POLL_SECONDS: '0'}, - }); - const read = name => readFileSync(join(dir, name), 'utf8'); - return { - status: result.status, stdout: result.stdout, stderr: result.stderr, - records: read('records').trim().split('\n').filter(Boolean), - state: read('state').trim(), - evidence: name => existsSync(join(dir, 'parity-evidence', name)), - read: name => read(join('parity-evidence', name)), - }; -} - -test('a passing PHP record runs every target once under the usual prefix', () => { - const result = run(''); - assert.equal(result.status, 0, result.stderr); - assert.deepEqual(result.records, ['php parity-v1', 'embedded parity-v1', 'rust parity-v1']); - assert.doesNotMatch(result.stdout, /::warning/); - assert.equal(result.evidence('update-validation-php.attempt-1.json'), false); - assert.equal(result.evidence('update-validation-maintenance-schedule-retry.txt'), false); - assert.equal(result.state, 'running'); -}); - -test('a PHP record that lost a write to the SQLite lock runs once more, and every target takes the new prefix', () => { - const result = run('php1=fail-locked'); - assert.equal(result.status, 0, result.stderr); - assert.deepEqual(result.records, ['php parity-v1', 'php parity-v1-retry', 'embedded parity-v1-retry', 'rust parity-v1-retry']); - assert.match(result.stdout, /^::warning title=Published PHP update-validation record retried::The PHP record failed \(== update-validation \(product-fail\): update validation lost_approve \) after published PHP logged 'database is locked'/m); - // The first attempt's record, excerpt and lock lines stay as evidence. - assert.match(result.read('update-validation-php.attempt-1.json'), /product-fail/); - assert.match(result.read('update-validation-php.attempt-1.failure-excerpt.txt'), /lost_approve/); - assert.match(result.read('update-validation-php.attempt-1.locked.txt'), /database is locked/); - assert.match(result.read('update-validation-php.json'), /"pass"/); - assert.equal(result.evidence('update-validation-php.failure-excerpt.txt'), false); - // Both PHP records ran with maintenance stopped, and it is running again. - for (const schedule of ['update-validation-maintenance-schedule.txt', 'update-validation-maintenance-schedule-retry.txt']) { - assert.match(result.read(schedule), /^during php record: exited$/m, schedule); - assert.match(result.read(schedule), /^after php record: running/m, schedule); - } - assert.equal(result.state, 'running'); -}); - -test('a PHP failure without the lock error during the record fails at once', () => { - // No lock error at all, or one PHP logged only before or after the record. - for (const outcome of ['fail', 'fail-lockbefore', 'fail-lockafter']) { - const result = run(`php1=${outcome}`); - assert.equal(result.status, 1, outcome); - assert.deepEqual(result.records, ['php parity-v1'], outcome); - assert.doesNotMatch(result.stdout, /::warning/, outcome); - assert.equal(result.evidence('update-validation-php.attempt-1.locked.txt'), false, outcome); - assert.match(result.read('update-validation-php.failure-excerpt.txt'), /lost_approve/, outcome); - assert.equal(result.state, 'running', outcome); - } -}); - -test('a PHP retry that fails again fails the record', () => { - const result = run('php1=fail-locked php2=fail-locked'); - assert.equal(result.status, 1); - assert.deepEqual(result.records, ['php parity-v1', 'php parity-v1-retry']); - assert.equal(result.state, 'running'); -}); - -test('a native or embedded failure is never retried, even after a PHP retry', () => { - for (const [outcomes, records] of [ - ['rust1=fail-locked', ['php parity-v1', 'embedded parity-v1', 'rust parity-v1']], - ['embedded1=fail', ['php parity-v1', 'embedded parity-v1']], - ['php1=fail-locked rust1=fail', ['php parity-v1', 'php parity-v1-retry', 'embedded parity-v1-retry', 'rust parity-v1-retry']], - ]) { - const result = run(outcomes); - assert.equal(result.status, 1, outcomes); - assert.deepEqual(result.records, records, outcomes); - } -}); - -test('a named fixture records and retries under its own evidence names', () => { - const result = run('php1=fail-locked', 'service-calls'); - assert.equal(result.status, 0, result.stderr); - assert.deepEqual(result.records, ['php parity-v1', 'php parity-v1-retry', 'embedded parity-v1-retry', 'rust parity-v1-retry']); - assert.match(result.stdout, /^::warning title=Published PHP service-calls record retried::/m); - for (const name of ['service-calls-php.json', 'service-calls-embedded.json', 'service-calls-rust.json', 'service-calls-comparison.json', - 'service-calls-php.attempt-1.json', 'service-calls-php.attempt-1.locked.txt', 'service-calls-maintenance-schedule-retry.txt']) { - assert.equal(result.evidence(name), true, name); - } - assert.equal(result.evidence('update-validation-php.json'), false); - assert.equal(result.state, 'running'); -}); - -test('a fixture name outside the reviewed corpus records nothing', () => { - for (const name of ['no-such-fixture', '../ServerParityPending/workflow-task-transient-retry', 'Service-Calls']) { - const result = run('', name); - assert.notEqual(result.status, 0, name); - assert.deepEqual(result.records, [], name); - } -});