From 0d7bb4e9300b837b7861c32f561fad0db53491b4 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Sat, 10 Oct 2026 20:20:04 +0000 Subject: [PATCH 1/8] Reproduce nested attributed workflow state leaking across replay --- tests/WorkflowHandlerLifetimeTest.php | 42 +++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/WorkflowHandlerLifetimeTest.php b/tests/WorkflowHandlerLifetimeTest.php index 95ef5991..3c2db866 100644 --- a/tests/WorkflowHandlerLifetimeTest.php +++ b/tests/WorkflowHandlerLifetimeTest.php @@ -22,6 +22,16 @@ final class WorkflowHandlerLifetimeTest extends TestCase { + public function testConstructorCreatedNestedStateIsFreshAcrossExecutionsReplayQueriesAndUpdates(): void + { + $worker = Worker::create( + new Client('https://server.example', transport: new FakeTransport()), + 'php-workers', + )->register(NestedStateWorkflow::class); + + $this->assertReplaySafeLifetime($worker, 'nested-state', 'step-1'); + } + public function testNoContainerWorkflowStateIsFreshAcrossExecutionsAndReplay(): void { $worker = Worker::create( @@ -190,6 +200,38 @@ public function __construct(public readonly string $value) } } +final class NestedStateWorkflow +{ + private readonly object $state; + + public function __construct(private readonly ?WorkflowStepPrefix $prefix = null) + { + $this->state = (object) ['replays' => 0]; + } + + #[Workflow('nested-state')] + public function run(WorkflowContext $context): array + { + ++$this->state->replays; + $prefix = $this->prefix === null ? '' : "{$this->prefix->value}-"; + $result = $context->activity("{$prefix}step-{$this->state->replays}"); + + return ['replays' => $this->state->replays, 'result' => $result]; + } + + #[Query('state')] + public function state(QueryContext $context): int + { + return $this->state->replays; + } + + #[Update('increment')] + public function increment(QueryContext $context, int $amount = 1): int + { + return $this->state->replays += $amount; + } +} + final class StatefulActivities { private int $executions = 0; From 814bbc6ad5f8fc34f8df9338da18ed127c95a546 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Sat, 10 Oct 2026 20:29:40 +0000 Subject: [PATCH 2/8] Construct fresh attributed workflow state through explicit factories --- docs/protocol.md | 17 ++- docs/sdk-reference.md | 48 +++++-- scripts/ci/run-replay-regression-fixture.php | 22 +++ src/Bridge/Laravel/WorkerFactory.php | 3 +- .../DurableWorkflowExtension.php | 16 ++- .../WorkflowLifetimePass.php | 33 +++++ src/Bridge/Symfony/DurableWorkflowBundle.php | 2 + src/Bridge/Symfony/WorkerFactory.php | 17 ++- src/Testing/WorkerTestHarness.php | 8 +- src/Worker/HandlerDefinition.php | 15 +- src/Worker/HandlerDiscovery.php | 23 +-- src/Worker/HandlerResolver.php | 2 +- src/Worker/WorkflowFactory.php | 57 ++++++++ tests/Support/ReplayRegressionFixture.php | 22 +++ tests/WorkflowHandlerLifetimeTest.php | 132 ++++++++++++++++-- tests/compat/laravel.php | 13 ++ tests/compat/symfony.php | 49 ++++++- .../attributed-workflow-nested-state.json | 20 +++ 18 files changed, 449 insertions(+), 50 deletions(-) create mode 100644 src/Bridge/Symfony/DependencyInjection/WorkflowLifetimePass.php create mode 100644 src/Worker/WorkflowFactory.php create mode 100644 tests/fixtures/replay-regressions/attributed-workflow-nested-state.json diff --git a/docs/protocol.md b/docs/protocol.md index 6d8b74a8..a3324e3b 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -94,13 +94,16 @@ interleaved workflow executions cannot consume one another's execution state. Generator-returning workflow handlers are not part of the 2.0 service-mode contract. -Attribute-discovered workflow entry points, queries, and updates receive a fresh -shallow clone of the registered workflow handler template for every invocation. -The template retains constructor-injected collaborators, but mutable properties -on the workflow handler always begin at their registration-time values. Activity -handlers are different: the worker invokes the resolved activity service -instance directly across tasks. Low-level callable registrations likewise keep -the lifetime and captured state of the callable supplied by the application. +Attribute-discovered workflow entry points, queries, and updates receive a newly +constructed handler for every invocation. Class registration uses a fresh +constructor or transient container resolution. Custom construction uses an +explicit `WorkflowFactory`; it creates local mutable state while retaining +configured injected services. Existing workflow objects and singleton workflow +bindings require a factory migration rather than shallow cloning. Invalid +factories are rejected before polling, or produce a recoverable replay error if +they become invalid later. Activity handlers use their resolved service instance +across tasks. Low-level callable registrations keep the lifetime and captured +state of the callable supplied by the application. The runtime fetches all paginated history before replay. Activity handlers can heartbeat and observe cancellation. Query handlers receive immutable committed diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index 081834ab..773c9e6d 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -434,20 +434,50 @@ Worker::create($client, 'php-workers') ->run(); ``` -`register()` resolves class names once and validates every attributed method +`register()` resolves class names and validates every attributed method before registration or polling. With no container, concrete classes with no required constructor arguments are instantiated automatically. Pass any PSR-11 `ContainerInterface` as the third argument to `Worker::create()` when handlers have application dependencies. -Attributed workflow classes have a replay-scoped lifecycle. Registration -captures a clean handler template, then each workflow task replay, query, and -update runs on a fresh shallow clone. Mutable properties on the workflow object -therefore cannot cross workflow IDs, runs, or replay attempts, while -constructor-injected collaborators retain their configured identity. Keep -workflow-local mutable state directly on the handler; injected collaborators -are shared services and must not be used to hold execution-local state. -Workflow handler classes must remain cloneable. +Attributed workflow classes have a replay-scoped lifecycle. Each workflow task +replay, query, and update constructs a new handler. Constructor-created nested +state belongs to that invocation, including mutable objects held by readonly +properties. Constructors and container factories must be free of external side +effects because construction repeats during replay. Perform side effects in +activities. + +With a PSR-11 container, workflow bindings must return a new instance on every +resolution. Dependencies such as API clients, loggers, and configuration may +remain shared. Laravel's bridge resolves workflows for each invocation. The +Symfony bridge makes attributed workflow services non-shared while retaining +their dependencies' configured lifetimes. + +Use `WorkflowFactory` for custom construction or a container that exposes only +singleton workflow services: + +```php +use DurableWorkflow\Worker\WorkflowFactory; + +$sharedClient = $container->get(ApplicationApiClient::class); +$worker->register(new WorkflowFactory( + static fn (): OrderWorkflow => new OrderWorkflow($sharedClient), +)); +``` + +Create execution-local state inside the workflow constructor or factory. Do not +capture a mutable workflow-state object outside the factory and share it among +the new handlers. Injected services retain their identity and must not hold +execution-local state. + +**Registration migration:** replace `register($workflowObject)` with class +registration or `register(new WorkflowFactory(fn () => new YourWorkflow($dependencies)))`. +The SDK cannot infer a configured object's construction recipe or distinguish +its nested state from shared services. Reused factory results and singleton +workflow bindings fail validation before polling. A factory that becomes invalid +after startup produces a recoverable `workflow_instance_factory_invalid` replay +error instead of a terminal workflow completion or failure. Workflow classes no +longer need to be cloneable. Activity services have worker-scoped lifetimes instead: their resolved instance is reused for activity tasks, so they can retain clients and other service diff --git a/scripts/ci/run-replay-regression-fixture.php b/scripts/ci/run-replay-regression-fixture.php index bdda24ca..c29a58a2 100644 --- a/scripts/ci/run-replay-regression-fixture.php +++ b/scripts/ci/run-replay-regression-fixture.php @@ -306,6 +306,26 @@ public function run(WorkflowContext $context): array } } +final class ReplayRegressionAttributedNestedWorkflow +{ + private readonly object $state; + + public function __construct() + { + $this->state = (object) ['invocation' => 0]; + } + + #[WorkflowHandler('golden.attributed-nested-state')] + public function run(WorkflowContext $context): array + { + return [ + 'workflow_id' => $context->workflowId, + 'run_id' => $context->runId, + 'invocation' => ++$this->state->invocation, + ]; + } +} + final class ReplayRegressionConsumer { private const FIXTURE_SCHEMA = 'durable-workflow.replay-regression/v1'; @@ -464,6 +484,8 @@ private static function executeLayout( ); if ($workflowType === 'golden.attributed-stateful') { $worker->register(ReplayRegressionAttributedStatefulWorkflow::class); + } elseif ($workflowType === 'golden.attributed-nested-state') { + $worker->register(ReplayRegressionAttributedNestedWorkflow::class); } else { $worker->registerWorkflow($workflowType, self::workflow($workflowType)); } diff --git a/src/Bridge/Laravel/WorkerFactory.php b/src/Bridge/Laravel/WorkerFactory.php index 3e61055b..5acdc858 100644 --- a/src/Bridge/Laravel/WorkerFactory.php +++ b/src/Bridge/Laravel/WorkerFactory.php @@ -8,6 +8,7 @@ use DurableWorkflow\Bridge\ServiceConfiguration; use DurableWorkflow\Client; use DurableWorkflow\Worker; +use DurableWorkflow\Worker\WorkflowFactory; use Illuminate\Contracts\Container\Container; use Illuminate\Contracts\Events\Dispatcher; use InvalidArgumentException; @@ -64,7 +65,7 @@ public function make(?string $taskQueue = null): Worker $handlers = []; foreach ($this->configuration->handlers as $handler) { - $handlers[] = $this->container->make($handler); + $handlers[] = new WorkflowFactory(fn (): object => $this->container->make($handler)); } return $worker->register(...$handlers); diff --git a/src/Bridge/Symfony/DependencyInjection/DurableWorkflowExtension.php b/src/Bridge/Symfony/DependencyInjection/DurableWorkflowExtension.php index 7d57164a..e9867ed6 100644 --- a/src/Bridge/Symfony/DependencyInjection/DurableWorkflowExtension.php +++ b/src/Bridge/Symfony/DependencyInjection/DurableWorkflowExtension.php @@ -4,12 +4,14 @@ namespace DurableWorkflow\Bridge\Symfony\DependencyInjection; +use DurableWorkflow\Attribute\Workflow; use DurableWorkflow\Bridge\ServiceConfiguration; use DurableWorkflow\Bridge\Symfony\WorkerCommand; use DurableWorkflow\Bridge\Symfony\WorkerFactory; use DurableWorkflow\Client; use DurableWorkflow\WorkflowClientInterface; use Psr\Log\LoggerInterface; +use Symfony\Component\DependencyInjection\Argument\ServiceLocatorArgument; use Symfony\Component\DependencyInjection\Argument\TaggedIteratorArgument; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Extension\Extension; @@ -47,14 +49,24 @@ public function load(array $configs, ContainerBuilder $container): void ->setAutowired(true) ->setAutoconfigured(true); } - $container->findDefinition($handler)->addTag(self::HANDLER_TAG); + $definition = $container->findDefinition($handler); + $definition->addTag(self::HANDLER_TAG); + $class = $definition->getClass() ?? $handler; + if (class_exists($class)) { + foreach ((new \ReflectionClass($class))->getMethods() as $method) { + if ($method->getAttributes(Workflow::class) !== []) { + $definition->setShared(false); + break; + } + } + } } $container->register(WorkerFactory::class, WorkerFactory::class) ->setArguments([ new Reference(ServiceConfiguration::class), new Reference(self::WORKER_CLIENT_SERVICE), - new TaggedIteratorArgument(self::HANDLER_TAG), + new ServiceLocatorArgument(new TaggedIteratorArgument(self::HANDLER_TAG, needsIndexes: true)), new Reference(LoggerInterface::class, ContainerBuilder::NULL_ON_INVALID_REFERENCE), new Reference(EventDispatcherInterface::class, ContainerBuilder::NULL_ON_INVALID_REFERENCE), ]) diff --git a/src/Bridge/Symfony/DependencyInjection/WorkflowLifetimePass.php b/src/Bridge/Symfony/DependencyInjection/WorkflowLifetimePass.php new file mode 100644 index 00000000..2069724e --- /dev/null +++ b/src/Bridge/Symfony/DependencyInjection/WorkflowLifetimePass.php @@ -0,0 +1,33 @@ +getDefinitions() as $definition) { + if (!$definition->isAutoconfigured() && !$definition->hasTag(DurableWorkflowExtension::HANDLER_TAG)) { + continue; + } + $class = $definition->getClass(); + if ($class === null || !class_exists($class)) { + continue; + } + foreach ((new ReflectionClass($class))->getMethods() as $method) { + if ($method->getAttributes(Workflow::class) !== []) { + $definition->setShared(false); + break; + } + } + } + } +} diff --git a/src/Bridge/Symfony/DurableWorkflowBundle.php b/src/Bridge/Symfony/DurableWorkflowBundle.php index f1c859cc..78465543 100644 --- a/src/Bridge/Symfony/DurableWorkflowBundle.php +++ b/src/Bridge/Symfony/DurableWorkflowBundle.php @@ -10,6 +10,7 @@ use DurableWorkflow\Attribute\Update; use DurableWorkflow\Attribute\Workflow; use DurableWorkflow\Bridge\Symfony\DependencyInjection\DurableWorkflowExtension; +use DurableWorkflow\Bridge\Symfony\DependencyInjection\WorkflowLifetimePass; use Symfony\Component\DependencyInjection\ChildDefinition; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Bundle\Bundle; @@ -20,6 +21,7 @@ final class DurableWorkflowBundle extends Bundle public function build(ContainerBuilder $container): void { parent::build($container); + $container->addCompilerPass(new WorkflowLifetimePass()); foreach ([Workflow::class, Activity::class, Query::class, Signal::class, Update::class] as $attribute) { $container->registerAttributeForAutoconfiguration( diff --git a/src/Bridge/Symfony/WorkerFactory.php b/src/Bridge/Symfony/WorkerFactory.php index 7d9a3b26..96e96bbb 100644 --- a/src/Bridge/Symfony/WorkerFactory.php +++ b/src/Bridge/Symfony/WorkerFactory.php @@ -8,20 +8,22 @@ use DurableWorkflow\Bridge\ServiceConfiguration; use DurableWorkflow\Client; use DurableWorkflow\Worker; +use DurableWorkflow\Worker\WorkflowFactory; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Symfony\Contracts\Service\ServiceProviderInterface; /** Builds workers from Symfony's tagged, autowired handler services. */ final class WorkerFactory { /** - * @param iterable $handlers + * @param ServiceProviderInterface|iterable $handlers */ public function __construct( private readonly ServiceConfiguration $configuration, private readonly Client $client, - private readonly iterable $handlers, + private readonly ServiceProviderInterface|iterable $handlers, private readonly ?LoggerInterface $logger = null, private readonly ?EventDispatcherInterface $events = null, ) { @@ -40,8 +42,15 @@ public function make(?string $taskQueue = null): Worker ); $handlers = []; - foreach ($this->handlers as $handler) { - $handlers[] = $handler; + if ($this->handlers instanceof ServiceProviderInterface) { + $provider = $this->handlers; + foreach ($provider->getProvidedServices() as $id => $_type) { + $handlers[] = new WorkflowFactory(static fn (): object => $provider->get($id)); + } + } else { + foreach ($this->handlers as $handler) { + $handlers[] = $handler; + } } if ($handlers === []) { throw new \InvalidArgumentException( diff --git a/src/Testing/WorkerTestHarness.php b/src/Testing/WorkerTestHarness.php index c776ba64..82dd41b1 100644 --- a/src/Testing/WorkerTestHarness.php +++ b/src/Testing/WorkerTestHarness.php @@ -31,6 +31,7 @@ public function __construct(private readonly Worker $worker, ?Client $contextCli * @param list $input * @param list> $history * @param array $task + * @phpstan-impure */ public function runWorkflow( string $workflowType, @@ -47,7 +48,10 @@ public function runWorkflow( ); } - /** @param list $arguments */ + /** + * @param list $arguments + * @phpstan-impure + */ public function runActivity(string $activityType, array $arguments = []): mixed { $context = new ActivityContext( @@ -65,6 +69,7 @@ public function runActivity(string $activityType, array $arguments = []): mixed /** * @param list $arguments * @param list> $history + * @phpstan-impure */ public function runQuery( string $workflowType, @@ -81,6 +86,7 @@ public function runQuery( /** * @param list $arguments * @param list> $history + * @phpstan-impure */ public function runUpdate( string $workflowType, diff --git a/src/Worker/HandlerDefinition.php b/src/Worker/HandlerDefinition.php index c410f642..8ed087ba 100644 --- a/src/Worker/HandlerDefinition.php +++ b/src/Worker/HandlerDefinition.php @@ -5,6 +5,8 @@ namespace DurableWorkflow\Worker; use Closure; +use DurableWorkflow\Exception\NonDeterministicWorkflow; +use Throwable; /** @internal Retains a handler contract separately from its invocation lifetime. */ final class HandlerDefinition @@ -27,14 +29,21 @@ public static function shared(callable $handler): self return new self($contract, static fn (): Closure => $contract); } - public static function replaySafe(object $prototype, string $method): self + public static function replaySafe(object $prototype, string $method, WorkflowFactory $factory): self { $contract = Closure::fromCallable([$prototype, $method]); return new self( $contract, - static function () use ($prototype, $method): Closure { - $handler = clone $prototype; + static function () use ($factory, $method): Closure { + try { + $handler = $factory->create(); + } catch (Throwable $exception) { + throw new NonDeterministicWorkflow( + 'Workflow instance construction failed: '.$exception->getMessage(), + reason: 'workflow_instance_factory_invalid', + ); + } return Closure::fromCallable([$handler, $method]); }, diff --git a/src/Worker/HandlerDiscovery.php b/src/Worker/HandlerDiscovery.php index ae66c4d0..1ab26f7f 100644 --- a/src/Worker/HandlerDiscovery.php +++ b/src/Worker/HandlerDiscovery.php @@ -32,7 +32,10 @@ public function __construct(private readonly HandlerResolver $resolver) /** @param class-string|object $service */ public function discover(string|object $service): DiscoveredHandlers { - $handler = is_object($service) ? $service : $this->resolver->resolve($service); + $factory = $service instanceof WorkflowFactory ? $service : ( + is_string($service) ? new WorkflowFactory(fn (): object => $this->resolver->resolve($service)) : null + ); + $handler = $factory === null ? $service : $factory->create(); $reflection = new ReflectionClass($handler); $annotated = []; $workflowMethods = []; @@ -79,16 +82,15 @@ public function discover(string|object $service): DiscoveredHandlers ); } - $workflowPrototype = null; if ($workflowMethods !== []) { - if (!$reflection->isCloneable()) { + if ($factory === null) { throw new InvalidWorkerDefinition( $reflection->getName(), - 'Allow the workflow handler object to be cloned so every replay can start from clean instance state.', + 'Register the workflow class or a WorkflowFactory that creates a new workflow instance. An existing object cannot describe how to reconstruct its execution-local state and injected dependencies.', ); } - - $workflowPrototype = clone $handler; + // Detect a singleton binding or invalid factory before registering or polling work. + $factory->create(); } $workflowType = null; @@ -103,8 +105,9 @@ public function discover(string|object $service): DiscoveredHandlers $workflowType = $this->name($attribute->name, $method, 'workflow'); $this->assertContext($method, WorkflowContext::class, 'workflow'); $workflows[$workflowType] = HandlerDefinition::replaySafe( - $workflowPrototype ?? $handler, + $handler, $method->getName(), + $factory, ); } elseif ($attribute instanceof Activity) { $name = $this->name($attribute->name ?? $method->getName(), $method, 'activity'); @@ -130,8 +133,9 @@ public function discover(string|object $service): DiscoveredHandlers $this->assertContext($method, QueryContext::class, 'query'); $this->assertLocalUnique($queries[$workflowType] ?? [], $name, $method, 'query'); $queries[$workflowType][$name] = HandlerDefinition::replaySafe( - $workflowPrototype ?? $handler, + $handler, $method->getName(), + $factory, ); } elseif ($attribute instanceof Signal) { $this->assertNotContextual($method, 'signal'); @@ -141,8 +145,9 @@ public function discover(string|object $service): DiscoveredHandlers $this->assertContext($method, QueryContext::class, 'update'); $this->assertLocalUnique($updates[$workflowType] ?? [], $name, $method, 'update'); $updates[$workflowType][$name] = HandlerDefinition::replaySafe( - $workflowPrototype ?? $handler, + $handler, $method->getName(), + $factory, ); } } diff --git a/src/Worker/HandlerResolver.php b/src/Worker/HandlerResolver.php index 0ed8e096..eeccb774 100644 --- a/src/Worker/HandlerResolver.php +++ b/src/Worker/HandlerResolver.php @@ -58,7 +58,7 @@ public function resolve(string $class): object if ($constructor !== null && $constructor->getNumberOfRequiredParameters() > 0) { throw new InvalidWorkerDefinition( $class.'::__construct()', - 'Pass a PSR-11 container to Worker::create() to resolve constructor dependencies, or register an object instance.', + 'Pass a PSR-11 container to Worker::create() to resolve constructor dependencies, or register a WorkflowFactory for workflows and an object instance for activities.', ); } diff --git a/src/Worker/WorkflowFactory.php b/src/Worker/WorkflowFactory.php new file mode 100644 index 00000000..2c31e5ec --- /dev/null +++ b/src/Worker/WorkflowFactory.php @@ -0,0 +1,57 @@ + */ + private readonly WeakMap $instances; + /** @var class-string|null */ + private ?string $class = null; + + /** @param callable(): mixed $factory */ + public function __construct(callable $factory) + { + $this->factory = Closure::fromCallable($factory); + $this->instances = new WeakMap(); + } + + /** @internal Used by attributed handler discovery and invocation. */ + public function create(): object + { + try { + $instance = ($this->factory)(); + } catch (Throwable $exception) { + throw new InvalidWorkerDefinition( + $this->class ?? 'workflow factory', + 'The factory could not construct a handler: '.$exception->getMessage(), + ); + } + if (!is_object($instance)) { + throw new InvalidWorkerDefinition('workflow factory', 'Return a new attributed handler object.'); + } + $this->class ??= $instance::class; + if ($instance::class !== $this->class) { + throw new InvalidWorkerDefinition($this->class, 'Return the same handler class on every factory invocation.'); + } + if (isset($this->instances[$instance])) { + throw new InvalidWorkerDefinition( + $this->class, + 'Create a new workflow instance on every factory invocation. Use a transient workflow container binding, or pass new WorkflowFactory(fn () => new YourWorkflow($sharedDependencies)) to register().', + ); + } + $this->instances[$instance] = true; + + return $instance; + } +} diff --git a/tests/Support/ReplayRegressionFixture.php b/tests/Support/ReplayRegressionFixture.php index cc776ba4..b325c8e5 100644 --- a/tests/Support/ReplayRegressionFixture.php +++ b/tests/Support/ReplayRegressionFixture.php @@ -238,6 +238,8 @@ private static function executeWorkflowTasks( $worker = Worker::create($client, 'regression-corpus'); if ($workflowType === 'golden.attributed-stateful') { $worker->register(ReplayRegressionAttributedStatefulWorkflow::class); + } elseif ($workflowType === 'golden.attributed-nested-state') { + $worker->register(ReplayRegressionAttributedNestedWorkflow::class); } else { $worker->registerWorkflow($workflowType, self::workflow($workflowType)); } @@ -660,3 +662,23 @@ public function run(WorkflowContext $context): array ]; } } + +final class ReplayRegressionAttributedNestedWorkflow +{ + private readonly object $state; + + public function __construct() + { + $this->state = (object) ['invocation' => 0]; + } + + #[Workflow('golden.attributed-nested-state')] + public function run(WorkflowContext $context): array + { + return [ + 'workflow_id' => $context->workflowId, + 'run_id' => $context->runId, + 'invocation' => ++$this->state->invocation, + ]; + } +} diff --git a/tests/WorkflowHandlerLifetimeTest.php b/tests/WorkflowHandlerLifetimeTest.php index 3c2db866..4677290e 100644 --- a/tests/WorkflowHandlerLifetimeTest.php +++ b/tests/WorkflowHandlerLifetimeTest.php @@ -11,12 +11,14 @@ use DurableWorkflow\Client; use DurableWorkflow\Codec\AvroPayloadCodec; use DurableWorkflow\Exception\InvalidWorkerDefinition; +use DurableWorkflow\Exception\NonDeterministicWorkflow; use DurableWorkflow\Testing\WorkerTestHarness; use DurableWorkflow\Tests\Support\FakeTransport; use DurableWorkflow\Worker; use DurableWorkflow\Worker\ActivityContext; use DurableWorkflow\Worker\QueryContext; use DurableWorkflow\Worker\WorkflowContext; +use DurableWorkflow\Worker\WorkflowFactory; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; @@ -44,11 +46,13 @@ public function testNoContainerWorkflowStateIsFreshAcrossExecutionsAndReplay(): public function testContainerWorkflowStateIsFreshWithoutLosingConstructorDependencies(): void { - $service = new StatefulWorkflow(new WorkflowStepPrefix('injected')); - $container = new class($service) implements ContainerInterface { + $prefix = new WorkflowStepPrefix('injected'); + $container = new class($prefix) implements ContainerInterface { public int $resolutions = 0; + /** @var list */ + public array $instances = []; - public function __construct(private readonly object $service) + public function __construct(private readonly WorkflowStepPrefix $prefix) { } @@ -56,22 +60,28 @@ public function get(string $id): mixed { ++$this->resolutions; - return $this->service; + $instance = new NestedStateWorkflow($this->prefix); + $this->instances[] = $instance; + + return $instance; } public function has(string $id): bool { - return $id === StatefulWorkflow::class; + return $id === NestedStateWorkflow::class; } }; $worker = Worker::create( new Client('https://server.example', transport: new FakeTransport()), 'php-workers', $container, - )->register(StatefulWorkflow::class); + )->register(NestedStateWorkflow::class); - $this->assertReplaySafeLifetime($worker, 'stateful', 'injected-step-1'); - self::assertSame(1, $container->resolutions); + $this->assertReplaySafeLifetime($worker, 'nested-state', 'injected-step-1'); + self::assertGreaterThan(1, $container->resolutions); + foreach ($container->instances as $instance) { + self::assertSame($prefix, $instance->prefix); + } } public function testActivityServicesAndLowLevelCallablesKeepTheirOwnedLifetime(): void @@ -97,7 +107,56 @@ static function (WorkflowContext $context) use (&$workflowCalls): int { self::assertSame(2, $workflowCalls); } - public function testAttributedWorkflowHandlersMustBeCloneable(): void + public function testFreshConstructionDoesNotRequireCloning(): void + { + $worker = Worker::create( + new Client('https://server.example', transport: new FakeTransport()), + 'php-workers', + ); + + $worker->register(NonCloneableWorkflow::class); + $result = (new WorkerTestHarness($worker))->runWorkflow('non-cloneable'); + self::assertSame('complete', (new AvroPayloadCodec())->decodeEnvelope($result->commands[0]['result'])); + } + + public function testAnExplicitFactoryRebuildsNestedStateAndPreservesSharedDependencies(): void + { + $prefix = new WorkflowStepPrefix('injected'); + $instances = []; + $factory = new WorkflowFactory(static function () use ($prefix, &$instances): NestedStateWorkflow { + $instance = new NestedStateWorkflow($prefix); + $instances[] = $instance; + + return $instance; + }); + $worker = Worker::create( + new Client('https://server.example', transport: new FakeTransport()), + 'php-workers', + )->register($factory); + + $this->assertReplaySafeLifetime($worker, 'nested-state', 'injected-step-1'); + foreach ($instances as $instance) { + self::assertSame($prefix, $instance->prefix); + } + } + + public function testAReusedFactoryInstanceIsRejectedBeforePolling(): void + { + $transport = new FakeTransport(); + $instance = new NestedStateWorkflow(); + $worker = Worker::create(new Client('https://server.example', transport: $transport), 'php-workers'); + + try { + $worker->register(new WorkflowFactory(static fn (): NestedStateWorkflow => $instance)); + self::fail('A singleton workflow was registered.'); + } catch (InvalidWorkerDefinition $exception) { + self::assertStringContainsString('Create a new workflow instance', $exception->remediation); + } + self::assertSame([], $transport->requests); + self::assertSame([], $worker->contracts()['workflows']); + } + + public function testAnExistingWorkflowObjectRequiresItsConstructionFactory(): void { $worker = Worker::create( new Client('https://server.example', transport: new FakeTransport()), @@ -105,9 +164,58 @@ public function testAttributedWorkflowHandlersMustBeCloneable(): void ); $this->expectException(InvalidWorkerDefinition::class); - $this->expectExceptionMessage('Allow the workflow handler object to be cloned'); + $this->expectExceptionMessage('Register the workflow class or a WorkflowFactory'); + $worker->register(new NestedStateWorkflow()); + } - $worker->register(NonCloneableWorkflow::class); + public function testFactoryResultsMustBeObjectsOfTheOriginalHandlerClass(): void + { + $transport = new FakeTransport(); + $worker = Worker::create(new Client('https://server.example', transport: $transport), 'php-workers'); + foreach ([ + new WorkflowFactory(static fn (): int => 42), + new WorkflowFactory((static function (): \Closure { + $calls = 0; + + return static function () use (&$calls): object { + return ++$calls === 1 ? new NestedStateWorkflow() : new StatefulWorkflow(); + }; + })()), + new WorkflowFactory(static function (): never { + throw new \RuntimeException('Constructor dependency is unavailable.'); + }), + ] as $factory) { + try { + $worker->register($factory); + self::fail('An invalid factory was registered.'); + } catch (InvalidWorkerDefinition $exception) { + self::assertNotSame('', $exception->remediation); + } + } + self::assertSame([], $transport->requests); + self::assertSame([], $worker->contracts()['workflows']); + } + + public function testFactoryReuseAfterStartupCannotBecomeAWorkflowFailure(): void + { + $calls = 0; + $instance = new NestedStateWorkflow(); + $factory = new WorkflowFactory(static function () use (&$calls, $instance): NestedStateWorkflow { + return ++$calls <= 2 ? new NestedStateWorkflow() : $instance; + }); + $worker = Worker::create( + new Client('https://server.example', transport: new FakeTransport()), + 'php-workers', + )->register($factory); + $harness = new WorkerTestHarness($worker); + $harness->runWorkflow('nested-state'); + + try { + $harness->runWorkflow('nested-state'); + self::fail('A reused factory result was invoked.'); + } catch (NonDeterministicWorkflow $exception) { + self::assertSame('workflow_instance_factory_invalid', $exception->reason); + } } private function assertReplaySafeLifetime(Worker $worker, string $workflowType, string $activityType): void @@ -204,7 +312,7 @@ final class NestedStateWorkflow { private readonly object $state; - public function __construct(private readonly ?WorkflowStepPrefix $prefix = null) + public function __construct(public readonly ?WorkflowStepPrefix $prefix = null) { $this->state = (object) ['replays' => 0]; } diff --git a/tests/compat/laravel.php b/tests/compat/laravel.php index fb301d32..147c5b20 100644 --- a/tests/compat/laravel.php +++ b/tests/compat/laravel.php @@ -43,16 +43,28 @@ public function __construct( } } +final class LaravelWorkflowState +{ + public int $invocations = 0; +} + final class LaravelGreetingWorkflow { + private readonly LaravelWorkflowState $state; + public static ?LaravelGreetingPrefix $expectedDependency = null; + public function __construct(private readonly LaravelGreetingPrefix $prefix) { + $this->state = new LaravelWorkflowState(); } /** @return array{primary: string, secondary: string} */ #[Workflow('laravel.greeting')] public function workflow(WorkflowContext $context, string $name): array { + if (++$this->state->invocations !== 1 || $this->prefix !== self::$expectedDependency) { + throw new RuntimeException('Laravel must reconstruct local nested state and retain the shared dependency identity.'); + } if ($this->prefix->value === '') { throw new RuntimeException('Laravel did not inject the workflow application dependency.'); } @@ -215,6 +227,7 @@ function laravelAuthenticationRejectsRole(Authentication $authentication, bool $ $app = new Application(sys_get_temp_dir().'/durable-workflow-laravel-compat'); $app->instance('config', new Repository(['durable-workflow' => $values])); $app->instance(LaravelGreetingPrefix::class, new LaravelGreetingPrefix('hello from Laravel')); +LaravelGreetingWorkflow::$expectedDependency = $app->make(LaravelGreetingPrefix::class); $events = new Dispatcher($app); $diagnostics = []; $events->listen(WorkerDiagnosticEvent::class, static function (WorkerDiagnosticEvent $event) use (&$diagnostics): void { diff --git a/tests/compat/symfony.php b/tests/compat/symfony.php index fdfddf17..0083a910 100644 --- a/tests/compat/symfony.php +++ b/tests/compat/symfony.php @@ -5,6 +5,8 @@ require $argv[1]; use DurableWorkflow\Attribute\Activity; +use DurableWorkflow\Attribute\Query; +use DurableWorkflow\Attribute\Update; use DurableWorkflow\Attribute\Workflow; use DurableWorkflow\Auth\Authentication; use DurableWorkflow\Bridge\Event\WorkerDiagnosticEvent; @@ -13,8 +15,11 @@ use DurableWorkflow\Bridge\Symfony\Testing\InteractsWithDurableWorkflow; use DurableWorkflow\Bridge\Symfony\WorkerFactory; use DurableWorkflow\Client; +use DurableWorkflow\Codec\AvroPayloadCodec; +use DurableWorkflow\Testing\WorkerTestHarness; use DurableWorkflow\Testing\WorkflowClientFake; use DurableWorkflow\Worker\ActivityContext; +use DurableWorkflow\Worker\QueryContext; use DurableWorkflow\Worker\WorkflowContext; use DurableWorkflow\WorkflowClientInterface; use Psr\Log\AbstractLogger; @@ -27,18 +32,42 @@ final class SymfonyGreetingDependency { } +final class SymfonyWorkflowState +{ + public int $invocations = 0; +} + final class SymfonyGreetingHandler { + private readonly SymfonyWorkflowState $state; + public static ?SymfonyGreetingDependency $expectedDependency = null; + public function __construct(public readonly SymfonyGreetingDependency $dependency) { + $this->state = new SymfonyWorkflowState(); } #[Workflow('symfony.greeting')] public function workflow(WorkflowContext $context, string $name): string { + if (++$this->state->invocations !== 1 || $this->dependency !== self::$expectedDependency) { + throw new RuntimeException('Symfony must reconstruct local nested state and retain the shared dependency identity.'); + } return $context->activity('symfony.greet', [$name]); } + #[Query('state')] + public function state(QueryContext $context): int + { + return $this->state->invocations; + } + + #[Update('increment')] + public function increment(QueryContext $context): int + { + return ++$this->state->invocations; + } + #[Activity('symfony.greet')] public function activity(ActivityContext $context, string $name): string { @@ -115,7 +144,7 @@ public function fake(): WorkflowClientFake $diagnostics[] = $event->name; }); $container = new ContainerBuilder(); -$container->register(SymfonyGreetingDependency::class, SymfonyGreetingDependency::class); +$container->register(SymfonyGreetingDependency::class, SymfonyGreetingDependency::class)->setPublic(true); $container->register(LoggerInterface::class)->setSynthetic(true)->setPublic(true); $container->register(EventDispatcherInterface::class)->setSynthetic(true)->setPublic(true); $bundle = new DurableWorkflowBundle(); @@ -136,6 +165,7 @@ public function fake(): WorkflowClientFake $container->compile(); $container->set(LoggerInterface::class, $logger); $container->set(EventDispatcherInterface::class, $events); +SymfonyGreetingHandler::$expectedDependency = $container->get(SymfonyGreetingDependency::class); $bundleExtension = $bundle->getContainerExtension(); if (!$bundleExtension instanceof DurableWorkflowExtension || !trait_exists(InteractsWithDurableWorkflow::class)) { @@ -176,6 +206,23 @@ public function fake(): WorkflowClientFake ) { throw new RuntimeException('Symfony autowired handlers were not registered.'); } +$harness = new WorkerTestHarness($worker); +$codec = new AvroPayloadCodec(); +$history = [ + ['event_type' => 'ActivityScheduled', 'payload' => ['sequence' => 1, 'activity_type' => 'symfony.greet']], + ['event_type' => 'ActivityCompleted', 'payload' => ['sequence' => 1, 'activity_type' => 'symfony.greet', 'result' => $codec->envelope('hello, Ada')]], +]; +foreach (['workflow-a', 'workflow-b', 'workflow-a'] as $id) { + $result = $harness->runWorkflow('symfony.greeting', ['Ada'], $history, ['workflow_id' => $id, 'run_id' => $id.'-run']); + if ($codec->decodeEnvelope($result->commands[0]['result'] ?? null) !== 'hello, Ada') { + throw new RuntimeException('Symfony replay did not preserve the recorded result across workflow instances.'); + } +} +if ($harness->runQuery('symfony.greeting', 'state') !== 0 + || $harness->runUpdate('symfony.greeting', 'increment') !== 1 + || $harness->runQuery('symfony.greeting', 'state') !== 0) { + throw new RuntimeException('Symfony queries and updates shared workflow execution state.'); +} $worker->requestShutdown(); if ($diagnostics !== ['worker.shutdown_requested'] || $logger->messages !== ['worker.shutdown_requested'] diff --git a/tests/fixtures/replay-regressions/attributed-workflow-nested-state.json b/tests/fixtures/replay-regressions/attributed-workflow-nested-state.json new file mode 100644 index 00000000..2441e44c --- /dev/null +++ b/tests/fixtures/replay-regressions/attributed-workflow-nested-state.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://raw.githubusercontent.com/durable-workflow/.github/main/regression-corpus/evidence-schema.json", + "fixture_schema": "durable-workflow.replay-regression/v1", + "id": "attributed-workflow-nested-state", + "protocol_version": "1", + "bindings": ["php"], + "workflow": {"type": "golden.attributed-nested-state", "input": []}, + "workflow_tasks": [ + {"workflow_id": "workflow-a", "run_id": "run-a"}, + {"workflow_id": "workflow-b", "run_id": "run-b"}, + {"workflow_id": "workflow-a", "run_id": "run-a"} + ], + "expected": { + "workflow_tasks": [ + {"command_sequence": [{"type": "complete_workflow", "result": {"workflow_id": "workflow-a", "run_id": "run-a", "invocation": 1}}]}, + {"command_sequence": [{"type": "complete_workflow", "result": {"workflow_id": "workflow-b", "run_id": "run-b", "invocation": 1}}]}, + {"command_sequence": [{"type": "complete_workflow", "result": {"workflow_id": "workflow-a", "run_id": "run-a", "invocation": 1}}]} + ] + } +} From 9085dd2857c45dcd054d331036bda00f897667cb Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Sat, 10 Oct 2026 20:31:43 +0000 Subject: [PATCH 3/8] Preserve lifetime mutation proof and type the Symfony service provider --- scripts/ci/test-regression-corpus-policy.py | 19 +++++++------------ src/Bridge/Symfony/WorkerFactory.php | 2 +- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/scripts/ci/test-regression-corpus-policy.py b/scripts/ci/test-regression-corpus-policy.py index 9ad75ebc..ffa8f921 100644 --- a/scripts/ci/test-regression-corpus-policy.py +++ b/scripts/ci/test-regression-corpus-policy.py @@ -2197,21 +2197,16 @@ def test_official_php_runner_uses_attributed_worker_registration(self) -> None: shutil.copytree(REPOSITORY_ROOT / "src", shared_source_root / "src") definition_path = shared_source_root / "src/Worker/HandlerDefinition.php" definition = definition_path.read_text() - replay_safe_resolver = """ return new self( - $contract, - static function () use ($prototype, $method): Closure { - $handler = clone $prototype; + method_start = definition.index(" public static function replaySafe(") + method_end = definition.index(" public function contract()", method_start) + shared_resolver = """ public static function replaySafe(object $prototype, string $method, WorkflowFactory $factory): self + { + return self::shared([$prototype, $method]); + } - return Closure::fromCallable([$handler, $method]); - }, - ); """ - shared_resolver = ( - " return new self($contract, static fn (): Closure => $contract);\n" - ) - self.assertIn(replay_safe_resolver, definition) definition_path.write_text( - definition.replace(replay_safe_resolver, shared_resolver, 1) + definition[:method_start] + shared_resolver + definition[method_end:] ) defective_shared = self.run_official_php_runner( diff --git a/src/Bridge/Symfony/WorkerFactory.php b/src/Bridge/Symfony/WorkerFactory.php index 96e96bbb..ea52c6a6 100644 --- a/src/Bridge/Symfony/WorkerFactory.php +++ b/src/Bridge/Symfony/WorkerFactory.php @@ -18,7 +18,7 @@ final class WorkerFactory { /** - * @param ServiceProviderInterface|iterable $handlers + * @param ServiceProviderInterface|iterable $handlers */ public function __construct( private readonly ServiceConfiguration $configuration, From c5a4f8ce8d7f7360858e8b5522458a5a967adb0a Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Sat, 10 Oct 2026 20:38:18 +0000 Subject: [PATCH 4/8] Guard workflow factories with replay corpus policy --- regression-corpus-policy.json | 9 ++++++ scripts/ci/test-regression-corpus-policy.py | 32 +++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/regression-corpus-policy.json b/regression-corpus-policy.json index fed65cce..f69645b7 100644 --- a/regression-corpus-policy.json +++ b/regression-corpus-policy.json @@ -68,6 +68,15 @@ "function\\s+resolve\\s*\\(" ] }, + { + "glob": "src/Worker/HandlerDefinition.php", + "content_patterns": [ + "function\\s+replaySafe\\s*\\(" + ] + }, + { + "glob": "src/Worker/WorkflowFactory.php" + }, { "glob": "src/Worker.php", "content_patterns": [ diff --git a/scripts/ci/test-regression-corpus-policy.py b/scripts/ci/test-regression-corpus-policy.py index ffa8f921..1a36168a 100644 --- a/scripts/ci/test-regression-corpus-policy.py +++ b/scripts/ci/test-regression-corpus-policy.py @@ -161,6 +161,28 @@ def setUp(self) -> None: return new $class(); } } +""" + ) + (self.root / "src/Worker/HandlerDefinition.php").write_text( + """factory)(); + } +} """ ) (self.root / "vendor/autoload.php").write_text(" None "return new $class();", "return clone new $class();", ), + "handler replay factory": ( + self.root / "src/Worker/HandlerDefinition.php", + "return $factory;", + "return $this->shared;", + ), + "workflow instance factory": ( + self.root / "src/Worker/WorkflowFactory.php", + "return ($this->factory)();", + "return $this->shared;", + ), } for name, (path, original, changed) in cases.items(): From 81780d08dcb09e9590a1df8b41edc5d626b5db40 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Tue, 20 Oct 2026 19:02:04 +0000 Subject: [PATCH 5/8] Validate SDK release identities independently of Server major --- tests/DependencyBoundaryTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/DependencyBoundaryTest.php b/tests/DependencyBoundaryTest.php index d6bbdfba..d9c42de3 100644 --- a/tests/DependencyBoundaryTest.php +++ b/tests/DependencyBoundaryTest.php @@ -44,7 +44,7 @@ public function testReleaseMetadataDeclaresExactArtifacts(): void $metadata = $this->manifest()['extra']['durable-workflow']; $quickstart = $this->quickstartContract(); - $versionPattern = '/^2\.[0-9]+\.[0-9]+(?:-(?:alpha|beta|rc)\.[0-9]+)?$/D'; + $versionPattern = '/^[1-9][0-9]*\.[0-9]+\.[0-9]+(?:-(?:alpha|beta|rc)\.[0-9]+)?$/D'; self::assertMatchesRegularExpression($versionPattern, $metadata['product-train']); self::assertMatchesRegularExpression($versionPattern, $metadata['supported-server-versions']); self::assertSame('1.20', $metadata['worker-protocol-version']); From 7cc85094d25bf0626ce804236d98ebac3d1b3eb4 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Tue, 20 Oct 2026 19:21:56 +0000 Subject: [PATCH 6/8] Migrate Symfony cache fixture to replay-scoped construction --- tests/compat/symfony.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/compat/symfony.php b/tests/compat/symfony.php index cef3c49d..18d74321 100644 --- a/tests/compat/symfony.php +++ b/tests/compat/symfony.php @@ -23,6 +23,7 @@ use DurableWorkflow\Worker\ActivityContext; use DurableWorkflow\Worker\QueryContext; use DurableWorkflow\Worker\WorkflowContext; +use DurableWorkflow\Worker\WorkflowFactory; use DurableWorkflow\WorkflowClientInterface; use Psr\Log\AbstractLogger; use Psr\Log\LoggerInterface; @@ -215,11 +216,12 @@ public function fake(): WorkflowClientFake 'task_queue' => 'symfony-workers', 'worker' => ['sticky_cache' => ['capacity' => 1, 'ttl_seconds' => 37]], ]); + $dependency = new SymfonyGreetingDependency(); return (new WorkerFactory( $configuration, $configuration->workerClient($transport), - [new SymfonyGreetingHandler(new SymfonyGreetingDependency())], + [new WorkflowFactory(static fn (): SymfonyGreetingHandler => new SymfonyGreetingHandler($dependency))], events: $cacheEvents, ))->make(); }); From f33756c7b8cfcf9aa7359e425dc9a6688a3e4513 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Tue, 20 Oct 2026 19:26:27 +0000 Subject: [PATCH 7/8] Document the PHP 3 registration migration at installation --- README.md | 9 ++++++++- docs/portal/frameworks/laravel.md | 6 +++++- docs/portal/frameworks/symfony.md | 6 ++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a6f96502..b009b030 100644 --- a/README.md +++ b/README.md @@ -18,12 +18,19 @@ injection, commands, logging, and tests. ## Install ```bash -composer require durable-workflow/sdk:^2.0 +composer require durable-workflow/sdk ``` The SDK requires PHP 8.1 or newer. It uses the official `apache/avro` package for portable payloads and accepts any PSR-18 HTTP client. +Upgrading from PHP SDK 2 requires a workflow registration migration. Register +workflow classes or a `WorkflowFactory` that constructs a fresh handler instead +of passing prebuilt workflow objects. Workflow container bindings must be +transient, while injected collaborators may remain shared. Constructors run +during replay and must be free of external side effects. See the +[registration migration guide](https://php.durable-workflow.com/build/workflows-activities/#migrate-workflow-registration-from-php-sdk-2). + ## Choose Your Path | Starting point | Recommended guide | diff --git a/docs/portal/frameworks/laravel.md b/docs/portal/frameworks/laravel.md index 14bbe8c1..8262370a 100644 --- a/docs/portal/frameworks/laravel.md +++ b/docs/portal/frameworks/laravel.md @@ -267,7 +267,11 @@ Register both classes in the published configuration: ``` An empty handler list is a startup error. The worker resolves every configured -class through the Laravel container before polling. Dependencies read by a +class through the Laravel container before polling. Each workflow replay, +query and update resolves a fresh workflow instance. Use transient workflow +bindings, such as `bind()`, rather than `singleton()` or `instance()`. +Injected collaborators may retain their configured shared lifetime. Workflow +constructors must be free of external side effects. Dependencies read by a workflow must be deterministic and stable across replay; put database, network, clock, and other side-effecting dependencies in activities. diff --git a/docs/portal/frameworks/symfony.md b/docs/portal/frameworks/symfony.md index 82de5671..cb0dfde0 100644 --- a/docs/portal/frameworks/symfony.md +++ b/docs/portal/frameworks/symfony.md @@ -88,6 +88,12 @@ final class FulfillOrderWorkflow Add an autowired activity service with `#[Activity('orders.reserve')]`. The Bundle tags both services during container compilation, and the worker resolves their ordinary constructor dependencies. Use the explicit `handlers` list only for classes outside Symfony's autoconfigured imports. +Attributed workflow services are non-shared. Every workflow replay, query and +update constructs a fresh handler while injected collaborators keep their +configured lifetimes. Keep workflow constructors free of external side effects. +When constructing `WorkerFactory` manually, supply a `WorkflowFactory` for a +workflow with custom dependencies instead of passing a prebuilt workflow object. + Autowired workflow services use the same ordered barrier for concurrent work: ```php From ac69c379ce01505a7bdce68ac39853f5b30e38bd Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Tue, 20 Oct 2026 19:40:53 +0000 Subject: [PATCH 8/8] Check PHP 3 workflow lifetimes through the installed package --- .../service-mode-published-smoke.yml | 15 +- tests/smoke/workflow-lifetime.php | 200 ++++++++++++++++++ 2 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 tests/smoke/workflow-lifetime.php diff --git a/.github/workflows/service-mode-published-smoke.yml b/.github/workflows/service-mode-published-smoke.yml index ac3a8a70..e4ebfbb6 100644 --- a/.github/workflows/service-mode-published-smoke.yml +++ b/.github/workflows/service-mode-published-smoke.yml @@ -268,11 +268,24 @@ jobs: fi - name: Check out only the smoke runner - if: inputs.cooperative_server_image != '' uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: persist-credentials: false + - name: Verify PHP 3 workflow instance lifetime from the installed package + env: + SDK_VERSION: ${{ inputs.sdk_version }} + LIFETIME_RUNTIME_URL: ${{ env.DURABLE_WORKFLOW_SERVER_URL }} + LIFETIME_NAMESPACE: ${{ env.DURABLE_WORKFLOW_NAMESPACE }} + run: | + set -euo pipefail + if (( ${SDK_VERSION%%.*} < 3 )); then + echo 'The fresh workflow instance contract starts with SDK 3.' + exit 0 + fi + consumer="$(cat "$RUNNER_TEMP/service-mode-consumer")" + php tests/smoke/workflow-lifetime.php "$consumer/vendor/autoload.php" + - name: Verify published cooperative discovery with a scoped worker token if: inputs.cooperative_server_image != '' env: diff --git a/tests/smoke/workflow-lifetime.php b/tests/smoke/workflow-lifetime.php new file mode 100644 index 00000000..f850b0f9 --- /dev/null +++ b/tests/smoke/workflow-lifetime.php @@ -0,0 +1,200 @@ +getFileName()); +if ($version !== getenv('SDK_VERSION') || $root === false || $implementation === false + || !str_starts_with($implementation, $root.'/')) { + throw new RuntimeException('The lifetime check must execute the selected installed SDK without a source fallback.'); +} +$reference = InstalledVersions::getReference('durable-workflow/sdk'); +$expectedReference = getenv('SDK_EXPECTED_REFERENCE'); +if (is_string($expectedReference) && $expectedReference !== '' && $reference !== $expectedReference) { + throw new RuntimeException('The installed SDK reference does not match the selected artifact.'); +} + +final class PackageLifetimeWorkflow +{ + public static ?object $expectedDependency = null; + private readonly object $state; + + public function __construct(object $dependency) + { + if ($dependency !== self::$expectedDependency) { + throw new RuntimeException('Workflow construction replaced the shared application dependency.'); + } + $this->state = (object) ['counter' => 0]; + } + + #[Workflow('smoke.instance_lifetime')] + public function run(WorkflowContext $context): int + { + ++$this->state->counter; + if (!$context->waitCondition( + static fn (): bool => $context->signals('finish') !== [], + key: 'finish', + timeout: 45, + )) { + throw new RuntimeException('The original lifetime observation window expired.'); + } + + return $this->state->counter; + } + + #[Query('counter')] + public function counter(QueryContext $_context): int + { + return $this->state->counter; + } + + #[Update('increment')] + public function increment(QueryContext $_context): int + { + return ++$this->state->counter; + } + + #[Signal('finish')] + public function finish(): void + { + // WorkflowContext reads this signal from committed history. + } +} + +$url = getenv('LIFETIME_RUNTIME_URL') ?: 'http://localhost:8080'; +$token = getenv('LIFETIME_TOKEN') ?: null; +$clientToken = getenv('DURABLE_WORKFLOW_CLIENT_TOKEN') ?: null; +$workerToken = getenv('DURABLE_WORKFLOW_WORKER_TOKEN') ?: null; +if (($token === null && ($clientToken === null || $workerToken === null)) + || !function_exists('pcntl_fork') || !function_exists('posix_kill')) { + throw new RuntimeException('Provide scoped client/worker tokens or LIFETIME_TOKEN, with pcntl and posix.'); +} +$namespace = getenv('LIFETIME_NAMESPACE') ?: 'default'; +$prefix = 'lifetime-'.bin2hex(random_bytes(6)); +$queue = $prefix.'-workers'; +$pid = pcntl_fork(); +if ($pid === -1) { + throw new RuntimeException('The lifetime worker process could not start.'); +} +if ($pid === 0) { + try { + $dependency = new stdClass(); + PackageLifetimeWorkflow::$expectedDependency = $dependency; + $container = new class($dependency) implements ContainerInterface { + public function __construct(private readonly object $dependency) {} + + public function has(string $id): bool + { + return $id === PackageLifetimeWorkflow::class; + } + + public function get(string $id): mixed + { + if (!$this->has($id)) { + throw new RuntimeException('Unexpected lifetime handler resolution.'); + } + + return new PackageLifetimeWorkflow($this->dependency); + } + }; + Worker::create(new Client( + $url, token: $token, namespace: $namespace, + workerToken: $token === null ? $workerToken : null, + ), $queue, $container) + ->register(PackageLifetimeWorkflow::class) + ->run(1); + exit(0); + } catch (Throwable $failure) { + fwrite(STDERR, $failure::class.': '.$failure->getMessage().PHP_EOL); + exit(1); + } +} + +$client = new Client( + $url, token: $token, namespace: $namespace, + controlToken: $token === null ? $clientToken : null, +); +$handles = []; +$failure = null; +try { + $registrationDeadline = microtime(true) + 20; + while (($client->listWorkers($queue)['workers'] ?? []) === []) { + if (microtime(true) >= $registrationDeadline) { + throw new RuntimeException('The installed worker did not register within the original observation window.'); + } + usleep(50_000); + } + foreach (['a', 'b'] as $suffix) { + $handles[] = $client->startWorkflow( + 'smoke.instance_lifetime', $prefix.'-'.$suffix, $queue, + executionTimeoutSeconds: 90, runTimeoutSeconds: 60, + ); + } + foreach ($handles as $handle) { + if ($handle->query('counter') !== 0 + || $handle->update('increment', waitTimeoutSeconds: 10) !== 1 + || $handle->query('counter') !== 0) { + throw new RuntimeException('An execution, query or update retained another invocation state.'); + } + } + foreach ($handles as $handle) { + $handle->signal('finish'); + if ($handle->result(timeoutSeconds: 20, pollIntervalSeconds: 0.1) !== 1) { + throw new RuntimeException('Cold workflow replay retained nested instance state.'); + } + } +} catch (Throwable $exception) { + $failure = $exception; +} +if ($failure !== null) { + foreach ($handles as $handle) { + try { + $handle->terminate('Lifetime qualification cleanup'); + } catch (Throwable $cleanupFailure) { + fwrite(STDERR, 'Cleanup: '.$cleanupFailure->getMessage().PHP_EOL); + } + } +} +try { + posix_kill($pid, SIGTERM); + $shutdownDeadline = microtime(true) + 15; + while (pcntl_waitpid($pid, $status, WNOHANG) === 0) { + if (microtime(true) >= $shutdownDeadline) { + posix_kill($pid, SIGKILL); + pcntl_waitpid($pid, $status); + throw new RuntimeException('The lifetime worker failed to stop gracefully.'); + } + usleep(50_000); + } + if (!pcntl_wifexited($status) || pcntl_wexitstatus($status) !== 0) { + throw new RuntimeException('The lifetime worker exited unsuccessfully.'); + } +} catch (Throwable $cleanupFailure) { + $failure ??= $cleanupFailure; +} +if ($failure !== null) { + throw $failure; +} +if (($client->listWorkers($queue)['workers'] ?? []) !== []) { + throw new RuntimeException('The lifetime worker registration survived orderly shutdown.'); +} +echo json_encode([ + 'sdk_version' => $version, 'sdk_reference' => $reference, + 'workflow_ids' => array_map(static fn ($handle): string => $handle->workflowId, $handles), + 'nested_state_fresh' => true, 'query_update_state_fresh' => true, + 'shared_dependency_preserved' => true, 'worker_deregistered' => true, +], JSON_THROW_ON_ERROR).PHP_EOL;