Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/testing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
31 changes: 30 additions & 1 deletion src/Internal/Declaration/Prototype/WorkflowCollection.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,37 @@
namespace Temporal\Internal\Declaration\Prototype;

use Temporal\Internal\Repository\ArrayRepository;
use Temporal\Internal\Repository\Identifiable;

/**
* @template-extends ArrayRepository<WorkflowPrototype>
*/
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);
}
}
15 changes: 15 additions & 0 deletions src/Internal/Declaration/Prototype/WorkflowPrototype.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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;
Expand Down
5 changes: 4 additions & 1 deletion src/Internal/Declaration/Reader/WorkflowReader.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
3 changes: 3 additions & 0 deletions src/Internal/Transport/Router/GetWorkerInfo.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 => [
Expand Down
16 changes: 15 additions & 1 deletion src/Internal/Transport/Router/StartWorkflow.php
Original file line number Diff line number Diff line change
Expand Up @@ -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),
);
}
Expand Down
34 changes: 21 additions & 13 deletions src/Internal/Workflow/Process/Process.php
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ public function initAndStart(
bool $deferred,
): void {
$handler = $instance->getHandler();
$dynamic = $instance->getPrototype()->isDynamic();
$instance = $context->getWorkflowInstance();
$arguments = null;
$values = [];
Expand All @@ -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);
Expand All @@ -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));
}

Expand Down
11 changes: 10 additions & 1 deletion src/Workflow/WorkflowMethod.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
58 changes: 58 additions & 0 deletions tests/Acceptance/App/DynamicWorkflowFailureTestCase.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

declare(strict_types=1);

namespace Temporal\Tests\Acceptance\App;

use Temporal\Api\Enums\V1\EventType;
use Temporal\Client\WorkflowClientInterface;
use Temporal\Client\WorkflowOptions;
use Temporal\Client\WorkflowStubInterface;
use Temporal\Exception\Client\WorkflowFailedException;
use Temporal\Exception\Failure\ApplicationFailure;
use Temporal\Exception\InvalidArgumentException;
use Temporal\Testing\Transcript\TranscriptLine;

abstract class DynamicWorkflowFailureTestCase extends TestCase
{
protected function assertHandlerFailure(
WorkflowStubInterface $stub,
WorkflowClientInterface $client,
string $probeType,
string $message,
string $cause = \TypeError::class,
): void {
try {
$stub->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));
}
}
7 changes: 7 additions & 0 deletions tests/Acceptance/App/TaskQueueResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ final class TaskQueueResolver
* @var list<class-string>
*/
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,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

declare(strict_types=1);

namespace Temporal\Tests\Acceptance\Extra\Workflow\DynamicWorkflowArrayParameter;

use PHPUnit\Framework\Attributes\Test;
use Temporal\Client\WorkflowClientInterface;
use Temporal\Client\WorkflowStubInterface;
use Temporal\Tests\Acceptance\App\Attribute\Stub;
use Temporal\Tests\Acceptance\App\DynamicWorkflowFailureTestCase;
use Temporal\Workflow;
use Temporal\Workflow\WorkflowInterface;
use Temporal\Workflow\WorkflowMethod;

class DynamicWorkflowArrayParameterTest extends DynamicWorkflowFailureTestCase
{
#[Test]
public function arrayParameterFailsWithSdkException(
#[Stub('Extra_Workflow_Dynamic_ArrayParameter', args: [['hello', 42]])]
WorkflowStubInterface $stub,
WorkflowClientInterface $client,
): void {
$this->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());
}
}
Loading
Loading