Skip to content

Commit d6ea21b

Browse files
HamptonMakesclaude
andauthored
The index reads every plan's tags once, not once per plan (#200)
The last thing #196 left on the table. Its location preload had to count only the location tables, because counting everything would have failed on `tag_names` — which was doing a query a row — and the number would then have been bumped to whatever made it pass. That note called the pluck the cause and left it alone. The pluck isn't the cause. `pluck` on a relation that's already loaded reads the loaded records instead of querying; it has since Rails 6. So `tag_names` is free on a plan whose tags are loaded and one query on a plan whose tags aren't, and the whole story is that the API index never eager-loaded `:tags`. Five plans, five `Tag Pluck` queries; add `:tags` to the includes and there are none. Every other list endpoint that reads tags — the libraries organization API, the workspace index, home, search — already preloads them, which is why this only ever showed up here. So: `:tags` joins the index's eager loads, and the comment on tag_names now says which of its two costs a caller is paying, so the next reader doesn't re-derive this the way I just did. The spec is the location one's twin — tag-table query count for thirty plans equals the count for three, plus the assertion that each plan still gets its own tags rather than a neighbour's, since a preload can be cheap and wrong. Both now share the counter as a helper. It fails without the includes: thirty queries against three. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 0ef95d0 commit d6ea21b

3 files changed

Lines changed: 49 additions & 20 deletions

File tree

engine/app/controllers/coplan/api/v1/plans_controller.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ def index
1212
# folder's ancestors, and `url` walks those *and* the library for
1313
# its handle. Left to the associations that's several queries a
1414
# plan on a list endpoint agents page through.
15-
.includes(:plan_type, :created_by_user,
15+
.includes(:plan_type, :created_by_user, :tags,
1616
placement: [ :library, { folder: { parent: :parent } } ])
1717
.visible_to(current_user)
1818
.order(updated_at: :desc)

engine/app/models/coplan/plan.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,9 @@ def stripped_content
289289
end
290290
end
291291

292+
# Free when `tags` is already loaded — `pluck` reads the loaded records
293+
# instead of querying — and one query when it isn't. So a list endpoint
294+
# pays a query a plan unless it eager-loads `:tags`; they all do.
292295
def tag_names
293296
tags.pluck(:name)
294297
end

spec/requests/api/v1/plans_spec.rb

Lines changed: 45 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -13,36 +13,36 @@
1313
alice_token # ensure token exists
1414
end
1515

16+
# Counts the index's queries against one set of tables rather than all of
17+
# them: each preload spec below is about a single field, and a total would
18+
# fail for an unrelated query and then get bumped to whatever number made
19+
# it pass. The assertion is a shape — the cost doesn't follow the list —
20+
# not a magic number.
21+
def index_queries_touching(table_pattern)
22+
count = 0
23+
sub = ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload|
24+
next if payload[:name].to_s =~ /SCHEMA|TRANSACTION/
25+
count += 1 if payload[:sql].to_s.match?(table_pattern)
26+
end
27+
get api_v1_plans_path, headers: headers
28+
ActiveSupport::Notifications.unsubscribe(sub)
29+
expect(response).to have_http_status(:success)
30+
count
31+
end
32+
1633
# Every row carries `url` and `folder_path`, and both want the plan's
1734
# whole location — the folder's ancestors, and the library for its handle.
1835
# Reached through the associations that's several queries per plan on a
1936
# list agents page through, so the location is preloaded.
20-
#
21-
# Counts only the location tables, not every query: `tag_names` plucks per
22-
# plan for reasons of its own, and a total would fail for that instead and
23-
# then get bumped to whatever number made it pass. Asserted as a shape —
24-
# the cost doesn't follow the list — rather than a magic number.
2537
it "reads each plan's location once for the whole index, not once per plan" do
2638
folder = create(:folder, name: "LiveOrder", created_by_user: alice)
2739
nested = create(:folder, name: "Q3", parent: folder, created_by_user: alice)
2840

29-
location_queries = lambda do
30-
count = 0
31-
sub = ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload|
32-
next if payload[:name].to_s =~ /SCHEMA|TRANSACTION/
33-
count += 1 if payload[:sql].to_s.match?(/coplan_(plan_placements|folders|libraries)/)
34-
end
35-
get api_v1_plans_path, headers: headers
36-
ActiveSupport::Notifications.unsubscribe(sub)
37-
expect(response).to have_http_status(:success)
38-
count
39-
end
40-
4141
3.times { |i| CoPlan::Plans::Place.call(plan: create(:plan, :considering, created_by_user: alice, title: "Roadmap #{i}"), folder: nested, actor: alice) }
42-
few = location_queries.call
42+
few = index_queries_touching(/coplan_(plan_placements|folders|libraries)/)
4343

4444
27.times { |i| CoPlan::Plans::Place.call(plan: create(:plan, :considering, created_by_user: alice, title: "Later #{i}"), folder: folder, actor: alice) }
45-
many = location_queries.call
45+
many = index_queries_touching(/coplan_(plan_placements|folders|libraries)/)
4646

4747
body = JSON.parse(response.body)
4848
expect(body.size).to eq(30)
@@ -53,6 +53,32 @@
5353
expect(many).to eq(few)
5454
end
5555

56+
# Same story for `tags`: `tag_names` reads them straight off the plan when
57+
# they're loaded, and queries for them when they aren't, so leaving them
58+
# out of the index's eager loads costs a query a row.
59+
it "reads every plan's tags once for the whole index, not once per plan" do
60+
tag_plans = lambda do |count, prefix|
61+
count.times do |i|
62+
plan = create(:plan, :considering, created_by_user: alice, title: "#{prefix} #{i}")
63+
plan.tag_names = [ "roadmap", "#{prefix.downcase}-#{i}" ]
64+
end
65+
end
66+
67+
tag_plans.call(3, "Roadmap")
68+
few = index_queries_touching(/coplan_(tags|plan_tags)/)
69+
70+
tag_plans.call(27, "Later")
71+
many = index_queries_touching(/coplan_(tags|plan_tags)/)
72+
73+
body = JSON.parse(response.body)
74+
expect(body.size).to eq(30)
75+
# Cheap and correct: each plan still gets its own two tags, not another's.
76+
expected = (0...3).map { |i| [ "roadmap", "roadmap-#{i}" ] } +
77+
(0...27).map { |i| [ "later-#{i}", "roadmap" ] }
78+
expect(body.map { |p| p["tags"].sort }.sort).to eq(expected.map(&:sort).sort)
79+
expect(many).to eq(few)
80+
end
81+
5682
it "index returns plans" do
5783
plan # trigger creation
5884
get api_v1_plans_path, headers: headers

0 commit comments

Comments
 (0)