Skip to content

Commit c96150b

Browse files
gumclawclaude
andcommitted
Fix N+1 Elasticsearch queries in Api::V2::LinksController#index
The index action was calling successful_sales_count and total_usd_cents individually on each product when scopes include view_sales/account, resulting in 2N Elasticsearch queries. For users with many products, this caused Rack::Timeout (120s) exceptions. Add batch_successful_sales_counts and batch_total_usd_cents class methods to Product::Stats that use ES terms aggregation on product_id to fetch per-product values in just 2 queries total. The controller preloads these and passes them through as_json_options, with fallback to individual queries for other callers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 48b4d13 commit c96150b

6 files changed

Lines changed: 102 additions & 2 deletions

File tree

app/controllers/api/v2/links_controller.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ def index
3434
preloaded_ppp_factors: PurchasingPowerParityService.new.get_all_countries_factors(current_resource_owner)
3535
}
3636

37+
if (doorkeeper_token.scopes & %w[view_sales account]).present?
38+
as_json_options[:preloaded_sales_counts] = Link.batch_successful_sales_counts(products: products)
39+
as_json_options[:preloaded_total_usd_cents] = Link.batch_total_usd_cents(products: products)
40+
end
41+
3742
products_as_json = products.as_json(as_json_options)
3843

3944
render json: { success: true, products: products_as_json }

app/models/concerns/product/as_json.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,8 +165,8 @@ def as_json_for_api(options)
165165

166166
if (options[:api_scopes] & %w[view_sales account]).present?
167167
json["custom_delivery_url"] = nil # Deprecated
168-
json["sales_count"] = successful_sales_count
169-
json["sales_usd_cents"] = total_usd_cents
168+
json["sales_count"] = options[:preloaded_sales_counts]&.fetch(id, 0) || successful_sales_count
169+
json["sales_usd_cents"] = options[:preloaded_total_usd_cents]&.fetch(id, 0) || total_usd_cents
170170
end
171171

172172
json

app/modules/product/stats.rb

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,46 @@ def successful_sales_count(products:, extra_search_options: nil)
1919
PurchaseSearchService.search(search_options).results.total
2020
end
2121

22+
def batch_successful_sales_counts(products:)
23+
return {} if products.blank?
24+
25+
search_options = Purchase::ACTIVE_SALES_SEARCH_OPTIONS.merge(
26+
product: products,
27+
size: 0,
28+
aggs: {
29+
per_product: {
30+
terms: { field: "product_id", size: Array.wrap(products).size }
31+
}
32+
}
33+
)
34+
result = PurchaseSearchService.search(search_options)
35+
result.aggregations.per_product.buckets.each_with_object({}) do |bucket, hash|
36+
hash[bucket[:key]] = bucket[:doc_count]
37+
end
38+
end
39+
40+
def batch_total_usd_cents(products:)
41+
return {} if products.blank?
42+
43+
search_options = Purchase::CHARGED_SALES_SEARCH_OPTIONS.merge(
44+
product: products,
45+
size: 0,
46+
aggs: {
47+
per_product: {
48+
terms: { field: "product_id", size: Array.wrap(products).size },
49+
aggs: {
50+
price_cents_total: { sum: { field: "price_cents" } },
51+
amount_refunded_cents_total: { sum: { field: "amount_refunded_cents" } },
52+
}
53+
}
54+
}
55+
)
56+
result = PurchaseSearchService.search(search_options)
57+
result.aggregations.per_product.buckets.each_with_object({}) do |bucket, hash|
58+
hash[bucket[:key]] = bucket.dig(:price_cents_total, :value) - bucket.dig(:amount_refunded_cents_total, :value)
59+
end
60+
end
61+
2262
def monthly_recurring_revenue(products:)
2363
return 0 if products.blank?
2464

spec/controllers/api/v2/links_controller_spec.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,13 @@
6363
@product2.reload
6464
expect(response.parsed_body).to eq({ success: true, products: [@product2, @product1] }.as_json(api_scopes: ["view_sales"], slim: true))
6565
end
66+
67+
it "batch preloads sales stats instead of querying per product" do
68+
expect(Link).to receive(:batch_successful_sales_counts).once.and_return({})
69+
expect(Link).to receive(:batch_total_usd_cents).once.and_return({})
70+
get @action, params: @params
71+
expect(response).to be_successful
72+
end
6673
end
6774

6875
it "grants access with the account scope" do

spec/models/concerns/product/as_json_spec.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,18 @@
204204
expect(result["sales_count"]).to eq(1)
205205
expect(result["sales_usd_cents"]).to eq(100)
206206
end
207+
208+
it "uses preloaded sales data when provided" do
209+
product = create(:product)
210+
211+
result = product.as_json(
212+
api_scopes: %w[view_sales],
213+
preloaded_sales_counts: { product.id => 42 },
214+
preloaded_total_usd_cents: { product.id => 9900 }
215+
)
216+
expect(result["sales_count"]).to eq(42)
217+
expect(result["sales_usd_cents"]).to eq(9900)
218+
end
207219
end
208220
end
209221

spec/modules/product/stats_spec.rb

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,42 @@
5656
end
5757
end
5858

59+
describe ".batch_successful_sales_counts", :sidekiq_inline, :elasticsearch_wait_for_refresh do
60+
it "returns per-product sales counts in a single query" do
61+
product1 = create(:product, price_cents: 500)
62+
product2 = create(:product, price_cents: 500)
63+
create_list(:purchase, 2, link: product1)
64+
create(:purchase, link: product1, stripe_refunded: true)
65+
create(:purchase, link: product2)
66+
67+
counts = Link.batch_successful_sales_counts(products: [product1, product2])
68+
expect(counts[product1.id]).to eq(2)
69+
expect(counts[product2.id]).to eq(1)
70+
end
71+
72+
it "returns an empty hash when products is blank" do
73+
expect(Link.batch_successful_sales_counts(products: [])).to eq({})
74+
end
75+
end
76+
77+
describe ".batch_total_usd_cents", :sidekiq_inline, :elasticsearch_wait_for_refresh do
78+
it "returns per-product net revenue in a single query" do
79+
product1 = create(:product, price_cents: 500)
80+
product2 = create(:product, price_cents: 300)
81+
create_list(:purchase, 2, link: product1)
82+
create(:purchase, link: product1, stripe_refunded: true)
83+
create(:purchase, link: product2)
84+
85+
totals = Link.batch_total_usd_cents(products: [product1, product2])
86+
expect(totals[product1.id]).to eq(1000)
87+
expect(totals[product2.id]).to eq(300)
88+
end
89+
90+
it "returns an empty hash when products is blank" do
91+
expect(Link.batch_total_usd_cents(products: [])).to eq({})
92+
end
93+
end
94+
5995
describe "#total_usd_cents", :sidekiq_inline, :elasticsearch_wait_for_refresh do
6096
it "returns net revenue" do
6197
product = create(:product)

0 commit comments

Comments
 (0)