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
This commit is contained in:
Andras Bacsai
2026-10-05 21:20:32 +02:00
parent 99d4071f2c
commit 14523ed738
2 changed files with 82 additions and 0 deletions
+7
View File
@@ -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 {
@@ -0,0 +1,75 @@
<?php
use App\Events\BackupCreated;
use App\Jobs\DatabaseBackupJob;
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\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
Storage::fake('ssh-keys');
Storage::fake('ssh-mux');
Event::fake([BackupCreated::class]);
Notification::fake();
InstanceSettings::unguarded(fn () => 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();
});