Skip to content

Commit fe92250

Browse files
committed
Add an internal API to trigger association loading asynchronously
This is not yet a public API, I'd like to experiment with a few ideas and run it by a few people first. But that's pretty much all the necessary plumbing. One big limitation right now is with `has_many through`, as it requires two queries back to back. I'm not sure how to best handle that, but I think worst case we could at least trigger the first of the two, that would already be a win.
1 parent 01a5efc commit fe92250

File tree

12 files changed

+200
-60
lines changed

12 files changed

+200
-60
lines changed

activerecord/lib/active_record/associations/association.rb

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ module Associations
3434
# the <tt>reflection</tt> object represents a <tt>:has_many</tt> macro.
3535
class Association # :nodoc:
3636
attr_accessor :owner
37-
attr_reader :target, :reflection, :disable_joins
37+
attr_reader :reflection, :disable_joins
3838

3939
delegate :options, to: :reflection
4040

@@ -50,6 +50,13 @@ def initialize(owner, reflection)
5050
@skip_strict_loading = nil
5151
end
5252

53+
def target
54+
if @target.is_a?(Promise)
55+
@target = @target.value
56+
end
57+
@target
58+
end
59+
5360
# Resets the \loaded flag to +false+ and sets the \target to +nil+.
5461
def reset
5562
@loaded = false
@@ -172,14 +179,21 @@ def extensions
172179
# ActiveRecord::RecordNotFound is rescued within the method, and it is
173180
# not reraised. The proxy is \reset and +nil+ is the return value.
174181
def load_target
175-
@target = find_target if (@stale_state && stale_target?) || find_target?
182+
@target = find_target(async: false) if (@stale_state && stale_target?) || find_target?
176183

177184
loaded! unless loaded?
178185
target
179186
rescue ActiveRecord::RecordNotFound
180187
reset
181188
end
182189

190+
def async_load_target # :nodoc:
191+
@target = find_target(async: true) if (@stale_state && stale_target?) || find_target?
192+
193+
loaded! unless loaded?
194+
nil
195+
end
196+
183197
# We can't dump @reflection and @through_reflection since it contains the scope proc
184198
def marshal_dump
185199
ivars = (instance_variables - [:@reflection, :@through_reflection]).map { |name| [name, instance_variable_get(name)] }
@@ -223,13 +237,19 @@ def ensure_klass_exists!
223237
klass
224238
end
225239

226-
def find_target
240+
def find_target(async: false)
227241
if violates_strict_loading?
228242
Base.strict_loading_violation!(owner: owner.class, reflection: reflection)
229243
end
230244

231245
scope = self.scope
232-
return scope.to_a if skip_statement_cache?(scope)
246+
if skip_statement_cache?(scope)
247+
if async
248+
return scope.load_async.then(&:to_a)
249+
else
250+
return scope.to_a
251+
end
252+
end
233253

234254
sc = reflection.association_scope_cache(klass, owner) do |params|
235255
as = AssociationScope.create { params.bind }
@@ -238,7 +258,7 @@ def find_target
238258

239259
binds = AssociationScope.get_bind_values(owner, reflection.chain)
240260
klass.with_connection do |c|
241-
sc.execute(binds, c) do |record|
261+
sc.execute(binds, c, async: async) do |record|
242262
set_inverse_instance(record)
243263
if owner.strict_loading_n_plus_one_only? && reflection.macro == :has_many
244264
record.strict_loading!

activerecord/lib/active_record/associations/has_many_through_association.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,8 @@ def delete_through_records(records)
216216
end
217217
end
218218

219-
def find_target
219+
def find_target(async: false)
220+
raise NotImplementedError, "No async loading for HasManyThroughAssociation yet" if async
220221
return [] unless target_reflection_has_associated_record?
221222
return scope.to_a if disable_joins
222223
super

activerecord/lib/active_record/associations/singular_association.rb

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ def reader
1818
def reset
1919
super
2020
@target = nil
21+
@future_target = nil
2122
end
2223

2324
# Implements the writer method, e.g. foo.bar= for Foo.belongs_to :bar
@@ -43,11 +44,15 @@ def scope_for_create
4344
super.except!(*Array(klass.primary_key))
4445
end
4546

46-
def find_target
47+
def find_target(async: false)
4748
if disable_joins
48-
scope.first
49+
if async
50+
scope.load_async.then(&:first)
51+
else
52+
scope.first
53+
end
4954
else
50-
super.first
55+
super.then(&:first)
5156
end
5257
end
5358

activerecord/lib/active_record/core.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -432,8 +432,8 @@ def cached_find_by(keys, values)
432432
where(wheres).limit(1)
433433
}
434434

435-
begin
436-
statement.execute(values.flatten, connection, allow_retry: true).first
435+
statement.execute(values.flatten, connection, allow_retry: true).then do |r|
436+
r.first
437437
rescue TypeError
438438
raise ActiveRecord::StatementInvalid
439439
end

activerecord/lib/active_record/querying.rb

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,10 @@ def find_by_sql(sql, binds = [], preparable: nil, allow_retry: false, &block)
5656
end
5757

5858
# Same as <tt>#find_by_sql</tt> but perform the query asynchronously and returns an ActiveRecord::Promise.
59-
def async_find_by_sql(sql, binds = [], preparable: nil, &block)
60-
result = with_connection do |c|
61-
_query_by_sql(c, sql, binds, preparable: preparable, async: true)
62-
end
63-
64-
result.then do |result|
59+
def async_find_by_sql(sql, binds = [], preparable: nil, allow_retry: false, &block)
60+
with_connection do |c|
61+
_query_by_sql(c, sql, binds, preparable: preparable, allow_retry: allow_retry, async: true)
62+
end.then do |result|
6563
_load_from_sql(result, &block)
6664
end
6765
end

