mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-14 16:52:45 -04:00
The scheduled job manager checked every enabled schedule each minute and evaluated its cron expression in PHP. On Coolify Cloud one run took more than 60 seconds, so runs overlapped and minutes were skipped. Each schedule now stores its next due time: - next_run_at on scheduled database backups, scheduled tasks, and volume backups, and docker_cleanup_next_run_at on server settings (UTC, indexed) - model hooks calculate it when a schedule is created or its frequency or enabled flag changes; a server timezone change or a move to another server calculates it again for the affected schedules The `scheduled:dispatch` command runs the manager every minute from the scheduler, not from a queue. It selects only due rows and claims each occurrence with an atomic update of next_run_at, together with its delivery record, so parallel runs on several nodes never dispatch it twice. - database and volume backups run once when they are late; tasks and Docker cleanups more than 10 minutes late are logged as missed - a local time that occurs two times when the clocks go back runs once - the migration continues each schedule after its last recorded occurrence and drops scheduled_job_states Show the next run, in the server timezone, on the database backup, scheduled task, volume backup, and Docker cleanup pages, or "Invalid frequency" when the frequency cannot be calculated. scheduled:diagnostics now lists overdue schedules and open deliveries. Remove workarounds that are no longer needed: the queue overlap lock and its stale-lock repair, the catch-up window, and debug-only logs.
131 lines
4.6 KiB
PHP
131 lines
4.6 KiB
PHP
<?php
|
|
|
|
use App\Jobs\CleanupOrphanedPreviewContainersJob;
|
|
use App\Jobs\ScheduledJobManager;
|
|
use App\Models\PrivateKey;
|
|
use App\Models\Server;
|
|
use App\Models\ServerSetting;
|
|
use App\Models\Team;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Tests\TestCase;
|
|
|
|
uses(TestCase::class, RefreshDatabase::class);
|
|
|
|
function createServerForPlaceholderIpTest(string $ip): Server
|
|
{
|
|
$team = Team::create([
|
|
'name' => 'Test Team',
|
|
'personal_team' => false,
|
|
]);
|
|
$privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
|
|
|
|
return Server::factory()->create([
|
|
'team_id' => $team->id,
|
|
'private_key_id' => $privateKey->id,
|
|
'ip' => $ip,
|
|
]);
|
|
}
|
|
|
|
it('detects placeholder IPs', function () {
|
|
$server = createServerForPlaceholderIpTest(Server::PLACEHOLDER_IP);
|
|
|
|
foreach ([Server::PLACEHOLDER_IP, '0.0.0.0', '::'] as $ip) {
|
|
$server->update(['ip' => $ip]);
|
|
|
|
expect($server->fresh()->hasPlaceholderIp())->toBeTrue();
|
|
}
|
|
});
|
|
|
|
it('does not treat a real IP as a placeholder', function () {
|
|
$server = createServerForPlaceholderIpTest('203.0.113.5');
|
|
|
|
expect($server->hasPlaceholderIp())->toBeFalse();
|
|
});
|
|
|
|
it('backfills a placeholder IP with the real address', function () {
|
|
$server = createServerForPlaceholderIpTest(Server::PLACEHOLDER_IP);
|
|
|
|
expect($server->backfillPlaceholderIp('203.0.113.5'))->toBeTrue();
|
|
expect($server->fresh()->ip)->toBe('203.0.113.5');
|
|
});
|
|
|
|
it('does not overwrite a real IP when backfilling', function () {
|
|
$server = createServerForPlaceholderIpTest('203.0.113.5');
|
|
|
|
expect($server->backfillPlaceholderIp('198.51.100.9'))->toBeFalse();
|
|
expect($server->fresh()->ip)->toBe('203.0.113.5');
|
|
});
|
|
|
|
it('ignores a missing IP when backfilling', function () {
|
|
$server = createServerForPlaceholderIpTest(Server::PLACEHOLDER_IP);
|
|
|
|
expect($server->backfillPlaceholderIp(null))->toBeFalse();
|
|
expect($server->fresh()->ip)->toBe(Server::PLACEHOLDER_IP);
|
|
});
|
|
|
|
it('does not replace a placeholder with another placeholder', function (string $replacementIp) {
|
|
$server = createServerForPlaceholderIpTest(Server::PLACEHOLDER_IP);
|
|
|
|
expect($server->backfillPlaceholderIp($replacementIp))->toBeFalse();
|
|
expect($server->fresh()->ip)->toBe(Server::PLACEHOLDER_IP);
|
|
})->with([
|
|
'unspecified IPv4 address' => '0.0.0.0',
|
|
'unspecified IPv6 address' => '::',
|
|
]);
|
|
|
|
it('does not overwrite an address configured after the placeholder model was loaded', function () {
|
|
$staleServer = createServerForPlaceholderIpTest(Server::PLACEHOLDER_IP);
|
|
$concurrentServer = Server::query()->findOrFail($staleServer->id);
|
|
|
|
$concurrentServer->update(['ip' => 'server.internal.example.com']);
|
|
|
|
expect($staleServer->backfillPlaceholderIp('203.0.113.10'))->toBeFalse()
|
|
->and($staleServer->fresh()->ip)->toBe('server.internal.example.com');
|
|
});
|
|
|
|
it('skips servers with a placeholder IP in scheduled jobs', function () {
|
|
$server = createServerForPlaceholderIpTest(Server::PLACEHOLDER_IP);
|
|
|
|
expect($server->skipServer())->toBeTrue();
|
|
});
|
|
|
|
it('excludes every placeholder address from scheduled Docker cleanup', function () {
|
|
$realServer = createServerForPlaceholderIpTest('203.0.113.10');
|
|
|
|
foreach (Server::PLACEHOLDER_IPS as $placeholderIp) {
|
|
Server::factory()->create([
|
|
'team_id' => $realServer->team_id,
|
|
'private_key_id' => $realServer->private_key_id,
|
|
'ip' => $placeholderIp,
|
|
]);
|
|
}
|
|
|
|
ServerSetting::query()->update(['docker_cleanup_next_run_at' => null]);
|
|
Queue::fake();
|
|
|
|
(new ScheduledJobManager)->handle();
|
|
|
|
// The dispatcher calculates the next cleanup only for servers that it selects.
|
|
expect(ServerSetting::query()->whereNotNull('docker_cleanup_next_run_at')->pluck('server_id')->all())->toBe([$realServer->id]);
|
|
});
|
|
|
|
it('excludes every placeholder address from orphaned preview cleanup', function () {
|
|
$realServer = createServerForPlaceholderIpTest('203.0.113.10');
|
|
$realServer->settings->update(['is_reachable' => true, 'is_usable' => true]);
|
|
|
|
foreach (Server::PLACEHOLDER_IPS as $placeholderIp) {
|
|
$server = Server::factory()->create([
|
|
'team_id' => $realServer->team_id,
|
|
'private_key_id' => $realServer->private_key_id,
|
|
'ip' => $placeholderIp,
|
|
]);
|
|
$server->settings->update(['is_reachable' => true, 'is_usable' => true]);
|
|
}
|
|
|
|
$method = new ReflectionMethod(CleanupOrphanedPreviewContainersJob::class, 'getServersToCheck');
|
|
$servers = $method->invoke(new CleanupOrphanedPreviewContainersJob);
|
|
|
|
expect($servers->modelKeys())->toBe([$realServer->id]);
|
|
});
|