Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.

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

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