├── .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 | [](./actions)
5 | [](https://coveralls.io/github/Coreoz/Wisp?branch=master)
6 | [](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 extends E> 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 super E> c) {
176 | return this.delegate.drainTo(c);
177 | }
178 |
179 | @Override
180 | public int drainTo(Collection super E> 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 |
--------------------------------------------------------------------------------