Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Experimental PG patch #61

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ jobs:
ports:
- 9200:9200
options: -e="discovery.type=single-node" --health-cmd="curl http://localhost:9200/_cluster/health" --health-interval=3s --health-timeout=5s --health-retries=20
postgres:
image: postgres:12.6
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: --health-cmd="pg_isready -U postgres" --health-interval=3s --health-timeout=5s --health-retries=20
steps:
- uses: actions/checkout@v3
- uses: ruby/setup-ruby@v1
Expand Down
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ gem 'byebug', platforms: not_jruby
gem 'elasticsearch', '> 7', '< 7.14'
gem 'honeybadger', '>= 2.0'
gem 'irb', '~> 1.0'
gem 'pg', '~> 1.1'
# Minimum of 0.5.0 for specific error classes
gem 'mysql2', '>= 0.5.0', platforms: not_jruby
gem 'redcarpet', '~> 3.5', platforms: not_jruby
Expand Down
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1047,6 +1047,34 @@ mysql.query('SELECT * FROM users') # raises Faulty::CircuitError if connection f
mysql = Mysql2::Client.new(host: '127.0.0.1')
mysql.query('SELECT * FROM users') # not protected by a circuit
```
### Patch::Postgres

[`Faulty::Patch::Postgres`](https://www.rubydoc.info/gems/faulty/Faulty/Patch/Postgres)
protects a `PG::Connection` with an internal circuit. Pass a `:faulty` key along
with your connection options to enable the circuit breaker.

Faulty supports the pg gem versions 1.0 and greater.

```ruby
require 'faulty/patch/postgres'

pg = PG::Connection.new(host: 'localhost', faulty: {
# The name for the Postgres circuit
name: 'postgres'

# The faulty instance to use
# This can also be a registered faulty instance or a constant name. See API
# docs for more details
instance: Faulty.default

# By default, circuit errors will be subclasses of PG::Error
# To disable this behavior, set patch_errors to false and Faulty
# will raise its default errors
patch_errors: true
})
```



### Patch::Elasticsearch

Expand Down
56 changes: 56 additions & 0 deletions lib/faulty/patch/postgres.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# frozen_string_literal: true

require 'pg'

class Faulty
module Patch
# Patch for the Postgres gem
module Postgres
include Base

Patch.define_circuit_errors(self, ::PG::ConnectionBad)

QUERY_WHITELIST = [
%r{\A(?:/\*.*?\*/)?\s*ROLLBACK}i,
%r{\A(?:/\*.*?\*/)?\s*COMMIT}i,
%r{\A(?:/\*.*?\*/)?\s*RELEASE\s+SAVEPOINT}i
].freeze

def initialize(opts = {})
@faulty_circuit = Patch.circuit_from_hash(
'pg',
opts[:faulty],
errors: [
::PG::ConnectionBad,
::PG::UnableToSend
],
patched_error_mapper: Faulty::Patch::Postgres
)

super
end

def ping
faulty_run { super }
rescue Faulty::Patch::Postgres::FaultyError
false
end

def connect(*args)
faulty_run { super }
end

def query(*args)
return super if QUERY_WHITELIST.any? { |r| !r.match(args.first).nil? }

faulty_run { super }
end
end
end
end

module PG
class Connection
prepend Faulty::Patch::Postgres
end
end
93 changes: 93 additions & 0 deletions spec/patch/postgres_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# frozen_string_literal: true

RSpec.describe 'Faulty::Patch::Postgres', if: defined?(PG) do
def new_client(options = {})
PG::Connection.new({
username: ENV.fetch('POSTGRES_USER', nil),
password: ENV.fetch('POSTGRES_PASSWORD', nil),
host: ENV.fetch('POSTGRES_HOST', nil),
port: ENV.fetch('POSTGRES_PORT', nil),
socket: ENV.fetch('POSTGRES_SOCKET', nil)
}.merge(options))
end

def create_table(client, table_name)
client.exec("CREATE TABLE #{table_name} (id serial PRIMARY KEY, name text)")
end

def trip_circuit
client
4.times do
begin
new_client(host: '127.0.0.1', port: 9999, faulty: { instance: 'faulty' })
rescue PG::ConnectionBad
# expected
end
end
end

let(:client) { new_client(database: db_name, faulty: { instance: 'faulty' }) }
let(:bad_client) { new_client(host: '127.0.0.1', port: 9999, faulty: { instance: 'faulty' }) }
let(:bad_unpatched_client) { new_client(host: '127.0.0.1', port: 9999) }
let(:faulty) { Faulty.new(listeners: [], circuit_defaultts: { sample_threshold: 2 }) }

before do
new_client.exec("CREATE DATABASE #{db_name}")
end

after do
new_client.exec("DROP DATABASE #{db_name}")
end

it 'captures connection error' do
expect { bad_client.query('SELECT 1 FROM dual') }.to raise_error do |error|
expect(error).to be_a(Faulty::Patch::PG::ConnectionError)
expect(error.cause).to be_a(PG::Error::ConnectionBad)
end
expect(faulty.circuit('postgres').status.failure_rate).to eq(1)
end

it 'does not capture unpatched client errors' do
expect { bad_unpatched_client.query('SELECT 1 FROM dual') }.to raise_error(PG::Error::ConnectionBad)
expect(faulty.circuit('postgres').status.failure_rate).to eq(0)
end

it 'does not capture application errors' do
expect { client.query('SELECT * FROM not_a_table') }.to raise_error(PG::Error)
expect(faulty.circuit('postgres').status.failure_rate).to eq(0)
end

it 'successfully executes query' do
create_table(client, 'test')
client.query('INSERT INTO test VALUES(1)')
expect(client.query('SELECT * FROM test').to_a).to eq([{ 'id' => '1' }])
expect(faulty.circuit('postgres').status.failure_rate).to eq(0)
end

it 'prevents additional queries when tripped' do
trip_circuit
expect { client.query('SELECT 1 FROM dual') }.to raise_error(Faulty::Patch::PG::ConnectionError)
end

it 'allows COMMIT when tripped' do
create_table(client, 'test')
client.query('BEGIN')
client.query('INSERT INTO test VALUES(1)')
trip_circuit
expect { client.query('COMMIT') }.to be_nil
expect(client.query('SELECT * FROM test')).to raise_error(Faulty::Patch::PG::ConnectionError)
faulty.circuit('postgres').reset
expect(client.query('SELECT * FROM test').to_a).to eq([{ 'id' => '1' }])
end

it 'allows ROLLBACK with a leading comment when tripped' do
create_table(client, 'test')
client.query('BEGIN')
client.query('INSERT INTO test VALUES(1)')
trip_circuit
expect { client.query('/* hi there */ ROLLBACK') }.to be_nil
expect { client.query('SELECT * FROM test') }.to raise_error(Faulty::Patch::PG::ConnectionError)
faulty.circuit('postgres').reset
expect(client.query('SELECT * FROM test').to_a).to eq([])
end
end
1 change: 1 addition & 0 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
require 'faulty'
require 'faulty/patch/redis'
require 'faulty/patch/elasticsearch'
require 'faulty/patch/postgres'
require 'timecop'
require 'redis'
require 'json'
Expand Down