Skip to content

Commit da4bfef

Browse files
StephenHinckzinic
authored andcommitted
Instantiate measurement and logging during optimization, but take no action. Update README.
1 parent 6e6e304 commit da4bfef

4 files changed

Lines changed: 290 additions & 13 deletions

File tree

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,23 @@ export CONNECTION_STRING="neo4j://neo4j:weneedbetterpasswords@localhost:7687"
5555
```
5656

5757
Use `make test` for unit tests only and `make test_integration` for integration tests only.
58+
59+
## PostgreSQL Extensions
60+
61+
The PostgreSQL driver's schema bootstrap (`drivers/pg/query/sql/schema_up.sql`) installs the following extensions:
62+
63+
| Extension | Required | Purpose |
64+
|---------------|----------|----------------------------------------------------------------------------------------------------------|
65+
| `pg_trgm` | yes | GIN trigram indexes for `contains` / `starts with` / `ends with` lookups on graph entity properties. |
66+
| `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. |
68+
69+
`pgstattuple` is treated as best-effort. Installing it typically requires a superuser role and on some managed
70+
Postgres deployments the contrib package is not exposed at all. Bootstrap wraps the install in an exception
71+
handler that downgrades any failure to a `WARNING`, and the driver's `Optimize` implementation re-checks for the
72+
extension at runtime and logs a warning when it is missing rather than failing the caller. To enable index
73+
optimization in such environments, have an administrator install the extension out-of-band:
74+
75+
```sql
76+
create extension if not exists pgstattuple;
77+
```

drivers/pg/optimize.go

Lines changed: 156 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,162 @@
11
package pg
22

3-
import "context"
3+
import (
4+
"context"
5+
"fmt"
6+
"log/slog"
7+
)
48

