package de.otto.edison.jobs.status;
import de.otto.edison.jobs.definition.JobDefinition;
import de.otto.edison.jobs.domain.JobInfo;
import de.otto.edison.jobs.domain.JobInfo.JobStatus;
import de.otto.edison.jobs.repository.JobRepository;
import de.otto.edison.status.domain.Status;
import de.otto.edison.status.domain.StatusDetail;
import org.slf4j.Logger;
import java.time.OffsetDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static de.otto.edison.status.domain.Link.link;
import static de.otto.edison.status.domain.Status.ERROR;
import static de.otto.edison.status.domain.Status.OK;
import static de.otto.edison.status.domain.Status.WARNING;
import static java.lang.String.format;
import static java.time.OffsetDateTime.now;
import static java.time.format.DateTimeFormatter.ISO_DATE_TIME;
import static java.util.Arrays.asList;
import static org.slf4j.LoggerFactory.getLogger;
/**
* Strategy used to calculate the StatusDetail for the last N executions of a job.
* <p>
* JobStatusCalculators are used to calculate the {@link StatusDetail} for a {@link JobStatusDetailIndicator}
* using the last couple of job executions.
* </p>
* <p>
* Multiple calculators can be configured as a Spring Bean. They are identified by their unique {@link #key} and
* configured in the {@code application.properties} for {@link JobDefinition#jobType() job types} as follows:
* </p>
* <pre><code>
* edison.jobs.status.default=<key of calculator>
* edison.jobs.status.<someJobType>=<key of calculator>
* edison.jobs.status.<otherJobType>=<key of calculator>
* </code></pre>
* <p>
* The JobStatusCalculator can be configured to behave differently, depending on
* how many jobs failed in the last couple of executions:
* </p>
* <ul>
* <li>
* {@code numberOfJobs}: This specifies how many of the last job executions are taken into the calculation.
* </li>
* <li>
* {@code maxFailedJobs}: The maximum number of jobs that are accepted to fail.
* </li>
* </ul>
* Depending on the state of the last job execution and the number of failed jobs during the last {@code numberOfJobs},
* the result of the calculator is as follows:
* <ul>
* <li>
* If the last job execution was {@link JobStatus#OK successful}, the calculator will resolve to
* {@link Status#WARNING}, if more than {@code maxFailedJobs} out of the last {@code numberOfJobs} have
* failed.
* </li>
* <li>
* If the last job execution {@link JobStatus#ERROR failed} for some reason, the calculator will
* resolve to {@link Status#ERROR}, if more than {@code maxFailedJobs} out of the last
* {@code numberOfJobs} have failed. Otherwise, the result of the calculation will be
* {@link Status#WARNING}
* </li>
* </ul>
* If the last job is {@link JobStatus#DEAD}, the resulting status will be {@link Status#WARNING}.
*/
public class JobStatusCalculator {
private static final Logger LOG = getLogger(JobStatusCalculator.class);
private static final String SUCCESS_MESSAGE = "Last job was successful";
private static final String ERROR_MESSAGE = "Job had an error";
private static final String DEAD_MESSAGE = "Job died";
private static final String TOO_MANY_JOBS_FAILED_MESSAGE = "%d out of %d job executions failed";
private static final String JOB_TOO_OLD_MESSAGE = "Job didn't run in the past %s";
private static final String LOAD_JOBS_EXCEPTION_MESSAGE = "Failed to load job status";
private static final String REL_JOB = "http://github.com/otto-de/edison/link-relations/job";
private final String key;
private final int numberOfJobs;
private final int maxFailedJobs;
private final JobRepository jobRepository;
/**
* Creates a JobStatusCalculator.
*
* @param key the key of the calculator.
* @param numberOfJobs the total number of jobs to take into calculation.
* @param maxFailedJobs the maximum number of jobs that that are accepted to fail.
* @param jobRepository repository to fetch the last {@code numberOfJobs}.
*/
public JobStatusCalculator(final String key,
final int numberOfJobs,
final int maxFailedJobs,
final JobRepository jobRepository) {
checkArgument(!key.isEmpty(), "Key must not be empty");
checkArgument(maxFailedJobs <= numberOfJobs, "Parameter maxFailedJobs must not be greater numberOfJobs");
checkArgument(numberOfJobs > 0, "Parameter numberOfJobs must be greater 0");
checkArgument(maxFailedJobs >= 0, "Parameter maxFailedJobs must not be negative");
this.key = key;
this.numberOfJobs = numberOfJobs;
this.maxFailedJobs = maxFailedJobs;
this.jobRepository = jobRepository;
}
/**
* Builds a JobStatusCalculator that is reporting {@link Status#WARNING} if the last job failed.
*
* @param key key of the calculator
* @param jobRepository the repository
* @return JobStatusCalculator
*/
public static JobStatusCalculator warningOnLastJobFailed(final String key, final JobRepository jobRepository) {
return new JobStatusCalculator(
key, 1, 1, jobRepository
);
}
/**
* Builds a JobStatusCalculator that is reporting {@link Status#ERROR} if the last job failed.
*
* @param key key of the calculator
* @param jobRepository the repository
* @return JobStatusCalculator
*/
public static JobStatusCalculator errorOnLastJobFailed(final String key, final JobRepository jobRepository) {
return new JobStatusCalculator(
key, 1, 0, jobRepository
);
}
/**
* Builds a JobStatusCalculator that is reporting {@link Status#ERROR} if the last {@code numJobs} job failed.
*
* @param key key of the calculator
* @param numJobs the number of last jobs used to calculate the job status
* @param jobRepository the repository
* @return JobStatusCalculator
*/
public static JobStatusCalculator errorOnLastNumJobsFailed(final String key, final int numJobs, final JobRepository jobRepository) {
return new JobStatusCalculator(
key, numJobs, numJobs-1, jobRepository
);
}
/**
* The key of the JobStatusCalculator.
* <p>
* Used as a value of the application property {@code edison.jobs.status.calculator.*} to configure the
* calculator for a {@link JobDefinition#jobType() job type}
* </p>
*
* @return key used to select one of several calculators
*/
public String getKey() {
return key;
}
/**
* Returns a StatusDetail for a JobDefinition. The Status of the StatusDetail is calculated using
* the last job executions and depends on the configuration of the calculator.
*
* @param jobDefinition definition of the job to calculate.
* @return StatusDetail of job executions of the {@link JobDefinition#jobType()}
*/
public StatusDetail statusDetail(final JobDefinition jobDefinition) {
try {
final List<JobInfo> jobs = jobRepository.findLatestBy(jobDefinition.jobType(), numberOfJobs);
return jobs.isEmpty()
? statusDetailWhenNoJobAvailable(jobDefinition)
: toStatusDetail(jobs, jobDefinition);
} catch (final Exception e) {
LOG.error(LOAD_JOBS_EXCEPTION_MESSAGE + ": " + e.getMessage());
return StatusDetail.statusDetail(jobDefinition.jobName(), ERROR, LOAD_JOBS_EXCEPTION_MESSAGE);
}
}
private StatusDetail statusDetailWhenNoJobAvailable(final JobDefinition jobDefinition) {
return StatusDetail.statusDetail(jobDefinition.jobName(), Status.OK, SUCCESS_MESSAGE);
}
/**
* Calculates the StatusDetail from the last job executions.
*
* @param jobInfos one or more JobInfo
* @param jobDefinition definition of the last job
* @return StatusDetail to indicate for the given last job
*/
protected StatusDetail toStatusDetail(final List<JobInfo> jobInfos,
final JobDefinition jobDefinition) {
final Status status;
final String message;
final JobInfo lastJob = jobInfos.get(0);
long numFailedJobs = getNumFailedJobs(jobInfos);
switch(lastJob.getStatus()) {
case OK:
case SKIPPED:
if(jobTooOld(lastJob, jobDefinition)) {
status = WARNING;
message = jobAgeMessage(jobDefinition);
} else if (numFailedJobs > maxFailedJobs) {
status = WARNING;
message = format(TOO_MANY_JOBS_FAILED_MESSAGE, numFailedJobs, jobInfos.size());
} else {
status = OK;
message = SUCCESS_MESSAGE;
}
break;
case ERROR:
if (numFailedJobs > maxFailedJobs) {
status = ERROR;
} else {
status = WARNING;
}
if (numberOfJobs == 1 && maxFailedJobs <= 1) {
message = ERROR_MESSAGE;
} else {
message = format(TOO_MANY_JOBS_FAILED_MESSAGE, numFailedJobs, jobInfos.size());
}
break;
case DEAD:
default:
status = WARNING;
message = DEAD_MESSAGE;
}
return StatusDetail.statusDetail(
jobDefinition.jobName(),
status,
message,
asList(
link(REL_JOB, "/internal/jobs/" + lastJob.getJobId(), "Details")
),
runningDetailsFor(lastJob)
);
}
/**
* Returns the number of failed jobs.
*
* @param jobInfos list of job infos
* @return num failed jobs
*/
protected final long getNumFailedJobs(final List<JobInfo> jobInfos) {
return jobInfos
.stream()
.filter(job -> JobStatus.ERROR.equals(job.getStatus()))
.count();
}
/**
* Returns additional information like job uri, running state, started and stopped timestamps.
*
* @param jobInfo the job information of the last job
* @return map containing uri, starting, and running or stopped entries.
*/
protected Map<String, String> runningDetailsFor(final JobInfo jobInfo) {
final Map<String, String> details = new HashMap<>();
details.put("Started", ISO_DATE_TIME.format(jobInfo.getStarted()));
if (jobInfo.getStopped().isPresent()) {
details.put("Stopped", ISO_DATE_TIME.format(jobInfo.getStopped().get()));
}
return details;
}
/**
* Calculates whether or not the last job execution is too old.
*
* @param jobInfo job info of the last job execution
* @param jobDefinition job definition, specifying the max age of jobs
* @return boolean
*/
protected boolean jobTooOld(final JobInfo jobInfo, final JobDefinition jobDefinition) {
final Optional<OffsetDateTime> stopped = jobInfo.getStopped();
if (stopped.isPresent() && jobDefinition.maxAge().isPresent()) {
final OffsetDateTime deadlineToRerun = stopped.get().plus(jobDefinition.maxAge().get());
return deadlineToRerun.isBefore(now());
}
return false;
}
private String jobAgeMessage(JobDefinition jobDefinition) {
return format(JOB_TOO_OLD_MESSAGE, (jobDefinition.maxAge().isPresent() ? jobDefinition.maxAge().get().getSeconds() + " seconds" : "N/A"));
}
private void checkArgument(final boolean expression, final String message) {
if (!expression) {
throw new IllegalArgumentException(message);
}
}
}