From c0c4b41474d216f178b608282f7068be2e00ae78 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:27:56 +0000 Subject: [PATCH 1/5] Explain worker-bound Nexus routing and result observation --- docs/contracts/nexus.md | 31 +++++++++++++++++++++++++++++++ docs/worker-command-contracts.md | 11 +++++++++++ 2 files changed, 42 insertions(+) diff --git a/docs/contracts/nexus.md b/docs/contracts/nexus.md index 0956901d0..7493d8459 100644 --- a/docs/contracts/nexus.md +++ b/docs/contracts/nexus.md @@ -172,6 +172,37 @@ The execute, describe, and cancel surfaces require the operator role. Catalog mutations require admin. The caller-history surface requires operator and is read-only. +## Workflow bindings + +PHP Server can bind catalog operations to an SDK-worker workflow without loading +that workflow's implementation into the Server process: + +| Binding | Target | +|---|---| +| `start_workflow` | Workflow type, with optional `workflow_instance_id` and `queue` in `handler_binding`. | +| `signal_workflow` | Existing workflow instance and declared signal name. | +| `update_workflow` | Existing workflow instance and declared update name. | +| `query_workflow` | Existing workflow instance and declared query name. | + +The start uses the same task-queue resolution and promoted worker build as a +normal workflow start. Its durable command declarations come from that selected +namespace, queue, workflow type and build cohort. A different queue's handler +registration cannot supply the target run's update or query contract. Register +the target worker and its declarations before starting the workflow. See +[worker command declarations](../worker-command-contracts.md). + +Use the SDK's Avro argument encoding. A completed query or update exposes its +value in `result_envelope`, including null, false and zero. An asynchronous +update may initially return a started call without a result. Retain its +`service_call_id` and use the describe route until the call is terminal; replay +the same idempotency key for the same intent rather than creating another call. + +An invalid update argument produces a typed Nexus handler failure with +`accepted: false` and HTTP 409, such as `invalid_update_arguments`. The rejected +command remains available for inspection. This differs from request validation, +such as an invalid top-level zero completion wait, which returns HTTP 422 before +admission. Use the call and rejected command to inspect the refusal. + ## Durable record and retry semantics Workflow task completion derives caller namespace and run/instance identity from diff --git a/docs/worker-command-contracts.md b/docs/worker-command-contracts.md index 7b7ca3c96..97ae8d69f 100644 --- a/docs/worker-command-contracts.md +++ b/docs/worker-command-contracts.md @@ -18,3 +18,14 @@ selected build cohort. A later registration applies to new admissions and preserves the original run's durable snapshot. Existing runs with missing metadata remain unavailable until an explicit supported recovery can establish their original contract. + +Workflow-bound Nexus starts, updates and queries use the same declarations as +the ordinary workflow routes. [Nexus workflow bindings](contracts/nexus.md#workflow-bindings) +describe queue selection, result observation and typed refusals. + +An update's durable identity and completion record distinguish it from a second +accepted update. SDKs that reconstruct workflow state may invoke its recorded +handler again during replay, including replay for a query. Keep replayed +handlers deterministic and use durable workflow operations for external +effects. A callback entry count alone does not prove duplicate admission or +exactly-once external execution. From e8fd3483f3e1929c1a80fd9c68207d54135f447f Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:31:38 +0000 Subject: [PATCH 2/5] Scope update refusal to the captured argument contract --- docs/contracts/nexus.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/contracts/nexus.md b/docs/contracts/nexus.md index 7493d8459..17bcb0440 100644 --- a/docs/contracts/nexus.md +++ b/docs/contracts/nexus.md @@ -184,7 +184,7 @@ that workflow's implementation into the Server process: | `update_workflow` | Existing workflow instance and declared update name. | | `query_workflow` | Existing workflow instance and declared query name. | -The start uses the same task-queue resolution and promoted worker build as a +The start uses the same task-queue resolution and any promoted worker build as a normal workflow start. Its durable command declarations come from that selected namespace, queue, workflow type and build cohort. A different queue's handler registration cannot supply the target run's update or query contract. Register @@ -197,7 +197,7 @@ update may initially return a started call without a result. Retain its `service_call_id` and use the describe route until the call is terminal; replay the same idempotency key for the same intent rather than creating another call. -An invalid update argument produces a typed Nexus handler failure with +When its captured contract rejects an update argument, Nexus returns a typed handler failure with `accepted: false` and HTTP 409, such as `invalid_update_arguments`. The rejected command remains available for inspection. This differs from request validation, such as an invalid top-level zero completion wait, which returns HTTP 422 before From e91eaef0be9a035bf7a76e23e1b8304ceb7dfb33 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:58:03 +0000 Subject: [PATCH 3/5] Enforce workflow deadlines before acknowledging SDK history waits --- app/Http/Controllers/Api/WorkerController.php | 11 +++ tests/Feature/WorkflowWorkerProtocolTest.php | 73 +++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/app/Http/Controllers/Api/WorkerController.php b/app/Http/Controllers/Api/WorkerController.php index df7bfff68..012d120e3 100644 --- a/app/Http/Controllers/Api/WorkerController.php +++ b/app/Http/Controllers/Api/WorkerController.php @@ -88,6 +88,7 @@ use Workflow\V2\Support\StickyExecution; use Workflow\V2\Support\WorkerProtocolVersion; use Workflow\V2\Support\WorkflowCommandNormalizer; +use Workflow\V2\Support\WorkflowExecutor; use Workflow\V2\Support\WorkflowStepHistory; use Workflow\V2\Support\WorkflowTaskOwnership; @@ -3968,6 +3969,16 @@ private function acknowledgeWorkflowTaskWaitingForHistory(string $namespace, str return ['recorded' => false, 'reason' => 'run_already_closed', 'next_task_id' => null]; } + $now = now(); + $deadlineExpired = ($run->execution_deadline_at !== null && $now->gte($run->execution_deadline_at)) + || ($run->run_deadline_at !== null && $now->gte($run->run_deadline_at)); + + // A deadline probe may have no new history. Close the expired run + // through Core before acknowledging the worker's wait. + if ($deadlineExpired && app(WorkflowExecutor::class)->timeoutIfDeadlineExpired($run, $task)) { + return ['recorded' => false, 'reason' => 'run_timed_out', 'next_task_id' => null]; + } + if ($this->workflowTaskResumesSignal($task)) { $bridgeOutcome = $bridge->complete($taskId, []); diff --git a/tests/Feature/WorkflowWorkerProtocolTest.php b/tests/Feature/WorkflowWorkerProtocolTest.php index 17974a144..dc274f9d5 100644 --- a/tests/Feature/WorkflowWorkerProtocolTest.php +++ b/tests/Feature/WorkflowWorkerProtocolTest.php @@ -3689,6 +3689,79 @@ public function test_waiting_for_scheduled_history_workflow_task_failure_acknowl ); } + #[DataProvider('waitingHistoryDeadlineCases')] + public function test_waiting_for_history_ack_enforces_the_original_deadline(string $kind, bool $expired): void + { + Queue::fake(); + $this->configureWorkflowTypes(); + $this->createNamespace('default', 'Default'); + $clock = Carbon::parse('2026-11-01T18:00:00Z'); + Carbon::setTestNow($clock); + + try { + $runId = $this->postJson('/api/workflows', [ + 'workflow_id' => 'waiting-history-deadline', + 'workflow_type' => 'tests.external-greeting-workflow', + 'task_queue' => 'waiting-history-deadline', + 'input' => ['deadline'], + 'run_timeout_seconds' => $kind === 'run' ? 2 : 60, + 'execution_timeout_seconds' => $kind === 'execution' ? 2 : 60, + ], $this->apiHeaders())->assertCreated()->json('run_id'); + $this->registerWorker('waiting-history-deadline-worker', 'waiting-history-deadline'); + $task = $this->postJson('/api/worker/workflow-tasks/poll', [ + 'worker_id' => 'waiting-history-deadline-worker', 'task_queue' => 'waiting-history-deadline', + ], $this->workerHeaders())->assertOk()->json('task'); + $this->assertIsArray($task); + Carbon::setTestNow($clock->copy()->addSeconds($expired ? 3 : 1)); + $body = [ + 'lease_owner' => $task['lease_owner'], 'workflow_task_attempt' => $task['workflow_task_attempt'], + 'failure' => ['type' => 'WorkflowTaskWaitingForHistory', 'message' => 'history is not ready yet'], + ]; + $path = '/api/worker/workflow-tasks/'.$task['task_id'].'/fail'; + $runBefore = WorkflowRun::query()->findOrFail($runId)->getRawOriginal(); + $historyBefore = WorkflowHistoryEvent::query()->where('workflow_run_id', $runId)->count(); + $stale = $body; + $stale['workflow_task_attempt']++; + $this->postJson($path, $stale, $this->workerHeaders())->assertStatus(409); + $this->assertSame($runBefore, WorkflowRun::query()->findOrFail($runId)->getRawOriginal()); + $this->assertSame($historyBefore, WorkflowHistoryEvent::query()->where('workflow_run_id', $runId)->count()); + + $response = $this->postJson($path, $body, $this->workerHeaders()); + $run = WorkflowRun::query()->findOrFail($runId); + $closed = WorkflowHistoryEvent::query()->where('workflow_run_id', $runId) + ->where('event_type', HistoryEventType::WorkflowTimedOut->value)->count(); + if (! $expired) { + $response->assertOk()->assertJsonPath('recorded', true); + $this->assertSame('waiting', $run->status->value); + $this->assertSame(0, $closed); + + return; + } + + $response->assertStatus(409)->assertJsonPath('recorded', false)->assertJsonPath('reason', 'run_timed_out'); + $this->assertSame('failed', $run->status->value); + $this->assertSame('timed_out', $run->closed_reason); + $this->assertSame(1, $closed); + $this->assertSame($kind.'_timeout', WorkflowHistoryEvent::query()->where('workflow_run_id', $runId) + ->where('event_type', HistoryEventType::WorkflowTimedOut->value)->sole()->payload['timeout_kind']); + $this->assertFalse(WorkflowTask::query()->where('workflow_run_id', $runId) + ->whereIn('status', [TaskStatus::Ready->value, TaskStatus::Leased->value])->exists()); + $this->postJson($path, $body, $this->workerHeaders())->assertStatus(409); + $this->assertSame(1, WorkflowHistoryEvent::query()->where('workflow_run_id', $runId) + ->where('event_type', HistoryEventType::WorkflowTimedOut->value)->count()); + } finally { + Carbon::setTestNow(); + } + } + + public static function waitingHistoryDeadlineCases(): iterable + { + yield 'elapsed run deadline' => ['run', true]; + yield 'elapsed execution deadline' => ['execution', true]; + yield 'unelapsed run deadline' => ['run', false]; + yield 'unelapsed execution deadline' => ['execution', false]; + } + public function test_waiting_for_history_failure_type_acknowledges_the_wait_without_message_phrase(): void { Queue::fake(); From 955689279ac8f3af981794aca522e8cfdfeee3c9 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:27:15 +0000 Subject: [PATCH 4/5] Preserve the canonical timeout receipt for published SDK workers --- app/Http/Controllers/Api/WorkerController.php | 18 +++++++++++++++++- docs/worker-command-contracts.md | 6 ++++++ tests/Feature/WorkflowWorkerProtocolTest.php | 8 ++++++-- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/app/Http/Controllers/Api/WorkerController.php b/app/Http/Controllers/Api/WorkerController.php index 012d120e3..477852587 100644 --- a/app/Http/Controllers/Api/WorkerController.php +++ b/app/Http/Controllers/Api/WorkerController.php @@ -3765,6 +3765,19 @@ public function failWorkflowTask(Request $request, string $taskId): JsonResponse return $outcome; } + if ($outcome['reason'] === 'run_timed_out') { + return WorkerProtocol::json([ + 'task_id' => $taskId, + 'workflow_task_attempt' => (int) $validated['workflow_task_attempt'], + 'outcome' => 'completed', + 'recorded' => false, + 'run_id' => $outcome['run_id'], + 'run_status' => $outcome['run_status'], + 'created_task_ids' => [], + 'reason' => 'run_timed_out', + ], 409); + } + return WorkerProtocol::json([ 'task_id' => $taskId, 'workflow_task_attempt' => (int) $validated['workflow_task_attempt'], @@ -3976,7 +3989,8 @@ private function acknowledgeWorkflowTaskWaitingForHistory(string $namespace, str // A deadline probe may have no new history. Close the expired run // through Core before acknowledging the worker's wait. if ($deadlineExpired && app(WorkflowExecutor::class)->timeoutIfDeadlineExpired($run, $task)) { - return ['recorded' => false, 'reason' => 'run_timed_out', 'next_task_id' => null]; + return ['recorded' => false, 'reason' => 'run_timed_out', 'next_task_id' => null, + 'run_id' => $run->id, 'run_status' => $run->status->value]; } if ($this->workflowTaskResumesSignal($task)) { @@ -3989,6 +4003,8 @@ private function acknowledgeWorkflowTaskWaitingForHistory(string $namespace, str ? $bridgeOutcome['reason'] : 'workflow_bridge_completion_failed', 'next_task_id' => null, + 'run_id' => $bridgeOutcome['workflow_run_id'] ?? null, + 'run_status' => $bridgeOutcome['run_status'] ?? null, ]; } diff --git a/docs/worker-command-contracts.md b/docs/worker-command-contracts.md index 97ae8d69f..da45e39cc 100644 --- a/docs/worker-command-contracts.md +++ b/docs/worker-command-contracts.md @@ -29,3 +29,9 @@ handler again during replay, including replay for a query. Keep replayed handlers deterministic and use durable workflow operations for external effects. A callback entry count alone does not prove duplicate admission or exactly-once external execution. + +When a history-wait acknowledgment closes an expired run, Server returns the +normal timeout completion receipt: HTTP 409 `run_timed_out`, `outcome=completed` +and `recorded=false`, with the original task, run and attempt identities and +`run_status=failed`. Workers can recognize that matching terminal receipt and +continue processing other workflows. diff --git a/tests/Feature/WorkflowWorkerProtocolTest.php b/tests/Feature/WorkflowWorkerProtocolTest.php index dc274f9d5..efec1097b 100644 --- a/tests/Feature/WorkflowWorkerProtocolTest.php +++ b/tests/Feature/WorkflowWorkerProtocolTest.php @@ -3731,14 +3731,18 @@ public function test_waiting_for_history_ack_enforces_the_original_deadline(stri $closed = WorkflowHistoryEvent::query()->where('workflow_run_id', $runId) ->where('event_type', HistoryEventType::WorkflowTimedOut->value)->count(); if (! $expired) { - $response->assertOk()->assertJsonPath('recorded', true); + $response->assertOk()->assertJsonPath('recorded', true)->assertJsonPath('outcome', 'waiting_for_history') + ->assertJsonMissingPath('run_id')->assertJsonMissingPath('run_status'); $this->assertSame('waiting', $run->status->value); $this->assertSame(0, $closed); return; } - $response->assertStatus(409)->assertJsonPath('recorded', false)->assertJsonPath('reason', 'run_timed_out'); + $response->assertStatus(409)->assertJsonPath('recorded', false)->assertJsonPath('reason', 'run_timed_out') + ->assertJsonPath('outcome', 'completed')->assertJsonPath('run_id', $runId)->assertJsonPath('run_status', 'failed') + ->assertJsonPath('task_id', $task['task_id'])->assertJsonPath('workflow_task_attempt', $task['workflow_task_attempt']) + ->assertJsonPath('created_task_ids', []); $this->assertSame('failed', $run->status->value); $this->assertSame('timed_out', $run->closed_reason); $this->assertSame(1, $closed); From 42e44a09f093b8537040a99be51d09de240ce3a1 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:48:22 +0000 Subject: [PATCH 5/5] Enforce workflow deadlines on ordinary failure acknowledgments Share the locked deadline check and canonical timeout receipt across failure paths, preserve unexpired retries, and prepare Server 2.11.9 with chart 0.3.9. --- app/Http/Controllers/Api/WorkerController.php | 73 ++++++++++++++----- composer.json | 2 +- composer.lock | 2 +- docker-compose.dedicated-matching.yml | 4 +- docker-compose.memo-rolling.yml | 4 +- docker-compose.php.yml | 4 +- docker-compose.small-cluster.yml | 2 +- docker-compose.yml | 8 +- docs/worker-command-contracts.md | 4 +- k8s/README.md | 10 +-- k8s/helm/durable-workflow/Chart.yaml | 6 +- k8s/helm/durable-workflow/README.md | 2 +- .../ci/existing-secrets-values.yaml | 2 +- .../ci/ingress-and-hpa-values.yaml | 2 +- .../ci/inline-secrets-values.yaml | 2 +- .../durable-workflow/templates/_helpers.tpl | 2 +- k8s/helm/durable-workflow/values.yaml | 2 +- k8s/helm/examples/values-dev.yaml | 2 +- .../values-external-secrets-operator.yaml | 2 +- .../values-production-existing-secrets.yaml | 2 +- k8s/migration-job.yaml | 2 +- k8s/scheduler-cronjob.yaml | 2 +- k8s/secret.yaml | 2 +- k8s/server-deployment.yaml | 2 +- k8s/worker-deployment.yaml | 2 +- resources/release/source-release.json | 4 +- scripts/k8s-kind-smoke.sh | 2 +- tests/Feature/WorkflowWorkerProtocolTest.php | 28 ++++--- 28 files changed, 111 insertions(+), 70 deletions(-) diff --git a/app/Http/Controllers/Api/WorkerController.php b/app/Http/Controllers/Api/WorkerController.php index 477852587..cc1cfb83a 100644 --- a/app/Http/Controllers/Api/WorkerController.php +++ b/app/Http/Controllers/Api/WorkerController.php @@ -3766,16 +3766,7 @@ public function failWorkflowTask(Request $request, string $taskId): JsonResponse } if ($outcome['reason'] === 'run_timed_out') { - return WorkerProtocol::json([ - 'task_id' => $taskId, - 'workflow_task_attempt' => (int) $validated['workflow_task_attempt'], - 'outcome' => 'completed', - 'recorded' => false, - 'run_id' => $outcome['run_id'], - 'run_status' => $outcome['run_status'], - 'created_task_ids' => [], - 'reason' => 'run_timed_out', - ], 409); + return $this->workflowTaskTimeoutResponse($taskId, (int) $validated['workflow_task_attempt'], $outcome); } return WorkerProtocol::json([ @@ -3805,7 +3796,8 @@ public function failWorkflowTask(Request $request, string $taskId): JsonResponse $taskId, $validated, ): array|JsonResponse { - if (! NamespaceWorkflowScope::lockTaskForMutation($namespace, $taskId) instanceof WorkflowTask) { + $task = NamespaceWorkflowScope::lockTaskForMutation($namespace, $taskId); + if (! $task instanceof WorkflowTask) { return $this->workflowTaskNotFound($taskId, (int) $validated['workflow_task_attempt']); } if ($response = $this->guardWorkflowTaskOwnership($request, $namespace, $taskId, @@ -3813,6 +3805,14 @@ public function failWorkflowTask(Request $request, string $taskId): JsonResponse return $response; } + $run = WorkflowRun::query()->lockForUpdate()->find($task->workflow_run_id); + if ($run instanceof WorkflowRun) { + $timeout = $this->workflowTaskDeadlineOutcome($run, $task); + if ($timeout !== null) { + return $timeout; + } + } + $outcome = $bridge->fail($taskId, $validated['failure']); $nextTaskId = is_string($outcome['next_task_id'] ?? null) ? $outcome['next_task_id'] @@ -3859,6 +3859,10 @@ public function failWorkflowTask(Request $request, string $taskId): JsonResponse ? $outcome['next_task_id'] : null; + if ($outcome['reason'] === 'run_timed_out') { + return $this->workflowTaskTimeoutResponse($taskId, (int) $validated['workflow_task_attempt'], $outcome); + } + return WorkerProtocol::json([ 'task_id' => $taskId, 'workflow_task_attempt' => (int) $validated['workflow_task_attempt'], @@ -3869,6 +3873,41 @@ public function failWorkflowTask(Request $request, string $taskId): JsonResponse ], $this->workflowOutcomeStatus($outcome['reason'])); } + /** @param array $outcome */ + private function workflowTaskTimeoutResponse(string $taskId, int $attempt, array $outcome): JsonResponse + { + return WorkerProtocol::json([ + 'task_id' => $taskId, + 'workflow_task_attempt' => $attempt, + 'outcome' => 'completed', + 'recorded' => false, + 'run_id' => $outcome['run_id'], + 'run_status' => $outcome['run_status'], + 'created_task_ids' => [], + 'reason' => 'run_timed_out', + ], 409); + } + + /** @return array|null */ + private function workflowTaskDeadlineOutcome(WorkflowRun $run, WorkflowTask $task): ?array + { + if ($task->task_type !== TaskType::Workflow || $run->status->isTerminal()) { + return null; + } + $now = now(); + $expired = ($run->execution_deadline_at !== null && $now->gte($run->execution_deadline_at)) + || ($run->run_deadline_at !== null && $now->gte($run->run_deadline_at)); + + // Both callers hold the task/run locks after revalidating ownership. + // Avoid loading the full run history for ordinary, unexpired waits. + if ($expired && app(WorkflowExecutor::class)->timeoutIfDeadlineExpired($run, $task)) { + return ['recorded' => false, 'reason' => 'run_timed_out', 'next_task_id' => null, + 'run_id' => $run->id, 'run_status' => $run->status->value]; + } + + return null; + } + /** * @param array $failure */ @@ -3982,15 +4021,9 @@ private function acknowledgeWorkflowTaskWaitingForHistory(string $namespace, str return ['recorded' => false, 'reason' => 'run_already_closed', 'next_task_id' => null]; } - $now = now(); - $deadlineExpired = ($run->execution_deadline_at !== null && $now->gte($run->execution_deadline_at)) - || ($run->run_deadline_at !== null && $now->gte($run->run_deadline_at)); - - // A deadline probe may have no new history. Close the expired run - // through Core before acknowledging the worker's wait. - if ($deadlineExpired && app(WorkflowExecutor::class)->timeoutIfDeadlineExpired($run, $task)) { - return ['recorded' => false, 'reason' => 'run_timed_out', 'next_task_id' => null, - 'run_id' => $run->id, 'run_status' => $run->status->value]; + $timeout = $this->workflowTaskDeadlineOutcome($run, $task); + if ($timeout !== null) { + return $timeout; } if ($this->workflowTaskResumesSignal($task)) { diff --git a/composer.json b/composer.json index 6c4a3033a..b0dd1921a 100644 --- a/composer.json +++ b/composer.json @@ -48,7 +48,7 @@ }, "extra": { "durable-workflow": { - "product-train": "2.11.8" + "product-train": "2.11.9" }, "laravel": { "dont-discover": [] diff --git a/composer.lock b/composer.lock index 4a98a06ab..1a3f02d52 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": "26f05963a35e77fc93c3cc53a277a070", + "content-hash": "7d77d542a7b11e3a04a3ce14f7c9654e", "packages": [ { "name": "apache/avro", diff --git a/docker-compose.dedicated-matching.yml b/docker-compose.dedicated-matching.yml index 363903611..127e9837d 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.11.8}} +x-server-image: &server-image ${DW_SERVER_IMAGE:-durableworkflow/server:${DW_SERVER_TAG:-2.11.9}} 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.11.8}} + APP_VERSION: ${APP_VERSION:-${DW_SERVER_TAG:-2.11.9}} 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 384849e3b..c0b6b31a4 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.11.8} + APP_VERSION: ${APP_VERSION:-2.11.9} successor: image: ${DW_MEMO_SUCCESSOR_IMAGE:-durable-workflow/server-memo-rolling:local} ports: !override [] environment: <<: *runtime-environment - APP_VERSION: ${APP_VERSION:-2.11.8} + APP_VERSION: ${APP_VERSION:-2.11.9} DW_SERVER_ID: memo-successor DW_SERVER_TOPOLOGY_SHAPE: standalone_server DW_SERVER_PROCESS_CLASS: server_http_node diff --git a/docker-compose.php.yml b/docker-compose.php.yml index 8a7601a1d..94b94ef53 100644 --- a/docker-compose.php.yml +++ b/docker-compose.php.yml @@ -7,13 +7,13 @@ name: durable-workflow-server # conformance runners that qualify PHP releases. # # 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.11.8}} +x-server-image: &server-image ${DW_SERVER_IMAGE:-durableworkflow/server:${DW_SERVER_TAG:-2.11.9}} 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.11.8}} + APP_VERSION: ${APP_VERSION:-${DW_SERVER_TAG:-2.11.9}} 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 a9538574c..3cce775fc 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.11.8} + APP_VERSION: ${APP_VERSION:-2.11.9} 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 d0cd359f6..18c122450 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.11.8}" + APP_VERSION: "${APP_VERSION:-2.11.9}" 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.11.8}" + APP_VERSION: "${APP_VERSION:-2.11.9}" 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.11.8}" + APP_VERSION: "${APP_VERSION:-2.11.9}" 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.11.8}" + APP_VERSION: "${APP_VERSION:-2.11.9}" DB_CONNECTION: mysql DB_HOST: mysql DB_PORT: 3306 diff --git a/docs/worker-command-contracts.md b/docs/worker-command-contracts.md index da45e39cc..0c14d768e 100644 --- a/docs/worker-command-contracts.md +++ b/docs/worker-command-contracts.md @@ -30,8 +30,8 @@ handlers deterministic and use durable workflow operations for external effects. A callback entry count alone does not prove duplicate admission or exactly-once external execution. -When a history-wait acknowledgment closes an expired run, Server returns the -normal timeout completion receipt: HTTP 409 `run_timed_out`, `outcome=completed` +When a failure or history-wait acknowledgment closes an expired run, Server +returns the normal timeout completion receipt: HTTP 409 `run_timed_out`, `outcome=completed` and `recorded=false`, with the original task, run and attempt identities and `run_status=failed`. Workers can recognize that matching terminal receipt and continue processing other workflows. diff --git a/k8s/README.md b/k8s/README.md index 04a9c0e98..9e2ea3c25 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.11.8 +durableworkflow/server:2.11.9 ``` ## Application logs @@ -42,15 +42,15 @@ digest you intend to run: ```bash kubectl set image -n durable-workflow deploy/durable-workflow-server \ - server=durableworkflow/server:2.11.8 + server=durableworkflow/server:2.11.9 kubectl set image -n durable-workflow deploy/durable-workflow-worker \ - worker=durableworkflow/server:2.11.8 + worker=durableworkflow/server:2.11.9 kubectl set image -n durable-workflow cronjob/durable-workflow-scheduler \ - scheduler=durableworkflow/server:2.11.8 + scheduler=durableworkflow/server:2.11.9 ``` GitHub Container Registry publishes the same release line at -`ghcr.io/durable-workflow/server:2.11.8`. Digest pinning is preferred for strict +`ghcr.io/durable-workflow/server:2.11.9`. 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 8dd7b3f4d..0caebd8b7 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/UPGRADING.md alongside the chart. -version: 0.3.8 +version: 0.3.9 # 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.11.8" +appVersion: "2.11.9" 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.11.8" + dev.durable-workflow.image-reference: "docker.io/durableworkflow/server:2.11.9" 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 d3c1a5c1e..4cdcfced2 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.11.8" + tag: "2.11.9" # 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 cbf158ce7..42ef6c91e 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.11.8" + tag: "2.11.9" 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 b69353d51..c9b58e850 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.11.8" + tag: "2.11.9" 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 8a09a9e07..4f128f947 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.11.8" + tag: "2.11.9" externalDatabase: connection: mysql diff --git a/k8s/helm/durable-workflow/templates/_helpers.tpl b/k8s/helm/durable-workflow/templates/_helpers.tpl index 337deef70..373e6fadb 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.11.8" -}} +{{- if eq $normalized "docker.io/durableworkflow/server:2.11.9" -}} 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 e0e0cad11..8f1840634 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.11.8" + tag: "2.11.9" # 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 5339b9c37..faf46788d 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.11.8" + tag: "2.11.9" externalDatabase: connection: mysql diff --git a/k8s/helm/examples/values-external-secrets-operator.yaml b/k8s/helm/examples/values-external-secrets-operator.yaml index 7b8314c7f..51146e8e9 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.11.8" + tag: "2.11.9" externalDatabase: connection: pgsql diff --git a/k8s/helm/examples/values-production-existing-secrets.yaml b/k8s/helm/examples/values-production-existing-secrets.yaml index 9507c65e5..4e6ba9564 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.11.8" + tag: "2.11.9" externalDatabase: connection: pgsql diff --git a/k8s/migration-job.yaml b/k8s/migration-job.yaml index dfadfd35c..ebc6f7459 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.11.8 + image: durableworkflow/server:2.11.9 command: ["server-entrypoint"] args: ["server-bootstrap"] envFrom: diff --git a/k8s/scheduler-cronjob.yaml b/k8s/scheduler-cronjob.yaml index 39b400495..f2db6d40f 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.11.8 + image: durableworkflow/server:2.11.9 command: ["server-entrypoint"] args: - sh diff --git a/k8s/secret.yaml b/k8s/secret.yaml index 7464692df..f7d0a0733 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.11.8" + APP_VERSION: "2.11.9" APP_ENV: production APP_DEBUG: "false" LOG_CHANNEL: "stderr" diff --git a/k8s/server-deployment.yaml b/k8s/server-deployment.yaml index 4fbe14f8b..19caaca25 100644 --- a/k8s/server-deployment.yaml +++ b/k8s/server-deployment.yaml @@ -23,7 +23,7 @@ spec: spec: containers: - name: server - image: durableworkflow/server:2.11.8 + image: durableworkflow/server:2.11.9 ports: - containerPort: 8080 name: http diff --git a/k8s/worker-deployment.yaml b/k8s/worker-deployment.yaml index 014f6abbc..ef38f23bd 100644 --- a/k8s/worker-deployment.yaml +++ b/k8s/worker-deployment.yaml @@ -19,7 +19,7 @@ spec: spec: containers: - name: worker - image: durableworkflow/server:2.11.8 + image: durableworkflow/server:2.11.9 command: ["server-entrypoint"] args: ["php", "artisan", "queue:work", "--sleep=1", "--tries=3", "--max-time=3600"] envFrom: diff --git a/resources/release/source-release.json b/resources/release/source-release.json index 301b079e3..67fa8c662 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.11.8" + "version": "2.11.9" }, "helm_chart": { - "version": "0.3.8" + "version": "0.3.9" } } diff --git a/scripts/k8s-kind-smoke.sh b/scripts/k8s-kind-smoke.sh index f9f1064bd..340049c65 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.11.8" +manifest_image="durableworkflow/server:2.11.9" 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 efec1097b..aea81bd0f 100644 --- a/tests/Feature/WorkflowWorkerProtocolTest.php +++ b/tests/Feature/WorkflowWorkerProtocolTest.php @@ -3689,8 +3689,8 @@ public function test_waiting_for_scheduled_history_workflow_task_failure_acknowl ); } - #[DataProvider('waitingHistoryDeadlineCases')] - public function test_waiting_for_history_ack_enforces_the_original_deadline(string $kind, bool $expired): void + #[DataProvider('workflowFailureDeadlineCases')] + public function test_workflow_failure_ack_enforces_the_original_deadline(string $kind, bool $expired, bool $waiting): void { Queue::fake(); $this->configureWorkflowTypes(); @@ -3715,7 +3715,8 @@ public function test_waiting_for_history_ack_enforces_the_original_deadline(stri Carbon::setTestNow($clock->copy()->addSeconds($expired ? 3 : 1)); $body = [ 'lease_owner' => $task['lease_owner'], 'workflow_task_attempt' => $task['workflow_task_attempt'], - 'failure' => ['type' => 'WorkflowTaskWaitingForHistory', 'message' => 'history is not ready yet'], + 'failure' => ['type' => $waiting ? 'WorkflowTaskWaitingForHistory' : 'FixtureFailure', + 'message' => $waiting ? 'history is not ready yet' : 'fixture callback failed'], ]; $path = '/api/worker/workflow-tasks/'.$task['task_id'].'/fail'; $runBefore = WorkflowRun::query()->findOrFail($runId)->getRawOriginal(); @@ -3731,9 +3732,14 @@ public function test_waiting_for_history_ack_enforces_the_original_deadline(stri $closed = WorkflowHistoryEvent::query()->where('workflow_run_id', $runId) ->where('event_type', HistoryEventType::WorkflowTimedOut->value)->count(); if (! $expired) { - $response->assertOk()->assertJsonPath('recorded', true)->assertJsonPath('outcome', 'waiting_for_history') + $response->assertOk()->assertJsonPath('recorded', true)->assertJsonPath('outcome', $waiting ? 'waiting_for_history' : 'failed') ->assertJsonMissingPath('run_id')->assertJsonMissingPath('run_status'); - $this->assertSame('waiting', $run->status->value); + $this->assertFalse($run->status->isTerminal()); + if ($waiting) { + $this->assertSame('waiting', $run->status->value); + } else { + $this->assertIsString($response->json('next_task_id')); + } $this->assertSame(0, $closed); return; @@ -3758,12 +3764,14 @@ public function test_waiting_for_history_ack_enforces_the_original_deadline(stri } } - public static function waitingHistoryDeadlineCases(): iterable + public static function workflowFailureDeadlineCases(): iterable { - yield 'elapsed run deadline' => ['run', true]; - yield 'elapsed execution deadline' => ['execution', true]; - yield 'unelapsed run deadline' => ['run', false]; - yield 'unelapsed execution deadline' => ['execution', false]; + foreach (['waiting' => true, 'ordinary failure' => false] as $surface => $waiting) { + yield $surface.' elapsed run deadline' => ['run', true, $waiting]; + yield $surface.' elapsed execution deadline' => ['execution', true, $waiting]; + yield $surface.' unelapsed run deadline' => ['run', false, $waiting]; + yield $surface.' unelapsed execution deadline' => ['execution', false, $waiting]; + } } public function test_waiting_for_history_failure_type_acknowledges_the_wait_without_message_phrase(): void