9ad5d1ef9dd1eafb938cf365a10c74ccc10d387a
[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     hidden = db.BooleanProperty()
99
100     @staticmethod
101     def create_if_possible(key, name):
102         return _create_if_possible(Platform, key, name)
103
104
105 class Builder(db.Model):
106     name = db.StringProperty(required=True)
107     password = db.StringProperty(required=True)
108
109     @staticmethod
110     def create(name, raw_password):
111         return Builder(name=name, password=Builder._hashed_password(raw_password), key_name=name).put()
112
113     def update_password(self, raw_password):
114         self.password = Builder._hashed_password(raw_password)
115         self.put()
116
117     def authenticate(self, raw_password):
118         return self.password == hashlib.sha256(raw_password).hexdigest()
119
120     @staticmethod
121     def _hashed_password(raw_password):
122         return hashlib.sha256(raw_password).hexdigest()
123
124
125 class Build(db.Model):
126     branch = db.ReferenceProperty(Branch, required=True, collection_name='build_branch')
127     platform = db.ReferenceProperty(Platform, required=True, collection_name='build_platform')
128     builder = db.ReferenceProperty(Builder, required=True, collection_name='builder_key')
129     buildNumber = db.IntegerProperty(required=True)
130     revision = db.IntegerProperty(required=True)
131     chromiumRevision = db.IntegerProperty()
132     timestamp = db.DateTimeProperty(required=True)
133
134     @staticmethod
135     def get_or_insert_from_log(log):
136         builder = log.builder()
137         key_name = builder.name + ':' + str(int(mktime(log.timestamp().timetuple())))
138
139         return Build.get_or_insert(key_name, branch=log.branch(), platform=log.platform(), builder=builder,
140             buildNumber=log.build_number(), timestamp=log.timestamp(),
141             revision=log.webkit_revision(), chromiumRevision=log.chromium_revision())
142
143
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     hidden = db.BooleanProperty()
152
153     @staticmethod
154     def update_or_insert(test_name, branch, platform):
155         existing_test = [None]
156
157         def execute(id):
158             test = Test.get_by_key_name(test_name)
159             if test:
160                 if branch.key() not in test.branches:
161                     test.branches.append(branch.key())
162                 if platform.key() not in test.platforms:
163                     test.platforms.append(platform.key())
164                 test.put()
165                 existing_test[0] = test
166                 return None
167
168             test = Test(id=id, name=test_name, key_name=test_name, branches=[branch.key()], platforms=[platform.key()])
169             test.put()
170             return test
171
172         return create_in_transaction_with_numeric_id_holder(execute) or existing_test[0]
173
174     def merge(self, other):
175         assert self.key() != other.key()
176
177         merged_results = TestResult.all()
178         merged_results.filter('name =', other.name)
179
180         # FIXME: We should be doing this check in a transaction but only ancestor queries are allowed
181         for result in merged_results:
182             if TestResult.get_by_key_name(TestResult.key_name(result.build, self.name)):
183                 return None
184
185         branches_and_platforms_to_update = set()
186         for result in merged_results:
187             branches_and_platforms_to_update.add((result.build.branch.id, result.build.platform.id))
188             result.replace_to_change_test_name(self.name)
189
190         delete_model_with_numeric_id_holder(other)
191
192         return branches_and_platforms_to_update
193
194
195 class TestResult(db.Model):
196     name = db.StringProperty(required=True)
197     build = db.ReferenceProperty(Build, required=True)
198     value = db.FloatProperty(required=True)
199     valueMedian = db.FloatProperty()
200     valueStdev = db.FloatProperty()
201     valueMin = db.FloatProperty()
202     valueMax = db.FloatProperty()
203
204     @staticmethod
205     def key_name(build, test_name):
206         return build.key().name() + ':' + test_name
207
208     @classmethod
209     def get_or_insert_from_parsed_json(cls, test_name, build, result):
210         key_name = cls.key_name(build, test_name)
211
212         def _float_or_none(dictionary, key):
213             value = dictionary.get(key)
214             if value:
215                 return float(value)
216             return None
217
218         if not isinstance(result, dict):
219             return cls.get_or_insert(key_name, name=test_name, build=build, value=float(result))
220
221         return cls.get_or_insert(key_name, name=test_name, build=build, value=float(result['avg']),
222             valueMedian=_float_or_none(result, 'median'), valueStdev=_float_or_none(result, 'stdev'),
223             valueMin=_float_or_none(result, 'min'), valueMax=_float_or_none(result, 'max'))
224
225     def replace_to_change_test_name(self, new_name):
226         clone = TestResult(key_name=TestResult.key_name(self.build, new_name), name=new_name, build=self.build,
227             value=self.value, valueMedian=self.valueMedian, valueStdev=self.valueMin, valueMin=self.valueMin, valueMax=self.valueMax)
228         clone.put()
229         self.delete()
230         return clone
231
232
233 class ReportLog(db.Model):
234     timestamp = db.DateTimeProperty(required=True)
235     headers = db.TextProperty()
236     payload = db.TextProperty()
237     commit = db.BooleanProperty()
238
239     def _parsed_payload(self):
240         if self.__dict__.get('_parsed') == None:
241             try:
242                 self._parsed = json.loads(self.payload)
243             except ValueError:
244                 self._parsed = False
245         return self._parsed
246
247     def get_value(self, keyName):
248         if not self._parsed_payload():
249             return None
250         return self._parsed.get(keyName)
251
252     def results(self):
253         return self.get_value('results')
254
255     def builder(self):
256         return self._model_by_key_name_in_payload(Builder, 'builder-name')
257
258     def branch(self):
259         return self._model_by_key_name_in_payload(Branch, 'branch')
260
261     def platform(self):
262         return self._model_by_key_name_in_payload(Platform, 'platform')
263
264     def build_number(self):
265         return self._integer_in_payload('build-number')
266
267     def webkit_revision(self):
268         return self._integer_in_payload('webkit-revision')
269
270     def chromium_revision(self):
271         return self._integer_in_payload('chromium-revision')
272
273     def _model_by_key_name_in_payload(self, model, keyName):
274         key = self.get_value(keyName)
275         if not key:
276             return None
277         return model.get_by_key_name(key)
278
279     def _integer_in_payload(self, keyName):
280         try:
281             return int(self.get_value(keyName))
282         except TypeError:
283             return None
284         except ValueError:
285             return None
286
287     # FIXME: We also have timestamp as a member variable.
288     def timestamp(self):
289         try:
290             return datetime.fromtimestamp(self._integer_in_payload('timestamp'))
291         except TypeError:
292             return None
293         except ValueError:
294             return None
295
296
297 class PersistentCache(db.Model):
298     value = db.TextProperty(required=True)
299
300     @staticmethod
301     def set_cache(name, value):
302         memcache.set(name, value)
303         PersistentCache(key_name=name, value=value).put()
304
305     @staticmethod
306     def get_cache(name):
307         value = memcache.get(name)
308         if value:
309             return value
310         cache = PersistentCache.get_by_key_name(name)
311         if not cache:
312             return None
313         memcache.set(name, cache.value)
314         return cache.value
315
316
317 class Runs(db.Model):
318     branch = db.ReferenceProperty(Branch, required=True, collection_name='runs_branch')
319     platform = db.ReferenceProperty(Platform, required=True, collection_name='runs_platform')
320     test = db.ReferenceProperty(Test, required=True, collection_name='runs_test')
321     json_runs = db.TextProperty()
322     json_averages = db.TextProperty()
323     json_min = db.FloatProperty()
324     json_max = db.FloatProperty()
325
326     @staticmethod
327     def _generate_runs(branch, platform, test_name):
328         builds = Build.all()
329         builds.filter('branch =', branch)
330         builds.filter('platform =', platform)
331
332         for build in builds:
333             results = TestResult.all()
334             results.filter('name =', test_name)
335             results.filter('build =', build)
336             for result in results:
337                 yield build, result
338         raise StopIteration
339
340     @staticmethod
341     def _entry_from_build_and_result(build, result):
342         builder_id = build.builder.key().id()
343         timestamp = mktime(build.timestamp.timetuple())
344         statistics = None
345         supplementary_revisions = None
346
347         if result.valueStdev != None and result.valueMin != None and result.valueMax != None:
348             statistics = {'stdev': result.valueStdev, 'min': result.valueMin, 'max': result.valueMax}
349
350         if build.chromiumRevision != None:
351             supplementary_revisions = {'Chromium': build.chromiumRevision}
352
353         return [result.key().id(),
354             [build.key().id(), build.buildNumber, build.revision, supplementary_revisions],
355             timestamp, result.value, 0,  # runNumber
356             [],  # annotations
357             builder_id, statistics]
358
359     @staticmethod
360     def _timestamp_and_value_from_json_entry(json_entry):
361         return json_entry[2], json_entry[3]
362
363     @staticmethod
364     def _key_name(branch_id, platform_id, test_id):
365         return 'runs:%d,%d,%d' % (test_id, branch_id, platform_id)
366
367     @classmethod
368     def update_or_insert(cls, branch, platform, test):
369         key_name = cls._key_name(branch.id, platform.id, test.id)
370         runs = Runs(key_name=key_name, branch=branch, platform=platform, test=test, json_runs='', json_averages='')
371
372         for build, result in cls._generate_runs(branch, platform, test.name):
373             runs.update_incrementally(build, result, check_duplicates_and_save=False)
374
375         runs.put()
376         memcache.set(key_name, runs.to_json())
377         return runs
378
379     def update_incrementally(self, build, result, check_duplicates_and_save=True):
380         new_entry = Runs._entry_from_build_and_result(build, result)
381
382         # Check for duplicate entries
383         if check_duplicates_and_save:
384             revision_is_in_runs = str(build.revision) in json.loads('{' + self.json_averages + '}')
385             if revision_is_in_runs and new_entry[1] in [entry[1] for entry in json.loads('[' + self.json_runs + ']')]:
386                 return
387
388         if self.json_runs:
389             self.json_runs += ','
390
391         if self.json_averages:
392             self.json_averages += ','
393
394         self.json_runs += json.dumps(new_entry)
395         # FIXME: Calculate the average. In practice, we wouldn't have more than one value for a given revision.
396         self.json_averages += '"%d": %f' % (build.revision, result.value)
397         self.json_min = min(self.json_min, result.value) if self.json_min != None else result.value
398         self.json_max = max(self.json_max, result.value)
399
400         if check_duplicates_and_save:
401             self.put()
402             memcache.set(self.key().name(), self.to_json())
403
404     @staticmethod
405     def get_by_objects(branch, platform, test):
406         return Runs.get_by_key_name(Runs._key_name(branch.id, platform.id, test.id))
407
408     @classmethod
409     def json_by_ids(cls, branch_id, platform_id, test_id):
410         key_name = cls._key_name(branch_id, platform_id, test_id)
411         runs_json = memcache.get(key_name)
412         if not runs_json:
413             runs = cls.get_by_key_name(key_name)
414             if not runs:
415                 return None
416             runs_json = runs.to_json()
417             memcache.set(key_name, runs_json)
418         return runs_json
419
420     def to_json(self):
421         # date_range is never used by common.js.
422         return '{"test_runs": [%s], "averages": {%s}, "min": %s, "max": %s, "date_range": null, "stat": "ok"}' % (self.json_runs,
423             self.json_averages, str(self.json_min) if self.json_min else 'null', str(self.json_max) if self.json_max else 'null')
424
425     def chart_params(self, display_days, now=datetime.now().replace(hour=12, minute=0, second=0, microsecond=0)):
426         chart_data_x = []
427         chart_data_y = []
428         end_time = now
429         start_timestamp = mktime((end_time - timedelta(display_days)).timetuple())
430         end_timestamp = mktime(end_time.timetuple())
431
432         for entry in json.loads('[' + self.json_runs + ']'):
433             timestamp, value = Runs._timestamp_and_value_from_json_entry(entry)
434             if timestamp < start_timestamp or timestamp > end_timestamp:
435                 continue
436             chart_data_x.append(timestamp)
437             chart_data_y.append(value)
438
439         if not chart_data_y:
440             return None
441
442         dates = [end_time - timedelta(display_days / 7.0 * (7 - i)) for i in range(0, 8)]
443
444         y_max = max(chart_data_y) * 1.1
445         y_axis_label_step = int(y_max / 5 + 0.5)  # This won't work for decimal numbers
446
447         return {
448             'cht': 'lxy',  # Specify with X and Y coordinates
449             'chxt': 'x,y',  # Display both X and Y axies
450             'chxl': '0:|' + '|'.join([date.strftime('%b %d') for date in dates]),  # X-axis labels
451             'chxr': '1,0,%f,%f' % (int(y_max + 0.5), y_axis_label_step),  # Y-axis range: min=0, max, step
452             'chds': '%f,%f,%f,%f' % (start_timestamp, end_timestamp, 0, y_max),  # X, Y data range
453             'chxs': '1,676767,11.167,0,l,676767',  # Y-axis label: 1,color,font-size,centerd on tick,axis line/no ticks, tick color
454             'chs': '360x240',  # Image size: 360px by 240px
455             'chco': 'ff0000',  # Plot line color
456             'chg': '%f,20,0,0' % (100 / (len(dates) - 1)),  # X, Y grid line step sizes - max is 100.
457             'chls': '3',  # Line thickness
458             'chf': 'bg,s,eff6fd',  # Transparent background
459             'chd': 't:' + ','.join([str(x) for x in chart_data_x]) + '|' + ','.join([str(y) for y in chart_data_y]),  # X, Y data
460         }
461
462
463 class DashboardImage(db.Model):
464     image = db.BlobProperty(required=True)
465     createdAt = db.DateTimeProperty(required=True, auto_now=True)
466
467     @staticmethod
468     def create(branch_id, platform_id, test_id, display_days, image):
469         key_name = DashboardImage.key_name(branch_id, platform_id, test_id, display_days)
470         instance = DashboardImage(key_name=key_name, image=image)
471         instance.put()
472         memcache.set('dashboard-image:' + key_name, image)
473         return instance
474
475     @staticmethod
476     def get_image(branch_id, platform_id, test_id, display_days):
477         key_name = DashboardImage.key_name(branch_id, platform_id, test_id, display_days)
478         image = memcache.get('dashboard-image:' + key_name)
479         if not image:
480             instance = DashboardImage.get_by_key_name(key_name)
481             image = instance.image
482             memcache.set('dashboard-image:' + key_name, image)
483         return image
484
485     @classmethod
486     def needs_update(cls, branch_id, platform_id, test_id, display_days, now=datetime.now()):
487         if display_days < 10:
488             return True
489         image = DashboardImage.get_by_key_name(cls.key_name(branch_id, platform_id, test_id, display_days))
490         duration = math.sqrt(display_days) / 10
491         # e.g. 13 hours for 30 days, 23 hours for 90 days, and 46 hours for 365 days
492         return not image or image.createdAt < now - timedelta(duration)
493
494     @staticmethod
495     def key_name(branch_id, platform_id, test_id, display_days):
496         return '%d:%d:%d:%d' % (branch_id, platform_id, test_id, display_days)