From 8d493e51ee054a9f18adf686f9d43a4b6db98663 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Thu, 3 Jul 2025 14:33:50 +0400 Subject: [PATCH 1/4] Add test case for child_workflow/cancel_abandon --- .../child_workflow/cancel_abandon/feature.php | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 features/child_workflow/cancel_abandon/feature.php diff --git a/features/child_workflow/cancel_abandon/feature.php b/features/child_workflow/cancel_abandon/feature.php new file mode 100644 index 00000000..27d4fed3 --- /dev/null +++ b/features/child_workflow/cancel_abandon/feature.php @@ -0,0 +1,105 @@ +withParentClosePolicy(Workflow\ParentClosePolicy::Abandon), + ); + + yield $child->start('test 42'); + + try { + return yield $child->getResult(); + } catch (CanceledFailure) { + return 'cancelled'; + } catch (ChildWorkflowFailure $failure) { + # Check CanceledFailure + return $failure->getPrevious()::class === CanceledFailure::class + ? 'child-cancelled' + : throw $failure; + } + } +} + +#[WorkflowInterface] +class ChildWorkflow +{ + private bool $exit = false; + + #[WorkflowMethod('Harness_ChildWorkflow_CancelAbandon_Child')] + public function run(string $input) + { + yield Workflow::await(fn(): bool => $this->exit); + return $input; + } + + #[Workflow\SignalMethod('exit')] + public function exit(): void + { + $this->exit = true; + } +} + +class FeatureChecker +{ + #[Check] + public static function check( + #[Stub('MainWorkflow')] WorkflowStubInterface $stub, + WorkflowClientInterface $client, + ): void { + # Find the child workflow execution ID + $deadline = \microtime(true) + 10; + child_id: + $execution = null; + foreach ($client->getWorkflowHistory($stub->getExecution()) as $event) { + if ($event->hasChildWorkflowExecutionStartedEventAttributes()) { + $execution = $event->getChildWorkflowExecutionStartedEventAttributes()->getWorkflowExecution(); + break; + } + } + + if ($execution === null && \microtime(true) < $deadline) { + goto child_id; + } + + Assert::notNull($execution, 'Child workflow execution not found in history'); + + # Get Child Workflow Stub + $child = $client->newUntypedRunningWorkflowStub( + $execution->getWorkflowId(), + $execution->getRunId(), + 'Harness_ChildWorkflow_CancelAbandon_Child', + ); + + # Cancel the parent workflow + $stub->cancel(); + # Expect the CanceledFailure in the parent workflow + Assert::same('cancelled', $stub->getResult()); + + # Signal the child workflow to exit + $child->signal('exit'); + # No canceled failure in the child workflow + Assert::same('test 42', $child->getResult()); + } +} From bed4a09cdad705e4357c999db7b157a43a355020 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 7 Oct 2025 10:38:53 +0400 Subject: [PATCH 2/4] Resolve TODO --- features/child_workflow/result/feature.php | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/features/child_workflow/result/feature.php b/features/child_workflow/result/feature.php index acb73713..f558811e 100644 --- a/features/child_workflow/result/feature.php +++ b/features/child_workflow/result/feature.php @@ -18,11 +18,7 @@ class MainWorkflow #[WorkflowMethod('MainWorkflow')] public function run() { - return yield Workflow::newChildWorkflowStub( - ChildWorkflow::class, - // TODO: remove after https://github.com/temporalio/sdk-php/issues/451 is fixed - Workflow\ChildWorkflowOptions::new()->withTaskQueue(Workflow::getInfo()->taskQueue), - )->run('Test'); + return yield Workflow::newChildWorkflowStub(ChildWorkflow::class)->run('Test'); } } From 034290d0d2f48914ae112366f2d9aa2db5bd1486 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 7 Oct 2025 11:01:01 +0400 Subject: [PATCH 3/4] Update Cancel Abandoned Child Workflow tests --- .../child_workflow/cancel_abandon/feature.php | 165 +++++++++++++++--- harness/php/composer.json | 2 +- harness/php/worker.php | 1 + 3 files changed, 146 insertions(+), 22 deletions(-) diff --git a/features/child_workflow/cancel_abandon/feature.php b/features/child_workflow/cancel_abandon/feature.php index 27d4fed3..2f4a3aaf 100644 --- a/features/child_workflow/cancel_abandon/feature.php +++ b/features/child_workflow/cancel_abandon/feature.php @@ -10,34 +10,86 @@ use Temporal\Client\WorkflowStubInterface; use Temporal\Exception\Failure\CanceledFailure; use Temporal\Exception\Failure\ChildWorkflowFailure; +use Temporal\Promise; use Temporal\Workflow; +use Temporal\Workflow\CancellationScopeInterface; use Temporal\Workflow\WorkflowInterface; use Temporal\Workflow\WorkflowMethod; use Webmozart\Assert\Assert; #[WorkflowInterface] -class MainWorkflow +class InnerScopeCancelWorkflow { - #[WorkflowMethod('MainWorkflow')] - public function run() + private CancellationScopeInterface $scope; + + #[WorkflowMethod('Harness_ChildWorkflow_CancelAbandon_InnerScopeCancel')] + public function run(string $input) { - $child = Workflow::newUntypedChildWorkflowStub( + $this->scope = Workflow::async(static function () use ($input) { + /** @see ChildWorkflow */ + $stub = Workflow::newUntypedChildWorkflowStub( + 'Harness_ChildWorkflow_CancelAbandon_Child', + Workflow\ChildWorkflowOptions::new() + ->withWorkflowRunTimeout('20 seconds') + ->withParentClosePolicy(Workflow\ParentClosePolicy::Abandon), + ); + yield $stub->start($input); + + return yield $stub->getResult('string'); + }); + + try { + yield Promise::race([Workflow::timer(5) ,$this->scope]); + return 'timer'; + } catch (CanceledFailure) { + return 'cancelled'; + } catch (ChildWorkflowFailure $failure) { + # Check CanceledFailure + return $failure->getPrevious()::class === CanceledFailure::class + ? 'child-cancelled' + : throw $failure; + } finally { + yield Workflow::asyncDetached(function () { + # We shouldn't complete the Workflow immediately: + # all the commands from the tick must be sent for testing purposes. + yield Workflow::timer(1); + }); + } + } +} + +#[WorkflowInterface] +class MainScopeWorkflow +{ + #[WorkflowMethod('Harness_ChildWorkflow_CancelAbandon_MainScope')] + public function run(string $input) + { + /** @see ChildWorkflow */ + $stub = Workflow::newUntypedChildWorkflowStub( 'Harness_ChildWorkflow_CancelAbandon_Child', Workflow\ChildWorkflowOptions::new() + ->withWorkflowRunTimeout('20 seconds') ->withParentClosePolicy(Workflow\ParentClosePolicy::Abandon), ); - yield $child->start('test 42'); + yield $stub->start($input); try { - return yield $child->getResult(); + yield Promise::race([$stub->getResult(), Workflow::timer(5)]); + return 'timer'; } catch (CanceledFailure) { return 'cancelled'; } catch (ChildWorkflowFailure $failure) { # Check CanceledFailure return $failure->getPrevious()::class === CanceledFailure::class - ? 'child-cancelled' + ? 'cancelled' : throw $failure; + } finally { + yield Workflow::asyncDetached(function () { + # We shouldn't complete the Workflow immediately: + # all the commands from the tick must be sent for testing purposes. + yield Workflow::timer(1); + }); } } } @@ -63,11 +115,92 @@ public function exit(): void class FeatureChecker { + /** + * If an abandoned Child Workflow is started in the main Workflow scope, + * the Child Workflow should not be affected by the cancellation of the parent workflow. + * But need to consider that we can miss the Cancellation signal if awaiting only on the Child Workflow. + * In the {@see MainScopeWorkflow} we use Timer + Child Workflow to ensure we catch the Cancellation signal. + */ + #[Check] + public static function CancelChildWorkflowInMainScope( + #[Stub('Harness_ChildWorkflow_CancelAbandon_MainScope', args: ['test 42'])] + WorkflowStubInterface $stub, + WorkflowClientInterface $client, + ): void { + self::runTestScenario($stub, $client, 'test 42'); + } + + /** + * If an abandoned Child Workflow is started in an async Scope {@see Workflow::async()} that is later cancelled, + * the Child Workflow should not be affected by the cancellation of the parent workflow. + * Int his case the Scope will throw the CanceledFailure. + * @see InnerScopeCancelWorkflow + */ + #[Check] + public static function CancelChildWorkflowInsideScope( + #[Stub('Harness_ChildWorkflow_CancelAbandon_InnerScopeCancel', args: ['baz'])] + WorkflowStubInterface $stub, + WorkflowClientInterface $client, + ): void { + self::runTestScenario($stub, $client, 'baz'); + } + + /** + * If an abandoned Child Workflow is started in an async scope {@see Workflow::async()} that + * is later cancelled manually by a Signal to the parent workflow {@see InnerScopeCancelWorkflow::close()}, + * the Child Workflow should not be affected by the cancellation of the parent scope. + */ #[Check] - public static function check( - #[Stub('MainWorkflow')] WorkflowStubInterface $stub, + public static function childWorkflowInClosingInnerScope( + #[Stub('Harness_ChildWorkflow_CancelAbandon_InnerScopeCancel', args: ['foo bar'])] + WorkflowStubInterface $stub, WorkflowClientInterface $client, ): void { + # Get Child Workflow Stub + $child = self::getChildWorkflowStub($client, $stub); + + # Cancel the async scope + /** @see InnerScopeCancelWorkflow::close() */ + $stub->signal('close'); + # Expect the CanceledFailure in the parent workflow + Assert::same($stub->getResult(timeout: 5), 'cancelled'); + + # Signal the child workflow to exit + $child->signal('exit'); + # No canceled failure in the child workflow + Assert::same($child->getResult(), 'foo bar'); + } + + /** + * Send cancel to the parent workflow and expect the child workflow to be abandoned + * and not cancelled. + */ + private static function runTestScenario( + WorkflowStubInterface $stub, + WorkflowClientInterface $client, + string $result, + ): void { + # Get Child Workflow Stub + $child = self::getChildWorkflowStub($client, $stub); + + # Cancel the parent workflow + $stub->cancel(); + # Expect the CanceledFailure in the parent workflow + Assert::same($stub->getResult(timeout: 5), 'cancelled'); + + # Signal the child workflow to exit + $child->signal('exit'); + # No canceled failure in the child workflow + Assert::same($child->getResult(), $result); + } + + /** + * Get Child Workflow Stub + */ + private static function getChildWorkflowStub( + WorkflowClientInterface $client, + WorkflowStubInterface $stub, + ): WorkflowStubInterface { # Find the child workflow execution ID $deadline = \microtime(true) + 10; child_id: @@ -83,23 +216,13 @@ public static function check( goto child_id; } - Assert::notNull($execution, 'Child workflow execution not found in history'); + Assert::notNull($execution, 'Child Workflow execution not found in the history.'); # Get Child Workflow Stub - $child = $client->newUntypedRunningWorkflowStub( + return $client->newUntypedRunningWorkflowStub( $execution->getWorkflowId(), $execution->getRunId(), 'Harness_ChildWorkflow_CancelAbandon_Child', ); - - # Cancel the parent workflow - $stub->cancel(); - # Expect the CanceledFailure in the parent workflow - Assert::same('cancelled', $stub->getResult()); - - # Signal the child workflow to exit - $child->signal('exit'); - # No canceled failure in the child workflow - Assert::same('test 42', $child->getResult()); } } diff --git a/harness/php/composer.json b/harness/php/composer.json index cca01066..b9bb026b 100644 --- a/harness/php/composer.json +++ b/harness/php/composer.json @@ -8,7 +8,7 @@ "buggregator/trap": "^1.9", "spiral/core": "^3.13", "symfony/process": ">=6.4", - "temporal/sdk": "^2.13.2", + "temporal/sdk": "^2.16.0", "webmozart/assert": "^1.11" }, "autoload": { diff --git a/harness/php/worker.php b/harness/php/worker.php index 9bbcd8ec..13104549 100644 --- a/harness/php/worker.php +++ b/harness/php/worker.php @@ -35,6 +35,7 @@ $workers = []; FeatureFlags::$workflowDeferredHandlerStart = true; +FeatureFlags::$cancelAbandonedChildWorkflows = false; try { // Load runtime options From 6477c0b46c47bf554adbdbc87ad66353e0c6baf5 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 7 Oct 2025 11:12:26 +0400 Subject: [PATCH 4/4] Add close method to cancel workflow --- features/child_workflow/cancel_abandon/feature.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/features/child_workflow/cancel_abandon/feature.php b/features/child_workflow/cancel_abandon/feature.php index 2f4a3aaf..1fe5b7b5 100644 --- a/features/child_workflow/cancel_abandon/feature.php +++ b/features/child_workflow/cancel_abandon/feature.php @@ -56,6 +56,12 @@ public function run(string $input) }); } } + + #[Workflow\SignalMethod('close')] + public function close(): void + { + $this->scope->cancel(); + } } #[WorkflowInterface]