Skip to content

Commit 729e1e6

Browse files
feat(db): add occ db:info, db:size, db:index-usage and db:locks
Implements RFC #59422. Adds four read-only diagnostic commands to the occ CLI for administrators to inspect database health without needing external tools: - db:info: shows engine version and key config variables with health check against recommended values - db:size: lists all tables ordered by total disk usage - db:index-usage: reports unused indexes via performance_schema (MySQL) or pg_stat_user_indexes (PostgreSQL) - db:locks: detects active blocking transactions and deadlocks All commands support MySQL/MariaDB and PostgreSQL. A --json flag is available for automated parsing. Includes 31 unit tests. Closes #59422 Signed-off-by: Rodrigo Correia <rodrigo.mendes.correia@tecnico.ulisboa.pt> Signed-off-by: Carolina Quinteiro <carolinafquinteiro@tecnico.ulisboa.pt> Co-authored-by: Carolina Quinteiro <carolinafquinteiro@tecnico.ulisboa.pt>
1 parent 6f7961f commit 729e1e6

9 files changed

Lines changed: 1044 additions & 0 deletions

File tree

core/Command/Db/DbIndexUsage.php

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
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+
namespace OC\Core\Command\Db;
10+
11+
use OC\DB\Connection;
12+
use Symfony\Component\Console\Command\Command;
13+
use Symfony\Component\Console\Helper\Table;
14+
use Symfony\Component\Console\Input\InputInterface;
15+
use Symfony\Component\Console\Input\InputOption;
16+
use Symfony\Component\Console\Output\OutputInterface;
17+
use Doctrine\DBAL\Platforms\MySQLPlatform;
18+
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
19+
20+
class DbIndexUsage extends Command {
21+
22+
public function __construct(
23+
private readonly Connection $connection,
24+
) {
25+
parent::__construct();
26+
}
27+
28+
protected function configure(): void {
29+
$this
30+
->setName('db:index-usage')
31+
->setDescription('Report unused database indexes (indexes that slow writes but are never read)')
32+
->addOption('json', null, InputOption::VALUE_NONE, 'Output in JSON format')
33+
->addOption('all', null, InputOption::VALUE_NONE, 'Show all indexes, not just unused ones');
34+
}
35+
36+
protected function execute(InputInterface $input, OutputInterface $output): int {
37+
$platform = $this->connection->getDatabasePlatform();
38+
$asJson = $input->getOption('json');
39+
$showAll = $input->getOption('all');
40+
41+
if ($platform instanceof MySQLPlatform) {
42+
// Requires performance_schema to be enabled (default in MySQL 5.6+/MariaDB 10.0+)
43+
$unused_filter = $showAll ? '' : "WHERE s.count_read = 0 AND s.index_name IS NOT NULL AND s.index_name != 'PRIMARY'";
44+
$sql = "
45+
SELECT s.object_name AS `table`,
46+
s.index_name AS `index`,
47+
s.count_read AS reads,
48+
s.count_write AS writes
49+
FROM performance_schema.table_io_waits_summary_by_index_usage s
50+
{$unused_filter}
51+
ORDER BY s.object_name, s.index_name
52+
";
53+
} elseif ($platform instanceof PostgreSQLPlatform) {
54+
$unused_filter = $showAll ? '' : 'AND idx_scan = 0';
55+
$sql = "
56+
SELECT relname AS table,
57+
indexrelname AS index,
58+
idx_scan AS reads,
59+
idx_tup_read AS tuples_read,
60+
idx_tup_fetch AS tuples_fetched
61+
FROM pg_stat_user_indexes
62+
JOIN pg_index USING (indexrelid)
63+
WHERE indisunique IS FALSE
64+
{$unused_filter}
65+
ORDER BY relname, indexrelname
66+
";
67+
} else {
68+
$output->writeln('<comment>db:index-usage is not supported for SQLite.</comment>');
69+
return Command::SUCCESS;
70+
}
71+
72+
try {
73+
$rows = $this->connection->executeQuery($sql)->fetchAllAssociative();
74+
} catch (\Doctrine\DBAL\Exception $e) {
75+
$output->writeln('<error>Failed to query index usage statistics. The required performance tables may not be available on this database version.</error>');
76+
$output->writeln('<comment>Details: ' . $e->getMessage() . '</comment>');
77+
return Command::FAILURE;
78+
}
79+
80+
if (empty($rows)) {
81+
$output->writeln('<info>No unused indexes found. Great!</info>');
82+
return Command::SUCCESS;
83+
}
84+
85+
if ($asJson) {
86+
$output->writeln(json_encode($rows, JSON_PRETTY_PRINT));
87+
return Command::SUCCESS;
88+
}
89+
90+
$table = new Table($output);
91+
92+
if ($platform instanceof MySQLPlatform) {
93+
$table->setHeaders(['Table', 'Index', 'Reads', 'Writes']);
94+
foreach ($rows as $row) {
95+
$table->addRow([$row['table'], $row['index'], $row['reads'], $row['writes']]);
96+
}
97+
} else {
98+
$table->setHeaders(['Table', 'Index', 'Scans', 'Tuples Read', 'Tuples Fetched']);
99+
foreach ($rows as $row) {
100+
$table->addRow([$row['table'], $row['index'], $row['reads'], $row['tuples_read'], $row['tuples_fetched']]);
101+
}
102+
}
103+
104+
$table->render();
105+
106+
if (!$showAll) {
107+
$output->writeln(sprintf(
108+
'<comment>Found %d unused index(es). Consider removing them to improve write performance.</comment>',
109+
count($rows)
110+
));
111+
}
112+
113+
return Command::SUCCESS;
114+
}
115+
}

