Merge branch 'MDL-68768-adhoc-task-faildelay-check' of https://github.com/brendanheyw...
[moodle.git] / lib / classes / task / manager.php
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.
13 //
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.
19  *
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
24  */
25 namespace core\task;
27 define('CORE_TASK_TASKS_FILENAME', 'db/tasks.php');
28 /**
29  * Collection of task related methods.
30  *
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
36  */
37 class manager {
39     /**
40      * Given a component name, will load the list of tasks in the db/tasks.php file for that component.
41      *
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.
46      */
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();
52         }
54         $file = $dir . '/' . CORE_TASK_TASKS_FILENAME;
55         if (!file_exists($file)) {
56             return array();
57         }
59         $tasks = null;
60         include($file);
62         if (!isset($tasks)) {
63             return array();
64         }
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;
75             }
76         }
78         return $scheduledtasks;
79     }
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.
85      *
86      * @param string $componentname - The frankenstyle component name.
87      */
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;
102                 }
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);
113             }
114         }
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);
123         }
124         $DB->delete_records_select('task_scheduled', $sql, $params);
125     }
127     /**
128      * Checks if the task with the same classname, component and customdata is already scheduled
129      *
130      * @param adhoc_task $task
131      * @return bool
132      */
133     protected static function task_is_scheduled($task) {
134         return false !== self::get_queued_adhoc_task_record($task);
135     }
137     /**
138      * Checks if the task with the same classname, component and customdata is already scheduled
139      *
140      * @param adhoc_task $task
141      * @return bool
142      */
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 = ? ";
154         }
155         return $DB->get_record_select('task_adhoc', $sql, $params);
156     }
158     /**
159      * Schedule a new task, or reschedule an existing adhoc task which has matching data.
160      *
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.
163      *
164      * @param \core\task\adhoc_task $task - The new adhoc task information to store.
165      * @since Moodle 3.7
166      */
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]);
175             }
176         } else {
177             // There is nothing queued yet. Just queue as normal.
178             self::queue_adhoc_task($task);
179         }
180     }
182     /**
183      * Queue an adhoc task to run in the background.
184      *
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.
189      */
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);
196         }
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;
202         }
204         // Check if the same task is already scheduled.
205         if ($checkforexisting && self::task_is_scheduled($task)) {
206             return false;
207         }
209         // Queue the task.
210         $result = $DB->insert_record('task_adhoc', $record);
212         return $result;
213     }
215     /**
216      * Change the default configuration for a scheduled task.
217      * The list of scheduled tasks is taken from {@link load_scheduled_tasks_for_component}.
218      *
219      * @param \core\task\scheduled_task $task - The new scheduled task information to store.
220      * @return boolean - True if the config was saved.
221      */
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;
236     }
238     /**
239      * Utility method to create a DB record from a scheduled task.
240      *
241      * @param \core\task\scheduled_task $task
242      * @return \stdClass
243      */
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;
264     }
266     /**
267      * Utility method to create a DB record from an adhoc task.
268      *
269      * @param \core\task\adhoc_task $task
270      * @return \stdClass
271      */
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;
288     }
290     /**
291      * Utility method to create an adhoc task from a DB record.
292      *
293      * @param \stdClass $record
294      * @return \core\task\adhoc_task
295      */
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;
301         }
302         $task = new $classname;
303         if (isset($record->nextruntime)) {
304             $task->set_next_run_time($record->nextruntime);
305         }
306         if (isset($record->id)) {
307             $task->set_id($record->id);
308         }
309         if (isset($record->component)) {
310             $task->set_component($record->component);
311         }
312         $task->set_blocking(!empty($record->blocking));
313         if (isset($record->faildelay)) {
314             $task->set_fail_delay($record->faildelay);
315         }
316         if (isset($record->customdata)) {
317             $task->set_custom_data_as_string($record->customdata);
318         }
320         if (isset($record->userid)) {
321             $task->set_userid($record->userid);
322         }
323         if (isset($record->timestarted)) {
324             $task->set_timestarted($record->timestarted);
325         }
326         if (isset($record->hostname)) {
327             $task->set_hostname($record->hostname);
328         }
329         if (isset($record->pid)) {
330             $task->set_pid($record->pid);
331         }
333         return $task;
334     }
336     /**
337      * Utility method to create a task from a DB record.
338      *
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
344      */
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;
350         }
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);
357         }
359         if (isset($record->lastruntime)) {
360             $task->set_last_run_time($record->lastruntime);
361         }
362         if (isset($record->nextruntime)) {
363             $task->set_next_run_time($record->nextruntime);
364         }
365         if (isset($record->customised)) {
366             $task->set_customised($record->customised);
367         }
368         if (isset($record->component)) {
369             $task->set_component($record->component);
370         }
371         $task->set_blocking(!empty($record->blocking));
372         if (isset($record->minute)) {
373             $task->set_minute($record->minute, $expandr);
374         }
375         if (isset($record->hour)) {
376             $task->set_hour($record->hour, $expandr);
377         }
378         if (isset($record->day)) {
379             $task->set_day($record->day);
380         }
381         if (isset($record->month)) {
382             $task->set_month($record->month);
383         }
384         if (isset($record->dayofweek)) {
385             $task->set_day_of_week($record->dayofweek, $expandr);
386         }
387         if (isset($record->faildelay)) {
388             $task->set_fail_delay($record->faildelay);
389         }
390         if (isset($record->disabled)) {
391             $task->set_disabled($record->disabled);
392         }
393         if (isset($record->timestarted)) {
394             $task->set_timestarted($record->timestarted);
395         }
396         if (isset($record->hostname)) {
397             $task->set_hostname($record->hostname);
398         }
399         if (isset($record->pid)) {
400             $task->set_pid($record->pid);
401         }
402         $task->set_overridden(self::scheduled_task_has_override($classname));
404         return $task;
405     }
407     /**
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[]
412      */
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;
424             }
425         }
427         return $tasks;
428     }
430     /**
431      * This function load the scheduled task details for a given classname.
432      *
433      * @param string $classname
434      * @return \core\task\scheduled_task or false
435      */
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;
444         }
445         return self::scheduled_task_from_record($record);
446     }
448     /**
449      * This function load the adhoc tasks for a given classname.
450      *
451      * @param string $classname
452      * @return \core\task\adhoc_task[]
453      */
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);
464     }
466     /**
467      * This function load the default scheduled task details for a given classname.
468      *
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
473      */
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);
482         }
484         foreach ($componenttasks as $componenttask) {
485             if (get_class($componenttask) == get_class($task)) {
486                 return $componenttask;
487             }
488         }
490         return false;
491     }
493     /**
494      * This function will return a list of all the scheduled tasks that exist in the database.
495      *
496      * @return \core\task\scheduled_task[]
497      */
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;
509             }
510         }
512         return $tasks;
513     }
515     /**
516      * This function will return a list of all adhoc tasks that have a faildelay
517      *
518      * @param int $delay filter how long the task has been delayed
519      * @return \core\task\adhoc_task[]
520      */
521     public static function get_failed_adhoc_tasks(int $delay = 0): array {
522         global $DB;
524         $tasks = [];
525         $records = $DB->get_records_sql('SELECT * from {task_adhoc} WHERE faildelay > ?', [$delay]);
527         foreach ($records as $record) {
528             $task = self::adhoc_task_from_record($record);
529             if ($task) {
530                 $tasks[] = $task;
531             }
532         }
533         return $tasks;
534     }
536     /**
537      * Ensure quality of service for the ad hoc task queue.
538      *
539      * This reshuffles the adhoc tasks queue to balance by type to ensure a
540      * level of quality of service per type, while still maintaining the
541      * relative order of tasks queued by timestamp.
542      *
543      * @param array $records array of task records
544      * @param array $records array of same task records shuffled
545      */
546     public static function ensure_adhoc_task_qos(array $records): array {
548         $count = count($records);
549         if ($count == 0) {
550             return $records;
551         }
553         $queues = []; // This holds a queue for each type of adhoc task.
554         $limits = []; // The relative limits of each type of task.
555         $limittotal = 0;
557         // Split the single queue up into queues per type.
558         foreach ($records as $record) {
559             $type = $record->classname;
560             if (!array_key_exists($type, $queues)) {
561                 $queues[$type] = [];
562             }
563             if (!array_key_exists($type, $limits)) {
564                 $limits[$type] = 1;
565                 $limittotal += 1;
566             }
567             $queues[$type][] = $record;
568         }
570         $qos = []; // Our new queue with ensured quality of service.
571         $seed = $count % $limittotal; // Which task queue to shuffle from first?
573         $move = 1; // How many tasks to shuffle at a time.
574         do {
575             $shuffled = 0;
577             // Now cycle through task type queues and interleaving the tasks
578             // back into a single queue.
579             foreach ($limits as $type => $limit) {
581                 // Just interleaving the queue is not enough, because after
582                 // any task is processed the whole queue is rebuilt again. So
583                 // we need to deterministically start on different types of
584                 // tasks so that *on average* we rotate through each type of task.
585                 //
586                 // We achieve this by using a $seed to start moving tasks off a
587                 // different queue each time. The seed is based on the task count
588                 // modulo the number of types of tasks on the queue. As we count
589                 // down this naturally cycles through each type of record.
590                 if ($seed < 1) {
591                     $shuffled = 1;
592                     $seed += 1;
593                     continue;
594                 }
595                 $tasks = array_splice($queues[$type], 0, $move);
596                 $qos = array_merge($qos, $tasks);
598                 // Stop if we didn't move any tasks onto the main queue.
599                 $shuffled += count($tasks);
600             }
601             // Generally the only tasks that matter are those that are near the start so
602             // after we have shuffled the first few 1 by 1, start shuffling larger groups.
603             if (count($qos) >= (4 * count($limits))) {
604                 $move *= 2;
605             }
606         } while ($shuffled > 0);
608         return $qos;
609     }
611     /**
612      * This function will dispatch the next adhoc task in the queue. The task will be handed out
613      * with an open lock - possibly on the entire cron process. Make sure you call either
614      * {@link adhoc_task_failed} or {@link adhoc_task_complete} to release the lock and reschedule the task.
615      *
616      * @param int $timestart
617      * @param bool $checklimits Should we check limits?
618      * @return \core\task\adhoc_task or null if not found
619      * @throws \moodle_exception
620      */
621     public static function get_next_adhoc_task($timestart, $checklimits = true) {
622         global $DB;
624         $where = '(nextruntime IS NULL OR nextruntime < :timestart1)';
625         $params = array('timestart1' => $timestart);
626         $records = $DB->get_records_select('task_adhoc', $where, $params, 'nextruntime ASC, id ASC', '*', 0, 2000);
627         $records = self::ensure_adhoc_task_qos($records);
629         $cronlockfactory = \core\lock\lock_config::get_lock_factory('cron');
631         $skipclasses = array();
633         foreach ($records as $record) {
635             if (in_array($record->classname, $skipclasses)) {
636                 // Skip the task if it can't be started due to per-task concurrency limit.
637                 continue;
638             }
640             if ($lock = $cronlockfactory->get_lock('adhoc_' . $record->id, 0)) {
642                 // Safety check, see if the task has been already processed by another cron run.
643                 $record = $DB->get_record('task_adhoc', array('id' => $record->id));
644                 if (!$record) {
645                     $lock->release();
646                     continue;
647                 }
649                 $task = self::adhoc_task_from_record($record);
650                 // Safety check in case the task in the DB does not match a real class (maybe something was uninstalled).
651                 if (!$task) {
652                     $lock->release();
653                     continue;
654                 }
656                 $tasklimit = $task->get_concurrency_limit();
657                 if ($checklimits && $tasklimit > 0) {
658                     if ($concurrencylock = self::get_concurrent_task_lock($task)) {
659                         $task->set_concurrency_lock($concurrencylock);
660                     } else {
661                         // Unable to obtain a concurrency lock.
662                         mtrace("Skipping $record->classname adhoc task class as the per-task limit of $tasklimit is reached.");
663                         $skipclasses[] = $record->classname;
664                         $lock->release();
665                         continue;
666                     }
667                 }
669                 // The global cron lock is under the most contention so request it
670                 // as late as possible and release it as soon as possible.
671                 if (!$cronlock = $cronlockfactory->get_lock('core_cron', 10)) {
672                     $lock->release();
673                     throw new \moodle_exception('locktimeout');
674                 }
676                 $task->set_lock($lock);
677                 if (!$task->is_blocking()) {
678                     $cronlock->release();
679                 } else {
680                     $task->set_cron_lock($cronlock);
681                 }
682                 return $task;
683             }
684         }
686         return null;
687     }
689     /**
690      * This function will dispatch the next scheduled task in the queue. The task will be handed out
691      * with an open lock - possibly on the entire cron process. Make sure you call either
692      * {@link scheduled_task_failed} or {@link scheduled_task_complete} to release the lock and reschedule the task.
693      *
694      * @param int $timestart - The start of the cron process - do not repeat any tasks that have been run more recently than this.
695      * @return \core\task\scheduled_task or null
696      * @throws \moodle_exception
697      */
698     public static function get_next_scheduled_task($timestart) {
699         global $DB;
700         $cronlockfactory = \core\lock\lock_config::get_lock_factory('cron');
702         $where = "(lastruntime IS NULL OR lastruntime < :timestart1)
703                   AND (nextruntime IS NULL OR nextruntime < :timestart2)
704                   ORDER BY lastruntime, id ASC";
705         $params = array('timestart1' => $timestart, 'timestart2' => $timestart);
706         $records = $DB->get_records_select('task_scheduled', $where, $params);
708         $pluginmanager = \core_plugin_manager::instance();
710         foreach ($records as $record) {
712             $task = self::scheduled_task_from_record($record);
713             // Safety check in case the task in the DB does not match a real class (maybe something was uninstalled).
714             // Also check to see if task is disabled or enabled after applying overrides.
715             if (!$task || $task->get_disabled()) {
716                 continue;
717             }
719             if ($lock = $cronlockfactory->get_lock(($record->classname), 0)) {
720                 $classname = '\\' . $record->classname;
722                 $task->set_lock($lock);
724                 // See if the component is disabled.
725                 $plugininfo = $pluginmanager->get_plugin_info($task->get_component());
727                 if ($plugininfo) {
728                     if (($plugininfo->is_enabled() === false) && !$task->get_run_if_component_disabled()) {
729                         $lock->release();
730                         continue;
731                     }
732                 }
734                 if (!self::scheduled_task_has_override($record->classname)) {
735                     // Make sure the task data is unchanged unless an override is being used.
736                     if (!$DB->record_exists('task_scheduled', (array)$record)) {
737                         $lock->release();
738                         continue;
739                     }
740                 }
742                 // The global cron lock is under the most contention so request it
743                 // as late as possible and release it as soon as possible.
744                 if (!$cronlock = $cronlockfactory->get_lock('core_cron', 10)) {
745                     $lock->release();
746                     throw new \moodle_exception('locktimeout');
747                 }
749                 if (!$task->is_blocking()) {
750                     $cronlock->release();
751                 } else {
752                     $task->set_cron_lock($cronlock);
753                 }
754                 return $task;
755             }
756         }
758         return null;
759     }
761     /**
762      * This function indicates that an adhoc task was not completed successfully and should be retried.
763      *
764      * @param \core\task\adhoc_task $task
765      */
766     public static function adhoc_task_failed(adhoc_task $task) {
767         global $DB;
768         // Finalise the log output.
769         logmanager::finalise_log(true);
771         $delay = $task->get_fail_delay();
773         // Reschedule task with exponential fall off for failing tasks.
774         if (empty($delay)) {
775             $delay = 60;
776         } else {
777             $delay *= 2;
778         }
780         // Max of 24 hour delay.
781         if ($delay > 86400) {
782             $delay = 86400;
783         }
785         // Reschedule and then release the locks.
786         $task->set_timestarted();
787         $task->set_hostname();
788         $task->set_pid();
789         $task->set_next_run_time(time() + $delay);
790         $task->set_fail_delay($delay);
791         $record = self::record_from_adhoc_task($task);
792         $DB->update_record('task_adhoc', $record);
794         $task->release_concurrency_lock();
795         if ($task->is_blocking()) {
796             $task->get_cron_lock()->release();
797         }
798         $task->get_lock()->release();
799     }
801     /**
802      * Records that a adhoc task is starting to run.
803      *
804      * @param adhoc_task $task Task that is starting
805      * @param int $time Start time (leave blank for now)
806      * @throws \dml_exception
807      * @throws \coding_exception
808      */
809     public static function adhoc_task_starting(adhoc_task $task, int $time = 0) {
810         global $DB;
811         $pid = (int)getmypid();
812         $hostname = (string)gethostname();
814         if (empty($time)) {
815             $time = time();
816         }
818         $task->set_timestarted($time);
819         $task->set_hostname($hostname);
820         $task->set_pid($pid);
822         $record = self::record_from_adhoc_task($task);
823         $DB->update_record('task_adhoc', $record);
824     }
826     /**
827      * This function indicates that an adhoc task was completed successfully.
828      *
829      * @param \core\task\adhoc_task $task
830      */
831     public static function adhoc_task_complete(adhoc_task $task) {
832         global $DB;
834         // Finalise the log output.
835         logmanager::finalise_log();
836         $task->set_timestarted();
837         $task->set_hostname();
838         $task->set_pid();
840         // Delete the adhoc task record - it is finished.
841         $DB->delete_records('task_adhoc', array('id' => $task->get_id()));
843         // Release the locks.
844         $task->release_concurrency_lock();
845         if ($task->is_blocking()) {
846             $task->get_cron_lock()->release();
847         }
848         $task->get_lock()->release();
849     }
851     /**
852      * This function indicates that a scheduled task was not completed successfully and should be retried.
853      *
854      * @param \core\task\scheduled_task $task
855      */
856     public static function scheduled_task_failed(scheduled_task $task) {
857         global $DB;
858         // Finalise the log output.
859         logmanager::finalise_log(true);
861         $delay = $task->get_fail_delay();
863         // Reschedule task with exponential fall off for failing tasks.
864         if (empty($delay)) {
865             $delay = 60;
866         } else {
867             $delay *= 2;
868         }
870         // Max of 24 hour delay.
871         if ($delay > 86400) {
872             $delay = 86400;
873         }
875         $task->set_timestarted();
876         $task->set_hostname();
877         $task->set_pid();
879         $classname = self::get_canonical_class_name($task);
881         $record = $DB->get_record('task_scheduled', array('classname' => $classname));
882         $record->nextruntime = time() + $delay;
883         $record->faildelay = $delay;
884         $record->timestarted = null;
885         $record->hostname = null;
886         $record->pid = null;
887         $DB->update_record('task_scheduled', $record);
889         if ($task->is_blocking()) {
890             $task->get_cron_lock()->release();
891         }
892         $task->get_lock()->release();
893     }
895     /**
896      * Clears the fail delay for the given task and updates its next run time based on the schedule.
897      *
898      * @param scheduled_task $task Task to reset
899      * @throws \dml_exception If there is a database error
900      */
901     public static function clear_fail_delay(scheduled_task $task) {
902         global $DB;
904         $record = new \stdClass();
905         $record->id = $DB->get_field('task_scheduled', 'id',
906                 ['classname' => self::get_canonical_class_name($task)]);
907         $record->nextruntime = $task->get_next_scheduled_time();
908         $record->faildelay = 0;
909         $DB->update_record('task_scheduled', $record);
910     }
912     /**
913      * Records that a scheduled task is starting to run.
914      *
915      * @param scheduled_task $task Task that is starting
916      * @param int $time Start time (0 = current)
917      * @throws \dml_exception If the task doesn't exist
918      */
919     public static function scheduled_task_starting(scheduled_task $task, int $time = 0) {
920         global $DB;
921         $pid = (int)getmypid();
922         $hostname = (string)gethostname();
924         if (!$time) {
925             $time = time();
926         }
928         $task->set_timestarted($time);
929         $task->set_hostname($hostname);
930         $task->set_pid($pid);
932         $classname = self::get_canonical_class_name($task);
933         $record = $DB->get_record('task_scheduled', ['classname' => $classname], '*', MUST_EXIST);
934         $record->timestarted = $time;
935         $record->hostname = $hostname;
936         $record->pid = $pid;
937         $DB->update_record('task_scheduled', $record);
938     }
940     /**
941      * This function indicates that a scheduled task was completed successfully and should be rescheduled.
942      *
943      * @param \core\task\scheduled_task $task
944      */
945     public static function scheduled_task_complete(scheduled_task $task) {
946         global $DB;
948         // Finalise the log output.
949         logmanager::finalise_log();
950         $task->set_timestarted();
951         $task->set_hostname();
952         $task->set_pid();
954         $classname = self::get_canonical_class_name($task);
955         $record = $DB->get_record('task_scheduled', array('classname' => $classname));
956         if ($record) {
957             $record->lastruntime = time();
958             $record->faildelay = 0;
959             $record->nextruntime = $task->get_next_scheduled_time();
960             $record->timestarted = null;
961             $record->hostname = null;
962             $record->pid = null;
964             $DB->update_record('task_scheduled', $record);
965         }
967         // Reschedule and then release the locks.
968         if ($task->is_blocking()) {
969             $task->get_cron_lock()->release();
970         }
971         $task->get_lock()->release();
972     }
974     /**
975      * Gets a list of currently-running tasks.
976      *
977      * @param  string $sort Sorting method
978      * @return array Array of scheduled and adhoc tasks
979      * @throws \dml_exception
980      */
981     public static function get_running_tasks($sort = ''): array {
982         global $DB;
983         if (empty($sort)) {
984             $sort = 'timestarted ASC, classname ASC';
985         }
986         $params = ['now1' => time(), 'now2' => time()];
988         $sql = "SELECT subquery.*
989                   FROM (SELECT concat('s', ts.id) as uniqueid,
990                                ts.id,
991                                'scheduled' as type,
992                                ts.classname,
993                                (:now1 - ts.timestarted) as time,
994                                ts.timestarted,
995                                ts.hostname,
996                                ts.pid
997                           FROM {task_scheduled} ts
998                          WHERE ts.timestarted IS NOT NULL
999                          UNION ALL
1000                         SELECT concat('a', ta.id) as uniqueid,
1001                                ta.id,
1002                                'adhoc' as type,
1003                                ta.classname,
1004                                (:now2 - ta.timestarted) as time,
1005                                ta.timestarted,
1006                                ta.hostname,
1007                                ta.pid
1008                           FROM {task_adhoc} ta
1009                          WHERE ta.timestarted IS NOT NULL) subquery
1010               ORDER BY " . $sort;
1012         return $DB->get_records_sql($sql, $params);
1013     }
1015     /**
1016      * This function is used to indicate that any long running cron processes should exit at the
1017      * next opportunity and restart. This is because something (e.g. DB changes) has changed and
1018      * the static caches may be stale.
1019      */
1020     public static function clear_static_caches() {
1021         global $DB;
1022         // Do not use get/set config here because the caches cannot be relied on.
1023         $record = $DB->get_record('config', array('name'=>'scheduledtaskreset'));
1024         if ($record) {
1025             $record->value = time();
1026             $DB->update_record('config', $record);
1027         } else {
1028             $record = new \stdClass();
1029             $record->name = 'scheduledtaskreset';
1030             $record->value = time();
1031             $DB->insert_record('config', $record);
1032         }
1033     }
1035     /**
1036      * Return true if the static caches have been cleared since $starttime.
1037      * @param int $starttime The time this process started.
1038      * @return boolean True if static caches need resetting.
1039      */
1040     public static function static_caches_cleared_since($starttime) {
1041         global $DB;
1042         $record = $DB->get_record('config', array('name'=>'scheduledtaskreset'));
1043         return $record && (intval($record->value) > $starttime);
1044     }
1046     /**
1047      * Gets class name for use in database table. Always begins with a \.
1048      *
1049      * @param string|task_base $taskorstring Task object or a string
1050      */
1051     protected static function get_canonical_class_name($taskorstring) {
1052         if (is_string($taskorstring)) {
1053             $classname = $taskorstring;
1054         } else {
1055             $classname = get_class($taskorstring);
1056         }
1057         if (strpos($classname, '\\') !== 0) {
1058             $classname = '\\' . $classname;
1059         }
1060         return $classname;
1061     }
1063     /**
1064      * Gets the concurrent lock required to run an adhoc task.
1065      *
1066      * @param   adhoc_task $task The task to obtain the lock for
1067      * @return  \core\lock\lock The lock if one was obtained successfully
1068      * @throws  \coding_exception
1069      */
1070     protected static function get_concurrent_task_lock(adhoc_task $task): ?\core\lock\lock {
1071         $adhoclock = null;
1072         $cronlockfactory = \core\lock\lock_config::get_lock_factory(get_class($task));
1074         for ($run = 0; $run < $task->get_concurrency_limit(); $run++) {
1075             if ($adhoclock = $cronlockfactory->get_lock("concurrent_run_{$run}", 0)) {
1076                 return $adhoclock;
1077             }
1078         }
1080         return null;
1081     }
1083     /**
1084      * Find the path of PHP CLI binary.
1085      *
1086      * @return string|false The PHP CLI executable PATH
1087      */
1088     protected static function find_php_cli_path() {
1089         global $CFG;
1091         if (!empty($CFG->pathtophp) && is_executable(trim($CFG->pathtophp))) {
1092             return $CFG->pathtophp;
1093         }
1095         return false;
1096     }
1098     /**
1099      * Returns if Moodle have access to PHP CLI binary or not.
1100      *
1101      * @return bool
1102      */
1103     public static function is_runnable():bool {
1104         return self::find_php_cli_path() !== false;
1105     }
1107     /**
1108      * Executes a cron from web invocation using PHP CLI.
1109      *
1110      * @param \core\task\task_base $task Task that be executed via CLI.
1111      * @return bool
1112      * @throws \moodle_exception
1113      */
1114     public static function run_from_cli(\core\task\task_base $task):bool {
1115         global $CFG;
1117         if (!self::is_runnable()) {
1118             $redirecturl = new \moodle_url('/admin/settings.php', ['section' => 'systempaths']);
1119             throw new \moodle_exception('cannotfindthepathtothecli', 'core_task', $redirecturl->out());
1120         } else {
1121             // Shell-escaped path to the PHP binary.
1122             $phpbinary = escapeshellarg(self::find_php_cli_path());
1124             // Shell-escaped path CLI script.
1125             $pathcomponents = [$CFG->dirroot, $CFG->admin, 'cli', 'scheduled_task.php'];
1126             $scriptpath     = escapeshellarg(implode(DIRECTORY_SEPARATOR, $pathcomponents));
1128             // Shell-escaped task name.
1129             $classname = get_class($task);
1130             $taskarg   = escapeshellarg("--execute={$classname}") . " " . escapeshellarg("--force");
1132             // Build the CLI command.
1133             $command = "{$phpbinary} {$scriptpath} {$taskarg}";
1135             // Execute it.
1136             passthru($command);
1137         }
1139         return true;
1140     }
1142     /**
1143      * For a given scheduled task record, this method will check to see if any overrides have
1144      * been applied in config and return a copy of the record with any overridden values.
1145      *
1146      * The format of the config value is:
1147      *      $CFG->scheduled_tasks = array(
1148      *          '$classname' => array(
1149      *              'schedule' => '* * * * *',
1150      *              'disabled' => 1,
1151      *          ),
1152      *      );
1153      *
1154      * Where $classname is the value of the task's classname, i.e. '\core\task\grade_cron_task'.
1155      *
1156      * @param \stdClass $record scheduled task record
1157      * @return \stdClass scheduled task with any configured overrides
1158      */
1159     protected static function get_record_with_config_overrides(\stdClass $record): \stdClass {
1160         global $CFG;
1162         $scheduledtaskkey = self::scheduled_task_get_override_key($record->classname);
1163         $overriddenrecord = $record;
1165         if ($scheduledtaskkey) {
1166             $overriddenrecord->customised = true;
1167             $taskconfig = $CFG->scheduled_tasks[$scheduledtaskkey];
1169             if (isset($taskconfig['disabled'])) {
1170                 $overriddenrecord->disabled = $taskconfig['disabled'];
1171             }
1172             if (isset($taskconfig['schedule'])) {
1173                 list (
1174                     $overriddenrecord->minute,
1175                     $overriddenrecord->hour,
1176                     $overriddenrecord->day,
1177                     $overriddenrecord->dayofweek,
1178                     $overriddenrecord->month) = explode(' ', $taskconfig['schedule']);
1179             }
1180         }
1182         return $overriddenrecord;
1183     }
1185     /**
1186      * This checks whether or not there is a value set in config
1187      * for a scheduled task.
1188      *
1189      * @param string $classname Scheduled task's classname
1190      * @return bool true if there is an entry in config
1191      */
1192     public static function scheduled_task_has_override(string $classname): bool {
1193         return self::scheduled_task_get_override_key($classname) !== null;
1194     }
1196     /**
1197      * Get the key within the scheduled tasks config object that
1198      * for a classname.
1199      *
1200      * @param string $classname the scheduled task classname to find
1201      * @return string the key if found, otherwise null
1202      */
1203     public static function scheduled_task_get_override_key(string $classname): ?string {
1204         global $CFG;
1206         if (isset($CFG->scheduled_tasks)) {
1207             // Firstly, attempt to get a match against the full classname.
1208             if (isset($CFG->scheduled_tasks[$classname])) {
1209                 return $classname;
1210             }
1212             // Check to see if there is a wildcard matching the classname.
1213             foreach (array_keys($CFG->scheduled_tasks) as $key) {
1214                 if (strpos($key, '*') === false) {
1215                     continue;
1216                 }
1218                 $pattern = '/' . str_replace('\\', '\\\\', str_replace('*', '.*', $key)) . '/';
1220                 if (preg_match($pattern, $classname)) {
1221                     return $key;
1222                 }
1223             }
1224         }
1226         return null;
1227     }