51f029f31daa5d220139b00bd77144b43b3d886a
[WebKit-https.git] / Websites / webkit-perf.appspot.com / models.py
1 #!/usr/bin/env python
2 # Copyright (C) 2012 Google Inc. All rights reserved.
3 #
4 # Redistribution and use in source and binary forms, with or without
5 # modification, are permitted provided that the following conditions are
6 # met:
7 #
8 #     * Redistributions of source code must retain the above copyright
9 # notice, this list of conditions and the following disclaimer.
10 #     * Redistributions in binary form must reproduce the above
11 # copyright notice, this list of conditions and the following disclaimer
12 # in the documentation and/or other materials provided with the
13 # distribution.
14 #     * Neither the name of Google Inc. nor the names of its
15 # contributors may be used to endorse or promote products derived from
16 # this software without specific prior written permission.
17 #
18 # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
19 # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
20 # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
21 # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
22 # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
23 # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
24 # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
25 # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
26 # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27 # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28 # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29
30 import hashlib
31 import json
32 import math
33 import re
34
35 from datetime import datetime
36 from datetime import timedelta
37 from google.appengine.ext import db
38 from google.appengine.api import memcache
39 from time import mktime
40
41
42 class NumericIdHolder(db.Model):
43     owner = db.ReferenceProperty()
44     # Dummy class whose sole purpose is to generate key().id()
45
46
47 def create_in_transaction_with_numeric_id_holder(callback):
48     id_holder = NumericIdHolder()
49     id_holder.put()
50     id_holder = NumericIdHolder.get(id_holder.key())
51     owner = None
52     try:
53         owner = db.run_in_transaction(callback, id_holder.key().id())
54         if owner:
55             id_holder.owner = owner
56             id_holder.put()
57     finally:
58         if not owner:
59             id_holder.delete()
60     return owner
61
62
63 def delete_model_with_numeric_id_holder(model):
64     id_holder = NumericIdHolder.get_by_id(model.id)
65     model.delete()
66     id_holder.delete()
67
68
69 def model_from_numeric_id(id, expected_kind):
70     id_holder = NumericIdHolder.get_by_id(id)
71     return id_holder.owner if id_holder and id_holder.owner and isinstance(id_holder.owner, expected_kind) else None
72
73
74 def _create_if_possible(model, key, name):
75
76     def execute(id):
77         if model.get_by_key_name(key):
78             return None
79         branch = model(id=id, name=name, key_name=key)
80         branch.put()
81         return branch
82
83     return create_in_transaction_with_numeric_id_holder(execute)
84
85
86 class Branch(db.Model):
87     id = db.IntegerProperty(required=True)
88     name = db.StringProperty(required=True)
89
90     @staticmethod
91     def create_if_possible(key, name):
92         return _create_if_possible(Branch, key, name)
93
94
95 class Platform(db.Model):
96     id = db.IntegerProperty(required=True)
97     name = db.StringProperty(required=True)
98
99     @staticmethod
100     def create_if_possible(key, name):
101         return _create_if_possible(Platform, key, name)
102
103
104 class Builder(db.Model):
105     name = db.StringProperty(required=True)
106     password = db.StringProperty(required=True)
107
108     @staticmethod
109     def create(name, raw_password):
110         return Builder(name=name, password=Builder._hashed_password(raw_password), key_name=name).put()
111
112     def update_password(self, raw_password):
113         self.password = Builder._hashed_password(raw_password)
114         self.put()
115
116     def authenticate(self, raw_password):
117         return self.password == hashlib.sha256(raw_password).hexdigest()
118
119     @staticmethod
120     def _hashed_password(raw_password):
121         return hashlib.sha256(raw_password).hexdigest()
122
123
124 class Build(db.Model):
125     branch = db.ReferenceProperty(Branch, required=True, collection_name='build_branch')
126     platform = db.ReferenceProperty(Platform, required=True, collection_name='build_platform')
127     builder = db.ReferenceProperty(Builder, required=True, collection_name='builder_key')
128     buildNumber = db.IntegerProperty(required=True)
129     revision = db.IntegerProperty(required=True)
130     chromiumRevision = db.IntegerProperty()
131     timestamp = db.DateTimeProperty(required=True)
132
133     @staticmethod
134     def get_or_insert_from_log(log):
135         builder = log.builder()
136         key_name = builder.name + ':' + str(int(mktime(log.timestamp().timetuple())))
137
138         return Build.get_or_insert(key_name, branch=log.branch(), platform=log.platform(), builder=builder,
139             buildNumber=log.build_number(), timestamp=log.timestamp(),
140             revision=log.webkit_revision(), chromiumRevision=log.chromium_revision())
141
142
143 # Used to generate TestMap in the manifest efficiently
144 class Test(db.Model):
145     id = db.IntegerProperty(required=True)
146     name = db.StringProperty(required=True)
147     # FIXME: Storing branches and platforms separately is flawed since a test maybe available on
148     # one platform but only on some branch and vice versa.
149     branches = db.ListProperty(db.Key)
150     platforms = db.ListProperty(db.Key)
151
152     @staticmethod
153     def update_or_insert(test_name, branch, platform):
154         existing_test = [None]
155
156         def execute(id):
157             test = Test.get_by_key_name(test_name)
158             if test:
159                 if branch.key() not in test.branches:
160                     test.branches.append(branch.key())
161                 if platform.key() not in test.platforms:
162                     test.platforms.append(platform.key())
163                 test.put()
164                 existing_test[0] = test
165                 return None
166
167             test = Test(id=id, name=test_name, key_name=test_name, branches=[branch.key()], platforms=[platform.key()])
168             test.put()
169             return test
170
171         return create_in_transaction_with_numeric_id_holder(execute) or existing_test[0]
172
173     def merge(self, other):
174         assert self.key() != other.key()
175
176         merged_results = TestResult.all()
177         merged_results.filter('name =', other.name)
178
179         # FIXME: We should be doing this check in a transaction but only ancestor queries are allowed
180         for result in merged_results:
181             if TestResult.get_by_key_name(TestResult.key_name(result.build, self.name)):
182                 return None
183
184         branches_and_platforms_to_update = set()
185         for result in merged_results:
186             branches_and_platforms_to_update.add((result.build.branch.id, result.build.platform.id))
187             result.replace_to_change_test_name(self.name)
188
189         delete_model_with_numeric_id_holder(other)
190
191         return branches_and_platforms_to_update
192
193
194 class TestResult(db.Model):
195     name = db.StringProperty(required=True)
196     build = db.ReferenceProperty(Build, required=True)
197     value = db.FloatProperty(required=True)
198     valueMedian = db.FloatProperty()
199     valueStdev = db.FloatProperty()
200     valueMin = db.FloatProperty()
201     valueMax = db.FloatProperty()
202
203     @staticmethod
204     def key_name(build, test_name):
205         return build.key().name() + ':' + test_name
206
207     @classmethod
208     def get_or_insert_from_parsed_json(cls, test_name, build, result):
209         key_name = cls.key_name(build, test_name)
210
211         def _float_or_none(dictionary, key):
212             value = dictionary.get(key)
213             if value:
214                 return float(value)
215             return None
216
217         if not isinstance(result, dict):
218             return cls.get_or_insert(key_name, name=test_name, build=build, value=float(result))
219
220         return cls.get_or_insert(key_name, name=test_name, build=build, value=float(result['avg']),
221             valueMedian=_float_or_none(result, 'median'), valueStdev=_float_or_none(result, 'stdev'),
222             valueMin=_float_or_none(result, 'min'), valueMax=_float_or_none(result, 'max'))
223
224     def replace_to_change_test_name(self, new_name):
225         clone = TestResult(key_name=TestResult.key_name(self.build, new_name), name=new_name, build=self.build,
226             value=self.value, valueMedian=self.valueMedian, valueStdev=self.valueMin, valueMin=self.valueMin, valueMax=self.valueMax)
227         clone.put()
228         self.delete()
229         return clone
230
231
232 class ReportLog(db.Model):
233     timestamp = db.DateTimeProperty(required=True)
234     headers = db.TextProperty()
235     payload = db.TextProperty()
236     commit = db.BooleanProperty()
237
238     def _parsed_payload(self):
239         if self.__dict__.get('_parsed') == None:
240             try:
241                 self._parsed = json.loads(self.payload)
242             except ValueError:
243                 self._parsed = False
244         return self._parsed
245
246     def get_value(self, keyName):
247         if not self._parsed_payload():
248             return None
249         return self._parsed.get(keyName)
250
251     def results(self):
252         return self.get_value('results')
253
254     def builder(self):
255         return self._model_by_key_name_in_payload(Builder, 'builder-name')
256
257     def branch(self):
258         return self._model_by_key_name_in_payload(Branch, 'branch')
259
260     def platform(self):
261         return self._model_by_key_name_in_payload(Platform, 'platform')
262
263     def build_number(self):
264         return self._integer_in_payload('build-number')
265
266     def webkit_revision(self):
267         return self._integer_in_payload('webkit-revision')
268
269     def chromium_revision(self):
270         return self._integer_in_payload('chromium-revision')
271
272     def _model_by_key_name_in_payload(self, model, keyName):
273         key = self.get_value(keyName)
274         if not key:
275             return None
276         return model.get_by_key_name(key)
277
278     def _integer_in_payload(self, keyName):
279         try:
280             return int(self.get_value(keyName))
281         except TypeError:
282             return None
283         except ValueError:
284             return None
285
286     # FIXME: We also have timestamp as a member variable.
287     def timestamp(self):
288         try:
289             return datetime.fromtimestamp(self._integer_in_payload('timestamp'))
290         except TypeError:
291             return None
292         except ValueError:
293             return None
294
295
296 class PersistentCache(db.Model):
297     value = db.TextProperty(required=True)
298
299     @staticmethod
300     def set_cache(name, value):
301         memcache.set(name, value)
302         PersistentCache(key_name=name, value=value).put()
303
304     @staticmethod
305     def get_cache(name):
306         value = memcache.get(name)
307         if value:
308             return value
309         cache = PersistentCache.get_by_key_name(name)
310         if not cache:
311             return None
312         memcache.set(name, cache.value)
313         return cache.value
314
315
316 class Runs(db.Model):
317     branch = db.ReferenceProperty(Branch, required=True, collection_name='runs_branch')
318     platform = db.ReferenceProperty(Platform, required=True, collection_name='runs_platform')
319     test = db.ReferenceProperty(Test, required=True, collection_name='runs_test')
320     json_runs = db.TextProperty()
321     json_averages = db.TextProperty()
322     json_min = db.FloatProperty()
323     json_max = db.FloatProperty()
324
325     @staticmethod
326     def _generate_runs(branch, platform, test_name):
327         builds = Build.all()
328         builds.filter('branch =', branch)
329         builds.filter('platform =', platform)
330
331         for build in builds:
332             results = TestResult.all()
333             results.filter('name =', test_name)
334             results.filter('build =', build)
335             for result in results:
336                 yield build, result
337         raise StopIteration
338
339     @staticmethod
340     def _entry_from_build_and_result(build, result):
341         builder_id = build.builder.key().id()
342         timestamp = mktime(build.timestamp.timetuple())
343         statistics = None
344         supplementary_revisions = None
345
346         if result.valueStdev != None and result.valueMin != None and result.valueMax != None:
347             statistics = {'stdev': result.valueStdev, 'min': result.valueMin, 'max': result.valueMax}
348
349         if build.chromiumRevision != None:
350             supplementary_revisions = {'Chromium': build.chromiumRevision}
351
352         return [result.key().id(),
353             [build.key().id(), build.buildNumber, build.revision, supplementary_revisions],
354             timestamp, result.value, 0,  # runNumber
355             [],  # annotations
356             builder_id, statistics]
357
358     @staticmethod
359     def _key_name(branch_id, platform_id, test_id):
360         return 'runs:%d,%d,%d' % (test_id, branch_id, platform_id)
361
362     @classmethod
363     def update_or_insert(cls, branch, platform, test):
364         test_runs = []
365         averages = {}
366         values = []
367
368         for build, result in cls._generate_runs(branch, platform, test.name):
369             test_runs.append(cls._entry_from_build_and_result(build, result))
370             # FIXME: Calculate the average. In practice, we wouldn't have more than one value for a given revision.
371             averages[build.revision] = result.value
372             values.append(result.value)
373
374         min_value = min(values) if values else None
375         max_value = max(values) if values else None
376
377         key_name = cls._key_name(branch.id, platform.id, test.id)
378         runs = Runs(key_name=key_name, branch=branch, platform=platform, test=test,
379             json_runs=json.dumps(test_runs)[1:-1], json_averages=json.dumps(averages)[1:-1], json_min=min_value, json_max=max_value)
380         runs.put()
381         memcache.set(key_name, runs.to_json())
382         return runs
383
384     @classmethod
385     def json_by_ids(cls, branch_id, platform_id, test_id):
386         key_name = cls._key_name(branch_id, platform_id, test_id)
387         runs_json = memcache.get(key_name)
388         if not runs_json:
389             runs = cls.get_by_key_name(key_name)
390             if not runs:
391                 return None
392             runs_json = runs.to_json()
393             memcache.set(key_name, runs_json)
394         return runs_json
395
396     def to_json(self):
397         # date_range is never used by common.js.
398         return '{"test_runs": [%s], "averages": {%s}, "min": %s, "max": %s, "date_range": null, "stat": "ok"}' % (self.json_runs,
399             self.json_averages, str(self.json_min) if self.json_min else 'null', str(self.json_max) if self.json_max else 'null')
400
401     # FIXME: Use data in JSON to compute values to avoid iterating through the datastore.
402     def chart_params(self, display_days, now=datetime.now().replace(hour=12, minute=0, second=0, microsecond=0)):
403         chart_data_x = []
404         chart_data_y = []
405         end_time = now
406         start_timestamp = mktime((end_time - timedelta(display_days)).timetuple())
407         end_timestamp = mktime(end_time.timetuple())
408
409         for build, result in self._generate_runs(self.branch, self.platform, self.test.name):
410             timestamp = mktime(build.timestamp.timetuple())
411             if timestamp < start_timestamp or timestamp > end_timestamp:
412                 continue
413             chart_data_x.append(timestamp)
414             chart_data_y.append(result.value)
415
416         dates = [end_time - timedelta(display_days / 7.0 * (7 - i)) for i in range(0, 8)]
417
418         y_max = max(chart_data_y) * 1.1
419         y_axis_label_step = int(y_max / 5 + 0.5)  # This won't work for decimal numbers
420
421         return {
422             'cht': 'lxy',  # Specify with X and Y coordinates
423             'chxt': 'x,y',  # Display both X and Y axies
424             'chxl': '0:|' + '|'.join([date.strftime('%b %d') for date in dates]),  # X-axis labels
425             'chxr': '1,0,%f,%f' % (int(y_max + 0.5), y_axis_label_step),  # Y-axis range: min=0, max, step
426             'chds': '%f,%f,%f,%f' % (start_timestamp, end_timestamp, 0, y_max),  # X, Y data range
427             'chxs': '1,676767,11.167,0,l,676767',  # Y-axis label: 1,color,font-size,centerd on tick,axis line/no ticks, tick color
428             'chs': '360x240',  # Image size: 360px by 240px
429             'chco': 'ff0000',  # Plot line color
430             'chg': '%f,20,0,0' % (100 / (len(dates) - 1)),  # X, Y grid line step sizes - max is 100.
431             'chls': '3',  # Line thickness
432             'chf': 'bg,s,eff6fd',  # Transparent background
433             'chd': 't:' + ','.join([str(x) for x in chart_data_x]) + '|' + ','.join([str(y) for y in chart_data_y]),  # X, Y data
434         }
435
436
437 class DashboardImage(db.Model):
438     image = db.BlobProperty(required=True)
439     createdAt = db.DateTimeProperty(required=True, auto_now=True)
440
441     @staticmethod
442     def create(branch_id, platform_id, test_id, display_days, image):
443         key_name = DashboardImage.key_name(branch_id, platform_id, test_id, display_days)
444         instance = DashboardImage(key_name=key_name, image=image)
445         instance.put()
446         memcache.set('dashboard-image:' + key_name, image)
447         return instance
448
449     @staticmethod
450     def get_image(branch_id, platform_id, test_id, display_days):
451         key_name = DashboardImage.key_name(branch_id, platform_id, test_id, display_days)
452         image = memcache.get('dashboard-image:' + key_name)
453         if not image:
454             instance = DashboardImage.get_by_key_name(key_name)
455             image = instance.image
456             memcache.set('dashboard-image:' + key_name, image)
457         return image
458
459     @classmethod
460     def needs_update(cls, branch_id, platform_id, test_id, display_days, now=datetime.now()):
461         if display_days < 10:
462             return True
463         image = DashboardImage.get_by_key_name(cls.key_name(branch_id, platform_id, test_id, display_days))
464         duration = math.sqrt(display_days) / 10
465         # e.g. 13 hours for 30 days, 23 hours for 90 days, and 46 hours for 365 days
466         return not image or image.createdAt < now - timedelta(duration)
467
468     @staticmethod
469     def key_name(branch_id, platform_id, test_id, display_days):
470         return '%d:%d:%d:%d' % (branch_id, platform_id, test_id, display_days)