@@ -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