txnap 0.1.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 +7 -0
- data/Appraisals +9 -0
- data/LICENSE.txt +21 -0
- data/README.md +233 -0
- data/Rakefile +9 -0
- data/benchmark/transaction.rb +54 -0
- data/gemfiles/rails_7_2.gemfile +7 -0
- data/gemfiles/rails_8_0.gemfile +7 -0
- data/gemfiles/rails_main.gemfile +5 -0
- data/lib/txnap/call_site.rb +31 -0
- data/lib/txnap/configuration.rb +66 -0
- data/lib/txnap/errors.rb +7 -0
- data/lib/txnap/execution_state.rb +28 -0
- data/lib/txnap/formatter.rb +50 -0
- data/lib/txnap/lock_heuristics.rb +93 -0
- data/lib/txnap/notifier.rb +51 -0
- data/lib/txnap/railtie.rb +9 -0
- data/lib/txnap/registry.rb +51 -0
- data/lib/txnap/report.rb +58 -0
- data/lib/txnap/sql_event_classifier.rb +26 -0
- data/lib/txnap/subscribers/sql_subscriber.rb +76 -0
- data/lib/txnap/subscribers/transaction_subscriber.rb +44 -0
- data/lib/txnap/trace.rb +124 -0
- data/lib/txnap/version.rb +5 -0
- data/lib/txnap.rb +82 -0
- data/script/event_probe.rb +94 -0
- metadata +202 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e25e7973412d6c47a46bd8200327ab03514e2487dd17b806be084ff4bed447e3
|
|
4
|
+
data.tar.gz: 905642c31d101baaf851397e3888efda9b1cea8ffef45f92d50fa15536ca2718
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 8519156462359a000ed5d280a5401aeca9d7a1c598abc90d7fcdae0286f5372b2a2f5346e077bae29b8e9be91a85be747268bd88e533ef9e789c9db1422dedae
|
|
7
|
+
data.tar.gz: 19c6b62e818a98cdb2188553b8f18fa574dcae81b059dcb6d202a30d1dd5e53a6dd29500853124c3e939cd0729c51badd64137a6b83eca5e972e7b796f5de46e
|
data/Appraisals
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yudai Takada
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# Txnap
|
|
2
|
+
|
|
3
|
+
Catch transactions napping with locks held.
|
|
4
|
+
|
|
5
|
+
`txnap` detects time spent away from the database while an
|
|
6
|
+
Active Record transaction is materially open.
|
|
7
|
+
|
|
8
|
+
A transaction can take one second even when its SQL totals only 30 ms. The
|
|
9
|
+
remaining 970 ms may be Ruby computation, file I/O, an external request, a
|
|
10
|
+
`sleep`, or Fiber scheduling while row locks are still held. Slow-query tools
|
|
11
|
+
do not expose that gap.
|
|
12
|
+
|
|
13
|
+
This gem measures from the completion of the real `BEGIN` through the start of
|
|
14
|
+
`COMMIT` or `ROLLBACK`, then reports the longest interval between database
|
|
15
|
+
events. It never changes transaction behavior.
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
The gem has not been published yet. Until the first release, install it from
|
|
20
|
+
Git:
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
gem "txnap", github: "ydah/txnap"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
It supports Active Record 7.2 and 8.0. Rails applications install the
|
|
27
|
+
notification subscribers automatically.
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
Configure the detector in an initializer:
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
Txnap.configure do |config|
|
|
35
|
+
config.mode = :log
|
|
36
|
+
config.gap_threshold = 0.1
|
|
37
|
+
config.min_transaction_duration = 0.0
|
|
38
|
+
config.only_with_locks = false
|
|
39
|
+
config.capture_call_sites = :always
|
|
40
|
+
config.ignore_if = nil
|
|
41
|
+
end
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Non-Rails Active Record applications must also call:
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
Txnap.install!
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
A report looks like:
|
|
51
|
+
|
|
52
|
+
```text
|
|
53
|
+
Txnap::IdleGapDetected
|
|
54
|
+
|
|
55
|
+
Transaction held for 1,024 ms (BEGIN → COMMIT)
|
|
56
|
+
Database time: 31 ms
|
|
57
|
+
Longest non-database gap: 742 ms
|
|
58
|
+
|
|
59
|
+
Gap occurred between:
|
|
60
|
+
after: UPDATE "users" SET ... (app/services/checkout.rb:88)
|
|
61
|
+
before: INSERT INTO "audit_logs" ... (app/services/checkout.rb:131)
|
|
62
|
+
|
|
63
|
+
Locks possibly held during the gap:
|
|
64
|
+
UPDATE users (row lock) app/services/checkout.rb:88
|
|
65
|
+
|
|
66
|
+
Move slow non-database work (I/O, rendering, external calls,
|
|
67
|
+
heavy computation) outside the transaction.
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Configuration
|
|
71
|
+
|
|
72
|
+
| Setting | Default | Description |
|
|
73
|
+
| --- | --- | --- |
|
|
74
|
+
| `mode` | `:log` | `:log` writes a warning. `:raise` is intended for tests. |
|
|
75
|
+
| `gap_threshold` | `0.1` | Inclusive longest-gap threshold in seconds. |
|
|
76
|
+
| `min_transaction_duration` | `0.0` | Ignore shorter materialized transactions. |
|
|
77
|
+
| `only_with_locks` | `false` | Report only when the longest gap follows likely lock acquisition. |
|
|
78
|
+
| `capture_call_sites` | `:always` | `:always`, `:sampled`, or `:off`. |
|
|
79
|
+
| `ignore_if` | `nil` | A callable receiving the report; truthy suppresses notification. |
|
|
80
|
+
| `logger` | Rails logger | Optional logger override. |
|
|
81
|
+
|
|
82
|
+
`:sampled` calls `caller_locations` only when an SQL event creates a new
|
|
83
|
+
longest-gap candidate. This lowers overhead, but one side of a reported gap can
|
|
84
|
+
have no call site. `:off` retains SQL text without application locations.
|
|
85
|
+
|
|
86
|
+
`:raise` runs from the `transaction.active_record` finish notification. A
|
|
87
|
+
commit has already succeeded by this point, so the exception cannot roll it
|
|
88
|
+
back. The exception message states this explicitly.
|
|
89
|
+
|
|
90
|
+
### Suppression and filtering
|
|
91
|
+
|
|
92
|
+
Suppress a known-safe block in the current isolated execution context:
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
Txnap.suppress do
|
|
96
|
+
ApplicationRecord.transaction { rebuild_local_cache }
|
|
97
|
+
end
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Or filter completed reports:
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
Txnap.configure do |config|
|
|
104
|
+
config.ignore_if = ->(report) {
|
|
105
|
+
report.transaction_duration < 0.5 && report.locks.empty?
|
|
106
|
+
}
|
|
107
|
+
end
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Notifications and APM integration
|
|
111
|
+
|
|
112
|
+
Every accepted report emits `idle_gap.txnap`. Numeric durations
|
|
113
|
+
are unrounded milliseconds; rounding happens only in log formatting.
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
ActiveSupport::Notifications.subscribe("idle_gap.txnap") do |event|
|
|
117
|
+
payload = event.payload
|
|
118
|
+
|
|
119
|
+
MyAPM.record_custom_event(
|
|
120
|
+
"ActiveRecordIdleGap",
|
|
121
|
+
transaction_ms: payload[:transaction_duration_ms],
|
|
122
|
+
database_ms: payload[:database_time_ms],
|
|
123
|
+
longest_gap_ms: payload[:longest_gap_ms],
|
|
124
|
+
sql_count: payload[:sql_count]
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
The payload also includes the gap's preceding and following SQL metadata, the
|
|
130
|
+
three longest gaps, likely locks, and the transaction outcome.
|
|
131
|
+
|
|
132
|
+
## What is and is not measured
|
|
133
|
+
|
|
134
|
+
- The origin is the finish of the actual `BEGIN` SQL notification.
|
|
135
|
+
- The endpoint is the start of `COMMIT` or `ROLLBACK`.
|
|
136
|
+
- Transaction control SQL contributes to database time.
|
|
137
|
+
- Schema notifications and query-cache hits are ignored as timeline
|
|
138
|
+
boundaries. Modern Rails marks cache hits with `payload[:cached] == true`;
|
|
139
|
+
their event name is not reliably `CACHE`.
|
|
140
|
+
- Savepoints join the real transaction timeline and do not create reports of
|
|
141
|
+
their own.
|
|
142
|
+
- Memory is bounded: each trace retains the previous event, the three longest
|
|
143
|
+
gaps, and at most 20 distinct lock candidates rather than all SQL.
|
|
144
|
+
|
|
145
|
+
Active Record lazily materializes transactions. Therefore this code does not
|
|
146
|
+
report the sleep:
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
ApplicationRecord.transaction do
|
|
150
|
+
sleep 0.3
|
|
151
|
+
User.create!
|
|
152
|
+
end
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
The database has no open transaction and holds no locks until `User.create!`
|
|
156
|
+
causes `BEGIN`. Excluding that pre-BEGIN time is intentional.
|
|
157
|
+
|
|
158
|
+
Lock reporting is heuristic. It recognizes `INSERT`, `UPDATE`, `DELETE`,
|
|
159
|
+
locking `SELECT`, `LOCK TABLE`, and common PostgreSQL/MySQL advisory-lock
|
|
160
|
+
functions. It does not parse primary keys or prove that a lock is still held.
|
|
161
|
+
|
|
162
|
+
Fiber or async scheduler waits count as gaps when the checked-out connection
|
|
163
|
+
remains in a transaction. That is intentional: another Fiber may run, but the
|
|
164
|
+
database session can still retain locks.
|
|
165
|
+
|
|
166
|
+
## Related safeguards
|
|
167
|
+
|
|
168
|
+
[Isolator](https://github.com/palkan/isolator) detects specific side effects
|
|
169
|
+
such as HTTP calls or job enqueueing inside transactions. This gem detects
|
|
170
|
+
elapsed time regardless of the work type, including CPU-only work and unknown
|
|
171
|
+
I/O. The tools are complementary.
|
|
172
|
+
|
|
173
|
+
For PostgreSQL, also configure
|
|
174
|
+
`idle_in_transaction_session_timeout` as a server-side last line of defense.
|
|
175
|
+
This gem provides application context and early visibility; it is not a
|
|
176
|
+
replacement for the database timeout.
|
|
177
|
+
|
|
178
|
+
## Compatibility
|
|
179
|
+
|
|
180
|
+
| Ruby | Active Record 7.2 | Active Record 8.0 |
|
|
181
|
+
| --- | --- | --- |
|
|
182
|
+
| 3.2 | supported | supported |
|
|
183
|
+
| 3.3 | supported | supported |
|
|
184
|
+
| 3.4 | supported | supported |
|
|
185
|
+
| 4.0 | supported | supported |
|
|
186
|
+
|
|
187
|
+
Rails 7.1 and earlier are not supported because the implementation relies on
|
|
188
|
+
the 7.2 transaction lifecycle notifications. SQLite is used for the main test
|
|
189
|
+
suite and PostgreSQL 16 for adapter and concurrent-connection smoke tests.
|
|
190
|
+
|
|
191
|
+
## Performance
|
|
192
|
+
|
|
193
|
+
The benchmark measures `transaction { one SQL }` before subscriptions, with
|
|
194
|
+
no-op notification subscribers, and with detection enabled:
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
bundle exec ruby benchmark/transaction.rb
|
|
198
|
+
DATABASE_URL=postgresql://... bundle exec ruby benchmark/transaction.rb
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
On Ruby 4.0.0, Active Record 8.0.5, and a local PostgreSQL 16 Docker container,
|
|
202
|
+
with call-site capture disabled, a five-second run measured 1,473 i/s without
|
|
203
|
+
the detector and 1,417 i/s with it: **3.78% overhead**. A SQLite in-memory
|
|
204
|
+
microbenchmark is dominated by notification and Ruby bookkeeping and is not
|
|
205
|
+
representative of networked database latency.
|
|
206
|
+
|
|
207
|
+
Re-run the benchmark on the same database topology and Ruby version used by
|
|
208
|
+
the application. Enable `:sampled` or `:off` call-site capture in
|
|
209
|
+
latency-sensitive production environments.
|
|
210
|
+
|
|
211
|
+
## Development
|
|
212
|
+
|
|
213
|
+
Install dependencies and run the default RSpec plus Standard Ruby checks:
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
bundle install
|
|
217
|
+
bundle exec rake
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Run both supported Active Record families:
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
bundle exec appraisal rails_7_2 rspec
|
|
224
|
+
bundle exec appraisal rails_8_0 rspec
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Use `TIME_SCALE=1.5` to relax timing windows on slower CI workers. Event payload
|
|
228
|
+
spike results and reproduction commands are in
|
|
229
|
+
[`spec/fixtures/event_payloads.md`](spec/fixtures/event_payloads.md).
|
|
230
|
+
|
|
231
|
+
## License
|
|
232
|
+
|
|
233
|
+
The gem is available under the [MIT License](LICENSE.txt).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "benchmark/ips"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
require "active_record"
|
|
7
|
+
|
|
8
|
+
database_config = ENV["DATABASE_URL"] || {adapter: "sqlite3", database: ":memory:"}
|
|
9
|
+
ActiveRecord::Base.establish_connection(database_config)
|
|
10
|
+
connection = ActiveRecord::Base.connection
|
|
11
|
+
connection.select_value("SELECT 1")
|
|
12
|
+
|
|
13
|
+
warmup = ENV.fetch("WARMUP", "2").to_i
|
|
14
|
+
time = ENV.fetch("TIME", "5").to_i
|
|
15
|
+
|
|
16
|
+
measure = lambda do |label|
|
|
17
|
+
Benchmark.ips do |benchmark|
|
|
18
|
+
benchmark.config(warmup: warmup, time: time)
|
|
19
|
+
benchmark.report(label) do
|
|
20
|
+
connection.transaction { connection.select_value("SELECT 1") }
|
|
21
|
+
end
|
|
22
|
+
end.entries.fetch(0).ips
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
baseline_ips = measure.call("without txnap")
|
|
26
|
+
|
|
27
|
+
require_relative "../lib/txnap"
|
|
28
|
+
|
|
29
|
+
notification_subscriptions = %w[
|
|
30
|
+
sql.active_record
|
|
31
|
+
start_transaction.active_record
|
|
32
|
+
transaction.active_record
|
|
33
|
+
].map do |event_name|
|
|
34
|
+
ActiveSupport::Notifications.monotonic_subscribe(event_name) { nil }
|
|
35
|
+
end
|
|
36
|
+
notification_ips = measure.call("notification subscribers only")
|
|
37
|
+
notification_subscriptions.each { |subscription| ActiveSupport::Notifications.unsubscribe(subscription) }
|
|
38
|
+
|
|
39
|
+
Txnap.configure do |config|
|
|
40
|
+
config.gap_threshold = 60.0
|
|
41
|
+
config.capture_call_sites = :off
|
|
42
|
+
end
|
|
43
|
+
Txnap.install!
|
|
44
|
+
|
|
45
|
+
instrumented_ips = measure.call("with txnap")
|
|
46
|
+
overhead = ((baseline_ips - instrumented_ips) / baseline_ips) * 100
|
|
47
|
+
|
|
48
|
+
puts format(
|
|
49
|
+
"baseline=%.1f i/s notifications=%.1f i/s instrumented=%.1f i/s overhead=%+.2f%%",
|
|
50
|
+
baseline_ips,
|
|
51
|
+
notification_ips,
|
|
52
|
+
instrumented_ips,
|
|
53
|
+
overhead
|
|
54
|
+
)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Txnap
|
|
4
|
+
module CallSite
|
|
5
|
+
LIB_ROOT = File.expand_path("..", __dir__)
|
|
6
|
+
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def capture(locations = caller_locations(2, 80))
|
|
10
|
+
location = locations.find { |candidate| application_location?(candidate) }
|
|
11
|
+
return unless location
|
|
12
|
+
|
|
13
|
+
"#{display_path(location)}:#{location.lineno}"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def application_location?(location)
|
|
17
|
+
path = location.absolute_path || location.path
|
|
18
|
+
return false if path.nil? || path.start_with?(LIB_ROOT)
|
|
19
|
+
return false if path.include?("/gems/activerecord-") || path.include?("/gems/activesupport-")
|
|
20
|
+
return false if path.include?("/gems/bundler-") || path.start_with?("<internal:")
|
|
21
|
+
|
|
22
|
+
true
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def display_path(location)
|
|
26
|
+
path = location.absolute_path || location.path
|
|
27
|
+
working_directory = "#{Dir.pwd}/"
|
|
28
|
+
path.start_with?(working_directory) ? path.delete_prefix(working_directory) : path
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Txnap
|
|
4
|
+
class Configuration
|
|
5
|
+
MODES = %i[log raise].freeze
|
|
6
|
+
CAPTURE_CALL_SITES = %i[always sampled off].freeze
|
|
7
|
+
|
|
8
|
+
attr_accessor :gap_threshold,
|
|
9
|
+
:min_transaction_duration,
|
|
10
|
+
:only_with_locks,
|
|
11
|
+
:capture_call_sites,
|
|
12
|
+
:ignore_if,
|
|
13
|
+
:logger
|
|
14
|
+
attr_reader :mode
|
|
15
|
+
|
|
16
|
+
def initialize
|
|
17
|
+
@mode = :log
|
|
18
|
+
@gap_threshold = 0.1
|
|
19
|
+
@min_transaction_duration = 0.0
|
|
20
|
+
@only_with_locks = false
|
|
21
|
+
@capture_call_sites = :always
|
|
22
|
+
@ignore_if = nil
|
|
23
|
+
@logger = nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def mode=(value)
|
|
27
|
+
@mode = value&.to_sym
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def validate!
|
|
31
|
+
validate_choice!(:mode, mode, MODES)
|
|
32
|
+
validate_non_negative_number!(:gap_threshold, gap_threshold)
|
|
33
|
+
validate_non_negative_number!(:min_transaction_duration, min_transaction_duration)
|
|
34
|
+
validate_boolean!(:only_with_locks, only_with_locks)
|
|
35
|
+
validate_choice!(:capture_call_sites, capture_call_sites, CAPTURE_CALL_SITES)
|
|
36
|
+
validate_callable!(:ignore_if, ignore_if)
|
|
37
|
+
self
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def validate_choice!(name, value, choices)
|
|
43
|
+
return if choices.include?(value)
|
|
44
|
+
|
|
45
|
+
raise ConfigurationError, "#{name} must be one of: #{choices.join(", ")}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def validate_non_negative_number!(name, value)
|
|
49
|
+
return if value.is_a?(Numeric) && value.finite? && value >= 0
|
|
50
|
+
|
|
51
|
+
raise ConfigurationError, "#{name} must be a finite, non-negative number"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def validate_boolean!(name, value)
|
|
55
|
+
return if value == true || value == false
|
|
56
|
+
|
|
57
|
+
raise ConfigurationError, "#{name} must be true or false"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def validate_callable!(name, value)
|
|
61
|
+
return if value.nil? || value.respond_to?(:call)
|
|
62
|
+
|
|
63
|
+
raise ConfigurationError, "#{name} must respond to call"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
data/lib/txnap/errors.rb
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Txnap
|
|
4
|
+
module ExecutionState
|
|
5
|
+
KEY = :txnap_suppression_depth
|
|
6
|
+
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def suppress
|
|
10
|
+
state[KEY] = suppression_depth + 1
|
|
11
|
+
yield
|
|
12
|
+
ensure
|
|
13
|
+
state[KEY] = [suppression_depth - 1, 0].max
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def suppressed?
|
|
17
|
+
suppression_depth.positive?
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def suppression_depth
|
|
21
|
+
state[KEY] || 0
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def state
|
|
25
|
+
ActiveSupport::IsolatedExecutionState
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Txnap
|
|
4
|
+
class Formatter
|
|
5
|
+
def call(report)
|
|
6
|
+
gap = report.longest_gap
|
|
7
|
+
lines = [
|
|
8
|
+
"Txnap::IdleGapDetected",
|
|
9
|
+
"",
|
|
10
|
+
"Transaction held for #{milliseconds(report.transaction_duration)} ms (BEGIN → #{report.outcome.to_s.upcase})",
|
|
11
|
+
"Database time: #{milliseconds(report.database_time)} ms",
|
|
12
|
+
"Longest non-database gap: #{milliseconds(gap.duration)} ms",
|
|
13
|
+
"",
|
|
14
|
+
"Gap occurred between:",
|
|
15
|
+
" after: #{event(gap.after)}",
|
|
16
|
+
" before: #{event(gap.before)}"
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
append_locks(lines, report.locks)
|
|
20
|
+
lines.concat([
|
|
21
|
+
"",
|
|
22
|
+
"Move slow non-database work (I/O, rendering, external calls,",
|
|
23
|
+
"heavy computation) outside the transaction."
|
|
24
|
+
])
|
|
25
|
+
lines.join("\n")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def milliseconds(seconds)
|
|
31
|
+
(seconds * 1000).round
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def event(metadata)
|
|
35
|
+
return "(transaction boundary)" unless metadata
|
|
36
|
+
|
|
37
|
+
[metadata.sql, metadata.call_site && "(#{metadata.call_site})"].compact.join(" ")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def append_locks(lines, locks)
|
|
41
|
+
return if locks.empty?
|
|
42
|
+
|
|
43
|
+
lines.concat(["", "Locks possibly held during the gap:"])
|
|
44
|
+
locks.each do |lock|
|
|
45
|
+
location = lock.call_site && " #{lock.call_site}"
|
|
46
|
+
lines << " #{lock.statement} #{lock.table} (#{lock.kind})#{location}"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Txnap
|
|
4
|
+
module LockHeuristics
|
|
5
|
+
IDENTIFIER_PART = /(?:"(?:[^"]|"")*"|`(?:[^`]|``)*`|\[[^\]]+\]|[a-z_][a-z0-9_$]*)/i
|
|
6
|
+
QUALIFIED_IDENTIFIER = /#{IDENTIFIER_PART}(?:\s*\.\s*#{IDENTIFIER_PART})*/
|
|
7
|
+
ADVISORY_FUNCTION = /
|
|
8
|
+
(?:
|
|
9
|
+
pg_(?:try_)?advisory_(?:xact_)?lock(?:_shared)?
|
|
10
|
+
|
|
|
11
|
+
get_lock
|
|
12
|
+
)
|
|
13
|
+
/ix
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def detect(sql, call_site: nil)
|
|
18
|
+
statement = strip_leading_comments(Array(sql).join(" "))
|
|
19
|
+
return if statement.empty?
|
|
20
|
+
|
|
21
|
+
operation, table, kind =
|
|
22
|
+
case statement
|
|
23
|
+
when /\AINSERT(?:\s+OR\s+\w+)?\s+INTO\s+(#{QUALIFIED_IDENTIFIER})/io
|
|
24
|
+
["INSERT", normalize_identifier(Regexp.last_match(1)), "row lock"]
|
|
25
|
+
when /\AUPDATE\s+(?:ONLY\s+)?(#{QUALIFIED_IDENTIFIER})/io
|
|
26
|
+
["UPDATE", normalize_identifier(Regexp.last_match(1)), "row lock"]
|
|
27
|
+
when /\ADELETE\s+FROM\s+(?:ONLY\s+)?(#{QUALIFIED_IDENTIFIER})/io
|
|
28
|
+
["DELETE", normalize_identifier(Regexp.last_match(1)), "row lock"]
|
|
29
|
+
when /\ALOCK\s+TABLE\s+(?:ONLY\s+)?(#{QUALIFIED_IDENTIFIER})/io
|
|
30
|
+
["LOCK TABLE", normalize_identifier(Regexp.last_match(1)), "table lock"]
|
|
31
|
+
else
|
|
32
|
+
detect_select_or_advisory(statement)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
return unless operation
|
|
36
|
+
|
|
37
|
+
LockCandidate.new(
|
|
38
|
+
statement: operation,
|
|
39
|
+
table: table,
|
|
40
|
+
kind: kind,
|
|
41
|
+
call_site: call_site
|
|
42
|
+
).freeze
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def detect_select_or_advisory(statement)
|
|
46
|
+
if statement.match?(/\ASELECT\b/im) &&
|
|
47
|
+
statement.match?(/\bFOR\s+(?:NO\s+KEY\s+)?(?:UPDATE|SHARE|KEY\s+SHARE)\b/im)
|
|
48
|
+
table = statement[/\bFROM\s+(#{QUALIFIED_IDENTIFIER})/io, 1]
|
|
49
|
+
return ["SELECT", normalize_identifier(table), "row lock"] if table
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
function = statement[ADVISORY_FUNCTION]
|
|
53
|
+
return unless function
|
|
54
|
+
|
|
55
|
+
["ADVISORY LOCK", function.downcase, "advisory lock"]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def strip_leading_comments(sql)
|
|
59
|
+
remaining = sql.lstrip
|
|
60
|
+
|
|
61
|
+
loop do
|
|
62
|
+
stripped =
|
|
63
|
+
if remaining.start_with?("--")
|
|
64
|
+
remaining.sub(/\A--[^\n]*(?:\n|\z)/, "").lstrip
|
|
65
|
+
elsif remaining.start_with?("/*")
|
|
66
|
+
remaining.sub(/\A\/\*.*?\*\//m, "").lstrip
|
|
67
|
+
else
|
|
68
|
+
remaining
|
|
69
|
+
end
|
|
70
|
+
return remaining if stripped == remaining
|
|
71
|
+
|
|
72
|
+
remaining = stripped
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def normalize_identifier(identifier)
|
|
77
|
+
return unless identifier
|
|
78
|
+
|
|
79
|
+
identifier.split(/\s*\.\s*/).map do |part|
|
|
80
|
+
case part
|
|
81
|
+
when /\A"(.*)"\z/m
|
|
82
|
+
Regexp.last_match(1).gsub('""', '"')
|
|
83
|
+
when /\A`(.*)`\z/m
|
|
84
|
+
Regexp.last_match(1).gsub("``", "`")
|
|
85
|
+
when /\A\[(.*)\]\z/m
|
|
86
|
+
Regexp.last_match(1)
|
|
87
|
+
else
|
|
88
|
+
part
|
|
89
|
+
end
|
|
90
|
+
end.join(".")
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Txnap
|
|
4
|
+
class Notifier
|
|
5
|
+
EVENT_NAME = "idle_gap.txnap"
|
|
6
|
+
|
|
7
|
+
def initialize(configuration:, logger:)
|
|
8
|
+
@configuration = configuration
|
|
9
|
+
@logger = logger
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call(report)
|
|
13
|
+
return false unless report
|
|
14
|
+
return false unless report.longest_gap.duration >= configuration.gap_threshold
|
|
15
|
+
return false unless report.transaction_duration >= configuration.min_transaction_duration
|
|
16
|
+
return false if configuration.only_with_locks && report.locks.empty?
|
|
17
|
+
return false if ExecutionState.suppressed?
|
|
18
|
+
return false if ignored?(report)
|
|
19
|
+
|
|
20
|
+
ActiveSupport::Notifications.instrument(EVENT_NAME, report.to_h)
|
|
21
|
+
message = Formatter.new.call(report)
|
|
22
|
+
|
|
23
|
+
raise IdleGapDetected, raise_message(message, report) if configuration.mode == :raise
|
|
24
|
+
|
|
25
|
+
logger.warn(message)
|
|
26
|
+
true
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
attr_reader :configuration, :logger
|
|
32
|
+
|
|
33
|
+
def ignored?(report)
|
|
34
|
+
predicate = configuration.ignore_if
|
|
35
|
+
return false unless predicate
|
|
36
|
+
|
|
37
|
+
predicate.arity.zero? ? predicate.call : predicate.call(report)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def raise_message(message, report)
|
|
41
|
+
suffix =
|
|
42
|
+
if report.outcome.to_sym == :commit
|
|
43
|
+
"The transaction has already been committed; this exception cannot roll it back."
|
|
44
|
+
else
|
|
45
|
+
"The transaction has already finished; this exception cannot change its outcome."
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
"#{message}\n\n#{suffix}"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|