Skip to content

Commit eb09786

Browse files
committed
feat(H001): add decision support for invalid index handling
Extend H001 check with decision logic for handling invalid indexes: - Add new fields to metrics SQL: has_valid_index_on_same_columns, backs_constraint, constraint_name, table_row_count_estimate - Generate recommendations based on flowchart decision tree: - DROP if valid index exists on same columns (duplicate) - RECREATE if index backs UNIQUE/PK constraint - DROP for small tables (<10K rows) - INVESTIGATE for large tables (needs query analysis RCA) - Always use CONCURRENTLY variants (DROP INDEX CONCURRENTLY, REINDEX INDEX CONCURRENTLY) - Update JSON schema, TypeScript/Python implementations, and tests
1 parent 6d64dfc commit eb09786

7 files changed

Lines changed: 541 additions & 20 deletions

File tree

cli/lib/checkup.ts

Lines changed: 107 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,18 @@ export interface ClusterMetric {
107107
description: string;
108108
}
109109

110+
/**
111+
* Recommendation for handling an invalid index (H001)
112+
*/
113+
export interface InvalidIndexRecommendation {
114+
/** Recommended action: drop (safe to remove), recreate (REINDEX CONCURRENTLY), or investigate (needs RCA) */
115+
action: "drop" | "recreate" | "investigate";
116+
/** Explanation of why this action is recommended */
117+
reason: string;
118+
/** SQL commands to execute (always use CONCURRENTLY variants) */
119+
sql_commands: string[];
120+
}
121+
110122
/**
111123
* Invalid index entry (H001) - matches H001.schema.json invalidIndex
112124
*/
@@ -120,6 +132,16 @@ export interface InvalidIndex {
120132
/** Full CREATE INDEX statement from pg_get_indexdef(), useful for DROP/CREATE migrations */
121133
index_definition: string;
122134
supports_fk: boolean;
135+
/** True if a valid index exists on the same columns (duplicate invalid index) */
136+
has_valid_index_on_same_columns: boolean;
137+
/** True if this index backs a UNIQUE or PRIMARY KEY constraint */
138+
backs_constraint: boolean;
139+
/** Name of the constraint this index backs, or null if none */
140+
constraint_name: string | null;
141+
/** Estimated row count for the table (from pg_class.reltuples) */
142+
table_row_count_estimate: number;
143+
/** Recommended action based on decision flowchart */
144+
recommendation: InvalidIndexRecommendation;
123145
}
124146

