Skip to content

Commit 73718ba

Browse files
karlitschekChristophWurst
authored andcommitted
feat(services): add cron and background-job services
Adds three read-only service classes used by the upcoming admin dashboard cards. None are wired into existing controllers yet, so this change is a pure addition with no behavior change. * CronInfo - cron mode (cron / webcron / ajax), last-run timestamp, and a status threshold * JobQueueInfo - total / reserved / stuck job counts and the top job classes by queue size * SlowestJobs - average and max execution time per job class Signed-off-by: Frank Karlitschek <karlitschek@users.noreply.github.com>
1 parent 67a3a0f commit 73718ba

3 files changed

Lines changed: 229 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/SlowestJobs.php

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
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 SlowestJobs {
15+
public function __construct(
16+
private IDBConnection $db,
17+
) {
18+
}
19+
20+
/**
21+
* Returns the job classes with the highest average execution time
22+
* (last_run - last_checked when reservations are released). Pulls
23+
* the columns directly because there is no public API.
24+
*
25+
* @return list<array{class: string, count: int, avgSeconds: int, maxSeconds: int}>
26+
*/
27+
public function getSlowestJobs(int $limit = 5): array {
28+
$qb = $this->db->getQueryBuilder();
29+
$qb->select('class')
30+
->selectAlias($qb->func()->count('id'), 'count')
31+
->selectAlias($qb->createFunction('AVG(' . $qb->getColumnName('execution_duration') . ')'), 'avg_dur')
32+
->selectAlias($qb->createFunction('MAX(' . $qb->getColumnName('execution_duration') . ')'), 'max_dur')
33+
->from('jobs')
34+
->where($qb->expr()->gt('execution_duration', $qb->createNamedParameter(0)))
35+
->groupBy('class')
36+
->orderBy('avg_dur', 'DESC')
37+
->setMaxResults($limit);
38+
try {
39+
$result = $qb->executeQuery();
40+
} catch (\Throwable) {
41+
return [];
42+
}
43+
$out = [];
44+
while (($row = $result->fetch()) !== false) {
45+
$out[] = [
46+
'class' => (string)($row['class'] ?? ''),
47+
'count' => (int)($row['count'] ?? 0),
48+
'avgSeconds' => (int)round((float)($row['avg_dur'] ?? 0)),
49+
'maxSeconds' => (int)($row['max_dur'] ?? 0),
50+
];
51+
}
52+
$result->closeCursor();
53+
return $out;
54+
}
55+
}

0 commit comments

Comments
 (0)