Skip to content

Commit 2f609c9

Browse files
hsbtclaude
andcommitted
Avoid per-config wide-row queries in MCP tools
find_failure_origin issued SELECT reports.* per configuration, which took 2-5 seconds each on production (no composite index covers the datetime sort and each row carries the multi-KB ltsv column) and hit Heroku's 30s H12 timeout. Scan the window once with a narrow pluck and load only the boundary reports by id. failing_servers had the same shape in its latest-report check; replace it with one grouped query. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent d64eaca commit 2f609c9

2 files changed

Lines changed: 63 additions & 30 deletions

File tree

app/tools/mcp_tools/failing_servers.rb

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,17 +37,23 @@ def self.call(query:, branch: nil, from: nil, to: nil, server_context: nil)
3737
end
3838
match_ids = matches.map(&:id).to_set
3939

40+
# Latest report id per configuration in one grouped query. A per-config
41+
# ORDER BY datetime DESC LIMIT 1 loads wide rows without a usable index
42+
# and takes seconds each on production.
43+
latest_ids = Report.where(server_id: matches.map(&:server_id).uniq, branch: matches.map(&:branch).uniq).
44+
where("datetime >= ?", from_t).
45+
group(:server_id, :branch, :option).maximum(:id)
46+
4047
configs = matches.group_by { |r| [r.server_id, r.branch, r.option] }.map do |(server_id, br, opt), reports|
4148
first = reports.first
4249
last = reports.last
43-
latest = Report.where(server_id: server_id, branch: br, option: opt).order(datetime: :desc).first
4450
{
4551
server: first.server.name,
4652
server_id: server_id,
4753
branch: br,
4854
option: opt,
4955
occurrences: reports.size,
50-
still_failing: match_ids.include?(latest.id),
56+
still_failing: match_ids.include?(latest_ids[[server_id, br, opt]]),
5157
first_seen: first.as_mcp_json,
5258
last_seen: last.as_mcp_json.merge(matching_line: last.log_excerpt&.matching_line(query)).compact,
5359
}

app/tools/mcp_tools/find_failure_origin.rb

Lines changed: 55 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,16 @@ class FindFailureOrigin < MCP::Tool
33
extend Helpers
44

55
MAX_REPORTS_PER_CONFIG = 300
6+
MAX_MATCH_IDS = 10_000
7+
8+
# Light per-report tuple loaded via pluck. Loading full rows per
9+
# configuration (ltsv is several KB each) took seconds per query on
10+
# production and blew past the 30s request timeout.
11+
Row = Struct.new(:id, :server_id, :option, :datetime, :summary) do
12+
def success?
13+
summary.include?(" success")
14+
end
15+
end
616

