diff --git a/.idea/misc.xml b/.idea/misc.xml index a56dd24..b0137f1 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,7 +1,7 @@ - + \ No newline at end of file diff --git a/README.md b/README.md index 6680b8d..6578861 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,13 @@ keeping things simple and easy to use. ### Highlights ✨ -- Lightweight with no external dependencies other than `kotlinx:kotlinx-coroutines-core` from the Kotlin standard library. +- Lightweight with no external dependencies other than `kotlinx:kotlinx-coroutines-core` from the Kotlin standard + library. - Designed to respect time zones, allowing you to set the time zone yourself or use the system's time zone by default. -- Provides four different types of triggers to execute jobs daily, at certain intervals, once at a given time, or with a cron-like schedule. -- Can run multiple instances of a job concurrently while giving you the option to run only one instance if the job is already executing. +- Provides four different types of triggers to execute jobs daily, at certain intervals, once at a given time, or with a + cron-like schedule. +- Can run multiple instances of a job concurrently while giving you the option to run only one instance if the job is + already executing. - Can be easily extended to suit your specific use case by allowing you to write custom triggers and job stores. - Easy to use and straightforward API with full KDoc/Javadoc documentation coverage. - 100% unit test coverage to ensure reliability across different scenarios. @@ -50,6 +53,7 @@ dependencies { implementation("com.github.Pool-Of-Tears:KtScheduler:version") } ``` + ------ ### Documentation 📑 diff --git a/build.gradle.kts b/build.gradle.kts index 32dd9e4..d979143 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -90,7 +90,7 @@ tasks.register("printLineCoverage") { group = "verification" dependsOn("koverXmlReport") doLast { - val report = file("$buildDir/reports/kover/report.xml") + val report = file("${layout.buildDirectory}/reports/kover/report.xml") val doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(report) val rootNode = doc.firstChild var childNode = rootNode.firstChild diff --git a/src/main/kotlin/dev/starry/ktscheduler/event/JobEvent.kt b/src/main/kotlin/dev/starry/ktscheduler/event/JobEvent.kt index abcb855..6d52e07 100644 --- a/src/main/kotlin/dev/starry/ktscheduler/event/JobEvent.kt +++ b/src/main/kotlin/dev/starry/ktscheduler/event/JobEvent.kt @@ -17,6 +17,8 @@ package dev.starry.ktscheduler.event +import dev.starry.ktscheduler.event.JobStatus.ERROR +import dev.starry.ktscheduler.event.JobStatus.SUCCESS import java.time.ZonedDateTime /** diff --git a/src/main/kotlin/dev/starry/ktscheduler/job/Job.kt b/src/main/kotlin/dev/starry/ktscheduler/job/Job.kt index 25adbd2..3452fca 100644 --- a/src/main/kotlin/dev/starry/ktscheduler/job/Job.kt +++ b/src/main/kotlin/dev/starry/ktscheduler/job/Job.kt @@ -46,8 +46,12 @@ data class Job( /** * The next time the job should run. + * + * When adding a new job, it is used as the initial run time. + * If not provided, it will be calculated automatically based + * on the [Trigger.getNextRunTime] method when the job is added. */ - val nextRunTime: ZonedDateTime, + val nextRunTime: ZonedDateTime? = null, /** * Whether to run multiple instances of this job concurrently. @@ -67,3 +71,5 @@ data class Job( val callback: suspend () -> Unit ) + + diff --git a/src/main/kotlin/dev/starry/ktscheduler/jobstore/InMemoryJobStore.kt b/src/main/kotlin/dev/starry/ktscheduler/jobstore/InMemoryJobStore.kt index 72e9c71..3e16433 100644 --- a/src/main/kotlin/dev/starry/ktscheduler/jobstore/InMemoryJobStore.kt +++ b/src/main/kotlin/dev/starry/ktscheduler/jobstore/InMemoryJobStore.kt @@ -57,7 +57,7 @@ class InMemoryJobStore : JobStore { */ override fun getDueJobs(currentTime: ZonedDateTime, maxGraceTime: Duration?): List { return jobs.values.filter { job -> - val jobNextRunTime = job.nextRunTime + val jobNextRunTime = job.nextRunTime!! maxGraceTime?.let { graceTime -> jobNextRunTime <= currentTime && currentTime <= jobNextRunTime.plus(graceTime) } ?: (jobNextRunTime <= currentTime) diff --git a/src/main/kotlin/dev/starry/ktscheduler/scheduler/KtScheduler.kt b/src/main/kotlin/dev/starry/ktscheduler/scheduler/KtScheduler.kt index 5f6c2e0..aa70d95 100644 --- a/src/main/kotlin/dev/starry/ktscheduler/scheduler/KtScheduler.kt +++ b/src/main/kotlin/dev/starry/ktscheduler/scheduler/KtScheduler.kt @@ -27,20 +27,9 @@ import dev.starry.ktscheduler.triggers.CronTrigger import dev.starry.ktscheduler.triggers.DailyTrigger import dev.starry.ktscheduler.triggers.IntervalTrigger import dev.starry.ktscheduler.triggers.OneTimeTrigger -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import java.time.DayOfWeek -import java.time.Duration -import java.time.LocalTime -import java.time.ZoneId -import java.time.ZonedDateTime -import java.util.UUID +import kotlinx.coroutines.* +import java.time.* +import java.util.* import java.util.logging.Logger /** @@ -163,7 +152,14 @@ class KtScheduler( */ override fun addJob(job: Job) { logger.info("Adding job ${job.jobId}") - jobStore.addJob(job) + val jobToAdd = job.nextRunTime?.let { + job + } ?: job.copy( + nextRunTime = job.trigger.getNextRunTime(ZonedDateTime.now(timeZone), timeZone) + ).apply { + logger.info("Calculated next run time for job ${job.jobId}: $nextRunTime") + } + jobStore.addJob(jobToAdd) } /** @@ -282,17 +278,18 @@ class KtScheduler( * ``` * val trigger = CronTrigger(daysOfWeek, time) * val job = Job( - * jobId = "runCron-${UUID.randomUUID()}", - * trigger = trigger, - * nextRunTime = trigger.getNextRunTime(ZonedDateTime.now(timeZone), timeZone), - * runConcurrently = runConcurrently, - * dispatcher = dispatcher, - * callback = callback + * jobId = "runCron-${UUID.randomUUID()}", + * trigger = trigger, + * nextRunTime = trigger.getNextRunTime(ZonedDateTime.now(timeZone), timeZone), + * runConcurrently = runConcurrently, + * dispatcher = dispatcher, + * callback = callback * ) * * scheduler.addJob(job) * ``` * + * @param jobId The ID of the job. Default is a random UUID. * @param daysOfWeek The set of days of the week on which the job should run. * @param time The time at which the job should run. * @param dispatcher The coroutine dispatcher to use. Default is [Dispatchers.Default]. @@ -301,6 +298,7 @@ class KtScheduler( * @return The ID of the scheduled job. */ fun runCron( + jobId: String = "runCron-${UUID.randomUUID()}", daysOfWeek: Set, time: LocalTime, dispatcher: CoroutineDispatcher = Dispatchers.Default, @@ -309,14 +307,14 @@ class KtScheduler( ): String { val trigger = CronTrigger(daysOfWeek, time) val job = Job( - jobId = "runCron-${UUID.randomUUID()}", + jobId = jobId, trigger = trigger, nextRunTime = trigger.getNextRunTime(ZonedDateTime.now(timeZone), timeZone), runConcurrently = runConcurrently, dispatcher = dispatcher, callback = block ) - job.let { addJob(it) }.also { return job.jobId } + addJob(job).also { return job.jobId } } /** @@ -328,17 +326,18 @@ class KtScheduler( * ``` * val trigger = DailyTrigger(dailyTime) * val job = Job( - * jobId = "runDaily-${UUID.randomUUID()}", - * trigger = trigger, - * nextRunTime = trigger.getNextRunTime(ZonedDateTime.now(timeZone), timeZone), - * runConcurrently = runConcurrently, - * dispatcher = dispatcher, - * callback = callback + * jobId = "runDaily-${UUID.randomUUID()}", + * trigger = trigger, + * nextRunTime = trigger.getNextRunTime(ZonedDateTime.now(timeZone), timeZone), + * runConcurrently = runConcurrently, + * dispatcher = dispatcher, + * callback = callback * ) * * scheduler.addJob(job) * ``` * + * @param jobId The ID of the job. Default is a random UUID. * @param dailyTime The time at which the job should run daily. * @param dispatcher The coroutine dispatcher to use. Default is [Dispatchers.Default]. * @param runConcurrently Whether the job should run concurrently. Default is `true`. @@ -346,6 +345,7 @@ class KtScheduler( * @return The ID of the scheduled job. */ fun runDaily( + jobId: String = "runDaily-${UUID.randomUUID()}", dailyTime: LocalTime, dispatcher: CoroutineDispatcher = Dispatchers.Default, runConcurrently: Boolean = true, @@ -353,14 +353,14 @@ class KtScheduler( ): String { val trigger = DailyTrigger(dailyTime) val job = Job( - jobId = "runDaily-${UUID.randomUUID()}", + jobId = jobId, trigger = trigger, nextRunTime = trigger.getNextRunTime(ZonedDateTime.now(timeZone), timeZone), runConcurrently = runConcurrently, dispatcher = dispatcher, callback = block ) - job.let { addJob(it) }.also { return job.jobId } + addJob(job).also { return job.jobId } } /** @@ -372,17 +372,18 @@ class KtScheduler( * ``` * val trigger = IntervalTrigger(intervalSeconds) * val job = Job( - * jobId = "runRepeating-${UUID.randomUUID()}", - * trigger = trigger, - * nextRunTime = trigger.getNextRunTime(ZonedDateTime.now(timeZone), timeZone), - * runConcurrently = runConcurrently, - * dispatcher = dispatcher, - * callback = block + * jobId = "runRepeating-${UUID.randomUUID()}", + * trigger = trigger, + * nextRunTime = trigger.getNextRunTime(ZonedDateTime.now(timeZone), timeZone), + * runConcurrently = runConcurrently, + * dispatcher = dispatcher, + * callback = block * ) * * scheduler.addJob(job) * ``` * + * @param jobId The ID of the job. Default is a random UUID. * @param intervalSeconds The interval in seconds at which the job should run. * @param dispatcher The coroutine dispatcher to use. Default is [Dispatchers.Default]. * @param runConcurrently Whether the job should run concurrently. Default is `true`. @@ -390,6 +391,7 @@ class KtScheduler( * @return The ID of the scheduled job. */ fun runRepeating( + jobId: String = "runRepeating-${UUID.randomUUID()}", intervalSeconds: Long, dispatcher: CoroutineDispatcher = Dispatchers.Default, runConcurrently: Boolean = true, @@ -397,14 +399,14 @@ class KtScheduler( ): String { val trigger = IntervalTrigger(intervalSeconds) val job = Job( - jobId = "runRepeating-${UUID.randomUUID()}", + jobId = jobId, trigger = trigger, nextRunTime = trigger.getNextRunTime(ZonedDateTime.now(timeZone), timeZone), runConcurrently = runConcurrently, dispatcher = dispatcher, callback = block ) - job.let { addJob(it) }.also { return job.jobId } + addJob(job).also { return job.jobId } } /** @@ -415,17 +417,18 @@ class KtScheduler( * * ``` * val job = Job( - * jobId = "runOnce-${UUID.randomUUID()}", - * trigger = OneTimeTrigger(runAt), - * nextRunTime = runAt, - * runConcurrently = runConcurrently, - * dispatcher = dispatcher, - * callback = callback + * jobId = "runOnce-${UUID.randomUUID()}", + * trigger = OneTimeTrigger(runAt), + * nextRunTime = runAt, + * runConcurrently = runConcurrently, + * dispatcher = dispatcher, + * callback = callback * ) * * scheduler.addJob(job) * ``` * + * @param jobId The ID of the job. Default is a random UUID. * @param runAt The time at which the job should run. * @param dispatcher The coroutine dispatcher to use. Default is [Dispatchers.Default]. * @param runConcurrently Whether the job should run concurrently. Default is `true`. @@ -433,20 +436,21 @@ class KtScheduler( * @return The ID of the scheduled job. */ fun runOnce( + jobId: String = "runOnce-${UUID.randomUUID()}", runAt: ZonedDateTime, dispatcher: CoroutineDispatcher = Dispatchers.Default, runConcurrently: Boolean = true, block: suspend () -> Unit ): String { val job = Job( - jobId = "runOnce-${UUID.randomUUID()}", + jobId = jobId, trigger = OneTimeTrigger(runAt), nextRunTime = runAt, runConcurrently = runConcurrently, dispatcher = dispatcher, callback = block ) - job.let { addJob(it) }.also { return job.jobId } + addJob(job).also { return job.jobId } } // ============================================================================================ diff --git a/src/test/kotlin/dev/starry/ktscheduler/CoroutineExecutorTest.kt b/src/test/kotlin/dev/starry/ktscheduler/CoroutineExecutorTest.kt index 37165e5..517f4e7 100644 --- a/src/test/kotlin/dev/starry/ktscheduler/CoroutineExecutorTest.kt +++ b/src/test/kotlin/dev/starry/ktscheduler/CoroutineExecutorTest.kt @@ -40,15 +40,6 @@ import kotlin.test.assertNotNull @OptIn(ExperimentalCoroutinesApi::class) class CoroutineExecutorTest { - private lateinit var executor: CoroutineExecutor - private lateinit var trigger: OneTimeTrigger - - @Before - fun setUp() { - executor = CoroutineExecutor() - trigger = OneTimeTrigger(ZonedDateTime.now(ZoneId.of("UTC")).plusSeconds(1)) - } - @After fun tearDown() { Dispatchers.resetMain() @@ -56,6 +47,7 @@ class CoroutineExecutorTest { @Test fun testExecuteSuccess(): Unit = runTest { + val executor = CoroutineExecutor() val job = createTestJob(scheduler = testScheduler) { } var onSuccessCalled = false val onSuccess: () -> Unit = { onSuccessCalled = true } @@ -68,6 +60,7 @@ class CoroutineExecutorTest { @Test fun testExecuteError(): Unit = runTest { + val executor = CoroutineExecutor() val job = createTestJob(scheduler = testScheduler) { throw IllegalArgumentException("Error") } val onSuccess: () -> Unit = { fail("onSuccess should not be called") } @@ -83,6 +76,7 @@ class CoroutineExecutorTest { @Test fun testConcurrentExecution(): Unit = runTest { + val executor = CoroutineExecutor() // Create a job that takes 100ms to execute. val job = createTestJob( scheduler = testScheduler, runConcurrently = true @@ -102,11 +96,14 @@ class CoroutineExecutorTest { @Test fun testNonConcurrentExecution(): Unit = runTest { + val executor = CoroutineExecutor() // Create a job that takes 100ms to execute. - val job = createTestJob(scheduler = testScheduler, runConcurrently = false) { delay(100) } + val job = createTestJob( + scheduler = testScheduler, runConcurrently = false + ) { delay(100) } var onSuccessCalled = 0 - val onSuccess: () -> Unit = { onSuccessCalled++ } + val onSuccess: () -> Unit = { onSuccessCalled += 1 } val onError: (Throwable) -> Unit = { fail("onError should not be called") } // Execute the job 3 times concurrently. executor.execute(job, onSuccess, onError) @@ -124,7 +121,7 @@ class CoroutineExecutorTest { callback: suspend () -> Unit, ): Job = Job( jobId = jobId, - trigger = trigger, + trigger = OneTimeTrigger(ZonedDateTime.now(ZoneId.of("UTC")).plusSeconds(1)), nextRunTime = ZonedDateTime.now(), dispatcher = UnconfinedTestDispatcher(scheduler), runConcurrently = runConcurrently, diff --git a/src/test/kotlin/dev/starry/ktscheduler/KtSchedulerTest.kt b/src/test/kotlin/dev/starry/ktscheduler/KtSchedulerTest.kt index c706767..506fd47 100644 --- a/src/test/kotlin/dev/starry/ktscheduler/KtSchedulerTest.kt +++ b/src/test/kotlin/dev/starry/ktscheduler/KtSchedulerTest.kt @@ -27,7 +27,6 @@ import dev.starry.ktscheduler.triggers.IntervalTrigger import dev.starry.ktscheduler.triggers.OneTimeTrigger import junit.framework.TestCase.assertFalse import junit.framework.TestCase.assertTrue -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.test.runTest import org.junit.Test @@ -39,7 +38,6 @@ import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.fail -@OptIn(ExperimentalCoroutinesApi::class) class KtSchedulerTest { @Test @@ -274,7 +272,7 @@ class KtSchedulerTest { scheduler.addEventListener(eventListener) scheduler.start() - Thread.sleep(2100) + Thread.sleep(2200) // Job 1 should be completed twice assertEquals(2, eventListener.completedJobs.size) @@ -284,12 +282,12 @@ class KtSchedulerTest { // Job 1 should be rescheduled val rescheduledJob = scheduler.getJob("job1") assertNotNull(rescheduledJob) - assertEquals(startTime.plusSeconds(3).year, rescheduledJob.nextRunTime.year) - assertEquals(startTime.plusSeconds(3).month, rescheduledJob.nextRunTime.month) - assertEquals(startTime.plusSeconds(3).dayOfMonth, rescheduledJob.nextRunTime.dayOfMonth) - assertEquals(startTime.plusSeconds(3).hour, rescheduledJob.nextRunTime.hour) - assertEquals(startTime.plusSeconds(3).minute, rescheduledJob.nextRunTime.minute) - assertEquals(startTime.plusSeconds(3).second, rescheduledJob.nextRunTime.second) + assertEquals(startTime.plusSeconds(3).year, rescheduledJob.nextRunTime!!.year) + assertEquals(startTime.plusSeconds(3).month, rescheduledJob.nextRunTime!!.month) + assertEquals(startTime.plusSeconds(3).dayOfMonth, rescheduledJob.nextRunTime!!.dayOfMonth) + assertEquals(startTime.plusSeconds(3).hour, rescheduledJob.nextRunTime!!.hour) + assertEquals(startTime.plusSeconds(3).minute, rescheduledJob.nextRunTime!!.minute) + assertEquals(startTime.plusSeconds(3).second, rescheduledJob.nextRunTime!!.second) scheduler.shutdown() } @@ -346,7 +344,7 @@ class KtSchedulerTest { // because the job is not run concurrently, and it takes 2 seconds to execute assertEquals(1, eventListener.completedJobs.size) // Assert that the job was executed twice after 4 seconds - Thread.sleep(1100) + Thread.sleep(1200) assertEquals(2, eventListener.completedJobs.size) assertEquals("longRunningJob", eventListener.completedJobs[0]) assertEquals("longRunningJob", eventListener.completedJobs[1]) @@ -382,10 +380,71 @@ class KtSchedulerTest { assertEquals("longRunningJob", eventListener.completedJobs[1]) } + @Test + fun `test nextRuntime is getting calculated if not passed`() { + val scheduler = KtScheduler() + val intervalJob = Job( + jobId = "intervalJob", + trigger = IntervalTrigger(intervalSeconds = 1), + callback = { /* Do nothing */ } + ) + val oneTimeJob = Job( + jobId = "oneTimeJob", + trigger = OneTimeTrigger(runAt = ZonedDateTime.now().plusSeconds(1)), + callback = { /* Do nothing */ } + ) + val dailyJob = Job( + jobId = "dailyJob", + trigger = DailyTrigger(time = LocalTime.of(10, 0)), + callback = { /* Do nothing */ } + ) + val cronJob = Job( + jobId = "cronJob", + trigger = CronTrigger( + daysOfWeek = setOf(DayOfWeek.SATURDAY, DayOfWeek.SUNDAY), + time = LocalTime.of(10, 0) + ), + callback = { /* Do nothing */ } + ) + + scheduler.addJob(intervalJob) + scheduler.addJob(oneTimeJob) + scheduler.addJob(dailyJob) + scheduler.addJob(cronJob) + + scheduler.getJob("intervalJob")?.let { + assertNotNull(it.nextRunTime) + assertTrue(it.nextRunTime!! > ZonedDateTime.now()) + assertTrue(it.nextRunTime!! < ZonedDateTime.now().plusSeconds(2)) + } ?: fail("Job not found") + + scheduler.getJob("oneTimeJob")?.let { + assertNotNull(it.nextRunTime) + assertTrue(it.nextRunTime!! > ZonedDateTime.now()) + assertTrue(it.nextRunTime!! < ZonedDateTime.now().plusSeconds(2)) + } ?: fail("Job not found") + + scheduler.getJob("dailyJob")?.let { + assertNotNull(it.nextRunTime) + assertTrue(it.nextRunTime!!.hour == 10) + assertTrue(it.nextRunTime!!.minute == 0) + } ?: fail("Job not found") + scheduler.getJob("cronJob")?.let { + assertNotNull(it.nextRunTime) + assertTrue(it.nextRunTime!!.dayOfWeek == DayOfWeek.SATURDAY || it.nextRunTime!!.dayOfWeek == DayOfWeek.SUNDAY) + assertTrue(it.nextRunTime!!.hour == 10) + assertTrue(it.nextRunTime!!.minute == 0) + } ?: fail("Job not found") + } + @Test fun `test runCron schedules the cron job`() { val scheduler = KtScheduler() - val jobId = scheduler.runCron(setOf(DayOfWeek.SATURDAY, DayOfWeek.SUNDAY), LocalTime.of(10, 0)) {} + val jobId = scheduler.runCron( + daysOfWeek = setOf(DayOfWeek.SATURDAY, DayOfWeek.SUNDAY), + time = LocalTime.of(10, 0) + ) { /* do nothing */ } + scheduler.getJob(jobId)?.let { assertTrue(it.trigger is CronTrigger) assertTrue(it.jobId.startsWith("runCron")) @@ -418,7 +477,7 @@ class KtSchedulerTest { @Test fun `test runOnce schedules the one time job`() { val scheduler = KtScheduler() - val jobId = scheduler.runOnce(ZonedDateTime.now().plusSeconds(10)) {/* do nothing */ } + val jobId = scheduler.runOnce(runAt = ZonedDateTime.now().plusSeconds(10)) {/* do nothing */ } scheduler.getJob(jobId)?.let { assertTrue(it.trigger is OneTimeTrigger) assertTrue(it.jobId.startsWith("runOnce"))