Skip to content

Commit 4b8288e

Browse files
skipkayhilbyroot
authored andcommitted
Enable statement-cached queries to be retryable
The StatementCache uses a different code path for generating SQL and executing queries, so it doesn't currently benefit from the ability to mark queries as retryable. This mainly affects association SELECTs, because the other user of StatementCache is `find_by` and it explicitly passes `allow_retry: true` when executing a cached query. This commit enables queries that would be retryable without using the StatementCache to also be retryable when using it. Just like in `to_sql_and_binds`, `cacheable_query` now sets `retryable: true` on the `collector` that constructs the query and additionally copies the `collector`'s final `retryable` value to the query object. Then, instead of accepting an `allow_retry` parameter when executing the cached query, `execute` now uses the query object's `retryable` attribute. The test for this case uses Author/Books instead of Post/Comments because Comment has a `default_scope`, so `post.comments` does not use the statement cache.
1 parent fc01241 commit 4b8288e

File tree

5 files changed

+27
-14
lines changed

5 files changed

+27
-14
lines changed

activerecord/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
* Enable automatically retrying idempotent association queries on connection
2+
errors.
3+
4+
*Hartley McGuire*
5+
16
* Add `allow_retry` to `sql.active_record` instrumentation.
27

38
This enables identifying queries which are and are not automatically

activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,15 @@ def to_sql_and_binds(arel_or_sql_string, binds = [], preparable = nil, allow_ret
5555
# can be used to query the database repeatedly.
5656
def cacheable_query(klass, arel) # :nodoc:
5757
if prepared_statements
58+
collector = collector()
59+
collector.retryable = true
5860
sql, binds = visitor.compile(arel.ast, collector)
59-
query = klass.query(sql)
61+
query = klass.query(sql, retryable: collector.retryable)
6062
else
6163
collector = klass.partial_query_collector
64+
collector.retryable = true
6265
parts, binds = visitor.compile(arel.ast, collector)
63-
query = klass.partial_query(parts)
66+
query = klass.partial_query(parts, retryable: collector.retryable)
6467
end
6568
[query, binds]
6669
end

activerecord/lib/active_record/core.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -450,7 +450,7 @@ def cached_find_by(keys, values)
450450
where(wheres).limit(1)
451451
}
452452

453-
statement.execute(values.flatten, connection, allow_retry: true).then do |r|
453+
statement.execute(values.flatten, connection).then do |r|
454454
r.first
455455
rescue TypeError
456456
raise ActiveRecord::StatementInvalid

activerecord/lib/active_record/statement_cache.rb

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,11 @@ class StatementCache # :nodoc:
3131
class Substitute; end # :nodoc:
3232

3333
class Query # :nodoc:
34-
def initialize(sql)
34+
attr_reader :retryable
35+
36+
def initialize(sql, retryable:)
3537
@sql = sql
38+
@retryable = retryable
3639
end
3740

3841
def sql_for(binds, connection)
@@ -41,11 +44,12 @@ def sql_for(binds, connection)
4144
end
4245

4346
class PartialQuery < Query # :nodoc:
44-
def initialize(values)
47+
def initialize(values, retryable:)
4548
@values = values
4649
@indexes = values.each_with_index.find_all { |thing, i|
4750
Substitute === thing
4851
}.map(&:last)
52+
@retryable = retryable
4953
end
5054

5155
def sql_for(binds, connection)
@@ -94,12 +98,12 @@ def value
9498
end
9599
end
96100

97-
def self.query(sql)
98-
Query.new(sql)
101+
def self.query(...)
102+
Query.new(...)
99103
end
100104

101-
def self.partial_query(values)
102-
PartialQuery.new(values)
105+
def self.partial_query(...)
106+
PartialQuery.new(...)
103107
end
104108

105109
def self.partial_query_collector
@@ -142,14 +146,14 @@ def initialize(query_builder, bind_map, model)
142146
@model = model
143147
end
144148

145-
def execute(params, connection, allow_retry: false, async: false, &block)
149+
def execute(params, connection, async: false, &block)
146150
bind_values = @bind_map.bind params
147151
sql = @query_builder.sql_for bind_values, connection
148152

149153
if async
150-
@model.async_find_by_sql(sql, bind_values, preparable: true, allow_retry: allow_retry, &block)
154+
@model.async_find_by_sql(sql, bind_values, preparable: true, allow_retry: @query_builder.retryable, &block)
151155
else
152-
@model.find_by_sql(sql, bind_values, preparable: true, allow_retry: allow_retry, &block)
156+
@model.find_by_sql(sql, bind_values, preparable: true, allow_retry: @query_builder.retryable, &block)
153157
end
154158
rescue ::RangeError
155159
async ? Promise.wrap([]) : []

activerecord/test/cases/adapter_test.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -702,14 +702,15 @@ def teardown
702702

703703
test "idempotent SELECT queries allow retries" do
704704
notifications = capture_notifications("sql.active_record") do
705-
assert Post.first
705+
assert (a = Author.first)
706706
assert Post.where(id: [1, 2]).first
707707
assert Post.find(1)
708708
assert Post.find_by(title: "Welcome to the weblog")
709709
assert_predicate Post, :exists?
710+
a.books.to_a
710711
end.select { |n| n.payload[:name] != "SCHEMA" }
711712

712-
assert_equal 5, notifications.length
713+
assert_equal 6, notifications.length
713714

714715
notifications.each do |n|
715716
assert n.payload[:allow_retry]

0 commit comments

Comments
 (0)