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..591defb2c --- /dev/null +++ b/app/Support/WorkflowTaskCompletionValidation.php @@ -0,0 +1,163 @@ +> */ + 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' => [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'], + '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/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 new file mode 100644 index 000000000..1f8393344 --- /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 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. + +## 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/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 new file mode 100644 index 000000000..f9f10e199 --- /dev/null +++ b/tests/Feature/SelectionCancellationHttpTest.php @@ -0,0 +1,177 @@ +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, [ + '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) + ->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); + + $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('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); + } + + 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], + ]; + } + + 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(); + WorkflowTask::query()->findOrFail($task['task_id'])->forceFill(['lease_expires_at' => now()->subSecond()])->save(); + $this->complete($task, [$cancel])->assertStatus(409)->assertJsonPath('reason', 'lease_expired'); + $this->assertSame('pending', ActivityExecution::query()->sole()->status->value); + $this->assertSame(0, WorkflowHistoryEvent::query()->where('workflow_run_id', $runId) + ->where('event_type', HistoryEventType::SelectionOperationCancelled->value)->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', [ + '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'): 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)); + } +} 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], + ]; + } +}