diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 677ba1f..b854d98 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,15 +12,15 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - name: set up JDK 21 - uses: actions/setup-java@v3 - with: - java-version: '21' - distribution: 'temurin' - cache: gradle + - uses: actions/checkout@v3 + - name: set up JDK 21 + uses: actions/setup-java@v3 + with: + java-version: '21' + distribution: 'temurin' + cache: gradle - - name: Grant execute permission for gradlew - run: chmod +x gradlew - - name: Run tests with gradle - run: ./gradlew test --stacktrace + - name: Grant execute permission for gradlew + run: chmod +x gradlew + - name: Run tests with gradle + run: ./gradlew test --stacktrace diff --git a/README.md b/README.md index 6c4790d..1dba333 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,9 @@ Project Status: Active – The project has reached a stable, usable state and is being actively developed.

-**KtScheduler** is a lightweight task/job scheduling library for Kotlin, powered by Kotlin coroutines! The design of this library is inspired by the [APScheduler](https://github.com/agronholm/apscheduler) library for Python, while keeping things simple and easy to use. +**KtScheduler** is a lightweight task/job scheduling library for Kotlin, powered by Kotlin coroutines! The design of +this library is inspired by the [APScheduler](https://github.com/agronholm/apscheduler) library for Python, while +keeping things simple and easy to use. ------ @@ -66,7 +68,8 @@ scheduler.idle() #### Triggers -Triggers determine when and at what frequency a particular job should be executed. KtScheduler provides four types of triggers: +Triggers determine when and at what frequency a particular job should be executed. KtScheduler provides four types of +triggers: 1. `CronTrigger` - A trigger that determines the next run time based on the specified days of the week and time. @@ -131,7 +134,8 @@ class WeekendTrigger(private val time: LocalTime) : Trigger { #### Listening for Job Events -You can listen for job events such as completion or failure due to errors by attaching a `JobEventListener` to the `KtScheduler`. Here's an example: +You can listen for job events such as completion or failure due to errors by attaching a `JobEventListener` to +the `KtScheduler`. Here's an example: ```kotlin import dev.starry.ktscheduler.event.JobEvent @@ -150,21 +154,26 @@ class MyEventListener : JobEventListener { val eventListener = MyEventListener() scheduler.addEventListener(eventListener) ``` + ------ -#### Contributing 🫶 +### Contributing 🫶 -Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change, or feel free to tackle any of the open issues present at the moment. If you're doing the latter, please leave a comment on the issue you want to contribute to before starting to work on it. +Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change, or +feel free to tackle any of the open issues present at the moment. If you're doing the latter, please leave a comment on +the issue you want to contribute to before starting to work on it. ------ -#### Supporting ❤️ +### Supporting ❤️ -If you found this library helpful, you can support me by giving a small tip via [GitHub Sponsors](https://github.com/sponsors/starry-shivam) and joining the list of stargazers 🌟 +If you found this library helpful, you can support me by giving a small tip +via [GitHub Sponsors](https://github.com/sponsors/starry-shivam) and joining the list of stargazers 🌟 ------ -#### License ©️ +### License ©️ + ``` Copyright [2024 - Present] starry-shivam diff --git a/build.gradle.kts b/build.gradle.kts index 8abadd9..db4c6ac 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,6 @@ plugins { kotlin("jvm") version "2.0.0" + id("org.jetbrains.kotlinx.kover") version "0.8.1" `maven-publish` } @@ -21,13 +22,18 @@ dependencies { testImplementation("org.jetbrains.kotlin:kotlin-test-junit:2.0.0") } +kover { + reports { + verify { rule { minBound(70) } } + } +} publishing { publications { register("mavenJava", MavenPublication::class) { - groupId = "dev.starry.ktscheduler" + groupId = group.toString() artifactId = "ktscheduler" - version = "1.0.0" + version = version.toString() from(components["java"]) } } diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt deleted file mode 100644 index 773261a..0000000 --- a/src/main/kotlin/Main.kt +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Copyright [2024 - Present] starry-shivam - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package dev.starry.ktscheduler - -import dev.starry.ktscheduler.event.JobEvent -import dev.starry.ktscheduler.event.JobEventListener -import dev.starry.ktscheduler.job.Job -import dev.starry.ktscheduler.scheduler.KtScheduler -import dev.starry.ktscheduler.triggers.IntervalTrigger -import dev.starry.ktscheduler.triggers.OneTimeTrigger -import kotlinx.coroutines.Dispatchers -import java.time.ZoneId -import java.time.ZonedDateTime - -class MyEventListener : JobEventListener { - override fun onJobComplete(event: JobEvent) { - println("Job ${event.jobId} completed successfully at ${event.timestamp}") - } - - override fun onJobError(event: JobEvent) { - println("Job ${event.jobId} failed with exception ${event.exception} at ${event.timestamp}") - } -} - -fun main() { - val timeZone = ZoneId.of("Asia/Kolkata") - val scheduler = KtScheduler(timeZone = timeZone) - - val job = Job( - jobId = "OneTimeJob", - function = { println("OneTime Job executed at ${ZonedDateTime.now(timeZone)}") }, - trigger = OneTimeTrigger(ZonedDateTime.now(timeZone).plusSeconds(5)), - nextRunTime = ZonedDateTime.now(timeZone).plusSeconds(5), - dispatcher = Dispatchers.Default - ) - - val errorJob = Job( - jobId = "RaiseErrorJob", - function = { throw Exception("Meow >~<") }, - trigger = OneTimeTrigger(ZonedDateTime.now(timeZone).plusSeconds(10)), - nextRunTime = ZonedDateTime.now(timeZone).plusSeconds(10), - dispatcher = Dispatchers.Default - ) - - val intervalJob = Job( - jobId = "RepeatingJob", - function = { println("Repeating job executed at ${ZonedDateTime.now(timeZone)}") }, - trigger = IntervalTrigger(intervalSeconds = 5), - nextRunTime = ZonedDateTime.now(timeZone).plusSeconds(5), - dispatcher = Dispatchers.Default - ) - - val eventListener = MyEventListener() - scheduler.addEventListener(eventListener) - - scheduler.addJob(job) - scheduler.addJob(errorJob) - scheduler.addJob(intervalJob) - scheduler.start() - - // Block the main thread and idle the scheduler. - scheduler.idle() -} diff --git a/src/main/kotlin/scheduler/KtScheduler.kt b/src/main/kotlin/scheduler/KtScheduler.kt index cd35eac..0941967 100644 --- a/src/main/kotlin/scheduler/KtScheduler.kt +++ b/src/main/kotlin/scheduler/KtScheduler.kt @@ -58,10 +58,6 @@ class KtScheduler( private val logger = Logger.getLogger(TAG) } - // The coroutine scope with a SupervisorJob to prevent cancellation of all jobs - // if one of them fails. - private val coroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - // The list of event listeners attached to the scheduler. private val eventListeners = mutableListOf() @@ -77,14 +73,26 @@ class KtScheduler( // The tick interval in milliseconds. private val tickInterval = 100L + // The coroutine scope with a SupervisorJob to prevent cancellation of all jobs + // if one of them fails. + private lateinit var coroutineScope: CoroutineScope + /** * Starts the scheduler. * * The scheduler will run in a coroutine and continuously process due jobs * at the specified tick interval unless it is paused or shut down. + * + * @throws IllegalStateException If the scheduler is already running. */ override fun start() { + // Check if the scheduler is already running. + if (::coroutineScope.isInitialized && coroutineScope.isActive) { + throw IllegalStateException("Scheduler is already running") + } + // Start the scheduler. logger.info("Starting scheduler") + coroutineScope = createCoroutineScope() coroutineScope.launch { while (isActive) { if (!isPaused) { @@ -231,6 +239,9 @@ class KtScheduler( eventListeners.add(listener) } + // Creates a new coroutine scope. + private fun createCoroutineScope() = CoroutineScope(Dispatchers.Default + SupervisorJob()) + // Processes due jobs and executes them. private suspend fun processDueJobs() { val now = ZonedDateTime.now(timeZone) diff --git a/src/test/kotlin/CoroutineExecutorTest.kt b/src/test/kotlin/CoroutineExecutorTest.kt index d1794b4..7cd0f7f 100644 --- a/src/test/kotlin/CoroutineExecutorTest.kt +++ b/src/test/kotlin/CoroutineExecutorTest.kt @@ -23,7 +23,6 @@ import dev.starry.ktscheduler.triggers.OneTimeTrigger import junit.framework.TestCase.assertTrue import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.delay import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest diff --git a/src/test/kotlin/TestScheduler.kt b/src/test/kotlin/KtSchedulerTest.kt similarity index 53% rename from src/test/kotlin/TestScheduler.kt rename to src/test/kotlin/KtSchedulerTest.kt index 899283c..fbf3f0c 100644 --- a/src/test/kotlin/TestScheduler.kt +++ b/src/test/kotlin/KtSchedulerTest.kt @@ -20,8 +20,8 @@ package dev.starry.ktscheduler.test import dev.starry.ktscheduler.event.JobEvent import dev.starry.ktscheduler.event.JobEventListener import dev.starry.ktscheduler.job.Job -import dev.starry.ktscheduler.jobstore.InMemoryJobStore import dev.starry.ktscheduler.scheduler.KtScheduler +import dev.starry.ktscheduler.triggers.IntervalTrigger import dev.starry.ktscheduler.triggers.OneTimeTrigger import junit.framework.TestCase.assertFalse import junit.framework.TestCase.assertTrue @@ -30,11 +30,24 @@ import kotlinx.coroutines.test.runTest import org.junit.Test import java.time.ZonedDateTime import kotlin.test.assertEquals +import kotlin.test.assertNotNull import kotlin.test.assertNull @OptIn(ExperimentalCoroutinesApi::class) class KtSchedulerTest { + @Test + fun `scheduler should throw exception when starting if already running`() { + val scheduler = KtScheduler() + scheduler.start() + try { + scheduler.start() + } catch (e: IllegalStateException) { + assertEquals("Scheduler is already running", e.message) + } + scheduler.shutdown() + } + @Test fun `addJob should add job to the scheduler`() { val scheduler = KtScheduler() @@ -84,6 +97,76 @@ class KtSchedulerTest { scheduler.shutdown() } + @Test + fun `scheduler should not process jobs when paused`() { + val scheduler = KtScheduler() + val job = createTestJob("job1") + val eventListener = TestJobEventListener() + + scheduler.addEventListener(eventListener) + scheduler.addJob(job) + + // Start and pause scheduler + scheduler.start() + scheduler.pause() + Thread.sleep(1200) + + // Job should not be completed + assertEquals(0, eventListener.completedJobs.size) + // job should not be removed as it is not completed. + val retrievedJob = scheduler.getJob("job1") + assertNotNull(retrievedJob) + assertEquals(job, retrievedJob) + + // Resume scheduler + scheduler.resume() + Thread.sleep(1200) + + // Job should be completed + assertEquals(1, eventListener.completedJobs.size) + assertEquals("job1", eventListener.completedJobs[0]) + + // One time trigger job should be removed after execution + val processedJob = scheduler.getJob("job1") + assertNull(processedJob) + + scheduler.shutdown() + } + + @Test + fun `scheduler should not process jobs when shutdown`() { + val scheduler = KtScheduler() + val job = createTestJob("job1") + val eventListener = TestJobEventListener() + + scheduler.addEventListener(eventListener) + scheduler.addJob(job) + + // Start and shutdown scheduler + scheduler.start() + scheduler.shutdown() + + // Job should not be completed + assertEquals(0, eventListener.completedJobs.size) + // job should not be removed as it is not completed. + val retrievedJob = scheduler.getJob("job1") + assertNotNull(retrievedJob) + assertEquals(job, retrievedJob) + + // Start scheduler again + scheduler.start() + Thread.sleep(1200) + + // Job should be completed + assertEquals(1, eventListener.completedJobs.size) + assertEquals("job1", eventListener.completedJobs[0]) + + // One time trigger job should be removed after execution + val processedJob = scheduler.getJob("job1") + assertNull(processedJob) + scheduler.shutdown() + } + @Test fun `pauseJob and resumeJob should control individual job execution`() { val scheduler = KtScheduler() @@ -99,8 +182,7 @@ class KtSchedulerTest { @Test fun `scheduler should process due jobs`(): Unit = runTest { - val jobStore = InMemoryJobStore() - val scheduler = KtScheduler(jobStore) + val scheduler = KtScheduler() val startTime = ZonedDateTime.now() // Job 1 should run after 1 second val job = createTestJob("job1", startTime.plusSeconds(1)) @@ -134,6 +216,44 @@ class KtSchedulerTest { scheduler.shutdown() } + @Test + fun `scheduler should reschedule jobs with recurring triggers`(): Unit = runTest { + val scheduler = KtScheduler() + val startTime = ZonedDateTime.now() + // Job 1 should run after 1 second and then every 1 second + val job = Job( + jobId = "job1", + function = {}, + trigger = IntervalTrigger(intervalSeconds = 1), + nextRunTime = startTime.plusSeconds(1) + ) + + scheduler.addJob(job) + + val eventListener = TestJobEventListener() + scheduler.addEventListener(eventListener) + + scheduler.start() + Thread.sleep(2100) + + // Job 1 should be completed twice + assertEquals(2, eventListener.completedJobs.size) + assertEquals("job1", eventListener.completedJobs[0]) + assertEquals("job1", eventListener.completedJobs[1]) + + // 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) + + scheduler.shutdown() + } + private fun createTestJob( jobId: String, runAt: ZonedDateTime = ZonedDateTime.now().plusSeconds(1),