28 private final ScheduledExecutorService executor;
29 private final Clock clock;
30 private final Consumer<CronFailure> failureHandler;
33 this(Executors.newSingleThreadScheduledExecutor(defaultThreadFactory()), Clock.systemDefaultZone(),
34 CronFailure::printToStandardError);
38 this(Executors.newScheduledThreadPool(validateThreads(threads), defaultThreadFactory()), Clock.systemDefaultZone(),
39 CronFailure::printToStandardError);
43 this(executor, Clock.systemDefaultZone(), CronFailure::printToStandardError);
47 final Consumer<CronFailure> failureHandler) {
48 this.executor = Objects.requireNonNull(executor,
"executor");
49 this.clock = Objects.requireNonNull(clock,
"clock");
50 this.failureHandler = Objects.requireNonNull(failureHandler,
"failureHandler");
55 return every(name, Duration.ofSeconds(seconds), job);
60 return every(name, Duration.ofMinutes(minutes), job);
65 return every(name, Duration.ofHours(hours), job);
69 return every(name, interval, interval, job);
72 public ScheduledTask every(
final String name,
final Duration initialDelay,
final Duration interval,
74 final var task =
new CronTask(name, job);
75 validateNonNegative(initialDelay,
"initialDelay");
76 validatePositive(interval,
"interval");
78 final long initialDelayMillis = initialDelay.toMillis();
79 final long delayMillis = interval.toMillis();
80 final ScheduledFuture<?> future = executor.scheduleAtFixedRate(wrap(task), initialDelayMillis, delayMillis,
81 TimeUnit.MILLISECONDS);
82 return new JdkScheduledTask(task.name(), future);
87 final var task =
new CronTask(name, job);
88 Objects.requireNonNull(time,
"time");
90 final var handle =
new ReschedulingTask(task.name());
91 scheduleNextDailyRun(handle, task, time);
95 Duration delayUntilNextDailyRun(
final LocalTime time) {
96 Objects.requireNonNull(time,
"time");
97 final LocalDateTime now = LocalDateTime.now(clock);
98 LocalDateTime next = now.toLocalDate().atTime(time);
99 if (!next.isAfter(now)) {
100 next = next.plusDays(1);
102 return Duration.between(now, next);
112 validateNonNegative(timeout,
"timeout");
113 return executor.awaitTermination(timeout.toMillis(), TimeUnit.MILLISECONDS);
118 executor.shutdownNow();
121 void scheduleNextDailyRun(
final ReschedulingTask handle,
final CronTask task,
final LocalTime time) {
122 if (handle.isCancelled()) {
126 final long delayMillis = millisUntilNextDailyRun(time);
128 final ScheduledFuture<?> future = executor.schedule(() -> {
129 if (handle.isCancelled()) {
133 scheduleNextDailyRun(handle, task, time);
134 }, delayMillis, TimeUnit.MILLISECONDS);
135 handle.replaceFuture(future);
136 }
catch (
final RejectedExecutionException ignored) {
141 private long millisUntilNextDailyRun(
final LocalTime time) {
142 final long millis = delayUntilNextDailyRun(time).toMillis();
143 return Math.max(1L, millis);
146 private Runnable wrap(
final CronTask task) {
150 }
catch (
final Exception e) {
151 handleFailure(task.name(), e);
156 private void handleFailure(
final String taskName,
final Exception cause) {
158 failureHandler.accept(
new CronFailure(taskName, cause));
159 }
catch (
final Exception ignored) {
164 private static ThreadFactory defaultThreadFactory() {
165 final AtomicInteger counter =
new AtomicInteger();
167 final Thread thread =
new Thread(runnable,
"ether-cron-" + counter.incrementAndGet());
168 thread.setDaemon(
true);
173 private static int validateThreads(
final int threads) {
175 throw new IllegalArgumentException(
"threads must be greater than zero");
180 private static void validatePositive(
final Duration duration,
final String name) {
181 Objects.requireNonNull(duration, name);
182 if (duration.isZero() || duration.isNegative()) {
183 throw new IllegalArgumentException(name +
" must be positive");
185 if (duration.toMillis() < 1L) {
186 throw new IllegalArgumentException(name +
" must be at least 1 millisecond");
190 private static void validateNonNegative(
final Duration duration,
final String name) {
191 Objects.requireNonNull(duration, name);
192 if (duration.isNegative()) {
193 throw new IllegalArgumentException(name +
" must not be negative");
195 if (!duration.isZero() && duration.toMillis() < 1L) {
196 throw new IllegalArgumentException(name +
" must be zero or at least 1 millisecond");
203 if (taskName ==
null || taskName.isBlank()) {
204 throw new IllegalArgumentException(
"Task name must not be blank");
206 Objects.requireNonNull(cause,
"cause");
209 static void printToStandardError(
final CronFailure failure) {
210 System.err.println(
"Job failed: " + failure.taskName() +
" - " + failure.cause().getMessage());
214 private record JdkScheduledTask(String name, ScheduledFuture<?> future) implements
ScheduledTask {
217 public boolean cancel() {
218 return cancel(
false);
222 public boolean cancel(
final boolean mayInterruptIfRunning) {
223 return future.
cancel(mayInterruptIfRunning);
227 public boolean isCancelled() {
228 return future.isCancelled();
232 public boolean isDone() {
233 return future.isDone();
237 static final class ReschedulingTask
implements ScheduledTask {
239 private final String name;
240 private final AtomicReference<ScheduledFuture<?>> future =
new AtomicReference<>();
241 private volatile boolean cancelled;
243 ReschedulingTask(
final String name) {
244 this.name = Objects.requireNonNull(name,
"name");
247 void replaceFuture(
final ScheduledFuture<?> nextFuture) {
248 Objects.requireNonNull(nextFuture,
"nextFuture");
250 nextFuture.cancel(
false);
253 future.set(nextFuture);
257 public String
name() {
267 public boolean cancel(
final boolean mayInterruptIfRunning) {
269 final ScheduledFuture<?> current = future.get();
270 return current ==
null || current.cancel(mayInterruptIfRunning);
275 final ScheduledFuture<?> current = future.get();
276 return cancelled || current !=
null && current.isCancelled();
281 final ScheduledFuture<?> current = future.get();
282 return cancelled || current !=
null && current.isDone();