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",
|
"Can run scheduled jobs manually": "Kann geplante Aufgaben manuell ausführen",
|
||||||
"Scheduled job not found": "Geplante Aufgabe nicht gefunden",
|
"Scheduled job not found": "Geplante Aufgabe nicht gefunden",
|
||||||
"Scheduled job is not registered": "Geplante Aufgabe ist nicht registriert",
|
"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 could not be saved": "Geplante Aufgabe konnte nicht gespeichert werden",
|
||||||
"Scheduled job saved": "Geplante Aufgabe gespeichert",
|
"Scheduled job saved": "Geplante Aufgabe gespeichert",
|
||||||
"Edit scheduled job": "Geplante Aufgabe bearbeiten",
|
"Edit scheduled job": "Geplante Aufgabe bearbeiten",
|
||||||
|
|||||||
@@ -1150,6 +1150,7 @@
|
|||||||
"Can run scheduled jobs manually": "Can run scheduled jobs manually",
|
"Can run scheduled jobs manually": "Can run scheduled jobs manually",
|
||||||
"Scheduled job not found": "Scheduled job not found",
|
"Scheduled job not found": "Scheduled job not found",
|
||||||
"Scheduled job is not registered": "Scheduled job is not registered",
|
"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 could not be saved": "Scheduled job could not be saved",
|
||||||
"Scheduled job saved": "Scheduled job saved",
|
"Scheduled job saved": "Scheduled job saved",
|
||||||
"Edit scheduled job": "Edit scheduled job",
|
"Edit scheduled job": "Edit scheduled job",
|
||||||
|
|||||||
@@ -240,6 +240,22 @@ class ScheduledJobRepository implements ScheduledJobRepositoryInterface
|
|||||||
return $updated !== false;
|
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
|
private function normalizeRow(mixed $row): ?array
|
||||||
{
|
{
|
||||||
if (!is_array($row)) {
|
if (!is_array($row)) {
|
||||||
|
|||||||
@@ -28,4 +28,7 @@ interface ScheduledJobRepositoryInterface
|
|||||||
?string $errorCode,
|
?string $errorCode,
|
||||||
?string $errorMessage
|
?string $errorMessage
|
||||||
): bool;
|
): 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->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
|
public function listPaged(array $filters): array
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ if (!$job) {
|
|||||||
Router::redirect('admin/scheduled-jobs');
|
Router::redirect('admin/scheduled-jobs');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$isRegistered = $scheduledJobService->isRegistered((string) ($job['job_key'] ?? ''));
|
||||||
|
|
||||||
$currentUserId = (int) ($session['user']['id'] ?? 0);
|
$currentUserId = (int) ($session['user']['id'] ?? 0);
|
||||||
$authorizationService = app(\MintyPHP\Service\Access\AuthorizationService::class);
|
$authorizationService = app(\MintyPHP\Service\Access\AuthorizationService::class);
|
||||||
$canManage = $authorizationService->authorize(\MintyPHP\Service\Access\OperationsAuthorizationPolicy::ABILITY_ADMIN_JOBS_MANAGE, [
|
$canManage = $authorizationService->authorize(\MintyPHP\Service\Access\OperationsAuthorizationPolicy::ABILITY_ADMIN_JOBS_MANAGE, [
|
||||||
@@ -39,6 +41,12 @@ $form = [
|
|||||||
];
|
];
|
||||||
$errorBag = formErrors();
|
$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 (requestInput()->method() === 'POST') {
|
||||||
if (!$canManage) {
|
if (!$canManage) {
|
||||||
Guard::deny();
|
Guard::deny();
|
||||||
|
|||||||
@@ -102,6 +102,39 @@ class ScheduledJobServiceTest extends TestCase
|
|||||||
$service->ensureSystemJobs();
|
$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
|
public function testUpdateFromAdminReturnsValidationErrorForWeeklyWithoutWeekdays(): void
|
||||||
{
|
{
|
||||||
$jobRepository = $this->createMock(ScheduledJobRepository::class);
|
$jobRepository = $this->createMock(ScheduledJobRepository::class);
|
||||||
|
|||||||
Reference in New Issue
Block a user