From 9405b5aa6d1d7fdf7f366a0fcc538aa4e0c4292b Mon Sep 17 00:00:00 2001 From: Srdjan Date: Mon, 21 Mar 2022 16:27:46 +1000 Subject: [PATCH 1/5] MDL-70975 task: Support for running adhoc tasks by id * CLI adhoc_task.php: new option --id * cron::run_adhoc_task($taskid) for running tasks by id * core\task\manager::get_adhoc_task($taskid) for retreival/locking --- admin/cli/adhoc_task.php | 34 ++++++++----- lang/en/moodle.php | 3 ++ lib/classes/cron.php | 15 ++++++ lib/classes/task/manager.php | 80 +++++++++++++++++++++++------- lib/tests/task/adhoc_task_test.php | 11 +++- 5 files changed, 112 insertions(+), 31 deletions(-) diff --git a/admin/cli/adhoc_task.php b/admin/cli/adhoc_task.php index e6ccff48bca..2b96501dbac 100644 --- a/admin/cli/adhoc_task.php +++ b/admin/cli/adhoc_task.php @@ -30,13 +30,14 @@ require_once("{$CFG->libdir}/clilib.php"); list($options, $unrecognized) = cli_get_params( [ - 'execute' => false, 'help' => false, - 'keep-alive' => 0, 'showsql' => false, 'showdebugging' => false, + 'execute' => false, + 'keep-alive' => 0, 'ignorelimits' => false, 'force' => false, + 'id' => null, ], [ 'h' => 'help', 'e' => 'execute', @@ -51,6 +52,9 @@ if ($unrecognized) { cli_error(get_string('cliunknowoption', 'admin', $unrecognized)); } +if ($options['id']) { + $options['execute'] = true; +} if ($options['help'] or empty($options['execute'])) { $help = <<showcrondebugging)) { set_debugging(DEBUG_DEVELOPER, true); } -$checklimits = empty($options['ignorelimits']); - core_php_time_limit::raise(); // Increase memory limit. @@ -121,10 +121,18 @@ raise_memory_limit(MEMORY_EXTRA); // Emulate normal session - we use admin account by default. \core\cron::setup_user(); -$humantimenow = date('r', time()); -$keepalive = (int)$options['keep-alive']; - \core\local\cli\shutdown::script_supports_graceful_exit(); - +$humantimenow = date('r', time()); mtrace("Server Time: {$humantimenow}\n"); -\core\cron::run_adhoc_tasks(time(), $keepalive, $checklimits); + +if (!empty($options['id'])) { + $taskid = (int) $options['id']; + \core\cron::run_adhoc_task($taskid); +} elseif (!empty($options['execute'])) { + + $checklimits = empty($options['ignorelimits']); + + $keepalive = (int)$options['keep-alive']; + + \core\cron::run_adhoc_tasks(time(), $keepalive, $checklimits); +} diff --git a/lang/en/moodle.php b/lang/en/moodle.php index 23f99b9dfaf..18449e927d7 100644 --- a/lang/en/moodle.php +++ b/lang/en/moodle.php @@ -1163,6 +1163,8 @@ $string['interestslist'] = 'List of interests'; $string['interestslist_help'] = 'Enter your interests, one by one, which will then be displayed on your profile page as tags.'; $string['invalidemail'] = 'Invalid email address'; $string['invalidlogin'] = 'Invalid login, please try again'; +$string['invalidtaskid'] = 'Invalid task ID'; +$string['invalidtaskclassname'] = 'Invalid task class {$a}'; $string['invalidusername'] = 'The username can only contain alphanumeric lowercase characters (letters and numbers), underscore (_), hyphen (-), period (.) or at symbol (@).'; $string['invalidusernameupload'] = 'Invalid username'; $string['ip_address'] = 'IP address'; @@ -2354,6 +2356,7 @@ $string['withdisablednote'] = '{$a} (disabled)'; $string['withoutuserdata'] = 'without user data'; $string['withselectedusers'] = 'With selected users...'; $string['withuserdata'] = 'with user data'; +$string['wontrunfuturescheduledtask'] = "Won't run task that hasn't failed and is scheduled to run in the future"; $string['wordforstudent'] = 'Your word for Student'; $string['wordforstudenteg'] = 'eg Student, Participant etc'; $string['wordforstudents'] = 'Your word for Students'; diff --git a/lib/classes/cron.php b/lib/classes/cron.php index 10f62c18a50..334b29cbacb 100644 --- a/lib/classes/cron.php +++ b/lib/classes/cron.php @@ -327,6 +327,21 @@ class cron { } } + /** + * Execute a (failed) adhoc task. + * + * @param int $taskid + */ + public static function run_adhoc_task(int $taskid): void { + $task = \core\task\manager::get_adhoc_task($taskid); + if (!$task->get_fail_delay() && $task->get_next_run_time() > time()) { + throw new \moodle_exception('wontrunfuturescheduledtask'); + } + + self::run_inner_adhoc_task($task); + self::set_process_title("Running adhoc task $taskid"); + } + /** * Shared code that handles running of a single scheduled task within the cron. * diff --git a/lib/classes/task/manager.php b/lib/classes/task/manager.php index 35c8751f809..0f851edec1c 100644 --- a/lib/classes/task/manager.php +++ b/lib/classes/task/manager.php @@ -474,7 +474,7 @@ class manager { * This function load the adhoc tasks for a given classname. * * @param string $classname - * @return \core\task\adhoc_task[] + * @return array */ public static function get_adhoc_tasks($classname) { global $DB; @@ -645,10 +645,10 @@ class manager { * * @param int $timestart * @param bool $checklimits Should we check limits? - * @return \core\task\adhoc_task or null if not found + * @return \core\task\adhoc_task|null * @throws \moodle_exception */ - public static function get_next_adhoc_task($timestart, $checklimits = true) { + public static function get_next_adhoc_task(int $timestart, bool $checklimits = true): ?adhoc_task { global $DB; $concurrencylimit = get_config('core', 'task_adhoc_concurrency_limit'); @@ -797,21 +797,9 @@ class manager { } } - // The global cron lock is under the most contention so request it - // as late as possible and release it as soon as possible. - if (!$cronlock = $cronlockfactory->get_lock('core_cron', 10)) { - $lock->release(); - throw new \moodle_exception('locktimeout'); - } - - $task->set_lock($lock); - if (!$task->is_blocking()) { - $cronlock->release(); - } else { - $task->set_cron_lock($cronlock); - } - + self::set_locks($task, $lock, $cronlockfactory); unset(self::$miniqueue[$taskid]); + return $task; } else { unset(self::$miniqueue[$taskid]); @@ -880,6 +868,64 @@ class manager { ); } + /** + * This function will get a (failed) adhoc task by id. The task will be handed out + * with an open lock - possibly on the entire cron process. Make sure you call either + * {@see ::adhoc_task_failed} or {@see ::adhoc_task_complete} to release the lock and reschedule the task. + * + * @param int $taskid + * @return \core\task\adhoc_task|null + * @throws \moodle_exception + */ + public static function get_adhoc_task(int $taskid): ?adhoc_task { + global $DB; + + $record = $DB->get_record('task_adhoc', array('id' => $taskid)); + if (!$record) { + throw new \moodle_exception('invalidtaskid'); + } + + $cronlockfactory = \core\lock\lock_config::get_lock_factory('cron'); + + if ($lock = $cronlockfactory->get_lock('adhoc_' . $record->id, 0)) { + $task = self::adhoc_task_from_record($record); + // Safety check in case the task in the DB does not match a real class (maybe something was uninstalled). + if (!$task) { + $lock->release(); + throw new \moodle_exception('invalidtaskclassname'); + } + + self::set_locks($task, $lock, $cronlockfactory); + return $task; + } + + return null; + } + + /** + * This function will set locks on the task. + * + * @param \core\task\adhoc_task $task + * @param \core\lock\lock $lock task lock + * @param \core\lock\lock_factory $cronlockfactory + * @throws \moodle_exception + */ + private static function set_locks($task, $lock, $cronlockfactory): void { + // The global cron lock is under the most contention so request it + // as late as possible and release it as soon as possible. + if (!$cronlock = $cronlockfactory->get_lock('core_cron', 10)) { + $lock->release(); + throw new \moodle_exception('locktimeout'); + } + + $task->set_lock($lock); + if (!$task->is_blocking()) { + $cronlock->release(); + } else { + $task->set_cron_lock($cronlock); + } + } + /** * This function will dispatch the next scheduled task in the queue. The task will be handed out * with an open lock - possibly on the entire cron process. Make sure you call either diff --git a/lib/tests/task/adhoc_task_test.php b/lib/tests/task/adhoc_task_test.php index 197ef611443..7b646d946ae 100644 --- a/lib/tests/task/adhoc_task_test.php +++ b/lib/tests/task/adhoc_task_test.php @@ -75,6 +75,7 @@ class adhoc_task_test extends \advanced_testcase { * Test adhoc task failure retry backoff. * * @covers ::get_next_adhoc_task + * @covers ::get_adhoc_task */ public function test_get_next_adhoc_task_fail_retry() { $this->resetAfterTest(true); @@ -87,17 +88,25 @@ class adhoc_task_test extends \advanced_testcase { // Get it from the scheduler, execute it, and mark it as failed. $task = manager::get_next_adhoc_task($now); + $taskid = $task->get_id(); $task->execute(); manager::adhoc_task_failed($task); // The task will not be returned immediately. $this->assertNull(manager::get_next_adhoc_task($now)); - // Should get the adhoc task (retry after delay). + // Should get the adhoc task (retry after delay). Fail it again. $task = manager::get_next_adhoc_task($now + 120); $this->assertInstanceOf('\\core\\task\\adhoc_test_task', $task); + $this->assertEquals($taskid, $task->get_id()); $task->execute(); + manager::adhoc_task_failed($task); + // Should get the adhoc task immediately. + $task = manager::get_adhoc_task($taskid); + $this->assertInstanceOf('\\core\\task\\adhoc_test_task', $task); + $this->assertEquals($taskid, $task->get_id()); + $task->execute(); manager::adhoc_task_complete($task); // Should not get any task. From 3491ea16505e473ab0dec2147400f3599d291628 Mon Sep 17 00:00:00 2001 From: Srdjan Date: Tue, 22 Mar 2022 12:39:10 +1000 Subject: [PATCH 2/5] MDL-70975 task: Support for running adhoc tasks filtered by class * CLI adhoc_task.php: new option --classname * core\task\manager::get_next_adhoc_task(): new param $classname for filtering tasks --- admin/cli/adhoc_task.php | 10 ++++++++-- lib/classes/cron.php | 4 +++- lib/classes/task/manager.php | 8 +++++++- lib/tests/task/adhoc_task_test.php | 29 +++++++++++++++++++++++++++++ lib/upgrade.txt | 1 + 5 files changed, 48 insertions(+), 4 deletions(-) diff --git a/admin/cli/adhoc_task.php b/admin/cli/adhoc_task.php index 2b96501dbac..4c6fa9f2274 100644 --- a/admin/cli/adhoc_task.php +++ b/admin/cli/adhoc_task.php @@ -38,12 +38,14 @@ list($options, $unrecognized) = cli_get_params( 'ignorelimits' => false, 'force' => false, 'id' => null, + 'classname' => null, ], [ 'h' => 'help', 'e' => 'execute', 'k' => 'keep-alive', 'i' => 'ignorelimits', 'f' => 'force', + 'c' => 'classname', ] ); @@ -52,7 +54,7 @@ if ($unrecognized) { cli_error(get_string('cliunknowoption', 'admin', $unrecognized)); } -if ($options['id']) { +if ($options['id'] || $options['classname']) { $options['execute'] = true; } if ($options['help'] or empty($options['execute'])) { @@ -68,10 +70,14 @@ Options: -i --ignorelimits Ignore task_adhoc_concurrency_limit and task_adhoc_max_runtime limits -f, --force Run even if cron is disabled --id Run (failed) task with id + -c, --classname Run tasks with a certain classname (FQN) Example: \$sudo -u www-data /usr/bin/php admin/cli/adhoc_task.php --execute \$sudo -u www-data /usr/bin/php admin/cli/adhoc_task.php --id=123456 +\$sudo -u www-data /usr/bin/php admin/cli/adhoc_task.php --classname=\\\\core_course\\\\task\\\\course_delete_modules + +Double backslash for the shell escape reasons. EOT; @@ -134,5 +140,5 @@ if (!empty($options['id'])) { $keepalive = (int)$options['keep-alive']; - \core\cron::run_adhoc_tasks(time(), $keepalive, $checklimits); + \core\cron::run_adhoc_tasks(time(), $keepalive, $checklimits, null, $options['classname']); } diff --git a/lib/classes/cron.php b/lib/classes/cron.php index 334b29cbacb..3455b28dbc2 100644 --- a/lib/classes/cron.php +++ b/lib/classes/cron.php @@ -226,6 +226,7 @@ class cron { * @param int $keepalive Keep this public static function alive for N seconds and poll for new adhoc tasks. * @param bool $checklimits Should we check limits? * @param null|int $startprocesstime The time this process started. + * @param null|string $classname Run only tasks of this class * @throws \moodle_exception */ public static function run_adhoc_tasks( @@ -233,6 +234,7 @@ class cron { $keepalive = 0, $checklimits = true, ?int $startprocesstime = null, + ?string $classname = null, ): void { // Allow a restriction on the number of adhoc task runners at once. $cronlockfactory = \core\lock\lock_config::get_lock_factory('cron'); @@ -281,7 +283,7 @@ class cron { } try { - $task = \core\task\manager::get_next_adhoc_task(time(), $checklimits); + $task = \core\task\manager::get_next_adhoc_task(time(), $checklimits, $classname); } catch (\Throwable $e) { if ($adhoclock) { // Release the adhoc task runner lock. diff --git a/lib/classes/task/manager.php b/lib/classes/task/manager.php index 0f851edec1c..c3741092781 100644 --- a/lib/classes/task/manager.php +++ b/lib/classes/task/manager.php @@ -645,10 +645,11 @@ class manager { * * @param int $timestart * @param bool $checklimits Should we check limits? + * @param string|null $classname Return only task of this class * @return \core\task\adhoc_task|null * @throws \moodle_exception */ - public static function get_next_adhoc_task(int $timestart, bool $checklimits = true): ?adhoc_task { + public static function get_next_adhoc_task(int $timestart, ?bool $checklimits = true, ?string $classname = null): ?adhoc_task { global $DB; $concurrencylimit = get_config('core', 'task_adhoc_concurrency_limit'); @@ -760,6 +761,11 @@ class manager { foreach (self::$miniqueue as $taskid => $record) { + if (!empty($classname) && $record->classname != self::get_canonical_class_name($classname)) { + // Skip the task if The class is specified, and doesn't match. + continue; + } + if (in_array($record->classname, $skipclasses)) { // Skip the task if it can't be started due to per-task concurrency limit. continue; diff --git a/lib/tests/task/adhoc_task_test.php b/lib/tests/task/adhoc_task_test.php index 7b646d946ae..106d918091c 100644 --- a/lib/tests/task/adhoc_task_test.php +++ b/lib/tests/task/adhoc_task_test.php @@ -53,6 +53,8 @@ class adhoc_task_test extends \advanced_testcase { /** * Test basic adhoc task execution. + * + * @covers ::get_next_adhoc_task */ public function test_get_next_adhoc_task_now() { $this->resetAfterTest(true); @@ -71,6 +73,33 @@ class adhoc_task_test extends \advanced_testcase { manager::adhoc_task_complete($task); } + /** + * Test basic adhoc task execution. + * + * @covers ::get_next_adhoc_task + */ + public function test_get_next_adhoc_task_class() { + $this->resetAfterTest(true); + + // Create an adhoc task. + $task = new \core\task\adhoc_test_task(); + + // Queue it. + manager::queue_adhoc_task($task); + + $now = time(); + $classname = get_class($task); + + // The task will not be returned. + $this->assertNull(manager::get_next_adhoc_task($now, true, "${classname}x")); + + // Get it from the scheduler. + $task = manager::get_next_adhoc_task($now, true, $classname); + $this->assertInstanceOf('\\core\\task\\adhoc_test_task', $task); + $task->execute(); + manager::adhoc_task_complete($task); + } + /** * Test adhoc task failure retry backoff. * diff --git a/lib/upgrade.txt b/lib/upgrade.txt index 0bf366ab15f..67eb38a8a4a 100644 --- a/lib/upgrade.txt +++ b/lib/upgrade.txt @@ -115,6 +115,7 @@ information provided here is intended especially for developers. parameter. This information can be used by the plugins when enclosing the icons in `.activityiconcontainer .icon` or `.activityiconcontainer .activityicon` containers to determine whether CSS filtering should be applied to the icon. If the icon needs to be rendered as is and not whitened out, the `.nofilter` CSS class needs to be applied to the icon. +* Functions get_next_adhoc_task() and cron::run_adhoc_tasks() have additional parameter $classname to filter by the specified class. === 4.1 === From fa61fce08db9bdda7420bbbfae97c6bb534b7ea6 Mon Sep 17 00:00:00 2001 From: Srdjan Date: Tue, 22 Mar 2022 14:28:44 +1000 Subject: [PATCH 3/5] MDL-70975 task: Support for limiting the number of adhoc tasks to run * CLI adhoc_task.php: new option --taskslimit * CLI adhoc_task.php: --execute option is implied when other options are given * core\task\manager::get_next_adhoc_task(): new param $number for limiting number of tasks to run --- admin/cli/adhoc_task.php | 71 ++++++++++++++++++++++++++-------------- lib/classes/cron.php | 5 +++ lib/upgrade.txt | 1 + 3 files changed, 52 insertions(+), 25 deletions(-) diff --git a/admin/cli/adhoc_task.php b/admin/cli/adhoc_task.php index 4c6fa9f2274..e0a211c1866 100644 --- a/admin/cli/adhoc_task.php +++ b/admin/cli/adhoc_task.php @@ -39,6 +39,7 @@ list($options, $unrecognized) = cli_get_params( 'force' => false, 'id' => null, 'classname' => null, + 'taskslimit' => null, ], [ 'h' => 'help', 'e' => 'execute', @@ -46,6 +47,7 @@ list($options, $unrecognized) = cli_get_params( 'i' => 'ignorelimits', 'f' => 'force', 'c' => 'classname', + 'l' => 'taskslimit', ] ); @@ -54,11 +56,7 @@ if ($unrecognized) { cli_error(get_string('cliunknowoption', 'admin', $unrecognized)); } -if ($options['id'] || $options['classname']) { - $options['execute'] = true; -} -if ($options['help'] or empty($options['execute'])) { - $help = <<set_debug(true); + exit(0); } if (CLI_MAINTENANCE) { @@ -108,8 +109,13 @@ if (!get_config('core', 'cron_enabled') && !$options['force']) { exit(1); } -if (empty($options['keep-alive'])) { - $options['keep-alive'] = 0; +// Common debugging options. +if ($options['showdebugging']) { + set_debugging(DEBUG_DEVELOPER, true); +} + +if ($options['showsql']) { + $DB->set_debug(true); } if (!empty($CFG->showcronsql)) { @@ -119,6 +125,7 @@ if (!empty($CFG->showcrondebugging)) { set_debugging(DEBUG_DEVELOPER, true); } +// Process params. core_php_time_limit::raise(); // Increase memory limit. @@ -131,14 +138,28 @@ raise_memory_limit(MEMORY_EXTRA); $humantimenow = date('r', time()); mtrace("Server Time: {$humantimenow}\n"); +// Run a single adhoc task only, if requested. if (!empty($options['id'])) { $taskid = (int) $options['id']; \core\cron::run_adhoc_task($taskid); -} elseif (!empty($options['execute'])) { - - $checklimits = empty($options['ignorelimits']); - - $keepalive = (int)$options['keep-alive']; - - \core\cron::run_adhoc_tasks(time(), $keepalive, $checklimits, null, $options['classname']); + exit(0); } + +// Examine params and determine if we should run. +$execute = (bool) $options['execute']; +$classname = $options['classname']; +$keepalive = empty($options['keep-alive']) ? 0 : (int) $options['keep-alive']; +$taskslimit = empty($options['taskslimit']) ? null : (int) $options['taskslimit']; +$checklimits = empty($options['ignorelimits']); + +if ($classname || $keepalive || $taskslimit) { + $execute = true; +} + +// Output the help text if no criteria for running the adhoc tasks are given. +if (!$execute) { + echo $help; + exit(0); +} + +\core\cron::run_adhoc_tasks(time(), $keepalive, $checklimits, null, $taskslimit, $classname); diff --git a/lib/classes/cron.php b/lib/classes/cron.php index 3455b28dbc2..a4de32b3719 100644 --- a/lib/classes/cron.php +++ b/lib/classes/cron.php @@ -226,6 +226,7 @@ class cron { * @param int $keepalive Keep this public static function alive for N seconds and poll for new adhoc tasks. * @param bool $checklimits Should we check limits? * @param null|int $startprocesstime The time this process started. + * @param int|null $maxtasks Limit number of tasks to run` * @param null|string $classname Run only tasks of this class * @throws \moodle_exception */ @@ -234,6 +235,7 @@ class cron { $keepalive = 0, $checklimits = true, ?int $startprocesstime = null, + ?int $maxtasks = null, ?string $classname = null, ): void { // Allow a restriction on the number of adhoc task runners at once. @@ -300,6 +302,9 @@ class cron { self::run_inner_adhoc_task($task); self::set_process_title("Waiting for next adhoc task"); $taskcount++; + if ($maxtasks && $taskcount >= $maxtasks) { + break; + } unset($task); } else { $timeleft = $finishtime - time(); diff --git a/lib/upgrade.txt b/lib/upgrade.txt index 67eb38a8a4a..7dc728faf7d 100644 --- a/lib/upgrade.txt +++ b/lib/upgrade.txt @@ -116,6 +116,7 @@ information provided here is intended especially for developers. `.activityiconcontainer .activityicon` containers to determine whether CSS filtering should be applied to the icon. If the icon needs to be rendered as is and not whitened out, the `.nofilter` CSS class needs to be applied to the icon. * Functions get_next_adhoc_task() and cron::run_adhoc_tasks() have additional parameter $classname to filter by the specified class. +* Function cron::run_adhoc_tasks() has additional parameter $number to limit the number of the tasks to run. === 4.1 === From 6d9aaa841235c23f1ec6af8d410666401bf33450 Mon Sep 17 00:00:00 2001 From: Srdjan Date: Thu, 24 Mar 2022 15:25:55 +1000 Subject: [PATCH 4/5] MDL-70975 task: Support for running only failed ad hoc tasks * CLI adhoc_task.php: new option --failed --- admin/cli/adhoc_task.php | 11 ++++++++++- lib/classes/cron.php | 22 +++++++++++++++++++++- lib/classes/task/manager.php | 2 +- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/admin/cli/adhoc_task.php b/admin/cli/adhoc_task.php index e0a211c1866..7f8bd4bde17 100644 --- a/admin/cli/adhoc_task.php +++ b/admin/cli/adhoc_task.php @@ -40,6 +40,7 @@ list($options, $unrecognized) = cli_get_params( 'id' => null, 'classname' => null, 'taskslimit' => null, + 'failed' => false, ], [ 'h' => 'help', 'e' => 'execute', @@ -70,6 +71,7 @@ Options: --id Run (failed) task with id -c, --classname Run tasks with a certain classname (FQN) -l, --taskslimit=N Run at most N tasks + --failed Run only tasks that failed, ie those with a fail delay Run all queued tasks: \$sudo -u www-data /usr/bin/php admin/cli/adhoc_task.php --execute @@ -138,6 +140,8 @@ raise_memory_limit(MEMORY_EXTRA); $humantimenow = date('r', time()); mtrace("Server Time: {$humantimenow}\n"); +$classname = $options['classname']; + // Run a single adhoc task only, if requested. if (!empty($options['id'])) { $taskid = (int) $options['id']; @@ -145,9 +149,14 @@ if (!empty($options['id'])) { exit(0); } +// Run all failed tasks. +if (!empty($options['failed'])) { + \core\cron::run_failed_adhoc_tasks($classname); + exit(0); +} + // Examine params and determine if we should run. $execute = (bool) $options['execute']; -$classname = $options['classname']; $keepalive = empty($options['keep-alive']) ? 0 : (int) $options['keep-alive']; $taskslimit = empty($options['taskslimit']) ? null : (int) $options['taskslimit']; $checklimits = empty($options['ignorelimits']); diff --git a/lib/classes/cron.php b/lib/classes/cron.php index a4de32b3719..74470025752 100644 --- a/lib/classes/cron.php +++ b/lib/classes/cron.php @@ -335,7 +335,7 @@ class cron { } /** - * Execute a (failed) adhoc task. + * Execute an adhoc task. * * @param int $taskid */ @@ -349,6 +349,26 @@ class cron { self::set_process_title("Running adhoc task $taskid"); } + /** + * Execute all failed adhoc tasks. + * + * @param string|null $classname Run only tasks of this class + */ + public static function run_failed_adhoc_tasks(?string $classname = null): void { + global $DB; + + $where = 'faildelay > 0'; + $params = []; + if ($classname) { + $where .= ' AND classname = :classname'; + $params['classname'] = \core\task\manager::get_canonical_class_name($classname); + } + $tasks = $DB->get_records_sql("SELECT * from {task_adhoc} WHERE $where", $params); + foreach ($tasks as $t) { + self::run_adhoc_task($t->id); + } + } + /** * Shared code that handles running of a single scheduled task within the cron. * diff --git a/lib/classes/task/manager.php b/lib/classes/task/manager.php index c3741092781..4437ab7ba68 100644 --- a/lib/classes/task/manager.php +++ b/lib/classes/task/manager.php @@ -1359,7 +1359,7 @@ class manager { * * @param string|task_base $taskorstring Task object or a string */ - protected static function get_canonical_class_name($taskorstring) { + public static function get_canonical_class_name($taskorstring) { if (is_string($taskorstring)) { $classname = $taskorstring; } else { From 85323070e7f741ea8d7e2e9e488d9201424033a0 Mon Sep 17 00:00:00 2001 From: Srdjan Date: Wed, 23 Mar 2022 16:27:34 +1000 Subject: [PATCH 5/5] MDL-70975 task: adhoctasks.php - queued ad hoc tasks report Similar to scheduled tasks report core\task\manager::adhoc_task_from_record() now raises moodle_exception --- admin/tasklogs.php | 2 +- admin/tool/task/adhoctasks.php | 60 ++++++ admin/tool/task/lang/en/tool_task.php | 21 ++ admin/tool/task/lib.php | 9 + admin/tool/task/renderer.php | 283 +++++++++++++++++++++++++- admin/tool/task/run_adhoctasks.php | 157 ++++++++++++++ admin/tool/task/schedule_task.php | 10 - admin/tool/task/settings.php | 9 + lib/classes/task/manager.php | 185 ++++++++++++++--- lib/tests/task/adhoc_task_test.php | 50 ++++- lib/upgrade.txt | 7 + 11 files changed, 755 insertions(+), 38 deletions(-) create mode 100644 admin/tool/task/adhoctasks.php create mode 100644 admin/tool/task/run_adhoctasks.php diff --git a/admin/tasklogs.php b/admin/tasklogs.php index a2b94d4ae0e..57788d1c17b 100644 --- a/admin/tasklogs.php +++ b/admin/tasklogs.php @@ -60,7 +60,7 @@ $report = system_report_factory::create(task_logs::class, context_system::instan if (!empty($filter)) { $report->set_filter_values([ - 'task_log:name_values' => $filter, + 'task_log:name_values' => trim($filter, '\\'), ]); } diff --git a/admin/tool/task/adhoctasks.php b/admin/tool/task/adhoctasks.php new file mode 100644 index 00000000000..aa66fec9e17 --- /dev/null +++ b/admin/tool/task/adhoctasks.php @@ -0,0 +1,60 @@ +. + +/** + * Ad hoc task list. + * + * @package tool_task + * @copyright Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +require_once(__DIR__ . '/../../../config.php'); +require_once($CFG->libdir.'/adminlib.php'); + +admin_externalpage_setup('adhoctasks'); + +$failedonly = optional_param('failedonly', false, PARAM_BOOL); +$classname = optional_param('classname', null, PARAM_RAW); + +$renderer = $PAGE->get_renderer('tool_task'); + +if ($classname) { + $pageurl = new moodle_url('/admin/tool/task/adhoctasks.php'); + $PAGE->navbar->add(get_string('adhoctasks', 'tool_task'), $pageurl); + $PAGE->navbar->add($classname, $PAGE->url); + + $tasks = core\task\manager::get_adhoc_tasks($classname, $failedonly); + + echo $OUTPUT->header(); + + if (!get_config('core', 'cron_enabled')) { + echo $renderer->cron_disabled(); + } + + echo $renderer->adhoc_tasks_class_table($classname, $tasks, ['failedonly' => $failedonly]); +} else { + $summary = core\task\manager::get_adhoc_tasks_summary(); + + echo $OUTPUT->header(); + + if (!get_config('core', 'cron_enabled')) { + echo $renderer->cron_disabled(); + } + + echo $renderer->adhoc_tasks_summary_table($summary); +} +echo $OUTPUT->footer(); diff --git a/admin/tool/task/lang/en/tool_task.php b/admin/tool/task/lang/en/tool_task.php index c7f6c04a653..68ecbfba4fd 100644 --- a/admin/tool/task/lang/en/tool_task.php +++ b/admin/tool/task/lang/en/tool_task.php @@ -24,11 +24,17 @@ $string['adhoc'] = 'Ad hoc'; $string['adhoctaskid'] = 'Ad hoc task ID: {$a}'; +$string['adhoctaskrun'] = 'Ad hoc task run initiated'; $string['adhoctasks'] = 'Ad hoc tasks'; +$string['adhoctasksdue'] = 'Ad hoc tasks due'; +$string['adhoctasksfailed'] = 'Ad hoc tasks failed'; +$string['adhoctasksfuture'] = 'Future ad hoc tasks'; +$string['adhoctasksrunning'] = 'Ad hoc tasks running'; $string['asap'] = 'ASAP'; $string['adhocempty'] = 'Ad hoc task queue is empty'; $string['adhocqueuesize'] = 'Ad hoc task queue has {$a} tasks'; $string['adhocqueueold'] = 'Oldest unprocessed task is {$a->age}, which is more than {$a->max}'; +$string['backtoadhoctasks'] = 'Back to ad hoc tasks'; $string['backtoscheduledtasks'] = 'Back to scheduled tasks'; $string['blocking'] = 'Blocking'; $string['cannotfindthepathtothecli'] = 'Cannot find the path to the PHP CLI executable so task execution aborted. Set the \'Path to PHP CLI\' setting in Site administration / Server / System paths.'; @@ -51,31 +57,46 @@ $string['edittaskschedule'] = 'Edit task schedule: {$a}'; $string['enablerunnow'] = 'Allow \'Run now\' for scheduled tasks'; $string['enablerunnow_desc'] = 'Allows administrators to run a single scheduled task immediately, rather than waiting for it to run as scheduled. The feature requires \'Path to PHP CLI\' (pathtophp) to be set in System paths. The task runs on the web server, so you may wish to disable this feature to avoid potential performance issues.'; $string['faildelay'] = 'Fail delay'; +$string['failed'] = 'Failed'; $string['fromcomponent'] = 'From component: {$a}'; $string['hostname'] = 'Host name'; $string['lastcronstart'] = 'Time since last cron run: {$a}'; $string['lastruntime'] = 'Last run'; $string['lastupdated'] = 'Last updated {$a}.'; $string['nextruntime'] = 'Next run'; +$string['noclassname'] = 'Class name not specified'; +$string['notasks'] = 'No tasks to run'; +$string['payload'] = 'Payload'; $string['pid'] = 'PID'; $string['plugindisabled'] = 'Plugin disabled'; $string['pluginname'] = 'Scheduled task configuration'; $string['resettasktodefaults'] = 'Reset task schedule to defaults'; $string['resettasktodefaults_help'] = 'This will discard any local changes and revert the schedule for this task back to its original settings.'; +$string['run_adhoctasks'] = 'Run ad hoc tasks'; +$string['runningalltasks'] = 'Running all tasks'; +$string['runningfailedtasks'] = 'Running failed tasks'; $string['runningtasks'] = 'Tasks running now'; $string['runnow'] = 'Run now'; $string['runagain'] = 'Run again'; +$string['runadhoc_confirm'] = 'Tasks will run on the web server and may take some time to complete.'; +$string['runadhoc'] = 'Run ad hoc tasks now?'; $string['runnow_confirm'] = 'Are you sure you want to run this task \'{$a}\' now? The task will run on the web server and may take some time to complete.'; +$string['runclassname'] = 'Run all'; +$string['runclassnamefailedonly'] = 'Run all failed'; $string['runpattern'] = 'Run pattern'; $string['scheduled'] = 'Scheduled'; $string['scheduledtasks'] = 'Scheduled tasks'; $string['scheduledtaskchangesdisabled'] = 'Modifications to the list of scheduled tasks have been prevented in Moodle configuration'; $string['slowtask'] = 'Task has run for longer than {$a}'; +$string['showall'] = 'Show all'; +$string['showfailedonly'] = 'Show failed only'; +$string['showsummary'] = 'Show ad hoc tasks summary'; $string['started'] = 'Started'; $string['taskage'] = 'Run time'; $string['taskdetails'] = 'Tasks running for more than {$a->time} (max {$a->maxtime}): {$a->count}'; $string['taskdisabled'] = 'Task disabled'; $string['taskfailures'] = '{$a} task(s) failing'; +$string['taskid'] = 'Task ID'; $string['tasklogs'] = 'Task logs'; $string['tasknofailures'] = 'There are no tasks failing'; $string['taskrunningtime'] = 'Task has run for {$a}'; diff --git a/admin/tool/task/lib.php b/admin/tool/task/lib.php index 8a418934820..56cd18310dd 100644 --- a/admin/tool/task/lib.php +++ b/admin/tool/task/lib.php @@ -38,3 +38,12 @@ function tool_task_status_checks() : array { ]; } +/** + * Function used to handle mtrace by outputting the text to normal browser window. + * + * @param string $message Message to output + * @param string $eol End of line character + */ +function tool_task_mtrace_wrapper(string $message, string $eol): void { + echo s($message . $eol); +} diff --git a/admin/tool/task/renderer.php b/admin/tool/task/renderer.php index 691da67fadf..c30a430ad84 100644 --- a/admin/tool/task/renderer.php +++ b/admin/tool/task/renderer.php @@ -35,6 +35,279 @@ use core\task\scheduled_task; * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class tool_task_renderer extends plugin_renderer_base { + + /** + * This function will render a table with the summary of all adhoc tasks. + * + * @param array $summary + * @return string HTML to output. + */ + public function adhoc_tasks_summary_table(array $summary): string { + $adhocurl = '/admin/tool/task/adhoctasks.php'; + $adhocrunurl = '/admin/tool/task/run_adhoctasks.php'; + + // Main tasks table. + $table = new html_table(); + $table->caption = get_string('adhoctasks', 'tool_task'); + $table->head = [ + get_string('component', 'tool_task') . ' / ' . get_string('classname', 'tool_task'), + get_string('adhoctasksrunning', 'tool_task'), + get_string('adhoctasksdue', 'tool_task'), + get_string('adhoctasksfuture', 'tool_task'), + get_string('adhoctasksfailed', 'tool_task'), + get_string('nextruntime', 'tool_task'), + ]; + + $table->attributes['class'] = 'admintable generaltable'; + $table->colclasses = []; + + // For each task entry (row) show action buttons/logs link depending on the user permissions. + $data = []; + $canruntasks = \core\task\manager::is_runnable() && get_config('tool_task', 'enablerunnow'); + foreach ($summary as $component => $classes) { + // Component cell. + $componentcell = new html_table_cell($component); + $componentcell->header = true; + $componentcell->id = "tasks-$component"; + $componentcell->colspan = 6; + + $data[] = new html_table_row([$componentcell]); + + foreach ($classes as $classname => $stats) { + // Task class cell. + $classbits = explode('\\', $classname); + $classcontent = html_writer::link( + new moodle_url($adhocurl, ['classname' => $classname]), + end($classbits) + ); + $classcell = new html_table_cell($classcontent); + $classcell->header = true; + $classcell->class = "task-class-summary text-ltr"; + + $duecontent = $stats['due']; + if ($canruntasks && ($stats['due'] > 0 || $stats['failed'] > 0)) { + $duecontent .= html_writer::div( + html_writer::link( + new moodle_url( + $adhocrunurl, + ['classname' => $classname] + ), + get_string('runclassname', 'tool_task') + ), + 'task-runnow' + ); + } + + // Mark cell if has failed tasks. + $failed = $stats['failed']; + if ($canruntasks && $failed > 0) { + $failed .= html_writer::div( + html_writer::link( + new moodle_url( + $adhocrunurl, + ['classname' => $classname, 'failedonly' => 1] + ), + get_string('runclassnamefailedonly', 'tool_task') + ), + 'task-runnow' + ); + } + $failedcell = new html_table_cell($failed); + if ($failed > 0) { + $failedcell->attributes['class'] = 'table-danger'; + } + + // Prepares the next run time cell contents. + $nextrun = ''; + if ($stats['due'] > 0) { + $nextrun = get_string('asap', 'tool_task'); + } else if ($stats['nextruntime']) { + $nextrun = userdate($stats['nextruntime']); + } + + $data[] = new html_table_row([ + $classcell, + new html_table_cell($stats['running']), + new html_table_cell($duecontent), + new html_table_cell($stats['count'] - $stats['running'] - $stats['due']), + $failedcell, + new html_table_cell($nextrun), + ]); + } + } + $table->data = $data; + return html_writer::table($table); + } + + /** + * This function will render a table with all the adhoc tasks for the class. + * + * @param string $classname + * @param array $tasks - list of all adhoc tasks. + * @param array|null $params + * @return string HTML to output. + */ + public function adhoc_tasks_class_table(string $classname, array $tasks, ?array $params = []): string { + $adhocurl = '/admin/tool/task/adhoctasks.php'; + $adhocrunurl = '/admin/tool/task/run_adhoctasks.php'; + $showloglink = \core\task\logmanager::has_log_report(); + $failedonly = !empty($params['failedonly']); + $canruntasks = \core\task\manager::is_runnable() && get_config('tool_task', 'enablerunnow'); + + // Depending on the currently set parameters, set up toggle buttons. + $failedorall = html_writer::link( + new moodle_url( + $adhocurl, + array_merge($params, ['classname' => $classname, 'failedonly' => !$failedonly]) + ), + get_string($failedonly ? 'showall' : 'showfailedonly', 'tool_task') + ); + + // Main tasks table. + $table = $this->generate_adhoc_tasks_simple_table($tasks, $canruntasks); + + $table->caption = "$classname " + . get_string($failedonly ? 'adhoctasksfailed' : 'adhoctasks', 'tool_task'); + $table->head[3] .= " $failedorall"; // Spice up faildelay heading. + + if ($showloglink) { + // Insert logs as the second col. + array_splice($table->head, 1, 0, [get_string('logs')]); + array_walk($table->data, function ($row, $idx) use ($classname) { + $loglink = ''; + $faildelaycell = $row->cells[3]; + if ($faildelaycell->attributes['class'] == 'table-danger') { + // Failed task. + $loglink = $this->output->action_icon( + \core\task\logmanager::get_url_for_task_class($classname), + new pix_icon('e/file-text', get_string('viewlogs', 'tool_task', $classname) + )); + } + + array_splice($row->cells, 1, 0, [new html_table_cell($loglink)]); + }); + } + + return html_writer::table($table) + . html_writer::div( + html_writer::link( + new moodle_url( + $adhocrunurl, + array_merge($params, ['classname' => $classname]) + ), + get_string('runclassname', 'tool_task') + ), + 'task-runnow' + ) + . html_writer::div( + html_writer::link( + new moodle_url( + $adhocurl + ), + get_string('showsummary', 'tool_task') + ), + 'task-show-summary' + ); + } + + /** + * This function will render a plain adhoc tasks table. + * + * @param array $tasks - list of adhoc tasks. + * @return string HTML to output. + */ + public function adhoc_tasks_simple_table(array $tasks): string { + $table = $this->generate_adhoc_tasks_simple_table($tasks); + + return html_writer::table($table); + } + + /** + * This function will render a plain adhoc tasks table. + * + * @param array $tasks - list of adhoc tasks. + * @param bool $wantruntasks add 'Run now' link + * @return html_table + */ + private function generate_adhoc_tasks_simple_table(array $tasks, bool $wantruntasks = false): html_table { + $adhocrunurl = '/admin/tool/task/run_adhoctasks.php'; + $now = time(); + $failedstr = get_string('failed', 'tool_task'); + + // Main tasks table. + $table = new html_table(); + $table->caption = get_string('adhoctasks', 'tool_task'); + $table->head = [ + get_string('taskid', 'tool_task'), + get_string('nextruntime', 'tool_task'), + get_string('payload', 'tool_task'), + $failedstr + ]; + + $table->attributes['class'] = 'generaltable'; + $table->colclasses = []; + + // For each task entry (row) show action buttons/logs link depending on the user permissions. + $data = []; + foreach ($tasks as $task) { + $taskid = $task->get_id(); + $started = $task->get_timestarted(); + + // Task id cell. + $taskidcellcontent = html_writer::span($taskid, 'task-id'); + $taskidcell = new html_table_cell($taskidcellcontent); + $taskidcell->header = true; + $taskidcell->id = "task-$taskid"; + + // Mark cell if task has failed. + $faildelay = $task->get_fail_delay(); + $faildelaycell = new html_table_cell($faildelay ? $failedstr : ''); + if ($faildelay) { + $faildelaycell->attributes['class'] = 'table-danger'; + } + + // Prepares the next run time cell contents. + $nextrun = get_string('started', 'tool_task'); + if (!$started) { + $nextruntime = $task->get_next_run_time(); + $due = $nextruntime < $now; + $nextrun = $due ? userdate($nextruntime) : get_string('asap', 'tool_task'); + + if ($wantruntasks && ($faildelay || $due)) { + $nextrun .= ' '.html_writer::div( + html_writer::link( + new moodle_url( + $adhocrunurl, + ['id' => $taskid] + ), + get_string('runnow', 'tool_task') + ), + 'task-runnow' + ); + } + } + + $data[] = new html_table_row([ + $taskidcell, + new html_table_cell($nextrun), + new html_table_cell($task->get_custom_data_as_string()), + $faildelaycell, + ]); + } + $table->data = $data; + + return $table; + } + + /** + * Displays a notification on ad hoc task run request. + * + * @return string HTML notification block for task initiated message + */ + public function adhoc_task_run(): string { + return $this->output->notification(get_string('adhoctaskrun', 'tool_task'), 'info'); + } + /** * This function will render one beautiful table with all the scheduled tasks. * @@ -121,10 +394,14 @@ class tool_task_renderer extends plugin_renderer_base { $faildelaycell = new html_table_cell($task->get_fail_delay()); if ($task->get_fail_delay()) { - $faildelaycell->text .= html_writer::div(html_writer::link( + $faildelaycell->text .= html_writer::div( + $this->output->single_button( new moodle_url('/admin/tool/task/clear_fail_delay.php', - ['task' => $classname, 'sesskey' => sesskey()]), - get_string('clear')), 'task-clearfaildelay'); + ['task' => $classname]), + get_string('clear') + ), + 'task-runnow' + ); $faildelaycell->attributes['class'] = 'table-danger'; } diff --git a/admin/tool/task/run_adhoctasks.php b/admin/tool/task/run_adhoctasks.php new file mode 100644 index 00000000000..c8a292ec32f --- /dev/null +++ b/admin/tool/task/run_adhoctasks.php @@ -0,0 +1,157 @@ +. + +/** + * Web run ad hoc task(s) + * + * This script runs a group or a single ad hoc task from the web UI. + * + * @package tool_task + * @copyright Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +define('NO_OUTPUT_BUFFERING', true); + +require_once(__DIR__ . '/../../../config.php'); +require_once($CFG->libdir.'/adminlib.php'); + +admin_externalpage_setup('adhoctasks'); + +$runurl = '/admin/tool/task/run_adhoctasks.php'; +$tasksurl = '/admin/tool/task/adhoctasks.php'; + +// Allow execution of single task. This requires login and has different rules. +$classname = optional_param('classname', null, PARAM_RAW); +$failedonly = optional_param('failedonly', false, PARAM_BOOL); +$taskid = optional_param('id', null, PARAM_INT); +$confirmed = optional_param('confirm', 0, PARAM_INT); + +if (!\core\task\manager::is_runnable()) { + $redirecturl = new \moodle_url('/admin/settings.php', ['section' => 'systempaths']); + throw new moodle_exception('cannotfindthepathtothecli', 'tool_task', $redirecturl->out()); +} + +$params = ['classname' => $classname, 'failedonly' => $failedonly, 'id' => $taskid]; + +// Check input parameter id against all existing tasks. +if ($taskid) { + $record = $DB->get_record('task_adhoc', ['id' => $taskid]); + if (!$record) { + throw new \moodle_exception('invalidtaskid'); + } + $classname = $record->classname; + $heading = "Run $classname task Id $taskid"; + $tasks = [core\task\manager::adhoc_task_from_record($record)]; +} else { + if (!$classname) { + throw new \moodle_exception('noclassname', 'tool_task'); + } + $heading = "Run $classname " . ($failedonly ? "failed" : "all")." tasks"; + $now = time(); + $tasks = array_filter( + core\task\manager::get_adhoc_tasks($classname, $failedonly, true), + function ($t) use ($now) { + return $t->get_fail_delay() || $t->get_next_run_time() <= $now; + } + ); +} + +// Start output. +$context = context_system::instance(); +$PAGE->set_context($context); +$PAGE->set_heading($SITE->fullname); +$PAGE->set_title($classname); + +echo $OUTPUT->header(); +echo $OUTPUT->heading($heading); + +if (!$tasks) { + echo $OUTPUT->single_button($tasksurl, + get_string('notasks', 'tool_task'), + 'get'); + echo $OUTPUT->footer(); + exit; +} + +$renderer = $PAGE->get_renderer('tool_task'); +if (!get_config('core', 'cron_enabled')) { + echo $renderer->cron_disabled(); +} +echo $renderer->adhoc_tasks_simple_table($tasks); + +// The initial request just shows the confirmation page; we don't do anything further unless +// they confirm. +if (!$confirmed) { + echo $OUTPUT->confirm(get_string('runadhoc_confirm', 'tool_task'), + new single_button(new moodle_url($runurl, array_merge($params, ['confirm' => 1])), + get_string('runadhoc', 'tool_task')), + new single_button(new moodle_url($tasksurl, $params), + get_string('cancel'), false)); + echo $OUTPUT->footer(); + exit; +} + +// Action requires session key. +require_sesskey(); + +\core\session\manager::write_close(); + +// Prepare to handle output via mtrace. +$CFG->mtrace_wrapper = 'tool_task_mtrace_wrapper'; + +// Run the specified tasks. +if ($taskid) { + $repeat = $DB->get_record('task_adhoc', ['id' => $taskid]); + + echo html_writer::start_tag('pre'); + \core\task\manager::run_adhoc_from_cli($taskid); + echo html_writer::end_tag('pre'); +} else { + $repeat = core\task\manager::get_adhoc_tasks($classname, $failedonly, true); + + // Run failed first (if any). We have to run them separately anyway, + // because faildelay is observed if failed flag is not true. + echo html_writer::tag('p', get_string('runningfailedtasks', 'tool_task'), ['class' => 'lead']); + echo html_writer::start_tag('pre'); + \core\task\manager::run_all_adhoc_from_cli(true, $classname); + echo html_writer::end_tag('pre'); + + if (!$failedonly) { + echo html_writer::tag('p', get_string('runningalltasks', 'tool_task'), ['class' => 'lead']); + echo html_writer::start_tag('pre'); + \core\task\manager::run_all_adhoc_from_cli(false, $classname); + echo html_writer::end_tag('pre'); + } +} + +if ($repeat) { + echo html_writer::div( + $OUTPUT->single_button( + new moodle_url($runurl, array_merge($params, ['confirm' => 1])), + get_string('runagain', 'tool_task') + ) + ); +} + +echo html_writer::div( + html_writer::link( + new moodle_url($tasksurl, $taskid ? ['classname' => $classname] : []), + get_string('backtoadhoctasks', 'tool_task') + ) +); + +echo $OUTPUT->footer(); diff --git a/admin/tool/task/schedule_task.php b/admin/tool/task/schedule_task.php index d2cd31d93e1..538e6b5fe8a 100644 --- a/admin/tool/task/schedule_task.php +++ b/admin/tool/task/schedule_task.php @@ -28,16 +28,6 @@ define('NO_OUTPUT_BUFFERING', true); require('../../../config.php'); -/** - * Function used to handle mtrace by outputting the text to normal browser window. - * - * @param string $message Message to output - * @param string $eol End of line character - */ -function tool_task_mtrace_wrapper($message, $eol) { - echo s($message . $eol); -} - // Allow execution of single task. This requires login and has different rules. $taskname = required_param('task', PARAM_RAW_TRIMMED); diff --git a/admin/tool/task/settings.php b/admin/tool/task/settings.php index 3fd9669b81f..21a3b4ba3f2 100644 --- a/admin/tool/task/settings.php +++ b/admin/tool/task/settings.php @@ -34,6 +34,15 @@ if ($hassiteconfig) { ) ); + $ADMIN->add( + 'taskconfig', + new admin_externalpage( + 'adhoctasks', + new lang_string('adhoctasks', 'tool_task'), + "$CFG->wwwroot/$CFG->admin/tool/task/adhoctasks.php" + ) + ); + $ADMIN->add( 'taskconfig', new admin_externalpage( diff --git a/lib/classes/task/manager.php b/lib/classes/task/manager.php index 4437ab7ba68..3abaecff591 100644 --- a/lib/classes/task/manager.php +++ b/lib/classes/task/manager.php @@ -24,6 +24,9 @@ */ namespace core\task; +use core\lock\lock; +use core\lock\lock_factory; + define('CORE_TASK_TASKS_FILENAME', 'db/tasks.php'); /** * Collection of task related methods. @@ -317,12 +320,12 @@ class manager { * * @param \stdClass $record * @return \core\task\adhoc_task + * @throws \moodle_exception */ public static function adhoc_task_from_record($record) { $classname = self::get_canonical_class_name($record->classname); if (!class_exists($classname)) { - debugging("Failed to load task: " . $classname, DEBUG_DEVELOPER); - return false; + throw new \moodle_exception('invalidtaskclassname', '', '', $record->classname); } $task = new $classname; if (isset($record->nextruntime)) { @@ -474,18 +477,83 @@ class manager { * This function load the adhoc tasks for a given classname. * * @param string $classname + * @param bool $failedonly + * @param bool $skiprunning do not return tasks that are in the running state * @return array */ - public static function get_adhoc_tasks($classname) { + public static function get_adhoc_tasks(string $classname, bool $failedonly = false, bool $skiprunning = false): array { global $DB; - $classname = self::get_canonical_class_name($classname); - // We are just reading - so no locks required. - $records = $DB->get_records('task_adhoc', array('classname' => $classname)); + $conds[] = 'classname = ?'; + $params[] = self::get_canonical_class_name($classname); + if ($failedonly) { + $conds[] = 'faildelay > 0'; + } + if ($skiprunning) { + $conds[] = 'timestarted IS NULL'; + } + + // We are just reading - so no locks required. + $sql = 'SELECT * FROM {task_adhoc}'; + if ($conds) { + $sql .= ' WHERE '.implode(' AND ', $conds); + } + $rs = $DB->get_records_sql($sql, $params); return array_map(function($record) { return self::adhoc_task_from_record($record); - }, $records); + }, $rs); + } + + /** + * This function returns adhoc tasks summary per component classname + * + * @return array + */ + public static function get_adhoc_tasks_summary(): array { + global $DB; + + $now = time(); + $records = $DB->get_records('task_adhoc'); + $summary = []; + foreach ($records as $r) { + if (!isset($summary[$r->component])) { + $summary[$r->component] = []; + } + + if (isset($summary[$r->component][$r->classname])) { + $classsummary = $summary[$r->component][$r->classname]; + } else { + $classsummary = [ + 'nextruntime' => null, + 'count' => 0, + 'failed' => 0, + 'running' => 0, + 'due' => 0, + ]; + } + + $classsummary['count']++; + $nextruntime = (int)$r->nextruntime; + if (!$classsummary['nextruntime'] || $nextruntime < $classsummary['nextruntime']) { + $classsummary['nextruntime'] = $nextruntime; + } + + if ((int)$r->timestarted > 0) { + $classsummary['running']++; + } else { + if ((int)$r->faildelay > 0) { + $classsummary['failed']++; + } + + if ($nextruntime <= $now) { + $classsummary['due']++; + } + } + + $summary[$r->component][$r->classname] = $classsummary; + } + return $summary; } /** @@ -550,9 +618,10 @@ class manager { $records = $DB->get_records_sql('SELECT * from {task_adhoc} WHERE faildelay > ?', [$delay]); foreach ($records as $record) { - $task = self::adhoc_task_from_record($record); - if ($task) { - $tasks[] = $task; + try { + $tasks[] = self::adhoc_task_from_record($record); + } catch (\moodle_exception $e) { + debugging("Failed to load task: $record->classname", DEBUG_DEVELOPER, $e->getTrace()); } } return $tasks; @@ -781,9 +850,11 @@ class manager { continue; } - $task = self::adhoc_task_from_record($record); // Safety check in case the task in the DB does not match a real class (maybe something was uninstalled). - if (!$task) { + try { + $task = self::adhoc_task_from_record($record); + } catch (\moodle_exception $e) { + debugging("Failed to load task: $record->classname", DEBUG_DEVELOPER); $lock->release(); unset(self::$miniqueue[$taskid]); continue; @@ -875,7 +946,7 @@ class manager { } /** - * This function will get a (failed) adhoc task by id. The task will be handed out + * This function will get an adhoc task by id. The task will be handed out * with an open lock - possibly on the entire cron process. Make sure you call either * {@see ::adhoc_task_failed} or {@see ::adhoc_task_complete} to release the lock and reschedule the task. * @@ -886,7 +957,7 @@ class manager { public static function get_adhoc_task(int $taskid): ?adhoc_task { global $DB; - $record = $DB->get_record('task_adhoc', array('id' => $taskid)); + $record = $DB->get_record('task_adhoc', ['id' => $taskid]); if (!$record) { throw new \moodle_exception('invalidtaskid'); } @@ -894,11 +965,12 @@ class manager { $cronlockfactory = \core\lock\lock_config::get_lock_factory('cron'); if ($lock = $cronlockfactory->get_lock('adhoc_' . $record->id, 0)) { - $task = self::adhoc_task_from_record($record); // Safety check in case the task in the DB does not match a real class (maybe something was uninstalled). - if (!$task) { + try { + $task = self::adhoc_task_from_record($record); + } catch (\moodle_exception $e) { $lock->release(); - throw new \moodle_exception('invalidtaskclassname'); + throw $e; } self::set_locks($task, $lock, $cronlockfactory); @@ -911,12 +983,12 @@ class manager { /** * This function will set locks on the task. * - * @param \core\task\adhoc_task $task - * @param \core\lock\lock $lock task lock - * @param \core\lock\lock_factory $cronlockfactory + * @param adhoc_task $task + * @param lock $lock task lock + * @param lock_factory $cronlockfactory * @throws \moodle_exception */ - private static function set_locks($task, $lock, $cronlockfactory): void { + private static function set_locks(adhoc_task $task, lock $lock, lock_factory $cronlockfactory): void { // The global cron lock is under the most contention so request it // as late as possible and release it as soon as possible. if (!$cronlock = $cronlockfactory->get_lock('core_cron', 10)) { @@ -1418,11 +1490,11 @@ class manager { /** * Executes a cron from web invocation using PHP CLI. * - * @param \core\task\task_base $task Task that be executed via CLI. + * @param scheduled_task $task Task that be executed via CLI. * @return bool * @throws \moodle_exception */ - public static function run_from_cli(\core\task\task_base $task):bool { + public static function run_from_cli(scheduled_task $task): bool { global $CFG; if (!self::is_runnable()) { @@ -1450,6 +1522,73 @@ class manager { return true; } + /** + * Executes an ad hoc task from web invocation using PHP CLI. + * + * @param int $taskid Task to execute via CLI. + * @throws \moodle_exception + */ + public static function run_adhoc_from_cli(int $taskid) { + // Shell-escaped task name. + $taskarg = escapeshellarg("--id={$taskid}"); + + self::run_adhoc_from_cli_base($taskarg); + } + + /** + * Executes ad hoc tasks from web invocation using PHP CLI. + * + * @param bool|null $failedonly + * @param string|null $classname Task class to execute via CLI. + * @throws \moodle_exception + */ + public static function run_all_adhoc_from_cli(?bool $failedonly = false, ?string $classname = null) { + $taskargs = []; + if ($failedonly) { + $taskargs[] = '--failed'; + } + if ($classname) { + // Shell-escaped task select. + $taskargs[] = escapeshellarg("--classname={$classname}"); + } + + self::run_adhoc_from_cli_base($taskargs ? implode(' ', $taskargs) : '--execute'); + } + + /** + * Executes an ad hoc task from web invocation using PHP CLI. + * + * @param string $taskarg Task to execute via CLI. + * @throws \moodle_exception + */ + private static function run_adhoc_from_cli_base(string $taskarg): void { + global $CFG; + + if (!self::is_runnable()) { + $redirecturl = new \moodle_url('/admin/settings.php', ['section' => 'systempaths']); + throw new \moodle_exception('cannotfindthepathtothecli', 'tool_task', $redirecturl->out()); + } + + // Shell-escaped path to the PHP binary. + $phpbinary = escapeshellarg(self::find_php_cli_path()); + + // Shell-escaped path CLI script. + $pathcomponents = [$CFG->dirroot, $CFG->admin, 'cli', 'adhoc_task.php']; + $scriptpath = escapeshellarg(implode(DIRECTORY_SEPARATOR, $pathcomponents)); + + // Build the CLI command. + $command = "{$phpbinary} {$scriptpath} {$taskarg} --force"; + + // We cannot run it in phpunit. + if (PHPUNIT_TEST) { + echo $command; + return; + } + + // Execute it. + passthru($command); + } + /** * For a given scheduled task record, this method will check to see if any overrides have * been applied in config and return a copy of the record with any overridden values. diff --git a/lib/tests/task/adhoc_task_test.php b/lib/tests/task/adhoc_task_test.php index 106d918091c..dbd7033deb8 100644 --- a/lib/tests/task/adhoc_task_test.php +++ b/lib/tests/task/adhoc_task_test.php @@ -91,7 +91,7 @@ class adhoc_task_test extends \advanced_testcase { $classname = get_class($task); // The task will not be returned. - $this->assertNull(manager::get_next_adhoc_task($now, true, "${classname}x")); + $this->assertNull(manager::get_next_adhoc_task($now, true, "{$classname}notexists")); // Get it from the scheduler. $task = manager::get_next_adhoc_task($now, true, $classname); @@ -557,4 +557,52 @@ class adhoc_task_test extends \advanced_testcase { $this->assertEquals('Task 1', $task->get_custom_data_as_string()); manager::adhoc_task_complete($task); } + + /** + * Test adhoc task run from CLI. + * @covers ::run_adhoc_from_cli + */ + public function test_run_adhoc_from_cli() { + $this->resetAfterTest(true); + + $taskid = 1; + + if (!manager::is_runnable()) { + $this->markTestSkipped("Cannot run tasks"); + } + + ob_start(); + manager::run_adhoc_from_cli($taskid); + $output = ob_get_contents(); + ob_end_clean(); + + $this->assertMatchesRegularExpression( + sprintf('!admin/cli/adhoc_task.php\W+--id=%d\W+--force!', $taskid), + $output + ); + } + + /** + * Test adhoc class run from CLI. + * @covers ::run_all_adhoc_from_cli + */ + public function test_run_all_adhoc_from_cli() { + $this->resetAfterTest(true); + + $classname = 'fake'; + + if (!manager::is_runnable()) { + $this->markTestSkipped("Cannot run tasks"); + } + + ob_start(); + manager::run_all_adhoc_from_cli(false, $classname); + $output = ob_get_contents(); + ob_end_clean(); + + $this->assertMatchesRegularExpression( + sprintf('!admin/cli/adhoc_task.php\W+--classname=%s\W+--force!', $classname), + $output + ); + } } diff --git a/lib/upgrade.txt b/lib/upgrade.txt index 7dc728faf7d..66c4ddfba13 100644 --- a/lib/upgrade.txt +++ b/lib/upgrade.txt @@ -117,6 +117,13 @@ information provided here is intended especially for developers. needs to be rendered as is and not whitened out, the `.nofilter` CSS class needs to be applied to the icon. * Functions get_next_adhoc_task() and cron::run_adhoc_tasks() have additional parameter $classname to filter by the specified class. * Function cron::run_adhoc_tasks() has additional parameter $number to limit the number of the tasks to run. +* Function get_adhoc_tasks() has additional parameter $failedonly to return only failed tasks. +* admin/cli/adhoc_task.php has additional parameters: + - id - to run individual tasks by id + - classname - to run tasks by classname + - taskslimit - to limit the number of tasks in one run + - failed - to limit the run to only the tasks that failed in their previous run + Can be mixed, apart from 'id' of course. === 4.1 ===