From 9d7fbb1b864df1e24fb00dae2ea7e304a30aa4a5 Mon Sep 17 00:00:00 2001 From: Carl Schwan Date: Thu, 2 Oct 2025 15:48:42 +0200 Subject: [PATCH] refactor: Move cron setup to a service This will allow in the future the following things: - Create unit tests for it - Make cron.php a occ command - Make webcron a proper controller Signed-off-by: Carl Schwan --- build/psalm-baseline.xml | 7 - core/Service/CronService.php | 246 +++++++++++++++++++ cron.php | 252 +++----------------- lib/composer/composer/autoload_classmap.php | 1 + lib/composer/composer/autoload_static.php | 1 + 5 files changed, 281 insertions(+), 226 deletions(-) create mode 100644 core/Service/CronService.php diff --git a/build/psalm-baseline.xml b/build/psalm-baseline.xml index df8b4d89e8b..08de4aaff0b 100644 --- a/build/psalm-baseline.xml +++ b/build/psalm-baseline.xml @@ -3073,13 +3073,6 @@ - - - ['message' => 'Background jobs disabled!']])]]> - ['message' => 'Backgroundjobs are using system cron!']])]]> - - - diff --git a/core/Service/CronService.php b/core/Service/CronService.php new file mode 100644 index 00000000000..d5b4034a338 --- /dev/null +++ b/core/Service/CronService.php @@ -0,0 +1,246 @@ +verboseCallback = $callback; + } + + /** + * @throws \RuntimeException + */ + public function run(?array $jobClasses): void { + if (Util::needUpgrade()) { + $this->logger->debug('Update required, skipping cron', ['app' => 'core']); + return; + } + + if ($this->config->getSystemValueBool('maintenance', false)) { + $this->logger->debug('We are in maintenance mode, skipping cron', ['app' => 'core']); + return; + } + + // Don't do anything if Nextcloud has not been installed + if (!$this->config->getSystemValueBool('installed', false)) { + return; + } + + // load all apps to get all api routes properly setup + $this->appManager->loadApps(); + $this->session->close(); + + // initialize a dummy memory session + $session = new Memory(); + $session = $this->cryptoWrapper->wrapSession($session); + $this->sessionStorage->setSession($session); + $this->userSession->setSession($session); + $this->store->setSession($session); + + $this->tempManager->cleanOld(); + + // Exit if background jobs are disabled! + $appMode = $this->appConfig->getValueString('core', 'backgroundjobs_mode', 'ajax'); + if ($appMode === 'none') { + throw new \RuntimeException('Background Jobs are disabled!'); + } + + if ($this->isCLI) { + $this->runCli($appMode, $jobClasses); + } else { + $this->runWeb($appMode); + } + + // Log the successful cron execution + $this->appConfig->setValueInt('core', 'lastcron', time()); + } + + /** + * @throws \RuntimeException + */ + private function runCli(string $appMode, ?array $jobClasses): void { + // set to run indefinitely if needed + if (!str_contains(@ini_get('disable_functions'), 'set_time_limit')) { + @set_time_limit(0); + } + + // the cron job must be executed with the right user + if (!function_exists('posix_getuid')) { + throw new \RuntimeException('The posix extensions are required - see https://www.php.net/manual/en/book.posix.php'); + } + + $user = posix_getuid(); + $configUser = fileowner(OC::$configDir . 'config.php'); + if ($user !== $configUser) { + throw new \RuntimeException('Console has to be executed with the user that owns the file config/config.php.' . PHP_EOL . 'Current user id: ' . $user . PHP_EOL . 'Owner id of config.php: ' . $configUser . PHP_EOL); + } + + // We call Nextcloud from the CLI (aka cron) + if ($appMode !== 'cron') { + $this->appConfig->setValueString('core', 'backgroundjobs_mode', 'cron'); + } + + // Low-load hours + $onlyTimeSensitive = false; + $startHour = $this->config->getSystemValueInt('maintenance_window_start', 100); + if ($jobClasses === null && $startHour <= 23) { + $date = new \DateTime('now', new \DateTimeZone('UTC')); + $currentHour = (int)$date->format('G'); + $endHour = $startHour + 4; + + if ($startHour <= 20) { + // Start time: 01:00 + // End time: 05:00 + // Only run sensitive tasks when it's before the start or after the end + $onlyTimeSensitive = $currentHour < $startHour || $currentHour > $endHour; + } else { + // Start time: 23:00 + // End time: 03:00 + $endHour -= 24; // Correct the end time from 27:00 to 03:00 + // Only run sensitive tasks when it's after the end and before the start + $onlyTimeSensitive = $currentHour > $endHour && $currentHour < $startHour; + } + } + + // We only ask for jobs for 14 minutes, because after 5 minutes the next + // system cron task should spawn and we want to have at most three + // cron jobs running in parallel. + $endTime = time() + 14 * 60; + + $executedJobs = []; + + while ($job = $this->jobList->getNext($onlyTimeSensitive, $jobClasses)) { + if (isset($executedJobs[$job->getId()])) { + $this->jobList->unlockJob($job); + break; + } + + $jobDetails = get_class($job) . ' (id: ' . $job->getId() . ', arguments: ' . json_encode($job->getArgument()) . ')'; + $this->logger->debug('CLI cron call has selected job ' . $jobDetails, ['app' => 'cron']); + + $timeBefore = time(); + $memoryBefore = memory_get_usage(); + $memoryPeakBefore = memory_get_peak_usage(); + + $this->verboseOutput('Starting job ' . $jobDetails); + + $job->start($this->jobList); + + $timeAfter = time(); + $memoryAfter = memory_get_usage(); + $memoryPeakAfter = memory_get_peak_usage(); + + $cronInterval = 5 * 60; + $timeSpent = $timeAfter - $timeBefore; + if ($timeSpent > $cronInterval) { + $logLevel = match (true) { + $timeSpent > $cronInterval * 128 => ILogger::FATAL, + $timeSpent > $cronInterval * 64 => ILogger::ERROR, + $timeSpent > $cronInterval * 16 => ILogger::WARN, + $timeSpent > $cronInterval * 8 => ILogger::INFO, + default => ILogger::DEBUG, + }; + $this->logger->log( + $logLevel, + 'Background job ' . $jobDetails . ' ran for ' . $timeSpent . ' seconds', + ['app' => 'cron'] + ); + } + + if ($memoryAfter - $memoryBefore > 50_000_000) { + $message = 'Used memory grew by more than 50 MB when executing job ' . $jobDetails . ': ' . Util::humanFileSize($memoryAfter) . ' (before: ' . Util::humanFileSize($memoryBefore) . ')'; + $this->logger->warning($message, ['app' => 'cron']); + $this->verboseOutput($message); + } + if ($memoryPeakAfter > 300_000_000 && $memoryPeakBefore <= 300_000_000) { + $message = 'Cron job used more than 300 MB of ram after executing job ' . $jobDetails . ': ' . Util::humanFileSize($memoryPeakAfter) . ' (before: ' . Util::humanFileSize($memoryPeakBefore) . ')'; + $this->logger->warning($message, ['app' => 'cron']); + $this->verboseOutput($message); + } + + // clean up after unclean jobs + $this->setupManager->tearDown(); + $this->tempManager->clean(); + + $this->verboseOutput('Job ' . $jobDetails . ' done in ' . ($timeAfter - $timeBefore) . ' seconds'); + + $this->jobList->setLastJob($job); + $executedJobs[$job->getId()] = true; + unset($job); + + if ($timeAfter > $endTime) { + break; + } + } + } + + private function runWeb(string $appMode): void { + if ($appMode === 'cron') { + // Cron is cron :-P + throw new \RuntimeException('Backgroundjobs are using system cron!'); + } else { + // Work and success :-) + $job = $this->jobList->getNext(); + if ($job != null) { + $this->logger->debug('WebCron call has selected job with ID ' . strval($job->getId()), ['app' => 'cron']); + $job->start($this->jobList); + $this->jobList->setLastJob($job); + } + } + } + + private function verboseOutput(string $message): void { + if ($this->verboseCallback !== null) { + call_user_func($this->verboseCallback, $message); + } + } +} diff --git a/cron.php b/cron.php index 445177a4501..cb858ab09b1 100644 --- a/cron.php +++ b/cron.php @@ -2,10 +2,9 @@ declare(strict_types=1); -use OC\Files\SetupManager; -use OC\Session\CryptoWrapper; -use OC\Session\Memory; -use OCP\ILogger; +use OC\Core\Service\CronService; +use OCP\Server; +use Psr\Log\LoggerInterface; /** * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors @@ -15,16 +14,6 @@ use OCP\ILogger; require_once __DIR__ . '/lib/versioncheck.php'; -use OCP\App\IAppManager; -use OCP\BackgroundJob\IJobList; -use OCP\IAppConfig; -use OCP\IConfig; -use OCP\ISession; -use OCP\ITempManager; -use OCP\Server; -use OCP\Util; -use Psr\Log\LoggerInterface; - try { require_once __DIR__ . '/lib/base.php'; @@ -45,219 +34,44 @@ Options: exit(0); } - if (Util::needUpgrade()) { - Server::get(LoggerInterface::class)->debug('Update required, skipping cron', ['app' => 'cron']); - exit; - } - - $config = Server::get(IConfig::class); - - if ($config->getSystemValueBool('maintenance', false)) { - Server::get(LoggerInterface::class)->debug('We are in maintenance mode, skipping cron', ['app' => 'cron']); - exit; - } - - // Don't do anything if Nextcloud has not been installed - if (!$config->getSystemValueBool('installed', false)) { - exit(0); - } - - // load all apps to get all api routes properly setup - Server::get(IAppManager::class)->loadApps(); - Server::get(ISession::class)->close(); - - $verbose = isset($argv[1]) && ($argv[1] === '-v' || $argv[1] === '--verbose'); - - // initialize a dummy memory session - $session = new Memory(); - $cryptoWrapper = Server::get(CryptoWrapper::class); - $session = $cryptoWrapper->wrapSession($session); - \OC::$server->setSession($session); - - $logger = Server::get(LoggerInterface::class); - $appConfig = Server::get(IAppConfig::class); - $tempManager = Server::get(ITempManager::class); - - $tempManager->cleanOld(); - - // Exit if background jobs are disabled! - $appMode = $appConfig->getValueString('core', 'backgroundjobs_mode', 'ajax'); - if ($appMode === 'none') { - if (OC::$CLI) { - echo 'Background Jobs are disabled!' . PHP_EOL; - } else { - OC_JSON::error(['data' => ['message' => 'Background jobs disabled!']]); - } - exit(1); - } - - if (OC::$CLI) { - // set to run indefinitely if needed - if (strpos(@ini_get('disable_functions'), 'set_time_limit') === false) { - @set_time_limit(0); - } - - // the cron job must be executed with the right user - if (!function_exists('posix_getuid')) { - echo 'The posix extensions are required - see https://www.php.net/manual/en/book.posix.php' . PHP_EOL; - exit(1); - } - - $user = posix_getuid(); - $configUser = fileowner(OC::$configDir . 'config.php'); - if ($user !== $configUser) { - echo 'Console has to be executed with the user that owns the file config/config.php' . PHP_EOL; - echo 'Current user id: ' . $user . PHP_EOL; - echo 'Owner id of config.php: ' . $configUser . PHP_EOL; - exit(1); - } - - - // We call Nextcloud from the CLI (aka cron) - if ($appMode !== 'cron') { - $appConfig->setValueString('core', 'backgroundjobs_mode', 'cron'); - } - - // a specific job class list can optionally be given as argument + $cronService = Server::get(CronService::class); + if (isset($argv[1])) { + $verbose = $argv[1] === '-v' || $argv[1] === '--verbose'; $jobClasses = array_slice($argv, $verbose ? 2 : 1); $jobClasses = empty($jobClasses) ? null : $jobClasses; - // Low-load hours - $onlyTimeSensitive = false; - $startHour = $config->getSystemValueInt('maintenance_window_start', 100); - if ($jobClasses === null && $startHour <= 23) { - $date = new \DateTime('now', new \DateTimeZone('UTC')); - $currentHour = (int)$date->format('G'); - $endHour = $startHour + 4; - - if ($startHour <= 20) { - // Start time: 01:00 - // End time: 05:00 - // Only run sensitive tasks when it's before the start or after the end - $onlyTimeSensitive = $currentHour < $startHour || $currentHour > $endHour; - } else { - // Start time: 23:00 - // End time: 03:00 - $endHour -= 24; // Correct the end time from 27:00 to 03:00 - // Only run sensitive tasks when it's after the end and before the start - $onlyTimeSensitive = $currentHour > $endHour && $currentHour < $startHour; - } - } - - // Work - $jobList = Server::get(IJobList::class); - - // We only ask for jobs for 14 minutes, because after 5 minutes the next - // system cron task should spawn and we want to have at most three - // cron jobs running in parallel. - $endTime = time() + 14 * 60; - - $executedJobs = []; - - while ($job = $jobList->getNext($onlyTimeSensitive, $jobClasses)) { - if (isset($executedJobs[$job->getId()])) { - $jobList->unlockJob($job); - break; - } - - $jobDetails = get_class($job) . ' (id: ' . $job->getId() . ', arguments: ' . json_encode($job->getArgument()) . ')'; - $logger->debug('CLI cron call has selected job ' . $jobDetails, ['app' => 'cron']); - - $timeBefore = time(); - $memoryBefore = memory_get_usage(); - $memoryPeakBefore = memory_get_peak_usage(); - - if ($verbose) { - echo 'Starting job ' . $jobDetails . PHP_EOL; - } - - $job->start($jobList); - - $timeAfter = time(); - $memoryAfter = memory_get_usage(); - $memoryPeakAfter = memory_get_peak_usage(); - - $cronInterval = 5 * 60; - $timeSpent = $timeAfter - $timeBefore; - if ($timeSpent > $cronInterval) { - $logLevel = match (true) { - $timeSpent > $cronInterval * 128 => ILogger::FATAL, - $timeSpent > $cronInterval * 64 => ILogger::ERROR, - $timeSpent > $cronInterval * 16 => ILogger::WARN, - $timeSpent > $cronInterval * 8 => ILogger::INFO, - default => ILogger::DEBUG, - }; - $logger->log( - $logLevel, - 'Background job ' . $jobDetails . ' ran for ' . $timeSpent . ' seconds', - ['app' => 'cron'] - ); - } - - if ($memoryAfter - $memoryBefore > 50_000_000) { - $message = 'Used memory grew by more than 50 MB when executing job ' . $jobDetails . ': ' . Util::humanFileSize($memoryAfter) . ' (before: ' . Util::humanFileSize($memoryBefore) . ')'; - $logger->warning($message, ['app' => 'cron']); - if ($verbose) { - echo $message . PHP_EOL; - } - } - if ($memoryPeakAfter > 300_000_000 && $memoryPeakBefore <= 300_000_000) { - $message = 'Cron job used more than 300 MB of ram after executing job ' . $jobDetails . ': ' . Util::humanFileSize($memoryPeakAfter) . ' (before: ' . Util::humanFileSize($memoryPeakBefore) . ')'; - $logger->warning($message, ['app' => 'cron']); - if ($verbose) { - echo $message . PHP_EOL; - } - } - - // clean up after unclean jobs - Server::get(SetupManager::class)->tearDown(); - $tempManager->clean(); - - if ($verbose) { - echo 'Job ' . $jobDetails . ' done in ' . ($timeAfter - $timeBefore) . ' seconds' . PHP_EOL; - } - - $jobList->setLastJob($job); - $executedJobs[$job->getId()] = true; - unset($job); - - if ($timeAfter > $endTime) { - break; - } + if ($verbose) { + $cronService->registerVerboseCallback(function (string $message) { + echo $message . PHP_EOL; + }); } } else { - // We call cron.php from some website - if ($appMode === 'cron') { - // Cron is cron :-P - OC_JSON::error(['data' => ['message' => 'Backgroundjobs are using system cron!']]); - } else { - // Work and success :-) - $jobList = Server::get(IJobList::class); - $job = $jobList->getNext(); - if ($job != null) { - $logger->debug('WebCron call has selected job with ID ' . strval($job->getId()), ['app' => 'cron']); - $job->start($jobList); - $jobList->setLastJob($job); - } - OC_JSON::success(); - } + $jobClasses = null; } - // Log the successful cron execution - $appConfig->setValueInt('core', 'lastcron', time()); - exit(); -} catch (Exception $ex) { - Server::get(LoggerInterface::class)->error( - $ex->getMessage(), - ['app' => 'cron', 'exception' => $ex] - ); - echo $ex . PHP_EOL; - exit(1); -} catch (Error $ex) { + $cronService->run($jobClasses); + if (!OC::$CLI) { + $data = [ + 'status' => 'success', + ]; + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($data, JSON_HEX_TAG); + } + exit(0); +} catch (Throwable $e) { Server::get(LoggerInterface::class)->error( - $ex->getMessage(), - ['app' => 'cron', 'exception' => $ex] + $e->getMessage(), + ['app' => 'cron', 'exception' => $e] ); - echo $ex . PHP_EOL; + if (OC::$CLI) { + echo $e->getMessage() . PHP_EOL; + } else { + $data = [ + 'status' => 'error', + 'message' => $e->getMessage(), + ]; + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($data, JSON_HEX_TAG); + } exit(1); } diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index ee77fbd4cda..df87efcef30 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -1530,6 +1530,7 @@ return array( 'OC\\Core\\Migrations\\Version32000Date20250806110519' => $baseDir . '/core/Migrations/Version32000Date20250806110519.php', 'OC\\Core\\Notification\\CoreNotifier' => $baseDir . '/core/Notification/CoreNotifier.php', 'OC\\Core\\ResponseDefinitions' => $baseDir . '/core/ResponseDefinitions.php', + 'OC\\Core\\Service\\CronService' => $baseDir . '/core/Service/CronService.php', 'OC\\Core\\Service\\LoginFlowV2Service' => $baseDir . '/core/Service/LoginFlowV2Service.php', 'OC\\DB\\Adapter' => $baseDir . '/lib/private/DB/Adapter.php', 'OC\\DB\\AdapterMySQL' => $baseDir . '/lib/private/DB/AdapterMySQL.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 3b18f00da96..d85113291a8 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -1571,6 +1571,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Core\\Migrations\\Version32000Date20250806110519' => __DIR__ . '/../../..' . '/core/Migrations/Version32000Date20250806110519.php', 'OC\\Core\\Notification\\CoreNotifier' => __DIR__ . '/../../..' . '/core/Notification/CoreNotifier.php', 'OC\\Core\\ResponseDefinitions' => __DIR__ . '/../../..' . '/core/ResponseDefinitions.php', + 'OC\\Core\\Service\\CronService' => __DIR__ . '/../../..' . '/core/Service/CronService.php', 'OC\\Core\\Service\\LoginFlowV2Service' => __DIR__ . '/../../..' . '/core/Service/LoginFlowV2Service.php', 'OC\\DB\\Adapter' => __DIR__ . '/../../..' . '/lib/private/DB/Adapter.php', 'OC\\DB\\AdapterMySQL' => __DIR__ . '/../../..' . '/lib/private/DB/AdapterMySQL.php',