Skip to content

Commit 15d61fb

Browse files
authored
Merge pull request #646: Control Abandoned Child Workflow Cancellation
2 parents 118af2a + 1c00687 commit 15d61fb

File tree

7 files changed

+191
-30
lines changed

7 files changed

+191
-30
lines changed

src/Internal/Workflow/ChildWorkflowStub.php

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,12 @@
2121
use Temporal\Internal\Transport\Request\ExecuteChildWorkflow;
2222
use Temporal\Internal\Transport\Request\GetChildWorkflowExecution;
2323
use Temporal\Internal\Transport\Request\SignalExternalWorkflow;
24+
use Temporal\Worker\FeatureFlags;
2425
use Temporal\Worker\Transport\Command\RequestInterface;
2526
use Temporal\Workflow;
2627
use Temporal\Workflow\ChildWorkflowOptions;
2728
use Temporal\Workflow\ChildWorkflowStubInterface;
29+
use Temporal\Workflow\ParentClosePolicy;
2830
use Temporal\Workflow\WorkflowExecution;
2931

3032
final class ChildWorkflowStub implements ChildWorkflowStubInterface
@@ -70,7 +72,10 @@ public function start(... $args): PromiseInterface
7072
$this->header,
7173
);
7274

73-
$this->result = $this->request($this->request);
75+
$cancellable = FeatureFlags::$cancelAbandonedChildWorkflows
76+
|| $this->options->parentClosePolicy !== ParentClosePolicy::Abandon->value;
77+
78+
$this->result = $this->request($this->request, cancellable: $cancellable);
7479

7580
$started = $this->request(new GetChildWorkflowExecution($this->request))
7681
->then(
@@ -118,12 +123,12 @@ function (WorkflowExecution $execution) use ($name, $args) {
118123
);
119124
}
120125

121-
protected function request(RequestInterface $request): PromiseInterface
126+
protected function request(RequestInterface $request, bool $cancellable = true): PromiseInterface
122127
{
123128
/** @var Workflow\WorkflowContextInterface $context */
124129
$context = Workflow::getCurrentContext();
125130

126-
return $context->request($request);
131+
return $context->request($request, cancellable: $cancellable);
127132
}
128133

129134
private function getOptionArray(): array

src/Internal/Workflow/Process/Scope.php

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -355,9 +355,9 @@ protected function callSignalOrUpdateHandler(callable $handler, ValuesInterface
355355
}, $values)->catch($this->onException(...));
356356
}
357357