activerecord/lib/active_record/relation.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1150,6 +1150,16 @@ def load_async
11501150
self
11511151
end
11521152

1153+
def then(&block) # :nodoc:
1154+
if @future_result
1155+
@future_result.then do
1156+
yield self
1157+
end
1158+
else
1159+
super
1160+
end
1161+
end
1162+
11531163
# Returns <tt>true</tt> if the relation was scheduled on the background
11541164
# thread pool.
11551165
def scheduled?

activerecord/lib/active_record/statement_cache.rb

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -142,14 +142,17 @@ def initialize(query_builder, bind_map, model)
142142
@model = model
143143
end
144144

145-
def execute(params, connection, allow_retry: false, &block)
145+
def execute(params, connection, allow_retry: false, async: false, &block)
146146
bind_values = @bind_map.bind params
147-
148147
sql = @query_builder.sql_for bind_values, connection
149148

150-
@model.find_by_sql(sql, bind_values, preparable: true, allow_retry: allow_retry, &block)
149+
if async
150+
@model.async_find_by_sql(sql, bind_values, preparable: true, allow_retry: allow_retry, &block)
151+
else
152+
@model.find_by_sql(sql, bind_values, preparable: true, allow_retry: allow_retry, &block)
153+
end
151154
rescue ::RangeError
152-
[]
155+
async ? Promise.wrap([]) : []
153156
end
154157

155158
def self.unsupported_value?(value)

activerecord/test/cases/associations/belongs_to_associations_test.rb

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1839,3 +1839,37 @@ def test_destroy_linked_models
18391839
assert_not Author.exists?(author.id)
18401840
end
18411841
end
1842+
1843+
class AsyncBelongsToAssociationsTest < ActiveRecord::TestCase
1844+
include WaitForAsyncTestHelper
1845+
1846+
self.use_transactional_tests = false
1847+
1848+
fixtures :companies
1849+
1850+
unless in_memory_db?
1851+
def test_async_load_belongs_to
1852+
client = Client.find(3)
1853+
first_firm = companies(:first_firm)
1854+
1855+
client.association(:firm).async_load_target
1856+
wait_for_async_query
1857+
1858+
events = []
1859+
callback = -> (event) do
1860+
events << event unless event.payload[:name] == "SCHEMA"
1861+
end
1862+
ActiveSupport::Notifications.subscribed(callback, "sql.active_record") do
1863+
client.firm
1864+
end
1865+
1866+
assert_no_queries do
1867+
assert_equal first_firm, client.firm
1868+
assert_equal first_firm.name, client.firm.name
1869+
end
1870+
1871+
assert_equal 1, events.size
1872+
assert_equal true, events.first.payload[:async]
1873+
end
1874+
end
1875+
end

activerecord/test/cases/associations/has_many_associations_test.rb

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3252,3 +3252,36 @@ def force_signal37_to_load_all_clients_of_firm
32523252
companies(:first_firm).clients_of_firm.load_target
32533253
end
32543254
end
3255+
3256+
class AsyncHasOneAssociationsTest < ActiveRecord::TestCase
3257+
include WaitForAsyncTestHelper
3258+
3259+
self.use_transactional_tests = false
3260+
3261+
fixtures :companies
3262+
3263+
unless in_memory_db?
3264+
def test_async_load_has_many
3265+
firm = companies(:first_firm)
3266+
3267+
firm.association(:clients).async_load_target
3268+
wait_for_async_query
3269+
3270+
events = []
3271+
callback = -> (event) do
3272+
events << event unless event.payload[:name] == "SCHEMA"
3273+
end
3274+
3275+
ActiveSupport::Notifications.subscribed(callback, "sql.active_record") do
3276+
assert_equal 3, firm.clients.size
3277+
end
3278+
3279+
assert_no_queries do
3280+
assert_not_nil firm.clients[2]
3281+
end
3282+
3283+
assert_equal 1, events.size
3284+
assert_equal true, events.first.payload[:async]
3285+
end
3286+
end
3287+
end

activerecord/test/cases/associations/has_one_associations_test.rb

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -943,3 +943,37 @@ def test_has_one_with_touch_option_on_nonpersisted_built_associations_doesnt_upd
943943
MESSAGE
944944
end
945945
end
946+
947+
class AsyncHasOneAssociationsTest < ActiveRecord::TestCase
948+
include WaitForAsyncTestHelper
949+
950+
self.use_transactional_tests = false
951+
952+
fixtures :companies, :accounts
953+
954+
unless in_memory_db?
955+
def test_async_load_has_one
956+
firm = companies(:first_firm)
957+
first_account = Account.find(1)
958+
959+
firm.association(:account).async_load_target
960+
wait_for_async_query
961+
962+
events = []
963+
callback = -> (event) do
964+
events << event unless event.payload[:name] == "SCHEMA"
965+
end
966+
ActiveSupport::Notifications.subscribed(callback, "sql.active_record") do
967+
firm.account
968+
end
969+
970+
assert_no_queries do
971+
assert_equal first_account, firm.account
972+
assert_equal first_account.credit_limit, firm.account.credit_limit
973+
end
974+
975+
assert_equal 1, events.size
976+
assert_equal true, events.first.payload[:async]
977+
end
978+
end
979+
end

0 commit comments

Comments
 (0)