Skip to content

Commit 7a7e09a

Browse files
committed
Unused Indexes: PgHero-style context + size sort
A user reported ~231 indexes flagged as unused on a real PG database, basically every non-PK/non-unique index. Investigation: idx_scan is cumulative since the stats source was last reset, so a recent PG restart or pg_stat_reset() makes everything read as "0 scans" until real traffic accumulates. We were emitting a bare JSON array of matches, with no context for the user to tell "truly unused" apart from "stats are fresh." Matches PgHero's approach: - The route now returns { indexes, stats_reset_at, min_scans } so the dashboard can render "Stats last reset 2h ago · threshold: scans ≤ 0" above the table. `stats_reset_at` comes from pg_stat_database.stats_reset on PG and is nil on MySQL (whose table_io_waits counters don't surface a reset time cheaply). - New config.min_unused_index_scans (default 0 to match PgHero). Both builders thread it through to idx_scan <= N / COUNT_READ <= N so users can ignore indexes that are technically used but rarely enough to be drop candidates. - PG results now sort by pg_relation_size(indexrelid) DESC and carry a size_bytes field; the table shows a Size column with the biggest candidates at the top (where most wins live). MySQL emits a nil size_bytes — individual index size isn't cheap to surface from information_schema.STATISTICS. - Dropped the `c.reltuples > 0` filter on PG. PgHero doesn't have it and an empty table with unused indexes is still actionable noise. The view + count widget were updated to read data.indexes; existing JSON-array consumers will need the small shape change but that's called out in CHANGELOG. IndexPlanner (which consumed UnusedIndexes directly) now pulls .indexes off the Result struct. https://claude.ai/code/session_01Xy96nK3Ron2NqukBiSgtzE
1 parent 51fe567 commit 7a7e09a

12 files changed

Lines changed: 252 additions & 61 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@
2121
- The `mysql_genius-desktop` gem stub. Desktop sidecar / macOS DMG packaging scripts (`packaging/macos/`) are removed; they only assembled the now-deleted gem.
2222
- `MysqlGenius::Core::VERSION` constant. Use `MysqlGenius::VERSION`.
2323

24+
### Unused Indexes: PgHero-style context
25+
- `GET /mysql_genius/unused_indexes` now returns `{ indexes: [...], stats_reset_at, min_scans }` instead of a bare array. The Unused Indexes tab shows "Stats last reset N ago · threshold: scans ≤ M" above the table so an empty/fresh stats source doesn't read as "everything is unused."
26+
- New `config.min_unused_index_scans` (default 0 — PgHero parity). Raise it (e.g. 50) to ignore indexes that are technically used but rarely enough to consider dropping.
27+
- The PG query now sorts results by index byte size DESC (largest first — biggest wins). Each result hash carries a `size_bytes` field; the table shows a Size column. `pg_relation_size` is the source.
28+
- Removed the `c.reltuples > 0` filter so indexes on empty tables show up too (matches PgHero — empty tables with indexes are still actionable noise).
29+
2430
### Notes
2531
- PostgreSQL query stats require the `pg_stat_statements` extension to be installed and enabled (`shared_preload_libraries`).
2632
- Slow query log capture remains MySQL-only.

app/controllers/concerns/mysql_genius/database_analysis.rb

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,15 @@ def query_stats
2626
end
2727

2828
def unused_indexes
29-
indexes = MysqlGenius::Core::Analysis::UnusedIndexes.new(rails_connection).call
30-
render(json: indexes)
29+
result = MysqlGenius::Core::Analysis::UnusedIndexes.new(
30+
rails_connection,
31+
min_scans: mysql_genius_config.min_unused_index_scans,
32+
).call
33+
render(json: {
34+
indexes: result.indexes,
35+
stats_reset_at: result.stats_reset_at,
36+
min_scans: result.min_scans,
37+
})
3138
rescue ActiveRecord::StatementInvalid => e
3239
render(json: { error: "#{unused_indexes_source_name} #{e.message.split(":").last.strip}" }, status: :unprocessable_entity)
3340
end

