Skip to content

Commit d0ea456

Browse files
Merge pull request #981 from nextcloud/split/04-services-cron-jobs
feat(services): add cron and background-job services
2 parents 67a3a0f + 11e999b commit d0ea456

8 files changed

Lines changed: 548 additions & 0 deletions

File tree

lib/CronInfo.php

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\ServerInfo;
11+
12+
use OCP\IAppConfig;
13+
use OCP\IConfig;
14+
15+
class CronInfo {
16+
public function __construct(
17+
private IConfig $config,
18+
private IAppConfig $appConfig,
19+
) {
20+
}
21+
22+
/**
23+
* @return array{
24+
* mode: string,
25+
* lastRun: int,
26+
* secondsSince: int,
27+
* status: string
28+
* }
29+
*/
30+
public function getCronInfo(): array {
31+
$mode = $this->config->getAppValue('core', 'backgroundjobs_mode', 'ajax');
32+
$lastRun = $this->appConfig->getValueInt('core', 'lastcron', 0);
33+
$secondsSince = $lastRun > 0 ? max(0, time() - $lastRun) : -1;
34+
35+
return [
36+
'mode' => $mode,
37+
'lastRun' => $lastRun,
38+
'secondsSince' => $secondsSince,
39+
'status' => $this->statusFor($mode, $secondsSince),
40+
];
41+
}
42+
43+
private function statusFor(string $mode, int $secondsSince): string {
44+
if ($secondsSince < 0) {
45+
return 'critical';
46+
}
47+
// Cron job runs every 5 minutes; allow a generous grace period.
48+
if ($mode === 'cron') {
49+
if ($secondsSince > 3600) {
50+
return 'critical';
51+
}
52+
if ($secondsSince > 900) {
53+
return 'warning';
54+
}
55+
return 'ok';
56+
}
57+
// Webcron / ajax modes: looser thresholds.
58+
if ($secondsSince > 7200) {
59+
return 'critical';
60+
}
61+
if ($secondsSince > 3600) {
62+
return 'warning';
63+
}
64+
return 'ok';
65+
}
66+
}

lib/JobQueueInfo.php

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\ServerInfo;
11+
12+
use OCP\IDBConnection;
13+
14+
class JobQueueInfo {
15+
private const STUCK_THRESHOLD_SECONDS = 12 * 3600;
16+
17+
public function __construct(
18+
private IDBConnection $db,
19+
) {
20+
}
21+
22+
/**
23+
* @return array{
24+
* total: int,
25+
* reserved: int,
26+
* stuck: int,
27+
* oldestLastRun: int,
28+
* topClasses: list<array{class: string, count: int}>
29+
* }
30+
*/
31+
public function getJobQueueInfo(): array {
32+
return [
33+
'total' => $this->countTotal(),
34+
'reserved' => $this->countReserved(),
35+
'stuck' => $this->countStuck(),
36+
'oldestLastRun' => $this->oldestLastRun(),
37+
'topClasses' => $this->topClasses(5),
38+
];
39+
}
40+
41+
private function countTotal(): int {
42+
$qb = $this->db->getQueryBuilder();
43+
$qb->select($qb->func()->count('id'))
44+
->from('jobs');
45+
$result = $qb->executeQuery();
46+
$count = (int)$result->fetchOne();
47+
$result->closeCursor();
48+
return $count;
49+
}
50+
51+
private function countReserved(): int {
52+
$qb = $this->db->getQueryBuilder();
53+
$qb->select($qb->func()->count('id'))
54+
->from('jobs')
55+
->where($qb->expr()->gt('reserved_at', $qb->createNamedParameter(0)));
56+
$result = $qb->executeQuery();
57+
$count = (int)$result->fetchOne();
58+
$result->closeCursor();
59+
return $count;
60+
}
61+
62+
private function countStuck(): int {
63+
$threshold = time() - self::STUCK_THRESHOLD_SECONDS;
64+
$qb = $this->db->getQueryBuilder();
65+
$qb->select($qb->func()->count('id'))
66+
->from('jobs')
67+
->where($qb->expr()->gt('reserved_at', $qb->createNamedParameter(0)))
68+
->andWhere($qb->expr()->lt('reserved_at', $qb->createNamedParameter($threshold)));
69+
$result = $qb->executeQuery();
70+
$count = (int)$result->fetchOne();
71+
$result->closeCursor();
72+
return $count;
73+
}
74+
75+
private function oldestLastRun(): int {
76+
$qb = $this->db->getQueryBuilder();
77+
$qb->select($qb->func()->min('last_run'))
78+
->from('jobs')
79+
->where($qb->expr()->gt('last_run', $qb->createNamedParameter(0)));
80+
$result = $qb->executeQuery();
81+
$min = $result->fetchOne();
82+
$result->closeCursor();
83+
return $min === false || $min === null ? 0 : (int)$min;
84+
}
85+
86+
/**
87+
* @return list<array{class: string, count: int}>
88+
*/
89+
private function topClasses(int $limit): array {
90+
$qb = $this->db->getQueryBuilder();
91+
$qb->select('class')
92+
->selectAlias($qb->func()->count('id'), 'count')
93+
->from('jobs')
94+
->groupBy('class')
95+
->orderBy('count', 'DESC')
96+
->setMaxResults($limit);
97+
$result = $qb->executeQuery();
98+
$out = [];
99+
while (($row = $result->fetch()) !== false) {
100+
$out[] = [
101+
'class' => (string)($row['class'] ?? ''),
102+
'count' => (int)($row['count'] ?? 0),
103+
];
104+
}
105+
$result->closeCursor();
106+
return $out;
107+
}
108+
}

