Skip to content

Commit 6af8b70

Browse files
authored
Use deferred join to improve pagination performance (#3983)
* Use deferred join to improve pagination performance When tables have large number of records using LIMIT and larger values for OFFSET result in inefficient queries today. Instead of performing the pagination on the whole table, this technique performs the pagination on a subset of the data. The subset is generated by a subquery that only looks at the ID column (which is indexed) and then rejoins the result to the original table. This improve DB query times generated by the SequelPaginator on both MySQL and Postgres. This technique has been implemented and works for both pagination using OVER window functions and without. Sequel gem supports eager loading and eager graph loading which introduces complications for this technique, therefore we do not yet support this technique for this method. Additionally, some paginated queries are not generated from a table, but are simply the result of a subquery, for example Roles is a union of a permissions, we also explicitly do not perform this technique on these requests as there is no natural ID field that will have been indexed. We only do deferred joins when the SQL query "FROM" is from a table (e.g. :apps, "apps"). See https://planetscale.com/learn/courses/mysql-for-developers/examples/deferred-joins * Only remove SequelPaginator tmp field when present
1 parent 6a75725 commit 6af8b70

File tree

2 files changed

+92
-11
lines changed

2 files changed

+92
-11
lines changed

lib/cloud_controller/paging/sequel_paginator.rb

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ def get_page(dataset, pagination_options)
1818
records, count = if can_paginate_with_window_function?(dataset)
1919
paginate_with_window_function(dataset, per_page, page, table_name)
2020
else
21-
paginate_with_extension(dataset, per_page, page)
21+
paginate_with_extension(dataset, per_page, page, table_name)
2222
end
2323

2424
PaginatedResult.new(records, count, pagination_options)
@@ -34,20 +34,56 @@ def can_paginate_with_window_function?(dataset)
3434

3535
def paginate_with_window_function(dataset, per_page, page, table_name)
3636
dataset = dataset.from_self if dataset.opts[:distinct]
37-
dataset = if dataset.opts[:graph]
38-
dataset.add_graph_aliases(pagination_total_results: [table_name, :pagination_total_results, Sequel.function(:count).*.over])
39-
else
40-
dataset.select_append(Sequel.as(Sequel.function(:count).*.over, :pagination_total_results))
41-
end
42-
records = dataset.limit(per_page, (page - 1) * per_page).all
37+
38+
paged_dataset = dataset.limit(per_page, (page - 1) * per_page)
39+
40+
paged_dataset = if dataset.opts[:graph]
41+
paged_dataset.add_graph_aliases(pagination_total_results: [table_name, :pagination_total_results, Sequel.function(:count).*.over])
42+
elsif from_is_table?(dataset)
43+
dataset.join_table(
44+
:inner,
45+
paged_dataset.select(Sequel[table_name][:id].as(:tmp_deferred_id)).
46+
select_append(Sequel.as(Sequel.function(:count).*.over, :pagination_total_results)).
47+
as(:tmp_deferred_table),
48+
Sequel[table_name][:id] => Sequel[:tmp_deferred_table][:tmp_deferred_id]
49+
).select_append(:pagination_total_results)
50+
else
51+
paged_dataset.select_append(Sequel.as(Sequel.function(:count).*.over, :pagination_total_results))
52+
end
53+
54+
records = paged_dataset.all
55+
4356
count = records.any? ? records.first[:pagination_total_results] : 0
44-
records.each { |x| x.values.delete(:pagination_total_results) }
57+
58+
records.each do |x|
59+
x.values.delete(:pagination_total_results)
60+
x.values.delete(:tmp_deferred_id)
61+
end
62+
[records, count]
63+
end
64+
65+
def paginate_with_extension(dataset, per_page, page, table_name)
66+
paged_dataset = dataset.extension(:pagination).paginate(page, per_page)
67+
count = paged_dataset.pagination_record_count
68+
69+
if from_is_table?(dataset)
70+
paged_dataset = dataset.join_table(
71+
:inner,
72+
paged_dataset.select(Sequel[table_name][:id].as(:tmp_deferred_id)).as(:tmp_deferred_table),
73+
Sequel[table_name][:id] => Sequel[:tmp_deferred_table][:tmp_deferred_id]
74+
)
75+
end
76+
77+
records = paged_dataset.all
78+
79+
has_tmp_deferred_id = records.first&.keys&.include?(:tmp_deferred_id)
80+
records.each { |x| x.values.delete(:tmp_deferred_id) } if has_tmp_deferred_id
81+
4582
[records, count]
4683
end
4784

48-
def paginate_with_extension(dataset, per_page, page)
49-
query = dataset.extension(:pagination).paginate(page, per_page)
50-
[query.all, query.pagination_record_count]
85+
def from_is_table?(dataset)
86+
[Symbol, String].include?(dataset.opts[:from].first.class)
5187
end
5288
end
5389
end

spec/unit/lib/cloud_controller/paging/sequel_paginator_spec.rb

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ module VCAP::CloudController
1313
let!(:app_model2) { AppModel.make }
1414
let!(:app_model3) { AppModel.make }
1515
let!(:app_model4) { AppModel.make }
16+
let!(:space_manager_model) { SpaceManager.make }
17+
let!(:space_developer_model) { SpaceDeveloper.make }
1618
let(:page) { 1 }
1719
let(:per_page) { 1 }
1820

@@ -96,6 +98,49 @@ module VCAP::CloudController
9698
expect(paginated_result.records[0].associations[:space].name).to eq(space.name)
9799
end
98100

101+
it 'works when pages are generated from a subquery' do
102+
options = { page: page, per_page: per_page, order_by: :guid }
103+
pagination_options = PaginationOptions.new(options)
104+
dataset = Role.dataset
105+
paginated_result = nil
106+
expect do
107+
paginated_result = paginator.get_page(dataset, pagination_options)
108+
end.to have_queried_db_times(/select/i, paginator.can_paginate_with_window_function?(dataset) ? 1 : 2)
109+
expect(paginated_result.total).to eq(2)
110+
end
111+
112+
context 'when not using window functions' do
113+
let(:my_config) do
114+
{
115+
db: {
116+
enable_paginate_window: false
117+
}
118+
}
119+
end
120+
121+
before do
122+
TestConfig.override(**my_config)
123+
end
124+
125+
it 'works when pages are generated from a subquery' do
126+
options = { page: page, per_page: per_page, order_by: :guid }
127+
pagination_options = PaginationOptions.new(options)
128+
dataset = Role.dataset
129+
paginated_result = nil
130+
expect do
131+
paginated_result = paginator.get_page(dataset, pagination_options)
132+
end.to have_queried_db_times(/select/i, 2)
133+
expect(paginated_result.total).to eq(2)
134+
end
135+
end
136+
137+
it 'paged results do not contain extra columns' do
138+
options = { page:, per_page: }
139+
pagination_options = PaginationOptions.new(options)
140+
paginated_result = paginator.get_page(dataset, pagination_options)
141+
expect(paginated_result.records.first.keys).to match_array(AppModel.columns)
142+
end
143+
99144
it 'orders by GUID as a secondary field when available' do
100145
options = { page: page, per_page: 2, order_by: 'created_at', order_direction: 'asc' }
101146
app_model1.update(guid: '1', created_at: '2019-12-25T13:00:00Z')

0 commit comments

Comments
 (0)