Merge remote-tracking branch 'origin/main' into next

This commit is contained in:
github-actions[bot]
2026-10-28 21:17:02 +00:00
12 changed files with 123 additions and 13 deletions
+16 -4
View File
@@ -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<int, PrivateChannel>
*/
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}")];
}
/**
@@ -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
{
+3
View File
@@ -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',
@@ -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',
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Traits;
use Illuminate\Database\Eloquent\Builder;
/**
* Stores the host that created the execution, so a host can count only the runs of its own workers.
* The host name is internal, so API and MCP responses do not show it.
*/
trait RecordsWorkerHost
{
protected static function bootRecordsWorkerHost(): void
{
static::creating(function (self $execution): void {
$execution->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'));
}
}
+4 -4
View File
@@ -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();
+1 -1
View File
@@ -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),
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private const TABLES = ['scheduled_task_executions', 'scheduled_database_backup_executions', 'scheduled_volume_backup_executions'];
public function up(): void
{
foreach (self::TABLES as $table) {
Schema::table($table, function (Blueprint $table) {
$table->string('worker_host')->nullable();
});
}
}
public function down(): void
{
foreach (self::TABLES as $table) {
Schema::table($table, function (Blueprint $table) {
$table->dropColumn('worker_host');
});
}
}
};
+1 -1
View File
@@ -1,7 +1,7 @@
{
"coolify": {
"v4": {
"version": "4.5.3"
"version": "4.5.4"
},
"nightly": {
"version": "4.6-rc.1"
+16
View File
@@ -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() === []);
});
@@ -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);
});
+1 -1
View File
@@ -1,7 +1,7 @@
{
"coolify": {
"v4": {
"version": "4.5.3"
"version": "4.5.4"
},
"nightly": {
"version": "4.6-rc.1"