├── .gitattributes ├── src ├── Finalizer │ ├── Finalizer.php │ ├── DoctrineClearEntityManagerFinalizer.php │ ├── ChainFinalizer.php │ └── DoctrinePingConnectionFinalizer.php ├── Attribute │ └── AssignWorker.php ├── Runtime │ ├── TemporalRunner.php │ ├── Runtime.php │ └── TemporalRuntime.php ├── Environment.php ├── Interceptor │ └── DoctrineActivityInboundInterceptor.php ├── TemporalBundle.php ├── InstalledVersions.php ├── DataCollector │ └── TemporalCollector.php ├── DependencyInjection │ ├── Compiler │ │ ├── DoctrineCompilerPass.php │ │ ├── SentryCompilerPass.php │ │ ├── ScheduleClientCompilerPass.php │ │ ├── ClientCompilerPass.php │ │ └── WorkflowCompilerPass.php │ ├── TemporalExtension.php │ ├── function.php │ └── Configuration.php ├── UI │ └── Cli │ │ ├── WorkerDebugCommand.php │ │ ├── ClientDebugCommand.php │ │ ├── ScheduleClientDebugCommand.php │ │ ├── ActivityDebugCommand.php │ │ └── WorkflowDebugCommand.php └── DataConverter │ └── SymfonySerializerDataConverter.php ├── .rr.temporal.yaml ├── .docker ├── Dockerfile └── docker-compose-temporal.yml ├── .recipie ├── index.json └── 0.1 │ └── manifest.json ├── temporal.yaml ├── LICENSE ├── templates └── data_collector │ ├── assets │ ├── temporal.svg │ └── temporal_with_text.svg │ └── layout.html.twig ├── composer.json ├── .php-cs-fixer.php ├── config └── service.php └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | .php-cs-fixer.dist.php export-ignore 2 | .gitignore export-ignore 3 | .recipe export-ignore 4 | .github export-ignore 5 | phpunit.xml export-ignore 6 | Makefile export-ignore 7 | phpstan.neon export-ignore 8 | tests export-ignore 9 | -------------------------------------------------------------------------------- /src/Finalizer/Finalizer.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright Copyright (c) 2023, The Vanta 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace Vanta\Integration\Symfony\Temporal\Finalizer; 13 | 14 | interface Finalizer 15 | { 16 | public function finalize(): void; 17 | } 18 | -------------------------------------------------------------------------------- /.rr.temporal.yaml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | server: 4 | command: "php public/index.php" 5 | env: 6 | - APP_RUNTIME: Vanta\Integration\Symfony\Temporal\Runtime\TemporalRuntime 7 | 8 | rpc: 9 | listen: tcp://0.0.0.0:6001 10 | 11 | temporal: 12 | address: "temporal:7233" 13 | activities: 14 | num_workers: 1 15 | 16 | logs: 17 | level: debug 18 | channels: 19 | temporal.level: error 20 | -------------------------------------------------------------------------------- /src/Attribute/AssignWorker.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright Copyright (c) 2023, The Vanta 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace Vanta\Integration\Symfony\Temporal\Attribute; 13 | 14 | use Attribute; 15 | 16 | #[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)] 17 | final readonly class AssignWorker 18 | { 19 | /** 20 | * @param non-empty-string $name 21 | */ 22 | public function __construct( 23 | public string $name, 24 | ) { 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.docker/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG PHP_VERSION=8.2 2 | ARG PHP_INSTALLER_VERSION=2.1 3 | ARG ROADRUNNER_VERSION=2023.2 4 | 5 | FROM ghcr.io/roadrunner-server/roadrunner:${ROADRUNNER_VERSION} AS roadrunner 6 | FROM mlocati/php-extension-installer:${PHP_INSTALLER_VERSION} as installer 7 | FROM php:${PHP_VERSION}-cli-buster AS temporal_php 8 | 9 | COPY --from=roadrunner /usr/bin/rr /usr/local/bin/rr 10 | 11 | RUN --mount=type=bind,from=installer,source=/usr/bin/install-php-extensions,target=/usr/local/bin/install-php-extensions \ 12 | install-php-extensions opcache zip intl pcntl sockets protobuf grpc 13 | 14 | WORKDIR /app 15 | 16 | CMD rr serve -c .rr.temporal.yaml -------------------------------------------------------------------------------- /.recipie/index.json: -------------------------------------------------------------------------------- 1 | { 2 | "aliases": { 3 | "temporal": "vanta/temporal-bundle", 4 | "temporal-sentry": "vanta/temporal-sentry", 5 | "sentry": "sentry/sentry-symfony" 6 | }, 7 | "recipes": { 8 | "vanta/temporal-bundle": [ 9 | "0.1" 10 | ] 11 | }, 12 | "branch": "master", 13 | "is_contrib": false, 14 | "_links": { 15 | "repository": "github.com/VantaFinance/temporal-bundle/.recipie", 16 | "origin_template": "github.com/VantaFinance/temporal-bundle/.recipie/{version}:master", 17 | "recipe_template": "https://raw.githubusercontent.com/VantaFinance/temporal-bundle/main/.recipie/{version}/manifest.json" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Runtime/TemporalRunner.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright Copyright (c) 2023, The Vanta 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace Vanta\Integration\Symfony\Temporal\Runtime; 13 | 14 | use Symfony\Component\Runtime\RunnerInterface as Runner; 15 | 16 | final readonly class TemporalRunner implements Runner 17 | { 18 | public function __construct( 19 | private Runtime $runtime 20 | ) { 21 | } 22 | 23 | 24 | public function run(): int 25 | { 26 | $this->runtime->run(); 27 | 28 | return 0; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Environment.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright Copyright (c) 2023, The Vanta 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace Vanta\Integration\Symfony\Temporal; 13 | 14 | use Spiral\RoadRunner\Environment as RoadRunnerEnvironment; 15 | 16 | /** 17 | * @phpstan-import-type EnvironmentVariables from RoadRunnerEnvironment 18 | */ 19 | final class Environment 20 | { 21 | /** 22 | * @param EnvironmentVariables $with 23 | */ 24 | public static function create(array $with): RoadRunnerEnvironment 25 | { 26 | /**@phpstan-ignore-next-line */ 27 | return new RoadRunnerEnvironment([...$_ENV, ...$_SERVER, ...$with]); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Finalizer/DoctrineClearEntityManagerFinalizer.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright Copyright (c) 2023, The Vanta 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace Vanta\Integration\Symfony\Temporal\Finalizer; 13 | 14 | use Doctrine\Persistence\ManagerRegistry; 15 | 16 | final readonly class DoctrineClearEntityManagerFinalizer implements Finalizer 17 | { 18 | public function __construct( 19 | private ManagerRegistry $managerRegistry 20 | ) { 21 | } 22 | 23 | 24 | public function finalize(): void 25 | { 26 | foreach ($this->managerRegistry->getManagers() as $manager) { 27 | $manager->clear(); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Runtime/Runtime.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright Copyright (c) 2023, The Vanta 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace Vanta\Integration\Symfony\Temporal\Runtime; 13 | 14 | use Countable; 15 | use Temporal\Worker\WorkerFactoryInterface as WorkerFactory; 16 | use Temporal\Worker\WorkerInterface as Worker; 17 | 18 | final readonly class Runtime implements Countable 19 | { 20 | /** 21 | * @param array $workers 22 | */ 23 | public function __construct( 24 | private WorkerFactory $factory, 25 | private array $workers, 26 | ) { 27 | } 28 | 29 | 30 | 31 | public function run(): void 32 | { 33 | $this->factory->run(); 34 | } 35 | 36 | public function count(): int 37 | { 38 | return count($this->workers); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /temporal.yaml: -------------------------------------------------------------------------------- 1 | temporal: 2 | defaultClient: default 3 | pool: 4 | dataConverter: temporal.data_converter 5 | roadrunnerRPC: '%env(RR_RPC)%' 6 | 7 | workers: 8 | default: 9 | taskQueue: default 10 | maxConcurrentActivityExecutionSize: 0 11 | workerActivitiesPerSecond: 0 12 | exceptionInterceptor: temporal.exception_interceptor 13 | finalizers: [ ] 14 | maxConcurrentLocalActivityExecutionSize: 0 15 | workerLocalActivitiesPerSecond: 0 16 | taskQueueActivitiesPerSecond: 0 17 | maxConcurrentActivityTaskPollers: 0 18 | maxConcurrentWorkflowTaskExecutionSize: 0 19 | maxConcurrentWorkflowTaskPollers: 0 20 | enableSessionWorker: false 21 | sessionResourceId: null 22 | maxConcurrentSessionExecutionSize: 1000 23 | 24 | clients: 25 | default: 26 | namespace: default 27 | address: '%env(TEMPORAL_ADDRESS)%' 28 | dataConverter: temporal.data_converter -------------------------------------------------------------------------------- /src/Runtime/TemporalRuntime.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright Copyright (c) 2023, The Vanta 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace Vanta\Integration\Symfony\Temporal\Runtime; 13 | 14 | use Symfony\Component\HttpKernel\KernelInterface as Kernel; 15 | use Symfony\Component\Runtime\RunnerInterface as Runner; 16 | use Symfony\Component\Runtime\SymfonyRuntime; 17 | 18 | final class TemporalRuntime extends SymfonyRuntime 19 | { 20 | public function getRunner(?object $application): Runner 21 | { 22 | if ($application instanceof Kernel) { 23 | $application->boot(); 24 | 25 | $runtime = $application->getContainer()->get('temporal.runtime'); 26 | 27 | return $runtime instanceof Runtime ? new TemporalRunner($runtime) : parent::getRunner($application); 28 | } 29 | 30 | return parent::getRunner($application); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.recipie/0.1/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifests": { 3 | "vanta/temporal-bundle": { 4 | "manifest": { 5 | "bundles": { 6 | "Vanta\\Integration\\Symfony\\Temporal\\TemporalBundle": ["all"] 7 | }, 8 | "copy-from-package": { 9 | ".rr.temporal.yaml": ".rr.temporal.yaml", 10 | ".docker/docker-compose-temporal.yml": ".docker/docker-compose-temporal.yml", 11 | ".docker/Dockerfile": ".docker/Dockerfile", 12 | "temporal.yaml": "%CONFIG_DIR%/packages/temporal.yaml" 13 | }, 14 | "post-install-output": [ 15 | " * Configure the workers/clients in config/packages/temporal.yaml", 16 | " * Configure the docker in dir .docker/" 17 | ], 18 | "env": { 19 | "TEMPORAL_ADDRESS": "temporal:7233", 20 | "RR_RPC": "tcp://rr_temporal:6001" 21 | } 22 | }, 23 | "ref": "3a1f8a7e2a44c46ff3cc741709b4ed2057a78ed0" 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Finalizer/ChainFinalizer.php: -------------------------------------------------------------------------------- 1 | $finalizers 17 | */ 18 | public function __construct( 19 | private readonly iterable $finalizers, 20 | ?Logger $logger = new NullLogger(), 21 | ) { 22 | $this->logger = $logger ?? new NullLogger(); 23 | } 24 | 25 | public function finalize(): void 26 | { 27 | foreach ($this->finalizers as $finalizer) { 28 | try { 29 | $finalizer->finalize(); 30 | } catch (Throwable $e) { 31 | $this->logger->critical('Failed to finalize the finalizer', [ 32 | 'exception' => $e, 33 | ]); 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Vanta 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Interceptor/DoctrineActivityInboundInterceptor.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright Copyright (c) 2023, The Vanta 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace Vanta\Integration\Symfony\Temporal\Interceptor; 13 | 14 | use Doctrine\DBAL\Exception\DriverException; 15 | use Doctrine\ORM\Exception\EntityManagerClosed; 16 | use Temporal\Interceptor\ActivityInbound\ActivityInput; 17 | use Temporal\Interceptor\ActivityInboundInterceptor; 18 | use Throwable; 19 | use Vanta\Integration\Symfony\Temporal\Finalizer\DoctrinePingConnectionFinalizer; 20 | 21 | final readonly class DoctrineActivityInboundInterceptor implements ActivityInboundInterceptor 22 | { 23 | public function __construct( 24 | private DoctrinePingConnectionFinalizer $finalizer 25 | ) { 26 | } 27 | 28 | 29 | /** 30 | * @throws Throwable 31 | */ 32 | public function handleActivityInbound(ActivityInput $input, callable $next): mixed 33 | { 34 | try { 35 | $result = $next($input); 36 | } catch (Throwable $e) { 37 | if ($e instanceof EntityManagerClosed || $e instanceof DriverException) { 38 | $this->finalizer->finalize(); 39 | } 40 | 41 | throw $e; 42 | } 43 | 44 | return $result; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/TemporalBundle.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright Copyright (c) 2023, The Vanta 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace Vanta\Integration\Symfony\Temporal; 13 | 14 | use function dirname; 15 | 16 | use Symfony\Component\DependencyInjection\ContainerBuilder; 17 | use Symfony\Component\HttpKernel\Bundle\Bundle; 18 | use Vanta\Integration\Symfony\Temporal\DependencyInjection\Compiler\ClientCompilerPass; 19 | use Vanta\Integration\Symfony\Temporal\DependencyInjection\Compiler\DoctrineCompilerPass; 20 | use Vanta\Integration\Symfony\Temporal\DependencyInjection\Compiler\ScheduleClientCompilerPass; 21 | use Vanta\Integration\Symfony\Temporal\DependencyInjection\Compiler\SentryCompilerPass; 22 | use Vanta\Integration\Symfony\Temporal\DependencyInjection\Compiler\WorkflowCompilerPass; 23 | 24 | final class TemporalBundle extends Bundle 25 | { 26 | public function build(ContainerBuilder $container): void 27 | { 28 | $container->addCompilerPass(new WorkflowCompilerPass()); 29 | $container->addCompilerPass(new ClientCompilerPass()); 30 | $container->addCompilerPass(new DoctrineCompilerPass()); 31 | $container->addCompilerPass(new SentryCompilerPass()); 32 | $container->addCompilerPass(new ScheduleClientCompilerPass()); 33 | } 34 | 35 | public function getPath(): string 36 | { 37 | return dirname(__DIR__); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/InstalledVersions.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright Copyright (c) 2023, The Vanta 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace Vanta\Integration\Symfony\Temporal; 13 | 14 | use Closure; 15 | use Symfony\Component\DependencyInjection\ContainerBuilder; 16 | 17 | /** 18 | * @internal 19 | * @phpstan-type Handler \Closure(non-empty-string, class-string, array|array{}): bool 20 | */ 21 | final class InstalledVersions 22 | { 23 | /** 24 | * @var Handler|null 25 | */ 26 | private static ?Closure $handler = null; 27 | 28 | 29 | /** 30 | * @param Handler|null $handler 31 | */ 32 | public static function setHandler(?Closure $handler = null): void 33 | { 34 | self::$handler = $handler; 35 | } 36 | 37 | 38 | /** 39 | * @param non-empty-string $package 40 | * @param class-string $class 41 | * @param non-empty-array|array{} $parentPackages 42 | */ 43 | public static function willBeAvailable(string $package, string $class, array $parentPackages = []): bool 44 | { 45 | $handler = self::$handler; 46 | 47 | if ($handler) { 48 | return $handler($package, $class, $parentPackages); 49 | } 50 | 51 | return ContainerBuilder::willBeAvailable($package, $class, $parentPackages); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /templates/data_collector/assets/temporal.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/Finalizer/DoctrinePingConnectionFinalizer.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright Copyright (c) 2023, The Vanta 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace Vanta\Integration\Symfony\Temporal\Finalizer; 13 | 14 | use Doctrine\DBAL\Exception as DBALException; 15 | use Doctrine\ORM\EntityManagerInterface as EntityManager; 16 | use Doctrine\Persistence\ManagerRegistry; 17 | use InvalidArgumentException; 18 | 19 | final readonly class DoctrinePingConnectionFinalizer implements Finalizer 20 | { 21 | public function __construct( 22 | private ManagerRegistry $managerRegistry, 23 | private string $entityManagerName, 24 | ) { 25 | } 26 | 27 | 28 | /** 29 | * @throws DBALException 30 | */ 31 | public function finalize(): void 32 | { 33 | try { 34 | $entityManager = $this->managerRegistry->getManager($this->entityManagerName); 35 | } catch (InvalidArgumentException) { 36 | return; 37 | } 38 | 39 | if (!$entityManager instanceof EntityManager) { 40 | return; 41 | } 42 | 43 | $connection = $entityManager->getConnection(); 44 | 45 | try { 46 | $connection->executeQuery($connection->getDatabasePlatform()->getDummySelectSQL()); 47 | } catch (DBALException) { 48 | $connection->close(); 49 | 50 | // Attempt to reestablish the lazy connection by sending another query. 51 | $connection->executeQuery($connection->getDatabasePlatform()->getDummySelectSQL()); 52 | } 53 | 54 | if (!$entityManager->isOpen()) { 55 | $this->managerRegistry->resetManager($this->entityManagerName); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vanta/temporal-bundle", 3 | "type": "symfony-bundle", 4 | "description": "Integration temporal with symfony", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Vlad Shashkov", 9 | "email": "v.shashkov@pos-credit.ru" 10 | }, 11 | { 12 | "name": "Vanta Team", 13 | "homepage": "https://vanta.ru" 14 | } 15 | ], 16 | "keywords": [ 17 | "temporal", 18 | "symfony", 19 | "bundle" 20 | ], 21 | "autoload": { 22 | "psr-4": { 23 | "Vanta\\Integration\\Symfony\\Temporal\\": "src/" 24 | }, 25 | "files": [ 26 | "src/DependencyInjection/function.php" 27 | ] 28 | }, 29 | "autoload-dev": { 30 | "psr-4": { 31 | "Vanta\\Integration\\Symfony\\Temporal\\Test\\": "tests/" 32 | } 33 | }, 34 | "require": { 35 | "php": "^8.2", 36 | "symfony/dependency-injection": "^6.0|^7.0", 37 | "symfony/http-kernel": "^6.0|^7.0", 38 | "symfony/runtime": "^6.0|^7.0", 39 | "temporal/sdk": "^2.9" 40 | }, 41 | "require-dev": { 42 | "symfony/serializer": "^6.0|^7.0", 43 | "doctrine/doctrine-bundle": "^2.10", 44 | "doctrine/orm": "^2.15", 45 | "phpstan/phpstan": "^2.0", 46 | "friendsofphp/php-cs-fixer": "3.65.0", 47 | "phpstan/extension-installer": "^1.4", 48 | "phpstan/phpstan-symfony": "^2.0", 49 | "psr/log": "^3.0", 50 | "sentry/sentry-symfony": "^4.10", 51 | "nyholm/symfony-bundle-test": "^3.0", 52 | "phpunit/phpunit": "^10.3", 53 | "symfony/monolog-bundle": "^3.8", 54 | "symfony/web-profiler-bundle": "^6.0|^7.0", 55 | "vanta/temporal-sentry": "^0.1.1" 56 | }, 57 | "suggest": { 58 | "vanta/temporal-sentry": "Integration for sentry" 59 | }, 60 | "config": { 61 | "allow-plugins": { 62 | "symfony/runtime": true, 63 | "phpstan/extension-installer": true, 64 | "php-http/discovery": false 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/DataCollector/TemporalCollector.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright Copyright (c) 2023, The Vanta 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace Vanta\Integration\Symfony\Temporal\DataCollector; 13 | 14 | use Symfony\Bundle\FrameworkBundle\DataCollector\TemplateAwareDataCollectorInterface as TemplateAwareDataCollector; 15 | use Symfony\Component\HttpFoundation\Request; 16 | use Symfony\Component\HttpFoundation\Response; 17 | use Throwable; 18 | 19 | final readonly class TemporalCollector implements TemplateAwareDataCollector 20 | { 21 | /** 22 | * @param array> $workers 23 | * @param list> $clients 24 | * @param list}}>> $workflows 25 | * @param list}}>> $activities 26 | * @param list> $scheduleClients 27 | */ 28 | public function __construct( 29 | public array $workers, 30 | public array $clients, 31 | public array $workflows, 32 | public array $activities, 33 | public array $scheduleClients, 34 | ) { 35 | } 36 | 37 | 38 | public function collect(Request $request, Response $response, ?Throwable $exception = null): void 39 | { 40 | } 41 | 42 | /** 43 | * @return non-empty-string 44 | */ 45 | public function getName(): string 46 | { 47 | return 'Temporal'; 48 | } 49 | 50 | public function reset(): void 51 | { 52 | } 53 | 54 | public static function getTemplate(): string 55 | { 56 | return '@Temporal/data_collector/layout.html.twig'; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | in(['src', 'tests', 'config']) 7 | ->exclude([ 8 | 'var', 9 | 'vendor', 10 | ]) 11 | ; 12 | 13 | return (new PhpCsFixer\Config()) 14 | ->setRules([ 15 | '@PER-CS1.0' => true, 16 | '@PER-CS1.0:risky' => true, 17 | '@PHP80Migration:risky' => true, 18 | '@PHP82Migration' => true, 19 | 'no_superfluous_phpdoc_tags' => true, 20 | 'single_line_throw' => false, 21 | 'concat_space' => ['spacing' => 'one'], 22 | 'ordered_imports' => true, 23 | 'global_namespace_import' => [ 24 | 'import_classes' => true, 25 | 'import_constants' => true, 26 | 'import_functions' => true, 27 | ], 28 | 'native_constant_invocation' => false, 29 | 'native_function_invocation' => false, 30 | 'modernize_types_casting' => true, 31 | 'is_null' => true, 32 | 'array_syntax' => [ 33 | 'syntax' => 'short', 34 | ], 35 | 'final_public_method_for_abstract_class' => true, 36 | 'phpdoc_annotation_without_dot' => false, 37 | 'phpdoc_summary' => false, 38 | 'logical_operators' => true, 39 | 'class_definition' => false, 40 | 'binary_operator_spaces' => ['operators' => ['=>' => 'align_single_space_minimal', '=' => 'align_single_space_minimal']], 41 | 'declare_strict_types' => true, 42 | 'yoda_style' => false, 43 | 'no_unused_imports' => true, 44 | 'use_arrow_functions' => false, 45 | 'nullable_type_declaration_for_default_null_value' => true, 46 | ]) 47 | ->setFinder($finder) 48 | ->setRiskyAllowed(true) 49 | ->setCacheFile(__DIR__ . '/var/.php_cs.cache') 50 | ; -------------------------------------------------------------------------------- /src/DependencyInjection/Compiler/DoctrineCompilerPass.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright Copyright (c) 2023, The Vanta 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace Vanta\Integration\Symfony\Temporal\DependencyInjection\Compiler; 13 | 14 | use Doctrine\ORM\EntityManager; 15 | use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface as CompilerPass; 16 | use Symfony\Component\DependencyInjection\ContainerBuilder; 17 | use Symfony\Component\DependencyInjection\Reference; 18 | 19 | use function Vanta\Integration\Symfony\Temporal\DependencyInjection\definition; 20 | 21 | use Vanta\Integration\Symfony\Temporal\Finalizer\DoctrinePingConnectionFinalizer; 22 | use Vanta\Integration\Symfony\Temporal\InstalledVersions; 23 | use Vanta\Integration\Symfony\Temporal\Interceptor\DoctrineActivityInboundInterceptor; 24 | 25 | final readonly class DoctrineCompilerPass implements CompilerPass 26 | { 27 | public function process(ContainerBuilder $container): void 28 | { 29 | if (!InstalledVersions::willBeAvailable('doctrine/doctrine-bundle', EntityManager::class, [])) { 30 | return; 31 | } 32 | 33 | if (!$container->hasParameter('doctrine.entity_managers')) { 34 | return; 35 | } 36 | 37 | /** @var array $entityManagers */ 38 | $entityManagers = $container->getParameter('doctrine.entity_managers'); 39 | 40 | foreach ($entityManagers as $entityManager => $id) { 41 | $finalizerId = sprintf('temporal.doctrine_ping_connection_%s.finalizer', $entityManager); 42 | 43 | $container->register($finalizerId, DoctrinePingConnectionFinalizer::class) 44 | ->setArguments([ 45 | new Reference('doctrine'), 46 | $entityManager, 47 | ]) 48 | ->addTag('temporal.finalizer') 49 | ; 50 | 51 | $interceptorId = sprintf('temporal.doctrine_ping_connection_%s_activity_inbound.interceptor', $entityManager); 52 | 53 | $container->register($interceptorId, DoctrineActivityInboundInterceptor::class) 54 | ->setArguments([ 55 | definition(DoctrinePingConnectionFinalizer::class) 56 | ->setArguments([ 57 | new Reference('doctrine'), 58 | $entityManager, 59 | ]), 60 | ]) 61 | ; 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/DependencyInjection/TemporalExtension.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright Copyright (c) 2023, The Vanta 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace Vanta\Integration\Symfony\Temporal\DependencyInjection; 13 | 14 | use Exception; 15 | use ReflectionClass; 16 | use Reflector; 17 | use Symfony\Component\Config\FileLocator; 18 | use Symfony\Component\DependencyInjection\ChildDefinition; 19 | use Symfony\Component\DependencyInjection\ContainerBuilder; 20 | use Symfony\Component\DependencyInjection\Extension\Extension; 21 | use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; 22 | use Temporal\Activity\ActivityInterface as Activity; 23 | use Temporal\Workflow\WorkflowInterface as Workflow; 24 | 25 | final class TemporalExtension extends Extension 26 | { 27 | /** 28 | * @throws Exception 29 | */ 30 | public function load(array $configs, ContainerBuilder $container): void 31 | { 32 | $loader = new PhpFileLoader($container, new FileLocator(__DIR__ . '/../../config')); 33 | 34 | $loader->load('service.php'); 35 | 36 | $configuration = new Configuration(); 37 | 38 | 39 | $container->setParameter('temporal.config', $this->processConfiguration($configuration, $configs)); 40 | $container->registerAttributeForAutoconfiguration(Workflow::class, workflowConfigurator(...)); 41 | $container->registerAttributeForAutoconfiguration(Activity::class, activityConfigurator(...)); 42 | } 43 | } 44 | 45 | 46 | /** 47 | * @internal 48 | */ 49 | function workflowConfigurator(ChildDefinition $definition, Workflow $attribute, Reflector $reflector): void 50 | { 51 | if (!$reflector instanceof ReflectionClass) { 52 | return; 53 | } 54 | 55 | $assignWorkers = getWorkers($reflector); 56 | $attributes = []; 57 | 58 | if ($assignWorkers != []) { 59 | $attributes['workers'] = $assignWorkers; 60 | } 61 | 62 | $definition->addTag('temporal.workflow', $attributes); 63 | } 64 | 65 | 66 | /** 67 | * @internal 68 | */ 69 | function activityConfigurator(ChildDefinition $definition, Activity $attribute, Reflector $reflector): void 70 | { 71 | if (!$reflector instanceof ReflectionClass) { 72 | return; 73 | } 74 | 75 | $assignWorkers = getWorkers($reflector); 76 | $attributes = ['prefix' => $attribute->prefix]; 77 | 78 | if ($assignWorkers != []) { 79 | $attributes['workers'] = $assignWorkers; 80 | } 81 | 82 | $definition->addTag('temporal.activity', $attributes); 83 | } 84 | -------------------------------------------------------------------------------- /src/UI/Cli/WorkerDebugCommand.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright Copyright (c) 2023, The Vanta 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace Vanta\Integration\Symfony\Temporal\UI\Cli; 13 | 14 | use Symfony\Component\Console\Attribute\AsCommand; 15 | use Symfony\Component\Console\Command\Command; 16 | use Symfony\Component\Console\Helper\TableSeparator; 17 | use Symfony\Component\Console\Input\InputArgument; 18 | use Symfony\Component\Console\Input\InputInterface as Input; 19 | use Symfony\Component\Console\Output\OutputInterface as Output; 20 | use Symfony\Component\Console\Style\SymfonyStyle; 21 | use Temporal\Worker\WorkerInterface as Worker; 22 | 23 | #[AsCommand('debug:temporal:workers', 'List registered workers')] 24 | final class WorkerDebugCommand extends Command 25 | { 26 | /** 27 | * @param array $workers 28 | */ 29 | public function __construct(private readonly array $workers) 30 | { 31 | parent::__construct(); 32 | } 33 | 34 | 35 | protected function configure(): void 36 | { 37 | $this->addArgument('workers', mode: InputArgument::IS_ARRAY | InputArgument::OPTIONAL, description: 'Worker names', default: []); 38 | } 39 | 40 | 41 | protected function execute(Input $input, Output $output): int 42 | { 43 | $rows = []; 44 | $workers = $this->workers; 45 | /** @var list $interestedWorkers */ 46 | $interestedWorkers = $input->getArgument('workers'); 47 | $hasInterestedWorkers = $interestedWorkers != []; 48 | $io = new SymfonyStyle($input, $output); 49 | 50 | 51 | if ($hasInterestedWorkers) { 52 | $workers = array_filter( 53 | $this->workers, 54 | static fn (string $key): bool => in_array($key, $interestedWorkers), 55 | ARRAY_FILTER_USE_KEY 56 | ); 57 | } 58 | 59 | $io->title('Temporal Workers'); 60 | 61 | foreach ($workers as $name => $worker) { 62 | $rows[] = [$name, json_encode($worker->getOptions(), JSON_PRETTY_PRINT)]; 63 | $rows[] = new TableSeparator(); 64 | } 65 | 66 | if (!is_array(end($rows))) { 67 | array_pop($rows); 68 | } 69 | 70 | if ($rows == []) { 71 | $io->note('Not found workers'); 72 | 73 | return self::SUCCESS; 74 | } 75 | 76 | $io->table(['Name', 'Options'], $rows); 77 | 78 | return self::SUCCESS; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /.docker/docker-compose-temporal.yml: -------------------------------------------------------------------------------- 1 | version: "3.5" 2 | services: 3 | temporal-php-worker: 4 | container_name: temporal-php-worker 5 | build: 6 | context: ../ 7 | dockerfile: ./.docker/Dockerfile 8 | target: temporal_php 9 | volumes: 10 | - ../:/app 11 | networks: 12 | - temporal-network 13 | depends_on: 14 | - temporal 15 | healthcheck: 16 | test: [ "CMD", "curl", "--fail", "0.0.0.0:2114/health?plugin=temporal" ] 17 | interval: 10s 18 | timeout: 10s 19 | retries: 5 20 | 21 | temporal-postgresql: 22 | container_name: temporal-postgresql 23 | environment: 24 | POSTGRES_PASSWORD: temporal 25 | POSTGRES_USER: temporal 26 | image: postgres:15-alpine 27 | networks: 28 | - temporal-network 29 | ports: 30 | - 5432:5432 31 | volumes: 32 | - temporal_pgsql:/var/lib/postgresql/data 33 | healthcheck: 34 | test: [ "CMD", "pg_isready", "-U", "temporal", "-d", "temporal" ] 35 | interval: 10s 36 | timeout: 5s 37 | retries: 5 38 | 39 | temporal: 40 | container_name: temporal 41 | depends_on: 42 | temporal-postgresql: 43 | condition: service_healthy 44 | environment: 45 | - DB=postgres12 46 | - DB_PORT=5432 47 | - POSTGRES_USER=temporal 48 | - POSTGRES_PWD=temporal 49 | - POSTGRES_SEEDS=temporal-postgresql 50 | - TEMPORAL_ADDRESS=temporal:7233 51 | image: temporalio/auto-setup:1.21.5 52 | networks: 53 | - temporal-network 54 | ports: 55 | - 7233:7233 56 | healthcheck: 57 | test: [ "CMD", "temporal", "workflow", "list" ] 58 | interval: 1s 59 | timeout: 5s 60 | retries: 30 61 | 62 | temporal-admin-tools: 63 | container_name: temporal-admin-tools 64 | depends_on: 65 | temporal: 66 | condition: service_healthy 67 | environment: 68 | - TEMPORAL_ADDRESS=temporal:7233 69 | - TEMPORAL_CLI_ADDRESS=temporal:7233 70 | image: temporalio/admin-tools:1.21.4 71 | networks: 72 | - temporal-network 73 | stdin_open: true 74 | tty: true 75 | 76 | temporal-ui: 77 | container_name: temporal-ui 78 | depends_on: 79 | temporal: 80 | condition: service_healthy 81 | environment: 82 | - TEMPORAL_ADDRESS=temporal:7233 83 | - TEMPORAL_CORS_ORIGINS=http://localhost:3000 84 | - TEMPORAL_CSRF_COOKIE_INSECURE=true 85 | image: temporalio/ui:2.17.2 86 | networks: 87 | - temporal-network 88 | ports: 89 | - 8080:8080 90 | 91 | networks: 92 | temporal-network: 93 | driver: bridge 94 | name: temporal-network 95 | 96 | 97 | volumes: 98 | temporal_pgsql: -------------------------------------------------------------------------------- /src/DependencyInjection/Compiler/SentryCompilerPass.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright Copyright (c) 2023, The Vanta 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace Vanta\Integration\Symfony\Temporal\DependencyInjection\Compiler; 13 | 14 | use Sentry\SentryBundle\SentryBundle; 15 | use Sentry\Serializer\RepresentationSerializer; 16 | use Sentry\StacktraceBuilder; 17 | use Sentry\State\HubInterface as Hub; 18 | use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface as CompilerPass; 19 | use Symfony\Component\DependencyInjection\ContainerBuilder; 20 | use Symfony\Component\DependencyInjection\Reference; 21 | 22 | use function Vanta\Integration\Symfony\Temporal\DependencyInjection\definition; 23 | 24 | use Vanta\Integration\Symfony\Temporal\InstalledVersions; 25 | use Vanta\Integration\Temporal\Sentry\SentryActivityInboundInterceptor; 26 | use Vanta\Integration\Temporal\Sentry\SentryWorkflowOutboundCallsInterceptor; 27 | 28 | final readonly class SentryCompilerPass implements CompilerPass 29 | { 30 | public function process(ContainerBuilder $container): void 31 | { 32 | if (!InstalledVersions::willBeAvailable('sentry/sentry-symfony', SentryBundle::class, [])) { 33 | return; 34 | } 35 | 36 | if (!InstalledVersions::willBeAvailable('vanta/temporal-sentry', SentryWorkflowOutboundCallsInterceptor::class)) { 37 | return; 38 | } 39 | 40 | if (!$container->has(Hub::class) && !$container->has('sentry.client.options')) { 41 | return; 42 | } 43 | 44 | $container->register('temporal.sentry_stack_trace_builder', StacktraceBuilder::class) 45 | ->setArguments([ 46 | new Reference('sentry.client.options'), 47 | definition(RepresentationSerializer::class) 48 | ->setArguments([ 49 | new Reference('sentry.client.options'), 50 | ]), 51 | ]) 52 | ; 53 | 54 | $container->register('temporal.sentry_workflow_outbound_calls.interceptor', SentryWorkflowOutboundCallsInterceptor::class) 55 | ->setArguments([ 56 | new Reference(Hub::class), 57 | new Reference('temporal.sentry_stack_trace_builder'), 58 | ]) 59 | ; 60 | 61 | $container->register('temporal.sentry_activity_inbound.interceptor', SentryActivityInboundInterceptor::class) 62 | ->setArguments([ 63 | new Reference(Hub::class), 64 | new Reference('temporal.sentry_stack_trace_builder'), 65 | ]) 66 | ; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /config/service.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright Copyright (c) 2023, The Vanta 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | use Doctrine\ORM\EntityManagerInterface as EntityManager; 13 | use Monolog\Logger; 14 | use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; 15 | 16 | use function Symfony\Component\DependencyInjection\Loader\Configurator\inline_service; 17 | use function Symfony\Component\DependencyInjection\Loader\Configurator\service; 18 | 19 | use Symfony\Component\Serializer\SerializerInterface as Serializer; 20 | use Temporal\DataConverter\DataConverter; 21 | use Temporal\DataConverter\JsonConverter; 22 | use Temporal\Exception\ExceptionInterceptor; 23 | use Vanta\Integration\Symfony\Temporal\DataCollector\TemporalCollector; 24 | use Vanta\Integration\Symfony\Temporal\DataConverter\SymfonySerializerDataConverter; 25 | use Vanta\Integration\Symfony\Temporal\Finalizer\DoctrineClearEntityManagerFinalizer; 26 | use Vanta\Integration\Symfony\Temporal\InstalledVersions; 27 | 28 | return static function (ContainerConfigurator $configurator): void { 29 | $services = $configurator->services(); 30 | 31 | $services->set('temporal.data_converter', DataConverter::class) 32 | ->args([ 33 | inline_service(JsonConverter::class), 34 | ]) 35 | 36 | ->set('temporal.exception_interceptor', ExceptionInterceptor::class) 37 | ->factory([ExceptionInterceptor::class, 'createDefault']) 38 | 39 | ->set('temporal.collector', TemporalCollector::class) 40 | ->tag('data_collector', ['id' => 'Temporal']) 41 | ; 42 | 43 | 44 | if (InstalledVersions::willBeAvailable('symfony/serializer', Serializer::class)) { 45 | $services->set('temporal.data_converter', DataConverter::class) 46 | ->args([ 47 | inline_service(SymfonySerializerDataConverter::class) 48 | ->args([ 49 | service('serializer'), 50 | ]), 51 | ]) 52 | ; 53 | } 54 | 55 | if (InstalledVersions::willBeAvailable('doctrine/doctrine-bundle', EntityManager::class)) { 56 | $services->set('temporal.doctrine_clear_entity_manager.finalizer', DoctrineClearEntityManagerFinalizer::class) 57 | ->args([service('doctrine')]) 58 | ->tag('temporal.finalizer') 59 | ; 60 | } 61 | 62 | if (InstalledVersions::willBeAvailable('symfony/monolog-bundle', Logger::class)) { 63 | $services->set('monolog.logger.temporal') 64 | ->parent('monolog.logger') 65 | ->call('withName', ['temporal'], true) 66 | ; 67 | } 68 | }; 69 | -------------------------------------------------------------------------------- /src/DataConverter/SymfonySerializerDataConverter.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright Copyright (c) 2023, The Vanta 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace Vanta\Integration\Symfony\Temporal\DataConverter; 13 | 14 | use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer as ObjectNormalizer; 15 | use Symfony\Component\Serializer\SerializerInterface as Serializer; 16 | use Temporal\Api\Common\V1\Payload; 17 | use Temporal\DataConverter\EncodingKeys; 18 | use Temporal\DataConverter\JsonConverter; 19 | use Temporal\DataConverter\PayloadConverterInterface as PayloadConverter; 20 | use Temporal\DataConverter\Type; 21 | use Temporal\Exception\DataConverterException; 22 | use Throwable; 23 | 24 | final readonly class SymfonySerializerDataConverter implements PayloadConverter 25 | { 26 | private const INPUT_TYPE = 'symfony.serializer.type'; 27 | 28 | 29 | public function __construct( 30 | private Serializer $serializer, 31 | private PayloadConverter $payloadConverter = new JsonConverter(), 32 | ) { 33 | } 34 | 35 | 36 | public function getEncodingType(): string 37 | { 38 | return EncodingKeys::METADATA_ENCODING_JSON; 39 | } 40 | 41 | public function toPayload($value): Payload 42 | { 43 | $metadata = [ 44 | EncodingKeys::METADATA_ENCODING_KEY => $this->getEncodingType(), 45 | ]; 46 | 47 | $context = [ObjectNormalizer::PRESERVE_EMPTY_OBJECTS => true]; 48 | 49 | if (is_object($value)) { 50 | $metadata[self::INPUT_TYPE] = $value::class; 51 | } 52 | 53 | try { 54 | $data = $this->serializer->serialize($value, 'json', $context); 55 | } catch (Throwable $e) { 56 | throw new DataConverterException($e->getMessage(), $e->getCode(), $e); 57 | } 58 | 59 | $payload = new Payload(); 60 | $payload->setMetadata($metadata); 61 | $payload->setData($data); 62 | 63 | return $payload; 64 | } 65 | 66 | public function fromPayload(Payload $payload, Type $type): mixed 67 | { 68 | if ("null" == $payload->getData() && $type->allowsNull()) { 69 | return null; 70 | } 71 | 72 | /** @var string|null $inputType */ 73 | $inputType = $payload->getMetadata()[self::INPUT_TYPE] ?? null; 74 | 75 | if (!$type->isClass() && $inputType == null) { 76 | return $this->payloadConverter->fromPayload($payload, $type); 77 | } 78 | 79 | try { 80 | return $this->serializer->deserialize($payload->getData(), $inputType ?? $type->getName(), 'json'); 81 | } catch (Throwable $e) { 82 | throw new DataConverterException($e->getMessage(), $e->getCode(), $e); 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/UI/Cli/ClientDebugCommand.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright Copyright (c) 2023, The Vanta 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace Vanta\Integration\Symfony\Temporal\UI\Cli; 13 | 14 | use Symfony\Component\Console\Attribute\AsCommand; 15 | use Symfony\Component\Console\Command\Command; 16 | use Symfony\Component\Console\Helper\TableSeparator; 17 | use Symfony\Component\Console\Input\InputArgument; 18 | use Symfony\Component\Console\Input\InputInterface as Input; 19 | use Symfony\Component\Console\Output\OutputInterface as Output; 20 | use Symfony\Component\Console\Style\SymfonyStyle; 21 | use Temporal\Client\ClientOptions; 22 | 23 | #[AsCommand('debug:temporal:clients', 'List registered clients')] 24 | final class ClientDebugCommand extends Command 25 | { 26 | /** 27 | * @param array $clients 34 | */ 35 | public function __construct(private readonly array $clients) 36 | { 37 | parent::__construct(); 38 | } 39 | 40 | 41 | protected function configure(): void 42 | { 43 | $this->addArgument('clients', mode: InputArgument::IS_ARRAY | InputArgument::OPTIONAL, description: 'Client names', default: []); 44 | } 45 | 46 | protected function execute(Input $input, Output $output): int 47 | { 48 | $foundClients = false; 49 | /** @var list $interestedClients */ 50 | $interestedClients = $input->getArgument('clients'); 51 | $io = new SymfonyStyle($input, $output); 52 | 53 | $io->title('Temporal Clients'); 54 | 55 | foreach ($this->clients as $client) { 56 | $rows = []; 57 | 58 | if ($interestedClients != [] && !in_array($client['name'], $interestedClients)) { 59 | continue; 60 | } 61 | 62 | $foundClients = true; 63 | 64 | $io->title(sprintf('Client: %s', $client['name'])); 65 | 66 | $rows[] = [ 67 | $client['id'], 68 | $client['address'], 69 | $client['dataConverter'], 70 | json_encode($client['options'], JSON_PRETTY_PRINT), 71 | ]; 72 | 73 | $rows[] = new TableSeparator(); 74 | 75 | 76 | /**@phpstan-ignore-next-line **/ 77 | if (!is_array(end($rows))) { 78 | array_pop($rows); 79 | } 80 | 81 | $io->table(['Id', 'Address', 'DataConverterId','Options'], $rows); 82 | } 83 | 84 | 85 | if (!$foundClients) { 86 | $io->note('Not found clients'); 87 | 88 | return self::SUCCESS; 89 | } 90 | 91 | 92 | return self::SUCCESS; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/UI/Cli/ScheduleClientDebugCommand.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright Copyright (c) 2023, The Vanta 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace Vanta\Integration\Symfony\Temporal\UI\Cli; 13 | 14 | use Symfony\Component\Console\Attribute\AsCommand; 15 | use Symfony\Component\Console\Command\Command; 16 | use Symfony\Component\Console\Helper\TableSeparator; 17 | use Symfony\Component\Console\Input\InputArgument; 18 | use Symfony\Component\Console\Input\InputInterface as Input; 19 | use Symfony\Component\Console\Output\OutputInterface as Output; 20 | use Symfony\Component\Console\Style\SymfonyStyle; 21 | use Temporal\Client\ClientOptions; 22 | 23 | #[AsCommand('debug:temporal:schedule-clients', 'List registered schedule clients')] 24 | final class ScheduleClientDebugCommand extends Command 25 | { 26 | /** 27 | * @param array $clients 34 | */ 35 | public function __construct(private readonly array $clients) 36 | { 37 | parent::__construct(); 38 | } 39 | 40 | 41 | protected function configure(): void 42 | { 43 | $this->addArgument('clients', mode: InputArgument::IS_ARRAY | InputArgument::OPTIONAL, description: 'Client names', default: []); 44 | } 45 | 46 | protected function execute(Input $input, Output $output): int 47 | { 48 | $foundClients = false; 49 | /** @var list $interestedClients */ 50 | $interestedClients = $input->getArgument('clients'); 51 | $io = new SymfonyStyle($input, $output); 52 | 53 | $io->title('Temporal Schedule Clients'); 54 | 55 | foreach ($this->clients as $client) { 56 | $rows = []; 57 | 58 | if ($interestedClients != [] && !in_array($client['name'], $interestedClients)) { 59 | continue; 60 | } 61 | 62 | $foundClients = true; 63 | 64 | $io->title(sprintf('Client: %s', $client['name'])); 65 | 66 | $rows[] = [ 67 | $client['id'], 68 | $client['address'], 69 | $client['dataConverter'], 70 | json_encode($client['options'], JSON_PRETTY_PRINT), 71 | ]; 72 | 73 | $rows[] = new TableSeparator(); 74 | 75 | 76 | /**@phpstan-ignore-next-line **/ 77 | if (!is_array(end($rows))) { 78 | array_pop($rows); 79 | } 80 | 81 | $io->table(['Id', 'Address', 'DataConverterId','Options'], $rows); 82 | } 83 | 84 | 85 | if (!$foundClients) { 86 | $io->note('Not found clients'); 87 | 88 | return self::SUCCESS; 89 | } 90 | 91 | 92 | return self::SUCCESS; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/DependencyInjection/Compiler/ScheduleClientCompilerPass.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright Copyright (c) 2023, The Vanta 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace Vanta\Integration\Symfony\Temporal\DependencyInjection\Compiler; 13 | 14 | use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface as CompilerPass; 15 | use Symfony\Component\DependencyInjection\ContainerBuilder; 16 | use Symfony\Component\DependencyInjection\Reference; 17 | use Temporal\Client\ClientOptions; 18 | use Temporal\Client\ScheduleClient as GrpcScheduleClient; 19 | use Temporal\Client\ScheduleClientInterface as ScheduleClient; 20 | use Vanta\Integration\Symfony\Temporal\DependencyInjection\Configuration; 21 | 22 | use function Vanta\Integration\Symfony\Temporal\DependencyInjection\definition; 23 | use function Vanta\Integration\Symfony\Temporal\DependencyInjection\grpcClient; 24 | 25 | use Vanta\Integration\Symfony\Temporal\UI\Cli\ScheduleClientDebugCommand; 26 | 27 | /** 28 | * @phpstan-import-type RawConfiguration from Configuration 29 | */ 30 | final class ScheduleClientCompilerPass implements CompilerPass 31 | { 32 | public function process(ContainerBuilder $container): void 33 | { 34 | /** @var RawConfiguration $config */ 35 | $config = $container->getParameter('temporal.config'); 36 | $clients = []; 37 | 38 | foreach ($config['scheduleClients'] as $name => $client) { 39 | $options = definition(ClientOptions::class) 40 | ->addMethodCall('withNamespace', [$client['namespace']], true); 41 | 42 | if ($client['identity'] ?? false) { 43 | $options->addMethodCall('withIdentity', [$client['identity']], true); 44 | } 45 | 46 | if (array_key_exists('queryRejectionCondition', $client)) { 47 | $options->addMethodCall('withQueryRejectionCondition', [$client['queryRejectionCondition']], true); 48 | } 49 | 50 | $id = sprintf('temporal.%s.schedule_client', $name); 51 | 52 | $container->register($id, ScheduleClient::class) 53 | ->setFactory([GrpcScheduleClient::class, 'create']) 54 | ->setArguments([ 55 | '$serviceClient' => grpcClient($client), 56 | '$options' => $options, 57 | '$converter' => new Reference($client['dataConverter']), 58 | ]); 59 | 60 | if ($name == $config['defaultScheduleClient']) { 61 | $container->setAlias(ScheduleClient::class, $id); 62 | } 63 | 64 | $container->registerAliasForArgument($id, ScheduleClient::class, sprintf('%sScheduleClient', $name)); 65 | 66 | 67 | $clients[] = [ 68 | 'id' => $id, 69 | 'name' => $name, 70 | 'options' => $options, 71 | 'dataConverter' => $client['dataConverter'], 72 | 'address' => $client['address'], 73 | ]; 74 | } 75 | 76 | $container->register('temporal.schedule_client_debug.command', ScheduleClientDebugCommand::class) 77 | ->setArguments([ 78 | '$clients' => $clients, 79 | ]) 80 | ->addTag('console.command') 81 | ; 82 | 83 | 84 | $container->getDefinition('temporal.collector') 85 | ->setArgument('$scheduleClients', $clients) 86 | ; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/DependencyInjection/Compiler/ClientCompilerPass.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright Copyright (c) 2023, The Vanta 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace Vanta\Integration\Symfony\Temporal\DependencyInjection\Compiler; 13 | 14 | use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface as CompilerPass; 15 | use Symfony\Component\DependencyInjection\ContainerBuilder; 16 | use Symfony\Component\DependencyInjection\Reference; 17 | use Temporal\Client\ClientOptions; 18 | use Temporal\Client\WorkflowClient as GrpcWorkflowClient; 19 | use Temporal\Client\WorkflowClientInterface as WorkflowClient; 20 | use Temporal\Interceptor\SimplePipelineProvider; 21 | use Vanta\Integration\Symfony\Temporal\DependencyInjection\Configuration; 22 | 23 | use function Vanta\Integration\Symfony\Temporal\DependencyInjection\definition; 24 | use function Vanta\Integration\Symfony\Temporal\DependencyInjection\grpcClient; 25 | use function Vanta\Integration\Symfony\Temporal\DependencyInjection\reference; 26 | 27 | use Vanta\Integration\Symfony\Temporal\UI\Cli\ClientDebugCommand; 28 | 29 | /** 30 | * @phpstan-import-type RawConfiguration from Configuration 31 | */ 32 | final class ClientCompilerPass implements CompilerPass 33 | { 34 | public function process(ContainerBuilder $container): void 35 | { 36 | /** @var RawConfiguration $config */ 37 | $config = $container->getParameter('temporal.config'); 38 | $clients = []; 39 | 40 | foreach ($config['clients'] as $name => $client) { 41 | $options = definition(ClientOptions::class) 42 | ->addMethodCall('withNamespace', [$client['namespace']], true); 43 | 44 | if ($client['identity'] ?? false) { 45 | $options->addMethodCall('withIdentity', [$client['identity']], true); 46 | } 47 | 48 | if (array_key_exists('queryRejectionCondition', $client)) { 49 | $options->addMethodCall('withQueryRejectionCondition', [$client['queryRejectionCondition']], true); 50 | } 51 | 52 | 53 | $id = sprintf('temporal.%s.client', $name); 54 | 55 | $container->register($id, WorkflowClient::class) 56 | ->setFactory([GrpcWorkflowClient::class, 'create']) 57 | ->setArguments([ 58 | '$serviceClient' => grpcClient($client), 59 | '$options' => $options, 60 | '$converter' => new Reference($client['dataConverter']), 61 | '$interceptorProvider' => definition(SimplePipelineProvider::class) 62 | ->setArguments([ 63 | array_map(reference(...), $client['interceptors']), 64 | ]), 65 | ]); 66 | 67 | if ($name == $config['defaultClient']) { 68 | $container->setAlias(WorkflowClient::class, $id); 69 | } 70 | 71 | $container->registerAliasForArgument($id, WorkflowClient::class, sprintf('%sWorkflowClient', $name)); 72 | 73 | 74 | $clients[] = [ 75 | 'id' => $id, 76 | 'name' => $name, 77 | 'options' => $options, 78 | 'dataConverter' => $client['dataConverter'], 79 | 'address' => $client['address'], 80 | ]; 81 | } 82 | 83 | $container->register('temporal.client_debug.command', ClientDebugCommand::class) 84 | ->setArguments([ 85 | '$clients' => $clients, 86 | ]) 87 | ->addTag('console.command') 88 | ; 89 | 90 | 91 | $container->getDefinition('temporal.collector') 92 | ->setArgument('$clients', $clients) 93 | ; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /templates/data_collector/assets/temporal_with_text.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/UI/Cli/ActivityDebugCommand.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright Copyright (c) 2023, The Vanta 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace Vanta\Integration\Symfony\Temporal\UI\Cli; 13 | 14 | use Symfony\Component\Console\Attribute\AsCommand; 15 | use Symfony\Component\Console\Command\Command; 16 | use Symfony\Component\Console\Helper\TableSeparator; 17 | use Symfony\Component\Console\Input\InputArgument; 18 | use Symfony\Component\Console\Input\InputInterface as Input; 19 | use Symfony\Component\Console\Output\OutputInterface as Output; 20 | use Symfony\Component\Console\Style\SymfonyStyle; 21 | use Temporal\Worker\WorkerInterface as Worker; 22 | 23 | #[AsCommand('debug:temporal:activities', 'List registered activities')] 24 | final class ActivityDebugCommand extends Command 25 | { 26 | /** 27 | * @param array $workers 28 | * @param list $activitiesWithoutWorkers 29 | */ 30 | public function __construct( 31 | private readonly array $workers, 32 | private readonly array $activitiesWithoutWorkers 33 | ) { 34 | parent::__construct(); 35 | } 36 | 37 | 38 | protected function configure(): void 39 | { 40 | $this->addArgument('workers', mode: InputArgument::IS_ARRAY | InputArgument::OPTIONAL, description: 'Worker names', default: []); 41 | } 42 | 43 | 44 | protected function execute(Input $input, Output $output): int 45 | { 46 | /** @var list $workers */ 47 | $workers = $input->getArgument('workers'); 48 | $io = new SymfonyStyle($input, $output); 49 | 50 | $io->title('Temporal Activities'); 51 | 52 | foreach ($this->workers as $name => $worker) { 53 | if ($workers != [] && !in_array($name, $workers)) { 54 | continue; 55 | } 56 | 57 | $rows = []; 58 | 59 | $io->title(sprintf('Worker: %s', $name)); 60 | 61 | foreach ($worker->getActivities() as $activity) { 62 | if (in_array($activity->getClass()->name, $this->activitiesWithoutWorkers)) { 63 | continue; 64 | } 65 | 66 | $rows[] = [ 67 | $activity->getID(), 68 | $activity->getClass()->name, 69 | $activity->isLocalActivity() ? 'Yes' : 'No', 70 | $activity->getMethodRetry() ? json_encode($activity->getMethodRetry(), JSON_PRETTY_PRINT) : 'None', 71 | ]; 72 | 73 | $rows[] = new TableSeparator(); 74 | } 75 | 76 | if ($rows == []) { 77 | $io->note('Not found activities'); 78 | 79 | continue; 80 | } 81 | 82 | if (!is_array(end($rows))) { 83 | array_pop($rows); 84 | } 85 | 86 | $io->table(['Id', 'Class','IsLocalActivity', 'Retry Policy'], $rows); 87 | } 88 | 89 | 90 | 91 | if ($this->activitiesWithoutWorkers == [] || $workers != []) { 92 | return self::SUCCESS; 93 | } 94 | 95 | 96 | $io->title('Registered activity at all workers'); 97 | 98 | $printedActivities = []; 99 | 100 | foreach ($this->workers as $worker) { 101 | $rows = []; 102 | 103 | foreach ($worker->getActivities() as $activity) { 104 | if (!in_array($activity->getClass()->name, $this->activitiesWithoutWorkers)) { 105 | continue; 106 | } 107 | 108 | if (in_array($activity->getClass()->name, $printedActivities)) { 109 | continue; 110 | } 111 | 112 | 113 | $rows[] = [ 114 | $activity->getID(), 115 | $activity->getClass()->name, 116 | $activity->isLocalActivity() ? 'Yes' : 'No', 117 | $activity->getMethodRetry() ? json_encode($activity->getMethodRetry(), JSON_PRETTY_PRINT) : 'None', 118 | ]; 119 | 120 | $rows[] = new TableSeparator(); 121 | $printedActivities[] = $activity->getClass()->name; 122 | } 123 | 124 | if ($rows == []) { 125 | continue; 126 | } 127 | 128 | if (!is_array(end($rows))) { 129 | array_pop($rows); 130 | } 131 | 132 | 133 | $io->table(['Id', 'Class','IsLocalActivity', 'Retry Policy'], $rows); 134 | } 135 | 136 | 137 | return self::SUCCESS; 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/UI/Cli/WorkflowDebugCommand.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright Copyright (c) 2023, The Vanta 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace Vanta\Integration\Symfony\Temporal\UI\Cli; 13 | 14 | use Symfony\Component\Console\Attribute\AsCommand; 15 | use Symfony\Component\Console\Command\Command; 16 | use Symfony\Component\Console\Helper\TableSeparator; 17 | use Symfony\Component\Console\Input\InputArgument; 18 | use Symfony\Component\Console\Input\InputInterface as Input; 19 | use Symfony\Component\Console\Output\OutputInterface as Output; 20 | use Symfony\Component\Console\Style\SymfonyStyle; 21 | use Temporal\Worker\WorkerInterface as Worker; 22 | 23 | #[AsCommand('debug:temporal:workflows', 'List registered workflows')] 24 | final class WorkflowDebugCommand extends Command 25 | { 26 | /** 27 | * @param array $workers 28 | * @param list $workflowsWithoutWorkers 29 | */ 30 | public function __construct( 31 | private readonly array $workers, 32 | private readonly array $workflowsWithoutWorkers 33 | ) { 34 | parent::__construct(); 35 | } 36 | 37 | 38 | protected function configure(): void 39 | { 40 | $this->addArgument('workers', mode: InputArgument::IS_ARRAY | InputArgument::OPTIONAL, description: 'Worker names', default: []); 41 | } 42 | 43 | 44 | protected function execute(Input $input, Output $output): int 45 | { 46 | /** @var list $workers */ 47 | $workers = $input->getArgument('workers'); 48 | $io = new SymfonyStyle($input, $output); 49 | 50 | $io->title('Temporal Workflows'); 51 | 52 | foreach ($this->workers as $name => $worker) { 53 | if ($workers != [] && !in_array($name, $workers)) { 54 | continue; 55 | } 56 | 57 | 58 | $io->title(sprintf('Worker: %s', $name)); 59 | 60 | $rows = []; 61 | 62 | foreach ($worker->getWorkflows() as $workflow) { 63 | if (in_array($workflow->getClass()->getName(), $this->workflowsWithoutWorkers)) { 64 | continue; 65 | } 66 | 67 | $rows[] = [ 68 | $workflow->getID(), 69 | $workflow->getClass()->getName(), 70 | $workflow->getCronSchedule()->interval ?? 'None', 71 | $workflow->getMethodRetry() ? json_encode($workflow->getMethodRetry(), JSON_PRETTY_PRINT) : 'None', 72 | ]; 73 | 74 | $rows[] = new TableSeparator(); 75 | } 76 | 77 | if ($rows == []) { 78 | $io->note('Not found workflows'); 79 | 80 | continue; 81 | } 82 | 83 | if (!is_array(end($rows))) { 84 | array_pop($rows); 85 | } 86 | 87 | $io->table(['Id', 'Class', 'Schedule Plan', 'Retry Policy'], $rows); 88 | } 89 | 90 | 91 | if ($this->workflowsWithoutWorkers == [] || $workers != []) { 92 | return self::SUCCESS; 93 | } 94 | 95 | 96 | $io->title('Registered workflow at all workers'); 97 | 98 | $printedWorkflows = []; 99 | 100 | foreach ($this->workers as $worker) { 101 | $rows = []; 102 | 103 | foreach ($worker->getWorkflows() as $workflow) { 104 | if (!in_array($workflow->getClass()->getName(), $this->workflowsWithoutWorkers)) { 105 | continue; 106 | } 107 | 108 | if (in_array($workflow->getClass()->getName(), $printedWorkflows)) { 109 | continue; 110 | } 111 | 112 | $rows[] = [ 113 | $workflow->getID(), 114 | $workflow->getClass()->getName(), 115 | $workflow->getCronSchedule()->interval ?? 'None', 116 | $workflow->getMethodRetry() ? json_encode($workflow->getMethodRetry(), JSON_PRETTY_PRINT) : 'None', 117 | ]; 118 | 119 | 120 | $rows[] = new TableSeparator(); 121 | $printedWorkflows[] = $workflow->getClass()->getName(); 122 | } 123 | 124 | if ($rows == []) { 125 | continue; 126 | } 127 | 128 | if (!is_array(end($rows))) { 129 | array_pop($rows); 130 | } 131 | 132 | $io->table(['Id', 'Class', 'Schedule Plan', 'Retry Policy'], $rows); 133 | } 134 | 135 | 136 | return self::SUCCESS; 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/DependencyInjection/function.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright Copyright (c) 2023, The Vanta 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace Vanta\Integration\Symfony\Temporal\DependencyInjection; 13 | 14 | use DateInterval; 15 | use ReflectionAttribute; 16 | use ReflectionClass; 17 | use Symfony\Component\DependencyInjection\ContainerInterface as Container; 18 | use Symfony\Component\DependencyInjection\Definition; 19 | use Symfony\Component\DependencyInjection\Reference; 20 | use Temporal\Client\Common\RpcRetryOptions; 21 | use Temporal\Client\GRPC\Context; 22 | use Temporal\Client\GRPC\ServiceClient as GrpcServiceClient; 23 | use Temporal\Client\GRPC\ServiceClientInterface as ServiceClient; 24 | use Vanta\Integration\Symfony\Temporal\Attribute\AssignWorker; 25 | 26 | /** 27 | * @internal 28 | * 29 | * @param class-string|null $class 30 | * @param array $arguments 31 | */ 32 | function definition(?string $class = null, array $arguments = []): Definition 33 | { 34 | return new Definition($class, $arguments); 35 | } 36 | 37 | /** 38 | * @internal 39 | */ 40 | function dateIntervalDefinition(string $interval): Definition 41 | { 42 | return definition(DateInterval::class) 43 | ->setFactory([DateInterval::class, 'createFromDateString']) 44 | ->setArguments([$interval]) 45 | ; 46 | } 47 | 48 | 49 | /** 50 | * @internal 51 | * 52 | * @param non-empty-string $id 53 | */ 54 | function reference(string $id, int $invalidBehavior = Container::EXCEPTION_ON_INVALID_REFERENCE): Reference 55 | { 56 | return new Reference($id, $invalidBehavior); 57 | } 58 | 59 | /** 60 | * @internal 61 | */ 62 | function referenceLogger(): Reference 63 | { 64 | return reference('monolog.logger.temporal', Container::IGNORE_ON_INVALID_REFERENCE); 65 | } 66 | 67 | 68 | /** 69 | * @internal 70 | * 71 | * @param array{ 72 | * address: non-empty-string, 73 | * clientKey: ?non-empty-string, 74 | * clientPem: ?non-empty-string, 75 | * grpcContext: array 76 | * } $client 77 | */ 78 | function grpcClient(array $client): Definition 79 | { 80 | $serviceClient = definition(ServiceClient::class, [$client['address']]) 81 | ->setFactory([GrpcServiceClient::class, 'create']) 82 | ; 83 | 84 | if (($client['clientKey'] ?? false) && ($client['clientPem'] ?? false)) { 85 | $serviceClient = definition(ServiceClient::class, [ 86 | $client['address'], 87 | null, // root CA - Not required for Temporal Cloud 88 | $client['clientKey'], 89 | $client['clientPem'], 90 | null, // Overwrite server name 91 | ])->setFactory([GrpcServiceClient::class, 'createSSL']); 92 | } 93 | 94 | return $serviceClient->addMethodCall('withContext', [ 95 | grpcContext($client['grpcContext']), 96 | ], true); 97 | } 98 | 99 | 100 | /** 101 | * @internal 102 | * 103 | * @param array{} $rawContext 104 | */ 105 | function grpcContext(array $rawContext): Definition 106 | { 107 | $context = definition(Context::class) 108 | ->setFactory([Context::class, 'default']) 109 | ; 110 | 111 | /** @phpstan-ignore-next-line **/ 112 | foreach ($rawContext as $name => $value) { 113 | $method = sprintf('with%s', ucfirst($name)); 114 | 115 | if (!method_exists(Context::class, $method)) { 116 | continue; 117 | } 118 | 119 | 120 | /** @phpstan-ignore-next-line **/ 121 | if (array_key_exists('value', $value) && array_key_exists('format', $value)) { 122 | $context->addMethodCall($method, [$value['value'], $value['format']], true); 123 | 124 | continue; 125 | } 126 | 127 | /** @phpstan-ignore-next-line **/ 128 | if ($name == 'retryOptions') { 129 | $rawRetryOptions = $value; 130 | $value = definition(RpcRetryOptions::class) 131 | ->setFactory([RpcRetryOptions::class, 'new']) 132 | ; 133 | 134 | foreach ($rawRetryOptions as $retryOptionName => $retryOptionValue) { 135 | $retryMethod = sprintf('with%s', ucfirst($retryOptionName)); 136 | 137 | if (!method_exists(RpcRetryOptions::class, $retryMethod)) { 138 | continue; 139 | } 140 | 141 | if (str_ends_with($retryOptionName, 'Timeout') || str_ends_with($retryOptionName, 'Interval')) { 142 | if (!is_string($retryOptionValue)) { 143 | continue; 144 | } 145 | 146 | $retryOptionValue = dateIntervalDefinition($retryOptionValue); 147 | } 148 | 149 | $value->addMethodCall($retryMethod, [$retryOptionValue], true); 150 | } 151 | 152 | 153 | $context->addMethodCall($method, [$value], true); 154 | 155 | continue; 156 | } 157 | 158 | 159 | $context->addMethodCall($method, [$value], true); 160 | } 161 | 162 | return $context; 163 | } 164 | 165 | 166 | /** 167 | * @internal 168 | * 169 | * @param ReflectionClass $reflectionClass 170 | * 171 | * @return array 172 | */ 173 | function getWorkers(ReflectionClass $reflectionClass): array 174 | { 175 | $workers = array_map(static function (ReflectionAttribute $reflectionAttribute): string { 176 | return $reflectionAttribute->newInstance()->name; 177 | }, $reflectionClass->getAttributes(AssignWorker::class)); 178 | 179 | return array_unique($workers); 180 | } 181 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Temporal Bundle 2 | 3 | [Temporal](https://temporal.io/) is the simple, scalable open source way to write and run reliable cloud applications. 4 | 5 | ## Features 6 | 7 | - **Sentry**: Send throwable events (if the [`SentryBundle`](https://github.com/getsentry/sentry-symfony) use) 8 | - **Doctrine**: clear opened managers and check connection is still usable after each request ( 9 | if [`DoctrineBundle`](https://github.com/doctrine/DoctrineBundle) is use) 10 | - **Serializer**: Deserialize and serialize messages (if [`Symfony/Serializer`](https://github.com/symfony/serializer) 11 | is use, **Recommend use**) 12 | 13 | ## Requirements: 14 | 15 | - php >= 8.2 16 | - symfony >= 6.0 17 | 18 | ## Installation: 19 | 20 | 1. Connect recipes 21 | 22 | ```bash 23 | composer config --json extra.symfony.endpoint '["https://raw.githubusercontent.com/VantaFinance/temporal-bundle/main/.recipie/index.json", "flex://defaults"]' 24 | ``` 25 | 26 | 2. Install package 27 | 28 | ```bash 29 | composer req temporal serializer 30 | ``` 31 | 32 | 3. Configure docker-compose-temporal.yml/Dockerfile 33 | 34 | 4. Added Workflow/Activity. See [examples](https://github.com/temporalio/samples-php) to get started. 35 | 36 | ## Doctrine integrations 37 | 38 | If [`DoctrineBundle`](https://github.com/doctrine/DoctrineBundle) is use, the following finalizer is available to you: 39 | 40 | - `temporal.doctrine_ping_connection_.finalizer` 41 | - `temporal.doctrine_clear_entity_manager.finalizer` 42 | 43 | 44 | And interceptors: 45 | - `temporal.doctrine_ping_connection__activity_inbound.interceptor` 46 | 47 | 48 | Example config: 49 | 50 | ```yaml 51 | temporal: 52 | defaultClient: default 53 | pool: 54 | dataConverter: temporal.data_converter 55 | roadrunnerRPC: '%env(RR_RPC)%' 56 | 57 | workers: 58 | default: 59 | taskQueue: default 60 | exceptionInterceptor: temporal.exception_interceptor 61 | finalizers: 62 | - temporal.doctrine_ping_connection_default.finalizer 63 | - temporal.doctrine_clear_entity_manager.finalizer 64 | interceptors: 65 | - temporal.doctrine_ping_connection_default_activity_inbound.interceptor 66 | 67 | clients: 68 | default: 69 | namespace: default 70 | address: '%env(TEMPORAL_ADDRESS)%' 71 | dataConverter: temporal.data_converter 72 | cloud: 73 | namespace: default 74 | address: '%env(TEMPORAL_ADDRESS)%' 75 | dataConverter: temporal.data_converter 76 | clientKey: '%env(TEMPORAL_CLIENT_KEY_PATH)%' 77 | clientPem: '%env(TEMPORAL_CLIENT_CERT_PATH)%' 78 | ``` 79 | 80 | 81 | 82 | 83 | ## Sentry integrations 84 | 85 | Install packages: 86 | 87 | ```bash 88 | composer require sentry temporal-sentry 89 | ``` 90 | 91 | If [`SentryBundle`](https://github.com/getsentry/sentry-symfony) is use, the following interceptors is available to you: 92 | 93 | - `temporal.sentry_workflow_outbound_calls.interceptor` 94 | - `temporal.sentry_activity_inbound.interceptor` 95 | 96 | 97 | 98 | 99 | Example config: 100 | 101 | ```yaml 102 | temporal: 103 | defaultClient: default 104 | pool: 105 | dataConverter: temporal.data_converter 106 | roadrunnerRPC: '%env(RR_RPC)%' 107 | 108 | workers: 109 | default: 110 | taskQueue: default 111 | exceptionInterceptor: temporal.exception_interceptor 112 | interceptors: 113 | - temporal.sentry_workflow_outbound_calls.intercepto 114 | - temporal.sentry_activity_inbound.interceptor 115 | 116 | clients: 117 | default: 118 | namespace: default 119 | address: '%env(TEMPORAL_ADDRESS)%' 120 | dataConverter: temporal.data_converter 121 | ``` 122 | 123 | 124 | 125 | ## Worker Factory 126 | 127 | By default the `Temporal\WorkerFactory` is used to instantiate the workers. However when you are unit-testing you 128 | may wish to override the default factory with the one provided by the ['Testing framework'](https://github.com/temporalio/sdk-php/tree/master/testing) 129 | 130 | Example Config: 131 | 132 | ```yaml 133 | temporal: 134 | defaultClient: default 135 | pool: 136 | dataConverter: temporal.data_converter 137 | roadrunnerRPC: '%env(RR_RPC)%' 138 | 139 | workers: 140 | default: 141 | taskQueue: default 142 | exceptionInterceptor: temporal.exception_interceptor 143 | interceptors: 144 | - temporal.sentry_workflow_outbound_calls.intercepto 145 | - temporal.sentry_activity_inbound.interceptor 146 | 147 | clients: 148 | default: 149 | namespace: default 150 | address: '%env(TEMPORAL_ADDRESS)%' 151 | dataConverter: temporal.data_converter 152 | 153 | when@test: 154 | temporal: 155 | workerFactory: Temporal\Testing\WorkerFactory 156 | ``` 157 | 158 | 159 | 160 | ## Assign worker 161 | 162 | Running workflows and activities with different task queue 163 | Add a [`AssignWorker`](src/Attribute/AssignWorker.php) attribute to your Workflow or Activity with the name of the 164 | worker. This Workflow or Activity will be processed by the specified worker. 165 | 166 | **Workflow example:** 167 | 168 | ```php 169 | 7 | * @copyright Copyright (c) 2023, The Vanta 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace Vanta\Integration\Symfony\Temporal\DependencyInjection\Compiler; 13 | 14 | use Closure; 15 | use Spiral\RoadRunner\Environment as RoadRunnerEnvironment; 16 | use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument; 17 | use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface as CompilerPass; 18 | use Symfony\Component\DependencyInjection\ContainerBuilder; 19 | use Symfony\Component\DependencyInjection\Definition; 20 | use Symfony\Component\DependencyInjection\Reference; 21 | use Temporal\Interceptor\SimplePipelineProvider; 22 | use Temporal\Worker\Transport\Goridge; 23 | use Temporal\Worker\WorkerFactoryInterface; 24 | use Temporal\Worker\WorkerInterface; 25 | use Temporal\Worker\WorkerOptions; 26 | use Vanta\Integration\Symfony\Temporal\DependencyInjection\Configuration; 27 | 28 | use function Vanta\Integration\Symfony\Temporal\DependencyInjection\dateIntervalDefinition; 29 | use function Vanta\Integration\Symfony\Temporal\DependencyInjection\definition; 30 | use function Vanta\Integration\Symfony\Temporal\DependencyInjection\reference; 31 | use function Vanta\Integration\Symfony\Temporal\DependencyInjection\referenceLogger; 32 | 33 | use Vanta\Integration\Symfony\Temporal\Environment; 34 | use Vanta\Integration\Symfony\Temporal\Finalizer\ChainFinalizer; 35 | use Vanta\Integration\Symfony\Temporal\Runtime\Runtime; 36 | use Vanta\Integration\Symfony\Temporal\UI\Cli\ActivityDebugCommand; 37 | use Vanta\Integration\Symfony\Temporal\UI\Cli\WorkerDebugCommand; 38 | use Vanta\Integration\Symfony\Temporal\UI\Cli\WorkflowDebugCommand; 39 | 40 | /** 41 | * @phpstan-import-type RawConfiguration from Configuration 42 | */ 43 | final class WorkflowCompilerPass implements CompilerPass 44 | { 45 | public function process(ContainerBuilder $container): void 46 | { 47 | /** @var RawConfiguration $config */ 48 | $config = $container->getParameter('temporal.config'); 49 | 50 | $factory = $container->register('temporal.worker_factory', WorkerFactoryInterface::class) 51 | ->setFactory([$config["workerFactory"], 'create']) 52 | ->setArguments([ 53 | new Reference($config['pool']['dataConverter']), 54 | definition(Goridge::class) 55 | ->setFactory([Goridge::class, 'create']) 56 | ->setArguments([ 57 | definition(RoadRunnerEnvironment::class) 58 | ->setFactory([Environment::class, 'create']) 59 | ->setArguments([ 60 | ['RR_RPC' => $config['pool']['roadrunnerRPC']], 61 | ]), 62 | ]), 63 | ]) 64 | ->setPublic(true) 65 | ; 66 | 67 | $configuredWorkers = []; 68 | $activitiesWithoutWorkers = []; 69 | $workflowsWithoutWorkers = []; 70 | 71 | foreach ($config['workers'] as $workerName => $worker) { 72 | $options = definition(WorkerOptions::class) 73 | ->setFactory([WorkerOptions::class, 'new']) 74 | ; 75 | 76 | foreach ($worker as $option => $value) { 77 | $method = sprintf('with%s', ucfirst($option)); 78 | 79 | if (!method_exists(WorkerOptions::class, $method)) { 80 | continue; 81 | } 82 | 83 | if (str_ends_with($option, 'Timeout') || str_ends_with($option, 'Interval')) { 84 | if (!is_string($value)) { 85 | continue; 86 | } 87 | 88 | $value = dateIntervalDefinition($value); 89 | } 90 | 91 | $options->addMethodCall($method, [$value], true); 92 | } 93 | 94 | $newWorker = $container->register(sprintf('temporal.%s.worker', $workerName), WorkerInterface::class) 95 | ->setFactory([$factory, 'newWorker']) 96 | ->setArguments([ 97 | $worker['taskQueue'], 98 | $options, 99 | new Reference($worker['exceptionInterceptor']), 100 | definition(SimplePipelineProvider::class) 101 | ->setArguments([ 102 | array_map(reference(...), $worker['interceptors']), 103 | ]), 104 | ]) 105 | ->setPublic(true) 106 | ; 107 | 108 | foreach ($container->findTaggedServiceIds('temporal.workflow') as $id => $attributes) { 109 | $class = $container->getDefinition($id)->getClass(); 110 | 111 | if ($class == null) { 112 | continue; 113 | } 114 | 115 | $workerNames = $attributes[0]['workers'] ?? null; 116 | 117 | if ($workerNames == null) { 118 | $workflowsWithoutWorkers[] = $class; 119 | } 120 | 121 | if ($workerNames != null && !in_array($workerName, $workerNames)) { 122 | continue; 123 | } 124 | 125 | $newWorker->addMethodCall('registerWorkflowTypes', [$class]); 126 | } 127 | 128 | foreach ($container->findTaggedServiceIds('temporal.activity') as $id => $attributes) { 129 | $class = $container->getDefinition($id)->getClass(); 130 | 131 | if ($class == null) { 132 | continue; 133 | } 134 | 135 | $workerNames = $attributes[0]['workers'] ?? null; 136 | 137 | if ($workerNames == null) { 138 | $activitiesWithoutWorkers[] = $class; 139 | } 140 | 141 | 142 | if ($workerNames != null && !in_array($workerName, $workerNames)) { 143 | continue; 144 | } 145 | 146 | $newWorker->addMethodCall('registerActivity', [ 147 | $class, 148 | new ServiceClosureArgument(new Reference($id)), 149 | ]); 150 | } 151 | 152 | $this->registerFinalizers($worker['finalizers'], $workerName, $container); 153 | 154 | $configuredWorkers[$workerName] = $newWorker; 155 | } 156 | 157 | 158 | $container->register('temporal.runtime', Runtime::class) 159 | ->setArguments([ 160 | $factory, 161 | $configuredWorkers, 162 | ]) 163 | ->setPublic(true) 164 | ; 165 | 166 | 167 | $container->register('temporal.worker_debug.command', WorkerDebugCommand::class) 168 | ->setArguments([ 169 | '$workers' => $configuredWorkers, 170 | ]) 171 | ->addTag('console.command') 172 | ; 173 | 174 | $container->register('temporal.workflow_debug.command', WorkflowDebugCommand::class) 175 | ->setArguments([ 176 | '$workers' => $configuredWorkers, 177 | '$workflowsWithoutWorkers' => $workflowsWithoutWorkers, 178 | ]) 179 | ->addTag('console.command') 180 | ; 181 | 182 | 183 | $container->register('temporal.activity_debug.command', ActivityDebugCommand::class) 184 | ->setArguments([ 185 | '$workers' => $configuredWorkers, 186 | '$activitiesWithoutWorkers' => $activitiesWithoutWorkers, 187 | ]) 188 | ->addTag('console.command') 189 | ; 190 | 191 | 192 | $container->getDefinition('temporal.collector') 193 | ->setArgument('$workers', array_map(static function (Definition $worker): Definition { 194 | $worker = clone $worker; 195 | 196 | return $worker->addMethodCall('getOptions', returnsClone: true); 197 | }, $configuredWorkers)) 198 | ->setArgument('$workflows', $container->findTaggedServiceIds('temporal.workflow')) 199 | ->setArgument('$activities', $container->findTaggedServiceIds('temporal.activity')) 200 | ; 201 | 202 | 203 | foreach ($container->findTaggedServiceIds('temporal.workflow') as $id => $attributes) { 204 | $container->removeDefinition($id); 205 | } 206 | } 207 | 208 | /** 209 | * @param array $finalizers 210 | * @param non-empty-string $workerName 211 | */ 212 | private function registerFinalizers(array $finalizers, string $workerName, ContainerBuilder $container): void 213 | { 214 | if ($finalizers == []) { 215 | return; 216 | } 217 | 218 | $chain = $container->register(sprintf('temporal.%s.worker.finalizer', $workerName), ChainFinalizer::class) 219 | ->setArguments([ 220 | array_map(reference(...), $finalizers), 221 | referenceLogger(), 222 | ]) 223 | ; 224 | 225 | $container->getDefinition(sprintf('temporal.%s.worker', $workerName)) 226 | ->addMethodCall('registerActivityFinalizer', [ 227 | definition(Closure::class, [[$chain, 'finalize']]) 228 | ->setFactory([Closure::class, 'fromCallable']), 229 | ]) 230 | ; 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /templates/data_collector/layout.html.twig: -------------------------------------------------------------------------------- 1 | {% extends '@WebProfiler/Profiler/layout.html.twig' %} 2 | 3 | 4 | {% block toolbar %} 5 | {% set icon %} 6 | 7 | {{ include('@Temporal/data_collector/assets/temporal_with_text.svg') }} 8 | 9 | {% endset %} 10 | 11 | 12 | {{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { 'link': true }) }} 13 | {% endblock %} 14 | 15 | 16 | 17 | {% block menu %} 18 | 19 | 20 | {{ include('@Temporal/data_collector/assets/temporal.svg') }} 21 | 22 | Temporal 23 | 24 | {% endblock %} 25 | 26 | 27 | 28 | {% block panel %} 29 | 46 | 47 | 48 |

Temporal Metrics

49 |
50 |
51 |
52 | {{ collector.workers | length }} 53 | Count Workers 54 |
55 | 56 |
57 | {{ collector.clients | length }} 58 | Count Client 59 |
60 | 61 |
62 | {{ collector.scheduleClients | length }} 63 | Count Schedule Client 64 |
65 | 66 | 67 |
68 | {{ collector.workflows | length }} 69 | Count Workflows 70 |
71 | 72 |
73 | {{ collector.activities | length }} 74 | Count Activites 75 |
76 |
77 |
78 | 79 | 80 |
81 |
82 |

Clients

83 |
84 | {% if not collector.clients %} 85 |
86 |

There are no configured temporal clients.

87 |
88 | {% else %} 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | {% for value in collector.clients %} 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | {% endfor %} 109 | 110 |
IdNameDataConverterAddressOptions
{{ value.id }}{{ value.name }}{{ value.dataConverter }}{{ value.address }}
{{ value.options | json_encode(constant('JSON_PRETTY_PRINT')) }}
111 | {% endif %} 112 |
113 |
114 | 115 |
116 |

Schedule Clients

117 |
118 | {% if not collector.scheduleClients %} 119 |
120 |

There are no configured temporal schedule clients.

121 |
122 | {% else %} 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | {% for value in collector.scheduleClients %} 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | {% endfor %} 143 | 144 |
IdNameDataConverterAddressOptions
{{ value.id }}{{ value.name }}{{ value.dataConverter }}{{ value.address }}
{{ value.options | json_encode(constant('JSON_PRETTY_PRINT')) }}
145 | {% endif %} 146 |
147 |
148 | 149 | 150 |
151 |

