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"""Task queue task which migrates a Test and its Rows to a new name. 6 7A rename consists of listing all Test entities which match the old_name, 8and then, for each, completing these steps: 9 * Create a new Test entity with the new name. 10 * Re-parent all Test and Row entities from the old Test to the new Test. 11 * Update alerts to reference the new Test. 12 * Delete the old Test. 13 14For any rename, there could be hundreds of Tests and many thousands of Rows. 15Datastore operations often time out after a few hundred puts(), so this task 16is split up using the task queue. 17""" 18 19import re 20 21from google.appengine.api import mail 22from google.appengine.api import taskqueue 23from google.appengine.ext import ndb 24 25from dashboard import datastore_hooks 26from dashboard import graph_revisions 27from dashboard import list_tests 28from dashboard import request_handler 29from dashboard import utils 30from dashboard.models import anomaly 31from dashboard.models import graph_data 32from dashboard.models import stoppage_alert 33 34_MAX_DATASTORE_PUTS_PER_PUT_MULTI_CALL = 50 35 36# Properties of Test that should not be copied when a Test is being copied. 37_TEST_COMPUTED_PROPERTIES = [ 38 'bot', 39 'parent_test', 40 'test_path', 41 'id', 42 'master_name', 43 'bot_name', 44 'suite_name', 45 'test_part1_name', 46 'test_part2_name', 47 'test_part3_name', 48 'test_part4_name', 49] 50# The following shouldn't be copied because they were removed from the Model; 51# Creating a new entity with one of these properties will result in an error. 52_TEST_DEPRECATED_PROPERTIES = [ 53 'has_multi_value_rows', 54 'important', 55 'is_stacked', 56 'last_added_revision', 57 'overridden_gasp_modelset', 58 'units_x', 59 'buildername', 60 'masterid', 61] 62_TEST_EXCLUDE = _TEST_COMPUTED_PROPERTIES + _TEST_DEPRECATED_PROPERTIES 63 64# Properties of Row that shouldn't be copied. 65_ROW_EXCLUDE = ['parent_test', 'revision', 'id'] 66 67_SHERIFF_ALERT_EMAIL_BODY = """ 68The test %(old_test_path)s has been migrated to %(new_test_path)s. 69 70It was previously sheriffed by %(old_sheriff)s. 71 72Please ensure the new test is properly sheriffed! 73""" 74 75# Match square brackets and group inside them. 76_BRACKETS_REGEX = r'\[([^\]]*)\]' 77 78# Queue name needs to be listed in queue.yaml. 79_TASK_QUEUE_NAME = 'migrate-test-names-queue' 80 81 82class BadInputPatternError(Exception): 83 pass 84 85 86class MigrateTestNamesHandler(request_handler.RequestHandler): 87 """Migrates the data for a test which has been renamed on the buildbots.""" 88 89 def get(self): 90 """Displays a simple UI form to kick off migrations.""" 91 self.RenderHtml('migrate_test_names.html', {}) 92 93 def post(self): 94 """Starts migration of old Test entity names to new ones. 95 96 The form that's used to kick off migrations will give the parameters 97 old_pattern and new_pattern, which are both test path pattern strings. 98 99 When this handler is called from the task queue, however, it will be given 100 the parameters old_test_key and new_test_key, which should both be keys 101 of Test entities in urlsafe form. 102 """ 103 datastore_hooks.SetPrivilegedRequest() 104 105 old_pattern = self.request.get('old_pattern') 106 new_pattern = self.request.get('new_pattern') 107 old_test_key = self.request.get('old_test_key') 108 new_test_key = self.request.get('new_test_key') 109 110 if old_pattern and new_pattern: 111 try: 112 _AddTasksForPattern(old_pattern, new_pattern) 113 self.RenderHtml('result.html', { 114 'headline': 'Test name migration task started.' 115 }) 116 except BadInputPatternError as error: 117 self.ReportError('Error: %s' % error.message, status=400) 118 elif old_test_key and new_test_key: 119 _MigrateOldTest(old_test_key, new_test_key) 120 else: 121 self.ReportError('Missing required parameters of /migrate_test_names.') 122 123 124def _AddTasksForPattern(old_pattern, new_pattern): 125 """Enumerates individual test migration tasks and enqueues them. 126 127 Typically, this function is called by a request initiated by the user. 128 The purpose of this function is to queue up a set of requests which will 129 do all of the actual work. 130 131 Args: 132 old_pattern: Test path pattern for old names. 133 new_pattern: Test path pattern for new names. 134 135 Raises: 136 BadInputPatternError: Something was wrong with the input patterns. 137 """ 138 tests = list_tests.GetTestsMatchingPattern(old_pattern, list_entities=True) 139 for test in tests: 140 _AddTaskForTest(test, new_pattern) 141 142 143def _AddTaskForTest(test, new_pattern): 144 """Adds a task to the task queue to migrate a Test and its descendants. 145 146 Args: 147 test: A Test entity. 148 new_pattern: A test path pattern which determines the new name. 149 """ 150 old_path = utils.TestPath(test.key) 151 new_path = _GetNewTestPath(old_path, new_pattern) 152 new_path_parts = new_path.split('/') 153 154 new_path_leaf_name = new_path_parts[-1] 155 new_path_parent = '/'.join(new_path_parts[:-1]) 156 157 # Copy the new test from the old test. The new parent should exist. 158 new_test_key = _CreateRenamedEntityIfNotExists( 159 graph_data.Test, test, new_path_leaf_name, 160 utils.TestKey(new_path_parent), _TEST_EXCLUDE).put() 161 task_params = { 162 'old_test_key': test.key.urlsafe(), 163 'new_test_key': new_test_key.urlsafe(), 164 } 165 taskqueue.add( 166 url='/migrate_test_names', 167 params=task_params, 168 queue_name=_TASK_QUEUE_NAME) 169 170 171def _GetNewTestPath(old_path, new_pattern): 172 """Returns the destination test path that a test should be renamed to. 173 174 The given |new_pattern| consists of a sequence of parts separated by slashes, 175 and each part can be one of: 176 (1) a single asterisk; in this case the corresponding part of the original 177 test path will be used. 178 (2) a string with brackets; which means that the corresponding part of the 179 original test path should be used, but with the bracketed part removed. 180 (3) a literal string; in this case this literal string will be used. 181 182 The new_pattern can have fewer parts than the old test path; it can also be 183 longer, but in this case the new pattern can't contain asterisks or brackets 184 in the parts at the end. 185 186 Args: 187 old_path: A test path, e.g. ChromiumPerf/linux/sunspider/Total 188 new_pattern: A destination path pattern. 189 190 Returns: 191 The new test path to use. 192 193 Raises: 194 BadInputPatternError: Something was wrong with the input patterns. 195 """ 196 assert '*' not in old_path, '* should never appear in actual test paths.' 197 old_path_parts = old_path.split('/') 198 new_pattern_parts = new_pattern.split('/') 199 new_path_parts = [] 200 for old_part, new_part in map(None, old_path_parts, new_pattern_parts): 201 if not new_part: 202 break # In this case, the new path is shorter than the old. 203 elif new_part == '*': 204 # The old part field must exist. 205 if not old_part: 206 raise BadInputPatternError('* in new pattern has no corresponding ' 207 'part in old test path %s' % old_path) 208 new_path_parts.append(old_part) 209 elif re.search(_BRACKETS_REGEX, new_part): 210 # A string contained in brackets in new path should be replaced by 211 # old path with that string deleted. If the current part of old_path 212 # is exactly that string, the new path rows are parented to the 213 # previous part of old path. 214 modified_old_part = _RemoveBracketedSubstring(old_part, new_part) 215 if not modified_old_part: 216 break 217 new_path_parts.append(modified_old_part) 218 else: 219 if '*' in new_part: 220 raise BadInputPatternError('Unexpected * in new test path pattern.') 221 new_path_parts.append(new_part) 222 return '/'.join(new_path_parts) 223 224 225def _RemoveBracketedSubstring(old_part, new_part): 226 """Returns the new name obtained by removing the given substring. 227 228 Examples: 229 _RemoveBracketedSubstring('asdf', '[sd]') => 'af' 230 _RemoveBracketedSubstring('asdf', '[asdf]') => '' 231 _RemoveBracketedSubstring('asdf', '[xy]') => Exception 232 233 Args: 234 old_part: A part of a test path. 235 new_part: A string starting and ending with brackets, where the part 236 inside the brackets is a substring of |old_part|. 237 238 Returns: 239 The |old_part| string with the substring removed. 240 241 Raises: 242 BadInputPatternError: The input was invalid. 243 """ 244 substring_to_remove = re.search(_BRACKETS_REGEX, new_part).group(1) 245 if substring_to_remove not in old_part: 246 raise BadInputPatternError('Bracketed part not in %s.' % old_part) 247 modified_old_part = old_part.replace(substring_to_remove, '', 1) 248 return modified_old_part 249 250 251def _MigrateOldTest(old_test_key_urlsafe, new_test_key_urlsafe): 252 """Migrates Rows for one Test. 253 254 This migrates up to _MAX_DATASTORE_PUTS_PER_PUT_MULTI_CALL Rows at once 255 for one level of descendant tests of old_test_key. Adds tasks to the task 256 queue so that it will be called again until there is nothing to migrate. 257 258 Args: 259 old_test_key_urlsafe: Key of old Test entity in urlsafe form. 260 new_test_key_urlsafe: Key of new Test entity in urlsafe form. 261 """ 262 old_test_key = ndb.Key(urlsafe=old_test_key_urlsafe) 263 new_test_key = ndb.Key(urlsafe=new_test_key_urlsafe) 264 finished = _MigrateTestToNewKey(old_test_key, new_test_key) 265 if not finished: 266 task_params = { 267 'old_test_key': old_test_key_urlsafe, 268 'new_test_key': new_test_key_urlsafe, 269 } 270 taskqueue.add( 271 url='/migrate_test_names', 272 params=task_params, 273 queue_name=_TASK_QUEUE_NAME) 274 275 276def _MigrateTestToNewKey(old_test_key, new_test_key): 277 """Migrates data (Row entities) from the old to the new test. 278 279 Migrating all rows in one request is usually too much work to do before 280 we hit a deadline exceeded error, so this function only does a limited 281 chunk of work at one time. 282 283 Args: 284 old_test_key: The key of the Test to migrate data from. 285 new_test_key: The key of the Test to migrate data to. 286 287 Returns: 288 True if finished or False if there is more work. 289 """ 290 futures = [] 291 292 # Try to re-parent children test first. If this does not complete in one 293 # request, the reset of the actions should be done in a separate request. 294 if not _ReparentChildTests(old_test_key, new_test_key): 295 return False 296 297 migrate_rows_result = _MigrateTestRows(old_test_key, new_test_key) 298 if migrate_rows_result['moved_rows']: 299 futures += migrate_rows_result['put_future'] 300 futures += migrate_rows_result['delete_future'] 301 ndb.Future.wait_all(futures) 302 return False 303 304 futures += _MigrateAnomalies(old_test_key, new_test_key) 305 futures += _MigrateStoppageAlerts(old_test_key, new_test_key) 306 307 if not futures: 308 _SendNotificationEmail(old_test_key, new_test_key) 309 old_test_key.delete() 310 else: 311 ndb.Future.wait_all(futures) 312 return False 313 314 return True 315 316 317def _ReparentChildTests(old_parent_key, new_parent_key): 318 """Migrates child tests from one parent test to another. 319 320 This will involve calling |_MigrateTestToNewKey|, which then 321 recursively moves children under these children until all of 322 the children are moved. 323 324 Args: 325 old_parent_key: Test entity key of the test to move from. 326 new_parent_key: Test entity key of the test to move to. 327 328 Returns: 329 True if finished, False otherwise. 330 """ 331 tests_to_reparent = graph_data.Test.query( 332 graph_data.Test.parent_test == old_parent_key).fetch( 333 limit=_MAX_DATASTORE_PUTS_PER_PUT_MULTI_CALL) 334 for test in tests_to_reparent: 335 new_subtest_key = _CreateRenamedEntityIfNotExists( 336 graph_data.Test, test, test.key.string_id(), new_parent_key, 337 _TEST_EXCLUDE).put() 338 finished = _MigrateTestToNewKey(test.key, new_subtest_key) 339 if not finished: 340 return False 341 return True 342 343 344def _MigrateTestRows(old_parent_key, new_parent_key): 345 """Copies Row entities from one parent to another, deleting old ones. 346 347 Args: 348 old_parent_key: Test entity key of the test to move from. 349 new_parent_key: Test entity key of the test to move to. 350 351 Returns: 352 A dictionary with the following keys: 353 put_future: A list of Future objects for entities being put. 354 delete_future: A list of Future objects for entities being deleted. 355 moved_rows: Whether or not any entities were moved. 356 """ 357 # In this function we'll build up lists of entities to put and delete 358 # before returning Future objects for the entities being put and deleted. 359 rows_to_put = [] 360 rows_to_delete = [] 361 362 # Add some Row entities to the lists of entities to put and delete. 363 query = graph_data.Row.query(graph_data.Row.parent_test == old_parent_key) 364 rows = query.fetch(limit=_MAX_DATASTORE_PUTS_PER_PUT_MULTI_CALL) 365 for row in rows: 366 rows_to_put.append(_CreateRenamedEntityIfNotExists( 367 graph_data.Row, row, row.key.id(), new_parent_key, _ROW_EXCLUDE)) 368 rows_to_delete.append(row.key) 369 370 # Clear the cached revision range selector data for both the old and new 371 # tests because it will no longer be valid after migration. The cache should 372 # be updated with accurate data the next time it's set, which will happen 373 # when someone views the graph. 374 graph_revisions.DeleteCache(utils.TestPath(old_parent_key)) 375 graph_revisions.DeleteCache(utils.TestPath(new_parent_key)) 376 377 return { 378 'put_future': ndb.put_multi_async(rows_to_put), 379 'delete_future': ndb.delete_multi_async(rows_to_delete), 380 'moved_rows': bool(rows_to_put), 381 } 382 383 384def _MigrateAnomalies(old_parent_key, new_parent_key): 385 """Copies the Anomaly entities from one test to another. 386 387 Args: 388 old_parent_key: Source Test entity key. 389 new_parent_key: Destination Test entity key. 390 391 Returns: 392 A list of Future objects for Anomaly entities to update. 393 """ 394 anomalies_to_update = anomaly.Anomaly.query( 395 anomaly.Anomaly.test == old_parent_key).fetch( 396 limit=_MAX_DATASTORE_PUTS_PER_PUT_MULTI_CALL) 397 if not anomalies_to_update: 398 return [] 399 for anomaly_entity in anomalies_to_update: 400 anomaly_entity.test = new_parent_key 401 return ndb.put_multi_async(anomalies_to_update) 402 403 404def _MigrateStoppageAlerts(old_parent_key, new_parent_key): 405 """Copies the StoppageAlert entities from one test to another. 406 407 Args: 408 old_parent_key: Source Test entity key. 409 new_parent_key: Destination Test entity key. 410 411 Returns: 412 A list of Future objects for StoppageAlert puts and deletes. 413 """ 414 query = stoppage_alert.StoppageAlert.query( 415 stoppage_alert.StoppageAlert.test == old_parent_key) 416 alerts_to_update = query.fetch(limit=_MAX_DATASTORE_PUTS_PER_PUT_MULTI_CALL) 417 if not alerts_to_update: 418 return [] 419 futures = [] 420 for entity in alerts_to_update: 421 new_entity = stoppage_alert.StoppageAlert( 422 parent=ndb.Key('StoppageAlertParent', utils.TestPath(new_parent_key)), 423 id=entity.key.id(), 424 mail_sent=entity.mail_sent, 425 recovered=entity.recovered) 426 futures.append(entity.key.delete_async()) 427 futures.append(new_entity.put_async()) 428 return futures 429 430 431def _SendNotificationEmail(old_test_key, new_test_key): 432 """Send a notification email about the test migration. 433 434 This function should be called after we have already found out that there are 435 no new rows to move from the old test to the new test, but before we actually 436 delete the old test. 437 438 Args: 439 old_test_key: Test key of the test that's about to be deleted. 440 new_test_key: Test key of the test that's replacing the old one. 441 """ 442 old_entity = old_test_key.get() 443 if not old_entity or not old_entity.sheriff: 444 return 445 body = _SHERIFF_ALERT_EMAIL_BODY % { 446 'old_test_path': utils.TestPath(old_test_key), 447 'new_test_path': utils.TestPath(new_test_key), 448 'old_sheriff': old_entity.sheriff.string_id(), 449 } 450 mail.send_mail(sender='gasper-alerts@google.com', 451 to='chrome-perf-dashboard-alerts@google.com', 452 subject='Sheriffed Test Migrated', 453 body=body) 454 455 456def _CreateRenamedEntityIfNotExists( 457 cls, old_entity, new_name, parent_key, exclude): 458 """Create an entity with the desired name if one does not exist. 459 460 Args: 461 cls: The class of the entity to create, either Row or Test. 462 old_entity: The old entity to copy. 463 new_name: The string id of the new entity. 464 parent_key: The ndb.Key for the parent test of the new entity. 465 exclude: Properties to not copy from the old entity. 466 467 Returns: 468 The new Row or Test entity (or the existing one, if one already exists). 469 """ 470 new_entity = cls.get_by_id(new_name, parent=parent_key) 471 if new_entity: 472 return new_entity 473 if old_entity.key.kind() == 'Row': 474 parent_key = utils.GetTestContainerKey(parent_key) 475 create_args = { 476 'id': new_name, 477 'parent': parent_key, 478 } 479 for prop, val in old_entity.to_dict(exclude=exclude).iteritems(): 480 create_args[prop] = val 481 new_entity = cls(**create_args) 482 return new_entity 483