2011-01-06 Julie Parent <jparent@chromium.org>
[WebKit-https.git] / Tools / TestResultServer / model / jsonresults.py
1 # Copyright (C) 2010 Google Inc. All rights reserved.
2 #
3 # Redistribution and use in source and binary forms, with or without
4 # modification, are permitted provided that the following conditions are
5 # met:
6 #
7 #     * Redistributions of source code must retain the above copyright
8 # notice, this list of conditions and the following disclaimer.
9 #     * Redistributions in binary form must reproduce the above
10 # copyright notice, this list of conditions and the following disclaimer
11 # in the documentation and/or other materials provided with the
12 # distribution.
13 #     * Neither the name of Google Inc. nor the names of its
14 # contributors may be used to endorse or promote products derived from
15 # this software without specific prior written permission.
16 #
17 # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
18 # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
19 # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
20 # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
21 # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
22 # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
23 # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
24 # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
25 # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26 # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
27 # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28
29 from datetime import datetime
30 from django.utils import simplejson
31 import logging
32
33 from model.testfile import TestFile
34
35 JSON_RESULTS_FILE = "results.json"
36 JSON_RESULTS_FILE_SMALL = "results-small.json"
37 JSON_RESULTS_PREFIX = "ADD_RESULTS("
38 JSON_RESULTS_SUFFIX = ");"
39 JSON_RESULTS_VERSION_KEY = "version"
40 JSON_RESULTS_BUILD_NUMBERS = "buildNumbers"
41 JSON_RESULTS_TESTS = "tests"
42 JSON_RESULTS_RESULTS = "results"
43 JSON_RESULTS_TIMES = "times"
44 JSON_RESULTS_PASS = "P"
45 JSON_RESULTS_NO_DATA = "N"
46 JSON_RESULTS_MIN_TIME = 1
47 JSON_RESULTS_VERSION = 3
48 JSON_RESULTS_MAX_BUILDS = 750
49 JSON_RESULTS_MAX_BUILDS_SMALL = 200
50
51
52 class JsonResults(object):
53     @classmethod
54     def _strip_prefix_suffix(cls, data):
55         """Strip out prefix and suffix of json results string.
56
57         Args:
58             data: json file content.
59
60         Returns:
61             json string without prefix and suffix.
62         """
63
64         assert(data.startswith(JSON_RESULTS_PREFIX))
65         assert(data.endswith(JSON_RESULTS_SUFFIX))
66
67         return data[len(JSON_RESULTS_PREFIX):
68                     len(data) - len(JSON_RESULTS_SUFFIX)]
69
70     @classmethod
71     def _generate_file_data(cls, json, sort_keys=False):
72         """Given json string, generate file content data by adding
73            prefix and suffix.
74
75         Args:
76             json: json string without prefix and suffix.
77
78         Returns:
79             json file data.
80         """
81
82         data = simplejson.dumps(json, separators=(',', ':'),
83             sort_keys=sort_keys)
84         return JSON_RESULTS_PREFIX + data + JSON_RESULTS_SUFFIX
85
86     @classmethod
87     def _load_json(cls, file_data):
88         """Load json file to a python object.
89
90         Args:
91             file_data: json file content.
92
93         Returns:
94             json object or
95             None on failure.
96         """
97
98         json_results_str = cls._strip_prefix_suffix(file_data)
99         if not json_results_str:
100             logging.warning("No json results data.")
101             return None
102
103         try:
104             return simplejson.loads(json_results_str)
105         except Exception, err:
106             logging.debug(json_results_str)
107             logging.error("Failed to load json results: %s", str(err))
108             return None
109
110     @classmethod
111     def _merge_json(cls, aggregated_json, incremental_json, num_runs):
112         """Merge incremental json into aggregated json results.
113
114         Args:
115             aggregated_json: aggregated json object.
116             incremental_json: incremental json object.
117             num_runs: number of total runs to include.
118
119         Returns:
120             True if merge succeeds or
121             False on failure.
122         """
123
124         # Merge non tests property data.
125         # Tests properties are merged in _merge_tests.
126         if not cls._merge_non_test_data(aggregated_json, incremental_json, num_runs):
127             return False
128
129         # Merge tests results and times
130         incremental_tests = incremental_json[JSON_RESULTS_TESTS]
131         if incremental_tests:
132             aggregated_tests = aggregated_json[JSON_RESULTS_TESTS]
133             cls._merge_tests(aggregated_tests, incremental_tests, num_runs)
134
135         return True
136
137     @classmethod
138     def _merge_non_test_data(cls, aggregated_json, incremental_json, num_runs):
139         """Merge incremental non tests property data into aggregated json results.
140
141         Args:
142             aggregated_json: aggregated json object.
143             incremental_json: incremental json object.
144             num_runs: number of total runs to include.
145
146         Returns:
147             True if merge succeeds or
148             False on failure.
149         """
150
151         incremental_builds = incremental_json[JSON_RESULTS_BUILD_NUMBERS]
152         aggregated_builds = aggregated_json[JSON_RESULTS_BUILD_NUMBERS]
153         aggregated_build_number = int(aggregated_builds[0])
154         # Loop through all incremental builds, start from the oldest run.
155         for index in reversed(range(len(incremental_builds))):
156             build_number = int(incremental_builds[index])
157             logging.debug("Merging build %s, incremental json index: %d.",
158                 build_number, index)
159
160             # Return if not all build numbers in the incremental json results
161             # are newer than the most recent build in the aggregated results.
162             # FIXME: make this case work.
163             if build_number < aggregated_build_number:
164                 logging.warning(("Build %d in incremental json is older than "
165                     "the most recent build in aggregated results: %d"),
166                     build_number, aggregated_build_number)
167                 return False
168
169             # Return if the build number is duplicated.
170             # FIXME: skip the duplicated build and merge rest of the results.
171             #        Need to be careful on skiping the corresponding value in
172             #        _merge_tests because the property data for each test could
173             #        be accumulated.
174             if build_number == aggregated_build_number:
175                 logging.warning("Duplicate build %d in incremental json",
176                     build_number)
177                 return False
178
179             # Merge this build into aggreagated results.
180             cls._merge_one_build(aggregated_json, incremental_json, index, num_runs)
181
182         return True
183
184     @classmethod
185     def _merge_one_build(cls, aggregated_json, incremental_json,
186                          incremental_index, num_runs):
187         """Merge one build of incremental json into aggregated json results.
188
189         Args:
190             aggregated_json: aggregated json object.
191             incremental_json: incremental json object.
192             incremental_index: index of the incremental json results to merge.
193             num_runs: number of total runs to include.
194         """
195
196         for key in incremental_json.keys():
197             # Merge json results except "tests" properties (results, times etc).
198             # "tests" properties will be handled separately.
199             if key == JSON_RESULTS_TESTS:
200                 continue
201
202             if key in aggregated_json:
203                 aggregated_json[key].insert(
204                     0, incremental_json[key][incremental_index])
205                 aggregated_json[key] = \
206                     aggregated_json[key][:num_runs]
207             else:
208                 aggregated_json[key] = incremental_json[key]
209
210     @classmethod
211     def _merge_tests(cls, aggregated_json, incremental_json, num_runs):
212         """Merge "tests" properties:results, times.
213
214         Args:
215             aggregated_json: aggregated json object.
216             incremental_json: incremental json object.
217             num_runs: number of total runs to include.
218         """
219
220         all_tests = (set(aggregated_json.iterkeys()) |
221                      set(incremental_json.iterkeys()))
222         for test_name in all_tests:
223             if test_name in aggregated_json:
224                 aggregated_test = aggregated_json[test_name]
225                 if test_name in incremental_json:
226                     incremental_test = incremental_json[test_name]
227                     results = incremental_test[JSON_RESULTS_RESULTS]
228                     times = incremental_test[JSON_RESULTS_TIMES]
229                 else:
230                     results = [[1, JSON_RESULTS_NO_DATA]]
231                     times = [[1, 0]]
232
233                 cls._insert_item_run_length_encoded(
234                     results, aggregated_test[JSON_RESULTS_RESULTS], num_runs)
235                 cls._insert_item_run_length_encoded(
236                     times, aggregated_test[JSON_RESULTS_TIMES], num_runs)
237                 cls._normalize_results_json(test_name, aggregated_json, num_runs)
238             else:
239                 aggregated_json[test_name] = incremental_json[test_name]
240
241     @classmethod
242     def _insert_item_run_length_encoded(cls, incremental_item, aggregated_item, num_runs):
243         """Inserts the incremental run-length encoded results into the aggregated
244            run-length encoded results.
245
246         Args:
247             incremental_item: incremental run-length encoded results.
248             aggregated_item: aggregated run-length encoded results.
249             num_runs: number of total runs to include.
250         """
251
252         for item in incremental_item:
253             if len(aggregated_item) and item[1] == aggregated_item[0][1]:
254                 aggregated_item[0][0] = min(
255                     aggregated_item[0][0] + item[0], num_runs)
256             else:
257                 aggregated_item.insert(0, item)
258
259     @classmethod
260     def _normalize_results_json(cls, test_name, aggregated_json, num_runs):
261         """ Prune tests where all runs pass or tests that no longer exist and
262         truncate all results to num_runs.
263
264         Args:
265           test_name: Name of the test.
266           aggregated_json: The JSON object with all the test results for
267                            this builder.
268           num_runs: number of total runs to include.
269         """
270
271         aggregated_test = aggregated_json[test_name]
272         aggregated_test[JSON_RESULTS_RESULTS] = \
273             cls._remove_items_over_max_number_of_builds(
274                 aggregated_test[JSON_RESULTS_RESULTS], num_runs)
275         aggregated_test[JSON_RESULTS_TIMES] = \
276             cls._remove_items_over_max_number_of_builds(
277                 aggregated_test[JSON_RESULTS_TIMES], num_runs)
278
279         is_all_pass = cls._is_results_all_of_type(
280             aggregated_test[JSON_RESULTS_RESULTS], JSON_RESULTS_PASS)
281         is_all_no_data = cls._is_results_all_of_type(
282             aggregated_test[JSON_RESULTS_RESULTS], JSON_RESULTS_NO_DATA)
283
284         max_time = max(
285             [time[1] for time in aggregated_test[JSON_RESULTS_TIMES]])
286         # Remove all passes/no-data from the results to reduce noise and
287         # filesize. If a test passes every run, but
288         # takes >= JSON_RESULTS_MIN_TIME to run, don't throw away the data.
289         if (is_all_no_data or
290            (is_all_pass and max_time < JSON_RESULTS_MIN_TIME)):
291             del aggregated_json[test_name]
292
293     @classmethod
294     def _remove_items_over_max_number_of_builds(cls, encoded_list, num_runs):
295         """Removes items from the run-length encoded list after the final
296         item that exceeds the max number of builds to track.
297
298         Args:
299           encoded_results: run-length encoded results. An array of arrays, e.g.
300               [[3,'A'],[1,'Q']] encodes AAAQ.
301           num_runs: number of total runs to include.
302         """
303         num_builds = 0
304         index = 0
305         for result in encoded_list:
306             num_builds = num_builds + result[0]
307             index = index + 1
308             if num_builds > num_runs:
309                 return encoded_list[:index]
310
311         return encoded_list
312
313     @classmethod
314     def _is_results_all_of_type(cls, results, type):
315         """Returns whether all the results are of the given type
316         (e.g. all passes).
317         """
318
319         return len(results) == 1 and results[0][1] == type
320
321     @classmethod
322     def _check_json(cls, builder, json):
323         """Check whether the given json is valid.
324
325         Args:
326             builder: builder name this json is for.
327             json: json object to check.
328
329         Returns:
330             True if the json is valid or
331             False otherwise.
332         """
333
334         version = json[JSON_RESULTS_VERSION_KEY]
335         if version > JSON_RESULTS_VERSION:
336             logging.error("Results JSON version '%s' is not supported.",
337                 version)
338             return False
339
340         if not builder in json:
341             logging.error("Builder '%s' is not in json results.", builder)
342             return False
343
344         results_for_builder = json[builder]
345         if not JSON_RESULTS_BUILD_NUMBERS in results_for_builder:
346             logging.error("Missing build number in json results.")
347             return False
348
349         return True
350
351     @classmethod
352     def merge(cls, builder, aggregated, incremental, num_runs, sort_keys=False):
353         """Merge incremental json file data with aggregated json file data.
354
355         Args:
356             builder: builder name.
357             aggregated: aggregated json file data.
358             incremental: incremental json file data.
359             sort_key: whether or not to sort key when dumping json results.
360
361         Returns:
362             Merged json file data if merge succeeds or
363             None on failure.
364         """
365
366         if not incremental:
367             logging.warning("Nothing to merge.")
368             return None
369
370         logging.info("Loading incremental json...")
371         incremental_json = cls._load_json(incremental)
372         if not incremental_json:
373             return None
374
375         logging.info("Checking incremental json...")
376         if not cls._check_json(builder, incremental_json):
377             return None
378
379         logging.info("Loading existing aggregated json...")
380         aggregated_json = cls._load_json(aggregated)
381         if not aggregated_json:
382             return incremental
383
384         logging.info("Checking existing aggregated json...")
385         if not cls._check_json(builder, aggregated_json):
386             return incremental
387
388         logging.info("Merging json results...")
389         try:
390             if not cls._merge_json(aggregated_json[builder], incremental_json[builder], num_runs):
391                 return None
392         except Exception, err:
393             logging.error("Failed to merge json results: %s", str(err))
394             return None
395
396         aggregated_json[JSON_RESULTS_VERSION_KEY] = JSON_RESULTS_VERSION
397
398         return cls._generate_file_data(aggregated_json, sort_keys)
399
400     @classmethod
401     def update(cls, master, builder, test_type, incremental):
402         """Update datastore json file data by merging it with incremental json
403            file. Writes the large file and a small file. The small file just stores
404            fewer runs.
405
406         Args:
407             master: master name.
408             builder: builder name.
409             test_type: type of test results.
410             incremental: incremental json file data to merge.
411
412         Returns:
413             Large TestFile object if update succeeds or
414             None on failure.
415         """
416         small_file_updated = cls.update_file(master, builder, test_type, incremental, JSON_RESULTS_FILE_SMALL, JSON_RESULTS_MAX_BUILDS_SMALL)
417         large_file_updated = cls.update_file(master, builder, test_type, incremental, JSON_RESULTS_FILE, JSON_RESULTS_MAX_BUILDS)
418
419         return small_file_updated and large_file_updated
420
421     @classmethod
422     def update_file(cls, master, builder, test_type, incremental, filename, num_runs):
423         files = TestFile.get_files(master, builder, test_type, filename)
424         if files:
425             file = files[0]
426             new_results = cls.merge(builder, file.data, incremental, num_runs)
427         else:
428             # Use the incremental data if there is no aggregated file to merge.
429             file = TestFile()            
430             file.master = master
431             file.builder = builder
432             file.test_type = test_type
433             file.name = filename
434             new_results = incremental
435             logging.info("No existing json results, incremental json is saved.")
436
437         if not new_results or not file.save(new_results):
438             logging.info(
439                 "Update failed, master: %s, builder: %s, test_type: %s, name: %s." %
440                 (master, builder, test_type, filename))
441             return False
442
443         return True
444
445     @classmethod
446     def get_test_list(cls, builder, json_file_data):
447         """Get list of test names from aggregated json file data.
448
449         Args:
450             json_file_data: json file data that has all test-data and
451                             non-test-data.
452
453         Returns:
454             json file with test name list only. The json format is the same
455             as the one saved in datastore, but all non-test-data and test detail
456             results are removed.
457         """
458
459         logging.debug("Loading test results json...")
460         json = cls._load_json(json_file_data)
461         if not json:
462             return None
463
464         logging.debug("Checking test results json...")
465         if not cls._check_json(builder, json):
466             return None
467
468         test_list_json = {}
469         tests = json[builder][JSON_RESULTS_TESTS]
470         test_list_json[builder] = {
471             "tests": dict.fromkeys(tests, {})}
472
473         return cls._generate_file_data(test_list_json)