active_record_query_counter 3.1.1 → 3.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2e1c73a61e42bb29aabf511c59f3009a49760a9173eab4686de8ecdf05c9e24b
4
- data.tar.gz: 973f7b3a3ea5d285a023eb3545ff2e86350f51b1bd19db668c3efdda5ce976d4
3
+ metadata.gz: daaa5b12132d82613fb803966073b0b55131f0e47b83c1d05e0e6dca63c3ba98
4
+ data.tar.gz: ede14e3f86b236a2d39009dfe84d930a0541eea70c1273d7346ff4ac40218154
5
5
  SHA512:
6
- metadata.gz: 36fc6f8d28bc037c472fb0090828dc545ed8b386f49fbb2222a60313d560efb521ee407da6fb2f9d9a28880a170c7eb00f1a3a644a6f7a3a7d701575da28b07b
7
- data.tar.gz: 4f5bbb92f7ea0001e5f902537ec3d41a617af9685efc6c3a15b8abb89ceacc9e35969b7ea0f5ecbe2b664fdd9ab71df50aa4e893a199c3aad8abbb891552c8b5
6
+ metadata.gz: 03c8d4e734928d45f7a95188e4a9dc805ef45dabef52d0144d4323f5100163c2603955d1ffa3e78f22178fec458932ea8f56ee67f346bb7a51eae83264de4cee
7
+ data.tar.gz: 38e3d4959598bc8a9d8cd80c06b07fa34485e9a812095be7783f7a8161e3bcf0e4f3aaa619a114fe8beac9b4251bfdc78b3e445b67e5e0fe21a505b865bb33b2
data/CHANGELOG.md CHANGED
@@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## 3.2.0
8
+
9
+ ### Added
10
+
11
+ - Connection setup time (time spent in the adapter's `connect!`, `reconnect!`, and `verify!` methods while running a query) is now measured and subtracted from the reported query time, so a query that triggers a reconnect after an idle period or a database failover is no longer reported as an inexplicably slow query. The time is reported separately as `:connection_time` in the `query_time` and `row_count` notification payloads.
12
+
13
+ ### Removed
14
+
15
+ - Support for ActiveRecord versions prior to 7.1.
16
+
7
17
  ## 3.1.1
8
18
 
9
19
  ### Fixed
data/README.md CHANGED
@@ -57,9 +57,11 @@ end
57
57
 
58
58
  ### Query Time
59
59
 
60
- The query time (`ActiveRecordQueryCounter.query_time` and the duration reported by the notifications) is **not** the raw wall clock time a query took. The wall clock time includes time the thread was not actually waiting on the database, such as GC pauses (which can be triggered by other threads and stop the world) and the Ruby CPU work of building the result objects. On a busy, multi-threaded server these can add up to seconds, making a trivial query look pathologically slow.
60
+ The query time (`ActiveRecordQueryCounter.query_time` and the duration reported by the notifications) is **not** the raw wall clock time a query took. The wall clock time includes time the thread was not actually waiting on the database, such as GC pauses (which can be triggered by other threads and stop the world), the Ruby CPU work of building the result objects, and the time spent establishing or re-establishing the database connection. On a busy, multi-threaded server these can add up to seconds, making a trivial query look pathologically slow.
61
61
 
62
- To report the time actually spent waiting on the database as closely as possible, the GC time and thread CPU time that elapsed while the query ran are subtracted from the wall clock time. The raw wall clock time is still available as `:elapsed_time` in the notification payloads.
62
+ To report the time actually spent waiting on the database as closely as possible, the connection setup time, GC time, and thread CPU time that elapsed while the query ran are subtracted from the wall clock time. The raw wall clock time is still available as `:elapsed_time` in the notification payloads.
63
+
64
+ Connection setup time is the wall clock time spent inside the adapter's `connect!`, `reconnect!`, and `verify!` methods while running the query. ActiveRecord (re)establishes and verifies connections lazily, from within the query execution path, so when a connection has gone stale — after an idle period, or a database failover (common with clustered databases such as Amazon Aurora) — the reconnect (DNS resolution, TCP connect, TLS handshake, and authentication) happens on the next query and is otherwise charged to it. This is reported separately as `:connection_time` in the notification payloads so these events are diagnosable rather than appearing as inexplicably slow queries.
63
65
 
64
66
  > [!NOTE]
65
67
  > Measuring GC time requires Ruby's GC total time measurement, which is enabled by default (`GC.measure_total_time`). Thread CPU time is measured via `Process::CLOCK_THREAD_CPUTIME_ID`; on platforms that do not provide it, CPU time is treated as zero.
@@ -118,8 +120,9 @@ Triggered when a query exceeds the query_time threshold with the payload:
118
120
  - `:elapsed_time` - The raw wall clock time the query took (in milliseconds).
119
121
  - `:gc_time` - The GC time that elapsed while the query ran (in milliseconds).
120
122
  - `:cpu_time` - The thread CPU time spent while the query ran (in milliseconds).
123
+ - `:connection_time` - The time spent establishing, verifying, or reconnecting the database connection while the query ran (in milliseconds).
121
124
 
122
- The duration of the notification event is the query time: the wall clock time with the GC time and CPU time subtracted out (see [Query Time](#query-time)). The raw wall clock time is still available as `:elapsed_time`.
125
+ The duration of the notification event is the query time: the wall clock time with the connection setup time, GC time, and CPU time subtracted out (see [Query Time](#query-time)). The raw wall clock time is still available as `:elapsed_time`.
123
126
 
124
127
  ##### 2. active_record_query_counter.row_count notification
125
128
 
@@ -132,6 +135,7 @@ Triggered when a query exceeds the row_count threshold with the payload:
132
135
  - `:elapsed_time` - The raw wall clock time the query took (in milliseconds).
133
136
  - `:gc_time` - The GC time that elapsed while the query ran (in milliseconds).
134
137
  - `:cpu_time` - The thread CPU time spent while the query ran (in milliseconds).
138
+ - `:connection_time` - The time spent establishing, verifying, or reconnecting the database connection while the query ran (in milliseconds).
135
139
 
136
140
  ##### 3. active_record_query_counter.transaction_time notification
137
141
 
data/VERSION CHANGED
@@ -1 +1 @@
1
- 3.1.1
1
+ 3.2.0
@@ -34,9 +34,7 @@ Gem::Specification.new do |spec|
34
34
 
35
35
  spec.require_paths = ["lib"]
36
36
 
37
- spec.add_dependency "activerecord", ">= 6.0"
38
-
39
- spec.add_development_dependency "bundler"
37
+ spec.add_dependency "activerecord", ">= 7.1"
40
38
 
41
39
  spec.required_ruby_version = ">= 3.1"
42
40
  end
@@ -8,19 +8,28 @@ module ActiveRecordQueryCounter
8
8
  # measured and is treated as zero.
9
9
  CPU_CLOCK_ID = (Process::CLOCK_THREAD_CPUTIME_ID if defined?(Process::CLOCK_THREAD_CPUTIME_ID))
10
10
 
11
+ # Connection adapter methods that establish, verify, or reconnect the underlying database
12
+ # connection. When these run inside a query (for example when a stale connection is
13
+ # re-established after an idle period or a database failover), the wall clock time they
14
+ # consume is spent setting up the connection rather than executing the query. It is measured
15
+ # separately so it can be subtracted from the reported query time.
16
+ CONNECTION_SETUP_METHODS = %i[connect! reconnect! verify!].freeze
17
+
11
18
  class << self
12
19
  def inject(connection_class)
13
- # Rails 7.1+ uses internal_exec_query instead of exec_query.
14
- mod = (connection_class.method_defined?(:internal_exec_query) ? InternalExecQuery : ExecQuery)
15
- unless connection_class.include?(mod)
16
- connection_class.prepend(mod)
20
+ unless connection_class.include?(InternalExecQuery)
21
+ connection_class.prepend(InternalExecQuery)
22
+ end
23
+
24
+ unless connection_class.include?(ConnectionSetupExtension)
25
+ connection_class.prepend(ConnectionSetupExtension)
17
26
  end
18
27
  end
19
28
 
20
29
  # Measure a query by wrapping its execution. In addition to the wall clock time, the GC
21
- # time and thread CPU time spent while the query runs are captured so that the time
22
- # actually spent waiting on the database can be isolated from time lost to garbage
23
- # collection and Ruby VM work.
30
+ # time, thread CPU time, and connection setup time spent while the query runs are captured
31
+ # so that the time actually spent waiting on the database can be isolated from time lost to
32
+ # garbage collection, Ruby VM work, and (re)establishing the database connection.
24
33
  #
25
34
  # @param sql [String] the SQL statement being executed
26
35
  # @param name [String, nil] the name of the query
@@ -30,13 +39,18 @@ module ActiveRecordQueryCounter
30
39
  def measure_query(sql, name, binds)
31
40
  gc_start = GC.total_time
32
41
  cpu_start = current_cpu_time
42
+ previous_timer = ActiveRecordQueryCounter.start_connection_timer
33
43
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
34
- result = yield
44
+ begin
45
+ result = yield
46
+ ensure
47
+ connection_time = ActiveRecordQueryCounter.stop_connection_timer(previous_timer)
48
+ end
35
49
  if result.is_a?(ActiveRecord::Result)
36
50
  end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
37
51
  cpu_time = current_cpu_time - cpu_start
38
52
  gc_time = (GC.total_time - gc_start) / 1_000_000_000.0
39
- ActiveRecordQueryCounter.add_query(sql, name, binds, result.length, start_time, end_time, gc_time, cpu_time)
53
+ ActiveRecordQueryCounter.add_query(sql, name, binds, result.length, start_time, end_time, gc_time, cpu_time, connection_time)
40
54
  end
41
55
  result
42
56
  end
@@ -51,15 +65,27 @@ module ActiveRecordQueryCounter
51
65
  end
52
66
  end
53
67
 
54
- module ExecQuery
55
- def exec_query(sql, name = nil, binds = [], **kwargs)
68
+ module InternalExecQuery
69
+ def internal_exec_query(sql, name = nil, binds = [], **kwargs)
56
70
  ConnectionAdapterExtension.measure_query(sql, name, binds) { super }
57
71
  end
58
72
  end
59
73
 
60
- module InternalExecQuery
61
- def internal_exec_query(sql, name = nil, binds = [], **kwargs)
62
- ConnectionAdapterExtension.measure_query(sql, name, binds) { super }
74
+ # Module prepended to the connection adapter to measure the wall clock time spent
75
+ # establishing, verifying, or reconnecting the database connection while a query is running
76
+ # (see {CONNECTION_SETUP_METHODS}). The measured time is accumulated on the current query's
77
+ # connection timer so it can be subtracted from the reported query time.
78
+ module ConnectionSetupExtension
79
+ def connect!(...)
80
+ ActiveRecordQueryCounter.measure_connection_setup { super }
81
+ end
82
+
83
+ def reconnect!(...)
84
+ ActiveRecordQueryCounter.measure_connection_setup { super }
85
+ end
86
+
87
+ def verify!(...)
88
+ ActiveRecordQueryCounter.measure_connection_setup { super }
63
89
  end
64
90
  end
65
91
  end
@@ -72,10 +72,11 @@ module ActiveRecordQueryCounter
72
72
  # Increment the query counters.
73
73
  #
74
74
  # The reported query time is the wall clock time spent executing the query with the GC
75
- # time and Ruby thread CPU time subtracted out so that it reflects the time actually
76
- # spent waiting on the database as closely as possible (see {.database_query_time}). This
77
- # query time, rather than the raw wall clock time, is what is accumulated, compared against
78
- # the threshold, and used as the duration of the emitted notification.
75
+ # time, Ruby thread CPU time, and connection setup time subtracted out so that it reflects
76
+ # the time actually spent waiting on the database as closely as possible (see
77
+ # {.database_query_time}). This query time, rather than the raw wall clock time, is what is
78
+ # accumulated, compared against the threshold, and used as the duration of the emitted
79
+ # notification.
79
80
  #
80
81
  # @param sql [String] the SQL statement that was executed
81
82
  # @param name [String, nil] the name of the query
@@ -85,16 +86,18 @@ module ActiveRecordQueryCounter
85
86
  # @param end_time [Float] the monotonic time when the query ended
86
87
  # @param gc_time [Float] the GC time in seconds that elapsed while the query ran
87
88
  # @param cpu_time [Float] the thread CPU time in seconds spent while the query ran
89
+ # @param connection_time [Float] the time in seconds spent establishing, verifying, or
90
+ # reconnecting the database connection while the query ran
88
91
  # @return [void]
89
92
  # @api private
90
- def add_query(sql, name, binds, row_count, start_time, end_time, gc_time, cpu_time)
93
+ def add_query(sql, name, binds, row_count, start_time, end_time, gc_time, cpu_time, connection_time = 0.0)
91
94
  return if IGNORED_STATEMENTS.include?(name)
92
95
 
93
96
  counter = current_counter
94
97
  return unless counter.is_a?(Counter)
95
98
 
96
99
  elapsed_time = end_time - start_time
97
- query_time = database_query_time(elapsed_time, gc_time, cpu_time)
100
+ query_time = database_query_time(elapsed_time, gc_time, cpu_time, connection_time)
98
101
  counter.query_count += 1
99
102
  counter.row_count += row_count
100
103
  counter.query_time += query_time
@@ -107,14 +110,14 @@ module ActiveRecordQueryCounter
107
110
  query_time_threshold = counter.thresholds.query_time || -1
108
111
  if query_time_threshold.between?(0, query_time)
109
112
  trace = backtrace
110
- payload = notification_payload(sql: sql, binds: binds, row_count: row_count, trace: trace, elapsed_time: elapsed_time, gc_time: gc_time, cpu_time: cpu_time)
113
+ payload = notification_payload(sql: sql, binds: binds, row_count: row_count, trace: trace, elapsed_time: elapsed_time, gc_time: gc_time, cpu_time: cpu_time, connection_time: connection_time)
111
114
  send_notification("query_time", start_time, notification_end_time, **payload)
112
115
  end
113
116
 
114
117
  row_count_threshold = counter.thresholds.row_count || -1
115
118
  if row_count_threshold.between?(0, row_count)
116
119
  trace ||= backtrace
117
- payload = notification_payload(sql: sql, binds: binds, row_count: row_count, trace: trace, elapsed_time: elapsed_time, gc_time: gc_time, cpu_time: cpu_time)
120
+ payload = notification_payload(sql: sql, binds: binds, row_count: row_count, trace: trace, elapsed_time: elapsed_time, gc_time: gc_time, cpu_time: cpu_time, connection_time: connection_time)
118
121
  send_notification("row_count", start_time, notification_end_time, **payload)
119
122
  end
120
123
  end
@@ -149,6 +152,54 @@ module ActiveRecordQueryCounter
149
152
  counter.rollback_count += 1
150
153
  end
151
154
 
155
+ # Begin measuring the time spent establishing, verifying, or reconnecting the database
156
+ # connection for a single query. Returns the timer that was previously in effect so it can
157
+ # be restored by {.stop_connection_timer}; this keeps nested queries (should they ever
158
+ # occur) from leaking connection time into one another.
159
+ #
160
+ # @return [Object, nil] the previous connection timer
161
+ # @api private
162
+ def start_connection_timer
163
+ previous_timer = connection_timer
164
+ self.connection_timer = {elapsed: 0.0, measuring: false}
165
+ previous_timer
166
+ end
167
+
168
+ # Finish measuring connection setup time for the current query and restore the previously
169
+ # active timer.
170
+ #
171
+ # @param previous_timer [Object, nil] the timer returned by {.start_connection_timer}
172
+ # @return [Float] the connection setup time in seconds accumulated for the query
173
+ # @api private
174
+ def stop_connection_timer(previous_timer)
175
+ timer = connection_timer
176
+ self.connection_timer = previous_timer
177
+ timer ? timer[:elapsed] : 0.0
178
+ end
179
+
180
+ # Measure the wall clock time a connection setup operation (connect, reconnect, or verify)
181
+ # takes and accumulate it onto the current query's connection timer. When no query is being
182
+ # measured, or when a connection setup operation is already being measured (for example when
183
+ # `verify!` delegates to `reconnect!`), the block is yielded without recording so the
184
+ # interval is only counted once.
185
+ #
186
+ # @yield the connection setup operation
187
+ # @return [Object] the result of the block
188
+ # @api private
189
+ def measure_connection_setup
190
+ timer = connection_timer
191
+ return yield if timer.nil? || timer[:measuring]
192
+
193
+ timer[:measuring] = true
194
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
195
+ begin
196
+ yield
197
+ ensure
198
+ timer[:elapsed] += Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
199
+ timer[:measuring] = false
200
+ end
201
+ end
202
+
152
203
  # Return the number of queries that have been counted within the current block.
153
204
  # Returns nil if not inside a block where queries are being counted.
154
205
  #
@@ -295,24 +346,24 @@ module ActiveRecordQueryCounter
295
346
 
296
347
  private
297
348
 
298
- # The counter is stored in ActiveSupport::IsolatedExecutionState when available so that
299
- # it follows the application's configured isolation level (thread or fiber). The fallback
300
- # for ActiveSupport 6.x uses Thread.current, which is fiber-local, so on those versions
301
- # queries executed in a fiber spawned inside the block are not counted.
349
+ # The counter is stored in ActiveSupport::IsolatedExecutionState so that it follows the
350
+ # application's configured isolation level (thread or fiber).
302
351
  def current_counter
303
- if defined?(ActiveSupport::IsolatedExecutionState)
304
- ActiveSupport::IsolatedExecutionState[:active_record_query_counter]
305
- else
306
- Thread.current[:active_record_query_counter]
307
- end
352
+ ActiveSupport::IsolatedExecutionState[:active_record_query_counter]
308
353
  end
309
354
 
310
355
  def current_counter=(counter)
311
- if defined?(ActiveSupport::IsolatedExecutionState)
312
- ActiveSupport::IsolatedExecutionState[:active_record_query_counter] = counter
313
- else
314
- Thread.current[:active_record_query_counter] = counter
315
- end
356
+ ActiveSupport::IsolatedExecutionState[:active_record_query_counter] = counter
357
+ end
358
+
359
+ # The connection timer accumulates the connection setup time for the query currently being
360
+ # measured. It is stored with the same isolation as the counter (see {#current_counter}).
361
+ def connection_timer
362
+ ActiveSupport::IsolatedExecutionState[:active_record_query_counter_connection_timer]
363
+ end
364
+
365
+ def connection_timer=(timer)
366
+ ActiveSupport::IsolatedExecutionState[:active_record_query_counter_connection_timer] = timer
316
367
  end
317
368
 
318
369
  def send_notification(name, start_time, end_time, payload = {})
@@ -320,7 +371,7 @@ module ActiveRecordQueryCounter
320
371
  ActiveSupport::Notifications.publish("active_record_query_counter.#{name}", start_time, end_time, id, payload)
321
372
  end
322
373
 
323
- def notification_payload(sql:, binds:, row_count:, trace:, elapsed_time:, gc_time:, cpu_time:)
374
+ def notification_payload(sql:, binds:, row_count:, trace:, elapsed_time:, gc_time:, cpu_time:, connection_time:)
324
375
  {
325
376
  sql: sql,
326
377
  binds: binds,
@@ -328,12 +379,18 @@ module ActiveRecordQueryCounter
328
379
  trace: trace,
329
380
  elapsed_time: (elapsed_time * 1000.0).round(6),
330
381
  gc_time: (gc_time * 1000.0).round(6),
331
- cpu_time: (cpu_time * 1000.0).round(6)
382
+ cpu_time: (cpu_time * 1000.0).round(6),
383
+ connection_time: (connection_time * 1000.0).round(6)
332
384
  }
333
385
  end
334
386
 
335
- # Estimate the time spent waiting on the database by subtracting the GC time and thread CPU
336
- # time from the wall clock time the query took.
387
+ # Estimate the time spent waiting on the database by subtracting the connection setup time,
388
+ # GC time, and thread CPU time from the wall clock time the query took.
389
+ #
390
+ # The connection setup time is a measured sub-interval of the wall clock time that was spent
391
+ # establishing, verifying, or reconnecting the database connection rather than executing the
392
+ # query, so it is removed first. This is the time that inflates a trivial query into a
393
+ # multi-second one after an idle period or a database failover.
337
394
  #
338
395
  # The GC time and CPU time normally measure distinct, non-overlapping intervals: a GC pause
339
396
  # triggered by another thread happens while this thread is parked waiting on the database
@@ -347,13 +404,18 @@ module ActiveRecordQueryCounter
347
404
  # @param elapsed_time [Float] the wall clock time the query took in seconds
348
405
  # @param gc_time [Float] the GC time in seconds that elapsed while the query ran
349
406
  # @param cpu_time [Float] the thread CPU time in seconds spent while the query ran
407
+ # @param connection_time [Float] the time in seconds spent establishing, verifying, or
408
+ # reconnecting the database connection while the query ran
350
409
  # @return [Float] the estimated database time in seconds
351
- def database_query_time(elapsed_time, gc_time, cpu_time)
410
+ def database_query_time(elapsed_time, gc_time, cpu_time, connection_time = 0.0)
352
411
  return 0.0 if elapsed_time <= 0.0
353
412
 
354
- query_time = elapsed_time - (gc_time + cpu_time)
355
- query_time = elapsed_time - [gc_time, cpu_time].max if query_time.negative?
356
- query_time.clamp(0.0, elapsed_time)
413
+ wait_time = (elapsed_time - connection_time).clamp(0.0, elapsed_time)
414
+ return 0.0 if wait_time <= 0.0
415
+
416
+ query_time = wait_time - (gc_time + cpu_time)
417
+ query_time = wait_time - [gc_time, cpu_time].max if query_time.negative?
418
+ query_time.clamp(0.0, wait_time)
357
419
  end
358
420
 
359
421
  def backtrace
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_record_query_counter
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.1.1
4
+ version: 3.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Durand
@@ -15,28 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '6.0'
18
+ version: '7.1'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: '6.0'
26
- - !ruby/object:Gem::Dependency
27
- name: bundler
28
- requirement: !ruby/object:Gem::Requirement
29
- requirements:
30
- - - ">="
31
- - !ruby/object:Gem::Version
32
- version: '0'
33
- type: :development
34
- prerelease: false
35
- version_requirements: !ruby/object:Gem::Requirement
36
- requirements:
37
- - - ">="
38
- - !ruby/object:Gem::Version
39
- version: '0'
25
+ version: '7.1'
40
26
  email:
41
27
  - bbdurand@gmail.com
42
28
  executables: []