-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathpgexplain.go
More file actions
1228 lines (1027 loc) · 42.6 KB
/
Copy pathpgexplain.go
File metadata and controls
1228 lines (1027 loc) · 42.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/*
2019 © Postgres.ai
Based on the code from Simon Engledew @ https://github.com/simon-engledew/gocmdpev
*/
// Package pgexplain provides tools for Postgres explain processing.
package pgexplain
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"regexp"
"strconv"
"strings"
"gitlab.com/postgres-ai/joe/pkg/util"
)
type EstimateDirection string
const (
Over EstimateDirection = "Over"
Under = "Under"
)
// The EXPLAIN form joe issues and the JSON fields parsed below are one contract;
// callers (pkg/bot/command) choose which version-gated options to include.
const (
// ExplainAnalyzeQuery is the EXPLAIN form joe issues; %s carries the
// version-gated options (SETTINGS, WAL).
ExplainAnalyzeQuery = "EXPLAIN (ANALYZE, COSTS, VERBOSE, BUFFERS, FORMAT JSON %s) "
// ExplainSettingsOption enables SETTINGS output (PostgreSQL 12+).
ExplainSettingsOption = ", SETTINGS TRUE"
// ExplainWALOption enables WAL output (PostgreSQL 13+).
ExplainWALOption = ", WAL"
)
type NodeType string
// Node types as they appear in EXPLAIN's "Node Type" field; values must match
// PostgreSQL verbatim (e.g. "ModifyTable" has no space, "Seq Scan" does).
// Types without a special case fall through to the generic renderer, which
// prints the raw node-type string.
const (
Limit NodeType = "Limit"
Append NodeType = "Append"
MergeAppend NodeType = "Merge Append"
RecursiveUnion NodeType = "Recursive Union"
Sort NodeType = "Sort"
IncrementalSort NodeType = "Incremental Sort"
NestedLoop NodeType = "Nested Loop"
MergeJoin NodeType = "Merge Join"
Hash NodeType = "Hash"
HashJoin NodeType = "Hash Join"
Aggregate NodeType = "Aggregate"
GroupAggregate NodeType = "Group"
WindowAgg NodeType = "WindowAgg"
Unique NodeType = "Unique"
SetOp NodeType = "SetOp"
Gather NodeType = "Gather"
GatherMerge NodeType = "Gather Merge"
Memoize NodeType = "Memoize"
Materialize NodeType = "Materialize"
Result NodeType = "Result"
ProjectSet NodeType = "ProjectSet"
LockRows NodeType = "LockRows"
BitmapAnd NodeType = "BitmapAnd"
BitmapOr NodeType = "BitmapOr"
SequenceScan NodeType = "Seq Scan"
SampleScan NodeType = "Sample Scan"
IndexScan NodeType = "Index Scan"
IndexOnlyScan NodeType = "Index Only Scan"
BitmapHeapScan NodeType = "Bitmap Heap Scan"
BitmapIndexScan NodeType = "Bitmap Index Scan"
TidScan NodeType = "Tid Scan"
TidRangeScan NodeType = "Tid Range Scan"
CTEScan NodeType = "CTE Scan"
NamedTuplestore NodeType = "Named Tuplestore Scan"
WorkTableScan NodeType = "WorkTable Scan"
FunctionScan NodeType = "Function Scan"
TableFuncScan NodeType = "Table Function Scan"
SubqueryScan NodeType = "Subquery Scan"
ValuesScan NodeType = "Values Scan"
ForeignScan NodeType = "Foreign Scan"
CustomScan NodeType = "Custom Scan"
ModifyTable NodeType = "ModifyTable"
)
type Explain struct {
Plan Plan `json:"Plan"`
Triggers []Trigger `json:"Triggers"`
QueryIdentifier int64 `json:"Query Identifier"` // PostgreSQL queryid is a signed 64-bit value and is frequently negative.
Settings map[string]string `json:"Settings"`
PlanningTime float64 `json:"Planning Time"`
ExecutionTime float64 `json:"Execution Time"`
TotalTime float64
TotalCost float64
// Buffers.
SharedHitBlocks uint64
SharedDirtiedBlocks uint64
SharedReadBlocks uint64
SharedWrittenBlocks uint64
LocalHitBlocks uint64
LocalReadBlocks uint64
LocalDirtiedBlocks uint64
LocalWrittenBlocks uint64
TempReadBlocks uint64
TempWrittenBlocks uint64
// IO timing.
IOReadTime *float64
IOWriteTime *float64
ActualRows float64
MaxRows float64
MaxCost float64
MaxDuration float64
ContainsSeqScan bool
}
// Trigger describes triggers in the explain output.
type Trigger struct {
Name string `json:"Trigger Name"`
ConstraintName string `json:"Constraint Name"`
Relation string `json:"Relation"`
Time float64 `json:"Time"`
Calls uint64 `json:"Calls"`
}
type Plan struct {
Plans []Plan `json:"Plans"`
// Buffers.
SharedHitBlocks uint64 `json:"Shared Hit Blocks"`
SharedReadBlocks uint64 `json:"Shared Read Blocks"`
SharedDirtiedBlocks uint64 `json:"Shared Dirtied Blocks"`
SharedWrittenBlocks uint64 `json:"Shared Written Blocks"`
LocalHitBlocks uint64 `json:"Local Hit Blocks"`
LocalReadBlocks uint64 `json:"Local Read Blocks"`
LocalDirtiedBlocks uint64 `json:"Local Dirtied Blocks"`
LocalWrittenBlocks uint64 `json:"Local Written Blocks"`
TempReadBlocks uint64 `json:"Temp Read Blocks"`
TempWrittenBlocks uint64 `json:"Temp Written Blocks"`
// IO timing. PostgreSQL 17+ replaces the aggregate fields with per-buffer-type splits;
// normalizeIOTiming folds the splits back into IOReadTime/IOWriteTime when those are nil.
IOReadTime *float64 `json:"I/O Read Time,omitempty"` // ms
IOWriteTime *float64 `json:"I/O Write Time,omitempty"` // ms
SharedIOReadTime *float64 `json:"Shared I/O Read Time,omitempty"` // ms
SharedIOWriteTime *float64 `json:"Shared I/O Write Time,omitempty"` // ms
LocalIOReadTime *float64 `json:"Local I/O Read Time,omitempty"` // ms
LocalIOWriteTime *float64 `json:"Local I/O Write Time,omitempty"` // ms
TempIOReadTime *float64 `json:"Temp I/O Read Time,omitempty"` // ms
TempIOWriteTime *float64 `json:"Temp I/O Write Time,omitempty"` // ms
// Actual.
ActualLoops uint64 `json:"Actual Loops"`
ActualRows float64 `json:"Actual Rows"` // PostgreSQL 18+ reports this as a fraction (rows averaged over loops).
ActualStartupTime float64 `json:"Actual Startup Time"`
ActualTotalTime float64 `json:"Actual Total Time"`
// Estimates.
PlanRows uint64 `json:"Plan Rows"`
PlanWidth uint64 `json:"Plan Width"`
StartupCost float64 `json:"Startup Cost"`
TotalCost float64 `json:"Total Cost"`
// WAL.
WALRecords uint64 `json:"WAL Records,omitempty"`
WALFPI uint64 `json:"WAL FPI,omitempty"`
WALBytes uint64 `json:"WAL Bytes,omitempty"`
WALBuffersFull uint64 `json:"WAL Buffers Full,omitempty"` // PostgreSQL 18+
// PostgreSQL 18+ per-node fields, absent on older servers; renderers emit them
// only when present (true / non-zero / non-empty), so pre-18 output is unchanged.
Disabled bool `json:"Disabled,omitempty"` // cost-based node disablement (was disable_cost)
IndexSearches uint64 `json:"Index Searches,omitempty"` // Index/Index-Only/Bitmap-Index Scan
Storage string `json:"Storage,omitempty"` // Material/WindowAgg/CTE, e.g. "Memory"
MaximumStorage uint64 `json:"Maximum Storage,omitempty"` // kB
// General.
Alias string `json:"Alias"`
CteName string `json:"CTE Name"`
Filter string `json:"Filter"`
OneTimeFilter string `json:"One-Time Filter"` // Result node's gating qual (constant or InitPlan expression)
FunctionName string `json:"Function Name"`
FunctionCall string `json:"Function Call"` // Function Scan; comma-joined for a multi-function ROWS FROM
GroupKey []string `json:"Group Key"`
HashBatches uint64 `json:"Hash Batches"`
HashBuckets uint64 `json:"Hash Buckets"`
HashCondition string `json:"Hash Cond"`
HeapFetches uint64 `json:"Heap Fetches"`
IndexCondition string `json:"Index Cond"`
IndexName string `json:"Index Name"`
MergeCondition string `json:"Merge Cond"`
TidCondition string `json:"TID Cond"` // Tid Scan / Tid Range Scan ctid qual
JoinType string `json:"Join Type"`
NodeType NodeType `json:"Node Type"`
Operation string `json:"Operation"`
OriginalHashBatches uint64 `json:"Original Hash Batches"`
OriginalHashBuckets uint64 `json:"Original Hash Buckets"`
Output []string `json:"Output"`
ParallelAware bool `json:"Parallel Aware"`
ParentRelationship string `json:"Parent Relationship"`
PeakMemoryUsage uint64 `json:"Peak Memory Usage"` // kB
RelationName string `json:"Relation Name"`
RowsRemovedByFilter uint64 `json:"Rows Removed by Filter"`
RowsRemovedByIndexRecheck uint64 `json:"Rows Removed by Index Recheck"`
ScanDirection string `json:"Scan Direction"`
Schema string `json:"Schema"`
SortKey []string `json:"Sort Key"`
SortMethod string `json:"Sort Method"`
SortSpaceType string `json:"Sort Space Type"`
SortSpaceUsed uint64 `json:"Sort Space Used"` // kB
Strategy string `json:"Strategy"` // Aggregate (Plain/Sorted/Hashed/Mixed) or SetOp (Sorted/Hashed) strategy.
Command string `json:"Command"` // SetOp command: Except/Except All/Intersect/Intersect All
PartialMode string `json:"Partial Mode"` // PG "Partial Mode": Simple, or Partial/Finalize (parallel agg phases)
SubplanName string `json:"Subplan Name"`
WorkersLaunched uint `json:"Workers Launched"`
WorkersPlanned uint `json:"Workers Planned"`
// Diagnostic fields for specific node types, each rendered only when present
// (non-empty / non-nil / >0) so unrelated plans stay byte-for-byte unchanged.
// Memoize cache statistics; "Memory Usage" reuses PeakMemoryUsage above.
CacheKey string `json:"Cache Key"`
CacheMode string `json:"Cache Mode"` // "logical" or "binary"
CacheHits uint64 `json:"Cache Hits"`
CacheMisses uint64 `json:"Cache Misses"`
CacheEvictions uint64 `json:"Cache Evictions"`
CacheOverflows uint64 `json:"Cache Overflows"`
// Join-node qual evaluated at the join (not pushed down to a child).
JoinFilter string `json:"Join Filter"`
RowsRemovedByJoinFilter uint64 `json:"Rows Removed by Join Filter"`
// Incremental Sort. PresortedKey (always present) is the already-ordered prefix;
// the *Groups stats appear only under ANALYZE, hence pointers (nil = absent).
PresortedKey []string `json:"Presorted Key"`
FullSortGroups *SortGroups `json:"Full-sort Groups"`
PresortedGroups *SortGroups `json:"Pre-sorted Groups"`
// Bitmap Heap Scan: recheck qual for lossy pages, plus exact/lossy page counts.
RecheckCond string `json:"Recheck Cond"`
ExactHeapBlocks uint64 `json:"Exact Heap Blocks"`
LossyHeapBlocks uint64 `json:"Lossy Heap Blocks"`
// Hashed/Mixed Aggregate stats. HashAggBatches (and "Memory Usage", which reuses
// PeakMemoryUsage) render for any hashed Aggregate; PlannedPartitions/DiskUsage
// appear only after a spill.
PlannedPartitions uint64 `json:"Planned Partitions"`
HashAggBatches uint64 `json:"HashAgg Batches"`
DiskUsage uint64 `json:"Disk Usage"` // kB
// WindowAgg run condition: a scalar string (NOT an array — typing it []string
// would fail the whole decode).
RunCondition string `json:"Run Condition"`
// Calculated params.
ActualCost float64
ActualDuration float64
Costliest bool
Largest bool
PlannerRowEstimateDirection EstimateDirection
PlannerRowEstimateFactor float64
Slowest bool
}
// SortGroups holds the per-group statistics PostgreSQL reports under EXPLAIN
// ANALYZE for one category of Incremental Sort groups ("Full-sort" or
// "Pre-sorted"); a Plan carries one pointer per category.
type SortGroups struct {
GroupCount uint64 `json:"Group Count"`
SortMethodsUsed []string `json:"Sort Methods Used"` // e.g. quicksort, top-N heapsort, external merge
// At most one is set: a group sorts either in memory or (on spill) to disk.
SortSpaceMemory *SortSpaceUsage `json:"Sort Space Memory"`
SortSpaceDisk *SortSpaceUsage `json:"Sort Space Disk"`
}
// SortSpaceUsage is the average/peak sort space (kB) for a group of sorts.
type SortSpaceUsage struct {
AverageSortSpaceUsed uint64 `json:"Average Sort Space Used"` // kB
PeakSortSpaceUsed uint64 `json:"Peak Sort Space Used"` // kB
}
type Tip struct {
Code string `yaml:"code"`
Name string `yaml:"name"`
Description string `yaml:"description"`
DetailsUrl string `yaml:"detailsUrl"`
}
// Explain Processing.
func NewExplain(explainJSON string) (*Explain, error) {
var explains []Explain
err := json.NewDecoder(strings.NewReader(explainJSON)).Decode(&explains)
if err != nil {
return nil, err
}
if len(explains) == 0 {
return nil, errors.New("Empty explain")
}
// TODO(anatoly): Is it possible to have more than one explain?
var ex = &explains[0]
ex.processExplain()
return ex, nil
}
func (ex *Explain) RenderPlanText() string {
buf := new(bytes.Buffer)
ex.writeExplainText(buf)
return buf.String()
}
func (ex *Explain) RenderStats() string {
buf := new(bytes.Buffer)
ex.writeStatsText(buf)
return buf.String()
}
func (ex *Explain) processExplain() {
ex.Plan.normalizeIOTiming()
ex.calculateParams()
ex.processPlan(&ex.Plan)
ex.calculateOutlierNodes(&ex.Plan)
}
// normalizeIOTiming folds PostgreSQL 17+ per-buffer-type I/O timings
// (Shared/Local/Temp) into the legacy IOReadTime/IOWriteTime fields when those
// are absent, so downstream rendering stays version-agnostic.
func (plan *Plan) normalizeIOTiming() {
if plan.IOReadTime == nil {
plan.IOReadTime = sumFloat64Pointers(plan.SharedIOReadTime, plan.LocalIOReadTime, plan.TempIOReadTime)
}
if plan.IOWriteTime == nil {
plan.IOWriteTime = sumFloat64Pointers(plan.SharedIOWriteTime, plan.LocalIOWriteTime, plan.TempIOWriteTime)
}
for index := range plan.Plans {
plan.Plans[index].normalizeIOTiming()
}
}
func sumFloat64Pointers(values ...*float64) *float64 {
var (
total float64
found bool
)
for _, value := range values {
if value == nil {
continue
}
total += *value
found = true
}
if !found {
return nil
}
return &total
}
func (ex *Explain) calculateParams() {
ex.ActualRows = ex.Plan.ActualRows
ex.SharedHitBlocks = ex.Plan.SharedHitBlocks
ex.SharedReadBlocks = ex.Plan.SharedReadBlocks
ex.SharedDirtiedBlocks = ex.Plan.SharedDirtiedBlocks
ex.SharedWrittenBlocks = ex.Plan.SharedWrittenBlocks
ex.LocalHitBlocks = ex.Plan.LocalHitBlocks
ex.LocalReadBlocks = ex.Plan.LocalReadBlocks
ex.LocalDirtiedBlocks = ex.Plan.LocalDirtiedBlocks
ex.LocalWrittenBlocks = ex.Plan.LocalWrittenBlocks
ex.TempReadBlocks = ex.Plan.TempReadBlocks
ex.TempWrittenBlocks = ex.Plan.TempWrittenBlocks
ex.TotalTime = ex.PlanningTime + ex.ExecutionTime
ex.IOReadTime = ex.Plan.IOReadTime
ex.IOWriteTime = ex.Plan.IOWriteTime
}
func (ex *Explain) processPlan(plan *Plan) {
ex.checkSeqScan(plan)
ex.calculatePlannerEstimate(plan)
ex.calculateActuals(plan)
ex.calculateMaximums(plan)
for index := range plan.Plans {
ex.processPlan(&plan.Plans[index])
}
}
func (ex *Explain) checkSeqScan(plan *Plan) {
ex.ContainsSeqScan = ex.ContainsSeqScan || plan.NodeType == SequenceScan
}
func (ex *Explain) calculatePlannerEstimate(plan *Plan) {
plan.PlannerRowEstimateFactor = 0
if float64(plan.PlanRows) == plan.ActualRows {
return
}
plan.PlannerRowEstimateDirection = Under
if plan.PlanRows != 0 {
plan.PlannerRowEstimateFactor = plan.ActualRows / float64(plan.PlanRows)
}
if plan.PlannerRowEstimateFactor < 1.0 {
plan.PlannerRowEstimateFactor = 0
plan.PlannerRowEstimateDirection = Over
if plan.ActualRows != 0 {
plan.PlannerRowEstimateFactor = float64(plan.PlanRows) / plan.ActualRows
}
}
}
func (ex *Explain) calculateActuals(plan *Plan) {
plan.ActualDuration = plan.ActualTotalTime
plan.ActualCost = plan.TotalCost
for _, child := range plan.Plans {
if child.NodeType != CTEScan {
plan.ActualDuration = plan.ActualDuration - child.ActualTotalTime
plan.ActualCost = plan.ActualCost - child.TotalCost
}
}
if plan.ActualCost < 0 {
plan.ActualCost = 0
}
ex.TotalCost = ex.TotalCost + plan.ActualCost
plan.ActualDuration = plan.ActualDuration * float64(plan.ActualLoops)
}
func (ex *Explain) calculateMaximums(plan *Plan) {
if ex.MaxRows < plan.ActualRows {
ex.MaxRows = plan.ActualRows
}
if ex.MaxCost < plan.ActualCost {
ex.MaxCost = plan.ActualCost
}
if ex.MaxDuration < plan.ActualDuration {
ex.MaxDuration = plan.ActualDuration
}
}
func (ex *Explain) calculateOutlierNodes(plan *Plan) {
plan.Costliest = plan.ActualCost == ex.MaxCost
plan.Largest = plan.ActualRows == ex.MaxRows
plan.Slowest = plan.ActualDuration == ex.MaxDuration
for index := range plan.Plans {
ex.calculateOutlierNodes(&plan.Plans[index])
}
}
func (ex *Explain) writeExplainText(writer io.Writer) {
ex.writePlanText(writer, &ex.Plan, " ", 0, true)
if len(ex.Triggers) > 0 {
_, _ = fmt.Fprint(writer, printTriggers(ex.Triggers))
}
if len(ex.Settings) > 0 {
_, _ = fmt.Fprintf(writer, "Settings: %s\n", printMap(ex.Settings))
}
if ex.QueryIdentifier != 0 {
_, _ = fmt.Fprintf(writer, "Query ID: %d\n", ex.QueryIdentifier)
}
}
func (ex *Explain) writeExplainTextWithoutCosts(writer io.Writer) {
ex.writePlanText(writer, &ex.Plan, " ", 0, false)
if len(ex.Triggers) > 0 {
_, _ = fmt.Fprint(writer, printTriggers(ex.Triggers))
}
if len(ex.Settings) > 0 {
_, _ = fmt.Fprintf(writer, "Settings: %s\n", printMap(ex.Settings))
}
}
// nolint
func (ex *Explain) writeStatsText(writer io.Writer) {
fmt.Fprintf(writer, "\nTime: %s\n", util.MillisecondsToString(ex.TotalTime))
fmt.Fprintf(writer, " - planning: %s\n", util.MillisecondsToString(ex.PlanningTime))
fmt.Fprintf(writer, " - execution: %s\n", util.MillisecondsToString(ex.ExecutionTime))
ioRead := util.NA
if ex.IOReadTime != nil {
ioRead = util.MillisecondsToString(*ex.IOReadTime)
}
fmt.Fprintf(writer, " - I/O read: %s\n", ioRead)
ioWrite := util.NA
if ex.IOWriteTime != nil {
ioWrite = util.MillisecondsToString(*ex.IOWriteTime)
}
fmt.Fprintf(writer, " - I/O write: %s\n", ioWrite)
fmt.Fprintf(writer, "\nShared buffers:\n")
ex.writeBlocks(writer, "hits", ex.SharedHitBlocks, "from the buffer pool")
ex.writeBlocks(writer, "reads", ex.SharedReadBlocks, "from the OS file cache, including disk I/O")
ex.writeBlocks(writer, "dirtied", ex.SharedDirtiedBlocks, "")
ex.writeBlocks(writer, "writes", ex.SharedWrittenBlocks, "")
if ex.LocalHitBlocks > 0 || ex.LocalReadBlocks > 0 ||
ex.LocalDirtiedBlocks > 0 || ex.LocalWrittenBlocks > 0 {
fmt.Fprintf(writer, "\nLocal buffers:\n")
}
if ex.LocalHitBlocks > 0 {
ex.writeBlocks(writer, "hits", ex.LocalHitBlocks, "")
}
if ex.LocalReadBlocks > 0 {
ex.writeBlocks(writer, "reads", ex.LocalReadBlocks, "")
}
if ex.LocalDirtiedBlocks > 0 {
ex.writeBlocks(writer, "dirtied", ex.LocalDirtiedBlocks, "")
}
if ex.LocalWrittenBlocks > 0 {
ex.writeBlocks(writer, "writes", ex.LocalWrittenBlocks, "")
}
if ex.TempReadBlocks > 0 || ex.TempWrittenBlocks > 0 {
fmt.Fprintf(writer, "\nTemp buffers:\n")
}
if ex.TempReadBlocks > 0 {
ex.writeBlocks(writer, "reads", ex.TempReadBlocks, "")
}
if ex.TempWrittenBlocks > 0 {
ex.writeBlocks(writer, "writes", ex.TempWrittenBlocks, "")
}
}
func (ex *Explain) writeBlocks(writer io.Writer, name string, blocks uint64, cmmt string) {
if len(cmmt) > 0 {
cmmt = " " + cmmt
}
if blocks == 0 {
fmt.Fprintf(writer, " - %s: 0%s\n", name, cmmt)
return
}
fmt.Fprintf(writer, " - %s: %d (~%s)%s\n", name, blocks, blocksToBytes(blocks), cmmt)
}
func (ex *Explain) writePlanText(writer io.Writer, plan *Plan, prefix string, depth int, withCosts bool) {
currentPrefix := prefix
subplanPrefix := ""
var outputFn = func(format string, a ...interface{}) (int, error) {
return fmt.Fprintf(writer, fmt.Sprintf("%s%s\n", currentPrefix, format), a...)
}
// Treat subplan as additional details.
if plan.SubplanName != "" {
writeSubplanTextNodeCaption(outputFn, plan)
subplanPrefix = " "
depth++
}
if depth != 0 {
currentPrefix = prefix + subplanPrefix + "-> "
}
writePlanTextNodeCaption(outputFn, plan, withCosts)
currentPrefix = prefix + " "
if depth != 0 {
currentPrefix = prefix + subplanPrefix + " "
}
writePlanTextNodeDetails(outputFn, plan)
for index := range plan.Plans {
ex.writePlanText(writer, &plan.Plans[index], currentPrefix, depth+1, withCosts)
}
}
func writeSubplanTextNodeCaption(outputFn func(string, ...interface{}) (int, error), plan *Plan) {
outputFn("%s", plan.SubplanName)
}
func planCostsAndTiming(plan *Plan) string {
costs := fmt.Sprintf("(cost=%.2f..%.2f rows=%d width=%d)", plan.StartupCost, plan.TotalCost, plan.PlanRows, plan.PlanWidth)
// A node that ran zero loops was never reached during execution; PostgreSQL
// prints "(never executed)" in place of the actual-timing clause.
timing := "(never executed)"
if plan.ActualLoops != 0 {
timing = fmt.Sprintf("(actual time=%.3f..%.3f rows=%s loops=%d)",
plan.ActualStartupTime, plan.ActualTotalTime, formatActualRows(plan.ActualRows), plan.ActualLoops)
}
return fmt.Sprintf(" %s %s", costs, timing)
}
// formatActualRows renders an actual-row count. PostgreSQL 18+ reports fractional
// counts (averaged over loops), so print whole values as integers (pre-18 compat)
// and fractional values with two decimals (PostgreSQL 18's text format).
func formatActualRows(rows float64) string {
if rows == math.Trunc(rows) {
return strconv.FormatInt(int64(rows), 10)
}
return strconv.FormatFloat(rows, 'f', 2, 64)
}
// aggregateNodeType maps an Aggregate node's strategy and partial mode to the
// caption PostgreSQL emits: the strategy selects the base name, and a non-Simple
// partial mode prepends "Partial "/"Finalize " (e.g. "Finalize GroupAggregate").
func aggregateNodeType(plan *Plan) string {
name := string(Aggregate)
switch plan.Strategy {
case "Sorted":
name = "GroupAggregate"
case "Hashed":
name = "HashAggregate"
case "Mixed":
name = "MixedAggregate"
}
switch plan.PartialMode {
case "Partial":
name = "Partial " + name
case "Finalize":
name = "Finalize " + name
}
return name
}
// setOpNodeType maps a SetOp node's strategy and command to the caption
// PostgreSQL emits: a hashed strategy makes the base name "HashSetOp" (else
// "SetOp"), and the set command (Except/Except All/Intersect/Intersect All) is
// appended, e.g. "HashSetOp Except" or "SetOp Intersect All".
func setOpNodeType(plan *Plan) string {
name := string(SetOp)
if plan.Strategy == "Hashed" {
name = "HashSetOp"
}
if plan.Command != "" {
name += " " + plan.Command
}
return name
}
// safeIdentifier matches an identifier PostgreSQL can print without quoting: it
// must start with a lowercase letter or underscore and contain only lowercase
// letters, digits, and underscores (an uppercase letter or special character
// forces quoting).
var safeIdentifier = regexp.MustCompile(`^[a-z_][a-z0-9_]*$`)
// nonUnreservedKeywords is the set of SQL keywords PostgreSQL's quote_identifier()
// double-quotes when they appear as a bare (lowercase, safe-pattern) identifier.
// quote_identifier() (src/backend/utils/adt/ruleutils.c) quotes any keyword whose
// category is NOT UNRESERVED, i.e. every RESERVED_KEYWORD, COL_NAME_KEYWORD, and
// TYPE_FUNC_NAME_KEYWORD in the parser's keyword list (src/include/parser/kwlist.h);
// the ~330 UNRESERVED keywords are left bare. The set below is derived from the
// live server's pg_get_keywords() (equivalently kwlist.h) as the union across
// PostgreSQL 16-19: the RESERVED and TYPE_FUNC_NAME categories are word-identical
// across those majors, and COL_NAME is unioned (its lone cross-major variance is
// documented on that block). Uppercase/special-character identifiers are already
// forced to quote by safeIdentifier above, so only these lowercase keyword
// collisions (e.g. left, join, inner, between, int, values) need the table.
var nonUnreservedKeywords = map[string]bool{
// RESERVED_KEYWORD (78 words, word-identical across PG 16-19).
"all": true, "analyse": true, "analyze": true, "and": true, "any": true,
"array": true, "as": true, "asc": true, "asymmetric": true, "both": true,
"case": true, "cast": true, "check": true, "collate": true, "column": true,
"constraint": true, "create": true, "current_catalog": true, "current_date": true, "current_role": true,
"current_time": true, "current_timestamp": true, "current_user": true, "default": true, "deferrable": true,
"desc": true, "distinct": true, "do": true, "else": true, "end": true,
"except": true, "false": true, "fetch": true, "for": true, "foreign": true,
"from": true, "grant": true, "group": true, "having": true, "in": true,
"initially": true, "intersect": true, "into": true, "lateral": true, "leading": true,
"limit": true, "localtime": true, "localtimestamp": true, "not": true, "null": true,
"offset": true, "on": true, "only": true, "or": true, "order": true,
"placing": true, "primary": true, "references": true, "returning": true, "select": true,
"session_user": true, "some": true, "symmetric": true, "system_user": true, "table": true,
"then": true, "to": true, "trailing": true, "true": true, "union": true,
"unique": true, "user": true, "using": true, "variadic": true, "when": true,
"where": true, "window": true, "with": true,
// TYPE_FUNC_NAME_KEYWORD (23 words, word-identical across PG 16-19).
"authorization": true, "binary": true, "collation": true, "concurrently": true, "cross": true,
"current_schema": true, "freeze": true, "full": true, "ilike": true, "inner": true,
"is": true, "isnull": true, "join": true, "left": true, "like": true,
"natural": true, "notnull": true, "outer": true, "overlaps": true, "right": true,
"similar": true, "tablesample": true, "verbose": true,
// COL_NAME_KEYWORD (union of 64 words across PG 16-19). Its only cross-major
// variance: json is unreserved on PG 16, and the json_*/merge_action family
// (json_exists, json_query, json_scalar, json_serialize, json_table,
// json_value, merge_action) plus graph_table are not keywords before PG 17
// (graph_table before PG 19). joe quotes all of them on every major, which
// matches psql where they are keywords and harmlessly over-quotes them as
// bare aliases on older majors where they are unreserved; none is a realistic
// alias, so this residual is never exercised by the fidelity guard.
"between": true, "bigint": true, "bit": true, "boolean": true, "char": true,
"character": true, "coalesce": true, "dec": true, "decimal": true, "exists": true,
"extract": true, "float": true, "graph_table": true, "greatest": true, "grouping": true,
"inout": true, "int": true, "integer": true, "interval": true, "json": true,
"json_array": true, "json_arrayagg": true, "json_exists": true, "json_object": true, "json_objectagg": true,
"json_query": true, "json_scalar": true, "json_serialize": true, "json_table": true, "json_value": true,
"least": true, "merge_action": true, "national": true, "nchar": true, "none": true,
"normalize": true, "nullif": true, "numeric": true, "out": true, "overlay": true,
"position": true, "precision": true, "real": true, "row": true, "setof": true,
"smallint": true, "substring": true, "time": true, "timestamp": true, "treat": true,
"trim": true, "values": true, "varchar": true, "xmlattributes": true, "xmlconcat": true,
"xmlelement": true, "xmlexists": true, "xmlforest": true, "xmlnamespaces": true, "xmlparse": true,
"xmlpi": true, "xmlroot": true, "xmlserialize": true, "xmltable": true,
}
// quoteIdentifier mirrors PostgreSQL's quote_identifier(): an identifier is left
// bare only when it is a safe lowercase token (see safeIdentifier) that is not a
// keyword requiring quotes (see nonUnreservedKeywords); otherwise it is wrapped in
// double quotes with any embedded double quote doubled. So an ordinary alias like
// ss1 passes through unchanged, while a SetOp child alias such as "*SELECT* 1"
// becomes `"*SELECT* 1"` and a lowercase keyword alias like left or values is
// quoted exactly as psql would. Note Go's %q is not equivalent: it quotes
// unconditionally and uses C-style escapes.
func quoteIdentifier(s string) string {
if safeIdentifier.MatchString(s) && !nonUnreservedKeywords[s] {
return s
}
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
}
// scanTarget builds the " on [schema.]name [alias]" caption suffix shared by
// relation, CTE, and function scans: it schema-qualifies the name when a schema
// is present and appends the alias only when it differs from the name (matching
// PostgreSQL, which drops a redundant alias). An empty name yields no suffix.
func scanTarget(schema, name, alias string) string {
if name == "" {
return ""
}
on := " on " + name
if schema != "" {
on = " on " + schema + "." + name
}
if alias != "" && alias != name {
on += " " + alias
}
return on
}
func writePlanTextNodeCaption(outputFn func(string, ...interface{}) (int, error), plan *Plan, withCostsAndTiming bool) {
costsAndTiming := ""
if withCostsAndTiming {
costsAndTiming = planCostsAndTiming(plan)
}
using := ""
if plan.IndexName != "" {
// A Bitmap Index Scan has no table of its own, so PostgreSQL names the
// index with "on"; Index/Index Only Scans use "using <index> on <table>".
if plan.NodeType == BitmapIndexScan {
using = fmt.Sprintf(" on %v", plan.IndexName)
} else {
using = fmt.Sprintf(" using %v", plan.IndexName)
}
}
// Relation and CTE scans share the "[schema.]name [alias]" target form with
// Function Scan below (see scanTarget).
relName := plan.RelationName
if relName == "" {
relName = plan.CteName
}
on := scanTarget(plan.Schema, relName, plan.Alias)
nodeType := string(plan.NodeType)
switch plan.NodeType {
case ModifyTable: // E.g. for Insert.
nodeType = plan.Operation
case ValuesScan:
on = fmt.Sprintf(" on %q", plan.Alias)
case FunctionScan:
// Schema-qualify the function name like the relation scans above. A
// multi-function ROWS FROM carries no "Function Name", only an "Alias";
// psql's ExplainTargetRel then targets that alias (the RTE eref name), so
// fall back to it rather than dropping the " on" clause. psql runs that
// refname through quote_identifier(), so a keyword or special-char alias
// (e.g. ROWS FROM (...) AS "left"(a,b)) is quoted like FIX-7's Subquery
// Scan; an ordinary alias such as t stays bare.
if plan.FunctionName != "" {
on = scanTarget(plan.Schema, plan.FunctionName, plan.Alias)
} else if plan.Alias != "" {
on = scanTarget("", quoteIdentifier(plan.Alias), "")
}
case SubqueryScan:
// psql quote_identifier()s the subquery alias, so a synthetic SetOp child
// alias like "*SELECT* 1" is double-quoted; an ordinary alias is left bare.
nodeType = fmt.Sprintf("%s on %s", plan.NodeType, quoteIdentifier(plan.Alias))
case MergeJoin:
if plan.JoinType != "Inner" {
nodeType = fmt.Sprintf("Merge %s Join", plan.JoinType)
}
case HashJoin:
if plan.JoinType != "Inner" {
nodeType = fmt.Sprintf("Hash %s Join", plan.JoinType)
}
case Aggregate:
nodeType = aggregateNodeType(plan)
case SetOp:
nodeType = setOpNodeType(plan)
case NestedLoop:
if plan.JoinType != "Inner" {
nodeType = fmt.Sprintf("%v %s Join", plan.NodeType, plan.JoinType)
}
}
parallel := ""
if plan.ParallelAware {
parallel = "Parallel "
}
details := formatDetails(plan)
_, _ = outputFn("%s%v%s%s%s%s", parallel, nodeType, details, using, on, costsAndTiming)
}
// bufferCounter is one labelled counter ("hit=42") within a Buffers section.
type bufferCounter struct {
label string
value uint64
}
// appendBufferSection appends a "<name> label=val ..." section (e.g. "shared
// hit=5 read=2") to the Buffers line, comma-separating it from any preceding
// section. Only positive counters appear, and an all-zero section is omitted
// entirely — matching psql (sections comma-separated, counters within a section
// space-separated).
func appendBufferSection(buffers, name string, counters ...bufferCounter) string {
section := ""
for _, c := range counters {
if c.value > 0 {
section += fmt.Sprintf(" %s=%d", c.label, c.value)
}
}
if section == "" {
return buffers
}
if buffers != "" {
buffers += ", "
}
return buffers + name + section
}
func writePlanTextNodeDetails(outputFn func(string, ...interface{}) (int, error), plan *Plan) {
// PostgreSQL 18+ marks planner-disabled nodes (enable_* = off) explicitly.
if plan.Disabled {
_, _ = outputFn("Disabled: true")
}
if len(plan.SortKey) > 0 {
keys := ""
for _, key := range plan.SortKey {
if keys != "" {
keys += ", "
}
keys += key
}
outputFn("Sort Key: %s", keys)
}
// Incremental Sort: the prefix of the sort key the input is already ordered by.
if len(plan.PresortedKey) > 0 {
_, _ = outputFn("Presorted Key: %s", strings.Join(plan.PresortedKey, ", "))
}
// Incremental Sort per-group sort statistics (EXPLAIN ANALYZE).
writeSortGroups(outputFn, "Full-sort", plan.FullSortGroups)
writeSortGroups(outputFn, "Pre-sorted", plan.PresortedGroups)
if plan.SortMethod != "" || plan.SortSpaceType != "" {
details := ""
if plan.SortMethod != "" {
details += fmt.Sprintf("Sort Method: %s", plan.SortMethod)
}
if plan.SortSpaceType != "" {
if details != "" {
details += " "
}
details += fmt.Sprintf("%s: %dkB", plan.SortSpaceType, plan.SortSpaceUsed)
}
outputFn("%s", details)
}
if len(plan.GroupKey) > 0 {
keys := ""
for _, key := range plan.GroupKey {
if keys != "" {
keys += ", "
}
keys += key
}
outputFn("Group Key: %s", keys)
}
if plan.HashBuckets != 0 {
outputFn("Buckets: %d Batches: %d Memory Usage: %dkB", plan.HashBuckets, plan.HashBatches, plan.PeakMemoryUsage)
}
// Memoize cache stats: key/mode always; the hit/miss line only under ANALYZE,
// once the node executed (a fully-cached node has CacheHits > 0, CacheMisses 0).
if plan.NodeType == Memoize {
if plan.CacheKey != "" {
_, _ = outputFn("Cache Key: %s", plan.CacheKey)
}
if plan.CacheMode != "" {
_, _ = outputFn("Cache Mode: %s", plan.CacheMode)
}
if plan.CacheHits > 0 || plan.CacheMisses > 0 {
_, _ = outputFn("Hits: %d Misses: %d Evictions: %d Overflows: %d Memory Usage: %dkB",
plan.CacheHits, plan.CacheMisses, plan.CacheEvictions, plan.CacheOverflows, plan.PeakMemoryUsage)
}
}
if plan.IndexCondition != "" {
outputFn("Index Cond: %v", plan.IndexCondition)
}
if plan.MergeCondition != "" {
outputFn("Merge Cond: %v", plan.MergeCondition)
}
// Tid Scan / Tid Range Scan ctid qual (both use the same "TID Cond" label).
if plan.TidCondition != "" {
_, _ = outputFn("TID Cond: %v", plan.TidCondition)
}
if plan.NodeType == IndexOnlyScan {
outputFn("Heap Fetches: %d", plan.HeapFetches)
}
// PostgreSQL 18+: number of index descents on Index/Index-Only/Bitmap-Index Scans.
if plan.IndexSearches > 0 {
_, _ = outputFn("Index Searches: %d", plan.IndexSearches)
}
if plan.HashCondition != "" {
outputFn("Hash Cond: %v", plan.HashCondition)
}
// Bitmap Heap Scan: recheck of the index qual, and rows the recheck discarded
// on lossy pages (count only under ANALYZE, hence the >0 guard).
if plan.RecheckCond != "" {
_, _ = outputFn("Recheck Cond: %v", plan.RecheckCond)