5-
// Optimize satisfies the graph.Optimizer interface. The body is currently a
6-
// no-op stub; the actual index maintenance logic (assessment via pgstattuple
7-
// followed by REINDEX CONCURRENTLY of bloated indexes) is implemented in a
8-
// later phase.
9+
const (
10+
// bloatedIndexLeafDensityThreshold is the average leaf-page fill percentage
11+
// below which a btree index is considered dense enough that REINDEX would
12+
// reclaim significant space. Calibrated against seven production tenant
13+
// samples whose live indexes regularly fell to 21-55%, against a freshly
14+
// rebuilt baseline (edge_1_pkey on a recently restored tenant) of 73.8%.
15+
bloatedIndexLeafDensityThreshold = 60.0
16+
17+
// highIndexFragmentationThreshold is the leaf-page fragmentation percentage
18+
// at or above which a btree index has accumulated enough out-of-order page
19+
// splits to warrant a rebuild even when leaf density alone has not crossed
20+
// its threshold.
21+
highIndexFragmentationThreshold = 40.0
22+
)
23+
24+
const (
25+
sqlPgstattupleInstalled = `select exists(select 1 from pg_extension where extname = 'pgstattuple')`
26+
27+
sqlSelectGraphPartitionBtreeIndexes = `
28+
select i.oid as index_oid,
29+
n.nspname as schema_name,
30+
c.relname as table_name,
31+
i.relname as index_name,
32+
pg_relation_size(i.oid) as index_size_bytes
33+
from pg_inherits inh
34+
join pg_class p on p.oid = inh.inhparent
35+
join pg_class c on c.oid = inh.inhrelid
36+
join pg_namespace n on n.oid = c.relnamespace
37+
join pg_index x on x.indrelid = c.oid
38+
join pg_class i on i.oid = x.indexrelid
39+
join pg_am a on a.oid = i.relam
40+
where p.relname in ('node', 'edge')
41+
and p.relnamespace = n.oid
42+
and a.amname = 'btree'
43+
order by c.relname, i.relname`
44+
45+
sqlSelectIndexBloatMetrics = `select avg_leaf_density, leaf_fragmentation from pgstatindex($1::regclass)`
46+
)
47+
48+
// indexRow is a candidate index discovered by the listing query, prior to
49+
// per-index pgstatindex assessment.
50+
type indexRow struct {
51+
oid uint32
52+
schema string
53+
table string
54+
index string
55+
sizeBytes int64
56+
}
57+
58+
// indexCandidate is an index whose pgstatindex measurement caused it to be
59+
// flagged for rebuild by needsReindex.
60+
type indexCandidate struct {
61+
indexRow
62+
leafDensity float64
63+
fragmentation float64
64+
reason string
65+
}
66+
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.
971
func (s *Driver) Optimize(ctx context.Context) error {
72+
if installed, err := s.pgstattupleInstalled(ctx); err != nil {
73+
return fmt.Errorf("checking pgstattuple extension: %w", err)
74+
} else if !installed {
75+
slog.WarnContext(ctx, "Index optimization skipped: pgstattuple extension is not installed; verify the DAWGS schema bootstrap completed successfully")
76+
return nil
77+
}
78+
79+
indexes, err := s.listGraphPartitionBtreeIndexes(ctx)
80+
if err != nil {
81+
return fmt.Errorf("listing graph partition btree indexes: %w", err)
82+
}
83+
84+
slog.InfoContext(ctx, fmt.Sprintf("Index optimization assessment starting: %d btree index(es) under consideration", len(indexes)))
85+
86+
var (
87+
candidates []indexCandidate
88+
totalBytes int64
89+
)
90+
for _, idx := range indexes {
91+
density, fragmentation, err := s.measureIndexBloat(ctx, idx.oid)
92+
if err != nil {
93+
slog.WarnContext(ctx, fmt.Sprintf("Skipping bloat assessment for index %s.%s: %v", idx.schema, idx.index, err))
94+
continue
95+
}
96+
97+
candidate := indexCandidate{indexRow: idx, leafDensity: density, fragmentation: fragmentation}
98+
if reason, flagged := needsReindex(candidate); flagged {
99+
candidate.reason = reason
100+
candidates = append(candidates, candidate)
101+
totalBytes += candidate.sizeBytes
102+
slog.InfoContext(ctx, fmt.Sprintf(
103+
"Index optimization candidate: %s.%s on %s (size=%d bytes, leaf_density=%.1f%%, fragmentation=%.1f%%, reason=%s)",
104+
candidate.schema, candidate.index, candidate.table,
105+
candidate.sizeBytes, candidate.leafDensity, candidate.fragmentation, candidate.reason,
106+
))
107+
}
108+
}
109+
110+
slog.InfoContext(ctx, fmt.Sprintf(
111+
"Index optimization assessment complete: %d candidate(s) totaling %d bytes",
112+
len(candidates), totalBytes,
113+
))
10114
return nil
11115
}
116+
117+
// needsReindex applies the rebuild thresholds to a measured index and returns
118+
// a short human-readable reason when the index is flagged. Pure function;
119+
// unit-tested in optimize_test.go.
120+
func needsReindex(c indexCandidate) (string, bool) {
121+
if c.leafDensity < bloatedIndexLeafDensityThreshold {
122+
return fmt.Sprintf("leaf density %.1f%% below %.1f%% threshold", c.leafDensity, bloatedIndexLeafDensityThreshold), true
123+
}
124+
if c.fragmentation >= highIndexFragmentationThreshold {
125+
return fmt.Sprintf("fragmentation %.1f%% at or above %.1f%% threshold", c.fragmentation, highIndexFragmentationThreshold), true
126+
}
127+
return "", false
128+
}
129+
130+
func (s *Driver) pgstattupleInstalled(ctx context.Context) (bool, error) {
131+
var installed bool
132+
if err := s.pool.QueryRow(ctx, sqlPgstattupleInstalled).Scan(&installed); err != nil {
133+
return false, err
134+
}
135+
return installed, nil
136+
}
137+
138+
func (s *Driver) listGraphPartitionBtreeIndexes(ctx context.Context) ([]indexRow, error) {
139+
rows, err := s.pool.Query(ctx, sqlSelectGraphPartitionBtreeIndexes)
140+
if err != nil {
141+
return nil, err
142+
}
143+
defer rows.Close()
144+
145+
var out []indexRow
146+
for rows.Next() {
147+
var r indexRow
148+
if err := rows.Scan(&r.oid, &r.schema, &r.table, &r.index, &r.sizeBytes); err != nil {
149+
return nil, err
150+
}
151+
out = append(out, r)
152+
}
153+
return out, rows.Err()
154+
}
155+
156+
func (s *Driver) measureIndexBloat(ctx context.Context, indexOID uint32) (float64, float64, error) {
157+
var density, fragmentation float64
158+
if err := s.pool.QueryRow(ctx, sqlSelectIndexBloatMetrics, indexOID).Scan(&density, &fragmentation); err != nil {
159+
return 0, 0, err
160+
}
161+
return density, fragmentation, nil
162+
}

drivers/pg/optimize_test.go

Lines changed: 101 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,113 @@
11
package pg
22

33
import (
4-
"context"
4+
"strings"
55
"testing"
66

77
"github.com/specterops/dawgs/graph"
8-
"github.com/stretchr/testify/require"
8+
"github.com/stretchr/testify/assert"
99
)
1010

1111
// Compile-time assertion that *Driver implements graph.Optimizer.
1212
var _ graph.Optimizer = (*Driver)(nil)
1313

