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('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(); }