@@ -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
913const (
@@ -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.
7199func (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+ }
0 commit comments