Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions .github/workflows/published-worker-metrics.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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', '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']
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:
Expand All @@ -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
Expand All @@ -28,16 +32,21 @@ 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 && '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")"
Expand Down Expand Up @@ -139,6 +148,18 @@ 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
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
Expand All @@ -148,6 +169,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
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions docs/quickstart-contract.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
8 changes: 7 additions & 1 deletion scripts/ci/run-codec-regression-fixture.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
15 changes: 12 additions & 3 deletions src/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed> */
Expand Down
85 changes: 85 additions & 0 deletions tests/QueryResultProjectionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?php

declare(strict_types=1);

namespace DurableWorkflow\Tests;

use DurableWorkflow\Client;
use DurableWorkflow\Codec\AvroPayloadCodec;
use DurableWorkflow\Exception\CodecException;
use DurableWorkflow\Tests\Support\CodecRegressionFixture;
use DurableWorkflow\Transport\Psr18Transport;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;

final class QueryResultProjectionTest extends TestCase
{
public function testBinaryCorpusCompletesThroughTheActualJsonTransport(): void
{
CodecRegressionFixture::executeFile('codec-regression-v1',
__DIR__.'/fixtures/codec-regressions/query-nested-binary-result.json');
self::addToAssertionCount(1);
}

#[DataProvider('plainResults')]
public function testJsonDisplayProjectionIsPreserved(mixed $result): void
{
$http = self::http();
$client = new Client('https://query.invalid', transport: new Psr18Transport($http));
$client->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));
}
};
}
}
41 changes: 41 additions & 0 deletions tests/Support/CodecRegressionFixture.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, mixed> $fixture */
Expand Down
61 changes: 61 additions & 0 deletions tests/ci/test_published_query_tuple.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""Keep both installed feature options bound to the qualified native tuple."""

from pathlib import Path
import shlex
import subprocess
import tempfile
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):

Check warning on line 43 in tests/ci/test_published_query_tuple.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this exception test to have only one invocation possibly throwing an exception.

See more on https://sonarcloud.io/project/issues?id=durable-workflow_sdk-php&issues=AaHVhyQHUPDpoJKGxgvJ&open=AaHVhyQHUPDpoJKGxgvJ&pullRequest=415
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()
22 changes: 22 additions & 0 deletions tests/fixtures/codec-regressions/query-nested-binary-result.json
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading