Skip to content

Commit 4d2f53c

Browse files
authored
Merge pull request #515 from Shopify/micro-opt-allowlist
Return early from allow list when possible
2 parents 78d167e + 75ccb82 commit 4d2f53c

File tree

6 files changed

+70
-23
lines changed

6 files changed

+70
-23
lines changed

.rubocop.yml

+2
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ inherit_gem:
1212
AllCops:
1313
TargetRubyVersion: 2.7
1414
NewCops: enable
15+
Exclude:
16+
- test/allow_list_bench.rb
1517

1618
Layout/LineLength:
1719
Exclude:

Gemfile

+1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ gem "rake-compiler"
77

88
group :test do
99
gem "benchmark-memory"
10+
gem "benchmark-ips"
1011
gem "memory_profiler"
1112
gem "minitest"
1213
gem "mocha"

Gemfile.lock

+2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/semian/activerecord_trilogy_adapter.rb

+34-12
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,39 @@ module ActiveRecordTrilogyAdapter
2929
ResourceBusyError = ::ActiveRecord::ConnectionAdapters::TrilogyAdapter::ResourceBusyError
3030
CircuitOpenError = ::ActiveRecord::ConnectionAdapters::TrilogyAdapter::CircuitOpenError
3131

32+
QUERY_ALLOWLIST = %r{\A(?:/\*.*?\*/)?\s*(ROLLBACK|COMMIT|RELEASE\s+SAVEPOINT)}i
33+
34+
# The common case here is NOT to have transaction management statements, therefore
35+
# we are exploiting the fact that Active Record will use COMMIT/ROLLBACK as
36+
# the suffix of the command string and
37+
# name savepoints by level of nesting as `active_record_1` ... n.
38+
#
39+
# Since looking at the last characters in a string using `end_with?` is a LOT cheaper than
40+
# running a regex, we are returning early if the last characters of
41+
# the SQL statements are NOT the last characters of the known transaction
42+
# control statements.
43+
class << self
44+
def query_allowlisted?(sql, *)
45+
# COMMIT, ROLLBACK
46+
tx_command_statement = sql.end_with?("T") || sql.end_with?("K")
47+
48+
# RELEASE SAVEPOINT. Nesting past _3 levels won't get bypassed.
49+
# Active Record does not send trailing spaces or `;`, so we are in the realm of hand crafted queries here.
50+
savepoint_statement = sql.end_with?("_1") || sql.end_with?("_2")
51+
unclear = sql.end_with?(" ") || sql.end_with?(";")
52+
53+
if !tx_command_statement && !savepoint_statement && !unclear
54+
false
55+
else
56+
QUERY_ALLOWLIST.match?(sql)
57+
end
58+
rescue ArgumentError
59+
return false unless sql.valid_encoding?
60+
61+
raise
62+
end
63+
end
64+
3265
attr_reader :raw_semian_options, :semian_identifier
3366

3467
def initialize(*options)
@@ -48,7 +81,7 @@ def initialize(*options)
4881
end
4982

5083
def raw_execute(sql, *)
51-
if query_allowlisted?(sql)
84+
if Semian::ActiveRecordTrilogyAdapter.query_allowlisted?(sql)
5285
super
5386
else
5487
acquire_semian_resource(adapter: :trilogy_adapter, scope: :query) do
@@ -90,17 +123,6 @@ def resource_exceptions
90123
]
91124
end
92125

93-
# TODO: share this with Mysql2
94-
QUERY_ALLOWLIST = %r{\A(?:/\*.*?\*/)?\s*(ROLLBACK|COMMIT|RELEASE\s+SAVEPOINT)}i
95-
96-
def query_allowlisted?(sql, *)
97-
QUERY_ALLOWLIST.match?(sql)
98-
rescue ArgumentError
99-
return false unless sql.valid_encoding?
100-
101-
raise
102-
end
103-
104126
def connect(*args)
105127
acquire_semian_resource(adapter: :trilogy_adapter, scope: :connection) do
106128
super

test/adapters/activerecord_trilogy_adapter_test.rb

+11-11
Original file line numberDiff line numberDiff line change
@@ -306,52 +306,52 @@ def test_semian_allows_rollback
306306
@adapter.execute("START TRANSACTION;")
307307

308308
Semian[:mysql_testing].acquire do
309-
@adapter.execute("ROLLBACK;")
309+
@adapter.execute("ROLLBACK")
310310
end
311311
end
312312

313313
def test_semian_allows_rollback_with_marginalia
314314
@adapter.execute("START TRANSACTION;")
315315

316316
Semian[:mysql_testing].acquire do
317-
@adapter.execute("/*foo:bar*/ ROLLBACK;")
317+
@adapter.execute("/*foo:bar*/ ROLLBACK")
318318
end
319319
end
320320

321321
def test_semian_allows_commit
322322
@adapter.execute("START TRANSACTION;")
323323

324324
Semian[:mysql_testing].acquire do
325-
@adapter.execute("COMMIT;")
325+
@adapter.execute("COMMIT")
326326
end
327327
end
328328

329329
def test_query_allowlisted_returns_false_for_binary_sql
330330
binary_query = File.read(File.expand_path("../../fixtures/binary.sql", __FILE__))
331331

332-
refute(@adapter.send(:query_allowlisted?, binary_query))
332+
refute(Semian::ActiveRecordTrilogyAdapter.query_allowlisted?(binary_query))
333333
end
334334

335-
def test_semian_allows_rollback_to_safepoint
335+
def test_semian_allows_release_savepoint
336336
@adapter.execute("START TRANSACTION;")
337-
@adapter.execute("SAVEPOINT foobar;")
337+
@adapter.execute("SAVEPOINT active_record_2;")
338338

339339
Semian[:mysql_testing].acquire do
340-
@adapter.execute("ROLLBACK TO foobar;")
340+
@adapter.execute("RELEASE SAVEPOINT active_record_2")
341341
end
342342

343343
@adapter.execute("ROLLBACK;")
344344
end
345345

346-
def test_semian_allows_release_savepoint
346+
def test_semian_allows_rollback_to_savepoint
347347
@adapter.execute("START TRANSACTION;")
348-
@adapter.execute("SAVEPOINT foobar;")
348+
@adapter.execute("SAVEPOINT active_record_1;")
349349

350350
Semian[:mysql_testing].acquire do
351-
@adapter.execute("RELEASE SAVEPOINT foobar;")
351+
@adapter.execute("ROLLBACK TO SAVEPOINT active_record_1")
352352
end
353353

354-
@adapter.execute("ROLLBACK;")
354+
@adapter.execute("ROLLBACK")
355355
end
356356

357357
def test_changes_timeout_when_half_open_and_configured

test/allow_list_bench.rb

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# frozen_string_literal: true
2+
3+
require "semian"
4+
require "active_record"
5+
require "semian/activerecord_trilogy_adapter"
6+
require "benchmark/ips"
7+
8+
# sql_commit = "/*key:value,key:morevalue,morekey:morevalue,id:IDIDIDIDI,anotherkey:anothervalue,k:v,k:v*/ COMMIT"
9+
10+
# Common case
11+
sql_not_commit = "/*key:value,key:morevalue,morekey:morevalue,id:IDIDIDIDID,anotherkey:anothervalue,k:v,k:v*/ SELECT /*+ 1111111111111111111111111*/ `line_items`.`id`, `line_items`.`shop_id`, `line_items`.`title`, `line_items`.`sku`, `line_items`.`vendor`,`line_items`.`variant_id`, `line_items`.`variant_title`, `line_items`.`order_id`, `line_items`.`currency`, `line_items`.`presentment_price`, `line_items`.`price`, `line_items`.`gift_card` FROM `line_items` WHERE `line_items`.`id` = ASKJAKJSDASDKJ"
12+
13+
Benchmark.ips do |x|
14+
x.report("end-with-case?") do
15+
Semian::ActiveRecordTrilogyAdapter.query_allowlisted?(sql_not_commit)
16+
end
17+
18+
x.report("regex") { Semian::ActiveRecordTrilogyAdapter::QUERY_ALLOWLIST.match?(sql_not_commit) }
19+
x.compare!
20+
end

0 commit comments

Comments
 (0)