diff --git a/features/nexus/async_cancellation/feature.php b/features/nexus/async_cancellation/feature.php new file mode 100644 index 00000000..97620a1f --- /dev/null +++ b/features/nexus/async_cancellation/feature.php @@ -0,0 +1,135 @@ +withWorkflowId('async-cancellation-' . $name), + $name, + ); + } +} + +#[WorkflowInterface] +class BlockingWorkflow +{ + #[WorkflowMethod('AsyncCancellationBlockingWorkflow')] + public function run(string $name) + { + yield Workflow::await(static fn(): bool => false); + + return ''; + } +} + +#[WorkflowInterface] +class FeatureWorkflow +{ + #[WorkflowMethod('Workflow')] + public function run(string $endpoint) + { + $stub = Workflow::newUntypedNexusOperationStub( + NexusOperationOptions::new() + ->withEndpoint($endpoint) + ->withService('test-service') + ->withScheduleToCloseTimeout('1 minute'), + ); + + /** @var NexusOperationHandle|null $handle */ + $handle = null; + $scope = Workflow::async(static function () use ($stub, &$handle) { + $handle = yield $stub->start('block-forever', ['world'], 'string'); + yield $handle->getResult(); + }); + + yield Workflow::await(static function () use (&$handle): bool { + return $handle !== null; + }); + yield Workflow::timer(CarbonInterval::seconds(1)); + $scope->cancel(); + + try { + yield $scope; + } catch (CanceledFailure) { + return 'canceled'; + } catch (NexusOperationFailure $e) { + if ($e->getPrevious() instanceof CanceledFailure) { + return 'canceled'; + } + + throw $e; + } + + throw new \RuntimeException('expected the cancelled operation to fail'); + } +} + +class FeatureChecker +{ + #[Check] + public static function check(WorkflowClientInterface $client, Feature $feature): void + { + Assert::notNull($feature->nexusEndpoint, 'Nexus endpoint is not provided by the runner'); + + $stub = $client->newUntypedWorkflowStub( + 'Workflow', + WorkflowOptions::new() + ->withTaskQueue($feature->taskQueue) + ->withWorkflowExecutionTimeout('1 minute'), + ); + $client->start($stub, $feature->nexusEndpoint); + + Assert::same($stub->getResult('string'), 'canceled'); + + $events = \iterator_to_array($client->getWorkflowHistory($stub->getExecution())->getEvents(), false); + + Assert::true( + self::hasEvent( + $events, + static fn(HistoryEvent $e): bool => $e->hasNexusOperationCancelRequestedEventAttributes(), + ), + 'NexusOperationCancelRequested event is missing', + ); + } + + /** + * @param list $events + * @param callable(HistoryEvent): bool $predicate + */ + private static function hasEvent(array $events, callable $predicate): bool + { + foreach ($events as $event) { + if ($predicate($event)) { + return true; + } + } + + return false; + } +} diff --git a/features/nexus/async_success/feature.php b/features/nexus/async_success/feature.php new file mode 100644 index 00000000..332c8423 --- /dev/null +++ b/features/nexus/async_success/feature.php @@ -0,0 +1,122 @@ +withWorkflowId('async-success-' . $name), + $name, + ); + } +} + +#[WorkflowInterface] +class HandlerWorkflow +{ + #[WorkflowMethod('AsyncSuccessHandlerWorkflow')] + public function run(string $name) + { + yield Workflow::timer(CarbonInterval::milliseconds(50)); + + return "Hello, {$name}!"; + } +} + +#[WorkflowInterface] +class FeatureWorkflow +{ + #[WorkflowMethod('Workflow')] + public function run(string $endpoint) + { + $stub = Workflow::newUntypedNexusOperationStub( + NexusOperationOptions::new() + ->withEndpoint($endpoint) + ->withService('test-service') + ->withScheduleToCloseTimeout('1 minute'), + ); + + /** @var NexusOperationHandle $handle */ + $handle = yield $stub->start('say-hello-async', ['world'], 'string'); + + $token = $handle->getOperationToken(); + if ($token === null || $token === '') { + throw new \RuntimeException('expected a non-empty operation token'); + } + + return 'token+' . (yield $handle->getResult()); + } +} + +class FeatureChecker +{ + #[Check] + public static function check(WorkflowClientInterface $client, Feature $feature): void + { + Assert::notNull($feature->nexusEndpoint, 'Nexus endpoint is not provided by the runner'); + + $stub = $client->newUntypedWorkflowStub( + 'Workflow', + WorkflowOptions::new() + ->withTaskQueue($feature->taskQueue) + ->withWorkflowExecutionTimeout('1 minute'), + ); + $client->start($stub, $feature->nexusEndpoint); + + Assert::same($stub->getResult('string'), 'token+Hello, world!'); + + $events = \iterator_to_array($client->getWorkflowHistory($stub->getExecution())->getEvents(), false); + + Assert::true( + self::hasEvent($events, static fn(HistoryEvent $e): bool => $e->hasNexusOperationScheduledEventAttributes()), + 'NexusOperationScheduled event is missing', + ); + Assert::true( + self::hasEvent($events, static fn(HistoryEvent $e): bool => $e->hasNexusOperationStartedEventAttributes()), + 'NexusOperationStarted event is missing', + ); + Assert::true( + self::hasEvent($events, static fn(HistoryEvent $e): bool => $e->hasNexusOperationCompletedEventAttributes()), + 'NexusOperationCompleted event is missing', + ); + } + + /** + * @param list $events + * @param callable(HistoryEvent): bool $predicate + */ + private static function hasEvent(array $events, callable $predicate): bool + { + foreach ($events as $event) { + if ($predicate($event)) { + return true; + } + } + + return false; + } +} diff --git a/features/nexus/parallel_operations/feature.php b/features/nexus/parallel_operations/feature.php new file mode 100644 index 00000000..f5cfadfb --- /dev/null +++ b/features/nexus/parallel_operations/feature.php @@ -0,0 +1,111 @@ +withEndpoint($endpoint) + ->withScheduleToCloseTimeout('1 minute'), + ); + + $promises = []; + foreach (NAMES as $name) { + $promises[] = $service->sayHello($name); + } + + return \implode(' ', yield Promise::all($promises)); + } +} + +class FeatureChecker +{ + #[Check] + public static function check(WorkflowClientInterface $client, Feature $feature): void + { + Assert::notNull($feature->nexusEndpoint, 'Nexus endpoint is not provided by the runner'); + + $stub = $client->newUntypedWorkflowStub( + 'Workflow', + WorkflowOptions::new() + ->withTaskQueue($feature->taskQueue) + ->withWorkflowExecutionTimeout('1 minute'), + ); + $client->start($stub, $feature->nexusEndpoint); + + Assert::same($stub->getResult('string'), 'Hello, one! Hello, two! Hello, three!'); + + $events = \iterator_to_array($client->getWorkflowHistory($stub->getExecution())->getEvents(), false); + + Assert::same( + self::countEvents($events, static fn(HistoryEvent $e): bool => $e->hasNexusOperationScheduledEventAttributes()), + \count(NAMES), + 'Expected one NexusOperationScheduled event per operation', + ); + Assert::same( + self::countEvents($events, static fn(HistoryEvent $e): bool => $e->hasNexusOperationCompletedEventAttributes()), + \count(NAMES), + 'Expected one NexusOperationCompleted event per operation', + ); + Assert::same( + self::countEvents($events, static fn(HistoryEvent $e): bool => $e->hasNexusOperationStartedEventAttributes()), + 0, + 'Synchronous operations must not produce NexusOperationStarted events', + ); + } + + /** + * @param list $events + * @param callable(HistoryEvent): bool $predicate + */ + private static function countEvents(array $events, callable $predicate): int + { + $count = 0; + foreach ($events as $event) { + if ($predicate($event)) { + ++$count; + } + } + + return $count; + } +} diff --git a/features/nexus/sync_operation_error/feature.php b/features/nexus/sync_operation_error/feature.php new file mode 100644 index 00000000..9cf106fc --- /dev/null +++ b/features/nexus/sync_operation_error/feature.php @@ -0,0 +1,127 @@ +withEndpoint($endpoint) + ->withService('test-service') + ->withScheduleToCloseTimeout('1 minute'), + ); + + try { + yield $stub->execute('fail', ['world'], 'string'); + } catch (NexusOperationFailure $e) { + $cause = self::findApplicationFailure($e, ERROR_TYPE); + if ($cause === null) { + throw new \RuntimeException('expected an application error cause of type ' . ERROR_TYPE); + } + + if (!\str_contains($cause->getOriginalMessage(), ERROR_MESSAGE)) { + throw new \RuntimeException( + 'expected the original failure message, got: ' . $cause->getOriginalMessage(), + ); + } + + return $cause->getType() . ': ' . ERROR_MESSAGE; + } + + throw new \RuntimeException('expected the operation to fail'); + } + + private static function findApplicationFailure(\Throwable $error, string $type): ?ApplicationFailure + { + for ($current = $error->getPrevious(); $current !== null; $current = $current->getPrevious()) { + if ($current instanceof ApplicationFailure && $current->getType() === $type) { + return $current; + } + } + + return null; + } +} + +class FeatureChecker +{ + #[Check] + public static function check(WorkflowClientInterface $client, Feature $feature): void + { + Assert::notNull($feature->nexusEndpoint, 'Nexus endpoint is not provided by the runner'); + + $stub = $client->newUntypedWorkflowStub( + 'Workflow', + WorkflowOptions::new() + ->withTaskQueue($feature->taskQueue) + ->withWorkflowExecutionTimeout('1 minute'), + ); + $client->start($stub, $feature->nexusEndpoint); + + Assert::same($stub->getResult('string'), ERROR_TYPE . ': ' . ERROR_MESSAGE); + + $events = \iterator_to_array($client->getWorkflowHistory($stub->getExecution())->getEvents(), false); + + Assert::true( + self::hasEvent($events, static fn(HistoryEvent $e): bool => $e->hasNexusOperationFailedEventAttributes()), + 'NexusOperationFailed event is missing', + ); + Assert::false( + self::hasEvent($events, static fn(HistoryEvent $e): bool => $e->hasNexusOperationCompletedEventAttributes()), + 'Unexpected NexusOperationCompleted event for a failed operation', + ); + } + + /** + * @param list $events + * @param callable(HistoryEvent): bool $predicate + */ + private static function hasEvent(array $events, callable $predicate): bool + { + foreach ($events as $event) { + if ($predicate($event)) { + return true; + } + } + + return false; + } +} diff --git a/features/nexus/sync_success/feature.php b/features/nexus/sync_success/feature.php new file mode 100644 index 00000000..5c9786ac --- /dev/null +++ b/features/nexus/sync_success/feature.php @@ -0,0 +1,99 @@ +withEndpoint($endpoint) + ->withScheduleToCloseTimeout('1 minute'), + ); + + return yield $service->sayHello('world'); + } +} + +class FeatureChecker +{ + #[Check] + public static function check(WorkflowClientInterface $client, Feature $feature): void + { + Assert::notNull($feature->nexusEndpoint, 'Nexus endpoint is not provided by the runner'); + + $stub = $client->newUntypedWorkflowStub( + 'Workflow', + WorkflowOptions::new() + ->withTaskQueue($feature->taskQueue) + ->withWorkflowExecutionTimeout('1 minute'), + ); + $client->start($stub, $feature->nexusEndpoint); + + Assert::same($stub->getResult('string'), 'Hello, world!'); + + $events = \iterator_to_array($client->getWorkflowHistory($stub->getExecution())->getEvents(), false); + + Assert::true( + self::hasEvent($events, static fn(HistoryEvent $e): bool => $e->hasNexusOperationScheduledEventAttributes()), + 'NexusOperationScheduled event is missing', + ); + Assert::true( + self::hasEvent($events, static fn(HistoryEvent $e): bool => $e->hasNexusOperationCompletedEventAttributes()), + 'NexusOperationCompleted event is missing', + ); + Assert::false( + self::hasEvent($events, static fn(HistoryEvent $e): bool => $e->hasNexusOperationStartedEventAttributes()), + 'Synchronous operation must not produce a NexusOperationStarted event', + ); + } + + /** + * @param list $events + * @param callable(HistoryEvent): bool $predicate + */ + private static function hasEvent(array $events, callable $predicate): bool + { + foreach ($events as $event) { + if ($predicate($event)) { + return true; + } + } + + return false; + } +} diff --git a/harness/php/composer.json b/harness/php/composer.json index f2ddb5a7..013fe774 100644 --- a/harness/php/composer.json +++ b/harness/php/composer.json @@ -9,7 +9,7 @@ "buggregator/trap": "^1.9", "spiral/core": "^3.13", "symfony/process": ">=6.4", - "temporal/sdk": "^2.16.0", + "temporal/sdk": "dev-nexus-new#85126b125b82c5218c70d9b96a1d8bbdbafd00b7 as 2.18.0", "webmozart/assert": "^1.11" }, "autoload": { @@ -18,8 +18,17 @@ } }, "scripts": { - "rr-get": "rr get" + "rr-get": "dload build --config ../harness/php/dload.xml" }, "prefer-stable": true, - "minimum-stability": "dev" + "minimum-stability": "dev", + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/temporalio/sdk-php" + } + ], + "require-dev": { + "internal/dload": "^1.8" + } } diff --git a/harness/php/dload.xml b/harness/php/dload.xml new file mode 100644 index 00000000..9fc5ed02 --- /dev/null +++ b/harness/php/dload.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/harness/php/runner.php b/harness/php/runner.php index d67e2e61..bb1ca667 100644 --- a/harness/php/runner.php +++ b/harness/php/runner.php @@ -36,7 +36,10 @@ $runner = new Runner($runtime); // Run RoadRunner server if workflows or activities are defined -if (\iterator_to_array($runtime->workflows(), false) !== [] || \iterator_to_array($runtime->activities(), false) !== []) { +if (\iterator_to_array($runtime->workflows(), false) !== [] + || \iterator_to_array($runtime->activities(), false) !== [] + || \iterator_to_array($runtime->nexusServices(), false) !== [] +) { $runner->start(); } diff --git a/harness/php/src/Input/Command.php b/harness/php/src/Input/Command.php index 3fab194e..b76789e9 100644 --- a/harness/php/src/Input/Command.php +++ b/harness/php/src/Input/Command.php @@ -67,11 +67,13 @@ public static function fromCommandLine(array $argv): self continue; } - [$dir, $taskQueue] = \explode(':', $chunk, 2); + [$dir, $taskQueue, $nexusEndpoint] = \array_pad(\explode(':', $chunk, 3), 3, null); + $nexusEndpoint === '' and $nexusEndpoint = null; $self->features[] = new Feature( dir: $dir, namespace: 'Harness\\Feature\\' . self::namespaceFromPath($dir), taskQueue: $taskQueue, + nexusEndpoint: $nexusEndpoint, ); } @@ -91,7 +93,9 @@ public function toCommandLineArguments(): array $this->tlsServerName === null or $result[] = "tls.server-name=$this->tlsServerName"; $this->tlsCaCert === null or $result[] = "tls.ca-cert=$this->tlsCaCert"; foreach ($this->features as $feature) { - $result[] = "{$feature->dir}:{$feature->taskQueue}"; + $result[] = $feature->nexusEndpoint === null + ? "{$feature->dir}:{$feature->taskQueue}" + : "{$feature->dir}:{$feature->taskQueue}:{$feature->nexusEndpoint}"; } return $result; diff --git a/harness/php/src/Input/Feature.php b/harness/php/src/Input/Feature.php index b3cc2bff..5758734c 100644 --- a/harness/php/src/Input/Feature.php +++ b/harness/php/src/Input/Feature.php @@ -10,6 +10,7 @@ public function __construct( public string $dir, public string $namespace, public string $taskQueue, + public ?string $nexusEndpoint = null, ) { } } diff --git a/harness/php/src/Runtime/Feature.php b/harness/php/src/Runtime/Feature.php index f916ae4b..9f88b9b3 100644 --- a/harness/php/src/Runtime/Feature.php +++ b/harness/php/src/Runtime/Feature.php @@ -20,8 +20,12 @@ final class Feature /** @var list> Lazy callables */ public array $converters = []; + /** @var list Nexus service implementations */ + public array $nexusServices = []; + public function __construct( public readonly string $taskQueue, + public readonly ?string $nexusEndpoint = null, ) { } } diff --git a/harness/php/src/Runtime/State.php b/harness/php/src/Runtime/State.php index 122aabba..33dc0cfd 100644 --- a/harness/php/src/Runtime/State.php +++ b/harness/php/src/Runtime/State.php @@ -59,6 +59,20 @@ public function activities(): \Traversable } } + /** + * Iterate over all the Nexus service implementations. + * + * @return \Traversable + */ + public function nexusServices(): \Traversable + { + foreach ($this->features as $feature) { + foreach ($feature->nexusServices as $service) { + yield $feature => $service; + } + } + } + /** * Iterate over all the Payload Converters. * @@ -120,8 +134,19 @@ public function addActivity(\Harness\Input\Feature $inputFeature, string $class) $this->getFeature($inputFeature)->activities[] = $class; } + /** + * @param class-string $class + */ + public function addNexusService(\Harness\Input\Feature $inputFeature, string $class): void + { + $this->getFeature($inputFeature)->nexusServices[] = $class; + } + private function getFeature(\Harness\Input\Feature $feature): Feature { - return $this->features[$feature->namespace] ??= new Feature($feature->taskQueue); + return $this->features[$feature->namespace] ??= new Feature( + $feature->taskQueue, + $feature->nexusEndpoint, + ); } -} \ No newline at end of file +} diff --git a/harness/php/src/RuntimeBuilder.php b/harness/php/src/RuntimeBuilder.php index cc7e3da3..3a1c2427 100644 --- a/harness/php/src/RuntimeBuilder.php +++ b/harness/php/src/RuntimeBuilder.php @@ -10,6 +10,7 @@ use Harness\Runtime\State; use Temporal\Activity\ActivityInterface; use Temporal\DataConverter\PayloadConverterInterface; +use Temporal\Nexus\Attribute\Service; use Temporal\Workflow\WorkflowInterface; final class RuntimeBuilder @@ -29,6 +30,11 @@ public static function createState(array $argv, string $workDir): State $class->getAttributes(ActivityInterface::class) === [] or $runtime ->addActivity($feature, $class->getName()); + # Register Nexus Service + if (self::isNexusService($class)) { + $runtime->addNexusService($feature, $class->getName()); + } + # Register Converters $class->implementsInterface(PayloadConverterInterface::class) and $runtime ->addConverter($feature, $class->getName()); @@ -43,6 +49,25 @@ public static function createState(array $argv, string $workDir): State return $runtime; } + private static function isNexusService(\ReflectionClass $class): bool + { + if ($class->isAbstract()) { + return false; + } + + if ($class->getAttributes(Service::class) !== []) { + return true; + } + + foreach ($class->getInterfaces() as $interface) { + if ($interface->getAttributes(Service::class) !== []) { + return true; + } + } + + return false; + } + public static function init(): void { \ini_set('display_errors', 'stderr'); diff --git a/harness/php/velox.toml b/harness/php/velox.toml new file mode 100644 index 00000000..6fb934db --- /dev/null +++ b/harness/php/velox.toml @@ -0,0 +1,27 @@ +[roadrunner] +ref = "v2025.1.15" + +[log] +level = "info" +mode = "production" + +[github] + [github.token] + token = "${GITHUB_TOKEN}" + + [github.plugins] + [github.plugins.server] + ref = "v5.2.10" + owner = "roadrunner-server" + repository = "server" + + [github.plugins.logger] + ref = "v5.1.9" + owner = "roadrunner-server" + repository = "logger" + + # A fork cannot be used here: Go resolves the module by its canonical path. + [github.plugins.temporal] + ref = "nexus" + owner = "temporalio" + repository = "roadrunner-temporal" diff --git a/harness/php/worker.php b/harness/php/worker.php index a416389d..a70e3f56 100644 --- a/harness/php/worker.php +++ b/harness/php/worker.php @@ -58,14 +58,6 @@ $converter = new DataConverter(...$converters); $container->bindSingleton(DataConverter::class, $converter); - $factory = WorkerFactory::create(converter: $converter); - $getWorker = static function (string $taskQueue) use (&$workers, $factory): WorkerInterface { - return $workers[$taskQueue] ??= $factory->newWorker( - $taskQueue, - WorkerOptions::new()->withMaxConcurrentActivityExecutionSize(10) - ); - }; - // Create client services $serviceClient = $runtime->command->tlsKey === null && $runtime->command->tlsCert === null ? ServiceClient::create($runtime->address) @@ -79,6 +71,14 @@ $workflowClient = WorkflowClient::create(serviceClient: $serviceClient, options: $options, converter: $converter); $scheduleClient = ScheduleClient::create(serviceClient: $serviceClient, options: $options, converter: $converter); + $factory = WorkerFactory::create(converter: $converter, client: $workflowClient); + $getWorker = static function (string $taskQueue) use (&$workers, $factory): WorkerInterface { + return $workers[$taskQueue] ??= $factory->newWorker( + $taskQueue, + WorkerOptions::new()->withMaxConcurrentActivityExecutionSize(10) + ); + }; + // Bind services $container->bindSingleton(State::class, $runtime); $container->bindSingleton(ServiceClientInterface::class, $serviceClient); @@ -100,6 +100,11 @@ $getWorker($feature->taskQueue)->registerActivityImplementations($container->make($activity)); } + // Register Nexus Services + foreach ($runtime->nexusServices() as $feature => $service) { + $getWorker($feature->taskQueue)->registerNexusServiceImplementation($container->make($service)); + } + $factory->run(); } catch (\Throwable $e) { \td($e);