From f83855d65e75be986362f82ea17a5d43a90c614f Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 15:21:31 +0000 Subject: [PATCH 1/6] Preserve binary Avro query results when JSON projection is unavailable --- scripts/ci/run-codec-regression-fixture.php | 8 +- src/Client.php | 15 +++- tests/QueryResultProjectionTest.php | 85 +++++++++++++++++++ tests/Support/CodecRegressionFixture.php | 41 +++++++++ .../query-nested-binary-result.json | 22 +++++ 5 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 tests/QueryResultProjectionTest.php create mode 100644 tests/fixtures/codec-regressions/query-nested-binary-result.json diff --git a/scripts/ci/run-codec-regression-fixture.php b/scripts/ci/run-codec-regression-fixture.php index fb0f8fd6..4f160f80 100644 --- a/scripts/ci/run-codec-regression-fixture.php +++ b/scripts/ci/run-codec-regression-fixture.php @@ -56,14 +56,20 @@ function reportOutcome(string $outcome): void 'Apache\\Avro\\' => $avroSource, 'Psr\\Container\\' => $psrContainerSource, 'Psr\\Log\\' => $psrLogSource, + 'Psr\\Http\\Client\\' => $vendorRoot.'/psr/http-client/src', + 'Psr\\Http\\Message\\' => $vendorRoot.'/psr/http-message/src', + 'GuzzleHttp\\Psr7\\' => $vendorRoot.'/guzzlehttp/psr7/src', ]; spl_autoload_register( - static function (string $class) use ($prefixes): void { + static function (string $class) use ($prefixes, $vendorRoot): void { foreach ($prefixes as $prefix => $directory) { if (!str_starts_with($class, $prefix)) { continue; } $path = $directory.'/'.str_replace('\\', '/', substr($class, strlen($prefix))).'.php'; + if (!is_file($path) && $prefix === 'Psr\\Http\\Message\\') { + $path = $vendorRoot.'/psr/http-factory/src/'.substr($class, strlen($prefix)).'.php'; + } if (is_file($path)) { require $path; } diff --git a/src/Client.php b/src/Client.php index 26c4eaee..5c8caba6 100644 --- a/src/Client.php +++ b/src/Client.php @@ -2865,12 +2865,21 @@ public function queryTaskStatus(string $taskId, string $leaseOwner, int $attempt public function completeQueryTask(string $taskId, string $leaseOwner, int $attempt, mixed $result, ?RequestBudget $budget = null): array { $client = $budget === null ? $this : $this->withBoundedWorkerRequests(); - return $client->worker('POST', '/worker/query-tasks/'.$this->segment($taskId).self::TASK_COMPLETION_SUFFIX, [ + $body = [ 'lease_owner' => $leaseOwner, 'query_task_attempt' => $attempt, - 'result' => $result, 'result_envelope' => $this->codec->envelope($result), - ], $budget); + ]; + try { + // The plain result is an optional display projection. Binary Avro + // values remain authoritative through the encoded envelope. + JsonDocument::encode($result); + $body['result'] = $result; + } catch (\JsonException) { + // A valid Avro result need not have a JSON representation. + } + + return $client->worker('POST', '/worker/query-tasks/'.$this->segment($taskId).self::TASK_COMPLETION_SUFFIX, $body, $budget); } /** @return array */ diff --git a/tests/QueryResultProjectionTest.php b/tests/QueryResultProjectionTest.php new file mode 100644 index 00000000..bdfdd7f3 --- /dev/null +++ b/tests/QueryResultProjectionTest.php @@ -0,0 +1,85 @@ +completeQueryTask('query-json', 'worker-json', 2, $result); + + self::assertCount(2, $http->requests); + $request = $http->requests[1]; + self::assertSame('/api/worker/query-tasks/query-json/complete', $request->getUri()->getPath()); + $body = json_decode((string) $request->getBody(), true, flags: JSON_THROW_ON_ERROR); + self::assertArrayHasKey('result', $body); + self::assertSame($result, $body['result']); + self::assertSame('worker-json', $body['lease_owner']); + self::assertSame(2, $body['query_task_attempt']); + self::assertSame((new AvroPayloadCodec())->envelope($result), $body['result_envelope']); + if ($result === -0.0) { + self::assertSame(pack('E', $result), pack('E', $body['result'])); + } + } + + public static function plainResults(): iterable + { + yield 'null' => [null]; + yield 'whole float' => [1.0]; + yield 'signed zero' => [-0.0]; + yield 'nested JSON' => [['values' => [null, true, 'λ', 7.0]]]; + } + + public function testInvalidAvroResultStillRefusesBeforeAnyHttpRequest(): void + { + $http = self::http(); + $client = new Client('https://query.invalid', transport: new Psr18Transport($http)); + try { + $client->completeQueryTask('query-invalid', 'worker-invalid', 1, "\xff"); + self::fail('An invalid UTF-8 string must be wrapped explicitly as Avro bytes.'); + } catch (CodecException) { + self::assertSame([], $http->requests); + } + } + + private static function http(): ClientInterface + { + return new class implements ClientInterface { + public array $requests = []; + public function sendRequest(RequestInterface $request): ResponseInterface + { + $this->requests[] = $request; + $reply = str_ends_with($request->getUri()->getPath(), '/cluster/info') + ? ['limits' => ['max_payload_bytes' => 2097152], + 'namespace' => ['external_payload_storage' => ['status' => 'unconfigured']]] + : ['outcome' => 'completed']; + + return new Response(200, [], json_encode($reply, JSON_THROW_ON_ERROR)); + } + }; + } +} diff --git a/tests/Support/CodecRegressionFixture.php b/tests/Support/CodecRegressionFixture.php index cfa2c5cc..6c00ede4 100644 --- a/tests/Support/CodecRegressionFixture.php +++ b/tests/Support/CodecRegressionFixture.php @@ -9,6 +9,8 @@ use DurableWorkflow\Codec\AvroMapValue; use DurableWorkflow\Codec\AvroPayloadCodec; use DurableWorkflow\Exception\CodecException; +use DurableWorkflow\Transport\Psr18Transport; +use GuzzleHttp\Psr7\Response; use DurableWorkflow\Worker; use DurableWorkflow\Worker\ActivityContext; use DurableWorkflow\Worker\QueryContext; @@ -119,6 +121,45 @@ private static function assertCodecRegressionFixture( if (array_key_exists('task_transport', $fixture)) { self::assertTaskTransportCodecRejection($fixture, $path); } + if (($fixture['query_completion'] ?? false) === true) { + self::assertBinaryQueryCompletion($value, $wire); + } + } + + private static function assertBinaryQueryCompletion(mixed $value, mixed $wire): void + { + $http = new class implements \Psr\Http\Client\ClientInterface { + public array $completions = []; + public array $paths = []; + public function sendRequest(\Psr\Http\Message\RequestInterface $request): \Psr\Http\Message\ResponseInterface + { + $path = $request->getUri()->getPath(); + $this->paths[] = $path; + if (str_ends_with($path, '/cluster/info')) { + $reply = ['limits' => ['max_payload_bytes' => 2097152], + 'namespace' => ['external_payload_storage' => ['status' => 'unconfigured']]]; + } elseif (str_ends_with($path, '/worker/query-tasks/query-binary/complete')) { + $this->completions[] = json_decode((string) $request->getBody(), true, flags: JSON_THROW_ON_ERROR); + $reply = ['outcome' => 'completed']; + } else { + throw new RuntimeException('Unexpected binary query fixture request.'); + } + + return new Response(200, [], json_encode($reply, JSON_THROW_ON_ERROR)); + } + }; + $client = new Client('https://query-regression.invalid', transport: new Psr18Transport($http)); + try { + $client->completeQueryTask('query-binary', 'query-worker', 3, $value); + } catch (\JsonException $error) { + throw new CodecRegressionAssertionFailed('Plain JSON projection blocked a valid Avro query result.', 0, $error); + } + self::assertSame(2, count($http->paths)); + self::assertSame(1, count($http->completions)); + self::assertSame([ + 'lease_owner' => 'query-worker', 'query_task_attempt' => 3, + 'result_envelope' => ['codec' => 'avro', 'blob' => $wire], + ], $http->completions[0]); } /** @param array $fixture */ diff --git a/tests/fixtures/codec-regressions/query-nested-binary-result.json b/tests/fixtures/codec-regressions/query-nested-binary-result.json new file mode 100644 index 00000000..c0bc70a7 --- /dev/null +++ b/tests/fixtures/codec-regressions/query-nested-binary-result.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://raw.githubusercontent.com/durable-workflow/.github/main/regression-corpus/evidence-schema.json", + "fixture_schema": "durable-workflow.codec-regression/v1", + "id": "query-nested-binary-result", + "protocol": { + "codec": "avro", + "schema": "durable_workflow.protocol.Value", + "version": "1", + "fingerprint": "e2a33dff55802237" + }, + "bindings": ["php"], + "value": { + "type": "array", + "items": [{"type": "array", "items": [{"type": "bytes", "base64": "/wCA"}]}] + }, + "framing": { + "encoding": "avro-single-object", + "wire_base64": "wwHioz3/VYAiNwwCDAIIBv8AgAAA" + }, + "failure_policy": {"operation": "round_trip", "error": null}, + "query_completion": true +} From 0e9ed1a98f633be86ab5f73418022749cc50c9e9 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 15:24:10 +0000 Subject: [PATCH 2/6] Qualify the 3.18.2 query patch with an installed native Worker --- .../workflows/published-worker-metrics.yml | 14 ++- CHANGELOG.md | 8 ++ composer.json | 2 +- docs/quickstart-contract.json | 4 +- tests/smoke/query-results.php | 112 ++++++++++++++++++ 5 files changed, 135 insertions(+), 5 deletions(-) create mode 100644 tests/smoke/query-results.php diff --git a/.github/workflows/published-worker-metrics.yml b/.github/workflows/published-worker-metrics.yml index cf81f88b..f70596fa 100644 --- a/.github/workflows/published-worker-metrics.yml +++ b/.github/workflows/published-worker-metrics.yml @@ -2,10 +2,10 @@ name: Published worker metrics on: pull_request: - paths: ['.github/workflows/published-worker-metrics.yml', 'tests/smoke/worker-metrics.php', 'tests/smoke/update-outcome.php', 'tests/smoke/workflow-completion-limits.php'] + paths: ['.github/workflows/published-worker-metrics.yml', 'tests/smoke/worker-metrics.php', 'tests/smoke/update-outcome.php', 'tests/smoke/workflow-completion-limits.php', 'tests/smoke/query-results.php'] push: branches: [main] - paths: ['.github/workflows/published-worker-metrics.yml', 'tests/smoke/worker-metrics.php', 'tests/smoke/update-outcome.php', 'tests/smoke/workflow-completion-limits.php'] + paths: ['.github/workflows/published-worker-metrics.yml', 'tests/smoke/worker-metrics.php', 'tests/smoke/update-outcome.php', 'tests/smoke/workflow-completion-limits.php', 'tests/smoke/query-results.php'] workflow_dispatch: inputs: sdk_version: @@ -16,6 +16,10 @@ on: description: Qualify completion limits on the immutable native 3.9.1 tuple type: boolean default: false + query_results: + description: Qualify binary and JSON query results with the installed Worker + type: boolean + default: false candidate_source: description: Optional exact SDK commit for prepublication dist qualification only type: string @@ -139,6 +143,11 @@ jobs: run: | php -l tests/smoke/workflow-completion-limits.php timeout 240s php tests/smoke/workflow-completion-limits.php "$CONSUMER/vendor/autoload.php" | tee workflow-completion-limits.json + - name: Assert installed query results over real HTTP + if: inputs.query_results + run: | + php -l tests/smoke/query-results.php + timeout 90s php tests/smoke/query-results.php "$CONSUMER/vendor/autoload.php" | tee query-results.json - name: Retain the tuple, observations and installed dependency graph if: always() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 @@ -148,6 +157,7 @@ jobs: worker-metrics.json update-outcome.json workflow-completion-limits.json + query-results.json candidate-package.json ${{ env.CONSUMER }}/candidate.zip ${{ env.CONSUMER }}/composer.lock diff --git a/CHANGELOG.md b/CHANGELOG.md index d194f4c6..19bb239d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [3.18.2] - 2026-11-13 + +### Fixed + +- Query completion preserves valid Avro results containing nested binary bytes + when the optional plain JSON display projection cannot be encoded. Ordinary + JSON display values and query lease/payload bounds remain unchanged (#414). + ## [3.18.1] - 2026-11-13 ### Fixed diff --git a/composer.json b/composer.json index 751b0777..52cb788f 100644 --- a/composer.json +++ b/composer.json @@ -97,7 +97,7 @@ } }, "durable-workflow": { - "product-train": "3.18.1", + "product-train": "3.18.2", "supported-server-versions": "2.5.0", "supported-server-release-baselines": { "2": "2.5.0", diff --git a/docs/quickstart-contract.json b/docs/quickstart-contract.json index fbd87c03..dc8da1d7 100644 --- a/docs/quickstart-contract.json +++ b/docs/quickstart-contract.json @@ -3,8 +3,8 @@ "schema_version": 2, "package": { "name": "durable-workflow/sdk", - "published_version": "3.18.1", - "composer_requirement": "3.18.1", + "published_version": "3.18.2", + "composer_requirement": "3.18.2", "onboarding_requirement": "^3.18" }, "runtime_targets": { diff --git a/tests/smoke/query-results.php b/tests/smoke/query-results.php new file mode 100644 index 00000000..73684476 --- /dev/null +++ b/tests/smoke/query-results.php @@ -0,0 +1,112 @@ +getFileName()); + requireQueryAcceptance($file !== false && str_starts_with($file, $root.'/'), 'Source fallback is forbidden.'); +} +requireQueryAcceptance(function_exists('pcntl_fork') && function_exists('posix_kill'), 'Process control is required.'); +$url = getenv('METRICS_RUNTIME_URL'); +$token = getenv('METRICS_TOKEN'); +$client = new Client($url, token: $token); +$info = $client->clusterInfo(); +requireQueryAcceptance($info->version === getenv('SERVER_VERSION') && ($info->raw['implementation'] ?? null) === 'rust', + 'Unexpected published native Server.'); +$queue = 'query-results-'.bin2hex(random_bytes(6)); +$pid = pcntl_fork(); +requireQueryAcceptance($pid !== -1, 'Cannot start the query Worker.'); +if ($pid === 0) { + try { + $worker = new Worker(new Client($url, token: $token), $queue); + $worker->registerWorkflow('smoke.query-results', static function (WorkflowContext $context): void { + $context->awaitSignal('finish'); + }); + $worker->registerQuery('smoke.query-results', 'binary', static fn (QueryContext $context): array => + ['nested' => [AvroBinaryValue::fromBytes("\xff\x00\x80")]]); + $worker->registerQuery('smoke.query-results', 'json', static fn (QueryContext $context): array => + ['values' => [null, true, 'λ', 1.0, -0.0]]); + $worker->registerQuery('smoke.query-results', 'null', static fn (QueryContext $context): mixed => null); + pcntl_async_signals(true); + pcntl_signal(SIGTERM, static fn () => $worker->requestShutdown()); + $worker->run(0); + exit(0); + } catch (Throwable $error) { + fwrite(STDERR, 'Query Worker failed: '.$error::class.PHP_EOL); + exit(1); + } +} +$handle = null; +$failure = null; +try { + $deadline = microtime(true) + 15; + while (($client->listWorkers($queue)['workers'] ?? []) === []) { + requireQueryAcceptance(microtime(true) < $deadline, 'The query Worker did not register.'); + usleep(50_000); + } + $handle = $client->startWorkflow('smoke.query-results', $queue, $queue, + executionTimeoutSeconds: 60, runTimeoutSeconds: 60); + $json = $handle->query('json'); + requireQueryAcceptance($json === ['values' => [null, true, 'λ', 1.0, -0.0]] + && pack('E', $json['values'][4]) === pack('E', -0.0), 'JSON query values changed.'); + $binary = $handle->query('binary'); + requireQueryAcceptance(is_array($binary) && ($binary['nested'][0] ?? null) instanceof AvroBinaryValue + && $binary['nested'][0]->bytes === "\xff\x00\x80", 'The nested binary query did not round-trip.'); + requireQueryAcceptance($handle->query('null') === null, 'The null query result changed.'); + $handle->signal('finish'); + requireQueryAcceptance($handle->result(timeoutSeconds: 15, pollIntervalSeconds: 0.1) === null, + 'The query workflow did not complete.'); +} catch (Throwable $error) { + $failure = $error; + if ($handle !== null) { + try { + $handle->terminate('Query acceptance cleanup'); + } catch (Throwable $cleanup) { + fwrite(STDERR, 'Query cleanup failed: '.$cleanup::class.PHP_EOL); + } + } +} finally { + posix_kill($pid, SIGTERM); + $deadline = microtime(true) + 15; + while (pcntl_waitpid($pid, $status, WNOHANG) === 0) { + if (microtime(true) >= $deadline) { + posix_kill($pid, SIGKILL); + pcntl_waitpid($pid, $status); + $failure ??= new RuntimeException('The query Worker failed to stop gracefully.'); + break; + } + usleep(50_000); + } + if (!pcntl_wifexited($status) || pcntl_wexitstatus($status) !== 0) { + $failure ??= new RuntimeException('The query Worker exited unsuccessfully.'); + } +} +if ($failure !== null) { + throw $failure; +} +requireQueryAcceptance(($client->listWorkers($queue)['workers'] ?? []) === [], 'The query Worker did not deregister.'); +echo json_encode(['sdk_version' => $version, 'sdk_reference' => $reference, + 'server_version' => $info->version, 'binary_bytes' => 3, 'nested_binary_roundtrip' => true, + 'json_projection_control' => true, 'signed_zero_preserved' => true, 'null_result' => true, + 'workflow_completed' => true, 'worker_deregistered' => true], JSON_THROW_ON_ERROR).PHP_EOL; From 7d4ce6adb9a366c106560b9796b432ff8a6dc578 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 15:28:11 +0000 Subject: [PATCH 3/6] Bind either query or completion qualification to native 3.9.1 --- .../workflows/published-worker-metrics.yml | 10 +++-- tests/ci/test_published_query_tuple.py | 45 +++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 tests/ci/test_published_query_tuple.py diff --git a/.github/workflows/published-worker-metrics.yml b/.github/workflows/published-worker-metrics.yml index f70596fa..2bf21457 100644 --- a/.github/workflows/published-worker-metrics.yml +++ b/.github/workflows/published-worker-metrics.yml @@ -2,10 +2,10 @@ name: Published worker metrics on: pull_request: - paths: ['.github/workflows/published-worker-metrics.yml', 'tests/smoke/worker-metrics.php', 'tests/smoke/update-outcome.php', 'tests/smoke/workflow-completion-limits.php', 'tests/smoke/query-results.php'] + paths: ['.github/workflows/published-worker-metrics.yml', 'tests/smoke/worker-metrics.php', 'tests/smoke/update-outcome.php', 'tests/smoke/workflow-completion-limits.php', 'tests/smoke/query-results.php', 'tests/ci/test_published_query_tuple.py'] push: branches: [main] - paths: ['.github/workflows/published-worker-metrics.yml', 'tests/smoke/worker-metrics.php', 'tests/smoke/update-outcome.php', 'tests/smoke/workflow-completion-limits.php', 'tests/smoke/query-results.php'] + paths: ['.github/workflows/published-worker-metrics.yml', 'tests/smoke/worker-metrics.php', 'tests/smoke/update-outcome.php', 'tests/smoke/workflow-completion-limits.php', 'tests/smoke/query-results.php', 'tests/ci/test_published_query_tuple.py'] workflow_dispatch: inputs: sdk_version: @@ -34,14 +34,16 @@ jobs: timeout-minutes: 10 env: SDK_VERSION: ${{ inputs.sdk_version || '3.18.0' }} - SERVER_VERSION: ${{ inputs.completion_limits && '3.9.1' || '3.8.0' }} - SERVER_IMAGE: ${{ inputs.completion_limits && 'durableworkflow/server@sha256:946c65eb70e8b4b7c4694be8a181c9669d46c3408b80d4e44d96f2030ea3cf51' || 'durableworkflow/server@sha256:7988a23525d9e50e997a9f6f02e801300ed4f626de3cf6977033657ae7e9f58e' }} + SERVER_VERSION: ${{ (inputs.completion_limits || inputs.query_results) && '3.9.1' || '3.8.0' }} + SERVER_IMAGE: ${{ (inputs.completion_limits || inputs.query_results) && 'durableworkflow/server@sha256:946c65eb70e8b4b7c4694be8a181c9669d46c3408b80d4e44d96f2030ea3cf51' || 'durableworkflow/server@sha256:7988a23525d9e50e997a9f6f02e801300ed4f626de3cf6977033657ae7e9f58e' }} METRICS_RUNTIME_URL: http://127.0.0.1:8080 METRICS_TOKEN: published-metrics-test-token steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: persist-credentials: false + - name: Require the exact published query tuple selectors + run: python3 -I -S -B tests/ci/test_published_query_tuple.py - name: Create isolated Composer state run: | state="$(mktemp -d "$RUNNER_TEMP/worker-metrics.XXXXXX")" diff --git a/tests/ci/test_published_query_tuple.py b/tests/ci/test_published_query_tuple.py new file mode 100644 index 00000000..9ffc6eff --- /dev/null +++ b/tests/ci/test_published_query_tuple.py @@ -0,0 +1,45 @@ +"""Keep both installed feature options bound to the qualified native tuple.""" + +from pathlib import Path +import unittest + + +SELECTOR = "(inputs.completion_limits || inputs.query_results)" +IMAGE = "durableworkflow/server@sha256:946c65eb70e8b4b7c4694be8a181c9669d46c3408b80d4e44d96f2030ea3cf51" +OLD_IMAGE = "durableworkflow/server@sha256:7988a23525d9e50e997a9f6f02e801300ed4f626de3cf6977033657ae7e9f58e" + + +def require_tuple(source: str) -> None: + for key, current, previous in [ + ("SERVER_VERSION", "3.9.1", "3.8.0"), + ("SERVER_IMAGE", IMAGE, OLD_IMAGE), + ]: + expected = f" {key}: ${{{{ {SELECTOR} && '{current}' || '{previous}' }}}}" + if expected not in source.splitlines(): + raise ValueError(f"{key} must bind either feature option to the qualified 3.9.1 tuple") + + +class PublishedQueryTupleTest(unittest.TestCase): + def setUp(self) -> None: + self.source = (Path(__file__).resolve().parents[2] / ".github/workflows/published-worker-metrics.yml").read_text() + + def test_current_source(self) -> None: + require_tuple(self.source) + + def test_predecessor_and_independent_selector_mutations_refuse(self) -> None: + for key in ["SERVER_VERSION", "SERVER_IMAGE"]: + with self.subTest(key=key): + changed = "\n".join( + line.replace(SELECTOR, "inputs.completion_limits") if f"{key}:" in line else line + for line in self.source.splitlines() + ) + with self.assertRaises(ValueError): + require_tuple(changed) + + def test_wrong_image_refuses(self) -> None: + with self.assertRaises(ValueError): + require_tuple(self.source.replace(IMAGE, OLD_IMAGE)) + + +if __name__ == "__main__": + unittest.main() From c99fbd6eeee878e60269bbf5946384bff1c0a76a Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 15:31:36 +0000 Subject: [PATCH 4/6] Fail qualification on probe errors and preserve an active query fixture --- .github/workflows/published-worker-metrics.yml | 10 ++++++++++ tests/ci/test_published_query_tuple.py | 16 ++++++++++++++++ tests/smoke/query-results.php | 5 ++++- 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/.github/workflows/published-worker-metrics.yml b/.github/workflows/published-worker-metrics.yml index 2bf21457..95f074b2 100644 --- a/.github/workflows/published-worker-metrics.yml +++ b/.github/workflows/published-worker-metrics.yml @@ -32,6 +32,9 @@ jobs: installed-worker-metrics: runs-on: ubuntu-24.04 timeout-minutes: 10 + defaults: + run: + shell: bash --noprofile --norc -e -o pipefail {0} env: SDK_VERSION: ${{ inputs.sdk_version || '3.18.0' }} SERVER_VERSION: ${{ (inputs.completion_limits || inputs.query_results) && '3.9.1' || '3.8.0' }} @@ -150,6 +153,13 @@ jobs: run: | php -l tests/smoke/query-results.php timeout 90s php tests/smoke/query-results.php "$CONSUMER/vendor/autoload.php" | tee query-results.json + php -r '$r=json_decode(file_get_contents("query-results.json"),true,flags:JSON_THROW_ON_ERROR); + foreach (["nested_binary_roundtrip","json_projection_control","signed_zero_preserved","null_result","workflow_completed","worker_deregistered"] as $key) { + if (($r[$key]??null)!==true) { throw new RuntimeException("Missing query evidence: ".$key); } + } + if (($r["sdk_version"]??null)!==getenv("SDK_VERSION") || ($r["server_version"]??null)!==getenv("SERVER_VERSION")) { + throw new RuntimeException("Query evidence tuple mismatch."); + }' - name: Retain the tuple, observations and installed dependency graph if: always() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 diff --git a/tests/ci/test_published_query_tuple.py b/tests/ci/test_published_query_tuple.py index 9ffc6eff..fbb1768e 100644 --- a/tests/ci/test_published_query_tuple.py +++ b/tests/ci/test_published_query_tuple.py @@ -1,6 +1,9 @@ """Keep both installed feature options bound to the qualified native tuple.""" from pathlib import Path +import shlex +import subprocess +import tempfile import unittest @@ -40,6 +43,19 @@ def test_wrong_image_refuses(self) -> None: with self.assertRaises(ValueError): require_tuple(self.source.replace(IMAGE, OLD_IMAGE)) + def test_configured_shell_propagates_probe_failure_through_tee(self) -> None: + shell = "bash --noprofile --norc -e -o pipefail {0}" + self.assertIn(f" shell: {shell}", self.source.splitlines()) + with tempfile.TemporaryDirectory() as temporary: + probe = Path(temporary) / "probe.sh" + probe.write_text("(exit 7) | cat > /dev/null\n") + current = [str(probe) if word == "{0}" else word for word in shlex.split(shell)] + self.assertEqual(7, subprocess.run(current, check=False).returncode) + self.assertEqual(0, subprocess.run(["bash", "-e", str(probe)], check=False).returncode) + + def test_empty_query_evidence_is_not_accepted(self) -> None: + self.assertIn('json_decode(file_get_contents("query-results.json"),true,flags:JSON_THROW_ON_ERROR)', self.source) + if __name__ == "__main__": unittest.main() diff --git a/tests/smoke/query-results.php b/tests/smoke/query-results.php index 73684476..ab76c861 100644 --- a/tests/smoke/query-results.php +++ b/tests/smoke/query-results.php @@ -41,7 +41,10 @@ function requireQueryAcceptance(bool $condition, string $message): void try { $worker = new Worker(new Client($url, token: $token), $queue); $worker->registerWorkflow('smoke.query-results', static function (WorkflowContext $context): void { - $context->awaitSignal('finish'); + if (!$context->waitCondition(static fn (): bool => $context->signals('finish') !== [], + key: 'finish', timeout: 45)) { + throw new RuntimeException('The query observation window expired.'); + } }); $worker->registerQuery('smoke.query-results', 'binary', static fn (QueryContext $context): array => ['nested' => [AvroBinaryValue::fromBytes("\xff\x00\x80")]]); From 20a8c68d4ba926d8cfe958dde2919135c835c611 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 15:33:37 +0000 Subject: [PATCH 5/6] Declare the query fixture completion signal in its durable contract --- tests/smoke/query-results.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/smoke/query-results.php b/tests/smoke/query-results.php index ab76c861..cd1a7492 100644 --- a/tests/smoke/query-results.php +++ b/tests/smoke/query-results.php @@ -51,6 +51,7 @@ function requireQueryAcceptance(bool $condition, string $message): void $worker->registerQuery('smoke.query-results', 'json', static fn (QueryContext $context): array => ['values' => [null, true, 'λ', 1.0, -0.0]]); $worker->registerQuery('smoke.query-results', 'null', static fn (QueryContext $context): mixed => null); + $worker->registerSignal('smoke.query-results', 'finish', static function (): void {}); pcntl_async_signals(true); pcntl_signal(SIGTERM, static fn () => $worker->requestShutdown()); $worker->run(0); From b0ce44195e10c55100dd93594c5497b03047be09 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 15:35:54 +0000 Subject: [PATCH 6/6] Use the published declareSignal API in query acceptance --- tests/smoke/query-results.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/smoke/query-results.php b/tests/smoke/query-results.php index cd1a7492..cb3d8ef0 100644 --- a/tests/smoke/query-results.php +++ b/tests/smoke/query-results.php @@ -51,7 +51,7 @@ function requireQueryAcceptance(bool $condition, string $message): void $worker->registerQuery('smoke.query-results', 'json', static fn (QueryContext $context): array => ['values' => [null, true, 'λ', 1.0, -0.0]]); $worker->registerQuery('smoke.query-results', 'null', static fn (QueryContext $context): mixed => null); - $worker->registerSignal('smoke.query-results', 'finish', static function (): void {}); + $worker->declareSignal('smoke.query-results', 'finish'); pcntl_async_signals(true); pcntl_signal(SIGTERM, static fn () => $worker->requestShutdown()); $worker->run(0);