diff --git a/README.md b/README.md index 914ba2d0..7ea76ee3 100644 --- a/README.md +++ b/README.md @@ -8,40 +8,10 @@ [![Total Downloads](https://poser.pugx.org/dereuromark/cakephp-queue/d/total)](https://packagist.org/packages/dereuromark/cakephp-queue) [![Coding Standards](https://img.shields.io/badge/cs-PSR--2--R-purple.svg?style=flat-square)](https://github.com/php-fig-rectified/fig-rectified-standards) -This branch is for use with **CakePHP 5.1+**. For details see [version map](https://github.com/dereuromark/cakephp-queue/wiki#cakephp-version-map). +Queue stores background jobs in your application's database and processes them with CLI workers. Tasks support retries, delayed execution, progress, and optional admin tools. -## Background - -This is a very simple and minimalistic job queue (or deferred-task) system for CakePHP. -If you need a very basic PHP internal queue tool, this is definitely an option. -It is also a great tool for demo purposes on how queues work and doesn't have any dependencies. - -Overall functionality is inspired by systems like Gearman, Beanstalk or dropr, but without -any illusion to compete with these more advanced Systems. - -The plugin is an attempt to provide a basic, simple to use method to enable deferred job execution, -without the hassle of setting up or running an extra queue daemon, while integrating nicely into -CakePHP and also simplifying the creation of worker scripts. You can also easily provide progress and status information into your pages. - -Please also read my blog posts about [deferred execution](https://www.dereuromark.de/2013/12/22/queue-deferred-execution-in-cakephp/) and [real-life example usage](https://www.dereuromark.de/2021/07/15/cakephp-queuing-real-life-examples/) [new]. -For more high-volume and sophisticated use cases please see the [awesome list](https://github.com/FriendsOfCake/awesome-cakephp#queue) alternatives. - -### Why use deferred execution? - -Deferred execution makes sense (especially in PHP) when your page wants to execute tasks, which are not directly related to rendering the current page. -For instance, in a BBS-type system, a new users post might require the creation of multiple personalized email messages, -notifying other users of the new content. -Creating and sending these emails is completely irrelevant to the currently active user, and should not increase page response time. -Another example would be downloading, extraction and/or analyzing an external file per request of the user. -The regular solution to these problems would be to create specialized cronjobs which use specific database states to determine which action should be done. - -The Queue plugin provides a simple method to create and run such non-user-interaction-critical tasks. - -Another important reason is that specific jobs can be (auto)retried if they failed. -So if the email server didn't work the first time, or the API gateway had an issue, the current job to be executed isn't lost but kept for rerun. Most of those external services should be treated as failable once every x calls, and as such a queue implementation can help reducing issues due to such failures. If a job still can't finish despite retries, you still have the option to debug its payload and why this job cannot complete. No data is lost here. - -While you can run multiple workers, and can (to some extent) spread these workers to different machines via a shared database, you should consider using a more advanced system for high volume/high number of workers systems. +See the [documentation](https://dereuromark.github.io/cakephp-queue/) for [installation](https://dereuromark.github.io/cakephp-queue/guide/installation), [getting started](https://dereuromark.github.io/cakephp-queue/guide/getting-started), and the full reference. ## Demo See [Sandbox app](https://sandbox.dereuromark.de/sandbox/queue-examples). @@ -50,4 +20,4 @@ See [Sandbox app](https://sandbox.dereuromark.de/sandbox/queue-examples). Full documentation: https://dereuromark.github.io/cakephp-queue/ ## Cronjob based background scheduling -If you are looking for scheduling certain background jobs: This plugin works flawlessly with [QueueScheduler plugin](https://github.com/dereuromark/cakephp-queue-scheduler). +If you are looking for scheduling certain background jobs: Use [QueueScheduler plugin](https://github.com/dereuromark/cakephp-queue-scheduler). diff --git a/config/app.example.php b/config/app.example.php index ad859748..c84c8d45 100644 --- a/config/app.example.php +++ b/config/app.example.php @@ -74,13 +74,13 @@ // set default datasource connection 'connection' => null, - // Multi-connection mode: whitelist of datasource connection names a - // worker / job command may run against. Leave empty (or with fewer than - // 2 entries) to stay in single-connection mode, where the singular - // `connection` above (or 'default') is used. With 2+ entries, the first - // is the default and any --connection passed to the run/job commands - // must appear in this list or a RuntimeException is thrown. The admin - // dashboard also exposes a connection switcher when this has 2+ entries. + // Multi-connection mode: allowed datasource names for add, run, job, + // info and worker commands. With 2+ entries, the first is the default; + // an explicit --connection must appear in this list. + // With fewer than 2 entries, commands preserve the QueuedJobs table's + // assigned connection, whose default comes from `connection` above + // or 'default'. An explicit --connection overrides that choice. + // The admin dashboard exposes a switcher with 2+ entries. 'connections' => [ //'default', //'queue_secondary', diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 19350260..5d1c329c 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -6,7 +6,8 @@ function guideSidebar() { text: 'Guide', items: [ { text: 'Overview', link: '/guide/' }, - { text: 'Basic Setup', link: '/guide/basic-setup' }, + { text: 'Installation', link: '/guide/installation' }, + { text: 'Getting started', link: '/guide/getting-started' }, { text: 'Queueing Jobs', link: '/guide/queueing-jobs' }, { text: 'Custom Tasks', link: '/guide/custom-tasks' }, ], @@ -52,6 +53,8 @@ export default defineConfig({ themeConfig: { logo: '/logo.svg', nav: [ + { text: 'Installation', link: '/guide/installation' }, + { text: 'Getting started', link: '/guide/getting-started' }, { text: 'Guide', link: '/guide/', activeMatch: '/(guide|admin)/' }, { text: 'Tasks', link: '/tasks/', activeMatch: '/tasks/' }, { text: 'Reference', link: '/reference/', activeMatch: '/reference/' }, diff --git a/docs/.vitepress/theme/custom.css b/docs/.vitepress/theme/custom.css index f9f13748..14b56395 100644 --- a/docs/.vitepress/theme/custom.css +++ b/docs/.vitepress/theme/custom.css @@ -1,7 +1,7 @@ :root { --vp-c-brand-1: #0f766e; --vp-c-brand-2: #14b8a6; - --vp-c-brand-3: #5eead4; + --vp-c-brand-3: #0f766e; --vp-c-brand-soft: rgba(20, 184, 166, 0.14); --vp-home-hero-name-color: transparent; @@ -13,7 +13,7 @@ .dark { --vp-c-brand-1: #5eead4; --vp-c-brand-2: #2dd4bf; - --vp-c-brand-3: #14b8a6; + --vp-c-brand-3: #0f766e; } .vp-doc table code, diff --git a/docs/admin/index.md b/docs/admin/index.md index a53842bf..813c4071 100644 --- a/docs/admin/index.md +++ b/docs/admin/index.md @@ -2,7 +2,7 @@ The Queue plugin includes a modern, self-contained admin dashboard for managing your queued jobs. The dashboard is completely isolated from your application's CSS/JS, using Bootstrap 5 and Font Awesome 6 via CDN. -## Features +## Available actions - **Statistics Overview**: Real-time counts of pending, scheduled, running, and failed jobs - **Status Banner**: Visual indicator showing whether the queue is running or idle @@ -55,22 +55,15 @@ This is useful when: ### Authorization (`Queue.adminAccess`, required) -The admin UI can trigger jobs (via `AddFromBackendInterface` tasks), -reset/remove queued jobs, and terminate workers — operational damage if -exposed. The plugin therefore **fails closed by default**: every request -to `/admin/queue/...` is rejected with `403` until the host app -explicitly configures access. +Set `Queue.adminAccess` to a `Closure` that receives the current request and returns `true` to grant access. Requests reaching this gate without a configured closure are denied with 403. The host controller's redirects take precedence in the default inherited mode. -Set `Queue.adminAccess` to a `Closure` that receives the current request -and returns literal `true` to grant access. Anything else (unset, -non-`Closure`, returns `false`, returns a truthy non-bool, throws) -yields a `403`. +The gate protects job creation, resetting and deleting jobs, and worker management. It runs in both inherited and standalone modes. ```php use Cake\Core\Configure; use Cake\Http\ServerRequest; -// Example — admin role check (cakephp/authentication identity): +// Example: admin role check (cakephp/authentication identity): Configure::write('Queue.adminAccess', function (ServerRequest $request): bool { $identity = $request->getAttribute('identity'); return $identity !== null && in_array('admin', (array)$identity->roles, true); @@ -78,13 +71,12 @@ Configure::write('Queue.adminAccess', function (ServerRequest $request): bool { ``` The gate runs in `beforeFilter` for every admin controller in the plugin -and plays nicely with the cakephp/authorization plugin (it calls -`skipAuthorization()` so the policy layer doesn't double-reject). +and calls `skipAuthorization()` when an authorization service is present. `ForbiddenException` raised inside the Closure is respected as-is so callers can short-circuit with their own message; other throwables are logged via `Cake\Log\Log` and converted to a generic `403`. -`Queue.adminAccess` is independent of `Queue.standalone` — the access +`Queue.adminAccess` is independent of `Queue.standalone`; the access gate runs in both modes. ### Using Your Application's Layout diff --git a/docs/guide/basic-setup.md b/docs/guide/basic-setup.md index a8056857..c8ab285e 100644 --- a/docs/guide/basic-setup.md +++ b/docs/guide/basic-setup.md @@ -1,27 +1,3 @@ -# Basic Setup +# Basic setup -## Installation -``` -composer require dereuromark/cakephp-queue -``` -Load the plugin -``` -bin/cake plugin load Queue -``` - -If you don't want to also access the backend controller (just using CLI), you need to use -``` -bin/cake plugin load Queue --no-routes -``` - -Important: Make sure to use authentication if you are using the backend. You do not want visitors to be able to browse it. - -## Database migration - -Run the following command in the CakePHP console to create the tables using the Migrations plugin: -```sh -bin/cake migrations migrate -p Queue -``` - -Hint: use a native *nix-like or console and not the one provided like from Git (git-bash). This may lead to a non-working `migrations` command. -It is also advised to have the `posix` PHP extension enabled. +Setup is covered in [Installation](./installation). To queue and run your first job, follow [Getting started](./getting-started). diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index b9ac72a1..e2c819e4 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1,202 +1,75 @@ # Configuration -## Global configuration -The plugin allows some simple runtime configuration. -You may create a file called `app_queue.php` inside your `config` folder (NOT the plugins config folder) to set the following values: - -- Seconds to sleep() when no executable job is found: - - ```php - $config['Queue']['sleeptime'] = 10; - ``` - -- Probability in percent of an old job cleanup happening: - - ```php - $config['Queue']['gcprob'] = 10; - ``` - -- Default timeout after which a job is requeued if the worker doesn't report back: - - ```php - $config['Queue']['defaultRequeueTimeout'] = 180; // 3 minutes - // Legacy: 'defaultworkertimeout' is deprecated but still supported - ``` - - **Note:** Individual task `timeout` values are automatically capped to this `defaultRequeueTimeout` value to prevent duplicate execution. If a task needs a longer timeout, increase `defaultRequeueTimeout` accordingly (recommended: at least 2x your longest task timeout). - -- Default number of retries if a job fails or times out: - - ```php - $config['Queue']['defaultJobRetries'] = 3; - // Legacy: 'defaultworkerretries' is deprecated but still supported - ``` - -- Seconds of running time after which the worker process will terminate: - - ```php - $config['Queue']['workerLifetime'] = 60; // 1 minute (same as respawn time) - // Legacy: 'workermaxruntime' is deprecated but still supported - ``` - - **Important:** While 0 (unlimited) is technically allowed, it is **strongly discouraged**. Using 0 means workers will run indefinitely, which can lead to workers piling up if spawned faster than they can naturally terminate (e.g., when there are no jobs), potentially overloading your server. - - If you need workers to run for extended periods, use a very large value (e.g., 86400 for 24 hours). However, it's recommended to use shorter durations (e.g., 60-300 seconds) with cronjob respawning for better control and safety. - - **Note:** You can override this config value using the `--max-runtime` CLI option: - ```bash - bin/cake queue run --max-runtime 0 # Run indefinitely (only for local dev/debug) - bin/cake queue run --max-runtime 300 # Run for 5 minutes - ``` - -- Optional per-worker jitter (in seconds) added to the worker lifetime: - - ```php - $config['Queue']['workerLifetimeJitter'] = 30; // up to +30s random offset per worker - ``` - - Each worker picks a random offset in `[0, workerLifetimeJitter]` at startup and adds it to its effective lifetime. Useful when many workers are spawned simultaneously (e.g. ECS/Kubernetes) to stagger shutdowns and avoid a thundering herd of concurrent restarts. Defaults to `0` (no jitter). If `workerLifetime` or the `--max-runtime` override is `0` (unlimited), this setting has no effect. - -- Seconds of running time after which the PHP process of the worker will terminate: - - ```php - $config['Queue']['workerPhpTimeout'] = 120; // 2 minutes - // Legacy: 'workertimeout' is deprecated but still supported - ``` - - **Important:** While technically 0 (unlimited) is allowed for this setting, it is **strongly discouraged** if you are using a cronjob to permanently start new workers and if you do not exit on idle. This timeout serves as the last line of defense to prevent an excessive number of worker processes from accumulating, which could overload your server. - - Set this value high enough to never cut off running jobs, but low enough to keep the process count manageable. A good practice is to set it to at least 2x your `workerLifetime` value. - -- Should a worker process quit when there are no more tasks for it to execute (true = exit, false = keep running): - - ```php - $config['Queue']['exitwhennothingtodo'] = false; - ``` - -- Minimum number of seconds before a cleanup run will remove a completed task (set to 0 to disable): - - ```php - $config['Queue']['cleanuptimeout'] = 2592000; // 30 days - ``` - -- Seconds after their last attempt before a cleanup run removes aborted jobs (retries exhausted). Defaults to 0, which keeps them until they are reset or flushed: - - ```php - $config['Queue']['cleanupAbortedTimeout'] = 604800; // 7 days - ``` - -- Max workers (per server): - - ```php - $config['Queue']['maxworkers'] = 3; // Defaults to 1 (single worker can be run per server) - ``` - -- Multi-server setup: - - ```php - $config['Queue']['multiserver'] = true; // Defaults to false (single server) - ``` - - For multiple servers running either CLI/web separately, or even multiple CLI workers on top, make sure to enable this. - -- Use a different connection: - - ```php - $config['Queue']['connection'] = 'custom'; // Defaults to 'default' - ``` - -- Ignore certain task classes to they don't end up in the generated SQL query. Can be used to filter out the example tasks classes shipped with the plugin, if you're not using them: - - ```php - $config['Queue']['ignoredTasks'] = [ - 'Queue\Queue\Task\CostsExampleTask', - 'Queue\Queue\Task\EmailTask', - 'Queue\Queue\Task\ExampleTask', - 'Queue\Queue\Task\ExceptionExampleTask', - 'Queue\Queue\Task\ExecuteTask', - 'Queue\Queue\Task\MonitorExampleTask', - 'Queue\Queue\Task\ProgressExampleTask', - 'Queue\Queue\Task\RetryExampleTask', - 'Queue\Queue\Task\SuperExampleTask', - 'Queue\Queue\Task\UniqueExampleTask', - ]; // Defaults to [] - ``` - -Don't forget to load that config file with `Configure::load('app_queue');` in your bootstrap. - -Example `app_queue.php`: +Set options under the `Queue` key in `config/app.php` or `app_local.php`. For a separate file, create `config/app_queue.php` returning an array, then load it in bootstrap with `Configure::load('app_queue')`. ```php return [ 'Queue' => [ - 'workerLifetime' => 60, // Worker process runs for 60 seconds - 'defaultRequeueTimeout' => 300, // Jobs requeued after 5 minutes if not completed - 'defaultJobRetries' => 2, // Retry failed jobs twice - 'sleeptime' => 15, // Sleep 15 seconds when no jobs + 'workerLifetime' => 60, + 'defaultRequeueTimeout' => 600, + 'defaultJobRetries' => 1, + 'sleeptime' => 10, + 'maxworkers' => 1, ], ]; ``` -You can also drop the configuration into an existing config file (recommended) that is already been loaded. -The values above are the default settings which apply, when no configuration is found. +The [example configuration](https://github.com/dereuromark/cakephp-queue/blob/master/config/app.example.php) contains suggested overrides. It is not loaded automatically, and some values differ from the runtime defaults below. -### Backend configuration +## Worker settings -- isSearchEnabled: Set to false if you do not want search/filtering capability. - This is auto-detected based on [Search](https://github.com/FriendsOfCake/search) plugin being available/loaded if not disabled. +| Key | Runtime default | Meaning | +| --- | --- | --- | +| `workerLifetime` | `120` on the current branch | Seconds before a worker exits between jobs | +| `workerLifetimeJitter` | `0` | Random seconds added to a bounded lifetime | +| `workerPhpTimeout` | Twice the worker lifetime | Value passed to PHP `set_time_limit()` | +| `defaultRequeueTimeout` | `600` | Default task claim timeout in seconds | +| `defaultJobRetries` | `1` | Retries after the first attempt | +| `sleeptime` | `10` | Seconds to sleep when no eligible job exists | +| `maxworkers` | `1` | Concurrent workers allowed per server; zero disables this cap | +| `exitwhennothingtodo` | `false` | Exit after the first empty poll | +| `gcprob` | `10` | Probability setting for cleanup between loop iterations | +| `staleHeartbeatThreshold` | `90` | Seconds before a worker heartbeat is treated as stale | +| `cleanuptimeout` | `2592000` | Retention for completed jobs; zero keeps them | +| `cleanupAbortedTimeout` | `0` | Retention after an aborted job's last fetch; zero keeps it | +| `multiserver` | `false` | Enable coordination across servers | +| `connection` | `default` | Queue datasource when no named connection is selected | +| `connections` | `[]` | Whitelist enabling multi-connection mode with two or more names | +| `log` | `false` when unset | Emit informational worker logs | +| `captureOutput` | Schema detection | Capture task output when the `output` column exists | +| `maxOutputSize` | `65536` | Stored output cap in bytes; zero uses the default | +| `canInterruptSleep` | `false` | Allow worker wake-up signals | -- isStatsEnabled: Set to true to enable. This requires [chart.js](https://github.com/chartjs/Chart.js) asset to be available. - You can also overwrite the template and as such change the asset library as well as the output/chart. +`--max-runtime` overrides the worker lifetime. A non-null CLI override also sets PHP's timeout to twice that value; zero removes both limits. Earlier releases may require explicit worker lifetime configuration. See [Upgrading](../reference/upgrading). -### Configuration tips +[PHP's timeout](https://www.php.net/manual/en/function.set-time-limit.php) is not a portable wall-clock limit: on Unix, time blocked in many I/O operations is excluded. Put timeouts on external calls and size task claim timeouts above the longest expected execution time. -For the beginning maybe use not too many runners in parallel, and keep the runtimes rather short while starting new jobs every few minutes. -You can then always increase spawning of runners if there is a shortage. +## Task settings -## Task configuration +Tasks expose `timeout`, `retries`, `rate`, `costs`, and `unique`. Override them on the class or by task name: -You can set two main things on each task as property: timeout and retries. ```php - /** - * Timeout for this task in seconds, after which the task is reassigned to a new worker. - * - * @var ?int - */ - public ?int $timeout = 120; - - /** - * Number of times a failed instance of this task should be restarted before giving up. - * - * @var ?int - */ - public ?int $retries = 1; +return [ + 'Queue' => [ + 'defaultRequeueTimeout' => 600, + 'tasks' => [ + 'Queue.ProgressExample' => [ + 'timeout' => 300, + 'retries' => 2, + ], + ], + ], +]; ``` -Make sure you set the timeout high enough so that it could never run longer than this, otherwise you risk it being re-run while still being run. -It is recommended setting it to at least 2x the maximum possible execution length. See [Concurrent workers](/reference/limitations) -Set the retries to at least 1, otherwise it will never execute again after failure in the first run. +A task timeout is capped at `defaultRequeueTimeout`. Increase that global cap before increasing a task's timeout. A claim that expires while a worker is still running can be executed by another worker. -### Configure-based task overrides +`rate` delays successive executions of a task within a worker; it is not a global concurrency cap. `unique` restricts concurrent execution of that task type. `costs` participates in the per-server worker cost budget. These controls do not replace application idempotency checks. -You can also override task properties via Configure, which is useful for: -- Third-party tasks you cannot modify -- Environment-specific settings (dev vs production) -- Centralized configuration management +## Discovery and admin -```php -$config['Queue']['tasks'] = [ - 'Queue.ProgressExample' => [ - 'timeout' => 300, // Override the task's timeout - 'retries' => 5, // Override the task's retries - ], - 'MyPlugin.HeavyTask' => [ - 'timeout' => 600, - 'rate' => 10, - 'costs' => 50, - 'unique' => true, - ], -]; -``` +`ignoredTasks` excludes task classes from discovery; `plugins` adds task sources. `skipExistenceCheck => true` bypasses task existence checks when creating jobs, so invalid task names can remain unprocessed. + +`isSearchEnabled` controls optional Search integration. `isStatisticEnabled` enables the optional statistics view. It is not named `isStatsEnabled`. See [Admin](../admin/) for access, layout, and dashboard settings. -The priority order is: Configure override > Task class property > Global default. +`executeAllowedCommands` is required for `Queue.Execute` when debug is disabled. See [Execute](../tasks/execute) before enabling command execution. diff --git a/docs/guide/custom-tasks.md b/docs/guide/custom-tasks.md index 3a7fc4bc..ea2131a0 100644 --- a/docs/guide/custom-tasks.md +++ b/docs/guide/custom-tasks.md @@ -22,6 +22,7 @@ You need to at least implement the `run()` method: namespace App\Queue\Task; use Queue\Queue\Task; +use RuntimeException; class YourNameForItTask extends Task { diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md new file mode 100644 index 00000000..a3d017fe --- /dev/null +++ b/docs/guide/getting-started.md @@ -0,0 +1,45 @@ +# Getting started + +After [installation](./installation), run these commands from your application directory. They create and process a harmless example job. + +## Add a job + +```sh +bin/cake queue add Queue.Example +``` + +The command stores a `Queue.Example` job in `queued_jobs`. It does not run the job itself. + +## Start a worker + +```sh +bin/cake queue run --max-runtime 20 --type Queue.Example -v +``` + +Look for the example task's success message and `Job Finished`. The worker continues polling until its lifetime is reached. The lifetime is checked between jobs; it does not interrupt a running task. + +On the current branch, a worker without explicit lifetime configuration uses 120 seconds. Earlier releases may require `Queue.workerLifetime` or the `--max-runtime` option; the command above supplies that option. + +Use `queue run` to process jobs. `queue worker` manages registered worker processes and does not start one. + +## Inspect the queue + +```sh +bin/cake queue info +bin/cake queue job +``` + +The second command lists recent jobs, then exits with an error code because no management action was supplied. To inspect one job, copy its ID and run `bin/cake queue job view 123`, replacing `123`. + +## Queue from application code + +From a controller using `fetchTable()`: + +```php +$queuedJobsTable = $this->fetchTable('Queue.QueuedJobs'); +$job = $queuedJobsTable->createJob('Queue.Example'); +``` + +For useful application work, [create your own task](./custom-tasks) and pass a small array of values or record IDs. Jobs store JSON data, so the worker receives arrays and scalars rather than the original entity objects. + +When ready to process other task types, omit `--type Queue.Example`. Keep a worker running with [cron](./cron) or a [process supervisor](./operations), and make tasks safe to retry. diff --git a/docs/guide/index.md b/docs/guide/index.md index c404d4c1..28bfb67f 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -1,24 +1,17 @@ -# Guide +# Overview -The Queue plugin runs background jobs out of your CakePHP database — no Redis, no RabbitMQ, no extra infrastructure. Jobs are persisted, retried on failure, and processed by one or more worker processes you trigger via cron, supervisor, or systemd. +Queue stores background jobs in your application's database. A separate CLI worker processes them, records completion, and retries failures according to each task's configuration. -## Where to start +Start with [Installation](./installation), then [Getting started](./getting-started). Use [Queueing jobs](./queueing-jobs) for payloads, priorities, references, and progress, or [Custom tasks](./custom-tasks) to implement your application's work. -- [Basic Setup](/guide/basic-setup) — install, load the plugin, run migrations. -- [Queueing Jobs](/guide/queueing-jobs) — create jobs from your app code, with priorities, references, and progress tracking. -- [Custom Tasks](/guide/custom-tasks) — write your own task classes. +## Operations -## Operating the queue +Configure worker lifetimes, timeouts, and retries in [Configuration](./configuration). Follow [Cron](./cron) or [Operations](./operations) to keep workers running. [Multi-connection](./multi-connection) covers separate queue databases. -- [Configuration](/guide/configuration) — runtime tuning (worker lifetime, timeouts, retries, multi-server). -- [Operations](/guide/operations) — production checklist: sizing workers, supervisor/systemd, monitoring, failure handling, schema migrations on live workers. -- [Cron Setup](/guide/cron) — start workers on a schedule. -- [Multi-Connection](/guide/multi-connection) — run queues against multiple databases. -- [Real-Time Progress](/guide/realtime-progress) — Mercure / SSE for live progress UIs. +Jobs can be executed more than once after crashes or timeouts. Design tasks to tolerate retries; the queue does not guarantee exactly-once execution. See [Limitations](../reference/limitations). -## Email handling +## Email and admin tools -- [Mailing](/guide/mailing) — queueing emails via QueueTransport, custom email tasks, and the built-in tasks reference. +[Mailing](./mailing) covers queue transports and custom email tasks. The [built-in tasks](../tasks/) include Email, Mailer, and Execute. -For built-in tasks (Execute, Email, Mailer), see [Tasks](/tasks/). -For the admin UI, see [Admin Dashboard](/admin/). +The optional [admin interface](../admin/) shows jobs and worker activity behind a required application access callback. [Real-time progress](./realtime-progress) uses an optional Mercure integration. diff --git a/docs/guide/installation.md b/docs/guide/installation.md new file mode 100644 index 00000000..d6268c62 --- /dev/null +++ b/docs/guide/installation.md @@ -0,0 +1,31 @@ +# Installation + +Composer checks the package's supported PHP and CakePHP versions. + +```sh +composer require dereuromark/cakephp-queue +bin/cake plugin load Queue +``` + +For CLI-only use, load the plugin with `--no-routes` instead. If you load plugins in `Application::bootstrap()`, use `$this->addPlugin('Queue')` after the parent call instead of the load command. + +## Database schema + +The plugin needs `queued_jobs` and `queue_processes` tables. If Migrations is not already installed: + +```sh +composer require cakephp/migrations +bin/cake plugin load Migrations +``` + +Apply all Queue migrations to your application's default connection: + +```sh +bin/cake migrations migrate -p Queue +``` + +For a separate queue database, migrate that connection with `-c connection_name` and follow [Multi-connection](./multi-connection). Run migrations during deployment and after plugin upgrades. + +The web application and CLI worker must use the same database configuration. POSIX and PCNTL extensions enable process signals on supported systems; check those extensions when using worker termination or wake-up controls. + +Continue with [Getting started](./getting-started). The optional [admin interface](../admin/) denies access until you configure its callback. diff --git a/docs/guide/mailing.md b/docs/guide/mailing.md index eff1b74b..f8ae1db1 100644 --- a/docs/guide/mailing.md +++ b/docs/guide/mailing.md @@ -1,26 +1,20 @@ # Mailing -There are multiple ways to send emails asynchronously using the Queue plugin: - -## Using built-in tasks -* [Email Task](/tasks/email) - For basic emails using Message class (most flexible) -* [Mailer Task](/tasks/mailer) - For reusable templated emails using Mailer classes (best for standard emails) +Use the [Email task](/tasks/email) for message payloads or the [Mailer task](/tasks/mailer) for reusable Mailer actions. Both require a configured transport that can send mail from the worker environment. ## Using QueueTransport -Instead of manually adding job every time you want to send mail -you can use existing code ond change only EmailTransport and Email configurations in `app.php`. +QueueTransport stores an already rendered message as a job. Configure a separate delivery transport so the worker sends it instead of queuing it again: ```php +// config/app_local.php: use your SMTP provider's credentials. 'EmailTransport' => [ 'default' => [ 'className' => 'Smtp', - // The following keys are used in SMTP transports - 'host' => 'host@gmail.com', + 'host' => 'smtp.example.com', 'port' => 587, - 'timeout' => 30, - 'username' => 'username', - 'password' => 'password', + 'username' => env('SMTP_USERNAME'), + 'password' => env('SMTP_PASSWORD'), 'tls' => true, ], 'queue' => [ @@ -28,95 +22,66 @@ you can use existing code ond change only EmailTransport and Email configuration 'transport' => 'default', ], ], - 'Email' => [ 'default' => [ 'transport' => 'queue', - 'from' => 'no-reply@host.com', - 'charset' => 'utf-8', - 'headerCharset' => 'utf-8', + 'from' => 'no-reply@example.com', ], ], ``` -This way each time with `$mailer->deliver()` it will use `QueueTransport` as main to create job and worker will use `'transport'` setting to send mail. +Calls to `deliver()` using this mail configuration create jobs. A running Queue worker delivers them through `default`. Successful queuing does not confirm delivery. -### Difference between QueueTransport and SimpleQueueTransport +### QueueTransport and SimpleQueueTransport -* `QueueTransport` serializes whole email into the database and is useful when you have custom `Message` class. -* `SimpleQueueTransport` extracts all data from Message (to, bcc, template etc.) and then uses this to recreate Message inside task, this - is useful when dealing with emails which serialization would overflow database `data` field length. - This can only be used for non-templated emails. +QueueTransport serializes the complete Message, including its rendered body. SimpleQueueTransport extracts message settings and body data for reconstruction by the Email task. Templates have already rendered before either transport receives the Message. Neither transport removes the need to fit the payload into your database's `data` column. +For SimpleQueueTransport, use `Queue.SimpleQueue` as `className`, with the same separate delivery transport. ## Creating custom email tasks -For more complex email workflows, you can create custom task classes. - -This is the most customizable way to generate your asynchronous emails. Don't generate emails directly in your code and pass them to the queue. Instead, pass only the minimum requirements (non-persistent data and primary keys of records). - -Example: Sending a notification when someone posts a comment. - -Inside your CommentsTable class after saving: +Pass record IDs when the worker should load current data and render the email later. For example, after saving a comment: ```php -/** - * @param \App\Model\Entity\Comment $comment - * @return void - */ -protected function _notifyAdmin(Comment $comment) -{ - /** @var \Queue\Model\Table\QueuedJobsTable $QueuedJobs */ - $QueuedJobs = TableRegistry::getTableLocator()->get('Queue.QueuedJobs'); - $data = [ - 'settings' => [ - 'subject' => __('New comment submitted by {0}', $comment->name), - ], - 'vars' => [ - 'comment' => $comment->toArray(), - ], - ]; - $QueuedJobs->createJob('CommentNotification', $data); -} +use Cake\ORM\TableRegistry; + +$queuedJobs = TableRegistry::getTableLocator()->get('Queue.QueuedJobs'); +$queuedJobs->createJob('CommentNotification', ['comment_id' => $comment->id]); ``` -Create your custom task class `src/Queue/Task/CommentNotificationTask.php`: +Create `src/Queue/Task/CommentNotificationTask.php`: ```php namespace App\Queue\Task; -use Cake\Mailer\MailerAwareTrait; +use Cake\Mailer\Mailer; use Queue\Queue\Task; class CommentNotificationTask extends Task { - use MailerAwareTrait; - public function run(array $data, int $jobId): void { - $mailer = $this->getMailer('User'); + $comment = $this->fetchTable('Comments')->get($data['comment_id']); + $mailer = new Mailer(); + $mailer->setTransport('default') + ->setFrom('no-reply@example.com') + ->setTo('admin@example.com') + ->setSubject('New comment') + ->setEmailFormat('html') + ->setViewVars(['comment' => $comment]); $mailer->viewBuilder()->setTemplate('comment_notification'); - - if (!empty($data['settings']['subject'])) { - $mailer->setSubject($data['settings']['subject']); - } - if (!empty($data['vars'])) { - $mailer->setViewVars($data['vars']); - } - $mailer->deliver(); } } ``` -Create the email template `templates/email/html/comment_notification.php`: +Use your application's real sender, recipient and delivery transport. The task throws if the comment has been deleted; decide whether deleted records should instead be skipped in your application. -```php -name ?> (email ?>) wrote: - -message ?> +Create `templates/email/html/comment_notification.php`: -Url->build(['prefix' => 'Admin', 'controller' => 'Comments', 'action'=> 'view', $comment['id']], true) ?> +```php +

name) ?> (email) ?>) wrote:

