├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── composer-require-checker.json ├── composer.json ├── config ├── di.php └── params.php ├── phpbench.json ├── rector.php ├── src ├── Adapter │ ├── AdapterInterface.php │ └── SynchronousAdapter.php ├── ChannelNormalizer.php ├── Cli │ ├── LoopInterface.php │ ├── SignalLoop.php │ ├── SimpleLoop.php │ └── SoftLimitTrait.php ├── Command │ ├── ListenAllCommand.php │ ├── ListenCommand.php │ └── RunCommand.php ├── Debug │ ├── QueueCollector.php │ ├── QueueDecorator.php │ ├── QueueProviderInterfaceProxy.php │ └── QueueWorkerInterfaceProxy.php ├── Exception │ ├── AdapterConfiguration │ │ └── AdapterNotConfiguredException.php │ └── JobFailureException.php ├── JobStatus.php ├── Message │ ├── Envelope.php │ ├── EnvelopeInterface.php │ ├── IdEnvelope.php │ ├── JsonMessageSerializer.php │ ├── Message.php │ ├── MessageHandlerInterface.php │ ├── MessageInterface.php │ └── MessageSerializerInterface.php ├── Middleware │ ├── CallableFactory.php │ ├── Consume │ │ ├── ConsumeFinalHandler.php │ │ ├── ConsumeMiddlewareDispatcher.php │ │ ├── ConsumeRequest.php │ │ ├── MessageHandlerConsumeInterface.php │ │ ├── MiddlewareConsumeInterface.php │ │ ├── MiddlewareConsumeStack.php │ │ ├── MiddlewareFactoryConsume.php │ │ └── MiddlewareFactoryConsumeInterface.php │ ├── FailureHandling │ │ ├── FailureEnvelope.php │ │ ├── FailureFinalHandler.php │ │ ├── FailureHandlingRequest.php │ │ ├── FailureMiddlewareDispatcher.php │ │ ├── Implementation │ │ │ ├── ExponentialDelayMiddleware.php │ │ │ └── SendAgainMiddleware.php │ │ ├── MessageFailureHandlerInterface.php │ │ ├── MiddlewareFactoryFailure.php │ │ ├── MiddlewareFactoryFailureInterface.php │ │ ├── MiddlewareFailureInterface.php │ │ └── MiddlewareFailureStack.php │ ├── InvalidCallableConfigurationException.php │ ├── InvalidMiddlewareDefinitionException.php │ └── Push │ │ ├── AdapterPushHandler.php │ │ ├── Implementation │ │ ├── DelayMiddlewareInterface.php │ │ └── IdMiddleware.php │ │ ├── MessageHandlerPushInterface.php │ │ ├── MiddlewareFactoryPush.php │ │ ├── MiddlewareFactoryPushInterface.php │ │ ├── MiddlewarePushInterface.php │ │ ├── MiddlewarePushStack.php │ │ ├── PushMiddlewareDispatcher.php │ │ └── PushRequest.php ├── Provider │ ├── AdapterFactoryQueueProvider.php │ ├── ChannelNotFoundException.php │ ├── CompositeQueueProvider.php │ ├── InvalidQueueConfigException.php │ ├── PrototypeQueueProvider.php │ ├── QueueProviderException.php │ └── QueueProviderInterface.php ├── Queue.php ├── QueueInterface.php └── Worker │ ├── Worker.php │ └── WorkerInterface.php └── stubs ├── StubAdapter.php ├── StubLoop.php ├── StubQueue.php └── StubWorker.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Yii Queue Change Log 2 | 3 | ## 1.0.0 under development 4 | 5 | - Initial release. 6 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright © 2008 by Yii Software (https://www.yiiframework.com/) 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions 6 | are met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | * Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in 12 | the documentation and/or other materials provided with the 13 | distribution. 14 | * Neither the name of Yii Software nor the names of its 15 | contributors may be used to endorse or promote products derived 16 | from this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 19 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 20 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 21 | FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 22 | COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 23 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 24 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 25 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 27 | LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 28 | ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 29 | POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Yii 4 | 5 |

Yii Queue

6 |
7 |

