diff --git a/app/Config/Jobs.php b/app/Config/Jobs.php index ffa37f2db..9f5e9740d 100644 --- a/app/Config/Jobs.php +++ b/app/Config/Jobs.php @@ -8,4 +8,5 @@ class Jobs extends BaseConfig { public string $mode = 'web'; // auto | web | manual public int $webMaxSeconds = 5; + public int $taskMaxSeconds = 30; } diff --git a/app/Controllers/Jobs.php b/app/Controllers/Jobs.php index 5029635ed..3e0d998c8 100644 --- a/app/Controllers/Jobs.php +++ b/app/Controllers/Jobs.php @@ -48,13 +48,15 @@ class Jobs extends Secure_Controller public function postSaveSettings(): ResponseInterface { $rules = [ - 'mode' => 'required|in_list[auto,web,manual]', - 'web_max_seconds' => 'required|is_natural' + 'mode' => 'required|in_list[auto,web,manual]', + 'web_max_seconds' => 'required|is_natural', + 'task_max_seconds' => 'required|is_natural' ]; $messages = [ - 'mode' => ['in_list' => lang('Jobs.mode_invalid')], - 'web_max_seconds' => ['is_natural' => lang('Jobs.web_max_seconds_invalid')] + 'mode' => ['in_list' => lang('Jobs.mode_invalid')], + 'web_max_seconds' => ['is_natural' => lang('Jobs.web_max_seconds_invalid')], + 'task_max_seconds' => ['is_natural' => lang('Jobs.task_max_seconds_invalid')] ]; if ($response = $this->validateFields($rules, $messages)) { @@ -62,8 +64,9 @@ class Jobs extends Secure_Controller } $batchSaveData = [ - 'jobs_mode' => $this->request->getPost('mode'), - 'jobs_web_max_seconds' => $this->request->getPost('web_max_seconds', FILTER_SANITIZE_NUMBER_INT) + 'jobs_mode' => $this->request->getPost('mode'), + 'jobs_web_max_seconds' => $this->request->getPost('web_max_seconds', FILTER_SANITIZE_NUMBER_INT), + 'jobs_task_max_seconds' => $this->request->getPost('task_max_seconds', FILTER_SANITIZE_NUMBER_INT) ]; $success = $this->appconfig->batch_save($batchSaveData); diff --git a/app/Database/Migrations/20260923000000_AddJobsTaskMaxSecondsConfigKey.php b/app/Database/Migrations/20260923000000_AddJobsTaskMaxSecondsConfigKey.php new file mode 100644 index 000000000..3b877f0e6 --- /dev/null +++ b/app/Database/Migrations/20260923000000_AddJobsTaskMaxSecondsConfigKey.php @@ -0,0 +1,18 @@ +db->table('app_config')->ignore(true)->insert(['key' => 'jobs_task_max_seconds', 'value' => '30']); + } + + public function down(): void + { + $this->db->table('app_config')->where('key', 'jobs_task_max_seconds')->delete(); + } +} diff --git a/app/Filters/BoundedTaskRunner.php b/app/Filters/BoundedTaskRunner.php index 5263fe0a4..d42281086 100644 --- a/app/Filters/BoundedTaskRunner.php +++ b/app/Filters/BoundedTaskRunner.php @@ -15,16 +15,22 @@ use Throwable; * mid-execution (PHP has no portable, FPM-safe preemption mechanism), so * this only bounds how many *additional* tasks are started after the * deadline, not the runtime of a task that was already running. + * + * $taskMaxSeconds is a soft, log-only budget: if a single task's run() + * takes longer than this, a warning is logged after the fact. It cannot + * stop or interrupt the task for the same preemption reason above. */ class BoundedTaskRunner extends TaskRunner { private float $deadline; + private float $taskMaxSeconds; - public function __construct(float $deadline) + public function __construct(float $deadline, float $taskMaxSeconds) { parent::__construct(); $this->deadline = $deadline; + $this->taskMaxSeconds = $taskMaxSeconds; } public function run() @@ -51,6 +57,7 @@ class BoundedTaskRunner extends TaskRunner $error = null; $start = Time::now(); + $startMicrotime = microtime(true); $output = null; $this->cliWrite('Processing: ' . ($task->name ?: 'Task'), 'green'); @@ -58,6 +65,17 @@ class BoundedTaskRunner extends TaskRunner try { $output = $task->run(); + $elapsed = microtime(true) - $startMicrotime; + + if ($elapsed > $this->taskMaxSeconds) { + log_message('warning', sprintf( + 'JobRunner: task "%s" took %.2fs, exceeding jobs_task_max_seconds (%.2fs).', + $task->name ?: 'Task', + $elapsed, + $this->taskMaxSeconds + )); + } + $this->cliWrite('Executed: ' . ($task->name ?: 'Task'), 'cyan'); } catch (Throwable $e) { $this->cliWrite('Failed: ' . ($task->name ?: 'Task'), 'red'); diff --git a/app/Filters/JobRunner.php b/app/Filters/JobRunner.php index 87514dcea..4d7ea8dce 100644 --- a/app/Filters/JobRunner.php +++ b/app/Filters/JobRunner.php @@ -43,8 +43,9 @@ class JobRunner implements FilterInterface } $maxSeconds = (int)($config['jobs_web_max_seconds'] ?? config('Jobs')->webMaxSeconds); + $taskMaxSeconds = (int)($config['jobs_task_max_seconds'] ?? config('Jobs')->taskMaxSeconds); - register_shutdown_function(static function () use ($maxSeconds, $lockHandle): void { + register_shutdown_function(static function () use ($maxSeconds, $taskMaxSeconds, $lockHandle): void { if (function_exists('fastcgi_finish_request')) { fastcgi_finish_request(); } @@ -55,7 +56,7 @@ class JobRunner implements FilterInterface config(Tasks::class)->init(service('scheduler')); if (microtime(true) - $start < $maxSeconds) { - $runner = new BoundedTaskRunner($start + $maxSeconds); + $runner = new BoundedTaskRunner($start + $maxSeconds, $taskMaxSeconds); $runner->run(); } } catch (Throwable $e) { diff --git a/app/Language/en/Jobs.php b/app/Language/en/Jobs.php index dbda3d068..429e640a3 100644 --- a/app/Language/en/Jobs.php +++ b/app/Language/en/Jobs.php @@ -16,6 +16,10 @@ return [ 'select_jobs' => 'Select Jobs', 'settings' => 'Settings', 'settings_configuration' => 'Job Queue Settings', + 'task_max_seconds' => 'Task Time Warning Threshold', + 'task_max_seconds_invalid' => 'Task Time Warning Threshold is required and must be a non-negative integer.', + 'task_max_seconds_tooltip' => 'If a single task runs longer than this many seconds, a warning is logged. Does not stop or limit the task.', + 'task_max_seconds_tooltip_disabled' => 'Task Time Warning Threshold is only available when Mode is set to Web (no cron required).', 'throttle_count' => 'Count', 'throttle_count_required' => 'Throttle count is required and must be a non-negative integer.', 'throttle_period' => 'Period', diff --git a/app/Views/jobs/settings_config.php b/app/Views/jobs/settings_config.php index 01731ef8c..97c7ba5d8 100644 --- a/app/Views/jobs/settings_config.php +++ b/app/Views/jobs/settings_config.php @@ -55,6 +55,33 @@ +
+ 'required control-label col-xs-4 col-sm-3 col-md-2']) ?> +
+
+ 'number', + 'min' => 0, + 'name' => 'task_max_seconds', + 'id' => 'task_max_seconds', + 'class' => 'form-control input-sm required digits', + 'value' => $config['jobs_task_max_seconds'] ?? 30 + ], ($config['jobs_mode'] ?? 'web') !== 'web' ? ['disabled' => true] : [])) ?> + + + +
+
+
+ 'submit_jobs_settings', 'id' => 'submit_jobs_settings', @@ -96,10 +123,15 @@ const toggleWebMaxSeconds = function() { const isWeb = $('#mode').val() === 'web'; $('#web_max_seconds').prop('disabled', !isWeb); + $('#task_max_seconds').prop('disabled', !isWeb); const $tooltip = $('#web_max_seconds_tooltip'); const text = isWeb ? $tooltip.attr('data-tooltip-enabled') : $tooltip.attr('data-tooltip-disabled'); $tooltip.attr('data-original-title', text); + + const $taskTooltip = $('#task_max_seconds_tooltip'); + const taskText = isWeb ? $taskTooltip.attr('data-tooltip-enabled') : $taskTooltip.attr('data-tooltip-disabled'); + $taskTooltip.attr('data-original-title', taskText); }; $('#mode').change(toggleWebMaxSeconds); toggleWebMaxSeconds(); @@ -107,6 +139,7 @@ $('#jobs_settings_form').validate($.extend(form_support.handler, { submitHandler: function(form) { $('#web_max_seconds').prop('disabled', false); + $('#task_max_seconds').prop('disabled', false); $(form).ajaxSubmit({ success: function(response) { $.notify({ diff --git a/tests/Controllers/JobsControllerTest.php b/tests/Controllers/JobsControllerTest.php index c49ce2eda..ceff25058 100644 --- a/tests/Controllers/JobsControllerTest.php +++ b/tests/Controllers/JobsControllerTest.php @@ -61,8 +61,9 @@ class JobsControllerTest extends CIUnitTestCase $this->loginAsAdmin(); $response = $this->post('/jobs/saveSettings', [ - 'mode' => 'bogus', - 'web_max_seconds' => 5, + 'mode' => 'bogus', + 'web_max_seconds' => 5, + 'task_max_seconds' => 30, ]); $response->assertStatus(200); @@ -75,8 +76,9 @@ class JobsControllerTest extends CIUnitTestCase $this->loginAsAdmin(); $response = $this->post('/jobs/saveSettings', [ - 'mode' => 'web', - 'web_max_seconds' => -5, + 'mode' => 'web', + 'web_max_seconds' => -5, + 'task_max_seconds' => 30, ]); $response->assertStatus(200); @@ -89,8 +91,9 @@ class JobsControllerTest extends CIUnitTestCase $this->loginAsAdmin(); $response = $this->post('/jobs/saveSettings', [ - 'mode' => 'manual', - 'web_max_seconds' => 10, + 'mode' => 'manual', + 'web_max_seconds' => 10, + 'task_max_seconds' => 20, ]); $response->assertStatus(200); @@ -99,6 +102,7 @@ class JobsControllerTest extends CIUnitTestCase $this->seeInDatabase('app_config', ['key' => 'jobs_mode', 'value' => 'manual']); $this->seeInDatabase('app_config', ['key' => 'jobs_web_max_seconds', 'value' => '10']); + $this->seeInDatabase('app_config', ['key' => 'jobs_task_max_seconds', 'value' => '20']); } public function testPostSaveThrottlesSavesAndDeletesMissingThrottles(): void