358-
protected function onRequest(RequestInterface $request, PromiseInterface $promise): void
358+
protected function onRequest(RequestInterface $request, PromiseInterface $promise, bool $cancellable = true): void
359359
{
360-
$this->onCancel[++$this->cancelID] = function (?\Throwable $reason = null) use ($request): void {
360+
$this->onCancel[++$this->cancelID] = function (?\Throwable $reason = null) use ($request, $cancellable): void {
361361
$client = $this->context->getClient();
362362
if ($reason instanceof DestructMemorizedInstanceException) {
363363
// memory flush
@@ -370,6 +370,11 @@ protected function onRequest(RequestInterface $request, PromiseInterface $promis
370370
return;
371371
}
372372

373+
if (!$cancellable) {
374+
// non-cancellable request
375+
return;
376+
}
377+
373378
$client->request(new Cancel($request->getID()), $this->scopeContext);
374379
};
375380

src/Internal/Workflow/ScopeContext.php

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,17 @@
2323

2424
class ScopeContext extends WorkflowContext implements ScopedContextInterface
2525
{
26+
/** @var \Closure(RequestInterface, PromiseInterface, bool $cancellable): void */
27+
private \Closure $onRequest;
28+
2629
private WorkflowContext $parent;
2730
private Scope $scope;
28-
private \Closure $onRequest;
2931
private ?UpdateContext $updateContext = null;
3032

3133
/**
3234
* Creates scope specific context.
35+
*
36+
* @param \Closure(RequestInterface, PromiseInterface, bool $cancellable): void $onRequest
3337
*/
3438
public static function fromWorkflowContext(
3539
WorkflowContext $context,
@@ -78,11 +82,11 @@ public function request(
7882
);
7983

8084
if (!$waitResponse) {
81-
return $this->parent->request($request, $cancellable, false);
85+
return $this->parent->request($request, cancellable: $cancellable, waitResponse: false);
8286
}
8387

84-
$promise = $this->parent->request($request);
85-
($this->onRequest)($request, $promise);
88+
$promise = $this->parent->request($request, cancellable: $cancellable);
89+
($this->onRequest)($request, $promise, $cancellable);
8690

8791
return new CompletableResult(
8892
$this,

src/Worker/FeatureFlags.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,21 @@ final class FeatureFlags
3131
* @since SDK 2.11.0
3232
*/
3333
public static bool $warnOnWorkflowUnfinishedHandlers = true;
34+
35+
/**
36+
* When a parent workflow is canceled, it will also cancel all its Child Workflows, including abandoned ones.
37+
* This behavior is not correct and will be improved by default in the next major SDK version.
38+
*
39+
* To fix the behavior now, set this flag to TRUE. In this case, be aware of the following:
40+
* - If you start an abandoned Child Workflow in the main Workflow scope, it may miss
41+
* the cancellation signal if you await only on the Child Workflow.
42+
* - If you start an abandoned Child Workflow in an async scope {@see Workflow::async()},
43+
* that is later canceled, the Child Workflow will not be affected.
44+
* - You still can cancel abandoned Child Workflows manually by calling {@see WorkflowStubInterface::cancel()}.
45+
*
46+
* @see Workflow\ParentClosePolicy::Abandon
47+
*
48+
* @since SDK 2.16.0
49+
*/
50+
public static bool $cancelAbandonedChildWorkflows = true;
3451
}

src/Workflow/WorkflowContextInterface.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,8 @@ public function registerUpdate(string $name, callable $handler, ?callable $valid
126126
/**
127127
* Exchanges data between worker and host process.
128128
*
129+
* @param bool $waitResponse Determine if the Request requires a Response from RoadRunner.
130+
*
129131
* @internal This is an internal method
130132
*/
131133
public function request(

tests/Acceptance/App/RuntimeBuilder.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ public static function init(): void
7171
\ini_set('display_errors', 'stderr');
7272
// Feature flags
7373
FeatureFlags::$workflowDeferredHandlerStart = true;
74+
FeatureFlags::$cancelAbandonedChildWorkflows = false;
7475
}
7576

7677
/**

tests/Acceptance/Harness/ChildWorkflow/CancelAbandonTest.php

Lines changed: 148 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,102 @@
99
use Temporal\Client\WorkflowStubInterface;
1010
use Temporal\Exception\Failure\CanceledFailure;
1111
use Temporal\Exception\Failure\ChildWorkflowFailure;
12+
use Temporal\Promise;
1213
use Temporal\Tests\Acceptance\App\Attribute\Stub;
1314
use Temporal\Tests\Acceptance\App\TestCase;
1415
use Temporal\Workflow;
16+
use Temporal\Workflow\CancellationScopeInterface;
1517
use Temporal\Workflow\WorkflowInterface;
1618
use Temporal\Workflow\WorkflowMethod;
1719

1820
class CancelAbandonTest extends TestCase
1921
{
22+
/**
23+
* If an abandoned Child Workflow is started in the main Workflow scope,
24+
* the Child Workflow should not be affected by the cancellation of the parent workflow.
25+
* But need to consider that we can miss the Cancellation signal if awaiting only on the Child Workflow.
26+
* In the {@see MainScopeWorkflow} we use Timer + Child Workflow to ensure we catch the Cancellation signal.
27+
*/
2028
#[Test]
21-
public static function check(
22-
#[Stub('Harness_ChildWorkflow_CancelAbandon')]
29+
public static function childWorkflowInMainScope(
30+
#[Stub('Harness_ChildWorkflow_CancelAbandon_MainScope', args: ['test 42'])]
2331
WorkflowStubInterface $stub,
2432
WorkflowClientInterface $client,
2533
): void {
26-
self::markTestSkipped('To be resolved with https://github.com/temporalio/sdk-php/issues/634');
34+
self::runTestScenario($stub, $client, 'test 42');
35+
}
36+
37+
/**
38+
* If an abandoned Child Workflow is started in an async Scope {@see Workflow::async()} that is later cancelled,
39+
* the Child Workflow should not be affected by the cancellation of the parent workflow.
40+
* Int his case the Scope will throw the CanceledFailure.
41+
* @see InnerScopeCancelWorkflow
42+
*/
43+
#[Test]
44+
public static function childWorkflowInInnerScopeCancel(
45+
#[Stub('Harness_ChildWorkflow_CancelAbandon_InnerScopeCancel', args: ['baz'])]
46+
WorkflowStubInterface $stub,
47+
WorkflowClientInterface $client,
48+
): void {
49+
self::runTestScenario($stub, $client, 'baz');
50+
}
51+
52+
/**
53+
* If an abandoned Child Workflow is started in an async scope {@see Workflow::async()} that
54+
* is later cancelled manually by a Signal to the parent workflow {@see InnerScopeCancelWorkflow::close()},
55+
* the Child Workflow should not be affected by the cancellation of the parent scope.
56+
*/
57+
#[Test]
58+
public static function childWorkflowInClosingInnerScope(
59+
#[Stub('Harness_ChildWorkflow_CancelAbandon_InnerScopeCancel', args: ['foo bar'])]
60+
WorkflowStubInterface $stub,
61+
WorkflowClientInterface $client,
62+
): void {
63+
# Get Child Workflow Stub
64+
$child = self::getChildWorkflowStub($client, $stub);
65+
66+
# Cancel the async scope
67+
/** @see InnerScopeCancelWorkflow::close() */
68+
$stub->signal('close');
69+
# Expect the CanceledFailure in the parent workflow
70+
self::assertSame('cancelled', $stub->getResult(timeout: 5));
71+
72+
# Signal the child workflow to exit
73+
$child->signal('exit');
74+
# No canceled failure in the child workflow
75+
self::assertSame('foo bar', $child->getResult());
76+
}
77+
78+
/**
79+
* Send cancel to the parent workflow and expect the child workflow to be abandoned
80+
* and not cancelled.
81+
*/
82+
private static function runTestScenario(
83+
WorkflowStubInterface $stub,
84+
WorkflowClientInterface $client,
85+
string $result,
86+
): void {
87+
# Get Child Workflow Stub
88+
$child = self::getChildWorkflowStub($client, $stub);
89+
90+
# Cancel the parent workflow
91+
$stub->cancel();
92+
# Expect the CanceledFailure in the parent workflow
93+
self::assertSame('cancelled', $stub->getResult(timeout: 5));
94+
95+
# Signal the child workflow to exit
96+
$child->signal('exit');
97+
# No canceled failure in the child workflow
98+
self::assertSame($result, $child->getResult());
99+
}
27100

101+
/**
102+
* Get Child Workflow Stub
103+
*/
104+
private static function getChildWorkflowStub(
105+
WorkflowClientInterface $client,
106+
WorkflowStubInterface $stub,
107+
): WorkflowStubInterface {
28108
# Find the child workflow execution ID
29109
$deadline = \microtime(true) + 10;
30110
child_id:
@@ -40,52 +120,99 @@ public static function check(
40120
goto child_id;
41121
}
42122

43-
self::assertNotNull($execution, 'Child workflow execution not found in history');
123+
self::assertNotNull($execution, 'Child Workflow execution not found in the history.');
44124

45125
# Get Child Workflow Stub
46-
$child = $client->newUntypedRunningWorkflowStub(
126+
return $client->newUntypedRunningWorkflowStub(
47127
$execution->getWorkflowId(),
48128
$execution->getRunId(),
49129
'Harness_ChildWorkflow_CancelAbandon_Child',
50130
);
51-
52-
# Cancel the parent workflow
53-
$stub->cancel();
54-
# Expect the CanceledFailure in the parent workflow
55-
self::assertSame('cancelled', $stub->getResult());
56-
57-
# Signal the child workflow to exit
58-
$child->signal('exit');
59-
# No canceled failure in the child workflow
60-
self::assertSame('test 42', $child->getResult());
61131
}
62132
}
63133

64134
#[WorkflowInterface]
65-
class MainWorkflow
135+
class MainScopeWorkflow
66136
{
67-
#[WorkflowMethod('Harness_ChildWorkflow_CancelAbandon')]
68-
public function run()
137+
#[WorkflowMethod('Harness_ChildWorkflow_CancelAbandon_MainScope')]
138+
public function run(string $input)
69139
{
70-
$child = Workflow::newUntypedChildWorkflowStub(
140+
/** @see ChildWorkflow */
141+
$stub = Workflow::newUntypedChildWorkflowStub(
71142
'Harness_ChildWorkflow_CancelAbandon_Child',
72143
Workflow\ChildWorkflowOptions::new()
144+
->withWorkflowRunTimeout('20 seconds')
73145
->withParentClosePolicy(Workflow\ParentClosePolicy::Abandon),
74146
);
75147

76-
yield $child->start('test 42');
148+
yield $stub->start($input);
149+
150+
try {
151+
yield Promise::race([$stub->getResult(), Workflow::timer(5)]);
152+
return 'timer';
153+
} catch (CanceledFailure) {
154+
return 'cancelled';
155+
} catch (ChildWorkflowFailure $failure) {
156+
# Check CanceledFailure
157+
return $failure->getPrevious()::class === CanceledFailure::class
158+
? 'cancelled'
159+
: throw $failure;
160+
} finally {
161+
yield Workflow::asyncDetached(function () {
162+
# We shouldn't complete the Workflow immediately:
163+
# all the commands from the tick must be sent for testing purposes.
164+
yield Workflow::timer(1);
165+
});
166+
}
167+
}
168+
}
169+
170+
#[WorkflowInterface]
171+
class InnerScopeCancelWorkflow
172+
{
173+
private CancellationScopeInterface $scope;
174+
175+
#[WorkflowMethod('Harness_ChildWorkflow_CancelAbandon_InnerScopeCancel')]
176+
public function run(string $input)
177+
{
178+
$this->scope = Workflow::async(static function () use ($input) {
179+
/** @see ChildWorkflow */
180+
$stub = Workflow::newUntypedChildWorkflowStub(
181+
'Harness_ChildWorkflow_CancelAbandon_Child',
182+
Workflow\ChildWorkflowOptions::new()
183+
->withWorkflowRunTimeout('20 seconds')
184+
->withParentClosePolicy(Workflow\ParentClosePolicy::Abandon),
185+
);
186+
yield $stub->start($input);
187+
188+
return yield $stub->getResult('string');
189+
});
190+
77191

78192
try {
79-
return yield $child->getResult();
193+
yield Promise::race([Workflow::timer(5) ,$this->scope]);
194+
return 'timer';
80195
} catch (CanceledFailure) {
81196
return 'cancelled';
82197
} catch (ChildWorkflowFailure $failure) {
83198
# Check CanceledFailure
84199
return $failure->getPrevious()::class === CanceledFailure::class
85200
? 'cancelled'
86201
: throw $failure;
202+
} finally {
203+
yield Workflow::asyncDetached(function () {
204+
# We shouldn't complete the Workflow immediately:
205+
# all the commands from the tick must be sent for testing purposes.
206+
yield Workflow::timer(1);
207+
});
87208
}
88209
}
210+
211+
#[Workflow\SignalMethod('close')]
212+
public function close(): void
213+
{
214+
$this->scope->cancel();
215+
}
89216
}
90217

91218
#[WorkflowInterface]

0 commit comments

Comments
 (0)