Skip to content

Commit 48c2dd6

Browse files
authored
Merge pull request nextcloud#55517 from nextcloud/carl/cron-service
refactor: Move cron setup to a service
2 parents 9d98f85 + 9d7fbb1 commit 48c2dd6

File tree

5 files changed

+281
-226
lines changed

5 files changed

+281
-226
lines changed

build/psalm-baseline.xml

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3372,13 +3372,6 @@
33723372
<code><![CDATA[listen]]></code>
33733373
</DeprecatedMethod>
33743374
</file>
3375-
<file src="cron.php">
3376-
<DeprecatedMethod>
3377-
<code><![CDATA[OC_JSON::error(['data' => ['message' => 'Background jobs disabled!']])]]></code>
3378-
<code><![CDATA[OC_JSON::error(['data' => ['message' => 'Backgroundjobs are using system cron!']])]]></code>
3379-
<code><![CDATA[OC_JSON::success()]]></code>
3380-
</DeprecatedMethod>
3381-
</file>
33823375
<file src="lib/base.php">
33833376
<InvalidArgument>
33843377
<code><![CDATA[$restrictions]]></code>

core/Service/CronService.php

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-FileCopyrightText: 2016 ownCloud, Inc.
8+
* SPDX-FileContributor: Carl Schwan
9+
* SPDX-License-Identifier: AGPL-3.0-only
10+
*/
11+
12+
namespace OC\Core\Service;
13+
14+
use OC;
15+
use OC\Authentication\LoginCredentials\Store;
16+
use OC\Files\SetupManager;
17+
use OC\Security\CSRF\TokenStorage\SessionStorage;
18+
use OC\Session\CryptoWrapper;
19+
use OC\Session\Memory;
20+
use OC\User\Session;
21+
use OCP\App\IAppManager;
22+
use OCP\BackgroundJob\IJobList;
23+
use OCP\IAppConfig;
24+
use OCP\IConfig;
25+
use OCP\ILogger;
26+
use OCP\ISession;
27+
use OCP\ITempManager;
28+
use OCP\Util;
29+
use Psr\Log\LoggerInterface;
30+
31+
class CronService {
32+
/** * @var ?callable $verboseCallback */
33+
private $verboseCallback = null;
34+
35+
public function __construct(
36+
private readonly LoggerInterface $logger,
37+
private readonly IConfig $config,
38+
private readonly IAppManager $appManager,
39+
private readonly ISession $session,
40+
private readonly Session $userSession,
41+
private readonly CryptoWrapper $cryptoWrapper,
42+
private readonly Store $store,
43+
private readonly SessionStorage $sessionStorage,
44+
private readonly ITempManager $tempManager,
45+
private readonly IAppConfig $appConfig,
46+
private readonly IJobList $jobList,
47+
private readonly SetupManager $setupManager,
48+
private readonly bool $isCLI,
49+
) {
50+
}
51+
52+
/**
53+
* @param callable(string):void $callback
54+
*/
55+
public function registerVerboseCallback(callable $callback): void {
56+
$this->verboseCallback = $callback;
57+
}
58+
59+
/**
60+
* @throws \RuntimeException
61+
*/
62+
public function run(?array $jobClasses): void {
63+
if (Util::needUpgrade()) {
64+
$this->logger->debug('Update required, skipping cron', ['app' => 'core']);
65+
return;
66+
}
67+
68+
if ($this->config->getSystemValueBool('maintenance', false)) {
69+
$this->logger->debug('We are in maintenance mode, skipping cron', ['app' => 'core']);
70+
return;
71+
}
72+
73+
// Don't do anything if Nextcloud has not been installed
74+
if (!$this->config->getSystemValueBool('installed', false)) {
75+
return;
76+
}
77+
78+
// load all apps to get all api routes properly setup
79+
$this->appManager->loadApps();
80+
$this->session->close();
81+
82+
// initialize a dummy memory session
83+
$session = new Memory();
84+
$session = $this->cryptoWrapper->wrapSession($session);
85+
$this->sessionStorage->setSession($session);
86+
$this->userSession->setSession($session);
87+
$this->store->setSession($session);
88+
89+
$this->tempManager->cleanOld();
90+
91+
// Exit if background jobs are disabled!
92+
$appMode = $this->appConfig->getValueString('core', 'backgroundjobs_mode', 'ajax');
93+
if ($appMode === 'none') {
94+
throw new \RuntimeException('Background Jobs are disabled!');
95+
}
96+
97+
if ($this->isCLI) {
98+
$this->runCli($appMode, $jobClasses);
99+
} else {
100+
$this->runWeb($appMode);
101+
}
102+
103+
// Log the successful cron execution
104+
$this->appConfig->setValueInt('core', 'lastcron', time());
105+
}
106+
107+
/**
108+
* @throws \RuntimeException
109+
*/
110+
private function runCli(string $appMode, ?array $jobClasses): void {
111+
// set to run indefinitely if needed
112+
if (!str_contains(@ini_get('disable_functions'), 'set_time_limit')) {
113+
@set_time_limit(0);
114+
}
115+
116+
// the cron job must be executed with the right user
117+
if (!function_exists('posix_getuid')) {
118+
throw new \RuntimeException('The posix extensions are required - see https://www.php.net/manual/en/book.posix.php');
119+
}
120+
121+
$user = posix_getuid();
122+
$configUser = fileowner(OC::$configDir . 'config.php');
123+
if ($user !== $configUser) {
124+
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);
125+
}
126+
127+
// We call Nextcloud from the CLI (aka cron)
128+
if ($appMode !== 'cron') {
129+
$this->appConfig->setValueString('core', 'backgroundjobs_mode', 'cron');
130+
}
131+
132+
// Low-load hours
133+
$onlyTimeSensitive = false;
134+
$startHour = $this->config->getSystemValueInt('maintenance_window_start', 100);
135+
if ($jobClasses === null && $startHour <= 23) {
136+
$date = new \DateTime('now', new \DateTimeZone('UTC'));
137+
$currentHour = (int)$date->format('G');
138+
$endHour = $startHour + 4;
139+
140+
if ($startHour <= 20) {
141+
// Start time: 01:00
142+
// End time: 05:00
143+
// Only run sensitive tasks when it's before the start or after the end
144+
$onlyTimeSensitive = $currentHour < $startHour || $currentHour > $endHour;
145+
} else {
146+
// Start time: 23:00
147+
// End time: 03:00
148+
$endHour -= 24; // Correct the end time from 27:00 to 03:00
149+
// Only run sensitive tasks when it's after the end and before the start
150+
$onlyTimeSensitive = $currentHour > $endHour && $currentHour < $startHour;
151+
}
152+
}
153+
154+
// We only ask for jobs for 14 minutes, because after 5 minutes the next
155+
// system cron task should spawn and we want to have at most three
156+
// cron jobs running in parallel.
157+
$endTime = time() + 14 * 60;
158+
159+
$executedJobs = [];
160+
161+
while ($job = $this->jobList->getNext($onlyTimeSensitive, $jobClasses)) {
162+
if (isset($executedJobs[$job->getId()])) {
163+
$this->jobList->unlockJob($job);
164+
break;
165+
}
166+
167+
$jobDetails = get_class($job) . ' (id: ' . $job->getId() . ', arguments: ' . json_encode($job->getArgument()) . ')';
168+
$this->logger->debug('CLI cron call has selected job ' . $jobDetails, ['app' => 'cron']);
169+
170+
$timeBefore = time();
171+
$memoryBefore = memory_get_usage();
172+
$memoryPeakBefore = memory_get_peak_usage();
173+
174+
$this->verboseOutput('Starting job ' . $jobDetails);
175+
176+
$job->start($this->jobList);
177+
178+
$timeAfter = time();
179+
$memoryAfter = memory_get_usage();
180+
$memoryPeakAfter = memory_get_peak_usage();
181+
182+
$cronInterval = 5 * 60;
183+
$timeSpent = $timeAfter - $timeBefore;
184+
if ($timeSpent > $cronInterval) {
185+
$logLevel = match (true) {
186+
$timeSpent > $cronInterval * 128 => ILogger::FATAL,
187+
$timeSpent > $cronInterval * 64 => ILogger::ERROR,
188+
$timeSpent > $cronInterval * 16 => ILogger::WARN,
189+
$timeSpent > $cronInterval * 8 => ILogger::INFO,
190+
default => ILogger::DEBUG,
191+
};
192+
$this->logger->log(
193+
$logLevel,
194+
'Background job ' . $jobDetails . ' ran for ' . $timeSpent . ' seconds',
195+
['app' => 'cron']
196+
);
197+
}
198+
199+
if ($memoryAfter - $memoryBefore > 50_000_000) {
200+
$message = 'Used memory grew by more than 50 MB when executing job ' . $jobDetails . ': ' . Util::humanFileSize($memoryAfter) . ' (before: ' . Util::humanFileSize($memoryBefore) . ')';
201+
$this->logger->warning($message, ['app' => 'cron']);
202+
$this->verboseOutput($message);
203+
}
204+
if ($memoryPeakAfter > 300_000_000 && $memoryPeakBefore <= 300_000_000) {
205+
$message = 'Cron job used more than 300 MB of ram after executing job ' . $jobDetails . ': ' . Util::humanFileSize($memoryPeakAfter) . ' (before: ' . Util::humanFileSize($memoryPeakBefore) . ')';
206+
$this->logger->warning($message, ['app' => 'cron']);
207+
$this->verboseOutput($message);
208+
}
209+
210+
// clean up after unclean jobs
211+
$this->setupManager->tearDown();
212+
$this->tempManager->clean();
213+
214+
$this->verboseOutput('Job ' . $jobDetails . ' done in ' . ($timeAfter - $timeBefore) . ' seconds');
215+
216+
$this->jobList->setLastJob($job);
217+
$executedJobs[$job->getId()] = true;
218+
unset($job);
219+
220+
if ($timeAfter > $endTime) {
221+
break;
222+
}
223+
}
224+
}
225+
226+
private function runWeb(string $appMode): void {
227+
if ($appMode === 'cron') {
228+
// Cron is cron :-P
229+
throw new \RuntimeException('Backgroundjobs are using system cron!');
230+
} else {
231+
// Work and success :-)
232+
$job = $this->jobList->getNext();
233+
if ($job != null) {
234+
$this->logger->debug('WebCron call has selected job with ID ' . strval($job->getId()), ['app' => 'cron']);
235+
$job->start($this->jobList);
236+
$this->jobList->setLastJob($job);
237+
}
238+
}
239+
}
240+
241+
private function verboseOutput(string $message): void {
242+
if ($this->verboseCallback !== null) {
243+
call_user_func($this->verboseCallback, $message);
244+
}
245+
}
246+
}

0 commit comments

Comments
 (0)