Skip to content

Commit 628620b

Browse files
StephenHinckzinic
authored andcommitted
Perform the REINDEX and cleanup failed previous attempts, add unit tests (AI commit)
1 parent 3b1668b commit 628620b

3 files changed

Lines changed: 225 additions & 6 deletions

File tree

README.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ The PostgreSQL driver's schema bootstrap (`drivers/pg/query/sql/schema_up.sql`)
6464
|---------------|----------|----------------------------------------------------------------------------------------------------------|
6565
| `pg_trgm` | yes | GIN trigram indexes for `contains` / `starts with` / `ends with` lookups on graph entity properties. |
6666
| `intarray` | yes | Extended integer array operations used when maintaining node kind arrays. |
67-
| `pgstattuple` | no | Measures btree leaf density and fragmentation for the driver-managed index optimization assessment. |
67+
| `pgstattuple` | no | Measures btree leaf density and fragmentation for the driver-managed index optimization. |
6868

6969
`pgstattuple` is treated as best-effort. Installing it typically requires a superuser role and on some managed
7070
Postgres deployments the contrib package is not exposed at all. Bootstrap wraps the install in an exception
@@ -75,3 +75,24 @@ optimization in such environments, have an administrator install the extension o
7575
```sql
7676
create extension if not exists pgstattuple;
7777
```
78+
79+
### Index Optimization
80+
81+
When `pgstattuple` is available the driver's `Optimize` method (exposed through the optional `graph.Optimizer`
82+
interface) performs the following on each invocation against btree indexes on partitions of the `node` and `edge`
83+
tables:
84+
85+
1. Drops any `INVALID` indexes whose name matches the `_ccnew[N]` pattern, which Postgres leaves behind when a
86+
prior `REINDEX CONCURRENTLY` was aborted. Cleanup failures are logged at `WARN` and never fatal; an orphan
87+
wastes disk but does not block productive rebuilds.
88+
2. Measures every candidate index with `pgstatindex` and flags those whose average leaf density falls below
89+
`60%` or whose leaf-page fragmentation reaches `40%`. Thresholds are calibrated against production samples
90+
and a freshly rebuilt baseline (~73.8% density).
91+
3. Rebuilds each flagged index with `REINDEX INDEX CONCURRENTLY`, smallest first so that an early cancellation
92+
still produces the maximum number of completed rebuilds. Per-index failures are logged at `WARN` and the
93+
loop continues with the next candidate. Context cancellation aborts before the next candidate; an in-flight
94+
`REINDEX CONCURRENTLY` runs to completion (interrupting it would leave a `_ccnew` artifact for the next pass
95+
to reap).
96+
97+
The pass is not bounded by wall-clock time or candidate count. Callers should serialize `Optimize` against
98+
their own scheduling loop.

drivers/pg/optimize.go

Lines changed: 138 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import (
44
"context"
55
"fmt"
66
"log/slog"
7+
"sort"
8+
"time"
9+
10+
"github.com/jackc/pgx/v5"
711
)
812