8 | 9 | [![Latest Stable Version](https://poser.pugx.org/yiisoft/queue/v/stable.svg)](https://packagist.org/packages/yiisoft/queue) 10 | [![Total Downloads](https://poser.pugx.org/yiisoft/queue/downloads.svg)](https://packagist.org/packages/yiisoft/queue) 11 | [![Build status](https://github.com/yiisoft/queue/workflows/build/badge.svg)](https://github.com/yiisoft/queue/actions) 12 | [![Code coverage](https://codecov.io/gh/yiisoft/queue/graph/badge.svg?token=NU2ST01B1U)](https://codecov.io/gh/yiisoft/queue) 13 | [![Mutation testing badge](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fyiisoft%2Fqueue%2Fmaster)](https://dashboard.stryker-mutator.io/reports/github.com/yiisoft/queue/master) 14 | [![static analysis](https://github.com/yiisoft/queue/workflows/static%20analysis/badge.svg)](https://github.com/yiisoft/queue/actions?query=workflow%3A%22static+analysis%22) 15 | [![type-coverage](https://shepherd.dev/github/yiisoft/queue/coverage.svg)](https://shepherd.dev/github/yiisoft/queue) 16 | 17 | An extension for running tasks asynchronously via queues. 18 | 19 | ## Requirements 20 | 21 | - PHP 8.1 or higher. 22 | 23 | ## Installation 24 | 25 | The package could be installed with [Composer](https://getcomposer.org): 26 | 27 | ```shell 28 | composer require yiisoft/queue 29 | ``` 30 | 31 | ## Ready for Yii Config 32 | 33 | If you are using [yiisoft/config](https://github.com/yiisoft/config), you'll find out this package has some defaults 34 | in the [`common`](config/di.php) and [`params`](config/params.php) configurations saving your time. Things you should 35 | change to start working with the queue: 36 | 37 | - Optionally: define default `\Yiisoft\Queue\Adapter\AdapterInterface` implementation. 38 | - And/or define channel-specific `AdapterInterface` implementations in the `channel` params key to be used 39 | with the [queue provider](#different-queue-channels). 40 | - Define [message handlers](docs/guide/worker.md#handler-format) in the `handlers` params key to be used with the `QueueWorker`. 41 | - Resolve other `\Yiisoft\Queue\Queue` dependencies (psr-compliant event dispatcher). 42 | 43 | ## Differences to yii2-queue 44 | 45 | If you have experience with `yiisoft/yii2-queue`, you will find out that this package is similar. 46 | Though, there are some key differences that are described in the "[migrating from yii2-queue](docs/guide/migrating-from-yii2-queue.md)" 47 | article. 48 | 49 | ## General usage 50 | 51 | Each queue task consists of two parts: 52 | 53 | 1. A message is a class implementing `MessageInterface`. For simple cases you can use the default implementation, 54 | `Yiisoft\Queue\Message\Message`. For more complex cases, you should implement the interface by your own. 55 | 2. A message handler is a callable called by a `Yiisoft\Queue\Worker\Worker`. The handler handles each queue message. 56 | 57 | For example, if you need to download and save a file, your message creation may look like the following: 58 | - Message handler as the first parameter 59 | - Message data as the second parameter 60 | 61 | ```php 62 | $data = [ 63 | 'url' => $url, 64 | 'destinationFile' => $filename, 65 | ]; 66 | $message = new \Yiisoft\Queue\Message\Message(FileDownloader::class, $data); 67 | ``` 68 | 69 | Then you should push it to the queue: 70 | 71 | ```php 72 | $queue->push($message); 73 | ``` 74 | 75 | Its handler may look like the following: 76 | 77 | ```php 78 | class FileDownloader 79 | { 80 | private string $absolutePath; 81 | 82 | public function __construct(string $absolutePath) 83 | { 84 | $this->absolutePath = $absolutePath; 85 | } 86 | 87 | public function handle(\Yiisoft\Queue\Message\MessageInterface $downloadMessage): void 88 | { 89 | $fileName = $downloadMessage->getData()['destinationFile']; 90 | $path = "$this->absolutePath/$fileName"; 91 | file_put_contents($path, file_get_contents($downloadMessage->getData()['url'])); 92 | } 93 | } 94 | ``` 95 | 96 | The last thing we should do is to create a configuration for the `Yiisoft\Queue\Worker\Worker`: 97 | 98 | ```php 99 | $worker = new \Yiisoft\Queue\Worker\Worker( 100 | [], 101 | $logger, 102 | $injector, 103 | $container 104 | ); 105 | ``` 106 | 107 | There is a way to run all the messages that are already in the queue, and then exit: 108 | 109 | ```php 110 | $queue->run(); // this will execute all the existing messages 111 | $queue->run(10); // while this will execute only 10 messages as a maximum before exit 112 | ``` 113 | 114 | If you don't want your script to exit immediately, you can use the `listen` method: 115 | 116 | ```php 117 | $queue->listen(); 118 | ``` 119 | 120 | You can also check the status of a pushed message (the queue adapter you are using must support this feature): 121 | 122 | ```php 123 | $queue->push($message); 124 | $id = $message->getId(); 125 | 126 | // Get status of the job 127 | $status = $queue->status($id); 128 | 129 | // Check whether the job is waiting for execution. 130 | $status->isWaiting(); 131 | 132 | // Check whether a worker got the job from the queue and executes it. 133 | $status->isReserved(); 134 | 135 | // Check whether a worker has executed the job. 136 | $status->isDone(); 137 | ``` 138 | 139 | ## Custom handler names 140 | ### Custom handler names 141 | 142 | By default, when you push a message to the queue, the message handler name is the fully qualified class name of the handler. 143 | This can be useful for most cases, but sometimes you may want to use a shorter name or arbitrary string as the handler name. 144 | This can be useful when you want to reduce the amount of data being passed or when you communicate with external systems. 145 | 146 | To use a custom handler name before message push, you can pass it as the first argument `Message` when creating it: 147 | ```php 148 | new Message('handler-name', $data); 149 | ``` 150 | 151 | To use a custom handler name on message consumption, you should configure handler mapping for the `Worker` class: 152 | ```php 153 | $worker = new \Yiisoft\Queue\Worker\Worker( 154 | ['handler-name' => FooHandler::class], 155 | $logger, 156 | $injector, 157 | $container 158 | ); 159 | ``` 160 | 161 | ## Different queue channels 162 | 163 | Often we need to push to different queue channels with an only application. There is the `QueueProviderInterface` 164 | interface that provides different `Queue` objects creation for different channels. With implementation of this interface 165 | channel-specific `Queue` creation is as simple as 166 | 167 | ```php 168 | $queue = $provider->get('channel-name'); 169 | ``` 170 | 171 | Out of the box, there are four implementations of the `QueueProviderInterface`: 172 | 173 | - `AdapterFactoryQueueProvider` 174 | - `PrototypeQueueProvider` 175 | - `CompositeQueueProvider` 176 | 177 | ### `AdapterFactoryQueueProvider` 178 | 179 | Provider based on the definition of channel-specific adapters. Definitions are passed in 180 | the `$definitions` constructor parameter of the factory, where keys are channel names and values are definitions 181 | for the [`Yiisoft\Factory\Factory`](https://github.com/yiisoft/factory). Below are some examples: 182 | 183 | ```php 184 | use Yiisoft\Queue\Adapter\SynchronousAdapter; 185 | 186 | [ 187 | 'channel1' => new SynchronousAdapter(), 188 | 'channel2' => static fn(SynchronousAdapter $adapter) => $adapter->withChannel('channel2'), 189 | 'channel3' => [ 190 | 'class' => SynchronousAdapter::class, 191 | '__constructor' => ['channel' => 'channel3'], 192 | ], 193 | ] 194 | ``` 195 | 196 | For more information about the definition formats available, see the [factory](https://github.com/yiisoft/factory) documentation. 197 | 198 | ### `PrototypeQueueProvider` 199 | 200 | Queue provider that only changes the channel name of the base queue. It can be useful when your queues used the same 201 | adapter. 202 | 203 | > Warning: This strategy is not recommended as it does not give you any protection against typos and mistakes 204 | > in channel names. 205 | 206 | ### `CompositeQueueProvider` 207 | 208 | This provider allows you to combine multiple providers into one. It will try to get a queue from each provider in the 209 | order they are passed to the constructor. The first queue found will be returned. 210 | 211 | ## Console execution 212 | 213 | The exact way of task execution depends on the adapter used. Most adapters can be run using 214 | console commands, which the component automatically registers in your application. 215 | 216 | The following command obtains and executes tasks in a loop until the queue is empty: 217 | 218 | ```sh 219 | yii queue:run 220 | ``` 221 | 222 | The following command launches a daemon which infinitely queries the queue: 223 | 224 | ```sh 225 | yii queue:listen 226 | ``` 227 | 228 | See the documentation for more details about adapter specific console commands and their options. 229 | 230 | The component can also track the status of a job which was pushed into queue. 231 | 232 | For more details, see [the guide](docs/guide/en/README.md). 233 | 234 | ## Middleware pipelines 235 | 236 | Any message pushed to a queue or consumed from it passes through two different middleware pipelines: one pipeline 237 | on message push and another - on a message consume. The process is the same as for the HTTP request, but it is executed 238 | twice for a queue message. That means you can add extra functionality on message pushing and consuming with configuration 239 | of the two classes: `PushMiddlewareDispatcher` and `ConsumeMiddlewareDispatcher` respectively. 240 | 241 | You can use any of these formats to define a middleware: 242 | 243 | - A ready-to-use middleware object: `new FooMiddleware()`. It must implement `MiddlewarePushInterface`, 244 | `MiddlewareConsumeInterface` or `MiddlewareFailureInterface` depending on the place you use it. 245 | - An array in the format of [yiisoft/definitions](https://github.com/yiisoft/definitions). 246 | **Only if you use yiisoft/definitions and yiisoft/di**. 247 | - A `callable`: `fn() => // do stuff`, `$object->foo(...)`, etc. It will be executed through the 248 | [yiisoft/injector](https://github.com/yiisoft/injector), so all the dependencies of your callable will be resolved. 249 | - A string for your DI container to resolve the middleware, e.g. `FooMiddleware::class` 250 | 251 | Middleware will be executed forwards in the same order they are defined. If you define it like the following: 252 | `[$middleware1, $midleware2]`, the execution will look like this: 253 | 254 | ```mermaid 255 | graph LR 256 | StartPush((Start)) --> PushMiddleware1[$middleware1] --> PushMiddleware2[$middleware2] --> Push(Push to a queue) 257 | -.-> PushMiddleware2[$middleware2] -.-> PushMiddleware1[$middleware1] 258 | PushMiddleware1[$middleware1] -.-> EndPush((End)) 259 | 260 | 261 | StartConsume((Start)) --> ConsumeMiddleware1[$middleware1] --> ConsumeMiddleware2[$middleware2] --> Consume(Consume / handle) 262 | -.-> ConsumeMiddleware2[$middleware2] -.-> ConsumeMiddleware1[$middleware1] 263 | ConsumeMiddleware1[$middleware1] -.-> EndConsume((End)) 264 | ``` 265 | 266 | ### Push a pipeline 267 | 268 | When you push a message, you can use middlewares to modify both message and queue adapter. 269 | With message modification you can add extra data, obfuscate data, collect metrics, etc. 270 | With queue adapter modification you can redirect the message to another queue, delay message consuming, and so on. 271 | 272 | To use this feature, you have to create a middleware class, which implements `MiddlewarePushInterface`, and 273 | return a modified `PushRequest` object from the `processPush` method: 274 | 275 | ```php 276 | return $pushRequest->withMessage($newMessage)->withAdapter($newAdapter); 277 | ``` 278 | 279 | With push middlewares you can define an adapter object at the runtime, not in the `Queue` constructor. 280 | There is a restriction: by the time all middlewares are executed in the forward order, the adapter must be specified 281 | in the `PushRequest` object. You will get a `AdapterNotConfiguredException`, if it isn't. 282 | 283 | You have three places to define push middlewares: 284 | 285 | 1. `PushMiddlewareDispatcher`. You can pass it either to the constructor, or to the `withMiddlewares()` method, which 286 | creates a completely new dispatcher object with only those middlewares, which are passed as arguments. 287 | If you use [yiisoft/config](yiisoft/config), you can add middleware to the `middlewares-push` key of the 288 | `yiisoft/queue` array in the `params`. 289 | 2. Pass middlewares to either `Queue::withMiddlewares()` or `Queue::withMiddlewaresAdded()` methods. The difference is 290 | that the former will completely replace an existing middleware stack, while the latter will add passed middlewares to 291 | the end of the existing stack. These middlewares will be executed after the common ones, passed directly to the 292 | `PushMiddlewareDispatcher`. It's useful when defining a queue channel. Both methods return a new instance of the `Queue` 293 | class. 294 | 3. Put middlewares into the `Queue::push()` method like this: `$queue->push($message, ...$middlewares)`. These 295 | middlewares have the lowest priority and will be executed after those which are in the `PushMiddlewareDispatcher` and 296 | the ones passed to the `Queue::withMiddlewares()` and `Queue::withMiddlewaresAdded()` and only for the message passed 297 | along with them. 298 | 299 | ### Consume pipeline 300 | 301 | You can set a middleware pipeline for a message when it will be consumed from a queue server. This is useful to collect metrics, modify message data, etc. In a pair with a Push middleware you can deduplicate messages in the queue, calculate time from push to consume, handle errors (push to a queue again, redirect failed message to another queue, send a notification, etc.). Except push pipeline, you have only one place to define the middleware stack: in the `ConsumeMiddlewareDispatcher`, either in the constructor, or in the `withMiddlewares()` method. If you use [yiisoft/config](yiisoft/config), you can add middleware to the `middlewares-consume` key of the `yiisoft/queue` array in the `params`. 302 | 303 | ### Error handling pipeline 304 | 305 | Often when some job is failing, we want to retry its execution a couple more times or redirect it to another queue channel. This can be done in `yiisoft/queue` with a Failure middleware pipeline. They are triggered each time message processing via the Consume middleware pipeline is interrupted with any `Throwable`. The key differences from the previous two pipelines: 306 | 307 | - You should set up the middleware pipeline separately for each queue channel. That means, the format should be `['channel-name' => [FooMiddleware::class]]` instead of `[FooMiddleware::class]`, like for the other two pipelines. There is also a default key, which will be used for those channels without their own one: `FailureMiddlewareDispatcher::DEFAULT_PIPELINE`. 308 | - The last middleware will throw the exception, which will come with the `FailureHandlingRequest` object. If you don't want the exception to be thrown, your middlewares should `return` a request without calling `$handler->handleFailure()`. 309 | 310 | You can declare error handling a middleware pipeline in the `FailureMiddlewareDispatcher`, either in the constructor, or in the `withMiddlewares()` method. If you use [yiisoft/config](yiisoft/config), you can add middleware to the `middlewares-fail` key of the `yiisoft/queue` array in the `params`. 311 | 312 | See [error handling docs](docs/guide/error-handling.md) for details. 313 | 314 | ## Documentation 315 | 316 | - [Guide](docs/guide/en/README.md) 317 | - [Internals](docs/internals.md) 318 | 319 | If you need help or have a question, the [Yii Forum](https://forum.yiiframework.com/c/yii-3-0/63) is a good place for that. 320 | You may also check out other [Yii Community Resources](https://www.yiiframework.com/community). 321 | 322 | ## License 323 | 324 | The Yii Queue is free software. It is released under the terms of the BSD License. 325 | Please see [`LICENSE`](./LICENSE.md) for more information. 326 | 327 | Maintained by [Yii Software](https://www.yiiframework.com/). 328 | 329 | ### Support the project 330 | 331 | [![Open Collective](https://img.shields.io/badge/Open%20Collective-sponsor-7eadf1?logo=open%20collective&logoColor=7eadf1&labelColor=555555)](https://opencollective.com/yiisoft) 332 | 333 | ### Follow updates 334 | 335 | [![Official website](https://img.shields.io/badge/Powered_by-Yii_Framework-green.svg?style=flat)](https://www.yiiframework.com/) 336 | [![Twitter](https://img.shields.io/badge/twitter-follow-1DA1F2?logo=twitter&logoColor=1DA1F2&labelColor=555555?style=flat)](https://twitter.com/yiiframework) 337 | [![Telegram](https://img.shields.io/badge/telegram-join-1DA1F2?style=flat&logo=telegram)](https://t.me/yii3en) 338 | [![Facebook](https://img.shields.io/badge/facebook-join-1DA1F2?style=flat&logo=facebook&logoColor=ffffff)](https://www.facebook.com/groups/yiitalk) 339 | [![Slack](https://img.shields.io/badge/slack-join-1DA1F2?style=flat&logo=slack)](https://yiiframework.com/go/slack) 340 | -------------------------------------------------------------------------------- /composer-require-checker.json: -------------------------------------------------------------------------------- 1 | { 2 | "symbol-whitelist": [ 3 | "Yiisoft\\Yii\\Debug\\Collector\\CollectorTrait", 4 | "Yiisoft\\Yii\\Debug\\Collector\\SummaryCollectorInterface", 5 | "pcntl_signal", 6 | "pcntl_signal_dispatch", 7 | "SIGCONT", 8 | "SIGHUP", 9 | "SIGINT", 10 | "SIGTERM", 11 | "SIGTSTP" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yiisoft/queue", 3 | "description": "Queue Extension which supported DB, Redis, RabbitMQ, Beanstalk, SQS and Gearman", 4 | "type": "library", 5 | "keywords": [ 6 | "yii", 7 | "queue", 8 | "async", 9 | "gii", 10 | "db", 11 | "redis", 12 | "rabbitmq", 13 | "beanstalk", 14 | "gearman", 15 | "sqs" 16 | ], 17 | "license": "BSD-3-Clause", 18 | "support": { 19 | "issues": "https://github.com/yiisoft/queue/issues?state=open", 20 | "source": "https://github.com/yiisoft/queue", 21 | "forum": "https://www.yiiframework.com/forum/", 22 | "wiki": "https://www.yiiframework.com/wiki/", 23 | "irc": "ircs://irc.libera.chat:6697/yii", 24 | "chat": "https://t.me/yii3en" 25 | }, 26 | "funding": [ 27 | { 28 | "type": "opencollective", 29 | "url": "https://opencollective.com/yiisoft" 30 | }, 31 | { 32 | "type": "github", 33 | "url": "https://github.com/sponsors/yiisoft" 34 | } 35 | ], 36 | "minimum-stability": "dev", 37 | "prefer-stable": true, 38 | "require": { 39 | "php": "8.1 - 8.4", 40 | "psr/container": "^1.0 || ^2.0", 41 | "psr/log": "^2.0 || ^3.0", 42 | "symfony/console": "^5.4 || ^6.0 || ^7.0", 43 | "yiisoft/arrays": "^3.1", 44 | "yiisoft/definitions": "^3.3.1", 45 | "yiisoft/factory": "^1.3", 46 | "yiisoft/friendly-exception": "^1.0", 47 | "yiisoft/injector": "^1.0" 48 | }, 49 | "require-dev": { 50 | "maglnet/composer-require-checker": "^4.7.1", 51 | "phpbench/phpbench": "^1.4.1", 52 | "phpunit/phpunit": "^10.5.45", 53 | "rector/rector": "^2.0.11", 54 | "roave/infection-static-analysis-plugin": "^1.35", 55 | "spatie/phpunit-watcher": "^1.24", 56 | "vimeo/psalm": "^5.26.1 || ^6.10", 57 | "yiisoft/test-support": "^3.0.2", 58 | "yiisoft/yii-debug": "dev-master" 59 | }, 60 | "suggest": { 61 | "ext-pcntl": "Need for process signals" 62 | }, 63 | "autoload": { 64 | "psr-4": { 65 | "Yiisoft\\Queue\\": "src", 66 | "Yiisoft\\Queue\\Stubs\\": "stubs" 67 | } 68 | }, 69 | "autoload-dev": { 70 | "psr-4": { 71 | "Yiisoft\\Queue\\Tests\\": "tests" 72 | } 73 | }, 74 | "extra": { 75 | "branch-alias": { 76 | "dev-master": "3.0.x-dev" 77 | }, 78 | "config-plugin-options": { 79 | "source-directory": "config" 80 | }, 81 | "config-plugin": { 82 | "di": "di.php", 83 | "params": "params.php" 84 | } 85 | }, 86 | "config": { 87 | "sort-packages": true, 88 | "allow-plugins": { 89 | "infection/extension-installer": true, 90 | "composer/package-versions-deprecated": true, 91 | "yiisoft/config": false 92 | } 93 | }, 94 | "scripts": { 95 | "test": "phpunit --testdox --no-interaction", 96 | "test-watch": "phpunit-watcher watch" 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /config/di.php: -------------------------------------------------------------------------------- 1 | [ 33 | '__construct()' => [ 34 | 'definitions' => $params['yiisoft/queue']['channels'], 35 | ], 36 | ], 37 | QueueProviderInterface::class => AdapterFactoryQueueProvider::class, 38 | QueueWorker::class => [ 39 | 'class' => QueueWorker::class, 40 | '__construct()' => [$params['yiisoft/queue']['handlers']], 41 | ], 42 | WorkerInterface::class => QueueWorker::class, 43 | LoopInterface::class => static function (ContainerInterface $container): LoopInterface { 44 | return extension_loaded('pcntl') 45 | ? $container->get(SignalLoop::class) 46 | : $container->get(SimpleLoop::class); 47 | }, 48 | QueueInterface::class => Queue::class, 49 | MiddlewareFactoryPushInterface::class => MiddlewareFactoryPush::class, 50 | MiddlewareFactoryConsumeInterface::class => MiddlewareFactoryConsume::class, 51 | MiddlewareFactoryFailureInterface::class => MiddlewareFactoryFailure::class, 52 | PushMiddlewareDispatcher::class => [ 53 | '__construct()' => ['middlewareDefinitions' => $params['yiisoft/queue']['middlewares-push']], 54 | ], 55 | ConsumeMiddlewareDispatcher::class => [ 56 | '__construct()' => ['middlewareDefinitions' => $params['yiisoft/queue']['middlewares-consume']], 57 | ], 58 | FailureMiddlewareDispatcher::class => [ 59 | '__construct()' => ['middlewareDefinitions' => $params['yiisoft/queue']['middlewares-fail']], 60 | ], 61 | MessageSerializerInterface::class => JsonMessageSerializer::class, 62 | RunCommand::class => [ 63 | '__construct()' => [ 64 | 'channels' => array_keys($params['yiisoft/queue']['channels']), 65 | ], 66 | ], 67 | ListenAllCommand::class => [ 68 | '__construct()' => [ 69 | 'channels' => array_keys($params['yiisoft/queue']['channels']), 70 | ], 71 | ], 72 | ]; 73 | -------------------------------------------------------------------------------- /config/params.php: -------------------------------------------------------------------------------- 1 | [ 18 | 'commands' => [ 19 | 'queue:run' => RunCommand::class, 20 | 'queue:listen' => ListenCommand::class, 21 | 'queue:listen:all' => ListenAllCommand::class, 22 | ], 23 | ], 24 | 'yiisoft/queue' => [ 25 | 'handlers' => [], 26 | 'channels' => [ 27 | QueueInterface::DEFAULT_CHANNEL => AdapterInterface::class, 28 | ], 29 | 'middlewares-push' => [], 30 | 'middlewares-consume' => [], 31 | 'middlewares-fail' => [], 32 | ], 33 | 'yiisoft/yii-debug' => [ 34 | 'collectors' => [ 35 | QueueCollector::class, 36 | ], 37 | 'trackedServices' => [ 38 | QueueProviderInterface::class => [QueueProviderInterfaceProxy::class, QueueCollector::class], 39 | WorkerInterface::class => [QueueWorkerInterfaceProxy::class, QueueCollector::class], 40 | ], 41 | ], 42 | ]; 43 | -------------------------------------------------------------------------------- /phpbench.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema":"./vendor/phpbench/phpbench/phpbench.schema.json", 3 | "runner.bootstrap": "vendor/autoload.php", 4 | "runner.path": "tests/Benchmark", 5 | "runner.revs": 100000, 6 | "runner.iterations": 5, 7 | "runner.warmup": 5 8 | } 9 | -------------------------------------------------------------------------------- /rector.php: -------------------------------------------------------------------------------- 1 | paths([ 13 | __DIR__ . '/src', 14 | __DIR__ . '/tests', 15 | ]); 16 | 17 | // register a single rule 18 | $rectorConfig->rule(InlineConstructorDefaultToPropertyRector::class); 19 | 20 | // define sets of rules 21 | $rectorConfig->sets([ 22 | LevelSetList::UP_TO_PHP_81, 23 | ]); 24 | 25 | $rectorConfig->skip([ 26 | ClosureToArrowFunctionRector::class, 27 | ReadOnlyPropertyRector::class, 28 | ]); 29 | }; 30 | -------------------------------------------------------------------------------- /src/Adapter/AdapterInterface.php: -------------------------------------------------------------------------------- 1 | channel = ChannelNormalizer::normalize($channel); 28 | } 29 | 30 | public function __destruct() 31 | { 32 | $this->runExisting(function (MessageInterface $message): bool { 33 | $this->worker->process($message, $this->queue); 34 | 35 | return true; 36 | }); 37 | } 38 | 39 | public function runExisting(callable $handlerCallback): void 40 | { 41 | $result = true; 42 | while (isset($this->messages[$this->current]) && $result === true) { 43 | $result = $handlerCallback($this->messages[$this->current]); 44 | unset($this->messages[$this->current]); 45 | $this->current++; 46 | } 47 | } 48 | 49 | public function status(string|int $id): JobStatus 50 | { 51 | $id = (int) $id; 52 | 53 | if ($id < 0) { 54 | throw new InvalidArgumentException('This adapter IDs start with 0.'); 55 | } 56 | 57 | if ($id < $this->current) { 58 | return JobStatus::DONE; 59 | } 60 | 61 | if (isset($this->messages[$id])) { 62 | return JobStatus::WAITING; 63 | } 64 | 65 | throw new InvalidArgumentException('There is no message with the given ID.'); 66 | } 67 | 68 | public function push(MessageInterface $message): MessageInterface 69 | { 70 | $key = count($this->messages) + $this->current; 71 | $this->messages[] = $message; 72 | 73 | return new IdEnvelope($message, $key); 74 | } 75 | 76 | public function subscribe(callable $handlerCallback): void 77 | { 78 | $this->runExisting($handlerCallback); 79 | } 80 | 81 | public function withChannel(string|BackedEnum $channel): self 82 | { 83 | $channel = ChannelNormalizer::normalize($channel); 84 | 85 | if ($channel === $this->channel) { 86 | return $this; 87 | } 88 | 89 | $new = clone $this; 90 | $new->channel = $channel; 91 | $new->messages = []; 92 | 93 | return $new; 94 | } 95 | 96 | public function getChannel(): string 97 | { 98 | return $this->channel; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/ChannelNormalizer.php: -------------------------------------------------------------------------------- 1 | value : $channel; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Cli/LoopInterface.php: -------------------------------------------------------------------------------- 1 | $this->exit = true); 37 | } 38 | foreach (self::SIGNALS_SUSPEND as $signal) { 39 | pcntl_signal($signal, fn () => $this->pause = true); 40 | } 41 | foreach (self::SIGNALS_RESUME as $signal) { 42 | pcntl_signal($signal, fn () => $this->pause = false); 43 | } 44 | } 45 | 46 | /** 47 | * Checks signals state. 48 | * 49 | * {@inheritdoc} 50 | */ 51 | public function canContinue(): bool 52 | { 53 | if ($this->memoryLimitReached()) { 54 | return false; 55 | } 56 | 57 | return $this->dispatchSignals(); 58 | } 59 | 60 | protected function dispatchSignals(): bool 61 | { 62 | pcntl_signal_dispatch(); 63 | 64 | // Wait for resume signal until the loop is suspended 65 | while ($this->pause && !$this->exit) { 66 | usleep(10000); 67 | pcntl_signal_dispatch(); 68 | } 69 | 70 | return !$this->exit; 71 | } 72 | 73 | protected function getMemoryLimit(): int 74 | { 75 | return $this->memorySoftLimit; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Cli/SimpleLoop.php: -------------------------------------------------------------------------------- 1 | memoryLimitReached(); 22 | } 23 | 24 | protected function getMemoryLimit(): int 25 | { 26 | return $this->memorySoftLimit; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Cli/SoftLimitTrait.php: -------------------------------------------------------------------------------- 1 | getMemoryLimit(); 14 | 15 | if ($limit !== 0) { 16 | $usage = memory_get_usage(true); 17 | 18 | if ($usage >= $limit) { 19 | return true; 20 | } 21 | } 22 | 23 | return false; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Command/ListenAllCommand.php: -------------------------------------------------------------------------------- 1 | addArgument( 39 | 'channel', 40 | InputArgument::OPTIONAL | InputArgument::IS_ARRAY, 41 | 'Queue channel name list to connect to', 42 | $this->channels, 43 | ) 44 | ->addOption( 45 | 'pause', 46 | 'p', 47 | InputOption::VALUE_REQUIRED, 48 | 'Pause between queue channel iterations in seconds. May save some CPU. Default: 1', 49 | 1, 50 | ) 51 | ->addOption( 52 | 'maximum', 53 | 'm', 54 | InputOption::VALUE_REQUIRED, 55 | 'Maximum number of messages to process in each channel before switching to another channel. ' . 56 | 'Default is 0 (no limits).', 57 | 0, 58 | ); 59 | 60 | $this->addUsage('[channel1 [channel2 [...]]] [--timeout=] [--maximum=]'); 61 | } 62 | 63 | protected function execute(InputInterface $input, OutputInterface $output): int 64 | { 65 | $queues = []; 66 | /** @var string $channel */ 67 | foreach ($input->getArgument('channel') as $channel) { 68 | $queues[] = $this->queueProvider->get($channel); 69 | } 70 | 71 | $pauseSeconds = (int) $input->getOption('pause'); 72 | if ($pauseSeconds < 0) { 73 | $pauseSeconds = 1; 74 | } 75 | 76 | while ($this->loop->canContinue()) { 77 | $hasMessages = false; 78 | foreach ($queues as $queue) { 79 | $hasMessages = $queue->run((int) $input->getOption('maximum')) > 0 || $hasMessages; 80 | } 81 | 82 | if (!$hasMessages) { 83 | /** @psalm-var 0|positive-int $pauseSeconds */ 84 | sleep($pauseSeconds); 85 | } 86 | } 87 | 88 | return 0; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Command/ListenCommand.php: -------------------------------------------------------------------------------- 1 | addArgument( 30 | 'channel', 31 | InputArgument::OPTIONAL, 32 | 'Queue channel name to connect to', 33 | QueueInterface::DEFAULT_CHANNEL, 34 | ); 35 | } 36 | 37 | protected function execute(InputInterface $input, OutputInterface $output): int 38 | { 39 | $this->queueProvider 40 | ->get($input->getArgument('channel')) 41 | ->listen(); 42 | 43 | return 0; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Command/RunCommand.php: -------------------------------------------------------------------------------- 1 | addArgument( 31 | 'channel', 32 | InputArgument::OPTIONAL | InputArgument::IS_ARRAY, 33 | 'Queue channel name list to connect to.', 34 | $this->channels, 35 | ) 36 | ->addOption( 37 | 'maximum', 38 | 'm', 39 | InputOption::VALUE_REQUIRED, 40 | 'Maximum number of messages to process in each channel. Default is 0 (no limits).', 41 | 0, 42 | ) 43 | ->addUsage('[channel1 [channel2 [...]]] --maximum 100'); 44 | } 45 | 46 | protected function execute(InputInterface $input, OutputInterface $output): int 47 | { 48 | /** @var string $channel */ 49 | foreach ($input->getArgument('channel') as $channel) { 50 | $output->write("Processing channel $channel... "); 51 | $count = $this->queueProvider 52 | ->get($channel) 53 | ->run((int)$input->getOption('maximum')); 54 | 55 | $output->writeln("Messages processed: $count."); 56 | } 57 | 58 | return 0; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Debug/QueueCollector.php: -------------------------------------------------------------------------------- 1 | isActive()) { 25 | return []; 26 | } 27 | 28 | return [ 29 | 'pushes' => $this->pushes, 30 | 'statuses' => $this->statuses, 31 | 'processingMessages' => $this->processingMessages, 32 | ]; 33 | } 34 | 35 | public function collectStatus(string $id, JobStatus $status): void 36 | { 37 | if (!$this->isActive()) { 38 | return; 39 | } 40 | 41 | $this->statuses[] = [ 42 | 'id' => $id, 43 | 'status' => $status->key(), 44 | ]; 45 | } 46 | 47 | public function collectPush( 48 | ?string $channel, 49 | MessageInterface $message, 50 | string|array|callable|MiddlewarePushInterface ...$middlewareDefinitions, 51 | ): void { 52 | if (!$this->isActive()) { 53 | return; 54 | } 55 | if ($channel === null) { 56 | $channel = 'null'; 57 | } 58 | 59 | $this->pushes[$channel][] = [ 60 | 'message' => $message, 61 | 'middlewares' => $middlewareDefinitions, 62 | ]; 63 | } 64 | 65 | public function collectWorkerProcessing(MessageInterface $message, QueueInterface $queue): void 66 | { 67 | if (!$this->isActive()) { 68 | return; 69 | } 70 | $this->processingMessages[$queue->getChannel()][] = $message; 71 | } 72 | 73 | private function reset(): void 74 | { 75 | $this->pushes = []; 76 | $this->statuses = []; 77 | $this->processingMessages = []; 78 | } 79 | 80 | public function getSummary(): array 81 | { 82 | if (!$this->isActive()) { 83 | return []; 84 | } 85 | 86 | $countPushes = array_sum(array_map(static fn ($messages) => is_countable($messages) ? count($messages) : 0, $this->pushes)); 87 | $countStatuses = count($this->statuses); 88 | $countProcessingMessages = array_sum(array_map(static fn ($messages) => is_countable($messages) ? count($messages) : 0, $this->processingMessages)); 89 | 90 | return [ 91 | 'countPushes' => $countPushes, 92 | 'countStatuses' => $countStatuses, 93 | 'countProcessingMessages' => $countProcessingMessages, 94 | ]; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Debug/QueueDecorator.php: -------------------------------------------------------------------------------- 1 | queue->status($id); 24 | $this->collector->collectStatus((string) $id, $result); 25 | 26 | return $result; 27 | } 28 | 29 | public function push( 30 | MessageInterface $message, 31 | string|array|callable|MiddlewarePushInterface ...$middlewareDefinitions 32 | ): MessageInterface { 33 | $message = $this->queue->push($message, ...$middlewareDefinitions); 34 | $this->collector->collectPush($this->queue->getChannel(), $message, ...$middlewareDefinitions); 35 | return $message; 36 | } 37 | 38 | public function run(int $max = 0): int 39 | { 40 | return $this->queue->run($max); 41 | } 42 | 43 | public function listen(): void 44 | { 45 | $this->queue->listen(); 46 | } 47 | 48 | public function withAdapter(AdapterInterface $adapter): QueueInterface 49 | { 50 | return new self($this->queue->withAdapter($adapter), $this->collector); 51 | } 52 | 53 | public function getChannel(): string 54 | { 55 | return $this->queue->getChannel(); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Debug/QueueProviderInterfaceProxy.php: -------------------------------------------------------------------------------- 1 | queueProvider->get($channel); 22 | return new QueueDecorator($queue, $this->collector); 23 | } 24 | 25 | public function has(string|BackedEnum $channel): bool 26 | { 27 | return $this->queueProvider->has($channel); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Debug/QueueWorkerInterfaceProxy.php: -------------------------------------------------------------------------------- 1 | collector->collectWorkerProcessing($message, $queue); 22 | return $this->worker->process($message, $queue); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Exception/AdapterConfiguration/AdapterNotConfiguredException.php: -------------------------------------------------------------------------------- 1 | getMessage(); 19 | $messageId = $queueMessage->getMetadata()[IdEnvelope::MESSAGE_ID_KEY] ?? 'null'; 20 | $messageText = "Processing of message #$messageId is stopped because of an exception:\n$error."; 21 | 22 | parent::__construct($messageText, 0, $previous); 23 | } 24 | 25 | public function getQueueMessage(): MessageInterface 26 | { 27 | return $this->queueMessage; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/JobStatus.php: -------------------------------------------------------------------------------- 1 | 'waiting', 17 | self::RESERVED => 'reserved', 18 | self::DONE => 'done', 19 | }; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Message/Envelope.php: -------------------------------------------------------------------------------- 1 | message; 25 | } 26 | 27 | public function getHandlerName(): string 28 | { 29 | return $this->message->getHandlerName(); 30 | } 31 | 32 | public function getData(): mixed 33 | { 34 | return $this->message->getData(); 35 | } 36 | 37 | public function getMetadata(): array 38 | { 39 | if ($this->metadata === null) { 40 | $messageMeta = $this->message->getMetadata(); 41 | 42 | $stack = $messageMeta[EnvelopeInterface::ENVELOPE_STACK_KEY] ?? []; 43 | if (!is_array($stack)) { 44 | $stack = []; 45 | } 46 | 47 | $this->metadata = array_merge( 48 | $messageMeta, 49 | [ 50 | EnvelopeInterface::ENVELOPE_STACK_KEY => array_merge( 51 | $stack, 52 | [static::class], 53 | ), 54 | ], 55 | $this->getEnvelopeMetadata(), 56 | ); 57 | } 58 | 59 | return $this->metadata; 60 | } 61 | 62 | abstract protected function getEnvelopeMetadata(): array; 63 | } 64 | -------------------------------------------------------------------------------- /src/Message/EnvelopeInterface.php: -------------------------------------------------------------------------------- 1 | getMetadata()[self::MESSAGE_ID_KEY] ?? null; 24 | 25 | /** @var int|string|null $id */ 26 | $id = match (true) { 27 | $rawId === null => null, // don't remove this branch: it's important for compute speed 28 | is_string($rawId) => $rawId, 29 | is_int($rawId) => $rawId, 30 | is_object($rawId) && method_exists($rawId, '__toString') => (string)$rawId, 31 | default => null, 32 | }; 33 | 34 | return new self($message, $id); 35 | } 36 | 37 | public function getId(): string|int|null 38 | { 39 | return $this->id; 40 | } 41 | 42 | protected function getEnvelopeMetadata(): array 43 | { 44 | return [self::MESSAGE_ID_KEY => $this->getId()]; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Message/JsonMessageSerializer.php: -------------------------------------------------------------------------------- 1 | $message->getHandlerName(), 19 | 'data' => $message->getData(), 20 | 'meta' => $message->getMetadata(), 21 | ]; 22 | if (!isset($payload['meta']['message-class'])) { 23 | $payload['meta']['message-class'] = $message instanceof EnvelopeInterface 24 | ? $message->getMessage()::class 25 | : $message::class; 26 | } 27 | 28 | return json_encode($payload, JSON_THROW_ON_ERROR); 29 | } 30 | 31 | /** 32 | * @throws JsonException 33 | * @throws InvalidArgumentException 34 | */ 35 | public function unserialize(string $value): MessageInterface 36 | { 37 | $payload = json_decode($value, true, 512, JSON_THROW_ON_ERROR); 38 | if (!is_array($payload)) { 39 | throw new InvalidArgumentException('Payload must be array. Got ' . get_debug_type($payload) . '.'); 40 | } 41 | 42 | $name = $payload['name'] ?? null; 43 | if (!isset($name) || !is_string($name)) { 44 | throw new InvalidArgumentException('Handler name must be a string. Got ' . get_debug_type($name) . '.'); 45 | } 46 | 47 | $meta = $payload['meta'] ?? []; 48 | if (!is_array($meta)) { 49 | throw new InvalidArgumentException('Metadata must be an array. Got ' . get_debug_type($meta) . '.'); 50 | } 51 | 52 | $envelopes = []; 53 | if (isset($meta[EnvelopeInterface::ENVELOPE_STACK_KEY]) && is_array($meta[EnvelopeInterface::ENVELOPE_STACK_KEY])) { 54 | $envelopes = $meta[EnvelopeInterface::ENVELOPE_STACK_KEY]; 55 | } 56 | $meta[EnvelopeInterface::ENVELOPE_STACK_KEY] = []; 57 | 58 | $class = $payload['meta']['message-class'] ?? Message::class; 59 | // Don't check subclasses when it's a default class: that's faster 60 | if ($class !== Message::class && !is_subclass_of($class, MessageInterface::class)) { 61 | $class = Message::class; 62 | } 63 | 64 | /** 65 | * @var class-string $class 66 | */ 67 | $message = $class::fromData($name, $payload['data'] ?? null, $meta); 68 | 69 | foreach ($envelopes as $envelope) { 70 | if (is_string($envelope) && class_exists($envelope) && is_subclass_of($envelope, EnvelopeInterface::class)) { 71 | $message = $envelope::fromMessage($message); 72 | } 73 | } 74 | 75 | return $message; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Message/Message.php: -------------------------------------------------------------------------------- 1 | handlerName; 29 | } 30 | 31 | public function getData(): mixed 32 | { 33 | return $this->data; 34 | } 35 | 36 | public function getMetadata(): array 37 | { 38 | return $this->metadata; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Message/MessageHandlerInterface.php: -------------------------------------------------------------------------------- 1 | container->has($definition)) { 40 | // Object with an __invoke() method 41 | $callable = $this->container->get($definition); 42 | } 43 | 44 | if (is_array($definition) 45 | && array_keys($definition) === [0, 1] 46 | && is_string($definition[0]) 47 | && is_string($definition[1]) 48 | ) { 49 | [$className, $methodName] = $definition; 50 | $callable = $this->fromDefinition($className, $methodName); 51 | } 52 | 53 | if ($callable === null) { 54 | $callable = $definition; 55 | } 56 | 57 | if (is_callable($callable)) { 58 | return $callable; 59 | } 60 | 61 | throw new InvalidCallableConfigurationException(); 62 | } 63 | 64 | /** 65 | * @throws ContainerExceptionInterface Error while retrieving the entry from container. 66 | * @throws NotFoundExceptionInterface 67 | */ 68 | private function fromDefinition(string $className, string $methodName): ?callable 69 | { 70 | $result = null; 71 | 72 | if (class_exists($className)) { 73 | try { 74 | $reflection = new ReflectionMethod($className, $methodName); 75 | if ($reflection->isStatic()) { 76 | $result = [$className, $methodName]; 77 | } 78 | } catch (ReflectionException) { 79 | } 80 | } 81 | 82 | if ($result === null && $this->container->has($className)) { 83 | $result = [ 84 | $this->container->get($className), 85 | $methodName, 86 | ]; 87 | } 88 | 89 | return is_callable($result) ? $result : null; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Middleware/Consume/ConsumeFinalHandler.php: -------------------------------------------------------------------------------- 1 | handler; 22 | $handler($request->getMessage()); 23 | 24 | return $request; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Middleware/Consume/ConsumeMiddlewareDispatcher.php: -------------------------------------------------------------------------------- 1 | middlewareDefinitions = array_reverse($middlewareDefinitions); 28 | } 29 | 30 | /** 31 | * Dispatch request through middleware to get response. 32 | * 33 | * @param ConsumeRequest $request Request to pass to middleware. 34 | * @param MessageHandlerConsumeInterface $finishHandler Handler to use in case no middleware produced a response. 35 | */ 36 | public function dispatch( 37 | ConsumeRequest $request, 38 | MessageHandlerConsumeInterface $finishHandler 39 | ): ConsumeRequest { 40 | $handlerName = $request->getMessage()->getHandlerName(); 41 | if (!array_key_exists($handlerName, $this->stack)) { 42 | $this->stack[$handlerName] = new MiddlewareConsumeStack($this->buildMiddlewares(), $finishHandler); 43 | } 44 | 45 | return $this->stack[$handlerName]->handleConsume($request); 46 | } 47 | 48 | /** 49 | * Returns new instance with middleware handlers replaced with the ones provided. 50 | * The last specified handler will be executed first. 51 | * 52 | * @param array[]|callable[]|MiddlewareConsumeInterface[]|string[] $middlewareDefinitions Each array element is: 53 | * 54 | * - A name of a middleware class. The middleware instance will be obtained from container executed. 55 | * - A callable with `function(ServerRequestInterface $request, RequestHandlerInterface $handler): 56 | * ResponseInterface` signature. 57 | * - A "callable-like" array in format `[FooMiddleware::class, 'index']`. `FooMiddleware` instance will 58 | * be created and `index()` method will be executed. 59 | * - A function returning middleware. The middleware returned will be executed. 60 | * 61 | * For callables typed parameters are automatically injected using dependency injection container. 62 | * 63 | * @return self New instance of the {@see ConsumeMiddlewareDispatcher} 64 | */ 65 | public function withMiddlewares(array $middlewareDefinitions): self 66 | { 67 | $instance = clone $this; 68 | $instance->middlewareDefinitions = array_reverse($middlewareDefinitions); 69 | 70 | // Fixes a memory leak. 71 | unset($instance->stack); 72 | $instance->stack = []; 73 | 74 | return $instance; 75 | } 76 | 77 | /** 78 | * @return bool Whether there are middleware defined in the dispatcher. 79 | */ 80 | public function hasMiddlewares(): bool 81 | { 82 | return $this->middlewareDefinitions !== []; 83 | } 84 | 85 | /** 86 | * @return Closure[] 87 | */ 88 | private function buildMiddlewares(): array 89 | { 90 | $middlewares = []; 91 | $factory = $this->middlewareFactory; 92 | 93 | foreach ($this->middlewareDefinitions as $middlewareDefinition) { 94 | $middlewares[] = static fn (): MiddlewareConsumeInterface => $factory->createConsumeMiddleware( 95 | $middlewareDefinition 96 | ); 97 | } 98 | 99 | return $middlewares; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/Middleware/Consume/ConsumeRequest.php: -------------------------------------------------------------------------------- 1 | message; 19 | } 20 | 21 | public function getQueue(): QueueInterface 22 | { 23 | return $this->queue; 24 | } 25 | 26 | public function withMessage(MessageInterface $message): self 27 | { 28 | $instance = clone $this; 29 | $instance->message = $message; 30 | 31 | return $instance; 32 | } 33 | 34 | public function withQueue(QueueInterface $queue): self 35 | { 36 | $instance = clone $this; 37 | $instance->queue = $queue; 38 | 39 | return $instance; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Middleware/Consume/MessageHandlerConsumeInterface.php: -------------------------------------------------------------------------------- 1 | stack === null) { 33 | $this->build(); 34 | } 35 | 36 | /** @psalm-suppress PossiblyNullReference */ 37 | return $this->stack->handleConsume($request); 38 | } 39 | 40 | private function build(): void 41 | { 42 | $handler = $this->finishHandler; 43 | 44 | foreach ($this->middlewares as $middleware) { 45 | $handler = $this->wrap($middleware, $handler); 46 | } 47 | 48 | $this->stack = $handler; 49 | } 50 | 51 | /** 52 | * Wrap handler by middlewares. 53 | */ 54 | private function wrap(Closure $middlewareFactory, MessageHandlerConsumeInterface $handler): MessageHandlerConsumeInterface 55 | { 56 | return new class ($middlewareFactory, $handler) implements MessageHandlerConsumeInterface { 57 | private ?MiddlewareConsumeInterface $middleware = null; 58 | 59 | public function __construct( 60 | private readonly Closure $middlewareFactory, 61 | private readonly MessageHandlerConsumeInterface $handler, 62 | ) { 63 | } 64 | 65 | public function handleConsume(ConsumeRequest $request): ConsumeRequest 66 | { 67 | if ($this->middleware === null) { 68 | $this->middleware = ($this->middlewareFactory)(); 69 | } 70 | 71 | return $this->middleware->processConsume($request, $this->handler); 72 | } 73 | }; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Middleware/Consume/MiddlewareFactoryConsume.php: -------------------------------------------------------------------------------- 1 | getFromContainer($middlewareDefinition); 63 | } 64 | 65 | return $this->tryGetFromCallable($middlewareDefinition) 66 | ?? $this->tryGetFromArrayDefinition($middlewareDefinition) 67 | ?? throw new InvalidMiddlewareDefinitionException($middlewareDefinition); 68 | } 69 | 70 | private function getFromContainer(string $middlewareDefinition): MiddlewareConsumeInterface 71 | { 72 | if (class_exists($middlewareDefinition)) { 73 | if (is_subclass_of($middlewareDefinition, MiddlewareConsumeInterface::class)) { 74 | /** @var MiddlewareConsumeInterface */ 75 | return $this->container->get($middlewareDefinition); 76 | } 77 | } elseif ($this->container->has($middlewareDefinition)) { 78 | $middleware = $this->container->get($middlewareDefinition); 79 | if ($middleware instanceof MiddlewareConsumeInterface) { 80 | return $middleware; 81 | } 82 | } 83 | 84 | throw new InvalidMiddlewareDefinitionException($middlewareDefinition); 85 | } 86 | 87 | private function wrapCallable(callable $callback): MiddlewareConsumeInterface 88 | { 89 | return new class ($callback, $this->container) implements MiddlewareConsumeInterface { 90 | private $callback; 91 | 92 | public function __construct( 93 | callable $callback, 94 | private readonly ContainerInterface $container 95 | ) { 96 | $this->callback = $callback; 97 | } 98 | 99 | public function processConsume(ConsumeRequest $request, MessageHandlerConsumeInterface $handler): ConsumeRequest 100 | { 101 | $response = (new Injector($this->container))->invoke($this->callback, [$request, $handler]); 102 | if ($response instanceof ConsumeRequest) { 103 | return $response; 104 | } 105 | 106 | if ($response instanceof MiddlewareConsumeInterface) { 107 | return $response->processConsume($request, $handler); 108 | } 109 | 110 | throw new InvalidMiddlewareDefinitionException($this->callback); 111 | } 112 | }; 113 | } 114 | 115 | private function tryGetFromCallable( 116 | callable|MiddlewareConsumeInterface|array|string $definition 117 | ): ?MiddlewareConsumeInterface { 118 | if ($definition instanceof Closure) { 119 | return $this->wrapCallable($definition); 120 | } 121 | 122 | if ( 123 | is_array($definition) 124 | && array_keys($definition) === [0, 1] 125 | ) { 126 | try { 127 | return $this->wrapCallable($this->callableFactory->create($definition)); 128 | } catch (InvalidCallableConfigurationException $exception) { 129 | throw new InvalidMiddlewareDefinitionException($definition, previous: $exception); 130 | } 131 | } else { 132 | return null; 133 | } 134 | } 135 | 136 | private function tryGetFromArrayDefinition( 137 | callable|MiddlewareConsumeInterface|array|string $definition 138 | ): ?MiddlewareConsumeInterface { 139 | if (!is_array($definition)) { 140 | return null; 141 | } 142 | 143 | try { 144 | DefinitionValidator::validateArrayDefinition($definition); 145 | 146 | $middleware = ArrayDefinition::fromConfig($definition)->resolve($this->container); 147 | if ($middleware instanceof MiddlewareConsumeInterface) { 148 | return $middleware; 149 | } 150 | 151 | throw new InvalidMiddlewareDefinitionException($definition); 152 | } catch (InvalidConfigException) { 153 | } 154 | 155 | throw new InvalidMiddlewareDefinitionException($definition); 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/Middleware/Consume/MiddlewareFactoryConsumeInterface.php: -------------------------------------------------------------------------------- 1 | getMetadata()[self::FAILURE_META_KEY] ?? []; 25 | 26 | return new self($message, $metadata); 27 | } 28 | 29 | protected function getEnvelopeMetadata(): array 30 | { 31 | /** @var array $metadata */ 32 | $metadata = $this->message->getMetadata()[self::FAILURE_META_KEY] ?? []; 33 | 34 | return [self::FAILURE_META_KEY => ArrayHelper::merge($metadata, $this->metadata)]; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Middleware/FailureHandling/FailureFinalHandler.php: -------------------------------------------------------------------------------- 1 | getException(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Middleware/FailureHandling/FailureHandlingRequest.php: -------------------------------------------------------------------------------- 1 | message; 23 | } 24 | 25 | public function getException(): Throwable 26 | { 27 | return $this->exception; 28 | } 29 | 30 | public function getQueue(): QueueInterface 31 | { 32 | return $this->queue; 33 | } 34 | 35 | public function withMessage(MessageInterface $message): self 36 | { 37 | $instance = clone $this; 38 | $instance->message = $message; 39 | 40 | return $instance; 41 | } 42 | 43 | public function withException(Throwable $exception): self 44 | { 45 | $instance = clone $this; 46 | $instance->exception = $exception; 47 | 48 | return $instance; 49 | } 50 | 51 | public function withQueue(QueueInterface $queue): self 52 | { 53 | $instance = clone $this; 54 | $instance->queue = $queue; 55 | 56 | return $instance; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Middleware/FailureHandling/FailureMiddlewareDispatcher.php: -------------------------------------------------------------------------------- 1 | init(); 28 | } 29 | 30 | /** 31 | * Dispatch request through middleware to get response. 32 | * 33 | * @param FailureHandlingRequest $request Request to pass to middleware. 34 | * @param MessageFailureHandlerInterface $finishHandler Handler to use in case no middleware produced a response. 35 | */ 36 | public function dispatch( 37 | FailureHandlingRequest $request, 38 | MessageFailureHandlerInterface $finishHandler 39 | ): FailureHandlingRequest { 40 | /** @var string $channel It is always string in this context */ 41 | $channel = $request->getQueue()->getChannel(); 42 | if (!isset($this->middlewareDefinitions[$channel]) || $this->middlewareDefinitions[$channel] === []) { 43 | $channel = self::DEFAULT_PIPELINE; 44 | } 45 | $definitions = array_reverse($this->middlewareDefinitions[$channel]); 46 | 47 | if (!isset($this->stack[$channel])) { 48 | $this->stack[$channel] = new MiddlewareFailureStack($this->buildMiddlewares(...$definitions), $finishHandler); 49 | } 50 | 51 | return $this->stack[$channel]->handleFailure($request); 52 | } 53 | 54 | /** 55 | * Returns new instance with middleware handlers replaced with the ones provided. 56 | * The last specified handler will be executed first. 57 | * 58 | * @param array[][]|callable[][]|MiddlewareFailureInterface[][]|string[][] $middlewareDefinitions Each array element is: 59 | * 60 | * - A name of a middleware class. The middleware instance will be obtained from container executed. 61 | * - A callable with `function(ServerRequestInterface $request, RequestHandlerInterface $handler): 62 | * ResponseInterface` signature. 63 | * - A "callable-like" array in format `[FooMiddleware::class, 'index']`. `FooMiddleware` instance will 64 | * be created and `index()` method will be executed. 65 | * - A function returning a middleware. The middleware returned will be executed. 66 | * 67 | * For callables typed parameters are automatically injected using dependency injection container. 68 | * 69 | * @return self New instance of the {@see FailureMiddlewareDispatcher} 70 | */ 71 | public function withMiddlewares(array $middlewareDefinitions): self 72 | { 73 | $instance = clone $this; 74 | $instance->middlewareDefinitions = $middlewareDefinitions; 75 | 76 | // Fixes a memory leak. 77 | unset($instance->stack); 78 | $instance->stack = []; 79 | 80 | $instance->init(); 81 | 82 | return $instance; 83 | } 84 | 85 | private function init(): void 86 | { 87 | if (!isset($this->middlewareDefinitions[self::DEFAULT_PIPELINE])) { 88 | $this->middlewareDefinitions[self::DEFAULT_PIPELINE] = []; 89 | } 90 | } 91 | 92 | /** 93 | * @return Closure[] 94 | */ 95 | private function buildMiddlewares(array|callable|string|MiddlewareFailureInterface ...$definitions): array 96 | { 97 | $middlewares = []; 98 | $factory = $this->middlewareFactory; 99 | 100 | foreach ($definitions as $middlewareDefinition) { 101 | $middlewares[] = static fn (): MiddlewareFailureInterface => 102 | $factory->createFailureMiddleware($middlewareDefinition); 103 | } 104 | 105 | return $middlewares; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/Middleware/FailureHandling/Implementation/ExponentialDelayMiddleware.php: -------------------------------------------------------------------------------- 1 | maxAttempts given."); 45 | } 46 | 47 | if ($delayInitial <= 0) { 48 | throw new InvalidArgumentException("delayInitial parameter must be a positive float, $this->delayInitial given."); 49 | } 50 | 51 | if ($delayMaximum < $delayInitial) { 52 | throw new InvalidArgumentException("delayMaximum parameter must not be less then delayInitial, , $this->delayMaximum given."); 53 | } 54 | 55 | if ($exponent <= 0) { 56 | throw new InvalidArgumentException("exponent parameter must not be zero or less, $this->exponent given."); 57 | } 58 | } 59 | 60 | public function processFailure( 61 | FailureHandlingRequest $request, 62 | MessageFailureHandlerInterface $handler 63 | ): FailureHandlingRequest { 64 | $message = $request->getMessage(); 65 | if ($this->suites($message)) { 66 | $envelope = new FailureEnvelope($message, $this->createNewMeta($message)); 67 | $queue = $this->queue ?? $request->getQueue(); 68 | $middlewareDefinitions = $this->delayMiddleware->withDelay($this->getDelay($envelope)); 69 | $messageNew = $queue->push( 70 | $envelope, 71 | $middlewareDefinitions 72 | ); 73 | 74 | return $request->withMessage($messageNew); 75 | } 76 | 77 | return $handler->handleFailure($request); 78 | } 79 | 80 | private function suites(MessageInterface $message): bool 81 | { 82 | return $this->maxAttempts > $this->getAttempts($message); 83 | } 84 | 85 | private function createNewMeta(MessageInterface $message): array 86 | { 87 | return [ 88 | self::META_KEY_DELAY . "-$this->id" => $this->getDelay($message), 89 | self::META_KEY_ATTEMPTS . "-$this->id" => $this->getAttempts($message) + 1, 90 | ]; 91 | } 92 | 93 | private function getAttempts(MessageInterface $message): int 94 | { 95 | return $message->getMetadata()[FailureEnvelope::FAILURE_META_KEY][self::META_KEY_ATTEMPTS . "-$this->id"] ?? 0; 96 | } 97 | 98 | private function getDelay(MessageInterface $message): float 99 | { 100 | $meta = $message->getMetadata()[FailureEnvelope::FAILURE_META_KEY] ?? []; 101 | $key = self::META_KEY_DELAY . "-$this->id"; 102 | 103 | $delayOriginal = (float) ($meta[$key] ?? 0); 104 | if ($delayOriginal <= 0) { 105 | $delayOriginal = $this->delayInitial; 106 | } 107 | 108 | $result = $delayOriginal * $this->exponent; 109 | 110 | return min($result, $this->delayMaximum); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/Middleware/FailureHandling/Implementation/SendAgainMiddleware.php: -------------------------------------------------------------------------------- 1 | maxAttempts given."); 35 | } 36 | } 37 | 38 | public function processFailure( 39 | FailureHandlingRequest $request, 40 | MessageFailureHandlerInterface $handler 41 | ): FailureHandlingRequest { 42 | $message = $request->getMessage(); 43 | if ($this->suites($message)) { 44 | $envelope = new FailureEnvelope($message, $this->createMeta($message)); 45 | $envelope = ($this->targetQueue ?? $request->getQueue())->push($envelope); 46 | 47 | return $request->withMessage($envelope) 48 | ->withQueue($this->targetQueue ?? $request->getQueue()); 49 | } 50 | 51 | return $handler->handleFailure($request); 52 | } 53 | 54 | private function suites(MessageInterface $message): bool 55 | { 56 | return $this->getAttempts($message) < $this->maxAttempts; 57 | } 58 | 59 | private function createMeta(MessageInterface $message): array 60 | { 61 | return [$this->getMetaKey() => $this->getAttempts($message) + 1]; 62 | } 63 | 64 | private function getAttempts(MessageInterface $message): int 65 | { 66 | $result = $message->getMetadata()[FailureEnvelope::FAILURE_META_KEY][$this->getMetaKey()] ?? 0; 67 | if ($result < 0) { 68 | $result = 0; 69 | } 70 | 71 | return (int) $result; 72 | } 73 | 74 | private function getMetaKey(): string 75 | { 76 | return self::META_KEY_RESEND . "-$this->id"; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Middleware/FailureHandling/MessageFailureHandlerInterface.php: -------------------------------------------------------------------------------- 1 | getFromContainer($middlewareDefinition); 66 | } 67 | 68 | return $this->tryGetFromCallable($middlewareDefinition) 69 | ?? $this->tryGetFromArrayDefinition($middlewareDefinition) 70 | ?? throw new InvalidMiddlewareDefinitionException($middlewareDefinition); 71 | } 72 | 73 | private function getFromContainer(string $middlewareDefinition): MiddlewareFailureInterface 74 | { 75 | if (class_exists($middlewareDefinition)) { 76 | if (is_subclass_of($middlewareDefinition, MiddlewareFailureInterface::class)) { 77 | /** @var MiddlewareFailureInterface */ 78 | return $this->container->get($middlewareDefinition); 79 | } 80 | } elseif ($this->container->has($middlewareDefinition)) { 81 | $middleware = $this->container->get($middlewareDefinition); 82 | if ($middleware instanceof MiddlewareFailureInterface) { 83 | return $middleware; 84 | } 85 | } 86 | 87 | throw new InvalidMiddlewareDefinitionException($middlewareDefinition); 88 | } 89 | 90 | private function wrapCallable(callable $callback): MiddlewareFailureInterface 91 | { 92 | return new class ($callback, $this->container) implements MiddlewareFailureInterface { 93 | private $callback; 94 | 95 | public function __construct( 96 | callable $callback, 97 | private readonly ContainerInterface $container 98 | ) { 99 | $this->callback = $callback; 100 | } 101 | 102 | public function processFailure(FailureHandlingRequest $request, MessageFailureHandlerInterface $handler): FailureHandlingRequest 103 | { 104 | $response = (new Injector($this->container))->invoke($this->callback, [$request, $handler]); 105 | if ($response instanceof FailureHandlingRequest) { 106 | return $response; 107 | } 108 | 109 | if ($response instanceof MiddlewareFailureInterface) { 110 | return $response->processFailure($request, $handler); 111 | } 112 | 113 | throw new InvalidMiddlewareDefinitionException($this->callback); 114 | } 115 | }; 116 | } 117 | 118 | private function tryGetFromCallable( 119 | callable|MiddlewareFailureInterface|array|string $definition 120 | ): ?MiddlewareFailureInterface { 121 | if ($definition instanceof Closure) { 122 | return $this->wrapCallable($definition); 123 | } 124 | 125 | if ( 126 | is_array($definition) 127 | && array_keys($definition) === [0, 1] 128 | ) { 129 | try { 130 | return $this->wrapCallable($this->callableFactory->create($definition)); 131 | } catch (InvalidCallableConfigurationException $exception) { 132 | throw new InvalidMiddlewareDefinitionException($definition, previous: $exception); 133 | } 134 | } else { 135 | return null; 136 | } 137 | } 138 | 139 | private function tryGetFromArrayDefinition( 140 | callable|MiddlewareFailureInterface|array|string $definition 141 | ): ?MiddlewareFailureInterface { 142 | if (!is_array($definition)) { 143 | return null; 144 | } 145 | 146 | try { 147 | DefinitionValidator::validateArrayDefinition($definition); 148 | 149 | $middleware = ArrayDefinition::fromConfig($definition)->resolve($this->container); 150 | if ($middleware instanceof MiddlewareFailureInterface) { 151 | return $middleware; 152 | } 153 | 154 | throw new InvalidMiddlewareDefinitionException($definition); 155 | } catch (InvalidConfigException) { 156 | } 157 | 158 | throw new InvalidMiddlewareDefinitionException($definition); 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/Middleware/FailureHandling/MiddlewareFactoryFailureInterface.php: -------------------------------------------------------------------------------- 1 | stack === null) { 33 | $this->build(); 34 | } 35 | 36 | /** @psalm-suppress PossiblyNullReference */ 37 | return $this->stack->handleFailure($request); 38 | } 39 | 40 | private function build(): void 41 | { 42 | $handler = $this->finishHandler; 43 | 44 | foreach ($this->middlewares as $middleware) { 45 | $handler = $this->wrap($middleware, $handler); 46 | } 47 | 48 | $this->stack = $handler; 49 | } 50 | 51 | /** 52 | * Wrap handler by middlewares. 53 | */ 54 | private function wrap(Closure $middlewareFactory, MessageFailureHandlerInterface $handler): MessageFailureHandlerInterface 55 | { 56 | return new class ($middlewareFactory, $handler) implements MessageFailureHandlerInterface { 57 | private ?MiddlewareFailureInterface $middleware = null; 58 | 59 | public function __construct( 60 | private readonly Closure $middlewareFactory, 61 | private readonly MessageFailureHandlerInterface $handler, 62 | ) { 63 | } 64 | 65 | public function handleFailure(FailureHandlingRequest $request): FailureHandlingRequest 66 | { 67 | if ($this->middleware === null) { 68 | $this->middleware = ($this->middlewareFactory)(); 69 | } 70 | 71 | return $this->middleware->processFailure($request, $this->handler); 72 | } 73 | }; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Middleware/InvalidCallableConfigurationException.php: -------------------------------------------------------------------------------- 1 | convertDefinitionToString($middlewareDefinition); 24 | if ($definitionString !== null) { 25 | $message .= ' Got ' . $definitionString . '.'; 26 | } 27 | 28 | parent::__construct($message, $code, $previous); 29 | } 30 | 31 | /** 32 | * @param mixed $middlewareDefinition Middleware definition. 33 | * @return string|null 34 | */ 35 | private function convertDefinitionToString(mixed $middlewareDefinition): ?string 36 | { 37 | if (is_object($middlewareDefinition)) { 38 | return 'an instance of "' . $middlewareDefinition::class . '"'; 39 | } 40 | 41 | if (is_string($middlewareDefinition)) { 42 | return '"' . $middlewareDefinition . '"'; 43 | } 44 | 45 | if (is_array($middlewareDefinition)) { 46 | $items = $middlewareDefinition; 47 | foreach ($middlewareDefinition as $item) { 48 | if (!is_string($item)) { 49 | return null; 50 | } 51 | } 52 | array_walk( 53 | $items, 54 | static function (mixed &$item, int|string $key) { 55 | $item = (string) $item; 56 | $item = '"' . $item . '"'; 57 | if (is_string($key)) { 58 | $item = '"' . $key . '" => ' . $item; 59 | } 60 | } 61 | ); 62 | 63 | /** @var string[] $items */ 64 | return '[' . implode(', ', $items) . ']'; 65 | } 66 | 67 | return null; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Middleware/Push/AdapterPushHandler.php: -------------------------------------------------------------------------------- 1 | getAdapter()) === null) { 17 | throw new AdapterNotConfiguredException(); 18 | } 19 | return $request->withMessage($adapter->push($request->getMessage())); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Middleware/Push/Implementation/DelayMiddlewareInterface.php: -------------------------------------------------------------------------------- 1 | getMessage()->getMetadata(); 20 | if (empty($meta[IdEnvelope::MESSAGE_ID_KEY])) { 21 | $request = $request->withMessage(new IdEnvelope($request->getMessage(), uniqid('yii3-message-', true))); 22 | } 23 | 24 | return $handler->handlePush($request); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Middleware/Push/MessageHandlerPushInterface.php: -------------------------------------------------------------------------------- 1 | getFromContainer($middlewareDefinition); 63 | } 64 | 65 | return $this->tryGetFromCallable($middlewareDefinition) 66 | ?? $this->tryGetFromArrayDefinition($middlewareDefinition) 67 | ?? throw new InvalidMiddlewareDefinitionException($middlewareDefinition); 68 | } 69 | 70 | private function getFromContainer(string $middlewareDefinition): MiddlewarePushInterface 71 | { 72 | if (class_exists($middlewareDefinition)) { 73 | if (is_subclass_of($middlewareDefinition, MiddlewarePushInterface::class)) { 74 | /** @var MiddlewarePushInterface */ 75 | return $this->container->get($middlewareDefinition); 76 | } 77 | } elseif ($this->container->has($middlewareDefinition)) { 78 | $middleware = $this->container->get($middlewareDefinition); 79 | if ($middleware instanceof MiddlewarePushInterface) { 80 | return $middleware; 81 | } 82 | } 83 | 84 | throw new InvalidMiddlewareDefinitionException($middlewareDefinition); 85 | } 86 | 87 | private function wrapCallable(callable $callback): MiddlewarePushInterface 88 | { 89 | return new class ($callback, $this->container) implements MiddlewarePushInterface { 90 | private $callback; 91 | 92 | public function __construct( 93 | callable $callback, 94 | private readonly ContainerInterface $container 95 | ) { 96 | $this->callback = $callback; 97 | } 98 | 99 | public function processPush(PushRequest $request, MessageHandlerPushInterface $handler): PushRequest 100 | { 101 | $response = (new Injector($this->container))->invoke($this->callback, [$request, $handler]); 102 | if ($response instanceof PushRequest) { 103 | return $response; 104 | } 105 | 106 | if ($response instanceof MiddlewarePushInterface) { 107 | return $response->processPush($request, $handler); 108 | } 109 | 110 | throw new InvalidMiddlewareDefinitionException($this->callback); 111 | } 112 | }; 113 | } 114 | 115 | private function tryGetFromCallable( 116 | callable|MiddlewarePushInterface|array|string $definition 117 | ): ?MiddlewarePushInterface { 118 | if ($definition instanceof Closure) { 119 | return $this->wrapCallable($definition); 120 | } 121 | 122 | if ( 123 | is_array($definition) 124 | && array_keys($definition) === [0, 1] 125 | ) { 126 | try { 127 | return $this->wrapCallable($this->callableFactory->create($definition)); 128 | } catch (InvalidCallableConfigurationException $exception) { 129 | throw new InvalidMiddlewareDefinitionException($definition, previous: $exception); 130 | } 131 | } else { 132 | return null; 133 | } 134 | } 135 | 136 | private function tryGetFromArrayDefinition( 137 | callable|MiddlewarePushInterface|array|string $definition 138 | ): ?MiddlewarePushInterface { 139 | if (!is_array($definition)) { 140 | return null; 141 | } 142 | 143 | try { 144 | DefinitionValidator::validateArrayDefinition($definition); 145 | 146 | $middleware = ArrayDefinition::fromConfig($definition)->resolve($this->container); 147 | if ($middleware instanceof MiddlewarePushInterface) { 148 | return $middleware; 149 | } 150 | 151 | throw new InvalidMiddlewareDefinitionException($definition); 152 | } catch (InvalidConfigException) { 153 | } 154 | 155 | throw new InvalidMiddlewareDefinitionException($definition); 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/Middleware/Push/MiddlewareFactoryPushInterface.php: -------------------------------------------------------------------------------- 1 | stack === null) { 33 | $this->build(); 34 | } 35 | 36 | /** @psalm-suppress PossiblyNullReference */ 37 | return $this->stack->handlePush($request); 38 | } 39 | 40 | private function build(): void 41 | { 42 | $handler = $this->finishHandler; 43 | 44 | foreach ($this->middlewares as $middleware) { 45 | $handler = $this->wrap($middleware, $handler); 46 | } 47 | 48 | $this->stack = $handler; 49 | } 50 | 51 | /** 52 | * Wrap handler by middlewares. 53 | */ 54 | private function wrap(Closure $middlewareFactory, MessageHandlerPushInterface $handler): MessageHandlerPushInterface 55 | { 56 | return new class ($middlewareFactory, $handler) implements MessageHandlerPushInterface { 57 | private ?MiddlewarePushInterface $middleware = null; 58 | 59 | public function __construct( 60 | private readonly Closure $middlewareFactory, 61 | private readonly MessageHandlerPushInterface $handler, 62 | ) { 63 | } 64 | 65 | public function handlePush(PushRequest $request): PushRequest 66 | { 67 | if ($this->middleware === null) { 68 | $this->middleware = ($this->middlewareFactory)(); 69 | } 70 | 71 | return $this->middleware->processPush($request, $this->handler); 72 | } 73 | }; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Middleware/Push/PushMiddlewareDispatcher.php: -------------------------------------------------------------------------------- 1 | middlewareDefinitions = array_reverse($middlewareDefinitions); 27 | } 28 | 29 | /** 30 | * Dispatch request through middleware to get response. 31 | * 32 | * @param PushRequest $request Request to pass to middleware. 33 | * @param MessageHandlerPushInterface $finishHandler Handler to use in case no middleware produced a response. 34 | */ 35 | public function dispatch( 36 | PushRequest $request, 37 | MessageHandlerPushInterface $finishHandler 38 | ): PushRequest { 39 | if ($this->stack === null) { 40 | $this->stack = new MiddlewarePushStack($this->buildMiddlewares(), $finishHandler); 41 | } 42 | 43 | return $this->stack->handlePush($request); 44 | } 45 | 46 | /** 47 | * Returns new instance with middleware handlers replaced with the ones provided. 48 | * The last specified handler will be executed first. 49 | * 50 | * @param array[]|callable[]|MiddlewarePushInterface[]|string[] $middlewareDefinitions Each array element is: 51 | * 52 | * - A name of a middleware class. The middleware instance will be obtained from container executed. 53 | * - A callable with `function(ServerRequestInterface $request, RequestHandlerInterface $handler): 54 | * ResponseInterface` signature. 55 | * - A "callable-like" array in format `[FooMiddleware::class, 'index']`. `FooMiddleware` instance will 56 | * be created and `index()` method will be executed. 57 | * - A function returning a middleware. The middleware returned will be executed. 58 | * 59 | * For callables typed parameters are automatically injected using dependency injection container. 60 | * 61 | * @return self New instance of the {@see PushMiddlewareDispatcher} 62 | */ 63 | public function withMiddlewares(array $middlewareDefinitions): self 64 | { 65 | $instance = clone $this; 66 | $instance->middlewareDefinitions = array_reverse($middlewareDefinitions); 67 | 68 | // Fixes a memory leak. 69 | unset($instance->stack); 70 | $instance->stack = null; 71 | 72 | return $instance; 73 | } 74 | 75 | /** 76 | * @return bool Whether there are middleware defined in the dispatcher. 77 | */ 78 | public function hasMiddlewares(): bool 79 | { 80 | return $this->middlewareDefinitions !== []; 81 | } 82 | 83 | /** 84 | * @return Closure[] 85 | */ 86 | private function buildMiddlewares(): array 87 | { 88 | $middlewares = []; 89 | $factory = $this->middlewareFactory; 90 | 91 | foreach ($this->middlewareDefinitions as $middlewareDefinition) { 92 | $middlewares[] = static fn (): MiddlewarePushInterface => $factory->createPushMiddleware( 93 | $middlewareDefinition 94 | ); 95 | } 96 | 97 | return $middlewares; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/Middleware/Push/PushRequest.php: -------------------------------------------------------------------------------- 1 | message; 19 | } 20 | 21 | public function getAdapter(): ?AdapterInterface 22 | { 23 | return $this->adapter; 24 | } 25 | 26 | public function withMessage(MessageInterface $message): self 27 | { 28 | $instance = clone $this; 29 | $instance->message = $message; 30 | 31 | return $instance; 32 | } 33 | 34 | public function withAdapter(AdapterInterface $adapter): self 35 | { 36 | $instance = clone $this; 37 | $instance->adapter = $adapter; 38 | 39 | return $instance; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Provider/AdapterFactoryQueueProvider.php: -------------------------------------------------------------------------------- 1 | 28 | */ 29 | private array $queues = []; 30 | 31 | private readonly StrictFactory $factory; 32 | 33 | /** 34 | * @param QueueInterface $baseQueue Base queue for queues creation. 35 | * @param array $definitions Adapter definitions indexed by channel names. 36 | * @param ContainerInterface|null $container Container to use for dependencies resolving. 37 | * @param bool $validate If definitions should be validated when set. 38 | * 39 | * @psalm-param array $definitions 40 | * @throws InvalidQueueConfigException 41 | */ 42 | public function __construct( 43 | private readonly QueueInterface $baseQueue, 44 | array $definitions, 45 | ?ContainerInterface $container = null, 46 | bool $validate = true, 47 | ) { 48 | try { 49 | $this->factory = new StrictFactory($definitions, $container, $validate); 50 | } catch (InvalidConfigException $exception) { 51 | throw new InvalidQueueConfigException($exception->getMessage(), previous: $exception); 52 | } 53 | } 54 | 55 | public function get(string|BackedEnum $channel): QueueInterface 56 | { 57 | $channel = ChannelNormalizer::normalize($channel); 58 | 59 | $queue = $this->getOrTryToCreate($channel); 60 | if ($queue === null) { 61 | throw new ChannelNotFoundException($channel); 62 | } 63 | 64 | return $queue; 65 | } 66 | 67 | public function has(string|BackedEnum $channel): bool 68 | { 69 | $channel = ChannelNormalizer::normalize($channel); 70 | return $this->factory->has($channel); 71 | } 72 | 73 | /** 74 | * @throws InvalidQueueConfigException 75 | */ 76 | private function getOrTryToCreate(string $channel): QueueInterface|null 77 | { 78 | if (array_key_exists($channel, $this->queues)) { 79 | return $this->queues[$channel]; 80 | } 81 | 82 | if ($this->factory->has($channel)) { 83 | $adapter = $this->factory->create($channel); 84 | if (!$adapter instanceof AdapterInterface) { 85 | throw new InvalidQueueConfigException( 86 | sprintf( 87 | 'Adapter must implement "%s". For channel "%s" got "%s" instead.', 88 | AdapterInterface::class, 89 | $channel, 90 | get_debug_type($adapter), 91 | ), 92 | ); 93 | } 94 | $this->queues[$channel] = $this->baseQueue->withAdapter($adapter->withChannel($channel)); 95 | } else { 96 | $this->queues[$channel] = null; 97 | } 98 | 99 | return $this->queues[$channel]; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/Provider/ChannelNotFoundException.php: -------------------------------------------------------------------------------- 1 | providers = $providers; 27 | } 28 | 29 | public function get(string|BackedEnum $channel): QueueInterface 30 | { 31 | foreach ($this->providers as $provider) { 32 | if ($provider->has($channel)) { 33 | return $provider->get($channel); 34 | } 35 | } 36 | throw new ChannelNotFoundException($channel); 37 | } 38 | 39 | public function has(string|BackedEnum $channel): bool 40 | { 41 | foreach ($this->providers as $provider) { 42 | if ($provider->has($channel)) { 43 | return true; 44 | } 45 | } 46 | return false; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Provider/InvalidQueueConfigException.php: -------------------------------------------------------------------------------- 1 | baseQueue->withAdapter($this->baseAdapter->withChannel($channel)); 29 | } 30 | 31 | public function has(string|BackedEnum $channel): bool 32 | { 33 | return true; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Provider/QueueProviderException.php: -------------------------------------------------------------------------------- 1 | middlewareDefinitions = $middlewareDefinitions; 37 | $this->adapterPushHandler = new AdapterPushHandler(); 38 | } 39 | 40 | public function getChannel(): string 41 | { 42 | $this->checkAdapter(); 43 | return $this->adapter->getChannel(); 44 | } 45 | 46 | public function push( 47 | MessageInterface $message, 48 | MiddlewarePushInterface|callable|array|string ...$middlewareDefinitions 49 | ): MessageInterface { 50 | $this->checkAdapter(); 51 | $this->logger->debug( 52 | 'Preparing to push message with handler name "{handlerName}".', 53 | ['handlerName' => $message->getHandlerName()] 54 | ); 55 | 56 | $request = new PushRequest($message, $this->adapter); 57 | $message = $this->pushMiddlewareDispatcher 58 | ->dispatch($request, $this->createPushHandler(...$middlewareDefinitions)) 59 | ->getMessage(); 60 | 61 | /** @var string $messageId */ 62 | $messageId = $message->getMetadata()[IdEnvelope::MESSAGE_ID_KEY] ?? 'null'; 63 | $this->logger->info( 64 | 'Pushed message with handler name "{handlerName}" to the queue. Assigned ID #{id}.', 65 | ['handlerName' => $message->getHandlerName(), 'id' => $messageId] 66 | ); 67 | 68 | return $message; 69 | } 70 | 71 | public function run(int $max = 0): int 72 | { 73 | $this->checkAdapter(); 74 | 75 | $this->logger->debug('Start processing queue messages.'); 76 | $count = 0; 77 | 78 | $handlerCallback = function (MessageInterface $message) use (&$max, &$count): bool { 79 | if (($max > 0 && $max <= $count) || !$this->handle($message)) { 80 | return false; 81 | } 82 | $count++; 83 | 84 | return true; 85 | }; 86 | 87 | $this->adapter->runExisting($handlerCallback); 88 | 89 | $this->logger->info( 90 | 'Processed {count} queue messages.', 91 | ['count' => $count] 92 | ); 93 | 94 | return $count; 95 | } 96 | 97 | public function listen(): void 98 | { 99 | $this->checkAdapter(); 100 | 101 | $this->logger->info('Start listening to the queue.'); 102 | $this->adapter->subscribe(fn (MessageInterface $message) => $this->handle($message)); 103 | $this->logger->info('Finish listening to the queue.'); 104 | } 105 | 106 | public function status(string|int $id): JobStatus 107 | { 108 | $this->checkAdapter(); 109 | return $this->adapter->status($id); 110 | } 111 | 112 | public function withAdapter(AdapterInterface $adapter): self 113 | { 114 | $new = clone $this; 115 | $new->adapter = $adapter; 116 | 117 | return $new; 118 | } 119 | 120 | public function withMiddlewares(MiddlewarePushInterface|callable|array|string ...$middlewareDefinitions): self 121 | { 122 | $instance = clone $this; 123 | $instance->middlewareDefinitions = $middlewareDefinitions; 124 | 125 | return $instance; 126 | } 127 | 128 | public function withMiddlewaresAdded(MiddlewarePushInterface|callable|array|string ...$middlewareDefinitions): self 129 | { 130 | $instance = clone $this; 131 | $instance->middlewareDefinitions = [...array_values($instance->middlewareDefinitions), ...array_values($middlewareDefinitions)]; 132 | 133 | return $instance; 134 | } 135 | 136 | private function handle(MessageInterface $message): bool 137 | { 138 | $this->worker->process($message, $this); 139 | 140 | return $this->loop->canContinue(); 141 | } 142 | 143 | /** 144 | * @psalm-assert AdapterInterface $this->adapter 145 | */ 146 | private function checkAdapter(): void 147 | { 148 | if ($this->adapter === null) { 149 | throw new AdapterNotConfiguredException(); 150 | } 151 | } 152 | 153 | private function createPushHandler(MiddlewarePushInterface|callable|array|string ...$middlewares): MessageHandlerPushInterface 154 | { 155 | return new class ( 156 | $this->adapterPushHandler, 157 | $this->pushMiddlewareDispatcher, 158 | array_merge($this->middlewareDefinitions, $middlewares) 159 | ) implements MessageHandlerPushInterface { 160 | public function __construct( 161 | private readonly AdapterPushHandler $adapterPushHandler, 162 | private readonly PushMiddlewareDispatcher $dispatcher, 163 | /** 164 | * @var array|array[]|callable[]|MiddlewarePushInterface[]|string[] 165 | */ 166 | private readonly array $middlewares, 167 | ) { 168 | } 169 | 170 | public function handlePush(PushRequest $request): PushRequest 171 | { 172 | return $this->dispatcher 173 | ->withMiddlewares($this->middlewares) 174 | ->dispatch($request, $this->adapterPushHandler); 175 | } 176 | }; 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /src/QueueInterface.php: -------------------------------------------------------------------------------- 1 | logger->info('Processing message #{message}.', ['message' => $message->getMetadata()[IdEnvelope::MESSAGE_ID_KEY] ?? 'null']); 51 | 52 | $name = $message->getHandlerName(); 53 | $handler = $this->getHandler($name); 54 | if ($handler === null) { 55 | throw new RuntimeException(sprintf('Queue handler with name "%s" does not exist', $name)); 56 | } 57 | 58 | $request = new ConsumeRequest($message, $queue); 59 | $closure = fn (MessageInterface $message): mixed => $this->injector->invoke($handler, [$message]); 60 | try { 61 | return $this->consumeMiddlewareDispatcher->dispatch($request, $this->createConsumeHandler($closure))->getMessage(); 62 | } catch (Throwable $exception) { 63 | $request = new FailureHandlingRequest($request->getMessage(), $exception, $request->getQueue()); 64 | 65 | try { 66 | $result = $this->failureMiddlewareDispatcher->dispatch($request, $this->createFailureHandler()); 67 | $this->logger->info($exception->getMessage()); 68 | 69 | return $result->getMessage(); 70 | } catch (Throwable $exception) { 71 | $exception = new JobFailureException($message, $exception); 72 | $this->logger->error($exception->getMessage()); 73 | throw $exception; 74 | } 75 | } 76 | } 77 | 78 | private function getHandler(string $name): ?callable 79 | { 80 | if (!array_key_exists($name, $this->handlersCached)) { 81 | $definition = $this->handlers[$name] ?? null; 82 | if ($definition === null && $this->container->has($name)) { 83 | $handler = $this->container->get($name); 84 | if ($handler instanceof MessageHandlerInterface) { 85 | $this->handlersCached[$name] = $handler->handle(...); 86 | 87 | return $this->handlersCached[$name]; 88 | } 89 | 90 | return null; 91 | } 92 | 93 | $this->handlersCached[$name] = $this->prepare($this->handlers[$name] ?? null); 94 | } 95 | 96 | return $this->handlersCached[$name]; 97 | } 98 | 99 | /** 100 | * Checks if the handler is a DI container alias 101 | * 102 | * @param array|callable|object|string|null $definition 103 | * 104 | * @throws ContainerExceptionInterface 105 | * @throws NotFoundExceptionInterface 106 | * @return callable|null 107 | */ 108 | private function prepare(callable|object|array|string|null $definition): callable|null 109 | { 110 | if (is_string($definition) && $this->container->has($definition)) { 111 | return $this->container->get($definition); 112 | } 113 | 114 | if ( 115 | is_array($definition) 116 | && array_keys($definition) === [0, 1] 117 | && is_string($definition[0]) 118 | && is_string($definition[1]) 119 | ) { 120 | [$className, $methodName] = $definition; 121 | 122 | if (!class_exists($className) && $this->container->has($className)) { 123 | return [ 124 | $this->container->get($className), 125 | $methodName, 126 | ]; 127 | } 128 | 129 | if (!class_exists($className)) { 130 | $this->logger->error("$className doesn't exist."); 131 | 132 | return null; 133 | } 134 | 135 | try { 136 | $reflection = new ReflectionMethod($className, $methodName); 137 | } catch (ReflectionException $e) { 138 | $this->logger->error($e->getMessage()); 139 | 140 | return null; 141 | } 142 | if ($reflection->isStatic()) { 143 | return [$className, $methodName]; 144 | } 145 | if ($this->container->has($className)) { 146 | return [ 147 | $this->container->get($className), 148 | $methodName, 149 | ]; 150 | } 151 | 152 | return null; 153 | } 154 | 155 | return $definition; 156 | } 157 | 158 | private function createConsumeHandler(Closure $handler): MessageHandlerConsumeInterface 159 | { 160 | return new ConsumeFinalHandler($handler); 161 | } 162 | 163 | private function createFailureHandler(): MessageFailureHandlerInterface 164 | { 165 | return new FailureFinalHandler(); 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/Worker/WorkerInterface.php: -------------------------------------------------------------------------------- 1 | channel = ChannelNormalizer::normalize($channel); 25 | } 26 | 27 | public function runExisting(callable $handlerCallback): void 28 | { 29 | } 30 | 31 | public function status(int|string $id): JobStatus 32 | { 33 | return JobStatus::DONE; 34 | } 35 | 36 | public function push(MessageInterface $message): MessageInterface 37 | { 38 | return $message; 39 | } 40 | 41 | public function subscribe(callable $handlerCallback): void 42 | { 43 | } 44 | 45 | public function withChannel(string|BackedEnum $channel): AdapterInterface 46 | { 47 | $new = clone $this; 48 | $new->channel = ChannelNormalizer::normalize($channel); 49 | return $new; 50 | } 51 | 52 | public function getChannel(): string 53 | { 54 | return $this->channel; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /stubs/StubLoop.php: -------------------------------------------------------------------------------- 1 | canContinue; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /stubs/StubQueue.php: -------------------------------------------------------------------------------- 1 | adapter; 47 | } 48 | 49 | public function withAdapter(AdapterInterface $adapter): QueueInterface 50 | { 51 | $new = clone $this; 52 | $new->adapter = $adapter; 53 | 54 | return $new; 55 | } 56 | 57 | public function getChannel(): string 58 | { 59 | if ($this->adapter === null) { 60 | throw new LogicException('Adapter is not set.'); 61 | } 62 | 63 | return $this->adapter->getChannel(); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /stubs/StubWorker.php: -------------------------------------------------------------------------------- 1 |