diff --git a/.travis.yml b/.travis.yml index 87d8e6e5ef17a..246b2d4739e66 100644 --- a/.travis.yml +++ b/.travis.yml @@ -16,6 +16,7 @@ env: global: - MIN_PHP=5.5.9 - SYMFONY_PROCESS_PHP_TEST_BINARY=~/.phpenv/versions/5.6/bin/php + - RABBITMQ_URL=amqp://guest:guest@localhost:5672/ matrix: include: @@ -41,6 +42,7 @@ services: - memcached - mongodb - redis-server + - rabbitmq before_install: - | @@ -82,6 +84,7 @@ before_install: echo apc.enable_cli = 1 >> $INI echo extension = ldap.so >> $INI echo extension = redis.so >> $INI + echo extension = amqp.so >> $INI echo extension = memcached.so >> $INI [[ $PHP = 5.* ]] && echo extension = memcache.so >> $INI if [[ $PHP = 5.* ]]; then @@ -159,6 +162,9 @@ install: - if [[ ! $skip ]]; then $COMPOSER_UP; fi - if [[ ! $skip ]]; then ./phpunit install; fi + - | + # setup rabbitmq + src/Symfony/Component/Amqp/bin/reset.php force - | # phpinfo if [[ ! $PHP = hhvm* ]]; then php -i; else hhvm --php -r 'print_r($_SERVER);print_r(ini_get_all());'; fi diff --git a/composer.json b/composer.json index b41cd557d9c08..921a6d51a8cf4 100644 --- a/composer.json +++ b/composer.json @@ -32,6 +32,7 @@ "symfony/polyfill-util": "~1.0" }, "replace": { + "symfony/amqp": "self.version", "symfony/asset": "self.version", "symfony/browser-kit": "self.version", "symfony/cache": "self.version", @@ -81,6 +82,7 @@ "symfony/web-link": "self.version", "symfony/web-profiler-bundle": "self.version", "symfony/web-server-bundle": "self.version", + "symfony/worker": "self.version", "symfony/workflow": "self.version", "symfony/yaml": "self.version" }, @@ -98,7 +100,9 @@ "symfony/phpunit-bridge": "~3.2", "symfony/polyfill-apcu": "~1.1", "symfony/security-acl": "~2.8|~3.0", - "phpdocumentor/reflection-docblock": "^3.0" + "phpdocumentor/reflection-docblock": "^3.0", + "queue-interop/queue-interop": "^0.5", + "queue-interop/amqp-interop": "dev-master" }, "conflict": { "phpdocumentor/reflection-docblock": "<3.0", @@ -134,5 +138,11 @@ "branch-alias": { "dev-master": "3.4-dev" } - } + }, + "repositories": [ + { + "type": "vcs", + "url": "git@github.com:queue-interop/amqp-interop.git" + } + ] } diff --git a/phpunit.xml.dist b/phpunit.xml.dist index e44535a81a19e..b300206da02e2 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -19,6 +19,7 @@ + diff --git a/pr.body.md b/pr.body.md new file mode 100644 index 0000000000000..4b1d1083cc171 --- /dev/null +++ b/pr.body.md @@ -0,0 +1,322 @@ +Hello. + +I'm happy and excited to share with you 2 new components. + +note: The PR description (what you are currently reading) is also committed (as +`pr.body.md`). I will remove it just before the merge. Like that you could also +ask question about the "documentation". But please, don't over-comment the +"language / English". This part of the job will be done in the doc repository. + +### AMQP + +It is a library created at @SensioLabs few years ago (Mon Mar 18 17:26:01 2013 +0100). +Its goal is to ease the communication with a service that implement [AMQP](https://fr.wikipedia.org/wiki/Advanced_Message_Queuing_Protocol) +For example, [RabbitMQ](http://www.rabbitmq.com/) implements AMQP. + +At that time, [Swarrot](https://github.com/swarrot/swarrot) did not exist yet +and only [php-amqplib](https://github.com/php-amqplib/php-amqplib) existed. + +We started by using ``php-amqplib`` but we faced many issues: memory leak, bad +handling of signal, poor documentation... + +So we decided to stop using it and to build our own library. Over the years, we +added very nice features, we fixed very weird edge cases and we gain real +expertise on AMQP. + +Nowadays, it's very common to use AMQP in a web / CLI project. + +So four years later, we decided to open-source it and to add it to Symfony to +leverage the Symfony ecosystem (code quality, release process, documentation, +visibility, community, etc.) + +So basically it's an abstraction of the [AMQP pecl](https://github.com/pdezwart/php-amqp/). + +Here is the README.rst we had for this lib. I have updated it to match the +version that will land in Symfony. + +
+The old README (but updated) + +Symfony AMQP +============ + +Fed up of writing the same boiler-plate code over and over again whenever you +need to use your favorite AMQP broker? Have you a hard time remembering how to +publish a message or how to wire exchanges and queues? I had the exact same +feeling. There are many AMQP libraries providing a very good low-level access to +the AMQP protocol, but what about providing a simple API for abstracting the +most common use cases? This library gives you an opinionated way of using any +AMQP brokers and it also provides a nice and consistent API for low-level +interaction with any AMQP brokers. + +Dependencies +------------ + +This library depends on the ``amqp`` PECL extensions (version 1.4.0-beta2 or +later):: + + sudo apt-get install php-amqp + +Using the Conventions +--------------------- + +The simplest usage of an AMQP broker is sending a message that is consumed by +another script:: + + use Symfony\Component\Amqp\Broker; + + // connects to a local AMQP broker by default + $broker = new Broker(); + + // publish a message on the 'log' queue + $broker->publish('log', 'some message'); + + // in another script (non-blocking) + // $message is false if no messages are in the queue + $message = $broker->get('log'); + + // blocking (waits for a message to be available in the queue) + $message = $broker->consume('log'); + +The example above is based on some "conventions" and as such makes the +following assumptions: + +* A default exchange is used to publish the message (named + ``symfony.default``); + +* The routing is done via the routing key (``log`` in this example); + +* Queues and exchanges are created implicitly when first accessed; + +* The connection to the broker is done lazily whenever a message must be sent + or received. + +Retrying a Message +------------------ + +Retrying processing a message when an error occurs is as easy as defining a +retry strategy on a queue:: + + use Symfony\Component\Amqp\RetryStrategy\ConstantRetryStrategy; + + // configure the queue explicitly + $broker->createQueue('log', array( + // retry every 5 seconds + 'retry_strategy' => new ConstantRetryStrategy(5), + )); + +Whenever you ``$broker->retry()`` a message, it is going to be automatically re- +enqueued after a ``5`` seconds wait for a retry. + +You can also drop the message after a limited number of retries (``2`` in the +following example):: + + $broker->createQueue('log', array( + // retry 2 times + 'retry_strategy' => new ConstantRetryStrategy(5, 2), + )); + +Instead of trying every ``n`` seconds, you can also use a retry mechanism based +on a truncated exponential backoff algorithm:: + + use Symfony\Component\Amqp\RetryStrategy\ExponentialRetryStrategy; + + $broker->createQueue('log', array( + // retry 5 times + 'retry_strategy' => new ExponentialRetryStrategy(5), + )); + +The message will be re-enqueued after 1 second the first time you call +``retry()``, ``2^1`` seconds the second time, ``2^2`` seconds the third time, +and ``2^n`` seconds the nth time. If you want to wait more than 1 second the +first time, you can pass an offset:: + + $broker->createQueue('log', array( + // starts at 2^3 + 'retry_strategy' => new ExponentialRetryStrategy(5, 3), + )); + +.. note:: + + The retry strategies are implemented by using the dead-lettering feature of + AMQP. Behind the scene, a special exchange is bound to queues configured + based on the retry strategy you set. + +.. note:: + + Don't forget to ``ack`` or ``nack`` your message if you retry it. And + obviously you should not use the AMQP_Requeue flag. + +Configuring a Broker +-------------------- + +By default, a broker tries to connect to a local AMQP broker with the default +port, username, and password. If you have a different setting, pass a URI to +the ``Broker`` constructor:: + + $broker = new Broker('amqp://user:pass@10.1.2.3:345/some-vhost'); + +Configuring an Exchange +----------------------- + +The default exchange used by the library is of type ``direct``. You can also +create your own exchange:: + + // define a new fanout exchange + $broker->createExchange('sensiolabs.fanout', array('type' => \AMQP_EX_TYPE_FANOUT)); + +You can then binding a queue to this named exchange easily:: + + $broker->createQueue('logs', array('exchange' => 'sensiolabs.fanout', 'routing_keys' => null)); + $broker->createQueue('logs.again', array('exchange' => 'sensiolabs.fanout', 'routing_keys' => null)); + +The second argument of ``createExchange()`` takes an array of arguments passed +to the exchange. The following keys are used to further configure the exchange: + +* ``flags``: sets the exchange flags; + +* ``type``: sets the type of the queue (see ``\AMQP_EX_TYPE_*`` constants). + +.. note:: + + Note that ``createExchange()`` automatically declares the exchange. + +Configuring a Queue +------------------- + +As demonstrated in some examples, you can create your own queue. As for the +exchange, the second argument of the ``createQueue()`` method is a list of +queue arguments; the following keys are used to further configure the queue: + +* ``exchange``: The exchange name to bind the queue to (the default exchange is + used if not set); + +* ``flags``: Sets the exchange flags; + +* ``bind_arguments``: An array of arguments to pass when binding the queue with + an exchange; + +* ``retry_strategy``: The retry strategy to use (an instance of + :class:``Symfony\\Amqp\\RetryStrategy\\RetryStrategyInterface``). + +.. note:: + + Note that ``createQueue()`` automatically declares and binds the queue. + +Implementation details +---------------------- + +The retry strategy +.................. + +The retry strategy is implemented with two custom and private exchanges: +``symfony.dead_letter`` and ``symfony.retry``. + +Calling ``Broker::retry`` will publish the same message in the +``symfony.dead_letter`` exchange. + +This exchange will route the message to a queue named like +``%exchange%.%time%.wait``, for example ``sensiolabs.default.000005.wait``. This +queue has a TTL of 5 seconds. It means that if nothing consumes this message, it +will be dropped after 5 seconds. But this queue has also a Dead Letter (DL). It +means that instead of dropping the message, the AMQP server will re-publish +automatically the message to the Exchange configured as DL. + +After 5 seconds the message will be re-published to ``symfony.retry`` Exchange. +This exchange is bound with every single queue. Finally, the message will land +in the original queue. + +
+ +### Worker + +The second component was extracted from our internal SensioLabsAmqp component. +We extracted it as is decoupled from the AMQP component. Thus it could be used, +for example, to write redis, kafka daemon. + +
+Documentation + +Symfony Worker +============== + +The worker component help you to write simple but flexible daemon. + +Introduction +------------ + +First you need something that ``fetch`` some messages. If the message are sent +to AMQP, you should use the ``AmqpMessageFetcher``:: + + use Symfony\Component\Amqp\Broker; + use Symfony\Component\Worker\MessageFetcher\AmqpMessageFetcher; + + $broker = new Broker(); + $fetcher = new AmqpMessageFetcher($broker, 'queue_name'); + +Then you need a Consumer that will ``consumer`` each AMQP message:: + + namespace AppBundle\Consumer; + + use Symfony\Component\Amqp\Broker; + use Symfony\Component\Worker\Consumer\ConsumerInterface; + use Symfony\Component\Worker\MessageCollection; + + class DumpConsumer implements ConsumerInterface + { + private $broker; + + public function __construct(Broker $broker) + { + $this->broker = $broker; + } + + public function consume(MessageCollection $messageCollection) + { + foreach ($messageCollection as $message) { + dump($message); + + $this->broker->ack($message); + } + } + } + +Finally plug everything together:: + + use AppBundle\Consumer\DumpConsumer; + use Symfony\Component\Amqp\Broker; + use Symfony\Component\Worker\Loop\Loop; + use Symfony\Component\Worker\MessageFetcher\AmqpMessageFetcher; + + $broker = new Broker(); + $fetcher = new AmqpMessageFetcher($broker, 'queue_name'); + $consumer = new DumpConsumer($broker); + + $loop = new Loop(new DirectRouter($fetcher, $consumer)); + + $loop->run(); + +Message Fetcher +--------------- + +* ``AmqpMessageFetcher``: Proxy to interact with an AMQP server +* ``BufferedMessageFetcher``: Wrapper to buffer some message. Useful if you want to call an API in a "bulk" way. +* ``InMemoryMessageFetcher``: Useful in test env + +Router +------ + +The router has the responsibility to fetch a message, then to dispatch it to a +consumer. + +* ``DirectRouter``: Use a ``MessageFetcherInterface`` and a ``ConsumerInterface``. Each message fetched is passed to the consumer. +* ``RoundRobinRouter``: Wrapper to be able to fetch message from various sources. + +
+ +--- + +In Symfony full stack, everything is simpler. + +I have forked [the standard edition](https://github.com/lyrixx/symfony-standard/tree/amqp) +to show how it works. diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index fcc0e714c8cb6..8bef33ead14d2 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -27,6 +27,7 @@ * FrameworkExtension configuration structure. * * @author Jeremy Mikola + * @author Grégoire Pineau */ class Configuration implements ConfigurationInterface { @@ -130,6 +131,8 @@ public function getConfigTreeBuilder() $this->addCacheSection($rootNode); $this->addPhpErrorsSection($rootNode); $this->addWebLinkSection($rootNode); + $this->addAmqpSection($rootNode); + $this->addWorkerSection($rootNode); return $treeBuilder; } @@ -858,4 +861,386 @@ private function addWebLinkSection(ArrayNodeDefinition $rootNode) ->end() ; } + + private function addAmqpSection($rootNode) + { + $rootNode + ->children() + ->arrayNode('amqp') + ->fixXmlConfig('connection') + ->children() + ->arrayNode('connections') + ->addDefaultChildrenIfNoneSet('default') + ->useAttributeAsKey('name') + ->prototype('array') + ->fixXmlConfig('exchange') + ->fixXmlConfig('queue') + ->children() + ->scalarNode('name') + ->cannotBeEmpty() + ->end() + ->scalarNode('url') + ->cannotBeEmpty() + ->defaultValue('amqp://guest:guest@localhost:5672/symfony') + ->end() + ->arrayNode('exchanges') + ->prototype('array') + ->fixXmlConfig('argument') + ->children() + ->scalarNode('name') + ->isRequired() + ->cannotBeEmpty() + ->end() + ->variableNode('arguments') + ->defaultValue(array()) + // Deal with XML config + ->beforeNormalization() + ->always() + ->then(function ($v) { + return $this->fixXmlArguments($v); + }) + ->end() + ->validate() + ->ifTrue(function ($v) { + return !is_array($v); + }) + ->thenInvalid('Arguments should be an array (got %s).') + ->end() + ->end() + ->end() + ->end() + ->end() + ->arrayNode('queues') + ->prototype('array') + ->fixXmlConfig('argument') + ->children() + ->scalarNode('name') + ->isRequired() + ->cannotBeEmpty() + ->end() + ->variableNode('arguments') + ->defaultValue(array()) + // Deal with XML config + ->beforeNormalization() + ->always() + ->then(function ($v) { + return $this->fixXmlArguments($v); + }) + ->end() + ->validate() + ->ifTrue(function ($v) { + return !is_array($v); + }) + ->thenInvalid('Arguments should be an array (got %s).') + ->end() + ->end() + ->enumNode('retry_strategy') + ->values(array(null, 'constant', 'exponential')) + ->defaultNull() + ->end() + ->variableNode('retry_strategy_options') + ->validate() + ->ifTrue(function ($v) { + return !is_array($v); + }) + ->thenInvalid('Arguments should be an array (got %s).') + ->end() + ->end() + ->arrayNode('thresholds') + ->addDefaultsIfNotSet() + ->children() + ->integerNode('warning')->defaultNull()->end() + ->integerNode('critical')->defaultNull()->end() + ->end() + ->end() + ->end() + ->validate() + ->ifTrue(function ($config) { + return 'constant' === $config['retry_strategy'] && !array_key_exists('max', $config['retry_strategy_options']); + }) + ->thenInvalid('"max" of "retry_strategy_options" should be set for constant retry strategy.') + ->end() + ->validate() + ->ifTrue(function ($config) { + return 'constant' === $config['retry_strategy'] && !array_key_exists('time', $config['retry_strategy_options']); + }) + ->thenInvalid('"time" of "retry_strategy_options" should be set for constant retry strategy.') + ->end() + ->validate() + ->ifTrue(function ($config) { + return 'exponential' === $config['retry_strategy'] && !array_key_exists('max', $config['retry_strategy_options']); + }) + ->thenInvalid('"max" of "retry_strategy_options" should be set for exponential retry strategy.') + ->end() + ->validate() + ->ifTrue(function ($config) { + return 'exponential' === $config['retry_strategy'] && !array_key_exists('offset', $config['retry_strategy_options']); + }) + ->thenInvalid('"offset" of "retry_strategy_options" should be set for exponential retry strategy.') + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ->scalarNode('default_connection') + ->cannotBeEmpty() + ->end() + ->end() + ->end() + ->end() + ; + } + + private function addWorkerSection($rootNode) + { + $rootNode + ->children() + ->arrayNode('worker') + ->addDefaultsIfNotSet() + ->fixXmlConfig('worker') + ->children() + ->arrayNode('fetchers') + ->addDefaultsIfNotSet() + ->fixXmlConfig('amqp') + ->fixXmlConfig('service') + ->fixXmlConfig('buffer') + ->children() + ->arrayNode('amqps') + ->beforeNormalization() + ->always() + ->then(function ($v) { + $v = $this->useKeyAsAttribute($v, 'queue_name'); + $v = $this->useKeyAsAttribute($v, 'name'); + + return $v; + }) + ->end() + ->useAttributeAsKey('name', false) + ->prototype('array') + ->children() + ->scalarNode('name') + ->isRequired() + ->cannotBeEmpty() + ->end() + ->scalarNode('queue_name') + ->isRequired() + ->cannotBeEmpty() + ->end() + ->booleanNode('auto_ack') + ->defaultValue(false) + ->end() + ->scalarNode('connection') + ->cannotBeEmpty() + ->end() + ->end() + ->end() + ->end() + ->arrayNode('buffers') + ->beforeNormalization() + ->always() + ->then(function ($v) { + $v = $this->useKeyAsAttribute($v, 'wrap'); + $v = $this->useKeyAsAttribute($v, 'name'); + + return $v; + }) + ->end() + ->useAttributeAsKey('name', false) + ->prototype('array') + ->children() + ->scalarNode('name') + ->isRequired() + ->cannotBeEmpty() + ->end() + ->scalarNode('wrap') + ->isRequired() + ->cannotBeEmpty() + ->end() + ->integerNode('max_messages') + ->defaultValue(10) + ->end() + ->integerNode('max_buffering_time') + ->defaultValue(10) + ->end() + ->end() + ->end() + ->end() + ->arrayNode('services') + ->beforeNormalization() + ->always() + ->then(function ($v) { + $v = $this->useKeyAsAttribute($v, 'service'); + $v = $this->useKeyAsAttribute($v, 'name'); + + return $v; + }) + ->end() + ->useAttributeAsKey('name', false) + ->prototype('array') + ->children() + ->scalarNode('service') + ->isRequired() + ->cannotBeEmpty() + ->end() + ->scalarNode('name') + ->isRequired() + ->cannotBeEmpty() + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ->arrayNode('routers') + ->addDefaultsIfNotSet() + ->fixXmlConfig('direct') + ->fixXmlConfig('round_robin') + ->children() + ->arrayNode('directs') + ->beforeNormalization() + ->always() + ->then(function ($v) { + $v = $this->useKeyAsAttribute($v, 'fetcher'); + $v = $this->useKeyAsAttribute($v, 'name'); + + return $v; + }) + ->end() + ->useAttributeAsKey('name', false) + ->prototype('array') + ->children() + ->scalarNode('name') + ->isRequired() + ->cannotBeEmpty() + ->end() + ->scalarNode('fetcher') + ->isRequired() + ->cannotBeEmpty() + ->end() + ->scalarNode('consumer') + ->isRequired() + ->cannotBeEmpty() + ->end() + ->end() + ->end() + ->end() + ->arrayNode('round_robins') + ->beforeNormalization() + ->always() + ->then(function ($v) { + $v = $this->useKeyAsAttribute($v, 'name'); + + return $v; + }) + ->end() + ->useAttributeAsKey('name', false) + ->prototype('array') + ->fixXmlConfig('group') + ->children() + ->scalarNode('name') + ->isRequired() + ->cannotBeEmpty() + ->end() + ->arrayNode('groups') + ->isRequired() + ->requiresAtLeastOneElement() + ->prototype('scalar') + ->isRequired() + ->cannotBeEmpty() + ->end() + ->end() + ->booleanNode('consume_everything') + ->defaultValue(false) + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() + + ->arrayNode('workers') + ->beforeNormalization() + ->always() + ->then(function ($v) { + $v = $this->useKeyAsAttribute($v, 'name'); + + return $v; + }) + ->end() + ->useAttributeAsKey('name', false) + ->prototype('array') + ->children() + ->scalarNode('name') + ->cannotBeEmpty() + ->end() + ->scalarNode('router') + ->cannotBeEmpty() + ->end() + ->scalarNode('fetcher') + ->cannotBeEmpty() + ->end() + ->scalarNode('consumer') + ->cannotBeEmpty() + ->end() + ->end() + ->validate() + ->ifTrue(function ($v) { + return isset($v['router'], $v['fetcher']) || isset($v['router'], $v['consumer']) || !isset($v['router']) && !isset($v['fetcher']) && !isset($v['consumer']); + }) + ->thenInvalid('You should use either "router" or "fetcher" and "consumer" options.') + ->end() + ->validate() + ->ifTrue(function ($v) { + return isset($v['fetcher']) && !isset($v['consumer']) || !isset($v['fetcher']) && isset($v['consumer']); + }) + ->thenInvalid('The fetcher and the consumer should be configured.') + ->end() + ->end() + ->end() + ->scalarNode('cli_title_prefix') + ->defaultValue('app') + ->cannotBeEmpty() + ->end() + ->end() + ->end() + ->end() + ->end() + ; + } + + private function useKeyAsAttribute(array $v, $attribute) + { + $return = array(); + + foreach ($v as $name => $config) { + if (isset($config['name'])) { + $name = $config['name']; + } + if (null === $config || is_array($config) && !array_key_exists($attribute, $config)) { + $config[$attribute] = $name; + } + $return[$name] = $config; + } + + return $return; + } + + private function fixXmlArguments($v) + { + if (!is_array($v)) { + return $v; + } + + $tmp = array(); + + foreach ($v as $key => $value) { + if (!isset($value['key']) && !isset($value['value'])) { + return $v; + } + $tmp[$value['key']] = $value['value']; + } + + return $tmp; + } } diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index f3b77f0a12409..c4ae356a91533 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -16,12 +16,13 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\Controller; use Symfony\Bundle\FrameworkBundle\Routing\AnnotatedRouteControllerLoader; +use Symfony\Component\Amqp\Broker; use Symfony\Component\Cache\Adapter\AdapterInterface; use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\Config\FileLocator; use Symfony\Component\Config\Loader\LoaderInterface; -use Symfony\Component\Config\Resource\DirectoryResource; use Symfony\Component\Config\ResourceCheckerInterface; +use Symfony\Component\Config\Resource\DirectoryResource; use Symfony\Component\Console\Application; use Symfony\Component\Console\Command\Command; use Symfony\Component\DependencyInjection\Alias; @@ -33,6 +34,7 @@ use Symfony\Component\DependencyInjection\Loader\XmlFileLoader; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\DependencyInjection\ServiceSubscriberInterface; +use Symfony\Component\DependencyInjection\TypedReference; use Symfony\Component\EventDispatcher\ContainerAwareEventDispatcher; use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\EventDispatcher\EventSubscriberInterface; @@ -66,6 +68,7 @@ use Symfony\Component\Validator\ObjectInitializerInterface; use Symfony\Component\WebLink\HttpHeaderSerializer; use Symfony\Component\Workflow; +use Symfony\Component\Worker; /** * FrameworkExtension. @@ -238,6 +241,10 @@ public function load(array $configs, ContainerBuilder $container) $this->registerAnnotationsConfiguration($config['annotations'], $container, $loader); $this->registerPropertyAccessConfiguration($config['property_access'], $container); + if (isset($config['amqp'])) { + $this->registerAmqpConfiguration($config['amqp'], $container, $loader); + } + $this->registerWorkerConfiguration($config['worker'], $container, $loader); if ($this->isConfigEnabled($container, $config['serializer'])) { $this->registerSerializerConfiguration($config['serializer'], $container, $loader); @@ -1299,6 +1306,168 @@ private function registerPropertyAccessConfiguration(array $config, ContainerBui ; } + private function registerAmqpConfiguration(array $config, ContainerBuilder $container, XmlFileLoader $loader) + { + $loader->load('amqp.xml'); + + $defaultConnectionName = null; + if (isset($config['default_connection'])) { + $defaultConnectionName = $config['default_connection']; + } + + $match = false; + foreach ($config['connections'] as $name => $connection) { + $container + ->register("amqp.broker.$name", Broker::class) + ->addArgument($connection['url']) + ->addArgument($connection['queues']) + ->addArgument($connection['exchanges']) + ->setPublic(false) + ; + if (!$defaultConnectionName) { + $defaultConnectionName = $name; + } + if ($defaultConnectionName === $name) { + $match = true; + } + } + + if (!$match) { + throw new \InvalidArgumentException(sprintf('The default_connection "%s" does not exist.', $defaultConnectionName)); + } + + $container->setAlias('amqp.broker', sprintf('amqp.broker.%s', $defaultConnectionName)); + } + + private function registerWorkerConfiguration(array $config, ContainerBuilder $container, XmlFileLoader $loader) + { + $loader->load('worker.xml'); + + $fetchers = array(); + foreach ($config['fetchers']['amqps'] as $name => $fetcher) { + if (isset($fetchers[$name])) { + throw new \InvalidArgumentException(sprintf('A fetcher named "%s" already exist.', $name)); + } + if (isset($fetcher['connection'])) { + $connection = new Reference('amqp.broker.'.$fetcher['connection']); + } else { + $connection = new Reference('amqp.broker'); + } + $id = "worker.message_fetcher.amqp.$name"; + $container + ->register($id, Worker\MessageFetcher\AmqpMessageFetcher::class) + ->addArgument($connection) + ->addArgument($fetcher['queue_name']) + ->addArgument($fetcher['auto_ack']) + ->setPublic(false) + ; + + $fetchers[$name] = $id; + } + + foreach ($config['fetchers']['services'] as $name => $fetcher) { + if (isset($fetchers[$name])) { + throw new \InvalidArgumentException(sprintf('A fetcher named "%s" already exist.', $name)); + } + $id = "worker.message_fetcher.service.$name"; + $container->setAlias($id, $fetcher['service']); + $fetchers[$name] = $id; + } + + foreach ($config['fetchers']['buffers'] as $name => $fetcher) { + if (!isset($fetchers[$fetcher['wrap']])) { + throw new \InvalidArgumentException(sprintf('The fetcher named "%s" could not wrap "%s" as it does not exist.', $name, $fetcher['wrap'])); + } + $id = "worker.message_fetcher.buffer.$name"; + $container + ->register($id, Worker\MessageFetcher\BufferedMessageFetcher::class) + ->addArgument(new Reference($fetchers[$fetcher['wrap']])) + ->addArgument(array( + 'max_buffering_time' => $fetcher['max_buffering_time'], + 'max_messages' => $fetcher['max_messages'], + )) + ->setPublic(false) + ; + $fetchers[$name] = $id; + } + + $routers = array(); + + foreach ($config['routers']['directs'] as $name => $router) { + if (isset($routers[$name])) { + throw new \InvalidArgumentException(sprintf('A router named "%s" already exist.', $name)); + } + if (!isset($fetchers[$router['fetcher']])) { + throw new \InvalidArgumentException(sprintf('The router named "%s" could not use fetcher "%s" as it does not exist.', $name, $router['fetcher'])); + } + $id = "worker.router.direct.$name"; + $container + ->register($id, Worker\Router\DirectRouter::class) + ->addArgument(new Reference($fetchers[$router['fetcher']])) + ->addArgument(new Reference($router['consumer'])) + ->setPublic(false) + ; + + $routers[$name] = $id; + } + + foreach ($config['routers']['round_robins'] as $name => $router) { + $wrappedRouters = array(); + foreach ($router['groups'] as $wrappedRouter) { + if (!isset($routers[$wrappedRouter])) { + throw new \InvalidArgumentException(sprintf('The router named "%s" could not use "%s" as it does not exist.', $name, $wrappedRouter)); + } + $wrappedRouters[] = new Reference($routers[$wrappedRouter]); + } + $id = "worker.router.round_robin.$name"; + + $container + ->register($id, Worker\Router\RoundRobinRouter::class) + ->addArgument($wrappedRouters) + ->addArgument($router['consume_everything']) + ->setPublic(false) + ; + $routers[$name] = $id; + } + + $workers = array(); + + foreach ($config['workers'] as $name => $worker) { + if (isset($worker['router'])) { + if (!isset($routers[$worker['router']])) { + throw new \InvalidArgumentException(sprintf('The worker named "%s" could not use router "%s" as it does not exist.', $name, $worker['router'])); + } + $router = new Reference($routers[$worker['router']]); + } elseif ($worker['fetcher']) { + if (!isset($fetchers[$worker['fetcher']])) { + throw new \InvalidArgumentException(sprintf('The worker named "%s" could not use fetcher "%s" as it does not exist.', $name, $worker['fetcher'])); + } + $router = new Definition(Worker\Router\DirectRouter::class); + $router->addArgument(new Reference($fetchers[$worker['fetcher']])); + $router->addArgument(new Reference($worker['consumer'])); + $router->setPublic(false); + } + + $id = "worker.worker.$name"; + $container + ->register($id, Worker\Loop\Loop::class) + ->addArgument($router) + ->addArgument(new Reference('event_dispatcher', ContainerInterface::NULL_ON_INVALID_REFERENCE)) + ->addArgument(new Reference('logger', ContainerInterface::NULL_ON_INVALID_REFERENCE)) + ->addArgument($name) + ; + + $workers[$name] = new TypedReference($id, Worker\Loop\Loop::class); + } + + $container->getDefinition('worker.command.list')->replaceArgument(0, $workerNames = array_keys($workers)); + $container->getDefinition('worker.worker_locator')->replaceArgument(0, $workers); + $container + ->getDefinition('worker.command.run') + ->replaceArgument(1, trim($config['cli_title_prefix'], '_')) + ->replaceArgument(2, $workerNames); + } + /** * Loads the security configuration. * diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/amqp.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/amqp.xml new file mode 100644 index 0000000000000..a698632011c18 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/amqp.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd b/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd index d6138133cf2ea..d6a8fdcc84eec 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd @@ -29,6 +29,8 @@ + + @@ -294,4 +296,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/worker.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/worker.xml new file mode 100644 index 0000000000000..3dfc4f54c9d89 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/worker.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php index 0d2578db040af..0e87910bd2f76 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php @@ -339,6 +339,19 @@ protected static function getBundleDefaultConfig() 'web_link' => array( 'enabled' => !class_exists(FullStack::class), ), + 'worker' => array( + 'fetchers' => array( + 'amqps' => array(), + 'buffers' => array(), + 'services' => array(), + ), + 'routers' => array( + 'directs' => array(), + 'round_robins' => array(), + ), + 'workers' => array(), + 'cli_title_prefix' => 'app', + ), ); } } diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/amqp_empty.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/amqp_empty.php new file mode 100644 index 0000000000000..c4aeee120d63e --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/amqp_empty.php @@ -0,0 +1,6 @@ +loadFromExtension('framework', array( + 'amqp' => array( + ), +)); diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/amqp_full.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/amqp_full.php new file mode 100644 index 0000000000000..f177d58ac95bf --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/amqp_full.php @@ -0,0 +1,37 @@ +loadFromExtension('framework', array( + 'amqp' => array( + 'connections' => array( + 'queue_staging' => array( + 'url' => 'amqp://foo:baz@rabbitmq:1234/staging', + ), + 'queue_prod' => array( + 'url' => 'amqp://foo:bar@rabbitmq:1234/prod', + 'queues' => array( + array( + 'name' => 'retry_strategy_exponential', + 'retry_strategy' => 'exponential', + 'retry_strategy_options' => array('offset' => 1, 'max' => 3), + ), + array( + 'name' => 'arguments', + 'arguments' => array( + 'routing_keys' => 'my_routing_key', + 'flags' => 2, + ), + ), + ), + 'exchanges' => array( + array( + 'name' => 'headers', + 'arguments' => array( + 'type' => 'headers', + ), + ), + ), + ), + ), + 'default_connection' => 'queue_prod', + ), +)); diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/worker_empty.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/worker_empty.php new file mode 100644 index 0000000000000..22afb753af3ca --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/worker_empty.php @@ -0,0 +1,6 @@ +loadFromExtension('framework', array( + 'worker' => array( + ), +)); diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/worker_full.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/worker_full.php new file mode 100644 index 0000000000000..7089d144eca28 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/worker_full.php @@ -0,0 +1,171 @@ +loadFromExtension('framework', array( + 'amqp' => array( + 'connections' => array( + 'default' => array(), + 'another_one' => array(), + ), + ), + 'worker' => array( + 'cli_title_prefix' => 'foobar', + ), +)); + +/* worker.fetcher.amqp */ +$container->loadFromExtension('framework', array( + 'worker' => array( + 'fetchers' => array( + 'amqps' => array( + 'queue_a' => null, + 'queue_b' => array(), + 'queue_c_1' => array( + 'queue_name' => 'queue_c', + ), + ), + ), + ), +)); +$container->loadFromExtension('framework', array( + 'worker' => array( + 'fetchers' => array( + 'amqps' => array( + 'queue_d_1' => array( + 'name' => 'queue_d', + ), + 'queue_e (key not used)' => array( + 'name' => 'queue_e', + 'queue_name' => 'queue_e', + ), + 'queue_f' => array( + 'connection' => 'another_one', + 'auto_ack' => true, + ), + ), + ), + ), +)); + +/* worker.fetcher.service */ +$container->loadFromExtension('framework', array( + 'worker' => array( + 'fetchers' => array( + 'services' => array( + 'service_a' => null, + 'service_b' => array(), + 'service_c_1' => array( + 'service' => 'service_c', + ), + ), + ), + ), +)); +$container->loadFromExtension('framework', array( + 'worker' => array( + 'fetchers' => array( + 'services' => array( + 'service_d_1' => array( + 'name' => 'service_d', + ), + 'service_e (key not used)' => array( + 'name' => 'service_e', + 'service' => 'service_e', + ), + ), + ), + ), +)); + +/* worker.fetcher.buffer */ +$container->loadFromExtension('framework', array( + 'worker' => array( + 'fetchers' => array( + 'buffers' => array( + 'queue_a' => null, + 'queue_b' => array(), + 'queue_c' => array( + 'wrap' => 'queue_c_1', + ), + ), + ), + ), +)); +$container->loadFromExtension('framework', array( + 'worker' => array( + 'fetchers' => array( + 'buffers' => array( + 'queue_d (key not used)' => array( + 'name' => 'queue_d_1', + 'wrap' => 'queue_d', + ), + 'queue_e' => array( + 'max_messages' => 12, + 'max_buffering_time' => 60, + ), + 'service_a' => null, + ), + ), + ), +)); + +/* worker.router.direct */ +$container->loadFromExtension('framework', array( + 'worker' => array( + 'routers' => array( + 'directs' => array( + 'queue_a' => array( + 'consumer' => 'a_consumer_service', + ), + 'queue_b_1' => array( + 'consumer' => 'a_consumer_service', + 'name' => 'queue_b', + ), + 'queue_c (key is not used)' => array( + 'consumer' => 'a_consumer_service', + 'fetcher' => 'queue_c', + 'name' => 'router_c', + ), + 'router_d' => array( + 'consumer' => 'a_consumer_service', + 'fetcher' => 'queue_d', + ), + ), + ), + ), +)); +$container->loadFromExtension('framework', array( + 'worker' => array( + 'routers' => array( + 'round_robins' => array( + 'router_c_and_d' => array( + 'groups' => array('router_c', 'router_d'), + ), + ), + ), + ), +)); + +/* worker.router.direct */ +$container->loadFromExtension('framework', array( + 'worker' => array( + 'workers' => array( + 'worker_d' => array( + 'router' => 'router_d', + ), + ), + ), +)); +$container->loadFromExtension('framework', array( + 'worker' => array( + 'workers' => array( + 'worker_service_a' => array( + 'fetcher' => 'service_a', + 'consumer' => 'a_consumer_service', + ), + ), + ), +)); diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/amqp_empty.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/amqp_empty.xml new file mode 100644 index 0000000000000..ce539158af084 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/amqp_empty.xml @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/amqp_full.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/amqp_full.xml new file mode 100644 index 0000000000000..3da97e21fd7d7 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/amqp_full.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/worker_empty.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/worker_empty.xml new file mode 100644 index 0000000000000..779ccd4c0f390 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/worker_empty.xml @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/worker_full.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/worker_full.xml new file mode 100644 index 0000000000000..b55b5d83ac6f0 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/worker_full.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + router_c + router_d + + + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/amqp_empty.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/amqp_empty.yml new file mode 100644 index 0000000000000..099df455ba565 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/amqp_empty.yml @@ -0,0 +1,2 @@ +framework: + amqp: ~ diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/amqp_full.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/amqp_full.yml new file mode 100644 index 0000000000000..762f8fd77e26d --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/amqp_full.yml @@ -0,0 +1,21 @@ +framework: + amqp: + connections: + queue_staging: + url: amqp://foo:baz@rabbitmq:1234/staging + + queue_prod: + url: amqp://foo:bar@rabbitmq:1234/prod + queues: + - name: retry_strategy_exponential + retry_strategy: exponential + retry_strategy_options: { offset: 1 , max: 3 } + - name: arguments + arguments: + routing_keys: my_routing_key + flags: 2 + exchanges: + - name: headers + arguments: + type: headers + default_connection: queue_prod diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/worker_empty.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/worker_empty.yml new file mode 100644 index 0000000000000..54277214b4fb4 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/worker_empty.yml @@ -0,0 +1,2 @@ +framework: + worker: diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/worker_full.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/worker_full.yml new file mode 100644 index 0000000000000..f0b48f7368956 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/worker_full.yml @@ -0,0 +1,74 @@ +# AMQP need to be enabled in order to use AMQP Fetcher +framework: + amqp: + connections: + default: ~ + another_one: ~ + + worker: + cli_title_prefix: foobar + + fetchers: + amqps: + queue_a: ~ + queue_b: {} + queue_c_1: + queue_name: queue_c + queue_d_1: + name: queue_d + queue_e (key not used): + name: queue_e + queue_name: queue_e + queue_f: + connection: another_one + auto_ack: true + + services: + service_a: ~ + service_b: {} + service_c_1: + service: service_c + service_d_1: + name: service_d + service_e (key not used): + name: service_e + service: service_e + + buffers: + queue_a: ~ + queue_b: {} + queue_c: + wrap: queue_c_1 + queue_d (key not used): + name: queue_d_1 + wrap: queue_d + queue_e: + max_messages: 12 + max_buffering_time: 60 + service_a: ~ + + routers: + directs: + queue_a: + consumer: a_consumer_service + queue_b_1: + consumer: a_consumer_service + name: queue_b + queue_c (key is not used): + consumer: a_consumer_service + fetcher: queue_c + name: router_c + router_d: + consumer: a_consumer_service + fetcher: queue_d + + round_robins: + router_c_and_d: + groups: [router_c, router_d] + + workers: + worker_d: + router: router_d + worker_service_a: + fetcher: service_a + consumer: a_consumer_service diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php index 10fbdbf5363c8..f79b0eb095334 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php @@ -30,6 +30,7 @@ use Symfony\Component\DependencyInjection\Loader\ClosureLoader; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag; use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\DependencyInjection\TypedReference; use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\PropertyAccess\PropertyAccessor; use Symfony\Component\Serializer\Mapping\Loader\AnnotationLoader; @@ -42,6 +43,7 @@ use Symfony\Component\Serializer\Normalizer\JsonSerializableNormalizer; use Symfony\Component\Translation\DependencyInjection\TranslatorPass; use Symfony\Component\Validator\DependencyInjection\AddConstraintValidatorsPass; +use Symfony\Component\Worker\Loop\Loop; abstract class FrameworkExtensionTest extends TestCase { @@ -971,6 +973,186 @@ public function testCachePoolServices() $this->assertCachePoolServiceDefinitionIsCreated($container, 'cache.def', 'cache.app', 11); } + public function testAmqpEmpty() + { + $container = $this->createContainerFromFile('amqp_empty'); + + $this->assertTrue($container->hasDefinition('amqp.broker.default')); + $this->assertSame(\Symfony\Component\Amqp\Broker::class, $container->getDefinition('amqp.broker.default')->getClass()); + $this->assertSame('amqp://guest:guest@localhost:5672/symfony', $container->getDefinition('amqp.broker.default')->getArgument(0)); + + $this->assertTrue($container->hasAlias('amqp.broker')); + $this->assertSame('amqp.broker.default', (string) $container->getAlias('amqp.broker')); + $this->assertEquals(new Reference('amqp.broker'), $container->getDefinition('amqp.command.move')->getArgument(0)); + } + + public function testAmqpFull() + { + $container = $this->createContainerFromFile('amqp_full'); + + $this->assertTrue($container->hasDefinition('amqp.broker.queue_staging')); + $this->assertSame(\Symfony\Component\Amqp\Broker::class, $container->getDefinition('amqp.broker.queue_staging')->getClass()); + $this->assertSame('amqp://foo:baz@rabbitmq:1234/staging', $container->getDefinition('amqp.broker.queue_staging')->getArgument(0)); + + $this->assertTrue($container->hasDefinition('amqp.broker.queue_prod')); + $this->assertSame(\Symfony\Component\Amqp\Broker::class, $container->getDefinition('amqp.broker.queue_prod')->getClass()); + $this->assertSame('amqp://foo:bar@rabbitmq:1234/prod', $container->getDefinition('amqp.broker.queue_prod')->getArgument(0)); + $queueConfiguration = array( + array( + 'name' => 'retry_strategy_exponential', + 'retry_strategy' => 'exponential', + 'retry_strategy_options' => array( + 'offset' => 1, + 'max' => 3, + ), + 'arguments' => array(), + 'thresholds' => array( + 'warning' => null, + 'critical' => null, + ), + ), + array( + 'name' => 'arguments', + 'arguments' => array( + 'routing_keys' => 'my_routing_key', + 'flags' => 2, + ), + 'retry_strategy' => null, + 'thresholds' => array( + 'warning' => null, + 'critical' => null, + ), + ), + ); + $this->assertEquals($queueConfiguration, $container->getDefinition('amqp.broker.queue_prod')->getArgument(1)); + $exchangeConfiguration = array( + array( + 'name' => 'headers', + 'arguments' => array( + 'type' => 'headers', + ), + ), + ); + $this->assertSame($exchangeConfiguration, $container->getDefinition('amqp.broker.queue_prod')->getArgument(2)); + + $this->assertTrue($container->hasAlias('amqp.broker')); + $this->assertSame('amqp.broker.queue_prod', (string) $container->getAlias('amqp.broker')); + $this->assertEquals(new Reference('amqp.broker'), $container->getDefinition('amqp.command.move')->getArgument(0)); + } + + public function testWorkerEmpty() + { + $container = $this->createContainerFromFile('worker_empty'); + + $this->assertSame(array(), $container->getDefinition('worker.command.list')->getArgument(0)); + } + + public function testWorkerFull() + { + $container = $this->createContainerFromFile('worker_full'); + + /* workers.fetchers.amqp */ + $assertFetcherAmqp = function (ContainerBuilder $container, $id, $queueName, $connection = 'amqp.broker') { + $this->assertTrue($container->hasDefinition("worker.message_fecher.amqp.$id")); + $fetcher = $container->getDefinition("worker.message_fecher.amqp.$id"); + $this->assertInstanceOf(Reference::class, $fetcher->getArgument(0)); + $this->assertSame($connection, (string) $fetcher->getArgument(0)); + $this->assertSame($queueName, $fetcher->getArgument(1)); + }; + $assertFetcherAmqp($container, 'queue_a', 'queue_a'); + $assertFetcherAmqp($container, 'queue_b', 'queue_b'); + $assertFetcherAmqp($container, 'queue_c_1', 'queue_c'); + $assertFetcherAmqp($container, 'queue_d', 'queue_d'); + $assertFetcherAmqp($container, 'queue_e', 'queue_e'); + $assertFetcherAmqp($container, 'queue_f', 'queue_f', 'amqp.broker.another_one'); + + /* workers.fetchers.service */ + $assertFetcherService = function (ContainerBuilder $container, $id, $service) { + $this->assertTrue($container->hasAlias("worker.message_fecher.service.$id")); + $fetcher = $container->getAlias("worker.message_fecher.service.$id"); + $this->assertSame($service, (string) $fetcher); + }; + $assertFetcherService($container, 'service_a', 'service_a'); + $assertFetcherService($container, 'service_b', 'service_b'); + $assertFetcherService($container, 'service_c_1', 'service_c'); + $assertFetcherService($container, 'service_d', 'service_d'); + $assertFetcherService($container, 'service_e', 'service_e'); + + /* workers.fetchers.buffer */ + $assertFetcherBuffer = function (ContainerBuilder $container, $id, $wrap) { + $this->assertTrue($container->hasDefinition("worker.message_fecher.buffer.$id")); + $fetcher = $container->getDefinition("worker.message_fecher.buffer.$id"); + $this->assertInstanceOf(Reference::class, $fetcher->getArgument(0)); + $this->assertSame("worker.message_fecher.$wrap", (string) $fetcher->getArgument(0)); + }; + $assertFetcherBuffer($container, 'queue_a', 'amqp.queue_a'); + $assertFetcherBuffer($container, 'queue_b', 'amqp.queue_b'); + $assertFetcherBuffer($container, 'queue_c', 'amqp.queue_c_1'); + $assertFetcherBuffer($container, 'queue_d_1', 'amqp.queue_d'); + $assertFetcherBuffer($container, 'queue_e', 'amqp.queue_e'); + $assertFetcherBuffer($container, 'service_a', 'service.service_a'); + + /* workers.routers.direct */ + $assertWorkerDirect = function (ContainerBuilder $container, $id, $fetcher) { + $this->assertTrue($container->hasDefinition("worker.router.direct.$id")); + $router = $container->getDefinition("worker.router.direct.$id"); + $this->assertInstanceOf(Reference::class, $router->getArgument(0)); + $this->assertSame("worker.message_fecher.$fetcher", (string) $router->getArgument(0)); + $this->assertInstanceOf(Reference::class, $router->getArgument(1)); + $this->assertSame('a_consumer_service', (string) $router->getArgument(1)); + }; + $assertWorkerDirect($container, 'queue_a', 'buffer.queue_a'); // "buffer" and not "amqp" because the buffer fetcher replace the original fetcher + $assertWorkerDirect($container, 'queue_b', 'buffer.queue_b'); // "buffer" and not "amqp" because the buffer fetcher replace the original fetcher + $assertWorkerDirect($container, 'router_c', 'buffer.queue_c'); // "buffer" and not "amqp" because the buffer fetcher replace the original fetcher + $assertWorkerDirect($container, 'router_d', 'amqp.queue_d'); + + /* workers.routers.round_robin */ + $this->assertTrue($container->hasDefinition('worker.router.round_robin.router_c_and_d')); + $router = $container->getDefinition('worker.router.round_robin.router_c_and_d'); + $routers = $router->getArgument(0); + $this->assertCount(2, $routers); + $this->assertInstanceOf(Reference::class, $routers[0]); + $this->assertSame('worker.router.direct.router_c', (string) $routers[0]); + $this->assertInstanceOf(Reference::class, $routers[1]); + $this->assertSame('worker.router.direct.router_d', (string) $routers[1]); + + /* workers.workers */ + $this->assertTrue($container->hasDefinition('worker.worker.worker_d')); + $worker = $container->getDefinition('worker.worker.worker_d'); + $this->assertInstanceOf(Reference::class, $worker->getArgument(0)); + $this->assertSame('worker.router.direct.router_d', (string) $worker->getArgument(0)); + $this->assertInstanceOf(Reference::class, $worker->getArgument(1)); + $this->assertSame('event_dispatcher', (string) $worker->getArgument(1)); + $this->assertInstanceOf(Reference::class, $worker->getArgument(2)); + $this->assertSame('logger', (string) $worker->getArgument(2)); + $this->assertSame('worker_d', $worker->getArgument(3)); + + $this->assertTrue($container->hasDefinition('worker.worker.worker_service_a')); + $worker = $container->getDefinition('worker.worker.worker_service_a'); + $this->assertInstanceOf(Definition::class, $worker->getArgument(0)); + $router = $worker->getArgument(0); + $this->assertInstanceOf(Reference::class, $router->getArgument(0)); + $this->assertSame('worker.message_fecher.buffer.service_a', (string) $router->getArgument(0)); + $this->assertInstanceOf(Reference::class, $router->getArgument(1)); + $this->assertSame('a_consumer_service', (string) $router->getArgument(1)); + $this->assertInstanceOf(Reference::class, $worker->getArgument(1)); + $this->assertSame('event_dispatcher', (string) $worker->getArgument(1)); + $this->assertInstanceOf(Reference::class, $worker->getArgument(2)); + $this->assertSame('logger', (string) $worker->getArgument(2)); + $this->assertSame('worker_service_a', $worker->getArgument(3)); + $workerLocator = $container->getDefinition('worker.worker_locator'); + $this->assertEquals(array('worker_d' => new TypedReference('worker.worker.worker_d', Loop::class), 'worker_service_a' => new TypedReference('worker.worker.worker_service_a', Loop::class)), $workerLocator->getArgument(0)); + + /* worker:list command */ + $this->assertSame(array('worker_d', 'worker_service_a'), $container->getDefinition('worker.command.list')->getArgument(0)); + + /* worker:run command */ + $workerRunCommand = $container->getDefinition('worker.command.run'); + $this->assertEquals(new Reference('worker.worker_locator'), $workerRunCommand->getArgument(0)); + $this->assertEquals('foobar', $workerRunCommand->getArgument(1), 'worker:run expects the "worker.cli_title_prefix" config value as 2nd argument'); + $this->assertSame(array('worker_d', 'worker_service_a'), $workerRunCommand->getArgument(2)); + } + protected function createContainer(array $data = array()) { return new ContainerBuilder(new ParameterBag(array_merge(array( diff --git a/src/Symfony/Component/Amqp/Broker.php b/src/Symfony/Component/Amqp/Broker.php new file mode 100644 index 0000000000000..d529d74934b53 --- /dev/null +++ b/src/Symfony/Component/Amqp/Broker.php @@ -0,0 +1,831 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Amqp; + +use Enqueue\AmqpTools\DelayStrategyAware; +use Enqueue\AmqpTools\RabbitMqDlxDelayStrategy; +use Interop\Amqp\AmqpConsumer; +use Interop\Amqp\AmqpContext; +use Interop\Amqp\AmqpTopic; +use Interop\Amqp\AmqpMessage; +use Interop\Amqp\AmqpQueue; +use Interop\Amqp\Impl\AmqpBind; +use Symfony\Component\Amqp\Exception\InvalidArgumentException; +use Symfony\Component\Amqp\Exception\LogicException; +use Symfony\Component\Amqp\Exception\NonRetryableException; +use Symfony\Component\Amqp\RetryStrategy\ConstantRetryStrategy; +use Symfony\Component\Amqp\RetryStrategy\ExponentialRetryStrategy; +use Symfony\Component\Amqp\RetryStrategy\RetryStrategyInterface; + +class Broker +{ + const DEFAULT_EXCHANGE = 'symfony.default'; + const DEAD_LETTER_EXCHANGE = 'symfony.dead_letter'; + const RETRY_EXCHANGE = 'symfony.retry'; + + /** + * @var AmqpContext + */ + private $context; + + private $queuesConfiguration = array(); + private $exchangesConfiguration = array(); + private $exchanges = array(); + private $queues = array(); + + /** + * @var AmqpConsumer[] + */ + private $queueConsumers = array(); + + /** + * @var string[] + */ + private $retryStrategies = array(); + + /** + * @var string[] + */ + private $retryStrategyQueuePatterns = array(); + private $queuesBindings = array(); + + private $exchangeBindings = array(); + + /** + * @param AmqpContext $context An AmqpContext instance + * @param array $queuesConfiguration A collection of queue configurations + * @param array $exchangesConfiguration A collection of exchange configurations + * + * example of $queuesConfiguration: + * array( + * array( + * 'name' => 'project.created', + * 'arguments' => array(), // array, passed to Queue constructor + * 'retry_strategy' => null, // null, 'exponential', 'constant' + * 'retry_strategy_options' => array(), // array, passed to the Strategy constructor + * 'thresholds' => array('warning' => null, 'critical' => null), + * ) + * ) + * + * example of $exchangesConfiguration: + * array( + * array( + * 'name' => 'fanout' + * 'arguments' => array(), // array, passed to Exchange constructor + * ) + * ) + */ + public function __construct(AmqpContext $context, array $queuesConfiguration = array(), array $exchangesConfiguration = array()) + { + $this->context = $context; + + $this->setQueuesConfiguration($queuesConfiguration); + $this->setExchangesConfiguration($exchangesConfiguration); + + // Force the creation of this special exchange. It can not be lazy loaded as + // it is needed for the retry workflow because all queues are bound to it. + $this->getOrCreateExchange(self::RETRY_EXCHANGE); + } + + /** + * Returns arrays of configuration by queue name. + * + * @return array[] + */ + public function getQueuesConfiguration() + { + return $this->queuesConfiguration; + } + + /** + * Disconnects from AMQP and clears all parameters excepted configurations. + */ + public function disconnect() + { + $this->context->close(); + } + + /** + * Creates a new Exchange. + * + * Special arguments: See the Exchange constructor. + * + * @param string $name + * @param array $arguments + * + * @return AmqpTopic + */ + public function createExchange($name, array $arguments = array()) + { + $topic = $this->context->createTopic($name); + + if (Broker::DEAD_LETTER_EXCHANGE === $name) { + $topic->setType(AmqpTopic::TYPE_HEADERS); + unset($arguments['type']); + } elseif (Broker::RETRY_EXCHANGE === $name) { + $topic->setType(AmqpTopic::TYPE_DIRECT); + unset($arguments['type']); + } elseif (isset($arguments['type'])) { + $topic->setType($arguments['type']); + unset($arguments['type']); + } else { + $topic->setType(AmqpTopic::TYPE_DIRECT); + } + + if (isset($arguments['flags'])) { + $topic->setFlags($arguments['flags']); + unset($arguments['flags']); + } else { + $topic->addFlag(AmqpTopic::FLAG_DURABLE); + } + + $topic->setArguments($arguments); + + $this->context->declareTopic($topic); + + return $this->exchanges[$name] = $topic; + } + + /** + * @param string $name + * + * @return AmqpTopic + */ + public function getExchange($name) + { + if (!isset($this->exchanges[$name])) { + if (!isset($this->exchangesConfiguration[$name])) { + throw new InvalidArgumentException(sprintf('Exchange "%s" does not exist.', $name)); + } + $this->createExchangeFromConfiguration($this->exchangesConfiguration[$name]); + } + + return $this->exchanges[$name]; + } + + /** + * Sets or replaces the given exchange if its name is already known. + * + * @param AmqpTopic $exchange + */ + public function addExchange(AmqpTopic $exchange) + { + $this->exchanges[$exchange->getTopicName()] = $exchange; + } + + /** + * Creates a new Queue. + * + * Special arguments: See the Queue constructor. + * + * @param string $name Queue name + * @param array $arguments Queue constructor arguments + * @param bool $declare True by default, the Queue will be bound to the current broker + * + * @return AmqpQueue + */ + public function createQueue($name, array $arguments = array(), $declare = true) + { + $amqpQueue = $this->context->createQueue($name); + + if (isset($arguments['exchange'])) { + $this->getOrCreateExchange($arguments['exchange']); + } else { + $this->getOrCreateExchange(self::DEFAULT_EXCHANGE); + } + + if (array_key_exists('routing_keys', $arguments)) { + $routingKeys = $arguments['routing_keys']; + if (is_string($routingKeys)) { + $routingKeys = array($routingKeys); + } + if (!is_array($routingKeys) && null !== $routingKeys && false !== $routingKeys) { + throw new InvalidArgumentException(sprintf('"routing_keys" option should be a string, false, null or an array of string, "%s" given.', gettype($routingKeys))); + } + + unset($arguments['routing_keys']); + } else { + $routingKeys = array($name); + } + + if (isset($arguments['flags'])) { + $amqpQueue->setFlags($arguments['flags']); + unset($arguments['flags']); + } else { + $amqpQueue->setFlags(AmqpQueue::FLAG_DURABLE); + } + + if (isset($arguments['exchange'])) { + $exchange = $arguments['exchange']; + unset($arguments['exchange']); + } else { + $exchange = Broker::DEFAULT_EXCHANGE; + } + + if (array_key_exists('retry_strategy', $arguments)) { + $retryStrategy = $arguments['retry_strategy']; + if (!$retryStrategy instanceof RetryStrategyInterface) { + throw new InvalidArgumentException('The retry_strategy should be an instance of RetryStrategyInterface.'); + } + + $this->retryStrategies[$name] = $retryStrategy; + unset($arguments['retry_strategy']); + } + + if (array_key_exists('retry_strategy_queue_pattern', $arguments)) { + $this->retryStrategyQueuePatterns[$name] = $arguments['retry_strategy_queue_pattern']; + unset($arguments['retry_strategy_queue_pattern']); + } else { + $this->retryStrategyQueuePatterns[$name] = '%exchange%.%time%.wait'; + } + + if (isset($arguments['bind_arguments'])) { + $bindArguments = $arguments['bind_arguments']; + unset($arguments['bind_arguments']); + } else { + $bindArguments = array(); + } + + $amqpQueue->setArguments($arguments); + + if (null === $routingKeys) { + $bindingConfig = [ + 'queue' => $name, + 'exchange' => $exchange, + 'routing_key' => null, + 'bind_arguments' => $bindArguments, + ]; + + $this->queuesBindings[$name][] = $bindingConfig; + $this->exchangeBindings[$exchange][] = $bindingConfig; + } elseif (is_array($routingKeys)) { + + foreach ($routingKeys as $routingKey) { + $bindingConfig = [ + 'queue' => $name, + 'exchange' => $exchange, + 'routing_key' => $routingKey, + 'bind_arguments' => $bindArguments, + ]; + + $this->queuesBindings[$name][] = $bindingConfig; + $this->exchangeBindings[$exchange] = $bindingConfig; + } + } + + // Special binding: Bind this queue, with its name as the routing key + // with the retry exchange in order to have a nice retry workflow. + $bindingConfig = [ + 'queue' => $name, + 'exchange' => Broker::RETRY_EXCHANGE, + 'routing_key' => $name, + 'bind_arguments' => $bindArguments, + ]; + $this->queuesBindings[$name][] = $bindingConfig; + $this->exchangeBindings[Broker::RETRY_EXCHANGE][] = $bindingConfig; + + if ($declare) { + $this->context->declareQueue($amqpQueue); + + foreach ($this->queuesBindings[$name] as $config) { + $amqpTopic = $this->getExchange($config['exchange']); + + $this->context->bind(new AmqpBind( + $amqpTopic, + $amqpQueue, + $config['routing_key'], + AmqpBind::FLAG_NOPARAM, + $config['bind_arguments'] + )); + } + } + + $this->queues[$name] = $amqpQueue; + + return $amqpQueue; + } + + /** + * Returns a Queue for its given name. + * + * @param string $name + * + * @return AmqpQueue + */ + public function getQueue($name) + { + if (!isset($this->queues[$name])) { + if (!isset($this->queuesConfiguration[$name])) { + throw new InvalidArgumentException(sprintf('Queue "%s" does not exist.', $name)); + } + $this->createQueueFromConfiguration($this->queuesConfiguration[$name]); + } + + return $this->queues[$name]; + } + + /** + * Returns whether a Queue has a retry strategy or not. + * + * @param string $queueName + * + * @return bool + */ + public function hasRetryStrategy($queueName) + { + return isset($this->retryStrategies[$queueName]); + } + + /** + * Publishes a new message. + * + * Special attributes: + * + * * flags: if set, will be used during the Exchange::publish call + * * exchange: The exchange name to use ("symfony.default" by default) + * + * @param string $routingKey + * @param string $message + * @param array $attributes + * + * @return bool True is the message was published, false otherwise + */ + public function publish($routingKey, $message, array $attributes = array()) + { + $amqpMessage = $this->context->createMessage($message); + + if (isset($attributes['flags'])) { + $amqpMessage->setFlags($attributes['flags']); + + unset($attributes['flags']); + } else { + $amqpMessage->addFlag(AmqpMessage::FLAG_MANDATORY); + } + + if (isset($attributes['exchange'])) { + $exchangeName = $attributes['exchange']; + unset($attributes['exchange']); + } else { + $exchangeName = self::DEFAULT_EXCHANGE; + } + + if (isset($attributes['headers'])) { + $amqpMessage->setProperties($attributes['headers']); + unset($attributes['headers']); + } + + $amqpMessage->setHeaders($attributes); + + // Force Exchange creation if needed + $topic = $this->getOrCreateExchange($exchangeName); + + // Force Queue creation if needed + if ($this->shouldCreateQueue($topic, $routingKey)) { + $this->lazyLoadQueues($topic, $routingKey); + } + + $amqpMessage->setRoutingKey($routingKey); + $amqpMessage->setDeliveryMode(AmqpMessage::DELIVERY_MODE_PERSISTENT); + + $producer = $this->context->createProducer(); + + if (isset($attributes['delay']) && $producer instanceof DelayStrategyAware) { + $producer + ->setDelayStrategy(new RabbitMqDlxDelayStrategy()) + ->setDeliveryDelay($attributes['delay'] * 1000) + ; + } + + $producer->send($topic, $amqpMessage); + + return true; + } + + /** + * Sends a message with delay. + * + * The message is stored in a pending queue before it's in the expected + * target. + * + * If the target queue is not created, it will be created with default + * configuration. + * + * @param string $routingKey + * @param string $message + * @param int $delay Delay in seconds + * @param array $attributes See the publish method + * + * @return bool + */ + public function delay($routingKey, $message, $delay, array $attributes = array()) + { + $attributes['delay'] = $delay; + + return $this->publish($routingKey, $message, $attributes); + } + + /** + * Consumes a Queue for its given name. + * + * @param string $name + * @param callable|null $callback + * @param int $flags + * @param string|null $consumerTag + */ + public function consume($name, $callback = null, $flags = AmqpConsumer::FLAG_NOPARAM, $consumerTag = null) + { + $consumer = $this->getQueueConsumer($name); + $consumer->setConsumerTag($consumerTag); + $consumer->setFlags($flags); + + while (true) { + if ($message = $consumer->receive(1000)) { + if (false === call_user_func($callback, $message, $consumer)) { + return; + } + } + } + } + + /** + * Gets an Envelope from a Queue by its given name. + * + * @param string $name The queue name + * @param int $flags + * + * @return AmqpMessage|null + */ + public function get($name, $flags = AmqpConsumer::FLAG_NOPARAM) + { + $consumer = $this->getQueueConsumer($name); + $consumer->setFlags($flags); + + return $consumer->receiveNoWait(); + } + + /** + * WARNING: This shortcut only works when using the conventions + * where the queue and the routing queue have the same name. + * + * If it's not the case, you MUST specify the queueName. + * + * @param AmqpMessage $message + * @param string|null $queueName + * + * @return bool + */ + public function ack(AmqpMessage $message, $queueName = null) + { + $queueName = $queueName ?: $message->getRoutingKey(); + + $this->getQueueConsumer($queueName)->acknowledge($message); + } + + /** + * WARNING: This shortcut only works when using the conventions + * where the queue and the routing queue have the same name. + * + * If it's not the case, you MUST specify the queueName. + * + * @param AmqpMessage $message + * @param string|null $queueName + * + * @return bool + */ + public function nack(AmqpMessage $message, $queueName = null, $requeue = false) + { + $queueName = $queueName ?: $message->getRoutingKey(); + + $this->getQueueConsumer($queueName)->reject($message, $requeue); + } + + /** + * WARNING: This shortcut only works when using the conventions + * where the queue and the routing queue have the same name. + * + * If it's not the case, you MUST specify the queueName. + * + * @param AmqpMessage $amqpMessage + * @param string|null $queueName + * @param string|null $retryMessage + * + * @return bool + */ + public function retry(AmqpMessage $amqpMessage, $queueName = null, $retryMessage = null) + { + $queueName = $queueName ?: $amqpMessage->getRoutingKey(); + + if (!$this->hasRetryStrategy($queueName)) { + throw new LogicException(sprintf('The queue "%s" has no retry strategy.', $queueName)); + } + + $retryStrategy = $this->retryStrategies[$queueName]; + + if (!$retryStrategy->isRetryable($amqpMessage)) { + throw new NonRetryableException($retryStrategy, $amqpMessage); + } + + $time = $retryStrategy->getWaitingTime($amqpMessage); + + $this->createDelayedQueue($queueName, $time); + + // Copy previous headers, but omit x-death + $headers = $amqpMessage->getHeaders(); + unset($headers['x-death']); + $headers['queue-time'] = (string) $time; + $headers['exchange'] = (string) self::RETRY_EXCHANGE; + $headers['retries'] = $amqpMessage->getHeader('retries') + 1; + + // Some RabbitMQ versions fail when $message is null + // + if a message already exists, we want to keep it. + if (null !== $retryMessage) { + $headers['retry-message'] = $retryMessage; + } + + return $this->publish($queueName, $amqpMessage->getBody(), array( + 'exchange' => self::DEAD_LETTER_EXCHANGE, + 'headers' => $headers, + )); + } + + /** + * Moves a message to a given route. + * + * If attributes are given as third argument they will override the + * message ones. + * + * @param AmqpMessage $msg + * @param string $routingKey + * @param array $attributes + * + * @return bool + */ + public function move(AmqpMessage $msg, $routingKey, $attributes) + { + $attributes = array_replace($msg->getHeaders(), $attributes); + + return $this->publish($routingKey, $msg->getBody(), $attributes); + } + + /** + * @param AmqpMessage $msg + * @param array $attributes + * + * @return bool + */ + public function moveToDeadLetter(AmqpMessage $msg, array $attributes = array()) + { + return $this->move($msg, $msg->getRoutingKey().'.dead', $attributes); + } + + private function setQueuesConfiguration(array $queuesConfiguration) + { + $defaultQueueConfiguration = array( + 'arguments' => array(), + 'retry_strategy' => null, + 'retry_strategy_options' => array(), + 'thresholds' => array('warning' => null, 'critical' => null), + ); + + foreach ($queuesConfiguration as $configuration) { + if (!isset($configuration['name'])) { + throw new InvalidArgumentException('The key "name" is required to configure a Queue.'); + } + + if (isset($this->queuesConfiguration[$configuration['name']])) { + throw new InvalidArgumentException(sprintf('A queue named "%s" already exists.', $configuration['name'])); + } + + $configuration = array_replace_recursive($defaultQueueConfiguration, $configuration); + + $this->queuesConfiguration[$configuration['name']] = $configuration; + } + } + + private function setExchangesConfiguration(array $exchangesConfiguration) + { + $defaultExchangeConfiguration = array( + 'arguments' => array(), + ); + + foreach ($exchangesConfiguration as $configuration) { + if (!isset($configuration['name'])) { + throw new InvalidArgumentException('The key "name" is required to configure an Exchange.'); + } + + if (isset($this->exchangesConfiguration[$configuration['name']])) { + throw new InvalidArgumentException(sprintf('An exchange named "%s" already exists.', $configuration['name'])); + } + + $configuration = array_replace_recursive($defaultExchangeConfiguration, $configuration); + + $this->exchangesConfiguration[$configuration['name']] = $configuration; + } + } + + /** + * @param string $name + * @param string $type + * + * @return AmqpTopic + */ + private function getOrCreateExchange($name, $type = AmqpTopic::TYPE_DIRECT) + { + if (!isset($this->exchanges[$name])) { + if (isset($this->exchangesConfiguration[$name])) { + $this->createExchangeFromConfiguration($this->exchangesConfiguration[$name]); + } else { + $this->createExchange($name, array('type' => $type)); + } + } + + return $this->exchanges[$name]; + } + + /** + * @param array $conf + * + * @return AmqpTopic + */ + private function createExchangeFromConfiguration(array $conf) + { + return $this->createExchange($conf['name'], $conf['arguments']); + } + + /** + * @param string $name + * @param array $arguments + * + * @return Queue + */ + private function getOrCreateQueue($name, array $arguments = array()) + { + if (!isset($this->queues[$name])) { + if (isset($this->queuesConfiguration[$name])) { + $this->createQueueFromConfiguration($this->queuesConfiguration[$name]); + } else { + $this->createQueue($name, $arguments); + } + } + + return $this->queues[$name]; + } + + /** + * @param array $conf + * @param bool $declareAndBind + * + * @return AmqpQueue + */ + private function createQueueFromConfiguration(array $conf, $declareAndBind = true) + { + $args = $conf['arguments']; + + if ('constant' === $conf['retry_strategy']) { + $args['retry_strategy'] = new ConstantRetryStrategy($conf['retry_strategy_options']['time'], $conf['retry_strategy_options']['max']); + } elseif ('exponential' === $conf['retry_strategy']) { + $args['retry_strategy'] = new ExponentialRetryStrategy($conf['retry_strategy_options']['max'], $conf['retry_strategy_options']['offset']); + } + + return $this->createQueue($conf['name'], $args, $declareAndBind); + } + + /** + * @param string $name + * @param int $time + * @param string|null $originalExchange + */ + private function createDelayedQueue($name, $time, $originalExchange = null) + { + if ($originalExchange) { + $retryExchange = $originalExchange; + $retryRoutingKey = str_replace( + array('%exchange%', '%time%'), + array($retryExchange, sprintf('%06d', $time)), + '%exchange%.%time%.wait' + ); + } else { + $originalExchange = self::RETRY_EXCHANGE; + $retryExchange = self::RETRY_EXCHANGE; + $retryRoutingKey = str_replace( + array('%exchange%', '%time%'), + array($retryExchange, sprintf('%06d', $time)), + isset($this->retryStrategyQueuePatterns[$name]) ? $this->retryStrategyQueuePatterns[$name] : '%exchange%.%time%.wait' + ); + } + + if (isset($this->queues[$retryRoutingKey])) { + return; + } + + // Force Exchange creation if needed + $this->getOrCreateExchange(self::DEAD_LETTER_EXCHANGE); + + // Force retry Queue creation if needed + $this->getOrCreateQueue($retryRoutingKey, array( + 'exchange' => self::DEAD_LETTER_EXCHANGE, + 'x-message-ttl' => $time * 1000, + 'x-dead-letter-exchange' => $retryExchange, + 'bind_arguments' => array( + 'queue-time' => (string) $time, + 'exchange' => $originalExchange, + 'x-match' => 'all', + ), + )); + } + + private function shouldCreateQueue(AmqpTopic $topic, $routingKey) + { + if (AmqpTopic::TYPE_DIRECT === $topic->getType() && null === $routingKey) { + return false; + } + + $topicName = $topic->getTopicName(); + + if ($topicName === self::DEAD_LETTER_EXCHANGE) { + return false; + } + + if ($topicName === self::RETRY_EXCHANGE) { + return false; + } + + return true; + } + + private function lazyLoadQueues(AmqpTopic $amqpTopic, $routingKey) + { + // TODO find out what it does and implement this + +// $match = false; +// $exchangeName = $amqpTopic->getTopicName(); +// +// // A queue is already setup +//// if (isset($this->queuesBindings[$exchangeName][$routingKey])) { +//// $match = true; +//// } +// +// // Try to find a queue which is already configured +// foreach ($this->exchangeBindings[$exchangeName] as $index => $config) { +// if (isset($config['configured'])) { +// $match = true; +// continue; +// } +// +// if ($config['routing_key'] != $routingKey) { +// continue; +// } +// +// $queue = $this->createQueueFromConfiguration($this->queuesConfiguration[$config['queue']], false); +// $this->queues[$queue->getQueueName()] = $queue; +// +// +// +// // Can only lazy load direct queue +// if (AmqpTopic::TYPE_DIRECT !== $amqpTopic->getType()) { +// $match = true; +// $this->context->declareQueue($queue); +// $this->exchangeBindings[$exchangeName][$index]['configured'] = true; +// +// continue; +// } +// +// foreach ($bindings as $binding) { +// if ($routingKey === $binding['routing_key']) { +// $match = true; +// $queue->declareAndBind(); +// $this->queuesConfiguration[$name]['configured'] = true; +// $this->addQueue($queue); +// } +// } +// } +// +// if (!$match) { +// $this->createQueue($routingKey, array('exchange' => $exchangeName)); +// } + } + + /** + * @param string $name + * + * @return AmqpConsumer + */ + private function getQueueConsumer($name) + { + if (false == isset($this->queueConsumers[$name])) { + $this->queueConsumers = $this->context->createConsumer($this->getQueue($name)); + } + + return $this->queueConsumers[$name]; + + } +} diff --git a/src/Symfony/Component/Amqp/CHANGELOG.md b/src/Symfony/Component/Amqp/CHANGELOG.md new file mode 100644 index 0000000000000..c4df4750f73b2 --- /dev/null +++ b/src/Symfony/Component/Amqp/CHANGELOG.md @@ -0,0 +1,2 @@ +CHANGELOG +========= diff --git a/src/Symfony/Component/Amqp/Command/AmqpMoveCommand.php b/src/Symfony/Component/Amqp/Command/AmqpMoveCommand.php new file mode 100644 index 0000000000000..202db5b8e0d72 --- /dev/null +++ b/src/Symfony/Component/Amqp/Command/AmqpMoveCommand.php @@ -0,0 +1,74 @@ + + * @author Robin Chalas + */ +class AmqpMoveCommand extends Command +{ + private $broker; + private $logger; + + public function __construct(Broker $broker, LoggerInterface $logger = null) + { + parent::__construct(); + + $this->broker = $broker; + $this->logger = $logger; + } + + protected function configure() + { + $this + ->setName('amqp:move') + ->setDescription('Takes all messages from a queue and sends them to the default exchange with a new routing key.') + ->setDefinition(array( + new InputArgument('from', InputArgument::REQUIRED, 'The queue.'), + new InputArgument('to', InputArgument::REQUIRED, 'The new routing key.'), + )) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $io = new SymfonyStyle($input, $output); + $from = $input->getArgument('from'); + $to = $input->getArgument('to'); + + while (false !== $message = $this->broker->get($from)) { + $io->comment("Moving a message from $from to $to..."); + + if (null !== $this->logger) { + $this->logger->info('Moving a message from {from} to {to}.', array( + 'from' => $from, + 'to' => $to, + )); + } + + $this->broker->move($message, $to); + $this->broker->ack($message); + + if ($output->isDebug()) { + $io->comment("...message moved from $from to $to."); + } + + if (null !== $this->logger) { + $this->logger->debug('...message moved {from} to {to}.', array( + 'from' => $from, + 'to' => $to, + )); + } + } + } +} diff --git a/src/Symfony/Component/Amqp/Exception/ExceptionInterface.php b/src/Symfony/Component/Amqp/Exception/ExceptionInterface.php new file mode 100644 index 0000000000000..bde6759278cfc --- /dev/null +++ b/src/Symfony/Component/Amqp/Exception/ExceptionInterface.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Amqp\Exception; + +/** + * @author Fabien Potencier + * @author Grégoire Pineau + */ +interface ExceptionInterface +{ +} diff --git a/src/Symfony/Component/Amqp/Exception/InvalidArgumentException.php b/src/Symfony/Component/Amqp/Exception/InvalidArgumentException.php new file mode 100644 index 0000000000000..c1e83f7e4a707 --- /dev/null +++ b/src/Symfony/Component/Amqp/Exception/InvalidArgumentException.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Amqp\Exception; + +class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/Amqp/Exception/LogicException.php b/src/Symfony/Component/Amqp/Exception/LogicException.php new file mode 100644 index 0000000000000..e6a4928269b8f --- /dev/null +++ b/src/Symfony/Component/Amqp/Exception/LogicException.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Amqp\Exception; + +class LogicException extends \LogicException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/Amqp/Exception/NonRetryableException.php b/src/Symfony/Component/Amqp/Exception/NonRetryableException.php new file mode 100644 index 0000000000000..8190106d51cb3 --- /dev/null +++ b/src/Symfony/Component/Amqp/Exception/NonRetryableException.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Amqp\Exception; + +use Interop\Amqp\AmqpMessage; +use Symfony\Component\Amqp\RetryStrategy\RetryStrategyInterface; + +/** + * @author Fabien Potencier + * @author Grégoire Pineau + */ +class NonRetryableException extends \RuntimeException implements ExceptionInterface +{ + /** + * @var RetryStrategyInterface + */ + private $retryStrategy; + + /** + * @var AmqpMessage + */ + private $amqpMessage; + + public function __construct(RetryStrategyInterface $retryStrategy, AmqpMessage $amqpMessage) + { + parent::__construct(sprintf('The message has been retried too many times (%s).', $amqpMessage->getHeader('retries'))); + + $this->retryStrategy = $retryStrategy; + $this->amqpMessage = $amqpMessage; + } + + public function getRetryStrategy() + { + return $this->retryStrategy; + } + + /** + * @return AmqpMessage + */ + public function getAmqpMessage() + { + return $this->amqpMessage; + } +} diff --git a/src/Symfony/Component/Amqp/Helper/MessageExporter.php b/src/Symfony/Component/Amqp/Helper/MessageExporter.php new file mode 100644 index 0000000000000..fae583dd67c7b --- /dev/null +++ b/src/Symfony/Component/Amqp/Helper/MessageExporter.php @@ -0,0 +1,96 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Amqp\Helper; + +use Symfony\Component\Amqp\Broker; +use Symfony\Component\Amqp\Exception\InvalidArgumentException; + +/** + * An utility class to return a compressed file with all + * message for a Queue. + * + * @author Grégoire Pineau + */ +class MessageExporter +{ + private $broker; + + public function __construct(Broker $broker) + { + $this->broker = $broker; + } + + /** + * @param string $queueName + * @param bool $ack + * + * @return string|null A tgz filename or null if there is no message in the queue + */ + public function export($queueName, $ack = false) + { + $this->checkQueueName($queueName); + + $messages = array(); + while (false !== $message = $this->broker->get($queueName)) { + $messages[] = $message; + } + + if (!$messages) { + return; + } + + $filename = sprintf('%s/symfony-amqp-consumer-queue-%s.tar', sys_get_temp_dir(), str_replace('.', '-', $queueName)); + $tgz = $filename.'.gz'; + + // A previous phar could exist + if (file_exists($filename)) { + unlink($filename); + } + if (file_exists($tgz)) { + unlink($tgz); + } + + $phar = new \PharData($filename); + foreach ($messages as $i => $message) { + if ($ack) { + $this->broker->ack($message, $queueName); + } else { + $this->broker->nack($message, $queueName); + } + $buffer = ''; + foreach ($message->getHeaders() as $name => $value) { + $buffer .= sprintf("%s: %s\n", $name, $value); + } + $buffer .= "\n"; + $buffer .= $message->getBody(); + $phar->addFromString('message-'.$i, $buffer); + } + $phar->compress(\Phar::GZ); + + // we can remove the phar, as we only use the gz'ed one + unlink($filename); + + return $tgz; + } + + /** + * @param string $queueName + * + * @throws InvalidArgumentException + */ + protected function checkQueueName($queueName) + { + if ('.dead' !== substr($queueName, -5)) { + throw new InvalidArgumentException('Only dead queue can be exported.'); + } + } +} diff --git a/src/Symfony/Component/Amqp/README.md b/src/Symfony/Component/Amqp/README.md new file mode 100644 index 0000000000000..deea88380e250 --- /dev/null +++ b/src/Symfony/Component/Amqp/README.md @@ -0,0 +1,11 @@ +Amqp Component +============== + +Resources +--------- + + * [Documentation](https://symfony.com/doc/current/components/amqp.html) + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/src/Symfony/Component/Amqp/RetryStrategy/ConstantRetryStrategy.php b/src/Symfony/Component/Amqp/RetryStrategy/ConstantRetryStrategy.php new file mode 100644 index 0000000000000..5ff3acc81837a --- /dev/null +++ b/src/Symfony/Component/Amqp/RetryStrategy/ConstantRetryStrategy.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Amqp\RetryStrategy; + +use Interop\Amqp\AmqpMessage; +use Symfony\Component\Amqp\Exception\InvalidArgumentException; + +/** + * @author Fabien Potencier + * @author Grégoire Pineau + */ +class ConstantRetryStrategy implements RetryStrategyInterface +{ + private $time; + private $max; + + /** + * @param int $time Time to wait in the queue in seconds + * @param int $max The maximum number of attempts (0 means no limit) + */ + public function __construct($time, $max = 0) + { + $time = (int) $time; + + if ($time < 1) { + throw new InvalidArgumentException('"time" should be at least 1.'); + } + + $this->time = $time; + $this->max = $max; + } + + /** + * {@inheritdoc} + */ + public function isRetryable(AmqpMessage $msg) + { + $retries = (int) $msg->getProperty('retries'); + + return $this->max ? $retries < $this->max : true; + } + + /** + * {@inheritdoc} + */ + public function getWaitingTime(AmqpMessage $msg) + { + return $this->time; + } +} diff --git a/src/Symfony/Component/Amqp/RetryStrategy/ExponentialRetryStrategy.php b/src/Symfony/Component/Amqp/RetryStrategy/ExponentialRetryStrategy.php new file mode 100644 index 0000000000000..737163855d4df --- /dev/null +++ b/src/Symfony/Component/Amqp/RetryStrategy/ExponentialRetryStrategy.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Amqp\RetryStrategy; + +use Interop\Amqp\AmqpMessage; + +/** + * The retry mechanism is based on a truncated exponential backoff algorithm. + * + * @author Fabien Potencier + * @author Grégoire Pineau + */ +class ExponentialRetryStrategy implements RetryStrategyInterface +{ + private $max; + private $offset; + + /** + * @param int $max The maximum number of time to retry (0 means indefinitely) + * @param int $offset The offset for the first power of 2 + */ + public function __construct($max = 0, $offset = 0) + { + $this->max = $max; + $this->offset = $offset; + } + + /** + * {@inheritdoc} + */ + public function isRetryable(AmqpMessage $msg) + { + if (0 === $this->max) { + return true; + } + + $retries = (int) $msg->getProperty('retries', 0); + + return $retries < $this->max; + } + + /** + * {@inheritdoc} + */ + public function getWaitingTime(AmqpMessage $msg) + { + $retries = (int) $msg->getProperty('retries', 0); + + return pow(2, $retries + $this->offset); + } +} diff --git a/src/Symfony/Component/Amqp/RetryStrategy/RetryStrategyInterface.php b/src/Symfony/Component/Amqp/RetryStrategy/RetryStrategyInterface.php new file mode 100644 index 0000000000000..1e49cbd199b3d --- /dev/null +++ b/src/Symfony/Component/Amqp/RetryStrategy/RetryStrategyInterface.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Amqp\RetryStrategy; + +use Interop\Amqp\AmqpMessage; + +/** + * @author Fabien Potencier + * @author Grégoire Pineau + */ +interface RetryStrategyInterface +{ + /** + * @param AmqpMessage $msg + * + * @return bool + */ + public function isRetryable(AmqpMessage $msg); + + /** + * @param AmqpMessage $msg + * + * @return int + */ + public function getWaitingTime(AmqpMessage $msg); +} diff --git a/src/Symfony/Component/Amqp/Test/AmqpTestTrait.php b/src/Symfony/Component/Amqp/Test/AmqpTestTrait.php new file mode 100644 index 0000000000000..ebccc9a564407 --- /dev/null +++ b/src/Symfony/Component/Amqp/Test/AmqpTestTrait.php @@ -0,0 +1,116 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Amqp\Test; + +use Symfony\Component\Amqp\UrlParser; + +/** + * @author Grégoire Pineau + */ +trait AmqpTestTrait +{ + /** + * @param string $body + * @param string $queueName + */ + private function assertNextMessageBody($body, $queueName) + { + $msg = $this->createQueue($queueName)->get(\AMQP_AUTOACK); + + $this->assertInstanceOf(\AMQPEnvelope::class, $msg); + $this->assertSame($body, $msg->getBody()); + + return $msg; + } + + /** + * @param int $expected The count + * @param string $queueName + */ + private function assertQueueSize($expected, $queueName) + { + $queue = $this->createQueue($queueName); + + $msgs = array(); + while (false !== $msg = $queue->get()) { + $msgs[] = $msg; + } + + foreach ($msgs as $msg) { + $queue->nack($msg->getDeliveryTag(), \AMQP_REQUEUE); + } + + $this->assertSame($expected, count($msgs)); + } + + /** + * @param string $name + * + * @return \AmqpExchange + */ + private function createExchange($name) + { + $exchange = new \AmqpExchange($this->createChannel()); + $exchange->setName($name); + $exchange->setType(\AMQP_EX_TYPE_DIRECT); + $exchange->setFlags(\AMQP_DURABLE); + $exchange->declareExchange(); + + return $exchange; + } + + /** + * @param string $name + * + * @return \AmqpQueue + */ + private function createQueue($name) + { + $queue = new \AmqpQueue($this->createChannel()); + $queue->setName($name); + $queue->setFlags(\AMQP_DURABLE); + $queue->declareQueue(); + + return $queue; + } + + /** + * @param string $name + */ + private function emptyQueue($name) + { + $this->createQueue($name)->purge(); + } + + /** + * @return \AmqpChannel + */ + private function createChannel() + { + return new \AmqpChannel($this->createConnection()); + } + + /** + * @param string|null $rabbitmqUrl + * + * @return \AmqpConnection + */ + private function createConnection($rabbitmqUrl = null) + { + $rabbitmqUrl = $rabbitmqUrl ?: getenv('RABBITMQ_URL'); + + $connection = new \AmqpConnection(UrlParser::parseUrl($rabbitmqUrl)); + $connection->connect(); + + return $connection; + } +} diff --git a/src/Symfony/Component/Amqp/Tests/BrokerTest.php b/src/Symfony/Component/Amqp/Tests/BrokerTest.php new file mode 100644 index 0000000000000..50e145f124280 --- /dev/null +++ b/src/Symfony/Component/Amqp/Tests/BrokerTest.php @@ -0,0 +1,792 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Amqp\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Amqp\Broker; +use Symfony\Component\Amqp\Exception\InvalidArgumentException; +use Symfony\Component\Amqp\Exception\NonRetryableException; +use Symfony\Component\Amqp\Exchange; +use Symfony\Component\Amqp\Queue; +use Symfony\Component\Amqp\RetryStrategy\ConstantRetryStrategy; +use Symfony\Component\Amqp\RetryStrategy\ExponentialRetryStrategy; +use Symfony\Component\Amqp\Test\AmqpTestTrait; + +class BrokerTest extends TestCase +{ + use AmqpTestTrait; + + /** + * @expectedException \Symfony\Component\Amqp\Exception\InvalidArgumentException + * @expectedExceptionMessage The connection should be a DSN or an instance of AMQPConnection. + */ + public function testConstructorWithInvalidConnection() + { + new Broker(new \stdClass()); + } + + public function testConstructorUri() + { + $broker = new Broker('amqp://foo:bar@rabbitmq-3.lxc:1234/symfony_amqp'); + + $connection = $broker->getConnection(); + + $expected = array('rabbitmq-3.lxc', 'foo', 'bar', 1234, 'symfony_amqp'); + + $this->assertEquals($expected, array( + $connection->getHost(), + $connection->getLogin(), + $connection->getPassword(), + $connection->getPort(), + $connection->getVhost(), + )); + } + + public function testConstructorWithConnectionInstance() + { + $conn = $this->createConnection(); + + $broker = new Broker($conn); + + $this->assertSame($conn, $broker->getConnection()); + $this->assertTrue($broker->isConnected()); + + $channel = $broker->getChannel(); + + $this->assertInstanceOf(\AMQPChannel::class, $channel); + $this->assertTrue($channel->isConnected()); + } + + /** + * @dataProvider provideInvalidConfiguration + */ + public function testConstructorWithInvalidConfiguration($expectedMessage, $queuesConfiguration, $exchangesConfiguration) + { + try { + new Broker('amqp://guest:guest@localhost:5672/', $queuesConfiguration, $exchangesConfiguration); + + $this->fail('The configuration should not be valid'); + } catch (\Exception $e) { + $this->assertInstanceOf(InvalidArgumentException::class, $e); + $this->assertSame($expectedMessage, $e->getMessage()); + } + } + + public function provideInvalidConfiguration() + { + yield 'missing queue name' => array( + 'The key "name" is required to configure a Queue.', + array( + array( + 'arguments' => array(), + ), + ), + array(), + ); + + yield '2 queues with the same name' => array( + 'A queue named "non unique name" already exists.', + array( + array( + 'name' => 'non unique name', + ), + array( + 'name' => 'non unique name', + ), + ), + array(), + ); + + yield 'missing exchange name' => array( + 'The key "name" is required to configure an Exchange.', + array(), + array( + array( + 'arguments' => array(), + ), + ), + ); + + yield '2 exchanges with the same name' => array( + 'An exchange named "non unique name" already exists.', + array(), + array( + array( + 'name' => 'non unique name', + ), + array( + 'name' => 'non unique name', + ), + ), + ); + } + + public function testConnection() + { + $broker = $this->createBroker(); + + $this->assertFalse($broker->isConnected()); + + $broker->connect(); + + $this->assertTrue($broker->isConnected()); + + $broker->disconnect(); + + $this->assertFalse($broker->isConnected()); + } + + public function testConfigure() + { + $config = array( + array( + 'arguments' => array(), + 'retry_strategy' => 'constant', + 'retry_strategy_options' => array('time' => 1, 'max' => 2), + 'thresholds' => array('warning' => 10, 'critical' => 20), + 'name' => 'test_broker.configure.constant', + ), + array( + 'arguments' => array(), + 'retry_strategy' => 'exponential', + 'retry_strategy_options' => array('max' => 1, 'offset' => 2), + 'thresholds' => array('warning' => 10, 'critical' => 20), + 'name' => 'test_broker.configure.exponential', + ), + ); + $broker = $this->createBroker($config); + + $this->assertSame($config, array_values($broker->getQueuesConfiguration())); + + $queueA = $broker->getQueue('test_broker.configure.constant'); + + // Creating a queue lazy-instantiate connection + $this->assertTrue($broker->isConnected()); + + $this->assertInstanceOf(Queue::class, $queueA); + $this->assertSame('test_broker.configure.constant', $queueA->getName()); + $this->assertInstanceOf(ConstantRetryStrategy::class, $queueA->getRetryStrategy()); + $this->assertTrue($broker->hasRetryStrategy('test_broker.configure.constant')); + + $queueB = $broker->getQueue('test_broker.configure.exponential'); + + $this->assertInstanceOf(Queue::class, $queueB); + $this->assertSame('test_broker.configure.exponential', $queueB->getName()); + $this->assertInstanceOf(ExponentialRetryStrategy::class, $queueB->getRetryStrategy()); + $this->assertTrue($broker->hasRetryStrategy('test_broker.configure.exponential')); + } + + public function testCreateExchange() + { + $broker = $this->createBroker(); + $exchange = $broker->createExchange('test_broker.create_exchange'); + + $this->assertInstanceOf(Exchange::class, $exchange); + $this->assertSame('test_broker.create_exchange', $exchange->getName()); + } + + public function testGetExchangeFromConfiguration() + { + $broker = $this->createBroker(array(), array( + array( + 'name' => 'test_broker.get_exchange_from_configuration.exchange_1', + 'arguments' => array( + 'type' => 'fanout', + ), + ), + array( + 'name' => 'test_broker.get_exchange_from_configuration.exchange_2', + 'arguments' => array( + 'type' => 'fanout', + ), + ), + )); + + $exchange = $broker->getExchange('test_broker.get_exchange_from_configuration.exchange_1'); + + $this->assertInstanceOf(Exchange::class, $exchange); + $this->assertSame('test_broker.get_exchange_from_configuration.exchange_1', $exchange->getName()); + $this->assertSame('fanout', $exchange->getType()); + + $broker->createQueue('test_broker.get_exchange_from_configuration.queue', array( + 'exchange' => 'test_broker.get_exchange_from_configuration.exchange_2', + )); + + $exchange = $broker->getExchange('test_broker.get_exchange_from_configuration.exchange_2'); + + $this->assertInstanceOf(Exchange::class, $exchange); + $this->assertSame('test_broker.get_exchange_from_configuration.exchange_2', $exchange->getName()); + $this->assertSame('fanout', $exchange->getType()); + } + + public function testGetSetExchange() + { + $name = 'test_broker.get_set_exchange'; + $exchange = $this->createExchange($name); + $broker = $this->createBroker(); + + $broker->addExchange($exchange); + + $newExchange = $broker->getExchange($name); + + $this->assertSame($newExchange, $exchange); + } + + /** + * @expectedException \Symfony\Component\Amqp\Exception\InvalidArgumentException + * @expectedExceptionMessage Exchange "404" does not exist. + */ + public function testGetExchangeWithUnknownExchange() + { + $this->createBroker()->getExchange('404'); + } + + public function testCreateQueue() + { + $broker = $this->createBroker(); + $queue = $broker->createQueue('test_broker.create_queue'); + + $this->assertInstanceOf(Queue::class, $queue); + $this->assertSame('test_broker.create_queue', $queue->getName()); + } + + public function testGetQueueFromConfiguration() + { + $broker = $this->createBroker(array( + array( + 'name' => 'test_broker.get_queue_from_configuration', + 'arguments' => array( + 'flags' => \AMQP_AUTODELETE, + ), + ), + array( + 'name' => 'test_broker.get_queue_from_configuration_2', + 'arguments' => array( + 'flags' => \AMQP_AUTODELETE, + ), + ), + )); + + $queue = $broker->getQueue('test_broker.get_queue_from_configuration'); + + $this->assertInstanceOf(Queue::class, $queue); + $this->assertSame('test_broker.get_queue_from_configuration', $queue->getName()); + $this->assertSame(\AMQP_AUTODELETE, $queue->getFlags()); + + $broker->get('test_broker.get_queue_from_configuration_2'); + $queue = $broker->getQueue('test_broker.get_queue_from_configuration_2'); + + $this->assertInstanceOf(Queue::class, $queue); + $this->assertSame('test_broker.get_queue_from_configuration_2', $queue->getName()); + $this->assertSame(\AMQP_AUTODELETE, $queue->getFlags()); + } + + public function testGetSetQueue() + { + $name = 'test_broker.get_set_queue'; + $queue = new Queue($this->createChannel(), $name); + $broker = $this->createBroker(); + + $broker->addQueue($queue); + + $newQueue = $broker->getQueue($name); + + $this->assertSame($newQueue, $queue); + } + + /** + * @expectedException \Symfony\Component\Amqp\Exception\InvalidArgumentException + * @expectedExceptionMessage Queue "404" does not exist. + */ + public function testGetQueueWithUnknownQueue() + { + $this->createBroker()->getQueue('404'); + } + + public function testHasRetryStrategy() + { + $broker = $this->createBroker(); + + $broker->createQueue('test_broker.has_retry_strategy_no'); + + $this->assertFalse($broker->hasRetryStrategy('test_broker.has_retry_strategy_no')); + + $broker->createQueue('test_broker.has_retry_strategy_yes', array('retry_strategy' => new ConstantRetryStrategy(2))); + + $this->assertTrue($broker->hasRetryStrategy('test_broker.has_retry_strategy_yes')); + } + + public function testPublishCreateEverything() + { + $this->emptyQueue('test_broker.publish_default'); + + $broker = $this->createBroker(); + $broker->publish('test_broker.publish_default', 'payload-1'); + + $exchange = $broker->getExchange('symfony.default'); + + $this->assertInstanceOf(Exchange::class, $exchange); + $this->assertSame('symfony.default', $exchange->getName()); + + $queue = $broker->getQueue('test_broker.publish_default'); + + $this->assertInstanceOf(Queue::class, $queue); + $this->assertSame('test_broker.publish_default', $queue->getName()); + + $this->assertNextMessageBody('payload-1', 'test_broker.publish_default'); + } + + public function testPublishInCustomExchange() + { + $this->emptyQueue('test_broker.publish_custom_exchange'); + + $broker = $this->createBroker(); + $broker->publish('test_broker.publish_custom_exchange', 'payload-2', array( + 'exchange' => 'test_broker.custom_exchange', + )); + + $exchange = $broker->getExchange('test_broker.custom_exchange'); + $this->assertInstanceOf(Exchange::class, $exchange); + $this->assertSame('test_broker.custom_exchange', $exchange->getName()); + + $queue = $broker->getQueue('test_broker.publish_custom_exchange'); + $this->assertInstanceOf(Queue::class, $queue); + $this->assertSame('test_broker.publish_custom_exchange', $queue->getName()); + + $this->assertNextMessageBody('payload-2', 'test_broker.publish_custom_exchange'); + } + + public function testPublishWithSpecialExchangeAndFlags() + { + $broker = $this->createBroker(); + $channel = $broker->getChannel(); + + $exchange = $this->getMockBuilder(Exchange::class) + ->setConstructorArgs(array($channel, 'test_broker.publish_flags')) + ->enableProxyingToOriginalMethods() + ->getMock() + ; + $exchange + ->expects($this->exactly(3)) + ->method('getName') + ->will($this->returnValue('test_broker.publish_flags')) + ; + $exchange + ->expects($this->once()) + ->method('publish') + ->with( + 'payload', + 'test_broker.publish_flags', + \AMQP_MANDATORY, + array('delivery_mode' => 1, 'message_id' => 1234) + ) + ; + + $broker->addExchange($exchange); + + $broker->publish('test_broker.publish_flags', 'payload', array( + 'delivery_mode' => 1, + 'flags' => \AMQP_MANDATORY, + 'exchange' => 'test_broker.publish_flags', + 'message_id' => 1234, + )); + } + + public function testPublishWithCustomBinding() + { + $broker = $this->createBroker(); + $broker->createQueue('test_broker.extra.queue_1', array( + 'routing_keys' => 'test_broker.extra.queue', + )); + $broker->createQueue('test_broker.extra.queue_2', array( + 'routing_keys' => 'test_broker.extra.queue', + )); + $this->emptyQueue('test_broker.extra.queue_1'); + $this->emptyQueue('test_broker.extra.queue_2'); + + $this->assertQueueSize(0, 'test_broker.extra.queue_1'); + $this->assertQueueSize(0, 'test_broker.extra.queue_2'); + + $broker->publish('test_broker.extra.queue', 'payload-42'); + + // Ensure we don't create extra queue + try { + $broker->getQueue('test_broker.extra.queue'); + + $this->fail('The queues exists!'); + } catch (\Exception $e) { + $this->assertSame('Queue "test_broker.extra.queue" does not exist.', $e->getMessage()); + } + + $this->assertQueueSize(1, 'test_broker.extra.queue_1'); + $this->assertNextMessageBody('payload-42', 'test_broker.extra.queue_1'); + + $this->assertQueueSize(1, 'test_broker.extra.queue_2'); + $this->assertNextMessageBody('payload-42', 'test_broker.extra.queue_2'); + } + + public function testPublishWithCustomBindingInConfig() + { + $config = array( + array( + 'name' => 'test_broker.extra2.queue_1', + 'retry_strategy' => array(), + 'arguments' => array( + 'routing_keys' => 'test_broker.extra2.queue', + ), + ), + array( + 'name' => 'test_broker.extra2.queue_2', + 'retry_strategy' => array(), + 'arguments' => array( + 'routing_keys' => 'test_broker.extra2.queue', + ), + ), + ); + + $broker = $this->createBroker($config); + + $this->emptyQueue('test_broker.extra2.queue_1'); + $this->emptyQueue('test_broker.extra2.queue_2'); + + $this->assertQueueSize(0, 'test_broker.extra2.queue_1'); + $this->assertQueueSize(0, 'test_broker.extra2.queue_2'); + + $broker->publish('test_broker.extra2.queue', 'payload-42'); + + // Ensure we don't create extra queue + try { + $broker->getQueue('test_broker.extra2.queue'); + + $this->fail('The queues exists!'); + } catch (\Exception $e) { + $this->assertSame('Queue "test_broker.extra2.queue" does not exist.', $e->getMessage()); + } + + $this->assertQueueSize(1, 'test_broker.extra2.queue_1'); + $this->assertNextMessageBody('payload-42', 'test_broker.extra2.queue_1'); + + $this->assertQueueSize(1, 'test_broker.extra2.queue_2'); + $this->assertNextMessageBody('payload-42', 'test_broker.extra2.queue_2'); + } + + public function provideExchangeTests() + { + yield 'with default exchange' => array(Broker::DEFAULT_EXCHANGE); + yield 'with a custom exchange' => array('foobar'); + } + + /** + * @dataProvider provideExchangeTests + */ + public function testDelay($exchange) + { + $broker = $this->createBroker(); + + $broker->createQueue('test_broker.delay.step_1', array( + 'routing_keys' => 'test_broker.delay', + 'exchange' => $exchange, + )); + $broker->createQueue('test_broker.delay.step_2', array( + 'retry_strategy' => new ConstantRetryStrategy(1, 2), + 'routing_keys' => 'test_broker.delay', + 'exchange' => $exchange, + )); + + $this->emptyQueue('test_broker.delay.step_1'); + $this->emptyQueue('test_broker.delay.step_2'); + + $broker->delay('test_broker.delay', 'my_message', 1, array('exchange' => $exchange)); + + sleep(1); + + $this->assertQueueSize(1, 'test_broker.delay.step_1'); + $this->assertQueueSize(1, 'test_broker.delay.step_2'); + } + + public function testGet() + { + $broker = $this->createBroker(); + $broker->createQueue('test_broker.get'); + + $this->emptyQueue('test_broker.get'); + + $broker->publish('test_broker.get', 'payload-42'); + usleep(100); + + $msg = $broker->get('test_broker.get'); + $this->assertSame('payload-42', $msg->getBody()); + } + + public function testConsume() + { + $broker = $this->createBroker(); + $broker->createQueue('test_broker.consume'); + + $this->emptyQueue('test_broker.consume'); + + $broker->publish('test_broker.consume', 'payload-42'); + usleep(100); + + $consumed = false; + $broker->consume('test_broker.consume', function (\AMQPEnvelope $msg) use (&$consumed) { + $consumed = true; + $this->assertInstanceOf(\AMQPEnvelope::class, $msg); + $this->assertSame('payload-42', $msg->getBody()); + + return false; + }, \AMQP_AUTOACK); + $this->assertTrue($consumed); + } + + public function testAck() + { + $broker = $this->createBroker(); + + $this->emptyQueue('test_broker.ack'); + + $broker->publish('test_broker.ack', 'payload-42'); + usleep(100); + + $msg = $broker->get('test_broker.ack'); + + $broker->ack($msg); + $broker->disconnect(); + + $this->assertQueueSize(0, 'test_broker.ack'); + } + + public function testNack() + { + $broker = $this->createBroker(); + + $this->emptyQueue('test_broker.nack'); + + $broker->publish('test_broker.nack', 'payload-42'); + usleep(100); + + $msg = $broker->get('test_broker.nack'); + + $broker->nack($msg); + $broker->disconnect(); + + $this->assertQueueSize(0, 'test_broker.nack'); + } + + public function testNackAndRequeue() + { + $broker = $this->createBroker(); + + $this->emptyQueue('test_broker.nack_and_requeue'); + + $broker->publish('test_broker.nack_and_requeue', 'payload-42'); + usleep(100); + + $msg = $broker->get('test_broker.nack_and_requeue'); + + $broker->nack($msg, \AMQP_REQUEUE); + $broker->disconnect(); + + $this->assertQueueSize(1, 'test_broker.nack_and_requeue'); + $this->assertNextMessageBody('payload-42', 'test_broker.nack_and_requeue'); + } + + /** + * @expectedException \Symfony\Component\Amqp\Exception\LogicException + * @expectedExceptionMessage The queue "test_broker.no_retry" has no retry strategy + */ + public function testRetryWhenRSIsNotDefined() + { + $broker = $this->createBroker(); + + $this->emptyQueue('test_broker.no_retry'); + + $broker->publish('test_broker.no_retry', 'payload-42'); + usleep(100); + + $msg = $broker->get('test_broker.no_retry'); + $broker->ack($msg); + $broker->retry($msg); + } + + /** + * @dataProvider provideExchangeTests + */ + public function testRetry($exchange) + { + $broker = $this->createBroker(); + $broker->createQueue('test_broker.retry', array( + 'retry_strategy' => $rs = new ConstantRetryStrategy(1, 2), + 'exchange' => $exchange, + )); + + $this->emptyQueue('test_broker.retry'); + + $broker->publish('test_broker.retry', 'payload-42', array( + 'exchange' => $exchange, + )); + usleep(100); + + $msg = $this->assertNextMessageBody('payload-42', 'test_broker.retry'); + $this->assertFalse($msg->getHeader('retries')); + + $broker->retry($msg, null, 'a message'); + + $this->assertQueueSize(0, 'test_broker.retry'); + usleep(1000100); + + $this->assertQueueSize(1, 'test_broker.retry'); + $msg = $this->assertNextMessageBody('payload-42', 'test_broker.retry'); + $this->assertSame('a message', $msg->getHeader('retry-message')); + $this->assertSame(1, $msg->getHeader('retries')); + + $broker->retry($msg); + + $this->assertQueueSize(0, 'test_broker.retry'); + usleep(1000100); + + $this->assertQueueSize(1, 'test_broker.retry'); + $msg = $this->assertNextMessageBody('payload-42', 'test_broker.retry'); + $this->assertSame(2, $msg->getHeader('retries')); + $this->assertSame('a message', $msg->getHeader('retry-message')); + + try { + $broker->retry($msg); + + $this->fail('We should reach NonRetryable limit.'); + } catch (\Exception $e) { + $this->assertInstanceOf(NonRetryableException::class, $e); + $this->assertSame('The message has been retried too many times (2).', $e->getMessage()); + $this->assertSame($rs, $e->getRetryStrategy()); + $this->assertSame($msg, $e->getEnvelope()); + } + } + + /** + * @dataProvider provideExchangeTests + */ + public function testRetryWithSpecialBinding($exchange) + { + $broker = $this->createBroker(); + $broker->createQueue('test_broker.retry_finished.step_1', array( + 'retry_strategy' => new ConstantRetryStrategy(1, 2), + 'routing_keys' => 'test_broker.retry_finished', + 'exchange' => $exchange, + )); + $broker->createQueue('test_broker.retry_finished.step_2', array( + 'retry_strategy' => new ConstantRetryStrategy(1, 2), + 'routing_keys' => 'test_broker.retry_finished', + 'exchange' => $exchange, + )); + + $this->emptyQueue('test_broker.retry_finished.step_1'); + $this->emptyQueue('test_broker.retry_finished.step_2'); + + $broker->publish('test_broker.retry_finished', 'payload-42', array( + 'exchange' => $exchange, + )); + usleep(100); + + $this->assertQueueSize(1, 'test_broker.retry_finished.step_1'); + $this->assertQueueSize(1, 'test_broker.retry_finished.step_2'); + + $msg = $this->assertNextMessageBody('payload-42', 'test_broker.retry_finished.step_1'); + $this->assertFalse($msg->getHeader('retries')); + + $broker->retry($msg, 'test_broker.retry_finished.step_1'); + + $this->assertQueueSize(0, 'test_broker.retry_finished.step_1'); + $this->assertQueueSize(1, 'test_broker.retry_finished.step_2'); + usleep(1000100); + + $this->assertQueueSize(1, 'test_broker.retry_finished.step_1'); + $msg = $this->assertNextMessageBody('payload-42', 'test_broker.retry_finished.step_1'); + + $this->assertSame(1, $msg->getHeader('retries')); + + $broker->retry($msg, 'test_broker.retry_finished.step_1'); + + $this->assertQueueSize(0, 'test_broker.retry_finished.step_1'); + $this->assertQueueSize(1, 'test_broker.retry_finished.step_2'); + usleep(1000100); + + $this->assertQueueSize(1, 'test_broker.retry_finished.step_1'); + $msg = $this->assertNextMessageBody('payload-42', 'test_broker.retry_finished.step_1'); + $this->assertSame(2, $msg->getHeader('retries')); + + try { + $broker->retry($msg, 'test_broker.retry_finished.step_1'); + + $this->fail('We should reach NonRetryable limit.'); + } catch (\Exception $e) { + $this->assertInstanceOf('Symfony\Component\Amqp\Exception\NonRetryableException', $e); + $this->assertSame('The message has been retried too many times (2).', $e->getMessage()); + $this->assertSame($msg, $e->getEnvelope()); + } + + $this->assertQueueSize(0, 'test_broker.retry_finished.step_1'); + $this->assertQueueSize(1, 'test_broker.retry_finished.step_2'); + } + + public function testMove() + { + $broker = $this->createBroker(); + $this->emptyQueue('test_broker.move.from'); + + $broker->publish('test_broker.move.from', 'payload-42', array( + 'app_id' => 'app', + 'headers' => array( + 'foo' => 'bar', + ), + )); + $message = $broker->get('test_broker.move.from', \AMQP_AUTOACK); + + $broker->move($message, 'test_broker.move.to'); + + $this->assertQueueSize(1, 'test_broker.move.to'); + + $message = $broker->get('test_broker.move.to', \AMQP_AUTOACK); + + $this->assertSame('payload-42', $message->getBody()); + $this->assertSame('bar', $message->getHeader('foo')); + $this->assertSame('app', $message->getAppId()); + } + + public function testMoveToDeadLetter() + { + $broker = $this->createBroker(); + $this->emptyQueue('test_broker.move_to_dl'); + + $broker->publish('test_broker.move_to_dl', 'payload-42', array( + 'app_id' => 'app', + 'headers' => array( + 'foo' => 'bar', + ), + )); + $message = $broker->get('test_broker.move_to_dl', \AMQP_AUTOACK); + + $broker->moveToDeadLetter($message); + + $this->assertQueueSize(1, 'test_broker.move_to_dl.dead'); + + $message = $broker->get('test_broker.move_to_dl.dead', \AMQP_AUTOACK); + + $this->assertSame('payload-42', $message->getBody()); + $this->assertSame('bar', $message->getHeader('foo')); + $this->assertSame('app', $message->getAppId()); + } + + private function createBroker(array $queuesConfiguration = array(), $exchangesConfiguration = array()) + { + return new Broker(getenv('RABBITMQ_URL'), $queuesConfiguration, $exchangesConfiguration); + } +} diff --git a/src/Symfony/Component/Amqp/Tests/ExchangeTest.php b/src/Symfony/Component/Amqp/Tests/ExchangeTest.php new file mode 100644 index 0000000000000..da2f1f08d3f95 --- /dev/null +++ b/src/Symfony/Component/Amqp/Tests/ExchangeTest.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Amqp\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Amqp\Exchange; +use Symfony\Component\Amqp\Test\AmqpTestTrait; + +class ExchangeTest extends TestCase +{ + use AmqpTestTrait; + + public function getUri() + { + return array( + array('exchange_name=test_ex.default', 'test_ex.default', \AMQP_EX_TYPE_DIRECT, \AMQP_DURABLE), + array('exchange_name=test_ex.fanout_durable&type=fanout&flags=2', 'test_ex.fanout_durable', \AMQP_EX_TYPE_FANOUT, \AMQP_DURABLE), + ); + } + + /** + * @dataProvider getUri + */ + public function testCreateFromUri($qsa, $name, $type, $flags) + { + $exchange = Exchange::createFromUri(getenv('RABBITMQ_URL').'?'.$qsa); + + $this->assertInstanceOf(Exchange::class, $exchange); + $this->assertEquals($name, $exchange->getName()); + $this->assertEquals($type, $exchange->getType()); + $this->assertEquals($flags, $exchange->getFlags()); + } + + /** + * @expectedException \Symfony\Component\Amqp\Exception\LogicException + * @expectedExceptionMessage The "exchange_name" must be part of the query string. + */ + public function testCreateFromUriWithInvalidUri() + { + Exchange::createFromUri(getenv('RABBITMQ_URL').'/?type=fanout'); + } + + public function testPublish() + { + $name = 'test_exchange.publish'; + + $exchange = new Exchange($this->createChannel(), $name); + + $queue = $this->createQueue($name); + $queue->bind($name, $name); + + $this->emptyQueue($name); + + $message = json_encode(microtime(true)); + $exchange->publish($message, $name, \AMQP_MANDATORY, array('content_type' => 'application/json')); + + $this->assertQueueSize(1, $name); + $this->assertNextMessageBody($message, $name, function (\AMQPEnvelope $msg) { + $this->assertSame('application/json', $msg->getContentType()); + }); + } +} diff --git a/src/Symfony/Component/Amqp/Tests/QueueTest.php b/src/Symfony/Component/Amqp/Tests/QueueTest.php new file mode 100644 index 0000000000000..5727b93a0d0f0 --- /dev/null +++ b/src/Symfony/Component/Amqp/Tests/QueueTest.php @@ -0,0 +1,131 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Amqp\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Amqp\Broker; +use Symfony\Component\Amqp\Queue; +use Symfony\Component\Amqp\RetryStrategy\ConstantRetryStrategy; +use Symfony\Component\Amqp\Test\AmqpTestTrait; + +class QueueTest extends TestCase +{ + use AmqpTestTrait; + + private $channel; + + protected function setUp() + { + // Default queue is auto bounded to the default exchange. + $this->createExchange(Broker::DEFAULT_EXCHANGE); + // And all queues are bounded to the retry exchange. + $this->createExchange(Broker::RETRY_EXCHANGE); + + $this->channel = $this->createChannel(); + } + + public function testDefaultConstructor() + { + $queue = new Queue($this->channel, 'test_queue.default'); + + $this->assertSame('test_queue.default', $queue->getName()); + $this->assertSame(\AMQP_DURABLE, $queue->getFlags()); + } + + public function testCustomConstructor() + { + $this->createExchange('test_queue.custom_exchange'); + + $queue = new Queue($this->channel, 'test_queue.routing_key', array( + 'flags' => \AMQP_NOPARAM, + 'exchange' => 'test_queue.custom_exchange', + 'retry_strategy' => $r = new ConstantRetryStrategy(5), + 'retry_strategy_queue_pattern' => '10', + )); + + $this->assertSame('test_queue.routing_key', $queue->getName()); + $this->assertSame(\AMQP_NOPARAM, $queue->getFlags()); + $this->assertSame($r, $queue->getRetryStrategy()); + $this->assertSame('10', $queue->getRetryStrategyQueuePattern()); + } + + public function provideCustomBinding() + { + $defaultBindings = array( + Broker::RETRY_EXCHANGE => array( + array( + 'routing_key' => 'test_queue.binding', + 'bind_arguments' => array(), + ), + ), + ); + + yield array($defaultBindings, false); + + $bindings = $defaultBindings + array( + Broker::DEFAULT_EXCHANGE => array( + array( + 'routing_key' => null, + 'bind_arguments' => array(), + ), + ), + ); + yield array($bindings, null); + + $bindings = $defaultBindings + array( + Broker::DEFAULT_EXCHANGE => array( + array( + 'routing_key' => 'foobar', + 'bind_arguments' => array(), + ), + ), + ); + yield array($bindings, 'foobar'); + + $bindings = $defaultBindings + array( + Broker::DEFAULT_EXCHANGE => array( + array( + 'routing_key' => 'foobar', + 'bind_arguments' => array(), + ), + array( + 'routing_key' => 'baz', + 'bind_arguments' => array(), + ), + ), + ); + yield array($bindings, array('foobar', 'baz')); + } + + /** + * @dataProvider provideCustomBinding + */ + public function testCustomBinding($expected, $routingKeys) + { + $queue = new Queue($this->channel, 'test_queue.binding', array( + 'routing_keys' => $routingKeys, + )); + + $this->assertEquals($expected, $queue->getBindings()); + } + + /** + * @expectedException \Symfony\Component\Amqp\Exception\InvalidArgumentException + * @expectedExceptionMessage "routing_keys" option should be a string, false, null or an array of string, "object" given. + */ + public function testInvalidRoutingKeys() + { + new Queue($this->channel, 'test_queue.binding', array( + 'routing_keys' => new \stdClass(), + )); + } +} diff --git a/src/Symfony/Component/Amqp/Tests/RetryStrategy/ConstantRetryStrategyTest.php b/src/Symfony/Component/Amqp/Tests/RetryStrategy/ConstantRetryStrategyTest.php new file mode 100644 index 0000000000000..e8f29a07200ac --- /dev/null +++ b/src/Symfony/Component/Amqp/Tests/RetryStrategy/ConstantRetryStrategyTest.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Amqp\Tests\RetryStrategy; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Amqp\RetryStrategy\ConstantRetryStrategy; + +class ConstantRetryStrategyTest extends TestCase +{ + /** + * @expectedException \Symfony\Component\Amqp\Exception\InvalidArgumentException + * @expectedExceptionMessage "time" should be at least 1. + */ + public function testConstrutorWithInvalidTime() + { + new ConstantRetryStrategy(0); + } + + public function testIsRetryable() + { + $strategy = new ConstantRetryStrategy(2, 3); + + $msg = $this->createMock(\AMQPEnvelope::class); + $msg + ->expects($this->at(0)) + ->method('getHeader') + ->with('retries') + ->willReturn(0) + ; + $msg + ->expects($this->at(1)) + ->method('getHeader') + ->with('retries') + ->willReturn(1) + ; + $msg + ->expects($this->at(2)) + ->method('getHeader') + ->with('retries') + ->willReturn(2) + ; + $msg + ->expects($this->at(3)) + ->method('getHeader') + ->with('retries') + ->willReturn(3) + ; + + $this->assertTrue($strategy->isRetryable($msg)); + $this->assertTrue($strategy->isRetryable($msg)); + $this->assertTrue($strategy->isRetryable($msg)); + $this->assertFalse($strategy->isRetryable($msg)); + + $this->assertSame(2, $strategy->getWaitingTime($msg)); + } +} diff --git a/src/Symfony/Component/Amqp/Tests/RetryStrategy/ExponentialRetryStrategyTest.php b/src/Symfony/Component/Amqp/Tests/RetryStrategy/ExponentialRetryStrategyTest.php new file mode 100644 index 0000000000000..3970d51d2e245 --- /dev/null +++ b/src/Symfony/Component/Amqp/Tests/RetryStrategy/ExponentialRetryStrategyTest.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Amqp\Tests\RetryStrategy; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Amqp\RetryStrategy\ExponentialRetryStrategy; + +class ExponentialRetryStrategyTest extends TestCase +{ + public function testIsRetryable() + { + $strategy = new ExponentialRetryStrategy(3); + + $msg = $this->createMock(\AMQPEnvelope::class); + $msg + ->expects($this->at(0)) + ->method('getHeader') + ->with('retries') + ->willReturn(0) + ; + $msg + ->expects($this->at(1)) + ->method('getHeader') + ->with('retries') + ->willReturn(1) + ; + $msg + ->expects($this->at(2)) + ->method('getHeader') + ->with('retries') + ->willReturn(2) + ; + $msg + ->expects($this->at(3)) + ->method('getHeader') + ->with('retries') + ->willReturn(3) + ; + $msg + ->expects($this->at(4)) + ->method('getHeader') + ->with('retries') + ->willReturn(3) + ; + + $this->assertTrue($strategy->isRetryable($msg)); + $this->assertTrue($strategy->isRetryable($msg)); + $this->assertTrue($strategy->isRetryable($msg)); + $this->assertFalse($strategy->isRetryable($msg)); + + $this->assertSame(8, $strategy->getWaitingTime($msg)); + } +} diff --git a/src/Symfony/Component/Amqp/Tests/UrlParserTest.php b/src/Symfony/Component/Amqp/Tests/UrlParserTest.php new file mode 100644 index 0000000000000..e040f36a0a086 --- /dev/null +++ b/src/Symfony/Component/Amqp/Tests/UrlParserTest.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Amqp\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Amqp\UrlParser; + +class UrlParserTest extends TestCase +{ + public function provideUri() + { + yield array('', array( + 'host' => 'localhost', + 'login' => 'guest', + 'password' => 'guest', + 'port' => 5672, + 'vhost' => '/', + )); + yield array('amqp://localhost', array( + 'host' => 'localhost', + 'login' => 'guest', + 'password' => 'guest', + 'port' => 5672, + 'vhost' => '/', + )); + yield array('amqp://localhost/', array( + 'host' => 'localhost', + 'login' => 'guest', + 'password' => 'guest', + 'port' => 5672, + 'vhost' => '/', + )); + yield array('amqp://localhost//', array( + 'host' => 'localhost', + 'login' => 'guest', + 'password' => 'guest', + 'port' => 5672, + 'vhost' => '/', + )); + yield array('amqp://foo:bar@rabbitmq-3.lxc:1234/symfony_amqp', array( + 'host' => 'rabbitmq-3.lxc', + 'login' => 'foo', + 'password' => 'bar', + 'port' => 1234, + 'vhost' => 'symfony_amqp', + )); + } + + /** + * @dataProvider provideUri + * */ + public function testParse($url, $expected) + { + $parts = UrlParser::parseUrl($url); + + $this->assertEquals($expected, $parts); + } +} diff --git a/src/Symfony/Component/Amqp/bin/reset.php b/src/Symfony/Component/Amqp/bin/reset.php new file mode 100755 index 0000000000000..c20303aa54d7a --- /dev/null +++ b/src/Symfony/Component/Amqp/bin/reset.php @@ -0,0 +1,62 @@ +#!/usr/bin/env php + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +if (file_exists($autoload = __DIR__.'/../vendor/autoload.php')) { + require $autoload; +} elseif (file_exists($autoload = __DIR__.'/../../../../../vendor/autoload.php')) { + require $autoload; +} else { + throw new \Exception('Impossible to find the autoloader.'); +} + +$url = getenv('RABBITMQ_URL'); + +if (!$url) { + $xml = new DomDocument(); + $xml->load(__DIR__.'/../phpunit.xml.dist'); + $url = (new DOMXpath($xml))->query('//php/env[@name="RABBITMQ_URL"]')[0]->getAttribute('value'); +} + +if (!isset($argv[1]) || 'force' !== $argv[1]) { + echo "You are going to use $url\n"; + echo 'Do you confirm? [Y/n]'; + $confirmation = strtolower(trim(fgets(STDIN))) ?: 'y'; + if (0 === strpos($confirmation, 'n')) { + echo "Aborted !\n"; + exit(1); + } +} + +extract(Symfony\Component\Amqp\UrlParser::parseUrl($url)); + +function call_api($method, $url, $content = null) +{ + global $host, $login, $password; + + $contextOptions = array( + 'http' => array( + 'header' => 'Authorization: Basic '.base64_encode("$login:$password")."\r\nContent-Type: application/json", + 'method' => $method, + 'ignore_errors' => true, + ), + ); + + if ($content) { + $contextOptions['http']['content'] = $content; + } + + file_get_contents("http://$host:15672/api$url", false, stream_context_create($contextOptions)); +} + +call_api('DELETE', "/vhosts/$vhost"); +call_api('PUT', "/vhosts/$vhost"); +call_api('PUT', "/permissions/$vhost/$login", '{"configure":".*","write":".*","read":".*"}'); diff --git a/src/Symfony/Component/Amqp/composer.json b/src/Symfony/Component/Amqp/composer.json new file mode 100644 index 0000000000000..1979b888142c3 --- /dev/null +++ b/src/Symfony/Component/Amqp/composer.json @@ -0,0 +1,45 @@ +{ + "name": "symfony/amqp", + "type": "library", + "description": "Library to dialog with AMQP", + "keywords": ["amqp", "rabbitmq", "consumer", "producer", "queue", "exchange", "worker"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=5.5.9", + "psr/log": "~1.0", + "symfony/event-dispatcher": "^2.3|^3.0|^4.0", + "queue-interop/amqp-interop": "^0.6", + "enqueue/amqp-tools": "^0.7" + }, + "require-dev": { + "symfony/phpunit-bridge": "^3.3", + "enqueue/amqp-bunny": "^0.7" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Amqp\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev", + "extra": { + "branch-alias": { + "dev-master": "3.4-dev" + } + } +} diff --git a/src/Symfony/Component/Amqp/phpunit.xml.dist b/src/Symfony/Component/Amqp/phpunit.xml.dist new file mode 100644 index 0000000000000..f8cc7e1dd4406 --- /dev/null +++ b/src/Symfony/Component/Amqp/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./bin + ./Test + ./Tests + ./vendor + + + + diff --git a/src/Symfony/Component/Worker/CHANGELOG.md b/src/Symfony/Component/Worker/CHANGELOG.md new file mode 100644 index 0000000000000..c4df4750f73b2 --- /dev/null +++ b/src/Symfony/Component/Worker/CHANGELOG.md @@ -0,0 +1,2 @@ +CHANGELOG +========= diff --git a/src/Symfony/Component/Worker/Command/WorkerListCommand.php b/src/Symfony/Component/Worker/Command/WorkerListCommand.php new file mode 100644 index 0000000000000..3110da5f60b68 --- /dev/null +++ b/src/Symfony/Component/Worker/Command/WorkerListCommand.php @@ -0,0 +1,46 @@ + + * @author Robin Chalas + */ +class WorkerListCommand extends Command +{ + private $workers; + + public function __construct(array $workers = array()) + { + parent::__construct(); + + $this->workers = $workers; + } + + protected function configure() + { + $this + ->setName('worker:list') + ->setDescription('Lists available workers.') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $io = new SymfonyStyle($input, $output); + + if (!$this->workers) { + $io->getErrorStyle()->error('There are no available workers.'); + + return; + } + + $io->getErrorStyle()->title('Available workers'); + $io->listing($this->workers); + } +} diff --git a/src/Symfony/Component/Worker/Command/WorkerRunCommand.php b/src/Symfony/Component/Worker/Command/WorkerRunCommand.php new file mode 100644 index 0000000000000..7eab301f91bf6 --- /dev/null +++ b/src/Symfony/Component/Worker/Command/WorkerRunCommand.php @@ -0,0 +1,90 @@ + + * @author Robin Chalas + */ +class WorkerRunCommand extends Command +{ + private $container; + private $processTitlePrefix; + private $workers; + + /** + * @param ContainerInterface $container A PSR11 container from which to load workers by names + * @param string $processTitlePrefix + * @param string[] $workers + */ + public function __construct(ContainerInterface $container, $processTitlePrefix, array $workers = array()) + { + parent::__construct(); + + $this->container = $container; + $this->processTitlePrefix = $processTitlePrefix; + $this->workers = $workers; + } + + protected function configure() + { + $this + ->setName('worker:run') + ->setDescription('Runs a worker') + ->setDefinition(array( + new InputArgument('worker', InputArgument::REQUIRED, 'The worker'), + new InputOption('name', null, InputOption::VALUE_REQUIRED, 'A name, useful for stats/monitoring. Defaults to worker name.'), + )) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + if (!extension_loaded('pcntl')) { + throw new \RuntimeException('The pcntl extension is mandatory.'); + } + + $workerName = $input->getArgument('worker'); + $loop = $this->getLoop($workerName); + + $loopName = $input->getOption('name') ?: $loop->getName(); + + if ($loop instanceof ConfigurableLoopInterface) { + $loop->setName($loopName); + } + + $this->setProcessTitle(sprintf('%s_%s', $this->processTitlePrefix, $loopName)); + + pcntl_signal(SIGTERM, function () use ($loop) { + $loop->stop('Signaled with SIGTERM.'); + }); + pcntl_signal(SIGINT, function () use ($loop) { + $loop->stop('Signaled with SIGINT.'); + }); + + (new SymfonyStyle($input, $output))->success("Running worker $workerName"); + + $loop->run(); + } + + private function getLoop($workerName) + { + if (!array_key_exists($workerName, $this->workers)) { + throw new \InvalidArgumentException(sprintf( + 'The worker "%s" does not exist. Available ones are: "%s".', + $workerName, implode('", "', $this->workers) + )); + } + + return $this->container->get($this->workers[$workerName]); + } +} diff --git a/src/Symfony/Component/Worker/Consumer/ConsumerEvents.php b/src/Symfony/Component/Worker/Consumer/ConsumerEvents.php new file mode 100644 index 0000000000000..273de590ebf0c --- /dev/null +++ b/src/Symfony/Component/Worker/Consumer/ConsumerEvents.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Worker\Consumer; + +/** + * @author Grégoire Pineau + */ +class ConsumerEvents +{ + const PRE_CONSUME = 'worker.pre_consume'; + const POST_CONSUME = 'worker.post_consume'; + + private function __construct() + { + } +} diff --git a/src/Symfony/Component/Worker/Consumer/ConsumerInterface.php b/src/Symfony/Component/Worker/Consumer/ConsumerInterface.php new file mode 100644 index 0000000000000..ddf5a90366ad4 --- /dev/null +++ b/src/Symfony/Component/Worker/Consumer/ConsumerInterface.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Worker\Consumer; + +use Symfony\Component\Worker\MessageCollection; + +/** + * @author Grégoire Pineau + */ +interface ConsumerInterface +{ + public function consume(MessageCollection $messageCollection); +} diff --git a/src/Symfony/Component/Worker/Consumer/EventConsumer.php b/src/Symfony/Component/Worker/Consumer/EventConsumer.php new file mode 100644 index 0000000000000..76c45d4f15bdd --- /dev/null +++ b/src/Symfony/Component/Worker/Consumer/EventConsumer.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Worker\Consumer; + +use Symfony\Component\Worker\MessageCollection; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; + +/** + * @author Grégoire Pineau + */ +class EventConsumer implements ConsumerInterface +{ + private $consumer; + private $eventDispatcher; + private $name; + + public function __construct(ConsumerInterface $consumer, EventDispatcherInterface $eventDispatcher, $name = null) + { + $this->consumer = $consumer; + $this->eventDispatcher = $eventDispatcher; + $this->name = $name; + } + + public function consume(MessageCollection $messageCollection) + { + $this->dispatch(ConsumerEvents::PRE_CONSUME, $messageCollection); + + $this->consumer->consume($messageCollection); + + $this->dispatch(ConsumerEvents::POST_CONSUME, $messageCollection); + } + + private function dispatch($eventName, MessageCollection $messageCollection) + { + $event = new MessageCollectionEvent($messageCollection); + + $this->eventDispatcher->dispatch($eventName, $event); + + if ($this->name) { + $localEventName = sprintf('%s.%s', $eventName, $this->name); + $this->eventDispatcher->dispatch($localEventName, $event); + } + } +} diff --git a/src/Symfony/Component/Worker/Consumer/MessageCollectionEvent.php b/src/Symfony/Component/Worker/Consumer/MessageCollectionEvent.php new file mode 100644 index 0000000000000..f0986c88130be --- /dev/null +++ b/src/Symfony/Component/Worker/Consumer/MessageCollectionEvent.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Worker\Consumer; + +use Symfony\Component\Worker\MessageCollection; +use Symfony\Component\EventDispatcher\Event; + +/** + * @author Grégoire Pineau + */ +class MessageCollectionEvent extends Event +{ + private $messageCollection; + + public function __construct(MessageCollection $messageCollection) + { + $this->messageCollection = $messageCollection; + } + + /** + * @return MessageCollection + */ + public function getMessageCollection() + { + return $this->messageCollection; + } +} diff --git a/src/Symfony/Component/Worker/EventListener/ClearDoctrineListener.php b/src/Symfony/Component/Worker/EventListener/ClearDoctrineListener.php new file mode 100644 index 0000000000000..2bfa5fc2c32f6 --- /dev/null +++ b/src/Symfony/Component/Worker/EventListener/ClearDoctrineListener.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Worker\EventListener; + +use Doctrine\ORM\EntityManager; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Worker\Consumer\ConsumerEvents; +use Symfony\Component\Worker\Loop\LoopEvents; + +/** + * @author Grégoire Pineau + */ +class ClearDoctrineListener implements EventSubscriberInterface +{ + private $entityManager; + + public function __construct(EntityManagerInterface $entityManager = null) + { + $this->entityManager = $entityManager; + } + + public static function getSubscribedEvents() + { + return array( + LoopEvents::SLEEP => 'clearDoctrine', + ConsumerEvents::POST_CONSUME => 'clearDoctrine', + ); + } + + public function clearDoctrine() + { + if ($this->entityManager) { + $this->entityManager->clear(); + } + } +} diff --git a/src/Symfony/Component/Worker/EventListener/LimitMemoryUsageListener.php b/src/Symfony/Component/Worker/EventListener/LimitMemoryUsageListener.php new file mode 100644 index 0000000000000..a2608c18d10d7 --- /dev/null +++ b/src/Symfony/Component/Worker/EventListener/LimitMemoryUsageListener.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Worker\EventListener; + +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Worker\Consumer\ConsumerEvents; +use Symfony\Component\Worker\Exception\StopException; +use Symfony\Component\Worker\Loop\LoopEvents; + +/** + * @author Grégoire Pineau + */ +class LimitMemoryUsageListener implements EventSubscriberInterface +{ + private $threshold; + + /** + * @param int $threshold in bytes. Defaults to 100Mb + */ + public function __construct($threshold = 104857600) + { + $this->threshold = $threshold; + } + + public static function getSubscribedEvents() + { + return array( + LoopEvents::SLEEP => 'limitMemoryUsage', + ConsumerEvents::POST_CONSUME => 'limitMemoryUsage', + ); + } + + /** + * @throws StopException + */ + public function limitMemoryUsage() + { + gc_collect_cycles(); + + if ($this->threshold < memory_get_usage()) { + throw new StopException(sprintf('Memory usage is too high (current: %s, limit: %s)', memory_get_usage(), $this->threshold)); + } + } +} diff --git a/src/Symfony/Component/Worker/Exception/StopException.php b/src/Symfony/Component/Worker/Exception/StopException.php new file mode 100644 index 0000000000000..6ce93db577723 --- /dev/null +++ b/src/Symfony/Component/Worker/Exception/StopException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Worker\Exception; + +/** + * @author Grégoire Pineau + */ +class StopException extends \RuntimeException +{ +} diff --git a/src/Symfony/Component/Worker/Loop/ConfigurableLoopInterface.php b/src/Symfony/Component/Worker/Loop/ConfigurableLoopInterface.php new file mode 100644 index 0000000000000..a9cd1f00cb46d --- /dev/null +++ b/src/Symfony/Component/Worker/Loop/ConfigurableLoopInterface.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Worker\Loop; + +/** + * @author Grégoire Pineau + */ +interface ConfigurableLoopInterface extends LoopInterface +{ + public function setName($name); +} diff --git a/src/Symfony/Component/Worker/Loop/Loop.php b/src/Symfony/Component/Worker/Loop/Loop.php new file mode 100644 index 0000000000000..ad197a5a45b5f --- /dev/null +++ b/src/Symfony/Component/Worker/Loop/Loop.php @@ -0,0 +1,199 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Worker\Loop; + +use Psr\Log\LoggerInterface; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\Worker\Exception\StopException; +use Symfony\Component\Worker\MessageCollection; +use Symfony\Component\Worker\Router\RouterInterface; + +/** + * @author Grégoire Pineau + */ +class Loop implements ConfigurableLoopInterface +{ + private $router; + private $eventDispatcher; + private $logger; + private $name; + private $options; + + private $stopped; + private $startedAt; + private $lastHealthCheck; + + public function __construct(RouterInterface $router, EventDispatcherInterface $eventDispatcher = null, LoggerInterface $logger = null, $name = 'unnamed', array $options = array()) + { + if (!extension_loaded('pcntl')) { + throw new \RuntimeException('The pcntl extension is mandatory.'); + } + + $this->router = $router; + $this->eventDispatcher = $eventDispatcher; + $this->logger = $logger; + $this->name = $name; + $this->options = array_replace(array( + 'loop_sleep' => 200000, + 'health_check_interval' => 10, + ), $options); + + $this->stopped = false; + $this->startedAt = time(); + // Fake the date to trigger right now a new health check. + $this->lastHealthCheck = 0; + } + + public function run() + { + if (null !== $this->logger) { + $this->logger->notice('Worker {worker} started.', array( + 'worker' => $this->name, + )); + } + + $this->dispatch(LoopEvents::RUN); + + try { + loop: + + pcntl_signal_dispatch(); + + if ($this->healthCheck()) { + $this->dispatch(LoopEvents::HEALTH_CHECK); + } + + if ($this->stopped) { + return; + } + + $this->dispatch(LoopEvents::WAKE_UP); + + while (false !== $messageCollection = $this->router->fetchMessages()) { + if (!$messageCollection instanceof MessageCollection) { + throw new \RuntimeException('This is not a MessageCollection instance.'); + } + if (null !== $this->logger) { + $this->logger->notice('New message.'); + } + + $result = $this->router->consume($messageCollection); + + if (null !== $this->logger) { + if (false === $result) { + $this->logger->warning('Messages consumed with failure.'); + } else { + $this->logger->info('Messages consumed successfully.'); + } + } + + pcntl_signal_dispatch(); + + if ($this->healthCheck()) { + $this->dispatch(LoopEvents::HEALTH_CHECK); + } + + if ($this->stopped) { + return; + } + } + + $this->dispatch(LoopEvents::SLEEP); + + usleep($this->options['loop_sleep']); + + goto loop; + } catch (StopException $e) { + $this->stop('Force shut down of the worker because a StopException has been thrown.', $e); + + return; + } catch (\Exception $e) { + } catch (\Throwable $e) { + } + + // Not possible, but here just in case. + if (!isset($e)) { + return; + } + + if (null !== $this->logger) { + $this->logger->error('Worker {worker} has errored, shutting down. ({message})', array( + 'exception' => $e, + 'worker' => $this->name, + 'message' => $e->getMessage(), + )); + } + + throw $e; + } + + public function stop($message = 'unknown reason.', \Exception $exception = null) + { + $this->dispatch(LoopEvents::STOP); + + if (null !== $this->logger) { + $this->logger->notice('Worker {worker} stopped ({message}).', array( + 'exception' => $exception, + 'message' => $message, + 'worker' => $this->name, + )); + } + + $this->stopped = true; + } + + /** + * @return int + */ + public function getStartedAt() + { + return $this->startedAt; + } + + /** + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * @param string $name + */ + public function setName($name) + { + $this->name = $name; + } + + private function dispatch($eventName) + { + if (null === $this->eventDispatcher) { + return; + } + + $event = new LoopEvent($this); + + $this->eventDispatcher->dispatch($eventName, $event); + } + + private function healthCheck() + { + if (time() >= $this->lastHealthCheck + $this->options['health_check_interval']) { + $this->lastHealthCheck = time(); + + return true; + } + + return false; + } +} diff --git a/src/Symfony/Component/Worker/Loop/LoopEvent.php b/src/Symfony/Component/Worker/Loop/LoopEvent.php new file mode 100644 index 0000000000000..0f8ea71ff8df6 --- /dev/null +++ b/src/Symfony/Component/Worker/Loop/LoopEvent.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Worker\Loop; + +use Symfony\Component\EventDispatcher\Event; + +/** + * @author Grégoire Pineau + */ +class LoopEvent extends Event +{ + private $loop; + + public function __construct(LoopInterface $loop) + { + $this->loop = $loop; + } + + /** + * @return LoopInterface + */ + public function getLoop() + { + return $this->loop; + } +} diff --git a/src/Symfony/Component/Worker/Loop/LoopEvents.php b/src/Symfony/Component/Worker/Loop/LoopEvents.php new file mode 100644 index 0000000000000..c73e5e8fe93d7 --- /dev/null +++ b/src/Symfony/Component/Worker/Loop/LoopEvents.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Worker\Loop; + +/** + * @author Grégoire Pineau + */ +final class LoopEvents +{ + const RUN = 'worker.run'; + const HEALTH_CHECK = 'worker.health_check'; + const WAKE_UP = 'worker.wake_up'; + const SLEEP = 'worker.sleep'; + const STOP = 'worker.stop'; + + private function __construct() + { + } +} diff --git a/src/Symfony/Component/Worker/Loop/LoopInterface.php b/src/Symfony/Component/Worker/Loop/LoopInterface.php new file mode 100644 index 0000000000000..ed6effeb21046 --- /dev/null +++ b/src/Symfony/Component/Worker/Loop/LoopInterface.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Worker\Loop; + +/** + * @author Grégoire Pineau + */ +interface LoopInterface +{ + public function run(); + + public function stop(); + + /** + * @return int + */ + public function getStartedAt(); + + /** + * @return string + */ + public function getName(); +} diff --git a/src/Symfony/Component/Worker/MessageCollection.php b/src/Symfony/Component/Worker/MessageCollection.php new file mode 100644 index 0000000000000..cdbec34fb9b39 --- /dev/null +++ b/src/Symfony/Component/Worker/MessageCollection.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Worker; + +/** + * @author Grégoire Pineau + */ +class MessageCollection implements \IteratorAggregate, \Countable +{ + private $messages = array(); + + public function __construct($message = null) + { + if ($message) { + $this->messages[] = $message; + } + } + + public function add($message) + { + $this->messages[] = $message; + } + + public function all() + { + $all = $this->messages; + + $this->messages = array(); + + return $all; + } + + public function pop() + { + return array_shift($this->messages); + } + + public function getIterator() + { + return new \ArrayIterator($this->messages); + } + + public function count() + { + return count($this->messages); + } +} diff --git a/src/Symfony/Component/Worker/MessageFetcher/AmqpMessageFetcher.php b/src/Symfony/Component/Worker/MessageFetcher/AmqpMessageFetcher.php new file mode 100644 index 0000000000000..582b1ec73fa7f --- /dev/null +++ b/src/Symfony/Component/Worker/MessageFetcher/AmqpMessageFetcher.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Worker\MessageFetcher; + +use Interop\Amqp\AmqpConsumer; +use Symfony\Component\Amqp\Broker; +use Symfony\Component\Worker\MessageCollection; + +/** + * @author Grégoire Pineau + */ +class AmqpMessageFetcher implements MessageFetcherInterface +{ + private $broker; + private $queueName; + private $flags; + + public function __construct(Broker $broker, $queueName, $autoAck = false) + { + $this->broker = $broker; + $this->queueName = $queueName; + $this->flags = $autoAck ? AmqpConsumer::FLAG_NOACK : AmqpConsumer::FLAG_NOPARAM; + } + + public function fetchMessages() + { + $msg = $this->broker->get($this->queueName, $this->flags); + + if (false === $msg) { + return false; + } + + return new MessageCollection($msg); + } +} diff --git a/src/Symfony/Component/Worker/MessageFetcher/BufferedMessageFetcher.php b/src/Symfony/Component/Worker/MessageFetcher/BufferedMessageFetcher.php new file mode 100644 index 0000000000000..be0e0fe725eb2 --- /dev/null +++ b/src/Symfony/Component/Worker/MessageFetcher/BufferedMessageFetcher.php @@ -0,0 +1,79 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Worker\MessageFetcher; + +use Symfony\Component\Worker\MessageCollection; + +/** + * @author Grégoire Pineau + */ +class BufferedMessageFetcher implements MessageFetcherInterface +{ + private $messageFetcher; + private $options; + private $messageCollections; + private $lastBufferingAt; + + public function __construct(MessageFetcherInterface $messageFetcher, array $options = array()) + { + $this->messageFetcher = $messageFetcher; + $this->options = array_replace(array( + 'max_buffering_time' => 10, + 'max_messages' => 10, + ), $options); + $this->messageCollections = array(); + } + + /** + * @return MessageCollection|bool A collection of messages, false otherwise + */ + public function fetchMessages() + { + $bufferSize = count($this->messageCollections); + + while ($messageCollection = $this->fetchNextMessage($bufferSize)) { + $this->messageCollections[] = $messageCollection; + $bufferSize += count($messageCollection); + $this->lastBufferingAt = time(); + } + + $isBufferFull = $bufferSize === $this->options['max_messages']; + $isBufferExpirated = time() - $this->lastBufferingAt >= $this->options['max_buffering_time'] && 0 !== $bufferSize; + + if ($isBufferFull || $isBufferExpirated) { + $messageCollections = $this->messageCollections; + + $this->messageCollections = array(); + + $messageCollection = new MessageCollection(); + + foreach ($messageCollections as $msgCollection) { + foreach ($msgCollection as $message) { + $messageCollection->add($message); + } + } + + return $messageCollection; + } + + return false; + } + + private function fetchNextMessage($bufferSize) + { + if ($bufferSize >= $this->options['max_messages']) { + return false; + } + + return $this->messageFetcher->fetchMessages(); + } +} diff --git a/src/Symfony/Component/Worker/MessageFetcher/InMemoryMessageFetcher.php b/src/Symfony/Component/Worker/MessageFetcher/InMemoryMessageFetcher.php new file mode 100644 index 0000000000000..e89e9ad7797cd --- /dev/null +++ b/src/Symfony/Component/Worker/MessageFetcher/InMemoryMessageFetcher.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Worker\MessageFetcher; + +use Symfony\Component\Worker\MessageCollection; + +/** + * @author Grégoire Pineau + */ +class InMemoryMessageFetcher implements MessageFetcherInterface +{ + private $messages; + + public function __construct(array $messages = array()) + { + $this->messages = $messages; + } + + public function fetchMessages() + { + if (!$this->messages) { + return false; + } + + $message = array_shift($this->messages); + + if (false === $message) { + return false; + } + + return new MessageCollection($message); + } + + public function queueMessage($message) + { + $this->messages[] = $message; + } +} diff --git a/src/Symfony/Component/Worker/MessageFetcher/MessageFetcherInterface.php b/src/Symfony/Component/Worker/MessageFetcher/MessageFetcherInterface.php new file mode 100644 index 0000000000000..5b631133d5ec5 --- /dev/null +++ b/src/Symfony/Component/Worker/MessageFetcher/MessageFetcherInterface.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Worker\MessageFetcher; + +/** + * @author Grégoire Pineau + */ +interface MessageFetcherInterface +{ + /** + * @return string|bool The message or false + */ + public function fetchMessages(); +} diff --git a/src/Symfony/Component/Worker/README.md b/src/Symfony/Component/Worker/README.md new file mode 100644 index 0000000000000..b926a05ad95e2 --- /dev/null +++ b/src/Symfony/Component/Worker/README.md @@ -0,0 +1,11 @@ +Worker Component +================ + +Resources +--------- + + * [Documentation](https://symfony.com/doc/current/components/worker.html) + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/src/Symfony/Component/Worker/Router/DirectRouter.php b/src/Symfony/Component/Worker/Router/DirectRouter.php new file mode 100644 index 0000000000000..ce355e521b9f0 --- /dev/null +++ b/src/Symfony/Component/Worker/Router/DirectRouter.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Worker\Router; + +use Symfony\Component\Worker\Consumer\ConsumerInterface; +use Symfony\Component\Worker\MessageCollection; +use Symfony\Component\Worker\MessageFetcher\MessageFetcherInterface; + +/** + * @author Grégoire Pineau + */ +class DirectRouter implements RouterInterface +{ + private $messageFetcher; + private $consumer; + + public function __construct(MessageFetcherInterface $messageFetcher, ConsumerInterface $consumer) + { + $this->messageFetcher = $messageFetcher; + $this->consumer = $consumer; + } + + public function fetchMessages() + { + return $this->messageFetcher->fetchMessages(); + } + + public function consume(MessageCollection $messageCollection) + { + return $this->consumer->consume($messageCollection); + } +} diff --git a/src/Symfony/Component/Worker/Router/RoundRobinRouter.php b/src/Symfony/Component/Worker/Router/RoundRobinRouter.php new file mode 100644 index 0000000000000..6b0cc3a96efae --- /dev/null +++ b/src/Symfony/Component/Worker/Router/RoundRobinRouter.php @@ -0,0 +1,93 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Worker\Router; + +use Symfony\Component\Worker\MessageCollection; + +/** + * @author Grégoire Pineau + */ +class RoundRobinRouter implements RouterInterface +{ + private $consumeEverything; + private $cycle; + private $mapping; + + public function __construct(array $routers, $consumeEverything = false) + { + if (!$routers) { + throw new \LogicException('At least one routers should be set up.'); + } + + foreach ($routers as $router) { + if (!$router instanceof RouterInterface) { + throw new \LogicException('The item is not an instance of RouterInterface.'); + } + } + + $this->cycle = new \InfiniteIterator(new \ArrayIterator($routers)); + $this->cycle->rewind(); + + $this->consumeEverything = $consumeEverything; + + $this->mapping = new \SplObjectStorage(); + } + + public function fetchMessages() + { + $router = $this->cycle->current(); + + $messageCollection = $router->fetchMessages(); + + if (false !== $messageCollection && !$messageCollection instanceof MessageCollection) { + throw new \RuntimeException('This is not a MessageCollection instance or false.'); + } + + if (false !== $messageCollection) { + $this->mapping[$messageCollection] = $router; + + return $messageCollection; + } + + // Try other fetcher, but stop the loop after one iteration + while (($nextRouter = $this->next()) && $nextRouter !== $router) { + $messageCollection = $nextRouter->fetchMessages(); + + if (false !== $messageCollection) { + $this->mapping[$messageCollection] = $nextRouter; + + return $messageCollection; + } + } + + return false; + } + + public function consume(MessageCollection $messageCollection) + { + if (false === $this->consumeEverything) { + $this->next(); + } + + $router = $this->mapping[$messageCollection]; + $this->mapping->detach($messageCollection); + + return $router->consume($messageCollection); + } + + private function next() + { + $this->cycle->next(); + + return $this->cycle->current(); + } +} diff --git a/src/Symfony/Component/Worker/Router/RouterInterface.php b/src/Symfony/Component/Worker/Router/RouterInterface.php new file mode 100644 index 0000000000000..ac027545287b5 --- /dev/null +++ b/src/Symfony/Component/Worker/Router/RouterInterface.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Worker\Router; + +use Symfony\Component\Worker\MessageCollection; + +/** + * @author Grégoire Pineau + */ +interface RouterInterface +{ + public function fetchMessages(); + + public function consume(MessageCollection $messageCollection); +} diff --git a/src/Symfony/Component/Worker/Tests/Command/WorkerListCommandTest.php b/src/Symfony/Component/Worker/Tests/Command/WorkerListCommandTest.php new file mode 100644 index 0000000000000..c03e3f33964d6 --- /dev/null +++ b/src/Symfony/Component/Worker/Tests/Command/WorkerListCommandTest.php @@ -0,0 +1,36 @@ +execute(array()); + $this->assertSame($expected, $tester->getDisplay()); + } + + public function testExecuteNoWorker() + { + $tester = new CommandTester(new WorkerListCommand()); + $tester->execute(array(), array('decorated' => false)); + $this->assertContains('[ERROR] There are no available workers.', $tester->getDisplay()); + } +} diff --git a/src/Symfony/Component/Worker/Tests/Loop/LoopTest.php b/src/Symfony/Component/Worker/Tests/Loop/LoopTest.php new file mode 100644 index 0000000000000..20655f0fc79f6 --- /dev/null +++ b/src/Symfony/Component/Worker/Tests/Loop/LoopTest.php @@ -0,0 +1,276 @@ +messageFetcher = new MessageFetcher(); + $this->consumer = new ConsumerMock(); + $this->eventDispatcher = new EventDispatcherMock(); + $this->logger = new LoggerMock(); + + $this->loop = new Loop(new DirectRouter($this->messageFetcher, $this->consumer), $this->eventDispatcher, $this->logger, 'a_queue_name'); + } + + public function provideReturnStatus() + { + yield array(false, 'warning Messages consumed with failure.'); + yield array(true, 'info Messages consumed successfully.'); + } + + /** + * @dataProvider provideReturnStatus + */ + public function testConsumeAllPendingMessagesInOneRow($returnStatus, $expectedLog) + { + $this->messageFetcher->messages = array('a', 'b'); + $this->consumer->setConsumeCode(function () use ($returnStatus) { + return $returnStatus; + }); + + $this->loop->run(); + + $this->assertSame(array('a', 'b'), $this->consumer->messages); + + $expectedEvents = array( + 'worker.run', + 'worker.health_check', + 'worker.wake_up', + 'worker.stop', + ); + + $this->assertSame($expectedEvents, $this->eventDispatcher->dispatchedEvents); + + $expectedLogs = array( + 'notice Worker a_queue_name started.', + 'notice New message.', + $expectedLog, + 'notice New message.', + $expectedLog, + 'notice Worker a_queue_name stopped (Force shut down of the worker because a StopException has been thrown.).', + ); + + $this->assertEquals($expectedLogs, $this->logger->logs); + } + + public function testConsumePendingMessages() + { + $this->messageFetcher->messages = array('a', false, 'b'); + + $this->loop->run(); + + $this->assertSame(array('a', 'b'), $this->consumer->messages); + + $expectedEvents = array( + 'worker.run', + 'worker.health_check', + 'worker.wake_up', + 'worker.sleep', + 'worker.wake_up', + 'worker.stop', + ); + + $this->assertEquals($expectedEvents, $this->eventDispatcher->dispatchedEvents); + + $expectedLogs = array( + 'notice Worker a_queue_name started.', + 'notice New message.', + 'info Messages consumed successfully.', + 'notice New message.', + 'info Messages consumed successfully.', + 'notice Worker a_queue_name stopped (Force shut down of the worker because a StopException has been thrown.).', + ); + + $this->assertSame($expectedLogs, $this->logger->logs); + } + + public function testSignal() + { + $this->messageFetcher->messages = array('a'); + + // After 1 second a SIGALRM signal will be fired and it will stop the + // loop. + pcntl_signal(SIGALRM, function () { + $this->loop->stop('Signaled with SIGALRM'); + }); + pcntl_alarm(1); + + // Let's wait 1 second in the consumer, to avoid too many loop iteration + // in order to avoid too many event. + $this->consumer->setConsumeCode(function () { + // we don't want to use the mock sleep here + \sleep(1); + }); + + $this->loop->run(); + + $expectedEvents = array( + 'worker.run', + 'worker.health_check', + 'worker.wake_up', + 'worker.stop', + ); + + $this->assertSame($expectedEvents, $this->eventDispatcher->dispatchedEvents); + + $expectedLogs = array( + 'notice Worker a_queue_name started.', + 'notice New message.', + 'info Messages consumed successfully.', + 'notice Worker a_queue_name stopped (Signaled with SIGALRM).', + ); + + $this->assertSame($expectedLogs, $this->logger->logs); + } + + public function testHealthCheck() + { + $this->messageFetcher->messages = array('a'); + + // default health check is done every 10 seconds + $this->consumer->setConsumeCode(function () { + sleep(10); + }); + + $this->loop->run(); + + $expectedEvents = array( + 'worker.run', + 'worker.health_check', + 'worker.wake_up', + 'worker.health_check', + 'worker.stop', + ); + + $this->assertEquals($expectedEvents, $this->eventDispatcher->dispatchedEvents); + } + + public function provideException() + { + yield array(new \AMQPConnectionException('AMQP connexion error.'), 'error Worker a_queue_name has errored, shutting down. (AMQP connexion error.)'); + yield array(new \Exception('oups.'), 'error Worker a_queue_name has errored, shutting down. (oups.)'); + } + + /** + * @dataProvider provideException + */ + public function testException(\Exception $exception, $expectedLog) + { + $this->messageFetcher->messages = array('a'); + + $this->consumer->setConsumeCode(function () use ($exception) { + throw $exception; + }); + + $expectedLogs = array( + 'notice Worker a_queue_name started.', + 'notice New message.', + $expectedLog, + ); + + try { + $this->loop->run(); + + $this->fail('An exception should be thrown.'); + } catch (\Exception $e) { + $this->assertSame($e, $exception); + } + + $this->assertSame($expectedLogs, $this->logger->logs); + } +} + +class ConsumerMock implements ConsumerInterface +{ + public $loop; + public $messages = array(); + + private $consumeCode; + + public function consume(MessageCollection $messageCollection) + { + foreach ($messageCollection as $message) { + $this->messages[] = $message; + } + + if ($this->consumeCode) { + return call_user_func($this->consumeCode); + } + } + + public function setConsumeCode(callable $consumeCode) + { + $this->consumeCode = $consumeCode; + } +} + +class MessageFetcher implements MessageFetcherInterface +{ + public $messages = array(); + + public function fetchMessages() + { + if (!$this->messages) { + throw new StopException(); + } + + $message = array_shift($this->messages); + + if (false === $message) { + return false; + } + + return new MessageCollection($message); + } +} + +class EventDispatcherMock extends EventDispatcher +{ + public $dispatchedEvents = array(); + + public function dispatch($eventName, \Symfony\Component\EventDispatcher\Event $event = null) + { + $this->dispatchedEvents[] = $eventName; + } +} + +class LoggerMock extends AbstractLogger +{ + public $logs = array(); + + public function log($level, $message, array $context = array()) + { + $replacements = array(); + foreach ($context as $key => $val) { + if (null === $val || is_scalar($val) || (is_object($val) && method_exists($val, '__toString'))) { + $replacements['{'.$key.'}'] = $val; + } + } + + $message = strtr($message, $replacements); + + $this->logs[] = sprintf('%-8s %s', $level, $message); + } +} diff --git a/src/Symfony/Component/Worker/Tests/MessageCollectionTest.php b/src/Symfony/Component/Worker/Tests/MessageCollectionTest.php new file mode 100644 index 0000000000000..fc75380f13709 --- /dev/null +++ b/src/Symfony/Component/Worker/Tests/MessageCollectionTest.php @@ -0,0 +1,32 @@ +add('B'); + + $this->assertCount(2, $col); + $this->assertEquals(array('A', 'B'), $col->all()); + + $this->assertCount(0, $col); + $this->assertEquals(array(), $col->all()); + + $col->add('D'); + $col->add('E'); + + $this->assertCount(2, $col); + $this->assertSame(array('D', 'E'), iterator_to_array($col)); + $this->assertEquals('D', $col->pop()); + $this->assertEquals('E', $col->pop()); + $this->assertEquals(null, $col->pop()); + $this->assertEquals(array(), $col->all()); + $this->assertCount(0, $col); + } +} diff --git a/src/Symfony/Component/Worker/Tests/MessageFetcher/AmqpMessageFetcherTest.php b/src/Symfony/Component/Worker/Tests/MessageFetcher/AmqpMessageFetcherTest.php new file mode 100644 index 0000000000000..7c7e11aeb6e3b --- /dev/null +++ b/src/Symfony/Component/Worker/Tests/MessageFetcher/AmqpMessageFetcherTest.php @@ -0,0 +1,77 @@ +getMockBuilder(Broker::class)->disableOriginalConstructor()->getMock(); + $broker + ->expects($this->once()) + ->method('get') + ->with('queue', \AMQP_AUTOACK) + ->willReturn(false) + ; + + $messageFetcher = new AmqpMessageFetcher($broker, 'queue', true); + + $collection = $messageFetcher->fetchMessages(); + $this->assertFalse($collection); + } + + public function testWithAutoAckAndOneMessage() + { + $broker = $this->getMockBuilder(Broker::class)->disableOriginalConstructor()->getMock(); + $broker + ->expects($this->once()) + ->method('get') + ->with('queue', \AMQP_AUTOACK) + ->willReturn('A') + ; + + $messageFetcher = new AmqpMessageFetcher($broker, 'queue', true); + + $collection = $messageFetcher->fetchMessages(); + $this->assertInstanceOf(MessageCollection::class, $collection); + $this->assertSame(array('A'), iterator_to_array($collection)); + } + + public function testWithoutAutoAckAndNoMessage() + { + $broker = $this->getMockBuilder(Broker::class)->disableOriginalConstructor()->getMock(); + $broker + ->expects($this->once()) + ->method('get') + ->with('queue', \AMQP_NOPARAM) + ->willReturn(false) + ; + + $messageFetcher = new AmqpMessageFetcher($broker, 'queue', false); + + $collection = $messageFetcher->fetchMessages(); + $this->assertFalse($collection); + } + + public function testWithoutAutoAckAndOneMessage() + { + $broker = $this->getMockBuilder(Broker::class)->disableOriginalConstructor()->getMock(); + $broker + ->expects($this->once()) + ->method('get') + ->with('queue', \AMQP_NOPARAM) + ->willReturn('A') + ; + + $messageFetcher = new AmqpMessageFetcher($broker, 'queue', false); + + $collection = $messageFetcher->fetchMessages(); + $this->assertInstanceOf(MessageCollection::class, $collection); + $this->assertSame(array('A'), iterator_to_array($collection)); + } +} diff --git a/src/Symfony/Component/Worker/Tests/MessageFetcher/BufferedMessageFetcherTest.php b/src/Symfony/Component/Worker/Tests/MessageFetcher/BufferedMessageFetcherTest.php new file mode 100644 index 0000000000000..3aa859c1f76a7 --- /dev/null +++ b/src/Symfony/Component/Worker/Tests/MessageFetcher/BufferedMessageFetcherTest.php @@ -0,0 +1,43 @@ + 2, + )); + + $collection = $buffer->fetchMessages(); + $this->assertInstanceOf(MessageCollection::class, $collection); + $this->assertSame(array(1, 2), iterator_to_array($collection)); + + $collection = $buffer->fetchMessages(); + $this->assertInstanceOf(MessageCollection::class, $collection); + $this->assertSame(array(3, 4), iterator_to_array($collection)); + + // Wait for another message + $collection = $buffer->fetchMessages(); + $this->assertFalse($collection); + + sleep(10); + + $collection = $buffer->fetchMessages(); + $this->assertInstanceOf(MessageCollection::class, $collection); + $this->assertSame(array(5), iterator_to_array($collection)); + + $collection = $buffer->fetchMessages(); + $this->assertFalse($collection); + } +} diff --git a/src/Symfony/Component/Worker/Tests/MessageFetcher/InMemoryMessageFetcherTest.php b/src/Symfony/Component/Worker/Tests/MessageFetcher/InMemoryMessageFetcherTest.php new file mode 100644 index 0000000000000..868cc685a084d --- /dev/null +++ b/src/Symfony/Component/Worker/Tests/MessageFetcher/InMemoryMessageFetcherTest.php @@ -0,0 +1,38 @@ +fetchMessages(); + $this->assertInstanceOf(MessageCollection::class, $collection); + $this->assertSame(array('A'), iterator_to_array($collection)); + + $collection = $fetcher->fetchMessages(); + $this->assertFalse($collection); + + $collection = $fetcher->fetchMessages(); + $this->assertInstanceOf(MessageCollection::class, $collection); + $this->assertSame(array('C'), iterator_to_array($collection)); + + $collection = $fetcher->fetchMessages(); + $this->assertFalse($collection); + + $fetcher->queueMessage('D'); + + $collection = $fetcher->fetchMessages(); + $this->assertInstanceOf(MessageCollection::class, $collection); + $this->assertSame(array('D'), iterator_to_array($collection)); + + $collection = $fetcher->fetchMessages(); + $this->assertFalse($collection); + } +} diff --git a/src/Symfony/Component/Worker/Tests/Router/DirectRouterTest.php b/src/Symfony/Component/Worker/Tests/Router/DirectRouterTest.php new file mode 100644 index 0000000000000..2cab5db24ebac --- /dev/null +++ b/src/Symfony/Component/Worker/Tests/Router/DirectRouterTest.php @@ -0,0 +1,36 @@ +createMock(MessageFetcherInterface::class); + $messageFetcher->expects($this->once())->method('fetchMessages'); + $consumer = $this->createMock(ConsumerInterface::class); + + $router = new DirectRouter($messageFetcher, $consumer); + + $router->fetchMessages(); + } + + public function testConsume() + { + $messageCollection = new MessageCollection(); + + $messageFetcher = $this->createMock(MessageFetcherInterface::class); + $consumer = $this->createMock(ConsumerInterface::class); + $consumer->expects($this->once())->method('consume')->with($messageCollection); + + $router = new DirectRouter($messageFetcher, $consumer); + + $router->consume($messageCollection); + } +} diff --git a/src/Symfony/Component/Worker/Tests/Router/RoundRobinRouterTest.php b/src/Symfony/Component/Worker/Tests/Router/RoundRobinRouterTest.php new file mode 100644 index 0000000000000..105a59a0242f3 --- /dev/null +++ b/src/Symfony/Component/Worker/Tests/Router/RoundRobinRouterTest.php @@ -0,0 +1,137 @@ +fetchMessages(); + $this->assertSame(array('A'), iterator_to_array($messageCollection)); + $router->consume($messageCollection); + $this->assertSame(array('A'), $consumer1->messages); + + $messageCollection = $router->fetchMessages(); + $this->assertSame(array('B'), iterator_to_array($messageCollection)); + $router->consume($messageCollection); + $this->assertSame(array('A', 'B'), $consumer1->messages); + + $messageCollection = $router->fetchMessages(); + $this->assertSame(array('D'), iterator_to_array($messageCollection)); + $router->consume($messageCollection); + $this->assertSame(array('D'), $consumer2->messages); + + $messageCollection = $router->fetchMessages(); + $this->assertSame(array('E'), iterator_to_array($messageCollection)); + $router->consume($messageCollection); + $this->assertSame(array('D', 'E'), $consumer2->messages); + + $messageCollection = $router->fetchMessages(); + $this->assertSame(false, $messageCollection); + } + + public function testConsumeEverythingInSequence() + { + $fetcher1 = new InMemoryMessageFetcher(array('A', false, 'B')); + $consumer1 = new ConsumerMock(); + $router1 = new DirectRouter($fetcher1, $consumer1); + + $fetcher2 = new InMemoryMessageFetcher(array('D', false, 'E')); + $consumer2 = new ConsumerMock(); + $router2 = new DirectRouter($fetcher2, $consumer2); + + $router = new RoundRobinRouter(array($router1, $router2), true); + + $messageCollection = $router->fetchMessages(); + $this->assertSame(array('A'), iterator_to_array($messageCollection)); + $router->consume($messageCollection); + $this->assertSame(array('A'), $consumer1->messages); + + $messageCollection = $router->fetchMessages(); + $this->assertSame(array('D'), iterator_to_array($messageCollection)); + $router->consume($messageCollection); + $this->assertSame(array('D'), $consumer2->messages); + + $messageCollection = $router->fetchMessages(); + $this->assertSame(array('B'), iterator_to_array($messageCollection)); + $router->consume($messageCollection); + $this->assertSame(array('A', 'B'), $consumer1->messages); + + $messageCollection = $router->fetchMessages(); + $this->assertSame(array('E'), iterator_to_array($messageCollection)); + $router->consume($messageCollection); + $this->assertSame(array('D', 'E'), $consumer2->messages); + + $messageCollection = $router->fetchMessages(); + $this->assertSame(false, $messageCollection); + } + + public function testConsumeInSequence() + { + $fetcher1 = new InMemoryMessageFetcher(array('A', false, 'B')); + $consumer1 = new ConsumerMock(); + $router1 = new DirectRouter($fetcher1, $consumer1); + + $fetcher2 = new InMemoryMessageFetcher(array('D', false, 'E')); + $consumer2 = new ConsumerMock(); + $router2 = new DirectRouter($fetcher2, $consumer2); + + $router = new RoundRobinRouter(array($router1, $router2), false); + + $messageCollection = $router->fetchMessages(); + $this->assertSame(array('A'), iterator_to_array($messageCollection)); + $router->consume($messageCollection); + $this->assertSame(array('A'), $consumer1->messages); + + $messageCollection = $router->fetchMessages(); + $this->assertSame(array('D'), iterator_to_array($messageCollection)); + $router->consume($messageCollection); + $this->assertSame(array('D'), $consumer2->messages); + + // Both message fetch return false + $messageCollection = $router->fetchMessages(); + $this->assertFalse($messageCollection); + + $messageCollection = $router->fetchMessages(); + $this->assertSame(array('B'), iterator_to_array($messageCollection)); + $router->consume($messageCollection); + $this->assertSame(array('A', 'B'), $consumer1->messages); + + $messageCollection = $router->fetchMessages(); + $this->assertSame(array('E'), iterator_to_array($messageCollection)); + $router->consume($messageCollection); + $this->assertSame(array('D', 'E'), $consumer2->messages); + + $messageCollection = $router->fetchMessages(); + $this->assertSame(false, $messageCollection); + } +} + +class ConsumerMock implements ConsumerInterface +{ + public $messages = array(); + + public function consume(MessageCollection $messageCollection) + { + foreach ($messageCollection as $message) { + $this->messages[] = $message; + } + } +} diff --git a/src/Symfony/Component/Worker/composer.json b/src/Symfony/Component/Worker/composer.json new file mode 100644 index 0000000000000..1dc954cde387a --- /dev/null +++ b/src/Symfony/Component/Worker/composer.json @@ -0,0 +1,41 @@ +{ + "name": "symfony/worker", + "type": "library", + "description": "Library to build workers", + "keywords": ["worker", "consumer", "queue", "message", "amqp"], + "homepage": "http://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "http://symfony.com/contributors" + } + ], + "require": { + "php": ">=5.5.9", + "ext-pcntl": "*", + "symfony/event-dispatcher": "^2.3|^3.0|^4.0", + "psr/log": "~1.0" + }, + "require-dev": { + "symfony/amqp": "^3.4", + "symfony/console": "^3.0", + "symfony/phpunit-bridge": "^3.2" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Worker\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev", + "extra": { + "branch-alias": { + "dev-master": "3.4-dev" + } + } +} diff --git a/src/Symfony/Component/Worker/phpunit.xml.dist b/src/Symfony/Component/Worker/phpunit.xml.dist new file mode 100644 index 0000000000000..395cd60135fd8 --- /dev/null +++ b/src/Symfony/Component/Worker/phpunit.xml.dist @@ -0,0 +1,28 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./Tests + ./vendor + + + +