• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# Copyright 2015 The Chromium Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5"""The datastore models for graph data.
6
7The Chromium project uses Buildbot to run its performance tests, and the
8structure of the data for the Performance Dashboard reflects this. Metadata
9about tests are structured in Master, Bot, and TestMetadata entities. Master and
10Bot entities represent Buildbot masters and builders respectively, and
11TestMetadata entities represent groups of results, or individual data series,
12keyed by a full path to the test separated by '/' characters.
13
14For example, entities might be structured as follows:
15
16  Master: ChromiumPerf
17  Bot: win7
18  TestMetadata: ChromiumPerf/win7/page_cycler.moz
19  TestMetadata: ChromiumPerf/win7/page_cycler.moz/times
20  TestMetadata: ChromiumPerf/win7/page_cycler.moz/times/page_load_time
21  TestMetadata: ChromiumPerf/win7/page_cycler.moz/times/page_load_time_ref
22  TestMetadata: ChromiumPerf/win7/page_cycler.moz/times/www.amazon.com
23  TestMetadata: ChromiumPerf/win7/page_cycler.moz/times/www.bing.com
24  TestMetadata: ChromiumPerf/win7/page_cycler.moz/commit_charge
25  TestMetadata: ChromiumPerf/win7/page_cycler.moz/commit_charge/ref
26  TestMetadata: ChromiumPerf/win7/page_cycler.moz/commit_charge/www.amazon.com
27  TestMetadata: ChromiumPerf/win7/page_cycler.moz/commit_charge/www.bing.com
28
29The graph data points are represented by Row entities. Each Row entity contains
30a revision and value, which are its X and Y values on a graph, and any other
31metadata associated with an individual performance test result.
32
33The keys of the Row entities for a particular data series are start with a
34TestContainer key, instead of a TestMetadata key. This way, the Row entities for
35each data series are in a different "entity group". This allows a faster rate of
36putting data in the datastore for many series at once.
37
38For example, Row entities are organized like this:
39
40  TestContainer: ChromiumPerf/win7/page_cycler.moz/times/page_load_time
41    Row: revision 12345, value 2.5
42    Row: revision 12346, value 2.0
43    Row: revision 12347, value 2.1
44  TestContainer: ChromiumPerf/win7/page_cycler.moz/times/page_load_time_ref
45    Row: revision 12345, value 2.4
46    Row: revision 12346, value 2.0
47    Row: revision 12347, value 2.2
48  TestContainer: ChromiumPerf/win7/page_cycler.moz/commit_charge
49    Row: revision 12345, value 10
50    Row: revision 12346, value 12
51    Row: revision 12347, value 11
52
53
54IMPORTANT: If you add new kinds to this file, you must also add them to the
55Daily Backup url in cron.yaml in order for them to be properly backed up.
56See: https://developers.google.com/appengine/articles/scheduled_backups
57"""
58
59import logging
60
61from google.appengine.ext import ndb
62
63from dashboard import datastore_hooks
64from dashboard import layered_cache
65from dashboard import utils
66from dashboard.models import anomaly
67from dashboard.models import anomaly_config
68from dashboard.models import internal_only_model
69from dashboard.models import sheriff as sheriff_module
70from dashboard.models import stoppage_alert as stoppage_alert_module
71
72# Maximum level of nested tests.
73MAX_TEST_ANCESTORS = 10
74
75# Keys to the datastore-based cache. See stored_object.
76LIST_TESTS_SUBTEST_CACHE_KEY = 'list_tests_get_tests_new_%s_%s_%s'
77
78_MAX_STRING_LENGTH = 500
79
80
81class Master(internal_only_model.InternalOnlyModel):
82  """Information about the Buildbot master.
83
84  Masters are keyed by name, e.g. 'ChromiumGPU' or 'ChromiumPerf'.
85  All Bot entities that are Buildbot slaves of one master are children of one
86  Master entity in the datastore.
87  """
88  # Master has no properties; the name of the master is the ID.
89
90
91class Bot(internal_only_model.InternalOnlyModel):
92  """Information about a Buildbot slave that runs perf tests.
93
94  Bots are keyed by name, e.g. 'xp-release-dual-core'. A Bot entity contains
95  information about whether the tests are only viewable to internal users, and
96  each bot has a parent that is a Master entity. To query the tests that run on
97  a Bot, check the bot_name and master_name properties of the TestMetadata.
98  """
99  internal_only = ndb.BooleanProperty(default=False, indexed=True)
100
101
102class TestMetadata(internal_only_model.CreateHookInternalOnlyModel):
103  """A TestMetadata entity is a node in a hierarchy of tests.
104
105  A TestMetadata entity can represent a specific series of results which will be
106  plotted on a graph, or it can represent a group of such series of results, or
107  both. A TestMetadata entity that the property has_rows set to True corresponds
108  to a timeseries on a graph, and the TestMetadata for a group of such tests
109  has a path one level less deep, which corresponds to a graph with several
110  timeseries. A TestMetadata one level less deep for that test would correspond
111  to a group of related graphs. Top-level TestMetadata (also known as test
112  suites) are keyed master/bot/test.
113
114  TestMetadata are keyed by the full path to the test (for example
115  master/bot/test/metric/page), and they also contain other metadata such as
116  description and units.
117
118  NOTE: If you remove any properties from TestMetadata, they should be added to
119  the TEST_EXCLUDE_PROPERTIES list in migrate_test_names.py.
120  """
121  internal_only = ndb.BooleanProperty(default=False, indexed=True)
122
123  # Sheriff rotation for this test. Rotations are specified by regular
124  # expressions that can be edited at /edit_sheriffs.
125  sheriff = ndb.KeyProperty(kind=sheriff_module.Sheriff, indexed=True)
126
127  # There is a default anomaly threshold config (in anomaly.py), and it can
128  # be overridden for a group of tests by using /edit_sheriffs.
129  overridden_anomaly_config = ndb.KeyProperty(
130      kind=anomaly_config.AnomalyConfig, indexed=True)
131
132  # Keep track of what direction is an improvement for this graph so we can
133  # filter out alerts on regressions.
134  improvement_direction = ndb.IntegerProperty(
135      default=anomaly.UNKNOWN,
136      choices=[
137          anomaly.UP,
138          anomaly.DOWN,
139          anomaly.UNKNOWN
140      ],
141      indexed=False
142  )
143
144  # Units of the child Rows of this test, or None if there are no child Rows.
145  units = ndb.StringProperty(indexed=False)
146
147  # The last alerted revision is used to avoid duplicate alerts.
148  last_alerted_revision = ndb.IntegerProperty(indexed=False)
149
150  # Whether or not the test has child rows. Set by hook on Row class put.
151  has_rows = ndb.BooleanProperty(default=False, indexed=True)
152
153  # If there is a currently a StoppageAlert that indicates that data hasn't
154  # been received for some time, then will be set. Otherwise, it is None.
155  stoppage_alert = ndb.KeyProperty(
156      kind=stoppage_alert_module.StoppageAlert, indexed=True)
157
158  # A test is marked "deprecated" if no new points have been received for
159  # a long time; these tests should usually not be listed.
160  deprecated = ndb.BooleanProperty(default=False, indexed=True)
161
162  # For top-level test entities, this is a list of sub-tests that are checked
163  # for alerts (i.e. they have a sheriff). For other tests, this is empty.
164  monitored = ndb.KeyProperty(repeated=True, indexed=True)
165
166  # Description of what the test measures.
167  description = ndb.TextProperty(indexed=True)
168
169  # Source code location of the test. Optional.
170  code = ndb.StringProperty(indexed=False, repeated=True)
171
172  # Command to run the test. Optional.
173  command_line = ndb.StringProperty(indexed=False)
174
175  # Computed properties are treated like member variables, so they have
176  # lowercase names, even though they look like methods to pylint.
177  # pylint: disable=invalid-name
178
179  @ndb.ComputedProperty
180  def bot(self):  # pylint: disable=invalid-name
181    """Immediate parent Bot entity, or None if this is not a test suite."""
182    parts = self.key.id().split('/')
183    if len(parts) != 3:
184      # This is not a test suite.
185      return None
186    return ndb.Key('Master', parts[0], 'Bot', parts[1])
187
188  @ndb.ComputedProperty
189  def parent_test(self):  # pylint: disable=invalid-name
190    """Immediate parent TestMetadata entity, or None if this is a test suite."""
191    parts = self.key.id().split('/')
192    if len(parts) < 4:
193      # This is a test suite
194      return None
195    return ndb.Key('TestMetadata', '/'.join(parts[:-1]))
196
197  @property
198  def test_name(self):
199    """The name of this specific test, without the test_path preceding."""
200    return self.key.id().split('/')[-1]
201
202  @property
203  def test_path(self):
204    """Slash-separated list of key parts, 'master/bot/suite/chart/...'."""
205    return utils.TestPath(self.key)
206
207  @ndb.ComputedProperty
208  def master_name(self):
209    return self.key.id().split('/')[0]
210
211  @ndb.ComputedProperty
212  def bot_name(self):
213    return self.key.id().split('/')[1]
214
215  @ndb.ComputedProperty
216  def suite_name(self):
217    return self.key.id().split('/')[2]
218
219  @ndb.ComputedProperty
220  def test_part1_name(self):
221    parts = self.key.id().split('/')
222    if len(parts) < 4:
223      return ''
224    return parts[3]
225
226  @ndb.ComputedProperty
227  def test_part2_name(self):
228    parts = self.key.id().split('/')
229    if len(parts) < 5:
230      return ''
231    return parts[4]
232
233  @ndb.ComputedProperty
234  def test_part3_name(self):
235    parts = self.key.id().split('/')
236    if len(parts) < 6:
237      return ''
238    return parts[5]
239
240  @ndb.ComputedProperty
241  def test_part4_name(self):
242    parts = self.key.id().split('/')
243    if len(parts) < 7:
244      return ''
245    return parts[6]
246
247  @classmethod
248  def _GetMasterBotSuite(cls, key):
249    if not key:
250      return None
251    return tuple(key.id().split('/')[:3])
252
253  def __init__(self, *args, **kwargs):
254    # Indexed StringProperty has a maximum length. If this length is exceeded,
255    # then an error will be thrown in ndb.Model.__init__.
256    # Truncate the "description" property if necessary.
257    description = kwargs.get('description') or ''
258    kwargs['description'] = description[:_MAX_STRING_LENGTH]
259    super(TestMetadata, self).__init__(*args, **kwargs)
260
261  def _pre_put_hook(self):
262    """This method is called before a TestMetadata is put into the datastore.
263
264    Here, we check the key to make sure it is valid and check the sheriffs and
265    anomaly configs to make sure they are current. We also update the monitored
266    list of the test suite.
267    """
268    # Check to make sure the key is valid.
269    # TestMetadata should not be an ancestor, so key.pairs() should have length
270    # of 1. The id should have at least 3 slashes to represent master/bot/suite.
271    assert len(self.key.pairs()) == 1
272    path_parts = self.key.id().split('/')
273    assert len(path_parts) >= 3
274
275    # Set the sheriff to the first sheriff (alphabetically by sheriff name)
276    # that has a test pattern that matches this test.
277    self.sheriff = None
278    for sheriff_entity in sheriff_module.Sheriff.query().fetch():
279      for pattern in sheriff_entity.patterns:
280        if utils.TestMatchesPattern(self, pattern):
281          self.sheriff = sheriff_entity.key
282      if self.sheriff:
283        break
284
285    # If this test is monitored, add it to the monitored list of its test suite.
286    # A test is be monitored iff it has a sheriff, and monitored tests are
287    # tracked in the monitored list of a test suite TestMetadata entity.
288    test_suite = ndb.Key('TestMetadata', '/'.join(path_parts[:3])).get()
289    if self.sheriff:
290      if test_suite and self.key not in test_suite.monitored:
291        test_suite.monitored.append(self.key)
292        test_suite.put()
293    elif test_suite and self.key in test_suite.monitored:
294      test_suite.monitored.remove(self.key)
295      test_suite.put()
296
297    # Set the anomaly threshold config to the first one that has a test pattern
298    # that matches this test, if there is one. Anomaly configs are sorted by
299    # name, so that a config with a name that comes earlier lexicographically
300    # is considered higher-priority.
301    self.overridden_anomaly_config = None
302    anomaly_configs = anomaly_config.AnomalyConfig.query().fetch()
303    anomaly_configs.sort(key=lambda config: config.key.string_id())
304    for anomaly_config_entity in anomaly_configs:
305      for pattern in anomaly_config_entity.patterns:
306        if utils.TestMatchesPattern(self, pattern):
307          self.overridden_anomaly_config = anomaly_config_entity.key
308      if self.overridden_anomaly_config:
309        break
310
311  def CreateCallback(self):
312    """Called when the entity is first saved."""
313    if len(self.key.id().split('/')) > 3:
314      # Since this is not a test suite, the menu cache for the suite must
315      # be updated.
316      layered_cache.Delete(
317          LIST_TESTS_SUBTEST_CACHE_KEY % self._GetMasterBotSuite(self.key))
318
319  @classmethod
320  # pylint: disable=unused-argument
321  def _pre_delete_hook(cls, key):
322    if len(key.id().split('/')) > 3:
323      # Since this is not a test suite, the menu cache for the suite must
324      # be updated.
325      layered_cache.Delete(
326          LIST_TESTS_SUBTEST_CACHE_KEY % TestMetadata._GetMasterBotSuite(key))
327
328
329class LastAddedRevision(ndb.Model):
330  """Represents the last added revision for a test path.
331
332  The reason this property is separated from TestMetadata entity is to avoid
333  contention issues (Frequent update of entity within the same group).  This
334  property is updated very frequent in /add_point.
335  """
336  revision = ndb.IntegerProperty(indexed=False)
337
338
339class Row(internal_only_model.InternalOnlyModel, ndb.Expando):
340  """A Row represents one data point.
341
342  A Row has a revision and a value, which are the X and Y values, respectively.
343  Each Row belongs to one TestMetadata, along with all of the other Row entities
344  that it is plotted with. Rows are keyed by revision.
345
346  In addition to the properties defined below, Row entities may also have other
347  properties which specify additional supplemental data. These are called
348  "supplemental columns", and should have the following prefixes:
349    d_: A data point, such as d_1st_run or d_50th_percentile. FloatProperty.
350    r_: Revision such as r_webkit or r_v8. StringProperty, limited to 25
351        characters, '0-9' and '.'.
352    a_: Annotation such as a_chrome_bugid or a_gasp_anomaly. StringProperty.
353  """
354  # Don't index by default (only explicitly indexed properties are indexed).
355  _default_indexed = False
356  internal_only = ndb.BooleanProperty(default=False, indexed=True)
357
358  # The parent_test is the key of the TestMetadata entity that this Row belongs
359  # to.
360  @ndb.ComputedProperty
361  def parent_test(self):  # pylint: disable=invalid-name
362    # The Test entity that a Row belongs to isn't actually its parent in
363    # the datastore. Rather, the parent key of each Row contains a test path,
364    # which contains the information necessary to get the actual Test
365    # key. The Test key will need to be converted back to a new style
366    # TestMetadata key to get information back out. This is because we have
367    # over 3 trillion Rows in the datastore and cannot convert them all :(
368    return utils.OldStyleTestKey(utils.TestKey(self.key.parent().string_id()))
369
370  # Points in each graph are sorted by "revision". This is usually a Chromium
371  # SVN version number, but it might also be any other integer, as long as
372  # newer points have higher numbers.
373  @ndb.ComputedProperty
374  def revision(self):  # pylint: disable=invalid-name
375    return self.key.integer_id()
376
377  # The time the revision was added to the dashboard is tracked in order
378  # to too many points from being added in a short period of time, which would
379  # indicate an error or malicious code.
380  timestamp = ndb.DateTimeProperty(auto_now_add=True, indexed=True)
381
382  # The Y-value at this point.
383  value = ndb.FloatProperty(indexed=True)
384
385  # The standard deviation at this point. Optional.
386  error = ndb.FloatProperty(indexed=False)
387
388  def _pre_put_hook(self):
389    """Sets the has_rows property of the parent test before putting this Row.
390
391    This isn't atomic because the parent_test put() and Row put() don't happen
392    in the same transaction. But in practice it shouldn't be an issue because
393    the parent test will get more points as the test runs.
394    """
395    parent_test = utils.TestMetadataKey(self.key.parent().id()).get()
396
397    # If the TestMetadata pointed to by parent_test is not valid, that indicates
398    # that a TestMetadata entity was not properly created in add_point.
399    if not parent_test:
400      parent_key = self.key.parent()
401      logging.warning(
402          'Row put without valid TestMetadata. Parent key: %s', parent_key)
403      return
404
405    if not parent_test.has_rows:
406      parent_test.has_rows = True
407      parent_test.put()
408
409
410def GetRowsForTestInRange(test_key, start_rev, end_rev, privileged=False):
411  """Gets all the Row entities for a test between a given start and end."""
412  test_key = utils.OldStyleTestKey(test_key)
413  if privileged:
414    datastore_hooks.SetSinglePrivilegedRequest()
415  query = Row.query(
416      Row.parent_test == test_key,
417      Row.revision >= start_rev,
418      Row.revision <= end_rev)
419  return query.fetch(batch_size=100)
420
421
422def GetRowsForTestAroundRev(test_key, rev, num_points, privileged=False):
423  """Gets up to |num_points| Row entities for a test centered on a revision."""
424  test_key = utils.OldStyleTestKey(test_key)
425  num_rows_before = int(num_points / 2) + 1
426  num_rows_after = int(num_points / 2)
427
428  return GetRowsForTestBeforeAfterRev(
429      test_key, rev, num_rows_before, num_rows_after, privileged)
430
431
432def GetRowsForTestBeforeAfterRev(
433    test_key, rev, num_rows_before, num_rows_after, privileged=False):
434  """Gets up to |num_points| Row entities for a test centered on a revision."""
435  test_key = utils.OldStyleTestKey(test_key)
436
437  if privileged:
438    datastore_hooks.SetSinglePrivilegedRequest()
439  query_up_to_rev = Row.query(Row.parent_test == test_key, Row.revision <= rev)
440  query_up_to_rev = query_up_to_rev.order(-Row.revision)
441  rows_up_to_rev = list(reversed(
442      query_up_to_rev.fetch(limit=num_rows_before, batch_size=100)))
443
444  if privileged:
445    datastore_hooks.SetSinglePrivilegedRequest()
446  query_after_rev = Row.query(Row.parent_test == test_key, Row.revision > rev)
447  query_after_rev = query_after_rev.order(Row.revision)
448  rows_after_rev = query_after_rev.fetch(limit=num_rows_after, batch_size=100)
449
450  return rows_up_to_rev + rows_after_rev
451
452
453def GetLatestRowsForTest(
454    test_key, num_points, keys_only=False, privileged=False):
455  """Gets the latest num_points Row entities for a test."""
456  test_key = utils.OldStyleTestKey(test_key)
457  if privileged:
458    datastore_hooks.SetSinglePrivilegedRequest()
459  query = Row.query(Row.parent_test == test_key)
460  query = query.order(-Row.revision)
461
462  return query.fetch(limit=num_points, batch_size=100, keys_only=keys_only)
463