diff --git a/src/Interceptor/WorkflowOutboundCalls/AwaitWithTimeoutInput.php b/src/Interceptor/WorkflowOutboundCalls/AwaitWithTimeoutInput.php index f34e6f07d..692be8928 100644 --- a/src/Interceptor/WorkflowOutboundCalls/AwaitWithTimeoutInput.php +++ b/src/Interceptor/WorkflowOutboundCalls/AwaitWithTimeoutInput.php @@ -13,6 +13,7 @@ use React\Promise\PromiseInterface; use Temporal\Workflow\Mutex; +use Temporal\Workflow\TimerOptions; /** * @psalm-immutable @@ -28,6 +29,7 @@ final class AwaitWithTimeoutInput public function __construct( public readonly \DateInterval $interval, public readonly array $conditions, + public readonly ?TimerOptions $timerOptions = null, ) {} /** @@ -40,6 +42,7 @@ public function with( return new self( $interval ?? $this->interval, $conditions ?? $this->conditions, + $this->timerOptions, ); } } diff --git a/src/Internal/Transport/Request/NewTimer.php b/src/Internal/Transport/Request/NewTimer.php index c2736f7f6..5749e4d51 100644 --- a/src/Internal/Transport/Request/NewTimer.php +++ b/src/Internal/Transport/Request/NewTimer.php @@ -12,8 +12,8 @@ namespace Temporal\Internal\Transport\Request; use Carbon\CarbonInterval; -use Temporal\Internal\Workflow\AwaitOptions; use Temporal\Worker\Transport\Command\Client\Request; +use Temporal\Workflow\AwaitOptions; /** * @psalm-immutable diff --git a/src/Internal/Workflow/AwaitOptions.php b/src/Internal/Workflow/AwaitOptions.php deleted file mode 100644 index 2c572af30..000000000 --- a/src/Internal/Workflow/AwaitOptions.php +++ /dev/null @@ -1,33 +0,0 @@ -callsInterceptor->with( function (AwaitWithTimeoutInput $input): PromiseInterface { /** Bypassing {@see timer()} to acquire a timer request ID */ - $request = new NewTimer(new AwaitOptions($input->interval, null)); + $request = new NewTimer(new AwaitOptions($input->interval, $input->timerOptions)); $requestId = $request->getID(); $timer = $this->request($request); \assert($timer instanceof CompletableResultInterface); @@ -707,7 +710,7 @@ static function (\Throwable $failure) use ($cancelPendingTimer): never { }, /** @see WorkflowOutboundCallsInterceptor::awaitWithTimeout() */ 'awaitWithTimeout', - )(new AwaitWithTimeoutInput($intervalObject, $conditions)); + )(new AwaitWithTimeoutInput($options->interval, $conditions, $options->options)); } /** diff --git a/src/Workflow.php b/src/Workflow.php index 6a303c793..5ab5b833c 100644 --- a/src/Workflow.php +++ b/src/Workflow.php @@ -29,6 +29,7 @@ use Temporal\Internal\Workflow\ContinueAsNewProxy; use Temporal\Internal\Workflow\ExternalWorkflowProxy; use Temporal\Workflow\ActivityStubInterface; +use Temporal\Workflow\AwaitOptions; use Temporal\Workflow\CancellationScopeInterface; use Temporal\Workflow\ChildWorkflowOptions; use Temporal\Workflow\ChildWorkflowStubInterface; @@ -335,12 +336,24 @@ public static function await(callable|Mutex|PromiseInterface ...$conditions): Pr * } * ``` * - * @param DateIntervalValue $interval + * Pass {@see AwaitOptions} instead of a timeout value to configure the underlying timer, + * for example to set its summary displayed in UI/CLI: + * + * ```php + * yield Workflow::awaitWithTimeout( + * AwaitOptions::new(42)->withTimerOptions( + * TimerOptions::new()->withSummary('continued-wait'), + * ), + * fn() => $this->continued, + * ); + * ``` + * + * @param DateIntervalValue|AwaitOptions $intervalOrOptions Timeout value or await options. * @return PromiseInterface */ - public static function awaitWithTimeout($interval, callable|Mutex|PromiseInterface ...$conditions): PromiseInterface + public static function awaitWithTimeout($intervalOrOptions, callable|Mutex|PromiseInterface ...$conditions): PromiseInterface { - return self::getCurrentContext()->awaitWithTimeout($interval, ...$conditions); + return self::getCurrentContext()->awaitWithTimeout($intervalOrOptions, ...$conditions); } /** diff --git a/src/Workflow/AwaitOptions.php b/src/Workflow/AwaitOptions.php new file mode 100644 index 000000000..7604e64f3 --- /dev/null +++ b/src/Workflow/AwaitOptions.php @@ -0,0 +1,70 @@ +withTimerOptions( + * TimerOptions::new()->withSummary('rtds-resolution-wait'), + * ), + * fn(): bool => $this->resolution !== null, + * ); + * ``` + * + * @psalm-import-type DateIntervalValue from DateInterval + */ +final class AwaitOptions +{ + public function __construct( + /** + * Await timeout. + */ + public readonly \DateInterval $interval, + + /** + * Options set for the underlying timer created. + */ + public readonly ?TimerOptions $options = null, + ) {} + + /** + * @param DateIntervalValue $interval Await timeout. + */ + public static function new(mixed $interval, ?TimerOptions $options = null): self + { + return new self(DateInterval::parse($interval, DateInterval::FORMAT_SECONDS), $options); + } + + /** + * Await timeout. + * + * @param DateIntervalValue $interval + */ + public function withInterval(mixed $interval): self + { + return new self(DateInterval::parse($interval, DateInterval::FORMAT_SECONDS), $this->options); + } + + /** + * Options set for the underlying timer created. + */ + public function withTimerOptions(?TimerOptions $options): self + { + return new self($this->interval, $options); + } +} diff --git a/src/Workflow/WorkflowContextInterface.php b/src/Workflow/WorkflowContextInterface.php index 0711d6701..bc891cc09 100644 --- a/src/Workflow/WorkflowContextInterface.php +++ b/src/Workflow/WorkflowContextInterface.php @@ -318,10 +318,10 @@ public function await(callable|Mutex|PromiseInterface ...$conditions): PromiseIn * * @see Workflow::awaitWithTimeout() * - * @param DateIntervalValue $interval + * @param DateIntervalValue|AwaitOptions $intervalOrOptions Timeout value or await options. * @return PromiseInterface */ - public function awaitWithTimeout($interval, callable|Mutex|PromiseInterface ...$conditions): PromiseInterface; + public function awaitWithTimeout($intervalOrOptions, callable|Mutex|PromiseInterface ...$conditions): PromiseInterface; /** * Returns a complete trace of the last calls (for debugging). diff --git a/tests/Acceptance/Extra/Workflow/UserMetadataTest.php b/tests/Acceptance/Extra/Workflow/UserMetadataTest.php index 3d847854e..27b1286ec 100644 --- a/tests/Acceptance/Extra/Workflow/UserMetadataTest.php +++ b/tests/Acceptance/Extra/Workflow/UserMetadataTest.php @@ -219,6 +219,42 @@ public function localActivityMetadata( } } + #[Test] + public function awaitWithTimeoutMetadata( + #[Stub('Extra_Workflow_UserMetadata')] + WorkflowStubInterface $stub, + WorkflowClientInterface $client, + DataConverterInterface $dataConverter, + ): void { + try { + /** @see TestWorkflow::awaitWithTimeout() */ + $timedOut = $stub->update('await_with_timeout', 'await timer summary')->getValue(0); + self::assertFalse($timedOut); + + # Check that the timer created by awaitWithTimeout() carries the summary + $found = false; + foreach ($client->getWorkflowHistory($stub->getExecution()) as $event) { + if (!$event->hasTimerStartedEventAttributes()) { + continue; + } + + $payload = $event->getUserMetadata()?->getSummary(); + if (!$payload instanceof Payload) { + continue; + } + + if ($dataConverter->fromPayload($payload, 'string') === 'await timer summary') { + $found = true; + break; + } + } + + self::assertTrue($found, 'Await timer metadata not found in workflow history'); + } finally { + self::terminate($stub); + } + } + private static function terminate(WorkflowStubInterface $stub): void { try { @@ -249,6 +285,17 @@ public function ping(): string return 'pong'; } + #[Workflow\UpdateMethod('await_with_timeout')] + public function awaitWithTimeout(string $summary) + { + return yield Workflow::awaitWithTimeout( + Workflow\AwaitOptions::new(1)->withTimerOptions( + Workflow\TimerOptions::new()->withSummary($summary), + ), + fn(): bool => $this->exit, + ); + } + #[Workflow\UpdateMethod('start_child')] public function startChild(string $summary, string $details) { diff --git a/tests/Fixtures/src/Workflow/AwaitWithTimeoutOptionsWorkflow.php b/tests/Fixtures/src/Workflow/AwaitWithTimeoutOptionsWorkflow.php new file mode 100644 index 000000000..77c49e8c9 --- /dev/null +++ b/tests/Fixtures/src/Workflow/AwaitWithTimeoutOptionsWorkflow.php @@ -0,0 +1,42 @@ + FALSE because of the timeout. + */ + #[WorkflowMethod] + public function handler(?string $summary = null, int $timeout = 1, bool $useAwaitOptions = true) + { + $intervalOrOptions = $useAwaitOptions + ? AwaitOptions::new($timeout)->withTimerOptions( + $summary === null ? null : TimerOptions::new()->withSummary($summary), + ) + : $timeout; + + return yield Workflow::awaitWithTimeout($intervalOrOptions, static fn(): bool => false); + } +} diff --git a/tests/Fixtures/src/Workflow/ConcurrentAwaitWithTimeoutOptionsWorkflow.php b/tests/Fixtures/src/Workflow/ConcurrentAwaitWithTimeoutOptionsWorkflow.php new file mode 100644 index 000000000..15bcc3bba --- /dev/null +++ b/tests/Fixtures/src/Workflow/ConcurrentAwaitWithTimeoutOptionsWorkflow.php @@ -0,0 +1,54 @@ + Both awaits are settled by timers. + */ + #[WorkflowMethod] + public function handler(string $first, string $second) + { + return yield Promise::all([ + $this->await($first, 1), + $this->await($second, 2), + ]); + } + + /** + * @param non-empty-string $summary + */ + private function await(string $summary, int $timeout): PromiseInterface + { + return Workflow::async(static function () use ($summary, $timeout): \Generator { + return yield Workflow::awaitWithTimeout( + AwaitOptions::new($timeout)->withTimerOptions( + TimerOptions::new()->withSummary($summary), + ), + static fn(): bool => false, + ); + }); + } +} diff --git a/tests/Functional/AwaitWithTimeoutOptionsTestCase.php b/tests/Functional/AwaitWithTimeoutOptionsTestCase.php new file mode 100644 index 000000000..0540cbff7 --- /dev/null +++ b/tests/Functional/AwaitWithTimeoutOptionsTestCase.php @@ -0,0 +1,180 @@ +startAwait('await-timer-summary', 1); + + // The condition is never met, so the await is settled by the timer + self::assertFalse($run->getResult('bool', 30)); + + $timers = $this->timerStartedEvents($run->getExecution()); + self::assertCount(1, $timers); + self::assertSame('await-timer-summary', $this->summaryOf($timers[0])); + } + + public function testAwaitOptionsIntervalIsUsedAsTimerTimeout(): void + { + /** @see AwaitWithTimeoutOptionsWorkflow::handler() */ + $run = $this->startAwait('await-timer-interval', 3); + + self::assertFalse($run->getResult('bool', 30)); + + $timers = $this->timerStartedEvents($run->getExecution()); + self::assertSame( + 3, + (int) $timers[0]->getTimerStartedEventAttributes()?->getStartToFireTimeout()?->getSeconds(), + ); + } + + public function testAwaitTimerIsFiredAndWorkflowCompleted(): void + { + /** @see AwaitWithTimeoutOptionsWorkflow::handler() */ + $run = $this->startAwait('await-timer-fired', 1); + + self::assertFalse($run->getResult('bool', 30)); + + $types = $this->eventTypes($run->getExecution()); + self::assertContains(EventType::EVENT_TYPE_TIMER_STARTED, $types); + self::assertContains(EventType::EVENT_TYPE_TIMER_FIRED, $types); + self::assertContains(EventType::EVENT_TYPE_WORKFLOW_EXECUTION_COMPLETED, $types); + self::assertNotContains(EventType::EVENT_TYPE_TIMER_CANCELED, $types); + } + + public function testConcurrentAwaitsHaveOwnTimerSummaries(): void + { + $workflow = $this->workflowClient->newWorkflowStub( + ConcurrentAwaitWithTimeoutOptionsWorkflow::class, + WorkflowOptions::new()->withWorkflowRunTimeout('30 seconds'), + ); + + /** @see ConcurrentAwaitWithTimeoutOptionsWorkflow::handler() */ + $run = $this->workflowClient->start($workflow, 'first-await', 'second-await'); + + self::assertSame([false, false], $run->getResult('array', 30)); + + $summaries = \array_map( + $this->summaryOf(...), + $this->timerStartedEvents($run->getExecution()), + ); + + \sort($summaries); + self::assertSame(['first-await', 'second-await'], $summaries); + } + + public function testAwaitOptionsWithoutTimerOptionsSendsNoSummary(): void + { + /** @see AwaitWithTimeoutOptionsWorkflow::handler() */ + $run = $this->startAwait(null, 1); + + self::assertFalse($run->getResult('bool', 30)); + + $timers = $this->timerStartedEvents($run->getExecution()); + self::assertCount(1, $timers); + self::assertNull($timers[0]->getUserMetadata()?->getSummary()); + } + + public function testPlainTimeoutSendsNoTimerSummary(): void + { + /** @see AwaitWithTimeoutOptionsWorkflow::handler() */ + $run = $this->startAwait(null, 1, useAwaitOptions: false); + + self::assertFalse($run->getResult('bool', 30)); + + $timers = $this->timerStartedEvents($run->getExecution()); + self::assertCount(1, $timers); + self::assertNull($timers[0]->getUserMetadata()?->getSummary()); + } + + protected function setUp(): void + { + $this->dataConverter = DataConverter::createDefault(); + $this->workflowClient = new WorkflowClient( + ServiceClient::create(TemporalServer::address()), + converter: $this->dataConverter, + ); + + parent::setUp(); + } + + private function startAwait( + ?string $summary, + int $timeout, + bool $useAwaitOptions = true, + ): WorkflowRunInterface { + $workflow = $this->workflowClient->newWorkflowStub( + AwaitWithTimeoutOptionsWorkflow::class, + WorkflowOptions::new()->withWorkflowRunTimeout('30 seconds'), + ); + + return $this->workflowClient->start($workflow, $summary, $timeout, $useAwaitOptions); + } + + /** + * @return list + */ + private function timerStartedEvents(WorkflowExecution $execution): array + { + $result = []; + foreach ($this->workflowClient->getWorkflowHistory($execution, pageSize: 50) as $event) { + $event->hasTimerStartedEventAttributes() and $result[] = $event; + } + + $result === [] and $this->fail('Timer not found in the workflow history.'); + + return $result; + } + + /** + * @return list + */ + private function eventTypes(WorkflowExecution $execution): array + { + $result = []; + foreach ($this->workflowClient->getWorkflowHistory($execution, pageSize: 50) as $event) { + $result[] = $event->getEventType(); + } + + return $result; + } + + private function summaryOf(HistoryEvent $event): ?string + { + $payload = $event->getUserMetadata()?->getSummary(); + + return $payload instanceof Payload + ? (string) $this->dataConverter->fromPayload($payload, 'string') + : null; + } +} diff --git a/tests/Unit/DTO/AwaitOptionsTestCase.php b/tests/Unit/DTO/AwaitOptionsTestCase.php new file mode 100644 index 000000000..448540c95 --- /dev/null +++ b/tests/Unit/DTO/AwaitOptionsTestCase.php @@ -0,0 +1,61 @@ +interval)?->totalSeconds); + self::assertNull($options->options); + } + + public function testIntervalAcceptsDateInterval(): void + { + $options = AwaitOptions::new(CarbonInterval::minutes(3)); + + self::assertSame(180.0, CarbonInterval::make($options->interval)?->totalSeconds); + } + + public function testWithTimerOptionsIsImmutable(): void + { + $options = AwaitOptions::new(5); + $timerOptions = TimerOptions::new()->withSummary('test summary'); + + $result = $options->withTimerOptions($timerOptions); + + self::assertNotSame($options, $result); + self::assertNull($options->options); + self::assertSame($timerOptions, $result->options); + self::assertSame($options->interval, $result->interval); + } + + public function testWithIntervalIsImmutable(): void + { + $timerOptions = TimerOptions::new()->withSummary('test summary'); + $options = AwaitOptions::new(5, $timerOptions); + + $result = $options->withInterval(10); + + self::assertNotSame($options, $result); + self::assertSame(5.0, CarbonInterval::make($options->interval)?->totalSeconds); + self::assertSame(10.0, CarbonInterval::make($result->interval)?->totalSeconds); + self::assertSame($timerOptions, $result->options); + } +} diff --git a/tests/Unit/Framework/Expectation/Timer.php b/tests/Unit/Framework/Expectation/Timer.php index c3712abd1..058d25087 100644 --- a/tests/Unit/Framework/Expectation/Timer.php +++ b/tests/Unit/Framework/Expectation/Timer.php @@ -19,14 +19,21 @@ final class Timer implements ExpectationInterface { private int $seconds; - public function __construct(int $seconds) + /** + * @param null|string $summary Timer summary to check. NULL means no check. + */ + public function __construct(int $seconds, private readonly ?string $summary = null) { $this->seconds = $seconds; } public function matches(CommandInterface $command): bool { - return $command instanceof NewTimer && $command->getOptions()['ms'] / 1000 === $this->seconds; + if (!$command instanceof NewTimer || $command->getOptions()['ms'] / 1000 !== $this->seconds) { + return false; + } + + return $this->summary === null || ($command->getOptions()['summary'] ?? null) === $this->summary; } public function run(CommandInterface $command): CommandInterface @@ -36,6 +43,10 @@ public function run(CommandInterface $command): CommandInterface public function fail(): void { - throw new ExpectationFailedException("Expected timer for $this->seconds seconds."); + throw new ExpectationFailedException( + $this->summary === null + ? "Expected timer for $this->seconds seconds." + : "Expected timer for $this->seconds seconds with summary `$this->summary`.", + ); } } diff --git a/tests/Unit/Framework/WorkerMock.php b/tests/Unit/Framework/WorkerMock.php index d09ff2976..092902ece 100644 --- a/tests/Unit/Framework/WorkerMock.php +++ b/tests/Unit/Framework/WorkerMock.php @@ -174,9 +174,9 @@ public function expectActivityCall(string $class, string $method, ...$returnValu $this->server->expect(new ActivityCall($class, $method, $returnValues)); } - public function expectTimer(int $seconds): void + public function expectTimer(int $seconds, ?string $summary = null): void { - $this->server->expect(new Timer($seconds)); + $this->server->expect(new Timer($seconds, $summary)); } public function assertWorkflowReturns($value): void diff --git a/tests/Unit/WorkflowContext/AwaitWithTimeoutTestCase.php b/tests/Unit/WorkflowContext/AwaitWithTimeoutTestCase.php index 30ef4348f..6c2f6187a 100644 --- a/tests/Unit/WorkflowContext/AwaitWithTimeoutTestCase.php +++ b/tests/Unit/WorkflowContext/AwaitWithTimeoutTestCase.php @@ -10,6 +10,8 @@ use Temporal\Worker\WorkerFactoryInterface; use Temporal\Worker\WorkerInterface; use Temporal\Workflow; +use Temporal\Workflow\AwaitOptions; +use Temporal\Workflow\TimerOptions; use Temporal\Workflow\WorkflowMethod; use function PHPUnit\Framework\assertFalse; @@ -133,4 +135,54 @@ public function cancel(): void $this->factory->run($this->worker); } + + public function testAwaitWithTimeoutStartsTimerWithSummaryFromAwaitOptions(): void + { + // We don't have native PHPUnit assertions in this scenario + $this->expectNotToPerformAssertions(); + + $this->worker->registerWorkflowObject( + new + #[Workflow\WorkflowInterface] + class { + #[WorkflowMethod(name: 'AwaitWorkflow')] + public function handler(): iterable + { + yield Workflow::awaitWithTimeout( + AwaitOptions::new(5)->withTimerOptions( + TimerOptions::new()->withSummary('await-summary'), + ), + fn() => false, + ); + return 'OK'; + } + } + ); + + $this->worker->runWorkflow('AwaitWorkflow'); + $this->worker->expectTimer(5, 'await-summary'); + $this->worker->assertWorkflowReturns('OK'); + $this->factory->run($this->worker); + } + + public function testAwaitWithTimeoutAcceptsAwaitOptionsWithoutTimerOptions(): void + { + $this->worker->registerWorkflowObject( + new + #[Workflow\WorkflowInterface] + class { + #[WorkflowMethod(name: 'AwaitWorkflow')] + public function handler(): iterable + { + $result = yield Workflow::awaitWithTimeout(AwaitOptions::new(5), fn() => true); + assertTrue($result); + return 'OK'; + } + } + ); + + $this->worker->runWorkflow('AwaitWorkflow'); + $this->worker->assertWorkflowReturns('OK'); + $this->factory->run($this->worker); + } }