├── .gitignore ├── README.md ├── composer.json ├── phpunit.xml ├── src ├── DataConverter │ └── ClassObjectConverter.php ├── DependencyInjection │ ├── Configuration.php │ └── TemporalExtension.php ├── Factory │ └── ClientOptionsFactory.php ├── Pass │ └── ActivityCompilerPass.php ├── Registry │ └── ActivityRegistry.php ├── Resources │ └── config │ │ └── services.php ├── Serializer │ ├── DefaultSerializerFactory.php │ └── SerializerFactory.php ├── TemporalBundle.php ├── WorkflowClientFactory.php ├── WorkflowClientFactoryInterface.php └── WorkflowRuntimeCommand.php ├── tests ├── .gitkeep └── Factory │ └── ClientOptionsFactoryTest.php └── writing-tests.md /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | vendor/ 3 | composer.lock 4 | .phpunit.result.cache 5 | .phpunit.cache/test-results -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Symfony Temporal Bundle 2 | 3 | ## Description 4 | 5 | This is a wrapper package for the [official PHP SDK](https://github.com/temporalio/sdk-php) with Activity Registry and full-configurable worker and workflow client. 6 | 7 | ## Table of Contents (Optional) 8 | 9 | If your README is long, add a table of contents to make it easy for users to find what they need. 10 | 11 | - [Installation](#installation) 12 | - [Usage](#usage) 13 | - [Writing PHPUnit tests](writing-tests.md) 14 | - [Credits](#credits) 15 | - [License](#license) 16 | 17 | ## Installation 18 | 19 | Use this command to install 20 | `composer require highcore/temporal-bundle` 21 | 22 | ## Usage 23 | 24 | Create config/workflows.php 25 | 26 | And register here your workflows, like a config/bundles.php for symfony 27 | 28 | Example config/workflows.php: 29 | ```php 30 | logger = new Logger(); 176 | } 177 | 178 | public function upload(string $localFileName, string $url): void 179 | { 180 | if (!is_file($localFileName)) { 181 | throw new \InvalidArgumentException("Invalid file type: " . $localFileName); 182 | } 183 | 184 | // Faking upload to simplify sample implementation. 185 | $this->log('upload activity: uploaded from %s to %s', $localFileName, $url); 186 | } 187 | 188 | public function process(string $inputFileName): string 189 | { 190 | try { 191 | $this->log('process activity: sourceFile=%s', $inputFileName); 192 | $processedFile = $this->processFile($inputFileName); 193 | $this->log('process activity: processed file=%s', $processedFile); 194 | 195 | return $processedFile; 196 | } catch (\Throwable $e) { 197 | throw $e; 198 | } 199 | } 200 | 201 | public function download(string $url): TaskQueueFilenamePair 202 | { 203 | try { 204 | $this->log('download activity: downloading %s', $url); 205 | 206 | $data = file_get_contents($url); 207 | $file = tempnam(sys_get_temp_dir(), 'demo'); 208 | 209 | file_put_contents($file, $data); 210 | 211 | $this->log('download activity: downloaded from %s to %s', $url, realpath($file)); 212 | 213 | return new TaskQueueFilenamePair(self::$taskQueue, $file); 214 | } catch (\Throwable $e) { 215 | throw $e; 216 | } 217 | } 218 | 219 | private function processFile(string $filename): string 220 | { 221 | // faking processing for simplicity 222 | return $filename; 223 | } 224 | 225 | /** 226 | * @param string $message 227 | * @param mixed ...$arg 228 | */ 229 | private function log(string $message, ...$arg) 230 | { 231 | // by default all error logs are forwarded to the application server log and docker log 232 | $this->logger->debug(sprintf($message, ...$arg)); 233 | } 234 | } 235 | ``` 236 | 237 | Example workflow interface: 238 | ```php 239 | defaultStoreActivities = Workflow::newActivityStub( 294 | StoreActivitiesInterface::class, 295 | ActivityOptions::new() 296 | ->withScheduleToCloseTimeout(CarbonInterval::minute(5)) 297 | ->withTaskQueue(self::DEFAULT_TASK_QUEUE) 298 | ); 299 | } 300 | 301 | public function processFile(string $sourceURL, string $destinationURL) 302 | { 303 | /** @var TaskQueueFilenamePair $downloaded */ 304 | $downloaded = yield $this->defaultStoreActivities->download($sourceURL); 305 | 306 | $hostSpecificStore = Workflow::newActivityStub( 307 | StoreActivitiesInterface::class, 308 | ActivityOptions::new() 309 | ->withScheduleToCloseTimeout(CarbonInterval::minute(5)) 310 | ->withTaskQueue($downloaded->hostTaskQueue) 311 | ); 312 | 313 | // Call processFile activity to zip the file. 314 | // Call the activity to process the file using worker-specific task queue. 315 | $processed = yield $hostSpecificStore->process($downloaded->filename); 316 | 317 | // Call upload activity to upload the zipped file. 318 | yield $hostSpecificStore->upload($processed, $destinationURL); 319 | 320 | return 'OK'; 321 | } 322 | } 323 | ``` 324 | 325 | Register with symfony service container: 326 | ```php 327 | services(); 331 | $services->defaults() 332 | ->public() 333 | ->autowire(true) 334 | ->autoconfigure(true); 335 | 336 | $services->set(Temporal\Samples\FileProcessing\StoreActivity::class) 337 | // Setting a "label to your activity" will add the activity to the ActivityRegistry, 338 | // allowing your employee to use this activity in your Workflow 339 | ->tag('temporal.activity.registry'); 340 | ``` 341 | 342 | Now you can run: 343 | ```bash 344 | rr serve rr.yaml 345 | ``` 346 | 347 | And call workflow by: 348 | ```php 349 | workflowClient->newWorkflowStub( 369 | \Temporal\Samples\FileProcessing\FileProcessingWorkflowInterface::class, 370 | WorkflowOptions::new() 371 | ->withRetryOptions( 372 | RetryOptions::new() 373 | ->withMaximumAttempts(3) 374 | ->withNonRetryableExceptions(\LogicException::class) 375 | ) 376 | ); 377 | 378 | // Start Workflow async, with no-wait result 379 | /** @var WorkflowRunInterface $result */ 380 | $result = $this->workflowClient->start($workflow, 'https://example.com/example_file', 's3://s3.example.com'); 381 | 382 | echo 'Run ID: ' . $result->getExecution()->getRunID(); 383 | 384 | // Or you can call workflow sync with wait result 385 | $result = $workflow->processingFile('https://example.com/example_file', 's3://s3.example.com'); 386 | 387 | echo $result; // OK 388 | } 389 | 390 | } 391 | ``` 392 | 393 | More php examples you can find [here](https://github.com/temporalio/samples-php) 394 | 395 | ## Credits 396 | 397 | - [Official Temporal PHP SDK](https://github.com/temporalio/sdk-php) 398 | - [Official Temporal PHP Samples](https://github.com/temporalio/samples-php) 399 | - [Symfony Framework](https://github.com/symfony/symfony) 400 | 401 | ## License 402 | 403 | MIT License 404 | 405 | Copyright (c) 2023 Highcore.org 406 | 407 | Permission is hereby granted, free of charge, to any person obtaining a copy 408 | of this software and associated documentation files (the "Software"), to deal 409 | in the Software without restriction, including without limitation the rights 410 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 411 | copies of the Software, and to permit persons to whom the Software is 412 | furnished to do so, subject to the following conditions: 413 | 414 | The above copyright notice and this permission notice shall be included in all 415 | copies or substantial portions of the Software. 416 | 417 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 418 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 419 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 420 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 421 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 422 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 423 | SOFTWARE. 424 | 425 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "highcore/temporal-bundle", 3 | "description": "Command Runner and Activity Registry for Temporal", 4 | "type": "library", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "loper", 9 | "email": "bizrenay@gmail.com" 10 | } 11 | ], 12 | "minimum-stability": "stable", 13 | "require": { 14 | "php": ">=8.2", 15 | "ext-json": "*", 16 | "temporal/sdk": "^2.10", 17 | "symfony/dependency-injection": "^6.4 || ^7.0", 18 | "symfony/http-kernel": "^6.4 || ^7.0", 19 | "symfony/config": "^6.4 || ^7.0", 20 | "symfony/console": "^6.4 || ^7.0", 21 | "symfony/serializer": "^6.4 || ^7.0", 22 | "symfony/property-info": "^6.4 || ^7.0" 23 | }, 24 | "require-dev": { 25 | "phpunit/phpunit": "^10.3" 26 | }, 27 | "autoload": { 28 | "psr-4": { 29 | "Highcore\\TemporalBundle\\": "src/" 30 | } 31 | }, 32 | "autoload-dev": { 33 | "psr-4": { 34 | "Tests\\Highcore\\TemporalBundle\\": "tests" 35 | } 36 | }, 37 | "conflict": { 38 | "symfony/symfony": "*" 39 | }, 40 | "config": { 41 | "allow-plugins": { 42 | "symfony/flex": true 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 11 | ./tests 12 | 13 | 14 | 15 | 16 | ./src 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/DataConverter/ClassObjectConverter.php: -------------------------------------------------------------------------------- 1 | create($this->getSerializer()->serialize($value, 'json')); 37 | } 38 | 39 | public function fromPayload(Payload $payload, Type $type) 40 | { 41 | if (!$type->isClass()) { 42 | throw new DataConverterException('Unable to decode value using class object converter - '); 43 | } 44 | 45 | $dataToHydrate = json_decode($payload->getData(), true, 512, JSON_THROW_ON_ERROR); 46 | 47 | return $this->getSerializer()->denormalize($dataToHydrate, $type->getName()); 48 | } 49 | 50 | private function getSerializer(): Serializer 51 | { 52 | return $this->serializer ?? ($this->serializer = $this->serializerFactory->create()); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | getRootNode() 22 | ->children() 23 | ->scalarNode('address')->defaultValue('localhost:7233')->end() 24 | ->arrayNode('worker') 25 | ->children() 26 | ->scalarNode('queue')->defaultValue('default')->end() 27 | ->variableNode('factory')->defaultValue(WorkerFactory::class)->end() 28 | ->arrayNode('data_converter') 29 | ->children() 30 | ->arrayNode('converters') 31 | ->requiresAtLeastOneElement() 32 | ->useAttributeAsKey('name') 33 | ->prototype('scalar')->end() 34 | ->end() 35 | ->scalarNode('class')->defaultValue(DataConverter::class)->end() 36 | ->end() 37 | ->end() 38 | ->arrayNode('testing') 39 | ->addDefaultsIfNotSet() 40 | ->children() 41 | ->booleanNode('enabled')->defaultFalse()->end() 42 | ->scalarNode('activity_invocation_cache')->defaultValue(InMemoryActivityInvocationCache::class)->end() 43 | ->end() 44 | ->end() 45 | ->end() 46 | ->end() 47 | ->arrayNode('workflow_client') 48 | ->children() 49 | ->arrayNode('options') 50 | ->children() 51 | ->scalarNode('namespace')->defaultValue('default')->end() 52 | ->scalarNode('identity')->example('pid@host')->end() 53 | ->enumNode('query_rejection_condition') 54 | ->values([ 55 | QueryRejectCondition::QUERY_REJECT_CONDITION_NONE, 56 | QueryRejectCondition::QUERY_REJECT_CONDITION_UNSPECIFIED, 57 | QueryRejectCondition::QUERY_REJECT_CONDITION_NOT_OPEN, 58 | QueryRejectCondition::QUERY_REJECT_CONDITION_NOT_COMPLETED_CLEANLY, 59 | ]) 60 | ->defaultValue(QueryRejectCondition::QUERY_REJECT_CONDITION_NONE) 61 | ->end() 62 | ->end() 63 | ->end() 64 | ->scalarNode('factory')->defaultValue(WorkflowClientFactory::class)->end() 65 | ->end() 66 | ->end() 67 | ->end() 68 | ; 69 | 70 | return $treeBuilder; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/DependencyInjection/TemporalExtension.php: -------------------------------------------------------------------------------- 1 | setParameter( 98 | 'temporal.worker.queue', 99 | $this->extractWorkerQueue($config, $defaultQueue), 100 | ); 101 | } 102 | 103 | private function registerTemporalAddressParameter( 104 | array $config, 105 | ContainerBuilder $container, 106 | ): void { 107 | $container->setParameter( 108 | 'temporal.address', 109 | $this->extractTemporalAddress($config), 110 | ); 111 | } 112 | 113 | private function registerTemporalNamespaceParameter( 114 | array $config, 115 | ContainerBuilder $container, 116 | string $defaultNamespace = 'default', 117 | ): void { 118 | $container->setParameter( 119 | 'temporal.namespace', 120 | $this->extractTemporalNamespace($config, $defaultNamespace), 121 | ); 122 | } 123 | 124 | private function registerDataConverterService( 125 | array $config, 126 | ContainerBuilder $container, 127 | string $defaultDataConverterFacadeClass = DataConverter::class, 128 | ): Definition { 129 | $dataConverterFacadeClass = $this->extractDataConverterFacadeClass($config, $defaultDataConverterFacadeClass); 130 | if ($container->hasDefinition($dataConverterFacadeClass)) { 131 | return $container->getDefinition($dataConverterFacadeClass); 132 | } 133 | 134 | return $container->register($dataConverterFacadeClass) 135 | ->setArguments(array_map(function ($dataConverterDelegateClass) use ($container): Reference { 136 | if (class_exists($dataConverterDelegateClass) && !$container->hasDefinition($dataConverterDelegateClass)) { 137 | $container->register($dataConverterDelegateClass) 138 | ->setAutoconfigured(true) 139 | ->setAutowired(true) 140 | ; 141 | } 142 | 143 | return new Reference($dataConverterDelegateClass); 144 | }, $this->extractDataConverterClasses($config))); 145 | } 146 | 147 | private function registerDataConverterAlias( 148 | array $config, 149 | ContainerBuilder $container, 150 | string $defaultDataConverterFacadeClass = DataConverter::class, 151 | ): Alias { 152 | if ($container->hasAlias(DataConverterInterface::class)) { 153 | return $container->getAlias(DataConverterInterface::class); 154 | } 155 | 156 | return $container->setAlias(DataConverterInterface::class, $this->extractDataConverterFacadeClass($config, $defaultDataConverterFacadeClass)); 157 | } 158 | 159 | private function registerWorkerFactoryService( 160 | array $config, 161 | ContainerBuilder $container, 162 | string $defaultWorkerFactoryClass = WorkerFactory::class, 163 | string $defaultDataConverterFacadeClass = DataConverter::class, 164 | string $defaultActivityInvocationCacheClass = InMemoryActivityInvocationCache::class, 165 | ): Definition { 166 | $workerFactoryClass = $this->extractWorkerFactoryClass($config, $defaultWorkerFactoryClass); 167 | if ($container->hasDefinition($workerFactoryClass)) { 168 | return $container->getDefinition($workerFactoryClass); 169 | } 170 | 171 | if ($this->isTestingEnabled($config)) { 172 | return $container->register($workerFactoryClass) 173 | ->setPublic(true) 174 | ->setAutowired(true) 175 | ->setAutoconfigured(true) 176 | ->setFactory("{$workerFactoryClass}::create") 177 | ->setArgument('$converter', new Reference($this->extractDataConverterFacadeClass($config, $defaultDataConverterFacadeClass))) 178 | ->setArgument('$activityCache', new Reference($this->extractActivityInvocationCacheClass($config, $defaultActivityInvocationCacheClass))) 179 | ; 180 | } 181 | 182 | return $container->register($workerFactoryClass) 183 | ->setPublic(true) 184 | ->setAutowired(true) 185 | ->setAutoconfigured(true) 186 | ->setFactory("{$workerFactoryClass}::create") 187 | ->setArgument('$converter', new Reference($this->extractDataConverterFacadeClass($config, $defaultDataConverterFacadeClass))) 188 | ; 189 | } 190 | 191 | private function registerWorkerFactoryAlias( 192 | array $config, 193 | ContainerBuilder $container, 194 | string $defaultWorkerFactoryClass = WorkerFactory::class, 195 | ): Alias { 196 | $workerFactoryClass = $this->extractWorkerFactoryClass($config, $defaultWorkerFactoryClass); 197 | if ($container->hasDefinition($workerFactoryClass)) { 198 | return $container->setAlias(WorkerFactoryInterface::class, $workerFactoryClass); 199 | } 200 | 201 | if ($this->isTestingEnabled($config)) { 202 | return $container->setAlias(WorkerFactoryInterface::class, TestingWorkerFactory::class); 203 | } 204 | 205 | return $container->setAlias(WorkerFactoryInterface::class, WorkerFactory::class); 206 | } 207 | 208 | private function registerActivityInvocationCacheService( 209 | array $config, 210 | ContainerBuilder $container, 211 | string $defaultActivityInvocationCacheClass = InMemoryActivityInvocationCache::class, 212 | string $defaultDataConverterFacadeClass = DataConverter::class, 213 | ): Definition { 214 | return match ($this->extractActivityInvocationCacheClass($config, $defaultActivityInvocationCacheClass)) { 215 | RoadRunnerActivityInvocationCache::class => $this->registerRoadRunnerActivityInvocationCacheService($config, $container, $defaultDataConverterFacadeClass), 216 | InMemoryActivityInvocationCache::class => $this->registerInMemoryActivityInvocationCacheService($config, $container, $defaultDataConverterFacadeClass), 217 | default => $container->getDefinition($this->extractActivityInvocationCacheClass($config, $defaultActivityInvocationCacheClass)), 218 | }; 219 | } 220 | 221 | private function registerRoadRunnerActivityInvocationCacheService( 222 | array $config, 223 | ContainerBuilder $container, 224 | string $defaultDataConverterFacadeClass = DataConverter::class, 225 | ): Definition { 226 | if ($container->hasDefinition(RoadRunnerActivityInvocationCache::class)) { 227 | return $container->getDefinition(RoadRunnerActivityInvocationCache::class); 228 | } 229 | 230 | return $container->register(RoadRunnerActivityInvocationCache::class) 231 | ->setFactory(sprintf('%s::create', RoadRunnerActivityInvocationCache::class)) 232 | ->setArgument('$dataConverter', new Reference($this->extractDataConverterFacadeClass($config, $defaultDataConverterFacadeClass))) 233 | ; 234 | } 235 | 236 | private function registerInMemoryActivityInvocationCacheService( 237 | array $config, 238 | ContainerBuilder $container, 239 | string $defaultDataConverterFacadeClass = DataConverter::class, 240 | ): Definition { 241 | if ($container->hasDefinition(InMemoryActivityInvocationCache::class)) { 242 | return $container->getDefinition(InMemoryActivityInvocationCache::class); 243 | } 244 | 245 | return $container->register(InMemoryActivityInvocationCache::class) 246 | ->setArgument('$dataConverter', new Reference($this->extractDataConverterFacadeClass($config, $defaultDataConverterFacadeClass))) 247 | ; 248 | } 249 | 250 | private function registerActivityInvocationCacheAlias( 251 | array $config, 252 | ContainerBuilder $container, 253 | string $defaultActivityInvocationCacheClass = InMemoryActivityInvocationCache::class, 254 | ): Alias { 255 | $activityInvocationCacheClass = $this->extractActivityInvocationCacheClass($config, $defaultActivityInvocationCacheClass); 256 | 257 | return $container->setAlias(ActivityInvocationCacheInterface::class, new Alias($activityInvocationCacheClass)); 258 | } 259 | 260 | private function registerWorkflowClientFactoryService( 261 | array $config, 262 | ContainerBuilder $container, 263 | string $defaultWorkflowClientFactoryClass = WorkflowClientFactory::class, 264 | string $defaultDataConverterFacadeClass = DataConverter::class, 265 | ): Definition { 266 | $workflowClientFactoryClass = $this->extractWorkflowClientFactoryClass($config, $defaultWorkflowClientFactoryClass); 267 | 268 | if ($container->hasDefinition($workflowClientFactoryClass)) { 269 | return $container->getDefinition($workflowClientFactoryClass); 270 | } 271 | 272 | return $container->register($workflowClientFactoryClass) 273 | ->setPublic(true) 274 | ->setAutowired(true) 275 | ->setAutoconfigured(true) 276 | ->addMethodCall('setDataConverter', [new Reference($this->extractDataConverterFacadeClass($config, $defaultDataConverterFacadeClass))]) 277 | ->addMethodCall('setAddress', [$this->extractTemporalAddress($config)]) 278 | ->addMethodCall('setOptions', [$this->extractWorkflowClientOptions($config)]) 279 | ; 280 | } 281 | 282 | private function registerWorkflowClientService( 283 | array $config, 284 | ContainerBuilder $container, 285 | string $defaultWorkflowClientFactoryClass = WorkflowClientFactory::class, 286 | ): Definition { 287 | return $container->register(WorkflowClient::class) 288 | ->setFactory(new Reference($this->extractWorkflowClientFactoryClass($config, $defaultWorkflowClientFactoryClass))); 289 | } 290 | 291 | public function load(array $configs, ContainerBuilder $container): void 292 | { 293 | $loader = new PhpFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config')); 294 | $loader->load('services.php'); 295 | 296 | $config = $this->processConfiguration(new Configuration(), $configs); 297 | 298 | $options = $config['workflow_client']['options'] ?? []; 299 | 300 | $this->registerWorkerQueueParameter($config, $container); 301 | $this->registerTemporalAddressParameter($config, $container); 302 | $this->registerTemporalNamespaceParameter($config, $container); 303 | 304 | $this->registerDataConverterService($config, $container); 305 | $this->registerDataConverterAlias($config, $container); 306 | $this->registerWorkflowClientFactoryService($config, $container); 307 | $this->registerWorkflowClientService($config, $container); 308 | 309 | if ($this->isTestingEnabled($config)) { 310 | $this->registerActivityInvocationCacheService($config, $container); 311 | $this->registerActivityInvocationCacheAlias($config, $container); 312 | } 313 | 314 | $this->registerWorkerFactoryService($config, $container); 315 | $this->registerWorkerFactoryAlias($config, $container); 316 | } 317 | } 318 | -------------------------------------------------------------------------------- /src/Factory/ClientOptionsFactory.php: -------------------------------------------------------------------------------- 1 | withNamespace($options['namespace']); 17 | } 18 | 19 | if (isset($options['identity'])) { 20 | $clientOptions = $clientOptions->withIdentity($options['identity']); 21 | } 22 | 23 | if (isset($options['query-rejection-condition'])) { 24 | $clientOptions = $clientOptions->withQueryRejectionCondition($options['query-rejection-condition']); 25 | } 26 | 27 | return $clientOptions; 28 | } 29 | } -------------------------------------------------------------------------------- /src/Pass/ActivityCompilerPass.php: -------------------------------------------------------------------------------- 1 | has(ActivityRegistry::class)) { 17 | return; 18 | } 19 | 20 | $definition = $container->findDefinition(ActivityRegistry::class); 21 | $taggedServices = $container->findTaggedServiceIds('temporal.activity.registry'); 22 | 23 | foreach ($taggedServices as $id => $tags) { 24 | $definition->addMethodCall('add', [new Reference($id)]); 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /src/Registry/ActivityRegistry.php: -------------------------------------------------------------------------------- 1 | isActivity($reflection)) { 19 | throw new \LogicException(\sprintf( 20 | 'Class "%s" does not have "%s" or "%s" attribute.', 21 | $activity::class, ActivityInterface::class, LocalActivityInterface::class)); 22 | } 23 | 24 | $this->activities[] = $activity; 25 | } 26 | 27 | public function all(): array 28 | { 29 | return $this->activities; 30 | } 31 | 32 | private function isActivity(\ReflectionObject $reflection): bool 33 | { 34 | if (\count($reflection->getAttributes(ActivityInterface::class)) >= 1 35 | || \count($reflection->getAttributes(LocalActivityInterface::class)) >= 1 36 | ) { 37 | return true; 38 | } 39 | 40 | foreach ($reflection->getInterfaces() as $interface) { 41 | if (\count($interface->getAttributes(ActivityInterface::class)) >= 1 42 | || \count($interface->getAttributes(LocalActivityInterface::class)) >= 1 43 | ) { 44 | return true; 45 | } 46 | } 47 | 48 | return false; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Resources/config/services.php: -------------------------------------------------------------------------------- 1 | services(); 8 | $services 9 | ->defaults() 10 | ->autowire() 11 | ->autoconfigure(); 12 | 13 | $services->set(Highcore\TemporalBundle\Registry\ActivityRegistry::class); 14 | $services->set(Highcore\TemporalBundle\WorkflowRuntimeCommand::class) 15 | ->arg('$activityRegistry', service(Highcore\TemporalBundle\Registry\ActivityRegistry::class)) 16 | ->arg('$workerFactory', service(Temporal\Worker\WorkerFactoryInterface::class)) 17 | ->arg('$workerQueue', '%temporal.worker.queue%') 18 | ->arg('$kernel', service('kernel')) 19 | ->tag('console.command'); 20 | 21 | $services->alias(Temporal\Client\WorkflowClientInterface::class, Temporal\Client\WorkflowClient::class); 22 | }; 23 | -------------------------------------------------------------------------------- /src/Serializer/DefaultSerializerFactory.php: -------------------------------------------------------------------------------- 1 | new JsonEncoder()] 31 | ); 32 | } 33 | } -------------------------------------------------------------------------------- /src/Serializer/SerializerFactory.php: -------------------------------------------------------------------------------- 1 | addCompilerPass(new ActivityCompilerPass()); 16 | } 17 | } -------------------------------------------------------------------------------- /src/WorkflowClientFactory.php: -------------------------------------------------------------------------------- 1 | address), 24 | options: $this->options, 25 | converter: $this->dataConverter 26 | ); 27 | } 28 | 29 | public function setOptions(array $options): void 30 | { 31 | $this->options = (new ClientOptionsFactory())->createFromArray($options); 32 | } 33 | 34 | public function setAddress(string $address): void 35 | { 36 | $this->address = $address; 37 | } 38 | 39 | public function setDataConverter(DataConverterInterface $converter): void 40 | { 41 | $this->dataConverter = $converter; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/WorkflowClientFactoryInterface.php: -------------------------------------------------------------------------------- 1 | kernel = $kernel; 32 | $this->workerQueue = $workerQueue; 33 | $this->activityRegistry = $activityRegistry; 34 | $this->workerFactory = $workerFactory; 35 | } 36 | 37 | protected function execute(InputInterface $input, OutputInterface $output): int 38 | { 39 | $style = new SymfonyStyle($input, $output); 40 | 41 | if ('' === $this->workerQueue) { 42 | $style->error(\sprintf('Worker queue name "%s" is not valid.', $this->workerQueue)); 43 | return Command::FAILURE; 44 | } 45 | 46 | $queueName = $this->workerQueue ?? WorkerFactoryInterface::DEFAULT_TASK_QUEUE; 47 | 48 | 49 | $worker = $this->workerFactory->newWorker($queueName); 50 | 51 | foreach ($this->getWorkflowTypes() as $workflowType) { 52 | $worker->registerWorkflowTypes($workflowType); 53 | } 54 | 55 | foreach ($this->activityRegistry->all() as $activity) { 56 | $worker->registerActivity(get_class($activity), static fn() => $activity); 57 | } 58 | 59 | 60 | $this->workerFactory->run(); 61 | 62 | return Command::SUCCESS; 63 | } 64 | 65 | private function getWorkflowTypes(): array 66 | { 67 | $workflowTypesConfig = $this->kernel->getProjectDir() . '/config/workflows.php'; 68 | 69 | if (!\file_exists($workflowTypesConfig)) { 70 | return []; 71 | } 72 | 73 | $workflowTypes = require $workflowTypesConfig; 74 | 75 | if (!\is_array($workflowTypes)) { 76 | throw new \RuntimeException('Workflow config should return array.'); 77 | } 78 | 79 | return $workflowTypes; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /tests/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/highcoreorg/temporal-bundle/a47c0a2d1d16b23b1c0bee6755da4e33eecbc03c/tests/.gitkeep -------------------------------------------------------------------------------- /tests/Factory/ClientOptionsFactoryTest.php: -------------------------------------------------------------------------------- 1 | createFromArray([ 20 | 'namespace' => $namespace, 21 | 'identity' => $identity, 22 | 'query-rejection-condition' => $queryRejectionCondition, 23 | ]); 24 | 25 | $this->assertEquals($namespace, $options->namespace); 26 | $this->assertEquals($identity, $options->identity); 27 | $this->assertEquals($queryRejectionCondition, $options->queryRejectionCondition); 28 | } 29 | 30 | public static function data(): array 31 | { 32 | return [ 33 | ['namespace', 'identity', 123], 34 | ['', 'another', 456], 35 | ]; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /writing-tests.md: -------------------------------------------------------------------------------- 1 | Writing Tests 2 | === 3 | 4 | Configuration 5 | --- 6 | 7 | In order to enable the testing functionality, it is required to make some changes to the configuration and follow a few 8 | steps from the Temporal SDK documentation. 9 | 10 | First of all, you will have to add configuration to your app, with adding a `config/packages/test/temporal.yaml` file 11 | inside your project, with the following contents: 12 | 13 | ```yaml 14 | temporal: 15 | worker: 16 | factory: Temporal\Testing\WorkerFactory 17 | 18 | testing: 19 | enabled: true 20 | activity_invocation_cache: Temporal\Worker\ActivityInvocationCache\RoadRunnerActivityInvocationCache 21 | ``` 22 | 23 | ### Creating a testing environment 24 | 25 | The Temporal SDK documentation does not provide some extensive documentation about how to configure your testing 26 | environment. Some generic examples are provided, but you will usually need to dig into the internals to figure out the 27 | proper way to do it. 28 | 29 | Here is a simplified checklist in the context of a Symfony or API Platform project using this bundle: 30 | 31 | 1. Install PHPUnit in your preferred version, or install the latest with `composer require phpunit/phpunit` 32 | 2. Make sure your PHPUnit configuration is providing a bootstrap file, as described in the documentation [ [1](https://symfony.com/doc/current/testing/bootstrap.html) ] and [ [2](https://docs.phpunit.de/en/10.5/configuration.html#the-bootstrap-attribute) ] 33 | ```xml 34 | 35 | 36 | 39 | 40 | 41 | ``` 42 | 3. In your bootstrap file lcated in `tests/bootstrap.php` you should have the following content: 43 | ```php 44 | bootEnv(dirname(__DIR__).'/.env'); 52 | } 53 | 54 | if ($_SERVER['APP_DEBUG']) { 55 | umask(0000); 56 | } 57 | 58 | $environment = \Temporal\Testing\Environment::create(); 59 | // The ./rr file is created after running installation through composer 60 | // see https://github.com/roadrunner-server/roadrunner?tab=readme-ov-file#installation-via-composer 61 | // the Temporal\Testing\Environment class does not support other ways of installation 62 | //$environment->start('./rr serve -w /app -c .rr.yaml'); 63 | $environment->start('./rr serve -c tests/.rr.test.yaml -w /app'); 64 | register_shutdown_function(fn () => $environment->stop()); 65 | ``` 66 | 4. You will then need to create a `tests/.rr.test.yaml` file with the following contents: 67 | ```yaml 68 | version: '3' 69 | 70 | server: 71 | command: "bin/console temporal:workflow:runtime --env=test" 72 | # user: "backend" # Set up your user, or remove this value 73 | # group: "backend" # Set up your group, or remove this value 74 | 75 | temporal: 76 | address: "${TEMPORAL_TEST_ADDRESS:-127.0.0.1:7233}" # It is important to let 127.0.0.1 here, as you will use the testing server, launched by the Temporal\Testing\Environment class. 77 | namespace: "${TEMPORAL_TEST_WORKER_QUEUE:-default}" # Configure a temporal namespace (you must create a namespace manually or use the default namespace named "default") 78 | activities: 79 | num_workers: 1 # Set up your worker count 80 | 81 | # Set up your values 82 | logs: 83 | mode: development 84 | output: stdout 85 | err_output: stderr 86 | encoding: console 87 | level: debug 88 | 89 | 90 | rpc: 91 | listen: 'tcp://127.0.0.1:6001' 92 | 93 | kv: 94 | test: 95 | driver: memory 96 | config: 97 | interval: 10 98 | ``` 99 | 100 | You will then have a complete testing environment. 101 | 102 | Writing your first PHPUnit test with Temporal 103 | --- 104 | 105 | ### Activity and Workflow examples 106 | 107 | Let's assume you have an Activity declared in the `App\SimpleActivity` class as follows: 108 | 109 | ```php 110 | */ 172 | private ActivityProxy $activities; 173 | 174 | public function __construct() 175 | { 176 | $this->activities = Workflow::newActivityStub( 177 | SimpleActivityInterface::class, 178 | ActivityOptions::new() 179 | ->withStartToCloseTimeout(CarbonInterval::minutes(15)) 180 | // disable retries for example to run faster 181 | ->withRetryOptions( 182 | RetryOptions::new() 183 | ->withMaximumAttempts(5) 184 | ->withInitialInterval(10) 185 | ) 186 | ); 187 | } 188 | 189 | #[WorkflowMethod(name: 'greeting')] 190 | public function greeting(string $name): \Generator 191 | { 192 | return yield $this->activities->greeting($name); 193 | } 194 | } 195 | ``` 196 | 197 | ### How to write a PHPUnit test for your Workflow 198 | 199 | In order to test your workflow, the preferred method is to mock every activity. The reason you should do this is to make 200 | your unit tests isolated from any I/O or other service that could mess with the results of your tests (Database, 201 | Network, Files, etc.). Also, this is to prevent tests from interacting with each other, each test should be run 202 | independently and should not interact with other test. 203 | 204 | This is the reasons why it is preferred to use a generic `PHPUnit\Framework\TestCase` provided by PHPUnit than the 205 | `Symfony\Bundle\FrameworkBundle\Test\KernelTestCase` provided by Symfony. 206 | 207 | However, it is not always possible. eg. if you have integration tests requiring access to a database and some data 208 | fixtures. In those cases, you will be required to keep in mind that a Temporal environment requires 2 PHP processes and 209 | those 2 processes needs to access to consistent data between those processes. 210 | 211 | Here is an example to test the `App\SimpleWorkflow` Workflow class with a generic test: 212 | 213 | ```php 214 | workflowClient = new WorkflowClient(ServiceClient::create('127.0.0.1:7233')); 236 | $this->activityMocks = new ActivityMocker(); 237 | 238 | parent::setUp(); 239 | } 240 | 241 | protected function tearDown(): void 242 | { 243 | $this->activityMocks->clear(); 244 | parent::tearDown(); 245 | } 246 | 247 | public function testWorkflowReturnsUpperCasedInput(): void 248 | { 249 | $this->activityMocks->expectCompletion('SimpleActivity.greeting', 'hello'); 250 | $workflow = $this->workflowClient->newWorkflowStub(SimpleWorkflow::class); 251 | 252 | $run = $this->workflowClient->start($workflow, 'hello'); 253 | 254 | $this->assertSame('hello', $run->getResult('string')); 255 | } 256 | } 257 | ``` 258 | 259 | References 260 | --- 261 | 262 | * [highcoreorg/temporal-bundle#12](https://github.com/highcoreorg/temporal-bundle/pull/12) 263 | * [PHPUnit with Symfony](https://symfony.com/doc/current/testing/bootstrap.html) 264 | * [PHPUnit bootstrap attribute](https://docs.phpunit.de/en/10.5/configuration.html#the-bootstrap-attribute) --------------------------------------------------------------------------------