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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -0
  3. data/README.md +12 -4
  4. data/app/views/layouts/pg_reports/application.html.erb +70 -61
  5. data/app/views/pg_reports/dashboard/_show_scripts.html.erb +53 -1
  6. data/app/views/pg_reports/dashboard/_show_styles.html.erb +31 -11
  7. data/app/views/pg_reports/dashboard/index.html.erb +14 -8
  8. data/app/views/pg_reports/dashboard/show.html.erb +6 -2
  9. data/config/locales/en.yml +109 -0
  10. data/config/locales/ru.yml +81 -0
  11. data/config/locales/uk.yml +126 -0
  12. data/lib/pg_reports/compatibility.rb +63 -0
  13. data/lib/pg_reports/configuration.rb +2 -0
  14. data/lib/pg_reports/dashboard/reports_registry.rb +36 -0
  15. data/lib/pg_reports/definitions/indexes/fk_without_indexes.yml +30 -0
  16. data/lib/pg_reports/definitions/indexes/index_correlation.yml +31 -0
  17. data/lib/pg_reports/definitions/indexes/inefficient_indexes.yml +45 -0
  18. data/lib/pg_reports/definitions/queries/temp_file_queries.yml +39 -0
  19. data/lib/pg_reports/definitions/system/wraparound_risk.yml +31 -0
  20. data/lib/pg_reports/definitions/tables/tables_without_pk.yml +28 -0
  21. data/lib/pg_reports/engine.rb +6 -0
  22. data/lib/pg_reports/modules/indexes.rb +3 -0
  23. data/lib/pg_reports/modules/queries.rb +1 -0
  24. data/lib/pg_reports/modules/system.rb +27 -0
  25. data/lib/pg_reports/modules/tables.rb +1 -0
  26. data/lib/pg_reports/query_monitor.rb +64 -32
  27. data/lib/pg_reports/sql/indexes/fk_without_indexes.sql +23 -0
  28. data/lib/pg_reports/sql/indexes/index_correlation.sql +27 -0
  29. data/lib/pg_reports/sql/indexes/inefficient_indexes.sql +22 -0
  30. data/lib/pg_reports/sql/queries/temp_file_queries.sql +16 -0
  31. data/lib/pg_reports/sql/system/checkpoint_stats.sql +20 -0
  32. data/lib/pg_reports/sql/system/checkpoint_stats_legacy.sql +19 -0
  33. data/lib/pg_reports/sql/system/wraparound_risk.sql +21 -0
  34. data/lib/pg_reports/sql/tables/tables_without_pk.sql +20 -0
  35. data/lib/pg_reports/version.rb +1 -1
  36. data/lib/pg_reports.rb +5 -0
  37. 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
@@ -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)
@@ -13,6 +13,7 @@ module PgReports
13
13
  # - expensive_queries(limit: 20)
14
14
  # - missing_index_queries(limit: 20)
15
15
  # - low_cache_hit_queries(limit: 20, min_calls: 100)
16
+ # - temp_file_queries(limit: 20)
16
17
  # - all_queries(limit: 50)
17
18
 
18
19
  # Reset pg_stat_statements statistics
@@ -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
@@ -14,6 +14,7 @@ module PgReports
14
14
  # - row_counts(limit: 50)
15
15
  # - cache_hit_ratios(limit: 50)
16
16
  # - seq_scans(limit: 20)
17
+ # - tables_without_pk(limit: 50)
17
18
  # - recently_modified(limit: 20)
18
19
 
19
20
  private
@@ -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
- cache_read(CACHE_KEY_ENABLED) || false
31
+ @enabled
24
32
  end
25
33
 
26
34
  def session_id
27
- cache_read(CACHE_KEY_SESSION_ID)
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
- # Store state in cache so all processes can see it
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
- return unless enabled
196
-
197
- # Skip if should be filtered
198
- return if should_skip?(payload)
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
- # Extract source location
205
- source_location = extract_source_location
206
-
207
- # Build query entry
208
- query_entry = {
209
- type: "query",
210
- session_id: session_id,
211
- sql: sql,
212
- duration_ms: duration_ms,
213
- name: query_name,
214
- source_location: source_location,
215
- timestamp: Time.current.iso8601
216
- }
217
-
218
- add_to_buffer(query_entry)
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: 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;