fix(backups): stop containers during volume backup creation

Track and recover stopped containers across interrupted backup executions.
This commit is contained in:
Andras Bacsai 2026-07-15 15:59:35 +02:00
parent 63961e0799
commit 4eec1ac547
11 changed files with 131 additions and 81 deletions

View file

@ -112,7 +112,7 @@ public function handle(): void
}
try {
$this->recoverPausedVolumeBackupContainers();
$this->recoverStoppedVolumeBackupContainers();
$this->processScheduledVolumeBackups();
} catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Failed to process scheduled volume backups', [
@ -365,11 +365,11 @@ private function processScheduledVolumeBackups(): void
});
}
private function recoverPausedVolumeBackupContainers(): void
private function recoverStoppedVolumeBackupContainers(): void
{
ScheduledVolumeBackupExecution::query()
->where(fn (Builder $query) => $query
->where('pause_recovery_pending', true)
->where('stop_recovery_pending', true)
->orWhere('s3_cleanup_pending', true))
->chunkById(self::CHUNK_SIZE, function ($executions): void {
foreach ($executions as $execution) {
@ -385,7 +385,7 @@ private function processScheduledVolumeBackup(ScheduledVolumeBackup $backup): vo
if ($backup->executions()
->where(fn (Builder $query) => $query
->where('pause_recovery_pending', true)
->where('stop_recovery_pending', true)
->orWhere('s3_cleanup_pending', true))
->exists()) {
$this->skippedCount++;

View file

@ -83,11 +83,11 @@ public function handle(): void
.' '.escapeshellarg($image)
.' tar -czf - -C /volume . > '.escapeshellarg($backupLocation);
if ($this->backup->pause_during_backup) {
if ($this->backup->stop_during_backup) {
$containers = $this->containersUsingVolume($source, $server);
if ($containers !== []) {
$this->execution->update(['pause_recovery_pending' => true]);
$archiveCommand = $this->archiveWithPausedContainers(
$this->execution->update(['stop_recovery_pending' => true]);
$archiveCommand = $this->archiveWithStoppedContainers(
$archiveCommand,
$containers,
VolumeBackupRecoveryJob::stateFile($this->execution),
@ -101,8 +101,8 @@ public function handle(): void
$archiveCommand,
], $server, timeout: $this->timeout, disableMultiplexing: true);
$this->execution->update([
'pause_container_ids' => null,
'pause_recovery_pending' => false,
'stop_container_ids' => null,
'stop_recovery_pending' => false,
]);
$size = (int) instant_remote_process(
@ -253,7 +253,7 @@ private function containersUsingVolume(string $source, Server $server): array
/**
* @param array<int, string> $containers
*/
private function archiveWithPausedContainers(string $archiveCommand, array $containers, string $stateFile): string
private function archiveWithStoppedContainers(string $archiveCommand, array $containers, string $stateFile): string
{
if ($containers === []) {
return $archiveCommand;
@ -263,9 +263,12 @@ private function archiveWithPausedContainers(string $archiveCommand, array $cont
$script = "set -eu\n"
.'state_file='.escapeshellarg($stateFile)."\n"
.": > \"\$state_file\"\n"
."cleanup() { status=\$?; trap - EXIT HUP INT TERM; while IFS= read -r container; do docker unpause \"\$container\" >/dev/null || status=1; done < \"\$state_file\"; [ \$status -eq 0 ] && rm -f \"\$state_file\"; exit \$status; }\n"
."trap cleanup EXIT HUP INT TERM\n"
."for container in {$containerList}; do echo \"\$container\" >> \"\$state_file\"; docker pause \"\$container\" >/dev/null; done\n"
."cleanup() { status=\$?; trap - EXIT HUP INT TERM; while IFS= read -r container; do docker start \"\$container\" >/dev/null || status=1; done < \"\$state_file\"; [ \$status -eq 0 ] && rm -f \"\$state_file\"; exit \$status; }\n"
."trap cleanup EXIT\n"
."trap 'exit 129' HUP\n"
."trap 'exit 130' INT\n"
."trap 'exit 143' TERM\n"
."for container in {$containerList}; do echo \"\$container\" >> \"\$state_file\"; docker stop \"\$container\" >/dev/null; done\n"
.$archiveCommand;
return 'sh -c '.escapeshellarg($script);
@ -273,7 +276,7 @@ private function archiveWithPausedContainers(string $archiveCommand, array $cont
private function recoverIncompleteBackup(ScheduledVolumeBackupExecution $execution): string
{
if (! $execution->pause_recovery_pending && ! $execution->s3_cleanup_pending) {
if (! $execution->stop_recovery_pending && ! $execution->s3_cleanup_pending) {
return '';
}

View file

@ -48,7 +48,7 @@ public static function recover(ScheduledVolumeBackupExecution $execution): void
{
$execution->loadMissing('scheduledVolumeBackup.volume.resource');
if ($execution->pause_recovery_pending) {
if ($execution->stop_recovery_pending) {
self::recoverContainers($execution);
}
@ -75,22 +75,22 @@ private static function recoverContainers(ScheduledVolumeBackupExecution $execut
->filter(fn (string $container): bool => preg_match('/^[a-f0-9]{6,64}$/i', $container) === 1)
->values()
->all();
$execution->update(['pause_container_ids' => $containers]);
$execution->update(['stop_container_ids' => $containers]);
$remainingFile = $stateFile.'.remaining';
$script = 'status=0; : > '.escapeshellarg($remainingFile).'; '
.'if [ -f '.escapeshellarg($stateFile).' ]; then while IFS= read -r container; do '
.'[ -z "$container" ] && continue; paused=$(docker inspect --format \'{{.State.Paused}}\' "$container" 2>/dev/null) '
.'[ -z "$container" ] && continue; running=$(docker inspect --format \'{{.State.Running}}\' "$container" 2>/dev/null) '
.'|| { echo "$container" >> '.escapeshellarg($remainingFile).'; status=1; continue; }; '
.'if [ "$paused" = true ] && ! docker unpause "$container" >/dev/null; then echo "$container" >> '
.'if [ "$running" != true ] && ! docker start "$container" >/dev/null; then echo "$container" >> '
.escapeshellarg($remainingFile).'; status=1; fi; done < '.escapeshellarg($stateFile).'; fi; '
.'if [ -s '.escapeshellarg($remainingFile).' ]; then mv '.escapeshellarg($remainingFile).' '.escapeshellarg($stateFile)
.'; else rm -f '.escapeshellarg($stateFile).' '.escapeshellarg($remainingFile).'; fi; exit $status';
instant_remote_process(['sh -c '.escapeshellarg($script)], $server, disableMultiplexing: true);
$execution->update([
'pause_container_ids' => null,
'pause_recovery_pending' => false,
'stop_container_ids' => null,
'stop_recovery_pending' => false,
]);
}
@ -112,6 +112,6 @@ public static function cleanupS3Upload(ScheduledVolumeBackupExecution $execution
public static function stateFile(ScheduledVolumeBackupExecution $execution): string
{
return '/tmp/coolify-volume-backup-'.$execution->uuid.'.paused';
return '/tmp/coolify-volume-backup-'.$execution->uuid.'.stopped';
}
}

View file

@ -32,7 +32,7 @@ class VolumeBackups extends Component
public bool $disableLocalBackup = false;
public bool $pauseDuringBackup = false;
public bool $stopDuringBackup = false;
public ?int $s3StorageId = null;
@ -63,7 +63,7 @@ protected function rules(): array
'enabled' => ['required', 'boolean'],
'saveToS3' => ['required', 'boolean'],
'disableLocalBackup' => ['required', 'boolean'],
'pauseDuringBackup' => ['required', 'boolean'],
'stopDuringBackup' => ['required', 'boolean'],
's3StorageId' => ['nullable', 'integer'],
'retentionAmountLocally' => ['required', 'integer', 'min:0', 'max:10000'],
'retentionDaysLocally' => ['required', 'integer', 'min:0'],
@ -90,7 +90,7 @@ public function mount(): void
$this->enabled = $this->backup->enabled;
$this->saveToS3 = $this->backup->save_s3;
$this->disableLocalBackup = $this->backup->disable_local_backup;
$this->pauseDuringBackup = $this->backup->pause_during_backup;
$this->stopDuringBackup = $this->backup->stop_during_backup;
$this->s3StorageId = $this->backup->s3_storage_id ?? $this->availableS3Storages->first()?->id;
$this->retentionAmountLocally = $this->backup->retention_amount_locally;
$this->retentionDaysLocally = $this->backup->retention_days_locally;
@ -188,7 +188,7 @@ public function delete(?string $password = null, array $selectedActions = [])
if ($this->backup->executions()
->where(fn ($query) => $query
->where('status', 'running')
->orWhere('pause_recovery_pending', true)
->orWhere('stop_recovery_pending', true)
->orWhere('s3_cleanup_pending', true))
->exists()) {
$this->dispatch('error', 'Wait for the running volume backup and container recovery to finish before deleting this schedule.');
@ -246,7 +246,7 @@ public function cleanupFailed(): void
$deletedCount = $this->backup?->executions()
->where('status', 'failed')
->where('pause_recovery_pending', false)
->where('stop_recovery_pending', false)
->where('s3_cleanup_pending', false)
->where(fn ($query) => $query
->whereNull('filename')
@ -292,7 +292,7 @@ public function deleteBackup(int $executionId, string $password, array $selected
return false;
}
if ($execution->status === 'running' || $execution->pause_recovery_pending || $execution->s3_cleanup_pending) {
if ($execution->status === 'running' || $execution->stop_recovery_pending || $execution->s3_cleanup_pending) {
$this->dispatch('error', 'Wait for the backup and recovery operations to finish before deleting it.');
return false;
@ -369,7 +369,7 @@ private function persistBackup(bool $enabled): ScheduledVolumeBackup
'enabled' => $enabled,
'save_s3' => $this->saveToS3,
'disable_local_backup' => $this->disableLocalBackup,
'pause_during_backup' => $this->pauseDuringBackup,
'stop_during_backup' => $this->stopDuringBackup,
's3_storage_id' => $this->saveToS3 ? $this->s3StorageId : null,
'retention_amount_locally' => $this->retentionAmountLocally,
'retention_days_locally' => $this->retentionDaysLocally,

View file

@ -17,7 +17,7 @@ class ScheduledVolumeBackup extends BaseModel
'enabled',
'save_s3',
'disable_local_backup',
'pause_during_backup',
'stop_during_backup',
'retention_amount_locally',
'retention_days_locally',
'retention_max_storage_locally',
@ -33,7 +33,7 @@ protected function casts(): array
'enabled' => 'boolean',
'save_s3' => 'boolean',
'disable_local_backup' => 'boolean',
'pause_during_backup' => 'boolean',
'stop_during_backup' => 'boolean',
'retention_amount_locally' => 'integer',
'retention_days_locally' => 'integer',
'retention_max_storage_locally' => 'float',

View file

@ -13,8 +13,8 @@ class ScheduledVolumeBackupExecution extends BaseModel
'message',
'size',
'filename',
'pause_container_ids',
'pause_recovery_pending',
'stop_container_ids',
'stop_recovery_pending',
's3_cleanup_pending',
'finished_at',
'local_storage_deleted',
@ -27,8 +27,8 @@ protected function casts(): array
return [
'size' => 'integer',
'finished_at' => 'datetime',
'pause_container_ids' => 'array',
'pause_recovery_pending' => 'boolean',
'stop_container_ids' => 'array',
'stop_recovery_pending' => 'boolean',
's3_cleanup_pending' => 'boolean',
'local_storage_deleted' => 'boolean',
's3_storage_deleted' => 'boolean',

View file

@ -21,7 +21,7 @@ public function up(): void
$table->boolean('enabled')->default(true);
$table->boolean('save_s3')->default(false);
$table->boolean('disable_local_backup')->default(false);
$table->boolean('pause_during_backup')->default(false);
$table->boolean('stop_during_backup')->default(false);
$table->unsignedInteger('retention_amount_locally')->default(7);
$table->unsignedInteger('retention_amount_s3')->default(7);
$table->unsignedInteger('timeout')->default(3600);

View file

@ -19,8 +19,8 @@ public function up(): void
$table->longText('message')->nullable();
$table->unsignedBigInteger('size')->default(0);
$table->text('filename')->nullable();
$table->json('pause_container_ids')->nullable();
$table->boolean('pause_recovery_pending')->default(false);
$table->json('stop_container_ids')->nullable();
$table->boolean('stop_recovery_pending')->default(false);
$table->boolean('s3_cleanup_pending')->default(false);
$table->timestamp('finished_at')->nullable();
$table->boolean('local_storage_deleted')->default(false);

View file

@ -65,6 +65,15 @@
</div>
@endcan
@endif
@if ($resource instanceof \App\Models\Application)
@can('update', $resource)
<x-modal-input buttonTitle="Configure Backup" title="Configure Volume Backup" :wireIgnore="false">
<livewire:project.application.backup.create :application="$resource"
:selected-volume-id="$storage->id"
wire:key="configure-readonly-volume-backup-{{ $storage->id }}" />
</x-modal-input>
@endcan
@endif
@else
@can('update', $resource)
@if ($isFirst)
@ -150,15 +159,4 @@
@endcan
@endif
</form>
@if ($isReadOnly && $resource instanceof \App\Models\Application)
@can('update', $resource)
<div class="pt-2">
<x-modal-input buttonTitle="Configure Backup" title="Configure Volume Backup" :wireIgnore="false">
<livewire:project.application.backup.create :application="$resource"
:selected-volume-id="$storage->id"
wire:key="configure-readonly-volume-backup-{{ $storage->id }}" />
</x-modal-input>
</div>
@endcan
@endif
</div>

View file

@ -33,12 +33,16 @@
<span class="font-medium text-neutral-800 dark:text-neutral-200">{{ $storage->name }}</span>
</p>
</div>
<div class="p-3 text-sm rounded bg-warning/10 text-warning">
Backups made while the application is writing to this volume may be inconsistent or corrupted. You can
gracefully stop containers during the archive step for a safer file-level backup, but this briefly
interrupts the application.
</div>
</div>
<div class="w-full max-w-md">
<x-forms.checkbox id="pauseDuringBackup" label="Pause containers while creating the archive"
helper="Off by default. Containers using this volume are resumed immediately after the archive is created." />
<x-forms.checkbox instantSave id="stopDuringBackup" label="Stop containers while creating the archive"
helper="Off by default. Containers using this volume are gracefully stopped and restarted immediately after the archive is created." />
@if ($availableS3Storages->isNotEmpty())
<x-forms.checkbox instantSave id="saveToS3" label="S3 Enabled" />
@elseif ($saveToS3)
@ -80,11 +84,7 @@
<div class="flex flex-col gap-4">
<h3>Settings</h3>
<div class="p-3 text-sm rounded bg-warning/10 text-warning">
Backups made while the application is writing to this volume may be inconsistent or corrupted. You can
pause containers during the archive step for a more consistent backup, but this briefly interrupts the
application.
</div>
<div class="grid grid-cols-1 gap-2 md:grid-cols-3">
<x-forms.input id="frequency" label="Frequency" required
helper="Use every_minute, hourly, daily, weekly, monthly, yearly, or a cron expression." />

View file

@ -96,18 +96,25 @@
->assertSee($volume->name);
});
it('shows the configure backup modal trigger instead of inline backup settings on a volume', function () {
it('shows the configure backup modal trigger inside the volume card instead of inline backup settings', function () {
InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0]));
$team = Team::factory()->create();
signInForVolumeBackups($this, $team);
[$application, $volume] = createVolumeBackupApplication($team);
Livewire::test(Show::class, [
$component = Livewire::test(Show::class, [
'storage' => $volume,
'resource' => $application,
])
->set('isReadOnly', true)
->assertSee('Configure Backup')
->assertDontSee('Backups made while the application is writing');
$html = $component->html();
expect(strpos($html, 'Configure Backup'))
->toBeGreaterThan(strpos($html, '<form'))
->toBeLessThan(strpos($html, '</form>'));
});
it('only shows the backup enabled badge for an enabled volume backup', function () {
@ -151,7 +158,7 @@
'enabled',
'save_s3',
'disable_local_backup',
'pause_during_backup',
'stop_during_backup',
'retention_amount_locally',
'retention_days_locally',
'retention_max_storage_locally',
@ -167,8 +174,8 @@
'message',
'size',
'filename',
'pause_container_ids',
'pause_recovery_pending',
'stop_container_ids',
'stop_recovery_pending',
's3_cleanup_pending',
'finished_at',
'local_storage_deleted',
@ -215,7 +222,7 @@
'Backup Now',
'Delete Backups and Schedule',
'Persistent volume:',
'Pause containers while creating the archive',
'Stop containers while creating the archive',
'S3 Enabled',
'Disable Local Backup',
'S3 Storage',
@ -397,11 +404,12 @@ function signInForVolumeBackups($testCase, Team $team): User
->assertSee('Scheduled Backup')
->assertSee('Persistent volume:')
->assertSee('inconsistent or corrupted')
->assertSee('gracefully stop containers')
->set('frequency', 'daily')
->set('retentionAmountLocally', 5)
->set('retentionDaysLocally', 14)
->set('retentionMaxStorageLocally', 1.5)
->set('pauseDuringBackup', true)
->set('stopDuringBackup', true)
->call('save')
->assertDispatched('success');
@ -413,7 +421,7 @@ function signInForVolumeBackups($testCase, Team $team): User
->and($backup->retention_amount_locally)->toBe(5)
->and($backup->retention_days_locally)->toBe(14)
->and($backup->retention_max_storage_locally)->toBe(1.5)
->and($backup->pause_during_backup)->toBeTrue()
->and($backup->stop_during_backup)->toBeTrue()
->and($backup->save_s3)->toBeFalse();
});
@ -487,6 +495,45 @@ function signInForVolumeBackups($testCase, Team $team): User
->and($backup->fresh()->disable_local_backup)->toBeFalse();
});
it('saves each editable volume backup checkbox immediately', function () {
$team = Team::factory()->create();
signInForVolumeBackups($this, $team);
[$application, $volume] = createVolumeBackupApplication($team);
$s3Storage = S3Storage::create([
'name' => 'Volume backups',
'region' => 'us-east-1',
'key' => 'key',
'secret' => 'secret',
'bucket' => 'bucket',
'endpoint' => 'https://s3.example.com',
'team_id' => $team->id,
'is_usable' => true,
]);
$backup = ScheduledVolumeBackup::create([
'local_persistent_volume_id' => $volume->id,
'team_id' => $team->id,
's3_storage_id' => $s3Storage->id,
'frequency' => 'daily',
'save_s3' => true,
]);
$component = Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application]);
$html = $component->html();
foreach (['stopDuringBackup', 'saveToS3', 'disableLocalBackup'] as $property) {
preg_match('/<input\b(?=[^>]*wire:model=(?:"'.$property.'"|'.$property.'))[^>]*>/', $html, $matches);
expect($matches[0] ?? null)->not->toBeNull()
->and($matches[0])->toContain("wire:click='instantSave'");
}
$component->set('stopDuringBackup', true)->call('instantSave')->assertDispatched('success');
expect($backup->refresh()->stop_during_backup)->toBeTrue();
$component->set('stopDuringBackup', false)->call('instantSave')->assertDispatched('success');
expect($backup->refresh()->stop_during_backup)->toBeFalse();
});
it('allows volume S3 backups to be disabled when no usable storage remains', function () {
$team = Team::factory()->create();
signInForVolumeBackups($this, $team);
@ -829,7 +876,7 @@ function signInForVolumeBackups($testCase, Team $team): User
->and($oldExecution->fresh()->local_storage_deleted)->toBeFalse();
});
it('pauses and resumes containers that use the volume when requested', function () {
it('stops and restarts containers that use the volume when requested', function () {
config(['broadcasting.default' => 'null']);
InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0]));
$team = Team::factory()->create();
@ -838,7 +885,7 @@ function signInForVolumeBackups($testCase, Team $team): User
'local_persistent_volume_id' => $volume->id,
'team_id' => $team->id,
'frequency' => 'daily',
'pause_during_backup' => true,
'stop_during_backup' => true,
]);
Process::fake([
@ -849,9 +896,11 @@ function signInForVolumeBackups($testCase, Team $team): User
(new VolumeBackupJob($backup))->handle();
Process::assertRan(fn ($process) => str_contains($process->command, 'trap cleanup EXIT HUP INT TERM')
&& str_contains($process->command, 'docker pause')
&& str_contains($process->command, 'docker unpause')
Process::assertRan(fn ($process) => str_contains($process->command, 'trap cleanup EXIT')
&& str_contains($process->command, 'exit 143')
&& str_contains($process->command, 'TERM')
&& str_contains($process->command, 'docker stop')
&& str_contains($process->command, 'docker start')
&& str_contains($process->command, 'state_file='));
Process::assertRan(fn ($process) => str_contains($process->command, '{{println .Source}}{{println .Name}}')
&& str_contains($process->command, "grep -Fqx -- 'app-data'")
@ -859,7 +908,7 @@ function signInForVolumeBackups($testCase, Team $team): User
&& str_contains($process->command, 'continue'));
});
it('finds containers using a bind mounted host path before pausing', function () {
it('finds containers using a bind mounted host path before stopping', function () {
config(['broadcasting.default' => 'null']);
InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0]));
$team = Team::factory()->create();
@ -869,7 +918,7 @@ function signInForVolumeBackups($testCase, Team $team): User
'local_persistent_volume_id' => $volume->id,
'team_id' => $team->id,
'frequency' => 'daily',
'pause_during_backup' => true,
'stop_during_backup' => true,
]);
Process::fake([
@ -884,7 +933,7 @@ function signInForVolumeBackups($testCase, Team $team): User
&& str_contains($process->command, "grep -Fqx -- '/srv/app-data'"));
});
it('retries container recovery when a timed out backup left a container paused', function () {
it('retries container recovery when a timed out backup left a container stopped', function () {
Queue::fake();
$team = Team::factory()->create();
[$application, $volume] = createVolumeBackupApplication($team);
@ -896,23 +945,23 @@ function signInForVolumeBackups($testCase, Team $team): User
$execution = ScheduledVolumeBackupExecution::create([
'scheduled_volume_backup_id' => $backup->id,
'status' => 'running',
'pause_recovery_pending' => true,
'stop_recovery_pending' => true,
]);
Process::fake([
'*cat *coolify-volume-backup-*' => 'abc123',
'*docker unpause*' => Process::result(errorOutput: 'daemon unavailable', exitCode: 1),
'*docker start*' => Process::result(errorOutput: 'daemon unavailable', exitCode: 1),
]);
(new VolumeBackupJob($backup))->failed(new RuntimeException('Worker timed out'));
expect($execution->fresh()->status)->toBe('failed')
->and($execution->fresh()->message)->toContain('Container recovery failed')
->and($execution->fresh()->pause_container_ids)->toBe(['abc123'])
->and($execution->fresh()->pause_recovery_pending)->toBeTrue();
->and($execution->fresh()->stop_container_ids)->toBe(['abc123'])
->and($execution->fresh()->stop_recovery_pending)->toBeTrue();
Queue::assertPushed(VolumeBackupRecoveryJob::class);
});
it('resumes containers and removes a partial archive when archiving fails', function () {
it('restarts containers and removes a partial archive when archiving fails', function () {
config(['broadcasting.default' => 'null']);
InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0]));
$team = Team::factory()->create();
@ -921,11 +970,11 @@ function signInForVolumeBackups($testCase, Team $team): User
'local_persistent_volume_id' => $volume->id,
'team_id' => $team->id,
'frequency' => 'daily',
'pause_during_backup' => true,
'stop_during_backup' => true,
]);
Process::fake([
'*docker ps -q*' => 'abc123',
'*trap cleanup EXIT HUP INT TERM*' => Process::result(errorOutput: 'archive failed', exitCode: 1),
'*trap cleanup EXIT*' => Process::result(errorOutput: 'archive failed', exitCode: 1),
'*' => '',
]);
@ -933,7 +982,7 @@ function signInForVolumeBackups($testCase, Team $team): User
$execution = ScheduledVolumeBackupExecution::query()->sole();
expect($execution->status)->toBe('failed')
->and($execution->pause_container_ids)->toBeNull();
->and($execution->stop_container_ids)->toBeNull();
Process::assertRan(fn ($process) => str_contains($process->command, 'rm -f')
&& str_contains($process->command, '.tar.gz'));
});
@ -1079,7 +1128,7 @@ function signInForVolumeBackups($testCase, Team $team): User
$execution = ScheduledVolumeBackupExecution::create([
'scheduled_volume_backup_id' => $backup->id,
'status' => 'failed',
'pause_recovery_pending' => true,
'stop_recovery_pending' => true,
]);
(new ScheduledJobManager)->handle();