├── LICENSE.txt ├── README.md ├── composer.json ├── config └── Migrations │ └── 20221007202459_CreateFailedJobs.php ├── docs ├── config │ ├── __init__.py │ └── all.py └── en │ ├── conf.py │ ├── contents.rst │ └── index.rst ├── src ├── Command │ ├── JobCommand.php │ ├── PurgeFailedCommand.php │ ├── RequeueCommand.php │ └── WorkerCommand.php ├── Consumption │ ├── LimitAttemptsExtension.php │ ├── LimitConsumedMessagesExtension.php │ └── RemoveUniqueJobIdFromCacheExtension.php ├── Job │ ├── JobInterface.php │ ├── MailerJob.php │ ├── Message.php │ └── SendMailJob.php ├── Listener │ └── FailedJobsListener.php ├── Mailer │ ├── QueueTrait.php │ └── Transport │ │ └── QueueTransport.php ├── Model │ ├── Entity │ │ └── FailedJob.php │ └── Table │ │ └── FailedJobsTable.php ├── Plugin.php ├── Queue │ └── Processor.php └── QueueManager.php └── templates └── bake └── job.twig /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020-present, Cake Software Foundation, Inc. (https://cakefoundation.org) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Queue plugin for CakePHP 2 | 3 | ![Build Status](https://github.com/cakephp/queue/actions/workflows/ci.yml/badge.svg?branch=master) 4 | [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.txt) 5 | [![Coverage Status](https://img.shields.io/codecov/c/github/cakephp/queue/master.svg?style=flat-square)](https://codecov.io/github/cakephp/queue?branch=master) 6 | [![Total Downloads](https://img.shields.io/packagist/dt/cakephp/queue.svg?style=flat-square)](https://packagist.org/packages/cakephp/queue) 7 | 8 | This is a Queue system for CakePHP. 9 | 10 | The plugin consists of a CakePHP shell wrapper and Queueing libraries for the [php-queue](https://php-enqueue.github.io) queue library. 11 | 12 | ## Installation 13 | 14 | You can install this plugin into your CakePHP application using [Composer](https://getcomposer.org). 15 | 16 | Run the following command 17 | ```sh 18 | composer require cakephp/queue 19 | ``` 20 | 21 | Install the transport you wish to use. For a list of available transports, see [this page](https://php-enqueue.github.io/transport). The example below is for pure-php redis: 22 | 23 | ```shell 24 | composer require enqueue/redis predis/predis:^1 25 | ``` 26 | 27 | ## Configuration 28 | 29 | You can load the plugin using the shell command: 30 | 31 | ``` 32 | bin/cake plugin load Cake/Queue 33 | ``` 34 | 35 | Or you can manually add the loading statement in the **src/Application.php** file of your application: 36 | ```php 37 | public function bootstrap(): void 38 | { 39 | parent::bootstrap(); 40 | $this->addPlugin('Cake/Queue'); 41 | } 42 | ``` 43 | 44 | Additionally, you will need to configure the ``default`` queue configuration in your **config/app.php** file. 45 | 46 | ## Documentation 47 | 48 | Full documentation of the plugin can be found on the [CakePHP Cookbook](https://book.cakephp.org/queue/1/). 49 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cakephp/queue", 3 | "description": "Queue plugin for CakePHP", 4 | "license": "MIT", 5 | "type": "cakephp-plugin", 6 | "keywords": [ 7 | "cakephp", 8 | "queue" 9 | ], 10 | "authors": [ 11 | { 12 | "name": "CakePHP Community", 13 | "homepage": "https://github.com/cakephp/queue/graphs/contributors" 14 | } 15 | ], 16 | "homepage": "https://github.com/cakephp/queue", 17 | "support": { 18 | "issues": "https://github.com/cakephp/queue/issues", 19 | "forum": "https://stackoverflow.com/tags/cakephp", 20 | "irc": "irc://irc.freenode.org/cakephp", 21 | "source": "https://github.com/cakephp/queue" 22 | }, 23 | "require": { 24 | "php": ">=7.2.0", 25 | "cakephp/cakephp": "^4.1", 26 | "enqueue/simple-client": "^0.10", 27 | "psr/log": "^1.1 || ^2.0" 28 | }, 29 | "require-dev": { 30 | "cakephp/bake": "^2.1", 31 | "cakephp/cakephp-codesniffer": "^4.0", 32 | "enqueue/fs": "^0.10", 33 | "phpunit/phpunit": "^8.5 || ^9.3" 34 | }, 35 | "suggest": { 36 | "cakephp/bake": "Required if you want to generate jobs.", 37 | "cakephp/migrations": "Needed for running the migrations necessary for using Failed Jobs." 38 | }, 39 | "autoload": { 40 | "psr-4": { 41 | "Cake\\Queue\\": "src/" 42 | } 43 | }, 44 | "autoload-dev": { 45 | "psr-4": { 46 | "Cake\\Queue\\Test\\": "tests/", 47 | "Cake\\Test\\": "vendor/cakephp/cakephp/tests/", 48 | "TestApp\\": "tests/test_app/src/" 49 | } 50 | }, 51 | "config": { 52 | "allow-plugins": { 53 | "dealerdirect/phpcodesniffer-composer-installer": true 54 | }, 55 | "sort-packages": true 56 | }, 57 | "scripts": { 58 | "check": [ 59 | "@cs-check", 60 | "@test" 61 | ], 62 | "cs-check": "phpcs --colors -p src/ tests/", 63 | "cs-fix": "phpcbf --colors -p src/ tests/", 64 | "stan": "phpstan analyse && psalm.phar --show-info=false", 65 | "stan-setup": "cp composer.json composer.backup && composer require --dev phpstan/phpstan:^1.5 psalm/phar:~4.22 && mv composer.backup composer.json", 66 | "test": "phpunit", 67 | "test-coverage": "phpunit --coverage-clover=clover.xml" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /config/Migrations/20221007202459_CreateFailedJobs.php: -------------------------------------------------------------------------------- 1 | table('queue_failed_jobs'); 18 | $table->addColumn('class', 'string', [ 19 | 'length' => 255, 20 | 'null' => false, 21 | 'default' => null, 22 | ]) 23 | ->addColumn('method', 'string', [ 24 | 'length' => 255, 25 | 'null' => false, 26 | 'default' => null, 27 | ]) 28 | ->addColumn('data', 'text', [ 29 | 'null' => false, 30 | 'default' => null, 31 | ]) 32 | ->addColumn('config', 'string', [ 33 | 'length' => 255, 34 | 'null' => false, 35 | 'default' => null, 36 | ]) 37 | ->addColumn('priority', 'string', [ 38 | 'length' => 255, 39 | 'null' => true, 40 | 'default' => null, 41 | ]) 42 | ->addColumn('queue', 'string', [ 43 | 'length' => 255, 44 | 'null' => false, 45 | 'default' => null, 46 | ]) 47 | ->addColumn('exception', 'text', [ 48 | 'null' => true, 49 | 'default' => null, 50 | ]) 51 | ->addColumn('created', 'datetime', [ 52 | 'default' => null, 53 | 'limit' => null, 54 | 'null' => true, 55 | ]) 56 | ->create(); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /docs/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cakephp/queue/949a79453b1daf1a32d7f2f6404396e3e40b7c2d/docs/config/__init__.py -------------------------------------------------------------------------------- /docs/config/all.py: -------------------------------------------------------------------------------- 1 | # Global configuration information used across all the 2 | # translations of documentation. 3 | # 4 | # Import the base theme configuration 5 | from cakephpsphinx.config.all import * 6 | 7 | # The version info for the project you're documenting, acts as replacement for 8 | # |version| and |release|, also used in various other places throughout the 9 | # built documents. 10 | # 11 | 12 | # The full version, including alpha/beta/rc tags. 13 | release = '1.x' 14 | 15 | # The search index version. 16 | search_version = 'queue-1' 17 | 18 | # The marketing display name for the book. 19 | version_name = '' 20 | 21 | # Project name shown in the black header bar 22 | project = 'CakePHP Queue' 23 | 24 | # Other versions that display in the version picker menu. 25 | version_list = [ 26 | {'name': '1.x', 'number': '/queue/1/', 'title': '1.x', 'current': True}, 27 | ] 28 | 29 | # Languages available. 30 | languages = ['en'] 31 | 32 | # The GitHub branch name for this version of the docs 33 | # for edit links to point at. 34 | branch = '1.x' 35 | 36 | # Current version being built 37 | version = '1.x' 38 | 39 | show_root_link = True 40 | 41 | repository = 'cakephp/queue' 42 | 43 | source_path = 'docs/' 44 | 45 | hide_page_contents = ('search', '404', 'contents') 46 | -------------------------------------------------------------------------------- /docs/en/conf.py: -------------------------------------------------------------------------------- 1 | import sys, os 2 | 3 | # Append the top level directory of the docs, so we can import from the config dir. 4 | sys.path.insert(0, os.path.abspath('..')) 5 | 6 | # Pull in all the configuration options defined in the global config file.. 7 | from config.all import * 8 | 9 | 10 | # Language in use for this directory. 11 | language = 'en' 12 | -------------------------------------------------------------------------------- /docs/en/contents.rst: -------------------------------------------------------------------------------- 1 | Contents 2 | ######## 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | :caption: CakePHP Queue 7 | 8 | /index 9 | -------------------------------------------------------------------------------- /docs/en/index.rst: -------------------------------------------------------------------------------- 1 | Queue 2 | ##### 3 | 4 | The Queue plugin provides an easy-to-use interface for the `php-queue 5 | `_ project, which abstracts dozens of queuing 6 | backends for use within your application. Queues can be used to increase the 7 | performance of your application by deferring long-running processes - such as 8 | email or notification sending - until a later time. 9 | 10 | Installation 11 | ============ 12 | 13 | You can install this plugin into your CakePHP application using `composer 14 | `_. 15 | 16 | The recommended way to install composer packages is: 17 | 18 | .. code-block:: bash 19 | 20 | composer require cakephp/queue 21 | 22 | Install the transport you wish to use. For a list of available transports, see 23 | `this page `_. The example below is for 24 | pure-php redis: 25 | 26 | .. code-block:: bash 27 | 28 | composer require enqueue/redis predis/predis:^1 29 | 30 | Ensure that the plugin is loaded in your ``src/Application.php`` file, within 31 | the ``Application::bootstrap()`` function:: 32 | 33 | $this->addPlugin('Cake/Queue'); 34 | 35 | Configuration 36 | ============= 37 | 38 | The following configuration should be present in the config array of your **config/app.php**:: 39 | 40 | // ... 41 | 'Queue' => [ 42 | 'default' => [ 43 | // A DSN for your configured backend. default: null 44 | // Can contain protocol/port/username/password or be null if the backend defaults to localhost 45 | 'url' => 'redis://myusername:mypassword@example.com:1000', 46 | 47 | // The queue that will be used for sending messages. default: default 48 | // This can be overridden when queuing or processing messages 49 | 'queue' => 'default', 50 | 51 | // The name of a configured logger, default: null 52 | 'logger' => 'stdout', 53 | 54 | // The name of an event listener class to associate with the worker 55 | 'listener' => \App\Listener\WorkerListener::class, 56 | 57 | // The amount of time in milliseconds to sleep if no jobs are currently available. default: 10000 58 | 'receiveTimeout' => 10000, 59 | 60 | // Whether to store failed jobs in the queue_failed_jobs table. default: false 61 | 'storeFailedJobs' => true, 62 | 63 | // (optional) The cache configuration for storing unique job ids. `duration` 64 | // should be greater than the maximum length of time any job can be expected 65 | // to remain on the queue. Otherwise, duplicate jobs may be 66 | // possible. Defaults to +24 hours. Note that `File` engine is only suitable 67 | // for local development. 68 | // See https://book.cakephp.org/4/en/core-libraries/caching.html#configuring-cache-engines. 69 | 'uniqueCache' => [ 70 | 'engine' => 'File', 71 | ], 72 | ] 73 | ], 74 | // ... 75 | 76 | The ``Queue`` config key can contain one or more queue configurations. Each of 77 | these is used for interacting with a different queuing backend. 78 | 79 | If ``storeFailedJobs`` is set to ``true``, make sure to run the plugin migrations to create the ``queue_failed_jobs`` table. 80 | 81 | Install the migrations plugin: 82 | 83 | .. code-block:: bash 84 | 85 | composer require cakephp/migrations:"^3.1" 86 | 87 | Run the migrations: 88 | 89 | .. code-block:: bash 90 | 91 | bin/cake migrations migrate --plugin Cake/Queue 92 | 93 | 94 | Usage 95 | ===== 96 | 97 | Defining Jobs 98 | ------------- 99 | 100 | Workloads are defined as 'jobs'. Job classes can recieve dependencies from your 101 | application's dependency injection container in their constructor just like 102 | Controllers or Commands. Jobs are responsible for processing queue messages. 103 | A simple job that logs received messages would look like:: 104 | 105 | getArgument('id'); 137 | $data = $message->getArgument('data'); 138 | 139 | $this->log(sprintf('%d %s', $id, $data)); 140 | 141 | return Processor::ACK; 142 | } 143 | } 144 | 145 | The passed ``Message`` object has the following methods: 146 | 147 | - ``getArgument($key = null, $default = null)``: Can return the entire passed 148 | dataset or a value based on a ``Hash::get()`` notation key. 149 | - ``getContext()``: Returns the original context object. 150 | - ``getOriginalMessage()``: Returns the original queue message object. 151 | - ``getParsedBody()``: Returns the parsed queue message body. 152 | 153 | A job *may* return any of the following values: 154 | 155 | - ``Processor::ACK``: Use this constant when the message is processed 156 | successfully. The message will be removed from the queue. 157 | - ``Processor::REJECT``: Use this constant when the message could not be 158 | processed. The message will be removed from the queue. 159 | - ``Processor::REQUEUE``: Use this constant when the message is not valid or 160 | could not be processed right now but we can try again later. The original 161 | message is removed from the queue but a copy is published to the queue again. 162 | 163 | The job **may** also return a null value, which is interpreted as 164 | ``Processor::ACK``. Failure to respond with a valid type will result in an 165 | interpreted message failure and requeue of the message. 166 | 167 | Job Properties: 168 | 169 | - ``maxAttempts``: The maximum number of times the job may be requeued as a result 170 | of an exception or by explicitly returning ``Processor::REQUEUE``. If 171 | provided, this value will override the value provided in the worker command 172 | line option ``--max-attempts``. If a value is not provided by the job or by 173 | the command line option, the job may be requeued an infinite number of times. 174 | - ``shouldBeUnique``: If ``true``, only one instance of the job, identified by 175 | it's class, method, and data, will be allowed to be present on the queue at a 176 | time. Subsequent pushes will be silently dropped. This is useful for 177 | idempotent operations where consecutive job executions have no benefit. For 178 | example, refreshing calculated data. If ``true``, the ``uniqueCache`` 179 | configuration must be set. 180 | 181 | Queueing Jobs 182 | ------------- 183 | 184 | You can enqueue jobs using ``Cake\Queue\QueueManager``:: 185 | 186 | use App\Job\ExampleJob; 187 | use Cake\Queue\QueueManager; 188 | 189 | $data = ['id' => 7, 'is_premium' => true]; 190 | $options = ['config' => 'default']; 191 | 192 | QueueManager::push(ExampleJob::class, $data, $options); 193 | 194 | Arguments: 195 | 196 | - ``$className``: The class that will have it's execute method invoked when the 197 | job is processed. 198 | - ``$data`` (optional): A json-serializable array of data that will be 199 | passed to your job as a message. It should be key-value pairs. 200 | - ``$options`` (optional): An array of optional data for message queueing. 201 | 202 | The following keys are valid for use within the ``options`` array: 203 | 204 | - ``config``: 205 | 206 | - default: default 207 | - description: A queue config name 208 | - type: string 209 | 210 | - ``delay``: 211 | 212 | - default: ``null`` 213 | - description: Time - in integer seconds - to delay message, after which it will be processed. Not all message brokers accept this. 214 | - type: integer 215 | 216 | - ``expires``: 217 | 218 | - default: ``null`` 219 | - description: Time - in integer seconds - after which the message expires. 220 | The message will be removed from the queue if this time is exceeded and it 221 | has not been consumed. 222 | - type: integer 223 | 224 | - ``priority``: 225 | 226 | - default: ``null`` 227 | - type: constant 228 | - valid values: 229 | 230 | - ``\Enqueue\Client\MessagePriority::VERY_LOW`` 231 | - ``\Enqueue\Client\MessagePriority::LOW`` 232 | - ``\Enqueue\Client\MessagePriority::NORMAL`` 233 | - ``\Enqueue\Client\MessagePriority::HIGH`` 234 | - ``\Enqueue\Client\MessagePriority::VERY_HIGH`` 235 | 236 | - ``queue``: 237 | 238 | - default: from queue ``config`` array or string ``default`` if empty 239 | - description: The name of a queue to use 240 | - type: string 241 | 242 | Queueing Mailer Actions 243 | ----------------------- 244 | 245 | Mailer actions can be queued by adding the ``Queue\Mailer\QueueTrait`` to the 246 | mailer class. The following example shows how to setup the trait within a mailer 247 | class:: 248 | 249 | setTo($emailAddress) 265 | ->setSubject(sprintf('Welcome %s', $username)); 266 | } 267 | 268 | // ... other actions here ... 269 | } 270 | 271 | It is now possible to use the ``UserMailer`` to send out user-related emails in 272 | a delayed fashion from anywhere in our application. To queue the mailer action, 273 | use the ``push()`` method on a mailer instance:: 274 | 275 | $this->getMailer('User')->push('welcome', ['example@example.com', 'josegonzalez']); 276 | 277 | This ``QueueTrait::push()`` call will generate an intermediate ``MailerJob`` 278 | that handles processing of the email message. If the MailerJob is unable to 279 | instantiate the Email or Mailer instances, it is interpreted as 280 | a ``Processor::REJECT``. An invalid ``action`` is also interpreted as 281 | a ``Processor::REJECT``, as will the action throwing 282 | a ``BadMethodCallException``. Any non-exception result will be seen as 283 | a ``Processor:ACK``. 284 | 285 | The exposed ``QueueTrait::push()`` method has a similar signature to 286 | ``Mailer::send()``, and also supports an ``$options`` array argument. The 287 | options this array holds are the same options as those available for 288 | ``QueueManager::push()``. 289 | 290 | Delivering E-mail via Queue Jobs 291 | -------------------------------- 292 | 293 | If your application isn't using Mailers but you still want to deliver email via 294 | queue jobs, you can use the ``QueueTransport``. In your application's 295 | ``EmailTransport`` configuration add a transport:: 296 | 297 | // in app/config.php 298 | use Cake\Queue\Mailer\Transport\QueueTransport; 299 | 300 | return [ 301 | // ... other configuration 302 | 'EmailTransport' => [ 303 | 'default' => [ 304 | 'className' => MailTransport::class, 305 | // Configuration for MailTransport. 306 | ] 307 | 'queue' => [ 308 | 'className' => QueueTransport::class, 309 | // The transport name to use inside the queue job. 310 | 'transport' => 'default', 311 | ] 312 | ], 313 | 'Email' => [ 314 | 'default' => [ 315 | // Connect the default email profile to deliver 316 | // by queue jobs. 317 | 'transport' => 'queue', 318 | ] 319 | ] 320 | ]; 321 | 322 | With this configuration in place, any time you send an email with the ``default`` 323 | email profile CakePHP will generate a queue message. Once that queue message is 324 | processed the default ``MailTransport`` will be used to deliver the email messages. 325 | 326 | Run the worker 327 | ============== 328 | 329 | Once a message is queued, you may run a worker via the included ``queue worker`` shell: 330 | 331 | .. code-block:: bash 332 | 333 | bin/cake queue worker 334 | 335 | This shell can take a few different options: 336 | 337 | - ``--config`` (default: default): Name of a queue config to use 338 | - ``--queue`` (default: default): Name of queue to bind to 339 | - ``--processor`` (default: ``null``): Name of processor to bind to 340 | - ``--logger`` (default: ``stdout``): Name of a configured logger 341 | - ``--max-jobs`` (default: ``null``): Maximum number of jobs to process. Worker will exit after limit is reached. 342 | - ``--max-runtime`` (default: ``null``): Maximum number of seconds to run. Worker will exit after limit is reached. 343 | - ``--max-attempts`` (default: ``null``): Maximum number of times each job will be attempted. Maximum attempts defined on a job will override this value. 344 | - ``--verbose`` or ``-v`` (default: ``null``): Provide verbose output, displaying the current values for: 345 | 346 | - Max Iterations 347 | - Max Runtime 348 | - Runtime: Time since the worker started, the worker will finish when Runtime is over Max Runtime value 349 | 350 | Failed Jobs 351 | =========== 352 | 353 | By default, jobs that throw an exception are requeued indefinitely. However, if 354 | ``maxAttempts`` is configured on the job class or via a command line argument, a 355 | job will be considered failed if a ``Processor::REQUEUE`` response is received 356 | after processing (typically due to an exception being thrown) and there are no 357 | remaining attempts. The job will then be rejected and added to the 358 | ``queue_failed_jobs`` table and can be requeued manually. 359 | 360 | Your chosen transport may offer a dead-letter queue feature. While Failed Jobs 361 | has a similar purpose, it specifically captures jobs that return a 362 | ``Processor::REQUEUE`` response and does not handle other failure cases. It is 363 | agnostic of transport and only supports database persistence. 364 | 365 | The following options passed when originally queueing the job will be preserved: 366 | ``config``, ``queue``, and ``priority``. 367 | 368 | Requeue Failed Jobs 369 | ------------------- 370 | 371 | Push jobs back onto the queue and remove them from the ``queue_failed_jobs`` 372 | table. If a job fails to requeue it is not guaranteed that the job was not run. 373 | 374 | .. code-block:: bash 375 | 376 | bin/cake queue requeue 377 | 378 | Optional filters: 379 | 380 | - ``--id``: Requeue job by the ID of the ``FailedJob`` 381 | - ``--class``: Requeue jobs by the job class 382 | - ``--queue``: Requeue jobs by the queue the job was received on 383 | - ``--config``: Requeue jobs by the config used to queue the job 384 | 385 | If no filters are provided then all failed jobs will be requeued. 386 | 387 | Purge Failed Jobs 388 | ------------------ 389 | 390 | Delete jobs from the ``queue_failed_jobs`` table. 391 | 392 | .. code-block:: bash 393 | 394 | bin/cake queue purge_failed 395 | 396 | Optional filters: 397 | 398 | - ``--id``: Purge job by the ID of the ``FailedJob`` 399 | - ``--class``: Purge jobs by the job class 400 | - ``--queue``: Purge jobs by the queue the job was received on 401 | - ``--config``: Purge jobs by the config used to queue the job 402 | 403 | If no filters are provided then all failed jobs will be purged. 404 | 405 | 406 | Worker Events 407 | ============= 408 | 409 | The worker shell may invoke the events during normal execution. These events may 410 | be listened to by the associated ``listener`` in the Queue config. 411 | 412 | - ``Processor.message.exception``: 413 | 414 | - description: Dispatched when a message throws an exception. 415 | - arguments: ``message`` and ``exception`` 416 | 417 | - ``Processor.message.invalid``: 418 | 419 | - description: Dispatched when a message has an invalid callable. 420 | - arguments: ``message`` 421 | 422 | - ``Processor.message.reject``: 423 | 424 | - description: Dispatched when a message completes and is to be rejected. 425 | - arguments: ``message`` 426 | 427 | - ``Processor.message.success``: 428 | 429 | - description: Dispatched when a message completes and is to be acknowledged. 430 | - arguments: ``message`` 431 | 432 | - ``Processor.message.failure``: 433 | 434 | - description: Dispatched when a message completes and is to be requeued. 435 | - arguments: ``message`` 436 | 437 | - ``Processor.message.seen``: 438 | 439 | - description: Dispatched when a message is seen. 440 | - arguments: ``message`` 441 | 442 | - ``Processor.message.start``: 443 | 444 | - description: Dispatched before a message is started. 445 | - arguments: ``message`` 446 | -------------------------------------------------------------------------------- /src/Command/JobCommand.php: -------------------------------------------------------------------------------- 1 | getOption('max-attempts'); 59 | 60 | $data = [ 61 | 'isUnique' => $arguments->getOption('unique'), 62 | 'maxAttempts' => $maxAttempts ? (int)$maxAttempts : null, 63 | ]; 64 | 65 | return array_merge($parentData, $data); 66 | } 67 | 68 | /** 69 | * Gets the option parser instance and configures it. 70 | * 71 | * @param \Cake\Console\ConsoleOptionParser $parser The parser to update. 72 | * @return \Cake\Console\ConsoleOptionParser 73 | */ 74 | public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser 75 | { 76 | $parser = $this->_setCommonOptions($parser); 77 | 78 | return $parser 79 | ->setDescription('Bake a queue job class.') 80 | ->addArgument('name', [ 81 | 'help' => 'The name of the queue job class to create.', 82 | ]) 83 | ->addOption('max-attempts', [ 84 | 'help' => 'The maximum number of times the job may be attempted.', 85 | 'default' => null, 86 | ]) 87 | ->addOption('unique', [ 88 | 'help' => 'Whether there should be only one instance of a job on the queue at a time.', 89 | 'boolean' => true, 90 | 'default' => false, 91 | ]); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/Command/PurgeFailedCommand.php: -------------------------------------------------------------------------------- 1 | setDescription('Delete failed jobs.'); 49 | 50 | $parser->addArgument('ids', [ 51 | 'required' => false, 52 | 'help' => 'Delete jobs by the FailedJob ID (comma-separated).', 53 | ]); 54 | $parser->addOption('class', [ 55 | 'help' => 'Delete jobs by the job class.', 56 | ]); 57 | $parser->addOption('queue', [ 58 | 'help' => 'Delete jobs by the queue the job was received on.', 59 | ]); 60 | $parser->addOption('config', [ 61 | 'help' => 'Delete jobs by the config used to queue the job.', 62 | ]); 63 | $parser->addOption('force', [ 64 | 'help' => 'Automatically assume yes in response to confirmation prompt.', 65 | 'short' => 'f', 66 | 'boolean' => true, 67 | ]); 68 | 69 | return $parser; 70 | } 71 | 72 | /** 73 | * @param \Cake\Console\Arguments $args Arguments 74 | * @param \Cake\Console\ConsoleIo $io ConsoleIo 75 | * @return void 76 | */ 77 | public function execute(Arguments $args, ConsoleIo $io) 78 | { 79 | /** @var \Cake\Queue\Model\Table\FailedJobsTable $failedJobsTable */ 80 | $failedJobsTable = $this->getTableLocator()->get('Cake/Queue.FailedJobs'); 81 | 82 | $jobsToDelete = $failedJobsTable->find(); 83 | 84 | if ($args->hasArgument('ids')) { 85 | $idsArg = $args->getArgument('ids'); 86 | 87 | if ($idsArg !== null) { 88 | $ids = explode(',', $idsArg); 89 | 90 | $jobsToDelete->whereInList($failedJobsTable->aliasField('id'), $ids); 91 | } 92 | } 93 | 94 | if ($args->hasOption('class')) { 95 | $jobsToDelete->where(['class' => $args->getOption('class')]); 96 | } 97 | 98 | if ($args->hasOption('queue')) { 99 | $jobsToDelete->where(['queue' => $args->getOption('queue')]); 100 | } 101 | 102 | if ($args->hasOption('config')) { 103 | $jobsToDelete->where(['config' => $args->getOption('config')]); 104 | } 105 | 106 | $deletingCount = $jobsToDelete->count(); 107 | 108 | if (!$deletingCount) { 109 | $io->out('0 jobs found.'); 110 | 111 | return; 112 | } 113 | 114 | if (!$args->getOption('force')) { 115 | $confirmed = $io->askChoice("Delete {$deletingCount} jobs?", ['y', 'n'], 'n'); 116 | 117 | if ($confirmed !== 'y') { 118 | return; 119 | } 120 | } 121 | 122 | $io->out("Deleting {$deletingCount} jobs."); 123 | 124 | $failedJobsTable->deleteManyOrFail($jobsToDelete); 125 | 126 | $io->success("{$deletingCount} jobs deleted."); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/Command/RequeueCommand.php: -------------------------------------------------------------------------------- 1 | setDescription('Requeue failed jobs.'); 51 | 52 | $parser->addArgument('ids', [ 53 | 'required' => false, 54 | 'help' => 'Requeue jobs by the FailedJob ID (comma-separated).', 55 | ]); 56 | $parser->addOption('class', [ 57 | 'help' => 'Requeue jobs by the job class.', 58 | ]); 59 | $parser->addOption('queue', [ 60 | 'help' => 'Requeue jobs by the queue the job was received on.', 61 | ]); 62 | $parser->addOption('config', [ 63 | 'help' => 'Requeue jobs by the config used to queue the job.', 64 | ]); 65 | $parser->addOption('force', [ 66 | 'help' => 'Automatically assume yes in response to confirmation prompt.', 67 | 'short' => 'f', 68 | 'boolean' => true, 69 | ]); 70 | 71 | return $parser; 72 | } 73 | 74 | /** 75 | * @param \Cake\Console\Arguments $args Arguments 76 | * @param \Cake\Console\ConsoleIo $io ConsoleIo 77 | * @return void 78 | */ 79 | public function execute(Arguments $args, ConsoleIo $io) 80 | { 81 | /** @var \Cake\Queue\Model\Table\FailedJobsTable $failedJobsTable */ 82 | $failedJobsTable = $this->getTableLocator()->get('Cake/Queue.FailedJobs'); 83 | 84 | $jobsToRequeue = $failedJobsTable->find(); 85 | 86 | if ($args->hasArgument('ids')) { 87 | $idsArg = $args->getArgument('ids'); 88 | 89 | if ($idsArg !== null) { 90 | $ids = explode(',', $idsArg); 91 | 92 | $jobsToRequeue->whereInList('id', $ids); 93 | } 94 | } 95 | 96 | if ($args->hasOption('class')) { 97 | $jobsToRequeue->where(['class' => $args->getOption('class')]); 98 | } 99 | 100 | if ($args->hasOption('queue')) { 101 | $jobsToRequeue->where(['queue' => $args->getOption('queue')]); 102 | } 103 | 104 | if ($args->hasOption('config')) { 105 | $jobsToRequeue->where(['config' => $args->getOption('config')]); 106 | } 107 | 108 | $requeueingCount = $jobsToRequeue->count(); 109 | 110 | if (!$requeueingCount) { 111 | $io->out('0 jobs found.'); 112 | 113 | return; 114 | } 115 | 116 | if (!$args->getOption('force')) { 117 | $confirmed = $io->askChoice("Requeue {$requeueingCount} jobs?", ['y', 'n'], 'n'); 118 | 119 | if ($confirmed !== 'y') { 120 | return; 121 | } 122 | } 123 | 124 | $io->out("Requeueing {$requeueingCount} jobs."); 125 | 126 | $succeededCount = 0; 127 | $failedCount = 0; 128 | 129 | foreach ($jobsToRequeue as $failedJob) { 130 | $io->verbose("Requeueing FailedJob with ID {$failedJob->id}."); 131 | try { 132 | QueueManager::push( 133 | [$failedJob->class, $failedJob->method], 134 | $failedJob->decoded_data, 135 | [ 136 | 'config' => $failedJob->config, 137 | 'priority' => $failedJob->priority, 138 | 'queue' => $failedJob->queue, 139 | ] 140 | ); 141 | 142 | $failedJobsTable->deleteOrFail($failedJob); 143 | 144 | $succeededCount++; 145 | } catch (Exception $e) { 146 | $io->err("Exception occurred while requeueing FailedJob with ID {$failedJob->id}"); 147 | $io->err((string)$e); 148 | 149 | $failedCount++; 150 | } 151 | } 152 | 153 | if ($failedCount) { 154 | $io->err("Failed to requeue {$failedCount} jobs."); 155 | } 156 | 157 | if ($succeededCount) { 158 | $io->success("{$succeededCount} jobs requeued."); 159 | } 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/Command/WorkerCommand.php: -------------------------------------------------------------------------------- 1 | container = $container; 57 | } 58 | 59 | /** 60 | * Get the command name. 61 | * 62 | * @return string 63 | */ 64 | public static function defaultName(): string 65 | { 66 | return 'queue worker'; 67 | } 68 | 69 | /** 70 | * Gets the option parser instance and configures it. 71 | * 72 | * @return \Cake\Console\ConsoleOptionParser 73 | */ 74 | public function getOptionParser(): ConsoleOptionParser 75 | { 76 | $parser = parent::getOptionParser(); 77 | 78 | $parser->addOption('config', [ 79 | 'default' => 'default', 80 | 'help' => 'Name of a queue config to use', 81 | 'short' => 'c', 82 | ]); 83 | $parser->addOption('queue', [ 84 | 'help' => 'Name of queue to bind to. Defaults to the queue config (--config).', 85 | 'short' => 'Q', 86 | ]); 87 | $parser->addOption('processor', [ 88 | 'help' => 'Name of processor to bind to', 89 | 'default' => null, 90 | 'short' => 'p', 91 | ]); 92 | $parser->addOption('logger', [ 93 | 'help' => 'Name of a configured logger', 94 | 'default' => 'stdout', 95 | 'short' => 'l', 96 | ]); 97 | $parser->addOption('max-jobs', [ 98 | 'help' => 'Maximum number of jobs to process. Worker will exit after limit is reached.', 99 | 'default' => null, 100 | 'short' => 'i', 101 | ]); 102 | $parser->addOption('max-runtime', [ 103 | 'help' => 'Maximum number of seconds worker will run. Worker will exit after limit is reached.', 104 | 'default' => null, 105 | 'short' => 'r', 106 | ]); 107 | $parser->addOption('max-attempts', [ 108 | 'help' => 'Maximum number of times each job will be attempted.' 109 | . ' Maximum attempts defined on a job will override this value.', 110 | 'default' => null, 111 | 'short' => 'a', 112 | ]); 113 | $parser->setDescription( 114 | 'Runs a queue worker that consumes from the named queue.' 115 | ); 116 | 117 | return $parser; 118 | } 119 | 120 | /** 121 | * Creates and returns a QueueExtension object 122 | * 123 | * @param \Cake\Console\Arguments $args Arguments 124 | * @param \Psr\Log\LoggerInterface $logger Logger instance. 125 | * @return \Enqueue\Consumption\ExtensionInterface 126 | */ 127 | protected function getQueueExtension(Arguments $args, LoggerInterface $logger): ExtensionInterface 128 | { 129 | $limitAttempsExtension = new LimitAttemptsExtension((int)$args->getOption('max-attempts') ?: null); 130 | 131 | $limitAttempsExtension->getEventManager()->on(new FailedJobsListener()); 132 | 133 | $configKey = (string)$args->getOption('config'); 134 | $config = QueueManager::getConfig($configKey); 135 | 136 | $extensions = [ 137 | new LoggerExtension($logger), 138 | $limitAttempsExtension, 139 | ]; 140 | 141 | if (!is_null($args->getOption('max-jobs'))) { 142 | $maxJobs = (int)$args->getOption('max-jobs'); 143 | $extensions[] = new LimitConsumedMessagesExtension($maxJobs); 144 | } 145 | 146 | if (!is_null($args->getOption('max-runtime'))) { 147 | $endTime = new DateTime(sprintf('+%d seconds', (int)$args->getOption('max-runtime'))); 148 | $extensions[] = new LimitConsumptionTimeExtension($endTime); 149 | } 150 | 151 | if (isset($config['uniqueCacheKey'])) { 152 | $extensions[] = new RemoveUniqueJobIdFromCacheExtension($config['uniqueCacheKey']); 153 | } 154 | 155 | return new ChainExtension($extensions); 156 | } 157 | 158 | /** 159 | * Creates and returns a LoggerInterface object 160 | * 161 | * @param \Cake\Console\Arguments $args Arguments 162 | * @return \Psr\Log\LoggerInterface 163 | */ 164 | protected function getLogger(Arguments $args): LoggerInterface 165 | { 166 | $logger = null; 167 | if (!empty($args->getOption('verbose'))) { 168 | $logger = Log::engine((string)$args->getOption('logger')); 169 | } 170 | 171 | return $logger ?? new NullLogger(); 172 | } 173 | 174 | /** 175 | * @param \Cake\Console\Arguments $args Arguments 176 | * @param \Cake\Console\ConsoleIo $io ConsoleIo 177 | * @return int|void|null 178 | */ 179 | public function execute(Arguments $args, ConsoleIo $io) 180 | { 181 | $config = (string)$args->getOption('config'); 182 | if (!Configure::check(sprintf('Queue.%s', $config))) { 183 | $io->error(sprintf('Configuration key "%s" was not found', $config)); 184 | $this->abort(); 185 | } 186 | 187 | $logger = $this->getLogger($args); 188 | $processor = new Processor($logger, $this->container); 189 | $extension = $this->getQueueExtension($args, $logger); 190 | 191 | $hasListener = Configure::check(sprintf('Queue.%s.listener', $config)); 192 | if ($hasListener) { 193 | $listenerClassName = Configure::read(sprintf('Queue.%s.listener', $config)); 194 | if (!class_exists($listenerClassName)) { 195 | $io->error(sprintf('Listener class %s not found', $listenerClassName)); 196 | $this->abort(); 197 | } 198 | 199 | /** @var \Cake\Event\EventListenerInterface $listener */ 200 | $listener = new $listenerClassName(); 201 | $processor->getEventManager()->on($listener); 202 | } 203 | $client = QueueManager::engine($config); 204 | $queue = $args->getOption('queue') 205 | ? (string)$args->getOption('queue') 206 | : Configure::read("Queue.{$config}.queue", 'default'); 207 | $processorName = $args->getOption('processor') ? (string)$args->getOption('processor') : 'default'; 208 | 209 | $client->bindTopic($queue, $processor, $processorName); 210 | $client->consume($extension); 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /src/Consumption/LimitAttemptsExtension.php: -------------------------------------------------------------------------------- 1 | maxAttempts = $maxAttempts; 39 | } 40 | 41 | /** 42 | * @param \Enqueue\Consumption\Context\MessageResult $context The result of the message after it was processed. 43 | * @return void 44 | */ 45 | public function onResult(MessageResult $context): void 46 | { 47 | if ($context->getResult() != Processor::REQUEUE) { 48 | return; 49 | } 50 | 51 | $message = $context->getMessage(); 52 | 53 | $jobMessage = new Message($message, $context->getContext()); 54 | 55 | $maxAttempts = $jobMessage->getMaxAttempts() ?? $this->maxAttempts; 56 | 57 | if ($maxAttempts === null) { 58 | return; 59 | } 60 | 61 | $attemptNumber = $message->getProperty(self::ATTEMPTS_PROPERTY, 0) + 1; 62 | 63 | if ($attemptNumber >= $maxAttempts) { 64 | $context->changeResult( 65 | Result::reject(sprintf('The maximum number of %d allowed attempts was reached.', $maxAttempts)) 66 | ); 67 | 68 | $exception = (string)$message->getProperty('jobException'); 69 | 70 | $this->dispatchEvent( 71 | 'Consumption.LimitAttemptsExtension.failed', 72 | ['exception' => $exception, 'logger' => $context->getLogger()], 73 | $jobMessage 74 | ); 75 | 76 | return; 77 | } 78 | 79 | $newMessage = clone $message; 80 | $newMessage->setProperty(self::ATTEMPTS_PROPERTY, $attemptNumber); 81 | 82 | $queueContext = $context->getContext(); 83 | $producer = $queueContext->createProducer(); 84 | $consumer = $context->getConsumer(); 85 | $producer->send($consumer->getQueue(), $newMessage); 86 | 87 | $context->changeResult( 88 | Result::reject('A copy of the message was sent with an incremented attempt count.') 89 | ); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Consumption/LimitConsumedMessagesExtension.php: -------------------------------------------------------------------------------- 1 | messageLimit = $messageLimit; 37 | } 38 | 39 | /** 40 | * Executed at every new cycle before calling SubscriptionConsumer::consume method. 41 | * The consumption could be interrupted at this step. 42 | * 43 | * @param \Enqueue\Consumption\Context\PreConsume $context The PreConsume context. 44 | * @return void 45 | */ 46 | public function onPreConsume(PreConsume $context): void 47 | { 48 | // this is added here to handle an edge case. when a user sets zero as limit. 49 | if ($this->shouldBeStopped($context->getLogger())) { 50 | $context->interruptExecution(); 51 | } 52 | } 53 | 54 | /** 55 | * The method is called after SubscriptionConsumer::consume method exits. 56 | * The consumption could be interrupted at this point. 57 | * 58 | * @param \Enqueue\Consumption\Context\PostConsume $context The PostConsume context. 59 | * @return void 60 | */ 61 | public function onPostConsume(PostConsume $context): void 62 | { 63 | ++$this->messageConsumed; 64 | 65 | if ($this->shouldBeStopped($context->getLogger())) { 66 | $context->interruptExecution(); 67 | } 68 | } 69 | 70 | /** 71 | * Check if the consumer should be stopped. 72 | * 73 | * @param \Psr\Log\LoggerInterface $logger The logger where messages will be logged. 74 | * @return bool 75 | */ 76 | protected function shouldBeStopped(LoggerInterface $logger): bool 77 | { 78 | if ($this->messageConsumed >= $this->messageLimit) { 79 | $logger->debug(sprintf( 80 | '[LimitConsumedMessagesExtension] Message consumption is interrupted since the message limit ' . 81 | 'reached. limit: "%s"', 82 | $this->messageLimit 83 | )); 84 | 85 | return true; 86 | } 87 | 88 | return false; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Consumption/RemoveUniqueJobIdFromCacheExtension.php: -------------------------------------------------------------------------------- 1 | cache = $cache; 28 | } 29 | 30 | /** 31 | * @param \Enqueue\Consumption\Context\MessageResult $context The result of the message after it was processed. 32 | * @return void 33 | */ 34 | public function onResult(MessageResult $context): void 35 | { 36 | $message = $context->getMessage(); 37 | 38 | $jobMessage = new Message($message, $context->getContext()); 39 | 40 | [$class, $method] = $jobMessage->getTarget(); 41 | 42 | /** @psalm-suppress InvalidPropertyFetch */ 43 | if (empty($class::$shouldBeUnique)) { 44 | return; 45 | } 46 | 47 | $data = $jobMessage->getArgument(); 48 | 49 | $uniqueId = QueueManager::getUniqueId($class, $method, $data); 50 | 51 | Cache::delete($uniqueId, $this->cache); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Job/JobInterface.php: -------------------------------------------------------------------------------- 1 | getArgument('mailerName'); 37 | $mailerConfig = $message->getArgument('mailerConfig'); 38 | $action = $message->getArgument('action'); 39 | $args = $message->getArgument('args', []); 40 | $headers = $message->getArgument('headers', []); 41 | 42 | try { 43 | $mailer = $this->getMailer($mailerName, $mailerConfig); 44 | } catch (MissingMailerException $e) { 45 | return Processor::REJECT; 46 | } 47 | 48 | try { 49 | $mailer->send($action, $args, $headers); 50 | } catch (BadMethodCallException $e) { 51 | return Processor::REJECT; 52 | } 53 | 54 | return Processor::ACK; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Job/Message.php: -------------------------------------------------------------------------------- 1 | context = $context; 62 | $this->originalMessage = $originalMessage; 63 | $this->parsedBody = json_decode($originalMessage->getBody(), true); 64 | $this->container = $container; 65 | } 66 | 67 | /** 68 | * @return \Interop\Queue\Context 69 | */ 70 | public function getContext(): Context 71 | { 72 | return $this->context; 73 | } 74 | 75 | /** 76 | * @return \Interop\Queue\Message 77 | */ 78 | public function getOriginalMessage(): QueueMessage 79 | { 80 | return $this->originalMessage; 81 | } 82 | 83 | /** 84 | * @return array 85 | */ 86 | public function getParsedBody(): array 87 | { 88 | return $this->parsedBody; 89 | } 90 | 91 | /** 92 | * Get a closure containing the callable in the job. 93 | * 94 | * Supported callables include: 95 | * - array of [class, method]. The class will be constructed with no constructor parameters. 96 | * 97 | * @return \Closure 98 | */ 99 | public function getCallable() 100 | { 101 | if ($this->callable) { 102 | return $this->callable; 103 | } 104 | 105 | $target = $this->getTarget(); 106 | if ($this->container && $this->container->has($target[0])) { 107 | $object = $this->container->get($target[0]); 108 | } else { 109 | $object = new $target[0](); 110 | } 111 | 112 | $this->callable = Closure::fromCallable([$object, $target[1]]); 113 | 114 | return $this->callable; 115 | } 116 | 117 | /** 118 | * Get the target class and method. 119 | * 120 | * @return array{string, string} 121 | * @psalm-return array{class-string, string} 122 | */ 123 | public function getTarget(): array 124 | { 125 | /** @var array|null $target */ 126 | $target = $this->parsedBody['class'] ?? null; 127 | 128 | if (!is_array($target) || count($target) !== 2) { 129 | throw new RuntimeException(sprintf( 130 | 'Message class should be in the form `[class, method]` got `%s`', 131 | json_encode($target) 132 | )); 133 | } 134 | 135 | return $target; 136 | } 137 | 138 | /** 139 | * @param mixed $key Key 140 | * @param mixed $default Default value. 141 | * @return mixed 142 | */ 143 | public function getArgument($key = null, $default = null) 144 | { 145 | if (array_key_exists('data', $this->parsedBody)) { 146 | $data = $this->parsedBody['data']; 147 | } else { 148 | // support old jobs that still use args key 149 | $data = $this->parsedBody['args'][0]; 150 | } 151 | 152 | if ($key === null) { 153 | return $data; 154 | } 155 | 156 | return Hash::get($data, $key, $default); 157 | } 158 | 159 | /** 160 | * The maximum number of attempts allowed by the job. 161 | * 162 | * @return null|int 163 | */ 164 | public function getMaxAttempts(): ?int 165 | { 166 | $target = $this->getTarget(); 167 | 168 | $class = $target[0]; 169 | 170 | /** @psalm-suppress InvalidPropertyFetch */ 171 | return $class::$maxAttempts ?? null; 172 | } 173 | 174 | /** 175 | * @return string 176 | * @psalm-suppress InvalidToString 177 | */ 178 | public function __toString() 179 | { 180 | return (string)json_encode($this); 181 | } 182 | 183 | /** 184 | * @return array 185 | */ 186 | #[\ReturnTypeWillChange] 187 | public function jsonSerialize() 188 | { 189 | return $this->parsedBody; 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /src/Job/SendMailJob.php: -------------------------------------------------------------------------------- 1 | getArgument('transport'); 37 | $config = $message->getArgument('config', []); 38 | /** @var \Cake\Mailer\AbstractTransport $transport */ 39 | $transport = $this->getTransport($transportClassName, $config); 40 | 41 | $emailMessage = new \Cake\Mailer\Message(); 42 | $data = json_decode($message->getArgument('emailMessage'), true); 43 | if (!is_array($data)) { 44 | throw new \InvalidArgumentException('Email Message cannot be decoded.'); 45 | } 46 | $emailMessage->createFromArray($data); 47 | $result = $transport->send($emailMessage); 48 | } catch (\Exception $e) { 49 | Log::error(sprintf('An error has occurred processing message: %s', $e->getMessage())); 50 | } 51 | 52 | if (!$result) { 53 | return Processor::REJECT; 54 | } 55 | 56 | return Processor::ACK; 57 | } 58 | 59 | /** 60 | * Initialize transport 61 | * 62 | * @param string $transportClassName Transport class name 63 | * @param array $config Transport config 64 | * @return \Cake\Mailer\AbstractTransport 65 | * @throws \InvalidArgumentException if empty transport class name, class does not exist or send method is not defined for class 66 | */ 67 | protected function getTransport(string $transportClassName, array $config): AbstractTransport 68 | { 69 | if ($transportClassName === '') { 70 | throw new \InvalidArgumentException('Transport class name is empty.'); 71 | } 72 | 73 | if ( 74 | !class_exists($transportClassName) || 75 | !method_exists($transportClassName, 'send') 76 | ) { 77 | return TransportFactory::get($transportClassName); 78 | } 79 | 80 | $transport = new $transportClassName($config); 81 | 82 | if (!($transport instanceof AbstractTransport)) { 83 | throw new \InvalidArgumentException('Provided class does not extend AbstractTransport.'); 84 | } 85 | 86 | return $transport; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/Listener/FailedJobsListener.php: -------------------------------------------------------------------------------- 1 | 32 | */ 33 | public function implementedEvents(): array 34 | { 35 | return [ 36 | 'Consumption.LimitAttemptsExtension.failed' => 'storeFailedJob', 37 | ]; 38 | } 39 | 40 | /** 41 | * @param \Cake\Event\EventInterface $event EventInterface. 42 | * @return void 43 | */ 44 | public function storeFailedJob($event): void 45 | { 46 | /** @var \Cake\Queue\Job\Message $jobMessage */ 47 | $jobMessage = $event->getSubject(); 48 | 49 | [$class, $method] = $jobMessage->getTarget(); 50 | 51 | $originalMessage = $jobMessage->getOriginalMessage(); 52 | 53 | $originalMessageBody = json_decode($originalMessage->getBody(), true); 54 | 55 | ['data' => $data, 'requeueOptions' => $requeueOptions] = $originalMessageBody; 56 | 57 | $config = QueueManager::getConfig($requeueOptions['config']); 58 | 59 | if (!($config['storeFailedJobs'] ?? false)) { 60 | return; 61 | } 62 | 63 | /** @var \Cake\Queue\Model\Table\FailedJobsTable $failedJobsTable */ 64 | $failedJobsTable = $this->getTableLocator()->get('Cake/Queue.FailedJobs'); 65 | 66 | $failedJob = $failedJobsTable->newEntity([ 67 | 'class' => $class, 68 | 'method' => $method, 69 | 'data' => json_encode($data), 70 | 'config' => $requeueOptions['config'], 71 | 'priority' => $requeueOptions['priority'], 72 | 'queue' => $requeueOptions['queue'], 73 | 'exception' => $event->getData('exception'), 74 | ]); 75 | 76 | try { 77 | $failedJobsTable->saveOrFail($failedJob); 78 | /** @phpstan-ignore-next-line */ 79 | } catch (PersistenceFailedException $e) { 80 | $logger = $event->getData('logger'); 81 | 82 | if (!$logger) { 83 | throw new RuntimeException( 84 | sprintf('`logger` was not defined on %s event.', $event->getName()), 85 | 0, 86 | $e 87 | ); 88 | } 89 | 90 | if (!($logger instanceof LoggerInterface)) { 91 | throw new RuntimeException( 92 | sprintf('`logger` is not an instance of `LoggerInterface` on %s event.', $event->getName()), 93 | 0, 94 | $e 95 | ); 96 | } 97 | 98 | $logger->error((string)$e); 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/Mailer/QueueTrait.php: -------------------------------------------------------------------------------- 1 | static::class, 43 | 'action' => $action, 44 | ]); 45 | } 46 | 47 | QueueManager::push(MailerJob::class, [ 48 | 'mailerConfig' => $options['mailerConfig'] ?? null, 49 | 'mailerName' => static::class, 50 | 'action' => $action, 51 | 'args' => $args, 52 | 'headers' => $headers, 53 | ], $options); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Mailer/Transport/QueueTransport.php: -------------------------------------------------------------------------------- 1 | 30 | */ 31 | protected $_defaultConfig = [ 32 | 'options' => [], 33 | 'transport' => MailTransport::class, 34 | ]; 35 | 36 | /** 37 | * @inheritDoc 38 | */ 39 | public function send(Message $message): array 40 | { 41 | $data = $this->prepareData($message); 42 | $options = $this->getConfig('options'); 43 | $this->enqueueJob($data, $options); 44 | 45 | $headers = $message->getHeadersString( 46 | [ 47 | 'from', 48 | 'to', 49 | 'subject', 50 | 'sender', 51 | 'replyTo', 52 | 'readReceipt', 53 | 'returnPath', 54 | 'cc', 55 | 'bcc', 56 | ] 57 | ); 58 | 59 | return ['headers' => $headers, 'message' => 'Message has been enqueued']; 60 | } 61 | 62 | /** 63 | * Add job to queue 64 | * 65 | * @param array $data Data to be sent to job 66 | * @param array $options Job options 67 | * @return void 68 | */ 69 | protected function enqueueJob(array $data, array $options): void 70 | { 71 | QueueManager::push( 72 | [SendMailJob::class, 'execute'], 73 | $data, 74 | $options 75 | ); 76 | } 77 | 78 | /** 79 | * Prepare data for job 80 | * 81 | * @param \Cake\Mailer\Message $message Email message 82 | * @return array 83 | */ 84 | protected function prepareData(Message $message): array 85 | { 86 | return [ 87 | 'transport' => $this->getConfig('transport'), 88 | 'config' => is_array($this->getConfig('config')) ? $this->getConfig('config') : $this->getConfig(), 89 | 'emailMessage' => json_encode($message), 90 | ]; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Model/Entity/FailedJob.php: -------------------------------------------------------------------------------- 1 | true` 30 | * means that any field not defined in the map will be accessible by default 31 | * 32 | * @var array 33 | */ 34 | protected $_accessible = [ 35 | 'class' => true, 36 | 'method' => true, 37 | 'data' => true, 38 | 'config' => true, 39 | 'priority' => true, 40 | 'queue' => true, 41 | 'exception' => true, 42 | 'created' => true, 43 | ]; 44 | 45 | /** 46 | * @return array 47 | */ 48 | protected function _getDecodedData(): array 49 | { 50 | return json_decode($this->data, true); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Model/Table/FailedJobsTable.php: -------------------------------------------------------------------------------- 1 | newEntities(array $data, array $options = []) 15 | * @method \Cake\Queue\Model\Entity\FailedJob get($primaryKey, $options = []) 16 | * @method \Cake\Queue\Model\Entity\FailedJob findOrCreate($search, ?callable $callback = null, $options = []) 17 | * @method \Cake\Queue\Model\Entity\FailedJob patchEntity(\Cake\Datasource\EntityInterface $entity, array $data, array $options = []) 18 | * @method array<\Cake\Queue\Model\Entity\FailedJob> patchEntities(iterable $entities, array $data, array $options = []) 19 | * @method \Cake\Queue\Model\Entity\FailedJob|false save(\Cake\Datasource\EntityInterface $entity, $options = []) 20 | * @method \Cake\Queue\Model\Entity\FailedJob saveOrFail(\Cake\Datasource\EntityInterface $entity, $options = []) 21 | * @method iterable<\Cake\Queue\Model\Entity\FailedJob>|false saveMany(iterable $entities, $options = []) 22 | * @method iterable<\Cake\Queue\Model\Entity\FailedJob> saveManyOrFail(iterable $entities, $options = []) 23 | * @method iterable<\Cake\Queue\Model\Entity\FailedJob>|false deleteMany(iterable $entities, $options = []) 24 | * @method iterable<\Cake\Queue\Model\Entity\FailedJob> deleteManyOrFail(iterable $entities, $options = []) 25 | * @mixin \Cake\ORM\Behavior\TimestampBehavior 26 | */ 27 | class FailedJobsTable extends Table 28 | { 29 | /** 30 | * Initialize method 31 | * 32 | * @param array $config The configuration for the Table. 33 | * @return void 34 | */ 35 | public function initialize(array $config): void 36 | { 37 | parent::initialize($config); 38 | 39 | $this->setTable('queue_failed_jobs'); 40 | $this->setDisplayField('id'); 41 | $this->setPrimaryKey('id'); 42 | 43 | $this->addBehavior('Timestamp'); 44 | } 45 | 46 | /** 47 | * Default validation rules. 48 | * 49 | * @param \Cake\Validation\Validator $validator Validator instance. 50 | * @return \Cake\Validation\Validator 51 | */ 52 | public function validationDefault(Validator $validator): Validator 53 | { 54 | $validator 55 | ->integer('id') 56 | ->allowEmptyString('id', null, 'create'); 57 | 58 | $validator 59 | ->scalar('class') 60 | ->maxLength('class', 255) 61 | ->requirePresence('class', 'create') 62 | ->notEmptyString('class'); 63 | 64 | $validator 65 | ->scalar('method') 66 | ->maxLength('method', 255) 67 | ->requirePresence('method', 'create') 68 | ->notEmptyString('method'); 69 | 70 | $validator 71 | ->scalar('data') 72 | ->requirePresence('data', 'create') 73 | ->notEmptyString('data'); 74 | 75 | $validator 76 | ->scalar('config') 77 | ->maxLength('config', 255) 78 | ->notEmptyString('config'); 79 | 80 | $validator 81 | ->scalar('priority') 82 | ->maxLength('priority', 255) 83 | ->allowEmptyString('priority'); 84 | 85 | $validator 86 | ->scalar('queue') 87 | ->maxLength('queue', 255) 88 | ->notEmptyString('queue'); 89 | 90 | $validator 91 | ->scalar('exception') 92 | ->allowEmptyString('exception'); 93 | 94 | return $validator; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Plugin.php: -------------------------------------------------------------------------------- 1 | $data) { 64 | if (QueueManager::getConfig($key) === null) { 65 | QueueManager::setConfig($key, $data); 66 | } 67 | } 68 | } 69 | 70 | /** 71 | * Add console commands for the plugin. 72 | * 73 | * @param \Cake\Console\CommandCollection $commands The command collection to update 74 | * @return \Cake\Console\CommandCollection 75 | */ 76 | public function console(CommandCollection $commands): CommandCollection 77 | { 78 | if (class_exists('Bake\Command\SimpleBakeCommand')) { 79 | $commands->add('bake job', JobCommand::class); 80 | } 81 | 82 | return $commands 83 | ->add('queue worker', WorkerCommand::class) 84 | ->add('worker', WorkerCommand::class) 85 | ->add('queue requeue', RequeueCommand::class) 86 | ->add('queue purge_failed', PurgeFailedCommand::class); 87 | } 88 | 89 | /** 90 | * Add DI container to Worker command 91 | * 92 | * @param \Cake\Core\ContainerInterface $container The DI container 93 | * @return void 94 | */ 95 | public function services(ContainerInterface $container): void 96 | { 97 | $container->add(ContainerInterface::class, $container); 98 | $container 99 | ->add(WorkerCommand::class) 100 | ->addArgument(ContainerInterface::class); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Queue/Processor.php: -------------------------------------------------------------------------------- 1 | logger = $logger ?: new NullLogger(); 55 | $this->container = $container; 56 | } 57 | 58 | /** 59 | * The method processes messages 60 | * 61 | * @param \Interop\Queue\Message $message Message. 62 | * @param \Interop\Queue\Context $context Context. 63 | * @return string|object with __toString method implemented 64 | */ 65 | public function process(QueueMessage $message, Context $context) 66 | { 67 | $this->dispatchEvent('Processor.message.seen', ['queueMessage' => $message]); 68 | 69 | $jobMessage = new Message($message, $context, $this->container); 70 | try { 71 | $jobMessage->getCallable(); 72 | } catch (RuntimeException | Error $e) { 73 | $this->logger->debug('Invalid callable for message. Rejecting message from queue.'); 74 | $this->dispatchEvent('Processor.message.invalid', ['message' => $jobMessage]); 75 | 76 | return InteropProcessor::REJECT; 77 | } 78 | 79 | $this->dispatchEvent('Processor.message.start', ['message' => $jobMessage]); 80 | 81 | try { 82 | $response = $this->processMessage($jobMessage); 83 | } catch (Throwable $e) { 84 | $message->setProperty('jobException', $e); 85 | 86 | $this->logger->debug(sprintf('Message encountered exception: %s', $e->getMessage())); 87 | $this->dispatchEvent('Processor.message.exception', [ 88 | 'message' => $jobMessage, 89 | 'exception' => $e, 90 | ]); 91 | 92 | return Result::requeue('Exception occurred while processing message'); 93 | } 94 | 95 | if ($response === InteropProcessor::ACK) { 96 | $this->logger->debug('Message processed successfully'); 97 | $this->dispatchEvent('Processor.message.success', ['message' => $jobMessage]); 98 | 99 | return InteropProcessor::ACK; 100 | } 101 | 102 | if ($response === InteropProcessor::REJECT) { 103 | $this->logger->debug('Message processed with rejection'); 104 | $this->dispatchEvent('Processor.message.reject', ['message' => $jobMessage]); 105 | 106 | return InteropProcessor::REJECT; 107 | } 108 | 109 | $this->logger->debug('Message processed with failure, requeuing'); 110 | $this->dispatchEvent('Processor.message.failure', ['message' => $jobMessage]); 111 | 112 | return InteropProcessor::REQUEUE; 113 | } 114 | 115 | /** 116 | * @param \Cake\Queue\Job\Message $message Message. 117 | * @return string|object with __toString method implemented 118 | */ 119 | public function processMessage(Message $message) 120 | { 121 | $callable = $message->getCallable(); 122 | $response = $callable($message); 123 | if ($response === null) { 124 | $response = InteropProcessor::ACK; 125 | } 126 | 127 | return $response; 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/QueueManager.php: -------------------------------------------------------------------------------- 1 | configuration data for adapter. 77 | * @throws \BadMethodCallException When trying to modify an existing config. 78 | * @throws \LogicException When trying to store an invalid structured config array. 79 | * @return void 80 | */ 81 | public static function setConfig($key, $config = null): void 82 | { 83 | if ($config === null) { 84 | if (!is_array($key)) { 85 | throw new LogicException('If config is null, key must be an array.'); 86 | } 87 | foreach ($key as $name => $settings) { 88 | static::setConfig($name, $settings); 89 | } 90 | 91 | return; 92 | } elseif (is_array($key)) { 93 | throw new LogicException('If config is not null, key must be a string.'); 94 | } 95 | 96 | if (isset(static::$_config[$key])) { 97 | /** @psalm-suppress PossiblyInvalidArgument */ 98 | throw new BadMethodCallException(sprintf('Cannot reconfigure existing key `%s`', $key)); 99 | } 100 | 101 | if (empty($config['url'])) { 102 | throw new BadMethodCallException('Must specify `url` key.'); 103 | } 104 | 105 | if (!empty($config['queue'])) { 106 | if (!is_array($config['url'])) { 107 | $config['url'] = [ 108 | 'transport' => $config['url'], 109 | 'client' => [ 110 | 'router_topic' => $config['queue'], 111 | 'router_queue' => $config['queue'], 112 | 'default_queue' => $config['queue'], 113 | ], 114 | ]; 115 | } else { 116 | $clientConfig = $config['url']['client'] ?? []; 117 | $config['url']['client'] = $clientConfig + [ 118 | 'router_topic' => $config['queue'], 119 | 'router_queue' => $config['queue'], 120 | 'default_queue' => $config['queue'], 121 | ]; 122 | } 123 | } 124 | 125 | if (!empty($config['uniqueCache'])) { 126 | $cacheDefaults = [ 127 | 'duration' => '+24 hours', 128 | ]; 129 | 130 | $cacheConfig = array_merge($cacheDefaults, $config['uniqueCache']); 131 | 132 | $config['uniqueCacheKey'] = "Cake/Queue.queueUnique.{$key}"; 133 | 134 | Cache::setConfig($config['uniqueCacheKey'], $cacheConfig); 135 | } 136 | 137 | /** @psalm-suppress InvalidPropertyAssignmentValue */ 138 | static::$_config[$key] = $config; 139 | } 140 | 141 | /** 142 | * Reads existing configuration. 143 | * 144 | * @param string $key The name of the configuration. 145 | * @return mixed Configuration data at the named key or null if the key does not exist. 146 | */ 147 | public static function getConfig(string $key) 148 | { 149 | return static::$_config[$key] ?? null; 150 | } 151 | 152 | /** 153 | * Remove a configured queue adapter. 154 | * 155 | * @param string $key The config name to drop. 156 | * @return void 157 | */ 158 | public static function drop(string $key): void 159 | { 160 | unset(static::$_clients[$key], static::$_config[$key]); 161 | } 162 | 163 | /** 164 | * Get a queueing engine 165 | * 166 | * @param string $name Key name of a configured adapter to get. 167 | * @return \Enqueue\SimpleClient\SimpleClient 168 | */ 169 | public static function engine(string $name): SimpleClient 170 | { 171 | if (isset(static::$_clients[$name])) { 172 | return static::$_clients[$name]; 173 | } 174 | 175 | $config = static::getConfig($name) + [ 176 | 'logger' => null, 177 | 'receiveTimeout' => null, 178 | ]; 179 | 180 | $logger = $config['logger'] ? Log::engine($config['logger']) : null; 181 | 182 | static::$_clients[$name] = new SimpleClient($config['url'], $logger); 183 | static::$_clients[$name]->setupBroker(); 184 | 185 | if (!is_null($config['receiveTimeout'])) { 186 | static::$_clients[$name]->getQueueConsumer()->setReceiveTimeout($config['receiveTimeout']); 187 | } 188 | 189 | return static::$_clients[$name]; 190 | } 191 | 192 | /** 193 | * Push a single job onto the queue. 194 | * 195 | * @param string|string[] $className The classname of a job that implements the 196 | * \Cake\Queue\Job\JobInterface. The class will be constructed by 197 | * \Cake\Queue\Processor and have the execute method invoked. 198 | * @param array $data An array of data that will be passed to the job. 199 | * @param array $options An array of options for publishing the job: 200 | * - `config` - A queue config name. Defaults to 'default'. 201 | * - `delay` - Time (in integer seconds) to delay message, after which it 202 | * will be processed. Not all message brokers accept this. Default `null`. 203 | * - `expires` - Time (in integer seconds) after which the message expires. 204 | * The message will be removed from the queue if this time is exceeded 205 | * and it has not been consumed. Default `null`. 206 | * - `priority` - Valid values: 207 | * - `\Enqueue\Client\MessagePriority::VERY_LOW` 208 | * - `\Enqueue\Client\MessagePriority::LOW` 209 | * - `\Enqueue\Client\MessagePriority::NORMAL` 210 | * - `\Enqueue\Client\MessagePriority::HIGH` 211 | * - `\Enqueue\Client\MessagePriority::VERY_HIGH` 212 | * - `queue` - The name of a queue to use, from queue `config` array or 213 | * string 'default' if empty. 214 | * @return void 215 | */ 216 | public static function push($className, array $data = [], array $options = []): void 217 | { 218 | [$class, $method] = is_array($className) ? $className : [$className, 'execute']; 219 | 220 | $class = App::className($class, 'Job', 'Job'); 221 | if (is_null($class)) { 222 | throw new InvalidArgumentException("`$class` class does not exist."); 223 | } 224 | 225 | $name = $options['config'] ?? 'default'; 226 | 227 | $config = static::getConfig($name) + [ 228 | 'logger' => null, 229 | ]; 230 | 231 | $logger = $config['logger'] ? Log::engine($config['logger']) : null; 232 | 233 | /** @psalm-suppress InvalidPropertyFetch */ 234 | if (!empty($class::$shouldBeUnique)) { 235 | if (empty($config['uniqueCache'])) { 236 | throw new InvalidArgumentException( 237 | "$class::\$shouldBeUnique is set to `true` but `uniqueCache` configuration is missing." 238 | ); 239 | } 240 | 241 | $uniqueId = static::getUniqueId($class, $method, $data); 242 | 243 | if (Cache::read($uniqueId, $config['uniqueCacheKey'])) { 244 | if ($logger) { 245 | $logger->debug( 246 | "An identical instance of $class already exists on the queue. This push will be ignored." 247 | ); 248 | } 249 | 250 | return; 251 | } 252 | } 253 | 254 | $queue = $options['queue'] ?? $config['queue'] ?? 'default'; 255 | 256 | $message = new ClientMessage([ 257 | 'class' => [$class, $method], 258 | 'args' => [$data], 259 | 'data' => $data, 260 | 'requeueOptions' => [ 261 | 'config' => $name, 262 | 'priority' => $options['priority'] ?? null, 263 | 'queue' => $queue, 264 | ], 265 | ]); 266 | 267 | if (isset($options['delay'])) { 268 | $message->setDelay($options['delay']); 269 | } 270 | 271 | if (isset($options['expires'])) { 272 | $message->setExpire($options['expires']); 273 | } 274 | 275 | if (isset($options['priority'])) { 276 | $message->setPriority($options['priority']); 277 | } 278 | 279 | $client = static::engine($name); 280 | $client->sendEvent($queue, $message); 281 | 282 | /** @psalm-suppress InvalidPropertyFetch */ 283 | if (!empty($class::$shouldBeUnique)) { 284 | $uniqueId = static::getUniqueId($class, $method, $data); 285 | 286 | Cache::add($uniqueId, true, $config['uniqueCacheKey']); 287 | } 288 | } 289 | 290 | /** 291 | * @param string $class Class name 292 | * @param string $method Method name 293 | * @param array $data Message data 294 | * @return string 295 | */ 296 | public static function getUniqueId(string $class, string $method, array $data): string 297 | { 298 | sort($data); 299 | 300 | $hashInput = implode([ 301 | $class, 302 | $method, 303 | json_encode($data), 304 | ]); 305 | 306 | return hash('md5', $hashInput); 307 | } 308 | } 309 | -------------------------------------------------------------------------------- /templates/bake/job.twig: -------------------------------------------------------------------------------- 1 | {# 2 | /** 3 | * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) 4 | * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org/) 5 | * 6 | * Licensed under The MIT License 7 | * For full copyright and license information, please see the LICENSE.txt 8 | * Redistributions of files must retain the above copyright notice. 9 | * 10 | * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org/) 11 | * @link https://cakephp.org CakePHP(tm) Project 12 | * @since 0.1.0 13 | * @license https://opensource.org/licenses/MIT MIT License 14 | */ 15 | #} 16 |