fix(backup-scheduling): handle duplicate run slots
This commit is contained in:
parent
4d3fcd28a9
commit
c69b459c18
@ -41,6 +41,7 @@
|
|||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\UniqueConstraintViolationException;
|
||||||
use Illuminate\Support\Facades\Bus;
|
use Illuminate\Support\Facades\Bus;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
@ -320,26 +321,32 @@ public static function table(Table $table): Table
|
|||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
|
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
|
||||||
|
$run = null;
|
||||||
|
|
||||||
for ($i = 0; $i < 5; $i++) {
|
for ($i = 0; $i < 5; $i++) {
|
||||||
$exists = BackupScheduleRun::query()
|
try {
|
||||||
->where('backup_schedule_id', $record->id)
|
$run = BackupScheduleRun::create([
|
||||||
->where('scheduled_for', $scheduledFor)
|
'backup_schedule_id' => $record->id,
|
||||||
->exists();
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||||
if (! $exists) {
|
'status' => BackupScheduleRun::STATUS_RUNNING,
|
||||||
|
'summary' => null,
|
||||||
|
]);
|
||||||
break;
|
break;
|
||||||
|
} catch (UniqueConstraintViolationException) {
|
||||||
|
$scheduledFor = $scheduledFor->addMinute();
|
||||||
}
|
}
|
||||||
|
|
||||||
$scheduledFor = $scheduledFor->addMinute();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$run = BackupScheduleRun::create([
|
if (! $run instanceof BackupScheduleRun) {
|
||||||
'backup_schedule_id' => $record->id,
|
Notification::make()
|
||||||
'tenant_id' => $tenant->getKey(),
|
->title('Run already queued')
|
||||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
->body('Please wait a moment and try again.')
|
||||||
'status' => BackupScheduleRun::STATUS_RUNNING,
|
->warning()
|
||||||
'summary' => null,
|
->send();
|
||||||
]);
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
app(AuditLogger::class)->log(
|
app(AuditLogger::class)->log(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
@ -387,26 +394,32 @@ public static function table(Table $table): Table
|
|||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
|
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
|
||||||
|
$run = null;
|
||||||
|
|
||||||
for ($i = 0; $i < 5; $i++) {
|
for ($i = 0; $i < 5; $i++) {
|
||||||
$exists = BackupScheduleRun::query()
|
try {
|
||||||
->where('backup_schedule_id', $record->id)
|
$run = BackupScheduleRun::create([
|
||||||
->where('scheduled_for', $scheduledFor)
|
'backup_schedule_id' => $record->id,
|
||||||
->exists();
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||||
if (! $exists) {
|
'status' => BackupScheduleRun::STATUS_RUNNING,
|
||||||
|
'summary' => null,
|
||||||
|
]);
|
||||||
break;
|
break;
|
||||||
|
} catch (UniqueConstraintViolationException) {
|
||||||
|
$scheduledFor = $scheduledFor->addMinute();
|
||||||
}
|
}
|
||||||
|
|
||||||
$scheduledFor = $scheduledFor->addMinute();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$run = BackupScheduleRun::create([
|
if (! $run instanceof BackupScheduleRun) {
|
||||||
'backup_schedule_id' => $record->id,
|
Notification::make()
|
||||||
'tenant_id' => $tenant->getKey(),
|
->title('Retry already queued')
|
||||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
->body('Please wait a moment and try again.')
|
||||||
'status' => BackupScheduleRun::STATUS_RUNNING,
|
->warning()
|
||||||
'summary' => null,
|
->send();
|
||||||
]);
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
app(AuditLogger::class)->log(
|
app(AuditLogger::class)->log(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
@ -470,26 +483,26 @@ public static function table(Table $table): Table
|
|||||||
/** @var BackupSchedule $record */
|
/** @var BackupSchedule $record */
|
||||||
foreach ($records as $record) {
|
foreach ($records as $record) {
|
||||||
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
|
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
|
||||||
|
$run = null;
|
||||||
|
|
||||||
for ($i = 0; $i < 5; $i++) {
|
for ($i = 0; $i < 5; $i++) {
|
||||||
$exists = BackupScheduleRun::query()
|
try {
|
||||||
->where('backup_schedule_id', $record->id)
|
$run = BackupScheduleRun::create([
|
||||||
->where('scheduled_for', $scheduledFor)
|
'backup_schedule_id' => $record->id,
|
||||||
->exists();
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||||
if (! $exists) {
|
'status' => BackupScheduleRun::STATUS_RUNNING,
|
||||||
|
'summary' => null,
|
||||||
|
]);
|
||||||
break;
|
break;
|
||||||
|
} catch (UniqueConstraintViolationException) {
|
||||||
|
$scheduledFor = $scheduledFor->addMinute();
|
||||||
}
|
}
|
||||||
|
|
||||||
$scheduledFor = $scheduledFor->addMinute();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$run = BackupScheduleRun::create([
|
if (! $run instanceof BackupScheduleRun) {
|
||||||
'backup_schedule_id' => $record->id,
|
continue;
|
||||||
'tenant_id' => $tenant->getKey(),
|
}
|
||||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
|
||||||
'status' => BackupScheduleRun::STATUS_RUNNING,
|
|
||||||
'summary' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$createdRunIds[] = (int) $run->id;
|
$createdRunIds[] = (int) $run->id;
|
||||||
|
|
||||||
@ -522,11 +535,17 @@ public static function table(Table $table): Table
|
|||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
Notification::make()
|
$notification = Notification::make()
|
||||||
->title('Runs dispatched')
|
->title('Runs dispatched')
|
||||||
->body(sprintf('Queued %d run(s).', count($createdRunIds)))
|
->body(sprintf('Queued %d run(s).', count($createdRunIds)));
|
||||||
->success()
|
|
||||||
->send();
|
if (count($createdRunIds) === 0) {
|
||||||
|
$notification->warning();
|
||||||
|
} else {
|
||||||
|
$notification->success();
|
||||||
|
}
|
||||||
|
|
||||||
|
$notification->send();
|
||||||
}),
|
}),
|
||||||
BulkAction::make('bulk_retry')
|
BulkAction::make('bulk_retry')
|
||||||
->label('Retry')
|
->label('Retry')
|
||||||
@ -548,26 +567,26 @@ public static function table(Table $table): Table
|
|||||||
/** @var BackupSchedule $record */
|
/** @var BackupSchedule $record */
|
||||||
foreach ($records as $record) {
|
foreach ($records as $record) {
|
||||||
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
|
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
|
||||||
|
$run = null;
|
||||||
|
|
||||||
for ($i = 0; $i < 5; $i++) {
|
for ($i = 0; $i < 5; $i++) {
|
||||||
$exists = BackupScheduleRun::query()
|
try {
|
||||||
->where('backup_schedule_id', $record->id)
|
$run = BackupScheduleRun::create([
|
||||||
->where('scheduled_for', $scheduledFor)
|
'backup_schedule_id' => $record->id,
|
||||||
->exists();
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||||
if (! $exists) {
|
'status' => BackupScheduleRun::STATUS_RUNNING,
|
||||||
|
'summary' => null,
|
||||||
|
]);
|
||||||
break;
|
break;
|
||||||
|
} catch (UniqueConstraintViolationException) {
|
||||||
|
$scheduledFor = $scheduledFor->addMinute();
|
||||||
}
|
}
|
||||||
|
|
||||||
$scheduledFor = $scheduledFor->addMinute();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$run = BackupScheduleRun::create([
|
if (! $run instanceof BackupScheduleRun) {
|
||||||
'backup_schedule_id' => $record->id,
|
continue;
|
||||||
'tenant_id' => $tenant->getKey(),
|
}
|
||||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
|
||||||
'status' => BackupScheduleRun::STATUS_RUNNING,
|
|
||||||
'summary' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$createdRunIds[] = (int) $run->id;
|
$createdRunIds[] = (int) $run->id;
|
||||||
|
|
||||||
@ -600,11 +619,17 @@ public static function table(Table $table): Table
|
|||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
Notification::make()
|
$notification = Notification::make()
|
||||||
->title('Retries dispatched')
|
->title('Retries dispatched')
|
||||||
->body(sprintf('Queued %d run(s).', count($createdRunIds)))
|
->body(sprintf('Queued %d run(s).', count($createdRunIds)));
|
||||||
->success()
|
|
||||||
->send();
|
if (count($createdRunIds) === 0) {
|
||||||
|
$notification->warning();
|
||||||
|
} else {
|
||||||
|
$notification->success();
|
||||||
|
}
|
||||||
|
|
||||||
|
$notification->send();
|
||||||
}),
|
}),
|
||||||
DeleteBulkAction::make('bulk_delete')
|
DeleteBulkAction::make('bulk_delete')
|
||||||
->visible(fn (): bool => static::currentTenantRole()?->canManageBackupSchedules() ?? false),
|
->visible(fn (): bool => static::currentTenantRole()?->canManageBackupSchedules() ?? false),
|
||||||
|
|||||||
@ -88,6 +88,7 @@ ### Tests (Pest)
|
|||||||
- [X] T029 [P] [US3] Add feature test tests/Feature/BackupScheduling/ApplyRetentionJobTest.php (keeps last N backup_sets; soft-deletes older)
|
- [X] T029 [P] [US3] Add feature test tests/Feature/BackupScheduling/ApplyRetentionJobTest.php (keeps last N backup_sets; soft-deletes older)
|
||||||
- [X] T038 [P] [US1] Add feature test tests/Feature/BackupScheduling/BackupScheduleBulkDeleteTest.php (bulk delete action regression)
|
- [X] T038 [P] [US1] Add feature test tests/Feature/BackupScheduling/BackupScheduleBulkDeleteTest.php (bulk delete action regression)
|
||||||
- [X] T039 [P] [US1] Extend tests/Feature/BackupScheduling/BackupScheduleBulkDeleteTest.php (operator cannot bulk delete)
|
- [X] T039 [P] [US1] Extend tests/Feature/BackupScheduling/BackupScheduleBulkDeleteTest.php (operator cannot bulk delete)
|
||||||
|
- [X] T041 [P] [US3] Make manual dispatch actions idempotent under concurrency in app/Filament/Resources/BackupScheduleResource.php (avoid unique constraint 500); add regression in tests/Feature/BackupScheduling/RunNowRetryActionsTest.php
|
||||||
|
|
||||||
### Implementation
|
### Implementation
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
use App\Models\BackupSchedule;
|
use App\Models\BackupSchedule;
|
||||||
use App\Models\BackupScheduleRun;
|
use App\Models\BackupScheduleRun;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
@ -203,3 +204,67 @@
|
|||||||
Queue::assertPushed(RunBackupScheduleJob::class, 2);
|
Queue::assertPushed(RunBackupScheduleJob::class, 2);
|
||||||
$this->assertDatabaseCount('notifications', 1);
|
$this->assertDatabaseCount('notifications', 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('operator can bulk retry even if a run already exists for this minute', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||||
|
|
||||||
|
$scheduleA = BackupSchedule::query()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'name' => 'Nightly A',
|
||||||
|
'is_enabled' => true,
|
||||||
|
'timezone' => 'UTC',
|
||||||
|
'frequency' => 'daily',
|
||||||
|
'time_of_day' => '01:00:00',
|
||||||
|
'days_of_week' => null,
|
||||||
|
'policy_types' => ['deviceConfiguration'],
|
||||||
|
'include_foundations' => true,
|
||||||
|
'retention_keep_last' => 30,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$scheduleB = BackupSchedule::query()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'name' => 'Nightly B',
|
||||||
|
'is_enabled' => true,
|
||||||
|
'timezone' => 'UTC',
|
||||||
|
'frequency' => 'daily',
|
||||||
|
'time_of_day' => '02:00:00',
|
||||||
|
'days_of_week' => null,
|
||||||
|
'policy_types' => ['deviceConfiguration'],
|
||||||
|
'include_foundations' => true,
|
||||||
|
'retention_keep_last' => 30,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
|
||||||
|
BackupScheduleRun::query()->create([
|
||||||
|
'backup_schedule_id' => $scheduleA->id,
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||||
|
'status' => BackupScheduleRun::STATUS_RUNNING,
|
||||||
|
'summary' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(ListBackupSchedules::class)
|
||||||
|
->callTableBulkAction('bulk_retry', collect([$scheduleA, $scheduleB]));
|
||||||
|
|
||||||
|
expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleA->id)->count())
|
||||||
|
->toBe(2);
|
||||||
|
|
||||||
|
$newRunA = BackupScheduleRun::query()
|
||||||
|
->where('backup_schedule_id', $scheduleA->id)
|
||||||
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($newRunA)->not->toBeNull();
|
||||||
|
expect($newRunA->scheduled_for->setTimezone('UTC')->toDateTimeString())
|
||||||
|
->toBe($scheduledFor->addMinute()->toDateTimeString());
|
||||||
|
|
||||||
|
expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleB->id)->count())
|
||||||
|
->toBe(1);
|
||||||
|
|
||||||
|
Queue::assertPushed(RunBackupScheduleJob::class, 2);
|
||||||
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user