diff --git a/.github/workflows/server-parity.yml b/.github/workflows/server-parity.yml index 7a738dc73..62c6cad7f 100644 --- a/.github/workflows/server-parity.yml +++ b/.github/workflows/server-parity.yml @@ -11,6 +11,7 @@ on: - 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 @@ -47,6 +48,7 @@ on: - 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 @@ -132,6 +134,15 @@ jobs: DW_PARITY_CORRECTED_CARGO: sdk-cargo DW_PARITY_CORRECTED_TARGET: sdk-target run: bash scripts/conformance/server-parity/corrected-marker-adapters.sh + - name: Build the separate current query consumers once + run: bash scripts/conformance/server-parity/query-authority-adapters.sh + - name: Share current query consumers without compiler caches + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: query-authority-adapters + path: query-authority-adapters + retention-days: 1 + if-no-files-found: error - name: Share the corrected cohort without compiler caches or target trees uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: @@ -193,6 +204,8 @@ jobs: corrected-marker-adapters/*-build.txt corrected-marker-adapters/*-install.txt codec-native-build/build.txt + query-authority-adapters/*-build.txt + query-authority-adapters/*-install.txt retention-days: 1 if-no-files-found: ignore @@ -302,6 +315,12 @@ jobs: name: published-sdk-patch-adapters path: published-sdk-adapters digest-mismatch: error + - name: Fetch this run's current query consumers + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: query-authority-adapters + path: query-authority-adapters + digest-mismatch: error - name: Fetch this run's genuine legacy-marker adapters uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: @@ -642,6 +661,14 @@ jobs: if: always() 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() }} + timeout-minutes: 10 + run: bash scripts/conformance/server-parity/query-authority-cohort.sh mysql + - name: Retire query authority resources + if: always() + 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() }} run: bash scripts/conformance/server-parity/worker-fencing-profile.sh mysql @@ -765,6 +792,12 @@ jobs: name: published-sdk-patch-adapters path: published-sdk-adapters digest-mismatch: error + - name: Fetch this run's current query consumers + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: query-authority-adapters + path: query-authority-adapters + digest-mismatch: error - name: Fetch this run's genuine legacy-marker adapters uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: @@ -1091,6 +1124,18 @@ jobs: 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() }} + 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() + 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() }} env: @@ -1233,6 +1278,12 @@ jobs: name: published-sdk-patch-adapters path: published-sdk-adapters digest-mismatch: error + - name: Fetch this run's current query consumers + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: query-authority-adapters + path: query-authority-adapters + digest-mismatch: error - name: Fetch this run's genuine legacy-marker adapters uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: @@ -1294,7 +1345,7 @@ jobs: "$image" --test tests/Unit/ServerParityCodecDeadlineLoadTest.mjs > parity-evidence/codec-load-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/ServerParityArtifactSummaryTest.mjs > parity-evidence/artifact-summary-tests.txt + "$image" --test tests/Unit/ServerParityArtifactSummaryTest.mjs tests/Unit/ServerParityQueryObserverTest.mjs > parity-evidence/artifact-summary-tests.txt docker network create "$RESOURCE_SCOPE" docker run -d --name "$RESOURCE_SCOPE-rust" --network "$RESOURCE_SCOPE" --network-alias=parity-rust \ --user="$(id -u):$(id -g)" --cpus=1 --memory=256m --memory-swap=256m \ @@ -1515,6 +1566,18 @@ jobs: 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() }} + 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() + 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() }} env: diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index 8d580ee61..0f6ff5469 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -6,16 +6,13 @@ The unpublished Rust crate has an Avro foundation and an opt-in SQLite/PostgreSQ execution slice. Full runtime parity, migration, performance improvement and cutover remain unqualified. -The delivered native foundation is [#450](https://github.com/durable-workflow/server/pull/450), -following memo transactions, activity/continuation replay and SDK memo authoring -in #445/#446/#448. Their exact fresh-main trees pass all 13 shared jobs across -six databases. #446's first failed main query remains failed and unexplained; -its unchanged second attempt reran all 13 jobs, rather than one database. -The consumer runner [#457](https://github.com/durable-workflow/server/pull/457) -is merged as `303ffc633` with its qualified candidate tree. All six candidate -execution logs have 45 passing records; separate fresh-main qualification -[38668891437](https://github.com/durable-workflow/server/actions/runs/38668891437) -remains pending. No result is carried across changed trees. +The latest qualified merged foundation is [#457](https://github.com/durable-workflow/server/pull/457), +at `303ffc633`, including #445's memo transaction preparation, #446's bounded +continuation, #448's SDK memo authoring and #450's local external transport. +Its separate fresh-main [38668891437](https://github.com/durable-workflow/server/actions/runs/38668891437) +passes all 13 jobs across six databases; all six execution logs were read. +Required PHP/source and boundary checks also pass. Earlier failed observations +remain failed; this current-tree result is not carried across later changes. The merged native development runtime enables memo upserts, while full external transport discovery stays unadvertised. Frozen published PHP, embedded and SDK tuples remain separate from current PHP release work. Representative timer, signal, @@ -25,54 +22,173 @@ failed; later qualifying runs are separate observations. ## Work order and current status -The pending #455/#458 stack will be promoted through #458 after its combined -main candidate passes all required checks. This delivers the reviewed parent -in the same tested main tree. Earlier admitted matrices remain separate -observations and continue; strict branch protection is preserved. - +The current integration order is: + +- Merged [#445](https://github.com/durable-workflow/server/pull/445), + [#446](https://github.com/durable-workflow/server/pull/446), + [#448](https://github.com/durable-workflow/server/pull/448) and + [#450](https://github.com/durable-workflow/server/pull/450) provide memo + transactions, actual SDK memo authoring, bounded continuation and verified + local external values. Their individual acceptance records preserve original + registration, runner and 100 ms upload failures. Current merged execution is + covered by the separate #457 fresh-main qualification above. External memo + policy, complete continuation transfer and full external transport remain open. - [#455](https://github.com/durable-workflow/server/pull/455): opt-in, - namespace/queue-scoped null-task poll hint without claim or lease authority. - The earlier component passes all 13 jobs in 38647001027. Current combined - qualification remains pending in #458. Both PHP query middleware additions - and combined OpenAPI revision 55 stay intact. An earlier component failed after an activity + namespace/queue-scoped null-task poll wake. Component `1fa1cc2e6` passes all + 13 jobs and all six databases; promotion is through the separately qualified + combined #458 tree. An earlier component's SQLite PHP target failed after an activity completion response when the Rust actor's registration DELETE returned lock-pressure 503; later refusal/repair phases were not reached. That original stays failed. Idle/query cost and matched performance remain open. -- [#458](https://github.com/durable-workflow/server/pull/458): version-1 activity - result completion uploads, combined with the parent poll hints. It has 70 - passing library tests (18 explicit ignores), 113 SQLite HTTP cases and three - focused authority/race controls on MySQL 8.4 and PostgreSQL 17. Actual 36 - published-PHP/native originals and strict readers pass across those three - local databases with genuine Unicode ownership, original deadlines, exact - payload hashes and cold parent completion. Full current-main/all-six - qualification and promotion remain pending. Initial Unicode, MySQL identity, - JSON member-order and 100ms control failures stay recorded in the PR. - The complete combined `3a268200` run `38680689284` includes qualified PHP - `ed803519`, passes twelve jobs and fails PostgreSQL 17 at the independent +- [#457](https://github.com/durable-workflow/server/pull/457): extend the actual + installed external consumers to separate PHP/native databases in all six + configurations. Candidate and separate merged-main executions now pass all + six configurations, with five original workflows per target and exact typed + cold reads. It is delivered at `303ffc633`. This does not qualify automatic + policy, S3 or completion-upload authority. +- [#458](https://github.com/durable-workflow/server/pull/458): completion-bound + local activity-result uploads, including exact PHP Unicode context hashing, + original live authority and durable allowances. Combined head `93368d2ae` + includes #455 and passes its own 13-job, six-database matrix. The later + `3a268200` incorporates qualified PHP `ed803519`. Its complete run + `38680689284` passes twelve jobs and fails PostgreSQL 17 at the independent fresh upload still using the preceding stalled request's 100 ms test policy. All six small execution summaries were independently read: five cells pass 113 execution tests; PostgreSQL 17 has 112 passes and that one failure. - That original remains failed. - The next fixture revision keeps the stalled deadline and shared semaphore, - then uses normal policy for the separate fresh upload. Recovered-target - checks will require their actual preparation step to succeed, preventing - dependent absence from looking like an execution failure. The next combined - source also integrates separately qualified PHP empty-command-contract - correction `35cd3642`; it needs its own full qualification. - No workflow/query/prepared - context, pressure-bypass or full transport capability is advertised. -- [#460](https://github.com/durable-workflow/server/pull/460): read-only upgrade - source inventory. Four actual published PHP 2.8.0 profiles pass locally, with - eight original pending runs, twelve refusals and unchanged source state. - WAL SQLite refuses before opening; all backup/quiescence/representation/ - takeover flags remain false. Combined hosted qualification and actual - conversion/backup/continuation gates remain ahead. + New candidate `3409a776e` keeps that stalled deadline and shared semaphore, + gives the separate fresh upload normal policy, gates dependent recovered + readers on successful target preparation and incorporates qualified PHP + empty-command-contract source `35cd3642`. + That component's six execution and six health cells pass in run + `38686850381`. Attempt 2 reran only MySQL 8.0, with unchanged source and + limits. Its original query transport failure remains failed and unexplained. + The tested merge tree equals the candidate tree; later combined promotion + still needs fresh qualification. +- [#460](https://github.com/durable-workflow/server/pull/460): read-only PHP + catalog/data inventory. Four actual published PHP 2.8.0 profiles pass locally, + with eight original pending runs, twelve refusals and unchanged source state. + Head `4459140cd` adds actual FIFO/directory refusal + and a separately budgeted fresh-upload capacity control. Its matrix is + incomplete. The SQLite job failed in frozen PHP restart preparation before + creating the recovered native target. Its later empty response therefore + reflects an absent prerequisite, not a demonstrated native crash. The earlier + SQLite 100 ms upload failure also remains failed. Inspection + does not establish backup consistency, PHP quiescence, stored-representation + compatibility or takeover authority. All those qualification flags stay false. +- Native [query authority](https://github.com/durable-workflow/server/issues/433) + is prepared at `5d10c6b74` under the agreed PHP contract: immutable UTC6 + timing, capped leases, non-renewing status, registration/namespace fences and + content-bound query receipts. Local staged checks cover SQLite, MySQL 8.4 + and PostgreSQL 17. The corrected waiter preserves the original deadline under + a held writer and refuses a result delayed by codec capacity. This is not a + clean full-suite or matched PHP/SDK qualification: the earlier complete + SQLite run still has its failed fresh-upload control. Retained receipts + now reconcile before writer admission and codec work. Actual held-writer + HTTP controls pass on SQLite, MySQL 8.4 and PostgreSQL 17 before and after + expiry, preserving conflicting-payload refusal and the original receipt's + bytes and expiry. The separate SQLite codec-and-writer counterfactual fails + the prior implementation and passes the correction. Genuine query/continuation + controls now pass on SQLite, MySQL 8.4 and PostgreSQL 17: live original queries + still block continuation, while expired retained snapshots do not. + A separate published Server 2.8.0 / PHP SDK 2.4.2 profile matches four actual + named-namespace completed-workflow controls on PHP and native MySQL 8.4: + timely 1.20/1.19, held poll reply and late synchronous handler. Original clocks + and histories survive, with zero late submissions and eight zero-exit Workers. + Its fifth active native original remains failed at unsupported termination + cleanup. The revised query-only observer delegates database retirement to + the caller. Its revised five-case profile passes all ten fresh SQLite originals + with the same four-second query and thirty-second observation budgets. Its + [original all-six execution](https://github.com/durable-workflow/server/pull/463#issuecomment-6185577908) + passes all thirteen jobs at `8e4cb2efc`: sixty actual PHP SDK query originals + and 123 native execution tests per configuration. The separate boundary + cleanup check fails and remains failed. A later six-case SQLite cohort passes all twelve + fresh originals, including a query on a genuine waiting named timer workflow. + That new case uses the same installed SDK, reference image and original budgets. + The first PHP SQLite observer raced the Worker's + acknowledgement and stays failed. The revised observer waits for that actual + acknowledgement inside its original budget. + Earlier worker-list and named-timer originals remain failed. Main + keeps `query_task_authority=false`; the unpublished candidate advertises it + for actual SDK qualification before promotion. Full namespace CRUD remains + open, including the absent native DELETE route. Worker listing and termination + remain outside this query slice. The narrow named-timer correction admits the + existing timer command and carries its persisted namespace through global + maintenance and backend task lookup. Both new baseline tests fail at the + former namespace refusal; both corrected cases pass on SQLite, MySQL 8.4 and + PostgreSQL 17, including cold two-node recovery, duplicate identity, foreign + completion refusal, atomic invalid batches and zero-delay firing. Three + existing default timer/selection controls also pass per backend. The initial + MySQL regression database hit its 768 MiB memory limit and remains failed; + its separate fresh 1.5 GiB database run passes with unchanged test deadlines. + These local controls do not establish full namespace or timer parity. See the + [exact installed checkpoint](https://github.com/durable-workflow/server/issues/433#issuecomment-6184509976). + The query integration also preserves #455's opt-in poll hints and #458's + completion uploads. Hint readiness now reads the same immutable UTC6 query + fields as execution, while the held query poll keeps its original registration + proof. This combined source needs its own qualification; component passes do + not qualify the merge. + +The next query qualification also includes the separately qualified PHP +`7e215d29` metadata source and normally installed Python/Rust consumers described +in the [local outcome](https://github.com/durable-workflow/server/issues/433#issuecomment-6185525047). +The preceding sixty-case result does not qualify these additions. New consumers +run in every existing database configuration with their original deadlines; +their runtime artifacts are shared without compiler caches. Cleanup is owned by +the existing unconditional workflow steps. + +Candidate `396a22e98` first failed during a pinned image pull before scenarios. +Its identical-source retry exposed a missing third namespace binding in +cooperative timer cancellation, producing protocol errors on network databases. +SQLite silently left the original timer task ready at delivery. The +[three-database counterfactual and correction](https://github.com/durable-workflow/server/pull/463#issuecomment-6185925195) +fail before and pass after `a78956cd2`, checking the same original task reaches +cancelled state while preserving cancellation history, deadlines and replay. +Fresh corrected SQLite passes 75 library tests with eighteen existing ignores +and all 125 execution tests. The separate older namespace test now retains its +unqualified-command refusal using a child command, since timers have positive +coverage. Hosted failures remain failed; neither correction qualifies a new +combined matrix or explains historical PHP storage failures. + +The current client observer records delivery before a Worker can consume the +response, so stopping immediately at that event does not prove expiry refusal. +The follow-up recipe waits for Python's actual public authority-loss diagnostic +or Rust's recorded expired authority observation. Proven original or monotonic +expiry can legally omit redundant status HTTP; Python's no-status acceptance +requires its actual expiry reason, exact claim identity/timing and affirmative +discovery. Rust exposes no public reason for an immediate local refusal, so such +a case stays unqualified rather than being called an SDK defect. Unexpired work +still requires observation. Observer failures now retain bounded method/path +diagnostics instead of an unexplained assertion. Five fresh installed Python +2.5.15 controls pass against the pinned PHP 2.8.0 SQLite API, with the original +four/thirty-second budgets; the held poll records an actual timeout refusal. +The earlier PostgreSQL observer error remains unexplained. Run `38692717327` +at unchanged `17382228` then passes four execution cells and all six health +cells, while MySQL 8.4 and PostgreSQL 17 fail in the installed-client phase +without a retained client error. Those originals remain failed. + +Prepared fixture `c184f28da` preserves bounded client errors ahead of completed +setup logs, refuses invalid calendar dates and compares UTC6 timing using +integer microseconds. Seven focused fixture/exporter controls pass. The old +observer accepts February 30; its calendar-corrected successor still accepts a +one-microsecond deadline change. The corresponding constructed HTTP controls +fail those parents and pass the corrected observer without rewriting bytes. +Five fresh Python 2.5.15 originals also pass against the hosted native binary +from `17382228` / tested merge `3c05f817`, using this revised fixture. The held +poll records `query_task_timed_out` with no callback; async cancellation and +late sync return record their original budget-expiry reasons with no result +submission. Timely 1.20 and 1.19 return exact typed values. All five histories +remain unchanged through their queries and all five Workers exit zero. This +does not qualify the no-status expiry branch, revised Rust consumer or a new +all-database combined tree. Earlier PHP component checks remain scoped to +their original fixture source. Full transport discovery stays unadvertised. Complete batch workflow semantics, memo/query reference ingress, upload allowances, automatic result policy, S3, cleanup/backup/restart and full database/consumer qualification remain open. Current PHP 2.8 query-authority and activity-result-inspection parity also -require separate native qualification. +require separate native qualification. PHP's #436 empty-declaration correction +is incorporated as qualified source; native empty/omitted/malformed declaration +and selected-build-cohort parity remains a separate open gate. Broader runtime/operational parity, safe sequential PHP database takeover, matched performance measurements and release/cutover preparation remain #325 requirements. No native release or deployment is qualified. @@ -905,8 +1021,38 @@ The [query-authority proposal](https://github.com/durable-workflow/server/issues and [native/shared acknowledgment](https://github.com/durable-workflow/server/issues/433#issuecomment-6138337171) agree on immutable original timing, six-digit UTC fields, inclusive authority expiry, non-renewing protocol-1.20 observation and committed-receipt ordering. -Native implementation and separate shared qualification remain pending; current -internal timing does not establish compliance with the proposed wire contract. +Native implementation is in [#463](https://github.com/durable-workflow/server/pull/463). +Candidate `76c6d35a` passes all thirteen shared jobs in run `38713356493`, +including six execution and six codec/deadline-health database cells. All six +execution artifacts independently match the tested native tree and binary, +with 29 passing comparisons and 20 successful profile statuses each. The +query profiles include normally installed PHP, Python and Rust SDKs, original +timing, delayed poll delivery and late handlers. Conditional prepared-local +and cancellation source qualification remains skipped. + +Combined candidate `ee219c0b` incorporates qualified PHP security source +`f7d1bfc7` through merge `500299d6`. Its exact whole tree now passes all thirteen +shared jobs in `38721415410` and every applicable ordinary check. Each of the +six independently audited execution archives has 29 passing comparisons and +20 zero exit records, with exact merge/native/binary identities. The +[qualified checkpoint](https://github.com/durable-workflow/server/pull/463#issuecomment-6191285509) +retains all six digests and earlier failed-original limits. + +The next integration includes qualified release-workflow-only main `28914d25` +through `c7e81c24`. Native, shared, runner and OpenAPI bytes remain unchanged; +that whole tree still requires its own qualification before promotion. The +published PHP image acceptance remains separate from these frozen reference +profiles. No current image adoption, complete query capability, performance, +migration or release qualification follows here. + +The later [installed-consumer observation](https://github.com/durable-workflow/server/issues/433#issuecomment-6185525047) +passes twenty Python 2.5.15 / Rust 3.5.5 originals across separate PHP 2.8.0 and +candidate native SQLite APIs. Timely 1.20/1.19 and expired poll/async/sync +delivery preserve original four-second query and thirty-second whole budgets, +typed values, history and claim identity without late submissions. The earlier +short poll-transport observer failure stays failed. Subsequent fixture/build +sharing edits and the combined source require fresh hosted qualification; +external loading, pressure and full capability qualification remain open. Earlier MariaDB/MySQL failure causes remain unestablished; the new lookup site does not retroactively establish them. diff --git a/rust/README.md b/rust/README.md index b2619e628..997d96092 100644 --- a/rust/README.md +++ b/rust/README.md @@ -260,19 +260,30 @@ worker as signals. Other missing paths return Starts, ordered history, task claims and outcomes are transactional. Expired or revoked owner/attempt fences cannot commit. Identical completion retries return the existing receipt without another outcome; conflicting retries are refused. -Query completion follows the PHP broker's terminal lease refusal on repetition. +The separate native query-authority slice retains content-bound completion and +failure decisions for the original owner and attempt. Matching retries return +the committed decision with `recorded=false`, including after the original +deadline; conflicting operations or payloads remain refused. This does not +change the activity or workflow receipt contract. Query poll-request receipt idempotency is explicitly advertised as false; repeat polling by the active owner retains its existing attempt and snapshot. Transient query entries use a native prefix in a separate `dw_query_cache` table, with -16 pending requests and 64 pending/results combined per default-namespace +16 pending requests and 64 pending/results combined per namespace in the development database. Arguments are capped at 64 KiB, each complete task and snapshot at one MiB, result blobs at 256 KiB, and completion bodies at 512 KiB. -The query deadline is 30 seconds, leases last at most ten seconds, and recent -results expire after 60 seconds. Admission/polling reclaim expired entries; -abandoned requests occupy a bounded slot until their deadline plus 60 seconds. -Results discard replay snapshots. These are development bounds, not qualified -customer limits. Full query failures, worker compatibility, busy routing and -request-id receipts still need reviewed fixtures. +Admission snapshots UTC timestamps with six fractional digits. Query timing +uses the PHP configuration aliases: the default result duration is 45 seconds, +the configured 60-second lease is capped by the original result deadline, and +the default retention is 180 seconds, with a minimum of the configured result +duration plus lease duration plus 60 seconds. Policy changes and polls preserve +those original snapshots; zero expires immediately. Missing legacy timing +gets a fixed refusal without a fresh execution budget. Results discard replay +snapshots. Protocol 1.20 status reads observe the original claim without +renewing it; 1.19 receives an explicit unavailable response. Discovery keeps +`query_task_authority=false` until separate current-PHP and installed-consumer +qualification. These are development bounds, not qualified customer limits. +Queue-lock pressure, full query failures, worker compatibility, busy routing, +request-id receipts and cross-runtime adoption still need reviewed fixtures. Poll IDs retain the original task/snapshot across process boundaries, with expired receipts reclaimed. Worker/control-plane history is paginated, capped at 1000 records and eight MiB of encoded payloads per page. Requests are capped diff --git a/rust/src/runtime/backend.rs b/rust/src/runtime/backend.rs index cfee23605..b91ca99c4 100644 --- a/rust/src/runtime/backend.rs +++ b/rust/src/runtime/backend.rs @@ -16,7 +16,7 @@ pub(super) trait Backend: Database { const WORKER_REGISTRATION_SQL: &'static str = super::store::WORKER_REGISTRATION_SQL; const TASK_CANDIDATES_SQL: &'static str = super::store::TASK_CANDIDATES_SQL; const POLL_WAKE_TASKS_SQL: &'static str = super::cross_kind_poll::TASKS_SQL; - const POLL_WAKE_QUERIES_SQL: &'static str = "SELECT \"key\" AS id FROM dw_query_cache WHERE \"key\" LIKE 'dw-native-query:%' AND expiration>$1 AND CAST(value AS jsonb)->>'namespace'=$2 AND CAST(value AS jsonb)->>'task_queue'=$3 AND CAST(CAST(value AS jsonb)->>'deadline' AS BIGINT)>$4 AND (CAST(value AS jsonb)->>'status'='pending' OR (CAST(value AS jsonb)->>'status'='leased' AND CAST(CAST(value AS jsonb)->>'lease_until' AS BIGINT)<=$5)) ORDER BY \"key\" LIMIT 65"; + const POLL_WAKE_QUERIES_SQL: &'static str = "SELECT \"key\" AS id FROM dw_query_cache WHERE \"key\" LIKE 'dw-native-query:%' AND expiration>$1 AND CAST(value AS jsonb)->>'namespace'=$2 AND CAST(value AS jsonb)->>'task_queue'=$3 AND CAST(value AS jsonb)->>'query_deadline_at'>$4 AND (CAST(value AS jsonb)->>'status'='pending' OR (CAST(value AS jsonb)->>'status'='leased' AND CAST(value AS jsonb)->>'lease_expires_at'<=$5)) ORDER BY \"key\" LIMIT 65"; const POLL_RECEIPT_CLEANUP_SQL: &'static str = super::store::POLL_RECEIPT_CLEANUP_SQL; const TIMER_TASK_SQL: &'static str; const SELECTION_RESOLUTION_SQL: &'static str = "SELECT id,payload FROM workflow_history_events WHERE workflow_run_id=$1 AND event_type='SelectionResolved' AND payload->>'selection_group_id'=$2 ORDER BY sequence LIMIT 1"; @@ -24,8 +24,8 @@ pub(super) trait Backend: Database { const REPAIR_COUNTER_MAX: i64 = i32::MAX as i64; const RANGE_REPAIR_TASK_SQL: &'static str = "SELECT * FROM workflow_tasks WHERE workflow_run_id=$1 AND namespace=$2 AND task_type='workflow' AND status='failed' AND payload->>'replay_blocked'='true' ORDER BY CASE WHEN available_at IS NULL THEN 1 ELSE 0 END,available_at,created_at,id LIMIT 1"; const ACTIVITY_OPEN_TASK_SQL: &'static str = "SELECT * FROM workflow_tasks WHERE workflow_run_id=$1 AND task_type='activity' AND status IN ('ready','leased') AND payload->>'activity_execution_id'=$2 ORDER BY id LIMIT 1"; - const QUERY_CACHE_SCAN: &'static str = "SELECT \"key\",value,expiration FROM dw_query_cache WHERE \"key\" LIKE 'dw-native-query:%' ORDER BY \"key\" LIMIT 65"; - const QUERY_CACHE_PENDING_FOR_RUN: &'static str = "SELECT COUNT(*) FROM dw_query_cache WHERE \"key\" LIKE 'dw-native-query:%' AND expiration>$2 AND value::jsonb->>'run_id'=$1 AND value::jsonb->>'status' IN ('pending','leased')"; + const QUERY_CACHE_SCAN: &'static str = "SELECT \"key\",value,expiration FROM dw_query_cache WHERE \"key\" LIKE 'dw-native-query:%' AND value::jsonb->>'namespace'=$1 ORDER BY \"key\" LIMIT 65"; + const QUERY_CACHE_PENDING_FOR_RUN: &'static str = "SELECT value::jsonb->>'query_admitted_at' AS query_admitted_at,value::jsonb->>'query_deadline_at' AS query_deadline_at,value::jsonb->>'receipt_expires_at' AS receipt_expires_at FROM dw_query_cache WHERE \"key\" LIKE 'dw-native-query:%' AND expiration>$2 AND value::jsonb->>'run_id'=$1 AND value::jsonb->>'status' IN ('pending','leased') ORDER BY \"key\" LIMIT 65"; const QUERY_CACHE_GET: &'static str = "SELECT value,expiration FROM dw_query_cache WHERE \"key\"=$1"; const QUERY_CACHE_INSERT: &'static str = @@ -86,7 +86,9 @@ fn sqlite_instant(value: String) -> Result> { } impl Backend for Sqlite { - const POLL_WAKE_QUERIES_SQL: &'static str = "SELECT \"key\" AS id FROM dw_query_cache WHERE \"key\" LIKE 'dw-native-query:%' AND expiration>$1 AND json_extract(value,'$.namespace')=$2 AND json_extract(value,'$.task_queue')=$3 AND json_extract(value,'$.deadline')>$4 AND (json_extract(value,'$.status')='pending' OR (json_extract(value,'$.status')='leased' AND json_extract(value,'$.lease_until')<=$5)) ORDER BY \"key\" LIMIT 65"; + const QUERY_CACHE_SCAN: &'static str = "SELECT \"key\",value,expiration FROM dw_query_cache WHERE \"key\" LIKE 'dw-native-query:%' AND json_extract(value,'$.namespace')=$1 ORDER BY \"key\" LIMIT 65"; + const QUERY_CACHE_PENDING_FOR_RUN: &'static str = "SELECT json_extract(value,'$.query_admitted_at') AS query_admitted_at,json_extract(value,'$.query_deadline_at') AS query_deadline_at,json_extract(value,'$.receipt_expires_at') AS receipt_expires_at FROM dw_query_cache WHERE \"key\" LIKE 'dw-native-query:%' AND expiration>$2 AND json_extract(value,'$.run_id')=$1 AND json_extract(value,'$.status') IN ('pending','leased') ORDER BY \"key\" LIMIT 65"; + const POLL_WAKE_QUERIES_SQL: &'static str = "SELECT \"key\" AS id FROM dw_query_cache WHERE \"key\" LIKE 'dw-native-query:%' AND expiration>$1 AND json_extract(value,'$.namespace')=$2 AND json_extract(value,'$.task_queue')=$3 AND json_extract(value,'$.query_deadline_at')>$4 AND (json_extract(value,'$.status')='pending' OR (json_extract(value,'$.status')='leased' AND json_extract(value,'$.lease_expires_at')<=$5)) ORDER BY \"key\" LIMIT 65"; const POLL_WAKE_TASKS_SQL: &'static str = r#" WITH utc_tasks AS ( SELECT t.id,t.namespace,t.queue,t.workflow_run_id,t.status,t.available_at, @@ -105,7 +107,6 @@ WHERE r.status IN ('pending','running','waiting') AND (t.available_at IS NULL OR t.available_order<=rtrim(rtrim($5,'0'),'.')) ORDER BY t.available_order,t.id LIMIT 100 "#; - const QUERY_CACHE_PENDING_FOR_RUN: &'static str = "SELECT COUNT(*) FROM dw_query_cache WHERE \"key\" LIKE 'dw-native-query:%' AND expiration>$2 AND json_extract(value,'$.run_id')=$1 AND json_extract(value,'$.status') IN ('pending','leased')"; const SELECTION_RESOLUTION_SQL: &'static str = "SELECT id,payload FROM workflow_history_events WHERE workflow_run_id=$1 AND event_type='SelectionResolved' AND json_extract(payload,'$.selection_group_id')=$2 ORDER BY sequence LIMIT 1"; const REPAIR_COUNTER_MAX: i64 = i64::MAX; const RANGE_REPAIR_TASK_SQL: &'static str = "SELECT * FROM workflow_tasks WHERE workflow_run_id=$1 AND namespace=$2 AND task_type='workflow' AND status='failed' AND json_extract(payload,'$.replay_blocked')=1 ORDER BY CASE WHEN available_at IS NULL THEN 1 ELSE 0 END,available_at,created_at,id LIMIT 1"; @@ -114,7 +115,7 @@ ORDER BY t.available_order,t.id LIMIT 100 type EncodedTime = String; type EncodedOptionalTime = Option; type EncodedDocument = String; - const TIMER_TASK_SQL: &'static str = "SELECT id,payload FROM workflow_tasks WHERE workflow_run_id=$1 AND namespace='default' AND task_type='timer' AND status='ready' AND json_extract(payload,'$.timer_id')=$2"; + const TIMER_TASK_SQL: &'static str = "SELECT id,payload FROM workflow_tasks WHERE workflow_run_id=$1 AND task_type='timer' AND status='ready' AND json_extract(payload,'$.timer_id')=$2 AND namespace=$3"; const HISTORY_PAGE: &'static str = "SELECT * FROM (SELECT *,SUM(length(CAST(payload AS BLOB))) OVER (ORDER BY sequence) AS page_bytes FROM workflow_history_events WHERE workflow_run_id=$1 AND sequence>$2 ORDER BY sequence LIMIT $3) AS page WHERE page_bytes<=8388608 ORDER BY sequence"; // Normalize only the two supported UTC storage shapes. Removing trailing // fractional zeros preserves exact decimal ordering, including earlier @@ -233,7 +234,7 @@ impl Backend for Postgres { type EncodedTime = NaiveDateTime; type EncodedOptionalTime = Option; type EncodedDocument = PostgresDocument; - const TIMER_TASK_SQL: &'static str = "SELECT id,payload FROM workflow_tasks WHERE workflow_run_id=$1 AND namespace='default' AND task_type='timer' AND status='ready' AND payload->>'timer_id'=$2"; + const TIMER_TASK_SQL: &'static str = "SELECT id,payload FROM workflow_tasks WHERE workflow_run_id=$1 AND task_type='timer' AND status='ready' AND payload->>'timer_id'=$2 AND namespace=$3"; const HISTORY_PAGE: &'static str = "SELECT * FROM (SELECT *,SUM(octet_length(payload::text)) OVER (ORDER BY sequence) AS page_bytes FROM workflow_history_events WHERE workflow_run_id=$1 AND sequence>$2 ORDER BY sequence LIMIT $3) AS page WHERE page_bytes<=8388608 ORDER BY sequence"; fn bind_time(time: DateTime) -> NaiveDateTime { time.naive_utc() @@ -308,7 +309,8 @@ impl Backend for Postgres { } impl Backend for MySql { - const POLL_WAKE_QUERIES_SQL: &'static str = "SELECT `key` AS id FROM dw_query_cache WHERE `key` LIKE 'dw-native-query:%' AND expiration>? AND CAST(JSON_UNQUOTE(JSON_EXTRACT(value,'$.namespace')) AS BINARY)=CAST(? AS BINARY) AND CAST(JSON_UNQUOTE(JSON_EXTRACT(value,'$.task_queue')) AS BINARY)=CAST(? AS BINARY) AND CAST(JSON_UNQUOTE(JSON_EXTRACT(value,'$.deadline')) AS SIGNED)>? AND (JSON_UNQUOTE(JSON_EXTRACT(value,'$.status'))='pending' OR (JSON_UNQUOTE(JSON_EXTRACT(value,'$.status'))='leased' AND CAST(JSON_UNQUOTE(JSON_EXTRACT(value,'$.lease_until')) AS SIGNED)<=?)) ORDER BY `key` LIMIT 65"; + const QUERY_CACHE_SCAN: &'static str = "SELECT `key`,value,expiration FROM dw_query_cache WHERE `key` LIKE 'dw-native-query:%' AND BINARY JSON_UNQUOTE(JSON_EXTRACT(value,'$.namespace'))=BINARY ? ORDER BY `key` LIMIT 65"; + const POLL_WAKE_QUERIES_SQL: &'static str = "SELECT `key` AS id FROM dw_query_cache WHERE `key` LIKE 'dw-native-query:%' AND expiration>? AND CAST(JSON_UNQUOTE(JSON_EXTRACT(value,'$.namespace')) AS BINARY)=CAST(? AS BINARY) AND CAST(JSON_UNQUOTE(JSON_EXTRACT(value,'$.task_queue')) AS BINARY)=CAST(? AS BINARY) AND CAST(JSON_UNQUOTE(JSON_EXTRACT(value,'$.query_deadline_at')) AS BINARY)>CAST(? AS BINARY) AND (JSON_UNQUOTE(JSON_EXTRACT(value,'$.status'))='pending' OR (JSON_UNQUOTE(JSON_EXTRACT(value,'$.status'))='leased' AND CAST(JSON_UNQUOTE(JSON_EXTRACT(value,'$.lease_expires_at')) AS BINARY)<=CAST(? AS BINARY))) ORDER BY `key` LIMIT 65"; const EXTERNAL_PAYLOAD_BYTES: &'static str = "SELECT CAST(COALESCE(SUM(size_bytes),0) AS SIGNED) FROM runtime_external_payloads WHERE namespace=$1"; const MEMO_UPSERT: &'static str = "INSERT INTO workflow_memos(workflow_run_id,workflow_instance_id,`key`,value,upserted_at_sequence,inherited_from_parent,created_at,updated_at,portable_value,portable_value_sequence) VALUES ($1,$2,$3,$4,$5,false,$6,$7,$8,$9) ON DUPLICATE KEY UPDATE value=VALUES(value),upserted_at_sequence=VALUES(upserted_at_sequence),inherited_from_parent=false,updated_at=VALUES(updated_at),portable_value=VALUES(portable_value),portable_value_sequence=VALUES(portable_value_sequence)"; const MEMO_DELETE: &'static str = @@ -323,8 +325,7 @@ impl Backend for MySql { type EncodedTime = DateTime; type EncodedOptionalTime = Option>; type EncodedDocument = Json; - const QUERY_CACHE_SCAN: &'static str = "SELECT `key`,value,expiration FROM dw_query_cache WHERE `key` LIKE 'dw-native-query:%' ORDER BY `key` LIMIT 65"; - const QUERY_CACHE_PENDING_FOR_RUN: &'static str = "SELECT COUNT(*) FROM dw_query_cache WHERE `key` LIKE 'dw-native-query:%' AND JSON_UNQUOTE(JSON_EXTRACT(value,'$.run_id'))=? AND expiration>? AND JSON_UNQUOTE(JSON_EXTRACT(value,'$.status')) IN ('pending','leased')"; + const QUERY_CACHE_PENDING_FOR_RUN: &'static str = "SELECT JSON_UNQUOTE(JSON_EXTRACT(value,'$.query_admitted_at')) AS query_admitted_at,JSON_UNQUOTE(JSON_EXTRACT(value,'$.query_deadline_at')) AS query_deadline_at,JSON_UNQUOTE(JSON_EXTRACT(value,'$.receipt_expires_at')) AS receipt_expires_at FROM dw_query_cache WHERE `key` LIKE 'dw-native-query:%' AND JSON_UNQUOTE(JSON_EXTRACT(value,'$.run_id'))=? AND expiration>? AND JSON_UNQUOTE(JSON_EXTRACT(value,'$.status')) IN ('pending','leased') ORDER BY `key` LIMIT 65"; const QUERY_CACHE_GET: &'static str = "SELECT value,expiration FROM dw_query_cache WHERE `key`=$1"; const QUERY_CACHE_INSERT: &'static str = @@ -332,7 +333,7 @@ impl Backend for MySql { const QUERY_CACHE_UPDATE: &'static str = "UPDATE dw_query_cache SET value=$1,expiration=$2 WHERE `key`=$3"; const QUERY_CACHE_DELETE: &'static str = "DELETE FROM dw_query_cache WHERE `key`=$1"; - const TIMER_TASK_SQL: &'static str = "SELECT id,payload FROM workflow_tasks WHERE workflow_run_id=? AND namespace='default' AND task_type='timer' AND status='ready' AND JSON_UNQUOTE(JSON_EXTRACT(payload,'$.timer_id'))=?"; + const TIMER_TASK_SQL: &'static str = "SELECT id,payload FROM workflow_tasks WHERE workflow_run_id=? AND task_type='timer' AND status='ready' AND JSON_UNQUOTE(JSON_EXTRACT(payload,'$.timer_id'))=? AND BINARY namespace=BINARY ?"; // First use the preserved run/sequence index to materialize only IDs and // byte counts for this bounded page. Sorting JSON payloads in the window // exhausts MySQL's default sort buffer before the size rejection can run. @@ -348,6 +349,7 @@ impl Backend for MySql { || sql == Self::ACTIVITY_OPEN_TASK_SQL || sql == Self::POLL_WAKE_QUERIES_SQL || sql == Self::QUERY_CACHE_PENDING_FOR_RUN + || sql == Self::QUERY_CACHE_SCAN { return Cow::Borrowed(sql); } diff --git a/rust/src/runtime/cancellation.rs b/rust/src/runtime/cancellation.rs index c24128fc8..db75a159c 100644 --- a/rust/src/runtime/cancellation.rs +++ b/rust/src/runtime/cancellation.rs @@ -197,7 +197,7 @@ where let updates = Self::scalar("SELECT COUNT(*) FROM workflow_updates WHERE workflow_run_id=$1 AND status='accepted'") .bind(&run_id).fetch_one(&mut *tx).await?; let pending_queries = - Self::query_entries(&mut tx) + self.query_entries(&mut tx) .await? .into_iter() .any(|(_, task)| { diff --git a/rust/src/runtime/continuations.rs b/rust/src/runtime/continuations.rs index 72054dd24..129179aa4 100644 --- a/rust/src/runtime/continuations.rs +++ b/rust/src/runtime/continuations.rs @@ -158,21 +158,35 @@ where "continue_as_new_transfer_unqualified", )); } - // A bounded cache prefix cannot prove that this run has no pending - // query. Check the complete native-owned scope without loading or - // parsing its history snapshots on the executor. - if Self::scalar(DB::QUERY_CACHE_PENDING_FOR_RUN) + // Select this run before applying the bound. Receipt retention does + // not extend original query execution, and unrelated cache prefixes + // cannot hide a live claim. Read timing only, never history snapshots. + let queries = Self::query(DB::QUERY_CACHE_PENDING_FOR_RUN) .bind(&old) .bind(now().timestamp()) - .fetch_one(&mut **tx) - .await? - != 0 - { + .fetch_all(&mut **tx) + .await?; + if queries.len() > super::queries::MAX_ENTRIES { return Err(refuse( StatusCode::CONFLICT, "continue_as_new_transfer_unqualified", )); } + let observed = super::query_authority::now(); + for query in queries { + let timing = json!({ + "query_admitted_at":DB::optional_string(&query,"query_admitted_at")?, + "query_deadline_at":DB::optional_string(&query,"query_deadline_at")?, + "receipt_expires_at":DB::optional_string(&query,"receipt_expires_at")?, + }); + if super::query_authority::deadline(&timing).is_none_or(|deadline| observed < deadline) + { + return Err(refuse( + StatusCode::CONFLICT, + "continue_as_new_transfer_unqualified", + )); + } + } let rows = Self::query(DB::MEMO_READ) .bind(&old) .fetch_all(&mut **tx) diff --git a/rust/src/runtime/cooperative.rs b/rust/src/runtime/cooperative.rs index 68045f294..6b9bb113a 100644 --- a/rust/src/runtime/cooperative.rs +++ b/rust/src/runtime/cooperative.rs @@ -306,7 +306,7 @@ where "cancellation_delivery_not_eligible", )); } - Self::cancel_root_timer(&mut tx, &run_id, request_id, &timer, Some(task_id), now()) + Self::cancel_root_timer(&mut tx, &run, request_id, &timer, Some(task_id), now()) .await?; } let at = now(); @@ -338,20 +338,23 @@ where async fn cancel_root_timer( tx: &mut Transaction<'_, DB>, - run_id: &str, + run: &DB::Row, request_id: &str, timer: &DB::Row, task_id: Option<&str>, at: DateTime, ) -> Result<()> { + let run_id = DB::string(run, "id")?; + let namespace = DB::string(run, "namespace")?; let timer_id = DB::string(timer, "id")?; Self::query("UPDATE workflow_run_timers SET status='cancelled' WHERE id=$1") .bind(&timer_id) .execute(&mut **tx) .await?; for timer_task in Self::query(DB::TIMER_TASK_SQL) - .bind(run_id) + .bind(&run_id) .bind(&timer_id) + .bind(&namespace) .fetch_all(&mut **tx) .await? { @@ -362,7 +365,7 @@ where .execute(&mut **tx) .await?; } - Self::append(tx,run_id,"TimerCancelled",json!({"timer_id":timer_id,"sequence":DB::number(timer,"sequence")?, + Self::append(tx,&run_id,"TimerCancelled",json!({"timer_id":timer_id,"sequence":DB::number(timer,"sequence")?, "delay_seconds":DB::number(timer,"delay_seconds")?,"fire_at":DB::instant(timer,"fire_at")?,"cancelled_at":at}),task_id,Some(request_id)).await } @@ -427,7 +430,7 @@ where .bind(&run_id).execute(&mut **tx).await?; Self::cancel_open_activities(tx, &run_id, &request_id, activities, at).await?; for timer in timers { - Self::cancel_root_timer(tx, &run_id, &request_id, &timer, task_id, at).await?; + Self::cancel_root_timer(tx, run, &request_id, &timer, task_id, at).await?; } let failure_id = id(); let message = format!("Workflow cancelled: {reason}"); diff --git a/rust/src/runtime/cross_kind_poll.rs b/rust/src/runtime/cross_kind_poll.rs index f780b5c5b..bb8bd7155 100644 --- a/rust/src/runtime/cross_kind_poll.rs +++ b/rust/src/runtime/cross_kind_poll.rs @@ -69,13 +69,16 @@ where } } if kind != "query" { + // Query timing uses the immutable UTC6 authority fields. This + // readiness hint still grants no claim or execution authority. + let query_now = super::query_authority::wire(at); ready.extend( Self::query(DB::POLL_WAKE_QUERIES_SQL) .bind(at.timestamp()) .bind(self.namespace.as_ref()) .bind(text(body, "task_queue")?) - .bind(at.timestamp()) - .bind(at.timestamp()) + .bind(query_now.clone()) + .bind(query_now) .fetch_all(&self.pool) .await? .iter() diff --git a/rust/src/runtime/execution.rs b/rust/src/runtime/execution.rs index d9ab2be00..710a65ea2 100644 --- a/rust/src/runtime/execution.rs +++ b/rust/src/runtime/execution.rs @@ -21,6 +21,10 @@ enum Storage { MySql(Store), } +#[cfg(test)] +#[path = "query_codec_tests.rs"] +mod query_codec_tests; + #[derive(Clone)] pub struct Runtime { storage: Arc, @@ -29,6 +33,7 @@ pub struct Runtime { scheduler: Arc, signal_codec: Arc, pub(super) external: Arc, + queries: Arc, } struct TimerScheduler { @@ -114,6 +119,10 @@ impl Runtime { self.external = Arc::new(config); self } + pub fn with_query_task_config(mut self, config: super::QueryTaskConfig) -> Self { + self.queries = Arc::new(config); + self + } /// Immutable configured-token cohort. Database transitions and scheduler /// stay shared; configuring credentials does not mutate process state. pub fn with_role_tokens( @@ -140,34 +149,38 @@ impl Runtime { pub async fn open(database: &str, token: String) -> Result { Self::validate_token(&token)?; let external = super::ExternalPayloadConfig::from_env()?; + let queries = super::QueryTaskConfig::from_env()?; let limit = super::memo_limits::configured_limit()?; let mut store = Store::new(sqlite::open(database).await?); store.memo_size_limit = limit; - Self::from_storage(Storage::Sqlite(store), token, external).await + Self::from_storage(Storage::Sqlite(store), token, external, queries).await } pub async fn open_postgres(options: PgConnectOptions, token: String) -> Result { Self::validate_token(&token)?; let external = super::ExternalPayloadConfig::from_env()?; + let queries = super::QueryTaskConfig::from_env()?; let limit = super::memo_limits::configured_limit()?; let mut store = Store::new(PostgresStorage::open(options).await?.into_pool()); store.memo_size_limit = limit; - Self::from_storage(Storage::Postgres(store), token, external).await + Self::from_storage(Storage::Postgres(store), token, external, queries).await } pub async fn open_mysql(options: MySqlConnectOptions, token: String) -> Result { Self::validate_token(&token)?; let external = super::ExternalPayloadConfig::from_env()?; + let queries = super::QueryTaskConfig::from_env()?; let limit = super::memo_limits::configured_limit()?; let mut store = Store::new(MySqlStorage::open(options).await?.into_pool()); store.memo_size_limit = limit; - Self::from_storage(Storage::MySql(store), token, external).await + Self::from_storage(Storage::MySql(store), token, external, queries).await } async fn from_storage( storage: Storage, token: String, external: super::ExternalPayloadConfig, + queries: super::QueryTaskConfig, ) -> Result { let runtime = Self { storage: Arc::new(storage), @@ -176,6 +189,7 @@ impl Runtime { scheduler: Arc::new(TimerScheduler::new()), signal_codec: Arc::new(Semaphore::new(1)), external: Arc::new(external), + queries: Arc::new(queries), }; runtime.verify_ready().await?; if let Err(error) = runtime.initialize_namespace_registry().await { @@ -243,6 +257,7 @@ impl Runtime { scheduler: self.scheduler.clone(), signal_codec: self.signal_codec.clone(), external: self.external.clone(), + queries: self.queries.clone(), } } pub(super) fn namespace(&self) -> &str { @@ -529,23 +544,58 @@ impl Runtime { match &*self.storage { Storage::Sqlite(store) => { store - .query_workflow(workflow_id, run_id, name, arguments, values) + .query_workflow(workflow_id, run_id, name, arguments, values, &self.queries) .await } Storage::Postgres(store) => { store - .query_workflow(workflow_id, run_id, name, arguments, values) + .query_workflow(workflow_id, run_id, name, arguments, values, &self.queries) .await } Storage::MySql(store) => { store - .query_workflow(workflow_id, run_id, name, arguments, values) + .query_workflow(workflow_id, run_id, name, arguments, values, &self.queries) .await } } } - delegate!(poll_query(body: Value) -> Result); + pub(crate) async fn poll_query(&self, body: Value) -> Result { + match &*self.storage { + Storage::Sqlite(store) => store.poll_query(body, &self.queries).await, + Storage::Postgres(store) => store.poll_query(body, &self.queries).await, + Storage::MySql(store) => store.poll_query(body, &self.queries).await, + } + } + pub(crate) async fn query_status(&self, id: &str, owner: &str, attempt: i64) -> Result { + match &*self.storage { + Storage::Sqlite(store) => store.query_status(id, owner, attempt, &self.queries).await, + Storage::Postgres(store) => store.query_status(id, owner, attempt, &self.queries).await, + Storage::MySql(store) => store.query_status(id, owner, attempt, &self.queries).await, + } + } pub(crate) async fn complete_query(&self, task_id: &str, body: Value) -> Result { + // Check retained decisions and live authority before codec work, then + // repeat the same fences atomically after decoding. + let receipt = match &*self.storage { + Storage::Sqlite(store) => { + store + .guard_query_outcome(task_id, &body, false, &self.queries) + .await + } + Storage::Postgres(store) => { + store + .guard_query_outcome(task_id, &body, false, &self.queries) + .await + } + Storage::MySql(store) => { + store + .guard_query_outcome(task_id, &body, false, &self.queries) + .await + } + }?; + if let Some(receipt) = receipt { + return Ok(receipt); + } let result = super::envelope(&body, "result_envelope")?; if result.len() > super::queries::RESULT_BYTES { return Err(refuse( @@ -562,7 +612,30 @@ impl Runtime { .await?; self.finish_query(task_id, body, false).await } - delegate!(finish_query(task_id: &str, body: Value, failed: bool) -> Result); + pub(crate) async fn finish_query( + &self, + task_id: &str, + body: Value, + failed: bool, + ) -> Result { + match &*self.storage { + Storage::Sqlite(store) => { + store + .finish_query(task_id, body, failed, &self.queries) + .await + } + Storage::Postgres(store) => { + store + .finish_query(task_id, body, failed, &self.queries) + .await + } + Storage::MySql(store) => { + store + .finish_query(task_id, body, failed, &self.queries) + .await + } + } + } delegate!( history( workflow_id: &str, diff --git a/rust/src/runtime/http.rs b/rust/src/runtime/http.rs index 6df259fbd..4ee23c22e 100644 --- a/rust/src/runtime/http.rs +++ b/rust/src/runtime/http.rs @@ -168,6 +168,10 @@ pub fn router(runtime: Runtime) -> Router { post(complete_activity), ) .route("/api/worker/query-tasks/poll", post(poll_query)) + .route( + "/api/worker/query-tasks/{task_id}/status", + get(query_status), + ) .route( "/api/worker/query-tasks/{task_id}/complete", post(complete_query), @@ -393,6 +397,12 @@ fn named_namespace_operation(method: &str, path: &str) -> bool { ) { return method == "POST"; } + if matches!( + parts.as_slice(), + [_, "query", _] | [_, "runs", _, "query", _] + ) { + return method == "POST"; + } return matches!( parts.as_slice(), [_] | [_, "cancel"] | [_, "runs", _] | [_, "runs", _, "history" | "cancel"] @@ -407,6 +417,7 @@ fn named_namespace_operation(method: &str, path: &str) -> bool { | ["registrations", _, "deregister"] | ["workflow-tasks" | "activity-tasks", "poll"] | ["query-tasks", "poll"] + | ["query-tasks", _, "complete" | "fail" | "status"] | ["workflow-tasks", _, "complete" | "history" | "heartbeat"] | [ "activity-tasks", @@ -1080,6 +1091,42 @@ async fn complete_query( ) -> Result> { runtime.complete_query(&task_id, body).await.map(Json) } +#[derive(Deserialize)] +struct QueryStatusRequest { + lease_owner: String, + query_task_attempt: i64, +} +async fn query_status( + Extension(runtime): Extension, + Path(task_id): Path, + request: Request, +) -> Result> { + let minor = request + .headers() + .get("x-durable-workflow-protocol-version") + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .and_then(|version| version.split_once('.')) + .and_then(|(_, minor)| minor.parse::().ok()); + if minor.is_none_or(|minor| minor < 20) { + return Err(super::RuntimeError::Protocol { + status: StatusCode::UNPROCESSABLE_ENTITY, + response: json!({"reason":"query_task_status_unavailable","error":"Query task observation requires worker protocol 1.20.","minimum_protocol_version":"1.20"}), + }); + } + let Query(claim) = Query::::try_from_uri(request.uri()) + .map_err(|_| refuse(StatusCode::UNPROCESSABLE_ENTITY, "invalid_query_status"))?; + if claim.lease_owner.is_empty() || claim.query_task_attempt < 1 { + return Err(refuse( + StatusCode::UNPROCESSABLE_ENTITY, + "invalid_query_status", + )); + } + runtime + .query_status(&task_id, &claim.lease_owner, claim.query_task_attempt) + .await + .map(Json) +} async fn fail_query( Extension(runtime): Extension, Path(task_id): Path, diff --git a/rust/src/runtime/mod.rs b/rust/src/runtime/mod.rs index 8539db8e5..0065927a7 100644 --- a/rust/src/runtime/mod.rs +++ b/rust/src/runtime/mod.rs @@ -27,6 +27,7 @@ mod namespaces; pub mod postgres; mod previews; mod queries; +mod query_authority; mod schedule_execution; mod schedule_http; mod schedule_storage; @@ -45,6 +46,7 @@ mod worker_incarnations; pub use execution::Runtime; pub use external_config::ExternalPayloadConfig; pub use http::router; +pub use query_authority::QueryTaskConfig; use axum::{ Json, diff --git a/rust/src/runtime/queries.rs b/rust/src/runtime/queries.rs index 34040bc59..8143895ef 100644 --- a/rust/src/runtime/queries.rs +++ b/rust/src/runtime/queries.rs @@ -3,7 +3,9 @@ use super::{ Result, backend::Backend, - envelope, refuse, + envelope, + query_authority::{self as authority, QueryTaskConfig}, + refuse, store::{Store, validate_arguments}, text, wire, }; @@ -15,14 +17,21 @@ use tokio::sync::Semaphore; const PREFIX: &str = "dw-native-query:"; const MAX_PENDING: usize = 16; -const MAX_ENTRIES: usize = 64; -const WAIT_SECONDS: i64 = 30; -const LEASE_SECONDS: i64 = 10; -const RECEIPT_SECONDS: i64 = 60; +pub(super) const MAX_ENTRIES: usize = 64; const SNAPSHOT_BYTES: usize = 1024 * 1024; pub(super) const ARGUMENT_BYTES: usize = 64 * 1024; pub(super) const RESULT_BYTES: usize = 256 * 1024; +enum QueryClaim { + Task(Value), + Empty, + StaleWorker, +} + +fn poll_result(task: Option, status: &str) -> Value { + json!({"task":task,"poll_status":status,"server_capabilities":super::store::capabilities()}) +} + fn now() -> i64 { chrono::Utc::now().timestamp() } @@ -94,9 +103,11 @@ where usize: ColumnIndex, { pub(super) async fn query_entries( + &self, tx: &mut Transaction<'_, DB>, ) -> Result> { let rows = Self::query(DB::QUERY_CACHE_SCAN) + .bind(self.namespace.to_string()) .fetch_all(&mut **tx) .await?; let mut entries = vec![]; @@ -109,7 +120,11 @@ where .await?; continue; } - entries.push((key, serde_json::from_str(&DB::string(&row, "value")?)?)); + let mut task: Value = serde_json::from_str(&DB::string(&row, "value")?)?; + if authority::expire(&mut task, authority::now())? { + Self::put_query(tx, &key, &task, authority::expiration(&task)?).await?; + } + entries.push((key, task)); } if entries.len() > MAX_ENTRIES { return Err(refuse( @@ -142,6 +157,7 @@ where name: &str, arguments: String, values: Vec, + config: &QueryTaskConfig, ) -> Result<(StatusCode, Value)> { if name.is_empty() || name.len() > 255 { return Err(refuse( @@ -150,9 +166,15 @@ where )); } let mut tx = self.begin().await?; - let run = Self::query("SELECT r.* FROM workflow_runs r JOIN workflow_instances i ON i.id=r.workflow_instance_id WHERE i.id=$1 AND r.id=COALESCE($2,i.current_run_id) AND r.namespace='default'") - .bind(workflow_id).bind(selected_run).fetch_optional(&mut *tx).await? + let run = Self::query("SELECT r.* FROM workflow_runs r JOIN workflow_instances i ON i.id=r.workflow_instance_id WHERE i.id=$1 AND r.id=COALESCE($2,i.current_run_id) AND r.namespace=$3") + .bind(workflow_id).bind(selected_run).bind(self.namespace.as_ref()).fetch_optional(&mut *tx).await? .ok_or_else(|| refuse(StatusCode::NOT_FOUND, "instance_not_found"))?; + if DB::string(&run, "namespace")? != self.namespace.as_ref() + || DB::string(&run, "workflow_instance_id")? != workflow_id + || selected_run.is_some_and(|id| DB::string(&run, "id").ok().as_deref() != Some(id)) + { + return Err(refuse(StatusCode::NOT_FOUND, "instance_not_found")); + } let run_id = DB::string(&run, "id")?; let status = DB::string(&run, "status")?; if !matches!( @@ -186,7 +208,7 @@ where "unsupported_query_contract", "invalid_query_arguments", )?; - let entries = Self::query_entries(&mut tx).await?; + let entries = self.query_entries(&mut tx).await?; if entries.len() >= MAX_ENTRIES || entries .iter() @@ -208,14 +230,18 @@ where } let task_id = ulid::Ulid::new().to_string(); let key = key(&task_id)?; - let deadline = now() + WAIT_SECONDS; - let task = json!({"query_task_id":task_id,"query_task_attempt":0,"status":"pending", - "namespace":"default","workflow_id":workflow_id,"run_id":run_id, + let admitted = authority::now(); + let (deadline, retained_until) = config.timing(admitted)?; + let mut task = json!({"query_task_id":task_id,"query_task_attempt":0,"status":"pending", + "namespace":self.namespace.as_ref(),"workflow_id":workflow_id,"run_id":run_id, "workflow_type":DB::string(&run,"workflow_type")?,"workflow_class":DB::string(&run,"workflow_class")?, "task_queue":DB::string(&run,"queue")?,"payload_codec":"avro","query_name":name, "workflow_arguments":wire(&DB::string(&run,"arguments")?),"query_arguments":wire(&arguments), "run_status":status,"history_events":history,"last_history_sequence":DB::number(&run,"last_history_sequence")?, - "history_cutoff_sequence":DB::number(&run,"last_history_sequence")?,"deadline":deadline}); + "history_cutoff_sequence":DB::number(&run,"last_history_sequence")?, + "query_admitted_at":authority::wire(admitted),"query_deadline_at":authority::wire(deadline), + "receipt_expires_at":authority::wire(retained_until)}); + authority::expire(&mut task, authority::now())?; if serde_json::to_vec(&task)?.len() > SNAPSHOT_BYTES { return Err(refuse( StatusCode::PAYLOAD_TOO_LARGE, @@ -225,21 +251,32 @@ where Self::query(DB::QUERY_CACHE_INSERT) .bind(&key) .bind(task.to_string()) - .bind(deadline + RECEIPT_SECONDS) + .bind(authority::expiration(&task)?) .execute(&mut *tx) .await?; tx.commit().await?; self.wake.notify_waiters(); // Never hold the transition lock or a pooled connection across the wait. // Periodic probes observe results committed by another server process. + let timed_out = |claimed| super::RuntimeError::Protocol { + status: StatusCode::GATEWAY_TIMEOUT, + response: json!({"workflow_id":workflow_id,"run_id":run_id,"query_name":name, + "reason":if claimed { "query_worker_execution_timeout" } else { "query_task_not_claimed" }, + "message":"Timed out waiting for the original query result."}), + }; + let mut claimed = false; loop { let wake = self.wake.notified(); - let row = Self::query(DB::QUERY_CACHE_GET) + let remaining = (deadline - authority::now()).to_std().unwrap_or_default(); + let read = Self::query(DB::QUERY_CACHE_GET) .bind(&key) - .fetch_optional(&self.pool) - .await? + .fetch_optional(&self.pool); + let row = tokio::time::timeout(remaining, read) + .await + .map_err(|_| timed_out(claimed))?? .ok_or_else(|| refuse(StatusCode::CONFLICT, "query_task_timed_out"))?; let task: Value = serde_json::from_str(&DB::string(&row, "value")?)?; + claimed = task["query_task_attempt"].as_i64().unwrap_or(0) > 0; match task["status"].as_str() { Some("completed") => { return Ok(( @@ -260,7 +297,17 @@ where body["query_name"] = json!(name); return Ok((status, body)); } - _ if now() >= deadline => { + Some("timed_out") => { + return Err(timed_out(claimed)); + } + _ if authority::deadline(&task) + .is_some_and(|deadline| authority::now() >= deadline) => + { + // The stored deadline already fences every new outcome. + // Cleanup must not delay the caller behind a busy writer. + return Err(timed_out(claimed)); + } + _ if authority::deadline(&task).is_none() => { let mut tx = self.begin().await?; // Recheck under the same lock as completion: a committed // result wins over a racing timeout. @@ -270,13 +317,13 @@ where .await?; let mut task: Value = serde_json::from_str(&DB::string(&row, "value")?)?; if matches!(task["status"].as_str(), Some("completed" | "failed")) { + tx.rollback().await?; continue; } - task["status"] = json!("timed_out"); - task.as_object_mut().unwrap().remove("history_events"); - Self::put_query(&mut tx, &key, &task, now() + RECEIPT_SECONDS).await?; + authority::expire(&mut task, authority::now())?; + Self::put_query(&mut tx, &key, &task, authority::expiration(&task)?).await?; tx.commit().await?; - return Err(refuse(StatusCode::CONFLICT, "query_task_timed_out")); + continue; } _ => {} } @@ -284,15 +331,23 @@ where } } - pub(super) async fn poll_query(&self, body: Value) -> Result { - text(&body, "worker_id")?; + pub(super) async fn poll_query(&self, body: Value, config: &QueryTaskConfig) -> Result { + let owner = text(&body, "worker_id")?; text(&body, "task_queue")?; let timeout = body .get("timeout_seconds") .map_or(Some(5), Value::as_u64) .filter(|v| *v <= 60) .ok_or_else(|| refuse(StatusCode::UNPROCESSABLE_ENTITY, "invalid_poll_timeout"))?; + let timeout = timeout.min(config.poll_timeout_seconds.max(1) as u64); let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout); + // One request belongs to the registration observed before its wait. + // A later same-ID registration cannot silently upgrade this poll. + let Some(proof) = + Self::query_worker_proof(&self.pool, self.namespace.as_ref(), owner, config).await? + else { + return Ok(poll_result(None, "stale_worker_registration")); + }; let mut cross_kind = if timeout > 0 { self.poll_wake_snapshot(&body, "query").await? } else { @@ -300,15 +355,15 @@ where }; loop { let wake = self.wake.notified(); - if let Some(task) = self.claim_query(&body).await? { - return Ok( - json!({"task":task,"poll_status":"task","server_capabilities":super::store::capabilities()}), - ); + match self.claim_query(&body, config, &proof).await? { + QueryClaim::Task(task) => return Ok(poll_result(Some(task), "task")), + QueryClaim::StaleWorker => { + return Ok(poll_result(None, "stale_worker_registration")); + } + QueryClaim::Empty => {} } if tokio::time::Instant::now() >= deadline { - return Ok( - json!({"task":null,"poll_status":"timeout","server_capabilities":super::store::capabilities()}), - ); + return Ok(poll_result(None, "timeout")); } if self .poll_queue_changed(&body, "query", &mut cross_kind) @@ -323,13 +378,78 @@ where } } - async fn claim_query(&self, body: &Value) -> Result> { + async fn query_worker_proof<'e, E>( + executor: E, + namespace: &str, + owner: &str, + config: &QueryTaskConfig, + ) -> Result> + where + E: Executor<'e, Database = DB>, + { + let rows = Self::query("SELECT w.worker_id,w.namespace,w.last_heartbeat_at,i.token,i.worker_id AS incarnation_worker_id,i.namespace AS incarnation_namespace FROM workflow_worker_registrations w JOIN dw_worker_registration_incarnations i ON i.worker_id=w.worker_id AND i.namespace=w.namespace AND i.status='active' WHERE w.namespace=$1 AND w.worker_id=$2 AND w.status='active' LIMIT 2") + .bind(namespace.to_owned()).bind(owner.to_owned()).fetch_all(executor).await?; + if rows.len() != 1 { + return Ok(None); + } + let row = &rows[0]; + if DB::string(row, "worker_id")? != owner + || DB::string(row, "namespace")? != namespace + || DB::string(row, "incarnation_worker_id")? != owner + || DB::string(row, "incarnation_namespace")? != namespace + || DB::instant(row, "last_heartbeat_at")? + <= QueryTaskConfig::add( + authority::now(), + -config.worker_stale_after_seconds.max(1), + )? + { + return Ok(None); + } + use sha2::{Digest, Sha256}; + let digest: String = Sha256::digest(DB::string(row, "token")?.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + Ok(Some(digest)) + } + + fn query_delivery(task: &Value) -> Value { + let mut delivered = task.clone(); + for field in [ + "receipt_expires_at", + "terminal_receipt", + "registration_incarnation", + "legacy_refusal_at", + ] { + delivered + .as_object_mut() + .expect("native query task") + .remove(field); + } + delivered["server_time"] = json!(authority::wire(authority::now())); + delivered + } + + async fn claim_query( + &self, + body: &Value, + config: &QueryTaskConfig, + expected_proof: &str, + ) -> Result { let owner = text(body, "worker_id")?; let queue = text(body, "task_queue")?; let mut tx = self.begin().await?; - let worker = Self::query("SELECT * FROM workflow_worker_registrations WHERE namespace=$1 AND worker_id=$2 AND status='active' AND last_heartbeat_at>$3") - .bind(self.namespace.as_ref()).bind(owner).bind(DB::bind_time(chrono::Utc::now() - chrono::Duration::seconds(60))) - .fetch_optional(&mut *tx).await?.ok_or_else(|| refuse(StatusCode::CONFLICT, "worker_not_registered"))?; + let worker = Self::query("SELECT * FROM workflow_worker_registrations WHERE namespace=$1 AND worker_id=$2 AND status='active'") + .bind(self.namespace.as_ref()).bind(owner) + .fetch_optional(&mut *tx).await?; + let Some(worker) = worker else { + return Ok(QueryClaim::StaleWorker); + }; + let proof = + Self::query_worker_proof(&mut *tx, self.namespace.as_ref(), owner, config).await?; + let Some(proof) = proof.filter(|proof| proof == expected_proof) else { + return Ok(QueryClaim::StaleWorker); + }; if DB::string(&worker, "task_queue")? != queue { return Err(refuse(StatusCode::CONFLICT, "task_queue_mismatch")); } @@ -340,12 +460,12 @@ where .as_array() .is_some_and(|values| values.iter().any(|v| v == "query_tasks")) { - return Ok(None); + return Ok(QueryClaim::Empty); } - for (key, mut task) in Self::query_entries(&mut tx).await? { + for (key, mut task) in self.query_entries(&mut tx).await? { if !matches!(task["status"].as_str(), Some("pending" | "leased")) || task["namespace"] != self.namespace.as_ref() - || task["deadline"].as_i64().unwrap_or(0) <= now() + || authority::deadline(&task).is_none_or(|deadline| deadline <= authority::now()) || task["task_queue"] != queue || !types .as_array() @@ -362,32 +482,144 @@ where { continue; } - if task["status"] == "leased" && task["lease_until"].as_i64().unwrap_or(0) > now() { - if task["lease_owner"] == owner { - return Ok(Some(task)); + if task["status"] == "leased" + && authority::timestamp(&task["lease_expires_at"]) + .is_some_and(|lease| lease > authority::now()) + { + let existing_owner = task["lease_owner"].as_str().unwrap_or(""); + let existing_proof = Self::query_worker_proof( + &mut *tx, + self.namespace.as_ref(), + existing_owner, + config, + ) + .await?; + if existing_proof + .as_deref() + .is_some_and(|proof| task["registration_incarnation"] == proof) + { + if existing_owner == owner { + let delivered = Self::query_delivery(&task); + tx.commit().await?; + return Ok(QueryClaim::Task(delivered)); + } + continue; } - continue; } - task["query_task_attempt"] = - json!(task["query_task_attempt"].as_i64().unwrap_or(0) + 1); + let next_attempt = task["query_task_attempt"] + .as_i64() + .and_then(|attempt| attempt.checked_add(1)) + .ok_or_else(|| refuse(StatusCode::CONFLICT, "query_task_attempt_exhausted"))?; + task["query_task_attempt"] = json!(next_attempt); task["status"] = json!("leased"); task["lease_owner"] = json!(owner); - let expires = (now() + LEASE_SECONDS).min(task["deadline"].as_i64().unwrap()); - task["lease_until"] = json!(expires); - task["lease_expires_at"] = json!(chrono::DateTime::from_timestamp(expires, 0).unwrap()); - Self::put_query( - &mut tx, - &key, - &task, - task["deadline"].as_i64().unwrap() + RECEIPT_SECONDS, - ) - .await?; + let time = authority::now(); + let expires = QueryTaskConfig::add(time, config.lease_timeout_seconds.max(1))? + .min(authority::deadline(&task).expect("checked original deadline")); + if expires <= time { + continue; + } + task["lease_expires_at"] = json!(authority::wire(expires)); + task["registration_incarnation"] = json!(proof); + Self::put_query(&mut tx, &key, &task, authority::expiration(&task)?).await?; tx.commit().await?; - return Ok(Some(task)); + return Ok(QueryClaim::Task(Self::query_delivery(&task))); } // Commit cleanup even when no compatible task is found. tx.commit().await?; - Ok(None) + Ok(QueryClaim::Empty) + } + + fn cached_query(row: &DB::Row, id: &str, namespace: &str) -> Result { + if DB::number(row, "expiration")? <= now() { + return Err(refuse(StatusCode::NOT_FOUND, "query_task_not_found")); + } + let task: Value = serde_json::from_str(&DB::string(row, "value")?)?; + authority::scope(&task, id, namespace)?; + Ok(task) + } + + async fn query_claim_reason<'e, E>( + executor: E, + task: &Value, + owner: &str, + attempt: i64, + config: &QueryTaskConfig, + ) -> Result> + where + E: Executor<'e, Database = DB>, + { + let reason = authority::claim_reason(task, owner, attempt, authority::now()); + if reason.is_some_and(|reason| reason != "lease_expired") { + return Ok(reason); + } + let proof = Self::query_worker_proof( + executor, + task["namespace"].as_str().unwrap_or(""), + owner, + config, + ) + .await?; + if proof + .as_deref() + .is_none_or(|proof| task["registration_incarnation"] != proof) + { + return Ok(Some("stale_worker_registration")); + } + // Database reads can consume the remaining lease. Recheck against a + // fresh clock after observing current registration authority. + Ok(authority::claim_reason( + task, + owner, + attempt, + authority::now(), + )) + } + + pub(super) async fn query_status( + &self, + id: &str, + owner: &str, + attempt: i64, + config: &QueryTaskConfig, + ) -> Result { + let key = key(id)?; + let row = Self::query(DB::QUERY_CACHE_GET) + .bind(&key) + .fetch_optional(&self.pool) + .await? + .ok_or_else(|| refuse(StatusCode::NOT_FOUND, "query_task_not_found"))?; + let task = Self::cached_query(&row, id, self.namespace.as_ref())?; + // Observation reveals timing only to the bound claim. It neither + // expires a row nor writes a tombstone, heartbeat or receipt. + if task["lease_owner"] != owner { + return Err(authority::reject_reason("lease_owner_mismatch")); + } + if task["query_task_attempt"] != attempt { + return Err(authority::reject_reason("query_task_attempt_mismatch")); + } + let reason = Self::query_claim_reason(&self.pool, &task, owner, attempt, config).await?; + let status = match reason { + Some("query_deadline_unavailable") => "failed", + Some("query_task_timed_out") => "timed_out", + _ => task["status"].as_str().unwrap_or("failed"), + }; + Ok( + json!({"query_task_id":id,"query_task_attempt":attempt,"lease_owner":owner, + "query_admitted_at":task["query_admitted_at"],"query_deadline_at":task["query_deadline_at"], + "lease_expires_at":task["lease_expires_at"],"server_time":authority::wire(authority::now()), + "query_task_status":status,"can_continue":reason.is_none(),"reason":reason,"status":200}), + ) + } + + pub(super) async fn guard_query_outcome( + &self, + id: &str, + body: &Value, + failed: bool, + config: &QueryTaskConfig, + ) -> Result> { + self.query_outcome(id, body, failed, config, false).await } pub(super) async fn finish_query( @@ -395,7 +627,21 @@ where task_id: &str, body: Value, failed: bool, + config: &QueryTaskConfig, ) -> Result { + self.query_outcome(task_id, &body, failed, config, true) + .await? + .ok_or_else(|| refuse(StatusCode::SERVICE_UNAVAILABLE, "query_outcome_unavailable")) + } + + async fn query_outcome( + &self, + task_id: &str, + body: &Value, + failed: bool, + config: &QueryTaskConfig, + commit: bool, + ) -> Result> { if serde_json::to_vec(&body)?.len() > 512 * 1024 { return Err(refuse( StatusCode::PAYLOAD_TOO_LARGE, @@ -403,7 +649,7 @@ where )); } let key = key(task_id)?; - let owner = text(&body, "lease_owner")?; + let owner = text(body, "lease_owner")?; let attempt = body["query_task_attempt"] .as_i64() .filter(|v| *v > 0) @@ -413,79 +659,109 @@ where "invalid_query_task_attempt", ) })?; + let fingerprint = authority::operation(body, failed)?; + // Retained decisions are immutable. Reconcile them using a scoped + // read before acquiring a writer or preparing the result again. + let observed = Self::query(DB::QUERY_CACHE_GET) + .bind(&key) + .fetch_optional(&self.pool) + .await? + .ok_or_else(|| refuse(StatusCode::NOT_FOUND, "query_task_not_found"))?; + let observed = Self::cached_query(&observed, task_id, self.namespace.as_ref())?; + if let Some(receipt) = + authority::receipt(&observed, owner, attempt, &fingerprint, authority::now())? + { + return Ok(Some(receipt)); + } + // A decision may commit between this read and writer admission. The + // transaction repeats receipt lookup before any fresh-authority fence. let mut tx = self.begin().await?; let row = Self::query(DB::QUERY_CACHE_GET) .bind(&key) .fetch_optional(&mut *tx) .await? .ok_or_else(|| refuse(StatusCode::NOT_FOUND, "query_task_not_found"))?; - let mut task: Value = serde_json::from_str(&DB::string(&row, "value")?)?; - if task["deadline"].as_i64().unwrap_or(0) <= now() - || DB::number(&row, "expiration")? <= now() + let mut task = Self::cached_query(&row, task_id, self.namespace.as_ref())?; + if let Some(receipt) = + authority::receipt(&task, owner, attempt, &fingerprint, authority::now())? { - return Err(refuse(StatusCode::CONFLICT, "query_task_timed_out")); - } - if task["status"] != "leased" { - return Err(refuse(StatusCode::CONFLICT, "query_task_not_leased")); + tx.rollback().await?; + return Ok(Some(receipt)); } - if task["lease_owner"] != owner { - return Err(refuse( - StatusCode::CONFLICT, - "query_task_lease_owner_mismatch", - )); - } - if task["query_task_attempt"] != attempt { - return Err(refuse(StatusCode::CONFLICT, "query_task_attempt_mismatch")); + if authority::expire(&mut task, authority::now())? { + let reason = if authority::deadline(&task).is_none() { + "query_deadline_unavailable" + } else { + "query_task_timed_out" + }; + Self::put_query(&mut tx, &key, &task, authority::expiration(&task)?).await?; + tx.commit().await?; + self.wake.notify_waiters(); + return Err(authority::reject_reason(reason)); } - if task["lease_until"].as_i64().unwrap_or(0) <= now() { - return Err(refuse(StatusCode::CONFLICT, "query_task_lease_expired")); + if let Some(reason) = + Self::query_claim_reason(&mut *tx, &task, owner, attempt, config).await? + { + return Err(authority::reject_reason(reason)); } - // Registration revocation also fences an otherwise unexpired claim. - let registered = Self::query("SELECT worker_id FROM workflow_worker_registrations WHERE namespace='default' AND worker_id=$1 AND status='active'") - .bind(owner).fetch_optional(&mut *tx).await?; - if registered.is_none() { - return Err(refuse(StatusCode::CONFLICT, "worker_not_registered")); + if !commit { + tx.rollback().await?; + return Ok(None); } - let reason; + let reason: Option; + let failure; + let result; if failed { - let failure = &body["failure"]; - if !failure.is_object() || serde_json::to_vec(failure)?.len() > 32 * 1024 { + let supplied = &body["failure"]; + if !supplied.is_object() || serde_json::to_vec(supplied)?.len() > 32 * 1024 { return Err(refuse( StatusCode::UNPROCESSABLE_ENTITY, "invalid_query_failure", )); } - reason = failure["reason"] - .as_str() - .filter(|v| !v.is_empty() && v.len() <= 255) - .unwrap_or("query_rejected"); - task["failure"] = failure.clone(); - task["failure"]["reason"] = json!(reason); - task["status"] = json!("failed"); + reason = Some( + supplied["reason"] + .as_str() + .filter(|v| !v.is_empty() && v.len() <= 255) + .unwrap_or("query_rejected") + .to_owned(), + ); + failure = Some(supplied.clone()); + result = None; } else { - let result = envelope(&body, "result_envelope")?; - if result.len() > RESULT_BYTES { + let supplied = envelope(body, "result_envelope")?; + if supplied.len() > RESULT_BYTES { return Err(refuse( StatusCode::PAYLOAD_TOO_LARGE, "query_result_exceeds_budget", )); } - task["result_envelope"] = wire(&result); - task["status"] = json!("completed"); - reason = ""; + result = Some(wire(&supplied)); + failure = None; + reason = None; + } + let decided_at = authority::now(); + if let Some(reason) = authority::claim_reason(&task, owner, attempt, decided_at) { + return Err(authority::reject_reason(reason)); } + let decision = json!({"query_task_id":task_id,"query_task_attempt":attempt, + "outcome":if failed {"failed"} else {"completed"},"reason":reason,"status":200,"recorded":true}); + task["status"] = json!(if failed { "failed" } else { "completed" }); + if let Some(failure) = failure { + task["failure"] = failure; + task["failure"]["reason"] = json!(reason); + } + if let Some(result) = result { + task["result_envelope"] = result; + } + task["terminal_receipt"] = json!({"lease_owner":owner,"attempt":attempt, + "fingerprint":fingerprint,"decision":decision,"decided_at":authority::wire(decided_at)}); // Results reserve their slot at admission, and discard the large replay // snapshot. No workflow rows, histories, cursors or waits are changed. - let object = task.as_object_mut().unwrap(); - for field in ["history_events", "workflow_arguments", "query_arguments"] { - object.remove(field); - } - Self::put_query(&mut tx, &key, &task, now() + RECEIPT_SECONDS).await?; + authority::discard_snapshot(&mut task); + Self::put_query(&mut tx, &key, &task, authority::expiration(&task)?).await?; tx.commit().await?; self.wake.notify_waiters(); - Ok( - json!({"query_task_id":task_id,"query_task_attempt":attempt,"outcome":if failed {"failed"} else {"completed"}, - "reason":if failed {Some(reason)} else {None},"status":200}), - ) + Ok(Some(decision)) } } diff --git a/rust/src/runtime/query_authority.rs b/rust/src/runtime/query_authority.rs new file mode 100644 index 000000000..26bea17c4 --- /dev/null +++ b/rust/src/runtime/query_authority.rs @@ -0,0 +1,357 @@ +//! Original query timing and content-bound receipts. These helpers never +//! derive a new execution deadline from a poll, observation or retry. +use super::{Result, RuntimeError, refuse}; +use axum::http::StatusCode; +use chrono::{DateTime, SecondsFormat, Utc}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::env; + +#[derive(Clone, Debug)] +pub struct QueryTaskConfig { + pub timeout_seconds: i64, + pub lease_timeout_seconds: i64, + pub receipt_ttl_seconds: i64, + pub worker_stale_after_seconds: i64, + pub poll_timeout_seconds: i64, +} + +impl Default for QueryTaskConfig { + fn default() -> Self { + Self { + timeout_seconds: 45, + lease_timeout_seconds: 60, + receipt_ttl_seconds: 180, + worker_stale_after_seconds: 30, + poll_timeout_seconds: 5, + } + } +} + +impl QueryTaskConfig { + pub(super) fn from_env() -> Result { + fn integer(primary: &str, legacy: &str, default: i64) -> Result { + for key in [primary, legacy] { + match env::var(key) { + Ok(value) => { + if let Some(value) = environment_integer(&value) { + return Ok(value); + } + } + Err(env::VarError::NotPresent) => {} + Err(_) => { + return Err(refuse( + StatusCode::BAD_REQUEST, + "invalid_query_configuration", + )); + } + } + } + Ok(default) + } + let poll = integer( + "DW_WORKER_POLL_TIMEOUT", + "WORKFLOW_SERVER_WORKER_POLL_TIMEOUT", + 30, + )?; + let lease = integer("DW_WORKFLOW_TASK_TIMEOUT", "WORKFLOW_TASK_TIMEOUT", 60)?; + let heartbeat = integer( + "DW_WORKER_HEARTBEAT_INTERVAL_SECONDS", + "WORKFLOW_SERVER_WORKER_HEARTBEAT_INTERVAL_SECONDS", + 10, + )?; + Ok(Self { + timeout_seconds: integer( + "DW_QUERY_TASK_TIMEOUT", + "WORKFLOW_SERVER_QUERY_TASK_TIMEOUT", + poll.saturating_add(15).clamp(40, 55), + )? + .max(0), + lease_timeout_seconds: integer( + "DW_QUERY_TASK_LEASE_TIMEOUT", + "WORKFLOW_SERVER_QUERY_TASK_LEASE_TIMEOUT", + lease, + )? + .max(1), + receipt_ttl_seconds: integer( + "DW_QUERY_TASK_TTL_SECONDS", + "WORKFLOW_SERVER_QUERY_TASK_TTL_SECONDS", + 180, + )? + .max(60), + worker_stale_after_seconds: integer( + "DW_WORKER_STALE_AFTER_SECONDS", + "WORKFLOW_SERVER_WORKER_STALE_AFTER_SECONDS", + heartbeat.saturating_mul(3).max(30), + )? + .max(1), + poll_timeout_seconds: integer( + "DW_QUERY_TASK_POLL_TIMEOUT", + "WORKFLOW_SERVER_QUERY_TASK_POLL_TIMEOUT", + 5, + )? + .max(1), + }) + } + + pub(super) fn add(time: DateTime, seconds: i64) -> Result> { + chrono::Duration::try_seconds(seconds) + .and_then(|duration| time.checked_add_signed(duration)) + .ok_or_else(|| refuse(StatusCode::BAD_REQUEST, "invalid_query_configuration")) + } + + pub(super) fn timing(&self, admitted: DateTime) -> Result<(DateTime, DateTime)> { + let timeout = self.timeout_seconds.max(0); + let deadline = Self::add(admitted, timeout)?; + let retention = self.receipt_ttl_seconds.max(60).max( + timeout + .saturating_add(self.lease_timeout_seconds.max(1)) + .saturating_add(60), + ); + Ok((deadline, Self::add(admitted, retention)?)) + } +} + +fn environment_integer(raw: &str) -> Option { + match raw.to_ascii_lowercase().as_str() { + "null" | "(null)" => return None, + "true" | "(true)" => return Some(1), + "false" | "(false)" | "empty" | "(empty)" => return Some(0), + _ => {} + } + let value = raw + .strip_prefix('"') + .and_then(|value| value.strip_suffix('"')) + .or_else(|| { + raw.strip_prefix('\'') + .and_then(|value| value.strip_suffix('\'')) + }) + .unwrap_or(raw); + Some(super::memo_limits::cast_numeric_string(value)) +} + +pub(super) fn now() -> DateTime { + DateTime::from_timestamp_micros(Utc::now().timestamp_micros()).expect("current UTC time") +} + +pub(super) fn wire(time: DateTime) -> String { + time.to_rfc3339_opts(SecondsFormat::Micros, true) +} + +pub(super) fn timestamp(value: &Value) -> Option> { + let raw = value.as_str()?; + if raw.len() != 27 || !raw.ends_with('Z') { + return None; + } + let parsed = DateTime::parse_from_rfc3339(raw).ok()?.with_timezone(&Utc); + (wire(parsed) == raw).then_some(parsed) +} + +pub(super) fn deadline(task: &Value) -> Option> { + let admitted = timestamp(&task["query_admitted_at"])?; + let deadline = timestamp(&task["query_deadline_at"])?; + let retention = timestamp(&task["receipt_expires_at"])?; + let minimum = deadline.checked_add_signed(chrono::Duration::seconds(60))?; + (deadline >= admitted && retention >= minimum).then_some(deadline) +} + +pub(super) fn expiration(task: &Value) -> Result { + let expires = timestamp(&task["receipt_expires_at"]) + .ok_or_else(|| refuse(StatusCode::CONFLICT, "query_deadline_unavailable"))?; + // SQL cache expiry is second-resolution. Round cleanup up, while the + // canonical microsecond timestamp remains the authority for every read. + Ok(expires + .timestamp() + .saturating_add(i64::from(expires.timestamp_subsec_micros() != 0))) +} + +pub(super) fn scope(task: &Value, id: &str, namespace: &str) -> Result<()> { + if task["query_task_id"] != id || task["namespace"] != namespace { + return Err(refuse(StatusCode::NOT_FOUND, "query_task_not_found")); + } + Ok(()) +} + +pub(super) fn operation(body: &Value, failed: bool) -> Result { + let payload = if failed { + body["failure"].clone() + } else { + json!({"result":body["result"],"result_envelope":body["result_envelope"]}) + }; + // serde_json's ordered maps preserve object/list and scalar distinctions. + // This fingerprint is private native cache state, never a wire primitive. + let bytes = serde_json::to_vec(&json!([if failed { "fail" } else { "complete" }, payload]))?; + Ok(Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect()) +} + +pub(super) fn receipt( + task: &Value, + owner: &str, + attempt: i64, + fingerprint: &str, + time: DateTime, +) -> Result> { + let receipt = &task["terminal_receipt"]; + if !receipt.is_object() + || deadline(task).is_none() + || timestamp(&task["receipt_expires_at"]).is_none_or(|expiry| expiry <= time) + || receipt["lease_owner"] != owner + || receipt["attempt"] != attempt + { + return Ok(None); + } + if receipt["fingerprint"] != fingerprint { + return Err(refuse(StatusCode::CONFLICT, "query_completion_conflict")); + } + let mut decision = receipt["decision"].clone(); + if !decision.is_object() || decision["query_task_id"] != task["query_task_id"] { + return Err(refuse(StatusCode::CONFLICT, "query_receipt_unavailable")); + } + decision["recorded"] = json!(false); + Ok(Some(decision)) +} + +pub(super) fn claim_reason( + task: &Value, + owner: &str, + attempt: i64, + time: DateTime, +) -> Option<&'static str> { + let Some(deadline) = deadline(task) else { + return Some("query_deadline_unavailable"); + }; + if matches!(task["status"].as_str(), Some("completed" | "failed")) { + return Some("query_task_not_leased"); + } + if time >= deadline { + return Some("query_task_timed_out"); + } + if task["status"] == "timed_out" { + return Some("query_task_timed_out"); + } + if task["status"] != "leased" { + return Some("query_task_not_leased"); + } + if task["lease_owner"] != owner { + return Some("lease_owner_mismatch"); + } + if task["query_task_attempt"] != attempt { + return Some("query_task_attempt_mismatch"); + } + if timestamp(&task["lease_expires_at"]).is_none_or(|lease| time >= lease) { + return Some("lease_expired"); + } + None +} + +pub(super) fn discard_snapshot(task: &mut Value) { + if let Some(object) = task.as_object_mut() { + for field in ["history_events", "workflow_arguments", "query_arguments"] { + object.remove(field); + } + } +} + +pub(super) fn expire(task: &mut Value, time: DateTime) -> Result { + if !matches!(task["status"].as_str(), Some("pending" | "leased")) { + return Ok(false); + } + let original = deadline(task); + if original.is_some_and(|deadline| time < deadline) { + return Ok(false); + } + if original.is_none() { + task["status"] = json!("failed"); + task["failure"] = json!({"reason":"query_deadline_unavailable", "message":"The original query result deadline is unavailable. Submit a new query explicitly."}); + // A legacy entry gets only one refusal tombstone. This timestamp never + // becomes an admission time, execution budget or successful receipt. + if timestamp(&task["receipt_expires_at"]).is_none() { + task["receipt_expires_at"] = json!(wire(QueryTaskConfig::add(time, 60)?)); + } + task["legacy_refusal_at"] = json!(wire(time)); + } else { + task["status"] = json!("timed_out"); + task["timed_out_at"] = json!(wire(time)); + } + discard_snapshot(task); + Ok(true) +} + +pub(super) fn reject_reason(reason: &'static str) -> RuntimeError { + refuse(StatusCode::CONFLICT, reason) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn zero_timeout_preserves_admission_and_retention_microseconds() { + let admitted = timestamp(&json!("2026-10-15T00:00:00.123456Z")).unwrap(); + let policy = QueryTaskConfig { + timeout_seconds: 0, + receipt_ttl_seconds: 1, + lease_timeout_seconds: 2, + ..Default::default() + }; + let (deadline, retention) = policy.timing(admitted).unwrap(); + assert_eq!(deadline, admitted); + assert_eq!(retention, admitted + chrono::Duration::seconds(62)); + let task = json!({"query_admitted_at":wire(admitted),"query_deadline_at":wire(deadline),"receipt_expires_at":wire(retention)}); + assert_eq!(super::deadline(&task), Some(admitted)); + assert_eq!(expiration(&task).unwrap(), retention.timestamp() + 1); + assert_eq!( + claim_reason(&task, "worker", 1, admitted), + Some("query_task_timed_out") + ); + } + + #[test] + fn missing_or_noncanonical_original_time_gets_one_refusal_without_rebasing() { + let time = timestamp(&json!("2026-10-15T00:00:00.123456Z")).unwrap(); + for unavailable in [ + Value::Null, + json!("2026-10-15T00:00:00Z"), + json!("2026-10-15 00:00:00.123456"), + json!("2026-10-15T00:00:00.123456+00:00"), + ] { + let mut task = json!({"status":"leased","deadline":9999999999_i64,"query_admitted_at":wire(time), + "query_deadline_at":unavailable,"receipt_expires_at":wire(time+chrono::Duration::seconds(120)), + "history_events":[{"must_not_be_consumed":true}]}); + assert_eq!( + claim_reason(&task, "worker", 1, time), + Some("query_deadline_unavailable") + ); + assert!(expire(&mut task, time).unwrap()); + let once = task.clone(); + assert!(!expire(&mut task, time + chrono::Duration::seconds(20)).unwrap()); + assert_eq!(task, once); + assert_eq!(task["status"], "failed"); + assert_eq!(task["query_admitted_at"], wire(time)); + assert!(task.get("history_events").is_none()); + assert_eq!( + timestamp(&task["receipt_expires_at"]), + Some(time + chrono::Duration::seconds(120)) + ); + } + } + + #[test] + fn environment_values_keep_php_null_fallback_and_integer_casts() { + for (raw, expected) in [ + ("null", None), + ("(null)", None), + ("true", Some(1)), + ("false", Some(0)), + ("\"4\"", Some(4)), + ("-2", Some(-2)), + ("2e2", Some(200)), + ("garbage", Some(0)), + ] { + assert_eq!(environment_integer(raw), expected, "{raw}"); + } + } +} diff --git a/rust/src/runtime/query_codec_tests.rs b/rust/src/runtime/query_codec_tests.rs new file mode 100644 index 000000000..35042771b --- /dev/null +++ b/rust/src/runtime/query_codec_tests.rs @@ -0,0 +1,292 @@ +//! A real original loses authority while waiting for bounded codec capacity. +//! Holding this private semaphore is test instrumentation, not a wire clock. +use super::*; +use crate::{ + codec::{Value as Payload, ValueCodec}, + runtime::{QueryTaskConfig, RuntimeError}, +}; +use serde_json::json; + +fn envelope(value: Payload) -> Value { + json!({"codec":"avro","blob":ValueCodec::new().unwrap().encode(&value).unwrap()}) +} + +struct Original { + _directory: tempfile::TempDir, + runtime: Runtime, + started: Value, + history_before: Vec<(i64, String, String)>, + waiting: JoinHandle>, + task: Value, +} + +async fn original(timeout_seconds: i64) -> Original { + let directory = tempfile::tempdir().unwrap(); + let runtime = Runtime::open( + directory.path().join("runtime.sqlite").to_str().unwrap(), + "test-token".into(), + ) + .await + .unwrap() + .with_query_task_config(QueryTaskConfig { + timeout_seconds, + lease_timeout_seconds: timeout_seconds, + ..Default::default() + }); + runtime.register(json!({"worker_id":"codec-worker","task_queue":"test","runtime":"php", + "supported_workflow_types":["echo"],"supported_activity_types":[],"capabilities":["query_tasks"], + "workflow_command_contracts":{"echo":{"queries":["state"],"query_contracts":[{"name":"state","parameters":[]}]}}})).await.unwrap(); + let started = runtime + .start( + json!({"workflow_id":"codec-query-original","workflow_type":"echo","task_queue":"test", + "input":envelope(Payload::Array(vec![]))}), + ) + .await + .unwrap(); + let workflow = runtime + .poll( + json!({"worker_id":"codec-worker","task_queue":"test","timeout_seconds":0}), + "workflow", + "1.20", + ) + .await + .unwrap()["task"] + .clone(); + runtime.complete_workflow(workflow["task_id"].as_str().unwrap(),json!({"lease_owner":"codec-worker", + "workflow_task_attempt":workflow["workflow_task_attempt"],"last_history_sequence":workflow["last_history_sequence"], + "commands":[{"type":"complete_workflow","result":envelope(Payload::Long(7))}]})).await.unwrap(); + let pool = runtime.sqlite_pool().clone(); + let history_before: Vec<(i64,String,String)> = sqlx::query_as("SELECT sequence,event_type,payload FROM workflow_history_events WHERE workflow_run_id=$1 ORDER BY sequence") + .bind(started["run_id"].as_str().unwrap()).fetch_all(&pool).await.unwrap(); + let waiting = { + let runtime = runtime.clone(); + tokio::spawn(async move { + runtime + .query_workflow( + "codec-query-original", + None, + "state", + json!({"input":envelope(Payload::Array(vec![]))}), + ) + .await + }) + }; + let task = tokio::time::timeout(Duration::from_secs(1), async { + loop { + let response = runtime + .poll_query( + json!({"worker_id":"codec-worker","task_queue":"test","timeout_seconds":0}), + ) + .await + .unwrap(); + if !response["task"].is_null() { + break response["task"].clone(); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + Original { + _directory: directory, + runtime, + started, + history_before, + waiting, + task, + } +} + +#[tokio::test] +async fn query_outcome_rechecks_original_deadline_after_codec_wait_without_holding_writer() { + let Original { + _directory, + runtime, + started, + history_before, + mut waiting, + task, + } = original(2).await; + let pool = runtime.sqlite_pool().clone(); + let held = runtime.signal_codec.clone().acquire_owned().await.unwrap(); + let mut complete = { + let runtime = runtime.clone(); + let task = task.clone(); + tokio::spawn(async move { + runtime + .complete_query( + task["query_task_id"].as_str().unwrap(), + json!({ + "lease_owner":"codec-worker","query_task_attempt":task["query_task_attempt"], + "result_envelope":envelope(Payload::Long(9007199254740993))}), + ) + .await + }) + }; + assert!( + tokio::time::timeout(Duration::from_millis(50), &mut complete) + .await + .is_err(), + "codec capacity must hold this submission" + ); + // A separate writer remains usable while the submission waits for the + // semaphore. The completion cannot hold its preflight transaction open. + let key = format!( + "dw-native-query:{}", + task["query_task_id"].as_str().unwrap() + ); + tokio::time::timeout( + Duration::from_millis(250), + sqlx::query("UPDATE dw_query_cache SET value=value WHERE \"key\"=$1") + .bind(&key) + .execute(&pool), + ) + .await + .expect("codec wait must release the writer") + .unwrap(); + let mut writer = pool.acquire().await.unwrap(); + sqlx::query("BEGIN IMMEDIATE") + .execute(&mut *writer) + .await + .unwrap(); + let deadline = + chrono::DateTime::parse_from_rfc3339(task["query_deadline_at"].as_str().unwrap()) + .unwrap() + .with_timezone(&chrono::Utc); + let remaining = (deadline - chrono::Utc::now()).to_std().unwrap_or_default(); + let caller = tokio::time::timeout(remaining + Duration::from_millis(250), &mut waiting) + .await + .expect("bookkeeping lock pressure must not extend the original caller deadline") + .unwrap() + .unwrap_err(); + assert!( + matches!( + caller, + RuntimeError::Protocol { + status: StatusCode::GATEWAY_TIMEOUT, + ref response + } if response["reason"] == "query_worker_execution_timeout" + ), + "{caller:?}" + ); + sqlx::query("ROLLBACK").execute(&mut *writer).await.unwrap(); + drop(writer); + drop(held); + let result = complete.await.unwrap().unwrap_err(); + assert!( + matches!( + result, + RuntimeError::Refused { + status: StatusCode::CONFLICT, + reason: "query_task_timed_out" + } + ), + "{result:?}" + ); + let stored: String = sqlx::query_scalar("SELECT value FROM dw_query_cache WHERE \"key\"=$1") + .bind(&key) + .fetch_one(&pool) + .await + .unwrap(); + let stored: Value = serde_json::from_str(&stored).unwrap(); + assert_eq!(stored["query_admitted_at"], task["query_admitted_at"]); + assert_eq!(stored["query_deadline_at"], task["query_deadline_at"]); + assert_eq!(stored["status"], "timed_out"); + assert!(stored.get("result_envelope").is_none()); + assert!(stored.get("terminal_receipt").is_none()); + let history_after: Vec<(i64,String,String)> = sqlx::query_as("SELECT sequence,event_type,payload FROM workflow_history_events WHERE workflow_run_id=$1 ORDER BY sequence") + .bind(started["run_id"].as_str().unwrap()).fetch_all(&pool).await.unwrap(); + assert_eq!(history_before, history_after); + runtime.close().await; +} + +#[tokio::test] +async fn committed_query_receipt_ignores_writer_and_codec_pressure_before_and_after_expiry() { + let Original { + _directory, + runtime, + started, + history_before, + waiting, + task, + } = original(3).await; + let pool = runtime.sqlite_pool().clone(); + let id = task["query_task_id"].as_str().unwrap(); + let key = format!("dw-native-query:{id}"); + let body = json!({"lease_owner":"codec-worker","query_task_attempt":task["query_task_attempt"], + "result_envelope":envelope(Payload::Long(9007199254740993))}); + let first = runtime.complete_query(id, body.clone()).await.unwrap(); + assert_eq!(first["recorded"], true); + assert_eq!(waiting.await.unwrap().unwrap().0, StatusCode::OK); + let before: (String, i64) = + sqlx::query_as("SELECT value,expiration FROM dw_query_cache WHERE \"key\"=$1") + .bind(&key) + .fetch_one(&pool) + .await + .unwrap(); + let held = runtime.signal_codec.clone().acquire_owned().await.unwrap(); + let mut writer = pool.acquire().await.unwrap(); + sqlx::query("BEGIN IMMEDIATE") + .execute(&mut *writer) + .await + .unwrap(); + for after_expiry in [false, true] { + if after_expiry { + let deadline = + chrono::DateTime::parse_from_rfc3339(task["query_deadline_at"].as_str().unwrap()) + .unwrap() + .with_timezone(&chrono::Utc); + tokio::time::sleep( + (deadline - chrono::Utc::now()).to_std().unwrap_or_default() + + Duration::from_millis(10), + ) + .await; + } + let duplicate = tokio::time::timeout( + Duration::from_millis(250), + runtime.complete_query(id, body.clone()), + ) + .await + .expect("a retained decision must not acquire the writer or codec slot") + .unwrap(); + let mut expected = first.clone(); + expected["recorded"] = json!(false); + assert_eq!(duplicate, expected); + } + let mut different = body; + different["result_envelope"] = json!({"codec":"avro","blob":"different-invalid-bytes"}); + let conflict = tokio::time::timeout( + Duration::from_millis(250), + runtime.complete_query(id, different), + ) + .await + .expect("conflicting retained decisions also refuse before writer/codec work") + .unwrap_err(); + assert!( + matches!( + conflict, + RuntimeError::Refused { + status: StatusCode::CONFLICT, + reason: "query_completion_conflict" + } + ), + "{conflict:?}" + ); + let after: (String, i64) = + sqlx::query_as("SELECT value,expiration FROM dw_query_cache WHERE \"key\"=$1") + .bind(&key) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + before, after, + "a retry must not write or extend the receipt" + ); + let history_after: Vec<(i64,String,String)> = sqlx::query_as("SELECT sequence,event_type,payload FROM workflow_history_events WHERE workflow_run_id=$1 ORDER BY sequence") + .bind(started["run_id"].as_str().unwrap()).fetch_all(&pool).await.unwrap(); + assert_eq!(history_before, history_after); + sqlx::query("ROLLBACK").execute(&mut *writer).await.unwrap(); + drop(writer); + drop(held); + runtime.close().await; +} diff --git a/rust/src/runtime/store.rs b/rust/src/runtime/store.rs index 6e739078c..648a19bc5 100644 --- a/rust/src/runtime/store.rs +++ b/rust/src/runtime/store.rs @@ -1452,7 +1452,7 @@ where if self.namespace.as_ref() != "default" && !matches!( text(command, "type")?, - "complete_workflow" | "schedule_activity" + "complete_workflow" | "schedule_activity" | "start_timer" ) && !(text(command, "type")? == "open_condition_wait" && commands.len() == 1) { @@ -2115,7 +2115,9 @@ where // New timers use the canonical PHP timestamp shape on SQLite and typed // UTC values elsewhere. Existing PHP data still cannot be adopted. // The read probe avoids acquiring the transition lock when idle. - const DUE: &str = "SELECT t.*,r.queue FROM workflow_run_timers t JOIN workflow_runs r ON r.id=t.workflow_run_id WHERE r.namespace='default' AND r.status IN ('running','waiting') AND t.status='pending' AND t.fire_at<=$1 ORDER BY t.fire_at,t.id LIMIT 50"; + // Maintenance visits every namespace; each timer's persisted run + // supplies its namespace, independently of the store that woke it. + const DUE: &str = "SELECT t.*,r.queue,r.namespace AS run_namespace FROM workflow_run_timers t JOIN workflow_runs r ON r.id=t.workflow_run_id WHERE r.status IN ('running','waiting') AND t.status='pending' AND t.fire_at<=$1 ORDER BY t.fire_at,t.id LIMIT 50"; if Self::query(DUE) .bind(DB::bind_time(now())) .fetch_optional(&self.pool) @@ -2150,6 +2152,7 @@ where let task = Self::query(DB::TIMER_TASK_SQL) .bind(&run_id) .bind(&timer_id) + .bind(DB::string(&timer, "run_namespace")?) .fetch_one(&mut *tx) .await?; let task_id = DB::string(&task, "id")?; @@ -2754,7 +2757,7 @@ pub(crate) fn capabilities() -> Value { "worker_deregistration_fencing":{"schema":"durable-workflow.v2.worker-deregistration.v1","supported":true,"receipt_retention_seconds":600,"endpoint":"/worker/registrations/{workerId}/deregister"}, "workflow_memo_updates": false, "cooperative_cancellation": true, "prepared_local_activities": false, "worker_sessions": false, "sticky_execution": false, "local_activities": false, "message_streams": false, - "workflow_updates": true, "query_tasks": true, "query_task_poll_request_idempotency": false}); + "workflow_updates": true, "query_tasks": true, "query_task_authority":true, "query_task_poll_request_idempotency": false}); if super::memos::UPDATES_SUPPORTED { value["supported_workflow_task_commands"] .as_array_mut() diff --git a/rust/tests/execution.rs b/rust/tests/execution.rs index 3a325453d..b0a6da801 100644 --- a/rust/tests/execution.rs +++ b/rust/tests/execution.rs @@ -37,6 +37,9 @@ mod role_tokens; #[path = "execution/namespaces.rs"] mod namespaces; +#[path = "execution/named_timers.rs"] +mod named_timers; + #[path = "execution/selected_run_signals.rs"] mod selected_run_signals; @@ -69,6 +72,8 @@ mod condition_occurrences; #[path = "execution/query_arguments.rs"] mod query_arguments; +#[path = "execution/query_authority.rs"] +mod query_authority; #[path = "execution/cross_kind_poll.rs"] mod cross_kind_poll; @@ -248,6 +253,50 @@ impl TestDatabase { } } + async fn timer_task_states(&self, run: &Value) -> Vec<(String, String)> { + let query = "SELECT id,status FROM workflow_tasks WHERE workflow_run_id=$1 AND task_type='timer' ORDER BY id"; + let run = run.as_str().unwrap(); + match self { + Self::Sqlite(dir) => { + let pool = sqlx::SqlitePool::connect(&format!( + "sqlite://{}", + dir.path().join("runtime.sqlite").display() + )) + .await + .unwrap(); + let rows = sqlx::query_as(query) + .bind(run) + .fetch_all(&pool) + .await + .unwrap(); + pool.close().await; + rows + } + Self::Postgres { options, .. } => { + let pool = sqlx::postgres::PgPoolOptions::new() + .connect_with(options.as_ref().clone()) + .await + .unwrap(); + let rows = sqlx::query_as(query) + .bind(run) + .fetch_all(&pool) + .await + .unwrap(); + pool.close().await; + rows + } + Self::MySql { options, .. } => { + let pool = sqlx::mysql::MySqlPoolOptions::new() + .connect_with(options.as_ref().clone()) + .await + .unwrap(); + let rows = sqlx::query_as("SELECT id,status FROM workflow_tasks WHERE workflow_run_id=? AND task_type='timer' ORDER BY id").bind(run).fetch_all(&pool).await.unwrap(); + pool.close().await; + rows + } + } + } + async fn update_failure_count(&self, command: &str, failure: &str) -> i64 { let query = "SELECT COUNT(*) FROM workflow_failures WHERE source_id=$1 AND id=$2 AND source_kind='workflow_command' AND propagation_kind='update' AND failure_category='application' AND non_retryable=true AND handled=false"; match self { @@ -2072,6 +2121,19 @@ async fn await_query(app: &Router, worker: &str) -> Value { .expect("query must be admitted without holding the database pool") } +fn assert_query_replay(first: &Value, repeated: Value) { + let initial = + chrono::DateTime::parse_from_rfc3339(first["server_time"].as_str().unwrap()).unwrap(); + let current = + chrono::DateTime::parse_from_rfc3339(repeated["server_time"].as_str().unwrap()).unwrap(); + assert!(current >= initial); + let mut replay = repeated; + // Only this observation clock is nondeterministic. Original query timing, + // lease, identities, values and every business-history field remain exact. + replay["server_time"] = first["server_time"].clone(); + assert_eq!(replay, *first); +} + #[tokio::test] async fn queries_fence_recovered_leases_across_pools_without_mutating_the_run() { let database = TestDatabase::new().await; @@ -2168,7 +2230,7 @@ async fn queries_fence_recovered_leases_across_pools_without_mutating_the_run() ); assert_eq!(projected, before["events"]); assert_eq!(claim["query_arguments"], input["input"]); - assert_eq!(poll(&a, "query-a", "query").await, claim); + assert_query_replay(&claim, poll(&a, "query-a", "query").await); assert!(poll(&b, "query-b", "query").await.is_null()); let finish_path = format!( "/api/worker/query-tasks/{}/complete", @@ -2180,7 +2242,7 @@ async fn queries_fence_recovered_leases_across_pools_without_mutating_the_run() wrong["lease_owner"] = json!("query-b"); assert_eq!( request(&b, "POST", &finish_path, wrong).await.1["reason"], - "query_task_lease_owner_mismatch" + "lease_owner_mismatch" ); let mut invalid = completion.clone(); invalid["result_envelope"]["blob"] = json!("not-avro"); @@ -2189,15 +2251,15 @@ async fn queries_fence_recovered_leases_across_pools_without_mutating_the_run() "invalid_query_result" ); match &database { - TestDatabase::Sqlite(_) => database.execute("UPDATE dw_query_cache SET value=json_set(value,'$.lease_until',0) WHERE value LIKE '%\"status\":\"leased\"%'").await, - TestDatabase::Postgres{..} => database.execute("UPDATE dw_query_cache SET value=jsonb_set(value::jsonb,'{lease_until}','0')::text WHERE value LIKE '%\"status\":\"leased\"%'").await, - TestDatabase::MySql{..} => database.execute("UPDATE dw_query_cache SET value=JSON_SET(value,'$.lease_until',0) WHERE value LIKE '%\"status\":\"leased\"%'").await, + TestDatabase::Sqlite(_) => database.execute("UPDATE dw_query_cache SET value=json_set(value,'$.lease_expires_at','2000-01-01T00:00:00.000000Z') WHERE value LIKE '%\"status\":\"leased\"%'").await, + TestDatabase::Postgres{..} => database.execute("UPDATE dw_query_cache SET value=jsonb_set(value::jsonb,'{lease_expires_at}','\"2000-01-01T00:00:00.000000Z\"')::text WHERE value LIKE '%\"status\":\"leased\"%'").await, + TestDatabase::MySql{..} => database.execute("UPDATE dw_query_cache SET value=JSON_SET(value,'$.lease_expires_at','2000-01-01T00:00:00.000000Z') WHERE value LIKE '%\"status\":\"leased\"%'").await, } assert_eq!( request(&a, "POST", &finish_path, completion.clone()) .await .1["reason"], - "query_task_lease_expired" + "lease_expired" ); let recovered = await_query(&a, "query-b").await; assert_eq!(recovered["query_task_id"], claim["query_task_id"]); @@ -2207,7 +2269,7 @@ async fn queries_fence_recovered_leases_across_pools_without_mutating_the_run() request(&a, "POST", &finish_path, completion.clone()) .await .1["reason"], - "query_task_lease_owner_mismatch" + "lease_owner_mismatch" ); let mut current = completion.clone(); current["lease_owner"] = json!("query-b"); @@ -2223,10 +2285,9 @@ async fn queries_fence_recovered_leases_across_pools_without_mutating_the_run() let outcome = waiting.await.unwrap(); assert_eq!(outcome.0, StatusCode::OK, "{}", outcome.1); assert_eq!(outcome.1["result_envelope"], result); - assert_eq!( - request(&b, "POST", &finish_path, current).await.1["reason"], - "query_task_not_leased" - ); + let duplicate = request(&b, "POST", &finish_path, current).await; + assert_eq!(duplicate.0, StatusCode::OK, "{}", duplicate.1); + assert_eq!(duplicate.1["recorded"], false); assert_eq!( request(&a, "GET", &history_path, Value::Null).await.1, before @@ -3244,6 +3305,8 @@ async fn cooperative_delivery_cleanup_and_original_deadline_survive_fresh_runtim assert_eq!(completed.0, StatusCode::OK, "{}", completed.1); } let prefix = run_history(&app_b, &workflow, &run).await; + let timer_tasks = database.timer_task_states(&run).await; + assert_eq!(timer_tasks.len(), usize::from(phase == "pending_timer")); let control = format!( "/api/workflows/cleanup-root/runs/{}/request-cancellation", run.as_str().unwrap() @@ -3282,6 +3345,14 @@ async fn cooperative_delivery_cleanup_and_original_deadline_survive_fresh_runtim let delivered = request_protocol(&app_a, "POST", &endpoint, delivery.clone(), "1.20").await; assert_eq!(delivered.0, StatusCode::OK, "{}", delivered.1); assert_eq!(delivered.1["delivered"], true); + assert_eq!( + database.timer_task_states(&run).await, + timer_tasks + .into_iter() + .map(|(id, _)| (id, "cancelled".to_owned())) + .collect::>(), + "cancellation must retire the same original timer task at delivery" + ); let canonical_history = run_history(&app_a, &workflow, &run).await; let worker_history = request_protocol(&app_a, "POST", &format!("/api/worker/workflow-tasks/{}/history", task["task_id"].as_str().unwrap()), diff --git a/rust/tests/execution/named_timers.rs b/rust/tests/execution/named_timers.rs new file mode 100644 index 000000000..4cdef648c --- /dev/null +++ b/rust/tests/execution/named_timers.rs @@ -0,0 +1,244 @@ +use super::namespaces::{create, scoped, start_scoped}; +use super::*; +use std::time::Duration; + +async fn register_timer_worker(app: &Router, namespace: &str) { + let (status, body) = scoped( + app, + namespace, + "POST", + "/api/worker/register", + json!({ + "worker_id":"timer-worker","task_queue":"test","runtime":"php", + "supported_workflow_types":["echo"],"supported_activity_types":[] + }), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{body}"); +} + +async fn claim(app: &Router, namespace: &str, request_id: &str) -> Value { + let (status, body) = scoped( + app, + namespace, + "POST", + "/api/worker/workflow-tasks/poll", + json!({ + "worker_id":"timer-worker","task_queue":"test","timeout_seconds":0, + "poll_request_id":request_id + }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + body["task"].clone() +} + +async fn finish( + app: &Router, + namespace: &str, + task: &Value, + commands: Value, +) -> (StatusCode, Value) { + scoped( + app, + namespace, + "POST", + &format!( + "/api/worker/workflow-tasks/{}/complete", + task["task_id"].as_str().unwrap() + ), + completion(task, commands), + ) + .await +} + +#[tokio::test] +async fn named_timers_keep_original_namespaces_across_cold_two_node_recovery() { + let deadline = tokio::time::Instant::now() + Duration::from_secs(35); + let database = TestDatabase::new().await; + let runtime = database.open().await.unwrap(); + let app = router(runtime.clone()); + let mut originals = Vec::new(); + for namespace in ["alpha", "beta", "default"] { + if namespace != "default" { + create(&app, namespace).await; + } + register_timer_worker(&app, namespace).await; + let workflow = format!("named-timer-{namespace}"); + let run = start_scoped(&app, namespace, &workflow).await; + let task = claim(&app, namespace, "original-claim").await; + assert_eq!(task["run_id"], run["run_id"]); + originals.push((namespace, workflow, run, task)); + } + let mut snapshots = Vec::new(); + for (namespace, workflow, run, task) in &originals { + let commands = json!([{"type":"start_timer","delay_seconds":10}]); + let accepted = finish(&app, namespace, task, commands.clone()).await; + assert_eq!(accepted.0, StatusCode::OK, "{}", accepted.1); + assert_eq!( + finish(&app, namespace, task, commands).await.1["recorded"], + false + ); + let path = format!( + "/api/workflows/{workflow}/runs/{}/history", + run["run_id"].as_str().unwrap() + ); + let before = scoped(&app, namespace, "GET", &path, Value::Null).await; + assert_eq!(before.0, StatusCode::OK, "{}", before.1); + assert_eq!(before.1["events"].as_array().unwrap().len(), 3); + assert_eq!(before.1["events"][2]["event_type"], "TimerScheduled"); + snapshots.push((path, before.1)); + } + runtime.close().await; + let (a, b) = tokio::join!(database.open(), database.open()); + let (a, b) = (a.unwrap(), b.unwrap()); + let app_a = router(a.clone()); + let app_b = router(b.clone()); + for ((namespace, _, run, _), (path, before)) in originals.iter().zip(&snapshots) { + let fired = loop { + let response = scoped(&app_a, namespace, "GET", path, Value::Null).await; + assert_eq!(response.0, StatusCode::OK, "{}", response.1); + if response.1["events"].as_array().unwrap().len() == 4 { + break response.1; + } + assert!( + tokio::time::Instant::now() < deadline, + "original timer never fired: {}", + response.1 + ); + tokio::time::sleep(Duration::from_millis(50)).await; + }; + assert_eq!( + &fired["events"].as_array().unwrap()[..3], + before["events"].as_array().unwrap().as_slice() + ); + let scheduled = &before["events"][2]["payload"]; + let event = &fired["events"][3]; + assert_eq!(event["event_type"], "TimerFired"); + for field in ["timer_id", "sequence", "delay_seconds", "fire_at"] { + assert_eq!(event["payload"][field], scheduled[field], "{field}"); + } + assert!( + chrono::DateTime::parse_from_rfc3339(event["payload"]["fired_at"].as_str().unwrap()) + .unwrap() + >= chrono::DateTime::parse_from_rfc3339(scheduled["fire_at"].as_str().unwrap()) + .unwrap() + ); + let (first, second) = tokio::join!( + claim(&app_a, namespace, "recovered-a"), + claim(&app_b, namespace, "recovered-b") + ); + assert_ne!(first.is_null(), second.is_null()); + let task = if first.is_null() { second } else { first }; + assert_eq!(task["run_id"], run["run_id"]); + assert_eq!(task["workflow_task_attempt"], 1); + let foreign = if *namespace == "alpha" { + "beta" + } else { + "alpha" + }; + let commands = json!([{"type":"complete_workflow","result":envelope(Payload::Long(9007199254740993))}]); + assert_eq!( + finish(&app_b, foreign, &task, commands.clone()).await.0, + StatusCode::NOT_FOUND + ); + assert_eq!( + scoped(&app_a, namespace, "GET", path, Value::Null).await.1, + fired + ); + let completed = finish(&app_b, namespace, &task, commands).await; + assert_eq!(completed.0, StatusCode::OK, "{}", completed.1); + // Other namespaces still have due work on this exact queue/worker ID. + assert!( + claim(&app_a, namespace, "no-foreign-delivery") + .await + .is_null() + ); + } + tokio::time::sleep(Duration::from_millis(300)).await; + for ((namespace, _, _, _), (path, before)) in originals.iter().zip(&snapshots) { + let after = scoped(&app_b, namespace, "GET", path, Value::Null).await.1; + assert_eq!(after["events"].as_array().unwrap().len(), 5); + assert_eq!( + &after["events"].as_array().unwrap()[..3], + before["events"].as_array().unwrap().as_slice() + ); + assert_eq!(after["events"][4]["event_type"], "WorkflowCompleted"); + } + a.close().await; + b.close().await; + database.remove().await; +} + +#[tokio::test] +async fn named_timer_invalid_batch_is_atomic_and_zero_delay_keeps_its_identity() { + let deadline = tokio::time::Instant::now() + Duration::from_secs(15); + let database = TestDatabase::new().await; + let runtime = database.open().await.unwrap(); + let app = router(runtime.clone()); + create(&app, "alpha").await; + register_timer_worker(&app, "alpha").await; + let run = start_scoped(&app, "alpha", "named-timer-atomic").await; + let task = claim(&app, "alpha", "original-claim").await; + let path = format!( + "/api/workflows/named-timer-atomic/runs/{}/history", + run["run_id"].as_str().unwrap() + ); + let before = scoped(&app, "alpha", "GET", &path, Value::Null).await; + let refused = finish( + &app, + "alpha", + &task, + json!([ + {"type":"start_timer","delay_seconds":1},{"type":"start_timer","delay_seconds":-1} + ]), + ) + .await; + assert_eq!(refused.0, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(refused.1["reason"], "invalid_timer_delay"); + assert_eq!( + scoped(&app, "alpha", "GET", &path, Value::Null).await, + before + ); + let scheduled = finish( + &app, + "alpha", + &task, + json!([{"type":"start_timer","delay_seconds":0}]), + ) + .await; + assert_eq!(scheduled.0, StatusCode::OK, "{}", scheduled.1); + let history = loop { + let history = scoped(&app, "alpha", "GET", &path, Value::Null).await.1; + if history["events"].as_array().unwrap().len() == 4 { + break history; + } + assert!( + tokio::time::Instant::now() < deadline, + "zero timer never fired: {history}" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + }; + for field in ["timer_id", "sequence", "delay_seconds", "fire_at"] { + assert_eq!( + history["events"][2]["payload"][field], + history["events"][3]["payload"][field] + ); + } + assert_eq!(history["events"][2]["payload"]["delay_seconds"], 0); + let resumed = claim(&app, "alpha", "zero-timer-wakeup").await; + assert_eq!(resumed["run_id"], run["run_id"]); + assert_eq!( + finish( + &app, + "alpha", + &resumed, + json!([{"type":"complete_workflow","result":envelope(Payload::Null)}]) + ) + .await + .0, + StatusCode::OK + ); + runtime.close().await; + database.remove().await; +} diff --git a/rust/tests/execution/namespaces.rs b/rust/tests/execution/namespaces.rs index a6c513e15..21449e985 100644 --- a/rust/tests/execution/namespaces.rs +++ b/rust/tests/execution/namespaces.rs @@ -281,7 +281,7 @@ async fn named_idle_query_poll_preserves_same_id_default_namespace_claim() { idle.1["task"].is_null(), "named poll must not replay another namespace's same-ID lease" ); - assert_eq!(poll(&app, "shared-query-worker", "query").await, original); + assert_query_replay(&original, poll(&app, "shared-query-worker", "query").await); assert_eq!(request(&app,"POST", &format!("/api/worker/query-tasks/{}/complete",original["query_task_id"].as_str().unwrap()), json!({"lease_owner":"shared-query-worker","query_task_attempt":original["query_task_attempt"],"result_envelope":result})).await.0, @@ -531,7 +531,9 @@ async fn same_worker_id_isolated_claims_completion_and_deregistration_by_namespa &path, completion( &first_task, - json!([{"type":"start_timer","delay_seconds":0}]), + // Timers have separate positive namespace coverage. Child commands + // remain outside this namespace slice and must fail atomically. + json!([child_command(9007199254740993)]), ), ) .await; diff --git a/rust/tests/execution/query_authority.rs b/rust/tests/execution/query_authority.rs new file mode 100644 index 000000000..21737681f --- /dev/null +++ b/rust/tests/execution/query_authority.rs @@ -0,0 +1,1122 @@ +//! The published query-authority contract is independent of activity and +//! workflow completion receipts. No business history is changed by a query. +use super::*; +use chrono::{DateTime, SecondsFormat, Utc}; +use std::time::Duration; +use tokio::task::JoinHandle; + +struct Original { + database: TestDatabase, + runtime: Runtime, + app: Router, + claim: Value, + waiting: JoinHandle<(StatusCode, Value)>, + history_path: String, + history: Value, +} + +async fn original(name: &str) -> Original { + configured_original(name, None).await +} + +async fn configured_original( + name: &str, + config: Option, +) -> Original { + let database = TestDatabase::new().await; + let mut runtime = database.open().await.unwrap(); + if let Some(config) = config { + runtime = runtime.with_query_task_config(config); + } + let app = router(runtime.clone()); + query_worker(&app, "authority-worker").await; + let started = start(&app, name).await; + let task = poll(&app, "authority-worker", "workflow").await; + let completed = request( + &app, + "POST", + &format!( + "/api/worker/workflow-tasks/{}/complete", + task["task_id"].as_str().unwrap() + ), + completion( + &task, + json!([{"type":"complete_workflow","result":envelope(Payload::Long(7))}]), + ), + ) + .await; + assert_eq!(completed.0, StatusCode::OK, "{}", completed.1); + let history_path = format!( + "/api/workflows/{name}/runs/{}/history", + started["run_id"].as_str().unwrap() + ); + let history = request(&app, "GET", &history_path, Value::Null).await.1; + let waiting = { + let app = app.clone(); + let path = format!("/api/workflows/{name}/query/state"); + tokio::spawn(async move { + request( + &app, + "POST", + &path, + json!({"input":envelope(Payload::Array(vec![Payload::Long(7)]))}), + ) + .await + }) + }; + let claim = await_query(&app, "authority-worker").await; + Original { + database, + runtime, + app, + claim, + waiting, + history_path, + history, + } +} + +fn instant(value: &Value) -> DateTime { + let raw = value.as_str().expect("original query authority timestamp"); + let parsed = DateTime::parse_from_rfc3339(raw) + .unwrap() + .with_timezone(&Utc); + assert_eq!(raw, parsed.to_rfc3339_opts(SecondsFormat::Micros, true)); + parsed +} + +fn submitted(claim: &Value) -> Value { + json!({"lease_owner":"authority-worker", "query_task_attempt":claim["query_task_attempt"], + "result_envelope":envelope(Payload::Array(vec![Payload::Long(9007199254740993),Payload::Null,Payload::String("rangeλ".into())]))}) +} + +async fn cache_snapshot(database: &TestDatabase, id: &str) -> (String, i64) { + let key = format!("dw-native-query:{id}"); + macro_rules! read { + ($pool:expr,$sql:literal) => {{ + let pool = $pool; + let snapshot = sqlx::query_as($sql) + .bind(&key) + .fetch_one(&pool) + .await + .unwrap(); + pool.close().await; + snapshot + }}; + } + match database { + TestDatabase::Sqlite(directory) => read!( + sqlx::SqlitePool::connect(&format!( + "sqlite://{}", + directory.path().join("runtime.sqlite").display() + )) + .await + .unwrap(), + "SELECT value,expiration FROM dw_query_cache WHERE \"key\"=$1" + ), + TestDatabase::Postgres { options, .. } => read!( + sqlx::postgres::PgPoolOptions::new() + .connect_with(options.as_ref().clone()) + .await + .unwrap(), + "SELECT value,expiration FROM dw_query_cache WHERE \"key\"=$1" + ), + TestDatabase::MySql { options, .. } => read!( + sqlx::mysql::MySqlPoolOptions::new() + .connect_with(options.as_ref().clone()) + .await + .unwrap(), + "SELECT value,expiration FROM dw_query_cache WHERE `key`=?" + ), + } +} + +async fn replace_cache(database: &TestDatabase, id: &str, task: &Value, expires: i64) { + let key = format!("dw-native-query:{id}"); + macro_rules! write { + ($pool:expr,$sql:literal) => {{ + let pool = $pool; + assert_eq!( + sqlx::query($sql) + .bind(task.to_string()) + .bind(expires) + .bind(&key) + .execute(&pool) + .await + .unwrap() + .rows_affected(), + 1 + ); + pool.close().await; + }}; + } + match database { + TestDatabase::Sqlite(directory) => write!( + sqlx::SqlitePool::connect(&format!( + "sqlite://{}", + directory.path().join("runtime.sqlite").display() + )) + .await + .unwrap(), + "UPDATE dw_query_cache SET value=$1,expiration=$2 WHERE \"key\"=$3" + ), + TestDatabase::Postgres { options, .. } => write!( + sqlx::postgres::PgPoolOptions::new() + .connect_with(options.as_ref().clone()) + .await + .unwrap(), + "UPDATE dw_query_cache SET value=$1,expiration=$2 WHERE \"key\"=$3" + ), + TestDatabase::MySql { options, .. } => write!( + sqlx::mysql::MySqlPoolOptions::new() + .connect_with(options.as_ref().clone()) + .await + .unwrap(), + "UPDATE dw_query_cache SET value=?,expiration=? WHERE `key`=?" + ), + } +} + +async fn observation(app: &Router, id: &str, owner: &str, attempt: i64) -> (StatusCode, Value) { + let response = admission::admission_http( + app, + "GET", + &format!( + "/api/worker/query-tasks/{id}/status?lease_owner={owner}&query_task_attempt={attempt}" + ), + true, + Some("test-token"), + Some("1.20"), + None, + Value::Null, + ) + .await; + (response.0, response.2) +} + +enum HeldWriter { + Sqlite(sqlx::SqliteConnection), + Postgres(sqlx::PgConnection), + MySql(sqlx::MySqlConnection), +} + +impl HeldWriter { + async fn acquire(database: &TestDatabase) -> Self { + use sqlx::Connection; + match database { + TestDatabase::Sqlite(directory) => { + let mut connection = sqlx::SqliteConnection::connect(&format!( + "sqlite://{}", + directory.path().join("runtime.sqlite").display() + )) + .await + .unwrap(); + sqlx::query("BEGIN IMMEDIATE") + .execute(&mut connection) + .await + .unwrap(); + Self::Sqlite(connection) + } + TestDatabase::Postgres { options, .. } => { + let mut connection = sqlx::PgConnection::connect_with(options).await.unwrap(); + sqlx::query("BEGIN").execute(&mut connection).await.unwrap(); + sqlx::query("SELECT pg_advisory_xact_lock(4924501473315676163)") + .execute(&mut connection) + .await + .unwrap(); + Self::Postgres(connection) + } + TestDatabase::MySql { options, .. } => { + let mut connection = sqlx::MySqlConnection::connect_with(options).await.unwrap(); + sqlx::raw_sql("START TRANSACTION") + .execute(&mut connection) + .await + .unwrap(); + sqlx::query("SELECT engine FROM dw_server_schema FOR UPDATE") + .execute(&mut connection) + .await + .unwrap(); + Self::MySql(connection) + } + } + } + + async fn release(self) { + match self { + Self::Sqlite(mut connection) => { + sqlx::query("ROLLBACK") + .execute(&mut connection) + .await + .unwrap(); + } + Self::Postgres(mut connection) => { + sqlx::query("ROLLBACK") + .execute(&mut connection) + .await + .unwrap(); + } + Self::MySql(mut connection) => { + sqlx::raw_sql("ROLLBACK") + .execute(&mut connection) + .await + .unwrap(); + } + } + } +} + +#[tokio::test] +async fn concurrent_identical_failure_has_one_decision_and_conflicts_stay_refused() { + let original = original("query-authority-failed-receipt").await; + let id = original.claim["query_task_id"].as_str().unwrap(); + let path = format!("/api/worker/query-tasks/{id}/fail"); + let body = json!({"lease_owner":"authority-worker","query_task_attempt":original.claim["query_task_attempt"], + "failure":{"reason":"application_refused","message":"rangeλ","details":{"value":9007199254740993_i64,"nil":null}}}); + let (first, second) = tokio::join!( + request(&original.app, "POST", &path, body.clone()), + request(&original.app, "POST", &path, body.clone()) + ); + assert_eq!( + (first.0, second.0), + (StatusCode::OK, StatusCode::OK), + "{first:?} {second:?}" + ); + let mut recorded = [ + first.1["recorded"].as_bool().unwrap(), + second.1["recorded"].as_bool().unwrap(), + ]; + recorded.sort(); + assert_eq!(recorded, [false, true]); + assert_eq!(first.1["outcome"], "failed"); + let caller = original.waiting.await.unwrap(); + assert_eq!(caller.0, StatusCode::CONFLICT, "{}", caller.1); + assert_eq!(caller.1["reason"], "application_refused"); + let snapshot = cache_snapshot(&original.database, id).await; + let mut wrong_owner = body.clone(); + wrong_owner["lease_owner"] = json!("other-worker"); + for changed in [wrong_owner, Value::Null] { + // Different operations and claims cannot borrow an earlier decision. + let (route, payload, reason) = if changed.is_null() { + ( + format!("/api/worker/query-tasks/{id}/complete"), + submitted(&original.claim), + "query_completion_conflict", + ) + } else { + (path.clone(), changed, "query_task_not_leased") + }; + let refused = request(&original.app, "POST", &route, payload).await; + assert_eq!(refused.0, StatusCode::CONFLICT, "{}", refused.1); + assert_eq!(refused.1["reason"], reason); + } + let status = observation(&original.app, id, "authority-worker", 1).await; + assert_eq!(status.0, StatusCode::OK, "{}", status.1); + assert_eq!(status.1["can_continue"], false); + assert_eq!(status.1["reason"], "query_task_not_leased"); + assert_eq!(cache_snapshot(&original.database, id).await, snapshot); + assert_eq!( + request(&original.app, "GET", &original.history_path, Value::Null) + .await + .1, + original.history + ); + original.runtime.close().await; + original.database.remove().await; +} + +#[tokio::test] +async fn status_checks_auth_namespace_and_claim_before_revealing_original_timing() { + let original = original("query-authority-private-observation").await; + let id = original.claim["query_task_id"].as_str().unwrap(); + let path = format!( + "/api/worker/query-tasks/{id}/status?lease_owner=authority-worker&query_task_attempt=1" + ); + let snapshot = cache_snapshot(&original.database, id).await; + let unauthorized = admission::admission_http( + &original.app, + "GET", + &path, + true, + None, + Some("1.20"), + Some("ghost"), + Value::Null, + ) + .await; + assert_eq!(unauthorized.0, StatusCode::UNAUTHORIZED); + namespaces::create(&original.app, "alpha").await; + let foreign = admission::admission_http( + &original.app, + "GET", + &path, + true, + Some("test-token"), + Some("1.20"), + Some("alpha"), + Value::Null, + ) + .await; + assert_eq!(foreign.0, StatusCode::NOT_FOUND, "{}", foreign.2); + assert!(foreign.2.get("query_deadline_at").is_none()); + for (owner, attempt, reason) in [ + ("other-worker", 1, "lease_owner_mismatch"), + ("authority-worker", 2, "query_task_attempt_mismatch"), + ] { + let refused = observation(&original.app, id, owner, attempt).await; + assert_eq!(refused.0, StatusCode::CONFLICT, "{}", refused.1); + assert_eq!(refused.1["reason"], reason); + assert!(refused.1.get("query_deadline_at").is_none()); + } + assert_eq!(cache_snapshot(&original.database, id).await, snapshot); + let complete = request( + &original.app, + "POST", + &format!("/api/worker/query-tasks/{id}/complete"), + submitted(&original.claim), + ) + .await; + assert_eq!(complete.0, StatusCode::OK, "{}", complete.1); + assert_eq!(original.waiting.await.unwrap().0, StatusCode::OK); + original.runtime.close().await; + original.database.remove().await; +} + +#[tokio::test] +async fn unavailable_legacy_deadline_gets_only_one_fixed_refusal_tombstone() { + legacy_refusal(false).await; + legacy_refusal(true).await; +} + +async fn legacy_refusal(missing_retention: bool) { + let original = original("query-authority-legacy-refusal").await; + original.waiting.abort(); + assert!(original.waiting.await.unwrap_err().is_cancelled()); + let id = original.claim["query_task_id"].as_str().unwrap(); + let (raw, expires) = cache_snapshot(&original.database, id).await; + let mut legacy: Value = serde_json::from_str(&raw).unwrap(); + legacy.as_object_mut().unwrap().remove("query_deadline_at"); + if missing_retention { + legacy.as_object_mut().unwrap().remove("receipt_expires_at"); + } + legacy["deadline"] = json!(9999999999_i64); + replace_cache(&original.database, id, &legacy, expires).await; + let before = cache_snapshot(&original.database, id).await; + let status = observation(&original.app, id, "authority-worker", 1).await; + assert_eq!(status.0, StatusCode::OK, "{}", status.1); + assert_eq!(status.1["can_continue"], false); + assert_eq!(status.1["reason"], "query_deadline_unavailable"); + assert_eq!( + cache_snapshot(&original.database, id).await, + before, + "status must not create a tombstone" + ); + let path = format!("/api/worker/query-tasks/{id}/complete"); + let refused = request(&original.app, "POST", &path, submitted(&original.claim)).await; + assert_eq!(refused.0, StatusCode::CONFLICT, "{}", refused.1); + assert_eq!(refused.1["reason"], "query_deadline_unavailable"); + let fixed = cache_snapshot(&original.database, id).await; + let tombstone: Value = serde_json::from_str(&fixed.0).unwrap(); + if missing_retention { + assert_eq!( + instant(&tombstone["receipt_expires_at"]) - instant(&tombstone["legacy_refusal_at"]), + chrono::Duration::seconds(60) + ); + } else { + assert_eq!( + tombstone["receipt_expires_at"], + legacy["receipt_expires_at"] + ); + } + assert_eq!(tombstone["query_admitted_at"], legacy["query_admitted_at"]); + assert!(tombstone.get("query_deadline_at").is_none()); + assert!(tombstone.get("terminal_receipt").is_none()); + assert!(tombstone.get("history_events").is_none()); + tokio::time::sleep(Duration::from_millis(20)).await; + let again = request(&original.app, "POST", &path, submitted(&original.claim)).await; + assert_eq!(again.0, StatusCode::CONFLICT, "{}", again.1); + assert_eq!(again.1["reason"], "query_deadline_unavailable"); + assert_eq!(cache_snapshot(&original.database, id).await, fixed); + assert!( + poll(&original.app, "authority-worker", "query") + .await + .is_null() + ); + assert_eq!(cache_snapshot(&original.database, id).await, fixed); + assert_eq!( + request(&original.app, "GET", &original.history_path, Value::Null) + .await + .1, + original.history + ); + original.runtime.close().await; + original.database.remove().await; +} + +#[tokio::test] +async fn expired_original_refuses_before_decoding_and_status_does_not_extend_it() { + let config = durable_workflow_server::runtime::QueryTaskConfig { + timeout_seconds: 2, + lease_timeout_seconds: 2, + ..Default::default() + }; + let original = configured_original("query-authority-expired-original", Some(config)).await; + let id = original.claim["query_task_id"].as_str().unwrap(); + let caller = original.waiting.await.unwrap(); + assert_eq!(caller.0, StatusCode::GATEWAY_TIMEOUT); + assert_eq!(caller.1["reason"], "query_worker_execution_timeout"); + let snapshot = cache_snapshot(&original.database, id).await; + let status = observation(&original.app, id, "authority-worker", 1).await; + assert_eq!(status.0, StatusCode::OK, "{}", status.1); + assert_eq!(status.1["can_continue"], false); + assert_eq!(status.1["reason"], "query_task_timed_out"); + for field in ["query_admitted_at", "query_deadline_at", "lease_expires_at"] { + assert_eq!(status.1[field], original.claim[field]); + } + assert_eq!(cache_snapshot(&original.database, id).await, snapshot); + let mut body = submitted(&original.claim); + body["result_envelope"] = json!({"codec":"avro","blob":"invalid-avro"}); + let refused = request( + &original.app, + "POST", + &format!("/api/worker/query-tasks/{id}/complete"), + body, + ) + .await; + assert_eq!(refused.0, StatusCode::CONFLICT, "{}", refused.1); + assert_eq!(refused.1["reason"], "query_task_timed_out"); + let (raw, _) = cache_snapshot(&original.database, id).await; + let expired: Value = serde_json::from_str(&raw).unwrap(); + let before: Value = serde_json::from_str(&snapshot.0).unwrap(); + assert_eq!(expired["status"], "timed_out"); + for field in [ + "query_admitted_at", + "query_deadline_at", + "receipt_expires_at", + ] { + assert_eq!(expired[field], before[field]); + } + assert_eq!( + request(&original.app, "GET", &original.history_path, Value::Null) + .await + .1, + original.history + ); + original.runtime.close().await; + original.database.remove().await; +} + +#[tokio::test] +async fn waiting_poll_cannot_claim_for_a_same_id_replacement_registration() { + let original = original("query-authority-poll-incarnation").await; + let id = original.claim["query_task_id"].as_str().unwrap(); + let finished = request( + &original.app, + "POST", + &format!("/api/worker/query-tasks/{id}/complete"), + submitted(&original.claim), + ) + .await; + assert_eq!(finished.0, StatusCode::OK, "{}", finished.1); + assert_eq!(original.waiting.await.unwrap().0, StatusCode::OK); + let waiting_poll = { + let app = original.app.clone(); + tokio::spawn(async move { + request( + &app, + "POST", + "/api/worker/query-tasks/poll", + json!({"worker_id":"authority-worker","task_queue":"test","timeout_seconds":5}), + ) + .await + }) + }; + // Wait through multiple empty poll probes before replacing the already + // registered worker. This request belongs to the old registration. + tokio::time::sleep(Duration::from_millis(150)).await; + query_worker(&original.app, "authority-worker").await; + let stale = tokio::time::timeout(Duration::from_secs(2), waiting_poll) + .await + .expect("a replaced poll must refuse without waiting for new work") + .unwrap(); + assert_eq!(stale.0, StatusCode::OK, "{}", stale.1); + assert_eq!(stale.1["poll_status"], "stale_worker_registration"); + assert!(stale.1["task"].is_null()); + let waiting = { + let app = original.app.clone(); + tokio::spawn(async move { + request( + &app, + "POST", + "/api/workflows/query-authority-poll-incarnation/query/state", + json!({"input":envelope(Payload::Array(vec![Payload::Long(9)]))}), + ) + .await + }) + }; + let replacement = await_query(&original.app, "authority-worker").await; + assert_eq!(replacement["query_task_attempt"], 1); + let complete = request( + &original.app, + "POST", + &format!( + "/api/worker/query-tasks/{}/complete", + replacement["query_task_id"].as_str().unwrap() + ), + submitted(&replacement), + ) + .await; + assert_eq!(complete.0, StatusCode::OK, "{}", complete.1); + assert_eq!(waiting.await.unwrap().0, StatusCode::OK); + assert_eq!( + request(&original.app, "GET", &original.history_path, Value::Null) + .await + .1, + original.history + ); + original.runtime.close().await; + original.database.remove().await; +} + +#[tokio::test] +async fn delivery_and_status_keep_original_microseconds_without_renewal() { + let original = original("query-authority-observation").await; + let admitted = instant(&original.claim["query_admitted_at"]); + let deadline = instant(&original.claim["query_deadline_at"]); + let lease = instant(&original.claim["lease_expires_at"]); + let served = instant(&original.claim["server_time"]); + let cached = cache_snapshot( + &original.database, + original.claim["query_task_id"].as_str().unwrap(), + ) + .await; + assert!(admitted <= served && served < deadline && lease <= deadline); + let status_path = format!( + "/api/worker/query-tasks/{}/status?lease_owner=authority-worker&query_task_attempt={}", + original.claim["query_task_id"].as_str().unwrap(), + original.claim["query_task_attempt"] + ); + for _ in 0..2 { + let (status, _, observation) = admission::admission_http( + &original.app, + "GET", + &status_path, + true, + Some("test-token"), + Some("1.20"), + None, + Value::Null, + ) + .await; + assert_eq!(status, StatusCode::OK, "{observation}"); + for field in [ + "query_task_id", + "query_task_attempt", + "lease_owner", + "query_admitted_at", + "query_deadline_at", + "lease_expires_at", + ] { + assert_eq!(observation[field], original.claim[field], "{field}"); + } + assert_eq!(observation["can_continue"], true); + assert_eq!(observation["query_task_status"], "leased"); + assert!(instant(&observation["server_time"]) >= served); + tokio::time::sleep(Duration::from_millis(10)).await; + } + let legacy = admission::admission_http( + &original.app, + "GET", + &status_path, + true, + Some("test-token"), + Some("1.19"), + None, + Value::Null, + ) + .await; + assert_eq!(legacy.0, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(legacy.2["reason"], "query_task_status_unavailable"); + assert_eq!( + cache_snapshot( + &original.database, + original.claim["query_task_id"].as_str().unwrap() + ) + .await, + cached + ); + let finished = request( + &original.app, + "POST", + &format!( + "/api/worker/query-tasks/{}/complete", + original.claim["query_task_id"].as_str().unwrap() + ), + submitted(&original.claim), + ) + .await; + assert_eq!(finished.0, StatusCode::OK, "{}", finished.1); + assert_eq!(original.waiting.await.unwrap().0, StatusCode::OK); + assert_eq!( + request(&original.app, "GET", &original.history_path, Value::Null) + .await + .1, + original.history + ); + original.runtime.close().await; + original.database.remove().await; +} + +#[tokio::test] +async fn identical_committed_result_reconciles_before_and_after_original_expiry() { + let config = durable_workflow_server::runtime::QueryTaskConfig { + timeout_seconds: 4, + lease_timeout_seconds: 4, + ..Default::default() + }; + let original = configured_original("query-authority-receipt", Some(config)).await; + let path = format!( + "/api/worker/query-tasks/{}/complete", + original.claim["query_task_id"].as_str().unwrap() + ); + let body = submitted(&original.claim); + let first = request(&original.app, "POST", &path, body.clone()).await; + assert_eq!(first.0, StatusCode::OK, "{}", first.1); + assert_eq!(first.1["recorded"], true); + assert_eq!(original.waiting.await.unwrap().0, StatusCode::OK); + let cache_before = cache_snapshot( + &original.database, + original.claim["query_task_id"].as_str().unwrap(), + ) + .await; + let writer = HeldWriter::acquire(&original.database).await; + let before = tokio::time::timeout( + Duration::from_millis(500), + request(&original.app, "POST", &path, body.clone()), + ) + .await + .expect("retained query receipt must reconcile without writer admission"); + assert_eq!(before.0, StatusCode::OK, "{}", before.1); + assert_eq!(before.1["recorded"], false); + let mut conflicting = body.clone(); + conflicting["result_envelope"] = json!({"codec":"avro","blob":"not-the-committed-bytes"}); + let refused = tokio::time::timeout( + Duration::from_millis(500), + request(&original.app, "POST", &path, conflicting.clone()), + ) + .await + .expect("conflicting receipt must refuse without writer admission"); + assert_eq!(refused.0, StatusCode::CONFLICT, "{}", refused.1); + assert_eq!(refused.1["reason"], "query_completion_conflict"); + let remaining = instant(&original.claim["query_deadline_at"]) - Utc::now(); + tokio::time::sleep(remaining.to_std().unwrap_or_default() + Duration::from_millis(20)).await; + let after = tokio::time::timeout( + Duration::from_millis(500), + request(&original.app, "POST", &path, body), + ) + .await + .expect("original expiry must not add writer admission to a retained receipt"); + assert_eq!(after.0, StatusCode::OK, "{}", after.1); + assert_eq!(after.1["recorded"], false); + let refused = tokio::time::timeout( + Duration::from_millis(500), + request(&original.app, "POST", &path, conflicting), + ) + .await + .expect("expired conflicting receipt must also refuse without writer admission"); + assert_eq!(refused.0, StatusCode::CONFLICT, "{}", refused.1); + assert_eq!(refused.1["reason"], "query_completion_conflict"); + assert_eq!( + cache_snapshot( + &original.database, + original.claim["query_task_id"].as_str().unwrap() + ) + .await, + cache_before + ); + writer.release().await; + assert_eq!( + request(&original.app, "GET", &original.history_path, Value::Null) + .await + .1, + original.history + ); + original.runtime.close().await; + original.database.remove().await; +} + +#[tokio::test] +async fn original_query_deadline_releases_continuation_without_consuming_its_snapshot() { + let database = TestDatabase::new().await; + let runtime = database.open().await.unwrap().with_query_task_config( + durable_workflow_server::runtime::QueryTaskConfig { + timeout_seconds: 4, + lease_timeout_seconds: 4, + ..Default::default() + }, + ); + let app = router(runtime.clone()); + query_worker(&app, "authority-worker").await; + let started = start(&app, "query-authority-continuation").await; + let initial = poll(&app, "authority-worker", "workflow").await; + assert_eq!( + request( + &app, + "POST", + &format!( + "/api/worker/workflow-tasks/{}/complete", + initial["task_id"].as_str().unwrap() + ), + completion(&initial, json!([{"type":"start_timer","delay_seconds":1}])), + ) + .await + .0, + StatusCode::OK + ); + let waiting = { + let app = app.clone(); + tokio::spawn(async move { + request( + &app, + "POST", + "/api/workflows/query-authority-continuation/query/state", + json!({"input":envelope(Payload::Array(vec![Payload::Long(7)]))}), + ) + .await + }) + }; + let claim = await_query(&app, "authority-worker").await; + let id = claim["query_task_id"].as_str().unwrap(); + let snapshot = cache_snapshot(&database, id).await; + let resumed = loop { + let resumed = poll(&app, "authority-worker", "workflow").await; + if !resumed.is_null() { + break resumed; + } + assert!( + Utc::now() < instant(&claim["query_deadline_at"]), + "timer did not produce a genuine workflow lease within the original query deadline" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + }; + let path = format!( + "/api/worker/workflow-tasks/{}/complete", + resumed["task_id"].as_str().unwrap() + ); + let body = completion(&resumed, json!([{"type":"continue_as_new"}])); + let live = request(&app, "POST", &path, body.clone()).await; + assert_eq!(live.0, StatusCode::CONFLICT, "{}", live.1); + assert_eq!(live.1["reason"], "continue_as_new_transfer_unqualified"); + assert_eq!(cache_snapshot(&database, id).await, snapshot); + let caller = waiting.await.unwrap(); + assert_eq!(caller.0, StatusCode::GATEWAY_TIMEOUT); + // No query poll or outcome has swept the retained claim. Its exact bytes + // and SQL expiry must survive continuation on the same workflow lease. + assert_eq!(cache_snapshot(&database, id).await, snapshot); + let continued = request(&app, "POST", &path, body).await; + assert_eq!(continued.0, StatusCode::OK, "{}", continued.1); + assert_eq!(cache_snapshot(&database, id).await, snapshot); + let runs = request( + &app, + "GET", + "/api/workflows/query-authority-continuation/runs", + Value::Null, + ) + .await; + assert_eq!(runs.1["runs"].as_array().unwrap().len(), 2); + assert_eq!(runs.1["runs"][0]["run_id"], started["run_id"]); + let late = request( + &app, + "POST", + &format!("/api/worker/query-tasks/{id}/complete"), + submitted(&claim), + ) + .await; + assert_eq!(late.0, StatusCode::CONFLICT, "{}", late.1); + assert_eq!(late.1["reason"], "query_task_timed_out"); + let successor = poll(&app, "authority-worker", "workflow").await; + assert_ne!(successor["run_id"], started["run_id"]); + assert_eq!( + request( + &app, + "POST", + &format!( + "/api/worker/workflow-tasks/{}/complete", + successor["task_id"].as_str().unwrap() + ), + completion( + &successor, + json!([{"type":"complete_workflow","result":envelope(Payload::Long(7))}]) + ) + ) + .await + .0, + StatusCode::OK + ); + runtime.close().await; + database.remove().await; +} + +#[tokio::test] +async fn zero_is_immediate_and_later_policy_does_not_rebase_an_existing_query() { + let original = original("query-authority-policy").await; + let changed = router(original.runtime.clone().with_query_task_config( + durable_workflow_server::runtime::QueryTaskConfig { + timeout_seconds: 0, + receipt_ttl_seconds: 60, + ..Default::default() + }, + )); + let expired = request( + &changed, + "POST", + "/api/workflows/query-authority-policy/query/state", + json!({"input":envelope(Payload::Array(vec![Payload::Long(8)]))}), + ) + .await; + assert_eq!(expired.0, StatusCode::GATEWAY_TIMEOUT, "{}", expired.1); + assert_eq!(expired.1["reason"], "query_task_not_claimed"); + assert_query_replay( + &original.claim, + poll(&changed, "authority-worker", "query").await, + ); + let complete_path = format!( + "/api/worker/query-tasks/{}/complete", + original.claim["query_task_id"].as_str().unwrap() + ); + assert_eq!( + request(&changed, "POST", &complete_path, submitted(&original.claim)) + .await + .0, + StatusCode::OK + ); + assert_eq!(original.waiting.await.unwrap().0, StatusCode::OK); + assert_eq!( + request(&changed, "GET", &original.history_path, Value::Null) + .await + .1, + original.history + ); + original.runtime.close().await; + original.database.remove().await; +} + +#[tokio::test] +async fn same_worker_id_across_namespaces_and_replacement_keep_separate_originals() { + let original = original("query-authority-default").await; + namespaces::create(&original.app, "alpha").await; + let definition = json!({"worker_id":"authority-worker","task_queue":"test","runtime":"php", + "supported_workflow_types":["echo"],"supported_activity_types":[],"capabilities":["query_tasks"], + "workflow_command_contracts":{"echo":{"queries":["state"],"query_contracts":[{"name":"state", + "parameters":[{"name":"request","position":0,"type":"int","required":true, + "variadic":false,"default_available":false,"allows_null":false}]}]}}}); + assert_eq!( + namespaces::scoped( + &original.app, + "alpha", + "POST", + "/api/worker/register", + definition.clone() + ) + .await + .0, + StatusCode::CREATED + ); + let started = namespaces::scoped( + &original.app, + "alpha", + "POST", + "/api/workflows", + json!({ + "workflow_id":"query-authority-alpha","workflow_type":"echo","task_queue":"test", + "input":envelope(Payload::Array(vec![Payload::Long(7)]))}), + ) + .await; + assert_eq!(started.0, StatusCode::CREATED, "{}", started.1); + let task = namespaces::scoped( + &original.app, + "alpha", + "POST", + "/api/worker/workflow-tasks/poll", + json!({"worker_id":"authority-worker","task_queue":"test","timeout_seconds":0}), + ) + .await + .1["task"] + .clone(); + assert!(!task.is_null()); + assert_eq!( + namespaces::scoped( + &original.app, + "alpha", + "POST", + &format!( + "/api/worker/workflow-tasks/{}/complete", + task["task_id"].as_str().unwrap() + ), + completion( + &task, + json!([{"type":"complete_workflow","result":envelope(Payload::Long(9))}]) + ) + ) + .await + .0, + StatusCode::OK + ); + let alpha_history_path = format!( + "/api/workflows/query-authority-alpha/runs/{}/history", + started.1["run_id"].as_str().unwrap() + ); + let alpha_history = namespaces::scoped( + &original.app, + "alpha", + "GET", + &alpha_history_path, + Value::Null, + ) + .await; + let alpha_waiting = { + let app = original.app.clone(); + tokio::spawn(async move { + namespaces::scoped( + &app, + "alpha", + "POST", + "/api/workflows/query-authority-alpha/query/state", + json!({"input":envelope(Payload::Array(vec![Payload::Long(9)]))}), + ) + .await + }) + }; + let alpha_claim = tokio::time::timeout(Duration::from_secs(5), async { + loop { + let response = namespaces::scoped( + &original.app, + "alpha", + "POST", + "/api/worker/query-tasks/poll", + json!({"worker_id":"authority-worker","task_queue":"test","timeout_seconds":0}), + ) + .await; + assert_eq!(response.0, StatusCode::OK, "{}", response.1); + if !response.1["task"].is_null() { + break response.1["task"].clone(); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + assert_eq!(alpha_claim["namespace"], "alpha"); + assert_ne!( + alpha_claim["query_task_id"], + original.claim["query_task_id"] + ); + let default_complete = format!( + "/api/worker/query-tasks/{}/complete", + original.claim["query_task_id"].as_str().unwrap() + ); + let foreign = namespaces::scoped( + &original.app, + "alpha", + "POST", + &default_complete, + submitted(&original.claim), + ) + .await; + assert_eq!(foreign.0, StatusCode::NOT_FOUND, "{}", foreign.1); + let alpha_complete = format!( + "/api/worker/query-tasks/{}/complete", + alpha_claim["query_task_id"].as_str().unwrap() + ); + assert_eq!( + request( + &original.app, + "POST", + &alpha_complete, + submitted(&alpha_claim) + ) + .await + .0, + StatusCode::NOT_FOUND + ); + assert_eq!( + namespaces::scoped( + &original.app, + "alpha", + "POST", + "/api/worker/register", + definition + ) + .await + .0, + StatusCode::CREATED + ); + let old = namespaces::scoped( + &original.app, + "alpha", + "POST", + &alpha_complete, + submitted(&alpha_claim), + ) + .await; + assert_eq!(old.0, StatusCode::CONFLICT, "{}", old.1); + assert_eq!(old.1["reason"], "stale_worker_registration"); + let replacement = namespaces::scoped( + &original.app, + "alpha", + "POST", + "/api/worker/query-tasks/poll", + json!({"worker_id":"authority-worker","task_queue":"test","timeout_seconds":0}), + ) + .await + .1["task"] + .clone(); + assert_eq!(replacement["query_task_id"], alpha_claim["query_task_id"]); + assert_eq!(replacement["query_task_attempt"], 2); + for field in ["query_admitted_at", "query_deadline_at", "history_events"] { + assert_eq!(replacement[field], alpha_claim[field], "{field}"); + } + assert_eq!( + namespaces::scoped( + &original.app, + "alpha", + "POST", + &alpha_complete, + submitted(&replacement) + ) + .await + .0, + StatusCode::OK + ); + assert_eq!(alpha_waiting.await.unwrap().0, StatusCode::OK); + assert_eq!( + request( + &original.app, + "POST", + &default_complete, + submitted(&original.claim) + ) + .await + .0, + StatusCode::OK + ); + assert_eq!(original.waiting.await.unwrap().0, StatusCode::OK); + assert_eq!( + namespaces::scoped( + &original.app, + "alpha", + "GET", + &alpha_history_path, + Value::Null + ) + .await, + alpha_history + ); + assert_eq!( + request(&original.app, "GET", &original.history_path, Value::Null) + .await + .1, + original.history + ); + original.runtime.close().await; + original.database.remove().await; +} diff --git a/scripts/conformance/server-parity/README.md b/scripts/conformance/server-parity/README.md index e42dbfefb..498782d62 100644 --- a/scripts/conformance/server-parity/README.md +++ b/scripts/conformance/server-parity/README.md @@ -19,6 +19,10 @@ assertions. Reproduce a failed scenario with its original profile and limits when omitted raw details are needed, and keep only the useful failure trace in its owning issue. +Query client failures print a bounded error tail in the job log. Their client +and observer diagnostics take precedence over completed setup logs in failed +artifacts, within the same file, byte and entry limits. + Native cluster discovery publishes the same carrier auth-composition v1 contract as the frozen PHP reference. Published workers validate this manifest before registration; it declares client setting precedence and secret redaction, diff --git a/scripts/conformance/server-parity/artifact-summary.mjs b/scripts/conformance/server-parity/artifact-summary.mjs index 25fa669e7..91b58da86 100644 --- a/scripts/conformance/server-parity/artifact-summary.mjs +++ b/scripts/conformance/server-parity/artifact-summary.mjs @@ -9,6 +9,7 @@ const ENTRY_LIMIT = 128; function priority(path, failed) { const name = basename(path); if (/\.status$/.test(name) || /-status\.txt$/.test(name)) return -1; + if (failed && /(?:^|\/)query-authority\/clients\/(?:php|rust)\/[^/]+\/(?:client\.stderr|observer\.log)$/.test(path)) return 0; if (/^(rust|mysql|postgres|comparator|codec-load|artifact-summary)(-execution)?-tests\.txt$/.test(name)) return 0; if (/^(comparison|compare)([-.].*)?\.json$/.test(name) || /-comparison\.json$/.test(name)) return 1; if (failed && (/^(?:php|embedded|rust)-record\.txt$/.test(name) diff --git a/scripts/conformance/server-parity/query-authority-adapters.sh b/scripts/conformance/server-parity/query-authority-adapters.sh new file mode 100755 index 000000000..4d777cc5b --- /dev/null +++ b/scripts/conformance/server-parity/query-authority-adapters.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Compile/install this separate published tuple once, then share runtime files. +set -euo pipefail +profile=tests/Fixtures/ServerParityProfiles/query-authority/artifacts.json +output=query-authority-adapters +cargo_home=${DW_PARITY_QUERY_CARGO:-sdk-cargo} +cargo_target=${DW_PARITY_QUERY_TARGET:-sdk-target} +test ! -e "$output" +mkdir -p "$output/python" "$cargo_home" "$cargo_target" +rust_image=$(jq -r '.client_images.rust' "$profile") +python_image=$(jq -r '.client_images.python' "$profile") +docker run --rm --user="$(id -u):$(id -g)" --cpus=2 --memory=2g --memory-swap=2g \ + -e CARGO_HOME=/cargo -e CARGO_TARGET_DIR=/target -e CARGO_BUILD_JOBS=2 \ + -e CARGO_INCREMENTAL=0 -e CARGO_PROFILE_DEV_DEBUG=0 \ + -v "$PWD:/source:ro" -v "$(realpath "$cargo_home"):/cargo" -v "$(realpath "$cargo_target"):/target" \ + -w /source/scripts/conformance/server-parity/query-authority/rust "$rust_image" \ + cargo build --locked >"$output/rust-build.txt" 2>&1 +cp "$cargo_target/debug/server-parity-query-authority-client" "$output/rust-client" +cp scripts/conformance/server-parity/query-authority/rust/Cargo.lock "$output/Cargo.lock" +docker run --rm --user="$(id -u):$(id -g)" --cpus=1 --memory=512m --memory-swap=512m \ + -v "$PWD:/source:ro" -v "$PWD/$output:/adapters" "$python_image" \ + python -m pip install --disable-pip-version-check --no-cache-dir --no-compile --only-binary=:all: --require-hashes \ + --target /adapters/python --report /adapters/python-install.json \ + -r /source/scripts/conformance/server-parity/query-authority/requirements.txt >"$output/python-install.txt" 2>&1 +docker run --rm -i --user="$(id -u):$(id -g)" --network=none --cpus=1 --memory=256m --memory-swap=256m \ + -e PYTHONDONTWRITEBYTECODE=1 -e PYTHONPATH=/adapters/python \ + -v "$PWD:/source:ro" -v "$PWD/$output:/adapters:ro" "$python_image" python - >"$output/identity.json" <<'PY' +import hashlib, importlib.metadata, json, tomllib +from pathlib import Path +import durable_workflow +profile=json.loads(Path('/source/tests/Fixtures/ServerParityProfiles/query-authority/artifacts.json').read_text()) +package=Path(durable_workflow.__file__).parent +hashes={str(path.relative_to(package)):hashlib.sha256(path.read_bytes()).hexdigest() + for path in package.rglob('*') if path.is_file() and (path.suffix=='.py' or path.name=='py.typed')} +tree=hashlib.sha256(json.dumps(hashes,sort_keys=True,separators=(',',':')).encode()).hexdigest() +assert importlib.metadata.version('durable-workflow')==profile['sdk_python']['version'] +assert len(hashes)==profile['sdk_python']['loaded_files'] and tree==profile['sdk_python']['loaded_tree_sha256'] +packages=tomllib.loads(Path('/adapters/Cargo.lock').read_text())['package'] +sdk=next(package for package in packages if package['name']=='durable-workflow') +assert sdk['version']==profile['sdk_rust']['version'] and sdk['checksum']==profile['sdk_rust']['crate_sha256'] +assert sdk['source']=='registry+https://github.com/rust-lang/crates.io-index' +print(json.dumps({'outcome':'pass','python_version':profile['sdk_python']['version'],'python_loaded_tree_sha256':tree, + 'rust_version':sdk['version'],'rust_crate_sha256':sdk['checksum']})) +PY +printf '%s\n' "$(git rev-parse HEAD)" >"$output/runner-revision.txt" +sha256sum scripts/conformance/server-parity/query-authority/{observer.mjs,python.py,requirements.txt} \ + scripts/conformance/server-parity/query-authority/rust/{Cargo.toml,Cargo.lock,src/main.rs} \ + "$profile" >"$output/source-hashes.sha256" +(cd "$output"; find . -type f ! -path ./SHA256SUMS -print0 | sort -z | xargs -0 sha256sum >SHA256SUMS) diff --git a/scripts/conformance/server-parity/query-authority-cleanup.sh b/scripts/conformance/server-parity/query-authority-cleanup.sh new file mode 100755 index 000000000..35f700a77 --- /dev/null +++ b/scripts/conformance/server-parity/query-authority-cleanup.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# Retire only this profile, including failed setup, before its database is stopped. +set -uo pipefail +backend=$1 +test -n "${RESOURCE_SCOPE:?Set the owning isolated network}" +evidence=parity-evidence/query-authority +state=parity-query-authority-state +profile=tests/Fixtures/ServerParityProfiles/query-authority/artifacts.json +test -f "$evidence/identity.json" || exit 0 +test "$(jq -r .resource_scope "$evidence/identity.json")" = "$RESOURCE_SCOPE" || exit 1 +test "$(jq -r .backend "$evidence/identity.json")" = "$backend" || exit 1 +result=0 +profile_status=$(cat "$evidence/profile-status.txt" 2>/dev/null || printf incomplete) +for target in php rust; do + for language in python rust; do + while IFS=$'\t' read -r mode protocol; do + if docker inspect "$RESOURCE_SCOPE-query-client-$target-$language-$mode-${protocol//./}" >/dev/null 2>&1; then + if docker inspect "$RESOURCE_SCOPE-query-client-$target-$language-$mode-${protocol//./}" | \ + jq -e --arg scope "$RESOURCE_SCOPE" '.[0].Config.Labels["durable-workflow.query-authority-client"]==$scope' >/dev/null; then + docker rm -f "$RESOURCE_SCOPE-query-client-$target-$language-$mode-${protocol//./}" >/dev/null || result=1 + else result=1; fi + fi + if docker inspect "$RESOURCE_SCOPE-query-client-observer-$target-$language-$mode-${protocol//./}" >/dev/null 2>&1; then + if docker inspect "$RESOURCE_SCOPE-query-client-observer-$target-$language-$mode-${protocol//./}" | \ + jq -e --arg scope "$RESOURCE_SCOPE" '.[0].Config.Labels["durable-workflow.query-authority-client"]==$scope' >/dev/null; then + docker stop --signal=SIGTERM --timeout=10 "$RESOURCE_SCOPE-query-client-observer-$target-$language-$mode-${protocol//./}" >/dev/null 2>&1 || true + docker rm -f "$RESOURCE_SCOPE-query-client-observer-$target-$language-$mode-${protocol//./}" >/dev/null || result=1 + else result=1; fi + fi + done < <(jq -r '.client_cases[]|[.mode,.protocol]|@tsv' "$profile") + done +done +if docker inspect "$RESOURCE_SCOPE-query-client-observer" >/dev/null 2>&1; then + if docker inspect "$RESOURCE_SCOPE-query-client-observer" | \ + jq -e --arg scope "$RESOURCE_SCOPE" '.[0].Config.Labels["durable-workflow.query-authority-client"]==$scope' >/dev/null; then + docker stop --signal=SIGTERM --timeout=10 "$RESOURCE_SCOPE-query-client-observer" >"$evidence/client-observer-stop.txt" 2>&1 || true + docker inspect --format '{{json .State}}' "$RESOURCE_SCOPE-query-client-observer" >"$evidence/client-observer-state.json" + jq -e '.ExitCode==0 and .OOMKilled==false and .Running==false' "$evidence/client-observer-state.json" >/dev/null || result=1 + docker rm -f "$RESOURCE_SCOPE-query-client-observer" >/dev/null || result=1 + else result=1; fi +fi +for target in php rust; do + while IFS=$'\t' read -r mode protocol; do + if docker inspect "$RESOURCE_SCOPE-query-$target-$mode-${protocol//./}" >/dev/null 2>&1; then + docker rm -f "$RESOURCE_SCOPE-query-$target-$mode-${protocol//./}" >/dev/null || result=1 + fi + done < <(jq -r '.cases[]|[.mode,.protocol]|@tsv' "$profile") +done +for target in php native; do + if docker inspect "$RESOURCE_SCOPE-query-authority-$target" >/dev/null 2>&1; then + docker stop --signal=SIGTERM --timeout=10 "$RESOURCE_SCOPE-query-authority-$target" >"$evidence/$target-stop.txt" 2>&1 || true + docker logs "$RESOURCE_SCOPE-query-authority-$target" > "$evidence/$target-server.log" 2>&1 + docker inspect --format '{{json .State}}' "$RESOURCE_SCOPE-query-authority-$target" > "$evidence/$target-state.json" + if ! jq -e '.ExitCode==0 and .OOMKilled==false and .Running==false' "$evidence/$target-state.json" >/dev/null; then + cat "$evidence/$target-state.json" + result=1 + fi + docker rm -f "$RESOURCE_SCOPE-query-authority-$target" >/dev/null || result=1 + if docker inspect "$RESOURCE_SCOPE-query-authority-$target" >/dev/null 2>&1; then result=1; fi + elif test "$profile_status" = 0; then + if ! jq -e '.ExitCode==0 and .OOMKilled==false and .Running==false' "$evidence/$target-state.json" >/dev/null 2>&1; then + printf '%s\n' "Completed query profile has no successful $target shutdown receipt" >&2 + result=1 + fi + fi +done +case "$backend" in + mysql|pgsql) + for target in php native; do + test -f "$evidence/$target-database-owned.txt" || continue + test "$(cat "$evidence/$target-database-owned.txt")" = "$RESOURCE_SCOPE" || { result=1; continue; } + database="query_authority_$target" + if test "$backend" = mysql; then + docker run --rm --user=1000:1000 --network "$RESOURCE_SCOPE" --entrypoint mysql "$MYSQL_IMAGE" \ + -h parity-mysql -uroot -pparity-disposable-password -e "DROP DATABASE IF EXISTS $database" \ + > "$evidence/$target-database-retire.txt" 2>&1 || result=1 + else + docker run --rm --user=1000:1000 --network "$RESOURCE_SCOPE" -e PGPASSWORD=parity-disposable-password \ + --entrypoint dropdb "$POSTGRES_IMAGE" --if-exists -h parity-pg -U parity "$database" \ + > "$evidence/$target-database-retire.txt" 2>&1 || result=1 + fi + done ;; + sqlite) ;; + *) exit 2 ;; +esac +if test -d "$state"; then + image=$(jq -r '.server.image' "$profile") + docker run --rm --user=1000:1000 --network=none --cpus=1 --memory=128m --memory-swap=128m \ + --entrypoint sh -v "$PWD/$state:/state" "$image" \ + -c 'find /state -mindepth 1 -depth -delete' || result=1 + rmdir -- "$state" || result=1 +fi +exit "$result" diff --git a/scripts/conformance/server-parity/query-authority-clients-profile.sh b/scripts/conformance/server-parity/query-authority-clients-profile.sh new file mode 100755 index 000000000..5db38ff71 --- /dev/null +++ b/scripts/conformance/server-parity/query-authority-clients-profile.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Run unchanged published clients behind a bounded, byte-preserving observer. +set -euo pipefail +test "$#" = 5 || exit 2 +network=$1 +url=$2 +python_sdk=$(realpath "$3") +output=$(realpath -m "$4") +target=$5 +case "$target" in php|rust) ;; *) exit 2 ;; esac +test "$network" = "${RESOURCE_SCOPE:?Set the isolated owning scope}" +source_root=$(git rev-parse --show-toplevel) +profile=tests/Fixtures/ServerParityProfiles/query-authority/artifacts.json +image=$(jq -r '.server.image' "$profile") +test ! -e "$output" +test -f "$python_sdk/durable_workflow/__init__.py" +client_binary=$(realpath "${DW_PARITY_QUERY_CLIENT_BIN:-rust-target/debug/server-parity-query-authority-client}") +test -f "$client_binary" +mkdir -p "$output" +jq -n --arg revision "$(git rev-parse HEAD)" --arg binary "$(sha256sum "$client_binary" | cut -d ' ' -f1)" \ + --arg observer "$(sha256sum scripts/conformance/server-parity/query-authority/observer.mjs | cut -d ' ' -f1)" \ + --arg profile "$(sha256sum "$profile" | cut -d ' ' -f1)" \ + '{runner_revision:$revision,consumer_binary_sha256:$binary,observer_sha256:$observer,profile_sha256:$profile}' >"$output/identity.json" +case_output='' +name='' +language='' +retire() { + local result=0 + if test -f "$case_output/client-container.id" && docker inspect "$RESOURCE_SCOPE-query-client-$target-$language-$name" >/dev/null 2>&1; then + test "$(docker inspect --format '{{.Id}}' "$RESOURCE_SCOPE-query-client-$target-$language-$name")" = "$(cat "$case_output/client-container.id")" || return 1 + docker stop --signal=SIGTERM --timeout=5 "$RESOURCE_SCOPE-query-client-$target-$language-$name" >/dev/null || result=1 + fi + if test -f "$case_output/observer-container.id" && docker inspect "$RESOURCE_SCOPE-query-client-observer-$target-$language-$name" >/dev/null 2>&1; then + test "$(docker inspect --format '{{.Id}}' "$RESOURCE_SCOPE-query-client-observer-$target-$language-$name")" = "$(cat "$case_output/observer-container.id")" || return 1 + docker stop --signal=SIGTERM --timeout=10 "$RESOURCE_SCOPE-query-client-observer-$target-$language-$name" >"$case_output/observer-stop.txt" 2>&1 || true + docker logs "$RESOURCE_SCOPE-query-client-observer-$target-$language-$name" >"$case_output/observer.log" 2>&1 + docker inspect --format '{{json .State}}' "$RESOURCE_SCOPE-query-client-observer-$target-$language-$name" >"$case_output/observer-state.json" + jq -e '.ExitCode==0 and .OOMKilled==false and .Running==false' "$case_output/observer-state.json" >/dev/null || result=1 + fi + return "$result" +} +trap retire EXIT +for language in python rust; do + while IFS=$'\t' read -r mode protocol; do + name="$mode-${protocol//./}" + case_output="$output/$language-$name" + mkdir "$case_output" + if docker inspect "$RESOURCE_SCOPE-query-client-observer-$target-$language-$name" >/dev/null 2>&1; then exit 1; fi + docker run -d --name "$RESOURCE_SCOPE-query-client-observer-$target-$language-$name" --network "$network" \ + --label "durable-workflow.query-authority-client=$RESOURCE_SCOPE" \ + --network-alias=parity-query-client-observer --user="$(id -u):$(id -g)" \ + --cpus=1 --memory=256m --memory-swap=256m --entrypoint node \ + -v "$source_root:/source:ro" -v "$case_output:/evidence" "$image" \ + /source/scripts/conformance/server-parity/query-authority/observer.mjs "$url" "$mode" /evidence/http.jsonl \ + >"$case_output/observer-container.id" + ready=0 + for attempt in $(seq 1 50); do + if docker logs "$RESOURCE_SCOPE-query-client-observer-$target-$language-$name" 2>/dev/null | jq -e '.ready==true' >/dev/null; then ready=1; break; fi + sleep .05 + done + test "$ready" = 1 + client_image=$(jq -r --arg language "$language" '.client_images[$language]' "$profile") + if test "$language" = python; then + actor=(--entrypoint python -e PYTHONPATH=/sdk -e PYTHONDONTWRITEBYTECODE=1 \ + -v "$python_sdk:/sdk:ro" "$client_image" /source/scripts/conformance/server-parity/query-authority/python.py) + else + actor=(--entrypoint /client -v "$client_binary:/client:ro" "$client_image") + fi + status=0 + timeout --signal=TERM --kill-after=5s 40s docker run --rm --cidfile "$case_output/client-container.id" \ + --name "$RESOURCE_SCOPE-query-client-$target-$language-$name" --network "$network" --user="$(id -u):$(id -g)" \ + --label "durable-workflow.query-authority-client=$RESOURCE_SCOPE" \ + --cpus=1 --memory=512m --memory-swap=512m -e DW_PARITY_TOKEN \ + -e DW_PARITY_QUERY_PROFILE=/source/tests/Fixtures/ServerParityProfiles/query-authority/artifacts.json \ + -v "$source_root:/source:ro" -v "$case_output:/evidence:ro" "${actor[@]}" \ + http://parity-query-client-observer:8080 "$mode" "$protocol" /evidence/http.jsonl \ + >"$case_output/result.json" 2>"$case_output/client.stderr" || status=$? + retire || status=1 + if test "$status" != 0; then + printf 'Query client failed: target=%s language=%s mode=%s protocol=%s exit=%s\n' \ + "$target" "$language" "$mode" "$protocol" "$status" >&2 + tail -c 8192 "$case_output/client.stderr" >&2 + if test -f "$case_output/observer.log"; then tail -c 8192 "$case_output/observer.log" >&2; fi + exit "$status" + fi + jq -e --arg language "$language" --arg mode "$mode" --arg protocol "$protocol" \ + '.outcome=="pass" and .language==$language and .mode==$mode and .protocol==$protocol + and .worker_exit==0 and .original_budget_seconds==30 and .elapsed_seconds<30' "$case_output/result.json" >/dev/null + jq '{outcome,language,mode,protocol,run_id,elapsed_seconds,worker_exit}' "$case_output/result.json" + done < <(jq -r '.client_cases[]|[.mode,.protocol]|@tsv' "$profile") +done +jq -s --arg target "$target" '{outcome:"pass",target:$target,originals:length,cases:map({language,mode,protocol, + namespace,run_id,elapsed_seconds,worker_exit,history_sha256})}' "$output/"*/result.json >"$output/comparison.json" +test "$(jq -r .originals "$output/comparison.json")" = "$(jq '.client_cases|length*2' "$profile")" diff --git a/scripts/conformance/server-parity/query-authority-cohort.sh b/scripts/conformance/server-parity/query-authority-cohort.sh new file mode 100755 index 000000000..d8598ea66 --- /dev/null +++ b/scripts/conformance/server-parity/query-authority-cohort.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# Keep current query authority separate from the frozen query actors. +set -euo pipefail +test "$#" = 1 || exit 2 +backend=$1 +test -n "${RESOURCE_SCOPE:?Set the owning isolated network}" +docker network inspect "$RESOURCE_SCOPE" >/dev/null +profile=tests/Fixtures/ServerParityProfiles/query-authority/artifacts.json +image=$(jq -r '.server.image' "$profile") +rust_image=mirror.gcr.io/library/rust@sha256:ba81bc3eaa4422af576c0262515d96b0111a628a6ccc2c86557cf55c9a4bbee0 +state=parity-query-authority-state +evidence=parity-evidence/query-authority +sdk="$state/sdk" +resource="$RESOURCE_SCOPE-query-authority" +test -f rust-target/debug/durable-workflow-server +test ! -e "$state" +test ! -e "$evidence" +mkdir -p "$sdk" "$state/native" "$evidence"/{php,rust} +mkdir -p "$state/php/storage/framework/"{cache/data,sessions,views} "$state/php/storage/logs" +cp scripts/conformance/server-parity/query-authority/composer.{json,lock} "$sdk/" +chmod -R a+rwX "$state" "$evidence" +jq -n --arg scope "$RESOURCE_SCOPE" --arg backend "$backend" --arg revision "$(git rev-parse HEAD)" \ + --arg image "$image" --arg binary "$(sha256sum rust-target/debug/durable-workflow-server | cut -d ' ' -f1)" \ + '{resource_scope:$scope,backend:$backend,runner_revision:$revision,php_image:$image,native_binary_sha256:$binary}' \ + > "$evidence/identity.json" +# The workflow's always-step retires partial setup and every admitted original. +trap 'printf "%s\n" "$?" > "$evidence/profile-status.txt"' EXIT +docker run --rm --user=1000:1000 --cpus=1 --memory=512m --memory-swap=512m \ + --entrypoint composer -e COMPOSER_HOME=/tmp/query-composer \ + -v "$PWD/$sdk:/sdk" -w /sdk "$image" install --no-dev --no-scripts --no-interaction --prefer-dist \ + > "$evidence/sdk-install.txt" 2>&1 +case "$backend" in + mysql) db=(-e DB_CONNECTION=mysql -e DB_HOST=parity-mysql -e DB_USERNAME=root -e DB_PASSWORD=parity-disposable-password) ;; + pgsql) db=(-e DB_CONNECTION=pgsql -e DB_HOST=parity-pg -e DB_USERNAME=parity -e DB_PASSWORD=parity-disposable-password) ;; + sqlite) db=(-e DB_CONNECTION=sqlite) ;; + *) exit 2 ;; +esac +for target in php native; do + database="query_authority_$target" + if test "$backend" = mysql; then + docker run --rm --user=1000:1000 --network "$RESOURCE_SCOPE" --entrypoint mysql "$MYSQL_IMAGE" \ + -h parity-mysql -uroot -pparity-disposable-password \ + -e "CREATE DATABASE $database CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" > "$evidence/$target-create.txt" + printf '%s\n' "$RESOURCE_SCOPE" > "$evidence/$target-database-owned.txt" + elif test "$backend" = pgsql; then + docker run --rm --user=1000:1000 --network "$RESOURCE_SCOPE" -e PGPASSWORD=parity-disposable-password \ + --entrypoint createdb "$POSTGRES_IMAGE" -h parity-pg -U parity "$database" > "$evidence/$target-create.txt" + printf '%s\n' "$RESOURCE_SCOPE" > "$evidence/$target-database-owned.txt" + else + database=/state/database.sqlite + if test "$target" = php; then touch "$state/php/database.sqlite"; chmod 0666 "$state/php/database.sqlite"; fi + fi + configuration=("${db[@]}" -e DB_DATABASE="$database" -e DW_AUTH_DRIVER=token \ + -e DW_AUTH_TOKEN=query-authority-fixture-token -e DW_QUERY_TASK_TIMEOUT=4) + mount=(-v "$PWD/$state/$target:/state") + if test "$target" = php; then + php_runtime=(-e APP_ENV=testing -e APP_DEBUG=false -e CACHE_STORE=file -e QUEUE_CONNECTION=database \ + -e DW_SERVER_KEY=base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= \ + -v "$PWD/$state/php/storage:/app/storage") + docker run --rm --user=1000:1000 --cpus=1 --memory=768m --memory-swap=768m --network "$RESOURCE_SCOPE" \ + "${configuration[@]}" "${mount[@]}" "${php_runtime[@]}" --entrypoint php "$image" \ + artisan server:bootstrap --force > "$evidence/php-bootstrap.txt" 2>&1 + docker run -d --name "$resource-php" --network "$RESOURCE_SCOPE" --network-alias=parity-query-authority-php \ + --user=1000:1000 --cpus=1 --memory=768m --memory-swap=768m \ + "${configuration[@]}" "${mount[@]}" "${php_runtime[@]}" "$image" > "$evidence/php-container.txt" + else + native=(-e DW_RUST_EXPERIMENTAL=1 -v "$PWD/rust-target:/target:ro" --entrypoint /target/debug/durable-workflow-server) + if test "$backend" != sqlite; then + docker run --rm --user=1000:1000 --cpus=1 --memory=768m --memory-swap=768m --network "$RESOURCE_SCOPE" \ + "${configuration[@]}" "${mount[@]}" "${native[@]}" "$rust_image" schema-bootstrap \ + > "$evidence/native-bootstrap.json" + fi + docker run -d --name "$resource-native" --network "$RESOURCE_SCOPE" --network-alias=parity-query-authority-native \ + --user=1000:1000 --cpus=1 --memory=768m --memory-swap=768m "${configuration[@]}" "${mount[@]}" \ + -e DW_BIND_ADDRESS=0.0.0.0:8080 "${native[@]}" "$rust_image" > "$evidence/native-container.txt" + fi + 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-query-authority-$target:8080/api/ready" > "$evidence/$target-readiness.json"; then ready=1; break; fi + sleep 1 + done + test "$ready" = 1 +done +export DW_PARITY_TOKEN=query-authority-fixture-token +bash scripts/conformance/server-parity/query-authority-profile.sh "$RESOURCE_SCOPE" "$resource-php" \ + http://parity-query-authority-php:8080 "$sdk" "$evidence/php" php > "$evidence/php-tests.txt" +bash scripts/conformance/server-parity/query-authority-profile.sh "$RESOURCE_SCOPE" "$resource-native" \ + http://parity-query-authority-native:8080 "$sdk" "$evidence/rust" rust > "$evidence/rust-tests.txt" +jq -s --arg backend "$backend" \ + '{outcome:"pass",backend:$backend,originals:length,cases:map({target,mode,protocol,namespace,run_id,original_run_status, + elapsed_seconds,worker_exit,history_sha256,sdk_source,runner_sha256})}' \ + <(for target in php rust; do + jq -c --arg target "$target" '.+{target:$target}' "$evidence/$target/"*-1*.json + done) > "$evidence/comparison.json" +test "$(jq -r .originals "$evidence/comparison.json")" = "$(jq '.cases|length*2' "$profile")" +cat "$evidence/comparison.json" +# Current Python/Rust consumers are separate from every frozen SDK actor. +(cd query-authority-adapters; sha256sum -c SHA256SUMS >/dev/null) +sha256sum -c query-authority-adapters/source-hashes.sha256 >/dev/null +test "$(cat query-authority-adapters/runner-revision.txt)" = "$(git rev-parse HEAD)" +test "$(jq -r .outcome query-authority-adapters/identity.json)" = pass +chmod +x query-authority-adapters/rust-client +export DW_PARITY_QUERY_CLIENT_BIN="$PWD/query-authority-adapters/rust-client" +for target in php rust; do + server_target=$target + if test "$target" = rust; then server_target=native; fi + bash scripts/conformance/server-parity/query-authority-clients-profile.sh "$RESOURCE_SCOPE" \ + "http://parity-query-authority-$server_target:8080" query-authority-adapters/python "$evidence/clients/$target" "$target" \ + >"$evidence/$target-clients-tests.txt" +done +jq -s '{outcome:"pass",originals:(map(.originals)|add),targets:.}' \ + "$evidence/clients/php/comparison.json" "$evidence/clients/rust/comparison.json" >"$evidence/clients-comparison.json" +test "$(jq -r .originals "$evidence/clients-comparison.json")" = "$(jq '.client_cases|length*4' "$profile")" diff --git a/scripts/conformance/server-parity/query-authority-profile.sh b/scripts/conformance/server-parity/query-authority-profile.sh new file mode 100755 index 000000000..723d29a65 --- /dev/null +++ b/scripts/conformance/server-parity/query-authority-profile.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# The caller owns the isolated target, its original configuration and cleanup. +set -euo pipefail +test "$#" = 6 || { echo 'Expected NETWORK SERVER_CONTAINER URL SDK_DIRECTORY OUTPUT_DIRECTORY TARGET' >&2; exit 2; } +network=$1 +server=$2 +url=$3 +sdk=$(realpath "$4") +mkdir -p "$5" +output=$(realpath "$5") +target=$6 +case "$target" in php|rust) ;; *) exit 2 ;; esac +source_root=$(git rev-parse --show-toplevel) +profile=tests/Fixtures/ServerParityProfiles/query-authority/artifacts.json +image=$(jq -r '.server.image' "$profile") +test -n "${RESOURCE_SCOPE:?Set the unique owning resource scope}" +test -f "$sdk/vendor/autoload.php" +cmp scripts/conformance/server-parity/query-authority/composer.lock "$sdk/composer.lock" +docker inspect "$server" | jq -e --arg network "$network" \ + '.[0].State.Running == true and (.[0].NetworkSettings.Networks | has($network))' >/dev/null +if test "$target" = php; then + docker inspect "$server" | jq -e --arg revision "$(jq -r '.server.source' "$profile")" \ + '.[0].Config.Labels["org.opencontainers.image.revision"] == $revision' >/dev/null +fi +native_binary_sha256='' +if test "$target" = rust; then + native_binary_sha256=$(docker exec "$server" sha256sum /target/debug/durable-workflow-server | cut -d ' ' -f1) +fi +jq -n --arg revision "$(git rev-parse HEAD)" --arg native_tree "$(git rev-parse HEAD:rust)" \ + --arg server_image "$(docker inspect "$server" --format '{{.Image}}')" \ + --arg native_binary_sha256 "$native_binary_sha256" \ + --arg target "$target" --arg profile_sha256 "$(sha256sum "$profile" | cut -d ' ' -f1)" \ + '{runner_revision:$revision,native_tree:$native_tree,native_binary_sha256:$native_binary_sha256, + target:$target,server_image:$server_image, + profile_sha256:$profile_sha256,performance:"not_measured"}' > "$output/identity.json" +while IFS=$'\t' read -r mode protocol; do + name="$mode-${protocol//./}" + test ! -e "$output/$name.json" + timeout --signal=TERM --kill-after=5s 40s \ + docker run --rm --name "$RESOURCE_SCOPE-query-$target-$name" --user="$(id -u):$(id -g)" \ + --cpus=1 --memory=512m --memory-swap=512m --network "$network" --entrypoint php \ + -e DW_PARITY_SDK_AUTOLOAD=/sdk/vendor/autoload.php -e DW_PARITY_TOKEN \ + -v "$source_root:/source:ro" -v "$sdk:/sdk:ro" -w /source "$image" \ + scripts/conformance/server-parity/query-authority/worker.php "$url" "$mode" "$protocol" \ + > "$output/$name.json" 2> "$output/$name.stderr" + jq -e --arg mode "$mode" --arg protocol "$protocol" \ + '.outcome=="pass" and .mode==$mode and .protocol==$protocol and .worker_exit==0 + and .original_budget_seconds==30 and .elapsed_seconds<30' "$output/$name.json" >/dev/null + jq '{outcome,mode,protocol,run_id,elapsed_seconds,worker_exit}' "$output/$name.json" +done < <(jq -r '.cases[] | [.mode,.protocol] | @tsv' "$profile") diff --git a/scripts/conformance/server-parity/query-authority/composer.json b/scripts/conformance/server-parity/query-authority/composer.json new file mode 100644 index 000000000..48755e52d --- /dev/null +++ b/scripts/conformance/server-parity/query-authority/composer.json @@ -0,0 +1,14 @@ +{ + "name": "durable-workflow/server-query-authority-consumer", + "description": "Separate published query-authority consumer for the Server port", + "type": "project", + "license": "MIT", + "require": { + "php": "^8.3", + "durable-workflow/sdk": "2.4.2" + }, + "config": { + "allow-plugins": false, + "sort-packages": true + } +} diff --git a/scripts/conformance/server-parity/query-authority/composer.lock b/scripts/conformance/server-parity/query-authority/composer.lock new file mode 100644 index 000000000..f9a11cd56 --- /dev/null +++ b/scripts/conformance/server-parity/query-authority/composer.lock @@ -0,0 +1,958 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "7c049633e8c1d4d7bf1d90a6605f8a33", + "packages": [ + { + "name": "apache/avro", + "version": "1.12.2", + "source": { + "type": "git", + "url": "https://github.com/apache/avro.git", + "reference": "8fa2067f70e3012cb3fd9a8839cd97e8c7cc1772" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/apache/avro/zipball/8fa2067f70e3012cb3fd9a8839cd97e8c7cc1772", + "reference": "8fa2067f70e3012cb3fd9a8839cd97e8c7cc1772", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "ext-curl": "*", + "ext-json": "*", + "ext-pcntl": "*", + "ext-xml": "*", + "php-mock/php-mock-phpunit": "^2.10", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", + "squizlabs/php_codesniffer": "3.*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Apache\\Avro\\": "lang/php/lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Apache Avro Developers", + "email": "dev@avro.apache.org", + "homepage": "http://avro.apache.org" + } + ], + "description": "Apache Avro™ is a data serialization system.", + "homepage": "http://avro.apache.org", + "keywords": [ + "avro", + "data", + "serialization" + ], + "support": { + "email": "dev@avro.apache.org", + "issues": "https://issues.apache.org/jira/browse/AVRO", + "source": "https://github.com/apache/avro/tree/release-1.12.2" + }, + "time": "2026-08-07T12:56:37+00:00" + }, + { + "name": "durable-workflow/sdk", + "version": "2.4.2", + "source": { + "type": "git", + "url": "https://github.com/durable-workflow/sdk-php.git", + "reference": "25d796ad2a55d0d114b58017c3ba23390b09e120" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/durable-workflow/sdk-php/zipball/25d796ad2a55d0d114b58017c3ba23390b09e120", + "reference": "25d796ad2a55d0d114b58017c3ba23390b09e120", + "shasum": "" + }, + "require": { + "apache/avro": "^1.12", + "composer-runtime-api": "^2.0", + "ext-json": "*", + "guzzlehttp/guzzle": "^7.8|^8.0", + "php": "^8.1", + "psr/container": "^1.1|^2.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1|^2.0", + "psr/log": "^1.1|^2.0|^3.0" + }, + "require-dev": { + "phpdocumentor/phpdocumentor": "^3.7", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^10.5|^11.0" + }, + "suggest": { + "ext-pcntl": "Required by framework console workers for graceful SIGINT and SIGTERM handling.", + "laravel/framework": "Enables the auto-discovered Laravel 9 through 13 service-mode bridge.", + "symfony/console": "Required with symfony/framework-bundle to run the Symfony 6.4, 7, and 8 worker command.", + "symfony/framework-bundle": "Enables the Symfony 6.4, 7, and 8 service-mode bundle." + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "DurableWorkflow": "DurableWorkflow\\Bridge\\Laravel\\Facades\\DurableWorkflow" + }, + "providers": [ + "DurableWorkflow\\Bridge\\Laravel\\DurableWorkflowServiceProvider" + ] + }, + "durable-workflow": { + "frameworks": { + "laravel": "^9.0|^10.0|^11.0|^12.0|^13.0", + "symfony": "^6.4|^7.0|^8.0" + }, + "product-train": "2.4.2", + "payload-codecs": [ + "avro" + ], + "message-streams": true, + "version-markers": true, + "workflow-streams": { + "contract": "durable-workflow.v2.workflow-streams.contract@1", + "delivery": "at-least-once", + "operations": [ + "list", + "describe", + "subscribe", + "append", + "close", + "error" + ], + "authoring-command": "record_side_effect.workflow_stream", + "external-payload-references": "opaque-reference" + }, + "durable-selection": true, + "control-plane-version": "2", + "version-marker-command": "record_version_marker", + "version-marker-helpers": [ + "patched", + "deprecatePatch" + ], + "worker-protocol-version": "1.20", + "supported-server-versions": "2.5.0", + "version-marker-history-event": "VersionMarkerRecorded", + "message-streams-minimum-worker-protocol-version": "1.15", + "durable-selection-minimum-worker-protocol-version": "1.19" + } + }, + "autoload": { + "psr-4": { + "DurableWorkflow\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Durable Workflow Contributors" + } + ], + "description": "PHP client and worker SDK for Durable Workflow Cloud and self-hosted Server", + "homepage": "https://php.durable-workflow.com/", + "keywords": [ + "cloud", + "durable", + "laravel", + "orchestration", + "php", + "sdk", + "symfony", + "worker", + "workflow" + ], + "support": { + "docs": "https://php.durable-workflow.com/", + "issues": "https://github.com/durable-workflow/sdk-php/issues", + "source": "https://github.com/durable-workflow/sdk-php" + }, + "time": "2026-10-15T03:25:43+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "8.2.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "93939470950a9b11e2e84204166ef5e048c55fe4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/93939470950a9b11e2e84204166ef5e048c55fe4", + "reference": "93939470950a9b11e2e84204166ef5e048c55fe4", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^3.0.2", + "guzzlehttp/psr7": "^3.1", + "php": "^7.4 || ^8.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "symfony/polyfill-php80": "^1.25", + "symfony/polyfill-php82": "^1.27" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "4.0.1", + "guzzlehttp/test-server": "^1.0.1", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^9.6.34", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/8.2.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2026-09-06T13:55:09+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "42118e66a53c492effaf92bc357e931985d5c6f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/42118e66a53c492effaf92bc357e931985d5c6f9", + "reference": "42118e66a53c492effaf92bc357e931985d5c6f9", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^9.6.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2026-08-24T10:00:26+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "3.1.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "a3059ba1a84c9139c4ae03cf0f45bea276c97c74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/a3059ba1a84c9139c4ae03cf0f45bea276c97c74", + "reference": "a3059ba1a84c9139c4ae03cf0f45bea276c97c74", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "psr/http-factory": "^1.1", + "psr/http-message": "^2.0", + "symfony/polyfill-php80": "^1.25", + "symfony/polyfill-php82": "^1.27" + }, + "provide": { + "psr/http-factory-implementation": "1.1", + "psr/http-message-implementation": "2.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "1.1.0", + "jshttp/mime-db": "1.54.0.1", + "php-http/psr7-integration-tests": "^1.5.1", + "phpunit/phpunit": "^9.6.34" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/3.1.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2026-08-24T11:02:13+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74", + "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:50:05+00:00" + }, + { + "name": "symfony/polyfill-php82", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php82.git", + "reference": "843bbe8b2a7934307b749751eede0f111f5d1cfc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/843bbe8b2a7934307b749751eede0f111f5d1cfc", + "reference": "843bbe8b2a7934307b749751eede0f111f5d1cfc", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php82\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.2+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php82/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:50:05+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": "^8.3" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/scripts/conformance/server-parity/query-authority/observer.mjs b/scripts/conformance/server-parity/query-authority/observer.mjs new file mode 100644 index 000000000..cbd4ad937 --- /dev/null +++ b/scripts/conformance/server-parity/query-authority/observer.mjs @@ -0,0 +1,143 @@ +// Isolated test observer: forward original HTTP bytes, hold one actual poll +// response when requested, and retain only bounded authority metadata. +import http from 'node:http'; +import {appendFileSync} from 'node:fs'; +import {createHash} from 'node:crypto'; +import {gunzipSync, inflateSync, brotliDecompressSync} from 'node:zlib'; + +const [target, mode, journal] = process.argv.slice(2); +if (!['timely', 'late-poll', 'late-handler-async', 'late-handler-sync'].includes(mode)) throw Error('Unknown case'); +const upstream = new URL(target); +if (upstream.protocol !== 'http:') throw Error('Use only an isolated HTTP target'); +let count = 0, retainedBytes = 0, held = false; +const authorityFields = ['query_task_id', 'query_task_attempt', 'lease_owner', 'task_queue', 'namespace', + 'workflow_id', 'run_id', 'query_admitted_at', 'query_deadline_at', 'lease_expires_at', 'server_time', 'can_continue', 'reason']; +const select = value => Object.fromEntries(authorityFields.filter(key => key in value).map(key => [key, value[key]])); +function record(event, details = {}) { + const line = JSON.stringify({event, observed_at: Date.now() / 1000, ...details}) + '\n'; + if (++count > 256 || (retainedBytes += Buffer.byteLength(line)) > 256 * 1024) throw Error('Observer limit exceeded'); + appendFileSync(journal, line); +} +function exchange(method, path, headers, body = Buffer.alloc(0)) { + return new Promise((resolve, reject) => { + const request = http.request(new URL(path, upstream), {method, headers: {...headers, host: upstream.host}}, incoming => { + const chunks = []; + let size = 0; + incoming.on('data', chunk => { + size += chunk.length; + if (size > 2 * 1024 * 1024) incoming.destroy(Error('Observer response limit exceeded')); + else chunks.push(chunk); + }); + incoming.on('error', reject); + incoming.on('end', () => resolve({status: incoming.statusCode, headers: incoming.headers, bytes: Buffer.concat(chunks)})); + }); + request.setTimeout(35_000, () => request.destroy(Error('Observer exchange timeout'))); + request.on('error', reject); + request.end(body); + }); +} +function json(reply) { + const options = {maxOutputLength: 2 * 1024 * 1024}; + const decoders = {identity: value => value, gzip: value => gunzipSync(value, options), + deflate: value => inflateSync(value, options), br: value => brotliDecompressSync(value, options)}; + const decoder = decoders[reply.headers['content-encoding'] ?? 'identity']; + if (!decoder) throw Error('Unknown observed encoding'); + return reply.bytes.length ? JSON.parse(decoder(reply.bytes).toString('utf8')) : {}; +} +const sha = bytes => createHash('sha256').update(bytes).digest('hex'); +const server = http.createServer(async (request, response) => { + const path = new URL(request.url, upstream).pathname; + try { + const chunks = []; + let size = 0; + for await (const chunk of request) { + if ((size += chunk.length) > 128 * 1024) throw Error('Observer request limit exceeded'); + chunks.push(chunk); + } + const body = Buffer.concat(chunks); + const headers = {...request.headers}; + const terminal = request.method === 'POST' && /\/query-tasks\/[^/]+\/(complete|fail)$/.test(path); + if (terminal) record('submission', {path}); + const query = request.method === 'POST' && /^\/api\/workflows\/[^/]+\/(?:runs\/[^/]+\/)?query\//.test(path); + let historyPath; + let before; + if (query) { + const workflowPath = path.replace(/\/query\/[^/]+$/, ''); + if (workflowPath.includes('/runs/')) historyPath = workflowPath + '/history'; + else { + const description = await exchange('GET', workflowPath, {...headers, 'content-length': '0'}); + if (description.status !== 200 || typeof json(description).run_id !== 'string') throw Error('Original run unavailable'); + historyPath = workflowPath + '/runs/' + encodeURIComponent(json(description).run_id) + '/history'; + } + const snapshot = await exchange('GET', historyPath, {...headers, 'content-length': '0'}); + if (snapshot.status !== 200) throw Error('Original history unavailable'); + before = sha(Buffer.from(JSON.stringify(json(snapshot)))); + } + const reply = await exchange(request.method, request.url, headers, body); + if (path === '/api/cluster/info' && request.method === 'GET') { + record('discovery', {status: reply.status, + query_task_authority: json(reply).worker_protocol?.server_capabilities?.query_task_authority === true}); + } + if (path === '/api/worker/register' && request.method === 'POST') record('registered', {status: reply.status}); + if (path.endsWith('/query-tasks/poll') && reply.status === 200) { + const task = json(reply).task; + if (task && typeof task === 'object') { + const utc6 = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{6}Z$/; + const times = {}; + for (const key of ['query_admitted_at', 'query_deadline_at', 'lease_expires_at', 'server_time']) { + const parsed = Date.parse(task[key]); + if (!utc6.test(task[key]) || !Number.isFinite(parsed) + || new Date(parsed).toISOString() !== task[key].slice(0, 23) + 'Z') { + throw Error('Missing original UTC6 authority: ' + key); + } + times[key] = BigInt(parsed) * 1000n + BigInt(task[key].slice(23, 26)); + } + const original = JSON.parse(body.toString('utf8')); + if (times.query_deadline_at - times.query_admitted_at !== 4_000_000n + || times.lease_expires_at < times.query_admitted_at + || times.lease_expires_at > times.query_deadline_at + || times.server_time < times.query_admitted_at + || task.query_task_attempt !== 1 || task.lease_owner !== original.worker_id + || task.task_queue !== original.task_queue + || typeof request.headers['x-namespace'] !== 'string' || !request.headers['x-namespace'] + || (task.namespace !== undefined && task.namespace !== request.headers['x-namespace']) + || ['query_task_id', 'workflow_id', 'run_id'].some(key => typeof task[key] !== 'string' || !task[key])) { + throw Error('Changed original query authority'); + } + record('claim', {protocol: request.headers['x-durable-workflow-protocol-version'], + namespace: request.headers['x-namespace'], claim: select(task)}); + if (mode === 'late-poll') { + if (held) throw Error('Multiple poll responses invalidate this single-delivery fault control'); + held = true; + record('held'); + await new Promise(resolve => setTimeout(resolve, 6_000)); + } + record('delivered'); + } + } else if (/\/query-tasks\/[^/]+\/status$/.test(path)) { + record('status', {status: reply.status, observation: select(json(reply))}); + } else if (terminal) { + record('acknowledgement', {status: reply.status, recorded: json(reply).recorded}); + } else if (request.method === 'DELETE' && path.startsWith('/api/worker')) { + record('deregistered', {status: reply.status}); + } + if (query) { + const snapshot = await exchange('GET', historyPath, {...headers, 'content-length': '0'}); + if (snapshot.status !== 200) throw Error('Original history disappeared'); + const after = sha(Buffer.from(JSON.stringify(json(snapshot)))); + if (after !== before) throw Error('Query changed the original workflow history'); + record('query-result', {status: reply.status, reason: json(reply).reason ?? null, history_sha256: before}); + } + response.writeHead(reply.status, reply.headers); + response.end(reply.bytes); + } catch (error) { + const details = {method: request.method, path, message: String(error).slice(0, 1024)}; + record('observer-error', details); + console.error(JSON.stringify({event: 'observer-error', ...details})); + response.destroy(error); + } +}); +server.listen(Number(process.env.DW_PARITY_QUERY_OBSERVER_PORT ?? 8080), '0.0.0.0', () => + console.log(JSON.stringify({ready: true, port: server.address().port}))); +process.on('SIGTERM', () => server.close(() => process.exit(0))); + diff --git a/scripts/conformance/server-parity/query-authority/python.py b/scripts/conformance/server-parity/query-authority/python.py new file mode 100644 index 000000000..7de8e8244 --- /dev/null +++ b/scripts/conformance/server-parity/query-authority/python.py @@ -0,0 +1,173 @@ +"""Normal installed Python Worker/client; the external observer holds real bytes.""" +import asyncio +import importlib.metadata +import hashlib +import json +import logging +import os +import re +import sys +import time +from pathlib import Path + +from durable_workflow import Client, Worker, workflow +from durable_workflow.errors import QueryFailed +import durable_workflow + +URL, MODE, PROTOCOL, JOURNAL = sys.argv[1:] +TYPE = 'parity.query-authority.python.v1' +EXPECTED = {'long': 9223372036854775807, 'nil': None, 'text': 'rangeλ'} +COUNTERS = {'calls': 0, 'returned': 0, 'cancelled': 0} +AUTHORITY_LOSSES = [] +EXPIRED_REASONS = {'query_execution_budget_expired', 'query_task_timed_out', 'query_lease_expired'} + + +def expired_losses(task_id): + return [loss for loss in AUTHORITY_LOSSES + if loss['query_task_id'] == task_id and loss['reason'] in EXPIRED_REASONS] + + +class AuthorityLog(logging.Handler): + """Observe the installed SDK's public logging without changing its worker.""" + def emit(self, record): + match = re.fullmatch( + r"(?:query poll error|query delivery suppressed): query task ['\"]([A-Za-z0-9_-]{1,128})['\"] cannot continue: ([a-z_]{1,64})", + record.getMessage()) + if match and len(AUTHORITY_LOSSES) < 16: + loss = {'query_task_id': match[1], 'reason': match[2], 'observed_at': time.time()} + AUTHORITY_LOSSES.append(loss) + print('query authority refusal: ' + json.dumps(loss), file=sys.stderr) + + +worker_log = logging.getLogger('durable_workflow.worker') +worker_log.setLevel(logging.INFO) +worker_log.addHandler(AuthorityLog()) + + +def events(): + text = Path(JOURNAL).read_text() if Path(JOURNAL).exists() else '' + # Only a complete line is an observation; the next read sees an append + # that was still in flight. No record or response is rewritten. + return [json.loads(line) for line in text.split('\n')[:-1] if line] + + +@workflow.defn(name=TYPE) +class QueryWorkflow: + def run(self, ctx, value): + self.value = value + yield ctx.sleep(60) + return value + + @workflow.query('value') + async def current(self): + COUNTERS['calls'] += 1 + assert self.value == 7 + try: + if MODE == 'late-handler-async': + await asyncio.sleep(6) + elif MODE == 'late-handler-sync': + time.sleep(6) + COUNTERS['returned'] += 1 + return EXPECTED + except asyncio.CancelledError: + COUNTERS['cancelled'] += 1 + raise + + +async def main(): + artifacts = json.loads(Path(os.environ['DW_PARITY_QUERY_PROFILE']).read_text()) + identity = artifacts['sdk_python'] + assert importlib.metadata.version('durable-workflow') == identity['version'] + package = Path(durable_workflow.__file__).parent + hashes = {str(path.relative_to(package)): hashlib.sha256(path.read_bytes()).hexdigest() + for path in package.rglob('*') if path.is_file() and (path.suffix == '.py' or path.name == 'py.typed')} + assert len(hashes) == identity['loaded_files'] + assert hashlib.sha256(json.dumps(hashes, sort_keys=True, separators=(',', ':')).encode()).hexdigest() == identity['loaded_tree_sha256'] + assert PROTOCOL in ('1.20', '1.19') and (MODE == 'timely' or PROTOCOL == '1.20') + os.environ['DURABLE_WORKFLOW_WORKER_PROTOCOL_VERSION'] = PROTOCOL + started = time.monotonic() + namespace = 'query-python-' + os.urandom(6).hex() + async with asyncio.timeout(30): + async with Client(URL, token=os.environ['DW_PARITY_TOKEN']) as admin: + await admin.create_namespace(namespace) + async with Client(URL, token=os.environ['DW_PARITY_TOKEN'], namespace=namespace) as client: + worker = Worker(client, workflows=[QueryWorkflow], task_queue=namespace, + worker_id=namespace, poll_timeout=8, shutdown_timeout=5, + require_query_task_authority=PROTOCOL == '1.20') + runner = asyncio.create_task(worker.run()) + try: + while not any(x['event'] == 'registered' and x['status'] == 201 for x in events()): + if runner.done(): + await runner + raise RuntimeError('Worker stopped before registration') + await asyncio.sleep(.02) + handle = await client.start_workflow(workflow_type=TYPE, task_queue=namespace, + workflow_id=namespace, input=[7]) + while (await handle.describe()).status.lower() != 'waiting': + if runner.done(): + await runner + raise RuntimeError('Worker stopped before timer admission') + await asyncio.sleep(.02) + if MODE == 'timely': + result = await client.query_workflow(namespace, 'value', args=[]) + assert result['result'] == EXPECTED and type(result['result']['long']) is int + else: + try: + await client.query_workflow(namespace, 'value', args=[]) + raise AssertionError('An expired original succeeded') + except QueryFailed as error: + assert error.status == 504 and error.reason == 'query_worker_execution_timeout' + # Delivery is recorded before the SDK receives its response. + # Wait for its actual refusal before stopping the worker. + while (MODE == 'late-poll' and not any( + expired_losses(event['claim']['query_task_id']) + for event in events() if event['event'] == 'claim') + or MODE == 'late-handler-async' and COUNTERS['cancelled'] != 1 + or MODE == 'late-handler-sync' and COUNTERS['returned'] != 1): + await asyncio.sleep(.02) + finally: + await worker.stop() + await runner + assert time.monotonic() - started < 30 + observed = events() + named = lambda name: [x for x in observed if x['event'] == name] + assert not named('observer-error'), named('observer-error') + assert len(named('claim')) == len(named('delivered')) == len(named('query-result')) == 1 + assert len(named('submission')) == len(named('acknowledgement')) == (1 if MODE == 'timely' else 0) + assert named('deregistered')[-1]['status'] == 200 + assert COUNTERS == {'calls': 0 if MODE == 'late-poll' else 1, + 'returned': 1 if MODE in ('timely', 'late-handler-sync') else 0, + 'cancelled': 1 if MODE == 'late-handler-async' else 0} + claim = named('claim')[0]['claim'] + assert named('claim')[0]['protocol'] == PROTOCOL + assert named('claim')[0]['namespace'] == claim.get('namespace', namespace) == namespace + assert claim['task_queue'] == claim['lease_owner'] == namespace + assert claim['workflow_id'] == namespace and claim['run_id'] == handle.run_id + if MODE != 'timely': + assert expired_losses(claim['query_task_id']), AUTHORITY_LOSSES + discovery = named('discovery') + if PROTOCOL == '1.20': + assert discovery and all(x['status'] == 200 and x['query_task_authority'] is True for x in discovery), discovery + if not named('status'): + # Proven expiry needs no redundant HTTP observation. No callback + # alone would also admit malformed metadata or an observer failure. + assert MODE == 'late-poll' + else: + assert not named('status') + for entry in named('status'): + for field in ('query_task_id', 'query_task_attempt', 'lease_owner', 'query_admitted_at', + 'query_deadline_at', 'lease_expires_at'): + assert entry['observation'][field] == claim[field], field + print(json.dumps({'outcome': 'pass', 'language': 'python', 'mode': MODE, 'protocol': PROTOCOL, + 'sdk_version': importlib.metadata.version('durable-workflow'), + 'namespace': namespace, 'run_id': handle.run_id, 'claim': claim, + 'counters': COUNTERS, 'status_reads': len(named('status')), + 'authority_losses': AUTHORITY_LOSSES, + 'authority_discovery': discovery, + 'history_sha256': named('query-result')[0]['history_sha256'], + 'original_budget_seconds': 30, 'elapsed_seconds': time.monotonic() - started, + 'worker_exit': 0})) + + +asyncio.run(main()) + diff --git a/scripts/conformance/server-parity/query-authority/requirements.txt b/scripts/conformance/server-parity/query-authority/requirements.txt new file mode 100644 index 000000000..2b48883ad --- /dev/null +++ b/scripts/conformance/server-parity/query-authority/requirements.txt @@ -0,0 +1,10 @@ +# Separate current query-authority consumer; frozen actors remain unchanged. +durable-workflow==2.5.15 --hash=sha256:b905d7e440d5d3708c4570ad8c8986d2ff83149bee6ba4bce209992ad3e8a7dc +fastavro==1.13.1 --hash=sha256:e3d7e0850230a9af977184dd0677e2bc6341659835d55a73a2fa76c7d2d2d65e +httpx==0.28.1 --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad +httpcore==1.0.9 --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 +h11==0.16.0 --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 +anyio==4.15.1 --hash=sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101 +idna==3.20 --hash=sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c +typing_extensions==4.16.0 --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 +certifi==2026.7.22 --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 diff --git a/scripts/conformance/server-parity/query-authority/rust/Cargo.lock b/scripts/conformance/server-parity/query-authority/rust/Cargo.lock new file mode 100644 index 000000000..644a6030d --- /dev/null +++ b/scripts/conformance/server-parity/query-authority/rust/Cargo.lock @@ -0,0 +1,1793 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "apache-avro" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36fa98bc79671c7981272d91a8753a928ff6a1cd8e4f20a44c45bd5d313840bf" +dependencies = [ + "bigdecimal", + "bon", + "digest", + "log", + "miniz_oxide", + "num-bigint", + "quad-rand", + "rand 0.9.5", + "regex-lite", + "serde", + "serde_bytes", + "serde_json", + "strum", + "strum_macros", + "thiserror 2.0.21", + "uuid", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bigdecimal" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fa3f3d8cbf4dffcfe4991de61d012bef509a409ecbe9dd41049bfe32b4d4653" +dependencies = [ + "autocfg", + "libm", + "num-bigint", + "num-integer", + "num-traits", + "serde", +] + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bon" +version = "3.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebeb9aaf9329dff6ceb65c689ca3db33dbf15f324909c60e4e5eef5701ce31b1" +dependencies = [ + "bon-macros", + "rustversion", +] + +[[package]] +name = "bon-macros" +version = "3.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77e9d642a7e3a318e37c2c9427b5a6a48aa1ad55dcd986f3034ab2239045a645" +dependencies = [ + "darling", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.119", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "num-traits", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "durable-workflow" +version = "3.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04147fcb82f749ea1e308c0a3836e30454efc415d5933884d615ca4aae76bb2e" +dependencies = [ + "apache-avro", + "base64 0.22.1", + "bon", + "chrono", + "futures-util", + "icu_collections", + "icu_locale_core", + "icu_normalizer", + "icu_properties", + "icu_provider", + "reqwest", + "serde", + "serde-value", + "serde_json", + "sha2", + "thiserror 1.0.69", + "tokio", + "uuid", + "yoke-derive", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", + "serde", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quad-rand" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a651516ddc9168ebd67b24afd085a718be02f8858fe406591b013d101ce2f40" + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.21", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.21", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "server-parity-query-authority-client" +version = "0.0.0" +dependencies = [ + "durable-workflow", + "reqwest", + "serde_json", + "tokio", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure 0.13.2", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure 0.14.0", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/scripts/conformance/server-parity/query-authority/rust/Cargo.toml b/scripts/conformance/server-parity/query-authority/rust/Cargo.toml new file mode 100644 index 000000000..6f826273c --- /dev/null +++ b/scripts/conformance/server-parity/query-authority/rust/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "server-parity-query-authority-client" +version = "0.0.0" +edition = "2021" +publish = false + +[dependencies] +durable-workflow = "=3.5.5" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +serde_json = "1" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] } + +[profile.dev] +debug = 0 +incremental = false + diff --git a/scripts/conformance/server-parity/query-authority/rust/src/main.rs b/scripts/conformance/server-parity/query-authority/rust/src/main.rs new file mode 100644 index 000000000..45fae759d --- /dev/null +++ b/scripts/conformance/server-parity/query-authority/rust/src/main.rs @@ -0,0 +1,278 @@ +//! Normal published SDK Worker/client; only namespace setup uses plain HTTP. +use durable_workflow::{Client, Error, Worker, WorkerProtocolVersion, SDK_VERSION}; +use serde_json::{json, Value}; +use std::{ + path::Path, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, + time::{Duration, Instant}, +}; + +type Result = std::result::Result>; +const TYPE: &str = "parity.query-authority.rust.v1"; + +#[derive(Default)] +struct Counters { + calls: AtomicUsize, + returned: AtomicUsize, + cancelled: AtomicUsize, +} +struct Callback { + counters: Arc, + returned: bool, +} +impl Drop for Callback { + fn drop(&mut self) { + if !self.returned { + self.counters.cancelled.fetch_add(1, Ordering::SeqCst); + } + } +} +fn expected() -> Value { + json!({"long":9223372036854775807_i64,"nil":null,"text":"rangeλ"}) +} +fn events(path: &Path) -> Vec { + let text = std::fs::read_to_string(path).unwrap_or_default(); + let complete = text + .rsplit_once('\n') + .map(|(prefix, _)| prefix) + .unwrap_or(""); + complete + .lines() + .filter(|line| !line.is_empty()) + .map(|line| serde_json::from_str(line).unwrap()) + .collect() +} +fn named<'a>(events: &'a [Value], event: &str) -> Vec<&'a Value> { + events + .iter() + .filter(|value| value["event"] == event) + .collect() +} +async fn wait(until: Instant, mut condition: impl FnMut() -> bool) -> Result<()> { + while !condition() { + if Instant::now() >= until { + return Err("Original thirty-second observation expired".into()); + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + Ok(()) +} + +#[tokio::main(flavor = "multi_thread", worker_threads = 2)] +async fn main() -> Result<()> { + let arguments: Vec = std::env::args().skip(1).collect(); + let [url, mode, protocol, journal] = arguments.as_slice() else { + return Err("Expected URL MODE PROTOCOL JOURNAL".into()); + }; + let profile: Value = + serde_json::from_slice(&std::fs::read(std::env::var("DW_PARITY_QUERY_PROFILE")?)?)?; + assert_eq!( + SDK_VERSION, + format!( + "durable-workflow-rust/{}", + profile["sdk_rust"]["version"].as_str().unwrap() + ) + ); + assert!(matches!( + mode.as_str(), + "timely" | "late-poll" | "late-handler-async" | "late-handler-sync" + )); + assert!(protocol == "1.20" || protocol == "1.19" && mode == "timely"); + let started = Instant::now(); + let until = started + Duration::from_secs(30); + let namespace = format!("query-rust-{}", durable_workflow::Uuid::new_v4().simple()); + let token = std::env::var("DW_PARITY_TOKEN")?; + let response = reqwest::Client::new() + .post(format!("{url}/api/namespaces")) + .bearer_auth(&token) + .header("X-Namespace", "default") + .header("X-Durable-Workflow-Control-Plane-Version", "2") + .json(&json!({"name":namespace})) + .send() + .await?; + assert_eq!(response.status(), reqwest::StatusCode::CREATED); + let mut builder = Client::builder(url) + .namespace(&namespace) + .token(Some(token)); + if protocol == "1.19" { + builder = builder.worker_protocol_version(WorkerProtocolVersion::V119); + } + let client = builder.build()?; + let counters = Arc::new(Counters::default()); + let mut worker = Worker::new(client.clone(), &namespace) + .worker_id(&namespace) + .poll_timeout(Duration::from_secs(8)) + .require_query_task_authority(protocol == "1.20"); + worker.register_workflow(TYPE, |context, arguments| async move { + if arguments != json!([7]) { + return Err(Error::WorkerLoop("Original input changed".into())); + } + context.sleep(Duration::from_secs(60)).await?; + Ok(json!(7)) + }); + let callback_counters = Arc::clone(&counters); + let callback_mode = mode.clone(); + worker.register_query(TYPE, "value", move |context, arguments| { + let counters = Arc::clone(&callback_counters); + let mode = callback_mode.clone(); + async move { + counters.calls.fetch_add(1, Ordering::SeqCst); + let mut callback = Callback { + counters: Arc::clone(&counters), + returned: false, + }; + if arguments != json!([]) || context.workflow_input() != &json!([7]) { + return Err(Error::WorkerLoop("Original query snapshot changed".into())); + } + if mode == "late-handler-async" { + tokio::time::sleep(Duration::from_secs(6)).await; + } + if mode == "late-handler-sync" { + std::thread::sleep(Duration::from_secs(6)); + } + callback.returned = true; + counters.returned.fetch_add(1, Ordering::SeqCst); + Ok(expected()) + } + }); + let (shutdown, stopped) = tokio::sync::oneshot::channel(); + let runner = tokio::spawn(async move { + worker + .run_until(async { + let _ = stopped.await; + }) + .await + }); + let outcome: Result = async { + wait(until, || { + named(&events(Path::new(journal)), "registered") + .iter() + .any(|event| event["status"] == 201) + }) + .await?; + client + .start_workflow(TYPE, &namespace, &namespace, json!([7])) + .await?; + let run_id = loop { + let run = client.describe_workflow(&namespace).await?; + if run.status.as_deref() == Some("waiting") { + break run.run_id.ok_or("Original run ID missing")?; + } + if Instant::now() >= until { + return Err("Original timer admission expired".into()); + } + tokio::time::sleep(Duration::from_millis(20)).await; + }; + let query = client.query_workflow(&namespace, "value", json!([])).await; + if mode == "timely" { + assert_eq!(query?, expected()); + } else { + match query { + Err(Error::QueryFailed(failure)) => { + assert_eq!(failure.status, 504); + assert_eq!(failure.reason, "query_worker_execution_timeout"); + } + other => { + return Err(format!( + "Expired original did not retain its caller timeout: {other:?}" + ) + .into()) + } + } + wait(until, || match mode.as_str() { + "late-poll" => named(&events(Path::new(journal)), "status") + .iter() + .any(|entry| { + entry["observation"]["can_continue"] == false + && matches!( + entry["observation"]["reason"].as_str(), + Some("query_task_timed_out" | "query_lease_expired") + ) + }), + "late-handler-async" => counters.cancelled.load(Ordering::SeqCst) == 1, + "late-handler-sync" => counters.returned.load(Ordering::SeqCst) == 1, + _ => false, + }) + .await?; + } + Ok(run_id) + } + .await; + let _ = shutdown.send(()); + tokio::time::timeout(until.saturating_duration_since(Instant::now()), runner).await???; + let run_id = outcome?; + let observed = events(Path::new(journal)); + assert!( + named(&observed, "observer-error").is_empty(), + "{:?}", + named(&observed, "observer-error") + ); + assert_eq!(named(&observed, "claim").len(), 1); + assert_eq!(named(&observed, "delivered").len(), 1); + assert_eq!(named(&observed, "query-result").len(), 1); + let submissions = if mode == "timely" { 1 } else { 0 }; + assert_eq!(named(&observed, "submission").len(), submissions); + assert_eq!(named(&observed, "acknowledgement").len(), submissions); + assert_eq!( + named(&observed, "deregistered").last().unwrap()["status"], + 200 + ); + assert_eq!( + counters.calls.load(Ordering::SeqCst), + usize::from(mode != "late-poll") + ); + assert_eq!( + counters.returned.load(Ordering::SeqCst), + usize::from(mode == "timely" || mode == "late-handler-sync") + ); + assert_eq!( + counters.cancelled.load(Ordering::SeqCst), + usize::from(mode == "late-handler-async") + ); + let claim_event = named(&observed, "claim")[0]; + let claim = &claim_event["claim"]; + assert_eq!(claim_event["protocol"], protocol.as_str()); + let status = named(&observed, "status"); + let discovery = named(&observed, "discovery"); + if protocol == "1.20" { + assert!(!discovery.is_empty()); + assert!(discovery + .iter() + .all(|entry| entry["status"] == 200 && entry["query_task_authority"] == true)); + if status.is_empty() { + // Immediate expiry may legally skip status. This SDK exposes no + // public refusal reason, so this recipe cannot qualify that case + // from zero callbacks alone. + return Err("No retained Rust expiry disposition; observation is unqualified".into()); + } + } else { + assert!(status.is_empty()); + } + for observation in &status { + for field in [ + "query_task_id", + "query_task_attempt", + "lease_owner", + "query_admitted_at", + "query_deadline_at", + "lease_expires_at", + ] { + assert_eq!(observation["observation"][field], claim[field], "{field}"); + } + } + assert!(Instant::now() < until); + println!( + "{}", + json!({"outcome":"pass","language":"rust","mode":mode,"protocol":protocol, + "sdk_version":SDK_VERSION,"namespace":namespace,"run_id":run_id,"claim":claim, + "calls":counters.calls.load(Ordering::SeqCst),"returned":counters.returned.load(Ordering::SeqCst), + "cancelled":counters.cancelled.load(Ordering::SeqCst),"status_reads":status.len(), + "authority_discovery":discovery, + "history_sha256":named(&observed,"query-result")[0]["history_sha256"], + "original_budget_seconds":30,"elapsed_seconds":started.elapsed().as_secs_f64(),"worker_exit":0}) + ); + Ok(()) +} diff --git a/scripts/conformance/server-parity/query-authority/worker.php b/scripts/conformance/server-parity/query-authority/worker.php new file mode 100644 index 000000000..d659f2864 --- /dev/null +++ b/scripts/conformance/server-parity/query-authority/worker.php @@ -0,0 +1,294 @@ +inner = new Psr18Transport(); + } + + public function supportsBoundedRequests(): bool { return $this->inner->supportsBoundedRequests(); } + public function send(string $method, string $uri, array $headers, ?array $body = null): ?array + { + $this->beforeSend($method, $uri, $body); + return $this->observe($uri, $this->inner->send($method, $uri, $headers, $body)); + } + public function sendBounded(string $method, string $uri, array $headers, ?array $body, int $timeoutSeconds): ?array + { + $this->beforeSend($method, $uri, $body); + return $this->observe($uri, $this->inner->sendBounded($method, $uri, $headers, $body, $timeoutSeconds)); + } + public function uploadPayload(string $uri, array $headers, string $blob, int $timeoutSeconds): array + { return $this->inner->uploadPayload($uri, $headers, $blob, $timeoutSeconds); } + public function uploadPayloadBounded(string $uri, array $headers, string $blob, int $timeoutSeconds): array + { return $this->inner->uploadPayloadBounded($uri, $headers, $blob, $timeoutSeconds); } + public function fetchPayload(string $uri, array $headers, int $maxBytes): string + { return $this->inner->fetchPayload($uri, $headers, $maxBytes); } + public function fetchPayloadBounded(string $uri, array $headers, int $maxBytes, int $timeoutSeconds): string + { return $this->inner->fetchPayloadBounded($uri, $headers, $maxBytes, $timeoutSeconds); } + public function fetchPayloadWithProgress(string $uri, array $headers, int $maxBytes, Closure $progress): string + { return $this->inner->fetchPayloadWithProgress($uri, $headers, $maxBytes, $progress); } + public function fetchPayloadWithBoundedProgress(string $uri, array $headers, int $maxBytes, Closure $progress, ?int $timeoutSeconds = null): string + { return $this->inner->fetchPayloadWithBoundedProgress($uri, $headers, $maxBytes, $progress, $timeoutSeconds); } + + private function beforeSend(string $method, string $uri, ?array $body): void + { + $path = parse_url($uri, PHP_URL_PATH); + if ($method === 'POST' && preg_match('~/query-tasks/[^/]+/(complete|fail)$~', $path)) { + // A rejected late POST is still a submission. Observe before I/O, + // rather than counting successful acknowledgements alone. + ($this->record)('submission', ['path' => $path, + 'lease_owner' => $body['lease_owner'] ?? null, + 'query_task_attempt' => $body['query_task_attempt'] ?? null]); + } + } + + private function observe(string $uri, ?array $reply): ?array + { + $path = parse_url($uri, PHP_URL_PATH); + if ($path === '/api/worker/query-tasks/poll' && is_array($reply['task'] ?? null)) { + ($this->record)('claim', $reply['task']); + if ($this->mode === 'late-poll') { + sleep(6); + } + } elseif (str_contains($path, '/query-tasks/') && str_ends_with($path, '/status')) { + ($this->record)('status', $reply ?? []); + } elseif (preg_match('~/query-tasks/[^/]+/(complete|fail)$~', $path)) { + ($this->record)('acknowledgement', $reply ?? []); + } + + return $reply; + } +} + +[$script, $url, $mode, $protocol] = $argv; +queryCheck(in_array([$mode, $protocol], [ + ['timely', '1.20'], ['timely', '1.19'], ['late-poll', '1.20'], ['late-handler', '1.20'], ['timely-active', '1.20'], + ['timely-named-active', '1.20'], +], true), 'Unknown query authority case.'); +$artifacts = json_decode(file_get_contents(__DIR__.'/../../../../tests/Fixtures/ServerParityProfiles/query-authority/artifacts.json'), true, flags: JSON_THROW_ON_ERROR); +queryCheck(InstalledVersions::getPrettyVersion('durable-workflow/sdk') === $artifacts['sdk_php']['version'] + && InstalledVersions::getReference('durable-workflow/sdk') === $artifacts['sdk_php']['source'], 'The installed SDK is not the separate qualified published tuple.'); +foreach ($artifacts['sdk_php']['loaded_sha256'] as $class => $sha256) { + queryCheck(hash_file('sha256', (new ReflectionClass($class))->getFileName()) === $sha256, + 'Loaded published SDK bytes differ: '.$class); +} +queryCheck(function_exists('pcntl_fork') && function_exists('posix_kill'), 'This real Worker fixture requires Unix CLI.'); +$started = hrtime(true) / 1e9; +$until = $started + $artifacts['original_observation_seconds']; +$workflowId = 'parity-query-authority-'.bin2hex(random_bytes(6)); +$active = in_array($mode, ['timely-active', 'timely-named-active'], true); +$timely = in_array($mode, ['timely', 'timely-active', 'timely-named-active'], true); +$namespace = $mode === 'timely-active' ? 'default' : $workflowId; +$token = getenv('DW_PARITY_TOKEN') ?: 'query-authority-fixture-token'; +$admin = new Client($url, token: $token); +if ($namespace !== 'default') { + $admin->createNamespace($namespace); +} +$client = new Client($url, namespace: $namespace, token: $token); +$journal = tempnam(sys_get_temp_dir(), 'query-authority-'); +$record = static function (string $event, array $details = []) use ($journal): void { + file_put_contents($journal, json_encode(['event' => $event, 'observed_at' => microtime(true), + 'details' => $details], JSON_THROW_ON_ERROR)."\n", FILE_APPEND | LOCK_EX); +}; +$read = static function () use ($journal): array { + $file = fopen($journal, 'rb'); + queryCheck(is_resource($file), 'Could not read the original Worker journal.'); + try { + queryCheck(flock($file, LOCK_SH), 'Could not lock the original Worker journal.'); + $bytes = stream_get_contents($file); + } finally { + flock($file, LOCK_UN); + fclose($file); + } + return array_map(static fn (string $line): array => json_decode($line, true, flags: JSON_THROW_ON_ERROR), + array_values(array_filter(explode("\n", $bytes), static fn (string $line): bool => $line !== ''))); +}; +$pid = 0; +$handle = null; +$result = null; +try { + $pid = pcntl_fork(); + queryCheck($pid >= 0, 'Could not fork the real Worker.'); + if ($pid === 0) { + try { + $worker = new Worker(new Client($url, namespace: $namespace, token: $token, + transport: new QueryAuthorityTransport($mode, $record), workerProtocolVersion: $protocol), $workflowId, + clock: static function () use ($until): float { + queryCheck(hrtime(true) / 1e9 < $until, 'Original Worker observation deadline expired.'); + return microtime(true); + }, diagnosticListener: static function (string $event, array $details) use ($record): void { + if ($event === 'worker.claim_aborted') { + $record('aborted', $details); + } elseif ($event === 'worker.registered') { + $record('registered', $details); + } + }, requireQueryTaskAuthority: $protocol === '1.20'); + $worker->registerWorkflow('parity.query-authority.v1', static function (WorkflowContext $context) use ($active): int { + if ($active) { + $context->sleep(60); + } + return 7; + }); + $worker->registerQuery('parity.query-authority.v1', 'value', static function (QueryContext $context) use ($mode, $record): array { + $record('handler'); + if ($mode === 'late-handler') { + sleep(6); + } + $record('returned'); + return ['long' => 9007199254740993, 'nil' => null, 'text' => 'rangeλ']; + }); + pcntl_async_signals(true); + pcntl_signal(SIGTERM, static fn () => $worker->requestShutdown()); + $worker->run(0); + $record('stopped'); + exit(0); + } catch (Throwable $error) { + $record('actor-error', ['type' => $error::class, 'message' => $error->getMessage()]); + exit(1); + } + } + do { + $events = $read(); + queryCheck(!in_array('actor-error', array_column($events, 'event'), true), 'The Worker refused startup: '.json_encode($events)); + if (in_array('registered', array_column($events, 'event'), true)) { + break; + } + usleep(20000); + } while (hrtime(true) / 1e9 < $until); + queryCheck(hrtime(true) / 1e9 < $until, 'No Worker registered within the original observation.'); + $handle = $client->startWorkflow('parity.query-authority.v1', $workflowId, $workflowId); + $expectedStatus = $active ? 'waiting' : 'completed'; + do { + $status = strtolower((string) $handle->describe()->status); + if ($status === $expectedStatus) { + break; + } + usleep(20000); + } while (hrtime(true) / 1e9 < $until); + queryCheck($status === $expectedStatus, 'The original did not reach its committed '.$expectedStatus.' state.'); + $before = $client->workflowHistory($workflowId, $handle->selectedRunId); + $expected = ['long' => 9007199254740993, 'nil' => null, 'text' => 'rangeλ']; + if ($timely) { + queryCheck($handle->query('value') === $expected, 'The actual typed query result changed.'); + // The independent caller can observe the commit before the Worker + // receives its acknowledgement. Wait inside the original budget. + do { + $events = $read(); + queryCheck(!in_array('actor-error', array_column($events, 'event'), true), 'The Worker refused the original outcome.'); + if (in_array('acknowledgement', array_column($events, 'event'), true)) { + break; + } + usleep(20000); + } while (hrtime(true) / 1e9 < $until); + } else { + try { + $handle->query('value'); + throw new RuntimeException('An expired original query succeeded.'); + } catch (QueryFailed $error) { + queryCheck($error->status === 504 && ($error->details['reason'] ?? null) === 'query_worker_execution_timeout', + 'The original caller did not retain its published timeout diagnostic.'); + } + do { + if (in_array('aborted', array_column($read(), 'event'), true)) { + break; + } + usleep(20000); + } while (hrtime(true) / 1e9 < $until); + queryCheck(in_array('aborted', array_column($read(), 'event'), true), 'The Worker did not refuse its expired original.'); + } + $events = $read(); + $named = static fn (string $name): array => array_values(array_filter($events, static fn (array $event): bool => $event['event'] === $name)); + queryCheck(count($named('claim')) === 1, 'Expected one actual original query claim.'); + $claim = $named('claim')[0]['details']; + queryCheck(($claim['workflow_id'] ?? null) === $workflowId && ($claim['run_id'] ?? null) === $handle->selectedRunId + && ($claim['task_queue'] ?? null) === $workflowId && ($claim['namespace'] ?? $client->namespace) === $namespace + && ($claim['query_task_attempt'] ?? null) === 1, 'The delivery changed its original run, namespace or attempt.'); + foreach (['query_admitted_at', 'query_deadline_at', 'lease_expires_at', 'server_time'] as $field) { + queryCheck(is_string($claim[$field] ?? null) && preg_match('/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{6}Z$/D', $claim[$field]) === 1, + 'Query timing is not canonical UTC6: '.$field); + } + $admitted = new DateTimeImmutable($claim['query_admitted_at']); + $deadline = new DateTimeImmutable($claim['query_deadline_at']); + queryCheck((float) $deadline->format('U.u') - (float) $admitted->format('U.u') === 4.0, + 'The original admission-to-result deadline is not four seconds.'); + queryCheck(new DateTimeImmutable($claim['lease_expires_at']) <= $deadline, 'The lease extends beyond original result authority.'); + foreach ($named('status') as $observation) { + foreach (['query_task_id', 'query_task_attempt', 'lease_owner', 'query_admitted_at', 'query_deadline_at', 'lease_expires_at'] as $field) { + queryCheck(($observation['details'][$field] ?? null) === $claim[$field], 'Status changed original query authority: '.$field); + } + } + queryCheck(($named('status') !== []) === ($protocol === '1.20'), 'Protocol status use does not match the declared boundary.'); + queryCheck(count($named('handler')) === ($mode === 'late-poll' ? 0 : 1), 'The callback dispatched outside its original authority.'); + queryCheck(count($named('submission')) === ($timely ? 1 : 0), 'An expired callback attempted a terminal submission.'); + queryCheck(count($named('acknowledgement')) === ($timely ? 1 : 0), 'The original Worker acknowledgement count changed.'); + queryCheck($before === $client->workflowHistory($workflowId, $handle->selectedRunId), 'Query execution changed the original workflow history.'); + queryCheck($named('actor-error') === [] && hrtime(true) / 1e9 < $until, 'The original Worker observation failed.'); + $result = ['outcome' => 'pass', 'mode' => $mode, 'protocol' => $protocol, 'workflow_id' => $workflowId, + 'namespace' => $namespace, 'original_run_status' => $expectedStatus, + 'run_id' => $handle->selectedRunId, 'claim' => $claim, 'observations' => $events, + 'original_budget_seconds' => 30, 'sdk_version' => InstalledVersions::getPrettyVersion('durable-workflow/sdk'), + 'sdk_source' => InstalledVersions::getReference('durable-workflow/sdk'), + 'runner_sha256' => hash_file('sha256', __FILE__), + 'artifacts_sha256' => hash_file('sha256', __DIR__.'/../../../../tests/Fixtures/ServerParityProfiles/query-authority/artifacts.json'), + 'sdk_lock_sha256' => hash_file('sha256', dirname(getenv('DW_PARITY_SDK_AUTOLOAD'), 2).'/composer.lock'), + 'worker_sha256' => hash_file('sha256', (new ReflectionClass(Worker::class))->getFileName()), + 'history_sha256' => hash('sha256', json_encode($before, JSON_THROW_ON_ERROR))]; +} catch (Throwable $error) { + fwrite(STDERR, json_encode(['outcome' => 'fail', 'mode' => $mode, 'protocol' => $protocol, + 'workflow_id' => $workflowId, 'namespace' => $namespace, 'run_id' => $handle?->selectedRunId, + 'error' => $error->getMessage(), 'observations' => $read()], JSON_THROW_ON_ERROR)."\n"); + throw $error; +} finally { + $drained = false; + if ($pid > 0) { + posix_kill($pid, SIGTERM); + do { + $ended = pcntl_waitpid($pid, $status, WNOHANG); + if ($ended === $pid) { + break; + } + usleep(20000); + } while (hrtime(true) / 1e9 < $until); + if ($ended !== $pid) { + posix_kill($pid, SIGKILL); + pcntl_waitpid($pid, $status); + } + $drained = pcntl_wifexited($status) && pcntl_wexitstatus($status) === 0; + } + // The caller destroys this profile's isolated database. Query authority + // does not depend on a separate workflow-termination capability. + unlink($journal); + queryCheck($pid > 0 && $drained, 'The real Worker did not exit zero and deregister within the original observation.'); +} +queryCheck(hrtime(true) / 1e9 < $until, 'Cleanup exceeded the original observation budget.'); +echo json_encode($result + ['elapsed_seconds' => hrtime(true) / 1e9 - $started, 'worker_exit' => 0], + JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE)."\n"; diff --git a/tests/Fixtures/ServerParityProfiles/query-authority/README.md b/tests/Fixtures/ServerParityProfiles/query-authority/README.md new file mode 100644 index 000000000..723c3f9da --- /dev/null +++ b/tests/Fixtures/ServerParityProfiles/query-authority/README.md @@ -0,0 +1,88 @@ +# Current query authority profile + +This separate profile uses normally installed PHP SDK 2.4.2 and published Server +2.8.0. It does not replace the frozen query actors. The earlier five-case profile +passes on both targets using separate SQLite databases. A fresh six-case cohort, +including the additional named waiting-workflow case, also passes on both +SQLite targets. Earlier four-case +MySQL observations are separate. The earlier active native termination-cleanup +failure and PHP acknowledgement-observer race remain failed. All-six +qualification is pending. +Main does not advertise native query authority. The unpublished +candidate advertises it for installed-consumer qualification before promotion. + +The owning contract is [Server #433](https://github.com/durable-workflow/server/issues/433). +`artifacts.json` freezes the consumer, reference image and original budgets. +Install the adjacent `scripts/conformance/server-parity/query-authority` +Composer project normally. Run its `worker.php URL MODE PROTOCOL` in a Unix PHP +container with `DW_PARITY_SDK_AUTOLOAD` pointing at that installation and +`DW_PARITY_TOKEN` set to the isolated Server token. +The host wrapper `scripts/conformance/server-parity/query-authority-profile.sh` +takes `NETWORK SERVER_CONTAINER URL SDK_DIRECTORY OUTPUT_DIRECTORY TARGET`, +where `TARGET` is `php` or `rust`. Set a unique `RESOURCE_SCOPE` and the isolated +token before invoking it. It refuses an existing case output instead of +overwriting a failed original. + +The hosted entry point `query-authority-cohort.sh BACKEND` creates separate PHP +and native databases, installs the locked SDK normally, and runs every declared case +against each target. `BACKEND` is `sqlite`, `mysql` or `pgsql`; the existing six +database jobs provide their pinned database images and isolated network. It +reuses the job's compiled native binary. Its separate always-step invokes +`query-authority-cleanup.sh BACKEND`, which checks ownership, stops both APIs, +records their actual exit states, and removes the two databases and SDK state. +The small comparison keeps original run identities and history hashes; raw +query snapshots are excluded from retained job summaries. + +Both services must use separate disposable databases, shared process-visible +polling caches and `DW_QUERY_TASK_TIMEOUT=4`. The caller owns bootstrap, +readiness and cleanup. Run each declared case once against each target. Every +original has one unchanged 30-second observation budget covering setup, query, +Worker drain and deregistration. The caller must also bound container lifetime +and remove its resources after success or failure. +The Worker stops within each original budget. The caller destroys the entire +isolated database afterward, including its active workflow and task namespaces. +This profile does not qualify workflow termination or namespace DELETE, which +are still absent in native source. It makes the same cleanup calls on both targets. +Startup waits for the installed Worker's `worker.registered` diagnostic, emitted +after its actual registration acknowledgement. It does not depend on worker +listing, which is also outside the current native slice. + +The four deadline cases use completed workflows in distinct named namespaces. +The timely-active and timely-named-active cases use genuine waiting timer +workflows in the default namespace and a distinct named namespace respectively. +The latter covers named-timer admission and query delivery together. Earlier +waiting named originals remain failed; this new case cannot rescue them. +Timely 1.20 and 1.19 controls retain the exact int64/null/Unicode result. +Separate late controls hold a genuine poll reply for six seconds or let a +synchronous callback return after six seconds. They require the original caller +timeout, no late completion submission and no callback dispatch after an expired +poll reply. Query status preserves the original claim and timing without +renewal. Protocol 1.19 makes no status request. Every query preserves the exact +workflow history and the Worker must exit zero. +The raw delivery is retained. PHP may omit its namespace member, as supported +by the published SDK, in which case the authenticated Client namespace applies. +An explicit conflicting namespace still fails. The earlier failed originals and +the exact four-case match are recorded in +[the owning query checkpoint](https://github.com/durable-workflow/server/issues/433#issuecomment-6184509976). + +The separate installed Python 2.5.15 and Rust 3.5.5 consumers pass twenty actual +SQLite originals across PHP and native targets at runner `a72c63277`. Both +languages cover timely 1.20/1.19, a six-second held poll response, async expiry +and blocking-handler expiry. Original query/whole budgets remain four/thirty +seconds. Typed results, unchanged history, original claim/timing, no late +submission and successful Worker/observer shutdown are checked. The +[local outcome](https://github.com/durable-workflow/server/issues/433#issuecomment-6185525047) +preserves the earlier failed observer control and exact installed identities. + +`query-authority-adapters.sh` installs the hash-locked Python wheel and builds +the normal registry Rust consumer once. It shares only checked runtime files +with the database jobs. `query-authority-clients-profile.sh` executes both +languages through a bounded byte-preserving observer; its per-case observers +stop after observation and the workflow's always-step removes them. Changes +after the local outcome still require an exact combined hosted qualification. + +External query loading, remaining route/pressure and continuation boundaries, +all-six consumer integration and capability qualification remain ahead. These +are correctness controls and establish no performance result. Completed raw +observations and build caches are deleted after audit; readable outcomes and +reproduction code remain. diff --git a/tests/Fixtures/ServerParityProfiles/query-authority/artifacts.json b/tests/Fixtures/ServerParityProfiles/query-authority/artifacts.json new file mode 100644 index 000000000..c23e52e28 --- /dev/null +++ b/tests/Fixtures/ServerParityProfiles/query-authority/artifacts.json @@ -0,0 +1,55 @@ +{ + "schema": "durable-workflow.server-query-authority/v1", + "qualification": "prepared-not-qualified", + "server": { + "version": "2.8.0", + "source": "dd720e6b51844707be2885f8873c23de98c847a7", + "image": "durableworkflow/server@sha256:cba7c4c6732529293ef6c2625a64785dcb21b4f7fe0013fdc74f1af466f534ac" + }, + "sdk_php": { + "version": "2.4.2", + "source": "25d796ad2a55d0d114b58017c3ba23390b09e120", + "loaded_sha256": { + "DurableWorkflow\\Worker": "eb59fde750a37c755b551a63f93c3882039310673fe9538eec63b5f68bfd0891", + "DurableWorkflow\\Client": "5b53ceaa2aca9a4f33cd680f1786f642436fe06874a76efcc8987669b33fcc4c", + "DurableWorkflow\\Worker\\QueryTaskAuthority": "264df07849d2ba8343238b12ad9eef89920d370d61b6a4bd26a3b4895bc858b5", + "DurableWorkflow\\Transport\\Psr18Transport": "0857170cf128876338040f197ba469c5c6431cbb020da0453705c3798c60883a" + } + }, + "sdk_python": { + "version": "2.5.15", + "source": "35aad4ab344cf13e01de3e5f4704380eac1532a6", + "wheel_sha256": "b905d7e440d5d3708c4570ad8c8986d2ff83149bee6ba4bce209992ad3e8a7dc", + "loaded_files": 34, + "loaded_tree_sha256": "88a3d64aee16523fcd577ab005ad6cda094349c4f88ee9f61fec3b8e4e679e57" + }, + "sdk_rust": { + "version": "3.5.5", + "source": "4d5047ba9da39785512340b0975ce28da09f0cee", + "crate_sha256": "04147fcb82f749ea1e308c0a3836e30454efc415d5933884d615ca4aae76bb2e" + }, + "client_images": { + "python": "mirror.gcr.io/library/python@sha256:bf44cdfcb76cd3b41e879bc058fc37ec5872002ccfde7fcb765e218cde0cd79c", + "rust": "mirror.gcr.io/library/rust@sha256:ba81bc3eaa4422af576c0262515d96b0111a628a6ccc2c86557cf55c9a4bbee0" + }, + "client_cases": [ + {"mode": "timely", "protocol": "1.20"}, + {"mode": "timely", "protocol": "1.19"}, + {"mode": "late-poll", "protocol": "1.20"}, + {"mode": "late-handler-async", "protocol": "1.20"}, + {"mode": "late-handler-sync", "protocol": "1.20"} + ], + "query_timeout_seconds": 4, + "original_observation_seconds": 30, + "delayed_response_seconds": 6, + "cases": [ + {"mode": "timely", "protocol": "1.20"}, + {"mode": "timely", "protocol": "1.19"}, + {"mode": "late-poll", "protocol": "1.20"}, + {"mode": "late-handler", "protocol": "1.20"}, + {"mode": "timely-active", "protocol": "1.20"}, + {"mode": "timely-named-active", "protocol": "1.20"} + ], + "embedded": "inapplicable: service query claims and status have no embedded worker lease", + "performance": "not_measured" +} diff --git a/tests/Unit/ServerParityArtifactSummaryTest.mjs b/tests/Unit/ServerParityArtifactSummaryTest.mjs index cbaa3d422..3dbe14f49 100644 --- a/tests/Unit/ServerParityArtifactSummaryTest.mjs +++ b/tests/Unit/ServerParityArtifactSummaryTest.mjs @@ -92,6 +92,27 @@ test('empty actor diagnostics cannot hide failed profile status or the actual er assert.equal(summary.retained[0].path, 'sdk-profile.status'); }); +test('query client failure survives a full collection of earlier successful setup logs', t => { + const {source, output, root} = fixture(t); + for (let index = 0; index < 140; index++) { + writeFileSync(join(source, `completed-${index}-install.txt`), 'installed successfully\n'); + } + const path = 'query-authority/clients/rust/python-late-poll-120'; + mkdirSync(join(source, path), {recursive: true}); + writeFileSync(join(source, path, 'client.stderr'), 'AssertionError: no original expiry reason\n'); + writeFileSync(join(source, path, 'observer.log'), 'observer error: upstream response closed\n'); + writeFileSync(join(source, 'query-authority/profile-status.txt'), '1\n'); + const summary = summarizeEvidence(source, output, {outcome: 'failure'}); + assert.equal(readFileSync(join(output, path, 'client.stderr'), 'utf8'), 'AssertionError: no original expiry reason\n'); + assert.equal(readFileSync(join(output, path, 'observer.log'), 'utf8'), 'observer error: upstream response closed\n'); + assert.equal(summary.retained.length, 128); + assert.equal(summary.limits.content_bytes, 2 * 1024 * 1024); + const success = join(root, 'success'); + summarizeEvidence(source, success, {outcome: 'success'}); + assert.equal(existsSync(join(success, path, 'client.stderr')), false); + assert.equal(existsSync(join(success, path, 'observer.log')), false); +}); + test('failed recorder diagnostic tails survive without retaining raw records or enlarging limits', t => { const {source, output, root} = fixture(t); const diagnostic = `${'x'.repeat(128 * 1024)}\nOriginal query observation failed\n`; diff --git a/tests/Unit/ServerParityQueryObserverTest.mjs b/tests/Unit/ServerParityQueryObserverTest.mjs new file mode 100644 index 000000000..f5f3292f4 --- /dev/null +++ b/tests/Unit/ServerParityQueryObserverTest.mjs @@ -0,0 +1,83 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import {spawn} from 'node:child_process'; +import {once} from 'node:events'; +import {mkdtempSync, readFileSync, rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {fileURLToPath} from 'node:url'; + +test('observer preserves response bytes and refuses invalid dates and microsecond authority changes', {timeout: 5000}, async t => { + const directory = mkdtempSync(join(tmpdir(), 'query-observer-')); + t.after(() => rmSync(directory, {recursive: true, force: true})); + const journal = join(directory, 'journal.jsonl'); + const date = '2026-02-28'; + let overrides = {}; + let original; + const upstream = http.createServer(async (request, response) => { + for await (const _chunk of request) { /* Drain this constructed poll body. */ } + original = JSON.stringify({task: { + query_task_id: 'original-query', query_task_attempt: 1, + namespace: 'isolated', task_queue: 'queue', lease_owner: 'worker', + workflow_id: 'workflow', run_id: 'run', + query_admitted_at: date + 'T00:00:00.123456Z', + query_deadline_at: date + 'T00:00:04.123456Z', + lease_expires_at: date + 'T00:00:04.123456Z', + server_time: date + 'T00:00:00.123456Z', + ...overrides, + }}); + response.writeHead(200, {'content-type': 'application/json'}); + response.end(original); + }); + upstream.listen(0, '127.0.0.1'); + await once(upstream, 'listening'); + t.after(() => new Promise(resolve => upstream.close(resolve))); + const observer = spawn(process.execPath, [ + fileURLToPath(new URL('../../scripts/conformance/server-parity/query-authority/observer.mjs', import.meta.url)), + `http://127.0.0.1:${upstream.address().port}`, 'timely', journal, + ], {env: {...process.env, DW_PARITY_QUERY_OBSERVER_PORT: '0'}, stdio: ['ignore', 'pipe', 'pipe']}); + const exited = once(observer, 'exit'); + t.after(async () => { observer.kill('SIGTERM'); assert.deepEqual(await exited, [0, null]); }); + let diagnostic = ''; + observer.stderr.on('data', chunk => { diagnostic += chunk; }); + const ready = await new Promise((resolve, reject) => { + let output = ''; + observer.on('error', reject); + observer.stdout.on('data', chunk => { + output += chunk; + if (output.includes('\n')) resolve(JSON.parse(output.split('\n')[0])); + }); + observer.on('exit', () => reject(Error('Observer stopped before readiness: ' + diagnostic))); + }); + assert.equal(ready.ready, true); + const poll = () => new Promise((resolve, reject) => { + const request = http.request(`http://127.0.0.1:${ready.port ?? 8080}/api/worker/query-tasks/poll`, { + method: 'POST', headers: {'X-Namespace': 'isolated', 'X-Durable-Workflow-Protocol-Version': '1.20'}, + }, response => { + let bytes = ''; + response.on('data', chunk => { bytes += chunk; }); + response.on('error', reject); + response.on('end', () => resolve({status: response.statusCode, bytes})); + }); + request.on('error', reject); + request.end(JSON.stringify({worker_id: 'worker', task_queue: 'queue'})); + }); + assert.deepEqual(await poll(), {status: 200, bytes: original}); + const invalid = [ + Object.fromEntries(['query_admitted_at', 'query_deadline_at', 'lease_expires_at', 'server_time'].map(key => + [key, `2026-02-30T00:00:${key === 'query_deadline_at' || key === 'lease_expires_at' ? '04' : '00'}.123456Z`])), + {query_deadline_at: date + 'T00:00:04.123457Z'}, + {lease_expires_at: date + 'T00:00:04.123457Z'}, + {server_time: date + 'T00:00:00.123455Z'}, + ]; + for (const fields of invalid) { + overrides = fields; + await assert.rejects(poll(), /socket hang up|reset|aborted/i, JSON.stringify(fields)); + } + const events = readFileSync(journal, 'utf8').trim().split('\n').map(line => JSON.parse(line)); + assert.equal(events.filter(event => event.event === 'claim').length, 1); + assert.equal(events.filter(event => event.event === 'delivered').length, 1); + assert.equal(events.filter(event => event.event === 'observer-error').length, invalid.length); + assert.match(events.find(event => event.event === 'observer-error').message, /query_admitted_at/); +});