From c7069837884b27e46a7ac01b4321476914dd5dd9 Mon Sep 17 00:00:00 2001 From: Jan Gaebel Date: Fri, 16 Dec 2022 11:47:54 +0100 Subject: [PATCH 01/28] feat(job): support serialization --- .../liftric/persisted/queue/Preferences.kt | 6 --- .../kotlin/com/liftric/persisted/queue/Job.kt | 17 ++++--- .../liftric/persisted/queue/JobDelegate.kt | 4 +- .../com/liftric/persisted/queue/JobInfo.kt | 2 + .../liftric/persisted/queue/JobScheduler.kt | 47 ++++++++++++++----- .../liftric/persisted/queue/JobSerializer.kt | 8 ---- .../liftric/persisted/queue/Preferences.kt | 25 ---------- .../com/liftric/persisted/queue/Queue.kt | 5 +- .../com/liftric/persisted/queue/Task.kt | 15 +++--- .../persisted/queue/rules/DelayRule.kt | 2 +- .../persisted/queue/rules/TimeoutRule.kt | 2 + .../persisted/queue/JobSchedulerTests.kt | 10 +++- .../com/liftric/persisted/queue/TestTask.kt | 5 +- .../liftric/persisted/queue/Preferences.kt | 6 --- 14 files changed, 75 insertions(+), 79 deletions(-) delete mode 100644 src/androidMain/kotlin/com/liftric/persisted/queue/Preferences.kt delete mode 100644 src/commonMain/kotlin/com/liftric/persisted/queue/JobSerializer.kt delete mode 100644 src/commonMain/kotlin/com/liftric/persisted/queue/Preferences.kt delete mode 100644 src/iosMain/kotlin/com/liftric/persisted/queue/Preferences.kt diff --git a/src/androidMain/kotlin/com/liftric/persisted/queue/Preferences.kt b/src/androidMain/kotlin/com/liftric/persisted/queue/Preferences.kt deleted file mode 100644 index 02c2822..0000000 --- a/src/androidMain/kotlin/com/liftric/persisted/queue/Preferences.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.liftric.persisted.queue - -import android.content.Context -import com.russhwolf.settings.SharedPreferencesSettings - -actual class Preferences(context: Context): AbstractPreferences(SharedPreferencesSettings.Factory(context).create("com.liftric.job.scheduler")) diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/Job.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/Job.kt index b4ad5fb..13d57fd 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/Job.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/Job.kt @@ -3,17 +3,20 @@ package com.liftric.persisted.queue import kotlinx.coroutines.* import kotlinx.datetime.Clock import kotlinx.datetime.Instant -import kotlin.time.Duration +import kotlinx.serialization.Contextual +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient -class Job( - override val id: UUID, +@Serializable +data class Job( + @Contextual override val id: UUID, override val info: JobInfo, - override val task: DataTask<*>, - override val startTime: Instant = Clock.System.now() + override val task: DataTask, + @Contextual override val startTime: Instant ): JobContext { - var delegate: JobDelegate? = null + @Transient var delegate: JobDelegate? = null - constructor(task: DataTask<*>, info: JobInfo) : this (UUID::class.instance(), info, task) + constructor(task: DataTask, info: JobInfo) : this (UUID::class.instance(), info, task, Clock.System.now()) private var cancellable: kotlinx.coroutines.Job? = null diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/JobDelegate.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/JobDelegate.kt index b20943f..ada5b0a 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/JobDelegate.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/JobDelegate.kt @@ -2,7 +2,7 @@ package com.liftric.persisted.queue class JobDelegate { var onExit: (suspend () -> Unit)? = null - var onRepeat: (suspend (Job) -> Unit)? = null + var onRepeat: (suspend (Job<*>) -> Unit)? = null var onEvent: (suspend (JobEvent) -> Unit)? = null suspend fun broadcast(event: JobEvent) { @@ -13,7 +13,7 @@ class JobDelegate { onExit?.invoke() } - suspend fun repeat(job: Job) { + suspend fun repeat(job: Job<*>) { onRepeat?.invoke(job) } } diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/JobInfo.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/JobInfo.kt index baa6251..95bcf63 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/JobInfo.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/JobInfo.kt @@ -1,7 +1,9 @@ package com.liftric.persisted.queue import kotlin.time.Duration +import kotlinx.serialization.Serializable +@Serializable data class JobInfo( var tag: String? = null, var timeout: Duration = Duration.INFINITE, diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt index a831fcd..685e990 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt @@ -1,19 +1,41 @@ package com.liftric.persisted.queue +import com.liftric.persisted.queue.rules.* import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.* -import kotlinx.serialization.InternalSerializationApi -import kotlinx.serialization.serializerOrNull +import kotlinx.datetime.serializers.InstantIso8601Serializer +import kotlinx.serialization.* +import kotlinx.serialization.json.Json +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.contextual +import kotlinx.serialization.modules.plus +import kotlinx.serialization.modules.polymorphic class JobScheduler( - configuration: Queue.Configuration? = null, - private val serializer: JobSerializer? = null + serializers: SerializersModule = SerializersModule {}, + configuration: Queue.Configuration? = null ) { val queue = JobQueue(configuration ?: Queue.Configuration(CoroutineScope(Dispatchers.Default), 1)) val onEvent = MutableSharedFlow(extraBufferCapacity = Int.MAX_VALUE) - private val delegate = JobDelegate() + private val module = SerializersModule { + contextual(UUIDSerializer) + contextual(InstantIso8601Serializer) + polymorphic(JobRule::class) { + subclass(DelayRule::class, DelayRule.serializer()) + subclass(PeriodicRule::class, PeriodicRule.serializer()) + subclass(RetryRule::class, RetryRule.serializer()) + subclass(TimeoutRule::class, TimeoutRule.serializer()) + subclass(UniqueRule::class, UniqueRule.serializer()) + subclass(PersistenceRule::class, PersistenceRule.serializer()) + } + } + + val format = Json { serializersModule = module.plus(serializers) } + + @PublishedApi + internal val delegate = JobDelegate() init { delegate.onExit = { /* Do something */ } @@ -21,21 +43,24 @@ class JobScheduler( delegate.onEvent = { onEvent.emit(it) } } - suspend fun schedule(task: () -> DataTask<*>, configure: JobInfo.() -> JobInfo = { JobInfo() }) { + suspend fun schedule(task: () -> DataTask, configure: JobInfo.() -> JobInfo = { JobInfo() }) { schedule(task(), configure) } - @OptIn(InternalSerializationApi::class) - suspend fun schedule(task: DataTask<*>, configure: JobInfo.() -> JobInfo = { JobInfo() }) = try { + suspend inline fun schedule(data: Data, task: (Data) -> DataTask, configure: JobInfo.() -> JobInfo = { JobInfo() }) { + schedule(task(data), configure) + } + + suspend inline fun schedule(task: DataTask, configure: JobInfo.() -> JobInfo = { JobInfo() }) = try { val info = configure(JobInfo()).apply { rules.forEach { it.mutating(this) } } - if (task.data!!::class.serializerOrNull() == null) throw Exception("Data must be serializable") - val job = Job(task, info) job.delegate = delegate + println(format.encodeToString(job)) + job.info.rules.forEach { it.willSchedule(queue, job) } @@ -47,7 +72,7 @@ class JobScheduler( onEvent.emit(JobEvent.DidThrowOnSchedule(error)) } - private suspend fun repeat(job: Job) = try { + private suspend fun repeat(job: Job<*>) = try { job.delegate = delegate job.info.rules.forEach { diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/JobSerializer.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/JobSerializer.kt deleted file mode 100644 index b1773c7..0000000 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/JobSerializer.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.liftric.persisted.queue - -interface JobSerializer { - val tag: String - fun store(job: Job) - fun retrieve(id: String): Job? - fun retrieveAll(): List -} diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/Preferences.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/Preferences.kt deleted file mode 100644 index 9dc8184..0000000 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/Preferences.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.liftric.persisted.queue - -import com.russhwolf.settings.Settings -import com.russhwolf.settings.get -import com.russhwolf.settings.set -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json - -expect class Preferences: AbstractPreferences -abstract class AbstractPreferences(private val settings: Settings): JobSerializer { - override val tag: String = "" - override fun retrieve(id: String): Job? { - val jsonString = settings.get(id) ?: return null - return Json.decodeFromString(jsonString) - } - - override fun retrieveAll(): List { - return settings.keys.mapNotNull { retrieve(it) } - } - - override fun store(job: Job) { - settings[job.id.toString()] = Json.encodeToString(job) - } -} diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt index 94efd79..bace1b4 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt @@ -24,7 +24,7 @@ interface Queue { class JobQueue(override val configuration: Queue.Configuration): Queue { private val cancellationQueue = MutableSharedFlow(extraBufferCapacity = Int.MAX_VALUE) - private val queue = atomic(mutableListOf()) + private val queue = atomic(mutableListOf>()) private val lock = Semaphore(configuration.maxConcurrency, 0) private val isCancelling = Mutex(false) override val jobs: List @@ -36,7 +36,8 @@ class JobQueue(override val configuration: Queue.Configuration): Queue { .launchIn(configuration.scope) } - internal fun add(job: Job) { + @PublishedApi + internal fun add(job: Job<*>) { queue.value = queue.value.plus(listOf(job)).sortedBy { it.startTime }.toMutableList() } diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/Task.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/Task.kt index aeb5208..36dda82 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/Task.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/Task.kt @@ -1,13 +1,12 @@ package com.liftric.persisted.queue -import kotlinx.serialization.Serializable - -@Serializable -abstract class DataTask(@Serializable val data: Data) { +interface DataTask { + val data: Data @Throws(Throwable::class) - abstract suspend fun body() - open suspend fun onRepeat(cause: Throwable): Boolean = false + suspend fun body() + suspend fun onRepeat(cause: Throwable): Boolean = false } -@Serializable -abstract class Task: DataTask(Unit) +abstract class Task: DataTask { + override val data: Unit = Unit +} diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/rules/DelayRule.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/rules/DelayRule.kt index 19a240e..4728f8e 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/rules/DelayRule.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/rules/DelayRule.kt @@ -2,9 +2,9 @@ package com.liftric.persisted.queue.rules import com.liftric.persisted.queue.* import kotlinx.coroutines.delay -import kotlinx.serialization.Serializable import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds +import kotlinx.serialization.Serializable @Serializable data class DelayRule(val duration: Duration = 0.seconds): JobRule() { diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/rules/TimeoutRule.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/rules/TimeoutRule.kt index 6b1c685..e1943f2 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/rules/TimeoutRule.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/rules/TimeoutRule.kt @@ -3,7 +3,9 @@ package com.liftric.persisted.queue.rules import com.liftric.persisted.queue.JobInfo import com.liftric.persisted.queue.JobRule import kotlin.time.Duration +import kotlinx.serialization.Serializable +@Serializable data class TimeoutRule(val timeout: Duration): JobRule() { override suspend fun mutating(info: JobInfo) { info.timeout = timeout diff --git a/src/commonTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt b/src/commonTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt index ddeb48a..983f35f 100644 --- a/src/commonTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt +++ b/src/commonTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt @@ -2,13 +2,19 @@ package com.liftric.persisted.queue import com.liftric.persisted.queue.rules.* import kotlinx.coroutines.* +import kotlinx.serialization.modules.SerializersModule import kotlin.test.* import kotlin.time.Duration.Companion.seconds +import kotlinx.serialization.modules.polymorphic class JobSchedulerTests { @Test fun testSchedule() = runBlocking { - val scheduler = JobScheduler() + val scheduler = JobScheduler(serializers = SerializersModule { + polymorphic(DataTask::class) { + subclass(TestTask::class, TestTask.serializer()) + } + }) val id = UUID::class.instance().toString() val job = async { scheduler.onEvent.collect { @@ -16,7 +22,7 @@ class JobSchedulerTests { } } - scheduler.schedule(TestTask(TestData(id))) { + scheduler.schedule(TestData(id), ::TestTask) { delay(1.seconds) unique(id) } diff --git a/src/commonTest/kotlin/com/liftric/persisted/queue/TestTask.kt b/src/commonTest/kotlin/com/liftric/persisted/queue/TestTask.kt index f697853..d1c06af 100644 --- a/src/commonTest/kotlin/com/liftric/persisted/queue/TestTask.kt +++ b/src/commonTest/kotlin/com/liftric/persisted/queue/TestTask.kt @@ -6,15 +6,18 @@ import kotlinx.serialization.Serializable @Serializable data class TestData(val testResultId: String) -class TestTask(data: TestData): DataTask(data) { +@Serializable +data class TestTask(override val data: TestData): DataTask { override suspend fun body() { } } +@Serializable class TestErrorTask: Task() { override suspend fun body() { throw Error("Oh shoot!") } override suspend fun onRepeat(cause: Throwable): Boolean = cause is Error } +@Serializable class LongRunningTask: Task() { override suspend fun body() { delay(10000L) } } diff --git a/src/iosMain/kotlin/com/liftric/persisted/queue/Preferences.kt b/src/iosMain/kotlin/com/liftric/persisted/queue/Preferences.kt deleted file mode 100644 index 3b233a1..0000000 --- a/src/iosMain/kotlin/com/liftric/persisted/queue/Preferences.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.liftric.persisted.queue - -import com.russhwolf.settings.NSUserDefaultsSettings -import platform.Foundation.NSUserDefaults - -actual class Preferences: AbstractPreferences(NSUserDefaultsSettings(NSUserDefaults(suiteName = "com.liftric.job.scheduler"))) From 576e14ac6b12a5eb7e7ba22790086d18a1fd06ab Mon Sep 17 00:00:00 2001 From: Jan Gaebel Date: Fri, 16 Dec 2022 11:54:01 +0100 Subject: [PATCH 02/28] fix(tests): serialize only if not void --- .../kotlin/com/liftric/persisted/queue/JobScheduler.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt index 685e990..86e036a 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt @@ -11,6 +11,7 @@ import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.contextual import kotlinx.serialization.modules.plus import kotlinx.serialization.modules.polymorphic +import kotlin.reflect.KClass class JobScheduler( serializers: SerializersModule = SerializersModule {}, @@ -59,7 +60,9 @@ class JobScheduler( val job = Job(task, info) job.delegate = delegate - println(format.encodeToString(job)) + if (Data::class.simpleName != "Unit") { + println(format.encodeToString(job)) + } job.info.rules.forEach { it.willSchedule(queue, job) From b7aae68ff78937c8c8de892309635dea101373fb Mon Sep 17 00:00:00 2001 From: Jan Gaebel Date: Fri, 16 Dec 2022 13:17:37 +0100 Subject: [PATCH 03/28] feat(queue): persistence --- build.gradle.kts | 1 + settings.gradle.kts | 1 + .../liftric/persisted/queue/JobScheduler.kt | 15 ++++++++ .../persisted/queue/JobSchedulerTests.kt | 14 +++++++ .../kotlin/com/liftric/persisted/queue/Job.kt | 4 +- .../liftric/persisted/queue/JobDelegate.kt | 6 +-- .../liftric/persisted/queue/JobScheduler.kt | 38 +++++++++++-------- .../com/liftric/persisted/queue/Queue.kt | 20 +++++++++- .../persisted/queue/JobSchedulerTests.kt | 22 +++-------- .../liftric/persisted/queue/JobScheduler.kt | 14 +++++++ .../persisted/queue/JobSchedulerTests.kt | 12 ++++++ 11 files changed, 109 insertions(+), 38 deletions(-) create mode 100644 src/androidMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt create mode 100644 src/androidTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt create mode 100644 src/iosMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt create mode 100644 src/iosTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt diff --git a/build.gradle.kts b/build.gradle.kts index e590de3..7c338cc 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -37,6 +37,7 @@ kotlin { implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.atomicfu) implementation(libs.multiplatform.settings) + implementation(libs.kstore) } } val commonTest by getting { diff --git a/settings.gradle.kts b/settings.gradle.kts index 4b10170..daeaae3 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -19,6 +19,7 @@ dependencyResolutionManagement { version("android-tools-gradle", "7.2.2") version("kotlin", "1.7.20") library("kotlinx-coroutines", "org.jetbrains.kotlinx", "kotlinx-coroutines-core").version("1.6.4") + library("kstore", "io.github.xxfast", "kstore").version("0.1.1") library("kotlinx-serialization", "org.jetbrains.kotlinx", "kotlinx-serialization-json").version("1.4.0") library("kotlinx-atomicfu", "org.jetbrains.kotlinx", "atomicfu").version("0.18.5") library("kotlinx-datetime", "org.jetbrains.kotlinx", "kotlinx-datetime").version("0.4.0") diff --git a/src/androidMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt b/src/androidMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt new file mode 100644 index 0000000..ad896b1 --- /dev/null +++ b/src/androidMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt @@ -0,0 +1,15 @@ +package com.liftric.persisted.queue + +import android.content.Context +import kotlinx.serialization.modules.SerializersModule + +actual class JobScheduler( + context: Context, + serializers: SerializersModule = SerializersModule {}, + configuration: Queue.Configuration? = null, + filePath: String? = null +) : AbstractJobScheduler( + serializers, + configuration, + filePath ?: context.filesDir.path +) diff --git a/src/androidTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt b/src/androidTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt new file mode 100644 index 0000000..a27c678 --- /dev/null +++ b/src/androidTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt @@ -0,0 +1,14 @@ +package com.liftric.persisted.queue + +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.polymorphic + +actual class JobSchedulerTests: AbstractJobSchedulerTests(JobScheduler( + context = InstrumentationRegistry.getInstrumentation().targetContext, + serializers = SerializersModule { + polymorphic(DataTask::class) { + subclass(TestTask::class, TestTask.serializer()) + } + } +)) diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/Job.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/Job.kt index 13d57fd..4459fd0 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/Job.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/Job.kt @@ -54,6 +54,8 @@ data class Job( delegate?.broadcast(JobEvent.DidCancel(this@Job, "Cancelled after run")) } catch (e: Error) { delegate?.broadcast(JobEvent.DidFailOnRemove(this@Job, e)) + } finally { + delegate?.exit(this@Job) } } } @@ -65,7 +67,7 @@ data class Job( cancellable?.cancel(CancellationException("Cancelled during run")) ?: run { delegate?.broadcast(JobEvent.DidCancel(this@Job, "Cancelled before run")) } - delegate?.exit() + delegate?.exit(this@Job) } override suspend fun repeat(id: UUID, info: JobInfo, task: DataTask<*>, startTime: Instant) { diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/JobDelegate.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/JobDelegate.kt index ada5b0a..5270ce3 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/JobDelegate.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/JobDelegate.kt @@ -1,7 +1,7 @@ package com.liftric.persisted.queue class JobDelegate { - var onExit: (suspend () -> Unit)? = null + var onExit: (suspend (Job<*>) -> Unit)? = null var onRepeat: (suspend (Job<*>) -> Unit)? = null var onEvent: (suspend (JobEvent) -> Unit)? = null @@ -9,8 +9,8 @@ class JobDelegate { onEvent?.invoke(event) } - suspend fun exit() { - onExit?.invoke() + suspend fun exit(job: Job<*>) { + onExit?.invoke(job) } suspend fun repeat(job: Job<*>) { diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt index 86e036a..f831679 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt @@ -1,26 +1,27 @@ package com.liftric.persisted.queue import com.liftric.persisted.queue.rules.* +import io.github.xxfast.kstore.* import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.* import kotlinx.datetime.serializers.InstantIso8601Serializer -import kotlinx.serialization.* import kotlinx.serialization.json.Json import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.contextual import kotlinx.serialization.modules.plus import kotlinx.serialization.modules.polymorphic -import kotlin.reflect.KClass -class JobScheduler( +expect class JobScheduler: AbstractJobScheduler +abstract class AbstractJobScheduler( serializers: SerializersModule = SerializersModule {}, - configuration: Queue.Configuration? = null + configuration: Queue.Configuration? = null, + filePath: String ) { - val queue = JobQueue(configuration ?: Queue.Configuration(CoroutineScope(Dispatchers.Default), 1)) val onEvent = MutableSharedFlow(extraBufferCapacity = Int.MAX_VALUE) - private val module = SerializersModule { + private val format = Json { serializersModule = module.plus(serializers) } + private val module = SerializersModule { contextual(UUIDSerializer) contextual(InstantIso8601Serializer) polymorphic(JobRule::class) { @@ -31,13 +32,22 @@ class JobScheduler( subclass(UniqueRule::class, UniqueRule.serializer()) subclass(PersistenceRule::class, PersistenceRule.serializer()) } - } - - val format = Json { serializersModule = module.plus(serializers) } + } @PublishedApi internal val delegate = JobDelegate() + @PublishedApi + internal val queue = JobQueue( + storeOf( + filePath = "${filePath}/jobs", + default = null, + enableCache = true, + serializer = format + ), + configuration ?: Queue.Configuration(CoroutineScope(Dispatchers.Default), 1) + ) + init { delegate.onExit = { /* Do something */ } delegate.onRepeat = { repeat(it) } @@ -60,10 +70,6 @@ class JobScheduler( val job = Job(task, info) job.delegate = delegate - if (Data::class.simpleName != "Unit") { - println(format.encodeToString(job)) - } - job.info.rules.forEach { it.willSchedule(queue, job) } @@ -82,9 +88,9 @@ class JobScheduler( it.willSchedule(queue, job) } - onEvent.emit(JobEvent.DidScheduleRepeat(job)) - - queue.add(job) + queue.add(job).apply { + onEvent.emit(JobEvent.DidScheduleRepeat(job)) + } } catch (error: Error) { onEvent.emit(JobEvent.DidThrowOnRepeat(error)) } diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt index bace1b4..8b07eca 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt @@ -1,5 +1,9 @@ package com.liftric.persisted.queue +import io.github.xxfast.kstore.KStore +import io.github.xxfast.kstore.getOrEmpty +import io.github.xxfast.kstore.minus +import io.github.xxfast.kstore.plus import kotlinx.atomicfu.atomic import kotlinx.coroutines.* import kotlinx.coroutines.flow.* @@ -22,7 +26,7 @@ interface Queue { ) } -class JobQueue(override val configuration: Queue.Configuration): Queue { +class JobQueue(private val store: KStore>>, override val configuration: Queue.Configuration): Queue { private val cancellationQueue = MutableSharedFlow(extraBufferCapacity = Int.MAX_VALUE) private val queue = atomic(mutableListOf>()) private val lock = Semaphore(configuration.maxConcurrency, 0) @@ -34,6 +38,12 @@ class JobQueue(override val configuration: Queue.Configuration): Queue { cancellationQueue.onEach { it.join() } .flowOn(Dispatchers.Default) .launchIn(configuration.scope) + + configuration.scope.launch { + store.getOrEmpty().forEach { job -> + add(job) + } + } } @PublishedApi @@ -51,9 +61,12 @@ class JobQueue(override val configuration: Queue.Configuration): Queue { queue.value.remove(job) } else if (job.startTime <= Clock.System.now()) { lock.withPermit { + if (job.info.shouldPersist) { + store.plus(job) + } withTimeout(job.info.timeout) { - job.run() queue.value.remove(job) + store.minus(job) } } } @@ -65,6 +78,7 @@ class JobQueue(override val configuration: Queue.Configuration): Queue { isCancelling.withLock { configuration.scope.coroutineContext.cancelChildren() queue.value.clear() + store.delete() } } } @@ -75,6 +89,7 @@ class JobQueue(override val configuration: Queue.Configuration): Queue { queue.value.firstOrNull { it.id == id }?.let { job -> job.cancel() queue.value.remove(job) + store.minus(job) } } } @@ -86,6 +101,7 @@ class JobQueue(override val configuration: Queue.Configuration): Queue { queue.value.firstOrNull { it.info.tag == tag }?.let { job -> job.cancel() queue.value.remove(job) + store.minus(job) } } } diff --git a/src/commonTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt b/src/commonTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt index 983f35f..6a96158 100644 --- a/src/commonTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt +++ b/src/commonTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt @@ -2,19 +2,17 @@ package com.liftric.persisted.queue import com.liftric.persisted.queue.rules.* import kotlinx.coroutines.* -import kotlinx.serialization.modules.SerializersModule import kotlin.test.* import kotlin.time.Duration.Companion.seconds -import kotlinx.serialization.modules.polymorphic -class JobSchedulerTests { +expect class JobSchedulerTests: AbstractJobSchedulerTests +abstract class AbstractJobSchedulerTests(private val scheduler: JobScheduler) { + @AfterTest + fun tearDown() = runBlocking { + scheduler.queue.cancel() + } @Test fun testSchedule() = runBlocking { - val scheduler = JobScheduler(serializers = SerializersModule { - polymorphic(DataTask::class) { - subclass(TestTask::class, TestTask.serializer()) - } - }) val id = UUID::class.instance().toString() val job = async { scheduler.onEvent.collect { @@ -44,8 +42,6 @@ class JobSchedulerTests { @Test fun testRetry() = runBlocking { - val scheduler = JobScheduler() - var count = 0 val job = launch { scheduler.onEvent.collect { @@ -68,8 +64,6 @@ class JobSchedulerTests { @Test fun testCancelDuringRun() { - val scheduler = JobScheduler() - runBlocking { scheduler.schedule(LongRunningTask()) { delay(10.seconds) @@ -95,8 +89,6 @@ class JobSchedulerTests { @Test fun testCancelByIdBeforeEnqueue() { - val scheduler = JobScheduler() - runBlocking { val completable = CompletableDeferred() @@ -126,8 +118,6 @@ class JobSchedulerTests { @Test fun testCancelByIdAfterEnqueue() { - val scheduler = JobScheduler() - runBlocking { launch { scheduler.onEvent.collect { diff --git a/src/iosMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt b/src/iosMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt new file mode 100644 index 0000000..f4fcebf --- /dev/null +++ b/src/iosMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt @@ -0,0 +1,14 @@ +package com.liftric.persisted.queue + +import kotlinx.serialization.modules.SerializersModule +import platform.Foundation.NSHomeDirectory + +actual class JobScheduler( + serializers: SerializersModule = SerializersModule {}, + configuration: Queue.Configuration? = null, + filePath: String = NSHomeDirectory() +) : AbstractJobScheduler( + serializers, + configuration, + filePath +) diff --git a/src/iosTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt b/src/iosTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt new file mode 100644 index 0000000..621ec7c --- /dev/null +++ b/src/iosTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt @@ -0,0 +1,12 @@ +package com.liftric.persisted.queue + +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.polymorphic + +actual class JobSchedulerTests: AbstractJobSchedulerTests(JobScheduler( + serializers = SerializersModule { + polymorphic(DataTask::class) { + subclass(TestTask::class, TestTask.serializer()) + } + } +)) From 502cb7584308bc14d3afe3ad0d144e961fe4ca89 Mon Sep 17 00:00:00 2001 From: Jan Gaebel Date: Fri, 16 Dec 2022 15:48:07 +0100 Subject: [PATCH 04/28] refactor(persistence): use userdefaults --- build.gradle.kts | 8 ++++-- settings.gradle.kts | 1 - .../liftric/persisted/queue/JobScheduler.kt | 4 +-- .../persisted/queue/JobSchedulerTests.kt | 7 ++++-- .../liftric/persisted/queue/JobScheduler.kt | 18 ++++++------- .../com/liftric/persisted/queue/Queue.kt | 25 ++++++++++--------- .../liftric/persisted/queue/JobScheduler.kt | 8 +++--- 7 files changed, 37 insertions(+), 34 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 7c338cc..b17b535 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,5 @@ +import org.jetbrains.kotlin.gradle.targets.native.tasks.KotlinNativeSimulatorTest + plugins { id("com.android.library") version libs.versions.android.tools.gradle kotlin("multiplatform") version libs.versions.kotlin @@ -37,7 +39,6 @@ kotlin { implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.atomicfu) implementation(libs.multiplatform.settings) - implementation(libs.kstore) } } val commonTest by getting { @@ -86,7 +87,6 @@ android { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } - testOptions { unitTests.apply { isReturnDefaultValues = true @@ -158,3 +158,7 @@ signing { useInMemoryPgpKeys(signingKey, signingPassword) sign(publishing.publications) } + +tasks.withType { + deviceId = "iPhone 14" +} diff --git a/settings.gradle.kts b/settings.gradle.kts index daeaae3..4b10170 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -19,7 +19,6 @@ dependencyResolutionManagement { version("android-tools-gradle", "7.2.2") version("kotlin", "1.7.20") library("kotlinx-coroutines", "org.jetbrains.kotlinx", "kotlinx-coroutines-core").version("1.6.4") - library("kstore", "io.github.xxfast", "kstore").version("0.1.1") library("kotlinx-serialization", "org.jetbrains.kotlinx", "kotlinx-serialization-json").version("1.4.0") library("kotlinx-atomicfu", "org.jetbrains.kotlinx", "atomicfu").version("0.18.5") library("kotlinx-datetime", "org.jetbrains.kotlinx", "kotlinx-datetime").version("0.4.0") diff --git a/src/androidMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt b/src/androidMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt index ad896b1..0d03c0b 100644 --- a/src/androidMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt +++ b/src/androidMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt @@ -1,15 +1,15 @@ package com.liftric.persisted.queue import android.content.Context +import com.russhwolf.settings.SharedPreferencesSettings import kotlinx.serialization.modules.SerializersModule actual class JobScheduler( context: Context, serializers: SerializersModule = SerializersModule {}, configuration: Queue.Configuration? = null, - filePath: String? = null ) : AbstractJobScheduler( serializers, configuration, - filePath ?: context.filesDir.path + SharedPreferencesSettings.Factory(context).create("com.liftric.persisted.queue") ) diff --git a/src/androidTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt b/src/androidTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt index a27c678..9d4c678 100644 --- a/src/androidTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt +++ b/src/androidTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt @@ -1,11 +1,14 @@ package com.liftric.persisted.queue -import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.core.app.ApplicationProvider import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.polymorphic +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +@RunWith(RobolectricTestRunner::class) actual class JobSchedulerTests: AbstractJobSchedulerTests(JobScheduler( - context = InstrumentationRegistry.getInstrumentation().targetContext, + context = ApplicationProvider.getApplicationContext(), serializers = SerializersModule { polymorphic(DataTask::class) { subclass(TestTask::class, TestTask.serializer()) diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt index f831679..52904ce 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt @@ -1,7 +1,7 @@ package com.liftric.persisted.queue import com.liftric.persisted.queue.rules.* -import io.github.xxfast.kstore.* +import com.russhwolf.settings.Settings import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.* @@ -14,13 +14,12 @@ import kotlinx.serialization.modules.polymorphic expect class JobScheduler: AbstractJobScheduler abstract class AbstractJobScheduler( - serializers: SerializersModule = SerializersModule {}, - configuration: Queue.Configuration? = null, - filePath: String + serializers: SerializersModule, + configuration: Queue.Configuration?, + settings: Settings ) { val onEvent = MutableSharedFlow(extraBufferCapacity = Int.MAX_VALUE) - private val format = Json { serializersModule = module.plus(serializers) } private val module = SerializersModule { contextual(UUIDSerializer) contextual(InstantIso8601Serializer) @@ -33,18 +32,15 @@ abstract class AbstractJobScheduler( subclass(PersistenceRule::class, PersistenceRule.serializer()) } } + private val format = Json { serializersModule = module + serializers } @PublishedApi internal val delegate = JobDelegate() @PublishedApi internal val queue = JobQueue( - storeOf( - filePath = "${filePath}/jobs", - default = null, - enableCache = true, - serializer = format - ), + settings, + format, configuration ?: Queue.Configuration(CoroutineScope(Dispatchers.Default), 1) ) diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt index 8b07eca..d071590 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt @@ -1,9 +1,7 @@ package com.liftric.persisted.queue -import io.github.xxfast.kstore.KStore -import io.github.xxfast.kstore.getOrEmpty -import io.github.xxfast.kstore.minus -import io.github.xxfast.kstore.plus +import com.russhwolf.settings.Settings +import com.russhwolf.settings.set import kotlinx.atomicfu.atomic import kotlinx.coroutines.* import kotlinx.coroutines.flow.* @@ -12,6 +10,9 @@ import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withPermit import kotlinx.datetime.Clock +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext import kotlin.coroutines.coroutineContext @@ -26,7 +27,7 @@ interface Queue { ) } -class JobQueue(private val store: KStore>>, override val configuration: Queue.Configuration): Queue { +class JobQueue(private val settings: Settings, private val format: Json, override val configuration: Queue.Configuration): Queue { private val cancellationQueue = MutableSharedFlow(extraBufferCapacity = Int.MAX_VALUE) private val queue = atomic(mutableListOf>()) private val lock = Semaphore(configuration.maxConcurrency, 0) @@ -40,8 +41,8 @@ class JobQueue(private val store: KStore>>, override val configurati .launchIn(configuration.scope) configuration.scope.launch { - store.getOrEmpty().forEach { job -> - add(job) + settings.keys.forEach { json -> + add(format.decodeFromString(json)) } } } @@ -62,11 +63,11 @@ class JobQueue(private val store: KStore>>, override val configurati } else if (job.startTime <= Clock.System.now()) { lock.withPermit { if (job.info.shouldPersist) { - store.plus(job) + settings[job.id.toString()] = format.encodeToString(job) } withTimeout(job.info.timeout) { queue.value.remove(job) - store.minus(job) + settings.remove(job.id.toString()) } } } @@ -78,7 +79,7 @@ class JobQueue(private val store: KStore>>, override val configurati isCancelling.withLock { configuration.scope.coroutineContext.cancelChildren() queue.value.clear() - store.delete() + settings.clear() } } } @@ -89,7 +90,7 @@ class JobQueue(private val store: KStore>>, override val configurati queue.value.firstOrNull { it.id == id }?.let { job -> job.cancel() queue.value.remove(job) - store.minus(job) + settings.remove(job.id.toString()) } } } @@ -101,7 +102,7 @@ class JobQueue(private val store: KStore>>, override val configurati queue.value.firstOrNull { it.info.tag == tag }?.let { job -> job.cancel() queue.value.remove(job) - store.minus(job) + settings.remove(job.id.toString()) } } } diff --git a/src/iosMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt b/src/iosMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt index f4fcebf..684cb38 100644 --- a/src/iosMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt +++ b/src/iosMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt @@ -1,14 +1,14 @@ package com.liftric.persisted.queue +import com.russhwolf.settings.NSUserDefaultsSettings import kotlinx.serialization.modules.SerializersModule -import platform.Foundation.NSHomeDirectory +import platform.Foundation.NSUserDefaults actual class JobScheduler( serializers: SerializersModule = SerializersModule {}, - configuration: Queue.Configuration? = null, - filePath: String = NSHomeDirectory() + configuration: Queue.Configuration? = null ) : AbstractJobScheduler( serializers, configuration, - filePath + NSUserDefaultsSettings(NSUserDefaults("com.liftric.persisted.queue")) ) From 22a2d4e8e0eae4a4994683902989407b7276afdd Mon Sep 17 00:00:00 2001 From: Jan Gaebel Date: Fri, 16 Dec 2022 16:59:16 +0100 Subject: [PATCH 05/28] fix(tests): not running under ios and android --- build.gradle.kts | 4 ++++ settings.gradle.kts | 3 +++ .../com/liftric/persisted/queue/JobScheduler.kt | 4 +++- .../kotlin/com/liftric/persisted/queue/UUID.kt | 4 +++- .../com/liftric/persisted/queue/JobSchedulerTests.kt | 12 +++++++----- .../kotlin/com/liftric/persisted/queue/Job.kt | 6 ++++-- .../com/liftric/persisted/queue/JobScheduler.kt | 3 +-- .../kotlin/com/liftric/persisted/queue/Queue.kt | 12 +++++++----- .../kotlin/com/liftric/persisted/queue/UUID.kt | 4 +++- .../com/liftric/persisted/queue/JobSchedulerTests.kt | 2 +- .../com/liftric/persisted/queue/JobScheduler.kt | 6 ++++-- .../kotlin/com/liftric/persisted/queue/UUID.kt | 4 +++- .../com/liftric/persisted/queue/JobSchedulerTests.kt | 4 +++- 13 files changed, 46 insertions(+), 22 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index b17b535..2b3fcf6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -46,6 +46,7 @@ kotlin { implementation(kotlin("test")) implementation(kotlin("test-common")) implementation(kotlin("test-annotations-common")) + implementation(libs.multiplatform.settings.test) } } val androidMain by getting @@ -55,6 +56,9 @@ kotlin { implementation(kotlin("test")) implementation(kotlin("test-junit")) implementation(libs.androidx.test.core) + implementation(libs.androidx.test.runner) + implementation(libs.androidx.test.ext) + } } val iosMain by getting diff --git a/settings.gradle.kts b/settings.gradle.kts index 4b10170..24403f2 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -23,8 +23,11 @@ dependencyResolutionManagement { library("kotlinx-atomicfu", "org.jetbrains.kotlinx", "atomicfu").version("0.18.5") library("kotlinx-datetime", "org.jetbrains.kotlinx", "kotlinx-datetime").version("0.4.0") library("androidx-test-core", "androidx.test", "core").version("1.4.0") + library("androidx-test-runner", "androidx.test", "runner").version("1.4.0") + library("androidx-test-ext", "androidx.test.ext", "junit").version("1.1.3") library("roboelectric", "org.robolectric", "robolectric").version("4.5.1") library("multiplatform-settings", "com.russhwolf", "multiplatform-settings").version("1.0.0-RC") + library("multiplatform-settings-test", "com.russhwolf", "multiplatform-settings-test").version("1.0.0-RC") plugin("versioning", "net.nemerosa.versioning").version("3.0.0") plugin("kotlin.serialization", "org.jetbrains.kotlin.plugin.serialization").versionRef("kotlin") } diff --git a/src/androidMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt b/src/androidMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt index 0d03c0b..90d48ea 100644 --- a/src/androidMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt +++ b/src/androidMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt @@ -1,6 +1,7 @@ package com.liftric.persisted.queue import android.content.Context +import com.russhwolf.settings.Settings import com.russhwolf.settings.SharedPreferencesSettings import kotlinx.serialization.modules.SerializersModule @@ -8,8 +9,9 @@ actual class JobScheduler( context: Context, serializers: SerializersModule = SerializersModule {}, configuration: Queue.Configuration? = null, + settings: Settings = SharedPreferencesSettings.Factory(context).create("com.liftric.persisted.queue") ) : AbstractJobScheduler( serializers, configuration, - SharedPreferencesSettings.Factory(context).create("com.liftric.persisted.queue") + settings ) diff --git a/src/androidMain/kotlin/com/liftric/persisted/queue/UUID.kt b/src/androidMain/kotlin/com/liftric/persisted/queue/UUID.kt index d1be5fa..58ccf2e 100644 --- a/src/androidMain/kotlin/com/liftric/persisted/queue/UUID.kt +++ b/src/androidMain/kotlin/com/liftric/persisted/queue/UUID.kt @@ -10,7 +10,9 @@ import kotlin.reflect.KClass actual typealias UUID = UUID -actual fun KClass.instance(): UUID = UUID.randomUUID() +internal actual object UUIDFactory { + actual fun create(): UUID = UUID.randomUUID() +} actual object UUIDSerializer: KSerializer { override val descriptor = PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING) diff --git a/src/androidTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt b/src/androidTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt index 9d4c678..e701fe0 100644 --- a/src/androidTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt +++ b/src/androidTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt @@ -1,17 +1,19 @@ package com.liftric.persisted.queue -import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.russhwolf.settings.MapSettings import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.polymorphic import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -@RunWith(RobolectricTestRunner::class) +@RunWith(AndroidJUnit4::class) actual class JobSchedulerTests: AbstractJobSchedulerTests(JobScheduler( - context = ApplicationProvider.getApplicationContext(), + context = InstrumentationRegistry.getInstrumentation().targetContext, serializers = SerializersModule { polymorphic(DataTask::class) { subclass(TestTask::class, TestTask.serializer()) } - } + }, + settings = MapSettings() )) diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/Job.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/Job.kt index 4459fd0..1c310fd 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/Job.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/Job.kt @@ -5,18 +5,20 @@ import kotlinx.datetime.Clock import kotlinx.datetime.Instant import kotlinx.serialization.Contextual import kotlinx.serialization.Serializable +import kotlinx.serialization.Serializer import kotlinx.serialization.Transient @Serializable data class Job( - @Contextual override val id: UUID, + @Serializable(UUIDSerializer::class) + override val id: UUID, override val info: JobInfo, override val task: DataTask, @Contextual override val startTime: Instant ): JobContext { @Transient var delegate: JobDelegate? = null - constructor(task: DataTask, info: JobInfo) : this (UUID::class.instance(), info, task, Clock.System.now()) + constructor(task: DataTask, info: JobInfo) : this (UUIDFactory.create(), info, task, Clock.System.now()) private var cancellable: kotlinx.coroutines.Job? = null diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt index 52904ce..5ccae0f 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt @@ -21,7 +21,6 @@ abstract class AbstractJobScheduler( val onEvent = MutableSharedFlow(extraBufferCapacity = Int.MAX_VALUE) private val module = SerializersModule { - contextual(UUIDSerializer) contextual(InstantIso8601Serializer) polymorphic(JobRule::class) { subclass(DelayRule::class, DelayRule.serializer()) @@ -45,7 +44,7 @@ abstract class AbstractJobScheduler( ) init { - delegate.onExit = { /* Do something */ } + delegate.onExit = { settings.remove(it.id.toString()) } delegate.onRepeat = { repeat(it) } delegate.onEvent = { onEvent.emit(it) } } diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt index d071590..b9533f5 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt @@ -53,7 +53,7 @@ class JobQueue(private val settings: Settings, private val format: Json, overrid } suspend fun start() { - while (configuration.scope.isActive) { + while (coroutineContext.isActive) { if (queue.value.isEmpty()) break if (isCancelling.isLocked) break if (lock.availablePermits < 1) break @@ -65,9 +65,11 @@ class JobQueue(private val settings: Settings, private val format: Json, overrid if (job.info.shouldPersist) { settings[job.id.toString()] = format.encodeToString(job) } - withTimeout(job.info.timeout) { - queue.value.remove(job) - settings.remove(job.id.toString()) + withContext(configuration.scope.coroutineContext) { + withTimeout(job.info.timeout) { + queue.value.remove(job) + job.run() + } } } } @@ -77,8 +79,8 @@ class JobQueue(private val settings: Settings, private val format: Json, overrid suspend fun cancel() { submitCancellation(coroutineContext) { isCancelling.withLock { - configuration.scope.coroutineContext.cancelChildren() queue.value.clear() + configuration.scope.coroutineContext.cancelChildren() settings.clear() } } diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/UUID.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/UUID.kt index c8f6a31..043d216 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/UUID.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/UUID.kt @@ -5,6 +5,8 @@ import kotlin.reflect.KClass expect class UUID -expect fun KClass.instance(): UUID +internal expect object UUIDFactory { + fun create(): UUID +} expect object UUIDSerializer: KSerializer diff --git a/src/commonTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt b/src/commonTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt index 6a96158..06c71d1 100644 --- a/src/commonTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt +++ b/src/commonTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt @@ -13,7 +13,7 @@ abstract class AbstractJobSchedulerTests(private val scheduler: JobScheduler) { } @Test fun testSchedule() = runBlocking { - val id = UUID::class.instance().toString() + val id = UUIDFactory.create().toString() val job = async { scheduler.onEvent.collect { println(it) diff --git a/src/iosMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt b/src/iosMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt index 684cb38..1c74dc6 100644 --- a/src/iosMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt +++ b/src/iosMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt @@ -1,14 +1,16 @@ package com.liftric.persisted.queue import com.russhwolf.settings.NSUserDefaultsSettings +import com.russhwolf.settings.Settings import kotlinx.serialization.modules.SerializersModule import platform.Foundation.NSUserDefaults actual class JobScheduler( serializers: SerializersModule = SerializersModule {}, - configuration: Queue.Configuration? = null + configuration: Queue.Configuration? = null, + settings: Settings = NSUserDefaultsSettings(NSUserDefaults("com.liftric.persisted.queue")) ) : AbstractJobScheduler( serializers, configuration, - NSUserDefaultsSettings(NSUserDefaults("com.liftric.persisted.queue")) + settings ) diff --git a/src/iosMain/kotlin/com/liftric/persisted/queue/UUID.kt b/src/iosMain/kotlin/com/liftric/persisted/queue/UUID.kt index fe555fe..897924f 100644 --- a/src/iosMain/kotlin/com/liftric/persisted/queue/UUID.kt +++ b/src/iosMain/kotlin/com/liftric/persisted/queue/UUID.kt @@ -12,7 +12,9 @@ import kotlin.reflect.KClass actual typealias UUID = NSUUID -actual fun KClass.instance(): UUID = NSUUID() +internal actual object UUIDFactory { + actual fun create(): UUID = NSUUID() +} actual object UUIDSerializer: KSerializer { override val descriptor = PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING) diff --git a/src/iosTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt b/src/iosTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt index 621ec7c..11f39a7 100644 --- a/src/iosTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt +++ b/src/iosTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt @@ -1,5 +1,6 @@ package com.liftric.persisted.queue +import com.russhwolf.settings.MapSettings import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.polymorphic @@ -8,5 +9,6 @@ actual class JobSchedulerTests: AbstractJobSchedulerTests(JobScheduler( polymorphic(DataTask::class) { subclass(TestTask::class, TestTask.serializer()) } - } + }, + settings = MapSettings() )) From e567245bae19f2996b1c75ff182045cacd75faeb Mon Sep 17 00:00:00 2001 From: Jan Gaebel Date: Fri, 16 Dec 2022 17:35:37 +0100 Subject: [PATCH 06/28] fix(queue): remove job after run --- src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt | 2 +- .../kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt | 4 +--- src/commonTest/kotlin/com/liftric/persisted/queue/TestTask.kt | 3 ++- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt index b9533f5..89529dd 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt @@ -67,8 +67,8 @@ class JobQueue(private val settings: Settings, private val format: Json, overrid } withContext(configuration.scope.coroutineContext) { withTimeout(job.info.timeout) { - queue.value.remove(job) job.run() + queue.value.remove(job) } } } diff --git a/src/commonTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt b/src/commonTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt index 06c71d1..1ab96e3 100644 --- a/src/commonTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt +++ b/src/commonTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt @@ -65,9 +65,7 @@ abstract class AbstractJobSchedulerTests(private val scheduler: JobScheduler) { @Test fun testCancelDuringRun() { runBlocking { - scheduler.schedule(LongRunningTask()) { - delay(10.seconds) - } + scheduler.schedule(::LongRunningTask) launch { scheduler.onEvent.collect { diff --git a/src/commonTest/kotlin/com/liftric/persisted/queue/TestTask.kt b/src/commonTest/kotlin/com/liftric/persisted/queue/TestTask.kt index d1c06af..35c3864 100644 --- a/src/commonTest/kotlin/com/liftric/persisted/queue/TestTask.kt +++ b/src/commonTest/kotlin/com/liftric/persisted/queue/TestTask.kt @@ -2,6 +2,7 @@ package com.liftric.persisted.queue import kotlinx.coroutines.delay import kotlinx.serialization.Serializable +import kotlin.time.Duration.Companion.seconds @Serializable data class TestData(val testResultId: String) @@ -19,5 +20,5 @@ class TestErrorTask: Task() { @Serializable class LongRunningTask: Task() { - override suspend fun body() { delay(10000L) } + override suspend fun body() { delay(10.seconds) } } From 70d02b55e07d9e491af603f57185fcd5a80d785b Mon Sep 17 00:00:00 2001 From: Jan Gaebel Date: Fri, 16 Dec 2022 18:50:35 +0100 Subject: [PATCH 07/28] refactor(job): remove data type --- .../com/liftric/persisted/queue/UUID.kt | 6 +++- .../persisted/queue/JobSchedulerTests.kt | 20 +++++++------ .../kotlin/com/liftric/persisted/queue/Job.kt | 13 ++++----- .../com/liftric/persisted/queue/JobContext.kt | 4 +-- .../liftric/persisted/queue/JobDelegate.kt | 8 +++--- .../liftric/persisted/queue/JobScheduler.kt | 9 +++--- .../com/liftric/persisted/queue/Queue.kt | 28 +++++++++++++------ .../com/liftric/persisted/queue/Task.kt | 7 ++--- .../com/liftric/persisted/queue/UUID.kt | 1 - .../persisted/queue/JobSchedulerTests.kt | 21 +++++++++++++- .../com/liftric/persisted/queue/TestTask.kt | 4 +-- .../com/liftric/persisted/queue/UUID.kt | 3 ++ .../persisted/queue/JobSchedulerTests.kt | 18 ++++++------ 13 files changed, 90 insertions(+), 52 deletions(-) diff --git a/src/androidMain/kotlin/com/liftric/persisted/queue/UUID.kt b/src/androidMain/kotlin/com/liftric/persisted/queue/UUID.kt index 58ccf2e..52ae58c 100644 --- a/src/androidMain/kotlin/com/liftric/persisted/queue/UUID.kt +++ b/src/androidMain/kotlin/com/liftric/persisted/queue/UUID.kt @@ -1,8 +1,11 @@ package com.liftric.persisted.queue import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.Serializer import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import java.util.UUID @@ -14,8 +17,9 @@ internal actual object UUIDFactory { actual fun create(): UUID = UUID.randomUUID() } +@Serializer(forClass = UUID::class) actual object UUIDSerializer: KSerializer { - override val descriptor = PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING) + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING) override fun deserialize(decoder: Decoder): UUID { return UUID.fromString(decoder.decodeString()) diff --git a/src/androidTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt b/src/androidTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt index e701fe0..4a601cd 100644 --- a/src/androidTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt +++ b/src/androidTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt @@ -8,12 +8,14 @@ import kotlinx.serialization.modules.polymorphic import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) -actual class JobSchedulerTests: AbstractJobSchedulerTests(JobScheduler( - context = InstrumentationRegistry.getInstrumentation().targetContext, - serializers = SerializersModule { - polymorphic(DataTask::class) { - subclass(TestTask::class, TestTask.serializer()) - } - }, - settings = MapSettings() -)) +actual class JobSchedulerTests: AbstractJobSchedulerTests({ + JobScheduler( + context = InstrumentationRegistry.getInstrumentation().targetContext, + serializers = SerializersModule { + polymorphic(Task::class) { + subclass(TestTask::class, TestTask.serializer()) + } + }, + settings = MapSettings() + ) +}) diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/Job.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/Job.kt index 1c310fd..6116b29 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/Job.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/Job.kt @@ -3,22 +3,19 @@ package com.liftric.persisted.queue import kotlinx.coroutines.* import kotlinx.datetime.Clock import kotlinx.datetime.Instant -import kotlinx.serialization.Contextual import kotlinx.serialization.Serializable -import kotlinx.serialization.Serializer import kotlinx.serialization.Transient @Serializable -data class Job( - @Serializable(UUIDSerializer::class) +data class Job( override val id: UUID, override val info: JobInfo, - override val task: DataTask, - @Contextual override val startTime: Instant + override val task: Task, + override val startTime: Instant ): JobContext { @Transient var delegate: JobDelegate? = null - constructor(task: DataTask, info: JobInfo) : this (UUIDFactory.create(), info, task, Clock.System.now()) + constructor(task: Task, info: JobInfo) : this (UUIDFactory.create(), info, task, Clock.System.now()) private var cancellable: kotlinx.coroutines.Job? = null @@ -72,7 +69,7 @@ data class Job( delegate?.exit(this@Job) } - override suspend fun repeat(id: UUID, info: JobInfo, task: DataTask<*>, startTime: Instant) { + override suspend fun repeat(id: UUID, info: JobInfo, task: Task, startTime: Instant) { if (canRepeat) { delegate?.repeat(Job(id, info, task, startTime)) } else { diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/JobContext.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/JobContext.kt index fe806eb..6e2ea44 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/JobContext.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/JobContext.kt @@ -5,9 +5,9 @@ import kotlinx.datetime.Instant interface JobContext { val id: UUID val info: JobInfo - val task: DataTask<*> + val task: Task val startTime: Instant suspend fun cancel() - suspend fun repeat(id: UUID = this.id, info: JobInfo = this.info, task: DataTask<*> = this.task, startTime: Instant = this.startTime) + suspend fun repeat(id: UUID = this.id, info: JobInfo = this.info, task: Task = this.task, startTime: Instant = this.startTime) suspend fun broadcast(event: RuleEvent) } diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/JobDelegate.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/JobDelegate.kt index 5270ce3..6f4a2c7 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/JobDelegate.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/JobDelegate.kt @@ -1,19 +1,19 @@ package com.liftric.persisted.queue class JobDelegate { - var onExit: (suspend (Job<*>) -> Unit)? = null - var onRepeat: (suspend (Job<*>) -> Unit)? = null + var onExit: (suspend (Job) -> Unit)? = null + var onRepeat: (suspend (Job) -> Unit)? = null var onEvent: (suspend (JobEvent) -> Unit)? = null suspend fun broadcast(event: JobEvent) { onEvent?.invoke(event) } - suspend fun exit(job: Job<*>) { + suspend fun exit(job: Job) { onExit?.invoke(job) } - suspend fun repeat(job: Job<*>) { + suspend fun repeat(job: Job) { onRepeat?.invoke(job) } } diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt index 5ccae0f..5242099 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt @@ -21,6 +21,7 @@ abstract class AbstractJobScheduler( val onEvent = MutableSharedFlow(extraBufferCapacity = Int.MAX_VALUE) private val module = SerializersModule { + contextual(UUIDSerializer) contextual(InstantIso8601Serializer) polymorphic(JobRule::class) { subclass(DelayRule::class, DelayRule.serializer()) @@ -49,15 +50,15 @@ abstract class AbstractJobScheduler( delegate.onEvent = { onEvent.emit(it) } } - suspend fun schedule(task: () -> DataTask, configure: JobInfo.() -> JobInfo = { JobInfo() }) { + suspend fun schedule(task: () -> Task, configure: JobInfo.() -> JobInfo = { JobInfo() }) { schedule(task(), configure) } - suspend inline fun schedule(data: Data, task: (Data) -> DataTask, configure: JobInfo.() -> JobInfo = { JobInfo() }) { + suspend fun schedule(data: Data, task: (Data) -> DataTask, configure: JobInfo.() -> JobInfo = { JobInfo() }) { schedule(task(data), configure) } - suspend inline fun schedule(task: DataTask, configure: JobInfo.() -> JobInfo = { JobInfo() }) = try { + suspend fun schedule(task: Task, configure: JobInfo.() -> JobInfo = { JobInfo() }) = try { val info = configure(JobInfo()).apply { rules.forEach { it.mutating(this) } } @@ -76,7 +77,7 @@ abstract class AbstractJobScheduler( onEvent.emit(JobEvent.DidThrowOnSchedule(error)) } - private suspend fun repeat(job: Job<*>) = try { + private suspend fun repeat(job: Job) = try { job.delegate = delegate job.info.rules.forEach { diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt index 89529dd..a836f10 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt @@ -13,9 +13,11 @@ import kotlinx.datetime.Clock import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import kotlinx.serialization.serializer import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext import kotlin.coroutines.coroutineContext +import kotlin.reflect.typeOf interface Queue { val jobs: List @@ -29,7 +31,7 @@ interface Queue { class JobQueue(private val settings: Settings, private val format: Json, override val configuration: Queue.Configuration): Queue { private val cancellationQueue = MutableSharedFlow(extraBufferCapacity = Int.MAX_VALUE) - private val queue = atomic(mutableListOf>()) + private val queue = atomic(mutableListOf()) private val lock = Semaphore(configuration.maxConcurrency, 0) private val isCancelling = Mutex(false) override val jobs: List @@ -41,15 +43,18 @@ class JobQueue(private val settings: Settings, private val format: Json, overrid .launchIn(configuration.scope) configuration.scope.launch { - settings.keys.forEach { json -> - add(format.decodeFromString(json)) - } + restoreJobs() } } @PublishedApi - internal fun add(job: Job<*>) { + internal fun add(job: Job) { queue.value = queue.value.plus(listOf(job)).sortedBy { it.startTime }.toMutableList() + if (job.info.shouldPersist) { + val json = format.encodeToString(job) + println(json) + settings[job.id.toString()] = format.encodeToString(job) + } } suspend fun start() { @@ -62,9 +67,6 @@ class JobQueue(private val settings: Settings, private val format: Json, overrid queue.value.remove(job) } else if (job.startTime <= Clock.System.now()) { lock.withPermit { - if (job.info.shouldPersist) { - settings[job.id.toString()] = format.encodeToString(job) - } withContext(configuration.scope.coroutineContext) { withTimeout(job.info.timeout) { job.run() @@ -76,6 +78,16 @@ class JobQueue(private val settings: Settings, private val format: Json, overrid } } + internal suspend fun restoreJobs() { + settings.keys.forEach { json -> + add(format.decodeFromString(serializer(), json)) + } + } + + internal suspend fun clearJobs() { + queue.value.clear() + } + suspend fun cancel() { submitCancellation(coroutineContext) { isCancelling.withLock { diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/Task.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/Task.kt index 36dda82..4a58f0f 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/Task.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/Task.kt @@ -1,12 +1,11 @@ package com.liftric.persisted.queue -interface DataTask { - val data: Data +interface Task { @Throws(Throwable::class) suspend fun body() suspend fun onRepeat(cause: Throwable): Boolean = false } -abstract class Task: DataTask { - override val data: Unit = Unit +interface DataTask: Task { + val data: Data } diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/UUID.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/UUID.kt index 043d216..da6d318 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/UUID.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/UUID.kt @@ -1,7 +1,6 @@ package com.liftric.persisted.queue import kotlinx.serialization.KSerializer -import kotlin.reflect.KClass expect class UUID diff --git a/src/commonTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt b/src/commonTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt index 1ab96e3..f150d35 100644 --- a/src/commonTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt +++ b/src/commonTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt @@ -6,7 +6,9 @@ import kotlin.test.* import kotlin.time.Duration.Companion.seconds expect class JobSchedulerTests: AbstractJobSchedulerTests -abstract class AbstractJobSchedulerTests(private val scheduler: JobScheduler) { +abstract class AbstractJobSchedulerTests(private val factory: () -> JobScheduler) { + private val scheduler = factory() + @AfterTest fun tearDown() = runBlocking { scheduler.queue.cancel() @@ -139,4 +141,21 @@ abstract class AbstractJobSchedulerTests(private val scheduler: JobScheduler) { } } } + + @Test + fun testPersist() = runBlocking { + scheduler.schedule(TestData(UUIDFactory.create().toString()), ::TestTask) { + persist() + } + + assertEquals(1, scheduler.queue.jobs.count()) + + scheduler.queue.clearJobs() + + assertEquals(0, scheduler.queue.jobs.count()) + + scheduler.queue.restoreJobs() + + assertEquals(1, scheduler.queue.jobs.count()) + } } diff --git a/src/commonTest/kotlin/com/liftric/persisted/queue/TestTask.kt b/src/commonTest/kotlin/com/liftric/persisted/queue/TestTask.kt index 35c3864..86a695b 100644 --- a/src/commonTest/kotlin/com/liftric/persisted/queue/TestTask.kt +++ b/src/commonTest/kotlin/com/liftric/persisted/queue/TestTask.kt @@ -13,12 +13,12 @@ data class TestTask(override val data: TestData): DataTask { } @Serializable -class TestErrorTask: Task() { +class TestErrorTask: Task { override suspend fun body() { throw Error("Oh shoot!") } override suspend fun onRepeat(cause: Throwable): Boolean = cause is Error } @Serializable -class LongRunningTask: Task() { +class LongRunningTask: Task { override suspend fun body() { delay(10.seconds) } } diff --git a/src/iosMain/kotlin/com/liftric/persisted/queue/UUID.kt b/src/iosMain/kotlin/com/liftric/persisted/queue/UUID.kt index 897924f..b1a26b4 100644 --- a/src/iosMain/kotlin/com/liftric/persisted/queue/UUID.kt +++ b/src/iosMain/kotlin/com/liftric/persisted/queue/UUID.kt @@ -1,6 +1,8 @@ package com.liftric.persisted.queue import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.Serializer import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor import kotlinx.serialization.encoding.Decoder @@ -16,6 +18,7 @@ internal actual object UUIDFactory { actual fun create(): UUID = NSUUID() } +@Serializer(forClass = UUID::class) actual object UUIDSerializer: KSerializer { override val descriptor = PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING) diff --git a/src/iosTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt b/src/iosTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt index 11f39a7..38db718 100644 --- a/src/iosTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt +++ b/src/iosTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt @@ -4,11 +4,13 @@ import com.russhwolf.settings.MapSettings import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.polymorphic -actual class JobSchedulerTests: AbstractJobSchedulerTests(JobScheduler( - serializers = SerializersModule { - polymorphic(DataTask::class) { - subclass(TestTask::class, TestTask.serializer()) - } - }, - settings = MapSettings() -)) +actual class JobSchedulerTests: AbstractJobSchedulerTests({ + JobScheduler( + serializers = SerializersModule { + polymorphic(Task::class) { + subclass(TestTask::class, TestTask.serializer()) + } + }, + settings = MapSettings() + ) +}) From b901ed6a10b8983224e72981eb4a89279903b743 Mon Sep 17 00:00:00 2001 From: Jan Gaebel Date: Fri, 16 Dec 2022 19:16:56 +0100 Subject: [PATCH 08/28] fix(queue): serialization --- .../kotlin/com/liftric/persisted/queue/UUID.kt | 7 +------ src/commonMain/kotlin/com/liftric/persisted/queue/Job.kt | 3 ++- .../kotlin/com/liftric/persisted/queue/Queue.kt | 8 ++------ src/iosMain/kotlin/com/liftric/persisted/queue/UUID.kt | 6 ------ 4 files changed, 5 insertions(+), 19 deletions(-) diff --git a/src/androidMain/kotlin/com/liftric/persisted/queue/UUID.kt b/src/androidMain/kotlin/com/liftric/persisted/queue/UUID.kt index 52ae58c..95968b8 100644 --- a/src/androidMain/kotlin/com/liftric/persisted/queue/UUID.kt +++ b/src/androidMain/kotlin/com/liftric/persisted/queue/UUID.kt @@ -1,15 +1,11 @@ package com.liftric.persisted.queue import kotlinx.serialization.KSerializer -import kotlinx.serialization.Serializable -import kotlinx.serialization.Serializer import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor -import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import java.util.UUID -import kotlin.reflect.KClass actual typealias UUID = UUID @@ -17,9 +13,8 @@ internal actual object UUIDFactory { actual fun create(): UUID = UUID.randomUUID() } -@Serializer(forClass = UUID::class) actual object UUIDSerializer: KSerializer { - override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING) + override val descriptor = PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING) override fun deserialize(decoder: Decoder): UUID { return UUID.fromString(decoder.decodeString()) diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/Job.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/Job.kt index 6116b29..0bd11c5 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/Job.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/Job.kt @@ -3,12 +3,13 @@ package com.liftric.persisted.queue import kotlinx.coroutines.* import kotlinx.datetime.Clock import kotlinx.datetime.Instant +import kotlinx.serialization.Contextual import kotlinx.serialization.Serializable import kotlinx.serialization.Transient @Serializable data class Job( - override val id: UUID, + @Contextual override val id: UUID, override val info: JobInfo, override val task: Task, override val startTime: Instant diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt index a836f10..936187e 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt @@ -13,11 +13,9 @@ import kotlinx.datetime.Clock import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -import kotlinx.serialization.serializer import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext import kotlin.coroutines.coroutineContext -import kotlin.reflect.typeOf interface Queue { val jobs: List @@ -51,8 +49,6 @@ class JobQueue(private val settings: Settings, private val format: Json, overrid internal fun add(job: Job) { queue.value = queue.value.plus(listOf(job)).sortedBy { it.startTime }.toMutableList() if (job.info.shouldPersist) { - val json = format.encodeToString(job) - println(json) settings[job.id.toString()] = format.encodeToString(job) } } @@ -79,8 +75,8 @@ class JobQueue(private val settings: Settings, private val format: Json, overrid } internal suspend fun restoreJobs() { - settings.keys.forEach { json -> - add(format.decodeFromString(serializer(), json)) + settings.keys.forEach { key -> + add(format.decodeFromString(settings.getString(key, ""))) } } diff --git a/src/iosMain/kotlin/com/liftric/persisted/queue/UUID.kt b/src/iosMain/kotlin/com/liftric/persisted/queue/UUID.kt index b1a26b4..fed36e2 100644 --- a/src/iosMain/kotlin/com/liftric/persisted/queue/UUID.kt +++ b/src/iosMain/kotlin/com/liftric/persisted/queue/UUID.kt @@ -1,16 +1,11 @@ package com.liftric.persisted.queue import kotlinx.serialization.KSerializer -import kotlinx.serialization.Serializable -import kotlinx.serialization.Serializer import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder -import platform.Foundation.NSObjectHashCallBacks import platform.Foundation.NSUUID -import platform.darwin.NSObject -import kotlin.reflect.KClass actual typealias UUID = NSUUID @@ -18,7 +13,6 @@ internal actual object UUIDFactory { actual fun create(): UUID = NSUUID() } -@Serializer(forClass = UUID::class) actual object UUIDSerializer: KSerializer { override val descriptor = PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING) From 5a5ad37c770ec823ad8da9411034f5af73ff750f Mon Sep 17 00:00:00 2001 From: Jan Gaebel Date: Mon, 19 Dec 2022 12:00:47 +0100 Subject: [PATCH 09/28] refactor(queue): life cycle --- .../liftric/persisted/queue/JobScheduler.kt | 60 ++++++++------ .../com/liftric/persisted/queue/Queue.kt | 81 ++++++++++--------- .../persisted/queue/JobSchedulerTests.kt | 7 +- 3 files changed, 85 insertions(+), 63 deletions(-) diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt index 5242099..42e0fa6 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt @@ -2,10 +2,10 @@ package com.liftric.persisted.queue import com.liftric.persisted.queue.rules.* import com.russhwolf.settings.Settings -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers +import com.russhwolf.settings.set import kotlinx.coroutines.flow.* import kotlinx.datetime.serializers.InstantIso8601Serializer +import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.contextual @@ -16,10 +16,9 @@ expect class JobScheduler: AbstractJobScheduler abstract class AbstractJobScheduler( serializers: SerializersModule, configuration: Queue.Configuration?, - settings: Settings + private val settings: Settings ) { - val onEvent = MutableSharedFlow(extraBufferCapacity = Int.MAX_VALUE) - + private val delegate = JobDelegate() private val module = SerializersModule { contextual(UUIDSerializer) contextual(InstantIso8601Serializer) @@ -34,20 +33,28 @@ abstract class AbstractJobScheduler( } private val format = Json { serializersModule = module + serializers } - @PublishedApi - internal val delegate = JobDelegate() - - @PublishedApi - internal val queue = JobQueue( - settings, - format, - configuration ?: Queue.Configuration(CoroutineScope(Dispatchers.Default), 1) + val onEvent = MutableSharedFlow(extraBufferCapacity = Int.MAX_VALUE) + val queue = JobQueue( + settings = settings, + format = format, + configuration = configuration ?: Queue.DefaultConfiguration, + onRestore = { job -> + job.delegate = delegate + } ) init { - delegate.onExit = { settings.remove(it.id.toString()) } - delegate.onRepeat = { repeat(it) } - delegate.onEvent = { onEvent.emit(it) } + delegate.onExit = { job -> + if (job.info.shouldPersist) { + settings.remove(job.id.toString()) + } + } + delegate.onRepeat = { job -> + repeat(job) + } + delegate.onEvent = { event -> + onEvent.emit(event) + } } suspend fun schedule(task: () -> Task, configure: JobInfo.() -> JobInfo = { JobInfo() }) { @@ -64,27 +71,32 @@ abstract class AbstractJobScheduler( } val job = Job(task, info) - job.delegate = delegate - job.info.rules.forEach { - it.willSchedule(queue, job) - } - - queue.add(job).apply { + schedule(job).apply { onEvent.emit(JobEvent.DidSchedule(job)) } } catch (error: Error) { onEvent.emit(JobEvent.DidThrowOnSchedule(error)) } - private suspend fun repeat(job: Job) = try { + private suspend fun schedule(job: Job) { job.delegate = delegate job.info.rules.forEach { it.willSchedule(queue, job) } - queue.add(job).apply { + if (job.info.shouldPersist) { + settings[job.id.toString()] = format.encodeToString(job) + } + + queue.add(job) + } + + private suspend fun repeat(job: Job) = try { + job.delegate = delegate + + schedule(job).apply { onEvent.emit(JobEvent.DidScheduleRepeat(job)) } } catch (error: Error) { diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt index 936187e..4ae35be 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt @@ -1,7 +1,6 @@ package com.liftric.persisted.queue import com.russhwolf.settings.Settings -import com.russhwolf.settings.set import kotlinx.atomicfu.atomic import kotlinx.coroutines.* import kotlinx.coroutines.flow.* @@ -11,7 +10,6 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withPermit import kotlinx.datetime.Clock import kotlinx.serialization.decodeFromString -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext @@ -25,48 +23,58 @@ interface Queue { val scope: CoroutineScope, val maxConcurrency: Int ) + + companion object Default { + val DefaultConfiguration = Configuration( + scope = CoroutineScope(Dispatchers.Default), + maxConcurrency = 1 + ) + } } -class JobQueue(private val settings: Settings, private val format: Json, override val configuration: Queue.Configuration): Queue { +class JobQueue( + private val settings: Settings, + private val format: Json, + override val configuration: Queue.Configuration, + private val onRestore: (Job) -> Unit +): Queue { private val cancellationQueue = MutableSharedFlow(extraBufferCapacity = Int.MAX_VALUE) private val queue = atomic(mutableListOf()) private val lock = Semaphore(configuration.maxConcurrency, 0) private val isCancelling = Mutex(false) override val jobs: List get() = queue.value + private var isRunning: Boolean = false + private var cancellable: kotlinx.coroutines.Job? = null init { cancellationQueue.onEach { it.join() } .flowOn(Dispatchers.Default) .launchIn(configuration.scope) - - configuration.scope.launch { - restoreJobs() - } } - @PublishedApi internal fun add(job: Job) { queue.value = queue.value.plus(listOf(job)).sortedBy { it.startTime }.toMutableList() - if (job.info.shouldPersist) { - settings[job.id.toString()] = format.encodeToString(job) - } } - suspend fun start() { - while (coroutineContext.isActive) { - if (queue.value.isEmpty()) break - if (isCancelling.isLocked) break - if (lock.availablePermits < 1) break - val job = queue.value.first() - if (job.isCancelled) { - queue.value.remove(job) - } else if (job.startTime <= Clock.System.now()) { - lock.withPermit { - withContext(configuration.scope.coroutineContext) { - withTimeout(job.info.timeout) { - job.run() - queue.value.remove(job) + fun start() { + if (isRunning) return else isRunning = true + restore() + cancellable = CoroutineScope(Dispatchers.Default).launch { + while (coroutineContext.isActive) { + if (queue.value.isEmpty()) break + if (isCancelling.isLocked) break + if (lock.availablePermits < 1) break + val job = queue.value.first() + if (job.isCancelled) { + queue.value.remove(job) + } else if (job.startTime <= Clock.System.now()) { + lock.withPermit { + configuration.scope.launch { + withTimeout(job.info.timeout) { + job.run() + queue.value.remove(job) + } } } } @@ -74,17 +82,12 @@ class JobQueue(private val settings: Settings, private val format: Json, overrid } } - internal suspend fun restoreJobs() { - settings.keys.forEach { key -> - add(format.decodeFromString(settings.getString(key, ""))) - } - } - - internal suspend fun clearJobs() { - queue.value.clear() + fun stop() { + if (isRunning) isRunning = false else return + cancellable?.cancel() } - suspend fun cancel() { + suspend fun clear() { submitCancellation(coroutineContext) { isCancelling.withLock { queue.value.clear() @@ -100,7 +103,6 @@ class JobQueue(private val settings: Settings, private val format: Json, overrid queue.value.firstOrNull { it.id == id }?.let { job -> job.cancel() queue.value.remove(job) - settings.remove(job.id.toString()) } } } @@ -112,12 +114,19 @@ class JobQueue(private val settings: Settings, private val format: Json, overrid queue.value.firstOrNull { it.info.tag == tag }?.let { job -> job.cancel() queue.value.remove(job) - settings.remove(job.id.toString()) } } } } + private fun restore() { + settings.keys.forEach { key -> + val job: Job = format.decodeFromString(settings.getString(key, "")) + onRestore(job) + add(job) + } + } + private fun submitCancellation( context: CoroutineContext = EmptyCoroutineContext, block: suspend CoroutineScope.() -> Unit diff --git a/src/commonTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt b/src/commonTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt index f150d35..22ec61e 100644 --- a/src/commonTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt +++ b/src/commonTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt @@ -11,8 +11,9 @@ abstract class AbstractJobSchedulerTests(private val factory: () -> JobScheduler @AfterTest fun tearDown() = runBlocking { - scheduler.queue.cancel() + scheduler.queue.clear() } + @Test fun testSchedule() = runBlocking { val id = UUIDFactory.create().toString() @@ -150,11 +151,11 @@ abstract class AbstractJobSchedulerTests(private val factory: () -> JobScheduler assertEquals(1, scheduler.queue.jobs.count()) - scheduler.queue.clearJobs() + scheduler.queue.clear() assertEquals(0, scheduler.queue.jobs.count()) - scheduler.queue.restoreJobs() + scheduler.queue.restore() assertEquals(1, scheduler.queue.jobs.count()) } From 8454e0af14ec14667de25dcc58985199e5fdd6fe Mon Sep 17 00:00:00 2001 From: Jan Gaebel Date: Mon, 19 Dec 2022 18:14:50 +0100 Subject: [PATCH 10/28] fix(queue): coroutines --- .../liftric/persisted/queue/JobScheduler.kt | 5 +- .../com/liftric/persisted/queue/UUID.kt | 4 +- .../persisted/queue/JobSchedulerTests.kt | 21 ++- .../kotlin/com/liftric/persisted/queue/Job.kt | 9 +- .../com/liftric/persisted/queue/JobEvent.kt | 8 +- .../liftric/persisted/queue/JobScheduler.kt | 22 +-- .../liftric/persisted/queue/JsonStorage.kt | 47 +++++++ .../com/liftric/persisted/queue/Queue.kt | 133 +++++++++--------- .../com/liftric/persisted/queue/UUID.kt | 1 + .../persisted/queue/rules/RetryRule.kt | 3 +- .../persisted/queue/rules/UniqueRule.kt | 5 +- .../persisted/queue/JobSchedulerTests.kt | 7 +- .../com/liftric/persisted/queue/TestTask.kt | 2 +- .../liftric/persisted/queue/JobScheduler.kt | 4 +- .../com/liftric/persisted/queue/UUID.kt | 1 + .../persisted/queue/JobSchedulerTests.kt | 19 ++- 16 files changed, 170 insertions(+), 121 deletions(-) create mode 100644 src/commonMain/kotlin/com/liftric/persisted/queue/JsonStorage.kt diff --git a/src/androidMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt b/src/androidMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt index 90d48ea..fcc2c43 100644 --- a/src/androidMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt +++ b/src/androidMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt @@ -1,7 +1,6 @@ package com.liftric.persisted.queue import android.content.Context -import com.russhwolf.settings.Settings import com.russhwolf.settings.SharedPreferencesSettings import kotlinx.serialization.modules.SerializersModule @@ -9,9 +8,9 @@ actual class JobScheduler( context: Context, serializers: SerializersModule = SerializersModule {}, configuration: Queue.Configuration? = null, - settings: Settings = SharedPreferencesSettings.Factory(context).create("com.liftric.persisted.queue") + store: JsonStorage = SettingsStorage(SharedPreferencesSettings.Factory(context).create("com.liftric.persisted.queue")) ) : AbstractJobScheduler( serializers, configuration, - settings + store ) diff --git a/src/androidMain/kotlin/com/liftric/persisted/queue/UUID.kt b/src/androidMain/kotlin/com/liftric/persisted/queue/UUID.kt index 95968b8..5e92014 100644 --- a/src/androidMain/kotlin/com/liftric/persisted/queue/UUID.kt +++ b/src/androidMain/kotlin/com/liftric/persisted/queue/UUID.kt @@ -5,12 +5,12 @@ import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder -import java.util.UUID -actual typealias UUID = UUID +actual typealias UUID = java.util.UUID internal actual object UUIDFactory { actual fun create(): UUID = UUID.randomUUID() + actual fun fromString(string: String): UUID = UUID.fromString(string) } actual object UUIDSerializer: KSerializer { diff --git a/src/androidTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt b/src/androidTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt index 4a601cd..65505f3 100644 --- a/src/androidTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt +++ b/src/androidTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt @@ -2,20 +2,17 @@ package com.liftric.persisted.queue import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry -import com.russhwolf.settings.MapSettings import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.polymorphic import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) -actual class JobSchedulerTests: AbstractJobSchedulerTests({ - JobScheduler( - context = InstrumentationRegistry.getInstrumentation().targetContext, - serializers = SerializersModule { - polymorphic(Task::class) { - subclass(TestTask::class, TestTask.serializer()) - } - }, - settings = MapSettings() - ) -}) +actual class JobSchedulerTests: AbstractJobSchedulerTests(JobScheduler( + context = InstrumentationRegistry.getInstrumentation().targetContext, + serializers = SerializersModule { + polymorphic(Task::class) { + subclass(TestTask::class, TestTask.serializer()) + } + }, + store = MapStorage() +)) diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/Job.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/Job.kt index 0bd11c5..091c7cd 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/Job.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/Job.kt @@ -6,6 +6,7 @@ import kotlinx.datetime.Instant import kotlinx.serialization.Contextual import kotlinx.serialization.Serializable import kotlinx.serialization.Transient +import kotlin.coroutines.coroutineContext @Serializable data class Job( @@ -26,8 +27,8 @@ data class Job( private var canRepeat: Boolean = false internal suspend fun run() { - coroutineScope { - if (isCancelled) return@coroutineScope + withContext(coroutineContext) { + if (isCancelled) return@withContext cancellable = launch { val event = try { info.rules.forEach { it.willRun(this@Job) } @@ -39,7 +40,7 @@ data class Job( JobEvent.DidEnd(this@Job) } catch (e: CancellationException) { JobEvent.DidCancel(this@Job, "Cancelled during run") - } catch (e: Error) { + } catch (e: Throwable) { canRepeat = task.onRepeat(e) JobEvent.DidFail(this@Job, e) } @@ -52,7 +53,7 @@ data class Job( info.rules.forEach { it.willRemove(this@Job, event) } } catch (e: CancellationException) { delegate?.broadcast(JobEvent.DidCancel(this@Job, "Cancelled after run")) - } catch (e: Error) { + } catch (e: Throwable) { delegate?.broadcast(JobEvent.DidFailOnRemove(this@Job, e)) } finally { delegate?.exit(this@Job) diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/JobEvent.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/JobEvent.kt index 07737ed..b204e48 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/JobEvent.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/JobEvent.kt @@ -4,12 +4,12 @@ sealed class JobEvent { data class DidSchedule(val job: JobContext): JobEvent() data class DidScheduleRepeat(val job: JobContext): JobEvent() data class WillRun(val job: JobContext): JobEvent() - data class DidThrowOnRepeat(val error: Error): JobEvent() - data class DidThrowOnSchedule(val error: Error): JobEvent() + data class DidThrowOnRepeat(val error: Throwable): JobEvent() + data class DidThrowOnSchedule(val error: Throwable): JobEvent() data class DidEnd(val job: JobContext): JobEvent() - data class DidFail(val job: JobContext, val error: Error): JobEvent() + data class DidFail(val job: JobContext, val error: Throwable): JobEvent() data class DidCancel(val job: JobContext, val message: String): JobEvent() - data class DidFailOnRemove(val job: JobContext, val error: Error): JobEvent() + data class DidFailOnRemove(val job: JobContext, val error: Throwable): JobEvent() data class NotAllowedToRepeat(val job: JobContext): JobEvent() } diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt index 42e0fa6..391a320 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt @@ -1,8 +1,6 @@ package com.liftric.persisted.queue import com.liftric.persisted.queue.rules.* -import com.russhwolf.settings.Settings -import com.russhwolf.settings.set import kotlinx.coroutines.flow.* import kotlinx.datetime.serializers.InstantIso8601Serializer import kotlinx.serialization.encodeToString @@ -16,7 +14,7 @@ expect class JobScheduler: AbstractJobScheduler abstract class AbstractJobScheduler( serializers: SerializersModule, configuration: Queue.Configuration?, - private val settings: Settings + private val store: JsonStorage ) { private val delegate = JobDelegate() private val module = SerializersModule { @@ -35,7 +33,7 @@ abstract class AbstractJobScheduler( val onEvent = MutableSharedFlow(extraBufferCapacity = Int.MAX_VALUE) val queue = JobQueue( - settings = settings, + store = store, format = format, configuration = configuration ?: Queue.DefaultConfiguration, onRestore = { job -> @@ -46,7 +44,7 @@ abstract class AbstractJobScheduler( init { delegate.onExit = { job -> if (job.info.shouldPersist) { - settings.remove(job.id.toString()) + store.remove(job.id.toString()) } } delegate.onRepeat = { job -> @@ -87,7 +85,7 @@ abstract class AbstractJobScheduler( } if (job.info.shouldPersist) { - settings[job.id.toString()] = format.encodeToString(job) + store.set(job.id.toString(), format.encodeToString(job)) } queue.add(job) @@ -96,9 +94,17 @@ abstract class AbstractJobScheduler( private suspend fun repeat(job: Job) = try { job.delegate = delegate - schedule(job).apply { - onEvent.emit(JobEvent.DidScheduleRepeat(job)) + job.info.rules.forEach { + it.willSchedule(queue, job) } + + if (job.info.shouldPersist) { + store.set(job.id.toString(), format.encodeToString(job)) + } + + onEvent.emit(JobEvent.DidScheduleRepeat(job)) + + queue.add(job) } catch (error: Error) { onEvent.emit(JobEvent.DidThrowOnRepeat(error)) } diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/JsonStorage.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/JsonStorage.kt new file mode 100644 index 0000000..bc322d7 --- /dev/null +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/JsonStorage.kt @@ -0,0 +1,47 @@ +package com.liftric.persisted.queue + +import com.russhwolf.settings.Settings +import com.russhwolf.settings.set + +interface JsonStorage { + val keys: Set + fun get(id: String): String + fun set(id: String, json: String) + fun clear() + fun remove(id: String) +} + +class MapStorage: JsonStorage { + private val store = mutableMapOf() + override val keys: Set + get() = store.keys + override fun get(id: String): String { + return store.getValue(id) + } + override fun set(id: String, json: String) { + store[id] = json + } + override fun clear() { + store.clear() + } + override fun remove(id: String) { + store.remove(id) + } +} + +internal class SettingsStorage(private val store: Settings): JsonStorage { + override val keys: Set + get() = store.keys + override fun get(id: String): String { + return store.getString(id, "") + } + override fun set(id: String, json: String) { + store[id] = json + } + override fun clear() { + store.clear() + } + override fun remove(id: String) { + store.remove(id) + } +} diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt index 4ae35be..82fa93c 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt @@ -1,6 +1,5 @@ package com.liftric.persisted.queue -import com.russhwolf.settings.Settings import kotlinx.atomicfu.atomic import kotlinx.coroutines.* import kotlinx.coroutines.flow.* @@ -11,12 +10,15 @@ import kotlinx.coroutines.sync.withPermit import kotlinx.datetime.Clock import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json -import kotlin.coroutines.CoroutineContext -import kotlin.coroutines.EmptyCoroutineContext import kotlin.coroutines.coroutineContext +import kotlin.time.Duration +import kotlin.time.Duration.Companion.ZERO +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds interface Queue { val jobs: List + val runningJobs: List val configuration: Configuration data class Configuration( @@ -33,48 +35,38 @@ interface Queue { } class JobQueue( - private val settings: Settings, + private val store: JsonStorage, private val format: Json, override val configuration: Queue.Configuration, private val onRestore: (Job) -> Unit ): Queue { - private val cancellationQueue = MutableSharedFlow(extraBufferCapacity = Int.MAX_VALUE) - private val queue = atomic(mutableListOf()) + private val enqueuedJobs = atomic(mutableListOf()) + private val dequeuedJobs = atomic(mutableListOf()) private val lock = Semaphore(configuration.maxConcurrency, 0) - private val isCancelling = Mutex(false) - override val jobs: List - get() = queue.value - private var isRunning: Boolean = false private var cancellable: kotlinx.coroutines.Job? = null - init { - cancellationQueue.onEach { it.join() } - .flowOn(Dispatchers.Default) - .launchIn(configuration.scope) - } + override val jobs: List + get() = enqueuedJobs.value + override val runningJobs: List + get() = dequeuedJobs.value - internal fun add(job: Job) { - queue.value = queue.value.plus(listOf(job)).sortedBy { it.startTime }.toMutableList() + init { + restore() } fun start() { - if (isRunning) return else isRunning = true - restore() - cancellable = CoroutineScope(Dispatchers.Default).launch { - while (coroutineContext.isActive) { - if (queue.value.isEmpty()) break - if (isCancelling.isLocked) break - if (lock.availablePermits < 1) break - val job = queue.value.first() - if (job.isCancelled) { - queue.value.remove(job) - } else if (job.startTime <= Clock.System.now()) { - lock.withPermit { - configuration.scope.launch { - withTimeout(job.info.timeout) { - job.run() - queue.value.remove(job) - } + cancellable = CoroutineScope(Dispatchers.Default).launchPeriodicAsync(1.seconds) { + if (enqueuedJobs.value.isEmpty()) return@launchPeriodicAsync + if (lock.availablePermits < 1) return@launchPeriodicAsync + val job = enqueuedJobs.value.removeFirst() + dequeuedJobs.value.add(job) + if (job.isCancelled) return@launchPeriodicAsync + if (job.startTime <= Clock.System.now()) { + lock.withPermit { + configuration.scope.launch { + withTimeout(job.info.timeout) { + job.run() + dequeuedJobs.value.remove(job) } } } @@ -83,55 +75,62 @@ class JobQueue( } fun stop() { - if (isRunning) isRunning = false else return cancellable?.cancel() + cancellable = null } - suspend fun clear() { - submitCancellation(coroutineContext) { - isCancelling.withLock { - queue.value.clear() - configuration.scope.coroutineContext.cancelChildren() - settings.clear() - } + fun clear(cancelJobs: Boolean = true, clearStore: Boolean = true) { + enqueuedJobs.value.clear() + if (cancelJobs) { + dequeuedJobs.value.clear() + configuration.scope.coroutineContext.cancelChildren() + } + if (clearStore) { + store.clear() } } suspend fun cancel(id: UUID) { - submitCancellation(coroutineContext) { - isCancelling.withLock { - queue.value.firstOrNull { it.id == id }?.let { job -> - job.cancel() - queue.value.remove(job) - } - } + enqueuedJobs.value.firstOrNull { it.id == id }?.let { job -> + job.cancel() + enqueuedJobs.value.remove(job) + } ?: dequeuedJobs.value.firstOrNull { it.id == id }?.let { job -> + job.cancel() + dequeuedJobs.value.remove(job) } } suspend fun cancel(tag: String) { - submitCancellation(coroutineContext) { - isCancelling.withLock { - queue.value.firstOrNull { it.info.tag == tag }?.let { job -> - job.cancel() - queue.value.remove(job) - } - } + enqueuedJobs.value.firstOrNull { it.info.tag == tag }?.let { job -> + job.cancel() + enqueuedJobs.value.remove(job) + } ?: dequeuedJobs.value.firstOrNull { it.info.tag == tag }?.let { job -> + job.cancel() + dequeuedJobs.value.remove(job) } } - private fun restore() { - settings.keys.forEach { key -> - val job: Job = format.decodeFromString(settings.getString(key, "")) - onRestore(job) - add(job) + internal fun add(job: Job) { + enqueuedJobs.value = enqueuedJobs.value.plus(listOf(job)).sortedBy { it.startTime }.toMutableList() + } + + internal fun restore() { + store.keys.forEach { key -> + val job: Job = format.decodeFromString(store.get(key)) + if (jobs.plus(runningJobs).none { it.id == job.id }) { + onRestore(job) + add(job) + } } } +} - private fun submitCancellation( - context: CoroutineContext = EmptyCoroutineContext, - block: suspend CoroutineScope.() -> Unit - ) { - val job = configuration.scope.launch(context, CoroutineStart.LAZY, block) - cancellationQueue.tryEmit(job) +private fun CoroutineScope.launchPeriodicAsync( + repeat: Duration, + action: suspend () -> Unit +) = async { + while (isActive) { + action() + delay(repeat) } } diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/UUID.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/UUID.kt index da6d318..3517490 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/UUID.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/UUID.kt @@ -6,6 +6,7 @@ expect class UUID internal expect object UUIDFactory { fun create(): UUID + fun fromString(string: String): UUID } expect object UUIDSerializer: KSerializer diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/rules/RetryRule.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/rules/RetryRule.kt index 7576ba1..a7c159c 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/rules/RetryRule.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/rules/RetryRule.kt @@ -16,8 +16,7 @@ data class RetryRule(val limit: RetryLimit, val delay: Duration = 0.seconds): Jo } is RetryLimit.Limited -> { if (limit.count > 0) { - val rules = context.info.rules.minus(this).plus(RetryRule(RetryLimit.Limited((limit.count + 1) - 2), delay)) - context.broadcast(RuleEvent.OnRemove(this, "Attempting to retry task=$context")) + val rules = context.info.rules.minus(this).plus(RetryRule(RetryLimit.Limited(limit.count - 1), delay)) context.repeat(info = context.info.copy(rules = rules.toMutableList()), startTime = Clock.System.now().plus(delay)) } } diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/rules/UniqueRule.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/rules/UniqueRule.kt index f6bf8e3..426702c 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/rules/UniqueRule.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/rules/UniqueRule.kt @@ -11,7 +11,10 @@ data class UniqueRule(private val tag: String? = null): JobRule() { override suspend fun willSchedule(queue: Queue, context: JobContext) { for (item in queue.jobs) { - if (item.info.tag == tag || item.id == context.id) { + if (item.info.tag == tag) { + throw Error("Job with tag=${item.info.tag} already exists") + } + if (item.id == context.id) { throw Error("Job with id=${item.id} already exists") } } diff --git a/src/commonTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt b/src/commonTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt index 22ec61e..b8fb25a 100644 --- a/src/commonTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt +++ b/src/commonTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt @@ -6,11 +6,10 @@ import kotlin.test.* import kotlin.time.Duration.Companion.seconds expect class JobSchedulerTests: AbstractJobSchedulerTests -abstract class AbstractJobSchedulerTests(private val factory: () -> JobScheduler) { - private val scheduler = factory() - +abstract class AbstractJobSchedulerTests(private val scheduler: JobScheduler) { @AfterTest fun tearDown() = runBlocking { + scheduler.queue.stop() scheduler.queue.clear() } @@ -151,7 +150,7 @@ abstract class AbstractJobSchedulerTests(private val factory: () -> JobScheduler assertEquals(1, scheduler.queue.jobs.count()) - scheduler.queue.clear() + scheduler.queue.clear(clearStore = false) assertEquals(0, scheduler.queue.jobs.count()) diff --git a/src/commonTest/kotlin/com/liftric/persisted/queue/TestTask.kt b/src/commonTest/kotlin/com/liftric/persisted/queue/TestTask.kt index 86a695b..d423ea9 100644 --- a/src/commonTest/kotlin/com/liftric/persisted/queue/TestTask.kt +++ b/src/commonTest/kotlin/com/liftric/persisted/queue/TestTask.kt @@ -14,7 +14,7 @@ data class TestTask(override val data: TestData): DataTask { @Serializable class TestErrorTask: Task { - override suspend fun body() { throw Error("Oh shoot!") } + override suspend fun body() { throw Error("Oh shoot!") } override suspend fun onRepeat(cause: Throwable): Boolean = cause is Error } diff --git a/src/iosMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt b/src/iosMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt index 1c74dc6..921e407 100644 --- a/src/iosMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt +++ b/src/iosMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt @@ -8,9 +8,9 @@ import platform.Foundation.NSUserDefaults actual class JobScheduler( serializers: SerializersModule = SerializersModule {}, configuration: Queue.Configuration? = null, - settings: Settings = NSUserDefaultsSettings(NSUserDefaults("com.liftric.persisted.queue")) + store: JsonStorage = SettingsStorage(NSUserDefaultsSettings(NSUserDefaults("com.liftric.persisted.queue"))) ) : AbstractJobScheduler( serializers, configuration, - settings + store ) diff --git a/src/iosMain/kotlin/com/liftric/persisted/queue/UUID.kt b/src/iosMain/kotlin/com/liftric/persisted/queue/UUID.kt index fed36e2..e66b59d 100644 --- a/src/iosMain/kotlin/com/liftric/persisted/queue/UUID.kt +++ b/src/iosMain/kotlin/com/liftric/persisted/queue/UUID.kt @@ -11,6 +11,7 @@ actual typealias UUID = NSUUID internal actual object UUIDFactory { actual fun create(): UUID = NSUUID() + actual fun fromString(string: String): UUID = UUID(string) } actual object UUIDSerializer: KSerializer { diff --git a/src/iosTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt b/src/iosTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt index 38db718..f0cb02f 100644 --- a/src/iosTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt +++ b/src/iosTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt @@ -1,16 +1,13 @@ package com.liftric.persisted.queue -import com.russhwolf.settings.MapSettings import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.polymorphic -actual class JobSchedulerTests: AbstractJobSchedulerTests({ - JobScheduler( - serializers = SerializersModule { - polymorphic(Task::class) { - subclass(TestTask::class, TestTask.serializer()) - } - }, - settings = MapSettings() - ) -}) +actual class JobSchedulerTests: AbstractJobSchedulerTests(JobScheduler( + serializers = SerializersModule { + polymorphic(Task::class) { + subclass(TestTask::class, TestTask.serializer()) + } + }, + store = MapStorage() +)) From bf362bbb77e02d3d8edc61713086d4935e5a945b Mon Sep 17 00:00:00 2001 From: Jan Gaebel Date: Tue, 20 Dec 2022 10:40:56 +0100 Subject: [PATCH 11/28] refactor(queue): final adjustements --- .../com/liftric/persisted/queue/UUID.kt | 1 + .../kotlin/com/liftric/persisted/queue/Job.kt | 62 +++++++--------- .../liftric/persisted/queue/JobDelegate.kt | 16 ++-- .../com/liftric/persisted/queue/JobEvent.kt | 6 +- .../liftric/persisted/queue/JobScheduler.kt | 58 ++++++++------- .../com/liftric/persisted/queue/Queue.kt | 74 +++++++++++++++---- .../persisted/queue/rules/PeriodicRule.kt | 2 +- .../persisted/queue/JobSchedulerTests.kt | 4 +- 8 files changed, 134 insertions(+), 89 deletions(-) diff --git a/src/androidMain/kotlin/com/liftric/persisted/queue/UUID.kt b/src/androidMain/kotlin/com/liftric/persisted/queue/UUID.kt index 5e92014..cb60d91 100644 --- a/src/androidMain/kotlin/com/liftric/persisted/queue/UUID.kt +++ b/src/androidMain/kotlin/com/liftric/persisted/queue/UUID.kt @@ -1,6 +1,7 @@ package com.liftric.persisted.queue import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor import kotlinx.serialization.encoding.Decoder diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/Job.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/Job.kt index 091c7cd..bd4a3ee 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/Job.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/Job.kt @@ -3,61 +3,56 @@ package com.liftric.persisted.queue import kotlinx.coroutines.* import kotlinx.datetime.Clock import kotlinx.datetime.Instant -import kotlinx.serialization.Contextual import kotlinx.serialization.Serializable import kotlinx.serialization.Transient import kotlin.coroutines.coroutineContext @Serializable data class Job( - @Contextual override val id: UUID, + @Serializable(with = UUIDSerializer::class) + override val id: UUID, override val info: JobInfo, override val task: Task, override val startTime: Instant ): JobContext { @Transient var delegate: JobDelegate? = null + @Transient var isCancelled: Boolean = false + private set constructor(task: Task, info: JobInfo) : this (UUIDFactory.create(), info, task, Clock.System.now()) - private var cancellable: kotlinx.coroutines.Job? = null - - var isCancelled: Boolean = false - private set - private var canRepeat: Boolean = false - internal suspend fun run() { + suspend fun run() { withContext(coroutineContext) { if (isCancelled) return@withContext - cancellable = launch { - val event = try { - info.rules.forEach { it.willRun(this@Job) } + val event = try { + info.rules.forEach { it.willRun(this@Job) } - delegate?.broadcast(JobEvent.WillRun(this@Job)) + delegate?.broadcast(JobEvent.WillRun(this@Job)) - task.body() + task.body() - JobEvent.DidEnd(this@Job) - } catch (e: CancellationException) { - JobEvent.DidCancel(this@Job, "Cancelled during run") - } catch (e: Throwable) { - canRepeat = task.onRepeat(e) - JobEvent.DidFail(this@Job, e) - } + JobEvent.DidSucceed(this@Job) + } catch (e: CancellationException) { + JobEvent.DidCancel(this@Job) + } catch (e: Throwable) { + canRepeat = task.onRepeat(e) + JobEvent.DidFail(this@Job, e) + } - try { - delegate?.broadcast(event) + try { + delegate?.broadcast(event) - if (isCancelled) return@launch + if (isCancelled) return@withContext - info.rules.forEach { it.willRemove(this@Job, event) } - } catch (e: CancellationException) { - delegate?.broadcast(JobEvent.DidCancel(this@Job, "Cancelled after run")) - } catch (e: Throwable) { - delegate?.broadcast(JobEvent.DidFailOnRemove(this@Job, e)) - } finally { - delegate?.exit(this@Job) - } + info.rules.forEach { it.willRemove(this@Job, event) } + } catch (e: CancellationException) { + delegate?.broadcast(JobEvent.DidCancel(this@Job)) + } catch (e: Throwable) { + delegate?.broadcast(JobEvent.DidFailOnRemove(this@Job, e)) + } finally { + delegate?.exit(this@Job) } } } @@ -65,10 +60,7 @@ data class Job( override suspend fun cancel() { if (isCancelled) return isCancelled = true - cancellable?.cancel(CancellationException("Cancelled during run")) ?: run { - delegate?.broadcast(JobEvent.DidCancel(this@Job, "Cancelled before run")) - } - delegate?.exit(this@Job) + delegate?.cancel(this@Job) } override suspend fun repeat(id: UUID, info: JobInfo, task: Task, startTime: Instant) { diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/JobDelegate.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/JobDelegate.kt index 6f4a2c7..310906d 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/JobDelegate.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/JobDelegate.kt @@ -1,20 +1,24 @@ package com.liftric.persisted.queue +import kotlinx.coroutines.flow.MutableSharedFlow + class JobDelegate { - var onExit: (suspend (Job) -> Unit)? = null - var onRepeat: (suspend (Job) -> Unit)? = null - var onEvent: (suspend (JobEvent) -> Unit)? = null + val onEvent = MutableSharedFlow(extraBufferCapacity = Int.MAX_VALUE) suspend fun broadcast(event: JobEvent) { - onEvent?.invoke(event) + onEvent.emit(event) + } + + suspend fun cancel(job: Job) { + onEvent.emit(JobEvent.DidCancel(job)) } suspend fun exit(job: Job) { - onExit?.invoke(job) + onEvent.emit(JobEvent.DidExit(job)) } suspend fun repeat(job: Job) { - onRepeat?.invoke(job) + onEvent.emit(JobEvent.ShouldRepeat(job)) } } diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/JobEvent.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/JobEvent.kt index b204e48..dbfddb2 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/JobEvent.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/JobEvent.kt @@ -6,9 +6,11 @@ sealed class JobEvent { data class WillRun(val job: JobContext): JobEvent() data class DidThrowOnRepeat(val error: Throwable): JobEvent() data class DidThrowOnSchedule(val error: Throwable): JobEvent() - data class DidEnd(val job: JobContext): JobEvent() + data class DidSucceed(val job: JobContext): JobEvent() data class DidFail(val job: JobContext, val error: Throwable): JobEvent() - data class DidCancel(val job: JobContext, val message: String): JobEvent() + data class DidExit(val job: JobContext): JobEvent() + data class ShouldRepeat(val job: Job): JobEvent() + data class DidCancel(val job: JobContext): JobEvent() data class DidFailOnRemove(val job: JobContext, val error: Throwable): JobEvent() data class NotAllowedToRepeat(val job: JobContext): JobEvent() } diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt index 391a320..5c63e84 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt @@ -1,7 +1,10 @@ package com.liftric.persisted.queue import com.liftric.persisted.queue.rules.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch import kotlinx.datetime.serializers.InstantIso8601Serializer import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -36,23 +39,32 @@ abstract class AbstractJobScheduler( store = store, format = format, configuration = configuration ?: Queue.DefaultConfiguration, - onRestore = { job -> - job.delegate = delegate - } + onRestore = { job -> job.apply { job.delegate = delegate } } ) init { - delegate.onExit = { job -> - if (job.info.shouldPersist) { - store.remove(job.id.toString()) + CoroutineScope(Dispatchers.Default).launch { + delegate.onEvent.collect { event -> + when (event) { + is JobEvent.DidCancel -> { + if (event.job.info.shouldPersist) { + store.remove(event.job.id.toString()) + } + queue.cancel(event.job.id) + onEvent.emit(event) + } + is JobEvent.DidExit -> { + if (event.job.info.shouldPersist) { + store.remove(event.job.id.toString()) + } + } + is JobEvent.ShouldRepeat -> { + repeat(event.job) + } + else -> onEvent.emit(event) + } } } - delegate.onRepeat = { job -> - repeat(job) - } - delegate.onEvent = { event -> - onEvent.emit(event) - } } suspend fun schedule(task: () -> Task, configure: JobInfo.() -> JobInfo = { JobInfo() }) { @@ -77,21 +89,15 @@ abstract class AbstractJobScheduler( onEvent.emit(JobEvent.DidThrowOnSchedule(error)) } - private suspend fun schedule(job: Job) { - job.delegate = delegate - - job.info.rules.forEach { - it.willSchedule(queue, job) - } - - if (job.info.shouldPersist) { - store.set(job.id.toString(), format.encodeToString(job)) + private suspend fun repeat(job: Job) = try { + schedule(job).apply { + onEvent.emit(JobEvent.DidScheduleRepeat(job)) } - - queue.add(job) + } catch (error: Error) { + onEvent.emit(JobEvent.DidThrowOnRepeat(error)) } - private suspend fun repeat(job: Job) = try { + private suspend fun schedule(job: Job) { job.delegate = delegate job.info.rules.forEach { @@ -102,10 +108,6 @@ abstract class AbstractJobScheduler( store.set(job.id.toString(), format.encodeToString(job)) } - onEvent.emit(JobEvent.DidScheduleRepeat(job)) - queue.add(job) - } catch (error: Error) { - onEvent.emit(JobEvent.DidThrowOnRepeat(error)) } } diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt index 82fa93c..a03d104 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt @@ -2,18 +2,12 @@ package com.liftric.persisted.queue import kotlinx.atomicfu.atomic import kotlinx.coroutines.* -import kotlinx.coroutines.flow.* -import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withPermit import kotlinx.datetime.Clock import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json -import kotlin.coroutines.coroutineContext import kotlin.time.Duration -import kotlin.time.Duration.Companion.ZERO -import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds interface Queue { @@ -23,26 +17,47 @@ interface Queue { data class Configuration( val scope: CoroutineScope, - val maxConcurrency: Int + val maxConcurrency: Int, + val startsAutomatically: Boolean ) - companion object Default { + companion object { val DefaultConfiguration = Configuration( scope = CoroutineScope(Dispatchers.Default), - maxConcurrency = 1 + maxConcurrency = 1, + startsAutomatically = false ) } } +/** + * Handles job enqueuing and cancelling. + * @param store Storage to restore jobs + * @param format Serializer to decode stored jobs + * @param configuration Queue configuration + * @param onRestore Callback to mutate restored jobs + */ class JobQueue( private val store: JsonStorage, private val format: Json, override val configuration: Queue.Configuration, - private val onRestore: (Job) -> Unit + private val onRestore: (Job) -> Job ): Queue { + /** + * Scheduled jobs + */ private val enqueuedJobs = atomic(mutableListOf()) + + /** + * Running jobs + */ private val dequeuedJobs = atomic(mutableListOf()) + + /** + * Semaphore to limit concurrency + */ private val lock = Semaphore(configuration.maxConcurrency, 0) + private var cancellable: kotlinx.coroutines.Job? = null override val jobs: List @@ -52,18 +67,25 @@ class JobQueue( init { restore() + + if (configuration.startsAutomatically) { + start() + } } + /** + * Starts enqueuing scheduled jobs + */ fun start() { cancellable = CoroutineScope(Dispatchers.Default).launchPeriodicAsync(1.seconds) { if (enqueuedJobs.value.isEmpty()) return@launchPeriodicAsync if (lock.availablePermits < 1) return@launchPeriodicAsync val job = enqueuedJobs.value.removeFirst() - dequeuedJobs.value.add(job) if (job.isCancelled) return@launchPeriodicAsync + dequeuedJobs.value.add(job) if (job.startTime <= Clock.System.now()) { - lock.withPermit { - configuration.scope.launch { + configuration.scope.launch { + lock.withPermit { withTimeout(job.info.timeout) { job.run() dequeuedJobs.value.remove(job) @@ -74,11 +96,19 @@ class JobQueue( } } + /** + * Stops enqueuing scheduled jobs + */ fun stop() { cancellable?.cancel() cancellable = null } + /** + * Removes all scheduled jobs + * @param cancelJobs Cancels running jobs + * @param clearStore Removes persisted jobs + */ fun clear(cancelJobs: Boolean = true, clearStore: Boolean = true) { enqueuedJobs.value.clear() if (cancelJobs) { @@ -90,6 +120,10 @@ class JobQueue( } } + /** + * Cancels jobs + * @param id Unique identifier of the job + */ suspend fun cancel(id: UUID) { enqueuedJobs.value.firstOrNull { it.id == id }?.let { job -> job.cancel() @@ -100,6 +134,10 @@ class JobQueue( } } + /** + * Cancels job + * @param tag User defined tag of the job + */ suspend fun cancel(tag: String) { enqueuedJobs.value.firstOrNull { it.info.tag == tag }?.let { job -> job.cancel() @@ -110,16 +148,22 @@ class JobQueue( } } + /** + * Enqueues job and sorts queue based on start time + * @param job Job to enqueue + */ internal fun add(job: Job) { enqueuedJobs.value = enqueuedJobs.value.plus(listOf(job)).sortedBy { it.startTime }.toMutableList() } + /** + * Restores all persisted jobs. Ensures job not already in queue. + */ internal fun restore() { store.keys.forEach { key -> val job: Job = format.decodeFromString(store.get(key)) if (jobs.plus(runningJobs).none { it.id == job.id }) { - onRestore(job) - add(job) + add(onRestore(job)) } } } diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/rules/PeriodicRule.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/rules/PeriodicRule.kt index 2ec6610..809c864 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/rules/PeriodicRule.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/rules/PeriodicRule.kt @@ -9,7 +9,7 @@ import kotlin.time.Duration.Companion.seconds @Serializable data class PeriodicRule(val interval: Duration = 0.seconds): JobRule() { override suspend fun willRemove(context: JobContext, result: JobEvent) { - if (result is JobEvent.DidEnd) { + if (result is JobEvent.DidSucceed) { context.repeat(startTime = Clock.System.now().plus(interval)) } } diff --git a/src/commonTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt b/src/commonTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt index b8fb25a..bc2edc1 100644 --- a/src/commonTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt +++ b/src/commonTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt @@ -72,7 +72,7 @@ abstract class AbstractJobSchedulerTests(private val scheduler: JobScheduler) { launch { scheduler.onEvent.collect { println(it) - if (it is JobEvent.DidEnd || it is JobEvent.DidFail) fail("Continued after run") + if (it is JobEvent.DidSucceed || it is JobEvent.DidFail) fail("Continued after run") if (it is JobEvent.WillRun) { scheduler.queue.cancel(it.job.id) } @@ -95,7 +95,7 @@ abstract class AbstractJobSchedulerTests(private val scheduler: JobScheduler) { launch { scheduler.onEvent.collect { println(it) - if (it is JobEvent.DidEnd || it is JobEvent.DidFail) fail("Continued after run") + if (it is JobEvent.DidSucceed || it is JobEvent.DidFail) fail("Continued after run") if (it is JobEvent.DidSchedule) { completable.complete(it.job.id) } From 0c965a68803ff0f92d301765348184221c76e5aa Mon Sep 17 00:00:00 2001 From: Jan Gaebel Date: Tue, 20 Dec 2022 10:46:01 +0100 Subject: [PATCH 12/28] fix(queue): ensure start only if not running --- src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt index a03d104..059e108 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt @@ -77,6 +77,7 @@ class JobQueue( * Starts enqueuing scheduled jobs */ fun start() { + if (cancellable != null) return cancellable = CoroutineScope(Dispatchers.Default).launchPeriodicAsync(1.seconds) { if (enqueuedJobs.value.isEmpty()) return@launchPeriodicAsync if (lock.availablePermits < 1) return@launchPeriodicAsync From a08d359b5d13f689bad3ebc3ecec6173d8d81f95 Mon Sep 17 00:00:00 2001 From: Jan Gaebel Date: Mon, 9 Jan 2023 18:27:37 +0100 Subject: [PATCH 13/28] chore(gradle): bump to Kotlin 1.8 --- build.gradle.kts | 9 +++++---- gradle.properties | 1 + gradle/wrapper/gradle-wrapper.properties | 2 +- settings.gradle.kts | 8 ++++---- src/main/AndroidManifest.xml | 1 - 5 files changed, 11 insertions(+), 10 deletions(-) delete mode 100644 src/main/AndroidManifest.xml diff --git a/build.gradle.kts b/build.gradle.kts index 2b3fcf6..34cd901 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -15,9 +15,8 @@ repositories { gradlePluginPortal() } -java { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 +tasks.withType(JavaCompile::class) { + options.release.set(8) } kotlin { @@ -81,6 +80,8 @@ kotlin { android { compileSdk = 30 + namespace = "com.liftric.persisted.queue" + defaultConfig { minSdk = 21 targetSdk = 30 @@ -92,7 +93,7 @@ android { targetCompatibility = JavaVersion.VERSION_11 } testOptions { - unitTests.apply { + unitTests { isReturnDefaultValues = true } } diff --git a/gradle.properties b/gradle.properties index 081cad3..c6d0049 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,6 +5,7 @@ org.gradle.vfs.watch=true kotlin.native.enableDependencyPropagation=false kotlin.mpp.enableGranularSourceSetsMetadata=true kotlin.mpp.enableCompatibilityMetadataVariant=true +kotlin.mpp.androidSourceSetLayoutVersion1.nowarn=true kotlin.incremental=true kotlin.incremental.multiplatform=true kotlin.caching.enabled=true diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 8fad3f5..f42e62f 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-all.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/settings.gradle.kts b/settings.gradle.kts index 24403f2..b3b001b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -16,11 +16,11 @@ dependencyResolutionManagement { versionCatalogs { create("libs") { - version("android-tools-gradle", "7.2.2") - version("kotlin", "1.7.20") + version("android-tools-gradle", "7.3.0") + version("kotlin", "1.8.0") library("kotlinx-coroutines", "org.jetbrains.kotlinx", "kotlinx-coroutines-core").version("1.6.4") - library("kotlinx-serialization", "org.jetbrains.kotlinx", "kotlinx-serialization-json").version("1.4.0") - library("kotlinx-atomicfu", "org.jetbrains.kotlinx", "atomicfu").version("0.18.5") + library("kotlinx-serialization", "org.jetbrains.kotlinx", "kotlinx-serialization-json").version("1.4.1") + library("kotlinx-atomicfu", "org.jetbrains.kotlinx", "atomicfu").version("0.19.0") library("kotlinx-datetime", "org.jetbrains.kotlinx", "kotlinx-datetime").version("0.4.0") library("androidx-test-core", "androidx.test", "core").version("1.4.0") library("androidx-test-runner", "androidx.test", "runner").version("1.4.0") diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml deleted file mode 100644 index 1a7c120..0000000 --- a/src/main/AndroidManifest.xml +++ /dev/null @@ -1 +0,0 @@ - From 728cea9e17ff670e7514634cec53e56bf645d748 Mon Sep 17 00:00:00 2001 From: Jan Gaebel Date: Mon, 9 Jan 2023 18:40:52 +0100 Subject: [PATCH 14/28] chore(gradle): set java release 11 --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 34cd901..a700fe9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -16,7 +16,7 @@ repositories { } tasks.withType(JavaCompile::class) { - options.release.set(8) + options.release.set(11) } kotlin { From 046daa446c787f3b1cbee6a95d3c2fe083d1e6df Mon Sep 17 00:00:00 2001 From: Jan Gaebel Date: Mon, 9 Jan 2023 18:53:23 +0100 Subject: [PATCH 15/28] chore(tests): try to fix flaky test --- .../kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/commonTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt b/src/commonTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt index bc2edc1..0878381 100644 --- a/src/commonTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt +++ b/src/commonTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt @@ -54,12 +54,14 @@ abstract class AbstractJobSchedulerTests(private val scheduler: JobScheduler) { } } + delay(1000L) + scheduler.schedule(TestErrorTask()) { retry(RetryLimit.Limited(3), delay = 1.seconds) } scheduler.queue.start() - delay(10000L) + delay(15000L) job.cancel() assertEquals(3, count) } From 02dcd9ecb6db971e9de8c48a7711f38b2ffc61b1 Mon Sep 17 00:00:00 2001 From: Jan Gaebel Date: Tue, 10 Jan 2023 13:10:40 +0100 Subject: [PATCH 16/28] refactor(queue): suspend with lock --- build.gradle.kts | 1 + settings.gradle.kts | 1 + .../kotlin/com/liftric/persisted/queue/Job.kt | 46 ++++---- .../com/liftric/persisted/queue/Queue.kt | 100 +++++++++--------- 4 files changed, 75 insertions(+), 73 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index a700fe9..d5371a0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -46,6 +46,7 @@ kotlin { implementation(kotlin("test-common")) implementation(kotlin("test-annotations-common")) implementation(libs.multiplatform.settings.test) + implementation(libs.kotlinx.coroutines.test) } } val androidMain by getting diff --git a/settings.gradle.kts b/settings.gradle.kts index b3b001b..bb1a2a6 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -19,6 +19,7 @@ dependencyResolutionManagement { version("android-tools-gradle", "7.3.0") version("kotlin", "1.8.0") library("kotlinx-coroutines", "org.jetbrains.kotlinx", "kotlinx-coroutines-core").version("1.6.4") + library("kotlinx-coroutines-test", "org.jetbrains.kotlinx", "kotlinx-coroutines-test").version("1.6.4") library("kotlinx-serialization", "org.jetbrains.kotlinx", "kotlinx-serialization-json").version("1.4.1") library("kotlinx-atomicfu", "org.jetbrains.kotlinx", "atomicfu").version("0.19.0") library("kotlinx-datetime", "org.jetbrains.kotlinx", "kotlinx-datetime").version("0.4.0") diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/Job.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/Job.kt index bd4a3ee..52f0aba 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/Job.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/Job.kt @@ -24,36 +24,34 @@ data class Job( private var canRepeat: Boolean = false suspend fun run() { - withContext(coroutineContext) { - if (isCancelled) return@withContext - val event = try { - info.rules.forEach { it.willRun(this@Job) } + if (isCancelled) return + val event = try { + info.rules.forEach { it.willRun(this@Job) } - delegate?.broadcast(JobEvent.WillRun(this@Job)) + delegate?.broadcast(JobEvent.WillRun(this@Job)) - task.body() + task.body() - JobEvent.DidSucceed(this@Job) - } catch (e: CancellationException) { - JobEvent.DidCancel(this@Job) - } catch (e: Throwable) { - canRepeat = task.onRepeat(e) - JobEvent.DidFail(this@Job, e) - } + JobEvent.DidSucceed(this@Job) + } catch (e: CancellationException) { + JobEvent.DidCancel(this@Job) + } catch (e: Throwable) { + canRepeat = task.onRepeat(e) + JobEvent.DidFail(this@Job, e) + } - try { - delegate?.broadcast(event) + try { + delegate?.broadcast(event) - if (isCancelled) return@withContext + if (isCancelled) return - info.rules.forEach { it.willRemove(this@Job, event) } - } catch (e: CancellationException) { - delegate?.broadcast(JobEvent.DidCancel(this@Job)) - } catch (e: Throwable) { - delegate?.broadcast(JobEvent.DidFailOnRemove(this@Job, e)) - } finally { - delegate?.exit(this@Job) - } + info.rules.forEach { it.willRemove(this@Job, event) } + } catch (e: CancellationException) { + delegate?.broadcast(JobEvent.DidCancel(this@Job)) + } catch (e: Throwable) { + delegate?.broadcast(JobEvent.DidFailOnRemove(this@Job, e)) + } finally { + delegate?.exit(this@Job) } } diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt index 059e108..ee93dd3 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt @@ -2,12 +2,13 @@ package com.liftric.persisted.queue import kotlinx.atomicfu.atomic import kotlinx.coroutines.* +import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withPermit import kotlinx.datetime.Clock import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json -import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds interface Queue { @@ -46,24 +47,25 @@ class JobQueue( /** * Scheduled jobs */ - private val enqueuedJobs = atomic(mutableListOf()) + private val scheduledJobs = atomic(mutableListOf()) /** * Running jobs */ - private val dequeuedJobs = atomic(mutableListOf()) + private val _runningJobs = atomic(mutableListOf()) /** * Semaphore to limit concurrency */ private val lock = Semaphore(configuration.maxConcurrency, 0) + private val isCancelling = Mutex() private var cancellable: kotlinx.coroutines.Job? = null override val jobs: List - get() = enqueuedJobs.value + get() = scheduledJobs.value override val runningJobs: List - get() = dequeuedJobs.value + get() = _runningJobs.value init { restore() @@ -78,18 +80,22 @@ class JobQueue( */ fun start() { if (cancellable != null) return - cancellable = CoroutineScope(Dispatchers.Default).launchPeriodicAsync(1.seconds) { - if (enqueuedJobs.value.isEmpty()) return@launchPeriodicAsync - if (lock.availablePermits < 1) return@launchPeriodicAsync - val job = enqueuedJobs.value.removeFirst() - if (job.isCancelled) return@launchPeriodicAsync - dequeuedJobs.value.add(job) - if (job.startTime <= Clock.System.now()) { - configuration.scope.launch { - lock.withPermit { - withTimeout(job.info.timeout) { - job.run() - dequeuedJobs.value.remove(job) + cancellable = CoroutineScope(Dispatchers.Default).launch { + while (isActive) { + lock.withPermit { + if (isCancelling.isLocked) return@withPermit + if (scheduledJobs.value.isEmpty()) return@withPermit + if (scheduledJobs.value.first().startTime.minus(Clock.System.now()) > 0.seconds) return@withPermit + val job = scheduledJobs.value.removeFirst() + if (job.isCancelled) return@withPermit + _runningJobs.value.add(job) + configuration.scope.launch { + try { + withTimeout(job.info.timeout) { + job.run() + } + } finally { + _runningJobs.value.remove(job) } } } @@ -110,14 +116,16 @@ class JobQueue( * @param cancelJobs Cancels running jobs * @param clearStore Removes persisted jobs */ - fun clear(cancelJobs: Boolean = true, clearStore: Boolean = true) { - enqueuedJobs.value.clear() - if (cancelJobs) { - dequeuedJobs.value.clear() - configuration.scope.coroutineContext.cancelChildren() - } - if (clearStore) { - store.clear() + suspend fun clear(cancelJobs: Boolean = true, clearStore: Boolean = true) { + isCancelling.withLock { + scheduledJobs.value.clear() + if (cancelJobs) { + _runningJobs.value.clear() + configuration.scope.coroutineContext.cancelChildren() + } + if (clearStore) { + store.clear() + } } } @@ -126,12 +134,14 @@ class JobQueue( * @param id Unique identifier of the job */ suspend fun cancel(id: UUID) { - enqueuedJobs.value.firstOrNull { it.id == id }?.let { job -> - job.cancel() - enqueuedJobs.value.remove(job) - } ?: dequeuedJobs.value.firstOrNull { it.id == id }?.let { job -> - job.cancel() - dequeuedJobs.value.remove(job) + isCancelling.withLock { + scheduledJobs.value.firstOrNull { it.id == id }?.let { job -> + job.cancel() + scheduledJobs.value.remove(job) + } ?: _runningJobs.value.firstOrNull { it.id == id }?.let { job -> + job.cancel() + _runningJobs.value.remove(job) + } } } @@ -140,12 +150,14 @@ class JobQueue( * @param tag User defined tag of the job */ suspend fun cancel(tag: String) { - enqueuedJobs.value.firstOrNull { it.info.tag == tag }?.let { job -> - job.cancel() - enqueuedJobs.value.remove(job) - } ?: dequeuedJobs.value.firstOrNull { it.info.tag == tag }?.let { job -> - job.cancel() - dequeuedJobs.value.remove(job) + isCancelling.withLock { + scheduledJobs.value.firstOrNull { it.info.tag == tag }?.let { job -> + job.cancel() + scheduledJobs.value.remove(job) + } ?: _runningJobs.value.firstOrNull { it.info.tag == tag }?.let { job -> + job.cancel() + _runningJobs.value.remove(job) + } } } @@ -154,7 +166,7 @@ class JobQueue( * @param job Job to enqueue */ internal fun add(job: Job) { - enqueuedJobs.value = enqueuedJobs.value.plus(listOf(job)).sortedBy { it.startTime }.toMutableList() + scheduledJobs.value = scheduledJobs.value.plus(listOf(job)).sortedBy { it.startTime }.toMutableList() } /** @@ -163,19 +175,9 @@ class JobQueue( internal fun restore() { store.keys.forEach { key -> val job: Job = format.decodeFromString(store.get(key)) - if (jobs.plus(runningJobs).none { it.id == job.id }) { + if (jobs.plus(this.runningJobs).none { it.id == job.id }) { add(onRestore(job)) } } } } - -private fun CoroutineScope.launchPeriodicAsync( - repeat: Duration, - action: suspend () -> Unit -) = async { - while (isActive) { - action() - delay(repeat) - } -} From 0dde5371c7fd0fbdc389f9b8b7f1e98c23f0342c Mon Sep 17 00:00:00 2001 From: Jan Gaebel Date: Wed, 11 Jan 2023 14:30:59 +0100 Subject: [PATCH 17/28] refactor(job): ignore cancellation exception --- .../kotlin/com/liftric/persisted/queue/Job.kt | 13 ------ .../com/liftric/persisted/queue/Queue.kt | 10 ++--- .../persisted/queue/JobSchedulerTests.kt | 45 ++++++++++--------- 3 files changed, 28 insertions(+), 40 deletions(-) diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/Job.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/Job.kt index 52f0aba..bdcd105 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/Job.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/Job.kt @@ -16,15 +16,12 @@ data class Job( override val startTime: Instant ): JobContext { @Transient var delegate: JobDelegate? = null - @Transient var isCancelled: Boolean = false - private set constructor(task: Task, info: JobInfo) : this (UUIDFactory.create(), info, task, Clock.System.now()) private var canRepeat: Boolean = false suspend fun run() { - if (isCancelled) return val event = try { info.rules.forEach { it.willRun(this@Job) } @@ -33,8 +30,6 @@ data class Job( task.body() JobEvent.DidSucceed(this@Job) - } catch (e: CancellationException) { - JobEvent.DidCancel(this@Job) } catch (e: Throwable) { canRepeat = task.onRepeat(e) JobEvent.DidFail(this@Job, e) @@ -43,11 +38,7 @@ data class Job( try { delegate?.broadcast(event) - if (isCancelled) return - info.rules.forEach { it.willRemove(this@Job, event) } - } catch (e: CancellationException) { - delegate?.broadcast(JobEvent.DidCancel(this@Job)) } catch (e: Throwable) { delegate?.broadcast(JobEvent.DidFailOnRemove(this@Job, e)) } finally { @@ -56,16 +47,12 @@ data class Job( } override suspend fun cancel() { - if (isCancelled) return - isCancelled = true delegate?.cancel(this@Job) } override suspend fun repeat(id: UUID, info: JobInfo, task: Task, startTime: Instant) { if (canRepeat) { delegate?.repeat(Job(id, info, task, startTime)) - } else { - delegate?.broadcast(JobEvent.NotAllowedToRepeat(this@Job)) } } diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt index ee93dd3..9e714c1 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt +++ b/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt @@ -87,16 +87,12 @@ class JobQueue( if (scheduledJobs.value.isEmpty()) return@withPermit if (scheduledJobs.value.first().startTime.minus(Clock.System.now()) > 0.seconds) return@withPermit val job = scheduledJobs.value.removeFirst() - if (job.isCancelled) return@withPermit _runningJobs.value.add(job) configuration.scope.launch { - try { - withTimeout(job.info.timeout) { - job.run() - } - } finally { - _runningJobs.value.remove(job) + withTimeout(job.info.timeout) { + job.run() } + _runningJobs.value.remove(job) } } } diff --git a/src/commonTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt b/src/commonTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt index 0878381..685f45e 100644 --- a/src/commonTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt +++ b/src/commonTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt @@ -14,32 +14,35 @@ abstract class AbstractJobSchedulerTests(private val scheduler: JobScheduler) { } @Test - fun testSchedule() = runBlocking { - val id = UUIDFactory.create().toString() - val job = async { - scheduler.onEvent.collect { - println(it) + fun testSchedule() { + runBlocking { + val id = UUIDFactory.create().toString() + val job = async { + scheduler.onEvent.collect { + println(it) + } } - } - scheduler.schedule(TestData(id), ::TestTask) { - delay(1.seconds) - unique(id) - } + scheduler.schedule(TestData(id), ::TestTask) { + delay(1.seconds) + unique(id) + } - scheduler.schedule(TestTask(TestData(id))) { - unique(id) - } + scheduler.schedule(TestTask(TestData(id))) { + unique(id) + } - assertEquals(1, scheduler.queue.jobs.count()) + assertEquals(1, scheduler.queue.jobs.count()) - scheduler.queue.start() + scheduler.queue.start() - delay(2000L) + delay(2000L) - assertEquals(0, scheduler.queue.jobs.count()) + assertEquals(0, scheduler.queue.jobs.count()) - job.cancel() + job.cancel() + + } } @Test @@ -69,8 +72,6 @@ abstract class AbstractJobSchedulerTests(private val scheduler: JobScheduler) { @Test fun testCancelDuringRun() { runBlocking { - scheduler.schedule(::LongRunningTask) - launch { scheduler.onEvent.collect { println(it) @@ -86,6 +87,10 @@ abstract class AbstractJobSchedulerTests(private val scheduler: JobScheduler) { } scheduler.queue.start() + + delay(2000L) + + scheduler.schedule(::LongRunningTask) } } From b77fdd27b94122a7429b9aef86c824f698b80739 Mon Sep 17 00:00:00 2001 From: Jan Gaebel Date: Wed, 11 Jan 2023 18:23:31 +0100 Subject: [PATCH 18/28] refactor(job): simplify wiring and optimize cancellation --- settings.gradle.kts | 2 +- .../JobScheduler.kt => job/queue/JobQueue.kt} | 8 +- .../liftric/{persisted => job}/queue/UUID.kt | 3 +- .../queue/JobQueueTests.kt} | 4 +- .../liftric/{persisted => job}/queue/Job.kt | 40 ++-- .../{persisted => job}/queue/JobContext.kt | 3 +- .../kotlin/com/liftric/job/queue/JobEvent.kt | 13 ++ .../{persisted => job}/queue/JobInfo.kt | 2 +- .../kotlin/com/liftric/job/queue/JobQueue.kt | 208 ++++++++++++++++++ .../{persisted => job}/queue/JobRule.kt | 2 +- .../{persisted => job}/queue/JsonStorage.kt | 2 +- .../kotlin/com/liftric/job/queue/Queue.kt | 23 ++ .../liftric/{persisted => job}/queue/Task.kt | 2 +- .../liftric/{persisted => job}/queue/UUID.kt | 2 +- .../queue/rules/DelayRule.kt | 5 +- .../queue/rules/PeriodicRule.kt | 4 +- .../queue/rules/PersistenceRule.kt | 6 +- .../queue/rules/RetryRule.kt | 4 +- .../queue/rules/TimeoutRule.kt | 6 +- .../queue/rules/UniqueRule.kt | 8 +- .../liftric/persisted/queue/JobDelegate.kt | 24 -- .../com/liftric/persisted/queue/JobEvent.kt | 34 --- .../liftric/persisted/queue/JobScheduler.kt | 113 ---------- .../com/liftric/persisted/queue/Queue.kt | 179 --------------- .../queue/JobQueueTests.kt} | 68 +++--- .../{persisted => job}/queue/TestTask.kt | 2 +- .../JobScheduler.kt => job/queue/JobQueue.kt} | 9 +- .../liftric/{persisted => job}/queue/UUID.kt | 2 +- .../queue/JobQueueTests.kt} | 4 +- 29 files changed, 336 insertions(+), 446 deletions(-) rename src/androidMain/kotlin/com/liftric/{persisted/queue/JobScheduler.kt => job/queue/JobQueue.kt} (73%) rename src/androidMain/kotlin/com/liftric/{persisted => job}/queue/UUID.kt (91%) rename src/androidTest/kotlin/com/liftric/{persisted/queue/JobSchedulerTests.kt => job/queue/JobQueueTests.kt} (83%) rename src/commonMain/kotlin/com/liftric/{persisted => job}/queue/Job.kt (58%) rename src/commonMain/kotlin/com/liftric/{persisted => job}/queue/JobContext.kt (78%) create mode 100644 src/commonMain/kotlin/com/liftric/job/queue/JobEvent.kt rename src/commonMain/kotlin/com/liftric/{persisted => job}/queue/JobInfo.kt (88%) create mode 100644 src/commonMain/kotlin/com/liftric/job/queue/JobQueue.kt rename src/commonMain/kotlin/com/liftric/{persisted => job}/queue/JobRule.kt (90%) rename src/commonMain/kotlin/com/liftric/{persisted => job}/queue/JsonStorage.kt (96%) create mode 100644 src/commonMain/kotlin/com/liftric/job/queue/Queue.kt rename src/commonMain/kotlin/com/liftric/{persisted => job}/queue/Task.kt (83%) rename src/commonMain/kotlin/com/liftric/{persisted => job}/queue/UUID.kt (85%) rename src/commonMain/kotlin/com/liftric/{persisted => job}/queue/rules/DelayRule.kt (71%) rename src/commonMain/kotlin/com/liftric/{persisted => job}/queue/rules/PeriodicRule.kt (88%) rename src/commonMain/kotlin/com/liftric/{persisted => job}/queue/rules/PersistenceRule.kt (74%) rename src/commonMain/kotlin/com/liftric/{persisted => job}/queue/rules/RetryRule.kt (94%) rename src/commonMain/kotlin/com/liftric/{persisted => job}/queue/rules/TimeoutRule.kt (73%) rename src/commonMain/kotlin/com/liftric/{persisted => job}/queue/rules/UniqueRule.kt (71%) delete mode 100644 src/commonMain/kotlin/com/liftric/persisted/queue/JobDelegate.kt delete mode 100644 src/commonMain/kotlin/com/liftric/persisted/queue/JobEvent.kt delete mode 100644 src/commonMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt delete mode 100644 src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt rename src/commonTest/kotlin/com/liftric/{persisted/queue/JobSchedulerTests.kt => job/queue/JobQueueTests.kt} (61%) rename src/commonTest/kotlin/com/liftric/{persisted => job}/queue/TestTask.kt (94%) rename src/iosMain/kotlin/com/liftric/{persisted/queue/JobScheduler.kt => job/queue/JobQueue.kt} (67%) rename src/iosMain/kotlin/com/liftric/{persisted => job}/queue/UUID.kt (95%) rename src/iosTest/kotlin/com/liftric/{persisted/queue/JobSchedulerTests.kt => job/queue/JobQueueTests.kt} (72%) diff --git a/settings.gradle.kts b/settings.gradle.kts index bb1a2a6..43f8a79 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,4 +1,4 @@ -rootProject.name = "persisted-queue" +rootProject.name = "job-queue" pluginManagement { repositories { diff --git a/src/androidMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt b/src/androidMain/kotlin/com/liftric/job/queue/JobQueue.kt similarity index 73% rename from src/androidMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt rename to src/androidMain/kotlin/com/liftric/job/queue/JobQueue.kt index fcc2c43..4a72fa0 100644 --- a/src/androidMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt +++ b/src/androidMain/kotlin/com/liftric/job/queue/JobQueue.kt @@ -1,15 +1,15 @@ -package com.liftric.persisted.queue +package com.liftric.job.queue import android.content.Context import com.russhwolf.settings.SharedPreferencesSettings import kotlinx.serialization.modules.SerializersModule -actual class JobScheduler( +actual class JobQueue( context: Context, serializers: SerializersModule = SerializersModule {}, - configuration: Queue.Configuration? = null, + configuration: Queue.Configuration = Queue.DefaultConfiguration, store: JsonStorage = SettingsStorage(SharedPreferencesSettings.Factory(context).create("com.liftric.persisted.queue")) -) : AbstractJobScheduler( +) : AbstractJobQueue( serializers, configuration, store diff --git a/src/androidMain/kotlin/com/liftric/persisted/queue/UUID.kt b/src/androidMain/kotlin/com/liftric/job/queue/UUID.kt similarity index 91% rename from src/androidMain/kotlin/com/liftric/persisted/queue/UUID.kt rename to src/androidMain/kotlin/com/liftric/job/queue/UUID.kt index cb60d91..f813735 100644 --- a/src/androidMain/kotlin/com/liftric/persisted/queue/UUID.kt +++ b/src/androidMain/kotlin/com/liftric/job/queue/UUID.kt @@ -1,7 +1,6 @@ -package com.liftric.persisted.queue +package com.liftric.job.queue import kotlinx.serialization.KSerializer -import kotlinx.serialization.Serializable import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor import kotlinx.serialization.encoding.Decoder diff --git a/src/androidTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt b/src/androidTest/kotlin/com/liftric/job/queue/JobQueueTests.kt similarity index 83% rename from src/androidTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt rename to src/androidTest/kotlin/com/liftric/job/queue/JobQueueTests.kt index 65505f3..cc1776d 100644 --- a/src/androidTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt +++ b/src/androidTest/kotlin/com/liftric/job/queue/JobQueueTests.kt @@ -1,4 +1,4 @@ -package com.liftric.persisted.queue +package com.liftric.job.queue import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry @@ -7,7 +7,7 @@ import kotlinx.serialization.modules.polymorphic import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) -actual class JobSchedulerTests: AbstractJobSchedulerTests(JobScheduler( +actual class JobQueueTests: AbstractJobQueueTests(JobQueue( context = InstrumentationRegistry.getInstrumentation().targetContext, serializers = SerializersModule { polymorphic(Task::class) { diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/Job.kt b/src/commonMain/kotlin/com/liftric/job/queue/Job.kt similarity index 58% rename from src/commonMain/kotlin/com/liftric/persisted/queue/Job.kt rename to src/commonMain/kotlin/com/liftric/job/queue/Job.kt index bdcd105..c029874 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/Job.kt +++ b/src/commonMain/kotlin/com/liftric/job/queue/Job.kt @@ -1,11 +1,15 @@ -package com.liftric.persisted.queue +package com.liftric.job.queue -import kotlinx.coroutines.* +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.datetime.Clock import kotlinx.datetime.Instant import kotlinx.serialization.Serializable import kotlinx.serialization.Transient -import kotlin.coroutines.coroutineContext + +internal class JobDelegate { + val onEvent = MutableSharedFlow(extraBufferCapacity = Int.MAX_VALUE) +} @Serializable data class Job( @@ -15,48 +19,44 @@ data class Job( override val task: Task, override val startTime: Instant ): JobContext { - @Transient var delegate: JobDelegate? = null + @Transient internal var delegate: JobDelegate? = null constructor(task: Task, info: JobInfo) : this (UUIDFactory.create(), info, task, Clock.System.now()) - private var canRepeat: Boolean = false + private var canRepeat: Boolean = true - suspend fun run() { + suspend fun run(): JobEvent { val event = try { info.rules.forEach { it.willRun(this@Job) } - delegate?.broadcast(JobEvent.WillRun(this@Job)) - task.body() JobEvent.DidSucceed(this@Job) + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { canRepeat = task.onRepeat(e) JobEvent.DidFail(this@Job, e) } - try { - delegate?.broadcast(event) - + return try { info.rules.forEach { it.willRemove(this@Job, event) } + + event + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { - delegate?.broadcast(JobEvent.DidFailOnRemove(this@Job, e)) - } finally { - delegate?.exit(this@Job) + JobEvent.DidFailOnRemove(this@Job, e) } } override suspend fun cancel() { - delegate?.cancel(this@Job) + delegate?.onEvent?.emit(JobEvent.DidCancel(this@Job)) } override suspend fun repeat(id: UUID, info: JobInfo, task: Task, startTime: Instant) { if (canRepeat) { - delegate?.repeat(Job(id, info, task, startTime)) + delegate?.onEvent?.emit(JobEvent.ShouldRepeat(Job(id, info, task, startTime))) } } - - override suspend fun broadcast(event: RuleEvent) { - delegate?.broadcast(event) - } } diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/JobContext.kt b/src/commonMain/kotlin/com/liftric/job/queue/JobContext.kt similarity index 78% rename from src/commonMain/kotlin/com/liftric/persisted/queue/JobContext.kt rename to src/commonMain/kotlin/com/liftric/job/queue/JobContext.kt index 6e2ea44..e23a6f2 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/JobContext.kt +++ b/src/commonMain/kotlin/com/liftric/job/queue/JobContext.kt @@ -1,4 +1,4 @@ -package com.liftric.persisted.queue +package com.liftric.job.queue import kotlinx.datetime.Instant @@ -9,5 +9,4 @@ interface JobContext { val startTime: Instant suspend fun cancel() suspend fun repeat(id: UUID = this.id, info: JobInfo = this.info, task: Task = this.task, startTime: Instant = this.startTime) - suspend fun broadcast(event: RuleEvent) } diff --git a/src/commonMain/kotlin/com/liftric/job/queue/JobEvent.kt b/src/commonMain/kotlin/com/liftric/job/queue/JobEvent.kt new file mode 100644 index 0000000..e1dc6ea --- /dev/null +++ b/src/commonMain/kotlin/com/liftric/job/queue/JobEvent.kt @@ -0,0 +1,13 @@ +package com.liftric.job.queue + +sealed class JobEvent { + data class DidSchedule(val job: JobContext): JobEvent() + data class DidScheduleRepeat(val job: JobContext): JobEvent() + data class WillRun(val job: JobContext): JobEvent() + data class DidThrowOnSchedule(val error: Throwable): JobEvent() + data class DidSucceed(val job: JobContext): JobEvent() + data class DidFail(val job: JobContext, val error: Throwable): JobEvent() + data class ShouldRepeat(val job: Job): JobEvent() + data class DidCancel(val job: JobContext): JobEvent() + data class DidFailOnRemove(val job: JobContext, val error: Throwable): JobEvent() +} diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/JobInfo.kt b/src/commonMain/kotlin/com/liftric/job/queue/JobInfo.kt similarity index 88% rename from src/commonMain/kotlin/com/liftric/persisted/queue/JobInfo.kt rename to src/commonMain/kotlin/com/liftric/job/queue/JobInfo.kt index 95bcf63..c19e15c 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/JobInfo.kt +++ b/src/commonMain/kotlin/com/liftric/job/queue/JobInfo.kt @@ -1,4 +1,4 @@ -package com.liftric.persisted.queue +package com.liftric.job.queue import kotlin.time.Duration import kotlinx.serialization.Serializable diff --git a/src/commonMain/kotlin/com/liftric/job/queue/JobQueue.kt b/src/commonMain/kotlin/com/liftric/job/queue/JobQueue.kt new file mode 100644 index 0000000..9563179 --- /dev/null +++ b/src/commonMain/kotlin/com/liftric/job/queue/JobQueue.kt @@ -0,0 +1,208 @@ +package com.liftric.job.queue + +import com.liftric.job.queue.rules.* +import kotlinx.atomicfu.atomic +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withLock +import kotlinx.datetime.Clock +import kotlinx.datetime.serializers.InstantIso8601Serializer +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.contextual +import kotlinx.serialization.modules.plus +import kotlinx.serialization.modules.polymorphic +import kotlin.time.Duration.Companion.seconds + +expect class JobQueue: AbstractJobQueue +abstract class AbstractJobQueue( + serializers: SerializersModule, + final override val configuration: Queue.Configuration, + private val store: JsonStorage +): Queue { + private val module = SerializersModule { + contextual(UUIDSerializer) + contextual(InstantIso8601Serializer) + polymorphic(JobRule::class) { + subclass(DelayRule::class, DelayRule.serializer()) + subclass(PeriodicRule::class, PeriodicRule.serializer()) + subclass(RetryRule::class, RetryRule.serializer()) + subclass(TimeoutRule::class, TimeoutRule.serializer()) + subclass(UniqueRule::class, UniqueRule.serializer()) + subclass(PersistenceRule::class, PersistenceRule.serializer()) + } + } + private val format = Json { serializersModule = module + serializers } + + val onEvent = MutableSharedFlow(extraBufferCapacity = Int.MAX_VALUE) + + /** + * Scheduled jobs + */ + private val running = atomic(mutableMapOf()) + private val queue = atomic(mutableListOf()) + + override val jobs: List + get() = queue.value + + override val numberOfJobs: Int + get() = queue.value.count() + + /** + * Semaphore to limit concurrency + */ + private val lock = Semaphore(configuration.maxConcurrency, 0) + + /** + * Mutex to suspend queue operations during cancellation + */ + private val isCancelling = Mutex() + + private var scheduler: kotlinx.coroutines.Job? = null + + init { + if (configuration.startsAutomatically) { + start() + } + } + + suspend fun schedule(task: () -> Task, configure: JobInfo.() -> JobInfo = { JobInfo() }) { + schedule(task(), configure) + } + + suspend fun schedule(data: Data, task: (Data) -> DataTask, configure: JobInfo.() -> JobInfo = { JobInfo() }) { + schedule(task(data), configure) + } + + suspend fun schedule(task: Task, configure: JobInfo.() -> JobInfo = { JobInfo() }) { + val info = configure(JobInfo()).apply { + rules.forEach { it.mutating(this) } + } + + val job = Job(task, info) + + schedule(job).apply { + onEvent.emit(JobEvent.DidSchedule(job)) + } + } + + private suspend fun schedule(job: Job) = try { + job.info.rules.forEach { + it.willSchedule(this, job) + } + + if (job.info.shouldPersist) { + store.set(job.id.toString(), format.encodeToString(job)) + } + + queue.value = queue.value.plus(listOf(job)).sortedBy { it.startTime }.toMutableList() + } catch (e: Throwable) { + onEvent.emit(JobEvent.DidThrowOnSchedule(e)) + } + + private val delegate = JobDelegate() + + /** + * Starts enqueuing scheduled jobs + */ + fun start() { + if (scheduler != null) return + scheduler = CoroutineScope(Dispatchers.Default).launch { + launch { + delegate.onEvent.collect { event -> + when (event) { + is JobEvent.DidCancel -> { + cancel(event.job.id) + } + is JobEvent.ShouldRepeat -> { + schedule(event.job).apply { + onEvent.emit(JobEvent.DidScheduleRepeat(event.job)) + } + } + else -> onEvent.emit(event) + } + } + } + restore() + while (isActive) { + if (isCancelling.isLocked) continue + if (queue.value.isEmpty()) continue + if (queue.value.first().startTime.minus(Clock.System.now()) > 0.seconds) continue + lock.acquire() + val job = queue.value.removeFirst() + job.delegate = delegate + running.value[job.id] = configuration.scope.launch { + try { + withTimeout(job.info.timeout) { + onEvent.emit(JobEvent.WillRun(job)) + val result = job.run() + onEvent.emit(result) + } + } catch (e: CancellationException) { + onEvent.emit(JobEvent.DidCancel(job)) + } finally { + if (job.info.shouldPersist) { + store.remove(job.id.toString()) + } + running.value[job.id]?.cancel() + running.value.remove(job.id) + lock.release() + } + } + } + } + } + + /** + * Stops enqueuing scheduled jobs + */ + fun stop() { + scheduler?.cancel() + scheduler = null + } + + /** + * Removes all scheduled jobs + */ + suspend fun clear() { + clear(true) + } + + internal suspend fun clear(clearStore: Boolean = true) { + isCancelling.withLock { + queue.value.clear() + running.value.clear() + configuration.scope.coroutineContext.cancelChildren() + if (clearStore) { store.clear() } + } + } + + /** + * Cancels jobs + * @param id Unique identifier of the job + */ + suspend fun cancel(id: UUID) { + isCancelling.withLock { + queue.value.firstOrNull { it.id == id }?.let { job -> + queue.value.remove(job) + onEvent.emit(JobEvent.DidCancel(job)) + } ?: running.value[id]?.cancel() + } + } + + /** + * Restores all persisted jobs. Ensures job not already in queue. + */ + internal suspend fun restore() { + store.keys.forEach { key -> + val job: Job = format.decodeFromString(store.get(key)) + if (queue.value.none { it.id == job.id }) { + schedule(job) + } + } + } +} diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/JobRule.kt b/src/commonMain/kotlin/com/liftric/job/queue/JobRule.kt similarity index 90% rename from src/commonMain/kotlin/com/liftric/persisted/queue/JobRule.kt rename to src/commonMain/kotlin/com/liftric/job/queue/JobRule.kt index b1fbafe..5d512ab 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/JobRule.kt +++ b/src/commonMain/kotlin/com/liftric/job/queue/JobRule.kt @@ -1,4 +1,4 @@ -package com.liftric.persisted.queue +package com.liftric.job.queue import kotlinx.serialization.Serializable diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/JsonStorage.kt b/src/commonMain/kotlin/com/liftric/job/queue/JsonStorage.kt similarity index 96% rename from src/commonMain/kotlin/com/liftric/persisted/queue/JsonStorage.kt rename to src/commonMain/kotlin/com/liftric/job/queue/JsonStorage.kt index bc322d7..727f00c 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/JsonStorage.kt +++ b/src/commonMain/kotlin/com/liftric/job/queue/JsonStorage.kt @@ -1,4 +1,4 @@ -package com.liftric.persisted.queue +package com.liftric.job.queue import com.russhwolf.settings.Settings import com.russhwolf.settings.set diff --git a/src/commonMain/kotlin/com/liftric/job/queue/Queue.kt b/src/commonMain/kotlin/com/liftric/job/queue/Queue.kt new file mode 100644 index 0000000..2bf4b15 --- /dev/null +++ b/src/commonMain/kotlin/com/liftric/job/queue/Queue.kt @@ -0,0 +1,23 @@ +package com.liftric.job.queue + +import kotlinx.coroutines.* + +interface Queue { + val jobs: List + val numberOfJobs: Int + val configuration: Configuration + + data class Configuration( + val scope: CoroutineScope, + val maxConcurrency: Int, + val startsAutomatically: Boolean + ) + + companion object { + val DefaultConfiguration = Configuration( + scope = CoroutineScope(Dispatchers.Default), + maxConcurrency = 1, + startsAutomatically = false + ) + } +} diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/Task.kt b/src/commonMain/kotlin/com/liftric/job/queue/Task.kt similarity index 83% rename from src/commonMain/kotlin/com/liftric/persisted/queue/Task.kt rename to src/commonMain/kotlin/com/liftric/job/queue/Task.kt index 4a58f0f..372ea0a 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/Task.kt +++ b/src/commonMain/kotlin/com/liftric/job/queue/Task.kt @@ -1,4 +1,4 @@ -package com.liftric.persisted.queue +package com.liftric.job.queue interface Task { @Throws(Throwable::class) diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/UUID.kt b/src/commonMain/kotlin/com/liftric/job/queue/UUID.kt similarity index 85% rename from src/commonMain/kotlin/com/liftric/persisted/queue/UUID.kt rename to src/commonMain/kotlin/com/liftric/job/queue/UUID.kt index 3517490..4422756 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/UUID.kt +++ b/src/commonMain/kotlin/com/liftric/job/queue/UUID.kt @@ -1,4 +1,4 @@ -package com.liftric.persisted.queue +package com.liftric.job.queue import kotlinx.serialization.KSerializer diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/rules/DelayRule.kt b/src/commonMain/kotlin/com/liftric/job/queue/rules/DelayRule.kt similarity index 71% rename from src/commonMain/kotlin/com/liftric/persisted/queue/rules/DelayRule.kt rename to src/commonMain/kotlin/com/liftric/job/queue/rules/DelayRule.kt index 4728f8e..a200305 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/rules/DelayRule.kt +++ b/src/commonMain/kotlin/com/liftric/job/queue/rules/DelayRule.kt @@ -1,6 +1,6 @@ -package com.liftric.persisted.queue.rules +package com.liftric.job.queue.rules -import com.liftric.persisted.queue.* +import com.liftric.job.queue.* import kotlinx.coroutines.delay import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -9,7 +9,6 @@ import kotlinx.serialization.Serializable @Serializable data class DelayRule(val duration: Duration = 0.seconds): JobRule() { override suspend fun willRun(context: JobContext) { - context.broadcast(RuleEvent.OnRun(this, "Delaying job=${context.id} by duration=$duration")) delay(duration) } } diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/rules/PeriodicRule.kt b/src/commonMain/kotlin/com/liftric/job/queue/rules/PeriodicRule.kt similarity index 88% rename from src/commonMain/kotlin/com/liftric/persisted/queue/rules/PeriodicRule.kt rename to src/commonMain/kotlin/com/liftric/job/queue/rules/PeriodicRule.kt index 809c864..8dc68d1 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/rules/PeriodicRule.kt +++ b/src/commonMain/kotlin/com/liftric/job/queue/rules/PeriodicRule.kt @@ -1,6 +1,6 @@ -package com.liftric.persisted.queue.rules +package com.liftric.job.queue.rules -import com.liftric.persisted.queue.* +import com.liftric.job.queue.* import kotlinx.datetime.Clock import kotlinx.serialization.Serializable import kotlin.time.Duration diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/rules/PersistenceRule.kt b/src/commonMain/kotlin/com/liftric/job/queue/rules/PersistenceRule.kt similarity index 74% rename from src/commonMain/kotlin/com/liftric/persisted/queue/rules/PersistenceRule.kt rename to src/commonMain/kotlin/com/liftric/job/queue/rules/PersistenceRule.kt index 3030829..186a777 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/rules/PersistenceRule.kt +++ b/src/commonMain/kotlin/com/liftric/job/queue/rules/PersistenceRule.kt @@ -1,7 +1,7 @@ -package com.liftric.persisted.queue.rules +package com.liftric.job.queue.rules -import com.liftric.persisted.queue.JobInfo -import com.liftric.persisted.queue.JobRule +import com.liftric.job.queue.JobInfo +import com.liftric.job.queue.JobRule import kotlinx.serialization.Serializable @Serializable diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/rules/RetryRule.kt b/src/commonMain/kotlin/com/liftric/job/queue/rules/RetryRule.kt similarity index 94% rename from src/commonMain/kotlin/com/liftric/persisted/queue/rules/RetryRule.kt rename to src/commonMain/kotlin/com/liftric/job/queue/rules/RetryRule.kt index a7c159c..8396c44 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/rules/RetryRule.kt +++ b/src/commonMain/kotlin/com/liftric/job/queue/rules/RetryRule.kt @@ -1,6 +1,6 @@ -package com.liftric.persisted.queue.rules +package com.liftric.job.queue.rules -import com.liftric.persisted.queue.* +import com.liftric.job.queue.* import kotlinx.datetime.Clock import kotlinx.serialization.Serializable import kotlin.time.Duration diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/rules/TimeoutRule.kt b/src/commonMain/kotlin/com/liftric/job/queue/rules/TimeoutRule.kt similarity index 73% rename from src/commonMain/kotlin/com/liftric/persisted/queue/rules/TimeoutRule.kt rename to src/commonMain/kotlin/com/liftric/job/queue/rules/TimeoutRule.kt index e1943f2..96a4bf0 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/rules/TimeoutRule.kt +++ b/src/commonMain/kotlin/com/liftric/job/queue/rules/TimeoutRule.kt @@ -1,7 +1,7 @@ -package com.liftric.persisted.queue.rules +package com.liftric.job.queue.rules -import com.liftric.persisted.queue.JobInfo -import com.liftric.persisted.queue.JobRule +import com.liftric.job.queue.JobInfo +import com.liftric.job.queue.JobRule import kotlin.time.Duration import kotlinx.serialization.Serializable diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/rules/UniqueRule.kt b/src/commonMain/kotlin/com/liftric/job/queue/rules/UniqueRule.kt similarity index 71% rename from src/commonMain/kotlin/com/liftric/persisted/queue/rules/UniqueRule.kt rename to src/commonMain/kotlin/com/liftric/job/queue/rules/UniqueRule.kt index 426702c..31966ec 100644 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/rules/UniqueRule.kt +++ b/src/commonMain/kotlin/com/liftric/job/queue/rules/UniqueRule.kt @@ -1,6 +1,6 @@ -package com.liftric.persisted.queue.rules +package com.liftric.job.queue.rules -import com.liftric.persisted.queue.* +import com.liftric.job.queue.* import kotlinx.serialization.Serializable @Serializable @@ -12,10 +12,10 @@ data class UniqueRule(private val tag: String? = null): JobRule() { override suspend fun willSchedule(queue: Queue, context: JobContext) { for (item in queue.jobs) { if (item.info.tag == tag) { - throw Error("Job with tag=${item.info.tag} already exists") + throw Throwable("Job with tag=${item.info.tag} already exists") } if (item.id == context.id) { - throw Error("Job with id=${item.id} already exists") + throw Throwable("Job with id=${item.id} already exists") } } } diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/JobDelegate.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/JobDelegate.kt deleted file mode 100644 index 310906d..0000000 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/JobDelegate.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.liftric.persisted.queue - -import kotlinx.coroutines.flow.MutableSharedFlow - -class JobDelegate { - val onEvent = MutableSharedFlow(extraBufferCapacity = Int.MAX_VALUE) - - suspend fun broadcast(event: JobEvent) { - onEvent.emit(event) - } - - suspend fun cancel(job: Job) { - onEvent.emit(JobEvent.DidCancel(job)) - } - - suspend fun exit(job: Job) { - onEvent.emit(JobEvent.DidExit(job)) - } - - suspend fun repeat(job: Job) { - onEvent.emit(JobEvent.ShouldRepeat(job)) - } -} - diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/JobEvent.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/JobEvent.kt deleted file mode 100644 index dbfddb2..0000000 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/JobEvent.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.liftric.persisted.queue - -sealed class JobEvent { - data class DidSchedule(val job: JobContext): JobEvent() - data class DidScheduleRepeat(val job: JobContext): JobEvent() - data class WillRun(val job: JobContext): JobEvent() - data class DidThrowOnRepeat(val error: Throwable): JobEvent() - data class DidThrowOnSchedule(val error: Throwable): JobEvent() - data class DidSucceed(val job: JobContext): JobEvent() - data class DidFail(val job: JobContext, val error: Throwable): JobEvent() - data class DidExit(val job: JobContext): JobEvent() - data class ShouldRepeat(val job: Job): JobEvent() - data class DidCancel(val job: JobContext): JobEvent() - data class DidFailOnRemove(val job: JobContext, val error: Throwable): JobEvent() - data class NotAllowedToRepeat(val job: JobContext): JobEvent() -} - -sealed class RuleEvent(open val rule: String, open val message: String): JobEvent() { - data class OnMutate(override val rule: String, override val message: String): RuleEvent(rule, message) { - constructor(rule: JobRule, message: String) : this(rule::class.simpleName!!, message) - } - - data class OnSchedule(override val rule: String, override val message: String): RuleEvent(rule, message) { - constructor(rule: JobRule, message: String) : this(rule::class.simpleName!!, message) - } - - data class OnRun(override val rule: String, override val message: String): RuleEvent(rule, message) { - constructor(rule: JobRule, message: String) : this(rule::class.simpleName!!, message) - } - - data class OnRemove(override val rule: String, override val message: String): RuleEvent(rule, message) { - constructor(rule: JobRule, message: String) : this(rule::class.simpleName!!, message) - } -} diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt deleted file mode 100644 index 5c63e84..0000000 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt +++ /dev/null @@ -1,113 +0,0 @@ -package com.liftric.persisted.queue - -import com.liftric.persisted.queue.rules.* -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.* -import kotlinx.coroutines.launch -import kotlinx.datetime.serializers.InstantIso8601Serializer -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import kotlinx.serialization.modules.SerializersModule -import kotlinx.serialization.modules.contextual -import kotlinx.serialization.modules.plus -import kotlinx.serialization.modules.polymorphic - -expect class JobScheduler: AbstractJobScheduler -abstract class AbstractJobScheduler( - serializers: SerializersModule, - configuration: Queue.Configuration?, - private val store: JsonStorage -) { - private val delegate = JobDelegate() - private val module = SerializersModule { - contextual(UUIDSerializer) - contextual(InstantIso8601Serializer) - polymorphic(JobRule::class) { - subclass(DelayRule::class, DelayRule.serializer()) - subclass(PeriodicRule::class, PeriodicRule.serializer()) - subclass(RetryRule::class, RetryRule.serializer()) - subclass(TimeoutRule::class, TimeoutRule.serializer()) - subclass(UniqueRule::class, UniqueRule.serializer()) - subclass(PersistenceRule::class, PersistenceRule.serializer()) - } - } - private val format = Json { serializersModule = module + serializers } - - val onEvent = MutableSharedFlow(extraBufferCapacity = Int.MAX_VALUE) - val queue = JobQueue( - store = store, - format = format, - configuration = configuration ?: Queue.DefaultConfiguration, - onRestore = { job -> job.apply { job.delegate = delegate } } - ) - - init { - CoroutineScope(Dispatchers.Default).launch { - delegate.onEvent.collect { event -> - when (event) { - is JobEvent.DidCancel -> { - if (event.job.info.shouldPersist) { - store.remove(event.job.id.toString()) - } - queue.cancel(event.job.id) - onEvent.emit(event) - } - is JobEvent.DidExit -> { - if (event.job.info.shouldPersist) { - store.remove(event.job.id.toString()) - } - } - is JobEvent.ShouldRepeat -> { - repeat(event.job) - } - else -> onEvent.emit(event) - } - } - } - } - - suspend fun schedule(task: () -> Task, configure: JobInfo.() -> JobInfo = { JobInfo() }) { - schedule(task(), configure) - } - - suspend fun schedule(data: Data, task: (Data) -> DataTask, configure: JobInfo.() -> JobInfo = { JobInfo() }) { - schedule(task(data), configure) - } - - suspend fun schedule(task: Task, configure: JobInfo.() -> JobInfo = { JobInfo() }) = try { - val info = configure(JobInfo()).apply { - rules.forEach { it.mutating(this) } - } - - val job = Job(task, info) - - schedule(job).apply { - onEvent.emit(JobEvent.DidSchedule(job)) - } - } catch (error: Error) { - onEvent.emit(JobEvent.DidThrowOnSchedule(error)) - } - - private suspend fun repeat(job: Job) = try { - schedule(job).apply { - onEvent.emit(JobEvent.DidScheduleRepeat(job)) - } - } catch (error: Error) { - onEvent.emit(JobEvent.DidThrowOnRepeat(error)) - } - - private suspend fun schedule(job: Job) { - job.delegate = delegate - - job.info.rules.forEach { - it.willSchedule(queue, job) - } - - if (job.info.shouldPersist) { - store.set(job.id.toString(), format.encodeToString(job)) - } - - queue.add(job) - } -} diff --git a/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt b/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt deleted file mode 100644 index 9e714c1..0000000 --- a/src/commonMain/kotlin/com/liftric/persisted/queue/Queue.kt +++ /dev/null @@ -1,179 +0,0 @@ -package com.liftric.persisted.queue - -import kotlinx.atomicfu.atomic -import kotlinx.coroutines.* -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.sync.withPermit -import kotlinx.datetime.Clock -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.json.Json -import kotlin.time.Duration.Companion.seconds - -interface Queue { - val jobs: List - val runningJobs: List - val configuration: Configuration - - data class Configuration( - val scope: CoroutineScope, - val maxConcurrency: Int, - val startsAutomatically: Boolean - ) - - companion object { - val DefaultConfiguration = Configuration( - scope = CoroutineScope(Dispatchers.Default), - maxConcurrency = 1, - startsAutomatically = false - ) - } -} - -/** - * Handles job enqueuing and cancelling. - * @param store Storage to restore jobs - * @param format Serializer to decode stored jobs - * @param configuration Queue configuration - * @param onRestore Callback to mutate restored jobs - */ -class JobQueue( - private val store: JsonStorage, - private val format: Json, - override val configuration: Queue.Configuration, - private val onRestore: (Job) -> Job -): Queue { - /** - * Scheduled jobs - */ - private val scheduledJobs = atomic(mutableListOf()) - - /** - * Running jobs - */ - private val _runningJobs = atomic(mutableListOf()) - - /** - * Semaphore to limit concurrency - */ - private val lock = Semaphore(configuration.maxConcurrency, 0) - private val isCancelling = Mutex() - - private var cancellable: kotlinx.coroutines.Job? = null - - override val jobs: List - get() = scheduledJobs.value - override val runningJobs: List - get() = _runningJobs.value - - init { - restore() - - if (configuration.startsAutomatically) { - start() - } - } - - /** - * Starts enqueuing scheduled jobs - */ - fun start() { - if (cancellable != null) return - cancellable = CoroutineScope(Dispatchers.Default).launch { - while (isActive) { - lock.withPermit { - if (isCancelling.isLocked) return@withPermit - if (scheduledJobs.value.isEmpty()) return@withPermit - if (scheduledJobs.value.first().startTime.minus(Clock.System.now()) > 0.seconds) return@withPermit - val job = scheduledJobs.value.removeFirst() - _runningJobs.value.add(job) - configuration.scope.launch { - withTimeout(job.info.timeout) { - job.run() - } - _runningJobs.value.remove(job) - } - } - } - } - } - - /** - * Stops enqueuing scheduled jobs - */ - fun stop() { - cancellable?.cancel() - cancellable = null - } - - /** - * Removes all scheduled jobs - * @param cancelJobs Cancels running jobs - * @param clearStore Removes persisted jobs - */ - suspend fun clear(cancelJobs: Boolean = true, clearStore: Boolean = true) { - isCancelling.withLock { - scheduledJobs.value.clear() - if (cancelJobs) { - _runningJobs.value.clear() - configuration.scope.coroutineContext.cancelChildren() - } - if (clearStore) { - store.clear() - } - } - } - - /** - * Cancels jobs - * @param id Unique identifier of the job - */ - suspend fun cancel(id: UUID) { - isCancelling.withLock { - scheduledJobs.value.firstOrNull { it.id == id }?.let { job -> - job.cancel() - scheduledJobs.value.remove(job) - } ?: _runningJobs.value.firstOrNull { it.id == id }?.let { job -> - job.cancel() - _runningJobs.value.remove(job) - } - } - } - - /** - * Cancels job - * @param tag User defined tag of the job - */ - suspend fun cancel(tag: String) { - isCancelling.withLock { - scheduledJobs.value.firstOrNull { it.info.tag == tag }?.let { job -> - job.cancel() - scheduledJobs.value.remove(job) - } ?: _runningJobs.value.firstOrNull { it.info.tag == tag }?.let { job -> - job.cancel() - _runningJobs.value.remove(job) - } - } - } - - /** - * Enqueues job and sorts queue based on start time - * @param job Job to enqueue - */ - internal fun add(job: Job) { - scheduledJobs.value = scheduledJobs.value.plus(listOf(job)).sortedBy { it.startTime }.toMutableList() - } - - /** - * Restores all persisted jobs. Ensures job not already in queue. - */ - internal fun restore() { - store.keys.forEach { key -> - val job: Job = format.decodeFromString(store.get(key)) - if (jobs.plus(this.runningJobs).none { it.id == job.id }) { - add(onRestore(job)) - } - } - } -} diff --git a/src/commonTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt b/src/commonTest/kotlin/com/liftric/job/queue/JobQueueTests.kt similarity index 61% rename from src/commonTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt rename to src/commonTest/kotlin/com/liftric/job/queue/JobQueueTests.kt index 685f45e..2e75893 100644 --- a/src/commonTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt +++ b/src/commonTest/kotlin/com/liftric/job/queue/JobQueueTests.kt @@ -1,16 +1,16 @@ -package com.liftric.persisted.queue +package com.liftric.job.queue -import com.liftric.persisted.queue.rules.* +import com.liftric.job.queue.rules.* import kotlinx.coroutines.* import kotlin.test.* import kotlin.time.Duration.Companion.seconds -expect class JobSchedulerTests: AbstractJobSchedulerTests -abstract class AbstractJobSchedulerTests(private val scheduler: JobScheduler) { +expect class JobQueueTests: AbstractJobQueueTests +abstract class AbstractJobQueueTests(private val queue: JobQueue) { @AfterTest fun tearDown() = runBlocking { - scheduler.queue.stop() - scheduler.queue.clear() + queue.stop() + queue.clear() } @Test @@ -18,27 +18,27 @@ abstract class AbstractJobSchedulerTests(private val scheduler: JobScheduler) { runBlocking { val id = UUIDFactory.create().toString() val job = async { - scheduler.onEvent.collect { + queue.onEvent.collect { println(it) } } - scheduler.schedule(TestData(id), ::TestTask) { + queue.schedule(TestData(id), ::TestTask) { delay(1.seconds) unique(id) } - scheduler.schedule(TestTask(TestData(id))) { + queue.schedule(TestTask(TestData(id))) { unique(id) } - assertEquals(1, scheduler.queue.jobs.count()) + assertEquals(1, queue.numberOfJobs) - scheduler.queue.start() + queue.start() delay(2000L) - assertEquals(0, scheduler.queue.jobs.count()) + assertEquals(0, queue.numberOfJobs) job.cancel() @@ -49,7 +49,7 @@ abstract class AbstractJobSchedulerTests(private val scheduler: JobScheduler) { fun testRetry() = runBlocking { var count = 0 val job = launch { - scheduler.onEvent.collect { + queue.onEvent.collect { println(it) if (it is JobEvent.DidScheduleRepeat) { count += 1 @@ -59,11 +59,11 @@ abstract class AbstractJobSchedulerTests(private val scheduler: JobScheduler) { delay(1000L) - scheduler.schedule(TestErrorTask()) { + queue.schedule(TestErrorTask()) { retry(RetryLimit.Limited(3), delay = 1.seconds) } - scheduler.queue.start() + queue.start() delay(15000L) job.cancel() assertEquals(3, count) @@ -73,24 +73,24 @@ abstract class AbstractJobSchedulerTests(private val scheduler: JobScheduler) { fun testCancelDuringRun() { runBlocking { launch { - scheduler.onEvent.collect { + queue.onEvent.collect { println(it) if (it is JobEvent.DidSucceed || it is JobEvent.DidFail) fail("Continued after run") if (it is JobEvent.WillRun) { - scheduler.queue.cancel(it.job.id) + queue.cancel(it.job.id) } if (it is JobEvent.DidCancel) { - assertTrue(scheduler.queue.jobs.isEmpty()) + assertTrue(queue.numberOfJobs == 0) cancel() } } } - scheduler.queue.start() + queue.start() delay(2000L) - scheduler.schedule(::LongRunningTask) + queue.schedule(::LongRunningTask) } } @@ -100,14 +100,14 @@ abstract class AbstractJobSchedulerTests(private val scheduler: JobScheduler) { val completable = CompletableDeferred() launch { - scheduler.onEvent.collect { + queue.onEvent.collect { println(it) if (it is JobEvent.DidSucceed || it is JobEvent.DidFail) fail("Continued after run") if (it is JobEvent.DidSchedule) { completable.complete(it.job.id) } if (it is JobEvent.DidCancel) { - assertTrue(scheduler.queue.jobs.isEmpty()) + assertTrue(queue.numberOfJobs == 0) cancel() } } @@ -115,11 +115,11 @@ abstract class AbstractJobSchedulerTests(private val scheduler: JobScheduler) { delay(1000L) - scheduler.schedule(::LongRunningTask) { + queue.schedule(::LongRunningTask) { delay(2.seconds) } - scheduler.queue.cancel(completable.await()) + queue.cancel(completable.await()) } } @@ -127,11 +127,11 @@ abstract class AbstractJobSchedulerTests(private val scheduler: JobScheduler) { fun testCancelByIdAfterEnqueue() { runBlocking { launch { - scheduler.onEvent.collect { + queue.onEvent.collect { println(it) if (it is JobEvent.DidSchedule) { delay(3000L) - scheduler.queue.cancel(it.job.id) + queue.cancel(it.job.id) } if (it is JobEvent.DidCancel) { cancel() @@ -141,9 +141,9 @@ abstract class AbstractJobSchedulerTests(private val scheduler: JobScheduler) { delay(1000L) - scheduler.queue.start() + queue.start() - scheduler.schedule(::LongRunningTask) { + queue.schedule(::LongRunningTask) { delay(10.seconds) } } @@ -151,18 +151,18 @@ abstract class AbstractJobSchedulerTests(private val scheduler: JobScheduler) { @Test fun testPersist() = runBlocking { - scheduler.schedule(TestData(UUIDFactory.create().toString()), ::TestTask) { + queue.schedule(TestData(UUIDFactory.create().toString()), ::TestTask) { persist() } - assertEquals(1, scheduler.queue.jobs.count()) + assertEquals(1, queue.numberOfJobs) - scheduler.queue.clear(clearStore = false) + queue.clear(clearStore = false) - assertEquals(0, scheduler.queue.jobs.count()) + assertEquals(0, queue.numberOfJobs) - scheduler.queue.restore() + queue.restore() - assertEquals(1, scheduler.queue.jobs.count()) + assertEquals(1, queue.numberOfJobs) } } diff --git a/src/commonTest/kotlin/com/liftric/persisted/queue/TestTask.kt b/src/commonTest/kotlin/com/liftric/job/queue/TestTask.kt similarity index 94% rename from src/commonTest/kotlin/com/liftric/persisted/queue/TestTask.kt rename to src/commonTest/kotlin/com/liftric/job/queue/TestTask.kt index d423ea9..081b7fa 100644 --- a/src/commonTest/kotlin/com/liftric/persisted/queue/TestTask.kt +++ b/src/commonTest/kotlin/com/liftric/job/queue/TestTask.kt @@ -1,4 +1,4 @@ -package com.liftric.persisted.queue +package com.liftric.job.queue import kotlinx.coroutines.delay import kotlinx.serialization.Serializable diff --git a/src/iosMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt b/src/iosMain/kotlin/com/liftric/job/queue/JobQueue.kt similarity index 67% rename from src/iosMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt rename to src/iosMain/kotlin/com/liftric/job/queue/JobQueue.kt index 921e407..ab1aa64 100644 --- a/src/iosMain/kotlin/com/liftric/persisted/queue/JobScheduler.kt +++ b/src/iosMain/kotlin/com/liftric/job/queue/JobQueue.kt @@ -1,15 +1,14 @@ -package com.liftric.persisted.queue +package com.liftric.job.queue import com.russhwolf.settings.NSUserDefaultsSettings -import com.russhwolf.settings.Settings import kotlinx.serialization.modules.SerializersModule import platform.Foundation.NSUserDefaults -actual class JobScheduler( +actual class JobQueue( serializers: SerializersModule = SerializersModule {}, - configuration: Queue.Configuration? = null, + configuration: Queue.Configuration = Queue.DefaultConfiguration, store: JsonStorage = SettingsStorage(NSUserDefaultsSettings(NSUserDefaults("com.liftric.persisted.queue"))) -) : AbstractJobScheduler( +) : AbstractJobQueue( serializers, configuration, store diff --git a/src/iosMain/kotlin/com/liftric/persisted/queue/UUID.kt b/src/iosMain/kotlin/com/liftric/job/queue/UUID.kt similarity index 95% rename from src/iosMain/kotlin/com/liftric/persisted/queue/UUID.kt rename to src/iosMain/kotlin/com/liftric/job/queue/UUID.kt index e66b59d..a1f43a6 100644 --- a/src/iosMain/kotlin/com/liftric/persisted/queue/UUID.kt +++ b/src/iosMain/kotlin/com/liftric/job/queue/UUID.kt @@ -1,4 +1,4 @@ -package com.liftric.persisted.queue +package com.liftric.job.queue import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.PrimitiveKind diff --git a/src/iosTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt b/src/iosTest/kotlin/com/liftric/job/queue/JobQueueTests.kt similarity index 72% rename from src/iosTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt rename to src/iosTest/kotlin/com/liftric/job/queue/JobQueueTests.kt index f0cb02f..0891133 100644 --- a/src/iosTest/kotlin/com/liftric/persisted/queue/JobSchedulerTests.kt +++ b/src/iosTest/kotlin/com/liftric/job/queue/JobQueueTests.kt @@ -1,9 +1,9 @@ -package com.liftric.persisted.queue +package com.liftric.job.queue import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.polymorphic -actual class JobSchedulerTests: AbstractJobSchedulerTests(JobScheduler( +actual class JobQueueTests: AbstractJobQueueTests(JobQueue( serializers = SerializersModule { polymorphic(Task::class) { subclass(TestTask::class, TestTask.serializer()) From 835b1a20a34fb200cde5d2140e61fd2150f5f2f1 Mon Sep 17 00:00:00 2001 From: Jan Gaebel Date: Wed, 11 Jan 2023 18:49:42 +0100 Subject: [PATCH 19/28] fix(queue): obj-c class not supported as hashmap key --- .../kotlin/com/liftric/job/queue/JobQueue.kt | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/src/commonMain/kotlin/com/liftric/job/queue/JobQueue.kt b/src/commonMain/kotlin/com/liftric/job/queue/JobQueue.kt index 9563179..3e67ca9 100644 --- a/src/commonMain/kotlin/com/liftric/job/queue/JobQueue.kt +++ b/src/commonMain/kotlin/com/liftric/job/queue/JobQueue.kt @@ -24,6 +24,13 @@ abstract class AbstractJobQueue( final override val configuration: Queue.Configuration, private val store: JsonStorage ): Queue { + val onEvent = MutableSharedFlow(extraBufferCapacity = Int.MAX_VALUE) + + override val jobs: List + get() = queue.value + override val numberOfJobs: Int + get() = queue.value.count() + private val module = SerializersModule { contextual(UUIDSerializer) contextual(InstantIso8601Serializer) @@ -38,19 +45,15 @@ abstract class AbstractJobQueue( } private val format = Json { serializersModule = module + serializers } - val onEvent = MutableSharedFlow(extraBufferCapacity = Int.MAX_VALUE) - /** * Scheduled jobs */ - private val running = atomic(mutableMapOf()) private val queue = atomic(mutableListOf()) - override val jobs: List - get() = queue.value - - override val numberOfJobs: Int - get() = queue.value.count() + /** + * Reference to the running jobs + */ + private val running = atomic(mutableMapOf()) /** * Semaphore to limit concurrency @@ -135,7 +138,7 @@ abstract class AbstractJobQueue( lock.acquire() val job = queue.value.removeFirst() job.delegate = delegate - running.value[job.id] = configuration.scope.launch { + running.value[job.id.toString()] = configuration.scope.launch { try { withTimeout(job.info.timeout) { onEvent.emit(JobEvent.WillRun(job)) @@ -148,8 +151,8 @@ abstract class AbstractJobQueue( if (job.info.shouldPersist) { store.remove(job.id.toString()) } - running.value[job.id]?.cancel() - running.value.remove(job.id) + running.value[job.id.toString()]?.cancel() + running.value.remove(job.id.toString()) lock.release() } } @@ -190,7 +193,7 @@ abstract class AbstractJobQueue( queue.value.firstOrNull { it.id == id }?.let { job -> queue.value.remove(job) onEvent.emit(JobEvent.DidCancel(job)) - } ?: running.value[id]?.cancel() + } ?: running.value[id.toString()]?.cancel() } } From 01804a62dfce08c76f50e1ae3f5ff7ecf363e8a1 Mon Sep 17 00:00:00 2001 From: Jan Gaebel Date: Wed, 11 Jan 2023 19:02:27 +0100 Subject: [PATCH 20/28] Revert "fix(queue): obj-c class not supported as hashmap key" This reverts commit 835b1a20a34fb200cde5d2140e61fd2150f5f2f1. --- .../kotlin/com/liftric/job/queue/JobQueue.kt | 27 +++++++++---------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/src/commonMain/kotlin/com/liftric/job/queue/JobQueue.kt b/src/commonMain/kotlin/com/liftric/job/queue/JobQueue.kt index 3e67ca9..9563179 100644 --- a/src/commonMain/kotlin/com/liftric/job/queue/JobQueue.kt +++ b/src/commonMain/kotlin/com/liftric/job/queue/JobQueue.kt @@ -24,13 +24,6 @@ abstract class AbstractJobQueue( final override val configuration: Queue.Configuration, private val store: JsonStorage ): Queue { - val onEvent = MutableSharedFlow(extraBufferCapacity = Int.MAX_VALUE) - - override val jobs: List - get() = queue.value - override val numberOfJobs: Int - get() = queue.value.count() - private val module = SerializersModule { contextual(UUIDSerializer) contextual(InstantIso8601Serializer) @@ -45,15 +38,19 @@ abstract class AbstractJobQueue( } private val format = Json { serializersModule = module + serializers } + val onEvent = MutableSharedFlow(extraBufferCapacity = Int.MAX_VALUE) + /** * Scheduled jobs */ + private val running = atomic(mutableMapOf()) private val queue = atomic(mutableListOf()) - /** - * Reference to the running jobs - */ - private val running = atomic(mutableMapOf()) + override val jobs: List + get() = queue.value + + override val numberOfJobs: Int + get() = queue.value.count() /** * Semaphore to limit concurrency @@ -138,7 +135,7 @@ abstract class AbstractJobQueue( lock.acquire() val job = queue.value.removeFirst() job.delegate = delegate - running.value[job.id.toString()] = configuration.scope.launch { + running.value[job.id] = configuration.scope.launch { try { withTimeout(job.info.timeout) { onEvent.emit(JobEvent.WillRun(job)) @@ -151,8 +148,8 @@ abstract class AbstractJobQueue( if (job.info.shouldPersist) { store.remove(job.id.toString()) } - running.value[job.id.toString()]?.cancel() - running.value.remove(job.id.toString()) + running.value[job.id]?.cancel() + running.value.remove(job.id) lock.release() } } @@ -193,7 +190,7 @@ abstract class AbstractJobQueue( queue.value.firstOrNull { it.id == id }?.let { job -> queue.value.remove(job) onEvent.emit(JobEvent.DidCancel(job)) - } ?: running.value[id.toString()]?.cancel() + } ?: running.value[id]?.cancel() } } From 705aec70d2557589e4a36bff020a73d237e5eefc Mon Sep 17 00:00:00 2001 From: Jan Gaebel Date: Wed, 11 Jan 2023 19:03:02 +0100 Subject: [PATCH 21/28] fix(serializer): contextual uuid serializer not supported on ios --- gradle.properties | 2 -- src/commonMain/kotlin/com/liftric/job/queue/JobQueue.kt | 1 - 2 files changed, 3 deletions(-) diff --git a/gradle.properties b/gradle.properties index c6d0049..2170f96 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,8 +2,6 @@ android.useAndroidX=true org.gradle.parallel=true org.gradle.jvmargs=-Xmx4096m org.gradle.vfs.watch=true -kotlin.native.enableDependencyPropagation=false -kotlin.mpp.enableGranularSourceSetsMetadata=true kotlin.mpp.enableCompatibilityMetadataVariant=true kotlin.mpp.androidSourceSetLayoutVersion1.nowarn=true kotlin.incremental=true diff --git a/src/commonMain/kotlin/com/liftric/job/queue/JobQueue.kt b/src/commonMain/kotlin/com/liftric/job/queue/JobQueue.kt index 9563179..4eba158 100644 --- a/src/commonMain/kotlin/com/liftric/job/queue/JobQueue.kt +++ b/src/commonMain/kotlin/com/liftric/job/queue/JobQueue.kt @@ -25,7 +25,6 @@ abstract class AbstractJobQueue( private val store: JsonStorage ): Queue { private val module = SerializersModule { - contextual(UUIDSerializer) contextual(InstantIso8601Serializer) polymorphic(JobRule::class) { subclass(DelayRule::class, DelayRule.serializer()) From 3ca1bf1dd7ef0802b45f5574ac901aad32c1e2a0 Mon Sep 17 00:00:00 2001 From: Jan Gaebel Date: Wed, 11 Jan 2023 19:22:30 +0100 Subject: [PATCH 22/28] chore(android): bump target sdk --- build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index d5371a0..4c5556e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -79,13 +79,13 @@ kotlin { } android { - compileSdk = 30 + compileSdk = 33 namespace = "com.liftric.persisted.queue" defaultConfig { minSdk = 21 - targetSdk = 30 + targetSdk = 33 testInstrumentationRunner = "androidx.test.runner" } From 7ff1447db385950de8e34aba804fc4c7ba1730a4 Mon Sep 17 00:00:00 2001 From: Jan Gaebel Date: Wed, 11 Jan 2023 19:23:03 +0100 Subject: [PATCH 23/28] fix(android): namespace --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 4c5556e..5c31743 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -81,7 +81,7 @@ kotlin { android { compileSdk = 33 - namespace = "com.liftric.persisted.queue" + namespace = "com.liftric.job.queue" defaultConfig { minSdk = 21 From 27fce1fdc5c40a211c9be1e0f2e5ab893ff92dbb Mon Sep 17 00:00:00 2001 From: Jan Gaebel Date: Thu, 12 Jan 2023 13:02:30 +0100 Subject: [PATCH 24/28] feat(github): support publishing to Github packages! --- .github/workflows/ci.yml | 11 +++--- .github/workflows/publish.yml | 25 +++++++------- build.gradle.kts | 63 +++++++++++++++++------------------ gradle.properties | 1 + 4 files changed, 49 insertions(+), 51 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7478f6..85c0688 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,4 @@ name: Build & test - on: pull_request: types: [ opened, reopened, synchronize ] @@ -8,19 +7,19 @@ on: push: branches: - main - jobs: test: runs-on: macOS-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: set up JDK 11 - uses: actions/setup-java@v1 + uses: actions/setup-java@v3 with: - java-version: 11 + java-version: '11' + distribution: 'adopt' - name: Build and test - run: region=${{ secrets.region }} clientId=${{ secrets.clientid }} ./gradlew build test + run: ./gradlew build test - name: Upload test result if: ${{ always() }} uses: actions/upload-artifact@v2 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4730b10..877120e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,27 +1,26 @@ -name: Publish to OSSRH +name: Publish to Github on: release: types: [published] - jobs: publish: runs-on: macOS-latest - steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up JDK 11 - uses: actions/setup-java@v1 + uses: actions/setup-java@v3 with: - java-version: 11 + java-version: '11' + distribution: 'adopt' + - name: Validate Gradle wrapper + uses: gradle/wrapper-validation-action@e6e38bacfdf1a337459f332974bb2327a31aaf4b - name: Grant Permission to Execute run: chmod +x gradlew - name: New version run: ./gradlew versionDisplay - - name: Publish Library + - name: Publish package + uses: gradle/gradle-build-action@67421db6bd0bf253fb4bd25b31ebb98943c375e1 + with: + arguments: publish env: - ORG_GRADLE_PROJECT_signingKey: ${{ secrets.OSSRH_GPG_SECRET_KEY }} - ORG_GRADLE_PROJECT_signingPassword: ${{ secrets.OSSRH_GPG_SECRET_KEY_PASSWORD }} - ORG_GRADLE_PROJECT_ossrhUsername: ${{ secrets.OSSRH_USERNAME }} - ORG_GRADLE_PROJECT_ossrhPassword: ${{ secrets.OSSRH_PASSWORD }} - ORG_GRADLE_PROJECT_npmAccessKey: ${{ secrets.NPMJS_ACCESS_KEY }} - run: region=${{ secrets.region }} clientId=${{ secrets.clientid }} ./gradlew publishAllPublicationsToSonatypeRepository + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/build.gradle.kts b/build.gradle.kts index 5c31743..ac734b9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,21 +9,13 @@ plugins { id("signing") } -repositories { - mavenCentral() - google() - gradlePluginPortal() -} - -tasks.withType(JavaCompile::class) { - options.release.set(11) +group = "com.liftric" +version = with(versioning.info) { + if (branch == "HEAD" && dirty.not()) tag else full } kotlin { - ios { - binaries.framework() - } - + ios() iosSimulatorArm64() android { @@ -98,16 +90,21 @@ android { isReturnDefaultValues = true } } + publishing { + multipleVariants { + withSourcesJar() + withJavadocJar() + allVariants() + } + } } -group = "com.liftric" -version = with(versioning.info) { - if (branch == "HEAD" && dirty.not()) tag else full -} - -afterEvaluate { - project.publishing.publications.withType(MavenPublication::class.java).forEach { - it.groupId = group.toString() +tasks { + withType { + deviceId = "iPhone 14" + } + withType(JavaCompile::class) { + options.release.set(11) } } @@ -121,11 +118,11 @@ val javadocJar by tasks.registering(Jar::class) { publishing { repositories { maven { - name = "sonatype" - setUrl("https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/") + name = "GitHubPackages" + setUrl("https://maven.pkg.github.com/Liftric/kmm-job-queue") credentials { - username = ossrhUsername - password = ossrhPassword + username = System.getenv("GITHUB_ACTOR") + password = System.getenv("GITHUB_TOKEN") } } } @@ -135,13 +132,13 @@ publishing { pom { name.set(project.name) - description.set("Kotlin Multiplatform persisted queue library.") - url.set("https://github.com/liftric/cognito-idp") + description.set("Persistable coroutine job queue for Kotlin Multiplatform projects.") + url.set("https://github.com/Liftric/kmm-job-queue") licenses { license { name.set("MIT") - url.set("https://github.com/liftric/cognito-idp/blob/master/LICENSE") + url.set("https://github.com/Liftric/kmm-job-queue/blob/master/LICENSE") } } developers { @@ -152,19 +149,21 @@ publishing { } } scm { - url.set("https://github.com/liftric/persisted-queue") + url.set("https://github.com/Liftric/kmm-job-queue") } } } } +afterEvaluate { + project.publishing.publications.withType(MavenPublication::class.java).forEach { + it.groupId = group.toString() + } +} + signing { val signingKey: String? by project val signingPassword: String? by project useInMemoryPgpKeys(signingKey, signingPassword) sign(publishing.publications) } - -tasks.withType { - deviceId = "iPhone 14" -} diff --git a/gradle.properties b/gradle.properties index 2170f96..ab72ab0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,5 @@ android.useAndroidX=true +android.disableAutomaticComponentCreation=true org.gradle.parallel=true org.gradle.jvmargs=-Xmx4096m org.gradle.vfs.watch=true From 7bbc40626250f3fff1ebaebf2e535754bf77c6a2 Mon Sep 17 00:00:00 2001 From: Jan Gaebel Date: Thu, 12 Jan 2023 13:15:13 +0100 Subject: [PATCH 25/28] refactor(README): update --- README.md | 38 ++++++++++++------- .../kotlin/com/liftric/job/queue/JobQueue.kt | 18 ++++----- .../com/liftric/job/queue/JobQueueTests.kt | 10 ++--- 3 files changed, 39 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 89185f2..396e9eb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Persisted-queue -Coroutine job scheduler inspired by `Android Work Manager` and `android-priority-jobqueue` for Kotlin Multiplatform projects. Run & repeat tasks. Rebuild them from disk. Fine tune execution with rules. +Coroutine job scheduler for Kotlin Multiplatform projects. Run & repeat tasks. Rebuild them from disk. Fine tune execution with rules. + +The library depends on `kotlinx-serialization` for the persistence of the jobs. ## Rules @@ -9,15 +11,16 @@ Coroutine job scheduler inspired by `Android Work Manager` and `android-priority - [x] Retry - [x] Periodic - [x] Unique -- [ ] Internet +- [ ] Network ## Capabilities -- [x] Cancellation (all, by id, by tag) +- [x] Cancellation (all, by id) +- [x] Restore from disk ## Example -Define a `DataTask<*>` or a `Task` (`DataTask`), customize its body and limit when it should repeat. +Define a `Task` (or `DataTask`), customize its body and limit when it should repeat. ⚠️ Make sure the data you pass into the task is serializable. @@ -27,25 +30,31 @@ data class UploadData(val id: String) class UploadTask(data: UploadData): DataTask(data) { override suspend fun body() { /* Do something */ } - override suspend fun onRepeat(cause: Throwable): Boolean { cause is NetworkException } + override suspend fun onRepeat(cause: Throwable): Boolean { cause is NetworkException } // Won't retry if false } ``` -Create a single instance of the scheduler on app start. To start enqueuing jobs run `queue.start()`. +Create a single instance of the job queue on app start. To start enqueuing jobs run `jobQueue.start()`. + +⚠️ You have to provide the polymorphic serializer of your custom task **if you want to persist it**. -You can pass a `Queue.Configuration` or a custom `JobSerializer` to the scheduler. +You can pass a custom `Queue.Configuration` or `JsonStorage` to the job queue. ```kotlin -val scheduler = JobScheduler() -scheduler.queue.start() +val jobQueue = JobQueue(serializers = SerializersModule { + polymorphic(Task::class) { + subclass(UploadTask::class, UploadTask.serializer()) + } +}) +jobQueue.start() ``` You can customize the jobs life cycle during schedule by defining rules. ```kotlin -val data = UploadData(id = ...) +val data = UploadData(id = "123456") -scheduler.schedule(UploadTask(data)) { +jobQueue.schedule(UploadTask(data)) { unique(data.id) retry(RetryLimit.Limited(3), delay = 30.seconds) persist() @@ -55,7 +64,10 @@ scheduler.schedule(UploadTask(data)) { You can subscribe to life cycle events (e.g. for logging). ```kotlin -scheduler.onEvent.collect { event -> - Logger.info(event) +jobQueue.listener.collect { event -> + when (event) { + is JobEvent.DidFail -> Logger.error(event.error) + else -> Logger.info(event) + } } ``` diff --git a/src/commonMain/kotlin/com/liftric/job/queue/JobQueue.kt b/src/commonMain/kotlin/com/liftric/job/queue/JobQueue.kt index 4eba158..d6d9d2b 100644 --- a/src/commonMain/kotlin/com/liftric/job/queue/JobQueue.kt +++ b/src/commonMain/kotlin/com/liftric/job/queue/JobQueue.kt @@ -37,7 +37,7 @@ abstract class AbstractJobQueue( } private val format = Json { serializersModule = module + serializers } - val onEvent = MutableSharedFlow(extraBufferCapacity = Int.MAX_VALUE) + val listener = MutableSharedFlow(extraBufferCapacity = Int.MAX_VALUE) /** * Scheduled jobs @@ -85,7 +85,7 @@ abstract class AbstractJobQueue( val job = Job(task, info) schedule(job).apply { - onEvent.emit(JobEvent.DidSchedule(job)) + listener.emit(JobEvent.DidSchedule(job)) } } @@ -100,7 +100,7 @@ abstract class AbstractJobQueue( queue.value = queue.value.plus(listOf(job)).sortedBy { it.startTime }.toMutableList() } catch (e: Throwable) { - onEvent.emit(JobEvent.DidThrowOnSchedule(e)) + listener.emit(JobEvent.DidThrowOnSchedule(e)) } private val delegate = JobDelegate() @@ -119,10 +119,10 @@ abstract class AbstractJobQueue( } is JobEvent.ShouldRepeat -> { schedule(event.job).apply { - onEvent.emit(JobEvent.DidScheduleRepeat(event.job)) + listener.emit(JobEvent.DidScheduleRepeat(event.job)) } } - else -> onEvent.emit(event) + else -> listener.emit(event) } } } @@ -137,12 +137,12 @@ abstract class AbstractJobQueue( running.value[job.id] = configuration.scope.launch { try { withTimeout(job.info.timeout) { - onEvent.emit(JobEvent.WillRun(job)) + listener.emit(JobEvent.WillRun(job)) val result = job.run() - onEvent.emit(result) + listener.emit(result) } } catch (e: CancellationException) { - onEvent.emit(JobEvent.DidCancel(job)) + listener.emit(JobEvent.DidCancel(job)) } finally { if (job.info.shouldPersist) { store.remove(job.id.toString()) @@ -188,7 +188,7 @@ abstract class AbstractJobQueue( isCancelling.withLock { queue.value.firstOrNull { it.id == id }?.let { job -> queue.value.remove(job) - onEvent.emit(JobEvent.DidCancel(job)) + listener.emit(JobEvent.DidCancel(job)) } ?: running.value[id]?.cancel() } } diff --git a/src/commonTest/kotlin/com/liftric/job/queue/JobQueueTests.kt b/src/commonTest/kotlin/com/liftric/job/queue/JobQueueTests.kt index 2e75893..4e22bb7 100644 --- a/src/commonTest/kotlin/com/liftric/job/queue/JobQueueTests.kt +++ b/src/commonTest/kotlin/com/liftric/job/queue/JobQueueTests.kt @@ -18,7 +18,7 @@ abstract class AbstractJobQueueTests(private val queue: JobQueue) { runBlocking { val id = UUIDFactory.create().toString() val job = async { - queue.onEvent.collect { + queue.listener.collect { println(it) } } @@ -49,7 +49,7 @@ abstract class AbstractJobQueueTests(private val queue: JobQueue) { fun testRetry() = runBlocking { var count = 0 val job = launch { - queue.onEvent.collect { + queue.listener.collect { println(it) if (it is JobEvent.DidScheduleRepeat) { count += 1 @@ -73,7 +73,7 @@ abstract class AbstractJobQueueTests(private val queue: JobQueue) { fun testCancelDuringRun() { runBlocking { launch { - queue.onEvent.collect { + queue.listener.collect { println(it) if (it is JobEvent.DidSucceed || it is JobEvent.DidFail) fail("Continued after run") if (it is JobEvent.WillRun) { @@ -100,7 +100,7 @@ abstract class AbstractJobQueueTests(private val queue: JobQueue) { val completable = CompletableDeferred() launch { - queue.onEvent.collect { + queue.listener.collect { println(it) if (it is JobEvent.DidSucceed || it is JobEvent.DidFail) fail("Continued after run") if (it is JobEvent.DidSchedule) { @@ -127,7 +127,7 @@ abstract class AbstractJobQueueTests(private val queue: JobQueue) { fun testCancelByIdAfterEnqueue() { runBlocking { launch { - queue.onEvent.collect { + queue.listener.collect { println(it) if (it is JobEvent.DidSchedule) { delay(3000L) From 54fea53a33dfd63ed07380590ddea4521b1da6f5 Mon Sep 17 00:00:00 2001 From: Jan Gaebel Date: Thu, 12 Jan 2023 13:15:53 +0100 Subject: [PATCH 26/28] chore(README): update headline --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 396e9eb..1de8da9 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Persisted-queue +# kmm-job-queue Coroutine job scheduler for Kotlin Multiplatform projects. Run & repeat tasks. Rebuild them from disk. Fine tune execution with rules. From df2d97fbe993a18b9ec0e3e88228816ff2ac2b59 Mon Sep 17 00:00:00 2001 From: Jan Gaebel Date: Thu, 12 Jan 2023 15:38:24 +0100 Subject: [PATCH 27/28] refactor(queue): restrict access --- README.md | 5 ++- .../kotlin/com/liftric/job/queue/Job.kt | 37 ++++++++++--------- .../com/liftric/job/queue/JobContext.kt | 9 +++-- .../kotlin/com/liftric/job/queue/JobQueue.kt | 13 +++---- .../kotlin/com/liftric/job/queue/Queue.kt | 2 +- 5 files changed, 36 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 1de8da9..c6cb020 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ Coroutine job scheduler for Kotlin Multiplatform projects. Run & repeat tasks. R The library depends on `kotlinx-serialization` for the persistence of the jobs. +⚠️ The project is still work in progress and shouldn't be used in a production project. + ## Rules - [x] Delay @@ -16,7 +18,8 @@ The library depends on `kotlinx-serialization` for the persistence of the jobs. ## Capabilities - [x] Cancellation (all, by id) -- [x] Restore from disk +- [x] Start & stop scheduling +- [x] Restore from disk (after start) ## Example diff --git a/src/commonMain/kotlin/com/liftric/job/queue/Job.kt b/src/commonMain/kotlin/com/liftric/job/queue/Job.kt index c029874..3b826b7 100644 --- a/src/commonMain/kotlin/com/liftric/job/queue/Job.kt +++ b/src/commonMain/kotlin/com/liftric/job/queue/Job.kt @@ -2,6 +2,7 @@ package com.liftric.job.queue import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.withTimeout import kotlinx.datetime.Clock import kotlinx.datetime.Instant import kotlinx.serialization.Serializable @@ -26,27 +27,29 @@ data class Job( private var canRepeat: Boolean = true suspend fun run(): JobEvent { - val event = try { - info.rules.forEach { it.willRun(this@Job) } + return withTimeout(info.timeout) { + val event = try { + info.rules.forEach { it.willRun(this@Job) } - task.body() + task.body() - JobEvent.DidSucceed(this@Job) - } catch (e: CancellationException) { - throw e - } catch (e: Throwable) { - canRepeat = task.onRepeat(e) - JobEvent.DidFail(this@Job, e) - } + JobEvent.DidSucceed(this@Job) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + canRepeat = task.onRepeat(e) + JobEvent.DidFail(this@Job, e) + } - return try { - info.rules.forEach { it.willRemove(this@Job, event) } + try { + info.rules.forEach { it.willRemove(this@Job, event) } - event - } catch (e: CancellationException) { - throw e - } catch (e: Throwable) { - JobEvent.DidFailOnRemove(this@Job, e) + event + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + JobEvent.DidFailOnRemove(this@Job, e) + } } } diff --git a/src/commonMain/kotlin/com/liftric/job/queue/JobContext.kt b/src/commonMain/kotlin/com/liftric/job/queue/JobContext.kt index e23a6f2..537d691 100644 --- a/src/commonMain/kotlin/com/liftric/job/queue/JobContext.kt +++ b/src/commonMain/kotlin/com/liftric/job/queue/JobContext.kt @@ -2,11 +2,14 @@ package com.liftric.job.queue import kotlinx.datetime.Instant -interface JobContext { +interface JobContext: JobData { + suspend fun cancel() + suspend fun repeat(id: UUID = this.id, info: JobInfo = this.info, task: Task = this.task, startTime: Instant = this.startTime) +} + +interface JobData { val id: UUID val info: JobInfo val task: Task val startTime: Instant - suspend fun cancel() - suspend fun repeat(id: UUID = this.id, info: JobInfo = this.info, task: Task = this.task, startTime: Instant = this.startTime) } diff --git a/src/commonMain/kotlin/com/liftric/job/queue/JobQueue.kt b/src/commonMain/kotlin/com/liftric/job/queue/JobQueue.kt index d6d9d2b..e99f351 100644 --- a/src/commonMain/kotlin/com/liftric/job/queue/JobQueue.kt +++ b/src/commonMain/kotlin/com/liftric/job/queue/JobQueue.kt @@ -45,11 +45,10 @@ abstract class AbstractJobQueue( private val running = atomic(mutableMapOf()) private val queue = atomic(mutableListOf()) - override val jobs: List + override val jobs: List get() = queue.value - override val numberOfJobs: Int - get() = queue.value.count() + get() = jobs.count() /** * Semaphore to limit concurrency @@ -136,11 +135,9 @@ abstract class AbstractJobQueue( job.delegate = delegate running.value[job.id] = configuration.scope.launch { try { - withTimeout(job.info.timeout) { - listener.emit(JobEvent.WillRun(job)) - val result = job.run() - listener.emit(result) - } + listener.emit(JobEvent.WillRun(job)) + val result = job.run() + listener.emit(result) } catch (e: CancellationException) { listener.emit(JobEvent.DidCancel(job)) } finally { diff --git a/src/commonMain/kotlin/com/liftric/job/queue/Queue.kt b/src/commonMain/kotlin/com/liftric/job/queue/Queue.kt index 2bf4b15..b13d10a 100644 --- a/src/commonMain/kotlin/com/liftric/job/queue/Queue.kt +++ b/src/commonMain/kotlin/com/liftric/job/queue/Queue.kt @@ -3,7 +3,7 @@ package com.liftric.job.queue import kotlinx.coroutines.* interface Queue { - val jobs: List + val jobs: List val numberOfJobs: Int val configuration: Configuration From fc0d1af8a2fb2f16e4e225f7fb8254f8ad9df151 Mon Sep 17 00:00:00 2001 From: Jan Gaebel Date: Thu, 12 Jan 2023 16:40:44 +0100 Subject: [PATCH 28/28] fix(tests): flakiness --- .../kotlin/com/liftric/job/queue/JobQueueTests.kt | 9 ++++++--- src/commonTest/kotlin/com/liftric/job/queue/TestTask.kt | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/commonTest/kotlin/com/liftric/job/queue/JobQueueTests.kt b/src/commonTest/kotlin/com/liftric/job/queue/JobQueueTests.kt index 4e22bb7..8a6791e 100644 --- a/src/commonTest/kotlin/com/liftric/job/queue/JobQueueTests.kt +++ b/src/commonTest/kotlin/com/liftric/job/queue/JobQueueTests.kt @@ -72,7 +72,7 @@ abstract class AbstractJobQueueTests(private val queue: JobQueue) { @Test fun testCancelDuringRun() { runBlocking { - launch { + val listener = launch { queue.listener.collect { println(it) if (it is JobEvent.DidSucceed || it is JobEvent.DidFail) fail("Continued after run") @@ -81,16 +81,19 @@ abstract class AbstractJobQueueTests(private val queue: JobQueue) { } if (it is JobEvent.DidCancel) { assertTrue(queue.numberOfJobs == 0) - cancel() } } } queue.start() - delay(2000L) + delay(1000L) queue.schedule(::LongRunningTask) + + delay(10000L) + + listener.cancel() } } diff --git a/src/commonTest/kotlin/com/liftric/job/queue/TestTask.kt b/src/commonTest/kotlin/com/liftric/job/queue/TestTask.kt index 081b7fa..fdfbc06 100644 --- a/src/commonTest/kotlin/com/liftric/job/queue/TestTask.kt +++ b/src/commonTest/kotlin/com/liftric/job/queue/TestTask.kt @@ -20,5 +20,5 @@ class TestErrorTask: Task { @Serializable class LongRunningTask: Task { - override suspend fun body() { delay(10.seconds) } + override suspend fun body() { delay(15.seconds) } }