From be72005980ae3eed2a352c53086760516b62a524 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:51:58 +0200 Subject: [PATCH] fix(deployments): expire missing Horizon jobs after deployment timeout Keep reserved jobs active and bound missing Horizon records by the server's deployment timeout. Add regression coverage for active and finished jobs. Use UTC PostgreSQL sessions and bump the release version to 4.4.1. --- app/Models/ApplicationDeploymentQueue.php | 13 ++++ bootstrap/helpers/shared.php | 3 +- config/constants.php | 2 +- config/database.php | 2 + other/nightly/versions.json | 2 +- .../DeploymentHorizonJobActiveTest.php | 59 +++++++++++++++++++ versions.json | 2 +- 7 files changed, 78 insertions(+), 5 deletions(-) create mode 100644 tests/Feature/DeploymentHorizonJobActiveTest.php diff --git a/app/Models/ApplicationDeploymentQueue.php b/app/Models/ApplicationDeploymentQueue.php index 6ebdf255ae..5994f187de 100644 --- a/app/Models/ApplicationDeploymentQueue.php +++ b/app/Models/ApplicationDeploymentQueue.php @@ -177,6 +177,19 @@ class ApplicationDeploymentQueue extends Model return getJobStatus($this->horizon_job_id); } + /** + * Horizon drops its job record 'trim.pending' minutes after the push, also while the job runs, + * so a missing record counts as running only until the deployment timeout has passed. + */ + public function isHorizonJobActive(): bool + { + return match ($this->getHorizonJobStatus()) { + 'reserved' => true, + 'unknown' => $this->updated_at?->gt(now()->subSeconds($this->server?->settings?->dynamic_timeout ?? 3600)) ?? false, + default => false, + }; + } + public function commitMessage() { if (empty($this->commit_message) || is_null($this->commit_message)) { diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 2e9fdbb253..81f2dbe726 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -2623,8 +2623,7 @@ function isAnyDeploymentInprogress(bool $showAll = false) $deploymentDetails = []; foreach ($runningJobs as $runningJob) { - $horizonJobStatus = getJobStatus($runningJob->horizon_job_id); - if ($horizonJobStatus === 'unknown' || $horizonJobStatus === 'reserved') { + if ($runningJob->isHorizonJobActive()) { $horizonJobIds[] = $runningJob->horizon_job_id; if ($showAll) { diff --git a/config/constants.php b/config/constants.php index c25c29ca9b..8c71e262dd 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.4.0', + 'version' => env('COOLIFY_VERSION') ?: '4.4.1', 'helper_version' => '1.0.17', 'railpack_version' => '0.23.0', 'self_hosted' => env('SELF_HOSTED', true), diff --git a/config/database.php b/config/database.php index 9238a70551..07039d6d62 100644 --- a/config/database.php +++ b/config/database.php @@ -33,6 +33,8 @@ $pgsql = [ 'prefix' => '', 'prefix_indexes' => true, 'search_path' => 'public', + // Laravel writes dates without an offset, so timestampTz columns need a UTC session. + 'timezone' => 'UTC', 'sslmode' => 'prefer', 'options' => [ (defined('Pdo\Pgsql::ATTR_DISABLE_PREPARES') ? Pgsql::ATTR_DISABLE_PREPARES : PDO::PGSQL_ATTR_DISABLE_PREPARES) => env('DB_DISABLE_PREPARES', false), diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 81016eeeb4..bd38eada4e 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "main" + "version": "4.4.1" }, "nightly": { "version": "4.5-rc.1" diff --git a/tests/Feature/DeploymentHorizonJobActiveTest.php b/tests/Feature/DeploymentHorizonJobActiveTest.php new file mode 100644 index 0000000000..07da481f90 --- /dev/null +++ b/tests/Feature/DeploymentHorizonJobActiveTest.php @@ -0,0 +1,59 @@ +create(); + $privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); + $this->server = Server::factory()->create(['team_id' => $team->id, 'private_key_id' => $privateKey->id]); + $this->server->settings->update(['dynamic_timeout' => 3600]); + + $this->deployment = ApplicationDeploymentQueue::create([ + 'application_id' => '1', + 'deployment_uuid' => 'horizon-active-uuid', + 'server_id' => $this->server->id, + 'horizon_job_id' => 'job-1', + ]); +}); + +function fakeHorizonJobStatus(?string $status): void +{ + $jobs = $status === null ? collect() : collect([(object) ['id' => 'job-1', 'status' => $status]]); + + $repository = Mockery::mock(JobRepository::class); + $repository->shouldReceive('getJobs')->andReturn($jobs); + app()->instance(JobRepository::class, $repository); +} + +it('counts a reserved job as active', function () { + fakeHorizonJobStatus('reserved'); + + expect($this->deployment->isHorizonJobActive())->toBeTrue(); +}); + +it('does not count a finished job as active', function (string $status) { + fakeHorizonJobStatus($status); + + expect($this->deployment->isHorizonJobActive())->toBeFalse(); +})->with(['completed', 'failed']); + +it('counts a job without a Horizon record as active within the deployment timeout', function () { + fakeHorizonJobStatus(null); + $this->travel(59)->minutes(); + + expect($this->deployment->isHorizonJobActive())->toBeTrue(); +}); + +it('does not count a job without a Horizon record as active after the deployment timeout', function () { + fakeHorizonJobStatus(null); + $this->travel(61)->minutes(); + + expect($this->deployment->isHorizonJobActive())->toBeFalse(); +}); diff --git a/versions.json b/versions.json index d4a76777b3..bd38eada4e 100644 --- a/versions.json +++ b/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.4.0" + "version": "4.4.1" }, "nightly": { "version": "4.5-rc.1"