feat(scheduler): delete orphaned jobs and block editing of unregistered jobs
Adds deleteOrphanedJobs() to clean up stale job_keys during ensureSystemJobs, and prevents editing jobs that are no longer registered in the runtime registry. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1150,6 +1150,7 @@
|
||||
"Can run scheduled jobs manually": "Kann geplante Aufgaben manuell ausführen",
|
||||
"Scheduled job not found": "Geplante Aufgabe nicht gefunden",
|
||||
"Scheduled job is not registered": "Geplante Aufgabe ist nicht registriert",
|
||||
"This scheduled job is no longer registered and cannot be edited. It has been superseded by a newer version.": "Diese geplante Aufgabe ist nicht mehr registriert und kann nicht bearbeitet werden. Sie wurde durch eine neuere Version ersetzt.",
|
||||
"Scheduled job could not be saved": "Geplante Aufgabe konnte nicht gespeichert werden",
|
||||
"Scheduled job saved": "Geplante Aufgabe gespeichert",
|
||||
"Edit scheduled job": "Geplante Aufgabe bearbeiten",
|
||||
|
||||
@@ -1150,6 +1150,7 @@
|
||||
"Can run scheduled jobs manually": "Can run scheduled jobs manually",
|
||||
"Scheduled job not found": "Scheduled job not found",
|
||||
"Scheduled job is not registered": "Scheduled job is not registered",
|
||||
"This scheduled job is no longer registered and cannot be edited. It has been superseded by a newer version.": "This scheduled job is no longer registered and cannot be edited. It has been superseded by a newer version.",
|
||||
"Scheduled job could not be saved": "Scheduled job could not be saved",
|
||||
"Scheduled job saved": "Scheduled job saved",
|
||||
"Edit scheduled job": "Edit scheduled job",
|
||||
|
||||
@@ -240,6 +240,22 @@ class ScheduledJobRepository implements ScheduledJobRepositoryInterface
|
||||
return $updated !== false;
|
||||
}
|
||||
|
||||
public function deleteOrphanedJobs(array $validKeys): int
|
||||
{
|
||||
$validKeys = array_filter(array_map('trim', $validKeys), static fn (string $k): bool => $k !== '');
|
||||
if ($validKeys === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($validKeys), '?'));
|
||||
$affected = DB::update(
|
||||
"DELETE FROM scheduled_jobs WHERE job_key NOT IN ({$placeholders})",
|
||||
...array_map('strval', $validKeys)
|
||||
);
|
||||
|
||||
return (int) $affected;
|
||||
}
|
||||
|
||||
private function normalizeRow(mixed $row): ?array
|
||||
{
|
||||
if (!is_array($row)) {
|
||||
|
||||
@@ -28,4 +28,7 @@ interface ScheduledJobRepositoryInterface
|
||||
?string $errorCode,
|
||||
?string $errorMessage
|
||||
): bool;
|
||||
|
||||
/** Delete jobs whose job_key is not in the given list of valid keys. Returns affected row count. */
|
||||
public function deleteOrphanedJobs(array $validKeys): int;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,13 @@ class ScheduledJobService
|
||||
}
|
||||
$this->scheduledJobRepository->updateJobMeta((int) $existing['id'], $normalized);
|
||||
}
|
||||
|
||||
$this->scheduledJobRepository->deleteOrphanedJobs(array_keys($definitions));
|
||||
}
|
||||
|
||||
public function isRegistered(string $jobKey): bool
|
||||
{
|
||||
return $this->scheduledJobRegistry->get($jobKey) !== null;
|
||||
}
|
||||
|
||||
public function listPaged(array $filters): array
|
||||
|
||||
@@ -20,6 +20,8 @@ if (!$job) {
|
||||
Router::redirect('admin/scheduled-jobs');
|
||||
}
|
||||
|
||||
$isRegistered = $scheduledJobService->isRegistered((string) ($job['job_key'] ?? ''));
|
||||
|
||||
$currentUserId = (int) ($session['user']['id'] ?? 0);
|
||||
$authorizationService = app(\MintyPHP\Service\Access\AuthorizationService::class);
|
||||
$canManage = $authorizationService->authorize(\MintyPHP\Service\Access\OperationsAuthorizationPolicy::ABILITY_ADMIN_JOBS_MANAGE, [
|
||||
@@ -39,6 +41,12 @@ $form = [
|
||||
];
|
||||
$errorBag = formErrors();
|
||||
|
||||
if (!$isRegistered) {
|
||||
$canManage = false;
|
||||
$canRunNow = false;
|
||||
$errorBag->addGlobal(t('This scheduled job is no longer registered and cannot be edited. It has been superseded by a newer version.'));
|
||||
}
|
||||
|
||||
if (requestInput()->method() === 'POST') {
|
||||
if (!$canManage) {
|
||||
Guard::deny();
|
||||
|
||||
@@ -102,6 +102,39 @@ class ScheduledJobServiceTest extends TestCase
|
||||
$service->ensureSystemJobs();
|
||||
}
|
||||
|
||||
public function testEnsureSystemJobsDisablesOrphanedJobs(): void
|
||||
{
|
||||
$jobRepository = $this->createMock(ScheduledJobRepository::class);
|
||||
$runRepository = $this->createMock(ScheduledJobRunRepository::class);
|
||||
$registry = $this->createMock(ScheduledJobRegistry::class);
|
||||
$calculator = new ScheduleCalculator();
|
||||
|
||||
$registry->expects($this->once())->method('definitions')->willReturn([
|
||||
'system_audit_purge' => [
|
||||
'label' => 'System audit log cleanup',
|
||||
'description' => 'Purges system audit log entries',
|
||||
'default_enabled' => 1,
|
||||
'default_timezone' => 'UTC',
|
||||
'default_schedule_type' => 'daily',
|
||||
'default_schedule_interval' => 1,
|
||||
'default_schedule_time' => '03:00',
|
||||
'default_schedule_weekdays_csv' => null,
|
||||
'default_catchup_once' => 1,
|
||||
'allowed_schedule_types' => ['daily', 'weekly'],
|
||||
],
|
||||
]);
|
||||
$jobRepository->method('findByKey')->willReturn(null);
|
||||
$jobRepository->method('create')->willReturn(1);
|
||||
|
||||
$jobRepository->expects($this->once())
|
||||
->method('deleteOrphanedJobs')
|
||||
->with(['system_audit_purge'])
|
||||
->willReturn(1);
|
||||
|
||||
$service = new ScheduledJobService($jobRepository, $runRepository, $registry, $calculator);
|
||||
$service->ensureSystemJobs();
|
||||
}
|
||||
|
||||
public function testUpdateFromAdminReturnsValidationErrorForWeeklyWithoutWeekdays(): void
|
||||
{
|
||||
$jobRepository = $this->createMock(ScheduledJobRepository::class);
|
||||
|
||||
Reference in New Issue
Block a user