Skip to content

Commit 05abe69

Browse files
authored
Merge pull request #889 from Shopify/semian_activerecord_postgres
Add Semian support for Active Record's PostgreSQL Adapter
2 parents 97da848 + 53bfe2c commit 05abe69

14 files changed

Lines changed: 761 additions & 446 deletions

.devcontainer/docker-compose.yml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ services:
2222
depends_on:
2323
- redis
2424
- mysql
25+
- postgres
2526
- toxiproxy
2627
command:
2728
- /bin/bash
@@ -60,8 +61,8 @@ services:
6061
- mysql
6162

6263
redis:
63-
image: redis:latest
6464
container_name: redis
65+
image: redis:latest
6566
command: redis-server
6667

6768
mysql:
@@ -70,3 +71,9 @@ services:
7071
environment:
7172
MYSQL_ROOT_PASSWORD: root
7273
MYSQL_ROOT_HOST: "%"
74+
75+
postgres:
76+
container_name: postgres
77+
image: postgres:15
78+
environment:
79+
POSTGRES_PASSWORD: root

.github/workflows/test.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,15 @@ jobs:
4646
--health-interval=10s
4747
--health-timeout=5s
4848
--health-retries=5
49+
postgres:
50+
image: postgres:15
51+
env:
52+
POSTGRES_PASSWORD: root
53+
options: >-
54+
--health-cmd="pg_isready"
55+
--health-interval=10s
56+
--health-timeout=5s
57+
--health-retries=5
4958
redis:
5059
image: redis
5160
options: >-
@@ -124,6 +133,7 @@ jobs:
124133
- redis_5
125134
- redis_client
126135
- activerecord_trilogy_adapter
136+
- activerecord_postgresql_adapter
127137
include:
128138
- gemfile: grpc
129139
adapter: grpc
@@ -143,6 +153,8 @@ jobs:
143153
adapter: redis_client
144154
- gemfile: activerecord_trilogy_adapter
145155
adapter: activerecord_trilogy_adapter
156+
- gemfile: activerecord_postgresql_adapter
157+
adapter: activerecord_postgresql_adapter
146158
services:
147159
mysql:
148160
image: mysql:9.3
@@ -153,6 +165,15 @@ jobs:
153165
--health-interval=10s
154166
--health-timeout=5s
155167
--health-retries=5
168+
postgres:
169+
image: postgres:15
170+
env:
171+
POSTGRES_PASSWORD: root
172+
options: >-
173+
--health-cmd="pg_isready"
174+
--health-interval=10s
175+
--health-timeout=5s
176+
--health-retries=5
156177
redis:
157178
image: redis
158179
options: >-

Gemfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ group :test do
2121
gem "grpc", "1.76.0"
2222
gem "mysql2", "~> 0.5"
2323
gem "trilogy", "~> 2.9"
24+
gem "pg", "~> 1.6"
2425
gem "activerecord", github: "rails/rails", branch: "main"
2526
gem "hiredis", "~> 0.6"
2627
# NOTE: v0.12.0 required for ruby 3.2.0. https://github.com/redis-rb/redis-client/issues/58

Gemfile.lock

Lines changed: 17 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ version is the version of the public gem with the same name:
7373
- [`semian/redis`][redis-semian-adapter] (~> 3.2.1)
7474
- [`semian/net_http`][nethttp-semian-adapter]
7575
- [`semian/activerecord_trilogy_adapter`][activerecord-trilogy-semian-adapter]
76+
- [`semian/activerecord_postgresql_adapter`][activerecord-postgresql-semian-adapter]
7677
- [`semian-postgres`][postgres-semian-adapter]
7778

7879
### Creating Adapters
@@ -1027,6 +1028,7 @@ $ bundle install
10271028
[postgres-semian-adapter]: https://github.com/mschoenlaub/semian-postgres
10281029
[redis-semian-adapter]: lib/semian/redis.rb
10291030
[activerecord-trilogy-semian-adapter]: lib/semian/activerecord_trilogy_adapter.rb
1031+
[activerecord-postgres-semian-adapter]: lib/semian/activerecord_postgres_adapter.rb
10301032
[semian-adapter]: lib/semian/adapter.rb
10311033
[nethttp-semian-adapter]: lib/semian/net_http.rb
10321034
[nethttp-default-errors]: lib/semian/net_http.rb#L35-L45
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# frozen_string_literal: true
2+
3+
source "https://rubygems.org"
4+
5+
gem "rake"
6+
gem "rake-compiler"
7+
gem "minitest"
8+
gem "mocha"
9+
gem "toxiproxy"
10+
gem "webrick"
11+
gem "concurrent-ruby"
12+
13+
gem "pg", "~> 1.6"
14+
gem "activerecord", github: "rails/rails"
15+
16+
gemspec path: "../"

