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