From 14523ed738eceaa40f792f425169b7cd62dd2214 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:20:32 +0200 Subject: [PATCH] fix(backups): reset per-database state in a multi-database backup When the execution of the next database could not be created, the error handler used the state of the previous database. It marked the successful backup of that database as failed and could delete its file. - Reset the execution, location, and output at the start of each database --- app/Jobs/DatabaseBackupJob.php | 7 ++ .../DatabaseBackupPerDatabaseStateTest.php | 75 +++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 tests/Feature/DatabaseBackupPerDatabaseStateTest.php diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 0cb2b4d9b0..b0117a4039 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -323,6 +323,13 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $this->backup_dir = backup_dir().'/coolify'."/coolify-db-$ip"; } foreach ($databasesToBackup as $database) { + // Reset per-database state, so a failure before this database's execution exists + // does not mark the previous database's execution failed or delete its backup file. + $this->backup_log = null; + $this->backup_location = null; + $this->backup_output = null; + $this->error_output = null; + // Generate unique UUID for each database backup execution $attempts = 0; do { diff --git a/tests/Feature/DatabaseBackupPerDatabaseStateTest.php b/tests/Feature/DatabaseBackupPerDatabaseStateTest.php new file mode 100644 index 0000000000..24e318c712 --- /dev/null +++ b/tests/Feature/DatabaseBackupPerDatabaseStateTest.php @@ -0,0 +1,75 @@ + InstanceSettings::firstOrCreate(['id' => 0])); +}); + +afterEach(function () { + ScheduledDatabaseBackupExecution::flushEventListeners(); +}); + +it('keeps the successful execution of an earlier database when creating the next execution fails', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id, + ]); + $server->settings()->update(['is_reachable' => true, 'is_usable' => true]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $team->id])->id]); + $database = create_standalone_postgresql($environment->id, $destination, ['status' => 'running:healthy']); + $backup = new ScheduledDatabaseBackup; + $backup->forceFill([ + 'enabled' => true, + 'save_s3' => false, + 'frequency' => 'daily', + 'databases_to_backup' => 'first,second', + 'database_id' => $database->id, + 'database_type' => $database->getMorphClass(), + 'team_id' => $team->id, + ])->save(); + $commands = collect(); + Process::fake(function ($process) use ($commands) { + $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; + $commands->push($command); + + return Process::result(output: str_contains($command, 'du -b') ? '128' : ''); + }); + ScheduledDatabaseBackupExecution::creating(function (ScheduledDatabaseBackupExecution $execution) { + if ($execution->database_name === 'second') { + throw new RuntimeException('database is unavailable'); + } + }); + + (new DatabaseBackupJob($backup->fresh()))->handle(); + + $first = ScheduledDatabaseBackupExecution::query()->sole(); + expect($first->database_name)->toBe('first') + ->and($first->status)->toBe('success') + ->and($first->filename)->not->toBeNull() + ->and($commands->contains(fn (string $command) => str_contains($command, 'rm ') && str_contains($command, $first->filename)))->toBeFalse(); +});