diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index acf641f17..f6de5bc86 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -58,3 +58,34 @@ jobs: fail-fast: false test-command: composer test:accept-fast download-binaries: true + + dynamic-workflow-compatibility: + name: Dynamic Workflow Compatibility + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + roadrunner: ['3.0.0', '2025.1.15'] + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TEMPORAL_HEADLESS: true + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 + + - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + with: + php-version: '8.5' + extensions: dom, sockets, grpc, curl + + - uses: ramsey/composer-install@5c2bcf28d7b060ef3c601d7b476d5430a7b46c27 # v4 + + - name: Download binaries + run: vendor/bin/dload get 'rr:${{ matrix.roadrunner }}' temporal --no-interaction + + - name: Test dynamic workflow compatibility + run: >- + vendor/bin/phpunit tests/Acceptance/Extra/Workflow/DynamicWorkflowTest.php + --filter=roadRunnerVersionCompatibility --testdox + env: + TEMPORAL_TRANSCRIPT_DUMP_ON_FAIL: '1' 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 91afe2b55..bab7dc3c4 100644 --- a/src/Internal/Transport/Router/GetWorkerInfo.php +++ b/src/Internal/Transport/Router/GetWorkerInfo.php @@ -54,6 +54,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 156208df8..b875a84f6 100644 --- a/src/Internal/Transport/Router/StartWorkflow.php +++ b/src/Internal/Transport/Router/StartWorkflow.php @@ -104,7 +104,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/Internal/Workflow/Process/Process.php b/src/Internal/Workflow/Process/Process.php index ecbea469a..b44f1a706 100644 --- a/src/Internal/Workflow/Process/Process.php +++ b/src/Internal/Workflow/Process/Process.php @@ -186,6 +186,7 @@ public function initAndStart( bool $deferred, ): void { $handler = $instance->getHandler(); + $dynamic = $instance->getPrototype()->isDynamic(); $instance = $context->getWorkflowInstance(); $arguments = null; $values = []; @@ -195,17 +196,20 @@ public function initAndStart( // // Resolve arguments if #[WorkflowInit] is used if ($instance->getPrototype()->hasInitializer()) { - // Resolve args - $values = $handler->resolveArguments($context->getInput()); - $arguments = EncodedValues::fromValues($values); - /** @psalm-suppress InaccessibleProperty */ - $context = $context->withInput( - new Input( - $context->getInfo(), - $arguments, - $context->getHeader(), - ), - ); + if ($dynamic) { + $values = [$context->getInput()]; + } else { + $values = $handler->resolveArguments($context->getInput()); + $arguments = EncodedValues::fromValues($values); + /** @psalm-suppress InaccessibleProperty */ + $context = $context->withInput( + new Input( + $context->getInfo(), + $arguments, + $context->getHeader(), + ), + ); + } } $this->setContext($context); @@ -220,9 +224,13 @@ public function initAndStart( $this->services->interceptorProvider ->getPipeline(WorkflowInboundCallsInterceptor::class) ->with( - function (WorkflowInput $input) use ($context, $arguments, $handler, $deferred): void { + function (WorkflowInput $input) use ($context, $arguments, $handler, $deferred, $dynamic): void { // Prepare typed input if values have been changed - if ($arguments === null || $input->arguments !== $context->getInput()) { + if ($dynamic) { + // Dynamic workflows receive the untouched argument collection so + // they can interpret types that are unknown at registration time. + $arguments = EncodedValues::fromValues([$input->arguments]); + } elseif ($arguments === null || $input->arguments !== $context->getInput()) { $arguments = EncodedValues::fromValues($handler->resolveArguments($input->arguments)); } diff --git a/src/Workflow/WorkflowMethod.php b/src/Workflow/WorkflowMethod.php index 4195f84b4..a336ca096 100644 --- a/src/Workflow/WorkflowMethod.php +++ b/src/Workflow/WorkflowMethod.php @@ -35,11 +35,20 @@ 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. + */ + #[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/Acceptance/App/DynamicWorkflowFailureTestCase.php b/tests/Acceptance/App/DynamicWorkflowFailureTestCase.php new file mode 100644 index 000000000..d06f1a614 --- /dev/null +++ b/tests/Acceptance/App/DynamicWorkflowFailureTestCase.php @@ -0,0 +1,58 @@ +getResult(timeout: 5); + self::fail('An invalid dynamic handler must fail the workflow.'); + } catch (WorkflowFailedException $exception) { + $failure = $exception->getPrevious(); + self::assertInstanceOf(ApplicationFailure::class, $failure); + self::assertSame(InvalidArgumentException::class, $failure->getType()); + self::assertSame($message, $failure->getOriginalMessage()); + self::assertInstanceOf(ApplicationFailure::class, $failure->getPrevious()); + self::assertSame($cause, $failure->getPrevious()->getType()); + } + + $starts = \array_values(\array_filter( + $this->readCurrentTestTranscript(), + static fn(TranscriptLine $line): bool => $line->getAttribute('event') === 'workflow_execute_start' + && $line->getAttribute('workflow_id') === $stub->getExecution()->getID(), + )); + self::assertCount(1, $starts); + + $events = []; + foreach ($client->getWorkflowHistory($stub->getExecution()) as $event) { + $events[] = $event->getEventType(); + } + self::assertContains(EventType::EVENT_TYPE_WORKFLOW_EXECUTION_FAILED, $events); + self::assertNotContains(EventType::EVENT_TYPE_WORKFLOW_TASK_FAILED, $events); + + $probe = $client->newUntypedWorkflowStub( + $probeType, + WorkflowOptions::new()->withTaskQueue($stub->getOptions()->taskQueue), + ); + $client->start($probe); + self::assertSame($starts[0]->processId, $probe->getResult('int', timeout: 5)); + } +} diff --git a/tests/Acceptance/App/TaskQueueResolver.php b/tests/Acceptance/App/TaskQueueResolver.php index b0d6b5890..422b8f935 100644 --- a/tests/Acceptance/App/TaskQueueResolver.php +++ b/tests/Acceptance/App/TaskQueueResolver.php @@ -14,6 +14,13 @@ final class TaskQueueResolver * @var list */ private const SHARED_QUEUE_EXCLUSIONS = [ + \Temporal\Tests\Acceptance\Extra\Workflow\DynamicWorkflow\DynamicWorkflowTest::class, + \Temporal\Tests\Acceptance\Extra\Workflow\DynamicWorkflowInit\DynamicWorkflowInitTest::class, + \Temporal\Tests\Acceptance\Extra\Workflow\DynamicWorkflowArrayParameter\DynamicWorkflowArrayParameterTest::class, + \Temporal\Tests\Acceptance\Extra\Workflow\DynamicWorkflowNoParameter\DynamicWorkflowNoParameterTest::class, + \Temporal\Tests\Acceptance\Extra\Workflow\DynamicWorkflowRequiredParameter\DynamicWorkflowRequiredParameterTest::class, + \Temporal\Tests\Acceptance\Extra\Workflow\DynamicWorkflowScalarParameter\DynamicWorkflowScalarParameterTest::class, + \Temporal\Tests\Acceptance\Extra\Workflow\DynamicWorkflowUntypedParameter\DynamicWorkflowUntypedParameterTest::class, \Temporal\Tests\Acceptance\Extra\Workflow\WorkflowA\WorkflowATest::class, \Temporal\Tests\Acceptance\Extra\Workflow\WorkflowB\WorkflowBTest::class, \Temporal\Tests\Acceptance\Harness\Activity\RetryOnError\RetryOnErrorTest::class, diff --git a/tests/Acceptance/Extra/Workflow/DynamicWorkflowArrayParameterTest.php b/tests/Acceptance/Extra/Workflow/DynamicWorkflowArrayParameterTest.php new file mode 100644 index 000000000..e12122065 --- /dev/null +++ b/tests/Acceptance/Extra/Workflow/DynamicWorkflowArrayParameterTest.php @@ -0,0 +1,46 @@ +assertHandlerFailure($stub, $client, 'Extra_Workflow_Dynamic_ArrayParameter_Probe', TestWorkflow::class . '::handle(): Argument #1 ($values) must be of type array, Temporal\\DataConverter\\EncodedValues given'); + } +} + +#[WorkflowInterface] +class TestWorkflow +{ + #[WorkflowMethod(dynamic: true)] + public function handle(array $values): array + { + return $values; + } +} + +#[WorkflowInterface] +class ProbeWorkflow +{ + #[WorkflowMethod('Extra_Workflow_Dynamic_ArrayParameter_Probe')] + public function handle() + { + return yield Workflow::sideEffect(static fn(): int => \getmypid()); + } +} diff --git a/tests/Acceptance/Extra/Workflow/DynamicWorkflowInitTest.php b/tests/Acceptance/Extra/Workflow/DynamicWorkflowInitTest.php new file mode 100644 index 000000000..5aaaaba18 --- /dev/null +++ b/tests/Acceptance/Extra/Workflow/DynamicWorkflowInitTest.php @@ -0,0 +1,55 @@ +newUntypedWorkflowStub( + 'Extra_Workflow_Dynamic_Init', + WorkflowOptions::new() + ->withTaskQueue($feature->taskQueue) + ->withWorkflowExecutionTimeout('15 seconds'), + ); + $client->start($stub, ...$arguments); + + self::assertSame([ + 'constructor' => $arguments, + 'handler' => $arguments, + ], $stub->getResult('array', 5)); + } + } +} + +#[WorkflowInterface] +class TestWorkflow +{ + #[WorkflowInit] + public function __construct(private ValuesInterface $arguments) {} + + #[WorkflowMethod(dynamic: true)] + public function handle(ValuesInterface $arguments): array + { + return [ + 'constructor' => $this->arguments->getValues(), + 'handler' => $arguments->getValues(), + ]; + } +} diff --git a/tests/Acceptance/Extra/Workflow/DynamicWorkflowNoParameterTest.php b/tests/Acceptance/Extra/Workflow/DynamicWorkflowNoParameterTest.php new file mode 100644 index 000000000..cc8fe5f5b --- /dev/null +++ b/tests/Acceptance/Extra/Workflow/DynamicWorkflowNoParameterTest.php @@ -0,0 +1,34 @@ +getResult('string', 5)); + } +} + +#[WorkflowInterface] +class TestWorkflow +{ + #[WorkflowMethod(dynamic: true)] + public function handle(): string + { + return Workflow::getInfo()->type->name; + } +} diff --git a/tests/Acceptance/Extra/Workflow/DynamicWorkflowRequiredParameterTest.php b/tests/Acceptance/Extra/Workflow/DynamicWorkflowRequiredParameterTest.php new file mode 100644 index 000000000..550783dbf --- /dev/null +++ b/tests/Acceptance/Extra/Workflow/DynamicWorkflowRequiredParameterTest.php @@ -0,0 +1,47 @@ +assertHandlerFailure($stub, $client, 'Extra_Workflow_Dynamic_RequiredParameter_Probe', 'Too few arguments to function ' . TestWorkflow::class . '::handle(), 1 passed and exactly 2 expected', \ArgumentCountError::class); + } +} + +#[WorkflowInterface] +class TestWorkflow +{ + #[WorkflowMethod(dynamic: true)] + public function handle(ValuesInterface $values, string $required): string + { + return $required; + } +} + +#[WorkflowInterface] +class ProbeWorkflow +{ + #[WorkflowMethod('Extra_Workflow_Dynamic_RequiredParameter_Probe')] + public function handle() + { + return yield Workflow::sideEffect(static fn(): int => \getmypid()); + } +} diff --git a/tests/Acceptance/Extra/Workflow/DynamicWorkflowScalarParameterTest.php b/tests/Acceptance/Extra/Workflow/DynamicWorkflowScalarParameterTest.php new file mode 100644 index 000000000..8452b941e --- /dev/null +++ b/tests/Acceptance/Extra/Workflow/DynamicWorkflowScalarParameterTest.php @@ -0,0 +1,46 @@ +assertHandlerFailure($stub, $client, 'Extra_Workflow_Dynamic_ScalarParameter_Probe', TestWorkflow::class . '::handle(): Argument #1 ($value) must be of type string, Temporal\\DataConverter\\EncodedValues given'); + } +} + +#[WorkflowInterface] +class TestWorkflow +{ + #[WorkflowMethod(dynamic: true)] + public function handle(string $value): string + { + return $value; + } +} + +#[WorkflowInterface] +class ProbeWorkflow +{ + #[WorkflowMethod('Extra_Workflow_Dynamic_ScalarParameter_Probe')] + public function handle() + { + return yield Workflow::sideEffect(static fn(): int => \getmypid()); + } +} diff --git a/tests/Acceptance/Extra/Workflow/DynamicWorkflowTest.php b/tests/Acceptance/Extra/Workflow/DynamicWorkflowTest.php new file mode 100644 index 000000000..36642c863 --- /dev/null +++ b/tests/Acceptance/Extra/Workflow/DynamicWorkflowTest.php @@ -0,0 +1,143 @@ +getResult('string'); + self::assertMatchesRegularExpression('/^v?(3\.|2025\.)/', $version); + + $stub = $client->newUntypedWorkflowStub( + 'Extra_Workflow_Dynamic_Compatibility', + WorkflowOptions::new() + ->withTaskQueue($feature->taskQueue) + ->withWorkflowExecutionTimeout('15 seconds'), + ); + $client->start($stub, 'hello', 42); + + try { + if (\str_starts_with(\ltrim($version, 'v'), '2025.')) { + // Older RoadRunner retries unknown types without invoking the PHP handler. + $deadline = \microtime(true) + 5; + do { + foreach ($client->getWorkflowHistory($stub->getExecution()) as $event) { + if ($event->getEventType() !== EventType::EVENT_TYPE_WORKFLOW_TASK_FAILED) { + continue; + } + + $message = $event->getWorkflowTaskFailedEventAttributes()->getFailure()->getMessage(); + self::assertStringContainsString('unable to find workflow type:', $message); + self::assertStringContainsString('Extra_Workflow_Dynamic_Compatibility', $message); + return; + } + + \usleep(100_000); + } while (\microtime(true) < $deadline); + + self::fail('Expected RoadRunner v2025 to reject the unregistered workflow type.'); + } + + self::assertSame([ + 'type' => 'Extra_Workflow_Dynamic_Compatibility', + 'args' => ['hello', 42], + ], $stub->getResult('array', 5)); + } finally { + if ($stub->describe()->info->status === WorkflowExecutionStatus::Running) { + $stub->terminate('test-end'); + } + } + } + + #[Test] + public function noParameters( + #[Stub('Extra_Workflow_Dynamic_Empty')] + WorkflowStubInterface $stub, + ): void { + self::assertSame([ + 'type' => 'Extra_Workflow_Dynamic_Empty', + 'args' => [], + ], $stub->getResult('array')); + } + + #[Test] + public function scalarParameters( + #[Stub('Extra_Workflow_Dynamic_Scalars', args: ['hello', 42])] + WorkflowStubInterface $stub, + ): void { + self::assertSame([ + 'type' => 'Extra_Workflow_Dynamic_Scalars', + 'args' => ['hello', 42], + ], $stub->getResult('array')); + } + + #[Test] + public function structuredParameters( + #[Stub('Extra_Workflow_Dynamic_Structured', args: [ + 'pipeline', 0, ['steps' => ['publish', 'notify'], 'enabled' => false], + ])] + WorkflowStubInterface $stub, + ): void { + self::assertSame([ + 'type' => 'Extra_Workflow_Dynamic_Structured', + 'args' => ['pipeline', 0, ['steps' => ['publish', 'notify'], 'enabled' => false]], + ], $stub->getResult('array')); + } + + #[Test] + public function mixedParameters( + #[Stub('Extra_Workflow_Dynamic_Mixed', args: ['', 0, null, false, 3.5, ['first', 'second'], null])] + WorkflowStubInterface $stub, + ): void { + self::assertSame([ + 'type' => 'Extra_Workflow_Dynamic_Mixed', + 'args' => ['', 0, null, false, 3.5, ['first', 'second'], null], + ], $stub->getResult('array')); + } +} + +#[WorkflowInterface] +class VersionWorkflow +{ + #[WorkflowMethod('Extra_Workflow_Dynamic_Version')] + public function handle(): string + { + return Environment::fromGlobals()->getVersion(); + } +} + +#[WorkflowInterface] +class TestWorkflow +{ + #[WorkflowMethod(dynamic: true)] + public function handle(ValuesInterface $args): array + { + return [ + 'type' => Workflow::getInfo()->type->name, + 'args' => $args->getValues(), + ]; + } +} diff --git a/tests/Acceptance/Extra/Workflow/DynamicWorkflowUntypedParameterTest.php b/tests/Acceptance/Extra/Workflow/DynamicWorkflowUntypedParameterTest.php new file mode 100644 index 000000000..f85517528 --- /dev/null +++ b/tests/Acceptance/Extra/Workflow/DynamicWorkflowUntypedParameterTest.php @@ -0,0 +1,54 @@ + 'Extra_Workflow_Dynamic_UntypedEmpty', + 'args' => [], + 'optional' => 'default', + ], $stub->getResult('array', 5)); + } + + #[Test] + public function untypedParameterReceivesOneCollectionAndKeepsOptionalDefault( + #[Stub('Extra_Workflow_Dynamic_UntypedMixed', args: ['hello', 42, null, false, ['nested' => true]])] + WorkflowStubInterface $stub, + ): void { + self::assertSame([ + 'type' => 'Extra_Workflow_Dynamic_UntypedMixed', + 'args' => ['hello', 42, null, false, ['nested' => true]], + 'optional' => 'default', + ], $stub->getResult('array', 5)); + } +} + +#[WorkflowInterface] +class TestWorkflow +{ + #[WorkflowMethod(dynamic: true)] + public function handle($values, string $optional = 'default'): array + { + return [ + 'type' => Workflow::getInfo()->type->name, + 'args' => $values->getValues(), + 'optional' => $optional, + ]; + } +} diff --git a/tests/Unit/Declaration/Fixture/WorkflowWithAnotherDynamic.php b/tests/Unit/Declaration/Fixture/WorkflowWithAnotherDynamic.php new file mode 100644 index 000000000..cb37b71b5 --- /dev/null +++ b/tests/Unit/Declaration/Fixture/WorkflowWithAnotherDynamic.php @@ -0,0 +1,24 @@ +assertNull($prototype->getHandler()); } + /** @throws \ReflectionException */ + #[TestDox("Reading a dynamic (catch-all) workflow sets the dynamic flag")] + public function testDynamicWorkflow(): void + { + $reader = new WorkflowReader(new AttributeReader()); + + $this->assertTrue($reader->fromClass(WorkflowWithDynamic::class)->isDynamic()); + $this->assertFalse($reader->fromClass(SimpleWorkflow::class)->isDynamic()); + } + + /** @throws \ReflectionException */ + #[TestDox("At most one dynamic workflow may be registered per worker")] + public function testMultipleDynamicWorkflowsAreRejected(): void + { + $reader = new WorkflowReader(new AttributeReader()); + $collection = new WorkflowCollection(); + $collection->add($reader->fromClass(WorkflowWithDynamic::class)); + + $this->expectException(\LogicException::class); + $collection->add($reader->fromClass(WorkflowWithAnotherDynamic::class)); + } + /** * @param WorkflowReader $reader * @throws \ReflectionException diff --git a/tests/Unit/Framework/WorkerTestCase.php b/tests/Unit/Framework/WorkerTestCase.php index 63e2e51f6..353dabeba 100644 --- a/tests/Unit/Framework/WorkerTestCase.php +++ b/tests/Unit/Framework/WorkerTestCase.php @@ -4,6 +4,7 @@ namespace Temporal\Tests\Unit\Framework; +use Temporal\DataConverter\ValuesInterface; use Temporal\Tests\Unit\AbstractUnit; use Temporal\Worker\WorkerFactoryInterface; use Temporal\Worker\WorkerInterface; @@ -18,17 +19,10 @@ final class WorkerTestCase extends AbstractUnit { private WorkerFactoryInterface $factory; + /** @var WorkerMock|WorkerInterface */ private $worker; - protected function setUp(): void - { - $this->factory = WorkerFactoryMock::create(); - $this->worker = $this->factory->newWorker(); - - parent::setUp(); - } - public function testRunWorker(): void { $this->worker->registerWorkflowObject( @@ -38,15 +32,104 @@ class { #[WorkflowMethod(name: 'SimpleWorkflow')] public function handler(): iterable { - $result = yield Workflow::awaitWithTimeout(5, fn() => false); + $result = yield Workflow::awaitWithTimeout(5, static fn() => false); assertFalse($result); return $result; } - } + }, ); $this->worker->runWorkflow('SimpleWorkflow'); $this->worker->expectTimer(5); $this->factory->run($this->worker); } + + public function testDynamicWorkflowReceivesActualTypeAndRawArguments(): void + { + $this->worker->registerWorkflowObject( + new + #[Workflow\WorkflowInterface] + class { + #[WorkflowMethod(name: 'DynamicWorkflow', dynamic: true)] + public function handler(ValuesInterface $arguments): array + { + return [ + Workflow::getInfo()->type->name, + $arguments->getValues(), + ]; + } + }, + ); + + $this->worker->runWorkflow('RuntimeDefinedWorkflow', 'alpha', 42); + $this->worker->assertWorkflowReturns(['RuntimeDefinedWorkflow', ['alpha', 42]]); + + $this->factory->run($this->worker); + self::assertCount(1, $this->worker->getWorkflows()); + } + + public function testNamedWorkflowTakesPrecedenceOverDynamicWorkflow(): void + { + $this->worker->registerWorkflowObject( + new + #[Workflow\WorkflowInterface] + class { + #[WorkflowMethod(name: 'DynamicWorkflow', dynamic: true)] + public function handler(ValuesInterface $arguments): string + { + return 'dynamic'; + } + }, + ); + $this->worker->registerWorkflowObject( + new + #[Workflow\WorkflowInterface] + class { + #[WorkflowMethod(name: 'NamedWorkflow')] + public function handler(): string + { + return 'named'; + } + }, + ); + + $this->worker->runWorkflow('NamedWorkflow'); + $this->worker->assertWorkflowReturns('named'); + + $this->factory->run($this->worker); + self::assertCount(2, $this->worker->getWorkflows()); + } + + public function testEachWorkerCanRegisterOneDynamicWorkflow(): void + { + $secondWorker = $this->factory->newWorker('other-task-queue'); + + $this->worker->registerWorkflowObject( + new + #[Workflow\WorkflowInterface] + class { + #[WorkflowMethod(name: 'DynamicWorkflow', dynamic: true)] + public function handler(): void {} + }, + ); + $secondWorker->registerWorkflowTypes(PerWorkerDynamicWorkflow::class); + + self::assertCount(1, $this->worker->getWorkflows()); + self::assertCount(1, $secondWorker->getWorkflows()); + } + + protected function setUp(): void + { + $this->factory = WorkerFactoryMock::create(); + $this->worker = $this->factory->newWorker(); + + parent::setUp(); + } +} + +#[Workflow\WorkflowInterface] +final class PerWorkerDynamicWorkflow +{ + #[WorkflowMethod(name: 'DynamicWorkflow', dynamic: true)] + public function handler(): void {} } diff --git a/tests/Unit/Router/GetWorkerInfoTestCase.php b/tests/Unit/Router/GetWorkerInfoTestCase.php index 047c016b9..27065bafc 100644 --- a/tests/Unit/Router/GetWorkerInfoTestCase.php +++ b/tests/Unit/Router/GetWorkerInfoTestCase.php @@ -5,6 +5,7 @@ namespace Temporal\Tests\Unit\Router; use React\Promise\Deferred; +use Spiral\Attributes\AttributeReader; use Temporal\Api\Common\V1\Payload; use Temporal\DataConverter\BinaryConverter; use Temporal\DataConverter\DataConverter; @@ -13,12 +14,16 @@ use Temporal\DataConverter\PayloadConverterInterface; use Temporal\DataConverter\ProtoConverter; use Temporal\DataConverter\ProtoJsonConverter; +use Temporal\DataConverter\RawValue; use Temporal\DataConverter\Type; +use Temporal\Internal\Declaration\Reader\WorkflowReader; use Temporal\Internal\Marshaller\MarshallerInterface; use Temporal\Internal\Repository\ArrayRepository; use Temporal\Internal\Transport\Router\GetWorkerInfo; use Temporal\Plugin\PluginRegistry; use Temporal\Tests\Unit\AbstractUnit; +use Temporal\Tests\Unit\Declaration\Fixture\SimpleWorkflow; +use Temporal\Tests\Unit\Declaration\Fixture\WorkflowWithDynamic; use Temporal\Tests\Unit\Framework\Requests\GetWorkerInfo as GetWorkerInfoRequest; use Temporal\Worker\ServiceCredentials; use Temporal\Worker\WorkerInterface; @@ -26,6 +31,49 @@ final class GetWorkerInfoTestCase extends AbstractUnit { + public function testDynamicWorkflowFlagIsAdvertised(): void + { + $reader = new WorkflowReader(new AttributeReader()); + $worker = $this->createMock(WorkerInterface::class); + $options = WorkerOptions::new(); + + $worker->method('getID')->willReturn('test-queue'); + $worker->method('getOptions')->willReturn($options); + $worker->method('getWorkflows')->willReturn([ + $reader->fromClass(WorkflowWithDynamic::class), + $reader->fromClass(SimpleWorkflow::class), + ]); + $worker->method('getActivities')->willReturn([]); + + $marshaller = $this->createMock(MarshallerInterface::class); + $marshaller->expects($this->once()) + ->method('marshal') + ->with($options) + ->willReturn([]); + + $router = new GetWorkerInfo( + new ArrayRepository([$worker]), + $marshaller, + ServiceCredentials::create(), + new PluginRegistry(), + ); + $resolver = new Deferred(); + $response = null; + $resolver->promise()->then(static function (EncodedValues $values) use (&$response): void { + $response = $values->getValue(0); + }); + + $router->handle(new GetWorkerInfoRequest(), [], $resolver); + + self::assertInstanceOf(RawValue::class, $response); + $response = DataConverter::createDefault()->fromPayload( + $response->getPayload(), + Type::TYPE_ANY, + ); + self::assertSame(true, $response->Workflows[0]->dynamic); + self::assertSame(false, $response->Workflows[1]->dynamic); + } + public function testWorkerInfoStaysPlainUnderCustomConverter(): void { $worker = $this->createMock(WorkerInterface::class); diff --git a/tests/Unit/Router/StartWorkflowTestCase.php b/tests/Unit/Router/StartWorkflowTestCase.php index a42f54c8c..0a49520a6 100644 --- a/tests/Unit/Router/StartWorkflowTestCase.php +++ b/tests/Unit/Router/StartWorkflowTestCase.php @@ -86,6 +86,24 @@ public function testStartingAlreadyRunningWorkflow(): void $this->router->handle($request, [], new Deferred()); } + public function testUnknownWorkflowWithoutDynamicWorkflowThrows(): void + { + $request = new Request($runId = Uuid::v4(), 'UnknownWorkflow', EncodedValues::fromValues([])); + + $workflowInfo = new WorkflowInfo(); + $workflowInfo->type->name = 'UnknownWorkflow'; + $workflowInfo->execution = new WorkflowExecution('123', $runId); + + $this->marshaller->expects($this->once()) + ->method('unmarshal') + ->willReturn(new Input($workflowInfo)); + + $this->expectException(\OutOfRangeException::class); + $this->expectExceptionMessage('Workflow with the specified name "UnknownWorkflow" was not registered'); + + $this->router->handle($request, [], new Deferred()); + } + protected function setUp(): void { $workflow = new \stdClass();