From f2504601bd1af281343f22337e200e4c624ff70b Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Wed, 21 Oct 2026 11:04:34 +0000 Subject: [PATCH 1/5] Reproduce select member cancellation lost at the HTTP boundary --- .../Feature/SelectionCancellationHttpTest.php | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 tests/Feature/SelectionCancellationHttpTest.php diff --git a/tests/Feature/SelectionCancellationHttpTest.php b/tests/Feature/SelectionCancellationHttpTest.php new file mode 100644 index 000000000..345123f51 --- /dev/null +++ b/tests/Feature/SelectionCancellationHttpTest.php @@ -0,0 +1,126 @@ +createNamespace('default'); + $this->createNamespace('other'); + $this->registerWorker('select-worker', 'select-http', supportedWorkflowTypes: ['select-http']); + $this->registerWorker('activity-worker', 'later', supportedActivityTypes: ['lose']); + } + + public function test_losing_queued_activity_is_cancelled_through_http_and_cannot_be_claimed(): void + { + [$runId, $task, $cancel] = $this->stageTimerWinner(); + $this->complete($task, [$cancel])->assertOk()->assertJsonPath('recorded', true); + + $this->assertSame('cancelled', ActivityExecution::query()->sole()->status->value); + $this->assertSame(1, WorkflowHistoryEvent::query()->where('workflow_run_id', $runId) + ->where('event_type', HistoryEventType::SelectionOperationCancelled->value)->count()); + $this->postJson('/api/worker/activity-tasks/poll', [ + 'worker_id' => 'activity-worker', 'task_queue' => 'later', + ], $this->workerHeaders())->assertOk()->assertJsonPath('task', null); + + $resume = $this->pollWorkflow(); + $this->complete($resume, [[ + 'type' => 'complete_workflow', 'result' => Serializer::serialize(['timer-won', 9007199254740993, null, 'λ']), + ]])->assertOk()->assertJsonPath('recorded', true); + $this->assertSame(RunStatus::Completed, WorkflowRun::query()->findOrFail($runId)->status); + $this->assertSame(0, WorkflowTask::query()->where('workflow_run_id', $runId) + ->where('task_type', TaskType::Activity->value)->where('status', TaskStatus::Leased->value)->count()); + } + + #[DataProvider('invalidMembers')] + public function test_invalid_or_foreign_member_cannot_cancel_the_original_activity(array $changes, string $namespace, int $status): void + { + [$runId, $task, $cancel] = $this->stageTimerWinner(); + $before = WorkflowHistoryEvent::query()->where('workflow_run_id', $runId)->count(); + $this->complete($task, [array_replace($cancel, $changes)], $namespace)->assertStatus($status); + $this->assertSame('scheduled', ActivityExecution::query()->sole()->status->value); + $this->assertSame($before, WorkflowHistoryEvent::query()->where('workflow_run_id', $runId)->count()); + $this->assertSame(TaskStatus::Leased, WorkflowTask::query()->findOrFail($task['task_id'])->status); + } + + public static function invalidMembers(): array + { + return [ + 'wrong operation' => [['operation_identity' => 'foreign-activity'], 'default', 409], + 'wrong member' => [['member_key' => 'deadline'], 'default', 409], + 'outside group' => [['member_base_sequence' => 3], 'default', 422], + 'wrong namespace' => [[], 'other', 404], + ]; + } + + private function stageTimerWinner(): array + { + $start = $this->postJson('/api/workflows', [ + 'workflow_id' => 'select-http', 'workflow_type' => 'select-http', 'task_queue' => 'select-http', + ], $this->apiHeaders())->assertCreated(); + $task = $this->pollWorkflow(); + $commands = []; + foreach ([['schedule_activity', 'act', 'activity'], ['start_timer', 'deadline', 'timer']] as $index => [$type, $key, $kind]) { + $entry = [ + 'parallel_group_id' => 'select-calls:1:2', 'parallel_group_kind' => 'mixed', + 'parallel_group_mode' => 'select', 'parallel_group_base_sequence' => 1, + 'parallel_group_size' => 2, 'parallel_group_index' => $index, + 'selection_member_key' => $key, 'selection_member_index' => $index, + 'selection_member_base_sequence' => 1 + $index, 'selection_member_size' => 1, + 'selection_member_kind' => $kind, + ]; + $commands[] = array_merge([ + 'type' => $type, ...$entry, 'parallel_group_path' => [$entry], + ], $index === 0 ? ['activity_type' => 'lose', 'queue' => 'later'] : ['delay_seconds' => 0]); + } + $this->complete($task, $commands)->assertOk()->assertJsonPath('recorded', true); + $timerTask = WorkflowTask::query()->where('task_type', TaskType::Timer->value)->sole(); + $this->app->call([new RunTimerTask($timerTask->id), 'handle']); + $cancel = [ + 'type' => 'cancel_selection_operation', 'selection_group_id' => 'select-calls:1:2', + 'member_key' => 'act', 'member_index' => 0, 'member_base_sequence' => 1, 'member_size' => 1, + 'operation_kind' => 'activity', 'operation_identity' => ActivityExecution::query()->sole()->id, + ]; + + return [$start->json('run_id'), $this->pollWorkflow(), $cancel]; + } + + private function pollWorkflow(): array + { + return $this->postJson('/api/worker/workflow-tasks/poll', [ + 'worker_id' => 'select-worker', 'task_queue' => 'select-http', + ], $this->workerHeaders())->assertOk()->json('task'); + } + + private function complete(array $task, array $commands, string $namespace = 'default'): \Illuminate\Testing\TestResponse + { + return $this->postJson('/api/worker/workflow-tasks/'.$task['task_id'].'/complete', [ + 'lease_owner' => 'select-worker', 'workflow_task_attempt' => $task['workflow_task_attempt'], + 'commands' => $commands, + ], $this->workerHeaders($namespace)); + } +} From 885114bcc04c8cd6dd463ad5cde61a8efc5d4aa3 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Wed, 21 Oct 2026 11:08:15 +0000 Subject: [PATCH 2/5] Retain select cancellation identity through worker HTTP validation --- app/Http/Controllers/Api/WorkerController.php | 141 +-------------- .../WorkflowTaskCompletionValidation.php | 161 ++++++++++++++++++ docs/select-member-cancellation-recovery.md | 37 ++++ .../Feature/SelectionCancellationHttpTest.php | 62 ++++++- .../WorkflowTaskCommandFieldRetentionTest.php | 100 +++++++++++ 5 files changed, 356 insertions(+), 145 deletions(-) create mode 100644 app/Support/WorkflowTaskCompletionValidation.php create mode 100644 docs/select-member-cancellation-recovery.md create mode 100644 tests/Feature/WorkflowTaskCommandFieldRetentionTest.php diff --git a/app/Http/Controllers/Api/WorkerController.php b/app/Http/Controllers/Api/WorkerController.php index 9f63b4ac5..6c230b67a 100644 --- a/app/Http/Controllers/Api/WorkerController.php +++ b/app/Http/Controllers/Api/WorkerController.php @@ -51,7 +51,7 @@ use App\Support\WorkflowQueryTaskBroker; use App\Support\WorkflowReplayEligibility; use App\Support\WorkflowStreamCommandProcessor; -use App\Support\WorkflowStreamService; +use App\Support\WorkflowTaskCompletionValidation; use App\Support\WorkflowTaskLeaseRecovery; use App\Support\WorkflowTaskPoller; use App\Support\WorkflowUpdateValidationTaskBroker; @@ -1659,144 +1659,7 @@ private function mutateWorkflowTaskCommands(Request $request, string $taskId, bo 'message_stream_waits.*.after_position' => ['required', 'integer', 'min:0'], ]); - $completionRules = [ - 'lease_owner' => ['required', 'string'], - 'workflow_task_attempt' => ['required', 'integer', 'min:1'], - 'commands' => ['required', 'array', 'min:1'], - 'commands.*.type' => ['required', 'string'], - 'commands.*.cancellation_scope_id' => ['sometimes', 'string', 'min:1', 'max:255'], - 'commands.*.cancellation_cleanup' => ['sometimes', 'required', 'array:scope_id,request_id,delivery_history_event_id'], - 'commands.*.cancellation_cleanup.scope_id' => ['sometimes', 'required', 'string', 'max:255'], - 'commands.*.cancellation_cleanup.request_id' => ['required_with:commands.*.cancellation_cleanup', 'string', 'max:255'], - 'commands.*.cancellation_cleanup.delivery_history_event_id' => ['required_with:commands.*.cancellation_cleanup', 'string', 'max:255'], - 'commands.*.result' => ['nullable'], - 'commands.*.activity_type' => ['nullable', 'string'], - 'commands.*.arguments' => ['nullable'], - 'commands.*.connection' => ['nullable', 'string'], - 'commands.*.queue' => ['nullable', 'string'], - 'commands.*.retry_policy' => ['nullable', 'array'], - 'commands.*.retry_policy.max_attempts' => ['nullable', 'integer', 'min:1'], - 'commands.*.retry_policy.backoff_seconds' => ['nullable', 'array'], - 'commands.*.retry_policy.backoff_seconds.*' => ['integer', 'min:0'], - 'commands.*.retry_policy.non_retryable_error_types' => ['nullable', 'array'], - 'commands.*.retry_policy.non_retryable_error_types.*' => ['string'], - 'commands.*.retry_policy.*' => ['nullable'], - 'commands.*.start_to_close_timeout' => ['nullable', 'integer', 'min:1'], - 'commands.*.schedule_to_start_timeout' => ['nullable', 'integer', 'min:1'], - 'commands.*.schedule_to_close_timeout' => ['nullable', 'integer', 'min:1'], - 'commands.*.heartbeat_timeout' => ['nullable', 'integer', 'min:1'], - 'commands.*.execution_mode' => ['nullable', 'string', 'in:local'], - 'commands.*.outcome' => ['nullable', 'string', 'in:completed,failed,timed_out,cancelled'], - 'commands.*.attempts' => ['nullable', 'array', 'max:100'], - 'commands.*.attempts.*.attempt_id' => ['nullable', 'string', 'max:255'], - 'commands.*.attempts.*.attempt_number' => ['required', 'integer', 'min:1'], - 'commands.*.attempts.*.outcome' => ['required', 'string', 'in:completed,failed,timed_out,cancelled'], - 'commands.*.attempts.*.duration_ms' => ['nullable', 'integer', 'min:0'], - 'commands.*.attempts.*.message' => ['nullable', 'string'], - 'commands.*.attempts.*.exception_type' => ['nullable', 'string', 'max:255'], - 'commands.*.attempts.*.non_retryable' => ['nullable', 'boolean'], - 'commands.*.attempts.*.timeout_kind' => ['nullable', 'string', 'in:start_to_close,schedule_to_close,heartbeat'], - 'commands.*.attempts.*.retry_reason' => ['nullable', 'string', 'in:failure,timeout,cold_replay'], - 'commands.*.attempts.*.backoff_seconds' => ['nullable', 'integer', 'min:0'], - 'commands.*.attempts.*.heartbeats' => ['nullable', 'array', 'max:1000'], - 'commands.*.attempts.*.heartbeats.*.details' => ['nullable', 'array'], - 'commands.*.attempts.*.heartbeats.*.elapsed_ms' => ['nullable', 'integer', 'min:0'], - 'commands.*.attempts.*.heartbeats.*.*' => ['nullable'], - 'commands.*.attempts.*.*' => ['nullable'], - 'commands.*.worker_session' => ['nullable', 'array'], - 'commands.*.worker_session.session_id' => ['nullable', 'string', 'max:255'], - 'commands.*.worker_session.connection' => ['nullable', 'string', 'max:255'], - 'commands.*.worker_session.queue' => ['nullable', 'string', 'max:255'], - 'commands.*.worker_session.requirements' => ['nullable', 'array'], - 'commands.*.worker_session.requirements.*' => ['string', 'max:255'], - 'commands.*.worker_session.lease_seconds' => ['nullable', 'integer', 'min:1'], - 'commands.*.worker_session.ttl_seconds' => ['nullable', 'integer', 'min:1'], - 'commands.*.worker_session.max_concurrent_activities' => ['nullable', 'integer', 'min:1'], - 'commands.*.worker_session.create_if_missing' => ['nullable', 'boolean'], - 'commands.*.worker_session.allow_reacquire_after_failure' => ['nullable', 'boolean'], - 'commands.*.execution_timeout_seconds' => ['nullable', 'integer', 'min:1'], - 'commands.*.run_timeout_seconds' => ['nullable', 'integer', 'min:1'], - 'commands.*.workflow_type' => ['nullable', 'string'], - 'commands.*.delay_seconds' => ['nullable', 'integer', 'min:0'], - 'commands.*.message' => ['nullable', 'string'], - 'commands.*.timeout_kind' => ['nullable', 'string', 'in:start_to_close,schedule_to_close,heartbeat'], - 'commands.*.payload_codec' => ['nullable', 'string'], - 'commands.*.update_id' => ['nullable', 'string'], - 'commands.*.exception_class' => ['nullable', 'string'], - 'commands.*.exception_type' => ['nullable', 'string'], - 'commands.*.exception' => ['nullable', 'array'], - 'commands.*.failed_step_sequence' => ['nullable', 'integer', 'min:1'], - 'commands.*.failed_activity_execution_id' => ['nullable', 'string', 'max:255'], - 'commands.*.change_id' => ['nullable', 'string'], - 'commands.*.version' => ['nullable', 'integer'], - 'commands.*.min_supported' => ['nullable', 'integer'], - 'commands.*.max_supported' => ['nullable', 'integer'], - 'commands.*.attributes' => ['nullable', 'array'], - 'commands.*.attribute_types' => ['nullable', 'array'], - 'commands.*.attribute_types.*' => ['string'], - 'commands.*.entries' => ['nullable', 'array'], - 'commands.*.non_retryable' => ['nullable', 'boolean'], - 'commands.*.parent_close_policy' => ['nullable', 'string'], - 'commands.*.cancellation_policy' => ['nullable', 'string', 'in:try_cancel,wait_cancellation_completed,abandon'], - 'commands.*.condition_key' => ['nullable', 'string'], - 'commands.*.condition_definition_fingerprint' => ['nullable', 'string'], - 'commands.*.condition_wait_occurrence_id' => ['nullable', 'string'], - 'commands.*.signal_name' => ['nullable', 'string'], - 'commands.*.timeout_seconds' => ['nullable', 'integer', 'min:0'], - 'commands.*.endpoint_name' => ['nullable', 'string', 'max:191'], - 'commands.*.service_name' => ['nullable', 'string', 'max:191'], - 'commands.*.operation_name' => ['nullable', 'string', 'max:191'], - 'commands.*.request_payload' => ['nullable'], - 'commands.*.namespace' => ['nullable', 'string', 'max:191'], - 'commands.*.caller_namespace' => ['nullable', 'string', 'max:191'], - 'commands.*.service_call_id' => ['nullable', 'string', 'max:191'], - 'commands.*.idempotency_key' => ['nullable', 'string', 'max:191'], - 'commands.*.mode_override' => ['nullable', 'string', 'in:sync,async'], - 'commands.*.wait_for' => ['nullable', 'string', 'in:accepted,completed'], - 'commands.*.wait_timeout_seconds' => ['nullable', 'integer', 'min:0'], - 'commands.*.target_workflow_instance_id' => ['nullable', 'string', 'max:191'], - 'commands.*.target_workflow_run_id' => ['nullable', 'string', 'max:191'], - 'commands.*.business_key' => ['nullable', 'string', 'max:191'], - 'commands.*.labels' => ['nullable', 'array'], - 'commands.*.memo' => ['nullable', 'array'], - 'commands.*.search_attributes' => ['nullable', 'array'], - 'commands.*.duplicate_start_policy' => ['nullable', 'string', 'in:reject_duplicate,return_existing_active'], - 'commands.*.metadata' => ['nullable', 'array'], - 'commands.*.request_payload_reference' => ['nullable', 'string', 'max:191'], - 'commands.*.principal_subject' => ['prohibited'], - 'commands.*.principal_method' => ['prohibited'], - 'commands.*.principal_roles' => ['prohibited'], - 'commands.*.principal_tenant' => ['prohibited'], - 'commands.*.principal_claims' => ['prohibited'], - ...WorkflowCommandNormalizer::parallelMetadataValidationRules(), - 'commands.*.workflow_stream' => ['nullable', 'array'], - 'commands.*.workflow_stream.operation' => ['required_with:commands.*.workflow_stream', 'string', 'in:append,close,error'], - 'commands.*.workflow_stream.stream_name' => ['required_with:commands.*.workflow_stream', 'string', 'max:191'], - 'commands.*.workflow_stream.command_identity' => ['required_with:commands.*.workflow_stream', 'string', 'max:191'], - 'commands.*.workflow_stream.command_ordinal' => ['required_with:commands.*.workflow_stream', 'integer', 'min:0'], - 'commands.*.workflow_stream.items' => ['nullable', 'array', 'max:'.WorkflowStreamService::DEFAULT_MAX_ITEMS_PER_APPEND], - 'commands.*.workflow_stream.items.*' => ['array'], - 'commands.*.workflow_stream.items.*.payload' => ['nullable'], - 'commands.*.workflow_stream.items.*.payload_reference' => ['nullable', 'string', 'max:191'], - 'commands.*.workflow_stream.items.*.payload_codec' => ['nullable', 'string', 'max:64'], - 'commands.*.workflow_stream.items.*.idempotency_key' => ['nullable', 'string', 'max:191'], - 'commands.*.workflow_stream.items.*.item_type' => ['nullable', 'string', 'max:64'], - 'commands.*.workflow_stream.items.*.content_type' => ['nullable', 'string', 'max:191'], - 'commands.*.workflow_stream.max_pending_items' => ['nullable', 'integer', 'min:1'], - 'commands.*.workflow_stream.error_reason' => ['nullable', 'string', 'max:191'], - 'commands.*.workflow_stream.retention_seconds' => ['nullable', 'integer', 'min:1'], - 'sticky_cache' => ['nullable', 'array'], - 'sticky_cache.worker_id' => ['required_with:sticky_cache', 'string', 'max:255'], - 'sticky_cache.workflow_id' => ['required_with:sticky_cache', 'string', 'max:255'], - 'sticky_cache.run_id' => ['required_with:sticky_cache', 'string', 'max:255'], - 'sticky_cache.build_id' => ['required_with:sticky_cache', 'string', 'max:255'], - 'sticky_cache.ttl_seconds' => ['required_with:sticky_cache', 'integer', 'min:1', 'max:3600'], - 'sticky_cache.metrics' => ['nullable', 'array'], - 'sticky_cache.metrics.hit' => ['nullable', 'integer', 'min:0'], - 'sticky_cache.metrics.miss' => ['nullable', 'integer', 'min:0'], - 'sticky_cache.metrics.eviction' => ['nullable', 'integer', 'min:0'], - 'sticky_cache.metrics.forced_cold_replay' => ['nullable', 'integer', 'min:0'], - ]; + $completionRules = WorkflowTaskCompletionValidation::rules(); $topLevelRules = []; if ($group) { diff --git a/app/Support/WorkflowTaskCompletionValidation.php b/app/Support/WorkflowTaskCompletionValidation.php new file mode 100644 index 000000000..2da7ae8f7 --- /dev/null +++ b/app/Support/WorkflowTaskCompletionValidation.php @@ -0,0 +1,161 @@ +> */ + public static function rules(): array + { + return [ + 'lease_owner' => ['required', 'string'], + 'workflow_task_attempt' => ['required', 'integer', 'min:1'], + 'commands' => ['required', 'array', 'min:1'], + 'commands.*.type' => ['required', 'string'], + 'commands.*.selection_group_id' => ['sometimes', 'string'], + 'commands.*.member_key' => ['sometimes', 'string'], + 'commands.*.member_index' => ['sometimes', 'integer', 'min:0'], + 'commands.*.member_base_sequence' => ['sometimes', 'integer', 'min:1'], + 'commands.*.member_size' => ['sometimes', 'integer', 'min:1'], + 'commands.*.operation_kind' => ['sometimes', 'string'], + 'commands.*.operation_identity' => ['sometimes', 'string'], + 'commands.*.cancellation_scope_id' => ['sometimes', 'string', 'min:1', 'max:255'], + 'commands.*.cancellation_cleanup' => ['sometimes', 'required', 'array:scope_id,request_id,delivery_history_event_id'], + 'commands.*.cancellation_cleanup.scope_id' => ['sometimes', 'required', 'string', 'max:255'], + 'commands.*.cancellation_cleanup.request_id' => ['required_with:commands.*.cancellation_cleanup', 'string', 'max:255'], + 'commands.*.cancellation_cleanup.delivery_history_event_id' => ['required_with:commands.*.cancellation_cleanup', 'string', 'max:255'], + 'commands.*.result' => ['nullable'], + 'commands.*.activity_type' => ['nullable', 'string'], + 'commands.*.arguments' => ['nullable'], + 'commands.*.connection' => ['nullable', 'string'], + 'commands.*.queue' => ['nullable', 'string'], + 'commands.*.retry_policy' => ['nullable', 'array'], + 'commands.*.retry_policy.max_attempts' => ['nullable', 'integer', 'min:1'], + 'commands.*.retry_policy.backoff_seconds' => ['nullable', 'array'], + 'commands.*.retry_policy.backoff_seconds.*' => ['integer', 'min:0'], + 'commands.*.retry_policy.non_retryable_error_types' => ['nullable', 'array'], + 'commands.*.retry_policy.non_retryable_error_types.*' => ['string'], + 'commands.*.retry_policy.*' => ['nullable'], + 'commands.*.start_to_close_timeout' => ['nullable', 'integer', 'min:1'], + 'commands.*.schedule_to_start_timeout' => ['nullable', 'integer', 'min:1'], + 'commands.*.schedule_to_close_timeout' => ['nullable', 'integer', 'min:1'], + 'commands.*.heartbeat_timeout' => ['nullable', 'integer', 'min:1'], + 'commands.*.execution_mode' => ['nullable', 'string', 'in:local'], + 'commands.*.outcome' => ['nullable', 'string', 'in:completed,failed,timed_out,cancelled'], + 'commands.*.attempts' => ['nullable', 'array', 'max:100'], + 'commands.*.attempts.*.attempt_id' => ['nullable', 'string', 'max:255'], + 'commands.*.attempts.*.attempt_number' => ['required', 'integer', 'min:1'], + 'commands.*.attempts.*.outcome' => ['required', 'string', 'in:completed,failed,timed_out,cancelled'], + 'commands.*.attempts.*.duration_ms' => ['nullable', 'integer', 'min:0'], + 'commands.*.attempts.*.message' => ['nullable', 'string'], + 'commands.*.attempts.*.exception_type' => ['nullable', 'string', 'max:255'], + 'commands.*.attempts.*.non_retryable' => ['nullable', 'boolean'], + 'commands.*.attempts.*.timeout_kind' => ['nullable', 'string', 'in:start_to_close,schedule_to_close,heartbeat'], + 'commands.*.attempts.*.retry_reason' => ['nullable', 'string', 'in:failure,timeout,cold_replay'], + 'commands.*.attempts.*.backoff_seconds' => ['nullable', 'integer', 'min:0'], + 'commands.*.attempts.*.heartbeats' => ['nullable', 'array', 'max:1000'], + 'commands.*.attempts.*.heartbeats.*.details' => ['nullable', 'array'], + 'commands.*.attempts.*.heartbeats.*.elapsed_ms' => ['nullable', 'integer', 'min:0'], + 'commands.*.attempts.*.heartbeats.*.*' => ['nullable'], + 'commands.*.attempts.*.*' => ['nullable'], + 'commands.*.worker_session' => ['nullable', 'array'], + 'commands.*.worker_session.session_id' => ['nullable', 'string', 'max:255'], + 'commands.*.worker_session.connection' => ['nullable', 'string', 'max:255'], + 'commands.*.worker_session.queue' => ['nullable', 'string', 'max:255'], + 'commands.*.worker_session.requirements' => ['nullable', 'array'], + 'commands.*.worker_session.requirements.*' => ['string', 'max:255'], + 'commands.*.worker_session.lease_seconds' => ['nullable', 'integer', 'min:1'], + 'commands.*.worker_session.ttl_seconds' => ['nullable', 'integer', 'min:1'], + 'commands.*.worker_session.max_concurrent_activities' => ['nullable', 'integer', 'min:1'], + 'commands.*.worker_session.create_if_missing' => ['nullable', 'boolean'], + 'commands.*.worker_session.allow_reacquire_after_failure' => ['nullable', 'boolean'], + 'commands.*.execution_timeout_seconds' => ['nullable', 'integer', 'min:1'], + 'commands.*.run_timeout_seconds' => ['nullable', 'integer', 'min:1'], + 'commands.*.workflow_type' => ['nullable', 'string'], + 'commands.*.delay_seconds' => ['nullable', 'integer', 'min:0'], + 'commands.*.message' => ['nullable', 'string'], + 'commands.*.timeout_kind' => ['nullable', 'string', 'in:start_to_close,schedule_to_close,heartbeat'], + 'commands.*.payload_codec' => ['nullable', 'string'], + 'commands.*.update_id' => ['nullable', 'string'], + 'commands.*.exception_class' => ['nullable', 'string'], + 'commands.*.exception_type' => ['nullable', 'string'], + 'commands.*.exception' => ['nullable', 'array'], + 'commands.*.failed_step_sequence' => ['nullable', 'integer', 'min:1'], + 'commands.*.failed_activity_execution_id' => ['nullable', 'string', 'max:255'], + 'commands.*.change_id' => ['nullable', 'string'], + 'commands.*.version' => ['nullable', 'integer'], + 'commands.*.min_supported' => ['nullable', 'integer'], + 'commands.*.max_supported' => ['nullable', 'integer'], + 'commands.*.attributes' => ['nullable', 'array'], + 'commands.*.attribute_types' => ['nullable', 'array'], + 'commands.*.attribute_types.*' => ['string'], + 'commands.*.entries' => ['nullable', 'array'], + 'commands.*.non_retryable' => ['nullable', 'boolean'], + 'commands.*.parent_close_policy' => ['nullable', 'string'], + 'commands.*.cancellation_policy' => ['nullable', 'string', 'in:try_cancel,wait_cancellation_completed,abandon'], + 'commands.*.condition_key' => ['nullable', 'string'], + 'commands.*.condition_definition_fingerprint' => ['nullable', 'string'], + 'commands.*.condition_wait_occurrence_id' => ['nullable', 'string'], + 'commands.*.signal_name' => ['nullable', 'string'], + 'commands.*.timeout_seconds' => ['nullable', 'integer', 'min:0'], + 'commands.*.endpoint_name' => ['nullable', 'string', 'max:191'], + 'commands.*.service_name' => ['nullable', 'string', 'max:191'], + 'commands.*.operation_name' => ['nullable', 'string', 'max:191'], + 'commands.*.request_payload' => ['nullable'], + 'commands.*.namespace' => ['nullable', 'string', 'max:191'], + 'commands.*.caller_namespace' => ['nullable', 'string', 'max:191'], + 'commands.*.service_call_id' => ['nullable', 'string', 'max:191'], + 'commands.*.idempotency_key' => ['nullable', 'string', 'max:191'], + 'commands.*.mode_override' => ['nullable', 'string', 'in:sync,async'], + 'commands.*.wait_for' => ['nullable', 'string', 'in:accepted,completed'], + 'commands.*.wait_timeout_seconds' => ['nullable', 'integer', 'min:0'], + 'commands.*.target_workflow_instance_id' => ['nullable', 'string', 'max:191'], + 'commands.*.target_workflow_run_id' => ['nullable', 'string', 'max:191'], + 'commands.*.business_key' => ['nullable', 'string', 'max:191'], + 'commands.*.labels' => ['nullable', 'array'], + 'commands.*.memo' => ['nullable', 'array'], + 'commands.*.search_attributes' => ['nullable', 'array'], + 'commands.*.duplicate_start_policy' => ['nullable', 'string', 'in:reject_duplicate,return_existing_active'], + 'commands.*.metadata' => ['nullable', 'array'], + 'commands.*.request_payload_reference' => ['nullable', 'string', 'max:191'], + 'commands.*.principal_subject' => ['prohibited'], + 'commands.*.principal_method' => ['prohibited'], + 'commands.*.principal_roles' => ['prohibited'], + 'commands.*.principal_tenant' => ['prohibited'], + 'commands.*.principal_claims' => ['prohibited'], + ...WorkflowCommandNormalizer::parallelMetadataValidationRules(), + 'commands.*.workflow_stream' => ['nullable', 'array'], + 'commands.*.workflow_stream.operation' => ['required_with:commands.*.workflow_stream', 'string', 'in:append,close,error'], + 'commands.*.workflow_stream.stream_name' => ['required_with:commands.*.workflow_stream', 'string', 'max:191'], + 'commands.*.workflow_stream.command_identity' => ['required_with:commands.*.workflow_stream', 'string', 'max:191'], + 'commands.*.workflow_stream.command_ordinal' => ['required_with:commands.*.workflow_stream', 'integer', 'min:0'], + 'commands.*.workflow_stream.items' => ['nullable', 'array', 'max:'.WorkflowStreamService::DEFAULT_MAX_ITEMS_PER_APPEND], + 'commands.*.workflow_stream.items.*' => ['array'], + 'commands.*.workflow_stream.items.*.payload' => ['nullable'], + 'commands.*.workflow_stream.items.*.payload_reference' => ['nullable', 'string', 'max:191'], + 'commands.*.workflow_stream.items.*.payload_codec' => ['nullable', 'string', 'max:64'], + 'commands.*.workflow_stream.items.*.idempotency_key' => ['nullable', 'string', 'max:191'], + 'commands.*.workflow_stream.items.*.item_type' => ['nullable', 'string', 'max:64'], + 'commands.*.workflow_stream.items.*.content_type' => ['nullable', 'string', 'max:191'], + 'commands.*.workflow_stream.max_pending_items' => ['nullable', 'integer', 'min:1'], + 'commands.*.workflow_stream.error_reason' => ['nullable', 'string', 'max:191'], + 'commands.*.workflow_stream.retention_seconds' => ['nullable', 'integer', 'min:1'], + 'sticky_cache' => ['nullable', 'array'], + 'sticky_cache.worker_id' => ['required_with:sticky_cache', 'string', 'max:255'], + 'sticky_cache.workflow_id' => ['required_with:sticky_cache', 'string', 'max:255'], + 'sticky_cache.run_id' => ['required_with:sticky_cache', 'string', 'max:255'], + 'sticky_cache.build_id' => ['required_with:sticky_cache', 'string', 'max:255'], + 'sticky_cache.ttl_seconds' => ['required_with:sticky_cache', 'integer', 'min:1', 'max:3600'], + 'sticky_cache.metrics' => ['nullable', 'array'], + 'sticky_cache.metrics.hit' => ['nullable', 'integer', 'min:0'], + 'sticky_cache.metrics.miss' => ['nullable', 'integer', 'min:0'], + 'sticky_cache.metrics.eviction' => ['nullable', 'integer', 'min:0'], + 'sticky_cache.metrics.forced_cold_replay' => ['nullable', 'integer', 'min:0'], + ]; + } +} diff --git a/docs/select-member-cancellation-recovery.md b/docs/select-member-cancellation-recovery.md new file mode 100644 index 000000000..5b9db73cb --- /dev/null +++ b/docs/select-member-cancellation-recovery.md @@ -0,0 +1,37 @@ +# Recover a workflow blocked while cancelling a select member + +PHP Server 2.10.0 discards select-member cancellation identity fields +when validating a workflow-task completion. The runtime then refuses the +`cancel_selection_operation` command. A worker can report that refusal as a +workflow replay error, leaving the run waiting with `workflow_replay_blocked`. +The activity the workflow tried to cancel can still be queued or running. + +Upgrade to the corrected PHP Server release before repairing affected runs. +The correction preserves the existing command format and does not require an +SDK or Workflow dependency upgrade. It does not automatically retry blocked +runs or undo an activity that has already executed. + +## Inspect and repair the original run + +Check the selected run's history and activity state. Confirm that the failure +names selection cancellation validation rather than another replay error. +Keep the original workflow and run IDs. Use the existing selected-run repair +endpoint with an operator-authorized token and the original namespace: + +```sh +curl --fail-with-body --request POST \ + "$DW_SERVER_URL/api/workflows/$WORKFLOW_ID/runs/$RUN_ID/repair" \ + --header "Authorization: Bearer $DW_API_TOKEN" \ + --header "X-Namespace: $DW_NAMESPACE" \ + --header 'X-Durable-Workflow-Control-Plane-Version: 2' \ + --header 'Content-Type: application/json' \ + --data '{"request_id":"repair-select-member-cancellation"}' +``` + +Use URL-encoded identifiers in the path. Keep a compatible workflow worker +running on the original queue. Repair makes the original run available for +replay; the workflow can then submit its original cancellation and complete. +Verify the new workflow-task outcome and the member's recorded outcome in +history. A still-queued losing activity records `SelectionOperationCancelled` +and cannot be claimed after cancellation. Already completed operations retain +their recorded results. diff --git a/tests/Feature/SelectionCancellationHttpTest.php b/tests/Feature/SelectionCancellationHttpTest.php index 345123f51..6576c9067 100644 --- a/tests/Feature/SelectionCancellationHttpTest.php +++ b/tests/Feature/SelectionCancellationHttpTest.php @@ -38,7 +38,9 @@ protected function setUp(): void public function test_losing_queued_activity_is_cancelled_through_http_and_cannot_be_claimed(): void { [$runId, $task, $cancel] = $this->stageTimerWinner(); - $this->complete($task, [$cancel])->assertOk()->assertJsonPath('recorded', true); + $this->complete($task, [$cancel, [ + 'type' => 'complete_workflow', 'result' => Serializer::serialize(['timer-won', 9007199254740993, null, 'λ']), + ]])->assertOk()->assertJsonPath('recorded', true); $this->assertSame('cancelled', ActivityExecution::query()->sole()->status->value); $this->assertSame(1, WorkflowHistoryEvent::query()->where('workflow_run_id', $runId) @@ -47,10 +49,6 @@ public function test_losing_queued_activity_is_cancelled_through_http_and_cannot 'worker_id' => 'activity-worker', 'task_queue' => 'later', ], $this->workerHeaders())->assertOk()->assertJsonPath('task', null); - $resume = $this->pollWorkflow(); - $this->complete($resume, [[ - 'type' => 'complete_workflow', 'result' => Serializer::serialize(['timer-won', 9007199254740993, null, 'λ']), - ]])->assertOk()->assertJsonPath('recorded', true); $this->assertSame(RunStatus::Completed, WorkflowRun::query()->findOrFail($runId)->status); $this->assertSame(0, WorkflowTask::query()->where('workflow_run_id', $runId) ->where('task_type', TaskType::Activity->value)->where('status', TaskStatus::Leased->value)->count()); @@ -62,7 +60,7 @@ public function test_invalid_or_foreign_member_cannot_cancel_the_original_activi [$runId, $task, $cancel] = $this->stageTimerWinner(); $before = WorkflowHistoryEvent::query()->where('workflow_run_id', $runId)->count(); $this->complete($task, [array_replace($cancel, $changes)], $namespace)->assertStatus($status); - $this->assertSame('scheduled', ActivityExecution::query()->sole()->status->value); + $this->assertSame('pending', ActivityExecution::query()->sole()->status->value); $this->assertSame($before, WorkflowHistoryEvent::query()->where('workflow_run_id', $runId)->count()); $this->assertSame(TaskStatus::Leased, WorkflowTask::query()->findOrFail($task['task_id'])->status); } @@ -77,6 +75,58 @@ public static function invalidMembers(): array ]; } + public function test_previously_blocked_run_can_be_repaired_and_cancel_its_original_member(): void + { + [$runId, $task, $cancel] = $this->stageTimerWinner(); + $this->postJson('/api/worker/workflow-tasks/'.$task['task_id'].'/fail', [ + 'lease_owner' => 'select-worker', 'workflow_task_attempt' => $task['workflow_task_attempt'], + 'failure' => [ + 'type' => 'ServerError', + 'message' => 'Workflow task completion failed after commands were produced: Selection cancellation must identify one authored member within a durable selection group.', + ], + ], $this->workerHeaders())->assertOk()->assertJsonPath('recorded', true); + $this->assertSame(0, WorkflowTask::query()->where('workflow_run_id', $runId) + ->where('task_type', TaskType::Workflow->value)->where('status', TaskStatus::Ready->value)->count()); + + $this->postJson('/api/workflows/select-http/runs/'.$runId.'/repair', [ + 'request_id' => 'repair-select-validation', + ], $this->apiHeaders())->assertOk(); + $this->complete($this->pollWorkflow(), [$cancel, [ + 'type' => 'complete_workflow', 'result' => Serializer::serialize('recovered'), + ]])->assertOk()->assertJsonPath('recorded', true); + $this->assertSame(RunStatus::Completed, WorkflowRun::query()->findOrFail($runId)->status); + $this->assertSame('cancelled', ActivityExecution::query()->sole()->status->value); + } + + public function test_expired_original_claim_cannot_cancel_a_member(): void + { + [$runId, $task, $cancel] = $this->stageTimerWinner(); + $before = WorkflowHistoryEvent::query()->where('workflow_run_id', $runId)->count(); + WorkflowTask::query()->findOrFail($task['task_id'])->forceFill(['lease_expires_at' => now()->subSecond()])->save(); + $this->complete($task, [$cancel])->assertStatus(409)->assertJsonPath('recorded', false); + $this->assertSame('pending', ActivityExecution::query()->sole()->status->value); + $this->assertSame($before, WorkflowHistoryEvent::query()->where('workflow_run_id', $runId)->count()); + } + + #[DataProvider('requiredMemberFields')] + public function test_missing_identity_field_is_refused_without_mutation(string $field): void + { + [$runId, $task, $cancel] = $this->stageTimerWinner(); + $before = WorkflowHistoryEvent::query()->where('workflow_run_id', $runId)->count(); + unset($cancel[$field]); + $this->complete($task, [$cancel])->assertUnprocessable(); + $this->assertSame('pending', ActivityExecution::query()->sole()->status->value); + $this->assertSame($before, WorkflowHistoryEvent::query()->where('workflow_run_id', $runId)->count()); + } + + public static function requiredMemberFields(): array + { + return array_map(static fn (string $field): array => [$field], [ + 'selection_group_id', 'member_key', 'member_index', 'member_base_sequence', + 'member_size', 'operation_kind', 'operation_identity', + ]); + } + private function stageTimerWinner(): array { $start = $this->postJson('/api/workflows', [ diff --git a/tests/Feature/WorkflowTaskCommandFieldRetentionTest.php b/tests/Feature/WorkflowTaskCommandFieldRetentionTest.php new file mode 100644 index 000000000..fd683ff28 --- /dev/null +++ b/tests/Feature/WorkflowTaskCommandFieldRetentionTest.php @@ -0,0 +1,100 @@ +commands(); + $types = array_merge(WorkerProtocolVersion::terminalCommandTypes(), WorkerProtocolVersion::nonTerminalCommandTypes()); + $covered = array_column($commands, 'type'); + sort($types); + sort($covered); + $this->assertSame($types, $covered, 'Every package command needs a field-retention example.'); + + foreach ($commands as $command) { + $normalized = WorkflowCommandNormalizer::normalize([$command]); + $validated = Validator::make([ + 'lease_owner' => 'original-worker', 'workflow_task_attempt' => 1, + 'commands' => [$command], + ], WorkflowTaskCompletionValidation::rules())->validate(); + $this->assertSame($normalized, WorkflowCommandNormalizer::normalize($validated['commands']), $command['type']); + } + } + + public function test_unknown_fields_stay_out_and_server_principal_fields_are_prohibited(): void + { + $input = [ + 'lease_owner' => 'original-worker', 'workflow_task_attempt' => 1, + 'commands' => [['type' => 'complete_workflow', 'result' => null, 'unknown' => 'discard']], + ]; + $validated = Validator::make($input, WorkflowTaskCompletionValidation::rules())->validate(); + $this->assertArrayNotHasKey('unknown', $validated['commands'][0]); + foreach (['principal_subject', 'principal_method', 'principal_roles', 'principal_tenant', 'principal_claims'] as $field) { + $forged = $input; + $forged['commands'][0][$field] = 'forged'; + $this->assertTrue(Validator::make($forged, WorkflowTaskCompletionValidation::rules())->fails(), $field); + } + } + + private function commands(): array + { + $payload = Serializer::serialize([9007199254740993, null, 'λ']); + $retry = ['max_attempts' => 2, 'backoff_seconds' => [1], 'non_retryable_error_types' => ['InvalidInput']]; + + return [ + ['type' => 'complete_workflow', 'result' => $payload], + ['type' => 'fail_workflow', 'message' => 'original failure', 'exception_class' => 'OriginalFailure', + 'non_retryable' => true, 'failed_step_sequence' => 1, 'failed_activity_execution_id' => 'original-activity', + 'exception' => ['type' => 'OriginalFailure', 'message' => 'original failure']], + ['type' => 'continue_as_new', 'workflow_type' => 'next-type', 'queue' => 'next-queue', 'arguments' => $payload], + ['type' => 'cancel_selection_operation', 'selection_group_id' => 'select-calls:1:2', + 'member_key' => '0', 'member_index' => 0, 'member_base_sequence' => 1, 'member_size' => 1, + 'operation_kind' => 'activity', 'operation_identity' => 'original-activity'], + ['type' => 'schedule_activity', 'activity_type' => 'activity', 'arguments' => $payload, + 'connection' => 'database', 'queue' => 'activities', 'retry_policy' => $retry, + 'start_to_close_timeout' => 10, 'schedule_to_start_timeout' => 5, + 'schedule_to_close_timeout' => 30, 'heartbeat_timeout' => 2], + ['type' => 'start_timer', 'delay_seconds' => 0], + ['type' => 'start_child_workflow', 'workflow_type' => 'child', 'arguments' => $payload, + 'connection' => 'database', 'queue' => 'children', 'parent_close_policy' => 'abandon', + 'cancellation_policy' => 'abandon', 'execution_timeout_seconds' => 30, + 'run_timeout_seconds' => 10, 'retry_policy' => $retry], + ['type' => 'start_service_operation', 'endpoint_name' => 'endpoint', 'service_name' => 'service', + 'operation_name' => 'operation', 'request_payload' => $payload, 'namespace' => 'target', + 'caller_namespace' => 'source', 'service_call_id' => 'call-id', 'idempotency_key' => 'key', + 'mode_override' => 'async', 'wait_for' => 'completed', 'wait_timeout_seconds' => 0, + 'target_workflow_instance_id' => 'target-workflow', 'target_workflow_run_id' => 'target-run', + 'connection' => 'database', 'queue' => 'target-queue', 'business_key' => 'business-key', + 'labels' => ['label'], 'memo' => ['key' => 'value'], 'search_attributes' => ['State' => 'active'], + 'duplicate_start_policy' => 'return_existing_active', 'metadata' => ['key' => 'value'], + 'request_payload_reference' => 'reference-id'], + ['type' => 'complete_update', 'update_id' => 'update-id', 'result' => $payload], + ['type' => 'fail_update', 'update_id' => 'update-id', 'message' => 'failure', + 'exception_type' => 'UpdateFailure', 'non_retryable' => true], + ['type' => 'record_side_effect', 'result' => $payload], + ['type' => 'record_local_activity', 'activity_type' => 'local', 'outcome' => 'completed', + 'arguments' => $payload, 'result' => $payload, 'retry_policy' => $retry, + 'start_to_close_timeout' => 10, 'schedule_to_close_timeout' => 30, 'heartbeat_timeout' => 2, + 'attempts' => [['attempt_id' => 'local-attempt', 'attempt_number' => 1, 'outcome' => 'completed', + 'duration_ms' => 3, 'heartbeats' => [['elapsed_ms' => 1, 'details' => ['step' => 'started']]]]]], + ['type' => 'record_version_marker', 'change_id' => 'change', 'version' => 0, 'min_supported' => -1, 'max_supported' => 1], + ['type' => 'upsert_memo', 'entries' => MemoPayload::mapEnvelope(['key' => 'λ', 'delete' => null])], + ['type' => 'upsert_search_attributes', 'attributes' => ['State' => 'active'], 'attribute_types' => ['State' => 'keyword']], + ['type' => 'open_condition_wait', 'condition_key' => 'condition', 'timeout_seconds' => 0, + 'condition_definition_fingerprint' => 'fingerprint', 'condition_wait_occurrence_id' => 'occurrence'], + ['type' => 'open_signal_wait', 'signal_name' => 'signal', 'timeout_seconds' => 0], + ]; + } +} From a19ff2ec59d091bfa34f47b3bb9158b8729c6201 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Wed, 21 Oct 2026 11:10:46 +0000 Subject: [PATCH 3/5] Use the actual expired-claim response contract in the HTTP regression --- tests/Feature/SelectionCancellationHttpTest.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/Feature/SelectionCancellationHttpTest.php b/tests/Feature/SelectionCancellationHttpTest.php index 6576c9067..4ac52cf07 100644 --- a/tests/Feature/SelectionCancellationHttpTest.php +++ b/tests/Feature/SelectionCancellationHttpTest.php @@ -6,6 +6,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Queue; +use Illuminate\Testing\TestResponse; use PHPUnit\Framework\Attributes\DataProvider; use Tests\Feature\Concerns\ServerTestHelpers; use Tests\TestCase; @@ -103,7 +104,7 @@ public function test_expired_original_claim_cannot_cancel_a_member(): void [$runId, $task, $cancel] = $this->stageTimerWinner(); $before = WorkflowHistoryEvent::query()->where('workflow_run_id', $runId)->count(); WorkflowTask::query()->findOrFail($task['task_id'])->forceFill(['lease_expires_at' => now()->subSecond()])->save(); - $this->complete($task, [$cancel])->assertStatus(409)->assertJsonPath('recorded', false); + $this->complete($task, [$cancel])->assertStatus(409); $this->assertSame('pending', ActivityExecution::query()->sole()->status->value); $this->assertSame($before, WorkflowHistoryEvent::query()->where('workflow_run_id', $runId)->count()); } @@ -166,7 +167,7 @@ private function pollWorkflow(): array ], $this->workerHeaders())->assertOk()->json('task'); } - private function complete(array $task, array $commands, string $namespace = 'default'): \Illuminate\Testing\TestResponse + private function complete(array $task, array $commands, string $namespace = 'default'): TestResponse { return $this->postJson('/api/worker/workflow-tasks/'.$task['task_id'].'/complete', [ 'lease_owner' => 'select-worker', 'workflow_task_attempt' => $task['workflow_task_attempt'], From d055dc55dcd15c59a64dfc6bc728d154a3197368 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Wed, 21 Oct 2026 11:12:20 +0000 Subject: [PATCH 4/5] Prepare Server 2.10.1 and Helm 0.2.3 for select cancellation recovery --- composer.json | 2 +- composer.lock | 2 +- docker-compose.dedicated-matching.yml | 4 ++-- docker-compose.memo-rolling.yml | 4 ++-- docker-compose.published.yml | 4 ++-- docker-compose.small-cluster.yml | 2 +- docker-compose.yml | 8 ++++---- docs/select-member-cancellation-recovery.md | 2 +- k8s/README.md | 10 +++++----- k8s/helm/durable-workflow/Chart.yaml | 6 +++--- k8s/helm/durable-workflow/README.md | 2 +- .../durable-workflow/ci/existing-secrets-values.yaml | 2 +- .../durable-workflow/ci/ingress-and-hpa-values.yaml | 2 +- .../durable-workflow/ci/inline-secrets-values.yaml | 2 +- k8s/helm/durable-workflow/templates/_helpers.tpl | 2 +- k8s/helm/durable-workflow/values.yaml | 2 +- k8s/helm/examples/values-dev.yaml | 2 +- .../examples/values-external-secrets-operator.yaml | 2 +- .../examples/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/SelectionCancellationHttpTest.php | 6 +++--- 27 files changed, 42 insertions(+), 42 deletions(-) diff --git a/composer.json b/composer.json index e40bcd2d7..ec9ad5514 100644 --- a/composer.json +++ b/composer.json @@ -48,7 +48,7 @@ }, "extra": { "durable-workflow": { - "product-train": "2.10.0" + "product-train": "2.10.1" }, "laravel": { "dont-discover": [] diff --git a/composer.lock b/composer.lock index a76e2a3f7..8e9e72613 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": "6efd01e2e4b94293e21ace88e7088f04", + "content-hash": "050062197e287c4c7ddc1ccd2605d0e5", "packages": [ { "name": "apache/avro", diff --git a/docker-compose.dedicated-matching.yml b/docker-compose.dedicated-matching.yml index 77b4b2b41..1ec3d1349 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.10.0}} +x-server-image: &server-image ${DW_SERVER_IMAGE:-durableworkflow/server:${DW_SERVER_TAG:-2.10.1}} 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.10.0}} + APP_VERSION: ${APP_VERSION:-${DW_SERVER_TAG:-2.10.1}} 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 1e43c93b7..83147fc2b 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.10.0} + APP_VERSION: ${APP_VERSION:-2.10.1} successor: image: ${DW_MEMO_SUCCESSOR_IMAGE:-durable-workflow/server-memo-rolling:local} ports: !override [] environment: <<: *runtime-environment - APP_VERSION: ${APP_VERSION:-2.10.0} + APP_VERSION: ${APP_VERSION:-2.10.1} 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 7514a273a..cf3bed2b7 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.10.0}} +x-server-image: &server-image ${DW_SERVER_IMAGE:-durableworkflow/server:${DW_SERVER_TAG:-2.10.1}} 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.10.0}} + APP_VERSION: ${APP_VERSION:-${DW_SERVER_TAG:-2.10.1}} 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 5750b765b..b416bcf96 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.10.0} + APP_VERSION: ${APP_VERSION:-2.10.1} 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 ff6b2bb1e..c7d5d7b5a 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.10.0}" + APP_VERSION: "${APP_VERSION:-2.10.1}" 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.10.0}" + APP_VERSION: "${APP_VERSION:-2.10.1}" 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.10.0}" + APP_VERSION: "${APP_VERSION:-2.10.1}" 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.10.0}" + APP_VERSION: "${APP_VERSION:-2.10.1}" DB_CONNECTION: mysql DB_HOST: mysql DB_PORT: 3306 diff --git a/docs/select-member-cancellation-recovery.md b/docs/select-member-cancellation-recovery.md index 5b9db73cb..1f8393344 100644 --- a/docs/select-member-cancellation-recovery.md +++ b/docs/select-member-cancellation-recovery.md @@ -6,7 +6,7 @@ when validating a workflow-task completion. The runtime then refuses the workflow replay error, leaving the run waiting with `workflow_replay_blocked`. The activity the workflow tried to cancel can still be queued or running. -Upgrade to the corrected PHP Server release before repairing affected runs. +Upgrade to PHP Server 2.10.1 or later before repairing affected runs. The correction preserves the existing command format and does not require an SDK or Workflow dependency upgrade. It does not automatically retry blocked runs or undo an activity that has already executed. diff --git a/k8s/README.md b/k8s/README.md index b58d07ed8..1f64202c7 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.10.0 +durableworkflow/server:2.10.1 ``` 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.10.0 + server=durableworkflow/server:2.10.1 kubectl set image -n durable-workflow deploy/durable-workflow-worker \ - worker=durableworkflow/server:2.10.0 + worker=durableworkflow/server:2.10.1 kubectl set image -n durable-workflow cronjob/durable-workflow-scheduler \ - scheduler=durableworkflow/server:2.10.0 + scheduler=durableworkflow/server:2.10.1 ``` GitHub Container Registry publishes the same release line at -`ghcr.io/durable-workflow/server:2.10.0`. Digest pinning is preferred for strict +`ghcr.io/durable-workflow/server:2.10.1`. 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 9f0ea4438..ee733b24b 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.2.2 +version: 0.2.3 # 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.10.0" +appVersion: "2.10.1" 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.10.0" + dev.durable-workflow.image-reference: "docker.io/durableworkflow/server:2.10.1" 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 61b0d7a20..4883a86fa 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.10.0" + tag: "2.10.1" # 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 9072e9a18..3d1e2d812 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.10.0" + tag: "2.10.1" 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 b1d930ed5..ab05a5d9f 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.10.0" + tag: "2.10.1" 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 9031db341..5a32320fd 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.10.0" + tag: "2.10.1" externalDatabase: connection: mysql diff --git a/k8s/helm/durable-workflow/templates/_helpers.tpl b/k8s/helm/durable-workflow/templates/_helpers.tpl index e0177287c..df6f4a5b7 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.10.0" -}} +{{- if eq $normalized "docker.io/durableworkflow/server:2.10.1" -}} 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 b0f960047..d521dd292 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.10.0" + tag: "2.10.1" # 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 68ccfe92e..9ba84bb69 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.10.0" + tag: "2.10.1" externalDatabase: connection: mysql diff --git a/k8s/helm/examples/values-external-secrets-operator.yaml b/k8s/helm/examples/values-external-secrets-operator.yaml index 83b970697..bc6d93e1b 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.10.0" + tag: "2.10.1" externalDatabase: connection: pgsql diff --git a/k8s/helm/examples/values-production-existing-secrets.yaml b/k8s/helm/examples/values-production-existing-secrets.yaml index b427f40d5..1bcccace7 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.10.0" + tag: "2.10.1" externalDatabase: connection: pgsql diff --git a/k8s/migration-job.yaml b/k8s/migration-job.yaml index 8eb2da137..f1d87ebdf 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.10.0 + image: durableworkflow/server:2.10.1 command: ["server-entrypoint"] args: ["server-bootstrap"] envFrom: diff --git a/k8s/scheduler-cronjob.yaml b/k8s/scheduler-cronjob.yaml index f80da825c..fed3121ca 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.10.0 + image: durableworkflow/server:2.10.1 command: ["server-entrypoint"] args: - sh diff --git a/k8s/secret.yaml b/k8s/secret.yaml index 5084f55ac..5236c860a 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.10.0" + APP_VERSION: "2.10.1" APP_ENV: production APP_DEBUG: "false" DB_CONNECTION: mysql diff --git a/k8s/server-deployment.yaml b/k8s/server-deployment.yaml index 214da09e1..38ca279b0 100644 --- a/k8s/server-deployment.yaml +++ b/k8s/server-deployment.yaml @@ -23,7 +23,7 @@ spec: spec: containers: - name: server - image: durableworkflow/server:2.10.0 + image: durableworkflow/server:2.10.1 ports: - containerPort: 8080 name: http diff --git a/k8s/worker-deployment.yaml b/k8s/worker-deployment.yaml index 84a6df475..39e90f133 100644 --- a/k8s/worker-deployment.yaml +++ b/k8s/worker-deployment.yaml @@ -19,7 +19,7 @@ spec: spec: containers: - name: worker - image: durableworkflow/server:2.10.0 + image: durableworkflow/server:2.10.1 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 271e80038..6d20c9916 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.10.0" + "version": "2.10.1" }, "helm_chart": { - "version": "0.2.2" + "version": "0.2.3" } } diff --git a/scripts/k8s-kind-smoke.sh b/scripts/k8s-kind-smoke.sh index 3d5efad4e..5e4e2ab07 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.10.0" +manifest_image="durableworkflow/server:2.10.1" 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/SelectionCancellationHttpTest.php b/tests/Feature/SelectionCancellationHttpTest.php index 4ac52cf07..f9f10e199 100644 --- a/tests/Feature/SelectionCancellationHttpTest.php +++ b/tests/Feature/SelectionCancellationHttpTest.php @@ -102,11 +102,11 @@ public function test_previously_blocked_run_can_be_repaired_and_cancel_its_origi public function test_expired_original_claim_cannot_cancel_a_member(): void { [$runId, $task, $cancel] = $this->stageTimerWinner(); - $before = WorkflowHistoryEvent::query()->where('workflow_run_id', $runId)->count(); WorkflowTask::query()->findOrFail($task['task_id'])->forceFill(['lease_expires_at' => now()->subSecond()])->save(); - $this->complete($task, [$cancel])->assertStatus(409); + $this->complete($task, [$cancel])->assertStatus(409)->assertJsonPath('reason', 'lease_expired'); $this->assertSame('pending', ActivityExecution::query()->sole()->status->value); - $this->assertSame($before, WorkflowHistoryEvent::query()->where('workflow_run_id', $runId)->count()); + $this->assertSame(0, WorkflowHistoryEvent::query()->where('workflow_run_id', $runId) + ->where('event_type', HistoryEventType::SelectionOperationCancelled->value)->count()); } #[DataProvider('requiredMemberFields')] From 171d25391523966c636dbe21ac0384d241e9b5c5 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Wed, 21 Oct 2026 11:55:16 +0000 Subject: [PATCH 5/5] Reuse the workflow stream validation condition --- app/Support/WorkflowTaskCompletionValidation.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/app/Support/WorkflowTaskCompletionValidation.php b/app/Support/WorkflowTaskCompletionValidation.php index 2da7ae8f7..591defb2c 100644 --- a/app/Support/WorkflowTaskCompletionValidation.php +++ b/app/Support/WorkflowTaskCompletionValidation.php @@ -9,6 +9,8 @@ /** Transport rules retain the package command grammar before semantic normalization. */ final class WorkflowTaskCompletionValidation { + private const REQUIRED_WORKFLOW_STREAM = 'required_with:commands.*.workflow_stream'; + /** @return array> */ public static function rules(): array { @@ -130,10 +132,10 @@ public static function rules(): array 'commands.*.principal_claims' => ['prohibited'], ...WorkflowCommandNormalizer::parallelMetadataValidationRules(), 'commands.*.workflow_stream' => ['nullable', 'array'], - 'commands.*.workflow_stream.operation' => ['required_with:commands.*.workflow_stream', 'string', 'in:append,close,error'], - 'commands.*.workflow_stream.stream_name' => ['required_with:commands.*.workflow_stream', 'string', 'max:191'], - 'commands.*.workflow_stream.command_identity' => ['required_with:commands.*.workflow_stream', 'string', 'max:191'], - 'commands.*.workflow_stream.command_ordinal' => ['required_with:commands.*.workflow_stream', 'integer', 'min:0'], + 'commands.*.workflow_stream.operation' => [self::REQUIRED_WORKFLOW_STREAM, 'string', 'in:append,close,error'], + 'commands.*.workflow_stream.stream_name' => [self::REQUIRED_WORKFLOW_STREAM, 'string', 'max:191'], + 'commands.*.workflow_stream.command_identity' => [self::REQUIRED_WORKFLOW_STREAM, 'string', 'max:191'], + 'commands.*.workflow_stream.command_ordinal' => [self::REQUIRED_WORKFLOW_STREAM, 'integer', 'min:0'], 'commands.*.workflow_stream.items' => ['nullable', 'array', 'max:'.WorkflowStreamService::DEFAULT_MAX_ITEMS_PER_APPEND], 'commands.*.workflow_stream.items.*' => ['array'], 'commands.*.workflow_stream.items.*.payload' => ['nullable'],