Proyectos de Subversion Moodle

Rev

Rev 1 | | Comparar con el anterior | Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 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
 */
25
namespace core\task;
26
 
27
use core\lock\lock;
28
use core\lock\lock_factory;
29
use core_shutdown_manager;
30
 
31
define('CORE_TASK_TASKS_FILENAME', 'db/tasks.php');
32
/**
33
 * Collection of task related methods.
34
 *
35
 * Some locking rules for this class:
36
 * All changes to scheduled tasks must be protected with both - the global cron lock and the lock
37
 * for the specific scheduled task (in that order). Locks must be released in the reverse order.
38
 * @copyright  2013 Damyon Wiese
39
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
40
 */
41
class manager {
42
 
43
    /**
44
     * @var int Used to tell the adhoc task queue to fairly distribute tasks.
45
     */
46
    const ADHOC_TASK_QUEUE_MODE_DISTRIBUTING = 0;
47
 
48
    /**
49
     * @var int Used to tell the adhoc task queue to try and fill unused capacity.
50
     */
51
    const ADHOC_TASK_QUEUE_MODE_FILLING = 1;
52
 
53
    /**
54
     * @var int Used to set the retention period for adhoc tasks that have failed and to be cleaned up.
55
     * The number is in week unit. The default value is 4 weeks.
56
     */
57
    const ADHOC_TASK_FAILED_RETENTION = 4 * WEEKSECS;
58
 
59
    /**
60
     * @var ?task_base $runningtask Used to tell what is the current running task in this process.
61
     */
62
    public static ?task_base $runningtask = null;
63
 
64
    /**
65
     * @var bool Used to tell if the manager's shutdown callback has been registered.
66
     */
67
    public static bool $registeredshutdownhandler = false;
68
 
69
    /**
70
     * @var array A cached queue of adhoc tasks
71
     */
72
    protected static array $miniqueue = [];
73
 
74
    /**
75
     * @var int The last recorded number of unique adhoc tasks.
76
     */
77
    protected static int $numtasks = 0;
78
 
79
    /**
80
     * @var null|int Used to determine if the adhoc task queue is distributing or filling capacity.
81
     */
82
    protected static ?int $mode = null;
83
 
84
    /**
85
     * Reset the state of the task manager.
86
     */
87
    public static function reset_state(): void {
88
        self::$miniqueue = [];
89
        self::$numtasks = 0;
90
        self::$mode = null;
91
    }
92
 
93
    /**
94
     * Given a component name, will load the list of tasks in the db/tasks.php file for that component.
95
     *
96
     * @param string $componentname - The name of the component to fetch the tasks for.
97
     * @param bool $expandr - if true (default) an 'R' value in a time is expanded to an appropriate int.
98
     *      If false, they are left as 'R'
99
     * @return \core\task\scheduled_task[] - List of scheduled tasks for this component.
100
     */
101
    public static function load_default_scheduled_tasks_for_component($componentname, $expandr = true) {
102
        $dir = \core_component::get_component_directory($componentname);
103
 
104
        if (!$dir) {
105
            return array();
106
        }
107
 
108
        $file = $dir . '/' . CORE_TASK_TASKS_FILENAME;
109
        if (!file_exists($file)) {
110
            return array();
111
        }
112
 
113
        $tasks = null;
114
        include($file);
115
 
116
        if (!isset($tasks)) {
117
            return array();
118
        }
119
 
120
        $scheduledtasks = array();
121
 
122
        foreach ($tasks as $task) {
123
            $record = (object) $task;
124
            $scheduledtask = self::scheduled_task_from_record($record, $expandr, false);
125
            // Safety check in case the task in the DB does not match a real class (maybe something was uninstalled).
126
            if ($scheduledtask) {
127
                $scheduledtask->set_component($componentname);
128
                $scheduledtasks[] = $scheduledtask;
129
            }
130
        }
131
 
132
        return $scheduledtasks;
133
    }
134
 
135
    /**
136
     * Update the database to contain a list of scheduled task for a component.
137
     * The list of scheduled tasks is taken from @load_scheduled_tasks_for_component.
138
     * Will throw exceptions for any errors.
139
     *
140
     * @param string $componentname - The frankenstyle component name.
141
     */
142
    public static function reset_scheduled_tasks_for_component($componentname) {
143
        global $DB;
144
        $tasks = self::load_default_scheduled_tasks_for_component($componentname);
145
        $validtasks = array();
146
 
147
        foreach ($tasks as $taskid => $task) {
148
            $classname = self::get_canonical_class_name($task);
149
 
150
            $validtasks[] = $classname;
151
 
152
            if ($currenttask = self::get_scheduled_task($classname)) {
153
                if ($currenttask->is_customised()) {
154
                    // If there is an existing task with a custom schedule, do not override it.
155
                    continue;
156
                }
157
 
158
                // Update the record from the default task data.
159
                self::configure_scheduled_task($task);
160
            } else {
161
                // Ensure that the first run follows the schedule.
162
                $task->set_next_run_time($task->get_next_scheduled_time());
163
 
164
                // Insert the new task in the database.
165
                $record = self::record_from_scheduled_task($task);
166
                $DB->insert_record('task_scheduled', $record);
167
            }
168
        }
169
 
170
        // Delete any task that is not defined in the component any more.
171
        $sql = "component = :component";
172
        $params = array('component' => $componentname);
173
        if (!empty($validtasks)) {
174
            list($insql, $inparams) = $DB->get_in_or_equal($validtasks, SQL_PARAMS_NAMED, 'param', false);
175
            $sql .= ' AND classname ' . $insql;
176
            $params = array_merge($params, $inparams);
177
        }
178
        $DB->delete_records_select('task_scheduled', $sql, $params);
179
    }
180
 
181
    /**
182
     * Checks if the task with the same classname, component and customdata is already scheduled
183
     *
184
     * @param adhoc_task $task
185
     * @return bool
186
     */
187
    protected static function task_is_scheduled($task) {
188
        return false !== self::get_queued_adhoc_task_record($task);
189
    }
190
 
191
    /**
192
     * Checks if the task with the same classname, component and customdata is already scheduled
193
     *
194
     * @param adhoc_task $task
195
     * @return \stdClass|false
196
     */
197
    protected static function get_queued_adhoc_task_record($task) {
198
        global $DB;
199
 
200
        $record = self::record_from_adhoc_task($task);
201
        $params = [$record->classname, $record->component, $record->customdata];
202
        $sql = 'classname = ? AND component = ? AND ' .
203
            $DB->sql_compare_text('customdata', \core_text::strlen($record->customdata) + 1) . ' = ?';
204
 
205
        if ($record->userid) {
206
            $params[] = $record->userid;
207
            $sql .= " AND userid = ? ";
208
        }
209
        return $DB->get_record_select('task_adhoc', $sql, $params);
210
    }
211
 
212
    /**
213
     * Schedule a new task, or reschedule an existing adhoc task which has matching data.
214
     *
215
     * Only a task matching the same user, classname, component, and customdata will be rescheduled.
216
     * If these values do not match exactly then a new task is scheduled.
217
     *
218
     * @param \core\task\adhoc_task $task - The new adhoc task information to store.
219
     * @since Moodle 3.7
220
     */
221
    public static function reschedule_or_queue_adhoc_task(adhoc_task $task): void {
222
        global $DB;
223
 
224
        if ($existingrecord = self::get_queued_adhoc_task_record($task)) {
225
            // Only update the next run time if it is explicitly set on the task.
226
            $nextruntime = $task->get_next_run_time();
227
            if ($nextruntime && ($existingrecord->nextruntime != $nextruntime)) {
228
                $DB->set_field('task_adhoc', 'nextruntime', $nextruntime, ['id' => $existingrecord->id]);
229
            }
230
        } else {
231
            // There is nothing queued yet. Just queue as normal.
232
            self::queue_adhoc_task($task);
233
        }
234
    }
235
 
236
    /**
237
     * Queue an adhoc task to run in the background.
238
     *
239
     * @param \core\task\adhoc_task $task - The new adhoc task information to store.
240
     * @param bool $checkforexisting - If set to true and the task with the same user, classname, component and customdata
241
     *     is already scheduled then it will not schedule a new task. Can be used only for ASAP tasks.
242
     * @return boolean - True if the config was saved.
243
     */
244
    public static function queue_adhoc_task(adhoc_task $task, $checkforexisting = false) {
245
        global $DB;
246
 
247
        if ($userid = $task->get_userid()) {
248
            // User found. Check that they are suitable.
249
            \core_user::require_active_user(\core_user::get_user($userid, '*', MUST_EXIST), true, true);
250
        }
251
 
252
        $record = self::record_from_adhoc_task($task);
253
        // Schedule it immediately if nextruntime not explicitly set.
254
        if (!$task->get_next_run_time()) {
255
            $record->nextruntime = time() - 1;
256
        }
257
 
258
        // Check if the task is allowed to be retried or not.
259
        $record->attemptsavailable = $task->retry_until_success() ? $record->attemptsavailable : 1;
260
        // Set the time the task was created.
261
        $record->timecreated = time();
262
 
263
        // Check if the same task is already scheduled.
264
        if ($checkforexisting && self::task_is_scheduled($task)) {
265
            return false;
266
        }
267
 
268
        // Queue the task.
269
        $result = $DB->insert_record('task_adhoc', $record);
270
 
271
        return $result;
272
    }
273
 
274
    /**
275
     * Change the default configuration for a scheduled task.
276
     * The list of scheduled tasks is taken from {@link load_scheduled_tasks_for_component}.
277
     *
278
     * @param \core\task\scheduled_task $task - The new scheduled task information to store.
279
     * @return boolean - True if the config was saved.
280
     */
281
    public static function configure_scheduled_task(scheduled_task $task) {
282
        global $DB;
283
 
284
        $classname = self::get_canonical_class_name($task);
285
 
286
        $original = $DB->get_record('task_scheduled', array('classname'=>$classname), 'id', MUST_EXIST);
287
 
288
        $record = self::record_from_scheduled_task($task);
289
        $record->id = $original->id;
290
        $record->nextruntime = $task->get_next_scheduled_time();
291
        unset($record->lastruntime);
292
        $result = $DB->update_record('task_scheduled', $record);
293
 
294
        return $result;
295
    }
296
 
297
    /**
298
     * Utility method to create a DB record from a scheduled task.
299
     *
300
     * @param \core\task\scheduled_task $task
301
     * @return \stdClass
302
     */
303
    public static function record_from_scheduled_task($task) {
304
        $record = new \stdClass();
305
        $record->classname = self::get_canonical_class_name($task);
306
        $record->component = $task->get_component();
307
        $record->customised = $task->is_customised();
308
        $record->lastruntime = $task->get_last_run_time();
309
        $record->nextruntime = $task->get_next_run_time();
310
        $record->faildelay = $task->get_fail_delay();
311
        $record->hour = $task->get_hour();
312
        $record->minute = $task->get_minute();
313
        $record->day = $task->get_day();
314
        $record->dayofweek = $task->get_day_of_week();
315
        $record->month = $task->get_month();
316
        $record->disabled = $task->get_disabled();
317
        $record->timestarted = $task->get_timestarted();
318
        $record->hostname = $task->get_hostname();
319
        $record->pid = $task->get_pid();
320
 
321
        return $record;
322
    }
323
 
324
    /**
325
     * Utility method to create a DB record from an adhoc task.
326
     *
327
     * @param \core\task\adhoc_task $task
328
     * @return \stdClass
329
     */
330
    public static function record_from_adhoc_task($task) {
331
        $record = new \stdClass();
332
        $record->classname = self::get_canonical_class_name($task);
333
        $record->id = $task->get_id();
334
        $record->component = $task->get_component();
335
        $record->nextruntime = $task->get_next_run_time();
336
        $record->faildelay = $task->get_fail_delay();
337
        $record->customdata = $task->get_custom_data_as_string();
338
        $record->userid = $task->get_userid();
339
        $record->timestarted = $task->get_timestarted();
340
        $record->hostname = $task->get_hostname();
341
        $record->pid = $task->get_pid();
342
        $record->attemptsavailable = $task->get_attempts_available();
343
 
344
        return $record;
345
    }
346
 
347
    /**
348
     * Utility method to create an adhoc task from a DB record.
349
     *
350
     * @param \stdClass $record
351
     * @return \core\task\adhoc_task
352
     * @throws \moodle_exception
353
     */
354
    public static function adhoc_task_from_record($record) {
355
        $classname = self::get_canonical_class_name($record->classname);
356
        if (!class_exists($classname)) {
357
            throw new \moodle_exception('invalidtaskclassname', '', '', $record->classname);
358
        }
359
        $task = new $classname;
360
        if (isset($record->nextruntime)) {
361
            $task->set_next_run_time($record->nextruntime);
362
        }
363
        if (isset($record->id)) {
364
            $task->set_id($record->id);
365
        }
366
        if (isset($record->component)) {
367
            $task->set_component($record->component);
368
        }
369
        if (isset($record->faildelay)) {
370
            $task->set_fail_delay($record->faildelay);
371
        }
372
        if (isset($record->customdata)) {
373
            $task->set_custom_data_as_string($record->customdata);
374
        }
375
 
376
        if (isset($record->userid)) {
377
            $task->set_userid($record->userid);
378
        }
379
        if (isset($record->timestarted)) {
380
            $task->set_timestarted($record->timestarted);
381
        }
382
        if (isset($record->hostname)) {
383
            $task->set_hostname($record->hostname);
384
        }
385
        if (isset($record->pid)) {
386
            $task->set_pid($record->pid);
387
        }
388
        if (isset($record->attemptsavailable)) {
389
            $task->set_attempts_available($record->attemptsavailable);
390
        }
391
 
392
        return $task;
393
    }
394
 
395
    /**
396
     * Utility method to create a task from a DB record.
397
     *
398
     * @param \stdClass $record
399
     * @param bool $expandr - if true (default) an 'R' value in a time is expanded to an appropriate int.
400
     *      If false, they are left as 'R'
401
     * @param bool $override - if true loads overridden settings from config.
402
     * @return \core\task\scheduled_task|false
403
     */
404
    public static function scheduled_task_from_record($record, $expandr = true, $override = true) {
405
        $classname = self::get_canonical_class_name($record->classname);
406
        if (!class_exists($classname)) {
407
            debugging("Failed to load task: " . $classname, DEBUG_DEVELOPER);
408
            return false;
409
        }
410
        /** @var \core\task\scheduled_task $task */
411
        $task = new $classname;
412
 
413
        if ($override) {
414
            // Update values with those defined in the config, if any are set.
415
            $record = self::get_record_with_config_overrides($record);
416
        }
417
 
418
        if (isset($record->lastruntime)) {
419
            $task->set_last_run_time($record->lastruntime);
420
        }
421
        if (isset($record->nextruntime)) {
422
            $task->set_next_run_time($record->nextruntime);
423
        }
424
        if (isset($record->customised)) {
425
            $task->set_customised($record->customised);
426
        }
427
        if (isset($record->component)) {
428
            $task->set_component($record->component);
429
        }
430
        if (isset($record->minute)) {
431
            $task->set_minute($record->minute, $expandr);
432
        }
433
        if (isset($record->hour)) {
434
            $task->set_hour($record->hour, $expandr);
435
        }
436
        if (isset($record->day)) {
437
            $task->set_day($record->day);
438
        }
439
        if (isset($record->month)) {
440
            $task->set_month($record->month);
441
        }
442
        if (isset($record->dayofweek)) {
443
            $task->set_day_of_week($record->dayofweek, $expandr);
444
        }
445
        if (isset($record->faildelay)) {
446
            $task->set_fail_delay($record->faildelay);
447
        }
448
        if (isset($record->disabled)) {
449
            $task->set_disabled($record->disabled);
450
        }
451
        if (isset($record->timestarted)) {
452
            $task->set_timestarted($record->timestarted);
453
        }
454
        if (isset($record->hostname)) {
455
            $task->set_hostname($record->hostname);
456
        }
457
        if (isset($record->pid)) {
458
            $task->set_pid($record->pid);
459
        }
460
        $task->set_overridden(self::scheduled_task_has_override($classname));
461
 
462
        return $task;
463
    }
464
 
465
    /**
466
     * Given a component name, will load the list of tasks from the scheduled_tasks table for that component.
467
     * Do not execute tasks loaded from this function - they have not been locked.
468
     * @param string $componentname - The name of the component to load the tasks for.
469
     * @return \core\task\scheduled_task[]
470
     */
471
    public static function load_scheduled_tasks_for_component($componentname) {
472
        global $DB;
473
 
474
        $tasks = array();
475
        // We are just reading - so no locks required.
476
        $records = $DB->get_records('task_scheduled', array('component' => $componentname), 'classname', '*', IGNORE_MISSING);
477
        foreach ($records as $record) {
478
            $task = self::scheduled_task_from_record($record);
479
            // Safety check in case the task in the DB does not match a real class (maybe something was uninstalled).
480
            if ($task) {
481
                $tasks[] = $task;
482
            }
483
        }
484
 
485
        return $tasks;
486
    }
487
 
488
    /**
489
     * This function load the scheduled task details for a given classname.
490
     *
491
     * @param string $classname
492
     * @return \core\task\scheduled_task or false
493
     */
494
    public static function get_scheduled_task($classname) {
495
        global $DB;
496
 
497
        $classname = self::get_canonical_class_name($classname);
498
        // We are just reading - so no locks required.
499
        $record = $DB->get_record('task_scheduled', array('classname'=>$classname), '*', IGNORE_MISSING);
500
        if (!$record) {
501
            return false;
502
        }
503
        return self::scheduled_task_from_record($record);
504
    }
505
 
506
    /**
507
     * This function load the adhoc tasks for a given classname.
508
     *
509
     * @param string $classname
510
     * @param bool $failedonly
511
     * @param bool $skiprunning do not return tasks that are in the running state
512
     * @return array
513
     */
514
    public static function get_adhoc_tasks(string $classname, bool $failedonly = false, bool $skiprunning = false): array {
515
        global $DB;
516
 
517
        $conds[] = 'classname = ?';
518
        $params[] = self::get_canonical_class_name($classname);
519
 
520
        if ($failedonly) {
521
            $conds[] = 'faildelay > 0';
522
        }
523
        if ($skiprunning) {
524
            $conds[] = 'timestarted IS NULL';
525
        }
526
 
527
        // We are just reading - so no locks required.
528
        $sql = 'SELECT * FROM {task_adhoc}';
529
        if ($conds) {
530
            $sql .= ' WHERE '.implode(' AND ', $conds);
531
        }
532
        $rs = $DB->get_records_sql($sql, $params);
533
        return array_map(function($record) {
534
            return self::adhoc_task_from_record($record);
535
        }, $rs);
536
    }
537
 
538
    /**
539
     * This function returns adhoc tasks summary per component classname
540
     *
541
     * @return array
542
     */
543
    public static function get_adhoc_tasks_summary(): array {
544
        global $DB;
545
 
546
        $now = time();
547
        $records = $DB->get_records('task_adhoc');
548
        $summary = [];
549
        foreach ($records as $r) {
550
            if (!isset($summary[$r->component])) {
551
                $summary[$r->component] = [];
552
            }
553
 
554
            if (isset($summary[$r->component][$r->classname])) {
555
                $classsummary = $summary[$r->component][$r->classname];
556
            } else {
557
                $classsummary = [
558
                    'nextruntime' => null,
559
                    'count' => 0,
560
                    'failed' => 0,
561
                    'running' => 0,
562
                    'due' => 0,
563
                    'stop' => false,
564
                ];
565
            }
566
 
567
            $classsummary['count']++;
568
            $nextruntime = (int)$r->nextruntime;
569
            if (!$classsummary['nextruntime'] || $nextruntime < $classsummary['nextruntime']) {
570
                $classsummary['nextruntime'] = $nextruntime;
571
            }
572
 
573
            if ((int)$r->timestarted > 0) {
574
                $classsummary['running']++;
575
            } else {
576
                if ((int)$r->faildelay > 0) {
577
                    $classsummary['failed']++;
578
                }
579
 
580
                if ($nextruntime <= $now) {
581
                    $classsummary['due']++;
582
                }
583
            }
584
 
585
            // Mark the task as stopped if it has no attempts available.
586
            if (isset($r->attemptsavailable) && $r->attemptsavailable == 0) {
587
                $classsummary['stop'] = true;
588
            }
589
 
590
            $summary[$r->component][$r->classname] = $classsummary;
591
        }
592
        return $summary;
593
    }
594
 
595
    /**
596
     * This function load the default scheduled task details for a given classname.
597
     *
598
     * @param string $classname
599
     * @param bool $expandr - if true (default) an 'R' value in a time is expanded to an appropriate int.
600
     *      If false, they are left as 'R'
601
     * @return \core\task\scheduled_task|false
602
     */
603
    public static function get_default_scheduled_task($classname, $expandr = true) {
604
        $task = self::get_scheduled_task($classname);
605
        $componenttasks = array();
606
 
607
        // Safety check in case no task was found for the given classname.
608
        if ($task) {
609
            $componenttasks = self::load_default_scheduled_tasks_for_component(
610
                    $task->get_component(), $expandr);
611
        }
612
 
613
        foreach ($componenttasks as $componenttask) {
614
            if (get_class($componenttask) == get_class($task)) {
615
                return $componenttask;
616
            }
617
        }
618
 
619
        return false;
620
    }
621
 
622
    /**
623
     * This function will return a list of all the scheduled tasks that exist in the database.
624
     *
625
     * @return \core\task\scheduled_task[]
626
     */
627
    public static function get_all_scheduled_tasks() {
628
        global $DB;
629
 
630
        $records = $DB->get_records('task_scheduled', null, 'component, classname', '*', IGNORE_MISSING);
631
        $tasks = array();
632
 
633
        foreach ($records as $record) {
634
            $task = self::scheduled_task_from_record($record);
635
            // Safety check in case the task in the DB does not match a real class (maybe something was uninstalled).
636
            if ($task) {
637
                $tasks[] = $task;
638
            }
639
        }
640
 
641
        return $tasks;
642
    }
643
 
644
    /**
645
     * This function will return a list of all adhoc tasks that have a faildelay
646
     *
647
     * @param int $delay filter how long the task has been delayed
648
     * @return \core\task\adhoc_task[]
649
     */
650
    public static function get_failed_adhoc_tasks(int $delay = 0): array {
651
        global $DB;
652
 
653
        $tasks = [];
654
        $records = $DB->get_records_sql('SELECT * from {task_adhoc} WHERE faildelay > ?', [$delay]);
655
 
656
        foreach ($records as $record) {
657
            try {
658
                $tasks[] = self::adhoc_task_from_record($record);
659
            } catch (\moodle_exception $e) {
660
                debugging("Failed to load task: $record->classname", DEBUG_DEVELOPER, $e->getTrace());
661
            }
662
        }
663
        return $tasks;
664
    }
665
 
666
    /**
667
     * Ensure quality of service for the ad hoc task queue.
668
     *
669
     * This reshuffles the adhoc tasks queue to balance by type to ensure a
670
     * level of quality of service per type, while still maintaining the
671
     * relative order of tasks queued by timestamp.
672
     *
673
     * @param array $records array of task records
674
     * @param array $records array of same task records shuffled
675
     * @deprecated since Moodle 4.1 MDL-67648 - please do not use this method anymore.
676
     * @todo MDL-74843 This method will be deleted in Moodle 4.5
677
     * @see \core\task\manager::get_next_adhoc_task
678
     */
679
    public static function ensure_adhoc_task_qos(array $records): array {
680
        debugging('The method \core\task\manager::ensure_adhoc_task_qos is deprecated.
681
             Please use \core\task\manager::get_next_adhoc_task instead.', DEBUG_DEVELOPER);
682
 
683
        $count = count($records);
684
        if ($count == 0) {
685
            return $records;
686
        }
687
 
688
        $queues = []; // This holds a queue for each type of adhoc task.
689
        $limits = []; // The relative limits of each type of task.
690
        $limittotal = 0;
691
 
692
        // Split the single queue up into queues per type.
693
        foreach ($records as $record) {
694
            $type = $record->classname;
695
            if (!array_key_exists($type, $queues)) {
696
                $queues[$type] = [];
697
            }
698
            if (!array_key_exists($type, $limits)) {
699
                $limits[$type] = 1;
700
                $limittotal += 1;
701
            }
702
            $queues[$type][] = $record;
703
        }
704
 
705
        $qos = []; // Our new queue with ensured quality of service.
706
        $seed = $count % $limittotal; // Which task queue to shuffle from first?
707
 
708
        $move = 1; // How many tasks to shuffle at a time.
709
        do {
710
            $shuffled = 0;
711
 
712
            // Now cycle through task type queues and interleaving the tasks
713
            // back into a single queue.
714
            foreach ($limits as $type => $limit) {
715
 
716
                // Just interleaving the queue is not enough, because after
717
                // any task is processed the whole queue is rebuilt again. So
718
                // we need to deterministically start on different types of
719
                // tasks so that *on average* we rotate through each type of task.
720
                //
721
                // We achieve this by using a $seed to start moving tasks off a
722
                // different queue each time. The seed is based on the task count
723
                // modulo the number of types of tasks on the queue. As we count
724
                // down this naturally cycles through each type of record.
725
                if ($seed < 1) {
726
                    $shuffled = 1;
727
                    $seed += 1;
728
                    continue;
729
                }
730
                $tasks = array_splice($queues[$type], 0, $move);
731
                $qos = array_merge($qos, $tasks);
732
 
733
                // Stop if we didn't move any tasks onto the main queue.
734
                $shuffled += count($tasks);
735
            }
736
            // Generally the only tasks that matter are those that are near the start so
737
            // after we have shuffled the first few 1 by 1, start shuffling larger groups.
738
            if (count($qos) >= (4 * count($limits))) {
739
                $move *= 2;
740
            }
741
        } while ($shuffled > 0);
742
 
743
        return $qos;
744
    }
745
 
746
    /**
747
     * This function will dispatch the next adhoc task in the queue. The task will be handed out
748
     * with an open lock - possibly on the entire cron process. Make sure you call either
749
     * {@link adhoc_task_failed} or {@link adhoc_task_complete} to release the lock and reschedule the task.
750
     *
751
     * @param int $timestart
752
     * @param bool $checklimits Should we check limits?
753
     * @param string|null $classname Return only task of this class
754
     * @return \core\task\adhoc_task|null
755
     * @throws \moodle_exception
756
     */
757
    public static function get_next_adhoc_task(int $timestart, ?bool $checklimits = true, ?string $classname = null): ?adhoc_task {
758
        global $DB;
759
 
760
        $concurrencylimit = get_config('core', 'task_adhoc_concurrency_limit');
761
        $cachedqueuesize = 1200;
762
 
763
        $uniquetasksinqueue = array_map(
764
            ['\core\task\manager', 'adhoc_task_from_record'],
765
            $DB->get_records_sql(
766
                'SELECT classname FROM {task_adhoc} WHERE nextruntime < :timestart GROUP BY classname',
767
                ['timestart' => $timestart]
768
            )
769
        );
770
 
771
        if (!isset(self::$numtasks) || self::$numtasks !== count($uniquetasksinqueue)) {
772
            self::$numtasks = count($uniquetasksinqueue);
773
            self::$miniqueue = [];
774
        }
775
 
776
        $concurrencylimits = [];
777
        if ($checklimits) {
778
            $concurrencylimits = array_map(
779
                function ($task) {
780
                    return $task->get_concurrency_limit();
781
                },
782
                $uniquetasksinqueue
783
            );
784
        }
785
 
786
        /*
787
         * The maximum number of cron runners that an individual task is allowed to use.
788
         * For example if the concurrency limit is 20 and there are 5 unique types of tasks
789
         * in the queue, each task should not be allowed to consume more than 3 (i.e., ⌊20/6⌋).
790
         * The + 1 is needed to prevent the queue from becoming full of only one type of class.
791
         * i.e., if it wasn't there and there were 20 tasks of the same type in the queue, every
792
         * runner would become consumed with the same (potentially long-running task) and no more
793
         * tasks can run. This way, some resources are always available if some new types
794
         * of tasks enter the queue.
795
         *
796
         * We use the short-ternary to force the value to 1 in the case when the number of tasks
797
         * exceeds the runners (e.g., there are 8 tasks and 4 runners, ⌊4/(8+1)⌋ = 0).
798
         */
799
        $slots = floor($concurrencylimit / (count($uniquetasksinqueue) + 1)) ?: 1;
800
        if (empty(self::$miniqueue)) {
801
            self::$mode = self::ADHOC_TASK_QUEUE_MODE_DISTRIBUTING;
802
            self::$miniqueue = self::get_candidate_adhoc_tasks(
803
                $timestart,
804
                $cachedqueuesize,
805
                $slots,
806
                $concurrencylimits
807
            );
808
        }
809
 
810
        // The query to cache tasks is expensive on big data sets, so we use this cheap
811
        // query to get the ordering (which is the interesting part about the main query)
812
        // We can use this information to filter the cache and also order it.
813
        $runningtasks = $DB->get_records_sql(
814
            'SELECT classname, COALESCE(COUNT(*), 0) running, MIN(timestarted) earliest
815
               FROM {task_adhoc}
816
              WHERE timestarted IS NOT NULL
817
                    AND (attemptsavailable > 0 OR attemptsavailable IS NULL)
818
                    AND nextruntime < :timestart
819
           GROUP BY classname
820
           ORDER BY running ASC, earliest DESC',
821
            ['timestart' => $timestart]
822
        );
823
 
824
        /*
825
         * Each runner has a cache, so the same task can be in multiple runners' caches.
826
         * We need to check that each task we have cached hasn't gone over its fair number
827
         * of slots. This filtering is only applied during distributing mode as when we are
828
         * filling capacity we intend for fast tasks to go over their slot limit.
829
         */
830
        if (self::$mode === self::ADHOC_TASK_QUEUE_MODE_DISTRIBUTING) {
831
            self::$miniqueue = array_filter(
832
                self::$miniqueue,
833
                function (\stdClass $task) use ($runningtasks, $slots) {
834
                    return !array_key_exists($task->classname, $runningtasks) || $runningtasks[$task->classname]->running < $slots;
835
                }
836
            );
837
        }
838
 
839
        /*
840
         * If this happens that means each task has consumed its fair share of capacity, but there's still
841
         * runners left over (and we are one of them). Fetch tasks without checking slot limits.
842
         */
843
        if (empty(self::$miniqueue) && array_sum(array_column($runningtasks, 'running')) < $concurrencylimit) {
844
            self::$mode = self::ADHOC_TASK_QUEUE_MODE_FILLING;
845
            self::$miniqueue = self::get_candidate_adhoc_tasks(
846
                $timestart,
847
                $cachedqueuesize,
848
                false,
849
                $concurrencylimits
850
            );
851
        }
852
 
853
        // Used below to order the cache.
854
        $ordering = array_flip(array_keys($runningtasks));
855
 
856
        // Order the queue so it's consistent with the ordering from the DB.
857
        usort(
858
            self::$miniqueue,
859
            function ($a, $b) use ($ordering) {
860
                return ($ordering[$a->classname] ?? -1) - ($ordering[$b->classname] ?? -1);
861
            }
862
        );
863
 
864
        $cronlockfactory = \core\lock\lock_config::get_lock_factory('cron');
865
 
866
        $skipclasses = array();
867
 
868
        foreach (self::$miniqueue as $taskid => $record) {
869
 
870
            if (!empty($classname) && $record->classname != self::get_canonical_class_name($classname)) {
871
                // Skip the task if The class is specified, and doesn't match.
872
                continue;
873
            }
874
 
875
            if (in_array($record->classname, $skipclasses)) {
876
                // Skip the task if it can't be started due to per-task concurrency limit.
877
                continue;
878
            }
879
 
880
            if ($lock = $cronlockfactory->get_lock('adhoc_' . $record->id, 0)) {
881
 
11 efrain 882
                // Safety check, see if the task has already been processed by another cron run (or attempted and failed).
883
                // If another cron run attempted to process the task and failed, nextruntime will be in the future.
884
                $record = $DB->get_record_select('task_adhoc',
885
                    "id = :id AND nextruntime < :timestart",
886
                    ['id' => $record->id, 'timestart' => $timestart]);
1 efrain 887
                if (!$record) {
888
                    $lock->release();
889
                    unset(self::$miniqueue[$taskid]);
890
                    continue;
891
                }
892
 
893
                // Safety check in case the task in the DB does not match a real class (maybe something was uninstalled).
894
                try {
895
                    $task = self::adhoc_task_from_record($record);
896
                } catch (\moodle_exception $e) {
897
                    debugging("Failed to load task: $record->classname", DEBUG_DEVELOPER);
898
                    $lock->release();
899
                    unset(self::$miniqueue[$taskid]);
900
                    continue;
901
                }
902
 
903
                $tasklimit = $task->get_concurrency_limit();
904
                if ($checklimits && $tasklimit > 0) {
905
                    if ($concurrencylock = self::get_concurrent_task_lock($task)) {
906
                        $task->set_concurrency_lock($concurrencylock);
907
                    } else {
908
                        // Unable to obtain a concurrency lock.
909
                        mtrace("Skipping $record->classname adhoc task class as the per-task limit of $tasklimit is reached.");
910
                        $skipclasses[] = $record->classname;
911
                        unset(self::$miniqueue[$taskid]);
912
                        $lock->release();
913
                        continue;
914
                    }
915
                }
916
 
917
                self::set_locks($task, $lock, $cronlockfactory);
918
                unset(self::$miniqueue[$taskid]);
919
 
920
                return $task;
921
            } else {
922
                unset(self::$miniqueue[$taskid]);
923
            }
924
        }
925
 
926
        return null;
927
    }
928
 
929
    /**
930
     * Return a list of candidate adhoc tasks to run.
931
     *
932
     * @param int $timestart Only return tasks where nextruntime is less than this value
933
     * @param int $limit Limit the list to this many results
934
     * @param int|null $runmax Only return tasks that have less than this value currently running
935
     * @param array $pertasklimits An array of classname => limit specifying how many instance of a task may be returned
936
     * @return array Array of candidate tasks
937
     */
938
    public static function get_candidate_adhoc_tasks(
939
        int $timestart,
940
        int $limit,
941
        ?int $runmax,
942
        array $pertasklimits = []
943
    ): array {
944
        global $DB;
945
 
946
        $pertaskclauses = array_map(
947
            function (string $class, int $limit, int $index): array {
948
                $limitcheck = $limit > 0 ? " AND COALESCE(run.running, 0) < :running_$index" : "";
949
                $limitparam = $limit > 0 ? ["running_$index" => $limit] : [];
950
 
951
                return [
952
                    "sql" => "(q.classname = :classname_$index" . $limitcheck . ")",
953
                    "params" => ["classname_$index" => $class] + $limitparam
954
                ];
955
            },
956
            array_keys($pertasklimits),
957
            $pertasklimits,
958
            $pertasklimits ? range(1, count($pertasklimits)) : []
959
        );
960
 
961
        $pertasksql = implode(" OR ", array_column($pertaskclauses, 'sql'));
962
        $pertaskparams = $pertaskclauses ? array_merge(...array_column($pertaskclauses, 'params')) : [];
963
 
964
        $params = ['timestart' => $timestart] +
965
                ($runmax ? ['runmax' => $runmax] : []) +
966
                $pertaskparams;
967
 
968
        return $DB->get_records_sql(
969
            "SELECT q.id, q.classname, q.timestarted, COALESCE(run.running, 0) running, run.earliest
970
              FROM {task_adhoc} q
971
         LEFT JOIN (
972
                       SELECT classname, COUNT(*) running, MIN(timestarted) earliest
973
                         FROM {task_adhoc} run
974
                        WHERE timestarted IS NOT NULL
975
                              AND (attemptsavailable > 0 OR attemptsavailable IS NULL)
976
                     GROUP BY classname
977
                   ) run ON run.classname = q.classname
978
             WHERE nextruntime < :timestart
979
                   AND q.timestarted IS NULL
980
                   AND (q.attemptsavailable > 0 OR q.attemptsavailable IS NULL) " .
981
            (!empty($pertasksql) ? "AND (" . $pertasksql . ") " : "") .
982
            ($runmax ? "AND (COALESCE(run.running, 0)) < :runmax " : "") .
983
         "ORDER BY COALESCE(run.running, 0) ASC, run.earliest DESC, q.nextruntime ASC, q.id ASC",
984
            $params,
985
            0,
986
            $limit
987
        );
988
    }
989
 
990
    /**
991
     * This function will get an adhoc task by id. The task will be handed out
992
     * with an open lock - possibly on the entire cron process. Make sure you call either
993
     * {@see ::adhoc_task_failed} or {@see ::adhoc_task_complete} to release the lock and reschedule the task.
994
     *
995
     * @param int $taskid
996
     * @return \core\task\adhoc_task|null
997
     * @throws \moodle_exception
998
     */
999
    public static function get_adhoc_task(int $taskid): ?adhoc_task {
1000
        global $DB;
1001
 
1002
        $record = $DB->get_record('task_adhoc', ['id' => $taskid]);
1003
        if (!$record) {
1004
            throw new \moodle_exception('invalidtaskid');
1005
        }
1006
 
1007
        $cronlockfactory = \core\lock\lock_config::get_lock_factory('cron');
1008
 
1009
        if ($lock = $cronlockfactory->get_lock('adhoc_' . $record->id, 0)) {
1010
            // Safety check in case the task in the DB does not match a real class (maybe something was uninstalled).
1011
            try {
1012
                $task = self::adhoc_task_from_record($record);
1013
            } catch (\moodle_exception $e) {
1014
                $lock->release();
1015
                throw $e;
1016
            }
1017
 
1018
            self::set_locks($task, $lock, $cronlockfactory);
1019
            return $task;
1020
        }
1021
 
1022
        return null;
1023
    }
1024
 
1025
    /**
1026
     * This function will set locks on the task.
1027
     *
1028
     * @param adhoc_task    $task
1029
     * @param lock          $lock task lock
1030
     * @param lock_factory  $cronlockfactory
1031
     * @throws \moodle_exception
1032
     */
1033
    private static function set_locks(adhoc_task $task, lock $lock, lock_factory $cronlockfactory): void {
1034
        // The global cron lock is under the most contention so request it
1035
        // as late as possible and release it as soon as possible.
1036
        if (!$cronlock = $cronlockfactory->get_lock('core_cron', 10)) {
1037
            $lock->release();
1038
            throw new \moodle_exception('locktimeout');
1039
        }
1040
 
1041
        $task->set_lock($lock);
1042
        $cronlock->release();
1043
    }
1044
 
1045
    /**
1046
     * This function will dispatch the next scheduled task in the queue. The task will be handed out
1047
     * with an open lock - possibly on the entire cron process. Make sure you call either
1048
     * {@link scheduled_task_failed} or {@link scheduled_task_complete} to release the lock and reschedule the task.
1049
     *
1050
     * @param int $timestart - The start of the cron process - do not repeat any tasks that have been run more recently than this.
1051
     * @return \core\task\scheduled_task or null
1052
     * @throws \moodle_exception
1053
     */
1054
    public static function get_next_scheduled_task($timestart) {
1055
        global $DB;
1056
        $cronlockfactory = \core\lock\lock_config::get_lock_factory('cron');
1057
 
1058
        $where = "(lastruntime IS NULL OR lastruntime < :timestart1)
1059
                  AND (nextruntime IS NULL OR nextruntime < :timestart2)
1060
                  ORDER BY lastruntime, id ASC";
1061
        $params = array('timestart1' => $timestart, 'timestart2' => $timestart);
1062
        $records = $DB->get_records_select('task_scheduled', $where, $params);
1063
 
1064
        $pluginmanager = \core_plugin_manager::instance();
1065
 
1066
        foreach ($records as $record) {
1067
 
1068
            $task = self::scheduled_task_from_record($record);
1069
            // Safety check in case the task in the DB does not match a real class (maybe something was uninstalled).
1070
            // Also check to see if task is disabled or enabled after applying overrides.
1071
            if (!$task || $task->get_disabled()) {
1072
                continue;
1073
            }
1074
 
1075
            if ($lock = $cronlockfactory->get_lock(($record->classname), 0)) {
1076
                $classname = '\\' . $record->classname;
1077
 
1078
                $task->set_lock($lock);
1079
 
1080
                // See if the component is disabled.
1081
                $plugininfo = $pluginmanager->get_plugin_info($task->get_component());
1082
 
1083
                if ($plugininfo) {
1084
                    if (($plugininfo->is_enabled() === false) && !$task->get_run_if_component_disabled()) {
1085
                        $lock->release();
1086
                        continue;
1087
                    }
1088
                }
1089
 
1090
                if (!self::scheduled_task_has_override($record->classname)) {
1091
                    // Make sure the task data is unchanged unless an override is being used.
1092
                    if (!$DB->record_exists('task_scheduled', (array)$record)) {
1093
                        $lock->release();
1094
                        continue;
1095
                    }
1096
                }
1097
 
1098
                // The global cron lock is under the most contention so request it
1099
                // as late as possible and release it as soon as possible.
1100
                if (!$cronlock = $cronlockfactory->get_lock('core_cron', 10)) {
1101
                    $lock->release();
1102
                    throw new \moodle_exception('locktimeout');
1103
                }
1104
 
1105
                $cronlock->release();
1106
                return $task;
1107
            }
1108
        }
1109
 
1110
        return null;
1111
    }
1112
 
1113
    /**
1114
     * This function will fail the currently running task, if there is one.
1115
     */
1116
    public static function fail_running_task(): void {
1117
        $runningtask = self::$runningtask;
1118
 
1119
        if ($runningtask === null) {
1120
            return;
1121
        }
1122
 
1123
        if ($runningtask instanceof scheduled_task) {
1124
            self::scheduled_task_failed($runningtask);
1125
            return;
1126
        }
1127
 
1128
        if ($runningtask instanceof adhoc_task) {
1129
            self::adhoc_task_failed($runningtask);
1130
            return;
1131
        }
1132
    }
1133
 
1134
    /**
1135
     * This function set's the $runningtask variable and ensures that the shutdown handler is registered.
1136
     * @param task_base $task
1137
     */
1138
    private static function task_starting(task_base $task): void {
1139
        self::$runningtask = $task;
1140
 
1141
        // Add \core\task\manager::fail_running_task to shutdown manager, so we can ensure running tasks fail on shutdown.
1142
        if (!self::$registeredshutdownhandler) {
1143
            core_shutdown_manager::register_function('\core\task\manager::fail_running_task');
1144
 
1145
            self::$registeredshutdownhandler = true;
1146
        }
1147
    }
1148
 
1149
    /**
1150
     * This function indicates that an adhoc task was not completed successfully and should be retried.
1151
     *
1152
     * @param \core\task\adhoc_task $task
1153
     */
1154
    public static function adhoc_task_failed(adhoc_task $task) {
1155
        global $DB;
1156
        // Finalise the log output.
1157
        logmanager::finalise_log(true);
1158
 
1159
        $delay = $task->get_fail_delay();
1160
 
1161
        // Reschedule task with exponential fall off for failing tasks.
1162
        if (empty($delay)) {
1163
            $delay = 60;
1164
        } else {
1165
            $delay *= 2;
1166
        }
1167
 
1168
        // Max of 24 hour delay.
1169
        if ($delay >= 86400) {
1170
            $delay = 86400;
1171
 
1172
            // Dispatch hook when max fail delay has reached.
1173
            $hook = new \core\hook\task\after_failed_task_max_delay(
1174
                task: $task,
1175
            );
1176
            \core\di::get(\core\hook\manager::class)->dispatch($hook);
1177
        }
1178
 
1179
        // Reschedule and then release the locks.
1180
        $task->set_timestarted();
1181
        $task->set_hostname();
1182
        $task->set_pid();
1183
        $task->set_next_run_time(time() + $delay);
1184
        $task->set_fail_delay($delay);
1185
        if ($task->get_attempts_available() > 0) {
1186
            $task->set_attempts_available($task->get_attempts_available() - 1);
1187
        }
1188
        $record = self::record_from_adhoc_task($task);
1189
        $DB->update_record('task_adhoc', $record);
1190
 
1191
        $task->release_concurrency_lock();
1192
        $task->get_lock()->release();
1193
 
1194
        self::$runningtask = null;
1195
    }
1196
 
1197
    /**
1198
     * Records that a adhoc task is starting to run.
1199
     *
1200
     * @param adhoc_task $task Task that is starting
1201
     * @param int $time Start time (leave blank for now)
1202
     * @throws \dml_exception
1203
     * @throws \coding_exception
1204
     */
1205
    public static function adhoc_task_starting(adhoc_task $task, int $time = 0) {
1206
        global $DB;
1207
        $pid = (int)getmypid();
1208
        $hostname = (string)gethostname();
1209
 
1210
        if (empty($time)) {
1211
            $time = time();
1212
        }
1213
 
1214
        $task->set_timestarted($time);
1215
        $task->set_hostname($hostname);
1216
        $task->set_pid($pid);
1217
 
1218
        $record = self::record_from_adhoc_task($task);
1219
 
1220
        // If this is the first time the task has been started, then set the first starting time.
1221
        $firststartingtime = $DB->get_field('task_adhoc', 'firststartingtime', ['id' => $record->id]);
1222
        if (is_null($firststartingtime)) {
1223
            $record->firststartingtime = $time;
1224
        }
1225
 
1226
        $DB->update_record('task_adhoc', $record);
1227
 
1228
        self::task_starting($task);
1229
    }
1230
 
1231
    /**
1232
     * This function indicates that an adhoc task was completed successfully.
1233
     *
1234
     * @param \core\task\adhoc_task $task
1235
     */
1236
    public static function adhoc_task_complete(adhoc_task $task) {
1237
        global $DB;
1238
 
1239
        // Finalise the log output.
1240
        logmanager::finalise_log();
1241
        $task->set_timestarted();
1242
        $task->set_hostname();
1243
        $task->set_pid();
1244
 
1245
        // Delete the adhoc task record - it is finished.
1246
        $DB->delete_records('task_adhoc', array('id' => $task->get_id()));
1247
 
1248
        // Release the locks.
1249
        $task->release_concurrency_lock();
1250
        $task->get_lock()->release();
1251
 
1252
        self::$runningtask = null;
1253
    }
1254
 
1255
    /**
1256
     * This function indicates that a scheduled task was not completed successfully and should be retried.
1257
     *
1258
     * @param \core\task\scheduled_task $task
1259
     */
1260
    public static function scheduled_task_failed(scheduled_task $task) {
1261
        global $DB;
1262
        // Finalise the log output.
1263
        logmanager::finalise_log(true);
1264
 
1265
        $delay = $task->get_fail_delay();
1266
 
1267
        // Reschedule task with exponential fall off for failing tasks.
1268
        if (empty($delay)) {
1269
            $delay = 60;
1270
        } else {
1271
            $delay *= 2;
1272
        }
1273
 
1274
        // Max of 24 hour delay.
1275
        if ($delay >= 86400) {
1276
            $delay = 86400;
1277
 
1278
            // Dispatch hook when max fail delay has reached.
1279
            $hook = new \core\hook\task\after_failed_task_max_delay(
1280
                task: $task,
1281
            );
1282
            \core\di::get(\core\hook\manager::class)->dispatch($hook);
1283
        }
1284
 
1285
        $task->set_timestarted();
1286
        $task->set_hostname();
1287
        $task->set_pid();
1288
 
1289
        $classname = self::get_canonical_class_name($task);
1290
 
1291
        $record = $DB->get_record('task_scheduled', array('classname' => $classname));
1292
        $record->nextruntime = time() + $delay;
1293
        $record->faildelay = $delay;
1294
        $record->timestarted = null;
1295
        $record->hostname = null;
1296
        $record->pid = null;
1297
        $DB->update_record('task_scheduled', $record);
1298
 
1299
        $task->get_lock()->release();
1300
 
1301
        self::$runningtask = null;
1302
    }
1303
 
1304
    /**
1305
     * Clears the fail delay for the given task and updates its next run time based on the schedule.
1306
     *
1307
     * @param scheduled_task $task Task to reset
1308
     * @throws \dml_exception If there is a database error
1309
     */
1310
    public static function clear_fail_delay(scheduled_task $task) {
1311
        global $DB;
1312
 
1313
        $record = new \stdClass();
1314
        $record->id = $DB->get_field('task_scheduled', 'id',
1315
                ['classname' => self::get_canonical_class_name($task)]);
1316
        $record->nextruntime = $task->get_next_scheduled_time();
1317
        $record->faildelay = 0;
1318
        $DB->update_record('task_scheduled', $record);
1319
    }
1320
 
1321
    /**
1322
     * Records that a scheduled task is starting to run.
1323
     *
1324
     * @param scheduled_task $task Task that is starting
1325
     * @param int $time Start time (0 = current)
1326
     * @throws \dml_exception If the task doesn't exist
1327
     */
1328
    public static function scheduled_task_starting(scheduled_task $task, int $time = 0) {
1329
        global $DB;
1330
        $pid = (int)getmypid();
1331
        $hostname = (string)gethostname();
1332
 
1333
        if (!$time) {
1334
            $time = time();
1335
        }
1336
 
1337
        $task->set_timestarted($time);
1338
        $task->set_hostname($hostname);
1339
        $task->set_pid($pid);
1340
 
1341
        $classname = self::get_canonical_class_name($task);
1342
        $record = $DB->get_record('task_scheduled', ['classname' => $classname], '*', MUST_EXIST);
1343
        $record->timestarted = $time;
1344
        $record->hostname = $hostname;
1345
        $record->pid = $pid;
1346
        $DB->update_record('task_scheduled', $record);
1347
 
1348
        self::task_starting($task);
1349
    }
1350
 
1351
    /**
1352
     * This function indicates that a scheduled task was completed successfully and should be rescheduled.
1353
     *
1354
     * @param \core\task\scheduled_task $task
1355
     */
1356
    public static function scheduled_task_complete(scheduled_task $task) {
1357
        global $DB;
1358
 
1359
        // Finalise the log output.
1360
        logmanager::finalise_log();
1361
        $task->set_timestarted();
1362
        $task->set_hostname();
1363
        $task->set_pid();
1364
 
1365
        $classname = self::get_canonical_class_name($task);
1366
        $record = $DB->get_record('task_scheduled', array('classname' => $classname));
1367
        if ($record) {
1368
            $record->lastruntime = time();
1369
            $record->faildelay = 0;
1370
            $record->nextruntime = $task->get_next_scheduled_time();
1371
            $record->timestarted = null;
1372
            $record->hostname = null;
1373
            $record->pid = null;
1374
 
1375
            $DB->update_record('task_scheduled', $record);
1376
        }
1377
 
1378
        // Reschedule and then release the locks.
1379
        $task->get_lock()->release();
1380
 
1381
        self::$runningtask = null;
1382
    }
1383
 
1384
    /**
1385
     * Gets a list of currently-running tasks.
1386
     *
1387
     * @param  string $sort Sorting method
1388
     * @return array Array of scheduled and adhoc tasks
1389
     * @throws \dml_exception
1390
     */
1391
    public static function get_running_tasks($sort = ''): array {
1392
        global $DB;
1393
        if (empty($sort)) {
1394
            $sort = 'timestarted ASC, classname ASC';
1395
        }
1396
        $params = ['now1' => time(), 'now2' => time()];
1397
 
1398
        $sql = "SELECT subquery.*
1399
                  FROM (SELECT " . $DB->sql_concat("'s'", 'ts.id') . " as uniqueid,
1400
                               ts.id,
1401
                               'scheduled' as type,
1402
                               ts.classname,
1403
                               (:now1 - ts.timestarted) as time,
1404
                               ts.timestarted,
1405
                               ts.hostname,
1406
                               ts.pid
1407
                          FROM {task_scheduled} ts
1408
                         WHERE ts.timestarted IS NOT NULL
1409
                         UNION ALL
1410
                        SELECT " . $DB->sql_concat("'a'", 'ta.id') . " as uniqueid,
1411
                               ta.id,
1412
                               'adhoc' as type,
1413
                               ta.classname,
1414
                               (:now2 - ta.timestarted) as time,
1415
                               ta.timestarted,
1416
                               ta.hostname,
1417
                               ta.pid
1418
                          FROM {task_adhoc} ta
1419
                         WHERE ta.timestarted IS NOT NULL) subquery
1420
              ORDER BY " . $sort;
1421
 
1422
        return $DB->get_records_sql($sql, $params);
1423
    }
1424
 
1425
    /**
1426
     * Cleanup stale task metadata.
1427
     */
1428
    public static function cleanup_metadata() {
1429
        global $DB;
1430
 
1431
        $cronlockfactory = \core\lock\lock_config::get_lock_factory('cron');
1432
        $runningtasks = self::get_running_tasks();
1433
 
1434
        foreach ($runningtasks as $runningtask) {
1435
            if ($runningtask->timestarted > time() - HOURSECS) {
1436
                continue;
1437
            }
1438
 
1439
            if ($runningtask->type == 'adhoc') {
1440
                $lock = $cronlockfactory->get_lock('adhoc_' . $runningtask->id, 0);
1441
            }
1442
 
1443
            if ($runningtask->type == 'scheduled') {
1444
                $lock = $cronlockfactory->get_lock($runningtask->classname, 0);
1445
            }
1446
 
1447
            // If we got this lock it means one of three things:
1448
            //
1449
            // 1. The task was stopped abnormally and the metadata was not cleaned up
1450
            // 2. This is the process running the cleanup task
1451
            // 3. We took so long getting to it in this loop that it did finish, and we now have the lock
1452
            //
1453
            // In the case of 1. we need to make the task as failed, in the case of 2. and 3. we do nothing.
1454
            if (!empty($lock)) {
1455
                if ($runningtask->classname == "\\" . \core\task\task_lock_cleanup_task::class) {
1456
                    $lock->release();
1457
                    continue;
1458
                }
1459
 
1460
                // We need to get the record again to verify whether or not we are dealing with case 3.
1461
                $taskrecord = $DB->get_record('task_' . $runningtask->type, ['id' => $runningtask->id]);
1462
 
1463
                if ($runningtask->type == 'scheduled') {
1464
                    // Empty timestarted indicates that this task finished (case 3) and was properly cleaned up.
1465
                    if (empty($taskrecord->timestarted)) {
1466
                        $lock->release();
1467
                        continue;
1468
                    }
1469
 
1470
                    $task = self::scheduled_task_from_record($taskrecord);
1471
                    $task->set_lock($lock);
1472
                    self::scheduled_task_failed($task);
1473
                } else if ($runningtask->type == 'adhoc') {
1474
                    // Ad hoc tasks are removed from the DB if they finish successfully.
1475
                    // If we can't re-get this task, that means it finished and was properly
1476
                    // cleaned up.
1477
                    if (!$taskrecord) {
1478
                        $lock->release();
1479
                        continue;
1480
                    }
1481
 
1482
                    $task = self::adhoc_task_from_record($taskrecord);
1483
                    $task->set_lock($lock);
1484
                    self::adhoc_task_failed($task);
1485
                }
1486
            }
1487
        }
1488
    }
1489
 
1490
    /**
1491
     * This function is used to indicate that any long running cron processes should exit at the
1492
     * next opportunity and restart. This is because something (e.g. DB changes) has changed and
1493
     * the static caches may be stale.
1494
     */
1495
    public static function clear_static_caches() {
1496
        global $DB;
1497
        // Do not use get/set config here because the caches cannot be relied on.
1498
        $record = $DB->get_record('config', array('name'=>'scheduledtaskreset'));
1499
        if ($record) {
1500
            $record->value = time();
1501
            $DB->update_record('config', $record);
1502
        } else {
1503
            $record = new \stdClass();
1504
            $record->name = 'scheduledtaskreset';
1505
            $record->value = time();
1506
            $DB->insert_record('config', $record);
1507
        }
1508
    }
1509
 
1510
    /**
1511
     * Return true if the static caches have been cleared since $starttime.
1512
     * @param int $starttime The time this process started.
1513
     * @return boolean True if static caches need resetting.
1514
     */
1515
    public static function static_caches_cleared_since($starttime) {
1516
        global $DB;
1517
        $record = $DB->get_record('config', array('name'=>'scheduledtaskreset'));
1518
        return $record && (intval($record->value) > $starttime);
1519
    }
1520
 
1521
    /**
1522
     * Gets class name for use in database table. Always begins with a \.
1523
     *
1524
     * @param string|task_base $taskorstring Task object or a string
1525
     */
1526
    public static function get_canonical_class_name($taskorstring) {
1527
        if (is_string($taskorstring)) {
1528
            $classname = $taskorstring;
1529
        } else {
1530
            $classname = get_class($taskorstring);
1531
        }
1532
        if (strpos($classname, '\\') !== 0) {
1533
            $classname = '\\' . $classname;
1534
        }
1535
        return $classname;
1536
    }
1537
 
1538
    /**
1539
     * Gets the concurrent lock required to run an adhoc task.
1540
     *
1541
     * @param   adhoc_task $task The task to obtain the lock for
1542
     * @return  \core\lock\lock The lock if one was obtained successfully
1543
     * @throws  \coding_exception
1544
     */
1545
    protected static function get_concurrent_task_lock(adhoc_task $task): ?\core\lock\lock {
1546
        $adhoclock = null;
1547
        $cronlockfactory = \core\lock\lock_config::get_lock_factory(get_class($task));
1548
 
1549
        for ($run = 0; $run < $task->get_concurrency_limit(); $run++) {
1550
            if ($adhoclock = $cronlockfactory->get_lock("concurrent_run_{$run}", 0)) {
1551
                return $adhoclock;
1552
            }
1553
        }
1554
 
1555
        return null;
1556
    }
1557
 
1558
    /**
1559
     * Find the path of PHP CLI binary.
1560
     *
1561
     * @return string|false The PHP CLI executable PATH
1562
     */
1563
    protected static function find_php_cli_path() {
1564
        global $CFG;
1565
 
1566
        if (!empty($CFG->pathtophp) && is_executable(trim($CFG->pathtophp))) {
1567
            return $CFG->pathtophp;
1568
        }
1569
 
1570
        return false;
1571
    }
1572
 
1573
    /**
1574
     * Returns if Moodle have access to PHP CLI binary or not.
1575
     *
1576
     * @return bool
1577
     */
1578
    public static function is_runnable(): bool {
1579
        return self::find_php_cli_path() !== false;
1580
    }
1581
 
1582
    /**
1583
     * Executes a cron from web invocation using PHP CLI.
1584
     *
1585
     * @param scheduled_task $task Task that be executed via CLI.
1586
     * @return bool
1587
     * @throws \moodle_exception
1588
     */
1589
    public static function run_from_cli(scheduled_task $task): bool {
1590
        global $CFG;
1591
 
1592
        if (!self::is_runnable()) {
1593
            $redirecturl = new \moodle_url('/admin/settings.php', ['section' => 'systempaths']);
1594
            throw new \moodle_exception('cannotfindthepathtothecli', 'tool_task', $redirecturl->out());
1595
        } else {
1596
            // Shell-escaped path to the PHP binary.
1597
            $phpbinary = escapeshellarg(self::find_php_cli_path());
1598
 
1599
            // Shell-escaped path CLI script.
1600
            $pathcomponents = [$CFG->dirroot, $CFG->admin, 'cli', 'scheduled_task.php'];
1601
            $scriptpath     = escapeshellarg(implode(DIRECTORY_SEPARATOR, $pathcomponents));
1602
 
1603
            // Shell-escaped task name.
1604
            $classname = get_class($task);
1605
            $taskarg   = escapeshellarg("--execute={$classname}") . " " . escapeshellarg("--force");
1606
 
1607
            // Build the CLI command.
1608
            $command = "{$phpbinary} {$scriptpath} {$taskarg}";
1609
 
1610
            // Execute it.
1611
            self::passthru_via_mtrace($command);
1612
        }
1613
 
1614
        return true;
1615
    }
1616
 
1617
    /**
1618
     * This behaves similar to passthru but filters every line via
1619
     * the mtrace function so it can be post processed.
1620
     *
1621
     * @param string $command to run
1622
     * @return void
1623
     */
1624
    public static function passthru_via_mtrace(string $command) {
1625
        $descriptorspec = [
1626
 
1627
            1 => ['pipe', 'w'], // STDOUT.
1628
            2 => ['pipe', 'w'], // STDERR.
1629
        ];
1630
        flush();
1631
        $process = proc_open($command, $descriptorspec, $pipes, realpath('./'));
1632
        if (is_resource($process)) {
1633
            while ($s = fgets($pipes[1])) {
1634
                mtrace($s, '');
1635
                flush();
1636
            }
1637
        }
1638
 
1639
        fclose($pipes[0]);
1640
        fclose($pipes[1]);
1641
        fclose($pipes[2]);
1642
        proc_close($process);
1643
    }
1644
 
1645
    /**
1646
     * Executes an ad hoc task from web invocation using PHP CLI.
1647
     *
1648
     * @param int   $taskid Task to execute via CLI.
1649
     * @throws \moodle_exception
1650
     */
1651
    public static function run_adhoc_from_cli(int $taskid) {
1652
        // Shell-escaped task name.
1653
        $taskarg = escapeshellarg("--id={$taskid}");
1654
 
1655
        self::run_adhoc_from_cli_base($taskarg);
1656
    }
1657
 
1658
    /**
1659
     * Executes ad hoc tasks from web invocation using PHP CLI.
1660
     *
1661
     * @param bool|null   $failedonly
1662
     * @param string|null $classname  Task class to execute via CLI.
1663
     * @throws \moodle_exception
1664
     */
1665
    public static function run_all_adhoc_from_cli(?bool $failedonly = false, ?string $classname = null) {
1666
        $taskargs = [];
1667
        if ($failedonly) {
1668
            $taskargs[] = '--failed';
1669
        }
1670
        if ($classname) {
1671
            // Shell-escaped task select.
1672
            $taskargs[] = escapeshellarg("--classname={$classname}");
1673
        }
1674
 
1675
        self::run_adhoc_from_cli_base($taskargs ? implode(' ', $taskargs) : '--execute');
1676
    }
1677
 
1678
    /**
1679
     * Executes an ad hoc task from web invocation using PHP CLI.
1680
     *
1681
     * @param string $taskarg Task to execute via CLI.
1682
     * @throws \moodle_exception
1683
     */
1684
    private static function run_adhoc_from_cli_base(string $taskarg): void {
1685
        global $CFG;
1686
 
1687
        if (!self::is_runnable()) {
1688
            $redirecturl = new \moodle_url('/admin/settings.php', ['section' => 'systempaths']);
1689
            throw new \moodle_exception('cannotfindthepathtothecli', 'tool_task', $redirecturl->out());
1690
        }
1691
 
1692
        // Shell-escaped path to the PHP binary.
1693
        $phpbinary = escapeshellarg(self::find_php_cli_path());
1694
 
1695
        // Shell-escaped path CLI script.
1696
        $pathcomponents = [$CFG->dirroot, $CFG->admin, 'cli', 'adhoc_task.php'];
1697
        $scriptpath = escapeshellarg(implode(DIRECTORY_SEPARATOR, $pathcomponents));
1698
 
1699
        // Build the CLI command.
1700
        $command = "{$phpbinary} {$scriptpath} {$taskarg} --force";
1701
 
1702
        // We cannot run it in phpunit.
1703
        if (PHPUNIT_TEST) {
1704
            echo $command;
1705
            return;
1706
        }
1707
 
1708
        // Execute it.
1709
        self::passthru_via_mtrace($command);
1710
    }
1711
 
1712
    /**
1713
     * For a given scheduled task record, this method will check to see if any overrides have
1714
     * been applied in config and return a copy of the record with any overridden values.
1715
     *
1716
     * The format of the config value is:
1717
     *      $CFG->scheduled_tasks = array(
1718
     *          '$classname' => array(
1719
     *              'schedule' => '* * * * *',
1720
     *              'disabled' => 1,
1721
     *          ),
1722
     *      );
1723
     *
1724
     * Where $classname is the value of the task's classname, i.e. '\core\task\grade_cron_task'.
1725
     *
1726
     * @param \stdClass $record scheduled task record
1727
     * @return \stdClass scheduled task with any configured overrides
1728
     */
1729
    protected static function get_record_with_config_overrides(\stdClass $record): \stdClass {
1730
        global $CFG;
1731
 
1732
        $scheduledtaskkey = self::scheduled_task_get_override_key($record->classname);
1733
        $overriddenrecord = $record;
1734
 
1735
        if ($scheduledtaskkey) {
1736
            $overriddenrecord->customised = true;
1737
            $taskconfig = $CFG->scheduled_tasks[$scheduledtaskkey];
1738
 
1739
            if (isset($taskconfig['disabled'])) {
1740
                $overriddenrecord->disabled = $taskconfig['disabled'];
1741
            }
1742
            if (isset($taskconfig['schedule'])) {
1743
                list (
1744
                    $overriddenrecord->minute,
1745
                    $overriddenrecord->hour,
1746
                    $overriddenrecord->day,
1747
                    $overriddenrecord->month,
1748
                    $overriddenrecord->dayofweek
1749
                ) = explode(' ', $taskconfig['schedule']);
1750
            }
1751
        }
1752
 
1753
        return $overriddenrecord;
1754
    }
1755
 
1756
    /**
1757
     * This checks whether or not there is a value set in config
1758
     * for a scheduled task.
1759
     *
1760
     * @param string $classname Scheduled task's classname
1761
     * @return bool true if there is an entry in config
1762
     */
1763
    public static function scheduled_task_has_override(string $classname): bool {
1764
        return self::scheduled_task_get_override_key($classname) !== null;
1765
    }
1766
 
1767
    /**
1768
     * Get the key within the scheduled tasks config object that
1769
     * for a classname.
1770
     *
1771
     * @param string $classname the scheduled task classname to find
1772
     * @return string the key if found, otherwise null
1773
     */
1774
    public static function scheduled_task_get_override_key(string $classname): ?string {
1775
        global $CFG;
1776
 
1777
        if (isset($CFG->scheduled_tasks)) {
1778
            // Firstly, attempt to get a match against the full classname.
1779
            if (isset($CFG->scheduled_tasks[$classname])) {
1780
                return $classname;
1781
            }
1782
 
1783
            // Check to see if there is a wildcard matching the classname.
1784
            foreach (array_keys($CFG->scheduled_tasks) as $key) {
1785
                if (strpos($key, '*') === false) {
1786
                    continue;
1787
                }
1788
 
1789
                $pattern = '/' . str_replace('\\', '\\\\', str_replace('*', '.*', $key)) . '/';
1790
 
1791
                if (preg_match($pattern, $classname)) {
1792
                    return $key;
1793
                }
1794
            }
1795
        }
1796
 
1797
        return null;
1798
    }
1799
 
1800
    /**
1801
     * Clean up failed adhoc tasks.
1802
     */
1803
    public static function clean_failed_adhoc_tasks(): void {
1804
        global $CFG, $DB;
1805
        $difftime = !empty($CFG->task_adhoc_failed_retention) ?
1806
            $CFG->task_adhoc_failed_retention : static::ADHOC_TASK_FAILED_RETENTION;
1807
        $DB->delete_records_select(
1808
            table: 'task_adhoc',
1809
            select: 'attemptsavailable = 0 AND firststartingtime < :time',
1810
            params: ['time' => time() - $difftime],
1811
        );
1812
    }
1813
}