125147
/**
@@ -562,29 +584,111 @@ export async function getClusterInfo(client: Client, pgMajorVersion: number = 16
562584
return info;
563585
}
564586

587+
/** Threshold for "small table" decision (tables below this are safe to drop index and monitor) */
588+
const SMALL_TABLE_ROW_THRESHOLD = 10000;
589+
590+
/**
591+
* Generate a recommendation for an invalid index based on decision flowchart.
592+
*
593+
* Decision logic:
594+
* 1. If valid index exists on same columns → DROP (duplicate)
595+
* 2. Else if backs a constraint (UNIQUE/PK) → RECREATE (REINDEX CONCURRENTLY)
596+
* 3. Else if table < 10K rows → DROP and monitor
597+
* 4. Else (large table, no constraint) → INVESTIGATE (needs query analysis RCA)
598+
*/
599+
function generateInvalidIndexRecommendation(
600+
schemaName: string,
601+
indexName: string,
602+
hasValidIndexOnSameColumns: boolean,
603+
backsConstraint: boolean,
604+
tableRowCountEstimate: number
605+
): InvalidIndexRecommendation {
606+
const qualifiedIndexName = schemaName === "public"
607+
? `"${indexName}"`
608+
: `"${schemaName}"."${indexName}"`;
609+
610+
// Case 1: Valid index exists on same columns - this is a duplicate, safe to drop
611+
if (hasValidIndexOnSameColumns) {
612+
return {
613+
action: "drop",
614+
reason: "A valid index with the same columns already exists. This invalid index is a duplicate and can be safely dropped.",
615+
sql_commands: [`DROP INDEX CONCURRENTLY ${qualifiedIndexName};`],
616+
};
617+
}
618+
619+
// Case 2: Backs a constraint (UNIQUE or PK) - must recreate
620+
if (backsConstraint) {
621+
return {
622+
action: "recreate",
623+
reason: "This index backs a UNIQUE or PRIMARY KEY constraint. Recreate it using REINDEX CONCURRENTLY to restore constraint enforcement.",
624+
sql_commands: [`REINDEX INDEX CONCURRENTLY ${qualifiedIndexName};`],
625+
};
626+
}
627+
628+
// Case 3: Small table (< 10K rows) - safe to drop and monitor
629+
if (tableRowCountEstimate < SMALL_TABLE_ROW_THRESHOLD) {
630+
return {
631+
action: "drop",
632+
reason: `Table has ~${tableRowCountEstimate.toLocaleString()} rows (< 10K). Safe to drop and monitor query performance. Recreate if performance degrades.`,
633+
sql_commands: [`DROP INDEX CONCURRENTLY ${qualifiedIndexName};`],
634+
};
635+
}
636+
637+
// Case 4: Large table, no constraint - needs investigation
638+
// We cannot determine if queries filter on this column without query analysis
639+
return {
640+
action: "investigate",
641+
reason: `Table has ~${tableRowCountEstimate.toLocaleString()} rows. Investigate whether queries filter on the indexed column(s). If yes, recreate with REINDEX CONCURRENTLY. If no, safe to drop and monitor.`,
642+
sql_commands: [
643+
`-- Option A: If queries use this index, recreate it:`,
644+
`REINDEX INDEX CONCURRENTLY ${qualifiedIndexName};`,
645+
`-- Option B: If queries don't use this index, drop it:`,
646+
`DROP INDEX CONCURRENTLY ${qualifiedIndexName};`,
647+
],
648+
};
649+
}
650+
565651
/**
566652
* Get invalid indexes from the database (H001).
567653
* Invalid indexes are indexes that failed to build (e.g., due to CONCURRENTLY failure).
568654
*
569655
* @param client - Connected PostgreSQL client
570656
* @param pgMajorVersion - PostgreSQL major version (default: 16)
571-
* @returns Array of invalid index entries with size and FK support info
657+
* @returns Array of invalid index entries with size, FK support info, and recommendations
572658
*/
573659
export async function getInvalidIndexes(client: Client, pgMajorVersion: number = 16): Promise<InvalidIndex[]> {
574660
const sql = getMetricSql(METRIC_NAMES.H001, pgMajorVersion);
575661
const result = await client.query(sql);
576662
return result.rows.map((row) => {
577663
const transformed = transformMetricRow(row);
578664
const indexSizeBytes = parseInt(String(transformed.index_size_bytes || 0), 10);
665+
const schemaName = String(transformed.schema_name || "");
666+
const indexName = String(transformed.index_name || "");
667+
const hasValidIndexOnSameColumns = toBool(transformed.has_valid_index_on_same_columns);
668+
const backsConstraint = toBool(transformed.backs_constraint);
669+
const constraintName = transformed.constraint_name ? String(transformed.constraint_name) : null;
670+
const tableRowCountEstimate = parseInt(String(transformed.table_row_count_estimate || 0), 10);
671+
579672
return {
580-
schema_name: String(transformed.schema_name || ""),
673+
schema_name: schemaName,
581674
table_name: String(transformed.table_name || ""),
582-
index_name: String(transformed.index_name || ""),
675+
index_name: indexName,
583676
relation_name: String(transformed.relation_name || ""),
584677
index_size_bytes: indexSizeBytes,
585678
index_size_pretty: formatBytes(indexSizeBytes),
586679
index_definition: String(transformed.index_definition || ""),
587680
supports_fk: toBool(transformed.supports_fk),
681+
has_valid_index_on_same_columns: hasValidIndexOnSameColumns,
682+
backs_constraint: backsConstraint,
683+
constraint_name: constraintName,
684+
table_row_count_estimate: tableRowCountEstimate,
685+
recommendation: generateInvalidIndexRecommendation(
686+
schemaName,
687+
indexName,
688+
hasValidIndexOnSameColumns,
689+
backsConstraint,
690+
tableRowCountEstimate
691+
),
588692
};
589693
});
590694
}

