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/>.
16
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 */
25namespace core\task;
26
27define('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 */
37class manager {
38
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);
49
50        if (!$dir) {
51            return array();
52        }
53
54        $file = $dir . '/' . CORE_TASK_TASKS_FILENAME;
55        if (!file_exists($file)) {
56            return array();
57        }
58
59        $tasks = null;
60        include($file);
61
62        if (!isset($tasks)) {
63            return array();
64        }
65
66        $scheduledtasks = array();
67
68        foreach ($tasks as $task) {
69            $record = (object) $task;
70            $scheduledtask = self::scheduled_task_from_record($record, $expandr);
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        }
77
78        return $scheduledtasks;
79    }
80
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();
92
93        foreach ($tasks as $taskid => $task) {
94            $classname = self::get_canonical_class_name($task);
95
96            $validtasks[] = $classname;
97
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                }
103
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());
109
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        }
115
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    }
126
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    }
136
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;
145
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) . ' = ?';
150
151        if ($record->userid) {
152            $params[] = $record->userid;
153            $sql .= " AND userid = ? ";
154        }
155        return $DB->get_record_select('task_adhoc', $sql, $params);
156    }
157
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;
169
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    }
181
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;
192
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        }
197
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        }
203
204        // Check if the same task is already scheduled.
205        if ($checkforexisting && self::task_is_scheduled($task)) {
206            return false;
207        }
208
209        // Queue the task.
210        $result = $DB->insert_record('task_adhoc', $record);
211
212        return $result;
213    }
214
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;
224
225        $classname = self::get_canonical_class_name($task);
226
227        $original = $DB->get_record('task_scheduled', array('classname'=>$classname), 'id', MUST_EXIST);
228
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);
234
235        return $result;
236    }
237
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
260        return $record;
261    }
262
263    /**
264     * Utility method to create a DB record from an adhoc task.
265     *
266     * @param \core\task\adhoc_task $task
267     * @return \stdClass
268     */
269    public static function record_from_adhoc_task($task) {
270        $record = new \stdClass();
271        $record->classname = self::get_canonical_class_name($task);
272        $record->id = $task->get_id();
273        $record->component = $task->get_component();
274        $record->blocking = $task->is_blocking();
275        $record->nextruntime = $task->get_next_run_time();
276        $record->faildelay = $task->get_fail_delay();
277        $record->customdata = $task->get_custom_data_as_string();
278        $record->userid = $task->get_userid();
279
280        return $record;
281    }
282
283    /**
284     * Utility method to create an adhoc task from a DB record.
285     *
286     * @param \stdClass $record
287     * @return \core\task\adhoc_task
288     */
289    public static function adhoc_task_from_record($record) {
290        $classname = self::get_canonical_class_name($record->classname);
291        if (!class_exists($classname)) {
292            debugging("Failed to load task: " . $classname, DEBUG_DEVELOPER);
293            return false;
294        }
295        $task = new $classname;
296        if (isset($record->nextruntime)) {
297            $task->set_next_run_time($record->nextruntime);
298        }
299        if (isset($record->id)) {
300            $task->set_id($record->id);
301        }
302        if (isset($record->component)) {
303            $task->set_component($record->component);
304        }
305        $task->set_blocking(!empty($record->blocking));
306        if (isset($record->faildelay)) {
307            $task->set_fail_delay($record->faildelay);
308        }
309        if (isset($record->customdata)) {
310            $task->set_custom_data_as_string($record->customdata);
311        }
312
313        if (isset($record->userid)) {
314            $task->set_userid($record->userid);
315        }
316
317        return $task;
318    }
319
320    /**
321     * Utility method to create a task from a DB record.
322     *
323     * @param \stdClass $record
324     * @param bool $expandr - if true (default) an 'R' value in a time is expanded to an appropriate int.
325     *      If false, they are left as 'R'
326     * @return \core\task\scheduled_task|false
327     */
328    public static function scheduled_task_from_record($record, $expandr = true) {
329        $classname = self::get_canonical_class_name($record->classname);
330        if (!class_exists($classname)) {
331            debugging("Failed to load task: " . $classname, DEBUG_DEVELOPER);
332            return false;
333        }
334        /** @var \core\task\scheduled_task $task */
335        $task = new $classname;
336        if (isset($record->lastruntime)) {
337            $task->set_last_run_time($record->lastruntime);
338        }
339        if (isset($record->nextruntime)) {
340            $task->set_next_run_time($record->nextruntime);
341        }
342        if (isset($record->customised)) {
343            $task->set_customised($record->customised);
344        }
345        if (isset($record->component)) {
346            $task->set_component($record->component);
347        }
348        $task->set_blocking(!empty($record->blocking));
349        if (isset($record->minute)) {
350            $task->set_minute($record->minute, $expandr);
351        }
352        if (isset($record->hour)) {
353            $task->set_hour($record->hour, $expandr);
354        }
355        if (isset($record->day)) {
356            $task->set_day($record->day);
357        }
358        if (isset($record->month)) {
359            $task->set_month($record->month);
360        }
361        if (isset($record->dayofweek)) {
362            $task->set_day_of_week($record->dayofweek, $expandr);
363        }
364        if (isset($record->faildelay)) {
365            $task->set_fail_delay($record->faildelay);
366        }
367        if (isset($record->disabled)) {
368            $task->set_disabled($record->disabled);
369        }
370
371        return $task;
372    }
373
374    /**
375     * Given a component name, will load the list of tasks from the scheduled_tasks table for that component.
376     * Do not execute tasks loaded from this function - they have not been locked.
377     * @param string $componentname - The name of the component to load the tasks for.
378     * @return \core\task\scheduled_task[]
379     */
380    public static function load_scheduled_tasks_for_component($componentname) {
381        global $DB;
382
383        $tasks = array();
384        // We are just reading - so no locks required.
385        $records = $DB->get_records('task_scheduled', array('component' => $componentname), 'classname', '*', IGNORE_MISSING);
386        foreach ($records as $record) {
387            $task = self::scheduled_task_from_record($record);
388            // Safety check in case the task in the DB does not match a real class (maybe something was uninstalled).
389            if ($task) {
390                $tasks[] = $task;
391            }
392        }
393
394        return $tasks;
395    }
396
397    /**
398     * This function load the scheduled task details for a given classname.
399     *
400     * @param string $classname
401     * @return \core\task\scheduled_task or false
402     */
403    public static function get_scheduled_task($classname) {
404        global $DB;
405
406        $classname = self::get_canonical_class_name($classname);
407        // We are just reading - so no locks required.
408        $record = $DB->get_record('task_scheduled', array('classname'=>$classname), '*', IGNORE_MISSING);
409        if (!$record) {
410            return false;
411        }
412        return self::scheduled_task_from_record($record);
413    }
414
415    /**
416     * This function load the adhoc tasks for a given classname.
417     *
418     * @param string $classname
419     * @return \core\task\adhoc_task[]
420     */
421    public static function get_adhoc_tasks($classname) {
422        global $DB;
423
424        $classname = self::get_canonical_class_name($classname);
425        // We are just reading - so no locks required.
426        $records = $DB->get_records('task_adhoc', array('classname' => $classname));
427
428        return array_map(function($record) {
429            return self::adhoc_task_from_record($record);
430        }, $records);
431    }
432
433    /**
434     * This function load the default scheduled task details for a given classname.
435     *
436     * @param string $classname
437     * @param bool $expandr - if true (default) an 'R' value in a time is expanded to an appropriate int.
438     *      If false, they are left as 'R'
439     * @return \core\task\scheduled_task|false
440     */
441    public static function get_default_scheduled_task($classname, $expandr = true) {
442        $task = self::get_scheduled_task($classname);
443        $componenttasks = array();
444
445        // Safety check in case no task was found for the given classname.
446        if ($task) {
447            $componenttasks = self::load_default_scheduled_tasks_for_component(
448                    $task->get_component(), $expandr);
449        }
450
451        foreach ($componenttasks as $componenttask) {
452            if (get_class($componenttask) == get_class($task)) {
453                return $componenttask;
454            }
455        }
456
457        return false;
458    }
459
460    /**
461     * This function will return a list of all the scheduled tasks that exist in the database.
462     *
463     * @return \core\task\scheduled_task[]
464     */
465    public static function get_all_scheduled_tasks() {
466        global $DB;
467
468        $records = $DB->get_records('task_scheduled', null, 'component, classname', '*', IGNORE_MISSING);
469        $tasks = array();
470
471        foreach ($records as $record) {
472            $task = self::scheduled_task_from_record($record);
473            // Safety check in case the task in the DB does not match a real class (maybe something was uninstalled).
474            if ($task) {
475                $tasks[] = $task;
476            }
477        }
478
479        return $tasks;
480    }
481
482    /**
483     * Ensure quality of service for the ad hoc task queue.
484     *
485     * This reshuffles the adhoc tasks queue to balance by type to ensure a
486     * level of quality of service per type, while still maintaining the
487     * relative order of tasks queued by timestamp.
488     *
489     * @param array $records array of task records
490     * @param array $records array of same task records shuffled
491     */
492    public static function ensure_adhoc_task_qos(array $records): array {
493
494        $count = count($records);
495        if ($count == 0) {
496            return $records;
497        }
498
499        $queues = []; // This holds a queue for each type of adhoc task.
500        $limits = []; // The relative limits of each type of task.
501        $limittotal = 0;
502
503        // Split the single queue up into queues per type.
504        foreach ($records as $record) {
505            $type = $record->classname;
506            if (!array_key_exists($type, $queues)) {
507                $queues[$type] = [];
508            }
509            if (!array_key_exists($type, $limits)) {
510                $limits[$type] = 1;
511                $limittotal += 1;
512            }
513            $queues[$type][] = $record;
514        }
515
516        $qos = []; // Our new queue with ensured quality of service.
517        $seed = $count % $limittotal; // Which task queue to shuffle from first?
518
519        $move = 1; // How many tasks to shuffle at a time.
520        do {
521            $shuffled = 0;
522
523            // Now cycle through task type queues and interleaving the tasks
524            // back into a single queue.
525            foreach ($limits as $type => $limit) {
526
527                // Just interleaving the queue is not enough, because after
528                // any task is processed the whole queue is rebuilt again. So
529                // we need to deterministically start on different types of
530                // tasks so that *on average* we rotate through each type of task.
531                //
532                // We achieve this by using a $seed to start moving tasks off a
533                // different queue each time. The seed is based on the task count
534                // modulo the number of types of tasks on the queue. As we count
535                // down this naturally cycles through each type of record.
536                if ($seed < 1) {
537                    $shuffled = 1;
538                    $seed += 1;
539                    continue;
540                }
541                $tasks = array_splice($queues[$type], 0, $move);
542                $qos = array_merge($qos, $tasks);
543
544                // Stop if we didn't move any tasks onto the main queue.
545                $shuffled += count($tasks);
546            }
547            // Generally the only tasks that matter are those that are near the start so
548            // after we have shuffled the first few 1 by 1, start shuffling larger groups.
549            if (count($qos) >= (4 * count($limits))) {
550                $move *= 2;
551            }
552        } while ($shuffled > 0);
553
554        return $qos;
555    }
556
557    /**
558     * This function will dispatch the next adhoc task in the queue. The task will be handed out
559     * with an open lock - possibly on the entire cron process. Make sure you call either
560     * {@link adhoc_task_failed} or {@link adhoc_task_complete} to release the lock and reschedule the task.
561     *
562     * @param int $timestart
563     * @param bool $checklimits Should we check limits?
564     * @return \core\task\adhoc_task or null if not found
565     * @throws \moodle_exception
566     */
567    public static function get_next_adhoc_task($timestart, $checklimits = true) {
568        global $DB;
569
570        $where = '(nextruntime IS NULL OR nextruntime < :timestart1)';
571        $params = array('timestart1' => $timestart);
572        $records = $DB->get_records_select('task_adhoc', $where, $params, 'nextruntime ASC, id ASC', '*', 0, 2000);
573        $records = self::ensure_adhoc_task_qos($records);
574
575        $cronlockfactory = \core\lock\lock_config::get_lock_factory('cron');
576
577        $skipclasses = array();
578
579        foreach ($records as $record) {
580
581            if (in_array($record->classname, $skipclasses)) {
582                // Skip the task if it can't be started due to per-task concurrency limit.
583                continue;
584            }
585
586            if ($lock = $cronlockfactory->get_lock('adhoc_' . $record->id, 0)) {
587
588                // Safety check, see if the task has been already processed by another cron run.
589                $record = $DB->get_record('task_adhoc', array('id' => $record->id));
590                if (!$record) {
591                    $lock->release();
592                    continue;
593                }
594
595                $task = self::adhoc_task_from_record($record);
596                // Safety check in case the task in the DB does not match a real class (maybe something was uninstalled).
597                if (!$task) {
598                    $lock->release();
599                    continue;
600                }
601
602                $tasklimit = $task->get_concurrency_limit();
603                if ($checklimits && $tasklimit > 0) {
604                    if ($concurrencylock = self::get_concurrent_task_lock($task)) {
605                        $task->set_concurrency_lock($concurrencylock);
606                    } else {
607                        // Unable to obtain a concurrency lock.
608                        mtrace("Skipping $record->classname adhoc task class as the per-task limit of $tasklimit is reached.");
609                        $skipclasses[] = $record->classname;
610                        $lock->release();
611                        continue;
612                    }
613                }
614
615                // The global cron lock is under the most contention so request it
616                // as late as possible and release it as soon as possible.
617                if (!$cronlock = $cronlockfactory->get_lock('core_cron', 10)) {
618                    $lock->release();
619                    throw new \moodle_exception('locktimeout');
620                }
621
622                $task->set_lock($lock);
623                if (!$task->is_blocking()) {
624                    $cronlock->release();
625                } else {
626                    $task->set_cron_lock($cronlock);
627                }
628                return $task;
629            }
630        }
631
632        return null;
633    }
634
635    /**
636     * This function will dispatch the next scheduled task in the queue. The task will be handed out
637     * with an open lock - possibly on the entire cron process. Make sure you call either
638     * {@link scheduled_task_failed} or {@link scheduled_task_complete} to release the lock and reschedule the task.
639     *
640     * @param int $timestart - The start of the cron process - do not repeat any tasks that have been run more recently than this.
641     * @return \core\task\scheduled_task or null
642     * @throws \moodle_exception
643     */
644    public static function get_next_scheduled_task($timestart) {
645        global $DB;
646        $cronlockfactory = \core\lock\lock_config::get_lock_factory('cron');
647
648        $where = "(lastruntime IS NULL OR lastruntime < :timestart1)
649                  AND (nextruntime IS NULL OR nextruntime < :timestart2)
650                  AND disabled = 0
651                  ORDER BY lastruntime, id ASC";
652        $params = array('timestart1' => $timestart, 'timestart2' => $timestart);
653        $records = $DB->get_records_select('task_scheduled', $where, $params);
654
655        $pluginmanager = \core_plugin_manager::instance();
656
657        foreach ($records as $record) {
658
659            if ($lock = $cronlockfactory->get_lock(($record->classname), 0)) {
660                $classname = '\\' . $record->classname;
661                $task = self::scheduled_task_from_record($record);
662                // Safety check in case the task in the DB does not match a real class (maybe something was uninstalled).
663                if (!$task) {
664                    $lock->release();
665                    continue;
666                }
667
668                $task->set_lock($lock);
669
670                // See if the component is disabled.
671                $plugininfo = $pluginmanager->get_plugin_info($task->get_component());
672
673                if ($plugininfo) {
674                    if (($plugininfo->is_enabled() === false) && !$task->get_run_if_component_disabled()) {
675                        $lock->release();
676                        continue;
677                    }
678                }
679
680                // Make sure the task data is unchanged.
681                if (!$DB->record_exists('task_scheduled', (array) $record)) {
682                    $lock->release();
683                    continue;
684                }
685
686                // The global cron lock is under the most contention so request it
687                // as late as possible and release it as soon as possible.
688                if (!$cronlock = $cronlockfactory->get_lock('core_cron', 10)) {
689                    $lock->release();
690                    throw new \moodle_exception('locktimeout');
691                }
692
693                if (!$task->is_blocking()) {
694                    $cronlock->release();
695                } else {
696                    $task->set_cron_lock($cronlock);
697                }
698                return $task;
699            }
700        }
701
702        return null;
703    }
704
705    /**
706     * This function indicates that an adhoc task was not completed successfully and should be retried.
707     *
708     * @param \core\task\adhoc_task $task
709     */
710    public static function adhoc_task_failed(adhoc_task $task) {
711        global $DB;
712        $delay = $task->get_fail_delay();
713
714        // Reschedule task with exponential fall off for failing tasks.
715        if (empty($delay)) {
716            $delay = 60;
717        } else {
718            $delay *= 2;
719        }
720
721        // Max of 24 hour delay.
722        if ($delay > 86400) {
723            $delay = 86400;
724        }
725
726        // Reschedule and then release the locks.
727        $task->set_next_run_time(time() + $delay);
728        $task->set_fail_delay($delay);
729        $record = self::record_from_adhoc_task($task);
730        $DB->update_record('task_adhoc', $record);
731
732        $task->release_concurrency_lock();
733        if ($task->is_blocking()) {
734            $task->get_cron_lock()->release();
735        }
736        $task->get_lock()->release();
737
738        // Finalise the log output.
739        logmanager::finalise_log(true);
740    }
741
742    /**
743     * This function indicates that an adhoc task was completed successfully.
744     *
745     * @param \core\task\adhoc_task $task
746     */
747    public static function adhoc_task_complete(adhoc_task $task) {
748        global $DB;
749
750        // Finalise the log output.
751        logmanager::finalise_log();
752
753        // Delete the adhoc task record - it is finished.
754        $DB->delete_records('task_adhoc', array('id' => $task->get_id()));
755
756        // Release the locks.
757        $task->release_concurrency_lock();
758        if ($task->is_blocking()) {
759            $task->get_cron_lock()->release();
760        }
761        $task->get_lock()->release();
762    }
763
764    /**
765     * This function indicates that a scheduled task was not completed successfully and should be retried.
766     *
767     * @param \core\task\scheduled_task $task
768     */
769    public static function scheduled_task_failed(scheduled_task $task) {
770        global $DB;
771
772        $delay = $task->get_fail_delay();
773
774        // Reschedule task with exponential fall off for failing tasks.
775        if (empty($delay)) {
776            $delay = 60;
777        } else {
778            $delay *= 2;
779        }
780
781        // Max of 24 hour delay.
782        if ($delay > 86400) {
783            $delay = 86400;
784        }
785
786        $classname = self::get_canonical_class_name($task);
787
788        $record = $DB->get_record('task_scheduled', array('classname' => $classname));
789        $record->nextruntime = time() + $delay;
790        $record->faildelay = $delay;
791        $DB->update_record('task_scheduled', $record);
792
793        if ($task->is_blocking()) {
794            $task->get_cron_lock()->release();
795        }
796        $task->get_lock()->release();
797
798        // Finalise the log output.
799        logmanager::finalise_log(true);
800    }
801
802    /**
803     * Clears the fail delay for the given task and updates its next run time based on the schedule.
804     *
805     * @param scheduled_task $task Task to reset
806     * @throws \dml_exception If there is a database error
807     */
808    public static function clear_fail_delay(scheduled_task $task) {
809        global $DB;
810
811        $record = new \stdClass();
812        $record->id = $DB->get_field('task_scheduled', 'id',
813                ['classname' => self::get_canonical_class_name($task)]);
814        $record->nextruntime = $task->get_next_scheduled_time();
815        $record->faildelay = 0;
816        $DB->update_record('task_scheduled', $record);
817    }
818
819    /**
820     * This function indicates that a scheduled task was completed successfully and should be rescheduled.
821     *
822     * @param \core\task\scheduled_task $task
823     */
824    public static function scheduled_task_complete(scheduled_task $task) {
825        global $DB;
826
827        // Finalise the log output.
828        logmanager::finalise_log();
829
830        $classname = self::get_canonical_class_name($task);
831        $record = $DB->get_record('task_scheduled', array('classname' => $classname));
832        if ($record) {
833            $record->lastruntime = time();
834            $record->faildelay = 0;
835            $record->nextruntime = $task->get_next_scheduled_time();
836
837            $DB->update_record('task_scheduled', $record);
838        }
839
840        // Reschedule and then release the locks.
841        if ($task->is_blocking()) {
842            $task->get_cron_lock()->release();
843        }
844        $task->get_lock()->release();
845    }
846
847    /**
848     * This function is used to indicate that any long running cron processes should exit at the
849     * next opportunity and restart. This is because something (e.g. DB changes) has changed and
850     * the static caches may be stale.
851     */
852    public static function clear_static_caches() {
853        global $DB;
854        // Do not use get/set config here because the caches cannot be relied on.
855        $record = $DB->get_record('config', array('name'=>'scheduledtaskreset'));
856        if ($record) {
857            $record->value = time();
858            $DB->update_record('config', $record);
859        } else {
860            $record = new \stdClass();
861            $record->name = 'scheduledtaskreset';
862            $record->value = time();
863            $DB->insert_record('config', $record);
864        }
865    }
866
867    /**
868     * Return true if the static caches have been cleared since $starttime.
869     * @param int $starttime The time this process started.
870     * @return boolean True if static caches need resetting.
871     */
872    public static function static_caches_cleared_since($starttime) {
873        global $DB;
874        $record = $DB->get_record('config', array('name'=>'scheduledtaskreset'));
875        return $record && (intval($record->value) > $starttime);
876    }
877
878    /**
879     * Gets class name for use in database table. Always begins with a \.
880     *
881     * @param string|task_base $taskorstring Task object or a string
882     */
883    protected static function get_canonical_class_name($taskorstring) {
884        if (is_string($taskorstring)) {
885            $classname = $taskorstring;
886        } else {
887            $classname = get_class($taskorstring);
888        }
889        if (strpos($classname, '\\') !== 0) {
890            $classname = '\\' . $classname;
891        }
892        return $classname;
893    }
894
895    /**
896     * Gets the concurrent lock required to run an adhoc task.
897     *
898     * @param   adhoc_task $task The task to obtain the lock for
899     * @return  \core\lock\lock The lock if one was obtained successfully
900     * @throws  \coding_exception
901     */
902    protected static function get_concurrent_task_lock(adhoc_task $task): ?\core\lock\lock {
903        $adhoclock = null;
904        $cronlockfactory = \core\lock\lock_config::get_lock_factory(get_class($task));
905
906        for ($run = 0; $run < $task->get_concurrency_limit(); $run++) {
907            if ($adhoclock = $cronlockfactory->get_lock("concurrent_run_{$run}", 0)) {
908                return $adhoclock;
909            }
910        }
911
912        return null;
913    }
914
915    /**
916     * Find the path of PHP CLI binary.
917     *
918     * @return string|false The PHP CLI executable PATH
919     */
920    protected static function find_php_cli_path() {
921        global $CFG;
922
923        if (!empty($CFG->pathtophp) && is_executable(trim($CFG->pathtophp))) {
924            return $CFG->pathtophp;
925        }
926
927        return false;
928    }
929
930    /**
931     * Returns if Moodle have access to PHP CLI binary or not.
932     *
933     * @return bool
934     */
935    public static function is_runnable():bool {
936        return self::find_php_cli_path() !== false;
937    }
938
939    /**
940     * Executes a cron from web invocation using PHP CLI.
941     *
942     * @param \core\task\task_base $task Task that be executed via CLI.
943     * @return bool
944     * @throws \moodle_exception
945     */
946    public static function run_from_cli(\core\task\task_base $task):bool {
947        global $CFG;
948
949        if (!self::is_runnable()) {
950            $redirecturl = new \moodle_url('/admin/settings.php', ['section' => 'systempaths']);
951            throw new \moodle_exception('cannotfindthepathtothecli', 'core_task', $redirecturl->out());
952        } else {
953            // Shell-escaped path to the PHP binary.
954            $phpbinary = escapeshellarg(self::find_php_cli_path());
955
956            // Shell-escaped path CLI script.
957            $pathcomponents = [$CFG->dirroot, $CFG->admin, 'cli', 'scheduled_task.php'];
958            $scriptpath     = escapeshellarg(implode(DIRECTORY_SEPARATOR, $pathcomponents));
959
960            // Shell-escaped task name.
961            $classname = get_class($task);
962            $taskarg   = escapeshellarg("--execute={$classname}");
963
964            // Build the CLI command.
965            $command = "{$phpbinary} {$scriptpath} {$taskarg}";
966
967            // Execute it.
968            passthru($command);
969        }
970
971        return true;
972    }
973}
974