From dd597d152722cb8f141dfa76b4de694fd4283618 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:10:23 +0200 Subject: [PATCH] fix(backups): support long-running volume backups --- .../Api/VolumeBackupsController.php | 4 +- app/Jobs/VolumeBackupJob.php | 4 +- .../Project/Shared/Storages/VolumeBackups.php | 2 +- app/Models/ScheduledVolumeBackup.php | 2 + config/horizon.php | 6 ++- ...increase_default_volume_backup_timeout.php | 27 ++++++++++++++ tests/Feature/VolumeBackupTest.php | 37 +++++++++++++++++++ 7 files changed, 76 insertions(+), 6 deletions(-) create mode 100644 database/migrations/2026_08_15_000000_increase_default_volume_backup_timeout.php diff --git a/app/Http/Controllers/Api/VolumeBackupsController.php b/app/Http/Controllers/Api/VolumeBackupsController.php index e51bf31f8e..a5225f5dd7 100644 --- a/app/Http/Controllers/Api/VolumeBackupsController.php +++ b/app/Http/Controllers/Api/VolumeBackupsController.php @@ -34,7 +34,7 @@ use RuntimeException; new OA\Property(property: 'retention_amount_s3', type: 'integer', default: 7, minimum: 0, maximum: 10000), new OA\Property(property: 'retention_days_s3', type: 'integer', default: 0, maximum: 2147483647, minimum: 0), new OA\Property(property: 'retention_max_storage_s3', type: 'number', format: 'float', default: 0, maximum: 9999999999, minimum: 0), - new OA\Property(property: 'timeout', type: 'integer', default: 3600, minimum: 60, maximum: 36000), + new OA\Property(property: 'timeout', type: 'integer', default: ScheduledVolumeBackup::DEFAULT_TIMEOUT, minimum: 60, maximum: 36000), ], type: 'object', additionalProperties: false, @@ -275,7 +275,7 @@ class VolumeBackupsController extends Controller 'retention_amount_s3' => $request->integer('retention_amount_s3', 7), 'retention_days_s3' => $request->integer('retention_days_s3'), 'retention_max_storage_s3' => $request->float('retention_max_storage_s3'), - 'timeout' => $request->integer('timeout', 3600), + 'timeout' => $request->integer('timeout', ScheduledVolumeBackup::DEFAULT_TIMEOUT), ]); $created = $backup->wasRecentlyCreated; diff --git a/app/Jobs/VolumeBackupJob.php b/app/Jobs/VolumeBackupJob.php index 39998a1f60..b567a71b7f 100644 --- a/app/Jobs/VolumeBackupJob.php +++ b/app/Jobs/VolumeBackupJob.php @@ -28,14 +28,14 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue public int $maxExceptions = 1; - public int $timeout = 3600; + public int $timeout = ScheduledVolumeBackup::DEFAULT_TIMEOUT; private ?ScheduledVolumeBackupExecution $execution = null; public function __construct(public ScheduledVolumeBackup $backup) { $this->onQueue(crons_queue()); - $this->timeout = $backup->timeout ?? 3600; + $this->timeout = $backup->timeout ?? ScheduledVolumeBackup::DEFAULT_TIMEOUT; } public function middleware(): array diff --git a/app/Livewire/Project/Shared/Storages/VolumeBackups.php b/app/Livewire/Project/Shared/Storages/VolumeBackups.php index a03820b4b5..a10eb5ad03 100644 --- a/app/Livewire/Project/Shared/Storages/VolumeBackups.php +++ b/app/Livewire/Project/Shared/Storages/VolumeBackups.php @@ -56,7 +56,7 @@ class VolumeBackups extends Component public string $timezone = ''; - public int $timeout = 3600; + public int $timeout = ScheduledVolumeBackup::DEFAULT_TIMEOUT; public int $perPage = 10; diff --git a/app/Models/ScheduledVolumeBackup.php b/app/Models/ScheduledVolumeBackup.php index a333681427..7f33fd92b9 100644 --- a/app/Models/ScheduledVolumeBackup.php +++ b/app/Models/ScheduledVolumeBackup.php @@ -11,6 +11,8 @@ use Illuminate\Database\Eloquent\Relations\MorphTo; class ScheduledVolumeBackup extends BaseModel { + public const int DEFAULT_TIMEOUT = 36000; + protected $fillable = [ 'uuid', 'backupable_type', diff --git a/config/horizon.php b/config/horizon.php index d86c52affe..fe35734c2d 100644 --- a/config/horizon.php +++ b/config/horizon.php @@ -1,5 +1,6 @@ 1, 'nice' => 0, 'sleep' => 3, - 'timeout' => env('HORIZON_TIMEOUT', 36000), + 'timeout' => min( + max((int) env('HORIZON_TIMEOUT', 39600), ScheduledVolumeBackup::DEFAULT_TIMEOUT + 600), + 85800, + ), ], ], diff --git a/database/migrations/2026_08_15_000000_increase_default_volume_backup_timeout.php b/database/migrations/2026_08_15_000000_increase_default_volume_backup_timeout.php new file mode 100644 index 0000000000..f895c7837c --- /dev/null +++ b/database/migrations/2026_08_15_000000_increase_default_volume_backup_timeout.php @@ -0,0 +1,27 @@ +where('timeout', 3600) + ->update(['timeout' => 36000]); + + Schema::table('scheduled_volume_backups', function (Blueprint $table) { + $table->unsignedInteger('timeout')->default(36000)->change(); + }); + } + + public function down(): void + { + Schema::table('scheduled_volume_backups', function (Blueprint $table) { + $table->unsignedInteger('timeout')->default(3600)->change(); + }); + } +}; diff --git a/tests/Feature/VolumeBackupTest.php b/tests/Feature/VolumeBackupTest.php index 4968945916..e18f1475f4 100644 --- a/tests/Feature/VolumeBackupTest.php +++ b/tests/Feature/VolumeBackupTest.php @@ -51,6 +51,43 @@ it('provides the volume backup domain classes and relationship', function () { ->and(method_exists(LocalFileVolume::class, 'scheduledBackups'))->toBeTrue(); }); +it('allows large volume backups to run for ten hours by default', function () { + $backup = new ScheduledVolumeBackup; + $job = new VolumeBackupJob($backup); + + expect($job->timeout)->toBe(36000) + ->and((new VolumeBackups)->timeout)->toBe(36000) + ->and(config('horizon.defaults.s6.timeout'))->toBeGreaterThan($job->timeout) + ->and(config('queue.connections.redis.retry_after'))->toBeGreaterThan(config('horizon.defaults.s6.timeout')); +}); + +it('upgrades existing default volume backup timeouts without changing custom timeouts', function () { + $team = Team::factory()->create(); + [$application, $defaultVolume] = createVolumeBackupApplication($team); + $customVolume = LocalPersistentVolume::create([ + 'name' => 'custom-timeout-data', + 'mount_path' => '/custom-data', + 'resource_id' => $application->id, + 'resource_type' => $application->getMorphClass(), + ]); + $defaultBackup = $defaultVolume->scheduledBackups()->create([ + 'team_id' => $team->id, + 'frequency' => 'daily', + 'timeout' => 3600, + ]); + $customBackup = $customVolume->scheduledBackups()->create([ + 'team_id' => $team->id, + 'frequency' => 'daily', + 'timeout' => 7200, + ]); + + $migration = require database_path('migrations/2026_08_15_000000_increase_default_volume_backup_timeout.php'); + $migration->up(); + + expect($defaultBackup->fresh()->timeout)->toBe(36000) + ->and($customBackup->fresh()->timeout)->toBe(7200); +}); + it('includes parallel gzip support in the Coolify helper image', function () { $dockerfile = file_get_contents(base_path('docker/coolify-helper/Dockerfile'));