Skip to content
Merged
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
2 changes: 2 additions & 0 deletions config/difflock.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use Difflock\Migration\Rules\DropTableRule;
use Difflock\Migration\Rules\ForeignKeyRule;
use Difflock\Migration\Rules\LargeTableRule;
use Difflock\Migration\Rules\RedundantIndexRule;
use Difflock\Migration\Rules\RenameColumnRule;
use Difflock\Migration\Rules\SensitiveColumnRule;
use Difflock\Migration\Rules\UnindexedForeignKeyRule;
Expand Down Expand Up @@ -223,6 +224,7 @@
DropIndexRule::class,
ForeignKeyRule::class,
UnindexedForeignKeyRule::class,
RedundantIndexRule::class,
SensitiveColumnRule::class,
LargeTableRule::class,
],
Expand Down
40 changes: 40 additions & 0 deletions src/Contracts/IndexStatistics.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

namespace Difflock\Contracts;

/**
* How much use the database has actually made of an index.
*
* This is the one thing that turns the drop-index rule from a hedge into an answer.
* Without it Difflock can only say it has no view of your query workload; with it
* the engine's own counters say whether anything has read the index at all.
*
* Every method may return null, and null means "the engine would not say" — never
* "zero". The difference matters more here than anywhere else in the package: a
* rule that read an unavailable counter as zero would tell you an index is unused
* and safe to drop when it is serving every request you have.
*
* @api Public API. Its shape is covered by the package version from 1.0 onward.
*/
interface IndexStatistics
{
/**
* How many times the index has been read since the engine last reset its
* counters, or null if it cannot be known.
*
* The number is cumulative and its window is the *engine's*, not Difflock's. A
* server restarted an hour ago reports an hour of history, and a rule using this
* must say so rather than implying the index has been unused forever.
*/
public function scans(string $table, string $index): ?int;

/**
* How long the counters have been accumulating, in days, or null if unknown.
*
* Without this a scan count of zero is uninterpretable. Eleven months of zero is
* evidence; eleven minutes of zero is nothing at all.
*/
public function observedDays(): ?int;
}
153 changes: 153 additions & 0 deletions src/Database/ConnectionIndexStatistics.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
<?php

declare(strict_types=1);

namespace Difflock\Database;

use Difflock\Contracts\IndexStatistics;
use Illuminate\Database\Connection;
use Illuminate\Database\ConnectionResolverInterface;
use RuntimeException;
use Throwable;