717
tool_name "find_failure_origin"
818
description "Locate when a failure started and the suspect commit range. For each failing " \
@@ -34,31 +44,45 @@ def self.call(branch:, server: nil, option: nil, query: nil, lookback_days: 90,
3444
end
3545
scope = scope.where(option: option) if option.present?
3646

37-
configs = scope.distinct.pluck(:server_id, :option)
38-
return error_response("No reports found for branch #{branch} in the lookback window") if configs.empty?
47+
rows = scope.pluck(:id, :server_id, :option, :datetime, :summary).map { |r| Row.new(*r) }
48+
return error_response("No reports found for branch #{branch} in the lookback window") if rows.empty?
3949

4050
match_ids = nil
4151
if query.present?
42-
match_ids = scope.joins(:log_excerpt).merge(LogExcerpt.content_match(query)).pluck(:id).to_set
52+
match_ids = scope.joins(:log_excerpt).merge(LogExcerpt.content_match(query)).
53+
limit(MAX_MATCH_IDS).pluck(:id).to_set
4354
if match_ids.empty?
4455
return json_response(branch: branch, query: query,
4556
message: "No failure log matching the query in the lookback window")
4657
end
4758
end
4859

49-
results = configs.filter_map do |server_id, opt|
50-
reports = Report.includes(:server).
51-
where(branch: branch, server_id: server_id, option: opt).
52-
where("datetime > ?", since).
53-
order(datetime: :desc).limit(MAX_REPORTS_PER_CONFIG).to_a
54-
next if reports.empty? || reports.first.server.nil?
55-
query.present? ? analyze_query(reports, match_ids, query) : analyze_streak(reports)
60+
boundaries = rows.group_by { |r| [r.server_id, r.option] }.filter_map do |_config, rs|
61+
rs.sort_by!(&:datetime)
62+
rs.reverse!
63+
rs = rs.first(MAX_REPORTS_PER_CONFIG)
64+
query.present? ? analyze_query(rs, match_ids) : analyze_streak(rs)
5665
end
57-
if results.empty?
66+
if boundaries.empty?
5867
return json_response(branch: branch, query: query,
5968
message: "No failing configuration found in the lookback window")
6069
end
6170

71+
ids = boundaries.flat_map { |b| [b[:first_bad_id], b[:last_good_id]] }.compact
72+
records = Report.where(id: ids).includes(:server).index_by(&:id)
73+
74+
results = boundaries.filter_map do |b|
75+
first_bad = records[b[:first_bad_id]]
76+
next if first_bad.nil? || first_bad.server.nil?
77+
last_good = b[:last_good_id] && records[b[:last_good_id]]
78+
entry = config_entry(first_bad, last_good, b)
79+
if query.present?
80+
line = first_bad.log_excerpt&.matching_line(query)
81+
entry[:first_bad][:matching_line] = line if line
82+
end
83+
entry
84+
end
85+
6286
results.sort_by! { |r| r[:first_bad][:datetime] }
6387
earliest = results.first
6488
json_response(
@@ -77,36 +101,39 @@ def self.call(branch:, server: nil, option: nil, query: nil, lookback_days: 90,
77101

78102
# Boundary of the current failing streak: skip configurations whose latest
79103
# report succeeded.
80-
def self.analyze_streak(reports)
81-
return nil if reports.first.success?
82-
streak = reports.take_while { |r| !r.success? }
83-
first_bad = streak.last
84-
last_good = reports[streak.size]
85-
config_entry(first_bad, last_good, reports, occurrences: streak.size)
104+
def self.analyze_streak(rows)
105+
return nil if rows.first.success?
106+
streak = rows.take_while { |r| !r.success? }
107+
{
108+
first_bad_id: streak.last.id,
109+
last_good_id: rows[streak.size]&.id,
110+
occurrences: streak.size,
111+
still_failing: true,
112+
}
86113
end
87114

88115
# Earliest report whose failure log matches the query. last_good is the
89116
# newest report older than it without the matching failure (it may have
90117
# failed for an unrelated reason).
91-
def self.analyze_query(reports, match_ids, query)
92-
matching = reports.select { |r| match_ids.include?(r.id) }
118+
def self.analyze_query(rows, match_ids)
119+
matching = rows.select { |r| match_ids.include?(r.id) }
93120
return nil if matching.empty?
94121
first_bad = matching.last
95-
last_good = reports[reports.index(first_bad) + 1]
96-
entry = config_entry(first_bad, last_good, reports, occurrences: matching.size,
97-
still_failing: match_ids.include?(reports.first.id))
98-
line = first_bad.log_excerpt&.matching_line(query)
99-
entry[:first_bad][:matching_line] = line if line
100-
entry
122+
{
123+
first_bad_id: first_bad.id,
124+
last_good_id: rows[rows.index(first_bad) + 1]&.id,
125+
occurrences: matching.size,
126+
still_failing: match_ids.include?(rows.first.id),
127+
}
101128
end
102129

103-
def self.config_entry(first_bad, last_good, reports, occurrences:, still_failing: true)
130+
def self.config_entry(first_bad, last_good, boundary)
104131
{
105132
server: first_bad.server.name,
106133
server_id: first_bad.server_id,
107134
option: first_bad.option,
108-
still_failing: still_failing,
109-
occurrences: occurrences,
135+
still_failing: boundary[:still_failing],
136+
occurrences: boundary[:occurrences],
110137
first_bad: first_bad.as_mcp_json,
111138
last_good: last_good&.as_mcp_json,
112139
compare_url: compare_url(last_good, first_bad),

0 commit comments

Comments
 (0)