├── .github └── workflows │ └── maven.yml ├── .gitignore ├── LICENSE ├── README.md ├── pom.xml └── src ├── main └── java │ └── com │ └── coreoz │ └── wisp │ ├── Job.java │ ├── JobStatus.java │ ├── LongRunningJobMonitor.java │ ├── ScalingThreadPoolExecutor.java │ ├── Scheduler.java │ ├── SchedulerConfig.java │ ├── WispThreadFactory.java │ ├── schedule │ ├── AfterInitialDelaySchedule.java │ ├── FixedDelaySchedule.java │ ├── FixedFrequencySchedule.java │ ├── FixedHourSchedule.java │ ├── OnceSchedule.java │ ├── Schedule.java │ ├── Schedules.java │ └── cron │ │ ├── CronExpressionSchedule.java │ │ └── CronSchedule.java │ ├── stats │ ├── SchedulerStats.java │ └── ThreadPoolStats.java │ └── time │ ├── SystemTimeProvider.java │ └── TimeProvider.java └── test ├── java └── com │ └── coreoz │ └── wisp │ ├── LongRunningJobMonitorTest.java │ ├── SchedulerCancelTest.java │ ├── SchedulerShutdownTest.java │ ├── SchedulerTest.java │ ├── SchedulerThreadPoolTest.java │ ├── Utils.java │ └── schedule │ ├── AfterInitialDelayScheduleTest.java │ ├── FixedFrequencyScheduleTest.java │ ├── FixedHourScheduleTest.java │ ├── OnceScheduleTest.java │ └── cron │ ├── CronExpressionScheduleTest.java │ └── CronScheduleTest.java └── resources └── logback.xml /.github/workflows/maven.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Java project with Maven, and cache/restore any dependencies to improve the workflow execution time 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-maven 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Java CI with Maven 10 | 11 | on: 12 | push: 13 | branches: [ "master" ] 14 | pull_request: 15 | branches: [ "master" ] 16 | 17 | jobs: 18 | build: 19 | 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - uses: actions/checkout@v3 24 | 25 | - name: Set up JDK 26 | uses: actions/setup-java@v3 27 | with: 28 | java-version: '21' 29 | distribution: 'temurin' 30 | cache: maven 31 | - name: Build & test with Maven and coverage 32 | run: mvn clean test jacoco:report 33 | 34 | # Optional: Uploads the full dependency graph to GitHub to improve the quality of Dependabot alerts this repository can receive 35 | - name: Update dependency graph 36 | uses: advanced-security/maven-dependency-submission-action@v4 37 | 38 | - name: Add coverage to PR 39 | id: jacoco 40 | uses: madrapps/jacoco-report@v1.3 41 | with: 42 | paths: ${{ github.workspace }}/target/site/jacoco/jacoco.xml 43 | token: ${{ secrets.GITHUB_TOKEN }} 44 | min-coverage-overall: 80 45 | min-coverage-changed-files: 90 46 | 47 | - name: Coveralls 48 | uses: coverallsapp/github-action@v2 49 | with: 50 | github-token: ${{ secrets.GITHUB_TOKEN }} 51 | format: jacoco 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Eclipse 2 | .classpath 3 | .project 4 | .settings/ 5 | bin/ 6 | .factorypath 7 | .README.md.html 8 | 9 | # Intellij 10 | .idea/ 11 | *.iml 12 | *.iws 13 | 14 | # Mac 15 | .DS_Store 16 | 17 | # Maven 18 | log/ 19 | target/ 20 | 21 | *.xlsx 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Wisp Scheduler 2 | ============== 3 | 4 | [![Build Status](https://github.com/Coreoz/Wisp/actions/workflows/maven.yml/badge.svg)](./actions) 5 | [![Coverage Status](https://coveralls.io/repos/github/Coreoz/Wisp/badge.svg?branch=master)](https://coveralls.io/github/Coreoz/Wisp?branch=master) 6 | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/com.coreoz/wisp/badge.svg)](https://maven-badges.herokuapp.com/maven-central/com.coreoz/wisp) 7 | 8 | Wisp is a library for managing the execution of recurring Java jobs. 9 | It works like the Java class `ScheduledThreadPoolExecutor`, but it comes with some advanced features: 10 | - [Jobs can be scheduled to run](#schedules) according to: a fixed hour (e.g. 00:30), a CRON expression, or a custom code-based expression, 11 | - [Statistics](#statistics) about each job execution can be retrieved, 12 | - A [too long jobs detection mechanism](#long-running-jobs-detection) can be configured, 13 | - The [thread pool can be configured to scale down](#scalable-thread-pool) when there is less jobs to execute concurrently. 14 | 15 | Wisp weighs only 30Kb and has zero dependency except SLF4J for logging. 16 | It will try to only create threads that will be used: if one thread is enough to run all the jobs, 17 | then only one thread will be created. 18 | A second thread will generally be created only when 2 jobs have to run at the same time. 19 | 20 | The scheduler precision will depend on the system load. 21 | Though a job will never be executed early, it will generally run after 1ms of the scheduled time. 22 | 23 | Wisp is compatible with Java 8 and higher. 24 | 25 | Getting started 26 | --------------- 27 | 28 | Include Wisp in your project: 29 | ```xml 30 | 31 | com.coreoz 32 | wisp 33 | 2.5.0 34 | 35 | ``` 36 | 37 | Schedule a job: 38 | ```java 39 | Scheduler scheduler = new Scheduler(); 40 | 41 | scheduler.schedule( 42 | () -> System.out.println("My first job"), // the runnable to be scheduled 43 | Schedules.fixedDelaySchedule(Duration.ofMinutes(5)) // the schedule associated to the runnable 44 | ); 45 | ``` 46 | Done! 47 | 48 | A project should generally contain only one instance of a `Scheduler`. 49 | So either a dependency injection framework handles this instance, 50 | or either a static instance of `Scheduler` should be created. 51 | 52 | In production, it is generally a good practice to configure the 53 | [monitor for long running jobs detection](#long-running-jobs-detection). 54 | 55 | Changelog and upgrade instructions 56 | ---------------------------------- 57 | All the changelog and the upgrades instructions are available 58 | in the [project releases page](https://github.com/Coreoz/Wisp/releases). 59 | 60 | Schedules 61 | --------- 62 | 63 | When a job is created or done executing, the schedule associated to the job 64 | is called to determine when the job should next be executed. 65 | There are multiple implications: 66 | - the same job will never be executed twice at a time, 67 | - if a job has to be executed at a fixed frequency, 68 | then the job has to finish running before the next execution is scheduled ; 69 | else the next execution will likely be skipped (depending of the `Schedule` implementation). 70 | 71 | ### Basics schedules 72 | Basics schedules are referenced in the `Schedules` class: 73 | - `fixedDelaySchedule(Duration)`: execute a job at a fixed delay after each execution. The delay is not guaranteed to be consistent depending on system load 74 | - `fixedFrequencySchedule(Duration)`: execute a job at a fixed frequency independent of the time the method was called and the system load (like cron) 75 | - `executeAt(String)`: execute a job at the same time every day, e.g. `executeAt("05:30")` 76 | 77 | ### Composition 78 | Schedules are very flexible and can easily be composed, e.g: 79 | - `Schedules.afterInitialDelay(Schedules.fixedDelaySchedule(Duration.ofMinutes(5)), Duration.ZERO)`: 80 | the job will be first executed ASAP and then with a fixed delay of 5 minutes between each execution, 81 | - `Schedules.executeOnce(Schedules.executeAt("05:30"))`: the job will be executed once at 05:30. 82 | - `Schedules.executeOnce(Schedules.fixedDelaySchedule(Duration.ofSeconds(10)))`: 83 | the job will be executed once 10 seconds after it has been scheduled. 84 | 85 | ### Cron 86 | Schedules can be created using [cron expressions](https://en.wikipedia.org/wiki/Cron#CRON_expression). 87 | This feature is made possible by the use of [cron library](https://github.com/frode-carlsen/cron). This library is very lightweight: it has no dependency and is made of a single Java class of 650 lines of code. 88 | 89 | So to use cron expression, this library has to be added: 90 | ```xml 91 | 92 | ch.eitchnet 93 | cron 94 | 1.6.2 95 | 96 | ``` 97 | 98 | Then to create a job which is executed every hour at the 30th minute, 99 | you can create the [schedule](#schedules) using: `CronExpressionSchedule.parse("30 * * * *")`. 100 | 101 | `CronExpressionSchedule` exposes two methods to create Cron expressions: 102 | - `CronExpressionSchedule.parse()` to parse a 5 fields Cron expression (Unix standard), so without a second field 103 | - `CronExpressionSchedule.parseWithSeconds()` to parse a 6 fields Cron expression, so the first field is the second 104 | 105 | 106 | Cron expression should be checked using a tool like: 107 | - [Cronhub](https://crontab.cronhub.io/) 108 | - [Freeformater](https://www.freeformatter.com/cron-expression-generator-quartz.html) *but be careful to not include the year field. So for the Cron expression `25 * * * * * *` (to run every minute at the second 25), the correct expression must be `25 * * * * *`* 109 | 110 | Sometimes a use case is to disable a job through configuration. This use case can be addressed by setting a Cron expression that looks up the 31st of February: 111 | - `* * 31 2 *` when used with `CronExpressionSchedule.parse()` 112 | - `* * * 31 2 *` when used with `CronExpressionSchedule.parseWithSeconds()` 113 | 114 | Cron-utils was the default Cron implementation before Wisp 2.2.2. This has [changed in version 2.3.0](/../../issues/14). 115 | Documentation about cron-utils implementation can be found at [Wisp 2.2.2](/../../tree/2.2.2#cron). 116 | Migration from cron-utils is detailed in the [release note of Wisp 2.3.0](/../../releases/tag/2.3.0). 117 | 118 | ### Custom schedules 119 | Custom schedules can be created, 120 | see the [Schedule](src/main/java/com/coreoz/wisp/schedule/Schedule.java) interface. 121 | 122 | ### Past schedule 123 | Schedules can reference a past time. 124 | However once a past time is returned by a schedule, 125 | the associated job will never be executed again. 126 | At the first execution, if a past time is referenced a warning will be logged 127 | but no exception will be raised. 128 | 129 | Statistics 130 | ---------- 131 | Two methods enable to fetch scheduler statistics: 132 | - `Scheduler.jobStatus()`: To fetch all the jobs executing on the scheduler. For each job, these data are available: 133 | - name, 134 | - status (see `JobStatus` for details), 135 | - executions count, 136 | - last execution start date, 137 | - last execution end date, 138 | - next execution date. 139 | - `Scheduler.stats()`: To fetch statistics about the underlying thread pool: 140 | - min threads, 141 | - max threads, 142 | - active threads running jobs, 143 | - idle threads, 144 | - largest thread pool size. 145 | 146 | Cleanup old terminated jobs 147 | --------------------------- 148 | The method `Scheduler.remove(String jobName)` enables to remove a jobs that is terminated, so in the `JobStatus.DONE` status. Once removed, the job is not returned anymore by `Scheduler.jobStatus()`. 149 | 150 | For an application that creates lots of jobs, to enable avoid memory leak, a cleaning job should be scheduled, for example: 151 | ```java 152 | scheduler.schedule( 153 | "Terminated jobs cleaner", 154 | () -> scheduler 155 | .jobStatus() 156 | .stream() 157 | .filter(job -> job.status() == JobStatus.DONE) 158 | // Clean only jobs that have finished executing since at least 10 seconds 159 | .filter(job -> job.lastExecutionEndedTimeInMillis() < (System.currentTimeMillis() - 10000)) 160 | .forEach(job -> scheduler.remove(job.name())), 161 | Schedules.fixedDelaySchedule(Duration.ofMinutes(10)) 162 | ); 163 | ``` 164 | 165 | Long running jobs detection 166 | --------------------------- 167 | 168 | To detect jobs that are running for too long, an optional job monitor is provided. 169 | It can be setup with: 170 | ```java 171 | scheduler.schedule( 172 | "Long running job monitor", 173 | new LongRunningJobMonitor(scheduler), 174 | Schedules.fixedDelaySchedule(Duration.ofMinutes(1)) 175 | ); 176 | ``` 177 | This way, every minute, the monitor will check for jobs that are running for more than 5 minutes. 178 | A warning message with the job stack trace will be logged for any job running for more than 5 minutes. 179 | 180 | The detection threshold can also be configured this way: `new LongRunningJobMonitor(scheduler, Duration.ofMinutes(15))` 181 | 182 | Scalable thread pool 183 | -------------------- 184 | 185 | By default the thread pool size will only grow up, from 0 to 10 threads (and not scale down). 186 | But it is also possible to define a maximum keep alive duration after which idle threads will be removed from the pool. 187 | This can be configured this way: 188 | ```java 189 | Scheduler scheduler = new Scheduler( 190 | SchedulerConfig 191 | .builder() 192 | .minThreads(2) 193 | .maxThreads(15) 194 | .threadsKeepAliveTime(Duration.ofHours(1)) 195 | .build() 196 | ); 197 | ``` 198 | In this example: 199 | - There will be always at least 2 threads to run the jobs, 200 | - The thread pool can grow up to 15 threads to run the jobs, 201 | - Idle threads for at least an hour will be removed from the pool, until the 2 minimum threads remain. 202 | 203 | Plume Framework integration 204 | --------------------------- 205 | 206 | If you are already using [Plume Framework](https://github.com/Coreoz/Plume), 207 | please take a look at [Plume Scheduler](https://github.com/Coreoz/Plume/tree/master/plume-scheduler). 208 | 209 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4.0.0 4 | 5 | com.coreoz 6 | wisp 7 | 2.5.1-SNAPSHOT 8 | jar 9 | 10 | Wisp Scheduler 11 | A simple Java Scheduler library with a minimal footprint and a straightforward API 12 | https://github.com/Coreoz/Wisp 13 | 14 | 15 | Coreoz 16 | http://coreoz.com/ 17 | 18 | 19 | 20 | 21 | Apache License, Version 2.0 22 | http://www.apache.org/licenses/LICENSE-2.0.txt 23 | repo 24 | 25 | 26 | 27 | 28 | 29 | Aurélien Manteaux 30 | amanteaux@coreoz.com 31 | Coreoz 32 | http://coreoz.com/ 33 | 34 | 35 | 36 | 37 | scm:git:git@github.com:Coreoz/Wisp.git 38 | scm:git:git@github.com:Coreoz/Wisp.git 39 | https://github.com/Coreoz/Wisp 40 | HEAD 41 | 42 | 43 | 44 | 45 | ossrh 46 | https://oss.sonatype.org/content/repositories/snapshots 47 | 48 | 49 | ossrh 50 | https://oss.sonatype.org/service/local/staging/deploy/maven2 51 | 52 | 53 | 54 | 55 | 56 | oss.public 57 | https://oss.sonatype.org/content/groups/public 58 | 59 | true 60 | 61 | 62 | 63 | maven_central 64 | Maven Central 65 | https://repo.maven.apache.org/maven2/ 66 | 67 | 68 | 69 | 70 | UTF-8 71 | 1.8 72 | 1.8 73 | -Xdoclint:none 74 | 75 | 76 | 77 | 78 | release 79 | 80 | 81 | 82 | org.apache.maven.plugins 83 | maven-source-plugin 84 | 3.0.1 85 | 86 | 87 | attach-sources 88 | 89 | jar-no-fork 90 | 91 | 92 | 93 | 94 | 95 | org.apache.maven.plugins 96 | maven-jar-plugin 97 | 98 | 99 | 100 | com.coreoz.wisp 101 | 102 | 103 | 104 | 105 | 106 | org.apache.maven.plugins 107 | maven-javadoc-plugin 108 | 2.10.4 109 | 110 | 111 | attach-javadocs 112 | 113 | jar 114 | 115 | 116 | 117 | 118 | java 119 | 120 | 121 | 122 | org.apache.maven.plugins 123 | maven-gpg-plugin 124 | 3.2.5 125 | 126 | 127 | sign-artifacts 128 | verify 129 | 130 | sign 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | org.apache.maven.plugins 144 | maven-release-plugin 145 | 2.5.3 146 | 147 | true 148 | false 149 | release 150 | deploy 151 | @{project.version} 152 | 153 | 154 | 155 | org.eluder.coveralls 156 | coveralls-maven-plugin 157 | 4.3.0 158 | 159 | 160 | org.jacoco 161 | jacoco-maven-plugin 162 | 0.8.12 163 | 164 | 165 | prepare-agent 166 | 167 | prepare-agent 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | org.slf4j 178 | slf4j-api 179 | 2.0.16 180 | 181 | 182 | 183 | org.projectlombok 184 | lombok 185 | 1.18.34 186 | provided 187 | 188 | 189 | 190 | com.cronutils 191 | cron-utils 192 | 9.2.1 193 | true 194 | 195 | 196 | ch.eitchnet 197 | cron 198 | 1.6.2 199 | true 200 | 201 | 202 | 203 | junit 204 | junit 205 | 4.13.2 206 | test 207 | 208 | 209 | org.assertj 210 | assertj-core 211 | 3.26.3 212 | test 213 | 214 | 215 | ch.qos.logback 216 | logback-classic 217 | 1.3.14 218 | test 219 | 220 | 221 | 222 | 223 | -------------------------------------------------------------------------------- /src/main/java/com/coreoz/wisp/Job.java: -------------------------------------------------------------------------------- 1 | package com.coreoz.wisp; 2 | 3 | import java.time.Instant; 4 | 5 | import com.coreoz.wisp.schedule.Schedule; 6 | 7 | /** 8 | * A {@code Job} is the association of a {@link Runnable} process 9 | * and its running {@link Schedule}.
10 | *
11 | * A {@code Job} also contains information about its status and its running 12 | * statistics. 13 | */ 14 | public class Job { 15 | 16 | private JobStatus status; 17 | private volatile long nextExecutionTimeInMillis; 18 | private volatile int executionsCount; 19 | private Long lastExecutionStartedTimeInMillis; 20 | private Long lastExecutionEndedTimeInMillis; 21 | private Thread threadRunningJob; 22 | private final String name; 23 | private Schedule schedule; 24 | private final Runnable runnable; 25 | private Runnable runningJob; 26 | 27 | // public API 28 | 29 | public JobStatus status() { 30 | return status; 31 | } 32 | 33 | public long nextExecutionTimeInMillis() { 34 | return nextExecutionTimeInMillis; 35 | } 36 | 37 | public int executionsCount() { 38 | return executionsCount; 39 | } 40 | 41 | /** 42 | * The timestamp of when the job has last been started. 43 | */ 44 | public Long lastExecutionStartedTimeInMillis() { 45 | return lastExecutionStartedTimeInMillis; 46 | } 47 | 48 | /** 49 | * The timestamp of when the job has last been started. 50 | * 51 | * @deprecated Use {@link #lastExecutionStartedTimeInMillis()}. 52 | * This method will be deleted in version 3.0.0. 53 | */ 54 | @Deprecated 55 | public Long timeInMillisSinceJobRunning() { 56 | return lastExecutionStartedTimeInMillis; 57 | } 58 | 59 | /** 60 | * The timestamp of when the job has last finished executing. 61 | */ 62 | public Long lastExecutionEndedTimeInMillis() { 63 | return lastExecutionEndedTimeInMillis; 64 | } 65 | 66 | /** 67 | * The timestamp of when the job has last finished executing. 68 | * @deprecated Use {@link #lastExecutionEndedTimeInMillis()}. 69 | * This method will be deleted in version 3.0.0. 70 | */ 71 | @Deprecated 72 | public Long lastExecutionTimeInMillis() { 73 | return lastExecutionEndedTimeInMillis; 74 | } 75 | 76 | public Thread threadRunningJob() { 77 | return threadRunningJob; 78 | } 79 | 80 | public String name() { 81 | return name; 82 | } 83 | 84 | public Schedule schedule() { 85 | return schedule; 86 | } 87 | 88 | public Runnable runnable() { 89 | return runnable; 90 | } 91 | 92 | // package API 93 | 94 | Job(JobStatus status, long nextExecutionTimeInMillis, int executionsCount, 95 | Long lastExecutionStartedTimeInMillis, Long lastExecutionEndedTimeInMillis, 96 | String name, Schedule schedule, Runnable runnable) { 97 | this.status = status; 98 | this.nextExecutionTimeInMillis = nextExecutionTimeInMillis; 99 | this.executionsCount = executionsCount; 100 | this.lastExecutionStartedTimeInMillis = lastExecutionStartedTimeInMillis; 101 | this.lastExecutionEndedTimeInMillis = lastExecutionEndedTimeInMillis; 102 | this.name = name; 103 | this.schedule = schedule; 104 | this.runnable = runnable; 105 | } 106 | 107 | void status(JobStatus status) { 108 | this.status = status; 109 | } 110 | 111 | void nextExecutionTimeInMillis(long nextExecutionTimeInMillis) { 112 | this.nextExecutionTimeInMillis = nextExecutionTimeInMillis; 113 | } 114 | 115 | void executionsCount(int executionsCount) { 116 | this.executionsCount = executionsCount; 117 | } 118 | 119 | void lastExecutionStartedTimeInMillis(Long lastExecutionStartedTimeInMillis) { 120 | this.lastExecutionStartedTimeInMillis = lastExecutionStartedTimeInMillis; 121 | } 122 | 123 | void lastExecutionEndedTimeInMillis(Long lastExecutionEndedTimeInMillis) { 124 | this.lastExecutionEndedTimeInMillis = lastExecutionEndedTimeInMillis; 125 | } 126 | 127 | void threadRunningJob(Thread threadRunningJob) { 128 | this.threadRunningJob = threadRunningJob; 129 | } 130 | 131 | void schedule(Schedule schedule) { 132 | this.schedule = schedule; 133 | } 134 | 135 | void runningJob(Runnable runningJob) { 136 | this.runningJob = runningJob; 137 | } 138 | 139 | Runnable runningJob() { 140 | return runningJob; 141 | } 142 | 143 | // toString 144 | 145 | @Override 146 | public String toString() { 147 | return "Job " + name + " [" + status + "] - will run " + schedule 148 | + " - next execution at " + Instant.ofEpochMilli(nextExecutionTimeInMillis); 149 | } 150 | 151 | } 152 | -------------------------------------------------------------------------------- /src/main/java/com/coreoz/wisp/JobStatus.java: -------------------------------------------------------------------------------- 1 | package com.coreoz.wisp; 2 | 3 | /** 4 | * Describe a {@link Job} state 5 | */ 6 | public enum JobStatus { 7 | 8 | /** 9 | * The job will not be run ever again 10 | */ 11 | DONE, 12 | /** 13 | * The job will be executed at his scheduled time 14 | */ 15 | SCHEDULED, 16 | /** 17 | * This is an intermediate status before {@link #RUNNING}, 18 | * it means that the job is placed on the thread pool executor 19 | * and is waiting to be executed as soon as a thread is available. 20 | * This status should not last more than a few µs/ms except if the 21 | * thread pool is full and running tasks are not terminating. 22 | */ 23 | READY, 24 | /** 25 | * The job is currently running 26 | */ 27 | RUNNING, 28 | 29 | } 30 | -------------------------------------------------------------------------------- /src/main/java/com/coreoz/wisp/LongRunningJobMonitor.java: -------------------------------------------------------------------------------- 1 | package com.coreoz.wisp; 2 | 3 | import java.time.Duration; 4 | import java.util.HashMap; 5 | import java.util.Map; 6 | import java.util.stream.Collectors; 7 | import java.util.stream.Stream; 8 | 9 | import org.slf4j.Logger; 10 | import org.slf4j.LoggerFactory; 11 | 12 | import com.coreoz.wisp.time.TimeProvider; 13 | 14 | import lombok.AllArgsConstructor; 15 | 16 | /** 17 | * Detect jobs that are running for too long. 18 | * When a job is running for too long, a warning message is being logged. 19 | */ 20 | public class LongRunningJobMonitor implements Runnable { 21 | 22 | private static final Logger logger = LoggerFactory.getLogger(LongRunningJobMonitor.class); 23 | 24 | public static final Duration DEFAULT_THRESHOLD_DETECTION = Duration.ofMinutes(5); 25 | 26 | private final Scheduler scheduler; 27 | private final TimeProvider timeProvider; 28 | private final long detectionThresholdInMillis; 29 | private final Map longRunningJobs; 30 | 31 | /** 32 | * Create a new {@link LongRunningJobMonitor}. 33 | * @param scheduler The scheduler that will be monitored 34 | * @param detectionThreshold The threshold after which a message will be logged if the job takes longer to execute 35 | * @param timeProvider The time provider used to calculate long running jobs 36 | */ 37 | public LongRunningJobMonitor(Scheduler scheduler, Duration detectionThreshold, TimeProvider timeProvider) { 38 | this.scheduler = scheduler; 39 | this.timeProvider = timeProvider; 40 | this.detectionThresholdInMillis = detectionThreshold.toMillis(); 41 | 42 | this.longRunningJobs = new HashMap<>(); 43 | } 44 | 45 | /** 46 | * Create a new {@link LongRunningJobMonitor}. 47 | * @param scheduler The scheduler that will be monitored 48 | * @param detectionThreshold The threshold after which a message will be logged if the job takes longer to execute 49 | */ 50 | public LongRunningJobMonitor(Scheduler scheduler, Duration detectionThreshold) { 51 | this(scheduler, detectionThreshold, SchedulerConfig.DEFAULT_TIME_PROVIDER); 52 | } 53 | 54 | /** 55 | * Create a new {@link LongRunningJobMonitor} with a 5 minutes detection threshold. 56 | * @param scheduler The scheduler that will be monitored 57 | */ 58 | public LongRunningJobMonitor(Scheduler scheduler) { 59 | this(scheduler, DEFAULT_THRESHOLD_DETECTION, SchedulerConfig.DEFAULT_TIME_PROVIDER); 60 | } 61 | 62 | /** 63 | * Run the too long jobs detection on the {@link Scheduler}. 64 | * This method is *not* thread safe. 65 | */ 66 | @Override 67 | public void run() { 68 | long currentTime = timeProvider.currentTime(); 69 | for(Job job : scheduler.jobStatus()) { 70 | cleanUpLongJobIfItHasFinishedExecuting(currentTime, job); 71 | detectLongRunningJob(currentTime, job); 72 | } 73 | } 74 | 75 | /** 76 | * Check whether a job is running for too long or not. 77 | * 78 | * @return true if the is running for too long, else false. 79 | * Returned value is made available for testing purposes. 80 | */ 81 | boolean detectLongRunningJob(long currentTime, Job job) { 82 | if(job.status() == JobStatus.RUNNING && !longRunningJobs.containsKey(job)) { 83 | int jobExecutionsCount = job.executionsCount(); 84 | Long jobStartedtimeInMillis = job.lastExecutionStartedTimeInMillis(); 85 | Thread threadRunningJob = job.threadRunningJob(); 86 | 87 | if(jobStartedtimeInMillis != null 88 | && threadRunningJob != null 89 | && currentTime - jobStartedtimeInMillis > detectionThresholdInMillis) { 90 | logger.warn( 91 | "Job '{}' is still running after {}ms (detection threshold = {}ms), stack trace = {}", 92 | job.name(), 93 | currentTime - jobStartedtimeInMillis, 94 | detectionThresholdInMillis, 95 | Stream 96 | .of(threadRunningJob.getStackTrace()) 97 | .map(StackTraceElement::toString) 98 | .collect(Collectors.joining("\n ")) 99 | ); 100 | 101 | longRunningJobs.put( 102 | job, 103 | new LongRunningJobInfo(jobStartedtimeInMillis, jobExecutionsCount) 104 | ); 105 | 106 | return true; 107 | } 108 | } 109 | return false; 110 | } 111 | 112 | /** 113 | * cleanup jobs that have finished executing after {@link #thresholdDetectionInMillis} 114 | */ 115 | Long cleanUpLongJobIfItHasFinishedExecuting(long currentTime, Job job) { 116 | if(longRunningJobs.containsKey(job) 117 | && longRunningJobs.get(job).executionsCount != job.executionsCount()) { 118 | Long jobLastExecutionTimeInMillis = job.lastExecutionEndedTimeInMillis(); 119 | int jobExecutionsCount = job.executionsCount(); 120 | LongRunningJobInfo jobRunningInfo = longRunningJobs.get(job); 121 | 122 | long jobExecutionDuration = 0L; 123 | if(jobExecutionsCount == jobRunningInfo.executionsCount + 1) { 124 | jobExecutionDuration = jobLastExecutionTimeInMillis - jobRunningInfo.jobStartedtimeInMillis; 125 | logger.info( 126 | "Job '{}' has finished executing after {}ms", 127 | job.name(), 128 | jobExecutionDuration 129 | ); 130 | } else { 131 | jobExecutionDuration = currentTime - jobRunningInfo.jobStartedtimeInMillis; 132 | logger.info( 133 | "Job '{}' has finished executing after about {}ms", 134 | job.name(), 135 | jobExecutionDuration 136 | ); 137 | } 138 | 139 | longRunningJobs.remove(job); 140 | return jobExecutionDuration; 141 | } 142 | 143 | return null; 144 | } 145 | 146 | @AllArgsConstructor 147 | private static class LongRunningJobInfo { 148 | final long jobStartedtimeInMillis; 149 | final int executionsCount; 150 | } 151 | 152 | } 153 | -------------------------------------------------------------------------------- /src/main/java/com/coreoz/wisp/ScalingThreadPoolExecutor.java: -------------------------------------------------------------------------------- 1 | package com.coreoz.wisp; 2 | 3 | import java.util.Collection; 4 | import java.util.Iterator; 5 | import java.util.concurrent.LinkedTransferQueue; 6 | import java.util.concurrent.RejectedExecutionException; 7 | import java.util.concurrent.RejectedExecutionHandler; 8 | import java.util.concurrent.ThreadFactory; 9 | import java.util.concurrent.ThreadPoolExecutor; 10 | import java.util.concurrent.TimeUnit; 11 | import java.util.concurrent.TransferQueue; 12 | 13 | /** 14 | * A thread pool executor that will reuse newly created threads 15 | * instead of building them when {@code maxPoolSize} is reached.
16 | *
17 | * Credits to Mohammad Nadeem. 18 | * @see 19 | * https://reachmnadeem.wordpress.com/2017/01/15/scalable-java-tpe/ 20 | * 21 | */ 22 | final class ScalingThreadPoolExecutor extends ThreadPoolExecutor { 23 | 24 | ScalingThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, 25 | TimeUnit keepAliveUnit, ThreadFactory threadFactory) { 26 | super(corePoolSize, maximumPoolSize, keepAliveTime, keepAliveUnit, 27 | new DynamicBlockingQueue<>(new LinkedTransferQueue()), threadFactory, new ForceQueuePolicy()); 28 | } 29 | 30 | @Override 31 | public void setRejectedExecutionHandler(RejectedExecutionHandler handler) { 32 | throw new IllegalArgumentException("Cant set rejection handler"); 33 | } 34 | 35 | private static class ForceQueuePolicy implements RejectedExecutionHandler { 36 | @Override 37 | public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { 38 | try { 39 | // Rejected work add to Queue. 40 | executor.getQueue().put(r); 41 | } catch (InterruptedException e) { 42 | // should never happen since we never wait 43 | throw new RejectedExecutionException(e); 44 | } 45 | } 46 | } 47 | 48 | private static class DynamicBlockingQueue implements TransferQueue { 49 | private final TransferQueue delegate; 50 | 51 | public DynamicBlockingQueue(final TransferQueue delegate) { 52 | this.delegate = delegate; 53 | } 54 | 55 | @Override 56 | public boolean offer(E o) { 57 | return tryTransfer(o); 58 | } 59 | 60 | @Override 61 | public boolean add(E o) { 62 | if (this.delegate.add(o)) { 63 | return true; 64 | } else {// Not possible in our case 65 | throw new IllegalStateException("Queue full"); 66 | } 67 | } 68 | 69 | @Override 70 | public E remove() { 71 | return this.delegate.remove(); 72 | } 73 | 74 | @Override 75 | public E poll() { 76 | return this.delegate.poll(); 77 | } 78 | 79 | @Override 80 | public E element() { 81 | return this.delegate.element(); 82 | } 83 | 84 | @Override 85 | public E peek() { 86 | return this.delegate.peek(); 87 | } 88 | 89 | @Override 90 | public int size() { 91 | return this.delegate.size(); 92 | } 93 | 94 | @Override 95 | public boolean isEmpty() { 96 | return this.delegate.isEmpty(); 97 | } 98 | 99 | @Override 100 | public Iterator iterator() { 101 | return this.delegate.iterator(); 102 | } 103 | 104 | @Override 105 | public Object[] toArray() { 106 | return this.delegate.toArray(); 107 | } 108 | 109 | @Override 110 | public T[] toArray(T[] a) { 111 | return this.delegate.toArray(a); 112 | } 113 | 114 | @Override 115 | public boolean containsAll(Collection c) { 116 | return this.delegate.containsAll(c); 117 | } 118 | 119 | @Override 120 | public boolean addAll(Collection c) { 121 | return this.delegate.addAll(c); 122 | } 123 | 124 | @Override 125 | public boolean removeAll(Collection c) { 126 | return this.delegate.removeAll(c); 127 | } 128 | 129 | @Override 130 | public boolean retainAll(Collection c) { 131 | return this.delegate.retainAll(c); 132 | } 133 | 134 | @Override 135 | public void clear() { 136 | this.delegate.clear(); 137 | } 138 | 139 | @Override 140 | public void put(E e) throws InterruptedException { 141 | this.delegate.put(e); 142 | } 143 | 144 | @Override 145 | public boolean offer(E e, long timeout, TimeUnit unit) throws InterruptedException { 146 | return this.delegate.offer(e, timeout, unit); 147 | } 148 | 149 | @Override 150 | public E take() throws InterruptedException { 151 | return this.delegate.take(); 152 | } 153 | 154 | @Override 155 | public E poll(long timeout, TimeUnit unit) throws InterruptedException { 156 | return this.delegate.poll(timeout, unit); 157 | } 158 | 159 | @Override 160 | public int remainingCapacity() { 161 | return this.delegate.remainingCapacity(); 162 | } 163 | 164 | @Override 165 | public boolean remove(Object o) { 166 | return this.delegate.remove(o); 167 | } 168 | 169 | @Override 170 | public boolean contains(Object o) { 171 | return this.delegate.contains(o); 172 | } 173 | 174 | @Override 175 | public int drainTo(Collection c) { 176 | return this.delegate.drainTo(c); 177 | } 178 | 179 | @Override 180 | public int drainTo(Collection c, int maxElements) { 181 | return this.delegate.drainTo(c, maxElements); 182 | } 183 | 184 | @Override 185 | public boolean tryTransfer(E e) { 186 | return this.delegate.tryTransfer(e); 187 | } 188 | 189 | @Override 190 | public void transfer(E e) throws InterruptedException { 191 | this.delegate.transfer(e); 192 | } 193 | 194 | @Override 195 | public boolean tryTransfer(E e, long timeout, TimeUnit unit) throws InterruptedException { 196 | return this.delegate.tryTransfer(e, timeout, unit); 197 | } 198 | 199 | @Override 200 | public boolean hasWaitingConsumer() { 201 | return this.delegate.hasWaitingConsumer(); 202 | } 203 | 204 | @Override 205 | public int getWaitingConsumerCount() { 206 | return this.delegate.getWaitingConsumerCount(); 207 | } 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /src/main/java/com/coreoz/wisp/Scheduler.java: -------------------------------------------------------------------------------- 1 | package com.coreoz.wisp; 2 | 3 | import java.time.Duration; 4 | import java.time.Instant; 5 | import java.util.ArrayList; 6 | import java.util.Collection; 7 | import java.util.Comparator; 8 | import java.util.Iterator; 9 | import java.util.Map; 10 | import java.util.Objects; 11 | import java.util.Optional; 12 | import java.util.concurrent.CompletableFuture; 13 | import java.util.concurrent.CompletionStage; 14 | import java.util.concurrent.ConcurrentHashMap; 15 | import java.util.concurrent.ThreadPoolExecutor; 16 | import java.util.concurrent.TimeUnit; 17 | import java.util.concurrent.atomic.AtomicBoolean; 18 | 19 | import org.slf4j.Logger; 20 | import org.slf4j.LoggerFactory; 21 | 22 | import com.coreoz.wisp.schedule.Schedule; 23 | import com.coreoz.wisp.stats.SchedulerStats; 24 | import com.coreoz.wisp.stats.ThreadPoolStats; 25 | import com.coreoz.wisp.time.SystemTimeProvider; 26 | import com.coreoz.wisp.time.TimeProvider; 27 | 28 | import lombok.SneakyThrows; 29 | 30 | /** 31 | * A {@code Scheduler} instance reference a group of jobs 32 | * and is responsible to schedule these jobs at the expected time.
33 | *
34 | * A job is executed only once at a time. 35 | * The scheduler will never execute the same job twice at a time. 36 | */ 37 | public final class Scheduler implements AutoCloseable { 38 | private static final Logger logger = LoggerFactory.getLogger(Scheduler.class); 39 | 40 | /** 41 | * @deprecated Default values are available in {@link SchedulerConfig} 42 | * It will be deleted in version 3.0.0. 43 | */ 44 | @Deprecated 45 | public static final int DEFAULT_THREAD_POOL_SIZE = 10; 46 | /** 47 | * @deprecated This value is not used anymore 48 | * It will be deleted in version 3.0.0. 49 | */ 50 | @Deprecated 51 | public static final long DEFAULT_MINIMUM_DELAY_IN_MILLIS_TO_REPLACE_JOB = 10L; 52 | 53 | private final ThreadPoolExecutor threadPoolExecutor; 54 | private final TimeProvider timeProvider; 55 | private final AtomicBoolean launcherNotifier; 56 | 57 | // jobs 58 | private final Map indexedJobsByName; 59 | private final ArrayList nextExecutionsOrder; 60 | private final Map> cancelHandles; 61 | 62 | private volatile boolean shuttingDown; 63 | 64 | // constructors 65 | 66 | /** 67 | * Create a scheduler with the defaults defined at {@link SchedulerConfig} 68 | */ 69 | public Scheduler() { 70 | this(SchedulerConfig.builder().build()); 71 | } 72 | 73 | /** 74 | * Create a scheduler with the defaults defined at {@link SchedulerConfig} 75 | * and with a max number of worker threads 76 | * @param maxThreads The maximum number of worker threads that can be created for the scheduler. 77 | * @throws IllegalArgumentException if {@code maxThreads <= 0} 78 | */ 79 | public Scheduler(int maxThreads) { 80 | this(SchedulerConfig.builder().maxThreads(maxThreads).build()); 81 | } 82 | 83 | /** 84 | * Create a scheduler according to the configuration 85 | * @throws IllegalArgumentException if one of the following holds:
86 | * {@code SchedulerConfig#getMinThreads() < 0}
87 | * {@code SchedulerConfig#getThreadsKeepAliveTime() < 0}
88 | * {@code SchedulerConfig#getMaxThreads() <= 0}
89 | * {@code SchedulerConfig#getMaxThreads() < SchedulerConfig#getMinThreads()} 90 | * @throws NullPointerException if {@code SchedulerConfig#getTimeProvider()} is {@code null} 91 | */ 92 | public Scheduler(SchedulerConfig config) { 93 | if(config.getTimeProvider() == null) { 94 | throw new NullPointerException("The timeProvider cannot be null"); 95 | } 96 | 97 | this.indexedJobsByName = new ConcurrentHashMap<>(); 98 | this.nextExecutionsOrder = new ArrayList<>(); 99 | this.timeProvider = config.getTimeProvider(); 100 | this.launcherNotifier = new AtomicBoolean(true); 101 | this.cancelHandles = new ConcurrentHashMap<>(); 102 | this.threadPoolExecutor = new ScalingThreadPoolExecutor( 103 | config.getMinThreads(), 104 | config.getMaxThreads(), 105 | config.getThreadsKeepAliveTime().toMillis(), 106 | TimeUnit.MILLISECONDS, 107 | config.getThreadFactory().get() 108 | ); 109 | // run job launcher thread 110 | Thread launcherThread = new Thread(this::launcher, "Wisp Monitor"); 111 | if (launcherThread.isDaemon()) { 112 | launcherThread.setDaemon(false); 113 | } 114 | launcherThread.start(); 115 | } 116 | 117 | /** 118 | * @deprecated Use {@link #Scheduler(SchedulerConfig)} to specify multiple configuration values. 119 | * It will be deleted in version 3.0.0. 120 | * @throws IllegalArgumentException if {@code maxThreads <= 0} 121 | */ 122 | @Deprecated 123 | public Scheduler(int maxThreads, long minimumDelayInMillisToReplaceJob) { 124 | this(maxThreads, minimumDelayInMillisToReplaceJob, new SystemTimeProvider()); 125 | } 126 | 127 | /** 128 | * @deprecated Use {@link #Scheduler(SchedulerConfig)} to specify multiple configuration values. 129 | * It will be deleted in version 3.0.0. 130 | * @throws IllegalArgumentException if {@code maxThreads <= 0} 131 | * @throws NullPointerException if {@code timeProvider} is {@code null} 132 | */ 133 | @Deprecated 134 | public Scheduler(int maxThreads, long minimumDelayInMillisToReplaceJob, 135 | TimeProvider timeProvider) { 136 | this(SchedulerConfig 137 | .builder() 138 | .maxThreads(maxThreads) 139 | .timeProvider(timeProvider) 140 | .build() 141 | ); 142 | } 143 | 144 | // public API 145 | 146 | /** 147 | * Schedule the executions of a process. 148 | * 149 | * @param runnable The process to be executed at a schedule 150 | * @param when The {@link Schedule} at which the process will be executed 151 | * @return The corresponding {@link Job} created. 152 | * @throws NullPointerException if {@code runnable} or {@code when} are {@code null} 153 | * @throws IllegalArgumentException if the same instance of {@code runnable} is 154 | * scheduled twice whereas the corresponding job status is not {@link JobStatus#DONE} 155 | */ 156 | public Job schedule(Runnable runnable, Schedule when) { 157 | return schedule(null, runnable, when); 158 | } 159 | 160 | /** 161 | * Schedule the executions of a process.
162 | *
163 | * If a job already exists with the same name and has the status {@link JobStatus#DONE}, 164 | * then the created job will inherit the stats of the existing done job: 165 | * {@link Job#executionsCount()} and {@link Job#lastExecutionTimeInMillis()} 166 | * 167 | * @param nullableName The name of the created job 168 | * @param runnable The process to be executed at a schedule 169 | * @param when The {@link Schedule} at which the process will be executed 170 | * @return The corresponding {@link Job} created. 171 | * @throws NullPointerException if {@code runnable} or {@code when} are {@code null} 172 | * @throws IllegalArgumentException if the same {@code nullableName} is 173 | * scheduled twice whereas the corresponding job status is not {@link JobStatus#DONE} 174 | */ 175 | public Job schedule(String nullableName, Runnable runnable, Schedule when) { 176 | Objects.requireNonNull(runnable, "Runnable must not be null"); 177 | Objects.requireNonNull(when, "Schedule must not be null"); 178 | 179 | String name = nullableName == null ? runnable.toString() : nullableName; 180 | 181 | Job job = prepareJob(name, runnable, when); 182 | long currentTimeInMillis = timeProvider.currentTime(); 183 | if(when.nextExecutionInMillis( 184 | currentTimeInMillis, 185 | job.executionsCount(), 186 | job.lastExecutionEndedTimeInMillis() 187 | ) < currentTimeInMillis) { 188 | logger.warn("The job '{}' is scheduled at a paste date: it will never be executed", name); 189 | } 190 | 191 | logger.info("Scheduling job '{}' to run {}", job.name(), job.schedule()); 192 | scheduleNextExecution(job); 193 | 194 | return job; 195 | } 196 | 197 | /** 198 | * Fetch the status of all the jobs that has been registered on the {@code Scheduler} 199 | * including the {@link JobStatus#DONE} jobs 200 | */ 201 | public Collection jobStatus() { 202 | return indexedJobsByName.values(); 203 | } 204 | 205 | /** 206 | * Find a job by its name 207 | */ 208 | public Optional findJob(String name) { 209 | return Optional.ofNullable(indexedJobsByName.get(name)); 210 | } 211 | 212 | /** 213 | * Issue a cancellation order for a job and 214 | * returns immediately a promise that enables to follow the job cancellation status
215 | *
216 | * If the job is running, the scheduler will wait until it is finished to remove it 217 | * from the jobs pool. 218 | * If the job is not running, the job will just be removed from the pool.
219 | * After the job is cancelled, the job has the status {@link JobStatus#DONE}. 220 | * 221 | * @param jobName The job name to cancel 222 | * @return The promise that succeed when the job is correctly cancelled 223 | * and will not be executed again. If the job is running when cancel(String) 224 | * is called, the promise will succeed when the job has finished executing. 225 | * @throws IllegalArgumentException if there is no job corresponding to the job name. 226 | */ 227 | public CompletionStage cancel(String jobName) { 228 | Job job = findJob(jobName).orElseThrow(IllegalArgumentException::new); 229 | 230 | synchronized (this) { 231 | JobStatus jobStatus = job.status(); 232 | if(jobStatus == JobStatus.DONE) { 233 | return CompletableFuture.completedFuture(job); 234 | } 235 | CompletableFuture existingHandle = cancelHandles.get(jobName); 236 | if(existingHandle != null) { 237 | return existingHandle; 238 | } 239 | 240 | job.schedule(Schedule.willNeverBeExecuted); 241 | if(jobStatus == JobStatus.READY && threadPoolExecutor.remove(job.runningJob())) { 242 | scheduleNextExecution(job); 243 | return CompletableFuture.completedFuture(job); 244 | } 245 | 246 | if(jobStatus == JobStatus.RUNNING 247 | // if the job status is/was READY but could not be removed from the thread pool, 248 | // then we have to wait for it to finish 249 | || jobStatus == JobStatus.READY) { 250 | CompletableFuture promise = new CompletableFuture<>(); 251 | cancelHandles.put(jobName, promise); 252 | return promise; 253 | } else { 254 | for (Iterator iterator = nextExecutionsOrder.iterator(); iterator.hasNext();) { 255 | Job nextJob = iterator.next(); 256 | if(nextJob == job) { 257 | iterator.remove(); 258 | job.status(JobStatus.DONE); 259 | return CompletableFuture.completedFuture(job); 260 | } 261 | } 262 | throw new IllegalStateException( 263 | "Cannot find the job " + job + " in " + nextExecutionsOrder 264 | + ". Please open an issue on https://github.com/Coreoz/Wisp/issues" 265 | ); 266 | } 267 | } 268 | } 269 | 270 | /** 271 | * Remove a terminated job, so with the status {@link JobStatus#DONE}), 272 | * from the monitored jobs. The monitored jobs are the ones 273 | * still referenced using {@link #jobStatus()}.
274 | *
275 | * This can be useful to avoid memory leak in case many jobs 276 | * with a short lifespan are created. 277 | * @param jobName The job name to remove 278 | * @throws IllegalArgumentException If there is no job corresponding to the job name or if the job is not on the status {@link JobStatus#DONE}) 279 | */ 280 | public void remove(String jobName) { 281 | Job jobToRemove = indexedJobsByName.get(jobName); 282 | if (jobToRemove == null) { 283 | throw new IllegalArgumentException("There is no existing job with the name " + jobName); 284 | } 285 | if(jobToRemove.status() != JobStatus.DONE) { 286 | throw new IllegalArgumentException("Job is not terminated. Need to call the cancel() method before trying to remove it?"); 287 | } 288 | indexedJobsByName.remove(jobName); 289 | } 290 | 291 | /** 292 | * Wait until the current running jobs are executed 293 | * and cancel jobs that are planned to be executed. 294 | * There is a 10 seconds timeout 295 | * @throws InterruptedException if the shutdown lasts more than 10 seconds 296 | */ 297 | public void gracefullyShutdown() { 298 | gracefullyShutdown(Duration.ofSeconds(10)); 299 | } 300 | 301 | /** 302 | * Wait until the current running jobs are executed 303 | * and cancel jobs that are planned to be executed. 304 | * @param timeout The maximum time to wait 305 | * @throws InterruptedException if the shutdown lasts more than 10 seconds 306 | */ 307 | @SneakyThrows 308 | public void gracefullyShutdown(Duration timeout) { 309 | logger.info("Shutting down..."); 310 | 311 | if(!shuttingDown) { 312 | synchronized (this) { 313 | shuttingDown = true; 314 | threadPoolExecutor.shutdown(); 315 | } 316 | 317 | // stops jobs that have not yet started to be executed 318 | for(Job job : jobStatus()) { 319 | Runnable runningJob = job.runningJob(); 320 | if(runningJob != null) { 321 | threadPoolExecutor.remove(runningJob); 322 | } 323 | job.status(JobStatus.DONE); 324 | } 325 | synchronized (launcherNotifier) { 326 | launcherNotifier.set(false); 327 | launcherNotifier.notify(); 328 | } 329 | } 330 | 331 | threadPoolExecutor.awaitTermination(timeout.toMillis(), TimeUnit.MILLISECONDS); 332 | } 333 | 334 | /** 335 | * Fetch statistics about the current {@code Scheduler} 336 | */ 337 | public SchedulerStats stats() { 338 | int activeThreads = threadPoolExecutor.getActiveCount(); 339 | return SchedulerStats.of(ThreadPoolStats.of( 340 | threadPoolExecutor.getCorePoolSize(), 341 | threadPoolExecutor.getMaximumPoolSize(), 342 | activeThreads, 343 | threadPoolExecutor.getPoolSize() - activeThreads, 344 | threadPoolExecutor.getLargestPoolSize() 345 | )); 346 | } 347 | 348 | // internal 349 | 350 | private Job prepareJob(String name, Runnable runnable, Schedule when) { 351 | // lock needed to make sure 2 jobs with the same name are not submitted at the same time 352 | synchronized (indexedJobsByName) { 353 | Job lastJob = findJob(name).orElse(null); 354 | 355 | if(lastJob != null && lastJob.status() != JobStatus.DONE) { 356 | throw new IllegalArgumentException("A job is already scheduled with the name:" + name); 357 | } 358 | 359 | Job job = new Job( 360 | JobStatus.SCHEDULED, 361 | 0L, 362 | lastJob != null ? lastJob.executionsCount() : 0, 363 | lastJob != null ? lastJob.lastExecutionStartedTimeInMillis() : null, 364 | lastJob != null ? lastJob.lastExecutionEndedTimeInMillis() : null, 365 | name, 366 | when, 367 | runnable 368 | ); 369 | indexedJobsByName.put(name, job); 370 | 371 | return job; 372 | } 373 | } 374 | 375 | private synchronized void scheduleNextExecution(Job job) { 376 | // clean up 377 | job.runningJob(null); 378 | 379 | // next execution time calculation 380 | long currentTimeInMillis = timeProvider.currentTime(); 381 | try { 382 | job.nextExecutionTimeInMillis( 383 | job.schedule().nextExecutionInMillis( 384 | currentTimeInMillis, job.executionsCount(), job.lastExecutionEndedTimeInMillis() 385 | ) 386 | ); 387 | } catch (Throwable t) { 388 | logger.error( 389 | "An exception was raised during the job next execution time calculation," 390 | + " therefore the job '{}' will not be executed again.", 391 | job.name(), 392 | t 393 | ); 394 | job.nextExecutionTimeInMillis(Schedule.WILL_NOT_BE_EXECUTED_AGAIN); 395 | } 396 | 397 | // next execution planning 398 | if(job.nextExecutionTimeInMillis() >= currentTimeInMillis) { 399 | job.status(JobStatus.SCHEDULED); 400 | nextExecutionsOrder.add(job); 401 | nextExecutionsOrder.sort(Comparator.comparing( 402 | Job::nextExecutionTimeInMillis 403 | )); 404 | 405 | synchronized (launcherNotifier) { 406 | launcherNotifier.set(false); 407 | launcherNotifier.notify(); 408 | } 409 | } else { 410 | logger.info( 411 | "Job '{}' will not be executed again since its next execution time, {}ms, is planned in the past", 412 | job.name(), 413 | Instant.ofEpochMilli(job.nextExecutionTimeInMillis()) 414 | ); 415 | job.status(JobStatus.DONE); 416 | 417 | CompletableFuture cancelHandle = cancelHandles.remove(job.name()); 418 | if(cancelHandle != null) { 419 | cancelHandle.complete(job); 420 | } 421 | } 422 | } 423 | 424 | /** 425 | * The daemon that will be in charge of placing the jobs in the thread pool 426 | * when they are ready to be executed. 427 | */ 428 | @SneakyThrows 429 | private void launcher() { 430 | while(!shuttingDown) { 431 | Long timeBeforeNextExecution = null; 432 | synchronized (this) { 433 | if(nextExecutionsOrder.size() > 0) { 434 | timeBeforeNextExecution = nextExecutionsOrder.get(0).nextExecutionTimeInMillis() 435 | - timeProvider.currentTime(); 436 | } 437 | } 438 | 439 | if(timeBeforeNextExecution == null || timeBeforeNextExecution > 0L) { 440 | synchronized (launcherNotifier) { 441 | if(shuttingDown) { 442 | return; 443 | } 444 | // If someone has notified the launcher 445 | // then the launcher must check again the next job to execute. 446 | // We must be sure not to miss any changes that would have 447 | // happened after the timeBeforeNextExecution calculation. 448 | if(launcherNotifier.get()) { 449 | if(timeBeforeNextExecution == null) { 450 | launcherNotifier.wait(); 451 | } else { 452 | launcherNotifier.wait(timeBeforeNextExecution); 453 | } 454 | } 455 | launcherNotifier.set(true); 456 | } 457 | } else { 458 | synchronized (this) { 459 | if(shuttingDown) { 460 | return; 461 | } 462 | 463 | if(nextExecutionsOrder.size() > 0) { 464 | Job jobToRun = nextExecutionsOrder.remove(0); 465 | jobToRun.status(JobStatus.READY); 466 | jobToRun.runningJob(() -> runJob(jobToRun)); 467 | if(threadPoolExecutor.getActiveCount() == threadPoolExecutor.getMaximumPoolSize()) { 468 | logger.warn( 469 | "Job thread pool is full, either tasks take too much time to execute" 470 | + " or either the thread pool is too small" 471 | ); 472 | } 473 | threadPoolExecutor.execute(jobToRun.runningJob()); 474 | } 475 | } 476 | } 477 | } 478 | } 479 | 480 | /** 481 | * The wrapper around a job that will be executed in the thread pool. 482 | * It is especially in charge of logging, changing the job status 483 | * and checking for the next job to be executed. 484 | * @param jobToRun the job to execute 485 | */ 486 | private void runJob(Job jobToRun) { 487 | long startExecutionTime = timeProvider.currentTime(); 488 | long timeBeforeNextExecution = jobToRun.nextExecutionTimeInMillis() - startExecutionTime; 489 | if(timeBeforeNextExecution < 0) { 490 | logger.debug("Job '{}' execution is {}ms late", jobToRun.name(), -timeBeforeNextExecution); 491 | } 492 | jobToRun.status(JobStatus.RUNNING); 493 | jobToRun.lastExecutionStartedTimeInMillis(startExecutionTime); 494 | jobToRun.threadRunningJob(Thread.currentThread()); 495 | 496 | try { 497 | jobToRun.runnable().run(); 498 | } catch(Throwable t) { 499 | logger.error("Error during job '{}' execution", jobToRun.name(), t); 500 | } 501 | jobToRun.executionsCount(jobToRun.executionsCount() + 1); 502 | jobToRun.lastExecutionEndedTimeInMillis(timeProvider.currentTime()); 503 | jobToRun.threadRunningJob(null); 504 | 505 | if(logger.isDebugEnabled()) { 506 | logger.debug( 507 | "Job '{}' executed in {}ms", jobToRun.name(), 508 | timeProvider.currentTime() - startExecutionTime 509 | ); 510 | } 511 | 512 | if(shuttingDown) { 513 | return; 514 | } 515 | synchronized (this) { 516 | scheduleNextExecution(jobToRun); 517 | } 518 | } 519 | 520 | @Override 521 | public void close() { 522 | gracefullyShutdown(); 523 | } 524 | } 525 | -------------------------------------------------------------------------------- /src/main/java/com/coreoz/wisp/SchedulerConfig.java: -------------------------------------------------------------------------------- 1 | package com.coreoz.wisp; 2 | 3 | import java.time.Duration; 4 | import java.util.concurrent.ThreadFactory; 5 | import java.util.function.Supplier; 6 | 7 | import com.coreoz.wisp.time.SystemTimeProvider; 8 | import com.coreoz.wisp.time.TimeProvider; 9 | 10 | import lombok.Builder; 11 | import lombok.Getter; 12 | 13 | /** 14 | * The configuration used by the scheduler 15 | */ 16 | @Getter 17 | @Builder 18 | public class SchedulerConfig { 19 | public static final TimeProvider DEFAULT_TIME_PROVIDER = new SystemTimeProvider(); 20 | private static final Duration NON_EXPIRABLE_THREADS = Duration.ofMillis(Long.MAX_VALUE); 21 | 22 | /** 23 | * The minimum number of threads that will live in the jobs threads pool. 24 | */ 25 | @Builder.Default private final int minThreads = 0; 26 | /** 27 | * The maximum number of threads that will live in the jobs threads pool. 28 | */ 29 | @Builder.Default private final int maxThreads = 10; 30 | /** 31 | * The time after which idle threads will be removed from the threads pool. 32 | * By default the thread pool does not scale down (duration = infinity ~ {@link Long#MAX_VALUE}ms) 33 | */ 34 | @Builder.Default private final Duration threadsKeepAliveTime = NON_EXPIRABLE_THREADS; 35 | /** 36 | * The time provider that will be used by the scheduler 37 | */ 38 | @Builder.Default private final TimeProvider timeProvider = DEFAULT_TIME_PROVIDER; 39 | /** 40 | * The thread factory used to create worker threads on which jobs will be executed 41 | */ 42 | @Builder.Default private final Supplier threadFactory = WispThreadFactory::new; 43 | } 44 | -------------------------------------------------------------------------------- /src/main/java/com/coreoz/wisp/WispThreadFactory.java: -------------------------------------------------------------------------------- 1 | package com.coreoz.wisp; 2 | 3 | import java.util.concurrent.ThreadFactory; 4 | import java.util.concurrent.atomic.AtomicInteger; 5 | 6 | /** 7 | * The default {@link ThreadFactory} for Wisp {@link Scheduler} 8 | */ 9 | class WispThreadFactory implements ThreadFactory { 10 | private static final AtomicInteger threadCounter = new AtomicInteger(0); 11 | 12 | @Override 13 | public Thread newThread(Runnable r) { 14 | Thread thread = new Thread(r, "Wisp Scheduler Worker #" + threadCounter.getAndIncrement()); 15 | if (thread.isDaemon()) { 16 | thread.setDaemon(false); 17 | } 18 | if (thread.getPriority() != Thread.NORM_PRIORITY) { 19 | thread.setPriority(Thread.NORM_PRIORITY); 20 | } 21 | return thread; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/com/coreoz/wisp/schedule/AfterInitialDelaySchedule.java: -------------------------------------------------------------------------------- 1 | package com.coreoz.wisp.schedule; 2 | 3 | import java.time.Duration; 4 | 5 | public class AfterInitialDelaySchedule implements Schedule { 6 | 7 | private final Schedule baseSchedule; 8 | private final Duration initialDelay; 9 | private Integer initialExecutionsCount; 10 | private boolean hasNotBeenExecuted; 11 | 12 | public AfterInitialDelaySchedule(Schedule baseSchedule, Duration initialDelay) { 13 | this.baseSchedule = baseSchedule; 14 | this.initialDelay = initialDelay; 15 | this.initialExecutionsCount = null; 16 | this.hasNotBeenExecuted = true; 17 | } 18 | 19 | @Override 20 | public long nextExecutionInMillis(long currentTimeInMillis, int executionsCount, Long lastExecutionTimeInMillis) { 21 | if(initialExecutionsCount == null) { 22 | initialExecutionsCount = executionsCount; 23 | } 24 | if(initialExecutionsCount >= executionsCount) { 25 | return initialDelay.toMillis() + currentTimeInMillis; 26 | } 27 | hasNotBeenExecuted = false; 28 | return baseSchedule.nextExecutionInMillis(currentTimeInMillis, executionsCount, lastExecutionTimeInMillis); 29 | } 30 | 31 | public Schedule baseSchedule() { 32 | return baseSchedule; 33 | } 34 | 35 | @Override 36 | public String toString() { 37 | if(hasNotBeenExecuted) { 38 | return "first after " + initialDelay + ", then " + baseSchedule; 39 | } 40 | return baseSchedule.toString(); 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /src/main/java/com/coreoz/wisp/schedule/FixedDelaySchedule.java: -------------------------------------------------------------------------------- 1 | package com.coreoz.wisp.schedule; 2 | 3 | import java.time.Duration; 4 | 5 | public class FixedDelaySchedule implements Schedule { 6 | 7 | private final Duration frequency; 8 | 9 | public FixedDelaySchedule(Duration frequency) { 10 | this.frequency = frequency; 11 | } 12 | 13 | @Override 14 | public long nextExecutionInMillis(long currentTimeInMillis, int executionsCount, Long lastExecutionTimeInMillis) { 15 | return currentTimeInMillis + frequency.toMillis(); 16 | } 17 | 18 | @Override 19 | public String toString() { 20 | return "every " + frequency.toMillis() + "ms"; 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/com/coreoz/wisp/schedule/FixedFrequencySchedule.java: -------------------------------------------------------------------------------- 1 | package com.coreoz.wisp.schedule; 2 | 3 | import java.time.Duration; 4 | 5 | public class FixedFrequencySchedule implements Schedule { 6 | 7 | private final Duration frequency; 8 | 9 | public FixedFrequencySchedule(Duration frequency) { 10 | this.frequency = frequency; 11 | } 12 | 13 | @Override 14 | public long nextExecutionInMillis(long currentTimeInMillis, int executionsCount, Long lastExecutionEndedTimeInMillis) { 15 | return currentTimeInMillis + frequency.toMillis() - (currentTimeInMillis % frequency.toMillis()); 16 | } 17 | 18 | @Override 19 | public String toString() { 20 | return "every " + frequency.toMillis() + "ms"; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/java/com/coreoz/wisp/schedule/FixedHourSchedule.java: -------------------------------------------------------------------------------- 1 | package com.coreoz.wisp.schedule; 2 | 3 | import java.time.Instant; 4 | import java.time.LocalTime; 5 | import java.time.ZoneId; 6 | import java.time.ZonedDateTime; 7 | import java.time.temporal.ChronoUnit; 8 | 9 | public class FixedHourSchedule implements Schedule { 10 | 11 | private final LocalTime executionTime; 12 | private final ZoneId zoneId; 13 | 14 | /** 15 | * Parse time in the form of "hh:mm" or "hh:mm:ss" 16 | */ 17 | public FixedHourSchedule(String every) { 18 | this(LocalTime.parse(every)); 19 | } 20 | 21 | /** 22 | * Parse time in the form of "hh:mm" or "hh:mm:ss" 23 | */ 24 | public FixedHourSchedule(String every, ZoneId zoneId) { 25 | this(LocalTime.parse(every), zoneId); 26 | } 27 | 28 | public FixedHourSchedule(LocalTime every) { 29 | this(every, ZoneId.systemDefault()); 30 | } 31 | 32 | public FixedHourSchedule(LocalTime every, ZoneId zoneId) { 33 | this.executionTime = every; 34 | this.zoneId = zoneId; 35 | } 36 | 37 | public LocalTime executionTime() { 38 | return executionTime; 39 | } 40 | 41 | public ZoneId zoneId() { 42 | return zoneId; 43 | } 44 | 45 | @Override 46 | public long nextExecutionInMillis(long currentTimeInMillis, int executionsCount, Long lastExecutionTimeInMillis) { 47 | return durationUntilNextExecutionInMillis(currentTimeInMillis, lastExecutionTimeInMillis) 48 | + currentTimeInMillis; 49 | } 50 | 51 | long durationUntilNextExecutionInMillis(long currentTimeInMillis, Long lastExecutionTimeInMillis) { 52 | ZonedDateTime currentDateTime = Instant 53 | .ofEpochMilli(currentTimeInMillis) 54 | .atZone(zoneId); 55 | 56 | return currentDateTime 57 | .until( 58 | nextExecutionDateTime( 59 | currentDateTime, 60 | lastExecutionTimeInMillis != null && lastExecutionTimeInMillis == currentTimeInMillis 61 | ), 62 | ChronoUnit.MILLIS 63 | ); 64 | } 65 | 66 | private ZonedDateTime nextExecutionDateTime(ZonedDateTime currentDateTime, boolean nextExecutionShouldBeNextDay) { 67 | if(!nextExecutionShouldBeNextDay && currentDateTime.toLocalTime().compareTo(executionTime) <= 0) { 68 | return executionTime.atDate(currentDateTime.toLocalDate()).atZone(zoneId); 69 | } 70 | return executionTime.atDate(currentDateTime.toLocalDate()).plusDays(1).atZone(zoneId); 71 | } 72 | 73 | @Override 74 | public String toString() { 75 | return "at " + executionTime + " " + zoneId; 76 | } 77 | 78 | } 79 | -------------------------------------------------------------------------------- /src/main/java/com/coreoz/wisp/schedule/OnceSchedule.java: -------------------------------------------------------------------------------- 1 | package com.coreoz.wisp.schedule; 2 | 3 | public class OnceSchedule implements Schedule { 4 | 5 | private final Schedule baseSchedule; 6 | private Integer initialExecutionsCount; 7 | 8 | public OnceSchedule(Schedule baseSchedule) { 9 | this.baseSchedule = baseSchedule; 10 | this.initialExecutionsCount = null; 11 | } 12 | 13 | @Override 14 | public long nextExecutionInMillis(long currentTimeInMillis, int executionsCount, Long lastExecutionTimeInMillis) { 15 | if(initialExecutionsCount == null) { 16 | initialExecutionsCount = executionsCount; 17 | } 18 | if(initialExecutionsCount < executionsCount) { 19 | return WILL_NOT_BE_EXECUTED_AGAIN; 20 | } 21 | return baseSchedule.nextExecutionInMillis(currentTimeInMillis, executionsCount, lastExecutionTimeInMillis); 22 | } 23 | 24 | public Schedule baseSchedule() { 25 | return baseSchedule; 26 | } 27 | 28 | @Override 29 | public String toString() { 30 | return "once, " + baseSchedule; 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /src/main/java/com/coreoz/wisp/schedule/Schedule.java: -------------------------------------------------------------------------------- 1 | package com.coreoz.wisp.schedule; 2 | 3 | /** 4 | * Provide the time of the next executions of a job. 5 | * The implementations should be thread-safe. 6 | * Moreover the same instance of a schedule should be usable on multiple jobs. 7 | */ 8 | public interface Schedule { 9 | 10 | /** 11 | * If the {@link #nextExecutionInMillis(long, int, Long)} returned value is {@code -1}, 12 | * then the corresponding job will not be executed again. 13 | */ 14 | static final long WILL_NOT_BE_EXECUTED_AGAIN = -1L; 15 | /** 16 | * A schedule that will always return {@link #WILL_NOT_BE_EXECUTED_AGAIN}. 17 | */ 18 | static final Schedule willNeverBeExecuted = (c, e, l) -> Schedule.WILL_NOT_BE_EXECUTED_AGAIN; 19 | 20 | /** 21 | * Compute the next execution time for a job. 22 | * This method should be thread-safe. 23 | * 24 | * @param currentTimeInMillis The current time in milliseconds. This time must be used if 25 | * a next execution is planned for the job 26 | * @param executionsCount The number of times a job has already been executed 27 | * @param lastExecutionEndedTimeInMillis The time at which the job has last been executed; will be null 28 | * if the job has never been executed 29 | * @return The time in milliseconds at which the job should execute next. 30 | * This time must be relative to {@code currentTimeInMillis}. 31 | * If the returned value is negative, the job will not be executed again. 32 | */ 33 | long nextExecutionInMillis(long currentTimeInMillis, int executionsCount, Long lastExecutionEndedTimeInMillis); 34 | 35 | } 36 | -------------------------------------------------------------------------------- /src/main/java/com/coreoz/wisp/schedule/Schedules.java: -------------------------------------------------------------------------------- 1 | package com.coreoz.wisp.schedule; 2 | 3 | import java.time.Duration; 4 | 5 | /** 6 | * Static helpers to build {@link Schedule} 7 | */ 8 | public class Schedules { 9 | 10 | /** 11 | * Execute a job at a fixed delay after each execution 12 | */ 13 | public static Schedule fixedDelaySchedule(Duration duration) { 14 | return new FixedDelaySchedule(duration); 15 | } 16 | 17 | /** 18 | * Execute a job at a fixed frequency which is guaranteed to be within 1ms of accuracy independent of system load 19 | */ 20 | public static Schedule fixedFrequencySchedule(Duration duration) { 21 | return new FixedFrequencySchedule(duration); 22 | } 23 | 24 | /** 25 | * Execute a job at the same time once a day. 26 | * The time format must be "hh:mm" or "hh:mm:ss" 27 | */ 28 | public static Schedule executeAt(String time) { 29 | return new FixedHourSchedule(time); 30 | } 31 | 32 | // composition schedules 33 | 34 | public static Schedule executeOnce(Schedule schedule) { 35 | return new OnceSchedule(schedule); 36 | } 37 | 38 | public static Schedule afterInitialDelay(Schedule schedule, Duration initialDelay) { 39 | return new AfterInitialDelaySchedule(schedule, initialDelay); 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /src/main/java/com/coreoz/wisp/schedule/cron/CronExpressionSchedule.java: -------------------------------------------------------------------------------- 1 | package com.coreoz.wisp.schedule.cron; 2 | 3 | import java.time.Instant; 4 | import java.time.ZoneId; 5 | import java.time.ZonedDateTime; 6 | 7 | import com.coreoz.wisp.schedule.Schedule; 8 | 9 | import fc.cron.CronExpression; 10 | 11 | /** 12 | * A {@link Schedule} based on a 13 | * Cron expression.
14 | *
15 | * This class depends on Cron library, 16 | * so this dependency has to be in the classpath in order to be able to use {@link CronExpressionSchedule}. 17 | * Since the Cron library is marked as optional in Wisp, it has to be 18 | * explicitly referenced in the project dependency configuration 19 | * (pom.xml, build.gradle, build.sbt etc.).
20 | *
21 | * See also {@link CronExpression} for format details and implementation. 22 | */ 23 | public class CronExpressionSchedule implements Schedule { 24 | 25 | private final CronExpression cronExpression; 26 | private final ZoneId zoneId; 27 | 28 | public CronExpressionSchedule(CronExpression cronExpression, ZoneId zoneId) { 29 | this.cronExpression = cronExpression; 30 | this.zoneId = zoneId; 31 | } 32 | 33 | public CronExpressionSchedule(CronExpression cronExpression) { 34 | this(cronExpression, ZoneId.systemDefault()); 35 | } 36 | 37 | @Override 38 | public long nextExecutionInMillis(long currentTimeInMillis, int executionsCount, Long lastExecutionTimeInMillis) { 39 | Instant currentInstant = Instant.ofEpochMilli(currentTimeInMillis); 40 | try { 41 | return cronExpression.nextTimeAfter(ZonedDateTime.ofInstant( 42 | currentInstant, 43 | zoneId 44 | )).toEpochSecond() * 1000L; 45 | } catch (IllegalArgumentException e) { 46 | return Schedule.WILL_NOT_BE_EXECUTED_AGAIN; 47 | } 48 | } 49 | 50 | @Override 51 | public String toString() { 52 | return cronExpression.toString(); 53 | } 54 | 55 | /** 56 | * Create a {@link Schedule} from a cron expression based on the Unix format, 57 | * e.g. 1 * * * * for each minute. 58 | */ 59 | public static CronExpressionSchedule parse(String cronExpression) { 60 | return new CronExpressionSchedule(CronExpression.createWithoutSeconds(cronExpression)); 61 | } 62 | 63 | /** 64 | * Create a {@link Schedule} from a cron expression based on the Unix format, but accepting a second field as the first one, 65 | * e.g. 29 * * * * * for each minute at the second 29, for instance 12:05:29. 66 | */ 67 | public static CronExpressionSchedule parseWithSeconds(String cronExpression) { 68 | return new CronExpressionSchedule(CronExpression.create(cronExpression)); 69 | } 70 | 71 | } 72 | -------------------------------------------------------------------------------- /src/main/java/com/coreoz/wisp/schedule/cron/CronSchedule.java: -------------------------------------------------------------------------------- 1 | package com.coreoz.wisp.schedule.cron; 2 | 3 | import java.time.Instant; 4 | import java.time.ZoneId; 5 | import java.time.ZonedDateTime; 6 | import java.util.Locale; 7 | 8 | import com.coreoz.wisp.schedule.Schedule; 9 | import com.cronutils.descriptor.CronDescriptor; 10 | import com.cronutils.model.Cron; 11 | import com.cronutils.model.CronType; 12 | import com.cronutils.model.definition.CronDefinitionBuilder; 13 | import com.cronutils.model.time.ExecutionTime; 14 | import com.cronutils.parser.CronParser; 15 | 16 | /** 17 | * A {@link Schedule} based on a 18 | * cron expression.
19 | *
20 | * This class depends on cron-utils, 21 | * so this dependency have to be in the classpath in order to be able to use {@link CronSchedule}. 22 | * Since cron-utils is marked as optional, it has to be explicitly referenced in the 23 | * project dependency configuration (pom.xml, build.gradle, build.sbt etc.). 24 | * 25 | * @deprecated Use {@link CronExpressionScheduleTest} instead. 26 | * This class has been deprecated to move away from cron-utils. See 27 | * issue #14 for details. 28 | */ 29 | @Deprecated 30 | public class CronSchedule implements Schedule { 31 | 32 | private static final CronParser UNIX_CRON_PARSER = new CronParser( 33 | CronDefinitionBuilder.instanceDefinitionFor(CronType.UNIX) 34 | ); 35 | private static final CronParser QUARTZ_CRON_PARSER = new CronParser( 36 | CronDefinitionBuilder.instanceDefinitionFor(CronType.QUARTZ) 37 | ); 38 | 39 | private static final CronDescriptor ENGLISH_DESCRIPTOR = CronDescriptor.instance(Locale.ENGLISH); 40 | 41 | private final ExecutionTime cronExpression; 42 | private final String description; 43 | private final ZoneId zoneId; 44 | 45 | public CronSchedule(Cron cronExpression, ZoneId zoneId) { 46 | this.cronExpression = ExecutionTime.forCron(cronExpression); 47 | this.description = ENGLISH_DESCRIPTOR.describe(cronExpression); 48 | this.zoneId = zoneId; 49 | } 50 | 51 | public CronSchedule(Cron cronExpression) { 52 | this(cronExpression, ZoneId.systemDefault()); 53 | } 54 | 55 | @Override 56 | public long nextExecutionInMillis(long currentTimeInMillis, int executionsCount, Long lastExecutionTimeInMillis) { 57 | Instant currentInstant = Instant.ofEpochMilli(currentTimeInMillis); 58 | return cronExpression.timeToNextExecution(ZonedDateTime.ofInstant( 59 | currentInstant, 60 | zoneId 61 | )) 62 | .map(durationBetweenNextExecution -> 63 | currentInstant.plus(durationBetweenNextExecution).toEpochMilli() 64 | ) 65 | .orElse(Schedule.WILL_NOT_BE_EXECUTED_AGAIN); 66 | } 67 | 68 | @Override 69 | public String toString() { 70 | return description; 71 | } 72 | 73 | /** 74 | * Create a {@link Schedule} from a cron expression based on the Unix format, 75 | * e.g. 1 * * * * for each minute. 76 | * 77 | * @deprecated Use {@link CronExpressionScheduleTest#parse(String)} instead 78 | */ 79 | @Deprecated 80 | public static CronSchedule parseUnixCron(String cronExpression) { 81 | return new CronSchedule(UNIX_CRON_PARSER.parse(cronExpression)); 82 | } 83 | 84 | /** 85 | * Create a {@link Schedule} from a cron expression based on the Quartz format, 86 | * e.g. 0 * * * * ? * for each minute. 87 | * 88 | * @deprecated Use {@link CronExpressionScheduleTest#parse(String)} 89 | * or {@link CronExpressionScheduleTest#parseWithSeconds(String)} instead 90 | */ 91 | @Deprecated 92 | public static CronSchedule parseQuartzCron(String cronExpression) { 93 | return new CronSchedule(QUARTZ_CRON_PARSER.parse(cronExpression)); 94 | } 95 | 96 | } 97 | -------------------------------------------------------------------------------- /src/main/java/com/coreoz/wisp/stats/SchedulerStats.java: -------------------------------------------------------------------------------- 1 | package com.coreoz.wisp.stats; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Getter; 5 | 6 | @Getter 7 | @AllArgsConstructor(staticName = "of") 8 | public class SchedulerStats { 9 | 10 | private final ThreadPoolStats threadPoolStats; 11 | 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/com/coreoz/wisp/stats/ThreadPoolStats.java: -------------------------------------------------------------------------------- 1 | package com.coreoz.wisp.stats; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Getter; 5 | 6 | @Getter 7 | @AllArgsConstructor(staticName = "of") 8 | public class ThreadPoolStats { 9 | 10 | private final int minThreads; 11 | private final int maxThreads; 12 | private final int activeThreads; 13 | private final int idleThreads; 14 | private final int largestPoolSize; 15 | 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/com/coreoz/wisp/time/SystemTimeProvider.java: -------------------------------------------------------------------------------- 1 | package com.coreoz.wisp.time; 2 | 3 | public class SystemTimeProvider implements TimeProvider { 4 | 5 | @Override 6 | public long currentTime() { 7 | return System.currentTimeMillis(); 8 | } 9 | 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/com/coreoz/wisp/time/TimeProvider.java: -------------------------------------------------------------------------------- 1 | package com.coreoz.wisp.time; 2 | 3 | /** 4 | * The time provider that will be used by the scheduler to plan jobs 5 | */ 6 | public interface TimeProvider { 7 | 8 | /** 9 | * Returns the current time in milliseconds 10 | */ 11 | long currentTime(); 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/test/java/com/coreoz/wisp/LongRunningJobMonitorTest.java: -------------------------------------------------------------------------------- 1 | package com.coreoz.wisp; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | 5 | import java.time.Duration; 6 | 7 | import org.junit.Test; 8 | 9 | public class LongRunningJobMonitorTest { 10 | 11 | @Test 12 | public void detectLongRunningJob__check_running_job_limits_detection() { 13 | LongRunningJobMonitor detector = new LongRunningJobMonitor(null, Duration.ofMillis(10)); 14 | 15 | Job job = newJob(); 16 | job.lastExecutionStartedTimeInMillis(-11L); 17 | job.threadRunningJob(Thread.currentThread()); 18 | assertThat(detector.detectLongRunningJob(0, job)).isTrue(); 19 | 20 | job = newJob(); 21 | job.lastExecutionStartedTimeInMillis(-12L); 22 | job.threadRunningJob(Thread.currentThread()); 23 | assertThat(detector.detectLongRunningJob(0, job)).isTrue(); 24 | 25 | job = newJob(); 26 | job.lastExecutionStartedTimeInMillis(-10L); 27 | job.threadRunningJob(Thread.currentThread()); 28 | assertThat(detector.detectLongRunningJob(0, job)).isFalse(); 29 | 30 | job = newJob(); 31 | job.lastExecutionStartedTimeInMillis(-9L); 32 | job.threadRunningJob(Thread.currentThread()); 33 | assertThat(detector.detectLongRunningJob(0, job)).isFalse(); 34 | } 35 | 36 | @Test 37 | public void detectLongRunningJob__check_that_a_job_long_execution_is_detected_only_once() { 38 | LongRunningJobMonitor detector = new LongRunningJobMonitor(null, Duration.ofMillis(10)); 39 | 40 | Job job = newJob(); 41 | job.lastExecutionStartedTimeInMillis(-100L); 42 | job.threadRunningJob(Thread.currentThread()); 43 | 44 | assertThat(detector.detectLongRunningJob(0, job)).isTrue(); 45 | assertThat(detector.detectLongRunningJob(0, job)).isFalse(); 46 | } 47 | 48 | @Test 49 | public void detectLongRunningJob__check_that_a_job_with_a_null_running_time_or_thread_is_not_detected() { 50 | LongRunningJobMonitor detector = new LongRunningJobMonitor(null, Duration.ofMillis(10)); 51 | 52 | Job job = newJob(); 53 | assertThat(detector.detectLongRunningJob(0, job)).isFalse(); 54 | 55 | job.lastExecutionStartedTimeInMillis(-100L); 56 | assertThat(detector.detectLongRunningJob(0, job)).isFalse(); 57 | 58 | job.lastExecutionStartedTimeInMillis(null); 59 | job.threadRunningJob(Thread.currentThread()); 60 | assertThat(detector.detectLongRunningJob(0, job)).isFalse(); 61 | } 62 | 63 | @Test 64 | public void detectLongRunningJob__check_that_a_job_not_being_run_is_not_detected_as_too_long() { 65 | LongRunningJobMonitor detector = new LongRunningJobMonitor(null); 66 | Job job = newJob(); 67 | job.status(JobStatus.SCHEDULED); 68 | job.lastExecutionStartedTimeInMillis(0L); // running for a long time... 69 | job.threadRunningJob(Thread.currentThread()); 70 | 71 | assertThat(detector.detectLongRunningJob(System.currentTimeMillis(), job)).isFalse(); 72 | job.status(JobStatus.DONE); 73 | assertThat(detector.detectLongRunningJob(System.currentTimeMillis(), job)).isFalse(); 74 | job.status(JobStatus.READY); 75 | assertThat(detector.detectLongRunningJob(System.currentTimeMillis(), job)).isFalse(); 76 | } 77 | 78 | @Test 79 | public void cleanUpLongJobIfItHasFinishedExecuting__check_that_a_job_not_detected_is_not_cleaned() { 80 | LongRunningJobMonitor detector = new LongRunningJobMonitor(null); 81 | Job job = newJob(); 82 | assertThat(detector.cleanUpLongJobIfItHasFinishedExecuting(0, job)).isNull(); 83 | } 84 | 85 | @Test 86 | public void cleanUpLongJobIfItHasFinishedExecuting__check_that_a_detected_same_running_job_is_not_cleaned() { 87 | LongRunningJobMonitor detector = new LongRunningJobMonitor(null, Duration.ofMillis(10)); 88 | 89 | Job job = newJob(); 90 | job.lastExecutionStartedTimeInMillis(-100L); 91 | job.threadRunningJob(Thread.currentThread()); 92 | detector.detectLongRunningJob(0, job); 93 | 94 | assertThat(detector.cleanUpLongJobIfItHasFinishedExecuting(0, job)).isNull(); 95 | } 96 | 97 | @Test 98 | public void cleanUpLongJobIfItHasFinishedExecuting__check_that_the_exact_job_execution_time_is_logged_when_job_execution_is_incremented_by_one() { 99 | LongRunningJobMonitor detector = new LongRunningJobMonitor(null, Duration.ofMillis(10)); 100 | 101 | Job job = newJob(); 102 | job.lastExecutionStartedTimeInMillis(-100L); 103 | job.threadRunningJob(Thread.currentThread()); 104 | detector.detectLongRunningJob(0, job); 105 | job.executionsCount(1); 106 | job.lastExecutionEndedTimeInMillis(-50L); 107 | 108 | assertThat(detector.cleanUpLongJobIfItHasFinishedExecuting(0, job)).isEqualTo(50L); 109 | } 110 | 111 | @Test 112 | public void cleanUpLongJobIfItHasFinishedExecuting__check_that_the_approximate_job_execution_time_is_logged_when_job_execution_is_incremented_by_more_than_one() { 113 | LongRunningJobMonitor detector = new LongRunningJobMonitor(null, Duration.ofMillis(10)); 114 | 115 | Job job = newJob(); 116 | job.lastExecutionStartedTimeInMillis(-100L); 117 | job.threadRunningJob(Thread.currentThread()); 118 | detector.detectLongRunningJob(0, job); 119 | job.executionsCount(2); 120 | job.lastExecutionEndedTimeInMillis(-50L); 121 | 122 | assertThat(detector.cleanUpLongJobIfItHasFinishedExecuting(0, job)).isEqualTo(100L); 123 | } 124 | 125 | @Test 126 | public void cleanUpLongJobIfItHasFinishedExecuting__check_that_a_clean_job_execution_is_really_cleaned() { 127 | LongRunningJobMonitor detector = new LongRunningJobMonitor(null, Duration.ofMillis(10)); 128 | 129 | Job job = newJob(); 130 | job.lastExecutionStartedTimeInMillis(-100L); 131 | job.threadRunningJob(Thread.currentThread()); 132 | detector.detectLongRunningJob(0, job); 133 | job.executionsCount(1); 134 | job.lastExecutionEndedTimeInMillis(-50L); 135 | 136 | assertThat(detector.cleanUpLongJobIfItHasFinishedExecuting(0, job)).isEqualTo(50L); 137 | assertThat(detector.cleanUpLongJobIfItHasFinishedExecuting(0, job)).isNull(); 138 | } 139 | 140 | private Job newJob() { 141 | return new Job( 142 | JobStatus.RUNNING, 143 | -1L, 144 | 0, 145 | null, 146 | null, 147 | "job name", 148 | null, 149 | null 150 | ); 151 | } 152 | 153 | } 154 | -------------------------------------------------------------------------------- /src/test/java/com/coreoz/wisp/SchedulerCancelTest.java: -------------------------------------------------------------------------------- 1 | package com.coreoz.wisp; 2 | 3 | import static com.coreoz.wisp.Utils.doNothing; 4 | import static org.assertj.core.api.Assertions.assertThat; 5 | import static org.junit.Assert.fail; 6 | 7 | import java.time.Duration; 8 | import java.util.Queue; 9 | import java.util.concurrent.CompletionStage; 10 | import java.util.concurrent.ConcurrentLinkedQueue; 11 | import java.util.concurrent.ExecutionException; 12 | import java.util.concurrent.TimeUnit; 13 | import java.util.concurrent.TimeoutException; 14 | 15 | import org.junit.Test; 16 | 17 | import com.coreoz.wisp.Utils.SingleJob; 18 | import com.coreoz.wisp.schedule.Schedules; 19 | 20 | import lombok.Value; 21 | 22 | /** 23 | * Tests about {@link Scheduler#cancel(String)} only 24 | */ 25 | public class SchedulerCancelTest { 26 | 27 | @Test 28 | public void cancel_should_throw_IllegalArgumentException_if_the_job_name_does_not_exist() { 29 | Scheduler scheduler = new Scheduler(SchedulerConfig.builder().maxThreads(1).build()); 30 | try { 31 | scheduler.cancel("job that does not exist"); 32 | fail("Should not accept to cancel a job that does not exist"); 33 | } catch (IllegalArgumentException e) { 34 | // as expected :) 35 | } 36 | scheduler.gracefullyShutdown(); 37 | } 38 | 39 | @Test 40 | public void cancel_should_returned_a_job_with_the_done_status() throws Exception { 41 | Scheduler scheduler = new Scheduler(SchedulerConfig.builder().maxThreads(1).build()); 42 | scheduler.schedule("doNothing", doNothing(), Schedules.fixedDelaySchedule(Duration.ofMillis(100))); 43 | Job job = scheduler.cancel("doNothing").toCompletableFuture().get(1, TimeUnit.SECONDS); 44 | 45 | assertThat(job).isNotNull(); 46 | assertThat(job.status()).isEqualTo(JobStatus.DONE); 47 | assertThat(scheduler.jobStatus().size()).isEqualTo(1); 48 | assertThat(job.name()).isEqualTo("doNothing"); 49 | assertThat(job.runnable()).isSameAs(doNothing()); 50 | 51 | scheduler.gracefullyShutdown(); 52 | 53 | assertThat(job.executionsCount()).isEqualTo(0); 54 | } 55 | 56 | @Test 57 | public void second_cancel_should_return_either_the_first_promise_or_either_a_completed_future() throws Exception { 58 | Scheduler scheduler = new Scheduler(SchedulerConfig.builder().maxThreads(1).build()); 59 | scheduler.schedule("job", Utils.TASK_THAT_SLEEPS_FOR_200MS, Schedules.fixedDelaySchedule(Duration.ofMillis(1))); 60 | 61 | // so the job can start executing 62 | Thread.sleep(20L); 63 | 64 | CompletionStage cancelFuture = scheduler.cancel("job"); 65 | CompletionStage otherCancelFuture = scheduler.cancel("job"); 66 | assertThat(cancelFuture).isSameAs(otherCancelFuture); 67 | 68 | cancelFuture.toCompletableFuture().get(1, TimeUnit.SECONDS); 69 | 70 | CompletionStage lastCancelFuture = scheduler.cancel("job"); 71 | assertThat(lastCancelFuture.toCompletableFuture().isDone()).isTrue(); 72 | 73 | scheduler.gracefullyShutdown(); 74 | } 75 | 76 | @Test 77 | public void cancelled_job_should_be_schedulable_again() throws Exception { 78 | Scheduler scheduler = new Scheduler(SchedulerConfig.builder().maxThreads(1).build()); 79 | scheduler.schedule("doNothing", doNothing(), Schedules.fixedDelaySchedule(Duration.ofMillis(100))); 80 | scheduler.cancel("doNothing").toCompletableFuture().get(1, TimeUnit.SECONDS); 81 | 82 | Job job = scheduler.schedule("doNothing", doNothing(), Schedules.fixedDelaySchedule(Duration.ofMillis(100))); 83 | 84 | assertThat(job).isNotNull(); 85 | assertThat(job.status()).isEqualTo(JobStatus.SCHEDULED); 86 | assertThat(job.name()).isEqualTo("doNothing"); 87 | assertThat(job.runnable()).isSameAs(doNothing()); 88 | 89 | scheduler.gracefullyShutdown(); 90 | } 91 | 92 | @Test 93 | public void cancelling_a_job_should_wait_until_it_is_terminated_and_other_jobs_should_continue_running() 94 | throws InterruptedException, ExecutionException, TimeoutException { 95 | Scheduler scheduler = new Scheduler(SchedulerConfig.builder().maxThreads(1).build()); 96 | 97 | SingleJob jobProcess1 = new SingleJob(); 98 | SingleJob jobProcess2 = new SingleJob() { 99 | @Override 100 | public void run() { 101 | try { 102 | Thread.sleep(200); 103 | super.run(); 104 | } catch (InterruptedException e) { 105 | throw new RuntimeException("Should not be interrupted", e); 106 | } 107 | } 108 | }; 109 | 110 | Job job1 = scheduler.schedule(jobProcess1, Schedules.fixedDelaySchedule(Duration.ofMillis(1))); 111 | Job job2 = scheduler.schedule(jobProcess2, Schedules.fixedDelaySchedule(Duration.ofMillis(1))); 112 | 113 | Thread.sleep(20); 114 | 115 | int job1ExecutionsCount = job1.executionsCount(); 116 | assertThat(job2.executionsCount()).isEqualTo(0); 117 | 118 | scheduler.cancel(job2.name()).toCompletableFuture().get(1, TimeUnit.SECONDS); 119 | 120 | Thread.sleep(60); 121 | scheduler.gracefullyShutdown(); 122 | 123 | assertThat(job2.executionsCount()).isEqualTo(1); 124 | assertThat(jobProcess2.countExecuted.get()).isEqualTo(1); 125 | // after job 2 is cancelled, job 1 should have been executed at least 3 times 126 | assertThat(job1.executionsCount()).isGreaterThan(job1ExecutionsCount + 3); 127 | } 128 | 129 | @Test 130 | public void a_job_should_be_cancelled_immediatly_if_it_has_the_status_ready() throws InterruptedException, ExecutionException, TimeoutException { 131 | Scheduler scheduler = new Scheduler(SchedulerConfig.builder().maxThreads(1).build()); 132 | 133 | SingleJob jobProcess1 = new SingleJob(); 134 | SingleJob jobProcess2 = new SingleJob() { 135 | @Override 136 | public void run() { 137 | try { 138 | Thread.sleep(300); 139 | super.run(); 140 | } catch (InterruptedException e) { 141 | throw new RuntimeException("Should not be interrupted", e); 142 | } 143 | } 144 | }; 145 | 146 | Job job1 = scheduler.schedule("Job 1", jobProcess1, Schedules.fixedDelaySchedule(Duration.ofMillis(1))); 147 | scheduler.schedule("Job 2", jobProcess2, Schedules.fixedDelaySchedule(Duration.ofMillis(1))); 148 | 149 | Thread.sleep(30); 150 | assertThat(job1.status()).isEqualTo(JobStatus.READY); 151 | 152 | long timeBeforeCancel = System.currentTimeMillis(); 153 | scheduler.cancel(job1.name()).toCompletableFuture().get(1, TimeUnit.SECONDS); 154 | assertThat(timeBeforeCancel - System.currentTimeMillis()).isLessThan(50L); 155 | 156 | scheduler.gracefullyShutdown(); 157 | } 158 | 159 | @Test 160 | public void cancelling_a_job_should_wait_until_it_is_terminated_and_other_jobs_should_continue_running__races_test() 161 | throws Exception { 162 | for(int i=0; i<10; i++) { 163 | cancelling_a_job_should_wait_until_it_is_terminated_and_other_jobs_should_continue_running(); 164 | } 165 | } 166 | 167 | 168 | @Test 169 | public void scheduling_a_done_job_should_keep_its_previous_stats() throws InterruptedException, ExecutionException, TimeoutException { 170 | Scheduler scheduler = new Scheduler(); 171 | 172 | Job job = scheduler.schedule("job", doNothing(), Schedules.fixedDelaySchedule(Duration.ofMillis(1))); 173 | Thread.sleep(25L); 174 | 175 | scheduler.cancel("job").toCompletableFuture().get(1, TimeUnit.SECONDS); 176 | Job newJob = scheduler.schedule("job", doNothing(), Schedules.fixedDelaySchedule(Duration.ofMillis(1))); 177 | scheduler.gracefullyShutdown(); 178 | 179 | assertThat(newJob.executionsCount()).isGreaterThanOrEqualTo(job.executionsCount()); 180 | assertThat(newJob.lastExecutionEndedTimeInMillis()).isNotNull(); 181 | } 182 | 183 | @Test 184 | public void check_that_a_done_job_scheduled_again_keeps_its_scheduler_stats() throws InterruptedException, ExecutionException, TimeoutException { 185 | Scheduler scheduler = new Scheduler(); 186 | 187 | Job job = scheduler.schedule("job", doNothing(), Schedules.fixedDelaySchedule(Duration.ofMillis(1))); 188 | Thread.sleep(25L); 189 | 190 | scheduler.cancel("job").toCompletableFuture().get(1, TimeUnit.SECONDS); 191 | int beforeScheduledAgainCount = job.executionsCount(); 192 | assertThat(beforeScheduledAgainCount).as("First job must have enough time to execute").isGreaterThan(0); 193 | 194 | Queue scheduledExecutions = new ConcurrentLinkedQueue<>(); 195 | scheduler.schedule("job", doNothing(), (long currentTimeInMillis, int executionsCount, Long lastExecutionEndedTimeInMillis) -> { 196 | scheduledExecutions.add(ScheduledExecution.of(currentTimeInMillis, executionsCount, lastExecutionEndedTimeInMillis)); 197 | return currentTimeInMillis; 198 | }); 199 | Thread.sleep(25L); 200 | scheduler.gracefullyShutdown(); 201 | 202 | for(ScheduledExecution scheduledExecution : scheduledExecutions) { 203 | assertThat(scheduledExecution.executionsCount).isGreaterThanOrEqualTo(beforeScheduledAgainCount); 204 | assertThat(scheduledExecution.lastExecutionEndedTimeInMillis).isNotNull(); 205 | assertThat(scheduledExecution.lastExecutionEndedTimeInMillis).isLessThanOrEqualTo(scheduledExecution.currentTimeInMillis); 206 | } 207 | } 208 | 209 | @Value(staticConstructor = "of") 210 | private static final class ScheduledExecution { 211 | private long currentTimeInMillis; 212 | private int executionsCount; 213 | private Long lastExecutionEndedTimeInMillis; 214 | } 215 | 216 | } 217 | -------------------------------------------------------------------------------- /src/test/java/com/coreoz/wisp/SchedulerShutdownTest.java: -------------------------------------------------------------------------------- 1 | package com.coreoz.wisp; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | 5 | import java.time.Duration; 6 | 7 | import org.junit.Test; 8 | 9 | import com.coreoz.wisp.Utils.SingleJob; 10 | import com.coreoz.wisp.schedule.Schedules; 11 | 12 | public class SchedulerShutdownTest { 13 | 14 | @Test 15 | public void shutdown_should_be_immediate_if_no_job_is_running() { 16 | Scheduler scheduler = new Scheduler(); 17 | long beforeShutdown = System.currentTimeMillis(); 18 | 19 | scheduler.gracefullyShutdown(); 20 | 21 | assertThat(System.currentTimeMillis() - beforeShutdown).isLessThan(20L); 22 | } 23 | 24 | @Test 25 | public void second_shutdown_should_still_wait_for_its_timeout() throws InterruptedException { 26 | Scheduler scheduler = new Scheduler(); 27 | 28 | scheduler.schedule(Utils.TASK_THAT_SLEEPS_FOR_200MS, Schedules.fixedDelaySchedule(Duration.ofMillis(1))); 29 | // so the job can start executing 30 | Thread.sleep(20L); 31 | 32 | try { 33 | scheduler.gracefullyShutdown(Duration.ofMillis(20)); // this will throw the exception 34 | throw new InterruptedException(); // so the compiler is happy 35 | } catch (InterruptedException e) { 36 | // as excepted 37 | } 38 | long beforeSecondShutdown = System.currentTimeMillis(); 39 | scheduler.gracefullyShutdown(); 40 | 41 | assertThat(System.currentTimeMillis() - beforeSecondShutdown).isGreaterThan(100L); 42 | } 43 | 44 | @Test 45 | public void ready_job_should_finish_without_being_executed_during_shutdown() throws InterruptedException { 46 | Scheduler scheduler = new Scheduler(SchedulerConfig.builder().maxThreads(1).build()); 47 | 48 | scheduler.schedule(Utils.TASK_THAT_SLEEPS_FOR_200MS, Schedules.fixedDelaySchedule(Duration.ofMillis(1))); 49 | // so the job can start executing 50 | Thread.sleep(20L); 51 | SingleJob jobThatExecuteInTwoseconds = new SingleJob() { 52 | @Override 53 | public void run() { 54 | try { 55 | Thread.sleep(2000); 56 | super.run(); 57 | } catch (InterruptedException e) { 58 | throw new RuntimeException("Should not be interrupted", e); 59 | } 60 | } 61 | }; 62 | Job jobThatWillNotBeExecuted = scheduler.schedule(jobThatExecuteInTwoseconds, Schedules.fixedDelaySchedule(Duration.ofMillis(1))); 63 | // so the job can be ready to be executed 64 | Thread.sleep(20L); 65 | 66 | assertThat(jobThatWillNotBeExecuted.status()).isEqualTo(JobStatus.READY); 67 | long beforeShutdown = System.currentTimeMillis(); 68 | scheduler.gracefullyShutdown(); 69 | assertThat(System.currentTimeMillis() - beforeShutdown).isLessThan(200L); 70 | assertThat(jobThatWillNotBeExecuted.executionsCount()).isZero(); 71 | } 72 | 73 | } 74 | -------------------------------------------------------------------------------- /src/test/java/com/coreoz/wisp/SchedulerTest.java: -------------------------------------------------------------------------------- 1 | package com.coreoz.wisp; 2 | 3 | import static com.coreoz.wisp.Utils.waitOn; 4 | import static org.assertj.core.api.Assertions.assertThat; 5 | import static org.junit.Assert.fail; 6 | 7 | import java.time.Duration; 8 | import java.util.concurrent.atomic.AtomicBoolean; 9 | 10 | import com.coreoz.wisp.schedule.Schedule; 11 | import org.assertj.core.data.Offset; 12 | import org.junit.Test; 13 | import org.slf4j.Logger; 14 | import org.slf4j.LoggerFactory; 15 | 16 | import com.coreoz.wisp.Utils.SingleJob; 17 | import com.coreoz.wisp.schedule.Schedules; 18 | import com.coreoz.wisp.stats.SchedulerStats; 19 | import com.coreoz.wisp.time.SystemTimeProvider; 20 | 21 | public class SchedulerTest { 22 | 23 | private static final Logger logger = LoggerFactory.getLogger(SchedulerTest.class); 24 | 25 | @Test 26 | public void check_that_two_job_cannot_be_scheduled_with_the_same_name() { 27 | Scheduler scheduler = new Scheduler(); 28 | 29 | scheduler.schedule("job", Utils.doNothing(), Schedules.fixedDelaySchedule(Duration.ofMillis(1))); 30 | try { 31 | scheduler.schedule("job", Utils.doNothing(), Schedules.fixedDelaySchedule(Duration.ofMillis(1))); 32 | fail(); 33 | } catch (IllegalArgumentException e) { 34 | // as expected 35 | } 36 | 37 | scheduler.gracefullyShutdown(); 38 | } 39 | 40 | @Test 41 | public void should_run_a_single_job() throws InterruptedException { 42 | Scheduler scheduler = new Scheduler(); 43 | SingleJob singleJob = new SingleJob(); 44 | scheduler.schedule( 45 | "test", 46 | singleJob, 47 | Schedules.executeOnce(Schedules.fixedDelaySchedule(Duration.ofMillis(1))) 48 | ); 49 | 50 | waitOn(singleJob, () -> singleJob.countExecuted.get() > 0, 10000); 51 | 52 | scheduler.gracefullyShutdown(); 53 | 54 | assertThat(singleJob.countExecuted.get()).isEqualTo(1); 55 | } 56 | 57 | @Test 58 | public void check_racing_conditions() throws InterruptedException { 59 | for(int i = 1; i <= 10000; i++) { 60 | should_run_each_job_once(); 61 | logger.info("iteration {} done", i); 62 | } 63 | for(int i = 1; i <= 100; i++) { 64 | should_not_launch_job_early(); 65 | logger.info("iteration {} done", i); 66 | } 67 | } 68 | 69 | @Test 70 | public void should_run_each_job_once() throws InterruptedException { 71 | Scheduler scheduler = new Scheduler(1); 72 | SingleJob job1 = new SingleJob(); 73 | SingleJob job2 = new SingleJob(); 74 | SingleJob job3 = new SingleJob(); 75 | scheduler.schedule( 76 | "job1", 77 | job1, 78 | Schedules.executeOnce(Schedules.fixedDelaySchedule(Duration.ofMillis(1))) 79 | ); 80 | scheduler.schedule( 81 | "job2", 82 | job2, 83 | Schedules.executeOnce(Schedules.fixedDelaySchedule(Duration.ofMillis(1))) 84 | ); 85 | scheduler.schedule( 86 | "job3", 87 | job3, 88 | Schedules.executeOnce(Schedules.fixedDelaySchedule(Duration.ofMillis(1))) 89 | ); 90 | Thread thread1 = new Thread(() -> { 91 | waitOn(job1, () -> job1.countExecuted.get() > 0, 10000); 92 | }); 93 | thread1.start(); 94 | Thread thread2 = new Thread(() -> { 95 | waitOn(job2, () -> job2.countExecuted.get() > 0, 10000); 96 | }); 97 | thread2.start(); 98 | Thread thread3 = new Thread(() -> { 99 | waitOn(job3, () -> job3.countExecuted.get() > 0, 10000); 100 | }); 101 | thread3.start(); 102 | 103 | thread1.join(); 104 | thread2.join(); 105 | thread3.join(); 106 | 107 | SchedulerStats stats = scheduler.stats(); 108 | scheduler.gracefullyShutdown(); 109 | 110 | assertThat(job1.countExecuted.get()).isEqualTo(1); 111 | assertThat(job2.countExecuted.get()).isEqualTo(1); 112 | assertThat(job3.countExecuted.get()).isEqualTo(1); 113 | 114 | // ensure that the initial thread limit is not exceeded 115 | assertThat( 116 | stats.getThreadPoolStats().getActiveThreads() 117 | + stats.getThreadPoolStats().getIdleThreads() 118 | ) 119 | .isLessThanOrEqualTo(1); 120 | } 121 | 122 | @Test 123 | public void should_not_launch_job_early() throws InterruptedException { 124 | SystemTimeProvider timeProvider = new SystemTimeProvider(); 125 | Scheduler scheduler = new Scheduler(SchedulerConfig 126 | .builder() 127 | .maxThreads(1) 128 | .timeProvider(timeProvider) 129 | .build() 130 | ); 131 | SingleJob job1 = new SingleJob(); 132 | long beforeExecutionTime = timeProvider.currentTime(); 133 | Duration jobIntervalTime = Duration.ofMillis(40); 134 | 135 | Job job = scheduler.schedule( 136 | "job1", 137 | job1, 138 | Schedules.executeOnce(Schedules.fixedDelaySchedule(jobIntervalTime)) 139 | ); 140 | Thread thread1 = new Thread(() -> { 141 | waitOn(job1, () -> job1.countExecuted.get() > 0, 10000); 142 | }); 143 | thread1.start(); 144 | thread1.join(); 145 | scheduler.gracefullyShutdown(); 146 | 147 | assertThat(job.lastExecutionEndedTimeInMillis() - beforeExecutionTime) 148 | .isGreaterThanOrEqualTo(jobIntervalTime.toMillis()); 149 | } 150 | 151 | @Test 152 | public void should_not_execute_past_job() throws InterruptedException { 153 | Scheduler scheduler = new Scheduler(); 154 | SingleJob job1 = new SingleJob(); 155 | 156 | scheduler.schedule( 157 | "job1", 158 | job1, 159 | Schedules.fixedDelaySchedule(Duration.ofMillis(-1000)) 160 | ); 161 | Thread thread1 = new Thread(() -> { 162 | waitOn(job1, () -> job1.countExecuted.get() > 0, 500); 163 | }); 164 | thread1.start(); 165 | thread1.join(); 166 | scheduler.gracefullyShutdown(); 167 | 168 | assertThat(job1.countExecuted.get()).isEqualTo(0); 169 | } 170 | 171 | @Test 172 | public void should_shutdown_instantly_if_no_job_is_running__races_test() { 173 | for(int i=0; i<10000; i++) { 174 | should_shutdown_instantly_if_no_job_is_running(); 175 | } 176 | } 177 | 178 | @Test 179 | public void should_shutdown_instantly_if_no_job_is_running() { 180 | Scheduler scheduler = new Scheduler(); 181 | 182 | scheduler.schedule("job1", () -> {}, Schedules.executeOnce(Schedules.fixedDelaySchedule(Duration.ofSeconds(60)))); 183 | scheduler.schedule("job2", () -> {}, Schedules.executeOnce(Schedules.fixedDelaySchedule(Duration.ofSeconds(20)))); 184 | 185 | long beforeShutdownTime = System.currentTimeMillis(); 186 | scheduler.gracefullyShutdown(); 187 | 188 | assertThat(System.currentTimeMillis() - beforeShutdownTime).isLessThan(Duration.ofSeconds(5).toMillis()); 189 | } 190 | 191 | @Test 192 | public void exception_in_schedule_should_not_alter_scheduler__races_test() 193 | throws InterruptedException { 194 | for(int i=0; i<1000; i++) { 195 | exception_in_schedule_should_not_alter_scheduler(); 196 | } 197 | } 198 | 199 | @Test 200 | public void exception_in_schedule_should_not_alter_scheduler() throws InterruptedException { 201 | Scheduler scheduler = new Scheduler(SchedulerConfig.builder().maxThreads(1).build()); 202 | 203 | AtomicBoolean isJob1ExecutedAfterJob2 = new AtomicBoolean(false); 204 | SingleJob job2 = new SingleJob(); 205 | SingleJob job1 = new SingleJob() { 206 | @Override 207 | public void run() { 208 | super.run(); 209 | if(job2.countExecuted.get() > 0) { 210 | isJob1ExecutedAfterJob2.set(true); 211 | } 212 | } 213 | }; 214 | 215 | scheduler.schedule("job1", job1, Schedules.fixedDelaySchedule(Duration.ofMillis(3))); 216 | scheduler.schedule("job2", job2, (currentTimeInMillis, executionsCount, lastExecutionTimeInMillis) -> { 217 | if(executionsCount == 0) { 218 | return currentTimeInMillis; 219 | } 220 | throw new RuntimeException("Expected exception"); 221 | }); 222 | 223 | Thread thread1 = new Thread(() -> { 224 | waitOn(job1, () -> isJob1ExecutedAfterJob2.get(), 10000); 225 | }); 226 | thread1.start(); 227 | thread1.join(); 228 | Thread thread2 = new Thread(() -> { 229 | waitOn(job2, () -> job2.countExecuted.get() > 0, 10000); 230 | }); 231 | thread2.start(); 232 | thread2.join(); 233 | 234 | scheduler.gracefullyShutdown(); 235 | 236 | assertThat(isJob1ExecutedAfterJob2.get()).isTrue(); 237 | } 238 | 239 | @Test 240 | public void exception_in_job_should_not_prevent_the_job_from_being_executed_again() throws InterruptedException { 241 | Scheduler scheduler = new Scheduler(); 242 | 243 | Runnable runnable = () -> { throw new RuntimeException("Excepted exception"); }; 244 | 245 | Job job = scheduler.schedule( 246 | runnable, 247 | Schedules.afterInitialDelay( 248 | Schedules.fixedDelaySchedule(Duration.ofMillis(5)), 249 | Duration.ZERO 250 | ) 251 | ); 252 | Thread.sleep(150L); 253 | scheduler.gracefullyShutdown(); 254 | 255 | assertThat(job.executionsCount()).isGreaterThan(1); 256 | } 257 | 258 | @Test 259 | public void check_that_a_scheduled_job_has_the_right_status() { 260 | Scheduler scheduler = new Scheduler(); 261 | 262 | Job job = scheduler.schedule(Utils.doNothing(), Schedules.fixedDelaySchedule(Duration.ofSeconds(1))); 263 | 264 | assertThat(job.status()).isEqualTo(JobStatus.SCHEDULED); 265 | 266 | scheduler.gracefullyShutdown(); 267 | assertThat(job.status()).isEqualTo(JobStatus.DONE); 268 | } 269 | 270 | @Test 271 | public void check_that_a_running_job_has_the_right_status() throws InterruptedException { 272 | Scheduler scheduler = new Scheduler(); 273 | 274 | Job job = scheduler.schedule(Utils.TASK_THAT_SLEEPS_FOR_200MS, Schedules.fixedDelaySchedule(Duration.ofMillis(1))); 275 | Thread.sleep(40L); 276 | assertThat(job.status()).isEqualTo(JobStatus.RUNNING); 277 | scheduler.gracefullyShutdown(); 278 | assertThat(job.status()).isEqualTo(JobStatus.DONE); 279 | } 280 | 281 | @Test 282 | public void check_that_a_long_running_job_does_not_prevent_other_jobs_to_run() throws InterruptedException { 283 | Scheduler scheduler = new Scheduler(); 284 | 285 | Job job = scheduler.schedule(Utils.doNothing(), Schedules.fixedDelaySchedule(Duration.ofMillis(10))); 286 | Thread.sleep(25L); 287 | scheduler.schedule(Utils.TASK_THAT_SLEEPS_FOR_200MS, Schedules.fixedDelaySchedule(Duration.ofMillis(1))); 288 | long countBeforeSleep = job.executionsCount(); 289 | Thread.sleep(50L); 290 | scheduler.gracefullyShutdown(); 291 | 292 | assertThat(job.executionsCount() - countBeforeSleep).isGreaterThan(3); 293 | } 294 | 295 | @Test 296 | public void check_that_metrics_are_correctly_updated_during_and_after_a_job_execution() throws InterruptedException { 297 | Scheduler scheduler = new Scheduler(); 298 | 299 | Job job = scheduler.schedule(Utils.TASK_THAT_SLEEPS_FOR_200MS, Schedules.fixedDelaySchedule(Duration.ofMillis(1))); 300 | 301 | Thread.sleep(25L); 302 | assertThat(job.executionsCount()).isZero(); 303 | assertThat(job.lastExecutionEndedTimeInMillis()).isNull(); 304 | assertThat(job.lastExecutionStartedTimeInMillis()).isCloseTo(System.currentTimeMillis(), Offset.offset(200L)); 305 | assertThat(job.threadRunningJob()).isNotNull(); 306 | 307 | scheduler.gracefullyShutdown(); 308 | 309 | assertThat(job.executionsCount()).isOne(); 310 | assertThat(job.lastExecutionEndedTimeInMillis()).isNotNull(); 311 | assertThat(job.lastExecutionStartedTimeInMillis()).isNotNull(); 312 | assertThat(job.threadRunningJob()).isNull(); 313 | } 314 | 315 | @Test 316 | public void remove__verify_that_a_done_job_is_correctly_removed() { 317 | Scheduler scheduler = new Scheduler(); 318 | Job job = scheduler.schedule(Utils.doNothing(), Schedule.willNeverBeExecuted); 319 | assertThat(scheduler.jobStatus()).contains(job); 320 | scheduler.remove(job.name()); 321 | assertThat(scheduler.jobStatus()).isEmpty(); 322 | } 323 | 324 | @Test(expected = IllegalArgumentException.class) 325 | public void remove__verify_that_a_non_existent_job_name_raises_an_illegal_argument_exception() { 326 | Scheduler scheduler = new Scheduler(); 327 | scheduler.remove("does not exist"); 328 | } 329 | 330 | @Test(expected = IllegalArgumentException.class) 331 | public void remove__verify_that_a_non_done_job_name_raises_an_illegal_argument_exception() { 332 | Scheduler scheduler = new Scheduler(); 333 | Job job = scheduler.schedule(Utils.doNothing(), Schedules.fixedDelaySchedule(Duration.ofMillis(1000))); 334 | scheduler.remove(job.name()); 335 | } 336 | 337 | @Test(expected = NullPointerException.class) 338 | public void should_fail_if_time_provider_is_null() { 339 | new Scheduler(SchedulerConfig.builder().maxThreads(1).timeProvider(null).build()); 340 | } 341 | } 342 | -------------------------------------------------------------------------------- /src/test/java/com/coreoz/wisp/SchedulerThreadPoolTest.java: -------------------------------------------------------------------------------- 1 | package com.coreoz.wisp; 2 | 3 | import static com.coreoz.wisp.Utils.waitOn; 4 | import static org.assertj.core.api.Assertions.assertThat; 5 | 6 | import java.time.Duration; 7 | 8 | import org.junit.Test; 9 | 10 | import com.coreoz.wisp.Utils.SingleJob; 11 | import com.coreoz.wisp.schedule.Schedules; 12 | import com.coreoz.wisp.stats.SchedulerStats; 13 | 14 | public class SchedulerThreadPoolTest { 15 | 16 | @Test 17 | public void thread_pool_should_scale_down_when_no_more_tasks_need_executing() throws InterruptedException { 18 | Scheduler scheduler = new Scheduler( 19 | SchedulerConfig 20 | .builder() 21 | .threadsKeepAliveTime(Duration.ofMillis(50)) 22 | .build() 23 | ); 24 | 25 | runTwoConcurrentJobsForAtLeastFiftyIterations(scheduler); 26 | scheduler.cancel("job1"); 27 | scheduler.cancel("job2"); 28 | 29 | Thread.sleep(80L); 30 | SchedulerStats stats = scheduler.stats(); 31 | scheduler.gracefullyShutdown(); 32 | 33 | assertThat( 34 | stats.getThreadPoolStats().getActiveThreads() 35 | + stats.getThreadPoolStats().getIdleThreads() 36 | ) 37 | .isLessThanOrEqualTo(0); 38 | assertThat(stats.getThreadPoolStats().getLargestPoolSize()).isGreaterThanOrEqualTo(1); 39 | } 40 | 41 | @Test 42 | public void should_not_create_more_threads_than_jobs_scheduled_over_time__races_test() 43 | throws InterruptedException { 44 | for(int i=0; i<100; i++) { 45 | should_not_create_more_threads_than_jobs_scheduled_over_time(); 46 | } 47 | } 48 | 49 | @Test 50 | public void should_not_create_more_threads_than_jobs_scheduled_over_time() throws InterruptedException { 51 | Scheduler scheduler = new Scheduler(); 52 | 53 | runTwoConcurrentJobsForAtLeastFiftyIterations(scheduler); 54 | 55 | SchedulerStats stats = scheduler.stats(); 56 | scheduler.gracefullyShutdown(); 57 | 58 | assertThat( 59 | stats.getThreadPoolStats().getActiveThreads() 60 | + stats.getThreadPoolStats().getIdleThreads() 61 | ) 62 | // 1 thread for each task => 2 63 | // but since most of the thread pool logic is delegated to ThreadPoolExecutor 64 | // we do not have precise control on how much threads will be created. 65 | // So we mostly want to check that not all threads of the pool are created. 66 | .isLessThanOrEqualTo(5); 67 | } 68 | 69 | @Test 70 | public void should_provide_accurate_pool_size_stats() throws InterruptedException { 71 | Scheduler scheduler = new Scheduler(); 72 | scheduler.schedule(() -> { 73 | try { 74 | Thread.sleep(100); 75 | } catch (Exception e) { 76 | // do not care any exception 77 | } 78 | }, 79 | Schedules.executeOnce(Schedules.fixedDelaySchedule(Duration.ZERO)) 80 | ); 81 | 82 | Thread.sleep(30L); 83 | 84 | SchedulerStats stats = scheduler.stats(); 85 | scheduler.gracefullyShutdown(); 86 | 87 | assertThat(stats.getThreadPoolStats().getActiveThreads()).isEqualTo(1); 88 | assertThat(stats.getThreadPoolStats().getIdleThreads()).isEqualTo(0); 89 | assertThat(stats.getThreadPoolStats().getMinThreads()).isEqualTo(0); 90 | assertThat(stats.getThreadPoolStats().getMaxThreads()).isEqualTo(10); 91 | assertThat(stats.getThreadPoolStats().getLargestPoolSize()).isEqualTo(1); 92 | } 93 | 94 | private void runTwoConcurrentJobsForAtLeastFiftyIterations(Scheduler scheduler) 95 | throws InterruptedException { 96 | SingleJob job1 = new SingleJob(); 97 | SingleJob job2 = new SingleJob(); 98 | 99 | scheduler.schedule("job1", job1, Schedules.fixedDelaySchedule(Duration.ofMillis(1))); 100 | scheduler.schedule("job2", job2, Schedules.fixedDelaySchedule(Duration.ofMillis(1))); 101 | 102 | Thread thread1 = new Thread(() -> { 103 | waitOn(job1, () -> job1.countExecuted.get() > 50, 100); 104 | }); 105 | thread1.start(); 106 | thread1.join(); 107 | Thread thread2 = new Thread(() -> { 108 | waitOn(job2, () -> job2.countExecuted.get() > 50, 100); 109 | }); 110 | thread2.start(); 111 | thread2.join(); 112 | } 113 | 114 | } 115 | -------------------------------------------------------------------------------- /src/test/java/com/coreoz/wisp/Utils.java: -------------------------------------------------------------------------------- 1 | package com.coreoz.wisp; 2 | 3 | import java.util.concurrent.atomic.AtomicInteger; 4 | import java.util.function.Supplier; 5 | 6 | public class Utils { 7 | 8 | public static final Runnable TASK_THAT_SLEEPS_FOR_200MS = () -> { 9 | try { 10 | Thread.sleep(200); 11 | } catch (InterruptedException e) { 12 | throw new RuntimeException("Should not be interrupted", e); 13 | } 14 | }; 15 | 16 | public static class SingleJob implements Runnable { 17 | AtomicInteger countExecuted = new AtomicInteger(0); 18 | 19 | @Override 20 | public void run() { 21 | countExecuted.incrementAndGet(); 22 | synchronized (this) { 23 | notifyAll(); 24 | } 25 | } 26 | } 27 | 28 | public static void waitOn(Object lockOn, Supplier condition, long maxWait) { 29 | long currentTime = System.currentTimeMillis(); 30 | long waitUntil = currentTime + maxWait; 31 | while(!condition.get() && waitUntil > currentTime) { 32 | synchronized (lockOn) { 33 | try { 34 | lockOn.wait(5); 35 | } catch (InterruptedException e) { 36 | } 37 | } 38 | currentTime = System.currentTimeMillis(); 39 | } 40 | } 41 | 42 | // a do nothing runnable 43 | private static Runnable doNothing = () -> {}; 44 | public static Runnable doNothing() { 45 | return doNothing; 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /src/test/java/com/coreoz/wisp/schedule/AfterInitialDelayScheduleTest.java: -------------------------------------------------------------------------------- 1 | package com.coreoz.wisp.schedule; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | 5 | import java.time.Duration; 6 | 7 | import org.junit.Test; 8 | 9 | import com.coreoz.wisp.Scheduler; 10 | import com.coreoz.wisp.Utils; 11 | 12 | public class AfterInitialDelayScheduleTest { 13 | 14 | @Test 15 | public void first_execution_should_depends_only_on_the_first_delay() { 16 | Schedule after1msDelay = Schedules.afterInitialDelay(null, Duration.ofMillis(1)); 17 | 18 | assertThat(after1msDelay.nextExecutionInMillis(0, 0, null)).isEqualTo(1); 19 | } 20 | 21 | @Test 22 | public void should_not_rely_only_on_job_executions_count() { 23 | Schedule every5ms = Schedules.fixedDelaySchedule(Duration.ofMillis(5)); 24 | Schedule afterUnusedDelay = Schedules.afterInitialDelay(every5ms, Duration.ZERO); 25 | 26 | assertThat(afterUnusedDelay.nextExecutionInMillis(0, 2, null)).isEqualTo(0); 27 | assertThat(afterUnusedDelay.nextExecutionInMillis(0, 2, null)).isEqualTo(0); 28 | assertThat(afterUnusedDelay.nextExecutionInMillis(0, 3, null)).isEqualTo(5); 29 | } 30 | 31 | @Test 32 | public void check_that_scheduler_really_rely_on_initial_delay() throws InterruptedException { 33 | Scheduler scheduler = new Scheduler(); 34 | scheduler.schedule("job", Utils.doNothing(), Schedules.afterInitialDelay(Schedules.fixedDelaySchedule(Duration.ofDays(1)), Duration.ZERO)); 35 | 36 | Thread.sleep(100); 37 | scheduler.gracefullyShutdown(); 38 | 39 | assertThat(scheduler.findJob("job").get().executionsCount()).isEqualTo(1); 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /src/test/java/com/coreoz/wisp/schedule/FixedFrequencyScheduleTest.java: -------------------------------------------------------------------------------- 1 | package com.coreoz.wisp.schedule; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | 5 | import org.junit.Test; 6 | 7 | import java.time.Duration; 8 | import java.util.concurrent.TimeUnit; 9 | 10 | public class FixedFrequencyScheduleTest { 11 | @Test 12 | public void test_next_execution_rounded() { 13 | assertThat(Schedules.fixedFrequencySchedule(Duration.ofHours(2)) 14 | .nextExecutionInMillis(TimeUnit.SECONDS.toMillis(8), 0, 0L)) 15 | .isEqualTo(TimeUnit.HOURS.toMillis(2)); 16 | } 17 | 18 | @Test 19 | public void test_next_execution_too_much() { 20 | assertThat(Schedules.fixedFrequencySchedule(Duration.ofHours(2)) 21 | .nextExecutionInMillis(TimeUnit.HOURS.toMillis(3), 0, 0L)) 22 | .isEqualTo(TimeUnit.HOURS.toMillis(4)); 23 | } 24 | 25 | @Test 26 | public void test_next_execution_not_rounded() { 27 | assertThat(Schedules.fixedFrequencySchedule(Duration.ofHours(2)) 28 | .nextExecutionInMillis(TimeUnit.HOURS.toMillis(4), 0, 0L)) 29 | .isEqualTo(TimeUnit.HOURS.toMillis(6)); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/test/java/com/coreoz/wisp/schedule/FixedHourScheduleTest.java: -------------------------------------------------------------------------------- 1 | package com.coreoz.wisp.schedule; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | 5 | import java.time.LocalDate; 6 | import java.time.LocalTime; 7 | import java.time.ZoneId; 8 | import java.time.ZonedDateTime; 9 | 10 | import org.junit.Test; 11 | 12 | public class FixedHourScheduleTest { 13 | 14 | @Test 15 | public void should_parse_time_without_seconds() { 16 | LocalTime executionTime = new FixedHourSchedule("12:31").executionTime(); 17 | 18 | assertThat(executionTime.getHour()).isEqualTo(12); 19 | assertThat(executionTime.getMinute()).isEqualTo(31); 20 | assertThat(executionTime.getSecond()).isEqualTo(0); 21 | } 22 | 23 | @Test 24 | public void should_parse_time_with_seconds() { 25 | LocalTime executionTime = new FixedHourSchedule("03:31:09").executionTime(); 26 | 27 | assertThat(executionTime.getHour()).isEqualTo(3); 28 | assertThat(executionTime.getMinute()).isEqualTo(31); 29 | assertThat(executionTime.getSecond()).isEqualTo(9); 30 | } 31 | 32 | @Test 33 | public void should_calcule_next_execution_from_epoch() { 34 | ZoneId ectZone = ZoneId.of("Europe/Paris"); 35 | ZonedDateTime augustMidnight = LocalDate 36 | .of(2016, 8, 31) 37 | .atStartOfDay() 38 | .atZone(ectZone); 39 | long midnight = augustMidnight.toEpochSecond() * 1000; 40 | 41 | assertThat( 42 | new FixedHourSchedule("00:00:00", ectZone).nextExecutionInMillis(midnight, 0, null) 43 | ).isEqualTo(midnight); 44 | } 45 | 46 | @Test 47 | public void should_calcule_next_execution_from_midnight() { 48 | ZoneId ectZone = ZoneId.of("Europe/Paris"); 49 | ZonedDateTime augustMidnight = LocalDate 50 | .of(2016, 8, 31) 51 | .atStartOfDay() 52 | .atZone(ectZone); 53 | long midnight = augustMidnight.toEpochSecond() * 1000; 54 | 55 | assertThat( 56 | new FixedHourSchedule("00:00:00", ectZone).durationUntilNextExecutionInMillis(midnight, null) 57 | ).isEqualTo(0); 58 | assertThat( 59 | new FixedHourSchedule("00:00:01", ectZone).durationUntilNextExecutionInMillis(midnight, null) 60 | ).isEqualTo(1000); 61 | } 62 | 63 | @Test 64 | public void should_calcule_next_execution_from_midday() { 65 | ZoneId ectZone = ZoneId.of("Europe/Paris"); 66 | ZonedDateTime augustMidday = LocalDate 67 | .of(2016, 8, 31) 68 | .atTime(12, 0) 69 | .atZone(ectZone); 70 | long midday = augustMidday.toEpochSecond() * 1000; 71 | 72 | assertThat( 73 | new FixedHourSchedule("12:00:00", ectZone).durationUntilNextExecutionInMillis(midday, null) 74 | ).isEqualTo(0); 75 | assertThat( 76 | new FixedHourSchedule("12:00:01", ectZone).durationUntilNextExecutionInMillis(midday, null) 77 | ).isEqualTo(1000); 78 | assertThat( 79 | new FixedHourSchedule("11:59:59", ectZone).durationUntilNextExecutionInMillis(midday, null) 80 | ).isEqualTo(24 * 60 * 60 * 1000 - 1000); 81 | assertThat( 82 | new FixedHourSchedule("00:00:00", ectZone).durationUntilNextExecutionInMillis(midday, null) 83 | ).isEqualTo(12 * 60 * 60 * 1000); 84 | } 85 | 86 | @Test 87 | public void should_calcule_next_execution_with_dst() { 88 | ZoneId ectZone = ZoneId.of("Europe/Paris"); 89 | ZonedDateTime midnight = LocalDate 90 | .of(2016, 10, 30) 91 | .atStartOfDay() 92 | .atZone(ectZone); 93 | long midnightMillis = midnight.toEpochSecond() * 1000; 94 | 95 | assertThat(new FixedHourSchedule("02:00:00", ectZone).durationUntilNextExecutionInMillis(midnightMillis, null)).isEqualTo(2 * 60 * 60 * 1000); 96 | assertThat(new FixedHourSchedule("03:00:00", ectZone).durationUntilNextExecutionInMillis(midnightMillis, null)).isEqualTo(4 * 60 * 60 * 1000); 97 | } 98 | 99 | @Test 100 | public void should_calcule_next_execution_during_time_change() { 101 | ZoneId ectZone = ZoneId.of("Europe/Paris"); 102 | ZonedDateTime oneSecBeforeTimeChange = LocalDate 103 | .of(2016, 10, 30) 104 | .atTime(1, 59, 59) 105 | .atZone(ectZone); 106 | long oneSecBeforeTimeChangeMillis = oneSecBeforeTimeChange.toEpochSecond() * 1000; 107 | long oneSecAfterTimeChangeMillis = (oneSecBeforeTimeChange.toEpochSecond() + 2) * 1000; 108 | 109 | assertThat( 110 | new FixedHourSchedule("02:00:00", ectZone) 111 | .durationUntilNextExecutionInMillis(oneSecBeforeTimeChangeMillis, null) 112 | ).isEqualTo(1000); 113 | assertThat( 114 | new FixedHourSchedule("02:00:00", ectZone) 115 | .durationUntilNextExecutionInMillis(oneSecAfterTimeChangeMillis, null) 116 | ).isEqualTo(25 * 60 * 60 * 1000 - 1000); 117 | } 118 | 119 | @Test 120 | public void should_not_return_current_time_if_last_execution_equals_current_time() { 121 | ZoneId ectZone = ZoneId.of("Europe/Paris"); 122 | ZonedDateTime augustMidnight = LocalDate 123 | .of(2016, 8, 31) 124 | .atStartOfDay() 125 | .atZone(ectZone); 126 | long midnight = augustMidnight.toEpochSecond() * 1000; 127 | 128 | assertThat( 129 | new FixedHourSchedule("00:00:00", ectZone).durationUntilNextExecutionInMillis(midnight, midnight) 130 | ).isEqualTo(24 * 60 * 60 * 1000); 131 | } 132 | 133 | } 134 | -------------------------------------------------------------------------------- /src/test/java/com/coreoz/wisp/schedule/OnceScheduleTest.java: -------------------------------------------------------------------------------- 1 | package com.coreoz.wisp.schedule; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | 5 | import java.time.Duration; 6 | 7 | import org.junit.Test; 8 | 9 | import com.coreoz.wisp.Scheduler; 10 | import com.coreoz.wisp.Utils; 11 | 12 | public class OnceScheduleTest { 13 | 14 | @Test 15 | public void should_not_rely_only_on_job_executions_count() { 16 | Schedule onceAfter5ms = Schedules.executeOnce(Schedules.fixedDelaySchedule(Duration.ofMillis(5))); 17 | 18 | assertThat(onceAfter5ms.nextExecutionInMillis(0, 2, null)).isEqualTo(5); 19 | assertThat(onceAfter5ms.nextExecutionInMillis(0, 2, null)).isEqualTo(5); 20 | assertThat(onceAfter5ms.nextExecutionInMillis(0, 3, null)).isEqualTo(Schedule.WILL_NOT_BE_EXECUTED_AGAIN); 21 | } 22 | 23 | @Test 24 | public void check_that_scheduler_really_execute_job_once() throws InterruptedException { 25 | Scheduler scheduler = new Scheduler(); 26 | scheduler.schedule("job", Utils.doNothing(), Schedules.executeOnce(Schedules.fixedDelaySchedule(Duration.ZERO))); 27 | 28 | Thread.sleep(100); 29 | scheduler.gracefullyShutdown(); 30 | 31 | assertThat(scheduler.findJob("job").get().executionsCount()).isEqualTo(1); 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /src/test/java/com/coreoz/wisp/schedule/cron/CronExpressionScheduleTest.java: -------------------------------------------------------------------------------- 1 | package com.coreoz.wisp.schedule.cron; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | 5 | import java.time.Duration; 6 | import java.time.LocalDate; 7 | import java.time.ZoneId; 8 | import java.time.ZonedDateTime; 9 | 10 | import org.junit.Test; 11 | 12 | public class CronExpressionScheduleTest { 13 | 14 | @Test 15 | public void should_calcule_the_next_execution_time_based_on_a_unix_cron_expression() { 16 | CronExpressionSchedule everyMinuteScheduler = CronExpressionSchedule.parse("* * * * *"); 17 | 18 | // To ease calculations, next execution time are calculated from the timestamp "0". 19 | // So here, the absolute timestamp for an execution in 1 minute will be 60 20 | assertThat(everyMinuteScheduler.nextExecutionInMillis(0, 0, null)) 21 | .isEqualTo(Duration.ofMinutes(1).toMillis()); 22 | } 23 | 24 | @Test 25 | public void should_calcule_the_next_execution_time_based_on_a_unix_cron_expression_with_seconds() { 26 | CronExpressionSchedule everyMinuteScheduler = CronExpressionSchedule.parseWithSeconds("29 * * * * *"); 27 | 28 | assertThat(everyMinuteScheduler.nextExecutionInMillis(0, 0, null)) 29 | // the first iteration will be the absolute timestamp "29" 30 | .isEqualTo(Duration.ofSeconds(29).toMillis()); 31 | assertThat(everyMinuteScheduler.nextExecutionInMillis(29000 , 1, null)) 32 | // the second iteration will be the absolute timestamp "89" 33 | .isEqualTo(Duration.ofSeconds(60 + 29).toMillis()); 34 | } 35 | 36 | @Test 37 | public void should_not_executed_daily_jobs_twice_a_day() { 38 | CronExpressionSchedule everyMinuteScheduler = CronExpressionSchedule.parse("0 12 * * *"); 39 | 40 | ZonedDateTime augustMidday = LocalDate 41 | .of(2016, 8, 31) 42 | .atTime(12, 0) 43 | .atZone(ZoneId.systemDefault()); 44 | long midday = augustMidday.toEpochSecond() * 1000; 45 | 46 | assertThat(everyMinuteScheduler.nextExecutionInMillis(midday-1, 0, null)) 47 | .isEqualTo(midday); 48 | assertThat(everyMinuteScheduler.nextExecutionInMillis(midday, 0, null)) 49 | .isEqualTo(midday + Duration.ofDays(1).toMillis()); 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /src/test/java/com/coreoz/wisp/schedule/cron/CronScheduleTest.java: -------------------------------------------------------------------------------- 1 | package com.coreoz.wisp.schedule.cron; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | 5 | import java.time.Duration; 6 | import java.time.LocalDate; 7 | import java.time.ZoneId; 8 | import java.time.ZonedDateTime; 9 | 10 | import org.junit.Test; 11 | 12 | public class CronScheduleTest { 13 | 14 | @Test 15 | public void should_calcule_the_next_execution_time_based_on_a_unix_cron_expression() { 16 | CronSchedule everyMinuteScheduler = CronSchedule.parseUnixCron("* * * * *"); 17 | 18 | assertThat(everyMinuteScheduler.nextExecutionInMillis(0, 0, null)) 19 | .isEqualTo(Duration.ofMinutes(1).toMillis()); 20 | } 21 | 22 | @Test 23 | public void should_calcule_the_next_execution_time_based_on_a_quartz_cron_expression() { 24 | CronSchedule everyMinuteScheduler = CronSchedule.parseQuartzCron("0 * * * * ? *"); 25 | 26 | assertThat(everyMinuteScheduler.nextExecutionInMillis(0, 0, null)) 27 | .isEqualTo(Duration.ofMinutes(1).toMillis()); 28 | } 29 | 30 | @Test 31 | public void should_not_executed_daily_jobs_twice_a_day() { 32 | CronSchedule everyMinuteScheduler = CronSchedule.parseQuartzCron("0 0 12 * * ? *"); 33 | 34 | ZonedDateTime augustMidday = LocalDate 35 | .of(2016, 8, 31) 36 | .atTime(12, 0) 37 | .atZone(ZoneId.systemDefault()); 38 | long midday = augustMidday.toEpochSecond() * 1000; 39 | 40 | assertThat(everyMinuteScheduler.nextExecutionInMillis(midday-1, 0, null)) 41 | .isEqualTo(midday); 42 | assertThat(everyMinuteScheduler.nextExecutionInMillis(midday, 0, null)) 43 | .isEqualTo(midday + Duration.ofDays(1).toMillis()); 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /src/test/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | %d{HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | --------------------------------------------------------------------------------