├── .gitignore ├── LICENSE ├── README.md ├── pom.xml └── src ├── main └── java │ └── com │ └── github │ └── davidmarquis │ └── redisscheduler │ ├── PollingThread.java │ ├── RedisConnectException.java │ ├── RedisDriver.java │ ├── RedisTaskScheduler.java │ ├── SchedulerIdentity.java │ ├── TaskRunner.java │ ├── TaskScheduler.java │ ├── TaskTriggerListener.java │ └── drivers │ ├── jedis │ └── JedisDriver.java │ ├── lettuce │ └── LettuceDriver.java │ └── spring │ └── RedisTemplateDriver.java └── test ├── java └── com │ └── github │ └── davidmarquis │ └── redisscheduler │ ├── AcceptanceTestSuite.java │ ├── JedisIntegrationTest.java │ ├── LettuceIntegrationTest.java │ ├── RedisTaskSchedulerTest.java │ ├── SpringIntegrationTest.java │ └── lib │ ├── LatchedTriggerListener.java │ ├── StartRedis.java │ └── StubbedClock.java └── resources ├── application-context-test.xml ├── logback.xml └── test-config.properties /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | *.ipr 3 | .idea 4 | target -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2011-2013 David Marquis 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | redis-scheduler 2 | =============== 3 | 4 | Distributed Scheduler using Redis for Java applications. 5 | 6 | What is this? 7 | ------------- 8 | 9 | `redis-scheduler` is a Java implementation of a distributed scheduler using Redis. It has the following features: 10 | 11 | - **Useable in a distributed environment**: Uses Redis transactions for effectively preventing a task to be run on 12 | multiple instances of the same application. 13 | - **Lightweight**: Uses a single thread. 14 | - **Configurable polling**: Polling delay can be configured to tweak execution precision (at the cost of performance) 15 | - **Multiple schedulers support**: You can create multiple schedulers in the same logical application if you need to. 16 | - **Support for multiple client libraries**: Drivers exist for [Jedis](https://github.com/xetorthio/jedis), [Lettuce](https://lettuce.io/) and [Spring Data's RedisTemplate](https://projects.spring.io/spring-data-redis/) 17 | 18 | High level concepts 19 | ------------------- 20 | 21 | #### Scheduled Task 22 | 23 | A scheduled task is a job that you need to execute in the future at a particular time. 24 | In `redis-scheduler`, a task is represented solely by an arbitrary string identifier that has no particular meaning to the library. 25 | It's your application that has to make sense of this identifier. 26 | 27 | #### Scheduler 28 | 29 | `RedisTaskScheduler`: This interface is where you submit your tasks for future execution. Once submitted, a task will only be 30 | executed at or after the trigger time you provide. 31 | 32 | #### `TaskTriggerListener` interface 33 | 34 | This is the main interface you must implement to actually run the tasks once they are due for execution. The library will 35 | call the `taskTriggered` method for each task that is due for execution. 36 | 37 | 38 | Building the project 39 | -------------------- 40 | 41 | ``` bash 42 | mvn package 43 | ``` 44 | 45 | Maven dependency 46 | ---------------- 47 | 48 | This artifact is published on Maven Central: 49 | 50 | ``` xml 51 | 52 | com.github.davidmarquis 53 | redis-scheduler 54 | 3.0.0 55 | 56 | ``` 57 | 58 | You'll need to add one of the specific dependencies to use the different available drivers: 59 | 60 | To use with Lettuce: 61 | 62 | ``` xml 63 | 64 | io.lettuce 65 | lettuce-core 66 | 5.0.3.RELEASE 67 | 68 | ``` 69 | 70 | To use with Jedis: 71 | 72 | ``` xml 73 | 74 | redis.clients 75 | jedis 76 | 2.9.0 77 | 78 | ``` 79 | 80 | To use with Spring Data Redis: 81 | 82 | ``` xml 83 | 84 | org.springframework.data 85 | spring-data-redis 86 | 1.8.11.RELEASE 87 | 88 | ``` 89 | 90 | 91 | Usage with Lettuce 92 | ------------------ 93 | 94 | The scheduler must be instantiated with the `LettuceDriver`: 95 | 96 | ``` java 97 | RedisClient client = RedisClient.create(RedisURI.create("localhost", 6379)); 98 | RedisTaskScheduler scheduler = new RedisTaskScheduler(new LettuceDriver(client), new YourTaskTriggerListener()); 99 | 100 | scheduler.start(); 101 | ``` 102 | 103 | Usage with Jedis 104 | ---------------- 105 | 106 | The scheduler must be instantiated with the `JedisDriver`: 107 | 108 | ``` java 109 | JedisPool pool = new JedisPool("localhost", 6379); 110 | RedisTaskScheduler scheduler = new RedisTaskScheduler(new JedisDriver(pool), new YourTaskTriggerListener()); 111 | 112 | scheduler.start(); 113 | ``` 114 | 115 | Usage with Spring 116 | ----------------- 117 | 118 | First declare the base beans for Redis connectivity (if not already done in your project). This part can be different 119 | for your project. 120 | 121 | ``` xml 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | ``` 138 | 139 | Finally, declare the scheduler instance: 140 | 141 | ``` xml 142 | 143 | 144 | 145 | 146 | 147 | 148 | ``` 149 | 150 | As noted above, `RedisTaskScheduler` expects an implementation of the `TaskTriggerListener` interface which it will notify when a task is due for execution. You must implement this interface yourself and provide it to the scheduler as a constructor argument. 151 | 152 | See the the test Spring context in `test/resources/application-context-test.xml` for a complete working example of the setup. 153 | 154 | 155 | Scheduling a task in the future 156 | ------------------------------- 157 | 158 | ``` java 159 | scheduler.schedule("mytask", new GregorianCalendar(2015, Calendar.JANUARY, 1, 4, 45, 0)); 160 | ``` 161 | 162 | This would schedule a task with ID "mytask" to be run at 4:45AM on January 1st 2015. 163 | 164 | Be notified once a task is due for execution 165 | -------------------------------------------- 166 | 167 | ``` java 168 | public class MyTaskTriggerListener implements TaskTriggerListener { 169 | public void taskTriggered(String taskId) { 170 | System.out.printf("Task %s is due for execution.", taskId); 171 | } 172 | } 173 | ``` 174 | 175 | Customizing polling delay 176 | ---------------------------------- 177 | 178 | By default, polling delay is set to a few seconds (see implementation `RedisTaskScheduler` for actual value). If 179 | you need your tasks to be triggered with more precision, decrease the polling delay using the `pollingDelayMillis` attribute of `RedisTaskScheduler`: 180 | 181 | In Java: 182 | 183 | ``` java 184 | scheduler.setPollingDelayMillis(500); 185 | ``` 186 | 187 | With Spring: 188 | 189 | ``` xml 190 | 191 | 192 | 193 | ``` 194 | 195 | Increasing polling delay comes with a cost: higher load on Redis and your connection. 196 | Try to find the best balance for your needs. 197 | 198 | Retry polling when a Redis connection error happens 199 | --------------------------------------------------- 200 | 201 | Retries can be configured using the `maxRetriesOnConnectionFailure` property on `RedisTaskScheduler`: 202 | 203 | In Java: 204 | 205 | ``` java 206 | scheduler.setMaxRetriesOnConnectionFailure(5); 207 | ``` 208 | 209 | With Spring: 210 | 211 | ``` xml 212 | 213 | 214 | 215 | ``` 216 | 217 | After the specified number of retries, the polling thread will stop and log an error. 218 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4.0.0 4 | 5 | com.github.davidmarquis 6 | redis-scheduler 7 | 3.0.1-SNAPSHOT 8 | jar 9 | 10 | redis-scheduler 11 | Java implementation of a lightweight distributed task scheduler backed by Redis 12 | https://github.com/davidmarquis/redis-scheduler 13 | 14 | 15 | 16 | MIT 17 | http://www.opensource.org/licenses/mit-license.php 18 | repo 19 | 20 | 21 | 22 | 23 | scm:git:git@github.com:davidmarquis/redis-scheduler.git 24 | scm:git:git@github.com:davidmarquis/redis-scheduler.git 25 | git@github.com:davidmarquis/redis-scheduler.git 26 | HEAD 27 | 28 | 29 | 30 | 31 | davidmarquis 32 | David Marquis 33 | david@radiant3.ca 34 | 35 | 36 | 37 | 38 | 39 | ossrh 40 | https://oss.sonatype.org/content/repositories/snapshots 41 | 42 | 43 | ossrh 44 | https://oss.sonatype.org/service/local/staging/deploy/maven2/ 45 | 46 | 47 | 48 | 49 | UTF-8 50 | 1.8 51 | 52 | 53 | 54 | 55 | javax.annotation 56 | javax.annotation-api 57 | 1.3.1 58 | 59 | 60 | 61 | 62 | io.lettuce 63 | lettuce-core 64 | 5.0.3.RELEASE 65 | provided 66 | 67 | 68 | 69 | 70 | redis.clients 71 | jedis 72 | 2.9.0 73 | provided 74 | 75 | 76 | 77 | 78 | org.springframework.data 79 | spring-data-redis 80 | 1.8.11.RELEASE 81 | provided 82 | 83 | 84 | org.apache.commons 85 | commons-pool2 86 | 2.2 87 | provided 88 | 89 | 90 | org.springframework 91 | spring-core 92 | 4.3.15.RELEASE 93 | provided 94 | 95 | 96 | org.springframework 97 | spring-test 98 | 4.3.15.RELEASE 99 | test 100 | 101 | 102 | 103 | 104 | ch.qos.logback 105 | logback-classic 106 | 1.2.3 107 | provided 108 | 109 | 110 | com.github.kstyrc 111 | embedded-redis 112 | 0.6 113 | test 114 | 115 | 116 | junit 117 | junit 118 | 4.12 119 | test 120 | 121 | 122 | org.hamcrest 123 | hamcrest-all 124 | 1.3 125 | test 126 | 127 | 128 | org.mockito 129 | mockito-all 130 | 1.9.5 131 | test 132 | 133 | 134 | 135 | 136 | 137 | release 138 | 139 | 140 | performRelease 141 | true 142 | 143 | 144 | 145 | 146 | 147 | org.apache.maven.plugins 148 | maven-source-plugin 149 | 2.2.1 150 | 151 | 152 | attach-sources 153 | 154 | jar-no-fork 155 | 156 | 157 | 158 | 159 | 160 | org.apache.maven.plugins 161 | maven-javadoc-plugin 162 | 3.0.0 163 | 164 | 165 | attach-javadocs 166 | 167 | jar 168 | 169 | 170 | 171 | 172 | 173 | org.apache.maven.plugins 174 | maven-gpg-plugin 175 | 1.5 176 | 177 | 178 | sign-artifacts 179 | verify 180 | 181 | sign 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | disable-java8-doclint 191 | 192 | [1.8,) 193 | 194 | 195 | -Xdoclint:none 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | org.apache.maven.plugins 204 | maven-compiler-plugin 205 | 3.7.0 206 | 207 | ${java.version} 208 | ${java.version} 209 | 210 | 211 | 212 | org.apache.maven.plugins 213 | maven-release-plugin 214 | 2.5.3 215 | 216 | true 217 | false 218 | release 219 | deploy 220 | 221 | 222 | 223 | org.sonatype.plugins 224 | nexus-staging-maven-plugin 225 | 1.6.7 226 | true 227 | 228 | ossrh 229 | https://oss.sonatype.org/ 230 | true 231 | 232 | 233 | 234 | 235 | 236 | ${project.basedir}/src/main/resources 237 | 238 | 239 | 240 | 241 | src/test/resources 242 | true 243 | 244 | 245 | 246 | 247 | -------------------------------------------------------------------------------- /src/main/java/com/github/davidmarquis/redisscheduler/PollingThread.java: -------------------------------------------------------------------------------- 1 | package com.github.davidmarquis.redisscheduler; 2 | 3 | import org.slf4j.Logger; 4 | import org.slf4j.LoggerFactory; 5 | 6 | class PollingThread extends Thread { 7 | 8 | private static final Logger log = LoggerFactory.getLogger(TaskScheduler.class); 9 | 10 | private TaskRunner runner; 11 | private int maxRetriesOnConnectionFailure; 12 | private int pollingDelayMillis; 13 | 14 | private boolean stopRequested = false; 15 | private int numRetriesAttempted = 0; 16 | 17 | PollingThread(TaskRunner runner, int maxRetriesOnConnectionFailure, int pollingDelayMillis) { 18 | this.runner = runner; 19 | this.maxRetriesOnConnectionFailure = maxRetriesOnConnectionFailure; 20 | this.pollingDelayMillis = pollingDelayMillis; 21 | } 22 | 23 | void requestStop() { 24 | stopRequested = true; 25 | } 26 | 27 | @Override 28 | public void run() { 29 | try { 30 | while (!stopRequested && !isMaxRetriesAttemptsReached()) { 31 | 32 | try { 33 | attemptTriggerNextTask(); 34 | } catch (InterruptedException e) { 35 | break; 36 | } 37 | } 38 | } catch (Exception e) { 39 | log.error(String.format( 40 | "[%s] Error while polling scheduled tasks. " + 41 | "No additional scheduled task will be triggered until the application is restarted.", getName()), e); 42 | } 43 | 44 | if (isMaxRetriesAttemptsReached()) { 45 | log.error(String.format("[%s] Maximum number of retries (%s) after Redis connection failure has been reached. " + 46 | "No additional scheduled task will be triggered until the application is restarted.", getName(), maxRetriesOnConnectionFailure)); 47 | } else { 48 | log.info(String.format("[%s] Redis Scheduler stopped", getName())); 49 | } 50 | } 51 | 52 | private void attemptTriggerNextTask() throws InterruptedException { 53 | try { 54 | boolean taskTriggered = runner.triggerNextTaskIfFound(); 55 | 56 | // if a task was triggered, we'll try again immediately. This will help to speed up the execution 57 | // process if a few tasks were due for execution. 58 | if (!taskTriggered) { 59 | sleep(pollingDelayMillis); 60 | } 61 | 62 | resetRetriesAttemptsCount(); 63 | } catch (RedisConnectException e) { 64 | incrementRetriesAttemptsCount(); 65 | log.warn(String.format("Connection failure during scheduler polling (attempt %s/%s)", numRetriesAttempted, maxRetriesOnConnectionFailure)); 66 | } 67 | } 68 | 69 | private boolean isMaxRetriesAttemptsReached() { 70 | return numRetriesAttempted >= maxRetriesOnConnectionFailure; 71 | } 72 | 73 | private void resetRetriesAttemptsCount() { 74 | numRetriesAttempted = 0; 75 | } 76 | 77 | private void incrementRetriesAttemptsCount() { 78 | numRetriesAttempted++; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/main/java/com/github/davidmarquis/redisscheduler/RedisConnectException.java: -------------------------------------------------------------------------------- 1 | package com.github.davidmarquis.redisscheduler; 2 | 3 | 4 | public class RedisConnectException extends RuntimeException { 5 | public RedisConnectException(Throwable cause) { 6 | super(cause); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/main/java/com/github/davidmarquis/redisscheduler/RedisDriver.java: -------------------------------------------------------------------------------- 1 | package com.github.davidmarquis.redisscheduler; 2 | 3 | import java.util.Optional; 4 | import java.util.function.Consumer; 5 | import java.util.function.Function; 6 | 7 | public interface RedisDriver { 8 | 9 | T fetch(Function block); 10 | 11 | default void execute(Consumer block) { 12 | fetch((Function) commands -> { 13 | block.accept(commands); 14 | return null; 15 | }); 16 | } 17 | 18 | interface Commands { 19 | void addToSetWithScore(String key, String taskId, long score); 20 | 21 | void removeFromSet(String key, String taskId); 22 | 23 | void remove(String key); 24 | 25 | void watch(String key); 26 | 27 | void unwatch(); 28 | 29 | void multi(); 30 | 31 | boolean exec(); 32 | 33 | Optional firstByScore(String key, long minScore, long maxScore); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/main/java/com/github/davidmarquis/redisscheduler/RedisTaskScheduler.java: -------------------------------------------------------------------------------- 1 | package com.github.davidmarquis.redisscheduler; 2 | 3 | import org.slf4j.Logger; 4 | import org.slf4j.LoggerFactory; 5 | 6 | import javax.annotation.PostConstruct; 7 | import javax.annotation.PreDestroy; 8 | import java.time.Clock; 9 | import java.time.Instant; 10 | import java.util.Optional; 11 | 12 | public class RedisTaskScheduler implements TaskScheduler, TaskRunner { 13 | 14 | private static final Logger log = LoggerFactory.getLogger(TaskScheduler.class); 15 | 16 | private static final String DEFAULT_SCHEDULER_NAME = "scheduler"; 17 | 18 | private Clock clock = Clock.systemDefaultZone(); 19 | private RedisDriver driver; 20 | private TaskTriggerListener listener; 21 | 22 | private SchedulerIdentity identity = SchedulerIdentity.of(DEFAULT_SCHEDULER_NAME); 23 | 24 | private PollingThread pollingThread; 25 | private int pollingDelayMillis = 10000; 26 | private int maxRetriesOnConnectionFailure = 1; 27 | 28 | public RedisTaskScheduler(RedisDriver driver, TaskTriggerListener listener) { 29 | this.driver = driver; 30 | this.listener = listener; 31 | } 32 | 33 | public void runNow(String taskId) { 34 | scheduleAt(taskId, clock.instant()); 35 | } 36 | 37 | public void scheduleAt(String taskId, Instant triggerTime) { 38 | if (triggerTime == null) { 39 | throw new IllegalArgumentException("A trigger time must be provided."); 40 | } 41 | 42 | driver.execute(commands -> commands.addToSetWithScore(identity.key(), taskId, triggerTime.toEpochMilli())); 43 | } 44 | 45 | @Override 46 | public void unschedule(String taskId) { 47 | driver.execute(commands -> commands.removeFromSet(identity.key(), taskId)); 48 | } 49 | 50 | @Override 51 | public void unscheduleAllTasks() { 52 | driver.execute(commands -> commands.remove(identity.key())); 53 | } 54 | 55 | @PostConstruct 56 | public void start() { 57 | pollingThread = new PollingThread(this, maxRetriesOnConnectionFailure, pollingDelayMillis); 58 | pollingThread.setName(identity.name() + "-polling"); 59 | 60 | pollingThread.start(); 61 | 62 | log.info(String.format("[%s] Started Redis Scheduler (polling freq: [%sms])", identity.name(), pollingDelayMillis)); 63 | } 64 | 65 | public void stop() { 66 | close(); 67 | } 68 | 69 | @Override 70 | @PreDestroy 71 | public void close() { 72 | if (pollingThread != null) { 73 | pollingThread.requestStop(); 74 | } 75 | } 76 | 77 | public void setClock(Clock clock) { 78 | this.clock = clock; 79 | } 80 | 81 | /** 82 | * If multiple schedulers are needed for the same application, customize their names to differentiate them in logs. 83 | */ 84 | public void setSchedulerName(String schedulerName) { 85 | this.identity = SchedulerIdentity.of(schedulerName); 86 | } 87 | 88 | /** 89 | * Delay between polling of the scheduled tasks. The lower the value, the best precision in triggering tasks. 90 | * However, the lower the value, the higher the load on Redis. 91 | */ 92 | public void setPollingDelayMillis(int pollingDelayMillis) { 93 | this.pollingDelayMillis = pollingDelayMillis; 94 | } 95 | 96 | public void setMaxRetriesOnConnectionFailure(int maxRetriesOnConnectionFailure) { 97 | this.maxRetriesOnConnectionFailure = maxRetriesOnConnectionFailure; 98 | } 99 | 100 | @SuppressWarnings("unchecked") 101 | public boolean triggerNextTaskIfFound() { 102 | return driver.fetch(commands -> { 103 | boolean taskWasTriggered = false; 104 | 105 | commands.watch(identity.key()); 106 | 107 | Optional nextTask = commands.firstByScore(identity.key(), 0, clock.millis()); 108 | 109 | if (nextTask.isPresent()) { 110 | String nextTaskId = nextTask.get(); 111 | 112 | commands.multi(); 113 | commands.removeFromSet(identity.key(), nextTaskId); 114 | boolean executionSuccess = commands.exec(); 115 | 116 | if (executionSuccess) { 117 | log.debug(String.format("[%s] Triggering execution of task [%s]", identity.name(), nextTaskId)); 118 | 119 | tryTaskExecution(nextTaskId); 120 | taskWasTriggered = true; 121 | } else { 122 | log.warn(String.format("[%s] Race condition detected for triggering of task [%s]. " + 123 | "The task has probably been triggered by another instance of this application.", identity.name(), nextTaskId)); 124 | } 125 | } else { 126 | commands.unwatch(); 127 | } 128 | 129 | return taskWasTriggered; 130 | }); 131 | } 132 | 133 | private void tryTaskExecution(String task) { 134 | try { 135 | listener.taskTriggered(task); 136 | } catch (Exception e) { 137 | log.error(String.format("[%s] Error during execution of task [%s]", identity.name(), task), e); 138 | } 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/main/java/com/github/davidmarquis/redisscheduler/SchedulerIdentity.java: -------------------------------------------------------------------------------- 1 | package com.github.davidmarquis.redisscheduler; 2 | 3 | class SchedulerIdentity { 4 | private static final String REDIS_KEY_FORMAT = "redis-scheduler.%s"; 5 | 6 | private String name; 7 | 8 | private SchedulerIdentity(String name) { 9 | this.name = name; 10 | } 11 | 12 | String key() { 13 | return String.format(REDIS_KEY_FORMAT, name); 14 | } 15 | 16 | String name() { 17 | return name; 18 | } 19 | 20 | static SchedulerIdentity of(String name) { 21 | return new SchedulerIdentity(name); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/com/github/davidmarquis/redisscheduler/TaskRunner.java: -------------------------------------------------------------------------------- 1 | package com.github.davidmarquis.redisscheduler; 2 | 3 | public interface TaskRunner { 4 | boolean triggerNextTaskIfFound(); 5 | } 6 | -------------------------------------------------------------------------------- /src/main/java/com/github/davidmarquis/redisscheduler/TaskScheduler.java: -------------------------------------------------------------------------------- 1 | package com.github.davidmarquis.redisscheduler; 2 | 3 | import java.io.Closeable; 4 | import java.time.Instant; 5 | 6 | /** 7 | * Schedules arbitrary tasks in the future. 8 | */ 9 | public interface TaskScheduler extends Closeable { 10 | /** 11 | * Runs a task immediately. 12 | * 13 | * @param taskId an arbitrary task identifier. That same identifier will be used in TaskTriggerListener callback 14 | * once the task is due for execution. 15 | */ 16 | void runNow(String taskId); 17 | 18 | /** 19 | * Schedules a task for future execution. 20 | * 21 | * @param taskId an arbitrary task identifier. That same identifier will be used in TaskTriggerListener callback 22 | * once the task is due for execution. 23 | * @param trigger the time at which we want the task to be executed. If this value is null or in the past, 24 | * then the task will be immediately scheduled for execution. 25 | */ 26 | void scheduleAt(String taskId, Instant trigger); 27 | 28 | /** 29 | * Removes all currently scheduled tasks from the scheduler. 30 | */ 31 | void unscheduleAllTasks(); 32 | 33 | /** 34 | * Removes a specific task from the scheduler. If the task was not previously scheduled, then calling this method 35 | * has no particular effect. 36 | * 37 | * @param taskId The task ID to remove. 38 | */ 39 | void unschedule(String taskId); 40 | } 41 | -------------------------------------------------------------------------------- /src/main/java/com/github/davidmarquis/redisscheduler/TaskTriggerListener.java: -------------------------------------------------------------------------------- 1 | package com.github.davidmarquis.redisscheduler; 2 | 3 | /** 4 | * Callback interface that will get executed when a previously-scheduled task is due for execution. 5 | */ 6 | public interface TaskTriggerListener { 7 | 8 | /** 9 | * Called by the scheduler once a task is due for execution. 10 | * 11 | * @param taskId the task ID that was originally submitted to the RedisTaskScheduler. 12 | */ 13 | void taskTriggered(String taskId); 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/com/github/davidmarquis/redisscheduler/drivers/jedis/JedisDriver.java: -------------------------------------------------------------------------------- 1 | package com.github.davidmarquis.redisscheduler.drivers.jedis; 2 | 3 | import com.github.davidmarquis.redisscheduler.RedisConnectException; 4 | import com.github.davidmarquis.redisscheduler.RedisDriver; 5 | import redis.clients.jedis.Jedis; 6 | import redis.clients.jedis.JedisPool; 7 | import redis.clients.jedis.Transaction; 8 | 9 | import java.io.IOException; 10 | import java.util.Optional; 11 | import java.util.function.Function; 12 | 13 | public class JedisDriver implements RedisDriver { 14 | 15 | private JedisPool jedisPool; 16 | 17 | public JedisDriver(JedisPool jedisPool) { 18 | this.jedisPool = jedisPool; 19 | } 20 | 21 | @Override 22 | public T fetch(Function block) { 23 | try (Jedis jedis = jedisPool.getResource()) { 24 | return block.apply(new JedisCommands(jedis)); 25 | } 26 | } 27 | 28 | private static class JedisCommands implements Commands { 29 | 30 | private Jedis jedis; 31 | 32 | private Transaction txn; 33 | 34 | private JedisCommands(Jedis jedis) { 35 | this.jedis = jedis; 36 | } 37 | 38 | @Override 39 | public void addToSetWithScore(String key, String taskId, long score) { 40 | jedis.zadd(key, score, taskId); 41 | } 42 | 43 | @Override 44 | public void removeFromSet(String key, String taskId) { 45 | if (txn != null) { 46 | txn.zrem(key, taskId); 47 | } else { 48 | jedis.zrem(key, taskId); 49 | } 50 | } 51 | 52 | @Override 53 | public void remove(String key) { 54 | jedis.del(key); 55 | } 56 | 57 | @Override 58 | public void watch(String key) { 59 | jedis.watch(key); 60 | } 61 | 62 | @Override 63 | public void unwatch() { 64 | jedis.unwatch(); 65 | } 66 | 67 | @Override 68 | public void multi() { 69 | txn = jedis.multi(); 70 | } 71 | 72 | @Override 73 | public boolean exec() { 74 | try { 75 | return Optional.ofNullable(txn) 76 | .map(Transaction::exec) 77 | .map(col -> !col.isEmpty()) 78 | .orElse(false); 79 | } finally { 80 | try { 81 | txn.close(); 82 | } catch (IOException e) { 83 | throw new RedisConnectException(e); 84 | } 85 | txn = null; 86 | } 87 | } 88 | 89 | @Override 90 | public Optional firstByScore(String key, long minScore, long maxScore) { 91 | return jedis.zrangeByScore(key, minScore, maxScore, 0, 1) 92 | .stream() 93 | .findFirst(); 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/main/java/com/github/davidmarquis/redisscheduler/drivers/lettuce/LettuceDriver.java: -------------------------------------------------------------------------------- 1 | package com.github.davidmarquis.redisscheduler.drivers.lettuce; 2 | 3 | import com.github.davidmarquis.redisscheduler.RedisConnectException; 4 | import com.github.davidmarquis.redisscheduler.RedisDriver; 5 | import io.lettuce.core.Limit; 6 | import io.lettuce.core.Range; 7 | import io.lettuce.core.RedisClient; 8 | import io.lettuce.core.RedisConnectionException; 9 | import io.lettuce.core.api.StatefulRedisConnection; 10 | import io.lettuce.core.api.sync.RedisCommands; 11 | 12 | import java.util.Optional; 13 | import java.util.function.Function; 14 | 15 | /** 16 | * Driver using Lettuce in synchronous mode (see lettuce.io) 17 | */ 18 | public class LettuceDriver implements RedisDriver { 19 | private final RedisClient client; 20 | 21 | public LettuceDriver(RedisClient client) { 22 | this.client = client; 23 | } 24 | 25 | @Override 26 | public T fetch(Function block) { 27 | try (StatefulRedisConnection connection = client.connect()) { 28 | RedisCommands commands = connection.sync(); 29 | return block.apply(new LettuceCommands(commands)); 30 | } catch (RedisConnectionException e) { 31 | throw new RedisConnectException(e); 32 | } 33 | } 34 | 35 | private static class LettuceCommands implements Commands { 36 | private final RedisCommands commands; 37 | 38 | private LettuceCommands(RedisCommands commands) { 39 | this.commands = commands; 40 | } 41 | 42 | @Override 43 | public void addToSetWithScore(String key, String taskId, long score) { 44 | commands.zadd(key, score, taskId); 45 | } 46 | 47 | @Override 48 | public void removeFromSet(String key, String taskId) { 49 | commands.zrem(key, taskId); 50 | } 51 | 52 | @Override 53 | public void remove(String key) { 54 | commands.del(key); 55 | } 56 | 57 | @Override 58 | public void watch(String key) { 59 | commands.watch(key); 60 | } 61 | 62 | @Override 63 | public void unwatch() { 64 | commands.unwatch(); 65 | } 66 | 67 | @Override 68 | public void multi() { 69 | commands.multi(); 70 | } 71 | 72 | @Override 73 | public boolean exec() { 74 | return !commands.exec().isEmpty(); 75 | } 76 | 77 | @Override 78 | public Optional firstByScore(String key, long minScore, long maxScore) { 79 | return commands.zrangebyscore(key, Range.create(minScore, maxScore), Limit.create(0, 1)) 80 | .stream() 81 | .findFirst(); 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/main/java/com/github/davidmarquis/redisscheduler/drivers/spring/RedisTemplateDriver.java: -------------------------------------------------------------------------------- 1 | package com.github.davidmarquis.redisscheduler.drivers.spring; 2 | 3 | import com.github.davidmarquis.redisscheduler.RedisConnectException; 4 | import com.github.davidmarquis.redisscheduler.RedisDriver; 5 | import org.springframework.dao.DataAccessException; 6 | import org.springframework.data.redis.RedisConnectionFailureException; 7 | import org.springframework.data.redis.core.RedisOperations; 8 | import org.springframework.data.redis.core.RedisTemplate; 9 | import org.springframework.data.redis.core.SessionCallback; 10 | 11 | import java.util.Optional; 12 | import java.util.function.Function; 13 | 14 | /** 15 | * Driver using Spring Data Redis 16 | */ 17 | public class RedisTemplateDriver implements RedisDriver { 18 | 19 | private RedisTemplate redisTemplate; 20 | 21 | public RedisTemplateDriver(RedisTemplate redisTemplate) { 22 | this.redisTemplate = redisTemplate; 23 | } 24 | 25 | @Override 26 | public T fetch(Function block) { 27 | try { 28 | return redisTemplate.execute(new SessionCallback() { 29 | @Override 30 | @SuppressWarnings("unchecked") 31 | public T execute(RedisOperations operations) throws DataAccessException { 32 | RedisConnectionCommands commands = new RedisConnectionCommands((RedisOperations) operations); 33 | return block.apply(commands); 34 | } 35 | }); 36 | } catch (RedisConnectionFailureException e) { 37 | throw new RedisConnectException(e); 38 | } 39 | } 40 | 41 | private static class RedisConnectionCommands implements Commands { 42 | private RedisOperations ops; 43 | 44 | private RedisConnectionCommands(RedisOperations ops) { 45 | this.ops = ops; 46 | } 47 | 48 | @Override 49 | public void addToSetWithScore(String key, String taskId, long score) { 50 | ops.opsForZSet().add(key, taskId, score); 51 | } 52 | 53 | @Override 54 | public void removeFromSet(String key, String taskId) { 55 | ops.opsForZSet().remove(key, taskId); 56 | } 57 | 58 | @Override 59 | public void remove(String key) { 60 | ops.delete(key); 61 | } 62 | 63 | @Override 64 | public void watch(String key) { 65 | ops.watch(key); 66 | } 67 | 68 | @Override 69 | public void unwatch() { 70 | ops.unwatch(); 71 | } 72 | 73 | @Override 74 | public void multi() { 75 | ops.multi(); 76 | } 77 | 78 | @Override 79 | public boolean exec() { 80 | return ops.exec() != null; 81 | } 82 | 83 | @Override 84 | public Optional firstByScore(String key, long minScore, long maxScore) { 85 | return ops.opsForZSet() 86 | .rangeByScore(key, minScore, maxScore, 0, 1) 87 | .stream() 88 | .findFirst(); 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/test/java/com/github/davidmarquis/redisscheduler/AcceptanceTestSuite.java: -------------------------------------------------------------------------------- 1 | package com.github.davidmarquis.redisscheduler; 2 | 3 | import com.github.davidmarquis.redisscheduler.lib.LatchedTriggerListener; 4 | import com.github.davidmarquis.redisscheduler.lib.StartRedis; 5 | import com.github.davidmarquis.redisscheduler.lib.StubbedClock; 6 | import org.junit.After; 7 | import org.junit.Before; 8 | import org.junit.ClassRule; 9 | import org.junit.Test; 10 | 11 | import java.util.List; 12 | 13 | import static java.util.Arrays.asList; 14 | import static java.util.concurrent.TimeUnit.HOURS; 15 | import static org.hamcrest.CoreMatchers.is; 16 | import static org.hamcrest.MatcherAssert.assertThat; 17 | 18 | public abstract class AcceptanceTestSuite { 19 | 20 | @ClassRule 21 | public static StartRedis redis = new StartRedis(); 22 | 23 | // Actors: 24 | RedisTaskScheduler scheduler; 25 | StubbedClock clock = new StubbedClock(); 26 | LatchedTriggerListener taskTriggerListener = new LatchedTriggerListener(); 27 | 28 | protected abstract void provideActors(); 29 | 30 | @Before 31 | public void setup() { 32 | provideActors(); 33 | 34 | scheduler.unscheduleAllTasks(); 35 | taskTriggerListener.reset(); 36 | 37 | clock.is("20180405 10:00"); 38 | } 39 | 40 | @After 41 | public void tearDown() { 42 | scheduler.unscheduleAllTasks(); 43 | taskTriggerListener.reset(); 44 | } 45 | 46 | @Test 47 | public void canTriggerTask() throws InterruptedException { 48 | scheduler.scheduleAt("mytask", clock.in(2, HOURS)); 49 | clock.fastForward(2, HOURS); 50 | 51 | checkExactTasksTriggered("mytask"); 52 | } 53 | 54 | @Test 55 | public void canTriggerMultipleTasks() throws InterruptedException { 56 | scheduler.scheduleAt("first", clock.in(1, HOURS)); 57 | scheduler.scheduleAt("second", clock.in(2, HOURS)); 58 | clock.fastForward(2, HOURS); 59 | 60 | checkExactTasksTriggered("first", "second"); 61 | } 62 | 63 | @Test 64 | public void canTriggerPastTasks() throws InterruptedException { 65 | scheduler.scheduleAt("mytask1", clock.in(1, HOURS)); 66 | scheduler.scheduleAt("mytask2", clock.in(2, HOURS)); 67 | scheduler.scheduleAt("mytask3", clock.in(5, HOURS)); 68 | clock.fastForward(3, HOURS); 69 | 70 | checkOnlyTasksTriggered("mytask1", "mytask2"); 71 | } 72 | 73 | @Test 74 | public void cannotTriggerFutureTasks() throws InterruptedException { 75 | scheduler.scheduleAt("mytask", clock.in(1, HOURS)); 76 | 77 | assertNoTaskTriggered(); 78 | } 79 | 80 | @Test 81 | public void canScheduleInThePast() throws InterruptedException { 82 | scheduler.scheduleAt("mytask", clock.in(-1, HOURS)); 83 | 84 | checkExactTasksTriggered("mytask"); 85 | } 86 | 87 | @Test(expected = IllegalArgumentException.class) 88 | public void schedulingAtNullTimeRaisesException() { 89 | scheduler.scheduleAt("mytask", null); 90 | } 91 | 92 | @Test 93 | public void runNowImmediatelyTriggers() throws InterruptedException { 94 | scheduler.runNow("immediate"); 95 | 96 | checkExactTasksTriggered("immediate"); 97 | } 98 | 99 | @Test 100 | public void canRescheduleTask() throws InterruptedException { 101 | scheduler.scheduleAt("mytask", clock.in(5, HOURS)); 102 | scheduler.scheduleAt("mytask", clock.in(1, HOURS)); 103 | clock.fastForward(2, HOURS); 104 | 105 | checkExactTasksTriggered("mytask"); 106 | } 107 | 108 | @Test 109 | public void canUnscheduleTask() throws InterruptedException { 110 | scheduler.scheduleAt("mytask1", clock.in(1, HOURS)); 111 | scheduler.scheduleAt("mytask2", clock.in(1, HOURS)); 112 | scheduler.unschedule("mytask2"); 113 | clock.fastForward(2, HOURS); 114 | 115 | checkOnlyTasksTriggered("mytask1"); 116 | } 117 | 118 | private void checkExactTasksTriggered(String... tasks) throws InterruptedException { 119 | taskTriggerListener.waitUntilTriggeredCount(tasks.length, 1000); 120 | 121 | assertTasksTriggered(tasks); 122 | } 123 | 124 | private void checkOnlyTasksTriggered(String... tasks) throws InterruptedException { 125 | // if only a subset of the scheduled tasks are expected to be triggered, then we need to wait for a while. 126 | Thread.sleep(1000); 127 | 128 | assertTasksTriggered(tasks); 129 | } 130 | 131 | private void assertTasksTriggered(String... tasks) { 132 | List triggeredTasks = taskTriggerListener.getTriggeredTasks(); 133 | assertThat("Triggered tasks count", triggeredTasks.size(), is(tasks.length)); 134 | assertThat("Triggered tasks", triggeredTasks, is(asList(tasks))); 135 | } 136 | 137 | private void assertNoTaskTriggered() throws InterruptedException { 138 | Thread.sleep(1000); 139 | 140 | assertThat("No tasks should triggered", taskTriggerListener.getTriggeredTasks().size(), is(0)); 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/test/java/com/github/davidmarquis/redisscheduler/JedisIntegrationTest.java: -------------------------------------------------------------------------------- 1 | package com.github.davidmarquis.redisscheduler; 2 | 3 | import com.github.davidmarquis.redisscheduler.drivers.jedis.JedisDriver; 4 | import org.junit.After; 5 | import org.junit.AfterClass; 6 | import redis.clients.jedis.JedisPool; 7 | 8 | public class JedisIntegrationTest extends AcceptanceTestSuite { 9 | 10 | private static final JedisPool pool = new JedisPool("localhost", 6379); 11 | 12 | @Override 13 | protected void provideActors() { 14 | scheduler = new RedisTaskScheduler(new JedisDriver(pool), taskTriggerListener); 15 | scheduler.setSchedulerName("jedis-scheduler"); 16 | scheduler.setClock(clock); 17 | scheduler.setPollingDelayMillis(50); 18 | scheduler.start(); 19 | } 20 | 21 | @After 22 | public void stopScheduler() { 23 | scheduler.stop(); 24 | } 25 | 26 | @AfterClass 27 | public static void shutdown() { 28 | pool.close(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/test/java/com/github/davidmarquis/redisscheduler/LettuceIntegrationTest.java: -------------------------------------------------------------------------------- 1 | package com.github.davidmarquis.redisscheduler; 2 | 3 | import com.github.davidmarquis.redisscheduler.drivers.lettuce.LettuceDriver; 4 | import io.lettuce.core.RedisClient; 5 | import io.lettuce.core.RedisURI; 6 | import org.junit.After; 7 | import org.junit.AfterClass; 8 | 9 | public class LettuceIntegrationTest extends AcceptanceTestSuite { 10 | 11 | private static final RedisClient client = RedisClient.create(RedisURI.create("localhost", 6379)); 12 | 13 | @Override 14 | protected void provideActors() { 15 | scheduler = new RedisTaskScheduler(new LettuceDriver(client), taskTriggerListener); 16 | scheduler.setSchedulerName("lettuce-scheduler"); 17 | scheduler.setClock(clock); 18 | scheduler.setPollingDelayMillis(50); 19 | scheduler.start(); 20 | } 21 | 22 | @After 23 | public void stopScheduler() { 24 | scheduler.stop(); 25 | } 26 | 27 | @AfterClass 28 | public static void shutdown() { 29 | client.shutdown(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/test/java/com/github/davidmarquis/redisscheduler/RedisTaskSchedulerTest.java: -------------------------------------------------------------------------------- 1 | package com.github.davidmarquis.redisscheduler; 2 | 3 | import org.junit.After; 4 | import org.junit.Before; 5 | import org.junit.Test; 6 | import org.junit.runner.RunWith; 7 | import org.mockito.Mock; 8 | import org.mockito.runners.MockitoJUnitRunner; 9 | 10 | import java.util.function.Function; 11 | 12 | import static org.mockito.Matchers.any; 13 | import static org.mockito.Mockito.*; 14 | import static org.mockito.internal.verification.VerificationModeFactory.atLeast; 15 | import static org.mockito.internal.verification.VerificationModeFactory.times; 16 | 17 | @RunWith(MockitoJUnitRunner.class) 18 | @SuppressWarnings("unchecked") 19 | public class RedisTaskSchedulerTest { 20 | 21 | private static final int MAX_RETRIES = 3; 22 | 23 | @Mock 24 | private RedisDriver driver; 25 | 26 | private RedisTaskScheduler scheduler; 27 | 28 | @Before 29 | public void setUp() { 30 | scheduler = new RedisTaskScheduler(driver, taskId -> {}); 31 | scheduler.setPollingDelayMillis(50); 32 | scheduler.setMaxRetriesOnConnectionFailure(MAX_RETRIES); 33 | } 34 | 35 | @After 36 | public void tearDown() { 37 | scheduler.stop(); 38 | } 39 | 40 | @Test 41 | public void canRetryAfterRedisConnectionError() throws InterruptedException { 42 | doThrow(RedisConnectException.class).when(driver).fetch(any(Function.class)); 43 | 44 | scheduler.start(); 45 | Thread.sleep(500); 46 | 47 | verify(driver, times(MAX_RETRIES)).fetch(any(Function.class)); 48 | } 49 | 50 | @Test 51 | public void canRecoverAfterSingleConnectionError() throws InterruptedException { 52 | when(driver.fetch(any(Function.class))) 53 | .thenThrow(RedisConnectException.class) 54 | .thenReturn(true); 55 | 56 | scheduler.start(); 57 | Thread.sleep(500); 58 | 59 | verify(driver, atLeast(MAX_RETRIES + 1)).fetch( any(Function.class)); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/test/java/com/github/davidmarquis/redisscheduler/SpringIntegrationTest.java: -------------------------------------------------------------------------------- 1 | package com.github.davidmarquis.redisscheduler; 2 | 3 | 4 | import com.github.davidmarquis.redisscheduler.lib.LatchedTriggerListener; 5 | import com.github.davidmarquis.redisscheduler.lib.StubbedClock; 6 | import org.junit.runner.RunWith; 7 | import org.springframework.beans.factory.annotation.Autowired; 8 | import org.springframework.context.ApplicationContext; 9 | import org.springframework.test.context.ContextConfiguration; 10 | import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; 11 | 12 | @RunWith(SpringJUnit4ClassRunner.class) 13 | @ContextConfiguration("/application-context-test.xml") 14 | public class SpringIntegrationTest extends AcceptanceTestSuite { 15 | 16 | @Autowired 17 | private ApplicationContext ctx; 18 | 19 | @Override 20 | protected void provideActors() { 21 | scheduler = ctx.getBean(RedisTaskScheduler.class); 22 | clock = ctx.getBean(StubbedClock.class); 23 | taskTriggerListener = ctx.getBean(LatchedTriggerListener.class); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/test/java/com/github/davidmarquis/redisscheduler/lib/LatchedTriggerListener.java: -------------------------------------------------------------------------------- 1 | package com.github.davidmarquis.redisscheduler.lib; 2 | 3 | import com.github.davidmarquis.redisscheduler.TaskTriggerListener; 4 | 5 | import java.util.ArrayList; 6 | import java.util.List; 7 | import java.util.concurrent.CountDownLatch; 8 | import java.util.concurrent.TimeUnit; 9 | 10 | /** 11 | * Special trigger listener that allows tests to wait until a certain number of tasks have been triggered 12 | * by the scheduler. This is necessary due to the asynchronous nature of the scheduler. 13 | */ 14 | public class LatchedTriggerListener implements TaskTriggerListener { 15 | 16 | private CountDownLatch latch; 17 | private List triggeredTasks = new ArrayList<>(); 18 | 19 | @Override 20 | public void taskTriggered(String taskId) { 21 | triggeredTasks.add(taskId); 22 | 23 | if (latch != null) { 24 | latch.countDown(); 25 | } 26 | } 27 | 28 | public List getTriggeredTasks() { 29 | return triggeredTasks; 30 | } 31 | 32 | public void waitUntilTriggeredCount(int nTimes, int timeoutMillis) throws InterruptedException { 33 | int remainingCount = nTimes - triggeredTasks.size(); 34 | if (remainingCount < 1) { 35 | return; 36 | } 37 | 38 | latch = new CountDownLatch(remainingCount); 39 | latch.await(timeoutMillis, TimeUnit.MILLISECONDS); 40 | } 41 | 42 | public void reset() { 43 | triggeredTasks.clear(); 44 | latch = null; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/test/java/com/github/davidmarquis/redisscheduler/lib/StartRedis.java: -------------------------------------------------------------------------------- 1 | package com.github.davidmarquis.redisscheduler.lib; 2 | 3 | import org.junit.rules.ExternalResource; 4 | import redis.embedded.RedisServer; 5 | 6 | public class StartRedis extends ExternalResource { 7 | 8 | private RedisServer server; 9 | 10 | @Override 11 | protected void before() throws Throwable { 12 | server = new RedisServer(); 13 | server.start(); 14 | } 15 | 16 | @Override 17 | protected void after() { 18 | server.stop(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/test/java/com/github/davidmarquis/redisscheduler/lib/StubbedClock.java: -------------------------------------------------------------------------------- 1 | package com.github.davidmarquis.redisscheduler.lib; 2 | 3 | import java.time.*; 4 | import java.time.format.DateTimeFormatter; 5 | import java.util.concurrent.TimeUnit; 6 | 7 | public class StubbedClock extends Clock { 8 | 9 | private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd HH:mm"); 10 | 11 | private Instant now = Instant.now(); 12 | 13 | public Instant instant() { 14 | return now; 15 | } 16 | 17 | public void is(String dateTimeStr) { 18 | LocalDateTime time = LocalDateTime.parse(dateTimeStr, FORMATTER); 19 | stubTime(time.atZone(ZoneId.systemDefault()).toInstant()); 20 | } 21 | 22 | public void fastForward(int period, TimeUnit unit) { 23 | stubTime(in(period, unit)); 24 | } 25 | 26 | public Instant in(int period, TimeUnit unit) { 27 | long currentTime = now.toEpochMilli(); 28 | long newTime = currentTime + (unit.toMillis(period)); 29 | return Instant.ofEpochMilli(newTime); 30 | } 31 | 32 | private void stubTime(Instant stubbedTime) { 33 | now = stubbedTime; 34 | } 35 | 36 | public ZoneId getZone() { 37 | return null; // not used 38 | } 39 | 40 | public Clock withZone(ZoneId zone) { 41 | return null; // not used 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/test/resources/application-context-test.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /src/test/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/test/resources/test-config.properties: -------------------------------------------------------------------------------- 1 | test.redis.host=localhost 2 | test.redis.port=6379 3 | test.redis.database=8 --------------------------------------------------------------------------------