|
| 1 | +package io.github.databaseaudits.audit.runtime.plan; |
| 2 | + |
| 3 | +import java.util.Comparator; |
| 4 | +import java.util.HashSet; |
| 5 | +import java.util.List; |
| 6 | +import java.util.Locale; |
| 7 | +import java.util.Set; |
| 8 | + |
| 9 | +import com.fasterxml.jackson.databind.JsonNode; |
| 10 | + |
| 11 | +import org.jspecify.annotations.Nullable; |
| 12 | + |
| 13 | +import io.github.databaseaudits.audit.finding.Finding; |
| 14 | +import io.github.databaseaudits.audit.finding.UnusedIndexFinding; |
| 15 | +import io.github.databaseaudits.capture.SqlCapturingStatementInspector; |
| 16 | +import io.github.databaseaudits.catalog.ForeignKeyCatalog; |
| 17 | +import io.github.databaseaudits.catalog.ForeignKeyDefinition; |
| 18 | +import io.github.databaseaudits.catalog.IndexCatalog; |
| 19 | +import io.github.databaseaudits.catalog.IndexDefinition; |
| 20 | +import io.github.databaseaudits.plan.QueryPlanExplainer; |
| 21 | +import lombok.AllArgsConstructor; |
| 22 | +import lombok.extern.slf4j.Slf4j; |
| 23 | + |
| 24 | +/** |
| 25 | + * Advisory: every index should be used by at least one captured statement's |
| 26 | + * plan. |
| 27 | + * |
| 28 | + * <p> |
| 29 | + * Every index taxes every write and consumes cache/storage; one that no real |
| 30 | + * query uses is pure cost. This is the <em>inverse</em> of the other plan |
| 31 | + * audits — instead of proving one statement lacks a serving index, it proves |
| 32 | + * one <em>index</em> serves no statement in the whole captured workload — so |
| 33 | + * it does not extend {@link CapturedSqlPlanAuditTemplate} (whose fixed |
| 34 | + * algorithm emits one finding per offending statement; this audit needs the |
| 35 | + * union of index usage across every statement, then a diff against the |
| 36 | + * catalog). Candidates are planned via the natural generic plan (no planner |
| 37 | + * penalties): {@link QueryPlanExplainer#planWith(String, String...)} with no |
| 38 | + * session settings, walking every {@code Index Name} the plan mentions at any |
| 39 | + * depth. An index from {@link IndexCatalog} is <em>justified</em> — never |
| 40 | + * reported — when it backs a primary key or a unique constraint (a partial |
| 41 | + * index is also never reported: a generic plan without bind values usually |
| 42 | + * cannot prove a partial index unusable, so this is a conservative skip), when |
| 43 | + * its name appears in the collected usage, or when it covers a foreign key |
| 44 | + * (an index {@link io.github.databaseaudits.audit.catalog.ForeignKeyIndexAudit |
| 45 | + * ForeignKeyIndexAudit} demands must never be reported unused here). |
| 46 | + * |
| 47 | + * <p> |
| 48 | + * <strong>This audit is advisory and workload-dependent</strong>: the capture |
| 49 | + * must hold a representative workload, or a genuinely used index looks |
| 50 | + * unused. A generic plan also has no real table statistics, so it can miss an |
| 51 | + * index the planner would pick under production data's distribution. Always |
| 52 | + * confirm against production {@code pg_stat_user_indexes} before dropping an |
| 53 | + * index this audit reports. Requires PostgreSQL 16+ and |
| 54 | + * {@code preferQueryMode=simple} on the JDBC URL, exactly like the other plan |
| 55 | + * audits; fails fast via {@link QueryPlanExplainer#requirePlanAuditSupport(String)} |
| 56 | + * on any other platform, and throws rather than reporting nothing (or, worse, |
| 57 | + * reporting every non-justified index as unused with no evidence at all) on an |
| 58 | + * empty capture, a capture with no {@code SELECT}/{@code WITH}/{@code UPDATE}/ |
| 59 | + * {@code DELETE} candidates at all (e.g. an INSERT-only workload), or a |
| 60 | + * wholly-unexplainable run. |
| 61 | + * |
| 62 | + * <p> |
| 63 | + * Fix: drop the index after confirming against production usage statistics, |
| 64 | + * or exclude it (e.g. an index kept for a rare admin query outside the |
| 65 | + * captured workload). |
| 66 | + */ |
| 67 | +@AllArgsConstructor |
| 68 | +@Slf4j |
| 69 | +public class UnusedIndexAudit { |
| 70 | + private static final String FAIL_NO_EXPLAINS_MSG = """ |
| 71 | + %d candidate statement shape(s) were captured but none could be EXPLAINed,\ |
| 72 | + so this audit verified nothing\ |
| 73 | + — this plan-based audit is PostgreSQL 16+ only. |
| 74 | + On PostgreSQL, the most likely cause is a missing \ |
| 75 | + preferQueryMode=simple on the test datasource JDBC URL. |
| 76 | + See: https://database-audits.github.io/spring-boot-integration/usage.html#postgresql-jdbc-requirement"""; |
| 77 | + |
| 78 | + private static final String FAIL_NO_CANDIDATES_MSG = """ |
| 79 | + %d statement(s) were captured but none were SELECT/WITH/UPDATE/DELETE,\ |
| 80 | + so this audit verified nothing about index usage\ |
| 81 | + — every catalog index would otherwise look unused with no evidence at all. |
| 82 | + Capture a representative read/write workload (not just INSERTs) before running this audit."""; |
| 83 | + |
| 84 | + private final QueryPlanExplainer queryPlanExplainer; |
| 85 | + private final SqlCapturingStatementInspector sqlCapturer; |
| 86 | + private final IndexCatalog indexCatalog; |
| 87 | + private final ForeignKeyCatalog foreignKeyCatalog; |
| 88 | + |
| 89 | + /** |
| 90 | + * Returns one {@link Finding} for every index used by no captured |
| 91 | + * statement's plan, except the excluded ones; an empty list when every |
| 92 | + * index is justified. |
| 93 | + * |
| 94 | + * @param schema |
| 95 | + * The schema to scan. |
| 96 | + * @param excludedIndexes |
| 97 | + * The index names to skip. |
| 98 | + * @return One {@link Finding} per unused index, sorted by table then index |
| 99 | + * — its {@link Finding#description() description} is the reported |
| 100 | + * line; an empty list when every index is justified. |
| 101 | + * @throws UnsupportedOperationException |
| 102 | + * On any non-PostgreSQL |
| 103 | + * platform. |
| 104 | + * @throws IllegalStateException |
| 105 | + * If nothing was captured, if |
| 106 | + * nothing captured was a |
| 107 | + * SELECT/WITH/UPDATE/DELETE |
| 108 | + * candidate, or if candidates |
| 109 | + * were captured but none could |
| 110 | + * be EXPLAINed. |
| 111 | + */ |
| 112 | + public List<Finding> audit(final String schema, |
| 113 | + final Set<String> excludedIndexes) { |
| 114 | + queryPlanExplainer.requirePlanAuditSupport("UnusedIndexAudit"); |
| 115 | + |
| 116 | + final Set<String> capturedSql = sqlCapturer.capturedSql(); |
| 117 | + if (capturedSql.isEmpty()) { |
| 118 | + throw new IllegalStateException( |
| 119 | + SqlCapturingStatementInspector.EMPTY_CAPTURE_MESSAGE); |
| 120 | + } |
| 121 | + |
| 122 | + final Set<String> usedIndexNames = new HashSet<>(); |
| 123 | + final Set<String> checkedShapes = new HashSet<>(); |
| 124 | + int explainedCount = 0; |
| 125 | + for (final String rawSql : capturedSql) { |
| 126 | + final String trimmedSql = rawSql.strip(); |
| 127 | + final String normalizedSql = sqlCapturer.normalize(trimmedSql); |
| 128 | + final String upperCasedSql = |
| 129 | + normalizedSql.toUpperCase(Locale.ROOT); |
| 130 | + if (!isCandidate(upperCasedSql) |
| 131 | + || !checkedShapes.add(upperCasedSql)) { |
| 132 | + continue; |
| 133 | + } |
| 134 | + explainedCount += explain(trimmedSql, usedIndexNames); |
| 135 | + } |
| 136 | + |
| 137 | + if (checkedShapes.isEmpty()) { |
| 138 | + throw new IllegalStateException( |
| 139 | + FAIL_NO_CANDIDATES_MSG.formatted(capturedSql.size())); |
| 140 | + } |
| 141 | + if (explainedCount == 0) { |
| 142 | + throw new IllegalStateException( |
| 143 | + FAIL_NO_EXPLAINS_MSG.formatted(checkedShapes.size())); |
| 144 | + } |
| 145 | + |
| 146 | + final List<ForeignKeyDefinition> foreignKeys = |
| 147 | + foreignKeyCatalog.readAll(schema); |
| 148 | + |
| 149 | + return indexCatalog.readAll(schema).stream() |
| 150 | + .filter(index -> !isJustified(index, usedIndexNames, |
| 151 | + foreignKeys)) |
| 152 | + .filter(index -> !excludedIndexes.contains(index.indexName())) |
| 153 | + .sorted(Comparator.comparing(IndexDefinition::tableName) |
| 154 | + .thenComparing(IndexDefinition::indexName)) |
| 155 | + .<Finding>map(index -> new UnusedIndexFinding( |
| 156 | + index.tableName(), index.indexName())) |
| 157 | + .toList(); |
| 158 | + } |
| 159 | + |
| 160 | + private boolean isCandidate(final String upperCasedSql) { |
| 161 | + return upperCasedSql.startsWith("SELECT") |
| 162 | + || upperCasedSql.startsWith("WITH") |
| 163 | + || upperCasedSql.startsWith("UPDATE") |
| 164 | + || upperCasedSql.startsWith("DELETE"); |
| 165 | + } |
| 166 | + |
| 167 | + private int explain(final String sql, final Set<String> usedIndexNames) { |
| 168 | + try { |
| 169 | + final JsonNode plan = queryPlanExplainer.planWith(sql); |
| 170 | + collectIndexNames(plan, usedIndexNames); |
| 171 | + return 1; |
| 172 | + } catch (final Exception e) { |
| 173 | + log.debug( |
| 174 | + "Skipping un-explainable statement [{}]: un-checkable (parameter " |
| 175 | + + "type inference, jsonb `?`, unparsable). The subsequent " |
| 176 | + + "all-skipped guard still catches a wholly vacuous run.", |
| 177 | + sql, e); |
| 178 | + return 0; |
| 179 | + } |
| 180 | + } |
| 181 | + |
| 182 | + private void collectIndexNames(final @Nullable JsonNode node, |
| 183 | + final Set<String> usedIndexNames) { |
| 184 | + if (node == null) { |
| 185 | + return; |
| 186 | + } |
| 187 | + final String indexName = |
| 188 | + queryPlanExplainer.textOf(node, PlanJson.INDEX_NAME); |
| 189 | + if (indexName != null) { |
| 190 | + usedIndexNames.add(indexName); |
| 191 | + } |
| 192 | + final JsonNode planNodes = node.get(PlanJson.PLANS); |
| 193 | + if (planNodes != null) { |
| 194 | + for (final JsonNode planNode : planNodes) { |
| 195 | + collectIndexNames(planNode, usedIndexNames); |
| 196 | + } |
| 197 | + } |
| 198 | + } |
| 199 | + |
| 200 | + private boolean isJustified(final IndexDefinition index, |
| 201 | + final Set<String> usedIndexNames, |
| 202 | + final List<ForeignKeyDefinition> foreignKeys) { |
| 203 | + return index.primary() || index.unique() || index.partial() |
| 204 | + || usedIndexNames.contains(index.indexName()) |
| 205 | + || coversAnyForeignKey(index, foreignKeys); |
| 206 | + } |
| 207 | + |
| 208 | + private boolean coversAnyForeignKey(final IndexDefinition index, |
| 209 | + final List<ForeignKeyDefinition> foreignKeys) { |
| 210 | + return foreignKeys.stream() |
| 211 | + .filter(fk -> fk.tableName().equals(index.tableName())) |
| 212 | + .anyMatch(fk -> index.leadingColumnsCover(fk.columns())); |
| 213 | + } |
| 214 | +} |
0 commit comments