Home | History | Annotate | Download | only in job
      1 /*
      2  * Copyright (c) 2016 Google Inc. All Rights Reserved.
      3  *
      4  * Licensed under the Apache License, Version 2.0 (the "License"); you
      5  * may not use this file except in compliance with the License. You may
      6  * obtain a copy of the License at
      7  *
      8  *     http://www.apache.org/licenses/LICENSE-2.0
      9  *
     10  * Unless required by applicable law or agreed to in writing, software
     11  * distributed under the License is distributed on an "AS IS" BASIS,
     12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
     13  * implied. See the License for the specific language governing
     14  * permissions and limitations under the License.
     15  */
     16 
     17 package com.android.vts.job;
     18 
     19 import com.android.vts.entity.DeviceInfoEntity;
     20 import com.android.vts.entity.TestAcknowledgmentEntity;
     21 import com.android.vts.entity.TestCaseRunEntity;
     22 import com.android.vts.entity.TestCaseRunEntity.TestCase;
     23 import com.android.vts.entity.TestRunEntity;
     24 import com.android.vts.entity.TestStatusEntity;
     25 import com.android.vts.entity.TestStatusEntity.TestCaseReference;
     26 import com.android.vts.proto.VtsReportMessage.TestCaseResult;
     27 import com.android.vts.util.DatastoreHelper;
     28 import com.android.vts.util.EmailHelper;
     29 import com.android.vts.util.FilterUtil;
     30 import com.android.vts.util.TimeUtil;
     31 import com.google.appengine.api.datastore.DatastoreFailureException;
     32 import com.google.appengine.api.datastore.DatastoreService;
     33 import com.google.appengine.api.datastore.DatastoreServiceFactory;
     34 import com.google.appengine.api.datastore.DatastoreTimeoutException;
     35 import com.google.appengine.api.datastore.Entity;
     36 import com.google.appengine.api.datastore.EntityNotFoundException;
     37 import com.google.appengine.api.datastore.FetchOptions;
     38 import com.google.appengine.api.datastore.Key;
     39 import com.google.appengine.api.datastore.KeyFactory;
     40 import com.google.appengine.api.datastore.Query;
     41 import com.google.appengine.api.datastore.Query.Filter;
     42 import com.google.appengine.api.datastore.Query.SortDirection;
     43 import com.google.appengine.api.datastore.Transaction;
     44 import com.google.appengine.api.taskqueue.Queue;
     45 import com.google.appengine.api.taskqueue.QueueFactory;
     46 import com.google.appengine.api.taskqueue.TaskOptions;
     47 import java.io.IOException;
     48 import java.io.UnsupportedEncodingException;
     49 import java.util.ArrayList;
     50 import java.util.Comparator;
     51 import java.util.ConcurrentModificationException;
     52 import java.util.HashMap;
     53 import java.util.HashSet;
     54 import java.util.List;
     55 import java.util.Map;
     56 import java.util.Set;
     57 import java.util.concurrent.TimeUnit;
     58 import java.util.logging.Level;
     59 import java.util.logging.Logger;
     60 import javax.mail.Message;
     61 import javax.mail.MessagingException;
     62 import javax.servlet.http.HttpServlet;
     63 import javax.servlet.http.HttpServletRequest;
     64 import javax.servlet.http.HttpServletResponse;
     65 import org.apache.commons.lang.StringUtils;
     66 
     67 /** Represents the notifications service which is automatically called on a fixed schedule. */
     68 public class VtsAlertJobServlet extends HttpServlet {
     69     private static final String ALERT_JOB_URL = "/task/vts_alert_job";
     70     protected static final Logger logger = Logger.getLogger(VtsAlertJobServlet.class.getName());
     71     protected static final int MAX_RUN_COUNT = 1000; // maximum number of runs to query for
     72 
     73     /**
     74      * Process the current test case failures for a test.
     75      *
     76      * @param status The TestStatusEntity object for the test.
     77      * @returns a map from test case name to the test case run ID for which the test case failed.
     78      */
     79     private static Map<String, TestCase> getCurrentFailures(TestStatusEntity status) {
     80         if (status.failingTestCases == null || status.failingTestCases.size() == 0) {
     81             return new HashMap<>();
     82         }
     83         DatastoreService datastore = DatastoreServiceFactory.getDatastoreService();
     84         Map<String, TestCase> failingTestcases = new HashMap<>();
     85         Set<Key> gets = new HashSet<>();
     86         for (TestCaseReference testCaseRef : status.failingTestCases) {
     87             gets.add(KeyFactory.createKey(TestCaseRunEntity.KIND, testCaseRef.parentId));
     88         }
     89         if (gets.size() == 0) {
     90             return failingTestcases;
     91         }
     92         Map<Key, Entity> testCaseMap = datastore.get(gets);
     93 
     94         for (TestCaseReference testCaseRef : status.failingTestCases) {
     95             Key key = KeyFactory.createKey(TestCaseRunEntity.KIND, testCaseRef.parentId);
     96             if (!testCaseMap.containsKey(key)) {
     97                 continue;
     98             }
     99             Entity testCaseRun = testCaseMap.get(key);
    100             TestCaseRunEntity testCaseRunEntity = TestCaseRunEntity.fromEntity(testCaseRun);
    101             if (testCaseRunEntity.testCases.size() <= testCaseRef.offset) {
    102                 continue;
    103             }
    104             TestCase testCase = testCaseRunEntity.testCases.get(testCaseRef.offset);
    105             failingTestcases.put(testCase.name, testCase);
    106         }
    107         return failingTestcases;
    108     }
    109 
    110     /**
    111      * Get the test acknowledgments for a test key.
    112      *
    113      * @param testKey The key to the test whose acknowledgments to fetch.
    114      * @return A list of test acknowledgments.
    115      */
    116     private static List<TestAcknowledgmentEntity> getTestCaseAcknowledgments(Key testKey) {
    117         DatastoreService datastore = DatastoreServiceFactory.getDatastoreService();
    118 
    119         List<TestAcknowledgmentEntity> acks = new ArrayList<>();
    120         Filter testFilter =
    121                 new Query.FilterPredicate(
    122                     TestAcknowledgmentEntity.TEST_KEY, Query.FilterOperator.EQUAL, testKey);
    123         Query q = new Query(TestAcknowledgmentEntity.KIND).setFilter(testFilter);
    124 
    125         for (Entity ackEntity : datastore.prepare(q).asIterable()) {
    126             TestAcknowledgmentEntity ack = TestAcknowledgmentEntity.fromEntity(ackEntity);
    127             if (ack == null) continue;
    128             acks.add(ack);
    129         }
    130         return acks;
    131     }
    132 
    133     /**
    134      * Get the test runs for the test in the specified time window.
    135      *
    136      * If the start and end time delta is greater than one day, the query will be truncated.
    137      *
    138      * @param testKey The key to the test whose runs to query.
    139      * @param startTime The start time for the query.
    140      * @param endTime The end time for the query.
    141      * @return A list of test runs in the specified time window.
    142      */
    143     private static List<TestRunEntity> getTestRuns(Key testKey, long startTime, long endTime) {
    144         DatastoreService datastore = DatastoreServiceFactory.getDatastoreService();
    145         Filter testTypeFilter = FilterUtil.getTestTypeFilter(false, true, false);
    146         long delta = endTime - startTime;
    147         delta = Math.min(delta, TimeUnit.DAYS.toMicros(1));
    148         Filter runFilter =
    149                 FilterUtil.getTimeFilter(
    150                         testKey, TestRunEntity.KIND, endTime - delta + 1, endTime, testTypeFilter);
    151 
    152         Query q =
    153                 new Query(TestRunEntity.KIND)
    154                         .setAncestor(testKey)
    155                         .setFilter(runFilter)
    156                         .addSort(Entity.KEY_RESERVED_PROPERTY, SortDirection.DESCENDING);
    157 
    158         List<TestRunEntity> testRuns = new ArrayList<>();
    159         for (Entity testRunEntity :
    160                 datastore.prepare(q).asIterable(FetchOptions.Builder.withLimit(MAX_RUN_COUNT))) {
    161             TestRunEntity testRun = TestRunEntity.fromEntity(testRunEntity);
    162             if (testRun == null) continue;
    163             testRuns.add(testRun);
    164         }
    165         return testRuns;
    166     }
    167 
    168     /**
    169      * Separate the test cases which are acknowledged by the provided acknowledgments.
    170      *
    171      * @param testCases The list of test case names.
    172      * @param devices The list of devices for a test run.
    173      * @param acks The list of acknowledgments for the test.
    174      * @return A list of acknowledged test case names that have been removed from the input test
    175      *     cases.
    176      */
    177     public static Set<String> separateAcknowledged(
    178             Set<String> testCases,
    179             List<DeviceInfoEntity> devices,
    180             List<TestAcknowledgmentEntity> acks) {
    181         Set<String> acknowledged = new HashSet<>();
    182         for (TestAcknowledgmentEntity ack : acks) {
    183             boolean allDevices = ack.devices == null || ack.devices.size() == 0;
    184             boolean allBranches = ack.branches == null || ack.branches.size() == 0;
    185             boolean isRelevant = allDevices && allBranches;
    186 
    187             // Determine if the acknowledgment is relevant to the devices.
    188             if (!isRelevant) {
    189                 for (DeviceInfoEntity device : devices) {
    190                     boolean deviceAcknowledged =
    191                             allDevices || ack.devices.contains(device.buildFlavor);
    192                     boolean branchAcknowledged =
    193                             allBranches || ack.branches.contains(device.branch);
    194                     if (deviceAcknowledged && branchAcknowledged) isRelevant = true;
    195                 }
    196             }
    197 
    198             if (isRelevant) {
    199                 // Separate the test cases
    200                 boolean allTestCases = ack.testCaseNames == null || ack.testCaseNames.size() == 0;
    201                 if (allTestCases) {
    202                     acknowledged.addAll(testCases);
    203                     testCases.removeAll(acknowledged);
    204                 } else {
    205                     for (String testCase : ack.testCaseNames) {
    206                         if (testCases.contains(testCase)) {
    207                             acknowledged.add(testCase);
    208                             testCases.remove(testCase);
    209                         }
    210                     }
    211                 }
    212             }
    213         }
    214         return acknowledged;
    215     }
    216 
    217     /**
    218      * Checks whether any new failures have occurred beginning since (and including) startTime.
    219      *
    220      * @param testRuns The list of test runs for which to update the status.
    221      * @param link The string URL linking to the test's status table.
    222      * @param failedTestCaseMap The map of test case names to TestCase for those failing in the last
    223      *     status update.
    224      * @param emailAddresses The list of email addresses to send notifications to.
    225      * @param messages The email Message queue.
    226      * @returns latest TestStatusMessage or null if no update is available.
    227      * @throws IOException
    228      */
    229     public TestStatusEntity getTestStatus(
    230             List<TestRunEntity> testRuns,
    231             String link,
    232             Map<String, TestCase> failedTestCaseMap,
    233             List<TestAcknowledgmentEntity> testAcks,
    234             List<String> emailAddresses,
    235             List<Message> messages)
    236             throws IOException {
    237         if (testRuns.size() == 0) return null;
    238         DatastoreService datastore = DatastoreServiceFactory.getDatastoreService();
    239 
    240         TestRunEntity mostRecentRun = null;
    241         Map<String, TestCaseResult> mostRecentTestCaseResults = new HashMap<>();
    242         Map<String, TestCase> testCaseBreakageMap = new HashMap<>();
    243         int passingTestcaseCount = 0;
    244         List<TestCaseReference> failingTestCases = new ArrayList<>();
    245         Set<String> fixedTestcases = new HashSet<>();
    246         Set<String> newTestcaseFailures = new HashSet<>();
    247         Set<String> continuedTestcaseFailures = new HashSet<>();
    248         Set<String> skippedTestcaseFailures = new HashSet<>();
    249         Set<String> transientTestcaseFailures = new HashSet<>();
    250 
    251         for (TestRunEntity testRun : testRuns) {
    252             if (mostRecentRun == null) {
    253                 mostRecentRun = testRun;
    254             }
    255             List<Key> testCaseKeys = new ArrayList<>();
    256             for (long testCaseId : testRun.testCaseIds) {
    257                 testCaseKeys.add(KeyFactory.createKey(TestCaseRunEntity.KIND, testCaseId));
    258             }
    259             Map<Key, Entity> entityMap = datastore.get(testCaseKeys);
    260             for (Key testCaseKey : testCaseKeys) {
    261                 if (!entityMap.containsKey(testCaseKey)) {
    262                     logger.log(Level.WARNING, "Test case entity missing: " + testCaseKey);
    263                     continue;
    264                 }
    265                 Entity testCaseRun = entityMap.get(testCaseKey);
    266                 TestCaseRunEntity testCaseRunEntity = TestCaseRunEntity.fromEntity(testCaseRun);
    267                 if (testCaseRunEntity == null) {
    268                     logger.log(Level.WARNING, "Invalid test case run: " + testCaseRun.getKey());
    269                     continue;
    270                 }
    271                 for (TestCase testCase : testCaseRunEntity.testCases) {
    272                     String testCaseName = testCase.name;
    273                     TestCaseResult result = TestCaseResult.valueOf(testCase.result);
    274 
    275                     if (mostRecentRun == testRun) {
    276                         mostRecentTestCaseResults.put(testCaseName, result);
    277                     } else {
    278                         if (!mostRecentTestCaseResults.containsKey(testCaseName)) {
    279                             // Deprecate notifications for tests that are not present on newer runs
    280                             continue;
    281                         }
    282                         TestCaseResult mostRecentRes = mostRecentTestCaseResults.get(testCaseName);
    283                         if (mostRecentRes == TestCaseResult.TEST_CASE_RESULT_SKIP) {
    284                             mostRecentTestCaseResults.put(testCaseName, result);
    285                         } else if (mostRecentRes == TestCaseResult.TEST_CASE_RESULT_PASS) {
    286                             // Test is passing now, witnessed a transient failure
    287                             if (result != TestCaseResult.TEST_CASE_RESULT_PASS
    288                                     && result != TestCaseResult.TEST_CASE_RESULT_SKIP) {
    289                                 transientTestcaseFailures.add(testCaseName);
    290                             }
    291                         }
    292                     }
    293 
    294                     // Record test case breakages
    295                     if (result != TestCaseResult.TEST_CASE_RESULT_PASS
    296                             && result != TestCaseResult.TEST_CASE_RESULT_SKIP) {
    297                         testCaseBreakageMap.put(testCaseName, testCase);
    298                     }
    299                 }
    300             }
    301         }
    302 
    303         Set<String> buildIdList = new HashSet<>();
    304         List<DeviceInfoEntity> devices = new ArrayList<>();
    305         Query deviceQuery = new Query(DeviceInfoEntity.KIND).setAncestor(mostRecentRun.key);
    306         for (Entity device : datastore.prepare(deviceQuery).asIterable()) {
    307             DeviceInfoEntity deviceEntity = DeviceInfoEntity.fromEntity(device);
    308             if (deviceEntity == null) {
    309                 continue;
    310             }
    311             buildIdList.add(deviceEntity.buildId);
    312             devices.add(deviceEntity);
    313         }
    314         String footer = EmailHelper.getEmailFooter(mostRecentRun, devices, link);
    315         String buildId = StringUtils.join(buildIdList, ",");
    316 
    317         for (String testCaseName : mostRecentTestCaseResults.keySet()) {
    318             TestCaseResult mostRecentResult = mostRecentTestCaseResults.get(testCaseName);
    319             boolean previouslyFailed = failedTestCaseMap.containsKey(testCaseName);
    320             if (mostRecentResult == TestCaseResult.TEST_CASE_RESULT_SKIP) {
    321                 // persist previous status
    322                 if (previouslyFailed) {
    323                     skippedTestcaseFailures.add(testCaseName);
    324                     failingTestCases.add(
    325                             new TestCaseReference(failedTestCaseMap.get(testCaseName)));
    326                 } else {
    327                     ++passingTestcaseCount;
    328                 }
    329             } else if (mostRecentResult == TestCaseResult.TEST_CASE_RESULT_PASS) {
    330                 ++passingTestcaseCount;
    331                 if (previouslyFailed && !transientTestcaseFailures.contains(testCaseName)) {
    332                     fixedTestcases.add(testCaseName);
    333                 }
    334             } else {
    335                 if (!previouslyFailed) {
    336                     newTestcaseFailures.add(testCaseName);
    337                     failingTestCases.add(
    338                             new TestCaseReference(testCaseBreakageMap.get(testCaseName)));
    339                 } else {
    340                     continuedTestcaseFailures.add(testCaseName);
    341                     failingTestCases.add(
    342                             new TestCaseReference(failedTestCaseMap.get(testCaseName)));
    343                 }
    344             }
    345         }
    346 
    347         Set<String> acknowledgedFailures =
    348                 separateAcknowledged(newTestcaseFailures, devices, testAcks);
    349         acknowledgedFailures.addAll(
    350                 separateAcknowledged(transientTestcaseFailures, devices, testAcks));
    351         acknowledgedFailures.addAll(
    352                 separateAcknowledged(continuedTestcaseFailures, devices, testAcks));
    353 
    354         String summary = new String();
    355         if (newTestcaseFailures.size() + continuedTestcaseFailures.size() > 0) {
    356             summary += "The following test cases failed in the latest test run:<br>";
    357 
    358             // Add new test case failures to top of summary in bold font.
    359             List<String> sortedNewTestcaseFailures = new ArrayList<>(newTestcaseFailures);
    360             sortedNewTestcaseFailures.sort(Comparator.naturalOrder());
    361             for (String testcaseName : sortedNewTestcaseFailures) {
    362                 summary += "- " + "<b>" + testcaseName + "</b><br>";
    363             }
    364 
    365             // Add continued test case failures to summary.
    366             List<String> sortedContinuedTestcaseFailures =
    367                     new ArrayList<>(continuedTestcaseFailures);
    368             sortedContinuedTestcaseFailures.sort(Comparator.naturalOrder());
    369             for (String testcaseName : sortedContinuedTestcaseFailures) {
    370                 summary += "- " + testcaseName + "<br>";
    371             }
    372         }
    373         if (fixedTestcases.size() > 0) {
    374             // Add fixed test cases to summary.
    375             summary += "<br><br>The following test cases were fixed in the latest test run:<br>";
    376             List<String> sortedFixedTestcases = new ArrayList<>(fixedTestcases);
    377             sortedFixedTestcases.sort(Comparator.naturalOrder());
    378             for (String testcaseName : sortedFixedTestcases) {
    379                 summary += "- <i>" + testcaseName + "</i><br>";
    380             }
    381         }
    382         if (transientTestcaseFailures.size() > 0) {
    383             // Add transient test case failures to summary.
    384             summary += "<br><br>The following transient test case failures occured:<br>";
    385             List<String> sortedTransientTestcaseFailures =
    386                     new ArrayList<>(transientTestcaseFailures);
    387             sortedTransientTestcaseFailures.sort(Comparator.naturalOrder());
    388             for (String testcaseName : sortedTransientTestcaseFailures) {
    389                 summary += "- " + testcaseName + "<br>";
    390             }
    391         }
    392         if (skippedTestcaseFailures.size() > 0) {
    393             // Add skipped test case failures to summary.
    394             summary += "<br><br>The following test cases have not been run since failing:<br>";
    395             List<String> sortedSkippedTestcaseFailures = new ArrayList<>(skippedTestcaseFailures);
    396             sortedSkippedTestcaseFailures.sort(Comparator.naturalOrder());
    397             for (String testcaseName : sortedSkippedTestcaseFailures) {
    398                 summary += "- " + testcaseName + "<br>";
    399             }
    400         }
    401         if (acknowledgedFailures.size() > 0) {
    402             // Add acknowledged test case failures to summary.
    403             List<String> sortedAcknowledgedFailures = new ArrayList<>(acknowledgedFailures);
    404             sortedAcknowledgedFailures.sort(Comparator.naturalOrder());
    405             if (acknowledgedFailures.size() > 0) {
    406                 summary +=
    407                         "<br><br>The following acknowledged test case failures continued to fail:<br>";
    408                 for (String testcaseName : sortedAcknowledgedFailures) {
    409                     summary += "- " + testcaseName + "<br>";
    410                 }
    411             }
    412         }
    413 
    414         String testName = mostRecentRun.key.getParent().getName();
    415         String uploadDateString = TimeUtil.getDateString(mostRecentRun.startTimestamp);
    416         String subject = "VTS Test Alert: " + testName + " @ " + uploadDateString;
    417         if (newTestcaseFailures.size() > 0) {
    418             String body =
    419                     "Hello,<br><br>New test case failure(s) in "
    420                             + testName
    421                             + " for device build ID(s): "
    422                             + buildId
    423                             + ".<br><br>"
    424                             + summary
    425                             + footer;
    426             try {
    427                 messages.add(EmailHelper.composeEmail(emailAddresses, subject, body));
    428             } catch (MessagingException | UnsupportedEncodingException e) {
    429                 logger.log(Level.WARNING, "Error composing email : ", e);
    430             }
    431         } else if (continuedTestcaseFailures.size() > 0) {
    432             String body =
    433                     "Hello,<br><br>Continuous test case failure(s) in "
    434                             + testName
    435                             + " for device build ID(s): "
    436                             + buildId
    437                             + ".<br><br>"
    438                             + summary
    439                             + footer;
    440             try {
    441                 messages.add(EmailHelper.composeEmail(emailAddresses, subject, body));
    442             } catch (MessagingException | UnsupportedEncodingException e) {
    443                 logger.log(Level.WARNING, "Error composing email : ", e);
    444             }
    445         } else if (transientTestcaseFailures.size() > 0) {
    446             String body =
    447                     "Hello,<br><br>Transient test case failure(s) in "
    448                             + testName
    449                             + " but tests all "
    450                             + "are passing in the latest device build(s): "
    451                             + buildId
    452                             + ".<br><br>"
    453                             + summary
    454                             + footer;
    455             try {
    456                 messages.add(EmailHelper.composeEmail(emailAddresses, subject, body));
    457             } catch (MessagingException | UnsupportedEncodingException e) {
    458                 logger.log(Level.WARNING, "Error composing email : ", e);
    459             }
    460         } else if (fixedTestcases.size() > 0) {
    461             String body =
    462                     "Hello,<br><br>All test cases passed in "
    463                             + testName
    464                             + " for device build ID(s): "
    465                             + buildId
    466                             + "!<br><br>"
    467                             + summary
    468                             + footer;
    469             try {
    470                 messages.add(EmailHelper.composeEmail(emailAddresses, subject, body));
    471             } catch (MessagingException | UnsupportedEncodingException e) {
    472                 logger.log(Level.WARNING, "Error composing email : ", e);
    473             }
    474         }
    475         return new TestStatusEntity(
    476                 testName,
    477                 mostRecentRun.startTimestamp,
    478                 passingTestcaseCount,
    479                 failingTestCases.size(),
    480                 failingTestCases);
    481     }
    482 
    483     /**
    484      * Add a task to process test run data
    485      *
    486      * @param testRunKey The key of the test run whose data process.
    487      */
    488     public static void addTask(Key testRunKey) {
    489         Queue queue = QueueFactory.getDefaultQueue();
    490         String keyString = KeyFactory.keyToString(testRunKey);
    491         queue.add(
    492                 TaskOptions.Builder.withUrl(ALERT_JOB_URL)
    493                         .param("runKey", keyString)
    494                         .method(TaskOptions.Method.POST));
    495     }
    496 
    497     @Override
    498     public void doPost(HttpServletRequest request, HttpServletResponse response)
    499             throws IOException {
    500         DatastoreService datastore = DatastoreServiceFactory.getDatastoreService();
    501         String runKeyString = request.getParameter("runKey");
    502 
    503         Key testRunKey;
    504         try {
    505             testRunKey = KeyFactory.stringToKey(runKeyString);
    506         } catch (IllegalArgumentException e) {
    507             logger.log(Level.WARNING, "Invalid key specified: " + runKeyString);
    508             return;
    509         }
    510         String testName = testRunKey.getParent().getName();
    511 
    512         TestStatusEntity status = null;
    513         Key statusKey = KeyFactory.createKey(TestStatusEntity.KIND, testName);
    514         try {
    515             status = TestStatusEntity.fromEntity(datastore.get(statusKey));
    516         } catch (EntityNotFoundException e) {
    517             // no existing status
    518         }
    519         if (status == null) {
    520             status = new TestStatusEntity(testName);
    521         }
    522         if (status.timestamp >= testRunKey.getId()) {
    523             // Another job has already updated the status first
    524             return;
    525         }
    526         List<String> emails = EmailHelper.getSubscriberEmails(testRunKey.getParent());
    527 
    528         StringBuffer fullUrl = request.getRequestURL();
    529         String baseUrl = fullUrl.substring(0, fullUrl.indexOf(request.getRequestURI()));
    530         String link =
    531                 baseUrl + "/show_tree?testName=" + testName + "&endTime=" + testRunKey.getId();
    532 
    533         List<Message> messageQueue = new ArrayList<>();
    534         Map<String, TestCase> failedTestcaseMap = getCurrentFailures(status);
    535         List<TestAcknowledgmentEntity> testAcks =
    536                 getTestCaseAcknowledgments(testRunKey.getParent());
    537         List<TestRunEntity> testRuns =
    538                 getTestRuns(testRunKey.getParent(), status.timestamp, testRunKey.getId());
    539         if (testRuns.size() == 0) return;
    540 
    541         TestStatusEntity newStatus =
    542                 getTestStatus(testRuns, link, failedTestcaseMap, testAcks, emails, messageQueue);
    543         if (newStatus == null) {
    544             // No changes to status
    545             return;
    546         }
    547 
    548         int retries = 0;
    549         while (true) {
    550             Transaction txn = datastore.beginTransaction();
    551             try {
    552                 try {
    553                     status = TestStatusEntity.fromEntity(datastore.get(statusKey));
    554                 } catch (EntityNotFoundException e) {
    555                     // no status left
    556                 }
    557                 if (status == null || status.timestamp >= newStatus.timestamp) {
    558                     txn.rollback();
    559                 } else { // This update is most recent.
    560                     datastore.put(newStatus.toEntity());
    561                     txn.commit();
    562                     EmailHelper.sendAll(messageQueue);
    563                 }
    564                 break;
    565             } catch (ConcurrentModificationException
    566                     | DatastoreFailureException
    567                     | DatastoreTimeoutException e) {
    568                 logger.log(Level.WARNING, "Retrying alert job insert: " + statusKey);
    569                 if (retries++ >= DatastoreHelper.MAX_WRITE_RETRIES) {
    570                     logger.log(Level.SEVERE, "Exceeded alert job retries: " + statusKey);
    571                     throw e;
    572                 }
    573             } finally {
    574                 if (txn.isActive()) {
    575                     txn.rollback();
    576                 }
    577             }
    578         }
    579     }
    580 }
    581