feat(jobs): add task time warning threshold setting

Introduce a soft, log-only per-task time budget (jobs_task_max_seconds)
separate from the existing web request budget (jobs_web_max_seconds),
since PHP cannot preempt a running task mid-execution.

- Config: add taskMaxSeconds default (30s) to app/Config/Jobs.php
- Controller: validate and persist task_max_seconds in
  Jobs::postSaveSettings
- BoundedTaskRunner: accept taskMaxSeconds, measure task runtime via
  microtime, log a warning when a task exceeds the threshold
- JobRunner: read jobs_task_max_seconds from config and pass it to
  BoundedTaskRunner
- Migration: add AddJobsTaskMaxSecondsConfigKey to seed default
  app_config value
- Language: add en strings for label, validation message, and
  enabled/disabled tooltips
- View: add task_max_seconds form field to settings_config.php,
  wired to same web-mode enable/disable toggle as web_max_seconds
- Tests: update JobsControllerTest to cover new field in save/
  validation scenarios

Signed-off-by: objecttothis <17935339+objecttothis@users.noreply.github.com>
This commit is contained in:
objecttothis committed 2026-09-23 13:22:16 +04:00
1 parent 1e42c3b7ef
commit 14b06eea30
8 files changed
+97 -15

No files matched your search

+1
View File
@@ -8,4 +8,5 @@ class Jobs extends BaseConfig
{
public string $mode = 'web'; // auto | web | manual
public int $webMaxSeconds = 5;
public int $taskMaxSeconds = 30;
}
+9 -6
View File
@@ -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);
@@ -0,0 +1,18 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddJobsTaskMaxSecondsConfigKey extends Migration
{
public function up(): void
{
$this->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();
}
}
+19 -1
View File
@@ -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');
+3 -2
View File
@@ -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) {
+4
View File
@@ -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',
+33
View File
@@ -55,6 +55,33 @@
</div>
</div>
<div class="form-group form-group-sm">
<?= form_label(lang('Jobs.task_max_seconds'), 'task_max_seconds', ['class' => 'required control-label col-xs-4 col-sm-3 col-md-2']) ?>
<div class="col-xs-4 col-sm-3 col-md-2">
<div class="input-group">
<?= form_input(array_merge([
'type' => '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] : [])) ?>
<span class="input-group-addon input-sm">
<span
id="task_max_seconds_tooltip"
class="glyphicon glyphicon-info-sign"
data-toggle="tooltip"
data-placement="right"
data-tooltip-enabled="<?= esc(lang('Jobs.task_max_seconds_tooltip')) ?>"
data-tooltip-disabled="<?= esc(lang('Jobs.task_max_seconds_tooltip_disabled')) ?>"
title="<?= lang('Jobs.task_max_seconds_tooltip') ?>"
></span>
</span>
</div>
</div>
</div>
<?= form_submit([
'name' => '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({
+10 -6
View File
@@ -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