Ctrl K
ComunidadGuías…

app/Jobs/DatabaseBackupJob.php

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
<?php

namespace App\Jobs;

use App\Events\BackupCreated;
use App\Helpers\SshMultiplexingHelper;
use App\Helpers\SshRetryHandler;
use App\Models\NotificationThrottle;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledDatabaseBackupExecution;
use App\Models\Server;
use App\Models\ServiceDatabase;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneSqlite;
use App\Models\Team;
use App\Notifications\Database\BackupFailed;
use App\Notifications\Database\BackupSuccess;
use App\Notifications\Database\BackupSuccessWithS3Warning;
use App\Notifications\ScheduledJobRecovered;
use App\Services\RcloneTransfer;
use App\Services\ScheduledJobDeliveryService;
use App\Services\Security\SensitiveDataRedactor;
use App\Support\BackupCompression;
use App\Support\ClickhouseBackupCommand;
use App\Support\DatabaseOperationReservation;
use App\Support\PostgresUser;
use App\Support\ResourceStartActivity;
use App\Support\ValidationPatterns;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Spatie\Activitylog\Models\Activity;
use Throwable;

class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    /**
     * Lets a backup that runs too long fail inside handle() before the worker times out.
     */
    public const WORKER_TIMEOUT_MARGIN_SECONDS = 120;

    public $maxExceptions = 1;

    public ?Team $team = null;

    public Server $server;

    public StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneClickhouse|StandaloneSqlite|ServiceDatabase $database;

    public ?string $container_name = null;

    public ?string $directory_name = null;

    public ?ScheduledDatabaseBackupExecution $backup_log = null;

    public string $backup_status = 'failed';

    public ?string $backup_location = null;

    public string $backup_dir;

    public string $backup_file;

    public int $size = 0;

    public ?string $backup_output = null;

    public ?string $error_output = null;

    public bool $s3_uploaded = false;

    public ?string $postgres_password = null;

    public ?string $mongo_root_username = null;

    public ?string $mongo_root_password = null;

    public $timeout = 3600 + self::WORKER_TIMEOUT_MARGIN_SECONDS;

    public ?string $backup_log_uuid = null;

    /**
     * Queued with the payload, so failed() finds this run's executions after a worker timeout.
     */
    public ?string $executionUuidPrefix = null;

    /**
     * Unix time when the backup timeout of this run ends. Set when handle() starts.
     */
    private ?int $deadline = null;

    public function __construct(public ScheduledDatabaseBackup $backup, public ?string $occurrenceUuid = null)
    {
        $this->onQueue(crons_queue());
        $this->timeout = ($backup->timeout ?? 3600) + self::WORKER_TIMEOUT_MARGIN_SECONDS;
        $this->executionUuidPrefix = new_public_id(16);
    }

    public function middleware(): array
    {
        $expireAfter = ($this->backup->timeout ?? 3600) + 300;

        return [ScheduledJobDeliveryService::withoutOverlapping('database-backup-'.$this->backup->id, $this->occurrenceUuid)->expireAfter($expireAfter)->dontRelease()];
    }

    public function handle(): void
    {
        if ($this->occurrenceUuid && ! app(ScheduledJobDeliveryService::class)->claim($this->occurrenceUuid, $this->job?->uuid() ?? $this->occurrenceUuid)) {
            return;
        }

        $failed = false;
        $this->deadline = Carbon::now()->getTimestamp() + (int) ($this->backup->timeout ?? 3600);

        try {
            $databasesToBackup = null;

            $this->team = Team::find($this->backup->team_id);
            if (! $this->team) {
                $this->logSkippedRun('team_not_found');
                $this->backup->delete();

                return;
            }
            if (data_get($this->backup, 'database_type') === ServiceDatabase::class) {
                $this->database = data_get($this->backup, 'database');
                $this->server = $this->database->service->server;
            } else {
                $this->database = data_get($this->backup, 'database');
                // The coolify-db container always runs on the Coolify host, whatever its destination row says.
                $this->server = $this->database instanceof StandalonePostgresql && $this->database->isInstanceDatabase()
                    ? Server::find(0)
                    : $this->database->destination->server;
            }
            if (is_null($this->server)) {
                throw new \Exception('Server not found?!');
            }
            if (is_null($this->database)) {
                throw new \Exception('Database not found?!');
            }

            $this->markStaleExecutionsAsFailed();

            BackupCreated::dispatch($this->team->id);

            $status = str(data_get($this->database, 'status'));
            if (! $status->startsWith('running') && $this->database->id !== 0) {
                $this->logSkippedRun('database_not_running', ['database_status' => (string) $status]);

                return;
            }
            if ($this->databaseOperationInProgress()) {
                $this->recordSkippedBackup('Skipped: a start, restart or import of this database was in progress. A backup taken now could contain a partly restored database.');
                $this->logSkippedRun('database_operation_in_progress');

                return;
            }
            if (data_get($this->backup, 'database_type') === ServiceDatabase::class) {
                $databaseType = $this->database->databaseType();
                $serviceUuid = $this->database->service->uuid;
                $serviceName = str($this->database->service->name)->slug();
                $this->container_name = "{$this->database->name}-$serviceUuid";
                if (! ValidationPatterns::isValidContainerName($this->container_name)) {
                    throw new \Exception('Invalid database container name.');
                }
                // The container name is validated above, so it is a safe folder name. Keep the folder of earlier releases.
                $this->directory_name = $serviceName.'-'.$this->container_name;
                $escapedContainerName = escapeshellarg($this->container_name);
                $configuredDatabases = $this->configuredDatabasesToBackup();
                if (str($databaseType)->contains('postgres')) {
                    $commands[] = "docker exec {$escapedContainerName} env | grep -E '^(POSTGRES|PGUSER=)'";
                    $envs = instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true);
                    $envs = str($envs)->explode("\n");

                    $this->database->postgres_user = PostgresUser::fromEnvLines($envs);

                    $db = $envs->filter(function ($env) {
                        return str($env)->startsWith('POSTGRES_DB=');
                    })->first();

                    if (filled($configuredDatabases)) {
                        $databasesToBackup = $configuredDatabases;
                    } elseif ($db) {
                        $databasesToBackup = str($db)->after('POSTGRES_DB=')->value();
                    } else {
                        $databasesToBackup = $this->database->postgres_user;
                    }
                    $this->postgres_password = $envs->filter(function ($env) {
                        return str($env)->startsWith('POSTGRES_PASSWORD=');
                    })->first();
                    if ($this->postgres_password) {
                        $this->postgres_password = str($this->postgres_password)->after('POSTGRES_PASSWORD=')->value();
                    }
                } elseif (str($databaseType)->contains('mysql')) {
                    $commands[] = "docker exec {$escapedContainerName} env | grep MYSQL_";
                    $envs = instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true);
                    $envs = str($envs)->explode("\n");

                    $rootPassword = $envs->filter(function ($env) {
                        return str($env)->startsWith('MYSQL_ROOT_PASSWORD=');
                    })->first();
                    if ($rootPassword) {
                        $this->database->mysql_root_password = str($rootPassword)->after('MYSQL_ROOT_PASSWORD=')->value();
                    }

                    $db = $envs->filter(function ($env) {
                        return str($env)->startsWith('MYSQL_DATABASE=');
                    })->first();

                    if (filled($configuredDatabases)) {
                        $databasesToBackup = $configuredDatabases;
                    } elseif ($db) {
                        $databasesToBackup = str($db)->after('MYSQL_DATABASE=')->value();
                    } else {
                        $this->recordUndeterminedDatabases('MYSQL_DATABASE');

                        return;
                    }
                } elseif (str($databaseType)->contains('mariadb')) {
                    $commands[] = "docker exec {$escapedContainerName} env";
                    $envs = instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true);
                    $envs = str($envs)->explode("\n");
                    $rootPassword = $envs->filter(function ($env) {
                        return str($env)->startsWith('MARIADB_ROOT_PASSWORD=');
                    })->first();
                    if ($rootPassword) {
                        $this->database->mariadb_root_password = str($rootPassword)->after('MARIADB_ROOT_PASSWORD=')->value();
                    } else {
                        $rootPassword = $envs->filter(function ($env) {
                            return str($env)->startsWith('MYSQL_ROOT_PASSWORD=');
                        })->first();
                        if ($rootPassword) {
                            $this->database->mariadb_root_password = str($rootPassword)->after('MYSQL_ROOT_PASSWORD=')->value();
                        }
                    }

                    $db = $envs->first(fn ($env) => str($env)->startsWith('MARIADB_DATABASE='))
                        ?? $envs->first(fn ($env) => str($env)->startsWith('MYSQL_DATABASE='));

                    if (filled($configuredDatabases)) {
                        $databasesToBackup = $configuredDatabases;
                    } elseif ($db) {
                        $databasesToBackup = str($db)->after('=')->value();
                    } else {
                        $this->recordUndeterminedDatabases('MARIADB_DATABASE or MYSQL_DATABASE');

                        return;
                    }
                } elseif (str($databaseType)->contains('mongo')) {
                    $databasesToBackup = ['*'];

                    // Try to extract MongoDB credentials from environment variables
                    try {
                        $commands = [];
                        $commands[] = "docker exec {$escapedContainerName} env | grep MONGO_INITDB_";
                        $envs = instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true);

                        if (filled($envs)) {
                            $envs = str($envs)->explode("\n");
                            $rootPassword = $envs->filter(function ($env) {
                                return str($env)->startsWith('MONGO_INITDB_ROOT_PASSWORD=');
                            })->first();
                            if ($rootPassword) {
                                $this->mongo_root_password = str($rootPassword)->after('MONGO_INITDB_ROOT_PASSWORD=')->value();
                            }
                            $rootUsername = $envs->filter(function ($env) {
                                return str($env)->startsWith('MONGO_INITDB_ROOT_USERNAME=');
                            })->first();
                            if ($rootUsername) {
                                $this->mongo_root_username = str($rootUsername)->after('MONGO_INITDB_ROOT_USERNAME=')->value();
                            }
                        }

                    } catch (Throwable $e) {
                        // Continue without env vars - will be handled in backup_standalone_mongodb method
                    }
                }
            } else {
                $databaseName = str($this->database->name)->slug()->value();
                $this->container_name = $this->database->uuid;
                $this->directory_name = $databaseName.'-'.$this->container_name;
                $databaseType = $this->database->type();
                $databasesToBackup = data_get($this->backup, 'databases_to_backup');
            }
            if (blank($databasesToBackup)) {
                if (str($databaseType)->contains('postgres')) {
                    $databasesToBackup = [$this->database->postgres_db];
                } elseif (str($databaseType)->contains('mongo')) {
                    $databasesToBackup = ['*'];
                } elseif (str($databaseType)->contains('mysql')) {
                    $databasesToBackup = [$this->database->mysql_database];
                } elseif (str($databaseType)->contains('mariadb')) {
                    $databasesToBackup = [$this->database->mariadb_database];
                } elseif ($this->database instanceof StandaloneClickhouse) {
                    $databasesToBackup = [$this->database->clickhouse_db];
                } elseif ($this->database instanceof StandaloneSqlite) {
                    $databasesToBackup = $this->database->sqlite_databases;
                } else {
                    $this->logSkippedRun('unsupported_database_type', ['service_database_type' => $databaseType]);

                    return;
                }
            }
            $databasesToBackup = $this->databasesToBackup($databaseType, $databasesToBackup);
            if ($databasesToBackup === []) {
                $this->logSkippedRun('no_databases_selected');

                return;
            }
            $this->backup_dir = backup_dir().'/databases/'.str($this->team->name)->slug().'-'.$this->team->id.'/'.$this->directory_name;
            if ($this->database instanceof StandalonePostgresql && $this->database->isInstanceDatabase()) {
                $databasesToBackup = ['coolify'];
                $this->directory_name = $this->container_name = 'coolify-db';
                $ip = Str::slug($this->server->ip);
                $this->backup_dir = backup_dir().'/coolify'."/coolify-db-$ip";
            }
            $runHadProblem = false;
            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;
                $this->s3_uploaded = false;

                // Generate unique UUID for each database backup execution
                $attempts = 0;
                do {
                    $this->backup_log_uuid = $this->executionUuidPrefix === null ? new_public_id() : $this->executionUuidPrefix.new_public_id(8);
                    $exists = ScheduledDatabaseBackupExecution::where('uuid', $this->backup_log_uuid)->exists();
                    $attempts++;
                    if ($attempts >= 3 && $exists) {
                        throw new \Exception('Unable to generate unique UUID for backup execution after 3 attempts');
                    }
                } while ($exists);

                $size = 0;
                $localBackupSucceeded = false;
                $s3UploadError = null;

                // Step 1: Create local backup
                try {
                    if (str($databaseType)->contains('postgres')) {
                        $fileDatabaseName = $this->backupFilenamePart($database);
                        $this->backup_file = "/pg-dump-$fileDatabaseName-".Carbon::now()->timestamp."-{$this->backup_log_uuid}.dmp";
                        if ($this->backup->dump_all) {
                            $this->backup_file = '/pg-dump-all-'.Carbon::now()->timestamp."-{$this->backup_log_uuid}.gz";
                        }
                        $this->backup_location = $this->backup_dir.$this->backup_file;
                        $this->backup_log = ScheduledDatabaseBackupExecution::create([
                            'uuid' => $this->backup_log_uuid,
                            'database_name' => $database,
                            'filename' => $this->backup_location,
                            'scheduled_database_backup_id' => $this->backup->id,
                            'local_storage_deleted' => false,
                        ]);
                        BackupCreated::dispatch($this->team->id);
                        $this->backup_standalone_postgresql($database);
                    } elseif (str($databaseType)->contains('mongo')) {
                        if ($database === '*') {
                            $database = 'all';
                            $databaseName = 'all';
                        } else {
                            if (str($database)->contains(':')) {
                                $databaseName = str($database)->before(':');
                            } else {
                                $databaseName = $database;
                            }
                        }
                        $fileDatabaseName = $this->backupFilenamePart($databaseName);
                        $this->backup_file = "/mongo-dump-$fileDatabaseName-".Carbon::now()->timestamp."-{$this->backup_log_uuid}.tar.gz";
                        $this->backup_location = $this->backup_dir.$this->backup_file;
                        $this->backup_log = ScheduledDatabaseBackupExecution::create([
                            'uuid' => $this->backup_log_uuid,
                            'database_name' => $databaseName,
                            'filename' => $this->backup_location,
                            'scheduled_database_backup_id' => $this->backup->id,
                            'local_storage_deleted' => false,
                        ]);
                        BackupCreated::dispatch($this->team->id);
                        $this->backup_standalone_mongodb($database);
                    } elseif (str($databaseType)->contains('mysql')) {
                        $fileDatabaseName = $this->backupFilenamePart($database);
                        $this->backup_file = "/mysql-dump-$fileDatabaseName-".Carbon::now()->timestamp."-{$this->backup_log_uuid}.dmp";
                        if ($this->backup->dump_all) {
                            $this->backup_file = '/mysql-dump-all-'.Carbon::now()->timestamp."-{$this->backup_log_uuid}.gz";
                        }
                        $this->backup_location = $this->backup_dir.$this->backup_file;
                        $this->backup_log = ScheduledDatabaseBackupExecution::create([
                            'uuid' => $this->backup_log_uuid,
                            'database_name' => $database,
                            'filename' => $this->backup_location,
                            'scheduled_database_backup_id' => $this->backup->id,
                            'local_storage_deleted' => false,
                        ]);
                        BackupCreated::dispatch($this->team->id);
                        $this->backup_standalone_mysql($database);
                    } elseif (str($databaseType)->contains('mariadb')) {
                        $fileDatabaseName = $this->backupFilenamePart($database);
                        $this->backup_file = "/mariadb-dump-$fileDatabaseName-".Carbon::now()->timestamp."-{$this->backup_log_uuid}.dmp";
                        if ($this->backup->dump_all) {
                            $this->backup_file = '/mariadb-dump-all-'.Carbon::now()->timestamp."-{$this->backup_log_uuid}.gz";
                        }
                        $this->backup_location = $this->backup_dir.$this->backup_file;
                        $this->backup_log = ScheduledDatabaseBackupExecution::create([
                            'uuid' => $this->backup_log_uuid,
                            'database_name' => $database,
                            'filename' => $this->backup_location,
                            'scheduled_database_backup_id' => $this->backup->id,
                            'local_storage_deleted' => false,
                        ]);
                        BackupCreated::dispatch($this->team->id);
                        $this->backup_standalone_mariadb($database);
                    } elseif ($this->database instanceof StandaloneClickhouse) {
                        $this->backup_file = '/clickhouse-backup-'.Carbon::now()->timestamp."-{$this->backup_log_uuid}.zip";
                        $this->backup_location = $this->backup_dir.$this->backup_file;
                        $this->backup_log = ScheduledDatabaseBackupExecution::create([
                            'uuid' => $this->backup_log_uuid,
                            'database_name' => $database,
                            'filename' => $this->backup_location,
                            'scheduled_database_backup_id' => $this->backup->id,
                            'local_storage_deleted' => false,
                        ]);
                        BackupCreated::dispatch($this->team->id);
                        $this->backup_standalone_clickhouse($database);
                    } elseif ($this->database instanceof StandaloneSqlite) {
                        $this->backup_file = "/sqlite-backup-$database-".Carbon::now()->timestamp."-{$this->backup_log_uuid}.gz";
                        $this->backup_location = $this->backup_dir.$this->backup_file;
                        $this->backup_log = ScheduledDatabaseBackupExecution::create([
                            'uuid' => $this->backup_log_uuid,
                            'database_name' => $database,
                            'filename' => $this->backup_location,
                            'scheduled_database_backup_id' => $this->backup->id,
                            'local_storage_deleted' => false,
                        ]);
                        BackupCreated::dispatch($this->team->id);
                        $this->backup_standalone_sqlite($database);
                    } else {
                        throw new \Exception('Unsupported database type');
                    }

                    $size = $this->size = (int) $this->calculate_size();

                    // Verify local backup succeeded
                    if ($size > 0) {
                        $localBackupSucceeded = true;
                    } else {
                        throw new \Exception('Local backup file is empty or was not created');
                    }
                } catch (Throwable $e) {
                    $runHadProblem = true;
                    // Local backup failed: the partial file is not tracked, so retention would never remove it.
                    deleteBackupsLocally($this->backup_location, $this->server);
                    $failureMessage = $this->redact($this->error_output ?? $this->backup_output ?? $e->getMessage());
                    $exitCodeReason = $this->exitCodeReason($e->getCode());
                    if ($exitCodeReason !== null) {
                        $failureMessage = $exitCodeReason."\n\n".$failureMessage;
                    }
                    if ($this->backup_log) {
                        $this->backup_log->update([
                            'status' => 'failed',
                            'message' => $failureMessage,
                            'size' => $size,
                            'filename' => null,
                            's3_uploaded' => null,
                            'finished_at' => Carbon::now()->toImmutable(),
                        ]);
                    }
                    try {
                        $this->team?->notify(new BackupFailed($this->backup, $this->database, $failureMessage, $database));
                    } catch (Throwable $notifyException) {
                        Log::channel('scheduled-errors')->warning('Failed to send backup failure notification', [
                            'backup_id' => $this->backup->uuid,
                            'database' => $database,
                            'error' => $notifyException->getMessage(),
                        ]);
                    }

                    continue;
                }

                // Step 2: Upload to S3 if enabled (independent of local backup)
                $localStorageDeleted = false;
                if ($this->backup->save_s3 && $localBackupSucceeded) {
                    try {
                        $s3UploadError = $this->uploadToS3Destinations();

                        // If local backup is disabled, delete the local file only when every destination has a copy
                        if ($s3UploadError === null && $this->backup->disable_local_backup) {
                            deleteBackupsLocally($this->backup_location, $this->server);
                            $localStorageDeleted = true;
                        }
                    } catch (Throwable $e) {
                        // S3 upload failed but local backup succeeded
                        $s3UploadError = $this->redact($e->getMessage());
                    }
                }

                // Step 3: Update status and send notifications based on results
                if ($s3UploadError) {
                    $runHadProblem = true;
                }
                if ($localBackupSucceeded) {
                    $message = $this->backup_output === null ? null : $this->redact($this->backup_output);

                    if ($s3UploadError) {
                        $message = $message
                            ? $message."\n\nWarning: Storage upload failed: ".$s3UploadError
                            : 'Warning: Storage upload failed: '.$s3UploadError;
                    }

                    $this->backup_log->update([
                        'status' => 'success',
                        'message' => $message,
                        'size' => $size,
                        's3_uploaded' => $this->backup->save_s3 ? $this->s3_uploaded : null,
                        'local_storage_deleted' => $localStorageDeleted,
                        'finished_at' => Carbon::now()->toImmutable(),
                    ]);

                    // Send appropriate notification (wrapped in try-catch so notification
                    // failures never affect backup status — see GitHub issue #9088)
                    try {
                        if ($s3UploadError) {
                            $this->team->notify(new BackupSuccessWithS3Warning($this->backup, $this->database, $database, $s3UploadError));
                        } else {
                            $this->team->notify(new BackupSuccess($this->backup, $this->database, $database));
                        }
                    } catch (Throwable $e) {
                        Log::channel('scheduled-errors')->warning('Failed to send backup success notification', [
                            'backup_id' => $this->backup->uuid,
                            'database' => $database,
                            'error' => $e->getMessage(),
                        ]);
                    }
                }
            }
            if ($this->backup_log && $this->backup_log->status === 'success') {
                $this->removeExpiredBackups();
            }
            if (! $runHadProblem) {
                $this->notifyIfRecovered();
            }
        } catch (Throwable $e) {
            $failed = true;
            throw $e;
        } finally {
            if (! $failed && $this->occurrenceUuid) {
                app(ScheduledJobDeliveryService::class)->complete($this->occurrenceUuid, $this->job?->uuid() ?? $this->occurrenceUuid);
            }

            if ($this->backup_log) {
                $this->backup_log->update([
                    'finished_at' => Carbon::now()->toImmutable(),
                ]);
            }
            if ($this->team) {
                BackupCreated::dispatch($this->team->id);
            }
        }
    }

    private function removeExpiredBackups(): void
    {
        try {
            removeOldBackups($this->backup);
        } catch (Throwable $exception) {
            Log::channel('scheduled-errors')->warning('Database backup retention cleanup failed', [
                'backup_id' => $this->backup->id,
                'execution_id' => $this->backup_log?->id,
                'error' => $exception->getMessage(),
            ]);
        }
    }

    /**
     * Seconds left of the backup timeout. The timeout limits the complete run (every database, dump and
     * upload), so a run that takes too long fails inside handle(), where the cleanup runs, and not when the
     * worker kills the job.
     */
    private function commandTimeout(): int
    {
        $timeout = (int) ($this->backup->timeout ?? 3600);
        if ($this->deadline === null) {
            return $timeout;
        }

        $remaining = $this->deadline - Carbon::now()->getTimestamp();
        if ($remaining < 1) {
            throw new \RuntimeException("The backup timeout of {$timeout} seconds has passed.", 124);
        }

        return $remaining;
    }

    /**
     * Runs the commands that write the backup file. They run only once: an SSH retry replays the batch, and
     * a dump that lost its SSH connection can still run in the database container, so a second dump would
     * write into the same file.
     *
     * @param  list<string>  $commands
     */
    private function runBackupCommands(array $commands): void
    {
        $output = trim((string) instant_remote_process($this->writeBackupFileAsRoot($commands), $this->server, true, false, $this->commandTimeout(), disableMultiplexing: true, maxAttempts: 1));
        $this->backup_output = $output === '' ? null : $output;
    }

    private function backupFilenamePart(string $database): string
    {
        return preg_replace('/[^A-Za-z0-9_.-]/', '-', $database);
    }

    private function backup_standalone_mongodb(string $databaseWithCollections): void
    {
        try {
            $url = $this->database->internal_db_url;
            if (blank($url)) {
                // For service-based MongoDB, try to build URL from environment variables
                if (filled($this->mongo_root_username) && filled($this->mongo_root_password)) {
                    // Use container name instead of server IP for service-based MongoDB
                    // URL-encode credentials to prevent URI injection
                    $encodedUser = rawurlencode($this->mongo_root_username);
                    $encodedPass = rawurlencode($this->mongo_root_password);
                    $url = "mongodb://{$encodedUser}:{$encodedPass}@{$this->container_name}:27017";
                } else {
                    // If no environment variables are available, throw an exception
                    throw new \Exception('MongoDB credentials not found. Ensure MONGO_INITDB_ROOT_USERNAME and MONGO_INITDB_ROOT_PASSWORD environment variables are available in the container.');
                }
            }
            Log::info('MongoDB backup URL configured', ['has_url' => filled($url), 'using_env_vars' => blank($this->database->internal_db_url)]);
            $escapedUrl = escapeshellarg($url);
            $escapedContainerName = escapeshellarg($this->container_name);
            $escapedBackupLocation = escapeshellarg($this->backup_location);
            if ($databaseWithCollections === 'all') {
                $commands = $this->backupDirectoryCommands();
                if (str($this->database->image)->startsWith('mongo:4')) {
                    $commands[] = "docker exec {$escapedContainerName} mongodump --uri=$escapedUrl --gzip --archive > {$escapedBackupLocation}";
                } else {
                    $commands[] = "docker exec {$escapedContainerName} mongodump --authenticationDatabase=admin --uri=$escapedUrl --gzip --archive > {$escapedBackupLocation}";
                }
            } else {
                if (str($databaseWithCollections)->contains(':')) {
                    $databaseName = str($databaseWithCollections)->before(':');
                    $collectionsToExclude = str($databaseWithCollections)->after(':')->explode(',');
                } else {
                    $databaseName = $databaseWithCollections;
                    $collectionsToExclude = collect();
                }
                $commands = $this->backupDirectoryCommands();

                // Validate and escape database name to prevent command injection
                validateShellSafePath($databaseName, 'database name');
                $escapedDatabaseName = escapeshellarg($databaseName);

                if ($collectionsToExclude->count() === 0) {
                    if (str($this->database->image)->startsWith('mongo:4')) {
                        $commands[] = "docker exec {$escapedContainerName} mongodump --uri=$escapedUrl --gzip --archive > {$escapedBackupLocation}";
                    } else {
                        $commands[] = "docker exec {$escapedContainerName} mongodump --authenticationDatabase=admin --uri=$escapedUrl --db $escapedDatabaseName --gzip --archive > {$escapedBackupLocation}";
                    }
                } else {
                    // Validate and escape each collection name
                    $escapedCollections = $collectionsToExclude->map(function ($collection) {
                        $collection = trim($collection);
                        validateShellSafePath($collection, 'collection name');

                        return escapeshellarg($collection);
                    });

                    if (str($this->database->image)->startsWith('mongo:4')) {
                        $commands[] = "docker exec {$escapedContainerName} mongodump --uri=$escapedUrl --gzip --excludeCollection ".$escapedCollections->implode(' --excludeCollection ')." --archive > {$escapedBackupLocation}";
                    } else {
                        $commands[] = "docker exec {$escapedContainerName} mongodump --authenticationDatabase=admin --uri=$escapedUrl --db $escapedDatabaseName --gzip --excludeCollection ".$escapedCollections->implode(' --excludeCollection ')." --archive > {$escapedBackupLocation}";
                    }
                }
            }
            $this->runBackupCommands($commands);
        } catch (Throwable $e) {
            $this->add_to_error_output($e->getMessage());
            throw $e;
        }
    }

    /** @return array<int, string> */
    private function databasesToBackup(string $databaseType, string|array $databases): array
    {
        $type = str($databaseType);

        if ($this->backup->dump_all && $type->contains(['postgres', 'mysql', 'mariadb'])) {
            return ['all'];
        }

        if (is_array($databases)) {
            return $databases;
        }

        if ($type->contains('mongo')) {
            return array_map('trim', explode('|', $databases));
        }

        if ($type->contains(['postgres', 'mysql', 'mariadb', 'clickhouse', 'sqlite'])) {
            return array_map('trim', explode(',', $databases));
        }

        return [];
    }

    private function backup_standalone_postgresql(string $database): void
    {
        try {
            $commands = $this->backupDirectoryCommands();
            $backupCommand = 'docker exec';
            if ($this->postgres_password) {
                $backupCommand .= ' -e PGPASSWORD='.escapeshellarg($this->postgres_password);
            }
            $escapedUsername = escapeshellarg($this->database->postgres_user);
            $escapedContainerName = escapeshellarg($this->container_name);
            $escapedBackupLocation = escapeshellarg($this->backup_location);
            if ($this->backup->dump_all) {
                $backupCommand .= " {$escapedContainerName} pg_dumpall --username $escapedUsername";
                $backupCommand = $this->buildCompressedDumpCommand($backupCommand, $escapedBackupLocation);
            } else {
                // Validate and escape database name to prevent command injection
                validateShellSafePath($database, 'database name');
                $escapedDatabase = escapeshellarg($database);
                // Keep owners and privileges in the archive: services such as Supabase need them,
                // and the restore skips them unless "Keep owners and privileges" is selected.
                $backupCommand .= " {$escapedContainerName} pg_dump --format=custom --username $escapedUsername $escapedDatabase > {$escapedBackupLocation}";
            }

            $commands[] = $backupCommand;
            $this->runBackupCommands($commands);
        } catch (Throwable $e) {
            $this->add_to_error_output($e->getMessage());
            throw $e;
        }
    }

    private function backup_standalone_mysql(string $database): void
    {
        try {
            $commands = $this->backupDirectoryCommands();
            $escapedPassword = escapeshellarg($this->database->mysql_root_password);
            $escapedContainerName = escapeshellarg($this->container_name);
            $escapedBackupLocation = escapeshellarg($this->backup_location);
            if ($this->backup->dump_all) {
                $dumpCommand = "docker exec {$escapedContainerName} mysqldump -u root -p$escapedPassword --all-databases --single-transaction --quick --lock-tables=false";
                $commands[] = $this->buildCompressedDumpCommand($dumpCommand, $escapedBackupLocation);
            } else {
                // Validate and escape database name to prevent command injection
                validateShellSafePath($database, 'database name');
                $escapedDatabase = escapeshellarg($database);
                $commands[] = "docker exec {$escapedContainerName} mysqldump -u root -p$escapedPassword $escapedDatabase > {$escapedBackupLocation}";
            }
            $this->runBackupCommands($commands);
        } catch (Throwable $e) {
            $this->add_to_error_output($e->getMessage());
            throw $e;
        }
    }

    private function backup_standalone_mariadb(string $database): void
    {
        try {
            $commands = $this->backupDirectoryCommands();
            $escapedPassword = escapeshellarg($this->database->mariadb_root_password);
            $escapedContainerName = escapeshellarg($this->container_name);
            $escapedBackupLocation = escapeshellarg($this->backup_location);
            if ($this->backup->dump_all) {
                $dumpCommand = "docker exec {$escapedContainerName} mariadb-dump -u root -p$escapedPassword --all-databases --single-transaction --quick --lock-tables=false";
                $commands[] = $this->buildCompressedDumpCommand($dumpCommand, $escapedBackupLocation);
            } else {
                // Validate and escape database name to prevent command injection
                validateShellSafePath($database, 'database name');
                $escapedDatabase = escapeshellarg($database);
                $commands[] = "docker exec {$escapedContainerName} mariadb-dump -u root -p$escapedPassword $escapedDatabase > {$escapedBackupLocation}";
            }
            $this->runBackupCommands($commands);
        } catch (Throwable $e) {
            $this->add_to_error_output($e->getMessage());
            throw $e;
        }
    }

    private function backup_standalone_clickhouse(string $database): void
    {
        $archiveName = ltrim($this->backup_file, '/');

        try {
            $commands = [...$this->backupDirectoryCommands(), ...ClickhouseBackupCommand::make(
                containerName: $this->container_name,
                database: $database,
                archiveName: $archiveName,
                backupDirectory: $this->backup_dir,
            )];

            $this->runBackupCommands($commands);
        } catch (Throwable $e) {
            $this->add_to_error_output($e->getMessage());
            throw $e;
        } finally {
            $cleanupCommand = ClickhouseBackupCommand::cleanup($this->container_name, $archiveName);
            instant_remote_process([$cleanupCommand], $this->server, false, false, null, disableMultiplexing: true);
        }
    }

    private function backup_standalone_sqlite(string $database): void
    {
        try {
            if (! preg_match(StandaloneSqlite::DATABASES_PATTERN, $database)) {
                throw new \Exception("Invalid database file name: {$database}");
            }
            $commands = $this->backupDirectoryCommands();
            $script = 'f=$(mktemp) && sqlite3 -readonly '.escapeshellarg(StandaloneSqlite::DATA_DIRECTORY.'/'.$database).' \'.timeout 10000\' "VACUUM INTO \'$f\'" && cat "$f"; s=$?; rm -f "$f"; exit $s';
            $dumpCommand = 'docker exec '.escapeshellarg($this->container_name).' sh -c '.escapeshellarg($script);
            $commands[] = $this->buildCompressedDumpCommand($dumpCommand, escapeshellarg($this->backup_location));
            $this->runBackupCommands($commands);
        } catch (Throwable $e) {
            $this->add_to_error_output($e->getMessage());
            throw $e;
        }
    }

    /**
     * Explains exit codes of a dump that was stopped from outside. The dump tool cannot report these
     * itself, so its output ends after the last progress line without an error.
     */
    private function exitCodeReason(int|string $exitCode): ?string
    {
        return match ($exitCode) {
            124 => 'The backup stopped with exit code 124: it did not finish within the backup timeout of '.((int) ($this->backup->timeout ?? 3600)).' seconds. '
                .'The timeout counts for the complete run: the dumps and S3 uploads of all databases. Increase the timeout in the backup settings.',
            137 => 'The backup stopped with exit code 137: the dump process or the database container was killed (SIGKILL). '
                .'This is usually the out-of-memory killer: the dump runs inside the database container and shares its memory limit. '
                .'Check the memory limit of the database and the kernel log (dmesg) on the server.',
            143 => 'The backup stopped with exit code 143: the dump process or the database container was terminated (SIGTERM), '
                .'for example because the database was stopped or restarted during the backup.',
            255 => 'The backup stopped with exit code 255: the SSH connection to the server closed. '
                .'Check the network connection and the SSH server log on the server.',
            default => null,
        };
    }

    private function add_to_backup_output($output): void
    {
        if ($this->backup_output) {
            $this->backup_output = $this->backup_output."\n".$output;
        } else {
            $this->backup_output = $output;
        }
    }

    private function add_to_error_output($output): void
    {
        if ($this->error_output) {
            $this->error_output = $this->error_output."\n".$output;
        } else {
            $this->error_output = $output;
        }
    }

    private function calculate_size()
    {
        return instant_remote_process(['du -b '.escapeshellarg($this->backup_location).' | cut -f1'], $this->server, false, false, null, disableMultiplexing: true);
    }

    /**
     * Host path of the backup file for the upload container. It differs from backup_location only for the
     * development testing-host server (see devHostDockerPath()).
     */
    private function backupMountSource(): string
    {
        return devHostDockerPath($this->server, $this->backup_location);
    }

    /**
     * Uploads the backup file to every selected S3 destination and records one replica per destination.
     *
     * @return string|null The failed destinations and their errors, or null when every upload succeeded.
     */
    private function uploadToS3Destinations(): ?string
    {
        $storages = $this->backup->selectedS3Storages();
        if ($storages->isEmpty()) {
            $previousS3StorageId = $this->backup->s3_storage_id;

            $this->backup->update([
                'save_s3' => false,
                's3_storage_id' => null,
            ]);

            throw new \Exception('Backup storage configuration is missing or has been deleted (storage ID: '.($previousS3StorageId ?? 'null').'). Uploads to a backup storage are disabled for this schedule.');
        }

        $failures = [];
        foreach ($storages as $storage) {
            $uploaded = false;
            $uploadError = null;
            try {
                $this->upload_to_s3($storage);
                $uploaded = true;
            } catch (Throwable $e) {
                $uploadError = $this->redact($e->getMessage());
                $failures[] = "{$storage->name}: {$uploadError}";
            }

            try {
                $this->backup_log->s3Replicas()->create([
                    's3_storage_id' => $storage->id,
                    's3_uploaded' => $uploaded,
                    'message' => $uploadError,
                ]);
            } catch (Throwable $e) {
                $failures[] = "{$storage->name}: {$this->redact($e->getMessage())}";
            }
        }

        $this->backup_log->refreshS3Summary();
        $this->s3_uploaded = $failures === [];

        return $failures === [] ? null : implode("\n", $failures);
    }

    protected function upload_to_s3(S3Storage $s3): void
    {
        try {
            $bucket = $s3->bucket;
            $endpoint = $s3->endpoint;
            $s3->testConnection(shouldSave: true);
            if (data_get($this->backup, 'database_type') === ServiceDatabase::class) {
                $network = $this->database->service->destination->network;
            } else {
                $network = $this->database->destination->network;
            }
            if (! $s3->isS3()) {
                $this->uploadWithRclone($s3, $network);

                return;
            }
            $safeNetwork = escapeshellarg($network);

            $fullImageName = $this->getFullImageName();

            // Remove a leftover helper in the same batch, so a replayed batch does not fail with a name conflict.
            $commands[] = "docker rm -f backup-of-{$this->backup_log_uuid} >/dev/null 2>&1 || true";
            $mount = escapeshellarg($this->backupMountSource().':'.$this->backup_location.':ro');
            $commands[] = "docker run -d --network {$safeNetwork} --name backup-of-{$this->backup_log_uuid} --rm {$s3->minioClientEnvOptions()} -v {$mount} {$fullImageName}";

            instant_remote_process($commands, $this->server, true, false, $this->commandTimeout(), disableMultiplexing: true);
            if ($s3->usesCurlTransfer()) {
                $uploadCommand = $s3->prepareCurlUpload($this->server, "backup-of-{$this->backup_log_uuid}", $this->backup_location, ltrim($this->backup_dir, '/').'/'.basename($this->backup_location));
            } else {
                $escapedBackupLocation = escapeshellarg($this->backup_location);
                $escapedS3Destination = escapeshellarg("temporary/{$bucket}{$this->backup_dir}/");
                $resolveOptions = $s3->minioClientResolveOptions($this->server, $fullImageName, $network);
                $partSizeOption = S3Storage::minioClientPartSizeOption($this->size);
                $this->setMinioClientAlias($this->server, "backup-of-{$this->backup_log_uuid}", $s3, $resolveOptions);
                $uploadCommand = "docker exec {$partSizeOption} backup-of-{$this->backup_log_uuid} mc cp{$resolveOptions} {$escapedBackupLocation} {$escapedS3Destination}";
            }
            S3Storage::retryMinioClientUpload(fn () => instant_remote_process(
                [$uploadCommand],
                $this->server, true, false, $this->commandTimeout(), disableMultiplexing: true,
            ));

        } catch (Throwable $e) {
            $this->add_to_error_output($e->getMessage());
            throw $e;
        } finally {
            $command = dockerRemoveCommand("backup-of-{$this->backup_log_uuid}");
            instant_remote_process([$command], $this->server, true, false, null, disableMultiplexing: true);
        }
    }

    /**
     * Uploads the backup file to an SFTP, FTPS, or WebDAV storage. The caller removes the container.
     */
    private function uploadWithRclone(S3Storage $storage, string $network): void
    {
        $transfer = new RcloneTransfer($storage, $this->server, "backup-of-{$this->backup_log_uuid}");
        $transfer->start([$this->backupMountSource().':'.$this->backup_location.':ro'], $network, $this->commandTimeout());
        $transfer->upload($this->backup_location, $this->backup_location, $this->commandTimeout());
    }

    /**
     * Sets the `temporary` mc alias in a helper container. The S3 keys go to mc through the stdin of the
     * SSH process, so they are not in the arguments of any process on Coolify or on the server.
     */
    private function setMinioClientAlias(Server $server, string $container, S3Storage $s3, string $resolveOptions): void
    {
        $remoteCommand = ($server->isNonRoot() ? 'sudo ' : '').'docker exec -i '.escapeshellarg($container)
            .' mc alias set'.$resolveOptions.$s3->minioClientAliasOptions().' temporary '.escapeshellarg($s3->endpoint).' >/dev/null';
        $timeout = (int) config('constants.ssh.command_timeout');

        SshRetryHandler::retry(
            function () use ($server, $remoteCommand, $s3, $timeout) {
                $sshCommand = SshMultiplexingHelper::generateSshStdinCommand($server, $remoteCommand, disableMultiplexing: true, commandTimeout: $timeout);
                $process = runSshProcess($sshCommand, $timeout, $s3->key."\n".$s3->secret."\n");

                if ($process->exitCode() !== 0) {
                    excludeCertainErrors($process->errorOutput(), $process->exitCode());
                }
            },
            [
                'server' => $server->ip,
                'function' => 'setMinioClientAlias',
            ],
        );
    }

    private function getFullImageName(): string
    {
        $helperImage = coolifyHelperImage();
        $latestVersion = getHelperVersion();

        return "{$helperImage}:{$latestVersion}";
    }

    /**
     * Creates the backup directory. On a server with a non-root SSH user, the directory gets the ownership step
     * that the sudo parser adds only to unquoted paths: the SSH user owns it (backup downloads read it over SFTP
     * as that user) and other users cannot read the dumps in it.
     *
     * @return array<int, string>
     */
    private function backupDirectoryCommands(): array
    {
        $backupDirectory = escapeshellarg($this->backup_dir);
        $commands = ['mkdir -p '.$backupDirectory];
        if ($this->server->isNonRoot()) {
            $commands[] = ownershipCommand($backupDirectory, $this->server);
        }

        return $commands;
    }

    /**
     * The SSH user's shell opens a `>` redirect. For a non-root SSH user, the dump and the redirect into
     * the backup directory must run in one root shell, so each line that writes the backup file becomes
     * one `sh -c` script. The exit status of the dump stays the exit status of the line.
     *
     * @param  list<string>  $commands
     * @return list<string>
     */
    private function writeBackupFileAsRoot(array $commands): array
    {
        $redirect = '> '.escapeshellarg($this->backup_location);

        return array_map(
            fn (string $command): string => str_contains($command, $redirect) ? 'sh -c '.escapeshellarg($command) : $command,
            $commands,
        );
    }

    private function buildCompressedDumpCommand(string $dumpCommand, string $escapedBackupLocation): string
    {
        $cpuPercentage = BackupCompression::cpuPercentage($this->server->settings->backup_compression_cpu_percentage);
        $compressorCommand = BackupCompression::compressorCommand($cpuPercentage);
        $script = "compressor=\$({$compressorCommand}); exec \$compressor";

        return pipeToFileKeepingExitStatus($dumpCommand, 'docker run --rm -i '.escapeshellarg($this->getFullImageName()).' sh -c '.escapeshellarg($script), $escapedBackupLocation);
    }

    /**
     * Log a run that ends without a backup execution, so it does not look like a missed schedule.
     *
     * @param  array<string, mixed>  $context
     */
    private function logSkippedRun(string $reason, array $context = []): void
    {
        Log::channel('scheduled')->warning('Database backup job ended without a backup', [
            'skip_reason' => $reason,
            'backup_id' => $this->backup->id,
            'database_id' => $this->backup->database_id,
            'database_type' => $this->backup->database_type,
            'team_id' => $this->backup->team_id,
            'occurrence_uuid' => $this->occurrenceUuid,
            ...$context,
        ]);
    }

    /**
     * A start, restart or import of the database is reserved, queued or running. Stale activities do
     * not count; they are failed by the start and import paths.
     */
    private function databaseOperationInProgress(): bool
    {
        $uuid = $this->database->uuid;
        if (blank($uuid)) {
            return false;
        }

        if (DatabaseOperationReservation::isHeldByOther($uuid)) {
            return true;
        }

        $liveOperation = collect([ResourceStartActivity::DATABASE_START_OPERATION, ResourceStartActivity::DATABASE_IMPORT_OPERATION])
            ->flatMap(fn (string $operation) => ResourceStartActivity::active($uuid, $operation))
            ->contains(fn (Activity $activity): bool => ! ResourceStartActivity::isStale($activity));

        return $liveOperation
            || ResourceStartActivity::latestRunning($uuid) !== null
            || ($this->database instanceof ServiceDatabase && ResourceStartActivity::latestRunning($this->database->service->uuid) !== null);
    }

    /**
     * Show a backup run that did not take a backup in the execution list. The run is expected, so it
     * sends no failure notification.
     */
    private function recordSkippedBackup(string $message): void
    {
        $databaseNames = $this->backup->dump_all ? 'all' : data_get($this->backup, 'databases_to_backup');
        $this->backup_log_uuid = new_public_id();
        $this->backup_log = ScheduledDatabaseBackupExecution::create([
            'uuid' => $this->backup_log_uuid,
            'database_name' => filled($databaseNames) ? Str::limit($databaseNames, 250) : null,
            'scheduled_database_backup_id' => $this->backup->id,
            'status' => 'failed',
            'message' => $message,
            'filename' => null,
            'local_storage_deleted' => false,
        ]);
    }

    /**
     * Databases picked in the backup settings of a service database. They take priority over the
     * database that the container env names, as for standalone databases.
     *
     * @return string|array<int, string>|null
     */
    /**
     * After a run without problems, tell the team once that a backup that failed before works again.
     */
    private function notifyIfRecovered(): void
    {
        $wasFailing = NotificationThrottle::release($this->backup, BackupFailed::class);
        $hadS3Warning = NotificationThrottle::release($this->backup, BackupSuccessWithS3Warning::class);
        if (! $wasFailing && ! $hadS3Warning) {
            return;
        }

        try {
            $this->team?->notify(new ScheduledJobRecovered(
                failureEvent: 'backup_failure',
                title: "Database backup for {$this->database->name} works again.",
                webhookEvent: 'backup_recovered',
                url: $this->database->link() ?? base_url(),
                webhookData: ['database_name' => $this->database->name, 'database_uuid' => $this->database->uuid, 'backup_uuid' => $this->backup->uuid],
            ));
        } catch (Throwable $e) {
            Log::channel('scheduled-errors')->warning('Failed to send backup recovery notification', [
                'backup_id' => $this->backup->uuid,
                'error' => $e->getMessage(),
            ]);
        }
    }

    private function configuredDatabasesToBackup(): string|array|null
    {
        if ($this->backup->dump_all) {
            return ['all'];
        }

        return $this->backup->databases_to_backup;
    }

    /**
     * Show a run that cannot start because no database is known in the execution list, and notify the team.
     */
    private function recordUndeterminedDatabases(string $envNames): void
    {
        $message = "No database to back up: the container does not set {$envNames}. Select \"All databases\" or enter the databases to back up in the backup settings.";
        $this->recordSkippedBackup($message);
        $this->logSkippedRun('no_databases_determined');
        try {
            $this->team->notify(new BackupFailed($this->backup, $this->database, $message, 'unknown'));
        } catch (Throwable $e) {
            Log::channel('scheduled-errors')->warning('Failed to send backup failure notification', [
                'backup_id' => $this->backup->uuid,
                'error' => $e->getMessage(),
            ]);
        }
    }

    private function markStaleExecutionsAsFailed(): void
    {
        try {
            $timeoutSeconds = ($this->backup->timeout ?? 3600) * 2;

            $staleExecutions = $this->backup->executions()
                ->where('status', 'running')
                ->where('created_at', '<', now()->subSeconds($timeoutSeconds))
                ->get();

            foreach ($staleExecutions as $execution) {
                $execution->update([
                    'status' => 'failed',
                    'message' => 'Marked as failed - backup execution exceeded maximum allowed time',
                    'finished_at' => now(),
                ]);
            }
        } catch (Throwable $e) {
            Log::channel('scheduled-errors')->warning('Failed to clean up stale backup executions', [
                'backup_id' => $this->backup->uuid,
                'error' => $e->getMessage(),
            ]);
        }
    }

    public function failed(?Throwable $exception): void
    {
        if ($this->occurrenceUuid) {
            app(ScheduledJobDeliveryService::class)->fail($this->occurrenceUuid, $this->job?->uuid() ?? $this->occurrenceUuid);
        }

        Log::channel('scheduled-errors')->error('DatabaseBackup permanently failed', [
            'job' => 'DatabaseBackupJob',
            'backup_id' => $this->backup->uuid,
            'database' => $this->database?->name ?? 'unknown',
            'database_type' => get_class($this->database ?? new \stdClass),
            'server' => $this->server?->name ?? 'unknown',
            'total_attempts' => $this->attempts(),
            'error' => $exception?->getMessage(),
            'trace' => $exception?->getTraceAsString(),
        ]);

        // After a worker timeout this runs on a fresh job copy without the state handle() set.
        $this->team ??= Team::find($this->backup->team_id);
        $executions = $this->executionsOfThisRun();

        // Don't overwrite a finished execution: handle() already reported it, and a post-backup
        // error (e.g. notification failure) must not mark a successful backup as failed (#9088).
        $unfinished = $executions->whereNotIn('status', ['success', 'failed']);
        foreach ($unfinished as $execution) {
            $execution->update([
                'status' => 'failed',
                'message' => $this->redact('Job permanently failed after '.$this->attempts().' attempts: '.($exception?->getMessage() ?? 'Unknown error')),
                'size' => 0,
                'filename' => null,
                'finished_at' => Carbon::now(),
            ]);
        }

        if ($executions->isNotEmpty() && $unfinished->isEmpty()) {
            return;
        }

        $output = $this->redact($this->backup_output ?? $exception?->getMessage() ?? 'Unknown error');
        $databaseNames = $unfinished->isEmpty() ? ['unknown'] : $unfinished->map(fn (ScheduledDatabaseBackupExecution $execution) => $execution->database_name ?? 'unknown');
        foreach ($databaseNames as $databaseName) {
            try {
                $this->team?->notify(new BackupFailed($this->backup, $this->backup->database, $output, $databaseName));
            } catch (Throwable $e) {
                Log::channel('scheduled-errors')->warning('Failed to send backup permanent failure notification', [
                    'backup_id' => $this->backup->uuid,
                    'error' => $e->getMessage(),
                ]);
            }
        }
    }

    /**
     * Redact the backed-up database credentials and the key and secret of every S3 destination from text that is stored or sent in notifications.
     */
    private function redact(string $text): string
    {
        try {
            $database = $this->database ?? $this->backup->database;
            $s3 = $this->s3 ?? $this->backup->s3;
            $lockedSecrets = [$this->postgres_password, $this->mongo_root_password, $s3?->key, $s3?->secret];
            foreach ($this->backup->selectedS3Storages() as $storage) {
                $lockedSecrets[] = $storage->key;
                $lockedSecrets[] = $storage->secret;
            }
            foreach (array_keys($database?->getAttributes() ?? []) as $attribute) {
                if (str_contains($attribute, 'password')) {
                    $lockedSecrets[] = $database->getAttribute($attribute);
                }
            }
        } catch (Throwable) {
            return SensitiveDataRedactor::FAILURE_MESSAGE;
        }

        return app(SensitiveDataRedactor::class)->redactText($text, lockedSecrets: array_filter($lockedSecrets, fn ($secret): bool => is_string($secret) && $secret !== ''));
    }

    /**
     * @return Collection<int, ScheduledDatabaseBackupExecution>
     */
    private function executionsOfThisRun(): Collection
    {
        if ($this->executionUuidPrefix === null && $this->backup_log_uuid === null) {
            return collect();
        }

        return ScheduledDatabaseBackupExecution::query()
            ->where('scheduled_database_backup_id', $this->backup->id)
            ->where(fn ($query) => $query
                ->when($this->executionUuidPrefix !== null, fn ($query) => $query->orWhere('uuid', 'like', $this->executionUuidPrefix.'%'))
                ->when($this->backup_log_uuid !== null, fn ($query) => $query->orWhere('uuid', $this->backup_log_uuid)))
            ->get();
    }
}