fix(queue): coalesce queued container status and helper cleanup jobs per server

Queued GetContainersStatus runs are unique per server until a worker
starts them, and CleanupHelperContainersJob locks per server instead of
class-wide. Add UniqueUntilProcessingJobDecorator for laravel-actions.
This commit is contained in:
Andras Bacsai committed 2026-10-03 21:25:00 +02:00
1 parent e144b990c3
commit c681dc0d0e
5 files changed
+141 -1

No files matched your search

+27 -1
View File
@@ -16,17 +16,33 @@ use App\Models\ServiceDatabase;
use App\Notifications\Application\RestartLimitReached as ApplicationRestartLimitReached;
use App\Services\ContainerStatusAggregator;
use App\Services\RestartCountTracker;
use App\Support\Actions\UniqueUntilProcessingJobDecorator;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Lorisleiva\Actions\Concerns\AsAction;
use Lorisleiva\Actions\Decorators\UniqueJobDecorator;
class GetContainersStatus
/**
* Queued runs are coalesced per server: while one run waits in the queue, further dispatches are
* dropped because the waiting run reads the same container snapshot. The lock is released when a
* worker starts the run, so a request made during a run still queues one follow-up.
* Synchronous run() calls are not affected.
*/
class GetContainersStatus implements ShouldBeUnique
{
use AsAction;
public string $jobQueue = 'high';
public int $jobTimeout = 120;
/**
* Bounds how long a lost queued run (for example a killed worker) blocks new refreshes.
*/
public int $jobUniqueFor = 60;
public $applications;
public ?Collection $containers;
@@ -45,6 +61,16 @@ class GetContainersStatus
protected ?Collection $serviceContainerRestartCounts;
public static function makeUniqueJob(mixed ...$arguments): UniqueJobDecorator
{
return new UniqueUntilProcessingJobDecorator(static::class, ...$arguments);
}
public function getJobUniqueId(Server $server): string
{
return $server->uuid;
}
public function handle(Server $server, ?Collection $containers = null, ?Collection $containerReplicates = null)
{
$this->containers = $containers;
+13
View File
@@ -17,8 +17,21 @@ class CleanupHelperContainersJob implements ShouldBeEncrypted, ShouldBeUnique, S
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Release the per-server lock after an hour if a worker dies before finishing.
*/
public int $uniqueFor = 3600;
public function __construct(public Server $server) {}
/**
* Keep the lock per server; a class-wide lock queued only one server per cleanup run.
*/
public function uniqueId(): string
{
return $this->server->uuid;
}
private static function helperContainersCommand(): string
{
return 'docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image|test("(^|/)coollabsio/coolify-helper(:|@)")))\'';
@@ -0,0 +1,14 @@
<?php
namespace App\Support\Actions;
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
use Lorisleiva\Actions\Decorators\UniqueJobDecorator;
/**
* Queued action job whose unique lock is released when a worker starts it, not when it finishes.
*
* laravel-actions only provides a decorator that holds the lock until the job finishes. An action
* opts in by returning this decorator from makeUniqueJob().
*/
class UniqueUntilProcessingJobDecorator extends UniqueJobDecorator implements ShouldBeUniqueUntilProcessing {}
@@ -0,0 +1,26 @@
<?php
use App\Jobs\CleanupHelperContainersJob;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
$this->team = Team::factory()->create();
});
it('queues one helper container cleanup per server', function () {
Queue::fake();
$first = Server::factory()->create(['team_id' => $this->team->id]);
$second = Server::factory()->create(['team_id' => $this->team->id]);
CleanupHelperContainersJob::dispatch($first);
CleanupHelperContainersJob::dispatch($second);
CleanupHelperContainersJob::dispatch($first);
Queue::assertPushed(CleanupHelperContainersJob::class, 2);
});
@@ -0,0 +1,61 @@
<?php
use App\Actions\Docker\GetContainersStatus;
use App\Models\Server;
use App\Models\Team;
use App\Support\Actions\UniqueUntilProcessingJobDecorator;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
$this->team = Team::factory()->create();
});
afterEach(function () {
GetContainersStatus::clearFake();
});
it('queues one status refresh per server while one is waiting', function () {
Queue::fake();
$first = Server::factory()->create(['team_id' => $this->team->id]);
$second = Server::factory()->create(['team_id' => $this->team->id]);
GetContainersStatus::dispatch($first);
GetContainersStatus::dispatch($first);
GetContainersStatus::dispatch($second);
Queue::assertPushed(UniqueUntilProcessingJobDecorator::class, 2);
Queue::assertPushedOn('high', UniqueUntilProcessingJobDecorator::class);
Queue::assertPushed(UniqueUntilProcessingJobDecorator::class, fn ($job) => $job->timeout === 120);
});
it('accepts a new status refresh after the waiting one is lost', function () {
Queue::fake();
$server = Server::factory()->create(['team_id' => $this->team->id]);
GetContainersStatus::dispatch($server);
$this->travel(61)->seconds();
GetContainersStatus::dispatch($server);
Queue::assertPushed(UniqueUntilProcessingJobDecorator::class, 2);
});
it('queues a follow-up status refresh requested while one is running', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
$runs = 0;
GetContainersStatus::partialMock()
->shouldReceive('handle')
->andReturnUsing(function (Server $server) use (&$runs) {
$runs++;
if ($runs === 1) {
GetContainersStatus::dispatch($server);
}
});
GetContainersStatus::dispatch($server);
expect($runs)->toBe(2);
});