Search moodle.org's
Developer Documentation

See Release Notes

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

Differences Between: [Versions 310 and 403] [Versions 311 and 403] [Versions 39 and 403] [Versions 400 and 403] [Versions 401 and 403] [Versions 402 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 task abstract class.
  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  /**
  28   * Abstract class defining a scheduled task.
  29   * @copyright  2013 Damyon Wiese
  30   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  31   */
  32  abstract class scheduled_task extends task_base {
  33  
  34      /** Minimum minute value. */
  35      const MINUTEMIN = 0;
  36      /** Maximum minute value. */
  37      const MINUTEMAX = 59;
  38  
  39      /** Minimum hour value. */
  40      const HOURMIN = 0;
  41      /** Maximum hour value. */
  42      const HOURMAX = 23;
  43  
  44      /** Minimum day of month value. */
  45      const DAYMIN = 1;
  46      /** Maximum day of month value. */
  47      const DAYMAX = 31;
  48  
  49      /** Minimum month value. */
  50      const MONTHMIN = 1;
  51      /** Maximum month value. */
  52      const MONTHMAX = 12;
  53  
  54      /** Minimum dayofweek value. */
  55      const DAYOFWEEKMIN = 0;
  56      /** Maximum dayofweek value. */
  57      const DAYOFWEEKMAX = 6;
  58      /** Maximum dayofweek value allowed in input (7 = 0). */
  59      const DAYOFWEEKMAXINPUT = 7;
  60  
  61      /**
  62       * Minute field identifier.
  63       */
  64      const FIELD_MINUTE = 'minute';
  65      /**
  66       * Hour field identifier.
  67       */
  68      const FIELD_HOUR = 'hour';
  69      /**
  70       * Day-of-month field identifier.
  71       */
  72      const FIELD_DAY = 'day';
  73      /**
  74       * Month field identifier.
  75       */
  76      const FIELD_MONTH = 'month';
  77      /**
  78       * Day-of-week field identifier.
  79       */
  80      const FIELD_DAYOFWEEK = 'dayofweek';
  81  
  82      /**
  83       * Time used for the next scheduled time when a task should never run. This is 2222-01-01 00:00 GMT
  84       * which is a large time that still fits in 10 digits.
  85       */
  86      const NEVER_RUN_TIME = 7952342400;
  87  
  88      /** @var string $hour - Pattern to work out the valid hours */
  89      private $hour = '*';
  90  
  91      /** @var string $minute - Pattern to work out the valid minutes */
  92      private $minute = '*';
  93  
  94      /** @var string $day - Pattern to work out the valid days */
  95      private $day = '*';
  96  
  97      /** @var string $month - Pattern to work out the valid months */
  98      private $month = '*';
  99  
 100      /** @var string $dayofweek - Pattern to work out the valid dayofweek */
 101      private $dayofweek = '*';
 102  
 103      /** @var int $lastruntime - When this task was last run */
 104      private $lastruntime = 0;
 105  
 106      /** @var boolean $customised - Has this task been changed from it's default schedule? */
 107      private $customised = false;
 108  
 109      /** @var boolean $overridden - Does the task have values set VIA config? */
 110      private $overridden = false;
 111  
 112      /** @var int $disabled - Is this task disabled in cron? */
 113      private $disabled = false;
 114  
 115      /**
 116       * Get the last run time for this scheduled task.
 117       *
 118       * @return int
 119       */
 120      public function get_last_run_time() {
 121          return $this->lastruntime;
 122      }
 123  
 124      /**
 125       * Set the last run time for this scheduled task.
 126       *
 127       * @param int $lastruntime
 128       */
 129      public function set_last_run_time($lastruntime) {
 130          $this->lastruntime = $lastruntime;
 131      }
 132  
 133      /**
 134       * Has this task been changed from it's default config?
 135       *
 136       * @return bool
 137       */
 138      public function is_customised() {
 139          return $this->customised;
 140      }
 141  
 142      /**
 143       * Set customised for this scheduled task.
 144       *
 145       * @param bool
 146       */
 147      public function set_customised($customised) {
 148          $this->customised = $customised;
 149      }
 150  
 151      /**
 152       * Determine if this task is using its default configuration changed from the default. Returns true
 153       * if it is and false otherwise. Does not rely on the customised field.
 154       *
 155       * @return bool
 156       */
 157      public function has_default_configuration(): bool {
 158          $defaulttask = \core\task\manager::get_default_scheduled_task($this::class);
 159          if ($defaulttask->get_minute() !== $this->get_minute()) {
 160              return false;
 161          }
 162          if ($defaulttask->get_hour() != $this->get_hour()) {
 163              return false;
 164          }
 165          if ($defaulttask->get_month() != $this->get_month()) {
 166              return false;
 167          }
 168          if ($defaulttask->get_day_of_week() != $this->get_day_of_week()) {
 169              return false;
 170          }
 171          if ($defaulttask->get_day() != $this->get_day()) {
 172              return false;
 173          }
 174          if ($defaulttask->get_disabled() != $this->get_disabled()) {
 175              return false;
 176          }
 177          return true;
 178      }
 179  
 180      /**
 181       * Disable the task.
 182       */
 183      public function disable(): void {
 184          $this->set_disabled(true);
 185          $this->set_customised(!$this->has_default_configuration());
 186          \core\task\manager::configure_scheduled_task($this);
 187      }
 188  
 189      /**
 190       * Enable the task.
 191       */
 192      public function enable(): void {
 193          $this->set_disabled(false);
 194          $this->set_customised(!$this->has_default_configuration());
 195          \core\task\manager::configure_scheduled_task($this);
 196      }
 197  
 198      /**
 199       * Has this task been changed from it's default config?
 200       *
 201       * @return bool
 202       */
 203      public function is_overridden(): bool {
 204          return $this->overridden;
 205      }
 206  
 207      /**
 208       * Set the overridden value.
 209       *
 210       * @param bool $overridden
 211       */
 212      public function set_overridden(bool $overridden): void {
 213          $this->overridden = $overridden;
 214      }
 215  
 216      /**
 217       * Setter for $minute. Accepts a special 'R' value
 218       * which will be translated to a random minute.
 219       *
 220       * @param string $minute
 221       * @param bool $expandr - if true (default) an 'R' value in a time is expanded to an appropriate int.
 222       *      If false, they are left as 'R'
 223       */
 224      public function set_minute($minute, $expandr = true) {
 225          if ($minute === 'R' && $expandr) {
 226              $minute = mt_rand(self::MINUTEMIN, self::MINUTEMAX);
 227          }
 228          $this->minute = $minute;
 229      }
 230  
 231      /**
 232       * Getter for $minute.
 233       *
 234       * @return string
 235       */
 236      public function get_minute() {
 237          return $this->minute;
 238      }
 239  
 240      /**
 241       * Setter for $hour. Accepts a special 'R' value
 242       * which will be translated to a random hour.
 243       *
 244       * @param string $hour
 245       * @param bool $expandr - if true (default) an 'R' value in a time is expanded to an appropriate int.
 246       *      If false, they are left as 'R'
 247       */
 248      public function set_hour($hour, $expandr = true) {
 249          if ($hour === 'R' && $expandr) {
 250              $hour = mt_rand(self::HOURMIN, self::HOURMAX);
 251          }
 252          $this->hour = $hour;
 253      }
 254  
 255      /**
 256       * Getter for $hour.
 257       *
 258       * @return string
 259       */
 260      public function get_hour() {
 261          return $this->hour;
 262      }
 263  
 264      /**
 265       * Setter for $month.
 266       *
 267       * @param string $month
 268       */
 269      public function set_month($month) {
 270          $this->month = $month;
 271      }
 272  
 273      /**
 274       * Getter for $month.
 275       *
 276       * @return string
 277       */
 278      public function get_month() {
 279          return $this->month;
 280      }
 281  
 282      /**
 283       * Setter for $day.
 284       *
 285       * @param string $day
 286       */
 287      public function set_day($day) {
 288          $this->day = $day;
 289      }
 290  
 291      /**
 292       * Getter for $day.
 293       *
 294       * @return string
 295       */
 296      public function get_day() {
 297          return $this->day;
 298      }
 299  
 300      /**
 301       * Setter for $dayofweek.
 302       *
 303       * @param string $dayofweek
 304       * @param bool $expandr - if true (default) an 'R' value in a time is expanded to an appropriate int.
 305       *      If false, they are left as 'R'
 306       */
 307      public function set_day_of_week($dayofweek, $expandr = true) {
 308          if ($dayofweek === 'R' && $expandr) {
 309              $dayofweek = mt_rand(self::DAYOFWEEKMIN, self::DAYOFWEEKMAX);
 310          }
 311          $this->dayofweek = $dayofweek;
 312      }
 313  
 314      /**
 315       * Getter for $dayofweek.
 316       *
 317       * @return string
 318       */
 319      public function get_day_of_week() {
 320          return $this->dayofweek;
 321      }
 322  
 323      /**
 324       * Setter for $disabled.
 325       *
 326       * @param bool $disabled
 327       */
 328      public function set_disabled($disabled) {
 329          $this->disabled = (bool)$disabled;
 330      }
 331  
 332      /**
 333       * Getter for $disabled.
 334       * @return bool
 335       */
 336      public function get_disabled() {
 337          return $this->disabled;
 338      }
 339  
 340      /**
 341       * Override this function if you want this scheduled task to run, even if the component is disabled.
 342       *
 343       * @return bool
 344       */
 345      public function get_run_if_component_disabled() {
 346          return false;
 347      }
 348  
 349      /**
 350       * Informs whether the given field is valid.
 351       * Use the constants FIELD_* to identify the field.
 352       * Have to be called after the method set_{field}(string).
 353       *
 354       * @param string $field field identifier; expected values from constants FIELD_*.
 355       *
 356       * @return bool true if given field is valid. false otherwise.
 357       */
 358      public function is_valid(string $field): bool {
 359          return !empty($this->get_valid($field));
 360      }
 361  
 362      /**
 363       * Calculates the list of valid values according to the given field and stored expression.
 364       *
 365       * @param string $field field identifier. Must be one of those FIELD_*.
 366       *
 367       * @return array(int) list of matching values.
 368       *
 369       * @throws \coding_exception when passed an invalid field identifier.
 370       */
 371      private function get_valid(string $field): array {
 372          switch($field) {
 373              case self::FIELD_MINUTE:
 374                  $min = self::MINUTEMIN;
 375                  $max = self::MINUTEMAX;
 376                  break;
 377              case self::FIELD_HOUR:
 378                  $min = self::HOURMIN;
 379                  $max = self::HOURMAX;
 380                  break;
 381              case self::FIELD_DAY:
 382                  $min = self::DAYMIN;
 383                  $max = self::DAYMAX;
 384                  break;
 385              case self::FIELD_MONTH:
 386                  $min = self::MONTHMIN;
 387                  $max = self::MONTHMAX;
 388                  break;
 389              case self::FIELD_DAYOFWEEK:
 390                  $min = self::DAYOFWEEKMIN;
 391                  $max = self::DAYOFWEEKMAXINPUT;
 392                  break;
 393              default:
 394                  throw new \coding_exception("Field '$field' is not a valid crontab identifier.");
 395          }
 396  
 397          $result = $this->eval_cron_field($this->{$field}, $min, $max);
 398          if ($field === self::FIELD_DAYOFWEEK) {
 399              // For day of week, 0 and 7 both mean Sunday; if there is a 7 we set 0. The result array is sorted.
 400              if (end($result) === 7) {
 401                  // Remove last element.
 402                  array_pop($result);
 403                  // Insert 0 as first element if it's not there already.
 404                  if (reset($result) !== 0) {
 405                      array_unshift($result, 0);
 406                  }
 407              }
 408          }
 409          return $result;
 410      }
 411  
 412      /**
 413       * Take a cron field definition and return an array of valid numbers with the range min-max.
 414       *
 415       * @param string $field - The field definition.
 416       * @param int $min - The minimum allowable value.
 417       * @param int $max - The maximum allowable value.
 418       * @return array(int)
 419       */
 420      public function eval_cron_field($field, $min, $max) {
 421          // Cleanse the input.
 422          $field = trim($field);
 423  
 424          // Format for a field is:
 425          // <fieldlist> := <range>(/<step>)(,<fieldlist>)
 426          // <step>  := int
 427          // <range> := <any>|<int>|<min-max>
 428          // <any>   := *
 429          // <min-max> := int-int
 430          // End of format BNF.
 431  
 432          // This function is complicated but is covered by unit tests.
 433          $range = array();
 434  
 435          $matches = array();
 436          preg_match_all('@[0-9]+|\*|,|/|-@', $field, $matches);
 437  
 438          $last = 0;
 439          $inrange = false;
 440          $instep = false;
 441          foreach ($matches[0] as $match) {
 442              if ($match == '*') {
 443                  array_push($range, range($min, $max));
 444              } else if ($match == '/') {
 445                  $instep = true;
 446              } else if ($match == '-') {
 447                  $inrange = true;
 448              } else if (is_numeric($match)) {
 449                  if ($min > $match || $match > $max) {
 450                      // This is a value error: The value lays out of the expected range of values.
 451                      return [];
 452                  }
 453                  if ($instep) {
 454                      // Normalise range property, account for "5/10".
 455                      $insteprange = $range[count($range) - 1];
 456                      if (!is_array($insteprange)) {
 457                          $range[count($range) - 1] = range($insteprange, $max);
 458                      }
 459                      for ($i = 0; $i < count($range[count($range) - 1]); $i++) {
 460                          if (($i) % $match != 0) {
 461                              $range[count($range) - 1][$i] = -1;
 462                          }
 463                      }
 464                      $instep = false;
 465                  } else if ($inrange) {
 466                      if (count($range)) {
 467                          $range[count($range) - 1] = range($last, $match);
 468                      }
 469                      $inrange = false;
 470                  } else {
 471                      array_push($range, $match);
 472                      $last = $match;
 473                  }
 474              }
 475          }
 476  
 477          // If inrange or instep were not processed, there is a syntax error.
 478          // Cleanup any existing values to show up the error.
 479          if ($inrange || $instep) {
 480              return [];
 481          }
 482  
 483          // Flatten the result.
 484          $result = array();
 485          foreach ($range as $r) {
 486              if (is_array($r)) {
 487                  foreach ($r as $rr) {
 488                      if ($rr >= $min && $rr <= $max) {
 489                          $result[$rr] = 1;
 490                      }
 491                  }
 492              } else if (is_numeric($r)) {
 493                  if ($r >= $min && $r <= $max) {
 494                      $result[$r] = 1;
 495                  }
 496              }
 497          }
 498          $result = array_keys($result);
 499          sort($result, SORT_NUMERIC);
 500          return $result;
 501      }
 502  
 503      /**
 504       * Assuming $list is an ordered list of items, this function returns the item
 505       * in the list that is greater than or equal to the current value (or 0). If
 506       * no value is greater than or equal, this will return the first valid item in the list.
 507       * If list is empty, this function will return 0.
 508       *
 509       * @param int $current The current value
 510       * @param int[] $list The list of valid items.
 511       * @return int $next.
 512       */
 513      private function next_in_list($current, $list) {
 514          foreach ($list as $l) {
 515              if ($l >= $current) {
 516                  return $l;
 517              }
 518          }
 519          if (count($list)) {
 520              return $list[0];
 521          }
 522  
 523          return 0;
 524      }
 525  
 526      /**
 527       * Calculate when this task should next be run based on the schedule.
 528       *
 529       * @param int $now Current time, for testing (leave 0 to use default time)
 530       * @return int $nextruntime.
 531       */
 532      public function get_next_scheduled_time(int $now = 0): int {
 533          if (!$now) {
 534              $now = time();
 535          }
 536  
 537          // We need to change to the server timezone before using php date() functions.
 538          \core_date::set_default_server_timezone();
 539  
 540          $validminutes = $this->get_valid(self::FIELD_MINUTE);
 541          $validhours = $this->get_valid(self::FIELD_HOUR);
 542          $validdays = $this->get_valid(self::FIELD_DAY);
 543          $validdaysofweek = $this->get_valid(self::FIELD_DAYOFWEEK);
 544          $validmonths = $this->get_valid(self::FIELD_MONTH);
 545  
 546          // If any of the fields contain no valid data then the task will never run.
 547          if (!$validminutes || !$validhours || !$validdays || !$validdaysofweek || !$validmonths) {
 548              return self::NEVER_RUN_TIME;
 549          }
 550  
 551          $result = self::get_next_scheduled_time_inner($now, $validminutes, $validhours, $validdays, $validdaysofweek, $validmonths);
 552          return $result;
 553      }
 554  
 555      /**
 556       * Recursively calculate the next valid time for this task.
 557       *
 558       * @param int $now Start time
 559       * @param array $validminutes Valid minutes
 560       * @param array $validhours Valid hours
 561       * @param array $validdays Valid days
 562       * @param array $validdaysofweek Valid days of week
 563       * @param array $validmonths Valid months
 564       * @param int $originalyear Zero for first call, original year for recursive calls
 565       * @return int Next run time
 566       */
 567      protected function get_next_scheduled_time_inner(int $now, array $validminutes, array $validhours,
 568              array $validdays, array $validdaysofweek, array $validmonths, int $originalyear = 0) {
 569          $currentyear = (int)date('Y', $now);
 570          if ($originalyear) {
 571              // In recursive calls, check we didn't go more than 8 years ahead, that indicates the
 572              // user has chosen an impossible date. 8 years is the maximum time, considering a task
 573              // set to run on 29 February over a century boundary when a leap year is skipped.
 574              if ($currentyear - $originalyear > 8) {
 575                  // Use this time if it's never going to happen.
 576                  return self::NEVER_RUN_TIME;
 577              }
 578              $firstyear = $originalyear;
 579          } else {
 580              $firstyear = $currentyear;
 581          }
 582          $currentmonth = (int)date('n', $now);
 583  
 584          // Evaluate month first.
 585          $nextvalidmonth = $this->next_in_list($currentmonth, $validmonths);
 586          if ($nextvalidmonth < $currentmonth) {
 587              $currentyear += 1;
 588          }
 589          // If we moved to another month, set the current time to start of month, and restart calculations.
 590          if ($nextvalidmonth !== $currentmonth) {
 591              $newtime = strtotime($currentyear . '-' . $nextvalidmonth . '-01 00:00');
 592              return $this->get_next_scheduled_time_inner($newtime, $validminutes, $validhours, $validdays,
 593                      $validdaysofweek, $validmonths, $firstyear);
 594          }
 595  
 596          // Special handling for dayofmonth vs dayofweek (see man 5 cron). If both are specified, then
 597          // it is ok to continue when either matches. If only one is specified then it must match.
 598          $currentday = (int)date("j", $now);
 599          $currentdayofweek = (int)date("w", $now);
 600          $nextvaliddayofmonth = self::next_in_list($currentday, $validdays);
 601          $nextvaliddayofweek = self::next_in_list($currentdayofweek, $validdaysofweek);
 602          $daysincrementbymonth = $nextvaliddayofmonth - $currentday;
 603          $daysinmonth = (int)date('t', $now);
 604          if ($nextvaliddayofmonth < $currentday) {
 605              $daysincrementbymonth += $daysinmonth;
 606          }
 607  
 608          $daysincrementbyweek = $nextvaliddayofweek - $currentdayofweek;
 609          if ($nextvaliddayofweek < $currentdayofweek) {
 610              $daysincrementbyweek += 7;
 611          }
 612  
 613          if ($this->dayofweek == '*') {
 614              $daysincrement = $daysincrementbymonth;
 615          } else if ($this->day == '*') {
 616              $daysincrement = $daysincrementbyweek;
 617          } else {
 618              // Take the smaller increment of days by month or week.
 619              $daysincrement = min($daysincrementbymonth, $daysincrementbyweek);
 620          }
 621  
 622          // If we moved day, recurse using new start time.
 623          if ($daysincrement != 0) {
 624              $newtime = strtotime($currentyear . '-' . $currentmonth . '-' . $currentday .
 625                      ' 00:00 +' . $daysincrement . ' days');
 626              return $this->get_next_scheduled_time_inner($newtime, $validminutes, $validhours, $validdays,
 627                      $validdaysofweek, $validmonths, $firstyear);
 628          }
 629  
 630          $currenthour = (int)date('H', $now);
 631          $nextvalidhour = $this->next_in_list($currenthour, $validhours);
 632          if ($nextvalidhour != $currenthour) {
 633              if ($nextvalidhour < $currenthour) {
 634                  $offset = ' +1 day';
 635              } else {
 636                  $offset = '';
 637              }
 638              $newtime = strtotime($currentyear . '-' . $currentmonth . '-' . $currentday . ' ' . $nextvalidhour .
 639                      ':00' . $offset);
 640              return $this->get_next_scheduled_time_inner($newtime, $validminutes, $validhours, $validdays,
 641                  $validdaysofweek, $validmonths, $firstyear);
 642          }
 643  
 644          // Round time down to an exact minute because we need to use numeric calculations on it now.
 645          // If we construct times based on all the components, it will mess up around DST changes
 646          // (because there are two times with the same representation).
 647          $now = intdiv($now, 60) * 60;
 648  
 649          $currentminute = (int)date('i', $now);
 650          $nextvalidminute = $this->next_in_list($currentminute, $validminutes);
 651          if ($nextvalidminute == $currentminute && !$originalyear) {
 652              // This is not a recursive call so time has not moved on at all yet. We can't use the
 653              // same minute as now because it has already happened, it has to be at least one minute
 654              // later, so update time and retry.
 655              $newtime = $now + 60;
 656              return $this->get_next_scheduled_time_inner($newtime, $validminutes, $validhours, $validdays,
 657                  $validdaysofweek, $validmonths, $firstyear);
 658          }
 659  
 660          if ($nextvalidminute < $currentminute) {
 661              // The time is in the next hour so we need to recurse. Don't use strtotime at this
 662              // point because it will mess up around DST changes.
 663              $minutesforward = $nextvalidminute + 60 - $currentminute;
 664              $newtime = $now + $minutesforward * 60;
 665              return $this->get_next_scheduled_time_inner($newtime, $validminutes, $validhours, $validdays,
 666                  $validdaysofweek, $validmonths, $firstyear);
 667          }
 668  
 669          // The next valid minute is in the same hour so it must be valid according to all other
 670          // checks and we can finally return it.
 671          return $now + ($nextvalidminute - $currentminute) * 60;
 672      }
 673  
 674      /**
 675       * Informs whether this task can be run.
 676       *
 677       * @return bool true when this task can be run. false otherwise.
 678       */
 679      public function can_run(): bool {
 680          return $this->is_component_enabled() || $this->get_run_if_component_disabled();
 681      }
 682  
 683      /**
 684       * Checks whether the component and the task disabled flag enables to run this task.
 685       * This do not checks whether the task manager allows running them or if the
 686       * site allows tasks to "run now".
 687       *
 688       * @return bool true if task is enabled. false otherwise.
 689       */
 690      public function is_enabled(): bool {
 691          return $this->can_run() && !$this->get_disabled();
 692      }
 693  
 694      /**
 695       * Produces a valid id string to use as id attribute based on the given FQCN class name.
 696       *
 697       * @param string $classname FQCN of a task.
 698       * @return string valid string to be used as id attribute.
 699       */
 700      public static function get_html_id(string $classname): string {
 701          return str_replace('\\', '-', ltrim($classname, '\\'));
 702      }
 703  }