Appearance
Queue module overview
Prerequisites
- Read Laravel Queues documentation.
- Read Laravel Horizon documentation.
TL;DR
Queues aim to run some code out of the request-response cycle. In order to do that, we need to have few separate PHP processes that work as daemons (“workers”) and a supervisor that will monitor these workers and re-run them if they fail or run of the memory limit.
Horizon is an all-in-one solution: workers, supervisors, queue configurators, and a dashboard for queues. There are few available drivers that allow for storing queued Jobs in different places (SQL DB, Redis, SQS or even sync that runs Jobs synchronously), but Horizon works with Redis only. Horizon can supervise workers, but still needs a system/OS supervisor to manage the master Horizon PHP process (artisan horizon) that supervises Horizon workers.
Use cases
Currently, we use Laravel queues (with a Redis driver) for processing jobs (incl. notifications) asynchronously.
Notifications
All notifications in the system, i.e. mails, SMS, Slack, and Postcard notifications are Queueable (ShouldQueue implementations) and thus being queued using a queue driver (currently in multiple queues).
Jobs
In order to be more responsive, we need to handle some jobs asynchronously. That’s where our redis queues comes in.
Events
We don’t have any async event handler, but we can add these to our infrastructure if needed.
Horizon
We use Horizon to handle queued jobs on all of our queues. When using Horizon, it’s simpler to setup queues with any number of workers, use different balancing strategies, have elastic worker processes being spawned by supervisor (it configures the supervisor instances automatically as well), and has a good GUI interface to see what’s going on (A lot of room for improvement on the usage of the dashboard).
Horizon console commands
To ensure the data on the Horizon dashboard stays up to date, it is recommended to use horizon:* commands to manage queues instead of Laravel’s queue:* commands. For example:
sh
// BAD
php artisan queue:clear redis --queue=notifications
// GOOD
php artisan horizon:clear redis --queue=notificationsHow it works under the hood
This is how queues looks like using htop (2023-09-27, production):
text
PID USER PRI NI VIRT RES SHR S CPU% MEM% TIME+ Command
1808 root 20 0 97168 130M 30872 S 0.7 0.8 0:00.85 │ /usr/bin/python /usr/bin/supervisord -n -c /etc/supervisor/supervisord.conf
31700 forge 20 0 491M 130M 30872 S 0.7 0.8 0:09.50 │ └─ php /home/forge/www.interaction-design.org/current/artisan horizon
31756 forge 20 0 490M 128M 31104 S 0.0 0.8 0:09.18 │ ├─ /usr/bin/php8.x artisan horizon:supervisor production-web-o7Er:supervisor--notifications redis --workers-name=default --balance=auto --max-processes=6 --min-processes=2 --nice=0 --balance-cooldown=3 --balance-max-shift=1 --parent-id=31700 --auto-scaling-strategy=time --backoff=0 --max-time=0 --max-jobs=0 --memory=128 --queue=notifications --sleep=3 --timeout=90 --tries=3 --rest=0
6821 forge 20 0 542M 113M 32712 S 0.0 0.7 0:02.06 │ │ ├─ /usr/bin/php8.x artisan horizon:work redis --name=default --supervisor=production-web-o7Er:supervisor--notifications --backoff=0 --max-time=0 --max-jobs=0 --memory=128 --queue=notifications --sleep=3 --timeout=90 --tries=3 --rest=0
5372 forge 20 0 555M 132M 38180 S 0.0 0.8 0:02.62 │ │ └─ /usr/bin/php8.x artisan horizon:work redis --name=default --supervisor=production-web-o7Er:supervisor--notifications --backoff=0 --max-time=0 --max-jobs=0 --memory=128 --queue=notifications --sleep=3 --timeout=90 --tries=3 --rest=0
31754 forge 20 0 489M 127M 30768 S 0.0 0.8 0:08.53 │ ├─ /usr/bin/php8.x artisan horizon:supervisor production-web-o7Er:supervisor--long redis-long --workers-name=default --balance= --max-processes=2 --min-processes=1 --nice=0 --balance-cooldown=3 --balance-max-shift=1 --parent-id=31700 --auto-scaling-strategy=time --backoff=0 --max-time=0 --max-jobs=0 --memory=128 --queue=default --sleep=3 --timeout=14400 --tries=2 --rest=0
5137 forge 20 0 458M 101M 31064 S 0.0 0.6 0:00.57 │ │ ├─ /usr/bin/php8.x artisan horizon:work redis-long --name=long-timeout --supervisor=production-web-o7Er:supervisor--long --backoff=0 --max-time=0 --max-jobs=0 --memory=128 --queue=long-timeout --sleep=3 --timeout=14400 --tries=2 --rest=0
3051 forge 20 0 540M 110M 32732 S 0.0 0.7 0:00.77 │ │ └─ /usr/bin/php8.x artisan horizon:work redis-long --name=long-timeout --supervisor=production-web-o7Er:supervisor--long --backoff=0 --max-time=0 --max-jobs=0 --memory=128 --queue=long-timeout --sleep=3 --timeout=14400 --tries=2 --rest=0
31752 forge 20 0 490M 128M 31008 S 0.0 0.8 0:09.11 │ ├─ /usr/bin/php8.x artisan horizon:supervisor production-web-o7Er:supervisor--priority redis --workers-name=default --balance= --max-processes=6 --min-processes=2 --nice=0 --balance-cooldown=3 --balance-max-shift=1 --parent-id=31700 --auto-scaling-strategy=time --backoff=0 --max-time=0 --max-jobs=0 --memory=128 --queue=high,default,low --sleep=3 --timeout=90 --tries=3 --rest=0
31774 forge 20 0 559M 130M 33048 S 0.0 0.8 0:03.07 │ │ ├─ /usr/bin/php8.x artisan horizon:work redis --name=default --supervisor=production-web-o7Er:supervisor--priority --backoff=0 --max-time=0 --max-jobs=0 --memory=128 --queue=high,default,low --sleep=3 --timeout=90 --tries=3 --rest=0
13288 forge 20 0 539M 108M 31880 S 0.0 0.7 0:02.00 │ │ ├─ /usr/bin/php8.x artisan horizon:work redis --name=default --supervisor=production-web-o7Er:supervisor--priority --backoff=0 --max-time=0 --max-jobs=0 --memory=128 --queue=high,default,low --sleep=3 --timeout=90 --tries=3 --rest=0
9097 forge 20 0 550M 122M 32912 S 0.0 0.8 0:02.23 │ │ ├─ /usr/bin/php8.x artisan horizon:work redis --name=default --supervisor=production-web-o7Er:supervisor--priority --backoff=0 --max-time=0 --max-jobs=0 --memory=128 --queue=high,default,low --sleep=3 --timeout=90 --tries=3 --rest=0
7771 forge 20 0 537M 107M 31880 S 0.0 0.7 0:01.97 │ │ ├─ /usr/bin/php8.x artisan horizon:work redis --name=default --supervisor=production-web-o7Er:supervisor--priority --backoff=0 --max-time=0 --max-jobs=0 --memory=128 --queue=high,default,low --sleep=3 --timeout=90 --tries=3 --rest=0
5138 forge 20 0 533M 103M 31792 S 0.0 0.6 0:00.65 │ │ ├─ /usr/bin/php8.x artisan horizon:work redis --name=default --supervisor=production-web-o7Er:supervisor--priority --backoff=0 --max-time=0 --max-jobs=0 --memory=128 --queue=high,default,low --sleep=3 --timeout=90 --tries=3 --rest=0
3029 forge 20 0 458M 101M 31184 S 0.0 0.6 0:00.69 │ │ └─ /usr/bin/php8.x artisan horizon:work redis --name=default --supervisor=production-web-o7Er:supervisor--priority --backoff=0 --max-time=0 --max-jobs=0 --memory=128 --queue=high,default,low --sleep=3 --timeout=90 --tries=3 --rest=0
31751 forge 25 5 489M 127M 31004 S 0.0 0.8 0:07.99 │ └─ /usr/bin/php8.x artisan horizon:supervisor production-web-o7Er:supervisor--sequential redis --workers-name=default --balance= --max-processes=1 --min-processes=1 --nice=5 --balance-cooldown=3 --balance-max-shift=1 --parent-id=31700 --auto-scaling-strategy=time --backoff=0 --max-time=0 --max-jobs=0 --memory=128 --queue=sequential--high,sequential--low --sleep=3 --timeout=90 --tries=2 --rest=0
31851 forge 25 5 460M 104M 31176 S 0.0 0.6 0:06.57 │ └─ /usr/bin/php8.x artisan horizon:work redis --name=default --supervisor=production-web-o7Er:supervisor--sequential --backoff=0 --max-time=0 --max-jobs=0 --memory=128 --queue=sequential--high,sequential--low --sleep=3 --timeout=90 --tries=2 --rest=0the same, simplified version
/usr/bin/python /usr/bin/supervisord -n -c /etc/supervisor/supervisord.conf
└─ horizon
├─ horizon:supervisor supervisor--priority redis --min-processes=2 --max-processes=6
│ ├─ horizon:work redis --queue=high,default,low --memory=128 --timeout=90
│ ├─ horizon:work redis --queue=high,default,low --memory=128 --timeout=90
├─ horizon:supervisor supervisor--notifications redis --min-processes=2 --max-processes=6
│ ├─ horizon:work redis --queue=notifications --memory=128 --timeout=90 --tries=3
│ └─ horizon:work redis --queue=notifications --memory=128 --timeout=90 --tries=3
├─ horizon:supervisor supervisor--sequential redis --min-processes=1 --max-processes=1
│ └─ horizon:work redis --queue=sequential--high,sequential--low --memory=128 --timeout=90
└─ horizon:supervisor supervisor--long redis-long --min-processes=1 --max-processes=2
├─ horizon:work redis-long --queue=long-timeout --memory=128 --timeout=14400
└─ horizon:work redis-long --queue=long-timeout --memory=128 --timeout=14400
As you can see:
- supervisord (via
/usr/bin/python /usr/bin/supervisord -n -c /etc/supervisor/supervisord.conf) spawns a singleartisan horizonmaster process. Note,/etc/supervisor/supervisord.confincludes - artisan horizon spawns
horizon:supervisorinstances as configured inhorizon.php - each
artisan horizon:supervisorinstance spawns a configured number ofhorizon:workworkers (seehorizon.defaults.*.processes, where*is a horizon supervisor name)
Retrying multiple jobs at once on Horizon
Horizon has a nice GUI for managing jobs and queues which can be accessed via https://www.interaction-design.org/admin/horizon. However, one thing it doesn't yet provide is a 'retry all failed jobs' action. If you find yourself needing to retry many jobs at once (e.g., if the mail provider API stopped responding for a period of time) you can open up the 'console' tab in your browser's developer tools and copy and paste the following:
javascript
document.querySelectorAll('table tr td:nth-child(4) > a').forEach((elem) => elem.click());That should select the 'retry' button on each row and click them all at once.
There's also another option, which retries all failed jobs at once. Open an SSH console to production, browse to the root project directory (cd ~/interaction-design.org/current), open a tinker session (php artisan tinker) and run the following:
php
\Illuminate\Support\Facades\DB::table('jobs--failed')
->where('failed_at', '>', now()->subDay()) // Add any condition
->pluck('uuid')
->mapInto(\Laravel\Horizon\Jobs\RetryFailedJob::class)
->each(fn ($j) => app()->call([$j, 'handle']))Laravel Queue Workers
We also have three (3) queue workers to run our default, high, and low queues. These processes are being handled by supervisor (number of processes, the command to run, restarts, etc.).
Connections
There are two redis connections for normal and long-running queued jobs/notifications/etc.
Note: I’m not sure why we need two connections, but I’ll try to find out and document it here.
Our queues
- Each connection has a
defaultqueue (which works as the fallback for everything that doesn’t have an explicit queue specified). - We use
lowqueue for low-priority jobs. This queue is tolerant for time-consuming and unstable jobs. All video-related jobs should use this queue. - We use
highqueue for high-priority jobs. Please do not push any long-running jobs to this queue. In order to make this queue fast, we have lower default number of tries per jobs. We usenotificationsqueue for all notifications. - We use the
long-timeoutqueue onredis-longconnection for any long-running job, with an extended timeout of 4 hours. - For debugging purposes, we may create some temp queues. The goal is to monitor queue size and performance issues. After some period of monitoring, we remove such queue and move all it’s jobs to other queues.
Balancing strategies
- auto: Scales the number of horizon workers (up & down) according to the load between all the queues
- simple: Simply divides the number of available processes between queues
- false: This acts like the default Laravel queue worker (order of declared queues matters)
Default configuration
- There’s a default configuration that applies to our staging/local environments. The default configuration has lower number of processes to reduce the load.
Supervisors on production
high,default, andlowqueues are all run by a single supervisor. The reason is to be able to use thefalsebalancing strategy so that ourhighqueue has the highest priority.notificationshave their own separate supervisor. This will enable auto-scaling ability so that we can spawn more processes when load increases.defaultqueue onredis-longhas a separate supervisor. The reason is to have an extended timeout (4 hours).
Number of processes
- In order to be able to increase/decrease the min/max number of processes in production, multiple environment variables are introduced. These include: --
HORIZON_MIN_PROCESSES: minimum number of processes forsupervisor--priorityandsupervisor--notificationsHorizon supervisors --HORIZON_MAX_PROCESSES: maximum number of processes forsupervisor--priorityandsupervisor--notificationsHorizon supervisors
When to create a new queue
- You need special limits for memory or timeouts
- You need special defaults for a group of Jobs (ex.: number of tries)
How to wipe all queues
In non-production environments, such as staging, it can be useful to wipe all queues and start fresh. You can use horizon:wipe command to do that. Internally it makes use of horizon:clear command and will clear all jobs from all queues across all connections.