diff --git a/app/Http/Controllers/Api/WorkerController.php b/app/Http/Controllers/Api/WorkerController.php index f9c7111c4..a36348918 100644 --- a/app/Http/Controllers/Api/WorkerController.php +++ b/app/Http/Controllers/Api/WorkerController.php @@ -1374,6 +1374,19 @@ public function pollWorkflowTasks(Request $request): JsonResponse $task = $this->formatTaskHistoryPagination($poll['task'] ?? null); + // PHP SDKs before 2.2.0 reject the valid StartAccepted prefix when + // a sticky hint triggers their authoritative-history fallback. + // They still receive the complete, paginated history and retain the + // previous full-replay behavior. Routing and lease identity are intact. + if ($task !== null + && $worker->runtime === 'php' + && is_string($worker->sdk_version) + && preg_match('/^durable-workflow-php\/v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/', $worker->sdk_version, $sdkVersion) === 1 + && version_compare($sdkVersion[1], '2.2.0', '<') + ) { + $task['sticky_replay_mode'] = null; + } + return WorkerProtocol::json([ 'task' => $task, 'poll_status' => is_string($poll['poll_status'] ?? null) diff --git a/app/Support/WorkflowTaskPoller.php b/app/Support/WorkflowTaskPoller.php index 427e00dd6..d11325694 100644 --- a/app/Support/WorkflowTaskPoller.php +++ b/app/Support/WorkflowTaskPoller.php @@ -2061,6 +2061,9 @@ private function taskPayload( ) : null, 'run_status' => $history['run_status'] ?? null, + 'sticky_worker_id' => $history['sticky_worker_id'] ?? $claim['sticky_worker_id'] ?? null, + 'sticky_until' => $history['sticky_until'] ?? $claim['sticky_until'] ?? null, + 'sticky_replay_mode' => $history['sticky_replay_mode'] ?? $claim['sticky_replay_mode'] ?? null, 'last_history_sequence' => $history['last_history_sequence'], 'total_history_events' => $history['total_history_events'], 'history_size_bytes' => $history['history_size_bytes'], diff --git a/composer.json b/composer.json index 682d557b3..1a3cc5642 100644 --- a/composer.json +++ b/composer.json @@ -48,7 +48,7 @@ }, "extra": { "durable-workflow": { - "product-train": "2.5.9" + "product-train": "2.5.10" }, "laravel": { "dont-discover": [] diff --git a/composer.lock b/composer.lock index 22c0fb7e2..eabbb465f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8a4c0d6184514a21c024916b180913cc", + "content-hash": "bdfaa34c6710ee47c966a25241f8c167", "packages": [ { "name": "apache/avro", diff --git a/docker-compose.dedicated-matching.yml b/docker-compose.dedicated-matching.yml index 518dd1910..056635e8a 100644 --- a/docker-compose.dedicated-matching.yml +++ b/docker-compose.dedicated-matching.yml @@ -32,13 +32,13 @@ name: durable-workflow-server # daemon reports `shape: dedicated`. # Generated by scripts/ci/sync-source-release.mjs. Do not edit the fallback. -x-server-image: &server-image ${DW_SERVER_IMAGE:-durableworkflow/server:${DW_SERVER_TAG:-2.5.9}} +x-server-image: &server-image ${DW_SERVER_IMAGE:-durableworkflow/server:${DW_SERVER_TAG:-2.5.10}} x-server-environment: &server-environment APP_NAME: "Durable Workflow Server" APP_ENV: ${APP_ENV:-local} DW_SERVER_KEY: ${DW_SERVER_KEY:-} - APP_VERSION: ${APP_VERSION:-${DW_SERVER_TAG:-2.5.9}} + APP_VERSION: ${APP_VERSION:-${DW_SERVER_TAG:-2.5.10}} APP_DEBUG: ${APP_DEBUG:-false} DB_CONNECTION: mysql DB_HOST: mysql diff --git a/docker-compose.memo-rolling.yml b/docker-compose.memo-rolling.yml index 002b639bc..a1bd5c0b4 100644 --- a/docker-compose.memo-rolling.yml +++ b/docker-compose.memo-rolling.yml @@ -49,14 +49,14 @@ services: command: ["server-bootstrap"] environment: <<: *runtime-environment - APP_VERSION: ${APP_VERSION:-2.5.9} + APP_VERSION: ${APP_VERSION:-2.5.10} successor: image: ${DW_MEMO_SUCCESSOR_IMAGE:-durable-workflow/server-memo-rolling:local} ports: !override [] environment: <<: *runtime-environment - APP_VERSION: ${APP_VERSION:-2.5.9} + APP_VERSION: ${APP_VERSION:-2.5.10} DW_SERVER_ID: memo-successor DW_SERVER_TOPOLOGY_SHAPE: standalone_server DW_SERVER_PROCESS_CLASS: server_http_node diff --git a/docker-compose.published.yml b/docker-compose.published.yml index a3c0cd035..3032567b3 100644 --- a/docker-compose.published.yml +++ b/docker-compose.published.yml @@ -1,13 +1,13 @@ name: durable-workflow-server # Generated by scripts/ci/sync-source-release.mjs. Do not edit the fallback. -x-server-image: &server-image ${DW_SERVER_IMAGE:-durableworkflow/server:${DW_SERVER_TAG:-2.5.9}} +x-server-image: &server-image ${DW_SERVER_IMAGE:-durableworkflow/server:${DW_SERVER_TAG:-2.5.10}} x-server-environment: &server-environment APP_NAME: "Durable Workflow Server" APP_ENV: ${APP_ENV:-local} DW_SERVER_KEY: ${DW_SERVER_KEY:-} - APP_VERSION: ${APP_VERSION:-${DW_SERVER_TAG:-2.5.9}} + APP_VERSION: ${APP_VERSION:-${DW_SERVER_TAG:-2.5.10}} APP_DEBUG: ${APP_DEBUG:-false} LOG_CHANNEL: ${LOG_CHANNEL:-stderr} LOG_LEVEL: ${LOG_LEVEL:-info} diff --git a/docker-compose.small-cluster.yml b/docker-compose.small-cluster.yml index 32711dd92..66b211265 100644 --- a/docker-compose.small-cluster.yml +++ b/docker-compose.small-cluster.yml @@ -12,7 +12,7 @@ x-server-build: &server-build x-server-environment: &server-environment APP_NAME: "Durable Workflow Server" APP_ENV: testing - APP_VERSION: ${APP_VERSION:-2.5.9} + APP_VERSION: ${APP_VERSION:-2.5.10} APP_DEBUG: "false" DW_SERVER_KEY: ${DW_SERVER_KEY:-base64:5Zt4nUhlCm3DD0nLXZJQdHiwPfb56yGo9gNV/g3jYbY=} DB_CONNECTION: ${DW_SMALL_CLUSTER_DB:-mysql} diff --git a/docker-compose.yml b/docker-compose.yml index 1d5f7dfe3..88a04697d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,7 +15,7 @@ services: DW_SERVER_KEY: "${DW_SERVER_KEY:-}" DW_SERVER_TOPOLOGY_SHAPE: standalone_server DW_SERVER_PROCESS_CLASS: server_http_node - APP_VERSION: "${APP_VERSION:-2.5.9}" + APP_VERSION: "${APP_VERSION:-2.5.10}" APP_DEBUG: "false" DB_CONNECTION: mysql DB_HOST: mysql @@ -62,7 +62,7 @@ services: APP_NAME: "Durable Workflow Server" APP_ENV: local DW_SERVER_KEY: "${DW_SERVER_KEY:-}" - APP_VERSION: "${APP_VERSION:-2.5.9}" + APP_VERSION: "${APP_VERSION:-2.5.10}" APP_DEBUG: "false" DB_CONNECTION: mysql DB_HOST: mysql @@ -124,7 +124,7 @@ services: DW_SERVER_KEY: "${DW_SERVER_KEY:-}" DW_SERVER_TOPOLOGY_SHAPE: standalone_server DW_SERVER_PROCESS_CLASS: worker_node - APP_VERSION: "${APP_VERSION:-2.5.9}" + APP_VERSION: "${APP_VERSION:-2.5.10}" DB_CONNECTION: mysql DB_HOST: mysql DB_PORT: 3306 @@ -178,7 +178,7 @@ services: DW_SERVER_KEY: "${DW_SERVER_KEY:-}" DW_SERVER_TOPOLOGY_SHAPE: standalone_server DW_SERVER_PROCESS_CLASS: scheduler_node - APP_VERSION: "${APP_VERSION:-2.5.9}" + APP_VERSION: "${APP_VERSION:-2.5.10}" DB_CONNECTION: mysql DB_HOST: mysql DB_PORT: 3306 diff --git a/k8s/README.md b/k8s/README.md index 7f5443b84..a37cfa63d 100644 --- a/k8s/README.md +++ b/k8s/README.md @@ -13,7 +13,7 @@ The checked-in manifests are synchronized with the repository's stable source release and pin its Docker Hub tag: ```text -durableworkflow/server:2.5.9 +durableworkflow/server:2.5.10 ``` Before production use, patch every workload image to the exact published tag or @@ -21,15 +21,15 @@ digest you intend to run: ```bash kubectl set image -n durable-workflow deploy/durable-workflow-server \ - server=durableworkflow/server:2.5.9 + server=durableworkflow/server:2.5.10 kubectl set image -n durable-workflow deploy/durable-workflow-worker \ - worker=durableworkflow/server:2.5.9 + worker=durableworkflow/server:2.5.10 kubectl set image -n durable-workflow cronjob/durable-workflow-scheduler \ - scheduler=durableworkflow/server:2.5.9 + scheduler=durableworkflow/server:2.5.10 ``` GitHub Container Registry publishes the same release line at -`ghcr.io/durable-workflow/server:2.5.9`. Digest pinning is preferred for strict +`ghcr.io/durable-workflow/server:2.5.10`. Digest pinning is preferred for strict change control. The manifests expect you to provide: diff --git a/k8s/helm/durable-workflow/Chart.yaml b/k8s/helm/durable-workflow/Chart.yaml index 6a55f204a..fe452dbd2 100644 --- a/k8s/helm/durable-workflow/Chart.yaml +++ b/k8s/helm/durable-workflow/Chart.yaml @@ -5,11 +5,11 @@ type: application # The chart's own semver version. Bumped on every chart release; treated as # independent of the server image version (appVersion). Breaking-change rules # for this version live in docs/helm-upgrading.md alongside the chart. -version: 0.1.146 +version: 0.1.147 # The immutable Durable Workflow Server identity this chart release packages. # The onboarding default in values.yaml and appVersion are generated from the # checked-in source release record. -appVersion: "2.5.9" +appVersion: "2.5.10" kubeVersion: ">=1.27.0-0" home: https://durable-workflow.github.io/docs/2.0/deployment sources: @@ -30,7 +30,7 @@ annotations: # exact commit that most recently changed the packaged chart. org.opencontainers.image.source: https://github.com/durable-workflow/server dev.durable-workflow.source-revision: "unreleased" - dev.durable-workflow.image-reference: "docker.io/durableworkflow/server:2.5.9" + dev.durable-workflow.image-reference: "docker.io/durableworkflow/server:2.5.10" artifacthub.io/license: MIT artifacthub.io/category: integration-delivery # Free-form changelog for the current chart release shown by Artifact Hub. diff --git a/k8s/helm/durable-workflow/README.md b/k8s/helm/durable-workflow/README.md index 5868eeb4c..8336a425d 100644 --- a/k8s/helm/durable-workflow/README.md +++ b/k8s/helm/durable-workflow/README.md @@ -63,7 +63,7 @@ helm install durable-workflow ./k8s/helm/durable-workflow \ ```yaml image: - tag: "2.5.9" + tag: "2.5.10" # Pin a digest in production: # digest: "sha256:abc123..." # memoPayloadStorage: "raw-json-v1" # Required for a digest or custom image. diff --git a/k8s/helm/durable-workflow/ci/existing-secrets-values.yaml b/k8s/helm/durable-workflow/ci/existing-secrets-values.yaml index 856f2f794..9275d4955 100644 --- a/k8s/helm/durable-workflow/ci/existing-secrets-values.yaml +++ b/k8s/helm/durable-workflow/ci/existing-secrets-values.yaml @@ -1,7 +1,7 @@ # CI fixture: GitOps / externally-managed-secret path. The chart consumes # existing Secrets and renders no Secret resources of its own. image: - tag: "2.5.9" + tag: "2.5.10" externalDatabase: connection: pgsql diff --git a/k8s/helm/durable-workflow/ci/ingress-and-hpa-values.yaml b/k8s/helm/durable-workflow/ci/ingress-and-hpa-values.yaml index 9a3362784..f0340b43e 100644 --- a/k8s/helm/durable-workflow/ci/ingress-and-hpa-values.yaml +++ b/k8s/helm/durable-workflow/ci/ingress-and-hpa-values.yaml @@ -1,6 +1,6 @@ # CI fixture: ingress + autoscaling enabled. Exercises optional templates. image: - tag: "2.5.9" + tag: "2.5.10" externalDatabase: connection: mysql diff --git a/k8s/helm/durable-workflow/ci/inline-secrets-values.yaml b/k8s/helm/durable-workflow/ci/inline-secrets-values.yaml index 2b2773eea..daf2964c1 100644 --- a/k8s/helm/durable-workflow/ci/inline-secrets-values.yaml +++ b/k8s/helm/durable-workflow/ci/inline-secrets-values.yaml @@ -2,7 +2,7 @@ # chart's render path is exercised end-to-end. Real deployments should use # existingSecret instead. image: - tag: "2.5.9" + tag: "2.5.10" externalDatabase: connection: mysql diff --git a/k8s/helm/durable-workflow/templates/_helpers.tpl b/k8s/helm/durable-workflow/templates/_helpers.tpl index fe2b1dd98..a1d955674 100644 --- a/k8s/helm/durable-workflow/templates/_helpers.tpl +++ b/k8s/helm/durable-workflow/templates/_helpers.tpl @@ -88,7 +88,7 @@ resolved by an explicit capability declaration or an existing workload marker. {{- define "durable-workflow.memoPayloadStorageForImage" -}} {{- $image := toString . -}} {{- $normalized := regexReplaceAll "^index\\.docker\\.io/" $image "docker.io/" -}} -{{- if eq $normalized "docker.io/durableworkflow/server:2.5.9" -}} +{{- if eq $normalized "docker.io/durableworkflow/server:2.5.10" -}} dual-v1 {{- else if regexMatch "^docker\\.io/durableworkflow/server:2\\.0\\.0-rc\\.[0-9]+$" $normalized -}} {{- $releaseCandidate := atoi (regexFind "[0-9]+$" $normalized) -}} diff --git a/k8s/helm/durable-workflow/values.yaml b/k8s/helm/durable-workflow/values.yaml index fe3fa4ae7..59d970b12 100644 --- a/k8s/helm/durable-workflow/values.yaml +++ b/k8s/helm/durable-workflow/values.yaml @@ -21,7 +21,7 @@ image: registry: docker.io repository: durableworkflow/server # Generated by scripts/ci/sync-source-release.mjs. Do not edit this default. - tag: "2.5.9" + tag: "2.5.10" # Optional digest pin. When set, takes precedence over tag for change control. # Example: "sha256:abc123..." digest: "" diff --git a/k8s/helm/examples/values-dev.yaml b/k8s/helm/examples/values-dev.yaml index f32df0d6c..93ebc3383 100644 --- a/k8s/helm/examples/values-dev.yaml +++ b/k8s/helm/examples/values-dev.yaml @@ -3,7 +3,7 @@ # shape in production. image: - tag: "2.5.9" + tag: "2.5.10" externalDatabase: connection: mysql diff --git a/k8s/helm/examples/values-external-secrets-operator.yaml b/k8s/helm/examples/values-external-secrets-operator.yaml index 0979de749..7cf59edbf 100644 --- a/k8s/helm/examples/values-external-secrets-operator.yaml +++ b/k8s/helm/examples/values-external-secrets-operator.yaml @@ -5,7 +5,7 @@ # concern. image: - tag: "2.5.9" + tag: "2.5.10" externalDatabase: connection: pgsql diff --git a/k8s/helm/examples/values-production-existing-secrets.yaml b/k8s/helm/examples/values-production-existing-secrets.yaml index 6a6b16185..a18f92f84 100644 --- a/k8s/helm/examples/values-production-existing-secrets.yaml +++ b/k8s/helm/examples/values-production-existing-secrets.yaml @@ -10,7 +10,7 @@ image: repository: durable-workflow/server # Pin a digest in production for change-control auditability. digest: "" # e.g. "sha256:abc123..." - tag: "2.5.9" + tag: "2.5.10" externalDatabase: connection: pgsql diff --git a/k8s/migration-job.yaml b/k8s/migration-job.yaml index f58a60172..25c74c508 100644 --- a/k8s/migration-job.yaml +++ b/k8s/migration-job.yaml @@ -13,7 +13,7 @@ spec: restartPolicy: OnFailure containers: - name: migrate - image: durableworkflow/server:2.5.9 + image: durableworkflow/server:2.5.10 command: ["server-entrypoint"] args: ["server-bootstrap"] envFrom: diff --git a/k8s/scheduler-cronjob.yaml b/k8s/scheduler-cronjob.yaml index ae8cf6d99..cba1a1dca 100644 --- a/k8s/scheduler-cronjob.yaml +++ b/k8s/scheduler-cronjob.yaml @@ -24,7 +24,7 @@ spec: restartPolicy: Never containers: - name: scheduler - image: durableworkflow/server:2.5.9 + image: durableworkflow/server:2.5.10 command: ["server-entrypoint"] args: - sh diff --git a/k8s/secret.yaml b/k8s/secret.yaml index 3cf9e45a5..aabb06674 100644 --- a/k8s/secret.yaml +++ b/k8s/secret.yaml @@ -12,7 +12,7 @@ metadata: app.kubernetes.io/name: durable-workflow data: APP_NAME: "Durable Workflow Server" - APP_VERSION: "2.5.9" + APP_VERSION: "2.5.10" APP_ENV: production APP_DEBUG: "false" DB_CONNECTION: mysql diff --git a/k8s/server-deployment.yaml b/k8s/server-deployment.yaml index d8ee6458d..fbb33be07 100644 --- a/k8s/server-deployment.yaml +++ b/k8s/server-deployment.yaml @@ -23,7 +23,7 @@ spec: spec: containers: - name: server - image: durableworkflow/server:2.5.9 + image: durableworkflow/server:2.5.10 ports: - containerPort: 8080 name: http diff --git a/k8s/worker-deployment.yaml b/k8s/worker-deployment.yaml index f2c697da8..7a301aeb7 100644 --- a/k8s/worker-deployment.yaml +++ b/k8s/worker-deployment.yaml @@ -19,7 +19,7 @@ spec: spec: containers: - name: worker - image: durableworkflow/server:2.5.9 + image: durableworkflow/server:2.5.10 command: ["server-entrypoint"] args: ["php", "artisan", "queue:work", "--sleep=1", "--tries=3", "--max-time=3600"] envFrom: diff --git a/resources/platform-protocol-specs/worker-protocol-api.openapi.yaml b/resources/platform-protocol-specs/worker-protocol-api.openapi.yaml index 3da1eb062..fba5c36b2 100644 --- a/resources/platform-protocol-specs/worker-protocol-api.openapi.yaml +++ b/resources/platform-protocol-specs/worker-protocol-api.openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: durable-workflow.v2.worker-protocol-api - version: "48" + version: "49" summary: Durable Workflow worker-plane HTTP+JSON API description: > Normative OpenAPI specification for the worker-plane HTTP+JSON API: @@ -1866,6 +1866,24 @@ components: workflow_task_attempt: { type: integer, minimum: 1 } lease_owner: { type: string, minLength: 1 } lease_expires_at: { type: string, format: date-time } + sticky_worker_id: + type: [string, "null"] + description: Process identity holding this run's optional sticky affinity. + x-durable-workflow-minimum-protocol-version: "1.18" + sticky_until: + type: [string, "null"] + format: date-time + description: Affinity expiry. Cache loss before expiry still requires cold replay. + x-durable-workflow-minimum-protocol-version: "1.18" + sticky_replay_mode: + type: [string, "null"] + enum: [cold_replay, sticky_hit_expected, forced_cold_replay, null] + description: >- + Authoritative mode for the current lease. A warm hint requires a valid + local prefix. PHP SDKs before 2.2.0 receive null to preserve full replay + because their sticky fallback rejects a valid StartAccepted prefix. + History, pagination, affinity routing and lease identity are unchanged. + x-durable-workflow-minimum-protocol-version: "1.18" history_events: { type: array, items: { $ref: "#/components/schemas/WorkerHistoryEvent" } } cancellation_request: $ref: "#/components/schemas/CooperativeCancellationRequest" diff --git a/resources/release/source-release.json b/resources/release/source-release.json index f4f99179b..4ec915784 100644 --- a/resources/release/source-release.json +++ b/resources/release/source-release.json @@ -1,9 +1,9 @@ { "schema": "durable-workflow.server.source-release/v1", "server": { - "version": "2.5.9" + "version": "2.5.10" }, "helm_chart": { - "version": "0.1.146" + "version": "0.1.147" } } diff --git a/scripts/k8s-kind-smoke.sh b/scripts/k8s-kind-smoke.sh index ca5b637f7..60c934f31 100755 --- a/scripts/k8s-kind-smoke.sh +++ b/scripts/k8s-kind-smoke.sh @@ -7,7 +7,7 @@ cluster="${K8S_SMOKE_CLUSTER:-durable-workflow-server-smoke}" image="${K8S_SMOKE_IMAGE:-durableworkflow/server:k8s-smoke}" # Generated by scripts/ci/sync-source-release.mjs so the smoke replaces the # same default shipped by the public manifests. -manifest_image="durableworkflow/server:2.5.9" +manifest_image="durableworkflow/server:2.5.10" kind_node_image="${K8S_SMOKE_KIND_NODE_IMAGE:-kindest/node:v1.29.4}" artifact_dir="${K8S_SMOKE_ARTIFACT_DIR:-/tmp/durable-workflow-k8s-kind-smoke-artifacts}" rendered_dir="${artifact_dir}/rendered-manifests" diff --git a/tests/Feature/WorkflowWorkerProtocolTest.php b/tests/Feature/WorkflowWorkerProtocolTest.php index 425129312..cb0f5761b 100644 --- a/tests/Feature/WorkflowWorkerProtocolTest.php +++ b/tests/Feature/WorkflowWorkerProtocolTest.php @@ -15,6 +15,7 @@ use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Queue; use Mockery\MockInterface; +use PHPUnit\Framework\Attributes\DataProvider; use Tests\Fixtures\ExternalGreetingWorkflow; use Tests\Fixtures\InteractiveCommandWorkflow; use Tests\TestCase; @@ -25,6 +26,7 @@ use Workflow\V2\Enums\TaskStatus; use Workflow\V2\Enums\TaskType; use Workflow\V2\Exceptions\StructuralLimitExceededException; +use Workflow\V2\Jobs\RunTimerTask; use Workflow\V2\Models\ActivityAttempt; use Workflow\V2\Models\ActivityExecution; use Workflow\V2\Models\WorkflowChildCall; @@ -986,8 +988,24 @@ public function test_local_activity_completion_uses_negotiated_nested_object_str } } - public function test_sticky_claim_requires_exact_identity_and_persists_only_as_an_optimization(): void + public static function stickyReplayWorkerProfiles(): array { + return [ + 'legacy PHP' => ['php', 'durable-workflow-php/2.1.5', false], + 'legacy PHP with v prefix' => ['php', 'durable-workflow-php/v2.1.6', false], + 'fixed PHP' => ['php', 'durable-workflow-php/2.2.0', true], + 'current PHP' => ['php', 'durable-workflow-php/2.2.4', true], + 'source capability manifest' => ['php', 'durable-workflow-php/test', true], + 'Python' => ['python', 'durable-workflow-python/2.5.0', true], + ]; + } + + #[DataProvider('stickyReplayWorkerProfiles')] + public function test_sticky_claim_requires_exact_identity_and_persists_only_as_an_optimization( + string $runtime, + string $sdkVersion, + bool $expectsReplayHint, + ): void { Queue::fake(); $this->configureWorkflowTypes(); $this->createNamespace('default', 'Default namespace'); @@ -1012,8 +1030,8 @@ public function test_sticky_claim_requires_exact_identity_and_persists_only_as_a $this->withHeaders($this->workerHeaders())->postJson('/api/worker/register', [ 'worker_id' => 'php-sticky-worker', 'task_queue' => 'portable-affinity', - 'runtime' => 'php', - 'sdk_version' => 'durable-workflow-php/test', + 'runtime' => $runtime, + 'sdk_version' => $sdkVersion, 'build_id' => 'build-a', 'supported_workflow_types' => ['tests.external-greeting-workflow'], 'capabilities' => $capabilities, @@ -1024,7 +1042,12 @@ public function test_sticky_claim_requires_exact_identity_and_persists_only_as_a 'worker_id' => 'php-sticky-worker', 'task_queue' => 'portable-affinity', 'build_id' => 'build-a', - ])->assertOk(); + 'history_page_size' => 1, + ])->assertOk() + ->assertJsonPath('task.sticky_worker_id', null) + ->assertJsonPath('task.sticky_until', null) + ->assertJsonPath('task.sticky_replay_mode', $expectsReplayHint ? StickyExecution::MODE_COLD_REPLAY : null); + $this->assertSame('StartAccepted', $poll->json('task.history_events.0.event_type')); $taskId = (string) $poll->json('task.task_id'); $this->withHeaders($this->workerHeaders()) @@ -1053,6 +1076,42 @@ public function test_sticky_claim_requires_exact_identity_and_persists_only_as_a ->firstOrFail() ->process_metrics['sticky_cache'], ); + + $timer = WorkflowTask::query()->where('workflow_run_id', $runId) + ->where('task_type', TaskType::Timer)->firstOrFail(); + $this->travel(6)->seconds(); + $this->app->call([new RunTimerTask($timer->id), 'handle']); + $body = [ + 'worker_id' => 'php-sticky-worker', + 'task_queue' => 'portable-affinity', + 'build_id' => 'build-a', + 'history_page_size' => 1, + 'poll_request_id' => 'sticky-warm-poll', + ]; + $warm = $this->withHeaders($this->workerHeaders())->postJson('/api/worker/workflow-tasks/poll', $body) + ->assertOk() + ->assertJsonPath('task.sticky_worker_id', 'php-sticky-worker') + ->assertJsonPath('task.sticky_replay_mode', $expectsReplayHint ? StickyExecution::MODE_STICKY_HIT_EXPECTED : null); + $this->assertNotNull($warm->json('task.sticky_until')); + $this->assertNotNull($warm->json('task.next_history_page_token')); + $this->withHeaders($this->workerHeaders())->postJson('/api/worker/workflow-tasks/poll', $body) + ->assertOk() + ->assertJsonPath('task.task_id', $warm->json('task.task_id')) + ->assertJsonPath('task.workflow_task_attempt', $warm->json('task.workflow_task_attempt')) + ->assertJsonPath('task.sticky_worker_id', 'php-sticky-worker') + ->assertJsonPath('task.sticky_until', $warm->json('task.sticky_until')) + ->assertJsonPath('task.sticky_replay_mode', $expectsReplayHint ? StickyExecution::MODE_STICKY_HIT_EXPECTED : null); + + // Losing the holder lease and affinity TTL keeps the original durable run. + WorkflowTask::query()->whereKey($warm->json('task.task_id'))->update([ + 'status' => TaskStatus::Ready, 'lease_owner' => null, 'lease_expires_at' => null, + 'sticky_until' => now()->subSecond(), + ]); + $body['poll_request_id'] = 'sticky-expired-poll'; + $this->withHeaders($this->workerHeaders())->postJson('/api/worker/workflow-tasks/poll', $body) + ->assertOk() + ->assertJsonPath('task.run_id', $runId) + ->assertJsonPath('task.sticky_replay_mode', $expectsReplayHint ? StickyExecution::MODE_FORCED_COLD_REPLAY : null); } public function test_worker_heartbeat_is_scoped_to_the_resolved_namespace(): void diff --git a/tests/Unit/WorkerProtocolOpenApiContractTest.php b/tests/Unit/WorkerProtocolOpenApiContractTest.php index 1a878c08e..2e1b1467a 100644 --- a/tests/Unit/WorkerProtocolOpenApiContractTest.php +++ b/tests/Unit/WorkerProtocolOpenApiContractTest.php @@ -146,6 +146,15 @@ public function test_worker_lifecycle_and_poll_routes_describe_unknown_database_ public function test_portable_worker_affinity_is_machine_described_at_protocol_1_18(): void { + $task = $this->spec['components']['schemas']['WorkflowTask']; + foreach (['sticky_worker_id', 'sticky_until', 'sticky_replay_mode'] as $field) { + $this->assertSame(['string', 'null'], $task['properties'][$field]['type']); + $this->assertSame('1.18', $task['properties'][$field]['x-durable-workflow-minimum-protocol-version']); + } + $this->assertSame( + ['cold_replay', 'sticky_hit_expected', 'forced_cold_replay', null], + $task['properties']['sticky_replay_mode']['enum'], + ); $contract = $this->spec['x-durable-workflow-portable-worker-affinity-contract']; $this->assertSame('1.18', $contract['minimum_protocol_version']); $this->assertSame(