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:
2026-04-13 14:13:09 +02:00
parent 1c784efd5c
commit 312d43d9d9
7 changed files with 69 additions and 0 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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)) {

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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();

View File

@@ -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);