Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
pawurb committed Aug 27, 2024
0 parents commit 934f25b
Show file tree
Hide file tree
Showing 18 changed files with 674 additions and 0 deletions.
127 changes: 127 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
name: Ruby CI

on:
push:
branches: [ main ]
pull_request:
branches: [ main ]

jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
ruby-version: ['3.2', '3.1', '3.0', '2.7', '2.6']
steps:
- uses: actions/checkout@v3
- name: Run PostgreSQL 11
run: |
docker run --env POSTGRES_USER=postgres \
--env POSTGRES_DB=pg-locks-monitor-test \
--env POSTGRES_PASSWORD=secret \
-d -p 5432:5432 postgres:11.18-alpine \
postgres -c shared_preload_libraries=pg_stat_statements
- name: Run PostgreSQL 12
run: |
docker run --env POSTGRES_USER=postgres \
--env POSTGRES_DB=pg-locks-monitor-test \
--env POSTGRES_PASSWORD=secret \
-d -p 5433:5432 postgres:12.13-alpine \
postgres -c shared_preload_libraries=pg_stat_statements
- name: Run PostgreSQL 13
run: |
docker run --env POSTGRES_USER=postgres \
--env POSTGRES_DB=pg-locks-monitor-test \
--env POSTGRES_PASSWORD=secret \
-d -p 5434:5432 postgres:13.9-alpine \
postgres -c shared_preload_libraries=pg_stat_statements
- name: Run PostgreSQL 14
run: |
docker run --env POSTGRES_USER=postgres \
--env POSTGRES_DB=pg-locks-monitor-test \
--env POSTGRES_PASSWORD=secret \
-d -p 5435:5432 postgres:14.6-alpine \
postgres -c shared_preload_libraries=pg_stat_statements
- name: Run PostgreSQL 15
run: |
docker run --env POSTGRES_USER=postgres \
--env POSTGRES_DB=pg-locks-monitor-test \
--env POSTGRES_PASSWORD=secret \
-d -p 5436:5432 postgres:15.1-alpine \
postgres -c shared_preload_libraries=pg_stat_statements
sleep 15
- name: Set up Ruby ${{ matrix.ruby-version }}
uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.ruby-version }}
- name: Setup dependencies
run: |
gem install bundler -v 2.4.22
sudo apt-get update --allow-releaseinfo-change
sudo apt install postgresql-client
sudo apt install libpq-dev
bundle config set --local path 'vendor/bundle'
bundle install
sleep 10
- name: Run tests for PG 11
env:
PG_VERSION: 11
POSTGRES_HOST: localhost
POSTGRES_USER: postgres
POSTGRES_DB: pg-locks-monitor-test
POSTGRES_PASSWORD: secret
DATABASE_URL: postgresql://postgres:secret@localhost:5432/pg-locks-monitor-test
run: |
bundle exec rspec spec/
- name: Run tests for PG 11
env:
PG_VERSION: 11
POSTGRES_HOST: localhost
POSTGRES_USER: postgres
POSTGRES_DB: pg-locks-monitor-test
POSTGRES_PASSWORD: secret
DATABASE_URL: postgresql://postgres:secret@localhost:5432/pg-locks-monitor-test
run: |
bundle exec rspec spec/
- name: Run tests for PG 12
env:
PG_VERSION: 12
POSTGRES_HOST: localhost
POSTGRES_USER: postgres
POSTGRES_DB: pg-locks-monitor-test
POSTGRES_PASSWORD: secret
DATABASE_URL: postgresql://postgres:secret@localhost:5433/pg-locks-monitor-test
run: |
bundle exec rspec spec/
- name: Run tests for PG 13
env:
PG_VERSION: 13
POSTGRES_HOST: localhost
POSTGRES_USER: postgres
POSTGRES_DB: pg-locks-monitor-test
POSTGRES_PASSWORD: secret
DATABASE_URL: postgresql://postgres:secret@localhost:5434/pg-locks-monitor-test
run: |
bundle exec rspec spec/
- name: Run tests for PG 14
env:
PG_VERSION: 14
POSTGRES_HOST: localhost
POSTGRES_USER: postgres
POSTGRES_DB: pg-locks-monitor-test
POSTGRES_PASSWORD: secret
DATABASE_URL: postgresql://postgres:secret@localhost:5435/pg-locks-monitor-test
run: |
bundle exec rspec spec/
- name: Run tests for PG 15
env:
PG_VERSION: 15
POSTGRES_HOST: localhost
POSTGRES_USER: postgres
POSTGRES_DB: pg-locks-monitor-test
POSTGRES_PASSWORD: secret
DATABASE_URL: postgresql://postgres:secret@localhost:5436/pg-locks-monitor-test
run: |
bundle exec rspec spec/
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Gemfile.lock
.ruby-version
pkg/
*.gem
docker-compose.yml

3 changes: 3 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
source "https://rubygems.org"

gemspec
22 changes: 22 additions & 0 deletions LICENSE.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
The MIT License (MIT)

Copyright © Paweł Urbanek 2024

Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:

The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
200 changes: 200 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
# 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)

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.

## Usage

`PgLocksMonitor` class provides a `snapshot!` method which notifies selected channels about database locks that match a configured criteria.

Start by adding the gem to your Gemfile:

```ruby
gem "pg-locks-monitor"
```

Then run `bundle install` and `rake pg_locks_monitor:init`. It creates a default configuration file with the following settings:

`config/initializers/pg_locks_monitor.rb`
```ruby
PgLocksMonitor.configure do |config|
config.locks_limit = 5

config.monitor_locks = true
config.locks_min_duration_ms = 200

config.monitor_blocking = true
config.blocking_min_duration_ms = 100

config.notify_logs = true

config.notify_slack = false
config.slack_webhook_url = ""
config.slack_channel = ""

config.notifier_class = PgLocksMonitor::DefaultNotifier
end
```

