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
+
+
+
+