14-
func TestDriver_Optimize_NoopReturnsNil(t *testing.T) {
15-
d := &Driver{}
16-
require.NoError(t, d.Optimize(context.Background()))
14+
// TestNeedsReindex exercises the threshold logic that decides whether a
15+
// measured index is flagged as a rebuild candidate. The function is pure;
16+
// integration coverage of the surrounding pg_extension / pg_inherits /
17+
// pgstatindex queries lands with the Phase 5 REINDEX work.
18+
func TestNeedsReindex(t *testing.T) {
19+
cases := []struct {
20+
name string
21+
leafDensity float64
22+
fragmentation float64
23+
wantFlagged bool
24+
wantReasonHas string
25+
}{
26+
{
27+
name: "healthy index is not flagged",
28+
leafDensity: 85.0,
29+
fragmentation: 5.0,
30+
wantFlagged: false,
31+
},
32+
{
33+
name: "freshly built baseline (73.8%/low frag) is not flagged",
34+
leafDensity: 73.8,
35+
fragmentation: 2.5,
36+
wantFlagged: false,
37+
},
38+
{
39+
name: "leaf density exactly at threshold is not flagged",
40+
leafDensity: bloatedIndexLeafDensityThreshold,
41+
fragmentation: 0,
42+
wantFlagged: false,
43+
},
44+
{
45+
name: "leaf density just below threshold is flagged",
46+
leafDensity: bloatedIndexLeafDensityThreshold - 0.01,
47+
fragmentation: 0,
48+
wantFlagged: true,
49+
wantReasonHas: "leaf density",
50+
},
51+
{
52+
name: "deeply bloated production sample (21%) is flagged on density",
53+
leafDensity: 21.0,
54+
fragmentation: 8.0,
55+
wantFlagged: true,
56+
wantReasonHas: "leaf density",
57+
},
58+
{
59+
name: "fragmentation just below threshold is not flagged when density is healthy",
60+
leafDensity: 85.0,
61+
fragmentation: highIndexFragmentationThreshold - 0.01,
62+
wantFlagged: false,
63+
},
64+
{
65+
name: "fragmentation exactly at threshold is flagged",
66+
leafDensity: 85.0,
67+
fragmentation: highIndexFragmentationThreshold,
68+
wantFlagged: true,
69+
wantReasonHas: "fragmentation",
70+
},
71+
{
72+
name: "fragmentation flag triggers when density alone would not",
73+
leafDensity: 65.0,
74+
fragmentation: 50.0,
75+
wantFlagged: true,
76+
wantReasonHas: "fragmentation",
77+
},
78+
{
79+
name: "density takes precedence in the reason when both cross",
80+
leafDensity: 30.0,
81+
fragmentation: 60.0,
82+
wantFlagged: true,
83+
wantReasonHas: "leaf density",
84+
},
85+
}
86+
87+
for _, tc := range cases {
88+
t.Run(tc.name, func(t *testing.T) {
89+
candidate := indexCandidate{
90+
leafDensity: tc.leafDensity,
91+
fragmentation: tc.fragmentation,
92+
}
93+
reason, flagged := needsReindex(candidate)
94+
assert.Equal(t, tc.wantFlagged, flagged, "unexpected flagged result for %s", tc.name)
95+
if tc.wantFlagged {
96+
assert.True(t, strings.Contains(reason, tc.wantReasonHas),
97+
"reason %q does not mention expected substring %q", reason, tc.wantReasonHas)
98+
} else {
99+
assert.Empty(t, reason, "expected empty reason when not flagged")
100+
}
101+
})
102+
}
103+
}
104+
105+
// TestThresholdsAreOrdered guards against accidental reordering of the
106+
// calibrated thresholds. If these inequalities ever fail the calibration
107+
// rationale documented on the constants must be revisited.
108+
func TestThresholdsAreOrdered(t *testing.T) {
109+
assert.Greater(t, bloatedIndexLeafDensityThreshold, 0.0, "leaf density threshold must be positive")
110+
assert.Less(t, bloatedIndexLeafDensityThreshold, 100.0, "leaf density threshold must be a percentage")
111+
assert.Greater(t, highIndexFragmentationThreshold, 0.0, "fragmentation threshold must be positive")
112+
assert.Less(t, highIndexFragmentationThreshold, 100.0, "fragmentation threshold must be a percentage")
17113
}

drivers/pg/query/sql/schema_up.sql

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,19 @@ create extension if not exists pg_trgm;
2222
create extension if not exists intarray;
2323

2424
-- We need the pgstattuple extension to measure btree leaf density and fragmentation on node and edge indexes
25-
-- during driver-managed index optimization. If this extension cannot be installed (e.g. a managed Postgres that
26-
-- does not expose it), the optimizer logs a warning and skips the assessment rather than failing.
27-
create extension if not exists pgstattuple;
25+
-- during driver-managed index optimization. Installing pgstattuple typically requires a superuser role, and on
26+
-- locked-down managed Postgres deployments (e.g. some RDS or Cloud SQL configurations) the contrib package may
27+
-- not be exposed at all. We wrap the install in a DO block that downgrades any failure to a WARNING so schema
28+
-- bootstrap continues to succeed; the optimizer performs its own runtime extension check and will skip the
29+
-- assessment with a logged warning when pgstattuple is unavailable.
30+
do $$
31+
begin
32+
create extension if not exists pgstattuple;
33+
exception
34+
when others then
35+
raise warning 'pgstattuple extension could not be installed (%); index optimization will be skipped at runtime', sqlerrm;
36+
end
37+
$$;
2838

2939
-- This is an optional but useful extension for validating performance of queries
3040
-- create extension if not exists pg_stat_statements;

0 commit comments

Comments
 (0)