diff --git a/.github/workflows/phpunit-feature.yml b/.github/workflows/phpunit-feature.yml index 9cecaf1bf..8810c5c45 100644 --- a/.github/workflows/phpunit-feature.yml +++ b/.github/workflows/phpunit-feature.yml @@ -5,6 +5,7 @@ on: branches: - main pull_request: + merge_group: workflow_dispatch: inputs: corpus_base_ref: @@ -71,6 +72,12 @@ jobs: - name: Require safe PHP image build context run: node --test tests/Unit/PhpImageContextTest.mjs + - name: Run the required checks for merge queue groups + run: node --test tests/Unit/MergeGroupTriggersTest.mjs + + - name: Report one Shared Server fixtures result on every pull request + run: node --test tests/Unit/ServerParityAggregateTest.mjs tests/Unit/ServerParityPartsTest.mjs + - name: Validate embedded Workflow package authority run: php scripts/ci/test-workflow-package-authority.php @@ -85,7 +92,7 @@ jobs: - name: Require versioned worker OpenAPI evolution env: - OPENAPI_BASE_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event_name == 'push' && github.event.before || inputs.corpus_base_ref }} + OPENAPI_BASE_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event_name == 'merge_group' && github.event.merge_group.base_sha || github.event_name == 'push' && github.event.before || inputs.corpus_base_ref }} run: php scripts/ci/check-worker-openapi-evolution.php "$OPENAPI_BASE_REF" - name: Audit locked PHP dependencies @@ -93,7 +100,7 @@ jobs: - name: Require durable codec evidence env: - CORPUS_BASE_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event_name == 'push' && github.event.before || inputs.corpus_base_ref }} + CORPUS_BASE_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event_name == 'merge_group' && github.event.merge_group.base_sha || github.event_name == 'push' && github.event.before || inputs.corpus_base_ref }} run: | set -euo pipefail if [[ ! "$CORPUS_BASE_REF" =~ ^[0-9a-f]{40}$ ]] || [[ "$CORPUS_BASE_REF" =~ ^0+$ ]]; then @@ -167,7 +174,7 @@ jobs: - name: Run feature, Nexus, and regression corpus suites shell: bash env: - CORPUS_BASE_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event_name == 'push' && github.event.before || inputs.corpus_base_ref }} + CORPUS_BASE_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event_name == 'merge_group' && github.event.merge_group.base_sha || github.event_name == 'push' && github.event.before || inputs.corpus_base_ref }} run: | set -euo pipefail if [[ ! "$CORPUS_BASE_REF" =~ ^[0-9a-f]{40}$ ]] || [[ "$CORPUS_BASE_REF" =~ ^0+$ ]]; then @@ -262,7 +269,7 @@ jobs: - name: Select production image qualification for changed build inputs id: image_inputs run: | - if git diff --quiet '${{ github.event.pull_request.base.sha || github.event.before || inputs.corpus_base_ref }}' HEAD -- Dockerfile Dockerfile.dockerignore .dockerignore composer.json composer.lock ':(glob)docker/**'; then + if git diff --quiet '${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before || inputs.corpus_base_ref }}' HEAD -- Dockerfile Dockerfile.dockerignore .dockerignore composer.json composer.lock ':(glob)docker/**'; then echo 'required=false' >> "$GITHUB_OUTPUT" else echo 'required=true' >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/public-boundary.yml b/.github/workflows/public-boundary.yml index 89bfb4a83..763b55446 100644 --- a/.github/workflows/public-boundary.yml +++ b/.github/workflows/public-boundary.yml @@ -2,6 +2,7 @@ name: Public Boundary on: pull_request: + merge_group: push: workflow_dispatch: @@ -28,6 +29,8 @@ jobs: if [[ "${{ github.event_name }}" == "pull_request" ]]; then range="${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}" + elif [[ "${{ github.event_name }}" == "merge_group" ]]; then + range="${{ github.event.merge_group.base_sha }}..${{ github.event.merge_group.head_sha }}" elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then range="-1 ${{ github.sha }}" elif [[ "${{ github.event.before }}" =~ ^0+$ ]]; then diff --git a/.github/workflows/server-parity.yml b/.github/workflows/server-parity.yml index 9960470e7..03ae6484f 100644 --- a/.github/workflows/server-parity.yml +++ b/.github/workflows/server-parity.yml @@ -1,61 +1,9 @@ name: Shared Server fixtures on: + # Pull requests run without a path filter so that the terminal result always + # reports. The `changes` job applies the push path list below to them. pull_request: - paths: - - .github/workflows/server-parity.yml - - scripts/conformance/server-parity* - - scripts/conformance/server-parity/** - - tests/Fixtures/ServerParity/** - - tests/Fixtures/ServerParityBaselines/** - - tests/Fixtures/ServerParityNormalization/** - - tests/Unit/ServerParityRunnerTest.mjs - - tests/Unit/ServerParityArtifactSummaryTest.mjs - - tests/Unit/ServerParityQueryObserverTest.mjs - - tests/Unit/ServerParityCodecDeadlineLoadTest.mjs - - tests/Unit/ServerParityCooperativeContractTest.mjs - - tests/Unit/ServerParityChildCancellationContractTest.mjs - - tests/Support/ServerParityChildCancellationObservation.mjs - - tests/Unit/ServerParityScheduleContractTest.mjs - - tests/Support/ServerParityScheduleObservation.mjs - - tests/Fixtures/ServerParityPending/** - - tests/Fixtures/ServerParityExpected/** - - tests/Fixtures/ServerParityProfiles/** - - tests/Unit/ServerParityVisibilityContractTest.mjs - - tests/Unit/ServerParityAdmissionContractTest.mjs - - tests/Unit/ServerParityRoleTokenContractTest.mjs - - tests/Unit/ServerParityWorkerDeregistrationContractTest.mjs - - tests/Unit/ServerParityLateWorkflowCompletionContractTest.mjs - - tests/Unit/ServerParitySearchAttributeContractTest.mjs - - tests/Unit/ServerParityStorageTestContractTest.mjs - - tests/Unit/ServerParitySchedulePoliciesContractTest.mjs - - tests/Unit/ServerParitySignalsBeforeWorkerContractTest.mjs - - tests/Unit/ServerParityScheduleCatalogContractTest.mjs - - tests/Unit/ServerParityWorkerListingContractTest.mjs - - tests/Unit/ServerParityWorkerReplacementContractTest.mjs - - tests/Unit/ServerParityOperatorMetricsContractTest.mjs - - tests/Unit/ServerParityActivityLeaseRenewalContractTest.mjs - - tests/Unit/ServerParityServiceCatalogContractTest.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/ServerParityWaitingHistoryContractTest.mjs - - tests/Unit/ServerParityTaskRetryContractTest.mjs - - tests/Unit/ServerParityPublishedPatchContractTest.mjs - - tests/Unit/ServerParityPublishedPatchTransportTest.mjs - - tests/Unit/ServerParityLegacyMarkerContractTest.mjs - - tests/Unit/ServerParityRangeRepairContractTest.mjs - - tests/Unit/ServerParitySdkDirectionContractTest.mjs - - tests/Unit/ServerParitySdkChildDirectionContractTest.mjs - - tests/Unit/ServerParitySdkTimerAuthorContractTest.mjs - - tests/Unit/ServerParitySdkSignalDirectionContractTest.mjs - - tests/Support/ServerParityAdmissionObservation.mjs - - tests/Support/ServerParityVisibilityObservation.mjs - - rust/** push: branches: [main] paths: @@ -66,6 +14,7 @@ on: - tests/Fixtures/ServerParityBaselines/** - tests/Fixtures/ServerParityNormalization/** - tests/Unit/ServerParityRunnerTest.mjs + - tests/Unit/ServerParitySdkDirectionLanesTest.mjs - tests/Unit/ServerParityArtifactSummaryTest.mjs - tests/Unit/ServerParityQueryObserverTest.mjs - tests/Unit/ServerParityCodecDeadlineLoadTest.mjs @@ -112,6 +61,7 @@ on: - tests/Support/ServerParityAdmissionObservation.mjs - tests/Support/ServerParityVisibilityObservation.mjs - rust/** + merge_group: workflow_dispatch: # A newer push to the same pull request supersedes the run in progress, so a @@ -131,7 +81,27 @@ env: DW_PARITY_CONTINUE_MEMO_UPSERTS: '1' jobs: + changes: + name: Shared Server fixture inputs + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + run: ${{ steps.classify.outputs.run }} + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + fetch-depth: 2 + persist-credentials: false + - name: Decide whether fixture inputs changed + id: classify + env: + EVENT_NAME: ${{ github.event_name }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: bash scripts/conformance/server-parity/changes.sh >> "$GITHUB_OUTPUT" + published-sdk-adapters: + needs: changes + if: ${{ needs.changes.outputs.run == 'true' }} runs-on: ubuntu-latest timeout-minutes: 25 env: @@ -195,23 +165,6 @@ jobs: path: corrected-marker-adapters retention-days: 1 if-no-files-found: error - - name: Build one bounded optimized native artifact for deadline health - run: bash scripts/conformance/server-parity/codec-operations-build.sh codec-native-build - - name: Share the optimized binary without compiler caches - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: codec-native-build - path: | - codec-native-build/server - codec-native-build/build.json - codec-native-build/SHA256SUMS - retention-days: 1 - if-no-files-found: error - - name: Retire the owned optimized build container - if: always() - run: | - build_container="$RESOURCE_SCOPE-build" - docker rm -f "$build_container" >/dev/null 2>&1 || true - name: Share thin published SDK direction adapters uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: @@ -254,8 +207,38 @@ jobs: retention-days: 1 if-no-files-found: ignore + # The optimized native build only the deadline-health cells use. It runs + # beside the SDK adapters, so the database jobs, which need only the + # adapters, no longer wait for it. + codec-native-build: + needs: changes + if: ${{ needs.changes.outputs.run == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 25 + env: + RESOURCE_SCOPE: dw-codec-native-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - name: Build one bounded optimized native artifact for deadline health + run: bash scripts/conformance/server-parity/codec-operations-build.sh codec-native-build + - name: Share the optimized binary without compiler caches + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: codec-native-build + path: | + codec-native-build/server + codec-native-build/build.json + codec-native-build/SHA256SUMS + retention-days: 1 + if-no-files-found: error + - name: Retire the owned optimized build container + if: always() + run: | + build_container="$RESOURCE_SCOPE-build" + docker rm -f "$build_container" >/dev/null 2>&1 || true + codec-deadline-health: - needs: published-sdk-adapters + needs: [published-sdk-adapters, codec-native-build] runs-on: ubuntu-latest timeout-minutes: 10 strategy: @@ -330,13 +313,18 @@ jobs: mysql-execution: needs: published-sdk-adapters runs-on: ubuntu-latest - # Corrected marker/range/clock coverage expands the job, and the fixture step - # runs every later profile before failing; scenario budgets stay fixed. - # MySQL runs take 75 to 85 minutes; PostgreSQL's jobs take about 60. - timeout-minutes: 105 + # Each part takes about 20 to 35 minutes; the fixture step runs every + # later profile before failing, and scenario budgets stay fixed. + timeout-minutes: 60 strategy: fail-fast: false + # Each database runs in three parts on separate runners: the native + # database suites and TLS checks, the shared corpus with its profiles and + # restart cases, and the separate cohorts. Every part bootstraps the + # same database and native build; no part depends on another. matrix: + label: [mysql-8.0, existing-php-mysql-matrix, mariadb-10.11] + part: [native-tests, corpus, cohorts] include: - label: mysql-8.0 image: mirror.gcr.io/library/mysql@sha256:62fb722c78b24245ddff1796a0fcee4a49cc5b87e0aaaf20c92d1da9e0a2497b @@ -351,6 +339,7 @@ jobs: # Both expanded originals exhausted the 512 MiB DB fixture ceiling. memory: 1g env: + DW_PARITY_PART: ${{ matrix.part }} RESOURCE_SCOPE: dw-mysql-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}-${{ matrix.label }} MYSQL_IMAGE: ${{ matrix.image }} DB_MEMORY: ${{ matrix.memory }} @@ -464,13 +453,13 @@ jobs: "$layout" artisan server:bootstrap --force > parity-evidence/published-bootstrap.txt # Compiling the library tests with two jobs peaks near 4.2 GB; a 4 GB limit OOM-killed rustc (exit 101). docker run --rm --user="$(id -u):$(id -g)" --network "$RESOURCE_SCOPE" --cpus=2 --memory=6g --memory-swap=6g \ - -e CARGO_HOME=/cargo -e CARGO_TARGET_DIR=/target -e CARGO_BUILD_JOBS=2 \ + -e CARGO_HOME=/cargo -e CARGO_TARGET_DIR=/target -e CARGO_BUILD_JOBS=2 -e DW_PARITY_PART \ -e DW_TEST_MYSQL_URL=mysql://root:parity-disposable-password@parity-mysql:3306/php_ref \ -e DW_EXECUTION_MYSQL_URL=mysql://root:parity-disposable-password@parity-mysql:3306/php_ref \ -v "$PWD:/source:ro" -v "$PWD/cargo-home:/cargo" -v "$PWD/rust-target:/target" \ -v "$PWD/parity-evidence:/evidence" -w /source/rust --entrypoint bash \ mirror.gcr.io/library/rust@sha256:ba81bc3eaa4422af576c0262515d96b0111a628a6ccc2c86557cf55c9a4bbee0 -c \ - 'cargo test --locked --offline --lib runtime::mysql:: -- --ignored --skip runtime::mysql::tests::bootstrap_child --nocapture --test-threads=2 > /evidence/mysql-tests.txt && cargo test --locked --offline --test execution -- --nocapture --test-threads=2 > /evidence/mysql-execution-tests.txt && cargo build --locked --offline' + 'if [ "$DW_PARITY_PART" = native-tests ]; then cargo test --locked --offline --lib runtime::mysql:: -- --ignored --skip runtime::mysql::tests::bootstrap_child --nocapture --test-threads=2 > /evidence/mysql-tests.txt && cargo test --locked --offline --test execution -- --nocapture --test-threads=2 > /evidence/mysql-execution-tests.txt; fi && cargo build --locked --offline' docker run --rm --user="$(id -u):$(id -g)" --network "$RESOURCE_SCOPE" \ -e DW_RUST_EXPERIMENTAL=1 -e DB_CONNECTION=mysql -e DB_HOST=parity-mysql -e DB_DATABASE=native_ref \ -e DB_USERNAME=root -e DB_PASSWORD=parity-disposable-password \ @@ -493,6 +482,7 @@ jobs: -e CACHE_STORE=file -e QUEUE_CONNECTION=database -e DW_AUTH_TOKEN=parity-test-token \ "$image" artisan server:bootstrap --force > parity-evidence/published-upgrade-bootstrap.txt - name: Compare three runtimes, recover killed native nodes and refuse published PHP takeover + if: ${{ matrix.part == 'corpus' }} id: prepare_runtime_comparison run: | set -euo pipefail @@ -617,47 +607,8 @@ jobs: bash scripts/conformance/server-parity/legacy-marker-profile.sh mysql http://parity-php:8080 http://parity-native-a:8080 bash scripts/conformance/server-parity/range-repair-profile.sh mysql http://parity-native-a:8080 bash scripts/conformance/server-parity/range-repair-profile.sh mysql http://parity-native-a:8080 pending-native - bash scripts/conformance/server-parity/sdk-direction-profile.sh mysql http://parity-native-a:8080 - docker stop --time 30 "$RESOURCE_SCOPE-sdk-directions-server" - bash scripts/conformance/server-parity/sdk-direction-profile.sh mysql http://parity-native-a:8080 child - docker stop --time 30 "$RESOURCE_SCOPE-sdk-child-directions-server" - bash scripts/conformance/server-parity/sdk-direction-profile.sh mysql http://parity-native-a:8080 timer - docker stop --time 30 "$RESOURCE_SCOPE-sdk-timers-server" - # Separate queues/databases/evidence; preserve statuses even after children exit. - ( - signal_status=0 - bash scripts/conformance/server-parity/sdk-direction-profile.sh mysql http://parity-native-a:8080 signal > parity-evidence/sdk-signals-profile.log 2>&1 || signal_status=$? - printf '%s\n' "$signal_status" > parity-evidence/sdk-signals-profile.status - ) & - ( - query_status=0 - bash scripts/conformance/server-parity/sdk-direction-profile.sh mysql http://parity-native-a:8080 query > parity-evidence/sdk-queries-profile.log 2>&1 || query_status=$? - printf '%s\n' "$query_status" > parity-evidence/sdk-queries-profile.status - ) & - update_status=0 - bash scripts/conformance/server-parity/sdk-direction-profile.sh mysql http://parity-native-a:8080 update || update_status=$? - docker stop --time 30 "$RESOURCE_SCOPE-sdk-updates-server" || true - failure_status=0 - bash scripts/conformance/server-parity/sdk-direction-profile.sh mysql http://parity-native-a:8080 update-failure || failure_status=$? - wait - signal_status=$(cat parity-evidence/sdk-signals-profile.status) - query_status=$(cat parity-evidence/sdk-queries-profile.status) - cat parity-evidence/sdk-signals-profile.log parity-evidence/sdk-queries-profile.log - for completed_profile in sdk-signals sdk-queries sdk-update-failures; do - docker stop --time 30 "$RESOURCE_SCOPE-$completed_profile-server" || true - done - namespace_signal_status=0 - bash scripts/conformance/server-parity/sdk-direction-profile.sh mysql http://parity-native-a:8080 namespace-signal || namespace_signal_status=$? - test "$signal_status" -eq 0 - test "$query_status" -eq 0 - test "$update_status" -eq 0 - test "$failure_status" -eq 0 - test "$namespace_signal_status" -eq 0 - docker stop --time 30 "$RESOURCE_SCOPE-sdk-namespace-signals-server" || true - selected_run_signal_status=0 - bash scripts/conformance/server-parity/sdk-direction-profile.sh mysql http://parity-native-a:8080 selected-run-signal || selected_run_signal_status=$? - docker stop --time 30 "$RESOURCE_SCOPE-sdk-selected-run-signals-server" || true - test "$selected_run_signal_status" -eq 0 + # Every SDK direction profile, in three concurrent lanes. + bash scripts/conformance/server-parity/sdk-direction-lanes.sh mysql http://parity-native-a:8080 docker run --rm --user=1000:1000 --network "$RESOURCE_SCOPE" --entrypoint sh \ -v "$PWD:/source:ro" -v "$PWD/parity-evidence:/evidence" -w /source \ -e DW_PARITY_TOKEN=parity-test-token "$image" -ec ' @@ -727,16 +678,45 @@ jobs: # check has run, so one difference cannot hide the rest. test "$native_record_status" -eq 0 test "$compare_status" -eq 0 + - name: Start the shared native node the cohorts use + id: prepare_cohorts + 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.1.json) + docker run --rm --user="$(id -u):$(id -g)" --entrypoint composer \ + -e COMPOSER_HOME=/tmp/composer -v "$PWD:/source" -w /source "$image" \ + 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 \ + --user="$(id -u):$(id -g)" --cpus=1 --memory=256m --memory-swap=256m \ + -e DW_RUST_EXPERIMENTAL=1 -e DW_AUTH_TOKEN=parity-test-token -e DW_BIND_ADDRESS=0.0.0.0:8080 \ + -e DB_CONNECTION=mysql -e DB_HOST=parity-mysql -e DB_DATABASE=native_ref \ + -e DB_USERNAME=root -e DB_PASSWORD=parity-disposable-password \ + -v "$PWD/rust-target:/target:ro" --tmpfs /state --entrypoint /target/debug/durable-workflow-server \ + mirror.gcr.io/library/rust@sha256:ba81bc3eaa4422af576c0262515d96b0111a628a6ccc2c86557cf55c9a4bbee0 + ready=0 + for attempt in $(seq 1 30); do + if docker run --rm --user=1000:1000 --network "$RESOURCE_SCOPE" --entrypoint curl "$image" \ + -fsS http://parity-native-b:8080/api/ready > parity-evidence/cohort-native-readiness.json; then + ready=1 + break + fi + sleep 1 + done + test "$ready" = 1 - name: Compare separate configured role-token cohort + if: ${{ matrix.part == 'cohorts' }} run: bash scripts/conformance/server-parity/role-profile.sh mysql - name: Back up, take over and resume a separate published PHP upgrade source + if: ${{ matrix.part == 'cohorts' }} timeout-minutes: 8 run: bash scripts/conformance/server-parity/upgrade-source-profile.sh mysql - name: Retire upgrade source cohort - if: always() + if: ${{ always() && matrix.part == 'cohorts' }} timeout-minutes: 3 run: bash scripts/conformance/server-parity/upgrade-source-cleanup.sh mysql - name: Back up, take over and resume a published PHP 2.9.7 upgrade source + if: ${{ matrix.part == 'cohorts' }} timeout-minutes: 8 env: DW_PARITY_UPGRADE_LABEL: php297 @@ -744,7 +724,7 @@ jobs: DW_PARITY_UPGRADE_PHP_IMAGE: ghcr.io/durable-workflow/server@sha256:30c34c5f3256a812fbccbe505a068309b58b0c14d01ffff0465d88604cb36d50 run: bash scripts/conformance/server-parity/upgrade-source-profile.sh mysql - name: Retire PHP 2.9.7 upgrade source cohort - if: always() + if: ${{ always() && matrix.part == 'cohorts' }} timeout-minutes: 3 env: DW_PARITY_UPGRADE_LABEL: php297 @@ -752,35 +732,36 @@ jobs: DW_PARITY_UPGRADE_PHP_IMAGE: ghcr.io/durable-workflow/server@sha256:30c34c5f3256a812fbccbe505a068309b58b0c14d01ffff0465d88604cb36d50 run: bash scripts/conformance/server-parity/upgrade-source-cleanup.sh mysql - name: Compare corrected markers, ranges, clocks and activity expiry - if: ${{ !cancelled() && steps.prepare_runtime_comparison.outcome == 'success' }} - # The preceding process-loss case deliberately kills native-a. + if: ${{ !cancelled() && matrix.part == 'cohorts' && steps.prepare_cohorts.outcome == 'success' }} + # The cohorts' own native node; the corpus part kills its native-a. run: bash scripts/conformance/server-parity/corrected-marker-shared-profile.sh mysql http://parity-native-b:8080 - name: Retire corrected cohort resources - if: always() + if: ${{ always() && matrix.part == 'cohorts' }} run: bash scripts/conformance/server-parity/corrected-marker-cleanup.sh mysql - name: Compare installed local external payload consumers - if: ${{ !cancelled() }} + if: ${{ !cancelled() && matrix.part == 'cohorts' }} timeout-minutes: 10 run: bash scripts/conformance/server-parity/external-consumer-profile.sh mysql - name: Retire local external consumer resources - if: always() + if: ${{ always() && matrix.part == 'cohorts' }} timeout-minutes: 5 run: bash scripts/conformance/server-parity/external-consumer-cleanup.sh mysql - name: Compare published query deadlines and installed Worker authority - if: ${{ !cancelled() }} + if: ${{ !cancelled() && matrix.part == 'cohorts' }} timeout-minutes: 10 run: bash scripts/conformance/server-parity/query-authority-cohort.sh mysql - name: Retire query authority resources - if: always() + if: ${{ always() && matrix.part == 'cohorts' }} timeout-minutes: 5 run: bash scripts/conformance/server-parity/query-authority-cleanup.sh mysql - name: Compare pinned PHP, embedded and native worker-fencing source cohort - if: ${{ !cancelled() }} + if: ${{ !cancelled() && matrix.part == 'cohorts' }} run: bash scripts/conformance/server-parity/worker-fencing-profile.sh mysql - name: Compare SDK temporary database pressure with declared fault limits - if: ${{ !cancelled() }} + if: ${{ !cancelled() && matrix.part == 'cohorts' }} run: bash scripts/conformance/server-parity/worker-fencing-profile.sh mysql pressure - name: Verify TLS certificate trust and hostname checks + if: ${{ matrix.part == 'native-tests' }} run: | set -euo pipefail docker run --rm --user="$(id -u):$(id -g)" --network "$RESOURCE_SCOPE" \ @@ -869,26 +850,32 @@ jobs: if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: mysql-execution-${{ matrix.label }} + name: mysql-execution-${{ matrix.label }}-${{ matrix.part }} path: parity-summary retention-days: 1 postgres-storage: needs: published-sdk-adapters runs-on: ubuntu-latest - # Corrected marker/range/clock coverage expands the job, and the fixture step - # runs every later profile before failing; scenario budgets stay fixed. - # MySQL runs take 75 to 85 minutes; PostgreSQL's jobs take about 60. - timeout-minutes: 105 + # Each part takes about 20 to 35 minutes; the fixture step runs every + # later profile before failing, and scenario budgets stay fixed. + timeout-minutes: 60 strategy: fail-fast: false + # Each database runs in three parts on separate runners: the native + # database suites and TLS checks, the shared corpus with its profiles and + # restart cases, and the separate cohorts. Every part bootstraps the + # same database and native build; no part depends on another. matrix: + label: [existing-php-matrix, postgres-17] + part: [native-tests, corpus, cohorts] include: - label: existing-php-matrix image: mirror.gcr.io/library/postgres@sha256:f1c3376c26f2609ab9f29f71f824103fe2fcd8ee0346485cb6122a4f93df6f94 - label: postgres-17 image: mirror.gcr.io/library/postgres@sha256:3645570cccdfa447589da9f57dd740faa29b30938e861289a5574b6ca6b03826 env: + DW_PARITY_PART: ${{ matrix.part }} RESOURCE_SCOPE: dw-pg-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}-${{ matrix.label }} POSTGRES_IMAGE: ${{ matrix.image }} steps: @@ -976,13 +963,13 @@ jobs: "$layout" artisan server:bootstrap --force > parity-evidence/published-bootstrap.txt # Compiling the library tests with two jobs peaks near 4.2 GB; a 4 GB limit OOM-killed rustc (exit 101). docker run --rm --user="$(id -u):$(id -g)" --network "$RESOURCE_SCOPE" --cpus=2 --memory=6g --memory-swap=6g \ - -e CARGO_HOME=/cargo -e CARGO_TARGET_DIR=/target -e CARGO_BUILD_JOBS=2 \ + -e CARGO_HOME=/cargo -e CARGO_TARGET_DIR=/target -e CARGO_BUILD_JOBS=2 -e DW_PARITY_PART \ -e DW_TEST_POSTGRES_URL=postgres://parity:parity-disposable-password@parity-pg:5432/php_ref \ -e DW_EXECUTION_POSTGRES_URL=postgres://parity:parity-disposable-password@parity-pg:5432/php_ref \ -v "$PWD:/source:ro" -v "$PWD/cargo-home:/cargo" -v "$PWD/rust-target:/target" \ -v "$PWD/parity-evidence:/evidence" -w /source/rust --entrypoint bash \ mirror.gcr.io/library/rust@sha256:ba81bc3eaa4422af576c0262515d96b0111a628a6ccc2c86557cf55c9a4bbee0 -c \ - 'cargo test --locked --offline --lib runtime::postgres::tests::qualification_ -- --ignored --nocapture > /evidence/postgres-tests.txt && cargo test --locked --offline --test execution -- --nocapture > /evidence/postgres-execution-tests.txt && cargo build --locked --offline' + 'if [ "$DW_PARITY_PART" = native-tests ]; then cargo test --locked --offline --lib runtime::postgres::tests::qualification_ -- --ignored --nocapture > /evidence/postgres-tests.txt && cargo test --locked --offline --test execution -- --nocapture > /evidence/postgres-execution-tests.txt; fi && cargo build --locked --offline' docker run --rm --user="$(id -u):$(id -g)" --network "$RESOURCE_SCOPE" \ -e DW_RUST_EXPERIMENTAL=1 -e DB_CONNECTION=pgsql -e DB_HOST=parity-pg -e DB_DATABASE=native_ref \ -e DB_USERNAME=parity -e DB_PASSWORD=parity-disposable-password \ @@ -1005,6 +992,7 @@ jobs: -e CACHE_STORE=file -e QUEUE_CONNECTION=database -e DW_AUTH_TOKEN=parity-test-token \ "$image" artisan server:bootstrap --force > parity-evidence/published-upgrade-bootstrap.txt - name: Compare three runtimes, recover killed native nodes and refuse published PHP takeover + if: ${{ matrix.part == 'corpus' }} id: prepare_runtime_comparison env: POSTGRES_IMAGE: ${{ matrix.image }} @@ -1130,47 +1118,8 @@ jobs: bash scripts/conformance/server-parity/legacy-marker-profile.sh pgsql http://parity-php:8080 http://parity-native-a:8080 bash scripts/conformance/server-parity/range-repair-profile.sh pgsql http://parity-native-a:8080 bash scripts/conformance/server-parity/range-repair-profile.sh pgsql http://parity-native-a:8080 pending-native - bash scripts/conformance/server-parity/sdk-direction-profile.sh pgsql http://parity-native-a:8080 - docker stop --time 30 "$RESOURCE_SCOPE-sdk-directions-server" - bash scripts/conformance/server-parity/sdk-direction-profile.sh pgsql http://parity-native-a:8080 child - docker stop --time 30 "$RESOURCE_SCOPE-sdk-child-directions-server" - bash scripts/conformance/server-parity/sdk-direction-profile.sh pgsql http://parity-native-a:8080 timer - docker stop --time 30 "$RESOURCE_SCOPE-sdk-timers-server" - # Separate queues/databases/evidence; preserve statuses even after children exit. - ( - signal_status=0 - bash scripts/conformance/server-parity/sdk-direction-profile.sh pgsql http://parity-native-a:8080 signal > parity-evidence/sdk-signals-profile.log 2>&1 || signal_status=$? - printf '%s\n' "$signal_status" > parity-evidence/sdk-signals-profile.status - ) & - ( - query_status=0 - bash scripts/conformance/server-parity/sdk-direction-profile.sh pgsql http://parity-native-a:8080 query > parity-evidence/sdk-queries-profile.log 2>&1 || query_status=$? - printf '%s\n' "$query_status" > parity-evidence/sdk-queries-profile.status - ) & - update_status=0 - bash scripts/conformance/server-parity/sdk-direction-profile.sh pgsql http://parity-native-a:8080 update || update_status=$? - docker stop --time 30 "$RESOURCE_SCOPE-sdk-updates-server" || true - failure_status=0 - bash scripts/conformance/server-parity/sdk-direction-profile.sh pgsql http://parity-native-a:8080 update-failure || failure_status=$? - wait - signal_status=$(cat parity-evidence/sdk-signals-profile.status) - query_status=$(cat parity-evidence/sdk-queries-profile.status) - cat parity-evidence/sdk-signals-profile.log parity-evidence/sdk-queries-profile.log - for completed_profile in sdk-signals sdk-queries sdk-update-failures; do - docker stop --time 30 "$RESOURCE_SCOPE-$completed_profile-server" || true - done - namespace_signal_status=0 - bash scripts/conformance/server-parity/sdk-direction-profile.sh pgsql http://parity-native-a:8080 namespace-signal || namespace_signal_status=$? - test "$signal_status" -eq 0 - test "$query_status" -eq 0 - test "$update_status" -eq 0 - test "$failure_status" -eq 0 - test "$namespace_signal_status" -eq 0 - docker stop --time 30 "$RESOURCE_SCOPE-sdk-namespace-signals-server" || true - selected_run_signal_status=0 - bash scripts/conformance/server-parity/sdk-direction-profile.sh pgsql http://parity-native-a:8080 selected-run-signal || selected_run_signal_status=$? - docker stop --time 30 "$RESOURCE_SCOPE-sdk-selected-run-signals-server" || true - test "$selected_run_signal_status" -eq 0 + # Every SDK direction profile, in three concurrent lanes. + bash scripts/conformance/server-parity/sdk-direction-lanes.sh pgsql http://parity-native-a:8080 docker run --rm --user=1000:1000 --network "$RESOURCE_SCOPE" --entrypoint sh \ -v "$PWD:/source:ro" -v "$PWD/parity-evidence:/evidence" -w /source \ -e DW_PARITY_TOKEN=parity-test-token "$image" -ec ' @@ -1240,18 +1189,47 @@ jobs: # check has run, so one difference cannot hide the rest. test "$native_record_status" -eq 0 test "$compare_status" -eq 0 + - name: Start the shared native node the cohorts use + id: prepare_cohorts + 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.1.json) + docker run --rm --user="$(id -u):$(id -g)" --entrypoint composer \ + -e COMPOSER_HOME=/tmp/composer -v "$PWD:/source" -w /source "$image" \ + 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 \ + --user="$(id -u):$(id -g)" --cpus=1 --memory=256m --memory-swap=256m \ + -e DW_RUST_EXPERIMENTAL=1 -e DW_AUTH_TOKEN=parity-test-token -e DW_BIND_ADDRESS=0.0.0.0:8080 \ + -e DB_CONNECTION=pgsql -e DB_HOST=parity-pg -e DB_DATABASE=native_ref \ + -e DB_USERNAME=parity -e DB_PASSWORD=parity-disposable-password \ + -v "$PWD/rust-target:/target:ro" --tmpfs /state --entrypoint /target/debug/durable-workflow-server \ + mirror.gcr.io/library/rust@sha256:ba81bc3eaa4422af576c0262515d96b0111a628a6ccc2c86557cf55c9a4bbee0 + ready=0 + for attempt in $(seq 1 30); do + if docker run --rm --user=1000:1000 --network "$RESOURCE_SCOPE" --entrypoint curl "$image" \ + -fsS http://parity-native-b:8080/api/ready > parity-evidence/cohort-native-readiness.json; then + ready=1 + break + fi + sleep 1 + done + test "$ready" = 1 - name: Compare separate configured role-token cohort + if: ${{ matrix.part == 'cohorts' }} env: POSTGRES_IMAGE: ${{ matrix.image }} run: bash scripts/conformance/server-parity/role-profile.sh pgsql - name: Back up, take over and resume a separate published PHP upgrade source + if: ${{ matrix.part == 'cohorts' }} timeout-minutes: 8 run: bash scripts/conformance/server-parity/upgrade-source-profile.sh pgsql - name: Retire upgrade source cohort - if: always() + if: ${{ always() && matrix.part == 'cohorts' }} timeout-minutes: 3 run: bash scripts/conformance/server-parity/upgrade-source-cleanup.sh pgsql - name: Back up, take over and resume a published PHP 2.9.7 upgrade source + if: ${{ matrix.part == 'cohorts' }} timeout-minutes: 8 env: DW_PARITY_UPGRADE_LABEL: php297 @@ -1259,7 +1237,7 @@ jobs: DW_PARITY_UPGRADE_PHP_IMAGE: ghcr.io/durable-workflow/server@sha256:30c34c5f3256a812fbccbe505a068309b58b0c14d01ffff0465d88604cb36d50 run: bash scripts/conformance/server-parity/upgrade-source-profile.sh pgsql - name: Retire PHP 2.9.7 upgrade source cohort - if: always() + if: ${{ always() && matrix.part == 'cohorts' }} timeout-minutes: 3 env: DW_PARITY_UPGRADE_LABEL: php297 @@ -1267,51 +1245,52 @@ jobs: DW_PARITY_UPGRADE_PHP_IMAGE: ghcr.io/durable-workflow/server@sha256:30c34c5f3256a812fbccbe505a068309b58b0c14d01ffff0465d88604cb36d50 run: bash scripts/conformance/server-parity/upgrade-source-cleanup.sh pgsql - name: Compare corrected markers, ranges, clocks and activity expiry - if: ${{ !cancelled() && steps.prepare_runtime_comparison.outcome == 'success' }} + if: ${{ !cancelled() && matrix.part == 'cohorts' && steps.prepare_cohorts.outcome == 'success' }} env: POSTGRES_IMAGE: ${{ matrix.image }} - # The preceding process-loss case deliberately kills native-a. + # The cohorts' own native node; the corpus part kills its native-a. run: bash scripts/conformance/server-parity/corrected-marker-shared-profile.sh pgsql http://parity-native-b:8080 - name: Retire corrected cohort resources - if: always() + if: ${{ always() && matrix.part == 'cohorts' }} env: POSTGRES_IMAGE: ${{ matrix.image }} run: bash scripts/conformance/server-parity/corrected-marker-cleanup.sh pgsql - name: Compare installed local external payload consumers - if: ${{ !cancelled() }} + if: ${{ !cancelled() && matrix.part == 'cohorts' }} timeout-minutes: 10 env: POSTGRES_IMAGE: ${{ matrix.image }} run: bash scripts/conformance/server-parity/external-consumer-profile.sh pgsql - name: Retire local external consumer resources - if: always() + if: ${{ always() && matrix.part == 'cohorts' }} timeout-minutes: 5 env: POSTGRES_IMAGE: ${{ matrix.image }} run: bash scripts/conformance/server-parity/external-consumer-cleanup.sh pgsql - name: Compare published query deadlines and installed Worker authority - if: ${{ !cancelled() }} + if: ${{ !cancelled() && matrix.part == 'cohorts' }} timeout-minutes: 10 env: POSTGRES_IMAGE: ${{ matrix.image }} run: bash scripts/conformance/server-parity/query-authority-cohort.sh pgsql - name: Retire query authority resources - if: always() + if: ${{ always() && matrix.part == 'cohorts' }} timeout-minutes: 5 env: POSTGRES_IMAGE: ${{ matrix.image }} run: bash scripts/conformance/server-parity/query-authority-cleanup.sh pgsql - name: Compare pinned PHP, embedded and native worker-fencing source cohort - if: ${{ !cancelled() }} + if: ${{ !cancelled() && matrix.part == 'cohorts' }} env: POSTGRES_IMAGE: ${{ matrix.image }} run: bash scripts/conformance/server-parity/worker-fencing-profile.sh pgsql - name: Compare SDK temporary database pressure with declared fault limits - if: ${{ !cancelled() }} + if: ${{ !cancelled() && matrix.part == 'cohorts' }} env: POSTGRES_IMAGE: ${{ matrix.image }} run: bash scripts/conformance/server-parity/worker-fencing-profile.sh pgsql pressure - name: Verify TLS certificate trust and hostname checks + if: ${{ matrix.part == 'native-tests' }} env: POSTGRES_IMAGE: ${{ matrix.image }} run: | @@ -1426,17 +1405,26 @@ jobs: if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: postgres-storage-${{ matrix.label }} + name: postgres-storage-${{ matrix.label }}-${{ matrix.part }} path: parity-summary retention-days: 1 execution-and-codec: needs: published-sdk-adapters runs-on: ubuntu-latest - # Corrected marker/range/clock coverage expands the job, and the fixture step - # runs every later profile before failing; scenario budgets stay fixed. - # MySQL runs take 75 to 85 minutes; PostgreSQL's jobs take about 60. - timeout-minutes: 105 + # Each part takes about 20 to 35 minutes; the fixture step runs every + # later profile before failing, and scenario budgets stay fixed. + timeout-minutes: 60 + strategy: + fail-fast: false + # Three parts on separate runners: the native suites and codec, the + # shared corpus with its profiles and restart cases, and the separate + # cohorts. Every part builds the same native server; no part depends on + # another. + matrix: + part: [native-tests, corpus, cohorts] + env: + DW_PARITY_PART: ${{ matrix.part }} steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Fetch this run's published SDK adapters @@ -1480,7 +1468,18 @@ jobs: -v "$PWD:/source:ro" -v "$PWD/cargo-home:/cargo" -w /source/rust \ mirror.gcr.io/library/rust@sha256:ba81bc3eaa4422af576c0262515d96b0111a628a6ccc2c86557cf55c9a4bbee0 \ cargo fetch --locked + - name: Build the native server + if: ${{ matrix.part != 'native-tests' }} + run: | + set -euo pipefail + mkdir -p cargo-home rust-target parity-evidence + docker run --rm --user="$(id -u):$(id -g)" --cpus=2 --memory=6g --memory-swap=6g \ + -e CARGO_HOME=/cargo -e CARGO_TARGET_DIR=/target -e CARGO_BUILD_JOBS=2 \ + -v "$PWD:/source:ro" -v "$PWD/cargo-home:/cargo" -v "$PWD/rust-target:/target" -w /source/rust \ + mirror.gcr.io/library/rust@sha256:ba81bc3eaa4422af576c0262515d96b0111a628a6ccc2c86557cf55c9a4bbee0 \ + cargo build --locked --offline - name: Execute official Rust codec and cross-decode with published PHP + if: ${{ matrix.part == 'native-tests' }} run: | set -euo pipefail # Failed Rust tests are printed from their evidence files into this step's log. @@ -1510,6 +1509,7 @@ jobs: cargo run --locked --offline --example codec-fixtures -- \ ../tests/Fixtures/ServerParity/Codec/v1.json /evidence/php-codec.json > parity-evidence/cross-decode.json - name: Execute and compare Rust, published PHP and embedded fixtures + if: ${{ matrix.part == 'corpus' }} id: prepare_runtime_comparison env: RESOURCE_SCOPE: dw-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} @@ -1529,7 +1529,7 @@ jobs: -e COMPOSER_HOME=/tmp/composer -v "$PWD:/source" -w /source "$image" \ install --working-dir=scripts/conformance/server-parity --no-interaction --no-progress docker run --rm --user=1000:1000 --entrypoint node -v "$PWD:/source:ro" -w /source \ - "$image" --test tests/Unit/ServerParityRunnerTest.mjs tests/Unit/ServerParityCooperativeContractTest.mjs tests/Unit/ServerParityChildCancellationContractTest.mjs tests/Unit/ServerParityScheduleContractTest.mjs tests/Unit/ServerParityVisibilityContractTest.mjs tests/Unit/ServerParityAdmissionContractTest.mjs tests/Unit/ServerParityRoleTokenContractTest.mjs tests/Unit/ServerParityWorkerDeregistrationContractTest.mjs tests/Unit/ServerParitySearchAttributeContractTest.mjs tests/Unit/ServerParityWorkerListingContractTest.mjs tests/Unit/ServerParityWorkerReplacementContractTest.mjs tests/Unit/ServerParityPatchDeploymentContractTest.mjs tests/Unit/ServerParityWaitingHistoryContractTest.mjs tests/Unit/ServerParityTaskRetryContractTest.mjs tests/Unit/ServerParityPublishedPatchContractTest.mjs tests/Unit/ServerParityPublishedPatchTransportTest.mjs tests/Unit/ServerParityRecordFileTest.mjs tests/Unit/ServerParityLateWorkflowCompletionContractTest.mjs tests/Unit/ServerParityStorageTestContractTest.mjs tests/Unit/ServerParitySchedulePoliciesContractTest.mjs tests/Unit/ServerParitySignalsBeforeWorkerContractTest.mjs tests/Unit/ServerParityScheduleCatalogContractTest.mjs tests/Unit/ServerParityOperatorMetricsContractTest.mjs tests/Unit/ServerParityActivityLeaseRenewalContractTest.mjs tests/Unit/ServerParityServiceCatalogContractTest.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 > parity-evidence/comparator-tests.txt + "$image" --test tests/Unit/ServerParityRunnerTest.mjs tests/Unit/ServerParitySdkDirectionLanesTest.mjs tests/Unit/ServerParityCooperativeContractTest.mjs tests/Unit/ServerParityChildCancellationContractTest.mjs tests/Unit/ServerParityScheduleContractTest.mjs tests/Unit/ServerParityVisibilityContractTest.mjs tests/Unit/ServerParityAdmissionContractTest.mjs tests/Unit/ServerParityRoleTokenContractTest.mjs tests/Unit/ServerParityWorkerDeregistrationContractTest.mjs tests/Unit/ServerParitySearchAttributeContractTest.mjs tests/Unit/ServerParityWorkerListingContractTest.mjs tests/Unit/ServerParityWorkerReplacementContractTest.mjs tests/Unit/ServerParityPatchDeploymentContractTest.mjs tests/Unit/ServerParityWaitingHistoryContractTest.mjs tests/Unit/ServerParityTaskRetryContractTest.mjs tests/Unit/ServerParityPublishedPatchContractTest.mjs tests/Unit/ServerParityPublishedPatchTransportTest.mjs tests/Unit/ServerParityRecordFileTest.mjs tests/Unit/ServerParityLateWorkflowCompletionContractTest.mjs tests/Unit/ServerParityStorageTestContractTest.mjs tests/Unit/ServerParitySchedulePoliciesContractTest.mjs tests/Unit/ServerParitySignalsBeforeWorkerContractTest.mjs tests/Unit/ServerParityScheduleCatalogContractTest.mjs tests/Unit/ServerParityOperatorMetricsContractTest.mjs tests/Unit/ServerParityActivityLeaseRenewalContractTest.mjs tests/Unit/ServerParityServiceCatalogContractTest.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 > 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 @@ -1635,47 +1635,8 @@ jobs: bash scripts/conformance/server-parity/legacy-marker-profile.sh sqlite http://parity-server:8080 http://parity-rust:8080 bash scripts/conformance/server-parity/range-repair-profile.sh sqlite http://parity-rust:8080 bash scripts/conformance/server-parity/range-repair-profile.sh sqlite http://parity-rust:8080 pending-native - bash scripts/conformance/server-parity/sdk-direction-profile.sh sqlite http://parity-rust:8080 - docker stop --time 30 "$RESOURCE_SCOPE-sdk-directions-server" - bash scripts/conformance/server-parity/sdk-direction-profile.sh sqlite http://parity-rust:8080 child - docker stop --time 30 "$RESOURCE_SCOPE-sdk-child-directions-server" - bash scripts/conformance/server-parity/sdk-direction-profile.sh sqlite http://parity-rust:8080 timer - docker stop --time 30 "$RESOURCE_SCOPE-sdk-timers-server" - # Separate queues/databases/evidence; preserve statuses even after children exit. - ( - signal_status=0 - bash scripts/conformance/server-parity/sdk-direction-profile.sh sqlite http://parity-rust:8080 signal > parity-evidence/sdk-signals-profile.log 2>&1 || signal_status=$? - printf '%s\n' "$signal_status" > parity-evidence/sdk-signals-profile.status - ) & - ( - query_status=0 - bash scripts/conformance/server-parity/sdk-direction-profile.sh sqlite http://parity-rust:8080 query > parity-evidence/sdk-queries-profile.log 2>&1 || query_status=$? - printf '%s\n' "$query_status" > parity-evidence/sdk-queries-profile.status - ) & - update_status=0 - bash scripts/conformance/server-parity/sdk-direction-profile.sh sqlite http://parity-rust:8080 update || update_status=$? - docker stop --time 30 "$RESOURCE_SCOPE-sdk-updates-server" || true - failure_status=0 - bash scripts/conformance/server-parity/sdk-direction-profile.sh sqlite http://parity-rust:8080 update-failure || failure_status=$? - wait - signal_status=$(cat parity-evidence/sdk-signals-profile.status) - query_status=$(cat parity-evidence/sdk-queries-profile.status) - cat parity-evidence/sdk-signals-profile.log parity-evidence/sdk-queries-profile.log - for completed_profile in sdk-signals sdk-queries sdk-update-failures; do - docker stop --time 30 "$RESOURCE_SCOPE-$completed_profile-server" || true - done - namespace_signal_status=0 - bash scripts/conformance/server-parity/sdk-direction-profile.sh sqlite http://parity-rust:8080 namespace-signal || namespace_signal_status=$? - test "$signal_status" -eq 0 - test "$query_status" -eq 0 - test "$update_status" -eq 0 - test "$failure_status" -eq 0 - test "$namespace_signal_status" -eq 0 - docker stop --time 30 "$RESOURCE_SCOPE-sdk-namespace-signals-server" || true - selected_run_signal_status=0 - bash scripts/conformance/server-parity/sdk-direction-profile.sh sqlite http://parity-rust:8080 selected-run-signal || selected_run_signal_status=$? - docker stop --time 30 "$RESOURCE_SCOPE-sdk-selected-run-signals-server" || true - test "$selected_run_signal_status" -eq 0 + # Every SDK direction profile, in three concurrent lanes. + bash scripts/conformance/server-parity/sdk-direction-lanes.sh sqlite http://parity-rust:8080 docker run --rm --user=1000:1000 --entrypoint php --network "$RESOURCE_SCOPE" \ -v "$PWD:/source:ro" -v "$PWD/parity-evidence:/evidence" -w /source \ -e DW_PARITY_TOKEN=parity-test-token "$image" scripts/conformance/server-parity/restart.php \ @@ -1744,22 +1705,58 @@ jobs: # check has run, so one difference cannot hide the rest. test "$native_record_status" -eq 0 test "$compare_status" -eq 0 + - name: Start the shared native node the cohorts use + id: prepare_cohorts + if: ${{ matrix.part == 'cohorts' }} + env: + 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.1.json) + docker run --rm --user="$(id -u):$(id -g)" --entrypoint composer \ + -e COMPOSER_HOME=/tmp/composer -v "$PWD:/source" -w /source "$image" \ + install --working-dir=scripts/conformance/server-parity --no-interaction --no-progress + mkdir -p parity-state parity-evidence + # The cohorts' published PHP containers run as 1000:1000; grant that + # user alone, not everyone. + setfacl -m u:1000:rwx,d:u:1000:rwx parity-state parity-evidence + docker network create "$RESOURCE_SCOPE" + docker run -d --name "$RESOURCE_SCOPE-recovered" --network "$RESOURCE_SCOPE" --network-alias=parity-recovered \ + --user="$(id -u):$(id -g)" --cpus=1 --memory=256m --memory-swap=256m \ + -e DW_RUST_EXPERIMENTAL=1 -e DW_AUTH_TOKEN=parity-test-token \ + -e DB_DATABASE=/state/rust.sqlite -e DW_BIND_ADDRESS=0.0.0.0:8080 \ + -v "$PWD/rust-target:/target:ro" -v "$PWD/parity-state:/state" \ + --entrypoint /target/debug/durable-workflow-server \ + mirror.gcr.io/library/rust@sha256:ba81bc3eaa4422af576c0262515d96b0111a628a6ccc2c86557cf55c9a4bbee0 + ready=0 + for attempt in $(seq 1 30); do + if docker run --rm --user=1000:1000 --network "$RESOURCE_SCOPE" --entrypoint curl "$image" \ + -fsS http://parity-recovered:8080/api/ready > parity-evidence/cohort-native-readiness.json; then + ready=1 + break + fi + sleep 1 + done + test "$ready" = 1 - name: Compare separate configured role-token cohort + if: ${{ matrix.part == 'cohorts' }} env: RESOURCE_SCOPE: dw-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} run: bash scripts/conformance/server-parity/role-profile.sh sqlite - name: Back up, take over and resume a separate published PHP upgrade source + if: ${{ matrix.part == 'cohorts' }} timeout-minutes: 8 env: RESOURCE_SCOPE: dw-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} run: bash scripts/conformance/server-parity/upgrade-source-profile.sh sqlite - name: Retire upgrade source cohort - if: always() + if: ${{ always() && matrix.part == 'cohorts' }} timeout-minutes: 3 env: RESOURCE_SCOPE: dw-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} run: bash scripts/conformance/server-parity/upgrade-source-cleanup.sh sqlite - name: Back up, take over and resume a published PHP 2.9.7 upgrade source + if: ${{ matrix.part == 'cohorts' }} timeout-minutes: 8 env: RESOURCE_SCOPE: dw-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} @@ -1768,7 +1765,7 @@ jobs: DW_PARITY_UPGRADE_PHP_IMAGE: ghcr.io/durable-workflow/server@sha256:30c34c5f3256a812fbccbe505a068309b58b0c14d01ffff0465d88604cb36d50 run: bash scripts/conformance/server-parity/upgrade-source-profile.sh sqlite - name: Retire PHP 2.9.7 upgrade source cohort - if: always() + if: ${{ always() && matrix.part == 'cohorts' }} timeout-minutes: 3 env: RESOURCE_SCOPE: dw-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} @@ -1777,46 +1774,46 @@ jobs: DW_PARITY_UPGRADE_PHP_IMAGE: ghcr.io/durable-workflow/server@sha256:30c34c5f3256a812fbccbe505a068309b58b0c14d01ffff0465d88604cb36d50 run: bash scripts/conformance/server-parity/upgrade-source-cleanup.sh sqlite - name: Compare corrected markers, ranges, clocks and activity expiry - if: ${{ !cancelled() && steps.prepare_runtime_comparison.outcome == 'success' }} + if: ${{ !cancelled() && matrix.part == 'cohorts' && steps.prepare_cohorts.outcome == 'success' }} env: RESOURCE_SCOPE: dw-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} run: bash scripts/conformance/server-parity/corrected-marker-shared-profile.sh sqlite http://parity-recovered:8080 - name: Retire corrected cohort resources - if: always() + if: ${{ always() && matrix.part == 'cohorts' }} env: RESOURCE_SCOPE: dw-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} run: bash scripts/conformance/server-parity/corrected-marker-cleanup.sh sqlite - name: Compare installed local external payload consumers - if: ${{ !cancelled() }} + if: ${{ !cancelled() && matrix.part == 'cohorts' }} timeout-minutes: 10 env: RESOURCE_SCOPE: dw-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} run: bash scripts/conformance/server-parity/external-consumer-profile.sh sqlite - name: Retire local external consumer resources - if: always() + if: ${{ always() && matrix.part == 'cohorts' }} timeout-minutes: 5 env: RESOURCE_SCOPE: dw-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} run: bash scripts/conformance/server-parity/external-consumer-cleanup.sh sqlite - name: Compare published query deadlines and installed Worker authority - if: ${{ !cancelled() }} + if: ${{ !cancelled() && matrix.part == 'cohorts' }} timeout-minutes: 10 env: RESOURCE_SCOPE: dw-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} run: bash scripts/conformance/server-parity/query-authority-cohort.sh sqlite - name: Retire query authority resources - if: always() + if: ${{ always() && matrix.part == 'cohorts' }} timeout-minutes: 5 env: RESOURCE_SCOPE: dw-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} run: bash scripts/conformance/server-parity/query-authority-cleanup.sh sqlite - name: Compare pinned PHP, embedded and native worker-fencing source cohort - if: ${{ !cancelled() }} + if: ${{ !cancelled() && matrix.part == 'cohorts' }} env: RESOURCE_SCOPE: dw-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} run: bash scripts/conformance/server-parity/worker-fencing-profile.sh sqlite - name: Compare SDK temporary database pressure with declared fault limits - if: ${{ !cancelled() }} + if: ${{ !cancelled() && matrix.part == 'cohorts' }} env: RESOURCE_SCOPE: dw-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} run: bash scripts/conformance/server-parity/worker-fencing-profile.sh sqlite pressure @@ -1876,7 +1873,37 @@ jobs: if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: shared-server-fixtures + name: shared-server-fixtures-${{ matrix.part }} path: parity-summary retention-days: 1 if-no-files-found: error + + # The one Shared Server fixtures result. It always runs, including after a + # failure or cancellation, and passes only when every fixture job passed, or + # when no fixture input changed and every fixture job was skipped for that + # reason. + result: + name: Shared Server fixtures result + needs: + - changes + - published-sdk-adapters + - codec-native-build + - codec-deadline-health + - mysql-execution + - postgres-storage + - execution-and-codec + if: ${{ always() }} + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + sparse-checkout: | + scripts/conformance/server-parity/aggregate.sh + scripts/conformance/server-parity/aggregate.mjs + sparse-checkout-cone-mode: false + persist-credentials: false + - name: Require every fixture job to pass, or every one to be skipped for unchanged inputs + env: + NEEDS: ${{ toJSON(needs) }} + run: bash scripts/conformance/server-parity/aggregate.sh diff --git a/rust/tests/execution/activity_deadlines.rs b/rust/tests/execution/activity_deadlines.rs index ea2d4d3a9..c67b5f724 100644 --- a/rust/tests/execution/activity_deadlines.rs +++ b/rust/tests/execution/activity_deadlines.rs @@ -483,9 +483,14 @@ async fn heartbeat_renews_only_progress_deadline_and_cannot_resurrect_elapsed_au assert_eq!(after.0, initial.0, "Start-to-close deadline remains fixed"); assert!(after.1 > initial.1, "Heartbeat deadline advances"); assert_eq!(after.2, initial.2, "Lifecycle deadline remains fixed"); + // Compare instants, not text: a lease on a whole second is written + // without a fraction ("...:26Z"), which sorts after "...:26.045Z". + let instant = |value: &Value| chrono::DateTime::parse_from_rfc3339(value.as_str().unwrap()).unwrap(); assert!( - renewed.1["lease_expires_at"].as_str().unwrap() - > activity["lease_expires_at"].as_str().unwrap() + instant(&renewed.1["lease_expires_at"]) > instant(&activity["lease_expires_at"]), + "{} renews {}", + renewed.1["lease_expires_at"], + activity["lease_expires_at"] ); let prefix = run_history(&app, &started["workflow_id"], &started["run_id"]).await; database diff --git a/scripts/conformance/server-parity/aggregate.mjs b/scripts/conformance/server-parity/aggregate.mjs new file mode 100644 index 000000000..6ee2481e7 --- /dev/null +++ b/scripts/conformance/server-parity/aggregate.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node +// The Shared Server fixtures result. NEEDS is the result job's `needs` +// context as JSON. +// +// Passes only when every fixture job passed, or when the classifier reported +// that no fixture input changed and every fixture job was skipped for that +// reason. A failed, cancelled or unexpectedly skipped job fails the result, +// and so does anything that cannot be read. +import {pathToFileURL} from 'node:url'; + +export function decide(needs) { + if (needs === null || typeof needs !== 'object' || Array.isArray(needs)) { + return {pass: false, reason: 'The needs context is not an object.'}; + } + const {changes, ...fixtureJobs} = needs; + const results = Object.entries(fixtureJobs).map(([job, value]) => [job, value?.result]); + const summary = results.map(([job, result]) => `${job}=${result}`).join(', '); + if (changes?.result !== 'success') { + return {pass: false, reason: `The change classifier did not succeed (${changes?.result}).`}; + } + if (results.length === 0) { + return {pass: false, reason: 'No fixture jobs reported.'}; + } + const run = changes.outputs?.run; + const expected = {true: 'success', false: 'skipped'}[run]; + if (expected === undefined) { + return {pass: false, reason: `The change classifier reported no decision (${run}).`}; + } + const unexpected = results.filter(([, result]) => result !== expected); + if (unexpected.length > 0) { + return { + pass: false, + reason: `Every fixture job must be ${expected} when run=${run}: ${unexpected.map(([job, result]) => `${job}=${result}`).join(', ')}.`, + }; + } + return { + pass: true, + reason: run === 'true' ? `Every fixture job passed: ${summary}.` : `No fixture input changed; every fixture job was skipped: ${summary}.`, + }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + let decision; + try { + decision = decide(JSON.parse(process.env.NEEDS ?? '')); + } catch (error) { + decision = {pass: false, reason: `The needs context could not be read: ${error.message}`}; + } + (decision.pass ? console.log : console.error)(decision.reason); + process.exitCode = decision.pass ? 0 : 1; +} diff --git a/scripts/conformance/server-parity/aggregate.sh b/scripts/conformance/server-parity/aggregate.sh new file mode 100755 index 000000000..013908c66 --- /dev/null +++ b/scripts/conformance/server-parity/aggregate.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# The Shared Server fixtures result, from the repository root. NEEDS is the +# result job's needs context as JSON. The decision rules live in aggregate.mjs. +set -euo pipefail +exec node scripts/conformance/server-parity/aggregate.mjs diff --git a/scripts/conformance/server-parity/changes.mjs b/scripts/conformance/server-parity/changes.mjs new file mode 100644 index 000000000..910b00d7e --- /dev/null +++ b/scripts/conformance/server-parity/changes.mjs @@ -0,0 +1,95 @@ +#!/usr/bin/env node +// Decides whether Shared Server fixtures must run for this event. +// +// Pull requests run the workflow without a path filter, so its terminal +// result always reports. This script applies the workflow's own push path +// filter to the pull request's changes, so one list governs both triggers. +// Every other event runs every fixture job, and so does any pull request +// whose changes cannot be classified. +// +// Writes `run=true` or `run=false` to stdout, for $GITHUB_OUTPUT. +import {execFileSync} from 'node:child_process'; +import {readFileSync} from 'node:fs'; +import {pathToFileURL} from 'node:url'; + +// The `paths` list of the workflow's `on.push` trigger. +export function pushPathPatterns(workflowText) { + const lines = workflowText.split('\n'); + const on = lines.indexOf('on:'); + const push = lines.indexOf(' push:', on); + if (on < 0 || push < 0) throw new Error('The workflow has no on.push trigger'); + const patterns = []; + let inPaths = false; + for (const line of lines.slice(push + 1)) { + if (/^ {0,3}\S/.test(line)) break; + if (line === ' paths:') { + inPaths = true; + } else if (inPaths && line.startsWith(' - ')) { + patterns.push(line.slice(' - '.length).trim()); + } else if (inPaths) { + break; + } + } + if (patterns.length === 0) throw new Error('The push trigger has no path filter'); + return patterns; +} + +// GitHub's filter syntax, limited to the forms this workflow uses: +// `**` matches any characters, `*` matches any characters except `/`. Every +// other character is literal, and file names may hold any character but NUL, +// newlines included. +export function patternRegExp(pattern) { + if (!/^[A-Za-z0-9._/*-]+$/.test(pattern)) throw new Error(`Unsupported path filter pattern: ${pattern}`); + const literal = text => text.replace(/[\\^$.*+?()[\]{}|\/-]/g, '\\$&'); + const body = pattern.split('**').map(part => part.split('*').map(literal).join('[^/]*')).join('[^]*'); + return new RegExp(`^${body}$`); +} + +// `git diff --name-only -z` output: complete paths split only on NUL, never +// quoted. A path that is not UTF-8 cannot be classified. +export function nulSeparatedPaths(bytes) { + const decoder = new TextDecoder('utf-8', {fatal: true}); + const paths = []; + let start = 0; + for (let index = bytes.indexOf(0); index >= 0; index = bytes.indexOf(0, start)) { + paths.push(decoder.decode(bytes.subarray(start, index))); + start = index + 1; + } + if (start !== bytes.length) throw new Error('The file list does not end with NUL'); + return paths; +} + +export function matchingFiles(patterns, files) { + const expressions = patterns.map(patternRegExp); + return files.filter(file => expressions.some(expression => expression.test(file))); +} + +function classify({event, headSha, workflowPath}) { + if (event !== 'pull_request') return {run: true, reason: `${event} runs every fixture job`}; + // The checkout is the pull request's merge commit: base, then head. Git runs + // without a shell, and its file list is read as bytes. + const git = (...args) => execFileSync('git', args); + let files; + let matched; + try { + const parents = git('rev-list', '--parents', '-n', '1', 'HEAD').toString('utf8').trim().split(' '); + if (parents.length !== 3 || parents[2] !== headSha) throw new Error('HEAD is not this pull request\'s merge commit'); + files = nulSeparatedPaths(git('diff', '--name-only', '-z', '--no-renames', 'HEAD^1', 'HEAD')); + matched = matchingFiles(pushPathPatterns(readFileSync(workflowPath, 'utf8')), files); + } catch (error) { + return {run: true, reason: `changes could not be classified (${error.message}); running every fixture job`}; + } + return matched.length > 0 + ? {run: true, reason: `fixture inputs changed: ${matched.map(file => JSON.stringify(file)).join(', ')}`} + : {run: false, reason: `no fixture input among ${files.length} changed files`}; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const decision = classify({ + event: process.env.EVENT_NAME ?? '', + headSha: process.env.PR_HEAD_SHA ?? '', + workflowPath: '.github/workflows/server-parity.yml', + }); + console.error(decision.reason); + console.log(`run=${decision.run}`); +} diff --git a/scripts/conformance/server-parity/changes.sh b/scripts/conformance/server-parity/changes.sh new file mode 100755 index 000000000..4f8635f0a --- /dev/null +++ b/scripts/conformance/server-parity/changes.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Prints run=true or run=false for the Shared Server fixtures workflow, from +# the repository root. The decision rules live in changes.mjs. +set -euo pipefail +exec node scripts/conformance/server-parity/changes.mjs diff --git a/scripts/conformance/server-parity/sdk-direction-lanes.sh b/scripts/conformance/server-parity/sdk-direction-lanes.sh new file mode 100755 index 000000000..713a18301 --- /dev/null +++ b/scripts/conformance/server-parity/sdk-direction-lanes.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Run every published-SDK direction profile against one native server, in +# three concurrent lanes. Each profile already has its own containers, PHP and +# embedded databases, evidence and state directory, and its own workflow-ID +# prefix on the shared native server, so profiles in different lanes cannot +# see each other unless they share a task queue. Within a lane they run in order, and each profile's PHP +# server is stopped as soon as its attempt ends, so at most three run at once. +# Every profile runs even after another fails; the script fails if any did, +# after printing each log. The job's sdk-direction-cleanup steps remove every +# profile's containers afterwards, as before. +set -euo pipefail +backend="${1:?Choose mysql, pgsql or sqlite}" +native_url="${2:?Set the independently isolated native URL}" +if [[ -z "${RESOURCE_SCOPE:-}" ]]; then + echo 'Set the owning isolated network/resource scope' >&2 + exit 2 +fi +evidence=parity-evidence/sdk-direction-lanes +mkdir -p "$evidence" +# Profile variant and its tag (evidence, log and container names). Profiles +# that share a task queue on the native server must share a lane: the three +# signal profiles all use server-parity-sdk-signals, and one's workers would +# show in another's routing diagnostics. Every other profile has its own queues. +lanes=( + "update:sdk-updates update-failure:sdk-update-failures child:sdk-child-directions" + "signal:sdk-signals namespace-signal:sdk-namespace-signals selected-run-signal:sdk-selected-run-signals" + "timer:sdk-timers activity:sdk-directions query:sdk-queries" +) +run_lane() { + local lane=$1 entry variant tag status + for entry in $lane; do + variant=${entry%%:*} tag=${entry#*:} status=0 + bash scripts/conformance/server-parity/sdk-direction-profile.sh "$backend" "$native_url" "$variant" \ + > "$evidence/$tag.log" 2>&1 || status=$? + printf '%s\n' "$status" > "$evidence/$tag.status" + # The profile's status is already kept; a failed stop leaves the server to + # the final cleanup. + docker stop --time 30 "$RESOURCE_SCOPE-$tag-server" >/dev/null 2>&1 \ + || echo "::warning::The $tag PHP server was not stopped; the final cleanup removes it" >&2 + done +} +for lane in "${lanes[@]}"; do + run_lane "$lane" & +done +wait +failed=0 +for lane in "${lanes[@]}"; do + for entry in $lane; do + tag=${entry#*:} + status=$(cat "$evidence/$tag.status" 2>/dev/null || echo missing) + echo "::group::$tag (exit $status)" + cat "$evidence/$tag.log" + echo "::endgroup::" + if [[ "$status" != 0 ]]; then + echo "::error::SDK direction profile $tag exited $status" >&2 + failed=1 + fi + done +done +exit "$failed" diff --git a/scripts/conformance/server-parity/sdk-directions/README.md b/scripts/conformance/server-parity/sdk-directions/README.md index f5fb86a88..5fea6d9ea 100644 --- a/scripts/conformance/server-parity/sdk-directions/README.md +++ b/scripts/conformance/server-parity/sdk-directions/README.md @@ -34,6 +34,8 @@ bash scripts/conformance/server-parity/sdk-direction-profile.sh BACKEND NATIVE_U bash scripts/conformance/server-parity/sdk-direction-cleanup.sh BACKEND ``` +CI runs every variant through `sdk-direction-lanes.sh BACKEND NATIVE_URL`, in three concurrent lanes against one native server; each variant keeps its own databases, containers and evidence. + Set `RESOURCE_SCOPE` to the owning network and supply `MYSQL_IMAGE` or `POSTGRES_IMAGE` for that backend, as the shared workflow does. The profile starts the exact PHP reference and embedded engine in independent databases; diff --git a/tests/Unit/MergeGroupTriggersTest.mjs b/tests/Unit/MergeGroupTriggersTest.mjs new file mode 100644 index 000000000..0b196a7df --- /dev/null +++ b/tests/Unit/MergeGroupTriggersTest.mjs @@ -0,0 +1,55 @@ +// The Actions checks a GitHub merge queue waits for must run on merge_group. +// Source-only: enabling the queue is a separate repository setting. Its Sonar +// gate is a separate prerequisite (Server #725); the terminal Shared Server +// fixtures result is covered by ServerParityAggregateTest.mjs. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; + +const workflow = name => readFileSync(`.github/workflows/${name}`, 'utf8'); +// The top-level `on:` block, up to the next top-level key. +const triggers = text => { + const start = text.indexOf('\non:\n'); + assert.notEqual(start, -1, 'workflow has an on: block'); + const rest = text.slice(start + 5); + const end = rest.search(/\n[a-z_-]+:/); + return end === -1 ? rest : rest.slice(0, end); +}; + +for (const name of ['phpunit-feature.yml', 'server-parity.yml', 'public-boundary.yml']) { + test(`${name} runs for merge queue groups`, () => { + assert.match(triggers(workflow(name)), /^ {2}merge_group:\s*$/m); + }); +} + +// The Sonar job receives SONAR_TOKEN and its fork guard is always true for a +// merge group, so candidate source must not reach it through the queue. +test('SonarQube does not run for merge queue groups', () => { + assert.doesNotMatch(triggers(workflow('sonar.yml')), /merge_group/); +}); + +test('PHPUnit Feature diffs a queue group against its base', () => { + const text = workflow('phpunit-feature.yml'); + const bases = text.match(/github\.event_name == 'merge_group' && github\.event\.merge_group\.base_sha/g) ?? []; + assert.equal(bases.length, 3, 'OpenAPI and both corpus base revisions'); + assert.match(text, /github\.event\.pull_request\.base\.sha \|\| github\.event\.merge_group\.base_sha \|\| github\.event\.before/); +}); + +test('Public Boundary scans a queue group over its exact range and still scans every push', () => { + const text = workflow('public-boundary.yml'); + assert.match(text, /"merge_group" \]\]; then\n\s+range="\$\{\{ github\.event\.merge_group\.base_sha \}\}\.\.\$\{\{ github\.event\.merge_group\.head_sha \}\}"/); + assert.match(triggers(text), /^ {2}push:\s*$/m); + assert.doesNotMatch(triggers(text), /branches-ignore|branches:|paths/); +}); + +// A workflow the queue waits for may cancel only a superseded pull request +// run. PHPUnit Feature has no concurrency group (its required gate runs with +// always(), per the central action policy), so it never cancels anything. +test('superseded-run cancellation never cancels a queue group', () => { + for (const name of ['phpunit-feature.yml', 'server-parity.yml', 'public-boundary.yml']) { + for (const line of workflow(name).match(/^ *cancel-in-progress:.*$/gm) ?? []) { + assert.match(line, /^ {2}cancel-in-progress: \$\{\{ github\.event_name == 'pull_request' \}\}$/, name); + } + } + assert.match(workflow('server-parity.yml'), /^ {2}cancel-in-progress: \$\{\{ github\.event_name == 'pull_request' \}\}$/m); +}); diff --git a/tests/Unit/OpenApiDocumentEvolutionTest.php b/tests/Unit/OpenApiDocumentEvolutionTest.php index 135121125..97499930c 100644 --- a/tests/Unit/OpenApiDocumentEvolutionTest.php +++ b/tests/Unit/OpenApiDocumentEvolutionTest.php @@ -55,7 +55,7 @@ public function test_candidate_structure_and_feature_ci_compare_to_the_base_revi $this->assertCount(1, $preflightStep); $this->assertSame( - '${{ github.event_name == \'pull_request\' && github.event.pull_request.base.sha || github.event_name == \'push\' && github.event.before || inputs.corpus_base_ref }}', + '${{ github.event_name == \'pull_request\' && github.event.pull_request.base.sha || github.event_name == \'merge_group\' && github.event.merge_group.base_sha || github.event_name == \'push\' && github.event.before || inputs.corpus_base_ref }}', $preflightStep[0]['env']['OPENAPI_BASE_REF'] ?? null, ); $this->assertSame( diff --git a/tests/Unit/PhpunitFeatureWorkflowContractTest.php b/tests/Unit/PhpunitFeatureWorkflowContractTest.php index 14c48570a..b404d7c95 100644 --- a/tests/Unit/PhpunitFeatureWorkflowContractTest.php +++ b/tests/Unit/PhpunitFeatureWorkflowContractTest.php @@ -106,7 +106,7 @@ public function test_regression_corpus_validation_uses_git_capable_ci_and_an_exa $this->assertSame(0, $checkout['with']['fetch-depth'] ?? null); $this->assertFalse($checkout['with']['persist-credentials'] ?? null); $this->assertSame( - '${{ github.event_name == \'pull_request\' && github.event.pull_request.base.sha || github.event_name == \'push\' && github.event.before || inputs.corpus_base_ref }}', + '${{ github.event_name == \'pull_request\' && github.event.pull_request.base.sha || github.event_name == \'merge_group\' && github.event.merge_group.base_sha || github.event_name == \'push\' && github.event.before || inputs.corpus_base_ref }}', $execution['env']['CORPUS_BASE_REF'] ?? null, ); $this->assertArrayNotHasKey('if', $execution); diff --git a/tests/Unit/ServerParityAggregateTest.mjs b/tests/Unit/ServerParityAggregateTest.mjs new file mode 100644 index 000000000..73d4c6add --- /dev/null +++ b/tests/Unit/ServerParityAggregateTest.mjs @@ -0,0 +1,275 @@ +// The Shared Server fixtures workflow reports one terminal result on every +// pull request, so that result can become a required check (Server #725). +// Pull requests run without a path filter; the `changes` job applies the push +// path list to them, and the `result` job fails closed on anything other than +// all fixture jobs passing, or all of them skipped for unchanged inputs. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {execFileSync, spawnSync} from 'node:child_process'; +import {copyFileSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {dirname, join, resolve} from 'node:path'; +import {matchingFiles, nulSeparatedPaths, patternRegExp, pushPathPatterns} from '../../scripts/conformance/server-parity/changes.mjs'; +import {decide} from '../../scripts/conformance/server-parity/aggregate.mjs'; + +const workflowPath = '.github/workflows/server-parity.yml'; +const workflowText = readFileSync(workflowPath, 'utf8'); +const aggregate = resolve('scripts/conformance/server-parity/aggregate.mjs'); +const aggregateWrapper = 'scripts/conformance/server-parity/aggregate.sh'; +const classifierWrapper = 'scripts/conformance/server-parity/changes.sh'; + +// Top-level job ids and each job's block of text. +function jobs(text) { + const body = text.slice(text.indexOf('\njobs:\n') + '\njobs:\n'.length); + const blocks = {}; + let current; + for (const line of body.split('\n')) { + const header = /^ {2}([a-z0-9-]+):$/.exec(line); + if (header) { + current = header[1]; + blocks[current] = ''; + } else if (current) { + blocks[current] += `${line}\n`; + } + } + return blocks; +} + +function triggers(text) { + const start = text.indexOf('\non:\n') + '\non:\n'.length; + const rest = text.slice(start); + return rest.slice(0, rest.search(/\n[a-z_-]+:/)); +} + +test('pull requests run without a path filter and push keeps the one path list', () => { + const on = triggers(workflowText); + const pullRequest = on.slice(on.indexOf(' pull_request:\n'), on.indexOf(' push:\n')); + assert.doesNotMatch(pullRequest, /paths|branches/); + const patterns = pushPathPatterns(workflowText); + assert.ok(patterns.includes('rust/**')); + assert.ok(patterns.includes('.github/workflows/server-parity.yml')); + assert.ok(patterns.includes('scripts/conformance/server-parity/**')); +}); + +test('path patterns follow GitHub filter semantics for the forms in use', () => { + assert.ok(patternRegExp('rust/**').test('rust/src/main.rs')); + assert.ok(patternRegExp('scripts/conformance/server-parity*').test('scripts/conformance/server-parity.sh')); + assert.ok(!patternRegExp('scripts/conformance/server-parity*').test('scripts/conformance/server-parity/run.mjs')); + assert.ok(!patternRegExp('tests/Unit/ServerParityRunnerTest.mjs').test('tests/Unit/ServerParityRunnerTestXmjs')); + assert.throws(() => patternRegExp('docs/{a,b}/**'), /Unsupported path filter pattern/); + assert.deepEqual(matchingFiles(['rust/**', 'docs/x.md'], ['README.md', 'rust/Cargo.toml', 'docs/x.md']), ['rust/Cargo.toml', 'docs/x.md']); +}); + +test('path patterns accept no regular-expression syntax and match every other character literally', () => { + for (const pattern of ['rust\\**', 'rust/\\d', 'a+b', 'a?b', 'a(b)', 'a[b]', 'a|b', 'a^b', 'a$b', 'a{1}', 'docs/{a,b}/**', 'a b', '']) { + assert.throws(() => patternRegExp(pattern), /Unsupported path filter pattern/, JSON.stringify(pattern)); + } + assert.ok(!patternRegExp('a.b').test('aXb')); + assert.ok(patternRegExp('a-b/c_d.e').test('a-b/c_d.e')); + assert.ok(!patternRegExp('a-b').test('a+b')); +}); + +test('path patterns match file names holding newlines and tabs', () => { + assert.ok(patternRegExp('rust/**').test('rust/hidden\nfixture.rs')); + assert.ok(patternRegExp('rust/**').test('rust/a\n/b\tc.rs')); + assert.ok(patternRegExp('scripts/conformance/server-parity*').test('scripts/conformance/server-parity\n.sh')); + assert.ok(!patternRegExp('scripts/conformance/server-parity*').test('scripts/conformance/server-parity\n/run.mjs')); + assert.ok(!patternRegExp('rust/**').test('docs/x\nrust/y')); +}); + +test('the file list splits only on NUL and refuses what it cannot decode', () => { + assert.deepEqual(nulSeparatedPaths(Buffer.from('README.md\0rust/hidden\nfixture.rs\0 lead\ttab \0')), + ['README.md', 'rust/hidden\nfixture.rs', ' lead\ttab ']); + assert.deepEqual(nulSeparatedPaths(Buffer.alloc(0)), []); + assert.throws(() => nulSeparatedPaths(Buffer.from('rust/a.rs')), /does not end with NUL/); + assert.throws(() => nulSeparatedPaths(Buffer.from([0x72, 0xff, 0])), TypeError); +}); + +test('the fixture jobs run only through the classifier and the result waits for all of them', () => { + const blocks = jobs(workflowText); + const ids = Object.keys(blocks); + assert.equal(ids[0], 'changes'); + assert.match(blocks.changes, /^ {4}timeout-minutes: \d+$/m); + assert.match(blocks.changes, /run: \$\{\{ steps\.classify\.outputs\.run \}\}/); + assert.match(blocks.changes, /run: bash scripts\/conformance\/server-parity\/changes\.sh >> "\$GITHUB_OUTPUT"$/m); + // The root fixture jobs run only through the classifier. + const roots = ['published-sdk-adapters', 'codec-native-build']; + for (const id of roots) { + assert.match(blocks[id], /^ {4}needs: changes\n {4}if: \$\{\{ needs\.changes\.outputs\.run == 'true' \}\}$/m, id); + } + const fixtureJobs = ids.filter(id => id !== 'changes' && id !== 'result'); + for (const id of fixtureJobs.filter(id => !roots.includes(id))) { + // A skipped root skips these jobs; no job-level condition may override + // that. Every one waits for the SDK adapters, and only deadline health + // also waits for the optimized native build. + const expected = id === 'codec-deadline-health' ? '[published-sdk-adapters, codec-native-build]' : 'published-sdk-adapters'; + assert.ok(blocks[id].split('\n').includes(` needs: ${expected}`), `${id} needs ${expected}`); + assert.doesNotMatch(blocks[id], /^ {4}if:/m, id); + } + // Every job other than the classifier and the result is a gated fixture job. + assert.deepEqual(fixtureJobs.sort(), ['codec-deadline-health', 'codec-native-build', 'execution-and-codec', + 'mysql-execution', 'postgres-storage', 'published-sdk-adapters']); + const result = blocks.result; + assert.equal(ids.at(-1), 'result'); + assert.match(result, /^ {4}name: Shared Server fixtures result$/m); + assert.doesNotMatch(result, /strategy:/); + assert.match(result, /^ {4}if: \$\{\{ always\(\) \}\}$/m); + const needs = [...result.matchAll(/^ {6}- ([a-z0-9-]+)$/gm)].map(match => match[1]); + assert.deepEqual(needs.sort(), ['changes', ...fixtureJobs].sort()); + assert.match(result, /NEEDS: \$\{\{ toJSON\(needs\) \}\}/); + assert.match(result, /run: bash scripts\/conformance\/server-parity\/aggregate\.sh$/m); + assert.match(result, /sparse-checkout: \|\n {12}scripts\/conformance\/server-parity\/aggregate\.sh\n {12}scripts\/conformance\/server-parity\/aggregate\.mjs\n/); +}); + +function runAggregate(needs) { + const text = typeof needs === 'string' ? needs : JSON.stringify(needs); + return spawnSync('node', [aggregate], {env: {...process.env, NEEDS: text}, encoding: 'utf8'}); +} + +const fixture = (result, run) => ({ + changes: {result: 'success', outputs: run === undefined ? {} : {run}}, + 'published-sdk-adapters': {result, outputs: {}}, + 'codec-native-build': {result, outputs: {}}, + 'codec-deadline-health': {result, outputs: {}}, + 'mysql-execution': {result, outputs: {}}, + 'postgres-storage': {result, outputs: {}}, + 'execution-and-codec': {result, outputs: {}}, +}); + +test('the result passes when every fixture job passed', () => { + const outcome = runAggregate(fixture('success', 'true')); + assert.equal(outcome.status, 0, outcome.stderr); + assert.match(outcome.stdout, /Every fixture job passed/); +}); + +test('the result passes when no input changed and every fixture job was skipped', () => { + const outcome = runAggregate(fixture('skipped', 'false')); + assert.equal(outcome.status, 0, outcome.stderr); + assert.match(outcome.stdout, /every fixture job was skipped/); +}); + +for (const [label, needs, reason] of [ + ['a failed fixture job', {...fixture('success', 'true'), 'mysql-execution': {result: 'failure', outputs: {}}}, /must be success when run=true: mysql-execution=failure\./], + ['a cancelled fixture job', {...fixture('success', 'true'), 'execution-and-codec': {result: 'cancelled', outputs: {}}}, /must be success when run=true: execution-and-codec=cancelled\./], + ['a skipped fixture job when inputs changed', {...fixture('success', 'true'), 'postgres-storage': {result: 'skipped', outputs: {}}}, /must be success when run=true: postgres-storage=skipped\./], + ['a fixture job that ran when no input changed', {...fixture('skipped', 'false'), 'codec-deadline-health': {result: 'success', outputs: {}}}, /must be skipped when run=false: codec-deadline-health=success\./], + ['a failed classifier', {...fixture('skipped', 'false'), changes: {result: 'failure', outputs: {}}}, /classifier did not succeed \(failure\)/], + ['a cancelled classifier', {...fixture('skipped'), changes: {result: 'cancelled', outputs: {}}}, /classifier did not succeed \(cancelled\)/], + ['a missing classifier', (({changes, ...rest}) => rest)(fixture('success', 'true')), /classifier did not succeed \(undefined\)/], + ['a classifier with no decision', fixture('skipped'), /reported no decision \(undefined\)/], + ['a classifier with an unknown decision', fixture('skipped', 'maybe'), /reported no decision \(maybe\)/], + ['no fixture jobs', {changes: {result: 'success', outputs: {run: 'true'}}}, /No fixture jobs reported/], + ['a context that is not an object', [], /not an object/], +]) { + test(`the result fails on ${label}`, () => { + const decision = decide(needs); + assert.equal(decision.pass, false); + assert.match(decision.reason, reason); + const outcome = runAggregate(needs); + assert.equal(outcome.status, 1); + assert.match(outcome.stderr, reason); + }); +} + +test('the workflow wrapper passes the result through, from the repository root', () => { + const run = needs => spawnSync('bash', [aggregateWrapper], {env: {...process.env, NEEDS: JSON.stringify(needs)}, encoding: 'utf8'}); + assert.equal(run(fixture('success', 'true')).status, 0); + const failed = run({...fixture('success', 'true'), 'mysql-execution': {result: 'failure', outputs: {}}}); + assert.equal(failed.status, 1); + assert.match(failed.stderr, /mysql-execution=failure/); +}); + +test('the result fails when the needs context cannot be read', () => { + for (const text of ['', 'not json']) { + const outcome = runAggregate(text); + assert.equal(outcome.status, 1); + assert.match(outcome.stderr, /could not be read/); + } +}); + +// A pull request merge commit in a scratch repository with this workflow. +function pullRequest(change) { + const root = mkdtempSync(join(tmpdir(), 'parity-changes-')); + const git = (...args) => execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@example.invalid', ...args], {cwd: root, encoding: 'utf8'}).trim(); + const write = (path, text) => { + mkdirSync(dirname(join(root, path)), {recursive: true}); + writeFileSync(join(root, path), text); + }; + git('init', '-q', '-b', 'main'); + mkdirSync(join(root, '.github/workflows'), {recursive: true}); + copyFileSync(workflowPath, join(root, workflowPath)); + mkdirSync(join(root, dirname(classifierWrapper)), {recursive: true}); + for (const file of [classifierWrapper, 'scripts/conformance/server-parity/changes.mjs']) copyFileSync(file, join(root, file)); + write('README.md', 'base\n'); + write('tests/Fixtures/ServerParity/case.json', '{}\n'); + git('add', '-A'); + git('commit', '-q', '-m', 'base'); + git('checkout', '-q', '-b', 'head'); + change({root, write}); + git('add', '-A'); + git('commit', '-q', '-m', 'head'); + const head = git('rev-parse', 'HEAD'); + git('checkout', '-q', 'main'); + write('MAIN.md', 'main moved\n'); + git('add', '-A'); + git('commit', '-q', '-m', 'main'); + git('merge', '-q', '--no-ff', '-m', 'merge', 'head'); + return {root, head}; +} + +function classify(root, env) { + // The workflow runs the wrapper from the repository root. + const outcome = spawnSync('bash', [classifierWrapper], {cwd: root, env: {...process.env, ...env}, encoding: 'utf8'}); + assert.equal(outcome.status, 0, outcome.stderr); + return outcome.stdout.trim(); +} + +for (const [label, change, expected] of [ + ['a documentation-only change skips the fixtures', ({write}) => write('docs/note.md', 'x\n'), 'run=false'], + ['a Rust change runs the fixtures', ({write}) => write('rust/src/lib.rs', 'x\n'), 'run=true'], + ['a Rust file whose name holds a newline runs the fixtures', ({write}) => write('rust/hidden\nfixture.rs', 'x\n'), 'run=true'], + ['a Rust file whose name holds a tab runs the fixtures', ({write}) => write('rust/hidden\tfixture.rs', 'x\n'), 'run=true'], + ['a fixture whose name holds a newline runs the fixtures', ({write}) => write('tests/Fixtures/ServerParity/a\nb.json', '{}\n'), 'run=true'], + ['a documentation file whose name holds a newline still skips', ({write}) => write('docs/a\nrust/b.md', 'x\n'), 'run=false'], + ['a file name that is not UTF-8 runs the fixtures', ({root}) => { + mkdirSync(join(root, 'docs'), {recursive: true}); + writeFileSync(Buffer.concat([Buffer.from(join(root, 'docs/')), Buffer.from([0xff, 0x2e, 0x6d, 0x64])]), 'x\n'); + }, 'run=true'], + ['an unsupported push path pattern runs the fixtures', ({write}) => { + write('docs/note.md', 'x\n'); + write(workflowPath, workflowText.replace(' paths:\n', ' paths:\n - docs/{a,b}/**\n')); + }, 'run=true'], + ['moving a fixture out of the corpus runs the fixtures', ({root, write}) => { + write('docs/.keep', ''); + renameSync(join(root, 'tests/Fixtures/ServerParity/case.json'), join(root, 'docs/case.json')); + }, 'run=true'], +]) { + test(`pull request classification: ${label}`, () => { + const {root, head} = pullRequest(change); + try { + assert.equal(classify(root, {EVENT_NAME: 'pull_request', PR_HEAD_SHA: head}), expected); + } finally { + rmSync(root, {recursive: true, force: true}); + } + }); +} + +test('pull request classification runs every fixture when HEAD is not that pull request', () => { + const {root} = pullRequest(({write}) => write('docs/note.md', 'x\n')); + try { + assert.equal(classify(root, {EVENT_NAME: 'pull_request', PR_HEAD_SHA: '0'.repeat(40)}), 'run=true'); + } finally { + rmSync(root, {recursive: true, force: true}); + } +}); + +for (const event of ['push', 'merge_group', 'workflow_dispatch', '']) { + test(`${event || 'an unnamed event'} runs every fixture job`, () => { + const {root, head} = pullRequest(({write}) => write('docs/note.md', 'x\n')); + try { + assert.equal(classify(root, {EVENT_NAME: event, PR_HEAD_SHA: head}), 'run=true'); + } finally { + rmSync(root, {recursive: true, force: true}); + } + }); +} diff --git a/tests/Unit/ServerParityPartsTest.mjs b/tests/Unit/ServerParityPartsTest.mjs new file mode 100644 index 000000000..2e57c6de2 --- /dev/null +++ b/tests/Unit/ServerParityPartsTest.mjs @@ -0,0 +1,91 @@ +// Each Shared Server fixtures database job runs in three parts on separate +// runners. Every step either prepares the part (and so runs in every part) or +// belongs to exactly one part, so no check runs twice or not at all. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; + +const workflow = readFileSync('.github/workflows/server-parity.yml', 'utf8'); +const PARTS = ['native-tests', 'corpus', 'cohorts']; +const EVERY_PART = [/^actions\/checkout@/, /^Fetch /, /^Create TLS fixture certificates$/, /^Bootstrap independent /, + /^Collect /, /^Summarize /, /^Retain /]; + +function job(name) { + const start = workflow.indexOf(`\n ${name}:\n`); + assert.ok(start >= 0, name); + const rest = workflow.slice(start + 1); + const end = rest.slice(1).search(/\n {2}[a-z0-9-]+:\n/); + return end < 0 ? rest : rest.slice(0, end + 2); +} + +function steps(block) { + return block.split(/\n(?= {6}- )/).slice(1).map(text => ({ + name: /^ {6}- (?:name: |uses: )(.*)$/m.exec(text)[1], + id: /^ {8}id: (.*)$/m.exec(text)?.[1], + condition: /^ {8}if: (.*)$/m.exec(text)?.[1] ?? null, + text, + })); +} + +// The parts a step runs in, read from its condition. +function partsOf(step) { + if (EVERY_PART.some(pattern => pattern.test(step.name))) return PARTS; + const only = /matrix\.part == '([a-z-]+)'/.exec(step.condition ?? ''); + if (only) return [only[1]]; + const except = /matrix\.part != '([a-z-]+)'/.exec(step.condition ?? ''); + if (except) return PARTS.filter(part => part !== except[1]); + return PARTS; +} + +for (const [name, key] of [['mysql-execution', 'Compare three runtimes'], ['postgres-storage', 'Compare three runtimes'], + ['execution-and-codec', 'Execute and compare Rust']]) { + test(`${name} runs every check in exactly one of three parts`, () => { + const block = job(name); + assert.match(block, /^ {8}part: \[native-tests, corpus, cohorts\]$/m); + assert.match(block, /^ {6}DW_PARITY_PART: \$\{\{ matrix\.part \}\}$/m); + assert.match(block, /^ {4}timeout-minutes: 60$/m); + const all = steps(block); + for (const step of all) { + const parts = partsOf(step); + if (parts.length === PARTS.length) { + assert.ok(EVERY_PART.some(pattern => pattern.test(step.name)), `${name}: "${step.name}" must belong to one part`); + } else if (step.name !== 'Build the native server') { + assert.equal(parts.length, 1, `${name}: "${step.name}"`); + } + } + const inPart = part => all.filter(step => partsOf(step).length === 1 && partsOf(step)[0] === part).map(step => step.name); + assert.ok(inPart('corpus').some(step => step.startsWith(key)), `${name}: the corpus comparison is in the corpus part`); + const cohorts = inPart('cohorts'); + for (const cohort of ['Start the shared native node the cohorts use', 'Compare separate configured role-token cohort', + 'Back up, take over and resume a separate published PHP upgrade source', + 'Back up, take over and resume a published PHP 2.9.7 upgrade source', + 'Compare corrected markers, ranges, clocks and activity expiry', 'Compare installed local external payload consumers', + 'Compare published query deadlines and installed Worker authority', + 'Compare pinned PHP, embedded and native worker-fencing source cohort', + 'Compare SDK temporary database pressure with declared fault limits']) { + assert.ok(cohorts.includes(cohort), `${name}: ${cohort} is in the cohorts part`); + } + // Cleanups still run after a failure within their part. + for (const step of all.filter(step => step.name.startsWith('Retire '))) { + assert.match(step.condition, /^\$\{\{ always\(\) && matrix\.part == 'cohorts' \}\}$/, step.name); + } + // The corrected cohort runs against the cohorts' own native node. + const corrected = all.find(step => step.name.startsWith('Compare corrected markers')); + assert.match(corrected.condition, /steps\.prepare_cohorts\.outcome == 'success'/); + assert.doesNotMatch(block, /steps\.prepare_runtime_comparison\.outcome/); + assert.equal(all.find(step => step.name === 'Start the shared native node the cohorts use').id, 'prepare_cohorts'); + // Each part retains its own summary. + assert.match(block, /^ {10}name: [a-z-]+(?:-\$\{\{ matrix\.label \}\})?-\$\{\{ matrix\.part \}\}$/m); + // The native database suites or the codec suite run once, in native-tests. + const native = inPart('native-tests'); + assert.ok(native.some(step => /^(Verify TLS|Execute official Rust codec)/.test(step)), `${name}: native-tests checks`); + }); +} + +test('the database bootstraps run the native suites only in the native-tests part', () => { + for (const name of ['mysql-execution', 'postgres-storage']) { + const bootstrap = steps(job(name)).find(step => step.name.startsWith('Bootstrap independent ')); + assert.match(bootstrap.text, /-e DW_PARITY_PART \\\n/, name); + assert.match(bootstrap.text, /'if \[ "\$DW_PARITY_PART" = native-tests \]; then cargo test [^']* --test execution [^']*; fi && cargo build --locked --offline'/, name); + } +}); diff --git a/tests/Unit/ServerParitySdkDirectionLanesTest.mjs b/tests/Unit/ServerParitySdkDirectionLanesTest.mjs new file mode 100644 index 000000000..76d034787 --- /dev/null +++ b/tests/Unit/ServerParitySdkDirectionLanesTest.mjs @@ -0,0 +1,159 @@ +import assert from 'node:assert/strict'; +import {spawnSync} from 'node:child_process'; +import {chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import test from 'node:test'; + +const LANES = new URL('../../scripts/conformance/server-parity/sdk-direction-lanes.sh', import.meta.url).pathname; +const PROFILES = { + update: 'sdk-updates', 'update-failure': 'sdk-update-failures', child: 'sdk-child-directions', + 'namespace-signal': 'sdk-namespace-signals', timer: 'sdk-timers', activity: 'sdk-directions', + 'selected-run-signal': 'sdk-selected-run-signals', signal: 'sdk-signals', query: 'sdk-queries', +}; + +// Runs the wrapper in a scratch tree whose profile script and `docker` are +// stubs that append to one event log: a profile starts its PHP server and +// exits FAIL_ (default 0); `docker stop` exits STOP_FAIL_. +function runLanes(environment = {}) { + const root = mkdtempSync(join(tmpdir(), 'dw-sdk-lanes-')); + try { + const scripts = join(root, 'scripts/conformance/server-parity'); + const bin = join(root, 'bin'); + mkdirSync(scripts, {recursive: true}); + mkdirSync(bin); + copyFileSync(LANES, join(scripts, 'sdk-direction-lanes.sh')); + const events = join(root, 'events.log'); + writeFileSync(join(scripts, 'sdk-direction-profile.sh'), `#!/usr/bin/env bash +set -euo pipefail +variant=$3 +echo "start $variant $1 $2" >> "$EVENTS" +echo "profile output for $variant" +sleep 0.2 +echo "end $variant" >> "$EVENTS" +fail="FAIL_\${variant//-/_}" +exit "\${!fail:-0}" +`); + writeFileSync(join(bin, 'docker'), `#!/usr/bin/env bash +echo "docker $*" >> "$EVENTS" +name=\${*: -1} +tag=\${name#"$RESOURCE_SCOPE"-} +tag=\${tag%-server} +fail="STOP_FAIL_\${tag//-/_}" +exit "\${!fail:-0}" +`); + chmodSync(join(bin, 'docker'), 0o755); + const child = spawnSync('bash', ['scripts/conformance/server-parity/sdk-direction-lanes.sh', 'sqlite', 'http://native:8080'], { + cwd: root, encoding: 'utf8', timeout: 30000, + env: {...process.env, ...environment, PATH: `${bin}:${process.env.PATH}`, EVENTS: events, RESOURCE_SCOPE: 'dw-scope-1'}, + }); + const statuses = Object.fromEntries(Object.values(PROFILES).map(tag => [tag, + readFileSync(join(root, 'parity-evidence/sdk-direction-lanes', `${tag}.status`), 'utf8').trim()])); + return {child, statuses, events: readFileSync(events, 'utf8').trim().split('\n')}; + } finally { + rmSync(root, {recursive: true, force: true}); + } +} + +const started = (events, variant) => events.findIndex(event => event.startsWith(`start ${variant} `)); + +// Each profile ran once against the given backend and URL, and its PHP server +// was stopped after the profile ended and before the lane's next profile. +function assertEveryServerRetired(events) { + const running = new Set(); + let peak = 0; + for (const event of events) { + const [kind, ...rest] = event.split(' '); + if (kind === 'start') { + assert.deepEqual(rest.slice(1), ['sqlite', 'http://native:8080'], event); + running.add(PROFILES[rest[0]]); + peak = Math.max(peak, running.size); + } else if (kind === 'docker') { + assert.deepEqual(rest.slice(0, 3), ['stop', '--time', '30'], event); + const tag = rest[3].replace(/^dw-scope-1-/, '').replace(/-server$/, ''); + assert.ok(running.delete(tag), `${tag} stopped once, after it started`); + const end = events.indexOf(`end ${Object.keys(PROFILES).find(variant => PROFILES[variant] === tag)}`); + assert.ok(end >= 0 && end < events.indexOf(event), `${tag} stopped after its profile ended`); + } + } + assert.equal(running.size, 0, `servers left running: ${[...running]}`); + assert.ok(peak <= 3, `at most three PHP servers run at once, not ${peak}`); + const started = events.filter(event => event.startsWith('start ')).map(event => event.split(' ')[1]).sort(); + assert.deepEqual(started, Object.keys(PROFILES).sort(), 'all nine profiles ran once'); + assert.deepEqual(events.filter(event => event.startsWith('docker ')).map(event => event.split(' ').at(-1)).sort(), + Object.values(PROFILES).map(tag => `dw-scope-1-${tag}-server`).sort(), 'every profile server stopped once'); +} + +test('all nine profiles pass and each PHP server is stopped after its profile', () => { + const {child, statuses, events} = runLanes(); + assert.equal(child.status, 0, child.stderr); + assert.deepEqual(new Set(Object.values(statuses)), new Set(['0'])); + assertEveryServerRetired(events); + for (const [variant, tag] of Object.entries(PROFILES)) { + assert.match(child.stdout, new RegExp(`::group::${tag} \\(exit 0\\)\\nprofile output for ${variant}\\n::endgroup::`)); + } + assert.doesNotMatch(child.stderr, /::error::|::warning::/); +}); + +test('a failed profile still runs its lane and fails the wrapper only after every status and log', () => { + // The timer profile is second in its lane; the activity profile follows it. + const {child, statuses, events} = runLanes({FAIL_timer: '7'}); + assert.equal(child.status, 1); + assert.deepEqual(statuses, {...Object.fromEntries(Object.values(PROFILES).map(tag => [tag, '0'])), 'sdk-timers': '7'}); + assertEveryServerRetired(events); + assert.ok(started(events, 'activity') > events.indexOf('end timer'), 'the lane continues after a failure'); + assert.match(child.stderr, /^::error::SDK direction profile sdk-timers exited 7$/m); + assert.equal(child.stderr.match(/::error::/g).length, 1); + assert.match(child.stdout, /::group::sdk-timers \(exit 7\)\nprofile output for timer\n::endgroup::/); + assert.match(child.stdout, /::group::sdk-queries \(exit 0\)/, 'logs after the failure are still printed'); +}); + +test('a failed stop warns without changing any profile status', () => { + const {child, statuses, events} = runLanes({STOP_FAIL_sdk_updates: '1'}); + assert.equal(child.status, 0, child.stderr); + assert.equal(statuses['sdk-updates'], '0'); + assert.match(child.stderr, /^::warning::The sdk-updates PHP server was not stopped; the final cleanup removes it$/m); + assert.ok(started(events, 'update-failure') > events.indexOf('docker stop --time 30 dw-scope-1-sdk-updates-server'), + 'the lane continues after a failed stop'); +}); + +test('a failed profile and a failed stop of its server keep the profile status', () => { + const {child, statuses} = runLanes({FAIL_query: '3', STOP_FAIL_sdk_queries: '1'}); + assert.equal(child.status, 1); + assert.equal(statuses['sdk-queries'], '3'); + assert.match(child.stderr, /^::error::SDK direction profile sdk-queries exited 3$/m); +}); + +test('the wrapper refuses to run without an owning resource scope', () => { + const child = spawnSync('bash', [LANES, 'sqlite', 'http://native:8080'], { + encoding: 'utf8', env: {...process.env, RESOURCE_SCOPE: ''}, cwd: tmpdir(), + }); + assert.equal(child.status, 2); + assert.match(child.stderr, /Set the owning isolated network\/resource scope/); +}); + +// The native server is shared by every lane. Two profiles whose fixtures name +// the same task queue would see each other's workers, so they share a lane. +test('profiles that share a task queue run in the same lane', () => { + const script = readFileSync(LANES, 'utf8'); + const lanes = [...script.slice(script.indexOf('lanes=(')).split(')')[0].matchAll(/^ {2}"([^"]+)"$/gm)] + .map(match => match[1].split(' ').map(entry => entry.split(':')[1])); + assert.deepEqual(lanes.flat().sort(), Object.values(PROFILES).sort(), 'every profile in exactly one lane'); + const queues = tag => { + const directory = new URL(`../Fixtures/ServerParityProfiles/published-${tag}/`, import.meta.url); + const text = readdirSync(directory).filter(name => name.endsWith('.json')) + .map(name => readFileSync(new URL(name, directory), 'utf8')).join('\n'); + return new Set(text.match(/server-parity-sdk-[a-z-]+/g) ?? []); + }; + for (const [index, lane] of lanes.entries()) { + for (const other of lanes.slice(index + 1)) { + for (const tag of lane) { + for (const peer of other) { + const shared = [...queues(tag)].filter(queue => queues(peer).has(queue)); + assert.deepEqual(shared, [], `${tag} and ${peer} share ${shared} but run in different lanes`); + } + } + } + } + assert.ok(queues('sdk-signals').has('server-parity-sdk-signals'), 'queues are read from the fixtures'); +});