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 namespace core; 18 19 use coding_exception; 20 use core_php_time_limit; 21 use moodle_exception; 22 use stdClass; 23 24 // Disable the moodle.PHP.ForbiddenFunctions.FoundWithAlternative sniff for this file. 25 // It detects uses of error_log() which are valid in this file. 26 // phpcs:disable moodle.PHP.ForbiddenFunctions.FoundWithAlternative 27 28 /** 29 * Cron and adhoc task functionality. 30 * 31 * @package core 32 * @copyright 2023 Andrew Lyons <andrew@nicols.co.uk> 33 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 34 */ 35 class cron { 36 37 /** @var ?stdClass A copy of the standard cron 'user' */ 38 protected static ?stdClass $cronuser = null; 39 40 /** @var ?stdClass The cron user's session data */ 41 protected static ?stdClass $cronsession = null; 42 43 /** 44 * Use a default value of 3 minutes. 45 * The recommended cron frequency is every minute, and the default adhoc concurrency is 3. 46 * A default value of 3 minutes allows all adhoc tasks to be run concurrently at their default value. 47 * 48 * @var int The default keepalive value for the main cron runner 49 */ 50 public const DEFAULT_MAIN_PROCESS_KEEPALIVE = 3 * MINSECS; 51 52 /** 53 * @var int The max keepalive value for the main cron runner 54 */ 55 public const MAX_MAIN_PROCESS_KEEPALIVE = 15 * MINSECS; 56 57 /** 58 * Execute cron tasks 59 * 60 * @param int|null $keepalive The keepalive time for this cron run. 61 */ 62 public static function run_main_process(?int $keepalive = null): void { 63 global $CFG, $DB; 64 65 if (CLI_MAINTENANCE) { 66 echo "CLI maintenance mode active, cron execution suspended.\n"; 67 exit(1); 68 } 69 70 if (moodle_needs_upgrading()) { 71 echo "Moodle upgrade pending, cron execution suspended.\n"; 72 exit(1); 73 } 74 75 require_once($CFG->libdir . '/adminlib.php'); 76 77 if (!empty($CFG->showcronsql)) { 78 $DB->set_debug(true); 79 } 80 if (!empty($CFG->showcrondebugging)) { 81 set_debugging(DEBUG_DEVELOPER, true); 82 } 83 84 core_php_time_limit::raise(); 85 86 // Increase memory limit. 87 raise_memory_limit(MEMORY_EXTRA); 88 89 // Emulate normal session. - we use admin account by default. 90 self::setup_user(); 91 92 // Start output log. 93 $timenow = time(); 94 mtrace("Server Time: " . date('r', $timenow) . "\n\n"); 95 96 // Record start time and interval between the last cron runs. 97 $laststart = get_config('tool_task', 'lastcronstart'); 98 set_config('lastcronstart', $timenow, 'tool_task'); 99 if ($laststart) { 100 // Record the interval between last two runs (always store at least 1 second). 101 set_config('lastcroninterval', max(1, $timenow - $laststart), 'tool_task'); 102 } 103 104 // Determine the time when the cron should finish. 105 if ($keepalive === null) { 106 $keepalive = get_config('core', 'cron_keepalive'); 107 if ($keepalive === false) { 108 $keepalive = self::DEFAULT_MAIN_PROCESS_KEEPALIVE; 109 } 110 } 111 112 if ($keepalive > self::MAX_MAIN_PROCESS_KEEPALIVE) { 113 // Attempt to prevent abnormally long keepalives. 114 mtrace("Cron keepalive time is too long, reducing to 15 minutes."); 115 $keepalive = self::MAX_MAIN_PROCESS_KEEPALIVE; 116 } 117 118 // Calculate the finish time based on the start time and keepalive. 119 $finishtime = $timenow + $keepalive; 120 121 do { 122 $startruntime = microtime(); 123 124 // Run all scheduled tasks. 125 self::run_scheduled_tasks(time(), $timenow); 126 127 // Run adhoc tasks. 128 self::run_adhoc_tasks(time(), 0, true, $timenow); 129 130 mtrace("Cron run completed correctly"); 131 132 gc_collect_cycles(); 133 134 $completiontime = date('H:i:s'); 135 $difftime = microtime_diff($startruntime, microtime()); 136 $memoryused = display_size(memory_get_usage()); 137 138 $message = "Cron completed at {$completiontime} in {$difftime} seconds. Memory used: {$memoryused}."; 139 140 // Check if we should continue to run. 141 // Only continue to run if: 142 // - The finish time has not been reached; and 143 // - The graceful exit flag has not been set; and 144 // - The static caches have not been cleared since the start of the cron run. 145 $remaining = $finishtime - time(); 146 $runagain = $remaining > 0; 147 $runagain = $runagain && !\core\local\cli\shutdown::should_gracefully_exit(); 148 $runagain = $runagain && !\core\task\manager::static_caches_cleared_since($timenow); 149 150 if ($runagain) { 151 $message .= " Continuing to check for tasks for {$remaining} more seconds."; 152 mtrace($message); 153 sleep(1); 154 155 // Re-check the graceful exit and cache clear flags after sleeping as these may have changed. 156 $runagain = $runagain && !\core\local\cli\shutdown::should_gracefully_exit(); 157 $runagain = $runagain && !\core\task\manager::static_caches_cleared_since($timenow); 158 } else { 159 mtrace($message); 160 } 161 } while ($runagain); 162 } 163 164 /** 165 * Execute all queued scheduled tasks, applying necessary concurrency limits and time limits. 166 * 167 * @param int $startruntime The time this run started. 168 * @param null|int $startprocesstime The time the process that owns this runner started. 169 * @throws \moodle_exception 170 */ 171 public static function run_scheduled_tasks( 172 int $startruntime, 173 ?int $startprocesstime = null, 174 ): void { 175 // Allow a restriction on the number of scheduled task runners at once. 176 $cronlockfactory = \core\lock\lock_config::get_lock_factory('cron'); 177 $maxruns = get_config('core', 'task_scheduled_concurrency_limit'); 178 $maxruntime = get_config('core', 'task_scheduled_max_runtime'); 179 180 if ($startprocesstime === null) { 181 $startprocesstime = $startruntime; 182 } 183 184 $scheduledlock = null; 185 for ($run = 0; $run < $maxruns; $run++) { 186 // If we can't get a lock instantly it means runner N is already running 187 // so fail as fast as possible and try N+1 so we don't limit the speed at 188 // which we bring new runners into the pool. 189 if ($scheduledlock = $cronlockfactory->get_lock("scheduled_task_runner_{$run}", 0)) { 190 break; 191 } 192 } 193 194 if (!$scheduledlock) { 195 mtrace("Skipping processing of scheduled tasks. Concurrency limit reached."); 196 return; 197 } 198 199 $starttime = time(); 200 201 // Run all scheduled tasks. 202 try { 203 while ( 204 !\core\local\cli\shutdown::should_gracefully_exit() && 205 !\core\task\manager::static_caches_cleared_since($startprocesstime) && 206 $task = \core\task\manager::get_next_scheduled_task($startruntime) 207 ) { 208 self::run_inner_scheduled_task($task); 209 unset($task); 210 211 if ((time() - $starttime) > $maxruntime) { 212 mtrace("Stopping processing of scheduled tasks as time limit has been reached."); 213 break; 214 } 215 } 216 } finally { 217 // Release the scheduled task runner lock. 218 $scheduledlock->release(); 219 } 220 } 221 222 /** 223 * Execute all queued adhoc tasks, applying necessary concurrency limits and time limits. 224 * 225 * @param int $startruntime The time this run started. 226 * @param int $keepalive Keep this public static function alive for N seconds and poll for new adhoc tasks. 227 * @param bool $checklimits Should we check limits? 228 * @param null|int $startprocesstime The time this process started. 229 * @param int|null $maxtasks Limit number of tasks to run` 230 * @param null|string $classname Run only tasks of this class 231 * @throws \moodle_exception 232 */ 233 public static function run_adhoc_tasks( 234 int $startruntime, 235 $keepalive = 0, 236 $checklimits = true, 237 ?int $startprocesstime = null, 238 ?int $maxtasks = null, 239 ?string $classname = null, 240 ): void { 241 // Allow a restriction on the number of adhoc task runners at once. 242 $cronlockfactory = \core\lock\lock_config::get_lock_factory('cron'); 243 $maxruns = get_config('core', 'task_adhoc_concurrency_limit'); 244 $maxruntime = get_config('core', 'task_adhoc_max_runtime'); 245 246 if ($startprocesstime === null) { 247 $startprocesstime = $startruntime; 248 } 249 250 $adhoclock = null; 251 if ($checklimits) { 252 for ($run = 0; $run < $maxruns; $run++) { 253 // If we can't get a lock instantly it means runner N is already running 254 // so fail as fast as possible and try N+1 so we don't limit the speed at 255 // which we bring new runners into the pool. 256 if ($adhoclock = $cronlockfactory->get_lock("adhoc_task_runner_{$run}", 0)) { 257 break; 258 } 259 } 260 261 if (!$adhoclock) { 262 mtrace("Skipping processing of adhoc tasks. Concurrency limit reached."); 263 return; 264 } 265 } 266 267 $humantimenow = date('r', $startruntime); 268 $finishtime = $startruntime + $keepalive; 269 $waiting = false; 270 $taskcount = 0; 271 272 // Run all adhoc tasks. 273 while ( 274 !\core\local\cli\shutdown::should_gracefully_exit() && 275 !\core\task\manager::static_caches_cleared_since($startprocesstime) 276 ) { 277 278 if ($checklimits && (time() - $startruntime) >= $maxruntime) { 279 if ($waiting) { 280 $waiting = false; 281 mtrace(''); 282 } 283 mtrace("Stopping processing of adhoc tasks as time limit has been reached."); 284 break; 285 } 286 287 try { 288 $task = \core\task\manager::get_next_adhoc_task(time(), $checklimits, $classname); 289 } catch (\Throwable $e) { 290 if ($adhoclock) { 291 // Release the adhoc task runner lock. 292 $adhoclock->release(); 293 } 294 throw $e; 295 } 296 297 if ($task) { 298 if ($waiting) { 299 mtrace(''); 300 } 301 $waiting = false; 302 self::run_inner_adhoc_task($task); 303 self::set_process_title("Waiting for next adhoc task"); 304 $taskcount++; 305 if ($maxtasks && $taskcount >= $maxtasks) { 306 break; 307 } 308 unset($task); 309 } else { 310 $timeleft = $finishtime - time(); 311 if ($timeleft <= 0) { 312 break; 313 } 314 if (!$waiting) { 315 mtrace('Waiting for more adhoc tasks to be queued ', ''); 316 } else { 317 mtrace('.', ''); 318 } 319 $waiting = true; 320 self::set_process_title("Waiting {$timeleft}s for next adhoc task"); 321 sleep(1); 322 } 323 } 324 325 if ($waiting) { 326 mtrace(''); 327 } 328 329 mtrace("Ran {$taskcount} adhoc tasks found at {$humantimenow}"); 330 331 if ($adhoclock) { 332 // Release the adhoc task runner lock. 333 $adhoclock->release(); 334 } 335 } 336 337 /** 338 * Execute an adhoc task. 339 * 340 * @param int $taskid 341 */ 342 public static function run_adhoc_task(int $taskid): void { 343 $task = \core\task\manager::get_adhoc_task($taskid); 344 if (!$task->get_fail_delay() && $task->get_next_run_time() > time()) { 345 throw new \moodle_exception('wontrunfuturescheduledtask'); 346 } 347 348 self::run_inner_adhoc_task($task); 349 self::set_process_title("Running adhoc task $taskid"); 350 } 351 352 /** 353 * Execute all failed adhoc tasks. 354 * 355 * @param string|null $classname Run only tasks of this class 356 */ 357 public static function run_failed_adhoc_tasks(?string $classname = null): void { 358 global $DB; 359 360 $where = 'faildelay > 0'; 361 $params = []; 362 if ($classname) { 363 $where .= ' AND classname = :classname'; 364 $params['classname'] = \core\task\manager::get_canonical_class_name($classname); 365 } 366 $tasks = $DB->get_records_sql("SELECT * from {task_adhoc} WHERE $where", $params); 367 foreach ($tasks as $t) { 368 self::run_adhoc_task($t->id); 369 } 370 } 371 372 /** 373 * Shared code that handles running of a single scheduled task within the cron. 374 * 375 * Not intended for calling directly outside of this library! 376 * 377 * @param \core\task\task_base $task 378 */ 379 public static function run_inner_scheduled_task(\core\task\task_base $task) { 380 global $CFG, $DB; 381 $debuglevel = $CFG->debug; 382 383 \core\task\manager::scheduled_task_starting($task); 384 \core\task\logmanager::start_logging($task); 385 386 $fullname = $task->get_name() . ' (' . get_class($task) . ')'; 387 mtrace('Execute scheduled task: ' . $fullname); 388 self::set_process_title('Scheduled task: ' . get_class($task)); 389 self::trace_time_and_memory(); 390 $predbqueries = null; 391 $predbqueries = $DB->perf_get_queries(); 392 $pretime = microtime(1); 393 394 // Ensure that we have a clean session with the correct cron user. 395 self::setup_user(); 396 397 try { 398 get_mailer('buffer'); 399 self::prepare_core_renderer(); 400 // Temporarily increase debug level if task has failed and debugging isn't already at maximum. 401 if ($debuglevel !== DEBUG_DEVELOPER && $faildelay = $task->get_fail_delay()) { 402 mtrace('Debugging increased temporarily due to faildelay of ' . $faildelay); 403 set_debugging(DEBUG_DEVELOPER); 404 } 405 $task->execute(); 406 if ($DB->is_transaction_started()) { 407 throw new coding_exception("Task left transaction open"); 408 } 409 if (isset($predbqueries)) { 410 mtrace("... used " . ($DB->perf_get_queries() - $predbqueries) . " dbqueries"); 411 mtrace("... used " . (microtime(1) - $pretime) . " seconds"); 412 } 413 mtrace('Scheduled task complete: ' . $fullname); 414 \core\task\manager::scheduled_task_complete($task); 415 } catch (\Throwable $e) { 416 if ($DB && $DB->is_transaction_started()) { 417 error_log('Database transaction aborted automatically in ' . get_class($task)); 418 $DB->force_transaction_rollback(); 419 } 420 if (isset($predbqueries)) { 421 mtrace("... used " . ($DB->perf_get_queries() - $predbqueries) . " dbqueries"); 422 mtrace("... used " . (microtime(1) - $pretime) . " seconds"); 423 } 424 mtrace('Scheduled task failed: ' . $fullname . ',' . $e->getMessage()); 425 if ($CFG->debugdeveloper) { 426 if (!empty($e->debuginfo)) { 427 mtrace("Debug info:"); 428 mtrace($e->debuginfo); 429 } 430 mtrace("Backtrace:"); 431 mtrace(format_backtrace($e->getTrace(), true)); 432 } 433 \core\task\manager::scheduled_task_failed($task); 434 } finally { 435 // Reset debugging if it changed. 436 if ($CFG->debug !== $debuglevel) { 437 set_debugging($debuglevel); 438 } 439 // Reset back to the standard admin user. 440 self::setup_user(); 441 self::set_process_title('Waiting for next scheduled task'); 442 self::prepare_core_renderer(true); 443 } 444 get_mailer('close'); 445 } 446 447 /** 448 * Shared code that handles running of a single adhoc task within the cron. 449 * 450 * @param \core\task\adhoc_task $task 451 */ 452 public static function run_inner_adhoc_task(\core\task\adhoc_task $task) { 453 global $CFG, $DB; 454 $debuglevel = $CFG->debug; 455 456 \core\task\manager::adhoc_task_starting($task); 457 \core\task\logmanager::start_logging($task); 458 459 mtrace("Execute adhoc task: " . get_class($task)); 460 mtrace("Adhoc task id: " . $task->get_id()); 461 mtrace("Adhoc task custom data: " . $task->get_custom_data_as_string()); 462 self::set_process_title('Adhoc task: ' . $task->get_id() . ' ' . get_class($task)); 463 self::trace_time_and_memory(); 464 $predbqueries = null; 465 $predbqueries = $DB->perf_get_queries(); 466 $pretime = microtime(1); 467 468 if ($userid = $task->get_userid()) { 469 // This task has a userid specified. 470 if ($user = \core_user::get_user($userid)) { 471 // User found. Check that they are suitable. 472 try { 473 \core_user::require_active_user($user, true, true); 474 } catch (moodle_exception $e) { 475 mtrace("User {$userid} cannot be used to run an adhoc task: " . get_class($task) . ". Cancelling task."); 476 $user = null; 477 } 478 } else { 479 // Unable to find the user for this task. 480 // A user missing in the database will never reappear. 481 mtrace("User {$userid} could not be found for adhoc task: " . get_class($task) . ". Cancelling task."); 482 } 483 484 if (empty($user)) { 485 // A user missing in the database will never reappear so the task needs to be failed to ensure that locks are 486 // removed, and then removed to prevent future runs. 487 // A task running as a user should only be run as that user. 488 \core\task\manager::adhoc_task_failed($task); 489 $DB->delete_records('task_adhoc', ['id' => $task->get_id()]); 490 491 return; 492 } 493 494 self::setup_user($user); 495 } else { 496 // No user specified, ensure that we have a clean session with the correct cron user. 497 self::setup_user(); 498 } 499 500 try { 501 get_mailer('buffer'); 502 self::prepare_core_renderer(); 503 // Temporarily increase debug level if task has failed and debugging isn't already at maximum. 504 if ($debuglevel !== DEBUG_DEVELOPER && $faildelay = $task->get_fail_delay()) { 505 mtrace('Debugging increased temporarily due to faildelay of ' . $faildelay); 506 set_debugging(DEBUG_DEVELOPER); 507 } 508 $task->execute(); 509 if ($DB->is_transaction_started()) { 510 throw new coding_exception("Task left transaction open"); 511 } 512 if (isset($predbqueries)) { 513 mtrace("... used " . ($DB->perf_get_queries() - $predbqueries) . " dbqueries"); 514 mtrace("... used " . (microtime(1) - $pretime) . " seconds"); 515 } 516 mtrace("Adhoc task complete: " . get_class($task)); 517 \core\task\manager::adhoc_task_complete($task); 518 } catch (\Throwable $e) { 519 if ($DB && $DB->is_transaction_started()) { 520 error_log('Database transaction aborted automatically in ' . get_class($task)); 521 $DB->force_transaction_rollback(); 522 } 523 if (isset($predbqueries)) { 524 mtrace("... used " . ($DB->perf_get_queries() - $predbqueries) . " dbqueries"); 525 mtrace("... used " . (microtime(1) - $pretime) . " seconds"); 526 } 527 mtrace("Adhoc task failed: " . get_class($task) . "," . $e->getMessage()); 528 if ($CFG->debugdeveloper) { 529 if (!empty($e->debuginfo)) { 530 mtrace("Debug info:"); 531 mtrace($e->debuginfo); 532 } 533 mtrace("Backtrace:"); 534 mtrace(format_backtrace($e->getTrace(), true)); 535 } 536 \core\task\manager::adhoc_task_failed($task); 537 } finally { 538 // Reset debug level if it changed. 539 if ($CFG->debug !== $debuglevel) { 540 set_debugging($debuglevel); 541 } 542 // Reset back to the standard admin user. 543 self::setup_user(); 544 self::prepare_core_renderer(true); 545 } 546 get_mailer('close'); 547 } 548 549 /** 550 * Sets the process title 551 * 552 * This makes it very easy for a sysadmin to immediately see what task 553 * a cron process is running at any given moment. 554 * 555 * @param string $title process status title 556 */ 557 public static function set_process_title(string $title) { 558 global $CFG; 559 if (CLI_SCRIPT) { 560 require_once($CFG->libdir . '/clilib.php'); 561 $datetime = userdate(time(), '%b %d, %H:%M:%S'); 562 cli_set_process_title_suffix("$datetime $title"); 563 } 564 } 565 566 /** 567 * Output some standard information during cron runs. Specifically current time 568 * and memory usage. This method also does gc_collect_cycles() (before displaying 569 * memory usage) to try to help PHP manage memory better. 570 */ 571 public static function trace_time_and_memory() { 572 gc_collect_cycles(); 573 mtrace('... started ' . date('H:i:s') . '. Current memory use ' . display_size(memory_get_usage()) . '.'); 574 } 575 576 /** 577 * Prepare the output renderer for the cron run. 578 * 579 * This involves creating a new $PAGE, and $OUTPUT fresh for each task and prevents any one task from influencing 580 * any other. 581 * 582 * @param bool $restore Whether to restore the original PAGE and OUTPUT 583 */ 584 public static function prepare_core_renderer($restore = false) { 585 global $OUTPUT, $PAGE; 586 587 // Store the original PAGE and OUTPUT values so that they can be reset at a later point to the original. 588 // This should not normally be required, but may be used in places such as the scheduled task tool's "Run now" 589 // functionality. 590 static $page = null; 591 static $output = null; 592 593 if (null === $page) { 594 $page = $PAGE; 595 } 596 597 if (null === $output) { 598 $output = $OUTPUT; 599 } 600 601 if (!empty($restore)) { 602 $PAGE = $page; 603 $page = null; 604 605 $OUTPUT = $output; 606 $output = null; 607 } else { 608 // Setup a new General renderer. 609 // Cron tasks may produce output to be used in web, so we must use the appropriate renderer target. 610 // This allows correct use of templates, etc. 611 $PAGE = new \moodle_page(); 612 $OUTPUT = new \core_renderer($PAGE, RENDERER_TARGET_GENERAL); 613 } 614 } 615 616 /** 617 * Sets up a user and course environment in cron. 618 * 619 * Note: This function is intended only for use in: 620 * - the cron runner scripts 621 * - individual tasks which extend the adhoc_task and scheduled_task classes 622 * - unit tests related to tasks 623 * - other parts of the cron/task system 624 * 625 * Please note that this function stores cache data statically. 626 * @see reset_user_cache() to reset this cache. 627 * 628 * @param null|stdClass $user full user object, null means default cron user (admin) 629 * @param null|stdClass $course full course record, null means $SITE 630 * @param null|bool $leavepagealone If specified, stops it messing with global page object 631 */ 632 public static function setup_user(?stdClass $user = null, ?stdClass $course = null, bool $leavepagealone = false): void { 633 // This function uses the $GLOBALS super global. Disable the VariableNameLowerCase sniff for this function. 634 // phpcs:disable moodle.NamingConventions.ValidVariableName.VariableNameLowerCase 635 global $CFG, $SITE, $PAGE; 636 637 if (!CLI_SCRIPT && !$leavepagealone) { 638 throw new coding_exception('It is not possible to use \core\cron\setup_user() in normal requests!'); 639 } 640 641 if (empty(self::$cronuser)) { 642 // The cron user is essentially the admin user, but with some value removed. 643 // We ginore the timezone language, and locale preferences - use the site default instead. 644 self::$cronuser = get_admin(); 645 self::$cronuser->timezone = $CFG->timezone; 646 self::$cronuser->lang = ''; 647 self::$cronuser->theme = ''; 648 unset(self::$cronuser->description); 649 650 self::$cronsession = new stdClass(); 651 } 652 653 if (!$user) { 654 // Cached default cron user (==modified admin for now). 655 \core\session\manager::init_empty_session(); 656 \core\session\manager::set_user(self::$cronuser); 657 $GLOBALS['SESSION'] = self::$cronsession; 658 } else { 659 // Emulate real user session - needed for caps in cron. 660 if ($GLOBALS['USER']->id != $user->id) { 661 \core\session\manager::init_empty_session(); 662 \core\session\manager::set_user($user); 663 } 664 } 665 666 // TODO MDL-19774 relying on global $PAGE in cron is a bad idea. 667 // Temporary hack so that cron does not give fatal errors. 668 if (!$leavepagealone) { 669 $PAGE = new \moodle_page(); 670 $PAGE->set_course($course ?? $SITE); 671 } 672 673 // TODO: it should be possible to improve perf by caching some limited number of users here. 674 // phpcs:enable 675 } 676 677 /** 678 * Resets the cache for the cron user used by `setup_user()`. 679 */ 680 public static function reset_user_cache(): void { 681 self::$cronuser = null; 682 self::$cronsession = null; 683 \core\session\manager::init_empty_session(); 684 } 685 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body