org.kiwiproject.dropwizard.util.job.MonitoredJob Maven / Gradle / Ivy
Show all versions of dropwizard-service-utilities Show documentation
package org.kiwiproject.dropwizard.util.job;
import static java.util.Objects.isNull;
import static java.util.Objects.nonNull;
import static org.kiwiproject.base.KiwiPreconditions.requireNotBlank;
import static org.kiwiproject.base.KiwiPreconditions.requireNotNull;
import static org.kiwiproject.concurrent.Async.doAsync;
import com.google.common.annotations.VisibleForTesting;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.kiwiproject.base.CatchingRunnable;
import org.kiwiproject.base.DefaultEnvironment;
import org.kiwiproject.base.KiwiEnvironment;
import org.kiwiproject.base.KiwiThrowables;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Predicate;
/**
* Sets up a job from a {@link Runnable} that can be monitored through health checks to ensure it is running correctly.
*
*
* MonitoredJob properties
*
*
* Property
* Description
* Required?
* Default
*
*
*
*
* task
* A {@link Runnable} to be executed on a recurring basis
* Yes
* None
*
*
* errorHandler
* A {@link JobErrorHandler} that should be called whenever exceptions are thrown by the task
* No
* A handler that does nothing (a no-op)
*
*
* timeout
* The maximum time a job is allowed to execute before it is terminated
* No
* No timeout
*
*
* name
* A name for the job
* Yes
* None
*
*
* decisionFunction
* A {@link Predicate} that decides whether the task should run at the moment when it is called
* No
* A Predicate that always returns true
*
*
* environment
* An instance of {@link KiwiEnvironment} (mainly useful for unit testing purposes)
* No
* A {@link DefaultEnvironment} instance
*
*
*
*/
@Slf4j
public class MonitoredJob implements CatchingRunnable {
private final Runnable task;
private final JobErrorHandler errorHandler;
private final Duration timeout;
@Getter
private final String name;
@Getter(AccessLevel.PACKAGE)
private final Predicate decisionFunction;
@Getter(AccessLevel.PACKAGE)
private final KiwiEnvironment environment;
/**
* Millis since epoch when job was last successful. Will be zero if job has never run or never succeeded.
*/
@Getter
private final AtomicLong lastSuccess = new AtomicLong();
/**
* Millis since epoch when job last failed. Will be zero if job has never run or never failed.
*/
@Getter
private final AtomicLong lastFailure = new AtomicLong();
/**
* Number of times job has failed. Will be zero if job has never run or never failed.
*/
@Getter
private final AtomicLong failureCount = new AtomicLong();
/**
* Millis since epoch when job was last executed. Will be zero if job has never run.
*/
@Getter
private final AtomicLong lastExecutionTime = new AtomicLong();
/**
* If the last job failure contained an exception, this will contain a {@link JobExceptionInfo}
* instance containing information about it. It intentionally does not store the actual
* Exception instance.
*/
@Getter
private final AtomicReference lastJobExceptionInfo = new AtomicReference<>();
@Builder
private MonitoredJob(Runnable task,
JobErrorHandler errorHandler,
Duration timeout,
String name,
Predicate decisionFunction,
KiwiEnvironment environment) {
this.name = requireNotBlank(name, "name is required");
this.task = requireNotNull(task, "task is required");
this.decisionFunction = isNull(decisionFunction) ? (job -> true) : decisionFunction;
this.errorHandler = isNull(errorHandler) ? JobErrorHandlers.noOpHandler() : errorHandler;
this.timeout = timeout;
this.environment = isNull(environment) ? new DefaultEnvironment() : environment;
}
@Override
@SneakyThrows
public void runSafely() {
if (isActive()) {
LOG.debug("Executing job: {}", name);
var startTime = environment.currentTimeMillis();
if (nonNull(timeout)) {
doAsync(task).get(timeout.toMillis(), TimeUnit.MILLISECONDS);
} else {
task.run();
}
lastExecutionTime.set(environment.currentTimeMillis() - startTime);
LOG.debug("Completed job: {}", name);
} else {
LOG.trace("Not active, skipping job: {}", name);
}
lastSuccess.set(environment.currentTimeMillis());
}
/**
* Checks if the job should be active and execute by delegating to the decision function.
*
* This is useful if the same job runs in separate JVMs but only a single one of the jobs should run at a time.
* For example, suppose there are multiple instances of a service that has a data cleanup job that runs
* occasionally, but you only want one of the active instances to actually run the cleanup job. In this
* situation, you could provide a decision function that uses a
* distributed leader latch to ensure
* only the "leader" service instance runs the job. Or, you could use time-based logic and only return true when
* a certain amount of time has elapsed since the last time a job ran; the last execution time would need to be
* stored in a database if coordination across separate JVMs is required.
*
* @return true if this job should run, false otherwise
*/
public boolean isActive() {
return decisionFunction.test(this);
}
@Override
public void handleExceptionSafely(Exception exception) {
logExceptionInfo(exception, name);
lastFailure.set(environment.currentTimeMillis());
failureCount.incrementAndGet();
var exceptionInfo = JobExceptionInfo.from(exception);
lastJobExceptionInfo.set(exceptionInfo);
if (nonNull(errorHandler)) {
errorHandler.handle(this, exception);
}
}
@VisibleForTesting
static void logExceptionInfo(Exception exception, String name) {
var exceptionType = KiwiThrowables.typeOf(exception);
var rootCause = KiwiThrowables.rootCauseOf(exception).orElse(null);
// If there is no cause, then the root cause is the original Exception.
// Customize the log message to include a root cause only when it has one.
if (rootCause == exception) {
LOG.warn("Encountered {} in job '{}'." +
" Look for the exception and stack trace (probably above this message) logged by CatchingRunnable#runSafely.",
exceptionType, name);
} else {
LOG.warn("Encountered {} in job '{}' with root cause {}." +
" Look for the exception and stack trace (probably above this message) logged by CatchingRunnable#runSafely.",
exceptionType, name, KiwiThrowables.typeOfNullable(rootCause).orElse(null));
}
}
}