pg_reports 0.5.4 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +40 -0
- data/README.md +12 -4
- data/app/views/layouts/pg_reports/application.html.erb +70 -61
- data/app/views/pg_reports/dashboard/_show_scripts.html.erb +53 -1
- data/app/views/pg_reports/dashboard/_show_styles.html.erb +31 -11
- data/app/views/pg_reports/dashboard/index.html.erb +14 -8
- data/app/views/pg_reports/dashboard/show.html.erb +6 -2
- data/config/locales/en.yml +109 -0
- data/config/locales/ru.yml +81 -0
- data/config/locales/uk.yml +126 -0
- data/lib/pg_reports/compatibility.rb +63 -0
- data/lib/pg_reports/configuration.rb +2 -0
- data/lib/pg_reports/dashboard/reports_registry.rb +36 -0
- data/lib/pg_reports/definitions/indexes/fk_without_indexes.yml +30 -0
- data/lib/pg_reports/definitions/indexes/index_correlation.yml +31 -0
- data/lib/pg_reports/definitions/indexes/inefficient_indexes.yml +45 -0
- data/lib/pg_reports/definitions/queries/temp_file_queries.yml +39 -0
- data/lib/pg_reports/definitions/system/wraparound_risk.yml +31 -0
- data/lib/pg_reports/definitions/tables/tables_without_pk.yml +28 -0
- data/lib/pg_reports/engine.rb +6 -0
- data/lib/pg_reports/modules/indexes.rb +3 -0
- data/lib/pg_reports/modules/queries.rb +1 -0
- data/lib/pg_reports/modules/system.rb +27 -0
- data/lib/pg_reports/modules/tables.rb +1 -0
- data/lib/pg_reports/query_monitor.rb +64 -32
- data/lib/pg_reports/sql/indexes/fk_without_indexes.sql +23 -0
- data/lib/pg_reports/sql/indexes/index_correlation.sql +27 -0
- data/lib/pg_reports/sql/indexes/inefficient_indexes.sql +22 -0
- data/lib/pg_reports/sql/queries/temp_file_queries.sql +16 -0
- data/lib/pg_reports/sql/system/checkpoint_stats.sql +20 -0
- data/lib/pg_reports/sql/system/checkpoint_stats_legacy.sql +19 -0
- data/lib/pg_reports/sql/system/wraparound_risk.sql +21 -0
- data/lib/pg_reports/sql/tables/tables_without_pk.sql +20 -0
- data/lib/pg_reports/version.rb +1 -1
- data/lib/pg_reports.rb +5 -0
- metadata +16 -1
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgReports
|
|
4
|
+
# Checks runtime environment and warns about outdated or unsupported versions.
|
|
5
|
+
# Called once at boot (Ruby/Rails) and lazily on first DB access (PostgreSQL).
|
|
6
|
+
module Compatibility
|
|
7
|
+
# Keep in sync with gemspec constraints
|
|
8
|
+
MINIMUM_RUBY_VERSION = "2.7"
|
|
9
|
+
MINIMUM_RAILS_VERSION = "5.0"
|
|
10
|
+
MINIMUM_PG_VERSION = 12_00_00 # server_version_num format
|
|
11
|
+
MINIMUM_PG_VERSION_LABEL = "12"
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
def check_ruby!
|
|
15
|
+
return if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new(MINIMUM_RUBY_VERSION)
|
|
16
|
+
|
|
17
|
+
warn "[pg_reports] Ruby #{RUBY_VERSION} is not supported. " \
|
|
18
|
+
"Minimum required version is Ruby #{MINIMUM_RUBY_VERSION}."
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def check_rails!
|
|
22
|
+
return unless defined?(Rails::VERSION::STRING)
|
|
23
|
+
return if Gem::Version.new(Rails::VERSION::STRING) >= Gem::Version.new(MINIMUM_RAILS_VERSION)
|
|
24
|
+
|
|
25
|
+
warn "[pg_reports] Rails #{Rails::VERSION::STRING} is not supported. " \
|
|
26
|
+
"Minimum required version is Rails #{MINIMUM_RAILS_VERSION}."
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def check_postgresql!
|
|
30
|
+
version_num = pg_version_num
|
|
31
|
+
return if version_num.nil? # no connection yet — skip silently
|
|
32
|
+
return if version_num >= MINIMUM_PG_VERSION
|
|
33
|
+
|
|
34
|
+
label = pg_version_label(version_num)
|
|
35
|
+
warn "[pg_reports] PostgreSQL #{label} is not supported. " \
|
|
36
|
+
"Minimum required version is PostgreSQL #{MINIMUM_PG_VERSION_LABEL}. " \
|
|
37
|
+
"Some reports may return errors or incomplete data."
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def check_all!
|
|
41
|
+
check_ruby!
|
|
42
|
+
check_rails!
|
|
43
|
+
check_postgresql!
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def pg_version_num
|
|
49
|
+
connection = PgReports.config.connection
|
|
50
|
+
result = connection.exec_query("SELECT current_setting('server_version_num')::int AS v")
|
|
51
|
+
result.first&.fetch("v", 0).to_i
|
|
52
|
+
rescue
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def pg_version_label(version_num)
|
|
57
|
+
major = version_num / 1_00_00
|
|
58
|
+
minor = (version_num % 1_00_00) / 100
|
|
59
|
+
"#{major}.#{minor}"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -13,6 +13,7 @@ module PgReports
|
|
|
13
13
|
|
|
14
14
|
# Index analysis thresholds
|
|
15
15
|
attr_accessor :unused_index_threshold_scans # Index with fewer scans is unused
|
|
16
|
+
attr_accessor :inefficient_index_threshold_ratio # Read/fetch ratio above this is inefficient
|
|
16
17
|
|
|
17
18
|
# Table analysis thresholds
|
|
18
19
|
attr_accessor :bloat_threshold_percent # Tables with more bloat are problematic
|
|
@@ -53,6 +54,7 @@ module PgReports
|
|
|
53
54
|
|
|
54
55
|
# Index thresholds
|
|
55
56
|
@unused_index_threshold_scans = 50
|
|
57
|
+
@inefficient_index_threshold_ratio = 10
|
|
56
58
|
|
|
57
59
|
# Table thresholds
|
|
58
60
|
@bloat_threshold_percent = 20
|
|
@@ -32,6 +32,10 @@ module PgReports
|
|
|
32
32
|
thresholds: {},
|
|
33
33
|
problem_fields: []
|
|
34
34
|
},
|
|
35
|
+
temp_file_queries: {
|
|
36
|
+
thresholds: {temp_mb_written: {warning: 100, critical: 1000}},
|
|
37
|
+
problem_fields: ["temp_mb_written"]
|
|
38
|
+
},
|
|
35
39
|
|
|
36
40
|
# === INDEXES ===
|
|
37
41
|
unused_indexes: {
|
|
@@ -50,6 +54,10 @@ module PgReports
|
|
|
50
54
|
thresholds: {seq_scan_ratio: {warning: 0.5, critical: 0.9}},
|
|
51
55
|
problem_fields: ["seq_scan", "seq_tup_read"]
|
|
52
56
|
},
|
|
57
|
+
inefficient_indexes: {
|
|
58
|
+
thresholds: {read_to_fetch_ratio: {warning: 10, critical: 100}},
|
|
59
|
+
problem_fields: ["read_to_fetch_ratio"]
|
|
60
|
+
},
|
|
53
61
|
index_usage: {
|
|
54
62
|
thresholds: {},
|
|
55
63
|
problem_fields: []
|
|
@@ -62,6 +70,14 @@ module PgReports
|
|
|
62
70
|
thresholds: {size_bytes: {warning: 1073741824, critical: 10737418240}},
|
|
63
71
|
problem_fields: ["size_bytes"]
|
|
64
72
|
},
|
|
73
|
+
fk_without_indexes: {
|
|
74
|
+
thresholds: {},
|
|
75
|
+
problem_fields: ["child_table_size_mb"]
|
|
76
|
+
},
|
|
77
|
+
index_correlation: {
|
|
78
|
+
thresholds: {correlation: {warning: 0.5, critical: 0.2, inverted: true}},
|
|
79
|
+
problem_fields: ["correlation"]
|
|
80
|
+
},
|
|
65
81
|
|
|
66
82
|
# === TABLES ===
|
|
67
83
|
table_sizes: {
|
|
@@ -92,6 +108,10 @@ module PgReports
|
|
|
92
108
|
thresholds: {},
|
|
93
109
|
problem_fields: []
|
|
94
110
|
},
|
|
111
|
+
tables_without_pk: {
|
|
112
|
+
thresholds: {},
|
|
113
|
+
problem_fields: []
|
|
114
|
+
},
|
|
95
115
|
|
|
96
116
|
# === CONNECTIONS ===
|
|
97
117
|
active_connections: {
|
|
@@ -159,6 +179,14 @@ module PgReports
|
|
|
159
179
|
thresholds: {cache_hit_ratio: {warning: 0.95, critical: 0.90, inverted: true}},
|
|
160
180
|
problem_fields: ["cache_hit_ratio"]
|
|
161
181
|
},
|
|
182
|
+
wraparound_risk: {
|
|
183
|
+
thresholds: {pct_towards_wraparound: {warning: 50, critical: 75}},
|
|
184
|
+
problem_fields: ["pct_towards_wraparound"]
|
|
185
|
+
},
|
|
186
|
+
checkpoint_stats: {
|
|
187
|
+
thresholds: {requested_pct: {warning: 50, critical: 75}},
|
|
188
|
+
problem_fields: ["requested_pct"]
|
|
189
|
+
},
|
|
162
190
|
|
|
163
191
|
# === SCHEMA ANALYSIS ===
|
|
164
192
|
missing_validations: {
|
|
@@ -178,6 +206,7 @@ module PgReports
|
|
|
178
206
|
expensive_queries: {name: "Expensive Queries", description: "Queries consuming most total time"},
|
|
179
207
|
missing_index_queries: {name: "Missing Index Queries", description: "Queries potentially missing indexes"},
|
|
180
208
|
low_cache_hit_queries: {name: "Low Cache Hit", description: "Queries with poor cache utilization"},
|
|
209
|
+
temp_file_queries: {name: "Temp File Queries", description: "Queries spilling to disk", new: true},
|
|
181
210
|
all_queries: {name: "All Queries", description: "All query statistics"}
|
|
182
211
|
}
|
|
183
212
|
},
|
|
@@ -190,8 +219,11 @@ module PgReports
|
|
|
190
219
|
duplicate_indexes: {name: "Duplicate Indexes", description: "Redundant indexes"},
|
|
191
220
|
invalid_indexes: {name: "Invalid Indexes", description: "Indexes that failed to build"},
|
|
192
221
|
missing_indexes: {name: "Missing Indexes", description: "Tables potentially missing indexes"},
|
|
222
|
+
inefficient_indexes: {name: "Inefficient Indexes", description: "Indexes with high read-to-fetch ratio", new: true},
|
|
193
223
|
index_usage: {name: "Index Usage", description: "Index scan statistics"},
|
|
194
224
|
bloated_indexes: {name: "Bloated Indexes", description: "Indexes with high bloat"},
|
|
225
|
+
fk_without_indexes: {name: "FK Without Indexes", description: "Foreign keys missing indexes", new: true},
|
|
226
|
+
index_correlation: {name: "Index Correlation", description: "Low physical correlation indexes", new: true},
|
|
195
227
|
index_sizes: {name: "Index Sizes", description: "Index disk usage"}
|
|
196
228
|
}
|
|
197
229
|
},
|
|
@@ -206,6 +238,7 @@ module PgReports
|
|
|
206
238
|
row_counts: {name: "Row Counts", description: "Table row counts"},
|
|
207
239
|
cache_hit_ratios: {name: "Cache Hit Ratios", description: "Table cache statistics"},
|
|
208
240
|
seq_scans: {name: "Sequential Scans", description: "Tables with high sequential scans"},
|
|
241
|
+
tables_without_pk: {name: "No Primary Key", description: "Tables missing primary keys", new: true},
|
|
209
242
|
recently_modified: {name: "Recently Modified", description: "Tables with recent activity"}
|
|
210
243
|
}
|
|
211
244
|
},
|
|
@@ -235,6 +268,8 @@ module PgReports
|
|
|
235
268
|
settings: {name: "Settings", description: "PostgreSQL configuration"},
|
|
236
269
|
extensions: {name: "Extensions", description: "Installed extensions"},
|
|
237
270
|
activity_overview: {name: "Activity Overview", description: "Current activity summary"},
|
|
271
|
+
wraparound_risk: {name: "Wraparound Risk", description: "Transaction ID wraparound proximity", new: true},
|
|
272
|
+
checkpoint_stats: {name: "Checkpoint Stats", description: "Checkpoint and bgwriter statistics", new: true},
|
|
238
273
|
cache_stats: {name: "Cache Stats", description: "Database cache statistics"}
|
|
239
274
|
}
|
|
240
275
|
},
|
|
@@ -272,6 +307,7 @@ module PgReports
|
|
|
272
307
|
what: I18n.t("#{i18n_key}.what", default: ""),
|
|
273
308
|
how: I18n.t("#{i18n_key}.how", default: ""),
|
|
274
309
|
nuances: I18n.t("#{i18n_key}.nuances", default: []),
|
|
310
|
+
ai_prompt: I18n.t("#{i18n_key}.ai_prompt", default: nil),
|
|
275
311
|
thresholds: config[:thresholds],
|
|
276
312
|
problem_fields: config[:problem_fields]
|
|
277
313
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Foreign keys without indexes on the child table
|
|
2
|
+
# Missing indexes cause sequential scans on parent DELETE/UPDATE
|
|
3
|
+
|
|
4
|
+
report:
|
|
5
|
+
name: fk_without_indexes
|
|
6
|
+
module: indexes
|
|
7
|
+
description: "Foreign keys missing indexes on the referencing table"
|
|
8
|
+
|
|
9
|
+
sql:
|
|
10
|
+
category: indexes
|
|
11
|
+
file: fk_without_indexes
|
|
12
|
+
|
|
13
|
+
title: "Foreign Keys Without Indexes"
|
|
14
|
+
|
|
15
|
+
columns:
|
|
16
|
+
- constraint_name
|
|
17
|
+
- child_table
|
|
18
|
+
- child_column
|
|
19
|
+
- parent_table
|
|
20
|
+
- parent_column
|
|
21
|
+
- child_table_size_mb
|
|
22
|
+
|
|
23
|
+
parameters:
|
|
24
|
+
limit:
|
|
25
|
+
type: integer
|
|
26
|
+
default: 50
|
|
27
|
+
description: "Maximum number of results"
|
|
28
|
+
|
|
29
|
+
problem_explanations:
|
|
30
|
+
child_table_size_mb: fk_without_index
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Index correlation analysis
|
|
2
|
+
# Low correlation means physical row order doesn't match index order, causing random I/O
|
|
3
|
+
|
|
4
|
+
report:
|
|
5
|
+
name: index_correlation
|
|
6
|
+
module: indexes
|
|
7
|
+
description: "Indexes with low physical correlation causing excessive random I/O"
|
|
8
|
+
|
|
9
|
+
sql:
|
|
10
|
+
category: indexes
|
|
11
|
+
file: index_correlation
|
|
12
|
+
|
|
13
|
+
title: "Index Correlation (tables > 10MB, scans > 100)"
|
|
14
|
+
|
|
15
|
+
columns:
|
|
16
|
+
- schema
|
|
17
|
+
- table_name
|
|
18
|
+
- column_name
|
|
19
|
+
- index_name
|
|
20
|
+
- correlation
|
|
21
|
+
- table_size_mb
|
|
22
|
+
- idx_scan
|
|
23
|
+
|
|
24
|
+
parameters:
|
|
25
|
+
limit:
|
|
26
|
+
type: integer
|
|
27
|
+
default: 50
|
|
28
|
+
description: "Maximum number of results"
|
|
29
|
+
|
|
30
|
+
problem_explanations:
|
|
31
|
+
correlation: low_correlation
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Inefficient indexes - indexes that are used but scan far more entries than they fetch
|
|
2
|
+
# High read-to-fetch ratio indicates misaligned composite index column order
|
|
3
|
+
|
|
4
|
+
report:
|
|
5
|
+
name: inefficient_indexes
|
|
6
|
+
module: indexes
|
|
7
|
+
description: "Indexes with high read-to-fetch ratio indicating inefficient scans"
|
|
8
|
+
|
|
9
|
+
sql:
|
|
10
|
+
category: indexes
|
|
11
|
+
file: inefficient_indexes
|
|
12
|
+
|
|
13
|
+
title: "Inefficient Indexes (read/fetch ratio > ${threshold})"
|
|
14
|
+
title_vars:
|
|
15
|
+
threshold:
|
|
16
|
+
source: config
|
|
17
|
+
key: inefficient_index_threshold_ratio
|
|
18
|
+
|
|
19
|
+
columns:
|
|
20
|
+
- schema
|
|
21
|
+
- table_name
|
|
22
|
+
- index_name
|
|
23
|
+
- idx_scan
|
|
24
|
+
- idx_tup_read
|
|
25
|
+
- idx_tup_fetch
|
|
26
|
+
- read_to_fetch_ratio
|
|
27
|
+
- index_size_mb
|
|
28
|
+
- index_definition
|
|
29
|
+
|
|
30
|
+
parameters:
|
|
31
|
+
limit:
|
|
32
|
+
type: integer
|
|
33
|
+
default: 50
|
|
34
|
+
description: "Maximum number of results"
|
|
35
|
+
|
|
36
|
+
filters:
|
|
37
|
+
- field: read_to_fetch_ratio
|
|
38
|
+
operator: gte
|
|
39
|
+
value:
|
|
40
|
+
source: config
|
|
41
|
+
key: inefficient_index_threshold_ratio
|
|
42
|
+
cast: float
|
|
43
|
+
|
|
44
|
+
problem_explanations:
|
|
45
|
+
read_to_fetch_ratio: inefficient_index
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Temp file heavy queries
|
|
2
|
+
# Queries that spill to disk due to insufficient work_mem
|
|
3
|
+
|
|
4
|
+
report:
|
|
5
|
+
name: temp_file_queries
|
|
6
|
+
module: queries
|
|
7
|
+
description: "Queries spilling data to temporary files on disk"
|
|
8
|
+
|
|
9
|
+
sql:
|
|
10
|
+
category: queries
|
|
11
|
+
file: temp_file_queries
|
|
12
|
+
params:
|
|
13
|
+
max_query_length:
|
|
14
|
+
source: config
|
|
15
|
+
key: max_query_length
|
|
16
|
+
|
|
17
|
+
title: "Temp File Queries"
|
|
18
|
+
|
|
19
|
+
columns:
|
|
20
|
+
- query
|
|
21
|
+
- calls
|
|
22
|
+
- temp_mb_written
|
|
23
|
+
- temp_mb_read
|
|
24
|
+
- total_time_sec
|
|
25
|
+
- mean_time_ms
|
|
26
|
+
- rows
|
|
27
|
+
|
|
28
|
+
parameters:
|
|
29
|
+
limit:
|
|
30
|
+
type: integer
|
|
31
|
+
default: 20
|
|
32
|
+
description: "Maximum number of results"
|
|
33
|
+
|
|
34
|
+
enrichment:
|
|
35
|
+
module: queries
|
|
36
|
+
hook: enrich_with_annotations
|
|
37
|
+
|
|
38
|
+
problem_explanations:
|
|
39
|
+
temp_mb_written: temp_file_heavy
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Transaction ID wraparound risk
|
|
2
|
+
# Monitors proximity to the 2-billion XID limit that triggers emergency shutdown
|
|
3
|
+
|
|
4
|
+
report:
|
|
5
|
+
name: wraparound_risk
|
|
6
|
+
module: system
|
|
7
|
+
description: "Transaction ID wraparound risk for all databases"
|
|
8
|
+
|
|
9
|
+
sql:
|
|
10
|
+
category: system
|
|
11
|
+
file: wraparound_risk
|
|
12
|
+
|
|
13
|
+
title: "Transaction ID Wraparound Risk"
|
|
14
|
+
|
|
15
|
+
columns:
|
|
16
|
+
- database_name
|
|
17
|
+
- xid_age
|
|
18
|
+
- pct_towards_wraparound
|
|
19
|
+
- remaining_xids
|
|
20
|
+
- freeze_max_age
|
|
21
|
+
- status
|
|
22
|
+
- database_size
|
|
23
|
+
|
|
24
|
+
parameters:
|
|
25
|
+
limit:
|
|
26
|
+
type: integer
|
|
27
|
+
default: 50
|
|
28
|
+
description: "Maximum number of results"
|
|
29
|
+
|
|
30
|
+
problem_explanations:
|
|
31
|
+
pct_towards_wraparound: wraparound_risk
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Tables without primary keys
|
|
2
|
+
# Missing PKs break logical replication and make row identification unreliable
|
|
3
|
+
|
|
4
|
+
report:
|
|
5
|
+
name: tables_without_pk
|
|
6
|
+
module: tables
|
|
7
|
+
description: "Tables missing a primary key"
|
|
8
|
+
|
|
9
|
+
sql:
|
|
10
|
+
category: tables
|
|
11
|
+
file: tables_without_pk
|
|
12
|
+
|
|
13
|
+
title: "Tables Without Primary Keys"
|
|
14
|
+
|
|
15
|
+
columns:
|
|
16
|
+
- schema
|
|
17
|
+
- table_name
|
|
18
|
+
- estimated_rows
|
|
19
|
+
- table_size_mb
|
|
20
|
+
|
|
21
|
+
parameters:
|
|
22
|
+
limit:
|
|
23
|
+
type: integer
|
|
24
|
+
default: 50
|
|
25
|
+
description: "Maximum number of results"
|
|
26
|
+
|
|
27
|
+
problem_explanations:
|
|
28
|
+
table_name: missing_pk
|
data/lib/pg_reports/engine.rb
CHANGED
|
@@ -13,6 +13,12 @@ module PgReports
|
|
|
13
13
|
config.i18n.load_path += Dir[root.join("config", "locales", "*.yml")]
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
initializer "pg_reports.compatibility_check" do
|
|
17
|
+
ActiveSupport.on_load(:active_record) do
|
|
18
|
+
PgReports::Compatibility.check_postgresql!
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
16
22
|
initializer "pg_reports.assets" do |_app|
|
|
17
23
|
# Assets are inline in views, no precompilation needed
|
|
18
24
|
end
|
|
@@ -12,6 +12,9 @@ module PgReports
|
|
|
12
12
|
# - duplicate_indexes
|
|
13
13
|
# - invalid_indexes
|
|
14
14
|
# - missing_indexes(limit: 20)
|
|
15
|
+
# - inefficient_indexes(limit: 50)
|
|
16
|
+
# - fk_without_indexes(limit: 50)
|
|
17
|
+
# - index_correlation(limit: 50)
|
|
15
18
|
# - index_usage(limit: 50)
|
|
16
19
|
# - bloated_indexes(limit: 20)
|
|
17
20
|
# - index_sizes(limit: 50)
|
|
@@ -12,7 +12,11 @@ module PgReports
|
|
|
12
12
|
# - settings
|
|
13
13
|
# - extensions
|
|
14
14
|
# - activity_overview
|
|
15
|
+
# - wraparound_risk(limit: 50)
|
|
15
16
|
# - cache_stats
|
|
17
|
+
#
|
|
18
|
+
# Manually implemented (version-dependent SQL):
|
|
19
|
+
# - checkpoint_stats(limit: 10)
|
|
16
20
|
|
|
17
21
|
# pg_stat_statements availability check
|
|
18
22
|
# @return [Boolean] Whether pg_stat_statements is available
|
|
@@ -132,6 +136,22 @@ module PgReports
|
|
|
132
136
|
end
|
|
133
137
|
end
|
|
134
138
|
|
|
139
|
+
# Checkpoint stats — uses version-specific SQL because PostgreSQL 17+
|
|
140
|
+
# moved checkpoint columns from pg_stat_bgwriter to pg_stat_checkpointer
|
|
141
|
+
def checkpoint_stats(limit: 10)
|
|
142
|
+
sql_file = pg_version >= 170_000 ? :checkpoint_stats : :checkpoint_stats_legacy
|
|
143
|
+
data = executor.execute_from_file(:system, sql_file)
|
|
144
|
+
data = data.first(limit) if limit
|
|
145
|
+
|
|
146
|
+
Report.new(
|
|
147
|
+
title: "Checkpoint Statistics",
|
|
148
|
+
data: data,
|
|
149
|
+
columns: %w[checkpoints_timed checkpoints_requested checkpoint_write_time_sec
|
|
150
|
+
checkpoint_sync_time_sec buffers_checkpoint buffers_clean
|
|
151
|
+
bgwriter_stops buffers_alloc requested_pct stats_reset]
|
|
152
|
+
)
|
|
153
|
+
end
|
|
154
|
+
|
|
135
155
|
# Get list of all databases
|
|
136
156
|
# @return [Array<Hash>] List of databases with sizes
|
|
137
157
|
def databases_list
|
|
@@ -152,6 +172,13 @@ module PgReports
|
|
|
152
172
|
|
|
153
173
|
private
|
|
154
174
|
|
|
175
|
+
def pg_version
|
|
176
|
+
@pg_version ||= begin
|
|
177
|
+
result = executor.execute("SELECT current_setting('server_version_num')::int AS v")
|
|
178
|
+
result.first&.fetch("v", 0).to_i
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
155
182
|
def executor
|
|
156
183
|
@executor ||= Executor.new
|
|
157
184
|
end
|
|
@@ -16,15 +16,23 @@ module PgReports
|
|
|
16
16
|
@subscriber = nil
|
|
17
17
|
@mutex = Mutex.new
|
|
18
18
|
@queries = []
|
|
19
|
+
@handling_event = false
|
|
20
|
+
|
|
21
|
+
# Local state — used by the event handler to avoid cache reads
|
|
22
|
+
# (which generate SQL events and cause infinite recursion with DB-backed caches)
|
|
23
|
+
@enabled = false
|
|
24
|
+
@session_id = nil
|
|
25
|
+
|
|
26
|
+
sync_from_cache
|
|
19
27
|
ensure_subscription_if_enabled
|
|
20
28
|
end
|
|
21
29
|
|
|
22
30
|
def enabled
|
|
23
|
-
|
|
31
|
+
@enabled
|
|
24
32
|
end
|
|
25
33
|
|
|
26
34
|
def session_id
|
|
27
|
-
|
|
35
|
+
@session_id
|
|
28
36
|
end
|
|
29
37
|
|
|
30
38
|
def start
|
|
@@ -37,7 +45,11 @@ module PgReports
|
|
|
37
45
|
new_session_id = SecureRandom.uuid
|
|
38
46
|
@queries = []
|
|
39
47
|
|
|
40
|
-
#
|
|
48
|
+
# Update local state first (used by event handler — no cache round-trip)
|
|
49
|
+
@enabled = true
|
|
50
|
+
@session_id = new_session_id
|
|
51
|
+
|
|
52
|
+
# Store state in cache so other processes can see it
|
|
41
53
|
cache_write(CACHE_KEY_ENABLED, true)
|
|
42
54
|
cache_write(CACHE_KEY_SESSION_ID, new_session_id)
|
|
43
55
|
|
|
@@ -52,6 +64,8 @@ module PgReports
|
|
|
52
64
|
{success: true, message: "Query monitoring started", session_id: new_session_id}
|
|
53
65
|
end
|
|
54
66
|
rescue => e
|
|
67
|
+
@enabled = false
|
|
68
|
+
@session_id = nil
|
|
55
69
|
cache_write(CACHE_KEY_ENABLED, false)
|
|
56
70
|
{success: false, error: e.message}
|
|
57
71
|
end
|
|
@@ -62,7 +76,11 @@ module PgReports
|
|
|
62
76
|
return {success: false, message: "Monitoring not active"}
|
|
63
77
|
end
|
|
64
78
|
|
|
65
|
-
current_session_id = session_id
|
|
79
|
+
current_session_id = @session_id
|
|
80
|
+
|
|
81
|
+
# Clear local state immediately — stops event handler from processing
|
|
82
|
+
@enabled = false
|
|
83
|
+
@session_id = nil
|
|
66
84
|
|
|
67
85
|
# Unsubscribe from notifications in THIS process
|
|
68
86
|
if @subscriber
|
|
@@ -71,12 +89,12 @@ module PgReports
|
|
|
71
89
|
end
|
|
72
90
|
|
|
73
91
|
# Write session end marker to file
|
|
74
|
-
write_session_marker("session_end")
|
|
92
|
+
write_session_marker("session_end", current_session_id)
|
|
75
93
|
|
|
76
94
|
# Flush queries to file
|
|
77
95
|
flush_to_file
|
|
78
96
|
|
|
79
|
-
# Clear state from cache
|
|
97
|
+
# Clear state from cache so other processes see it
|
|
80
98
|
cache_delete(CACHE_KEY_ENABLED)
|
|
81
99
|
cache_delete(CACHE_KEY_SESSION_ID)
|
|
82
100
|
|
|
@@ -175,9 +193,15 @@ module PgReports
|
|
|
175
193
|
defined?(Rails) && defined?(Rails.cache)
|
|
176
194
|
end
|
|
177
195
|
|
|
196
|
+
# Sync local state from shared cache (called on initialize for multi-process support)
|
|
197
|
+
def sync_from_cache
|
|
198
|
+
@enabled = enabled
|
|
199
|
+
@session_id = session_id
|
|
200
|
+
end
|
|
201
|
+
|
|
178
202
|
# Ensure this process is subscribed to notifications if monitoring is enabled
|
|
179
203
|
def ensure_subscription_if_enabled
|
|
180
|
-
return unless enabled
|
|
204
|
+
return unless @enabled
|
|
181
205
|
ensure_subscription
|
|
182
206
|
end
|
|
183
207
|
|
|
@@ -192,30 +216,38 @@ module PgReports
|
|
|
192
216
|
end
|
|
193
217
|
|
|
194
218
|
def handle_sql_event(name, started, finished, unique_id, payload)
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
return if
|
|
199
|
-
|
|
200
|
-
duration_ms = ((finished - started) * 1000).round(2)
|
|
201
|
-
sql = payload[:sql]
|
|
202
|
-
query_name = payload[:name]
|
|
219
|
+
# Use local @enabled instead of enabled (which hits cache and may generate SQL,
|
|
220
|
+
# causing infinite recursion with database-backed cache stores like SolidCache)
|
|
221
|
+
return unless @enabled
|
|
222
|
+
return if @handling_event
|
|
203
223
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
source_location
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
224
|
+
@handling_event = true
|
|
225
|
+
begin
|
|
226
|
+
# Skip if should be filtered
|
|
227
|
+
return if should_skip?(payload)
|
|
228
|
+
|
|
229
|
+
duration_ms = ((finished - started) * 1000).round(2)
|
|
230
|
+
sql = payload[:sql]
|
|
231
|
+
query_name = payload[:name]
|
|
232
|
+
|
|
233
|
+
# Extract source location
|
|
234
|
+
source_location = extract_source_location
|
|
235
|
+
|
|
236
|
+
# Build query entry
|
|
237
|
+
query_entry = {
|
|
238
|
+
type: "query",
|
|
239
|
+
session_id: @session_id,
|
|
240
|
+
sql: sql,
|
|
241
|
+
duration_ms: duration_ms,
|
|
242
|
+
name: query_name,
|
|
243
|
+
source_location: source_location,
|
|
244
|
+
timestamp: Time.current.iso8601
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
add_to_buffer(query_entry)
|
|
248
|
+
ensure
|
|
249
|
+
@handling_event = false
|
|
250
|
+
end
|
|
219
251
|
end
|
|
220
252
|
|
|
221
253
|
def should_skip?(payload)
|
|
@@ -317,12 +349,12 @@ module PgReports
|
|
|
317
349
|
end
|
|
318
350
|
end
|
|
319
351
|
|
|
320
|
-
def write_session_marker(marker_type)
|
|
352
|
+
def write_session_marker(marker_type, sid = @session_id)
|
|
321
353
|
return unless log_file_enabled?
|
|
322
354
|
|
|
323
355
|
marker = {
|
|
324
356
|
type: marker_type,
|
|
325
|
-
session_id:
|
|
357
|
+
session_id: sid,
|
|
326
358
|
timestamp: Time.current.iso8601
|
|
327
359
|
}
|
|
328
360
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
-- Foreign keys without indexes on the referencing (child) table
|
|
2
|
+
-- Missing indexes cause sequential scans on DELETE/UPDATE of parent rows
|
|
3
|
+
|
|
4
|
+
SELECT
|
|
5
|
+
c.conname AS constraint_name,
|
|
6
|
+
c.conrelid::regclass::text AS child_table,
|
|
7
|
+
a.attname AS child_column,
|
|
8
|
+
c.confrelid::regclass::text AS parent_table,
|
|
9
|
+
pa.attname AS parent_column,
|
|
10
|
+
pg_size_pretty(pg_relation_size(c.conrelid)) AS child_table_size,
|
|
11
|
+
ROUND(pg_relation_size(c.conrelid) / 1024.0 / 1024.0, 2) AS child_table_size_mb
|
|
12
|
+
FROM pg_constraint c
|
|
13
|
+
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey)
|
|
14
|
+
JOIN pg_attribute pa ON pa.attrelid = c.confrelid AND pa.attnum = ANY(c.confkey)
|
|
15
|
+
WHERE c.contype = 'f'
|
|
16
|
+
AND NOT EXISTS (
|
|
17
|
+
SELECT 1
|
|
18
|
+
FROM pg_index i
|
|
19
|
+
WHERE i.indrelid = c.conrelid
|
|
20
|
+
AND a.attnum = ANY(i.indkey)
|
|
21
|
+
AND i.indkey[0] = a.attnum
|
|
22
|
+
)
|
|
23
|
+
ORDER BY pg_relation_size(c.conrelid) DESC;
|