cli/test/checkup.test.ts

Lines changed: 124 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -477,10 +477,22 @@ describe("A004 - Cluster information", () => {
477477

478478
// Tests for H001 (Invalid indexes)
479479
describe("H001 - Invalid indexes", () => {
480-
test("getInvalidIndexes returns invalid indexes", async () => {
480+
test("getInvalidIndexes returns invalid indexes with decision fields", async () => {
481481
const mockClient = createMockClient({
482482
invalidIndexesRows: [
483-
{ schema_name: "public", table_name: "users", index_name: "users_email_idx", relation_name: "users", index_size_bytes: "1048576", index_definition: "CREATE INDEX users_email_idx ON public.users USING btree (email)", supports_fk: false },
483+
{
484+
schema_name: "public",
485+
table_name: "users",
486+
index_name: "users_email_idx",
487+
relation_name: "users",
488+
index_size_bytes: "1048576",
489+
index_definition: "CREATE INDEX users_email_idx ON public.users USING btree (email)",
490+
supports_fk: false,
491+
has_valid_index_on_same_columns: 0,
492+
backs_constraint: 0,
493+
constraint_name: null,
494+
table_row_count_estimate: 5000,
495+
},
484496
],
485497
});
486498

@@ -494,19 +506,117 @@ describe("H001 - Invalid indexes", () => {
494506
expect(indexes[0].index_definition).toMatch(/^CREATE INDEX/);
495507
expect(indexes[0].relation_name).toBe("users");
496508
expect(indexes[0].supports_fk).toBe(false);
509+
// Verify decision support fields
510+
expect(indexes[0].has_valid_index_on_same_columns).toBe(false);
511+
expect(indexes[0].backs_constraint).toBe(false);
512+
expect(indexes[0].constraint_name).toBe(null);
513+
expect(indexes[0].table_row_count_estimate).toBe(5000);
514+
// Verify recommendation (small table < 10K rows)
515+
expect(indexes[0].recommendation.action).toBe("drop");
516+
expect(indexes[0].recommendation.sql_commands[0]).toContain("DROP INDEX CONCURRENTLY");
517+
});
518+
519+
test("getInvalidIndexes recommends drop for duplicate index", async () => {
520+
const mockClient = createMockClient({
521+
invalidIndexesRows: [
522+
{
523+
schema_name: "public",
524+
table_name: "users",
525+
index_name: "users_dup_idx",
526+
relation_name: "users",
527+
index_size_bytes: "1048576",
528+
index_definition: "CREATE INDEX users_dup_idx ON public.users USING btree (email)",
529+
supports_fk: false,
530+
has_valid_index_on_same_columns: 1, // Has a valid duplicate
531+
backs_constraint: 0,
532+
constraint_name: null,
533+
table_row_count_estimate: 100000,
534+
},
535+
],
536+
});
537+
538+
const indexes = await checkup.getInvalidIndexes(mockClient as any);
539+
expect(indexes[0].has_valid_index_on_same_columns).toBe(true);
540+
expect(indexes[0].recommendation.action).toBe("drop");
541+
expect(indexes[0].recommendation.reason).toContain("duplicate");
542+
});
543+
544+
test("getInvalidIndexes recommends recreate for constraint-backed index", async () => {
545+
const mockClient = createMockClient({
546+
invalidIndexesRows: [
547+
{
548+
schema_name: "public",
549+
table_name: "users",
550+
index_name: "users_pkey",
551+
relation_name: "users",
552+
index_size_bytes: "1048576",
553+
index_definition: "CREATE UNIQUE INDEX users_pkey ON public.users USING btree (id)",
554+
supports_fk: true,
555+
has_valid_index_on_same_columns: 0,
556+
backs_constraint: 1, // Backs a constraint
557+
constraint_name: "users_pkey",
558+
table_row_count_estimate: 100000,
559+
},
560+
],
561+
});
562+
563+
const indexes = await checkup.getInvalidIndexes(mockClient as any);
564+
expect(indexes[0].backs_constraint).toBe(true);
565+
expect(indexes[0].constraint_name).toBe("users_pkey");
566+
expect(indexes[0].recommendation.action).toBe("recreate");
567+
expect(indexes[0].recommendation.sql_commands[0]).toContain("REINDEX INDEX CONCURRENTLY");
568+
});
569+
570+
test("getInvalidIndexes recommends investigate for large table without constraint", async () => {
571+
const mockClient = createMockClient({
572+
invalidIndexesRows: [
573+
{
574+
schema_name: "public",
575+
table_name: "orders",
576+
index_name: "orders_status_idx",
577+
relation_name: "orders",
578+
index_size_bytes: "2097152",
579+
index_definition: "CREATE INDEX orders_status_idx ON public.orders USING btree (status)",
580+
supports_fk: false,
581+
has_valid_index_on_same_columns: 0,
582+
backs_constraint: 0,
583+
constraint_name: null,
584+
table_row_count_estimate: 50000, // Large table (>= 10K)
585+
},
586+
],
587+
});
588+
589+
const indexes = await checkup.getInvalidIndexes(mockClient as any);
590+
expect(indexes[0].table_row_count_estimate).toBe(50000);
591+
expect(indexes[0].recommendation.action).toBe("investigate");
592+
expect(indexes[0].recommendation.reason).toContain("50,000 rows");
593+
// Should have both REINDEX and DROP options
594+
expect(indexes[0].recommendation.sql_commands.some(cmd => cmd.includes("REINDEX"))).toBe(true);
595+
expect(indexes[0].recommendation.sql_commands.some(cmd => cmd.includes("DROP"))).toBe(true);
497596
});
498597

499-
test("generateH001 creates report with invalid indexes", async () => {
598+
test("generateH001 creates report with invalid indexes and recommendations", async () => {
500599
const mockClient = createMockClient({
501600
versionRows: [
502601
{ name: "server_version", setting: "16.3" },
503602
{ name: "server_version_num", setting: "160003" },
504603
],
505-
invalidIndexesRows: [
506-
{ schema_name: "public", table_name: "orders", index_name: "orders_status_idx", relation_name: "orders", index_size_bytes: "2097152", index_definition: "CREATE INDEX orders_status_idx ON public.orders USING btree (status)", supports_fk: false },
507-
],
508-
}
509-
);
604+
invalidIndexesRows: [
605+
{
606+
schema_name: "public",
607+
table_name: "orders",
608+
index_name: "orders_status_idx",
609+
relation_name: "orders",
610+
index_size_bytes: "2097152",
611+
index_definition: "CREATE INDEX orders_status_idx ON public.orders USING btree (status)",
612+
supports_fk: false,
613+
has_valid_index_on_same_columns: 0,
614+
backs_constraint: 0,
615+
constraint_name: null,
616+
table_row_count_estimate: 5000, // Small table
617+
},
618+
],
619+
});
510620

511621
const report = await checkup.generateH001(mockClient as any, "test-node");
512622
expect(report.checkId).toBe("H001");
@@ -524,6 +634,12 @@ describe("H001 - Invalid indexes", () => {
524634
expect(dbData.database_size_bytes).toBeTruthy();
525635
expect(dbData.database_size_pretty).toBeTruthy();
526636
expect(report.results["test-node"].postgres_version).toBeTruthy();
637+
638+
// Verify recommendation in report
639+
const invalidIndex = dbData.invalid_indexes[0];
640+
expect(invalidIndex.recommendation).toBeTruthy();
641+
expect(invalidIndex.recommendation.action).toBe("drop");
642+
expect(invalidIndex.recommendation.sql_commands).toBeTruthy();
527643
});
528644
// Top-level structure tests removed - covered by schema-validation.test.ts
529645
});

cli/test/schema-validation.test.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,19 @@ const indexTestData = {
3030
emptyRows: { invalidIndexesRows: [] },
3131
dataRows: {
3232
invalidIndexesRows: [
33-
{ schema_name: "public", table_name: "users", index_name: "users_email_idx", relation_name: "users", index_size_bytes: "1048576", index_definition: "CREATE INDEX users_email_idx ON public.users USING btree (email)", supports_fk: false },
33+
{
34+
schema_name: "public",
35+
table_name: "users",
36+
index_name: "users_email_idx",
37+
relation_name: "users",
38+
index_size_bytes: "1048576",
39+
index_definition: "CREATE INDEX users_email_idx ON public.users USING btree (email)",
40+
supports_fk: false,
41+
has_valid_index_on_same_columns: 0,
42+
backs_constraint: 0,
43+
constraint_name: null,
44+
table_row_count_estimate: 5000,
45+
},
3446
],
3547
},
3648
},

config/pgwatch-prometheus/metrics.yml

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1575,6 +1575,8 @@ metrics:
15751575
This metric identifies invalid indexes in the database.
15761576
It provides insights into the number of invalid indexes and their details.
15771577
This metric helps administrators identify and fix invalid indexes to improve database performance.
1578+
Includes decision support data: whether a valid index exists on same columns, whether the index
1579+
backs a constraint (UNIQUE/PK), and table row count estimate for drop/recreate recommendations.
15781580
sqls:
15791581
11: |
15801582
with fk_indexes as ( /* pgwatch_generated */
@@ -1612,7 +1614,34 @@ metrics:
16121614
where
16131615
fi.tag_fk_table_ref = pct.relname
16141616
and fi.tag_opclasses like (array_to_string(pidx.indclass, ', ') || '%')
1615-
) > 0)::int as supports_fk
1617+
) > 0)::int as supports_fk,
1618+
-- Decision support: check if valid index exists on same columns (duplicate)
1619+
(exists (
1620+
select 1
1621+
from pg_index valid_idx
1622+
where valid_idx.indrelid = pidx.indrelid
1623+
and valid_idx.indisvalid = true
1624+
and valid_idx.indkey = pidx.indkey
1625+
and valid_idx.indclass = pidx.indclass
1626+
and valid_idx.indexrelid != pidx.indexrelid
1627+
))::int as has_valid_index_on_same_columns,
1628+
-- Decision support: check if index backs a constraint (UNIQUE or PK)
1629+
(exists (
1630+
select 1
1631+
from pg_constraint con
1632+
where con.conindid = pidx.indexrelid
1633+
and con.contype in ('p', 'u') -- primary key or unique
1634+
))::int as backs_constraint,
1635+
-- Get the constraint name if it backs one
1636+
(
1637+
select con.conname
1638+
from pg_constraint con
1639+
where con.conindid = pidx.indexrelid
1640+
and con.contype in ('p', 'u')
1641+
limit 1
1642+
) as constraint_name,
1643+
-- Decision support: estimated row count for the table
1644+
pct.reltuples::bigint as table_row_count_estimate
16161645
from pg_index pidx
16171646
join pg_class as pci on pci.oid = pidx.indexrelid
16181647
join pg_class as pct on pct.oid = pidx.indrelid

0 commit comments

Comments
 (0)