From 24648d4b2079f681b4a9c710cdb8a537cd42dfa1 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:13:27 +0100 Subject: [PATCH 1/3] fix(docker-cleanup): skip broadcast for executions of deleted servers Co-Authored-By: Claude Opus 5.5 --- app/Events/DockerCleanupDone.php | 20 ++++++++++++++++---- tests/Feature/DockerCleanupJobTest.php | 16 ++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/app/Events/DockerCleanupDone.php b/app/Events/DockerCleanupDone.php index f229c66451..71eb80e2f8 100644 --- a/app/Events/DockerCleanupDone.php +++ b/app/Events/DockerCleanupDone.php @@ -13,13 +13,25 @@ class DockerCleanupDone implements ShouldBroadcast { use Dispatchable, InteractsWithSockets, SerializesModels; - public function __construct(public DockerCleanupExecution $execution) {} + public ?int $teamId; + public function __construct(public DockerCleanupExecution $execution) + { + $this->teamId = $execution->server?->team_id; + } + + /** + * A deleted server has no team to tell, so nothing is broadcast. + * + * @return array + */ public function broadcastOn(): array { - return [ - new PrivateChannel('team.'.$this->execution->server->team->id), - ]; + if (is_null($this->teamId)) { + return []; + } + + return [new PrivateChannel("team.{$this->teamId}")]; } /** diff --git a/tests/Feature/DockerCleanupJobTest.php b/tests/Feature/DockerCleanupJobTest.php index a12808c15b..5d0b30b571 100644 --- a/tests/Feature/DockerCleanupJobTest.php +++ b/tests/Feature/DockerCleanupJobTest.php @@ -867,3 +867,19 @@ it('keeps the images of an application while it deploys, because its new images ->and($removals->first())->toContain("'{$idle->uuid}:old'") ->not->toContain($deploying->uuid); })->with(['queued', 'in_progress']); + +it('fails an interrupted cleanup of a deleted server without breaking its broadcast', function () { + Carbon::setTestNow(Carbon::create(2026, 10, 28, 12, 0, 0, 'UTC')); + Queue::fake(); + Notification::fake(); + $server = dockerCleanupReachableServer(); + $interrupted = DockerCleanupExecution::create(['server_id' => $server->id]); + $server->delete(); + Carbon::setTestNow(now()->addSeconds(CleanupDocker::overlapLockExpiresAfter() + 1)); + + DockerCleanupJob::failInterruptedExecutions(); + + expect($interrupted->fresh()->status)->toBe('failed'); + Queue::assertPushed(BroadcastEvent::class, fn (BroadcastEvent $job) => $job->event instanceof DockerCleanupDone + && $job->event->broadcastOn() === []); +}); From 57cdd92f7c5ea882cf39a552b83f0d758a3f3e12 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:13:36 +0100 Subject: [PATCH 2/3] fix(upgrade): count only scheduled jobs running on this host --- .../ScheduledDatabaseBackupExecution.php | 3 +- app/Models/ScheduledTaskExecution.php | 3 ++ app/Models/ScheduledVolumeBackupExecution.php | 3 +- app/Traits/RecordsWorkerHost.php | 32 +++++++++++++++++++ bootstrap/helpers/shared.php | 8 ++--- ...orker_host_to_scheduled_job_executions.php | 28 ++++++++++++++++ .../Feature/RunningScheduledJobCountTest.php | 17 ++++++++++ 7 files changed, 88 insertions(+), 6 deletions(-) create mode 100644 app/Traits/RecordsWorkerHost.php create mode 100644 database/migrations/2026_10_28_204953_add_worker_host_to_scheduled_job_executions.php diff --git a/app/Models/ScheduledDatabaseBackupExecution.php b/app/Models/ScheduledDatabaseBackupExecution.php index eb43a1a2e3..a11c581d9b 100644 --- a/app/Models/ScheduledDatabaseBackupExecution.php +++ b/app/Models/ScheduledDatabaseBackupExecution.php @@ -3,12 +3,13 @@ namespace App\Models; use App\Traits\HasS3Replicas; +use App\Traits\RecordsWorkerHost; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; class ScheduledDatabaseBackupExecution extends BaseModel { - use HasS3Replicas; + use HasS3Replicas, RecordsWorkerHost; protected static function booted(): void { diff --git a/app/Models/ScheduledTaskExecution.php b/app/Models/ScheduledTaskExecution.php index 8f496fc1e6..c5f1f393f7 100644 --- a/app/Models/ScheduledTaskExecution.php +++ b/app/Models/ScheduledTaskExecution.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\RecordsWorkerHost; use Illuminate\Database\Eloquent\Relations\BelongsTo; use OpenApi\Attributes as OA; @@ -22,6 +23,8 @@ use OpenApi\Attributes as OA; )] class ScheduledTaskExecution extends BaseModel { + use RecordsWorkerHost; + protected $fillable = [ 'scheduled_task_id', 'status', diff --git a/app/Models/ScheduledVolumeBackupExecution.php b/app/Models/ScheduledVolumeBackupExecution.php index 82dfe0ee3b..776b2b24fc 100644 --- a/app/Models/ScheduledVolumeBackupExecution.php +++ b/app/Models/ScheduledVolumeBackupExecution.php @@ -3,12 +3,13 @@ namespace App\Models; use App\Traits\HasS3Replicas; +use App\Traits\RecordsWorkerHost; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; class ScheduledVolumeBackupExecution extends BaseModel { - use HasS3Replicas; + use HasS3Replicas, RecordsWorkerHost; protected $fillable = [ 'uuid', diff --git a/app/Traits/RecordsWorkerHost.php b/app/Traits/RecordsWorkerHost.php new file mode 100644 index 0000000000..ac55770b65 --- /dev/null +++ b/app/Traits/RecordsWorkerHost.php @@ -0,0 +1,32 @@ +worker_host ??= gethostname(); + }); + } + + protected function initializeRecordsWorkerHost(): void + { + $this->hidden[] = 'worker_host'; + } + + /** + * Rows without a host were created before the column existed, so they can belong to this host. + */ + public function scopeOnThisHost(Builder $query): Builder + { + return $query->where(fn (Builder $query) => $query->where('worker_host', gethostname())->orWhereNull('worker_host')); + } +} diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index cd78aeee34..b31756dae9 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -2936,20 +2936,20 @@ function isAnyDeploymentInprogress(bool $showAll = false) } /** - * Counts scheduled task, database backup, and volume backup runs that are still running. + * Counts scheduled task, database backup, and volume backup runs that are still running on this host. * A run that started longer ago than its job timeout is stale: the worker has stopped it. */ function runningScheduledJobCount(): int { $startedWithin = fn (Model $execution, int $timeoutSeconds): bool => $execution->created_at?->gt(now()->subSeconds($timeoutSeconds)) ?? false; - $tasks = ScheduledTaskExecution::with('scheduledTask')->where('status', 'running')->get() + $tasks = ScheduledTaskExecution::with('scheduledTask')->onThisHost()->where('status', 'running')->get() ->filter(fn (ScheduledTaskExecution $execution) => $startedWithin($execution, ($execution->scheduledTask?->timeout ?? 300) + ScheduledTaskJob::WORKER_TIMEOUT_MARGIN_SECONDS)); - $databaseBackups = ScheduledDatabaseBackupExecution::with('scheduledDatabaseBackup')->where('status', 'running')->get() + $databaseBackups = ScheduledDatabaseBackupExecution::with('scheduledDatabaseBackup')->onThisHost()->where('status', 'running')->get() ->filter(fn (ScheduledDatabaseBackupExecution $execution) => $startedWithin($execution, ($execution->scheduledDatabaseBackup?->timeout ?? 3600) + DatabaseBackupJob::WORKER_TIMEOUT_MARGIN_SECONDS)); - $volumeBackups = ScheduledVolumeBackupExecution::with('scheduledVolumeBackup')->where('status', 'running')->get() + $volumeBackups = ScheduledVolumeBackupExecution::with('scheduledVolumeBackup')->onThisHost()->where('status', 'running')->get() ->filter(fn (ScheduledVolumeBackupExecution $execution) => $startedWithin($execution, $execution->scheduledVolumeBackup?->timeout ?? ScheduledVolumeBackup::DEFAULT_TIMEOUT)); return $tasks->count() + $databaseBackups->count() + $volumeBackups->count(); diff --git a/database/migrations/2026_10_28_204953_add_worker_host_to_scheduled_job_executions.php b/database/migrations/2026_10_28_204953_add_worker_host_to_scheduled_job_executions.php new file mode 100644 index 0000000000..b90a9eb1c8 --- /dev/null +++ b/database/migrations/2026_10_28_204953_add_worker_host_to_scheduled_job_executions.php @@ -0,0 +1,28 @@ +string('worker_host')->nullable(); + }); + } + } + + public function down(): void + { + foreach (self::TABLES as $table) { + Schema::table($table, function (Blueprint $table) { + $table->dropColumn('worker_host'); + }); + } + } +}; diff --git a/tests/Feature/RunningScheduledJobCountTest.php b/tests/Feature/RunningScheduledJobCountTest.php index 37edc5d6bc..17cf9423eb 100644 --- a/tests/Feature/RunningScheduledJobCountTest.php +++ b/tests/Feature/RunningScheduledJobCountTest.php @@ -64,3 +64,20 @@ it('does not count running rows older than their job timeout', function () { $this->travel(60)->minutes(); expect(runningScheduledJobCount())->toBe(0); }); + +it('records the worker host on new runs and hides it from responses', function () { + $execution = ScheduledTaskExecution::create(['scheduled_task_id' => $this->task->id, 'status' => 'running']); + + expect($execution->fresh()->worker_host)->toBe(gethostname()) + ->and($execution->toArray())->not->toHaveKey('worker_host'); +}); + +it('counts only runs on this host and runs without a recorded host', function () { + ScheduledTaskExecution::create(['scheduled_task_id' => $this->task->id, 'status' => 'running']); + (new ScheduledTaskExecution(['scheduled_task_id' => $this->task->id, 'status' => 'running']))->forceFill(['worker_host' => 'other-host'])->save(); + (new ScheduledDatabaseBackupExecution(['scheduled_database_backup_id' => $this->databaseBackup->id, 'database_name' => 'db', 'status' => 'running']))->forceFill(['worker_host' => 'other-host'])->save(); + $this->volumeBackup->executions()->make()->forceFill(['worker_host' => 'other-host'])->save(); + $this->volumeBackup->executions()->create()->forceFill(['worker_host' => null])->save(); + + expect(runningScheduledJobCount())->toBe(2); +}); From 06747f9954dcc8ee657e45bb6a6606fa45b41240 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:14:12 +0100 Subject: [PATCH 3/3] chore(release): bump Coolify version to 4.5.4 Co-Authored-By: Claude Opus 5.5 --- config/constants.php | 2 +- other/nightly/versions.json | 2 +- versions.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/config/constants.php b/config/constants.php index 322faa8fd3..2bacce35e1 100644 --- a/config/constants.php +++ b/config/constants.php @@ -5,7 +5,7 @@ $sshCommandTimeout = (int) env('SSH_COMMAND_TIMEOUT', 3600); return [ 'coolify' => [ - 'version' => env('COOLIFY_VERSION') ?: '4.5.3', + 'version' => env('COOLIFY_VERSION') ?: '4.5.4', 'helper_version' => '1.0.19', 'railpack_version' => '0.43.0', 'self_hosted' => env('SELF_HOSTED', true), diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 89c2037a53..c2fec20f13 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.5.3" + "version": "4.5.4" }, "nightly": { "version": "4.6-rc.1" diff --git a/versions.json b/versions.json index 37c44741b7..c7b80ac79f 100644 --- a/versions.json +++ b/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.5.3" + "version": "4.5.4" }, "nightly": { "version": "4.6-rc.1"