lib/Settings/AdminSettings.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,15 @@
1010

1111
namespace OCA\ServerInfo\Settings;
1212

13+
use OCA\ServerInfo\CronInfo;
1314
use OCA\ServerInfo\DatabaseStatistics;
1415
use OCA\ServerInfo\FpmStatistics;
16+
use OCA\ServerInfo\JobQueueInfo;
1517
use OCA\ServerInfo\Os;
1618
use OCA\ServerInfo\PhpStatistics;
1719
use OCA\ServerInfo\SessionStatistics;
1820
use OCA\ServerInfo\ShareStatistics;
21+
use OCA\ServerInfo\SlowestJobs;
1922
use OCA\ServerInfo\StorageStatistics;
2023
use OCA\ServerInfo\SystemStatistics;
2124
use OCP\AppFramework\Http\TemplateResponse;
@@ -36,6 +39,9 @@ public function __construct(
3639
private ShareStatistics $shareStatistics,
3740
private SessionStatistics $sessionStatistics,
3841
private SystemStatistics $systemStatistics,
42+
private CronInfo $cronInfo,
43+
private JobQueueInfo $jobQueueInfo,
44+
private SlowestJobs $slowestJobs,
3945
private IConfig $config,
4046
) {
4147
}
@@ -60,6 +66,9 @@ public function getForm(): TemplateResponse {
6066
'activeUsers' => $this->sessionStatistics->getSessionStatistics(),
6167
'system' => $this->systemStatistics->getSystemStatistics(true, true),
6268
'thermalzones' => $this->os->getThermalZones(),
69+
'cron' => $this->cronInfo->getCronInfo(),
70+
'jobQueue' => $this->jobQueueInfo->getJobQueueInfo(),
71+
'slowestJobs' => $this->slowestJobs->getSlowestJobs(),
6372
'phpinfo' => $this->config->getAppValue('serverinfo', 'phpinfo', 'no') === 'yes',
6473
'phpinfoUrl' => $this->urlGenerator->linkToRoute('serverinfo.page.phpinfo')
6574
];

lib/SlowestJobs.php

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\ServerInfo;
11+
12+
use OCP\IDBConnection;
13+
use Psr\Log\LoggerInterface;
14+
15+
class SlowestJobs {
16+
public function __construct(
17+
private IDBConnection $db,
18+
private LoggerInterface $logger,
19+
) {
20+
}
21+
22+
/**
23+
* Returns the job classes with the highest average execution time
24+
* (last_run - last_checked when reservations are released). Pulls
25+
* the columns directly because there is no public API.
26+
*
27+
* @return list<array{class: string, count: int, avgSeconds: int, maxSeconds: int}>
28+
*/
29+
public function getSlowestJobs(int $limit = 5): array {
30+
$qb = $this->db->getQueryBuilder();
31+
$qb->select('class')
32+
->selectAlias($qb->func()->count('id'), 'count')
33+
->selectAlias($qb->createFunction('AVG(' . $qb->getColumnName('execution_duration') . ')'), 'avg_dur')
34+
->selectAlias($qb->createFunction('MAX(' . $qb->getColumnName('execution_duration') . ')'), 'max_dur')
35+
->from('jobs')
36+
->where($qb->expr()->gt('execution_duration', $qb->createNamedParameter(0)))
37+
->groupBy('class')
38+
->orderBy('avg_dur', 'DESC')
39+
->setMaxResults($limit);
40+
try {
41+
$result = $qb->executeQuery();
42+
} catch (\Throwable $e) {
43+
$this->logger->warning('Failed to query slowest jobs', ['exception' => $e]);
44+
return [];
45+
}
46+
$out = [];
47+
while (($row = $result->fetch()) !== false) {
48+
$out[] = [
49+
'class' => (string)($row['class'] ?? ''),
50+
'count' => (int)($row['count'] ?? 0),
51+
'avgSeconds' => (int)round((float)($row['avg_dur'] ?? 0)),
52+
'maxSeconds' => (int)($row['max_dur'] ?? 0),
53+
];
54+
}
55+
$result->closeCursor();
56+
return $out;
57+
}
58+
}

