diff --git a/psalm-baseline.xml b/psalm-baseline.xml
index 511fbe21a..ed1fce631 100644
--- a/psalm-baseline.xml
+++ b/psalm-baseline.xml
@@ -952,12 +952,6 @@
-
- request($request), $returnType)]]>
-
-
-
-
@@ -968,43 +962,12 @@
workflow]]>
-
- execution->promise()->then(
- function (WorkflowExecution $execution) use ($name, $args) {
- $request = new SignalExternalWorkflow(
- $this->getOptions()->namespace,
- $execution->getID(),
- null,
- $name,
- EncodedValues::fromValues($args),
- true,
- );
-
- return $this->request($request);
- },
- )]]>
- start(...$args)->then(fn() => $this->getResult($returnType))]]>
-
-
-
-
-
-
-
-
- result]]>
-
-
-
-
-
-
diff --git a/src/Exception/InvalidSuspendException.php b/src/Exception/InvalidSuspendException.php
new file mode 100644
index 000000000..bd594341a
--- /dev/null
+++ b/src/Exception/InvalidSuspendException.php
@@ -0,0 +1,21 @@
+isStatic()
- ? $closure->bindTo(null, $ctx::class)?->__invoke(...$arguments) ?? $closure(...$arguments)
- : $closure->call($ctx, ...$arguments);
+ if (!$fun->isStatic()) {
+ return $closure->call($ctx, ...$arguments);
+ }
+
+ $bound = $closure->bindTo(null, $ctx::class);
+
+ return $bound === null
+ ? $closure(...$arguments)
+ : $bound(...$arguments);
} finally {
\restore_error_handler();
}
diff --git a/src/Internal/Support/StackRenderer.php b/src/Internal/Support/StackRenderer.php
index 6639fe2bc..318c8c8f0 100644
--- a/src/Internal/Support/StackRenderer.php
+++ b/src/Internal/Support/StackRenderer.php
@@ -110,6 +110,7 @@ public static function renderProto(array $stackTrace): EnhancedStackTrace
/** @var list $locations */
$locations = [];
+ $userFrameSeen = false;
foreach ($stackTrace as $line) {
$location = (new StackTraceFileLocation());
@@ -117,7 +118,6 @@ public static function renderProto(array $stackTrace): EnhancedStackTrace
$isInternal = false;
$file = $line['file'] ?? null;
if ($file !== null) {
- $location->setFilePath($file);
foreach (self::$ignorePaths as $str) {
if (\str_starts_with($file, $str)) {
$isInternal = true;
@@ -126,7 +126,13 @@ public static function renderProto(array $stackTrace): EnhancedStackTrace
}
}
- isset($line['line']) and $location->setLine($line['line']);
+ $exposeLocation = $isInternal || !$userFrameSeen;
+ if ($file !== null && $exposeLocation) {
+ $location->setFilePath($file);
+ }
+ if ($exposeLocation && isset($line['line'])) {
+ $location->setLine($line['line']);
+ }
if (isset($line['function'])) {
$location->setFunctionName(\sprintf(
@@ -139,17 +145,19 @@ public static function renderProto(array $stackTrace): EnhancedStackTrace
$locations[] = $location->setInternalCode($isInternal);
- // Store source code for non-internal files
- if (!$isInternal && $file !== null && !\array_key_exists($file, $sources)) {
- try {
- $code = @\file_get_contents($file);
- } catch (\Throwable $e) {
- $code = \sprintf("Cannot access code.\n---\n%s", $e->getMessage());
- }
+ if (!$isInternal && $file !== null) {
+ if (!\array_key_exists($file, $sources)) {
+ try {
+ $code = @\file_get_contents($file);
+ } catch (\Throwable $e) {
+ $code = \sprintf("Cannot access code.\n---\n%s", $e->getMessage());
+ }
- $sources[$file] = (new StackTraceFileSlice())
- ->setLineOffset(0)
- ->setContent($code === false ? "Failed to read file." : $code);
+ $sources[$file] = (new StackTraceFileSlice())
+ ->setLineOffset(0)
+ ->setContent($code === false ? "Failed to read file." : $code);
+ }
+ $userFrameSeen = true;
}
}
$stacks[] = (new StackTrace())
diff --git a/src/Internal/Transport/CompletableResultInterface.php b/src/Internal/Transport/CompletableResultInterface.php
index e72ee71fd..8b4c8610b 100644
--- a/src/Internal/Transport/CompletableResultInterface.php
+++ b/src/Internal/Transport/CompletableResultInterface.php
@@ -16,7 +16,6 @@
/**
* @template T
* @extends PromiseInterface
- * @yield T
*/
interface CompletableResultInterface extends PromiseInterface
{
diff --git a/src/Internal/Workflow/ActivityProxy.php b/src/Internal/Workflow/ActivityProxy.php
index df8f57429..59da850e4 100644
--- a/src/Internal/Workflow/ActivityProxy.php
+++ b/src/Internal/Workflow/ActivityProxy.php
@@ -21,7 +21,7 @@
use Temporal\Internal\Declaration\Prototype\ActivityPrototype;
use Temporal\Internal\Interceptor\Pipeline;
use Temporal\Internal\Support\Reflection;
-use Temporal\Internal\Transport\CompletableResultInterface;
+use Temporal\Internal\Workflow\Process\Awaiter;
use Temporal\Workflow\WorkflowContextInterface;
/**
@@ -63,11 +63,10 @@ public function __construct(
$this->ctx = $ctx;
}
- /**
- * @return CompletableResultInterface
- */
- public function __call(string $method, array $args = []): PromiseInterface
+ public function __call(string $method, array $args = []): mixed
{
+ Awaiter::assertManaged();
+
$prototype = $this->findPrototypeByHandlerNameOrFail($method);
$type = $prototype->getHandler()->getReturnType();
$options = $this->options->mergeWith($prototype->getMethodRetry());
@@ -85,12 +84,12 @@ public function __call(string $method, array $args = []): PromiseInterface
);
}
- return $prototype->isLocalActivity()
+ $result = $prototype->isLocalActivity()
// Run local activity through an interceptor pipeline
? $this->callsInterceptor->with(
fn(ExecuteLocalActivityInput $input): PromiseInterface => $this->ctx
->newUntypedActivityStub($input->options)
- ->execute($input->type, $input->args, $input->returnType, true),
+ ->executeAsync($input->type, $input->args, $input->returnType, true),
/** @see WorkflowOutboundCallsInterceptor::executeLocalActivity() */
'executeLocalActivity',
)(
@@ -107,7 +106,7 @@ public function __call(string $method, array $args = []): PromiseInterface
: $this->callsInterceptor->with(
fn(ExecuteActivityInput $input): PromiseInterface => $this->ctx
->newUntypedActivityStub($input->options)
- ->execute($input->type, $input->args, $input->returnType),
+ ->executeAsync($input->type, $input->args, $input->returnType),
/** @see WorkflowOutboundCallsInterceptor::executeActivity() */
'executeActivity',
)(
@@ -119,6 +118,8 @@ public function __call(string $method, array $args = []): PromiseInterface
$prototype->getHandler(),
)
);
+
+ return Awaiter::await($result, interruptOnCancel: false);
}
private function findPrototypeByHandlerNameOrFail(string $name): ActivityPrototype
diff --git a/src/Internal/Workflow/ActivityStub.php b/src/Internal/Workflow/ActivityStub.php
index 4ecc930a1..51ca89879 100644
--- a/src/Internal/Workflow/ActivityStub.php
+++ b/src/Internal/Workflow/ActivityStub.php
@@ -17,6 +17,7 @@
use Temporal\Interceptor\Header;
use Temporal\Interceptor\HeaderInterface;
use Temporal\Internal\Marshaller\MarshallerInterface;
+use Temporal\Internal\Workflow\Process\Awaiter;
use Temporal\Internal\Transport\Request\ExecuteActivity;
use Temporal\Internal\Transport\Request\ExecuteLocalActivity;
use Temporal\Worker\Transport\Command\RequestInterface;
@@ -60,6 +61,20 @@ public function execute(
array $args = [],
Type|string|\ReflectionClass|\ReflectionType|null $returnType = null,
bool $isLocalActivity = false,
+ ): mixed {
+ Awaiter::assertManaged();
+
+ return Awaiter::await(
+ $this->executeAsync($name, $args, $returnType, $isLocalActivity),
+ interruptOnCancel: false,
+ );
+ }
+
+ public function executeAsync(
+ string $name,
+ array $args = [],
+ Type|string|\ReflectionClass|\ReflectionType|null $returnType = null,
+ bool $isLocalActivity = false,
): PromiseInterface {
$request = $isLocalActivity ?
new ExecuteLocalActivity($name, EncodedValues::fromValues($args), $this->getOptionsArray(), $this->header) :
diff --git a/src/Internal/Workflow/ChildWorkflowProxy.php b/src/Internal/Workflow/ChildWorkflowProxy.php
index 62efd639c..534ffc556 100644
--- a/src/Internal/Workflow/ChildWorkflowProxy.php
+++ b/src/Internal/Workflow/ChildWorkflowProxy.php
@@ -11,11 +11,10 @@
namespace Temporal\Internal\Workflow;
-use React\Promise\PromiseInterface;
use Temporal\DataConverter\Type;
use Temporal\Internal\Declaration\Prototype\WorkflowPrototype;
use Temporal\Internal\Support\Reflection;
-use Temporal\Internal\Transport\CompletableResultInterface;
+use Temporal\Internal\Workflow\Process\Awaiter;
use Temporal\Workflow\ChildWorkflowOptions;
use Temporal\Workflow\ChildWorkflowStubInterface;
use Temporal\Workflow\WorkflowContextInterface;
@@ -49,10 +48,11 @@ public function __construct(
/**
* @param non-empty-string $method
- * @return CompletableResultInterface
*/
- public function __call(string $method, array $args): PromiseInterface
+ public function __call(string $method, array $args): mixed
{
+ Awaiter::assertManaged();
+
// If the proxy does not contain information about the running workflow,
// then we try to create a new stub from the workflow method and start
// the workflow.
@@ -90,7 +90,9 @@ public function __call(string $method, array $args): PromiseInterface
if ($definition->method->getName() === $method) {
$args = Reflection::orderArguments($definition->method, $args);
- return $this->stub->signal($name, $args);
+ $this->stub->signal($name, $args);
+
+ return null;
}
}
diff --git a/src/Internal/Workflow/ChildWorkflowStub.php b/src/Internal/Workflow/ChildWorkflowStub.php
index a918581f8..186c8d080 100644
--- a/src/Internal/Workflow/ChildWorkflowStub.php
+++ b/src/Internal/Workflow/ChildWorkflowStub.php
@@ -22,6 +22,7 @@
use Temporal\Internal\Transport\Request\ExecuteChildWorkflow;
use Temporal\Internal\Transport\Request\GetChildWorkflowExecution;
use Temporal\Internal\Transport\Request\SignalExternalWorkflow;
+use Temporal\Internal\Workflow\Process\Awaiter;
use Temporal\Worker\FeatureFlags;
use Temporal\Worker\Transport\Command\RequestInterface;
use Temporal\Workflow;
@@ -30,14 +31,18 @@
use Temporal\Workflow\ParentClosePolicy;
use Temporal\Workflow\WorkflowExecution;
+use function React\Promise\reject;
+
/**
* @psalm-import-type TType from Type
*/
final class ChildWorkflowStub implements ChildWorkflowStubInterface
{
private Deferred $execution;
- private ?ExecuteChildWorkflow $request = null;
private ?PromiseInterface $result = null;
+ private bool $started = false;
+ private bool $executionSettled = false;
+ private ?\Throwable $startFailure = null;
private HeaderInterface $header;
/**
@@ -58,50 +63,107 @@ public function getChildWorkflowType(): string
return $this->workflow;
}
- public function getExecution(): PromiseInterface
+ public function getExecution(): WorkflowExecution
+ {
+ $this->assertStarted();
+ Awaiter::assertManaged();
+
+ return Awaiter::await($this->getExecutionAsync(), interruptOnCancel: false);
+ }
+
+ public function getExecutionAsync(): PromiseInterface
{
+ $this->assertStarted();
+
return $this->execution->promise();
}
- public function start(... $args): PromiseInterface
+ public function start(...$args): WorkflowExecution
{
- if ($this->request !== null) {
+ Awaiter::assertManaged();
+
+ return Awaiter::await($this->startAsync(...$args), interruptOnCancel: false);
+ }
+
+ public function startAsync(...$args): PromiseInterface
+ {
+ if ($this->started) {
throw new \LogicException('Child workflow already has been executed');
}
- $this->request = new ExecuteChildWorkflow(
- $this->workflow,
- EncodedValues::fromValues($args),
- $this->getOptionArray(),
- $this->header,
- );
+ $this->started = true;
- $cancellable = FeatureFlags::$cancelAbandonedChildWorkflows
- || $this->options->parentClosePolicy !== ParentClosePolicy::Abandon->value;
+ try {
+ $request = new ExecuteChildWorkflow(
+ $this->workflow,
+ EncodedValues::fromValues($args),
+ $this->getOptionArray(),
+ $this->header,
+ );
- $this->result = $this->request($this->request, cancellable: $cancellable);
+ $cancellable = FeatureFlags::$cancelAbandonedChildWorkflows
+ || $this->options->parentClosePolicy !== ParentClosePolicy::Abandon->value;
+
+ $this->result = $this->request($request, cancellable: $cancellable);
+
+ $started = $this->request(new GetChildWorkflowExecution($request))
+ ->then(
+ function (ValuesInterface $values): mixed {
+ try {
+ $execution = $values->getValue(0, WorkflowExecution::class);
+ } catch (\Throwable $error) {
+ $this->failStart($error);
+ throw $error;
+ }
+
+ $this->resolveExecution($execution);
+
+ return $execution;
+ },
+ function (\Throwable $error): never {
+ $this->failStart($error);
+ throw $error;
+ },
+ );
+ } catch (\Throwable $error) {
+ $this->failStart($error);
+ throw $error;
+ }
- $started = $this->request(new GetChildWorkflowExecution($this->request))
- ->then(
- function (ValuesInterface $values): mixed {
- $execution = $values->getValue(0, WorkflowExecution::class);
- $this->execution->resolve($execution);
+ return EncodedValues::decodePromise($started);
+ }
- return $execution;
- },
- );
+ public function getResult($returnType = null): mixed
+ {
+ $this->assertStarted();
+ Awaiter::assertManaged();
- return EncodedValues::decodePromise($started);
+ return Awaiter::await($this->getResultAsync($returnType));
}
- public function getResult($returnType = null): PromiseInterface
+ public function getResultAsync($returnType = null): PromiseInterface
{
+ $this->assertStarted();
+
+ if ($this->startFailure !== null) {
+ return reject($this->startFailure);
+ }
+
+ \assert($this->result instanceof PromiseInterface);
+
return EncodedValues::decodePromise($this->result, $returnType);
}
- public function execute(array $args = [], $returnType = null): PromiseInterface
+ public function execute(array $args = [], $returnType = null): mixed
{
- return $this->start(...$args)->then(fn() => $this->getResult($returnType));
+ Awaiter::assertManaged();
+
+ return Awaiter::await($this->executeAsync($args, $returnType));
+ }
+
+ public function executeAsync(array $args = [], $returnType = null): PromiseInterface
+ {
+ return $this->startAsync(...$args)->then(fn() => $this->getResultAsync($returnType));
}
public function getOptions(): ChildWorkflowOptions
@@ -109,8 +171,18 @@ public function getOptions(): ChildWorkflowOptions
return $this->options;
}
- public function signal(string $name, array $args = []): PromiseInterface
+ public function signal(string $name, array $args = []): void
+ {
+ $this->assertStarted();
+ Awaiter::assertManaged();
+
+ Awaiter::await($this->signalAsync($name, $args), interruptOnCancel: false);
+ }
+
+ public function signalAsync(string $name, array $args = []): PromiseInterface
{
+ $this->assertStarted();
+
return $this->execution->promise()->then(
function (WorkflowExecution $execution) use ($name, $args) {
$request = new SignalExternalWorkflow(
@@ -136,4 +208,33 @@ private function getOptionArray(): array
{
return $this->marshaller->marshal($this->getOptions());
}
+
+ private function assertStarted(): void
+ {
+ if (!$this->started) {
+ throw new \LogicException('Child workflow has not been started');
+ }
+ }
+
+ private function resolveExecution(WorkflowExecution $execution): void
+ {
+ if ($this->executionSettled) {
+ return;
+ }
+
+ $this->executionSettled = true;
+ $this->execution->resolve($execution);
+ }
+
+ private function failStart(\Throwable $error): void
+ {
+ $this->startFailure ??= $error;
+
+ if ($this->executionSettled) {
+ return;
+ }
+
+ $this->executionSettled = true;
+ $this->execution->reject($error);
+ }
}
diff --git a/src/Internal/Workflow/ContinueAsNewProxy.php b/src/Internal/Workflow/ContinueAsNewProxy.php
index 09754e5f9..6de300bdc 100644
--- a/src/Internal/Workflow/ContinueAsNewProxy.php
+++ b/src/Internal/Workflow/ContinueAsNewProxy.php
@@ -11,9 +11,9 @@
namespace Temporal\Internal\Workflow;
-use React\Promise\PromiseInterface;
use Temporal\Internal\Declaration\Prototype\WorkflowPrototype;
use Temporal\Internal\Support\Reflection;
+use Temporal\Internal\Workflow\Process\Awaiter;
use Temporal\Workflow\ContinueAsNewOptions;
use Temporal\Workflow\WorkflowContextInterface;
@@ -54,11 +54,10 @@ public function __construct(
$this->context = $context;
}
- /**
- * @return PromiseInterface
- */
- public function __call(string $method, array $args)
+ public function __call(string $method, array $args): mixed
{
+ Awaiter::assertManaged();
+
if ($this->isContinued()) {
throw new \BadMethodCallException(
\sprintf(self::ERROR_ALREADY_CONTINUED, $this->workflow->getID()),
@@ -79,7 +78,10 @@ public function __call(string $method, array $args)
$args = Reflection::orderArguments($handler, $args);
}
- return $this->context->continueAsNew($this->workflow->getID(), $args, $this->options);
+ return Awaiter::await(
+ $this->context->continueAsNew($this->workflow->getID(), $args, $this->options),
+ interruptOnCancel: false,
+ );
}
private function isContinued(): bool
diff --git a/src/Internal/Workflow/ExternalWorkflowProxy.php b/src/Internal/Workflow/ExternalWorkflowProxy.php
index eac8944d5..a7cf100b0 100644
--- a/src/Internal/Workflow/ExternalWorkflowProxy.php
+++ b/src/Internal/Workflow/ExternalWorkflowProxy.php
@@ -11,7 +11,6 @@
namespace Temporal\Internal\Workflow;
-use React\Promise\PromiseInterface;
use Temporal\Internal\Declaration\Prototype\WorkflowPrototype;
use Temporal\Internal\Support\Reflection;
use Temporal\Workflow\ExternalWorkflowStubInterface;
@@ -49,13 +48,15 @@ public function __construct(string $class, WorkflowPrototype $workflow, External
$this->stub = $stub;
}
- public function __call(string $method, array $args): PromiseInterface
+ public function __call(string $method, array $args): mixed
{
foreach ($this->workflow->getSignalHandlers() as $name => $definition) {
if ($method === $definition->method->getName()) {
$args = Reflection::orderArguments($definition->method, $args);
- return $this->stub->signal($name, $args);
+ $this->stub->signal($name, $args);
+
+ return null;
}
}
diff --git a/src/Internal/Workflow/ExternalWorkflowStub.php b/src/Internal/Workflow/ExternalWorkflowStub.php
index 75b4ff374..876e70a9e 100644
--- a/src/Internal/Workflow/ExternalWorkflowStub.php
+++ b/src/Internal/Workflow/ExternalWorkflowStub.php
@@ -19,6 +19,7 @@
use Temporal\Internal\Interceptor\Pipeline;
use Temporal\Internal\Transport\Request\CancelExternalWorkflow;
use Temporal\Internal\Transport\Request\SignalExternalWorkflow;
+use Temporal\Internal\Workflow\Process\Awaiter;
use Temporal\Worker\Transport\Command\RequestInterface;
use Temporal\Workflow;
use Temporal\Workflow\ExternalWorkflowStubInterface;
@@ -39,7 +40,14 @@ public function getExecution(): WorkflowExecution
return $this->execution;
}
- public function signal(string $name, array $args = []): PromiseInterface
+ public function signal(string $name, array $args = []): void
+ {
+ Awaiter::assertManaged();
+
+ Awaiter::await($this->signalAsync($name, $args), interruptOnCancel: false);
+ }
+
+ public function signalAsync(string $name, array $args = []): PromiseInterface
{
return $this->callsInterceptor->with(
fn(SignalExternalWorkflowInput $input): PromiseInterface => $this
@@ -64,7 +72,14 @@ public function signal(string $name, array $args = []): PromiseInterface
));
}
- public function cancel(): PromiseInterface
+ public function cancel(): void
+ {
+ Awaiter::assertManaged();
+
+ Awaiter::await($this->cancelAsync(), interruptOnCancel: false);
+ }
+
+ public function cancelAsync(): PromiseInterface
{
return $this->callsInterceptor->with(
fn(CancelExternalWorkflowInput $input): PromiseInterface => $this
diff --git a/src/Internal/Workflow/Process/Awaiter.php b/src/Internal/Workflow/Process/Awaiter.php
new file mode 100644
index 000000000..c0bc80a5f
--- /dev/null
+++ b/src/Internal/Workflow/Process/Awaiter.php
@@ -0,0 +1,94 @@
+|null */
+ private static ?\WeakMap $managedFibers = null;
+
+ private function __construct() {}
+
+ /**
+ * @template T
+ * @param PromiseInterface $promise
+ * @return T
+ */
+ public static function await(PromiseInterface $promise, bool $interruptOnCancel = true): mixed
+ {
+ self::assertManaged();
+
+ $context = Workflow::getCurrentContext();
+
+ try {
+ /** @var T $result */
+ $result = \Fiber::suspend(new FiberSuspension($promise, $interruptOnCancel));
+ return $result;
+ } finally {
+ Workflow::setCurrentContext($context);
+ }
+ }
+
+ public static function assertManaged(): void
+ {
+ $fiber = \Fiber::getCurrent();
+
+ if ($fiber === null || self::$managedFibers === null || !isset(self::$managedFibers[$fiber])) {
+ $context = Workflow::getCurrentContext();
+
+ if ($context instanceof WorkflowContext) {
+ $context->assertWritable();
+ }
+
+ throw new InvalidSuspendException(
+ 'Temporal workflow APIs that suspend execution can only be called inside a managed workflow Fiber. '
+ . 'This one was called from a promise callback or another unmanaged context.',
+ );
+ }
+ }
+
+ public static function isManaged(): bool
+ {
+ $fiber = \Fiber::getCurrent();
+
+ return $fiber !== null && self::$managedFibers !== null && isset(self::$managedFibers[$fiber]);
+ }
+
+ public static function register(\Fiber $fiber): void
+ {
+ if (self::$managedFibers === null) {
+ /** @var \WeakMap<\Fiber, true> $fibers */
+ $fibers = new \WeakMap();
+ self::$managedFibers = $fibers;
+ }
+
+ self::$managedFibers[$fiber] = true;
+ }
+
+ public static function unregister(\Fiber $fiber): void
+ {
+ if (self::$managedFibers !== null) {
+ unset(self::$managedFibers[$fiber]);
+ }
+ }
+}
diff --git a/src/Internal/Workflow/Process/DeferredFiber.php b/src/Internal/Workflow/Process/DeferredFiber.php
new file mode 100644
index 000000000..29f0681f7
--- /dev/null
+++ b/src/Internal/Workflow/Process/DeferredFiber.php
@@ -0,0 +1,175 @@
+ */
+ private array $catchers = [];
+
+ private function __construct() {}
+
+ /**
+ * @param MethodHandler|\Closure(ValuesInterface): mixed $handler
+ */
+ public static function fromHandler(
+ MethodHandler|\Closure $handler,
+ ValuesInterface $values,
+ WorkflowContextInterface $context,
+ ): self {
+ $self = new self();
+ $self->fiber = new \Fiber(static function () use ($handler, $values, $context): mixed {
+ Workflow::setCurrentContext($context);
+
+ try {
+ $result = $handler($values);
+
+ if ($result instanceof \Generator) {
+ throw new InvalidSuspendException(
+ 'Generator workflow handlers are no longer supported. '
+ . 'Call Temporal workflow APIs directly instead of yielding them.',
+ );
+ }
+
+ if ($result instanceof PromiseInterface) {
+ throw new InvalidSuspendException(
+ 'Promise-returning workflow handlers are not supported. '
+ . 'Call direct workflow APIs, or await an async scope explicitly.',
+ );
+ }
+
+ return $result;
+ } finally {
+ Workflow::setCurrentContext(null);
+ }
+ });
+
+ return $self;
+ }
+
+ public function start(): mixed
+ {
+ if ($this->fiber->isStarted()) {
+ throw new \LogicException('Cannot start a workflow Fiber more than once.');
+ }
+
+ Awaiter::register($this->fiber);
+
+ try {
+ return $this->fiber->start();
+ } catch (\Throwable $e) {
+ $this->handleException($e);
+ } finally {
+ $this->unregisterIfTerminated();
+ }
+ }
+
+ public function resume(mixed $value): mixed
+ {
+ if (!$this->fiber->isSuspended()) {
+ throw new \LogicException('Cannot resume a workflow Fiber that is not suspended.');
+ }
+
+ try {
+ return $this->fiber->resume($value);
+ } catch (\Throwable $e) {
+ $this->handleException($e);
+ } finally {
+ $this->unregisterIfTerminated();
+ }
+ }
+
+ public function throw(\Throwable $exception): mixed
+ {
+ if (!$this->fiber->isSuspended()) {
+ throw new \LogicException('Cannot throw an exception into a workflow Fiber that is not suspended.');
+ }
+
+ try {
+ return $this->fiber->throw($exception);
+ } catch (\Throwable $e) {
+ $this->handleException($e);
+ } finally {
+ $this->unregisterIfTerminated();
+ }
+ }
+
+ public function isStarted(): bool
+ {
+ return $this->fiber->isStarted();
+ }
+
+ public function isSuspended(): bool
+ {
+ return $this->fiber->isSuspended();
+ }
+
+ public function isTerminated(): bool
+ {
+ return $this->fiber->isTerminated();
+ }
+
+ public function getReturn(): mixed
+ {
+ if (!$this->fiber->isTerminated()) {
+ throw new \LogicException('Cannot get the return value of a workflow Fiber that has not terminated.');
+ }
+
+ return $this->fiber->getReturn();
+ }
+
+ /**
+ * @param \Closure(\Throwable): mixed $handler
+ */
+ public function catch(callable $handler): self
+ {
+ $this->catchers[] = $handler(...);
+ return $this;
+ }
+
+ private function unregisterIfTerminated(): void
+ {
+ if ($this->fiber->isTerminated()) {
+ Awaiter::unregister($this->fiber);
+ }
+ }
+
+ private function handleException(\Throwable $e): never
+ {
+ foreach ($this->catchers as $catcher) {
+ try {
+ $catcher($e);
+ } catch (\Throwable) {
+ }
+ }
+
+ $this->catchers = [];
+
+ throw $e;
+ }
+}
diff --git a/src/Internal/Workflow/Process/DeferredGenerator.php b/src/Internal/Workflow/Process/DeferredGenerator.php
deleted file mode 100644
index f13138585..000000000
--- a/src/Internal/Workflow/Process/DeferredGenerator.php
+++ /dev/null
@@ -1,234 +0,0 @@
-
- *
- * @internal
- * @psalm-suppress PropertyNotSetInConstructor
- */
-final class DeferredGenerator implements \Iterator
-{
- private bool $started = false;
- private bool $finished = false;
- private \Generator $generator;
-
- /** @var array<\Closure(\Throwable): mixed> */
- private array $catchers = [];
-
- private \Closure|MethodHandler $handler;
- private ValuesInterface $values;
-
- private function __construct() {}
-
- /**
- * @param MethodHandler|\Closure(ValuesInterface): mixed $handler
- */
- public static function fromHandler(MethodHandler|\Closure $handler, ValuesInterface $values): self
- {
- $self = new self();
- $self->handler = $handler;
- $self->values = $values;
- return $self;
- }
-
- /**
- * @param \Generator $generator Started generator.
- */
- public static function fromGenerator(\Generator $generator): self
- {
- $self = new self();
- $self->generator = $generator;
- $self->started = true;
- return $self;
- }
-
- /**
- * Throw an exception into the generator.
- *
- * @note doesn't throw generator's exceptions; use {@see catch()} to handle them.
- */
- public function throw(\Throwable $exception): void
- {
- $this->started or throw new \LogicException('Cannot throw exception into a generator that was not started.');
- $this->finished and throw new \LogicException(
- 'Cannot throw exception into a generator that was already finished.',
- );
- try {
- $this->generator->throw($exception);
- } catch (\Throwable $e) {
- $this->handleException($e);
- }
- }
-
- /**
- * Send a value to the generator.
- *
- * @note doesn't throw generator's exceptions; use {@see catch()} to handle them.
- */
- public function send(mixed $value): mixed
- {
- $this->start();
- $this->finished and throw new \LogicException('Cannot send value to a generator that was already finished.');
- try {
- return $this->generator->send($value);
- } catch (\Throwable $e) {
- $this->handleException($e);
- }
- }
-
- /**
- * Get the return value of the generator if it was finished.
- */
- public function getReturn(): mixed
- {
- $this->finished or throw new \LogicException('Cannot get return value of a generator that was not finished.');
- try {
- return $this->generator->getReturn();
- } catch (\Throwable $e) {
- $this->handleException($e);
- }
- }
-
- /**
- * Get the current value of the generator.
- */
- public function current(): mixed
- {
- $this->start();
- try {
- return $this->generator->current();
- } catch (\Throwable $e) {
- $this->handleException($e);
- }
- }
-
- /**
- * Get the current key of the generator.
- */
- public function key(): mixed
- {
- $this->start();
- try {
- return $this->generator->key();
- } catch (\Throwable $e) {
- $this->handleException($e);
- }
- }
-
- /**
- * Start or resume the generator.
- */
- public function next(): void
- {
- if (!$this->started || $this->finished) {
- $this->finished or $this->start();
- return;
- }
-
- try {
- $this->generator->next();
- } catch (\Throwable $e) {
- $this->handleException($e);
- }
- }
-
- /**
- * Check if the generator is not finished.
- *
- * @note It starts the Generator.
- */
- public function valid(): bool
- {
- $this->start();
- try {
- $result = $this->generator->valid() or $this->finished = true;
- return $result;
- } catch (\Throwable $e) {
- $this->handleException($e);
- }
- }
-
- public function rewind(): void
- {
- $this->generator->rewind();
- }
-
- /**
- * Add an exception handler.
- *
- * @param \Closure(\Throwable): mixed $handler
- */
- public function catch(callable $handler): self
- {
- $this->catchers[] = $handler;
- return $this;
- }
-
- private static function getDummyGenerator(): \Generator
- {
- static $generator;
-
- if ($generator === null) {
- $generator = (static function (): \Generator {
- yield;
- })();
- $generator->current();
- }
-
- return $generator;
- }
-
- private function start(): void
- {
- if ($this->started) {
- return;
- }
-
- $this->started = true;
- try {
- $result = ($this->handler)($this->values);
-
- if ($result instanceof \Generator) {
- $this->generator = $result;
- return;
- }
-
- /** @psalm-suppress all */
- $this->generator = (static function (mixed $result): \Generator {
- return $result;
- yield;
- })($result);
- $this->finished = true;
- } catch (\Throwable $e) {
- $this->generator = self::getDummyGenerator();
- $this->handleException($e);
- } finally {
- unset($this->handler, $this->values);
- }
- }
-
- private function handleException(\Throwable $e): never
- {
- $this->finished and throw $e;
- $this->finished = true;
- foreach ($this->catchers as $catcher) {
- try {
- $catcher($e);
- } catch (\Throwable) {
- // Do nothing.
- }
- }
-
- $this->catchers = [];
- throw $e;
- }
-}
diff --git a/src/Internal/Workflow/Process/FiberSuspension.php b/src/Internal/Workflow/Process/FiberSuspension.php
new file mode 100644
index 000000000..2f15fd877
--- /dev/null
+++ b/src/Internal/Workflow/Process/FiberSuspension.php
@@ -0,0 +1,28 @@
+scopeContext);
$inboundPipeline->with(
function (UpdateInput $input) use ($handler): void {
- Workflow::setCurrentContext($this->scopeContext->withInput(
+ $context = $this->scopeContext->withInput(
new Input(
$this->scopeContext->getInfo(),
$input->arguments,
$input->header,
),
- ));
+ );
+ Workflow::setCurrentContext($context);
$handler($input->arguments);
},
/** @see WorkflowInboundCallsInterceptor::validateUpdate() */
@@ -129,39 +131,45 @@ static function () use ($handler, $inboundPipeline, $input): mixed {
// Configure signal handler
$workflowInstance->getSignalDispatcher()->onSignal(
function (string $name, callable $handler, ValuesInterface $arguments) use ($inboundPipeline): void {
+ $previous = Facade::getCurrentContext();
+
// Define Context for interceptors Pipeline
Workflow::setCurrentContext($this->scopeContext);
- $inboundPipeline->with(
- function (SignalInput $input) use ($handler): void {
- $this->createScope(
- true,
- LoopInterface::ON_SIGNAL,
- $this->context->withInput(
- new Input($input->info, $input->arguments, $input->header),
- ),
- )->onClose(
- function (?\Throwable $error): void {
- if ($error !== null) {
- // Fail process when signal scope fails
- $this->complete($error);
- }
- },
- )->startSignal(
- $handler,
- $input->arguments,
- $input->signalName,
- );
- },
- /** @see WorkflowInboundCallsInterceptor::handleSignal() */
- 'handleSignal',
- )(new SignalInput(
- $name,
- $this->scopeContext->getInfo(),
- $arguments,
- $this->scopeContext->getHeader(),
- $this->scopeContext->isReplaying(),
- ));
+ try {
+ $inboundPipeline->with(
+ function (SignalInput $input) use ($handler): void {
+ $this->createScope(
+ true,
+ LoopInterface::ON_SIGNAL,
+ $this->context->withInput(
+ new Input($input->info, $input->arguments, $input->header),
+ ),
+ )->onClose(
+ function (?\Throwable $error): void {
+ if ($error !== null) {
+ // Fail process when signal scope fails
+ $this->complete($error);
+ }
+ },
+ )->startSignal(
+ $handler,
+ $input->arguments,
+ $input->signalName,
+ );
+ },
+ /** @see WorkflowInboundCallsInterceptor::handleSignal() */
+ 'handleSignal',
+ )(new SignalInput(
+ $name,
+ $this->scopeContext->getInfo(),
+ $arguments,
+ $this->scopeContext->getHeader(),
+ $this->scopeContext->isReplaying(),
+ ));
+ } finally {
+ Workflow::setCurrentContext($previous);
+ }
},
);
@@ -240,7 +248,8 @@ function (WorkflowInput $input) use ($context, $arguments, $handler, $deferred):
));
} catch (\Throwable $e) {
isset($this->context) or $this->setContext($context);
- $context->setReadonly(false);
+ $this->context->setReadonly(false);
+ $this->scopeContext->setReadonly(false);
$this->complete($e);
} finally {
Workflow::setCurrentContext(null);
@@ -341,7 +350,7 @@ private function logRunningHandlers(string $happened = 'finished'): void
'This may have interrupted work that the update handler was doing, and the client ' .
'that sent the update will receive a \'workflow execution already completed\' RPCError ' .
'instead of the update result. You can wait for all update and signal handlers ' .
- 'to complete by using `yield Workflow::await(Workflow::allHandlersFinished(...));`. ' .
+ 'to complete by using `Workflow::await(fn() => Workflow::allHandlersFinished());`. ' .
'Alternatively, if both you and the clients sending the update are okay with interrupting ' .
'running handlers when the workflow finishes, and causing clients to receive errors, ' .
'then you can disable this warning via the update handler attribute: ' .
@@ -360,7 +369,7 @@ private function logRunningHandlers(string $happened = 'finished'): void
$message = "Workflow `$workflowName` $happened while signal handlers are still running. " .
'This may have interrupted work that the signal handler was doing. ' .
'You can wait for all update and signal handlers to complete by using ' .
- '`yield Workflow::await(Workflow::allHandlersFinished(...));`. ' .
+ '`Workflow::await(fn() => Workflow::allHandlersFinished());`. ' .
'Alternatively, if both you and the clients sending the signal are okay ' .
'with interrupting running handlers when the workflow finishes, ' .
'and causing clients to receive errors, then you can disable this warning via the signal ' .
diff --git a/src/Internal/Workflow/Process/Scope.php b/src/Internal/Workflow/Process/Scope.php
index 8981b3161..b21ac4565 100644
--- a/src/Internal/Workflow/Process/Scope.php
+++ b/src/Internal/Workflow/Process/Scope.php
@@ -20,9 +20,11 @@
use Temporal\Exception\Failure\CanceledFailure;
use Temporal\Exception\Failure\TemporalFailure;
use Temporal\Exception\InvalidArgumentException;
+use Temporal\Exception\InvalidSuspendException;
use Temporal\Interceptor\WorkflowInbound\UpdateInput;
use Temporal\Internal\Declaration\MethodHandler;
use Temporal\Internal\ServiceContainer;
+use Temporal\Internal\Support\Facade;
use Temporal\Internal\Transport\Request\Cancel;
use Temporal\Internal\Workflow\ScopeContext;
use Temporal\Internal\Workflow\WorkflowContext;
@@ -33,8 +35,6 @@
use Temporal\Workflow\CancellationScopeInterface;
/**
- * Unlike Java implementation, PHP has merged coroutine and cancellation scope into a single instance.
- *
* @internal CoroutineScope is an internal library class, please do not use it in your code.
* @psalm-internal Temporal\Internal
* @implements CancellationScopeInterface
@@ -43,53 +43,36 @@ class Scope implements CancellationScopeInterface, Destroyable
{
protected ServiceContainer $services;
- /**
- * Workflow context.
- *
- * @psalm-suppress PropertyNotSetInConstructor
- */
+ /** @psalm-suppress PropertyNotSetInConstructor */
protected WorkflowContext $context;
- /**
- * Coroutine scope context.
- *
- * @psalm-suppress PropertyNotSetInConstructor
- */
+ /** @psalm-suppress PropertyNotSetInConstructor */
protected ScopeContext $scopeContext;
protected Deferred $deferred;
+ protected DeferredFiber $coroutine;
- /**
- * Worker handler generator that yields promises and requests that are processed in the {@see self::next()} method.
- */
- protected DeferredGenerator $coroutine;
-
- /**
- * Every coroutine runs on its own loop layer.
- *
- * @var non-empty-string
- */
+ /** @var non-empty-string */
private string $layer = LoopInterface::ON_TICK;
- /**
- * Each onCancel receives unique ID.
- */
private int $cancelID = 0;
- /**
- * @var array
- */
+ /** @var array */
private array $onCancel = [];
- /**
- * @var array
- */
+ /** @var array */
private array $onClose = [];
+ /** @var array */
+ private array $children = [];
+
private bool $detached = false;
private bool $cancelled = false;
private bool $closed = false;
+ private bool $ownsContext = true;
+ private bool $skipInvalidArguments = false;
private ?\Throwable $cancelReason = null;
+ private ?int $suspensionCancelID = null;
public function __construct(
ServiceContainer $services,
@@ -126,8 +109,7 @@ public function getContext(): WorkflowContext
*/
public function start(MethodHandler|\Closure $handler, ValuesInterface $values, bool $deferred): void
{
- // Create a coroutine generator
- $this->coroutine = DeferredGenerator::fromHandler($handler, $values)
+ $this->coroutine = DeferredFiber::fromHandler($handler, $values, $this->scopeContext)
->catch($this->onException(...));
$deferred
@@ -141,7 +123,6 @@ public function start(MethodHandler|\Closure $handler, ValuesInterface $values,
*/
public function startUpdate(callable $handler, UpdateInput $input, Deferred $resolver): void
{
- // Update handler counter
$id = $this->context->getHandlerState()->addUpdate($input->updateId, $input->updateName);
$this->then(
fn() => $this->context->getHandlerState()->removeUpdate($id),
@@ -157,7 +138,6 @@ function (\Throwable $error) use ($resolver): void {
},
);
- // Create a coroutine generator
$this->coroutine = $this->callSignalOrUpdateHandler($handler, $input->arguments);
$this->next();
}
@@ -168,30 +148,16 @@ function (\Throwable $error) use ($resolver): void {
*/
public function startSignal(callable $handler, ValuesInterface $values, string $name): void
{
- // Update handler counter
$id = $this->context->getHandlerState()->addSignal($name);
$this->then(
fn() => $this->context->getHandlerState()->removeSignal($id),
fn() => $this->context->getHandlerState()->removeSignal($id),
);
- // Create a coroutine generator
$this->coroutine = $this->callSignalOrUpdateHandler($handler, $values);
$this->next();
}
- /**
- * @return $this
- */
- public function attach(\Generator $generator): self
- {
- $this->coroutine = DeferredGenerator::fromGenerator($generator)
- ->catch($this->onException(...));
-
- $this->next();
- return $this;
- }
-
public function onCancel(callable $then): self
{
$this->addOnCancel($then);
@@ -210,22 +176,23 @@ public function onClose(callable $then): self
public function cancel(?\Throwable $reason = null): void
{
- if ($this->detached && !$reason instanceof DestructMemorizedInstanceException) {
- // detaches scopes can be offload via memory flush
- return;
- }
-
- if ($this->cancelled) {
+ if ($this->cancelled || $this->closed) {
return;
}
$this->cancelled = true;
$this->cancelReason = $reason;
- foreach ($this->onCancel as $i => $handler) {
- $this->makeCurrent();
- unset($this->onCancel[$i]);
- $handler($reason);
+ $savedContext = Facade::getCurrentContext();
+
+ try {
+ foreach ($this->orderedCancelHandlers() as $i => $handler) {
+ $this->makeCurrent();
+ unset($this->onCancel[$i]);
+ $handler($reason);
+ }
+ } finally {
+ Workflow::setCurrentContext($savedContext);
}
}
@@ -234,8 +201,14 @@ public function cancel(?\Throwable $reason = null): void
*/
public function startScope(callable $handler, bool $detached, ?string $layer = null): CancellationScopeInterface
{
+ $savedContext = Facade::getCurrentContext();
$scope = $this->createScope($detached, $layer);
- $scope->start($handler(...), EncodedValues::empty(), false);
+
+ try {
+ $scope->start($handler(...), EncodedValues::empty(), false);
+ } finally {
+ Workflow::setCurrentContext($savedContext);
+ }
return $scope;
}
@@ -245,6 +218,11 @@ public function promise(): PromiseInterface
return $this->deferred->promise();
}
+ public function await(): mixed
+ {
+ return Awaiter::await($this, interruptOnCancel: false);
+ }
+
public function then(
?callable $onFulfilled = null,
?callable $onRejected = null,
@@ -288,7 +266,6 @@ public function onAwait(Deferred $deferred): void
$deferred->reject($e ?? new CanceledFailure(''));
});
- // do not cancel already complete promises
$cleanup = function () use ($cancelID): void {
$this->makeCurrent();
$this->context->resolveConditions();
@@ -300,8 +277,26 @@ public function onAwait(Deferred $deferred): void
public function destroy(): void
{
- $this->context?->destroy();
- $this->scopeContext?->destroy();
+ $children = $this->children;
+ $this->children = [];
+
+ foreach ($children as $child) {
+ $child->destroy();
+ }
+
+ /** @psalm-suppress RedundantPropertyInitializationCheck, RedundantCondition */
+ if (isset($this->coroutine) && $this->coroutine->isSuspended()) {
+ try {
+ $this->coroutine->throw(new DestructMemorizedInstanceException());
+ } catch (\Throwable) {
+ }
+ }
+
+ if ($this->ownsContext) {
+ $this->context?->destroy();
+ $this->scopeContext?->destroy();
+ }
+
unset(
$this->coroutine,
$this->context,
@@ -325,16 +320,18 @@ protected function createScope(
$scope = new Scope($this->services);
$scope->setContext($context ?? $this->context, $updateContext);
$scope->detached = $detached;
+ $scope->ownsContext = false;
if ($layer !== null) {
$scope->layer = $layer;
}
- $cancelID = $this->addOnCancel($scope->cancel(...));
+ $cancelID = $this->addOnCancel($scope->cancelFromParent(...));
+ $this->children[$cancelID] = $scope;
$scope->onClose(
function () use ($cancelID): void {
- unset($this->onCancel[$cancelID]);
+ unset($this->onCancel[$cancelID], $this->children[$cancelID]);
},
);
@@ -357,16 +354,12 @@ protected function setContext(WorkflowContext $ctx, ?Workflow\UpdateContext $upd
*
* @param callable(ValuesInterface): mixed $handler
*/
- protected function callSignalOrUpdateHandler(callable $handler, ValuesInterface $values): DeferredGenerator
+ protected function callSignalOrUpdateHandler(callable $handler, ValuesInterface $values): DeferredFiber
{
- return DeferredGenerator::fromHandler(static function (ValuesInterface $values) use ($handler): mixed {
- try {
- return $handler($values);
- } catch (InvalidArgumentException) {
- // Skip deserialization errors
- return null;
- }
- }, $values)->catch($this->onException(...));
+ $this->skipInvalidArguments = true;
+
+ return DeferredFiber::fromHandler($handler(...), $values, $this->scopeContext)
+ ->catch($this->onSignalOrUpdateException(...));
}
protected function onRequest(RequestInterface $request, PromiseInterface $promise, bool $cancellable = true): void
@@ -374,7 +367,6 @@ protected function onRequest(RequestInterface $request, PromiseInterface $promis
$cancelID = $this->addOnCancel(function (?\Throwable $reason = null) use ($request, $cancellable): void {
$client = $this->context->getClient();
if ($reason instanceof DestructMemorizedInstanceException) {
- // memory flush
$client->reject($request, $reason);
return;
}
@@ -385,14 +377,12 @@ protected function onRequest(RequestInterface $request, PromiseInterface $promis
}
if (!$cancellable) {
- // non-cancellable request
return;
}
$client->request(new Cancel($request->getID()), $this->scopeContext);
}, $cancellable);
- // do not cancel already complete promises
$cleanup = function () use ($cancelID): void {
$this->makeCurrent();
$this->context->resolveConditions();
@@ -410,52 +400,55 @@ protected function makeCurrent(): void
protected function next(): void
{
$this->makeCurrent();
- begin:
$this->context->resolveConditions();
try {
- if (!$this->coroutine->valid()) {
- $this->onResult($this->coroutine->getReturn());
- return;
- }
+ $suspended = $this->coroutine->start();
} catch (\Throwable) {
- $this->onResult(null);
return;
}
- $current = $this->coroutine->current();
+ $this->advance($suspended);
+ }
+
+ private function advance(mixed $suspended): void
+ {
+ $this->skipInvalidArguments = false;
+ $this->makeCurrent();
$this->context->resolveConditions();
- switch (true) {
- case $current instanceof Workflow\Mutex:
- $this->nextPromise($this->context->await($current));
- break;
-
- case $current instanceof PromiseInterface:
- $this->nextPromise($current);
- break;
-
- case $current instanceof Deferred:
- $this->nextPromise($current->promise());
- break;
-
- // todo ->context or ->scopeContext?
- case $current instanceof RequestInterface:
- $this->nextPromise($this->context->getClient()->request($current, $this->scopeContext));
- break;
-
- case $current instanceof \Generator:
- $this->nextPromise($this->createScope(false)->attach($current));
- break;
-
- default:
- try {
- $this->coroutine->send($current);
- } catch (\Throwable) {
- // Ignore
- }
- goto begin;
+ if ($this->coroutine->isTerminated()) {
+ try {
+ $this->onResult($this->coroutine->getReturn());
+ } catch (\Throwable $e) {
+ $this->onException($e);
+ }
+ return;
}
+
+ if (!$suspended instanceof FiberSuspension) {
+ $type = \get_debug_type($suspended);
+ $this->onException(new InvalidSuspendException(
+ "A workflow Fiber suspended with a value of type `$type` that is not part of the workflow " .
+ 'suspension protocol. This usually means a non-workflow asynchronous API was called inside ' .
+ 'the workflow body. Use the Temporal workflow API instead.',
+ ));
+ return;
+ }
+
+ $this->nextPromise($suspended->promise, $suspended->interruptOnCancel);
+ }
+
+ private function orderedCancelHandlers(): array
+ {
+ $handlers = $this->onCancel;
+ $suspensionID = $this->suspensionCancelID;
+
+ if ($suspensionID === null || !isset($handlers[$suspensionID])) {
+ return $handlers;
+ }
+
+ return [$suspensionID => $handlers[$suspensionID]] + $handlers;
}
private function addOnCancel(callable $handler, bool $cancellable = true): int
@@ -463,8 +456,15 @@ private function addOnCancel(callable $handler, bool $cancellable = true): int
$id = ++$this->cancelID;
if (FeatureFlags::$propagateCancellationToNewScopes && $this->cancelled && $cancellable) {
- $this->makeCurrent();
- $handler($this->cancelReason);
+ $savedContext = Facade::getCurrentContext();
+
+ try {
+ $this->makeCurrent();
+ $handler($this->cancelReason);
+ } finally {
+ Workflow::setCurrentContext($savedContext);
+ }
+
return $id;
}
@@ -472,7 +472,7 @@ private function addOnCancel(callable $handler, bool $cancellable = true): int
return $id;
}
- private function nextPromise(PromiseInterface $promise): void
+ private function nextPromise(PromiseInterface $promise, bool $interruptOnCancel): void
{
if ($promise instanceof CancellationScopeInterface && $promise->isCancelled()) {
$reason = FeatureFlags::$propagateCancellationToNewScopes && $promise instanceof self
@@ -482,24 +482,62 @@ private function nextPromise(PromiseInterface $promise): void
return;
}
- $onFulfilled = function (mixed $result): mixed {
+ $settled = false;
+ $cancelID = null;
+
+ if ($interruptOnCancel) {
+ $cancelID = $this->addOnCancel(function (?\Throwable $reason = null) use (&$settled): void {
+ if ($settled) {
+ return;
+ }
+
+ $settled = true;
+ $this->handleError($reason ?? new CanceledFailure(''));
+ });
+ $this->suspensionCancelID = $cancelID;
+ }
+
+ $cleanup = function () use (&$cancelID): void {
+ if ($cancelID !== null) {
+ unset($this->onCancel[$cancelID]);
+ if ($this->suspensionCancelID === $cancelID) {
+ $this->suspensionCancelID = null;
+ }
+ $cancelID = null;
+ }
+ };
+
+ $onFulfilled = function (mixed $result) use (&$settled, $cleanup): mixed {
+ if ($settled) {
+ return $result;
+ }
+
+ $settled = true;
+ $cleanup();
$this->defer(
function () use ($result): void {
$this->makeCurrent();
+
try {
- $this->coroutine->send($result);
- $this->next();
- } catch (\Throwable $e) {
- $this->onException($e);
+ $suspended = $this->coroutine->resume($result);
+ } catch (\Throwable) {
return;
}
+
+ $this->advance($suspended);
},
);
return $result;
};
- $onRejected = function (\Throwable $e): void {
+ $onRejected = function (\Throwable $e) use (&$settled, $cleanup): void {
+ if ($settled) {
+ throw $e;
+ }
+
+ $settled = true;
+ $cleanup();
$this->defer(
function () use ($e): void {
if ($e instanceof TemporalFailure && !$e->hasOriginalStackTrace()) {
@@ -515,7 +553,6 @@ function () use ($e): void {
$promise
->then($onFulfilled, $onRejected)
- // Handle last error
->then(null, static fn(\Throwable $e) => null);
}
@@ -529,13 +566,23 @@ private function handleError(\Throwable $e): void
$this->makeCurrent();
try {
- $this->coroutine->throw($e);
- } catch (\Throwable $e) {
- $this->onException($e);
+ $suspended = $this->coroutine->throw($e);
+ } catch (\Throwable) {
return;
}
- $this->next();
+ $this->advance($suspended);
+ }
+
+ private function onSignalOrUpdateException(\Throwable $e): void
+ {
+ if ($this->skipInvalidArguments && $e instanceof InvalidArgumentException) {
+ $this->skipInvalidArguments = false;
+ $this->onResult(null);
+ return;
+ }
+
+ $this->onException($e);
}
private function onException(\Throwable $e): void
@@ -545,14 +592,11 @@ private function onException(\Throwable $e): void
}
$this->closed = true;
- $this->deferred->reject($e);
-
$this->makeCurrent();
+ $this->deferred->reject($e);
$this->context->resolveConditions();
- foreach ($this->onClose as $close) {
- $close($e);
- }
+ $this->releaseExecutionState($e);
}
private function onResult(mixed $result): void
@@ -562,19 +606,46 @@ private function onResult(mixed $result): void
}
$this->closed = true;
- $this->deferred->resolve($result);
-
$this->makeCurrent();
+ $this->deferred->resolve($result);
$this->context->resolveConditions();
- foreach ($this->onClose as $close) {
- $close($result);
+ $this->releaseExecutionState($result);
+ }
+
+ private function releaseExecutionState(mixed $result): void
+ {
+ $onClose = $this->onClose;
+ $this->onClose = [];
+ $this->onCancel = [];
+ unset($this->coroutine);
+
+ try {
+ foreach ($onClose as $close) {
+ $close($result);
+ }
+ } finally {
+ if (!$this->ownsContext) {
+ $this->scopeContext->releaseScope();
+ }
}
}
private function defer(\Closure $tick): void
{
$this->services->loop->once($this->layer, $tick);
- $this->services->queue->count() === 0 and $this->services->loop->tick();
+
+ if ($this->services->queue->count() === 0) {
+ $this->services->loop->tick();
+ }
+ }
+
+ private function cancelFromParent(?\Throwable $reason = null): void
+ {
+ if ($this->detached && !$reason instanceof DestructMemorizedInstanceException) {
+ return;
+ }
+
+ $this->cancel($reason);
}
}
diff --git a/src/Internal/Workflow/ScopeContext.php b/src/Internal/Workflow/ScopeContext.php
index b9a3ebab4..133b7c541 100644
--- a/src/Internal/Workflow/ScopeContext.php
+++ b/src/Internal/Workflow/ScopeContext.php
@@ -102,6 +102,11 @@ public function getUpdateContext(): ?UpdateContext
return $this->updateContext;
}
+ public function releaseScope(): void
+ {
+ unset($this->scope, $this->onRequest);
+ }
+
public function resolveConditions(): void
{
$this->parent->resolveConditions();
@@ -121,7 +126,8 @@ public function rejectConditionGroup(string $conditionGroupId): void
public function destroy(): void
{
parent::destroy();
- unset($this->scope, $this->parent, $this->onRequest);
+ $this->releaseScope();
+ unset($this->parent);
}
protected function addCondition(string $conditionGroupId, callable $condition): PromiseInterface
diff --git a/src/Internal/Workflow/WorkflowContext.php b/src/Internal/Workflow/WorkflowContext.php
index f32ed58e9..1a98beaa3 100644
--- a/src/Internal/Workflow/WorkflowContext.php
+++ b/src/Internal/Workflow/WorkflowContext.php
@@ -170,6 +170,13 @@ public function getInput(): ValuesInterface
return $this->input->input;
}
+ public function assertWritable(): void
+ {
+ if ($this->readonly) {
+ throw new \RuntimeException('Workflow is not initialized.');
+ }
+ }
+
public function setReadonly(bool $value = true): static
{
$this->readonly = $value;
@@ -371,7 +378,7 @@ public function executeChildWorkflow(
return $this->callsInterceptor->with(
fn(ExecuteChildWorkflowInput $input): PromiseInterface => $this
->newUntypedChildWorkflowStub($input->type, $input->options)
- ->execute($input->args, $input->returnType),
+ ->executeAsync($input->args, $input->returnType),
/** @see WorkflowOutboundCallsInterceptor::executeChildWorkflow() */
'executeChildWorkflow',
)(new ExecuteChildWorkflowInput($type, $args, $options, $returnType));
@@ -428,14 +435,14 @@ public function executeActivity(
? $this->callsInterceptor->with(
fn(ExecuteLocalActivityInput $input): PromiseInterface => $this
->newUntypedActivityStub($input->options)
- ->execute($input->type, $input->args, $input->returnType, true),
+ ->executeAsync($input->type, $input->args, $input->returnType, true),
/** @see WorkflowOutboundCallsInterceptor::executeLocalActivity() */
'executeLocalActivity',
)(new ExecuteLocalActivityInput($type, $args, $options, $returnType))
: $this->callsInterceptor->with(
fn(ExecuteActivityInput $input): PromiseInterface => $this
->newUntypedActivityStub($input->options)
- ->execute($input->type, $input->args, $input->returnType),
+ ->executeAsync($input->type, $input->args, $input->returnType),
/** @see WorkflowOutboundCallsInterceptor::executeActivity() */
'executeActivity',
)(new ExecuteActivityInput($type, $args, $options, $returnType));
@@ -491,7 +498,7 @@ public function request(
bool $cancellable = true,
bool $waitResponse = true,
): PromiseInterface {
- $this->readonly and throw new \RuntimeException('Workflow is not initialized.');
+ $this->assertWritable();
$this->recordTrace();
// Intercept workflow outbound calls
@@ -772,8 +779,9 @@ protected function awaitRequest(callable|Mutex|PromiseInterface ...$conditions):
$this->recordTrace();
foreach ($conditions as $condition) {
- // Wrap Mutex into callable
- $condition instanceof Mutex and $condition = static fn(): bool => !$condition->isLocked();
+ if ($condition instanceof Mutex) {
+ $condition = static fn(): bool => !$condition->isLocked();
+ }
if ($condition instanceof \Closure) {
$callableResult = $condition($conditionGroupId);
diff --git a/src/Promise.php b/src/Promise.php
index 96751cb3a..757eb7236 100644
--- a/src/Promise.php
+++ b/src/Promise.php
@@ -166,7 +166,11 @@ public static function map(iterable $promises, callable $map): PromiseInterface
static function (callable $resolve, callable $reject) use ($promises, $map, $cancellationQueue): void {
resolve($promises)
->then(static function (iterable $array) use ($map, $cancellationQueue, $resolve, $reject): void {
- if (!\is_array($array) || !$array) {
+ if (!\is_array($array)) {
+ $array = \iterator_to_array($array);
+ }
+
+ if (!$array) {
$resolve([]);
return;
}
@@ -191,7 +195,8 @@ static function (mixed $mapped) use ($i, &$values, &$toResolve, $resolve): void
$reject,
);
}
- }, $reject);
+ }, $reject)
+ ->then(null, $reject);
},
$cancellationQueue,
);
@@ -225,7 +230,7 @@ static function (iterable $array) use (
$reject,
): void {
if (!\is_array($array)) {
- $array = [];
+ $array = \iterator_to_array($array);
}
$total = \count($array);
@@ -256,7 +261,8 @@ static function (iterable $array) use (
->then($resolve, $reject);
},
$reject,
- );
+ )
+ ->then(null, $reject);
},
$cancellationQueue,
);
diff --git a/src/Worker/FeatureFlags.php b/src/Worker/FeatureFlags.php
index f4e15eabd..8c5726ecd 100644
--- a/src/Worker/FeatureFlags.php
+++ b/src/Worker/FeatureFlags.php
@@ -68,16 +68,17 @@ final class FeatureFlags
/**
* Make scope cancellation sticky: a nested scope, an await or an onCancel handler registered
* after the surrounding scope was already cancelled is notified immediately instead of being
- * missed. Set to TRUE to enable this behavior.
+ * missed. TRUE by default.
*
- * When FALSE (default), the previous behavior is kept: cancel handlers registered after the
- * cancellation are never invoked.
+ * Set to FALSE to restore the previous behavior, where cancel handlers registered after the
+ * cancellation are never invoked. Note that a workflow awaiting on a promise while its scope
+ * is being cancelled may then hang forever.
*
* @experimental
* @since SDK 2.18.0
* @link https://github.com/temporalio/sdk-php/issues/769
*/
- public static bool $propagateCancellationToNewScopes = false;
+ public static bool $propagateCancellationToNewScopes = true;
/**
* Unblock multi-condition {@see Workflow::await()} / {@see Workflow::awaitWithTimeout()} on the
diff --git a/src/WorkerFactory.php b/src/WorkerFactory.php
index c64245815..827f5bf7e 100644
--- a/src/WorkerFactory.php
+++ b/src/WorkerFactory.php
@@ -27,6 +27,7 @@
use Temporal\Interceptor\PipelineProvider;
use Temporal\Interceptor\SimplePipelineProvider;
use Temporal\Internal\Events\EventEmitterTrait;
+use Temporal\Internal\Workflow\Process\Awaiter;
use Temporal\Internal\Interceptor\Pipeline;
use Temporal\Plugin\CompositePipelineProvider;
use Temporal\Plugin\PluginInterface;
@@ -279,6 +280,12 @@ public function tick(): void
{
$this->emit(LoopInterface::ON_SIGNAL);
$this->emit(LoopInterface::ON_CALLBACK);
+
+ if (Awaiter::isManaged()) {
+ $this->emit(LoopInterface::ON_TICK);
+ return;
+ }
+
$this->emit(LoopInterface::ON_QUERY);
$this->emit(LoopInterface::ON_TICK);
$this->emit(LoopInterface::ON_FINALLY);
diff --git a/src/Workflow.php b/src/Workflow.php
index 6a303c793..85e3ae2c6 100644
--- a/src/Workflow.php
+++ b/src/Workflow.php
@@ -28,6 +28,8 @@
use Temporal\Internal\Workflow\ChildWorkflowProxy;
use Temporal\Internal\Workflow\ContinueAsNewProxy;
use Temporal\Internal\Workflow\ExternalWorkflowProxy;
+use Temporal\Internal\Workflow\Process\Awaiter;
+use Temporal\Worker\FeatureFlags;
use Temporal\Workflow\ActivityStubInterface;
use Temporal\Workflow\CancellationScopeInterface;
use Temporal\Workflow\ChildWorkflowOptions;
@@ -178,21 +180,25 @@ public static function getInput(): ValuesInterface
* public function handler()
* {
* // Create the new "group" of executions
- * $promise = Workflow::async(function() {
- * $first = yield Workflow::executeActivity('first');
- * $second = yield Workflow::executeActivity('second');
- *
- * return yield Promise::all([$first, $second]);
+ * $scope = Workflow::async(function() {
+ * $first = Workflow::async(
+ * fn() => Workflow::executeActivity('first'),
+ * );
+ * $second = Workflow::async(
+ * fn() => Workflow::executeActivity('second'),
+ * );
+ *
+ * return Workflow::all([$first, $second]);
* });
*
* // Waiting for the execution result
- * yield $promise;
+ * $result = Workflow::await($scope);
*
* // Or cancel all group requests (activity executions)
- * $promise->cancel();
+ * $scope->cancel();
*
* // Or get information about the execution of the group
- * $promise->isCancelled();
+ * $scope->isCancelled();
* }
* ```
*
@@ -200,13 +206,14 @@ public static function getInput(): ValuesInterface
* asynchronous task in {@see CancellationScopeInterface} interface.
*
* @template TReturn
- * @param callable(): (TReturn|\Generator) $task
+ * @param callable(): TReturn $task
* @return CancellationScopeInterface
*
* @throws OutOfContextException in the absence of the workflow execution context.
*/
public static function async(callable $task): CancellationScopeInterface
{
+ Awaiter::assertManaged();
$ctx = self::getCurrentContext();
\assert($ctx instanceof ScopedContextInterface);
return $ctx->async($task);
@@ -254,18 +261,65 @@ public static function async(callable $task): CancellationScopeInterface
* Use asyncDetached to handle cleanup and compensation logic.
*
* @template TReturn
- * @param callable(): (TReturn|\Generator) $task
+ * @param callable(): TReturn $task
* @return CancellationScopeInterface
*
* @throws OutOfContextException in the absence of the workflow execution context.
*/
public static function asyncDetached(callable $task): CancellationScopeInterface
{
+ Awaiter::assertManaged();
$ctx = self::getCurrentContext();
\assert($ctx instanceof ScopedContextInterface);
return $ctx->asyncDetached($task);
}
+ /**
+ * Suspend until all workflow tasks complete.
+ *
+ * ```php
+ * [$first, $second] = Workflow::all([
+ * Workflow::async(fn() => Workflow::executeActivity('first')),
+ * Workflow::async(fn() => Workflow::executeActivity('second')),
+ * ]);
+ * ```
+ *
+ * @template T
+ * @param iterable|T> $tasks
+ * @return array
+ */
+ public static function all(iterable $tasks): array
+ {
+ Awaiter::assertManaged();
+ return Awaiter::await(Promise::all($tasks));
+ }
+
+ /**
+ * Suspend until the first workflow task completes successfully.
+ *
+ * @template T
+ * @param iterable|T> $tasks
+ * @return T
+ */
+ public static function any(iterable $tasks): mixed
+ {
+ Awaiter::assertManaged();
+ return Awaiter::await(Promise::any($tasks));
+ }
+
+ /**
+ * Suspend until the first workflow task settles, whether it is fulfilled or rejected.
+ *
+ * @template T
+ * @param iterable|T> $tasks
+ * @return T
+ */
+ public static function race(iterable $tasks): mixed
+ {
+ Awaiter::assertManaged();
+ return Awaiter::await(Promise::race($tasks));
+ }
+
/**
* Moves to the next step if the expression evaluates to `true`.
*
@@ -276,8 +330,8 @@ public static function asyncDetached(callable $task): CancellationScopeInterface
* #[WorkflowMethod]
* public function handler()
* {
- * yield Workflow::await(
- * Workflow::executeActivity('shouldByContinued')
+ * Workflow::await(
+ * Workflow::async(fn() => Workflow::executeActivity('shouldByContinued'))
* );
*
* // ...do something
@@ -293,7 +347,7 @@ public static function asyncDetached(callable $task): CancellationScopeInterface
* #[WorkflowMethod]
* public function handler()
* {
- * yield Workflow::await(fn() => $this->continued);
+ * Workflow::await(fn() => $this->continued);
*
* // ...continue execution
* }
@@ -305,13 +359,15 @@ public static function asyncDetached(callable $task): CancellationScopeInterface
* }
* ```
*
- * To wait for the first *fulfilled* condition and ignore rejected promise
- * conditions, combine them explicitly:
- * `yield Workflow::await(\Temporal\Promise::any([$a, $b]))`.
+ * When {@see FeatureFlags::$settleAwaitOnFirstSettledCondition} is enabled, a multi-condition
+ * await settles on the first *settled* condition, so a rejected promise condition is propagated
+ * instead of being ignored. To wait for the first *fulfilled* condition in that mode, combine
+ * the conditions explicitly: `Workflow::await(\Temporal\Promise::any([$a, $b]))`.
*/
- public static function await(callable|Mutex|PromiseInterface ...$conditions): PromiseInterface
+ public static function await(callable|Mutex|PromiseInterface ...$conditions): mixed
{
- return self::getCurrentContext()->await(...$conditions);
+ Awaiter::assertManaged();
+ return Awaiter::await(self::getCurrentContext()->await(...$conditions));
}
/**
@@ -329,18 +385,18 @@ public static function await(callable|Mutex|PromiseInterface ...$conditions): Pr
* public function handler()
* {
* // Continue after 42 seconds or when bool "continued" will be true.
- * yield Workflow::awaitWithTimeout(42, fn() => $this->continued);
+ * Workflow::awaitWithTimeout(42, fn() => $this->continued);
*
* // ...continue execution
* }
* ```
*
* @param DateIntervalValue $interval
- * @return PromiseInterface
*/
- public static function awaitWithTimeout($interval, callable|Mutex|PromiseInterface ...$conditions): PromiseInterface
+ public static function awaitWithTimeout($interval, callable|Mutex|PromiseInterface ...$conditions): bool
{
- return self::getCurrentContext()->awaitWithTimeout($interval, ...$conditions);
+ Awaiter::assertManaged();
+ return Awaiter::await(self::getCurrentContext()->awaitWithTimeout($interval, ...$conditions));
}
/**
@@ -534,21 +590,24 @@ public static function registerUpdate(
* #[WorkflowMethod]
* public function handler()
* {
- * $version = yield Workflow::getVersion('new-activity-added', 1, 2);
+ * $version = Workflow::getVersion('new-activity-added', 1, 2);
*
- * $result = yield match($version) {
+ * $result = match($version) {
* 1 => Workflow::executeActivity('before'), // Old behaviour
* 2 => Workflow::executeActivity('after'), // New behaviour
* }
* }
* ```
*
- * @return PromiseInterface
* @throws OutOfContextException in the absence of the workflow execution context.
*/
- public static function getVersion(string $changeId, int $minSupported, int $maxSupported): PromiseInterface
+ public static function getVersion(string $changeId, int $minSupported, int $maxSupported): int
{
- return self::getCurrentContext()->getVersion($changeId, $minSupported, $maxSupported);
+ Awaiter::assertManaged();
+ return Awaiter::await(
+ self::getCurrentContext()->getVersion($changeId, $minSupported, $maxSupported),
+ interruptOnCancel: false,
+ );
}
/**
@@ -567,19 +626,23 @@ public static function getVersion(string $changeId, int $minSupported, int $maxS
*
* // ✅ Good: The calculation of the data with the side-effect
* // will be performed once.
- * $time = yield Workflow::sideEffect(fn() => hrtime(true));
+ * $time = Workflow::sideEffect(fn() => hrtime(true));
* }
* ```
*
* @template TReturn
* @param callable(): TReturn $value
- * @return PromiseInterface
+ * @return TReturn
* @throws OutOfContextException in the absence of the workflow execution context.
*/
- public static function sideEffect(callable $value, ?SideEffectOptions $options = null): PromiseInterface
+ public static function sideEffect(callable $value, ?SideEffectOptions $options = null): mixed
{
+ Awaiter::assertManaged();
/** @psalm-suppress TooManyArguments */
- return self::getCurrentContext()->sideEffect($value, $options);
+ return Awaiter::await(
+ self::getCurrentContext()->sideEffect($value, $options),
+ interruptOnCancel: false,
+ );
}
/**
@@ -595,23 +658,26 @@ public static function sideEffect(callable $value, ?SideEffectOptions $options =
* public function handler()
* {
* // Wait 10 seconds
- * yield Workflow::timer(10);
+ * Workflow::timer(10);
*
* // Wait 42 hours
- * yield Workflow::timer(new \DateInterval('PT42H'));
+ * Workflow::timer(new \DateInterval('PT42H'));
*
* // Wait 23 months
- * yield Workflow::timer('23 months');
+ * Workflow::timer('23 months');
* }
* ```
*
* @param DateIntervalValue $interval
- * @return PromiseInterface
* @throws OutOfContextException in the absence of the workflow execution context.
*/
- public static function timer($interval, ?TimerOptions $options = null): PromiseInterface
+ public static function timer($interval, ?TimerOptions $options = null): void
{
- return self::getCurrentContext()->timer($interval, $options);
+ Awaiter::assertManaged();
+ Awaiter::await(
+ self::getCurrentContext()->timer($interval, $options),
+ interruptOnCancel: false,
+ );
}
/**
@@ -641,7 +707,7 @@ public static function setCurrentDetails(?string $details): void
* #[WorkflowMethod]
* public function handler()
* {
- * return yield Workflow::continueAsNew('AnyAnotherWorkflow');
+ * return Workflow::continueAsNew('AnyAnotherWorkflow');
* }
* ```
*
@@ -651,8 +717,12 @@ public static function continueAsNew(
string $type,
array $args = [],
?ContinueAsNewOptions $options = null,
- ): PromiseInterface {
- return self::getCurrentContext()->continueAsNew($type, $args, $options);
+ ): mixed {
+ Awaiter::assertManaged();
+ return Awaiter::await(
+ self::getCurrentContext()->continueAsNew($type, $args, $options),
+ interruptOnCancel: false,
+ );
}
/**
@@ -681,7 +751,7 @@ public static function continueAsNew(
* $proxy = Workflow::newContinueAsNewStub(ExampleWorkflow::class);
*
* // Executes ExampleWorkflow::handle(int $value)
- * return yield $proxy->handle(42);
+ * return $proxy->handle(42);
* }
* ```
*
@@ -707,30 +777,29 @@ public static function newContinueAsNewStub(string $class, ?ContinueAsNewOptions
* #[WorkflowMethod]
* public function handler()
* {
- * $result = yield Workflow::executeChildWorkflow('AnyAnotherWorkflow');
+ * $result = Workflow::executeChildWorkflow('AnyAnotherWorkflow');
*
* // Do something else
* }
* ```
*
- * Please note that due to the fact that PHP does not allow defining the
- * type on {@see \Generator}, you sometimes need to specify the type of
- * the child workflow result explicitly.
+ * For untyped child workflows, pass the expected result type explicitly
+ * when it cannot be inferred from a PHP workflow contract.
*
* ```php
- * // External child workflow handler method with Generator return type-hint
- * public function handle(): \Generator
+ * // External child workflow handler method with a native return type-hint
+ * public function handle(): int
* {
- * yield Workflow::executeActivity('example');
+ * Workflow::executeActivity('example');
*
- * return 42; // Generator which returns int type (Type::TYPE_INT)
+ * return 42; // int type (Type::TYPE_INT)
* }
*
* // Child workflow execution
* #[WorkflowMethod]
* public function handler()
* {
- * $result = yield Workflow::executeChildWorkflow(
+ * $result = Workflow::executeChildWorkflow(
* type: 'ChildWorkflow',
* returnType: Type::TYPE_INT,
* );
@@ -742,7 +811,6 @@ public static function newContinueAsNewStub(string $class, ?ContinueAsNewOptions
* @param non-empty-string $type
* @param list $args
* @param Type|string|\ReflectionType|\ReflectionClass|null $returnType
- * @return PromiseInterface
*
* @throws OutOfContextException in the absence of the workflow execution context.
*/
@@ -751,8 +819,12 @@ public static function executeChildWorkflow(
array $args = [],
?ChildWorkflowOptions $options = null,
mixed $returnType = null,
- ): PromiseInterface {
- return self::getCurrentContext()->executeChildWorkflow($type, $args, $options, $returnType);
+ ): mixed {
+ Awaiter::assertManaged();
+ return Awaiter::await(
+ self::getCurrentContext()->executeChildWorkflow($type, $args, $options, $returnType),
+ interruptOnCancel: false,
+ );
}
/**
@@ -782,7 +854,7 @@ public static function executeChildWorkflow(
* $proxy = Workflow::newChildWorkflowStub(ChildWorkflowExample::class);
*
* // Executes ChildWorkflowExample::handle(int $value)
- * $result = yield $proxy->handle(42);
+ * $result = $proxy->handle(42);
*
* // etc ...
* }
@@ -825,7 +897,7 @@ public static function newChildWorkflowStub(
* }
* ```
*
- * To start abandoned child workflow use `yield` and method `start()`:
+ * To start an abandoned child workflow, call `start()`:
*
* ```php
* #[WorkflowMethod]
@@ -838,7 +910,7 @@ public static function newChildWorkflowStub(
* );
*
* // Start child workflow
- * yield $workflow->start(42);
+ * $workflow->start(42);
* }
* ```
*
@@ -864,7 +936,7 @@ public static function newUntypedChildWorkflowStub(
* );
*
* // The method "signalMethod" from the class "ClassName" will be called:
- * yield $externalWorkflow->signalMethod();
+ * $externalWorkflow->signalMethod();
* }
* ```
*
@@ -913,32 +985,27 @@ public static function newUntypedExternalWorkflowStub(WorkflowExecution $executi
* #[WorkflowMethod]
* public function handler(string $existingWorkflowId)
* {
- * $result1 = yield Workflow::executeActivity('activityName');
- * $result2 = yield Workflow::executeActivity('anotherActivityName');
+ * $result1 = Workflow::executeActivity('activityName');
+ * $result2 = Workflow::executeActivity('anotherActivityName');
* }
* ```
*
- * In addition to this method of calling, you can use alternative methods
- * of working with the result using Promise API ({@see PromiseInterface}).
+ * Run independent activities concurrently by putting each direct call in
+ * an async scope and waiting for all scopes:
*
* ```php
* #[WorkflowMethod]
* public function handler(string $existingWorkflowId)
* {
- * Workflow::executeActivity('activityName')
- * ->then(function ($result) {
- * // Execution result
- * })
- * ->catch(function (\Throwable $error) {
- * // Execution error
- * })
- * ;
+ * [$first, $second] = Workflow::all([
+ * Workflow::async(fn() => Workflow::executeActivity('activityName')),
+ * Workflow::async(fn() => Workflow::executeActivity('anotherActivityName')),
+ * ]);
* }
* ```
*
* @param non-empty-string $type
* @param ActivityOptions|null $options
- * @return PromiseInterface
* @throws OutOfContextException in the absence of the workflow execution context.
*/
public static function executeActivity(
@@ -946,8 +1013,12 @@ public static function executeActivity(
array $args = [],
?ActivityOptionsInterface $options = null,
Type|string|\ReflectionClass|\ReflectionType|null $returnType = null,
- ): PromiseInterface {
- return self::getCurrentContext()->executeActivity($type, $args, $options, $returnType);
+ ): mixed {
+ Awaiter::assertManaged();
+ return Awaiter::await(
+ self::getCurrentContext()->executeActivity($type, $args, $options, $returnType),
+ interruptOnCancel: false,
+ );
}
/**
@@ -971,8 +1042,8 @@ public static function executeActivity(
* $activities = Workflow::newActivityStub(ExampleActivityClass::class);
*
* // Activity methods execution
- * yield $activities->firstActivity();
- * yield $activities->secondActivity();
+ * $activities->firstActivity();
+ * $activities->secondActivity();
* }
* ```
*
@@ -1005,7 +1076,7 @@ public static function newActivityStub(
* $activities = Workflow::newUntypedActivityStub($options);
*
* // Executes an activity named "activity"
- * $result = yield $activities->execute('activity');
+ * $result = $activities->execute('activity');
* }
* ```
*
@@ -1034,7 +1105,7 @@ public static function getStackTrace(): string
* interruption of in-progress handlers by workflow exit:
*
* ```php
- * yield Workflow.await(static fn() => Workflow::allHandlersFinished());
+ * Workflow::await(static fn() => Workflow::allHandlersFinished());
* ```
*
* @return bool True if all handlers have finished executing.
@@ -1121,26 +1192,24 @@ public static function upsertTypedSearchAttributes(SearchAttributeUpdate ...$upd
/**
* Generate a UUID.
- *
- * @return PromiseInterface
*/
- public static function uuid(): PromiseInterface
+ public static function uuid(): UuidInterface
{
+ Awaiter::assertManaged();
$context = self::getCurrentContext();
- return $context->uuid();
+ return Awaiter::await($context->uuid(), interruptOnCancel: false);
}
/**
* Generate a UUID version 4 (random).
- *
- * @return PromiseInterface
*/
- public static function uuid4(): PromiseInterface
+ public static function uuid4(): UuidInterface
{
+ Awaiter::assertManaged();
$context = self::getCurrentContext();
- return $context->uuid4();
+ return Awaiter::await($context->uuid4(), interruptOnCancel: false);
}
/**
@@ -1149,14 +1218,13 @@ public static function uuid4(): PromiseInterface
* @param \DateTimeInterface|null $dateTime An optional date/time from which
* to create the version 7 UUID. If not provided, the UUID is generated
* using the current date/time.
- *
- * @return PromiseInterface
*/
- public static function uuid7(?\DateTimeInterface $dateTime = null): PromiseInterface
+ public static function uuid7(?\DateTimeInterface $dateTime = null): UuidInterface
{
+ Awaiter::assertManaged();
$context = self::getCurrentContext();
- return $context->uuid7($dateTime);
+ return Awaiter::await($context->uuid7($dateTime), interruptOnCancel: false);
}
/**
@@ -1174,11 +1242,14 @@ public static function uuid7(?\DateTimeInterface $dateTime = null): PromiseInter
*/
public static function runLocked(Mutex $mutex, callable $callable): CancellationScopeInterface
{
- return Workflow::async(static function () use ($mutex, $callable): \Generator {
- yield $mutex->lock();
+ return Workflow::async(static function () use ($mutex, $callable): mixed {
+ $mutex->lock();
try {
- return yield $callable();
+ $result = $callable();
+ return $result instanceof PromiseInterface
+ ? Awaiter::await($result)
+ : $result;
} finally {
$mutex->unlock();
}
diff --git a/src/Workflow/ActivityStubInterface.php b/src/Workflow/ActivityStubInterface.php
index bc4a9fab0..cbb19fa1b 100644
--- a/src/Workflow/ActivityStubInterface.php
+++ b/src/Workflow/ActivityStubInterface.php
@@ -14,23 +14,31 @@
use React\Promise\PromiseInterface;
use Temporal\Activity\ActivityOptionsInterface;
use Temporal\DataConverter\Type;
-use Temporal\Internal\Transport\CompletableResultInterface;
interface ActivityStubInterface
{
public function getOptions(): ActivityOptionsInterface;
/**
- * Executes an activity asynchronously by its type name and arguments.
- *
* @param string $name name of an activity type to execute.
* @param array $args arguments of the activity.
- * @return CompletableResultInterface Promise to the activity result.
*/
public function execute(
string $name,
array $args = [],
Type|string|\ReflectionClass|\ReflectionType|null $returnType = null,
bool $isLocalActivity = false,
+ ): mixed;
+
+ /**
+ * @param string $name name of an activity type to execute.
+ * @param array $args arguments of the activity.
+ * @return PromiseInterface
+ */
+ public function executeAsync(
+ string $name,
+ array $args = [],
+ Type|string|\ReflectionClass|\ReflectionType|null $returnType = null,
+ bool $isLocalActivity = false,
): PromiseInterface;
}
diff --git a/src/Workflow/CancellationScopeInterface.php b/src/Workflow/CancellationScopeInterface.php
index fd38ec2b4..ac0ef8584 100644
--- a/src/Workflow/CancellationScopeInterface.php
+++ b/src/Workflow/CancellationScopeInterface.php
@@ -15,11 +15,21 @@
/**
* @template-covariant T
- * @yield T
* @extends PromiseInterface
*/
interface CancellationScopeInterface extends PromiseInterface
{
+ /**
+ * Suspend the current workflow until this scope completes.
+ *
+ * ```
+ * $result = Workflow::async(static fn(): string => 'done')->await();
+ * ```
+ *
+ * @return T
+ */
+ public function await(): mixed;
+
/**
* Detached scopes can continue working even if parent scope was cancelled.
*/
diff --git a/src/Workflow/ChildWorkflowStubInterface.php b/src/Workflow/ChildWorkflowStubInterface.php
index 6b208d250..ebbcdfae2 100644
--- a/src/Workflow/ChildWorkflowStubInterface.php
+++ b/src/Workflow/ChildWorkflowStubInterface.php
@@ -13,7 +13,6 @@
use React\Promise\PromiseInterface;
use Temporal\DataConverter\Type;
-use Temporal\Internal\Transport\CompletableResultInterface;
/**
* @psalm-import-type TType from Type
@@ -23,7 +22,14 @@ interface ChildWorkflowStubInterface
/**
* @throws \LogicException
*/
- public function getExecution(): PromiseInterface;
+ public function getExecution(): WorkflowExecution;
+
+ /**
+ * @internal
+ * @return PromiseInterface
+ * @throws \LogicException
+ */
+ public function getExecutionAsync(): PromiseInterface;
public function getChildWorkflowType(): string;
@@ -31,28 +37,52 @@ public function getOptions(): ChildWorkflowOptions;
/**
* @param TType $returnType
- *
- * @return CompletableResultInterface
*/
- public function execute(array $args = [], $returnType = null): PromiseInterface;
+ public function execute(array $args = [], $returnType = null): mixed;
/**
- * @param array $args
- *
- * @return CompletableResultInterface
+ * @param TType $returnType
+ * @return PromiseInterface
+ */
+ public function executeAsync(array $args = [], $returnType = null): PromiseInterface;
+
+ /**
+ * @param mixed ...$args
*/
- public function start(...$args): PromiseInterface;
+ public function start(...$args): WorkflowExecution;
+
+ /**
+ * @param mixed ...$args
+ * @internal
+ * @return PromiseInterface
+ */
+ public function startAsync(...$args): PromiseInterface;
+
+ /**
+ * @param TType $returnType
+ */
+ public function getResult($returnType = null): mixed;
/**
* @param TType $returnType
+ * @internal
+ * @return PromiseInterface
+ */
+ public function getResultAsync($returnType = null): PromiseInterface;
+
+ /**
+ * @param non-empty-string $name
+ *
+ * @throws \LogicException
*/
- public function getResult($returnType = null): PromiseInterface;
+ public function signal(string $name, array $args = []): void;
/**
* @param non-empty-string $name
+ * @internal
+ * @return PromiseInterface
*
- * @return CompletableResultInterface
* @throws \LogicException
*/
- public function signal(string $name, array $args = []): PromiseInterface;
+ public function signalAsync(string $name, array $args = []): PromiseInterface;
}
diff --git a/src/Workflow/ExternalWorkflowStubInterface.php b/src/Workflow/ExternalWorkflowStubInterface.php
index c8f69f9f3..54e3da95c 100644
--- a/src/Workflow/ExternalWorkflowStubInterface.php
+++ b/src/Workflow/ExternalWorkflowStubInterface.php
@@ -20,7 +20,21 @@ public function getExecution(): WorkflowExecution;
/**
* @throws \LogicException
*/
- public function signal(string $name, array $args = []): PromiseInterface;
+ public function signal(string $name, array $args = []): void;
- public function cancel(): PromiseInterface;
+ /**
+ * @internal
+ * @return PromiseInterface
+ *
+ * @throws \LogicException
+ */
+ public function signalAsync(string $name, array $args = []): PromiseInterface;
+
+ public function cancel(): void;
+
+ /**
+ * @internal
+ * @return PromiseInterface
+ */
+ public function cancelAsync(): PromiseInterface;
}
diff --git a/src/Workflow/Mutex.php b/src/Workflow/Mutex.php
index 0b20f857e..ba8a39056 100644
--- a/src/Workflow/Mutex.php
+++ b/src/Workflow/Mutex.php
@@ -4,26 +4,25 @@
namespace Temporal\Workflow;
-use React\Promise\Deferred;
-use React\Promise\PromiseInterface;
-use Temporal\Promise;
+use Temporal\Internal\Workflow\WorkflowContext;
+use Temporal\Workflow;
/**
- * If a mutex is yielded without calling `lock()`, the Workflow will continue
- * only when the lock is released.
+ * Use the mutex as an await condition when the Workflow should continue only
+ * after the current owner releases it.
*
* ```
* $this->mutex = new Mutex();
*
* // Continue only when the lock is released
- * yield $this->mutex;
+ * Workflow::await($this->mutex);
* ```
*/
final class Mutex
{
private bool $locked = false;
- /** @var Deferred[] */
+ /** @var list