nextcloud/core/Command/Db/DbIndexUsage.php
Rodrigo Correia 729e1e6920 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>
2026-05-27 13:37:40 +01:00

115 lines
3.9 KiB
PHP

<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\Core\Command\Db;
use OC\DB\Connection;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
class DbIndexUsage extends Command {
public function __construct(
private readonly Connection $connection,
) {
parent::__construct();
}
protected function configure(): void {
$this
->setName('db:index-usage')
->setDescription('Report unused database indexes (indexes that slow writes but are never read)')
->addOption('json', null, InputOption::VALUE_NONE, 'Output in JSON format')
->addOption('all', null, InputOption::VALUE_NONE, 'Show all indexes, not just unused ones');
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$platform = $this->connection->getDatabasePlatform();
$asJson = $input->getOption('json');
$showAll = $input->getOption('all');
if ($platform instanceof MySQLPlatform) {
// Requires performance_schema to be enabled (default in MySQL 5.6+/MariaDB 10.0+)
$unused_filter = $showAll ? '' : "WHERE s.count_read = 0 AND s.index_name IS NOT NULL AND s.index_name != 'PRIMARY'";
$sql = "
SELECT s.object_name AS `table`,
s.index_name AS `index`,
s.count_read AS reads,
s.count_write AS writes
FROM performance_schema.table_io_waits_summary_by_index_usage s
{$unused_filter}
ORDER BY s.object_name, s.index_name
";
} elseif ($platform instanceof PostgreSQLPlatform) {
$unused_filter = $showAll ? '' : 'AND idx_scan = 0';
$sql = "
SELECT relname AS table,
indexrelname AS index,
idx_scan AS reads,
idx_tup_read AS tuples_read,
idx_tup_fetch AS tuples_fetched
FROM pg_stat_user_indexes
JOIN pg_index USING (indexrelid)
WHERE indisunique IS FALSE
{$unused_filter}
ORDER BY relname, indexrelname
";
} else {
$output->writeln('<comment>db:index-usage is not supported for SQLite.</comment>');
return Command::SUCCESS;
}
try {
$rows = $this->connection->executeQuery($sql)->fetchAllAssociative();
} catch (\Doctrine\DBAL\Exception $e) {
$output->writeln('<error>Failed to query index usage statistics. The required performance tables may not be available on this database version.</error>');
$output->writeln('<comment>Details: ' . $e->getMessage() . '</comment>');
return Command::FAILURE;
}
if (empty($rows)) {
$output->writeln('<info>No unused indexes found. Great!</info>');
return Command::SUCCESS;
}
if ($asJson) {
$output->writeln(json_encode($rows, JSON_PRETTY_PRINT));
return Command::SUCCESS;
}
$table = new Table($output);
if ($platform instanceof MySQLPlatform) {
$table->setHeaders(['Table', 'Index', 'Reads', 'Writes']);
foreach ($rows as $row) {
$table->addRow([$row['table'], $row['index'], $row['reads'], $row['writes']]);
}
} else {
$table->setHeaders(['Table', 'Index', 'Scans', 'Tuples Read', 'Tuples Fetched']);
foreach ($rows as $row) {
$table->addRow([$row['table'], $row['index'], $row['reads'], $row['tuples_read'], $row['tuples_fetched']]);
}
}
$table->render();
if (!$showAll) {
$output->writeln(sprintf(
'<comment>Found %d unused index(es). Consider removing them to improve write performance.</comment>',
count($rows)
));
}
return Command::SUCCESS;
}
}