Proyectos de Subversion Moodle

Rev

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