lib/semian/activerecord_adapter.rb

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# frozen_string_literal: true
2+
3+
require "semian/adapter"
4+
require "active_record"
5+
6+
module Semian
7+
module ActiveRecordAdapter
8+
QUERY_ALLOWLIST = %r{\A(?:/\*.*?\*/)?\s*(ROLLBACK|COMMIT|RELEASE\s+SAVEPOINT)}i
9+
10+
module ClassMethods
11+
def query_allowlisted?(sql, *)
12+
# COMMIT, ROLLBACK
13+
tx_command_statement = sql.end_with?("T", "K")
14+
15+
# RELEASE SAVEPOINT. Nesting past _3 levels won't get bypassed.
16+
# Active Record does not send trailing spaces or `;`, so we are in the realm of hand crafted queries here.
17+
savepoint_statement = sql.end_with?("_1", "_2")
18+
unclear = sql.end_with?(" ", ";")
19+
20+
if !tx_command_statement && !savepoint_statement && !unclear
21+
false
22+
else
23+
QUERY_ALLOWLIST.match?(sql)
24+
end
25+
rescue ArgumentError
26+
return false unless sql.valid_encoding?
27+
28+
raise
29+
end
30+
end
31+
32+
class << self
33+
def included(base)
34+
base.extend(ClassMethods)
35+
base.class_eval do
36+
attr_reader(:raw_semian_options, :semian_identifier)
37+
end
38+
end
39+
end
40+
41+
def initialize(*options)
42+
*, config = options
43+
config = config.dup
44+
@raw_semian_options = config.delete(:semian)
45+
@semian_identifier = begin
46+
name = semian_options && semian_options[:name]
47+
unless name
48+
host = config[:host] || "localhost"
49+
port = config[:port] || semian_adapter_default_port
50+
name = "#{host}:#{port}"
51+
end
52+
:"#{semian_adapter_identifier_prefix}_#{name}"
53+
end
54+
super
55+
end
56+
57+
if ActiveRecord.version >= Gem::Version.new("8.2.a")
58+
def execute_intent(intent)
59+
return super if self.class.query_allowlisted?(intent.processed_sql)
60+
61+
acquire_semian_resource(adapter: semian_adapter_name, scope: :query) do
62+
super
63+
end
64+
end
65+
else
66+
def raw_execute(sql, *args, **kwargs, &block)
67+
if self.class.query_allowlisted?(sql)
68+
super
69+
else
70+
acquire_semian_resource(adapter: semian_adapter_name, scope: :query) do
71+
super(sql, *args, **kwargs, &block)
72+
end
73+
end
74+
end
75+
end
76+
77+
def active?
78+
acquire_semian_resource(adapter: semian_adapter_name, scope: :ping) do
79+
super
80+
end
81+
rescue resource_busy_error_class, circuit_open_error_class
82+
false
83+
end
84+
85+
def with_resource_timeout
86+
raise NotImplementedError, "#{self.class} must implement a `with_resource_timeout` method"
87+
end
88+
89+
private
90+
91+
def resource_exceptions
92+
[
93+
ActiveRecord::AdapterTimeout,
94+
ActiveRecord::ConnectionFailed,
95+
ActiveRecord::ConnectionNotEstablished,
96+
]
97+
end
98+
99+
def resource_busy_error_class
100+
self.class::ResourceBusyError
101+
end
102+
103+
def circuit_open_error_class
104+
self.class::CircuitOpenError
105+
end
106+
107+
def connect(*args)
108+
acquire_semian_resource(adapter: semian_adapter_name, scope: :connection) do
109+
super
110+
end
111+
end
112+
113+
def semian_adapter_name
114+
raise NotImplementedError, "#{self.class} must implement an `semian_adapter_name` method"
115+
end
116+
117+
def semian_adapter_default_port
118+
raise NotImplementedError, "#{self.class} must implement an `semian_adapter_default_port` method"
119+
end
120+
121+
def semian_adapter_identifier_prefix
122+
raise NotImplementedError, "#{self.class} must implement an `semian_adapter_identifier_prefix` method"
123+
end
124+
end
125+
end
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# frozen_string_literal: true
2+
3+
require "semian/activerecord_adapter"
4+
require "active_record/connection_adapters/postgresql_adapter"
5+
6+
module ActiveRecord
7+
module ConnectionAdapters
8+
class PostgreSQLAdapter
9+
ActiveRecord::ActiveRecordError.include(::Semian::AdapterError)
10+
11+
class SemianError < ConnectionNotEstablished
12+
def initialize(semian_identifier, *args)
13+
super(*args)
14+
@semian_identifier = semian_identifier
15+
end
16+
end
17+
18+
ResourceBusyError = Class.new(SemianError)
19+
CircuitOpenError = Class.new(SemianError)
20+
end
21+
end
22+
end
23+
24+
module Semian
25+
module ActiveRecordPostgreSQLAdapter
26+
include Semian::Adapter
27+
include Semian::ActiveRecordAdapter
28+
29+
class << self
30+
def prepended(base)
31+
base.extend(Semian::ActiveRecordAdapter::ClassMethods)
32+
end
33+
end
34+
35+
def with_resource_timeout(_temp_timeout)
36+
# Resource timeouts aren't possible with PostgreSQL because there is no
37+
# IO level timeout configuration, so we just yield.
38+
yield
39+
end
40+
41+
private
42+
43+
def semian_adapter_name = :postgresql_adapter
44+
45+
def semian_adapter_default_port = 5432
46+
47+
def semian_adapter_identifier_prefix = :postgresql
48+
end
49+
end
50+
51+
ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.prepend(Semian::ActiveRecordPostgreSQLAdapter)

0 commit comments

Comments
 (0)