Skip to content

Commit 20b9bb1

Browse files
jhawthornDinah Shi
andcommitted
Intelligent batch preloading
This examines all the association branches we are being asked to preload and will delay loading an association if it's likely that we find a similar association later and can batch them together. For example, when loading Author.preload(:posts, favorite_authors: :posts).first The preloader now knows to delay loading the top level posts so that it can load both the top level :posts and the :posts from the favourite authors associations together. Co-authored-by: Dinah Shi <[email protected]>
1 parent 3a32f44 commit 20b9bb1

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)