913
const (
@@ -43,6 +47,28 @@ const (
4347
order by c.relname, i.relname`
4448

4549
sqlSelectIndexBloatMetrics = `select avg_leaf_density, leaf_fragmentation from pgstatindex($1::regclass)`
50+
51+
// sqlSelectOrphanedReindexArtifacts identifies INVALID btree indexes left
52+
// behind by aborted REINDEX CONCURRENTLY runs on partitions of the node
53+
// and edge tables. Postgres names the in-progress copy <original>_ccnew or
54+
// <original>_ccnew<N>; on failure or cancellation those copies remain in
55+
// pg_index with indisvalid = false until explicitly dropped.
56+
sqlSelectOrphanedReindexArtifacts = `
57+
select n.nspname as schema_name,
58+
i.relname as index_name
59+
from pg_inherits inh
60+
join pg_class p on p.oid = inh.inhparent
61+
join pg_class c on c.oid = inh.inhrelid
62+
join pg_namespace n on n.oid = c.relnamespace
63+
join pg_index x on x.indrelid = c.oid
64+
join pg_class i on i.oid = x.indexrelid
65+
join pg_am a on a.oid = i.relam
66+
where p.relname in ('node', 'edge')
67+
and p.relnamespace = n.oid
68+
and a.amname = 'btree'
69+
and x.indisvalid = false
70+
and i.relname ~ '_ccnew[0-9]*$'
71+
order by n.nspname, i.relname`
4672
)
4773

4874
// indexRow is a candidate index discovered by the listing query, prior to
@@ -64,10 +90,12 @@ type indexCandidate struct {
6490
reason string
6591
}
6692

67-
// Optimize satisfies the graph.Optimizer interface. The current phase performs
68-
// a read-only assessment: it identifies btree indexes on partitions of the
69-
// node and edge tables whose leaf density or fragmentation cross the rebuild
70-
// thresholds and logs the candidates. No DDL is executed in this phase.
93+
// Optimize satisfies the graph.Optimizer interface. It performs a pre-flight
94+
// sweep of orphaned REINDEX CONCURRENTLY artifacts, identifies btree indexes
95+
// on partitions of the node and edge tables whose leaf density or
96+
// fragmentation cross the rebuild thresholds, and rebuilds each flagged index
97+
// with REINDEX INDEX CONCURRENTLY. Per-candidate failures are logged and do
98+
// not abort the pass; the loop honors ctx cancellation between candidates.
7199
func (s *Driver) Optimize(ctx context.Context) error {
72100
if installed, err := s.pgstattupleInstalled(ctx); err != nil {
73101
return fmt.Errorf("checking pgstattuple extension: %w", err)
@@ -76,6 +104,8 @@ func (s *Driver) Optimize(ctx context.Context) error {
76104
return nil
77105
}
78106

107+
s.cleanupOrphanedReindexArtifacts(ctx)
108+
79109
indexes, err := s.listGraphPartitionBtreeIndexes(ctx)
80110
if err != nil {
81111
return fmt.Errorf("listing graph partition btree indexes: %w", err)
@@ -111,6 +141,14 @@ func (s *Driver) Optimize(ctx context.Context) error {
111141
"Index optimization assessment complete: %d candidate(s) totaling %d bytes",
112142
len(candidates), totalBytes,
113143
))
144+
145+
// Process smallest candidates first so that a mid-pass ctx cancellation
146+
// still results in the maximum number of completed rebuilds.
147+
sort.SliceStable(candidates, func(i, j int) bool {
148+
return candidates[i].sizeBytes < candidates[j].sizeBytes
149+
})
150+
151+
s.reindexCandidates(ctx, candidates)
114152
return nil
115153
}
116154

@@ -160,3 +198,99 @@ func (s *Driver) measureIndexBloat(ctx context.Context, indexOID uint32) (float6
160198
}
161199
return density, fragmentation, nil
162200
}
201+
202+
// orphanedReindexArtifact is an INVALID btree index left behind by an aborted
203+
// REINDEX CONCURRENTLY run on a node or edge partition.
204+
type orphanedReindexArtifact struct {
205+
schema string
206+
name string
207+
}
208+
209+
// cleanupOrphanedReindexArtifacts scans for and drops INVALID _ccnew indexes
210+
// left behind by previously aborted REINDEX CONCURRENTLY runs. Failures are
211+
// logged at WARN and never fatal: an orphan wastes disk but does not block
212+
// productive rebuilds, so a stuck cleanup must not gate the rest of the pass.
213+
func (s *Driver) cleanupOrphanedReindexArtifacts(ctx context.Context) {
214+
orphans, err := s.listOrphanedReindexArtifacts(ctx)
215+
if err != nil {
216+
slog.WarnContext(ctx, fmt.Sprintf("Index optimization cleanup: failed to scan for orphaned reindex artifacts; continuing: %v", err))
217+
return
218+
}
219+
if len(orphans) == 0 {
220+
return
221+
}
222+
223+
slog.InfoContext(ctx, fmt.Sprintf("Index optimization cleanup: dropping %d orphaned reindex artifact(s)", len(orphans)))
224+
for _, o := range orphans {
225+
if _, err := s.pool.Exec(ctx, buildDropInvalidIndexSQL(o.schema, o.name)); err != nil {
226+
slog.WarnContext(ctx, fmt.Sprintf("Index optimization cleanup: failed to drop orphaned reindex artifact %s.%s; continuing: %v", o.schema, o.name, err))
227+
continue
228+
}
229+
slog.InfoContext(ctx, fmt.Sprintf("Index optimization cleanup: dropped orphaned reindex artifact %s.%s", o.schema, o.name))
230+
}
231+
}
232+
233+
func (s *Driver) listOrphanedReindexArtifacts(ctx context.Context) ([]orphanedReindexArtifact, error) {
234+
rows, err := s.pool.Query(ctx, sqlSelectOrphanedReindexArtifacts)
235+
if err != nil {
236+
return nil, err
237+
}
238+
defer rows.Close()
239+
240+
var out []orphanedReindexArtifact
241+
for rows.Next() {
242+
var o orphanedReindexArtifact
243+
if err := rows.Scan(&o.schema, &o.name); err != nil {
244+
return nil, err
245+
}
246+
out = append(out, o)
247+
}
248+
return out, rows.Err()
249+
}
250+
251+
// reindexCandidates rebuilds each flagged index with REINDEX INDEX
252+
// CONCURRENTLY, in the order supplied (Optimize sorts ascending by size).
253+
// Per-candidate failures are logged at WARN and the loop continues; ctx
254+
// cancellation aborts further candidates but in-flight REINDEX statements
255+
// must run to completion in Postgres to avoid leaving _ccnew artifacts.
256+
func (s *Driver) reindexCandidates(ctx context.Context, candidates []indexCandidate) {
257+
for _, c := range candidates {
258+
if err := ctx.Err(); err != nil {
259+
slog.WarnContext(ctx, fmt.Sprintf("Index optimization rebuild cancelled before processing %s.%s: %v", c.schema, c.index, err))
260+
return
261+
}
262+
263+
slog.InfoContext(ctx, fmt.Sprintf(
264+
"Index optimization rebuild starting: %s.%s on %s (size=%d bytes, leaf_density=%.1f%%, fragmentation=%.1f%%)",
265+
c.schema, c.index, c.table, c.sizeBytes, c.leafDensity, c.fragmentation,
266+
))
267+
268+
started := time.Now()
269+
if _, err := s.pool.Exec(ctx, buildReindexSQL(c.schema, c.index)); err != nil {
270+
slog.WarnContext(ctx, fmt.Sprintf(
271+
"Index optimization rebuild failed for %s.%s after %s; continuing with next candidate: %v",
272+
c.schema, c.index, time.Since(started), err,
273+
))
274+
continue
275+
}
276+
277+
slog.InfoContext(ctx, fmt.Sprintf(
278+
"Index optimization rebuild complete: %s.%s in %s",
279+
c.schema, c.index, time.Since(started),
280+
))
281+
}
282+
}
283+
284+
// buildReindexSQL composes a REINDEX INDEX CONCURRENTLY statement with a
285+
// safely quoted schema-qualified identifier. REINDEX does not accept query
286+
// parameters; quoting is the only defense against malformed identifiers.
287+
func buildReindexSQL(schema, name string) string {
288+
return "reindex index concurrently " + pgx.Identifier{schema, name}.Sanitize()
289+
}
290+
291+
// buildDropInvalidIndexSQL composes a DROP INDEX CONCURRENTLY IF EXISTS
292+
// statement for cleaning up an orphaned _ccnew artifact. IF EXISTS prevents
293+
// a race where another session has already dropped the same orphan.
294+
func buildDropInvalidIndexSQL(schema, name string) string {
295+
return "drop index concurrently if exists " + pgx.Identifier{schema, name}.Sanitize()
296+
}

drivers/pg/optimize_test.go

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ var _ graph.Optimizer = (*Driver)(nil)
1414
// TestNeedsReindex exercises the threshold logic that decides whether a
1515
// measured index is flagged as a rebuild candidate. The function is pure;
1616
// integration coverage of the surrounding pg_extension / pg_inherits /
17-
// pgstatindex queries lands with the Phase 5 REINDEX work.
17+
// pgstatindex queries and the REINDEX execution itself is exercised under
18+
// make test_integration against a real Postgres backend.
1819
func TestNeedsReindex(t *testing.T) {
1920
cases := []struct {
2021
name string
@@ -111,3 +112,66 @@ func TestThresholdsAreOrdered(t *testing.T) {
111112
assert.Greater(t, highIndexFragmentationThreshold, 0.0, "fragmentation threshold must be positive")
112113
assert.Less(t, highIndexFragmentationThreshold, 100.0, "fragmentation threshold must be a percentage")
113114
}
115+
116+
// TestBuildReindexSQL verifies the REINDEX statement is produced with both
117+
// schema and index name quoted via pgx.Identifier.Sanitize, including the
118+
// edge case where an identifier itself contains a double quote.
119+
func TestBuildReindexSQL(t *testing.T) {
120+
cases := []struct {
121+
name string
122+
schema string
123+
index string
124+
want string
125+
}{
126+
{
127+
name: "ordinary identifiers are double-quoted",
128+
schema: "graph",
129+
index: "edge_1_pkey",
130+
want: `reindex index concurrently "graph"."edge_1_pkey"`,
131+
},
132+
{
133+
name: "mixed-case identifiers preserve case under quoting",
134+
schema: "Graph",
135+
index: "Edge_1_PKey",
136+
want: `reindex index concurrently "Graph"."Edge_1_PKey"`,
137+
},
138+
{
139+
name: "embedded double quote is escaped by doubling",
140+
schema: `evil"schema`,
141+
index: "edge_1_pkey",
142+
want: `reindex index concurrently "evil""schema"."edge_1_pkey"`,
143+
},
144+
}
145+
for _, tc := range cases {
146+
t.Run(tc.name, func(t *testing.T) {
147+
assert.Equal(t, tc.want, buildReindexSQL(tc.schema, tc.index))
148+
})
149+
}
150+
}
151+
152+
// TestBuildDropInvalidIndexSQL verifies the orphan cleanup statement is
153+
// produced with IF EXISTS and CONCURRENTLY guarding identifier handling.
154+
func TestBuildDropInvalidIndexSQL(t *testing.T) {
155+
got := buildDropInvalidIndexSQL("graph", "edge_1_pkey_ccnew")
156+
assert.Equal(t, `drop index concurrently if exists "graph"."edge_1_pkey_ccnew"`, got)
157+
158+
// Identifier sanitization must apply to the orphan name as well, since
159+
// _ccnew suffixes are read directly from pg_class.relname.
160+
got = buildDropInvalidIndexSQL("graph", `weird"_ccnew`)
161+
assert.Equal(t, `drop index concurrently if exists "graph"."weird""_ccnew"`, got)
162+
}
163+
164+
// TestOrphanedReindexArtifactQuery_FiltersByValidityAndNameAndAm guards the
165+
// SQL string that scans for cleanup candidates against accidental loosening
166+
// of its filters, since this query controls the blast radius of DROP INDEX.
167+
func TestOrphanedReindexArtifactQuery_FiltersByValidityAndNameAndAm(t *testing.T) {
168+
for _, fragment := range []string{
169+
"x.indisvalid = false",
170+
"a.amname = 'btree'",
171+
"i.relname ~ '_ccnew[0-9]*$'",
172+
"p.relname in ('node', 'edge')",
173+
} {
174+
assert.Contains(t, sqlSelectOrphanedReindexArtifacts, fragment,
175+
"orphan-cleanup SQL is missing required filter %q; relaxing this filter risks dropping unrelated indexes", fragment)
176+
}
177+
}

0 commit comments

Comments
 (0)