diff --git a/src/Internal/Declaration/Prototype/WorkflowCollection.php b/src/Internal/Declaration/Prototype/WorkflowCollection.php index 7c31d603d..0d2c4c69a 100644 --- a/src/Internal/Declaration/Prototype/WorkflowCollection.php +++ b/src/Internal/Declaration/Prototype/WorkflowCollection.php @@ -12,8 +12,37 @@ namespace Temporal\Internal\Declaration\Prototype; use Temporal\Internal\Repository\ArrayRepository; +use Temporal\Internal\Repository\Identifiable; /** * @template-extends ArrayRepository */ -final class WorkflowCollection extends ArrayRepository {} +final class WorkflowCollection extends ArrayRepository +{ + /** + * A dynamic (catch-all) workflow is the single fallback used when no + * statically registered workflow matches the requested type. As in the + * other SDKs (Go panics, Python raises), at most one may be registered per + * worker — a second would make dispatch ambiguous. + */ + public function add(Identifiable $entry, bool $overwrite = false): void + { + if ($entry instanceof WorkflowPrototype && $entry->isDynamic()) { + foreach ($this as $existing) { + if ($existing instanceof WorkflowPrototype + && $existing->isDynamic() + && $existing->getID() !== $entry->getID() + ) { + throw new \LogicException(\sprintf( + 'Cannot register dynamic workflow "%s": a dynamic (catch-all) workflow "%s" is ' + . 'already registered. At most one dynamic workflow is allowed per worker.', + $entry->getID(), + $existing->getID(), + )); + } + } + } + + parent::add($entry, $overwrite); + } +} diff --git a/src/Internal/Declaration/Prototype/WorkflowPrototype.php b/src/Internal/Declaration/Prototype/WorkflowPrototype.php index cf47d7fae..0934dcee8 100644 --- a/src/Internal/Declaration/Prototype/WorkflowPrototype.php +++ b/src/Internal/Declaration/Prototype/WorkflowPrototype.php @@ -44,6 +44,7 @@ final class WorkflowPrototype extends Prototype private ?MethodRetry $methodRetry = null; private ?ReturnType $returnType = null; private bool $hasInitializer = false; + private bool $dynamic = false; private VersioningBehavior $versioningBehavior; /** @@ -72,6 +73,20 @@ public function setHasInitializer(bool $hasInitializer): void $this->hasInitializer = $hasInitializer; } + /** + * Whether this is the dynamic (catch-all) workflow, invoked when no + * statically registered workflow matches the requested type name. + */ + public function isDynamic(): bool + { + return $this->dynamic; + } + + public function setDynamic(bool $dynamic): void + { + $this->dynamic = $dynamic; + } + public function getCronSchedule(): ?CronSchedule { return $this->cronSchedule; diff --git a/src/Internal/Declaration/Reader/WorkflowReader.php b/src/Internal/Declaration/Reader/WorkflowReader.php index d97877a22..55dc116eb 100644 --- a/src/Internal/Declaration/Reader/WorkflowReader.php +++ b/src/Internal/Declaration/Reader/WorkflowReader.php @@ -432,6 +432,9 @@ private function findProto( $name = $info->name ?? $interface->getShortName(); - return new WorkflowPrototype($name, $handler, $class); + $prototype = new WorkflowPrototype($name, $handler, $class); + $prototype->setDynamic($info->dynamic); + + return $prototype; } } diff --git a/src/Internal/Transport/Router/GetWorkerInfo.php b/src/Internal/Transport/Router/GetWorkerInfo.php index 596c214d4..ac435962e 100644 --- a/src/Internal/Transport/Router/GetWorkerInfo.php +++ b/src/Internal/Transport/Router/GetWorkerInfo.php @@ -51,6 +51,9 @@ private function workerToArray(WorkerInterface $worker): array 'queries' => \array_keys($workflow->getQueryHandlers()), 'signals' => \array_keys($workflow->getSignalHandlers()), 'versioning_behavior' => $workflow->getVersioningBehavior()->value, + // Advertise the dynamic (catch-all) workflow so the RoadRunner + // temporal plugin registers a Go dynamic-workflow proxy for it. + 'dynamic' => $workflow->isDynamic(), ]; $activityMap = static fn(ActivityPrototype $activity): array => [ diff --git a/src/Internal/Transport/Router/StartWorkflow.php b/src/Internal/Transport/Router/StartWorkflow.php index 14e12083e..3d0cc0d70 100644 --- a/src/Internal/Transport/Router/StartWorkflow.php +++ b/src/Internal/Transport/Router/StartWorkflow.php @@ -91,7 +91,21 @@ public function handle(ServerRequestInterface $request, array $headers, Deferred private function findWorkflowOrFail(WorkflowInfo $info): WorkflowPrototype { - return $this->services->workflows->find($info->type->name) ?? throw new \OutOfRangeException( + $found = $this->services->workflows->find($info->type->name); + + if ($found instanceof WorkflowPrototype) { + return $found; + } + + // Fall back to the dynamic (catch-all) workflow, if one is registered. + // The handler reads the real type name via Workflow::getInfo()->type. + foreach ($this->services->workflows as $prototype) { + if ($prototype instanceof WorkflowPrototype && $prototype->isDynamic()) { + return $prototype; + } + } + + throw new \OutOfRangeException( \sprintf(self::ERROR_NOT_FOUND, $info->type->name), ); } diff --git a/src/Workflow/WorkflowMethod.php b/src/Workflow/WorkflowMethod.php index 4195f84b4..12e07d585 100644 --- a/src/Workflow/WorkflowMethod.php +++ b/src/Workflow/WorkflowMethod.php @@ -35,11 +35,23 @@ final class WorkflowMethod #[Immutable] public ?string $name = null; + /** + * Marks this as a dynamic (catch-all) workflow: it is invoked when the + * worker receives a workflow whose type name is not statically registered. + * At most one dynamic workflow may be registered per worker. The handler + * reads the actual type name via {@see \Temporal\Workflow::getInfo()} and + * receives the raw arguments (declare a {@see \Temporal\DataConverter\ValuesInterface} + * parameter to access them). + */ + #[Immutable] + public bool $dynamic = false; + /** * @param non-empty-string|null $name */ - public function __construct(?string $name = null) + public function __construct(?string $name = null, bool $dynamic = false) { $this->name = $name; + $this->dynamic = $dynamic; } } diff --git a/tests/Unit/Declaration/Fixture/WorkflowWithAnotherDynamic.php b/tests/Unit/Declaration/Fixture/WorkflowWithAnotherDynamic.php new file mode 100644 index 000000000..fc5c390af --- /dev/null +++ b/tests/Unit/Declaration/Fixture/WorkflowWithAnotherDynamic.php @@ -0,0 +1,26 @@ +assertNull($prototype->getHandler()); } + /** + * @param WorkflowReader $reader + * @throws \ReflectionException + */ + #[TestDox("Reading a dynamic (catch-all) workflow sets the dynamic flag")] + #[DataProvider('workflowReaderDataProvider')] + public function testDynamicWorkflow(WorkflowReader $reader): void + { + $this->assertTrue($reader->fromClass(WorkflowWithDynamic::class)->isDynamic()); + $this->assertFalse($reader->fromClass(SimpleWorkflow::class)->isDynamic()); + } + + /** + * @param WorkflowReader $reader + * @throws \ReflectionException + */ + #[TestDox("At most one dynamic workflow may be registered per worker")] + #[DataProvider('workflowReaderDataProvider')] + public function testMultipleDynamicWorkflowsAreRejected(WorkflowReader $reader): void + { + $collection = new WorkflowCollection(); + $collection->add($reader->fromClass(WorkflowWithDynamic::class)); + + $this->expectException(\LogicException::class); + $collection->add($reader->fromClass(WorkflowWithAnotherDynamic::class)); + } + /** * @param WorkflowReader $reader * @throws \ReflectionException