core/Command/Db/DbInfo.php

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
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+
namespace OC\Core\Command\Db;
10+
11+
use OC\DB\Connection;
12+
use Symfony\Component\Console\Command\Command;
13+
use Symfony\Component\Console\Helper\Table;
14+
use Symfony\Component\Console\Input\InputInterface;
15+
use Symfony\Component\Console\Input\InputOption;
16+
use Symfony\Component\Console\Output\OutputInterface;
17+
use Doctrine\DBAL\Platforms\MySQLPlatform;
18+
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
19+
use Doctrine\DBAL\Platforms\SqlitePlatform;
20+
21+
class DbInfo extends Command {
22+
23+
public function __construct(
24+
private readonly Connection $connection,
25+
) {
26+
parent::__construct();
27+
}
28+
29+
protected function configure(): void {
30+
$this
31+
->setName('db:info')
32+
->setDescription('Show database server information and configuration health check')
33+
->addOption('json', null, InputOption::VALUE_NONE, 'Output in JSON format');
34+
}
35+
36+
protected function execute(InputInterface $input, OutputInterface $output): int {
37+
$platform = $this->connection->getDatabasePlatform();
38+
$asJson = $input->getOption('json');
39+
40+
if ($platform instanceof MySQLPlatform) {
41+
$rows = $this->getMySQLInfo();
42+
} elseif ($platform instanceof PostgreSQLPlatform) {
43+
$rows = $this->getPostgreSQLInfo();
44+
} elseif ($platform instanceof SqlitePlatform) {
45+
$rows = $this->getSQLiteInfo();
46+
} else {
47+
$output->writeln('<error>Unsupported database platform.</error>');
48+
return Command::FAILURE;
49+
}
50+
51+
if ($asJson) {
52+
$output->writeln(json_encode($rows, JSON_PRETTY_PRINT));
53+
return Command::SUCCESS;
54+
}
55+
56+
$table = new Table($output);
57+
$table->setHeaders(['Setting', 'Value', 'Recommended', 'Status']);
58+
59+
foreach ($rows as $row) {
60+
$status = isset($row['recommended'])
61+
? ($row['ok'] ? '<info>OK</info>' : '<comment>CHECK</comment>')
62+
: '';
63+
$table->addRow([
64+
$row['setting'],
65+
$row['value'],
66+
$row['recommended'] ?? '',
67+
$status,
68+
]);
69+
}
70+
71+
$table->render();
72+
return Command::SUCCESS;
73+
}
74+
75+
private function getMySQLInfo(): array {
76+
$result = $this->connection->executeQuery(
77+
"SELECT VERSION() AS version, @@innodb_buffer_pool_size AS buffer_pool,
78+
@@max_connections AS max_conn, @@character_set_database AS charset,
79+
@@transaction_isolation AS tx_isolation"
80+
);
81+
$info = $result->fetchAssociative();
82+
83+
$bufferPoolGB = round(($info['buffer_pool'] / 1024 / 1024 / 1024), 2);
84+
85+
return [
86+
['setting' => 'Engine', 'value' => 'MySQL/MariaDB'],
87+
['setting' => 'Version', 'value' => $info['version']],
88+
['setting' => 'Character Set', 'value' => $info['charset'], 'recommended' => 'utf8mb4', 'ok' => str_contains($info['charset'], 'utf8mb4')],
89+
['setting' => 'Max Connections', 'value' => $info['max_conn'], 'recommended' => '≥ 150', 'ok' => (int)$info['max_conn'] >= 150],
90+
['setting' => 'InnoDB Buffer Pool (GB)','value' => $bufferPoolGB, 'recommended' => '≥ 1 GB', 'ok' => $bufferPoolGB >= 1],
91+
['setting' => 'Transaction Isolation', 'value' => $info['tx_isolation'], 'recommended' => 'READ-COMMITTED', 'ok' => $info['tx_isolation'] === 'READ-COMMITTED'],
92+
];
93+
}
94+
95+
private function getPostgreSQLInfo(): array {
96+
$result = $this->connection->executeQuery(
97+
"SELECT version(),
98+
current_setting('max_connections') AS max_conn,
99+
current_setting('shared_buffers') AS shared_buffers,
100+
current_setting('work_mem') AS work_mem"
101+
);
102+
$info = $result->fetchAssociative();
103+
104+
return [
105+
['setting' => 'Engine', 'value' => 'PostgreSQL'],
106+
['setting' => 'Version', 'value' => $info['version']],
107+
['setting' => 'Max Connections', 'value' => $info['max_conn'], 'recommended' => '≥ 100', 'ok' => (int)$info['max_conn'] >= 100],
108+
['setting' => 'Shared Buffers', 'value' => $info['shared_buffers'],'recommended' => '128MB+', 'ok' => true],
109+
['setting' => 'Work Mem', 'value' => $info['work_mem'], 'recommended' => '4MB+', 'ok' => true],
110+
];
111+
}
112+
113+
private function getSQLiteInfo(): array {
114+
$result = $this->connection->executeQuery('SELECT sqlite_version() AS version');
115+
$info = $result->fetchAssociative();
116+
return [
117+
['setting' => 'Engine', 'value' => 'SQLite'],
118+
['setting' => 'Version', 'value' => $info['version']],
119+
];
120+
}
121+
}

