fix(horizon): route Sentinel push jobs to a dedicated cloud sentinel queue

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-11-05 22:49:56 +01:00
co-authored by Claude Opus 5.5
parent 19762a7950
commit 9a6cc8316e
7 changed files with 42 additions and 12 deletions
+2
View File
@@ -44,6 +44,8 @@ DB_PORT=5432
# HORIZON_MAINTENANCE_PROCESSES=10
# GitHub pull request webhooks and Stripe events.
# HORIZON_WEBHOOKS_PROCESSES=10
# Sentinel pushes (PushServerUpdateJob).
# HORIZON_SENTINEL_PROCESSES=20
# Server-Timing headers + on-screen HUD (defaults ON when APP_ENV=local).
# Force on in any environment (including production): SERVER_TIMING_ENABLED=true
+1
View File
@@ -132,6 +132,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
public function __construct(public Server $server, public $data)
{
$this->onQueue(sentinel_queue());
$this->containers = collect();
$this->foundApplicationIds = collect();
$this->foundDatabaseUuids = collect();
+12
View File
@@ -1133,6 +1133,18 @@ function webhooks_queue(): string
return isCloud() ? 'webhooks' : 'high';
}
/**
* Resolve the queue for PushServerUpdateJob, which processes Sentinel pushes.
*
* On cloud these jobs run on a dedicated `sentinel` queue, so a burst of pushes from many
* servers cannot use the workers of the other queues. Self-hosted returns null, so the jobs
* stay on the default queue of the connection, as before.
*/
function sentinel_queue(): ?string
{
return isCloud() ? 'sentinel' : null;
}
function translate_cron_expression($expression_to_validate): string
{
if (isset(VALID_CRON_STRINGS[$expression_to_validate])) {
+5
View File
@@ -46,6 +46,10 @@ $selfHostedSupervisor = $workerOptions + [
| The `webhooks` pool (see webhooks_queue()) processes GitHub pull request
| webhooks, Stripe events, and server limit checks, so other jobs cannot
| delay them.
|
| The `sentinel` pool (see sentinel_queue()) processes Sentinel pushes
| (PushServerUpdateJob), so a push burst from many servers cannot take the
| workers of the other pools.
*/
$cloudQueues = [
'deployments' => ['HORIZON_DEPLOYMENTS_PROCESSES', 60],
@@ -54,6 +58,7 @@ $cloudQueues = [
'default' => ['HORIZON_DEFAULT_PROCESSES', 40],
'maintenance' => ['HORIZON_MAINTENANCE_PROCESSES', 60],
'webhooks' => ['HORIZON_WEBHOOKS_PROCESSES', 10],
'sentinel' => ['HORIZON_SENTINEL_PROCESSES', 20],
];
$cloudSupervisors = [];
+11 -6
View File
@@ -12,8 +12,9 @@ one fixed-size supervisor per queue. A busy queue cannot starve the others.
| `default` | `default` | `HORIZON_DEFAULT_PROCESSES` | 40 |
| `maintenance` | `maintenance` | `HORIZON_MAINTENANCE_PROCESSES` | 60 |
| `webhooks` | `webhooks` | `HORIZON_WEBHOOKS_PROCESSES` | 10 |
| `sentinel` | `sentinel` | `HORIZON_SENTINEL_PROCESSES` | 20 |
- Counts apply to **each node**. Two nodes with the defaults run 580 workers.
- Counts apply to **each node**. Two nodes with the defaults run 620 workers.
- `maintenance` runs 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
@@ -24,6 +25,9 @@ one fixed-size supervisor per queue. A busy queue cannot starve the others.
server limit checks that follow subscription changes, so a full `high`
queue cannot delay them. These jobs are short (60 s timeout or
less). Self-hosted instances keep them on `high`.
- `sentinel` processes Sentinel pushes (`PushServerUpdateJob`), so a burst
of pushes from many servers cannot take the workers of the other pools.
Self-hosted instances keep them on the default queue.
- Each pool has `minProcesses = maxProcesses` and `balance = false`. Horizon
does not scale them.
- A value that is not a positive integer falls back to the default.
@@ -51,6 +55,7 @@ HORIZON_HIGH_PROCESSES=60
HORIZON_DEFAULT_PROCESSES=40
HORIZON_MAINTENANCE_PROCESSES=60
HORIZON_WEBHOOKS_PROCESSES=10
HORIZON_SENTINEL_PROCESSES=20
# 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
@@ -114,13 +119,13 @@ Do these steps on one node at a time. Do not flush Redis or clear queues.
docker exec coolify php artisan horizon:terminate
```
5. Make sure the six supervisors are running on this node:
5. Make sure the seven supervisors are running on this node:
```bash
docker exec coolify php artisan horizon:supervisors
```
6. Make sure the `maintenance` and `webhooks` queues drain (its depth goes down) and that
6. Make sure the `maintenance`, `webhooks`, and `sentinel` queues drain (their depth goes down) and that
`php artisan scheduled:diagnostics` shows no growing count of stale
occurrences.
7. Do the same steps on the next node.
@@ -132,10 +137,10 @@ jobs) stay on `high`, and the `high` pool still processes them.
1. Deploy the previous image, or remove the `HORIZON_*_PROCESSES` overrides.
2. Run `config:cache` and `horizon:terminate` on one node at a time.
3. Jobs that wait on the `maintenance` or `webhooks` queue are not lost, but
3. Jobs that wait on the `maintenance`, `webhooks`, or `sentinel` queue 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`.
configuration until these queues are empty, or start a temporary worker:
`php artisan queue:work redis --queue=maintenance,webhooks,sentinel --tries=1 --timeout=600`.
4. Do not roll back the migration. The old code ignores the new columns.
## Metrics to collect before you change worker counts
+4 -3
View File
@@ -98,12 +98,13 @@ test('cloud production runs one fixed-size pool per queue with default process c
'HORIZON_DEFAULT_PROCESSES' => null,
'HORIZON_MAINTENANCE_PROCESSES' => null,
'HORIZON_WEBHOOKS_PROCESSES' => null,
'HORIZON_SENTINEL_PROCESSES' => null,
]));
$production = $parsed['production'];
expect(array_keys($production))->toBe(['deployments', 'crons', 'high', 'default', 'maintenance', 'webhooks']);
expect(array_keys($production))->toBe(['deployments', 'crons', 'high', 'default', 'maintenance', 'webhooks', 'sentinel']);
foreach (['deployments' => 60, 'crons' => 60, 'high' => 60, 'default' => 40, 'maintenance' => 60, 'webhooks' => 10] as $queue => $processes) {
foreach (['deployments' => 60, 'crons' => 60, 'high' => 60, 'default' => 40, 'maintenance' => 60, 'webhooks' => 10, 'sentinel' => 20] as $queue => $processes) {
$pool = $production[$queue];
expect($pool->queue)->toBe($queue)
->and($pool->balancing())->toBeFalse()
@@ -152,7 +153,7 @@ test('cloud queue helpers route to queues that have a dedicated pool', function
$production = horizonSupervisorsFor(loadHorizonConfigWithEnv(['SELF_HOSTED' => 'false']))['production'];
expect($production)->toHaveKeys([deployment_queue(), crons_queue(), maintenance_queue(), webhooks_queue()]);
expect($production)->toHaveKeys([deployment_queue(), crons_queue(), maintenance_queue(), webhooks_queue(), sentinel_queue()]);
});
test('self-hosted queue helpers route to a queue the s6 supervisor drains', function () {
@@ -221,11 +221,15 @@ it('ends the proxy restart when the proxy status listener runs', function () {
expect($server->isProxyTransitionInProgress())->toBeFalse();
});
it('uses default queue for PushServerUpdateJob', function () {
it('runs PushServerUpdateJob on the sentinel queue on cloud and on the default queue on self-hosted', function (bool $selfHosted, ?string $queue) {
config(['constants.coolify.self_hosted' => $selfHosted]);
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
$job = new PushServerUpdateJob($server, ['containers' => []]);
expect($job->queue)->toBeNull();
});
expect($job->queue)->toBe($queue);
})->with([
'cloud' => [false, 'sentinel'],
'self-hosted' => [true, null],
]);