MDL-70520 tasks: Keep lastruntime when a scheduled task is reset
[moodle.git] / lib / classes / task / manager.php
blob0c28dd617262c4b1f569df19aef2e06424b2ad87
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 // GNU General Public License for more details.
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle. If not, see <http://www.gnu.org/licenses/>.
17 /**
18 * Scheduled and adhoc task management.
20 * @package core
21 * @category task
22 * @copyright 2013 Damyon Wiese
23 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
25 namespace core\task;
27 define('CORE_TASK_TASKS_FILENAME', 'db/tasks.php');
28 /**
29 * Collection of task related methods.
31 * Some locking rules for this class:
32 * All changes to scheduled tasks must be protected with both - the global cron lock and the lock
33 * for the specific scheduled task (in that order). Locks must be released in the reverse order.
34 * @copyright 2013 Damyon Wiese
35 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
37 class manager {
39 /**
40 * Given a component name, will load the list of tasks in the db/tasks.php file for that component.
42 * @param string $componentname - The name of the component to fetch the tasks for.
43 * @param bool $expandr - if true (default) an 'R' value in a time is expanded to an appropriate int.
44 * If false, they are left as 'R'
45 * @return \core\task\scheduled_task[] - List of scheduled tasks for this component.
47 public static function load_default_scheduled_tasks_for_component($componentname, $expandr = true) {
48 $dir = \core_component::get_component_directory($componentname);
50 if (!$dir) {
51 return array();
54 $file = $dir . '/' . CORE_TASK_TASKS_FILENAME;
55 if (!file_exists($file)) {
56 return array();
59 $tasks = null;
60 include($file);
62 if (!isset($tasks)) {
63 return array();
66 $scheduledtasks = array();
68 foreach ($tasks as $task) {
69 $record = (object) $task;
70 $scheduledtask = self::scheduled_task_from_record($record, $expandr, false);
71 // Safety check in case the task in the DB does not match a real class (maybe something was uninstalled).
72 if ($scheduledtask) {
73 $scheduledtask->set_component($componentname);
74 $scheduledtasks[] = $scheduledtask;
78 return $scheduledtasks;
81 /**
82 * Update the database to contain a list of scheduled task for a component.
83 * The list of scheduled tasks is taken from @load_scheduled_tasks_for_component.
84 * Will throw exceptions for any errors.
86 * @param string $componentname - The frankenstyle component name.
88 public static function reset_scheduled_tasks_for_component($componentname) {
89 global $DB;
90 $tasks = self::load_default_scheduled_tasks_for_component($componentname);
91 $validtasks = array();
93 foreach ($tasks as $taskid => $task) {
94 $classname = self::get_canonical_class_name($task);
96 $validtasks[] = $classname;
98 if ($currenttask = self::get_scheduled_task($classname)) {
99 if ($currenttask->is_customised()) {
100 // If there is an existing task with a custom schedule, do not override it.
101 continue;
104 // Update the record from the default task data.
105 self::configure_scheduled_task($task);
106 } else {
107 // Ensure that the first run follows the schedule.
108 $task->set_next_run_time($task->get_next_scheduled_time());
110 // Insert the new task in the database.
111 $record = self::record_from_scheduled_task($task);
112 $DB->insert_record('task_scheduled', $record);
116 // Delete any task that is not defined in the component any more.
117 $sql = "component = :component";
118 $params = array('component' => $componentname);
119 if (!empty($validtasks)) {
120 list($insql, $inparams) = $DB->get_in_or_equal($validtasks, SQL_PARAMS_NAMED, 'param', false);
121 $sql .= ' AND classname ' . $insql;
122 $params = array_merge($params, $inparams);
124 $DB->delete_records_select('task_scheduled', $sql, $params);
128 * Checks if the task with the same classname, component and customdata is already scheduled
130 * @param adhoc_task $task
131 * @return bool
133 protected static function task_is_scheduled($task) {
134 return false !== self::get_queued_adhoc_task_record($task);
138 * Checks if the task with the same classname, component and customdata is already scheduled
140 * @param adhoc_task $task
141 * @return bool
143 protected static function get_queued_adhoc_task_record($task) {
144 global $DB;
146 $record = self::record_from_adhoc_task($task);
147 $params = [$record->classname, $record->component, $record->customdata];
148 $sql = 'classname = ? AND component = ? AND ' .
149 $DB->sql_compare_text('customdata', \core_text::strlen($record->customdata) + 1) . ' = ?';
151 if ($record->userid) {
152 $params[] = $record->userid;
153 $sql .= " AND userid = ? ";
155 return $DB->get_record_select('task_adhoc', $sql, $params);
159 * Schedule a new task, or reschedule an existing adhoc task which has matching data.
161 * Only a task matching the same user, classname, component, and customdata will be rescheduled.
162 * If these values do not match exactly then a new task is scheduled.
164 * @param \core\task\adhoc_task $task - The new adhoc task information to store.
165 * @since Moodle 3.7
167 public static function reschedule_or_queue_adhoc_task(adhoc_task $task) : void {
168 global $DB;
170 if ($existingrecord = self::get_queued_adhoc_task_record($task)) {
171 // Only update the next run time if it is explicitly set on the task.
172 $nextruntime = $task->get_next_run_time();
173 if ($nextruntime && ($existingrecord->nextruntime != $nextruntime)) {
174 $DB->set_field('task_adhoc', 'nextruntime', $nextruntime, ['id' => $existingrecord->id]);
176 } else {
177 // There is nothing queued yet. Just queue as normal.
178 self::queue_adhoc_task($task);
183 * Queue an adhoc task to run in the background.
185 * @param \core\task\adhoc_task $task - The new adhoc task information to store.
186 * @param bool $checkforexisting - If set to true and the task with the same user, classname, component and customdata
187 * is already scheduled then it will not schedule a new task. Can be used only for ASAP tasks.
188 * @return boolean - True if the config was saved.
190 public static function queue_adhoc_task(adhoc_task $task, $checkforexisting = false) {
191 global $DB;
193 if ($userid = $task->get_userid()) {
194 // User found. Check that they are suitable.
195 \core_user::require_active_user(\core_user::get_user($userid, '*', MUST_EXIST), true, true);
198 $record = self::record_from_adhoc_task($task);
199 // Schedule it immediately if nextruntime not explicitly set.
200 if (!$task->get_next_run_time()) {
201 $record->nextruntime = time() - 1;
204 // Check if the same task is already scheduled.
205 if ($checkforexisting && self::task_is_scheduled($task)) {
206 return false;
209 // Queue the task.
210 $result = $DB->insert_record('task_adhoc', $record);
212 return $result;
216 * Change the default configuration for a scheduled task.
217 * The list of scheduled tasks is taken from {@link load_scheduled_tasks_for_component}.
219 * @param \core\task\scheduled_task $task - The new scheduled task information to store.
220 * @return boolean - True if the config was saved.
222 public static function configure_scheduled_task(scheduled_task $task) {
223 global $DB;
225 $classname = self::get_canonical_class_name($task);
227 $original = $DB->get_record('task_scheduled', array('classname'=>$classname), 'id', MUST_EXIST);
229 $record = self::record_from_scheduled_task($task);
230 $record->id = $original->id;
231 $record->nextruntime = $task->get_next_scheduled_time();
232 unset($record->lastruntime);
233 $result = $DB->update_record('task_scheduled', $record);
235 return $result;
239 * Utility method to create a DB record from a scheduled task.
241 * @param \core\task\scheduled_task $task
242 * @return \stdClass
244 public static function record_from_scheduled_task($task) {
245 $record = new \stdClass();
246 $record->classname = self::get_canonical_class_name($task);
247 $record->component = $task->get_component();
248 $record->blocking = $task->is_blocking();
249 $record->customised = $task->is_customised();
250 $record->lastruntime = $task->get_last_run_time();
251 $record->nextruntime = $task->get_next_run_time();
252 $record->faildelay = $task->get_fail_delay();
253 $record->hour = $task->get_hour();
254 $record->minute = $task->get_minute();
255 $record->day = $task->get_day();
256 $record->dayofweek = $task->get_day_of_week();
257 $record->month = $task->get_month();
258 $record->disabled = $task->get_disabled();
259 $record->timestarted = $task->get_timestarted();
260 $record->hostname = $task->get_hostname();
261 $record->pid = $task->get_pid();
263 return $record;
267 * Utility method to create a DB record from an adhoc task.
269 * @param \core\task\adhoc_task $task
270 * @return \stdClass
272 public static function record_from_adhoc_task($task) {
273 $record = new \stdClass();
274 $record->classname = self::get_canonical_class_name($task);
275 $record->id = $task->get_id();
276 $record->component = $task->get_component();
277 $record->blocking = $task->is_blocking();
278 $record->nextruntime = $task->get_next_run_time();
279 $record->faildelay = $task->get_fail_delay();
280 $record->customdata = $task->get_custom_data_as_string();
281 $record->userid = $task->get_userid();
282 $record->timecreated = time();
283 $record->timestarted = $task->get_timestarted();
284 $record->hostname = $task->get_hostname();
285 $record->pid = $task->get_pid();
287 return $record;
291 * Utility method to create an adhoc task from a DB record.
293 * @param \stdClass $record
294 * @return \core\task\adhoc_task
296 public static function adhoc_task_from_record($record) {
297 $classname = self::get_canonical_class_name($record->classname);
298 if (!class_exists($classname)) {
299 debugging("Failed to load task: " . $classname, DEBUG_DEVELOPER);
300 return false;
302 $task = new $classname;
303 if (isset($record->nextruntime)) {
304 $task->set_next_run_time($record->nextruntime);
306 if (isset($record->id)) {
307 $task->set_id($record->id);
309 if (isset($record->component)) {
310 $task->set_component($record->component);
312 $task->set_blocking(!empty($record->blocking));
313 if (isset($record->faildelay)) {
314 $task->set_fail_delay($record->faildelay);
316 if (isset($record->customdata)) {
317 $task->set_custom_data_as_string($record->customdata);
320 if (isset($record->userid)) {
321 $task->set_userid($record->userid);
323 if (isset($record->timestarted)) {
324 $task->set_timestarted($record->timestarted);
326 if (isset($record->hostname)) {
327 $task->set_hostname($record->hostname);
329 if (isset($record->pid)) {
330 $task->set_pid($record->pid);
333 return $task;
337 * Utility method to create a task from a DB record.
339 * @param \stdClass $record
340 * @param bool $expandr - if true (default) an 'R' value in a time is expanded to an appropriate int.
341 * If false, they are left as 'R'
342 * @param bool $override - if true loads overridden settings from config.
343 * @return \core\task\scheduled_task|false
345 public static function scheduled_task_from_record($record, $expandr = true, $override = true) {
346 $classname = self::get_canonical_class_name($record->classname);
347 if (!class_exists($classname)) {
348 debugging("Failed to load task: " . $classname, DEBUG_DEVELOPER);
349 return false;
351 /** @var \core\task\scheduled_task $task */
352 $task = new $classname;
354 if ($override) {
355 // Update values with those defined in the config, if any are set.
356 $record = self::get_record_with_config_overrides($record);
359 if (isset($record->lastruntime)) {
360 $task->set_last_run_time($record->lastruntime);
362 if (isset($record->nextruntime)) {
363 $task->set_next_run_time($record->nextruntime);
365 if (isset($record->customised)) {
366 $task->set_customised($record->customised);
368 if (isset($record->component)) {
369 $task->set_component($record->component);
371 $task->set_blocking(!empty($record->blocking));
372 if (isset($record->minute)) {
373 $task->set_minute($record->minute, $expandr);
375 if (isset($record->hour)) {
376 $task->set_hour($record->hour, $expandr);
378 if (isset($record->day)) {
379 $task->set_day($record->day);
381 if (isset($record->month)) {
382 $task->set_month($record->month);
384 if (isset($record->dayofweek)) {
385 $task->set_day_of_week($record->dayofweek, $expandr);
387 if (isset($record->faildelay)) {
388 $task->set_fail_delay($record->faildelay);
390 if (isset($record->disabled)) {
391 $task->set_disabled($record->disabled);
393 if (isset($record->timestarted)) {
394 $task->set_timestarted($record->timestarted);
396 if (isset($record->hostname)) {
397 $task->set_hostname($record->hostname);
399 if (isset($record->pid)) {
400 $task->set_pid($record->pid);
402 $task->set_overridden(self::scheduled_task_has_override($classname));
404 return $task;
408 * Given a component name, will load the list of tasks from the scheduled_tasks table for that component.
409 * Do not execute tasks loaded from this function - they have not been locked.
410 * @param string $componentname - The name of the component to load the tasks for.
411 * @return \core\task\scheduled_task[]
413 public static function load_scheduled_tasks_for_component($componentname) {
414 global $DB;
416 $tasks = array();
417 // We are just reading - so no locks required.
418 $records = $DB->get_records('task_scheduled', array('component' => $componentname), 'classname', '*', IGNORE_MISSING);
419 foreach ($records as $record) {
420 $task = self::scheduled_task_from_record($record);
421 // Safety check in case the task in the DB does not match a real class (maybe something was uninstalled).
422 if ($task) {
423 $tasks[] = $task;
427 return $tasks;
431 * This function load the scheduled task details for a given classname.
433 * @param string $classname
434 * @return \core\task\scheduled_task or false
436 public static function get_scheduled_task($classname) {
437 global $DB;
439 $classname = self::get_canonical_class_name($classname);
440 // We are just reading - so no locks required.
441 $record = $DB->get_record('task_scheduled', array('classname'=>$classname), '*', IGNORE_MISSING);
442 if (!$record) {
443 return false;
445 return self::scheduled_task_from_record($record);
449 * This function load the adhoc tasks for a given classname.
451 * @param string $classname
452 * @return \core\task\adhoc_task[]
454 public static function get_adhoc_tasks($classname) {
455 global $DB;
457 $classname = self::get_canonical_class_name($classname);
458 // We are just reading - so no locks required.
459 $records = $DB->get_records('task_adhoc', array('classname' => $classname));
461 return array_map(function($record) {
462 return self::adhoc_task_from_record($record);
463 }, $records);
467 * This function load the default scheduled task details for a given classname.
469 * @param string $classname
470 * @param bool $expandr - if true (default) an 'R' value in a time is expanded to an appropriate int.
471 * If false, they are left as 'R'
472 * @return \core\task\scheduled_task|false
474 public static function get_default_scheduled_task($classname, $expandr = true) {
475 $task = self::get_scheduled_task($classname);
476 $componenttasks = array();
478 // Safety check in case no task was found for the given classname.
479 if ($task) {
480 $componenttasks = self::load_default_scheduled_tasks_for_component(
481 $task->get_component(), $expandr);
484 foreach ($componenttasks as $componenttask) {
485 if (get_class($componenttask) == get_class($task)) {
486 return $componenttask;
490 return false;
494 * This function will return a list of all the scheduled tasks that exist in the database.
496 * @return \core\task\scheduled_task[]
498 public static function get_all_scheduled_tasks() {
499 global $DB;
501 $records = $DB->get_records('task_scheduled', null, 'component, classname', '*', IGNORE_MISSING);
502 $tasks = array();
504 foreach ($records as $record) {
505 $task = self::scheduled_task_from_record($record);
506 // Safety check in case the task in the DB does not match a real class (maybe something was uninstalled).
507 if ($task) {
508 $tasks[] = $task;
512 return $tasks;
516 * Ensure quality of service for the ad hoc task queue.
518 * This reshuffles the adhoc tasks queue to balance by type to ensure a
519 * level of quality of service per type, while still maintaining the
520 * relative order of tasks queued by timestamp.
522 * @param array $records array of task records
523 * @param array $records array of same task records shuffled
525 public static function ensure_adhoc_task_qos(array $records): array {
527 $count = count($records);
528 if ($count == 0) {
529 return $records;
532 $queues = []; // This holds a queue for each type of adhoc task.
533 $limits = []; // The relative limits of each type of task.
534 $limittotal = 0;
536 // Split the single queue up into queues per type.
537 foreach ($records as $record) {
538 $type = $record->classname;
539 if (!array_key_exists($type, $queues)) {
540 $queues[$type] = [];
542 if (!array_key_exists($type, $limits)) {
543 $limits[$type] = 1;
544 $limittotal += 1;
546 $queues[$type][] = $record;
549 $qos = []; // Our new queue with ensured quality of service.
550 $seed = $count % $limittotal; // Which task queue to shuffle from first?
552 $move = 1; // How many tasks to shuffle at a time.
553 do {
554 $shuffled = 0;
556 // Now cycle through task type queues and interleaving the tasks
557 // back into a single queue.
558 foreach ($limits as $type => $limit) {
560 // Just interleaving the queue is not enough, because after
561 // any task is processed the whole queue is rebuilt again. So
562 // we need to deterministically start on different types of
563 // tasks so that *on average* we rotate through each type of task.
565 // We achieve this by using a $seed to start moving tasks off a
566 // different queue each time. The seed is based on the task count
567 // modulo the number of types of tasks on the queue. As we count
568 // down this naturally cycles through each type of record.
569 if ($seed < 1) {
570 $shuffled = 1;
571 $seed += 1;
572 continue;
574 $tasks = array_splice($queues[$type], 0, $move);
575 $qos = array_merge($qos, $tasks);
577 // Stop if we didn't move any tasks onto the main queue.
578 $shuffled += count($tasks);
580 // Generally the only tasks that matter are those that are near the start so
581 // after we have shuffled the first few 1 by 1, start shuffling larger groups.
582 if (count($qos) >= (4 * count($limits))) {
583 $move *= 2;
585 } while ($shuffled > 0);
587 return $qos;
591 * This function will dispatch the next adhoc task in the queue. The task will be handed out
592 * with an open lock - possibly on the entire cron process. Make sure you call either
593 * {@link adhoc_task_failed} or {@link adhoc_task_complete} to release the lock and reschedule the task.
595 * @param int $timestart
596 * @param bool $checklimits Should we check limits?
597 * @return \core\task\adhoc_task or null if not found
598 * @throws \moodle_exception
600 public static function get_next_adhoc_task($timestart, $checklimits = true) {
601 global $DB;
603 $where = '(nextruntime IS NULL OR nextruntime < :timestart1)';
604 $params = array('timestart1' => $timestart);
605 $records = $DB->get_records_select('task_adhoc', $where, $params, 'nextruntime ASC, id ASC', '*', 0, 2000);
606 $records = self::ensure_adhoc_task_qos($records);
608 $cronlockfactory = \core\lock\lock_config::get_lock_factory('cron');
610 $skipclasses = array();
612 foreach ($records as $record) {
614 if (in_array($record->classname, $skipclasses)) {
615 // Skip the task if it can't be started due to per-task concurrency limit.
616 continue;
619 if ($lock = $cronlockfactory->get_lock('adhoc_' . $record->id, 0)) {
621 // Safety check, see if the task has been already processed by another cron run.
622 $record = $DB->get_record('task_adhoc', array('id' => $record->id));
623 if (!$record) {
624 $lock->release();
625 continue;
628 $task = self::adhoc_task_from_record($record);
629 // Safety check in case the task in the DB does not match a real class (maybe something was uninstalled).
630 if (!$task) {
631 $lock->release();
632 continue;
635 $tasklimit = $task->get_concurrency_limit();
636 if ($checklimits && $tasklimit > 0) {
637 if ($concurrencylock = self::get_concurrent_task_lock($task)) {
638 $task->set_concurrency_lock($concurrencylock);
639 } else {
640 // Unable to obtain a concurrency lock.
641 mtrace("Skipping $record->classname adhoc task class as the per-task limit of $tasklimit is reached.");
642 $skipclasses[] = $record->classname;
643 $lock->release();
644 continue;
648 // The global cron lock is under the most contention so request it
649 // as late as possible and release it as soon as possible.
650 if (!$cronlock = $cronlockfactory->get_lock('core_cron', 10)) {
651 $lock->release();
652 throw new \moodle_exception('locktimeout');
655 $task->set_lock($lock);
656 if (!$task->is_blocking()) {
657 $cronlock->release();
658 } else {
659 $task->set_cron_lock($cronlock);
661 return $task;
665 return null;
669 * This function will dispatch the next scheduled task in the queue. The task will be handed out
670 * with an open lock - possibly on the entire cron process. Make sure you call either
671 * {@link scheduled_task_failed} or {@link scheduled_task_complete} to release the lock and reschedule the task.
673 * @param int $timestart - The start of the cron process - do not repeat any tasks that have been run more recently than this.
674 * @return \core\task\scheduled_task or null
675 * @throws \moodle_exception
677 public static function get_next_scheduled_task($timestart) {
678 global $DB;
679 $cronlockfactory = \core\lock\lock_config::get_lock_factory('cron');
681 $where = "(lastruntime IS NULL OR lastruntime < :timestart1)
682 AND (nextruntime IS NULL OR nextruntime < :timestart2)
683 AND disabled = 0
684 ORDER BY lastruntime, id ASC";
685 $params = array('timestart1' => $timestart, 'timestart2' => $timestart);
686 $records = $DB->get_records_select('task_scheduled', $where, $params);
688 $pluginmanager = \core_plugin_manager::instance();
690 foreach ($records as $record) {
692 if ($lock = $cronlockfactory->get_lock(($record->classname), 0)) {
693 $classname = '\\' . $record->classname;
694 $task = self::scheduled_task_from_record($record);
695 // Safety check in case the task in the DB does not match a real class (maybe something was uninstalled).
696 if (!$task) {
697 $lock->release();
698 continue;
701 $task->set_lock($lock);
703 // See if the component is disabled.
704 $plugininfo = $pluginmanager->get_plugin_info($task->get_component());
706 if ($plugininfo) {
707 if (($plugininfo->is_enabled() === false) && !$task->get_run_if_component_disabled()) {
708 $lock->release();
709 continue;
713 if (!self::scheduled_task_has_override($record->classname)) {
714 // Make sure the task data is unchanged unless an override is being used.
715 if (!$DB->record_exists('task_scheduled', (array)$record)) {
716 $lock->release();
717 continue;
721 // The global cron lock is under the most contention so request it
722 // as late as possible and release it as soon as possible.
723 if (!$cronlock = $cronlockfactory->get_lock('core_cron', 10)) {
724 $lock->release();
725 throw new \moodle_exception('locktimeout');
728 if (!$task->is_blocking()) {
729 $cronlock->release();
730 } else {
731 $task->set_cron_lock($cronlock);
733 return $task;
737 return null;
741 * This function indicates that an adhoc task was not completed successfully and should be retried.
743 * @param \core\task\adhoc_task $task
745 public static function adhoc_task_failed(adhoc_task $task) {
746 global $DB;
747 // Finalise the log output.
748 logmanager::finalise_log(true);
750 $delay = $task->get_fail_delay();
752 // Reschedule task with exponential fall off for failing tasks.
753 if (empty($delay)) {
754 $delay = 60;
755 } else {
756 $delay *= 2;
759 // Max of 24 hour delay.
760 if ($delay > 86400) {
761 $delay = 86400;
764 // Reschedule and then release the locks.
765 $task->set_timestarted();
766 $task->set_hostname();
767 $task->set_pid();
768 $task->set_next_run_time(time() + $delay);
769 $task->set_fail_delay($delay);
770 $record = self::record_from_adhoc_task($task);
771 $DB->update_record('task_adhoc', $record);
773 $task->release_concurrency_lock();
774 if ($task->is_blocking()) {
775 $task->get_cron_lock()->release();
777 $task->get_lock()->release();
781 * Records that a adhoc task is starting to run.
783 * @param adhoc_task $task Task that is starting
784 * @param int $time Start time (leave blank for now)
785 * @throws \dml_exception
786 * @throws \coding_exception
788 public static function adhoc_task_starting(adhoc_task $task, int $time = 0) {
789 global $DB;
790 $pid = (int)getmypid();
791 $hostname = (string)gethostname();
793 if (empty($time)) {
794 $time = time();
797 $task->set_timestarted($time);
798 $task->set_hostname($hostname);
799 $task->set_pid($pid);
801 $record = self::record_from_adhoc_task($task);
802 $DB->update_record('task_adhoc', $record);
806 * This function indicates that an adhoc task was completed successfully.
808 * @param \core\task\adhoc_task $task
810 public static function adhoc_task_complete(adhoc_task $task) {
811 global $DB;
813 // Finalise the log output.
814 logmanager::finalise_log();
815 $task->set_timestarted();
816 $task->set_hostname();
817 $task->set_pid();
819 // Delete the adhoc task record - it is finished.
820 $DB->delete_records('task_adhoc', array('id' => $task->get_id()));
822 // Release the locks.
823 $task->release_concurrency_lock();
824 if ($task->is_blocking()) {
825 $task->get_cron_lock()->release();
827 $task->get_lock()->release();
831 * This function indicates that a scheduled task was not completed successfully and should be retried.
833 * @param \core\task\scheduled_task $task
835 public static function scheduled_task_failed(scheduled_task $task) {
836 global $DB;
837 // Finalise the log output.
838 logmanager::finalise_log(true);
840 $delay = $task->get_fail_delay();
842 // Reschedule task with exponential fall off for failing tasks.
843 if (empty($delay)) {
844 $delay = 60;
845 } else {
846 $delay *= 2;
849 // Max of 24 hour delay.
850 if ($delay > 86400) {
851 $delay = 86400;
854 $task->set_timestarted();
855 $task->set_hostname();
856 $task->set_pid();
858 $classname = self::get_canonical_class_name($task);
860 $record = $DB->get_record('task_scheduled', array('classname' => $classname));
861 $record->nextruntime = time() + $delay;
862 $record->faildelay = $delay;
863 $record->timestarted = null;
864 $record->hostname = null;
865 $record->pid = null;
866 $DB->update_record('task_scheduled', $record);
868 if ($task->is_blocking()) {
869 $task->get_cron_lock()->release();
871 $task->get_lock()->release();
875 * Clears the fail delay for the given task and updates its next run time based on the schedule.
877 * @param scheduled_task $task Task to reset
878 * @throws \dml_exception If there is a database error
880 public static function clear_fail_delay(scheduled_task $task) {
881 global $DB;
883 $record = new \stdClass();
884 $record->id = $DB->get_field('task_scheduled', 'id',
885 ['classname' => self::get_canonical_class_name($task)]);
886 $record->nextruntime = $task->get_next_scheduled_time();
887 $record->faildelay = 0;
888 $DB->update_record('task_scheduled', $record);
892 * Records that a scheduled task is starting to run.
894 * @param scheduled_task $task Task that is starting
895 * @param int $time Start time (0 = current)
896 * @throws \dml_exception If the task doesn't exist
898 public static function scheduled_task_starting(scheduled_task $task, int $time = 0) {
899 global $DB;
900 $pid = (int)getmypid();
901 $hostname = (string)gethostname();
903 if (!$time) {
904 $time = time();
907 $task->set_timestarted($time);
908 $task->set_hostname($hostname);
909 $task->set_pid($pid);
911 $classname = self::get_canonical_class_name($task);
912 $record = $DB->get_record('task_scheduled', ['classname' => $classname], '*', MUST_EXIST);
913 $record->timestarted = $time;
914 $record->hostname = $hostname;
915 $record->pid = $pid;
916 $DB->update_record('task_scheduled', $record);
920 * This function indicates that a scheduled task was completed successfully and should be rescheduled.
922 * @param \core\task\scheduled_task $task
924 public static function scheduled_task_complete(scheduled_task $task) {
925 global $DB;
927 // Finalise the log output.
928 logmanager::finalise_log();
929 $task->set_timestarted();
930 $task->set_hostname();
931 $task->set_pid();
933 $classname = self::get_canonical_class_name($task);
934 $record = $DB->get_record('task_scheduled', array('classname' => $classname));
935 if ($record) {
936 $record->lastruntime = time();
937 $record->faildelay = 0;
938 $record->nextruntime = $task->get_next_scheduled_time();
939 $record->timestarted = null;
940 $record->hostname = null;
941 $record->pid = null;
943 $DB->update_record('task_scheduled', $record);
946 // Reschedule and then release the locks.
947 if ($task->is_blocking()) {
948 $task->get_cron_lock()->release();
950 $task->get_lock()->release();
954 * Gets a list of currently-running tasks.
956 * @param string $sort Sorting method
957 * @return array Array of scheduled and adhoc tasks
958 * @throws \dml_exception
960 public static function get_running_tasks($sort = ''): array {
961 global $DB;
962 if (empty($sort)) {
963 $sort = 'timestarted ASC, classname ASC';
965 $params = ['now1' => time(), 'now2' => time()];
967 $sql = "SELECT subquery.*
968 FROM (SELECT concat('s', ts.id) as uniqueid,
969 ts.id,
970 'scheduled' as type,
971 ts.classname,
972 (:now1 - ts.timestarted) as time,
973 ts.timestarted,
974 ts.hostname,
975 ts.pid
976 FROM {task_scheduled} ts
977 WHERE ts.timestarted IS NOT NULL
978 UNION ALL
979 SELECT concat('a', ta.id) as uniqueid,
980 ta.id,
981 'adhoc' as type,
982 ta.classname,
983 (:now2 - ta.timestarted) as time,
984 ta.timestarted,
985 ta.hostname,
986 ta.pid
987 FROM {task_adhoc} ta
988 WHERE ta.timestarted IS NOT NULL) subquery
989 ORDER BY " . $sort;
991 return $DB->get_records_sql($sql, $params);
995 * This function is used to indicate that any long running cron processes should exit at the
996 * next opportunity and restart. This is because something (e.g. DB changes) has changed and
997 * the static caches may be stale.
999 public static function clear_static_caches() {
1000 global $DB;
1001 // Do not use get/set config here because the caches cannot be relied on.
1002 $record = $DB->get_record('config', array('name'=>'scheduledtaskreset'));
1003 if ($record) {
1004 $record->value = time();
1005 $DB->update_record('config', $record);
1006 } else {
1007 $record = new \stdClass();
1008 $record->name = 'scheduledtaskreset';
1009 $record->value = time();
1010 $DB->insert_record('config', $record);
1015 * Return true if the static caches have been cleared since $starttime.
1016 * @param int $starttime The time this process started.
1017 * @return boolean True if static caches need resetting.
1019 public static function static_caches_cleared_since($starttime) {
1020 global $DB;
1021 $record = $DB->get_record('config', array('name'=>'scheduledtaskreset'));
1022 return $record && (intval($record->value) > $starttime);
1026 * Gets class name for use in database table. Always begins with a \.
1028 * @param string|task_base $taskorstring Task object or a string
1030 protected static function get_canonical_class_name($taskorstring) {
1031 if (is_string($taskorstring)) {
1032 $classname = $taskorstring;
1033 } else {
1034 $classname = get_class($taskorstring);
1036 if (strpos($classname, '\\') !== 0) {
1037 $classname = '\\' . $classname;
1039 return $classname;
1043 * Gets the concurrent lock required to run an adhoc task.
1045 * @param adhoc_task $task The task to obtain the lock for
1046 * @return \core\lock\lock The lock if one was obtained successfully
1047 * @throws \coding_exception
1049 protected static function get_concurrent_task_lock(adhoc_task $task): ?\core\lock\lock {
1050 $adhoclock = null;
1051 $cronlockfactory = \core\lock\lock_config::get_lock_factory(get_class($task));
1053 for ($run = 0; $run < $task->get_concurrency_limit(); $run++) {
1054 if ($adhoclock = $cronlockfactory->get_lock("concurrent_run_{$run}", 0)) {
1055 return $adhoclock;
1059 return null;
1063 * Find the path of PHP CLI binary.
1065 * @return string|false The PHP CLI executable PATH
1067 protected static function find_php_cli_path() {
1068 global $CFG;
1070 if (!empty($CFG->pathtophp) && is_executable(trim($CFG->pathtophp))) {
1071 return $CFG->pathtophp;
1074 return false;
1078 * Returns if Moodle have access to PHP CLI binary or not.
1080 * @return bool
1082 public static function is_runnable():bool {
1083 return self::find_php_cli_path() !== false;
1087 * Executes a cron from web invocation using PHP CLI.
1089 * @param \core\task\task_base $task Task that be executed via CLI.
1090 * @return bool
1091 * @throws \moodle_exception
1093 public static function run_from_cli(\core\task\task_base $task):bool {
1094 global $CFG;
1096 if (!self::is_runnable()) {
1097 $redirecturl = new \moodle_url('/admin/settings.php', ['section' => 'systempaths']);
1098 throw new \moodle_exception('cannotfindthepathtothecli', 'core_task', $redirecturl->out());
1099 } else {
1100 // Shell-escaped path to the PHP binary.
1101 $phpbinary = escapeshellarg(self::find_php_cli_path());
1103 // Shell-escaped path CLI script.
1104 $pathcomponents = [$CFG->dirroot, $CFG->admin, 'cli', 'scheduled_task.php'];
1105 $scriptpath = escapeshellarg(implode(DIRECTORY_SEPARATOR, $pathcomponents));
1107 // Shell-escaped task name.
1108 $classname = get_class($task);
1109 $taskarg = escapeshellarg("--execute={$classname}") . " " . escapeshellarg("--force");
1111 // Build the CLI command.
1112 $command = "{$phpbinary} {$scriptpath} {$taskarg}";
1114 // Execute it.
1115 passthru($command);
1118 return true;
1122 * For a given scheduled task record, this method will check to see if any overrides have
1123 * been applied in config and return a copy of the record with any overridden values.
1125 * The format of the config value is:
1126 * $CFG->scheduled_tasks = array(
1127 * '$classname' => array(
1128 * 'schedule' => '* * * * *',
1129 * 'disabled' => 1,
1130 * ),
1131 * );
1133 * Where $classname is the value of the task's classname, i.e. '\core\task\grade_cron_task'.
1135 * @param \stdClass $record scheduled task record
1136 * @return \stdClass scheduled task with any configured overrides
1138 protected static function get_record_with_config_overrides(\stdClass $record): \stdClass {
1139 global $CFG;
1141 $scheduledtaskkey = self::scheduled_task_get_override_key($record->classname);
1142 $overriddenrecord = $record;
1144 if ($scheduledtaskkey) {
1145 $overriddenrecord->customised = true;
1146 $taskconfig = $CFG->scheduled_tasks[$scheduledtaskkey];
1148 if (isset($taskconfig['disabled'])) {
1149 $overriddenrecord->disabled = $taskconfig['disabled'];
1151 if (isset($taskconfig['schedule'])) {
1152 list (
1153 $overriddenrecord->minute,
1154 $overriddenrecord->hour,
1155 $overriddenrecord->day,
1156 $overriddenrecord->dayofweek,
1157 $overriddenrecord->month) = explode(' ', $taskconfig['schedule']);
1161 return $overriddenrecord;
1165 * This checks whether or not there is a value set in config
1166 * for a scheduled task.
1168 * @param string $classname Scheduled task's classname
1169 * @return bool true if there is an entry in config
1171 public static function scheduled_task_has_override(string $classname): bool {
1172 return self::scheduled_task_get_override_key($classname) !== null;
1176 * Get the key within the scheduled tasks config object that
1177 * for a classname.
1179 * @param string $classname the scheduled task classname to find
1180 * @return string the key if found, otherwise null
1182 public static function scheduled_task_get_override_key(string $classname): ?string {
1183 global $CFG;
1185 if (isset($CFG->scheduled_tasks)) {
1186 // Firstly, attempt to get a match against the full classname.
1187 if (isset($CFG->scheduled_tasks[$classname])) {
1188 return $classname;
1191 // Check to see if there is a wildcard matching the classname.
1192 foreach (array_keys($CFG->scheduled_tasks) as $key) {
1193 if (strpos($key, '*') === false) {
1194 continue;
1197 $pattern = '/' . str_replace('\\', '\\\\', str_replace('*', '.*', $key)) . '/';
1199 if (preg_match($pattern, $classname)) {
1200 return $key;
1205 return null;