From 45e60d9d205b31b4328247cd9d0cd2346e4b5219 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Sat, 14 Nov 2026 11:15:28 +0000 Subject: [PATCH] Qualify installed PHP history and workflow pagination --- .../workflows/published-worker-metrics.yml | 42 +++++- .../test_published_activity_identity_tuple.py | 6 +- tests/ci/test_published_pagination_tuple.py | 54 +++++++ tests/smoke/pagination.php | 139 ++++++++++++++++++ 4 files changed, 234 insertions(+), 7 deletions(-) create mode 100644 tests/ci/test_published_pagination_tuple.py create mode 100644 tests/smoke/pagination.php diff --git a/.github/workflows/published-worker-metrics.yml b/.github/workflows/published-worker-metrics.yml index ac00f952..2ae9755d 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', 'tests/ci/test_published_query_tuple.py', 'tests/ci/check_published_laravel.py', 'tests/ci/test_published_laravel.py', 'tests/smoke/activity-retry-identity.php', 'tests/smoke/activity-retry-identity-assertions.php', 'tests/PublishedActivityRetryIdentityTest.php', 'tests/ci/test_published_activity_identity_tuple.py'] + 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', 'tests/ci/check_published_laravel.py', 'tests/ci/test_published_laravel.py', 'tests/smoke/activity-retry-identity.php', 'tests/smoke/activity-retry-identity-assertions.php', 'tests/PublishedActivityRetryIdentityTest.php', 'tests/ci/test_published_activity_identity_tuple.py', 'tests/smoke/pagination.php', 'tests/ci/test_published_pagination_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', 'tests/ci/test_published_query_tuple.py', 'tests/ci/check_published_laravel.py', 'tests/ci/test_published_laravel.py', 'tests/smoke/activity-retry-identity.php', 'tests/smoke/activity-retry-identity-assertions.php', 'tests/PublishedActivityRetryIdentityTest.php', 'tests/ci/test_published_activity_identity_tuple.py'] + 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', 'tests/ci/check_published_laravel.py', 'tests/ci/test_published_laravel.py', 'tests/smoke/activity-retry-identity.php', 'tests/smoke/activity-retry-identity-assertions.php', 'tests/PublishedActivityRetryIdentityTest.php', 'tests/ci/test_published_activity_identity_tuple.py', 'tests/smoke/pagination.php', 'tests/ci/test_published_pagination_tuple.py'] workflow_dispatch: inputs: sdk_version: @@ -24,6 +24,10 @@ on: description: Qualify ordinary retry IDs on published SDK 3.18.2/native 3.8.0 type: boolean default: false + pagination: + description: Qualify real paged iterators on published SDK 3.18.2/native 3.8.0 + type: boolean + default: false laravel_bridges: description: Qualify registry SDK installation on both patched Laravel floors type: boolean @@ -64,15 +68,30 @@ jobs: COMPLETION_LIMITS: ${{ inputs.completion_limits }} QUERY_RESULTS: ${{ inputs.query_results }} LARAVEL_BRIDGES: ${{ inputs.laravel_bridges }} + PAGINATION: ${{ inputs.pagination }} + run: | + [[ "$GITHUB_EVENT_NAME" = workflow_dispatch && "$GITHUB_REF" = refs/heads/main ]] + [[ -z "$CANDIDATE_SOURCE" && "$COMPLETION_LIMITS" != true && "$QUERY_RESULTS" != true && "$LARAVEL_BRIDGES" != true && "$PAGINATION" != true ]] + [[ "$SDK_VERSION" = 3.18.2 && "$SERVER_VERSION" = 3.8.0 ]] + [[ "$SERVER_IMAGE" = durableworkflow/server@sha256:7988a23525d9e50e997a9f6f02e801300ed4f626de3cf6977033657ae7e9f58e ]] + - name: Require the published pagination tuple + if: inputs.pagination + env: + CANDIDATE_SOURCE: ${{ inputs.candidate_source }} + COMPLETION_LIMITS: ${{ inputs.completion_limits }} + QUERY_RESULTS: ${{ inputs.query_results }} + LARAVEL_BRIDGES: ${{ inputs.laravel_bridges }} + ACTIVITY_RETRY_IDENTITY: ${{ inputs.activity_retry_identity }} run: | [[ "$GITHUB_EVENT_NAME" = workflow_dispatch && "$GITHUB_REF" = refs/heads/main ]] - [[ -z "$CANDIDATE_SOURCE" && "$COMPLETION_LIMITS" != true && "$QUERY_RESULTS" != true && "$LARAVEL_BRIDGES" != true ]] + [[ -z "$CANDIDATE_SOURCE" && "$COMPLETION_LIMITS" != true && "$QUERY_RESULTS" != true && "$LARAVEL_BRIDGES" != true && "$ACTIVITY_RETRY_IDENTITY" != true ]] [[ "$SDK_VERSION" = 3.18.2 && "$SERVER_VERSION" = 3.8.0 ]] [[ "$SERVER_IMAGE" = durableworkflow/server@sha256:7988a23525d9e50e997a9f6f02e801300ed4f626de3cf6977033657ae7e9f58e ]] - name: Require the exact published query tuple selectors run: | python3 -I -S -B tests/ci/test_published_query_tuple.py python3 -I -S -B tests/ci/test_published_activity_identity_tuple.py + python3 -I -S -B tests/ci/test_published_pagination_tuple.py python3 -S -B -m unittest discover -s tests/ci -p test_published_laravel.py - name: Create isolated Composer state run: | @@ -192,6 +211,20 @@ jobs: run: | php -l tests/smoke/activity-retry-identity.php timeout 60s php tests/smoke/activity-retry-identity.php "$CONSUMER/vendor/autoload.php" | tee activity-retry-identity.json + - name: Assert real filtered list and exact-run history iteration + if: inputs.pagination + run: | + php -l tests/smoke/pagination.php + timeout 60s php tests/smoke/pagination.php "$CONSUMER/vendor/autoload.php" | tee pagination.json + php -r '$r=json_decode(file_get_contents("pagination.json"),true,flags:JSON_THROW_ON_ERROR); + foreach (["lazy_first_page","workflow_completed","worker_deregistered"] as $key) { + if (($r[$key]??null)!==true) { throw new RuntimeException("Missing pagination evidence: ".$key); } + } + if (($r["sdk_version"]??null)!==getenv("SDK_VERSION") || ($r["server_version"]??null)!==getenv("SERVER_VERSION") + || ($r["source_fallback"]??null)!==false || count($r["workflow_ids"]??[])!==5 + || ($r["list_page_requests"]??null)!==3 || ($r["history_page_requests"]??0)<2) { + throw new RuntimeException("Pagination tuple or complete-page evidence mismatch."); + }' - name: Retain the tuple, observations and installed dependency graph if: always() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 @@ -203,6 +236,7 @@ jobs: workflow-completion-limits.json query-results.json activity-retry-identity.json + pagination.json candidate-package.json ${{ env.CONSUMER }}/candidate.zip ${{ env.CONSUMER }}/composer.lock @@ -215,7 +249,7 @@ jobs: run: docker rm --force --volumes sdk-php-metrics-runtime installed-laravel-bridges: - if: github.event_name == 'workflow_dispatch' && inputs.laravel_bridges && !inputs.activity_retry_identity + if: github.event_name == 'workflow_dispatch' && inputs.laravel_bridges && !inputs.activity_retry_identity && !inputs.pagination runs-on: ubuntu-24.04 timeout-minutes: 15 strategy: diff --git a/tests/ci/test_published_activity_identity_tuple.py b/tests/ci/test_published_activity_identity_tuple.py index abdffd4c..387a8a4a 100644 --- a/tests/ci/test_published_activity_identity_tuple.py +++ b/tests/ci/test_published_activity_identity_tuple.py @@ -13,7 +13,7 @@ def guard_source(): def require_disjoint_jobs(source): - expected = " if: github.event_name == 'workflow_dispatch' && inputs.laravel_bridges && !inputs.activity_retry_identity" + expected = " if: github.event_name == 'workflow_dispatch' && inputs.laravel_bridges && !inputs.activity_retry_identity && !inputs.pagination" header = source.split(" installed-laravel-bridges:\n", 1)[1].split(" runs-on:", 1)[0] if header.strip() != expected.strip(): raise ValueError("The sibling Laravel job must not start for activity identity qualification") @@ -25,7 +25,7 @@ def setUp(self): "PATH": os.defpath, "GITHUB_EVENT_NAME": "workflow_dispatch", "GITHUB_REF": "refs/heads/main", "SDK_VERSION": "3.18.2", "SERVER_VERSION": "3.8.0", "SERVER_IMAGE": "durableworkflow/server@sha256:7988a23525d9e50e997a9f6f02e801300ed4f626de3cf6977033657ae7e9f58e", - "CANDIDATE_SOURCE": "", "COMPLETION_LIMITS": "false", "QUERY_RESULTS": "false", "LARAVEL_BRIDGES": "false", + "CANDIDATE_SOURCE": "", "COMPLETION_LIMITS": "false", "QUERY_RESULTS": "false", "LARAVEL_BRIDGES": "false", "PAGINATION": "false", } def accepts(self, changes=None): @@ -38,7 +38,7 @@ def test_exact_published_tuple(self): def test_source_selector_and_unrelated_qualification_flags_refuse(self): for variable, value in [("CANDIDATE_SOURCE", "a" * 40), ("COMPLETION_LIMITS", "true"), - ("QUERY_RESULTS", "true"), ("LARAVEL_BRIDGES", "true")]: + ("QUERY_RESULTS", "true"), ("LARAVEL_BRIDGES", "true"), ("PAGINATION", "true")]: with self.subTest(variable=variable): self.assertFalse(self.accepts({variable: value})) diff --git a/tests/ci/test_published_pagination_tuple.py b/tests/ci/test_published_pagination_tuple.py new file mode 100644 index 00000000..f15c110c --- /dev/null +++ b/tests/ci/test_published_pagination_tuple.py @@ -0,0 +1,54 @@ +"""The installed pagination probe must not widen any other qualification tuple.""" + +import os +from pathlib import Path +import subprocess +import unittest + + +def workflow_source(): + return (Path(__file__).resolve().parents[2] / ".github/workflows/published-worker-metrics.yml").read_text() + + +def guard_source(): + step = workflow_source().split(" - name: Require the published pagination tuple\n", 1)[1].split(" - ", 1)[0] + return "\n".join(line[10:] for line in step.split(" run: |\n", 1)[1].splitlines()) + + +class PublishedPaginationTupleTest(unittest.TestCase): + def setUp(self): + self.env = { + "PATH": os.defpath, "GITHUB_EVENT_NAME": "workflow_dispatch", "GITHUB_REF": "refs/heads/main", + "SDK_VERSION": "3.18.2", "SERVER_VERSION": "3.8.0", + "SERVER_IMAGE": "durableworkflow/server@sha256:7988a23525d9e50e997a9f6f02e801300ed4f626de3cf6977033657ae7e9f58e", + "CANDIDATE_SOURCE": "", "COMPLETION_LIMITS": "false", "QUERY_RESULTS": "false", + "LARAVEL_BRIDGES": "false", "ACTIVITY_RETRY_IDENTITY": "false", + } + + def accepts(self, changes=None): + return subprocess.run(["bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", guard_source()], + env=self.env | (changes or {}), capture_output=True, check=False).returncode == 0 + + def test_exact_published_tuple(self): + self.assertTrue(self.accepts()) + + def test_candidates_conflicting_cases_and_foreign_tuples_refuse(self): + for variable, value in [("CANDIDATE_SOURCE", "a" * 40), ("COMPLETION_LIMITS", "true"), + ("QUERY_RESULTS", "true"), ("LARAVEL_BRIDGES", "true"), + ("ACTIVITY_RETRY_IDENTITY", "true"), ("SDK_VERSION", "3.18.3"), + ("SERVER_VERSION", "3.9.1"), ("SERVER_IMAGE", "durableworkflow/server:3.8.0"), + ("GITHUB_EVENT_NAME", "pull_request"), ("GITHUB_REF", "refs/heads/topic")]: + with self.subTest(variable=variable): + self.assertFalse(self.accepts({variable: value})) + + def test_sibling_laravel_job_excludes_pagination_before_effects(self): + header = workflow_source().split(" installed-laravel-bridges:\n", 1)[1].split(" runs-on:", 1)[0] + self.assertEqual("if: github.event_name == 'workflow_dispatch' && inputs.laravel_bridges && !inputs.activity_retry_identity && !inputs.pagination", + header.strip()) + + def test_nonempty_typed_evidence_is_required(self): + self.assertIn('json_decode(file_get_contents("pagination.json"),true,flags:JSON_THROW_ON_ERROR)', workflow_source()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/smoke/pagination.php b/tests/smoke/pagination.php new file mode 100644 index 00000000..edae0d83 --- /dev/null +++ b/tests/smoke/pagination.php @@ -0,0 +1,139 @@ +getFileName()); +if ($version !== '3.18.2' || getenv('SDK_VERSION') !== $version + || $reference !== 'f9483bb2b5b45f591a82d9e5419c5b60a6e967c6' + || $root === false || $implementation === false || !str_starts_with($implementation, $root.'/') + || getenv('SERVER_VERSION') !== '3.8.0' + || getenv('SERVER_IMAGE') !== 'durableworkflow/server@sha256:7988a23525d9e50e997a9f6f02e801300ed4f626de3cf6977033657ae7e9f58e') { + throw new RuntimeException('Require the reviewed published SDK 3.18.2/native 3.8.0 tuple.'); +} +$installed = json_decode(file_get_contents(dirname($argv[1]).'/composer/installed.json'), true, flags: JSON_THROW_ON_ERROR); +$packages = array_values(array_filter($installed['packages'] ?? [], static fn (array $row): bool => $row['name'] === 'durable-workflow/sdk')); +if (count($packages) !== 1 || ($packages[0]['installation-source'] ?? null) !== 'dist' + || ($packages[0]['source']['reference'] ?? null) !== $reference + || ($packages[0]['dist']['reference'] ?? null) !== $reference || ($packages[0]['dist']['type'] ?? null) !== 'zip') { + throw new RuntimeException('Require the exact SDK registry distribution without source fallback.'); +} +$client = new Client(getenv('METRICS_RUNTIME_URL'), token: getenv('METRICS_TOKEN')); +$info = $client->clusterInfo(); +if ($info->version !== '3.8.0' || ($info->raw['implementation'] ?? null) !== 'rust') { + throw new RuntimeException('Require the selected published native producer.'); +} +$nonce = bin2hex(random_bytes(8)); +$type = 'pagination.workflow.'.$nonce; +$queue = 'published-pagination-'.$nonce; +$worker = new Worker($client, $queue); +$completed = 0; +$handler = static function (WorkflowContext $context, int $value) use (&$completed, $worker): int { + if (++$completed === 6) { + $worker->requestShutdown(); + } + + return $value; +}; +$worker->registerWorkflow($type, $handler); +$worker->registerWorkflow($type.'.foreign', $handler); +$handles = []; +foreach (range(0, 5) as $index) { + $handles[] = $client->startWorkflow($index === 5 ? $type.'.foreign' : $type, + 'pagination-'.$nonce.'-'.$index, $queue, [$index], executionTimeoutSeconds: 45, runTimeoutSeconds: 45); +} +$worker->run(0); +foreach ($handles as $index => $handle) { + if ($handle->resultOfSelectedRun(timeoutSeconds: 5) !== $index) { + throw new RuntimeException('Every owned workflow must finish with its exact typed result.'); + } +} +$recorder = new class implements MetricsRecorder { + /** @var list> */ + public array $requests = []; + + public function increment(string $name, float $value = 1.0, array $tags = []): void + { + if ($name === 'durable_workflow_client_requests') { + $this->requests[] = $tags; + } + } + + public function record(string $name, float $value, array $tags = []): void + { + } + + public function count(string $route): int + { + return count(array_filter($this->requests, static fn (array $tags): bool => + ($tags['method'] ?? null) === 'GET' && ($tags['route'] ?? null) === $route + && ($tags['status_code'] ?? null) === '200' && ($tags['outcome'] ?? null) === 'ok')); + } +}; +$reader = new Client(getenv('METRICS_RUNTIME_URL'), token: getenv('METRICS_TOKEN'), metrics: $recorder); +$iterator = $reader->iterateWorkflows(workflowType: $type, status: 'completed', pageSize: 2); +if ($recorder->requests !== []) { + throw new RuntimeException('Constructing a lazy iterator must not make an HTTP request.'); +} +$listed = []; +foreach ($iterator as $execution) { + $listed[] = $execution->workflowId; + if (count($listed) === 1 && $recorder->count('/workflows') !== 1) { + throw new RuntimeException('The iterator must yield its first page before fetching subsequent pages.'); + } +} +$listPages = $recorder->count('/workflows'); +$expected = array_map(static fn ($handle): string => $handle->workflowId, array_slice($handles, 0, 5)); +sort($listed); +sort($expected); +if ($listed !== $expected || $listPages !== 3) { + throw new RuntimeException('Three real filtered pages must contain all five owned workflows exactly once.'); +} +$run = $handles[0]; +if ($run->selectedRunId === null) { + throw new RuntimeException('History must use the exact started run.'); +} +$referencePage = $client->workflowHistory($run->workflowId, $run->selectedRunId, pageSize: 100); +$referenceEvents = $referencePage['events'] ?? []; +if (count($referenceEvents) < 3 || count($referenceEvents) > 64 || !empty($referencePage['next_page_token'])) { + throw new RuntimeException('The bounded reference history must be complete and longer than one two-event page.'); +} +$recorder->requests = []; +$history = $reader->iterateWorkflowHistory($run->workflowId, $run->selectedRunId, pageSize: 2); +if ($recorder->requests !== []) { + throw new RuntimeException('Constructing a history iterator must not make an HTTP request.'); +} +$events = []; +$route = '/workflows/{workflow_id}/runs/{run_id}/history'; +foreach ($history as $event) { + $events[] = $event; + if (count($events) > 64 || (count($events) === 1 && $recorder->count($route) !== 1)) { + throw new RuntimeException('History must remain bounded and yield its first page before fetching the rest.'); + } +} +$historyPages = $recorder->count($route); +if ($events !== $referenceEvents || $historyPages !== (int) ceil(count($referenceEvents) / 2) + || $historyPages < 2 || ($client->listWorkers($queue)['workers'] ?? null) !== []) { + throw new RuntimeException('Real multi-page history must equal the exact-run reference with no gaps, duplicates or live Worker.'); +} +echo json_encode([ + 'sdk_version' => $version, 'sdk_reference' => $reference, + 'server_version' => $info->version, 'server_image' => getenv('SERVER_IMAGE'), + 'runner_commit' => getenv('GITHUB_SHA') ?: null, 'started_at' => $startedAt, 'completed_at' => gmdate('c'), + 'workflow_ids' => $listed, 'excluded_workflow_id' => $handles[5]->workflowId, + 'list_page_size' => 2, 'list_page_requests' => $listPages, + 'history_workflow_id' => $run->workflowId, 'history_run_id' => $run->selectedRunId, + 'history_page_size' => 2, 'history_page_requests' => $historyPages, 'history' => $events, + 'lazy_first_page' => true, 'workflow_completed' => true, 'worker_deregistered' => true, 'source_fallback' => false, +], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES).PHP_EOL;