fix(backups): include id 0 when paging scheduled jobs

Start backup and task pagination without an id lower bound so a legacy coolify-db backup or scheduled task at id 0 is dispatched with later positive ids. Document instance sentinel ids so they are not treated as ordinary autoincrement rows.
This commit is contained in:
Andras Bacsai
2026-08-13 11:08:54 +02:00
parent 6481ffffcf
commit 13948ac789
3 changed files with 133 additions and 6 deletions
+17
View File
@@ -122,6 +122,23 @@ function loginAsRoot(): mixed
- **Project/Environment** — Organizational hierarchy: Team → Project → Environment → Resources.
- **Proxy** — Traefik reverse proxy managed per server.
### Instance sentinels (`id = 0`)
Coolify seeds **instance-owned** rows at primary key `0`. That value is a sentinel meaning “this is the Coolify instance itself”, not a normal autoincrement id. Do not migrate, resequence, or “fix” these to a positive id.
| Record | Model / lookup | Meaning |
|---|---|---|
| Root team | `Team::find(0)`, `team_id === 0` | Instance / root team. Cloud billing and many skip-checks exempt `team_id === 0`. |
| Localhost server | `Server::find(0)` / `findOrFail(0)` | The machine running Coolify. Upgrades, instance backups, and docker inspect target this server. |
| Instance settings | `InstanceSettings` with `id = 0` | Singleton settings row. Tests must seed `InstanceSettings::create(['id' => 0])` (or `forceCreate`). |
| Instance Postgres | `StandalonePostgresql` `id = 0`, name `coolify-db` | Coolifys own database. UI treats `database_id === 0` as the instance DB (e.g. hide delete on backup screens). |
| Local docker dest | `StandaloneDocker` `id = 0` | Destination on the localhost server (`destination_id = 0`). |
| Root user / default GitHub App | seeders | First-install defaults. |
**Do not assign `id = 0` to new or non-instance rows.** In particular, `ScheduledDatabaseBackup` and `ScheduledTask` are ordinary schedules. Legacy installs may still have a `coolify-db` backup at `id = 0`; resolve that backup via the `coolify-db` relation / uuid, not `ScheduledDatabaseBackup::find(0)`.
`0` is a PHP/Eloquent landmine (`empty(0)` is true; keyset pagination `where('id', '>', $cursor)` starting at `0` skips the row). Queries that page by id must include `id = 0` on the first page (no lower bound, or cursor `< 0`). Prefer `chunkById()` over a hand-rolled `id > 0` cursor.
### Frontend
- Livewire 3 components with Alpine.js for client-side interactivity
- Blade templates in `resources/views/livewire/`
+6 -6
View File
@@ -149,8 +149,8 @@ class ScheduledJobManager implements ShouldQueue
private function processScheduledBackupsAndTasks(): void
{
$lastBackupId = 0;
$lastTaskId = 0;
$lastBackupId = null;
$lastTaskId = null;
do {
$backups = $this->scheduledBackupQuery($lastBackupId)->get();
@@ -190,16 +190,16 @@ class ScheduledJobManager implements ShouldQueue
}
}
private function scheduledBackupQuery(int $lastBackupId): Builder
private function scheduledBackupQuery(?int $lastBackupId): Builder
{
return ScheduledDatabaseBackup::with(['database', 'team.subscription'])
->where('enabled', true)
->where('id', '>', $lastBackupId)
->when($lastBackupId !== null, fn (Builder $query) => $query->where('id', '>', $lastBackupId))
->orderBy('id')
->limit(self::CHUNK_SIZE);
}
private function scheduledTaskQuery(int $lastTaskId): Builder
private function scheduledTaskQuery(?int $lastTaskId): Builder
{
return ScheduledTask::with([
'service.destination.server.settings',
@@ -208,7 +208,7 @@ class ScheduledJobManager implements ShouldQueue
'application.destination.server.team.subscription',
])
->where('enabled', true)
->where('id', '>', $lastTaskId)
->when($lastTaskId !== null, fn (Builder $query) => $query->where('id', '>', $lastTaskId))
->orderBy('id')
->limit(self::CHUNK_SIZE);
}
@@ -1,14 +1,17 @@
<?php
use App\Jobs\DatabaseBackupJob;
use App\Jobs\ScheduledJobManager;
use App\Jobs\ScheduledTaskJob;
use App\Models\Application;
use App\Models\Environment;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledTask;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
@@ -91,6 +94,63 @@ it('skips expensive dispatch for non-due schedules while seeding dedup cache', f
expect(Cache::get("scheduled-task:{$task->id}"))->not->toBeNull();
});
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();
@@ -148,3 +208,53 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
'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();
}