- CheckAndStartSentinelJob returns early when the server is not functional. It now reads the container state with `docker ps --filter`, so an SSH failure throws and is no longer treated as a missing container that needs a restart. - ServerManagerJob only dispatches the Sentinel version check for functional servers. - On cloud, ServerPatchCheckJob runs on the maintenance queue and ServerLimitCheckJob runs on the webhooks queue. - The default number of cloud maintenance workers goes from 10 to 60. - excludeCertainErrors drops its unused ignore list and is typed as `never`. - Docs and the Horizon config comments are updated, and tests cover the new behavior.
7.9 KiB
Coolify Cloud Horizon worker pools
Coolify Cloud runs Horizon on two app nodes that share one Redis. On Cloud
(SELF_HOSTED=false, APP_ENV=production), config/horizon.php provisions
one fixed-size supervisor per queue. A busy queue cannot starve the others.
| Supervisor | Queue | Env variable | Default per node |
|---|---|---|---|
deployments |
deployments |
HORIZON_DEPLOYMENTS_PROCESSES |
60 |
crons |
crons |
HORIZON_CRONS_PROCESSES |
60 |
high |
high |
HORIZON_HIGH_PROCESSES |
60 |
default |
default |
HORIZON_DEFAULT_PROCESSES |
40 |
maintenance |
maintenance |
HORIZON_MAINTENANCE_PROCESSES |
60 |
webhooks |
webhooks |
HORIZON_WEBHOOKS_PROCESSES |
10 |
- Counts apply to each node. Two nodes with the defaults run 580 workers.
maintenanceruns Docker cleanups (scheduled, manual, and the cleanup that runs after a resource stops) and the weekly server patch checks. Remote prunes and package checks can be slow, so this pool limits how many of them run at the same time and keeps them away from thehighworkers. It is added on top of the other pools. Each cleanup has a 600 s timeout, and only one cleanup runs per server at a time. Self-hosted instances keep cleanups onhigh.webhooksprocesses GitHub pull request webhooks, Stripe events, and the server limit checks that follow subscription changes, so a fullhighqueue cannot delay them. These jobs are short (60 s timeout or less). Self-hosted instances keep them onhigh.- Each pool has
minProcesses = maxProcessesandbalance = false. Horizon does not scale them. - A value that is not a positive integer falls back to the default.
HORIZON_QUEUES,HORIZON_MIN_PROCESSES,HORIZON_MAX_PROCESSES, and theHORIZON_BALANCE*variables have no effect on Cloud production. They still configure the self-hosteds6supervisor and thelocalenvironment.- Timeout, retry, memory, and recycling settings (
HORIZON_TIMEOUT,HORIZON_MAX_TIME,maxJobs,memory,tries) are the same as for self-hosted. - The config reads
SELF_HOSTEDwithenv()and does not callisCloud(), becauseisCloud()reads the config repository, which is not complete while config files load.
Sample Cloud configuration
Put these values in the .env of each node. The values are the defaults, so
set only the values that you change.
SELF_HOSTED=false
APP_ENV=production
HORIZON_DEPLOYMENTS_PROCESSES=60
HORIZON_CRONS_PROCESSES=60
HORIZON_HIGH_PROCESSES=60
HORIZON_DEFAULT_PROCESSES=40
HORIZON_MAINTENANCE_PROCESSES=60
HORIZON_WEBHOOKS_PROCESSES=10
# Worker timeout. Must stay above the longest job timeout (36000 s) and below
# retry_after (86400 s). The config clamps it to 36600..85800.
HORIZON_TIMEOUT=36600
Redis that holds queues must use maxmemory-policy noeviction. An evicting
policy deletes queued jobs without an error.
Worker shutdown, retry_after, and interrupted jobs
- Graceful (
php artisan horizon:terminate): workers stop taking new jobs and finish the current job.fast_terminationisfalse, so the master waits until the last worker exits. This can take up to the longest job timeout. Waiting jobs stay in Redis. The other node continues to process them. - Forced (SIGKILL, container stop after its grace period, OOM kill): the
running job stays in
queues:<queue>:reserved. Redis gives it to a worker again only afterretry_after(86400 s). Then the job has attempt 2:- Jobs with
tries = 1fail withMaxAttemptsExceededExceptionand theirfailed()method runs. - A
ScheduledTaskJobdoes not run its command again. It fails. - A scheduled occurrence that a killed worker claimed is marked
failed("interrupted") when it is older than the worker timeout plus 15 minutes. It is not run again, because part of its remote work can be done.
- Jobs with
- Do not lower
retry_afterbelow the worker timeout. A running job would then be given to a second worker.
Which settings need new workers
| Change | Needed action |
|---|---|
HORIZON_*_PROCESSES, HORIZON_TIMEOUT, queue names |
config:cache and horizon:terminate |
Horizon trim values |
config:cache and horizon:terminate |
retry_after (config/queue.php) |
config:cache and horizon:terminate |
| Application code (jobs, queues, middleware) | New image, then horizon:terminate |
| New database migration | php artisan migrate before new workers start |
Rollout
Do these steps on one node at a time. Do not flush Redis or clear queues.
-
Run the database migrations once (the new volume backup recovery columns must exist before the new scheduler runs):
docker exec coolify php artisan migrate --force -
Optional: set the
HORIZON_*_PROCESSESvariables in the node's.env. Remove manual pool overrides from earlier hotfixes. -
Rebuild the configuration cache:
docker exec coolify php artisan config:cache -
Restart Horizon on this node only.
horizon:terminatelets the running jobs finish, then s6 starts Horizon again with the new configuration:docker exec coolify php artisan horizon:terminate -
Make sure the six supervisors are running on this node:
docker exec coolify php artisan horizon:supervisors -
Make sure the
maintenanceandwebhooksqueues drain (its depth goes down) and thatphp artisan scheduled:diagnosticsshows no growing count of stale occurrences. -
Do the same steps on the next node.
Jobs that the old code put on high (for example Docker cleanups and webhook
jobs) stay on high, and the high pool still processes them.
Rollback
- Deploy the previous image, or remove the
HORIZON_*_PROCESSESoverrides. - Run
config:cacheandhorizon:terminateon one node at a time. - Jobs that wait on the
maintenanceorwebhooksqueue are not lost, but the old configuration has no pool for them. Keep one node with the new configuration until both queues are empty, or start a temporary worker:php artisan queue:work redis --queue=maintenance,webhooks --tries=1 --timeout=600. - Do not roll back the migration. The old code ignores the new columns.
Metrics to collect before you change worker counts
The incident samples are short and show only completed jobs. Collect these values before you change pool sizes:
- Arrival and completion rate per queue (per minute).
- Age of the oldest waiting job per queue (
LINDEX queues:<queue> -1, then readpushedAtfrom the payload). Do not scan the full list. - Queue depth (
LLEN queues:<queue>) and reserved age (ZRANGE queues:<queue>:reserved 0 0 WITHSCORES). - Active worker processes per supervisor (count processes, not the Horizon reserved count; reserved entries stay after forced restarts).
- Job duration distribution (p50/p95/max) and failure/retry rate per job class.
- Stale scheduled occurrences per status (
scheduled:diagnosticsand theopen_occurrences_over_15_minutesvalue inscheduled.log). - Redis
used_memory/maxmemory, the delta ofevicted_keys(INFO stats), and the delta oferrorstat_OOM(INFO errorstats). - Horizon metadata size: the count of
horizon:*job hashes and thehorizon:recent_jobs/horizon:completed_jobssizes.
Label metrics only by queue and job class. Do not label by resource ID or occurrence UUID.