core/Command/Db/DbLocks.php

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
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+
namespace OC\Core\Command\Db;
10+
11+
use OC\DB\Connection;
12+
use Symfony\Component\Console\Command\Command;
13+
use Symfony\Component\Console\Helper\Table;
14+
use Symfony\Component\Console\Input\InputInterface;
15+
use Symfony\Component\Console\Input\InputOption;
16+
use Symfony\Component\Console\Output\OutputInterface;
17+
use Doctrine\DBAL\Platforms\MySQLPlatform;
18+
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
19+
20+
class DbLocks extends Command {
21+
22+
public function __construct(
23+
private readonly Connection $connection,
24+
) {
25+
parent::__construct();
26+
}
27+
28+
protected function configure(): void {
29+
$this
30+
->setName('db:locks')
31+
->setDescription('Show active database locks, deadlocks, and long-running transactions')
32+
->addOption('json', null, InputOption::VALUE_NONE, 'Output in JSON format');
33+
}
34+
35+
protected function execute(InputInterface $input, OutputInterface $output): int {
36+
$platform = $this->connection->getDatabasePlatform();
37+
$asJson = $input->getOption('json');
38+
39+
if ($platform instanceof MySQLPlatform) {
40+
$sql = "
41+
SELECT r.trx_id AS waiting_trx_id,
42+
r.trx_mysql_thread_id AS waiting_thread,
43+
r.trx_query AS waiting_query,
44+
b.trx_id AS blocking_trx_id,
45+
b.trx_mysql_thread_id AS blocking_thread,
46+
b.trx_query AS blocking_query
47+
FROM information_schema.innodb_lock_waits w
48+
JOIN information_schema.innodb_trx b ON b.trx_id = w.blocking_trx_id
49+
JOIN information_schema.innodb_trx r ON r.trx_id = w.requesting_trx_id
50+
";
51+
$headers = ['Waiting TRX', 'Waiting Thread', 'Waiting Query', 'Blocking TRX', 'Blocking Thread', 'Blocking Query'];
52+
$cols = ['waiting_trx_id', 'waiting_thread', 'waiting_query', 'blocking_trx_id', 'blocking_thread', 'blocking_query'];
53+
} elseif ($platform instanceof PostgreSQLPlatform) {
54+
$sql = "
55+
SELECT blocked_locks.pid AS blocked_pid,
56+
blocked_activity.usename AS blocked_user,
57+
blocking_locks.pid AS blocking_pid,
58+
blocking_activity.usename AS blocking_user,
59+
blocked_activity.query AS blocked_query,
60+
blocking_activity.query AS blocking_query,
61+
now() - blocked_activity.query_start AS blocked_duration
62+
FROM pg_catalog.pg_locks blocked_locks
63+
JOIN pg_catalog.pg_stat_activity blocked_activity ON blocked_activity.pid = blocked_locks.pid
64+
JOIN pg_catalog.pg_locks blocking_locks
65+
ON blocking_locks.locktype = blocked_locks.locktype
66+
AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation
67+
AND blocking_locks.pid != blocked_locks.pid
68+
JOIN pg_catalog.pg_stat_activity blocking_activity ON blocking_activity.pid = blocking_locks.pid
69+
WHERE NOT blocked_locks.granted
70+
";
71+
$headers = ['Blocked PID', 'Blocked User', 'Blocking PID', 'Blocking User', 'Blocked Query', 'Duration'];
72+
$cols = ['blocked_pid', 'blocked_user', 'blocking_pid', 'blocking_user', 'blocked_query', 'blocked_duration'];
73+
} else {
74+
$output->writeln('<comment>db:locks is not supported for SQLite (SQLite uses file-level locking).</comment>');
75+
return Command::SUCCESS;
76+
}
77+
78+
$rows = $this->connection->executeQuery($sql)->fetchAllAssociative();
79+
80+
if (empty($rows)) {
81+
$output->writeln('<info>No active locks or blocking transactions detected.</info>');
82+
return Command::SUCCESS;
83+
}
84+
85+
if ($asJson) {
86+
$output->writeln(json_encode($rows, JSON_PRETTY_PRINT));
87+
return Command::SUCCESS;
88+
}
89+
90+
$output->writeln(sprintf('<error>Found %d blocking transaction(s)!</error>', count($rows)));
91+
$output->writeln('');
92+
93+
$table = new Table($output);
94+
$table->setHeaders($headers);
95+
96+
foreach ($rows as $row) {
97+
$table->addRow(array_map(fn($col) => $row[$col] ?? '', $cols));
98+
}
99+
100+
$table->render();
101+
return Command::SUCCESS;
102+
}
103+
}

0 commit comments

Comments
 (0)