Workers

152 |
153 | {% if not collector.workers %} 154 |
155 |

There are no configured temporal workers.

156 |
157 | {% else %} 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | {% for key, value in collector.workers %} 167 | 168 | 169 | 170 | 171 | {% endfor %} 172 | 173 |
NameOptions
{{ key }}
{{ value | json_encode(constant('JSON_PRETTY_PRINT')) }}
174 | {% endif %} 175 |
176 |
177 | 178 | 179 |
180 |

Workflows

181 |
182 | {% if not collector.workflows %} 183 |
184 |

There are no configured temporal workflows.

185 |
186 | {% else %} 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | {% for key, value in collector.workflows %} 196 | 197 | 198 | {% set attributes = value | first %} 199 | 200 | 201 | 202 | {% endfor %} 203 | 204 |
IdWorkers
{{ key }}
{{ (attributes.workers  is not defined) ? 'Registered in all workers' : attributes.workers | json_encode(constant('JSON_PRETTY_PRINT')) }}
205 | {% endif %} 206 |
207 |
208 | 209 |
210 |

Activities

211 |
212 | {% if not collector.activities %} 213 |
214 |

There are no configured temporal activities.

215 |
216 | {% else %} 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | {% for key, value in collector.activities %} 226 | 227 | 228 | {% set attributes = value | first %} 229 | 230 | 231 | 232 | {% endfor %} 233 | 234 |
IdWorkers
{{ key }}
{{ (attributes.workers  is not defined) ? 'Registered in all workers' : attributes.workers | json_encode(constant('JSON_PRETTY_PRINT')) }}
235 | {% endif %} 236 |
237 |
238 |
239 | {% endblock %} -------------------------------------------------------------------------------- /src/DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright Copyright (c) 2023, The Vanta 8 | */ 9 | 10 | declare(strict_types=1); 11 | 12 | namespace Vanta\Integration\Symfony\Temporal\DependencyInjection; 13 | 14 | use Closure; 15 | use DateMalformedIntervalStringException; 16 | use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; 17 | use Symfony\Component\Config\Definition\Builder\TreeBuilder; 18 | use Symfony\Component\Config\Definition\ConfigurationInterface as BundleConfiguration; 19 | use Symfony\Component\DependencyInjection\Loader\Configurator\EnvConfigurator; 20 | use Temporal\Api\Enums\V1\QueryRejectCondition; 21 | use Temporal\Internal\Support\DateInterval; 22 | use Temporal\Worker\WorkerFactoryInterface; 23 | use Temporal\WorkerFactory; 24 | 25 | /** 26 | * @phpstan-type PoolWorkerConfiguration array{ 27 | * dataConverter: non-empty-string, 28 | * roadrunnerRPC: non-empty-string, 29 | * } 30 | * 31 | * @phpstan-type GrpcContext array{ 32 | * timeout: array{ 33 | * value: positive-int, 34 | * format: DateInterval::FORMAT_*, 35 | * }, 36 | * options: array, 37 | * metadata: array, 38 | * retryOptions: array{ 39 | * initialInterval: ?non-empty-string, 40 | * maximumInterval: ?non-empty-string, 41 | * backoffCoefficient: float, 42 | * maximumAttempts: int<0, max>, 43 | * nonRetryableExceptions: array>, 44 | * }, 45 | * } 46 | * 47 | * @phpstan-type Client array{ 48 | * name: non-empty-string, 49 | * address: non-empty-string, 50 | * namespace: non-empty-string, 51 | * identity: ?non-empty-string, 52 | * dataConverter: non-empty-string, 53 | * queryRejectionCondition?: ?int, 54 | * interceptors: list, 55 | * clientKey: ?non-empty-string, 56 | * clientPem: ?non-empty-string, 57 | * grpcContext: GrpcContext, 58 | * } 59 | * 60 | * @phpstan-type ScheduleClient array{ 61 | * name: non-empty-string, 62 | * address: non-empty-string, 63 | * namespace: non-empty-string, 64 | * identity: ?non-empty-string, 65 | * dataConverter: non-empty-string, 66 | * queryRejectionCondition?: ?int, 67 | * clientKey: ?non-empty-string, 68 | * clientPem: ?non-empty-string, 69 | * grpcContext: GrpcContext, 70 | * } 71 | * 72 | * @phpstan-type Worker array{ 73 | * name: non-empty-string, 74 | * taskQueue: non-empty-string, 75 | * address: non-empty-string, 76 | * exceptionInterceptor: non-empty-string, 77 | * maxConcurrentActivityExecutionSize: int, 78 | * workerActivitiesPerSecond: float|int, 79 | * maxConcurrentLocalActivityExecutionSize: int, 80 | * workerLocalActivitiesPerSecond: float|int, 81 | * taskQueueActivitiesPerSecond: float|int, 82 | * maxConcurrentActivityTaskPollers: int, 83 | * maxConcurrentWorkflowTaskExecutionSize: int, 84 | * maxConcurrentWorkflowTaskPollers: int, 85 | * enableSessionWorker: bool, 86 | * sessionResourceId: ?non-empty-string, 87 | * maxConcurrentSessionExecutionSize: int, 88 | * finalizers: array, 89 | * interceptors: list, 90 | * } 91 | * 92 | * 93 | * @phpstan-type RawConfiguration array{ 94 | * defaultClient: non-empty-string, 95 | * defaultScheduleClient: non-empty-string, 96 | * workerFactory: class-string, 97 | * clients: array, 98 | * scheduleClients: array, 99 | * workers: array, 100 | * pool: PoolWorkerConfiguration, 101 | * } 102 | */ 103 | final class Configuration implements BundleConfiguration 104 | { 105 | public function getConfigTreeBuilder(): TreeBuilder 106 | { 107 | $treeBuilder = new TreeBuilder('temporal'); 108 | 109 | $dateIntervalValidator = static function (?string $v): bool { 110 | if ($v == null) { 111 | return false; 112 | } 113 | 114 | try { 115 | $value = \DateInterval::createFromDateString($v); 116 | } catch (DateMalformedIntervalStringException) { 117 | return true; 118 | } 119 | 120 | if ($value === false) { 121 | return true; 122 | } 123 | 124 | return false; 125 | }; 126 | 127 | //@formatter:off 128 | $treeBuilder->getRootNode() 129 | ->fixXmlConfig('client', 'clients') 130 | ->fixXmlConfig('worker', 'workers') 131 | ->fixXmlConfig('scheduleClient', 'scheduleClients') 132 | ->children() 133 | ->scalarNode('defaultClient') 134 | ->defaultValue('default') 135 | ->end() 136 | ->scalarNode('defaultScheduleClient') 137 | ->defaultValue('default') 138 | ->end() 139 | ->scalarNode('workerFactory')->defaultValue(WorkerFactory::class) 140 | ->validate() 141 | ->ifTrue(static function (string $v): bool { 142 | $interfaces = class_implements($v); 143 | 144 | if (!$interfaces) { 145 | return true; 146 | } 147 | 148 | 149 | if ($interfaces[WorkerFactoryInterface::class] ?? false) { 150 | return false; 151 | } 152 | 153 | return true; 154 | }) 155 | ->thenInvalid(sprintf('workerFactory does not implement interface: %s', WorkerFactoryInterface::class)) 156 | ->end() 157 | ->end() 158 | ->end() 159 | ->children() 160 | ->arrayNode('pool') 161 | ->children() 162 | ->scalarNode('dataConverter') 163 | ->cannotBeEmpty() 164 | ->end() 165 | ->scalarNode('roadrunnerRPC') 166 | ->cannotBeEmpty() 167 | ->end() 168 | ->end() 169 | ->end() 170 | ->end() 171 | 172 | ->children() 173 | ->arrayNode('workers') 174 | ->useAttributeAsKey('name') 175 | ->arrayPrototype() 176 | ->children() 177 | ->scalarNode('maxConcurrentActivityExecutionSize') 178 | ->defaultValue(0) 179 | ->info('To set the maximum concurrent activity executions this worker can have.') 180 | ->end() 181 | ->floatNode('workerActivitiesPerSecond') 182 | ->defaultValue(0) 183 | ->info( 184 | <<end() 195 | ->scalarNode('taskQueue') 196 | ->isRequired()->cannotBeEmpty() 197 | ->end() 198 | ->scalarNode('taskQueue') 199 | ->isRequired()->cannotBeEmpty() 200 | ->end() 201 | ->scalarNode('exceptionInterceptor') 202 | ->defaultValue('temporal.exception_interceptor')->cannotBeEmpty() 203 | ->end() 204 | ->arrayNode('finalizers') 205 | ->validate() 206 | ->ifTrue(static fn (array $values): bool => !(count($values) == count(array_unique($values)))) 207 | ->thenInvalid('Should not be repeated finalizer') 208 | ->end() 209 | ->defaultValue([]) 210 | ->scalarPrototype()->end() 211 | ->end() 212 | ->arrayNode('interceptors') 213 | ->validate() 214 | ->ifTrue(static fn (array $values): bool => !(count($values) == count(array_unique($values)))) 215 | ->thenInvalid('Should not be repeated interceptor') 216 | ->end() 217 | ->defaultValue([]) 218 | ->scalarPrototype()->end() 219 | ->end() 220 | ->integerNode('maxConcurrentLocalActivityExecutionSize') 221 | ->defaultValue(0) 222 | ->info('To set the maximum concurrent local activity executions this worker can have.') 223 | ->end() 224 | ->floatNode('workerLocalActivitiesPerSecond') 225 | ->defaultValue(0) 226 | ->info( 227 | <<end() 238 | ->integerNode('taskQueueActivitiesPerSecond') 239 | ->defaultValue(0) 240 | ->info( 241 | <<end() 254 | ->integerNode('maxConcurrentActivityTaskPollers') 255 | ->defaultValue(0) 256 | ->info( 257 | <<end() 263 | ->integerNode('maxConcurrentWorkflowTaskExecutionSize') 264 | ->defaultValue(0) 265 | ->info('To set the maximum concurrent workflow task executions this worker can have.') 266 | ->end() 267 | ->integerNode('maxConcurrentWorkflowTaskPollers') 268 | ->defaultValue(0) 269 | ->info( 270 | <<end() 277 | ->booleanNode('enableSessionWorker') 278 | ->defaultValue(false) 279 | ->info('Session workers is for activities within a session. Enable this option to allow worker to process sessions.') 280 | ->end() 281 | ->scalarNode('sessionResourceId') 282 | ->defaultValue(null) 283 | ->info( 284 | <<end() 291 | ->integerNode('maxConcurrentSessionExecutionSize') 292 | ->defaultValue(1000) 293 | ->info('Sets the maximum number of concurrently running sessions the resource support.') 294 | ->end() 295 | ->scalarNode('stickyScheduleToStartTimeout') 296 | ->defaultNull() 297 | ->example('5 seconds') 298 | ->validate() 299 | ->ifTrue($dateIntervalValidator) 300 | ->thenInvalid('Failed parse date-interval') 301 | ->end() 302 | ->info( 303 | <<end() 314 | ->scalarNode('workerStopTimeout') 315 | ->defaultNull() 316 | ->example('5 seconds') 317 | ->validate() 318 | ->ifTrue($dateIntervalValidator) 319 | ->thenInvalid('Failed parse date-interval, value: %s') 320 | ->end() 321 | ->info('Optional: worker graceful stop timeout.') 322 | ->end() 323 | ->scalarNode('deadlockDetectionTimeout') 324 | ->defaultNull() 325 | ->example('5 seconds') 326 | ->validate() 327 | ->ifTrue($dateIntervalValidator) 328 | ->thenInvalid('Failed parse date-interval, value: %s') 329 | ->end() 330 | ->info('Optional: If set defines maximum amount of time that workflow task will be allowed to run.') 331 | ->end() 332 | ->scalarNode('maxHeartbeatThrottleInterval') 333 | ->defaultNull() 334 | ->example('5 seconds') 335 | ->validate() 336 | ->ifTrue($dateIntervalValidator) 337 | ->thenInvalid('Failed parse date-interval, value: %s') 338 | ->end() 339 | ->info( 340 | <<end() 347 | ->end() 348 | ->end() 349 | ->end() 350 | ; 351 | 352 | 353 | $clients = $treeBuilder->getRootNode() 354 | ->children() 355 | ->arrayNode('clients') 356 | ->defaultValue(['default' => [ 357 | 'namespace' => 'default', 358 | 'address' => (new EnvConfigurator('TEMPORAL_ADDRESS'))->__toString(), 359 | 'dataConverter' => 'temporal.data_converter', 360 | 'grpcContext' => ['timeout' => ['value' => 5, 'format' => DateInterval::FORMAT_SECONDS]], 361 | 'interceptors' => [], 362 | ]]) 363 | ->useAttributeAsKey('name') 364 | ; 365 | 366 | $this->addClient($clients, $dateIntervalValidator); 367 | 368 | $scheduleClients = $treeBuilder->getRootNode() 369 | ->children() 370 | ->arrayNode('scheduleClients') 371 | ->defaultValue(['default' => [ 372 | 'namespace' => 'default', 373 | 'address' => (new EnvConfigurator('TEMPORAL_ADDRESS'))->__toString(), 374 | 'dataConverter' => 'temporal.data_converter', 375 | 'grpcContext' => ['timeout' => ['value' => 5, 'format' => DateInterval::FORMAT_SECONDS]], 376 | 'interceptors' => [], 377 | ]]) 378 | ->useAttributeAsKey('name') 379 | ; 380 | 381 | $this->addClient($scheduleClients, $dateIntervalValidator); 382 | 383 | return $treeBuilder; 384 | } 385 | 386 | 387 | 388 | /** 389 | * @param Closure(?string): bool $dateIntervalValidator 390 | */ 391 | private function addClient(ArrayNodeDefinition $node, Closure $dateIntervalValidator): void 392 | { 393 | 394 | //@formatter:off 395 | $node->arrayPrototype() 396 | ->addDefaultsIfNotSet() 397 | ->children() 398 | ->scalarNode('namespace') 399 | ->isRequired()->cannotBeEmpty() 400 | ->end() 401 | ->scalarNode('address') 402 | ->defaultValue((new EnvConfigurator('TEMPORAL_ADDRESS')))->cannotBeEmpty() 403 | ->end() 404 | ->scalarNode('identity') 405 | ->end() 406 | ->scalarNode('dataConverter') 407 | ->cannotBeEmpty()->defaultValue('temporal.data_converter') 408 | ->end() 409 | ->scalarNode('clientKey') 410 | ->example('%kernel.project_dir%/resource/temporal.key') 411 | ->end() 412 | ->scalarNode('clientPem') 413 | ->example('%kernel.project_dir%/resource/temporal.pem') 414 | ->end() 415 | ->enumNode('queryRejectionCondition') 416 | ->values([ 417 | QueryRejectCondition::QUERY_REJECT_CONDITION_UNSPECIFIED, 418 | QueryRejectCondition::QUERY_REJECT_CONDITION_NONE, 419 | QueryRejectCondition::QUERY_REJECT_CONDITION_NOT_OPEN, 420 | QueryRejectCondition::QUERY_REJECT_CONDITION_NOT_COMPLETED_CLEANLY, 421 | ]) 422 | ->validate() 423 | ->ifNotInArray([ 424 | QueryRejectCondition::QUERY_REJECT_CONDITION_UNSPECIFIED, 425 | QueryRejectCondition::QUERY_REJECT_CONDITION_NONE, 426 | QueryRejectCondition::QUERY_REJECT_CONDITION_NOT_OPEN, 427 | QueryRejectCondition::QUERY_REJECT_CONDITION_NOT_COMPLETED_CLEANLY, 428 | ]) 429 | ->thenInvalid(sprintf('"queryRejectionCondition" value is not in the enum: %s', QueryRejectCondition::class)) 430 | ->end() 431 | ->end() 432 | ->arrayNode('interceptors') 433 | ->validate() 434 | ->ifTrue(static fn (array $values): bool => !(count($values) == count(array_unique($values)))) 435 | ->thenInvalid('Should not be repeated interceptor') 436 | ->end() 437 | ->defaultValue([]) 438 | ->scalarPrototype()->end() 439 | ->end() 440 | ->arrayNode('grpcContext') 441 | ->addDefaultsIfNotSet() 442 | ->children() 443 | ->arrayNode('timeout') 444 | ->addDefaultsIfNotSet() 445 | ->children() 446 | ->integerNode('value') 447 | ->info('Value connection timeout') 448 | ->defaultValue(5) 449 | ->end() 450 | ->enumNode('format') 451 | ->info('Interval unit') 452 | ->defaultValue(DateInterval::FORMAT_SECONDS) 453 | ->values([ 454 | DateInterval::FORMAT_NANOSECONDS, 455 | DateInterval::FORMAT_MICROSECONDS, 456 | DateInterval::FORMAT_MILLISECONDS, 457 | DateInterval::FORMAT_SECONDS, 458 | DateInterval::FORMAT_MINUTES, 459 | DateInterval::FORMAT_HOURS, 460 | DateInterval::FORMAT_DAYS, 461 | DateInterval::FORMAT_WEEKS, 462 | DateInterval::FORMAT_MONTHS, 463 | DateInterval::FORMAT_YEARS, 464 | ]) 465 | ->validate() 466 | ->ifNotInArray([ 467 | DateInterval::FORMAT_NANOSECONDS, 468 | DateInterval::FORMAT_MICROSECONDS, 469 | DateInterval::FORMAT_MILLISECONDS, 470 | DateInterval::FORMAT_SECONDS, 471 | DateInterval::FORMAT_MINUTES, 472 | DateInterval::FORMAT_HOURS, 473 | DateInterval::FORMAT_DAYS, 474 | DateInterval::FORMAT_WEEKS, 475 | DateInterval::FORMAT_MONTHS, 476 | DateInterval::FORMAT_YEARS, 477 | ]) 478 | ->thenInvalid(sprintf('"format" value is not in the enum: %s', DateInterval::class)) 479 | ->end() 480 | ->end() 481 | ->end() 482 | ->end() 483 | ->arrayNode('options') 484 | ->normalizeKeys(false) 485 | ->defaultValue([]) 486 | ->prototype('variable')->end() 487 | ->end() 488 | ->arrayNode('metadata') 489 | ->normalizeKeys(false) 490 | ->defaultValue([]) 491 | ->prototype('variable')->end() 492 | ->end() 493 | ->arrayNode('retryOptions') 494 | ->children() 495 | ->scalarNode('initialInterval') 496 | ->defaultNull() 497 | ->example('30 seconds') 498 | ->info('Backoff interval for the first retry.') 499 | ->validate() 500 | ->ifTrue($dateIntervalValidator) 501 | ->thenInvalid('Failed parse date-interval,value: %s') 502 | ->end() 503 | ->end() 504 | ->scalarNode('maximumInterval') 505 | ->defaultNull() 506 | ->validate() 507 | ->ifTrue($dateIntervalValidator) 508 | ->thenInvalid('Failed parse date-interval,value: %s') 509 | ->end() 510 | ->info( 511 | <<end() 519 | ->floatNode('backoff_coefficient') 520 | ->info( 521 | <<end() 528 | ->integerNode('maximumAttempts') 529 | ->info( 530 | <<end() 537 | ->arrayNode('nonRetryableExceptions') 538 | ->scalarPrototype() 539 | ->info( 540 | <<end() 546 | ->end() 547 | ->end() 548 | ->end() 549 | ->end() 550 | ->end() 551 | ->end() 552 | ->end() 553 | ->end(); 554 | } 555 | } 556 | --------------------------------------------------------------------------------