/**
* Index read counts, taken from whatever the engine already keeps.
*
* - **PostgreSQL** — `pg_stat_user_indexes.idx_scan`, the number of index scans
* the planner has initiated. The window comes from `pg_stat_database.stats_reset`.
* - **MySQL/MariaDB** — `performance_schema.table_io_waits_summary_by_index_usage`,
* whose `COUNT_STAR` counts I/O operations against each index. Available only
* when performance_schema is enabled, which on many managed instances it is not.
* - **SQLite** — nothing. SQLite keeps no such counters, and this says so rather
* than counting zero.
*
* One query, run at most once, and never against user tables — only against the
* statistics views the engine maintains for itself.
*
* Everything here is best-effort by design. A role without access to the statistics
* views is an ordinary production setup, and the answer is then "unknown", which
* makes the rules more cautious rather than less.
*/
final class ConnectionIndexStatistics implements IndexStatistics
{
/** @var array<string, int>|null */
private ?array $scans = null;

private ?int $days = null;

public function __construct(
private readonly ConnectionResolverInterface $connections,
private readonly ?string $connection = null,
) {}

public function scans(string $table, string $index): ?int
{
$this->load();

return $this->scans[$this->key($table, $index)] ?? null;
}

public function observedDays(): ?int
{
$this->load();

return $this->days;
}

private function load(): void
{
if ($this->scans !== null) {
return;
}

$this->scans = [];

try {
match ($this->driver()) {
'pgsql' => $this->loadPostgres(),
'mysql', 'mariadb' => $this->loadMysql(),
default => null,
};
} catch (Throwable) {
// No access to the statistics views, or the schema does not have them.
// Unknown is a legitimate answer and the rules are built for it.
$this->scans = [];
$this->days = null;
}
}

private function loadPostgres(): void
{
$sql = 'select relname as table_name, indexrelname as index_name, idx_scan as scans '
.'from pg_stat_user_indexes';

foreach ($this->connection()->select($sql) as $row) {
$this->record($row);
}

$reset = $this->connection()->select(
'select extract(epoch from (now() - stats_reset)) / 86400 as days '
.'from pg_stat_database where datname = current_database()',
);

$days = ((array) ($reset[0] ?? []))['days'] ?? null;

$this->days = is_numeric($days) && (int) $days >= 0 ? (int) $days : null;
}

private function loadMysql(): void
{
$sql = 'select object_name as table_name, index_name, count_star as scans '
.'from performance_schema.table_io_waits_summary_by_index_usage '
.'where object_schema = database() and index_name is not null';

foreach ($this->connection()->select($sql) as $row) {
$this->record($row);
}

// MySQL's performance_schema counters reset when the server restarts, and
// uptime is the closest thing to a window it will give.
$uptime = $this->connection()->select("show global status like 'Uptime'");
$value = ((array) ($uptime[0] ?? []))['Value'] ?? null;

$this->days = is_numeric($value) ? (int) ((int) $value / 86400) : null;
}

private function record(mixed $row): void
{
$values = (array) $row;

$table = $values['table_name'] ?? null;
$index = $values['index_name'] ?? null;
$scans = $values['scans'] ?? null;

if (! is_scalar($table) || ! is_scalar($index) || ! is_numeric($scans)) {
return;
}

$this->scans[$this->key((string) $table, (string) $index)] = (int) $scans;
}

private function key(string $table, string $index): string
{
return strtolower($table)."\0".strtolower($index);
}

private function driver(): string
{
try {
return $this->connection()->getDriverName();
} catch (Throwable) {
return '';
}
}

private function connection(): Connection
{
$connection = $this->connections->connection($this->connection);

return $connection instanceof Connection
? $connection
: throw new RuntimeException('Difflock needs a database connection it can query.');
}
}
1 change: 1 addition & 0 deletions src/Database/DatabaseContextFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ private function build(): DatabaseContext
environment: $this->environment(),
version: $this->version(),
available: true,
indexes: new ConnectionIndexStatistics($this->connections, $this->connection),
);
}

Expand Down
37 changes: 37 additions & 0 deletions src/Database/FixedIndexStatistics.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace Difflock\Database;

use Difflock\Contracts\IndexStatistics;

/**
* Index usage supplied by hand, and the null object for engines that keep none.
*
* Part of the public API for the same reason as {@see FixedTableStatistics}: a rule
* that reasons about index usage should be testable without a database that has
* been running long enough to have any.
*
* @api Public API. Its shape is covered by the package version from 1.0 onward.
*/
final readonly class FixedIndexStatistics implements IndexStatistics
{
/**
* @param array<string, int> $scans Keyed `table.index`.
*/
public function __construct(
private array $scans = [],
private ?int $days = null,
) {}

public function scans(string $table, string $index): ?int
{
return $this->scans[$table.'.'.$index] ?? null;
}

public function observedDays(): ?int
{
return $this->days;
}
}
20 changes: 20 additions & 0 deletions src/Migration/DatabaseContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

namespace Difflock\Migration;

use Difflock\Contracts\IndexStatistics;
use Difflock\Contracts\TableStatistics;
use Difflock\Database\FixedIndexStatistics;
use Difflock\Schema\DatabaseSchema;
use Difflock\Schema\Table;

Expand Down Expand Up @@ -34,8 +36,26 @@ public function __construct(
public string $environment = 'unknown',
public ?string $version = null,
public bool $available = true,
public IndexStatistics $indexes = new FixedIndexStatistics,
) {}

/**
* How many times the engine has read this index, or null when it will not say.
*
* Null is "unknown", never zero — the distinction is load-bearing for the
* drop-index rule, which would otherwise call a heavily used index unused.
*/
public function indexScans(?string $table, string $index): ?int
{
return $table === null || ! $this->available ? null : $this->indexes->scans($table, $index);
}

/** How long the engine's index counters have been accumulating, in days. */
public function indexObservedDays(): ?int
{
return $this->available ? $this->indexes->observedDays() : null;
}

public function driver(): ?string
{
return $this->schema->driver;
Expand Down
Loading
Loading