- `locks_limit` - specify the max number of locks to report in a single notification
- `notify_locks` - observe database locks even if they don't conflict with a different SQL query
- `locks_min_duration_ms` - notify about locks which execeed this duration threshold in milliseconds
- `notify_blocking` - observe database locks which cause other SQL query to wait from them to release
- `blocking_min_duration_ms` - notify about blocking locks which execeed this duration threshold in milliseconds
- `notify_logs` - send notification about detected locks to using `Rails.logger.info` method
- `notify_slack` - send notification about detected locks to the configured Slack channel
- `slack_webhook_url` - webhook necessary for Slack notification to work
- `slack_channel` - name of the targer Slack channel
- `notifier_class` - customizable notifier class


## Testing the notification channels

Before configuring a recurring invocation of the `snapshot!` method it's recommended to first manually trigger the notification to test the configured channels.

You can generate an _"artificial"_ blocking lock and observe it by running the following code in the Rails console:

```ruby
user = User.last

Thread.new do
User.transaction do
user.update(email: "email-#{SecureRandom.hex(2)}@example.com")
sleep 5
raise ActiveRecord::Rollback
end
end

Thread.new do
User.transaction do
user.update(email: "email-#{SecureRandom.hex(2)}@example.com")
sleep 5
raise ActiveRecord::Rollback
end
end

sleep 0.5
PgLocksMonitor.snapshot!
```

Please remember to adjust the update operation to match your app's schema.

As a result of running the above snippet your should receive a notification about the acquired blocking database lock.

### Sample notification

Received notifications contain data that's useful in debugging the cause of long lasting locks.

And here's a sample blocking lock notification:

```ruby
[
{
# PID process which was blocking another query
"blocking_pid": 29,
# SQL query blocking other SQL query
"blocking_statement": "UPDATE \"users\" SET \"updated_at\" = $1 WHERE \"users\".\"id\" = $2 from/sidekiq_job:UserUpdater/",
# the duration of blocking SQL query
"blocking_duration": "PT0.972116S",
# app that triggered the blocking SQL query
"blocking_sql_app": "bin/sidekiq",

# PID of process which was blocked by other query
"blocked_pid": 30,
# SQL query blocked by other SQL query
"blocked_statement": "UPDATE \"users\" SET \"last_active_at\" = $1 WHERE \"users\".\"id\" = $2 from/controller_with_namespace:UsersController,action:update/",
# the duration of blocked SQL query
"blocked_duration": "PT0.483309S",
# app that triggered the blocked SQL query
"blocked_sql_app": "bin/puma"
}
]
```

This sample blocking notification shows than query originating from `UserUpdater` Sidekiq job is blocking an update operation on the same user for `UsersController#update` action. Remember to configure [marginalia gem](https://github.com/basecamp/marginalia) to enable these helpful query source annotations.

Here's a sample lock notication:

```ruby
[
{
# PID of the process which acquired the lock
"pid": 50,
# name of affected table/index
"relname": "users",
# ID of the source transaction
"transactionid": null,
# bool indicating if the lock is already granted
"granted": true,
# type of the acquired lock
"mode": "RowExclusiveLock",
# SQL query which acquired the lock
"query_snippet": "UPDATE \"users\" SET \"updated_at\" = $1 WHERE \"users\".\"id\" = $2 from/sidekiq_job:UserUpdater/",
# age of the lock
"age": "PT0.94945S",
# app that acquired the lock
"application": "bin/sidekiq"
},
```

You can read [this blogpost](https://pawelurbanek.com/rails-postgresql-locks) for more detailed info on locks in the Rails apps.

## Background job config

This gem is intended to be used via a recurring background job but it is agnostic of the background job provider. Here's a sample Sidekiq implementation:

`app/jobs/pg_locks_monitor_job.rb`
```ruby
require 'pg-locks-monitor'

class PgLocksMonitoringJob
include Sidekiq::Worker
sidekiq_options retry: false

def perform
PgLocksMonitor.snapshot!
ensure
if ENV["PG_LOCKS_MONITOR_ENABLED"]
PgLocksMonitoringJob.perform_in(1.minute)
end
end
end
```

Remember to schedule this job when your app starts:
`config/pg_locks_monitor.rb`

```ruby
#...

if ENV["PG_LOCKS_MONITOR_ENABLED"]
PgLocksMonitoringJob.perform
end
```

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.

A recommended frequency of invocation depends on your app's traffic. From my experience even 1 minute apart snapshots can provide a lot of useful data, but it all depends on how often the locks are occuring in your Rails application.

## Custom notifier class

`PgLocksMonitor::DefaultNotifier` supports sending lock notifications to `Rails.logger` or Slack channel. If you want to use different notification channels you can define your custom notifier like that:

`config/initializers/pg_locks_monitor.rb`
```ruby
class PgLocksEmailNotifier
def self.call(locks_data)
LocksMailer.with(locks_data: locks_data).notification.deliver_now
end
end

PgLocksMonitor.configure do |config|
# ...

config.notifier_class = PgLocksEmailNotifier
end

```

## Contributions

This is in a very early stage of development so feedback and PRs are welcome.
9 changes: 9 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
require "bundler/gem_tasks"
require "rspec/core/rake_task"

RSpec::Core::RakeTask.new(:spec)

desc "Test all PG versions"
task :test_all do
system("PG_VERSION=11 bundle exec rspec spec/ && PG_VERSION=12 bundle exec rspec spec/ && PG_VERSION=13 bundle exec rspec spec/ && PG_VERSION=14 bundle exec rspec spec/")
end
Loading

0 comments on commit 934f25b

Please sign in to comment.