+

message)) ?>

``` -This way all the email generation logic is in the specific task and template, making it easy to test separately. +A retry can send the email again if the worker fails after delivery but before marking the job complete. Account for that when designing notifications; see [limitations](/reference/limitations). diff --git a/docs/guide/multi-connection.md b/docs/guide/multi-connection.md index b6d4221e..93b37038 100644 --- a/docs/guide/multi-connection.md +++ b/docs/guide/multi-connection.md @@ -11,11 +11,13 @@ Enable multi-connection mode by defining an array of connections in your config: $config['Queue']['connections'] = ['default', 'acme']; ``` -The first connection in the array becomes the default when no specific connection is selected. +In single-connection mode, commands preserve the connection assigned to the shared `Queue.QueuedJobs` table. Its default comes from `Queue.connection`, otherwise `default`. An explicit `--connection` overrides the table connection. + +The first configured connection is the default for the admin interface, queue tables and CLI commands when no connection is selected. It takes precedence over `Queue.connection` and manually assigned table connections in multi-connection mode. For an application operation targeting another listed connection, select the same connection explicitly in the worker and management commands. **Important:** - Multi-connection mode is only enabled when 2 or more connections are configured -- Only connections in this whitelist can be used (security feature) +- The admin selector and CLI commands only accept connections from this list - Without this config, the plugin operates in backwards-compatible single-connection mode ## Admin Dashboard @@ -25,7 +27,7 @@ When multi-connection mode is enabled, a connection switcher dropdown appears in - The dropdown shows all configured connections - Select a connection to view jobs/workers for that specific database - The current connection is highlighted -- The connection selection persists via URL query parameter (`?connection=acme`) +- The selection is stored in the session; a URL query parameter (`?connection=acme`) overrides it All admin pages (Dashboard, Jobs, Workers, Processes) will show data for the selected connection. @@ -58,55 +60,33 @@ bin/cake queue job view 123 --connection acme bin/cake queue job reset all --connection acme ``` -## Creating Jobs for Specific Connections - -Jobs are stored in the database they were created against. To create a job for a specific connection, you need to get the table with that connection: +On versions containing the [connection option](../reference/upgrading#unreleased-worker-fixes), `queue add` follows the same connection rules: -```php -use Cake\Datasource\ConnectionManager; - -// Get the QueuedJobs table -$queuedJobsTable = $this->fetchTable('Queue.QueuedJobs'); +```sh +bin/cake queue add Queue.Example --connection acme +bin/cake queue run --connection acme +bin/cake queue info --connection acme --format json +bin/cake queue worker end all --connection acme +``` -// For a non-default connection, set it explicitly -$connection = ConnectionManager::get('acme'); -$queuedJobsTable->setConnection($connection); +Earlier versions need the table-instance approach below to enqueue on another connection. -// Create the job - it will be stored in the 'acme' database -$queuedJobsTable->createJob('MyTask', $data); -``` +## Creating Jobs for Specific Connections -Or using the table locator directly: +Jobs belong to the connection used to create them. Use a separate table instance for each connection so later calls to the shared table do not inherit a changed connection: ```php use Cake\Datasource\ConnectionManager; -use Cake\ORM\TableRegistry; - -$queuedJobsTable = TableRegistry::getTableLocator()->get('Queue.QueuedJobs'); -$queuedJobsTable->setConnection(ConnectionManager::get('acme')); +use Queue\Model\Table\QueuedJobsTable; -$queuedJobsTable->createJob('MyTask', ['key' => 'value']); +$queuedJobs = new QueuedJobsTable([ + 'alias' => 'AcmeQueuedJobs', + 'connection' => ConnectionManager::get('acme'), +]); +$queuedJobs->createJob('MyTask', ['key' => 'value']); ``` -### Helper Method Pattern - -For cleaner code, consider creating a helper method or service: - -```php -// In a utility class or base controller -protected function getQueueTable(string $connection = 'default'): QueuedJobsTable { - $table = $this->fetchTable('Queue.QueuedJobs'); - - if ($connection !== 'default') { - $table->setConnection(ConnectionManager::get($connection)); - } - - return $table; -} - -// Usage -$this->getQueueTable('acme')->createJob('MyTask', $data); -``` +Do not switch a shared table's connection and assume the next caller will restore it. Use matching workers and install the Queue schema on each database. ## Running Workers in Production diff --git a/docs/guide/operations.md b/docs/guide/operations.md index 231d978b..587d7b21 100644 --- a/docs/guide/operations.md +++ b/docs/guide/operations.md @@ -1,32 +1,20 @@ # Operations -A field guide for running the queue in production. This is not a tutorial — it's a checklist of things that bite, with the configuration knob or admin-UI lever next to each one. +Run workers with cron or a process supervisor. Keep the CLI database configuration aligned with the web application, and design tasks to tolerate retries. -## Sizing workers +The bare `queue worker` command lists recent workers and prints usage, but exits with an error status because no action was selected. Account for this when using it in monitoring scripts. -The right number of concurrent workers is bounded by: +## Worker capacity -- the slowest task you run (long-running tasks block the worker slot they occupy until completion); -- the database connection pool (each worker holds one connection); -- the I/O / CPU profile of your tasks (mailer-heavy: I/O-bound, can over-subscribe cores; image processing: CPU-bound, stay at or below `nproc`). +Start with a small `maxworkers` limit and measure queue latency, database load, and task duration. Each worker runs one task at a time. CPU-heavy tasks compete for cores; tasks waiting on remote services need timeouts on those calls. -A sensible starting point: one worker per available CPU core for CPU-bound workloads, two to four times that for I/O-bound workloads. Watch the admin dashboard's "queue length over time" graph for the first week and adjust. +A task's `unique` flag restricts its concurrent execution. `rate` delays repeats within a worker, and `costs` participates in the per-server budget. See [Configuration](./configuration) for their scope. -### Per-task concurrency limits +## Lifetimes and supervision -If a single task class can saturate workers (e.g. a heavy report generator), cap it with the task's `$rate`, `$timeout`, `$unique` or `$costs` properties. See [Configuration](/guide/configuration). +`workerLifetime` bounds how long a worker continues its loop. It is checked between jobs, so a task can finish after that lifetime has elapsed. `workerLifetimeJitter` spreads restarts across a fleet. `exitwhennothingtodo` is useful for cron workers that should exit after an empty poll. -## Worker lifetime and restart cadence - -Workers should be designed to exit and respawn periodically — this is the simplest defense against memory leaks, accumulated state, and any in-process cache that drifts from the database (e.g. a freshly migrated column that an older worker can't see yet — see the `captureOutput` regression that landed mid-2026). - -- **`workerLifetime`** (`Configure::write('Queue.workerLifetime', 3600)`) — exit cleanly after N seconds. Process supervisor brings up a fresh worker. Default 0 means run forever; for production use a bounded value. -- **`defaultJobRetries`** (or the task's `$retries` property) - number of retries on transient errors before a job is aborted. Match this to your task idempotency story. -- **`exitwhennothingtodo`** — when set, the worker exits on its first empty poll. Suitable for cron-managed workers; leave off for long-running supervisor-managed processes. - -### Recommended supervisor / systemd unit - -A systemd unit shape that survives normal failures: +A systemd unit can start and restart a worker: ```ini # /etc/systemd/system/cakephp-queue.service @@ -37,7 +25,7 @@ After=network.target [Service] User=www-data WorkingDirectory=/var/www/app -ExecStart=/var/www/app/bin/cake queue worker --verbose +ExecStart=/var/www/app/bin/cake queue run --max-runtime 300 -v Restart=always RestartSec=5 @@ -45,71 +33,56 @@ RestartSec=5 WantedBy=multi-user.target ``` -Then `systemctl enable --now cakephp-queue.service`. The `Restart=always` plus a bounded `workerLifetime` means workers cycle on their own schedule and any crash recovers without manual intervention. +Replace the user and application path, then run `systemctl enable --now cakephp-queue.service`. Use separate units or a template unit for multiple workers, within your configured cap. -For multiple parallel workers, use a `cakephp-queue@.service` template and `systemctl enable --now cakephp-queue@1.service cakephp-queue@2.service ...`. +`queue run` processes jobs. `queue worker` lists and manages worker registrations. ## Monitoring -The admin dashboard surfaces: - -- `queue_processes` table — currently-running workers with last-seen timestamp. -- `queued_jobs` table — pending + recent jobs grouped by task, with progress, failure-message, and exit status. -- Per-task counters: queued / in-flight / failed / completed in the last 24h. - -For external monitoring (Prometheus, Datadog, etc.), `bin/cake queue info --format json` prints the job counts (unfinished, pending, scheduled, aborted), the worker count and last run, queued jobs per task, and finished-job statistics. Settings are left out of the JSON because they can hold closures and credentials. You can also poll the tables directly. The cheapest signals worth alerting on: - -- **stale workers**: any row in `queue_processes` where `modified` is older than `defaultRequeueTimeout + 60s` is a worker that died without cleanup. The plugin auto-evicts these on next startup, but a persistent count > 0 means workers are crashing faster than they recover. -- **backlog growth**: `count(*)` of `queued_jobs WHERE completed IS NULL AND fetched IS NULL`. A monotonically rising number means you're under-provisioned. -- **per-task failure rate**: `count(*) WHERE failure_message IS NOT NULL AND created > now() - interval '1h'`, grouped by `job_task`. Alert when any task crosses your error budget. - -The admin dashboard's "tips" sidebar contains the same SQL for quick eyeballing. +```sh +bin/cake queue info --format json +bin/cake queue worker +``` -## Failure handling +The JSON output includes unfinished, pending, scheduled, and aborted counts, worker information, and per-task statistics. Settings are omitted because they can contain callbacks or credentials. -The queue retries failed jobs up to the task's `$retries` (default `Queue.defaultJobRetries`) times. After that the job is aborted: it stays in `queued_jobs` with `failure_message` populated and `status = 'aborted'`, shows in the dashboard's Aborted Jobs card, and runs again only after a reset. +Check backlog growth, task failures, and stale heartbeats. `staleHeartbeatThreshold` controls registration cleanup, with a runtime default of 90 seconds. `defaultRequeueTimeout` controls task claim timeouts and is a separate setting. A heartbeat can age while a long task is running; do not assume an old timestamp proves the process is dead. -### Investigating a failure +The [admin interface](../admin/) shows jobs, failures, and registered workers. Limit its access with `Queue.adminAccess`. -1. **Admin dashboard → Failed jobs view** sorts by most recent. Each row links to its full payload + stack trace. -2. **`bin/cake queue job clean`** deletes completed jobs older than `Queue.cleanuptimeout` (default 30 days), and aborted jobs whose last attempt is older than `Queue.cleanupAbortedTimeout` (default 0, keeps them). -3. **`bin/cake queue job reset `** requeues a dead job. Useful for transient failures (network blip, downstream service down) after you've fixed the cause. The dashboard's Aborted Jobs card has a "Reset All" button for all aborted jobs at once. -4. **`bin/cake queue job flush --aborted`** deletes all aborted jobs; without `--aborted` it removes failed jobs. +## Failures and retries -### Dead-letter pattern +A failed task is retried according to its `retries` setting. After exhausting attempts, its row is marked aborted and remains stored until reset, deletion, or configured cleanup. It is excluded from pending jobs. -There's no built-in DLQ table yet — failed jobs stay in `queued_jobs`. The current workaround: +```sh +bin/cake queue job view 123 +bin/cake queue job reset 123 +bin/cake queue job reset all +``` -1. Set `Queue.cleanuptimeout` high enough (e.g. 90 days) that failed rows survive long enough to investigate. -2. Filter the dashboard by `failed IS NOT NULL` for the DLQ view. -3. Use `queue retry` to requeue, `queue remove` to drop. +Replace `123` with the job ID. Inspect the payload and failure before resetting it. A task that catches an exception and returns normally counts as successful; throw a `QueueException` or another exception when work fails. -Issue tracker has the formal DLQ feature on the roadmap; see `Per-task circuit breaker / dead-letter status` in the strategic items section of the [merged plugin review](https://github.com/dereuromark/cakephp-queue/issues). +For deletion: -## Schema migrations on live workers +```sh +bin/cake queue job clean +bin/cake queue job flush --aborted +``` -Two cases worth knowing: +`clean` removes completed jobs older than `cleanuptimeout` and aborted jobs older than `cleanupAbortedTimeout`. The latter defaults to zero, retaining aborted jobs. `flush --aborted` deletes all aborted jobs; without that flag, `flush` deletes failed jobs. -- **New column on `queued_jobs`** (e.g. when you migrate to a version that adds an `output` column): the `captureOutput()` auto-detect path re-runs the schema check on every job since the mid-2026 fix, so the new column takes effect on the next dispatched job. No worker restart required. -- **Anything more invasive** (dropped column, renamed table, FK changes): use a rolling restart. Bring workers down via `systemctl stop cakephp-queue@*`, run migrations, bring them back. Jobs in-flight at restart time fall under the standard `defaultRequeueTimeout` (180s default) — they get re-picked-up by a fresh worker if not finished. +There is no separate dead-letter table. Aborted rows in `queued_jobs` provide the retained failure list. Completed-job retention does not control aborted retention. -## Multi-server deployments +## Deployments and schema changes -If two app servers run cron-triggered workers against the same queue: +Stop supervisor respawning while draining old workers. `bin/cake queue worker end all` requests that registered workers stop after their current job. Wait for those jobs to finish, migrate the relevant connection, deploy the code, and start fresh workers. -- Workers coordinate via the `queue_processes` table heartbeat + per-row `workerkey` claim, so there's no double-execution risk at the job level. -- BUT: if you also run `cakephp-queue-scheduler` on multiple hosts, the bundled `FileLock` is single-host by design. See the queue-scheduler operations guide for a multi-host advisory lock recipe. -- Run `bin/cake queue clean` from exactly **one** host (cron-driven, daily off-peak). Two simultaneous cleans don't corrupt anything but waste DB cycles. +Restart workers after schema changes, including adding the `output` column. Output auto-detection reads the table's schema, which CakePHP may already have cached in that process. Rechecking the same table does not guarantee a fresh database description. -## Common pitfalls +Interrupted jobs can be retried after their claim timeout. Increasing a timeout cannot make external effects atomic or guarantee exactly-once execution. -- **Tasks that swallow exceptions**: a task's `run()` method that catches `Throwable` and returns normally counts as success — even when the underlying work failed. If you must catch, re-throw `QueueException` to surface the failure to the worker. -- **Long sleeps inside `run()`**: anything that blocks longer than `defaultRequeueTimeout` will be considered abandoned and re-queued. Either chunk the work into smaller jobs or raise the per-task `$timeout`. -- **Worker started in CLI environment without DB access**: `bin/cake queue worker` reads the active CakePHP `Database.default` connection. If your CLI shell has a different DSN than your web shell, workers can't see jobs your web app created. Use the same `app_local.php` everywhere. -- **`captureOutput`-cached-stale**: fixed mid-2026 by re-checking schema per call. If you observe missing output on a long-running worker, restart it as a fallback. Migration ordering is also worth checking — add the column before deploying the code that writes to it. +## Multiple servers -## See also +Enable `multiserver`, use distinct server identities, and point workers at the same intended queue database. Claims coordinate database work, but crashes and expired claims can still cause duplicate execution. SQLite does not provide the same row-locking path as MySQL or PostgreSQL; use a suitable database and test contention before running concurrent production workers. -- [Configuration](/guide/configuration) — every Configure key the worker reads at runtime. -- [Multi-Connection](/guide/multi-connection) — running queues against multiple databases. -- [Tips](/reference/tips) — debugging notes and one-off SQL queries for the queued_jobs table. +See [Multi-connection](./multi-connection) for separate queues and [Limitations](../reference/limitations) for retry behavior. diff --git a/docs/guide/queueing-jobs.md b/docs/guide/queueing-jobs.md index ff80ff17..396f2fb5 100644 --- a/docs/guide/queueing-jobs.md +++ b/docs/guide/queueing-jobs.md @@ -37,7 +37,7 @@ use Queue\Model\Enum\Priority; $queuedJobsTable->createJob('MyTask', $data, ['priority' => Priority::High]); // or using JobConfig -$config = $queuedJobsTable->newConfig()->setPriority(Priority::High); +$config = $queuedJobsTable->createConfig()->setPriority(Priority::High); ``` Available priority levels (1 = highest, 10 = lowest): @@ -66,7 +66,7 @@ $queuedJobsTable->createJob('Queue.Email', [ ]); // Somewhere in the model or lib -use Cake\ORM\Locator\LocatorAwareTrait; +use Cake\ORM\TableRegistry; TableRegistry::getTableLocator()->get('Queue.QueuedJobs') ->createJob('Queue.Email', [ @@ -97,7 +97,7 @@ find the usage more easily, you can also use `::class` for `creat ```php use Queue\Queue\Task\EmailTask; -$queuedJobsTable->createJob(EmailTask::class, ['to' => 'user@example.org', ...]); +$queuedJobsTable->createJob(EmailTask::class, ['settings' => ['to' => 'user@example.org', 'from' => 'no-reply@example.org'], 'content' => 'Email body']); ``` This does, however, require adding use statements for each such line on top. @@ -108,7 +108,10 @@ use Queue\Queue\Task\EmailTask; $taskName = EmailTask::taskName(); -$queuedJobsTable->createJob($taskName, ['to' => 'user@example.org', ...]); +$queuedJobsTable->createJob($taskName, [ + 'settings' => ['to' => 'user@example.org', 'from' => 'no-reply@example.org'], + 'content' => 'Email body', +]); ``` This can be useful when more dynamically adding certain jobs of different types. @@ -124,7 +127,7 @@ $dataDto = OrderUpdateNotificationQueueDataDto::createFromArray([ OrderUpdateNotificationQueueDataDto::FIELD_MAILER => 'CustomerRequest', OrderUpdateNotificationQueueDataDto::FIELD_TYPE => 'request', ]); -$config = $queuedJobsTable->newConfig() +$config = $queuedJobsTable->createConfig() ->setPriority(Priority::Urgent) ->setReference('foo') ->setNotBefore('+1 hour'); @@ -225,13 +228,11 @@ $queuedJobsTable->createJob( // inserts a fresh row. ``` -`unique: true` without a `reference` throws `InvalidArgumentException` -— fail-fast at the call site rather than silently inserting an -undeduped row. The dedup check uses an info-level log entry +`unique: true` without a `reference` throws `InvalidArgumentException` at the call site. It does not insert a duplicate row. The dedup check uses an info-level log entry (`Queue.createJob: dedup hit for reference= ...`) so operators can confirm the guard is firing. -The dedup window is "the previous job is not yet completed" — pending, +The dedup window is "the previous job is not yet completed"; pending, in-progress, and failed-but-not-completed jobs all block. This is the right shape for cron fan-out: if a previous tick is stuck or still running, the next tick holds back instead of stacking a duplicate. @@ -359,30 +360,29 @@ echo $this->QueueProgress->htmlTimeoutProgressBar($queuedJob, $textTimeoutProgre If you want also to allow adding jobs from CLI, you need to implement the AddInterface: ```php -... -use Queue/Queue/AddInterface; - -class MyExampleTask extends Task implements AddInterface { +namespace App\Queue\Task; - ... +use Queue\Queue\AddInterface; +use Queue\Queue\Task; - /** - * @param string|null $data - * - * @return void - */ - public function add(?string $data): void { - ... +class MyExampleTask extends Task implements AddInterface +{ + public function add(?string $data): void + { + $this->QueuedJobs->createJob('MyExample', ['message' => $data ?? 'Hello']); } + public function run(array $data, int $jobId): void + { + $this->io->out($data['message']); + } } ``` -Now you can add them from both CLI and Web backend (/queue). -So `bin/cake queue add SpecialExample` for SpecialExampleTask. +Add the example from the CLI with `bin/cake queue add MyExample`. To expose it in the admin backend, also implement `AddFromBackendInterface`; see the next section. The payload is a simple string, make sure to quote if it contains whitespace: ``` -bin/cake queue add SpecialExample "Foo:Bar, Baz" +bin/cake queue add MyExample "Foo:Bar, Baz" ``` You need to handle the content of this `$data` string manually inside your `add()` method. diff --git a/docs/guide/realtime-progress.md b/docs/guide/realtime-progress.md index befaecc0..4d3603a9 100644 --- a/docs/guide/realtime-progress.md +++ b/docs/guide/realtime-progress.md @@ -1,62 +1,33 @@ -# Real-Time Progress with Mercure +# Real-time progress with Mercure -This guide explains how to display real-time queue job progress in the browser using [Mercure](https://mercure.rocks/) and Server-Sent Events (SSE). This is particularly useful with [FrankenPHP](https://frankenphp.dev/) which has Mercure built-in. +Queue progress is stored in the database. To push updates to browsers, your task can also publish through the optional [Mercure plugin](https://github.com/josbeir/cakephp-mercure). Queue does not publish Mercure events automatically. -## Overview +## Set up the hub and plugin -Instead of polling the server or requiring page refreshes, the queue worker pushes updates directly to the browser: +Install and load the optional plugin: -1. User triggers a background job -2. Job runs in queue worker process -3. Each progress step publishes a Mercure update -4. Browser receives updates instantly via EventSource - -## Requirements - -- [josbeir/cakephp-mercure](https://github.com/josbeir/cakephp-mercure) plugin -- Mercure hub (standalone or built into FrankenPHP) - -## Setup - -### 1. Install the Mercure Plugin - -```bash +```sh composer require josbeir/cakephp-mercure +bin/cake plugin load Mercure ``` -Load the plugin: +Run a Mercure hub using its [deployment documentation](https://mercure.rocks/docs/hub/install). Configure the hub's publisher and subscriber signing keys, and make the same secret available to CakePHP as `MERCURE_JWT_SECRET`. For HS256, use a secret of at least 32 bytes; `openssl rand -hex 32` generates one. -```php -// config/plugins.php -return [ - 'Mercure' => [], - // ... -]; -``` - -### 2. Configure Mercure - -Create `config/app_mercure.php`: +This example uses the application's HTTPS origin for browser subscriptions. Put the hub endpoint behind that origin so its authorization cookie applies to requests. For a separate origin, configure cookie scope and the hub's allowed origins according to the Mercure plugin docs. ```php - [ - // Internal URL for server-side publishing (inside container/server) - 'url' => 'http://localhost/.well-known/mercure', - - // External URL for browser EventSource connections - 'public_url' => 'https://your-domain.com/.well-known/mercure', - + 'url' => 'http://mercure/.well-known/mercure', + 'public_url' => 'https://app.example.com/.well-known/mercure', 'jwt' => [ - 'secret' => 'your-mercure-jwt-secret', + 'secret' => env('MERCURE_JWT_SECRET'), 'algorithm' => 'HS256', - 'publish' => ['*'], + 'publish' => ['https://app.example.com/jobs/{id}'], 'subscribe' => [], ], - 'cookie' => [ 'name' => 'mercureAuthorization', 'secure' => true, @@ -67,359 +38,125 @@ return [ ]; ``` -Load it in `config/bootstrap.php`: - -```php -Configure::load('app_mercure'); -``` - -### 3. FrankenPHP with Mercure - -If using FrankenPHP, add Mercure to your Caddyfile or environment: +Replace the URLs for your deployment. Load this configuration in both the web application and CLI worker. Use the [plugin's configuration reference](https://github.com/josbeir/cakephp-mercure/blob/master/config/mercure.php) for other signing strategies. -``` -CADDY_SERVER_EXTRA_DIRECTIVES= -mercure { - publisher_jwt your-mercure-jwt-secret - subscriber_jwt your-mercure-jwt-secret - anonymous - cors_origins * -} -``` - -For DDEV, create `.ddev/docker-compose.mercure.yaml`: - -```yaml -services: - web: - environment: - - |- - CADDY_SERVER_EXTRA_DIRECTIVES= - mercure { - publisher_jwt your-mercure-jwt-secret - subscriber_jwt your-mercure-jwt-secret - anonymous - cors_origins * - } -``` +## Publish private updates from a task -## Creating a Queue Task with Mercure Updates +Create `src/Queue/Task/MyProgressTask.php`: ```php -publishUpdate($topic, [ - 'status' => 'started', - 'progress' => 0, - 'message' => 'Job started', - 'jobId' => $jobId, - ]); - } - - for ($i = 1; $i <= $steps; $i++) { - // Do actual work here... + for ($step = 1; $step <= $steps; $step++) { + // Replace this delay with your application's work. sleep(1); - - $progress = (int)(($i / $steps) * 100); - - // Update queue progress (for DB tracking) - $this->QueuedJobs->updateProgress($jobId, $i / $steps, "Step {$i} of {$steps}"); - - // Publish Mercure update (for real-time UI) - if ($mercureConfigured) { - $this->publishUpdate($topic, [ - 'status' => 'progress', - 'progress' => $progress, - 'step' => $i, - 'totalSteps' => $steps, - 'message' => "Processing step {$i} of {$steps}", + $progress = $step / $steps; + $this->QueuedJobs->updateProgress($jobId, $progress); + Publisher::publish((new JsonUpdate($topic)) + ->data([ 'jobId' => $jobId, - ]); - } - } - - // Publish completion event - if ($mercureConfigured) { - $this->publishUpdate($topic, [ - 'status' => 'completed', - 'progress' => 100, - 'message' => 'Job completed successfully!', - 'jobId' => $jobId, - ]); - } - } - - protected function publishUpdate(string $topic, array $data): void { - try { - Publisher::publish(JsonUpdate::create( - topics: $topic, - data: $data, - )); - } catch (\Exception $e) { - $this->io->error('Mercure publish failed: ' . $e->getMessage()); + 'progress' => (int)round($progress * 100), + 'status' => $step === $steps ? 'completed' : 'progress', + ]) + ->private() + ->build()); } } } ``` -## Controller +A publishing failure throws and can cause a task retry. Decide whether that is appropriate for your work. For progress that is optional, catch publishing errors separately after recording database progress; keep task failures visible to the worker. + +## Authorize the browser's topic + +The controller must enforce your application's access rules before granting a Mercure subscription. This example gives the current session a separate random progress topic; it does not publish the PHP session ID. For jobs shared across requests or devices, store an operation ID and verify its ownership instead. ```php -loadComponent('Mercure.Mercure'); + } public function progress(): void { - $sid = $this->request->getSession()->id(); - $topic = '/jobs/user/' . $sid; - + $topic = $this->progressTopic(); + $this->Mercure->authorize([$topic]); $this->set('topic', $topic); $this->set('mercurePublicUrl', Configure::read('Mercure.public_url')); } public function startJob() { $this->request->allowMethod('post'); + $topic = $this->progressTopic(); + $this->fetchTable('Queue.QueuedJobs')->createJob('MyProgress', ['topic' => $topic]); - $queuedJobsTable = $this->fetchTable('Queue.QueuedJobs'); - $sid = $this->request->getSession()->id(); - $topic = '/jobs/user/' . $sid; - - $queuedJobsTable->createJob( - 'MyProgress', - ['topic' => $topic], - ['reference' => 'user-job-' . $sid], - ); - - $this->Flash->success('Job started!'); return $this->redirect(['action' => 'progress']); } -} -``` - -## Template with EventSource -```php - - -
-
-
0%
-
-

