• 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"""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