Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.11.x will end 14 Nov 2022 (12 months plus 6 months extension).
  • Bug fixes for security issues in 3.11.x will end 13 Nov 2023 (18 months plus 12 months extension).
  • PHP version: minimum PHP 7.3.0 Note: minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is supported too.

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

   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  define('CORE_TASK_TASKS_FILENAME', 'db/tasks.php');
  28  /**
  29   * Collection of task related methods.
  30   *
  31   * Some locking rules for this class:
  32   * All changes to scheduled tasks must be protected with both - the global cron lock and the lock
  33   * for the specific scheduled task (in that order). Locks must be released in the reverse order.
  34   * @copyright  2013 Damyon Wiese
  35   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  36   */
  37  class manager {
  38  
  39      /**
  40       * Given a component name, will load the list of tasks in the db/tasks.php file for that component.
  41       *
  42       * @param string $componentname - The name of the component to fetch the tasks for.
  43       * @param bool $expandr - if true (default) an 'R' value in a time is expanded to an appropriate int.
  44       *      If false, they are left as 'R'
  45       * @return \core\task\scheduled_task[] - List of scheduled tasks for this component.
  46       */
  47      public static function load_default_scheduled_tasks_for_component($componentname, $expandr = true) {
  48          $dir = \core_component::get_component_directory($componentname);
  49  
  50          if (!$dir) {
  51              return array();
  52          }
  53  
  54          $file = $dir . '/' . CORE_TASK_TASKS_FILENAME;
  55          if (!file_exists($file)) {
  56              return array();
  57          }
  58  
  59          $tasks = null;
  60          include($file);
  61  
  62          if (!isset($tasks)) {
  63              return array();
  64          }
  65  
  66          $scheduledtasks = array();
  67  
  68          foreach ($tasks as $task) {
  69              $record = (object) $task;
  70              $scheduledtask = self::scheduled_task_from_record($record, $expandr, false);
  71              // Safety check in case the task in the DB does not match a real class (maybe something was uninstalled).
  72              if ($scheduledtask) {
  73                  $scheduledtask->set_component($componentname);
  74                  $scheduledtasks[] = $scheduledtask;
  75              }
  76          }
  77  
  78          return $scheduledtasks;
  79      }
  80  
  81      /**
  82       * Update the database to contain a list of scheduled task for a component.
  83       * The list of scheduled tasks is taken from @load_scheduled_tasks_for_component.
  84       * Will throw exceptions for any errors.
  85       *
  86       * @param string $componentname - The frankenstyle component name.
  87       */
  88      public static function reset_scheduled_tasks_for_component($componentname) {
  89          global $DB;
  90          $tasks = self::load_default_scheduled_tasks_for_component($componentname);
  91          $validtasks = array();
  92  
  93          foreach ($tasks as $taskid => $task) {
  94              $classname = self::get_canonical_class_name($task);
  95  
  96              $validtasks[] = $classname;
  97  
  98              if ($currenttask = self::get_scheduled_task($classname)) {
  99                  if ($currenttask->is_customised()) {
 100                      // If there is an existing task with a custom schedule, do not override it.
 101                      continue;
 102                  }
 103  
 104                  // Update the record from the default task data.
 105                  self::configure_scheduled_task($task);
 106              } else {
 107                  // Ensure that the first run follows the schedule.
 108                  $task->set_next_run_time($task->get_next_scheduled_time());
 109  
 110                  // Insert the new task in the database.
 111                  $record = self::record_from_scheduled_task($task);
 112                  $DB->insert_record('task_scheduled', $record);
 113              }
 114          }
 115  
 116          // Delete any task that is not defined in the component any more.
 117          $sql = "component = :component";
 118          $params = array('component' => $componentname);
 119          if (!empty($validtasks)) {
 120              list($insql, $inparams) = $DB->get_in_or_equal($validtasks, SQL_PARAMS_NAMED, 'param', false);
 121              $sql .= ' AND classname ' . $insql;
 122              $params = array_merge($params, $inparams);
 123          }
 124          $DB->delete_records_select('task_scheduled', $sql, $params);
 125      }
 126  
 127      /**
 128       * Checks if the task with the same classname, component and customdata is already scheduled
 129       *
 130       * @param adhoc_task $task
 131       * @return bool
 132       */
 133      protected static function task_is_scheduled($task) {
 134          return false !== self::get_queued_adhoc_task_record($task);
 135      }
 136  
 137      /**
 138       * Checks if the task with the same classname, component and customdata is already scheduled
 139       *
 140       * @param adhoc_task $task
 141       * @return bool
 142       */
 143      protected static function get_queued_adhoc_task_record($task) {
 144          global $DB;
 145  
 146          $record = self::record_from_adhoc_task($task);
 147          $params = [$record->classname, $record->component, $record->customdata];
 148          $sql = 'classname = ? AND component = ? AND ' .
 149              $DB->sql_compare_text('customdata', \core_text::strlen($record->customdata) + 1) . ' = ?';
 150  
 151          if ($record->userid) {
 152              $params[] = $record->userid;
 153              $sql .= " AND userid = ? ";
 154          }
 155          return $DB->get_record_select('task_adhoc', $sql, $params);
 156      }
 157  
 158      /**
 159       * Schedule a new task, or reschedule an existing adhoc task which has matching data.
 160       *
 161       * Only a task matching the same user, classname, component, and customdata will be rescheduled.
 162       * If these values do not match exactly then a new task is scheduled.
 163       *
 164       * @param \core\task\adhoc_task $task - The new adhoc task information to store.
 165       * @since Moodle 3.7
 166       */
 167      public static function reschedule_or_queue_adhoc_task(adhoc_task $task) : void {
 168          global $DB;
 169  
 170          if ($existingrecord = self::get_queued_adhoc_task_record($task)) {
 171              // Only update the next run time if it is explicitly set on the task.
 172              $nextruntime = $task->get_next_run_time();
 173              if ($nextruntime && ($existingrecord->nextruntime != $nextruntime)) {
 174                  $DB->set_field('task_adhoc', 'nextruntime', $nextruntime, ['id' => $existingrecord->id]);
 175              }
 176          } else {
 177              // There is nothing queued yet. Just queue as normal.
 178              self::queue_adhoc_task($task);
 179          }
 180      }
 181  
 182      /**
 183       * Queue an adhoc task to run in the background.
 184       *
 185       * @param \core\task\adhoc_task $task - The new adhoc task information to store.
 186       * @param bool $checkforexisting - If set to true and the task with the same user, classname, component and customdata
 187       *     is already scheduled then it will not schedule a new task. Can be used only for ASAP tasks.
 188       * @return boolean - True if the config was saved.
 189       */
 190      public static function queue_adhoc_task(adhoc_task $task, $checkforexisting = false) {
 191          global $DB;
 192  
 193          if ($userid = $task->get_userid()) {
 194              // User found. Check that they are suitable.
 195              \core_user::require_active_user(\core_user::get_user($userid, '*', MUST_EXIST), true, true);
 196          }
 197  
 198          $record = self::record_from_adhoc_task($task);
 199          // Schedule it immediately if nextruntime not explicitly set.
 200          if (!$task->get_next_run_time()) {
 201              $record->nextruntime = time() - 1;
 202          }
 203  
 204          // Check if the same task is already scheduled.
 205          if ($checkforexisting && self::task_is_scheduled($task)) {
 206              return false;
 207          }
 208  
 209          // Queue the task.
 210          $result = $DB->insert_record('task_adhoc', $record);
 211  
 212          return $result;
 213      }
 214  
 215      /**
 216       * Change the default configuration for a scheduled task.
 217       * The list of scheduled tasks is taken from {@link load_scheduled_tasks_for_component}.
 218       *
 219       * @param \core\task\scheduled_task $task - The new scheduled task information to store.
 220       * @return boolean - True if the config was saved.
 221       */
 222      public static function configure_scheduled_task(scheduled_task $task) {
 223          global $DB;
 224  
 225          $classname = self::get_canonical_class_name($task);
 226  
 227          $original = $DB->get_record('task_scheduled', array('classname'=>$classname), 'id', MUST_EXIST);
 228  
 229          $record = self::record_from_scheduled_task($task);
 230          $record->id = $original->id;
 231          $record->nextruntime = $task->get_next_scheduled_time();
 232          unset($record->lastruntime);
 233          $result = $DB->update_record('task_scheduled', $record);
 234  
 235          return $result;
 236      }
 237  
 238      /**
 239       * Utility method to create a DB record from a scheduled task.
 240       *
 241       * @param \core\task\scheduled_task $task
 242       * @return \stdClass
 243       */
 244      public static function record_from_scheduled_task($task) {
 245          $record = new \stdClass();
 246          $record->classname = self::get_canonical_class_name($task);
 247          $record->component = $task->get_component();
 248          $record->blocking = $task->is_blocking();
 249          $record->customised = $task->is_customised();
 250          $record->lastruntime = $task->get_last_run_time();
 251          $record->nextruntime = $task->get_next_run_time();
 252          $record->faildelay = $task->get_fail_delay();
 253          $record->hour = $task->get_hour();
 254          $record->minute = $task->get_minute();
 255          $record->day = $task->get_day();
 256          $record->dayofweek = $task->get_day_of_week();
 257          $record->month = $task->get_month();
 258          $record->disabled = $task->get_disabled();
 259          $record->timestarted = $task->get_timestarted();
 260          $record->hostname = $task->get_hostname();
 261          $record->pid = $task->get_pid();
 262  
 263          return $record;
 264      }
 265  
 266      /**
 267       * Utility method to create a DB record from an adhoc task.
 268       *
 269       * @param \core\task\adhoc_task $task
 270       * @return \stdClass
 271       */
 272      public static function record_from_adhoc_task($task) {
 273          $record = new \stdClass();
 274          $record->classname = self::get_canonical_class_name($task);
 275          $record->id = $task->get_id();
 276          $record->component = $task->get_component();
 277          $record->blocking = $task->is_blocking();
 278          $record->nextruntime = $task->get_next_run_time();
 279          $record->faildelay = $task->get_fail_delay();
 280          $record->customdata = $task->get_custom_data_as_string();
 281          $record->userid = $task->get_userid();
 282          $record->timecreated = time();
 283          $record->timestarted = $task->get_timestarted();
 284          $record->hostname = $task->get_hostname();
 285          $record->pid = $task->get_pid();
 286  
 287          return $record;
 288      }
 289  
 290      /**
 291       * Utility method to create an adhoc task from a DB record.
 292       *
 293       * @param \stdClass $record
 294       * @return \core\task\adhoc_task
 295       */
 296      public static function adhoc_task_from_record($record) {
 297          $classname = self::get_canonical_class_name($record->classname);
 298          if (!class_exists($classname)) {
 299              debugging("Failed to load task: " . $classname, DEBUG_DEVELOPER);
 300              return false;
 301          }
 302          $task = new $classname;
 303          if (isset($record->nextruntime)) {
 304              $task->set_next_run_time($record->nextruntime);
 305          }
 306          if (isset($record->id)) {
 307              $task->set_id($record->id);
 308          }
 309          if (isset($record->component)) {
 310              $task->set_component($record->component);
 311          }
 312          $task->set_blocking(!empty($record->blocking));
 313          if (isset($record->faildelay)) {
 314              $task->set_fail_delay($record->faildelay);
 315          }
 316          if (isset($record->customdata)) {
 317              $task->set_custom_data_as_string($record->customdata);
 318          }
 319  
 320          if (isset($record->userid)) {
 321              $task->set_userid($record->userid);
 322          }
 323          if (isset($record->timestarted)) {
 324              $task->set_timestarted($record->timestarted);
 325          }
 326          if (isset($record->hostname)) {
 327              $task->set_hostname($record->hostname);
 328          }
 329          if (isset($record->pid)) {
 330              $task->set_pid($record->pid);
 331          }
 332  
 333          return $task;
 334      }
 335  
 336      /**
 337       * Utility method to create a task from a DB record.
 338       *
 339       * @param \stdClass $record
 340       * @param bool $expandr - if true (default) an 'R' value in a time is expanded to an appropriate int.
 341       *      If false, they are left as 'R'
 342       * @param bool $override - if true loads overridden settings from config.
 343       * @return \core\task\scheduled_task|false
 344       */
 345      public static function scheduled_task_from_record($record, $expandr = true, $override = true) {
 346          $classname = self::get_canonical_class_name($record->classname);
 347          if (!class_exists($classname)) {
 348              debugging("Failed to load task: " . $classname, DEBUG_DEVELOPER);
 349              return false;
 350          }
 351          /** @var \core\task\scheduled_task $task */
 352          $task = new $classname;
 353  
 354          if ($override) {
 355              // Update values with those defined in the config, if any are set.
 356              $record = self::get_record_with_config_overrides($record);
 357          }
 358  
 359          if (isset($record->lastruntime)) {
 360              $task->set_last_run_time($record->lastruntime);
 361          }
 362          if (isset($record->nextruntime)) {
 363              $task->set_next_run_time($record->nextruntime);
 364          }
 365          if (isset($record->customised)) {
 366              $task->set_customised($record->customised);
 367          }
 368          if (isset($record->component)) {
 369              $task->set_component($record->component);
 370          }
 371          $task->set_blocking(!empty($record->blocking));
 372          if (isset($record->minute)) {
 373              $task->set_minute($record->minute, $expandr);
 374          }
 375          if (isset($record->hour)) {
 376              $task->set_hour($record->hour, $expandr);
 377          }
 378          if (isset($record->day)) {
 379              $task->set_day($record->day);
 380          }
 381          if (isset($record->month)) {
 382              $task->set_month($record->month);
 383          }
 384          if (isset($record->dayofweek)) {
 385              $task->set_day_of_week($record->dayofweek, $expandr);
 386          }
 387          if (isset($record->faildelay)) {
 388              $task->set_fail_delay($record->faildelay);
 389          }
 390          if (isset($record->disabled)) {
 391              $task->set_disabled($record->disabled);
 392          }
 393          if (isset($record->timestarted)) {
 394              $task->set_timestarted($record->timestarted);
 395          }
 396          if (isset($record->hostname)) {
 397              $task->set_hostname($record->hostname);
 398          }
 399          if (isset($record->pid)) {
 400              $task->set_pid($record->pid);
 401          }
 402          $task->set_overridden(self::scheduled_task_has_override($classname));
 403  
 404          return $task;
 405      }
 406  
 407      /**
 408       * Given a component name, will load the list of tasks from the scheduled_tasks table for that component.
 409       * Do not execute tasks loaded from this function - they have not been locked.
 410       * @param string $componentname - The name of the component to load the tasks for.
 411       * @return \core\task\scheduled_task[]
 412       */
 413      public static function load_scheduled_tasks_for_component($componentname) {
 414          global $DB;
 415  
 416          $tasks = array();
 417          // We are just reading - so no locks required.
 418          $records = $DB->get_records('task_scheduled', array('component' => $componentname), 'classname', '*', IGNORE_MISSING);
 419          foreach ($records as $record) {
 420              $task = self::scheduled_task_from_record($record);
 421              // Safety check in case the task in the DB does not match a real class (maybe something was uninstalled).
 422              if ($task) {
 423                  $tasks[] = $task;
 424              }
 425          }
 426  
 427          return $tasks;
 428      }
 429  
 430      /**
 431       * This function load the scheduled task details for a given classname.
 432       *
 433       * @param string $classname
 434       * @return \core\task\scheduled_task or false
 435       */
 436      public static function get_scheduled_task($classname) {
 437          global $DB;
 438  
 439          $classname = self::get_canonical_class_name($classname);
 440          // We are just reading - so no locks required.
 441          $record = $DB->get_record('task_scheduled', array('classname'=>$classname), '*', IGNORE_MISSING);
 442          if (!$record) {
 443              return false;
 444          }
 445          return self::scheduled_task_from_record($record);
 446      }
 447  
 448      /**
 449       * This function load the adhoc tasks for a given classname.
 450       *
 451       * @param string $classname
 452       * @return \core\task\adhoc_task[]
 453       */
 454      public static function get_adhoc_tasks($classname) {
 455          global $DB;
 456  
 457          $classname = self::get_canonical_class_name($classname);
 458          // We are just reading - so no locks required.
 459          $records = $DB->get_records('task_adhoc', array('classname' => $classname));
 460  
 461          return array_map(function($record) {
 462              return self::adhoc_task_from_record($record);
 463          }, $records);
 464      }
 465  
 466      /**
 467       * This function load the default scheduled task details for a given classname.
 468       *
 469       * @param string $classname
 470       * @param bool $expandr - if true (default) an 'R' value in a time is expanded to an appropriate int.
 471       *      If false, they are left as 'R'
 472       * @return \core\task\scheduled_task|false
 473       */
 474      public static function get_default_scheduled_task($classname, $expandr = true) {
 475          $task = self::get_scheduled_task($classname);
 476          $componenttasks = array();
 477  
 478          // Safety check in case no task was found for the given classname.
 479          if ($task) {
 480              $componenttasks = self::load_default_scheduled_tasks_for_component(
 481                      $task->get_component(), $expandr);
 482          }
 483  
 484          foreach ($componenttasks as $componenttask) {
 485              if (get_class($componenttask) == get_class($task)) {
 486                  return $componenttask;
 487              }
 488          }
 489  
 490          return false;
 491      }
 492  
 493      /**
 494       * This function will return a list of all the scheduled tasks that exist in the database.
 495       *
 496       * @return \core\task\scheduled_task[]
 497       */
 498      public static function get_all_scheduled_tasks() {
 499          global $DB;
 500  
 501          $records = $DB->get_records('task_scheduled', null, 'component, classname', '*', IGNORE_MISSING);
 502          $tasks = array();
 503  
 504          foreach ($records as $record) {
 505              $task = self::scheduled_task_from_record($record);
 506              // Safety check in case the task in the DB does not match a real class (maybe something was uninstalled).
 507              if ($task) {
 508                  $tasks[] = $task;
 509              }
 510          }
 511  
 512          return $tasks;
 513      }
 514  
 515      /**
 516       * This function will return a list of all adhoc tasks that have a faildelay
 517       *
 518       * @param int $delay filter how long the task has been delayed
 519       * @return \core\task\adhoc_task[]
 520       */
 521      public static function get_failed_adhoc_tasks(int $delay = 0): array {
 522          global $DB;
 523  
 524          $tasks = [];
 525          $records = $DB->get_records_sql('SELECT * from {task_adhoc} WHERE faildelay > ?', [$delay]);
 526  
 527          foreach ($records as $record) {
 528              $task = self::adhoc_task_from_record($record);
 529              if ($task) {
 530                  $tasks[] = $task;
 531              }
 532          }
 533          return $tasks;
 534      }
 535  
 536      /**
 537       * Ensure quality of service for the ad hoc task queue.
 538       *
 539       * This reshuffles the adhoc tasks queue to balance by type to ensure a
 540       * level of quality of service per type, while still maintaining the
 541       * relative order of tasks queued by timestamp.
 542       *
 543       * @param array $records array of task records
 544       * @param array $records array of same task records shuffled
 545       */
 546      public static function ensure_adhoc_task_qos(array $records): array {
 547  
 548          $count = count($records);
 549          if ($count == 0) {
 550              return $records;
 551          }
 552  
 553          $queues = []; // This holds a queue for each type of adhoc task.
 554          $limits = []; // The relative limits of each type of task.
 555          $limittotal = 0;
 556  
 557          // Split the single queue up into queues per type.
 558          foreach ($records as $record) {
 559              $type = $record->classname;
 560              if (!array_key_exists($type, $queues)) {
 561                  $queues[$type] = [];
 562              }
 563              if (!array_key_exists($type, $limits)) {
 564                  $limits[$type] = 1;
 565                  $limittotal += 1;
 566              }
 567              $queues[$type][] = $record;
 568          }
 569  
 570          $qos = []; // Our new queue with ensured quality of service.
 571          $seed = $count % $limittotal; // Which task queue to shuffle from first?
 572  
 573          $move = 1; // How many tasks to shuffle at a time.
 574          do {
 575              $shuffled = 0;
 576  
 577              // Now cycle through task type queues and interleaving the tasks
 578              // back into a single queue.
 579              foreach ($limits as $type => $limit) {
 580  
 581                  // Just interleaving the queue is not enough, because after
 582                  // any task is processed the whole queue is rebuilt again. So
 583                  // we need to deterministically start on different types of
 584                  // tasks so that *on average* we rotate through each type of task.
 585                  //
 586                  // We achieve this by using a $seed to start moving tasks off a
 587                  // different queue each time. The seed is based on the task count
 588                  // modulo the number of types of tasks on the queue. As we count
 589                  // down this naturally cycles through each type of record.
 590                  if ($seed < 1) {
 591                      $shuffled = 1;
 592                      $seed += 1;
 593                      continue;
 594                  }
 595                  $tasks = array_splice($queues[$type], 0, $move);
 596                  $qos = array_merge($qos, $tasks);
 597  
 598                  // Stop if we didn't move any tasks onto the main queue.
 599                  $shuffled += count($tasks);
 600              }
 601              // Generally the only tasks that matter are those that are near the start so
 602              // after we have shuffled the first few 1 by 1, start shuffling larger groups.
 603              if (count($qos) >= (4 * count($limits))) {
 604                  $move *= 2;
 605              }
 606          } while ($shuffled > 0);
 607  
 608          return $qos;
 609      }
 610  
 611      /**
 612       * This function will dispatch the next adhoc task in the queue. The task will be handed out
 613       * with an open lock - possibly on the entire cron process. Make sure you call either
 614       * {@link adhoc_task_failed} or {@link adhoc_task_complete} to release the lock and reschedule the task.
 615       *
 616       * @param int $timestart
 617       * @param bool $checklimits Should we check limits?
 618       * @return \core\task\adhoc_task or null if not found
 619       * @throws \moodle_exception
 620       */
 621      public static function get_next_adhoc_task($timestart, $checklimits = true) {
 622          global $DB;
 623  
 624          $where = '(nextruntime IS NULL OR nextruntime < :timestart1)';
 625          $params = array('timestart1' => $timestart);
 626          $records = $DB->get_records_select('task_adhoc', $where, $params, 'nextruntime ASC, id ASC', '*', 0, 2000);
 627          $records = self::ensure_adhoc_task_qos($records);
 628  
 629          $cronlockfactory = \core\lock\lock_config::get_lock_factory('cron');
 630  
 631          $skipclasses = array();
 632  
 633          foreach ($records as $record) {
 634  
 635              if (in_array($record->classname, $skipclasses)) {
 636                  // Skip the task if it can't be started due to per-task concurrency limit.
 637                  continue;
 638              }
 639  
 640              if ($lock = $cronlockfactory->get_lock('adhoc_' . $record->id, 0)) {
 641  
 642                  // Safety check, see if the task has been already processed by another cron run.
 643                  $record = $DB->get_record('task_adhoc', array('id' => $record->id));
 644                  if (!$record) {
 645                      $lock->release();
 646                      continue;
 647                  }
 648  
 649                  $task = self::adhoc_task_from_record($record);
 650                  // Safety check in case the task in the DB does not match a real class (maybe something was uninstalled).
 651                  if (!$task) {
 652                      $lock->release();
 653                      continue;
 654                  }
 655  
 656                  $tasklimit = $task->get_concurrency_limit();
 657                  if ($checklimits && $tasklimit > 0) {
 658                      if ($concurrencylock = self::get_concurrent_task_lock($task)) {
 659                          $task->set_concurrency_lock($concurrencylock);
 660                      } else {
 661                          // Unable to obtain a concurrency lock.
 662                          mtrace("Skipping $record->classname adhoc task class as the per-task limit of $tasklimit is reached.");
 663                          $skipclasses[] = $record->classname;
 664                          $lock->release();
 665                          continue;
 666                      }
 667                  }
 668  
 669                  // The global cron lock is under the most contention so request it
 670                  // as late as possible and release it as soon as possible.
 671                  if (!$cronlock = $cronlockfactory->get_lock('core_cron', 10)) {
 672                      $lock->release();
 673                      throw new \moodle_exception('locktimeout');
 674                  }
 675  
 676                  $task->set_lock($lock);
 677                  if (!$task->is_blocking()) {
 678                      $cronlock->release();
 679                  } else {
 680                      $task->set_cron_lock($cronlock);
 681                  }
 682                  return $task;
 683              }
 684          }
 685  
 686          return null;
 687      }
 688  
 689      /**
 690       * This function will dispatch the next scheduled task in the queue. The task will be handed out
 691       * with an open lock - possibly on the entire cron process. Make sure you call either
 692       * {@link scheduled_task_failed} or {@link scheduled_task_complete} to release the lock and reschedule the task.
 693       *
 694       * @param int $timestart - The start of the cron process - do not repeat any tasks that have been run more recently than this.
 695       * @return \core\task\scheduled_task or null
 696       * @throws \moodle_exception
 697       */
 698      public static function get_next_scheduled_task($timestart) {
 699          global $DB;
 700          $cronlockfactory = \core\lock\lock_config::get_lock_factory('cron');
 701  
 702          $where = "(lastruntime IS NULL OR lastruntime < :timestart1)
 703                    AND (nextruntime IS NULL OR nextruntime < :timestart2)
 704                    ORDER BY lastruntime, id ASC";
 705          $params = array('timestart1' => $timestart, 'timestart2' => $timestart);
 706          $records = $DB->get_records_select('task_scheduled', $where, $params);
 707  
 708          $pluginmanager = \core_plugin_manager::instance();
 709  
 710          foreach ($records as $record) {
 711  
 712              $task = self::scheduled_task_from_record($record);
 713              // Safety check in case the task in the DB does not match a real class (maybe something was uninstalled).
 714              // Also check to see if task is disabled or enabled after applying overrides.
 715              if (!$task || $task->get_disabled()) {
 716                  continue;
 717              }
 718  
 719              if ($lock = $cronlockfactory->get_lock(($record->classname), 0)) {
 720                  $classname = '\\' . $record->classname;
 721  
 722                  $task->set_lock($lock);
 723  
 724                  // See if the component is disabled.
 725                  $plugininfo = $pluginmanager->get_plugin_info($task->get_component());
 726  
 727                  if ($plugininfo) {
 728                      if (($plugininfo->is_enabled() === false) && !$task->get_run_if_component_disabled()) {
 729                          $lock->release();
 730                          continue;
 731                      }
 732                  }
 733  
 734                  if (!self::scheduled_task_has_override($record->classname)) {
 735                      // Make sure the task data is unchanged unless an override is being used.
 736                      if (!$DB->record_exists('task_scheduled', (array)$record)) {
 737                          $lock->release();
 738                          continue;
 739                      }
 740                  }
 741  
 742                  // The global cron lock is under the most contention so request it
 743                  // as late as possible and release it as soon as possible.
 744                  if (!$cronlock = $cronlockfactory->get_lock('core_cron', 10)) {
 745                      $lock->release();
 746                      throw new \moodle_exception('locktimeout');
 747                  }
 748  
 749                  if (!$task->is_blocking()) {
 750                      $cronlock->release();
 751                  } else {
 752                      $task->set_cron_lock($cronlock);
 753                  }
 754                  return $task;
 755              }
 756          }
 757  
 758          return null;
 759      }
 760  
 761      /**
 762       * This function indicates that an adhoc task was not completed successfully and should be retried.
 763       *
 764       * @param \core\task\adhoc_task $task
 765       */
 766      public static function adhoc_task_failed(adhoc_task $task) {
 767          global $DB;
 768          // Finalise the log output.
 769          logmanager::finalise_log(true);
 770  
 771          $delay = $task->get_fail_delay();
 772  
 773          // Reschedule task with exponential fall off for failing tasks.
 774          if (empty($delay)) {
 775              $delay = 60;
 776          } else {
 777              $delay *= 2;
 778          }
 779  
 780          // Max of 24 hour delay.
 781          if ($delay > 86400) {
 782              $delay = 86400;
 783          }
 784  
 785          // Reschedule and then release the locks.
 786          $task->set_timestarted();
 787          $task->set_hostname();
 788          $task->set_pid();
 789          $task->set_next_run_time(time() + $delay);
 790          $task->set_fail_delay($delay);
 791          $record = self::record_from_adhoc_task($task);
 792          $DB->update_record('task_adhoc', $record);
 793  
 794          $task->release_concurrency_lock();
 795          if ($task->is_blocking()) {
 796              $task->get_cron_lock()->release();
 797          }
 798          $task->get_lock()->release();
 799      }
 800  
 801      /**
 802       * Records that a adhoc task is starting to run.
 803       *
 804       * @param adhoc_task $task Task that is starting
 805       * @param int $time Start time (leave blank for now)
 806       * @throws \dml_exception
 807       * @throws \coding_exception
 808       */
 809      public static function adhoc_task_starting(adhoc_task $task, int $time = 0) {
 810          global $DB;
 811          $pid = (int)getmypid();
 812          $hostname = (string)gethostname();
 813  
 814          if (empty($time)) {
 815              $time = time();
 816          }
 817  
 818          $task->set_timestarted($time);
 819          $task->set_hostname($hostname);
 820          $task->set_pid($pid);
 821  
 822          $record = self::record_from_adhoc_task($task);
 823          $DB->update_record('task_adhoc', $record);
 824      }
 825  
 826      /**
 827       * This function indicates that an adhoc task was completed successfully.
 828       *
 829       * @param \core\task\adhoc_task $task
 830       */
 831      public static function adhoc_task_complete(adhoc_task $task) {
 832          global $DB;
 833  
 834          // Finalise the log output.
 835          logmanager::finalise_log();
 836          $task->set_timestarted();
 837          $task->set_hostname();
 838          $task->set_pid();
 839  
 840          // Delete the adhoc task record - it is finished.
 841          $DB->delete_records('task_adhoc', array('id' => $task->get_id()));
 842  
 843          // Release the locks.
 844          $task->release_concurrency_lock();
 845          if ($task->is_blocking()) {
 846              $task->get_cron_lock()->release();
 847          }
 848          $task->get_lock()->release();
 849      }
 850  
 851      /**
 852       * This function indicates that a scheduled task was not completed successfully and should be retried.
 853       *
 854       * @param \core\task\scheduled_task $task
 855       */
 856      public static function scheduled_task_failed(scheduled_task $task) {
 857          global $DB;
 858          // Finalise the log output.
 859          logmanager::finalise_log(true);
 860  
 861          $delay = $task->get_fail_delay();
 862  
 863          // Reschedule task with exponential fall off for failing tasks.
 864          if (empty($delay)) {
 865              $delay = 60;
 866          } else {
 867              $delay *= 2;
 868          }
 869  
 870          // Max of 24 hour delay.
 871          if ($delay > 86400) {
 872              $delay = 86400;
 873          }
 874  
 875          $task->set_timestarted();
 876          $task->set_hostname();
 877          $task->set_pid();
 878  
 879          $classname = self::get_canonical_class_name($task);
 880  
 881          $record = $DB->get_record('task_scheduled', array('classname' => $classname));
 882          $record->nextruntime = time() + $delay;
 883          $record->faildelay = $delay;
 884          $record->timestarted = null;
 885          $record->hostname = null;
 886          $record->pid = null;
 887          $DB->update_record('task_scheduled', $record);
 888  
 889          if ($task->is_blocking()) {
 890              $task->get_cron_lock()->release();
 891          }
 892          $task->get_lock()->release();
 893      }
 894  
 895      /**
 896       * Clears the fail delay for the given task and updates its next run time based on the schedule.
 897       *
 898       * @param scheduled_task $task Task to reset
 899       * @throws \dml_exception If there is a database error
 900       */
 901      public static function clear_fail_delay(scheduled_task $task) {
 902          global $DB;
 903  
 904          $record = new \stdClass();
 905          $record->id = $DB->get_field('task_scheduled', 'id',
 906                  ['classname' => self::get_canonical_class_name($task)]);
 907          $record->nextruntime = $task->get_next_scheduled_time();
 908          $record->faildelay = 0;
 909          $DB->update_record('task_scheduled', $record);
 910      }
 911  
 912      /**
 913       * Records that a scheduled task is starting to run.
 914       *
 915       * @param scheduled_task $task Task that is starting
 916       * @param int $time Start time (0 = current)
 917       * @throws \dml_exception If the task doesn't exist
 918       */
 919      public static function scheduled_task_starting(scheduled_task $task, int $time = 0) {
 920          global $DB;
 921          $pid = (int)getmypid();
 922          $hostname = (string)gethostname();
 923  
 924          if (!$time) {
 925              $time = time();
 926          }
 927  
 928          $task->set_timestarted($time);
 929          $task->set_hostname($hostname);
 930          $task->set_pid($pid);
 931  
 932          $classname = self::get_canonical_class_name($task);
 933          $record = $DB->get_record('task_scheduled', ['classname' => $classname], '*', MUST_EXIST);
 934          $record->timestarted = $time;
 935          $record->hostname = $hostname;
 936          $record->pid = $pid;
 937          $DB->update_record('task_scheduled', $record);
 938      }
 939  
 940      /**
 941       * This function indicates that a scheduled task was completed successfully and should be rescheduled.
 942       *
 943       * @param \core\task\scheduled_task $task
 944       */
 945      public static function scheduled_task_complete(scheduled_task $task) {
 946          global $DB;
 947  
 948          // Finalise the log output.
 949          logmanager::finalise_log();
 950          $task->set_timestarted();
 951          $task->set_hostname();
 952          $task->set_pid();
 953  
 954          $classname = self::get_canonical_class_name($task);
 955          $record = $DB->get_record('task_scheduled', array('classname' => $classname));
 956          if ($record) {
 957              $record->lastruntime = time();
 958              $record->faildelay = 0;
 959              $record->nextruntime = $task->get_next_scheduled_time();
 960              $record->timestarted = null;
 961              $record->hostname = null;
 962              $record->pid = null;
 963  
 964              $DB->update_record('task_scheduled', $record);
 965          }
 966  
 967          // Reschedule and then release the locks.
 968          if ($task->is_blocking()) {
 969              $task->get_cron_lock()->release();
 970          }
 971          $task->get_lock()->release();
 972      }
 973  
 974      /**
 975       * Gets a list of currently-running tasks.
 976       *
 977       * @param  string $sort Sorting method
 978       * @return array Array of scheduled and adhoc tasks
 979       * @throws \dml_exception
 980       */
 981      public static function get_running_tasks($sort = ''): array {
 982          global $DB;
 983          if (empty($sort)) {
 984              $sort = 'timestarted ASC, classname ASC';
 985          }
 986          $params = ['now1' => time(), 'now2' => time()];
 987  
 988          $sql = "SELECT subquery.*
 989                    FROM (SELECT " . $DB->sql_concat("'s'", 'ts.id') . " as uniqueid,
 990                                 ts.id,
 991                                 'scheduled' as type,
 992                                 ts.classname,
 993                                 (:now1 - ts.timestarted) as time,
 994                                 ts.timestarted,
 995                                 ts.hostname,
 996                                 ts.pid
 997                            FROM {task_scheduled} ts
 998                           WHERE ts.timestarted IS NOT NULL
 999                           UNION ALL
1000                          SELECT " . $DB->sql_concat("'a'", 'ta.id') . " as uniqueid,
1001                                 ta.id,
1002                                 'adhoc' as type,
1003                                 ta.classname,
1004                                 (:now2 - ta.timestarted) as time,
1005                                 ta.timestarted,
1006                                 ta.hostname,
1007                                 ta.pid
1008                            FROM {task_adhoc} ta
1009                           WHERE ta.timestarted IS NOT NULL) subquery
1010                ORDER BY " . $sort;
1011  
1012          return $DB->get_records_sql($sql, $params);
1013      }
1014  
1015      /**
1016       * This function is used to indicate that any long running cron processes should exit at the
1017       * next opportunity and restart. This is because something (e.g. DB changes) has changed and
1018       * the static caches may be stale.
1019       */
1020      public static function clear_static_caches() {
1021          global $DB;
1022          // Do not use get/set config here because the caches cannot be relied on.
1023          $record = $DB->get_record('config', array('name'=>'scheduledtaskreset'));
1024          if ($record) {
1025              $record->value = time();
1026              $DB->update_record('config', $record);
1027          } else {
1028              $record = new \stdClass();
1029              $record->name = 'scheduledtaskreset';
1030              $record->value = time();
1031              $DB->insert_record('config', $record);
1032          }
1033      }
1034  
1035      /**
1036       * Return true if the static caches have been cleared since $starttime.
1037       * @param int $starttime The time this process started.
1038       * @return boolean True if static caches need resetting.
1039       */
1040      public static function static_caches_cleared_since($starttime) {
1041          global $DB;
1042          $record = $DB->get_record('config', array('name'=>'scheduledtaskreset'));
1043          return $record && (intval($record->value) > $starttime);
1044      }
1045  
1046      /**
1047       * Gets class name for use in database table. Always begins with a \.
1048       *
1049       * @param string|task_base $taskorstring Task object or a string
1050       */
1051      protected static function get_canonical_class_name($taskorstring) {
1052          if (is_string($taskorstring)) {
1053              $classname = $taskorstring;
1054          } else {
1055              $classname = get_class($taskorstring);
1056          }
1057          if (strpos($classname, '\\') !== 0) {
1058              $classname = '\\' . $classname;
1059          }
1060          return $classname;
1061      }
1062  
1063      /**
1064       * Gets the concurrent lock required to run an adhoc task.
1065       *
1066       * @param   adhoc_task $task The task to obtain the lock for
1067       * @return  \core\lock\lock The lock if one was obtained successfully
1068       * @throws  \coding_exception
1069       */
1070      protected static function get_concurrent_task_lock(adhoc_task $task): ?\core\lock\lock {
1071          $adhoclock = null;
1072          $cronlockfactory = \core\lock\lock_config::get_lock_factory(get_class($task));
1073  
1074          for ($run = 0; $run < $task->get_concurrency_limit(); $run++) {
1075              if ($adhoclock = $cronlockfactory->get_lock("concurrent_run_{$run}", 0)) {
1076                  return $adhoclock;
1077              }
1078          }
1079  
1080          return null;
1081      }
1082  
1083      /**
1084       * Find the path of PHP CLI binary.
1085       *
1086       * @return string|false The PHP CLI executable PATH
1087       */
1088      protected static function find_php_cli_path() {
1089          global $CFG;
1090  
1091          if (!empty($CFG->pathtophp) && is_executable(trim($CFG->pathtophp))) {
1092              return $CFG->pathtophp;
1093          }
1094  
1095          return false;
1096      }
1097  
1098      /**
1099       * Returns if Moodle have access to PHP CLI binary or not.
1100       *
1101       * @return bool
1102       */
1103      public static function is_runnable():bool {
1104          return self::find_php_cli_path() !== false;
1105      }
1106  
1107      /**
1108       * Executes a cron from web invocation using PHP CLI.
1109       *
1110       * @param \core\task\task_base $task Task that be executed via CLI.
1111       * @return bool
1112       * @throws \moodle_exception
1113       */
1114      public static function run_from_cli(\core\task\task_base $task):bool {
1115          global $CFG;
1116  
1117          if (!self::is_runnable()) {
1118              $redirecturl = new \moodle_url('/admin/settings.php', ['section' => 'systempaths']);
1119              throw new \moodle_exception('cannotfindthepathtothecli', 'tool_task', $redirecturl->out());
1120          } else {
1121              // Shell-escaped path to the PHP binary.
1122              $phpbinary = escapeshellarg(self::find_php_cli_path());
1123  
1124              // Shell-escaped path CLI script.
1125              $pathcomponents = [$CFG->dirroot, $CFG->admin, 'cli', 'scheduled_task.php'];
1126              $scriptpath     = escapeshellarg(implode(DIRECTORY_SEPARATOR, $pathcomponents));
1127  
1128              // Shell-escaped task name.
1129              $classname = get_class($task);
1130              $taskarg   = escapeshellarg("--execute={$classname}") . " " . escapeshellarg("--force");
1131  
1132              // Build the CLI command.
1133              $command = "{$phpbinary} {$scriptpath} {$taskarg}";
1134  
1135              // Execute it.
1136              self::passthru_via_mtrace($command);
1137          }
1138  
1139          return true;
1140      }
1141  
1142      /**
1143       * This behaves similar to passthru but filters every line via
1144       * the mtrace function so it can be post processed.
1145       *
1146       * @param string $command to run
1147       * @return void
1148       */
1149      public static function passthru_via_mtrace(string $command) {
1150          $descriptorspec = [
1151              0 => ['pipe', 'r'], // STDIN.
1152              1 => ['pipe', 'w'], // STDOUT.
1153              2 => ['pipe', 'w'], // STDERR.
1154          ];
1155          flush();
1156          $process = proc_open($command, $descriptorspec, $pipes, realpath('./'), []);
1157          if (is_resource($process)) {
1158              while ($s = fgets($pipes[1])) {
1159                  mtrace($s, '');
1160                  flush();
1161              }
1162          }
1163  
1164          fclose($pipes[0]);
1165          fclose($pipes[1]);
1166          fclose($pipes[2]);
1167          proc_close($process);
1168      }
1169  
1170      /**
1171       * For a given scheduled task record, this method will check to see if any overrides have
1172       * been applied in config and return a copy of the record with any overridden values.
1173       *
1174       * The format of the config value is:
1175       *      $CFG->scheduled_tasks = array(
1176       *          '$classname' => array(
1177       *              'schedule' => '* * * * *',
1178       *              'disabled' => 1,
1179       *          ),
1180       *      );
1181       *
1182       * Where $classname is the value of the task's classname, i.e. '\core\task\grade_cron_task'.
1183       *
1184       * @param \stdClass $record scheduled task record
1185       * @return \stdClass scheduled task with any configured overrides
1186       */
1187      protected static function get_record_with_config_overrides(\stdClass $record): \stdClass {
1188          global $CFG;
1189  
1190          $scheduledtaskkey = self::scheduled_task_get_override_key($record->classname);
1191          $overriddenrecord = $record;
1192  
1193          if ($scheduledtaskkey) {
1194              $overriddenrecord->customised = true;
1195              $taskconfig = $CFG->scheduled_tasks[$scheduledtaskkey];
1196  
1197              if (isset($taskconfig['disabled'])) {
1198                  $overriddenrecord->disabled = $taskconfig['disabled'];
1199              }
1200              if (isset($taskconfig['schedule'])) {
1201                  list (
1202                      $overriddenrecord->minute,
1203                      $overriddenrecord->hour,
1204                      $overriddenrecord->day,
1205                      $overriddenrecord->month,
1206                      $overriddenrecord->dayofweek
1207                  ) = explode(' ', $taskconfig['schedule']);
1208              }
1209          }
1210  
1211          return $overriddenrecord;
1212      }
1213  
1214      /**
1215       * This checks whether or not there is a value set in config
1216       * for a scheduled task.
1217       *
1218       * @param string $classname Scheduled task's classname
1219       * @return bool true if there is an entry in config
1220       */
1221      public static function scheduled_task_has_override(string $classname): bool {
1222          return self::scheduled_task_get_override_key($classname) !== null;
1223      }
1224  
1225      /**
1226       * Get the key within the scheduled tasks config object that
1227       * for a classname.
1228       *
1229       * @param string $classname the scheduled task classname to find
1230       * @return string the key if found, otherwise null
1231       */
1232      public static function scheduled_task_get_override_key(string $classname): ?string {
1233          global $CFG;
1234  
1235          if (isset($CFG->scheduled_tasks)) {
1236              // Firstly, attempt to get a match against the full classname.
1237              if (isset($CFG->scheduled_tasks[$classname])) {
1238                  return $classname;
1239              }
1240  
1241              // Check to see if there is a wildcard matching the classname.
1242              foreach (array_keys($CFG->scheduled_tasks) as $key) {
1243                  if (strpos($key, '*') === false) {
1244                      continue;
1245                  }
1246  
1247                  $pattern = '/' . str_replace('\\', '\\\\', str_replace('*', '.*', $key)) . '/';
1248  
1249                  if (preg_match($pattern, $classname)) {
1250                      return $key;
1251                  }
1252              }
1253          }
1254  
1255          return null;
1256      }
1257  }