Waiting for job...

-
- - - - -``` - -## Running Workers - -### Development (DDEV with FrankenPHP) - -Add to `.ddev/config.frankenphp.yaml`: - -```yaml -web_extra_daemons: - - name: "frankenphp" - command: "frankenphp run --config /etc/frankenphp/Caddyfile --adapter=caddyfile" - directory: /var/www/html - - name: "queue-worker" - command: "bash -c 'sleep 5 && DDEV_PROJECT=myproject bin/cake queue run -v'" - directory: /var/www/html -``` - -### Production (systemd) - -Create `/etc/systemd/system/myapp-queue.service`: - -```ini -[Unit] -Description=CakePHP Queue Worker -After=network.target mysql.service - -[Service] -Type=simple -User=www-data -Group=www-data -WorkingDirectory=/var/www/myapp -ExecStart=/usr/bin/php bin/cake queue run -Restart=always -RestartSec=5 -Environment=APP_ENV=production - -[Install] -WantedBy=multi-user.target -``` - -Enable and start: - -```bash -sudo systemctl enable myapp-queue -sudo systemctl start myapp-queue -``` - -### Production (supervisor) - -Create `/etc/supervisor/conf.d/myapp-queue.conf`: - -```ini -[program:myapp-queue] -command=/usr/bin/php bin/cake queue run -directory=/var/www/myapp -user=www-data -autostart=true -autorestart=true -numprocs=2 -process_name=%(program_name)s_%(process_num)02d -stderr_logfile=/var/log/myapp/queue-error.log -stdout_logfile=/var/log/myapp/queue.log -``` - -### Production (Docker / docker-compose) - -When running queue workers in Docker containers, there are specific considerations: - -**The PID 1 Problem**: In Docker, the main process always runs as PID 1. The queue plugin tracks workers by `PID + server hostname`. If a container crashes and restarts, it tries to register with the same PID but the old record still exists, causing a duplicate key error. - -**Solution**: Set a stable hostname and clean up stale processes on startup: - -```yaml -services: - app: - image: your-app:latest - # ... your main app config - queue: - image: your-app:latest - hostname: queue-worker # Stable hostname across restarts - restart: unless-stopped - depends_on: - - app - volumes: - - ./:/app - working_dir: /app - command: sh -c "php bin/cake.php queue worker end all 2>/dev/null || true; php bin/cake.php queue run" - environment: - - APP_ENV=production -``` - -Key points: -- `hostname: queue-worker` ensures the server name stays consistent across container restarts (instead of using the random container ID) -- The startup command first cleans up any stale process records, then starts the worker -- `|| true` ensures the worker starts even if there are no stale processes to clean - -**Crontab-style with short-lived workers**: For behavior similar to traditional crontab (fresh workers, natural scaling), use `--max-runtime`: - -```yaml - queue: - image: your-app:latest - hostname: queue-worker - restart: always # Always restart after clean exit - command: sh -c "php bin/cake.php queue worker end all 2>/dev/null || true; php bin/cake.php queue run --max-runtime=300" + return $topic; + } +} ``` -This runs workers for 5 minutes, exits cleanly, and Docker restarts them. Scale with: +Use your existing authenticated and authorized `AppController`. Keep CSRF protection on the POST that queues work. `authorize()` sets the subscriber cookie for this topic; private updates are not made public by knowing their topic name. -```bash -docker compose up -d --scale queue=3 -``` - -### Scaling Workers +## Subscribe in the template -The `maxworkers` config limits concurrent workers across all servers: +Place this in `templates/Jobs/progress.php`: ```php -'Queue' => [ - 'maxworkers' => 4, -], +

