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 + + + + pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy