pg-locks-monitor 0.0.1 → 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 38fe7be18449c8a637a4f6a756228055f217f4e2fa1c47eb84609009c31793cf
4
- data.tar.gz: 19e9b7bcacde528b1b0c34d4c2c28f2f482e6573742fe10536af51cbeb68db75
3
+ metadata.gz: b9127ef19c571c1eda59fa9962c694c6a416f4d2875d6660091cb72479e09d98
4
+ data.tar.gz: 56a1e90355cad04a9834e2a6eafeb76c9633596a9e786ce639af838757c1e87e
5
5
  SHA512:
6
- metadata.gz: 9b947dcacbdaccc1d2ce67244c7c054882b9120a996ea3a8c7b3b99ae9e516065920201fa42d5c0f536fcef3296c86a6a4066c3319d464a3d726f19ca2e6e481
7
- data.tar.gz: ad98c65cf24043c9381579442731e74d26784df9886a6633913fc9c9fb1ba20e76470a53b12b11b24b588fa3d8014364194eca34f0b7c96caaa8e632e1edd66f
6
+ metadata.gz: e9d94a09139dd75cfc5672aa863e288ed6654fa4953cf00dbbd62a6ec63e63feb60c99208f0aa0dda2ce01ccadd9cd92b3b58d05200be53bb75579e44bf85f9d
7
+ data.tar.gz: 6f57a40ce88babd6fcdd176371462c4b6fa40c248da5472ae1cfdcdad685bc17f0a5a3bbf0f152e7de50d48dbc84a18a8443d1ce6ce90bf112278b506e81a839
data/README.md CHANGED
@@ -1,3 +1,200 @@
1
1
  # PG Locks Monitor [![Gem Version](https://badge.fury.io/rb/pg-locks-monitor.svg)](https://badge.fury.io/rb/pg-locks-monitor) [![GH Actions](https://github.com/pawurb/pg-locks-monitor/actions/workflows/ci.yml/badge.svg)](https://github.com/pawurb/pg-locks-monitor/actions)
2
2
 
3
- WIP
3
+ This gem allows to observe database locks generated by a Rails application. By default, locks data is not persisted anywhere in the PostgreSQL logs, so the only way to monitor it is via analyzing the transient state of the `pg_locks` metadata table. `pg-locks-monitor` is a simple tool that makes this process quick to implement and adjust to each app's individual requirements.
4
+
5
+ ## Usage
6
+
7
+ `PgLocksMonitor` class provides a `snapshot!` method, which notifies selected channels about database locks that match configured criteria.
8
+
9
+ Start by adding the gem to your Gemfile:
10
+
11
+ ```ruby
12
+ gem "pg-locks-monitor"
13
+ ```
14
+
15
+ Then run `bundle install` and `rake pg_locks_monitor:init`. It creates a default configuration file with the following settings:
16
+
17
+ `config/initializers/pg_locks_monitor.rb`
18
+ ```ruby
19
+ PgLocksMonitor.configure do |config|
20
+ config.locks_limit = 5
21
+
22
+ config.monitor_locks = true
23
+ config.locks_min_duration_ms = 200
24
+
25
+ config.monitor_blocking = true
26
+ config.blocking_min_duration_ms = 100
27
+
28
+ config.notify_logs = true
29
+
30
+ config.notify_slack = false
31
+ config.slack_webhook_url = ""
32
+ config.slack_channel = ""
33
+
34
+ config.notifier_class = PgLocksMonitor::DefaultNotifier
35
+ end
36
+ ```
37
+
38
+ - `locks_limit` - specify the max number of locks to report in a single notification
39
+ - `notify_locks` - observe database locks even if they don't conflict with a different SQL query
40
+ - `locks_min_duration_ms` - notify about locks that execeed this duration threshold in milliseconds
41
+ - `notify_blocking` - observe database locks which cause other SQL query to wait from them to release
42
+ - `blocking_min_duration_ms` - notify about blocking locks that execeed this duration threshold in milliseconds
43
+ - `notify_logs` - send notifications about detected locks using `Rails.logger.info` method
44
+ - `notify_slack` - send notifications about detected locks to the configured Slack channel
45
+ - `slack_webhook_url` - webhook necessary for Slack notification to work
46
+ - `slack_channel` - the name of the target Slack channel
47
+ - `notifier_class` - customizable notifier class
48
+
49
+
50
+ ## Testing the notification channels
51
+
52
+ Before configuring a recurring invocation of the `snapshot!` method, it's recommended to first manually trigger the notification to test the configured channels.
53
+
54
+ You can generate an _"artificial"_ blocking lock and observe it by running the following code in the Rails console:
55
+
56
+ ```ruby
57
+ user = User.last
58
+
59
+ Thread.new do
60
+ User.transaction do
61
+ user.update(email: "email-#{SecureRandom.hex(2)}@example.com")
62
+ sleep 5
63
+ raise ActiveRecord::Rollback
64
+ end
65
+ end
66
+
67
+ Thread.new do
68
+ User.transaction do
69
+ user.update(email: "email-#{SecureRandom.hex(2)}@example.com")
70
+ sleep 5
71
+ raise ActiveRecord::Rollback
72
+ end
73
+ end
74
+
75
+ sleep 0.5
76
+ PgLocksMonitor.snapshot!
77
+ ```
78
+
79
+ Please remember to adjust the update operation to match your app's schema.
80
+
81
+ As a result of running the above snippet, you should receive a notification about the acquired blocking database lock.
82
+
83
+ ### Sample notification
84
+
85
+ Received notifications contain data helpful in debugging the cause of long lasting-locks.
86
+
87
+ And here's a sample blocking lock notification:
88
+
89
+ ```ruby
90
+ [
91
+ {
92
+ # PID of the process which was blocking another query
93
+ "blocking_pid": 29,
94
+ # SQL query blocking other SQL query
95
+ "blocking_statement": "UPDATE \"users\" SET \"updated_at\" = $1 WHERE \"users\".\"id\" = $2 from/sidekiq_job:UserUpdater/",
96
+ # the duration of blocking SQL query
97
+ "blocking_duration": "PT0.972116S",
98
+ # app that triggered the blocking SQL query
99
+ "blocking_sql_app": "bin/sidekiq",
100
+
101
+ # PID of the process which was blocked by another query
102
+ "blocked_pid": 30,
103
+ # SQL query blocked by other SQL query
104
+ "blocked_statement": "UPDATE \"users\" SET \"last_active_at\" = $1 WHERE \"users\".\"id\" = $2 from/controller_with_namespace:UsersController,action:update/",
105
+ # the duration of the blocked SQL query
106
+ "blocked_duration": "PT0.483309S",
107
+ # app that triggered the blocked SQL query
108
+ "blocked_sql_app": "bin/puma"
109
+ }
110
+ ]
111
+ ```
112
+
113
+ This sample blocking notification shows than a query originating from the `UserUpdater` Sidekiq job is blocking an update operation on the same user for the `UsersController#update` action. Remember to configure the [marginalia gem](https://github.com/basecamp/marginalia) to enable these helpful query source annotations.
114
+
115
+ Here's a sample lock notification:
116
+
117
+ ```ruby
118
+ [
119
+ {
120
+ # PID of the process which acquired the lock
121
+ "pid": 50,
122
+ # name of affected table/index
123
+ "relname": "users",
124
+ # ID of the source transaction
125
+ "transactionid": null,
126
+ # bool indicating if the lock is already granted
127
+ "granted": true,
128
+ # type of the acquired lock
129
+ "mode": "RowExclusiveLock",
130
+ # SQL query which acquired the lock
131
+ "query_snippet": "UPDATE \"users\" SET \"updated_at\" = $1 WHERE \"users\".\"id\" = $2 from/sidekiq_job:UserUpdater/",
132
+ # age of the lock
133
+ "age": "PT0.94945S",
134
+ # app that acquired the lock
135
+ "application": "bin/sidekiq"
136
+ },
137
+ ```
138
+
139
+ You can read [this blogpost](https://pawelurbanek.com/rails-postgresql-locks) for more detailed info on locks in the Rails apps.
140
+
141
+ ## Background job config
142
+
143
+ This gem is intended to be used via a recurring background job, but it is agnostic to the background job provider. Here's a sample Sidekiq implementation:
144
+
145
+ `app/jobs/pg_locks_monitor_job.rb`
146
+ ```ruby
147
+ require 'pg-locks-monitor'
148
+
149
+ class PgLocksMonitoringJob
150
+ include Sidekiq::Worker
151
+ sidekiq_options retry: false
152
+
153
+ def perform
154
+ PgLocksMonitor.snapshot!
155
+ ensure
156
+ if ENV["PG_LOCKS_MONITOR_ENABLED"]
157
+ PgLocksMonitoringJob.perform_in(1.minute)
158
+ end
159
+ end
160
+ end
161
+ ```
162
+
163
+ Remember to schedule this job when your app starts:
164
+ `config/pg_locks_monitor.rb`
165
+
166
+ ```ruby
167
+ #...
168
+
169
+ if ENV["PG_LOCKS_MONITOR_ENABLED"]
170
+ PgLocksMonitoringJob.perform
171
+ end
172
+ ```
173
+
174
+ A background job that schedules itself is not the cleanest pattern. So alternatively you can use [sidekiq-cron](https://github.com/sidekiq-cron/sidekiq-cron), [whenever](https://github.com/javan/whenever) or [clockwork](https://github.com/adamwiggins/clockwork) gems to trigger the `PgLocksMonitor.snapshot!` invocation periodically.
175
+
176
+ A recommended frequency of invocation depends on your app's traffic. From my experience, even 1 minute apart snapshots can provide a lot of valuable data, but it all depends on how often the locks are occurring in your Rails application.
177
+
178
+ ## Custom notifier class
179
+
180
+ `PgLocksMonitor::DefaultNotifier` supports sending lock notifications with `Rails.logger` or to a Slack channel. If you want to use different notification channels you can define your custom notifier like that:
181
+
182
+ `config/initializers/pg_locks_monitor.rb`
183
+ ```ruby
184
+ class PgLocksEmailNotifier
185
+ def self.call(locks_data)
186
+ LocksMailer.with(locks_data: locks_data).notification.deliver_now
187
+ end
188
+ end
189
+
190
+ PgLocksMonitor.configure do |config|
191
+ # ...
192
+
193
+ config.notifier_class = PgLocksEmailNotifier
194
+ end
195
+
196
+ ```
197
+
198
+ ## Contributions
199
+
200
+ This gem is in a very early stage of development so feedback and PRs are welcome.
@@ -4,4 +4,37 @@ require "uri"
4
4
  require "pg"
5
5
 
6
6
  module PgLocksMonitor
7
+ def self.snapshot!
8
+ locks = RailsPgExtras.locks(
9
+ in_format: :hash, args: {
10
+ limit: configuration.locks_limit,
11
+ },
12
+ ).select do |lock|
13
+ (ActiveSupport::Duration.parse(lock.fetch("age")).to_f * 1000) > configuration.locks_min_duration_ms
14
+ end
15
+
16
+ if locks.present? && configuration.monitor_locks
17
+ configuration.notifier_class.call(locks)
18
+ end
19
+
20
+ blocking = RailsPgExtras.blocking(in_format: :hash).select do |block|
21
+ (ActiveSupport::Duration.parse(block.fetch("blocking_duration")).to_f * 1000) > configuration.blocking_min_duration_ms
22
+ end
23
+
24
+ if blocking.present? && configuration.monitor_blocking
25
+ configuration.notifier_class.call(blocking)
26
+ end
27
+ end
28
+
29
+ def self.configuration
30
+ @configuration ||= Configuration.new(Configuration::DEFAULT)
31
+ end
32
+
33
+ def self.configure
34
+ yield(configuration)
35
+ end
7
36
  end
37
+
38
+ require "pg_locks_monitor/default_notifier"
39
+ require "pg_locks_monitor/configuration"
40
+ require "pg_locks_monitor/railtie" if defined?(Rails)
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgLocksMonitor
4
+ class Configuration
5
+ DEFAULT = {
6
+ locks_limit: 5,
7
+ monitor_locks: true,
8
+ monitor_blocking: true,
9
+ locks_min_duration_ms: 200,
10
+ blocking_min_duration_ms: 100,
11
+ notify_logs: true,
12
+ notify_slack: false,
13
+ slack_webhook_url: nil,
14
+ slack_channel: nil,
15
+ notifier_class: PgLocksMonitor::DefaultNotifier,
16
+ }
17
+
18
+ attr_accessor *DEFAULT.keys
19
+
20
+ def initialize(attrs)
21
+ DEFAULT.keys.each do |key|
22
+ value = attrs.fetch(key) { DEFAULT.fetch(key) }
23
+ public_send("#{key}=", value)
24
+ end
25
+ end
26
+
27
+ DEFAULT_CONFIG_FILE = <<-CONFIG
28
+ # Configuration for pg-locks-monitor
29
+
30
+ PgLocksMonitor.configure do |config|
31
+ config.locks_limit = #{DEFAULT[:locks_limit]}
32
+
33
+ config.monitor_locks = #{DEFAULT[:monitor_locks]}
34
+ config.monitor_blocking = #{DEFAULT[:monitor_blocking]}
35
+
36
+ config.locks_min_duration_ms = #{DEFAULT[:locks_min_duration_ms]}
37
+ config.blocking_min_duration_ms = #{DEFAULT[:blocking_min_duration_ms]}
38
+
39
+ config.notify_logs = #{DEFAULT[:notify_logs]}
40
+
41
+ config.notify_slack = #{DEFAULT[:notify_slack]}
42
+ config.slack_webhook_url = "#{DEFAULT[:slack_webhook_url]}"
43
+ config.slack_channel = "#{DEFAULT[:slack_channel]}"
44
+
45
+ config.notifier_class = #{DEFAULT[:notifier_class]}
46
+ end
47
+ CONFIG
48
+ end
49
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "slack-notifier"
4
+
5
+ module PgLocksMonitor
6
+ class DefaultNotifier
7
+ def self.call(locks_data)
8
+ config = PgLocksMonitor.configuration
9
+
10
+ if config.notify_logs && defined?(Rails)
11
+ Rails.logger.info locks_data.to_s
12
+ end
13
+
14
+ if config.notify_slack
15
+ slack_webhook_url = config.slack_webhook_url
16
+ if slack_webhook_url.nil? || slack_webhook_url.strip.length == 0
17
+ raise "Missing pg-locks-monitor slack_webhook_url config"
18
+ end
19
+
20
+ slack_channel = config.slack_channel
21
+ if slack_channel.nil? || slack_channel.strip.length == 0
22
+ raise "Missing pg-locks-monitor slack_channel config"
23
+ end
24
+
25
+ Slack::Notifier.new(
26
+ slack_webhook_url,
27
+ channel: slack_channel,
28
+ ).ping JSON.pretty_generate(locks_data)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class PgLocksMonitor::Railtie < Rails::Railtie
4
+ rake_tasks do
5
+ load "pg_locks_monitor/tasks/all.rake"
6
+ end
7
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pg-locks-monitor"
4
+ require "fileutils"
5
+
6
+ namespace :pg_locks_monitor do
7
+ desc "Initialize a config file"
8
+ task :init do
9
+ file_path = "config/initializers/pg_locks_monitor.rb"
10
+ if File.exist?(file_path)
11
+ puts "#{file_path} config file has already been initialized!"
12
+ else
13
+ File.write(file_path, PgLocksMonitor::Configuration::DEFAULT_CONFIG_FILE)
14
+ puts "Config file created at #{file_path}"
15
+ end
16
+ end
17
+
18
+ desc "Check for currently active locks"
19
+ task snapshot: :environment do
20
+ PgLocksMonitor.snapshot!
21
+ end
22
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PgLocksMonitor
4
- VERSION = "0.0.1"
4
+ VERSION = "0.1.1"
5
5
  end
@@ -8,7 +8,7 @@ Gem::Specification.new do |s|
8
8
  s.version = PgLocksMonitor::VERSION
9
9
  s.authors = ["pawurb"]
10
10
  s.email = ["contact@pawelurbanek.com"]
11
- s.summary = %q{ Observe PostgreSQL database locks obtained by your Ruby application. }
11
+ s.summary = %q{ Observe PostgreSQL database locks obtained by a Rails application. }
12
12
  s.description = %q{ This gem allows to monitor and notify about PostgreSQL database locks which meet certain criteria. You can report locks which are held for a certain amount of time, or locks which are held by a certain query. }
13
13
  s.homepage = "http://github.com/pawurb/pg-locks-monitor"
14
14
  s.files = `git ls-files`.split("\n")
@@ -16,6 +16,7 @@ Gem::Specification.new do |s|
16
16
  s.require_paths = ["lib"]
17
17
  s.license = "MIT"
18
18
  s.add_dependency "ruby-pg-extras"
19
+ s.add_dependency "slack-notifier"
19
20
  s.add_development_dependency "rake"
20
21
  s.add_development_dependency "rspec"
21
22
  s.add_development_dependency "rufo"
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ describe "PgLocksMonitor::Configuration" do
6
+ it "has a default configuration" do
7
+ config = PgLocksMonitor.configuration
8
+ expect(config.monitor_locks).to eq true
9
+ expect(config.monitor_blocking).to eq true
10
+ expect(config.locks_min_duration_ms).to eq 200
11
+ expect(config.blocking_min_duration_ms).to eq 100
12
+ expect(config.notifier_class).to eq PgLocksMonitor::DefaultNotifier
13
+ end
14
+
15
+ it "can be configured" do
16
+ PgLocksMonitor.configure do |config|
17
+ config.monitor_locks = false
18
+ end
19
+
20
+ expect(PgLocksMonitor.configuration.monitor_locks).to eq false
21
+ end
22
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ describe PgLocksMonitor::DefaultNotifier do
6
+ before do
7
+ # Mock Rails and its logger
8
+ Rails = nil
9
+ logger_double = double("Logger")
10
+ allow(logger_double).to receive(:info)
11
+ allow(Rails).to receive(:logger).and_return(logger_double)
12
+ end
13
+
14
+ it "requires correct config if Slack notifications enabled" do
15
+ expect {
16
+ PgLocksMonitor::DefaultNotifier.call({})
17
+ }.not_to raise_error
18
+ PgLocksMonitor.configure do |config|
19
+ config.notify_slack = true
20
+ end
21
+
22
+ expect {
23
+ PgLocksMonitor::DefaultNotifier.call({})
24
+ }.to raise_error(RuntimeError)
25
+ end
26
+
27
+ it "sends the Slack notification if enabled" do
28
+ PgLocksMonitor.configure do |config|
29
+ config.notify_slack = true
30
+ config.slack_webhook_url = "https://hooks.slack.com/services/123456789/123456789/123456789"
31
+ config.slack_channel = "pg-locks-monitor"
32
+ end
33
+
34
+ expect_any_instance_of(Slack::Notifier).to receive(:ping)
35
+ PgLocksMonitor::DefaultNotifier.call({ locks: "data" })
36
+ end
37
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pg-locks-monitor
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - pawurb
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-08-20 00:00:00.000000000 Z
11
+ date: 2024-08-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ruby-pg-extras
@@ -24,6 +24,20 @@ dependencies:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
26
  version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: slack-notifier
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
27
41
  - !ruby/object:Gem::Dependency
28
42
  name: rake
29
43
  requirement: !ruby/object:Gem::Requirement
@@ -83,8 +97,14 @@ files:
83
97
  - Rakefile
84
98
  - docker-compose.yml.sample
85
99
  - lib/pg-locks-monitor.rb
100
+ - lib/pg_locks_monitor/configuration.rb
101
+ - lib/pg_locks_monitor/default_notifier.rb
102
+ - lib/pg_locks_monitor/railtie.rb
103
+ - lib/pg_locks_monitor/tasks/all.rake
86
104
  - lib/pg_locks_monitor/version.rb
87
105
  - pg-locks-monitor.gemspec
106
+ - spec/configuration_spec.rb
107
+ - spec/default_notifier_spec.rb
88
108
  - spec/smoke_spec.rb
89
109
  - spec/spec_helper.rb
90
110
  homepage: http://github.com/pawurb/pg-locks-monitor
@@ -110,7 +130,9 @@ requirements: []
110
130
  rubygems_version: 3.5.4
111
131
  signing_key:
112
132
  specification_version: 4
113
- summary: Observe PostgreSQL database locks obtained by your Ruby application.
133
+ summary: Observe PostgreSQL database locks obtained by a Rails application.
114
134
  test_files:
135
+ - spec/configuration_spec.rb
136
+ - spec/default_notifier_spec.rb
115
137
  - spec/smoke_spec.rb
116
138
  - spec/spec_helper.rb