Files
coolify/tests/Feature/ScheduledJobManagerDispatchTest.php
T
e3d4411ff2 chore: merge main into release/v4.5 (#12192)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Furox <177975472+Furox-Art@users.noreply.github.com>
Co-authored-by: johandenhollander <johan.den.hollander@finalist.nl>
Co-authored-by: João Cruz <161638790+SrJooJ@users.noreply.github.com>
Co-authored-by: Saim Hashmi <hashmisaim037@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-18 23:43:20 +02:00

1315 lines
59 KiB
PHP

<?php
use App\Events\BackupCreated;
use App\Events\DockerCleanupDone;
use App\Jobs\DatabaseBackupJob;
use App\Jobs\DockerCleanupJob;
use App\Jobs\ScheduledJobManager;
use App\Jobs\ScheduledTaskJob;
use App\Models\Application;
use App\Models\DockerCleanupExecution;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledDatabaseBackupExecution;
use App\Models\ScheduledJobDelivery;
use App\Models\ScheduledJobState;
use App\Models\ScheduledTask;
use App\Models\ScheduledTaskExecution;
use App\Models\Server;
use App\Models\ServerSetting;
use App\Models\Service;
use App\Models\ServiceDatabase;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Notifications\Database\BackupFailed;
use App\Notifications\ScheduledTask\TaskFailed;
use App\Notifications\Server\DockerCleanupFailed;
use App\Services\ScheduledJobDeliveryService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\TimeoutExceededException;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
});
it('dispatches scheduled tasks across chunks', function () {
config(['constants.coolify.self_hosted' => true]);
Carbon::setTestNow(Carbon::create(2026, 5, 27, 0, 1, 0, 'UTC'));
Queue::fake();
$team = Team::factory()->create();
$privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
-----END OPENSSH PRIVATE KEY-----',
'team_id' => $team->id,
]);
$server = Server::factory()->create([
'team_id' => $team->id,
'private_key_id' => $privateKey->id,
]);
$server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
'force_disabled' => false,
'docker_cleanup_frequency' => '0 * * * *',
]);
$destination = StandaloneDocker::where('server_id', $server->id)->first()
?? StandaloneDocker::factory()->create(['server_id' => $server->id]);
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$application = Application::factory()->create([
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => StandaloneDocker::class,
'status' => 'running',
]);
ScheduledTask::factory()
->count(101)
->create([
'team_id' => $team->id,
'application_id' => $application->id,
'frequency' => '* * * * *',
'enabled' => true,
]);
(new ScheduledJobManager)->handle();
Queue::assertPushed(ScheduledTaskJob::class, 101);
});
it('skips expensive dispatch for schedules outside the catch-up window', function () {
config(['constants.coolify.self_hosted' => true]);
Carbon::setTestNow(Carbon::create(2026, 5, 27, 0, 1, 0, 'UTC'));
Queue::fake();
$application = createScheduledTaskApplication();
$task = ScheduledTask::factory()->create([
'team_id' => $application->environment->project->team_id,
'application_id' => $application->id,
'frequency' => '0 2 * * *',
'enabled' => true,
]);
(new ScheduledJobManager)->handle();
Queue::assertNotPushed(ScheduledTaskJob::class);
expect(ScheduledJobDelivery::query()->where('schedule_key', "scheduled-task:{$task->id}")->exists())->toBeFalse();
});
it('dispatches a recently missed daily task when deduplication cache is empty', function () {
config(['constants.coolify.self_hosted' => true]);
Carbon::setTestNow(Carbon::create(2026, 9, 17, 0, 10, 0, 'UTC'));
Queue::fake();
$application = createScheduledTaskApplication();
ScheduledTask::factory()->create([
'team_id' => $application->environment->project->team_id,
'application_id' => $application->id,
'frequency' => 'daily',
'enabled' => true,
]);
(new ScheduledJobManager)->handle();
Queue::assertPushed(ScheduledTaskJob::class, 1);
});
it('dispatches one job when multiple managers evaluate the same occurrence', function () {
config(['constants.coolify.self_hosted' => true]);
Carbon::setTestNow(Carbon::create(2026, 9, 17, 0, 5, 0, 'UTC'));
Queue::fake();
$application = createScheduledTaskApplication();
$task = ScheduledTask::factory()->create([
'team_id' => $application->environment->project->team_id,
'application_id' => $application->id,
'frequency' => 'daily',
'enabled' => true,
]);
(new ScheduledJobManager)->handle();
(new ScheduledJobManager)->handle();
Queue::assertPushed(ScheduledTaskJob::class, 1);
expect(ScheduledJobDelivery::query()->where('schedule_key', "scheduled-task:{$task->id}")->count())->toBe(1)
->and(ScheduledJobState::query()->where('schedule_key', "scheduled-task:{$task->id}")->count())->toBe(1);
});
it('does not retry an occurrence skipped while its server is not functional', function () {
config(['constants.coolify.self_hosted' => true]);
Carbon::setTestNow(Carbon::create(2026, 9, 17, 0, 5, 0, 'UTC'));
Queue::fake();
$application = createScheduledTaskApplication();
$task = createScheduledApplicationTask($application, ['frequency' => 'daily']);
$server = $task->server();
$server->settings()->update(['is_reachable' => false]);
(new ScheduledJobManager)->handle();
$server->settings()->update(['is_reachable' => true]);
Carbon::setTestNow(Carbon::create(2026, 9, 17, 0, 6, 0, 'UTC'));
(new ScheduledJobManager)->handle();
Queue::assertNotPushed(ScheduledTaskJob::class);
expect(ScheduledJobState::query()->where('schedule_key', "scheduled-task:{$task->id}")->value('last_scheduled_for'))
->not->toBeNull()
->and(ScheduledJobDelivery::query()->where('schedule_key', "scheduled-task:{$task->id}")->exists())
->toBeFalse();
});
it('does not publish a task occurrence when its application is not running', function () {
config(['constants.coolify.self_hosted' => true]);
Carbon::setTestNow(Carbon::create(2026, 9, 17, 0, 5, 0, 'UTC'));
Queue::fake();
$application = createScheduledTaskApplication();
$application->update(['status' => 'stopped']);
$task = createScheduledApplicationTask($application, ['frequency' => 'daily']);
(new ScheduledJobManager)->handle();
Queue::assertNotPushed(ScheduledTaskJob::class);
expect(ScheduledJobState::query()->where('schedule_key', "scheduled-task:{$task->id}")->exists())->toBeTrue()
->and(ScheduledJobDelivery::query()->where('schedule_key', "scheduled-task:{$task->id}")->exists())
->toBeFalse();
});
it('publishes a pending occurrence after a previous publisher interruption', function () {
config(['constants.coolify.self_hosted' => true]);
Carbon::setTestNow(Carbon::create(2026, 9, 17, 12, 0, 0, 'UTC'));
Queue::fake();
$application = createScheduledTaskApplication();
$task = ScheduledTask::factory()->create([
'team_id' => $application->environment->project->team_id,
'application_id' => $application->id,
'frequency' => 'daily',
'enabled' => true,
]);
$occurrence = ScheduledJobDelivery::create([
'schedule_key' => "scheduled-task:{$task->id}",
'scheduled_for' => Carbon::create(2026, 9, 17, 0, 0, 0, 'UTC'),
'job_type' => 'scheduled-task',
'resource_id' => $task->id,
]);
(new ScheduledJobManager)->handle();
Queue::assertPushed(ScheduledTaskJob::class, 1);
expect($occurrence->fresh()->status)->toBe('enqueued');
});
it('allows only one worker to claim an occurrence', function () {
$occurrence = ScheduledJobDelivery::create([
'schedule_key' => 'scheduled-task:claim-test',
'scheduled_for' => now(),
'job_type' => 'scheduled-task',
'resource_id' => 1,
'status' => 'enqueued',
]);
$service = app(ScheduledJobDeliveryService::class);
expect($service->claim($occurrence->uuid, 'worker-a'))->toBeTrue()
->and($service->claim($occurrence->uuid, 'worker-b'))->toBeFalse()
->and($service->claim($occurrence->uuid, 'worker-a'))->toBeTrue()
->and($occurrence->fresh()->status)->toBe('claimed');
$service->complete($occurrence->uuid, 'worker-a');
expect($occurrence->fresh())->toBeNull();
});
it('deletes only failed old delivery records', function () {
$oldFailed = ScheduledJobDelivery::create([
'schedule_key' => 'scheduled-task:old-failed',
'scheduled_for' => now()->subDays(31),
'job_type' => 'scheduled-task',
'resource_id' => 1,
'status' => 'failed',
]);
$oldPending = ScheduledJobDelivery::create([
'schedule_key' => 'scheduled-task:old-pending',
'scheduled_for' => now()->subDays(31),
'job_type' => 'scheduled-task',
'resource_id' => 1,
'status' => 'pending',
]);
$oldFailed->timestamps = false;
$oldFailed->forceFill(['created_at' => now()->subDays(31)])->save();
$oldPending->timestamps = false;
$oldPending->forceFill(['created_at' => now()->subDays(31)])->save();
app(ScheduledJobDeliveryService::class)->deleteOldOccurrences();
expect($oldFailed->fresh())->toBeNull()
->and($oldPending->fresh())->not->toBeNull();
});
it('marks stale claimed deliveries as failed', function () {
$delivery = ScheduledJobDelivery::create([
'schedule_key' => 'scheduled-task:stale-claim',
'scheduled_for' => now()->subDays(3),
'job_type' => 'scheduled-task',
'resource_id' => 1,
'status' => 'claimed',
'claim_token' => 'lost-worker',
]);
$delivery->timestamps = false;
$delivery->forceFill(['updated_at' => now()->subDays(3)])->save();
app(ScheduledJobDeliveryService::class)->deleteOldOccurrences();
expect($delivery->fresh()->status)->toBe('failed');
});
it('dispatches the instance coolify-db backup even when its id is zero', function () {
config(['constants.coolify.self_hosted' => true]);
Carbon::setTestNow(Carbon::create(2026, 5, 27, 0, 1, 0, 'UTC'));
Queue::fake();
$database = createScheduledBackupDatabase();
$backup = createScheduledDatabaseBackup($database, [
'id' => 0,
'frequency' => '* * * * *',
]);
expect($backup->id)->toBe(0);
(new ScheduledJobManager)->handle();
Queue::assertPushed(DatabaseBackupJob::class, 1);
Queue::assertPushed(DatabaseBackupJob::class, fn (DatabaseBackupJob $job) => $job->backup->id === 0);
});
it('dispatches zero-id schedules and continues with positive ids', function () {
config(['constants.coolify.self_hosted' => true]);
Carbon::setTestNow(Carbon::create(2026, 5, 27, 0, 1, 0, 'UTC'));
Queue::fake();
$application = createScheduledTaskApplication();
$database = StandalonePostgresql::create([
'name' => 'coolify-db',
'image' => 'postgres:16-alpine',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'postgres',
'status' => 'running',
'environment_id' => $application->environment_id,
'destination_id' => $application->destination_id,
'destination_type' => $application->destination_type,
]);
$zeroIdBackup = createScheduledDatabaseBackup($database, ['id' => 0]);
$positiveIdBackup = createScheduledDatabaseBackup($database);
$zeroIdTask = createScheduledApplicationTask($application, ['id' => 0]);
$positiveIdTask = createScheduledApplicationTask($application);
expect($zeroIdBackup->id)->toBe(0)
->and($positiveIdBackup->id)->toBeGreaterThan(0)
->and($zeroIdTask->id)->toBe(0)
->and($positiveIdTask->id)->toBeGreaterThan(0);
(new ScheduledJobManager)->handle();
Queue::assertPushed(DatabaseBackupJob::class, 2);
Queue::assertPushed(DatabaseBackupJob::class, fn (DatabaseBackupJob $job) => $job->backup->id === 0);
Queue::assertPushed(DatabaseBackupJob::class, fn (DatabaseBackupJob $job) => $job->backup->id === $positiveIdBackup->id);
Queue::assertPushed(ScheduledTaskJob::class, 2);
Queue::assertPushed(ScheduledTaskJob::class, fn (ScheduledTaskJob $job) => $job->task->id === 0);
Queue::assertPushed(ScheduledTaskJob::class, fn (ScheduledTaskJob $job) => $job->task->id === $positiveIdTask->id);
});
it('does not query relationships when constructing scheduled task jobs', function () {
$application = createScheduledTaskApplication();
$task = ScheduledTask::factory()->create([
'team_id' => $application->environment->project->team_id,
'application_id' => $application->id,
'frequency' => '* * * * *',
'enabled' => true,
])->fresh();
DB::flushQueryLog();
DB::enableQueryLog();
$job = new ScheduledTaskJob($task);
expect(DB::getQueryLog())->toBeEmpty()
->and($job->queue)->toBe(crons_queue())
->and($job->timeout)->toBe(300 + ScheduledTaskJob::WORKER_TIMEOUT_MARGIN_SECONDS);
});
it('reads current server settings in each queued manager run of the same worker', function () {
config(['constants.coolify.self_hosted' => true]);
Queue::fakeExcept(ScheduledJobManager::class);
$database = createScheduledBackupDatabase();
createScheduledDatabaseBackup($database, ['frequency' => 'daily']);
$serverId = $database->destination->server_id;
ServerSetting::query()->where('server_id', $serverId)->update(['is_reachable' => false]);
Carbon::setTestNow(Carbon::create(2026, 9, 16, 0, 5, 0, 'UTC'));
dispatch(new ScheduledJobManager);
Queue::assertNotPushed(DatabaseBackupJob::class);
// Another worker marks the server reachable again before the next daily run.
ServerSetting::query()->where('server_id', $serverId)->update(['is_reachable' => true]);
Carbon::setTestNow(Carbon::create(2026, 9, 17, 0, 5, 0, 'UTC'));
dispatch(new ScheduledJobManager);
Queue::assertPushed(DatabaseBackupJob::class, 1);
});
it('logs the current database server state when a backup is skipped', function () {
config(['constants.coolify.self_hosted' => true]);
Carbon::setTestNow(Carbon::create(2026, 9, 17, 0, 5, 0, 'UTC'));
Queue::fake();
$logPath = captureScheduledJobManagerTestLog('scheduled');
$database = createScheduledBackupDatabase();
$backup = createScheduledDatabaseBackup($database, ['frequency' => 'daily']);
ServerSetting::query()->where('server_id', $database->destination->server_id)->update(['is_reachable' => false]);
(new ScheduledJobManager)->handle();
$log = file_get_contents($logPath);
@unlink($logPath);
expect($log)->toContain('Backup skipped')
->toContain('"skip_reason":"server_not_functional"')
->toContain("\"backup_id\":{$backup->id}")
->toContain('"db_is_reachable":false');
});
it('logs each step of a scheduled occurrence', function () {
config(['constants.coolify.self_hosted' => true]);
Carbon::setTestNow(Carbon::create(2026, 9, 17, 0, 5, 0, 'UTC'));
Queue::fake();
$logPath = captureScheduledJobManagerTestLog('scheduled');
$backup = createScheduledDatabaseBackup(createScheduledBackupDatabase(), ['frequency' => 'daily']);
(new ScheduledJobManager)->handle();
$occurrenceUuid = ScheduledJobDelivery::query()->where('schedule_key', "scheduled-backup:{$backup->id}")->value('uuid');
$service = app(ScheduledJobDeliveryService::class);
expect($service->claim($occurrenceUuid, 'worker-a'))->toBeTrue()
->and($service->claim($occurrenceUuid, 'worker-b'))->toBeFalse();
$service->complete($occurrenceUuid, 'worker-a');
$log = file_get_contents($logPath);
@unlink($logPath);
expect($log)->toContain('Backup dispatched')
->toContain('Scheduled occurrence enqueued')
->toContain('"occurrence_uuid":"'.$occurrenceUuid.'"')
->toContain('"host":"'.gethostname().'"')
->toContain('Scheduled occurrence claimed')
->toContain('Scheduled occurrence claim rejected')
->toContain('Scheduled occurrence completed')
->toContain("\"schedule_key\":\"scheduled-backup:{$backup->id}\"")
->toContain('"seconds_since_due":300');
});
it('warns when the manager starts after the catch-up window', function (int $minutesSincePreviousStart, bool $expectWarning) {
config(['constants.coolify.self_hosted' => true]);
Carbon::setTestNow(Carbon::create(2026, 9, 17, 0, 30, 0, 'UTC'));
Queue::fake();
$logPath = captureScheduledJobManagerTestLog('scheduled-errors');
Cache::put('scheduled-job-manager:last-started-at', now()->subMinutes($minutesSincePreviousStart)->toIso8601String());
(new ScheduledJobManager)->handle();
$log = file_get_contents($logPath);
@unlink($logPath);
expect(str_contains($log, 'ScheduledJobManager started late'))->toBe($expectWarning);
})->with([
'normal one-minute interval' => [1, false],
'twenty-minute gap' => [20, true],
]);
it('warns when an occurrence is enqueued while the previous one is still running', function () {
config(['constants.coolify.self_hosted' => true]);
Queue::fake();
$logPath = captureScheduledJobManagerTestLog('scheduled');
$backup = createScheduledDatabaseBackup(createScheduledBackupDatabase(), ['frequency' => 'hourly']);
Carbon::setTestNow(Carbon::create(2026, 9, 16, 0, 5, 0, 'UTC'));
(new ScheduledJobManager)->handle();
$previousUuid = ScheduledJobDelivery::query()->where('schedule_key', "scheduled-backup:{$backup->id}")->value('uuid');
app(ScheduledJobDeliveryService::class)->claim($previousUuid, 'worker-a');
Carbon::setTestNow(Carbon::create(2026, 9, 16, 1, 5, 0, 'UTC'));
(new ScheduledJobManager)->handle();
$log = file_get_contents($logPath);
@unlink($logPath);
expect($log)->toContain('Scheduled occurrence enqueued while the previous occurrence is still open')
->toContain('"previous_occurrence_uuid":"'.$previousUuid.'"')
->toContain('"previous_status":"claimed"');
});
it('reports occurrences that stay open after fifteen minutes', function () {
config(['constants.coolify.self_hosted' => true]);
Carbon::setTestNow(Carbon::create(2026, 9, 17, 12, 30, 0, 'UTC'));
Queue::fake();
$logPath = captureScheduledJobManagerTestLog('scheduled');
foreach (['enqueued' => 20, 'claimed' => 20, 'failed' => 20] as $status => $minutesAgo) {
ScheduledJobDelivery::create([
'schedule_key' => "scheduled-backup:{$status}",
'scheduled_for' => now()->subMinutes($minutesAgo),
'job_type' => 'database-backup',
'resource_id' => 1,
'status' => $status,
])->forceFill(['created_at' => now()->subMinutes($minutesAgo)])->save();
}
ScheduledJobDelivery::create([
'schedule_key' => 'scheduled-backup:recent',
'scheduled_for' => now()->subMinutes(2),
'job_type' => 'database-backup',
'resource_id' => 1,
'status' => 'enqueued',
]);
(new ScheduledJobManager)->handle();
$log = file_get_contents($logPath);
@unlink($logPath);
expect($log)->toContain('"open_occurrences_over_15_minutes":{"claimed":1,"enqueued":1}');
});
it('keeps publishing pending occurrences after one of them fails', function () {
config(['constants.coolify.self_hosted' => true]);
Carbon::setTestNow(Carbon::create(2026, 9, 17, 0, 5, 0, 'UTC'));
$logPath = captureScheduledJobManagerTestLog('scheduled-errors');
$database = createScheduledBackupDatabase();
$occurrences = collect([createScheduledDatabaseBackup($database), createScheduledDatabaseBackup($database)])
->map(fn (ScheduledDatabaseBackup $backup) => ScheduledJobDelivery::create([
'schedule_key' => "scheduled-backup:{$backup->id}",
'scheduled_for' => now()->startOfMinute(),
'job_type' => 'database-backup',
'resource_id' => $backup->id,
'status' => 'pending',
]));
$dispatchAttempts = 0;
Bus::shouldReceive('dispatch')->andReturnUsing(function () use (&$dispatchAttempts) {
if (++$dispatchAttempts === 1) {
throw new RuntimeException('queue connection lost');
}
});
app(ScheduledJobDeliveryService::class)->publishPending();
$log = file_get_contents($logPath);
@unlink($logPath);
expect($occurrences->first()->fresh()->status)->toBe('pending')
->and($occurrences->last()->fresh()->status)->toBe('enqueued')
->and($log)->toContain('Failed to publish pending scheduled occurrence')
->toContain('queue connection lost');
});
it('logs a backup run that ends early, even when it completes before the enqueue log', function () {
config(['constants.coolify.self_hosted' => true]);
Carbon::setTestNow(Carbon::create(2026, 9, 17, 0, 5, 0, 'UTC'));
Event::fake([BackupCreated::class]);
Queue::fakeExcept([ScheduledJobManager::class, DatabaseBackupJob::class]);
$logPath = captureScheduledJobManagerTestLog('scheduled');
$database = createScheduledBackupDatabase();
$database->update(['status' => 'exited']);
$backup = createScheduledDatabaseBackup($database, ['frequency' => 'daily']);
dispatch(new ScheduledJobManager);
$log = file_get_contents($logPath);
@unlink($logPath);
expect($log)->toContain('Database backup job ended without a backup')
->toContain('"skip_reason":"database_not_running"')
->toContain('"database_status":"exited:unhealthy"')
->toContain('Scheduled occurrence completed')
->toMatch('/Scheduled occurrence enqueued \{[^\n]*"schedule_key":"scheduled-backup:'.$backup->id.'"[^\n]*"status":"enqueued"/')
->and(ScheduledJobDelivery::query()->where('schedule_key', "scheduled-backup:{$backup->id}")->exists())->toBeFalse();
});
it('runs the manager in the scheduler process through the scheduled:dispatch command', function () {
config(['constants.coolify.self_hosted' => true]);
Carbon::setTestNow(Carbon::create(2026, 5, 27, 0, 1, 0, 'UTC'));
Queue::fakeExcept(ScheduledJobManager::class);
$application = createScheduledTaskApplication();
createScheduledApplicationTask($application, ['frequency' => '* * * * *']);
$this->artisan('scheduled:dispatch')->assertSuccessful();
Queue::assertNotPushed(ScheduledJobManager::class);
Queue::assertPushed(ScheduledTaskJob::class, 1);
});
it('loads the server chain of scheduled database backups without a query for each backup', function () {
config(['constants.coolify.self_hosted' => true]);
Carbon::setTestNow(Carbon::create(2026, 9, 17, 0, 5, 0, 'UTC'));
Queue::fake();
$database = createScheduledBackupDatabase();
createScheduledDatabaseBackup($database, ['frequency' => '0 2 * * *']);
foreach (range(1, 2) as $number) {
$otherDatabase = StandalonePostgresql::create([
...$database->only(['image', 'postgres_user', 'postgres_password', 'postgres_db', 'status', 'environment_id', 'destination_id', 'destination_type']),
'name' => "database-{$number}",
]);
createScheduledDatabaseBackup($otherDatabase, ['frequency' => '0 2 * * *']);
}
createScheduledServiceDatabaseBackup(['frequency' => '0 2 * * *'], Application::query()->first());
DB::flushQueryLog();
DB::enableQueryLog();
(new ScheduledJobManager)->handle();
$queries = collect(DB::getQueryLog())->pluck('query');
DB::disableQueryLog();
$perBackupLookups = $queries->filter(fn (string $query) => str_contains($query, '"standalone_dockers"."id" = ?')
|| str_contains($query, '"services"."id" = ?')
|| str_contains($query, '"server_settings"."server_id" = ?'));
expect($perBackupLookups)->toBeEmpty();
});
it('dispatches a due backup of a service database', function () {
config(['constants.coolify.self_hosted' => true]);
Carbon::setTestNow(Carbon::create(2026, 9, 17, 0, 5, 0, 'UTC'));
Queue::fake();
$backup = createScheduledServiceDatabaseBackup(['frequency' => '* * * * *']);
(new ScheduledJobManager)->handle();
Queue::assertPushed(DatabaseBackupJob::class, fn (DatabaseBackupJob $job) => $job->backup->is($backup));
});
it('keeps dispatching other schedules when one schedule throws a PHP error', function () {
config(['constants.coolify.self_hosted' => true]);
Carbon::setTestNow(Carbon::create(2026, 9, 17, 0, 5, 0, 'UTC'));
$logPath = captureScheduledJobManagerTestLog('scheduled-errors');
$database = createScheduledBackupDatabase();
createScheduledDatabaseBackup($database);
createScheduledDatabaseBackup($database);
$dispatchAttempts = 0;
Bus::shouldReceive('dispatch')->andReturnUsing(function () use (&$dispatchAttempts) {
if (++$dispatchAttempts === 1) {
throw new Error('broken schedule');
}
});
(new ScheduledJobManager)->handle();
$log = file_get_contents($logPath);
@unlink($logPath);
// The first backup stays pending for publishPending(); the second one is still enqueued.
expect(ScheduledJobDelivery::query()->where('job_type', 'database-backup')->orderBy('id')->pluck('status')->all())->toBe(['pending', 'enqueued'])
->and($log)->toContain('Error processing backup')
->toContain('broken schedule');
});
it('republishes a backup occurrence whose queued job was lost, and the old job cannot run it again', function () {
config(['constants.coolify.self_hosted' => true]);
Carbon::setTestNow(Carbon::create(2026, 9, 17, 12, 0, 0, 'UTC'));
Queue::fake();
$backup = createScheduledDatabaseBackup(createScheduledBackupDatabase(), ['frequency' => 'daily']);
$occurrence = createStaleEnqueuedOccurrence("scheduled-backup:{$backup->id}", 'database-backup', $backup->id, Carbon::create(2026, 9, 17, 0, 0, 0, 'UTC'));
(new ScheduledJobManager)->handle();
Queue::assertPushed(DatabaseBackupJob::class, fn (DatabaseBackupJob $job) => $job->occurrenceUuid === $occurrence->uuid);
expect($occurrence->fresh()->status)->toBe('enqueued')
->and($occurrence->fresh()->enqueued_at->toDateTimeString())->toBe('2026-09-17 12:00:00');
$service = app(ScheduledJobDeliveryService::class);
expect($service->claim($occurrence->uuid, 'republished-job'))->toBeTrue()
->and($service->claim($occurrence->uuid, 'original-job'))->toBeFalse();
});
it('does not run a lost backup occurrence again when a newer occurrence exists', function () {
config(['constants.coolify.self_hosted' => true]);
Carbon::setTestNow(Carbon::create(2026, 9, 17, 12, 0, 0, 'UTC'));
Queue::fake();
$logPath = captureScheduledJobManagerTestLog('scheduled');
$backup = createScheduledDatabaseBackup(createScheduledBackupDatabase(), ['frequency' => 'hourly']);
$lost = createStaleEnqueuedOccurrence("scheduled-backup:{$backup->id}", 'database-backup', $backup->id, Carbon::create(2026, 9, 17, 10, 0, 0, 'UTC'));
createStaleEnqueuedOccurrence("scheduled-backup:{$backup->id}", 'database-backup', $backup->id, Carbon::create(2026, 9, 17, 11, 0, 0, 'UTC'), minutesAgo: 5);
(new ScheduledJobManager)->handle();
$log = file_get_contents($logPath);
@unlink($logPath);
Queue::assertNotPushed(DatabaseBackupJob::class, fn (DatabaseBackupJob $job) => $job->occurrenceUuid === $lost->uuid);
expect($lost->fresh()->status)->toBe('failed')
->and($log)->toContain('Scheduled occurrence skipped: its queued job was not started and a newer occurrence exists');
});
it('logs a lost scheduled task occurrence as missed instead of running it late', function () {
config(['constants.coolify.self_hosted' => true]);
Carbon::setTestNow(Carbon::create(2026, 9, 17, 12, 0, 0, 'UTC'));
Queue::fake();
$logPath = captureScheduledJobManagerTestLog('scheduled');
$task = createScheduledApplicationTask(createScheduledTaskApplication(), ['frequency' => 'daily']);
$occurrence = createStaleEnqueuedOccurrence("scheduled-task:{$task->id}", 'scheduled-task', $task->id, Carbon::create(2026, 9, 17, 0, 0, 0, 'UTC'));
(new ScheduledJobManager)->handle();
$log = file_get_contents($logPath);
@unlink($logPath);
Queue::assertNotPushed(ScheduledTaskJob::class);
expect($occurrence->fresh()->status)->toBe('failed')
->and(app(ScheduledJobDeliveryService::class)->claim($occurrence->uuid, 'original-job'))->toBeFalse()
->and($log)->toContain('Scheduled occurrence missed: its queued job was not started');
});
it('records a missed scheduled task as a failed execution and notifies the team once', function () {
config(['constants.coolify.self_hosted' => true]);
Carbon::setTestNow(Carbon::create(2026, 9, 17, 12, 0, 0, 'UTC'));
Queue::fake();
Notification::fake();
$task = createScheduledApplicationTask(createScheduledTaskApplication(), ['frequency' => 'daily']);
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
Team::find($task->team_id)->emailNotificationSettings->update(['smtp_enabled' => true, 'scheduled_task_failure_email_notifications' => true]);
$occurrence = createStaleEnqueuedOccurrence("scheduled-task:{$task->id}", 'scheduled-task', $task->id, Carbon::create(2026, 9, 17, 0, 0, 0, 'UTC'));
(new ScheduledJobManager)->handle();
(new ScheduledJobManager)->handle();
$executions = ScheduledTaskExecution::query()->where('scheduled_task_id', $task->id)->get();
expect($executions)->toHaveCount(1)
->and($executions->first()->status)->toBe('failed')
->and($executions->first()->message)->toBe('Skipped: the queued job did not start within 60 minutes.')
->and($executions->first()->finished_at)->not->toBeNull();
Notification::assertSentToTimes(Team::find($task->team_id), TaskFailed::class, 1);
Notification::assertSentTo(Team::find($task->team_id), TaskFailed::class, fn (TaskFailed $notification) => $notification->task->is($task)
&& $notification->output === 'Skipped: the queued job did not start within 60 minutes.');
$lateJob = new ScheduledTaskJob($task, $occurrence->uuid);
$lateJob->handle();
expect(ScheduledTaskExecution::query()->where('scheduled_task_id', $task->id)->count())->toBe(1);
});
it('records a missed Docker cleanup as a failed execution and notifies the team once', function () {
config(['constants.coolify.self_hosted' => true]);
Carbon::setTestNow(Carbon::create(2026, 9, 17, 12, 0, 0, 'UTC'));
Queue::fake();
Notification::fake();
Event::fake([DockerCleanupDone::class]);
$server = createScheduledTaskApplication()->destination->server;
$server->team->emailNotificationSettings->update(['smtp_enabled' => true, 'docker_cleanup_failure_email_notifications' => true]);
$occurrence = createStaleEnqueuedOccurrence("docker-cleanup:{$server->id}", 'docker-cleanup', $server->id, Carbon::create(2026, 9, 17, 0, 0, 0, 'UTC'));
(new ScheduledJobManager)->handle();
(new ScheduledJobManager)->handle();
$executions = DockerCleanupExecution::query()->where('server_id', $server->id)->get();
expect($executions)->toHaveCount(1)
->and($executions->first()->status)->toBe('failed')
->and($executions->first()->message)->toBe('Skipped: the queued job did not start within 60 minutes.')
->and($executions->first()->finished_at)->not->toBeNull()
->and($occurrence->fresh()->status)->toBe('failed')
->and(app(ScheduledJobDeliveryService::class)->claim($occurrence->uuid, 'original-job'))->toBeFalse();
Notification::assertSentToTimes($server->team, DockerCleanupFailed::class, 1);
Event::assertDispatched(DockerCleanupDone::class, 1);
});
it('records a missed database backup that is too old to run late as a failed execution and notifies the team once', function () {
config(['constants.coolify.self_hosted' => true]);
Carbon::setTestNow(Carbon::create(2026, 9, 17, 12, 0, 0, 'UTC'));
Queue::fake();
Notification::fake();
Event::fake([BackupCreated::class]);
$backup = createScheduledDatabaseBackup(createScheduledBackupDatabase(), ['frequency' => 'daily']);
$backup->team->emailNotificationSettings->update(['smtp_enabled' => true, 'backup_failure_email_notifications' => true]);
$occurrence = createStaleEnqueuedOccurrence("scheduled-backup:{$backup->id}", 'database-backup', $backup->id, Carbon::create(2026, 9, 16, 0, 0, 0, 'UTC'));
app(ScheduledJobDeliveryService::class)->recoverStaleEnqueued();
app(ScheduledJobDeliveryService::class)->recoverStaleEnqueued();
$executions = ScheduledDatabaseBackupExecution::query()->where('scheduled_database_backup_id', $backup->id)->get();
expect($executions)->toHaveCount(1)
->and($executions->first()->status)->toBe('failed')
->and($executions->first()->message)->toBe('Skipped: the queued job did not start within 60 minutes.')
->and($executions->first()->finished_at)->not->toBeNull()
->and($occurrence->fresh()->status)->toBe('failed');
Queue::assertNotPushed(DatabaseBackupJob::class);
Notification::assertSentToTimes($backup->team, BackupFailed::class, 1);
Notification::assertSentTo($backup->team, BackupFailed::class, fn (BackupFailed $notification) => $notification->output === 'Skipped: the queued job did not start within 60 minutes.');
Event::assertDispatched(BackupCreated::class);
});
it('sends at most one missed-run notification per hour for the same schedule', function (string $jobType) {
config(['constants.coolify.self_hosted' => true]);
Carbon::setTestNow(Carbon::create(2026, 9, 17, 12, 0, 0, 'UTC'));
Queue::fake();
Notification::fake();
Event::fake([DockerCleanupDone::class]);
if ($jobType === 'scheduled-task') {
$task = createScheduledApplicationTask(createScheduledTaskApplication(), ['frequency' => 'daily']);
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
$team = Team::find($task->team_id);
$team->emailNotificationSettings->update(['smtp_enabled' => true, 'scheduled_task_failure_email_notifications' => true]);
[$scheduleKey, $resourceId, $notification] = ["scheduled-task:{$task->id}", $task->id, TaskFailed::class];
$executions = fn () => ScheduledTaskExecution::query()->where('scheduled_task_id', $task->id)->count();
} else {
$server = createScheduledTaskApplication()->destination->server;
$team = $server->team;
$team->emailNotificationSettings->update(['smtp_enabled' => true, 'docker_cleanup_failure_email_notifications' => true]);
[$scheduleKey, $resourceId, $notification] = ["docker-cleanup:{$server->id}", $server->id, DockerCleanupFailed::class];
$executions = fn () => DockerCleanupExecution::query()->where('server_id', $server->id)->count();
}
// Workers are down: three runs of the same schedule were missed.
foreach ([0, 1, 2] as $minute) {
createStaleEnqueuedOccurrence($scheduleKey, $jobType, $resourceId, Carbon::create(2026, 9, 17, 10, $minute, 0, 'UTC'));
}
(new ScheduledJobManager)->handle();
expect($executions())->toBe(3);
Notification::assertSentToTimes($team, $notification, 1);
// More than an hour later, a new missed run is reported again.
Carbon::setTestNow(Carbon::create(2026, 9, 17, 13, 5, 0, 'UTC'));
createStaleEnqueuedOccurrence($scheduleKey, $jobType, $resourceId, Carbon::create(2026, 9, 17, 11, 30, 0, 'UTC'));
(new ScheduledJobManager)->handle();
// Each missed run still gets its own failed execution (Docker cleanup also has its own 12:00 run).
expect($executions())->toBeGreaterThanOrEqual(4);
Notification::assertSentToTimes($team, $notification, 2);
})->with(['scheduled task' => 'scheduled-task', 'docker cleanup' => 'docker-cleanup']);
it('leaves recently enqueued occurrences alone', function () {
config(['constants.coolify.self_hosted' => true]);
Carbon::setTestNow(Carbon::create(2026, 9, 17, 12, 0, 0, 'UTC'));
Queue::fake();
$backup = createScheduledDatabaseBackup(createScheduledBackupDatabase(), ['frequency' => 'daily']);
$occurrence = createStaleEnqueuedOccurrence("scheduled-backup:{$backup->id}", 'database-backup', $backup->id, Carbon::create(2026, 9, 17, 11, 0, 0, 'UTC'), minutesAgo: ScheduledJobDeliveryService::ENQUEUED_STALE_AFTER_MINUTES - 1);
(new ScheduledJobManager)->handle();
Queue::assertNotPushed(DatabaseBackupJob::class);
expect($occurrence->fresh()->enqueued_at->toDateTimeString())->toBe('2026-09-17 11:01:00');
});
it('closes occurrences that an earlier release left enqueued without failed executions or notifications', function () {
config(['constants.coolify.self_hosted' => true]);
Carbon::setTestNow(Carbon::create(2026, 9, 17, 12, 0, 0, 'UTC'));
Queue::fake();
Notification::fake();
Event::fake([DockerCleanupDone::class]);
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
$task = createScheduledApplicationTask(createScheduledTaskApplication(), ['frequency' => 'daily']);
$team = Team::find($task->team_id);
$team->emailNotificationSettings->update(['smtp_enabled' => true, 'scheduled_task_failure_email_notifications' => true, 'docker_cleanup_failure_email_notifications' => true]);
$server = $task->application->destination->server;
$backup = createScheduledDatabaseBackup(createScheduledBackupDatabase(), ['frequency' => 'daily']);
$legacy = [
createStaleEnqueuedOccurrence("scheduled-task:{$task->id}", 'scheduled-task', $task->id, Carbon::create(2026, 9, 10, 0, 0, 0, 'UTC'), minutesAgo: 7 * 24 * 60),
createStaleEnqueuedOccurrence("docker-cleanup:{$server->id}", 'docker-cleanup', $server->id, Carbon::create(2026, 9, 17, 9, 0, 0, 'UTC'), minutesAgo: 180),
createStaleEnqueuedOccurrence("scheduled-backup:{$backup->id}", 'database-backup', $backup->id, Carbon::create(2026, 9, 17, 0, 0, 0, 'UTC')),
];
// A recent job can still be waiting in the queue.
$recent = createStaleEnqueuedOccurrence("scheduled-task:{$task->id}", 'scheduled-task', $task->id, Carbon::create(2026, 9, 17, 11, 55, 0, 'UTC'), minutesAgo: 5);
(require database_path('migrations/2026_10_05_064841_close_legacy_enqueued_scheduled_job_deliveries.php'))->up();
(new ScheduledJobManager)->handle();
foreach ($legacy as $occurrence) {
expect($occurrence->fresh()->status)->toBe('skipped');
}
expect($recent->fresh()->status)->toBe('enqueued')
->and(ScheduledTaskExecution::query()->exists())->toBeFalse()
->and(DockerCleanupExecution::query()->exists())->toBeFalse();
Queue::assertNotPushed(DatabaseBackupJob::class);
Notification::assertNothingSent();
});
it('dispatches only the schedules of its own type', function (string $type, array $expected) {
config(['constants.coolify.self_hosted' => true]);
Carbon::setTestNow(Carbon::create(2026, 9, 17, 1, 0, 0, 'UTC'));
Queue::fakeExcept(ScheduledJobManager::class);
$application = createScheduledTaskApplication();
createScheduledApplicationTask($application, ['frequency' => '* * * * *']);
createScheduledDatabaseBackup(createScheduledBackupDatabase(), ['frequency' => '* * * * *']);
$this->artisan('scheduled:dispatch', ['--type' => $type])->assertSuccessful();
Queue::assertPushed(DatabaseBackupJob::class, $expected['backups']);
Queue::assertPushed(ScheduledTaskJob::class, $expected['tasks']);
Queue::assertPushed(DockerCleanupJob::class, $expected['docker-cleanups']);
})->with([
'backups' => ['backups', ['backups' => 1, 'tasks' => 0, 'docker-cleanups' => 0]],
'tasks' => ['tasks', ['backups' => 0, 'tasks' => 1, 'docker-cleanups' => 0]],
// Two servers: the task application and the backup database are on separate test servers.
'docker cleanups' => ['docker-cleanups', ['backups' => 0, 'tasks' => 0, 'docker-cleanups' => 2]],
]);
it('dispatches each occurrence once when the type dispatchers run after each other', function () {
config(['constants.coolify.self_hosted' => true]);
Carbon::setTestNow(Carbon::create(2026, 9, 17, 1, 0, 0, 'UTC'));
Queue::fakeExcept(ScheduledJobManager::class);
createScheduledApplicationTask(createScheduledTaskApplication(), ['frequency' => '* * * * *']);
createScheduledDatabaseBackup(createScheduledBackupDatabase(), ['frequency' => '* * * * *']);
foreach (array_keys(ScheduledJobManager::TYPES) as $type) {
$this->artisan('scheduled:dispatch', ['--type' => $type])->assertSuccessful();
}
(new ScheduledJobManager)->handle();
Queue::assertPushed(DatabaseBackupJob::class, 1);
Queue::assertPushed(ScheduledTaskJob::class, 1);
Queue::assertPushed(DockerCleanupJob::class, 2);
});
it('publishes and recovers only the deliveries of its own type', function () {
config(['constants.coolify.self_hosted' => true]);
Carbon::setTestNow(Carbon::create(2026, 9, 17, 12, 0, 0, 'UTC'));
Queue::fakeExcept(ScheduledJobManager::class);
$task = createScheduledApplicationTask(createScheduledTaskApplication(), ['frequency' => 'daily']);
$backup = createScheduledDatabaseBackup(createScheduledBackupDatabase(), ['frequency' => 'daily']);
$pendingTask = ScheduledJobDelivery::create([
'schedule_key' => "scheduled-task:{$task->id}",
'scheduled_for' => Carbon::create(2026, 9, 17, 0, 0, 0, 'UTC'),
'job_type' => 'scheduled-task',
'resource_id' => $task->id,
]);
$lostBackup = createStaleEnqueuedOccurrence("scheduled-backup:{$backup->id}", 'database-backup', $backup->id, Carbon::create(2026, 9, 17, 0, 0, 0, 'UTC'));
$this->artisan('scheduled:dispatch', ['--type' => 'tasks'])->assertSuccessful();
Queue::assertPushed(ScheduledTaskJob::class, 1);
Queue::assertNotPushed(DatabaseBackupJob::class);
expect($pendingTask->fresh()->status)->toBe('enqueued')
->and($lostBackup->fresh()->status)->toBe('enqueued');
$this->artisan('scheduled:dispatch', ['--type' => 'backups'])->assertSuccessful();
Queue::assertPushed(DatabaseBackupJob::class, fn (DatabaseBackupJob $job) => $job->occurrenceUuid === $lostBackup->uuid);
});
it('uses a separate overlap lock for each type', function () {
$lockKeys = collect([null, ...array_keys(ScheduledJobManager::TYPES)])
->map(fn (?string $type) => (new ReflectionProperty(WithoutOverlapping::class, 'key'))->getValue((new ScheduledJobManager($type))->middleware()[0]));
expect($lockKeys->all())->toBe([
'scheduled-job-manager',
'scheduled-job-manager:backups',
'scheduled-job-manager:tasks',
'scheduled-job-manager:volume-backups',
'scheduled-job-manager:docker-cleanups',
]);
});
it('rejects an unknown schedule type', function () {
Queue::fake();
$this->artisan('scheduled:dispatch', ['--type' => 'invalid'])->assertFailed();
Queue::assertNothingPushed();
});
it('reports a task occurrence as missed when its publication kept failing for an hour', function () {
config(['constants.coolify.self_hosted' => true]);
Carbon::setTestNow(Carbon::create(2026, 9, 17, 12, 0, 0, 'UTC'));
Queue::fake();
Notification::fake();
$task = createScheduledApplicationTask(createScheduledTaskApplication(), ['frequency' => 'daily']);
$occurrence = ScheduledJobDelivery::create([
'schedule_key' => "scheduled-task:{$task->id}",
'scheduled_for' => Carbon::create(2026, 9, 17, 0, 0, 0, 'UTC'),
'job_type' => 'scheduled-task',
'resource_id' => $task->id,
'status' => 'pending',
]);
$occurrence->forceFill(['created_at' => now()->subMinutes(ScheduledJobDeliveryService::ENQUEUED_STALE_AFTER_MINUTES + 1)])->save();
app(ScheduledJobDeliveryService::class)->publishPending();
Queue::assertNotPushed(ScheduledTaskJob::class);
expect($occurrence->fresh()->status)->toBe('failed')
->and(ScheduledTaskExecution::query()->where('scheduled_task_id', $task->id)->value('status'))->toBe('failed');
});
it('publishes a backup occurrence once late when its publication kept failing and no newer occurrence exists', function () {
config(['constants.coolify.self_hosted' => true]);
Carbon::setTestNow(Carbon::create(2026, 9, 17, 12, 0, 0, 'UTC'));
Queue::fake();
$backup = createScheduledDatabaseBackup(createScheduledBackupDatabase(), ['frequency' => 'daily']);
$occurrence = ScheduledJobDelivery::create([
'schedule_key' => "scheduled-backup:{$backup->id}",
'scheduled_for' => Carbon::create(2026, 9, 17, 0, 0, 0, 'UTC'),
'job_type' => 'database-backup',
'resource_id' => $backup->id,
'status' => 'pending',
]);
$occurrence->forceFill(['created_at' => now()->subHours(2)])->save();
app(ScheduledJobDeliveryService::class)->publishPending();
Queue::assertPushed(DatabaseBackupJob::class, fn (DatabaseBackupJob $job) => $job->occurrenceUuid === $occurrence->uuid);
expect($occurrence->fresh()->status)->toBe('enqueued');
});
it('does not run a lost backup occurrence late when it is older than the late-run limit', function () {
config(['constants.coolify.self_hosted' => true]);
Carbon::setTestNow(Carbon::create(2026, 9, 17, 12, 0, 0, 'UTC'));
Queue::fake();
$logPath = captureScheduledJobManagerTestLog('scheduled');
$backup = createScheduledDatabaseBackup(createScheduledBackupDatabase(), ['frequency' => 'weekly']);
$occurrence = createStaleEnqueuedOccurrence(
"scheduled-backup:{$backup->id}",
'database-backup',
$backup->id,
now()->subHours(ScheduledJobDeliveryService::LATE_RUN_MAX_AGE_HOURS + 1),
minutesAgo: (ScheduledJobDeliveryService::LATE_RUN_MAX_AGE_HOURS + 1) * 60,
);
app(ScheduledJobDeliveryService::class)->recoverStaleEnqueued();
$log = file_get_contents($logPath);
@unlink($logPath);
Queue::assertNotPushed(DatabaseBackupJob::class);
expect($occurrence->fresh()->status)->toBe('failed')
->and($log)->toContain('too old to run late');
});
it('fails claimed occurrences whose worker can no longer be running, and keeps recent claims', function () {
Carbon::setTestNow(Carbon::create(2026, 9, 17, 12, 0, 0, 'UTC'));
$deadAfterSeconds = (int) config('horizon.worker_timeout') + ScheduledJobDeliveryService::INTERRUPTED_CLAIM_MARGIN_MINUTES * 60;
$create = fn (string $key, int $startedSecondsAgo) => ScheduledJobDelivery::create([
'schedule_key' => $key,
'scheduled_for' => now()->subSeconds($startedSecondsAgo),
'job_type' => 'scheduled-task',
'resource_id' => 1,
'status' => 'claimed',
'claim_token' => "token-{$key}",
'started_at' => now()->subSeconds($startedSecondsAgo),
]);
$interrupted = $create('scheduled-task:interrupted', $deadAfterSeconds + 60);
$running = $create('scheduled-task:running', $deadAfterSeconds - 60);
$service = app(ScheduledJobDeliveryService::class);
$service->failInterruptedClaims();
expect($interrupted->fresh()->status)->toBe('failed')
->and($running->fresh()->status)->toBe('claimed')
// The job copy that Redis hands out again later cannot claim the failed occurrence.
->and($service->claim($interrupted->uuid, 'token-scheduled-task:interrupted'))->toBeFalse();
});
it('does not run a scheduled task again when Redis hands it out after its worker was killed', function () {
$task = createScheduledApplicationTask(createScheduledTaskApplication());
$occurrence = ScheduledJobDelivery::create([
'schedule_key' => "scheduled-task:{$task->id}",
'scheduled_for' => now()->subDay(),
'job_type' => 'scheduled-task',
'resource_id' => $task->id,
'status' => 'claimed',
'claim_token' => 'killed-worker',
'started_at' => now()->subDay(),
]);
$job = (new ScheduledTaskJob($task, $occurrence->uuid))->withFakeQueueInteractions();
$job->job->attempts = 2;
$job->handle();
$job->assertFailed();
expect(ScheduledTaskExecution::query()->where('scheduled_task_id', $task->id)->exists())->toBeFalse();
});
it('drops a scheduled task job while an earlier run of the same task holds the overlap lock', function () {
$task = createScheduledApplicationTask(createScheduledTaskApplication(), ['timeout' => 4200]);
$occurrence = ScheduledJobDelivery::create([
'schedule_key' => "scheduled-task:{$task->id}",
'scheduled_for' => now(),
'job_type' => 'scheduled-task',
'resource_id' => $task->id,
'status' => 'enqueued',
'enqueued_at' => now(),
]);
$job = (new ScheduledTaskJob($task, $occurrence->uuid))->withFakeQueueInteractions();
$middleware = $job->middleware()[0];
$runningLock = Cache::lock($middleware->getLockKey($job), 60);
$runningLock->get();
$middleware->handle($job, fn (ScheduledTaskJob $job) => $job->handle());
$otherTaskJob = new ScheduledTaskJob(createScheduledApplicationTask(createScheduledTaskApplication()));
$job->assertNotReleased();
expect($middleware->expiresAfter)->toBe(4200 + 300)
->and($occurrence->fresh()->status)->toBe('skipped')
->and(ScheduledTaskExecution::query()->where('scheduled_task_id', $task->id)->exists())->toBeFalse()
->and($otherTaskJob->middleware()[0]->getLockKey($otherTaskJob))->not->toBe($middleware->getLockKey($job));
$runningLock->release();
});
it('fails only its own execution and notifies the team when the worker timeout stops a scheduled task job', function () {
InstanceSettings::forceCreate(['id' => 0]);
Notification::fake();
Carbon::setTestNow(Carbon::create(2026, 9, 17, 12, 0, 0, 'UTC'));
$application = createScheduledTaskApplication();
$task = createScheduledApplicationTask($application, ['timeout' => 300]);
$team = Team::find($task->team_id);
$team->emailNotificationSettings->update(['smtp_enabled' => true, 'scheduled_task_failure_email_notifications' => true]);
$finished = ScheduledTaskExecution::create(['scheduled_task_id' => $task->id, 'status' => 'success', 'started_at' => now()->subHour(), 'finished_at' => now()->subMinutes(59)]);
$job = new ScheduledTaskJob($task);
// At the worker timeout, failed() runs on a copy rebuilt from the queued payload.
$queuedCopy = unserialize(serialize($job));
$commands = collect();
$atTimeout = null;
Process::fake(function ($process) use ($application, $task, $queuedCopy, $team, $commands, &$atTimeout) {
$commands->push($process->command);
if (str_contains($process->command, 'docker ps')) {
return Process::result(output: json_encode(['Names' => 'app-'.$application->uuid, 'Labels' => "coolify.applicationUuid={$application->uuid}", 'State' => 'running']));
}
if (str_contains($process->command, 'docker exec')) {
Carbon::setTestNow(now()->addMinute());
$newerRun = ScheduledTaskExecution::create(['scheduled_task_id' => $task->id, 'status' => 'running', 'started_at' => now()]);
$ownRun = ScheduledTaskExecution::query()->whereKeyNot([$newerRun->id])->where('status', 'running')->sole();
$queuedCopy->failed(new TimeoutExceededException('App\Jobs\ScheduledTaskJob has timed out.'));
$atTimeout = [
'own' => $ownRun->fresh()->status,
'own_finished' => $ownRun->fresh()->finished_at !== null,
'newer' => $newerRun->fresh()->status,
'notifications' => Notification::sent($team, TaskFailed::class)->count(),
];
throw new RuntimeException('The worker stopped the job.');
}
return Process::result(output: '');
});
expect(fn () => $job->handle())->toThrow(RuntimeException::class);
expect($atTimeout)->toBe(['own' => 'failed', 'own_finished' => true, 'newer' => 'running', 'notifications' => 1])
->and($finished->fresh()->status)->toBe('success')
->and($commands->first(fn (string $command) => str_contains($command, 'docker exec')))->toStartWith('timeout 300 ssh ')
->and($job->timeout)->toBeGreaterThan(300);
});
it('runs a fixed-time schedule once when daylight saving time repeats its local time', function () {
$service = app(ScheduledJobDeliveryService::class);
$record = fn (string $frequency, string $utc) => $service->recordSkipped("dst:{$frequency}", $frequency, 'Europe/Berlin', Carbon::parse($utc, 'UTC'));
// 2026-10-25: Berlin goes from 03:00 CEST back to 02:00 CET, so 02:30 happens at 00:30Z and 01:30Z.
expect($record('30 2 * * *', '2026-10-25 00:31:00'))->toBeTrue()
->and($record('30 2 * * *', '2026-10-25 01:31:00'))->toBeFalse()
->and($record('30 * * * *', '2026-10-25 00:31:00'))->toBeTrue()
->and($record('30 * * * *', '2026-10-25 01:31:00'))->toBeTrue();
});
it('publishes one backup when daylight saving time repeats the local backup time', function () {
config(['constants.coolify.self_hosted' => true]);
Queue::fake();
$backup = createScheduledDatabaseBackup(createScheduledBackupDatabase(), ['frequency' => '30 2 * * *']);
$service = app(ScheduledJobDeliveryService::class);
foreach (['2026-10-25 00:31:00', '2026-10-25 01:31:00'] as $utc) {
$service->recordAndPublish("scheduled-backup:{$backup->id}", '30 2 * * *', 'Europe/Berlin', 'database-backup', $backup->id, executionTime: Carbon::parse($utc, 'UTC'));
}
Queue::assertPushed(DatabaseBackupJob::class, 1);
});
it('does not run a fixed time that does not exist when daylight saving time starts', function () {
$service = app(ScheduledJobDeliveryService::class);
// 2026-03-29: Berlin skips from 02:00 CET to 03:00 CEST, so 02:30 does not exist that day.
expect($service->recordSkipped('dst:spring', '30 2 * * *', 'Europe/Berlin', Carbon::parse('2026-03-29 01:01:00', 'UTC')))->toBeFalse()
->and($service->recordSkipped('dst:spring', '30 2 * * *', 'Europe/Berlin', Carbon::parse('2026-03-30 00:31:00', 'UTC')))->toBeTrue();
});
it('records a midnight schedule in the server timezone, not in UTC', function () {
$service = app(ScheduledJobDeliveryService::class);
// Midnight in New York (EDT) is 04:00Z; 00:05Z is 20:05 the day before in New York.
expect($service->recordSkipped('midnight', '0 0 * * *', 'America/New_York', Carbon::parse('2026-10-03 00:05:00', 'UTC')))->toBeFalse()
->and($service->recordSkipped('midnight', '0 0 * * *', 'America/New_York', Carbon::parse('2026-10-03 04:05:00', 'UTC')))->toBeTrue()
->and($service->recordSkipped('midnight', '0 0 * * *', 'America/New_York', Carbon::parse('2026-10-03 04:09:00', 'UTC')))->toBeFalse()
->and(ScheduledJobState::query()->where('schedule_key', 'midnight')->value('last_scheduled_for')->toDateTimeString())->toBe('2026-10-03 04:00:00');
});
function createStaleEnqueuedOccurrence(string $scheduleKey, string $jobType, int $resourceId, Carbon $scheduledFor, int $minutesAgo = ScheduledJobDeliveryService::ENQUEUED_STALE_AFTER_MINUTES + 1): ScheduledJobDelivery
{
return ScheduledJobDelivery::create([
'schedule_key' => $scheduleKey,
'scheduled_for' => $scheduledFor,
'job_type' => $jobType,
'resource_id' => $resourceId,
'status' => 'enqueued',
'enqueued_at' => now()->subMinutes($minutesAgo),
]);
}
function createScheduledServiceDatabaseBackup(array $overrides = [], ?Application $application = null): ScheduledDatabaseBackup
{
$application ??= createScheduledTaskApplication();
$service = Service::factory()->create([
'environment_id' => $application->environment_id,
'destination_id' => $application->destination_id,
'destination_type' => $application->destination_type,
]);
$database = ServiceDatabase::create([
'name' => 'postgres',
'image' => 'postgres:16-alpine',
'service_id' => $service->id,
]);
$backup = new ScheduledDatabaseBackup;
$backup->forceFill(array_merge([
'enabled' => true,
'save_s3' => false,
'frequency' => '* * * * *',
'database_id' => $database->id,
'database_type' => $database->getMorphClass(),
'team_id' => $application->environment->project->team_id,
], $overrides));
$backup->save();
return $backup->fresh();
}
function captureScheduledJobManagerTestLog(string $channel): string
{
$path = tempnam(sys_get_temp_dir(), 'coolify-scheduled-log-');
config(["logging.channels.{$channel}" => ['driver' => 'single', 'path' => $path, 'level' => 'debug']]);
Log::forgetChannel($channel);
return $path;
}
function createScheduledTaskApplication(): Application
{
$team = Team::factory()->create();
$privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
-----END OPENSSH PRIVATE KEY-----',
'team_id' => $team->id,
]);
$server = Server::factory()->create([
'team_id' => $team->id,
'private_key_id' => $privateKey->id,
]);
$server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
'force_disabled' => false,
'docker_cleanup_frequency' => '0 * * * *',
]);
$destination = StandaloneDocker::where('server_id', $server->id)->first()
?? StandaloneDocker::factory()->create(['server_id' => $server->id]);
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
return Application::factory()->create([
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => StandaloneDocker::class,
'status' => 'running',
]);
}
function createScheduledBackupDatabase(): StandalonePostgresql
{
$application = createScheduledTaskApplication();
return StandalonePostgresql::create([
'name' => 'coolify-db',
'image' => 'postgres:16-alpine',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'postgres',
'status' => 'running',
'environment_id' => $application->environment_id,
'destination_id' => $application->destination_id,
'destination_type' => $application->destination_type,
]);
}
function createScheduledDatabaseBackup(StandalonePostgresql $database, array $overrides = []): ScheduledDatabaseBackup
{
$backup = new ScheduledDatabaseBackup;
$backup->forceFill(array_merge([
'enabled' => true,
'save_s3' => false,
'frequency' => '* * * * *',
'database_id' => $database->id,
'database_type' => $database->getMorphClass(),
'team_id' => $database->environment->project->team_id,
], $overrides));
$backup->save();
return $backup->fresh();
}
function createScheduledApplicationTask(Application $application, array $overrides = []): ScheduledTask
{
$task = new ScheduledTask;
$task->forceFill(array_merge([
'name' => 'scheduled-task',
'command' => 'echo hello',
'frequency' => '* * * * *',
'timeout' => 300,
'enabled' => true,
'team_id' => $application->environment->project->team_id,
'application_id' => $application->id,
], $overrides));
$task->save();
return $task->fresh();
}