Skip to content

Commit 0ff395e

Browse files
authored
Merge pull request rails#41790 from jhawthorn/preloader_smart_batching
"Smart" ActiveRecord Preloader batching
2 parents b206e2f + 20b9bb1 commit 0ff395e

File tree

5 files changed

+128
-12
lines changed

5 files changed

+128
-12
lines changed

activerecord/lib/active_record/associations/preloader/association.rb

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ def load_records_in_batch(loaders)
4040
end
4141
end
4242

43+
attr_reader :klass
44+
4345
def initialize(klass, owners, reflection, preload_scope, associate_by_default = true)
4446
@klass = klass
4547
@owners = owners.uniq(&:__id__)
@@ -50,8 +52,20 @@ def initialize(klass, owners, reflection, preload_scope, associate_by_default =
5052
@run = false
5153
end
5254

53-
def already_loaded?
54-
@already_loaded ||= owners.all? { |o| o.association(reflection.name).loaded? }
55+
def table_name
56+
@klass.table_name
57+
end
58+
59+
def data_available?
60+
already_loaded?
61+
end
62+
63+
def future_classes
64+
if run? || already_loaded?
65+
[]
66+
else
67+
[@klass]
68+
end
5569
end
5670

5771
def runnable_loaders
@@ -149,7 +163,11 @@ def load_records(raw_records = nil)
149163
end
150164

151165
private
152-
attr_reader :owners, :reflection, :preload_scope, :model, :klass
166+
attr_reader :owners, :reflection, :preload_scope, :model
167+
168+
def already_loaded?
169+
@already_loaded ||= owners.all? { |o| o.association(reflection.name).loaded? }
170+
end
153171

154172
def fetch_from_preloaded_records
155173
@records_by_owner = owners.index_with do |owner|

activerecord/lib/active_record/associations/preloader/batch.rb

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,20 @@ def call
1313
until branches.empty?
1414
loaders = branches.flat_map(&:runnable_loaders)
1515

16-
already_loaded, loaders = loaders.partition(&:already_loaded?)
17-
already_loaded.each(&:run)
18-
19-
group_and_load_similar(loaders)
20-
loaders.each(&:run)
16+
already_loaded = loaders.select(&:data_available?)
17+
if already_loaded.any?
18+
already_loaded.each(&:run)
19+
elsif loaders.any?
20+
future_tables = branches.flat_map do |branch|
21+
branch.future_classes - branch.runnable_loaders.map(&:klass)
22+
end.map(&:table_name).uniq
23+
24+
target_loaders = loaders.reject { |l| future_tables.include?(l.table_name) }
25+
target_loaders = loaders if target_loaders.empty?
26+
27+
group_and_load_similar(target_loaders)
28+
target_loaders.each(&:run)
29+
end
2130

2231
finished, in_progress = branches.partition(&:done?)
2332

activerecord/lib/active_record/associations/preloader/branch.rb

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,40 @@ def initialize(association:, children:, parent:, associate_by_default:, scope:)
1515
@associate_by_default = associate_by_default
1616

1717
@children = build_children(children)
18+
@loaders = nil
19+
end
20+
21+
def future_classes
22+
(immediate_future_classes + children.flat_map(&:future_classes)).uniq
23+
end
24+
25+
def immediate_future_classes
26+
if parent.done?
27+
loaders.flat_map(&:future_classes).uniq
28+
else
29+
likely_reflections.reject(&:polymorphic?).flat_map do |reflection|
30+
reflection.
31+
chain.
32+
map(&:klass)
33+
end.uniq
34+
end
35+
end
36+
37+
def target_classes
38+
if done?
39+
preloaded_records.map(&:klass).uniq
40+
elsif parent.done?
41+
loaders.map(&:klass).uniq
42+
else
43+
likely_reflections.reject(&:polymorphic?).map(&:klass).uniq
44+
end
45+
end
46+
47+
def likely_reflections
48+
parent_classes = parent.target_classes
49+
parent_classes.map do |parent_klass|
50+
parent_klass._reflect_on_association(@association)
51+
end.compact
1852
end
1953

2054
def root?
@@ -30,7 +64,7 @@ def preloaded_records
3064
end
3165

3266
def done?
33-
loaders.all?(&:run?)
67+
root? || (@loaders && @loaders.all?(&:run?))
3468
end
3569

3670
def runnable_loaders

activerecord/lib/active_record/associations/preloader/through_association.rb

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,16 +35,37 @@ def records_by_owner
3535
end
3636
end
3737

38+
def data_available?
39+
return true if super()
40+
through_preloaders.all?(&:run?) &&
41+
source_preloaders.all?(&:run?)
42+
end
43+
3844
def runnable_loaders
39-
if already_loaded?
45+
if data_available?
4046
[self]
4147
elsif through_preloaders.all?(&:run?)
42-
[self] + source_preloaders.flat_map(&:runnable_loaders)
48+
source_preloaders.flat_map(&:runnable_loaders)
4349
else
4450
through_preloaders.flat_map(&:runnable_loaders)
4551
end
4652
end
4753

54+
def future_classes
55+
if run? || data_available?
56+
[]
57+
elsif through_preloaders.all?(&:run?)
58+
source_preloaders.flat_map(&:future_classes).uniq
59+
else
60+
through_classes = through_preloaders.flat_map(&:future_classes)
61+
source_classes = source_reflection.
62+
chain.
63+
reject { |reflection| reflection.respond_to?(:polymorphic?) && reflection.polymorphic? }.
64+
map(&:klass)
65+
(through_classes + source_classes).uniq
66+
end
67+
end
68+
4869
private
4970
def source_preloaders
5071
@source_preloaders ||= ActiveRecord::Associations::Preloader.new(records: middle_records, associations: source_reflection.name, scope: scope, associate_by_default: false).loaders

activerecord/test/cases/associations_test.rb

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -362,7 +362,7 @@ def test_requires_symbol_argument
362362
end
363363

364364
class PreloaderTest < ActiveRecord::TestCase
365-
fixtures :posts, :comments, :books, :authors
365+
fixtures :posts, :comments, :books, :authors, :tags, :taggings
366366

367367
def test_preload_with_scope
368368
post = posts(:welcome)
@@ -569,6 +569,40 @@ def test_preload_with_grouping_sets_inverse_association
569569
end
570570
end
571571

572+
def test_preload_can_group_separate_levels
573+
mary = authors(:mary)
574+
bob = authors(:bob)
575+
576+
AuthorFavorite.create!(author: mary, favorite_author: bob)
577+
578+
assert_queries(3) do
579+
preloader = ActiveRecord::Associations::Preloader.new(records: [mary], associations: [:posts, favorite_authors: :posts])
580+
preloader.call
581+
end
582+
583+
assert_no_queries do
584+
mary.posts
585+
mary.favorite_authors.map(&:posts)
586+
end
587+
end
588+
589+
def test_preload_can_group_multi_level_ping_pong_through
590+
mary = authors(:mary)
591+
bob = authors(:bob)
592+
593+
AuthorFavorite.create!(author: mary, favorite_author: bob)
594+
595+
assert_queries(9) do
596+
preloader = ActiveRecord::Associations::Preloader.new(records: [mary], associations: { similar_posts: :comments, favorite_authors: { similar_posts: :comments } })
597+
preloader.call
598+
end
599+
600+
assert_no_queries do
601+
mary.similar_posts.map(&:comments).each(&:to_a)
602+
mary.favorite_authors.flat_map(&:similar_posts).map(&:comments).each(&:to_a)
603+
end
604+
end
605+
572606
def test_preload_does_not_group_same_class_different_scope
573607
post = posts(:welcome)
574608
postesque = Postesque.create(author: Author.last)

0 commit comments

Comments
 (0)