From e311eda49c1f7e0c9d81079fac8a710c38a27a7e Mon Sep 17 00:00:00 2001 From: Teletha Date: Mon, 7 Oct 2024 16:26:54 +0900 Subject: [PATCH] feat!: provide virtual scheduler with cron --- src/main/java/kiss/Cron.java | 211 +++++ src/main/java/kiss/Scheduler.java | 365 ++++++++ src/main/java/kiss/Task.java | 77 ++ src/main/java/kiss/Type.java | 84 ++ src/test/java/kiss/CronTest.java | 876 +++++++++++++++++++ src/test/java/kiss/SchedulerTest.java | 203 +++++ src/test/java/kiss/SchedulerTestSupport.java | 300 +++++++ src/test/java/kiss/ShutdownNowTest.java | 110 +++ src/test/java/kiss/ShutdownTest.java | 117 +++ src/test/java/kiss/StressBench.java | 39 + src/test/java/kiss/TestableScheduler.java | 197 +++++ src/test/java/kiss/ThreadLocalTest.java | 31 + 12 files changed, 2610 insertions(+) create mode 100644 src/main/java/kiss/Cron.java create mode 100644 src/main/java/kiss/Scheduler.java create mode 100644 src/main/java/kiss/Task.java create mode 100644 src/main/java/kiss/Type.java create mode 100644 src/test/java/kiss/CronTest.java create mode 100644 src/test/java/kiss/SchedulerTest.java create mode 100644 src/test/java/kiss/SchedulerTestSupport.java create mode 100644 src/test/java/kiss/ShutdownNowTest.java create mode 100644 src/test/java/kiss/ShutdownTest.java create mode 100644 src/test/java/kiss/StressBench.java create mode 100644 src/test/java/kiss/TestableScheduler.java create mode 100644 src/test/java/kiss/ThreadLocalTest.java diff --git a/src/main/java/kiss/Cron.java b/src/main/java/kiss/Cron.java new file mode 100644 index 00000000..027addff --- /dev/null +++ b/src/main/java/kiss/Cron.java @@ -0,0 +1,211 @@ +/* + * Copyright (C) 2024 Nameless Production Committee + * + * Licensed under the MIT License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://opensource.org/licenses/mit-license.php + */ +package kiss; + +import java.time.DayOfWeek; +import java.time.YearMonth; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoField; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Represents a single field in a cron expression. + */ +class Cron { + private static final Pattern FORMAT = Pattern + .compile("(?:(?:(\\*)|(\\?|L)) | ([0-9]{1,2}|[a-z]{3,3})(?:(L|W) | -([0-9]{1,2}|[a-z]{3,3}))?)(?:(/|\\#)([0-9]{1,7}))?", Pattern.CASE_INSENSITIVE | Pattern.COMMENTS); + + final Type type; + + /** + * [0] - start + * [1] - end + * [2] - increment + * [3] - modifier + * [4] - modifierForIncrement + */ + final List parts = new ArrayList(); + + /** + * Constructs a new Field instance based on the given type and expression. + * + * @param type The Type of this field. + * @param expr The expression string for this field. + * @throws IllegalArgumentException if the expression is invalid. + */ + Cron(Type type, String expr) { + this.type = type; + + for (String range : expr.split(",")) { + Matcher m = FORMAT.matcher(range); + if (!m.matches()) error(range); + + String start = m.group(3); + String mod = m.group(4); + String end = m.group(5); + String incmod = m.group(6); + String inc = m.group(7); + + int[] part = {-1, -1, -1, 0, 0}; + if (start != null) { + part[0] = type.map(start); + part[3] = mod == null ? 0 : mod.charAt(0); + if (end != null) { + part[1] = type.map(end); + part[2] = 1; + } else if (inc != null) { + part[1] = type.max; + } else { + part[1] = part[0]; + } + } else if (m.group(1) != null) { + part[0] = type.min; + part[1] = type.max; + part[2] = 1; + } else if (m.group(2) != null) { + part[3] = m.group(2).charAt(0); + } else { + error(range); + } + + if (inc != null) { + part[4] = incmod.charAt(0); + part[2] = Integer.parseInt(inc); + } + + // validate range + if ((part[0] != -1 && part[0] < type.min) || (part[1] != -1 && part[1] > type.max) || (part[0] != -1 && part[1] != -1 && part[0] > part[1])) { + error(range); + } + + // validate part + if (part[3] != 0 && Arrays.binarySearch(type.modifier, part[3]) < 0) { + error(String.valueOf((char) part[3])); + } else if (part[4] != 0 && Arrays.binarySearch(type.increment, part[4]) < 0) { + error(String.valueOf((char) part[4])); + } + parts.add(part); + } + + Collections.sort(parts, (x, y) -> Integer.compare(x[0], y[0])); + } + + /** + * Checks if the given date matches this field's constraints. + * + * @param date The LocalDate to check. + * @return true if the date matches, false otherwise. + */ + boolean matches(ZonedDateTime date) { + for (int[] part : parts) { + if (part[3] == 'L') { + YearMonth ym = YearMonth.of(date.getYear(), date.getMonth().getValue()); + if (type.max == 7) { + return date.getDayOfWeek() == DayOfWeek.of(part[0]) && date.getDayOfMonth() > (ym.lengthOfMonth() - 7); + } else { + return date.getDayOfMonth() == (ym.lengthOfMonth() - (part[0] == -1 ? 0 : part[0])); + } + } else if (part[3] == 'W') { + if (date.getDayOfWeek().getValue() <= 5) { + if (date.getDayOfMonth() == part[0]) { + return true; + } else if (date.getDayOfWeek().getValue() == 5) { + return date.plusDays(1).getDayOfMonth() == part[0]; + } else if (date.getDayOfWeek().getValue() == 1) { + return date.minusDays(1).getDayOfMonth() == part[0]; + } + } + } else if (part[4] == '#') { + if (date.getDayOfWeek() == DayOfWeek.of(part[0])) { + int num = date.getDayOfMonth() / 7; + return part[2] == (date.getDayOfMonth() % 7 == 0 ? num : num + 1); + } + return false; + } else { + int value = date.get(type.field); + if (part[3] == '?' || (part[0] <= value && value <= part[1] && (value - part[0]) % part[2] == 0)) { + return true; + } + } + } + return false; + } + + /** + * Finds the next matching value for this field. + * + * @param date Array containing a single ZonedDateTime to be updated. + * @return true if a match was found, false if the field overflowed. + */ + boolean nextMatch(ZonedDateTime[] date) { + int value = date[0].get(type.field); + + for (int[] part : parts) { + int nextMatch = nextMatch(value, part); + if (nextMatch > -1) { + if (nextMatch != value) { + if (type.field == ChronoField.MONTH_OF_YEAR) { + date[0] = date[0].withMonth(nextMatch).withDayOfMonth(1).truncatedTo(ChronoUnit.DAYS); + } else { + date[0] = date[0].with(type.field, nextMatch).truncatedTo(type.field.getBaseUnit()); + } + } + return true; + } + } + + if (type.field == ChronoField.MONTH_OF_YEAR) { + date[0] = date[0].plusYears(1).withMonth(1).withDayOfMonth(1).truncatedTo(ChronoUnit.DAYS); + } else { + date[0] = date[0].plus(1, type.upper).with(type.field, type.min).truncatedTo(type.field.getBaseUnit()); + } + return false; + } + + /** + * Finds the next matching value within a single Part. + * + * @param value The current value. + * @param part The Part to match against. + * @return The next matching value, or -1 if no match is found. + */ + private int nextMatch(int value, int[] part) { + if (value > part[1]) { + return -1; + } + int nextPotential = Math.max(value, part[0]); + if (part[2] == 1 || nextPotential == part[0]) { + return nextPotential; + } + + int remainder = ((nextPotential - part[0]) % part[2]); + if (remainder != 0) { + nextPotential += part[2] - remainder; + } + + return nextPotential <= part[1] ? nextPotential : -1; + } + + /** + * Throw the invalid format error. + * + * @param cron + * @return + */ + static int error(String cron) { + throw new IllegalArgumentException("Invalid format '" + cron + "'"); + } +} \ No newline at end of file diff --git a/src/main/java/kiss/Scheduler.java b/src/main/java/kiss/Scheduler.java new file mode 100644 index 00000000..e62a1ef3 --- /dev/null +++ b/src/main/java/kiss/Scheduler.java @@ -0,0 +1,365 @@ +/* + * Copyright (C) 2023 Nameless Production Committee + * + * Licensed under the MIT License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://opensource.org/licenses/mit-license.php + */ +package kiss; + +import static java.util.concurrent.Executors.*; + +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.AbstractExecutorService; +import java.util.concurrent.Callable; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.DelayQueue; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.RunnableFuture; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.LongUnaryOperator; + +/** + * A custom scheduler implementation based on the {@link ScheduledExecutorService} interface, + * using virtual threads to schedule tasks with specified delays or intervals. + * + *

+ * This class extends {@link AbstractExecutorService} and implements + * {@link ScheduledExecutorService} to provide scheduling capabilities with a task queue and delay + * mechanisms. It leverages {@link DelayQueue} to manage task execution times, and uses virtual + * threads to run tasks in a lightweight and efficient manner. + *

+ * + *

Core Functionality

+ * + * + *

Thread Management

+ *

+ * Virtual threads are created in an "unstarted" state when tasks are registered. Execution is + * delayed until the scheduled time, reducing memory usage. Once the scheduled time arrives, the + * virtual threads are started and the tasks are executed. If the task is periodic, it is + * rescheduled after completion. + *

+ * + *

Usage

+ *

+ * You can use the following methods to schedule tasks: + *

+ *

+ * + *

Task Lifecycle

+ *

+ * The scheduler maintains internal counters to track running tasks and completed tasks using + * {@link AtomicLong}. The task queue is managed through {@link DelayQueue}, which ensures tasks + * are executed at the correct time. Each task is wrapped in a custom {@link Task} class that + * handles execution, cancellation, and rescheduling (for periodic tasks). + *

+ * + *

Shutdown and Termination

+ *

+ * The scheduler can be shut down using the {@link #shutdown()} or {@link #shutdownNow()} methods, + * which stops the execution of any further tasks. The {@link #awaitTermination(long, TimeUnit)} + * method can be used to block until all tasks are finished executing after a shutdown request. + *

+ * + * @see ScheduledExecutorService + */ +public class Scheduler extends AbstractExecutorService implements ScheduledExecutorService { + + /** The the running task manager. */ + protected final Set runs = ConcurrentHashMap.newKeySet(); + + /** The task queue. */ + protected DelayQueue queue = new DelayQueue(); + + /** The running state of task queue. */ + private volatile boolean run = true; + + public Scheduler() { + Thread.ofVirtual().start(() -> { + try { + while (run || !queue.isEmpty()) { + Task task = queue.take(); + // Task execution state management is performed before thread execution because + // it is too slow if the task execution state management is performed within the + // task's execution thread. + runs.add(task); + + // execute task actually + task.thread.start(); + } + } catch (InterruptedException e) { + // stop + } + }); + } + + /** + * Execute the task. + * + * @param task + */ + protected Task executeTask(Task task) { + if (!run) { + throw new RejectedExecutionException(); + } + + if (!task.isCancelled()) { + // Threads are created when a task is registered, but execution is delayed until the + // scheduled time. Although it would be simpler to immediately schedule the task using + // Thread#sleep after execution, this implementation method is used to reduce memory + // usage as much as possible. Note that only the creation of the thread is done first, + // since the information is not inherited by InheritableThreadLocal if the thread is + // simply placed in the task queue. + task.thread = Thread.ofVirtual().unstarted(() -> { + try { + if (!task.isCancelled()) { + task.run(); + + if (task.interval == null || !run) { + // one shot or scheduler is already stopped + } else { + // reschedule task + task.next = task.interval.applyAsLong(task.next); + executeTask(task); + } + } + } finally { + runs.remove(task); + } + }); + queue.add(task); + } + + return task; + } + + /** + * {@inheritDoc} + */ + @Override + public ScheduledFuture schedule(Runnable command, long delay, TimeUnit unit) { + return schedule(Executors.callable(command), delay, unit); + } + + /** + * {@inheritDoc} + */ + @Override + public ScheduledFuture schedule(Callable command, long delay, TimeUnit unit) { + return executeTask(new Task(command, next(delay, unit), null)); + } + + /** + * {@inheritDoc} + */ + @Override + public ScheduledFuture scheduleAtFixedRate(Runnable command, long delay, long interval, TimeUnit unit) { + return executeTask(new Task(callable(command), next(delay, unit), old -> old + unit.toMillis(interval))); + } + + /** + * {@inheritDoc} + */ + @Override + public ScheduledFuture scheduleWithFixedDelay(Runnable command, long delay, long interval, TimeUnit unit) { + return executeTask(new Task(callable(command), next(delay, unit), old -> System.currentTimeMillis() + unit.toMillis(interval))); + } + + /** + * Schedules a task to be executed periodically based on a cron expression. + *

+ * This method uses a cron expression to determine the execution intervals for the given + * {@code Runnable} command. + * It creates a task that calculates the next execution time using the provided cron format. The + * task is executed at each calculated interval, and the next execution time is determined + * dynamically after each run. + *

+ * + * @param command The {@code Runnable} task to be scheduled for periodic execution. + * @param format A valid cron expression that defines the schedule for task execution. + * The cron format is parsed to calculate the next execution time. + * + * @return A {@code ScheduledFuture} representing the pending completion of the task. + * The {@code ScheduledFuture} can be used to cancel or check the status of the task. + * + * @throws IllegalArgumentException If the cron format is invalid or cannot be parsed correctly. + */ + public ScheduledFuture scheduleAt(Runnable command, String format) { + Cron[] fields = parse(format); + LongUnaryOperator next = old -> next(fields, ZonedDateTime.now()).toInstant().toEpochMilli(); + + return executeTask(new Task(callable(command), next.applyAsLong(0L), next)); + } + + /** + * Parses a cron expression into an array of {@link Cron} objects. + * The cron expression is expected to have 5 or 6 parts: + * - For a standard cron expression with 5 parts (minute, hour, day of month, month, day of + * week), the seconds field will be assumed to be "0". + * - For a cron expression with 6 parts (second, minute, hour, day of month, month, day of + * week), all fields are used directly from the cron expression. + * + * @param cron the cron expression to parse + * @return an array of {@link Cron} objects representing the parsed cron fields. + * @throws IllegalArgumentException if the cron expression does not have 5 or 6 parts + */ + static Cron[] parse(String cron) { + String[] parts = cron.split("\\s+"); + int i = parts.length == 5 ? 0 : parts.length == 6 ? 1 : Cron.error(cron); + + return new Cron[] { // + new Cron(Type.SECOND, i == 1 ? parts[0] : "0"), new Cron(Type.MINUTE, parts[i++]), new Cron(Type.HOUR, parts[i++]), + new Cron(Type.DAY_OF_MONTH, parts[i++]), new Cron(Type.MONTH, parts[i++]), new Cron(Type.DAY_OF_WEEK, parts[i++])}; + } + + /** + * Calculates the next execution time based on the provided cron fields and a base time. + * + * The search for the next execution time will start from the base time and continue until + * a matching time is found. The search will stop if no matching time is found within four + * years. + * + * @param cron an array of {@link Cron} objects representing the parsed cron fields + * @param base the {@link ZonedDateTime} representing the base time to start the search from + * @return the next execution time as a {@link ZonedDateTime} + * @throws IllegalArgumentException if no matching execution time is found within four years + */ + static ZonedDateTime next(Cron[] cron, ZonedDateTime base) { + // The range is four years, taking into account leap years. + ZonedDateTime limit = base.plusYears(4); + + ZonedDateTime[] next = {base.plusSeconds(1).truncatedTo(ChronoUnit.SECONDS)}; + root: while (true) { + if (next[0].isAfter(limit)) throw new IllegalArgumentException("Next time is not found before " + limit); + if (!cron[4].nextMatch(next)) continue; + + int month = next[0].getMonthValue(); + while (!(cron[3].matches(next[0]) && cron[5].matches(next[0]))) { + next[0] = next[0].plusDays(1).truncatedTo(ChronoUnit.DAYS); + if (next[0].getMonthValue() != month) continue root; + } + + if (!cron[2].nextMatch(next)) continue; + if (!cron[1].nextMatch(next)) continue; + if (!cron[0].nextMatch(next)) continue; + return next[0]; + } + } + + /** + * Calculates the next time point by adding the specified delay to the current system time. + * + * This method takes the current system time (in milliseconds) and adds the provided delay, + * which is converted to milliseconds based on the provided {@link TimeUnit}. The result is the + * time point (in milliseconds since the Unix epoch) that corresponds to the current time plus + * the delay. + * + * @param delay the delay to add to the current time + * @param unit the {@link TimeUnit} representing the unit of the delay (e.g., seconds, minutes) + * @return the next time point in milliseconds since the Unix epoch + */ + static long next(long delay, TimeUnit unit) { + return System.currentTimeMillis() + unit.toMillis(delay); + } + + /** + * {@inheritDoc} + */ + @Override + public void shutdown() { + run = false; + } + + /** + * {@inheritDoc} + */ + @Override + public List shutdownNow() { + run = false; + for (Task run : runs) { + run.thread.interrupt(); + } + + DelayQueue temp = queue; + queue = new DelayQueue(); + return new ArrayList(temp); + } + + /** + * {@inheritDoc} + */ + @Override + public boolean awaitTermination(long time, TimeUnit unit) throws InterruptedException { + long end = next(time, unit); + while (!isTerminated()) { + long rem = end - System.currentTimeMillis(); + if (rem < 0) { + return false; + } + Thread.sleep(Math.min(rem + 1, 100)); + } + return true; + } + + /** + * {@inheritDoc} + */ + @Override + public boolean isShutdown() { + return !run; + } + + /** + * {@inheritDoc} + */ + @Override + public boolean isTerminated() { + return !run && queue.isEmpty() && runs.isEmpty(); + } + + /** + * {@inheritDoc} + */ + @Override + public void execute(Runnable command) { + schedule(command, 0, TimeUnit.MILLISECONDS); + } + + /** + * {@inheritDoc} + */ + @Override + protected RunnableFuture newTaskFor(Callable callable) { + return new Task(callable, 0, null); + } + + /** + * {@inheritDoc} + */ + @Override + protected RunnableFuture newTaskFor(Runnable runnable, T value) { + return newTaskFor(Executors.callable(runnable, value)); + } +} \ No newline at end of file diff --git a/src/main/java/kiss/Task.java b/src/main/java/kiss/Task.java new file mode 100644 index 00000000..b99c75c8 --- /dev/null +++ b/src/main/java/kiss/Task.java @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2023 Nameless Production Committee + * + * Licensed under the MIT License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://opensource.org/licenses/mit-license.php + */ +package kiss; + +import java.util.concurrent.Callable; +import java.util.concurrent.Delayed; +import java.util.concurrent.FutureTask; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.function.LongUnaryOperator; + +class Task extends FutureTask implements ScheduledFuture { + + /** The next trigger time. (epoch ms) */ + volatile long next; + + /** The interval calculator. */ + final LongUnaryOperator interval; + + /** The executing thread. */ + Thread thread; + + /** + * Create new task. + * + * @param task + * @param next + * @param interval + */ + Task(Callable task, long next, LongUnaryOperator interval) { + super(task); + + this.next = next; + this.interval = interval; + } + + /** + * {@inheritDoc} + */ + @Override + public void run() { + if (interval == null) { + // one shot + super.run(); + } else { + // periodically + runAndReset(); + } + } + + /** + * {@inheritDoc} + */ + @Override + public long getDelay(TimeUnit unit) { + return unit.convert(next - System.currentTimeMillis(), TimeUnit.MILLISECONDS); + } + + /** + * {@inheritDoc} + */ + @Override + public int compareTo(Delayed other) { + if (other instanceof Task task) { + return Long.compare(next, task.next); + } else { + return 0; + } + } +} \ No newline at end of file diff --git a/src/main/java/kiss/Type.java b/src/main/java/kiss/Type.java new file mode 100644 index 00000000..22ba06eb --- /dev/null +++ b/src/main/java/kiss/Type.java @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2024 Nameless Production Committee + * + * Licensed under the MIT License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://opensource.org/licenses/mit-license.php + */ +package kiss; + +import static java.time.temporal.ChronoUnit.*; + +import java.time.temporal.ChronoField; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; +import java.util.List; + +/** + * Represents a type of cron field (e.g., second, minute, hour, etc.). + */ +class Type { + static final Type SECOND = new Type(ChronoField.SECOND_OF_MINUTE, MINUTES, 0, 59, "", "", "/"); + + static final Type MINUTE = new Type(ChronoField.MINUTE_OF_HOUR, HOURS, 0, 59, "", "", "/"); + + static final Type HOUR = new Type(ChronoField.HOUR_OF_DAY, DAYS, 0, 23, "", "", "/"); + + static final Type DAY_OF_MONTH = new Type(ChronoField.DAY_OF_MONTH, MONTHS, 1, 31, "", "?LW", "/"); + + static final Type MONTH = new Type(ChronoField.MONTH_OF_YEAR, YEARS, 1, 12, "JANFEBMARAPRMAYJUNJULAUGSEPOCTNOVDEC", "", "/"); + + static final Type DAY_OF_WEEK = new Type(ChronoField.DAY_OF_WEEK, null, 1, 7, "MONTUEWEDTHUFRISATSUN", "?L", "#/"); + + final ChronoField field; + + final ChronoUnit upper; + + final int min; + + final int max; + + private final List names; + + final int[] modifier; + + final int[] increment; + + /** + * Constructs a new Type instance. + * + * @param field The ChronoField this type represents. + * @param upper The upper ChronoUnit for this type. + * @param min The minimum allowed value for this type. + * @param max The maximum allowed value for this type. + * @param names List of string names for this type (e.g., month names). + * @param modifier Allowed modifiers for this type. + * @param increment Allowed increment modifiers for this type. + */ + private Type(ChronoField field, ChronoUnit upper, int min, int max, String names, String modifier, String increment) { + this.field = field; + this.upper = upper; + this.min = min; + this.max = max; + this.names = Arrays.asList(names.split("(?<=\\G...)")); // split every three letters + this.modifier = modifier.chars().toArray(); + this.increment = increment.chars().toArray(); + } + + /** + * Maps a string representation to its corresponding numeric value for this field. + * + * @param name The string representation to map. + * @return The corresponding numeric value. + */ + int map(String name) { + int index = names.indexOf(name.toUpperCase()); + if (index != -1) { + return index + min; + } + int value = Integer.parseInt(name); + return value == 0 && field == ChronoField.DAY_OF_WEEK ? 7 : value; + } +} \ No newline at end of file diff --git a/src/test/java/kiss/CronTest.java b/src/test/java/kiss/CronTest.java new file mode 100644 index 00000000..8e32f899 --- /dev/null +++ b/src/test/java/kiss/CronTest.java @@ -0,0 +1,876 @@ +/* + * Copyright (C) 2023 Nameless Production Committee + * + * Licensed under the MIT License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://opensource.org/licenses/mit-license.php + */ +package kiss; + +import static org.junit.jupiter.api.Assertions.*; + +import java.time.YearMonth; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.TimeZone; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import kiss.Cron; +import kiss.Scheduler; + +class CronTest { + + TimeZone original; + + ZoneId zoneId; + + @BeforeEach + public void setup() { + original = TimeZone.getDefault(); + TimeZone.setDefault(TimeZone.getTimeZone("Europe/Oslo")); + zoneId = TimeZone.getDefault().toZoneId(); + } + + @AfterEach + public void cleanup() { + TimeZone.setDefault(original); + } + + private boolean matches(Cron field, int value) { + return field.matches(ZonedDateTime.now().with(field.type.field, value)); + } + + private boolean matcheAll(Cron field, int... values) { + for (int value : values) { + assert matches(field, value); + } + return true; + } + + private boolean unmatcheAll(Cron field, int... values) { + for (int value : values) { + assert matches(field, value) == false; + } + return true; + } + + @Test + public void parseNumber() { + Cron field = new Cron(Type.MINUTE, "5"); + assert matches(field, 5); + assert unmatcheAll(field, 2, 4, 6, 8, 10, 30, 59); + } + + @Test + public void parseNumberWithIncrement() { + Cron field = new Cron(Type.MINUTE, "0/15"); + assert matcheAll(field, 0, 15, 30, 45); + assert unmatcheAll(field, 1, 2, 3, 4, 6, 7, 8, 9, 11, 12, 20, 33, 59); + } + + @Test + public void parseRange() { + Cron field = new Cron(Type.MINUTE, "5-10"); + assert matcheAll(field, 5, 6, 7, 8, 9, 10); + assert unmatcheAll(field, 1, 2, 3, 4, 11, 12, 30, 59); + } + + @Test + public void parseRangeWithIncrement() { + Cron field = new Cron(Type.MINUTE, "20-30/2"); + assert matcheAll(field, 20, 22, 24, 26, 28, 30); + assert unmatcheAll(field, 18, 19, 21, 23, 25, 27, 29, 31, 32, 59); + } + + @Test + public void parseAsterisk() { + Cron field = new Cron(Type.DAY_OF_WEEK, "*"); + assert matcheAll(field, 1, 2, 3, 4, 5, 6, 7); + } + + @Test + public void parseAsteriskWithIncrement() { + Cron field = new Cron(Type.DAY_OF_WEEK, "*/2"); + assert matcheAll(field, 1, 3, 5, 7); + assert unmatcheAll(field, 2, 4, 6); + } + + @Test + public void ignoreFieldInDayOfWeek() { + Cron field = new Cron(Type.DAY_OF_WEEK, "?"); + assert field.matches(ZonedDateTime.now()); + } + + @Test + public void ignoreFieldInDayOfMonth() { + Cron field = new Cron(Type.DAY_OF_MONTH, "?"); + assert field.matches(ZonedDateTime.now()); + } + + @Test + public void giveErrorIfInvalidCountField() { + assertThrows(IllegalArgumentException.class, () -> new Parsed("* 3 *")); + } + + @Test + public void giveErrorIfMinuteFieldIgnored() { + assertThrows(IllegalArgumentException.class, () -> { + new Cron(Type.MINUTE, "?"); + }); + } + + @Test + public void giveErrorIfHourFieldIgnored() { + assertThrows(IllegalArgumentException.class, () -> { + new Cron(Type.HOUR, "?"); + }); + } + + @Test + public void giveErrorIfMonthFieldIgnored() { + assertThrows(IllegalArgumentException.class, () -> { + new Cron(Type.MONTH, "?"); + }); + } + + @Test + public void giveLastDayOfMonthInLeapYear() { + Cron field = new Cron(Type.DAY_OF_MONTH, "L"); + assert field.matches(ZonedDateTime.of(2012, 02, 29, 0, 0, 0, 0, ZoneId.systemDefault())); + } + + @Test + public void giveLastDayOfMonth() { + Cron field = new Cron(Type.DAY_OF_MONTH, "L"); + YearMonth now = YearMonth.now(); + assert field.matches(ZonedDateTime.of(now.getYear(), now.getMonthValue(), now.lengthOfMonth(), 0, 0, 0, 0, ZoneId.systemDefault())); + } + + @Test + public void all() { + Parsed cronExpr = new Parsed("* * * * * *"); + + ZonedDateTime after = ZonedDateTime.of(2012, 4, 10, 13, 0, 1, 0, zoneId); + ZonedDateTime expected = ZonedDateTime.of(2012, 4, 10, 13, 0, 2, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 4, 10, 13, 2, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 4, 10, 13, 2, 1, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 4, 10, 13, 59, 59, 0, zoneId); + expected = ZonedDateTime.of(2012, 4, 10, 14, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + } + + @Test + public void invalidInput() { + assertThrows(NullPointerException.class, () -> new Parsed(null)); + } + + @Test + public void secondNumber() { + Parsed cronExpr = new Parsed("3 * * * * *"); + + ZonedDateTime after = ZonedDateTime.of(2012, 4, 10, 13, 1, 0, 0, zoneId); + ZonedDateTime expected = ZonedDateTime.of(2012, 4, 10, 13, 1, 3, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 4, 10, 13, 1, 3, 0, zoneId); + expected = ZonedDateTime.of(2012, 4, 10, 13, 2, 3, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 4, 10, 13, 59, 3, 0, zoneId); + expected = ZonedDateTime.of(2012, 4, 10, 14, 0, 3, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 4, 10, 23, 59, 3, 0, zoneId); + expected = ZonedDateTime.of(2012, 4, 11, 0, 0, 3, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 4, 30, 23, 59, 3, 0, zoneId); + expected = ZonedDateTime.of(2012, 5, 1, 0, 0, 3, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + } + + @Test + public void secondIncrement() { + Parsed cronExpr = new Parsed("5/15 * * * * *"); + + ZonedDateTime after = ZonedDateTime.of(2012, 4, 10, 13, 0, 0, 0, zoneId); + ZonedDateTime expected = ZonedDateTime.of(2012, 4, 10, 13, 0, 5, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 4, 10, 13, 0, 5, 0, zoneId); + expected = ZonedDateTime.of(2012, 4, 10, 13, 0, 20, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 4, 10, 13, 0, 20, 0, zoneId); + expected = ZonedDateTime.of(2012, 4, 10, 13, 0, 35, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 4, 10, 13, 0, 35, 0, zoneId); + expected = ZonedDateTime.of(2012, 4, 10, 13, 0, 50, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 4, 10, 13, 0, 50, 0, zoneId); + expected = ZonedDateTime.of(2012, 4, 10, 13, 1, 5, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + // if rolling over minute then reset second (cron rules - increment affects only values in + // own field) + after = ZonedDateTime.of(2012, 4, 10, 13, 0, 50, 0, zoneId); + expected = ZonedDateTime.of(2012, 4, 10, 13, 1, 10, 0, zoneId); + assert new Parsed("10/100 * * * * *").next(after).equals(expected); + + after = ZonedDateTime.of(2012, 4, 10, 13, 1, 10, 0, zoneId); + expected = ZonedDateTime.of(2012, 4, 10, 13, 2, 10, 0, zoneId); + assert new Parsed("10/100 * * * * *").next(after).equals(expected); + } + + @Test + public void secondList() { + Parsed cronExpr = new Parsed("7,19 * * * * *"); + + ZonedDateTime after = ZonedDateTime.of(2012, 4, 10, 13, 0, 0, 0, zoneId); + ZonedDateTime expected = ZonedDateTime.of(2012, 4, 10, 13, 0, 7, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 4, 10, 13, 0, 7, 0, zoneId); + expected = ZonedDateTime.of(2012, 4, 10, 13, 0, 19, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 4, 10, 13, 0, 19, 0, zoneId); + expected = ZonedDateTime.of(2012, 4, 10, 13, 1, 7, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + } + + @Test + public void secondRange() { + Parsed cronExpr = new Parsed("42-45 * * * * *"); + + ZonedDateTime after = ZonedDateTime.of(2012, 4, 10, 13, 0, 0, 0, zoneId); + ZonedDateTime expected = ZonedDateTime.of(2012, 4, 10, 13, 0, 42, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 4, 10, 13, 0, 42, 0, zoneId); + expected = ZonedDateTime.of(2012, 4, 10, 13, 0, 43, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 4, 10, 13, 0, 43, 0, zoneId); + expected = ZonedDateTime.of(2012, 4, 10, 13, 0, 44, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 4, 10, 13, 0, 44, 0, zoneId); + expected = ZonedDateTime.of(2012, 4, 10, 13, 0, 45, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 4, 10, 13, 0, 45, 0, zoneId); + expected = ZonedDateTime.of(2012, 4, 10, 13, 1, 42, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + } + + @Test + public void secondInvalidRange() { + assertThrows(IllegalArgumentException.class, () -> new Parsed("42-63 * * * * *")); + } + + @Test + public void secondInvalidIncrementModifier() { + assertThrows(IllegalArgumentException.class, () -> new Parsed("42#3 * * * * *")); + } + + @Test + public void minuteNumber() { + Parsed cronExpr = new Parsed("0 3 * * * *"); + + ZonedDateTime after = ZonedDateTime.of(2012, 4, 10, 13, 1, 0, 0, zoneId); + ZonedDateTime expected = ZonedDateTime.of(2012, 4, 10, 13, 3, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 4, 10, 13, 3, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 4, 10, 14, 3, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + } + + @Test + public void minuteIncrement() { + Parsed cronExpr = new Parsed("0 0/15 * * * *"); + + ZonedDateTime after = ZonedDateTime.of(2012, 4, 10, 13, 0, 0, 0, zoneId); + ZonedDateTime expected = ZonedDateTime.of(2012, 4, 10, 13, 15, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 4, 10, 13, 15, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 4, 10, 13, 30, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 4, 10, 13, 30, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 4, 10, 13, 45, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 4, 10, 13, 45, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 4, 10, 14, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + } + + @Test + public void minuteList() { + Parsed cronExpr = new Parsed("0 7,19 * * * *"); + + ZonedDateTime after = ZonedDateTime.of(2012, 4, 10, 13, 0, 0, 0, zoneId); + ZonedDateTime expected = ZonedDateTime.of(2012, 4, 10, 13, 7, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 4, 10, 13, 7, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 4, 10, 13, 19, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + } + + @Test + public void hourNumber() { + Parsed cronExpr = new Parsed("0 * 3 * * *"); + + ZonedDateTime after = ZonedDateTime.of(2012, 4, 10, 13, 1, 0, 0, zoneId); + ZonedDateTime expected = ZonedDateTime.of(2012, 4, 11, 3, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 4, 11, 3, 0, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 4, 11, 3, 1, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 4, 11, 3, 59, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 4, 12, 3, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + } + + @Test + public void hourIncrement() { + Parsed cronExpr = new Parsed("0 * 0/15 * * *"); + + ZonedDateTime after = ZonedDateTime.of(2012, 4, 10, 13, 0, 0, 0, zoneId); + ZonedDateTime expected = ZonedDateTime.of(2012, 4, 10, 15, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 4, 10, 15, 0, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 4, 10, 15, 1, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 4, 10, 15, 59, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 4, 11, 0, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 4, 11, 0, 0, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 4, 11, 0, 1, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 4, 11, 15, 0, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 4, 11, 15, 1, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + } + + @Test + public void hourList() { + Parsed cronExpr = new Parsed("0 * 7,19 * * *"); + + ZonedDateTime after = ZonedDateTime.of(2012, 4, 10, 13, 0, 0, 0, zoneId); + ZonedDateTime expected = ZonedDateTime.of(2012, 4, 10, 19, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 4, 10, 19, 0, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 4, 10, 19, 1, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 4, 10, 19, 59, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 4, 11, 7, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + } + + @Test + public void hourRun25timesInDST_ChangeToWintertime() { + Parsed cron = new Parsed("0 1 * * * *"); + ZonedDateTime start = ZonedDateTime.of(2011, 10, 30, 0, 0, 0, 0, zoneId); + ZonedDateTime slutt = start.plusDays(1); + ZonedDateTime tid = start; + + // throws: Unsupported unit: Seconds + // assertEquals(25, Duration.between(start.toLocalDate(), slutt.toLocalDate()).toHours()); + + int count = 0; + ZonedDateTime lastTime = tid; + while (tid.isBefore(slutt)) { + ZonedDateTime nextTime = cron.next(tid); + assert nextTime.isAfter(lastTime); + lastTime = nextTime; + tid = tid.plusHours(1); + count++; + } + assertEquals(25, count); + } + + @Test + public void hourRun23timesInDST_ChangeToSummertime() { + Parsed cron = new Parsed("0 0 * * * *"); + ZonedDateTime start = ZonedDateTime.of(2011, 03, 27, 1, 0, 0, 0, zoneId); + ZonedDateTime slutt = start.plusDays(1); + ZonedDateTime tid = start; + + // throws: Unsupported unit: Seconds + // assertEquals(23, Duration.between(start.toLocalDate(), slutt.toLocalDate()).toHours()); + + int count = 0; + ZonedDateTime lastTime = tid; + while (tid.isBefore(slutt)) { + ZonedDateTime nextTime = cron.next(tid); + assert nextTime.isAfter(lastTime); + lastTime = nextTime; + tid = tid.plusHours(1); + count++; + } + assertEquals(23, count); + } + + @Test + public void dayOfMonthNumber() { + Parsed cronExpr = new Parsed("0 * * 3 * *"); + + ZonedDateTime after = ZonedDateTime.of(2012, 4, 10, 13, 0, 0, 0, zoneId); + ZonedDateTime expected = ZonedDateTime.of(2012, 5, 3, 0, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 5, 3, 0, 0, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 5, 3, 0, 1, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 5, 3, 0, 59, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 5, 3, 1, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 5, 3, 23, 59, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 6, 3, 0, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + } + + @Test + public void dayOfMonthIncrement() { + Parsed cronExpr = new Parsed("0 0 0 1/15 * *"); + + ZonedDateTime after = ZonedDateTime.of(2012, 4, 10, 13, 0, 0, 0, zoneId); + ZonedDateTime expected = ZonedDateTime.of(2012, 4, 16, 0, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 4, 16, 0, 0, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 5, 1, 0, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 4, 30, 0, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 5, 1, 0, 0, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 5, 16, 0, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + } + + @Test + public void dayOfMonthList() { + Parsed cronExpr = new Parsed("0 0 0 7,19 * *"); + + ZonedDateTime after = ZonedDateTime.of(2012, 4, 10, 13, 0, 0, 0, zoneId); + ZonedDateTime expected = ZonedDateTime.of(2012, 4, 19, 0, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 4, 19, 0, 0, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 5, 7, 0, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 5, 7, 0, 0, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 5, 19, 0, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 5, 30, 0, 0, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 6, 7, 0, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + } + + @Test + public void dayOfMonthLast() { + Parsed cronExpr = new Parsed("0 0 0 L * *"); + + ZonedDateTime after = ZonedDateTime.of(2012, 4, 10, 13, 0, 0, 0, zoneId); + ZonedDateTime expected = ZonedDateTime.of(2012, 4, 30, 0, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 2, 12, 0, 0, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 2, 29, 0, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + } + + @Test + public void dayOfMonthNumberLast_L() { + Parsed cronExpr = new Parsed("0 0 0 3L * *"); + + ZonedDateTime after = ZonedDateTime.of(2012, 4, 10, 13, 0, 0, 0, zoneId); + ZonedDateTime expected = ZonedDateTime.of(2012, 4, 30 - 3, 0, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 2, 12, 0, 0, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 2, 29 - 3, 0, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + } + + @Test + public void dayOfMonthClosestWeekdayW() { + Parsed cronExpr = new Parsed("0 0 0 9W * *"); + + // 9 - is weekday in may + ZonedDateTime after = ZonedDateTime.of(2012, 5, 2, 0, 0, 0, 0, zoneId); + ZonedDateTime expected = ZonedDateTime.of(2012, 5, 9, 0, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + // 9 - is weekday in may + after = ZonedDateTime.of(2012, 5, 8, 0, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + // 9 - saturday, friday closest weekday in june + after = ZonedDateTime.of(2012, 5, 9, 0, 0, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 6, 8, 0, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + // 9 - sunday, monday closest weekday in september + after = ZonedDateTime.of(2012, 9, 1, 0, 0, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 9, 10, 0, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + } + + @Test + public void dayOfMonthInvalidModifier() { + assertThrows(IllegalArgumentException.class, () -> new Parsed("0 0 0 9X * *")); + } + + @Test + public void dayOfMonthInvalidIncrementModifier() { + assertThrows(IllegalArgumentException.class, () -> new Parsed("0 0 0 9#2 * *")); + } + + @Test + public void monthNumber() { + ZonedDateTime after = ZonedDateTime.of(2012, 2, 12, 0, 0, 0, 0, zoneId); + ZonedDateTime expected = ZonedDateTime.of(2012, 5, 1, 0, 0, 0, 0, zoneId); + assert new Parsed("0 0 0 1 5 *").next(after).equals(expected); + } + + @Test + public void monthIncrement() { + ZonedDateTime after = ZonedDateTime.of(2012, 2, 12, 0, 0, 0, 0, zoneId); + ZonedDateTime expected = ZonedDateTime.of(2012, 5, 1, 0, 0, 0, 0, zoneId); + assert new Parsed("0 0 0 1 5/2 *").next(after).equals(expected); + + after = ZonedDateTime.of(2012, 5, 1, 0, 0, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 7, 1, 0, 0, 0, 0, zoneId); + assert new Parsed("0 0 0 1 5/2 *").next(after).equals(expected); + + // if rolling over year then reset month field (cron rules - increments only affect own + // field) + after = ZonedDateTime.of(2012, 5, 1, 0, 0, 0, 0, zoneId); + expected = ZonedDateTime.of(2013, 5, 1, 0, 0, 0, 0, zoneId); + assert new Parsed("0 0 0 1 5/10 *").next(after).equals(expected); + } + + @Test + public void monthList() { + Parsed cronExpr = new Parsed("0 0 0 1 3,7,12 *"); + + ZonedDateTime after = ZonedDateTime.of(2012, 2, 12, 0, 0, 0, 0, zoneId); + ZonedDateTime expected = ZonedDateTime.of(2012, 3, 1, 0, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 3, 1, 0, 0, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 7, 1, 0, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 7, 1, 0, 0, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 12, 1, 0, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + } + + @Test + public void monthListByName() { + Parsed cronExpr = new Parsed("0 0 0 1 MAR,JUL,DEC *"); + + ZonedDateTime after = ZonedDateTime.of(2012, 2, 12, 0, 0, 0, 0, zoneId); + ZonedDateTime expected = ZonedDateTime.of(2012, 3, 1, 0, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 3, 1, 0, 0, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 7, 1, 0, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 7, 1, 0, 0, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 12, 1, 0, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + } + + @Test + public void monthInvalidModifier() { + assertThrows(IllegalArgumentException.class, () -> new Parsed("0 0 0 1 ? *")); + } + + @Test + public void dowNumber() { + Parsed cronExpr = new Parsed("0 0 0 * * 3"); + + ZonedDateTime after = ZonedDateTime.of(2012, 4, 1, 0, 0, 0, 0, zoneId); + ZonedDateTime expected = ZonedDateTime.of(2012, 4, 4, 0, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 4, 4, 0, 0, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 4, 11, 0, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 4, 12, 0, 0, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 4, 18, 0, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 4, 18, 0, 0, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 4, 25, 0, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + } + + @Test + public void dowIncrement() { + Parsed cronExpr = new Parsed("0 0 0 * * 3/2"); + + ZonedDateTime after = ZonedDateTime.of(2012, 4, 1, 0, 0, 0, 0, zoneId); + ZonedDateTime expected = ZonedDateTime.of(2012, 4, 4, 0, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 4, 4, 0, 0, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 4, 6, 0, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 4, 6, 0, 0, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 4, 8, 0, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 4, 8, 0, 0, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 4, 11, 0, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + } + + @Test + public void dowList() { + Parsed cronExpr = new Parsed("0 0 0 * * 1,5,7"); + + ZonedDateTime after = ZonedDateTime.of(2012, 4, 1, 0, 0, 0, 0, zoneId); + ZonedDateTime expected = ZonedDateTime.of(2012, 4, 2, 0, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 4, 2, 0, 0, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 4, 6, 0, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 4, 6, 0, 0, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 4, 8, 0, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + } + + @Test + public void dowListName() { + Parsed cronExpr = new Parsed("0 0 0 * * MON,FRI,SUN"); + + ZonedDateTime after = ZonedDateTime.of(2012, 4, 1, 0, 0, 0, 0, zoneId); + ZonedDateTime expected = ZonedDateTime.of(2012, 4, 2, 0, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 4, 2, 0, 0, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 4, 6, 0, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 4, 6, 0, 0, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 4, 8, 0, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + } + + @Test + public void dowLastFridayInMonth() { + Parsed cronExpr = new Parsed("0 0 0 * * 5L"); + + ZonedDateTime after = ZonedDateTime.of(2012, 4, 1, 1, 0, 0, 0, zoneId); + ZonedDateTime expected = ZonedDateTime.of(2012, 4, 27, 0, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 4, 27, 0, 0, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 5, 25, 0, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 2, 6, 0, 0, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 2, 24, 0, 0, 0, 0, zoneId); + assertEquals(expected, cronExpr.next(after)); + + after = ZonedDateTime.of(2012, 2, 6, 0, 0, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 2, 24, 0, 0, 0, 0, zoneId); + assert new Parsed("0 0 0 * * FRIL").next(after).equals(expected); + } + + @Test + public void dowInvalidModifier() { + assertThrows(IllegalArgumentException.class, () -> new Parsed("0 0 0 * * 5W")); + } + + @Test + public void dowInvalidIncrementModifier() { + assertThrows(IllegalArgumentException.class, () -> new Parsed("0 0 0 * * 5?3")); + } + + @Test + public void dowInterpret0Sunday() { + ZonedDateTime after = ZonedDateTime.of(2012, 4, 1, 0, 0, 0, 0, zoneId); + ZonedDateTime expected = ZonedDateTime.of(2012, 4, 8, 0, 0, 0, 0, zoneId); + assert new Parsed("0 0 0 * * 0").next(after).equals(expected); + + expected = ZonedDateTime.of(2012, 4, 29, 0, 0, 0, 0, zoneId); + assert new Parsed("0 0 0 * * 0L").next(after).equals(expected); + + expected = ZonedDateTime.of(2012, 4, 8, 0, 0, 0, 0, zoneId); + assert new Parsed("0 0 0 * * 0#2").next(after).equals(expected); + } + + @Test + public void dowInterpret7sunday() { + ZonedDateTime after = ZonedDateTime.of(2012, 4, 1, 0, 0, 0, 0, zoneId); + ZonedDateTime expected = ZonedDateTime.of(2012, 4, 8, 0, 0, 0, 0, zoneId); + assert new Parsed("0 0 0 * * 7").next(after).equals(expected); + + expected = ZonedDateTime.of(2012, 4, 29, 0, 0, 0, 0, zoneId); + assert new Parsed("0 0 0 * * 7L").next(after).equals(expected); + + expected = ZonedDateTime.of(2012, 4, 8, 0, 0, 0, 0, zoneId); + assert new Parsed("0 0 0 * * 7#2").next(after).equals(expected); + } + + @Test + public void dowNthDayInMonth() { + ZonedDateTime after = ZonedDateTime.of(2012, 4, 1, 0, 0, 0, 0, zoneId); + ZonedDateTime expected = ZonedDateTime.of(2012, 4, 20, 0, 0, 0, 0, zoneId); + assert new Parsed("0 0 0 * * 5#3").next(after).equals(expected); + + after = ZonedDateTime.of(2012, 4, 20, 0, 0, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 5, 18, 0, 0, 0, 0, zoneId); + assert new Parsed("0 0 0 * * 5#3").next(after).equals(expected); + + after = ZonedDateTime.of(2012, 3, 30, 0, 0, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 4, 1, 0, 0, 0, 0, zoneId); + assert new Parsed("0 0 0 * * 7#1").next(after).equals(expected); + + after = ZonedDateTime.of(2012, 4, 1, 0, 0, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 5, 6, 0, 0, 0, 0, zoneId); + assert new Parsed("0 0 0 * * 7#1").next(after).equals(expected); + + after = ZonedDateTime.of(2012, 2, 6, 0, 0, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 2, 29, 0, 0, 0, 0, zoneId); + assert new Parsed("0 0 0 * * 3#5").next(after).equals(expected); // leapday + + after = ZonedDateTime.of(2012, 2, 6, 0, 0, 0, 0, zoneId); + expected = ZonedDateTime.of(2012, 2, 29, 0, 0, 0, 0, zoneId); + assert new Parsed("0 0 0 * * WED#5").next(after).equals(expected); // leapday + } + + @Test + public void notSupportRollingPeriod() { + assertThrows(IllegalArgumentException.class, () -> new Parsed("* * 5-1 * * *")); + } + + @Test + public void non_existing_date_throws_exception() { + // Will check for the next 4 years - no 30th of February is found so a IAE is thrown. + assertThrows(IllegalArgumentException.class, () -> new Parsed("* * * 30 2 *").next(ZonedDateTime.now())); + } + + @Test + public void defaultBarrier() { + Parsed cronExpr = new Parsed("* * * 29 2 *"); + + ZonedDateTime after = ZonedDateTime.of(2012, 3, 1, 0, 0, 0, 0, zoneId); + ZonedDateTime expected = ZonedDateTime.of(2016, 2, 29, 0, 0, 0, 0, zoneId); + // the default barrier is 4 years - so leap years are considered. + assertEquals(expected, cronExpr.next(after)); + } + + @Test + public void withoutSeconds() { + ZonedDateTime after = ZonedDateTime.of(2012, 3, 1, 0, 0, 0, 0, zoneId); + ZonedDateTime expected = ZonedDateTime.of(2016, 2, 29, 0, 0, 0, 0, zoneId); + assert new Parsed("* * 29 2 *").next(after).equals(expected); + } + + @Test + public void triggerProblemSameMonth() { + assertEquals(ZonedDateTime.parse("2020-01-02T00:50:00Z"), new Parsed("00 50 * 1-8 1 *") + .next(ZonedDateTime.parse("2020-01-01T23:50:00Z"))); + } + + @Test + public void triggerProblemNextMonth() { + assertEquals(ZonedDateTime.parse("2020-02-01T00:50:00Z"), new Parsed("00 50 * 1-8 2 *") + .next(ZonedDateTime.parse("2020-01-31T23:50:00Z"))); + } + + @Test + public void triggerProblemNextYear() { + assertEquals(ZonedDateTime.parse("2020-01-01T00:50:00Z"), new Parsed("00 50 * 1-8 1 *") + .next(ZonedDateTime.parse("2019-12-31T23:50:00Z"))); + } + + @Test + public void triggerProblemNextMonthMonthAst() { + assertEquals(ZonedDateTime.parse("2020-02-01T00:50:00Z"), new Parsed("00 50 * 1-8 * *") + .next(ZonedDateTime.parse("2020-01-31T23:50:00Z"))); + } + + @Test + public void triggerProblemNextYearMonthAst() { + assertEquals(ZonedDateTime.parse("2020-01-01T00:50:00Z"), new Parsed("00 50 * 1-8 * *") + .next(ZonedDateTime.parse("2019-12-31T23:50:00Z"))); + } + + @Test + public void triggerProblemNextMonthDayAst() { + assertEquals(ZonedDateTime.parse("2020-02-01T00:50:00Z"), new Parsed("00 50 * * 2 *") + .next(ZonedDateTime.parse("2020-01-31T23:50:00Z"))); + } + + @Test + public void triggerProblemNextYearDayAst() { + assertEquals(ZonedDateTime.parse("2020-01-01T00:50:00Z"), new Parsed("00 50 * * 1 *") + .next(ZonedDateTime.parse("2019-12-31T22:50:00Z"))); + } + + @Test + public void triggerProblemNextMonthAllAst() { + assertEquals(ZonedDateTime.parse("2020-02-01T00:50:00Z"), new Parsed("00 50 * * * *") + .next(ZonedDateTime.parse("2020-01-31T23:50:00Z"))); + } + + @Test + public void triggerProblemNextYearAllAst() { + assertEquals(ZonedDateTime.parse("2020-01-01T00:50:00Z"), new Parsed("00 50 * * * *") + .next(ZonedDateTime.parse("2019-12-31T23:50:00Z"))); + } + + private static class Parsed { + Cron[] fields; + + Parsed(String format) { + fields = Scheduler.parse(format); + } + + ZonedDateTime next(ZonedDateTime base) { + return Scheduler.next(fields, base); + } + } +} \ No newline at end of file diff --git a/src/test/java/kiss/SchedulerTest.java b/src/test/java/kiss/SchedulerTest.java new file mode 100644 index 00000000..1c1cc7a0 --- /dev/null +++ b/src/test/java/kiss/SchedulerTest.java @@ -0,0 +1,203 @@ +/* + * Copyright (C) 2023 Nameless Production Committee + * + * Licensed under the MIT License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://opensource.org/licenses/mit-license.php + */ +package kiss; + +import java.util.Arrays; +import java.util.concurrent.Callable; +import java.util.concurrent.Future; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.RepeatedTest; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("resource") +class SchedulerTest extends SchedulerTestSupport { + + @RepeatedTest(MULTIPLICITY) + void execute() { + int[] count = {0}; + scheduler.execute(() -> { + count[0] = 1; + }); + assert scheduler.start().awaitIdling(); + assert count[0] == 1 : Arrays.toString(count) + " " + count[0] + scheduler; + } + + @RepeatedTest(MULTIPLICITY) + void submitCallable() { + Verifier verifier = new Verifier("OK"); + Future future = scheduler.submit((Callable) verifier); + assert verifyRunning(future); + assert scheduler.start().awaitIdling(); + assert verifySuccessed(future, "OK"); + assert verifier.verifyExecutionCount(1); + } + + @RepeatedTest(MULTIPLICITY) + void submitCallableCancel() { + Verifier verifier = new Verifier("OK"); + Future future = scheduler.submit((Callable) verifier); + future.cancel(false); + assert scheduler.start().awaitIdling(); + assert verifyCanceled(future); + assert verifier.verifyExecutionCount(0); + } + + @RepeatedTest(MULTIPLICITY) + void submitRunnable() { + int[] count = {0}; + Future future = scheduler.submit((Runnable) () -> count[0]++); + assert verifyRunning(future); + assert scheduler.start().awaitIdling(); + assert verifySuccessed(future, null); + assert count[0] == 1; + } + + @RepeatedTest(MULTIPLICITY) + void submitRunnableCancle() { + int[] count = {0}; + Future future = scheduler.submit((Runnable) () -> count[0]++); + future.cancel(false); + assert scheduler.start().awaitIdling(); + assert verifyCanceled(future); + } + + @RepeatedTest(MULTIPLICITY) + void schedule() { + Verifier verifier = new Verifier("OK"); + ScheduledFuture future = scheduler.schedule((Callable) verifier, 50, TimeUnit.MILLISECONDS); + assert verifyRunning(future); + assert scheduler.start().awaitIdling(); + assert verifySuccessed(future, "OK"); + assert verifier.verifyInitialDelay(50); + assert verifier.verifyExecutionCount(1); + } + + @RepeatedTest(MULTIPLICITY) + void scheduleMultiSameDelay() { + Verifier verifier1 = new Verifier("1"); + Verifier verifier2 = new Verifier("2"); + Verifier verifier3 = new Verifier("3"); + ScheduledFuture future1 = scheduler.schedule((Callable) verifier1, 50, TimeUnit.MILLISECONDS); + ScheduledFuture future2 = scheduler.schedule((Callable) verifier2, 50, TimeUnit.MILLISECONDS); + ScheduledFuture future3 = scheduler.schedule((Callable) verifier3, 50, TimeUnit.MILLISECONDS); + assert verifyRunning(future1, future2, future3); + assert scheduler.start().awaitIdling(); + assert verifySuccessed(future1, "1"); + assert verifySuccessed(future2, "2"); + assert verifySuccessed(future3, "3"); + assert verifier1.verifyInitialDelay(50); + assert verifier1.verifyExecutionCount(1); + assert verifier2.verifyInitialDelay(50); + assert verifier2.verifyExecutionCount(1); + assert verifier3.verifyInitialDelay(50); + assert verifier3.verifyExecutionCount(1); + } + + @RepeatedTest(MULTIPLICITY) + void scheduleMultiDifferentDelay() { + Verifier verifier1 = new Verifier(); + Verifier verifier2 = new Verifier(); + Verifier verifier3 = new Verifier(); + ScheduledFuture future1 = scheduler.schedule((Callable) verifier1, 500, TimeUnit.MILLISECONDS); + ScheduledFuture future2 = scheduler.schedule((Callable) verifier2, 250, TimeUnit.MILLISECONDS); + ScheduledFuture future3 = scheduler.schedule((Callable) verifier3, 10, TimeUnit.MILLISECONDS); + assert verifyRunning(future1, future2, future3); + assert scheduler.start().awaitIdling(); + assert verifySuccessed(future1, future2, future3); + assert verifyExecutionOrder(verifier3, verifier2, verifier1); + } + + @RepeatedTest(MULTIPLICITY) + void scheduleCancel() { + Verifier verifier = new Verifier("OK"); + ScheduledFuture future = scheduler.schedule((Callable) verifier, 50, TimeUnit.MILLISECONDS); + future.cancel(false); + assert scheduler.start().awaitIdling(); + assert verifyCanceled(future); + assert verifier.verifyExecutionCount(0); + } + + @RepeatedTest(MULTIPLICITY) + void scheduleTaskAfterCancel() { + Verifier verifier = new Verifier("OK"); + ScheduledFuture future = scheduler.schedule((Callable) verifier, 50, TimeUnit.MILLISECONDS); + future.cancel(false); + assert scheduler.start().awaitIdling(); + assert verifyCanceled(future); + assert verifier.verifyExecutionCount(0); + + // reschedule + ScheduledFuture reFuture = scheduler.schedule((Callable) verifier, 50, TimeUnit.MILLISECONDS); + assert verifyRunning(reFuture); + assert scheduler.awaitIdling(); + assert verifySuccessed(reFuture, "OK"); + assert verifier.verifyExecutionCount(1); + } + + @RepeatedTest(MULTIPLICITY) + void fixedRate() { + Verifier verifier = new Verifier(); + ScheduledFuture future = scheduler.scheduleAtFixedRate(verifier, 0, 50, TimeUnit.MILLISECONDS); + + assert verifyRunning(future); + assert scheduler.start().awaitExecutions(3); + assert verifier.verifyExecutionCount(3); + assert verifier.verifyRate(0, 30, 30); + } + + @RepeatedTest(MULTIPLICITY) + void fixedDelay() { + Verifier verifier = new Verifier(); + ScheduledFuture future = scheduler.scheduleWithFixedDelay(verifier, 0, 50, TimeUnit.MILLISECONDS); + + assert verifyRunning(future); + assert scheduler.start().awaitExecutions(3); + assert verifier.verifyExecutionCount(3); + assert verifier.verifyInterval(0, 50, 50); + } + + @Test + void cron() { + scheduler.limitAwaitTime(5000); + + Verifier verifier = new Verifier(); + ScheduledFuture future = scheduler.scheduleAt(verifier, "* * * * * *"); + + assert verifyRunning(future); + assert scheduler.start().awaitExecutions(3); + assert verifier.verifyExecutionCount(3); + assert verifier.verifyRate(0, 1000, 1000); + } + + @Test + void cronStep() { + scheduler.limitAwaitTime(5000); + + Verifier verifier = new Verifier(); + ScheduledFuture future = scheduler.scheduleAt(verifier, "*/2 * * * * *"); + + assert verifyRunning(future); + assert scheduler.start().awaitExecutions(2); + assert verifier.verifyExecutionCount(2); + assert verifier.verifyInterval(0, 2000); + } + + @RepeatedTest(MULTIPLICITY) + void handleExceptionDuringTask() { + Verifier verifier = new Verifier(new Error("Fail")); + ScheduledFuture future = scheduler.schedule((Callable) verifier, 50, TimeUnit.MILLISECONDS); + assert verifyRunning(future); + assert scheduler.start().awaitIdling(); + assert verifyFailed(future); + assert verifier.verifyExecutionCount(1); + } +} \ No newline at end of file diff --git a/src/test/java/kiss/SchedulerTestSupport.java b/src/test/java/kiss/SchedulerTestSupport.java new file mode 100644 index 00000000..9b7e063b --- /dev/null +++ b/src/test/java/kiss/SchedulerTestSupport.java @@ -0,0 +1,300 @@ +/* + * Copyright (C) 2023 Nameless Production Committee + * + * Licensed under the MIT License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://opensource.org/licenses/mit-license.php + */ +package kiss; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.Callable; +import java.util.concurrent.CancellationException; +import java.util.concurrent.Future; +import java.util.concurrent.Future.State; +import java.util.function.Supplier; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; + +import kiss.I; +import kiss.WiseSupplier; + +public class SchedulerTestSupport { + + protected static final int MULTIPLICITY = 5; + + private static final long TOLERANCE = 15; + + protected TestableScheduler scheduler; + + @BeforeEach + void setUp() { + scheduler = new TestableScheduler(); + + assert !Thread.currentThread().isVirtual(); + } + + @AfterEach + void tearDown() throws InterruptedException { + scheduler.shutdown(); + } + + /** + * Verify the given {@link Future} is running. + * + * @param futures + * @return + */ + protected boolean verifyRunning(Future... futures) { + for (Future future : futures) { + assert future.isCancelled() == false; + assert future.isDone() == false; + assert future.state() == State.RUNNING; + } + return true; + } + + /** + * Verify the given {@link Future} is canceled. + * + * @param futures + * @return + */ + protected boolean verifyCanceled(Future... futures) { + for (Future future : futures) { + assert future.isCancelled() == true; + assert future.isDone() == true; + assert future.state() == State.CANCELLED; + assertThrows(CancellationException.class, () -> future.get()); + } + return true; + } + + /** + * Verify the given {@link Future} is canceled. + * + * @param futures + * @return + */ + protected boolean verifyFailed(Future... futures) { + for (Future future : futures) { + assert future.isCancelled() == false; + assert future.isDone() == true; + assert future.state() == State.FAILED; + } + return true; + } + + /** + * Verify the given {@link Future} is canceled. + * + * @param futures + * @return + */ + protected boolean verifySuccessed(Future... futures) { + for (Future future : futures) { + assert future.isCancelled() == false; + assert future.isDone() == true; + assert future.state() == State.SUCCESS; + } + return true; + } + + /** + * Verify the given {@link Future} is canceled. + * + * @param future + * @return + */ + protected boolean verifySuccessed(Future future, T result) { + try { + assert future.isCancelled() == false; + assert future.isDone() == true; + assert future.state() == State.SUCCESS; + assert Objects.equals(future.get(), result); + return true; + } catch (Exception e) { + return false; + } + } + + /** + * Verify that they are executed in the specified order. + * + * @return + */ + protected boolean verifyExecutionOrder(Verifier... verifiers) { + assert verifyStartExecutionOrder(verifiers); + assert verifyEndExecutionOrder(verifiers); + return true; + } + + /** + * Verify that they are executed in the specified order. + * + * @return + */ + protected boolean verifyStartExecutionOrder(Verifier... verifiers) { + for (int i = 1; i < verifiers.length; i++) { + assert verifiers[i - 1].startTime.getFirst() <= verifiers[i].startTime.getFirst(); + } + return true; + } + + /** + * Verify that they are executed in the specified order. + * + * @return + */ + protected boolean verifyEndExecutionOrder(Verifier... verifiers) { + for (int i = 1; i < verifiers.length; i++) { + assert verifiers[i - 1].endTime.getFirst() <= verifiers[i].endTime.getFirst(); + } + return true; + } + + /** + * Verifiable {@link Callable} implementation. + */ + protected class Verifier implements Callable, Runnable { + + private final long created = System.currentTimeMillis(); + + private final List startTime = new ArrayList(); + + private final List endTime = new ArrayList(); + + private final Supplier expectedResult; + + private final Throwable expectedError; + + public Verifier() { + this((T) "Success"); + } + + public Verifier(T expectedResult) { + this.expectedResult = () -> expectedResult; + this.expectedError = null; + } + + public Verifier(WiseSupplier expectedResult) { + this.expectedResult = expectedResult; + this.expectedError = null; + } + + public Verifier(Throwable error) { + this.expectedResult = null; + this.expectedError = error; + } + + public Runnable asRunnable() { + return this; + } + + public Callable asCallable() { + return this; + } + + /** + * {@inheritDoc} + */ + @Override + public T call() throws Exception { + startTime.add(System.currentTimeMillis()); + try { + if (expectedError != null) { + throw I.quiet(expectedError); + } else { + return expectedResult.get(); + } + } finally { + endTime.add(System.currentTimeMillis()); + } + } + + /** + * {@inheritDoc} + */ + @Override + public void run() { + try { + call(); + } catch (Exception e) { + throw I.quiet(e); + } + } + + /** + * Verify the initial delay. + */ + protected boolean verifyInitialDelay(long millis) { + assert !startTime.isEmpty(); + assert millis - TOLERANCE <= startTime.get(0) - created; + + return true; + } + + /** + * Verify rate. + */ + protected boolean verifyRate(long... millis) { + assert startTime.size() == millis.length; + for (int i = 0; i < millis.length; i++) { + long diff = startTime.get(i) - (i == 0 ? created : startTime.get(i - 1)); + assert millis[i] - TOLERANCE <= diff : diff; + } + return true; + } + + /** + * Verify interval. + */ + protected boolean verifyInterval(long... millis) { + assert startTime.size() == millis.length; + for (int i = 0; i < millis.length; i++) { + long diff = startTime.get(i) - (i == 0 ? created : endTime.get(i - 1)); + assert millis[i] - TOLERANCE <= diff : diff; + } + return true; + } + + /** + * Verify the execution count. + */ + protected boolean verifyExecutionCount(long beforeAndAfter) { + return verifyExecutionCount(beforeAndAfter, beforeAndAfter); + } + + /** + * Verify the execution count. + */ + protected boolean verifyExecutionCount(long before, long after) { + assert startTime.size() == before; + assert endTime.size() == after; + return true; + } + + /** + * Verify the execution count. + */ + protected boolean verifyBeforeExecutionCount(long expected) { + assert startTime.size() == expected; + return true; + } + + /** + * Verify the execution count. + */ + protected boolean verifyAfterExecutionCount(long expected) { + assert endTime.size() == expected; + return true; + } + } +} diff --git a/src/test/java/kiss/ShutdownNowTest.java b/src/test/java/kiss/ShutdownNowTest.java new file mode 100644 index 00000000..5fd94c74 --- /dev/null +++ b/src/test/java/kiss/ShutdownNowTest.java @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2024 Nameless Production Committee + * + * Licensed under the MIT License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://opensource.org/licenses/mit-license.php + */ +package kiss; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; +import java.util.concurrent.Future; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.RepeatedTest; + +@SuppressWarnings("resource") +public class ShutdownNowTest extends SchedulerTestSupport { + + @RepeatedTest(MULTIPLICITY) + void rejectNewTask() { + assert scheduler.isShutdown() == false; + assert scheduler.isTerminated() == false; + + List remains = scheduler.start().shutdownNow(); + assert scheduler.isShutdown(); + assert scheduler.isTerminated(); + assert remains.isEmpty(); + + assertThrows(RejectedExecutionException.class, () -> scheduler.execute(new Verifier())); + assertThrows(RejectedExecutionException.class, () -> scheduler.submit(new Verifier().asCallable())); + assertThrows(RejectedExecutionException.class, () -> scheduler.submit(new Verifier().asRunnable())); + assertThrows(RejectedExecutionException.class, () -> scheduler.schedule(new Verifier().asRunnable(), 10, TimeUnit.SECONDS)); + assertThrows(RejectedExecutionException.class, () -> scheduler.schedule(new Verifier().asCallable(), 10, TimeUnit.SECONDS)); + assertThrows(RejectedExecutionException.class, () -> scheduler.scheduleAtFixedRate(new Verifier(), 10, 10, TimeUnit.SECONDS)); + assertThrows(RejectedExecutionException.class, () -> scheduler.scheduleAtFixedRate(new Verifier(), 10, 10, TimeUnit.SECONDS)); + assertThrows(RejectedExecutionException.class, () -> scheduler.scheduleAt(new Verifier(), "* * * * *")); + } + + @RepeatedTest(MULTIPLICITY) + void processExecutingTask() { + Verifier verifier = new Verifier(() -> { + try { + Thread.sleep(250); + return "Long Task"; + } catch (InterruptedException e) { + return "Stop"; + } + }); + + Future future = scheduler.submit(verifier.asCallable()); + assert scheduler.start().awaitRunning(); + + List remains = scheduler.shutdownNow(); + assert remains.isEmpty(); + assert scheduler.isShutdown(); + // Although the running task is interrupted and starts moving, the checking #isTerminated + // is not performed because it is still uncertain at this moment whether the task is running + // to the end or not. + // assert scheduler.isTerminated() == false; + + assert scheduler.awaitIdling(); + assert scheduler.isTerminated(); + assert verifySuccessed(future, "Stop"); + } + + @RepeatedTest(MULTIPLICITY) + void processQueuedTask() { + Verifier verifier = new Verifier("Queued"); + + Future future = scheduler.schedule(verifier.asCallable(), 250, TimeUnit.MILLISECONDS); + List remains = scheduler.start().shutdownNow(); + assert scheduler.isShutdown(); + assert scheduler.isTerminated(); + assert remains.size() == 1; + assert remains.get(0) == future; + + assert scheduler.isTerminated(); + assert verifyRunning(future); + } + + @RepeatedTest(MULTIPLICITY) + void awaitTermination() throws InterruptedException { + Verifier verifier = new Verifier(() -> { + try { + Thread.sleep(150); + return "Long Task"; + } catch (InterruptedException e) { + Thread.sleep(100); + return "Long Stop"; + } + }); + + Future future = scheduler.submit(verifier.asCallable()); + assert scheduler.start().awaitRunning(); + + List remains = scheduler.shutdownNow(); + assert scheduler.isShutdown(); + assert scheduler.isTerminated() == false; + assert remains.isEmpty(); + + assert scheduler.awaitTermination(300, TimeUnit.MILLISECONDS); + assert scheduler.isTerminated(); + assert verifySuccessed(future); + } +} diff --git a/src/test/java/kiss/ShutdownTest.java b/src/test/java/kiss/ShutdownTest.java new file mode 100644 index 00000000..6ea72e6f --- /dev/null +++ b/src/test/java/kiss/ShutdownTest.java @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2024 Nameless Production Committee + * + * Licensed under the MIT License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://opensource.org/licenses/mit-license.php + */ +package kiss; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.concurrent.Future; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.RepeatedTest; + +@SuppressWarnings("resource") +public class ShutdownTest extends SchedulerTestSupport { + + @RepeatedTest(MULTIPLICITY) + void rejectNewTask() { + assert scheduler.isShutdown() == false; + assert scheduler.isTerminated() == false; + + scheduler.start().shutdown(); + assert scheduler.isShutdown(); + assert scheduler.isTerminated(); + + assertThrows(RejectedExecutionException.class, () -> scheduler.execute(new Verifier())); + assertThrows(RejectedExecutionException.class, () -> scheduler.submit(new Verifier().asCallable())); + assertThrows(RejectedExecutionException.class, () -> scheduler.submit(new Verifier().asRunnable())); + assertThrows(RejectedExecutionException.class, () -> scheduler.schedule(new Verifier().asRunnable(), 10, TimeUnit.SECONDS)); + assertThrows(RejectedExecutionException.class, () -> scheduler.schedule(new Verifier().asCallable(), 10, TimeUnit.SECONDS)); + assertThrows(RejectedExecutionException.class, () -> scheduler.scheduleAtFixedRate(new Verifier(), 10, 10, TimeUnit.SECONDS)); + assertThrows(RejectedExecutionException.class, () -> scheduler.scheduleAtFixedRate(new Verifier(), 10, 10, TimeUnit.SECONDS)); + assertThrows(RejectedExecutionException.class, () -> scheduler.scheduleAt(new Verifier(), "* * * * *")); + } + + @RepeatedTest(MULTIPLICITY) + void processExecutingTask() { + Verifier verifier = new Verifier(() -> { + try { + Thread.sleep(250); + return "Long Task"; + } catch (InterruptedException e) { + return "Stop"; + } + }); + + Future future = scheduler.submit(verifier.asCallable()); + scheduler.start().shutdown(); + assert scheduler.isShutdown(); + // The result of isTerminated is undefined here because it is not necessarily retrieved from + // the queue at this time, although the queued task will certainly be executed in the + // future. + // assert scheduler.isTerminated() == false; + + assert scheduler.awaitIdling(); + assert scheduler.isTerminated(); + assert verifySuccessed(future, "Long Task"); + } + + @RepeatedTest(MULTIPLICITY) + void processQueuedTask() { + Verifier verifier = new Verifier("Queued"); + + Future future = scheduler.schedule(verifier.asCallable(), 150, TimeUnit.MILLISECONDS); + scheduler.start().shutdown(); + assert scheduler.isShutdown(); + assert scheduler.isTerminated() == false; + + assert scheduler.awaitIdling(); + assert scheduler.isTerminated(); + assert verifySuccessed(future); + } + + @RepeatedTest(MULTIPLICITY) + void awaitTermination() throws InterruptedException { + Verifier verifier = new Verifier("Queued"); + + Future future = scheduler.schedule(verifier.asCallable(), 150, TimeUnit.MILLISECONDS); + scheduler.start().shutdown(); + assert scheduler.isShutdown(); + assert scheduler.isTerminated() == false; + + assert scheduler.awaitTermination(300, TimeUnit.MILLISECONDS); + assert scheduler.isTerminated(); + assert verifySuccessed(future); + } + + @RepeatedTest(MULTIPLICITY) + void awaitTerminationLongTask() throws InterruptedException { + Verifier verifier = new Verifier(() -> { + try { + Thread.sleep(150); + return "Long Task"; + } catch (InterruptedException e) { + Thread.sleep(100); + return "Long Stop"; + } + }); + + Future future = scheduler.submit(verifier.asCallable()); + assert scheduler.start().awaitRunning(); + + scheduler.shutdown(); + assert scheduler.isShutdown(); + assert scheduler.isTerminated() == false; + + assert scheduler.awaitTermination(300, TimeUnit.MILLISECONDS); + assert scheduler.isTerminated(); + assert verifySuccessed(future); + } +} diff --git a/src/test/java/kiss/StressBench.java b/src/test/java/kiss/StressBench.java new file mode 100644 index 00000000..fc0a2188 --- /dev/null +++ b/src/test/java/kiss/StressBench.java @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2023 Nameless Production Committee + * + * Licensed under the MIT License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://opensource.org/licenses/mit-license.php + */ +package kiss; + +import java.util.Random; +import java.util.concurrent.TimeUnit; + +import kiss.Scheduler; + +public class StressBench { + + @SuppressWarnings("resource") + public static void main(String args[]) throws Exception { + Random random = new Random(); + Scheduler scheduler = new Scheduler(); + + for (int counter = 0; counter < 1000_000; ++counter) { + scheduler.schedule(new Job(counter), random.nextLong(5000, 1000 * 90), TimeUnit.MILLISECONDS); + } + + scheduler.scheduleAtFixedRate(System::gc, 30, 20, TimeUnit.SECONDS); + + Thread.sleep(1000 * 90); + } + + record Job(int id) implements Runnable { + @Override + public void run() { + System.out.println(id); + } + } +} diff --git a/src/test/java/kiss/TestableScheduler.java b/src/test/java/kiss/TestableScheduler.java new file mode 100644 index 00000000..806b353a --- /dev/null +++ b/src/test/java/kiss/TestableScheduler.java @@ -0,0 +1,197 @@ +/* + * Copyright (C) 2023 Nameless Production Committee + * + * Licensed under the MIT License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://opensource.org/licenses/mit-license.php + */ +package kiss; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; + +import kiss.I; +import kiss.Scheduler; + +public class TestableScheduler extends Scheduler { + + private long awaitingLimit = 1000; + + private final AtomicBoolean starting = new AtomicBoolean(); + + private List startingBuffer = new ArrayList(); + + private AtomicLong executed = new AtomicLong(); + + private Runnable wrap(Runnable task) { + return () -> { + try { + task.run(); + } finally { + executed.incrementAndGet(); + } + }; + } + + private Callable wrap(Callable task) { + return () -> { + try { + return task.call(); + } finally { + executed.incrementAndGet(); + } + }; + } + + /** + * {@inheritDoc} + */ + @Override + public ScheduledFuture schedule(Runnable command, long delay, TimeUnit unit) { + return super.schedule(wrap(command), delay, unit); + } + + /** + * {@inheritDoc} + */ + @Override + public ScheduledFuture schedule(Callable command, long delay, TimeUnit unit) { + return super.schedule(wrap(command), delay, unit); + } + + /** + * {@inheritDoc} + */ + @Override + public ScheduledFuture scheduleAtFixedRate(Runnable command, long delay, long interval, TimeUnit unit) { + return super.scheduleAtFixedRate(wrap(command), delay, interval, unit); + } + + /** + * {@inheritDoc} + */ + @Override + public ScheduledFuture scheduleWithFixedDelay(Runnable command, long delay, long interval, TimeUnit unit) { + return super.scheduleWithFixedDelay(wrap(command), delay, interval, unit); + } + + /** + * {@inheritDoc} + */ + @Override + public ScheduledFuture scheduleAt(Runnable command, String format) { + return super.scheduleAt(wrap(command), format); + } + + /** + * {@inheritDoc} + */ + @Override + protected Task executeTask(Task task) { + if (starting.get()) { + super.executeTask(task); + } else { + startingBuffer.add(task); + } + return task; + } + + /** + * Start task handler thread. + * + * @return + */ + protected final TestableScheduler start() { + if (starting.compareAndSet(false, true)) { + for (Task task : startingBuffer) { + super.executeTask(task); + } + startingBuffer.clear(); + } + return this; + } + + /** + * Await any task is running. + */ + protected boolean awaitRunning() { + int count = 0; // await at least once + long start = System.currentTimeMillis(); + while (count++ == 0 || runs.isEmpty()) { + try { + Thread.sleep(3); + } catch (InterruptedException e) { + throw I.quiet(e); + } + + if (awaitingLimit <= System.currentTimeMillis() - start) { + throw new Error("No task is active. " + this); + } + } + return true; + } + + protected TestableScheduler limitAwaitTime(long millis) { + awaitingLimit = millis; + return this; + } + + /** + * Await all tasks are executed. + */ + protected boolean awaitIdling() { + int count = 0; // await at least once + long start = System.currentTimeMillis(); + + while (count++ == 0 || !queue.isEmpty() || !runs.isEmpty()) { + try { + Thread.sleep(3); + } catch (InterruptedException e) { + throw I.quiet(e); + } + + if (awaitingLimit <= System.currentTimeMillis() - start) { + throw new Error("Too long task is active. " + this); + } + } + return true; + } + + /** + * Awaits until the specified number of tasks have been executed. + * + * @param required + * @return + */ + protected boolean awaitExecutions(long required) { + long start = System.currentTimeMillis(); + + while (executed.get() < required) { + try { + Thread.sleep(10); + } catch (InterruptedException e) { + throw I.quiet(e); + } + + if (awaitingLimit <= System.currentTimeMillis() - start) { + throw new Error("Too long task is active. " + this); + } + } + return true; + } + + /** + * {@inheritDoc} + */ + @Override + public String toString() { + return "Executor [running: " + runs.size() + " executed: " + executed + " queue: " + queue + "]"; + } +} diff --git a/src/test/java/kiss/ThreadLocalTest.java b/src/test/java/kiss/ThreadLocalTest.java new file mode 100644 index 00000000..4b7ed63e --- /dev/null +++ b/src/test/java/kiss/ThreadLocalTest.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2023 Nameless Production Committee + * + * Licensed under the MIT License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://opensource.org/licenses/mit-license.php + */ +package kiss; + +import java.util.concurrent.Callable; +import java.util.concurrent.Future; + +import org.junit.jupiter.api.Test; + +@SuppressWarnings("resource") +class ThreadLocalTest extends SchedulerTestSupport { + + @Test + void inherit() { + InheritableThreadLocal local = new InheritableThreadLocal(); + local.set("ROOT"); + + Verifier verifier = new Verifier(() -> local.get()); + Future future = scheduler.submit((Callable) verifier); + + assert scheduler.start().awaitIdling(); + assert verifySuccessed(future, "ROOT"); + } +}