Ether Framework
Unified API docs for Ether modules
Loading...
Searching...
No Matches
JdkCronScheduler.java
Go to the documentation of this file.
1/*-
2 * #%L
3 * ether-cron
4 * %%
5 * Copyright (C) 2026 Raúl Eduardo González Argote
6 * #L%
7 */
8
9package dev.rafex.ether.cron;
10
11import java.time.Clock;
12import java.time.Duration;
13import java.time.LocalDateTime;
14import java.time.LocalTime;
15import java.util.Objects;
16import java.util.concurrent.Executors;
17import java.util.concurrent.RejectedExecutionException;
18import java.util.concurrent.ScheduledExecutorService;
19import java.util.concurrent.ScheduledFuture;
20import java.util.concurrent.ThreadFactory;
21import java.util.concurrent.TimeUnit;
22import java.util.concurrent.atomic.AtomicInteger;
23import java.util.concurrent.atomic.AtomicReference;
24import java.util.function.Consumer;
25
26public final class JdkCronScheduler implements SchedulerPort {
27
28 private final ScheduledExecutorService executor;
29 private final Clock clock;
30 private final Consumer<CronFailure> failureHandler;
31
33 this(Executors.newSingleThreadScheduledExecutor(defaultThreadFactory()), Clock.systemDefaultZone(),
34 CronFailure::printToStandardError);
35 }
36
37 public JdkCronScheduler(final int threads) {
38 this(Executors.newScheduledThreadPool(validateThreads(threads), defaultThreadFactory()), Clock.systemDefaultZone(),
39 CronFailure::printToStandardError);
40 }
41
42 public JdkCronScheduler(final ScheduledExecutorService executor) {
43 this(executor, Clock.systemDefaultZone(), CronFailure::printToStandardError);
44 }
45
46 public JdkCronScheduler(final ScheduledExecutorService executor, final Clock clock,
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");
51 }
52
53 @Override
54 public ScheduledTask everySeconds(final String name, final long seconds, final EtherJob job) {
55 return every(name, Duration.ofSeconds(seconds), job);
56 }
57
58 @Override
59 public ScheduledTask everyMinutes(final String name, final long minutes, final EtherJob job) {
60 return every(name, Duration.ofMinutes(minutes), job);
61 }
62
63 @Override
64 public ScheduledTask everyHours(final String name, final long hours, final EtherJob job) {
65 return every(name, Duration.ofHours(hours), job);
66 }
67
68 public ScheduledTask every(final String name, final Duration interval, final EtherJob job) {
69 return every(name, interval, interval, job);
70 }
71
72 public ScheduledTask every(final String name, final Duration initialDelay, final Duration interval,
73 final EtherJob job) {
74 final var task = new CronTask(name, job);
75 validateNonNegative(initialDelay, "initialDelay");
76 validatePositive(interval, "interval");
77
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);
83 }
84
85 @Override
86 public ScheduledTask dailyAt(final String name, final LocalTime time, final EtherJob job) {
87 final var task = new CronTask(name, job);
88 Objects.requireNonNull(time, "time");
89
90 final var handle = new ReschedulingTask(task.name());
91 scheduleNextDailyRun(handle, task, time);
92 return handle;
93 }
94
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);
101 }
102 return Duration.between(now, next);
103 }
104
105 @Override
106 public void close() {
107 executor.shutdown();
108 }
109
110 @Override
111 public boolean awaitTermination(final Duration timeout) throws InterruptedException {
112 validateNonNegative(timeout, "timeout");
113 return executor.awaitTermination(timeout.toMillis(), TimeUnit.MILLISECONDS);
114 }
115
116 @Override
117 public void shutdownNow() {
118 executor.shutdownNow();
119 }
120
121 void scheduleNextDailyRun(final ReschedulingTask handle, final CronTask task, final LocalTime time) {
122 if (handle.isCancelled()) {
123 return;
124 }
125
126 final long delayMillis = millisUntilNextDailyRun(time);
127 try {
128 final ScheduledFuture<?> future = executor.schedule(() -> {
129 if (handle.isCancelled()) {
130 return;
131 }
132 wrap(task).run();
133 scheduleNextDailyRun(handle, task, time);
134 }, delayMillis, TimeUnit.MILLISECONDS);
135 handle.replaceFuture(future);
136 } catch (final RejectedExecutionException ignored) {
137 handle.cancel();
138 }
139 }
140
141 private long millisUntilNextDailyRun(final LocalTime time) {
142 final long millis = delayUntilNextDailyRun(time).toMillis();
143 return Math.max(1L, millis);
144 }
145
146 private Runnable wrap(final CronTask task) {
147 return () -> {
148 try {
149 task.job().run();
150 } catch (final Exception e) {
151 handleFailure(task.name(), e);
152 }
153 };
154 }
155
156 private void handleFailure(final String taskName, final Exception cause) {
157 try {
158 failureHandler.accept(new CronFailure(taskName, cause));
159 } catch (final Exception ignored) {
160 CronFailure.printToStandardError(new CronFailure(taskName, cause));
161 }
162 }
163
164 private static ThreadFactory defaultThreadFactory() {
165 final AtomicInteger counter = new AtomicInteger();
166 return runnable -> {
167 final Thread thread = new Thread(runnable, "ether-cron-" + counter.incrementAndGet());
168 thread.setDaemon(true);
169 return thread;
170 };
171 }
172
173 private static int validateThreads(final int threads) {
174 if (threads < 1) {
175 throw new IllegalArgumentException("threads must be greater than zero");
176 }
177 return threads;
178 }
179
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");
184 }
185 if (duration.toMillis() < 1L) {
186 throw new IllegalArgumentException(name + " must be at least 1 millisecond");
187 }
188 }
189
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");
194 }
195 if (!duration.isZero() && duration.toMillis() < 1L) {
196 throw new IllegalArgumentException(name + " must be zero or at least 1 millisecond");
197 }
198 }
199
200 public record CronFailure(String taskName, Exception cause) {
201
202 public CronFailure {
203 if (taskName == null || taskName.isBlank()) {
204 throw new IllegalArgumentException("Task name must not be blank");
205 }
206 Objects.requireNonNull(cause, "cause");
207 }
208
209 static void printToStandardError(final CronFailure failure) {
210 System.err.println("Job failed: " + failure.taskName() + " - " + failure.cause().getMessage());
211 }
212 }
213
214 private record JdkScheduledTask(String name, ScheduledFuture<?> future) implements ScheduledTask {
215
216 @Override
217 public boolean cancel() {
218 return cancel(false);
219 }
220
221 @Override
222 public boolean cancel(final boolean mayInterruptIfRunning) {
223 return future.cancel(mayInterruptIfRunning);
224 }
225
226 @Override
227 public boolean isCancelled() {
228 return future.isCancelled();
229 }
230
231 @Override
232 public boolean isDone() {
233 return future.isDone();
234 }
235 }
236
237 static final class ReschedulingTask implements ScheduledTask {
238
239 private final String name;
240 private final AtomicReference<ScheduledFuture<?>> future = new AtomicReference<>();
241 private volatile boolean cancelled;
242
243 ReschedulingTask(final String name) {
244 this.name = Objects.requireNonNull(name, "name");
245 }
246
247 void replaceFuture(final ScheduledFuture<?> nextFuture) {
248 Objects.requireNonNull(nextFuture, "nextFuture");
249 if (cancelled) {
250 nextFuture.cancel(false);
251 return;
252 }
253 future.set(nextFuture);
254 }
255
256 @Override
257 public String name() {
258 return name;
259 }
260
261 @Override
262 public boolean cancel() {
263 return cancel(false);
264 }
265
266 @Override
267 public boolean cancel(final boolean mayInterruptIfRunning) {
268 cancelled = true;
269 final ScheduledFuture<?> current = future.get();
270 return current == null || current.cancel(mayInterruptIfRunning);
271 }
272
273 @Override
274 public boolean isCancelled() {
275 final ScheduledFuture<?> current = future.get();
276 return cancelled || current != null && current.isCancelled();
277 }
278
279 @Override
280 public boolean isDone() {
281 final ScheduledFuture<?> current = future.get();
282 return cancelled || current != null && current.isDone();
283 }
284 }
285}
ScheduledTask every(final String name, final Duration interval, final EtherJob job)
ScheduledTask every(final String name, final Duration initialDelay, final Duration interval, final EtherJob job)
JdkCronScheduler(final ScheduledExecutorService executor)
ScheduledTask dailyAt(final String name, final LocalTime time, final EtherJob job)
ScheduledTask everySeconds(final String name, final long seconds, final EtherJob job)
JdkCronScheduler(final ScheduledExecutorService executor, final Clock clock, final Consumer< CronFailure > failureHandler)
boolean awaitTermination(final Duration timeout)
record CronFailure(String taskName, Exception cause)
ScheduledTask everyMinutes(final String name, final long minutes, final EtherJob job)
ScheduledTask everyHours(final String name, final long hours, final EtherJob job)
record CronTask(String name, EtherJob job)
Definition CronTask.java:13