tests/lib/CronInfoTest.php

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\ServerInfo\Tests;
11+
12+
use OCA\ServerInfo\CronInfo;
13+
use OCP\IAppConfig;
14+
use OCP\IConfig;
15+
use PHPUnit\Framework\MockObject\MockObject;
16+
17+
class CronInfoTest extends \Test\TestCase {
18+
private IConfig&MockObject $config;
19+
private IAppConfig&MockObject $appConfig;
20+
private CronInfo $instance;
21+
22+
protected function setUp(): void {
23+
parent::setUp();
24+
25+
$this->config = $this->createMock(IConfig::class);
26+
$this->appConfig = $this->createMock(IAppConfig::class);
27+
$this->instance = new CronInfo($this->config, $this->appConfig);
28+
}
29+
30+
public function testNeverRanIsCritical(): void {
31+
$this->config->method('getAppValue')->willReturn('cron');
32+
$this->appConfig->method('getValueInt')->willReturn(0);
33+
34+
$info = $this->instance->getCronInfo();
35+
36+
$this->assertSame('cron', $info['mode']);
37+
$this->assertSame(0, $info['lastRun']);
38+
$this->assertSame(-1, $info['secondsSince']);
39+
$this->assertSame('critical', $info['status']);
40+
}
41+
42+
public function testCronModeRecentRunIsOk(): void {
43+
$this->config->method('getAppValue')->willReturn('cron');
44+
$this->appConfig->method('getValueInt')->willReturn(time() - 60);
45+
46+
$info = $this->instance->getCronInfo();
47+
48+
$this->assertSame('ok', $info['status']);
49+
}
50+
51+
public function testCronModeOver15MinIsWarning(): void {
52+
$this->config->method('getAppValue')->willReturn('cron');
53+
$this->appConfig->method('getValueInt')->willReturn(time() - 1000);
54+
55+
$info = $this->instance->getCronInfo();
56+
57+
$this->assertSame('warning', $info['status']);
58+
}
59+
60+
public function testCronModeOver1HourIsCritical(): void {
61+
$this->config->method('getAppValue')->willReturn('cron');
62+
$this->appConfig->method('getValueInt')->willReturn(time() - 4000);
63+
64+
$info = $this->instance->getCronInfo();
65+
66+
$this->assertSame('critical', $info['status']);
67+
}
68+
69+
public function testWebcronModeOver2HoursIsCritical(): void {
70+
$this->config->method('getAppValue')->willReturn('webcron');
71+
$this->appConfig->method('getValueInt')->willReturn(time() - 8000);
72+
73+
$info = $this->instance->getCronInfo();
74+
75+
$this->assertSame('webcron', $info['mode']);
76+
$this->assertSame('critical', $info['status']);
77+
}
78+
79+
public function testWebcronModeOver1HourIsWarning(): void {
80+
$this->config->method('getAppValue')->willReturn('webcron');
81+
$this->appConfig->method('getValueInt')->willReturn(time() - 5000);
82+
83+
$info = $this->instance->getCronInfo();
84+
85+
$this->assertSame('warning', $info['status']);
86+
}
87+
88+
public function testWebcronModeRecentRunIsOk(): void {
89+
$this->config->method('getAppValue')->willReturn('webcron');
90+
$this->appConfig->method('getValueInt')->willReturn(time() - 60);
91+
92+
$info = $this->instance->getCronInfo();
93+
94+
$this->assertSame('ok', $info['status']);
95+
}
96+
}

0 commit comments

Comments
 (0)