-
Notifications
You must be signed in to change notification settings - Fork 65
feat(services): add storage, cache, and DB services #983
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| /** | ||
| * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors | ||
| * SPDX-License-Identifier: AGPL-3.0-or-later | ||
| */ | ||
|
|
||
| namespace OCA\ServerInfo; | ||
|
|
||
| use OCP\IConfig; | ||
|
|
||
| class CachingInfo { | ||
| public function __construct( | ||
| private IConfig $config, | ||
| ) { | ||
| } | ||
|
|
||
| /** | ||
| * @return array{ | ||
| * opcache: array{enabled: bool, hits: int, misses: int, hitRate: float, memoryUsedMB: float, memoryFreeMB: float, cachedScripts: int}, | ||
| * apcu: array{enabled: bool, hits: int, misses: int, hitRate: float, memoryUsedMB: float, memoryFreeMB: float}, | ||
| * redis: array{configured: bool, distributed: string, locking: string}, | ||
| * memcache: array{local: string, distributed: string, locking: string} | ||
| * } | ||
| */ | ||
| public function getCachingInfo(): array { | ||
| return [ | ||
| 'opcache' => $this->opcacheInfo(), | ||
| 'apcu' => $this->apcuInfo(), | ||
| 'redis' => $this->redisInfo(), | ||
| 'memcache' => [ | ||
| 'local' => $this->shortClassName($this->config->getSystemValue('memcache.local', '')), | ||
| 'distributed' => $this->shortClassName($this->config->getSystemValue('memcache.distributed', '')), | ||
| 'locking' => $this->shortClassName($this->config->getSystemValue('memcache.locking', '')), | ||
| ], | ||
| ]; | ||
| } | ||
|
|
||
| /** | ||
| * @return array{enabled: bool, hits: int, misses: int, hitRate: float, memoryUsedMB: float, memoryFreeMB: float, cachedScripts: int} | ||
| */ | ||
| private function opcacheInfo(): array { | ||
| if (!extension_loaded('Zend OPcache') || !function_exists('opcache_get_status')) { | ||
| return ['enabled' => false, 'hits' => 0, 'misses' => 0, 'hitRate' => 0.0, 'memoryUsedMB' => 0.0, 'memoryFreeMB' => 0.0, 'cachedScripts' => 0]; | ||
| } | ||
| $status = @opcache_get_status(false); | ||
| if (!is_array($status)) { | ||
| return ['enabled' => false, 'hits' => 0, 'misses' => 0, 'hitRate' => 0.0, 'memoryUsedMB' => 0.0, 'memoryFreeMB' => 0.0, 'cachedScripts' => 0]; | ||
| } | ||
| $stats = $status['opcache_statistics'] ?? []; | ||
| $mem = $status['memory_usage'] ?? []; | ||
| $hits = (int)($stats['hits'] ?? 0); | ||
| $misses = (int)($stats['misses'] ?? 0); | ||
| $total = $hits + $misses; | ||
| return [ | ||
| 'enabled' => (bool)($status['opcache_enabled'] ?? false), | ||
| 'hits' => $hits, | ||
| 'misses' => $misses, | ||
| 'hitRate' => $total > 0 ? ($hits / $total) * 100 : 0.0, | ||
| 'memoryUsedMB' => isset($mem['used_memory']) ? round($mem['used_memory'] / (1024 * 1024), 2) : 0.0, | ||
| 'memoryFreeMB' => isset($mem['free_memory']) ? round($mem['free_memory'] / (1024 * 1024), 2) : 0.0, | ||
| 'cachedScripts' => (int)($stats['num_cached_scripts'] ?? 0), | ||
| ]; | ||
| } | ||
|
|
||
| /** | ||
| * @return array{enabled: bool, hits: int, misses: int, hitRate: float, memoryUsedMB: float, memoryFreeMB: float} | ||
| */ | ||
| private function apcuInfo(): array { | ||
| if (!extension_loaded('apcu') || !function_exists('apcu_cache_info')) { | ||
| return ['enabled' => false, 'hits' => 0, 'misses' => 0, 'hitRate' => 0.0, 'memoryUsedMB' => 0.0, 'memoryFreeMB' => 0.0]; | ||
| } | ||
| $cache = @apcu_cache_info(true); | ||
| $sma = function_exists('apcu_sma_info') ? @apcu_sma_info(true) : false; | ||
| if (!is_array($cache)) { | ||
| return ['enabled' => false, 'hits' => 0, 'misses' => 0, 'hitRate' => 0.0, 'memoryUsedMB' => 0.0, 'memoryFreeMB' => 0.0]; | ||
| } | ||
| $hits = (int)($cache['num_hits'] ?? 0); | ||
| $misses = (int)($cache['num_misses'] ?? 0); | ||
| $total = $hits + $misses; | ||
| $used = is_array($sma) && isset($sma['seg_size'], $sma['avail_mem']) ? (int)$sma['seg_size'] - (int)$sma['avail_mem'] : 0; | ||
| $free = is_array($sma) && isset($sma['avail_mem']) ? (int)$sma['avail_mem'] : 0; | ||
| return [ | ||
| 'enabled' => true, | ||
| 'hits' => $hits, | ||
| 'misses' => $misses, | ||
| 'hitRate' => $total > 0 ? ($hits / $total) * 100 : 0.0, | ||
| 'memoryUsedMB' => round($used / (1024 * 1024), 2), | ||
| 'memoryFreeMB' => round($free / (1024 * 1024), 2), | ||
| ]; | ||
| } | ||
|
|
||
| /** | ||
| * @return array{configured: bool, distributed: string, locking: string} | ||
| */ | ||
| private function redisInfo(): array { | ||
| $distributed = (string)$this->config->getSystemValue('memcache.distributed', ''); | ||
| $locking = (string)$this->config->getSystemValue('memcache.locking', ''); | ||
| $usingRedis = stripos($distributed, 'Redis') !== false || stripos($locking, 'Redis') !== false; | ||
| return [ | ||
| 'configured' => $usingRedis, | ||
| 'distributed' => $this->shortClassName($distributed), | ||
| 'locking' => $this->shortClassName($locking), | ||
| ]; | ||
| } | ||
|
|
||
| private function shortClassName(string $cls): string { | ||
| if ($cls === '') { | ||
| return ''; | ||
| } | ||
| $parts = explode('\\', $cls); | ||
| return end($parts) ?: $cls; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| /** | ||
| * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors | ||
| * SPDX-License-Identifier: AGPL-3.0-or-later | ||
| */ | ||
|
|
||
| namespace OCA\ServerInfo; | ||
|
|
||
| use OCP\IConfig; | ||
| use OCP\IDBConnection; | ||
|
|
||
| class DbHealth { | ||
| public function __construct( | ||
| private IDBConnection $db, | ||
| private IConfig $config, | ||
| ) { | ||
| } | ||
|
|
||
| /** | ||
| * @return array{ | ||
| * driver: string, | ||
| * largestTables: list<array{name: string, rows: int, sizeBytes: int}>, | ||
| * available: bool | ||
| * } | ||
| */ | ||
| public function getDbHealth(): array { | ||
| $driver = (string)$this->config->getSystemValue('dbtype', 'sqlite'); | ||
|
|
||
| try { | ||
| $tables = match ($driver) { | ||
| 'mysql', 'mariadb' => $this->mysqlTables(), | ||
| 'pgsql' => $this->pgTables(), | ||
| default => [], | ||
| }; | ||
| } catch (\Throwable) { | ||
| $tables = []; | ||
| } | ||
|
|
||
| return [ | ||
| 'driver' => $driver, | ||
| 'largestTables' => $tables, | ||
| 'available' => $tables !== [] || $driver === 'sqlite', | ||
| ]; | ||
| } | ||
|
|
||
| /** | ||
| * @return list<array{name: string, rows: int, sizeBytes: int}> | ||
| */ | ||
| private function mysqlTables(int $limit = 8): array { | ||
| $dbName = (string)$this->config->getSystemValue('dbname', ''); | ||
| if ($dbName === '') { | ||
| return []; | ||
| } | ||
| $sql = 'SELECT table_name AS name, table_rows AS rows, ' | ||
| . '(data_length + index_length) AS size_bytes ' | ||
| . 'FROM information_schema.TABLES WHERE table_schema = ? ' | ||
| . 'ORDER BY size_bytes DESC LIMIT ' . (int)$limit; | ||
| $conn = $this->db; | ||
| $stmt = $conn->prepare($sql); | ||
| $stmt->bindValue(1, $dbName); | ||
| $result = $stmt->executeQuery(); | ||
| $out = []; | ||
| while (($row = $result->fetch()) !== false) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Prefer using fetchAssociative/fetchNumeric/fetchOne or iterateAssociate/iterateNumeric instead. |
||
| $out[] = [ | ||
| 'name' => (string)($row['name'] ?? ''), | ||
| 'rows' => (int)($row['rows'] ?? 0), | ||
| 'sizeBytes' => (int)($row['size_bytes'] ?? 0), | ||
| ]; | ||
| } | ||
| $result->closeCursor(); | ||
| return $out; | ||
| } | ||
|
|
||
| /** | ||
| * @return list<array{name: string, rows: int, sizeBytes: int}> | ||
| */ | ||
| private function pgTables(int $limit = 8): array { | ||
| $sql = 'SELECT relname AS name, n_live_tup AS rows, ' | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Neither pg_class or pg_namespace have a column "n_live_tup". I'm going to check ...
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Should list all tables the current user has access too |
||
| . 'pg_total_relation_size(C.oid) AS size_bytes ' | ||
| . 'FROM pg_class C ' | ||
| . 'LEFT JOIN pg_namespace N ON N.oid = C.relnamespace ' | ||
| . "WHERE relkind = 'r' AND nspname NOT IN ('pg_catalog', 'information_schema') " | ||
| . 'ORDER BY size_bytes DESC LIMIT ' . (int)$limit; | ||
| $result = $this->db->prepare($sql)->executeQuery(); | ||
| $out = []; | ||
| while (($row = $result->fetch()) !== false) { | ||
| $out[] = [ | ||
| 'name' => (string)($row['name'] ?? ''), | ||
| 'rows' => (int)($row['rows'] ?? 0), | ||
| 'sizeBytes' => (int)($row['size_bytes'] ?? 0), | ||
| ]; | ||
| } | ||
| $result->closeCursor(); | ||
| return $out; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
| @@ -0,0 +1,128 @@ | ||||
| <?php | ||||
|
|
||||
| declare(strict_types=1); | ||||
|
|
||||
| /** | ||||
| * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors | ||||
| * SPDX-License-Identifier: AGPL-3.0-or-later | ||||
| */ | ||||
|
|
||||
| namespace OCA\ServerInfo; | ||||
|
|
||||
| use OCP\IAppConfig; | ||||
| use OCP\IConfig; | ||||
|
|
||||
| /** | ||||
| * Persists daily snapshots of (free space + file count) and uses a | ||||
| * simple linear regression over the last samples to predict when | ||||
| * the system disk will fill up. | ||||
| * | ||||
| * Snapshots are stored in app config as JSON: list of {ts, free, files}. | ||||
| */ | ||||
| class DiskGrowth { | ||||
| private const HISTORY_KEY = 'storage_history'; | ||||
| private const MAX_SAMPLES = 60; | ||||
| private const SAMPLE_INTERVAL = 12 * 3600; | ||||
|
|
||||
| public function __construct( | ||||
| private IAppConfig $appConfig, | ||||
| private IConfig $config, | ||||
| private StorageStatistics $storageStatistics, | ||||
| ) { | ||||
| } | ||||
|
|
||||
| /** | ||||
| * Returns growth and prediction. Reads stored history; appends a | ||||
| * new sample if enough time has passed since the last one. | ||||
| * | ||||
| * @return array{ | ||||
| * samples: list<array{ts: int, freeBytes: int, files: int}>, | ||||
| * daysUntilFull: int, | ||||
| * bytesPerDay: int, | ||||
| * filesPerDay: int, | ||||
| * freeBytes: int, | ||||
| * hasEnoughData: bool | ||||
| * } | ||||
| */ | ||||
| public function getGrowthInfo(): array { | ||||
| $history = $this->loadHistory(); | ||||
| $now = time(); | ||||
| $shouldSample = $history === [] || ($now - $history[count($history) - 1]['ts']) >= self::SAMPLE_INTERVAL; | ||||
|
|
||||
| if ($shouldSample) { | ||||
| $dataDir = (string)$this->config->getSystemValue('datadirectory', ''); | ||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I believe the sampling should go to serverinfo/lib/StorageStatistics.php Line 57 in d0ea456
|
||||
| $free = $dataDir !== '' ? @disk_free_space($dataDir) : false; | ||||
| $files = (int)($this->storageStatistics->getStorageStatistics()['num_files'] ?? 0); | ||||
| if ($free !== false && $free > 0) { | ||||
| $history[] = ['ts' => $now, 'freeBytes' => (int)$free, 'files' => $files]; | ||||
| $history = array_slice($history, -self::MAX_SAMPLES); | ||||
| $this->saveHistory($history); | ||||
| } | ||||
| } | ||||
|
|
||||
| $count = count($history); | ||||
| $current = $count > 0 ? $history[$count - 1] : ['freeBytes' => 0, 'files' => 0, 'ts' => $now]; | ||||
|
|
||||
| $bytesPerDay = 0; | ||||
| $filesPerDay = 0; | ||||
| $daysUntilFull = -1; | ||||
| $hasEnough = false; | ||||
|
|
||||
| if ($count >= 2) { | ||||
| $first = $history[0]; | ||||
| $last = $history[$count - 1]; | ||||
| $dt = max(1, $last['ts'] - $first['ts']); | ||||
| $dayFactor = 86400 / $dt; | ||||
| // Negative bytesPerDay means free space is shrinking (i.e. usage growing). | ||||
| $bytesPerDay = (int)round(($last['freeBytes'] - $first['freeBytes']) * $dayFactor); | ||||
| $filesPerDay = (int)round(($last['files'] - $first['files']) * $dayFactor); | ||||
| $hasEnough = true; | ||||
|
|
||||
| if ($bytesPerDay < 0) { | ||||
| $daysUntilFull = (int)round($last['freeBytes'] / abs($bytesPerDay)); | ||||
| } | ||||
| } | ||||
|
|
||||
| return [ | ||||
| 'samples' => $history, | ||||
| 'daysUntilFull' => $daysUntilFull, | ||||
| 'bytesPerDay' => $bytesPerDay, | ||||
| 'filesPerDay' => $filesPerDay, | ||||
| 'freeBytes' => (int)($current['freeBytes'] ?? 0), | ||||
| 'hasEnoughData' => $hasEnough, | ||||
| ]; | ||||
| } | ||||
|
|
||||
| /** | ||||
| * @return list<array{ts: int, freeBytes: int, files: int}> | ||||
| */ | ||||
| private function loadHistory(): array { | ||||
| $raw = $this->appConfig->getValueString('serverinfo', self::HISTORY_KEY, '[]'); | ||||
| try { | ||||
| $parsed = json_decode($raw, true, 4, JSON_THROW_ON_ERROR); | ||||
| } catch (\Throwable) { | ||||
| return []; | ||||
| } | ||||
| if (!is_array($parsed)) { | ||||
| return []; | ||||
| } | ||||
| $out = []; | ||||
| foreach ($parsed as $entry) { | ||||
| if (!is_array($entry)) continue; | ||||
| $out[] = [ | ||||
| 'ts' => (int)($entry['ts'] ?? 0), | ||||
| 'freeBytes' => (int)($entry['freeBytes'] ?? 0), | ||||
| 'files' => (int)($entry['files'] ?? 0), | ||||
| ]; | ||||
| } | ||||
| return $out; | ||||
| } | ||||
|
|
||||
| private function saveHistory(array $history): void { | ||||
| try { | ||||
| $this->appConfig->setValueString('serverinfo', self::HISTORY_KEY, json_encode($history, JSON_THROW_ON_ERROR)); | ||||
| } catch (\Throwable) { | ||||
| // best-effort; storage unavailable | ||||
| } | ||||
| } | ||||
| } | ||||

There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This definitely needs error handler because by default Nextcloud drops admin privilege by creating a dedicated user during setup.
Checking options …
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It should work when mariadb allows seeing the objects the user has access to. The only remaining issue is that the information_schema access is notorious for rebuilding database metadata.
Accepting the risk for now to get this merged.