lib/mysql_genius/configuration.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,14 @@ class Configuration
7272
# Defaults to true.
7373
attr_accessor :stats_collection
7474

75+
# Maximum scan count for an index to still be considered "unused" by the
76+
# Unused Indexes dashboard. The default (0) means only indexes that have
77+
# never been scanned since the stats source was last reset are flagged.
78+
# Raise this to ignore indexes that are technically used but rarely
79+
# enough to be worth dropping (e.g. min_unused_index_scans = 50 to require
80+
# at least 50 scans before considering an index "useful").
81+
attr_accessor :min_unused_index_scans
82+
7583
def initialize
7684
@featured_tables = []
7785
@blocked_tables = [
@@ -96,6 +104,7 @@ def initialize
96104
@audit_logger = nil
97105
@base_controller = "ActionController::Base"
98106
@stats_collection = true
107+
@min_unused_index_scans = 0
99108
end
100109

101110
def ai_enabled?

lib/mysql_genius/core/ai/index_planner.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ def call(tables = nil)
1414
target_tables = resolve_tables(tables)
1515
return { "plan" => "No tables found to analyze." } if target_tables.empty?
1616

17-
unused = Analysis::UnusedIndexes.new(@connection).call
17+
unused = Analysis::UnusedIndexes.new(@connection).call.indexes
1818
duplicates = Analysis::DuplicateIndexes.new(@connection, blocked_tables: []).call
1919
schema = SchemaContextBuilder.new(@connection).call(target_tables, detail: :with_cardinality)
2020

lib/mysql_genius/core/analysis/unused_indexes.rb

Lines changed: 44 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,34 +3,58 @@
33
module MysqlGenius
44
module Core
55
module Analysis
6-
# Finds indexes with zero reads but a non-zero parent table row count.
7-
# On MySQL this reads performance_schema.table_io_waits_summary_by_index_usage;
8-
# on PostgreSQL it reads pg_stat_user_indexes. Returns hashes with a
9-
# ready-to-run DROP INDEX statement appropriate for the dialect.
6+
# Indexes whose scan count is at or below `min_scans` (default 0 — never
7+
# scanned since the underlying stats source was last reset). On MySQL this
8+
# reads performance_schema.table_io_waits_summary_by_index_usage; on
9+
# PostgreSQL it reads pg_stat_user_indexes plus pg_relation_size for the
10+
# index byte size.
1011
#
11-
# Skips primary key indexes on both dialects. Raises if the underlying
12-
# stats source is unavailable.
12+
# Returns a Result with:
13+
# indexes — Array of per-index hashes (sorted by size DESC on PG,
14+
# by write count DESC on MySQL); each carries a dialect-
15+
# appropriate `drop_sql` and a `size_bytes` value (nil
16+
# on MySQL where individual index sizes aren't cheap).
17+
# stats_reset_at — Time the underlying stats source was last reset
18+
# (PG only — pg_stat_database.stats_reset; nil on MySQL).
19+
# min_scans — The scan threshold used for this call, echoed back so
20+
# callers can display "indexes with ≤ N scans".
21+
#
22+
# Skips primary key indexes on both dialects, plus unique indexes (which
23+
# are usually backing a constraint the application depends on). Raises if
24+
# the underlying stats source is unavailable.
1325
class UnusedIndexes
14-
def initialize(connection)
26+
Result = Struct.new(:indexes, :stats_reset_at, :min_scans, keyword_init: true)
27+
28+
def initialize(connection, min_scans: 0)
1529
@connection = connection
1630
@builder = QueryBuilders.for(connection)
31+
@min_scans = [min_scans.to_i, 0].max
1732
end
1833

1934
def call
20-
result = @connection.exec_query(@builder.unused_indexes(@connection))
35+
rows = @connection.exec_query(@builder.unused_indexes(@connection, min_scans: @min_scans)).to_hashes
36+
Result.new(
37+
indexes: rows.map { |row| transform(row) },
38+
stats_reset_at: @builder.stats_reset_at(@connection),
39+
min_scans: @min_scans,
40+
)
41+
end
42+
43+
private
2144

22-
result.to_hashes.map do |row|
23-
table = row["table_name"] || row["TABLE_NAME"]
24-
index_name = row["index_name"] || row["INDEX_NAME"]
25-
{
26-
table: table,
27-
index_name: index_name,
28-
reads: (row["reads"] || row["READS"] || 0).to_i,
29-
writes: (row["writes"] || row["WRITES"] || 0).to_i,
30-
table_rows: (row["table_rows"] || row["TABLE_ROWS"] || 0).to_i,
31-
drop_sql: @builder.drop_index_sql(table: table, index_name: index_name),
32-
}
33-
end
45+
def transform(row)
46+
table = row["table_name"] || row["TABLE_NAME"]
47+
index_name = row["index_name"] || row["INDEX_NAME"]
48+
size_bytes = row["size_bytes"] || row["SIZE_BYTES"]
49+
{
50+
table: table,
51+
index_name: index_name,
52+
reads: (row["reads"] || row["READS"] || 0).to_i,
53+
writes: (row["writes"] || row["WRITES"] || 0).to_i,
54+
table_rows: (row["table_rows"] || row["TABLE_ROWS"] || 0).to_i,
55+
size_bytes: size_bytes&.to_i,
56+
drop_sql: @builder.drop_index_sql(table: table, index_name: index_name),
57+
}
3458
end
3559
end
3660
end

lib/mysql_genius/core/query_builders/mysql.rb

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,27 +89,35 @@ def stats_snapshot(connection, limit:)
8989
SQL
9090
end
9191

92-
def unused_indexes(connection)
92+
def unused_indexes(connection, min_scans: 0)
93+
threshold = [min_scans.to_i, 0].max
9394
<<~SQL
9495
SELECT
9596
s.OBJECT_SCHEMA AS table_schema,
9697
s.OBJECT_NAME AS table_name,
9798
s.INDEX_NAME AS index_name,
9899
s.COUNT_READ AS `reads`,
99100
s.COUNT_WRITE AS `writes`,
100-
t.TABLE_ROWS AS table_rows
101+
t.TABLE_ROWS AS table_rows,
102+
NULL AS size_bytes
101103
FROM performance_schema.table_io_waits_summary_by_index_usage s
102104
JOIN information_schema.tables t
103105
ON t.TABLE_SCHEMA = s.OBJECT_SCHEMA AND t.TABLE_NAME = s.OBJECT_NAME
104106
WHERE s.OBJECT_SCHEMA = #{connection.quote(connection.current_database)}
105107
AND s.INDEX_NAME IS NOT NULL
106108
AND s.INDEX_NAME != 'PRIMARY'
107-
AND s.COUNT_READ = 0
108-
AND t.TABLE_ROWS > 0
109+
AND s.COUNT_READ <= #{threshold}
109110
ORDER BY s.COUNT_WRITE DESC
110111
SQL
111112
end
112113

114+
# MySQL's table_io_waits counters track since server start with no
115+
# cheap way to surface that timestamp at query time, so we return nil
116+
# and let the dashboard fall back to "since server restart" wording.
117+
def stats_reset_at(_connection)
118+
nil
119+
end
120+
113121
def drop_index_sql(table:, index_name:)
114122
"ALTER TABLE `#{table}` DROP INDEX `#{index_name}`;"
115123
end

lib/mysql_genius/core/query_builders/postgresql.rb

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -105,26 +105,40 @@ def stats_snapshot(connection, limit:)
105105
SQL
106106
end
107107

108-
def unused_indexes(_connection)
108+
def unused_indexes(_connection, min_scans: 0)
109+
threshold = [min_scans.to_i, 0].max
109110
<<~SQL
110111
SELECT
111112
s.schemaname AS table_schema,
112113
s.relname AS table_name,
113114
s.indexrelname AS index_name,
114115
s.idx_scan AS reads,
115116
s.idx_tup_read AS writes,
116-
c.reltuples::bigint AS table_rows
117+
c.reltuples::bigint AS table_rows,
118+
pg_relation_size(s.indexrelid)::bigint AS size_bytes
117119
FROM pg_stat_user_indexes s
118120
JOIN pg_index i ON i.indexrelid = s.indexrelid
119121
JOIN pg_class c ON c.oid = s.relid
120122
WHERE NOT i.indisprimary
121123
AND NOT i.indisunique
122-
AND s.idx_scan = 0
123-
AND c.reltuples > 0
124-
ORDER BY s.idx_tup_read DESC, s.indexrelname ASC
124+
AND s.idx_scan <= #{threshold}
125+
ORDER BY pg_relation_size(s.indexrelid) DESC, s.indexrelname ASC
125126
SQL
126127
end
127128

129+
# Last time pg_stat_database counters (which back pg_stat_user_indexes,
130+
# pg_stat_user_tables, etc.) were reset for the current database.
131+
# Surfacing this lets the dashboard distinguish "this index is unused"
132+
# from "stats were reset five minutes ago and nothing has run yet".
133+
def stats_reset_at(connection)
134+
connection.select_value(
135+
"SELECT stats_reset FROM pg_stat_database " \
136+
"WHERE datname = #{connection.quote(connection.current_database)}",
137+
)
138+
rescue StandardError
139+
nil
140+
end
141+
128142
def drop_index_sql(table:, index_name:)
129143
_ = table
130144
%(DROP INDEX IF EXISTS "#{index_name.to_s.gsub('"', '""')}";)

lib/mysql_genius/core/views/mysql_genius/queries/_tab_unused_indexes.html.erb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
</div>
2121
</div>
2222
</div>
23+
<div id="unused-stats-context" class="mg-text-muted mg-hidden" style="font-size:12px;margin-bottom:8px;"></div>
2324
<div id="unused-loading" class="mg-text-center mg-hidden"><span class="mg-spinner"></span> Scanning...</div>
2425
<div id="unused-error" class="mg-hidden"></div>
2526
<div id="unused-empty" class="mg-text-center mg-text-muted mg-hidden">No unused indexes found.</div>
@@ -29,6 +30,7 @@
2930
<tr>
3031
<th>Table</th>
3132
<th>Index</th>
33+
<th style="text-align:right">Size</th>
3234
<th style="text-align:right">Reads</th>
3335
<th style="text-align:right">Writes</th>
3436
<th style="text-align:right">Table Rows</th>

lib/mysql_genius/core/views/mysql_genius/queries/dashboard.html.erb

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,47 @@
8989
function hide(e) { e.classList.add('mg-hidden'); }
9090
function escHtml(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
9191

92+
function formatBytes(bytes) {
93+
if (bytes === null || bytes === undefined) return '—';
94+
var n = Number(bytes);
95+
if (!isFinite(n) || n <= 0) return '0 B';
96+
var units = ['B', 'KB', 'MB', 'GB', 'TB'];
97+
var i = Math.min(units.length - 1, Math.floor(Math.log(n) / Math.log(1024)));
98+
return (n / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1) + ' ' + units[i];
99+
}
100+
101+
function formatRelativeTime(isoString) {
102+
if (!isoString) return null;
103+
var then = new Date(isoString);
104+
if (isNaN(then.getTime())) return null;
105+
var diffMs = Date.now() - then.getTime();
106+
if (diffMs < 0) diffMs = 0;
107+
var seconds = Math.floor(diffMs / 1000);
108+
if (seconds < 60) return seconds + 's ago';
109+
var minutes = Math.floor(seconds / 60);
110+
if (minutes < 60) return minutes + 'm ago';
111+
var hours = Math.floor(minutes / 60);
112+
if (hours < 48) return hours + 'h ago';
113+
var days = Math.floor(hours / 24);
114+
return days + 'd ago';
115+
}
116+
117+
function renderUnusedStatsContext(data) {
118+
var ctx = el('unused-stats-context');
119+
if (!ctx) return;
120+
var parts = [];
121+
if (data && data.stats_reset_at) {
122+
var rel = formatRelativeTime(data.stats_reset_at);
123+
if (rel) parts.push('Stats last reset <strong>' + escHtml(rel) + '</strong>');
124+
}
125+
var threshold = data && (data.min_scans || data.min_scans === 0) ? data.min_scans : null;
126+
if (threshold !== null) {
127+
parts.push('threshold: scans &le; <strong>' + threshold + '</strong>');
128+
}
129+
ctx.innerHTML = parts.length ? parts.join(' &middot; ') : '';
130+
if (parts.length) show(ctx); else hide(ctx);
131+
}
132+
92133
// --- Table Sorting ---
93134

94135
function parseSortValue(text) {
@@ -459,7 +500,8 @@
459500

460501
// Unused indexes count
461502
ajaxGet(ROUTES.unused_indexes, {}, function(data) {
462-
var count = Array.isArray(data) ? data.length : 0;
503+
var indexes = (data && data.indexes) || [];
504+
var count = indexes.length;
463505
var countEl = el('dash-unused-count');
464506
if (count === 0) {
465507
countEl.innerHTML = '<span style="color:#28a745;">&#10003; 0</span>';
@@ -1142,12 +1184,15 @@
11421184
el('unused-error').innerHTML = '<div class="mg-alert mg-alert-warning">' + escHtml(data.error) + '</div>';
11431185
show(el('unused-error')); return;
11441186
}
1145-
if (!data.length) { show(el('unused-empty')); el('unused-count').textContent = '0'; return; }
1146-
el('unused-count').textContent = data.length + ' found';
1147-
el('unused-tbody').innerHTML = data.map(function(d) {
1187+
var indexes = (data && data.indexes) || [];
1188+
renderUnusedStatsContext(data);
1189+
if (!indexes.length) { show(el('unused-empty')); el('unused-count').textContent = '0'; return; }
1190+
el('unused-count').textContent = indexes.length + ' found';
1191+
el('unused-tbody').innerHTML = indexes.map(function(d) {
11481192
return '<tr>' +
11491193
'<td><strong>' + escHtml(d.table) + '</strong></td>' +
11501194
'<td><code>' + escHtml(d.index_name) + '</code></td>' +
1195+
'<td class="mg-num">' + formatBytes(d.size_bytes) + '</td>' +
11511196
'<td class="mg-num">' + d.reads + '</td>' +
11521197
'<td class="mg-num">' + Number(d.writes).toLocaleString() + '</td>' +
11531198
'<td class="mg-num">' + Number(d.table_rows).toLocaleString() + '</td>' +
@@ -1161,7 +1206,7 @@
11611206
var ts = migrationTimestamp();
11621207
var migrationLines = ['# ' + ts + '_remove_unused_indexes.rb', '',
11631208
'class RemoveUnusedIndexes < ActiveRecord::Migration[' + RAILS_MIGRATION_VERSION + ']', ' def change'];
1164-
data.forEach(function(d) {
1209+
indexes.forEach(function(d) {
11651210
migrationLines.push(' remove_index :' + d.table + ', name: :' + d.index_name);
11661211
});
11671212
migrationLines.push(' end', 'end');

spec/mysql_genius/configuration_spec.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@
2828
expect(config.slow_query_threshold_ms).to(eq(250))
2929
end
3030

31+
it "defaults min_unused_index_scans to 0 (PgHero parity — flag only truly unused)" do
32+
expect(config.min_unused_index_scans).to(eq(0))
33+
end
34+
3135
it "defaults authenticate to allow all" do
3236
expect(config.authenticate.call(nil)).to(be(true))
3337
end

0 commit comments

Comments
 (0)