Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions core/Command/Db/DbIndexUsage.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<?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;
}
}
121 changes: 121 additions & 0 deletions core/Command/Db/DbInfo.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
<?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;
use Doctrine\DBAL\Platforms\SqlitePlatform;

class DbInfo extends Command {

public function __construct(
private readonly Connection $connection,
) {
parent::__construct();
}

protected function configure(): void {
$this
->setName('db:info')
->setDescription('Show database server information and configuration health check')
->addOption('json', null, InputOption::VALUE_NONE, 'Output in JSON format');
}

protected function execute(InputInterface $input, OutputInterface $output): int {
$platform = $this->connection->getDatabasePlatform();
$asJson = $input->getOption('json');

if ($platform instanceof MySQLPlatform) {
$rows = $this->getMySQLInfo();
} elseif ($platform instanceof PostgreSQLPlatform) {
$rows = $this->getPostgreSQLInfo();
} elseif ($platform instanceof SqlitePlatform) {
$rows = $this->getSQLiteInfo();
} else {
$output->writeln('<error>Unsupported database platform.</error>');
return Command::FAILURE;
}

if ($asJson) {
$output->writeln(json_encode($rows, JSON_PRETTY_PRINT));
return Command::SUCCESS;
}

$table = new Table($output);
$table->setHeaders(['Setting', 'Value', 'Recommended', 'Status']);

foreach ($rows as $row) {
$status = isset($row['recommended'])
? ($row['ok'] ? '<info>OK</info>' : '<comment>CHECK</comment>')
: '';
$table->addRow([
$row['setting'],
$row['value'],
$row['recommended'] ?? '—',
$status,
]);
}

$table->render();
return Command::SUCCESS;
}

private function getMySQLInfo(): array {
$result = $this->connection->executeQuery(
"SELECT VERSION() AS version, @@innodb_buffer_pool_size AS buffer_pool,
@@max_connections AS max_conn, @@character_set_database AS charset,
@@transaction_isolation AS tx_isolation"
);
$info = $result->fetchAssociative();

$bufferPoolGB = round(($info['buffer_pool'] / 1024 / 1024 / 1024), 2);

return [
['setting' => 'Engine', 'value' => 'MySQL/MariaDB'],
['setting' => 'Version', 'value' => $info['version']],
['setting' => 'Character Set', 'value' => $info['charset'], 'recommended' => 'utf8mb4', 'ok' => str_contains($info['charset'], 'utf8mb4')],
['setting' => 'Max Connections', 'value' => $info['max_conn'], 'recommended' => '≥ 150', 'ok' => (int)$info['max_conn'] >= 150],
['setting' => 'InnoDB Buffer Pool (GB)','value' => $bufferPoolGB, 'recommended' => '≥ 1 GB', 'ok' => $bufferPoolGB >= 1],
['setting' => 'Transaction Isolation', 'value' => $info['tx_isolation'], 'recommended' => 'READ-COMMITTED', 'ok' => $info['tx_isolation'] === 'READ-COMMITTED'],
];
}

private function getPostgreSQLInfo(): array {
$result = $this->connection->executeQuery(
"SELECT version(),
current_setting('max_connections') AS max_conn,
current_setting('shared_buffers') AS shared_buffers,
current_setting('work_mem') AS work_mem"
);
$info = $result->fetchAssociative();

return [
['setting' => 'Engine', 'value' => 'PostgreSQL'],
['setting' => 'Version', 'value' => $info['version']],
['setting' => 'Max Connections', 'value' => $info['max_conn'], 'recommended' => '≥ 100', 'ok' => (int)$info['max_conn'] >= 100],
['setting' => 'Shared Buffers', 'value' => $info['shared_buffers'],'recommended' => '128MB+', 'ok' => true],
['setting' => 'Work Mem', 'value' => $info['work_mem'], 'recommended' => '4MB+', 'ok' => true],
];
}

private function getSQLiteInfo(): array {
$result = $this->connection->executeQuery('SELECT sqlite_version() AS version');
$info = $result->fetchAssociative();
return [
['setting' => 'Engine', 'value' => 'SQLite'],
['setting' => 'Version', 'value' => $info['version']],
];
}
}
103 changes: 103 additions & 0 deletions core/Command/Db/DbLocks.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?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 DbLocks extends Command {

public function __construct(
private readonly Connection $connection,
) {
parent::__construct();
}

protected function configure(): void {
$this
->setName('db:locks')
->setDescription('Show active database locks, deadlocks, and long-running transactions')
->addOption('json', null, InputOption::VALUE_NONE, 'Output in JSON format');
}

protected function execute(InputInterface $input, OutputInterface $output): int {
$platform = $this->connection->getDatabasePlatform();
$asJson = $input->getOption('json');

if ($platform instanceof MySQLPlatform) {
$sql = "
SELECT r.trx_id AS waiting_trx_id,
r.trx_mysql_thread_id AS waiting_thread,
r.trx_query AS waiting_query,
b.trx_id AS blocking_trx_id,
b.trx_mysql_thread_id AS blocking_thread,
b.trx_query AS blocking_query
FROM information_schema.innodb_lock_waits w
JOIN information_schema.innodb_trx b ON b.trx_id = w.blocking_trx_id
JOIN information_schema.innodb_trx r ON r.trx_id = w.requesting_trx_id
";
$headers = ['Waiting TRX', 'Waiting Thread', 'Waiting Query', 'Blocking TRX', 'Blocking Thread', 'Blocking Query'];
$cols = ['waiting_trx_id', 'waiting_thread', 'waiting_query', 'blocking_trx_id', 'blocking_thread', 'blocking_query'];
} elseif ($platform instanceof PostgreSQLPlatform) {
$sql = "
SELECT blocked_locks.pid AS blocked_pid,
blocked_activity.usename AS blocked_user,
blocking_locks.pid AS blocking_pid,
blocking_activity.usename AS blocking_user,
blocked_activity.query AS blocked_query,
blocking_activity.query AS blocking_query,
now() - blocked_activity.query_start AS blocked_duration
FROM pg_catalog.pg_locks blocked_locks
JOIN pg_catalog.pg_stat_activity blocked_activity ON blocked_activity.pid = blocked_locks.pid
JOIN pg_catalog.pg_locks blocking_locks
ON blocking_locks.locktype = blocked_locks.locktype
AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation
AND blocking_locks.pid != blocked_locks.pid
JOIN pg_catalog.pg_stat_activity blocking_activity ON blocking_activity.pid = blocking_locks.pid
WHERE NOT blocked_locks.granted
";
$headers = ['Blocked PID', 'Blocked User', 'Blocking PID', 'Blocking User', 'Blocked Query', 'Duration'];
$cols = ['blocked_pid', 'blocked_user', 'blocking_pid', 'blocking_user', 'blocked_query', 'blocked_duration'];
} else {
$output->writeln('<comment>db:locks is not supported for SQLite (SQLite uses file-level locking).</comment>');
return Command::SUCCESS;
}

$rows = $this->connection->executeQuery($sql)->fetchAllAssociative();

if (empty($rows)) {
$output->writeln('<info>No active locks or blocking transactions detected.</info>');
return Command::SUCCESS;
}

if ($asJson) {
$output->writeln(json_encode($rows, JSON_PRETTY_PRINT));
return Command::SUCCESS;
}

$output->writeln(sprintf('<error>Found %d blocking transaction(s)!</error>', count($rows)));
$output->writeln('');

$table = new Table($output);
$table->setHeaders($headers);

foreach ($rows as $row) {
$table->addRow(array_map(fn($col) => $row[$col] ?? '—', $cols));
}

$table->render();
return Command::SUCCESS;
}
}
Loading