-
Notifications
You must be signed in to change notification settings - Fork 0
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
TSPS-140 Send email notifications for succeeded and failed jobs #181
Changes from all commits
8974dda
d35f5fe
8ee74b7
a72e8e1
6a8751c
11ad24c
b40ce69
104e73d
8c708bc
3a24d4b
ae3ea1b
c6b9cba
8a08658
5ffb6e4
3b6f617
392cb7b
f60ac84
ed60cb6
c7fb87b
eff5b8a
e10a1f3
7d35e9b
140e677
61f3525
34815b6
8bb9531
7fd29a3
6e92a56
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
package bio.terra.pipelines.app.configuration.internal; | ||
|
||
import org.springframework.boot.context.properties.ConfigurationProperties; | ||
|
||
@ConfigurationProperties("pipelines.notifications") | ||
public record NotificationConfiguration(String projectId, String topicId) {} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,56 @@ | ||
package bio.terra.pipelines.common.utils; | ||
|
||
import static bio.terra.pipelines.common.utils.FlightUtils.flightMapKeyIsTrue; | ||
|
||
import bio.terra.pipelines.dependencies.stairway.JobMapKeys; | ||
import bio.terra.pipelines.notifications.NotificationService; | ||
import bio.terra.stairway.FlightContext; | ||
import bio.terra.stairway.FlightMap; | ||
import bio.terra.stairway.FlightStatus; | ||
import bio.terra.stairway.HookAction; | ||
import bio.terra.stairway.StairwayHook; | ||
import java.util.UUID; | ||
import org.slf4j.Logger; | ||
import org.slf4j.LoggerFactory; | ||
import org.springframework.stereotype.Component; | ||
|
||
/** | ||
* A {@link StairwayHook} that sends a Job Failed Notification email via pubsub/Thurloe upon flight | ||
* failure. | ||
* | ||
* <p>This hook action will only run if the flight's input parameters contain the JobMapKeys key for | ||
* DO_SEND_JOB_FAILURE_NOTIFICATION_HOOK and the flight's status is not SUCCESS. | ||
* | ||
* <p>The JobMapKeys key for PIPELINE_NAME is required to send the notification. | ||
*/ | ||
@Component | ||
public class StairwaySendFailedJobNotificationHook implements StairwayHook { | ||
private final NotificationService notificationService; | ||
private static final Logger logger = | ||
LoggerFactory.getLogger(StairwaySendFailedJobNotificationHook.class); | ||
|
||
public StairwaySendFailedJobNotificationHook(NotificationService notificationService) { | ||
this.notificationService = notificationService; | ||
} | ||
|
||
@Override | ||
public HookAction endFlight(FlightContext context) { | ||
|
||
FlightMap inputParameters = context.getInputParameters(); | ||
|
||
if (flightMapKeyIsTrue(inputParameters, JobMapKeys.DO_SEND_JOB_FAILURE_NOTIFICATION_HOOK) | ||
&& context.getFlightStatus() != FlightStatus.SUCCESS) { | ||
logger.info( | ||
"Flight has status {}, sending failed job notification email", context.getFlightStatus()); | ||
|
||
FlightUtils.validateRequiredEntries(inputParameters, JobMapKeys.USER_ID); | ||
|
||
UUID jobId = UUID.fromString(context.getFlightId()); | ||
String userId = inputParameters.get(JobMapKeys.USER_ID, String.class); | ||
|
||
// send email notification | ||
notificationService.configureAndSendPipelineRunFailedNotification(jobId, userId, context); | ||
} | ||
return HookAction.CONTINUE; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
package bio.terra.pipelines.notifications; | ||
|
||
import lombok.Getter; | ||
|
||
/** Base class for Teaspoons job notifications. Contains common fields for all job notifications. */ | ||
@Getter | ||
public abstract class BaseTeaspoonsJobNotification { | ||
protected String notificationType; | ||
protected String recipientUserId; | ||
protected String pipelineDisplayName; | ||
protected String jobId; | ||
protected String timeSubmitted; | ||
protected String timeCompleted; | ||
protected String quotaRemaining; | ||
protected String quotaConsumedByJob; | ||
protected String userDescription; | ||
|
||
protected BaseTeaspoonsJobNotification( | ||
String recipientUserId, | ||
String pipelineDisplayName, | ||
String jobId, | ||
String timeSubmitted, | ||
String timeCompleted, | ||
String quotaRemaining, | ||
String userDescription) { | ||
this.recipientUserId = recipientUserId; | ||
this.pipelineDisplayName = pipelineDisplayName; | ||
this.jobId = jobId; | ||
this.timeSubmitted = timeSubmitted; | ||
this.timeCompleted = timeCompleted; | ||
this.quotaRemaining = quotaRemaining; | ||
this.userDescription = userDescription; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,162 @@ | ||
package bio.terra.pipelines.notifications; | ||
|
||
import static bio.terra.pipelines.app.controller.JobApiUtils.buildApiErrorReport; | ||
|
||
import bio.terra.pipelines.app.configuration.internal.NotificationConfiguration; | ||
import bio.terra.pipelines.db.entities.Pipeline; | ||
import bio.terra.pipelines.db.entities.PipelineRun; | ||
import bio.terra.pipelines.db.entities.UserQuota; | ||
import bio.terra.pipelines.generated.model.ApiErrorReport; | ||
import bio.terra.pipelines.service.PipelineRunsService; | ||
import bio.terra.pipelines.service.PipelinesService; | ||
import bio.terra.pipelines.service.QuotasService; | ||
import bio.terra.stairway.FlightContext; | ||
import com.fasterxml.jackson.databind.ObjectMapper; | ||
import java.io.IOException; | ||
import java.time.Instant; | ||
import java.time.ZoneId; | ||
import java.time.format.DateTimeFormatter; | ||
import java.util.Optional; | ||
import java.util.UUID; | ||
import org.slf4j.Logger; | ||
import org.slf4j.LoggerFactory; | ||
import org.springframework.stereotype.Service; | ||
|
||
/** | ||
* Service to encapsulate the logic for composing and sending email notifications to users about | ||
* completed pipeline runs. Works with the Terra Thurloe service via PubSub messages. | ||
*/ | ||
@Service | ||
public class NotificationService { | ||
private static final Logger logger = LoggerFactory.getLogger(NotificationService.class); | ||
|
||
private final PipelineRunsService pipelineRunsService; | ||
private final PipelinesService pipelinesService; | ||
private final QuotasService quotasService; | ||
private final PubsubService pubsubService; | ||
private final NotificationConfiguration notificationConfiguration; | ||
private final ObjectMapper objectMapper; | ||
|
||
public NotificationService( | ||
PipelineRunsService pipelineRunsService, | ||
PipelinesService pipelinesService, | ||
QuotasService quotasService, | ||
PubsubService pubsubService, | ||
NotificationConfiguration notificationConfiguration, | ||
ObjectMapper objectMapper) { | ||
this.pipelineRunsService = pipelineRunsService; | ||
this.pipelinesService = pipelinesService; | ||
this.quotasService = quotasService; | ||
this.pubsubService = pubsubService; | ||
this.notificationConfiguration = notificationConfiguration; | ||
this.objectMapper = objectMapper; | ||
} | ||
|
||
/** | ||
* Pull together the common fields for a notification. | ||
* | ||
* @param jobId the job id | ||
* @param userId the user id | ||
* @param context the flight context (only needed for failed notifications) | ||
* @param isSuccess whether the notification is for a succeeded job; if false, creates a failed | ||
* notification | ||
* @return the base notification object | ||
*/ | ||
private BaseTeaspoonsJobNotification createTeaspoonsJobNotification( | ||
UUID jobId, String userId, FlightContext context, boolean isSuccess) { | ||
PipelineRun pipelineRun = pipelineRunsService.getPipelineRun(jobId, userId); | ||
Pipeline pipeline = pipelinesService.getPipelineById(pipelineRun.getPipelineId()); | ||
String pipelineDisplayName = pipeline.getDisplayName(); | ||
|
||
// if flight fails before quota steps on user's first run, there won't be a row for them yet | ||
// in the quotas table | ||
UserQuota userQuota = | ||
quotasService.getOrCreateQuotaForUserAndPipeline(userId, pipeline.getName()); | ||
String quotaRemaining = String.valueOf(userQuota.getQuota() - userQuota.getQuotaConsumed()); | ||
|
||
if (isSuccess) { // succeeded | ||
return new TeaspoonsJobSucceededNotification( | ||
userId, | ||
pipelineDisplayName, | ||
jobId.toString(), | ||
formatInstantToReadableString(pipelineRun.getCreated()), | ||
formatInstantToReadableString(pipelineRun.getUpdated()), | ||
pipelineRun.getQuotaConsumed().toString(), | ||
quotaRemaining, | ||
pipelineRun.getDescription()); | ||
} else { // failed | ||
// get exception | ||
Optional<Exception> exception = context.getResult().getException(); | ||
String errorMessage; | ||
if (exception.isPresent()) { | ||
ApiErrorReport errorReport = | ||
buildApiErrorReport(exception.get()); // use same logic that the status endpoint uses | ||
errorMessage = errorReport.getMessage(); | ||
} else { | ||
logger.error( | ||
"No exception found in flight result for flight {} with status {}", | ||
context.getFlightId(), | ||
context.getFlightStatus()); | ||
errorMessage = "Unknown error"; | ||
} | ||
return new TeaspoonsJobFailedNotification( | ||
userId, | ||
pipelineDisplayName, | ||
jobId.toString(), | ||
errorMessage, | ||
formatInstantToReadableString(pipelineRun.getCreated()), | ||
formatInstantToReadableString(pipelineRun.getUpdated()), | ||
quotaRemaining, | ||
pipelineRun.getDescription()); | ||
} | ||
} | ||
|
||
/** | ||
* Format an Instant as a date time string in UTC using the RFC-1123 date-time formatter, such as | ||
* 'Tue, 3 Jun 2008 11:05:30 GMT'. | ||
* | ||
* @param dateTime the Instant to format | ||
* @return the formatted date time string | ||
*/ | ||
protected String formatInstantToReadableString(Instant dateTime) { | ||
return dateTime.atZone(ZoneId.of("UTC")).format(DateTimeFormatter.RFC_1123_DATE_TIME); | ||
} | ||
|
||
/** | ||
* Configure and send a notification that a job has succeeded. | ||
* | ||
* @param jobId the job id | ||
* @param userId the user id | ||
*/ | ||
public void configureAndSendPipelineRunSucceededNotification(UUID jobId, String userId) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. it looks liek this function and the failure equivalent are really the ones that should be called outside of this class, can we maek the rest of the functions private? |
||
try { | ||
pubsubService.publishMessage( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. what information does thurloe use in the message to figure out what template to use and what values to put where? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. the |
||
notificationConfiguration.projectId(), | ||
notificationConfiguration.topicId(), | ||
objectMapper.writeValueAsString( | ||
createTeaspoonsJobNotification(jobId, userId, null, true))); | ||
} catch (IOException e) { | ||
logger.error("Error sending pipelineRunSucceeded notification", e); | ||
} | ||
} | ||
|
||
/** | ||
* Configure and send a notification that a job has failed. | ||
* | ||
* @param jobId the job id | ||
* @param userId the user id | ||
* @param context the flight context | ||
*/ | ||
public void configureAndSendPipelineRunFailedNotification( | ||
UUID jobId, String userId, FlightContext context) { | ||
try { | ||
pubsubService.publishMessage( | ||
notificationConfiguration.projectId(), | ||
notificationConfiguration.topicId(), | ||
objectMapper.writeValueAsString( | ||
createTeaspoonsJobNotification(jobId, userId, context, false))); | ||
} catch (IOException e) { | ||
logger.error("Error sending pipelineRunFailed notification", e); | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can we log the flight id as well here
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
chatted with faces, this already has the flight id associated with the log itself