Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.2.x will end 22 April 2024 (12 months).
  • Bug fixes for security issues in 4.2.x will end 7 October 2024 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.1.x is supported too.

Differences Between: [Versions 310 and 402] [Versions 311 and 402] [Versions 39 and 402] [Versions 400 and 402] [Versions 401 and 402]

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