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.
This commit is contained in:
Andras Bacsai committed 2026-10-06 21:51:58 +02:00
1 parent 41ed5b7a69
commit be72005980
7 files changed
+78 -5

No files matched your search

+13
View File
@@ -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)) {
+1 -2
View File
@@ -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) {
+1 -1
View File
@@ -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),
+2
View File
@@ -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),
+1 -1
View File
@@ -1,7 +1,7 @@
{
"coolify": {
"v4": {
"version": "main"
"version": "4.4.1"
},
"nightly": {
"version": "4.5-rc.1"
@@ -0,0 +1,59 @@
<?php
use App\Models\ApplicationDeploymentQueue;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Horizon\Contracts\JobRepository;
uses(RefreshDatabase::class);
beforeEach(function () {
$team = Team::factory()->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();
});
+1 -1
View File
@@ -1,7 +1,7 @@
{
"coolify": {
"v4": {
"version": "4.4.0"
"version": "4.4.1"
},
"nightly": {
"version": "4.5-rc.1"