blob: be23982715ab1d0f696625ce13556377578d198b [file] [log] [blame]
/*
* Copyright (c) 2016 Google Inc. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you
* may not use this file except in compliance with the License. You may
* obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
* implied. See the License for the specific language governing
* permissions and limitations under the License.
*/
package com.android.vts.job;
import com.android.vts.entity.DeviceInfoEntity;
import com.android.vts.entity.TestAcknowledgmentEntity;
import com.android.vts.entity.TestCaseRunEntity;
import com.android.vts.entity.TestCaseRunEntity.TestCase;
import com.android.vts.entity.TestRunEntity;
import com.android.vts.entity.TestStatusEntity;
import com.android.vts.entity.TestStatusEntity.TestCaseReference;
import com.android.vts.proto.VtsReportMessage.TestCaseResult;
import com.android.vts.util.DatastoreHelper;
import com.android.vts.util.EmailHelper;
import com.android.vts.util.FilterUtil;
import com.android.vts.util.TimeUtil;
import com.google.appengine.api.datastore.DatastoreFailureException;
import com.google.appengine.api.datastore.DatastoreService;
import com.google.appengine.api.datastore.DatastoreServiceFactory;
import com.google.appengine.api.datastore.DatastoreTimeoutException;
import com.google.appengine.api.datastore.Entity;
import com.google.appengine.api.datastore.EntityNotFoundException;
import com.google.appengine.api.datastore.FetchOptions;
import com.google.appengine.api.datastore.Key;
import com.google.appengine.api.datastore.KeyFactory;
import com.google.appengine.api.datastore.Query;
import com.google.appengine.api.datastore.Query.Filter;
import com.google.appengine.api.datastore.Query.SortDirection;
import com.google.appengine.api.datastore.Transaction;
import com.google.appengine.api.taskqueue.Queue;
import com.google.appengine.api.taskqueue.QueueFactory;
import com.google.appengine.api.taskqueue.TaskOptions;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.ConcurrentModificationException;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.mail.Message;
import javax.mail.MessagingException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.lang.StringUtils;
/** Represents the notifications service which is automatically called on a fixed schedule. */
public class VtsAlertJobServlet extends BaseJobServlet {
private static final String ALERT_JOB_URL = "/task/vts_alert_job";
protected static final Logger logger = Logger.getLogger(VtsAlertJobServlet.class.getName());
protected static final int MAX_RUN_COUNT = 1000; // maximum number of runs to query for
/**
* Process the current test case failures for a test.
*
* @param status The TestStatusEntity object for the test.
* @returns a map from test case name to the test case run ID for which the test case failed.
*/
private static Map<String, TestCase> getCurrentFailures(TestStatusEntity status) {
if (status.getFailingTestCases() == null || status.getFailingTestCases().size() == 0) {
return new HashMap<>();
}
DatastoreService datastore = DatastoreServiceFactory.getDatastoreService();
Map<String, TestCase> failingTestcases = new HashMap<>();
Set<Key> gets = new HashSet<>();
for (TestCaseReference testCaseRef : status.getFailingTestCases()) {
gets.add(KeyFactory.createKey(TestCaseRunEntity.KIND, testCaseRef.parentId));
}
if (gets.size() == 0) {
return failingTestcases;
}
Map<Key, Entity> testCaseMap = datastore.get(gets);
for (TestCaseReference testCaseRef : status.getFailingTestCases()) {
Key key = KeyFactory.createKey(TestCaseRunEntity.KIND, testCaseRef.parentId);
if (!testCaseMap.containsKey(key)) {
continue;
}
Entity testCaseRun = testCaseMap.get(key);
TestCaseRunEntity testCaseRunEntity = TestCaseRunEntity.fromEntity(testCaseRun);
if (testCaseRunEntity.testCases.size() <= testCaseRef.offset) {
continue;
}
TestCase testCase = testCaseRunEntity.testCases.get(testCaseRef.offset);
failingTestcases.put(testCase.name, testCase);
}
return failingTestcases;
}
/**
* Get the test acknowledgments for a test key.
*
* @param testKey The key to the test whose acknowledgments to fetch.
* @return A list of test acknowledgments.
*/
private static List<TestAcknowledgmentEntity> getTestCaseAcknowledgments(Key testKey) {
DatastoreService datastore = DatastoreServiceFactory.getDatastoreService();
List<TestAcknowledgmentEntity> acks = new ArrayList<>();
Filter testFilter =
new Query.FilterPredicate(
TestAcknowledgmentEntity.TEST_KEY, Query.FilterOperator.EQUAL, testKey);
Query q = new Query(TestAcknowledgmentEntity.KIND).setFilter(testFilter);
for (Entity ackEntity : datastore.prepare(q).asIterable()) {
TestAcknowledgmentEntity ack = TestAcknowledgmentEntity.fromEntity(ackEntity);
if (ack == null) continue;
acks.add(ack);
}
return acks;
}
/**
* Get the test runs for the test in the specified time window.
*
* <p>If the start and end time delta is greater than one day, the query will be truncated.
*
* @param testKey The key to the test whose runs to query.
* @param startTime The start time for the query.
* @param endTime The end time for the query.
* @return A list of test runs in the specified time window.
*/
private static List<TestRunEntity> getTestRuns(Key testKey, long startTime, long endTime) {
DatastoreService datastore = DatastoreServiceFactory.getDatastoreService();
Filter testTypeFilter = FilterUtil.getTestTypeFilter(false, true, false);
long delta = endTime - startTime;
delta = Math.min(delta, TimeUnit.DAYS.toMicros(1));
Filter runFilter =
FilterUtil.getTimeFilter(
testKey, TestRunEntity.KIND, endTime - delta + 1, endTime, testTypeFilter);
Query q =
new Query(TestRunEntity.KIND)
.setAncestor(testKey)
.setFilter(runFilter)
.addSort(Entity.KEY_RESERVED_PROPERTY, SortDirection.DESCENDING);
List<TestRunEntity> testRuns = new ArrayList<>();
for (Entity testRunEntity :
datastore.prepare(q).asIterable(FetchOptions.Builder.withLimit(MAX_RUN_COUNT))) {
TestRunEntity testRun = TestRunEntity.fromEntity(testRunEntity);
if (testRun == null) continue;
testRuns.add(testRun);
}
return testRuns;
}
/**
* Separate the test cases which are acknowledged by the provided acknowledgments.
*
* @param testCases The list of test case names.
* @param devices The list of devices for a test run.
* @param acks The list of acknowledgments for the test.
* @return A list of acknowledged test case names that have been removed from the input test
* cases.
*/
public static Set<String> separateAcknowledged(
Set<String> testCases,
List<DeviceInfoEntity> devices,
List<TestAcknowledgmentEntity> acks) {
Set<String> acknowledged = new HashSet<>();
for (TestAcknowledgmentEntity ack : acks) {
boolean allDevices = ack.getDevices() == null || ack.getDevices().size() == 0;
boolean allBranches = ack.getBranches() == null || ack.getBranches().size() == 0;
boolean isRelevant = allDevices && allBranches;
// Determine if the acknowledgment is relevant to the devices.
if (!isRelevant) {
for (DeviceInfoEntity device : devices) {
boolean deviceAcknowledged =
allDevices || ack.getDevices().contains(device.getBuildFlavor());
boolean branchAcknowledged =
allBranches || ack.getBranches().contains(device.getBranch());
if (deviceAcknowledged && branchAcknowledged) isRelevant = true;
}
}
if (isRelevant) {
// Separate the test cases
boolean allTestCases =
ack.getTestCaseNames() == null || ack.getTestCaseNames().size() == 0;
if (allTestCases) {
acknowledged.addAll(testCases);
testCases.removeAll(acknowledged);
} else {
for (String testCase : ack.getTestCaseNames()) {
if (testCases.contains(testCase)) {
acknowledged.add(testCase);
testCases.remove(testCase);
}
}
}
}
}
return acknowledged;
}
/**
* Checks whether any new failures have occurred beginning since (and including) startTime.
*
* @param testRuns The list of test runs for which to update the status.
* @param link The string URL linking to the test's status table.
* @param failedTestCaseMap The map of test case names to TestCase for those failing in the last
* status update.
* @param emailAddresses The list of email addresses to send notifications to.
* @param messages The email Message queue.
* @returns latest TestStatusMessage or null if no update is available.
* @throws IOException
*/
public TestStatusEntity getTestStatus(
List<TestRunEntity> testRuns,
String link,
Map<String, TestCase> failedTestCaseMap,
List<TestAcknowledgmentEntity> testAcks,
List<String> emailAddresses,
List<Message> messages)
throws IOException {
if (testRuns.size() == 0) return null;
DatastoreService datastore = DatastoreServiceFactory.getDatastoreService();
TestRunEntity mostRecentRun = null;
Map<String, TestCaseResult> mostRecentTestCaseResults = new HashMap<>();
Map<String, TestCase> testCaseBreakageMap = new HashMap<>();
int passingTestcaseCount = 0;
List<TestCaseReference> failingTestCases = new ArrayList<>();
Set<String> fixedTestcases = new HashSet<>();
Set<String> newTestcaseFailures = new HashSet<>();
Set<String> continuedTestcaseFailures = new HashSet<>();
Set<String> skippedTestcaseFailures = new HashSet<>();
Set<String> transientTestcaseFailures = new HashSet<>();
for (TestRunEntity testRun : testRuns) {
if (mostRecentRun == null) {
mostRecentRun = testRun;
}
List<Key> testCaseKeys = new ArrayList<>();
for (long testCaseId : testRun.getTestCaseIds()) {
testCaseKeys.add(KeyFactory.createKey(TestCaseRunEntity.KIND, testCaseId));
}
Map<Key, Entity> entityMap = datastore.get(testCaseKeys);
for (Key testCaseKey : testCaseKeys) {
if (!entityMap.containsKey(testCaseKey)) {
logger.log(Level.WARNING, "Test case entity missing: " + testCaseKey);
continue;
}
Entity testCaseRun = entityMap.get(testCaseKey);
TestCaseRunEntity testCaseRunEntity = TestCaseRunEntity.fromEntity(testCaseRun);
if (testCaseRunEntity == null) {
logger.log(Level.WARNING, "Invalid test case run: " + testCaseRun.getKey());
continue;
}
for (TestCase testCase : testCaseRunEntity.testCases) {
String testCaseName = testCase.name;
TestCaseResult result = TestCaseResult.valueOf(testCase.result);
if (mostRecentRun == testRun) {
mostRecentTestCaseResults.put(testCaseName, result);
} else {
if (!mostRecentTestCaseResults.containsKey(testCaseName)) {
// Deprecate notifications for tests that are not present on newer runs
continue;
}
TestCaseResult mostRecentRes = mostRecentTestCaseResults.get(testCaseName);
if (mostRecentRes == TestCaseResult.TEST_CASE_RESULT_SKIP) {
mostRecentTestCaseResults.put(testCaseName, result);
} else if (mostRecentRes == TestCaseResult.TEST_CASE_RESULT_PASS) {
// Test is passing now, witnessed a transient failure
if (result != TestCaseResult.TEST_CASE_RESULT_PASS
&& result != TestCaseResult.TEST_CASE_RESULT_SKIP) {
transientTestcaseFailures.add(testCaseName);
}
}
}
// Record test case breakages
if (result != TestCaseResult.TEST_CASE_RESULT_PASS
&& result != TestCaseResult.TEST_CASE_RESULT_SKIP) {
testCaseBreakageMap.put(testCaseName, testCase);
}
}
}
}
Set<String> buildIdList = new HashSet<>();
List<DeviceInfoEntity> devices = new ArrayList<>();
Query deviceQuery = new Query(DeviceInfoEntity.KIND).setAncestor(mostRecentRun.getKey());
for (Entity device : datastore.prepare(deviceQuery).asIterable()) {
DeviceInfoEntity deviceEntity = DeviceInfoEntity.fromEntity(device);
if (deviceEntity == null) {
continue;
}
buildIdList.add(deviceEntity.getBuildId());
devices.add(deviceEntity);
}
String footer = EmailHelper.getEmailFooter(mostRecentRun, devices, link);
String buildId = StringUtils.join(buildIdList, ",");
for (String testCaseName : mostRecentTestCaseResults.keySet()) {
TestCaseResult mostRecentResult = mostRecentTestCaseResults.get(testCaseName);
boolean previouslyFailed = failedTestCaseMap.containsKey(testCaseName);
if (mostRecentResult == TestCaseResult.TEST_CASE_RESULT_SKIP) {
// persist previous status
if (previouslyFailed) {
skippedTestcaseFailures.add(testCaseName);
failingTestCases.add(
new TestCaseReference(failedTestCaseMap.get(testCaseName)));
} else {
++passingTestcaseCount;
}
} else if (mostRecentResult == TestCaseResult.TEST_CASE_RESULT_PASS) {
++passingTestcaseCount;
if (previouslyFailed && !transientTestcaseFailures.contains(testCaseName)) {
fixedTestcases.add(testCaseName);
}
} else {
if (!previouslyFailed) {
newTestcaseFailures.add(testCaseName);
failingTestCases.add(
new TestCaseReference(testCaseBreakageMap.get(testCaseName)));
} else {
continuedTestcaseFailures.add(testCaseName);
failingTestCases.add(
new TestCaseReference(failedTestCaseMap.get(testCaseName)));
}
}
}
Set<String> acknowledgedFailures =
separateAcknowledged(newTestcaseFailures, devices, testAcks);
acknowledgedFailures.addAll(
separateAcknowledged(transientTestcaseFailures, devices, testAcks));
acknowledgedFailures.addAll(
separateAcknowledged(continuedTestcaseFailures, devices, testAcks));
String summary = new String();
if (newTestcaseFailures.size() + continuedTestcaseFailures.size() > 0) {
summary += "The following test cases failed in the latest test run:<br>";
// Add new test case failures to top of summary in bold font.
List<String> sortedNewTestcaseFailures = new ArrayList<>(newTestcaseFailures);
sortedNewTestcaseFailures.sort(Comparator.naturalOrder());
for (String testcaseName : sortedNewTestcaseFailures) {
summary += "- " + "<b>" + testcaseName + "</b><br>";
}
// Add continued test case failures to summary.
List<String> sortedContinuedTestcaseFailures =
new ArrayList<>(continuedTestcaseFailures);
sortedContinuedTestcaseFailures.sort(Comparator.naturalOrder());
for (String testcaseName : sortedContinuedTestcaseFailures) {
summary += "- " + testcaseName + "<br>";
}
}
if (fixedTestcases.size() > 0) {
// Add fixed test cases to summary.
summary += "<br><br>The following test cases were fixed in the latest test run:<br>";
List<String> sortedFixedTestcases = new ArrayList<>(fixedTestcases);
sortedFixedTestcases.sort(Comparator.naturalOrder());
for (String testcaseName : sortedFixedTestcases) {
summary += "- <i>" + testcaseName + "</i><br>";
}
}
if (transientTestcaseFailures.size() > 0) {
// Add transient test case failures to summary.
summary += "<br><br>The following transient test case failures occured:<br>";
List<String> sortedTransientTestcaseFailures =
new ArrayList<>(transientTestcaseFailures);
sortedTransientTestcaseFailures.sort(Comparator.naturalOrder());
for (String testcaseName : sortedTransientTestcaseFailures) {
summary += "- " + testcaseName + "<br>";
}
}
if (skippedTestcaseFailures.size() > 0) {
// Add skipped test case failures to summary.
summary += "<br><br>The following test cases have not been run since failing:<br>";
List<String> sortedSkippedTestcaseFailures = new ArrayList<>(skippedTestcaseFailures);
sortedSkippedTestcaseFailures.sort(Comparator.naturalOrder());
for (String testcaseName : sortedSkippedTestcaseFailures) {
summary += "- " + testcaseName + "<br>";
}
}
if (acknowledgedFailures.size() > 0) {
// Add acknowledged test case failures to summary.
List<String> sortedAcknowledgedFailures = new ArrayList<>(acknowledgedFailures);
sortedAcknowledgedFailures.sort(Comparator.naturalOrder());
if (acknowledgedFailures.size() > 0) {
summary +=
"<br><br>The following acknowledged test case failures continued to fail:<br>";
for (String testcaseName : sortedAcknowledgedFailures) {
summary += "- " + testcaseName + "<br>";
}
}
}
String testName = mostRecentRun.getKey().getParent().getName();
String uploadDateString = TimeUtil.getDateString(mostRecentRun.getStartTimestamp());
String subject = "VTS Test Alert: " + testName + " @ " + uploadDateString;
if (newTestcaseFailures.size() > 0) {
String body =
"Hello,<br><br>New test case failure(s) in "
+ testName
+ " for device build ID(s): "
+ buildId
+ ".<br><br>"
+ summary
+ footer;
try {
messages.add(EmailHelper.composeEmail(emailAddresses, subject, body));
} catch (MessagingException | UnsupportedEncodingException e) {
logger.log(Level.WARNING, "Error composing email : ", e);
}
} else if (continuedTestcaseFailures.size() > 0) {
String body =
"Hello,<br><br>Continuous test case failure(s) in "
+ testName
+ " for device build ID(s): "
+ buildId
+ ".<br><br>"
+ summary
+ footer;
try {
messages.add(EmailHelper.composeEmail(emailAddresses, subject, body));
} catch (MessagingException | UnsupportedEncodingException e) {
logger.log(Level.WARNING, "Error composing email : ", e);
}
} else if (transientTestcaseFailures.size() > 0) {
String body =
"Hello,<br><br>Transient test case failure(s) in "
+ testName
+ " but tests all "
+ "are passing in the latest device build(s): "
+ buildId
+ ".<br><br>"
+ summary
+ footer;
try {
messages.add(EmailHelper.composeEmail(emailAddresses, subject, body));
} catch (MessagingException | UnsupportedEncodingException e) {
logger.log(Level.WARNING, "Error composing email : ", e);
}
} else if (fixedTestcases.size() > 0) {
String body =
"Hello,<br><br>All test cases passed in "
+ testName
+ " for device build ID(s): "
+ buildId
+ "!<br><br>"
+ summary
+ footer;
try {
messages.add(EmailHelper.composeEmail(emailAddresses, subject, body));
} catch (MessagingException | UnsupportedEncodingException e) {
logger.log(Level.WARNING, "Error composing email : ", e);
}
}
return new TestStatusEntity(
testName,
mostRecentRun.getStartTimestamp(),
passingTestcaseCount,
failingTestCases.size(),
failingTestCases);
}
/**
* Add a task to process test run data
*
* @param testRunKey The key of the test run whose data process.
*/
public static void addTask(Key testRunKey) {
Queue queue = QueueFactory.getDefaultQueue();
String keyString = KeyFactory.keyToString(testRunKey);
queue.add(
TaskOptions.Builder.withUrl(ALERT_JOB_URL)
.param("runKey", keyString)
.method(TaskOptions.Method.POST));
}
@Override
public void doPost(HttpServletRequest request, HttpServletResponse response)
throws IOException {
DatastoreService datastore = DatastoreServiceFactory.getDatastoreService();
String runKeyString = request.getParameter("runKey");
Key testRunKey;
try {
testRunKey = KeyFactory.stringToKey(runKeyString);
} catch (IllegalArgumentException e) {
logger.log(Level.WARNING, "Invalid key specified: " + runKeyString);
return;
}
String testName = testRunKey.getParent().getName();
TestStatusEntity status = null;
Key statusKey = KeyFactory.createKey(TestStatusEntity.KIND, testName);
try {
status = TestStatusEntity.fromEntity(datastore.get(statusKey));
} catch (EntityNotFoundException e) {
// no existing status
}
if (status == null) {
status = new TestStatusEntity(testName);
}
if (status.getUpdatedTimestamp() >= testRunKey.getId()) {
// Another job has already updated the status first
return;
}
List<String> emails = EmailHelper.getSubscriberEmails(testRunKey.getParent());
StringBuffer fullUrl = request.getRequestURL();
String baseUrl = fullUrl.substring(0, fullUrl.indexOf(request.getRequestURI()));
String link =
baseUrl + "/show_tree?testName=" + testName + "&endTime=" + testRunKey.getId();
List<Message> messageQueue = new ArrayList<>();
Map<String, TestCase> failedTestcaseMap = getCurrentFailures(status);
List<TestAcknowledgmentEntity> testAcks =
getTestCaseAcknowledgments(testRunKey.getParent());
List<TestRunEntity> testRuns =
getTestRuns(
testRunKey.getParent(), status.getUpdatedTimestamp(), testRunKey.getId());
if (testRuns.size() == 0) return;
TestStatusEntity newStatus =
getTestStatus(testRuns, link, failedTestcaseMap, testAcks, emails, messageQueue);
if (newStatus == null) {
// No changes to status
return;
}
int retries = 0;
while (true) {
Transaction txn = datastore.beginTransaction();
try {
try {
status = TestStatusEntity.fromEntity(datastore.get(statusKey));
} catch (EntityNotFoundException e) {
// no status left
}
if (status == null
|| status.getUpdatedTimestamp() >= newStatus.getUpdatedTimestamp()) {
txn.rollback();
} else { // This update is most recent.
datastore.put(newStatus.toEntity());
txn.commit();
EmailHelper.sendAll(messageQueue);
}
break;
} catch (ConcurrentModificationException
| DatastoreFailureException
| DatastoreTimeoutException e) {
logger.log(Level.WARNING, "Retrying alert job insert: " + statusKey);
if (retries++ >= DatastoreHelper.MAX_WRITE_RETRIES) {
logger.log(Level.SEVERE, "Exceeded alert job retries: " + statusKey);
throw e;
}
} finally {
if (txn.isActive()) {
txn.rollback();
}
}
}
}
}