Rename WebKitTools to Tools
[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
118         Returns:
119             True if merge succeeds or
120             False on failure.
121         """
122
123         # Merge non tests property data.
124         # Tests properties are merged in _merge_tests.
125         if not cls._merge_non_test_data(aggregated_json, incremental_json, num_runs):
126             return False
127
128         # Merge tests results and times
129         incremental_tests = incremental_json[JSON_RESULTS_TESTS]
130         if incremental_tests:
131             aggregated_tests = aggregated_json[JSON_RESULTS_TESTS]
132             cls._merge_tests(aggregated_tests, incremental_tests, num_runs)
133
134         return True
135
136     @classmethod
137     def _merge_non_test_data(cls, aggregated_json, incremental_json, num_runs):
138         """Merge incremental non tests property data into aggregated json results.
139
140         Args:
141             aggregated_json: aggregated json object.
142             incremental_json: incremental json object.
143
144         Returns:
145             True if merge succeeds or
146             False on failure.
147         """
148
149         incremental_builds = incremental_json[JSON_RESULTS_BUILD_NUMBERS]
150         aggregated_builds = aggregated_json[JSON_RESULTS_BUILD_NUMBERS]
151         aggregated_build_number = int(aggregated_builds[0])
152         # Loop through all incremental builds, start from the oldest run.
153         for index in reversed(range(len(incremental_builds))):
154             build_number = int(incremental_builds[index])
155             logging.debug("Merging build %s, incremental json index: %d.",
156                 build_number, index)
157
158             # Return if not all build numbers in the incremental json results
159             # are newer than the most recent build in the aggregated results.
160             # FIXME: make this case work.
161             if build_number < aggregated_build_number:
162                 logging.warning(("Build %d in incremental json is older than "
163                     "the most recent build in aggregated results: %d"),
164                     build_number, aggregated_build_number)
165                 return False
166
167             # Return if the build number is duplicated.
168             # FIXME: skip the duplicated build and merge rest of the results.
169             #        Need to be careful on skiping the corresponding value in
170             #        _merge_tests because the property data for each test could
171             #        be accumulated.
172             if build_number == aggregated_build_number:
173                 logging.warning("Duplicate build %d in incremental json",
174                     build_number)
175                 return False
176
177             # Merge this build into aggreagated results.
178             cls._merge_one_build(aggregated_json, incremental_json, index, num_runs)
179
180         return True
181
182     @classmethod
183     def _merge_one_build(cls, aggregated_json, incremental_json,
184                          incremental_index, num_runs):
185         """Merge one build of incremental json into aggregated json results.
186
187         Args:
188             aggregated_json: aggregated json object.
189             incremental_json: incremental json object.
190             incremental_index: index of the incremental json results to merge.
191         """
192
193         for key in incremental_json.keys():
194             # Merge json results except "tests" properties (results, times etc).
195             # "tests" properties will be handled separately.
196             if key == JSON_RESULTS_TESTS:
197                 continue
198
199             if key in aggregated_json:
200                 aggregated_json[key].insert(
201                     0, incremental_json[key][incremental_index])
202                 aggregated_json[key] = \
203                     aggregated_json[key][:num_runs]
204             else:
205                 aggregated_json[key] = incremental_json[key]
206
207     @classmethod
208     def _merge_tests(cls, aggregated_json, incremental_json, num_runs):
209         """Merge "tests" properties:results, times.
210
211         Args:
212             aggregated_json: aggregated json object.
213             incremental_json: incremental json object.
214         """
215
216         all_tests = (set(aggregated_json.iterkeys()) |
217                      set(incremental_json.iterkeys()))
218         for test_name in all_tests:
219             if test_name in aggregated_json:
220                 aggregated_test = aggregated_json[test_name]
221                 if test_name in incremental_json:
222                     incremental_test = incremental_json[test_name]
223                     results = incremental_test[JSON_RESULTS_RESULTS]
224                     times = incremental_test[JSON_RESULTS_TIMES]
225                 else:
226                     results = [[1, JSON_RESULTS_NO_DATA]]
227                     times = [[1, 0]]
228
229                 cls._insert_item_run_length_encoded(
230                     results, aggregated_test[JSON_RESULTS_RESULTS], num_runs)
231                 cls._insert_item_run_length_encoded(
232                     times, aggregated_test[JSON_RESULTS_TIMES], num_runs)
233                 cls._normalize_results_json(test_name, aggregated_json)
234             else:
235                 aggregated_json[test_name] = incremental_json[test_name]
236
237     @classmethod
238     def _insert_item_run_length_encoded(cls, incremental_item, aggregated_item, num_runs):
239         """Inserts the incremental run-length encoded results into the aggregated
240            run-length encoded results.
241
242         Args:
243             incremental_item: incremental run-length encoded results.
244             aggregated_item: aggregated run-length encoded results.
245         """
246
247         for item in incremental_item:
248             if len(aggregated_item) and item[1] == aggregated_item[0][1]:
249                 aggregated_item[0][0] = min(
250                     aggregated_item[0][0] + item[0], num_runs)
251             else:
252                 aggregated_item.insert(0, item)
253
254     @classmethod
255     def _normalize_results_json(cls, test_name, aggregated_json):
256         """ Prune tests where all runs pass or tests that no longer exist and
257         truncate all results to JSON_RESULTS_MAX_BUILDS.
258
259         Args:
260           test_name: Name of the test.
261           aggregated_json: The JSON object with all the test results for
262                            this builder.
263         """
264
265         aggregated_test = aggregated_json[test_name]
266         aggregated_test[JSON_RESULTS_RESULTS] = \
267             cls._remove_items_over_max_number_of_builds(
268                 aggregated_test[JSON_RESULTS_RESULTS])
269         aggregated_test[JSON_RESULTS_TIMES] = \
270             cls._remove_items_over_max_number_of_builds(
271                 aggregated_test[JSON_RESULTS_TIMES])
272
273         is_all_pass = cls._is_results_all_of_type(
274             aggregated_test[JSON_RESULTS_RESULTS], JSON_RESULTS_PASS)
275         is_all_no_data = cls._is_results_all_of_type(
276             aggregated_test[JSON_RESULTS_RESULTS], JSON_RESULTS_NO_DATA)
277
278         max_time = max(
279             [time[1] for time in aggregated_test[JSON_RESULTS_TIMES]])
280         # Remove all passes/no-data from the results to reduce noise and
281         # filesize. If a test passes every run, but
282         # takes >= JSON_RESULTS_MIN_TIME to run, don't throw away the data.
283         if (is_all_no_data or
284            (is_all_pass and max_time < JSON_RESULTS_MIN_TIME)):
285             del aggregated_json[test_name]
286
287     @classmethod
288     def _remove_items_over_max_number_of_builds(cls, encoded_list):
289         """Removes items from the run-length encoded list after the final
290         item that exceeds the max number of builds to track.
291
292         Args:
293           encoded_results: run-length encoded results. An array of arrays, e.g.
294               [[3,'A'],[1,'Q']] encodes AAAQ.
295         """
296         num_builds = 0
297         index = 0
298         for result in encoded_list:
299             num_builds = num_builds + result[0]
300             index = index + 1
301             if num_builds > JSON_RESULTS_MAX_BUILDS:
302                 return encoded_list[:index]
303
304         return encoded_list
305
306     @classmethod
307     def _is_results_all_of_type(cls, results, type):
308         """Returns whether all the results are of the given type
309         (e.g. all passes).
310         """
311
312         return len(results) == 1 and results[0][1] == type
313
314     @classmethod
315     def _check_json(cls, builder, json):
316         """Check whether the given json is valid.
317
318         Args:
319             builder: builder name this json is for.
320             json: json object to check.
321
322         Returns:
323             True if the json is valid or
324             False otherwise.
325         """
326
327         version = json[JSON_RESULTS_VERSION_KEY]
328         if version > JSON_RESULTS_VERSION:
329             logging.error("Results JSON version '%s' is not supported.",
330                 version)
331             return False
332
333         if not builder in json:
334             logging.error("Builder '%s' is not in json results.", builder)
335             return False
336
337         results_for_builder = json[builder]
338         if not JSON_RESULTS_BUILD_NUMBERS in results_for_builder:
339             logging.error("Missing build number in json results.")
340             return False
341
342         return True
343
344     @classmethod
345     def merge(cls, builder, aggregated, incremental, num_runs, sort_keys=False):
346         """Merge incremental json file data with aggregated json file data.
347
348         Args:
349             builder: builder name.
350             aggregated: aggregated json file data.
351             incremental: incremental json file data.
352             sort_key: whether or not to sort key when dumping json results.
353
354         Returns:
355             Merged json file data if merge succeeds or
356             None on failure.
357         """
358
359         if not incremental:
360             logging.warning("Nothing to merge.")
361             return None
362
363         logging.info("Loading incremental json...")
364         incremental_json = cls._load_json(incremental)
365         if not incremental_json:
366             return None
367
368         logging.info("Checking incremental json...")
369         if not cls._check_json(builder, incremental_json):
370             return None
371
372         logging.info("Loading existing aggregated json...")
373         aggregated_json = cls._load_json(aggregated)
374         if not aggregated_json:
375             return incremental
376
377         logging.info("Checking existing aggregated json...")
378         if not cls._check_json(builder, aggregated_json):
379             return incremental
380
381         logging.info("Merging json results...")
382         try:
383             if not cls._merge_json(aggregated_json[builder], incremental_json[builder], num_runs):
384                 return None
385         except Exception, err:
386             logging.error("Failed to merge json results: %s", str(err))
387             return None
388
389         aggregated_json[JSON_RESULTS_VERSION_KEY] = JSON_RESULTS_VERSION
390
391         return cls._generate_file_data(aggregated_json, sort_keys)
392
393     @classmethod
394     def update(cls, master, builder, test_type, incremental):
395         """Update datastore json file data by merging it with incremental json
396            file. Writes the large file and a small file. The small file just stores
397            fewer runs.
398
399         Args:
400             master: master name.
401             builder: builder name.
402             test_type: type of test results.
403             incremental: incremental json file data to merge.
404
405         Returns:
406             Large TestFile object if update succeeds or
407             None on failure.
408         """
409         small_file_updated = cls.update_file(master, builder, test_type, incremental, JSON_RESULTS_FILE_SMALL, JSON_RESULTS_MAX_BUILDS_SMALL)
410         large_file_updated = cls.update_file(master, builder, test_type, incremental, JSON_RESULTS_FILE, JSON_RESULTS_MAX_BUILDS)
411
412         return small_file_updated and large_file_updated
413
414     @classmethod
415     def update_file(cls, master, builder, test_type, incremental, filename, num_runs):
416         files = TestFile.get_files(master, builder, test_type, filename)
417         if files:
418             file = files[0]
419             new_results = cls.merge(builder, file.data, incremental, num_runs)
420         else:
421             # Use the incremental data if there is no aggregated file to merge.
422             file = TestFile()            
423             file.master = master
424             file.builder = builder
425             file.test_type = test_type
426             file.name = filename
427             new_results = incremental
428             logging.info("No existing json results, incremental json is saved.")
429
430         if not new_results or not file.save(new_results):
431             logging.info(
432                 "Update failed, master: %s, builder: %s, test_type: %s, name: %s." %
433                 (master, builder, test_type, filename))
434             return False
435
436         return True
437
438     @classmethod
439     def get_test_list(cls, builder, json_file_data):
440         """Get list of test names from aggregated json file data.
441
442         Args:
443             json_file_data: json file data that has all test-data and
444                             non-test-data.
445
446         Returns:
447             json file with test name list only. The json format is the same
448             as the one saved in datastore, but all non-test-data and test detail
449             results are removed.
450         """
451
452         logging.debug("Loading test results json...")
453         json = cls._load_json(json_file_data)
454         if not json:
455             return None
456
457         logging.debug("Checking test results json...")
458         if not cls._check_json(builder, json):
459             return None
460
461         test_list_json = {}
462         tests = json[builder][JSON_RESULTS_TESTS]
463         test_list_json[builder] = {
464             "tests": dict.fromkeys(tests, {})}
465
466         return cls._generate_file_data(test_list_json)