Skip to content
Draft
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
42 changes: 38 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', '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:
Expand All @@ -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
Expand Down Expand Up @@ -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: |
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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:
Expand Down
6 changes: 3 additions & 3 deletions tests/ci/test_published_activity_identity_tuple.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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):
Expand All @@ -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}))

Expand Down
54 changes: 54 additions & 0 deletions tests/ci/test_published_pagination_tuple.py
Original file line number Diff line number Diff line change
@@ -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()
139 changes: 139 additions & 0 deletions tests/smoke/pagination.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
<?php

declare(strict_types=1);

use Composer\InstalledVersions;
use DurableWorkflow\Client;
use DurableWorkflow\Metrics\MetricsRecorder;
use DurableWorkflow\Worker;
use DurableWorkflow\Worker\WorkflowContext;

require $argv[1];

$startedAt = gmdate('c');
$version = InstalledVersions::getPrettyVersion('durable-workflow/sdk');
$reference = InstalledVersions::getReference('durable-workflow/sdk');
$root = realpath((string) InstalledVersions::getInstallPath('durable-workflow/sdk'));
$implementation = realpath((string) (new ReflectionClass(Client::class))->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<array<string, string>> */
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;
Loading