Skip to content

Commit a6e5d5d

Browse files
committed
feat: exposed the support for EXPLAIN ANALYZE
1 parent 77d6956 commit a6e5d5d

8 files changed

Lines changed: 217 additions & 45 deletions

File tree

cmd/baseline.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,24 @@ import (
1111
var (
1212
baselineCwd string
1313
baselineRunFilter string
14+
baselineAnalyze bool
1415

1516
// baselineCmd represents the baseline command
1617
baselineCmd = &cobra.Command{
1718
Use: "baseline [flags]",
1819
Short: "Creates baseline EXPLAIN analysis for queries",
1920
Long: `Creates baseline EXPLAIN analysis for all queries in the suite.
2021
This command executes EXPLAIN for each query and stores the query plan
21-
metrics (costs, timing, rows) in JSON files under the baselines directory.`,
22+
metrics (costs, timing, rows) in JSON files under the baselines directory.
23+
24+
Use --analyze to create baselines with EXPLAIN (ANALYZE, BUFFERS) which
25+
captures actual buffer I/O counts for deterministic regression detection.`,
2226
Run: func(cmd *cobra.Command, args []string) {
2327
if err := checkDirectory(baselineCwd); err != nil {
2428
fmt.Print(err.Error())
2529
os.Exit(1)
2630
}
27-
regresql.BaselineQueries(baselineCwd, baselineRunFilter)
31+
regresql.BaselineQueries(baselineCwd, baselineRunFilter, baselineAnalyze)
2832
},
2933
}
3034
)
@@ -34,4 +38,5 @@ func init() {
3438

3539
baselineCmd.Flags().StringVarP(&baselineCwd, "cwd", "C", ".", "Change to Directory")
3640
baselineCmd.Flags().StringVar(&baselineRunFilter, "run", "", "Run only queries matching regexp (matches file names and query names)")
41+
baselineCmd.Flags().BoolVar(&baselineAnalyze, "analyze", false, "Use EXPLAIN (ANALYZE, BUFFERS) for baselines")
3742
}

regresql/baseline.go

Lines changed: 66 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,29 @@ import (
1616
// for query costs compared to baseline (10% = queries can cost up to 110% of baseline)
1717
const DefaultCostThresholdPercent = 10.0
1818

19-
// Baseline stores the EXPLAIN analysis results for a query
20-
type Baseline struct {
21-
Query string `json:"query"`
22-
Timestamp string `json:"timestamp"`
23-
Plan map[string]any `json:"plan"`
24-
PlanSignature *PlanSignature `json:"plan_signature,omitempty"` // Optional for backwards compatibility
25-
}
19+
type (
20+
Baseline struct {
21+
Query string `json:"query"`
22+
Timestamp string `json:"timestamp"`
23+
Plan map[string]any `json:"plan"`
24+
PlanSignature *PlanSignature `json:"plan_signature,omitempty"`
25+
AnalyzeMode bool `json:"analyze_mode,omitempty"`
26+
Buffers *BufferBaseline `json:"buffers,omitempty"`
27+
Actuals *ActualBaseline `json:"actuals,omitempty"`
28+
}
29+
30+
BufferBaseline struct {
31+
SharedHitBlocks int64 `json:"shared_hit_blocks"`
32+
SharedReadBlocks int64 `json:"shared_read_blocks"`
33+
TotalBuffers int64 `json:"total_buffers"`
34+
}
35+
36+
ActualBaseline struct {
37+
ActualRows float64 `json:"actual_rows"`
38+
PlanRows float64 `json:"plan_rows"`
39+
ExecutionTimeMs float64 `json:"execution_time_ms"`
40+
}
41+
)
2642

2743
// GetBaselinePath returns the path where baseline JSON file should be stored
2844
func getBaselinePath(q *Query, baselineDir string, bindingName string) string {
@@ -91,7 +107,7 @@ func ExecuteExplainWithOptions(q Querier, query string, opts ExplainOptions, arg
91107
return &plans[0], nil
92108
}
93109

94-
func (q *Query) CreateBaseline(baselineDir string, planDir string, db *sql.DB) error {
110+
func (q *Query) CreateBaseline(baselineDir string, planDir string, db *sql.DB, useAnalyze bool) error {
95111
var plan *Plan
96112
var err error
97113

@@ -108,7 +124,7 @@ func (q *Query) CreateBaseline(baselineDir string, planDir string, db *sql.DB) e
108124
}
109125
}
110126

111-
baselines, fullPlans, err := plan.CreateBaselines(db)
127+
baselines, fullPlans, err := plan.CreateBaselines(db, useAnalyze)
112128
if err != nil {
113129
return err
114130
}
@@ -119,15 +135,15 @@ func (q *Query) CreateBaseline(baselineDir string, planDir string, db *sql.DB) e
119135
if i < len(fullPlans) {
120136
fullPlan = fullPlans[i]
121137
}
122-
if err := writeBaselineFile(baseline.Query, baselinePath, baseline.Plan, fullPlan); err != nil {
138+
if err := writeBaselineFile(baseline.Query, baselinePath, baseline.Plan, fullPlan, useAnalyze); err != nil {
123139
return err
124140
}
125141
}
126142

127143
return nil
128144
}
129145

130-
func writeBaselineFile(queryName, baselinePath string, filteredPlan map[string]any, fullExplainPlan *ExplainOutput) error {
146+
func writeBaselineFile(queryName, baselinePath string, filteredPlan map[string]any, fullExplainPlan *ExplainOutput, useAnalyze bool) error {
131147
var planSignature *PlanSignature
132148
if fullExplainPlan != nil {
133149
planSignature = ExtractPlanSignatureFromNode(&fullExplainPlan.Plan)
@@ -140,6 +156,20 @@ func writeBaselineFile(queryName, baselinePath string, filteredPlan map[string]a
140156
PlanSignature: planSignature,
141157
}
142158

159+
if useAnalyze && fullExplainPlan != nil {
160+
baseline.AnalyzeMode = true
161+
baseline.Buffers = &BufferBaseline{
162+
SharedHitBlocks: fullExplainPlan.Plan.SharedHitBlocks,
163+
SharedReadBlocks: fullExplainPlan.Plan.SharedReadBlocks,
164+
TotalBuffers: fullExplainPlan.Plan.SharedHitBlocks + fullExplainPlan.Plan.SharedReadBlocks,
165+
}
166+
baseline.Actuals = &ActualBaseline{
167+
ActualRows: fullExplainPlan.Plan.ActualRows,
168+
PlanRows: fullExplainPlan.Plan.PlanRows,
169+
ExecutionTimeMs: fullExplainPlan.ExecutionTime,
170+
}
171+
}
172+
143173
jsonBytes, err := json.MarshalIndent(baseline, "", " ")
144174
if err != nil {
145175
return fmt.Errorf("failed to marshal baseline to JSON: %w", err)
@@ -149,12 +179,15 @@ func writeBaselineFile(queryName, baselinePath string, filteredPlan map[string]a
149179
return fmt.Errorf("failed to write baseline JSON: %w", err)
150180
}
151181

152-
fmt.Printf(" Created baseline: %s\n", filepath.Base(baselinePath))
182+
mode := ""
183+
if useAnalyze {
184+
mode = " [analyze]"
185+
}
186+
fmt.Printf(" Created baseline: %s%s\n", filepath.Base(baselinePath), mode)
153187
return nil
154188
}
155189

156-
// BaselineQueries creates baselines for all queries in the suite
157-
func BaselineQueries(root string, runFilter string) {
190+
func BaselineQueries(root string, runFilter string, analyzeOverride bool) {
158191
config, err := ReadConfig(root)
159192
ignorePatterns := []string{}
160193
if err == nil {
@@ -168,6 +201,8 @@ func BaselineQueries(root string, runFilter string) {
168201
fmt.Printf("Error reading config: %s\n", err.Error())
169202
os.Exit(3)
170203
}
204+
SetGlobalConfig(config)
205+
useAnalyze := analyzeOverride || IsAnalyzeEnabled()
171206

172207
if err := TestConnectionString(config.PgUri); err != nil {
173208
fmt.Printf("Error connecting to database: %s\n", err.Error())
@@ -189,7 +224,11 @@ func BaselineQueries(root string, runFilter string) {
189224
os.Exit(11)
190225
}
191226

192-
fmt.Println("\nCreating baselines for queries:")
227+
mode := "cost-based"
228+
if useAnalyze {
229+
mode = "analyze (buffers)"
230+
}
231+
fmt.Printf("\nCreating baselines for queries (%s):\n", mode)
193232

194233
for _, folder := range suite.Dirs {
195234
folderBaselineDir := filepath.Join(baselineDir, folder.Dir)
@@ -227,7 +266,7 @@ func BaselineQueries(root string, runFilter string) {
227266
continue
228267
}
229268

230-
if err := q.CreateBaseline(folderBaselineDir, folderPlanDir, db); err != nil {
269+
if err := q.CreateBaseline(folderBaselineDir, folderPlanDir, db, useAnalyze); err != nil {
231270
fmt.Printf(" Error creating baseline for %s: %s\n", q.Name, err.Error())
232271
}
233272
}
@@ -265,3 +304,14 @@ func CompareCost(actualCost, baselineCost, thresholdPercent float64) (bool, floa
265304

266305
return isOk, percentageIncrease
267306
}
307+
308+
func CompareBuffers(actualBuffers, baselineBuffers int64, thresholdPercent float64) (bool, float64) {
309+
if baselineBuffers == 0 {
310+
return actualBuffers == 0, 0
311+
}
312+
313+
percentageIncrease := (float64(actualBuffers-baselineBuffers) / float64(baselineBuffers)) * 100
314+
isOk := percentageIncrease <= thresholdPercent
315+
316+
return isOk, percentageIncrease
317+
}

regresql/config.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ type (
1616
PlanQuality *PlanQualityGlobal `yaml:"plan_quality,omitempty"`
1717
DiffComparison *DiffComparisonGlobal `yaml:"diff_comparison,omitempty"`
1818
Snapshot *SnapshotConfig `yaml:"snapshot,omitempty"`
19+
Analyze *AnalyzeConfig `yaml:"analyze,omitempty"`
20+
}
21+
22+
AnalyzeConfig struct {
23+
Enabled bool `yaml:"enabled"`
24+
BufferThreshold float64 `yaml:"buffer_threshold,omitempty"` // default: 2.0
25+
CostThreshold float64 `yaml:"cost_threshold,omitempty"` // default: 10.0
1926
}
2027

2128
PlanQualityGlobal struct {
@@ -169,3 +176,38 @@ func GetDiffConfig() *DiffConfig {
169176
}
170177
return cfg
171178
}
179+
180+
func GetAnalyzeConfig() *AnalyzeConfig {
181+
if cachedConfig == nil || cachedConfig.Analyze == nil {
182+
return &AnalyzeConfig{
183+
Enabled: false,
184+
BufferThreshold: 2.0,
185+
CostThreshold: 10.0,
186+
}
187+
}
188+
cfg := cachedConfig.Analyze
189+
result := &AnalyzeConfig{
190+
Enabled: cfg.Enabled,
191+
BufferThreshold: cfg.BufferThreshold,
192+
CostThreshold: cfg.CostThreshold,
193+
}
194+
if result.BufferThreshold == 0 {
195+
result.BufferThreshold = 2.0
196+
}
197+
if result.CostThreshold == 0 {
198+
result.CostThreshold = 10.0
199+
}
200+
return result
201+
}
202+
203+
func IsAnalyzeEnabled() bool {
204+
return GetAnalyzeConfig().Enabled
205+
}
206+
207+
func GetBufferThreshold() float64 {
208+
return GetAnalyzeConfig().BufferThreshold
209+
}
210+
211+
func GetCostThreshold() float64 {
212+
return GetAnalyzeConfig().CostThreshold
213+
}

regresql/formatter_console.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,17 @@ func (f *ConsoleFormatter) AddResult(r TestResult, w io.Writer) error {
9292
}
9393

9494
func (f *ConsoleFormatter) printCostFailure(r TestResult, w io.Writer) {
95-
fmt.Fprintf(w, " Expected cost: %.2f\n", r.ExpectedCost)
96-
fmt.Fprintf(w, " Actual cost: %.2f (+%.1f%%)\n", r.ActualCost, r.PercentIncrease)
97-
fmt.Fprintln(w)
95+
if r.AnalyzeMode {
96+
fmt.Fprintf(w, " Expected buffers: %d\n", r.BaselineBuffers)
97+
fmt.Fprintf(w, " Actual buffers: %d (+%.1f%%)\n", r.ActualBuffers, r.BufferIncrease)
98+
fmt.Fprintln(w)
99+
fmt.Fprintf(w, " Cost (info): %.2f (baseline: %.2f)\n", r.ActualCost, r.ExpectedCost)
100+
fmt.Fprintln(w)
101+
} else {
102+
fmt.Fprintf(w, " Expected cost: %.2f\n", r.ExpectedCost)
103+
fmt.Fprintf(w, " Actual cost: %.2f (+%.1f%%)\n", r.ActualCost, r.PercentIncrease)
104+
fmt.Fprintln(w)
105+
}
98106

99107
if len(r.PlanRegressions) > 0 {
100108
f.printPlanRegressions(r.PlanRegressions, w)

regresql/formatters.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,15 @@ type (
2424
ActualCost float64
2525
PercentIncrease float64
2626
Threshold float64
27-
PlanChanged bool
28-
PlanRegressions []PlanRegression
29-
PlanWarnings []PlanWarning
27+
PlanChanged bool
28+
PlanRegressions []PlanRegression
29+
PlanWarnings []PlanWarning
30+
31+
// Buffer comparisons (when analyze_mode baseline)
32+
AnalyzeMode bool
33+
ActualBuffers int64
34+
BaselineBuffers int64
35+
BufferIncrease float64
3036

3137
// Diagnostics
3238
QueryFile string

regresql/library.go

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,12 @@ type (
3030
PlanRegressions []PlanRegression
3131
PlanWarnings []PlanWarning
3232

33-
// Metrics from EXPLAIN ANALYZE (nil if ANALYZE not used or on error)
34-
Metrics *PlanMetrics
33+
Metrics *PlanMetrics // from EXPLAIN ANALYZE
34+
35+
AnalyzeMode bool
36+
ActualBuffers int64
37+
BaselineBuffers int64
38+
BufferIncrease float64
3539
}
3640
)
3741

@@ -156,12 +160,12 @@ func (p *Plan) CompareCostsData(db *sql.DB, baselines []Baseline, thresholdPerce
156160
return results
157161
}
158162

159-
func (p *Plan) CreateBaselines(db *sql.DB) ([]Baseline, []*ExplainOutput, error) {
163+
func (p *Plan) CreateBaselines(db *sql.DB, useAnalyze bool) ([]Baseline, []*ExplainOutput, error) {
160164
baselines := make([]Baseline, len(p.Names))
161165
fullPlans := make([]*ExplainOutput, len(p.Names))
162166

163167
for i := range p.Names {
164-
baseline, fullPlan, err := p.createSingleBaseline(db, i)
168+
baseline, fullPlan, err := p.createSingleBaseline(db, i, useAnalyze)
165169
if err != nil {
166170
return nil, nil, err
167171
}
@@ -172,15 +176,21 @@ func (p *Plan) CreateBaselines(db *sql.DB) ([]Baseline, []*ExplainOutput, error)
172176
return baselines, fullPlans, nil
173177
}
174178

175-
func (p *Plan) createSingleBaseline(db *sql.DB, index int) (Baseline, *ExplainOutput, error) {
179+
func (p *Plan) createSingleBaseline(db *sql.DB, index int, useAnalyze bool) (Baseline, *ExplainOutput, error) {
176180
var explainPlan *ExplainOutput
177181
var err error
178182

183+
opts := DefaultExplainOptions()
184+
if useAnalyze {
185+
opts.Analyze = true
186+
opts.Buffers = true
187+
}
188+
179189
if len(p.Query.Args) == 0 {
180-
explainPlan, err = ExecuteExplain(db, p.Query.OrdinalQuery)
190+
explainPlan, err = ExecuteExplainWithOptions(db, p.Query.OrdinalQuery, opts)
181191
} else {
182192
sql, args := p.Query.Prepare(p.Bindings[index])
183-
explainPlan, err = ExecuteExplain(db, sql, args...)
193+
explainPlan, err = ExecuteExplainWithOptions(db, sql, opts, args...)
184194
}
185195
if err != nil {
186196
return Baseline{}, nil, fmt.Errorf("failed to create baseline for %s: %w", p.Names[index], err)

regresql/snapshot_build.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@ func BuildSnapshot(basePgUri string, root string, opts SnapshotBuildOptions) (*s
4545
}
4646
}
4747

48-
if len(opts.Fixtures) == 0 && opts.SchemaPath == "" {
49-
return nil, fmt.Errorf("no schema or fixtures specified for snapshot build")
48+
if len(opts.Fixtures) == 0 && opts.SchemaPath == "" && opts.MigrationsDir == "" && opts.MigrationCommand == "" {
49+
return nil, fmt.Errorf("no schema, migrations, or fixtures specified for snapshot build")
5050
}
5151

5252
if opts.Verbose {

0 commit comments

Comments
 (0)