From 3575234b384553e567a2dbc4641308b086bc131d Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:25:17 +0200 Subject: [PATCH] fix(docker-cleanup): clean after a resource stop only when the disk needs it A stop or delete no longer queues a full cleanup each time. It skips the cleanup when one finished on the server in the last hour, and the queued run only cleans when disk usage is at or above the server cleanup threshold. The docker_cleanup API flag keeps its default of true. --- app/Actions/Application/StopApplication.php | 2 +- app/Actions/Database/StopDatabase.php | 2 +- app/Actions/Server/CleanupDocker.php | 36 ++++++++++++++++++- app/Actions/Service/StopService.php | 2 +- .../Api/ApplicationsController.php | 4 +-- .../Controllers/Api/DatabasesController.php | 4 +-- .../Controllers/Api/ServicesController.php | 4 +-- openapi.json | 12 +++---- openapi.yaml | 12 +++---- tests/Feature/DockerCleanupJobTest.php | 34 ++++++++++++++++++ 10 files changed, 90 insertions(+), 22 deletions(-) diff --git a/app/Actions/Application/StopApplication.php b/app/Actions/Application/StopApplication.php index af649692b2..9ac26b243c 100644 --- a/app/Actions/Application/StopApplication.php +++ b/app/Actions/Application/StopApplication.php @@ -58,7 +58,7 @@ class StopApplication } if ($dockerCleanup) { - CleanupDocker::dispatch($server, false, false); + CleanupDocker::dispatchAfterStop($server); } } catch (\Exception $e) { $errors[] = $e->getMessage(); diff --git a/app/Actions/Database/StopDatabase.php b/app/Actions/Database/StopDatabase.php index e830678fb1..ecdffdeda3 100644 --- a/app/Actions/Database/StopDatabase.php +++ b/app/Actions/Database/StopDatabase.php @@ -41,7 +41,7 @@ class StopDatabase } if ($dockerCleanup) { - CleanupDocker::dispatch($server, false, false); + CleanupDocker::dispatchAfterStop($server); } if ($database->is_public) { diff --git a/app/Actions/Server/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php index cde75e30de..201f86b367 100644 --- a/app/Actions/Server/CleanupDocker.php +++ b/app/Actions/Server/CleanupDocker.php @@ -7,6 +7,7 @@ use App\Support\Actions\UniqueUntilProcessingJobDecorator; use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Cache; use Lorisleiva\Actions\Concerns\AsAction; use Lorisleiva\Actions\Decorators\JobDecorator; use Lorisleiva\Actions\Decorators\UniqueJobDecorator; @@ -34,6 +35,12 @@ class CleanupDocker implements ShouldBeUnique */ public const QUEUED_DELAY = 60; + /** + * A resource stop does not queue a cleanup when a cleanup finished on the server in this + * window. The scheduled cleanup still runs. + */ + public const STOP_CLEANUP_COOLDOWN = 3600; + public int $jobTries = 1; /** @@ -71,6 +78,24 @@ class CleanupDocker implements ShouldBeUnique return new UniqueUntilProcessingJobDecorator(static::class, ...$arguments); } + public static function lastRunCacheKey(Server $server): string + { + return 'docker-cleanup-last-run-'.$server->uuid; + } + + /** + * Cleanup after a resource stop: skipped when a cleanup ran recently, and the queued run + * only cleans when the disk usage is at or above the server cleanup threshold. + */ + public static function dispatchAfterStop(Server $server): void + { + if (Cache::has(self::lastRunCacheKey($server))) { + return; + } + + static::dispatch($server, false, false, true); + } + public function getJobUniqueId(Server $server): string { return $server->uuid; @@ -95,9 +120,16 @@ class CleanupDocker implements ShouldBeUnique ]; } - public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $deleteUnusedNetworks = false) + public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $deleteUnusedNetworks = false, bool $onlyAboveThreshold = false) { $this->deadline = now()->getTimestamp() + self::REMOTE_COMMANDS_DEADLINE; + + if ($onlyAboveThreshold) { + $diskUsage = $server->getDiskUsage(); + if (is_numeric($diskUsage) && (int) $diskUsage < $server->settings->docker_cleanup_threshold) { + return []; + } + } $helperImageVersion = getHelperVersion(); $helperImage = coolifyHelperImage(); $helperImageWithVersion = "$helperImage:$helperImageVersion"; @@ -155,6 +187,8 @@ class CleanupDocker implements ShouldBeUnique } } + Cache::put(self::lastRunCacheKey($server), true, self::STOP_CLEANUP_COOLDOWN); + return $cleanupLog; } diff --git a/app/Actions/Service/StopService.php b/app/Actions/Service/StopService.php index f9d65d1ed1..ce6d7e25ff 100644 --- a/app/Actions/Service/StopService.php +++ b/app/Actions/Service/StopService.php @@ -62,7 +62,7 @@ class StopService $service->deleteConnectedNetworks(); } if ($dockerCleanup) { - CleanupDocker::dispatch($server, false, false); + CleanupDocker::dispatchAfterStop($server); } } catch (\Exception $e) { return $e->getMessage(); diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 1b3ba70694..ba762aff74 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -2926,7 +2926,7 @@ class ApplicationsController extends Controller ), new OA\Parameter(name: 'delete_configurations', in: 'query', required: false, description: 'Delete configurations.', schema: new OA\Schema(type: 'boolean', default: true)), new OA\Parameter(name: 'delete_volumes', in: 'query', required: false, description: 'Delete volumes.', schema: new OA\Schema(type: 'boolean', default: true)), - new OA\Parameter(name: 'docker_cleanup', in: 'query', required: false, description: 'Run docker cleanup.', schema: new OA\Schema(type: 'boolean', default: true)), + new OA\Parameter(name: 'docker_cleanup', in: 'query', required: false, description: 'Run docker cleanup when the server disk usage is at or above its cleanup threshold. Skipped when a cleanup ran on the server in the last hour.', schema: new OA\Schema(type: 'boolean', default: true)), new OA\Parameter(name: 'delete_connected_networks', in: 'query', required: false, description: 'Delete connected networks.', schema: new OA\Schema(type: 'boolean', default: true)), ], responses: [ @@ -4553,7 +4553,7 @@ class ApplicationsController extends Controller new OA\Parameter( name: 'docker_cleanup', in: 'query', - description: 'Perform docker cleanup (prune networks, volumes, etc.).', + description: 'Run docker cleanup when the server disk usage is at or above its cleanup threshold. Skipped when a cleanup ran on the server in the last hour.', schema: new OA\Schema( type: 'boolean', default: true, diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 91c68d6e4c..ed3e18ae17 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -2738,7 +2738,7 @@ class DatabasesController extends Controller ), new OA\Parameter(name: 'delete_configurations', in: 'query', required: false, description: 'Delete configurations.', schema: new OA\Schema(type: 'boolean', default: true)), new OA\Parameter(name: 'delete_volumes', in: 'query', required: false, description: 'Delete volumes.', schema: new OA\Schema(type: 'boolean', default: true)), - new OA\Parameter(name: 'docker_cleanup', in: 'query', required: false, description: 'Run docker cleanup.', schema: new OA\Schema(type: 'boolean', default: true)), + new OA\Parameter(name: 'docker_cleanup', in: 'query', required: false, description: 'Run docker cleanup when the server disk usage is at or above its cleanup threshold. Skipped when a cleanup ran on the server in the last hour.', schema: new OA\Schema(type: 'boolean', default: true)), new OA\Parameter(name: 'delete_connected_networks', in: 'query', required: false, description: 'Delete connected networks.', schema: new OA\Schema(type: 'boolean', default: true)), ], responses: [ @@ -3428,7 +3428,7 @@ class DatabasesController extends Controller new OA\Parameter( name: 'docker_cleanup', in: 'query', - description: 'Perform docker cleanup (prune networks, volumes, etc.).', + description: 'Run docker cleanup when the server disk usage is at or above its cleanup threshold. Skipped when a cleanup ran on the server in the last hour.', schema: new OA\Schema( type: 'boolean', default: true, diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 1737357c2c..74872c17ae 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -1005,7 +1005,7 @@ class ServicesController extends Controller new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Service UUID', schema: new OA\Schema(type: 'string')), new OA\Parameter(name: 'delete_configurations', in: 'query', required: false, description: 'Delete configurations.', schema: new OA\Schema(type: 'boolean', default: true)), new OA\Parameter(name: 'delete_volumes', in: 'query', required: false, description: 'Delete volumes.', schema: new OA\Schema(type: 'boolean', default: true)), - new OA\Parameter(name: 'docker_cleanup', in: 'query', required: false, description: 'Run docker cleanup.', schema: new OA\Schema(type: 'boolean', default: true)), + new OA\Parameter(name: 'docker_cleanup', in: 'query', required: false, description: 'Run docker cleanup when the server disk usage is at or above its cleanup threshold. Skipped when a cleanup ran on the server in the last hour.', schema: new OA\Schema(type: 'boolean', default: true)), new OA\Parameter(name: 'delete_connected_networks', in: 'query', required: false, description: 'Delete connected networks.', schema: new OA\Schema(type: 'boolean', default: true)), new OA\Parameter(name: 'delete_from_coolify_only', in: 'query', required: false, description: 'Remove only Coolify metadata without deleting Docker resources.', schema: new OA\Schema(type: 'boolean', default: false)), ], @@ -2184,7 +2184,7 @@ class ServicesController extends Controller new OA\Parameter( name: 'docker_cleanup', in: 'query', - description: 'Perform docker cleanup (prune networks, volumes, etc.).', + description: 'Run docker cleanup when the server disk usage is at or above its cleanup threshold. Skipped when a cleanup ran on the server in the last hour.', schema: new OA\Schema( type: 'boolean', default: true, diff --git a/openapi.json b/openapi.json index 9e6bf53c3e..f7a5c98442 100644 --- a/openapi.json +++ b/openapi.json @@ -2919,7 +2919,7 @@ { "name": "docker_cleanup", "in": "query", - "description": "Run docker cleanup.", + "description": "Run docker cleanup when the server disk usage is at or above its cleanup threshold. Skipped when a cleanup ran on the server in the last hour.", "required": false, "schema": { "type": "boolean", @@ -4359,7 +4359,7 @@ { "name": "docker_cleanup", "in": "query", - "description": "Perform docker cleanup (prune networks, volumes, etc.).", + "description": "Run docker cleanup when the server disk usage is at or above its cleanup threshold. Skipped when a cleanup ran on the server in the last hour.", "schema": { "type": "boolean", "default": true @@ -6512,7 +6512,7 @@ { "name": "docker_cleanup", "in": "query", - "description": "Run docker cleanup.", + "description": "Run docker cleanup when the server disk usage is at or above its cleanup threshold. Skipped when a cleanup ran on the server in the last hour.", "required": false, "schema": { "type": "boolean", @@ -8768,7 +8768,7 @@ { "name": "docker_cleanup", "in": "query", - "description": "Perform docker cleanup (prune networks, volumes, etc.).", + "description": "Run docker cleanup when the server disk usage is at or above its cleanup threshold. Skipped when a cleanup ran on the server in the last hour.", "schema": { "type": "boolean", "default": true @@ -18942,7 +18942,7 @@ { "name": "docker_cleanup", "in": "query", - "description": "Run docker cleanup.", + "description": "Run docker cleanup when the server disk usage is at or above its cleanup threshold. Skipped when a cleanup ran on the server in the last hour.", "required": false, "schema": { "type": "boolean", @@ -19895,7 +19895,7 @@ { "name": "docker_cleanup", "in": "query", - "description": "Perform docker cleanup (prune networks, volumes, etc.).", + "description": "Run docker cleanup when the server disk usage is at or above its cleanup threshold. Skipped when a cleanup ran on the server in the last hour.", "schema": { "type": "boolean", "default": true diff --git a/openapi.yaml b/openapi.yaml index 68950d480a..5ad716cc32 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1931,7 +1931,7 @@ paths: - name: docker_cleanup in: query - description: 'Run docker cleanup.' + description: 'Run docker cleanup when the server disk usage is at or above its cleanup threshold. Skipped when a cleanup ran on the server in the last hour.' required: false schema: type: boolean @@ -2841,7 +2841,7 @@ paths: - name: docker_cleanup in: query - description: 'Perform docker cleanup (prune networks, volumes, etc.).' + description: 'Run docker cleanup when the server disk usage is at or above its cleanup threshold. Skipped when a cleanup ran on the server in the last hour.' schema: type: boolean default: true @@ -4203,7 +4203,7 @@ paths: - name: docker_cleanup in: query - description: 'Run docker cleanup.' + description: 'Run docker cleanup when the server disk usage is at or above its cleanup threshold. Skipped when a cleanup ran on the server in the last hour.' required: false schema: type: boolean @@ -5765,7 +5765,7 @@ paths: - name: docker_cleanup in: query - description: 'Perform docker cleanup (prune networks, volumes, etc.).' + description: 'Run docker cleanup when the server disk usage is at or above its cleanup threshold. Skipped when a cleanup ran on the server in the last hour.' schema: type: boolean default: true @@ -11971,7 +11971,7 @@ paths: - name: docker_cleanup in: query - description: 'Run docker cleanup.' + description: 'Run docker cleanup when the server disk usage is at or above its cleanup threshold. Skipped when a cleanup ran on the server in the last hour.' required: false schema: type: boolean @@ -12547,7 +12547,7 @@ paths: - name: docker_cleanup in: query - description: 'Perform docker cleanup (prune networks, volumes, etc.).' + description: 'Run docker cleanup when the server disk usage is at or above its cleanup threshold. Skipped when a cleanup ran on the server in the last hour.' schema: type: boolean default: true diff --git a/tests/Feature/DockerCleanupJobTest.php b/tests/Feature/DockerCleanupJobTest.php index eeedb60559..85607cabf4 100644 --- a/tests/Feature/DockerCleanupJobTest.php +++ b/tests/Feature/DockerCleanupJobTest.php @@ -292,6 +292,40 @@ it('accepts a new queued cleanup once the waiting one is lost', function () { Queue::assertPushed(UniqueUntilProcessingJobDecorator::class, 2); }); +it('does not queue a cleanup after a stop when a cleanup ran on the server recently', function () { + Queue::fake(); + $cleanedServer = dockerCleanupReachableServer(); + $otherServer = dockerCleanupReachableServer(); + Cache::put(CleanupDocker::lastRunCacheKey($cleanedServer), true, CleanupDocker::STOP_CLEANUP_COOLDOWN); + + CleanupDocker::dispatchAfterStop($cleanedServer); + CleanupDocker::dispatchAfterStop($otherServer); + + Queue::assertPushed(UniqueUntilProcessingJobDecorator::class, 1); + Queue::assertPushed(UniqueUntilProcessingJobDecorator::class, fn (JobDecorator $job) => $job->getParameters()[0]->is($otherServer)); +}); + +it('cleans after a stop only when the disk usage reaches the server threshold', function (string $diskUsage, bool $cleans) { + $server = dockerCleanupReachableServer(); + $server->settings()->update(['docker_cleanup_threshold' => 80]); + $commands = collect(); + Process::fake(function ($process) use ($commands, $diskUsage) { + $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; + $commands->push($command); + + return Process::result(output: str_contains($command, 'df / --output=pcent') ? $diskUsage : ''); + }); + + CleanupDocker::run($server->fresh(), false, false, true); + + expect($commands->contains(fn (string $command) => str_contains($command, 'docker builder prune')))->toBe($cleans) + ->and(Cache::has(CleanupDocker::lastRunCacheKey($server)))->toBe($cleans); +})->with([ + 'below threshold' => ['79', false], + 'at threshold' => ['80', true], + 'unknown usage' => ['', true], +]); + it('scans application images with a fixed number of remote commands and keeps the retention rules', function () { $server = dockerCleanupReachableServer(); $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();