Waiting for progress

+ ``` -Scale horizontally by running workers on multiple servers - they share the same database queue and respect `maxworkers`. - -For longer-running production workers, increase `workerLifetime`: +Mercure updates are not a substitute for querying the stored job status. A visitor can open this page after the job has finished, or miss updates during a reconnect. Add a status endpoint that checks job ownership and reads the job's current state. -```php -'Queue' => [ - 'workerLifetime' => 3600, // 1 hour (0 = unlimited) -], -``` +## Run and test workers -## Testing +Use [Getting started](./getting-started) to run a worker, and [Operations](./operations) to keep it running in production. `maxworkers` is a per-server cap. Give each container a distinct server identity; assigning the same hostname to every scaled container can cause PID collisions. -Mock the Mercure Publisher in tests to prevent HTTP requests: +The Mercure plugin provides `MockPublisher` for tests: ```php use Mercure\Publisher; use Mercure\TestSuite\MockPublisher; -public function setUp(): void { - parent::setUp(); - Publisher::setInstance(new MockPublisher()); -} - -public function tearDown(): void { - parent::tearDown(); - Publisher::clear(); -} +Publisher::setInstance(new MockPublisher()); ``` -## See Also - -- [josbeir/cakephp-mercure](https://github.com/josbeir/cakephp-mercure) - CakePHP Mercure plugin -- [Mercure Protocol](https://mercure.rocks/) - Real-time protocol -- [FrankenPHP](https://frankenphp.dev/) - PHP app server with built-in Mercure +Reset the publisher with `Publisher::clear()` in teardown. Test both database progress and the update payload, and check the authorized browser subscription against a real hub before deploying. diff --git a/docs/index.md b/docs/index.md index 04a2c881..bbd44e70 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,8 +10,11 @@ hero: alt: cakephp-queue actions: - theme: brand - text: Get Started - link: /guide/ + text: Installation + link: /guide/installation + - theme: alt + text: Getting started + link: /guide/getting-started - theme: alt text: Built-in Tasks link: /tasks/ @@ -25,16 +28,16 @@ hero: features: - icon: 💾 title: Database-Backed - details: No extra infrastructure — uses your existing database. Survives reboots, deploys, and worker crashes. + details: No extra infrastructure; uses your existing database. Survives reboots, deploys, and worker crashes. - icon: ⚙️ title: Admin Dashboard - details: Self-contained Bootstrap 5 admin UI for monitoring jobs, workers, and processes — with auth gate, multi-connection switcher, and stats. + details: Self-contained Bootstrap 5 admin UI for monitoring jobs, workers, and processes; with auth gate, multi-connection switcher, and stats. - icon: 🔁 title: Retries and Scheduling details: Configurable retries, priorities, notBefore scheduling, unique reference checks, and per-task timeout overrides. - icon: 🧩 title: Built-in Tasks - details: Shipped Email, Mailer, and Execute tasks cover the common cases — drop in and queue. + details: Shipped Email, Mailer, and Execute tasks cover the common cases; drop in and queue. - icon: 🌐 title: Multi-Connection details: Run separate queues against multiple database connections, each with its own worker pool. diff --git a/docs/reference/index.md b/docs/reference/index.md index d976fe9d..8699edb5 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -2,7 +2,7 @@ Operational references and notes. -- [Tips](/reference/tips) — IDE support, multi-server setup, killing workers, rate limiting, DTO usage. -- [Misc](/reference/misc) — logging, resetting/rerunning jobs, custom finders, lifecycle events, Sentry integration. -- [Limitations](/reference/limitations) — known concurrency limitations. -- [Upgrading](/reference/upgrading) — upgrade notes for v7 → v8 and beyond. +- [Tips](/reference/tips): IDE support, multi-server setup, killing workers, rate limiting, DTO usage. +- [Misc](/reference/misc): logging, resetting/rerunning jobs, custom finders, lifecycle events, Sentry integration. +- [Limitations](/reference/limitations): known concurrency limitations. +- [Upgrading](/reference/upgrading): upgrade notes for v7 → v8 and beyond. diff --git a/docs/reference/limitations.md b/docs/reference/limitations.md index a3d0a67b..b737ee54 100644 --- a/docs/reference/limitations.md +++ b/docs/reference/limitations.md @@ -1,9 +1,13 @@ -# Known Limitations +# Limitations -## Concurrent workers may execute the same job multiple times +## Jobs can execute more than once -If you want to use multiple workers, please double-check that all jobs have a high enough timeout (>> 2x max possible execution time of a job). Currently it would otherwise risk the jobs being run multiple times! +A crashed or timed-out worker can leave a claim that another worker later retries. If the original worker is still executing, both can perform the same operation. A claim also cannot make database updates and an external API call one atomic transaction. -## Concurrent workers may execute the same job type multiple times +Choose task timeouts above the maximum expected duration, within `defaultRequeueTimeout`, and make tasks safe to retry. Use stable operation IDs, database constraints, or the target service's idempotency support where appropriate. -If you need limiting of how many times a specific job type can be run in parallel, you need to find a custom solution here. +## Task concurrency + +A task's `unique` flag restricts concurrent execution of that task type. `rate` applies within each worker, and `costs` limits the per-server cost budget. These controls do not guarantee exactly-once effects and do not provide arbitrary per-tenant concurrency limits. + +The `unique` option passed to `createJob()` is separate: it deduplicates pending jobs with the same task and reference. It does not prevent a completed operation from being queued again. diff --git a/docs/reference/misc.md b/docs/reference/misc.md index caf63734..0bd36644 100644 --- a/docs/reference/misc.md +++ b/docs/reference/misc.md @@ -6,10 +6,10 @@ By default, errors are always logged, and with log enabled also the execution of Make sure you add this to your config: ```php 'Log' => [ - ... 'queue' => [ - 'className' => ..., - 'type' => 'queue', + 'className' => 'File', + 'path' => LOGS, + 'file' => 'queue', 'levels' => ['info'], 'scopes' => ['queue'], ], @@ -26,7 +26,7 @@ With web backend you can reset specific ones, as well. From CLI you run this to reset all at once: ``` -bin/cake queue reset +bin/cake queue job reset all ``` ## Rerunning @@ -35,17 +35,17 @@ Usually weeks or months is a good balance to have those still stored for this ca This is especially useful for local development or debugging, though. As you would otherwise have to manually trigger or import the job all the time. -From CLI you run this to rerun all of a specific job type at once: +From CLI, rerun a completed job by ID: ``` -bin/cake queue rerun FooBar +bin/cake queue job rerun 123 ``` -You can add a reference to rerun a specific job. +Use `bin/cake queue job rerun all` to rerun all retained completed jobs. The command does not accept a task type or reference filter. ## Using custom finder You can use a convenience finder for tasks that are still queued, that means not yet finished. ```php $queuedJobsTable = $this->fetchTable('Queue.QueuedJobs'); -$query = $queuedJobsTable->find('queued')->...; +$query = $queuedJobsTable->find('queued'); ``` This includes also failed ones if not filtered further using `where()` conditions. diff --git a/docs/reference/tips.md b/docs/reference/tips.md index cd5d23cd..d917cdc8 100644 --- a/docs/reference/tips.md +++ b/docs/reference/tips.md @@ -14,7 +14,6 @@ Otherwise, you can manually include the Queue plugin generator tasks in your `co use Queue\Generator\Task\QueuedJobTask; return [ - ... 'IdeHelper' => [ 'generatorTasks' => [ QueuedJobTask::class, @@ -65,7 +64,7 @@ bin/cake queue worker end all -q ``` To avoid further deployment issues, also try to keep the runtime per worker to only a few minutes. -You can additionally do this call at the end of the deployment script to make sure any workers started in the meantime +You can call this at the end of the deployment script to make sure any workers started in the meantime will also be aborting early. ### Ending workers per server @@ -99,9 +98,7 @@ Thus it is disabled by default for trivial use cases. ## Killing workers -**Important:** The `workerLifetime` setting cannot be set to 0 - it will throw a RuntimeException if you try. This is by design to prevent workers from running indefinitely and potentially overloading your server. If you need long-running workers, use a very large value instead (e.g., 86400 for 24 hours). - -Similarly, avoid setting `workerPhpTimeout` to 0 (unlimited). While technically allowed, this removes the last safeguard against process accumulation and can lead to server overload if workers start faster than they terminate. +Set `workerLifetime` to `0` for an unbounded worker, or use a bounded lifetime with a process manager that restarts it. On versions containing the [runtime fixes](./upgrading#unreleased-worker-fixes), `--max-runtime 0` also disables PHP's execution timeout. Set timeouts on external calls and plan graceful worker shutdown during deployment. Note: The old config names `workermaxruntime` and `workertimeout` are deprecated but still supported. @@ -135,7 +132,6 @@ Set up a DTO per task in your `dto.xml`, e.g. - ... ``` Instead of a plain array you can now rely on a clean API for input: diff --git a/docs/reference/upgrading.md b/docs/reference/upgrading.md index 3274fe76..c94b68b1 100644 --- a/docs/reference/upgrading.md +++ b/docs/reference/upgrading.md @@ -1,6 +1,6 @@ # Upgrading from older versions -## New Features +## Version changes ### Events for job lifecycle (v8.4+) A new event `Queue.Job.maxAttemptsExhausted` is now dispatched when a job has failed all of its configured retry attempts. This allows you to implement custom handling for permanently failed jobs, such as sending notifications or logging to external services. See the [Events section](/reference/misc#events) for usage details. @@ -25,5 +25,15 @@ Two follow-ups for callers: etc ## Coming from before v7? -If you are upgrading from Cake3/4 and v5/v6, make sure to install v7 firs, run all migrations, +If you are upgrading from Cake3/4 and v5/v6, make sure to install v7 first, run all migrations, then jump to v8. + +## Unreleased worker fixes + +`queue add`, `queue info` and `queue worker` now accept `--connection` and use the same configured default and whitelist as `queue run` and `queue job`. In multi-connection setups, commands and queue tables now use the first configured connection by default. It takes precedence over `Queue.connection`. Explicit connection selection also applies when selecting `default`. + +Workers without an explicit lifetime use `Config::workermaxruntime()`'s 120-second default. The same configured lifetime drives PHP's default timeout. `--max-runtime 0` now disables PHP's timeout as well as the worker loop limit. + +Failed-status handling accepts an incomplete job entity without using a null array key on PHP 8.5. + +Restart workers after schema migrations so their ORM table schemas reflect the deployed database. diff --git a/docs/tasks/email.md b/docs/tasks/email.md index 0c74d19a..30ac329a 100644 --- a/docs/tasks/email.md +++ b/docs/tasks/email.md @@ -19,8 +19,7 @@ $queuedJobsTable = TableRegistry::getTableLocator()->get('Queue.QueuedJobs'); $queuedJobsTable->createJob('Queue.Email', $data); ``` -This will send a plain email. Each settings key must have a matching setter method on the Message class. -The prefix `set` will be auto-added here when calling it. +This will send a plain email. Address settings are passed to the Mailer. Template, layout, theme and helper settings configure its view builder; other settings call the matching `set...()` method. ### Sending a templated email @@ -31,11 +30,12 @@ $data = [ 'to' => $user->email, 'from' => Configure::read('Config.adminEmail'), 'subject' => $subject, - 'template' => 'my_template', // Uses templates/email/html/my_template.php + 'template' => 'my_template', + 'emailFormat' => 'html', // Uses templates/email/html/my_template.php ], 'vars' => [ - 'user' => $user, - 'myEntity' => $myEntity, + 'user' => $user->toArray(), + 'myEntity' => $myEntity->toArray(), ], ]; $queuedJobsTable = TableRegistry::getTableLocator()->get('Queue.QueuedJobs'); @@ -60,8 +60,8 @@ For fields like `to` and `cc`, you can specify multiple recipients: ```php 'settings' => [ 'cc' => [ - ['user1@example.com', 'User One'], - ['user2@example.com', 'User Two'], + 'user1@example.com' => 'User One', + 'user2@example.com' => 'User Two', ], ], ``` @@ -88,7 +88,7 @@ $data = [ 'template' => 'my_template', 'helpers' => [['Shim.Configure']], ], - 'vars' => [...], + 'vars' => ['message' => 'Hello'], ]; ``` @@ -104,7 +104,7 @@ $data = [ 'layout' => 'custom_layout', 'theme' => 'MyTheme', ], - 'vars' => [...], + 'vars' => ['message' => 'Hello'], ]; ``` @@ -150,7 +150,7 @@ $data = [ You can override the default transport for a specific email: ```php $data = [ - 'settings' => [...], + 'settings' => ['to' => 'reader@example.com', 'from' => 'admin@example.com'], 'content' => $content, 'transport' => 'smtp', // Use a different transport configuration ]; @@ -163,13 +163,14 @@ $data = [ You can assemble a Message object manually and pass it as a serialized array: ```php use Cake\Mailer\Message; +use Cake\ORM\TableRegistry; use Queue\Queue\Task\EmailTask; $message = new Message(); $message->setTo($user->email) ->setFrom('admin@example.com') ->setSubject('Subject') - ->setBody('Message body'); + ->setBodyText('Message body'); $data = [ 'class' => Message::class, @@ -180,7 +181,7 @@ $queuedJobsTable = TableRegistry::getTableLocator()->get('Queue.QueuedJobs'); $queuedJobsTable->createJob('Queue.Email', $data); ``` -The Message object is serialized to an array, which is safe for database storage and survives code changes. +The message is stored as an array. Process pending messages before framework upgrades that change the serialized message format. Template variables also become JSON data; use arrays and scalar values rather than expecting entity objects in the worker. ### Passing a PHP-serialized Message object (deprecated) @@ -196,28 +197,29 @@ $data = [ **Warning:** This is not recommended as it breaks when the underlying class changes during framework upgrades. Make sure all jobs are processed before upgrading your CakePHP version. -## Using custom Message class +## Using a custom Message class -If you have a custom Message class (extending `Cake\Mailer\Message`), you can use it by specifying the class: +Build the complete message before serializing it. When `class` is supplied, the task sends the Message directly through the transport; separate `content`, `vars` and `headers` payload keys are not applied. ```php -use App\Mailer\Message; // Your custom Message class +use App\Mailer\Message; +use Cake\ORM\TableRegistry; +use Queue\Queue\Task\EmailTask; + +$message = new Message(); +$message->setTo('reader@example.com') + ->setFrom('admin@example.com') + ->setSubject('Notification') + ->setBodyText('Your notification is ready.'); -$data = [ - 'class' => Message::class, - 'settings' => [ - 'to' => $user->email, - 'from' => 'admin@example.com', - 'subject' => $subject, - ], - 'content' => $content, -]; $queuedJobsTable = TableRegistry::getTableLocator()->get('Queue.QueuedJobs'); -$queuedJobsTable->createJob('Queue.Email', $data); +$queuedJobsTable->createJob('Queue.Email', [ + 'class' => Message::class, + 'settings' => EmailTask::serialize($message), + 'serialized' => true, +]); ``` -This approach is JsonSerializer safe and works well with the queue system. - ## See also - [QueueTransport configuration](/guide/mailing#using-queuetransport) - Automatically queue all emails by changing transport configuration diff --git a/docs/tasks/index.md b/docs/tasks/index.md index 1bc46fab..e71d77f5 100644 --- a/docs/tasks/index.md +++ b/docs/tasks/index.md @@ -5,7 +5,7 @@ The Queue plugin ships with three ready-to-use tasks for the most common asynchr | Task | When to use | | --- | --- | | [Execute](/tasks/execute) | Run a shell command (e.g. `bin/cake importer run`) asynchronously, with allow-listing and per-token escaping. | -| [Email](/tasks/email) | Send an email built from `Cake\Mailer\Message` settings — most flexible, supports attachments, custom headers, custom Message classes. | -| [Mailer](/tasks/mailer) | Send an email via a reusable `Cake\Mailer\Mailer` class action — best for standardized emails (welcome, password reset, etc.). | +| [Email](/tasks/email) | Send an email built from `Cake\Mailer\Message` settings; most flexible, supports attachments, custom headers, custom Message classes. | +| [Mailer](/tasks/mailer) | Send an email via a reusable `Cake\Mailer\Mailer` class action; best for standardized emails (welcome, password reset, etc.). | For writing your own task class, see [Custom Tasks](/guide/custom-tasks). diff --git a/docs/tasks/mailer.md b/docs/tasks/mailer.md index bb3e73da..bd3740cd 100644 --- a/docs/tasks/mailer.md +++ b/docs/tasks/mailer.md @@ -1,106 +1,72 @@ -# Using built-in Mailer task +# Mailer task -The Mailer task is designed for sending reusable, templated emails using CakePHP's Mailer classes. This approach is ideal when you have standardized email types that you send frequently throughout your application. +`Queue.Mailer` runs an action on a CakePHP Mailer class. Queue a class name, action, and JSON-safe arguments; the worker calls the action and sends the resulting mail. -## What is a Mailer? +## Define the mailer -A Mailer class in CakePHP encapsulates email logic, templates, and configuration in a reusable way. Instead of configuring emails inline, you define actions in a Mailer class that handle specific email types. - -See [CakePHP Mailer documentation](https://book.cakephp.org/5/en/core-libraries/email.html#sending-emails-using-mailer) for more information about creating Mailer classes. - -## When to use Mailer task vs Email task - -- **Use Mailer task** when you have reusable email types defined in Mailer classes (e.g., welcome emails, password resets, notifications) -- **Use Email task** when you need more flexibility or one-off emails with dynamic configuration - -## Basic usage - -Create a Mailer class (if you don't have one already): +Create `src/Mailer/UserMailer.php`: ```php -// src/Mailer/UserMailer.php namespace App\Mailer; use Cake\Mailer\Mailer; -class UserMailer extends Mailer -{ - public function welcome($user) - { - $this - ->setTo($user->email) - ->setSubject('Welcome to our site') - ->setTemplate('welcome') // Uses templates/email/html/welcome.php +class UserMailer extends Mailer { + public function welcome(array $user): void { + $this->setFrom('no-reply@example.com') + ->setTo($user['email']) + ->setSubject('Welcome') + ->setEmailFormat('html') ->setViewVars(['user' => $user]); + $this->viewBuilder()->setTemplate('welcome'); } - public function passwordReset($user, $token) - { - $this - ->setTo($user->email) - ->setSubject('Password Reset Request') - ->setTemplate('password_reset') - ->setViewVars([ - 'user' => $user, - 'resetLink' => \Cake\Routing\Router::url([ - 'controller' => 'Users', - 'action' => 'resetPassword', - $token, - ], true), - ]); + public function passwordReset(array $user, string $resetUrl): void { + $this->setFrom('no-reply@example.com') + ->setTo($user['email']) + ->setSubject('Reset your password') + ->setEmailFormat('html') + ->setViewVars(['user' => $user, 'resetUrl' => $resetUrl]); + $this->viewBuilder()->setTemplate('password_reset'); } } ``` -Queue the email: +Use your application's sender address. Create the corresponding templates in `templates/email/html/`, and configure the transport used by the worker. Template variables in this example are arrays, not entities. + +## Queue an email + +From a controller: ```php use App\Mailer\UserMailer; -$data = [ +$queuedJobsTable = $this->fetchTable('Queue.QueuedJobs'); +$queuedJobsTable->createJob('Queue.Mailer', [ 'class' => UserMailer::class, 'action' => 'welcome', - 'vars' => [$user], // Arguments passed to the mailer action -]; -$queuedJobsTable = TableRegistry::getTableLocator()->get('Queue.QueuedJobs'); -$queuedJobsTable->createJob('Queue.Mailer', $data); + 'vars' => [['email' => $user->email, 'name' => $user->name]], +]); ``` -Since we're passing a class string and settings (not objects), this is JsonSerializer safe. - -## Advanced usage +`vars` is a positional argument list. The example's first argument is a plain array. Do not rely on an entity object retaining its class after JSON storage. For data that must be current when sending, pass an ID and fetch the record in your mailer or a custom task. -### Passing multiple arguments - -The `vars` array is passed as arguments to your Mailer action: +## Multiple arguments and transport ```php -$data = [ +$queuedJobsTable->createJob('Queue.Mailer', [ 'class' => UserMailer::class, 'action' => 'passwordReset', - 'vars' => [$user, $resetToken], // Passed as passwordReset($user, $resetToken) -]; -$queuedJobsTable->createJob('Queue.Mailer', $data); + 'vars' => [ + ['email' => $user->email], + $resetUrl, + ], + 'transport' => 'smtp', +]); ``` -### Overriding transport - -You can override the default transport for a specific email: - -```php -$data = [ - 'class' => UserMailer::class, - 'action' => 'welcome', - 'vars' => [$user], - 'transport' => 'smtp', // Use a different transport configuration -]; -$queuedJobsTable->createJob('Queue.Mailer', $data); -``` +Generate `$resetUrl` with your application logic and configure the named `smtp` transport. Without an override, the task selects `default` before running the mailer action; the action can change it. -## Benefits +Failures throw back to the worker and follow the configured task retry policy. A delivery can succeed before a worker crashes, so a retry can send the message again. See [Limitations](../reference/limitations). -- **Reusability**: Define email templates and logic once, use everywhere -- **Testability**: Easier to test Mailer classes in isolation -- **Maintainability**: Centralized email configuration and templates -- **Type safety**: Can type-hint parameters in Mailer actions -- **Auto-retry**: Failed emails are automatically retried based on your queue configuration +For a message assembled from settings or serialized data, use the [Email task](./email). For queue transports, see [Mailing](../guide/mailing). diff --git a/src/Command/AddCommand.php b/src/Command/AddCommand.php index 6aae0932..9abd6138 100644 --- a/src/Command/AddCommand.php +++ b/src/Command/AddCommand.php @@ -7,7 +7,9 @@ use Cake\Console\Arguments; use Cake\Console\ConsoleIo; use Cake\Console\ConsoleOptionParser; +use Cake\Datasource\ConnectionManager; use Queue\Console\Io; +use Queue\Queue\Config; use Queue\Queue\TaskFinder; class AddCommand extends Command { @@ -40,6 +42,10 @@ public function getOptionParser(): ConsoleOptionParser { 'help' => 'Additional data if needed', 'required' => false, ]); + $parser->addOption('connection', [ + 'help' => 'Database connection to use (must be in Queue.connections whitelist if multi-connection mode is enabled)', + 'default' => null, + ]); $parser->setDescription( 'Adds a job into the queue. Only tasks that implement AddInterface can be added through CLI.', ); @@ -70,11 +76,21 @@ public function execute(Arguments $args, ConsoleIo $io) { $io->abort('Not a supported task.'); } - /** @var class-string<\Queue\Queue\AddInterface> $taskClass */ + $requestedConnection = $args->getOption('connection'); + /** @var class-string<\Queue\Queue\Task&\Queue\Queue\AddInterface> $taskClass */ $taskClass = $tasks[$taskName]; - /** @var \Queue\Queue\AddInterface $task */ + /** @var \Queue\Queue\Task&\Queue\Queue\AddInterface $task */ $task = new $taskClass(new Io($io)); - $task->add($args->getArgument('data')); + $previousConnection = $task->QueuedJobs->getConnection(); + $connection = Config::resolveConnection(is_string($requestedConnection) ? $requestedConnection : null, $previousConnection->configName()); + /** @var \Cake\Database\Connection $connectionObject */ + $connectionObject = ConnectionManager::get($connection); + $task->QueuedJobs->setConnection($connectionObject); + try { + $task->add($args->getArgument('data')); + } finally { + $task->QueuedJobs->setConnection($previousConnection); + } } /** diff --git a/src/Command/InfoCommand.php b/src/Command/InfoCommand.php index 578457cb..fd0b0231 100644 --- a/src/Command/InfoCommand.php +++ b/src/Command/InfoCommand.php @@ -8,7 +8,9 @@ use Cake\Console\ConsoleIo; use Cake\Console\ConsoleOptionParser; use Cake\Core\Configure; +use Cake\Datasource\ConnectionManager; use Cake\I18n\Number; +use Queue\Queue\Config; use Queue\Queue\TaskFinder; /** @@ -46,6 +48,10 @@ public function getOptionParser(): ConsoleOptionParser { 'choices' => ['text', 'json'], 'default' => 'text', ]); + $parser->addOption('connection', [ + 'help' => 'Database connection to use (must be in Queue.connections whitelist if multi-connection mode is enabled)', + 'default' => null, + ]); $parser->setDescription( 'Get list of available tasks as well as current settings and statistics.', ); @@ -60,6 +66,14 @@ public function getOptionParser(): ConsoleOptionParser { * @return int|null|void */ public function execute(Arguments $args, ConsoleIo $io) { + $requestedConnection = $args->getOption('connection'); + $table = $this->fetchTable('Queue.QueueProcesses'); + $connection = Config::resolveConnection(is_string($requestedConnection) ? $requestedConnection : null, $this->fetchTable('Queue.QueuedJobs')->getConnection()->configName()); + /** @var \Cake\Database\Connection $connectionObject */ + $connectionObject = ConnectionManager::get($connection); + $table->setConnection($connectionObject); + $this->fetchTable('Queue.QueuedJobs')->setConnection($connectionObject); + if ($args->getOption('format') === 'json') { $io->out((string)json_encode($this->jsonInfo(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); diff --git a/src/Command/JobCommand.php b/src/Command/JobCommand.php index 1439f5b7..5b9d996f 100644 --- a/src/Command/JobCommand.php +++ b/src/Command/JobCommand.php @@ -7,14 +7,12 @@ use Cake\Console\Arguments; use Cake\Console\ConsoleIo; use Cake\Console\ConsoleOptionParser; -use Cake\Core\Configure; use Cake\Datasource\ConnectionManager; use Cake\Error\Debugger; use Cake\I18n\DateTime; use Queue\Model\Entity\QueuedJob; use Queue\Model\Table\QueuedJobsTable; use Queue\Queue\Config; -use RuntimeException; /** * @property \Queue\Model\Table\QueuedJobsTable $QueuedJobs @@ -88,15 +86,13 @@ public function execute(Arguments $args, ConsoleIo $io) { if (!is_string($connection)) { $connection = null; } - $this->connection = $this->resolveConnection($connection); $this->QueuedJobs = $this->fetchTable('Queue.QueuedJobs'); + $this->connection = $this->resolveConnection($connection); // Set connection for multi-connection support - if ($this->connection !== 'default') { - /** @var \Cake\Database\Connection $connectionObject */ - $connectionObject = ConnectionManager::get($this->connection); - $this->QueuedJobs->setConnection($connectionObject); - } + /** @var \Cake\Database\Connection $connectionObject */ + $connectionObject = ConnectionManager::get($this->connection); + $this->QueuedJobs->setConnection($connectionObject); $action = $args->getArgument('action'); if (!$action) { @@ -363,25 +359,7 @@ protected function clean(ConsoleIo $io): int { * @return string */ protected function resolveConnection(?string $connection): string { - $connections = Configure::read('Queue.connections'); - - // Single connection mode (backwards compatible) - if (!$connections || !is_array($connections) || count($connections) < 2) { - return $connection ?: 'default'; - } - - // Multi-connection mode - if ($connection === null) { - // Use first connection as default - return $connections[0]; - } - - // Validate against whitelist - if (!in_array($connection, $connections, true)) { - throw new RuntimeException(__d('queue', 'Invalid connection: {0}. Must be one of: {1}', $connection, implode(', ', $connections))); - } - - return $connection; + return Config::resolveConnection($connection, $this->QueuedJobs->getConnection()->configName()); } } diff --git a/src/Command/WorkerCommand.php b/src/Command/WorkerCommand.php index 0655e2e3..78c0523f 100644 --- a/src/Command/WorkerCommand.php +++ b/src/Command/WorkerCommand.php @@ -8,6 +8,7 @@ use Cake\Console\ConsoleIo; use Cake\Console\ConsoleOptionParser; use Cake\Core\Configure; +use Cake\Datasource\ConnectionManager; use Cake\I18n\DateTime; use Queue\Model\Table\QueueProcessesTable; use Queue\Queue\Config; @@ -66,6 +67,10 @@ public function getOptionParser(): ConsoleOptionParser { 'boolean' => true, ]); + $parser->addOption('connection', [ + 'help' => 'Database connection to use (must be in Queue.connections whitelist if multi-connection mode is enabled)', + 'default' => null, + ]); $parser->setDescription( 'Display, end or kill running workers.', ); @@ -80,6 +85,13 @@ public function getOptionParser(): ConsoleOptionParser { * @return int|null|void */ public function execute(Arguments $args, ConsoleIo $io) { + $requestedConnection = $args->getOption('connection'); + $table = $this->fetchTable('Queue.QueueProcesses'); + $connection = Config::resolveConnection(is_string($requestedConnection) ? $requestedConnection : null, $this->fetchTable('Queue.QueuedJobs')->getConnection()->configName()); + /** @var \Cake\Database\Connection $connectionObject */ + $connectionObject = ConnectionManager::get($connection); + $table->setConnection($connectionObject); + $action = $args->getArgument('action'); if (!$action) { $io->out('Please use with [action] [PID] added.'); diff --git a/src/Controller/Admin/QueueAppController.php b/src/Controller/Admin/QueueAppController.php index fcab548b..7ad159a9 100644 --- a/src/Controller/Admin/QueueAppController.php +++ b/src/Controller/Admin/QueueAppController.php @@ -14,6 +14,7 @@ use Cake\Log\Log; use Closure; use Psr\Http\Message\ResponseInterface; +use Queue\Queue\Config; use Throwable; /** @@ -167,7 +168,7 @@ protected function resolveConnection(): string { // Single connection mode (backwards compatible) if (!$connections || !is_array($connections) || count($connections) < 2) { - return 'default'; + return Config::resolveConnection(null, $this->fetchTable('Queue.QueuedJobs')->getConnection()->configName()); } $session = $this->request->getSession(); diff --git a/src/Model/Table/QueueProcessesTable.php b/src/Model/Table/QueueProcessesTable.php index bfb500d3..983ed3e8 100644 --- a/src/Model/Table/QueueProcessesTable.php +++ b/src/Model/Table/QueueProcessesTable.php @@ -3,7 +3,6 @@ namespace Queue\Model\Table; -use Cake\Core\Configure; use Cake\I18n\DateTime; use Cake\ORM\Locator\LocatorAwareTrait; use Cake\ORM\Query\SelectQuery; @@ -45,13 +44,7 @@ class QueueProcessesTable extends Table { * @return string */ public static function defaultConnectionName(): string { - /** @var string|null $connection */ - $connection = Configure::read('Queue.connection'); - if ($connection) { - return $connection; - } - - return parent::defaultConnectionName(); + return Config::resolveConnection(null); } /** diff --git a/src/Model/Table/QueuedJobsTable.php b/src/Model/Table/QueuedJobsTable.php index 3ea4c87b..306edd5b 100644 --- a/src/Model/Table/QueuedJobsTable.php +++ b/src/Model/Table/QueuedJobsTable.php @@ -117,12 +117,7 @@ class QueuedJobsTable extends Table { * @return string */ public static function defaultConnectionName(): string { - $connection = Configure::read('Queue.connection'); - if (!empty($connection)) { - return $connection; - } - - return parent::defaultConnectionName(); + return Config::resolveConnection(null); } /** @@ -1157,8 +1152,8 @@ public function getHeatmapData(string $field = 'created', int $days = 30, ?strin * @return \Cake\ORM\Query\SelectQuery */ public function getPendingStats(): SelectQuery { - $findCond = [ - 'fields' => [ + return $this->find() + ->select([ 'id', 'job_task', 'created', @@ -1171,11 +1166,8 @@ public function getPendingStats(): SelectQuery { 'attempts', 'failure_message', 'memory', - ], - 'conditions' => $this->pendingConditions(), - ]; - - return $this->find('all', ...$findCond); + ]) + ->where($this->pendingConditions()); } /** @@ -1241,8 +1233,8 @@ public function getNewCount(): int { * @return \Cake\ORM\Query\SelectQuery */ public function getScheduledStats(): SelectQuery { - $findCond = [ - 'fields' => [ + return $this->find() + ->select([ 'id', 'job_task', 'created', @@ -1255,14 +1247,11 @@ public function getScheduledStats(): SelectQuery { 'attempts', 'failure_message', 'memory', - ], - 'conditions' => [ + ]) + ->where([ 'completed IS' => null, 'notbefore >' => new DateTime(), - ], - ]; - - return $this->find('all', ...$findCond); + ]); } /** @@ -1289,8 +1278,8 @@ public function getScheduledCount(): int { * @return \Cake\ORM\Query\SelectQuery */ public function getAbortedStats(): SelectQuery { - $findCond = [ - 'fields' => [ + return $this->find() + ->select([ 'id', 'job_task', 'created', @@ -1303,12 +1292,9 @@ public function getAbortedStats(): SelectQuery { 'attempts', 'failure_message', 'memory', - ], - 'conditions' => $this->abortedConditions(), - 'order' => ['id' => 'DESC'], - ]; - - return $this->find('all', ...$findCond); + ]) + ->where($this->abortedConditions()) + ->orderByDesc('id'); } /** @@ -1379,6 +1365,10 @@ public function resetAborted(): int { public function getFailedStatus(QueuedJob $queuedTask, array $taskConfiguration): string { $failureMessageRequeued = 'requeued'; + if ($queuedTask->get('job_task') === null) { + return $failureMessageRequeued; + } + $queuedTaskName = $queuedTask->job_task; if (empty($taskConfiguration[$queuedTaskName])) { // Try with plugin prefix for backward compatibility (e.g. "Example" => "Queue.Example") diff --git a/src/Queue/Config.php b/src/Queue/Config.php index 9f0a3363..af50ace4 100644 --- a/src/Queue/Config.php +++ b/src/Queue/Config.php @@ -5,9 +5,35 @@ use Cake\Core\Configure; use InvalidArgumentException; +use RuntimeException; class Config { + /** + * Resolve a queue connection using the configured whitelist and default. + * + * @param string|null $connection Requested connection. + * @param string|null $fallback Existing table connection when no explicit connection is selected. + * + * @throws \RuntimeException If the connection is not allowed. + * + * @return string + */ + public static function resolveConnection(?string $connection, ?string $fallback = null): string { + $connections = Configure::read('Queue.connections'); + if (!is_array($connections) || count($connections) < 2) { + return $connection ?: ($fallback ?? (Configure::read('Queue.connection') ?: 'default')); + } + if ($connection === null) { + return $connections[0]; + } + if (!in_array($connection, $connections, true)) { + throw new RuntimeException(__d('queue', 'Invalid connection: {0}. Must be one of: {1}', $connection, implode(', ', $connections))); + } + + return $connection; + } + /** * Timeout in seconds, after which the Task is reassigned to a new worker * if not finished successfully. @@ -55,7 +81,7 @@ public static function workermaxruntime(): int { } } - return $runtime ?? 120; + return (int)($runtime ?? 120); } /** diff --git a/src/Queue/Processor.php b/src/Queue/Processor.php index 08eab99b..08a9b22f 100644 --- a/src/Queue/Processor.php +++ b/src/Queue/Processor.php @@ -115,19 +115,17 @@ public function __construct(Io $io, LoggerInterface $logger, ?ContainerInterface $this->io = $io; $this->logger = $logger; $this->container = $container; - $this->connection = $this->resolveConnection($connection); $tableLocator = $this->getTableLocator(); $this->QueuedJobs = $tableLocator->get('Queue.QueuedJobs'); $this->QueueProcesses = $tableLocator->get('Queue.QueueProcesses'); + $this->connection = $this->resolveConnection($connection); // Set connection for multi-connection support - if ($this->connection !== 'default') { - /** @var \Cake\Database\Connection $connectionObject */ - $connectionObject = ConnectionManager::get($this->connection); - $this->QueuedJobs->setConnection($connectionObject); - $this->QueueProcesses->setConnection($connectionObject); - } + /** @var \Cake\Database\Connection $connectionObject */ + $connectionObject = ConnectionManager::get($this->connection); + $this->QueuedJobs->setConnection($connectionObject); + $this->QueueProcesses->setConnection($connectionObject); } /** @@ -140,25 +138,7 @@ public function __construct(Io $io, LoggerInterface $logger, ?ContainerInterface * @return string */ protected function resolveConnection(?string $connection): string { - $connections = Configure::read('Queue.connections'); - - // Single connection mode (backwards compatible) - if (!$connections || !is_array($connections) || count($connections) < 2) { - return $connection ?: 'default'; - } - - // Multi-connection mode - if ($connection === null) { - // Use first connection as default - return $connections[0]; - } - - // Validate against whitelist - if (!in_array($connection, $connections, true)) { - throw new RuntimeException(__d('queue', 'Invalid connection: {0}. Must be one of: {1}', $connection, implode(', ', $connections))); - } - - return $connection; + return Config::resolveConnection($connection, $this->QueuedJobs->getConnection()->configName()); } /** @@ -463,13 +443,8 @@ protected function captureOutput(): bool { return $this->captureOutput; } - // Auto-detection path: re-check on every call rather than caching - // for the lifetime of the worker. A long-running worker that was - // started before `bin/cake migrations migrate` added the `output` - // column would otherwise see the old `false` for the rest of its - // runtime and silently drop captured stdout until restart. The - // per-call cost is a single schema lookup against a cached - // description, which is negligible compared to running a job. + // Read the table's current schema each time. The ORM can cache that schema, + // so workers should still restart after database migrations. try { return $this->QueuedJobs->getSchema()->hasColumn('output'); } catch (Throwable) { @@ -649,7 +624,7 @@ protected function stringToArray(string $param): array { * @return void */ protected function setPhpTimeout(?int $maxruntime): void { - if ($maxruntime) { + if ($maxruntime !== null) { set_time_limit($maxruntime * 2); return; @@ -671,7 +646,7 @@ protected function setPhpTimeout(?int $maxruntime): void { $timeLimit = (int)$phpTimeout; } else { // Default to workermaxruntime * 2 (or workerLifetime * 2 with new naming) - $workerLifetime = Configure::read('Queue.workerLifetime') ?? Configure::read('Queue.workermaxruntime', 60); + $workerLifetime = Config::workermaxruntime(); $timeLimit = (int)$workerLifetime * 2; } @@ -702,17 +677,10 @@ protected function computeLifetimeJitterOffset(): int { * @param int|null $maxruntime Max runtime in seconds if set via CLI option. * @param int $jitterOffset Per-worker random offset in seconds. * - * @throws \RuntimeException - * * @return int */ protected function resolveMaxRuntime(?int $maxruntime, int $jitterOffset): int { - $workerLifetime = Configure::read('Queue.workerLifetime') ?? Configure::read('Queue.workermaxruntime'); - if ($workerLifetime === null && $maxruntime === null) { - throw new RuntimeException('Queue.workerLifetime (or deprecated workermaxruntime) config is required'); - } - - $resolvedMaxRuntime = $maxruntime ?? (int)$workerLifetime; + $resolvedMaxRuntime = $maxruntime ?? Config::workermaxruntime(); if ($resolvedMaxRuntime <= 0 || $jitterOffset <= 0) { return (int)$resolvedMaxRuntime; } diff --git a/tests/TestCase/Command/AddCommandTest.php b/tests/TestCase/Command/AddCommandTest.php index e534877c..5244fc63 100644 --- a/tests/TestCase/Command/AddCommandTest.php +++ b/tests/TestCase/Command/AddCommandTest.php @@ -3,12 +3,26 @@ namespace Queue\Test\TestCase\Command; +use Cake\Console\ConsoleIo; use Cake\Console\TestSuite\ConsoleIntegrationTestTrait; +use Cake\Core\Configure; +use Cake\Database\Connection; +use Cake\Database\Driver\Sqlite; +use Cake\Datasource\ConnectionManager; +use Cake\Http\ServerRequest; +use Cake\TestSuite\Fixture\SchemaLoader; use Cake\TestSuite\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\UsesClass; +use Psr\Log\NullLogger; +use Queue\Command\AddCommand; +use Queue\Console\Io; +use Queue\Controller\Admin\QueueAppController; +use Queue\Model\Table\QueuedJobsTable; +use Queue\Queue\Processor; +use RuntimeException; -/** - * @uses \Queue\Command\AddCommand - */ +#[UsesClass(AddCommand::class)] class AddCommandTest extends TestCase { use ConsoleIntegrationTestTrait; @@ -18,6 +32,7 @@ class AddCommandTest extends TestCase { */ protected array $fixtures = [ 'plugin.Queue.QueuedJobs', + 'plugin.Queue.QueueProcesses', ]; /** @@ -52,4 +67,188 @@ public function testExecuteAddExample(): void { $this->assertExitCode(0); } + public function testAddToExplicitConnection(): void { + $this->createSecondaryConnection(); + Configure::write('Queue.connections', ['default', 'queue_add_test']); + $table = $this->fetchTable('Queue.QueuedJobs'); + $previousConnection = $table->getConnection(); + + $this->exec('queue add Queue.Example --connection queue_add_test'); + + $this->assertExitCode(0); + $this->assertSame(0, $table->find()->count()); + $this->assertSame(1, (int)ConnectionManager::get('queue_add_test')->execute('SELECT COUNT(*) FROM queued_jobs')->fetchColumn(0)); + $this->assertSame($previousConnection, $table->getConnection()); + } + + public function testAddUsesFirstConfiguredConnection(): void { + $this->createSecondaryConnection(); + Configure::write('Queue.connections', ['queue_add_test', 'default']); + + $this->exec('queue add Queue.Example'); + + $this->assertExitCode(0); + $this->assertSame(0, (int)ConnectionManager::get('default')->execute('SELECT COUNT(*) FROM queued_jobs')->fetchColumn(0)); + $this->assertSame('queue_add_test', $this->fetchTable('Queue.QueuedJobs')->getConnection()->configName()); + $this->assertSame(1, (int)ConnectionManager::get('queue_add_test')->execute('SELECT COUNT(*) FROM queued_jobs')->fetchColumn(0)); + } + + public function testProcessorPreservesTableConnectionAndHonorsExplicitDefault(): void { + $this->createSecondaryConnection(); + $secondary = ConnectionManager::get('queue_add_test'); + $jobs = $this->fetchTable('Queue.QueuedJobs'); + $processes = $this->fetchTable('Queue.QueueProcesses'); + $jobs->setConnection($secondary); + $processes->setConnection($secondary); + new Processor(new Io(new ConsoleIo()), new NullLogger()); + $this->assertSame($secondary, $jobs->getConnection()); + $this->assertSame($secondary, $processes->getConnection()); + + new Processor(new Io(new ConsoleIo()), new NullLogger(), null, 'default'); + $this->assertSame(ConnectionManager::get('default'), $jobs->getConnection()); + $this->assertSame(ConnectionManager::get('default'), $processes->getConnection()); + } + + public function testJobViewHonorsExplicitDefault(): void { + $this->createSecondaryConnection(); + $defaultJobs = new QueuedJobsTable([ + 'alias' => 'DefaultJobs', + 'connection' => ConnectionManager::get('default'), + ]); + $job = $defaultJobs->createJob('Queue.Example', ['message' => 'Primary proof']); + $this->fetchTable('Queue.QueuedJobs')->setConnection(ConnectionManager::get('queue_add_test')); + + $this->exec('queue job view ' . $job->id . ' --connection default'); + + $this->assertExitCode(0); + $this->assertOutputContains('Primary proof'); + } + + public function testAdminUsesLegacyQueueConnection(): void { + $this->createSecondaryConnection(); + Configure::write('Queue.connection', 'queue_add_test'); + $controller = new QueueAppController(new ServerRequest()); + + $this->assertSame('queue_add_test', $controller->viewBuilder()->getVar('queueActiveConnection')); + } + + #[DataProvider('selectedConnectionModes')] + public function testInfoReadsSelectedConnection(string $option, bool $legacyDefault): void { + $this->createSecondaryConnection(); + $table = new QueuedJobsTable([ + 'alias' => 'SecondaryJobs', + 'connection' => ConnectionManager::get('queue_add_test'), + ]); + $table->createJob('Queue.Example', ['message' => 'Secondary']); + + $this->fetchTable('Queue.QueuedJobs')->setConnection(ConnectionManager::get('queue_add_test')); + if ($legacyDefault) { + Configure::write('Queue.connection', 'default'); + } + $this->exec('queue info --format json' . $option); + + $this->assertExitCode(0); + $info = json_decode($this->_out->output(), true, flags: JSON_THROW_ON_ERROR); + $this->assertSame(1, $info['jobs']['unfinished']); + } + + #[DataProvider('selectedConnectionModes')] + public function testWorkerEndTargetsSelectedConnection(string $option, bool $legacyDefault): void { + $this->createSecondaryConnection(); + $this->fetchTable('Queue.QueueProcesses')->updateAll(['terminate' => false], []); + $connection = ConnectionManager::get('queue_add_test'); + $connection->execute("INSERT INTO queue_processes (pid, workerkey, created, modified, terminate) VALUES ('secondary', 'secondary', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)"); + + $this->fetchTable('Queue.QueuedJobs')->setConnection($connection); + if ($legacyDefault) { + Configure::write('Queue.connection', 'default'); + } + $this->exec('queue worker end all' . $option); + + $this->assertExitCode(0); + $this->assertSame(1, (int)$connection->execute('SELECT terminate FROM queue_processes')->fetchColumn(0)); + $this->assertSame(0, (int)ConnectionManager::get('default')->execute('SELECT terminate FROM queue_processes')->fetchColumn(0)); + } + + public function testAddPreservesPreconfiguredTableConnection(): void { + $this->createSecondaryConnection(); + Configure::write('Queue.connection', 'default'); + $this->fetchTable('Queue.QueuedJobs')->setConnection(ConnectionManager::get('queue_add_test')); + + $this->exec('queue add Queue.Example'); + + $this->assertExitCode(0); + $this->assertSame(1, (int)ConnectionManager::get('queue_add_test')->execute('SELECT COUNT(*) FROM queued_jobs')->fetchColumn(0)); + $this->assertSame(0, (int)ConnectionManager::get('default')->execute('SELECT COUNT(*) FROM queued_jobs')->fetchColumn(0)); + } + + public function testExplicitConnectionWithoutWhitelist(): void { + $this->createSecondaryConnection(); + + $this->exec('queue add Queue.Example --connection queue_add_test'); + + $this->assertExitCode(0); + $this->assertSame(1, (int)ConnectionManager::get('queue_add_test')->execute('SELECT COUNT(*) FROM queued_jobs')->fetchColumn(0)); + $this->assertSame(0, (int)ConnectionManager::get('default')->execute('SELECT COUNT(*) FROM queued_jobs')->fetchColumn(0)); + } + + public function testAddPreservesLegacyQueueConnection(): void { + $this->createSecondaryConnection(); + Configure::write('Queue.connection', 'queue_add_test'); + + $this->exec('queue add Queue.Example'); + + $this->assertExitCode(0); + $this->assertSame(1, (int)ConnectionManager::get('queue_add_test')->execute('SELECT COUNT(*) FROM queued_jobs')->fetchColumn(0)); + $this->assertSame(0, (int)ConnectionManager::get('default')->execute('SELECT COUNT(*) FROM queued_jobs')->fetchColumn(0)); + } + + public function testExplicitDefaultOverridesLegacyQueueConnection(): void { + $this->createSecondaryConnection(); + Configure::write('Queue.connection', 'queue_add_test'); + + $this->exec('queue add Queue.Example --connection default'); + + $this->assertExitCode(0); + $this->assertSame(0, (int)ConnectionManager::get('queue_add_test')->execute('SELECT COUNT(*) FROM queued_jobs')->fetchColumn(0)); + $this->assertSame(1, (int)ConnectionManager::get('default')->execute('SELECT COUNT(*) FROM queued_jobs')->fetchColumn(0)); + } + + public function testAddRejectsConnectionOutsideWhitelist(): void { + Configure::write('Queue.connections', ['default', 'queue_add_test']); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Invalid connection: not_allowed'); + + $this->exec('queue add Queue.Example --connection not_allowed'); + } + + /** + * @return array + */ + public static function selectedConnectionModes(): array { + return [ + 'explicit' => [' --connection queue_add_test', false], + 'table override' => ['', false], + 'table override with legacy default' => ['', true], + ]; + } + + protected function createSecondaryConnection(): void { + $this->skipIf(!extension_loaded('pdo_sqlite'), 'Requires SQLite for an isolated second database.'); + ConnectionManager::setConfig('queue_add_test', [ + 'className' => Connection::class, + 'driver' => Sqlite::class, + 'database' => ':memory:', + ]); + (new SchemaLoader())->loadInternalFile(TESTS . 'schema.php', 'queue_add_test'); + } + + public function tearDown(): void { + ConnectionManager::drop('queue_add_test'); + Configure::delete('Queue.connections'); + Configure::delete('Queue.connection'); + + parent::tearDown(); + } + } diff --git a/tests/TestCase/Command/BakeQueueTaskCommandTest.php b/tests/TestCase/Command/BakeQueueTaskCommandTest.php index 099492d9..f3a13f2b 100644 --- a/tests/TestCase/Command/BakeQueueTaskCommandTest.php +++ b/tests/TestCase/Command/BakeQueueTaskCommandTest.php @@ -5,11 +5,11 @@ use Cake\Console\TestSuite\ConsoleIntegrationTestTrait; use Cake\TestSuite\TestCase; +use PHPUnit\Framework\Attributes\UsesClass; +use Queue\Command\BakeQueueTaskCommand; use Shim\TestSuite\TestTrait; -/** - * @uses \Queue\Command\BakeQueueTaskCommand - */ +#[UsesClass(BakeQueueTaskCommand::class)] class BakeQueueTaskCommandTest extends TestCase { use TestTrait; diff --git a/tests/TestCase/Command/InfoCommandTest.php b/tests/TestCase/Command/InfoCommandTest.php index 4a6258b9..a7bf6b49 100644 --- a/tests/TestCase/Command/InfoCommandTest.php +++ b/tests/TestCase/Command/InfoCommandTest.php @@ -6,10 +6,11 @@ use Cake\Console\TestSuite\ConsoleIntegrationTestTrait; use Cake\Core\Configure; use Cake\TestSuite\TestCase; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; +use PHPUnit\Framework\Attributes\UsesClass; +use Queue\Command\InfoCommand; -/** - * @uses \Queue\Command\InfoCommand - */ +#[UsesClass(InfoCommand::class)] class InfoCommandTest extends TestCase { use ConsoleIntegrationTestTrait; @@ -47,6 +48,7 @@ public function testExecute(): void { * * @return void */ + #[IgnoreDeprecations] public function testExecuteWithNewConfigNames(): void { // Set up some config values using old names Configure::write('Queue.defaultworkertimeout', 300); diff --git a/tests/TestCase/Command/JobCommandTest.php b/tests/TestCase/Command/JobCommandTest.php index c14afd3a..4d3058e3 100644 --- a/tests/TestCase/Command/JobCommandTest.php +++ b/tests/TestCase/Command/JobCommandTest.php @@ -7,12 +7,12 @@ use Cake\Core\Configure; use Cake\I18n\DateTime; use Cake\TestSuite\TestCase; +use PHPUnit\Framework\Attributes\UsesClass; +use Queue\Command\JobCommand; use Queue\Model\Entity\QueuedJob; use Queue\Model\Table\QueuedJobsTable; -/** - * @uses \Queue\Command\JobCommand - */ +#[UsesClass(JobCommand::class)] class JobCommandTest extends TestCase { use ConsoleIntegrationTestTrait; diff --git a/tests/TestCase/Command/RunCommandTest.php b/tests/TestCase/Command/RunCommandTest.php index e9a99166..41120f89 100644 --- a/tests/TestCase/Command/RunCommandTest.php +++ b/tests/TestCase/Command/RunCommandTest.php @@ -7,10 +7,10 @@ use Cake\Core\Configure; use Cake\Datasource\ConnectionManager; use Cake\TestSuite\TestCase; +use PHPUnit\Framework\Attributes\UsesClass; +use Queue\Command\RunCommand; -/** - * @uses \Queue\Command\RunCommand - */ +#[UsesClass(RunCommand::class)] class RunCommandTest extends TestCase { use ConsoleIntegrationTestTrait; @@ -71,6 +71,20 @@ public function testServiceInjection(): void { $this->assertExitCode(0); } + public function testGettingStartedExample(): void { + $this->_needsConnection(); + Configure::write('Queue.exitwhennothingtodo', true); + + $this->exec('queue add Queue.Example'); + $this->assertExitCode(0); + $this->exec('queue run --max-runtime 20 --type Queue.Example -v'); + $this->assertExitCode(0); + $this->assertOutputContains('CakePHP Queue Example task.'); + $this->assertOutputContains('Job Finished.'); + $job = $this->fetchTable('Queue.QueuedJobs')->find()->firstOrFail(); + $this->assertNotNull($job->completed); + } + /** * Helper method for skipping tests that need a real connection. * diff --git a/tests/TestCase/Command/WorkerCommandTest.php b/tests/TestCase/Command/WorkerCommandTest.php index 437409b6..02d29111 100644 --- a/tests/TestCase/Command/WorkerCommandTest.php +++ b/tests/TestCase/Command/WorkerCommandTest.php @@ -5,10 +5,10 @@ use Cake\Console\TestSuite\ConsoleIntegrationTestTrait; use Cake\TestSuite\TestCase; +use PHPUnit\Framework\Attributes\UsesClass; +use Queue\Command\WorkerCommand; -/** - * @uses \Queue\Command\WorkerCommand - */ +#[UsesClass(WorkerCommand::class)] class WorkerCommandTest extends TestCase { use ConsoleIntegrationTestTrait; diff --git a/tests/TestCase/Controller/Admin/QueueAppControllerTest.php b/tests/TestCase/Controller/Admin/QueueAppControllerTest.php index 9ae6d0c6..4c7e25f1 100644 --- a/tests/TestCase/Controller/Admin/QueueAppControllerTest.php +++ b/tests/TestCase/Controller/Admin/QueueAppControllerTest.php @@ -6,12 +6,12 @@ use Cake\Core\Configure; use Cake\Http\Exception\ForbiddenException; use Cake\TestSuite\IntegrationTestTrait; +use PHPUnit\Framework\Attributes\UsesClass; +use Queue\Controller\Admin\QueueAppController; use RuntimeException; use Shim\TestSuite\TestCase; -/** - * @uses \Queue\Controller\Admin\QueueAppController - */ +#[UsesClass(QueueAppController::class)] class QueueAppControllerTest extends TestCase { use IntegrationTestTrait; diff --git a/tests/TestCase/Controller/Admin/QueueControllerTest.php b/tests/TestCase/Controller/Admin/QueueControllerTest.php index 0347fdb8..55efb5de 100644 --- a/tests/TestCase/Controller/Admin/QueueControllerTest.php +++ b/tests/TestCase/Controller/Admin/QueueControllerTest.php @@ -10,13 +10,12 @@ use Cake\Http\ServerRequest; use Cake\I18n\DateTime; use Cake\TestSuite\IntegrationTestTrait; +use PHPUnit\Framework\Attributes\UsesClass; use Queue\Controller\Admin\QueueController; use Shim\TestSuite\TestCase; use Tools\I18n\DateTime as ToolsDateTime; -/** - * @uses \Queue\Controller\Admin\QueueController - */ +#[UsesClass(QueueController::class)] class QueueControllerTest extends TestCase { use IntegrationTestTrait; diff --git a/tests/TestCase/Controller/Admin/QueueProcessesControllerTest.php b/tests/TestCase/Controller/Admin/QueueProcessesControllerTest.php index 0fc4c18c..35da833b 100644 --- a/tests/TestCase/Controller/Admin/QueueProcessesControllerTest.php +++ b/tests/TestCase/Controller/Admin/QueueProcessesControllerTest.php @@ -5,12 +5,12 @@ use Cake\I18n\DateTime; use Cake\TestSuite\IntegrationTestTrait; +use PHPUnit\Framework\Attributes\UsesClass; +use Queue\Controller\Admin\QueueProcessesController; use Queue\Model\Table\QueuedJobsTable; use Shim\TestSuite\TestCase; -/** - * @uses \Queue\Controller\Admin\QueueProcessesController - */ +#[UsesClass(QueueProcessesController::class)] class QueueProcessesControllerTest extends TestCase { use IntegrationTestTrait; diff --git a/tests/TestCase/Controller/Admin/QueuedJobsControllerTest.php b/tests/TestCase/Controller/Admin/QueuedJobsControllerTest.php index 6d13cc26..ec90ce2f 100644 --- a/tests/TestCase/Controller/Admin/QueuedJobsControllerTest.php +++ b/tests/TestCase/Controller/Admin/QueuedJobsControllerTest.php @@ -9,11 +9,11 @@ use Cake\I18n\DateTime; use Cake\TestSuite\IntegrationTestTrait; use Laminas\Diactoros\UploadedFile; +use PHPUnit\Framework\Attributes\UsesClass; +use Queue\Controller\Admin\QueuedJobsController; use Shim\TestSuite\TestCase; -/** - * @uses \Queue\Controller\Admin\QueuedJobsController - */ +#[UsesClass(QueuedJobsController::class)] class QueuedJobsControllerTest extends TestCase { use IntegrationTestTrait; diff --git a/tests/TestCase/Model/Table/QueuedJobsTableTest.php b/tests/TestCase/Model/Table/QueuedJobsTableTest.php index 102fd73f..49773723 100644 --- a/tests/TestCase/Model/Table/QueuedJobsTableTest.php +++ b/tests/TestCase/Model/Table/QueuedJobsTableTest.php @@ -17,6 +17,7 @@ use Cake\ORM\TableRegistry; use Cake\TestSuite\TestCase; use InvalidArgumentException; +use Queue\Model\Entity\QueuedJob; use Queue\Model\Enum\Priority; use Queue\Model\Table\QueuedJobsTable; use Queue\Queue\Task\ExampleTask; @@ -1251,4 +1252,15 @@ protected function _needsConnection() { $this->skipIf($skip, 'Only Mysql/Postgres is working yet for this.'); } + public function testFailedStatusWithNullTaskName(): void { + $job = new QueuedJob(['job_task' => null]); + $this->assertSame('requeued', $this->QueuedJobs->getFailedStatus($job, [])); + } + + public function testFailedStatusWithoutTaskName(): void { + $job = new QueuedJob(); + + $this->assertSame('requeued', $this->QueuedJobs->getFailedStatus($job, [])); + } + } diff --git a/tests/TestCase/Queue/ProcessorTest.php b/tests/TestCase/Queue/ProcessorTest.php index d7c024fa..14134dde 100644 --- a/tests/TestCase/Queue/ProcessorTest.php +++ b/tests/TestCase/Queue/ProcessorTest.php @@ -10,6 +10,7 @@ use Cake\Event\EventList; use Cake\Event\EventManager; use Cake\TestSuite\TestCase; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; use Psr\Log\NullLogger; use Queue\Console\Io; use Queue\Model\Entity\QueuedJob; @@ -56,18 +57,9 @@ public function setUp(): void { } /** - * @return void - */ - /** - * Regression: `captureOutput()` used to cache the schema check result - * for the lifetime of the worker. A long-running worker started before - * `migrations migrate` added the `output` column would then see the - * cached `false` for the rest of its runtime and silently drop output - * until restart. The auto-detect path must re-check on each call so - * a mid-flight migration takes effect on the next job. - * - * Explicit config (Configure::write('Queue.captureOutput', true|false)) - * is still memoized — only the auto-detection branch is per-call. + * Auto-detection uses the table's current schema on each call. The ORM can + * cache that schema, so workers still need restarting after migrations. + * Explicit captureOutput configuration is memoized. * * @return void */ @@ -253,14 +245,14 @@ public function testMaxAttemptsExhaustedEvent() { ->setConstructorArgs([new Io(new ConsoleIo($out, $err)), new NullLogger()]) ->onlyMethods(['run']) ->getMock(); - $mockTask->method('run')->willThrowException(new RuntimeException('Task failed')); + $mockTask->expects($this->once())->method('run')->willThrowException(new RuntimeException('Task failed')); // Mock only the loadTask method $processor = $this->getMockBuilder(Processor::class) ->setConstructorArgs([new Io(new ConsoleIo($out, $err)), new NullLogger()]) ->onlyMethods(['loadTask']) ->getMock(); - $processor->method('loadTask')->willReturn($mockTask); + $processor->expects($this->once())->method('loadTask')->willReturn($mockTask); // Run the job (it will fail and should trigger the event) $this->invokeMethod($processor, 'runJob', [$job, 'test-pid']); @@ -290,8 +282,7 @@ public function testWorkerTimeoutHandling() { } // Create a mock job - $job = $this->getMockBuilder(QueuedJob::class) - ->getMock(); + $job = new QueuedJob(); $job->id = 123; $job->job_task = 'TestTask'; @@ -425,7 +416,8 @@ public function testJobStartedEventFired(): void { ->onlyMethods(['run']) ->getMock(); - $processor->method('loadTask')->willReturn($mockTask); + $mockTask->expects($this->once())->method('run'); + $processor->expects($this->once())->method('loadTask')->willReturn($mockTask); $this->invokeMethod($processor, 'runJob', [$job, 'test-pid']); @@ -458,7 +450,8 @@ public function testJobCompletedEventFired(): void { ->onlyMethods(['run']) ->getMock(); - $processor->method('loadTask')->willReturn($mockTask); + $mockTask->expects($this->once())->method('run'); + $processor->expects($this->once())->method('loadTask')->willReturn($mockTask); $this->invokeMethod($processor, 'runJob', [$job, 'test-pid']); @@ -490,9 +483,9 @@ public function testJobFailedEventFired(): void { ->setConstructorArgs([new Io(new ConsoleIo($out, $err)), new NullLogger()]) ->onlyMethods(['run']) ->getMock(); - $mockTask->method('run')->willThrowException(new RuntimeException('Task failed')); + $mockTask->expects($this->once())->method('run')->willThrowException(new RuntimeException('Task failed')); - $processor->method('loadTask')->willReturn($mockTask); + $processor->expects($this->once())->method('loadTask')->willReturn($mockTask); $this->invokeMethod($processor, 'runJob', [$job, 'test-pid']); @@ -533,6 +526,7 @@ public function testSetPhpTimeoutWithNewConfig() { * * @return void */ + #[IgnoreDeprecations] public function testSetPhpTimeoutWithDeprecatedConfig() { $processor = new Processor(new Io(new ConsoleIo()), new NullLogger()); @@ -600,4 +594,30 @@ public function testComputeLifetimeJitterOffsetIgnoresNegative() { Configure::delete('Queue.workerLifetimeJitter'); } + public function testWorkerLifetimeUsesConfigurationDefault(): void { + Configure::delete('Queue.workerLifetime'); + Configure::delete('Queue.workermaxruntime'); + $processor = new Processor(new Io(new ConsoleIo()), new NullLogger()); + + $this->assertSame(120, $this->invokeMethod($processor, 'resolveMaxRuntime', [null, 0])); + } + + public function testStringLifetimeConfiguration(): void { + Configure::write('Queue.workerLifetime', '60'); + $processor = new Processor(new Io(new ConsoleIo()), new NullLogger()); + $this->assertSame(60, $this->invokeMethod($processor, 'resolveMaxRuntime', [null, 0])); + } + + public function testUnlimitedRuntimeDisablesPhpTimeout(): void { + $original = ini_get('max_execution_time'); + $processor = new Processor(new Io(new ConsoleIo()), new NullLogger()); + try { + $this->invokeMethod($processor, 'setPhpTimeout', [0]); + + $this->assertSame('0', ini_get('max_execution_time')); + } finally { + set_time_limit((int)$original); + } + } + }