diff --git a/app/Services/BackupScheduling/BackupScheduleDispatcher.php b/app/Services/BackupScheduling/BackupScheduleDispatcher.php index f0bd207..a9280cf 100644 --- a/app/Services/BackupScheduling/BackupScheduleDispatcher.php +++ b/app/Services/BackupScheduling/BackupScheduleDispatcher.php @@ -8,8 +8,9 @@ use App\Models\Tenant; use App\Services\Intune\AuditLogger; use Carbon\CarbonImmutable; -use Illuminate\Database\QueryException; +use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Support\Facades\Bus; +use Illuminate\Support\Facades\Log; class BackupScheduleDispatcher { @@ -71,10 +72,20 @@ public function dispatchDue(?array $tenantIdentifiers = null): array 'status' => BackupScheduleRun::STATUS_RUNNING, 'summary' => null, ]); - } catch (QueryException $exception) { + } catch (UniqueConstraintViolationException) { // Idempotency: unique (backup_schedule_id, scheduled_for) $skippedRuns++; + Log::debug('Backup schedule run already dispatched for slot.', [ + 'tenant_id' => $schedule->tenant_id, + 'backup_schedule_id' => $schedule->id, + 'scheduled_for' => $slot->toDateTimeString(), + ]); + + $schedule->forceFill([ + 'next_run_at' => $this->scheduleTimeService->nextRunFor($schedule, $nowUtc), + ])->saveQuietly(); + continue; } diff --git a/specs/032-backup-scheduling-mvp/tasks.md b/specs/032-backup-scheduling-mvp/tasks.md index 7a4403f..ee1571c 100644 --- a/specs/032-backup-scheduling-mvp/tasks.md +++ b/specs/032-backup-scheduling-mvp/tasks.md @@ -89,6 +89,7 @@ ### Tests (Pest) - [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] 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 +- [X] T042 [P] [US2] Harden dispatcher idempotency in app/Services/BackupScheduling/BackupScheduleDispatcher.php (catch unique constraint only; treat as already dispatched, no side effects) and extend tests/Feature/BackupScheduling/DispatchIdempotencyTest.php ### Implementation diff --git a/tests/Feature/BackupScheduling/DispatchIdempotencyTest.php b/tests/Feature/BackupScheduling/DispatchIdempotencyTest.php index 8ab996b..8df6cd6 100644 --- a/tests/Feature/BackupScheduling/DispatchIdempotencyTest.php +++ b/tests/Feature/BackupScheduling/DispatchIdempotencyTest.php @@ -38,3 +38,44 @@ Bus::assertDispatchedTimes(RunBackupScheduleJob::class, 1); }); + +it('treats a unique constraint collision as already-dispatched and advances next_run_at', function () { + CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 5, 10, 0, 30, 'UTC')); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $schedule = BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Daily 10:00', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '10:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + 'next_run_at' => null, + ]); + + BackupScheduleRun::query()->create([ + 'backup_schedule_id' => $schedule->id, + 'tenant_id' => $tenant->id, + 'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute()->toDateTimeString(), + 'status' => BackupScheduleRun::STATUS_RUNNING, + 'summary' => null, + ]); + + Bus::fake(); + + $dispatcher = app(BackupScheduleDispatcher::class); + $dispatcher->dispatchDue([$tenant->external_id]); + + expect(BackupScheduleRun::query()->count())->toBe(1); + Bus::assertNotDispatched(RunBackupScheduleJob::class); + + $schedule->refresh(); + expect($schedule->next_run_at)->not->toBeNull(); + expect($schedule->next_run_at->toDateTimeString())->toBe('2026-01-06 10:00:00'); +});