diff --git a/.github/workflows/gradle-wrapper-validation.yml b/.github/workflows/gradle-wrapper-validation.yml index cac92774..cd5abb33 100644 --- a/.github/workflows/gradle-wrapper-validation.yml +++ b/.github/workflows/gradle-wrapper-validation.yml @@ -6,5 +6,5 @@ jobs: validation: runs-on: 'ubuntu-latest' steps: - - uses: 'actions/checkout@v3' - - uses: 'gradle/wrapper-validation-action@v1' + - uses: 'actions/checkout@v4' + - uses: 'gradle/actions/wrapper-validation@v4' diff --git a/.github/workflows/push_dev.yml b/.github/workflows/push_dev.yml index 926943e2..20c7ef7b 100644 --- a/.github/workflows/push_dev.yml +++ b/.github/workflows/push_dev.yml @@ -26,15 +26,17 @@ jobs: name: 'KtlintCheck on macos-latst' runs-on: 'macos-latest' steps: - - uses: 'actions/checkout@v3' + - uses: 'actions/checkout@v4' with: submodules: 'recursive' fetch-depth: 0 # all commit history and tags - name: 'Set up JDK ${{ matrix.java }}' - uses: 'actions/setup-java@v2' + uses: 'actions/setup-java@v4' with: distribution: 'temurin' java-version: '17' + - name: 'Pin JAVA_HOME to JDK 17' + run: echo "JAVA_HOME=$JAVA_HOME" >> $GITHUB_ENV - name: 'Run checks with Gradle' run: './gradlew ktlintCheck --no-daemon --stacktrace' @@ -53,14 +55,16 @@ jobs: name: 'Run ${{ matrix.config.target }} on ${{ matrix.config.os }} JDK ${{ matrix.config.java }}' runs-on: '${{ matrix.config.os }}' steps: - - uses: 'actions/checkout@v3' + - uses: 'actions/checkout@v4' with: submodules: 'recursive' fetch-depth: 0 # all commit history and tags - - uses: 'actions/setup-java@v2' + - uses: 'actions/setup-java@v4' with: distribution: 'temurin' java-version: ${{ matrix.config.java }} + - name: 'Pin JAVA_HOME to JDK 17' + run: echo "JAVA_HOME=$JAVA_HOME" >> $GITHUB_ENV - run: './gradlew ${{ matrix.config.target }} --stacktrace' publishSnapshotArtifacts: @@ -80,12 +84,14 @@ jobs: name: 'Publish ${{ matrix.config.target }} snapshot artifacts on ${{ matrix.config.os }} JDK ${{ matrix.config.java }}' runs-on: '${{ matrix.config.os }}' steps: - - uses: 'actions/checkout@v3' + - uses: 'actions/checkout@v4' with: submodules: 'recursive' fetch-depth: 0 # all commit history and tags - - uses: 'actions/setup-java@v2' + - uses: 'actions/setup-java@v4' with: distribution: 'temurin' java-version: ${{ matrix.config.java }} + - name: 'Pin JAVA_HOME to JDK 17' + run: echo "JAVA_HOME=$JAVA_HOME" >> $GITHUB_ENV - run: './gradlew publish${{ matrix.config.target }}PublicationToMavenCentralSnapshotsRepository' diff --git a/.github/workflows/push_docs.yml b/.github/workflows/push_docs.yml index aca2beec..26c749f3 100644 --- a/.github/workflows/push_docs.yml +++ b/.github/workflows/push_docs.yml @@ -15,15 +15,17 @@ jobs: GITHUB_ACTOR: '${{ github.actor }}' GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' steps: - - uses: 'actions/checkout@v2' + - uses: 'actions/checkout@v4' with: submodules: 'recursive' - run: 'git fetch --prune --unshallow --tags' - name: 'Set up JDK 17' - uses: 'actions/setup-java@v2' + uses: 'actions/setup-java@v4' with: distribution: 'temurin' java-version: '17' + - name: 'Pin JAVA_HOME to JDK 17' + run: echo "JAVA_HOME=$JAVA_HOME" >> $GITHUB_ENV - name: 'Build Documentation' run: './gradlew :docs:mkdocsBuild --stacktrace -Prelease' - name: 'Upload static files as artifact' diff --git a/.github/workflows/push_main.yml b/.github/workflows/push_main.yml index 028e6a3b..20f08b25 100644 --- a/.github/workflows/push_main.yml +++ b/.github/workflows/push_main.yml @@ -37,15 +37,17 @@ jobs: outputs: projectVersion: ${{ steps.outputProjectVersion.outputs.projectVersion }} steps: - - uses: 'actions/checkout@v3' + - uses: 'actions/checkout@v4' with: submodules: 'recursive' fetch-depth: 0 # all commit history and tags - name: 'Set up JDK 17' - uses: 'actions/setup-java@v2' + uses: 'actions/setup-java@v4' with: distribution: 'temurin' java-version: 17 + - name: 'Pin JAVA_HOME to JDK 17' + run: echo "JAVA_HOME=$JAVA_HOME" >> $GITHUB_ENV - name: 'Open Sonatype Staging Repository' run: './gradlew writeProjectVersion openSonatypeStagingRepository --no-configuration-cache --stacktrace -Prelease -PorchidEnvironment=prod' - id: 'outputProjectVersion' @@ -56,15 +58,17 @@ jobs: needs: ['openStagingRepo'] if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.releaseIntellijPlugin == true) steps: - - uses: 'actions/checkout@v3' + - uses: 'actions/checkout@v4' with: submodules: 'recursive' fetch-depth: 0 # all commit history and tags - name: 'Set up JDK 17' - uses: 'actions/setup-java@v2' + uses: 'actions/setup-java@v4' with: distribution: 'temurin' java-version: 17 + - name: 'Pin JAVA_HOME to JDK 17' + run: echo "JAVA_HOME=$JAVA_HOME" >> $GITHUB_ENV - name: 'Publish IDEA Plugin' run: './gradlew :ballast-idea-plugin:buildPlugin :ballast-idea-plugin:publishPlugin --stacktrace -Prelease -PorchidEnvironment=prod' @@ -88,14 +92,16 @@ jobs: needs: ['openStagingRepo'] if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.releaseArtifacts == true) steps: - - uses: 'actions/checkout@v3' + - uses: 'actions/checkout@v4' with: submodules: 'recursive' fetch-depth: 0 # all commit history and tags - - uses: 'actions/setup-java@v2' + - uses: 'actions/setup-java@v4' with: distribution: 'temurin' java-version: 17 + - name: 'Pin JAVA_HOME to JDK 17' + run: echo "JAVA_HOME=$JAVA_HOME" >> $GITHUB_ENV - run: './gradlew findSonatypeStagingRepository' - run: './gradlew publish${{ matrix.config.target }}PublicationToMavenCentralRepository --stacktrace -Prelease -PorchidEnvironment=prod' @@ -103,12 +109,12 @@ jobs: # runs-on: 'ubuntu-latest' # needs: ['openStagingRepo', 'publishArtifacts'] # steps: -# - uses: 'actions/checkout@v3' +# - uses: 'actions/checkout@v4' # with: # submodules: 'recursive' # fetch-depth: 0 # all commit history and tags # - name: 'Set up JDK 17' -# uses: 'actions/setup-java@v2' +# uses: 'actions/setup-java@v4' # with: # distribution: 'temurin' # java-version: 17 diff --git a/README.md b/README.md index 93687aa8..e1fd8aab 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,3 @@ ---- ---- - # Ballast > Opinionated Application State Management framework for Kotlin Multiplatform @@ -11,12 +8,12 @@ [![Intellij Plugin Version](https://img.shields.io/jetbrains/plugin/v/18702-ballast)](https://plugins.jetbrains.com/plugin/18702-ballast) ```kotlin -object TodosContract { +object TodosContract { data class State( - val loading: Boolean = false, - val todos: List = emptyList(), + val loading: Boolean = false, + val todos: List = emptyList(), ) - + sealed interface Inputs { data object FetchSavedTodos : Inputs data class AddTodo(val text: String) : Inputs @@ -28,85 +25,54 @@ class TodosInputHandler : InputHandler { override suspend fun InputHandlerScope.handleInput( input: TodosContract.Inputs ) = when (input) { - is FetchSavedTodos -> { - updateState { it.copy(loading = true) } + is FetchSavedTodos -> { + updateState { copy(loading = true) } val todos = todosApi.fetchTodos() - updateState { it.copy(loading = false, todos = todos) } - } - is AddTodo -> { - updateState { it.copy(todos = it.todos + input.text) } - } - is RemoveTodo -> { - updateState { it.copy(todos = it.todos - input.text) } + updateState { copy(loading = false, todos = todos) } } + is AddTodo -> updateState { copy(todos = todos + input.text) } + is RemoveTodo -> updateState { copy(todos = todos - input.text) } } } @Composable -fun App() { +fun App() { val coroutineScope = rememberCoroutineScope() val vm = remember(coroutineScope) { TodosViewModel(coroutineScope) } val vmState by vm.observeStates().collectAsState() - - LaunchedEffect(vm) { - vm.send(TodosContract.FetchSavedTodos) - } - - TodosList(vmState) { vm.trySend(it) } -} -@Composable -fun TodosList( - vmState: TodosContract.State, - postInput: (TodosContract.Inputs)->Unit, -) { - // ... + LaunchedEffect(vm) { vm.send(TodosContract.FetchSavedTodos) } + + TodosList(vmState, postInput = { vm.trySend(it) }) } ``` -* _This snippet omits some details for brevity, to demonstrate the general idea_ - -# Supported Platforms/Features +_This snippet omits some details for brevity. See [Getting Started](docs/getting-started.md) for a complete walkthrough._ -Ballast was intentionally designed to not be tied directly to any particular platform or UI toolkit. In fact, while most -Kotlin MVI libraries were initially developed for Android and show many artifacts of that initial base, Ballast started -as a State Management solution for Compose Desktop. +## Supported Platforms -Because Ballast was initially designed entirely in a non-Android context, it should work in any Kotlin target or -platform as long as it works with Coroutines and Flows. However, the following targets are officially supported, in -that they have been tested and are known to work there, or have specific features for that platform +Ballast was intentionally designed to not be tied to any particular platform or UI toolkit. It works in any Kotlin +target that supports Coroutines and Flows. The following platforms are officially supported and tested: -- [Android](https://copper-leaf.github.io/ballast/wiki/platforms/android) -- [iOS](https://copper-leaf.github.io/ballast/wiki/platforms/ios) -- [WasmJS](https://copper-leaf.github.io/ballast/wiki/platforms/wasmjs) -- [Compose](https://copper-leaf.github.io/ballast/wiki/platforms/compose) -- [KVision](https://copper-leaf.github.io/ballast/wiki/platforms/kvision) +| Platform | Supported | +|----------|-----------| +| JVM | ✅ | +| Android | ✅ | +| iOS | ✅ | +| JS | ✅ | +| WASM JS | ✅ | -# Installation +## Installation ```kotlin repositories { mavenCentral() - - // SNAPSHOT builds are available as well at the MavenCentral Snapshots repository - maven(url = "https://s01.oss.sonatype.org/content/repositories/snapshots") } // for plain JVM or Android projects dependencies { - implementation("io.github.copper-leaf:ballast-core:{{site.version}}") - - implementation("io.github.copper-leaf:ballast-repository:{{site.version}}") - implementation("io.github.copper-leaf:ballast-saved-state:{{site.version}}") - implementation("io.github.copper-leaf:ballast-sync:{{site.version}}") - implementation("io.github.copper-leaf:ballast-undo:{{site.version}}") - implementation("io.github.copper-leaf:ballast-navigation:{{site.version}}") - implementation("io.github.copper-leaf:ballast-schedules:{{site.version}}") - implementation("io.github.copper-leaf:ballast-crash-reporting:{{site.version}}") - implementation("io.github.copper-leaf:ballast-analytics:{{site.version}}") - implementation("io.github.copper-leaf:ballast-debugger-client:{{site.version}}") - - testImplementation("io.github.copper-leaf:ballast-test:{{site.version}}") + implementation("io.github.copper-leaf:ballast-core:{{ballastVersion}}") + testImplementation("io.github.copper-leaf:ballast-test:{{ballastVersion}}") } // for multiplatform projects @@ -114,73 +80,68 @@ kotlin { sourceSets { val commonMain by getting { dependencies { - implementation("io.github.copper-leaf:ballast-core:{{site.version}}") - - implementation("io.github.copper-leaf:ballast-repository:{{site.version}}") - implementation("io.github.copper-leaf:ballast-saved-state:{{site.version}}") - implementation("io.github.copper-leaf:ballast-sync:{{site.version}}") - implementation("io.github.copper-leaf:ballast-undo:{{site.version}}") - implementation("io.github.copper-leaf:ballast-navigation:{{site.version}}") - implementation("io.github.copper-leaf:ballast-schedules:{{site.version}}") - implementation("io.github.copper-leaf:ballast-crash-reporting:{{site.version}}") - implementation("io.github.copper-leaf:ballast-analytics:{{site.version}}") - implementation("io.github.copper-leaf:ballast-debugger-client:{{site.version}}")= + implementation("io.github.copper-leaf:ballast-core:{{ballastVersion}}") } } val commonTest by getting { dependencies { - implementation("io.github.copper-leaf:ballast-test:{{site.version}}") - } - } - val androidMain by getting { - dependencies { - implementation("io.github.copper-leaf:ballast-firebase-crashlytics:{{site.version}}") - implementation("io.github.copper-leaf:ballast-firebase-analytics:{{site.version}}") + implementation("io.github.copper-leaf:ballast-test:{{ballastVersion}}") } } } } ``` -# Documentation +Other modules can be added as needed. See [docs/README.md](docs/README.md) for the full list. + +## AI Coding Assistance + +Ballast ships an [`llms.txt`](llms.txt) file — a plain-Markdown context file for AI coding assistants. It covers core concepts, APIs, module list, and common pitfalls. -See the [website](https://copper-leaf.github.io/ballast/) for detailed documentation and usage instructions. +To use it, copy the file into your project's agent rules location: -# Community Chat +| Agent | Location | +|----------------|--------------------------------------------| +| Claude Code | `CLAUDE.md` or `.claude/rules/ballast.md` | +| Cursor | `.cursor/rules/ballast.mdc` | +| GitHub Copilot | `.github/copilot-instructions.md` | +| Windsurf | `.windsurfrules` | +| Other | Wherever your agent reads Markdown context | -Join us at https://kotlinlang.slack.com in the `#ballast` channel for support, or to show off what you're building with Ballast! +## Documentation -https://kotlinlang.slack.com/archives/C03GTEJ9Y3E +Full documentation is in the [docs/](docs/) directory: -# License +- **[Getting Started](docs/getting-started.md)** — step-by-step guide to building your first Ballast screen +- **[Feature Overview](docs/feature-overview.md)** — core concepts: Contracts, Handlers, Side Jobs, Interceptors +- **[Thinking in Ballast MVI](docs/mental-model.md)** — the MVI model and Ballast's design philosophy +- **[Feature Comparison](docs/feature-comparison.md)** — Ballast vs Redux, Orbit, MVIKotlin, Uniflow-kt +- **[Migration Guides](docs/migration/)** — upgrading between major versions +- **[All modules and examples](docs/README.md)** — index of every module and example with descriptions -Ballast is licensed under the BSD 3-Clause License, see [LICENSE.md](https://github.com/copper-leaf/ballast/tree/main/LICENSE.md). +## Community -# References +Join us on the Kotlin Slack in the [`#ballast`](https://kotlinlang.slack.com/archives/C03GTEJ9Y3E) channel for +support or to show off what you're building with Ballast. -Ballast is not new, it was built upon years of experience building UI applications in Android and observing the -direction UI programming has gone in the past few years. The MVI model has proven itself to be robust to a wide array -of applications, and there are different implementations of the pattern that focus on different aspects of the pattern. +## License -The following are some of the main libraries I drew inspiration from while using Ballast. If Ballast does not fit your -project's needs, maybe one of these will suit you better. See the [feature comparison][4] for a better breakdown of the -specific features of these libraries, to demonstrate the similarities and differences between them. +Ballast is licensed under the BSD 3-Clause License, see [LICENSE.md](LICENSE.md). -- [Redux][1]: The OG of the MVI programming model. It also was not the first MVI library, but React+Redux has certainly - been one of the biggest contributors to this pattern's popularity today, especially in JS, but also in many other - tech spaces -- [Orbit MVI][2]: A primary source of inspiration for Ballast. This library is mature and well-built, but in my opinion - was built a little too closely to Android, making it less useful on other KMP targets. It also uses terminology from - Redux like "reducer" and "transformer" that are intended to bridge the gap from users familiar with Redux, but are - a bit confusing for developers new to MVI. It is also missing some key features that one would expect from an MVI - library, like a graphical debugger. -- [How to write your own MVI system and why you shouldn't][3]: An intro video to the [Orbit MVI][2] library, and one of - the best introductions to the MVI model I've seen. By walking you through the thought process behind developing a - simple MVI library, it reinforces the concepts of the pattern and helps you understand how to use a mature MVI library - like Orbit or Ballast. +## References +Ballast was built upon years of experience building UI applications and observing the direction UI programming has +gone. The MVI model has proven itself robust across a wide range of applications, programming languages, and API +surfaces. Ballast is not the only MVI library in Kotlin, but it is unique in being a highly opinionated and highly +structured MVI library, which brings certain advantages. The [feature comparison](docs/feature-comparison.md) +is a detailed breakdown of similarities and differences among the many libraries that were consulted as a large +inspiration for Ballast. But by far, these 3 links were the most helpful in shaping how Ballast works and looks, and +studying these resources may give you a deeper understanding of why Ballast was built the way that it was. -[1]: https://github.com/reduxjs/redux -[2]: https://github.com/orbit-mvi/orbit-mvi -[3]: https://www.youtube.com/watch?v=E6obYmkkdko -[4]: https://copper-leaf.github.io/ballast/wiki/feature-comparison/ +- [Redux](https://github.com/reduxjs/redux): The most widely-known implementation of the MVI/Flux pattern. React+Redux + has been one of the biggest contributors to this pattern's popularity. +- [Orbit MVI](https://github.com/orbit-mvi/orbit-mvi): A primary source of inspiration for Ballast. Mature and + well-built, though oriented closely toward Android and missing some features like a graphical debugger. +- [How to write your own MVI system and why you shouldn't](https://www.youtube.com/watch?v=E6obYmkkdko): An intro video + to the Orbit MVI library and one of the best introductions to the MVI model available. Walking through building a + simple MVI library from scratch helps cement the concepts behind using a mature one. diff --git a/ballast-analytics/README.md b/ballast-analytics/README.md new file mode 100644 index 00000000..59ba2587 --- /dev/null +++ b/ballast-analytics/README.md @@ -0,0 +1,88 @@ +# Ballast Analytics + +## Overview + +Ballast's Analytics module automatically tracks Inputs sent to your ViewModels to send to your analytics SDK. Support +for Firebase Analytics is supported out-of-the-box on Android via [Ballast Firebase Analytics](./../ballast-firebase-analytics). + +## Supported Platforms + +| Platform | Supported | +|----------|-----------| +| JVM | ✅ | +| Android | ✅ | +| iOS | ✅ | +| JS | ✅ | +| WASM JS | ✅ | + +## See Also + +- [Ballast Firebase Analytics](./../ballast-firebase-analytics) +- [Ballast Crash Reporting](./../ballast-crash-reporting) +- [Ballast Firebase Crashlytics](./../ballast-firebase-crashlytics) + +## Usage + +```kotlin +class ExampleViewModel(coroutineScope: CoroutineScope) : BasicViewModel< + ExampleContract.Inputs, + ExampleContract.Events, + ExampleContract.State + >( + coroutineScope = coroutineScope, + config = BallastViewModelConfiguration.Builder() + .withViewModel(ExampleContract.State(), ExampleInputHandler()) + .apply { + interceptors += AnalyticsInterceptor( + tracker = ExampleAnalyticsTracker(), + + // implement AnalyticsAdapter for full control over the eventId and eventParameters passed to the Tracker + adapter = DefaultAnalyticsAdapter( + shouldTrackInput = { input -> + when (input) { + is ExampleContract.Inputs.TrackThis -> true + is ExampleContract.Inputs.DontTrackThis -> false + } + } + ) + ) + } + .build(), + eventHandler = eventHandler { }, +) + +class ExampleAnalyticsTracker : AnalyticsTracker { + override fun trackAnalyticsEvent( + eventId: String, + eventParameters: Map + ) { + // TODO: track this event to your analytics SDK + } +} +``` + +[Source](./src/commonTest/kotlin/com/copperleaf/ballast/analytics/vm/TestViewModel.kt) + +## Installation + +```kotlin +repositories { + mavenCentral() +} + +// for plain JVM or Android projects +dependencies { + implementation("io.github.copper-leaf:ballast-analytics:{{ballastVersion}}") +} + +// for multiplatform projects +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + implementation("io.github.copper-leaf:ballast-analytics:{{ballastVersion}}") + } + } + } +} +``` diff --git a/ballast-analytics/api/android/ballast-analytics.api b/ballast-analytics/api/android/ballast-analytics.api index 529a987a..82e7eb8a 100644 --- a/ballast-analytics/api/android/ballast-analytics.api +++ b/ballast-analytics/api/android/ballast-analytics.api @@ -1,6 +1,6 @@ public abstract interface class com/copperleaf/ballast/analytics/AnalyticsAdapter { public abstract fun getEventIdForInput (Ljava/lang/Object;)Ljava/lang/String; - public abstract fun getEventParametersForInput (Ljava/lang/String;Ljava/lang/Object;)Ljava/util/Map; + public abstract fun getEventParametersForInput (Ljava/lang/String;Ljava/lang/Object;Lcom/copperleaf/ballast/BallastEncoder;)Ljava/util/Map; public abstract fun shouldTrackInput (Ljava/lang/Object;)Z } @@ -17,9 +17,11 @@ public abstract interface class com/copperleaf/ballast/analytics/AnalyticsTracke } public final class com/copperleaf/ballast/analytics/DefaultAnalyticsAdapter : com/copperleaf/ballast/analytics/AnalyticsAdapter { + public fun ()V public fun (Lkotlin/jvm/functions/Function1;)V + public synthetic fun (Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun getEventIdForInput (Ljava/lang/Object;)Ljava/lang/String; - public fun getEventParametersForInput (Ljava/lang/String;Ljava/lang/Object;)Ljava/util/Map; + public fun getEventParametersForInput (Ljava/lang/String;Ljava/lang/Object;Lcom/copperleaf/ballast/BallastEncoder;)Ljava/util/Map; public fun shouldTrackInput (Ljava/lang/Object;)Z } diff --git a/ballast-analytics/api/jvm/ballast-analytics.api b/ballast-analytics/api/jvm/ballast-analytics.api index 529a987a..82e7eb8a 100644 --- a/ballast-analytics/api/jvm/ballast-analytics.api +++ b/ballast-analytics/api/jvm/ballast-analytics.api @@ -1,6 +1,6 @@ public abstract interface class com/copperleaf/ballast/analytics/AnalyticsAdapter { public abstract fun getEventIdForInput (Ljava/lang/Object;)Ljava/lang/String; - public abstract fun getEventParametersForInput (Ljava/lang/String;Ljava/lang/Object;)Ljava/util/Map; + public abstract fun getEventParametersForInput (Ljava/lang/String;Ljava/lang/Object;Lcom/copperleaf/ballast/BallastEncoder;)Ljava/util/Map; public abstract fun shouldTrackInput (Ljava/lang/Object;)Z } @@ -17,9 +17,11 @@ public abstract interface class com/copperleaf/ballast/analytics/AnalyticsTracke } public final class com/copperleaf/ballast/analytics/DefaultAnalyticsAdapter : com/copperleaf/ballast/analytics/AnalyticsAdapter { + public fun ()V public fun (Lkotlin/jvm/functions/Function1;)V + public synthetic fun (Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun getEventIdForInput (Ljava/lang/Object;)Ljava/lang/String; - public fun getEventParametersForInput (Ljava/lang/String;Ljava/lang/Object;)Ljava/util/Map; + public fun getEventParametersForInput (Ljava/lang/String;Ljava/lang/Object;Lcom/copperleaf/ballast/BallastEncoder;)Ljava/util/Map; public fun shouldTrackInput (Ljava/lang/Object;)Z } diff --git a/ballast-analytics/build.gradle.kts b/ballast-analytics/build.gradle.kts index 765dfb79..046709b8 100644 --- a/ballast-analytics/build.gradle.kts +++ b/ballast-analytics/build.gradle.kts @@ -3,7 +3,7 @@ plugins { id("copper-leaf-android-library") id("copper-leaf-targets") id("copper-leaf-tests") -// id("copper-leaf-lint") + id("copper-leaf-lint") id("copper-leaf-publish") } @@ -14,6 +14,12 @@ kotlin { implementation(project(":ballast-api")) } } + val commonTest by getting { + dependencies { + implementation(project(":ballast-test")) + implementation(project(":ballast-core")) + } + } val jvmMain by getting { dependencies { } } diff --git a/ballast-analytics/gradle.properties b/ballast-analytics/gradle.properties index dae7fba8..38db69e7 100644 --- a/ballast-analytics/gradle.properties +++ b/ballast-analytics/gradle.properties @@ -1,4 +1,4 @@ -copperleaf.description=Automatically track Inputs analytics +copperleaf.description=Automatic Input-tracking for any analytics SDK copperleaf.targets.android=true copperleaf.targets.jvm=true diff --git a/ballast-analytics/src/commonMain/kotlin/com/copperleaf/ballast/analytics/AnalyticsAdapter.kt b/ballast-analytics/src/commonMain/kotlin/com/copperleaf/ballast/analytics/AnalyticsAdapter.kt index 8c9418f7..d9db7e38 100644 --- a/ballast-analytics/src/commonMain/kotlin/com/copperleaf/ballast/analytics/AnalyticsAdapter.kt +++ b/ballast-analytics/src/commonMain/kotlin/com/copperleaf/ballast/analytics/AnalyticsAdapter.kt @@ -1,5 +1,7 @@ package com.copperleaf.ballast.analytics +import com.copperleaf.ballast.BallastEncoder + /** * An adapter for converting Inputs to data sent to an [AnalyticsTracker]. * @@ -22,5 +24,5 @@ public interface AnalyticsAdapter { * Get an identifier from the [input] for tracking an analytics event. Corresponds to `eventParameters` in * [AnalyticsTracker.trackAnalyticsEvent]. */ - public fun getEventParametersForInput(viewModelName: String, input: Inputs): Map + public fun getEventParametersForInput(viewModelName: String, input: Inputs, encoder: BallastEncoder): Map } diff --git a/ballast-analytics/src/commonMain/kotlin/com/copperleaf/ballast/analytics/AnalyticsInterceptor.kt b/ballast-analytics/src/commonMain/kotlin/com/copperleaf/ballast/analytics/AnalyticsInterceptor.kt index 4addc207..c9724df2 100644 --- a/ballast-analytics/src/commonMain/kotlin/com/copperleaf/ballast/analytics/AnalyticsInterceptor.kt +++ b/ballast-analytics/src/commonMain/kotlin/com/copperleaf/ballast/analytics/AnalyticsInterceptor.kt @@ -36,7 +36,7 @@ public class AnalyticsInterceptor( .onEach { input -> tracker.trackAnalyticsEvent( adapter.getEventIdForInput(input), - adapter.getEventParametersForInput(hostViewModelName, input), + adapter.getEventParametersForInput(hostViewModelName, input, encoder), ) } .collect() diff --git a/ballast-analytics/src/commonMain/kotlin/com/copperleaf/ballast/analytics/AnalyticsTracker.kt b/ballast-analytics/src/commonMain/kotlin/com/copperleaf/ballast/analytics/AnalyticsTracker.kt index 492bd40d..82748c0d 100644 --- a/ballast-analytics/src/commonMain/kotlin/com/copperleaf/ballast/analytics/AnalyticsTracker.kt +++ b/ballast-analytics/src/commonMain/kotlin/com/copperleaf/ballast/analytics/AnalyticsTracker.kt @@ -1,10 +1,9 @@ package com.copperleaf.ballast.analytics -public interface AnalyticsTracker { +public fun interface AnalyticsTracker { /** * Record an event with an analytics SDK. */ public fun trackAnalyticsEvent(eventId: String, eventParameters: Map) - } diff --git a/ballast-analytics/src/commonMain/kotlin/com/copperleaf/ballast/analytics/DefaultAnalyticsAdapter.kt b/ballast-analytics/src/commonMain/kotlin/com/copperleaf/ballast/analytics/DefaultAnalyticsAdapter.kt index 6fce0938..6ff59f01 100644 --- a/ballast-analytics/src/commonMain/kotlin/com/copperleaf/ballast/analytics/DefaultAnalyticsAdapter.kt +++ b/ballast-analytics/src/commonMain/kotlin/com/copperleaf/ballast/analytics/DefaultAnalyticsAdapter.kt @@ -1,27 +1,33 @@ package com.copperleaf.ballast.analytics +import com.copperleaf.ballast.BallastEncoder + /** * A default [AnalyticsAdapter] implementation that collects basic information about each Input and tracks them with * an `eventId` of "action". You must provide a `shouldTrackInput */ public class DefaultAnalyticsAdapter( - shouldTrackInput: (Inputs) -> Boolean, + shouldTrackInput: (Inputs) -> Boolean = { true }, ) : AnalyticsAdapter { - private val _shouldTrackInput: (Inputs) -> Boolean = shouldTrackInput + private val shouldTrackInputFn: (Inputs) -> Boolean = shouldTrackInput override fun shouldTrackInput(input: Inputs): Boolean { - return _shouldTrackInput(input) + return shouldTrackInputFn(input) } override fun getEventIdForInput(input: Inputs): String { return "action" } - override fun getEventParametersForInput(viewModelName: String, input: Inputs): Map { + override fun getEventParametersForInput( + viewModelName: String, + input: Inputs, + encoder: BallastEncoder + ): Map { return mapOf( Keys.ViewModelName to viewModelName, Keys.InputType to "$viewModelName.${input::class.simpleName}", - Keys.InputValue to "$viewModelName.$input", + Keys.InputValue to "$viewModelName.${encoder.encodeInputToString(input)}", ) } diff --git a/ballast-analytics/src/commonTest/kotlin/com/copperleaf/ballast/analytics/BallastAnalyticsTests.kt b/ballast-analytics/src/commonTest/kotlin/com/copperleaf/ballast/analytics/BallastAnalyticsTests.kt index 1e4ddf95..90f5dba5 100644 --- a/ballast-analytics/src/commonTest/kotlin/com/copperleaf/ballast/analytics/BallastAnalyticsTests.kt +++ b/ballast-analytics/src/commonTest/kotlin/com/copperleaf/ballast/analytics/BallastAnalyticsTests.kt @@ -1,5 +1,11 @@ package com.copperleaf.ballast.analytics +import com.copperleaf.ballast.BallastEncoder +import com.copperleaf.ballast.analytics.vm.TestContract +import com.copperleaf.ballast.analytics.vm.TestInputHandler +import com.copperleaf.ballast.core.FifoInputStrategy +import com.copperleaf.ballast.eventHandler +import com.copperleaf.ballast.test.viewModelTest import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals @@ -14,14 +20,159 @@ class BallastAnalyticsTests { ).toString() ) } -} -private class TestAnalyticsTracker : AnalyticsTracker { - override fun trackAnalyticsEvent(eventId: String, eventParameters: Map) { - TODO("Not yet implemented") + @Test + fun analyticsInterceptor_lambdaAdapter() = runTest { + viewModelTest( + inputHandler = TestInputHandler(), + eventHandler = eventHandler { }, + ) { + val trackedInputs = mutableListOf>>() + + defaultInputStrategy { FifoInputStrategy.typed() } + defaultInitialState { TestContract.State() } + addInterceptor { + AnalyticsInterceptor( + tracker = AnalyticsTracker { eventId, eventParameters -> + trackedInputs += eventId to eventParameters + }, + shouldTrackInput = { input -> + when (input) { + is TestContract.Inputs.TrackThis -> true + is TestContract.Inputs.DontTrackThis -> false + } + } + ) + } + + scenario("AnalyticsInterceptorTest") { + running { + +TestContract.Inputs.TrackThis + +TestContract.Inputs.DontTrackThis + } + resultsIn { + assertEquals( + actual = trackedInputs, + expected = listOf( + "action" to mapOf( + "ViewModelName" to "AnalyticsInterceptorTest", + "InputType" to "AnalyticsInterceptorTest.TrackThis", + "InputValue" to "AnalyticsInterceptorTest.TrackThis", + ) + ) + ) + } + } + } + } + + @Test + fun analyticsInterceptor_DefaultAnalyticsAdapter() = runTest { + viewModelTest( + inputHandler = TestInputHandler(), + eventHandler = eventHandler { }, + ) { + val trackedInputs = mutableListOf>>() + + defaultInputStrategy { FifoInputStrategy.typed() } + defaultInitialState { TestContract.State() } + addInterceptor { + AnalyticsInterceptor( + tracker = AnalyticsTracker { eventId, eventParameters -> + trackedInputs += eventId to eventParameters + }, + adapter = DefaultAnalyticsAdapter( + shouldTrackInput = { input -> + when (input) { + is TestContract.Inputs.TrackThis -> true + is TestContract.Inputs.DontTrackThis -> false + } + } + ), + ) + } + + scenario("AnalyticsInterceptorTest") { + running { + +TestContract.Inputs.TrackThis + +TestContract.Inputs.DontTrackThis + } + resultsIn { + assertEquals( + actual = trackedInputs, + expected = listOf( + "action" to mapOf( + "ViewModelName" to "AnalyticsInterceptorTest", + "InputType" to "AnalyticsInterceptorTest.TrackThis", + "InputValue" to "AnalyticsInterceptorTest.TrackThis", + ) + ) + ) + } + } + } } - override fun toString(): String { - return "TestAnalyticsTracker" + @Test + fun analyticsInterceptor_customAdapter() = runTest { + viewModelTest( + inputHandler = TestInputHandler(), + eventHandler = eventHandler { }, + ) { + val trackedInputs = mutableListOf>>() + + defaultInputStrategy { FifoInputStrategy.typed() } + defaultInitialState { TestContract.State() } + addInterceptor { + AnalyticsInterceptor( + tracker = AnalyticsTracker { eventId, eventParameters -> + trackedInputs += eventId to eventParameters + }, + adapter = object : AnalyticsAdapter { + override fun shouldTrackInput(input: TestContract.Inputs): Boolean { + return true + } + + override fun getEventIdForInput(input: TestContract.Inputs): String { + return input::class.simpleName ?: "" + } + + override fun getEventParametersForInput( + viewModelName: String, + input: TestContract.Inputs, + encoder: BallastEncoder + ): Map { + return emptyMap() + } + } + ) + } + + scenario("AnalyticsInterceptorTest") { + running { + +TestContract.Inputs.TrackThis + +TestContract.Inputs.DontTrackThis + } + resultsIn { + assertEquals( + actual = trackedInputs, + expected = listOf( + "TrackThis" to emptyMap(), + "DontTrackThis" to emptyMap(), + ) + ) + } + } + } + } + + private class TestAnalyticsTracker : AnalyticsTracker { + override fun trackAnalyticsEvent(eventId: String, eventParameters: Map) { + TODO("Not yet implemented") + } + + override fun toString(): String { + return "TestAnalyticsTracker" + } } } diff --git a/ballast-analytics/src/commonTest/kotlin/com/copperleaf/ballast/analytics/vm/TestContract.kt b/ballast-analytics/src/commonTest/kotlin/com/copperleaf/ballast/analytics/vm/TestContract.kt new file mode 100644 index 00000000..4e420e12 --- /dev/null +++ b/ballast-analytics/src/commonTest/kotlin/com/copperleaf/ballast/analytics/vm/TestContract.kt @@ -0,0 +1,14 @@ +package com.copperleaf.ballast.analytics.vm + +object TestContract { + data class State( + val loading: Boolean = false, + ) + + sealed interface Inputs { + data object TrackThis : Inputs + data object DontTrackThis : Inputs + } + + sealed interface Events +} diff --git a/ballast-analytics/src/commonTest/kotlin/com/copperleaf/ballast/analytics/vm/TestInputHandler.kt b/ballast-analytics/src/commonTest/kotlin/com/copperleaf/ballast/analytics/vm/TestInputHandler.kt new file mode 100644 index 00000000..f137e94c --- /dev/null +++ b/ballast-analytics/src/commonTest/kotlin/com/copperleaf/ballast/analytics/vm/TestInputHandler.kt @@ -0,0 +1,23 @@ +package com.copperleaf.ballast.analytics.vm + +import com.copperleaf.ballast.InputHandler +import com.copperleaf.ballast.InputHandlerScope + +class TestInputHandler : InputHandler< + TestContract.Inputs, + TestContract.Events, + TestContract.State> { + override suspend fun InputHandlerScope< + TestContract.Inputs, + TestContract.Events, + TestContract.State>.handleInput( + input: TestContract.Inputs + ): Unit = when (input) { + TestContract.Inputs.DontTrackThis -> { + noOp() + } + TestContract.Inputs.TrackThis -> { + noOp() + } + } +} diff --git a/ballast-analytics/src/commonTest/kotlin/com/copperleaf/ballast/analytics/vm/TestViewModel.kt b/ballast-analytics/src/commonTest/kotlin/com/copperleaf/ballast/analytics/vm/TestViewModel.kt new file mode 100644 index 00000000..37fabcac --- /dev/null +++ b/ballast-analytics/src/commonTest/kotlin/com/copperleaf/ballast/analytics/vm/TestViewModel.kt @@ -0,0 +1,47 @@ +package com.copperleaf.ballast.analytics.vm + +import com.copperleaf.ballast.BallastViewModelConfiguration +import com.copperleaf.ballast.analytics.AnalyticsInterceptor +import com.copperleaf.ballast.analytics.AnalyticsTracker +import com.copperleaf.ballast.analytics.DefaultAnalyticsAdapter +import com.copperleaf.ballast.build +import com.copperleaf.ballast.core.BasicViewModel +import com.copperleaf.ballast.eventHandler +import com.copperleaf.ballast.withViewModel +import kotlinx.coroutines.CoroutineScope + +class TestViewModel(coroutineScope: CoroutineScope) : BasicViewModel< + TestContract.Inputs, + TestContract.Events, + TestContract.State + >( + coroutineScope = coroutineScope, + config = BallastViewModelConfiguration.Builder() + .withViewModel(TestContract.State(), TestInputHandler()) + .apply { + interceptors += AnalyticsInterceptor( + tracker = TestAnalyticsTracker(), + + // implement AnalyticsAdapter for full control over the eventId and eventParameters passed to the Tracker + adapter = DefaultAnalyticsAdapter( + shouldTrackInput = { input -> + when (input) { + is TestContract.Inputs.TrackThis -> true + is TestContract.Inputs.DontTrackThis -> false + } + } + ) + ) + } + .build(), + eventHandler = eventHandler { }, +) + +class TestAnalyticsTracker : AnalyticsTracker { + override fun trackAnalyticsEvent( + eventId: String, + eventParameters: Map + ) { + // TODO: track this event to your analytics SDK + } +} diff --git a/ballast-api/README.md b/ballast-api/README.md new file mode 100644 index 00000000..85f2de8b --- /dev/null +++ b/ballast-api/README.md @@ -0,0 +1,53 @@ +# Ballast API + +## Overview + +These are the fundamental interfaces and internal implementations necessary to create and run a Ballast ViewModel. If +you're using Ballast ViewModels is an application, you probably should depend on [Ballast Core](./../ballast-core) +to get all the full functionality needed for your application. If you're building a library that uses or extends Ballast's +base functionality, this is the module you should depend on so you don't pull in unnecessary dependencies. + +## Supported Platforms + +| Platform | Supported | +|----------|-----------| +| JVM | ✅ | +| Android | ✅ | +| iOS | ✅ | +| JS | ✅ | +| WASM JS | ✅ | + +## See Also + +- [Ballast Core](./../ballast-core) + +## Usage + +`ballast-api` is not intended for direct use in application code. It contains the interfaces and core abstractions that +other Ballast modules and libraries build on. If you are using Ballast in an application, depend on +[Ballast Core](./../ballast-core) instead. If you are building a Ballast extension library or integration, depend on +`ballast-api` to avoid pulling in unnecessary platform-specific dependencies. + +## Installation + +```kotlin +repositories { + mavenCentral() +} + +// for plain JVM or Android projects +dependencies { + implementation("io.github.copper-leaf:ballast-api:{{ballastVersion}}") +} + +// for multiplatform projects +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + implementation("io.github.copper-leaf:ballast-api:{{ballastVersion}}") + } + } + } +} +``` diff --git a/ballast-api/api/android/ballast-api.api b/ballast-api/api/android/ballast-api.api index ab428ee9..989fa944 100644 --- a/ballast-api/api/android/ballast-api.api +++ b/ballast-api/api/android/ballast-api.api @@ -1,6 +1,23 @@ +public abstract interface class com/copperleaf/ballast/BallastDecoder { + public abstract fun decodeEventFromString (Ljava/lang/String;)Ljava/lang/Object; + public abstract fun decodeInputFromString (Ljava/lang/String;)Ljava/lang/Object; + public abstract fun decodeStateFromString (Ljava/lang/String;)Ljava/lang/Object; +} + public abstract interface annotation class com/copperleaf/ballast/BallastDsl : java/lang/annotation/Annotation { } +public abstract interface class com/copperleaf/ballast/BallastEncoder { + public abstract fun encodeEventToString (Ljava/lang/Object;)Ljava/lang/String; + public abstract fun encodeInputToString (Ljava/lang/Object;)Ljava/lang/String; + public abstract fun encodeStateToString (Ljava/lang/Object;)Ljava/lang/String; + public fun getContentType ()Ljava/lang/String; +} + +public final class com/copperleaf/ballast/BallastEncoder$DefaultImpls { + public static fun getContentType (Lcom/copperleaf/ballast/BallastEncoder;)Ljava/lang/String; +} + public abstract interface class com/copperleaf/ballast/BallastInterceptor { public fun getKey ()Lcom/copperleaf/ballast/BallastInterceptor$Key; public abstract fun start (Lcom/copperleaf/ballast/BallastInterceptorScope;Lkotlinx/coroutines/flow/Flow;)V @@ -14,6 +31,8 @@ public abstract interface class com/copperleaf/ballast/BallastInterceptor$Key { } public abstract interface class com/copperleaf/ballast/BallastInterceptorScope : kotlinx/coroutines/CoroutineScope { + public abstract fun getDecoder ()Lcom/copperleaf/ballast/BallastDecoder; + public abstract fun getEncoder ()Lcom/copperleaf/ballast/BallastEncoder; public abstract fun getHostViewModelName ()Ljava/lang/String; public abstract fun getHostViewModelType ()Ljava/lang/String; public abstract fun getInitialState ()Ljava/lang/Object; @@ -195,7 +214,8 @@ public abstract interface class com/copperleaf/ballast/BallastScopeFactory { public abstract fun createStateActor (Lcom/copperleaf/ballast/internal/BallastViewModelImpl;)Lcom/copperleaf/ballast/internal/actors/StateActor; } -public abstract interface class com/copperleaf/ballast/BallastViewModel { +public abstract interface class com/copperleaf/ballast/BallastViewModel : java/lang/AutoCloseable { + public abstract fun close ()V public abstract fun observeStates ()Lkotlinx/coroutines/flow/StateFlow; public abstract fun send (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun sendAndAwaitCompletion (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -203,6 +223,8 @@ public abstract interface class com/copperleaf/ballast/BallastViewModel { } public abstract interface class com/copperleaf/ballast/BallastViewModelConfiguration { + public abstract fun getDecoder ()Lcom/copperleaf/ballast/BallastDecoder; + public abstract fun getEncoder ()Lcom/copperleaf/ballast/BallastEncoder; public abstract fun getEventStrategy ()Lcom/copperleaf/ballast/EventStrategy; public abstract fun getEventsDispatcher ()Lkotlinx/coroutines/CoroutineDispatcher; public abstract fun getInitialState ()Ljava/lang/Object; @@ -213,17 +235,20 @@ public abstract interface class com/copperleaf/ballast/BallastViewModelConfigura public abstract fun getInterceptors ()Ljava/util/List; public abstract fun getLogger ()Lcom/copperleaf/ballast/BallastLogger; public abstract fun getName ()Ljava/lang/String; + public abstract fun getShutDownGracePeriod-UwyO8pc ()J public abstract fun getSideJobsDispatcher ()Lkotlinx/coroutines/CoroutineDispatcher; } public final class com/copperleaf/ballast/BallastViewModelConfiguration$Builder { - public fun ()V - public fun (Ljava/lang/String;Ljava/lang/Object;Lcom/copperleaf/ballast/InputHandler;Lcom/copperleaf/ballast/InputFilter;Ljava/util/List;Lcom/copperleaf/ballast/InputStrategy;Lcom/copperleaf/ballast/EventStrategy;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/jvm/functions/Function1;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/Object;Lcom/copperleaf/ballast/InputHandler;Lcom/copperleaf/ballast/InputFilter;Ljava/util/List;Lcom/copperleaf/ballast/InputStrategy;Lcom/copperleaf/ballast/EventStrategy;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/Object;Lcom/copperleaf/ballast/InputHandler;Lcom/copperleaf/ballast/InputFilter;Ljava/util/List;Lcom/copperleaf/ballast/InputStrategy;Lcom/copperleaf/ballast/EventStrategy;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/jvm/functions/Function1;Lcom/copperleaf/ballast/BallastEncoder;Lcom/copperleaf/ballast/BallastDecoder;JILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/Object;Lcom/copperleaf/ballast/InputHandler;Lcom/copperleaf/ballast/InputFilter;Ljava/util/List;Lcom/copperleaf/ballast/InputStrategy;Lcom/copperleaf/ballast/EventStrategy;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/jvm/functions/Function1;Lcom/copperleaf/ballast/BallastEncoder;Lcom/copperleaf/ballast/BallastDecoder;JLkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component10 ()Lkotlinx/coroutines/CoroutineDispatcher; public final fun component11 ()Lkotlinx/coroutines/CoroutineDispatcher; public final fun component12 ()Lkotlin/jvm/functions/Function1; + public final fun component13 ()Lcom/copperleaf/ballast/BallastEncoder; + public final fun component14 ()Lcom/copperleaf/ballast/BallastDecoder; + public final fun component15-UwyO8pc ()J public final fun component2 ()Ljava/lang/Object; public final fun component3 ()Lcom/copperleaf/ballast/InputHandler; public final fun component4 ()Lcom/copperleaf/ballast/InputFilter; @@ -232,9 +257,11 @@ public final class com/copperleaf/ballast/BallastViewModelConfiguration$Builder public final fun component7 ()Lcom/copperleaf/ballast/EventStrategy; public final fun component8 ()Lkotlinx/coroutines/CoroutineDispatcher; public final fun component9 ()Lkotlinx/coroutines/CoroutineDispatcher; - public final fun copy (Ljava/lang/String;Ljava/lang/Object;Lcom/copperleaf/ballast/InputHandler;Lcom/copperleaf/ballast/InputFilter;Ljava/util/List;Lcom/copperleaf/ballast/InputStrategy;Lcom/copperleaf/ballast/EventStrategy;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/jvm/functions/Function1;)Lcom/copperleaf/ballast/BallastViewModelConfiguration$Builder; - public static synthetic fun copy$default (Lcom/copperleaf/ballast/BallastViewModelConfiguration$Builder;Ljava/lang/String;Ljava/lang/Object;Lcom/copperleaf/ballast/InputHandler;Lcom/copperleaf/ballast/InputFilter;Ljava/util/List;Lcom/copperleaf/ballast/InputStrategy;Lcom/copperleaf/ballast/EventStrategy;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/copperleaf/ballast/BallastViewModelConfiguration$Builder; + public final fun copy-SNng-ko (Ljava/lang/String;Ljava/lang/Object;Lcom/copperleaf/ballast/InputHandler;Lcom/copperleaf/ballast/InputFilter;Ljava/util/List;Lcom/copperleaf/ballast/InputStrategy;Lcom/copperleaf/ballast/EventStrategy;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/jvm/functions/Function1;Lcom/copperleaf/ballast/BallastEncoder;Lcom/copperleaf/ballast/BallastDecoder;J)Lcom/copperleaf/ballast/BallastViewModelConfiguration$Builder; + public static synthetic fun copy-SNng-ko$default (Lcom/copperleaf/ballast/BallastViewModelConfiguration$Builder;Ljava/lang/String;Ljava/lang/Object;Lcom/copperleaf/ballast/InputHandler;Lcom/copperleaf/ballast/InputFilter;Ljava/util/List;Lcom/copperleaf/ballast/InputStrategy;Lcom/copperleaf/ballast/EventStrategy;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/jvm/functions/Function1;Lcom/copperleaf/ballast/BallastEncoder;Lcom/copperleaf/ballast/BallastDecoder;JILjava/lang/Object;)Lcom/copperleaf/ballast/BallastViewModelConfiguration$Builder; public fun equals (Ljava/lang/Object;)Z + public final fun getDecoder ()Lcom/copperleaf/ballast/BallastDecoder; + public final fun getEncoder ()Lcom/copperleaf/ballast/BallastEncoder; public final fun getEventStrategy ()Lcom/copperleaf/ballast/EventStrategy; public final fun getEventsDispatcher ()Lkotlinx/coroutines/CoroutineDispatcher; public final fun getFilter ()Lcom/copperleaf/ballast/InputFilter; @@ -246,8 +273,11 @@ public final class com/copperleaf/ballast/BallastViewModelConfiguration$Builder public final fun getInterceptors ()Ljava/util/List; public final fun getLogger ()Lkotlin/jvm/functions/Function1; public final fun getName ()Ljava/lang/String; + public final fun getShutDownGracePeriod-UwyO8pc ()J public final fun getSideJobsDispatcher ()Lkotlinx/coroutines/CoroutineDispatcher; public fun hashCode ()I + public final fun setDecoder (Lcom/copperleaf/ballast/BallastDecoder;)V + public final fun setEncoder (Lcom/copperleaf/ballast/BallastEncoder;)V public final fun setEventStrategy (Lcom/copperleaf/ballast/EventStrategy;)V public final fun setEventsDispatcher (Lkotlinx/coroutines/CoroutineDispatcher;)V public final fun setFilter (Lcom/copperleaf/ballast/InputFilter;)V @@ -263,10 +293,13 @@ public final class com/copperleaf/ballast/BallastViewModelConfiguration$Builder } public final class com/copperleaf/ballast/BallastViewModelConfiguration$TypedBuilder { - public fun (Ljava/lang/String;Ljava/lang/Object;Lcom/copperleaf/ballast/InputHandler;Ljava/util/List;Lcom/copperleaf/ballast/InputStrategy;Lcom/copperleaf/ballast/EventStrategy;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/jvm/functions/Function1;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/Object;Lcom/copperleaf/ballast/InputHandler;Ljava/util/List;Lcom/copperleaf/ballast/InputStrategy;Lcom/copperleaf/ballast/EventStrategy;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/jvm/functions/Function1;Lcom/copperleaf/ballast/BallastEncoder;Lcom/copperleaf/ballast/BallastDecoder;JLkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component10 ()Lkotlinx/coroutines/CoroutineDispatcher; public final fun component11 ()Lkotlin/jvm/functions/Function1; + public final fun component12 ()Lcom/copperleaf/ballast/BallastEncoder; + public final fun component13 ()Lcom/copperleaf/ballast/BallastDecoder; + public final fun component14-UwyO8pc ()J public final fun component2 ()Ljava/lang/Object; public final fun component3 ()Lcom/copperleaf/ballast/InputHandler; public final fun component4 ()Ljava/util/List; @@ -275,9 +308,11 @@ public final class com/copperleaf/ballast/BallastViewModelConfiguration$TypedBui public final fun component7 ()Lkotlinx/coroutines/CoroutineDispatcher; public final fun component8 ()Lkotlinx/coroutines/CoroutineDispatcher; public final fun component9 ()Lkotlinx/coroutines/CoroutineDispatcher; - public final fun copy (Ljava/lang/String;Ljava/lang/Object;Lcom/copperleaf/ballast/InputHandler;Ljava/util/List;Lcom/copperleaf/ballast/InputStrategy;Lcom/copperleaf/ballast/EventStrategy;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/jvm/functions/Function1;)Lcom/copperleaf/ballast/BallastViewModelConfiguration$TypedBuilder; - public static synthetic fun copy$default (Lcom/copperleaf/ballast/BallastViewModelConfiguration$TypedBuilder;Ljava/lang/String;Ljava/lang/Object;Lcom/copperleaf/ballast/InputHandler;Ljava/util/List;Lcom/copperleaf/ballast/InputStrategy;Lcom/copperleaf/ballast/EventStrategy;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/copperleaf/ballast/BallastViewModelConfiguration$TypedBuilder; + public final fun copy-9AGySmI (Ljava/lang/String;Ljava/lang/Object;Lcom/copperleaf/ballast/InputHandler;Ljava/util/List;Lcom/copperleaf/ballast/InputStrategy;Lcom/copperleaf/ballast/EventStrategy;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/jvm/functions/Function1;Lcom/copperleaf/ballast/BallastEncoder;Lcom/copperleaf/ballast/BallastDecoder;J)Lcom/copperleaf/ballast/BallastViewModelConfiguration$TypedBuilder; + public static synthetic fun copy-9AGySmI$default (Lcom/copperleaf/ballast/BallastViewModelConfiguration$TypedBuilder;Ljava/lang/String;Ljava/lang/Object;Lcom/copperleaf/ballast/InputHandler;Ljava/util/List;Lcom/copperleaf/ballast/InputStrategy;Lcom/copperleaf/ballast/EventStrategy;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/jvm/functions/Function1;Lcom/copperleaf/ballast/BallastEncoder;Lcom/copperleaf/ballast/BallastDecoder;JILjava/lang/Object;)Lcom/copperleaf/ballast/BallastViewModelConfiguration$TypedBuilder; public fun equals (Ljava/lang/Object;)Z + public final fun getDecoder ()Lcom/copperleaf/ballast/BallastDecoder; + public final fun getEncoder ()Lcom/copperleaf/ballast/BallastEncoder; public final fun getEventStrategy ()Lcom/copperleaf/ballast/EventStrategy; public final fun getEventsDispatcher ()Lkotlinx/coroutines/CoroutineDispatcher; public final fun getInitialState ()Ljava/lang/Object; @@ -288,8 +323,11 @@ public final class com/copperleaf/ballast/BallastViewModelConfiguration$TypedBui public final fun getInterceptors ()Ljava/util/List; public final fun getLogger ()Lkotlin/jvm/functions/Function1; public final fun getName ()Ljava/lang/String; + public final fun getShutDownGracePeriod-UwyO8pc ()J public final fun getSideJobsDispatcher ()Lkotlinx/coroutines/CoroutineDispatcher; public fun hashCode ()I + public final fun setDecoder (Lcom/copperleaf/ballast/BallastDecoder;)V + public final fun setEncoder (Lcom/copperleaf/ballast/BallastEncoder;)V public final fun setEventStrategy (Lcom/copperleaf/ballast/EventStrategy;)V public final fun setEventsDispatcher (Lkotlinx/coroutines/CoroutineDispatcher;)V public final fun setInitialState (Ljava/lang/Object;)V @@ -392,6 +430,7 @@ public final class com/copperleaf/ballast/InputStrategy$Guardian$DefaultImpls { public abstract interface class com/copperleaf/ballast/InputStrategyScope : kotlinx/coroutines/CoroutineScope { public abstract fun acceptQueued (Lcom/copperleaf/ballast/Queued;Lcom/copperleaf/ballast/InputStrategy$Guardian;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun acceptQueued (Lcom/copperleaf/ballast/Queued;Lcom/copperleaf/ballast/InputStrategy$Guardian;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun getCurrentState (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun getLogger ()Lcom/copperleaf/ballast/BallastLogger; public abstract fun rejectInput (Ljava/lang/Object;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -430,6 +469,7 @@ public abstract interface class com/copperleaf/ballast/SideJobScope : kotlinx/co public abstract fun getRestartState ()Lcom/copperleaf/ballast/SideJobScope$RestartState; public abstract fun postEvent (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun postInput (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun requestGracefulShutdown (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public final class com/copperleaf/ballast/SideJobScope$RestartState : java/lang/Enum { @@ -530,7 +570,9 @@ public class com/copperleaf/ballast/core/DefaultGuardian : com/copperleaf/ballas } public final class com/copperleaf/ballast/core/DefaultViewModelConfiguration : com/copperleaf/ballast/BallastViewModelConfiguration { - public fun (Ljava/lang/Object;Lcom/copperleaf/ballast/InputHandler;Ljava/util/List;Lcom/copperleaf/ballast/InputStrategy;Lcom/copperleaf/ballast/EventStrategy;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Ljava/lang/String;Lcom/copperleaf/ballast/BallastLogger;)V + public synthetic fun (Ljava/lang/Object;Lcom/copperleaf/ballast/InputHandler;Ljava/util/List;Lcom/copperleaf/ballast/InputStrategy;Lcom/copperleaf/ballast/EventStrategy;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Ljava/lang/String;Lcom/copperleaf/ballast/BallastLogger;Lcom/copperleaf/ballast/BallastEncoder;Lcom/copperleaf/ballast/BallastDecoder;JLkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun getDecoder ()Lcom/copperleaf/ballast/BallastDecoder; + public fun getEncoder ()Lcom/copperleaf/ballast/BallastEncoder; public fun getEventStrategy ()Lcom/copperleaf/ballast/EventStrategy; public fun getEventsDispatcher ()Lkotlinx/coroutines/CoroutineDispatcher; public fun getInitialState ()Ljava/lang/Object; @@ -541,6 +583,7 @@ public final class com/copperleaf/ballast/core/DefaultViewModelConfiguration : c public fun getInterceptors ()Ljava/util/List; public fun getLogger ()Lcom/copperleaf/ballast/BallastLogger; public fun getName ()Ljava/lang/String; + public fun getShutDownGracePeriod-UwyO8pc ()J public fun getSideJobsDispatcher ()Lkotlinx/coroutines/CoroutineDispatcher; } @@ -591,11 +634,22 @@ public final class com/copperleaf/ballast/core/ParallelInputStrategy$Guardian : public fun checkStateUpdate ()V } +public final class com/copperleaf/ballast/core/ToStringEncoder : com/copperleaf/ballast/BallastEncoder { + public fun ()V + public fun encodeEventToString (Ljava/lang/Object;)Ljava/lang/String; + public fun encodeInputToString (Ljava/lang/Object;)Ljava/lang/String; + public fun encodeStateToString (Ljava/lang/Object;)Ljava/lang/String; + public fun getContentType ()Ljava/lang/String; +} + public final class com/copperleaf/ballast/internal/BallastViewModelImpl : com/copperleaf/ballast/BallastViewModel, com/copperleaf/ballast/BallastViewModelConfiguration { public field viewModelScope Lkotlinx/coroutines/CoroutineScope; public fun (Ljava/lang/String;Lcom/copperleaf/ballast/BallastViewModelConfiguration;)V public final fun attachEventHandler (Lcom/copperleaf/ballast/EventHandler;Lkotlinx/coroutines/CoroutineScope;)V public static synthetic fun attachEventHandler$default (Lcom/copperleaf/ballast/internal/BallastViewModelImpl;Lcom/copperleaf/ballast/EventHandler;Lkotlinx/coroutines/CoroutineScope;ILjava/lang/Object;)V + public fun close ()V + public fun getDecoder ()Lcom/copperleaf/ballast/BallastDecoder; + public fun getEncoder ()Lcom/copperleaf/ballast/BallastEncoder; public final fun getEventActor ()Lcom/copperleaf/ballast/internal/actors/EventActor; public fun getEventStrategy ()Lcom/copperleaf/ballast/EventStrategy; public fun getEventsDispatcher ()Lkotlinx/coroutines/CoroutineDispatcher; @@ -609,9 +663,11 @@ public final class com/copperleaf/ballast/internal/BallastViewModelImpl : com/co public fun getInterceptors ()Ljava/util/List; public fun getLogger ()Lcom/copperleaf/ballast/BallastLogger; public fun getName ()Ljava/lang/String; + public fun getShutDownGracePeriod-UwyO8pc ()J public final fun getSideJobActor ()Lcom/copperleaf/ballast/internal/actors/SideJobActor; public fun getSideJobsDispatcher ()Lkotlinx/coroutines/CoroutineDispatcher; public final fun getStateActor ()Lcom/copperleaf/ballast/internal/actors/StateActor; + public final fun getType ()Ljava/lang/String; public final fun getViewModelScope ()Lkotlinx/coroutines/CoroutineScope; public fun observeStates ()Lkotlinx/coroutines/flow/StateFlow; public fun send (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -714,10 +770,14 @@ public final class com/copperleaf/ballast/internal/actors/EventActor { public final class com/copperleaf/ballast/internal/actors/InputActor { public fun (Lcom/copperleaf/ballast/internal/BallastViewModelImpl;Lcom/copperleaf/ballast/BallastScopeFactory;)V + public final fun enqueueQueued (Lcom/copperleaf/ballast/Queued;ZLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun safelyHandleQueued (Lcom/copperleaf/ballast/Queued;Lcom/copperleaf/ballast/InputStrategy$Guardian;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public final class com/copperleaf/ballast/internal/actors/InterceptorActor { public fun (Lcom/copperleaf/ballast/internal/BallastViewModelImpl;Lcom/copperleaf/ballast/BallastScopeFactory;)V + public final fun getInterceptor (Lcom/copperleaf/ballast/BallastInterceptor$Key;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun notify (Lcom/copperleaf/ballast/BallastNotification;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public final class com/copperleaf/ballast/internal/actors/SideJobActor { @@ -750,6 +810,7 @@ public class com/copperleaf/ballast/internal/scopes/DefaultBallastScopeFactory : public final class com/copperleaf/ballast/internal/scopes/InputStrategyScopeImpl : com/copperleaf/ballast/InputStrategyScope, kotlinx/coroutines/CoroutineScope { public fun (Lkotlinx/coroutines/CoroutineScope;Lcom/copperleaf/ballast/BallastLogger;Ljava/lang/String;Ljava/lang/String;Lcom/copperleaf/ballast/internal/actors/InputActor;Lcom/copperleaf/ballast/internal/actors/StateActor;Lcom/copperleaf/ballast/internal/actors/InterceptorActor;)V public fun acceptQueued (Lcom/copperleaf/ballast/Queued;Lcom/copperleaf/ballast/InputStrategy$Guardian;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun acceptQueued (Lcom/copperleaf/ballast/Queued;Lcom/copperleaf/ballast/InputStrategy$Guardian;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun getCoroutineContext ()Lkotlin/coroutines/CoroutineContext; public fun getCurrentState (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun getLogger ()Lcom/copperleaf/ballast/BallastLogger; diff --git a/ballast-api/api/jvm/ballast-api.api b/ballast-api/api/jvm/ballast-api.api index ab428ee9..989fa944 100644 --- a/ballast-api/api/jvm/ballast-api.api +++ b/ballast-api/api/jvm/ballast-api.api @@ -1,6 +1,23 @@ +public abstract interface class com/copperleaf/ballast/BallastDecoder { + public abstract fun decodeEventFromString (Ljava/lang/String;)Ljava/lang/Object; + public abstract fun decodeInputFromString (Ljava/lang/String;)Ljava/lang/Object; + public abstract fun decodeStateFromString (Ljava/lang/String;)Ljava/lang/Object; +} + public abstract interface annotation class com/copperleaf/ballast/BallastDsl : java/lang/annotation/Annotation { } +public abstract interface class com/copperleaf/ballast/BallastEncoder { + public abstract fun encodeEventToString (Ljava/lang/Object;)Ljava/lang/String; + public abstract fun encodeInputToString (Ljava/lang/Object;)Ljava/lang/String; + public abstract fun encodeStateToString (Ljava/lang/Object;)Ljava/lang/String; + public fun getContentType ()Ljava/lang/String; +} + +public final class com/copperleaf/ballast/BallastEncoder$DefaultImpls { + public static fun getContentType (Lcom/copperleaf/ballast/BallastEncoder;)Ljava/lang/String; +} + public abstract interface class com/copperleaf/ballast/BallastInterceptor { public fun getKey ()Lcom/copperleaf/ballast/BallastInterceptor$Key; public abstract fun start (Lcom/copperleaf/ballast/BallastInterceptorScope;Lkotlinx/coroutines/flow/Flow;)V @@ -14,6 +31,8 @@ public abstract interface class com/copperleaf/ballast/BallastInterceptor$Key { } public abstract interface class com/copperleaf/ballast/BallastInterceptorScope : kotlinx/coroutines/CoroutineScope { + public abstract fun getDecoder ()Lcom/copperleaf/ballast/BallastDecoder; + public abstract fun getEncoder ()Lcom/copperleaf/ballast/BallastEncoder; public abstract fun getHostViewModelName ()Ljava/lang/String; public abstract fun getHostViewModelType ()Ljava/lang/String; public abstract fun getInitialState ()Ljava/lang/Object; @@ -195,7 +214,8 @@ public abstract interface class com/copperleaf/ballast/BallastScopeFactory { public abstract fun createStateActor (Lcom/copperleaf/ballast/internal/BallastViewModelImpl;)Lcom/copperleaf/ballast/internal/actors/StateActor; } -public abstract interface class com/copperleaf/ballast/BallastViewModel { +public abstract interface class com/copperleaf/ballast/BallastViewModel : java/lang/AutoCloseable { + public abstract fun close ()V public abstract fun observeStates ()Lkotlinx/coroutines/flow/StateFlow; public abstract fun send (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun sendAndAwaitCompletion (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -203,6 +223,8 @@ public abstract interface class com/copperleaf/ballast/BallastViewModel { } public abstract interface class com/copperleaf/ballast/BallastViewModelConfiguration { + public abstract fun getDecoder ()Lcom/copperleaf/ballast/BallastDecoder; + public abstract fun getEncoder ()Lcom/copperleaf/ballast/BallastEncoder; public abstract fun getEventStrategy ()Lcom/copperleaf/ballast/EventStrategy; public abstract fun getEventsDispatcher ()Lkotlinx/coroutines/CoroutineDispatcher; public abstract fun getInitialState ()Ljava/lang/Object; @@ -213,17 +235,20 @@ public abstract interface class com/copperleaf/ballast/BallastViewModelConfigura public abstract fun getInterceptors ()Ljava/util/List; public abstract fun getLogger ()Lcom/copperleaf/ballast/BallastLogger; public abstract fun getName ()Ljava/lang/String; + public abstract fun getShutDownGracePeriod-UwyO8pc ()J public abstract fun getSideJobsDispatcher ()Lkotlinx/coroutines/CoroutineDispatcher; } public final class com/copperleaf/ballast/BallastViewModelConfiguration$Builder { - public fun ()V - public fun (Ljava/lang/String;Ljava/lang/Object;Lcom/copperleaf/ballast/InputHandler;Lcom/copperleaf/ballast/InputFilter;Ljava/util/List;Lcom/copperleaf/ballast/InputStrategy;Lcom/copperleaf/ballast/EventStrategy;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/jvm/functions/Function1;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/Object;Lcom/copperleaf/ballast/InputHandler;Lcom/copperleaf/ballast/InputFilter;Ljava/util/List;Lcom/copperleaf/ballast/InputStrategy;Lcom/copperleaf/ballast/EventStrategy;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/Object;Lcom/copperleaf/ballast/InputHandler;Lcom/copperleaf/ballast/InputFilter;Ljava/util/List;Lcom/copperleaf/ballast/InputStrategy;Lcom/copperleaf/ballast/EventStrategy;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/jvm/functions/Function1;Lcom/copperleaf/ballast/BallastEncoder;Lcom/copperleaf/ballast/BallastDecoder;JILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/Object;Lcom/copperleaf/ballast/InputHandler;Lcom/copperleaf/ballast/InputFilter;Ljava/util/List;Lcom/copperleaf/ballast/InputStrategy;Lcom/copperleaf/ballast/EventStrategy;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/jvm/functions/Function1;Lcom/copperleaf/ballast/BallastEncoder;Lcom/copperleaf/ballast/BallastDecoder;JLkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component10 ()Lkotlinx/coroutines/CoroutineDispatcher; public final fun component11 ()Lkotlinx/coroutines/CoroutineDispatcher; public final fun component12 ()Lkotlin/jvm/functions/Function1; + public final fun component13 ()Lcom/copperleaf/ballast/BallastEncoder; + public final fun component14 ()Lcom/copperleaf/ballast/BallastDecoder; + public final fun component15-UwyO8pc ()J public final fun component2 ()Ljava/lang/Object; public final fun component3 ()Lcom/copperleaf/ballast/InputHandler; public final fun component4 ()Lcom/copperleaf/ballast/InputFilter; @@ -232,9 +257,11 @@ public final class com/copperleaf/ballast/BallastViewModelConfiguration$Builder public final fun component7 ()Lcom/copperleaf/ballast/EventStrategy; public final fun component8 ()Lkotlinx/coroutines/CoroutineDispatcher; public final fun component9 ()Lkotlinx/coroutines/CoroutineDispatcher; - public final fun copy (Ljava/lang/String;Ljava/lang/Object;Lcom/copperleaf/ballast/InputHandler;Lcom/copperleaf/ballast/InputFilter;Ljava/util/List;Lcom/copperleaf/ballast/InputStrategy;Lcom/copperleaf/ballast/EventStrategy;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/jvm/functions/Function1;)Lcom/copperleaf/ballast/BallastViewModelConfiguration$Builder; - public static synthetic fun copy$default (Lcom/copperleaf/ballast/BallastViewModelConfiguration$Builder;Ljava/lang/String;Ljava/lang/Object;Lcom/copperleaf/ballast/InputHandler;Lcom/copperleaf/ballast/InputFilter;Ljava/util/List;Lcom/copperleaf/ballast/InputStrategy;Lcom/copperleaf/ballast/EventStrategy;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/copperleaf/ballast/BallastViewModelConfiguration$Builder; + public final fun copy-SNng-ko (Ljava/lang/String;Ljava/lang/Object;Lcom/copperleaf/ballast/InputHandler;Lcom/copperleaf/ballast/InputFilter;Ljava/util/List;Lcom/copperleaf/ballast/InputStrategy;Lcom/copperleaf/ballast/EventStrategy;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/jvm/functions/Function1;Lcom/copperleaf/ballast/BallastEncoder;Lcom/copperleaf/ballast/BallastDecoder;J)Lcom/copperleaf/ballast/BallastViewModelConfiguration$Builder; + public static synthetic fun copy-SNng-ko$default (Lcom/copperleaf/ballast/BallastViewModelConfiguration$Builder;Ljava/lang/String;Ljava/lang/Object;Lcom/copperleaf/ballast/InputHandler;Lcom/copperleaf/ballast/InputFilter;Ljava/util/List;Lcom/copperleaf/ballast/InputStrategy;Lcom/copperleaf/ballast/EventStrategy;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/jvm/functions/Function1;Lcom/copperleaf/ballast/BallastEncoder;Lcom/copperleaf/ballast/BallastDecoder;JILjava/lang/Object;)Lcom/copperleaf/ballast/BallastViewModelConfiguration$Builder; public fun equals (Ljava/lang/Object;)Z + public final fun getDecoder ()Lcom/copperleaf/ballast/BallastDecoder; + public final fun getEncoder ()Lcom/copperleaf/ballast/BallastEncoder; public final fun getEventStrategy ()Lcom/copperleaf/ballast/EventStrategy; public final fun getEventsDispatcher ()Lkotlinx/coroutines/CoroutineDispatcher; public final fun getFilter ()Lcom/copperleaf/ballast/InputFilter; @@ -246,8 +273,11 @@ public final class com/copperleaf/ballast/BallastViewModelConfiguration$Builder public final fun getInterceptors ()Ljava/util/List; public final fun getLogger ()Lkotlin/jvm/functions/Function1; public final fun getName ()Ljava/lang/String; + public final fun getShutDownGracePeriod-UwyO8pc ()J public final fun getSideJobsDispatcher ()Lkotlinx/coroutines/CoroutineDispatcher; public fun hashCode ()I + public final fun setDecoder (Lcom/copperleaf/ballast/BallastDecoder;)V + public final fun setEncoder (Lcom/copperleaf/ballast/BallastEncoder;)V public final fun setEventStrategy (Lcom/copperleaf/ballast/EventStrategy;)V public final fun setEventsDispatcher (Lkotlinx/coroutines/CoroutineDispatcher;)V public final fun setFilter (Lcom/copperleaf/ballast/InputFilter;)V @@ -263,10 +293,13 @@ public final class com/copperleaf/ballast/BallastViewModelConfiguration$Builder } public final class com/copperleaf/ballast/BallastViewModelConfiguration$TypedBuilder { - public fun (Ljava/lang/String;Ljava/lang/Object;Lcom/copperleaf/ballast/InputHandler;Ljava/util/List;Lcom/copperleaf/ballast/InputStrategy;Lcom/copperleaf/ballast/EventStrategy;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/jvm/functions/Function1;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/Object;Lcom/copperleaf/ballast/InputHandler;Ljava/util/List;Lcom/copperleaf/ballast/InputStrategy;Lcom/copperleaf/ballast/EventStrategy;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/jvm/functions/Function1;Lcom/copperleaf/ballast/BallastEncoder;Lcom/copperleaf/ballast/BallastDecoder;JLkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component10 ()Lkotlinx/coroutines/CoroutineDispatcher; public final fun component11 ()Lkotlin/jvm/functions/Function1; + public final fun component12 ()Lcom/copperleaf/ballast/BallastEncoder; + public final fun component13 ()Lcom/copperleaf/ballast/BallastDecoder; + public final fun component14-UwyO8pc ()J public final fun component2 ()Ljava/lang/Object; public final fun component3 ()Lcom/copperleaf/ballast/InputHandler; public final fun component4 ()Ljava/util/List; @@ -275,9 +308,11 @@ public final class com/copperleaf/ballast/BallastViewModelConfiguration$TypedBui public final fun component7 ()Lkotlinx/coroutines/CoroutineDispatcher; public final fun component8 ()Lkotlinx/coroutines/CoroutineDispatcher; public final fun component9 ()Lkotlinx/coroutines/CoroutineDispatcher; - public final fun copy (Ljava/lang/String;Ljava/lang/Object;Lcom/copperleaf/ballast/InputHandler;Ljava/util/List;Lcom/copperleaf/ballast/InputStrategy;Lcom/copperleaf/ballast/EventStrategy;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/jvm/functions/Function1;)Lcom/copperleaf/ballast/BallastViewModelConfiguration$TypedBuilder; - public static synthetic fun copy$default (Lcom/copperleaf/ballast/BallastViewModelConfiguration$TypedBuilder;Ljava/lang/String;Ljava/lang/Object;Lcom/copperleaf/ballast/InputHandler;Ljava/util/List;Lcom/copperleaf/ballast/InputStrategy;Lcom/copperleaf/ballast/EventStrategy;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/copperleaf/ballast/BallastViewModelConfiguration$TypedBuilder; + public final fun copy-9AGySmI (Ljava/lang/String;Ljava/lang/Object;Lcom/copperleaf/ballast/InputHandler;Ljava/util/List;Lcom/copperleaf/ballast/InputStrategy;Lcom/copperleaf/ballast/EventStrategy;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/jvm/functions/Function1;Lcom/copperleaf/ballast/BallastEncoder;Lcom/copperleaf/ballast/BallastDecoder;J)Lcom/copperleaf/ballast/BallastViewModelConfiguration$TypedBuilder; + public static synthetic fun copy-9AGySmI$default (Lcom/copperleaf/ballast/BallastViewModelConfiguration$TypedBuilder;Ljava/lang/String;Ljava/lang/Object;Lcom/copperleaf/ballast/InputHandler;Ljava/util/List;Lcom/copperleaf/ballast/InputStrategy;Lcom/copperleaf/ballast/EventStrategy;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/jvm/functions/Function1;Lcom/copperleaf/ballast/BallastEncoder;Lcom/copperleaf/ballast/BallastDecoder;JILjava/lang/Object;)Lcom/copperleaf/ballast/BallastViewModelConfiguration$TypedBuilder; public fun equals (Ljava/lang/Object;)Z + public final fun getDecoder ()Lcom/copperleaf/ballast/BallastDecoder; + public final fun getEncoder ()Lcom/copperleaf/ballast/BallastEncoder; public final fun getEventStrategy ()Lcom/copperleaf/ballast/EventStrategy; public final fun getEventsDispatcher ()Lkotlinx/coroutines/CoroutineDispatcher; public final fun getInitialState ()Ljava/lang/Object; @@ -288,8 +323,11 @@ public final class com/copperleaf/ballast/BallastViewModelConfiguration$TypedBui public final fun getInterceptors ()Ljava/util/List; public final fun getLogger ()Lkotlin/jvm/functions/Function1; public final fun getName ()Ljava/lang/String; + public final fun getShutDownGracePeriod-UwyO8pc ()J public final fun getSideJobsDispatcher ()Lkotlinx/coroutines/CoroutineDispatcher; public fun hashCode ()I + public final fun setDecoder (Lcom/copperleaf/ballast/BallastDecoder;)V + public final fun setEncoder (Lcom/copperleaf/ballast/BallastEncoder;)V public final fun setEventStrategy (Lcom/copperleaf/ballast/EventStrategy;)V public final fun setEventsDispatcher (Lkotlinx/coroutines/CoroutineDispatcher;)V public final fun setInitialState (Ljava/lang/Object;)V @@ -392,6 +430,7 @@ public final class com/copperleaf/ballast/InputStrategy$Guardian$DefaultImpls { public abstract interface class com/copperleaf/ballast/InputStrategyScope : kotlinx/coroutines/CoroutineScope { public abstract fun acceptQueued (Lcom/copperleaf/ballast/Queued;Lcom/copperleaf/ballast/InputStrategy$Guardian;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun acceptQueued (Lcom/copperleaf/ballast/Queued;Lcom/copperleaf/ballast/InputStrategy$Guardian;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun getCurrentState (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun getLogger ()Lcom/copperleaf/ballast/BallastLogger; public abstract fun rejectInput (Ljava/lang/Object;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -430,6 +469,7 @@ public abstract interface class com/copperleaf/ballast/SideJobScope : kotlinx/co public abstract fun getRestartState ()Lcom/copperleaf/ballast/SideJobScope$RestartState; public abstract fun postEvent (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun postInput (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun requestGracefulShutdown (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public final class com/copperleaf/ballast/SideJobScope$RestartState : java/lang/Enum { @@ -530,7 +570,9 @@ public class com/copperleaf/ballast/core/DefaultGuardian : com/copperleaf/ballas } public final class com/copperleaf/ballast/core/DefaultViewModelConfiguration : com/copperleaf/ballast/BallastViewModelConfiguration { - public fun (Ljava/lang/Object;Lcom/copperleaf/ballast/InputHandler;Ljava/util/List;Lcom/copperleaf/ballast/InputStrategy;Lcom/copperleaf/ballast/EventStrategy;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Ljava/lang/String;Lcom/copperleaf/ballast/BallastLogger;)V + public synthetic fun (Ljava/lang/Object;Lcom/copperleaf/ballast/InputHandler;Ljava/util/List;Lcom/copperleaf/ballast/InputStrategy;Lcom/copperleaf/ballast/EventStrategy;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;Ljava/lang/String;Lcom/copperleaf/ballast/BallastLogger;Lcom/copperleaf/ballast/BallastEncoder;Lcom/copperleaf/ballast/BallastDecoder;JLkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun getDecoder ()Lcom/copperleaf/ballast/BallastDecoder; + public fun getEncoder ()Lcom/copperleaf/ballast/BallastEncoder; public fun getEventStrategy ()Lcom/copperleaf/ballast/EventStrategy; public fun getEventsDispatcher ()Lkotlinx/coroutines/CoroutineDispatcher; public fun getInitialState ()Ljava/lang/Object; @@ -541,6 +583,7 @@ public final class com/copperleaf/ballast/core/DefaultViewModelConfiguration : c public fun getInterceptors ()Ljava/util/List; public fun getLogger ()Lcom/copperleaf/ballast/BallastLogger; public fun getName ()Ljava/lang/String; + public fun getShutDownGracePeriod-UwyO8pc ()J public fun getSideJobsDispatcher ()Lkotlinx/coroutines/CoroutineDispatcher; } @@ -591,11 +634,22 @@ public final class com/copperleaf/ballast/core/ParallelInputStrategy$Guardian : public fun checkStateUpdate ()V } +public final class com/copperleaf/ballast/core/ToStringEncoder : com/copperleaf/ballast/BallastEncoder { + public fun ()V + public fun encodeEventToString (Ljava/lang/Object;)Ljava/lang/String; + public fun encodeInputToString (Ljava/lang/Object;)Ljava/lang/String; + public fun encodeStateToString (Ljava/lang/Object;)Ljava/lang/String; + public fun getContentType ()Ljava/lang/String; +} + public final class com/copperleaf/ballast/internal/BallastViewModelImpl : com/copperleaf/ballast/BallastViewModel, com/copperleaf/ballast/BallastViewModelConfiguration { public field viewModelScope Lkotlinx/coroutines/CoroutineScope; public fun (Ljava/lang/String;Lcom/copperleaf/ballast/BallastViewModelConfiguration;)V public final fun attachEventHandler (Lcom/copperleaf/ballast/EventHandler;Lkotlinx/coroutines/CoroutineScope;)V public static synthetic fun attachEventHandler$default (Lcom/copperleaf/ballast/internal/BallastViewModelImpl;Lcom/copperleaf/ballast/EventHandler;Lkotlinx/coroutines/CoroutineScope;ILjava/lang/Object;)V + public fun close ()V + public fun getDecoder ()Lcom/copperleaf/ballast/BallastDecoder; + public fun getEncoder ()Lcom/copperleaf/ballast/BallastEncoder; public final fun getEventActor ()Lcom/copperleaf/ballast/internal/actors/EventActor; public fun getEventStrategy ()Lcom/copperleaf/ballast/EventStrategy; public fun getEventsDispatcher ()Lkotlinx/coroutines/CoroutineDispatcher; @@ -609,9 +663,11 @@ public final class com/copperleaf/ballast/internal/BallastViewModelImpl : com/co public fun getInterceptors ()Ljava/util/List; public fun getLogger ()Lcom/copperleaf/ballast/BallastLogger; public fun getName ()Ljava/lang/String; + public fun getShutDownGracePeriod-UwyO8pc ()J public final fun getSideJobActor ()Lcom/copperleaf/ballast/internal/actors/SideJobActor; public fun getSideJobsDispatcher ()Lkotlinx/coroutines/CoroutineDispatcher; public final fun getStateActor ()Lcom/copperleaf/ballast/internal/actors/StateActor; + public final fun getType ()Ljava/lang/String; public final fun getViewModelScope ()Lkotlinx/coroutines/CoroutineScope; public fun observeStates ()Lkotlinx/coroutines/flow/StateFlow; public fun send (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -714,10 +770,14 @@ public final class com/copperleaf/ballast/internal/actors/EventActor { public final class com/copperleaf/ballast/internal/actors/InputActor { public fun (Lcom/copperleaf/ballast/internal/BallastViewModelImpl;Lcom/copperleaf/ballast/BallastScopeFactory;)V + public final fun enqueueQueued (Lcom/copperleaf/ballast/Queued;ZLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun safelyHandleQueued (Lcom/copperleaf/ballast/Queued;Lcom/copperleaf/ballast/InputStrategy$Guardian;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public final class com/copperleaf/ballast/internal/actors/InterceptorActor { public fun (Lcom/copperleaf/ballast/internal/BallastViewModelImpl;Lcom/copperleaf/ballast/BallastScopeFactory;)V + public final fun getInterceptor (Lcom/copperleaf/ballast/BallastInterceptor$Key;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun notify (Lcom/copperleaf/ballast/BallastNotification;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public final class com/copperleaf/ballast/internal/actors/SideJobActor { @@ -750,6 +810,7 @@ public class com/copperleaf/ballast/internal/scopes/DefaultBallastScopeFactory : public final class com/copperleaf/ballast/internal/scopes/InputStrategyScopeImpl : com/copperleaf/ballast/InputStrategyScope, kotlinx/coroutines/CoroutineScope { public fun (Lkotlinx/coroutines/CoroutineScope;Lcom/copperleaf/ballast/BallastLogger;Ljava/lang/String;Ljava/lang/String;Lcom/copperleaf/ballast/internal/actors/InputActor;Lcom/copperleaf/ballast/internal/actors/StateActor;Lcom/copperleaf/ballast/internal/actors/InterceptorActor;)V public fun acceptQueued (Lcom/copperleaf/ballast/Queued;Lcom/copperleaf/ballast/InputStrategy$Guardian;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun acceptQueued (Lcom/copperleaf/ballast/Queued;Lcom/copperleaf/ballast/InputStrategy$Guardian;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun getCoroutineContext ()Lkotlin/coroutines/CoroutineContext; public fun getCurrentState (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun getLogger ()Lcom/copperleaf/ballast/BallastLogger; diff --git a/ballast-api/build.gradle.kts b/ballast-api/build.gradle.kts index 12526052..9ca18a82 100644 --- a/ballast-api/build.gradle.kts +++ b/ballast-api/build.gradle.kts @@ -3,7 +3,7 @@ plugins { id("copper-leaf-android-library") id("copper-leaf-targets") id("copper-leaf-tests") -// id("copper-leaf-lint") + id("copper-leaf-lint") id("copper-leaf-publish") } diff --git a/ballast-api/gradle.properties b/ballast-api/gradle.properties index 34743931..c25c3240 100644 --- a/ballast-api/gradle.properties +++ b/ballast-api/gradle.properties @@ -1,4 +1,4 @@ -copperleaf.description=Opinionated Application State Management framework for Kotlin Multiplatform +copperleaf.description=Fundamental interfaces and internal implementations necessary to create and run a Ballast ViewModel copperleaf.targets.android=true copperleaf.targets.jvm=true diff --git a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/BallastDecoder.kt b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/BallastDecoder.kt new file mode 100644 index 00000000..8f5320a5 --- /dev/null +++ b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/BallastDecoder.kt @@ -0,0 +1,8 @@ +package com.copperleaf.ballast + +public interface BallastDecoder { + + public fun decodeInputFromString(encoded: String): Inputs + public fun decodeEventFromString(encoded: String): Events + public fun decodeStateFromString(encoded: String): State +} diff --git a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/BallastEncoder.kt b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/BallastEncoder.kt new file mode 100644 index 00000000..d53de837 --- /dev/null +++ b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/BallastEncoder.kt @@ -0,0 +1,10 @@ +package com.copperleaf.ballast + +public interface BallastEncoder { + + public val contentType: String? get() = null + + public fun encodeInputToString(input: Inputs): String + public fun encodeEventToString(event: Events): String + public fun encodeStateToString(state: State): String +} diff --git a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/BallastInterceptor.kt b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/BallastInterceptor.kt index f6dc63e6..2ce5383d 100644 --- a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/BallastInterceptor.kt +++ b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/BallastInterceptor.kt @@ -6,7 +6,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.launch /** * The entry-point for attaching additional functionality to a ViewModel. As Inputs or other features get processed @@ -67,5 +66,5 @@ public interface BallastInterceptor { * } * ``` */ - public interface Key> + public interface Key> } diff --git a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/BallastInterceptorScope.kt b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/BallastInterceptorScope.kt index e86f71c9..ea8951be 100644 --- a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/BallastInterceptorScope.kt +++ b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/BallastInterceptorScope.kt @@ -33,6 +33,16 @@ public interface BallastInterceptorScope + + /** + * The decoder set in the [BallastViewModelConfiguration.decoder]. + */ + public val decoder: BallastDecoder? + /** * Send a [Queued] object back to the ViewModel to be processed. These items are queued just the same as if they * were sent to the ViewModel by something else through [BallastViewModel.send], @@ -49,5 +59,4 @@ public interface BallastInterceptorScope { interceptorCoroutineScope: CoroutineScope, ): BallastInterceptorScope - public fun createEventHandlerScope( - ): InternalEventHandlerScope + public fun createEventHandlerScope(): InternalEventHandlerScope public fun createEventStrategyScope( eventHandler: EventHandler, diff --git a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/BallastViewModel.kt b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/BallastViewModel.kt index 2e846264..308be407 100644 --- a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/BallastViewModel.kt +++ b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/BallastViewModel.kt @@ -18,7 +18,7 @@ import kotlinx.coroutines.flow.StateFlow * Practically-speaking, those platform-specific ViewModels just wrap an instance of [BallastViewModelImpl], which does * the actual work of implementing the pattern, and delegates all its internal calls to that internal implementation. */ -public interface BallastViewModel { +public interface BallastViewModel : AutoCloseable { /** * Observe the flow of states from this ViewModel @@ -43,4 +43,12 @@ public interface BallastViewModel { * has finished processing completely. */ public suspend fun sendAndAwaitCompletion(element: Inputs) + + /** + * Closes this Viewmodel gracefully, allowing a short grace period for any in-flight work to complete before being + * completely terminated. By closing this ViewModel, you are given no guarantee that it will be able to accept + * any more Inputs after this call returns. But it will do it's best to drain the current queue and allow all + * previously enqueued Inputs the chance to be processed. + */ + override fun close() } diff --git a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/BallastViewModelConfiguration.kt b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/BallastViewModelConfiguration.kt index 6d5b0238..8f870783 100644 --- a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/BallastViewModelConfiguration.kt +++ b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/BallastViewModelConfiguration.kt @@ -3,8 +3,11 @@ package com.copperleaf.ballast import com.copperleaf.ballast.core.BufferedEventStrategy import com.copperleaf.ballast.core.LifoInputStrategy import com.copperleaf.ballast.core.NoOpLogger +import com.copperleaf.ballast.core.ToStringEncoder import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds /** * This class collects all the configurable properties of a [BallastViewModel]. @@ -26,6 +29,11 @@ public interface BallastViewModelConfiguration + public val decoder: BallastDecoder? + + public val shutDownGracePeriod: Duration + public data class Builder( public var name: String? = null, public var initialState: Any? = null, @@ -43,6 +51,11 @@ public interface BallastViewModelConfiguration BallastLogger = { NoOpLogger() }, + + public var encoder: BallastEncoder<*, *, *> = ToStringEncoder(), + public var decoder: BallastDecoder<*, *, *>? = null, + + public val shutDownGracePeriod: Duration = 10.seconds, ) public data class TypedBuilder( @@ -60,5 +73,10 @@ public interface BallastViewModelConfiguration BallastLogger, + + public var encoder: BallastEncoder, + public var decoder: BallastDecoder?, + + public val shutDownGracePeriod: Duration, ) } diff --git a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/EventHandlerScope.kt b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/EventHandlerScope.kt index 2e0eef36..7794b03a 100644 --- a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/EventHandlerScope.kt +++ b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/EventHandlerScope.kt @@ -20,5 +20,5 @@ public interface EventHandlerScope { /** * Get an Interceptor registered to this ViewModel by its key. */ - public suspend fun > getInterceptor(key: BallastInterceptor.Key): I + public suspend fun > getInterceptor(key: BallastInterceptor.Key): I } diff --git a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/InputStrategyScope.kt b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/InputStrategyScope.kt index 89084340..4d82a187 100644 --- a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/InputStrategyScope.kt +++ b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/InputStrategyScope.kt @@ -34,6 +34,17 @@ public interface InputStrategyScope : C onCancelled: suspend () -> Unit ) + /** + * Send a Queued item back to the ViewModel for processing. It will be protected by its [InputStrategy.Guardian] to + * ensure that it is processed correctly. + */ + public suspend fun acceptQueued( + queued: Queued, + guardian: InputStrategy.Guardian, + onFailed: suspend (t: Throwable) -> Unit, + onCancelled: suspend () -> Unit + ) + public suspend fun rejectInput(input: Inputs, currentState: State) public suspend fun rollbackState(state: State) diff --git a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/SideJobScope.kt b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/SideJobScope.kt index 74b73efc..6f942059 100644 --- a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/SideJobScope.kt +++ b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/SideJobScope.kt @@ -57,12 +57,15 @@ public interface SideJobScope : Corouti */ public suspend fun postEvent(event: Events) + public suspend fun requestGracefulShutdown() + /** * Get an Interceptor registered to this ViewModel by its key. */ - public suspend fun > getInterceptor(key: BallastInterceptor.Key): I + public suspend fun > getInterceptor(key: BallastInterceptor.Key): I public enum class RestartState { - Initial, Restarted + Initial, + Restarted } } diff --git a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/core/BufferedEventStrategy.kt b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/core/BufferedEventStrategy.kt index 585f2e6d..8993683f 100644 --- a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/core/BufferedEventStrategy.kt +++ b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/core/BufferedEventStrategy.kt @@ -5,8 +5,7 @@ import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow -public class BufferedEventStrategy private constructor( -) : ChannelEventStrategy( +public class BufferedEventStrategy private constructor() : ChannelEventStrategy( capacity = Channel.BUFFERED, onBufferOverflow = BufferOverflow.SUSPEND, ) { diff --git a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/core/ChannelEventStrategy.kt b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/core/ChannelEventStrategy.kt index 10321b42..ac0934d5 100644 --- a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/core/ChannelEventStrategy.kt +++ b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/core/ChannelEventStrategy.kt @@ -15,11 +15,11 @@ public abstract class ChannelEventStrategy { - private val _eventsQueue: Channel = Channel(capacity, onBufferOverflow) - private val _eventsQueueDrained: CompletableDeferred = CompletableDeferred() + private val eventsQueue: Channel = Channel(capacity, onBufferOverflow) + private val eventsQueueDrained: CompletableDeferred = CompletableDeferred() final override suspend fun EventStrategyScope.start() { - _eventsQueue + eventsQueue .receiveAsFlow() .onEach { when (it) { @@ -28,7 +28,7 @@ public abstract class ChannelEventStrategy { - _eventsQueueDrained.complete(Unit) + eventsQueueDrained.complete(Unit) } } } @@ -38,19 +38,19 @@ public abstract class ChannelEventStrategy.processEvents( diff --git a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/core/ChannelInputStrategy.kt b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/core/ChannelInputStrategy.kt index 33d2dc86..4735b82d 100644 --- a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/core/ChannelInputStrategy.kt +++ b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/core/ChannelInputStrategy.kt @@ -19,33 +19,33 @@ public abstract class ChannelInputStrategy? ) : InputStrategy { - private val _mainQueue = Channel>(capacity, onBufferOverflow) - private val _mainQueueDrained = CompletableDeferred() + private val mainQueue = Channel>(capacity, onBufferOverflow) + private val mainQueueDrained = CompletableDeferred() final override fun InputStrategyScope.start() { launch { - _mainQueue + mainQueue .receiveAsFlow() .filter { queued -> filterQueued(queued) } - .onCompletion { _mainQueueDrained.complete(Unit) } + .onCompletion { mainQueueDrained.complete(Unit) } .let { processInputs(it) } } } final override suspend fun enqueue(queued: Queued) { - _mainQueue.send(queued) + mainQueue.send(queued) } final override fun tryEnqueue(queued: Queued): ChannelResult { - return _mainQueue.trySend(queued) + return mainQueue.trySend(queued) } final override fun close() { - _mainQueue.close() + mainQueue.close() } final override suspend fun flush() { - _mainQueueDrained.await() + mainQueueDrained.await() } private suspend fun InputStrategyScope.filterQueued(queued: Queued): Boolean { diff --git a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/core/DefaultViewModelConfiguration.kt b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/core/DefaultViewModelConfiguration.kt index 8fe98f62..c4d6aa4e 100644 --- a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/core/DefaultViewModelConfiguration.kt +++ b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/core/DefaultViewModelConfiguration.kt @@ -1,5 +1,7 @@ package com.copperleaf.ballast.core +import com.copperleaf.ballast.BallastDecoder +import com.copperleaf.ballast.BallastEncoder import com.copperleaf.ballast.BallastInterceptor import com.copperleaf.ballast.BallastLogger import com.copperleaf.ballast.BallastViewModelConfiguration @@ -7,6 +9,7 @@ import com.copperleaf.ballast.EventStrategy import com.copperleaf.ballast.InputHandler import com.copperleaf.ballast.InputStrategy import kotlinx.coroutines.CoroutineDispatcher +import kotlin.time.Duration /** * A default implementation of [BallastViewModelConfiguration] produced by [BallastViewModelConfiguration.Builder]. @@ -26,4 +29,7 @@ public class DefaultViewModelConfiguration, + override val decoder: BallastDecoder?, + override val shutDownGracePeriod: Duration, ) : BallastViewModelConfiguration diff --git a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/core/ParallelInputStrategy.kt b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/core/ParallelInputStrategy.kt index d84addc7..75673383 100644 --- a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/core/ParallelInputStrategy.kt +++ b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/core/ParallelInputStrategy.kt @@ -28,8 +28,7 @@ import kotlinx.coroutines.launch * Because multiple inputs may be processed at once, if an input is cancelled there is no meaningful way to know what * state should be rolled-back to. Cancelled inputs may leave the ViewModel in a bad state. */ -public class ParallelInputStrategy private constructor( -) : ChannelInputStrategy( +public class ParallelInputStrategy private constructor() : ChannelInputStrategy( capacity = Channel.BUFFERED, onBufferOverflow = BufferOverflow.SUSPEND, filter = null, diff --git a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/core/ToStringEncoder.kt b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/core/ToStringEncoder.kt new file mode 100644 index 00000000..75873741 --- /dev/null +++ b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/core/ToStringEncoder.kt @@ -0,0 +1,9 @@ +package com.copperleaf.ballast.core + +import com.copperleaf.ballast.BallastEncoder + +public class ToStringEncoder : BallastEncoder { + override fun encodeInputToString(input: Inputs): String = input.toString() + override fun encodeEventToString(event: Events): String = event.toString() + override fun encodeStateToString(state: State): String = state.toString() +} diff --git a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/internal/BallastViewModelImpl.kt b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/internal/BallastViewModelImpl.kt index 42703eab..e2d14464 100644 --- a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/internal/BallastViewModelImpl.kt +++ b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/internal/BallastViewModelImpl.kt @@ -11,14 +11,13 @@ import com.copperleaf.ballast.internal.actors.InputActor import com.copperleaf.ballast.internal.actors.InterceptorActor import com.copperleaf.ballast.internal.actors.SideJobActor import com.copperleaf.ballast.internal.actors.StateActor -import com.copperleaf.ballast.internal.scopes.DefaultBallastScopeFactory import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.ChannelResult import kotlinx.coroutines.flow.StateFlow public class BallastViewModelImpl( - internal val type: String, + public val type: String, config: BallastViewModelConfiguration, ) : BallastViewModel, BallastViewModelConfiguration by config { @@ -26,7 +25,7 @@ public class BallastViewModelImpl( // Internal properties // --------------------------------------------------------------------------------------------------------------------- - internal val scopeFactory: BallastScopeFactory = DefaultBallastScopeFactory(this) + internal val scopeFactory: BallastScopeFactory = inputStrategy.getScopeFactory(this) public val inputActor: InputActor = InputActor(this, scopeFactory) public val eventActor: EventActor = EventActor(this, scopeFactory) public val stateActor: StateActor = scopeFactory.createStateActor(this) @@ -60,7 +59,11 @@ public class BallastViewModelImpl( return inputActor.enqueueQueuedImmediate(Queued.HandleInput(null, element)) } -// ViewModel Lifecycle + override fun close() { + inputActor.enqueueQueuedImmediate(Queued.ShutDownGracefully(CompletableDeferred(), shutDownGracePeriod)) + } + + // ViewModel Lifecycle // --------------------------------------------------------------------------------------------------------------------- public fun attachEventHandler( diff --git a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/internal/ViewModelStatus.kt b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/internal/ViewModelStatus.kt index ce6c032b..7afeab2e 100644 --- a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/internal/ViewModelStatus.kt +++ b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/internal/ViewModelStatus.kt @@ -19,7 +19,6 @@ public sealed interface Status { } override fun checkCanClear() { - } override fun checkStateChangeOpen() { @@ -55,7 +54,6 @@ public sealed interface Status { override fun checkCanShutDown() {} override fun checkCanClear() { - } override fun checkStateChangeOpen() {} @@ -85,14 +83,12 @@ public sealed interface Status { } override fun checkCanClear() { - } override fun checkStateChangeOpen() { if (!stateChangeOpen) error("VM is shutting down and the state can no longer be changed") } - override fun checkMainQueueOpen() { if (!mainQueueOpen) error("VM is shutting down and no more Inputs can be accepted!") } diff --git a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/internal/actors/InputActor.kt b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/internal/actors/InputActor.kt index 6197b48b..cf31cfe8 100644 --- a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/internal/actors/InputActor.kt +++ b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/internal/actors/InputActor.kt @@ -25,11 +25,15 @@ public class InputActor( impl.inputsDispatcher, ) with(impl.inputStrategy) { - scope.start() + try { + scope.start() + } catch (e: Throwable) { + e.printStackTrace() + } } } - internal suspend fun enqueueQueued(queued: Queued, await: Boolean) { + public suspend fun enqueueQueued(queued: Queued, await: Boolean) { impl.coordinator.coordinatorState.value.checkMainQueueOpen() when (queued) { @@ -38,11 +42,9 @@ public class InputActor( } is Queued.RestoreState -> { - } is Queued.ShutDownGracefully -> { - } } @@ -68,11 +70,9 @@ public class InputActor( } is Queued.RestoreState -> { - } is Queued.ShutDownGracefully -> { - } } @@ -91,25 +91,24 @@ public class InputActor( } is Queued.RestoreState -> { - } is Queued.ShutDownGracefully -> { - } } } return result } - internal suspend fun safelyHandleQueued( + public suspend fun safelyHandleQueued( queued: Queued, guardian: InputStrategy.Guardian, - onCancelled: suspend () -> Unit + onFailed: suspend (e: Throwable) -> Unit, + onCancelled: suspend () -> Unit, ) { when (queued) { is Queued.HandleInput -> { - safelyHandleInput(queued.input, queued.deferred, guardian, onCancelled) + safelyHandleInput(queued.input, queued.deferred, guardian, onFailed, onCancelled) } is Queued.RestoreState -> { @@ -126,7 +125,8 @@ public class InputActor( input: Inputs, deferred: CompletableDeferred?, guardian: InputStrategy.Guardian, - onCancelled: suspend () -> Unit + onFailed: suspend (e: Throwable) -> Unit, + onCancelled: suspend () -> Unit, ) { impl.interceptorActor.notify(BallastNotification.InputAccepted(impl.type, impl.name, input)) @@ -161,6 +161,7 @@ public class InputActor( deferred?.complete(Unit) } catch (e: Throwable) { impl.interceptorActor.notify(BallastNotification.InputHandlerError(impl.type, impl.name, input, e)) + onFailed(e) deferred?.complete(Unit) } } diff --git a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/internal/actors/InterceptorActor.kt b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/internal/actors/InterceptorActor.kt index dc509bd1..911084e9 100644 --- a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/internal/actors/InterceptorActor.kt +++ b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/internal/actors/InterceptorActor.kt @@ -27,21 +27,21 @@ public class InterceptorActor( private val impl: BallastViewModelImpl, private val scopeFactory: BallastScopeFactory, ) { - private val _notificationsQueue: Channel> = + private val notificationsQueue: Channel> = Channel(BUFFERED, BufferOverflow.SUSPEND) - private val _notificationsQueueDrained: CompletableDeferred = CompletableDeferred() + private val notificationsQueueDrained: CompletableDeferred = CompletableDeferred() - private val _notifications: MutableSharedFlow> = MutableSharedFlow() + private val notifications: MutableSharedFlow> = MutableSharedFlow() internal fun close() { - _notificationsQueue.close() + notificationsQueue.close() } internal fun startInterceptorsInternal() { // send notifications to Interceptors impl.interceptors .forEach { interceptor -> - val notificationFlow: Flow> = _notifications + val notificationFlow: Flow> = notifications .asSharedFlow() .transformWhile { emit(it) @@ -92,31 +92,31 @@ public class InterceptorActor( internal fun startProcessingNotificationsInternal() { // observe and process Inputs impl.viewModelScope.launch { - _notificationsQueue + notificationsQueue .receiveAsFlow() - .onEach { _notifications.emit(it) } + .onEach { notifications.emit(it) } .flowOn(impl.sideJobsDispatcher) - .onCompletion { _notificationsQueueDrained.complete(Unit) } + .onCompletion { notificationsQueueDrained.complete(Unit) } .launchIn(this) } } - internal suspend fun notify(value: BallastNotification) { - _notificationsQueue.send(value) + public suspend fun notify(value: BallastNotification) { + notificationsQueue.send(value) } internal fun notifyImmediate(value: BallastNotification) { - _notificationsQueue.trySend(value) + notificationsQueue.trySend(value) } internal suspend fun gracefullyShutDownNotifications() { // close the Notifications queue and wait for all Notifications to be handled - _notificationsQueue.close() - _notificationsQueueDrained.await() + notificationsQueue.close() + notificationsQueueDrained.await() } @Suppress("UNCHECKED_CAST") - internal suspend fun > getInterceptor(key: BallastInterceptor.Key): I { + public suspend fun > getInterceptor(key: BallastInterceptor.Key): I { val interceptorsWithKey = impl.interceptors .filter { if (it.key == null) { diff --git a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/internal/actors/SideJobActor.kt b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/internal/actors/SideJobActor.kt index 54b1a533..c36597a9 100644 --- a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/internal/actors/SideJobActor.kt +++ b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/internal/actors/SideJobActor.kt @@ -32,9 +32,9 @@ public class SideJobActor( private val impl: BallastViewModelImpl, private val scopeFactory: BallastScopeFactory, ) { - private val _sideJobsRequestQueue: Channel> = + private val sideJobsRequestQueue: Channel> = Channel(BUFFERED, BufferOverflow.SUSPEND) - private val _sideJobsRequestQueueDrained = CompletableDeferred() + private val sideJobsRequestQueueDrained = CompletableDeferred() private val sideJobsState: MutableStateFlow> = MutableStateFlow( emptyMap(), @@ -43,7 +43,7 @@ public class SideJobActor( internal fun startSideJobsInternal() { // start sideJobs posted by Inputs impl.viewModelScope.launch { - _sideJobsRequestQueue + sideJobsRequestQueue .receiveAsFlow() .onEach { request -> when (request) { @@ -56,7 +56,7 @@ public class SideJobActor( } } } - .onCompletion { _sideJobsRequestQueueDrained.complete(Unit) } + .onCompletion { sideJobsRequestQueueDrained.complete(Unit) } .launchIn(this) } } @@ -67,14 +67,14 @@ public class SideJobActor( ) { impl.coordinator.coordinatorState.value.checkSideJobsOpen() impl.interceptorActor.notifyImmediate(BallastNotification.SideJobQueued(impl.type, impl.name, key)) - _sideJobsRequestQueue.trySend(SideJobRequest.StartOrRestartSideJob(key, block)) + sideJobsRequestQueue.trySend(SideJobRequest.StartOrRestartSideJob(key, block)) } public fun cancelSideJob( key: String, ) { impl.coordinator.coordinatorState.value.checkSideJobCancellationOpen() - _sideJobsRequestQueue.trySend(SideJobRequest.CancelSideJob(key)) + sideJobsRequestQueue.trySend(SideJobRequest.CancelSideJob(key)) } internal fun cancelAllSideJobs() { @@ -220,8 +220,8 @@ public class SideJobActor( try { withTimeout(gracePeriod) { // close the sideJobs request queue and wait for all requests to be handled - _sideJobsRequestQueue.close() - _sideJobsRequestQueueDrained.await() + sideJobsRequestQueue.close() + sideJobsRequestQueueDrained.await() // without forcibly cancelling, wait for all sideJobs to complete sideJobsState.value @@ -239,7 +239,7 @@ public class SideJobActor( } internal fun close() { - _sideJobsRequestQueue.close() + sideJobsRequestQueue.close() } private sealed class SideJobRequest { diff --git a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/internal/scopes/BallastInterceptorScopeImpl.kt b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/internal/scopes/BallastInterceptorScopeImpl.kt index c3ad165f..44c2ca18 100644 --- a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/internal/scopes/BallastInterceptorScopeImpl.kt +++ b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/internal/scopes/BallastInterceptorScopeImpl.kt @@ -1,5 +1,7 @@ package com.copperleaf.ballast.internal.scopes +import com.copperleaf.ballast.BallastDecoder +import com.copperleaf.ballast.BallastEncoder import com.copperleaf.ballast.BallastInterceptorScope import com.copperleaf.ballast.BallastLogger import com.copperleaf.ballast.Queued @@ -17,6 +19,9 @@ internal class BallastInterceptorScopeImpl, private val eventActor: EventActor, + + override val encoder: BallastEncoder, + override val decoder: BallastDecoder?, ) : BallastInterceptorScope, CoroutineScope by interceptorCoroutineScope { diff --git a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/internal/scopes/BallastScopeFactoryImpl.kt b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/internal/scopes/BallastScopeFactoryImpl.kt index e11b83b5..d2fd1f1c 100644 --- a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/internal/scopes/BallastScopeFactoryImpl.kt +++ b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/internal/scopes/BallastScopeFactoryImpl.kt @@ -27,11 +27,12 @@ public open class DefaultBallastScopeFactory = with(impl) { + override fun createEventHandlerScope(): InternalEventHandlerScope = with(impl) { return EventHandlerScopeImpl( logger = logger, inputActor = inputActor, @@ -89,6 +90,7 @@ public open class DefaultBallastScopeFactory( guardian: InputStrategy.Guardian, onCancelled: suspend () -> Unit ) { - inputActor.safelyHandleQueued(queued, guardian, onCancelled) + inputActor.safelyHandleQueued(queued, guardian, {}, onCancelled) + } + + override suspend fun acceptQueued( + queued: Queued, + guardian: InputStrategy.Guardian, + onFailed: suspend (t: Throwable) -> Unit, + onCancelled: suspend () -> Unit + ) { + inputActor.safelyHandleQueued(queued, guardian, onFailed, onCancelled) } override suspend fun getCurrentState(): State { diff --git a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/internal/scopes/SideJobScopeImpl.kt b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/internal/scopes/SideJobScopeImpl.kt index fbaa50c0..4f7fcff4 100644 --- a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/internal/scopes/SideJobScopeImpl.kt +++ b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/internal/scopes/SideJobScopeImpl.kt @@ -8,6 +8,7 @@ import com.copperleaf.ballast.internal.actors.EventActor import com.copperleaf.ballast.internal.actors.InputActor import com.copperleaf.ballast.internal.actors.InterceptorActor import kotlinx.coroutines.CoroutineScope +import kotlin.time.Duration internal class SideJobScopeImpl( sideJobCoroutineScope: CoroutineScope, @@ -20,6 +21,7 @@ internal class SideJobScopeImpl( override val key: String, override val restartState: SideJobScope.RestartState, + private val shutDownGracePeriod: Duration ) : SideJobScope, CoroutineScope by sideJobCoroutineScope { override suspend fun postInput(input: Inputs) { @@ -30,6 +32,10 @@ internal class SideJobScopeImpl( eventActor.enqueueEvent(event, null, false) } + override suspend fun requestGracefulShutdown() { + inputActor.enqueueQueued(Queued.ShutDownGracefully(null, shutDownGracePeriod), await = false) + } + override suspend fun > getInterceptor(key: BallastInterceptor.Key): I { return interceptorActor.getInterceptor(key) } diff --git a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/utils.kt b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/utils.kt index 8db60dea..0bcf527f 100644 --- a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/utils.kt +++ b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/utils.kt @@ -128,10 +128,6 @@ public inline fun eventHandler( } } - - - - /** * Used for keeping track of the state of discrete "subjects" within an Interceptor. For example, a single Input will * send Notifications for [BallastNotification.InputQueued], [BallastNotification.InputAccepted], and diff --git a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/utilsForBuilder.kt b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/utilsForBuilder.kt index 8ec83381..2045a6ff 100644 --- a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/utilsForBuilder.kt +++ b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/utilsForBuilder.kt @@ -9,8 +9,7 @@ import kotlinx.coroutines.CoroutineDispatcher /** * Create a default [BallastViewModelConfiguration] from a [BallastViewModelConfiguration.Builder]. */ -public fun BallastViewModelConfiguration.Builder.build( -): BallastViewModelConfiguration { +public fun BallastViewModelConfiguration.Builder.build(): BallastViewModelConfiguration { val vmName = name ?: "$inputHandler-vm" @Suppress("DEPRECATION") return DefaultViewModelConfiguration( @@ -25,14 +24,16 @@ public fun BallastViewModelConfigurati interceptorDispatcher = interceptorDispatcher, name = vmName, logger = logger(vmName), + encoder = encoder.requireTyped("encoder"), + decoder = decoder.requireTypedIfPresent("decoder"), + shutDownGracePeriod = shutDownGracePeriod, ) } /** * Create a default [BallastViewModelConfiguration] from a [BallastViewModelConfiguration.Builder]. */ -public fun BallastViewModelConfiguration.Builder.typedBuilder( -): BallastViewModelConfiguration.TypedBuilder { +public fun BallastViewModelConfiguration.Builder.typedBuilder(): BallastViewModelConfiguration.TypedBuilder { val vmName = name ?: "$inputHandler-vm" return BallastViewModelConfiguration.TypedBuilder( initialState = initialState.requireTypedIfPresent("initialState"), @@ -46,6 +47,9 @@ public fun BallastViewModelConfigurati interceptorDispatcher = interceptorDispatcher, name = vmName, logger = logger, + encoder = encoder.requireTyped("encoder"), + decoder = decoder.requireTypedIfPresent("decoder"), + shutDownGracePeriod = shutDownGracePeriod, ) } @@ -135,7 +139,6 @@ public fun BallastViewModelConfigurati // Internal Helpers // --------------------------------------------------------------------------------------------------------------------- - @Suppress("UNCHECKED_CAST") internal fun Any?.requireTyped(name: String): T { if (this == null) error("$name required") @@ -161,7 +164,6 @@ internal fun EventStrategy<*, *, *>?.r } @Suppress("UNCHECKED_CAST") -internal fun List>.mapAsTyped( -): List> { +internal fun List>.mapAsTyped(): List> { return this.map { it as BallastInterceptor } } diff --git a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/utilsForTypedBuilder.kt b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/utilsForTypedBuilder.kt index c8ac43bc..b8694d98 100644 --- a/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/utilsForTypedBuilder.kt +++ b/ballast-api/src/commonMain/kotlin/com/copperleaf/ballast/utilsForTypedBuilder.kt @@ -9,8 +9,7 @@ import kotlinx.coroutines.CoroutineDispatcher /** * Create a default [BallastViewModelConfiguration] from a [BallastViewModelConfiguration.Builder]. */ -public fun BallastViewModelConfiguration.TypedBuilder.build( -): BallastViewModelConfiguration { +public fun BallastViewModelConfiguration.TypedBuilder.build(): BallastViewModelConfiguration { val vmName = name ?: "$inputHandler-vm" @Suppress("DEPRECATION") return DefaultViewModelConfiguration( @@ -25,6 +24,9 @@ public fun BallastViewModelConfigurati interceptorDispatcher = interceptorDispatcher, name = vmName, logger = logger(vmName), + encoder = encoder, + decoder = decoder, + shutDownGracePeriod = shutDownGracePeriod, ) } diff --git a/ballast-autoscale/README.md b/ballast-autoscale/README.md new file mode 100644 index 00000000..6ff9f8f2 --- /dev/null +++ b/ballast-autoscale/README.md @@ -0,0 +1,115 @@ +# Ballast Autoscale + +> [!CAUTION] +> +> Experimental. This module may not still have issues or changes in its public API before being considered stable. +> Please use at your own risk, and file Issues for any problems you may encounter. + +## Overview + +`AutoscalingViewModel` acts as a wrapper around a pool of other ViewModels, and provides basic facilities for scaling +the pool of ViewModels up or down to adapt to load, and distributing work among the pool of ViewModel workers. The main +use-case would be in server-side applications such as job queue processors. For example, one could increase the +parallelism of processing jobs in the queue in response to the number of pending jobs, average time spent waiting for a +job to start, etc. + +## Supported Platforms + +| Platform | Supported | +|----------|-----------| +| JVM | ✅ | +| Android | ✅ | +| iOS | ✅ | +| JS | ✅ | +| WASM JS | ✅ | + +## See Also + +- [Ballast Ktor Server](./../ballast-ktor-server) +- [Ballast Queue Core](./../ballast-queue-core) +- [Ballast Scheduler ViewModel](./../ballast-scheduler-viewmodel) + +## Usage + +This module introduces a new implementation of `BallastViewModel`: `AutoscalingViewModel`. This new ViewModel type acts +as a wrapper around a pool of ViewModels of the same type, automatically adding or removing instances as needed to +respond to system pressure. It's intended to be used in a server-side context, most specifically in conjunction with +[Ballast Queue](./../ballast-queue-core), though it intentionally does not depend on any functionality that +would prevent it from being used in frontend apps or anywhere else. + +Your application code should treat the `AutoscalingViewModel` exactly the same as it if were a `BasicViewModel`, sending +Inputs to it as normal. It will then distribute those Inputs to one of the inner ViewModels to be enqueued and handled +as normal. There are 3 components that need to be provided to the `AutoscalingViewModel` to allow this autoscaling +functionality to work: + +### ViewModelFactory + +A `ViewModelFactory` is responsible for creating a new copy of a ViewModel so that it can be run within the "cluster". +The factory is provided a CoroutineScope which is a child of the scope passed to `AutoscalingViewModel`, and an integer +ID which should be used to give the VM a unique name. + +The ID provided to the factory function is the numerical index indicating its position in the current pool. IDs may be +reused if the cluster scales down, then back up, so it's not globally unique. However, it is intended to be stable such +that it can be used as a property to determine how configure the ViewModel. For example, you may want to attach a +`SchedulingInterceptor` from [Ballast Scheduler ViewModel](./../ballast-scheduler-viewmodel) to enqueue +maintenance tasks on a schedule, but you only want 1 replica to enqueue those tasks so you don't have to manually +deduplicate those jobs. For this case, you could configure the ViewModel to only attach the SchedulerInterceptor at +`ID: 0`. + +The ViewModels produced by this factory should run on the provided CoroutineScope, and will get closed automatically if +the `AutoscalingViewModel` gets closed. When a ViewModel gets removed from the cluster, it will be shut down gracefully +to try and allow in-progress Inputs to complete, using `BallastViewModel.close()`. + +### ScalingPolicy + +The `ScalingPolicy` returns a `Flow` which indicates how many replicas of the inner ViewModel you need running. The +Flow must always request at least 1 replica. `FixedScalingPolicy` is the only implementation provided by default, which +allows you to set a fixed number of replicas which all get created immediately and never get scaled down. + +Your application may instead need adjust the number of ViewModels in the pool dynamically based on real measured +pressure from your system. It is up to you to determine how to measure this pressure and determine how many replicas you +need. + +### DistributionPolicy + +Once the cluster is up and running and ready to accept Inputs, the `AutoscalingViewModel` will distribute the Inputs +its receives to exactly one of the ViewModels running in the cluster. The `DistributionPolicy` is responsible for +selecting a viewModel in the pool and allowing the `AutoscalingViewModel` to forward the Input to it. + +Several Distribution Policies are provided by default: + +- `LeaderDistributionPolicy`: the first ViewModel in the pool will receive all Inputs, which ensures all Inputs are + processed sequentially. This can be used to have the Leader insert the Input into a shared queue to be processed + later, such as a database table or SQS queue. +- `RoundRobinDistributionPolicy`: ViewModels are selected in a round-robin fashion, so no ViewModel will receive two + Inputs in a row (unless there's only 1 in the pool, of course). This may a good choice when using + `FixedScalingPolicy`, which will ensure all ViewModels in the pool receive an equal number of Inputs. These Inputs + will then be processed in parallel by all ViewModels in the cluster. +- `RandomDistributionPolicy`: ViewModels are selected randomly. This may cause some ViewModels to receive more + Inputs than others, but will help with distributing the load when ViewModels are scaling up and down quickly as Round + Robin would tend to favor ViewModels with lower IDs, as the Round Robin index may wrap around and skip a newly-added + ViewModel. These Inputs will then be processed in parallel by all ViewModels in the cluster. + +## Installation + +```kotlin +repositories { + mavenCentral() +} + +// for plain JVM or Android projects +dependencies { + implementation("io.github.copper-leaf:ballast-autoscale:{{ballastVersion}}") +} + +// for multiplatform projects +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + implementation("io.github.copper-leaf:ballast-autoscale:{{ballastVersion}}") + } + } + } +} +``` diff --git a/ballast-autoscale/api/android/ballast-autoscale.api b/ballast-autoscale/api/android/ballast-autoscale.api new file mode 100644 index 00000000..932348d1 --- /dev/null +++ b/ballast-autoscale/api/android/ballast-autoscale.api @@ -0,0 +1,49 @@ +public class com/copperleaf/ballast/autoscale/AutoscalingViewModel : com/copperleaf/ballast/BallastViewModel { + public synthetic fun (Lkotlinx/coroutines/CoroutineScope;Lcom/copperleaf/ballast/autoscale/ViewModelFactory;Lcom/copperleaf/ballast/autoscale/ScalingPolicy;Lcom/copperleaf/ballast/autoscale/DistributionPolicy;JILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Lkotlinx/coroutines/CoroutineScope;Lcom/copperleaf/ballast/autoscale/ViewModelFactory;Lcom/copperleaf/ballast/autoscale/ScalingPolicy;Lcom/copperleaf/ballast/autoscale/DistributionPolicy;JLkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun close ()V + public final fun getShutDownGracePeriod-UwyO8pc ()J + public fun observeStates ()Lkotlinx/coroutines/flow/StateFlow; + public fun send (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun sendAndAwaitCompletion (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun trySend-JP2dKIU (Ljava/lang/Object;)Ljava/lang/Object; +} + +public abstract interface class com/copperleaf/ballast/autoscale/DistributionPolicy { + public abstract fun getPolicyState ()Lcom/copperleaf/ballast/autoscale/DistributionPolicy$PolicyState; +} + +public abstract interface class com/copperleaf/ballast/autoscale/DistributionPolicy$PolicyState { + public abstract fun getNextViewModel (Ljava/lang/Object;Ljava/util/List;)Lcom/copperleaf/ballast/BallastViewModel; +} + +public abstract interface class com/copperleaf/ballast/autoscale/ScalingPolicy { + public abstract fun getReplicaCount ()Lkotlinx/coroutines/flow/Flow; +} + +public abstract interface class com/copperleaf/ballast/autoscale/ViewModelFactory { + public abstract fun createViewModel (Lkotlinx/coroutines/CoroutineScope;I)Lcom/copperleaf/ballast/BallastViewModel; +} + +public final class com/copperleaf/ballast/autoscale/policies/FixedScalingPolicy : com/copperleaf/ballast/autoscale/ScalingPolicy { + public fun (I)V + public fun getReplicaCount ()Lkotlinx/coroutines/flow/Flow; +} + +public final class com/copperleaf/ballast/autoscale/policies/LeaderDistributionPolicy : com/copperleaf/ballast/autoscale/DistributionPolicy { + public fun ()V + public fun getPolicyState ()Lcom/copperleaf/ballast/autoscale/DistributionPolicy$PolicyState; +} + +public final class com/copperleaf/ballast/autoscale/policies/RandomDistributionPolicy : com/copperleaf/ballast/autoscale/DistributionPolicy { + public fun ()V + public fun (Lkotlin/random/Random;)V + public synthetic fun (Lkotlin/random/Random;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun getPolicyState ()Lcom/copperleaf/ballast/autoscale/DistributionPolicy$PolicyState; +} + +public final class com/copperleaf/ballast/autoscale/policies/RoundRobinDistributionPolicy : com/copperleaf/ballast/autoscale/DistributionPolicy { + public fun ()V + public fun getPolicyState ()Lcom/copperleaf/ballast/autoscale/DistributionPolicy$PolicyState; +} + diff --git a/ballast-autoscale/api/jvm/ballast-autoscale.api b/ballast-autoscale/api/jvm/ballast-autoscale.api new file mode 100644 index 00000000..932348d1 --- /dev/null +++ b/ballast-autoscale/api/jvm/ballast-autoscale.api @@ -0,0 +1,49 @@ +public class com/copperleaf/ballast/autoscale/AutoscalingViewModel : com/copperleaf/ballast/BallastViewModel { + public synthetic fun (Lkotlinx/coroutines/CoroutineScope;Lcom/copperleaf/ballast/autoscale/ViewModelFactory;Lcom/copperleaf/ballast/autoscale/ScalingPolicy;Lcom/copperleaf/ballast/autoscale/DistributionPolicy;JILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Lkotlinx/coroutines/CoroutineScope;Lcom/copperleaf/ballast/autoscale/ViewModelFactory;Lcom/copperleaf/ballast/autoscale/ScalingPolicy;Lcom/copperleaf/ballast/autoscale/DistributionPolicy;JLkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun close ()V + public final fun getShutDownGracePeriod-UwyO8pc ()J + public fun observeStates ()Lkotlinx/coroutines/flow/StateFlow; + public fun send (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun sendAndAwaitCompletion (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun trySend-JP2dKIU (Ljava/lang/Object;)Ljava/lang/Object; +} + +public abstract interface class com/copperleaf/ballast/autoscale/DistributionPolicy { + public abstract fun getPolicyState ()Lcom/copperleaf/ballast/autoscale/DistributionPolicy$PolicyState; +} + +public abstract interface class com/copperleaf/ballast/autoscale/DistributionPolicy$PolicyState { + public abstract fun getNextViewModel (Ljava/lang/Object;Ljava/util/List;)Lcom/copperleaf/ballast/BallastViewModel; +} + +public abstract interface class com/copperleaf/ballast/autoscale/ScalingPolicy { + public abstract fun getReplicaCount ()Lkotlinx/coroutines/flow/Flow; +} + +public abstract interface class com/copperleaf/ballast/autoscale/ViewModelFactory { + public abstract fun createViewModel (Lkotlinx/coroutines/CoroutineScope;I)Lcom/copperleaf/ballast/BallastViewModel; +} + +public final class com/copperleaf/ballast/autoscale/policies/FixedScalingPolicy : com/copperleaf/ballast/autoscale/ScalingPolicy { + public fun (I)V + public fun getReplicaCount ()Lkotlinx/coroutines/flow/Flow; +} + +public final class com/copperleaf/ballast/autoscale/policies/LeaderDistributionPolicy : com/copperleaf/ballast/autoscale/DistributionPolicy { + public fun ()V + public fun getPolicyState ()Lcom/copperleaf/ballast/autoscale/DistributionPolicy$PolicyState; +} + +public final class com/copperleaf/ballast/autoscale/policies/RandomDistributionPolicy : com/copperleaf/ballast/autoscale/DistributionPolicy { + public fun ()V + public fun (Lkotlin/random/Random;)V + public synthetic fun (Lkotlin/random/Random;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun getPolicyState ()Lcom/copperleaf/ballast/autoscale/DistributionPolicy$PolicyState; +} + +public final class com/copperleaf/ballast/autoscale/policies/RoundRobinDistributionPolicy : com/copperleaf/ballast/autoscale/DistributionPolicy { + public fun ()V + public fun getPolicyState ()Lcom/copperleaf/ballast/autoscale/DistributionPolicy$PolicyState; +} + diff --git a/ballast-autoscale/build.gradle.kts b/ballast-autoscale/build.gradle.kts new file mode 100644 index 00000000..6f237837 --- /dev/null +++ b/ballast-autoscale/build.gradle.kts @@ -0,0 +1,30 @@ +plugins { + id("copper-leaf-base") + id("copper-leaf-android-library") + id("copper-leaf-targets") + id("copper-leaf-tests") + id("copper-leaf-lint") + id("copper-leaf-publish") +} + +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + implementation(project(":ballast-api")) + } + } + val jvmMain by getting { + dependencies { } + } + val androidMain by getting { + dependencies { } + } + val jsMain by getting { + dependencies { } + } + val iosMain by getting { + dependencies { } + } + } +} diff --git a/ballast-autoscale/gradle.properties b/ballast-autoscale/gradle.properties new file mode 100644 index 00000000..26631e6d --- /dev/null +++ b/ballast-autoscale/gradle.properties @@ -0,0 +1,8 @@ +copperleaf.description=Autoscale Ballast ViewModels to handle dynamic traffic loads. + +copperleaf.targets.android=true +copperleaf.targets.jvm=true +copperleaf.targets.ios=true +copperleaf.targets.js=true +copperleaf.targets.wasm.wasi=false +copperleaf.targets.wasm.js=true diff --git a/ballast-autoscale/src/androidMain/AndroidManifest.xml b/ballast-autoscale/src/androidMain/AndroidManifest.xml new file mode 100644 index 00000000..811d7660 --- /dev/null +++ b/ballast-autoscale/src/androidMain/AndroidManifest.xml @@ -0,0 +1,2 @@ + diff --git a/ballast-autoscale/src/commonMain/kotlin/com/copperleaf/ballast/autoscale/AutoscalingViewModel.kt b/ballast-autoscale/src/commonMain/kotlin/com/copperleaf/ballast/autoscale/AutoscalingViewModel.kt new file mode 100644 index 00000000..9dac279b --- /dev/null +++ b/ballast-autoscale/src/commonMain/kotlin/com/copperleaf/ballast/autoscale/AutoscalingViewModel.kt @@ -0,0 +1,124 @@ +package com.copperleaf.ballast.autoscale + +import com.copperleaf.ballast.BallastViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.InternalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.ChannelResult +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.job +import kotlinx.coroutines.launch +import kotlinx.coroutines.plus +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +public open class AutoscalingViewModel( + coroutineScope: CoroutineScope, + private val factory: ViewModelFactory, + private val scalingPolicy: ScalingPolicy, + private val distributionPolicy: DistributionPolicy, + public val shutDownGracePeriod: Duration = 10.seconds, +) : BallastViewModel { + + private val scalingScope: CoroutineScope = coroutineScope + SupervisorJob(coroutineScope.coroutineContext.job) + private val viewModelPool = MutableStateFlow>>(emptyList()) + private val distributionPolicyState: DistributionPolicy.PolicyState + + init { + distributionPolicyState = distributionPolicy.getPolicyState() + + scalingScope.launch { + scalingPolicy + .getReplicaCount() + .onEach { check(it >= 1) { "AutoscalingViewModel requires at least 1 replica to function." } } + .collect { replicaCount -> + autoscale(replicaCount) + } + } + scalingScope.coroutineContext.job.invokeOnCompletion { + // Clean up all ViewModels in the pool when the scalingScope is cancelled, which happens when this VM itself + // is closed + viewModelPool.value.forEach { it.close() } + } + } + + override fun observeStates(): StateFlow { + throw NotImplementedError("observeStates() is not available with autoscaled ViewModels, since each replica manages its own state independently.") + } + + @OptIn(InternalCoroutinesApi::class) + override fun trySend(element: Inputs): ChannelResult { + return getNextViewModelAccordingToPolicy(element).trySend(element) + } + + override suspend fun send(element: Inputs) { + return getNextViewModelAccordingToPolicy(element).send(element) + } + + override suspend fun sendAndAwaitCompletion(element: Inputs) { + return getNextViewModelAccordingToPolicy(element).sendAndAwaitCompletion(element) + } + + private fun getNextViewModelAccordingToPolicy(element: Inputs): BallastViewModel { + return distributionPolicyState.getNextViewModel(element, viewModelPool.value) + ?: error("DistributionPolicy was unable to select a ViewModel from the pool.") + } + + override fun close() { + scalingScope.launch { + viewModelPool.value.forEach { it.close() } + delay(shutDownGracePeriod) + scalingScope.cancel() + } + } + + // Autoscaling +// --------------------------------------------------------------------------------------------------------------------- + + private fun autoscale(replicaCount: Int) { + viewModelPool.update { currentPool -> + val currentReplicas = currentPool.size + when { + replicaCount > currentReplicas -> { + autoscaleUp(currentPool, replicaCount) + } + + replicaCount < currentReplicas -> { + autoscaleDown(currentPool, replicaCount) + } + + else -> { + currentPool + } + } + } + } + + private fun autoscaleUp( + currentPool: List>, + replicaCount: Int + ): List> { + return currentPool + List(replicaCount - currentPool.size) { index -> + factory.createViewModel(scalingScope, currentPool.size + index) + } + } + + private fun autoscaleDown( + currentPool: List>, + replicaCount: Int + ): List> { + // scale down + val (toKeep, toRemove) = currentPool.withIndex().partition { (index, _) -> + index < replicaCount + } + toRemove.forEach { (_, vm) -> + vm.close() + } + return toKeep.map { it.value } + } +} diff --git a/ballast-autoscale/src/commonMain/kotlin/com/copperleaf/ballast/autoscale/DistributionPolicy.kt b/ballast-autoscale/src/commonMain/kotlin/com/copperleaf/ballast/autoscale/DistributionPolicy.kt new file mode 100644 index 00000000..2859e860 --- /dev/null +++ b/ballast-autoscale/src/commonMain/kotlin/com/copperleaf/ballast/autoscale/DistributionPolicy.kt @@ -0,0 +1,14 @@ +package com.copperleaf.ballast.autoscale + +import com.copperleaf.ballast.BallastViewModel + +public fun interface DistributionPolicy { + public fun getPolicyState(): PolicyState + + public fun interface PolicyState { + public fun getNextViewModel( + input: Inputs, + pool: List> + ): BallastViewModel? + } +} diff --git a/ballast-autoscale/src/commonMain/kotlin/com/copperleaf/ballast/autoscale/ScalingPolicy.kt b/ballast-autoscale/src/commonMain/kotlin/com/copperleaf/ballast/autoscale/ScalingPolicy.kt new file mode 100644 index 00000000..6984df18 --- /dev/null +++ b/ballast-autoscale/src/commonMain/kotlin/com/copperleaf/ballast/autoscale/ScalingPolicy.kt @@ -0,0 +1,7 @@ +package com.copperleaf.ballast.autoscale + +import kotlinx.coroutines.flow.Flow + +public fun interface ScalingPolicy { + public fun getReplicaCount(): Flow +} diff --git a/ballast-autoscale/src/commonMain/kotlin/com/copperleaf/ballast/autoscale/ViewModelFactory.kt b/ballast-autoscale/src/commonMain/kotlin/com/copperleaf/ballast/autoscale/ViewModelFactory.kt new file mode 100644 index 00000000..40ea2637 --- /dev/null +++ b/ballast-autoscale/src/commonMain/kotlin/com/copperleaf/ballast/autoscale/ViewModelFactory.kt @@ -0,0 +1,11 @@ +package com.copperleaf.ballast.autoscale + +import com.copperleaf.ballast.BallastViewModel +import kotlinx.coroutines.CoroutineScope + +public fun interface ViewModelFactory { + public fun createViewModel( + coroutineScope: CoroutineScope, + id: Int, + ): BallastViewModel +} diff --git a/ballast-autoscale/src/commonMain/kotlin/com/copperleaf/ballast/autoscale/policies/FixedScalingPolicy.kt b/ballast-autoscale/src/commonMain/kotlin/com/copperleaf/ballast/autoscale/policies/FixedScalingPolicy.kt new file mode 100644 index 00000000..bdfb3ff8 --- /dev/null +++ b/ballast-autoscale/src/commonMain/kotlin/com/copperleaf/ballast/autoscale/policies/FixedScalingPolicy.kt @@ -0,0 +1,14 @@ +package com.copperleaf.ballast.autoscale.policies + +import com.copperleaf.ballast.autoscale.ScalingPolicy +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +public class FixedScalingPolicy( + private val replicas: Int, +) : ScalingPolicy { + + override fun getReplicaCount(): Flow { + return flowOf(replicas) + } +} diff --git a/ballast-autoscale/src/commonMain/kotlin/com/copperleaf/ballast/autoscale/policies/LeaderDistributionPolicy.kt b/ballast-autoscale/src/commonMain/kotlin/com/copperleaf/ballast/autoscale/policies/LeaderDistributionPolicy.kt new file mode 100644 index 00000000..de6fffac --- /dev/null +++ b/ballast-autoscale/src/commonMain/kotlin/com/copperleaf/ballast/autoscale/policies/LeaderDistributionPolicy.kt @@ -0,0 +1,13 @@ +package com.copperleaf.ballast.autoscale.policies + +import com.copperleaf.ballast.autoscale.DistributionPolicy + +public class LeaderDistributionPolicy : + DistributionPolicy { + + override fun getPolicyState(): DistributionPolicy.PolicyState { + return DistributionPolicy.PolicyState { input, pool -> + pool.firstOrNull() + } + } +} diff --git a/ballast-autoscale/src/commonMain/kotlin/com/copperleaf/ballast/autoscale/policies/RandomDistributionPolicy.kt b/ballast-autoscale/src/commonMain/kotlin/com/copperleaf/ballast/autoscale/policies/RandomDistributionPolicy.kt new file mode 100644 index 00000000..6cff9107 --- /dev/null +++ b/ballast-autoscale/src/commonMain/kotlin/com/copperleaf/ballast/autoscale/policies/RandomDistributionPolicy.kt @@ -0,0 +1,15 @@ +package com.copperleaf.ballast.autoscale.policies + +import com.copperleaf.ballast.autoscale.DistributionPolicy +import kotlin.random.Random + +public class RandomDistributionPolicy( + private val random: Random = Random.Default, +) : DistributionPolicy { + + override fun getPolicyState(): DistributionPolicy.PolicyState { + return DistributionPolicy.PolicyState { input, pool -> + pool.randomOrNull(random) + } + } +} diff --git a/ballast-autoscale/src/commonMain/kotlin/com/copperleaf/ballast/autoscale/policies/RoundRobinDistributionPolicy.kt b/ballast-autoscale/src/commonMain/kotlin/com/copperleaf/ballast/autoscale/policies/RoundRobinDistributionPolicy.kt new file mode 100644 index 00000000..4ffc81c9 --- /dev/null +++ b/ballast-autoscale/src/commonMain/kotlin/com/copperleaf/ballast/autoscale/policies/RoundRobinDistributionPolicy.kt @@ -0,0 +1,23 @@ +package com.copperleaf.ballast.autoscale.policies + +import com.copperleaf.ballast.autoscale.DistributionPolicy + +public class RoundRobinDistributionPolicy : + DistributionPolicy { + + override fun getPolicyState(): DistributionPolicy.PolicyState { + var currentIndex = -1 + return DistributionPolicy.PolicyState { input, pool -> + currentIndex++ + + if (currentIndex in pool.indices) { + // incrementing the index stayed in bounds, so return the VM at that index + pool.getOrNull(currentIndex) + } else { + // incrementing the index was no longer in bounds. Reset the index to 0 and return the first VM + currentIndex = 0 + pool.getOrNull(currentIndex) + } + } + } +} diff --git a/ballast-core/README.md b/ballast-core/README.md new file mode 100644 index 00000000..401650b8 --- /dev/null +++ b/ballast-core/README.md @@ -0,0 +1,339 @@ +# Ballast Core + +## Overview + +The Ballast Core module provides all the core capabilities of the entire Ballast MVI framework. This module is simply an +aggregation of other fundamental Ballast modules, which are combined to provide the basic functionality and +platform-specific integrations needed for developing application, and is the primary module you should include when +using Ballast for building applications. Library developers building additional features or integrations into Ballast +should depend on [Ballast API](./../ballast-api) instead, since a library should not need the +platform-specific features provided by the other modules. + +## Supported Platforms + +| Platform | Supported | +|----------|-----------| +| JVM | ✅ | +| Android | ✅ | +| iOS | ✅ | +| JS | ✅ | +| WASM JS | ✅ | + +## See Also + +- [Ballast API](./../ballast-api) +- [Ballast Viewmodel](./../ballast-viewmodel) +- [Ballast Logging](./../ballast-logging) +- [Ballast Utils](./../ballast-utils) + +## Usage + +`ballast-core` is the standard starting point for most Ballast applications. Adding it to your dependencies brings in +`ballast-api`, `ballast-viewmodel`, `ballast-logging`, and `ballast-utils` together, providing everything needed to +create and run ViewModels. + +At a high level, Ballast is a library to help you manage the state of your application as it changes over time. It +follows the basic pattern of MVI, where the ViewModel state cannot be changed directly — instead you send your _intent_ +to change the state to the library. The library processes those requests safely, in a way that is predictable and +repeatable, which generates new states that flow back to the UI automatically: + +``` +UI --[Inputs]--> ViewModel --[State]--> UI +``` + +The general workflow for building a Ballast screen involves: + +1. Define a Contract +2. Write the InputHandler +3. Write the EventHandler +4. Combine everything into a ViewModel +5. Connect the ViewModel to your UI + +### Contract + +The Contract is the declarative model of what is happening in a screen. It provides a structure for what data will be +changing (the State) and how you will be interacting with it (Inputs), giving you a single place to understand +everything about any given screen. + +The contract is canonically a single top-level `object` with a name like `*Contract`, and it contains 3 nested +classes: `State`, `Inputs`, and `Events`. If you're using Ballast in a multiplatform project, the Contract should be in +the `commonMain` sourceSet. + +```kotlin +object LoginScreenContract { + data class State( + val username: TextFieldValue = TextFieldValue(), + val password: TextFieldValue = TextFieldValue(), + val loggingIn: Boolean = false, + ) + + sealed interface Inputs { + data class UsernameChanged(val newValue: TextFieldValue) : Inputs + data class PasswordChanged(val newValue: TextFieldValue) : Inputs + data object LoginButtonClicked : Inputs + data object RegisterButtonClicked : Inputs + } + + sealed interface Events { + data object NavigateToDashboard : Events + data object NavigateToRegistration : Events + } +} +``` + +#### State + +The most important component of the MVI contract is the State. All data in your UI that changes meaningfully should be +modeled in your State. States are held in-memory and are guaranteed to always exist through the `StateFlow`. How you +build your UI and model your Inputs should be derived completely from how you model your State. + +State is modeled as a Kotlin immutable `data class`. While some MVI frameworks suggest using a `sealed class` for UI +state, Ballast's opinion is that the State should be a `data class` — real-world UIs are rarely cleanly delineated +between such discrete states, and commonly have many features that must all be modeled simultaneously. `sealed classes` +work great as individual properties _within_ that State, though. + +#### Inputs + +Inputs are the core of how Ballast does all its processing. The "intent" a user has when interacting with the UI is +captured into an Input class and sent to the ViewModel to be processed. Inputs are modeled as a Kotlin `sealed interface`. + +A good rule of thumb: avoid re-using any Input for more than one purpose. It should be entirely clear what an Input +will do to the State without having to look at its implementation. If you are tempted to re-send the same Input to do +2 different things, it should just be 2 different Inputs. + +#### Events + +Events are one-off side effects that must be handled exactly once at the appropriate time — such as navigation requests. +Events are sent from the InputHandler and delivered to the EventHandler, keeping platform-specific event-handling logic +out of the ViewModel. Like Inputs, Events are modeled as a Kotlin `sealed interface`. + +> **Note:** Ballast processes Events with a `Channel`, providing an "at-most once" delivery model. If your application +> requires stronger delivery guarantees, consider modeling those cases as State instead. + +### InputHandler + +The InputHandler is the only place in the MVI loop that is allowed to run arbitrary code. It implements the +`InputHandler` interface and receives Inputs from the queue one at a time. The `InputHandlerScope` DSL can update +ViewModel State, post Events, start side jobs, and call any other suspending functions. + +If you're using Ballast in a multiplatform project, the InputHandler should be in the `commonMain` sourceSet. + +```kotlin +import LoginScreenContract.* + +class LoginScreenInputHandler( + private val loginRepository: LoginRepository, +) : InputHandler { + override suspend fun InputHandlerScope.handleInput( + input: Inputs + ) = when (input) { + is UsernameChanged -> updateState { copy(username = input.newValue) } + is PasswordChanged -> updateState { copy(password = input.newValue) } + is LoginButtonClicked -> { + updateState { copy(loggingIn = true) } + sideJob("login") { + val success = loginRepository.login( + getState().username.text, + getState().password.text, + ) + if (success) postEvent(Events.NavigateToDashboard) + else postInput(Inputs.LoginFailed) + } + } + is RegisterButtonClicked -> postEvent(Events.NavigateToRegistration) + } +} +``` + +#### Side Jobs + +Side jobs allow you to start coroutines that run in the "background" of your ViewModel, alongside the normal Input +queue. They are bound by the same lifecycle as the ViewModel and can collect from infinite flows. + +```kotlin +sideJob("key") { + infiniteFlow() + .map { Inputs.SomeInputType() } + .onEach { postInput(it) } + .launchIn(this) +} +``` + +Side jobs cannot directly access or modify the ViewModel State, but can post Inputs and Events back to the ViewModel to +request state changes. + +### EventHandler + +The EventHandler handles Events sent from the ViewModel to the UI, and is the exact counterpart of the InputHandler. +Inputs flow from the UI into the ViewModel; Events flow from the ViewModel out to the UI. The EventHandler may be +attached and detached dynamically in response to the UI's lifecycle — Events sent while detached will be queued and +delivered once the UI is back in a valid state. + +```kotlin +import LoginScreenContract.* + +class LoginScreenEventHandler( + private val navigator: Navigator, +) : EventHandler { + override suspend fun EventHandlerScope.handleEvent( + event: Events + ) = when (event) { + is Events.NavigateToDashboard -> navigator.navigateToDashboard() + is Events.NavigateToRegistration -> navigator.navigateToRegistration() + } +} +``` + +### ViewModel + +The ViewModel combines everything together using `BallastViewModelConfiguration.Builder`. The exact base class varies +by platform — see [Ballast Viewmodel](./../ballast-viewmodel) for platform-specific details — but all configurations +look similar: + +```kotlin +// androidMain +class LoginScreenViewModel( + private val loginRepository: LoginRepository, +) : AndroidViewModel< + LoginScreenContract.Inputs, + LoginScreenContract.Events, + LoginScreenContract.State>( + config = BallastViewModelConfiguration.Builder() + .apply { + this += LoggingInterceptor() + logger = { AndroidBallastLogger(it) } + } + .withViewModel( + initialState = LoginScreenContract.State(), + inputHandler = LoginScreenInputHandler(loginRepository), + name = "LoginScreen", + ) + .build() +) + +// other platforms (JS, Desktop, iOS, etc.) +class LoginScreenViewModel( + coroutineScope: CoroutineScope, + loginRepository: LoginRepository, + navigator: Navigator, +) : BasicViewModel< + LoginScreenContract.Inputs, + LoginScreenContract.Events, + LoginScreenContract.State>( + config = BallastViewModelConfiguration.Builder() + .apply { + this += LoggingInterceptor() + logger = { JsConsoleBallastLogger(it) } + } + .withViewModel( + initialState = LoginScreenContract.State(), + inputHandler = LoginScreenInputHandler(loginRepository), + name = "LoginScreen", + ) + .build(), + eventHandler = LoginScreenEventHandler(navigator), + coroutineScope = coroutineScope, +) +``` + +### Input Strategies + +Ballast offers 3 different Input Strategies out-of-the-box, which each adapt Ballast's core functionality for different +applications: + +- **`LifoInputStrategy`**: A last-in-first-out strategy, and the default if none is provided. Only 1 Input is processed + at a time; if a new Input is received while one is still processing, the running Input is cancelled to immediately + accept the new one. Corresponds to `Flow.collectLatest { }`. Best for UI ViewModels that need a highly responsive UI + where you do not want to block the user's actions. + +- **`FifoInputStrategy`**: A first-in-first-out strategy. Inputs are processed in order, one at a time. Instead of + cancelling running Inputs, new ones are queued and consumed later when the queue is free. Corresponds to the normal + `Flow.collect { }`. Best for non-UI ViewModels, or UI ViewModels where it is acceptable to "block" the UI while + something is loading. + +- **`ParallelInputStrategy`**: For specific edge-cases where neither of the above strategies works. Inputs are all + handled concurrently, but this places additional restrictions on State reads/changes to prevent race conditions. + +> **Warning:** For historical reasons, `LifoInputStrategy` is the default, but it can be unintuitive and cause subtle +> issues in your application. It is recommended to explicitly choose `FifoInputStrategy` unless you are familiar enough +> with Ballast to understand the full implications of `LifoInputStrategy`. This default will likely change to +> `FifoInputStrategy` in a future version, so it is best to always set the strategy explicitly rather than relying on +> the default. + +Set the input strategy in the configuration builder: + +```kotlin +BallastViewModelConfiguration.Builder() + .apply { + inputStrategy = FifoInputStrategy.typed() + } + .withViewModel( + initialState = State(), + inputHandler = ExampleInputHandler(), + name = "Example", + ) + .build() +``` + +### Interceptors + +One of the primary features of Ballast is its interceptor plugin API. Because the MVI pattern decouples the _intent_ to +do work from the actual processing of that work, it is possible to intercept all objects moving through the ViewModel +and add useful functionality without requiring any changes to the Contract or Handler code. + +Interceptors receive `BallastNotification`s from the ViewModel at every step of processing (queued, started, +completed, failed, etc.): + +```kotlin +class CustomInterceptor : BallastInterceptor { + fun BallastInterceptorScope.start( + notifications: Flow>, + ) { + launch(start = CoroutineStart.UNDISPATCHED) { + notifications.awaitViewModelStart() + notifications + .onEach { /* observe notifications */ } + .collect() + } + } +} +``` + +Add interceptors to the configuration builder: + +```kotlin +BallastViewModelConfiguration.Builder() + .apply { + this += LoggingInterceptor() + this += BallastDebuggerInterceptor(debuggerConnection) + } + .withViewModel(...) + .build() +``` + +Ballast provides many built-in interceptors through its various modules. See the [See Also](#see-also) links and the +other modules in this repository for what's available. + +## Installation + +```kotlin +repositories { + mavenCentral() +} + +// for plain JVM or Android projects +dependencies { + implementation("io.github.copper-leaf:ballast-core:{{ballastVersion}}") +} + +// for multiplatform projects +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + implementation("io.github.copper-leaf:ballast-core:{{ballastVersion}}") + } + } + } +} +``` diff --git a/ballast-core/build.gradle.kts b/ballast-core/build.gradle.kts index 0a493a69..ca3b7c64 100644 --- a/ballast-core/build.gradle.kts +++ b/ballast-core/build.gradle.kts @@ -3,7 +3,7 @@ plugins { id("copper-leaf-android-library") id("copper-leaf-targets") id("copper-leaf-tests") -// id("copper-leaf-lint") + id("copper-leaf-lint") id("copper-leaf-publish") } diff --git a/ballast-crash-reporting/README.md b/ballast-crash-reporting/README.md new file mode 100644 index 00000000..5bf880e5 --- /dev/null +++ b/ballast-crash-reporting/README.md @@ -0,0 +1,96 @@ +# Ballast Crash Reporting + +## Overview + +Ballast's Crash Reporting module automatically sends errors in your ViewModels to you crash reporting SDK. Support +for Firebase Crashlytics is supported out-of-the-box on Android via [Ballast Firebase Crashlytics](./../ballast-firebase-crashlytics). + +## Supported Platforms + +| Platform | Supported | +|----------|-----------| +| JVM | ✅ | +| Android | ✅ | +| iOS | ✅ | +| JS | ✅ | +| WASM JS | ✅ | + +## See Also + +- [Ballast Analytics](./../ballast-analytics) +- [Ballast Firebase Analytics](./../ballast-firebase-analytics) +- [Ballast Firebase Crashlytics](./../ballast-firebase-crashlytics) + +## Usage + +```kotlin +class ExampleViewModel(coroutineScope: CoroutineScope) : BasicViewModel< + ExampleContract.Inputs, + ExampleContract.Events, + ExampleContract.State + >( + coroutineScope = coroutineScope, + config = BallastViewModelConfiguration.Builder() + .withViewModel(ExampleContract.State(), ExampleInputHandler()) + .apply { + interceptors += CrashReportingInterceptor( + crashReporter = ExampleCrashReporter(), + shouldTrackInput = { input -> + when (input) { + is ExampleContract.Inputs.TrackThis -> true + is ExampleContract.Inputs.DontTrackThis -> false + } + } + ) + } + .build(), + eventHandler = eventHandler { }, +) + +class ExampleCrashReporter : CrashReporter { + override fun logInput(viewModelName: String, input: Any) { + // log the event to your crash reporting system for trace of steps leading to a crash. Only inputs returning + // true from `shouldTrackInput` are sent here. + } + + override fun recordInputError(viewModelName: String, input: Any, throwable: Throwable) { + // record the error caused when handling an Input + } + + override fun recordEventError(viewModelName: String, event: Any, throwable: Throwable) { + // record the error caused when handling an Input + } + + override fun recordSideJobError(viewModelName: String, key: String, throwable: Throwable) { + // record the error caused by a running SideJob + } + + override fun recordUnhandledError(viewModelName: String, throwable: Throwable) { + // record the error caused by something else (most likely out of your control) + } +} +``` + +## Installation + +```kotlin +repositories { + mavenCentral() +} + +// for plain JVM or Android projects +dependencies { + implementation("io.github.copper-leaf:ballast-crash-reporting:{{ballastVersion}}") +} + +// for multiplatform projects +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + implementation("io.github.copper-leaf:ballast-crash-reporting:{{ballastVersion}}") + } + } + } +} +``` diff --git a/ballast-crash-reporting/build.gradle.kts b/ballast-crash-reporting/build.gradle.kts index 765dfb79..046709b8 100644 --- a/ballast-crash-reporting/build.gradle.kts +++ b/ballast-crash-reporting/build.gradle.kts @@ -3,7 +3,7 @@ plugins { id("copper-leaf-android-library") id("copper-leaf-targets") id("copper-leaf-tests") -// id("copper-leaf-lint") + id("copper-leaf-lint") id("copper-leaf-publish") } @@ -14,6 +14,12 @@ kotlin { implementation(project(":ballast-api")) } } + val commonTest by getting { + dependencies { + implementation(project(":ballast-test")) + implementation(project(":ballast-core")) + } + } val jvmMain by getting { dependencies { } } diff --git a/ballast-crash-reporting/src/commonMain/kotlin/com/copperleaf/ballast/crashreporting/CrashReporter.kt b/ballast-crash-reporting/src/commonMain/kotlin/com/copperleaf/ballast/crashreporting/CrashReporter.kt index 758fef6b..fff5053e 100644 --- a/ballast-crash-reporting/src/commonMain/kotlin/com/copperleaf/ballast/crashreporting/CrashReporter.kt +++ b/ballast-crash-reporting/src/commonMain/kotlin/com/copperleaf/ballast/crashreporting/CrashReporter.kt @@ -26,5 +26,4 @@ public interface CrashReporter { viewModelName: String, throwable: Throwable, ) - } diff --git a/ballast-crash-reporting/src/commonTest/kotlin/com/copperleaf/ballast/crashreporting/vm/TestContract.kt b/ballast-crash-reporting/src/commonTest/kotlin/com/copperleaf/ballast/crashreporting/vm/TestContract.kt new file mode 100644 index 00000000..c18dcff0 --- /dev/null +++ b/ballast-crash-reporting/src/commonTest/kotlin/com/copperleaf/ballast/crashreporting/vm/TestContract.kt @@ -0,0 +1,14 @@ +package com.copperleaf.ballast.crashreporting.vm + +object TestContract { + data class State( + val loading: Boolean = false, + ) + + sealed interface Inputs { + data object TrackThis : Inputs + data object DontTrackThis : Inputs + } + + sealed interface Events +} diff --git a/ballast-crash-reporting/src/commonTest/kotlin/com/copperleaf/ballast/crashreporting/vm/TestInputHandler.kt b/ballast-crash-reporting/src/commonTest/kotlin/com/copperleaf/ballast/crashreporting/vm/TestInputHandler.kt new file mode 100644 index 00000000..5ff3beee --- /dev/null +++ b/ballast-crash-reporting/src/commonTest/kotlin/com/copperleaf/ballast/crashreporting/vm/TestInputHandler.kt @@ -0,0 +1,23 @@ +package com.copperleaf.ballast.crashreporting.vm + +import com.copperleaf.ballast.InputHandler +import com.copperleaf.ballast.InputHandlerScope + +class TestInputHandler : InputHandler< + TestContract.Inputs, + TestContract.Events, + TestContract.State> { + override suspend fun InputHandlerScope< + TestContract.Inputs, + TestContract.Events, + TestContract.State>.handleInput( + input: TestContract.Inputs + ): Unit = when (input) { + TestContract.Inputs.DontTrackThis -> { + noOp() + } + TestContract.Inputs.TrackThis -> { + noOp() + } + } +} diff --git a/ballast-crash-reporting/src/commonTest/kotlin/com/copperleaf/ballast/crashreporting/vm/TestViewModel.kt b/ballast-crash-reporting/src/commonTest/kotlin/com/copperleaf/ballast/crashreporting/vm/TestViewModel.kt new file mode 100644 index 00000000..31824e90 --- /dev/null +++ b/ballast-crash-reporting/src/commonTest/kotlin/com/copperleaf/ballast/crashreporting/vm/TestViewModel.kt @@ -0,0 +1,55 @@ +package com.copperleaf.ballast.crashreporting.vm + +import com.copperleaf.ballast.BallastViewModelConfiguration +import com.copperleaf.ballast.build +import com.copperleaf.ballast.core.BasicViewModel +import com.copperleaf.ballast.crashreporting.CrashReporter +import com.copperleaf.ballast.crashreporting.CrashReportingInterceptor +import com.copperleaf.ballast.eventHandler +import com.copperleaf.ballast.withViewModel +import kotlinx.coroutines.CoroutineScope + +class TestViewModel(coroutineScope: CoroutineScope) : BasicViewModel< + TestContract.Inputs, + TestContract.Events, + TestContract.State + >( + coroutineScope = coroutineScope, + config = BallastViewModelConfiguration.Builder() + .withViewModel(TestContract.State(), TestInputHandler()) + .apply { + interceptors += CrashReportingInterceptor( + crashReporter = TestCrashReporter(), + shouldTrackInput = { input -> + when (input) { + is TestContract.Inputs.TrackThis -> true + is TestContract.Inputs.DontTrackThis -> false + } + } + ) + } + .build(), + eventHandler = eventHandler { }, +) + +class TestCrashReporter : CrashReporter { + override fun logInput(viewModelName: String, input: Any) { + // log the event to your crash reporting system for trace of steps leading to a crash + } + + override fun recordInputError(viewModelName: String, input: Any, throwable: Throwable) { + // record the error caused when handling an Input + } + + override fun recordEventError(viewModelName: String, event: Any, throwable: Throwable) { + // record the error caused when handling an Input + } + + override fun recordSideJobError(viewModelName: String, key: String, throwable: Throwable) { + // record the error caused by a running SideJob + } + + override fun recordUnhandledError(viewModelName: String, throwable: Throwable) { + // record the error caused by something else (most likely out of your control) + } +} diff --git a/ballast-debugger-client/README.md b/ballast-debugger-client/README.md new file mode 100644 index 00000000..7b053f2e --- /dev/null +++ b/ballast-debugger-client/README.md @@ -0,0 +1,222 @@ +# Ballast Debugger Client + +## Overview + +Ballast Debugger is a tool for inspecting the status of all components in your Ballast ViewModels through a graphical +UI. It consists of a client library which you install into your Ballast ViewModels as an Interceptor, and a companion +[IntelliJ plugin](./../ballast-idea-plugin) which displays the data collected from the interceptor and allows you to +browse and manipulate the ViewModels remotely. The client library communicates with the UI over WebSockets on +localhost, so it is intended to be used when running your application in a simulator/emulator or in the browser. + +Features: + +- Inspecting the status and data within all ViewModel features in real-time +- Time-travel debugging +- Direct State manipulation +- Remotely send Inputs +- Viewing ViewModel logs + +## Supported Platforms + +| Platform | Supported | +|----------|-----------| +| JVM | ✅ | +| Android | ✅ | +| iOS | ✅ | +| JS | ✅ | +| WASM JS | ✅ | + +## See Also + +- [Ballast Kotlinx Serialization](./../ballast-kotlinx-serialization) +- [Ballast IntelliJ Plugin](./../ballast-idea-plugin) + +## Usage + +### Basic Configuration + +Create a `BallastDebuggerClientConnection` with your choice of Ktor client engine and connect it on an +application-wide `CoroutineScope`. This starts a WebSocket connection to the IntelliJ plugin's server on localhost +port `9684` (the host and port are both configurable). The connection will automatically retry until it succeeds and +reconnect if terminated. + +The same connection should be shared among all ViewModels to optimize system resource usage and to group all +ViewModels together in the debugger UI. + +> **Warning:** The debugger drains system resources and potentially exposes sensitive information. You must ensure the +> debugger is not running in production. Configure your app to only start the connection and install the interceptor in +> debug builds — or better yet, only include the debugger dependency in debug builds so it can never run accidentally. + +```kotlin +private val debuggerConnection by lazy { + val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + BallastDebuggerClientConnection( + engineFactory = CIO, + applicationCoroutineScope = applicationScope, + host = "127.0.0.1", // use 10.0.2.2 when connecting from an Android emulator + ) { + // optional Ktor client engine configuration + }.also { it.connect() } +} + +class ExampleViewModel(coroutineScope: CoroutineScope) : BasicViewModel< + ExampleContract.Inputs, + ExampleContract.Events, + ExampleContract.State>( + coroutineScope = coroutineScope, + config = BallastViewModelConfiguration.Builder() + .withViewModel( + initialState = ExampleContract.State(), + inputHandler = ExampleInputHandler(), + name = "Example", + ) + .apply { + if (DEBUG) { + this += BallastDebuggerInterceptor(debuggerConnection) + } + } + .build(), + eventHandler = ExampleEventHandler(), +) +``` + +### Android + +On Android, connecting to the emulator's host machine requires cleartext traffic to `10.0.2.2`. Add a network security +configuration to permit this. + +Create `src/main/res/xml/network_security_config.xml` in your Android module: + +```xml + + + + 10.0.2.2 + + +``` + +Then reference it in your `AndroidManifest.xml`: + +```xml + + ... + +``` + +### State/Input Serialization + +Since v4.0.0, the Debugger allows you to send JSON from the graphical UI back to the connected ViewModel, where the +content is deserialized and processed as if sent from the application itself. This enables direct State manipulation and +sending Inputs remotely without recompiling your app. + +To opt in, make your State, Input, and Event classes serializable and tell the Interceptor how to deserialize them. + +#### kotlinx.serialization + +The simplest approach. Mark your classes with `@Serializable` and provide the generated serializers to the Interceptor: + +```kotlin +object ExampleContract { + @Serializable + data class State(val count: Int = 0) + + @Serializable + sealed interface Inputs { + @Serializable + data class Increment(val amount: Int) : Inputs + @Serializable + data class Decrement(val amount: Int) : Inputs + } + + @Serializable + sealed interface Events +} +``` + +Pass the serializers directly to `BallastDebuggerInterceptor`: + +```kotlin +this += BallastDebuggerInterceptor( + debuggerConnection, + inputsSerializer = ExampleContract.Inputs.serializer(), + eventsSerializer = ExampleContract.Events.serializer(), + stateSerializer = ExampleContract.State.serializer(), +) +``` + +Or wrap them in a `JsonDebuggerAdapter`: + +```kotlin +val adapter = JsonDebuggerAdapter( + inputsSerializer = ExampleContract.Inputs.serializer(), + eventsSerializer = ExampleContract.Events.serializer(), + stateSerializer = ExampleContract.State.serializer(), + json = Json { }, +) + +this += BallastDebuggerInterceptor(debuggerConnection, adapter = adapter) +``` + +#### Alternative serialization formats + +To use a different library (e.g. Moshi, Jackson) or format (e.g. XML), implement your own `DebuggerAdapter`: + +```kotlin +class MoshiDebuggerAdapter( + private val inputsAdapter: JsonAdapter, + private val eventsAdapter: JsonAdapter, + private val stateAdapter: JsonAdapter, +) : DebuggerAdapter { + override fun serializeInput(input: Inputs): Pair = + ContentType.Application.Json to inputsAdapter.toJson(input) + + override fun serializeEvent(event: Events): Pair = + ContentType.Application.Json to eventsAdapter.toJson(event) + + override fun serializeState(state: State): Pair = + ContentType.Application.Json to stateAdapter.toJson(state) + + override fun deserializeInput(contentType: ContentType, serializedInput: String): Inputs? { + check(contentType == ContentType.Application.Json) + return inputsAdapter.fromJson(serializedInput) + } + + override fun deserializeState(contentType: ContentType, serializedState: String): State? { + check(contentType == ContentType.Application.Json) + return stateAdapter.fromJson(serializedState) + } +} +``` + +Then pass an instance to the Interceptor: + +```kotlin +this += BallastDebuggerInterceptor(debuggerConnection, adapter = MoshiDebuggerAdapter(...)) +``` + +## Installation + +```kotlin +repositories { + mavenCentral() +} + +// for plain JVM or Android projects +dependencies { + debugImplementation("io.github.copper-leaf:ballast-debugger-client:{{ballastVersion}}") +} + +// for multiplatform projects +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + implementation("io.github.copper-leaf:ballast-debugger-client:{{ballastVersion}}") + } + } + } +} +``` diff --git a/ballast-debugger-client/build.gradle.kts b/ballast-debugger-client/build.gradle.kts index ad1f9c87..7180ee5f 100644 --- a/ballast-debugger-client/build.gradle.kts +++ b/ballast-debugger-client/build.gradle.kts @@ -7,7 +7,7 @@ plugins { id("copper-leaf-buildConfig") id("copper-leaf-serialization") id("copper-leaf-tests") -// id("copper-leaf-lint") + id("copper-leaf-lint") id("copper-leaf-publish") } @@ -43,20 +43,20 @@ kotlin { buildConfig { projectVersion(project, "BALLAST_VERSION") + packageName.set("io.github.copperleaf.ballastdebuggerclient") } - -//tasks.withType { +// tasks.withType { // compilerOptions { -//// jvmTarget.set(ConventionConfig.repoInfo(project).javaVersion) -//// freeCompilerArgs.add("-opt-in=kotlin.ExperimentalStdlibApi") -//// freeCompilerArgs.add("-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi") -//// freeCompilerArgs.add("-opt-in=androidx.compose.foundation.ExperimentalFoundationApi") -//// freeCompilerArgs.add("-opt-in=androidx.compose.animation.ExperimentalAnimationApi") -//// freeCompilerArgs.add("-opt-in=androidx.compose.ui.ExperimentalComposeUiApi") -//// freeCompilerArgs.add("-opt-in=androidx.compose.material.ExperimentalMaterialApi") -//// freeCompilerArgs.add("-opt-in=org.jetbrains.compose.splitpane.ExperimentalSplitPaneApi") -//// freeCompilerArgs.add("-opt-in=kotlin.time.ExperimentalTime") +// // jvmTarget.set(ConventionConfig.repoInfo(project).javaVersion) +// // freeCompilerArgs.add("-opt-in=kotlin.ExperimentalStdlibApi") +// // freeCompilerArgs.add("-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi") +// // freeCompilerArgs.add("-opt-in=androidx.compose.foundation.ExperimentalFoundationApi") +// // freeCompilerArgs.add("-opt-in=androidx.compose.animation.ExperimentalAnimationApi") +// // freeCompilerArgs.add("-opt-in=androidx.compose.ui.ExperimentalComposeUiApi") +// // freeCompilerArgs.add("-opt-in=androidx.compose.material.ExperimentalMaterialApi") +// // freeCompilerArgs.add("-opt-in=org.jetbrains.compose.splitpane.ExperimentalSplitPaneApi") +// // freeCompilerArgs.add("-opt-in=kotlin.time.ExperimentalTime") // freeCompilerArgs.add("-opt-in=kotlin.uuid.ExperimentalUuidApi") // } -//} +// } diff --git a/ballast-debugger-client/src/commonMain/kotlin/com/copperleaf/ballast/debugger/BallastDebuggerClientConnection.kt b/ballast-debugger-client/src/commonMain/kotlin/com/copperleaf/ballast/debugger/BallastDebuggerClientConnection.kt index 5376b331..57b6d795 100644 --- a/ballast-debugger-client/src/commonMain/kotlin/com/copperleaf/ballast/debugger/BallastDebuggerClientConnection.kt +++ b/ballast-debugger-client/src/commonMain/kotlin/com/copperleaf/ballast/debugger/BallastDebuggerClientConnection.kt @@ -1,5 +1,6 @@ package com.copperleaf.ballast.debugger +import com.copperleaf.ballast.BallastEncoder import com.copperleaf.ballast.BallastInterceptorScope import com.copperleaf.ballast.BallastLogger import com.copperleaf.ballast.BallastNotification @@ -12,7 +13,7 @@ import com.copperleaf.ballast.debugger.models.getActualValue import com.copperleaf.ballast.debugger.utils.now import com.copperleaf.ballast.debugger.versions.v5.BallastDebuggerActionV5 import com.copperleaf.ballast.debugger.versions.v5.BallastDebuggerEventV5 -import io.github.copper_leaf.ballast_debugger_client.BALLAST_VERSION +import io.github.copperleaf.ballastdebuggerclient.BALLAST_VERSION import io.ktor.client.HttpClient import io.ktor.client.HttpClientConfig import io.ktor.client.engine.HttpClientEngineConfig @@ -192,7 +193,7 @@ public class BallastDebuggerClientConnection( applicationCoroutineScope.launch(start = CoroutineStart.UNDISPATCHED) { viewModelConnection .notifications - .collect { acceptNotification(it, viewModelConnection) } + .collect { acceptNotification(it, viewModelConnection, encoder) } }.invokeOnCompletion { processIncomingJob.cancel() } } @@ -201,7 +202,8 @@ public class BallastDebuggerClientConnection( private suspend fun acceptNotification( notification: BallastNotification, - viewModelConnection: BallastDebuggerViewModelConnection + viewModelConnection: BallastDebuggerViewModelConnection, + ballastEncoder: BallastEncoder, ) { outgoingMessages.send( BallastDebuggerOutgoingEventWrapper( @@ -209,6 +211,7 @@ public class BallastDebuggerClientConnection( notification = notification, debuggerEvent = null, updateConnectionState = true, + ballastEncoder = ballastEncoder, ) ) waitForEvent.complete(Unit) @@ -344,6 +347,7 @@ public class BallastDebuggerClientConnection( action.viewModelName, ), updateConnectionState = false, + ballastEncoder = encoder, ) ) @@ -354,6 +358,7 @@ public class BallastDebuggerClientConnection( notification = null, debuggerEvent = it, updateConnectionState = false, + ballastEncoder = encoder, ) ) } @@ -367,6 +372,7 @@ public class BallastDebuggerClientConnection( action.viewModelName, ), updateConnectionState = false, + ballastEncoder = encoder, ) ) @@ -389,10 +395,19 @@ public class BallastDebuggerClientConnection( is BallastDebuggerActionV5.RequestReplaceState -> { val stateToReplaceResult = runCatching { - viewModelConnection.adapter.deserializeState( - ContentType.parse(action.stateContentType), - action.serializedState, - ) + if (viewModelConnection.adapter != null) { + // (legacy) decode using the viewModelConnection.adapter + viewModelConnection.adapter!!.deserializeState( + ContentType.parse(action.stateContentType), + action.serializedState, + ) + } else if (decoder != null) { + // (replacement) decode using the VM configuration's decoder + decoder?.decodeStateFromString(action.serializedState) + } else { + // do not decode + null + } } stateToReplaceResult.fold( @@ -425,10 +440,19 @@ public class BallastDebuggerClientConnection( is BallastDebuggerActionV5.RequestSendInput -> { val inputToSendResult = runCatching { - viewModelConnection.adapter.deserializeInput( - ContentType.parse(action.inputContentType), - action.serializedInput, - ) + if (viewModelConnection.adapter != null) { + // (legacy) decode using the viewModelConnection.adapter + viewModelConnection.adapter!!.deserializeInput( + ContentType.parse(action.inputContentType), + action.serializedInput, + ) + } else if (decoder != null) { + // (replacement) decode using the VM configuration's decoder + decoder?.decodeInputFromString(action.serializedInput) + } else { + // do not decode + null + } } inputToSendResult.fold( diff --git a/ballast-debugger-client/src/commonMain/kotlin/com/copperleaf/ballast/debugger/BallastDebuggerInterceptor.kt b/ballast-debugger-client/src/commonMain/kotlin/com/copperleaf/ballast/debugger/BallastDebuggerInterceptor.kt index d81dc206..f8bd5e84 100644 --- a/ballast-debugger-client/src/commonMain/kotlin/com/copperleaf/ballast/debugger/BallastDebuggerInterceptor.kt +++ b/ballast-debugger-client/src/commonMain/kotlin/com/copperleaf/ballast/debugger/BallastDebuggerInterceptor.kt @@ -8,9 +8,10 @@ import kotlinx.coroutines.flow.Flow import kotlinx.serialization.KSerializer import kotlinx.serialization.json.Json +@Suppress("DEPRECATION") public class BallastDebuggerInterceptor( private val connection: BallastDebuggerClientConnection<*>, - private val adapter: DebuggerAdapter = ToStringDebuggerAdapter(), + private val adapter: DebuggerAdapter? = ToStringDebuggerAdapter(), ) : BallastInterceptor { override fun BallastInterceptorScope.start(notifications: Flow>) { @@ -31,6 +32,7 @@ public class BallastDebuggerInterceptor public companion object { + @Deprecated("Set the serializers in the BallastViewModelConfiguration instead.") public operator fun invoke( connection: BallastDebuggerClientConnection<*>, serializeInput: (Inputs) -> Pair, @@ -47,6 +49,7 @@ public class BallastDebuggerInterceptor ) } + @Deprecated("Set the serializers in the BallastViewModelConfiguration instead.") public operator fun invoke( connection: BallastDebuggerClientConnection<*>, inputsSerializer: KSerializer? = null, diff --git a/ballast-debugger-client/src/commonMain/kotlin/com/copperleaf/ballast/debugger/JsonDebuggerAdapter.kt b/ballast-debugger-client/src/commonMain/kotlin/com/copperleaf/ballast/debugger/JsonDebuggerAdapter.kt index 0ebc7640..0f538324 100644 --- a/ballast-debugger-client/src/commonMain/kotlin/com/copperleaf/ballast/debugger/JsonDebuggerAdapter.kt +++ b/ballast-debugger-client/src/commonMain/kotlin/com/copperleaf/ballast/debugger/JsonDebuggerAdapter.kt @@ -4,6 +4,8 @@ import io.ktor.http.ContentType import kotlinx.serialization.KSerializer import kotlinx.serialization.json.Json +@Suppress("DEPRECATION") +@Deprecated("Set the serializers in the BallastViewModelConfiguration instead.") public class JsonDebuggerAdapter( private val inputsSerializer: KSerializer? = null, private val eventsSerializer: KSerializer? = null, diff --git a/ballast-debugger-client/src/commonMain/kotlin/com/copperleaf/ballast/debugger/LambdaDebuggerAdapter.kt b/ballast-debugger-client/src/commonMain/kotlin/com/copperleaf/ballast/debugger/LambdaDebuggerAdapter.kt index 0055ffc2..86b127ac 100644 --- a/ballast-debugger-client/src/commonMain/kotlin/com/copperleaf/ballast/debugger/LambdaDebuggerAdapter.kt +++ b/ballast-debugger-client/src/commonMain/kotlin/com/copperleaf/ballast/debugger/LambdaDebuggerAdapter.kt @@ -2,6 +2,8 @@ package com.copperleaf.ballast.debugger import io.ktor.http.ContentType +@Suppress("DEPRECATION") +@Deprecated("Set the serializers in the BallastViewModelConfiguration instead.") internal class LambdaDebuggerAdapter( private val serializeInput: ((Inputs) -> Pair)?, private val serializeEvent: ((Events) -> Pair)?, diff --git a/ballast-debugger-client/src/commonMain/kotlin/com/copperleaf/ballast/debugger/ToStringDebuggerAdapter.kt b/ballast-debugger-client/src/commonMain/kotlin/com/copperleaf/ballast/debugger/ToStringDebuggerAdapter.kt index fd720695..667b2e4d 100644 --- a/ballast-debugger-client/src/commonMain/kotlin/com/copperleaf/ballast/debugger/ToStringDebuggerAdapter.kt +++ b/ballast-debugger-client/src/commonMain/kotlin/com/copperleaf/ballast/debugger/ToStringDebuggerAdapter.kt @@ -2,6 +2,8 @@ package com.copperleaf.ballast.debugger import io.ktor.http.ContentType +@Suppress("DEPRECATION") +@Deprecated("Set the serializers in the BallastViewModelConfiguration instead.") public class ToStringDebuggerAdapter : DebuggerAdapter { diff --git a/ballast-debugger-models/README.md b/ballast-debugger-models/README.md new file mode 100644 index 00000000..9c3d53c1 --- /dev/null +++ b/ballast-debugger-models/README.md @@ -0,0 +1,50 @@ +# Ballast Debugger Models + +## Overview + +Shared data models used by both [Ballast Debugger Client](./../ballast-debugger-client) and the Ballast IntelliJ Plugin +for inspecting the internal state and activity of Ballast ViewModels. Typically you do not need to depend on this module +directly; use [Ballast Debugger Client](./../ballast-debugger-client) instead. + +## Supported Platforms + +| Platform | Supported | +|----------|-----------| +| JVM | ✅ | +| Android | ✅ | +| iOS | ✅ | +| JS | ✅ | +| WASM JS | ✅ | + +## See Also + +- [Ballast Debugger Client](./../ballast-debugger-client) + +## Usage + +`ballast-debugger-models` is not intended for direct use in application code. Use +[Ballast Debugger Client](./../ballast-debugger-client) to connect your ViewModels to the Ballast IntelliJ Plugin. + +## Installation + +```kotlin +repositories { + mavenCentral() +} + +// for plain JVM or Android projects +dependencies { + implementation("io.github.copper-leaf:ballast-debugger-models:{{ballastVersion}}") +} + +// for multiplatform projects +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + implementation("io.github.copper-leaf:ballast-debugger-models:{{ballastVersion}}") + } + } + } +} +``` diff --git a/ballast-debugger-models/api/android/ballast-debugger-models.api b/ballast-debugger-models/api/android/ballast-debugger-models.api index cfd29b86..5d3070e6 100644 --- a/ballast-debugger-models/api/android/ballast-debugger-models.api +++ b/ballast-debugger-models/api/android/ballast-debugger-models.api @@ -1,5 +1,5 @@ public final class com/copperleaf/ballast/debugger/BallastDebuggerOutgoingEventWrapper { - public fun (Lcom/copperleaf/ballast/debugger/BallastDebuggerViewModelConnection;Lcom/copperleaf/ballast/BallastNotification;Lcom/copperleaf/ballast/debugger/versions/v5/BallastDebuggerEventV5;Z)V + public fun (Lcom/copperleaf/ballast/debugger/BallastDebuggerViewModelConnection;Lcom/copperleaf/ballast/BallastNotification;Lcom/copperleaf/ballast/debugger/versions/v5/BallastDebuggerEventV5;ZLcom/copperleaf/ballast/BallastEncoder;)V public final fun getConnection ()Lcom/copperleaf/ballast/debugger/BallastDebuggerViewModelConnection; public final fun getDebuggerEvent ()Lcom/copperleaf/ballast/debugger/versions/v5/BallastDebuggerEventV5; public final fun getNotification ()Lcom/copperleaf/ballast/BallastNotification; diff --git a/ballast-debugger-models/api/jvm/ballast-debugger-models.api b/ballast-debugger-models/api/jvm/ballast-debugger-models.api index cfd29b86..5d3070e6 100644 --- a/ballast-debugger-models/api/jvm/ballast-debugger-models.api +++ b/ballast-debugger-models/api/jvm/ballast-debugger-models.api @@ -1,5 +1,5 @@ public final class com/copperleaf/ballast/debugger/BallastDebuggerOutgoingEventWrapper { - public fun (Lcom/copperleaf/ballast/debugger/BallastDebuggerViewModelConnection;Lcom/copperleaf/ballast/BallastNotification;Lcom/copperleaf/ballast/debugger/versions/v5/BallastDebuggerEventV5;Z)V + public fun (Lcom/copperleaf/ballast/debugger/BallastDebuggerViewModelConnection;Lcom/copperleaf/ballast/BallastNotification;Lcom/copperleaf/ballast/debugger/versions/v5/BallastDebuggerEventV5;ZLcom/copperleaf/ballast/BallastEncoder;)V public final fun getConnection ()Lcom/copperleaf/ballast/debugger/BallastDebuggerViewModelConnection; public final fun getDebuggerEvent ()Lcom/copperleaf/ballast/debugger/versions/v5/BallastDebuggerEventV5; public final fun getNotification ()Lcom/copperleaf/ballast/BallastNotification; diff --git a/ballast-debugger-models/build.gradle.kts b/ballast-debugger-models/build.gradle.kts index 76b2de92..b3e4ecbb 100644 --- a/ballast-debugger-models/build.gradle.kts +++ b/ballast-debugger-models/build.gradle.kts @@ -1,13 +1,10 @@ -import com.copperleaf.gradle.projectVersion - plugins { id("copper-leaf-base") id("copper-leaf-android-library") id("copper-leaf-targets") - id("copper-leaf-buildConfig") id("copper-leaf-serialization") id("copper-leaf-tests") -// id("copper-leaf-lint") + id("copper-leaf-lint") id("copper-leaf-publish") } @@ -38,7 +35,3 @@ kotlin { } } } - -buildConfig { - projectVersion(project, "BALLAST_VERSION") -} diff --git a/ballast-debugger-models/src/commonMain/kotlin/com/copperleaf/ballast/debugger/BallastDebuggerViewModelConnection.kt b/ballast-debugger-models/src/commonMain/kotlin/com/copperleaf/ballast/debugger/BallastDebuggerViewModelConnection.kt index 3ea8318a..662c497a 100644 --- a/ballast-debugger-models/src/commonMain/kotlin/com/copperleaf/ballast/debugger/BallastDebuggerViewModelConnection.kt +++ b/ballast-debugger-models/src/commonMain/kotlin/com/copperleaf/ballast/debugger/BallastDebuggerViewModelConnection.kt @@ -1,5 +1,6 @@ package com.copperleaf.ballast.debugger +import com.copperleaf.ballast.BallastEncoder import com.copperleaf.ballast.BallastNotification import com.copperleaf.ballast.debugger.models.serialize import com.copperleaf.ballast.debugger.versions.v5.BallastDebuggerEventV5 @@ -9,10 +10,11 @@ import kotlinx.datetime.LocalDateTime public const val CONNECTION_ID_HEADER: String = "x-ballast-connection-id" public const val BALLAST_VERSION_HEADER: String = "x-ballast-version" +@Suppress("DEPRECATION") public data class BallastDebuggerViewModelConnection( public val viewModelName: String, public val notifications: Flow>, - public val adapter: DebuggerAdapter + public val adapter: DebuggerAdapter? ) public class BallastDebuggerOutgoingEventWrapper( @@ -20,6 +22,7 @@ public class BallastDebuggerOutgoingEventWrapper?, public val debuggerEvent: BallastDebuggerEventV5?, public val updateConnectionState: Boolean, + private val ballastEncoder: BallastEncoder, ) { public fun serialize( connectionId: String, @@ -33,6 +36,7 @@ public class BallastDebuggerOutgoingEventWrapper { public fun serializeInput(input: Inputs): Pair { return ContentType.Text.Any to input.toString() diff --git a/ballast-debugger-models/src/commonMain/kotlin/com/copperleaf/ballast/debugger/models/BallastApplicationState.kt b/ballast-debugger-models/src/commonMain/kotlin/com/copperleaf/ballast/debugger/models/BallastApplicationState.kt index b8235de9..65828d2d 100644 --- a/ballast-debugger-models/src/commonMain/kotlin/com/copperleaf/ballast/debugger/models/BallastApplicationState.kt +++ b/ballast-debugger-models/src/commonMain/kotlin/com/copperleaf/ballast/debugger/models/BallastApplicationState.kt @@ -21,7 +21,7 @@ public data class BallastApplicationState( this[indexOfConnection] = this[indexOfConnection].block().copy(lastSeen = LocalDateTime.now()) } else { // this is the first time we're seeing this connection, create a new entry for it - this.add(0, BallastConnectionState(connectionId, firstSeen = LocalDateTime.now()).block()) + this.add(0, BallastConnectionState(connectionId, firstSeen = LocalDateTime.now()).block()) } } .toList(), diff --git a/ballast-debugger-models/src/commonMain/kotlin/com/copperleaf/ballast/debugger/models/BallastLocalDateTimeSerializer.kt b/ballast-debugger-models/src/commonMain/kotlin/com/copperleaf/ballast/debugger/models/BallastLocalDateTimeSerializer.kt index 4bc30480..5daf7345 100644 --- a/ballast-debugger-models/src/commonMain/kotlin/com/copperleaf/ballast/debugger/models/BallastLocalDateTimeSerializer.kt +++ b/ballast-debugger-models/src/commonMain/kotlin/com/copperleaf/ballast/debugger/models/BallastLocalDateTimeSerializer.kt @@ -27,7 +27,7 @@ import kotlinx.serialization.encoding.Encoder * https://github.com/Kotlin/kotlinx-datetime/blob/94bcc6ff1733c22ef4f937a25a276d3fd728a301/LICENSE.txt * https://github.com/Kotlin/kotlinx-datetime/blob/94bcc6ff1733c22ef4f937a25a276d3fd728a301/LICENSE.txt */ -public object BallastLocalDateTimeSerializer: KSerializer { +public object BallastLocalDateTimeSerializer : KSerializer { override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING) diff --git a/ballast-debugger-models/src/commonMain/kotlin/com/copperleaf/ballast/debugger/models/BallastViewModelState.kt b/ballast-debugger-models/src/commonMain/kotlin/com/copperleaf/ballast/debugger/models/BallastViewModelState.kt index c67ac43a..1de49340 100644 --- a/ballast-debugger-models/src/commonMain/kotlin/com/copperleaf/ballast/debugger/models/BallastViewModelState.kt +++ b/ballast-debugger-models/src/commonMain/kotlin/com/copperleaf/ballast/debugger/models/BallastViewModelState.kt @@ -420,8 +420,12 @@ public data class BallastViewModelState( } } - is BallastDebuggerEventV5.Heartbeat -> { this } - is BallastDebuggerEventV5.UnhandledError -> { this } + is BallastDebuggerEventV5.Heartbeat -> { + this + } + is BallastDebuggerEventV5.UnhandledError -> { + this + } } val newHistory = when (event) { @@ -441,5 +445,4 @@ public data class BallastViewModelState( fullHistory = newHistory ) } - } diff --git a/ballast-debugger-models/src/commonMain/kotlin/com/copperleaf/ballast/debugger/models/json.kt b/ballast-debugger-models/src/commonMain/kotlin/com/copperleaf/ballast/debugger/models/json.kt index 9be888ba..f6cfb9fd 100644 --- a/ballast-debugger-models/src/commonMain/kotlin/com/copperleaf/ballast/debugger/models/json.kt +++ b/ballast-debugger-models/src/commonMain/kotlin/com/copperleaf/ballast/debugger/models/json.kt @@ -1,7 +1,11 @@ +@file:Suppress("IfThenToElvis", "DEPRECATION") + package com.copperleaf.ballast.debugger.models +import com.copperleaf.ballast.BallastEncoder import com.copperleaf.ballast.BallastNotification import com.copperleaf.ballast.debugger.BallastDebuggerViewModelConnection +import com.copperleaf.ballast.debugger.DebuggerAdapter import com.copperleaf.ballast.debugger.versions.v5.BallastDebuggerEventV5 import com.copperleaf.ballast.internal.Status import io.ktor.http.ContentType @@ -18,56 +22,57 @@ internal fun BallastNotification, ): BallastDebuggerEventV5 { return when (this) { is BallastNotification.ViewModelStatusChanged -> { BallastDebuggerEventV5.ViewModelStatusChanged(connectionId, viewModelName, viewModelType, uuid, firstSeen, status.serialize()) } is BallastNotification.InputQueued -> { - val (contentType, serializedContent) = viewModelConnection.adapter.serializeInput(input) + val (contentType, serializedContent) = serializeInput(viewModelConnection.adapter, ballastEncoder, input) BallastDebuggerEventV5.InputQueued(connectionId, viewModelName, uuid, firstSeen, input.type, serializedContent, contentType.asContentTypeString()) } is BallastNotification.InputAccepted -> { - val (contentType, serializedContent) = viewModelConnection.adapter.serializeInput(input) + val (contentType, serializedContent) = serializeInput(viewModelConnection.adapter, ballastEncoder, input) BallastDebuggerEventV5.InputAccepted(connectionId, viewModelName, uuid, now, input.type, serializedContent, contentType.asContentTypeString()) } is BallastNotification.InputRejected -> { - val (contentType, serializedContent) = viewModelConnection.adapter.serializeInput(input) + val (contentType, serializedContent) = serializeInput(viewModelConnection.adapter, ballastEncoder, input) BallastDebuggerEventV5.InputRejected(connectionId, viewModelName, uuid, now, input.type, serializedContent, contentType.asContentTypeString()) } is BallastNotification.InputDropped -> { - val (contentType, serializedContent) = viewModelConnection.adapter.serializeInput(input) + val (contentType, serializedContent) = serializeInput(viewModelConnection.adapter, ballastEncoder, input) BallastDebuggerEventV5.InputDropped(connectionId, viewModelName, uuid, now, input.type, serializedContent, contentType.asContentTypeString()) } is BallastNotification.InputHandledSuccessfully -> { - val (contentType, serializedContent) = viewModelConnection.adapter.serializeInput(input) + val (contentType, serializedContent) = serializeInput(viewModelConnection.adapter, ballastEncoder, input) BallastDebuggerEventV5.InputHandledSuccessfully(connectionId, viewModelName, uuid, now, input.type, serializedContent, contentType.asContentTypeString()) } is BallastNotification.InputCancelled -> { - val (contentType, serializedContent) = viewModelConnection.adapter.serializeInput(input) + val (contentType, serializedContent) = serializeInput(viewModelConnection.adapter, ballastEncoder, input) BallastDebuggerEventV5.InputCancelled(connectionId, viewModelName, uuid, now, input.type, serializedContent, contentType.asContentTypeString()) } is BallastNotification.InputHandlerError -> { - val (contentType, serializedContent) = viewModelConnection.adapter.serializeInput(input) + val (contentType, serializedContent) = serializeInput(viewModelConnection.adapter, ballastEncoder, input) BallastDebuggerEventV5.InputHandlerError( connectionId, viewModelName, uuid, now, input.type, serializedContent, contentType.asContentTypeString(), throwable.stackTraceToString() ) } is BallastNotification.EventQueued -> { - val (contentType, serializedContent) = viewModelConnection.adapter.serializeEvent(event) + val (contentType, serializedContent) = serializeEvent(viewModelConnection.adapter, ballastEncoder, event) BallastDebuggerEventV5.EventQueued(connectionId, viewModelName, uuid, firstSeen, event.type, serializedContent, contentType.asContentTypeString()) } is BallastNotification.EventEmitted -> { - val (contentType, serializedContent) = viewModelConnection.adapter.serializeEvent(event) + val (contentType, serializedContent) = serializeEvent(viewModelConnection.adapter, ballastEncoder, event) BallastDebuggerEventV5.EventEmitted(connectionId, viewModelName, uuid, now, event.type, serializedContent, contentType.asContentTypeString()) } is BallastNotification.EventHandledSuccessfully -> { - val (contentType, serializedContent) = viewModelConnection.adapter.serializeEvent(event) + val (contentType, serializedContent) = serializeEvent(viewModelConnection.adapter, ballastEncoder, event) BallastDebuggerEventV5.EventHandledSuccessfully(connectionId, viewModelName, uuid, now, event.type, serializedContent, contentType.asContentTypeString()) } is BallastNotification.EventHandlerError -> { - val (contentType, serializedContent) = viewModelConnection.adapter.serializeEvent(event) + val (contentType, serializedContent) = serializeEvent(viewModelConnection.adapter, ballastEncoder, event) BallastDebuggerEventV5.EventHandlerError( connectionId, viewModelName, uuid, now, event.type, serializedContent, contentType.asContentTypeString(), throwable.stackTraceToString() @@ -80,7 +85,7 @@ internal fun BallastNotification { - val (contentType, serializedContent) = viewModelConnection.adapter.serializeState(state) + val (contentType, serializedContent) = serializeState(viewModelConnection.adapter, ballastEncoder, state) BallastDebuggerEventV5.StateChanged(connectionId, viewModelName, uuid, firstSeen, state.type, serializedContent, contentType.asContentTypeString()) } @@ -111,7 +116,7 @@ internal fun BallastNotification { BallastDebuggerEventV5.InterceptorAttached(connectionId, viewModelName, uuid, now, interceptor.type, interceptor.toString()) } - is BallastNotification.InterceptorFailed-> { + is BallastNotification.InterceptorFailed -> { BallastDebuggerEventV5.InterceptorFailed(connectionId, viewModelName, uuid, now, interceptor.type, interceptor.toString(), throwable.stackTraceToString()) } } @@ -138,7 +143,7 @@ public fun BallastNotification BallastDebuggerEventV5.StatusV5.NotStarted is Status.Running -> BallastDebuggerEventV5.StatusV5.Running is Status.ShuttingDown -> BallastDebuggerEventV5.StatusV5.ShuttingDown @@ -149,3 +154,48 @@ public fun Status.serialize(): BallastDebuggerEventV5.StatusV5 { private fun ContentType.asContentTypeString(): String { return "$contentType/$contentSubtype" } + +internal fun serializeInput( + debuggerAdapter: DebuggerAdapter?, + ballastEncoder: BallastEncoder, + input: Inputs, +): Pair { + return if (debuggerAdapter != null) { + debuggerAdapter.serializeInput(input) + } else { + val contentType = ballastEncoder.contentType + ?.let { runCatching { ContentType.parse(it) }.getOrNull() } + ?: ContentType.Any + contentType to ballastEncoder.encodeInputToString(input) + } +} + +internal fun BallastNotification.serializeEvent( + debuggerAdapter: DebuggerAdapter?, + ballastEncoder: BallastEncoder, + event: Events, +): Pair { + return if (debuggerAdapter != null) { + debuggerAdapter.serializeEvent(event) + } else { + val contentType = ballastEncoder.contentType + ?.let { runCatching { ContentType.parse(it) }.getOrNull() } + ?: ContentType.Any + contentType to ballastEncoder.encodeEventToString(event) + } +} + +internal fun BallastNotification.serializeState( + debuggerAdapter: DebuggerAdapter?, + ballastEncoder: BallastEncoder, + state: State, +): Pair { + return if (debuggerAdapter != null) { + debuggerAdapter.serializeState(state) + } else { + val contentType = ballastEncoder.contentType + ?.let { runCatching { ContentType.parse(it) }.getOrNull() } + ?: ContentType.Any + contentType to ballastEncoder.encodeStateToString(state) + } +} diff --git a/ballast-debugger-models/src/commonMain/kotlin/com/copperleaf/ballast/debugger/utils/utils.kt b/ballast-debugger-models/src/commonMain/kotlin/com/copperleaf/ballast/debugger/utils/utils.kt index 7655f0fd..99343078 100644 --- a/ballast-debugger-models/src/commonMain/kotlin/com/copperleaf/ballast/debugger/utils/utils.kt +++ b/ballast-debugger-models/src/commonMain/kotlin/com/copperleaf/ballast/debugger/utils/utils.kt @@ -25,7 +25,7 @@ public operator fun LocalDateTime.minus(other: LocalDateTime): Duration { @Suppress("REDUNDANT_ELSE_IN_WHEN") public fun Duration.removeFraction(minUnit: DurationUnit): Duration { - return when(minUnit) { + return when (minUnit) { DurationUnit.NANOSECONDS -> this.inWholeNanoseconds.nanoseconds DurationUnit.MICROSECONDS -> this.inWholeMicroseconds.microseconds DurationUnit.MILLISECONDS -> this.inWholeMilliseconds.milliseconds diff --git a/ballast-debugger-models/src/commonMain/kotlin/com/copperleaf/ballast/debugger/versions/v5/BallastDebuggerEventV5.kt b/ballast-debugger-models/src/commonMain/kotlin/com/copperleaf/ballast/debugger/versions/v5/BallastDebuggerEventV5.kt index 080b13ad..2c96ddd1 100644 --- a/ballast-debugger-models/src/commonMain/kotlin/com/copperleaf/ballast/debugger/versions/v5/BallastDebuggerEventV5.kt +++ b/ballast-debugger-models/src/commonMain/kotlin/com/copperleaf/ballast/debugger/versions/v5/BallastDebuggerEventV5.kt @@ -87,7 +87,6 @@ public sealed class BallastDebuggerEventV5 { } } - // Inputs // --------------------------------------------------------------------------------------------------------------------- @@ -493,7 +492,9 @@ public sealed class BallastDebuggerEventV5 { @Serializable public enum class StatusV5 { - NotStarted, Running, ShuttingDown, Cleared + NotStarted, + Running, + ShuttingDown, + Cleared } - } diff --git a/ballast-debugger-server/build.gradle.kts b/ballast-debugger-server/build.gradle.kts index b3e4d9e9..05fb5d65 100644 --- a/ballast-debugger-server/build.gradle.kts +++ b/ballast-debugger-server/build.gradle.kts @@ -6,7 +6,7 @@ plugins { id("copper-leaf-tests") id("copper-leaf-serialization") id("copper-leaf-buildConfig") -// id("copper-leaf-lint") + id("copper-leaf-lint") id("copper-leaf-publish") } @@ -34,4 +34,5 @@ kotlin { buildConfig { projectVersion(project, "BALLAST_VERSION") + packageName.set("io.github.copperleaf.ballastdebuggerserver") } diff --git a/ballast-debugger-server/src/commonMain/kotlin/com/copperleaf/ballast/debugger/server/BallastDebuggerServerConnection.kt b/ballast-debugger-server/src/commonMain/kotlin/com/copperleaf/ballast/debugger/server/BallastDebuggerServerConnection.kt index 229141d3..5220e32f 100644 --- a/ballast-debugger-server/src/commonMain/kotlin/com/copperleaf/ballast/debugger/server/BallastDebuggerServerConnection.kt +++ b/ballast-debugger-server/src/commonMain/kotlin/com/copperleaf/ballast/debugger/server/BallastDebuggerServerConnection.kt @@ -10,7 +10,7 @@ import com.copperleaf.ballast.debugger.versions.ClientModelSerializer import com.copperleaf.ballast.debugger.versions.ClientVersion import com.copperleaf.ballast.debugger.versions.v5.BallastDebuggerActionV5 import com.copperleaf.ballast.debugger.versions.v5.BallastDebuggerEventV5 -import io.github.copper_leaf.ballast_debugger_server.BALLAST_VERSION +import io.github.copperleaf.ballastdebuggerserver.BALLAST_VERSION import io.ktor.server.application.install import io.ktor.server.cio.CIO import io.ktor.server.engine.embeddedServer diff --git a/ballast-debugger-server/src/commonMain/kotlin/com/copperleaf/ballast/debugger/server/BallastDebuggerServerSettings.kt b/ballast-debugger-server/src/commonMain/kotlin/com/copperleaf/ballast/debugger/server/BallastDebuggerServerSettings.kt index bab06b04..b0677c01 100644 --- a/ballast-debugger-server/src/commonMain/kotlin/com/copperleaf/ballast/debugger/server/BallastDebuggerServerSettings.kt +++ b/ballast-debugger-server/src/commonMain/kotlin/com/copperleaf/ballast/debugger/server/BallastDebuggerServerSettings.kt @@ -10,4 +10,3 @@ package com.copperleaf.ballast.debugger.server public interface BallastDebuggerServerSettings { public val debuggerServerPort: Int } - diff --git a/ballast-debugger-server/src/commonMain/kotlin/com/copperleaf/ballast/debugger/server/vm/DebuggerServerContract.kt b/ballast-debugger-server/src/commonMain/kotlin/com/copperleaf/ballast/debugger/server/vm/DebuggerServerContract.kt index 31ae0072..0fead934 100644 --- a/ballast-debugger-server/src/commonMain/kotlin/com/copperleaf/ballast/debugger/server/vm/DebuggerServerContract.kt +++ b/ballast-debugger-server/src/commonMain/kotlin/com/copperleaf/ballast/debugger/server/vm/DebuggerServerContract.kt @@ -4,7 +4,7 @@ import com.copperleaf.ballast.debugger.models.BallastApplicationState import com.copperleaf.ballast.debugger.server.BallastDebuggerServerSettings import com.copperleaf.ballast.debugger.versions.v5.BallastDebuggerActionV5 import com.copperleaf.ballast.debugger.versions.v5.BallastDebuggerEventV5 -import io.github.copper_leaf.ballast_debugger_server.BALLAST_VERSION +import io.github.copperleaf.ballastdebuggerserver.BALLAST_VERSION import kotlinx.coroutines.flow.MutableSharedFlow public object DebuggerServerContract { @@ -45,6 +45,6 @@ public object DebuggerServerContract { } public sealed interface Events { - public data class ConnectionEstablished(val connectionId: String): Events + public data class ConnectionEstablished(val connectionId: String) : Events } } diff --git a/ballast-debugger-server/src/commonMain/kotlin/com/copperleaf/ballast/debugger/server/vm/DebuggerServerInputHandler.kt b/ballast-debugger-server/src/commonMain/kotlin/com/copperleaf/ballast/debugger/server/vm/DebuggerServerInputHandler.kt index 365bd965..be61419d 100644 --- a/ballast-debugger-server/src/commonMain/kotlin/com/copperleaf/ballast/debugger/server/vm/DebuggerServerInputHandler.kt +++ b/ballast-debugger-server/src/commonMain/kotlin/com/copperleaf/ballast/debugger/server/vm/DebuggerServerInputHandler.kt @@ -45,7 +45,6 @@ public class DebuggerServerInputHandler : InputHandler< ) } - is DebuggerServerContract.Inputs.ClearAll -> { updateState { DebuggerServerContract.State(actions = it.actions) } } @@ -85,7 +84,6 @@ public class DebuggerServerInputHandler : InputHandler< it.copy( allMessages = it.allMessages + input.message, applicationState = it.applicationState.updateConnection(input.message.connectionId) { - if (input.message is BallastDebuggerEventV5.Heartbeat) { copy(connectionBallastVersion = input.message.connectionBallastVersion) } else { diff --git a/ballast-debugger-server/src/commonMain/kotlin/com/copperleaf/ballast/debugger/versions/ClientVersion.kt b/ballast-debugger-server/src/commonMain/kotlin/com/copperleaf/ballast/debugger/versions/ClientVersion.kt index f1a4122a..852333c9 100644 --- a/ballast-debugger-server/src/commonMain/kotlin/com/copperleaf/ballast/debugger/versions/ClientVersion.kt +++ b/ballast-debugger-server/src/commonMain/kotlin/com/copperleaf/ballast/debugger/versions/ClientVersion.kt @@ -69,7 +69,6 @@ public data class ClientVersion(val major: Int, val minor: Int?, val patch: Int? } } - public companion object { public fun parse(connectionBallastVersion: String?): ClientVersion { return try { diff --git a/ballast-debugger-server/src/commonMain/kotlin/com/copperleaf/ballast/debugger/versions/v3/BallastDebuggerEventV3.kt b/ballast-debugger-server/src/commonMain/kotlin/com/copperleaf/ballast/debugger/versions/v3/BallastDebuggerEventV3.kt index 91bacf45..43dbe14b 100644 --- a/ballast-debugger-server/src/commonMain/kotlin/com/copperleaf/ballast/debugger/versions/v3/BallastDebuggerEventV3.kt +++ b/ballast-debugger-server/src/commonMain/kotlin/com/copperleaf/ballast/debugger/versions/v3/BallastDebuggerEventV3.kt @@ -87,7 +87,6 @@ public sealed class BallastDebuggerEventV3 { } } - // Inputs // --------------------------------------------------------------------------------------------------------------------- @@ -493,7 +492,9 @@ public sealed class BallastDebuggerEventV3 { @Serializable public enum class StatusV3 { - NotStarted, Running, ShuttingDown, Cleared + NotStarted, + Running, + ShuttingDown, + Cleared } - } diff --git a/ballast-debugger-server/src/commonMain/kotlin/com/copperleaf/ballast/debugger/versions/v4/BallastDebuggerEventV4.kt b/ballast-debugger-server/src/commonMain/kotlin/com/copperleaf/ballast/debugger/versions/v4/BallastDebuggerEventV4.kt index 63238e05..0ed55eb9 100644 --- a/ballast-debugger-server/src/commonMain/kotlin/com/copperleaf/ballast/debugger/versions/v4/BallastDebuggerEventV4.kt +++ b/ballast-debugger-server/src/commonMain/kotlin/com/copperleaf/ballast/debugger/versions/v4/BallastDebuggerEventV4.kt @@ -87,7 +87,6 @@ public sealed class BallastDebuggerEventV4 { } } - // Inputs // --------------------------------------------------------------------------------------------------------------------- @@ -493,7 +492,9 @@ public sealed class BallastDebuggerEventV4 { @Serializable public enum class StatusV4 { - NotStarted, Running, ShuttingDown, Cleared + NotStarted, + Running, + ShuttingDown, + Cleared } - } diff --git a/ballast-debugger-ui/build.gradle.kts b/ballast-debugger-ui/build.gradle.kts index 97fd4e4a..5ff2bd36 100644 --- a/ballast-debugger-ui/build.gradle.kts +++ b/ballast-debugger-ui/build.gradle.kts @@ -4,7 +4,7 @@ plugins { id("copper-leaf-tests") id("copper-leaf-serialization") id("copper-leaf-compose") -// id("copper-leaf-lint") + id("copper-leaf-lint") id("copper-leaf-publish") } @@ -14,7 +14,6 @@ kotlin { languageSettings.apply { optIn("androidx.compose.material.ExperimentalMaterialApi") optIn("androidx.compose.foundation.ExperimentalFoundationApi") - optIn("com.copperleaf.ballast.ExperimentalBallastApi") optIn("org.jetbrains.compose.splitpane.ExperimentalSplitPaneApi") } } diff --git a/ballast-debugger-ui/src/commonMain/kotlin/com/copperleaf/ballast/debugger/idea/features/debugger/ui/widgets/DebuggerScaffold.kt b/ballast-debugger-ui/src/commonMain/kotlin/com/copperleaf/ballast/debugger/idea/features/debugger/ui/widgets/DebuggerScaffold.kt index 7704bbf2..54cea324 100644 --- a/ballast-debugger-ui/src/commonMain/kotlin/com/copperleaf/ballast/debugger/idea/features/debugger/ui/widgets/DebuggerScaffold.kt +++ b/ballast-debugger-ui/src/commonMain/kotlin/com/copperleaf/ballast/debugger/idea/features/debugger/ui/widgets/DebuggerScaffold.kt @@ -1,4 +1,5 @@ @file:Suppress("UNUSED_PARAMETER") + package com.copperleaf.ballast.debugger.idea.features.debugger.ui.widgets import androidx.compose.foundation.layout.Box @@ -82,46 +83,42 @@ internal fun DebuggerScaffold( splitPaneState = rememberSplitPaneState(0.35f), ) { first(minSize = 60.dp) { mainContentLeftLambda() } - second() { + second { Row(Modifier.fillMaxSize()) { HorizontalSplitPane( splitPaneState = rememberSplitPaneState(0.35f), ) { first(minSize = 60.dp) { mainContentRightLambda() } - second() { stickyContentLambda() } + second { stickyContentLambda() } } } } } - } - else if (mainContentLeft != null && mainContentRight != null) { + } else if (mainContentLeft != null && mainContentRight != null) { HorizontalSplitPane( splitPaneState = rememberSplitPaneState(0.35f), ) { first(minSize = 60.dp) { mainContentLeftLambda() } - second() { + second { mainContentRightLambda() } } - } - else if (mainContentLeft != null && stickyContent != null) { + } else if (mainContentLeft != null && stickyContent != null) { HorizontalSplitPane( splitPaneState = rememberSplitPaneState(0.35f), ) { first(minSize = 60.dp) { mainContentLeftLambda() } - second() { + second { stickyContentLambda() } } - } - else if(mainContentLeft != null) { + } else if (mainContentLeft != null) { mainContentLeftLambda() - } - else if(mainContentRight != null) { + } else if (mainContentRight != null) { error("use mainContentLeft for a single-panel view instead") } } diff --git a/ballast-debugger-ui/src/commonMain/kotlin/com/copperleaf/ballast/debugger/idea/features/debugger/ui/widgets/Interceptors.kt b/ballast-debugger-ui/src/commonMain/kotlin/com/copperleaf/ballast/debugger/idea/features/debugger/ui/widgets/Interceptors.kt index efe94ebc..c71a664c 100644 --- a/ballast-debugger-ui/src/commonMain/kotlin/com/copperleaf/ballast/debugger/idea/features/debugger/ui/widgets/Interceptors.kt +++ b/ballast-debugger-ui/src/commonMain/kotlin/com/copperleaf/ballast/debugger/idea/features/debugger/ui/widgets/Interceptors.kt @@ -1,4 +1,5 @@ @file:Suppress("UNUSED_PARAMETER") + package com.copperleaf.ballast.debugger.idea.features.debugger.ui.widgets import androidx.compose.foundation.VerticalScrollbar diff --git a/ballast-debugger-ui/src/commonMain/kotlin/com/copperleaf/ballast/debugger/idea/features/debugger/ui/widgets/SpecialRouterToolbar.kt b/ballast-debugger-ui/src/commonMain/kotlin/com/copperleaf/ballast/debugger/idea/features/debugger/ui/widgets/SpecialRouterToolbar.kt index 8f0db033..dbdb1630 100644 --- a/ballast-debugger-ui/src/commonMain/kotlin/com/copperleaf/ballast/debugger/idea/features/debugger/ui/widgets/SpecialRouterToolbar.kt +++ b/ballast-debugger-ui/src/commonMain/kotlin/com/copperleaf/ballast/debugger/idea/features/debugger/ui/widgets/SpecialRouterToolbar.kt @@ -1,4 +1,5 @@ @file:Suppress("UNUSED_PARAMETER") + package com.copperleaf.ballast.debugger.idea.features.debugger.ui.widgets import androidx.compose.foundation.layout.ColumnScope diff --git a/ballast-debugger-ui/src/commonMain/kotlin/com/copperleaf/ballast/debugger/idea/features/debugger/ui/widgets/SpecialViewModelState.kt b/ballast-debugger-ui/src/commonMain/kotlin/com/copperleaf/ballast/debugger/idea/features/debugger/ui/widgets/SpecialViewModelState.kt index 7560ad9a..4c670100 100644 --- a/ballast-debugger-ui/src/commonMain/kotlin/com/copperleaf/ballast/debugger/idea/features/debugger/ui/widgets/SpecialViewModelState.kt +++ b/ballast-debugger-ui/src/commonMain/kotlin/com/copperleaf/ballast/debugger/idea/features/debugger/ui/widgets/SpecialViewModelState.kt @@ -1,4 +1,5 @@ @file:Suppress("UNUSED_PARAMETER") + package com.copperleaf.ballast.debugger.idea.features.debugger.ui.widgets import androidx.compose.foundation.layout.ColumnScope @@ -18,7 +19,7 @@ internal fun ColumnScope.SpecialViewModelState( postInput: (DebuggerUiContract.Inputs) -> Unit, ) { if (settings.getCachedOrNull()?.alwaysShowCurrentState == true) { - if(currentState != null) { + if (currentState != null) { Text("Current State") Divider() StateDetails(currentState, postInput) diff --git a/ballast-debugger-ui/src/commonMain/kotlin/com/copperleaf/ballast/debugger/idea/features/debugger/ui/widgets/ViewModelContentTab.kt b/ballast-debugger-ui/src/commonMain/kotlin/com/copperleaf/ballast/debugger/idea/features/debugger/ui/widgets/ViewModelContentTab.kt index c83b5465..35a53fd8 100644 --- a/ballast-debugger-ui/src/commonMain/kotlin/com/copperleaf/ballast/debugger/idea/features/debugger/ui/widgets/ViewModelContentTab.kt +++ b/ballast-debugger-ui/src/commonMain/kotlin/com/copperleaf/ballast/debugger/idea/features/debugger/ui/widgets/ViewModelContentTab.kt @@ -27,7 +27,8 @@ internal enum class ViewModelContentTab( SideJobs(Icons.Default.CloudUpload, "SideJobs"), Interceptors(Icons.Default.RestartAlt, "Interceptors"), Logs(Icons.Default.Description, "Logs"); -// Timeline(Icons.Default.Timeline, "Timeline"); + + // Timeline(Icons.Default.Timeline, "Timeline"); fun isEnabled( connection: BallastConnectionState diff --git a/ballast-debugger-ui/src/commonMain/kotlin/com/copperleaf/ballast/debugger/idea/features/debugger/ui/widgets/utils.kt b/ballast-debugger-ui/src/commonMain/kotlin/com/copperleaf/ballast/debugger/idea/features/debugger/ui/widgets/utils.kt index 5040cf3d..bec05cfc 100644 --- a/ballast-debugger-ui/src/commonMain/kotlin/com/copperleaf/ballast/debugger/idea/features/debugger/ui/widgets/utils.kt +++ b/ballast-debugger-ui/src/commonMain/kotlin/com/copperleaf/ballast/debugger/idea/features/debugger/ui/widgets/utils.kt @@ -312,7 +312,7 @@ public fun IntellijEditor( onContentCopied: ((String) -> Unit)? = null, ) { Box(modifier.fillMaxSize().background(Color(51, 51, 51))) { - if(onContentCopied != null) { + if (onContentCopied != null) { ToolBarActionIconButton( modifier = Modifier .align(Alignment.TopEnd) @@ -414,7 +414,6 @@ public fun getRouteForSelectedViewModel( .pathParameter("connectionId", connectionId) .pathParameter("viewModelName", viewModelName) .build() - } else { DebuggerRoute.Connection .directions() diff --git a/ballast-firebase-analytics/README.md b/ballast-firebase-analytics/README.md new file mode 100644 index 00000000..8d35e480 --- /dev/null +++ b/ballast-firebase-analytics/README.md @@ -0,0 +1,85 @@ +# Ballast Firebase Analytics + +## Overview + +This module extends the capabilities of [Ballast Analytics](./../ballast-analytics) to send analytics to +[Firebase Analytics](https://firebase.google.com/products/analytics). Currently only available on Android. + +## Supported Platforms + +| Platform | Supported | +|----------|-----------| +| JVM | ❌ | +| Android | ✅ | +| iOS | ❌ | +| JS | ❌ | +| WASM JS | ❌ | + +## See Also + +- [Ballast Analytics](./../ballast-analytics) +- [Ballast Crash Reporting](./../ballast-crash-reporting) +- [Ballast Firebase Crashlytics](./../ballast-firebase-crashlytics) + +## Usage + +Add the `FirebaseAnalyticsInterceptor` to your ViewModel configuration to track inputs and send them to Firebase +Analytics automatically. Only Inputs annotated with `@FirebaseAnalyticsTrackInput` will be tracked. Make sure any inputs +annotated with @FirebaseAnalyticsTrackInput do not leak any sensitive information through their `.toString()` value. + +```kotlin +class ExampleViewModel(coroutineScope: CoroutineScope) : BasicViewModel< + ExampleContract.Inputs, + ExampleContract.Events, + ExampleContract.State + >( + coroutineScope = coroutineScope, + config = BallastViewModelConfiguration.Builder() + .withViewModel(ExampleContract.State(), ExampleInputHandler()) + .apply { + interceptors += FirebaseAnalyticsInterceptor() + } + .build(), + eventHandler = eventHandler { }, +) + +object ExampleContract { + data class State( + val loading: Boolean = false, + ) + + sealed interface Inputs { + + @FirebaseAnalyticsTrackInput + data object TrackThis : Inputs + + data object DontTrackThis : Inputs + } + + sealed interface Events +} +``` + +## Installation + +```kotlin +repositories { + mavenCentral() +} + +// for plain Android projects +dependencies { + implementation("io.github.copper-leaf:ballast-firebase-analytics:{{ballastVersion}}") +} + +// for multiplatform projects +kotlin { + sourceSets { + val androidMain by getting { + dependencies { + implementation("io.github.copper-leaf:ballast-firebase-analytics:{{ballastVersion}}") + } + } + } +} +``` diff --git a/ballast-firebase-analytics/build.gradle.kts b/ballast-firebase-analytics/build.gradle.kts index eeee6f4e..38204221 100644 --- a/ballast-firebase-analytics/build.gradle.kts +++ b/ballast-firebase-analytics/build.gradle.kts @@ -3,7 +3,7 @@ plugins { id("copper-leaf-android-library") id("copper-leaf-targets") id("copper-leaf-tests") -// id("copper-leaf-lint") + id("copper-leaf-lint") id("copper-leaf-publish") } @@ -21,5 +21,11 @@ kotlin { implementation(libs.firebase.analytics) } } + val commonTest by getting { + dependencies { + implementation(project(":ballast-test")) + implementation(project(":ballast-core")) + } + } } } diff --git a/ballast-firebase-analytics/src/androidMain/kotlin/com/copperleaf/ballast/firebase/FirebaseAnalyticsInterceptor.kt b/ballast-firebase-analytics/src/androidMain/kotlin/com/copperleaf/ballast/firebase/FirebaseAnalyticsInterceptor.kt index 861b8dd0..a2ca53b6 100644 --- a/ballast-firebase-analytics/src/androidMain/kotlin/com/copperleaf/ballast/firebase/FirebaseAnalyticsInterceptor.kt +++ b/ballast-firebase-analytics/src/androidMain/kotlin/com/copperleaf/ballast/firebase/FirebaseAnalyticsInterceptor.kt @@ -1,19 +1,9 @@ package com.copperleaf.ballast.firebase -import com.copperleaf.ballast.BallastInterceptor -import com.copperleaf.ballast.BallastInterceptorScope -import com.copperleaf.ballast.BallastNotification import com.copperleaf.ballast.analytics.AnalyticsInterceptor -import com.copperleaf.ballast.awaitViewModelStart import com.google.firebase.analytics.FirebaseAnalytics import com.google.firebase.analytics.ktx.analytics -import com.google.firebase.analytics.ktx.logEvent import com.google.firebase.ktx.Firebase -import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch public fun FirebaseAnalyticsInterceptor( analytics: FirebaseAnalytics = Firebase.analytics, diff --git a/ballast-firebase-analytics/src/androidMain/kotlin/com/copperleaf/ballast/firebase/FirebaseAnalyticsTracker.kt b/ballast-firebase-analytics/src/androidMain/kotlin/com/copperleaf/ballast/firebase/FirebaseAnalyticsTracker.kt index ef787071..c9eba0fb 100644 --- a/ballast-firebase-analytics/src/androidMain/kotlin/com/copperleaf/ballast/firebase/FirebaseAnalyticsTracker.kt +++ b/ballast-firebase-analytics/src/androidMain/kotlin/com/copperleaf/ballast/firebase/FirebaseAnalyticsTracker.kt @@ -9,7 +9,7 @@ public class FirebaseAnalyticsTracker( ) : AnalyticsTracker { override fun trackAnalyticsEvent(eventId: String, eventParameters: Map) { analytics.logEvent(eventId) { - for((key, value) in eventParameters.entries) { + for ((key, value) in eventParameters.entries) { param(key, value) } } diff --git a/ballast-firebase-analytics/src/androidUnitTest/kotlin/com/copperleaf/ballast/analytics/vm/TestInputHandler.kt b/ballast-firebase-analytics/src/androidUnitTest/kotlin/com/copperleaf/ballast/analytics/vm/TestInputHandler.kt new file mode 100644 index 00000000..f137e94c --- /dev/null +++ b/ballast-firebase-analytics/src/androidUnitTest/kotlin/com/copperleaf/ballast/analytics/vm/TestInputHandler.kt @@ -0,0 +1,23 @@ +package com.copperleaf.ballast.analytics.vm + +import com.copperleaf.ballast.InputHandler +import com.copperleaf.ballast.InputHandlerScope + +class TestInputHandler : InputHandler< + TestContract.Inputs, + TestContract.Events, + TestContract.State> { + override suspend fun InputHandlerScope< + TestContract.Inputs, + TestContract.Events, + TestContract.State>.handleInput( + input: TestContract.Inputs + ): Unit = when (input) { + TestContract.Inputs.DontTrackThis -> { + noOp() + } + TestContract.Inputs.TrackThis -> { + noOp() + } + } +} diff --git a/ballast-firebase-analytics/src/androidUnitTest/kotlin/com/copperleaf/ballast/analytics/vm/TestViewModel.kt b/ballast-firebase-analytics/src/androidUnitTest/kotlin/com/copperleaf/ballast/analytics/vm/TestViewModel.kt new file mode 100644 index 00000000..8aafe203 --- /dev/null +++ b/ballast-firebase-analytics/src/androidUnitTest/kotlin/com/copperleaf/ballast/analytics/vm/TestViewModel.kt @@ -0,0 +1,41 @@ +package com.copperleaf.ballast.analytics.vm + +import com.copperleaf.ballast.BallastViewModelConfiguration +import com.copperleaf.ballast.build +import com.copperleaf.ballast.core.BasicViewModel +import com.copperleaf.ballast.eventHandler +import com.copperleaf.ballast.firebase.FirebaseAnalyticsInterceptor +import com.copperleaf.ballast.firebase.FirebaseAnalyticsTrackInput +import com.copperleaf.ballast.withViewModel +import kotlinx.coroutines.CoroutineScope + +class TestViewModel(coroutineScope: CoroutineScope) : BasicViewModel< + TestContract.Inputs, + TestContract.Events, + TestContract.State + >( + coroutineScope = coroutineScope, + config = BallastViewModelConfiguration.Builder() + .withViewModel(TestContract.State(), TestInputHandler()) + .apply { + interceptors += FirebaseAnalyticsInterceptor() + } + .build(), + eventHandler = eventHandler { }, +) + +object TestContract { + data class State( + val loading: Boolean = false, + ) + + sealed interface Inputs { + + @FirebaseAnalyticsTrackInput + data object TrackThis : Inputs + + data object DontTrackThis : Inputs + } + + sealed interface Events +} diff --git a/ballast-firebase-crashlytics/README.md b/ballast-firebase-crashlytics/README.md new file mode 100644 index 00000000..b7d3a506 --- /dev/null +++ b/ballast-firebase-crashlytics/README.md @@ -0,0 +1,88 @@ +# Ballast Firebase Crashlytics + +## Overview + +Extends [Ballast Crash Reporting](./../ballast-crash-reporting) to automatically send ViewModel errors to +[Firebase Crashlytics](https://firebase.google.com/products/crashlytics). Currently only available on Android. + +## Supported Platforms + +| Platform | Supported | +|----------|-----------| +| JVM | ❌ | +| Android | ✅ | +| iOS | ❌ | +| JS | ❌ | +| WASM JS | ❌ | + +## See Also + +- [Ballast Analytics](./../ballast-analytics) +- [Ballast Firebase Analytics](./../ballast-firebase-analytics) +- [Ballast Crash Reporting](./../ballast-crash-reporting) + +## Usage + +Add `FirebaseCrashlyticsInterceptor` to your ViewModel configuration. By default, all Inputs that are not annotated +with `@FirebaseCrashlyticsIgnore` will be logged to Crashlytics as breadcrumbs leading up to any recorded exceptions. + +```kotlin +class ExampleViewModel(coroutineScope: CoroutineScope) : BasicViewModel< + ExampleContract.Inputs, + ExampleContract.Events, + ExampleContract.State + >( + coroutineScope = coroutineScope, + config = BallastViewModelConfiguration.Builder() + .withViewModel(ExampleContract.State(), ExampleInputHandler()) + .apply { + interceptors += FirebaseCrashlyticsInterceptor( + shouldTrackInput = { input -> + when (input) { + is ExampleContract.Inputs.SensitiveInput -> false + else -> true + } + } + ) + } + .build(), + eventHandler = eventHandler { }, +) + +object ExampleContract { + data class State(val loading: Boolean = false) + + sealed interface Inputs { + data object NormalInput : Inputs + + @FirebaseCrashlyticsIgnore + data class SensitiveInput(val token: String) : Inputs + } + + sealed interface Events +} +``` + +## Installation + +```kotlin +repositories { + mavenCentral() +} + +// for plain Android projects +dependencies { + implementation("io.github.copper-leaf:ballast-firebase-crashlytics:{{ballastVersion}}") +} + +// for multiplatform projects +kotlin { + sourceSets { + val androidMain by getting { + dependencies { + implementation("io.github.copper-leaf:ballast-firebase-crashlytics:{{ballastVersion}}") + } + } + } +} +``` diff --git a/ballast-firebase-crashlytics/build.gradle.kts b/ballast-firebase-crashlytics/build.gradle.kts index 4dd9248b..b59cabca 100644 --- a/ballast-firebase-crashlytics/build.gradle.kts +++ b/ballast-firebase-crashlytics/build.gradle.kts @@ -3,7 +3,7 @@ plugins { id("copper-leaf-android-library") id("copper-leaf-targets") id("copper-leaf-tests") -// id("copper-leaf-lint") + id("copper-leaf-lint") id("copper-leaf-publish") } @@ -21,5 +21,10 @@ kotlin { implementation(libs.firebase.crashlytics) } } + val commonTest by getting { + dependencies { + implementation(project(":ballast-test")) + } + } } } diff --git a/ballast-firebase-crashlytics/src/androidMain/kotlin/com/copperleaf/ballast/firebase/FirebaseCrashReporter.kt b/ballast-firebase-crashlytics/src/androidMain/kotlin/com/copperleaf/ballast/firebase/FirebaseCrashReporter.kt index eab0cbc4..f4f4e41e 100644 --- a/ballast-firebase-crashlytics/src/androidMain/kotlin/com/copperleaf/ballast/firebase/FirebaseCrashReporter.kt +++ b/ballast-firebase-crashlytics/src/androidMain/kotlin/com/copperleaf/ballast/firebase/FirebaseCrashReporter.kt @@ -14,10 +14,10 @@ public class FirebaseCrashReporter( key(Keys.ViewModelName, viewModelName) key( Keys.InputType, - "${viewModelName}.${input::class.java.simpleName}" + "$viewModelName.${input::class.java.simpleName}" ) } - crashlytics.log("${viewModelName}.${input}") + crashlytics.log("$viewModelName.$input") } override fun recordInputError(viewModelName: String, input: Any, throwable: Throwable) { @@ -34,7 +34,7 @@ public class FirebaseCrashReporter( override fun recordSideJobError(viewModelName: String, key: String, throwable: Throwable) { onError(viewModelName, "SideJob", throwable, true) { - key(Keys.SideJobKey, "${viewModelName}.$key") + key(Keys.SideJobKey, "$viewModelName.$key") } } diff --git a/ballast-idea-plugin/README.md b/ballast-idea-plugin/README.md new file mode 100644 index 00000000..151a84c2 --- /dev/null +++ b/ballast-idea-plugin/README.md @@ -0,0 +1,73 @@ +# Ballast IntelliJ Plugin + +## Overview + +Ballast has an official IntelliJ plugin which offers several useful tools for developing applications with Ballast: + +- Real-time inspection of the status and data within all ViewModel features +- Time-travel debugging and direct State manipulation +- Code scaffolding templates for creating new Ballast components + +The plugin is available in both Community and Ultimate editions of IntelliJ IDEA. Note that the plugin's UI is built +with Compose for IDE Plugin Development, which currently requires a recent version of IntelliJ IDEA — the latest +stable release of Android Studio is not supported at this time. + +## Installation + +Search for "Ballast" in the IntelliJ plugin marketplace (`Settings > Plugins > Marketplace`), or visit the +[plugin page on the JetBrains Marketplace](https://plugins.jetbrains.com/plugin/18702-ballast). + +## Usage + +### Debugger + +The plugin works in conjunction with the [Ballast Debugger Client](./../ballast-debugger-client) library, which you +install into your application as an Interceptor. See that module's README for how to configure your app to connect. + +Video walkthrough: https://www.youtube.com/watch?v=KBUIdMzYdCo + +#### Connecting + +Once installed, a "Ballast Debugger" tool window appears in the IDE. Open it to start the debugger server. The +debugger communicates over WebSockets on localhost port `9684` (configurable in `Settings > Tools > Ballast`). + +- Desktop/JVM apps: connect using `127.0.0.1` +- Android emulators: connect using `10.0.2.2` (the emulated device's alias to the host loopback) + +The debugger server is only active while the tool window is open. Client interceptors will continuously attempt to +reconnect if the connection is lost — simply reopening the tool window and interacting with your app is enough to +re-establish the connection without restarting the application. + +#### Browsing ViewModel Data + +Once connected, each app launch is assigned a UUID and added to the "Connections" dropdown (most recent at the top). +You can connect multiple devices simultaneously. Select a connection, then select a ViewModel from the adjacent +dropdown to browse its data. + +When a ViewModel is selected, a series of tabs display the different types of data reported by the client: State, +Inputs, Events, Side Jobs, and Interceptors. Tab icons highlight when that type has anything actively processing. + +The data for each item is displayed as its `.toString()` representation by default. You can customize this by +overriding `.toString()`, or by providing a `JsonDebuggerAdapter` to serialize values to JSON via +`kotlinx.serialization`. + +#### Time-travel and Remote Manipulation + +For Inputs and States that have serialization configured, you can copy their JSON representations and send them back +to the device — manipulating the ViewModel's State or triggering Inputs remotely without recompiling. See the +[Ballast Debugger Client](./../ballast-debugger-client) README for details on configuring serialization. + +### Scaffolding Templates + +Ballast inherently involves a fair amount of boilerplate for each screen, but the plugin can generate it for you. +Templates are available from the file explorer's "Right-click > New" menu using IntelliJ's File and Code Templates +feature. + +Video walkthrough: https://www.youtube.com/watch?v=fDdF4E5u7SQ + +You can customize the generated content in `Preferences > Editor > File and Code Templates > Other`, though note that +future plugin updates will not automatically update your edited versions. + +### Plugin Settings + +Settings for the Ballast IntelliJ Plugin can be found at `Settings > Tools > Ballast`. diff --git a/ballast-idea-plugin/api/ballast-idea-plugin.api b/ballast-idea-plugin/api/ballast-idea-plugin.api deleted file mode 100644 index 559049cd..00000000 --- a/ballast-idea-plugin/api/ballast-idea-plugin.api +++ /dev/null @@ -1,730 +0,0 @@ -public final class com/copperleaf/ballast/debugger/idea/BallastIdeaPlugin { - public static final field $stable I - public static final field Companion Lcom/copperleaf/ballast/debugger/idea/BallastIdeaPlugin$Companion; - public fun ()V -} - -public final class com/copperleaf/ballast/debugger/idea/BallastIdeaPlugin$Companion { - public final fun getSettings (Lcom/intellij/openapi/project/Project;)Lcom/copperleaf/ballast/debugger/idea/settings/IntellijPluginSettingsSnapshot; -} - -public abstract interface class com/copperleaf/ballast/debugger/idea/BallastIntellijPluginInjector { - public static final field Companion Lcom/copperleaf/ballast/debugger/idea/BallastIntellijPluginInjector$Companion; - public abstract fun commonViewModelBuilder (ZLkotlin/jvm/functions/Function0;)Lcom/copperleaf/ballast/BallastViewModelConfiguration$Builder; - public static synthetic fun commonViewModelBuilder$default (Lcom/copperleaf/ballast/debugger/idea/BallastIntellijPluginInjector;ZLkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lcom/copperleaf/ballast/BallastViewModelConfiguration$Builder; - public abstract fun getDebuggerUseCase ()Lcom/copperleaf/ballast/debugger/idea/features/debugger/repository/DebuggerUseCase; - public abstract fun getDefaultCoroutineDispatcher ()Lkotlinx/coroutines/CoroutineDispatcher; - public abstract fun getIoCoroutineDispatcher ()Lkotlinx/coroutines/CoroutineDispatcher; - public abstract fun getMainCoroutineDispatcher ()Lkotlinx/coroutines/CoroutineDispatcher; - public abstract fun getProject ()Lcom/intellij/openapi/project/Project; - public abstract fun getRepository ()Lcom/copperleaf/ballast/core/BasicViewModel; - public abstract fun newMainCoroutineScope ()Lkotlinx/coroutines/CoroutineScope; -} - -public final class com/copperleaf/ballast/debugger/idea/BallastIntellijPluginInjector$Companion { - public final fun getInstance (Lcom/intellij/openapi/project/Project;)Lcom/copperleaf/ballast/debugger/idea/BallastIntellijPluginInjector; -} - -public final class com/copperleaf/ballast/debugger/idea/BallastIntellijPluginInjector$DefaultImpls { - public static synthetic fun commonViewModelBuilder$default (Lcom/copperleaf/ballast/debugger/idea/BallastIntellijPluginInjector;ZLkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lcom/copperleaf/ballast/BallastViewModelConfiguration$Builder; -} - -public final class com/copperleaf/ballast/debugger/idea/BallastIntellijPluginInjectorImpl : com/copperleaf/ballast/debugger/idea/BallastIntellijPluginInjector { - public static final field $stable I - public fun (Lcom/intellij/openapi/project/Project;)V - public fun commonViewModelBuilder (ZLkotlin/jvm/functions/Function0;)Lcom/copperleaf/ballast/BallastViewModelConfiguration$Builder; - public fun getDebuggerUseCase ()Lcom/copperleaf/ballast/debugger/idea/features/debugger/repository/DebuggerUseCase; - public fun getDefaultCoroutineDispatcher ()Lkotlinx/coroutines/CoroutineDispatcher; - public fun getIoCoroutineDispatcher ()Lkotlinx/coroutines/CoroutineDispatcher; - public fun getMainCoroutineDispatcher ()Lkotlinx/coroutines/CoroutineDispatcher; - public fun getProject ()Lcom/intellij/openapi/project/Project; - public fun getRepository ()Lcom/copperleaf/ballast/core/BasicViewModel; - public fun newMainCoroutineScope ()Lkotlinx/coroutines/CoroutineScope; -} - -public abstract class com/copperleaf/ballast/debugger/idea/base/BaseTemplateCreator : com/intellij/ide/actions/CreateFileFromTemplateAction, com/intellij/openapi/project/DumbAware { - public static final field $stable I - public fun (Ljava/lang/String;Ljava/lang/String;Ljavax/swing/Icon;)V - protected final fun addTemplate (Lcom/intellij/ide/actions/CreateFileFromTemplateDialog$Builder;Lcom/copperleaf/ballast/debugger/idea/base/BaseTemplateCreator$TemplateKind;)Lcom/intellij/ide/actions/CreateFileFromTemplateDialog$Builder; - protected final fun createFileFromTemplate (Ljava/lang/String;Lcom/intellij/ide/fileTemplates/FileTemplate;Lcom/intellij/psi/PsiDirectory;)Lcom/intellij/psi/PsiFile; - public abstract fun parseTemplateName (Lcom/intellij/openapi/project/Project;Ljava/lang/String;)Ljava/util/List; -} - -public abstract interface class com/copperleaf/ballast/debugger/idea/base/BaseTemplateCreator$TemplateKind { - public fun getActualFileName (Ljava/lang/String;)Ljava/lang/String; - public abstract fun getDisplayName ()Ljava/lang/String; - public abstract fun getFileNameSuffix ()Ljava/lang/String; - public abstract fun getIcon ()Ljavax/swing/Icon; - public fun getTemplate (Lcom/intellij/openapi/project/Project;)Lcom/intellij/ide/fileTemplates/FileTemplate; - public abstract fun getTemplateName ()Ljava/lang/String; -} - -public final class com/copperleaf/ballast/debugger/idea/base/BaseTemplateCreator$TemplateKind$DefaultImpls { - public static fun getActualFileName (Lcom/copperleaf/ballast/debugger/idea/base/BaseTemplateCreator$TemplateKind;Ljava/lang/String;)Ljava/lang/String; - public static fun getTemplate (Lcom/copperleaf/ballast/debugger/idea/base/BaseTemplateCreator$TemplateKind;Lcom/intellij/openapi/project/Project;)Lcom/intellij/ide/fileTemplates/FileTemplate; -} - -public final class com/copperleaf/ballast/debugger/idea/base/IntellijPluginBallastLogger : com/copperleaf/ballast/BallastLogger { - public static final field $stable I - public fun (Ljava/lang/String;)V - public fun debug (Ljava/lang/String;)V - public fun error (Ljava/lang/Throwable;)V - public fun info (Ljava/lang/String;)V -} - -public final class com/copperleaf/ballast/debugger/idea/base/UtilsKt { - public static final fun BallastComposePanel (IIIILkotlin/jvm/functions/Function2;)Ljavax/swing/JComponent; - public static synthetic fun BallastComposePanel$default (IIIILkotlin/jvm/functions/Function2;ILjava/lang/Object;)Ljavax/swing/JComponent; - public static final fun setContent (Lcom/intellij/openapi/wm/ToolWindow;Ljava/lang/String;ZLkotlin/jvm/functions/Function2;)Ljavax/swing/JComponent; - public static synthetic fun setContent$default (Lcom/intellij/openapi/wm/ToolWindow;Ljava/lang/String;ZLkotlin/jvm/functions/Function2;ILjava/lang/Object;)Ljavax/swing/JComponent; -} - -public final class com/copperleaf/ballast/debugger/idea/features/debugger/DebuggerToolWindow { - public static final field $stable I - public fun ()V -} - -public final class com/copperleaf/ballast/debugger/idea/features/debugger/DebuggerToolWindow$Factory : com/intellij/openapi/project/DumbAware, com/intellij/openapi/wm/ToolWindowFactory { - public static final field $stable I - public fun ()V - public fun createToolWindowContent (Lcom/intellij/openapi/project/Project;Lcom/intellij/openapi/wm/ToolWindow;)V - public fun getAnchor ()Lcom/intellij/openapi/wm/ToolWindowAnchor; - public fun getIcon ()Ljavax/swing/Icon; - public fun init (Lcom/intellij/openapi/wm/ToolWindow;)V - public fun isApplicable (Lcom/intellij/openapi/project/Project;)Z - public fun isApplicableAsync (Lcom/intellij/openapi/project/Project;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public fun isDoNotActivateOnStart ()Z - public fun manage (Lcom/intellij/openapi/wm/ToolWindow;Lcom/intellij/openapi/wm/ToolWindowManager;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public fun shouldBeAvailable (Lcom/intellij/openapi/project/Project;)Z -} - -public final class com/copperleaf/ballast/debugger/idea/features/debugger/DebuggerToolWindowInjectorImpl : com/copperleaf/ballast/debugger/idea/features/debugger/injector/DebuggerToolWindowInjector { - public static final field $stable I - public fun (Lcom/copperleaf/ballast/debugger/idea/BallastIntellijPluginInjector;Lkotlinx/coroutines/CoroutineScope;)V - public fun getDebuggerRouter ()Lcom/copperleaf/ballast/core/BasicViewModel; - public fun getDebuggerServerViewModel ()Lcom/copperleaf/ballast/core/BasicViewModel; - public fun getDebuggerUiViewModel ()Lcom/copperleaf/ballast/core/BasicViewModel; -} - -public final class com/copperleaf/ballast/debugger/idea/features/debugger/DebuggerUiEventHandler : com/copperleaf/ballast/EventHandler { - public static final field $stable I - public fun ()V - public fun handleEvent (Lcom/copperleaf/ballast/EventHandlerScope;Lcom/copperleaf/ballast/debugger/idea/features/debugger/vm/DebuggerUiContract$Events;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public synthetic fun handleEvent (Lcom/copperleaf/ballast/EventHandlerScope;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; -} - -public final class com/copperleaf/ballast/debugger/idea/features/debugger/DebuggerUseCaseImpl : com/copperleaf/ballast/debugger/idea/features/debugger/repository/DebuggerUseCase { - public static final field $stable I - public fun (Lcom/copperleaf/ballast/core/BasicViewModel;)V - public fun observeBallastDebuggerServerSettings ()Lkotlinx/coroutines/flow/Flow; - public fun observeDebuggerUiSettings ()Lkotlinx/coroutines/flow/Flow; - public fun observeGeneralSettings ()Lkotlinx/coroutines/flow/Flow; -} - -public abstract interface class com/copperleaf/ballast/debugger/idea/features/settings/injector/SettingsPanelInjector { - public static final field Companion Lcom/copperleaf/ballast/debugger/idea/features/settings/injector/SettingsPanelInjector$Companion; - public abstract fun getProject ()Lcom/intellij/openapi/project/Project; - public abstract fun getSettingsPanelCoroutineScope ()Lkotlinx/coroutines/CoroutineScope; - public abstract fun getSettingsPanelViewModel ()Lcom/copperleaf/ballast/core/BasicViewModel; -} - -public final class com/copperleaf/ballast/debugger/idea/features/settings/injector/SettingsPanelInjector$Companion { - public final fun getInstance (Lcom/intellij/openapi/project/Project;)Lcom/copperleaf/ballast/debugger/idea/features/settings/injector/SettingsPanelInjector; -} - -public final class com/copperleaf/ballast/debugger/idea/features/settings/injector/SettingsPanelInjectorImpl : com/copperleaf/ballast/debugger/idea/features/settings/injector/SettingsPanelInjector { - public static final field $stable I - public fun (Lcom/copperleaf/ballast/debugger/idea/BallastIntellijPluginInjector;)V - public fun getProject ()Lcom/intellij/openapi/project/Project; - public fun getSettingsPanelCoroutineScope ()Lkotlinx/coroutines/CoroutineScope; - public fun getSettingsPanelViewModel ()Lcom/copperleaf/ballast/core/BasicViewModel; -} - -public final class com/copperleaf/ballast/debugger/idea/features/settings/ui/BallastPluginSettingsPanel : com/intellij/openapi/options/Configurable, com/intellij/openapi/options/Configurable$NoScroll { - public static final field $stable I - public fun (Lcom/intellij/openapi/project/Project;)V - public fun apply ()V - public fun createComponent ()Ljavax/swing/JComponent; - public fun disposeUIResources ()V - public fun getDisplayName ()Ljava/lang/String; - public fun isModified ()Z - public fun reset ()V -} - -public final class com/copperleaf/ballast/debugger/idea/features/settings/ui/ComposableSingletons$SettingsUiKt { - public static final field INSTANCE Lcom/copperleaf/ballast/debugger/idea/features/settings/ui/ComposableSingletons$SettingsUiKt; - public fun ()V - public final fun getLambda$-1075288712$ballast_idea_plugin ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-1128047781$ballast_idea_plugin ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$-1304376038$ballast_idea_plugin ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$-1470405238$ballast_idea_plugin ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-1837170023$ballast_idea_plugin ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-2045057053$ballast_idea_plugin ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$-2066257349$ballast_idea_plugin ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$-708523927$ballast_idea_plugin ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-951719524$ballast_idea_plugin ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$1133123958$ballast_idea_plugin ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$1310852656$ballast_idea_plugin ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$1488028932$ballast_idea_plugin ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$1680129489$ballast_idea_plugin ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$1895005269$ballast_idea_plugin ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$2062680747$ballast_idea_plugin ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$306337154$ballast_idea_plugin ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$409784721$ballast_idea_plugin ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$726147621$ballast_idea_plugin ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$734396072$ballast_idea_plugin ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$783158140$ballast_idea_plugin ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$892133256$ballast_idea_plugin ()Lkotlin/jvm/functions/Function2; -} - -public final class com/copperleaf/ballast/debugger/idea/features/settings/ui/SettingsUi { - public static final field $stable I - public static final field INSTANCE Lcom/copperleaf/ballast/debugger/idea/features/settings/ui/SettingsUi; - public final fun Content (Lcom/copperleaf/ballast/debugger/idea/features/settings/injector/SettingsPanelInjector;Landroidx/compose/runtime/Composer;I)V - public final fun Content (Lcom/copperleaf/ballast/debugger/idea/features/settings/vm/SettingsUiContract$State;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;I)V -} - -public final class com/copperleaf/ballast/debugger/idea/features/settings/vm/SettingsUiContract { - public static final field $stable I - public static final field INSTANCE Lcom/copperleaf/ballast/debugger/idea/features/settings/vm/SettingsUiContract; -} - -public abstract interface class com/copperleaf/ballast/debugger/idea/features/settings/vm/SettingsUiContract$Events { -} - -public abstract interface class com/copperleaf/ballast/debugger/idea/features/settings/vm/SettingsUiContract$Inputs { -} - -public final class com/copperleaf/ballast/debugger/idea/features/settings/vm/SettingsUiContract$Inputs$ApplySettings : com/copperleaf/ballast/debugger/idea/features/settings/vm/SettingsUiContract$Inputs { - public static final field $stable I - public static final field INSTANCE Lcom/copperleaf/ballast/debugger/idea/features/settings/vm/SettingsUiContract$Inputs$ApplySettings; - public fun equals (Ljava/lang/Object;)Z - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class com/copperleaf/ballast/debugger/idea/features/settings/vm/SettingsUiContract$Inputs$CloseGracefully : com/copperleaf/ballast/debugger/idea/features/settings/vm/SettingsUiContract$Inputs { - public static final field $stable I - public static final field INSTANCE Lcom/copperleaf/ballast/debugger/idea/features/settings/vm/SettingsUiContract$Inputs$CloseGracefully; - public fun equals (Ljava/lang/Object;)Z - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class com/copperleaf/ballast/debugger/idea/features/settings/vm/SettingsUiContract$Inputs$DiscardChanges : com/copperleaf/ballast/debugger/idea/features/settings/vm/SettingsUiContract$Inputs { - public static final field $stable I - public static final field INSTANCE Lcom/copperleaf/ballast/debugger/idea/features/settings/vm/SettingsUiContract$Inputs$DiscardChanges; - public fun equals (Ljava/lang/Object;)Z - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class com/copperleaf/ballast/debugger/idea/features/settings/vm/SettingsUiContract$Inputs$Initialize : com/copperleaf/ballast/debugger/idea/features/settings/vm/SettingsUiContract$Inputs { - public static final field $stable I - public static final field INSTANCE Lcom/copperleaf/ballast/debugger/idea/features/settings/vm/SettingsUiContract$Inputs$Initialize; - public fun equals (Ljava/lang/Object;)Z - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class com/copperleaf/ballast/debugger/idea/features/settings/vm/SettingsUiContract$Inputs$RestoreDefaultSettings : com/copperleaf/ballast/debugger/idea/features/settings/vm/SettingsUiContract$Inputs { - public static final field $stable I - public static final field INSTANCE Lcom/copperleaf/ballast/debugger/idea/features/settings/vm/SettingsUiContract$Inputs$RestoreDefaultSettings; - public fun equals (Ljava/lang/Object;)Z - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class com/copperleaf/ballast/debugger/idea/features/settings/vm/SettingsUiContract$Inputs$SavedSettingsUpdated : com/copperleaf/ballast/debugger/idea/features/settings/vm/SettingsUiContract$Inputs { - public static final field $stable I - public fun (Lcom/copperleaf/ballast/repository/cache/Cached;)V - public final fun component1 ()Lcom/copperleaf/ballast/repository/cache/Cached; - public final fun copy (Lcom/copperleaf/ballast/repository/cache/Cached;)Lcom/copperleaf/ballast/debugger/idea/features/settings/vm/SettingsUiContract$Inputs$SavedSettingsUpdated; - public static synthetic fun copy$default (Lcom/copperleaf/ballast/debugger/idea/features/settings/vm/SettingsUiContract$Inputs$SavedSettingsUpdated;Lcom/copperleaf/ballast/repository/cache/Cached;ILjava/lang/Object;)Lcom/copperleaf/ballast/debugger/idea/features/settings/vm/SettingsUiContract$Inputs$SavedSettingsUpdated; - public fun equals (Ljava/lang/Object;)Z - public final fun getCachedSettings ()Lcom/copperleaf/ballast/repository/cache/Cached; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class com/copperleaf/ballast/debugger/idea/features/settings/vm/SettingsUiContract$Inputs$UpdateSettings : com/copperleaf/ballast/debugger/idea/features/settings/vm/SettingsUiContract$Inputs { - public static final field $stable I - public fun (Lkotlin/jvm/functions/Function1;)V - public final fun component1 ()Lkotlin/jvm/functions/Function1; - public final fun copy (Lkotlin/jvm/functions/Function1;)Lcom/copperleaf/ballast/debugger/idea/features/settings/vm/SettingsUiContract$Inputs$UpdateSettings; - public static synthetic fun copy$default (Lcom/copperleaf/ballast/debugger/idea/features/settings/vm/SettingsUiContract$Inputs$UpdateSettings;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/copperleaf/ballast/debugger/idea/features/settings/vm/SettingsUiContract$Inputs$UpdateSettings; - public fun equals (Ljava/lang/Object;)Z - public final fun getValue ()Lkotlin/jvm/functions/Function1; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class com/copperleaf/ballast/debugger/idea/features/settings/vm/SettingsUiContract$State { - public static final field $stable I - public fun ()V - public fun (Lcom/copperleaf/ballast/repository/cache/Cached;Lcom/copperleaf/ballast/debugger/idea/settings/IntellijPluginSettingsSnapshot;Lcom/copperleaf/ballast/debugger/idea/settings/IntellijPluginSettingsSnapshot;Lcom/copperleaf/ballast/debugger/idea/settings/IntellijPluginSettingsSnapshot;)V - public synthetic fun (Lcom/copperleaf/ballast/repository/cache/Cached;Lcom/copperleaf/ballast/debugger/idea/settings/IntellijPluginSettingsSnapshot;Lcom/copperleaf/ballast/debugger/idea/settings/IntellijPluginSettingsSnapshot;Lcom/copperleaf/ballast/debugger/idea/settings/IntellijPluginSettingsSnapshot;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Lcom/copperleaf/ballast/repository/cache/Cached; - public final fun component2 ()Lcom/copperleaf/ballast/debugger/idea/settings/IntellijPluginSettingsSnapshot; - public final fun component3 ()Lcom/copperleaf/ballast/debugger/idea/settings/IntellijPluginSettingsSnapshot; - public final fun component4 ()Lcom/copperleaf/ballast/debugger/idea/settings/IntellijPluginSettingsSnapshot; - public final fun copy (Lcom/copperleaf/ballast/repository/cache/Cached;Lcom/copperleaf/ballast/debugger/idea/settings/IntellijPluginSettingsSnapshot;Lcom/copperleaf/ballast/debugger/idea/settings/IntellijPluginSettingsSnapshot;Lcom/copperleaf/ballast/debugger/idea/settings/IntellijPluginSettingsSnapshot;)Lcom/copperleaf/ballast/debugger/idea/features/settings/vm/SettingsUiContract$State; - public static synthetic fun copy$default (Lcom/copperleaf/ballast/debugger/idea/features/settings/vm/SettingsUiContract$State;Lcom/copperleaf/ballast/repository/cache/Cached;Lcom/copperleaf/ballast/debugger/idea/settings/IntellijPluginSettingsSnapshot;Lcom/copperleaf/ballast/debugger/idea/settings/IntellijPluginSettingsSnapshot;Lcom/copperleaf/ballast/debugger/idea/settings/IntellijPluginSettingsSnapshot;ILjava/lang/Object;)Lcom/copperleaf/ballast/debugger/idea/features/settings/vm/SettingsUiContract$State; - public fun equals (Ljava/lang/Object;)Z - public final fun getCachedSettings ()Lcom/copperleaf/ballast/repository/cache/Cached; - public final fun getDefaultValues ()Lcom/copperleaf/ballast/debugger/idea/settings/IntellijPluginSettingsSnapshot; - public final fun getModifiedSettings ()Lcom/copperleaf/ballast/debugger/idea/settings/IntellijPluginSettingsSnapshot; - public final fun getOriginalSettings ()Lcom/copperleaf/ballast/debugger/idea/settings/IntellijPluginSettingsSnapshot; - public fun hashCode ()I - public final fun isModified ()Z - public fun toString ()Ljava/lang/String; -} - -public final class com/copperleaf/ballast/debugger/idea/features/settings/vm/SettingsUiEventHandler : com/copperleaf/ballast/EventHandler { - public static final field $stable I - public fun ()V - public fun handleEvent (Lcom/copperleaf/ballast/EventHandlerScope;Lcom/copperleaf/ballast/debugger/idea/features/settings/vm/SettingsUiContract$Events;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public synthetic fun handleEvent (Lcom/copperleaf/ballast/EventHandlerScope;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; -} - -public final class com/copperleaf/ballast/debugger/idea/features/settings/vm/SettingsUiInputHandler : com/copperleaf/ballast/InputHandler { - public static final field $stable I - public fun (Lcom/copperleaf/ballast/core/BasicViewModel;)V - public fun handleInput (Lcom/copperleaf/ballast/InputHandlerScope;Lcom/copperleaf/ballast/debugger/idea/features/settings/vm/SettingsUiContract$Inputs;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public synthetic fun handleInput (Lcom/copperleaf/ballast/InputHandlerScope;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; -} - -public final class com/copperleaf/ballast/debugger/idea/features/templates/BallastRepository : com/copperleaf/ballast/debugger/idea/base/BaseTemplateCreator, com/intellij/openapi/project/DumbAware { - public static final field $stable I - public fun ()V - public fun parseTemplateName (Lcom/intellij/openapi/project/Project;Ljava/lang/String;)Ljava/util/List; -} - -public final class com/copperleaf/ballast/debugger/idea/features/templates/BallastRepository$RepositoryTemplate : java/lang/Enum, com/copperleaf/ballast/debugger/idea/base/BaseTemplateCreator$TemplateKind { - public static final field AndroidRepositoryImpl Lcom/copperleaf/ballast/debugger/idea/features/templates/BallastRepository$RepositoryTemplate; - public static final field Contract Lcom/copperleaf/ballast/debugger/idea/features/templates/BallastRepository$RepositoryTemplate; - public static final field InputHandler Lcom/copperleaf/ballast/debugger/idea/features/templates/BallastRepository$RepositoryTemplate; - public static final field Repository Lcom/copperleaf/ballast/debugger/idea/features/templates/BallastRepository$RepositoryTemplate; - public static final field StandardRepositoryImpl Lcom/copperleaf/ballast/debugger/idea/features/templates/BallastRepository$RepositoryTemplate; - public fun getActualFileName (Ljava/lang/String;)Ljava/lang/String; - public fun getDisplayName ()Ljava/lang/String; - public static fun getEntries ()Lkotlin/enums/EnumEntries; - public fun getFileNameSuffix ()Ljava/lang/String; - public fun getIcon ()Ljavax/swing/Icon; - public fun getTemplate (Lcom/intellij/openapi/project/Project;)Lcom/intellij/ide/fileTemplates/FileTemplate; - public fun getTemplateName ()Ljava/lang/String; - public static fun valueOf (Ljava/lang/String;)Lcom/copperleaf/ballast/debugger/idea/features/templates/BallastRepository$RepositoryTemplate; - public static fun values ()[Lcom/copperleaf/ballast/debugger/idea/features/templates/BallastRepository$RepositoryTemplate; -} - -public final class com/copperleaf/ballast/debugger/idea/features/templates/BallastUi : com/copperleaf/ballast/debugger/idea/base/BaseTemplateCreator, com/intellij/openapi/project/DumbAware { - public static final field $stable I - public fun ()V - public fun parseTemplateName (Lcom/intellij/openapi/project/Project;Ljava/lang/String;)Ljava/util/List; -} - -public abstract class com/copperleaf/ballast/debugger/idea/features/templates/BallastUi$UiTemplate : com/copperleaf/ballast/debugger/idea/base/BaseTemplateCreator$TemplateKind { - public static final field $stable I - public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljavax/swing/Icon;Lkotlin/jvm/internal/DefaultConstructorMarker;)V - public fun getActualFileName (Ljava/lang/String;)Ljava/lang/String; - public fun getDisplayName ()Ljava/lang/String; - public fun getFileNameSuffix ()Ljava/lang/String; - public fun getIcon ()Ljavax/swing/Icon; - public fun getTemplate (Lcom/intellij/openapi/project/Project;)Lcom/intellij/ide/fileTemplates/FileTemplate; - public fun getTemplateName ()Ljava/lang/String; -} - -public final class com/copperleaf/ballast/debugger/idea/features/templates/BallastUi$UiTemplate$ComposeUi : com/copperleaf/ballast/debugger/idea/features/templates/BallastUi$UiTemplate { - public static final field $stable I - public static final field INSTANCE Lcom/copperleaf/ballast/debugger/idea/features/templates/BallastUi$UiTemplate$ComposeUi; - public fun equals (Ljava/lang/Object;)Z - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class com/copperleaf/ballast/debugger/idea/features/templates/BallastUi$UiTemplate$Contract : com/copperleaf/ballast/debugger/idea/features/templates/BallastUi$UiTemplate { - public static final field $stable I - public static final field INSTANCE Lcom/copperleaf/ballast/debugger/idea/features/templates/BallastUi$UiTemplate$Contract; - public fun equals (Ljava/lang/Object;)Z - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class com/copperleaf/ballast/debugger/idea/features/templates/BallastUi$UiTemplate$EventHandler : com/copperleaf/ballast/debugger/idea/features/templates/BallastUi$UiTemplate { - public static final field $stable I - public static final field INSTANCE Lcom/copperleaf/ballast/debugger/idea/features/templates/BallastUi$UiTemplate$EventHandler; - public fun equals (Ljava/lang/Object;)Z - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class com/copperleaf/ballast/debugger/idea/features/templates/BallastUi$UiTemplate$InputHandler : com/copperleaf/ballast/debugger/idea/features/templates/BallastUi$UiTemplate { - public static final field $stable I - public static final field INSTANCE Lcom/copperleaf/ballast/debugger/idea/features/templates/BallastUi$UiTemplate$InputHandler; - public fun equals (Ljava/lang/Object;)Z - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class com/copperleaf/ballast/debugger/idea/features/templates/BallastUi$UiTemplate$SavedStateAdapter : com/copperleaf/ballast/debugger/idea/features/templates/BallastUi$UiTemplate { - public static final field $stable I - public static final field INSTANCE Lcom/copperleaf/ballast/debugger/idea/features/templates/BallastUi$UiTemplate$SavedStateAdapter; - public fun equals (Ljava/lang/Object;)Z - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class com/copperleaf/ballast/debugger/idea/features/templates/BallastUi$UiTemplate$ViewModel : com/copperleaf/ballast/debugger/idea/features/templates/BallastUi$UiTemplate { - public static final field $stable I - public fun (Ljava/lang/String;)V -} - -public final class com/copperleaf/ballast/debugger/idea/features/templates/BallastViewModel : com/copperleaf/ballast/debugger/idea/base/BaseTemplateCreator, com/intellij/openapi/project/DumbAware { - public static final field $stable I - public fun ()V - public fun parseTemplateName (Lcom/intellij/openapi/project/Project;Ljava/lang/String;)Ljava/util/List; -} - -public final class com/copperleaf/ballast/debugger/idea/features/templates/BallastViewModel$DefaultVisibility : java/lang/Enum { - public static final field Default Lcom/copperleaf/ballast/debugger/idea/features/templates/BallastViewModel$DefaultVisibility; - public static final field Internal Lcom/copperleaf/ballast/debugger/idea/features/templates/BallastViewModel$DefaultVisibility; - public static final field Public Lcom/copperleaf/ballast/debugger/idea/features/templates/BallastViewModel$DefaultVisibility; - public final fun getClassVisibility ()Ljava/lang/String; - public final fun getDisplayName ()Ljava/lang/String; - public static fun getEntries ()Lkotlin/enums/EnumEntries; - public final fun getPropertyVisibility ()Ljava/lang/String; - public static fun valueOf (Ljava/lang/String;)Lcom/copperleaf/ballast/debugger/idea/features/templates/BallastViewModel$DefaultVisibility; - public static fun values ()[Lcom/copperleaf/ballast/debugger/idea/features/templates/BallastViewModel$DefaultVisibility; -} - -public final class com/copperleaf/ballast/debugger/idea/features/templates/BallastViewModel$ViewModelTemplate : java/lang/Enum, com/copperleaf/ballast/debugger/idea/base/BaseTemplateCreator$TemplateKind { - public static final field Android Lcom/copperleaf/ballast/debugger/idea/features/templates/BallastViewModel$ViewModelTemplate; - public static final field Basic Lcom/copperleaf/ballast/debugger/idea/features/templates/BallastViewModel$ViewModelTemplate; - public static final field Ios Lcom/copperleaf/ballast/debugger/idea/features/templates/BallastViewModel$ViewModelTemplate; - public static final field Typealias Lcom/copperleaf/ballast/debugger/idea/features/templates/BallastViewModel$ViewModelTemplate; - public fun getActualFileName (Ljava/lang/String;)Ljava/lang/String; - public fun getDisplayName ()Ljava/lang/String; - public static fun getEntries ()Lkotlin/enums/EnumEntries; - public fun getFileNameSuffix ()Ljava/lang/String; - public fun getIcon ()Ljavax/swing/Icon; - public fun getTemplate (Lcom/intellij/openapi/project/Project;)Lcom/intellij/ide/fileTemplates/FileTemplate; - public fun getTemplateName ()Ljava/lang/String; - public static fun valueOf (Ljava/lang/String;)Lcom/copperleaf/ballast/debugger/idea/features/templates/BallastViewModel$ViewModelTemplate; - public static fun values ()[Lcom/copperleaf/ballast/debugger/idea/features/templates/BallastViewModel$ViewModelTemplate; -} - -public final class com/copperleaf/ballast/debugger/idea/features/templates/ExposeOtherTemplates : com/intellij/ide/fileTemplates/FileTemplateGroupDescriptorFactory { - public static final field $stable I - public fun ()V - public fun getFileTemplatesDescriptor ()Lcom/intellij/ide/fileTemplates/FileTemplateGroupDescriptor; -} - -public final class com/copperleaf/ballast/debugger/idea/repository/RepositoryContract { - public static final field $stable I - public static final field INSTANCE Lcom/copperleaf/ballast/debugger/idea/repository/RepositoryContract; -} - -public abstract interface class com/copperleaf/ballast/debugger/idea/repository/RepositoryContract$Events { -} - -public abstract interface class com/copperleaf/ballast/debugger/idea/repository/RepositoryContract$Inputs { -} - -public final class com/copperleaf/ballast/debugger/idea/repository/RepositoryContract$Inputs$Initialize : com/copperleaf/ballast/debugger/idea/repository/RepositoryContract$Inputs { - public static final field $stable I - public static final field INSTANCE Lcom/copperleaf/ballast/debugger/idea/repository/RepositoryContract$Inputs$Initialize; - public fun equals (Ljava/lang/Object;)Z - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class com/copperleaf/ballast/debugger/idea/repository/RepositoryContract$Inputs$SaveUpdatedSettings : com/copperleaf/ballast/debugger/idea/repository/RepositoryContract$Inputs { - public static final field $stable I - public fun (Lcom/copperleaf/ballast/debugger/idea/settings/IntellijPluginSettingsSnapshot;)V - public final fun component1 ()Lcom/copperleaf/ballast/debugger/idea/settings/IntellijPluginSettingsSnapshot; - public final fun copy (Lcom/copperleaf/ballast/debugger/idea/settings/IntellijPluginSettingsSnapshot;)Lcom/copperleaf/ballast/debugger/idea/repository/RepositoryContract$Inputs$SaveUpdatedSettings; - public static synthetic fun copy$default (Lcom/copperleaf/ballast/debugger/idea/repository/RepositoryContract$Inputs$SaveUpdatedSettings;Lcom/copperleaf/ballast/debugger/idea/settings/IntellijPluginSettingsSnapshot;ILjava/lang/Object;)Lcom/copperleaf/ballast/debugger/idea/repository/RepositoryContract$Inputs$SaveUpdatedSettings; - public fun equals (Ljava/lang/Object;)Z - public final fun getSettings ()Lcom/copperleaf/ballast/debugger/idea/settings/IntellijPluginSettingsSnapshot; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class com/copperleaf/ballast/debugger/idea/repository/RepositoryContract$State { - public static final field $stable I - public fun ()V - public fun (Lcom/copperleaf/ballast/debugger/idea/settings/IntellijPluginPersistentSettings;Lcom/copperleaf/ballast/repository/cache/Cached;)V - public synthetic fun (Lcom/copperleaf/ballast/debugger/idea/settings/IntellijPluginPersistentSettings;Lcom/copperleaf/ballast/repository/cache/Cached;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component2 ()Lcom/copperleaf/ballast/repository/cache/Cached; - public final fun copy (Lcom/copperleaf/ballast/debugger/idea/settings/IntellijPluginPersistentSettings;Lcom/copperleaf/ballast/repository/cache/Cached;)Lcom/copperleaf/ballast/debugger/idea/repository/RepositoryContract$State; - public static synthetic fun copy$default (Lcom/copperleaf/ballast/debugger/idea/repository/RepositoryContract$State;Lcom/copperleaf/ballast/debugger/idea/settings/IntellijPluginPersistentSettings;Lcom/copperleaf/ballast/repository/cache/Cached;ILjava/lang/Object;)Lcom/copperleaf/ballast/debugger/idea/repository/RepositoryContract$State; - public fun equals (Ljava/lang/Object;)Z - public final fun getSettings ()Lcom/copperleaf/ballast/repository/cache/Cached; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class com/copperleaf/ballast/debugger/idea/repository/RepositoryEventHandler : com/copperleaf/ballast/EventHandler { - public static final field $stable I - public fun ()V - public fun handleEvent (Lcom/copperleaf/ballast/EventHandlerScope;Lcom/copperleaf/ballast/debugger/idea/repository/RepositoryContract$Events;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public synthetic fun handleEvent (Lcom/copperleaf/ballast/EventHandlerScope;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; -} - -public final class com/copperleaf/ballast/debugger/idea/repository/RepositoryInputHandler : com/copperleaf/ballast/InputHandler { - public static final field $stable I - public fun ()V - public fun handleInput (Lcom/copperleaf/ballast/InputHandlerScope;Lcom/copperleaf/ballast/debugger/idea/repository/RepositoryContract$Inputs;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public synthetic fun handleInput (Lcom/copperleaf/ballast/InputHandlerScope;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; -} - -public abstract interface class com/copperleaf/ballast/debugger/idea/settings/IntellijPluginMutableSettings : com/copperleaf/ballast/debugger/idea/settings/IntellijPluginSettings { - public abstract fun getAllComponentsIncludesComposeUi ()Z - public abstract fun getAllComponentsIncludesSavedStateAdapter ()Z - public abstract fun getAllComponentsIncludesViewModel ()Z - public abstract fun getAlwaysShowCurrentState ()Z - public abstract fun getAutoselectDebuggerConnections ()Z - public abstract fun getBaseViewModelType ()Lcom/copperleaf/ballast/debugger/idea/features/templates/BallastViewModel$ViewModelTemplate; - public abstract fun getDarkTheme ()Z - public abstract fun getDebuggerServerPort ()I - public abstract fun getDefaultVisibility ()Lcom/copperleaf/ballast/debugger/idea/features/templates/BallastViewModel$DefaultVisibility; - public abstract fun getDetailsPanePercentage ()F - public abstract fun getLastRoute ()Lcom/copperleaf/ballast/debugger/idea/features/debugger/router/DebuggerRoute; - public abstract fun getLastViewModelName ()Ljava/lang/String; - public abstract fun getRouterViewModelName ()Ljava/lang/String; - public abstract fun getShowCurrentRoute ()Z - public abstract fun getUseDataObjects ()Z - public abstract fun setAllComponentsIncludesComposeUi (Z)V - public abstract fun setAllComponentsIncludesSavedStateAdapter (Z)V - public abstract fun setAllComponentsIncludesViewModel (Z)V - public abstract fun setAlwaysShowCurrentState (Z)V - public abstract fun setAutoselectDebuggerConnections (Z)V - public abstract fun setBaseViewModelType (Lcom/copperleaf/ballast/debugger/idea/features/templates/BallastViewModel$ViewModelTemplate;)V - public abstract fun setDarkTheme (Z)V - public abstract fun setDebuggerServerPort (I)V - public abstract fun setDefaultVisibility (Lcom/copperleaf/ballast/debugger/idea/features/templates/BallastViewModel$DefaultVisibility;)V - public abstract fun setDetailsPanePercentage (F)V - public abstract fun setLastRoute (Lcom/copperleaf/ballast/debugger/idea/features/debugger/router/DebuggerRoute;)V - public abstract fun setLastViewModelName (Ljava/lang/String;)V - public abstract fun setRouterViewModelName (Ljava/lang/String;)V - public abstract fun setShowCurrentRoute (Z)V - public abstract fun setUseDataObjects (Z)V -} - -public final class com/copperleaf/ballast/debugger/idea/settings/IntellijPluginPersistentSettings : com/copperleaf/ballast/debugger/idea/settings/IntellijPluginMutableSettings, com/russhwolf/settings/Settings { - public static final field $stable I - public fun ()V - public final fun applyFromSnapshot (Lcom/copperleaf/ballast/debugger/idea/settings/IntellijPluginSettings;)V - public fun clear ()V - public fun getAllComponentsIncludesComposeUi ()Z - public fun getAllComponentsIncludesSavedStateAdapter ()Z - public fun getAllComponentsIncludesViewModel ()Z - public fun getAlwaysShowCurrentState ()Z - public fun getAutoselectDebuggerConnections ()Z - public fun getBallastVersion ()Ljava/lang/String; - public fun getBaseViewModelType ()Lcom/copperleaf/ballast/debugger/idea/features/templates/BallastViewModel$ViewModelTemplate; - public fun getBoolean (Ljava/lang/String;Z)Z - public fun getBooleanOrNull (Ljava/lang/String;)Ljava/lang/Boolean; - public fun getDarkTheme ()Z - public fun getDebuggerServerPort ()I - public fun getDefaultVisibility ()Lcom/copperleaf/ballast/debugger/idea/features/templates/BallastViewModel$DefaultVisibility; - public fun getDetailsPanePercentage ()F - public fun getDouble (Ljava/lang/String;D)D - public fun getDoubleOrNull (Ljava/lang/String;)Ljava/lang/Double; - public fun getFloat (Ljava/lang/String;F)F - public fun getFloatOrNull (Ljava/lang/String;)Ljava/lang/Float; - public fun getInt (Ljava/lang/String;I)I - public fun getIntOrNull (Ljava/lang/String;)Ljava/lang/Integer; - public fun getKeys ()Ljava/util/Set; - public fun getLastRoute ()Lcom/copperleaf/ballast/debugger/idea/features/debugger/router/DebuggerRoute; - public fun getLastViewModelName ()Ljava/lang/String; - public fun getLong (Ljava/lang/String;J)J - public fun getLongOrNull (Ljava/lang/String;)Ljava/lang/Long; - public fun getRouterViewModelName ()Ljava/lang/String; - public fun getShowCurrentRoute ()Z - public fun getSize ()I - public fun getString (Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String; - public fun getStringOrNull (Ljava/lang/String;)Ljava/lang/String; - public fun getUseDataObjects ()Z - public fun hasKey (Ljava/lang/String;)Z - public fun putBoolean (Ljava/lang/String;Z)V - public fun putDouble (Ljava/lang/String;D)V - public fun putFloat (Ljava/lang/String;F)V - public fun putInt (Ljava/lang/String;I)V - public fun putLong (Ljava/lang/String;J)V - public fun putString (Ljava/lang/String;Ljava/lang/String;)V - public fun remove (Ljava/lang/String;)V - public fun setAllComponentsIncludesComposeUi (Z)V - public fun setAllComponentsIncludesSavedStateAdapter (Z)V - public fun setAllComponentsIncludesViewModel (Z)V - public fun setAlwaysShowCurrentState (Z)V - public fun setAutoselectDebuggerConnections (Z)V - public fun setBaseViewModelType (Lcom/copperleaf/ballast/debugger/idea/features/templates/BallastViewModel$ViewModelTemplate;)V - public fun setDarkTheme (Z)V - public fun setDebuggerServerPort (I)V - public fun setDefaultVisibility (Lcom/copperleaf/ballast/debugger/idea/features/templates/BallastViewModel$DefaultVisibility;)V - public fun setDetailsPanePercentage (F)V - public fun setLastRoute (Lcom/copperleaf/ballast/debugger/idea/features/debugger/router/DebuggerRoute;)V - public fun setLastViewModelName (Ljava/lang/String;)V - public fun setRouterViewModelName (Ljava/lang/String;)V - public fun setShowCurrentRoute (Z)V - public fun setUseDataObjects (Z)V -} - -public abstract interface class com/copperleaf/ballast/debugger/idea/settings/IntellijPluginSettings : com/copperleaf/ballast/debugger/idea/settings/DebuggerUiSettings, com/copperleaf/ballast/debugger/idea/settings/GeneralSettings, com/copperleaf/ballast/debugger/idea/settings/TemplatesSettings, com/copperleaf/ballast/debugger/server/BallastDebuggerServerSettings { -} - -public final class com/copperleaf/ballast/debugger/idea/settings/IntellijPluginSettingsDefaults : com/copperleaf/ballast/debugger/idea/settings/IntellijPluginSettings { - public static final field $stable I - public fun ()V - public fun (Ljava/lang/String;ZILcom/copperleaf/ballast/debugger/idea/features/debugger/router/DebuggerRoute;Ljava/lang/String;ZZZLjava/lang/String;FLcom/copperleaf/ballast/debugger/idea/features/templates/BallastViewModel$ViewModelTemplate;ZZZLcom/copperleaf/ballast/debugger/idea/features/templates/BallastViewModel$DefaultVisibility;Z)V - public synthetic fun (Ljava/lang/String;ZILcom/copperleaf/ballast/debugger/idea/features/debugger/router/DebuggerRoute;Ljava/lang/String;ZZZLjava/lang/String;FLcom/copperleaf/ballast/debugger/idea/features/templates/BallastViewModel$ViewModelTemplate;ZZZLcom/copperleaf/ballast/debugger/idea/features/templates/BallastViewModel$DefaultVisibility;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Ljava/lang/String; - public final fun component10 ()F - public final fun component11 ()Lcom/copperleaf/ballast/debugger/idea/features/templates/BallastViewModel$ViewModelTemplate; - public final fun component12 ()Z - public final fun component13 ()Z - public final fun component14 ()Z - public final fun component15 ()Lcom/copperleaf/ballast/debugger/idea/features/templates/BallastViewModel$DefaultVisibility; - public final fun component16 ()Z - public final fun component2 ()Z - public final fun component3 ()I - public final fun component4 ()Lcom/copperleaf/ballast/debugger/idea/features/debugger/router/DebuggerRoute; - public final fun component5 ()Ljava/lang/String; - public final fun component6 ()Z - public final fun component7 ()Z - public final fun component8 ()Z - public final fun component9 ()Ljava/lang/String; - public final fun copy (Ljava/lang/String;ZILcom/copperleaf/ballast/debugger/idea/features/debugger/router/DebuggerRoute;Ljava/lang/String;ZZZLjava/lang/String;FLcom/copperleaf/ballast/debugger/idea/features/templates/BallastViewModel$ViewModelTemplate;ZZZLcom/copperleaf/ballast/debugger/idea/features/templates/BallastViewModel$DefaultVisibility;Z)Lcom/copperleaf/ballast/debugger/idea/settings/IntellijPluginSettingsDefaults; - public static synthetic fun copy$default (Lcom/copperleaf/ballast/debugger/idea/settings/IntellijPluginSettingsDefaults;Ljava/lang/String;ZILcom/copperleaf/ballast/debugger/idea/features/debugger/router/DebuggerRoute;Ljava/lang/String;ZZZLjava/lang/String;FLcom/copperleaf/ballast/debugger/idea/features/templates/BallastViewModel$ViewModelTemplate;ZZZLcom/copperleaf/ballast/debugger/idea/features/templates/BallastViewModel$DefaultVisibility;ZILjava/lang/Object;)Lcom/copperleaf/ballast/debugger/idea/settings/IntellijPluginSettingsDefaults; - public fun equals (Ljava/lang/Object;)Z - public fun getAllComponentsIncludesComposeUi ()Z - public fun getAllComponentsIncludesSavedStateAdapter ()Z - public fun getAllComponentsIncludesViewModel ()Z - public fun getAlwaysShowCurrentState ()Z - public fun getAutoselectDebuggerConnections ()Z - public fun getBallastVersion ()Ljava/lang/String; - public fun getBaseViewModelType ()Lcom/copperleaf/ballast/debugger/idea/features/templates/BallastViewModel$ViewModelTemplate; - public fun getDarkTheme ()Z - public fun getDebuggerServerPort ()I - public fun getDefaultVisibility ()Lcom/copperleaf/ballast/debugger/idea/features/templates/BallastViewModel$DefaultVisibility; - public fun getDetailsPanePercentage ()F - public fun getLastRoute ()Lcom/copperleaf/ballast/debugger/idea/features/debugger/router/DebuggerRoute; - public fun getLastViewModelName ()Ljava/lang/String; - public fun getRouterViewModelName ()Ljava/lang/String; - public fun getShowCurrentRoute ()Z - public fun getUseDataObjects ()Z - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class com/copperleaf/ballast/debugger/idea/settings/IntellijPluginSettingsSnapshot : com/copperleaf/ballast/debugger/idea/settings/IntellijPluginSettings { - public static final field $stable I - public static final field Companion Lcom/copperleaf/ballast/debugger/idea/settings/IntellijPluginSettingsSnapshot$Companion; - public fun (Ljava/lang/String;ZILcom/copperleaf/ballast/debugger/idea/features/debugger/router/DebuggerRoute;Ljava/lang/String;ZZZLjava/lang/String;FLcom/copperleaf/ballast/debugger/idea/features/templates/BallastViewModel$ViewModelTemplate;ZZZLcom/copperleaf/ballast/debugger/idea/features/templates/BallastViewModel$DefaultVisibility;Z)V - public final fun component1 ()Ljava/lang/String; - public final fun component10 ()F - public final fun component11 ()Lcom/copperleaf/ballast/debugger/idea/features/templates/BallastViewModel$ViewModelTemplate; - public final fun component12 ()Z - public final fun component13 ()Z - public final fun component14 ()Z - public final fun component15 ()Lcom/copperleaf/ballast/debugger/idea/features/templates/BallastViewModel$DefaultVisibility; - public final fun component16 ()Z - public final fun component2 ()Z - public final fun component3 ()I - public final fun component4 ()Lcom/copperleaf/ballast/debugger/idea/features/debugger/router/DebuggerRoute; - public final fun component5 ()Ljava/lang/String; - public final fun component6 ()Z - public final fun component7 ()Z - public final fun component8 ()Z - public final fun component9 ()Ljava/lang/String; - public final fun copy (Ljava/lang/String;ZILcom/copperleaf/ballast/debugger/idea/features/debugger/router/DebuggerRoute;Ljava/lang/String;ZZZLjava/lang/String;FLcom/copperleaf/ballast/debugger/idea/features/templates/BallastViewModel$ViewModelTemplate;ZZZLcom/copperleaf/ballast/debugger/idea/features/templates/BallastViewModel$DefaultVisibility;Z)Lcom/copperleaf/ballast/debugger/idea/settings/IntellijPluginSettingsSnapshot; - public static synthetic fun copy$default (Lcom/copperleaf/ballast/debugger/idea/settings/IntellijPluginSettingsSnapshot;Ljava/lang/String;ZILcom/copperleaf/ballast/debugger/idea/features/debugger/router/DebuggerRoute;Ljava/lang/String;ZZZLjava/lang/String;FLcom/copperleaf/ballast/debugger/idea/features/templates/BallastViewModel$ViewModelTemplate;ZZZLcom/copperleaf/ballast/debugger/idea/features/templates/BallastViewModel$DefaultVisibility;ZILjava/lang/Object;)Lcom/copperleaf/ballast/debugger/idea/settings/IntellijPluginSettingsSnapshot; - public fun equals (Ljava/lang/Object;)Z - public fun getAllComponentsIncludesComposeUi ()Z - public fun getAllComponentsIncludesSavedStateAdapter ()Z - public fun getAllComponentsIncludesViewModel ()Z - public fun getAlwaysShowCurrentState ()Z - public fun getAutoselectDebuggerConnections ()Z - public fun getBallastVersion ()Ljava/lang/String; - public fun getBaseViewModelType ()Lcom/copperleaf/ballast/debugger/idea/features/templates/BallastViewModel$ViewModelTemplate; - public fun getDarkTheme ()Z - public fun getDebuggerServerPort ()I - public fun getDefaultVisibility ()Lcom/copperleaf/ballast/debugger/idea/features/templates/BallastViewModel$DefaultVisibility; - public fun getDetailsPanePercentage ()F - public fun getLastRoute ()Lcom/copperleaf/ballast/debugger/idea/features/debugger/router/DebuggerRoute; - public fun getLastViewModelName ()Ljava/lang/String; - public fun getRouterViewModelName ()Ljava/lang/String; - public fun getShowCurrentRoute ()Z - public fun getUseDataObjects ()Z - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class com/copperleaf/ballast/debugger/idea/settings/IntellijPluginSettingsSnapshot$Companion { - public final fun defaults ()Lcom/copperleaf/ballast/debugger/idea/settings/IntellijPluginSettingsSnapshot; - public final fun fromSettings (Lcom/copperleaf/ballast/debugger/idea/settings/IntellijPluginSettings;)Lcom/copperleaf/ballast/debugger/idea/settings/IntellijPluginSettingsSnapshot; -} - -public abstract interface class com/copperleaf/ballast/debugger/idea/settings/TemplatesSettings { - public abstract fun getAllComponentsIncludesComposeUi ()Z - public abstract fun getAllComponentsIncludesSavedStateAdapter ()Z - public abstract fun getAllComponentsIncludesViewModel ()Z - public abstract fun getBaseViewModelType ()Lcom/copperleaf/ballast/debugger/idea/features/templates/BallastViewModel$ViewModelTemplate; - public abstract fun getDefaultVisibility ()Lcom/copperleaf/ballast/debugger/idea/features/templates/BallastViewModel$DefaultVisibility; - public abstract fun getUseDataObjects ()Z -} - -public final class com/copperleaf/ballast/debugger/idea/theme/IdeaPluginThemeKt { - public static final fun IdeaPluginTheme (Lcom/intellij/openapi/project/Project;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;I)V -} - -public final class com/copperleaf/ballast/debugger/idea/theme/ShapesKt { - public static final fun getShapes ()Landroidx/compose/material/Shapes; -} - -public abstract interface class com/copperleaf/ballast/debugger/idea/theme/SwingColor { - public abstract fun getBackground-0d7_KjU ()J -} - -public final class com/copperleaf/ballast/debugger/idea/theme/SwingColorKt { - public static final fun SwingColor (Landroidx/compose/runtime/Composer;I)Lcom/copperleaf/ballast/debugger/idea/theme/SwingColor; -} - -public final class com/copperleaf/ballast/debugger/idea/theme/TypographyKt { - public static final fun getTypography ()Landroidx/compose/material/Typography; -} - -public final class com/copperleaf/ballast/debugger/idea/utils/PropertiesComponentSettings : com/russhwolf/settings/Settings { - public static final field $stable I - public fun (Ljava/lang/String;Lcom/intellij/ide/util/PropertiesComponent;)V - public fun clear ()V - public fun getBoolean (Ljava/lang/String;Z)Z - public fun getBooleanOrNull (Ljava/lang/String;)Ljava/lang/Boolean; - public fun getDouble (Ljava/lang/String;D)D - public fun getDoubleOrNull (Ljava/lang/String;)Ljava/lang/Double; - public fun getFloat (Ljava/lang/String;F)F - public fun getFloatOrNull (Ljava/lang/String;)Ljava/lang/Float; - public fun getInt (Ljava/lang/String;I)I - public fun getIntOrNull (Ljava/lang/String;)Ljava/lang/Integer; - public fun getKeys ()Ljava/util/Set; - public fun getLong (Ljava/lang/String;J)J - public fun getLongOrNull (Ljava/lang/String;)Ljava/lang/Long; - public fun getSize ()I - public fun getString (Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String; - public fun getStringOrNull (Ljava/lang/String;)Ljava/lang/String; - public fun hasKey (Ljava/lang/String;)Z - public fun putBoolean (Ljava/lang/String;Z)V - public fun putDouble (Ljava/lang/String;D)V - public fun putFloat (Ljava/lang/String;F)V - public fun putInt (Ljava/lang/String;I)V - public fun putLong (Ljava/lang/String;J)V - public fun putString (Ljava/lang/String;Ljava/lang/String;)V - public fun remove (Ljava/lang/String;)V -} - -public final class com/copperleaf/ballast/debugger/idea/utils/SettingsUtilsKt { - public static final fun enum (Lcom/russhwolf/settings/Settings;Ljava/lang/String;Ljava/lang/Enum;Lkotlin/jvm/functions/Function1;)Lkotlin/properties/ReadWriteProperty; - public static synthetic fun enum$default (Lcom/russhwolf/settings/Settings;Ljava/lang/String;Ljava/lang/Enum;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lkotlin/properties/ReadWriteProperty; -} - diff --git a/ballast-idea-plugin/src/main/kotlin/com/copperleaf/ballast/debugger/idea/features/settings/injector/SettingsPanelInjectorImpl.kt b/ballast-idea-plugin/src/main/kotlin/com/copperleaf/ballast/debugger/idea/features/settings/injector/SettingsPanelInjectorImpl.kt index fa641bef..aaa595c9 100644 --- a/ballast-idea-plugin/src/main/kotlin/com/copperleaf/ballast/debugger/idea/features/settings/injector/SettingsPanelInjectorImpl.kt +++ b/ballast-idea-plugin/src/main/kotlin/com/copperleaf/ballast/debugger/idea/features/settings/injector/SettingsPanelInjectorImpl.kt @@ -2,13 +2,11 @@ package com.copperleaf.ballast.debugger.idea.features.settings.injector import com.copperleaf.ballast.build import com.copperleaf.ballast.core.BasicViewModel -import com.copperleaf.ballast.core.KillSwitch import com.copperleaf.ballast.debugger.idea.BallastIntellijPluginInjector import com.copperleaf.ballast.debugger.idea.features.settings.vm.SettingsUiContract import com.copperleaf.ballast.debugger.idea.features.settings.vm.SettingsUiEventHandler import com.copperleaf.ballast.debugger.idea.features.settings.vm.SettingsUiInputHandler import com.copperleaf.ballast.debugger.idea.features.settings.vm.SettingsUiViewModel -import com.copperleaf.ballast.plusAssign import com.copperleaf.ballast.withViewModel import com.intellij.openapi.project.Project import kotlinx.coroutines.CoroutineScope @@ -18,18 +16,12 @@ class SettingsPanelInjectorImpl( ) : SettingsPanelInjector { override val project: Project = pluginInjector.project override val settingsPanelCoroutineScope: CoroutineScope = pluginInjector.newMainCoroutineScope() - private val settingsPanelKillSwitch: KillSwitch< - SettingsUiContract.Inputs, - SettingsUiContract.Events, - SettingsUiContract.State> = KillSwitch() - override val settingsPanelViewModel: SettingsUiViewModel = BasicViewModel( coroutineScope = settingsPanelCoroutineScope, config = pluginInjector .commonViewModelBuilder(loggingEnabled = false) { SettingsUiContract.Inputs.Initialize } - .apply { this += settingsPanelKillSwitch } .withViewModel( initialState = SettingsUiContract.State(), inputHandler = SettingsUiInputHandler(pluginInjector.repository), diff --git a/ballast-idea-plugin/src/main/kotlin/com/copperleaf/ballast/debugger/idea/features/settings/vm/SettingsUiInputHandler.kt b/ballast-idea-plugin/src/main/kotlin/com/copperleaf/ballast/debugger/idea/features/settings/vm/SettingsUiInputHandler.kt index eaf3149a..405dccca 100644 --- a/ballast-idea-plugin/src/main/kotlin/com/copperleaf/ballast/debugger/idea/features/settings/vm/SettingsUiInputHandler.kt +++ b/ballast-idea-plugin/src/main/kotlin/com/copperleaf/ballast/debugger/idea/features/settings/vm/SettingsUiInputHandler.kt @@ -2,7 +2,6 @@ package com.copperleaf.ballast.debugger.idea.features.settings.vm import com.copperleaf.ballast.InputHandler import com.copperleaf.ballast.InputHandlerScope -import com.copperleaf.ballast.core.KillSwitch import com.copperleaf.ballast.debugger.idea.repository.RepositoryContract import com.copperleaf.ballast.debugger.idea.repository.RepositoryViewModel import com.copperleaf.ballast.observeFlows @@ -71,7 +70,7 @@ class SettingsUiInputHandler( is SettingsUiContract.Inputs.CloseGracefully -> { sideJob("CloseGracefully") { - getInterceptor(KillSwitch.Key).requestGracefulShutdown() + requestGracefulShutdown() } } } diff --git a/ballast-idea-plugin/src/main/kotlin/com/copperleaf/ballast/debugger/idea/repository/RepositoryEventHandler.kt b/ballast-idea-plugin/src/main/kotlin/com/copperleaf/ballast/debugger/idea/repository/RepositoryEventHandler.kt index 7d60ebb0..93df3c9f 100644 --- a/ballast-idea-plugin/src/main/kotlin/com/copperleaf/ballast/debugger/idea/repository/RepositoryEventHandler.kt +++ b/ballast-idea-plugin/src/main/kotlin/com/copperleaf/ballast/debugger/idea/repository/RepositoryEventHandler.kt @@ -13,7 +13,5 @@ class RepositoryEventHandler : EventHandler< RepositoryContract.Events, RepositoryContract.State>.handleEvent( event: RepositoryContract.Events - ) = when (event) { - else -> {} - } + ) { } } diff --git a/ballast-idea-plugin/src/main/resources/META-INF/plugin.xml b/ballast-idea-plugin/src/main/resources/META-INF/plugin.xml index b230647d..c3c715ab 100644 --- a/ballast-idea-plugin/src/main/resources/META-INF/plugin.xml +++ b/ballast-idea-plugin/src/main/resources/META-INF/plugin.xml @@ -1,5 +1,5 @@ - + com.copperleaf.ballast.Ballast Ballast Casey Brooks diff --git a/ballast-kotlinx-serialization/README.md b/ballast-kotlinx-serialization/README.md new file mode 100644 index 00000000..e0156f75 --- /dev/null +++ b/ballast-kotlinx-serialization/README.md @@ -0,0 +1,83 @@ +# Ballast Kotlinx Serialization + +## Overview + +Adds automatic JSON serialization/deserialization capabilities to ViewModels with +[Kotlinx Serialization](https://github.com/Kotlin/kotlinx.serialization). This allows you to register `KSerializers` +once for the entire ViewModel, then all Ballast Plugins in that ViewModel can serialize their Inputs, Events, and States +using those serializers. + +## Supported Platforms + +| Platform | Supported | +|----------|-----------| +| JVM | ✅ | +| Android | ✅ | +| iOS | ✅ | +| JS | ✅ | +| WASM JS | ✅ | + +## See Also + +- [Ballast Saved State](./../ballast-saved-state) +- [Ballast Debugger Client](./../ballast-debugger-client) +- [Ballast Queue ViewModel](./../ballast-queue-viewmodel) + +## Usage + +Ballast ViewModels contain `encoder` and `decoder` properties in their `BallastViewModelConfiguration`, which are used +anytime a ViewModel or Plugin needs to convert an `Input`, `Event`, or `State` object to a String, whether for logging +or for transport over a network, or for persistent storage. The default ViewModel configuration uses `.toString()` to +convert an object to a String, but does not include support for deserializing an object from a String. + +This module adds a simple `withSerialization()` function to the `BallastViewModelConfiguration.TypedBuilder` allowing +you to register `KSerializers` which get used for all of a ViewModel's serialization and deserialization tasks. + +```kotlin +class ExampleViewModel( + private val coroutineScope: CoroutineScope, +) : BasicViewModel< + ExampleContract.Inputs, + ExampleContract.Events, + ExampleContract.State>( + coroutineScope = coroutineScope, + config = BallastViewModelConfiguration.Builder() + .withViewModel( + inputHandler = ExampleInputHandler(), + initialState = ExampleContract.State, + name = "ExampleViewModel", + ) + .withJsonSerialization( + inputsSerializer = ExampleContract.Inputs.serializer(), + eventsSerializer = ExampleContract.Events.serializer(), + stateSerializer = ExampleContract.State.serializer(), + json = Json { prettyPrint = true }, // optional + ) + .build(), + eventHandler = eventHandler { }, +) +``` + +## Installation + +```kotlin +repositories { + mavenCentral() +} + +// for plain JVM or Android projects +dependencies { + implementation("io.github.copper-leaf:ballast-kotlinx-serialization:{{ballastVersion}}") +} + +// for multiplatform projects +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + implementation("io.github.copper-leaf:ballast-kotlinx-serialization:{{ballastVersion}}") + } + } + } +} +``` diff --git a/ballast-kotlinx-serialization/api/android/ballast-kotlinx-serialization.api b/ballast-kotlinx-serialization/api/android/ballast-kotlinx-serialization.api new file mode 100644 index 00000000..35f57a34 --- /dev/null +++ b/ballast-kotlinx-serialization/api/android/ballast-kotlinx-serialization.api @@ -0,0 +1,17 @@ +public final class com/copperleaf/ballast/JsonBallastEncoder : com/copperleaf/ballast/BallastDecoder, com/copperleaf/ballast/BallastEncoder { + public fun (Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/json/Json;)V + public synthetic fun (Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/json/Json;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun decodeEventFromString (Ljava/lang/String;)Ljava/lang/Object; + public fun decodeInputFromString (Ljava/lang/String;)Ljava/lang/Object; + public fun decodeStateFromString (Ljava/lang/String;)Ljava/lang/Object; + public fun encodeEventToString (Ljava/lang/Object;)Ljava/lang/String; + public fun encodeInputToString (Ljava/lang/Object;)Ljava/lang/String; + public fun encodeStateToString (Ljava/lang/Object;)Ljava/lang/String; + public fun getContentType ()Ljava/lang/String; +} + +public final class com/copperleaf/ballast/JsonBallastEncoderKt { + public static final fun withJsonSerialization (Lcom/copperleaf/ballast/BallastViewModelConfiguration$TypedBuilder;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/json/Json;)Lcom/copperleaf/ballast/BallastViewModelConfiguration$TypedBuilder; + public static synthetic fun withJsonSerialization$default (Lcom/copperleaf/ballast/BallastViewModelConfiguration$TypedBuilder;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/json/Json;ILjava/lang/Object;)Lcom/copperleaf/ballast/BallastViewModelConfiguration$TypedBuilder; +} + diff --git a/ballast-kotlinx-serialization/api/jvm/ballast-kotlinx-serialization.api b/ballast-kotlinx-serialization/api/jvm/ballast-kotlinx-serialization.api new file mode 100644 index 00000000..35f57a34 --- /dev/null +++ b/ballast-kotlinx-serialization/api/jvm/ballast-kotlinx-serialization.api @@ -0,0 +1,17 @@ +public final class com/copperleaf/ballast/JsonBallastEncoder : com/copperleaf/ballast/BallastDecoder, com/copperleaf/ballast/BallastEncoder { + public fun (Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/json/Json;)V + public synthetic fun (Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/json/Json;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun decodeEventFromString (Ljava/lang/String;)Ljava/lang/Object; + public fun decodeInputFromString (Ljava/lang/String;)Ljava/lang/Object; + public fun decodeStateFromString (Ljava/lang/String;)Ljava/lang/Object; + public fun encodeEventToString (Ljava/lang/Object;)Ljava/lang/String; + public fun encodeInputToString (Ljava/lang/Object;)Ljava/lang/String; + public fun encodeStateToString (Ljava/lang/Object;)Ljava/lang/String; + public fun getContentType ()Ljava/lang/String; +} + +public final class com/copperleaf/ballast/JsonBallastEncoderKt { + public static final fun withJsonSerialization (Lcom/copperleaf/ballast/BallastViewModelConfiguration$TypedBuilder;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/json/Json;)Lcom/copperleaf/ballast/BallastViewModelConfiguration$TypedBuilder; + public static synthetic fun withJsonSerialization$default (Lcom/copperleaf/ballast/BallastViewModelConfiguration$TypedBuilder;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/json/Json;ILjava/lang/Object;)Lcom/copperleaf/ballast/BallastViewModelConfiguration$TypedBuilder; +} + diff --git a/ballast-kotlinx-serialization/build.gradle.kts b/ballast-kotlinx-serialization/build.gradle.kts new file mode 100644 index 00000000..1dda69d9 --- /dev/null +++ b/ballast-kotlinx-serialization/build.gradle.kts @@ -0,0 +1,36 @@ +plugins { + id("copper-leaf-base") + id("copper-leaf-android-library") + id("copper-leaf-targets") + id("copper-leaf-tests") + id("copper-leaf-lint") + id("copper-leaf-publish") +} + +description = "Ballast Encoders and Decoders using Kotlinx Serialization" + +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + implementation(project(":ballast-api")) + implementation(libs.kotlinx.serialization.json) + } + } + val commonTest by getting { + dependencies { } + } + val jvmMain by getting { + dependencies { } + } + val androidMain by getting { + dependencies { } + } + val jsMain by getting { + dependencies { } + } + val iosMain by getting { + dependencies { } + } + } +} diff --git a/ballast-kotlinx-serialization/gradle.properties b/ballast-kotlinx-serialization/gradle.properties new file mode 100644 index 00000000..90bcabce --- /dev/null +++ b/ballast-kotlinx-serialization/gradle.properties @@ -0,0 +1,8 @@ +copperleaf.description=Ballast Encoders and Decoders using Kotlinx Serialization + +copperleaf.targets.android=true +copperleaf.targets.jvm=true +copperleaf.targets.ios=true +copperleaf.targets.js=true +copperleaf.targets.wasm.wasi=false +copperleaf.targets.wasm.js=true diff --git a/ballast-kotlinx-serialization/src/androidMain/AndroidManifest.xml b/ballast-kotlinx-serialization/src/androidMain/AndroidManifest.xml new file mode 100644 index 00000000..811d7660 --- /dev/null +++ b/ballast-kotlinx-serialization/src/androidMain/AndroidManifest.xml @@ -0,0 +1,2 @@ + diff --git a/ballast-kotlinx-serialization/src/commonMain/kotlin/com/copperleaf/ballast/JsonBallastEncoder.kt b/ballast-kotlinx-serialization/src/commonMain/kotlin/com/copperleaf/ballast/JsonBallastEncoder.kt new file mode 100644 index 00000000..6873c854 --- /dev/null +++ b/ballast-kotlinx-serialization/src/commonMain/kotlin/com/copperleaf/ballast/JsonBallastEncoder.kt @@ -0,0 +1,54 @@ +package com.copperleaf.ballast + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.Json + +public class JsonBallastEncoder( + private val inputsSerializer: KSerializer, + private val eventsSerializer: KSerializer, + private val stateSerializer: KSerializer, + private val json: Json = Json { prettyPrint = true }, +) : BallastEncoder, BallastDecoder { + + override val contentType: String = "application/json" + + override fun encodeInputToString(input: Inputs): String { + return json.encodeToString(inputsSerializer, input) + } + + override fun encodeEventToString(event: Events): String { + return json.encodeToString(eventsSerializer, event) + } + + override fun encodeStateToString(state: State): String { + return json.encodeToString(stateSerializer, state) + } + + override fun decodeInputFromString(encoded: String): Inputs { + return json.decodeFromString(inputsSerializer, encoded) + } + + override fun decodeEventFromString(encoded: String): Events { + return json.decodeFromString(eventsSerializer, encoded) + } + + override fun decodeStateFromString(encoded: String): State { + return json.decodeFromString(stateSerializer, encoded) + } +} + +public fun BallastViewModelConfiguration.TypedBuilder.withJsonSerialization( + inputsSerializer: KSerializer, + eventsSerializer: KSerializer, + stateSerializer: KSerializer, + json: Json = Json { prettyPrint = true }, +): BallastViewModelConfiguration.TypedBuilder = this.apply { + val encoderDecoder = JsonBallastEncoder( + inputsSerializer = inputsSerializer, + eventsSerializer = eventsSerializer, + stateSerializer = stateSerializer, + json = json, + ) + this.encoder = encoderDecoder + this.decoder = encoderDecoder +} diff --git a/ballast-ktor-server/README.md b/ballast-ktor-server/README.md new file mode 100644 index 00000000..6c2a05bf --- /dev/null +++ b/ballast-ktor-server/README.md @@ -0,0 +1,106 @@ +# Ballast Ktor Server + +> [!CAUTION] +> +> Experimental. This module may not still have issues or changes in its public API before being considered stable. +> Please use at your own risk, and file Issues for any problems you may encounter. + +## Overview + +A Ktor plugin to integrate Ballast ViewModels into server-side Ktor services. Intended to be used with other server-side +Ballast components like Schedulers, Job Queues, and autoscaling. + +## Supported Platforms + +| Platform | Supported | +|----------|-----------| +| JVM | ✅ | +| Android | ❌ | +| iOS | ❌ | +| JS | ❌ | +| WASM JS | ❌ | + +## See Also + +- [Ballast Autoscale](./../ballast-autoscale) +- [Ballast Scheduler ViewModel](./../ballast-scheduler-viewmodel) +- [Ballast Queue ViewModel](./../ballast-queue-viewmodel) + +## Usage + +This module provides basic functionality for registering ViewModels to be used in your Ktor server, which start up and +get shut down with the application server's lifecycle. + +ViewModels must be registered using an `AttributeKey` so it can be accessed from an `ApplicationCall` with +`ballastViewModel(key)`. This allows you to obtain a reference to the singleton ViewModel so you can send Inputs to it +from Request handlers. + +```kotlin +class EmailQueueViewModel( + private val coroutineScope: CoroutineScope, +) : BasicViewModel< + EmailQueueContract.Inputs, + EmailQueueContract.Events, + EmailQueueContract.State>( + coroutineScope = coroutineScope, + config = BallastViewModelConfiguration.Builder() + .withViewModel( + inputHandler = EmailQueueInputHandler(), + initialState = EmailQueueContract.State, + name = EmailQueueViewModel.Key.name, + ) + .build(), + eventHandler = eventHandler { }, +) { + companion object { + val Key = AttributeKey("EmailQueueViewModel") + } +} + +fun Application.module() { + install(Ballast) { + viewModel( + attributeKey = EmailQueueViewModel.Key, + createViewModel = { coroutineScope -> + EmailQueueViewModel(coroutineScope) + } + ) + } + + routing { + post("/send-email") { + // dispatch a Ballast Input to send an email in the background. Suspends until the Input has been enqueued, + // but does not wait for processing + ballastViewModel(EmailQueueViewModel.Key).send(EmailQueueContract.Inputs.SendEmail()) + + // return a response quickly so the application stays responsive for the end-user. The Input will be + // processed in the background to achieve eventual consistency + call.respondText("Hello") + } + } +} +``` + +## Installation + +```kotlin +repositories { + mavenCentral() +} + +// for plain JVM projects +dependencies { + implementation("io.github.copper-leaf:ballast-ktor-server:{{ballastVersion}}") +} + +// for multiplatform projects +kotlin { + sourceSets { + val jvmMain by getting { + dependencies { + implementation("io.github.copper-leaf:ballast-ktor-server:{{ballastVersion}}") + } + } + } +} +``` diff --git a/ballast-ktor-server/api/ballast-ktor-server.api b/ballast-ktor-server/api/ballast-ktor-server.api new file mode 100644 index 00000000..ea3a0ada --- /dev/null +++ b/ballast-ktor-server/api/ballast-ktor-server.api @@ -0,0 +1,22 @@ +public final class com/copperleaf/ballast/ktor/BallastKtorPluginConfiguration { + public fun ()V + public final fun viewModel (Lio/ktor/util/AttributeKey;Lkotlin/jvm/functions/Function1;)V +} + +public final class com/copperleaf/ballast/ktor/PluginKt { + public static final fun getBallast ()Lio/ktor/server/application/ApplicationPlugin; +} + +public final class com/copperleaf/ballast/ktor/RegisteredViewModel { + public fun (Lio/ktor/util/AttributeKey;Lkotlin/jvm/functions/Function1;)V + public final fun component1 ()Lio/ktor/util/AttributeKey; + public final fun component2 ()Lkotlin/jvm/functions/Function1; + public final fun copy (Lio/ktor/util/AttributeKey;Lkotlin/jvm/functions/Function1;)Lcom/copperleaf/ballast/ktor/RegisteredViewModel; + public static synthetic fun copy$default (Lcom/copperleaf/ballast/ktor/RegisteredViewModel;Lio/ktor/util/AttributeKey;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/copperleaf/ballast/ktor/RegisteredViewModel; + public fun equals (Ljava/lang/Object;)Z + public final fun getAttributeKey ()Lio/ktor/util/AttributeKey; + public final fun getCreateViewModel ()Lkotlin/jvm/functions/Function1; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + diff --git a/ballast-ktor-server/build.gradle.kts b/ballast-ktor-server/build.gradle.kts new file mode 100644 index 00000000..73fe8cb1 --- /dev/null +++ b/ballast-ktor-server/build.gradle.kts @@ -0,0 +1,25 @@ +plugins { + id("copper-leaf-base") + id("copper-leaf-targets") + id("copper-leaf-tests") + id("copper-leaf-lint") + id("copper-leaf-publish") +} + +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + api(project(":ballast-api")) + } + } + val commonTest by getting { + dependencies { } + } + val jvmMain by getting { + dependencies { + implementation(libs.ktor.server.core) + } + } + } +} diff --git a/ballast-ktor-server/gradle.properties b/ballast-ktor-server/gradle.properties new file mode 100644 index 00000000..000e657c --- /dev/null +++ b/ballast-ktor-server/gradle.properties @@ -0,0 +1,8 @@ +copperleaf.description=Integrate Ballast Viewmodels with Ktor Server applications. + +copperleaf.targets.android=false +copperleaf.targets.jvm=true +copperleaf.targets.ios=false +copperleaf.targets.js=false +copperleaf.targets.wasm.wasi=false +copperleaf.targets.wasm.js=false diff --git a/ballast-ktor-server/src/commonMain/kotlin/com/copperleaf/ballast/ktor/BallastKtorPluginConfiguration.kt b/ballast-ktor-server/src/commonMain/kotlin/com/copperleaf/ballast/ktor/BallastKtorPluginConfiguration.kt new file mode 100644 index 00000000..441171d2 --- /dev/null +++ b/ballast-ktor-server/src/commonMain/kotlin/com/copperleaf/ballast/ktor/BallastKtorPluginConfiguration.kt @@ -0,0 +1,20 @@ +package com.copperleaf.ballast.ktor + +import com.copperleaf.ballast.BallastViewModel +import io.ktor.util.AttributeKey +import kotlinx.coroutines.CoroutineScope + +@Suppress("UNCHECKED_CAST") +public class BallastKtorPluginConfiguration { + internal var viewModels: MutableList> = mutableListOf() + + public fun , Inputs : Any, Events : Any, State : Any> viewModel( + attributeKey: AttributeKey, + createViewModel: (CoroutineScope) -> VM, + ) { + viewModels += RegisteredViewModel( + attributeKey = attributeKey as AttributeKey>, + createViewModel = createViewModel + ) + } +} diff --git a/ballast-ktor-server/src/commonMain/kotlin/com/copperleaf/ballast/ktor/RegisteredViewModel.kt b/ballast-ktor-server/src/commonMain/kotlin/com/copperleaf/ballast/ktor/RegisteredViewModel.kt new file mode 100644 index 00000000..26845f98 --- /dev/null +++ b/ballast-ktor-server/src/commonMain/kotlin/com/copperleaf/ballast/ktor/RegisteredViewModel.kt @@ -0,0 +1,21 @@ +package com.copperleaf.ballast.ktor + +import com.copperleaf.ballast.BallastViewModel +import io.ktor.server.application.Application +import io.ktor.util.AttributeKey +import kotlinx.coroutines.CoroutineScope + +public data class RegisteredViewModel( + val attributeKey: AttributeKey>, + val createViewModel: (CoroutineScope) -> BallastViewModel, +) { + private lateinit var vm: BallastViewModel + internal fun startProcessing(application: Application, coroutineScope: CoroutineScope) { + vm = createViewModel(coroutineScope) + application.attributes.put(attributeKey, vm) + } + + internal suspend fun shutDownGracefully() { + vm.close() + } +} diff --git a/ballast-ktor-server/src/commonMain/kotlin/com/copperleaf/ballast/ktor/plugin.kt b/ballast-ktor-server/src/commonMain/kotlin/com/copperleaf/ballast/ktor/plugin.kt new file mode 100644 index 00000000..42f37c27 --- /dev/null +++ b/ballast-ktor-server/src/commonMain/kotlin/com/copperleaf/ballast/ktor/plugin.kt @@ -0,0 +1,43 @@ +@file:Suppress("UNCHECKED_CAST") + +package com.copperleaf.ballast.ktor + +import io.ktor.server.application.ApplicationPlugin +import io.ktor.server.application.ApplicationStarted +import io.ktor.server.application.ApplicationStopping +import io.ktor.server.application.createApplicationPlugin +import io.ktor.server.application.hooks.MonitoringEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.runBlocking + +public val Ballast: ApplicationPlugin = createApplicationPlugin( + name = "Ballast", + createConfiguration = ::BallastKtorPluginConfiguration +) { + // Standalone job not parented to the application scope. Ktor cancelling its + // own coroutine scope on SIGTERM will not immediately cancel the VMs; + // we control their lifecycle explicitly via graceful shutdown. + val ballastJob = SupervisorJob() + + on(MonitoringEvent(ApplicationStarted)) { application -> + // Replace the application's Job with our standalone one so VM coroutines + // are children of ballastJob, not the application scope. + val ballastScope = CoroutineScope(application.coroutineContext + ballastJob) + pluginConfig.viewModels.forEach { vm -> + vm.startProcessing(application, ballastScope) + } + } + + on(MonitoringEvent(ApplicationStopping)) { _ -> + // MonitoringEvent handlers are synchronous; runBlocking bridges into + // the coroutine world so we block until graceful shutdown completes + // before returning and allowing Ktor to continue its shutdown sequence. + runBlocking { + pluginConfig.viewModels.forEach { vm -> + vm.shutDownGracefully() + } + } + ballastJob.cancel() + } +} diff --git a/ballast-ktor-server/src/commonMain/kotlin/com/copperleaf/ballast/ktor/utils.kt b/ballast-ktor-server/src/commonMain/kotlin/com/copperleaf/ballast/ktor/utils.kt new file mode 100644 index 00000000..137910bd --- /dev/null +++ b/ballast-ktor-server/src/commonMain/kotlin/com/copperleaf/ballast/ktor/utils.kt @@ -0,0 +1,18 @@ +@file:Suppress("UNCHECKED_CAST") + +package com.copperleaf.ballast.ktor + +import com.copperleaf.ballast.BallastViewModel +import io.ktor.server.application.ApplicationCall +import io.ktor.util.AttributeKey + +public inline fun < + reified VM : BallastViewModel, + reified Inputs : Any, + reified Events : Any, + reified State : Any + > ApplicationCall.ballastViewModel( + key: AttributeKey +): VM { + return application.attributes[key] +} diff --git a/ballast-logging/README.md b/ballast-logging/README.md new file mode 100644 index 00000000..9d3e4e66 --- /dev/null +++ b/ballast-logging/README.md @@ -0,0 +1,122 @@ +# Ballast Logging + +## Overview + +This module provides platform-specific implementations of Ballast Loggers, as well as n Interceptor to automatically +log the activity of the ViewModel. + +## Supported Platforms + +| Platform | Supported | +|----------|-----------| +| JVM | ✅ | +| Android | ✅ | +| iOS | ✅ | +| JS | ✅ | +| WASM JS | ✅ | + +## See Also + +- [Ballast Core](./../ballast-core) + +## Usage + +Loggers are attached to a ViewModel with in the `BallastViewModelConfiguration`. This logger may be used by any +Interceptor, as well as your own InputHandlers, EventHandlers, or SideJobs. The same instance of the Logger is shared +by all components in the ViewModel. + +The `BallastViewModelConfiguration.Builder` is often defined with a common component shared by all ViewModels in your +application, where all cross-cutting functionality is attached. It is then converted to a +`BallastViewModelConfiguration.TypedBuilder` using the `builder.withViewModel()` function. It's recommended to define +your Logger in the common configuration. As such, the `BallastViewModelConfiguration.Builder.logger` property is a +factory function, and will be passed the name of the ViewModel set in `builder.withViewModel()` to be used as the tag. +Function references on the Logger class are a clean way to wire this up. + +```kotlin +class ExampleViewModel(coroutineScope: CoroutineScope) : BasicViewModel< + ExampleContract.Inputs, + ExampleContract.Events, + ExampleContract.State + >( + coroutineScope = coroutineScope, + config = BallastViewModelConfiguration.Builder() + .apply { + logger = ::PrintlnLogger + } + .withViewModel( + initialState = ExampleContract.State(), + inputHandler = ExampleInputHandler(), + name = "Example" + ) + .build(), + eventHandler = eventHandler { }, +) +``` + +### Platform Loggers + +| Logger | Platform | Notes | +|---------------------|-----------------|--------------------------------------------------------------------------| +| NoOpLogger | Any | Disables all logging for the ViewModel | +| PrintlnLogger | Any | Formats messages and prints them to stdout via `println` | +| AndroidLogger | Android | Prints messages directly to Android LogCat without additional formatting | +| NSLogLogger | iOS | Formats messages and prints them to NSLog (legacy logger) | +| OSLogLogger | iOS | Formats messages and prints them to OSLog (modern logger) | +| JsConsoleLogger | JS Browser | Formats messages and prints them to `console.log` | +| WasmJsConsoleLogger | WASM JS Browser | Formats messages and prints them to `console.log` | + +### LoggingInterceptor + +The `LoggingInterceptor` can be added to automatically log the internal behavior of your ViewModels. This should +typically only be added in debug builds, as it may leak sensitive information in production builds. The +LoggingInterceptor writes its logs to the logger added in `BallastViewModelConfiguration`. The information logged by +this interceptor may be quite verbose, but it can be really handy for inspecting the data in your ViewModel and +determining what happened in what order. + +```kotlin +class ExampleViewModel(coroutineScope: CoroutineScope) : BasicViewModel< + ExampleContract.Inputs, + ExampleContract.Events, + ExampleContract.State + >( + coroutineScope = coroutineScope, + config = BallastViewModelConfiguration.Builder() + .apply { + if (DEBUG) { // some build-time constant + logger = ::PrintlnLogger + interceptors += LoggingInterceptor() + } + } + .withViewModel( + initialState = ExampleContract.State(), + inputHandler = ExampleInputHandler(), + name = "Example" + ) + .build(), + eventHandler = eventHandler { }, +) +``` + +## Installation + +```kotlin +repositories { + mavenCentral() +} + +// for plain JVM or Android projects +dependencies { + implementation("io.github.copper-leaf:ballast-logging:{{ballastVersion}}") +} + +// for multiplatform projects +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + implementation("io.github.copper-leaf:ballast-logging:{{ballastVersion}}") + } + } + } +} +``` diff --git a/ballast-logging/build.gradle.kts b/ballast-logging/build.gradle.kts index cc6b9639..c956a2cb 100644 --- a/ballast-logging/build.gradle.kts +++ b/ballast-logging/build.gradle.kts @@ -3,7 +3,7 @@ plugins { id("copper-leaf-android-library") id("copper-leaf-targets") id("copper-leaf-tests") -// id("copper-leaf-lint") + id("copper-leaf-lint") id("copper-leaf-publish") } diff --git a/ballast-logging/src/commonMain/kotlin/com/copperleaf/ballast/core/LoggingInterceptor.kt b/ballast-logging/src/commonMain/kotlin/com/copperleaf/ballast/core/LoggingInterceptor.kt index 404d4f8a..f2dd7f86 100644 --- a/ballast-logging/src/commonMain/kotlin/com/copperleaf/ballast/core/LoggingInterceptor.kt +++ b/ballast-logging/src/commonMain/kotlin/com/copperleaf/ballast/core/LoggingInterceptor.kt @@ -115,9 +115,9 @@ public class LoggingInterceptor( override fun toString(): String { val enabled = buildList { - if(logDebug) { this += "debug" } - if(logInfo) { this += "info" } - if(logError) { this += "error" } + if (logDebug) this += "debug" + if (logInfo) this += "info" + if (logError) this += "error" } return "LoggingInterceptor(enabled=$enabled)" } diff --git a/ballast-logging/src/commonMain/kotlin/com/copperleaf/ballast/core/loggingUtils.kt b/ballast-logging/src/commonMain/kotlin/com/copperleaf/ballast/core/loggingUtils.kt index 3e303587..87da0552 100644 --- a/ballast-logging/src/commonMain/kotlin/com/copperleaf/ballast/core/loggingUtils.kt +++ b/ballast-logging/src/commonMain/kotlin/com/copperleaf/ballast/core/loggingUtils.kt @@ -1,7 +1,7 @@ package com.copperleaf.ballast.core public fun formatMessage(tag: String?, message: String): String { - return if(tag != null) { + return if (tag != null) { "[$tag] $message" } else { message diff --git a/ballast-logging/src/wasmJsMain/kotlin/com.copperleaf.ballast.core/WasmJsConsoleLogger.kt b/ballast-logging/src/wasmJsMain/kotlin/com.copperleaf.ballast.core/WasmJsConsoleLogger.kt index 9e520db3..4a919836 100644 --- a/ballast-logging/src/wasmJsMain/kotlin/com.copperleaf.ballast.core/WasmJsConsoleLogger.kt +++ b/ballast-logging/src/wasmJsMain/kotlin/com.copperleaf.ballast.core/WasmJsConsoleLogger.kt @@ -3,6 +3,7 @@ /** * Taken from https://touchlab.co/wasm-in-kermit */ + package com.copperleaf.ballast.core import com.copperleaf.ballast.BallastLogger diff --git a/docs/src/doc/docs/pages/wiki/modules/ballast-navigation.md b/ballast-navigation/README.md similarity index 57% rename from docs/src/doc/docs/pages/wiki/modules/ballast-navigation.md rename to ballast-navigation/README.md index cc186ebc..d6778484 100644 --- a/docs/src/doc/docs/pages/wiki/modules/ballast-navigation.md +++ b/ballast-navigation/README.md @@ -1,5 +1,4 @@ ---- ---- +# Ballast Navigation ## Overview @@ -17,6 +16,18 @@ it is built with Ballast at the core, you can extend your routing functionality - Synchronizing router state across components or devices with [Ballast Sync][3] - Tracking page views with [Ballast Analytics][4] +## Supported Platforms + +| Platform | Supported | +|----------|-----------| +| JVM | ✅ | +| Android | ✅ | +| iOS | ✅ | +| JS | ✅ | +| WASM JS | ✅ | + +## See Also + ## Usage Ballast Navigation can be used as your application's main router, or as a sub-router for tabbed views or similar UI @@ -481,11 +492,340 @@ restore the Backstack, these `RouteAnnotations` should generally be saved and re ## FAQs -//snippet 'navigationFaqs' +### Why make yet another routing library? + +The first reason, and why most people create new libraries, is that I was not happy with any of the existing solutions +out there. It's my opinion that Android's official navigation patterns (both the old, manual navigation, and the newer +Androidx Navigation library) encourage patterns in navigation that tend to lead to bad application architecture. And +unfortunately, most of the recent routing libraries I've tried seem to be copying that similar navigation patterns, +bringing Android's anti-patterns with them into the KMPP and Compose world. Compose and MVI as an ecosystem work because +they're not trying to copy old UIs patterns, so why are we still thinking that the old style of Navigation works? + +Most notably, Android's navigation system encourages a pattern of navigating to one screen, and then to another, loading +specific data on those screens as you go. Whether this is done with navigation from Activity-to-Activity, +Fragment-to-Fragment, or by defining a specific navigation order through a declarative NavGraph explicitly linking +destinations to one another, this style of navigation usually leads to data being loaded on a specific screen vs being +loaded when requested, regardless of the screen requesting it. This becomes problematic when trying to implement +deep-links, when one needs to add explicit handling of the deep-link case to load the data that would have been loaded +on an earlier screen with the "happy path" navigation. Instead, I believe the web's pattern of every screen being +defined by a URL and the user may jump directly to any given screen encourages a better pattern where you cannot assume +any given sequence of screens was visited, and thus you must push the loading of data out of the UI and into the +Repository layer, where it belongs. + +The second reason that I created this library is that I realized routing is really just an exercise in state management, +and Ballast is already very good at that. Routing libraries typically build up a subsystem for managing updates to the +state, and then build their routing logic within that, but because they're fundamentally _routing_ libraries and not +_state management_ libraries, the actual state management aspects of them are lacking. + +But Ballast is already proven to be a stable, robust, and predicable state management library, and it was relatively +simple to add navigation on top of what already exists here. And in the process, Ballast Navigation gains all the +features of the other Ballast extension libraries for free (like logging, debugging, or undo/redo), both current and +future, which would otherwise either be hardcoded in hacky ways into those other libraries, or else completely absent. + +### Is this library type-safe? + +It depends on what you mean by type-safe. If, by that, you mean that routing is done with data classes that are just +passed around, then no, this library is not type-safe. It works by parsing a URL to extract data from the path and query +parameters, and those values are ultimately passed around as Strings, not as strongly-typed objects. + +But if by type-safe you mean that when loading a route, you can easily ensure that the parameters exist and are of a +certain type, then yes, this library does support that. Route matching is strict and you manually define which +parameters must be present, and it offers a set of delegate functions to make it easy to extract those parameters in a +type-safe manner, preventing you to navigating to a route if the value is of an incorrect type. This style of routing is +not checked at compile time, unlike passing around a data class, but it actually has some other advantages that the +data-class argument-passing lacks: + +- By forcing you to represent the data passed between routes as a URL, it encourages the best-practice of only passing + the minimal amount of data needed for the new route to load the full objects it needs. Quoting from the documentation + of [Androidx Navigation][9], _"In general, you should strongly prefer passing only the minimal amount of data between + destinations. For example, you should pass a key to retrieve an object rather than passing the object itself...If you + need to pass large amounts of data, consider using a ViewModel as described in 'Share data between fragments'."_ +- You get deep-linking for free, since effectively _every_ navigation request is a deep-link. If you have to pass + configuration/argument objects, you would have to manually parse a deep-link URL to that object before attempting to + navigate with it, which can cause problems if your URL-parsing logic differs from the rest of your application's + navigation logic. +- KSP and Code Generation, or type-safe wrapper functions, can be easily added on top of this library, while it's more + difficult to take a library built with strong type-safety/code generation in mind and use it in any other way. This + eases the burden of evaluation or incremental adoption. For example, generating type-safe Directions functions and + arguments delegates could be done fairly easily, and the core routing APIs were intentionally designed to allow that + possibility, though it is not on the current roadmap for this library. This would be a very welcome addition from the + community, if someone wanted to create this as a KSP plugin! + +### Does this library integrate with Compose? + +Yes! Everything you need to integrate Ballast Navigation into Compose is provided in the core artifact, without any need +for a special Compose integration library. Ballast Navigation ultimately just manages a backstack of URLs and emits it +to the UI as a `StateFlow`, which can be easily collected from Compose. Anything else that you would typically want from +a "Compose integration" is almost certainly too specific to your use-case to be included within the core Ballast +Navigation library, but is easy enough for you to implement yourself. + +But when people typically ask this question, what they really are asking is, "does it live entirely within Compose code, +and give me automatic transition animations and stuff like that". And the answer to this question is no, Ballast +Navigation is intentionally kept outside the UI. A community-designed library to connect Ballast Navigation to Compose +for things like Animations would be a very welcome addition, however! + +For now, you can achieve basic transition animations with existing Compose UI APIs like `AnimatedContent`. Or if someone +wanted to help bring [rjrjr/compose-backstack][8] up-to-date with the latest Compose version and make it work with +Desktop, that would be the perfect companion library to Ballast Navigation! + +### How do I sync destinations with the browser address bar? + +When using Ballast Navigation in the browser, you may wish to show the current destination URL in the browser's address +bar to help the user understand the structure of your application, as well as allowing them to edit the URL to jump to +a specific screen, or save it as a bookmark. + +This is included as built-in functionality, for synchronizing the router state with the browser's address bar in both +directions: applying router state to the address bar, and passing changes made by the user back into the router. It will +also take care of reading the current URL when the page first loads, and navigating directly to that route. + +All that's needed to support this functionality is to add an Interceptor to the Router during creation. Both hash-based +routing and the [History API][10] are supported. + +#### Browser Hash + +Hash-based routing is the "older" mechanism for routing in a Single Page Application (SPA), though it should not be +considered obselete. In particular, one would have to set up server-side redirects to make the History API work, which +may not be feasible, in which case Hash-based routing is the only option left. + +Hash-based routing can be added with the `BrowserHashNavigationInterceptor`, or with the `withBrowserHashRouter` helper +function. + +```kotlin +class RouterViewModel( + viewModelCoroutineScope: CoroutineScope +) : BasicRouter( + config = BallastViewModelConfiguration.Builder() + .withBrowserHashRouter(RoutingTable.fromEnum(AppScreens.values()), AppScreens.Home) + .build(), + eventHandler = eventHandler { }, + coroutineScope = viewModelCoroutineScope, +) +``` + +#### Browser History + +Hash-based routing is done with the `#` portion of the URL, and isn't as user-friendly to read and share as with just +a normal URL path. The [Browser History API][10] allows websites to edit the entire URL shown in the address bar +and navigate forward and backward through the screens of your SPA with the browser's native buttons, so users wouldn't +even know that you'ure doing front-end routing. + +The caveat is that using the history API requires your hosting server to redirect all URLs to the SPA's main page. There +are plenty of tutorials online for configuring your server to do this, so I will not cover these details here. + +Routing with the History API can be added with the `BrowserHistoryNavigationInterceptor`, or with the +`withBrowserHistoryRouter` helper function. Unlike the Hash interceptor, the History interceptor needs to know which +portion of the URL path is just the page itself, and which is used for routing within the application, so you must pass +the base path for this page into the interceptor. + +```kotlin +class RouterViewModel( + viewModelCoroutineScope: CoroutineScope +) : BasicRouter( + config = BallastViewModelConfiguration.Builder() + .withBrowserHistoryRouter(RoutingTable.fromEnum(AppScreens.values()), basePath = "/app", initialRoute = AppScreens.Home) + .build(), + eventHandler = eventHandler { }, + coroutineScope = viewModelCoroutineScope, +) +``` + +I would recommend using the `BrowserHashNavigationInterceptor` when developing locally and switch it out for +`BrowserHistoryNavigationInterceptor` when deploying to production, so you don't have to mess with your Webpack dev +server configuration. There are several ways to determine if your running in production, such as checking the value of +`window.location.host`, setting a property as a hidden element in the page's HTML, or using something like +[Gradle BuildConfig plugin][11] to inject a value from the build pipeline into the Kotlin code. But if you do want to +use the `BrowserHistoryNavigationInterceptor` in development, [routing-compose][12] has instructions for getting your +environment set up. -### More FAQs +### How does this library handle transition animations? -See more FAQs [here][13] +It doesn't. Ballast Navigation just manages the backstack, but you can apply transition animations yourself when +handling route changes. Ballast Navigation intentionally keeps itself separate from the UI to allow maximum flexibility +and avoid bloat in its API. + +### How do I do nested sub-graphs? + +"Nested sub-graphs" in terms of pure navigation really aren't necessary, and is something of an anti-pattern that has +become popularized by the Androidx Navigation library. There's not really a good reason to group a bunch of destinations +and set up a hierarchy of routers/navControllers, which just adds unnecessary complexity without much benefit. + +One useful feature of Android's Nested NavGraphs, however, is the ability to scope a ViewModel to the sub-graph rather +than to an individual screen. This allows you to carry information between multiple screens in a "flow" without needing +to serialize it all in the Repository layer and manage when it should be reused/cleared. If the ViewModel data is +ephemeral and the ViewModel is discarded once the sub-graph is exited, then scoped ViewModels automatically clean up +that data after use. + +Right now, this feature is not supported in Ballast, and I'm still exploring possible options for handling this kind of +"sub-graph" scoping. You can use `RouteAnnotations` to define the bounds of a "sub-graph" and handle the purely +navigational use-case, but it's left up to you to determine how to manage the scope of ViewModels within those graphs. +Scoping ViewModels to the backstack (or anything else, really) is probably more appropriately handled by your DI +library's scope functionality, anyway, rather than Ballast itself. + +### How do I save/restore the backstack? + +Automatic state restoration is intentionally left out of this library, because I did not want to tie it directly to any +serialization mechanism or library. But this is easy enough to achieve on your own, all you need to do is persist the +original destination URLs and then restore them within an Input. This example shows how it might be done (if you are +using `RouteAnnotations`, you'll want to (de)serialize those as well). + +```kotlin +fun saveBackstack(router: Router) { + val backstackUrls: List = router.observeStates().value.map { it.originalDestinationUrl } + saveUrlsToSavedState(backstackUrls) +} + +fun restoreBackstack(router: Router) { + val backstackUrls: List = getUrlsFromSavedState() + router.trySend(RouterContract.Inputs.RestoreBackstack(backstackUrls)) +} +``` + +Automatically saving/restoring the state can be done with the help of the [Ballast Saved State module][13], by creating an +adapter like this: + +```kotlin +/** + * Automatically save and restore the state of the Router with any route changes. Do not pass an initial route to the + * BallastViewModelConfiguration.Builder.withRouter()` when using this adapter, as it will handle setting the initial + * route instead, and may conflict with the initial route set through that function. + * + * The actual serialization and persistence of the backstack is delegated through [prefs]. + * + * If you are also using the Ballast Undo/Redo module for forward/backward navigation, set [preserveDiscreteStates] to + * true so the backstack is restored through individual [RouterContract.Inputs.GoToDestination] Inputs to capture each + * intermediate state. If not, it can be set to false so that a single [RouterContract.Inputs.RestoreBackstack] is used + * instead. + */ +public class RouterSavedStateAdapter( + private val routingTable: RoutingTable, + private val initialRoute: T?, + private val prefs: Prefs, + private val preserveDiscreteStates: Boolean = false, +) : SavedStateAdapter< + RouterContract.Inputs, + RouterContract.Events, + RouterContract.State> { + + public interface Prefs { + var backstackUrls: List + } + + override suspend fun SaveStateScope< + RouterContract.Inputs, + RouterContract.Events, + RouterContract.State>.save() { + saveAll { backstack -> + prefs.backstackUrls = backstack.map { it.originalDestinationUrl } + } + } + + override suspend fun RestoreStateScope< + RouterContract.Inputs, + RouterContract.Events, + RouterContract.State + >.restore(): RouterContract.State { + val savedBackstack = prefs.backstackUrls + if(savedBackstack.isEmpty()) { + initialRoute?.let { initialRoute -> + check(initialRoute.isStatic()) { + "For a Route to be used as a Start Destination, it must be fully static. All path segments and " + + "declared query parameters must either be static or optional." + } + postInput( + RouterContract.Inputs.GoToDestination(initialRoute.directions().build()) + ) + } + } else if(preserveDiscreteStates) { + savedBackstack.forEach { destinationUrl -> + postInput( + RouterContract.Inputs.GoToDestination(destinationUrl) + ) + } + } else { + postInput( + RouterContract.Inputs.RestoreBackstack(savedBackstack) + ) + } + + return RouterContract.State(routingTable = routingTable) + } +} +``` + +### Why does this library force Ballast MVI state management? + +The technical implementation of this library actually does allow one to use a different mechanism for managing state. +All Navigation classes and features are completely separate from any core Ballast APIs, and it's entirely possible to +lift the Navigation code and place it into another State Management library. + +But if that is true, why is it coupled to the Ballast library? + +The main reason is that Routing needs some kind of state management solution in order to work properly. Things could end +up very poorly if your app attempts to make multiple navigation attempts quickly and the Router state gets corrupted, +and you users will be very unhappy with their experience using that app. The Router state needs to be protected from +unwanted changes and ensure things are being processed safely, so the options for building the routing library then +become: + +1) Keep the Navigation library completely separate from any State Management library +2) Couple it to a specific State Management library +3) Provide adapters to all the popular State Management libraries, so developers can choose which one they want to use + +If I went with option 1), then the reality is that I would need to build some minimal state-management system specific +to that library in order to allow its usage without pulling in a larger State Management library. It cannot simply exist +without state management, so it would need to be shipped with a minimal (and probably poorly-implemented solution) +instead to avoid any external dependencies. This would then mean it is lacking in features one might expect (like +logging, or browser-like forward/back buttons), or else have those features hardcoded into that minimal system to +support those core use-cases that are beyond the base Navigation system. This minimal solution is simply not going to be +a robust, extensible platform for state management that one would find in a dedicated State Management library like +Ballast. And having built Ballast already, if I were to build a State Management solution just to ship with the +navigation library, then I would basically just create Ballast again for it. Ballast is a pretty lightweight library, so +it just makes more sense to couple this navigation library to Ballast. + +And as for the question of why not provide adapters to other libraries, the answer is that this is a maintenance burden +that I do not want to support. I do not use any other State Management libraries, myself, so I am not the best person to +maintain an adapter using Ballast Navigation with those other libraries. I also intentionally crafted this library to +work well with the other Ballast modules, providing that additional functionality that I do not want to hardcode into +the navigation system itself. Using Ballast Navigation with those other solutions loses those features, and would +require a lot of extra documentation and testing to ensure everything's working properly with each library. It also +makes it more difficult for users to get started, as they could easily be overwhelmed at the thought of choosing a State +Management library that they may never interact with outside of Navigation. If I keep this Navigation library coupled to +Ballast, it's easy enough for users to get started without needing to know any of the intricacies of State Management or +specific libraries, they can just use the snippets in the documentation and focus on the Navigation library itself, +trusting that it is tested and known to work as they expect. + +If you would like to use Ballast Navigation without the core Ballast State Management library, you should be able to +exclude the `ballast-core` dependency from Gradle and wire it up to your own state management solution, as long as you +do not reference anything from the `com.copperleaf.ballast.navigation.vm` package. While this is not an +officially-supported way to use this library and I do not intend to keep any documentation for this use-case, I do +intend to keep the Navigation APIs free from any core Ballast APIs, so please let me know if something does not work if +you try this. At a high-level, [this snippet](https://kotlinlang.slack.com/archives/C03GTEJ9Y3E/p1669248216885769?thread_ts=1669053916.840399&cid=C03GTEJ9Y3E) +posted to the Ballast Slack channel might help you get started. + +### How do I do "up" navigation? + +Most UI platforms have a distinction between "backward" and "upward" navigation. In a nutshell, "backward" navigation +refers to going back to where you just came from, popping an entry off the backstack. "Upward" navigation means +navigating to a specific Route that is considered the "parent" of the current destination. In terms of URLs, if you were +previously at `/users/me` and navigated to your last post `/post/1234` backward navigation (Android's hardware back +button/gesture) brings you to `/users/me`, while upward navigation (the arrow in the toolbar) brings you to `/posts`. +Put in another way, a "backward" navigation is dynamic and determined by the history of screens you've already visited. +Upward navigation is static, navigating to a predefined destination. In most apps, the flow of navigation through the +application should match the route hierarchy, so a "back" and "up" action should do the same thing, but deep-links could +cause them to behave differently. + +Ballast Navigation does not explicitly handle the use-case of "upward" navigation. Because the upward navigation is +statically determined, one would have to explicitly describe the hierarchical structure of your routes if you wanted to +have a single `RouterContract.Inputs.NavigateUp()` action, which not only becomes cumbersome, but may not be entirely +possible within the Kotlin type system (for example, with recursive routes or cycles in the graph). It also becomes a +huge maintenance burden with the introduction of graph algorithms into the Navigation library, and something that is +easy to mess up or get wrong for the end user. + +But why do we need an `RouterContract.Inputs.NavigateUp()` action at all? The main idea is to navigate from one screen +to its parent screen, and with a statically-defined graph, that parent route would also be statically determined. So +rather than including a `NavigateUp` action and massively complicating this library, it's recommended to instead just +set the action on the toolbar back button to `RouterContract.Inputs.ReplaceTopDestination()` with the intended parent +route. This actually makes it easier to understand your application's navigational flows, while keeping the core Routing +mechanism simple and easy to work with. ## Full Code Snippet @@ -620,7 +960,7 @@ repositories { // for plain JVM or Android projects dependencies { - implementation("io.github.copper-leaf:ballast-navigation:{{gradle.version}}") + implementation("io.github.copper-leaf:ballast-navigation:{{ballastVersion}}") } // for multiplatform projects @@ -628,25 +968,27 @@ kotlin { sourceSets { val commonMain by getting { dependencies { - implementation("io.github.copper-leaf:ballast-navigation:{{gradle.version}}") + implementation("io.github.copper-leaf:ballast-navigation:{{ballastVersion}}") } } } } ``` -[1]: ballast-debugger.md -[2]: ballast-undo.md -[3]: ballast-sync.md -[4]: ballast-analytics.md -[5]: ../usage/index.md -[6]: ballast-navigation.md + +[1]: ./../ballast-debugger-client +[2]: ./../ballast-undo +[3]: ./../ballast-sync +[4]: ./../ballast-analytics +[5]: ./ +[6]: ./../ballast-navigation [7]: https://ktor.io/docs/routing-in-ktor.html#match_url [8]: https://github.com/rjrjr/compose-backstack [9]: https://developer.android.com/guide/navigation/navigation-pass-data [10]: https://developer.mozilla.org/en-US/docs/Web/API/History_API [11]: https://github.com/gmazzo/gradle-buildconfig-plugin [12]: https://github.com/hfhbd/routing-compose#development-usage +[13]: ./../ballast-saved-state [14]: https://github.com/copper-leaf/ballast/tree/main/examples/web [15]: https://github.com/copper-leaf/ballast/tree/main/examples/desktop [16]: https://github.com/copper-leaf/ballast/tree/main/examples/android diff --git a/ballast-navigation/api/android/ballast-navigation.api b/ballast-navigation/api/android/ballast-navigation.api index 1fc8b7e1..a64b87a3 100644 --- a/ballast-navigation/api/android/ballast-navigation.api +++ b/ballast-navigation/api/android/ballast-navigation.api @@ -612,6 +612,7 @@ public final class com/copperleaf/ballast/navigation/routing/RoutingUtilsKt { public static final fun optionalStringQuery (Lcom/copperleaf/ballast/navigation/routing/Destination$ParametersProvider;Ljava/lang/String;)Lkotlin/properties/PropertyDelegateProvider; public static synthetic fun optionalStringQuery$default (Lcom/copperleaf/ballast/navigation/routing/Destination$ParametersProvider;Ljava/lang/String;ILjava/lang/Object;)Lkotlin/properties/PropertyDelegateProvider; public static final fun path (Lcom/copperleaf/ballast/navigation/routing/Destination$Directions;[Ljava/lang/String;)Lcom/copperleaf/ballast/navigation/routing/Destination$Directions; + public static final fun pathFormat (Lcom/copperleaf/ballast/navigation/routing/Route;)Ljava/lang/String; public static final fun pathParameter (Lcom/copperleaf/ballast/navigation/routing/Destination$Directions;Ljava/lang/String;Ljava/lang/Iterable;)Lcom/copperleaf/ballast/navigation/routing/Destination$Directions; public static final fun pathParameter (Lcom/copperleaf/ballast/navigation/routing/Destination$Directions;Ljava/lang/String;[Ljava/lang/String;)Lcom/copperleaf/ballast/navigation/routing/Destination$Directions; public static final fun pathParameters (Lcom/copperleaf/ballast/navigation/routing/Destination$Directions;Ljava/util/Map;)Lcom/copperleaf/ballast/navigation/routing/Destination$Directions; diff --git a/ballast-navigation/api/jvm/ballast-navigation.api b/ballast-navigation/api/jvm/ballast-navigation.api index 09e3eeb2..a6b2cb8c 100644 --- a/ballast-navigation/api/jvm/ballast-navigation.api +++ b/ballast-navigation/api/jvm/ballast-navigation.api @@ -599,6 +599,7 @@ public final class com/copperleaf/ballast/navigation/routing/RoutingUtilsKt { public static final fun optionalStringQuery (Lcom/copperleaf/ballast/navigation/routing/Destination$ParametersProvider;Ljava/lang/String;)Lkotlin/properties/PropertyDelegateProvider; public static synthetic fun optionalStringQuery$default (Lcom/copperleaf/ballast/navigation/routing/Destination$ParametersProvider;Ljava/lang/String;ILjava/lang/Object;)Lkotlin/properties/PropertyDelegateProvider; public static final fun path (Lcom/copperleaf/ballast/navigation/routing/Destination$Directions;[Ljava/lang/String;)Lcom/copperleaf/ballast/navigation/routing/Destination$Directions; + public static final fun pathFormat (Lcom/copperleaf/ballast/navigation/routing/Route;)Ljava/lang/String; public static final fun pathParameter (Lcom/copperleaf/ballast/navigation/routing/Destination$Directions;Ljava/lang/String;Ljava/lang/Iterable;)Lcom/copperleaf/ballast/navigation/routing/Destination$Directions; public static final fun pathParameter (Lcom/copperleaf/ballast/navigation/routing/Destination$Directions;Ljava/lang/String;[Ljava/lang/String;)Lcom/copperleaf/ballast/navigation/routing/Destination$Directions; public static final fun pathParameters (Lcom/copperleaf/ballast/navigation/routing/Destination$Directions;Ljava/util/Map;)Lcom/copperleaf/ballast/navigation/routing/Destination$Directions; diff --git a/ballast-navigation/build.gradle.kts b/ballast-navigation/build.gradle.kts index 93c66247..506ab756 100644 --- a/ballast-navigation/build.gradle.kts +++ b/ballast-navigation/build.gradle.kts @@ -3,7 +3,7 @@ plugins { id("copper-leaf-android-library") id("copper-leaf-targets") id("copper-leaf-tests") -// id("copper-leaf-lint") + id("copper-leaf-lint") id("copper-leaf-publish") } diff --git a/ballast-navigation/src/androidMain/kotlin/com/copperleaf/ballast/navigation/bundleHelpers.kt b/ballast-navigation/src/androidMain/kotlin/com/copperleaf/ballast/navigation/bundleHelpers.kt index 8f2f38b4..2cf2a0d5 100644 --- a/ballast-navigation/src/androidMain/kotlin/com/copperleaf/ballast/navigation/bundleHelpers.kt +++ b/ballast-navigation/src/androidMain/kotlin/com/copperleaf/ballast/navigation/bundleHelpers.kt @@ -27,7 +27,7 @@ private class BundleDestinationParameters( private fun Map>.toParametersBundle(): Bundle { return Bundle().apply { - for((key, values) in entries) { + for ((key, values) in entries) { putStringArray(key, values.toTypedArray()) } } @@ -36,7 +36,7 @@ private fun Map>.toParametersBundle(): Bundle { private fun Bundle.fromParametersBundle(): Map> { val bundle = this return buildMap { - for(key in bundle.keySet()) { + for (key in bundle.keySet()) { put(key, bundle.getStringArray(key)?.toList() ?: error(ERROR_MESSAGE)) } } diff --git a/ballast-navigation/src/commonMain/kotlin/com/copperleaf/ballast/navigation/internal/PathParser.kt b/ballast-navigation/src/commonMain/kotlin/com/copperleaf/ballast/navigation/internal/PathParser.kt index 30f99865..1e23b96f 100644 --- a/ballast-navigation/src/commonMain/kotlin/com/copperleaf/ballast/navigation/internal/PathParser.kt +++ b/ballast-navigation/src/commonMain/kotlin/com/copperleaf/ballast/navigation/internal/PathParser.kt @@ -130,11 +130,13 @@ internal object PathParser { val (nextChar, remaining) = input.nextChar() - if (nextChar != '/') throw ParserException( + if (nextChar != '/') { + throw ParserException( "Path must start with a leading slash", this@LeadingSlashParser, input ) + } CharNode(nextChar, NodeContext(input, remaining)) to remaining } diff --git a/ballast-navigation/src/commonMain/kotlin/com/copperleaf/ballast/navigation/internal/QueryStringParser.kt b/ballast-navigation/src/commonMain/kotlin/com/copperleaf/ballast/navigation/internal/QueryStringParser.kt index 57fedd68..4ab61ade 100644 --- a/ballast-navigation/src/commonMain/kotlin/com/copperleaf/ballast/navigation/internal/QueryStringParser.kt +++ b/ballast-navigation/src/commonMain/kotlin/com/copperleaf/ballast/navigation/internal/QueryStringParser.kt @@ -117,5 +117,4 @@ internal object QueryStringParser { internal fun parseQueryString(queryString: String): List { return queryStringParser.parse(ParserContext.fromString(queryString)).first.value } - } diff --git a/ballast-navigation/src/commonMain/kotlin/com/copperleaf/ballast/navigation/internal/RouteParser.kt b/ballast-navigation/src/commonMain/kotlin/com/copperleaf/ballast/navigation/internal/RouteParser.kt index cc9ceebc..952785f9 100644 --- a/ballast-navigation/src/commonMain/kotlin/com/copperleaf/ballast/navigation/internal/RouteParser.kt +++ b/ballast-navigation/src/commonMain/kotlin/com/copperleaf/ballast/navigation/internal/RouteParser.kt @@ -56,7 +56,6 @@ internal object RouteParser { // --------------------------------------------------------------------------------------------------------------------- internal fun computeWeight(pathSegments: List, queryParameters: List): Double { - // we require 2 more query parameters than the number of path segments for query parameters to be considered more // specific than the path val pathPowerModifier = queryParameters.size + 1 diff --git a/ballast-navigation/src/commonMain/kotlin/com/copperleaf/ballast/navigation/internal/UriEncoder.kt b/ballast-navigation/src/commonMain/kotlin/com/copperleaf/ballast/navigation/internal/UriEncoder.kt index 3d1c2edb..e3ed7f59 100644 --- a/ballast-navigation/src/commonMain/kotlin/com/copperleaf/ballast/navigation/internal/UriEncoder.kt +++ b/ballast-navigation/src/commonMain/kotlin/com/copperleaf/ballast/navigation/internal/UriEncoder.kt @@ -15,10 +15,10 @@ internal object UriEncoder { queryComponent: String, spaceToPlus: Boolean = false, ): String { - return if(spaceToPlus) { + return if (spaceToPlus) { UriCodec.encode(queryComponent) .replace("%20", "+") - } else { + } else { UriCodec.encode(queryComponent) }.replace(".", "%2E") } @@ -27,10 +27,10 @@ internal object UriEncoder { queryComponent: String, spaceToPlus: Boolean = false, ): String { - return if(spaceToPlus) { + return if (spaceToPlus) { UriCodec.encode(queryComponent, allow = "?/=&") .replace("%20", "+") - } else { + } else { UriCodec.encode(queryComponent, allow = "?/=&") }.replace("%2E", ".") } diff --git a/ballast-navigation/src/commonMain/kotlin/com/copperleaf/ballast/navigation/routing/Destination.kt b/ballast-navigation/src/commonMain/kotlin/com/copperleaf/ballast/navigation/routing/Destination.kt index 86dceecc..14457f8c 100644 --- a/ballast-navigation/src/commonMain/kotlin/com/copperleaf/ballast/navigation/routing/Destination.kt +++ b/ballast-navigation/src/commonMain/kotlin/com/copperleaf/ballast/navigation/routing/Destination.kt @@ -32,7 +32,7 @@ public sealed interface Destination { public val annotations: Set = emptySet(), ) : Destination, Parameters, ParametersProvider { override fun toString(): String { - return "'${originalDestinationUrl}'" + return "'$originalDestinationUrl'" } override val parameters: Parameters get() = this @@ -46,7 +46,7 @@ public sealed interface Destination { override val originalDestinationUrl: String, ) : Destination { override fun toString(): String { - return "'${originalDestinationUrl}' (not found)" + return "'$originalDestinationUrl' (not found)" } } diff --git a/ballast-navigation/src/commonMain/kotlin/com/copperleaf/ballast/navigation/routing/Route.kt b/ballast-navigation/src/commonMain/kotlin/com/copperleaf/ballast/navigation/routing/Route.kt index 5e0ad981..f8e0930b 100644 --- a/ballast-navigation/src/commonMain/kotlin/com/copperleaf/ballast/navigation/routing/Route.kt +++ b/ballast-navigation/src/commonMain/kotlin/com/copperleaf/ballast/navigation/routing/Route.kt @@ -18,5 +18,4 @@ public interface Route { * directly to the router when navigating to a destination. */ public val annotations: Set - } diff --git a/ballast-navigation/src/commonMain/kotlin/com/copperleaf/ballast/navigation/routing/RouterContract.kt b/ballast-navigation/src/commonMain/kotlin/com/copperleaf/ballast/navigation/routing/RouterContract.kt index 27ae61d1..0ff17185 100644 --- a/ballast-navigation/src/commonMain/kotlin/com/copperleaf/ballast/navigation/routing/RouterContract.kt +++ b/ballast-navigation/src/commonMain/kotlin/com/copperleaf/ballast/navigation/routing/RouterContract.kt @@ -199,7 +199,6 @@ public object RouterContract { } } - /* I'm glad to hear the migration has been going well for you! Ballast was very intentionally created to be easier to use diff --git a/ballast-navigation/src/commonMain/kotlin/com/copperleaf/ballast/navigation/routing/routingUtils.kt b/ballast-navigation/src/commonMain/kotlin/com/copperleaf/ballast/navigation/routing/routingUtils.kt index 6cb7df27..0fbdf20e 100644 --- a/ballast-navigation/src/commonMain/kotlin/com/copperleaf/ballast/navigation/routing/routingUtils.kt +++ b/ballast-navigation/src/commonMain/kotlin/com/copperleaf/ballast/navigation/routing/routingUtils.kt @@ -20,6 +20,21 @@ public fun Route.isStatic(): Boolean { return matcher.path.all { it.isStatic } && matcher.query.all { it.isStatic } } +/** + * Returns the Route's path in its original format, intended to be used for sharing a Route between a Ktor client and + * a Ktor server route. + */ +public fun Route.pathFormat(): String { + return matcher.path.joinToString(separator = "/", prefix = "/") { + when (it) { + is PathSegment.Static -> it.text + is PathSegment.Parameter -> if (it.optional) "{${it.name}?}" else "{${it.name}}" + is PathSegment.Wildcard -> "*" + is PathSegment.Tailcard -> if (it.name != null) "{${it.name}...}" else "{...}" + } + } +} + /** * Start building a destination with directions from [this] [Route]. */ @@ -574,7 +589,6 @@ public fun BackstackNavigator.popUntilRoute( } } - /** * Navigate backward in the backstack, removing all destinations that contain the given [annotation]. */ diff --git a/ballast-navigation/src/commonTest/kotlin/com/copperleaf/ballast/navigation/SimpleRoute.kt b/ballast-navigation/src/commonTest/kotlin/com/copperleaf/ballast/navigation/SimpleRoute.kt index 0cfb4b66..572b8acd 100644 --- a/ballast-navigation/src/commonTest/kotlin/com/copperleaf/ballast/navigation/SimpleRoute.kt +++ b/ballast-navigation/src/commonTest/kotlin/com/copperleaf/ballast/navigation/SimpleRoute.kt @@ -48,7 +48,6 @@ public class MatchAllRoutingTable : RoutingTable { } } - public class Navigate( private val block: BackstackNavigator.() -> Unit ) : RouterContract.Inputs() { diff --git a/ballast-navigation/src/commonTest/kotlin/com/copperleaf/ballast/navigation/TestBackstack.kt b/ballast-navigation/src/commonTest/kotlin/com/copperleaf/ballast/navigation/TestBackstack.kt index 929f68c4..59d0be1a 100644 --- a/ballast-navigation/src/commonTest/kotlin/com/copperleaf/ballast/navigation/TestBackstack.kt +++ b/ballast-navigation/src/commonTest/kotlin/com/copperleaf/ballast/navigation/TestBackstack.kt @@ -41,7 +41,6 @@ class TestBackstack { originalBackstack = listOf("/one", "/two", "/three"), expectedResult = listOf("/one", "/two", "/three"), ) { - } } diff --git a/ballast-navigation/src/commonTest/kotlin/com/copperleaf/ballast/navigation/TestMatching.kt b/ballast-navigation/src/commonTest/kotlin/com/copperleaf/ballast/navigation/TestMatching.kt index a97186de..8efcc14f 100644 --- a/ballast-navigation/src/commonTest/kotlin/com/copperleaf/ballast/navigation/TestMatching.kt +++ b/ballast-navigation/src/commonTest/kotlin/com/copperleaf/ballast/navigation/TestMatching.kt @@ -4,6 +4,7 @@ import com.copperleaf.ballast.navigation.routing.Destination import com.copperleaf.ballast.navigation.routing.UnmatchedDestination import com.copperleaf.ballast.navigation.routing.matchDestination import com.copperleaf.ballast.navigation.routing.matchDestinationOrThrow +import com.copperleaf.ballast.navigation.routing.pathFormat import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals @@ -28,6 +29,7 @@ class TestMatching { this, expectedErrorMessage = "Destination '/one/two' does not match Route '/one': Path mismatch" ) + assertEquals("/one", this.pathFormat()) } SimpleRoute("/*").apply { "/one".shouldMatch(this) @@ -40,6 +42,7 @@ class TestMatching { this, expectedErrorMessage = "Destination '/one/two' does not match Route '/*': Path mismatch" ) + assertEquals("/*", this.pathFormat()) } SimpleRoute("/:one").apply { "/two".shouldMatch( @@ -58,6 +61,7 @@ class TestMatching { this, expectedErrorMessage = "Destination '/one/two' does not match Route '/:one': Path mismatch" ) + assertEquals("/{one}", this.pathFormat()) } SimpleRoute("/{one}").apply { "/two".shouldMatch( @@ -76,6 +80,7 @@ class TestMatching { this, expectedErrorMessage = "Destination '/one/two' does not match Route '/{one}': Path mismatch" ) + assertEquals("/{one}", this.pathFormat()) } SimpleRoute("/{one?}").apply { "/two".shouldMatch( @@ -94,6 +99,7 @@ class TestMatching { this, expectedErrorMessage = "Destination '/one/two' does not match Route '/{one?}': Path mismatch", ) + assertEquals("/{one?}", this.pathFormat()) } SimpleRoute("/{...}").apply { "/two".shouldMatch( @@ -112,6 +118,7 @@ class TestMatching { this, expectedPathParameters = emptyMap(), ) + assertEquals("/{...}", this.pathFormat()) } SimpleRoute("/{one...}").apply { "/two".shouldMatch( @@ -130,6 +137,7 @@ class TestMatching { this, expectedPathParameters = mapOf("one" to listOf("one", "two")), ) + assertEquals("/{one...}", this.pathFormat()) } SimpleRoute("/one/:two/three/{four}/*/{five...}").apply { @@ -149,6 +157,7 @@ class TestMatching { "five" to listOf("six", "seven", "eight"), ), ) + assertEquals("/one/{two}/three/{four}/*/{five...}", this.pathFormat()) } } @@ -183,6 +192,7 @@ class TestMatching { this, expectedErrorMessage = "Destination '/one?one=two&one=three' does not match Route '/one?one=two': Query string mismatch" ) + assertEquals("/one", this.pathFormat()) } SimpleRoute("/one?one={!}").apply { "/one?one=two".shouldMatch( @@ -213,6 +223,7 @@ class TestMatching { this, expectedErrorMessage = "Destination '/one?one=two&one=three' does not match Route '/one?one={!}': Query string mismatch" ) + assertEquals("/one", this.pathFormat()) } SimpleRoute("/one?one={[!]}").apply { "/one?one=two".shouldMatch( @@ -243,6 +254,7 @@ class TestMatching { this, expectedQueryParameters = mapOf("one" to listOf("two", "three")), ) + assertEquals("/one", this.pathFormat()) } SimpleRoute("/one?one={?}").apply { "/one?one=two".shouldMatch( @@ -273,6 +285,7 @@ class TestMatching { this, expectedErrorMessage = "Destination '/one?one=two&one=three' does not match Route '/one?one={?}': Query string mismatch" ) + assertEquals("/one", this.pathFormat()) } SimpleRoute("/one?one={[?]}").apply { "/one?one=two".shouldMatch( @@ -303,6 +316,7 @@ class TestMatching { this, expectedQueryParameters = mapOf("one" to listOf("two", "three")), ) + assertEquals("/one", this.pathFormat()) } SimpleRoute("/one?{...}").apply { "/one?one=two".shouldMatch( @@ -333,6 +347,7 @@ class TestMatching { this, expectedQueryParameters = mapOf("one" to listOf("two", "three")), ) + assertEquals("/one", this.pathFormat()) } } @@ -396,7 +411,6 @@ class TestMatching { assertSame(queryRoute, (destination as Destination.Match).originalRoute) } - companion object { fun String.shouldMatch( route: SimpleRoute, diff --git a/ballast-navigation/src/commonTest/kotlin/com/copperleaf/ballast/navigation/TestUriBuilder.kt b/ballast-navigation/src/commonTest/kotlin/com/copperleaf/ballast/navigation/TestUriBuilder.kt index 81daa985..336d197b 100644 --- a/ballast-navigation/src/commonTest/kotlin/com/copperleaf/ballast/navigation/TestUriBuilder.kt +++ b/ballast-navigation/src/commonTest/kotlin/com/copperleaf/ballast/navigation/TestUriBuilder.kt @@ -25,9 +25,9 @@ class TestUriBuilder { encodedQueryString = "one=1&two=2", ) val basePath = "/" - val updatedUrl = if(basePath != null) { + val updatedUrl = if (basePath != null) { UriBuilder.build( - encodedPath = "${basePath}/${url.encodedPath.trim('/')}", + encodedPath = "$basePath/${url.encodedPath.trim('/')}", encodedQueryString = url.encodedQueryString.trimStart('?'), ) } else { @@ -45,9 +45,9 @@ class TestUriBuilder { encodedQueryString = "one=1&two=2", ) val basePath = "/one/two/three" - val updatedUrl = if(basePath != null) { + val updatedUrl = if (basePath != null) { UriBuilder.build( - encodedPath = "${basePath}/${url.encodedPath.trim('/')}", + encodedPath = "$basePath/${url.encodedPath.trim('/')}", encodedQueryString = url.encodedQueryString.trimStart('?'), ) } else { @@ -65,9 +65,9 @@ class TestUriBuilder { encodedQueryString = "one=1&two=2", ) val basePath = null - val updatedUrl = if(basePath != null) { + val updatedUrl = if (basePath != null) { UriBuilder.build( - encodedPath = "${basePath}/${url.encodedPath.trim('/')}", + encodedPath = "$basePath/${url.encodedPath.trim('/')}", encodedQueryString = url.encodedQueryString.trimStart('?'), ) } else { @@ -86,9 +86,9 @@ class TestUriBuilder { ) val basePath = "/" assertFails { - if(basePath != null) { + if (basePath != null) { UriBuilder.build( - encodedPath = "${basePath}/${url.encodedPath.trim('/')}", + encodedPath = "$basePath/${url.encodedPath.trim('/')}", encodedQueryString = url.encodedQueryString, ) } else { diff --git a/ballast-navigation/src/jsMain/kotlin/com/copperleaf/ballast/navigation/browser/BrowserHistoryNavigationInterceptor.kt b/ballast-navigation/src/jsMain/kotlin/com/copperleaf/ballast/navigation/browser/BrowserHistoryNavigationInterceptor.kt index e262bce9..aed9b35d 100644 --- a/ballast-navigation/src/jsMain/kotlin/com/copperleaf/ballast/navigation/browser/BrowserHistoryNavigationInterceptor.kt +++ b/ballast-navigation/src/jsMain/kotlin/com/copperleaf/ballast/navigation/browser/BrowserHistoryNavigationInterceptor.kt @@ -38,7 +38,7 @@ public class BrowserHistoryNavigationInterceptor( override fun watchForUrlChanges(): Flow { return callbackFlow { window.onpopstate = { event: PopStateEvent -> - if(event.state != null) { + if (event.state != null) { this@callbackFlow.trySend(UriBuilder.parse(event.state.toString())) } Unit @@ -54,9 +54,9 @@ public class BrowserHistoryNavigationInterceptor( try { val previousDestination = getInitialUrl() if (previousDestination != url) { - val updatedUrl = if(basePath != null) { + val updatedUrl = if (basePath != null) { UriBuilder.build( - encodedPath = "${basePath}/${url.encodedPath.trim('/')}", + encodedPath = "$basePath/${url.encodedPath.trim('/')}", encodedQueryString = url.encodedQueryString.trimStart('?'), ) } else { diff --git a/ballast-navigation/src/wasmJsMain/kotlin/com/copperleaf/ballast/navigation/browser/BrowserHistoryNavigationInterceptor.kt b/ballast-navigation/src/wasmJsMain/kotlin/com/copperleaf/ballast/navigation/browser/BrowserHistoryNavigationInterceptor.kt index 775d831f..27c62a00 100644 --- a/ballast-navigation/src/wasmJsMain/kotlin/com/copperleaf/ballast/navigation/browser/BrowserHistoryNavigationInterceptor.kt +++ b/ballast-navigation/src/wasmJsMain/kotlin/com/copperleaf/ballast/navigation/browser/BrowserHistoryNavigationInterceptor.kt @@ -39,7 +39,7 @@ public class BrowserHistoryNavigationInterceptor( override fun watchForUrlChanges(): Flow { return callbackFlow { window.onpopstate = { event: PopStateEvent -> - if(event.state != null) { + if (event.state != null) { this@callbackFlow.trySend(UriBuilder.parse(event.state.toString())) } Unit @@ -56,9 +56,9 @@ public class BrowserHistoryNavigationInterceptor( try { val previousDestination = getInitialUrl() if (previousDestination != url) { - val updatedUrl = if(basePath != null) { + val updatedUrl = if (basePath != null) { UriBuilder.build( - encodedPath = "${basePath}/${url.encodedPath.trim('/')}", + encodedPath = "$basePath/${url.encodedPath.trim('/')}", encodedQueryString = url.encodedQueryString.trimStart('?'), ) } else { diff --git a/ballast-queue-core/README.md b/ballast-queue-core/README.md new file mode 100644 index 00000000..e53c650c --- /dev/null +++ b/ballast-queue-core/README.md @@ -0,0 +1,544 @@ +# Ballast Queue Core + +> [!CAUTION] +> +> Experimental. This module may not still have issues or changes in its public API before being considered stable. +> Please use at your own risk, and file Issues for any problems you may encounter. + +## Overview + +Ballast Scheduler is a lightweight way to reliably process a background, persistent job queue. This Core module is +completely independent of Ballast's MVI system, and focuses on the specific problem of enqueuing and running jobs, and +can be used without adopting the full MVI architecture. + +This module provides the low-level infrastructure necessary to serialize tasks and store them in a persistent queue, to +be executed later. In general, this queue system supports multiple named queues, automatic retries (with configurable +backoff strategies), job cancellation, job checkpoints in the form of state persisted between retries, and stored result +values. Other features like priority scheduling, unique jobs, or delayed job starts, may be implemented by the specific +queue driver implementation. + +Ballast Queue is a multiplatform project, with semantics and safety guarantees suitable for both long-running +server-side jobs queues meant to process large volumes of tasks, and also client-side applications for tasks such as +synchronizing local data with a server. + +## Supported Platforms + +| Platform | Supported | +|----------|-----------| +| JVM | ✅ | +| Android | ✅ | +| iOS | ✅ | +| JS | ✅ | +| WASM JS | ✅ | + +## See Also + +- [Ballast Queue Viewmodel](./../ballast-queue-viewmodel) +- [Ballast Queue Exposed Driver](./../ballast-queue-exposed-driver) + +## Usage + +Ballast Queue is a layered system for running queues. It couples a low-level `QueueDriver`, which implements the basic +functions of enqueueing and dequeueing jobs based on pure data. A `QueueExecutor` wraps a driver and adds higher-level +functionality for handling errors, type-safe job classes with automatic (de)serialization, and cancellation support. +You can then wrap the Executor in the common Ballast ViewModel interface with [Ballast Queue Viewmodel](./../ballast-queue-viewmodel) +so you can keep the same familiar syntax and semantics for processing persistent jobs that you already use for building +UI components. + +### Overview + +#### QueueDriver + +The Driver is a very low-level component, and should not be used directly from application code. Its purpose is to allow +different job queue backends to be used by Ballast. Currently, Ballast supports an in-memory driver for quick +experimentation, and a synchronous driver suitable for end-to-end testing. The [Ballast Queue Exposed Driver](./../ballast-queue-exposed-driver) +module adds support for storing jobs in a database table, and currently supports PostgreSQL and MySQL database engines. + +#### QueueExecutor + +The Queue Executor is what you will be using to interact with your queue, as it provides a type-safe interface for +processing your jobs, and additional necessary functionality that is not suitable for the Driver. + +#### Processing Loop + +Ballast jobs are simple data classes which get serialized to JSON by the `QueueExecutor` and stored in a `QueueDriver`. +The Driver then sets up a processing loop as a Flow, which emits values back to the Executor when a job is ready to be +processed. The executor then deserializes that JSON payload back to its original data class, and calls a lambda for you +to handle the job execution. That execution can store intermediate state, which will be maintained if the job fails and +needs to be retried. Jobs can also return a result if it runs to completion successfully. + +### Setting up a Queue + +#### Step 1: Select a Driver + +First, create an instance of your queue driver. The driver should be a singleton in your application. + +Currently, the following drivers are available: + +- **InMemoryQueueDriver**: The In-memory Queue Driver is a simple implementation of a QueueDriver that keeps all jobs in + a list in memory, held in a `StateFlow` for observing the state of the queue and its jobs. This is primarily useful + for testing and debugging, as its jobs are NOT persisted between application restarts. +- **SyncQueueDriver**: The Sync Queue Driver is a implementation of a QueueDriver that is intended for unit testing. It + does not actually keep a queue of jobs, but instead uses a `RENDEZVOUS` `Channel` to immediately process the job + synchronously. This allows you to have guarantees in your unit tests that calling `addToQueue` will process the job + before returning, as long as another coroutine is currently observing the queue. +- **ExposedDatabaseQueueDriver**: The Exposed driver stores jobs in a database table, and uses the [Kotlin Exposed](https://www.jetbrains.com/exposed/) + library to query that database. The table schema is designed for concurrency and safety of jobs, since it's meant to + be used in a server-side application. See the [Ballast Queue Exposed Driver](./../ballast-queue-exposed-driver) documentation + for more details on using this driver. + +#### Step 2: Set up an Executor + +The Executor provides a type-safe interface to the lower-level, untyped driver. It requires 4 generic type parameters: + +- **Payload**: The Payload is a simple data class which defines the actual work to be done. It should generally contain + only the minimal info necessary to run the jobs, such as an ID to a database record which needs to be processed. You + may set up your queues with a single data class, or a `sealed class` to have one queue capable of enqueuing and + dispatching multiple types of jobs. +- **State**: Jobs are able to maintain internal state which is only visible to that job. If a job fails and is retried, + the state updates from the first run will be maintained, and the re-run will start with that state. This should be + used primarily for building a system of "checkpoints" in the processing of a job, so retries don't need to be started + from the beginning every time. It can also be used to report progress to an observer. +- **Result**: A job that runs to completion successfully is able to return a result. The library itself does not make + use of this value, but your application logic may use it to store a report of what was processed, or temporarily store + data that needs to be passed to another job. +- **JobMetadata**: This is the connector between your job and the underlying driver. Unlike many other job queue + systems, Ballast does not try to implement all possible queueing logic in the primary interface, since the semantics + of queues, and thus the data needed to configure the queue, can be significantly different between server-side and + client-side use cases. Instead, it allows the Driver to define its own configuration, retry policies, etc. through a + metadata object derived from the Payload. You are responsible for converting the Payload to the correct JobMetadata + needed by the driver by implementing an instance of `QueueDriver.Adapter`. Each Driver should also include a generic + `DefaultAdapter` which only uses default values and does not require any special configuration. + +In addition to the type parameters, you will also need to provide a class to handle serialization and deserialization +of those types, by implementing `QueueExecutor.Serializers`. Support for [Kotlinx Serialization](https://github.com/Kotlin/kotlinx.serialization) +with JSON is provided out-of-the-box. + +Example: + +```kotlin +val driver = InMemoryQueueDriver(clock) +val executor = DefaultQueueExecutor( + driver = driver, + adapter = InMemoryQueueDriver.DefaultAdapter(), + serializers = JsonSerializers( + payloadSerializer = Payload.serializer(), + resultSerializer = Result.serializer(), + stateSerializer = State.serializer(), + json = Json { prettyPrint = true }, + ), +) +``` + +#### Step 3: Enqueue jobs + +Jobs are always inserted into a specific named queue. Queues with different names are treated as completely independent +entities. Jobs co-exist in the same storage, but are logically partitioned by their queue name. The queue name is just +an arbitrary String with no restrictions on name or format, but common names are `high`, `default`, `low` for defining +jobs of varying importance, and `dlq` for a "dead-letter queue". + +Enqueueing a job is done with `executor.insertJob()`, which requires a `Payload` and an initial `State`. It returns a +String of the unique ID of the job, generated by the driver implementation. + +Example: + +```kotlin +val executor = DefaultQueueExecutor( + // see Step 2 +) +val uuid = executor.insertJob( + queueName = "one", + payload = TestPayload("ballast"), + initialState = TestState(), +) +``` + +#### Step 4: Run the queue + +The Queue is then run by calling `executor.runQueue()` and collecting the resulting Flow. The Executor itself is +stateless, so you can use the same Executor instance to run multiple Queues in parallel. A single Flow processes jobs +sequentially, one at a time (as is normal for un-buffered Flows). If you wish to increase the parallelism of a single +queue, you can simply repeat `executor.runQueue()` with the same Queue Name and collect each flow in a separate +Coroutine. + +Normally, with Flows, the upstream Flow emits a value that is then processed by your downstream collector. In this case, +though, Ballast needs to perform some processing both _before_ and _after_ receiving a job from the driver. As a result, +you are required to pass in a lambda to `executor.runQueue()` to perform the task of processing a single job, and the +Flow returned actually emits _after_ a job has been processed, returning the result to the downstream collector. This +value may safely be ignored as it is already handled internally, but you may choose to inspect the job result for things +like logging or sending notifications on failure. + +Additionally, since the collection of this Flow completely controls the lifetime of the queue processor, you are able to +run it indefinitely as a daemon, or only collect a certain number of jobs before quitting. For example, you may instead +process jobs in batches, during specific times, etc. + +Example: + +```kotlin +val executor = DefaultQueueExecutor( + // see Step 2 +) +val oneJob = executor + .runQueue("one", ::processJob) + .first() +``` + +#### Step 5: Processing the job with state + +The `processJob` lambda is suspending, and is provided with a `QueueExecutorScope` receiver, which gives you a +handle to get and update the `State` of the job during execution. + +The first time a job is run, `scope.getCurrentState()` will return the initial State submitted to the queue with +`executor.insertJob()`. Within the same run, you can then call `scope.setState()` to update the Job record with a new +version of the state. This call will be applied synchronously to the Driver, so you have a guarantee that the state was +either persisted successfully if this call returns successfully, or else could not be applied for some reason, which +should throw an exception and fail the execution of this job. If the job failed and is retried, calling +`scope.getCurrentState()` in the subsequent run will instead return the last state successfully saved with +`scope.setState()`. + +Consider this example how this State can help in designing a durable workflow with a Ballast Job. Imagine we have a +system where a user uploads an MP3 file to publish a podcast. Our system needs to transcode this MP3 to several +different bitrates, send the file to a AI cloud vendor to generate a transcript, and send notifications to subscribers. +Each of these operations can take a significant amount of time, and may fail due to network issues, vendor downtime, +etc. + +To make this workflow durable, we can use the State to track and optionally skip operations that have already completed, +so retries do not necessarily need to do all 3 operations. + +```kotlin +data class State( + val transcodingComplete: Boolean = false, + val transcriptionComplete: Boolean = false, + val notificationsSent: Boolean, +) + +suspend fun QueueExecutorScope.processJob(podcast: Mp3File) { + if (!getCurrentState().transcodingComplete) { + performTranscoding(podcast) + updateState(getCurrentState().copy(transcodingComplete = true)) + } + + if (!getCurrentState().transcriptionComplete) { + transcriptionService.generateTranscription(podcast) + updateState(getCurrentState().copy(transcriptionComplete = true)) + } + + if (!getCurrentState().notificationsSent) { + notificationService.notifySubscribers(podcast) + updateState(getCurrentState().copy(notificationsSent = true)) + } +} +``` + +#### Step 6: Job Results + +The `Result` type parameter on your `QueueExecutor` is the value your job can return when it runs to completion +successfully. It's fully optional — you can use `Unit` if there's nothing meaningful to return, or `null` if the job +processed but produced no output in a particular run. + +**Returning a result from a job** + +Your `processJob` lambda simply returns a `Result?`. Return a value to signal success and attach data to the completed +job record; return `null` to signal success without any result payload. + +```kotlin +suspend fun QueueExecutorScope.processJob(payload: TranscodeMp3File): TranscodeResult? { + val mp3File = fileService.findFileByPath(payload.uploadFilePath) + ?: throw JobFailureException(permanentlyFail = true) + + performTranscoding(mp3File) + + // return a result to record what the job produced + return TranscodeResult(outputPath = mp3File.transcodedPath, durationSeconds = mp3File.durationSeconds) +} +``` + +The result value is serialized and stored by the driver alongside the job record, so it can be retrieved later for +audit purposes or to be passed on to a subsequent job. + +**Inspecting results from the Flow** + +`runQueue()` returns a `Flow>`. Each emission from this Flow represents a single job that +has finished processing — successfully or not. The `JobProcessingResult` carries the job ID, how long processing took, +and a `JobCompletionResult` describing the outcome: + +```kotlin +executor + .runQueue("default", ::processJob) + .onEach { jobResult -> + when (val result = jobResult.result) { + is JobCompletionResult.Success -> { + logger.info("Job ${jobResult.jobId} succeeded in ${jobResult.processingTime}: ${result.resultData}") + } + is JobCompletionResult.Failure -> { + logger.error("Job ${jobResult.jobId} failed after ${jobResult.processingTime}: ${result.cause.message}") + } + is JobCompletionResult.Timeout -> { + logger.warn("Job ${jobResult.jobId} timed out after ${jobResult.processingTime}") + } + is JobCompletionResult.Cancelled -> { + logger.warn("Job ${jobResult.jobId} was manually cancelled") + } + } + } + .launchIn(applicationCoroutineScope) +``` + +These emissions are already handled internally — the driver has already been updated with the outcome by the time each +value is emitted — so you are free to ignore the Flow entirely if you don't need to act on individual results. + +### Dealing with errors + +#### Processing Failure + +Work is typically moved to a queue because it takes a long time, has a nonzero chance of failure, and does not need to +be processed immediately. Designing your application to move these points of failure to a job will help you maintain a +fast, responsive application, while ensuring critical operations are guaranteed to be run successfully, eventually. +Notably, queues operate on a principle of _eventual consistency_. Work may not complete immediately, but you can have +assurance that it will at least complete _eventually_, being retried if it fails to recover from those errors. + +Ballast queues are designed to be safe against all kinds of failures, including: + +- **Normal processing errors**: Exceptions thrown during the precessing of a job will be caught and logged, and the + job scheduled for retry according to the driver's retry policy +- **Timeouts**: Background jobs are expected to be slow, but sometimes they take significantly longer to process than + they should. For example, a dependent service may be running particularly slowly, or your application server has run + out of memory and the job gets hung. In these cases, Ballast will enforce a timeout on the job, so if it takes too + long, it will cancel the job, report the error, and allow other jobs to continue which may be able to process faster. + The cancelled job will be scheduled for retry according to the driver's retry policy. +- **Cancellation**: In addition to cancellation due to timeouts, you can manually cancel a job. This will cancel the + coroutine currently processing the job, ensuring prompt termination and cleanup of the job, and allow the next job to + run. The cancelled job will be scheduled for retry according to the driver's retry policy. +- **Application crashes**: Server processes are never guaranteed, and may sometimes be shutdown without any opportunity + for the application to close gracefully. In this case, any jobs that were claimed for processing will get stuck in the + "running" state and ineligible for retry, which is obviously not an acceptable solution. When a job is claimed from + the driver, it is given a "lease" on that job for a short period of time (typically the timeout duration of the job, + plus a short buffer ~30 seconds). In the case of a server crash, this lease will eventually expire and allow the job + to be retried. + +For cases of job exceptions or cancellation/timeouts, the job will immediately be released back to the queue for retries +according to the job's retry policy. This phrase is intentionally vague, as Ballast enforces no retry policy on its own, +and instead leaves the Queue Driver to define how and when to retry the job, and structure its `JobMetadata` to let each +job configure that policy on its own. For example, the `ExposedDatabaseQueueDriver` allows jobs to be retried based on +the number of attempts or will retry as many times as it needs until a specified expiry time is exceeded. The +`InMemoryQueueDriver` only supports retries based on the number of attempts. Other queue systems, like Amazon SQS, may +include their own policies, and Ballast will simply notify the driver of the failure and it figure out whether it should +retry or not. + +#### Retry Backoff + +When a job fails and may need to be retried, it can be given a delay as a buffer against temporal issues. A default +retry for all jobs in the queue can be set in the `DriverQueue.Adapter.getDefaultRetryDelayTimeout()`, which can be +configured individually for each payload. This method is also provided the number of times the job has already been +attempted, so it can be used for increasing the backoff delay after each attempt. See example backoff strategies below: + +```kotlin +public fun getDefaultRetryDelayTimeout(payload: Unit, attempts: Int): Duration { + // exponential backoff: 2^attempts in minutes, to a maximum of 1 hour + return minOf((2.0.pow(attempts.toDouble()).toLong()).minutes, 60.minutes) +} + +public fun getDefaultRetryDelayTimeout(payload: Unit, attempts: Int): Duration { + // fixed array of increasing delays, in minutes + val delays = listOf(1, 2, 5, 10, 30, 60, 90) + val index = attempts.coerceAtMost(delays.size) - 1 + return delays[index].minutes +} +``` + +However, in some cases, a fixed retry delay is not always able to capture the real backoff needs, especially in the case +of calling a rate-limited API from an external webservice. These API endpoints return a specific number of seconds your +application must wait before requests will succeed, as a protection against DDoS attacks or as a way to meter API usage. + +To use data from the job processing itself as the basis for a backoff delay, throw `JobFailureException` from your job +and set the `retryDelay`. See this example for catching errors from the webservice to determine the necessary delay: + +```kotlin +suspend fun QueueExecutorScope.processJob(podcast: Mp3File) { + try { + notificationService.notifySubscribers(podcast) + } catch (e: HttpException) { + if (e.statusCode == 429) { + val retryAfter = Instant.parse(e.response.headers["Retry-After"]) + val now = clock.now() + val delay = retryAfter - now + throw JobFailureException(cause = e, retryDelay = delay) + } + } +} +``` + +#### Permanent failures and Dead-Letter Queues + +Ballast does not enforce any specific concept of a "dead-letter queue" (DLQ) by itself. Like Retry Policies, it leaves +this functionality up to the driver. Functionally, a DLQ is no different from any other queue. It simply defines the +"Queue Name" of a queue, and an alternate mode of processing that usually just notifies system admins of the failure +rather than actually processing the job. So if your driver has a DLQ, you just need to collect from that queue by name. + +Ballast does not automatically move jobs to a different DLQ, but instead would prefer to simply mark a job as +permanently failed and leave it in the original queue, ineligible to be claimed and processed. Should you need a DLQ, +it is either up to the driver to move the job to a DLQ immediately when marking it as failed, or else periodically +scanning the jobs store and manually moving the job to a DLQ. + +Jobs are considered "permanently failed" if they fail during execution, and the queue does not permit an additional +retry. They are moved to a "failed" state which indicates the permanent failure, so you can query the queue to +appropriately deal with those failed jobs. + +Sometimes, during the execution of a job, you can detect that the job will _never_ succeed, no matter how many times it +is retried. For example, an API token may have expired, a validation error in the job's Payload renders it +unprocessable, or the DB record that's supposed to be processed by the job has already been deleted. In these cases, +you'll want to mark the job as permanently failed immediately so Ballast does not attempt to retry that job, wasting +system resources. This is also done by throwing `JobFailureException` and setting `permanentlyFail = true`. + +```kotlin +suspend fun QueueExecutorScope.processJob(payload: TranscodeMp3File) { + val mp3File = fileService.findFileByPath(payload.uploadFilePath) + + if (mp3File == null) { + // oops, the file was already deleted + throw JobFailureException(cause = e, permanentlyFail = true) + } + + performTranscoding(mp3File) +} +``` + +### Rate-limiting + +#### Concept + +In the absence of any kind of rate-limiting, it would be very easy for an issue in your server to process jobs too +quickly and overwhelm other webservices. + +Consider this example: + +> You have a webservice which generates about 1,000 jobs per hour, which post data to a downstream API. You pay for a +> rate-limiting policy from that service which roughly matches the rate at which jobs are generated. Occasionally spikes +> in traffic will cause jobs to back up in the queue and be processed more slowly as that downstream API returns 429 +> errors, but subsequent dips in traffic easily allow the queue to catch back up within a short time. +> +> However, an issue causes your queue processor to go down at the same time you receive a large spike in traffic. During +> the outage, you end up with more than 50,000 jobs in the queue. When the service comes back online, it starts +> processing those jobs as quickly as it can, a 50-fold increase in the normal rate of processing. As such, the +> downstream service starts applying aggressive rate-limiting policies as DDoS protection. This DDoS protection causes +> all the jobs in your queue to fail, getting enqueued for retry. Meanwhile, more jobs are continually being added. This +> cascade of failures and retries continually prevents your server from being able to access the downstream service, and +> you're never able to drain the queue. You're forced to take your application offline, wait for the 429 errors to +> subside, then restart the queue and process the jobs very slowly to allow the system to catch. +> +> In all, because the queue did not enforce its own rate-limiting behavior, the downstream service's rate-limiting +> kicked in to protected itself, which exacerbated the original problem, causing another outage in your application. + +While the above scenario is obviously a bit fanciful, it is a real situation that one could get themselves in if care +isn't taken to protect your downstream services. This is where Ballast's `QueueThrottle` comes in. + +In Ballast Queues, a "throttle" is a lightweight policy _shared among all queue workers_ which helps limit the overall +concurrency or rate of job processing by the entire system. Ballast offers several simple, yet effective, policies to +avoid processing jobs too quickly. The default policies all operate in-memory, protecting a single process, though you +can implement your own policies to share state among nodes in a distributed system (i.e. using Redis distributed locks). + +Conceptually, Ballast Queues run a busy-loop in a coroutine. If a job is eligible for processing, it claims it, +processes the job, then stores the result. The loop is then repeated, and a delay is only applied to this loop if there +was no job available for processing. A `QueueThrottle`, therefore, adds a delay to that loop _before_ it checks for an +available job, suspending until the throttle permits the worker to try and claim a job. + +#### Applying Throttling policies + +Queue Throttles are intended to be created as a singleton, and passed into a supporting `QueueDriver`. The`QueuePolicy` +itself must be a singleton, shared by all workers and/or drivers of your application. + +In this example, there are a total of 7 Workers each running in parallel, but the ConcurrencyLimitThrottle limits the +queue to only 4 active jobs at a time amongst all 7 workers, regardless of the queue priority. + +```kotlin +val executor = DefaultQueueExecutor( + driver = InMemoryQueueDriver( + throttle = ConcurrencyLimitThrottle(4), + ), + adapter = InMemoryQueueDriver.DefaultAdapter(), + serializers = JsonSerializers( + payloadSerializer = TestPayload.serializer(), + resultSerializer = TestResult.serializer(), + stateSerializer = TestState.serializer(), + ), +) + +listOf("high" to 4, "default" to 2, "low" to 1).forEach { (queueName, replicaCount) -> + repeat(replicaCount) { + executor + .runQueue(queueName, ::proessJob) + .launchIn(applicationCoroutineScope) + } +} +``` + +#### Available Policies + +By default, Ballast does not impose any rate-limiting, by using the `UnlimitedThrottle`, but you should ensure any +production workloads do select and apply an appropriate policy. You may choose from one of the below policies available, +or implement a custom policy. + +- **UnlimitedThrottle**: Applies no throttling to the queue. Not recommended for server-side workloads, but probably + fine for low-volume client-side workloads. +- **ConcurrencyLimitThrottle**: Limits the workers to at most `N` jobs being actively processed concurrently. +- **TokenBucketThrottle**: A simple algorithm enforcing an upper-end on the rate of jobs. By continually filling a + "bucket" at a constant rate, queues must wait for the bucket to fill before being allowed to claim and process a + job. In low-volume scenarios, jobs will be processed as quickly as possible since the bucket will always have "tokens" + available, but as volume increases, jobs will only be processed at the rate at which "tokens" are added to the bucket. +- **PerQueueThrottle**: Delegate different throttling policies to each queue by name +- **CompositeThrottle**: Require multiple delegated policies to become available before a worker can claim a job. + +These policies can be combined together, to create more complex policies. For example: + +```kotlin +val totalSystemConcurrency = ConcurrencyLimitThrottle(4) + +// 1 job per second, processing bursts of up to 10 jobs +val highRateLimit = TokenBucketThrottle( + scope = applicationCoroutineScope, + capacity = 10, + refillRatePerTick = 1, + tickDuration = 1.seconds, +) + +// 1 job per minute, processing bursts of up to 4 jobs +val defaultAndLowRateLimit = TokenBucketThrottle( + scope = applicationCoroutineScope, + capacity = 4, + refillRatePerTick = 2, + tickDuration = 1.minutes, +) + +val throttle = PerQueueThrottle( + policies = mapOf( + "high" to CompositeThrottle(totalSystemConcurrency, highRateLimit), + "default" to CompositeThrottle(totalSystemConcurrency, defaultAndLowRateLimit), + "low" to CompositeThrottle(totalSystemConcurrency, defaultAndLowRateLimit), + ), + default = totalSystemConcurrency +) +``` + +## Installation + +```kotlin +repositories { + mavenCentral() +} + +// for plain JVM or Android projects +dependencies { + implementation("io.github.copper-leaf:ballast-queue-core:{{ballastVersion}}") +} + +// for multiplatform projects +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + implementation("io.github.copper-leaf:ballast-queue-core:{{ballastVersion}}") + } + } + } +} +``` diff --git a/ballast-queue-core/api/android/ballast-queue-core.api b/ballast-queue-core/api/android/ballast-queue-core.api new file mode 100644 index 00000000..48ed8424 --- /dev/null +++ b/ballast-queue-core/api/android/ballast-queue-core.api @@ -0,0 +1,307 @@ +public abstract interface class com/copperleaf/ballast/queue/JobCompletionResult { +} + +public final class com/copperleaf/ballast/queue/JobCompletionResult$Cancelled : com/copperleaf/ballast/queue/JobCompletionResult { + public synthetic fun (JLkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1-UwyO8pc ()J + public final fun copy-LRDsOJo (J)Lcom/copperleaf/ballast/queue/JobCompletionResult$Cancelled; + public static synthetic fun copy-LRDsOJo$default (Lcom/copperleaf/ballast/queue/JobCompletionResult$Cancelled;JILjava/lang/Object;)Lcom/copperleaf/ballast/queue/JobCompletionResult$Cancelled; + public fun equals (Ljava/lang/Object;)Z + public final fun getRetryDelay-UwyO8pc ()J + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/copperleaf/ballast/queue/JobCompletionResult$Failure : com/copperleaf/ballast/queue/JobCompletionResult { + public synthetic fun (Ljava/lang/Exception;JZZLkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/Exception; + public final fun component2-UwyO8pc ()J + public final fun component3 ()Z + public final fun component4 ()Z + public final fun copy-dWUq8MI (Ljava/lang/Exception;JZZ)Lcom/copperleaf/ballast/queue/JobCompletionResult$Failure; + public static synthetic fun copy-dWUq8MI$default (Lcom/copperleaf/ballast/queue/JobCompletionResult$Failure;Ljava/lang/Exception;JZZILjava/lang/Object;)Lcom/copperleaf/ballast/queue/JobCompletionResult$Failure; + public fun equals (Ljava/lang/Object;)Z + public final fun getCause ()Ljava/lang/Exception; + public final fun getPermanentlyFail ()Z + public final fun getRetryDelay-UwyO8pc ()J + public final fun getSkipAttempt ()Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/copperleaf/ballast/queue/JobCompletionResult$Success : com/copperleaf/ballast/queue/JobCompletionResult { + public fun (Ljava/lang/Object;)V + public final fun component1 ()Ljava/lang/Object; + public final fun copy (Ljava/lang/Object;)Lcom/copperleaf/ballast/queue/JobCompletionResult$Success; + public static synthetic fun copy$default (Lcom/copperleaf/ballast/queue/JobCompletionResult$Success;Ljava/lang/Object;ILjava/lang/Object;)Lcom/copperleaf/ballast/queue/JobCompletionResult$Success; + public fun equals (Ljava/lang/Object;)Z + public final fun getResultData ()Ljava/lang/Object; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/copperleaf/ballast/queue/JobCompletionResult$Timeout : com/copperleaf/ballast/queue/JobCompletionResult { + public synthetic fun (Lkotlinx/coroutines/TimeoutCancellationException;JLkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lkotlinx/coroutines/TimeoutCancellationException; + public final fun component2-UwyO8pc ()J + public final fun copy-HG0u8IE (Lkotlinx/coroutines/TimeoutCancellationException;J)Lcom/copperleaf/ballast/queue/JobCompletionResult$Timeout; + public static synthetic fun copy-HG0u8IE$default (Lcom/copperleaf/ballast/queue/JobCompletionResult$Timeout;Lkotlinx/coroutines/TimeoutCancellationException;JILjava/lang/Object;)Lcom/copperleaf/ballast/queue/JobCompletionResult$Timeout; + public fun equals (Ljava/lang/Object;)Z + public final fun getCause ()Lkotlinx/coroutines/TimeoutCancellationException; + public final fun getRetryDelay-UwyO8pc ()J + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/copperleaf/ballast/queue/JobCompletionResultType : java/lang/Enum { + public static final field Cancelled Lcom/copperleaf/ballast/queue/JobCompletionResultType; + public static final field Failure Lcom/copperleaf/ballast/queue/JobCompletionResultType; + public static final field Success Lcom/copperleaf/ballast/queue/JobCompletionResultType; + public static final field Timeout Lcom/copperleaf/ballast/queue/JobCompletionResultType; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/copperleaf/ballast/queue/JobCompletionResultType; + public static fun values ()[Lcom/copperleaf/ballast/queue/JobCompletionResultType; +} + +public abstract interface class com/copperleaf/ballast/queue/QueueDriver { + public abstract fun addToQueue-gwCluXo (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JLjava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun awaitShutdown (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun completeJobSuccessfully-WPwdCS8 (Ljava/lang/String;JLcom/copperleaf/ballast/queue/JobCompletionResultType;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun completeJobWithFailure-3c68mSE (Ljava/lang/String;JLcom/copperleaf/ballast/queue/JobCompletionResultType;JZZLjava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun observeQueue (Ljava/lang/String;)Lkotlinx/coroutines/flow/Flow; + public abstract fun requestJobCancellation (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun subscribeToJobCancellation (Ljava/lang/String;)Lkotlinx/coroutines/flow/Flow; + public abstract fun updateJobState (Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class com/copperleaf/ballast/queue/QueueDriver$Adapter { + public fun getDefaultRetryDelayTimeout-3nIYWDw (Ljava/lang/Object;I)J + public abstract fun getJobMetadata (Ljava/lang/Object;)Ljava/lang/Object; + public fun getJobTimeout-5sfh64U (Ljava/lang/Object;)J +} + +public final class com/copperleaf/ballast/queue/QueueDriver$Adapter$DefaultImpls { + public static fun getDefaultRetryDelayTimeout-3nIYWDw (Lcom/copperleaf/ballast/queue/QueueDriver$Adapter;Ljava/lang/Object;I)J + public static fun getJobTimeout-5sfh64U (Lcom/copperleaf/ballast/queue/QueueDriver$Adapter;Ljava/lang/Object;)J +} + +public final class com/copperleaf/ballast/queue/QueueDriver$DefaultImpls { + public static fun awaitShutdown (Lcom/copperleaf/ballast/queue/QueueDriver;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class com/copperleaf/ballast/queue/QueueExecutor { + public abstract fun insertJob (Ljava/lang/String;Ljava/lang/Object;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun runQueue (Ljava/lang/String;Lkotlin/jvm/functions/Function3;)Lkotlinx/coroutines/flow/Flow; +} + +public abstract interface class com/copperleaf/ballast/queue/QueueExecutor$Serializers { + public abstract fun deserializePayload (Ljava/lang/String;)Ljava/lang/Object; + public abstract fun deserializeResult (Ljava/lang/String;)Ljava/lang/Object; + public abstract fun deserializeState (Ljava/lang/String;)Ljava/lang/Object; + public abstract fun serializePayload (Ljava/lang/Object;)Ljava/lang/String; + public abstract fun serializeResult (Ljava/lang/Object;)Ljava/lang/String; + public abstract fun serializeState (Ljava/lang/Object;)Ljava/lang/String; +} + +public abstract interface class com/copperleaf/ballast/queue/QueueExecutorScope { + public abstract fun getCurrentState (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun setState (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class com/copperleaf/ballast/queue/QueueThrottle { + public abstract fun acquirePermit (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun awaitShutdown (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/copperleaf/ballast/queue/QueueThrottle$DefaultImpls { + public static fun awaitShutdown (Lcom/copperleaf/ballast/queue/QueueThrottle;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class com/copperleaf/ballast/queue/QueueThrottle$Permit { + public abstract fun release (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/copperleaf/ballast/queue/QueueUtilsKt { + public static final fun pollingFlow (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/flow/Flow; + public static final fun queueDriverPollingFlow (Ljava/lang/String;Lcom/copperleaf/ballast/queue/QueueThrottle;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/flow/Flow; +} + +public final class com/copperleaf/ballast/queue/SerializedJob { + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JLjava/lang/String;Ljava/lang/String;ILjava/lang/Object;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JLjava/lang/String;Ljava/lang/String;ILjava/lang/Object;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun component4-UwyO8pc ()J + public final fun component5 ()Ljava/lang/String; + public final fun component6 ()Ljava/lang/String; + public final fun component7 ()I + public final fun component8 ()Ljava/lang/Object; + public final fun copy-mGOUYlo (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JLjava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/copperleaf/ballast/queue/SerializedJob; + public static synthetic fun copy-mGOUYlo$default (Lcom/copperleaf/ballast/queue/SerializedJob;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JLjava/lang/String;Ljava/lang/String;ILjava/lang/Object;ILjava/lang/Object;)Lcom/copperleaf/ballast/queue/SerializedJob; + public fun equals (Ljava/lang/Object;)Z + public final fun getAttempts ()I + public final fun getJobId ()Ljava/lang/String; + public final fun getMetadata ()Ljava/lang/Object; + public final fun getQueueName ()Ljava/lang/String; + public final fun getSerializedPayload ()Ljava/lang/String; + public final fun getSerializedResultData ()Ljava/lang/String; + public final fun getSerializedState ()Ljava/lang/String; + public final fun getTimeoutDuration-UwyO8pc ()J + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/copperleaf/ballast/queue/driver/memory/InMemoryJobStatus : java/lang/Enum { + public static final field Completed Lcom/copperleaf/ballast/queue/driver/memory/InMemoryJobStatus; + public static final field Failed Lcom/copperleaf/ballast/queue/driver/memory/InMemoryJobStatus; + public static final field Pending Lcom/copperleaf/ballast/queue/driver/memory/InMemoryJobStatus; + public static final field Running Lcom/copperleaf/ballast/queue/driver/memory/InMemoryJobStatus; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/copperleaf/ballast/queue/driver/memory/InMemoryJobStatus; + public static fun values ()[Lcom/copperleaf/ballast/queue/driver/memory/InMemoryJobStatus; +} + +public final class com/copperleaf/ballast/queue/driver/memory/InMemoryQueueDriver : com/copperleaf/ballast/queue/QueueDriver { + public fun ()V + public fun (Lkotlin/time/Clock;Lcom/copperleaf/ballast/queue/QueueThrottle;)V + public synthetic fun (Lkotlin/time/Clock;Lcom/copperleaf/ballast/queue/QueueThrottle;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun addToQueue-gwCluXo (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JLcom/copperleaf/ballast/queue/driver/memory/InMemoryQueueDriver$Metadata;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public synthetic fun addToQueue-gwCluXo (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JLjava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun awaitShutdown (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun completeJobSuccessfully-WPwdCS8 (Ljava/lang/String;JLcom/copperleaf/ballast/queue/JobCompletionResultType;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun completeJobWithFailure-3c68mSE (Ljava/lang/String;JLcom/copperleaf/ballast/queue/JobCompletionResultType;JZZLjava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun observeJobState (Ljava/lang/String;)Lkotlinx/coroutines/flow/Flow; + public fun observeQueue (Ljava/lang/String;)Lkotlinx/coroutines/flow/Flow; + public final fun observeQueueState ()Lkotlinx/coroutines/flow/StateFlow; + public fun requestJobCancellation (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun subscribeToJobCancellation (Ljava/lang/String;)Lkotlinx/coroutines/flow/Flow; + public fun updateJobState (Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/copperleaf/ballast/queue/driver/memory/InMemoryQueueDriver$DefaultAdapter : com/copperleaf/ballast/queue/QueueDriver$Adapter { + public fun ()V + public fun (Lkotlin/time/Clock;)V + public synthetic fun (Lkotlin/time/Clock;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun getDefaultRetryDelayTimeout-3nIYWDw (Ljava/lang/Object;I)J + public fun getJobMetadata (Ljava/lang/Object;)Lcom/copperleaf/ballast/queue/driver/memory/InMemoryQueueDriver$Metadata; + public synthetic fun getJobMetadata (Ljava/lang/Object;)Ljava/lang/Object; + public fun getJobTimeout-5sfh64U (Ljava/lang/Object;)J +} + +public final class com/copperleaf/ballast/queue/driver/memory/InMemoryQueueDriver$Metadata { + public synthetic fun (Lkotlin/time/Instant;IILkotlin/time/Instant;Lcom/copperleaf/ballast/queue/driver/memory/InMemoryJobStatus;Lkotlin/time/Duration;Lcom/copperleaf/ballast/queue/JobCompletionResultType;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Lkotlin/time/Instant;IILkotlin/time/Instant;Lcom/copperleaf/ballast/queue/driver/memory/InMemoryJobStatus;Lkotlin/time/Duration;Lcom/copperleaf/ballast/queue/JobCompletionResultType;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lkotlin/time/Instant; + public final fun component2 ()I + public final fun component3 ()I + public final fun component4 ()Lkotlin/time/Instant; + public final fun component5 ()Lcom/copperleaf/ballast/queue/driver/memory/InMemoryJobStatus; + public final fun component6-FghU774 ()Lkotlin/time/Duration; + public final fun component7 ()Lcom/copperleaf/ballast/queue/JobCompletionResultType; + public final fun component8 ()Ljava/lang/String; + public final fun component9 ()Ljava/lang/String; + public final fun copy-ZfZE-DE (Lkotlin/time/Instant;IILkotlin/time/Instant;Lcom/copperleaf/ballast/queue/driver/memory/InMemoryJobStatus;Lkotlin/time/Duration;Lcom/copperleaf/ballast/queue/JobCompletionResultType;Ljava/lang/String;Ljava/lang/String;)Lcom/copperleaf/ballast/queue/driver/memory/InMemoryQueueDriver$Metadata; + public static synthetic fun copy-ZfZE-DE$default (Lcom/copperleaf/ballast/queue/driver/memory/InMemoryQueueDriver$Metadata;Lkotlin/time/Instant;IILkotlin/time/Instant;Lcom/copperleaf/ballast/queue/driver/memory/InMemoryJobStatus;Lkotlin/time/Duration;Lcom/copperleaf/ballast/queue/JobCompletionResultType;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/copperleaf/ballast/queue/driver/memory/InMemoryQueueDriver$Metadata; + public fun equals (Ljava/lang/Object;)Z + public final fun getInsertedAt ()Lkotlin/time/Instant; + public final fun getLastErrorMessage ()Ljava/lang/String; + public final fun getLastResultType ()Lcom/copperleaf/ballast/queue/JobCompletionResultType; + public final fun getLastRunDuration-FghU774 ()Lkotlin/time/Duration; + public final fun getLastStacktrace ()Ljava/lang/String; + public final fun getMaxAttempts ()I + public final fun getPriority ()I + public final fun getRunAt ()Lkotlin/time/Instant; + public final fun getStatus ()Lcom/copperleaf/ballast/queue/driver/memory/InMemoryJobStatus; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/copperleaf/ballast/queue/driver/sync/SyncQueueDriver : com/copperleaf/ballast/queue/QueueDriver { + public fun ()V + public synthetic fun addToQueue-gwCluXo (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JLjava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun addToQueue-gwCluXo (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JLkotlin/Unit;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun awaitShutdown (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun completeJobSuccessfully-WPwdCS8 (Ljava/lang/String;JLcom/copperleaf/ballast/queue/JobCompletionResultType;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun completeJobWithFailure-3c68mSE (Ljava/lang/String;JLcom/copperleaf/ballast/queue/JobCompletionResultType;JZZLjava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun getLastJob ()Lcom/copperleaf/ballast/queue/SerializedJob; + public final fun getLastJobFailureMessage ()Ljava/lang/String; + public final fun getLastJobResultData ()Ljava/lang/String; + public final fun getLastJobResultType ()Lcom/copperleaf/ballast/queue/JobCompletionResultType; + public fun observeQueue (Ljava/lang/String;)Lkotlinx/coroutines/flow/Flow; + public fun requestJobCancellation (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun subscribeToJobCancellation (Ljava/lang/String;)Lkotlinx/coroutines/flow/Flow; + public fun updateJobState (Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/copperleaf/ballast/queue/executor/DefaultQueueExecutor : com/copperleaf/ballast/queue/QueueExecutor { + public fun (Lcom/copperleaf/ballast/queue/QueueDriver;Lcom/copperleaf/ballast/queue/QueueDriver$Adapter;Lcom/copperleaf/ballast/queue/QueueExecutor$Serializers;ZLkotlin/time/TimeSource;)V + public synthetic fun (Lcom/copperleaf/ballast/queue/QueueDriver;Lcom/copperleaf/ballast/queue/QueueDriver$Adapter;Lcom/copperleaf/ballast/queue/QueueExecutor$Serializers;ZLkotlin/time/TimeSource;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun insertJob (Ljava/lang/String;Ljava/lang/Object;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun runQueue (Ljava/lang/String;Lkotlin/jvm/functions/Function3;)Lkotlinx/coroutines/flow/Flow; +} + +public final class com/copperleaf/ballast/queue/executor/JobFailureException : java/lang/RuntimeException { + public synthetic fun (Ljava/lang/Exception;Lkotlin/time/Duration;ZZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/lang/Exception;Lkotlin/time/Duration;ZZLkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getPermanentlyFail ()Z + public final fun getRetryDelay-FghU774 ()Lkotlin/time/Duration; + public final fun getSkipAttempt ()Z +} + +public final class com/copperleaf/ballast/queue/executor/JobProcessingResult { + public synthetic fun (Ljava/lang/String;JLcom/copperleaf/ballast/queue/JobCompletionResult;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2-UwyO8pc ()J + public final fun component3 ()Lcom/copperleaf/ballast/queue/JobCompletionResult; + public final fun copy-8Mi8wO0 (Ljava/lang/String;JLcom/copperleaf/ballast/queue/JobCompletionResult;)Lcom/copperleaf/ballast/queue/executor/JobProcessingResult; + public static synthetic fun copy-8Mi8wO0$default (Lcom/copperleaf/ballast/queue/executor/JobProcessingResult;Ljava/lang/String;JLcom/copperleaf/ballast/queue/JobCompletionResult;ILjava/lang/Object;)Lcom/copperleaf/ballast/queue/executor/JobProcessingResult; + public fun equals (Ljava/lang/Object;)Z + public final fun getJobId ()Ljava/lang/String; + public final fun getProcessingTime-UwyO8pc ()J + public final fun getResult ()Lcom/copperleaf/ballast/queue/JobCompletionResult; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/copperleaf/ballast/queue/executor/JsonSerializers : com/copperleaf/ballast/queue/QueueExecutor$Serializers { + public fun (Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/json/Json;)V + public synthetic fun (Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/json/Json;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun deserializePayload (Ljava/lang/String;)Ljava/lang/Object; + public fun deserializeResult (Ljava/lang/String;)Ljava/lang/Object; + public fun deserializeState (Ljava/lang/String;)Ljava/lang/Object; + public fun serializePayload (Ljava/lang/Object;)Ljava/lang/String; + public fun serializeResult (Ljava/lang/Object;)Ljava/lang/String; + public fun serializeState (Ljava/lang/Object;)Ljava/lang/String; +} + +public final class com/copperleaf/ballast/queue/throttle/CompositeThrottle : com/copperleaf/ballast/queue/QueueThrottle { + public fun ([Lcom/copperleaf/ballast/queue/QueueThrottle;)V + public fun acquirePermit (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun awaitShutdown (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/copperleaf/ballast/queue/throttle/ConcurrencyLimitThrottle : com/copperleaf/ballast/queue/QueueThrottle { + public fun (I)V + public fun acquirePermit (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun awaitShutdown (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/copperleaf/ballast/queue/throttle/PerQueueThrottle : com/copperleaf/ballast/queue/QueueThrottle { + public fun (Ljava/util/Map;Lcom/copperleaf/ballast/queue/QueueThrottle;)V + public fun acquirePermit (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun awaitShutdown (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/copperleaf/ballast/queue/throttle/TokenBucketThrottle : com/copperleaf/ballast/queue/QueueThrottle { + public synthetic fun (Lkotlinx/coroutines/CoroutineScope;IIJLkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun acquirePermit (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun awaitShutdown (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/copperleaf/ballast/queue/throttle/UnlimitedThrottle : com/copperleaf/ballast/queue/QueueThrottle { + public fun ()V + public fun acquirePermit (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun awaitShutdown (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + diff --git a/ballast-queue-core/api/jvm/ballast-queue-core.api b/ballast-queue-core/api/jvm/ballast-queue-core.api new file mode 100644 index 00000000..48ed8424 --- /dev/null +++ b/ballast-queue-core/api/jvm/ballast-queue-core.api @@ -0,0 +1,307 @@ +public abstract interface class com/copperleaf/ballast/queue/JobCompletionResult { +} + +public final class com/copperleaf/ballast/queue/JobCompletionResult$Cancelled : com/copperleaf/ballast/queue/JobCompletionResult { + public synthetic fun (JLkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1-UwyO8pc ()J + public final fun copy-LRDsOJo (J)Lcom/copperleaf/ballast/queue/JobCompletionResult$Cancelled; + public static synthetic fun copy-LRDsOJo$default (Lcom/copperleaf/ballast/queue/JobCompletionResult$Cancelled;JILjava/lang/Object;)Lcom/copperleaf/ballast/queue/JobCompletionResult$Cancelled; + public fun equals (Ljava/lang/Object;)Z + public final fun getRetryDelay-UwyO8pc ()J + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/copperleaf/ballast/queue/JobCompletionResult$Failure : com/copperleaf/ballast/queue/JobCompletionResult { + public synthetic fun (Ljava/lang/Exception;JZZLkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/Exception; + public final fun component2-UwyO8pc ()J + public final fun component3 ()Z + public final fun component4 ()Z + public final fun copy-dWUq8MI (Ljava/lang/Exception;JZZ)Lcom/copperleaf/ballast/queue/JobCompletionResult$Failure; + public static synthetic fun copy-dWUq8MI$default (Lcom/copperleaf/ballast/queue/JobCompletionResult$Failure;Ljava/lang/Exception;JZZILjava/lang/Object;)Lcom/copperleaf/ballast/queue/JobCompletionResult$Failure; + public fun equals (Ljava/lang/Object;)Z + public final fun getCause ()Ljava/lang/Exception; + public final fun getPermanentlyFail ()Z + public final fun getRetryDelay-UwyO8pc ()J + public final fun getSkipAttempt ()Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/copperleaf/ballast/queue/JobCompletionResult$Success : com/copperleaf/ballast/queue/JobCompletionResult { + public fun (Ljava/lang/Object;)V + public final fun component1 ()Ljava/lang/Object; + public final fun copy (Ljava/lang/Object;)Lcom/copperleaf/ballast/queue/JobCompletionResult$Success; + public static synthetic fun copy$default (Lcom/copperleaf/ballast/queue/JobCompletionResult$Success;Ljava/lang/Object;ILjava/lang/Object;)Lcom/copperleaf/ballast/queue/JobCompletionResult$Success; + public fun equals (Ljava/lang/Object;)Z + public final fun getResultData ()Ljava/lang/Object; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/copperleaf/ballast/queue/JobCompletionResult$Timeout : com/copperleaf/ballast/queue/JobCompletionResult { + public synthetic fun (Lkotlinx/coroutines/TimeoutCancellationException;JLkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lkotlinx/coroutines/TimeoutCancellationException; + public final fun component2-UwyO8pc ()J + public final fun copy-HG0u8IE (Lkotlinx/coroutines/TimeoutCancellationException;J)Lcom/copperleaf/ballast/queue/JobCompletionResult$Timeout; + public static synthetic fun copy-HG0u8IE$default (Lcom/copperleaf/ballast/queue/JobCompletionResult$Timeout;Lkotlinx/coroutines/TimeoutCancellationException;JILjava/lang/Object;)Lcom/copperleaf/ballast/queue/JobCompletionResult$Timeout; + public fun equals (Ljava/lang/Object;)Z + public final fun getCause ()Lkotlinx/coroutines/TimeoutCancellationException; + public final fun getRetryDelay-UwyO8pc ()J + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/copperleaf/ballast/queue/JobCompletionResultType : java/lang/Enum { + public static final field Cancelled Lcom/copperleaf/ballast/queue/JobCompletionResultType; + public static final field Failure Lcom/copperleaf/ballast/queue/JobCompletionResultType; + public static final field Success Lcom/copperleaf/ballast/queue/JobCompletionResultType; + public static final field Timeout Lcom/copperleaf/ballast/queue/JobCompletionResultType; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/copperleaf/ballast/queue/JobCompletionResultType; + public static fun values ()[Lcom/copperleaf/ballast/queue/JobCompletionResultType; +} + +public abstract interface class com/copperleaf/ballast/queue/QueueDriver { + public abstract fun addToQueue-gwCluXo (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JLjava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun awaitShutdown (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun completeJobSuccessfully-WPwdCS8 (Ljava/lang/String;JLcom/copperleaf/ballast/queue/JobCompletionResultType;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun completeJobWithFailure-3c68mSE (Ljava/lang/String;JLcom/copperleaf/ballast/queue/JobCompletionResultType;JZZLjava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun observeQueue (Ljava/lang/String;)Lkotlinx/coroutines/flow/Flow; + public abstract fun requestJobCancellation (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun subscribeToJobCancellation (Ljava/lang/String;)Lkotlinx/coroutines/flow/Flow; + public abstract fun updateJobState (Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class com/copperleaf/ballast/queue/QueueDriver$Adapter { + public fun getDefaultRetryDelayTimeout-3nIYWDw (Ljava/lang/Object;I)J + public abstract fun getJobMetadata (Ljava/lang/Object;)Ljava/lang/Object; + public fun getJobTimeout-5sfh64U (Ljava/lang/Object;)J +} + +public final class com/copperleaf/ballast/queue/QueueDriver$Adapter$DefaultImpls { + public static fun getDefaultRetryDelayTimeout-3nIYWDw (Lcom/copperleaf/ballast/queue/QueueDriver$Adapter;Ljava/lang/Object;I)J + public static fun getJobTimeout-5sfh64U (Lcom/copperleaf/ballast/queue/QueueDriver$Adapter;Ljava/lang/Object;)J +} + +public final class com/copperleaf/ballast/queue/QueueDriver$DefaultImpls { + public static fun awaitShutdown (Lcom/copperleaf/ballast/queue/QueueDriver;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class com/copperleaf/ballast/queue/QueueExecutor { + public abstract fun insertJob (Ljava/lang/String;Ljava/lang/Object;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun runQueue (Ljava/lang/String;Lkotlin/jvm/functions/Function3;)Lkotlinx/coroutines/flow/Flow; +} + +public abstract interface class com/copperleaf/ballast/queue/QueueExecutor$Serializers { + public abstract fun deserializePayload (Ljava/lang/String;)Ljava/lang/Object; + public abstract fun deserializeResult (Ljava/lang/String;)Ljava/lang/Object; + public abstract fun deserializeState (Ljava/lang/String;)Ljava/lang/Object; + public abstract fun serializePayload (Ljava/lang/Object;)Ljava/lang/String; + public abstract fun serializeResult (Ljava/lang/Object;)Ljava/lang/String; + public abstract fun serializeState (Ljava/lang/Object;)Ljava/lang/String; +} + +public abstract interface class com/copperleaf/ballast/queue/QueueExecutorScope { + public abstract fun getCurrentState (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun setState (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class com/copperleaf/ballast/queue/QueueThrottle { + public abstract fun acquirePermit (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun awaitShutdown (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/copperleaf/ballast/queue/QueueThrottle$DefaultImpls { + public static fun awaitShutdown (Lcom/copperleaf/ballast/queue/QueueThrottle;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class com/copperleaf/ballast/queue/QueueThrottle$Permit { + public abstract fun release (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/copperleaf/ballast/queue/QueueUtilsKt { + public static final fun pollingFlow (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/flow/Flow; + public static final fun queueDriverPollingFlow (Ljava/lang/String;Lcom/copperleaf/ballast/queue/QueueThrottle;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/flow/Flow; +} + +public final class com/copperleaf/ballast/queue/SerializedJob { + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JLjava/lang/String;Ljava/lang/String;ILjava/lang/Object;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JLjava/lang/String;Ljava/lang/String;ILjava/lang/Object;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun component4-UwyO8pc ()J + public final fun component5 ()Ljava/lang/String; + public final fun component6 ()Ljava/lang/String; + public final fun component7 ()I + public final fun component8 ()Ljava/lang/Object; + public final fun copy-mGOUYlo (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JLjava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/copperleaf/ballast/queue/SerializedJob; + public static synthetic fun copy-mGOUYlo$default (Lcom/copperleaf/ballast/queue/SerializedJob;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JLjava/lang/String;Ljava/lang/String;ILjava/lang/Object;ILjava/lang/Object;)Lcom/copperleaf/ballast/queue/SerializedJob; + public fun equals (Ljava/lang/Object;)Z + public final fun getAttempts ()I + public final fun getJobId ()Ljava/lang/String; + public final fun getMetadata ()Ljava/lang/Object; + public final fun getQueueName ()Ljava/lang/String; + public final fun getSerializedPayload ()Ljava/lang/String; + public final fun getSerializedResultData ()Ljava/lang/String; + public final fun getSerializedState ()Ljava/lang/String; + public final fun getTimeoutDuration-UwyO8pc ()J + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/copperleaf/ballast/queue/driver/memory/InMemoryJobStatus : java/lang/Enum { + public static final field Completed Lcom/copperleaf/ballast/queue/driver/memory/InMemoryJobStatus; + public static final field Failed Lcom/copperleaf/ballast/queue/driver/memory/InMemoryJobStatus; + public static final field Pending Lcom/copperleaf/ballast/queue/driver/memory/InMemoryJobStatus; + public static final field Running Lcom/copperleaf/ballast/queue/driver/memory/InMemoryJobStatus; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/copperleaf/ballast/queue/driver/memory/InMemoryJobStatus; + public static fun values ()[Lcom/copperleaf/ballast/queue/driver/memory/InMemoryJobStatus; +} + +public final class com/copperleaf/ballast/queue/driver/memory/InMemoryQueueDriver : com/copperleaf/ballast/queue/QueueDriver { + public fun ()V + public fun (Lkotlin/time/Clock;Lcom/copperleaf/ballast/queue/QueueThrottle;)V + public synthetic fun (Lkotlin/time/Clock;Lcom/copperleaf/ballast/queue/QueueThrottle;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun addToQueue-gwCluXo (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JLcom/copperleaf/ballast/queue/driver/memory/InMemoryQueueDriver$Metadata;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public synthetic fun addToQueue-gwCluXo (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JLjava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun awaitShutdown (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun completeJobSuccessfully-WPwdCS8 (Ljava/lang/String;JLcom/copperleaf/ballast/queue/JobCompletionResultType;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun completeJobWithFailure-3c68mSE (Ljava/lang/String;JLcom/copperleaf/ballast/queue/JobCompletionResultType;JZZLjava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun observeJobState (Ljava/lang/String;)Lkotlinx/coroutines/flow/Flow; + public fun observeQueue (Ljava/lang/String;)Lkotlinx/coroutines/flow/Flow; + public final fun observeQueueState ()Lkotlinx/coroutines/flow/StateFlow; + public fun requestJobCancellation (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun subscribeToJobCancellation (Ljava/lang/String;)Lkotlinx/coroutines/flow/Flow; + public fun updateJobState (Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/copperleaf/ballast/queue/driver/memory/InMemoryQueueDriver$DefaultAdapter : com/copperleaf/ballast/queue/QueueDriver$Adapter { + public fun ()V + public fun (Lkotlin/time/Clock;)V + public synthetic fun (Lkotlin/time/Clock;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun getDefaultRetryDelayTimeout-3nIYWDw (Ljava/lang/Object;I)J + public fun getJobMetadata (Ljava/lang/Object;)Lcom/copperleaf/ballast/queue/driver/memory/InMemoryQueueDriver$Metadata; + public synthetic fun getJobMetadata (Ljava/lang/Object;)Ljava/lang/Object; + public fun getJobTimeout-5sfh64U (Ljava/lang/Object;)J +} + +public final class com/copperleaf/ballast/queue/driver/memory/InMemoryQueueDriver$Metadata { + public synthetic fun (Lkotlin/time/Instant;IILkotlin/time/Instant;Lcom/copperleaf/ballast/queue/driver/memory/InMemoryJobStatus;Lkotlin/time/Duration;Lcom/copperleaf/ballast/queue/JobCompletionResultType;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Lkotlin/time/Instant;IILkotlin/time/Instant;Lcom/copperleaf/ballast/queue/driver/memory/InMemoryJobStatus;Lkotlin/time/Duration;Lcom/copperleaf/ballast/queue/JobCompletionResultType;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lkotlin/time/Instant; + public final fun component2 ()I + public final fun component3 ()I + public final fun component4 ()Lkotlin/time/Instant; + public final fun component5 ()Lcom/copperleaf/ballast/queue/driver/memory/InMemoryJobStatus; + public final fun component6-FghU774 ()Lkotlin/time/Duration; + public final fun component7 ()Lcom/copperleaf/ballast/queue/JobCompletionResultType; + public final fun component8 ()Ljava/lang/String; + public final fun component9 ()Ljava/lang/String; + public final fun copy-ZfZE-DE (Lkotlin/time/Instant;IILkotlin/time/Instant;Lcom/copperleaf/ballast/queue/driver/memory/InMemoryJobStatus;Lkotlin/time/Duration;Lcom/copperleaf/ballast/queue/JobCompletionResultType;Ljava/lang/String;Ljava/lang/String;)Lcom/copperleaf/ballast/queue/driver/memory/InMemoryQueueDriver$Metadata; + public static synthetic fun copy-ZfZE-DE$default (Lcom/copperleaf/ballast/queue/driver/memory/InMemoryQueueDriver$Metadata;Lkotlin/time/Instant;IILkotlin/time/Instant;Lcom/copperleaf/ballast/queue/driver/memory/InMemoryJobStatus;Lkotlin/time/Duration;Lcom/copperleaf/ballast/queue/JobCompletionResultType;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/copperleaf/ballast/queue/driver/memory/InMemoryQueueDriver$Metadata; + public fun equals (Ljava/lang/Object;)Z + public final fun getInsertedAt ()Lkotlin/time/Instant; + public final fun getLastErrorMessage ()Ljava/lang/String; + public final fun getLastResultType ()Lcom/copperleaf/ballast/queue/JobCompletionResultType; + public final fun getLastRunDuration-FghU774 ()Lkotlin/time/Duration; + public final fun getLastStacktrace ()Ljava/lang/String; + public final fun getMaxAttempts ()I + public final fun getPriority ()I + public final fun getRunAt ()Lkotlin/time/Instant; + public final fun getStatus ()Lcom/copperleaf/ballast/queue/driver/memory/InMemoryJobStatus; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/copperleaf/ballast/queue/driver/sync/SyncQueueDriver : com/copperleaf/ballast/queue/QueueDriver { + public fun ()V + public synthetic fun addToQueue-gwCluXo (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JLjava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun addToQueue-gwCluXo (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JLkotlin/Unit;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun awaitShutdown (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun completeJobSuccessfully-WPwdCS8 (Ljava/lang/String;JLcom/copperleaf/ballast/queue/JobCompletionResultType;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun completeJobWithFailure-3c68mSE (Ljava/lang/String;JLcom/copperleaf/ballast/queue/JobCompletionResultType;JZZLjava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun getLastJob ()Lcom/copperleaf/ballast/queue/SerializedJob; + public final fun getLastJobFailureMessage ()Ljava/lang/String; + public final fun getLastJobResultData ()Ljava/lang/String; + public final fun getLastJobResultType ()Lcom/copperleaf/ballast/queue/JobCompletionResultType; + public fun observeQueue (Ljava/lang/String;)Lkotlinx/coroutines/flow/Flow; + public fun requestJobCancellation (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun subscribeToJobCancellation (Ljava/lang/String;)Lkotlinx/coroutines/flow/Flow; + public fun updateJobState (Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/copperleaf/ballast/queue/executor/DefaultQueueExecutor : com/copperleaf/ballast/queue/QueueExecutor { + public fun (Lcom/copperleaf/ballast/queue/QueueDriver;Lcom/copperleaf/ballast/queue/QueueDriver$Adapter;Lcom/copperleaf/ballast/queue/QueueExecutor$Serializers;ZLkotlin/time/TimeSource;)V + public synthetic fun (Lcom/copperleaf/ballast/queue/QueueDriver;Lcom/copperleaf/ballast/queue/QueueDriver$Adapter;Lcom/copperleaf/ballast/queue/QueueExecutor$Serializers;ZLkotlin/time/TimeSource;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun insertJob (Ljava/lang/String;Ljava/lang/Object;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun runQueue (Ljava/lang/String;Lkotlin/jvm/functions/Function3;)Lkotlinx/coroutines/flow/Flow; +} + +public final class com/copperleaf/ballast/queue/executor/JobFailureException : java/lang/RuntimeException { + public synthetic fun (Ljava/lang/Exception;Lkotlin/time/Duration;ZZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/lang/Exception;Lkotlin/time/Duration;ZZLkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getPermanentlyFail ()Z + public final fun getRetryDelay-FghU774 ()Lkotlin/time/Duration; + public final fun getSkipAttempt ()Z +} + +public final class com/copperleaf/ballast/queue/executor/JobProcessingResult { + public synthetic fun (Ljava/lang/String;JLcom/copperleaf/ballast/queue/JobCompletionResult;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2-UwyO8pc ()J + public final fun component3 ()Lcom/copperleaf/ballast/queue/JobCompletionResult; + public final fun copy-8Mi8wO0 (Ljava/lang/String;JLcom/copperleaf/ballast/queue/JobCompletionResult;)Lcom/copperleaf/ballast/queue/executor/JobProcessingResult; + public static synthetic fun copy-8Mi8wO0$default (Lcom/copperleaf/ballast/queue/executor/JobProcessingResult;Ljava/lang/String;JLcom/copperleaf/ballast/queue/JobCompletionResult;ILjava/lang/Object;)Lcom/copperleaf/ballast/queue/executor/JobProcessingResult; + public fun equals (Ljava/lang/Object;)Z + public final fun getJobId ()Ljava/lang/String; + public final fun getProcessingTime-UwyO8pc ()J + public final fun getResult ()Lcom/copperleaf/ballast/queue/JobCompletionResult; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/copperleaf/ballast/queue/executor/JsonSerializers : com/copperleaf/ballast/queue/QueueExecutor$Serializers { + public fun (Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/json/Json;)V + public synthetic fun (Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/json/Json;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun deserializePayload (Ljava/lang/String;)Ljava/lang/Object; + public fun deserializeResult (Ljava/lang/String;)Ljava/lang/Object; + public fun deserializeState (Ljava/lang/String;)Ljava/lang/Object; + public fun serializePayload (Ljava/lang/Object;)Ljava/lang/String; + public fun serializeResult (Ljava/lang/Object;)Ljava/lang/String; + public fun serializeState (Ljava/lang/Object;)Ljava/lang/String; +} + +public final class com/copperleaf/ballast/queue/throttle/CompositeThrottle : com/copperleaf/ballast/queue/QueueThrottle { + public fun ([Lcom/copperleaf/ballast/queue/QueueThrottle;)V + public fun acquirePermit (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun awaitShutdown (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/copperleaf/ballast/queue/throttle/ConcurrencyLimitThrottle : com/copperleaf/ballast/queue/QueueThrottle { + public fun (I)V + public fun acquirePermit (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun awaitShutdown (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/copperleaf/ballast/queue/throttle/PerQueueThrottle : com/copperleaf/ballast/queue/QueueThrottle { + public fun (Ljava/util/Map;Lcom/copperleaf/ballast/queue/QueueThrottle;)V + public fun acquirePermit (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun awaitShutdown (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/copperleaf/ballast/queue/throttle/TokenBucketThrottle : com/copperleaf/ballast/queue/QueueThrottle { + public synthetic fun (Lkotlinx/coroutines/CoroutineScope;IIJLkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun acquirePermit (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun awaitShutdown (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/copperleaf/ballast/queue/throttle/UnlimitedThrottle : com/copperleaf/ballast/queue/QueueThrottle { + public fun ()V + public fun acquirePermit (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun awaitShutdown (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + diff --git a/ballast-queue-core/build.gradle.kts b/ballast-queue-core/build.gradle.kts new file mode 100644 index 00000000..e9c92e5d --- /dev/null +++ b/ballast-queue-core/build.gradle.kts @@ -0,0 +1,42 @@ +plugins { + id("copper-leaf-base") + id("copper-leaf-android-library") + id("copper-leaf-targets") + id("copper-leaf-tests") + id("copper-leaf-lint") + id("copper-leaf-publish") + id("copper-leaf-serialization") +} + +kotlin { + compilerOptions { + optIn.add("kotlin.time.ExperimentalTime") + optIn.add("kotlin.uuid.ExperimentalUuidApi") + } + + sourceSets { + val commonMain by getting { + dependencies { + api(libs.kotlinx.coroutines.core) + api(libs.kotlinx.serialization.json) + } + } + val commonTest by getting { + dependencies { + api(libs.kotlinx.datetime) + } + } + val jvmMain by getting { + dependencies { } + } + val androidMain by getting { + dependencies { } + } + val jsMain by getting { + dependencies { } + } + val iosMain by getting { + dependencies { } + } + } +} diff --git a/ballast-queue-core/gradle.properties b/ballast-queue-core/gradle.properties new file mode 100644 index 00000000..3349a577 --- /dev/null +++ b/ballast-queue-core/gradle.properties @@ -0,0 +1,8 @@ +copperleaf.description=Run async, persistent job queues in Kotlin Multiplatform + +copperleaf.targets.android=true +copperleaf.targets.jvm=true +copperleaf.targets.ios=true +copperleaf.targets.js=true +copperleaf.targets.wasm.wasi=false +copperleaf.targets.wasm.js=true diff --git a/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/JobCompletionResult.kt b/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/JobCompletionResult.kt new file mode 100644 index 00000000..e7388960 --- /dev/null +++ b/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/JobCompletionResult.kt @@ -0,0 +1,44 @@ +@file:Suppress("UNCHECKED_CAST") + +package com.copperleaf.ballast.queue + +import kotlinx.coroutines.TimeoutCancellationException +import kotlin.time.Duration + +public sealed interface JobCompletionResult { + /** + * The job completed successfully. Store the result payload for later use, if needed. This job is a candidate for + * deletion from the queue. + */ + public data class Success( + val resultData: Result? + ) : JobCompletionResult + + /** + * The job was cancelled before processing completed. This job is a candidate for being retried according to the + * queue's retry policy. + */ + public data class Cancelled( + val retryDelay: Duration + ) : JobCompletionResult + + /** + * The job failed because it was processing for too long and was cancelled due to a timeout. This job is a candidate + * for being retried according to the queue's retry policy. + */ + public data class Timeout( + val cause: TimeoutCancellationException, + val retryDelay: Duration, + ) : JobCompletionResult + + /** + * The job failed abnormally due to an Exception thrown during processing. This job is a candidate for being retried + * according to the queue's retry policy. + */ + public data class Failure( + val cause: Exception, + val retryDelay: Duration, + val permanentlyFail: Boolean, + val skipAttempt: Boolean, + ) : JobCompletionResult +} diff --git a/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/JobCompletionResultType.kt b/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/JobCompletionResultType.kt new file mode 100644 index 00000000..0488e053 --- /dev/null +++ b/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/JobCompletionResultType.kt @@ -0,0 +1,29 @@ +@file:Suppress("UNCHECKED_CAST") + +package com.copperleaf.ballast.queue + +public enum class JobCompletionResultType { + /** + * The job completed successfully. Store the result payload for later use, if needed. This job is a candidate for + * deletion from the queue. + */ + Success, + + /** + * The job was cancelled before processing completed. This job is a candidate for being retried according to the + * queue's retry policy. + */ + Cancelled, + + /** + * The job failed because it was processing for too long and was cancelled due to a timeout. This job is a candidate + * for being retried according to the queue's retry policy. + */ + Timeout, + + /** + * The job failed abnormally due to an Exception thrown during processing. This job is a candidate for being retried + * according to the queue's retry policy. + */ + Failure, +} diff --git a/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/QueueDriver.kt b/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/QueueDriver.kt new file mode 100644 index 00000000..907358ab --- /dev/null +++ b/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/QueueDriver.kt @@ -0,0 +1,142 @@ +package com.copperleaf.ballast.queue + +import kotlinx.coroutines.flow.Flow +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +/** + * The QueueDriver interface defines the operations that a persistent job queue driver must implement in order to + * support enqueuing, processing, and tracking jobs. It is a low level API responsible only for the persistent storage + * of jobs and generating a Flow for processing those jobs. + */ +public interface QueueDriver { + +// Insert/Query Operations +// --------------------------------------------------------------------------------------------------------------------- + + /** + * Adds an item to the queue. All properties of a [SerializedJob] must be provided except for the ID, which will be + * generated by the driver and returned to the caller. The job is inserted into a specific queue by name. + * + * This function should return a String representation of the job's unique ID. + */ + public suspend fun addToQueue( + queueName: String, + serializedPayload: String, + serializedInitialState: String, + timeoutDuration: Duration, + metadata: JobMetadata, + ): String + + /** + * Observe a flow of jobs from the queue. Items emitted to the flow should not be removed from the queue, since its + * state of the job will be updated in-place. + */ + public fun observeQueue( + queueName: String, + ): Flow> + +// Job Processing State/Results +// --------------------------------------------------------------------------------------------------------------------- + + /** + * The job state was updated while processing the job. If a job is retried, the state will be maintained between attempts. + */ + public suspend fun updateJobState( + jobId: String, + serializedState: String, + ) + + /** + * The job ran to completion successfully. + */ + public suspend fun completeJobSuccessfully( + jobId: String, + processingTime: Duration, + resultType: JobCompletionResultType, + serializedResultData: String?, + ) + + /** + * The job failed to complete. The processing time and result are provided so the driver can update the job record + * appropriately, and optionally enqueue it for retry at a later time. + */ + public suspend fun completeJobWithFailure( + jobId: String, + processingTime: Duration, + resultType: JobCompletionResultType, + retryDelay: Duration, + permanentlyFail: Boolean, + skipAttempt: Boolean, + failureMessage: String?, + failureStacktrace: String?, + ) + +// Cancellation +// --------------------------------------------------------------------------------------------------------------------- + + /** + * Request job cancellation + */ + public suspend fun requestJobCancellation(jobId: String) + + /** + * Listen for events from the driver to know when a job was cancelled + */ + public fun subscribeToJobCancellation(jobId: String): Flow + +// Shutdown +// --------------------------------------------------------------------------------------------------------------------- + + /** + * Signal that the queue should shut down gracefully. Drivers that use a [QueueThrottle] should delegate to + * [QueueThrottle.awaitShutdown] so that all in-flight jobs are allowed to finish before this method returns. + */ + public suspend fun awaitShutdown() {} + + public interface Adapter< + JobMetadata : Any, + Payload : Any, + Result : Any, + State : Any, + > { + + /** + * Get the timeout duration for the given job payload. This is how long the job has to complete before it is + * forcibly cancelled and marked as a failure. It may be retried according to the driver's retry policy. + * + * This is called when inserting a new job into the queue. + */ + public fun getJobTimeout(payload: Payload): Duration { + return 30.seconds + } + + /** + * Convert the payload into job metadata to be stored alongside the job in the queue. The metadata is not used + * by the [QueueExecutor] itself, but is needed [QueueDriver] implementation to determine how and when to + * enqueue and dequeue the job. Common data the Driver might store in the Metadata includes things like: + * + * - Initial delay + * - Number of times the job has already run + * - Max number of retry attempts before marking the job as permanently failed + * - Timestamps for when the job was inserted, last attempted, next available run time, etc. + * + * This is called when inserting a new job into the queue. + */ + public fun getJobMetadata(payload: Payload): JobMetadata + + /** + * Called after a job failed and is being retried, to determine how long to wait before making the job + * available to run again. The default implementation returns 1 minute. The [metadata] can be used to apply + * custom retry backoff strategies based on the number of attempts or other data stored by the [QueueDriver]. + * + * Jobs may instead throw [com.copperleaf.ballast.queue.executor.JobFailureException] during processing to + * request a specific delay that was determined at runtime, rather than using this default value. That would be + * common in scenarios such as network rate-limiting, where the server response indicates how long to wait. + */ + public fun getDefaultRetryDelayTimeout(payload: Payload, attempts: Int): Duration { + return 1.minutes + } + } +} diff --git a/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/QueueExecutor.kt b/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/QueueExecutor.kt new file mode 100644 index 00000000..f1aaf9a3 --- /dev/null +++ b/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/QueueExecutor.kt @@ -0,0 +1,42 @@ +package com.copperleaf.ballast.queue + +import com.copperleaf.ballast.queue.executor.JobProcessingResult +import kotlinx.coroutines.flow.Flow + +/** + * A QueueExecutor is a higher-level of abstraction over a [QueueDriver], allowing you to use typed objects as your + * Jobs, which then get serialized/deserialized automatically as they are inserted into and pulled from the queue. + */ +public interface QueueExecutor< + JobMetadata : Any, + Payload : Any, + Result : Any, + State : Any, + > { + + public fun runQueue( + queueName: String, + processJob: suspend QueueExecutorScope.(Payload) -> Result? + ): Flow> + + public suspend fun insertJob( + queueName: String, + payload: Payload, + initialState: State, + ): String + + public interface Serializers< + Payload : Any, + Result : Any, + State : Any, + > { + public fun serializePayload(payload: Payload): String + public fun deserializePayload(serializedPayload: String): Payload + + public fun serializeResult(result: Result): String + public fun deserializeResult(serializedResult: String): Result + + public fun serializeState(state: State): String + public fun deserializeState(serializedState: String): State + } +} diff --git a/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/QueueExecutorScope.kt b/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/QueueExecutorScope.kt new file mode 100644 index 00000000..7c69efc9 --- /dev/null +++ b/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/QueueExecutorScope.kt @@ -0,0 +1,6 @@ +package com.copperleaf.ballast.queue + +public interface QueueExecutorScope { + public suspend fun getCurrentState(): State + public suspend fun setState(state: State) +} diff --git a/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/QueueThrottle.kt b/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/QueueThrottle.kt new file mode 100644 index 00000000..a985e647 --- /dev/null +++ b/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/QueueThrottle.kt @@ -0,0 +1,21 @@ +package com.copperleaf.ballast.queue + +public interface QueueThrottle { + + public suspend fun acquirePermit(queueName: String): Permit + + /** + * Signal that the queue is shutting down gracefully. Implementations must: + * 1. Immediately stop issuing new permits — future [acquirePermit] callers should suspend indefinitely (they will be + * cancelled when the queue scope is eventually torn down). + * 2. Suspend until all currently active permits have been released, i.e. every in-flight job has finished + * processing. + * + * After this method returns it is safe to proceed with the Ballast shutdown, as no jobs are actively running. + */ + public suspend fun awaitShutdown() {} + + public fun interface Permit { + public suspend fun release() + } +} diff --git a/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/SerializedJob.kt b/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/SerializedJob.kt new file mode 100644 index 00000000..c1b20fc8 --- /dev/null +++ b/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/SerializedJob.kt @@ -0,0 +1,64 @@ +@file:Suppress("UNCHECKED_CAST") + +package com.copperleaf.ballast.queue + +import kotlin.time.Duration + +public data class SerializedJob( + /** + * A unique ID identifying this job in the queue. If the job fails an is retried, it must retain the same ID. + */ + val jobId: String, + + /** + * The name of the queue this job belongs to. A "queue" is a logical grouping of jobs that a specific worker is + * responsible to processing. + */ + val queueName: String, + + /** + * The payload of the job inserted into the queue. This property is immutable, and must not change between retries. + */ + val serializedPayload: String, + + /** + * The maximum duration the job is allowed to run before it gets terminated. A timeout indicates a failure of the + * job, and it may be retried according to the queue's retry policy. + */ + val timeoutDuration: Duration, + + /** + * The state of the job may be updated during processing, and must be retained between retries. This property allows + * jobs to report progress to an observer, or maintain intermediate state between attempts so the job can be resumed + * from the middle rather than starting over from the beginning. + * + * For example, a job may batch-upload a large number of files to a remote server. The [serializedPayload] contains the + * list of files to be uploaded, and the [serializedState] contains the list of files that have been successfully + * uploaded. An observer can display the process percentage by comparing the two lists. And if the job fails or + * times out, when it is retried later, it will only need to upload the remaining files, not all files in the + * initial payload. + * + * This property is entirely controlled by the job processor; the queue driver must not interpret or modify its + * contents. + */ + val serializedState: String, + + /** + * The result of the job after processing the latest attempt. It typically will contain information tracked by the + * QueueExecutor about the outcome of the processing attempt, such as an error message, stacktrace, or result data. + */ + val serializedResultData: String?, + + /** + * The number of times this job has been attempted. This starts at 0, and in incremented by 1 each time it is run. + * The very first time a job is attempted, this will be 1. If it fails ad is retried, the first retry is 2, etc. + */ + val attempts: Int = 0, + + /** + * Arbitrary data about this job that the [QueueDriver] uses to manage the job in the queue and implement its own + * queuing policies. This data is expected to be irrelevant to the processing of the job itself, but may be needed + * to determine how and when to process or retry the job. + */ + val metadata: JobMetadata, +) diff --git a/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/driver/memory/InMemoryJobStatus.kt b/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/driver/memory/InMemoryJobStatus.kt new file mode 100644 index 00000000..e37e6bdc --- /dev/null +++ b/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/driver/memory/InMemoryJobStatus.kt @@ -0,0 +1,32 @@ +package com.copperleaf.ballast.queue.driver.memory + +import com.copperleaf.ballast.queue.QueueDriver + +public enum class InMemoryJobStatus { + + /** + * The job is inserted into the queue and is waiting to be processed. If a job failed during processed but is + * eligible to be retried, it will be moved back to the `Pending` state. + */ + Pending, + + /** + * The job has been selected for processing and is currently being worked on. + * + * It is possible for a job to be left in the `Running` state indefinitely if the worker processing it crashes or + * is terminated externally. Therefore, the [QueueDriver] must implement a way to detect and recover such jobs, by + * moving them back to the `Pending` or `Failed` state according to its retry policy. + */ + Running, + + /** + * The job has finished processing successfully. + */ + Completed, + + /** + * The job has failed during processing, and should be considered a permanent failure. It is not eligible for + * automatic retry, though it may be manually retried or inspected later. + */ + Failed, +} diff --git a/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/driver/memory/InMemoryQueueDriver.kt b/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/driver/memory/InMemoryQueueDriver.kt new file mode 100644 index 00000000..dd46bd0f --- /dev/null +++ b/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/driver/memory/InMemoryQueueDriver.kt @@ -0,0 +1,280 @@ +package com.copperleaf.ballast.queue.driver.memory + +import com.copperleaf.ballast.queue.JobCompletionResultType +import com.copperleaf.ballast.queue.QueueDriver +import com.copperleaf.ballast.queue.QueueThrottle +import com.copperleaf.ballast.queue.SerializedJob +import com.copperleaf.ballast.queue.queueDriverPollingFlow +import com.copperleaf.ballast.queue.throttle.UnlimitedThrottle +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant +import kotlin.uuid.Uuid + +/** + * The In-memory Queue Driver is a simple implementation of a [QueueDriver] that keeps all jobs in a list in memory, + * held in a [StateFlow] for observing the state of the queue and its jobs. This is primarily useful for testing and + * debugging, as its jobs are NOT persisted between application restarts. + * + * It fundamentally operates like a real queue, but with limited flexibility in scheduling, and no persistence. + * + * Supported features: + * + * - **Multiple queues**: separated by name + * - **Job prioritization**: The queue will always select the job with the highest priority to run next, delaying the + * execution of jobs with lower priority (even if they have an earlier start time). + * - **Scheduling**: Jobs can be delayed to run at a specific time in the future + * - **Retries**: Jobs that are cancelled or failed during processing will be scheduled for retry. The delay to wait + * between retries, and the number of times to retry a job, are configured per-job. + * - **Cancellation**: Jobs can be cancelled while running, and will be rescheduled for retry if they have remaining + * attempts. + */ +public class InMemoryQueueDriver( + private val clock: Clock = Clock.System, + private val throttle: QueueThrottle = UnlimitedThrottle(), +) : QueueDriver { + +// Types +// --------------------------------------------------------------------------------------------------------------------- + + public data class Metadata( + val insertedAt: Instant, + val maxAttempts: Int, + + val priority: Int = 0, + val runAt: Instant = insertedAt, + val status: InMemoryJobStatus = InMemoryJobStatus.Pending, + + val lastRunDuration: Duration? = null, + val lastResultType: JobCompletionResultType? = null, + val lastErrorMessage: String? = null, + val lastStacktrace: String? = null, + ) + + public class DefaultAdapter< + Payload : Any, + Result : Any, + State : Any, + >( + private val clock: Clock = Clock.System, + ) : QueueDriver.Adapter { + override fun getJobMetadata(payload: Payload): Metadata { + val now = clock.now() + return Metadata( + insertedAt = now, + maxAttempts = 5, + ) + } + } + +// Driver state +// --------------------------------------------------------------------------------------------------------------------- + + private val mutex = Mutex() + private val queue = MutableStateFlow(emptyList>()) + private val cancellations = MutableSharedFlow() + +// Insert/Query Operations +// --------------------------------------------------------------------------------------------------------------------- + + override suspend fun addToQueue( + queueName: String, + serializedPayload: String, + serializedInitialState: String, + timeoutDuration: Duration, + metadata: Metadata, + ): String { + return mutex.withLock { + val serializedJob = SerializedJob( + jobId = Uuid.random().toString(), + queueName = queueName, + timeoutDuration = timeoutDuration, + serializedPayload = serializedPayload, + serializedState = serializedInitialState, + serializedResultData = null, + metadata = metadata, + ) + queue.update { it + serializedJob } + serializedJob.jobId + } + } + + override fun observeQueue( + queueName: String, + ): Flow> { + return queueDriverPollingFlow( + queueName = queueName, + throttle = throttle, + pollNext = { pollNext(queueName) }, + awaitNext = { delay(1.seconds) }, + ) + } + + public fun observeQueueState(): StateFlow>> { + return queue.asStateFlow() + } + + public fun observeJobState(jobId: String): Flow> { + return queue.mapNotNull { + it.singleOrNull { job -> job.jobId == jobId } + } + } + + internal suspend fun pollNext( + queueName: String, + ): SerializedJob? { + return mutex.withLock { + val now = clock.now() + + val item = queue + .value + .asSequence() + .filter { it.queueName == queueName } + .filter { isReady(it, now) } + .sortedBy { it.metadata.insertedAt } // oldest first so equal-priority jobs are FIFO + .maxByOrNull { it.metadata.priority } + + if (item != null) { + updateJobNoLock(item.jobId) { + it.copy( + attempts = it.attempts + 1, + metadata = it.metadata.copy( + status = InMemoryJobStatus.Running, + ) + ) + } + } else { + null + } + } + } + + private fun isReady(item: SerializedJob, now: Instant): Boolean { + return item.metadata.status == InMemoryJobStatus.Pending && + item.metadata.runAt <= now + } + +// Job Processing State/Results +// --------------------------------------------------------------------------------------------------------------------- + + override suspend fun updateJobState( + jobId: String, + serializedState: String, + ) { + updateJob(jobId) { + it.copy(serializedState = serializedState) + } + } + + override suspend fun completeJobSuccessfully( + jobId: String, + processingTime: Duration, + resultType: JobCompletionResultType, + serializedResultData: String? + ) { + updateJob(jobId) { + it.copy( + serializedResultData = serializedResultData, + metadata = it.metadata.copy( + status = InMemoryJobStatus.Completed, + runAt = it.metadata.runAt, + lastRunDuration = processingTime, + lastResultType = resultType, + lastErrorMessage = null, + lastStacktrace = null, + ) + ) + } + } + + override suspend fun completeJobWithFailure( + jobId: String, + processingTime: Duration, + resultType: JobCompletionResultType, + retryDelay: Duration, + permanentlyFail: Boolean, + skipAttempt: Boolean, + failureMessage: String?, + failureStacktrace: String? + ) { + updateJob(jobId) { + val shouldRetry = it.attempts < it.metadata.maxAttempts && !permanentlyFail + + it.copy( + serializedResultData = null, + metadata = it.metadata.copy( + status = when (resultType) { + JobCompletionResultType.Success -> { + error("Cannot complete job with failure using Success result type") + } + + JobCompletionResultType.Cancelled, + JobCompletionResultType.Timeout, + JobCompletionResultType.Failure -> + if (shouldRetry) InMemoryJobStatus.Pending else InMemoryJobStatus.Failed + }, + runAt = if (shouldRetry) clock.now() + retryDelay else it.metadata.runAt, + maxAttempts = if (skipAttempt) it.metadata.maxAttempts + 1 else it.metadata.maxAttempts, + lastRunDuration = processingTime, + lastResultType = resultType, + lastErrorMessage = failureMessage, + lastStacktrace = failureStacktrace, + ) + ) + } + } + +// Shutdown +// --------------------------------------------------------------------------------------------------------------------- + + override suspend fun awaitShutdown() { + throttle.awaitShutdown() + } + + // Cancellation +// --------------------------------------------------------------------------------------------------------------------- + + override suspend fun requestJobCancellation(jobId: String) { + cancellations.emit(jobId) + } + + override fun subscribeToJobCancellation(jobId: String): Flow { + return cancellations.filter { it == jobId }.map { } + } + +// Utils +// --------------------------------------------------------------------------------------------------------------------- + + private suspend fun updateJob( + jobId: String, + transform: (SerializedJob) -> SerializedJob, + ): SerializedJob { + return mutex.withLock { + updateJobNoLock(jobId, transform) + } + } + + private fun updateJobNoLock( + jobId: String, + transform: (SerializedJob) -> SerializedJob, + ): SerializedJob { + val queueList = queue.value.toMutableList() + val index = queueList.indexOfFirst { it.jobId == jobId } + queueList[index] = transform(queueList[index]) + queue.value = queueList.toList() + return queue.value[index] + } +} diff --git a/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/driver/sync/SyncQueueDriver.kt b/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/driver/sync/SyncQueueDriver.kt new file mode 100644 index 00000000..3b96b4b6 --- /dev/null +++ b/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/driver/sync/SyncQueueDriver.kt @@ -0,0 +1,130 @@ +package com.copperleaf.ballast.queue.driver.sync + +import com.copperleaf.ballast.queue.JobCompletionResultType +import com.copperleaf.ballast.queue.QueueDriver +import com.copperleaf.ballast.queue.SerializedJob +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow +import kotlin.time.Duration +import kotlin.uuid.Uuid + +/** + * The Sync Queue Driver is a implementation of a [com.copperleaf.ballast.queue.QueueDriver] that is intended for unit testing. It does not + * actually kep a queue of jobs, but instead uses a [kotlinx.coroutines.channels.Channel.Factory.RENDEZVOUS] Channel to immediately process the job synchronously. + * This allows you to have guarantees in your unit tests that calling [addToQueue] will process the job before + * returning, as long as another coroutine is currently observing the queue. + * + * In general, this driver assumes that the job will complete successfully. It does not support tracking metadata about + * the job, so it cannot be queried for job status or results. + * + * This queue does not support the typical features of a persistent queue, such as retries, timeouts, or job state + * updates. It is only intended for unit tests where you need prompt guarantees of the job being processed in an + * end-to-end scenario. One example would be testing that an endpoint to Create a resource, then a background job posts + * the created ID to a separate fine-grained authorization service. A follow-up endpoint needs to be called to verify + * the permissions were created correctly, and that the resource is accessible. if the queue were asynchronous, you + * would need to introduce arbitrary delays or polling to verify the end state, which would make your tests slower and + * flaky. + */ +public class SyncQueueDriver() : QueueDriver { + + private val channel = Channel>(Channel.Factory.RENDEZVOUS) + + private var _lastJob: SerializedJob? = null + private var _lastJobResultType: JobCompletionResultType? = null + private var _lastJobResultData: String? = null + private var _lastJobFailureMessage: String? = null + + public val lastJob: SerializedJob? get() = _lastJob + public val lastJobResultType: JobCompletionResultType? get() = _lastJobResultType + public val lastJobResultData: String? get() = _lastJobResultData + public val lastJobFailureMessage: String? get() = _lastJobFailureMessage + +// Insert/Query Operations +// --------------------------------------------------------------------------------------------------------------------- + + override suspend fun addToQueue( + queueName: String, + serializedPayload: String, + serializedInitialState: String, + timeoutDuration: Duration, + metadata: Unit, + ): String { + val serializedJob = SerializedJob( + jobId = Uuid.Companion.random().toString(), + queueName = queueName, + timeoutDuration = timeoutDuration, + serializedPayload = serializedPayload, + serializedState = serializedInitialState, + serializedResultData = null, + attempts = 1, + metadata = metadata, + ) + + channel.send(serializedJob) + + return serializedJob.jobId + } + + override fun observeQueue( + queueName: String, + ): Flow> { + return channel + .receiveAsFlow() + .onEach { job -> + _lastJob = job + _lastJobResultType = null + _lastJobResultData = null + _lastJobFailureMessage = null + } + } + +// Job Processing State/Results +// --------------------------------------------------------------------------------------------------------------------- + + override suspend fun updateJobState( + jobId: String, + serializedState: String, + ) { + // no-op + } + + override suspend fun completeJobSuccessfully( + jobId: String, + processingTime: Duration, + resultType: JobCompletionResultType, + serializedResultData: String? + ) { + _lastJobResultType = resultType + _lastJobResultData = serializedResultData + _lastJobFailureMessage = null + } + + override suspend fun completeJobWithFailure( + jobId: String, + processingTime: Duration, + resultType: JobCompletionResultType, + retryDelay: Duration, + permanentlyFail: Boolean, + skipAttempt: Boolean, + failureMessage: String?, + failureStacktrace: String? + ) { + _lastJobResultType = resultType + _lastJobResultData = null + _lastJobFailureMessage = failureMessage + } + + // Cancellation +// --------------------------------------------------------------------------------------------------------------------- + + override suspend fun requestJobCancellation(jobId: String) { + throw NotImplementedError("Cancellation not supported") + } + + override fun subscribeToJobCancellation(jobId: String): Flow { + return emptyFlow() + } +} diff --git a/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/executor/DefaultQueueExecutor.kt b/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/executor/DefaultQueueExecutor.kt new file mode 100644 index 00000000..3e295236 --- /dev/null +++ b/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/executor/DefaultQueueExecutor.kt @@ -0,0 +1,266 @@ +package com.copperleaf.ballast.queue.executor + +import com.copperleaf.ballast.queue.JobCompletionResult +import com.copperleaf.ballast.queue.JobCompletionResultType +import com.copperleaf.ballast.queue.QueueDriver +import com.copperleaf.ballast.queue.QueueExecutor +import com.copperleaf.ballast.queue.QueueExecutorScope +import com.copperleaf.ballast.queue.SerializedJob +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import kotlin.time.TimeSource + +@OptIn(ExperimentalCoroutinesApi::class) +public class DefaultQueueExecutor< + JobMetadata : Any, + Payload : Any, + Result : Any, + State : Any, + >( + private val driver: QueueDriver, + private val adapter: QueueDriver.Adapter, + private val serializers: QueueExecutor.Serializers, + private val captureErrorStacktrace: Boolean = false, + private val timeSource: TimeSource = TimeSource.Monotonic, +) : QueueExecutor { + +// Run job queue Flow +// --------------------------------------------------------------------------------------------------------------------- + + override fun runQueue( + queueName: String, + processJob: suspend QueueExecutorScope.(Payload) -> Result? + ): Flow> { + return driver + .observeQueue(queueName) + .map { prepareJob(it) } // deserialize stored JSON to real object + .map { runJob(it, processJob) } // run the job on a coroutine, respecting timeouts, cancellation, etc. + .onEach { finalizeJob(it) } // convert result data back to JSON, then mark the job as completed or failed, or re-enqueue it for retry + } + + private fun prepareJob(job: SerializedJob): RunningJob { + // extract JSON payloads, then deserialize to proper objects + val payload = serializers.deserializePayload(job.serializedPayload) + val state = serializers.deserializeState(job.serializedState) + + return RunningJob( + jobId = job.jobId, + payload = payload, + state = state, + attempts = job.attempts, + metadata = job.metadata, + timeoutDuration = job.timeoutDuration, + ) + } + + private suspend fun runJob( + job: RunningJob, + processJob: suspend QueueExecutorScope.(Payload) -> Result? + ): JobProcessingResult = coroutineScope { + val mark = timeSource.markNow() + + // CompletableDeferred is used instead of a bare var so that concurrent writes from + // inputProcessorJob and cancellationJob are safe: complete() is a no-op after the + // first call, giving first-writer-wins semantics with no data race. + val result = CompletableDeferred>() + + val inputProcessorJob: Job = launch { + try { + // process the job with a timeout, respecting cancellation requests, and capturing intermediate state + val scope = QueueExecutorScopeImpl(driver, serializers::serializeState, job.jobId, job.state) + + val processingResult = withTimeout(job.timeoutDuration) { + with(scope) { + processJob(job.payload) + } + } + + result.complete( + JobProcessingResult( + jobId = job.jobId, + processingTime = mark.elapsedNow(), + result = JobCompletionResult.Success(processingResult), + ) + ) + } catch (e: TimeoutCancellationException) { + // job was cancelled due to timeout + result.complete( + JobProcessingResult( + jobId = job.jobId, + processingTime = mark.elapsedNow(), + result = JobCompletionResult.Timeout( + cause = e, + retryDelay = adapter.getDefaultRetryDelayTimeout(job.payload, job.attempts), + ), + ) + ) + } catch (e: JobFailureException) { + // job failed with a known failure which is requesting a specific delay + result.complete( + JobProcessingResult( + jobId = job.jobId, + processingTime = mark.elapsedNow(), + result = JobCompletionResult.Failure( + cause = (e.cause as? Exception?) ?: e, + retryDelay = e.retryDelay ?: adapter.getDefaultRetryDelayTimeout(job.payload, job.attempts), + permanentlyFail = e.permanentlyFail, + skipAttempt = e.skipAttempt, + ), + ) + ) + } catch (e: CancellationException) { + // cooperate with coroutine cancellation from the downstream collector + throw e + } catch (e: Exception) { + // job failed with an unknown exception + result.complete( + JobProcessingResult( + jobId = job.jobId, + processingTime = mark.elapsedNow(), + result = JobCompletionResult.Failure( + cause = e, + retryDelay = adapter.getDefaultRetryDelayTimeout(job.payload, job.attempts), + permanentlyFail = false, + skipAttempt = false, + ), + ) + ) + } + } + + val cancellationJob = launch { + driver + .subscribeToJobCancellation(job.jobId) + .onEach { + // complete() is a no-op if inputProcessorJob already set a result, ensuring + // the first outcome (job completion or cancellation signal) always wins. + result.complete( + JobProcessingResult( + jobId = job.jobId, + processingTime = mark.elapsedNow(), + result = JobCompletionResult.Cancelled( + retryDelay = adapter.getDefaultRetryDelayTimeout(job.payload, job.attempts) + ), + ) + ) + inputProcessorJob.cancel() + inputProcessorJob.join() + } + .launchIn(this) + } + + inputProcessorJob.join() + + // once the inputProcessorJob has completed, cancel the cancellationJob so we can exit this function + cancellationJob.cancel() + cancellationJob.join() + + // Safety net: if neither coroutine completed the result (e.g. inputProcessorJob was + // cancelled by an external scope rather than by the cancellationJob), treat as cancelled. + result.complete( + JobProcessingResult( + jobId = job.jobId, + processingTime = mark.elapsedNow(), + result = JobCompletionResult.Cancelled( + retryDelay = adapter.getDefaultRetryDelayTimeout(job.payload, job.attempts) + ), + ) + ) + + result.await() + } + + private suspend fun finalizeJob(result: JobProcessingResult): Result? { + when (result.result) { + is JobCompletionResult.Success -> { + driver.completeJobSuccessfully( + jobId = result.jobId, + processingTime = result.processingTime, + resultType = JobCompletionResultType.Success, + serializedResultData = if (result.result.resultData != null) { + // if the job completed with a result, serialize it and set it as the result + serializers.serializeResult(result.result.resultData) + } else { + null + }, + ) + return result.result.resultData + } + is JobCompletionResult.Cancelled -> { + driver.completeJobWithFailure( + jobId = result.jobId, + processingTime = result.processingTime, + resultType = JobCompletionResultType.Cancelled, + retryDelay = result.result.retryDelay, + permanentlyFail = false, + skipAttempt = false, + failureMessage = null, + failureStacktrace = null, + ) + return null + } + is JobCompletionResult.Timeout -> { + driver.completeJobWithFailure( + jobId = result.jobId, + processingTime = result.processingTime, + resultType = JobCompletionResultType.Timeout, + retryDelay = result.result.retryDelay, + permanentlyFail = false, + skipAttempt = false, + failureMessage = result.result.cause.message, + failureStacktrace = null + ) + return null + } + is JobCompletionResult.Failure -> { + driver.completeJobWithFailure( + jobId = result.jobId, + processingTime = result.processingTime, + resultType = JobCompletionResultType.Failure, + retryDelay = result.result.retryDelay, + permanentlyFail = result.result.permanentlyFail, + skipAttempt = result.result.skipAttempt, + failureMessage = result.result.cause.message, + failureStacktrace = if (captureErrorStacktrace) { + result.result.cause.stackTraceToString() + } else { + null + }, + ) + return null + } + } + } + +// Serialize and enqueue a job +// --------------------------------------------------------------------------------------------------------------------- + + override suspend fun insertJob( + queueName: String, + payload: Payload, + initialState: State, + ): String { + val serializedPayload = serializers.serializePayload(payload) + val serializedState = serializers.serializeState(initialState) + val timeout = adapter.getJobTimeout(payload) + val metadata = adapter.getJobMetadata(payload) + + return driver.addToQueue( + queueName = queueName, + serializedPayload = serializedPayload, + serializedInitialState = serializedState, + timeoutDuration = timeout, + metadata = metadata, + ) + } +} diff --git a/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/executor/JobFailureException.kt b/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/executor/JobFailureException.kt new file mode 100644 index 00000000..9287da7b --- /dev/null +++ b/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/executor/JobFailureException.kt @@ -0,0 +1,33 @@ +package com.copperleaf.ballast.queue.executor + +import kotlin.time.Duration + +public class JobFailureException( + cause: Exception?, + + /** + * If set, indicates that the job should be retried after this delay period if it has any attempts left. If null, + * the retry delay will be set by [com.copperleaf.ballast.queue.QueueDriver.Adapter.getDefaultRetryDelayTimeout]. + */ + public val retryDelay: Duration?, + + /** + * If true, indicates that the job should be marked as permanently failed immediately, without any further retries. + * Useful for scenarios where the runtime can detect that the job will never succeed so retries will only waste + * compute resources, such as invalid input data or environmental changes that render the job obsolete. + */ + public val permanentlyFail: Boolean = false, + + /** + * If true, indicates that the job should be considered skipped without consuming one of its retry attempts. In + * practice, it is up to the Driver to decide how to handle skipped jobs, but a common approach is to enqueue the + * job for retry just as if it failed, but granting one additional retry attempt so that the job's total number of + * retries is not reduced. + * + * This is useful for scenarios where the job cannot be processed at this time for some condition that cannot be + * known ahead of time, but can be detected at runtime. For example, a job that depends on an external resource + * that is currently unavailable, or a job requiring internet connectivity in mobile sync queues. By skipping the + * job, it can be retried later without penalizing the job's retry count. + */ + public val skipAttempt: Boolean = false, +) : RuntimeException(cause) diff --git a/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/executor/JobProcessingResult.kt b/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/executor/JobProcessingResult.kt new file mode 100644 index 00000000..255eb2f4 --- /dev/null +++ b/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/executor/JobProcessingResult.kt @@ -0,0 +1,10 @@ +package com.copperleaf.ballast.queue.executor + +import com.copperleaf.ballast.queue.JobCompletionResult +import kotlin.time.Duration + +public data class JobProcessingResult( + val jobId: String, + val processingTime: Duration, + val result: JobCompletionResult, +) diff --git a/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/executor/JsonSerializers.kt b/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/executor/JsonSerializers.kt new file mode 100644 index 00000000..efaa15e9 --- /dev/null +++ b/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/executor/JsonSerializers.kt @@ -0,0 +1,40 @@ +package com.copperleaf.ballast.queue.executor + +import com.copperleaf.ballast.queue.QueueExecutor +import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.Json + +public class JsonSerializers< + Payload : Any, + Result : Any, + State : Any, + >( + private val payloadSerializer: KSerializer, + private val resultSerializer: KSerializer, + private val stateSerializer: KSerializer, + private val json: Json = Json.Default, +) : QueueExecutor.Serializers { + override fun serializePayload(payload: Payload): String { + return json.encodeToString(payloadSerializer, payload) + } + + override fun deserializePayload(serializedPayload: String): Payload { + return json.decodeFromString(payloadSerializer, serializedPayload) + } + + override fun serializeResult(result: Result): String { + return json.encodeToString(resultSerializer, result) + } + + override fun deserializeResult(serializedResult: String): Result { + return json.decodeFromString(resultSerializer, serializedResult) + } + + override fun serializeState(state: State): String { + return json.encodeToString(stateSerializer, state) + } + + override fun deserializeState(serializedState: String): State { + return json.decodeFromString(stateSerializer, serializedState) + } +} diff --git a/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/executor/QueueExecutorScopeImpl.kt b/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/executor/QueueExecutorScopeImpl.kt new file mode 100644 index 00000000..3af90626 --- /dev/null +++ b/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/executor/QueueExecutorScopeImpl.kt @@ -0,0 +1,22 @@ +package com.copperleaf.ballast.queue.executor + +import com.copperleaf.ballast.queue.QueueDriver +import com.copperleaf.ballast.queue.QueueExecutorScope + +internal class QueueExecutorScopeImpl( + private val driver: QueueDriver, + private val stateSerializer: (State) -> String, + private val jobId: String, + initialState: State, +) : QueueExecutorScope { + private var currentState: State = initialState + + override suspend fun getCurrentState(): State { + return currentState + } + + override suspend fun setState(state: State) { + driver.updateJobState(jobId, stateSerializer(state)) + currentState = state + } +} diff --git a/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/executor/RunningJob.kt b/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/executor/RunningJob.kt new file mode 100644 index 00000000..c6a95447 --- /dev/null +++ b/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/executor/RunningJob.kt @@ -0,0 +1,12 @@ +package com.copperleaf.ballast.queue.executor + +import kotlin.time.Duration + +internal data class RunningJob( + val jobId: String, + val attempts: Int, + val payload: Payload, + val state: State, + val metadata: JobMetadata, + val timeoutDuration: Duration, +) diff --git a/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/queueUtils.kt b/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/queueUtils.kt new file mode 100644 index 00000000..35a59bf9 --- /dev/null +++ b/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/queueUtils.kt @@ -0,0 +1,57 @@ +package com.copperleaf.ballast.queue + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +public inline fun pollingFlow( + crossinline pollNext: suspend () -> T?, + crossinline awaitNext: suspend (emptyPollCount: Int) -> Unit, +): Flow = flow { + var emptyPollCount = 0 + while (true) { + val next = pollNext() + + if (next != null) { + emit(next) + emptyPollCount = 0 + } else { + emptyPollCount++ + awaitNext(emptyPollCount) + } + } +} + +public inline fun queueDriverPollingFlow( + queueName: String, + throttle: QueueThrottle, + crossinline pollNext: suspend () -> T?, + crossinline awaitNext: suspend (emptyPollCount: Int) -> Unit, +): Flow = flow { + var emptyPollCount = 0 + while (true) { + // suspends until a permit is available, ensuring this worker doesn't poll jobs too quickly + val permit = throttle.acquirePermit(queueName) + + // check the queue to see if a new job is available and ready for processing + val next = pollNext() + + if (next != null) { + // emit the job downstream for processing, suspending until processing is complete. + // The permit is released in a finally block to guarantee it is always released even + // if the collecting coroutine is cancelled while the job is in flight. + try { + emit(next) + } finally { + permit.release() + } + emptyPollCount = 0 + } else { + // release the permit, allowing the throttle to issue another permit + permit.release() + + // with no job available and no pending permit, delay the worker polling to avoid busy-looping + emptyPollCount++ + awaitNext(emptyPollCount) + } + } +} diff --git a/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/throttle/CompositeThrottle.kt b/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/throttle/CompositeThrottle.kt new file mode 100644 index 00000000..1198918b --- /dev/null +++ b/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/throttle/CompositeThrottle.kt @@ -0,0 +1,35 @@ +package com.copperleaf.ballast.queue.throttle + +import com.copperleaf.ballast.queue.QueueThrottle +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch + +/** + * A [QueueThrottle] implementation that requires a worker to satisfy all the provided throttle [policies] before + * issuing its own permit to the worker. When this permit is released, all the underlying permits acquired from each + * policy are also released. + * + * This throttle will wait for each underlying policy in parallel using the [async]/[awaitAll] pattern to acquire + * each individual permit. The total wait time is not additive; it will be the greatest wait time of any individual + * policy. + */ +public class CompositeThrottle( + private vararg val policies: QueueThrottle +) : QueueThrottle { + + override suspend fun acquirePermit(queueName: String): QueueThrottle.Permit = coroutineScope { + val permits = policies + .map { async { it.acquirePermit(queueName) } } + .awaitAll() + + QueueThrottle.Permit { + permits.forEach { it.release() } + } + } + + override suspend fun awaitShutdown(): Unit = coroutineScope { + policies.forEach { policy -> launch { policy.awaitShutdown() } } + } +} diff --git a/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/throttle/ConcurrencyLimitThrottle.kt b/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/throttle/ConcurrencyLimitThrottle.kt new file mode 100644 index 00000000..69b236b6 --- /dev/null +++ b/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/throttle/ConcurrencyLimitThrottle.kt @@ -0,0 +1,54 @@ +package com.copperleaf.ballast.queue.throttle + +import com.copperleaf.ballast.queue.QueueThrottle +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.sync.Semaphore +import kotlin.concurrent.Volatile + +/** + * A [QueueThrottle] implementation that limits the total number of active jobs across all queues to + * [maxConcurrentJobs]. This allows you to safely run multiple redundant workers for each queue, but limiting the + * overall concurrency of the whole system to avoid overwhelming your process. + * + * As an example, you may have a system with three queues: "high", "default", and "low" priority. Each queue has 4 + * workers running in parallel, but we want to limit the total system load to 4 jobs at a time. Thus, you could end up + * with a scenario where all 4 "high" priority jobs are running, and the "default" and "low" priority queues are + * waiting for permits to become available. Or alternatively, 2 "high", 1 "default", and 1 "low", etc. + * + * In general, the max concurrency should at least the max number of workers for any given queue, to ensure all workers + * are actually able to be utilized if needed. If the concurrency limit is lower than the number of workers for a given + * queue, at least 1 worker will always be idle, and thus simply wasting system resources. + */ +public class ConcurrencyLimitThrottle( + private val maxConcurrentJobs: Int +) : QueueThrottle { + + private val semaphore = Semaphore(maxConcurrentJobs) + + @Volatile + private var shuttingDown = false + + override suspend fun acquirePermit(queueName: String): QueueThrottle.Permit { + if (shuttingDown) awaitCancellation() + + semaphore.acquire() + + // Double-check after potentially blocking on the semaphore. + if (shuttingDown) { + semaphore.release() + awaitCancellation() + } + + return QueueThrottle.Permit { + semaphore.release() + } + } + + override suspend fun awaitShutdown() { + shuttingDown = true + // Acquire every permit in the semaphore. The idle slots are grabbed immediately; slots held by active + // workers become available one-by-one as each job completes. Once we hold all maxConcurrentJobs permits + // we know every in-flight job has finished. + repeat(maxConcurrentJobs) { semaphore.acquire() } + } +} diff --git a/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/throttle/PerQueueThrottle.kt b/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/throttle/PerQueueThrottle.kt new file mode 100644 index 00000000..6d9a0bad --- /dev/null +++ b/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/throttle/PerQueueThrottle.kt @@ -0,0 +1,27 @@ +package com.copperleaf.ballast.queue.throttle + +import com.copperleaf.ballast.queue.QueueThrottle +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch + +/** + * A [QueueThrottle] implementation that applies different throttling [policies] based on the queue name provided + * when acquiring a permit. If no specific policy is found for the given queue name, the [default] policy is applied. + */ +public class PerQueueThrottle( + private val policies: Map, + private val default: QueueThrottle +) : QueueThrottle { + + override suspend fun acquirePermit(queueName: String): QueueThrottle.Permit { + val policy = policies[queueName] ?: default + return policy.acquirePermit(queueName) + } + + override suspend fun awaitShutdown(): Unit = coroutineScope { + // Deduplicate by identity in case the same instance appears in both the map and as the default. + (policies.values + default).toSet().forEach { policy -> + launch { policy.awaitShutdown() } + } + } +} diff --git a/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/throttle/TokenBucketThrottle.kt b/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/throttle/TokenBucketThrottle.kt new file mode 100644 index 00000000..48fb9c47 --- /dev/null +++ b/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/throttle/TokenBucketThrottle.kt @@ -0,0 +1,94 @@ +package com.copperleaf.ballast.queue.throttle + +import com.copperleaf.ballast.queue.QueueThrottle +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.coroutines.cancellation.CancellationException +import kotlin.math.min +import kotlin.time.Duration + +/** + * A [QueueThrottle] implementation that uses the token bucket algorithm to limit the rate of work processing. + * + * The bucket has a maximum [capacity] of tokens. Tokens are added to the bucket at a rate of [refillRatePerTick] + * every [tickDuration]. When a worker wants to process work, it must acquire a token from the bucket. If no tokens + * are available, the worker will suspend until a token becomes available. + * + * Conceptually, you can imagine a bucket slowly filling with water from a tap (tokens). When a worker wants to process + * work, it takes out a cup of water (a single token). If the bucket is empty, the worker must wait until enough water + * fills the bucket to fill its cup. + * + * This implementation uses a [CoroutineScope] to launch a coroutine immediately upon creation that refills the bucket + * at the specified rate. + */ +public class TokenBucketThrottle( + scope: CoroutineScope, + capacity: Int, + refillRatePerTick: Int, + tickDuration: Duration, +) : QueueThrottle { + + private val tokens = MutableStateFlow(capacity) + + private val mutex = Mutex() + private var shuttingDown = false + private val activePermits = MutableStateFlow(0) + + init { + scope.launch { + while (isActive) { + delay(tickDuration) + tokens.update { current -> + min(capacity, current + refillRatePerTick) + } + } + } + } + + override suspend fun acquirePermit(queueName: String): QueueThrottle.Permit { + // Atomically claim an active slot before suspending on the token wait. This ensures awaitShutdown() cannot + // return a count of zero while a worker is mid-acquisition waiting for a token. + val permit = mutex.withLock { + if (shuttingDown) { + null + } else { + activePermits.update { it + 1 } + QueueThrottle.Permit { + activePermits.update { it - 1 } + } + } + } + if (permit == null) awaitCancellation() + + // Wait for the bucket to fill up enough to take a token. Wrap in try/catch so that + // if this coroutine is cancelled while waiting, the active slot claimed above is + // released and awaitShutdown() is not left hanging. + try { + // Atomically wait for and consume a token using a CAS loop to avoid racing with + // concurrent workers that may observe the same positive count. + while (true) { + val current = tokens.value + if (current > 0 && tokens.compareAndSet(current, current - 1)) break + if (current <= 0) tokens.first { it > 0 } + } + } catch (e: CancellationException) { + activePermits.update { it - 1 } + throw e + } + + return permit + } + + override suspend fun awaitShutdown() { + mutex.withLock { shuttingDown = true } + activePermits.first { it == 0 } + } +} diff --git a/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/throttle/UnlimitedThrottle.kt b/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/throttle/UnlimitedThrottle.kt new file mode 100644 index 00000000..e2ff0bcc --- /dev/null +++ b/ballast-queue-core/src/commonMain/kotlin/com/copperleaf/ballast/queue/throttle/UnlimitedThrottle.kt @@ -0,0 +1,42 @@ +package com.copperleaf.ballast.queue.throttle + +import com.copperleaf.ballast.queue.QueueThrottle +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * A default [QueueThrottle] implementation that imposes no throttling at all, issuing permits immediately upon + * request. An internal counter tracks how many permits are currently active so that [awaitShutdown] can wait until + * all in-flight jobs have finished before returning. + */ +public class UnlimitedThrottle : QueueThrottle { + + private val mutex = Mutex() + private var shuttingDown = false + private val activePermits = MutableStateFlow(0) + + override suspend fun acquirePermit(queueName: String): QueueThrottle.Permit { + // Atomically check the shutdown flag and claim a slot. Returns null when shutting down so we can + // suspend outside the lock without holding it indefinitely. + val permit = mutex.withLock { + if (shuttingDown) { + null + } else { + activePermits.update { it + 1 } + QueueThrottle.Permit { + activePermits.update { it - 1 } + } + } + } + return permit ?: awaitCancellation() + } + + override suspend fun awaitShutdown() { + mutex.withLock { shuttingDown = true } + activePermits.first { it == 0 } + } +} diff --git a/ballast-queue-core/src/commonTest/kotlin/com/copperleaf/ballast/queue/TestClock.kt b/ballast-queue-core/src/commonTest/kotlin/com/copperleaf/ballast/queue/TestClock.kt new file mode 100644 index 00000000..273d78cc --- /dev/null +++ b/ballast-queue-core/src/commonTest/kotlin/com/copperleaf/ballast/queue/TestClock.kt @@ -0,0 +1,24 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.copperleaf.ballast.scheduler + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.currentTime +import kotlin.time.Clock +import kotlin.time.Instant + +private class TestScopeClock(private val testScope: TestScope) : Clock { + override fun now(): Instant { + return Instant.fromEpochMilliseconds(testScope.currentTime) + } +} + +fun TestScope.TestClock(startInstant: Instant? = null): Clock { + val clock = TestScopeClock(this) + startInstant?.let { + advanceTimeBy(startInstant.toEpochMilliseconds()) + } + return clock +} diff --git a/ballast-queue-core/src/commonTest/kotlin/com/copperleaf/ballast/queue/driver/InMemoryQueueDriverTest.kt b/ballast-queue-core/src/commonTest/kotlin/com/copperleaf/ballast/queue/driver/InMemoryQueueDriverTest.kt new file mode 100644 index 00000000..7c7f4fbe --- /dev/null +++ b/ballast-queue-core/src/commonTest/kotlin/com/copperleaf/ballast/queue/driver/InMemoryQueueDriverTest.kt @@ -0,0 +1,200 @@ +package com.copperleaf.ballast.queue.driver + +import com.copperleaf.ballast.queue.JobCompletionResultType +import com.copperleaf.ballast.queue.driver.memory.InMemoryJobStatus +import com.copperleaf.ballast.queue.driver.memory.InMemoryQueueDriver +import com.copperleaf.ballast.scheduler.TestClock +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +class InMemoryQueueDriverTest { + + @Test + fun enqueueAndPollNext() = runTest { + val timezone = TimeZone.UTC + val startInstant = LocalDate(2025, 1, 1).atStartOfDayIn(timezone) + val clock = TestClock(startInstant) + val driver = InMemoryQueueDriver(clock) + + val uuid = driver.addToQueue( + queueName = "one", + serializedPayload = "{}", + serializedInitialState = "{}", + timeoutDuration = 30.seconds, + metadata = InMemoryQueueDriver.Metadata( + insertedAt = clock.now(), + priority = 0, + runAt = clock.now() + 1.minutes, + maxAttempts = 5, + lastRunDuration = null, + ) + ) + + // no jobs ready yet, since runAt is in the future + driver.pollNext("one").let { + assertNull(it) + } + driver.observeJobState(uuid).firstOrNull().let { + assertNotNull(it) + assertEquals( + actual = it.metadata.status, + expected = InMemoryJobStatus.Pending, + ) + } + + advanceTimeBy(2.minutes) + + // the job is ready, but only in the intended Queue + driver.pollNext("two").let { + assertNull(it) + } + driver.pollNext("one").let { + assertNotNull(it) + } + + // because we received the job from observeQueue(), its status is now Running + driver.observeJobState(uuid).firstOrNull().let { + assertNotNull(it) + assertEquals( + actual = it.metadata.status, + expected = InMemoryJobStatus.Running, + ) + } + } + + @Test + fun failJobAndRetry() = runTest { + val timezone = TimeZone.UTC + val startInstant = LocalDate(2025, 1, 1).atStartOfDayIn(timezone) + val clock = TestClock(startInstant) + val driver = InMemoryQueueDriver(clock) + + val uuid = driver.addToQueue( + queueName = "one", + serializedPayload = "{}", + serializedInitialState = "{}", + timeoutDuration = 30.seconds, + metadata = InMemoryQueueDriver.Metadata( + insertedAt = clock.now(), + priority = 0, + runAt = clock.now(), + maxAttempts = 5, + lastRunDuration = null, + ) + ) + + assertNotNull(driver.pollNext("one")) + + // because we received the job from observeQueue(), its status is now Running + assertEquals( + actual = driver.observeJobState(uuid).firstOrNull()?.metadata?.status, + expected = InMemoryJobStatus.Running, + ) + + // mark job completion as a failure + driver.completeJobWithFailure( + jobId = uuid, + processingTime = 5.seconds, + resultType = JobCompletionResultType.Failure, + retryDelay = 30.seconds, + permanentlyFail = false, + failureMessage = "testError", + failureStacktrace = null, + skipAttempt = false, + ) + + // job gets re-enqueued because it still had retries left + driver.observeJobState(uuid).firstOrNull().let { + assertEquals( + actual = it?.metadata?.status, + expected = InMemoryJobStatus.Pending, + ) + assertEquals( + actual = it?.metadata?.lastRunDuration, + expected = 5.seconds, + ) + assertEquals( + actual = it?.serializedResultData, + expected = null, + ) + assertEquals( + actual = it?.metadata?.lastErrorMessage, + expected = "testError", + ) + } + } + + @Test + fun failJobAndPermanentlyFail() = runTest { + val timezone = TimeZone.UTC + val startInstant = LocalDate(2025, 1, 1).atStartOfDayIn(timezone) + val clock = TestClock(startInstant) + val driver = InMemoryQueueDriver(clock) + + val uuid = driver.addToQueue( + queueName = "one", + serializedPayload = "{}", + serializedInitialState = "{}", + timeoutDuration = 30.seconds, + metadata = InMemoryQueueDriver.Metadata( + insertedAt = clock.now(), + priority = 0, + runAt = clock.now(), + maxAttempts = 1, + lastRunDuration = null, + ) + ) + + assertNotNull(driver.pollNext("one")) + + // because we received the job from observeQueue(), its status is now Running + assertEquals( + actual = driver.observeJobState(uuid).firstOrNull()?.metadata?.status, + expected = InMemoryJobStatus.Running, + ) + + // mark job completion as a failure + driver.completeJobWithFailure( + jobId = uuid, + processingTime = 5.seconds, + resultType = JobCompletionResultType.Failure, + retryDelay = 30.seconds, + permanentlyFail = false, + failureMessage = "testError", + failureStacktrace = null, + skipAttempt = false, + ) + + // job gets marked as Failed because it was on its last retry + driver.observeJobState(uuid).firstOrNull().let { + assertEquals( + actual = it?.metadata?.status, + expected = InMemoryJobStatus.Failed, + ) + assertEquals( + actual = it?.metadata?.lastRunDuration, + expected = 5.seconds, + ) + assertEquals( + actual = it?.serializedResultData, + expected = null, + ) + assertEquals( + actual = it?.metadata?.lastErrorMessage, + expected = "testError", + ) + } + } +} diff --git a/ballast-queue-core/src/commonTest/kotlin/com/copperleaf/ballast/queue/executor/DefaultQueueExecutorTest.kt b/ballast-queue-core/src/commonTest/kotlin/com/copperleaf/ballast/queue/executor/DefaultQueueExecutorTest.kt new file mode 100644 index 00000000..d7718d87 --- /dev/null +++ b/ballast-queue-core/src/commonTest/kotlin/com/copperleaf/ballast/queue/executor/DefaultQueueExecutorTest.kt @@ -0,0 +1,514 @@ +package com.copperleaf.ballast.queue.executor + +import com.copperleaf.ballast.queue.JobCompletionResultType +import com.copperleaf.ballast.queue.QueueDriver +import com.copperleaf.ballast.queue.QueueExecutorScope +import com.copperleaf.ballast.queue.SerializedJob +import com.copperleaf.ballast.queue.driver.memory.InMemoryJobStatus +import com.copperleaf.ballast.queue.driver.memory.InMemoryQueueDriver +import com.copperleaf.ballast.scheduler.TestClock +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.asTimeSource +import kotlinx.datetime.atStartOfDayIn +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +@Suppress("DEPRECATION") +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultQueueExecutorTest { + + @Serializable + data class TestPayload(val data: String) + + @Serializable + data class TestState(val step: Int = 0) + + @Serializable + data class TestResult(val resultData: String) + + private class TestAdapter( + private val clock: Clock, + ) : QueueDriver.Adapter { + override fun getJobTimeout(payload: TestPayload): Duration { + return 30.seconds + } + + override fun getJobMetadata(payload: TestPayload): InMemoryQueueDriver.Metadata { + return InMemoryQueueDriver.Metadata( + insertedAt = clock.now(), + maxAttempts = 5, + ) + } + + override fun getDefaultRetryDelayTimeout( + payload: TestPayload, + attempts: Int, + ): Duration { + return 60.seconds + } + } + + val serializers = JsonSerializers(TestPayload.serializer(), TestResult.serializer(), TestState.serializer()) + + @Test + fun insertJob() = runTest { + val timezone = TimeZone.Companion.UTC + val startInstant = LocalDate(2025, 1, 1).atStartOfDayIn(timezone) + val clock = TestClock(startInstant) + val driver = InMemoryQueueDriver(clock) + val executor = DefaultQueueExecutor( + driver = driver, + adapter = TestAdapter(clock), + serializers = serializers, + captureErrorStacktrace = false, + timeSource = clock.asTimeSource(), + ) + + val uuid = executor.insertJob("one", TestPayload("ballast"), TestState()) + + assertEquals( + actual = driver.observeJobState(uuid).firstOrNull(), + expected = SerializedJob( + jobId = uuid, + queueName = "one", + serializedPayload = buildJsonObject { put("data", "ballast") }.toString(), + timeoutDuration = 30.seconds, + serializedState = "{}", + serializedResultData = null, + attempts = 0, + metadata = InMemoryQueueDriver.Metadata( + status = InMemoryJobStatus.Pending, + insertedAt = startInstant, + priority = 0, + runAt = startInstant, + maxAttempts = 5, + lastRunDuration = null, + ), + ), + ) + } + + @Test + fun processing_success() = runTest { + val timezone = TimeZone.Companion.UTC + val startInstant = LocalDate(2025, 1, 1).atStartOfDayIn(timezone) + val clock = TestClock(startInstant) + val driver = InMemoryQueueDriver(clock) + val executor = DefaultQueueExecutor( + driver = driver, + adapter = TestAdapter(clock), + serializers = serializers, + captureErrorStacktrace = false, + timeSource = clock.asTimeSource(), + ) + + val uuid = executor.insertJob("one", TestPayload("ballast"), TestState()) + + executor + .runQueue("one") { payload -> TestResult(payload.data.uppercase()) } + .first() + + val jobState = driver.observeJobState(uuid).firstOrNull() + + assertEquals( + actual = jobState, + expected = SerializedJob( + jobId = uuid, + queueName = "one", + serializedPayload = buildJsonObject { put("data", "ballast") }.toString(), + timeoutDuration = 30.seconds, + serializedState = "{}", + serializedResultData = buildJsonObject { + put("resultData", "BALLAST") + }.toString(), + attempts = 1, + metadata = InMemoryQueueDriver.Metadata( + status = InMemoryJobStatus.Completed, + insertedAt = startInstant, + priority = 0, + runAt = startInstant, + maxAttempts = 5, + lastRunDuration = Duration.Companion.ZERO, + lastErrorMessage = null, + lastResultType = JobCompletionResultType.Success, + ), + ), + ) + } + + @Test + fun processing_cancellation() = runTest { + val timezone = TimeZone.Companion.UTC + val startInstant = LocalDate(2025, 1, 1).atStartOfDayIn(timezone) + val clock = TestClock(startInstant) + val driver = InMemoryQueueDriver(clock) + val executor = DefaultQueueExecutor( + driver = driver, + adapter = TestAdapter(clock), + serializers = serializers, + captureErrorStacktrace = false, + timeSource = clock.asTimeSource(), + ) + + val uuid = executor.insertJob("one", TestPayload("ballast"), TestState()) + + launch { + delay(10.seconds) + driver.requestJobCancellation(uuid) + } + + executor + .runQueue("one") { payload -> + delay(20.seconds) + TestResult(payload.data.uppercase()) + } + .first() + + val jobState = driver.observeJobState(uuid).firstOrNull() + + assertEquals( + actual = jobState, + expected = SerializedJob( + jobId = uuid, + queueName = "one", + serializedPayload = buildJsonObject { put("data", "ballast") }.toString(), + timeoutDuration = 30.seconds, + serializedState = "{}", + serializedResultData = null, + attempts = 1, + metadata = InMemoryQueueDriver.Metadata( + status = InMemoryJobStatus.Pending, + insertedAt = startInstant, + priority = 0, + runAt = startInstant + 70.seconds, // time until cancellation + retry delay + maxAttempts = 5, + lastRunDuration = 10.seconds, + lastErrorMessage = null, + lastResultType = JobCompletionResultType.Cancelled, + ), + ), + ) + } + + @Test + fun processing_timeout() = runTest { + val timezone = TimeZone.Companion.UTC + val startInstant = LocalDate(2025, 1, 1).atStartOfDayIn(timezone) + val clock = TestClock(startInstant) + val driver = InMemoryQueueDriver(clock) + val executor = DefaultQueueExecutor( + driver = driver, + adapter = TestAdapter(clock), + serializers = serializers, + captureErrorStacktrace = false, + timeSource = clock.asTimeSource(), + ) + + val uuid = executor.insertJob("one", TestPayload("ballast"), TestState()) + + executor + .runQueue("one") { payload -> + delay(1.minutes) + TestResult(payload.data.uppercase()) + } + .first() + + val jobState = driver.observeJobState(uuid).firstOrNull() + + assertEquals( + actual = jobState, + expected = SerializedJob( + jobId = uuid, + queueName = "one", + serializedPayload = buildJsonObject { put("data", "ballast") }.toString(), + timeoutDuration = 30.seconds, + serializedState = "{}", + serializedResultData = null, + attempts = 1, + metadata = InMemoryQueueDriver.Metadata( + status = InMemoryJobStatus.Pending, + insertedAt = startInstant, + priority = 0, + runAt = startInstant + 90.seconds, // the time for the timeout + retry delay + maxAttempts = 5, + lastRunDuration = 30.seconds, + lastErrorMessage = "Timed out after 30s of _virtual_ (kotlinx.coroutines.test) time. To use the real time, wrap 'withTimeout' in 'withContext(Dispatchers.Default.limitedParallelism(1))'", + lastResultType = JobCompletionResultType.Timeout, + ), + ), + ) + } + + @Test + fun processing_normalFailure() = runTest { + val timezone = TimeZone.Companion.UTC + val startInstant = LocalDate(2025, 1, 1).atStartOfDayIn(timezone) + val clock = TestClock(startInstant) + val driver = InMemoryQueueDriver(clock) + val executor = DefaultQueueExecutor( + driver = driver, + adapter = TestAdapter(clock), + serializers = serializers, + captureErrorStacktrace = false, + timeSource = clock.asTimeSource(), + ) + + val uuid = executor.insertJob("one", TestPayload("ballast"), TestState()) + + executor + .runQueue("one") { payload -> + throw JobFailureException(RuntimeException("normal error"), 45.seconds) + } + .first() + + val jobState = driver.observeJobState(uuid).firstOrNull() + + assertEquals( + actual = jobState, + expected = SerializedJob( + jobId = uuid, + queueName = "one", + serializedPayload = buildJsonObject { put("data", "ballast") }.toString(), + timeoutDuration = 30.seconds, + serializedState = "{}", + serializedResultData = null, + attempts = 1, + metadata = InMemoryQueueDriver.Metadata( + status = InMemoryJobStatus.Pending, + insertedAt = startInstant, + priority = 0, + runAt = startInstant + 45.seconds, + maxAttempts = 5, + lastRunDuration = Duration.Companion.ZERO, + lastErrorMessage = "normal error", + lastResultType = JobCompletionResultType.Failure, + ), + ), + ) + } + + @Test + fun processing_abnormalFailure() = runTest { + val timezone = TimeZone.Companion.UTC + val startInstant = LocalDate(2025, 1, 1).atStartOfDayIn(timezone) + val clock = TestClock(startInstant) + val driver = InMemoryQueueDriver(clock) + val executor = DefaultQueueExecutor( + driver = driver, + adapter = TestAdapter(clock), + serializers = serializers, + captureErrorStacktrace = false, + timeSource = clock.asTimeSource(), + ) + + val uuid = executor.insertJob("one", TestPayload("ballast"), TestState()) + + executor + .runQueue("one") { payload -> + throw RuntimeException("normal error") + } + .first() + + val jobState = driver.observeJobState(uuid).firstOrNull() + + assertEquals( + actual = jobState, + expected = SerializedJob( + jobId = uuid, + queueName = "one", + serializedPayload = buildJsonObject { put("data", "ballast") }.toString(), + timeoutDuration = 30.seconds, + serializedState = "{}", + serializedResultData = null, + attempts = 1, + metadata = InMemoryQueueDriver.Metadata( + status = InMemoryJobStatus.Pending, + insertedAt = startInstant, + priority = 0, + runAt = startInstant + 60.seconds, + maxAttempts = 5, + lastRunDuration = Duration.Companion.ZERO, + lastErrorMessage = "normal error", + lastResultType = JobCompletionResultType.Failure, + ), + ), + ) + } + + @Test + fun processing_intermediateState() = runTest { + val timezone = TimeZone.Companion.UTC + val startInstant = LocalDate(2025, 1, 1).atStartOfDayIn(timezone) + val clock = TestClock(startInstant) + val driver = InMemoryQueueDriver(clock) + val executor = DefaultQueueExecutor( + driver = driver, + adapter = TestAdapter(clock), + serializers = serializers, + captureErrorStacktrace = false, + timeSource = clock.asTimeSource(), + ) + + val uuid = executor.insertJob("one", TestPayload("ballast"), TestState()) + + val processor: suspend QueueExecutorScope.(TestPayload) -> TestResult? = { payload -> + val state = getCurrentState() + + if (state.step == 0) { + delay(5.seconds) + setState(state.copy(step = state.step + 1)) + throw RuntimeException("please try again") + } + if (state.step == 1) { + delay(5.seconds) + setState(state.copy(step = state.step + 1)) + throw RuntimeException("please try again") + } + if (state.step == 2) { + delay(5.seconds) + setState(state.copy(step = state.step + 1)) + throw RuntimeException("please try again") + } + if (state.step == 3) { + delay(5.seconds) + setState(state.copy(step = state.step + 1)) + } + + TestResult(payload.data.uppercase()) + } + + // process first attempt + executor + .runQueue("one", processor) + .first() + + assertEquals( + actual = driver.observeJobState(uuid).firstOrNull(), + expected = SerializedJob( + jobId = uuid, + queueName = "one", + serializedPayload = buildJsonObject { put("data", "ballast") }.toString(), + timeoutDuration = 30.seconds, + serializedState = buildJsonObject { put("step", 1) }.toString(), + serializedResultData = null, + attempts = 1, + metadata = InMemoryQueueDriver.Metadata( + status = InMemoryJobStatus.Pending, + insertedAt = startInstant, + priority = 0, + runAt = startInstant + 65.seconds, + maxAttempts = 5, + lastRunDuration = 5.seconds, + lastErrorMessage = "please try again", + lastResultType = JobCompletionResultType.Failure, + ), + ), + ) + + // process second attempt + executor + .runQueue("one", processor) + .first() + + assertEquals( + actual = driver.observeJobState(uuid).firstOrNull(), + expected = SerializedJob( + jobId = uuid, + queueName = "one", + serializedPayload = buildJsonObject { put("data", "ballast") }.toString(), + timeoutDuration = 30.seconds, + serializedState = buildJsonObject { + put("step", 2) + }.toString(), + serializedResultData = null, + attempts = 2, + metadata = InMemoryQueueDriver.Metadata( + status = InMemoryJobStatus.Pending, + insertedAt = startInstant, + priority = 0, + runAt = startInstant + (65.seconds * 2), + maxAttempts = 5, + lastRunDuration = 5.seconds, + lastErrorMessage = "please try again", + lastResultType = JobCompletionResultType.Failure, + ), + ), + ) + + // process second attempt + executor + .runQueue("one", processor) + .first() + + assertEquals( + actual = driver.observeJobState(uuid).firstOrNull(), + expected = SerializedJob( + jobId = uuid, + queueName = "one", + serializedPayload = buildJsonObject { put("data", "ballast") }.toString(), + timeoutDuration = 30.seconds, + serializedState = buildJsonObject { + put("step", 3) + }.toString(), + serializedResultData = null, + attempts = 3, + metadata = InMemoryQueueDriver.Metadata( + status = InMemoryJobStatus.Pending, + insertedAt = startInstant, + priority = 0, + runAt = startInstant + (65.seconds * 3), + maxAttempts = 5, + lastRunDuration = 5.seconds, + lastErrorMessage = "please try again", + lastResultType = JobCompletionResultType.Failure, + ), + ), + ) + + // process second attempt + executor + .runQueue("one", processor) + .first() + + assertEquals( + actual = driver.observeJobState(uuid).firstOrNull(), + expected = SerializedJob( + jobId = uuid, + queueName = "one", + serializedPayload = buildJsonObject { put("data", "ballast") }.toString(), + timeoutDuration = 30.seconds, + serializedState = buildJsonObject { + put("step", 4) + }.toString(), + serializedResultData = buildJsonObject { + put("resultData", "BALLAST") + }.toString(), + attempts = 4, + metadata = InMemoryQueueDriver.Metadata( + status = InMemoryJobStatus.Completed, + insertedAt = startInstant, + priority = 0, + runAt = startInstant + (65.seconds * 3), + maxAttempts = 5, + lastRunDuration = 5.seconds, + lastErrorMessage = null, + lastResultType = JobCompletionResultType.Success, + ), + ), + ) + } +} diff --git a/ballast-queue-exposed-driver/README.md b/ballast-queue-exposed-driver/README.md new file mode 100644 index 00000000..8c46e608 --- /dev/null +++ b/ballast-queue-exposed-driver/README.md @@ -0,0 +1,318 @@ +# Ballast Queue Exposed Driver + +> [!CAUTION] +> +> Experimental. This module may not still have issues or changes in its public API before being considered stable. +> Please use at your own risk, and file Issues for any problems you may encounter. + +## Overview + +A Driver implementation backed by a database table with Jetbrains Exposed for database access, designed for server-side +workloads needing high throughput and safe concurrency, with several more advanced features commonly needed in +real-world job queues, including delayed jobs, cluster-wide unique jobs, and message groups. + +Supports PostgreSQL databases, with experimental support for MySQL and other dialects possibly supported in the future. + +## Supported Platforms + +| Platform | Supported | +|----------|-----------| +| JVM | ✅ | +| Android | ❌ | +| iOS | ❌ | +| JS | ❌ | +| WASM JS | ❌ | + +## Supported Database Engines + +| Platform | Supported | Notes | +|------------|-----------|------------------------------------------| +| Postgresql | ✅ | | +| MySQL | ⚠️ | Exposed migrations not working correctly | +| SQLite | ❌ | Planned, development not started | +| MariaDB | ❌ | Not Planned, but open for contribution | +| Oracle | ❌ | Not Planned, but open for contribution | + +## See Also + +- [Exposed](https://www.jetbrains.com/exposed/) +- [Ballast Queue Core](./../ballast-queue-core) + +## Usage + +This module uses the Exposed DSL to store and query a database table as the persistent store. It uses a specific +database table schema which is compatible with PostgreSQL and MySQL, and theoretically could work with other database +engines. PostgreSQL and MySQL both support row-level locking `FOR UPDATE SKIP LOCKED`, which is necessary to ensure +exactly-once delivery of a job even when multiple workers are processing the queue in parallel. Other databases without +this feature would need alternative mechanisms for polling the queue safely, which is why they are not supported by +default. + +### Job Status + +Jobs can be in one of 6 states: + +- `Pending`: This job is waiting to be processed. It will become available once all conditions are ready (delayed + start, message groups, etc.) +- `Running`: This job has been selected by a worker, and is currently running. That worker has exclusive access to the + across the entire distributed system for the duration of its lease. It's possible that the worker crashes while it + held the lease, leaving a job stuck in the `Running` state without actually being processed. A maintenance task is + needed to detect these jobs and move them back to `Pending` for a retry +- `Succeeded`: The job was successfully processed by a worker, and is considered complete. It may have stored a result + value that you need to move elsewhere, but otherwise, the work is done and this Job record is a candidate for + deletion from the queue by a a maintenance task. +- `Failed`: This job exceeded the max number of retries, and it appears like it will never succeed in its current state. + It's considered permanently failed. Perhaps a downstream service has moved, or there's a bug in your worker's +- processing code. Either way, you likely need to manually intervene to correct the issue before manually retrying the + job. +- `Cooldown`: A Unique job has completed successfully, but is still holding onto exclusivity for its deduplication key, + preventing more jobs at the same key from being inserted. A maintenance task is needed to move jobs from Cooldown to + Succeeded, allowing a new job at the same deduplication key to be enqueued. +- `Cancelled`: Jobs never enter this state on their own. Rather, by manually updating a job's status to Cancelled while + it is `Running`, it will request the worker that's processing the job to cancel the coroutine and stop processing the + job promptly. It will be treated like a normal failure, either being retried or permanently failed. + +Jobs move through these states according to the following state diagram: + +```mermaid +stateDiagram-v2 + [*] --> Pending + Pending --> Running: Selected for processing + Running --> Succeeded + Running --> Pending: Enqueued for retry + Running --> Failed: Permanently failed + Running --> Cooldown: Succeeded for unique job + + Cooldown --> Succeeded +``` + +### Queue Features and Configuration + +This queue supports several features one commonly needs in production-scale applications. These features are all +derived from the Job payload into `ExposedDatabaseQueueDriver.Metadata`, and stored as columns in the jobs table. See +below for a description of these features and their related Metadata property and column name. + +Queue features are configured by creating an `Adapter` which takes in your type-safe payload, and returns +`ExposedDatabaseQueueDriver.Metadata` with the job's configuration. Configurations are always set individually for each +job. You may instead use `ExposedDatabaseQueueDriver.DefaultAdapter()` to not use any per-job configuration, and always +use the driver's default values. + +```kotlin +public class ExampleAdapter( + private val clock: Clock = Clock.System, +) : QueueDriver.Adapter< + ExposedDatabaseQueueDriver.Metadata, + ExampleContract.Inputs, + ExampleContract.Events, + ExampleContract.State + > { + override fun getJobMetadata(payload: ExampleContract.Inputs) = ExposedDatabaseQueueDriver.Metadata( + insertedAt = clock.now(), + maxAttempts = 5, + ) +} +``` + +#### Insertion ordering + +| Metadata | Kotlin Type | DB Column | DB Type | Default Value | +|--------------|-------------|--------------|----------------------|-------------------| +| `insertedAt` | `Instant` | `created_at` | `TIMESTAMP NOT NULL` | Current Timestamp | + +Jobs track the moment they were inserted into the queue, and in general, jobs inserted earlier will be processed first +to avoid starvation. However, other features like prioritization, delayed starts, and message grouping will impact the +exact ordering in which jobs are pulled from the queue for processing. + +#### Delayed job start + +| Metadata | Kotlin Type | DB Column | DB Type | Default Value | +|----------|-------------|-----------|----------------------|-------------------| +| `runAt` | `Instant` | `run_at` | `TIMESTAMP NOT NULL` | Current Timestamp | + +All jobs have a `run_at` timestamp indicating a time at which the job becomes eligible for processing. It defaults to +the current moment at the time of job creation, meaning it is available for processing immediately. + +Setting a future `run_at` timestamp will impact the ordering which jobs are delivered for processing, and it will not +prevent jobs submitted later from being processed before a job submitted earlier. + +#### Job prioritization + +| Metadata | Kotlin Type | DB Column | DB Type | Default Value | +|------------|-------------|------------|----------------|---------------| +| `priority` | `Int` | `priority` | `INT NOT NULL` | 0 | + +Within a single queue, jobs with a higher priority will always be selected for processing before jobs with a lower +priority. If multiple jobs have the same priority, they will be selected in insertion order within that priority band. + +Higher priority will not entirely block lower priority jobs from being selected until all higher priority ones are, +since priority is only considered among jobs which are otherwise available for selection. For example, a job with +priority `10` but a `run_at` time in the future would not prevent a job with priority `0` from being selected if it has +`run_at` in the past. However, within the same queue, if two jobs of different priority are both eligible for selection, +the highest priority will always be selected before the lower priority job. + +You must be careful not to overuse priority, as jobs with lower priority can experience starvation if there are +consistently higher-priority jobs in the queue which always take precedence over lower priority ones. + +In general, think of priority as a general _suggestion_ of the order in which to run jobs, and use it rarely or ensure +you have enough workers on the queue to keep the queue empty to prevent low-priority starvation. For stronger, safer +ordering guarantees, consider using [Message Groups](#message-groups) instead. + +#### Deduplication + +| Metadata | Kotlin Type | DB Column | DB Type | Default Value | +|-------------------------|-------------|--------------------------|------------------|---------------| +| `deduplicationKey` | `String?` | `deduplication_key` | `TEXT NULL` | null | +| `deduplicationDuration` | `Duration?` | `deduplication_duration` | `BIGINT NULL` | null | +| | | `unique_until` | `TIMESTAMP NULL` | null | + +Uniqueness can be enforced across the entire system, preventing jobs with the same key from being inserted into the +queue. If `deduplicationKey` is set, `deduplicationDuration` must also be set, indicating the period of time which the +uniqueness is considered in "cooldown". As long as a job is currently in `Pending`, `Running`, or `Cooldown` states, +another job cannot be inserted into the queue with the same `deduplicationKey`. This is useful for situations like +debouncing jobs inserted into the job on a schedule, so you don't need to do synchronization between multiple containers +each running and inserting jobs on a schedule in parallel. + +Jobs are unique from `run_at + deduplication_duration`, set in the `unique_until` column at the time of job creation. +This time is not updated if the job fails an is retried, but in the case of retries it will be moved from `Running` +back to `Pending`, thus still holding uniqueness until it either succeeds or permanently fails. + +Jobs in Cooldown are not automatically moved to Succeeded to free the unique constraint. You must run +`JobsMaintenanceRepository.freeJobCooldowns()` to move all jobs in `Cooldown` past their `unique_until` timestamp into +`Succeeded` and allow another job at this key to be inserted. + +#### Message Groups + +| Metadata | Kotlin Type | DB Column | DB Type | Default Value | +|----------------|-------------|-----------------|-------------|---------------| +| `messageGroup` | `String?` | `message_group` | `TEXT NULL` | null | + +Message groups allow you to make FIFO queues similar to Amazon SQS, where jobs in the same message group can only have 1 +currently running at a time. Other jobs with the same `message_group` may be inserted into the queue, but only 1 job +within that group will be able to run at a time, across the entire pool of workers. + +While this may sound similar to [Deduplication](#deduplication), it serves a different purpose. Deduplication is about +debouncing the same job so the same task doesn't accidentally get processed twice. Message groups are for protecting +access to the same shared resource across multiple jobs. As such, deduplication typically uses the name of the job as +the deduplication key, while message groups should us something like a `userId` to ensure jobs which modify data for the +same user are not running in parallel, corrupting each other's work. But because multiple users wouldn't be updating +each other's data, it's fine to allow the same job at the same key among different users. + +#### Automatic Retries + +| Metadata | Kotlin Type | DB Column | DB Type | Default Value | +|---------------|-------------|----------------|------------------|---------------| +| `maxAttempts` | `Int` | `max_attempts` | `INT NOT NULL` | 5 | +| `retryUntil` | `Instant?` | `retry_until` | `TIMESTAMP NULL` | null | + +Whenever a job is unable to complete successfully, it may be moved to the `Failed` if it cannot be retried, or it may be +moved back to the `Pending` state if it is eligible for retry. Jobs can fail for many reasons, including: + +- timeouts +- exceptions thrown during processing +- explicit cancellation +- worker process crashes + +In all cases, whenever we need to determine how to deal with the issue, the job will be checked for retry eligibility. +Jobs are eligible for retry if: + +- The current number of `attempts` is less than `max_attempts` AND +- if `retry_until` is not null, the current time is less than `retry_until` + +If you wish to not worry about number of attempts and always attempt a retry until a given time, set `max_attempts` to +an arbitrarily high value like `Int.MAX_VALUE`. + +#### Crash Protection + +| Metadata | Kotlin Type | DB Column | DB Type | Default Value | +|------------------------|-------------|-------------------------|-------------------|---------------| +| `leasedAt` | `Instant?` | `leased_at` | `TIMESTAMP NULL` | null | +| `leasedBufferDuration` | `Duration` | `lease_buffer_duration` | `BIGINT NOT NULL` | 30 seconds | +| `leasedUntil` | `Instant?` | `leased_until` | `TIMESTAMP NULL` | null | +| | | `timeout_duration` | `BIGINT NOT NULL` | 30 seconds | + +Sometimes things don't go as planned, and your application process crashes or is forcibly shut down while a worker is +currently processing a job. Unfortunately, there's not much that can be done during the application process to recover +the job gracefully at the time the server is forcibly terminated. But as a protection against this scenario, when a job +is claimed from the queue by a worker, it is given a lease on that job to prevent it from being stuck in the `Running` +state indefinitely. + +When a job starts running, the `leased_until` property is set to `currentTime + timeout_duration + lease_buffer_duration`. +This means that if the job is actively running, it will either complete or timeout before the lease expires. But if the +process crashes, the job will only be stuck in the `Running` state for at most `timeout_duration + lease_buffer_duration`, +after which time the job can be released back to the queue for retry with `JobsMaintenanceRepository.retryHungJobs()`. +The lease buffer ensures jobs currently running will not get moved back to the queue for retry. + +### Component Details + +#### Jobs Table + +The `JobsTable` is an abstract class defining the database table schema which holds jobs, and which will be polled to +consume and attempt to process jobs. It is an [Exposed IdTable](https://www.jetbrains.com/help/exposed/working-with-tables.html) +using UUIDs as the job's primary key. Use `JobsTable.Default` as the primary top-level object to use this table with a +predefined table name of `jobs`. If you would like to use a different table name, you will need to maintain your own +singleton instance of `JobsTable` with your custom table name, and pass that to the Exposed QueueDriver. + +```kotlin +// use the JobsTable schema, but with a different table name +object AppJobsTable : JobsTable("app_jobs") + +val database = Database.connect(...) +val repository = JobsRepositoryImpl(database, AppJobsTable) +val driver = ExposedDatabaseQueueDriver(repository) +``` + +#### JobsRepository + +The Driver itself delegates all SQL to the `JobsRepository`, implemented by `JobsRepositoryImpl`. You will need to +create and manage the state of this Repository yourself, providing an explicit [database connection](https://www.jetbrains.com/help/exposed/working-with-database.html). + +Internally, the `JobsRepository` is stateless apart from the database itself, and does not have any long-running jobs or +in-memory caches. It's intended to be a stateless and more semantic interface to the underlying database table. All +SQL executes in a suspending transaction using the explicit `Database` instance passed to the `JobsRepositoryImpl` +constructor, to ensure consistent behavior throughout your app even if you use a different database for your Jobs table. +This database has only been tested with JDBC, but support for R2DBC is planned. + +#### JobsMaintenanceRepository + +By default, the Exposed job queue driver does not perform any maintenance to the jobs table, since organizational +compliance needs and application requirements may impact how often such maintenance tasks as deleting old jobs need to +be performed. `JobsMaintenanceRepository` encapsulates the common maintenance needs of the JobsTable, but it will be +left to you to actually schedule and call these tasks. Fortunately, these tasks can be easily scheduled with +[Ballast Scheduler](./../ballast-scheduler-core). + +Maintenance needs for the Jobs table are: + +- `JobsMaintenanceRepository.deleteOldJobs()` - Jobs are not automatically deleted when they complete successfully, + since they may contain a result payload that's needed by other application logic. Periodically, old jobs should be + deleted once they've been fully handled, to ensure the table does not grow indefinitely with rows that are not needed. +- `JobsMaintenanceRepository.freeJobCooldowns()` - Jobs with a deduplication key may hold a cooldown for an arbitrary + period of time after completing, which is not automatically released once the cooldown expires. You will need to run + this task to look for jobs still holding a cooldown, and move them to a `Success` state so another job with the same + key can be enqueued. +- `JobsMaintenanceRepository.retryHungJobs()` - If the server process crashes while a job is in progress, it will remain + in the `Running` state, even though there is no worker actively working on the job. Jobs are leased from the queue + with an expiry slightly longer than their timeout value, so if the server crashes, those jobs will eventually lose + their lease and be eligible for this task to move them back to a `Pending` state to be retried. + +## Installation + +```kotlin +repositories { + mavenCentral() +} + +// for plain JVM projects +dependencies { + implementation("io.github.copper-leaf:ballast-queue-exposed-driver:{{ballastVersion}}") +} + +// for multiplatform projects +kotlin { + sourceSets { + val jvmMain by getting { + dependencies { + implementation("io.github.copper-leaf:ballast-queue-exposed-driver:{{ballastVersion}}") + } + } + } +} +``` diff --git a/ballast-queue-exposed-driver/api/ballast-queue-exposed-driver.api b/ballast-queue-exposed-driver/api/ballast-queue-exposed-driver.api new file mode 100644 index 00000000..de9928b2 --- /dev/null +++ b/ballast-queue-exposed-driver/api/ballast-queue-exposed-driver.api @@ -0,0 +1,185 @@ +public final class com/copperleaf/ballast/queue/driver/db/ExposedDatabaseJobStatus : java/lang/Enum { + public static final field Cancelled Lcom/copperleaf/ballast/queue/driver/db/ExposedDatabaseJobStatus; + public static final field Cooldown Lcom/copperleaf/ballast/queue/driver/db/ExposedDatabaseJobStatus; + public static final field Failed Lcom/copperleaf/ballast/queue/driver/db/ExposedDatabaseJobStatus; + public static final field Pending Lcom/copperleaf/ballast/queue/driver/db/ExposedDatabaseJobStatus; + public static final field Running Lcom/copperleaf/ballast/queue/driver/db/ExposedDatabaseJobStatus; + public static final field Succeeded Lcom/copperleaf/ballast/queue/driver/db/ExposedDatabaseJobStatus; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/copperleaf/ballast/queue/driver/db/ExposedDatabaseJobStatus; + public static fun values ()[Lcom/copperleaf/ballast/queue/driver/db/ExposedDatabaseJobStatus; +} + +public final class com/copperleaf/ballast/queue/driver/db/ExposedDatabaseQueueDriver : com/copperleaf/ballast/queue/QueueDriver { + public fun (Lcom/copperleaf/ballast/queue/driver/db/repository/JobsRepository;Lcom/copperleaf/ballast/queue/QueueThrottle;)V + public synthetic fun (Lcom/copperleaf/ballast/queue/driver/db/repository/JobsRepository;Lcom/copperleaf/ballast/queue/QueueThrottle;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun addToQueue-gwCluXo (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JLcom/copperleaf/ballast/queue/driver/db/ExposedDatabaseQueueDriver$Metadata;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public synthetic fun addToQueue-gwCluXo (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JLjava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun awaitShutdown (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun completeJobSuccessfully-WPwdCS8 (Ljava/lang/String;JLcom/copperleaf/ballast/queue/JobCompletionResultType;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun completeJobWithFailure-3c68mSE (Ljava/lang/String;JLcom/copperleaf/ballast/queue/JobCompletionResultType;JZZLjava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun observeQueue (Ljava/lang/String;)Lkotlinx/coroutines/flow/Flow; + public fun requestJobCancellation (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun subscribeToJobCancellation (Ljava/lang/String;)Lkotlinx/coroutines/flow/Flow; + public fun updateJobState (Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/copperleaf/ballast/queue/driver/db/ExposedDatabaseQueueDriver$DefaultAdapter : com/copperleaf/ballast/queue/QueueDriver$Adapter { + public fun ()V + public fun (Lkotlin/time/Clock;)V + public synthetic fun (Lkotlin/time/Clock;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun getDefaultRetryDelayTimeout-3nIYWDw (Ljava/lang/Object;I)J + public fun getJobMetadata (Ljava/lang/Object;)Lcom/copperleaf/ballast/queue/driver/db/ExposedDatabaseQueueDriver$Metadata; + public synthetic fun getJobMetadata (Ljava/lang/Object;)Ljava/lang/Object; + public fun getJobTimeout-5sfh64U (Ljava/lang/Object;)J +} + +public final class com/copperleaf/ballast/queue/driver/db/ExposedDatabaseQueueDriver$Metadata { + public synthetic fun (Lkotlin/time/Instant;ILkotlin/time/Instant;Ljava/lang/String;Lkotlin/time/Duration;Ljava/lang/String;ILkotlin/time/Instant;Lcom/copperleaf/ballast/queue/driver/db/ExposedDatabaseJobStatus;JLkotlin/time/Instant;Lkotlin/time/Instant;Lkotlin/time/Instant;Lkotlin/time/Duration;Lcom/copperleaf/ballast/queue/JobCompletionResultType;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Lkotlin/time/Instant;ILkotlin/time/Instant;Ljava/lang/String;Lkotlin/time/Duration;Ljava/lang/String;ILkotlin/time/Instant;Lcom/copperleaf/ballast/queue/driver/db/ExposedDatabaseJobStatus;JLkotlin/time/Instant;Lkotlin/time/Instant;Lkotlin/time/Instant;Lkotlin/time/Duration;Lcom/copperleaf/ballast/queue/JobCompletionResultType;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lkotlin/time/Instant; + public final fun component10-UwyO8pc ()J + public final fun component11 ()Lkotlin/time/Instant; + public final fun component12 ()Lkotlin/time/Instant; + public final fun component13 ()Lkotlin/time/Instant; + public final fun component14-FghU774 ()Lkotlin/time/Duration; + public final fun component15 ()Lcom/copperleaf/ballast/queue/JobCompletionResultType; + public final fun component16 ()Ljava/lang/String; + public final fun component17 ()Ljava/lang/String; + public final fun component2 ()I + public final fun component3 ()Lkotlin/time/Instant; + public final fun component4 ()Ljava/lang/String; + public final fun component5-FghU774 ()Lkotlin/time/Duration; + public final fun component6 ()Ljava/lang/String; + public final fun component7 ()I + public final fun component8 ()Lkotlin/time/Instant; + public final fun component9 ()Lcom/copperleaf/ballast/queue/driver/db/ExposedDatabaseJobStatus; + public final fun copy-WxSPFH0 (Lkotlin/time/Instant;ILkotlin/time/Instant;Ljava/lang/String;Lkotlin/time/Duration;Ljava/lang/String;ILkotlin/time/Instant;Lcom/copperleaf/ballast/queue/driver/db/ExposedDatabaseJobStatus;JLkotlin/time/Instant;Lkotlin/time/Instant;Lkotlin/time/Instant;Lkotlin/time/Duration;Lcom/copperleaf/ballast/queue/JobCompletionResultType;Ljava/lang/String;Ljava/lang/String;)Lcom/copperleaf/ballast/queue/driver/db/ExposedDatabaseQueueDriver$Metadata; + public static synthetic fun copy-WxSPFH0$default (Lcom/copperleaf/ballast/queue/driver/db/ExposedDatabaseQueueDriver$Metadata;Lkotlin/time/Instant;ILkotlin/time/Instant;Ljava/lang/String;Lkotlin/time/Duration;Ljava/lang/String;ILkotlin/time/Instant;Lcom/copperleaf/ballast/queue/driver/db/ExposedDatabaseJobStatus;JLkotlin/time/Instant;Lkotlin/time/Instant;Lkotlin/time/Instant;Lkotlin/time/Duration;Lcom/copperleaf/ballast/queue/JobCompletionResultType;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/copperleaf/ballast/queue/driver/db/ExposedDatabaseQueueDriver$Metadata; + public fun equals (Ljava/lang/Object;)Z + public final fun getDeduplicationDuration-FghU774 ()Lkotlin/time/Duration; + public final fun getDeduplicationKey ()Ljava/lang/String; + public final fun getInsertedAt ()Lkotlin/time/Instant; + public final fun getLastErrorMessage ()Ljava/lang/String; + public final fun getLastResultType ()Lcom/copperleaf/ballast/queue/JobCompletionResultType; + public final fun getLastRunDuration-FghU774 ()Lkotlin/time/Duration; + public final fun getLastRunFinishedAt ()Lkotlin/time/Instant; + public final fun getLastStacktrace ()Ljava/lang/String; + public final fun getLeaseBufferDuration-UwyO8pc ()J + public final fun getLeasedAt ()Lkotlin/time/Instant; + public final fun getLeasedUntil ()Lkotlin/time/Instant; + public final fun getMaxAttempts ()I + public final fun getMessageGroup ()Ljava/lang/String; + public final fun getPriority ()I + public final fun getRetryUntil ()Lkotlin/time/Instant; + public final fun getRunAt ()Lkotlin/time/Instant; + public final fun getStatus ()Lcom/copperleaf/ballast/queue/driver/db/ExposedDatabaseJobStatus; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/copperleaf/ballast/queue/driver/db/ExposedDatabaseQueueMigrations { + public fun (Lorg/jetbrains/exposed/v1/jdbc/Database;Lcom/copperleaf/ballast/queue/driver/db/JobsTable;)V + public final fun applyMigrations (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract class com/copperleaf/ballast/queue/driver/db/JobsTable : org/jetbrains/exposed/v1/core/dao/id/IdTable { + public fun (Ljava/lang/String;)V + public final fun getAttempts ()Lorg/jetbrains/exposed/v1/core/Column; + public final fun getCreated_at ()Lorg/jetbrains/exposed/v1/core/Column; + public final fun getDeduplication_duration ()Lorg/jetbrains/exposed/v1/core/Column; + public final fun getDeduplication_key ()Lorg/jetbrains/exposed/v1/core/Column; + public final fun getId ()Lorg/jetbrains/exposed/v1/core/Column; + public final fun getJob_state ()Lorg/jetbrains/exposed/v1/core/Column; + public final fun getLast_run_duration ()Lorg/jetbrains/exposed/v1/core/Column; + public final fun getLast_run_failure_message ()Lorg/jetbrains/exposed/v1/core/Column; + public final fun getLast_run_failure_stacktrace ()Lorg/jetbrains/exposed/v1/core/Column; + public final fun getLast_run_finished_at ()Lorg/jetbrains/exposed/v1/core/Column; + public final fun getLast_run_result_type ()Lorg/jetbrains/exposed/v1/core/Column; + public final fun getLease_buffer_duration ()Lorg/jetbrains/exposed/v1/core/Column; + public final fun getLeased_at ()Lorg/jetbrains/exposed/v1/core/Column; + public final fun getLeased_until ()Lorg/jetbrains/exposed/v1/core/Column; + public final fun getMax_attempts ()Lorg/jetbrains/exposed/v1/core/Column; + public final fun getMessage_group ()Lorg/jetbrains/exposed/v1/core/Column; + public final fun getOriginal_queue ()Lorg/jetbrains/exposed/v1/core/Column; + public final fun getPayload ()Lorg/jetbrains/exposed/v1/core/Column; + public final fun getPrimaryKey ()Lorg/jetbrains/exposed/v1/core/Table$PrimaryKey; + public final fun getPriority ()Lorg/jetbrains/exposed/v1/core/Column; + public final fun getQueue ()Lorg/jetbrains/exposed/v1/core/Column; + public final fun getResult_data ()Lorg/jetbrains/exposed/v1/core/Column; + public final fun getRetry_until ()Lorg/jetbrains/exposed/v1/core/Column; + public final fun getRun_at ()Lorg/jetbrains/exposed/v1/core/Column; + public final fun getStatus ()Lorg/jetbrains/exposed/v1/core/Column; + public final fun getTimeout_duration ()Lorg/jetbrains/exposed/v1/core/Column; + public final fun getUnique_until ()Lorg/jetbrains/exposed/v1/core/Column; + public final fun getUpdated_at ()Lorg/jetbrains/exposed/v1/core/Column; +} + +public final class com/copperleaf/ballast/queue/driver/db/JobsTable$Default : com/copperleaf/ballast/queue/driver/db/JobsTable { + public static final field INSTANCE Lcom/copperleaf/ballast/queue/driver/db/JobsTable$Default; +} + +public abstract interface class com/copperleaf/ballast/queue/driver/db/repository/JobsMaintenanceRepository { + public abstract fun deleteFromDeadLetterQueue (Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun deleteOldJobs-VtjQ1oo (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun deleteOldJobs-VtjQ1oo$default (Lcom/copperleaf/ballast/queue/driver/db/repository/JobsMaintenanceRepository;JLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public abstract fun freeJobCooldowns (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun moveFromDeadLetterQueue (Ljava/lang/String;Ljava/lang/String;ILkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun moveFromDeadLetterQueue$default (Lcom/copperleaf/ballast/queue/driver/db/repository/JobsMaintenanceRepository;Ljava/lang/String;Ljava/lang/String;ILkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public abstract fun moveToDeadLetterQueue (Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun moveToDeadLetterQueue$default (Lcom/copperleaf/ballast/queue/driver/db/repository/JobsMaintenanceRepository;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public abstract fun retryHungJobs (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/copperleaf/ballast/queue/driver/db/repository/JobsMaintenanceRepository$DefaultImpls { + public static synthetic fun deleteOldJobs-VtjQ1oo$default (Lcom/copperleaf/ballast/queue/driver/db/repository/JobsMaintenanceRepository;JLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public static synthetic fun moveFromDeadLetterQueue$default (Lcom/copperleaf/ballast/queue/driver/db/repository/JobsMaintenanceRepository;Ljava/lang/String;Ljava/lang/String;ILkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public static synthetic fun moveToDeadLetterQueue$default (Lcom/copperleaf/ballast/queue/driver/db/repository/JobsMaintenanceRepository;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; +} + +public final class com/copperleaf/ballast/queue/driver/db/repository/JobsMaintenanceRepositoryImpl : com/copperleaf/ballast/queue/driver/db/repository/JobsMaintenanceRepository { + public fun (Lorg/jetbrains/exposed/v1/jdbc/Database;Lcom/copperleaf/ballast/queue/driver/db/JobsTable;Lkotlin/time/Clock;Lorg/jetbrains/exposed/v1/core/SqlLogger;)V + public synthetic fun (Lorg/jetbrains/exposed/v1/jdbc/Database;Lcom/copperleaf/ballast/queue/driver/db/JobsTable;Lkotlin/time/Clock;Lorg/jetbrains/exposed/v1/core/SqlLogger;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun deleteFromDeadLetterQueue (Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteOldJobs-VtjQ1oo (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun freeJobCooldowns (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun moveFromDeadLetterQueue (Ljava/lang/String;Ljava/lang/String;ILkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun moveToDeadLetterQueue (Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun retryHungJobs (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class com/copperleaf/ballast/queue/driver/db/repository/JobsRepository { + public abstract fun claimNextAvailableJob (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun completeJob-WPwdCS8 (Lkotlin/uuid/Uuid;JLcom/copperleaf/ballast/queue/JobCompletionResultType;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun deleteJob (Lkotlin/uuid/Uuid;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun forceRetry-dWUq8MI (Lkotlin/uuid/Uuid;JILkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun forceRetry-dWUq8MI$default (Lcom/copperleaf/ballast/queue/driver/db/repository/JobsRepository;Lkotlin/uuid/Uuid;JILkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public abstract fun getAllJobs (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun getAllJobsInQueue (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun insertJob-gwCluXo (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JLcom/copperleaf/ballast/queue/driver/db/ExposedDatabaseQueueDriver$Metadata;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun isJobCancelled (Lkotlin/uuid/Uuid;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun requestCancellation (Lkotlin/uuid/Uuid;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun retryOrPermanentlyFailJob-3c68mSE (Lkotlin/uuid/Uuid;JLcom/copperleaf/ballast/queue/JobCompletionResultType;JZZLjava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun setJobState (Lkotlin/uuid/Uuid;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/copperleaf/ballast/queue/driver/db/repository/JobsRepository$DefaultImpls { + public static synthetic fun forceRetry-dWUq8MI$default (Lcom/copperleaf/ballast/queue/driver/db/repository/JobsRepository;Lkotlin/uuid/Uuid;JILkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; +} + +public final class com/copperleaf/ballast/queue/driver/db/repository/JobsRepositoryImpl : com/copperleaf/ballast/queue/driver/db/repository/JobsRepository { + public fun (Lorg/jetbrains/exposed/v1/jdbc/Database;Lcom/copperleaf/ballast/queue/driver/db/JobsTable;Lkotlin/time/Clock;Lkotlinx/serialization/json/Json;Lorg/jetbrains/exposed/v1/core/SqlLogger;)V + public synthetic fun (Lorg/jetbrains/exposed/v1/jdbc/Database;Lcom/copperleaf/ballast/queue/driver/db/JobsTable;Lkotlin/time/Clock;Lkotlinx/serialization/json/Json;Lorg/jetbrains/exposed/v1/core/SqlLogger;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun claimNextAvailableJob (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun completeJob-WPwdCS8 (Lkotlin/uuid/Uuid;JLcom/copperleaf/ballast/queue/JobCompletionResultType;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteJob (Lkotlin/uuid/Uuid;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun forceRetry-dWUq8MI (Lkotlin/uuid/Uuid;JILkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getAllJobs (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getAllJobsInQueue (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun insertJob-gwCluXo (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JLcom/copperleaf/ballast/queue/driver/db/ExposedDatabaseQueueDriver$Metadata;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun isJobCancelled (Lkotlin/uuid/Uuid;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun requestCancellation (Lkotlin/uuid/Uuid;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun retryOrPermanentlyFailJob-3c68mSE (Lkotlin/uuid/Uuid;JLcom/copperleaf/ballast/queue/JobCompletionResultType;JZZLjava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun setJobState (Lkotlin/uuid/Uuid;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + diff --git a/ballast-queue-exposed-driver/build.gradle.kts b/ballast-queue-exposed-driver/build.gradle.kts new file mode 100644 index 00000000..c9f6f449 --- /dev/null +++ b/ballast-queue-exposed-driver/build.gradle.kts @@ -0,0 +1,35 @@ +plugins { + id("copper-leaf-base") + id("copper-leaf-targets") + id("copper-leaf-tests") + id("copper-leaf-lint") + id("copper-leaf-publish") +} + +kotlin { + compilerOptions { + optIn.add("kotlin.time.ExperimentalTime") + optIn.add("kotlin.uuid.ExperimentalUuidApi") + } + + sourceSets { + val jvmMain by getting { + dependencies { + api(project(":ballast-queue-core")) + api(libs.exposed.core) + api(libs.exposed.jdbc) + api(libs.exposed.kotlindatetime) + api(libs.exposed.json) + api(libs.exposed.migration.core) + api(libs.exposed.migration.jdbc) + } + } + val jvmTest by getting { + dependencies { + api(libs.jdbc.postgres) + api(libs.jdbc.mysql) + api(libs.testcontainers) + } + } + } +} diff --git a/ballast-queue-exposed-driver/gradle.properties b/ballast-queue-exposed-driver/gradle.properties new file mode 100644 index 00000000..ad390fa3 --- /dev/null +++ b/ballast-queue-exposed-driver/gradle.properties @@ -0,0 +1,8 @@ +copperleaf.description=Use a PostgreSQL table as the backing store for a Ballast persistent queue in server-side applications. + +copperleaf.targets.android=false +copperleaf.targets.jvm=true +copperleaf.targets.ios=false +copperleaf.targets.js=false +copperleaf.targets.wasm.wasi=false +copperleaf.targets.wasm.js=false diff --git a/ballast-queue-exposed-driver/src/jvmMain/kotlin/com/copperleaf/ballast/queue/driver/db/ExposedDatabaseJobStatus.kt b/ballast-queue-exposed-driver/src/jvmMain/kotlin/com/copperleaf/ballast/queue/driver/db/ExposedDatabaseJobStatus.kt new file mode 100644 index 00000000..502c726e --- /dev/null +++ b/ballast-queue-exposed-driver/src/jvmMain/kotlin/com/copperleaf/ballast/queue/driver/db/ExposedDatabaseJobStatus.kt @@ -0,0 +1,46 @@ +package com.copperleaf.ballast.queue.driver.db + +public enum class ExposedDatabaseJobStatus { + + /** + * The job is available to be selected once it has reached its scheduled time. + */ + Pending, + + /** + * The job has been selected for processing. It is now held exclusively by one worker, and is under a lease. If the + * worker crashes during processing, the job will be returned to Pending once the lease expires, assuming it has + * retries left. + */ + Running, + + /** + * The job has completed successfully. It is eligible to be deleted from the database as a maintenance task. + */ + Succeeded, + + /** + * The job has failed permanently, with no retries left. It should be considered dead, and should be reported as a + * catastrophic failure which needs human intervention, without which it will not be possible to complete this job. + * + * Failed jobs should not be automatically deleted from the database, as they represent important failure cases + * which need to be addressed, and perhaps scheduled for retry once a fix is in place. + */ + Failed, + + /** + * This was a unique job which completed successfully, and is now in a "cooldown" phase. No other jobs with the + * same deduplication key can be scheduled until this cooldown period has expired. + */ + Cooldown, + + /** + * This is an ephemeral state used to request cancallation of the job. By changing a job's status to Cancelled while + * it is running, it signals to the worker processing the job that it should halt processing as soon as possible. + * + * Cancellation is not guaranteed, but is a best-effort attempt to stop processing the job. Once a job is marked + * as Cancelled, it will be treated like a timeout or exception failure for purposes of retrys and backoff, assuming + * it has retries left. + */ + Cancelled +} diff --git a/ballast-queue-exposed-driver/src/jvmMain/kotlin/com/copperleaf/ballast/queue/driver/db/ExposedDatabaseQueueDriver.kt b/ballast-queue-exposed-driver/src/jvmMain/kotlin/com/copperleaf/ballast/queue/driver/db/ExposedDatabaseQueueDriver.kt new file mode 100644 index 00000000..a2974b32 --- /dev/null +++ b/ballast-queue-exposed-driver/src/jvmMain/kotlin/com/copperleaf/ballast/queue/driver/db/ExposedDatabaseQueueDriver.kt @@ -0,0 +1,194 @@ +package com.copperleaf.ballast.queue.driver.db + +import com.copperleaf.ballast.queue.JobCompletionResultType +import com.copperleaf.ballast.queue.QueueDriver +import com.copperleaf.ballast.queue.QueueThrottle +import com.copperleaf.ballast.queue.SerializedJob +import com.copperleaf.ballast.queue.driver.db.repository.JobsRepository +import com.copperleaf.ballast.queue.pollingFlow +import com.copperleaf.ballast.queue.queueDriverPollingFlow +import com.copperleaf.ballast.queue.throttle.UnlimitedThrottle +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant +import kotlin.uuid.Uuid + +public class ExposedDatabaseQueueDriver( + private val repository: JobsRepository, + private val throttle: QueueThrottle = UnlimitedThrottle(), +) : QueueDriver { + +// Types +// --------------------------------------------------------------------------------------------------------------------- + + public data class Metadata( + val insertedAt: Instant, + val maxAttempts: Int = 5, + val retryUntil: Instant? = null, + + val deduplicationKey: String? = null, + val deduplicationDuration: Duration? = null, + + val messageGroup: String? = null, + + val priority: Int = 0, + val runAt: Instant = insertedAt, + val status: ExposedDatabaseJobStatus = ExposedDatabaseJobStatus.Pending, + + val leaseBufferDuration: Duration = 30.seconds, + val leasedAt: Instant? = null, + val leasedUntil: Instant? = null, + + val lastRunFinishedAt: Instant? = null, + val lastRunDuration: Duration? = null, + val lastResultType: JobCompletionResultType? = null, + val lastErrorMessage: String? = null, + val lastStacktrace: String? = null, + ) + + public class DefaultAdapter< + Payload : Any, + Result : Any, + State : Any, + >( + private val clock: Clock = Clock.System, + ) : QueueDriver.Adapter { + override fun getJobMetadata(payload: Payload): Metadata { + val now = clock.now() + return Metadata( + insertedAt = now, + maxAttempts = 5, + ) + } + } + +// Insert/Query Operations +// --------------------------------------------------------------------------------------------------------------------- + + override suspend fun addToQueue( + queueName: String, + serializedPayload: String, + serializedInitialState: String, + timeoutDuration: Duration, + metadata: Metadata, + ): String { + return repository + .insertJob( + queueName, + serializedPayload, + serializedInitialState, + timeoutDuration, + metadata, + ) + .toString() + } + + override fun observeQueue(queueName: String): Flow> { + return queueDriverPollingFlow( + queueName = queueName, + throttle = throttle, + pollNext = { pollNext(queueName) }, + awaitNext = { delay(1.seconds) } + ) + } + + internal suspend fun pollNext( + queueName: String, + ): SerializedJob? { + return repository.claimNextAvailableJob(queueName) + } + +// Job Processing State/Results +// --------------------------------------------------------------------------------------------------------------------- + + override suspend fun updateJobState(jobId: String, serializedState: String) { + repository.setJobState( + jobId = Uuid.parse(jobId), + serializedState = serializedState, + ) + } + + override suspend fun completeJobSuccessfully( + jobId: String, + processingTime: Duration, + resultType: JobCompletionResultType, + serializedResultData: String? + ) { + repository.completeJob( + jobId = Uuid.parse(jobId), + processingTime = processingTime, + resultType = resultType, + serializedResultData = serializedResultData, + ) + } + + override suspend fun completeJobWithFailure( + jobId: String, + processingTime: Duration, + resultType: JobCompletionResultType, + retryDelay: Duration, + permanentlyFail: Boolean, + skipAttempt: Boolean, + failureMessage: String?, + failureStacktrace: String? + ) { + repository.retryOrPermanentlyFailJob( + jobId = Uuid.parse(jobId), + processingTime = processingTime, + resultType = resultType, + retryDelay = retryDelay, + permanentlyFail = permanentlyFail, + skipAttempt = skipAttempt, + failureMessage = failureMessage ?: "Unknown error", + failureStacktrace = failureStacktrace, + ) + } + +// Cancellation +// --------------------------------------------------------------------------------------------------------------------- + + override suspend fun requestJobCancellation(jobId: String) { + repository.requestCancellation( + jobId = Uuid.parse(jobId), + ) + } + + override fun subscribeToJobCancellation(jobId: String): Flow { + return pollingFlow( + pollNext = { + if (repository.isJobCancelled(Uuid.parse(jobId))) { + Unit + } else { + null + } + }, + awaitNext = { delay(1.seconds) } + ) + } + +// Utils +// --------------------------------------------------------------------------------------------------------------------- +} + +/* + +UPDATE jobs +SET + status= + CASE WHEN (jobs.unique_until IS NOT NULL) AND (jobs.unique_until > CURRENT_TIMESTAMP) + THEN + CAST('Cooldown' AS job_status) ELSE CAST('Succeeded' AS job_status) + END, + result_data=$1::jsonb, + last_run_result_type=$2, + last_run_duration=$3, + last_run_finished_at=$4, + last_run_failure_message=$5, + last_run_failure_stacktrace=$6 +WHERE jobs.id = $7 + + + */ diff --git a/ballast-queue-exposed-driver/src/jvmMain/kotlin/com/copperleaf/ballast/queue/driver/db/ExposedDatabaseQueueMigrations.kt b/ballast-queue-exposed-driver/src/jvmMain/kotlin/com/copperleaf/ballast/queue/driver/db/ExposedDatabaseQueueMigrations.kt new file mode 100644 index 00000000..7cbf10be --- /dev/null +++ b/ballast-queue-exposed-driver/src/jvmMain/kotlin/com/copperleaf/ballast/queue/driver/db/ExposedDatabaseQueueMigrations.kt @@ -0,0 +1,54 @@ +package com.copperleaf.ballast.queue.driver.db + +import org.jetbrains.exposed.v1.core.vendors.MysqlDialect +import org.jetbrains.exposed.v1.core.vendors.PostgreSQLDialect +import org.jetbrains.exposed.v1.core.vendors.currentDialect +import org.jetbrains.exposed.v1.jdbc.Database +import org.jetbrains.exposed.v1.jdbc.JdbcTransaction +import org.jetbrains.exposed.v1.jdbc.transactions.suspendTransaction + +/** + * This class is responsible for applying database migrations to the Exposed database. It does not track which + * migrations have been applied, so it should only be used for testing and evaluation. It should not be used in + * production code, you should use a proper migration tool like Flyway or Liquibase instead, using the migrations files + * provided in the `migrations` resource directory. + */ +@Deprecated("This class should only be used for testing and evaluation. It should not be used in production code.") +public class ExposedDatabaseQueueMigrations( + private val database: Database, + private val table: JobsTable, +) { + public suspend fun applyMigrations() { + suspendTransaction(database) { + applyV1() + } + } + + private suspend fun JdbcTransaction.applyV1() { + val migrationResource = when (currentDialect) { + is PostgreSQLDialect -> { + this::class.java.classLoader + .getResource("migrations/postgres/V01_create_table.sql") + ?: error("Migration file not found") + } + + is MysqlDialect -> { + this::class.java.classLoader + .getResource("migrations/mysql/V01_create_table.sql") + ?: error("Migration file not found") + } + + else -> { + error("Unsupported database dialect: $currentDialect") + } + } + + migrationResource + .readText() + .replace($$"${tableName}", table.tableName) + .split(";") + .map { it.trim() } + .filter { it.isNotBlank() } + .forEach { exec(it) } + } +} diff --git a/ballast-queue-exposed-driver/src/jvmMain/kotlin/com/copperleaf/ballast/queue/driver/db/JobsTable.kt b/ballast-queue-exposed-driver/src/jvmMain/kotlin/com/copperleaf/ballast/queue/driver/db/JobsTable.kt new file mode 100644 index 00000000..4fb7bbf3 --- /dev/null +++ b/ballast-queue-exposed-driver/src/jvmMain/kotlin/com/copperleaf/ballast/queue/driver/db/JobsTable.kt @@ -0,0 +1,202 @@ +package com.copperleaf.ballast.queue.driver.db + +import com.copperleaf.ballast.queue.JobCompletionResultType +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.and +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IdTable +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.inList +import org.jetbrains.exposed.v1.core.isNotNull +import org.jetbrains.exposed.v1.datetime.CurrentTimestamp +import org.jetbrains.exposed.v1.datetime.duration +import org.jetbrains.exposed.v1.datetime.timestamp +import org.jetbrains.exposed.v1.json.jsonb +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant +import kotlin.uuid.Uuid + +/** + * This class represents the "jobs" table in the database used for job queueing. It defines the schema of the + * table, including columns and indexes, for efficiently querying and maintaining the job queue. + * + * It is an abstract class that can be extended to rename the table in your DB to something more appropriate for your + * needs. By default, you can use the [JobsTable.Default] object which uses the table name "jobs". + */ +public abstract class JobsTable(tableName: String) : IdTable(tableName) { + + public object Default : JobsTable("jobs") + + // Columns +// --------------------------------------------------------------------------------------------------------------------- + final override val id: Column> = uuid("id") + .databaseGenerated() + .autoGenerate() + .entityId() + + final override val primaryKey: PrimaryKey = PrimaryKey(id) + + public val queue: Column = text("queue") + public val original_queue: Column = text("original_queue").nullable().default(null) + + public val payload: Column = jsonb("payload", Json, JsonElement.serializer()) + public val job_state: Column = jsonb("job_state", Json, JsonElement.serializer()) + public val result_data: Column = jsonb("result_data", Json, JsonElement.serializer()) + .nullable() + .default(null) + + public val priority: Column = integer("priority") + .default(0) + public val run_at: Column = timestamp("run_at") + .databaseGenerated() + .defaultExpression(CurrentTimestamp) + + public val max_attempts: Column = integer("max_attempts") + .default(5) + public val retry_until: Column = timestamp("retry_until") + .nullable() + .default(null) + + public val timeout_duration: Column = duration("timeout_duration") + .default(30.seconds) + public val lease_buffer_duration: Column = duration("lease_buffer_duration") + .default(30.seconds) + public val leased_at: Column = timestamp("leased_at") + .nullable() + .default(null) + public val leased_until: Column = timestamp("leased_until") + .nullable() + .default(null) + + public val deduplication_key: Column = text("deduplication_key") + .nullable() + .default(null) + public val deduplication_duration: Column = duration("deduplication_duration") + .nullable() + .default(null) + public val unique_until: Column = timestamp("unique_until") + .nullable() + .default(null) + + public val message_group: Column = text("message_group") + .nullable() + .default(null) + + // updated when a job is selected for processing + public val status: Column = + enumerationByName( + name = "status", + length = 10, + klass = ExposedDatabaseJobStatus::class + ) + .check { it inList ExposedDatabaseJobStatus.entries } + .default(ExposedDatabaseJobStatus.Pending) + public val attempts: Column = integer("attempts") + .default(0) + + // set when a job is completed successfully or failed + public val last_run_result_type: Column = + enumerationByName( + name = "last_run_result_type", + length = 10, + klass = JobCompletionResultType::class + ) + .nullable() + .check { it inList JobCompletionResultType.entries } + .default(null) + public val last_run_finished_at: Column = timestamp("last_run_finished_at") + .nullable() + .default(null) + public val last_run_duration: Column = duration("last_run_duration") + .nullable() + .default(null) + public val last_run_failure_message: Column = text("last_run_failure_message") + .nullable() + .default(null) + public val last_run_failure_stacktrace: Column = text("last_run_failure_stacktrace") + .nullable() + .default(null) + + public val created_at: Column = timestamp("created_at") + .databaseGenerated() + .defaultExpression(CurrentTimestamp) + public val updated_at: Column = timestamp("updated_at") + .databaseGenerated() + .defaultExpression(CurrentTimestamp) + +// Indexes +// --------------------------------------------------------------------------------------------------------------------- + + /** + * Index to enforce uniqueness of jobs with a deduplication key that are still considered "unique" (i.e., their + * uniqueness has not expired). This prevents multiple identical jobs from being enqueued simultaneously. + * + * Jobs with the same [deduplication_key] are unique until the [unique_until] has passed, while they are in one of + * the following states: + * + * - [ExposedDatabaseJobStatus.Pending]: The job is enqueued. Don't enqueue another, even if it's run_at would be later + * than this jobs's [unique_until], since it's possible that this job fails and will get scheduled for retry. + * - [ExposedDatabaseJobStatus.Running]: The unique job has been selected for processing. Don't enqueue another, since + * it's possible that this job fails and will get scheduled for retry. + * - [ExposedDatabaseJobStatus.Cooldown]: The job has completed, but is now in cooldown mode. A maintenance task will + * eventually move this job's [state] to [ExposedDatabaseJobStatus.Succeeded] once the cooldown period has expired. Until + * it has actually been moved to Succeeded, we must still consider it unique. + */ + private val uniqueindex__jobs__unique_jobs = index( + "uniqueindex__${tableName}__unique_jobs", + true, + *arrayOf(queue, deduplication_key), + ) { + unique_until.isNotNull() and + (status inList listOf(ExposedDatabaseJobStatus.Pending, ExposedDatabaseJobStatus.Running, ExposedDatabaseJobStatus.Cooldown)) + } + + /** + * Index to efficiently query for pending jobs that are ready to be processed, ordered by priority and scheduled + * run time. + * + * @see [com.copperleaf.ballast.queue.driver.db.repository.JobsRepository.claimNextAvailableJob] + */ + private val index__jobs__eligible_pending_jobs = index( + "index__${tableName}__eligible_pending_jobs", + false, + *arrayOf(queue, status, priority, run_at), + ) { status eq ExposedDatabaseJobStatus.Pending } + + /** + * Index to efficiently find completed jobs eligible for deletion by a maintenance task. + * + * @see [com.copperleaf.ballast.queue.driver.db.repository.JobsMaintenanceRepository.deleteOldJobs] + */ + private val index__jobs__age_expired = index( + "index__${tableName}__age_expired", + false, + *arrayOf(status, last_run_finished_at), + ) { status eq ExposedDatabaseJobStatus.Succeeded } + + /** + * Index to efficiently find jobs that are in cooldown mode, but beyond their [unique_until] time. These jobs + * can be moved to [ExposedDatabaseJobStatus.Succeeded] by a maintenance task. + * + * @see [com.copperleaf.ballast.queue.driver.db.repository.JobsMaintenanceRepository.freeJobCooldowns] + */ + private val index__jobs__cooldown_expired = index( + "index__${tableName}__cooldown_expired", + false, + *arrayOf(status, unique_until), + ) { status eq ExposedDatabaseJobStatus.Cooldown } + + /** + * Index to efficiently find running jobs that have exceeded their lease period, and are eligible to be retried. + * + * @see [com.copperleaf.ballast.queue.driver.db.repository.JobsMaintenanceRepository.retryHungJobs] + */ + private val index__jobs__lease_timeout_expired = index( + "index__${tableName}__lease_timeout_expired", + false, + *arrayOf(status, leased_until), + ) { (status eq ExposedDatabaseJobStatus.Running) } +} diff --git a/ballast-queue-exposed-driver/src/jvmMain/kotlin/com/copperleaf/ballast/queue/driver/db/TimestampAdd.kt b/ballast-queue-exposed-driver/src/jvmMain/kotlin/com/copperleaf/ballast/queue/driver/db/TimestampAdd.kt new file mode 100644 index 00000000..e66ca835 --- /dev/null +++ b/ballast-queue-exposed-driver/src/jvmMain/kotlin/com/copperleaf/ballast/queue/driver/db/TimestampAdd.kt @@ -0,0 +1,40 @@ +package com.copperleaf.ballast.queue.driver.db + +import org.jetbrains.exposed.v1.core.Expression +import org.jetbrains.exposed.v1.core.QueryBuilder +import org.jetbrains.exposed.v1.core.longLiteral +import org.jetbrains.exposed.v1.core.vendors.DatabaseDialect +import org.jetbrains.exposed.v1.core.vendors.MysqlDialect +import org.jetbrains.exposed.v1.core.vendors.PostgreSQLDialect +import kotlin.time.Duration +import kotlin.time.Instant + +internal class TimestampAdd( + private val start: Expression, + private val duration: Duration, + private val dialect: DatabaseDialect +) : Expression() { + override fun toQueryBuilder(queryBuilder: QueryBuilder) = queryBuilder { + when (dialect) { + is PostgreSQLDialect -> { + // ($start + ($duration || ' seconds')::interval) + append("(") + append(start) + append(" + (") + append(longLiteral(duration.inWholeSeconds)) + append(" || ' seconds')::interval)") + } + is MysqlDialect -> { + // DATE_ADD($start, INTERVAL $duration SECOND) + append("DATE_ADD(") + append(start) + append(", INTERVAL ") + append(longLiteral(duration.inWholeSeconds)) + append(" SECOND)") + } + else -> { + error("Unsupported database dialect: $dialect") + } + } + } +} diff --git a/ballast-queue-exposed-driver/src/jvmMain/kotlin/com/copperleaf/ballast/queue/driver/db/repository/JobsMaintenanceRepository.kt b/ballast-queue-exposed-driver/src/jvmMain/kotlin/com/copperleaf/ballast/queue/driver/db/repository/JobsMaintenanceRepository.kt new file mode 100644 index 00000000..bcbc9df8 --- /dev/null +++ b/ballast-queue-exposed-driver/src/jvmMain/kotlin/com/copperleaf/ballast/queue/driver/db/repository/JobsMaintenanceRepository.kt @@ -0,0 +1,67 @@ +package com.copperleaf.ballast.queue.driver.db.repository + +import com.copperleaf.ballast.queue.driver.db.ExposedDatabaseJobStatus.Cancelled +import com.copperleaf.ballast.queue.driver.db.ExposedDatabaseJobStatus.Cooldown +import com.copperleaf.ballast.queue.driver.db.ExposedDatabaseJobStatus.Failed +import com.copperleaf.ballast.queue.driver.db.ExposedDatabaseJobStatus.Pending +import com.copperleaf.ballast.queue.driver.db.ExposedDatabaseJobStatus.Running +import com.copperleaf.ballast.queue.driver.db.ExposedDatabaseJobStatus.Succeeded +import com.copperleaf.ballast.queue.driver.db.ExposedDatabaseQueueDriver.Metadata +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days + +public interface JobsMaintenanceRepository { + /** + * Deletes all jobs that have been in the [Succeeded] state for longer than the given [duration]. + */ + public suspend fun deleteOldJobs(duration: Duration = 30.days) + + /** + * Moves all Unique jobs in the [Cooldown] state to [Succeeded] if their cooldown period has expired, allowing + * another job at the same [Metadata.deduplicationKey]` to be inserted into the queue. + */ + public suspend fun freeJobCooldowns() + + /** + * Moves all jobs in the [Running] or [Cancelled] state whose lease has expired back to [Pending], so they can be + * retried, or moved to [Failed] if they are not eligible for retry. + */ + public suspend fun retryHungJobs() + + /** + * Moves all jobs in the [Failed] state to the given [deadLetterQueueName], so the permanent failure can be + * reported and inspected. It's assumed that the DLQ will do little more than log an error or trigger an alert + * to notify operators of the failure, so they issue can be addressed. If [originalQueueName] is non-null, then + * only the jobs from that queue will be sep + * + * Once the root issue has been resolved, jobs can be moved back from the DLQ to their original queue for + * reprocessing with [moveFromDeadLetterQueue]. + */ + public suspend fun moveToDeadLetterQueue(deadLetterQueueName: String, originalQueueName: String? = null) + + /** + * Moves jobs in the [Succeeded] state from the Dead Letter Queue with the given [deadLetterQueueName] back to their + * original queue, indicating that the issue causing the jobs to fail has been addressed and they are ready to be + * reprocessed. If [originalQueueName] is provided, only jobs from that original queue will be moved back; + * otherwise, all jobs in the dead letter queue will be moved back to their respective original queues. + * + * When moved back to the original queue, they are granted an additional number of attempts specified by + * [additionalAttempts] to allow for successful processing and retries + */ + public suspend fun moveFromDeadLetterQueue( + deadLetterQueueName: String, + originalQueueName: String?, + additionalAttempts: Int = 5, + ) + + /** + * Sometimes, messages sent to the DLQ are determined to be non-recoverable and should be deleted entirely. + * This function deletes jobs from the specified [deadLetterQueueName]. If [originalQueueName] is provided, + * only jobs that originated from that queue will be deleted; otherwise, all jobs in the dead letter queue will be + * deleted. + */ + public suspend fun deleteFromDeadLetterQueue( + deadLetterQueueName: String, + originalQueueName: String?, + ) +} diff --git a/ballast-queue-exposed-driver/src/jvmMain/kotlin/com/copperleaf/ballast/queue/driver/db/repository/JobsMaintenanceRepositoryImpl.kt b/ballast-queue-exposed-driver/src/jvmMain/kotlin/com/copperleaf/ballast/queue/driver/db/repository/JobsMaintenanceRepositoryImpl.kt new file mode 100644 index 00000000..884f5d45 --- /dev/null +++ b/ballast-queue-exposed-driver/src/jvmMain/kotlin/com/copperleaf/ballast/queue/driver/db/repository/JobsMaintenanceRepositoryImpl.kt @@ -0,0 +1,118 @@ +package com.copperleaf.ballast.queue.driver.db.repository + +import com.copperleaf.ballast.queue.driver.db.ExposedDatabaseJobStatus +import com.copperleaf.ballast.queue.driver.db.JobsTable +import com.copperleaf.ballast.queue.driver.db.TimestampAdd +import org.jetbrains.exposed.v1.core.Op +import org.jetbrains.exposed.v1.core.SqlLogger +import org.jetbrains.exposed.v1.core.and +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.inList +import org.jetbrains.exposed.v1.core.lessEq +import org.jetbrains.exposed.v1.core.plus +import org.jetbrains.exposed.v1.core.vendors.currentDialect +import org.jetbrains.exposed.v1.datetime.CurrentTimestamp +import org.jetbrains.exposed.v1.jdbc.Database +import org.jetbrains.exposed.v1.jdbc.deleteWhere +import org.jetbrains.exposed.v1.jdbc.transactions.suspendTransaction +import org.jetbrains.exposed.v1.jdbc.update +import kotlin.time.Clock +import kotlin.time.Duration + +public class JobsMaintenanceRepositoryImpl( + private val database: Database, + private val table: JobsTable = JobsTable.Default, + private val clock: Clock = Clock.System, + private val logger: SqlLogger? = null, +) : JobsMaintenanceRepository { + + private suspend fun withTransaction(log: Boolean = true, block: suspend () -> T): T { + return suspendTransaction(database) { + if (log && logger != null) { + addLogger(logger) + } + block() + } + } + + override suspend fun deleteOldJobs(duration: Duration) { + withTransaction { + table.deleteWhere { + (table.status eq ExposedDatabaseJobStatus.Succeeded) and + (TimestampAdd(last_run_finished_at, duration, currentDialect) lessEq CurrentTimestamp) + } + } + } + + override suspend fun freeJobCooldowns() { + withTransaction { + table.update({ + (table.status eq ExposedDatabaseJobStatus.Cooldown) and + (table.unique_until lessEq CurrentTimestamp) + }) { + it[table.status] = ExposedDatabaseJobStatus.Succeeded + } + } + } + + override suspend fun retryHungJobs() { + withTransaction { + table.update({ + (table.status inList listOf(ExposedDatabaseJobStatus.Running, ExposedDatabaseJobStatus.Cancelled)) and + (table.leased_until lessEq CurrentTimestamp) + }) { + retryOrFailStatusColumn(it) + // Clear the stale lease so the row is clean regardless of which status was chosen above. + it[table.leased_at] = null + it[table.leased_until] = null + } + } + } + + override suspend fun moveToDeadLetterQueue(deadLetterQueueName: String, originalQueueName: String?) { + withTransaction { + table.update({ + if (originalQueueName != null) { + (table.status eq ExposedDatabaseJobStatus.Failed) and (table.queue eq originalQueueName) + } else { + table.status eq ExposedDatabaseJobStatus.Failed + } + }) { + moveToDeadLetterQueue(it, deadLetterQueueName, clock) + } + } + } + + override suspend fun moveFromDeadLetterQueue( + deadLetterQueueName: String, + originalQueueName: String?, + additionalAttempts: Int, + ) { + withTransaction { + table.update({ + (table.queue eq deadLetterQueueName) and + (if (originalQueueName != null) table.original_queue eq originalQueueName else Op.TRUE) + }) { + it[table.queue] = original_queue + it[table.status] = ExposedDatabaseJobStatus.Pending + + it[run_at] = clock.now() + it[max_attempts] = max_attempts + additionalAttempts + it[retry_until] = null + it[table.original_queue] = null + } + } + } + + override suspend fun deleteFromDeadLetterQueue( + deadLetterQueueName: String, + originalQueueName: String? + ) { + withTransaction { + table.deleteWhere { + (table.queue eq deadLetterQueueName) and + (if (originalQueueName != null) table.original_queue eq originalQueueName else Op.TRUE) + } + } + } +} diff --git a/ballast-queue-exposed-driver/src/jvmMain/kotlin/com/copperleaf/ballast/queue/driver/db/repository/JobsRepository.kt b/ballast-queue-exposed-driver/src/jvmMain/kotlin/com/copperleaf/ballast/queue/driver/db/repository/JobsRepository.kt new file mode 100644 index 00000000..c875b43b --- /dev/null +++ b/ballast-queue-exposed-driver/src/jvmMain/kotlin/com/copperleaf/ballast/queue/driver/db/repository/JobsRepository.kt @@ -0,0 +1,69 @@ +package com.copperleaf.ballast.queue.driver.db.repository + +import com.copperleaf.ballast.queue.JobCompletionResultType +import com.copperleaf.ballast.queue.SerializedJob +import com.copperleaf.ballast.queue.driver.db.ExposedDatabaseQueueDriver +import kotlin.time.Duration +import kotlin.uuid.Uuid + +public interface JobsRepository { + + public suspend fun getAllJobs(): List> + + public suspend fun getAllJobsInQueue( + queueName: String, + ): List> + + public suspend fun claimNextAvailableJob( + queueName: String, + ): SerializedJob? + + public suspend fun insertJob( + queueName: String, + serializedPayload: String, + serializedInitialState: String, + timeoutDuration: Duration, + metadata: ExposedDatabaseQueueDriver.Metadata, + ): Uuid + + public suspend fun completeJob( + jobId: Uuid, + processingTime: Duration, + resultType: JobCompletionResultType, + serializedResultData: String?, + ) + + public suspend fun retryOrPermanentlyFailJob( + jobId: Uuid, + processingTime: Duration, + resultType: JobCompletionResultType, + retryDelay: Duration, + permanentlyFail: Boolean, + skipAttempt: Boolean, + failureMessage: String?, + failureStacktrace: String?, + ) + + public suspend fun setJobState( + jobId: Uuid, + serializedState: String, + ) + + public suspend fun requestCancellation( + jobId: Uuid, + ) + + public suspend fun isJobCancelled( + jobId: Uuid, + ): Boolean + + public suspend fun deleteJob( + jobId: Uuid, + ) + + public suspend fun forceRetry( + jobId: Uuid, + retryDelay: Duration = Duration.ZERO, + additionalAttempts: Int = 1, + ) +} diff --git a/ballast-queue-exposed-driver/src/jvmMain/kotlin/com/copperleaf/ballast/queue/driver/db/repository/JobsRepositoryImpl.kt b/ballast-queue-exposed-driver/src/jvmMain/kotlin/com/copperleaf/ballast/queue/driver/db/repository/JobsRepositoryImpl.kt new file mode 100644 index 00000000..47304ad9 --- /dev/null +++ b/ballast-queue-exposed-driver/src/jvmMain/kotlin/com/copperleaf/ballast/queue/driver/db/repository/JobsRepositoryImpl.kt @@ -0,0 +1,400 @@ +package com.copperleaf.ballast.queue.driver.db.repository + +import com.copperleaf.ballast.queue.JobCompletionResultType +import com.copperleaf.ballast.queue.SerializedJob +import com.copperleaf.ballast.queue.driver.db.ExposedDatabaseJobStatus +import com.copperleaf.ballast.queue.driver.db.ExposedDatabaseQueueDriver +import com.copperleaf.ballast.queue.driver.db.JobsTable +import kotlinx.serialization.json.Json +import org.jetbrains.exposed.v1.core.Case +import org.jetbrains.exposed.v1.core.LiteralOp +import org.jetbrains.exposed.v1.core.SortOrder +import org.jetbrains.exposed.v1.core.SqlLogger +import org.jetbrains.exposed.v1.core.alias +import org.jetbrains.exposed.v1.core.and +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.greater +import org.jetbrains.exposed.v1.core.intLiteral +import org.jetbrains.exposed.v1.core.isNotNull +import org.jetbrains.exposed.v1.core.isNull +import org.jetbrains.exposed.v1.core.lessEq +import org.jetbrains.exposed.v1.core.notExists +import org.jetbrains.exposed.v1.core.or +import org.jetbrains.exposed.v1.core.plus +import org.jetbrains.exposed.v1.core.vendors.ForUpdateOption +import org.jetbrains.exposed.v1.core.vendors.MysqlDialect +import org.jetbrains.exposed.v1.core.vendors.PostgreSQLDialect +import org.jetbrains.exposed.v1.core.vendors.currentDialect +import org.jetbrains.exposed.v1.datetime.CurrentTimestamp +import org.jetbrains.exposed.v1.jdbc.Database +import org.jetbrains.exposed.v1.jdbc.deleteWhere +import org.jetbrains.exposed.v1.jdbc.insertAndGetId +import org.jetbrains.exposed.v1.jdbc.select +import org.jetbrains.exposed.v1.jdbc.transactions.suspendTransaction +import org.jetbrains.exposed.v1.jdbc.update +import org.jetbrains.exposed.v1.jdbc.updateReturning +import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.uuid.Uuid + +public class JobsRepositoryImpl( + private val database: Database, + private val table: JobsTable = JobsTable.Default, + private val clock: Clock = Clock.System, + private val json: Json = Json.Default, + private val logger: SqlLogger? = null, +) : JobsRepository { + + private suspend fun withTransaction(log: Boolean = true, block: suspend () -> T): T { + return suspendTransaction(database) { + if (log && logger != null) { + addLogger(logger) + } + block() + } + } + + override suspend fun getAllJobs(): List> { + return withTransaction(false) { + table + .select(table.columns) + .map { resultRow -> + mapResultRowToSerializedJob( + table, + resultRow, + ) + } + } + } + + override suspend fun getAllJobsInQueue(queueName: String): List> { + return withTransaction { + table + .select(table.columns) + .where { table.queue eq queueName } + .map { resultRow -> + mapResultRowToSerializedJob( + table, + resultRow, + ) + } + } + } + +// Claim job +// --------------------------------------------------------------------------------------------------------------------- + + override suspend fun claimNextAvailableJob( + queueName: String, + ): SerializedJob? { + // assumes an existing database in transaction from the caller. But we need a sub-transaction here to do + // the FOR UPDATE SKIP LOCKED + return withTransaction(false) { + when (currentDialect) { + is PostgreSQLDialect -> { + claimNextAvailableJobForPostgres( + queueName, + ) + } + + is MysqlDialect -> { + claimNextAvailableJobForMysql( + queueName, + ) + } + + else -> { + error("Unsupported database dialect: $currentDialect") + } + } + } + } + + private suspend fun claimNextAvailableJobForPostgres( + queueName: String, + ): SerializedJob? { + // assumes an existing database in transaction from the caller. But we need a sub-transaction here to do + // the FOR UPDATE SKIP LOCKED + + val now = clock.now() + + val outerQueryTable = table.alias("outer_jobs") + val innerQueryTable = table.alias("inner_jobs") + + // Step 1: Find the next eligible job with FOR UPDATE SKIP LOCKED to ensure jobs are selected exactly once + val initialResultRow = outerQueryTable + .select(outerQueryTable.columns) + .where { + (outerQueryTable[table.queue] eq queueName) and + (outerQueryTable[table.status] eq ExposedDatabaseJobStatus.Pending) and + (outerQueryTable[table.run_at] lessEq now) and + ((outerQueryTable[table.message_group].isNull()) or notExists( + innerQueryTable + .select(intLiteral(1)) + .where { + (innerQueryTable[table.message_group] eq outerQueryTable[table.message_group]) and + (innerQueryTable[table.status] eq ExposedDatabaseJobStatus.Running) + } + )) + } + .orderBy( + outerQueryTable[table.priority] to SortOrder.DESC, + outerQueryTable[table.run_at] to SortOrder.ASC, // oldest eligible job first (FIFO within same priority) + ) + .forUpdate(ForUpdateOption.PostgreSQL.ForUpdate(ForUpdateOption.PostgreSQL.MODE.SKIP_LOCKED)) + .limit(1) + .singleOrNull() + ?: return null + + // Step 2: Update the job to mark it as in-progress, and return the updated job row + val resultRow = table + .updateReturning( + returning = table.columns, + where = { table.id eq initialResultRow[outerQueryTable[table.id]].value }, + body = { + it[status] = ExposedDatabaseJobStatus.Running + it[attempts] = initialResultRow[outerQueryTable[table.attempts]] + 1 + it[leased_at] = now + it[leased_until] = + now + initialResultRow[outerQueryTable[table.timeout_duration]] + initialResultRow[outerQueryTable[table.lease_buffer_duration]] + } + ) + .single() + + // Step 3: map the selected row to SerializedJob + return mapResultRowToSerializedJob( + table, + resultRow, + ) + } + + private suspend fun claimNextAvailableJobForMysql( + queueName: String, + ): SerializedJob? { + val now = clock.now() + + val outerQueryTable = table.alias("outer_jobs") + val innerQueryTable = table.alias("inner_jobs") + + // Step 1: Find the next eligible job with FOR UPDATE SKIP LOCKED to ensure jobs are selected exactly once + val initialResultRow = outerQueryTable + .select(outerQueryTable.columns) + .where { + (outerQueryTable[table.queue] eq queueName) and + (outerQueryTable[table.status] eq ExposedDatabaseJobStatus.Pending) and + (outerQueryTable[table.run_at] lessEq now) and + ((outerQueryTable[table.message_group].isNull()) or notExists( + innerQueryTable + .select(intLiteral(1)) + .where { + (innerQueryTable[table.message_group] eq outerQueryTable[table.message_group]) and + (innerQueryTable[table.status] eq ExposedDatabaseJobStatus.Running) + } + )) + } + .orderBy( + outerQueryTable[table.priority] to SortOrder.DESC, + outerQueryTable[table.run_at] to SortOrder.ASC, // oldest eligible job first (FIFO within same priority) + ) + .forUpdate(ForUpdateOption.MySQL.ForUpdate(ForUpdateOption.MySQL.MODE.SKIP_LOCKED)) + .limit(1) + .singleOrNull() + ?: return null + + // Step 2: Update the job to mark it as in-progress + table + .update( + where = { table.id eq initialResultRow[outerQueryTable[table.id]].value }, + body = { + it[status] = ExposedDatabaseJobStatus.Running + it[attempts] = initialResultRow[outerQueryTable[table.attempts]] + 1 + it[leased_at] = now + it[leased_until] = + now + initialResultRow[outerQueryTable[table.timeout_duration]] + initialResultRow[outerQueryTable[table.lease_buffer_duration]] + } + ) + + val resultRow = table + .select(table.columns) + .where { table.id eq initialResultRow[outerQueryTable[table.id]].value } + .limit(1) + .single() + + // Step 3: map the selected row to SerializedJob + return mapResultRowToSerializedJob( + table, + resultRow, + ) + } + +// Insert job +// --------------------------------------------------------------------------------------------------------------------- + + override suspend fun insertJob( + queueName: String, + serializedPayload: String, + serializedInitialState: String, + timeoutDuration: Duration, + metadata: ExposedDatabaseQueueDriver.Metadata, + ): Uuid { + return withTransaction { + table.insertAndGetId { + it[table.queue] = queueName + it[table.payload] = json.parseToJsonElement(serializedPayload) + it[table.job_state] = json.parseToJsonElement(serializedInitialState) + it[table.priority] = metadata.priority + it[table.run_at] = metadata.runAt + it[table.max_attempts] = metadata.maxAttempts + it[table.retry_until] = metadata.retryUntil + it[table.timeout_duration] = timeoutDuration + it[table.lease_buffer_duration] = metadata.leaseBufferDuration + + if (metadata.deduplicationKey != null) { + requireNotNull(metadata.deduplicationDuration) + it[table.deduplication_key] = metadata.deduplicationKey + it[table.unique_until] = metadata.runAt + metadata.deduplicationDuration + } else { + it[table.deduplication_key] = null + it[table.unique_until] = null + } + it[table.message_group] = metadata.messageGroup + }.value + } + } + + override suspend fun completeJob( + jobId: Uuid, + processingTime: Duration, + resultType: JobCompletionResultType, + serializedResultData: String?, + ) { + withTransaction { + table.update({ table.id eq jobId }) { + it[table.status] = Case() + .When( + cond = table.unique_until.isNotNull() and (table.unique_until greater CurrentTimestamp), + result = LiteralOp(table.status.columnType, ExposedDatabaseJobStatus.Cooldown), + ) + .Else( + LiteralOp(table.status.columnType, ExposedDatabaseJobStatus.Succeeded) + ) + + it[leased_at] = null + it[leased_until] = null + + it[table.result_data] = serializedResultData?.let { data -> json.parseToJsonElement(data) } + + it[table.last_run_result_type] = resultType + it[table.last_run_duration] = processingTime + it[table.last_run_finished_at] = clock.now() + it[table.last_run_failure_message] = null + it[table.last_run_failure_stacktrace] = null + } + } + } + + override suspend fun retryOrPermanentlyFailJob( + jobId: Uuid, + processingTime: Duration, + resultType: JobCompletionResultType, + retryDelay: Duration, + permanentlyFail: Boolean, + skipAttempt: Boolean, + failureMessage: String?, + failureStacktrace: String?, + ) { + withTransaction { + table.update({ table.id eq jobId }) { + if (permanentlyFail) { + it[table.status] = ExposedDatabaseJobStatus.Failed + } else { + retryOrFailStatusColumn(it) + it[run_at] = clock.now() + retryDelay + it[max_attempts] = if (skipAttempt) max_attempts + 1 else max_attempts + } + + it[leased_at] = null + it[leased_until] = null + + it[table.result_data] = null + + it[table.last_run_result_type] = resultType + it[table.last_run_duration] = processingTime + it[table.last_run_finished_at] = clock.now() + it[table.last_run_failure_message] = failureMessage + it[table.last_run_failure_stacktrace] = failureStacktrace + } + } + } + + override suspend fun setJobState( + jobId: Uuid, + serializedState: String, + ) { + withTransaction { + table.update({ table.id eq jobId }) { + it[table.job_state] = json.parseToJsonElement(serializedState) + } + } + } + + override suspend fun requestCancellation(jobId: Uuid) { + withTransaction { + table.update({ table.id eq jobId }) { + it[table.status] = Case() + .When( + cond = (table.status eq ExposedDatabaseJobStatus.Running) and + (table.leased_until greater CurrentTimestamp), + result = LiteralOp(table.status.columnType, ExposedDatabaseJobStatus.Cancelled), + ) + .Else(table.status) + } + } + } + + override suspend fun isJobCancelled(jobId: Uuid): Boolean { + return withTransaction(false) { + val jobStatus = table + .select(table.id, table.status) + .where { table.id eq jobId } + .withDistinct() + .limit(1) + .singleOrNull() + ?.let { it[table.status] } + + if (jobStatus == null) { + // the row was deleted, cancel the job + true + } else if (jobStatus == ExposedDatabaseJobStatus.Cancelled) { + // the row was manually cancelled, cancel the job + true + } else { + false + } + } + } + + override suspend fun deleteJob(jobId: Uuid) { + return withTransaction(false) { + table.deleteWhere { table.id eq jobId } + } + } + + override suspend fun forceRetry( + jobId: Uuid, + retryDelay: Duration, + additionalAttempts: Int, + ) { + withTransaction { + table.update({ table.id eq jobId }) { + it[table.status] = ExposedDatabaseJobStatus.Pending + + it[run_at] = clock.now() + retryDelay + it[max_attempts] = max_attempts + additionalAttempts + + it[leased_at] = null + it[leased_until] = null + } + } + } +} diff --git a/ballast-queue-exposed-driver/src/jvmMain/kotlin/com/copperleaf/ballast/queue/driver/db/repository/queries.kt b/ballast-queue-exposed-driver/src/jvmMain/kotlin/com/copperleaf/ballast/queue/driver/db/repository/queries.kt new file mode 100644 index 00000000..6248ee2b --- /dev/null +++ b/ballast-queue-exposed-driver/src/jvmMain/kotlin/com/copperleaf/ballast/queue/driver/db/repository/queries.kt @@ -0,0 +1,83 @@ +package com.copperleaf.ballast.queue.driver.db.repository + +import com.copperleaf.ballast.queue.SerializedJob +import com.copperleaf.ballast.queue.driver.db.ExposedDatabaseJobStatus +import com.copperleaf.ballast.queue.driver.db.ExposedDatabaseQueueDriver +import com.copperleaf.ballast.queue.driver.db.JobsTable +import org.jetbrains.exposed.v1.core.Case +import org.jetbrains.exposed.v1.core.LiteralOp +import org.jetbrains.exposed.v1.core.ResultRow +import org.jetbrains.exposed.v1.core.and +import org.jetbrains.exposed.v1.core.greater +import org.jetbrains.exposed.v1.core.isNull +import org.jetbrains.exposed.v1.core.less +import org.jetbrains.exposed.v1.core.or +import org.jetbrains.exposed.v1.core.plus +import org.jetbrains.exposed.v1.core.statements.UpdateStatement +import org.jetbrains.exposed.v1.datetime.CurrentTimestamp +import kotlin.time.Clock + +internal fun JobsTable.retryOrFailStatusColumn(update: UpdateStatement) { + update[status] = Case() + .When( + // Retry if: attempts remaining AND (no deadline OR deadline is still in the future). + // retry_until is a "do not retry after" deadline — so retry while it is still ahead of us. + cond = (attempts less max_attempts) and + ((retry_until.isNull()) or + (retry_until greater CurrentTimestamp)), + result = LiteralOp(status.columnType, ExposedDatabaseJobStatus.Pending) + ) + .Else( + LiteralOp(status.columnType, ExposedDatabaseJobStatus.Failed) + ) +} + +internal fun mapResultRowToSerializedJob( + table: JobsTable, + resultRow: ResultRow, +): SerializedJob { + return SerializedJob( + jobId = resultRow[table.id].value.toString(), + queueName = resultRow[table.queue], + serializedPayload = resultRow[table.payload].toString(), + timeoutDuration = resultRow[table.timeout_duration], + serializedState = resultRow[table.job_state].toString(), + serializedResultData = resultRow[table.result_data]?.toString(), + attempts = resultRow[table.attempts], + metadata = ExposedDatabaseQueueDriver.Metadata( + insertedAt = resultRow[table.created_at], + maxAttempts = resultRow[table.max_attempts], + retryUntil = resultRow[table.retry_until], + deduplicationKey = resultRow[table.deduplication_key], + deduplicationDuration = resultRow[table.deduplication_duration], + messageGroup = resultRow[table.message_group], + priority = resultRow[table.priority], + runAt = resultRow[table.run_at], + status = resultRow[table.status], + leasedAt = resultRow[table.leased_at], + leaseBufferDuration = resultRow[table.lease_buffer_duration], + leasedUntil = resultRow[table.leased_until], + lastRunFinishedAt = resultRow[table.last_run_finished_at], + lastRunDuration = resultRow[table.last_run_duration], + lastResultType = resultRow[table.last_run_result_type], + lastErrorMessage = resultRow[table.last_run_failure_message], + lastStacktrace = resultRow[table.last_run_failure_stacktrace], + ), + ) +} + +internal fun JobsTable.moveToDeadLetterQueue( + update: UpdateStatement, + deadLetterQueueName: String, + clock: Clock, +) { + update[this.queue] = deadLetterQueueName + update[this.status] = ExposedDatabaseJobStatus.Pending + + // give the job one more attempt, intended for the DLQ processor to handle. The DLQ must be able to + // successfully report on the failed job with a single attempt, so failed jobs don't get stuck forever + // in the DLQ + update[run_at] = clock.now() + update[max_attempts] = max_attempts + 1 + update[original_queue] = queue +} diff --git a/ballast-queue-exposed-driver/src/jvmMain/resources/migrations/mysql/V01_create_table.sql b/ballast-queue-exposed-driver/src/jvmMain/resources/migrations/mysql/V01_create_table.sql new file mode 100644 index 00000000..1b8f45b7 --- /dev/null +++ b/ballast-queue-exposed-driver/src/jvmMain/resources/migrations/mysql/V01_create_table.sql @@ -0,0 +1,37 @@ +CREATE TABLE IF NOT EXISTS jobs +( + id BINARY(16) PRIMARY KEY, + queue text NOT NULL, + original_queue text DEFAULT NULL NULL, + payload JSON NOT NULL, + job_state JSON NOT NULL, + result_data JSON DEFAULT (NULL) NULL, + priority INT DEFAULT 0 NOT NULL, + run_at DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6) NOT NULL, + max_attempts INT DEFAULT 5 NOT NULL, + retry_until DATETIME(6) DEFAULT NULL NULL, + timeout_duration BIGINT DEFAULT '30000000000' NOT NULL, + lease_buffer_duration BIGINT DEFAULT '30000000000' NOT NULL, + leased_at DATETIME(6) DEFAULT NULL NULL, + leased_until DATETIME(6) DEFAULT NULL NULL, + deduplication_key text DEFAULT NULL NULL, + deduplication_duration BIGINT DEFAULT NULL NULL, + unique_until DATETIME(6) DEFAULT NULL NULL, + message_group text DEFAULT NULL NULL, + status VARCHAR(10) DEFAULT 'Pending' NOT NULL, + attempts INT DEFAULT 0 NOT NULL, + last_run_result_type VARCHAR(10) DEFAULT NULL NULL, + last_run_finished_at DATETIME(6) DEFAULT NULL NULL, + last_run_duration BIGINT DEFAULT NULL NULL, + last_run_failure_message text DEFAULT NULL NULL, + last_run_failure_stacktrace text DEFAULT NULL NULL, + created_at DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6) NOT NULL, + updated_at DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6) NOT NULL, + CONSTRAINT check_jobs_0 CHECK (status IN ('Pending', 'Running', 'Succeeded', 'Failed', 'Cooldown', 'Cancelled')), + CONSTRAINT check_jobs_1 CHECK (last_run_result_type IN ('Success', 'Cancelled', 'Timeout', 'Failure')) +); +CREATE UNIQUE INDEX uniqueindex__jobs__unique_jobs ON jobs (queue(255), deduplication_key(255)); +CREATE INDEX index__jobs__eligible_pending_jobs ON jobs (queue(255), status, priority, run_at); +CREATE INDEX index__jobs__age_expired ON jobs (status, last_run_finished_at); +CREATE INDEX index__jobs__cooldown_expired ON jobs (status, unique_until); +CREATE INDEX index__jobs__lease_timeout_expired ON jobs (status, leased_until); diff --git a/ballast-queue-exposed-driver/src/jvmMain/resources/migrations/postgres/V01_create_table.sql b/ballast-queue-exposed-driver/src/jvmMain/resources/migrations/postgres/V01_create_table.sql new file mode 100644 index 00000000..8b1181e0 --- /dev/null +++ b/ballast-queue-exposed-driver/src/jvmMain/resources/migrations/postgres/V01_create_table.sql @@ -0,0 +1,38 @@ +CREATE TABLE ${tableName} +( + id uuid PRIMARY KEY, + queue TEXT NOT NULL, + original_queue TEXT DEFAULT NULL NULL, + payload JSONB NOT NULL, + job_state JSONB NOT NULL, + result_data JSONB DEFAULT NULL::jsonb NULL, + priority INT DEFAULT 0 NOT NULL, + run_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + max_attempts INT DEFAULT 5 NOT NULL, + retry_until TIMESTAMP DEFAULT NULL NULL, + timeout_duration BIGINT DEFAULT '30000000000' NOT NULL, + lease_buffer_duration BIGINT DEFAULT '30000000000' NOT NULL, + leased_at TIMESTAMP DEFAULT NULL NULL, + leased_until TIMESTAMP DEFAULT NULL NULL, + deduplication_key TEXT DEFAULT NULL NULL, + deduplication_duration BIGINT DEFAULT NULL NULL, + unique_until TIMESTAMP DEFAULT NULL NULL, + message_group TEXT DEFAULT NULL NULL, + status VARCHAR(10) DEFAULT 'Pending' NOT NULL, + attempts INT DEFAULT 0 NOT NULL, + last_run_result_type VARCHAR(10) DEFAULT NULL NULL, + last_run_finished_at TIMESTAMP DEFAULT NULL NULL, + last_run_duration BIGINT DEFAULT NULL NULL, + last_run_failure_message TEXT DEFAULT NULL NULL, + last_run_failure_stacktrace TEXT DEFAULT NULL NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT check_jobs_0 CHECK (status IN ('Pending', 'Running', 'Succeeded', 'Failed', 'Cooldown', 'Cancelled')), + CONSTRAINT check_jobs_1 CHECK (last_run_result_type IN ('Success', 'Cancelled', 'Timeout', 'Failure')) +); +CREATE UNIQUE INDEX uniqueindex__jobs__unique_jobs ON ${tableName} (queue, deduplication_key) WHERE + (${tableName}.unique_until IS NOT NULL) AND (${tableName}.status IN ('Pending', 'Running', 'Cooldown')); +CREATE INDEX index__jobs__eligible_pending_jobs ON ${tableName} (queue, status, priority, run_at) WHERE ${tableName}.status = 'Pending'; +CREATE INDEX index__jobs__age_expired ON ${tableName} (status, last_run_finished_at) WHERE ${tableName}.status = 'Succeeded'; +CREATE INDEX index__jobs__cooldown_expired ON ${tableName} (status, unique_until) WHERE ${tableName}.status = 'Cooldown'; +CREATE INDEX index__jobs__lease_timeout_expired ON ${tableName} (status, leased_until) WHERE ${tableName}.status = 'Running'; \ No newline at end of file diff --git a/ballast-queue-exposed-driver/src/jvmTest/kotlin/com/copperleaf/ballast/queue/BaseDatabaseTest.kt b/ballast-queue-exposed-driver/src/jvmTest/kotlin/com/copperleaf/ballast/queue/BaseDatabaseTest.kt new file mode 100644 index 00000000..fff87fab --- /dev/null +++ b/ballast-queue-exposed-driver/src/jvmTest/kotlin/com/copperleaf/ballast/queue/BaseDatabaseTest.kt @@ -0,0 +1,83 @@ +@file:Suppress("DEPRECATION") + +package com.copperleaf.ballast.queue + +import com.copperleaf.ballast.queue.driver.db.ExposedDatabaseQueueMigrations +import com.copperleaf.ballast.queue.driver.db.JobsTable +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.jetbrains.exposed.v1.jdbc.Database +import org.testcontainers.containers.GenericContainer + +abstract class BaseDatabaseTest { + + private suspend fun connectToPostgres(): Pair, Database> { + val postgresContainer = GenericContainer("postgres:latest") + .withExposedPorts(5432) + .withEnv("POSTGRES_USER", "postgres") + .withEnv("POSTGRES_PASSWORD", "postgres") + postgresContainer.start() + + val host = postgresContainer.host + val port = postgresContainer.firstMappedPort + + val database = Database.connect( + "jdbc:postgresql://$host:$port/postgres", + driver = "org.postgresql.Driver", + user = "postgres", + password = "postgres" + ) + + ExposedDatabaseQueueMigrations(database, JobsTable.Default).applyMigrations() + + return postgresContainer to database + } + + private suspend fun connectToMySql(): Pair, Database> { + println("Connecting to mysql") + + val mysqlContainer = GenericContainer("mysql:latest") + .withExposedPorts(3306) + .withEnv("MYSQL_ROOT_PASSWORD", "mysql") + .withEnv("MYSQL_DATABASE", "mysql") + .withEnv("MYSQL_USER", "mysql") + .withEnv("MYSQL_PASSWORD", "mysql") + mysqlContainer.start() + + val host = mysqlContainer.host + val port = mysqlContainer.firstMappedPort + + val database = Database.connect( + "jdbc:mysql://$host:$port/mysql", + driver = "com.mysql.cj.jdbc.Driver", + user = "mysql", + password = "mysql" + ) + + ExposedDatabaseQueueMigrations(database, JobsTable.Default).applyMigrations() + + return mysqlContainer to database + } + + internal fun runTestWithDatabase(block: suspend DatabaseTestScope.() -> Unit) = runTest { + val (postgresContainer, postgresDatabase) = connectToPostgres() + postgresContainer.use { + block(DatabaseTestScope(this, postgresDatabase, JobsTable.Default)) + } + + val (mysqlContainer, mysqlDatabase) = connectToMySql() + mysqlContainer.use { + println("Running test with MySQL database at ${mysqlContainer.host}:${mysqlContainer.firstMappedPort}") + block(DatabaseTestScope(this, mysqlDatabase, JobsTable.Default)) + } + } + +// Test Scope +// --------------------------------------------------------------------------------------------------------------------- + + class DatabaseTestScope( + val testScope: TestScope, + val database: Database, + val table: JobsTable, + ) +} diff --git a/ballast-queue-exposed-driver/src/jvmTest/kotlin/com/copperleaf/ballast/queue/ExposedDatabaseQueueDriverTest.kt b/ballast-queue-exposed-driver/src/jvmTest/kotlin/com/copperleaf/ballast/queue/ExposedDatabaseQueueDriverTest.kt new file mode 100644 index 00000000..379a1a39 --- /dev/null +++ b/ballast-queue-exposed-driver/src/jvmTest/kotlin/com/copperleaf/ballast/queue/ExposedDatabaseQueueDriverTest.kt @@ -0,0 +1,110 @@ +package com.copperleaf.ballast.queue + +import com.copperleaf.ballast.queue.driver.db.ExposedDatabaseQueueDriver +import com.copperleaf.ballast.queue.driver.db.JobsTable +import com.copperleaf.ballast.queue.driver.db.repository.JobsRepositoryImpl +import com.copperleaf.ballast.scheduler.TestClock +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import org.jetbrains.exposed.v1.core.StdOutSqlLogger +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.jetbrains.exposed.v1.jdbc.transactions.suspendTransaction +import kotlin.test.Test +import kotlin.time.Duration.Companion.seconds + +class ExposedDatabaseQueueDriverTest : BaseDatabaseTest() { + +// Test Setup +// --------------------------------------------------------------------------------------------------------------------- + + val timezone = TimeZone.UTC + val startInstant = LocalDate(2025, 1, 1).atStartOfDayIn(timezone) + +// Tests +// --------------------------------------------------------------------------------------------------------------------- + + @Test + fun addToQueueTest_success() = runTestWithDatabase { + val clock = testScope.TestClock(startInstant) + val table = JobsTable.Default + val repository = JobsRepositoryImpl(database, table, clock) + val driver = ExposedDatabaseQueueDriver(repository) + + suspendTransaction(database) { + addLogger(StdOutSqlLogger) + + driver.addToQueue( + queueName = "test-queue", + serializedPayload = """{"type":"TestJob","data":{"value":42}}""", + serializedInitialState = """{"type":"TestJob","data":{"value":42}}""", + timeoutDuration = 30.seconds, + metadata = ExposedDatabaseQueueDriver.Metadata( + insertedAt = clock.now(), + maxAttempts = 5, + ) + ) + + table.assertJobEquals( + rows = table.selectAll().toList(), + expected = listOf( + SerializedJob( + jobId = "", // ID is ignored + queueName = "test-queue", + serializedPayload = """{"type":"TestJob","data":{"value":42}}""", + timeoutDuration = 30.seconds, + serializedState = """{"type":"TestJob","data":{"value":42}}""", + serializedResultData = null, + metadata = ExposedDatabaseQueueDriver.Metadata( + insertedAt = clock.now(), + maxAttempts = 5, + ), + ) + ) + ) + } + } + + @Test + fun insertAndUpdate() = runTestWithDatabase { + val clock = testScope.TestClock(startInstant) + val table = JobsTable.Default + val repository = JobsRepositoryImpl(database, table, clock) + val driver = ExposedDatabaseQueueDriver(repository) + + suspendTransaction(database) { + addLogger(StdOutSqlLogger) + + driver.addToQueue( + queueName = "test-queue", + serializedPayload = """{"type":"TestJob","data":{"value":42}}""", + serializedInitialState = """{"type":"TestJob","data":{"value":42}}""", + timeoutDuration = 30.seconds, + metadata = ExposedDatabaseQueueDriver.Metadata( + insertedAt = clock.now(), + maxAttempts = 5, + ) + ) + } + + suspendTransaction(database) { + table.assertJobEquals( + rows = table.selectAll().toList(), + expected = listOf( + SerializedJob( + jobId = "", // ID is ignored + queueName = "test-queue", + serializedPayload = """{"type":"TestJob","data":{"value":42}}""", + timeoutDuration = 30.seconds, + serializedState = """{"type":"TestJob","data":{"value":42}}""", + serializedResultData = null, + metadata = ExposedDatabaseQueueDriver.Metadata( + insertedAt = clock.now(), + maxAttempts = 5, + ), + ) + ) + ) + } + } +} diff --git a/ballast-queue-exposed-driver/src/jvmTest/kotlin/com/copperleaf/ballast/queue/Migrate.kt b/ballast-queue-exposed-driver/src/jvmTest/kotlin/com/copperleaf/ballast/queue/Migrate.kt new file mode 100644 index 00000000..fd53c6bc --- /dev/null +++ b/ballast-queue-exposed-driver/src/jvmTest/kotlin/com/copperleaf/ballast/queue/Migrate.kt @@ -0,0 +1,85 @@ +package com.copperleaf.ballast.queue + +import com.copperleaf.ballast.queue.driver.db.JobsTable +import kotlinx.coroutines.test.runTest +import org.jetbrains.exposed.v1.core.ExperimentalDatabaseMigrationApi +import org.jetbrains.exposed.v1.core.InternalApi +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.jdbc.Database +import org.jetbrains.exposed.v1.jdbc.transactions.suspendTransaction +import org.jetbrains.exposed.v1.migration.jdbc.MigrationUtils.statementsRequiredForDatabaseMigration +import java.io.File +import kotlin.test.Ignore +import kotlin.test.Test + +@Ignore +class Migrate { + + @OptIn(ExperimentalDatabaseMigrationApi::class) + @Test + fun createPostgresMigrationScript() = runTest { + val postgresqldb = Database.connect( + "jdbc:postgresql://localhost:5432/postgres", + driver = "org.postgresql.Driver", + user = "postgres", + password = "postgres" + ) + suspendTransaction(postgresqldb) { + generateMigrationScript( + JobsTable.Default, + scriptDirectory = ".", + scriptName = "postgresql_jobs", + ).also { + println(it) + } + } + } + + @OptIn(ExperimentalDatabaseMigrationApi::class) + @Test + fun createMysqlMigrationScript() = runTest { + val mysqlDb = Database.connect( + "jdbc:mysql://localhost:3306/mysql", + driver = "com.mysql.cj.jdbc.Driver", + user = "mysql", + password = "mysql" + ) + suspendTransaction(mysqlDb) { + generateMigrationScript( + JobsTable.Default, + scriptDirectory = ".", + scriptName = "mysql_jobs", + ).also { + println(it) + } + } + } + + private fun generateMigrationScript( + vararg tables: Table, + scriptDirectory: String, + scriptName: String, + withLogs: Boolean = true + ): File { + require(tables.isNotEmpty()) { "Tables argument must not be empty" } + + val allStatements = statementsRequiredForDatabaseMigration(*tables, withLogs = withLogs) + + @OptIn(InternalApi::class) + return allStatements.writeMigrationScriptTo("$scriptDirectory/$scriptName.sql") + } + + protected fun List.writeMigrationScriptTo(filePath: String): File { + val migrationScript = File(filePath) + migrationScript.createNewFile() + // Clear existing content + migrationScript.writeText("") + // Append statements + forEach { statement -> + // Add semicolon only if it's not already there + val conditionalSemicolon = if (statement.lastOrNull() == ';') "" else ";" + migrationScript.appendText("$statement$conditionalSemicolon\n") + } + return migrationScript + } +} diff --git a/ballast-queue-exposed-driver/src/jvmTest/kotlin/com/copperleaf/ballast/queue/TestClock.kt b/ballast-queue-exposed-driver/src/jvmTest/kotlin/com/copperleaf/ballast/queue/TestClock.kt new file mode 100644 index 00000000..273d78cc --- /dev/null +++ b/ballast-queue-exposed-driver/src/jvmTest/kotlin/com/copperleaf/ballast/queue/TestClock.kt @@ -0,0 +1,24 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.copperleaf.ballast.scheduler + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.currentTime +import kotlin.time.Clock +import kotlin.time.Instant + +private class TestScopeClock(private val testScope: TestScope) : Clock { + override fun now(): Instant { + return Instant.fromEpochMilliseconds(testScope.currentTime) + } +} + +fun TestScope.TestClock(startInstant: Instant? = null): Clock { + val clock = TestScopeClock(this) + startInstant?.let { + advanceTimeBy(startInstant.toEpochMilliseconds()) + } + return clock +} diff --git a/ballast-queue-exposed-driver/src/jvmTest/kotlin/com/copperleaf/ballast/queue/repository/JobsMaintenanceRepositoryTest.kt b/ballast-queue-exposed-driver/src/jvmTest/kotlin/com/copperleaf/ballast/queue/repository/JobsMaintenanceRepositoryTest.kt new file mode 100644 index 00000000..d53a278e --- /dev/null +++ b/ballast-queue-exposed-driver/src/jvmTest/kotlin/com/copperleaf/ballast/queue/repository/JobsMaintenanceRepositoryTest.kt @@ -0,0 +1,3 @@ +package com.copperleaf.ballast.queue.repository + +class JobsMaintenanceRepositoryTest diff --git a/ballast-queue-exposed-driver/src/jvmTest/kotlin/com/copperleaf/ballast/queue/repository/JobsRepositoryTest.kt b/ballast-queue-exposed-driver/src/jvmTest/kotlin/com/copperleaf/ballast/queue/repository/JobsRepositoryTest.kt new file mode 100644 index 00000000..9188dc48 --- /dev/null +++ b/ballast-queue-exposed-driver/src/jvmTest/kotlin/com/copperleaf/ballast/queue/repository/JobsRepositoryTest.kt @@ -0,0 +1,3 @@ +package com.copperleaf.ballast.queue.repository + +class JobsRepositoryTest diff --git a/ballast-queue-exposed-driver/src/jvmTest/kotlin/com/copperleaf/ballast/queue/testUtils.kt b/ballast-queue-exposed-driver/src/jvmTest/kotlin/com/copperleaf/ballast/queue/testUtils.kt new file mode 100644 index 00000000..7a960029 --- /dev/null +++ b/ballast-queue-exposed-driver/src/jvmTest/kotlin/com/copperleaf/ballast/queue/testUtils.kt @@ -0,0 +1,47 @@ +package com.copperleaf.ballast.queue + +import com.copperleaf.ballast.queue.driver.db.ExposedDatabaseQueueDriver +import com.copperleaf.ballast.queue.driver.db.JobsTable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import org.jetbrains.exposed.v1.core.ResultRow +import kotlin.test.assertEquals + +fun JobsTable.assertJobEquals( + rows: List, + expected: List>, +) { + rows.zip(expected).forEach { (row, expectedJob) -> + assertJobEquals(row, expectedJob) + } +} + +fun JobsTable.assertJobEquals( + row: ResultRow, + expected: SerializedJob, +) { + assertEquals(message = "queue", actual = row[queue], expected = expected.queueName) + assertEquals(message = "payload", actual = row[payload].testJson(), expected = expected.serializedPayload.testJson()) + assertEquals(message = "timeout", actual = row[timeout_duration], expected = expected.timeoutDuration) + assertEquals(message = "state", actual = row[job_state].testJson(), expected = expected.serializedState.testJson()) + assertEquals(message = "result_data", actual = row[result_data].testJson(), expected = expected.serializedResultData.testJson()) + + assertEquals(message = "max_attempts", actual = row[max_attempts], expected = expected.metadata.maxAttempts) + assertEquals(message = "priority", actual = row[priority], expected = expected.metadata.priority) + assertEquals(message = "run_at", actual = row[run_at], expected = expected.metadata.runAt) + assertEquals(message = "status", actual = row[status], expected = expected.metadata.status) + assertEquals(message = "attempts", actual = row[attempts], expected = expected.attempts) + assertEquals(message = "last_run_finished_at", actual = row[last_run_finished_at], expected = expected.metadata.lastRunFinishedAt) + assertEquals(message = "last_run_duration", actual = row[last_run_duration], expected = expected.metadata.lastRunDuration) + assertEquals(message = "last_run_result_type", actual = row[last_run_result_type], expected = expected.metadata.lastResultType) + assertEquals(message = "last_run_failure_message", actual = row[last_run_failure_message], expected = expected.metadata.lastErrorMessage) + assertEquals(message = "last_run_failure_stacktrace", actual = row[last_run_failure_stacktrace], expected = expected.metadata.lastStacktrace) +} + +private fun JsonElement?.testJson(json: Json = Json { prettyPrint = false }): JsonElement? { + return this +} + +private fun String?.testJson(json: Json = Json { prettyPrint = false }): JsonElement? { + return json.decodeFromString(JsonElement.serializer(), this ?: return null) +} diff --git a/ballast-queue-viewmodel/README.md b/ballast-queue-viewmodel/README.md new file mode 100644 index 00000000..972df601 --- /dev/null +++ b/ballast-queue-viewmodel/README.md @@ -0,0 +1,170 @@ +# Ballast Queue ViewModel + +> [!CAUTION] +> +> Experimental. This module may not still have issues or changes in its public API before being considered stable. +> Please use at your own risk, and file Issues for any problems you may encounter. + +## Overview + +Use the familiar Ballast ViewModel structure as the interface to a persistent job queue, allowing similar code patterns +and semantics for both client-side and server-side workloads. + +## Supported Platforms + +| Platform | Supported | +|----------|-----------| +| JVM | ✅ | +| Android | ✅ | +| iOS | ✅ | +| JS | ✅ | +| WASM JS | ✅ | + +## See Also + +- [Ballast Queue Core](./../ballast-queue-core) +- [Ballast Kotlinx Serialization](./../ballast-kotlinx-serialization) +- [Ballast Ktor Server](./../ballast-ktor-server) + +## Usage + +This module wraps a `QueueDriver` from [Ballast Queue Core](./../ballast-queue-core) and exposes its functionality +through a Ballast ViewModel. This allows you to use all of the classes you're already familiar with for UI ViewModels +and apply it to persistent queues. + +A Job Queue is created as described in [Ballast Queue Core](./../ballast-queue-core/README.md#setting-up-a-queue). +You should familiarize yourself with the concepts of the base Queue module, as all that functionality will be supported +here, but will be exposed through the Ballast ViewModel API. + +To use Ballast ViewModels as a queue executor, you will create a ViewModel and set its InputStrategy to +`JobQueueInputStrategy`, which internally creates and interacts with the executor. Internally, it will create the +`DefaultQueueExecutor` and submit and pull jobs from that executor. + +From there, you can use all the features of normal ViewModels, such as sending Inputs, processing them with an +InputHandler, updating state, and using SideJobs. There are some notable differences to some features of the Viewmodel, +though: + +- ViewModels using `JobQueueInputStrategy` do not contain a `StateFlow` and have no external state to observe. State is + maintained individually for each job in the queue, rather than globally in the viewModel, so calls to + `getCurrentState()`, `updateState { }`, etc. are delegated to [the driver's job state](./../ballast-queue-core/README.md#step-5-processing-the-job-with-state). +- The semantics of `Events` is different. Rather than using Events as a way to communicate with the UI, the `JobQueueInputStrategy` + uses an Event as the way to provide a [success result](./../ballast-queue-core/README.md#step-6-job-results), since + InputHandlers only return `Unit` and cannot return a value. Only one Event may be posted during the processing of a + job; attempts to post multiple events with throw an exception and fail the job. Events posted from SideJobs or + Interceptors will similarly fail. Events may be posted anywhere in the InputHandler during the processing of a job, + but the result will only be stored if the job completes successfully. Additionally, these Events are _not_ sent to an + `EventHandler`, which is not used by ViewModels using `JobQueueInputStrategy`. +- Some Interceptors may not work correctly, since the semantics of state updates and events is different from a + traditional UI ViewModel. The `JobQueueInputStrategy` does send Notifications whenever relevant for the purposes of + logging an observability, but features like [Sync](./../ballast-sync), [Saved State](./../ballast-sync), etc. will not + work correctly since they depend on a specific ordering of events relating to States and Events. +- [Testing](./../ballast-test) should work correctly, but make sure to use the `SyncQueueDriver`. +- SideJobs work as normal, and are the intended way to chain multiple jobs together in a pipeline by using `postInput()` + from a SideJob. You can even observe flows in a sideJob to enqueue jobs regularly, but the + [Ballast Scheduler](./../ballast-scheduler-viewmodel) is recommended for greater control and safety around running + regularly-scheduled tasks. SideJobs are only dispatched if the inputHandler function returns successfully, indicating + job success. + +### Complete Example + +This example shows how one can set up a ViewModel as the Queue interface, with multiple independent workers, +observability via logging, automatic serialization/deserialization, repeating jobs, and durable database storage. + +Uses the following Ballast modules: + +- [Ballast Core](./../ballast-core) +- [Ballast Queue Core](./../ballast-queue-core) +- [Ballast Queue Exposed Driver](./../ballast-queue-exposed-driver) +- [Ballast Kotlinx Serialization](./../ballast-kotlinx-serialization) +- [Ballast Scheduler Core](./../ballast-scheduler-core) +- [Ballast Scheduler ViewModel](./../ballast-scheduler-viewmodel) +- [Ballast Scheduler Cron](./../ballast-scheduler-cron) +- [Ballast Autoscale](./../ballast-autoscale) + +```kotlin + +// Create an AutoscalingViewModel to run 4 copies of your queue in parallel. Store this ViewModel as a singleton and +// send jobs to the queue with JobMaintenanceViewModel.send(), which get distributed to a worker and persisted in the +// database queue. +class JobQueueViewModel( + coroutineScope: CoroutineScope, +) : AutoscalingViewModel< + JobQueueContract.Inputs, + JobQueueContract.Events, + JobQueueContract.State>( + coroutineScope = coroutineScope, + factory = ViewModelFactory { workerScope, id -> + koin.get { params(workerScope, id) } + }, + scalingPolicy = FixedScalingPolicy(4), + distributionPolicy = RoundRobinDistributionPolicy(), +) + +// the Worker uses JobQueueInputStrategy to enable persistent +// queues, and `SchedulerInterceptor` to enqueue a task on a +// regular cadence. +private class JobQueueViewModelWorker( + private val coroutineScope: CoroutineScope, + private val id: Int, + private val inputHandler: JobQueueInputHandler, + private val repository: JobsRepository, +) : BasicViewModel< + JobQueueContract.Inputs, + JobQueueContract.Events, + JobQueueContract.State>( + coroutineScope = coroutineScope, + config = BallastViewModelConfiguration.Builder() + .withViewModel( + inputHandler = inputHandler, + initialState = JobQueueContract.State, + name = "JobQueueViewModel-$id" + ) + .withSerialization( + inputsSerializer = JobQueueContract.Inputs.serializer(), + eventsSerializer = JobQueueContract.Events.serializer(), + stateSerializer = JobQueueContract.State.serializer(), + ) + .apply { + logger = ::PrintlnLogger + + inputStrategy = JobQueueInputStrategy( + queueName = "default", + driver = ExposedDatabaseQueueDriver(repository), + adapter = ExposedDatabaseQueueDriver.DefaultAdapter(), + ) + + interceptors += LoggingInterceptor() + interceptors += SchedulerInterceptor { + onSchedule( + schedule = CronSchedule(CronExpression.parse("0 * * * *")).named("every hour"), + ) { JobQueueContract.Inputs.RepeatedJob } + } + } + .build(), + eventHandler = eventHandler { }, +) +``` + +## Installation + +```kotlin +repositories { + mavenCentral() +} + +// for plain JVM or Android projects +dependencies { + implementation("io.github.copper-leaf:ballast-queue-viewmodel:{{ballastVersion}}") +} + +// for multiplatform projects +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + implementation("io.github.copper-leaf:ballast-queue-viewmodel:{{ballastVersion}}") + } + } + } +} +``` diff --git a/ballast-queue-viewmodel/api/android/ballast-queue-viewmodel.api b/ballast-queue-viewmodel/api/android/ballast-queue-viewmodel.api new file mode 100644 index 00000000..f7350c77 --- /dev/null +++ b/ballast-queue-viewmodel/api/android/ballast-queue-viewmodel.api @@ -0,0 +1,11 @@ +public final class com/copperleaf/ballast/queue/JobQueueInputStrategy : com/copperleaf/ballast/InputStrategy { + public fun (Ljava/lang/String;Lcom/copperleaf/ballast/queue/QueueDriver;Lcom/copperleaf/ballast/queue/QueueDriver$Adapter;Z)V + public synthetic fun (Ljava/lang/String;Lcom/copperleaf/ballast/queue/QueueDriver;Lcom/copperleaf/ballast/queue/QueueDriver$Adapter;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun close ()V + public fun enqueue (Lcom/copperleaf/ballast/Queued;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun flush (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getScopeFactory (Lcom/copperleaf/ballast/internal/BallastViewModelImpl;)Lcom/copperleaf/ballast/BallastScopeFactory; + public fun start (Lcom/copperleaf/ballast/InputStrategyScope;)V + public fun tryEnqueue-JP2dKIU (Lcom/copperleaf/ballast/Queued;)Ljava/lang/Object; +} + diff --git a/ballast-queue-viewmodel/api/jvm/ballast-queue-viewmodel.api b/ballast-queue-viewmodel/api/jvm/ballast-queue-viewmodel.api new file mode 100644 index 00000000..f7350c77 --- /dev/null +++ b/ballast-queue-viewmodel/api/jvm/ballast-queue-viewmodel.api @@ -0,0 +1,11 @@ +public final class com/copperleaf/ballast/queue/JobQueueInputStrategy : com/copperleaf/ballast/InputStrategy { + public fun (Ljava/lang/String;Lcom/copperleaf/ballast/queue/QueueDriver;Lcom/copperleaf/ballast/queue/QueueDriver$Adapter;Z)V + public synthetic fun (Ljava/lang/String;Lcom/copperleaf/ballast/queue/QueueDriver;Lcom/copperleaf/ballast/queue/QueueDriver$Adapter;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun close ()V + public fun enqueue (Lcom/copperleaf/ballast/Queued;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun flush (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getScopeFactory (Lcom/copperleaf/ballast/internal/BallastViewModelImpl;)Lcom/copperleaf/ballast/BallastScopeFactory; + public fun start (Lcom/copperleaf/ballast/InputStrategyScope;)V + public fun tryEnqueue-JP2dKIU (Lcom/copperleaf/ballast/Queued;)Ljava/lang/Object; +} + diff --git a/ballast-queue-viewmodel/build.gradle.kts b/ballast-queue-viewmodel/build.gradle.kts new file mode 100644 index 00000000..4647e789 --- /dev/null +++ b/ballast-queue-viewmodel/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + id("copper-leaf-base") + id("copper-leaf-android-library") + id("copper-leaf-targets") + id("copper-leaf-tests") + id("copper-leaf-lint") + id("copper-leaf-publish") + id("copper-leaf-serialization") +} + +kotlin { + compilerOptions { + optIn.add("kotlin.time.ExperimentalTime") + optIn.add("kotlin.uuid.ExperimentalUuidApi") + } + + sourceSets { + val commonMain by getting { + dependencies { + api(project(":ballast-api")) + api(project(":ballast-queue-core")) + api(libs.kotlinx.coroutines.core) + api(libs.kotlinx.serialization.json) + } + } + val commonTest by getting { + dependencies { + api(project(":ballast-test")) + api(project(":ballast-kotlinx-serialization")) + } + } + val jvmMain by getting { + dependencies { } + } + val androidMain by getting { + dependencies { } + } + val jsMain by getting { + dependencies { } + } + val iosMain by getting { + dependencies { } + } + } +} diff --git a/ballast-queue-viewmodel/gradle.properties b/ballast-queue-viewmodel/gradle.properties new file mode 100644 index 00000000..b27878ce --- /dev/null +++ b/ballast-queue-viewmodel/gradle.properties @@ -0,0 +1,8 @@ +copperleaf.description=Use Ballast ViewModels as the interface to persistent job queues in Kotlin Multiplatform + +copperleaf.targets.android=true +copperleaf.targets.jvm=true +copperleaf.targets.ios=true +copperleaf.targets.js=true +copperleaf.targets.wasm.wasi=false +copperleaf.targets.wasm.js=true diff --git a/ballast-queue-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/queue/BallastQueueSerializers.kt b/ballast-queue-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/queue/BallastQueueSerializers.kt new file mode 100644 index 00000000..f0a2365b --- /dev/null +++ b/ballast-queue-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/queue/BallastQueueSerializers.kt @@ -0,0 +1,34 @@ +package com.copperleaf.ballast.queue + +import com.copperleaf.ballast.BallastDecoder +import com.copperleaf.ballast.BallastEncoder + +internal class BallastQueueSerializers( + val encoder: BallastEncoder, + val decoder: BallastDecoder, +) : QueueExecutor.Serializers { + + override fun serializePayload(payload: Inputs): String { + return encoder.encodeInputToString(payload) + } + + override fun deserializePayload(serializedPayload: String): Inputs { + return decoder.decodeInputFromString(serializedPayload) + } + + override fun serializeResult(result: Events): String { + return encoder.encodeEventToString(result) + } + + override fun deserializeResult(serializedResult: String): Events { + return decoder.decodeEventFromString(serializedResult) + } + + override fun serializeState(state: State): String { + return encoder.encodeStateToString(state) + } + + override fun deserializeState(serializedState: String): State { + return decoder.decodeStateFromString(serializedState) + } +} diff --git a/ballast-queue-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/queue/JobQueueGuardian.kt b/ballast-queue-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/queue/JobQueueGuardian.kt new file mode 100644 index 00000000..1e80b5ac --- /dev/null +++ b/ballast-queue-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/queue/JobQueueGuardian.kt @@ -0,0 +1,83 @@ +package com.copperleaf.ballast.queue + +import com.copperleaf.ballast.InputStrategy + +internal class JobQueueGuardian( + internal val queueExecutorScope: QueueExecutorScope +) : InputStrategy.Guardian { + + private var stateAccessed: Boolean = false + private var sideJobsPosted: Boolean = false + private var usedProperly: Boolean = false + private var closed: Boolean = false + internal var resultEvent: Events? = null + + override fun checkStateAccess() { + checkNotClosed() + checkNoSideJobs() + stateAccessed = true + usedProperly = true + } + + override fun checkStateUpdate() { + checkNotClosed() + checkNoSideJobs() + stateAccessed = true + usedProperly = true + } + + override fun checkPostEvent() { + checkNotClosed() + checkNoSideJobs() + usedProperly = true + } + + override fun checkNoOp() { + checkNotClosed() + checkNoSideJobs() + usedProperly = true + } + + override fun checkSideJob() { + checkNotClosed() + sideJobsPosted = true + usedProperly = true + } + + override fun close() { + checkNotClosed() + checkUsedProperly() + closed = true + } + + internal fun setEventAsResult(event: Events) { + if (resultEvent == null) { + resultEvent = event + } else { + error( + "The Queue's InputHandler attempted to post multiple Events as results of a single Input. Only one " + + "Event can be posted as a result of handling an Input." + ) + } + } + +// Inner checks +// --------------------------------------------------------------------------------------------------------------------- + + private fun checkNotClosed() { + check(!closed) { "This InputHandlerScope has already been closed" } + } + + private fun checkNoSideJobs() { + check(!sideJobsPosted) { + "Side-Jobs must be the last statements of the InputHandler" + } + } + + private fun checkUsedProperly() { + check(usedProperly) { + "Input was not handled properly. To ensure you're following the MVI model properly, make sure any " + + "side-jobs are executed in a `sideJob { }` block." + } + } +} diff --git a/ballast-queue-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/queue/JobQueueInputStrategy.kt b/ballast-queue-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/queue/JobQueueInputStrategy.kt new file mode 100644 index 00000000..5436593a --- /dev/null +++ b/ballast-queue-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/queue/JobQueueInputStrategy.kt @@ -0,0 +1,128 @@ +package com.copperleaf.ballast.queue + +import com.copperleaf.ballast.BallastScopeFactory +import com.copperleaf.ballast.InputStrategy +import com.copperleaf.ballast.InputStrategyScope +import com.copperleaf.ballast.Queued +import com.copperleaf.ballast.core.DefaultGuardian +import com.copperleaf.ballast.internal.BallastViewModelImpl +import com.copperleaf.ballast.queue.executor.DefaultQueueExecutor +import com.copperleaf.ballast.queue.scope.JobQueueInputStrategyScope +import kotlinx.coroutines.InternalCoroutinesApi +import kotlinx.coroutines.channels.ChannelResult +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlin.time.TimeSource + +/** + * A normal InputStrategy directly reads from the input queue to handle Inputs. A JobQueue instead uses the channel + * simply as a buffer to read them and place into a persistent queue. Separately, a job polls the queue to pull items + * off the queue and process them. + * + * Inputs must be serializable, since they are stored persistently. Additionally, Inputs can be given a priority so that + * the order in which they are processed is not necessarily the order in which they were received. + */ +@OptIn(InternalCoroutinesApi::class) +public class JobQueueInputStrategy( + private val queueName: String, + private val driver: QueueDriver, + private val adapter: QueueDriver.Adapter, + private val captureErrorStacktrace: Boolean = false, +) : InputStrategy { + + private lateinit var queueExecutor: QueueExecutor + private lateinit var inputStrategyScope: JobQueueInputStrategyScope + + override fun InputStrategyScope.start() { + require(this is JobQueueInputStrategyScope) + requireNotNull(impl.decoder) + inputStrategyScope = this + + queueExecutor = DefaultQueueExecutor( + driver = driver, + adapter = adapter, + serializers = BallastQueueSerializers(impl.encoder, impl.decoder!!), + captureErrorStacktrace = captureErrorStacktrace, + timeSource = TimeSource.Monotonic, + ) + + queueExecutor + .runQueue(queueName) { payload -> + val queueExecutorScope = this + processJobInViewModel(queueExecutorScope, payload) + } + .launchIn(this) + } + + override suspend fun enqueue(queued: Queued) { + when (queued) { + is Queued.HandleInput -> { + queueExecutor.insertJob(queueName, queued.input, inputStrategyScope.impl.initialState) + } + + is Queued.RestoreState -> { + inputStrategyScope.acceptQueued(queued, DefaultGuardian()) { } + } + + is Queued.ShutDownGracefully -> { + // Launch the wait as a separate coroutine so enqueue() returns immediately. This keeps + // the strategy loop unblocked, allowing in-flight jobs to continue enqueuing sub-jobs + // (via insertJob) while we wait for the queue to drain. Only once the driver signals + // that all active jobs have finished do we forward the shutdown to the Ballast + // ViewModel itself. + inputStrategyScope.launch { + driver.awaitShutdown() + inputStrategyScope.acceptQueued(queued, DefaultGuardian()) { } + } + } + } + } + + override fun tryEnqueue(queued: Queued): ChannelResult { + return if (inputStrategyScope.isActive) { + inputStrategyScope.launch { + enqueue(queued) + } + ChannelResult.success(Unit) + } else { + ChannelResult.failure() + } + } + + override fun close() { + } + + override suspend fun flush() { + } + + override fun getScopeFactory(impl: BallastViewModelImpl): BallastScopeFactory { + return JobQueueScopeFactory(impl) + } + +// Process job in ViewModel +// --------------------------------------------------------------------------------------------------------------------- + + private suspend fun processJobInViewModel( + queueExecutorScope: QueueExecutorScope, + payload: Inputs, + ): Events? { + val queuedInput = Queued.HandleInput(null, payload) + val guardian = JobQueueGuardian(queueExecutorScope) + var error: Throwable? = null + inputStrategyScope.acceptQueued( + queued = queuedInput, + guardian = guardian, + onFailed = { error = it }, + onCancelled = { }, + ) + + if (error != null) { + // the queue executor expects an exception to be thrown as a signal for failure + throw error + } else { + // if no exception was throw, the queue executor will acknowledge the job as successful + return guardian.resultEvent + } + } +} diff --git a/ballast-queue-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/queue/JobQueueScopeFactory.kt b/ballast-queue-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/queue/JobQueueScopeFactory.kt new file mode 100644 index 00000000..7c4b8762 --- /dev/null +++ b/ballast-queue-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/queue/JobQueueScopeFactory.kt @@ -0,0 +1,57 @@ +package com.copperleaf.ballast.queue + +import com.copperleaf.ballast.InputStrategy +import com.copperleaf.ballast.InputStrategyScope +import com.copperleaf.ballast.SideJobScope +import com.copperleaf.ballast.internal.BallastViewModelImpl +import com.copperleaf.ballast.internal.actors.StateActor +import com.copperleaf.ballast.internal.scopes.DefaultBallastScopeFactory +import com.copperleaf.ballast.internal.scopes.InternalInputHandlerScope +import com.copperleaf.ballast.queue.scope.JobQueueInputHandlerScope +import com.copperleaf.ballast.queue.scope.JobQueueInputStrategyScope +import com.copperleaf.ballast.queue.scope.JobQueueSideJobScope +import com.copperleaf.ballast.queue.scope.JobQueueStateActor +import kotlinx.coroutines.CoroutineScope + +@Suppress("UNCHECKED_CAST") +internal class JobQueueScopeFactory( + impl: BallastViewModelImpl +) : DefaultBallastScopeFactory(impl) { + + override fun createInputHandlerScope( + guardian: InputStrategy.Guardian, + ): InternalInputHandlerScope = with(impl) { + require(guardian is JobQueueGuardian<*, *>) + return JobQueueInputHandlerScope( + guardian = guardian as JobQueueGuardian, + impl = impl, + ) + } + + override fun createStateActor(impl: BallastViewModelImpl): StateActor { + return JobQueueStateActor() + } + + override fun createInputStrategyScope(inputStrategyCoroutineScope: CoroutineScope): InputStrategyScope { + return JobQueueInputStrategyScope( + impl = impl, + inputStrategyCoroutineScope = inputStrategyCoroutineScope, + ) + } + + override fun createSideJobScope( + sideJobCoroutineScope: CoroutineScope, + key: String, + restartState: SideJobScope.RestartState + ): SideJobScope = with(impl) { + JobQueueSideJobScope( + sideJobCoroutineScope = sideJobCoroutineScope, + logger = logger, + inputActor = inputActor, + interceptorActor = interceptorActor, + key = key, + restartState = restartState, + shutDownGracePeriod = shutDownGracePeriod, + ) + } +} diff --git a/ballast-queue-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/queue/scope/JobQueueInputHandlerScope.kt b/ballast-queue-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/queue/scope/JobQueueInputHandlerScope.kt new file mode 100644 index 00000000..3a029650 --- /dev/null +++ b/ballast-queue-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/queue/scope/JobQueueInputHandlerScope.kt @@ -0,0 +1,88 @@ +package com.copperleaf.ballast.queue.scope + +import com.copperleaf.ballast.BallastLogger +import com.copperleaf.ballast.BallastNotification +import com.copperleaf.ballast.SideJobScope +import com.copperleaf.ballast.internal.BallastViewModelImpl +import com.copperleaf.ballast.internal.scopes.InternalInputHandlerScope +import com.copperleaf.ballast.queue.JobQueueGuardian + +internal class JobQueueInputHandlerScope( + private val guardian: JobQueueGuardian, + private val impl: BallastViewModelImpl, +) : InternalInputHandlerScope { + private val pendingSideJobs: MutableList.() -> Unit>> = mutableListOf() + + override val logger: BallastLogger get() = impl.logger + + override suspend fun getCurrentState(): State { + guardian.checkStateAccess() + return guardian.queueExecutorScope.getCurrentState() + } + + override suspend fun updateState(block: (State) -> State) { + guardian.checkStateUpdate() + val previousState = guardian.queueExecutorScope.getCurrentState() + val updatedState = block(previousState) + guardian.queueExecutorScope.setState(updatedState) + + // notify interceptors of state change. Mostly for logging purposes + impl.interceptorActor.notify(BallastNotification.StateChanged(impl.type, impl.name, getCurrentState())) + } + + override suspend fun updateStateAndGet(block: (State) -> State): State { + guardian.checkStateUpdate() + val previousState = guardian.queueExecutorScope.getCurrentState() + val updatedState = block(previousState) + guardian.queueExecutorScope.setState(updatedState) + + // notify interceptors of state change. Mostly for logging purposes + impl.interceptorActor.notify(BallastNotification.StateChanged(impl.type, impl.name, getCurrentState())) + + return updatedState + } + + override suspend fun getAndUpdateState(block: (State) -> State): State { + guardian.checkStateUpdate() + val previousState = guardian.queueExecutorScope.getCurrentState() + val updatedState = block(previousState) + guardian.queueExecutorScope.setState(updatedState) + + // notify interceptors of state change. Mostly for logging purposes + impl.interceptorActor.notify(BallastNotification.StateChanged(impl.type, impl.name, getCurrentState())) + + return previousState + } + + override suspend fun postEvent(event: Events) { + guardian.checkPostEvent() + guardian.setEventAsResult(event) + + // notify interceptors of state being emitted. Mostly for logging purposes + impl.interceptorActor.notify(BallastNotification.EventEmitted(impl.type, impl.name, event)) + } + + override fun sideJob( + key: String, + block: suspend SideJobScope.() -> Unit + ) { + guardian.checkSideJob() + pendingSideJobs += key to block + } + + override fun cancelSideJob(key: String) { + guardian.checkSideJob() + impl.sideJobActor.cancelSideJob(key) + } + + override fun noOp() { + guardian.checkNoOp() + } + + override fun markAsCompletedSuccessfully() { + guardian.close() + pendingSideJobs.forEach { (key, block) -> + impl.sideJobActor.enqueueSideJob(key, block) + } + } +} diff --git a/ballast-queue-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/queue/scope/JobQueueInputStrategyScope.kt b/ballast-queue-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/queue/scope/JobQueueInputStrategyScope.kt new file mode 100644 index 00000000..42742013 --- /dev/null +++ b/ballast-queue-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/queue/scope/JobQueueInputStrategyScope.kt @@ -0,0 +1,46 @@ +package com.copperleaf.ballast.queue.scope + +import com.copperleaf.ballast.BallastLogger +import com.copperleaf.ballast.InputStrategy +import com.copperleaf.ballast.InputStrategyScope +import com.copperleaf.ballast.Queued +import com.copperleaf.ballast.internal.BallastViewModelImpl +import kotlinx.coroutines.CoroutineScope + +internal class JobQueueInputStrategyScope( + internal val impl: BallastViewModelImpl, + inputStrategyCoroutineScope: CoroutineScope, +) : InputStrategyScope, + CoroutineScope by inputStrategyCoroutineScope { + + override val logger: BallastLogger get() = impl.logger + + override suspend fun acceptQueued( + queued: Queued, + guardian: InputStrategy.Guardian, + onCancelled: suspend () -> Unit + ) { + impl.inputActor.safelyHandleQueued(queued, guardian, {}, onCancelled) + } + + override suspend fun acceptQueued( + queued: Queued, + guardian: InputStrategy.Guardian, + onFailed: suspend (t: Throwable) -> Unit, + onCancelled: suspend () -> Unit, + ) { + impl.inputActor.safelyHandleQueued(queued, guardian, onFailed, onCancelled) + } + + override suspend fun getCurrentState(): State { + throw NotImplementedError("getCurrentState()") + } + + override suspend fun rollbackState(state: State) { + throw NotImplementedError("rollbackState()") + } + + override suspend fun rejectInput(input: Inputs, currentState: State) { + throw NotImplementedError("rejectInput()") + } +} diff --git a/ballast-queue-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/queue/scope/JobQueueSideJobScope.kt b/ballast-queue-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/queue/scope/JobQueueSideJobScope.kt new file mode 100644 index 00000000..4d47b033 --- /dev/null +++ b/ballast-queue-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/queue/scope/JobQueueSideJobScope.kt @@ -0,0 +1,40 @@ +package com.copperleaf.ballast.queue.scope + +import com.copperleaf.ballast.BallastInterceptor +import com.copperleaf.ballast.BallastLogger +import com.copperleaf.ballast.Queued +import com.copperleaf.ballast.SideJobScope +import com.copperleaf.ballast.internal.actors.InputActor +import com.copperleaf.ballast.internal.actors.InterceptorActor +import kotlinx.coroutines.CoroutineScope +import kotlin.time.Duration + +internal class JobQueueSideJobScope( + sideJobCoroutineScope: CoroutineScope, + + override val logger: BallastLogger, + + private val inputActor: InputActor, + private val interceptorActor: InterceptorActor, + + override val key: String, + override val restartState: SideJobScope.RestartState, + private val shutDownGracePeriod: Duration +) : SideJobScope, CoroutineScope by sideJobCoroutineScope { + + override suspend fun postInput(input: Inputs) { + inputActor.enqueueQueued(Queued.HandleInput(null, input), await = false) + } + + override suspend fun postEvent(event: Events) { + error("Events cannot be posted from SideJobs in JobQueueInputStrategy") + } + + override suspend fun requestGracefulShutdown() { + inputActor.enqueueQueued(Queued.ShutDownGracefully(null, shutDownGracePeriod), await = false) + } + + override suspend fun > getInterceptor(key: BallastInterceptor.Key): I { + return interceptorActor.getInterceptor(key) + } +} diff --git a/ballast-queue-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/queue/scope/JobQueueStateActor.kt b/ballast-queue-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/queue/scope/JobQueueStateActor.kt new file mode 100644 index 00000000..488f4a8b --- /dev/null +++ b/ballast-queue-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/queue/scope/JobQueueStateActor.kt @@ -0,0 +1,32 @@ +package com.copperleaf.ballast.queue.scope + +import com.copperleaf.ballast.internal.actors.StateActor +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.StateFlow + +internal class JobQueueStateActor : StateActor { + + override suspend fun getCurrentState(): State { + error("ViewModels with LocalStateInputStrategy do not have externally-visible state (getCurrentState)") + } + + override fun observeStates(): StateFlow { + error("ViewModels with LocalStateInputStrategy do not have externally-visible state (observeStates)") + } + + override suspend fun safelySetState(state: State, deferred: CompletableDeferred?) { + error("ViewModels with LocalStateInputStrategy do not have externally-visible state (safelySetState)") + } + + override suspend fun safelyUpdateState(block: (State) -> State) { + error("ViewModels with LocalStateInputStrategy do not have externally-visible state (safelyUpdateState)") + } + + override suspend fun safelyUpdateStateAndGet(block: (State) -> State): State { + error("ViewModels with LocalStateInputStrategy do not have externally-visible state (safelyUpdateStateAndGet)") + } + + override suspend fun safelyGetAndUpdateState(block: (State) -> State): State { + error("ViewModels with LocalStateInputStrategy do not have externally-visible state (safelyGetAndUpdateState)") + } +} diff --git a/ballast-queue-viewmodel/src/commonTest/kotlin/com/copperleaf/ballast/queue/QueueViewModelTest.kt b/ballast-queue-viewmodel/src/commonTest/kotlin/com/copperleaf/ballast/queue/QueueViewModelTest.kt new file mode 100644 index 00000000..5f2049c6 --- /dev/null +++ b/ballast-queue-viewmodel/src/commonTest/kotlin/com/copperleaf/ballast/queue/QueueViewModelTest.kt @@ -0,0 +1,100 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.copperleaf.ballast.queue + +import com.copperleaf.ballast.eventHandler +import com.copperleaf.ballast.queue.driver.sync.SyncQueueDriver +import com.copperleaf.ballast.queue.vm.TestContract +import com.copperleaf.ballast.queue.vm.TestInputHandler +import com.copperleaf.ballast.queue.vm.TestSyncQueueAdapter +import com.copperleaf.ballast.test.viewModelTest +import com.copperleaf.ballast.withJsonSerialization +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlin.test.Test +import kotlin.test.assertEquals + +class QueueViewModelTest { + + @Test + fun test() = runTest { + viewModelTest( + inputHandler = TestInputHandler(), + eventHandler = eventHandler { }, + ) { + defaultInitialState { TestContract.State() } + + scenario("test a queue-backed Viewmodel") { + val driver = SyncQueueDriver() + inputStrategy { + JobQueueInputStrategy( + queueName = "test-queue", + driver = driver, + adapter = TestSyncQueueAdapter(), + captureErrorStacktrace = false, + ) + } + customizeConfiguration { + it.withJsonSerialization( + inputsSerializer = TestContract.Inputs.serializer(), + eventsSerializer = TestContract.Events.serializer(), + stateSerializer = TestContract.State.serializer(), + json = Json.Default, + ) + } + + running { + +TestContract.Inputs.AsyncJob("one") + } + resultsIn { + assertEquals( + actual = states, + expected = listOf( + TestContract.State(), + TestContract.State(step = 1), + TestContract.State(step = 2), + TestContract.State(step = 3), + ), + ) + assertEquals( + actual = events, + expected = listOf( + TestContract.Events.JobCompleted("ONE"), + ), + ) + + assertEquals( + actual = driver.lastJob?.serializedPayload, + expected = buildJsonObject { + put("type", "com.copperleaf.ballast.queue.vm.TestContract.Inputs.AsyncJob") + put("inputData", "one") + }.toString(), + ) + assertEquals( + actual = driver.lastJob?.serializedState, + expected = buildJsonObject { + }.toString(), + ) + assertEquals( + actual = driver.lastJobResultType, + expected = JobCompletionResultType.Success, + ) + assertEquals( + actual = driver.lastJobResultData, + expected = buildJsonObject { + put("type", "com.copperleaf.ballast.queue.vm.TestContract.Events.JobCompleted") + put("result", "ONE") + }.toString(), + ) + assertEquals( + actual = driver.lastJobFailureMessage, + expected = null, + ) + } + } + } + } +} diff --git a/ballast-queue-viewmodel/src/commonTest/kotlin/com/copperleaf/ballast/queue/TestClock.kt b/ballast-queue-viewmodel/src/commonTest/kotlin/com/copperleaf/ballast/queue/TestClock.kt new file mode 100644 index 00000000..273d78cc --- /dev/null +++ b/ballast-queue-viewmodel/src/commonTest/kotlin/com/copperleaf/ballast/queue/TestClock.kt @@ -0,0 +1,24 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.copperleaf.ballast.scheduler + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.currentTime +import kotlin.time.Clock +import kotlin.time.Instant + +private class TestScopeClock(private val testScope: TestScope) : Clock { + override fun now(): Instant { + return Instant.fromEpochMilliseconds(testScope.currentTime) + } +} + +fun TestScope.TestClock(startInstant: Instant? = null): Clock { + val clock = TestScopeClock(this) + startInstant?.let { + advanceTimeBy(startInstant.toEpochMilliseconds()) + } + return clock +} diff --git a/ballast-queue-viewmodel/src/commonTest/kotlin/com/copperleaf/ballast/queue/vm/TestContract.kt b/ballast-queue-viewmodel/src/commonTest/kotlin/com/copperleaf/ballast/queue/vm/TestContract.kt new file mode 100644 index 00000000..9d431f83 --- /dev/null +++ b/ballast-queue-viewmodel/src/commonTest/kotlin/com/copperleaf/ballast/queue/vm/TestContract.kt @@ -0,0 +1,22 @@ +package com.copperleaf.ballast.queue.vm + +import kotlinx.serialization.Serializable + +object TestContract { + @Serializable + data class State( + val step: Int = 0, + ) + + @Serializable + sealed interface Inputs { + @Serializable + data class AsyncJob(val inputData: String) : Inputs + } + + @Serializable + sealed interface Events { + @Serializable + data class JobCompleted(val result: String) : Events + } +} diff --git a/ballast-queue-viewmodel/src/commonTest/kotlin/com/copperleaf/ballast/queue/vm/TestInputHandler.kt b/ballast-queue-viewmodel/src/commonTest/kotlin/com/copperleaf/ballast/queue/vm/TestInputHandler.kt new file mode 100644 index 00000000..2ed530ec --- /dev/null +++ b/ballast-queue-viewmodel/src/commonTest/kotlin/com/copperleaf/ballast/queue/vm/TestInputHandler.kt @@ -0,0 +1,28 @@ +package com.copperleaf.ballast.queue.vm + +import com.copperleaf.ballast.InputHandler +import com.copperleaf.ballast.InputHandlerScope +import kotlinx.coroutines.delay +import kotlin.time.Duration.Companion.seconds + +class TestInputHandler : InputHandler< + TestContract.Inputs, + TestContract.Events, + TestContract.State> { + override suspend fun InputHandlerScope< + TestContract.Inputs, + TestContract.Events, + TestContract.State>.handleInput( + input: TestContract.Inputs + ): Unit = when (input) { + is TestContract.Inputs.AsyncJob -> { + updateState { it.copy(step = 1) } + delay(3.seconds) + updateState { it.copy(step = 2) } + delay(3.seconds) + updateState { it.copy(step = 3) } + delay(3.seconds) + postEvent(TestContract.Events.JobCompleted(input.inputData.uppercase())) + } + } +} diff --git a/ballast-queue-viewmodel/src/commonTest/kotlin/com/copperleaf/ballast/queue/vm/TestSyncQueueAdapter.kt b/ballast-queue-viewmodel/src/commonTest/kotlin/com/copperleaf/ballast/queue/vm/TestSyncQueueAdapter.kt new file mode 100644 index 00000000..f323c53b --- /dev/null +++ b/ballast-queue-viewmodel/src/commonTest/kotlin/com/copperleaf/ballast/queue/vm/TestSyncQueueAdapter.kt @@ -0,0 +1,14 @@ +package com.copperleaf.ballast.queue.vm + +import com.copperleaf.ballast.queue.QueueDriver + +class TestSyncQueueAdapter : QueueDriver.Adapter< + Unit, + TestContract.Inputs, + TestContract.Events, + TestContract.State, + > { + + override fun getJobMetadata(payload: TestContract.Inputs) { + } +} diff --git a/docs/src/doc/docs/pages/wiki/modules/ballast-repository.md b/ballast-repository/README.md similarity index 78% rename from docs/src/doc/docs/pages/wiki/modules/ballast-repository.md rename to ballast-repository/README.md index 4d0354b8..88e7614c 100644 --- a/docs/src/doc/docs/pages/wiki/modules/ballast-repository.md +++ b/ballast-repository/README.md @@ -1,65 +1,89 @@ ---- ---- +# Ballast Repository + +> [!CAUTION] +> +> DEPRECATED +> +> This module was based on a flawed concept of application architecture, and has not proved to be as useful as initially +> envisioned when originally created. For historical reasons, this module and its original documentation has been +> preserved for those who may have already been using it, but it will not receive any further updates or support. +> +> In short, Ballast is best used strictly in the Presentation Layer of your application. It is not well-suited for +> managing caches in the Data Layer, or for encapsulating business logic in the Domain Layer. ## Overview MVI has been known for a while as a great option for managing UI state, but most applications will also need to manage some state that lives longer than a single screen. This would be things like account management, or caching of expensive computations or API calls, and MVI can actually be a great fit for this Repository Layer, too. The [Repository Layer][1] -has a lifetime that is longer than any single screen, and acts as a liaison between your UI code (the typical MVI area) +has a lifetime that is longer than any single screen, and acts as a liaison between your UI code (the typical MVI area) and the domain objects that make the UI work. -On Android, it's recommended to have a [Data Layer][2], but exactly how to build it is not well known, and there really -aren't any recommendations from Google, either. [Dropbox Store][3] attempted to step in and create a library to +On Android, it's recommended to have a [Data Layer][2], but exactly how to build it is not well known, and there really +aren't any recommendations from Google, either. [Dropbox Store][3] attempted to step in and create a library to implement this Data or Repository layer, but in practice it works more like a persistent cache than a true solution for app-wide State management. -Ballast Repository aims to fill that gap, and provide an opinionated way to manage the data in your application layer, +Ballast Repository aims to fill that gap, and provide an opinionated way to manage the data in your application layer, using the same MVI model you're used to with your UI code. One huge benefit of using Ballast as your repository layer -vs other solutions, is that you can approach both UI and non-UI development with the same mindset; you don't have to +vs other solutions, is that you can approach both UI and non-UI development with the same mindset; you don't have to "context switch" when moving between layers! -Ballast Repository is built around 3 core concepts: the MVI model as implemented with a special `BallastRepository` +Ballast Repository is built around 3 core concepts: the MVI model as implemented with a special `BallastRepository` ViewModel, the `Cached` interface to hold and update data within the Repository, and the `EventBus` to facilitate communication between Repository instances throughout the entire layer. +## Supported Platforms + +| Platform | Supported | +|----------|-----------| +| JVM | ✅ | +| Android | ✅ | +| iOS | ✅ | +| JS | ✅ | +| WASM JS | ✅ | + +## See Also + +N/A + ## Example Use-Case -Before diving into the usage of the Repository module, it may be helpful to get a basic intuition for when you might +Before diving into the usage of the Repository module, it may be helpful to get a basic intuition for when you might need it, and how this layer of your application is intended to work. Consider the following situation: -You have an app where users can log on and view how much they've used your service, and how much it costs them. The +You have an app where users can log on and view how much they've used your service, and how much it costs them. The users may have multiple linked accounts and switch between the accounts freely. Viewing their usage is tied to the -individual account, but billing is aggregated among all accounts to simplify paying the bill. +individual account, but billing is aggregated among all accounts to simplify paying the bill. -We want to minimize the number of API calls for a snappy user-experience, so we cache every API response. Whenever the +We want to minimize the number of API calls for a snappy user-experience, so we cache every API response. Whenever the user changes the current account, we want to refresh their usage data, but not the billing info, since we want to show the new usage data for the new account, but the billing data does not need to be changed. -In this model, using a [BallastRepository](#BallastRepository), we would hold the user account info in an -`AccountRepository`, the usage data in `UsageRepository`, and billing info in `BillingRepository`. All the cached data -is held within a [`Cached`](#Cached) property of each Repository's State. Changing accounts involves sending an Input -to `AccountRepository`, which then makes its own changes and then sends the relevant Input through the -[`EventBus`](#EventBus) to the `BillingRepository`. The UI layer does not need to know any specifics of what's going on -in the Repository layer, as it just passively observes the `Cached` properties. Furthermore, it also does not need to -know anything about the specific organization of data in it, when changing one property needs to clear the cache of -another, etc. You can easily wire up any screen to change the account or fetch the usage/billing info, trust that it -will be fetched only once if needed or else returned from the cache, and know that the relevant UI will be updated -automatically whenever the repository finished updating its cached without having to do any specific UI handling for -that. +In this model, using a [BallastRepository](#BallastRepository), we would hold the user account info in an +`AccountRepository`, the usage data in `UsageRepository`, and billing info in `BillingRepository`. All the cached data +is held within a [`Cached`](#Cached) property of each Repository's State. Changing accounts involves sending an Input +to `AccountRepository`, which then makes its own changes and then sends the relevant Input through the +[`EventBus`](#EventBus) to the `BillingRepository`. The UI layer does not need to know any specifics of what's going on +in the Repository layer, as it just passively observes the `Cached` properties. Furthermore, it also does not need to +know anything about the specific organization of data in it, when changing one property needs to clear the cache of +another, etc. You can easily wire up any screen to change the account or fetch the usage/billing info, trust that it +will be fetched only once if needed or else returned from the cache, and know that the relevant UI will be updated +automatically whenever the repository finished updating its cached without having to do any specific UI handling for +that. ## Usage ### BallastRepository -`BallastRepository` is a special `BallastViewModel` implementation that is intended to be used as the "ViewModel" of -your Repository layer. Unlike UI ViewModels, the Repositories do not have `EventHandlers`, as Events sent from the -Repository InputHandler are sent to the EventBus instead (which is simply a SharedFlow). It also uses the +`BallastRepository` is a special `BallastViewModel` implementation that is intended to be used as the "ViewModel" of +your Repository layer. Unlike UI ViewModels, the Repositories do not have `EventHandlers`, as Events sent from the +Repository InputHandler are sent to the EventBus instead (which is simply a SharedFlow). It also uses the `FifoInputStrategy` to ensure that all Inputs are handled, rather than being dropped or cancelled, though they're still processed one-at-a-time. -Repositories need a `CoroutineScope` to control their lifetime (commonly a single, glogal Application CoroutineScope), -and the `EventBus` instance, which should be shared among all Repositories. There also exists a +Repositories need a `CoroutineScope` to control their lifetime (commonly a single, glogal Application CoroutineScope), +and the `EventBus` instance, which should be shared among all Repositories. There also exists a `AndroidBallastRepository` which implements the same semantics, but is an instance of `androidx.lifecycle.ViewModel` and so can be scoped to a Navigation sub-graph. @@ -81,9 +105,9 @@ class ExampleRepositoryImpl( ) ``` -The `Contract` for a Repository can be anything you need it to be, but a common implementation based around Ballast's +The `Contract` for a Repository can be anything you need it to be, but a common implementation based around Ballast's own `Cached` interface looks like the example below. You can add as many cached properties to the same Repository as -needed, but they should typically be related by domain. +needed, but they should typically be related by domain. ```kotlin object ExampleRepositoryContract { @@ -172,10 +196,10 @@ class ExampleRepositoryInputHandler( } ``` -The final piece of the puzzle is where things start to look a bit different from normal UI MVI usage. A Ballast -Repository typically shouldn't be directly exposed to the UI, but instead hidden behind an interface so the UI layers -don't need to worry about sending the right Inputs and the right time to clear the caches, etc. Instead the UI just -requests data from the Repository interface as normal and receives the data it needs as a flow, while the Ballast +The final piece of the puzzle is where things start to look a bit different from normal UI MVI usage. A Ballast +Repository typically shouldn't be directly exposed to the UI, but instead hidden behind an interface so the UI layers +don't need to worry about sending the right Inputs and the right time to clear the caches, etc. Instead the UI just +requests data from the Repository interface as normal and receives the data it needs as a flow, while the Ballast Repository does all the work in the background to fetch or return cached data. ```kotlin @@ -214,41 +238,41 @@ class ExampleRepositoryImpl( } ``` -There is a lot of boilerplate to this method, and eventually there may be a generic Caching Repository to do all this +There is a lot of boilerplate to this method, and eventually there may be a generic Caching Repository to do all this for you. But for now, it's best to just be explicit, so you can easily track what data is being changed and at what time within each Repository. ### EventBus -The `EventBus` class is basically just a wrapper around a `SharedFlow`. It should share the same instance among all +The `EventBus` class is basically just a wrapper around a `SharedFlow`. It should share the same instance among all Repositories, so that one Repository can post an event to the bus, and it will be delivered to another Repository. -Each Repository should typically observe values of its own type from the EventBus, using -`eventBus.observeInputsFromBus()`, but you're free to observe values of any type. An +Each Repository should typically observe values of its own type from the EventBus, using +`eventBus.observeInputsFromBus()`, but you're free to observe values of any type. An example is using a generic "ClearCache" token sent to the bus, and all repositories can watch for that token and clear themselves. -Values can be sent from one Repository to another with the normal `InputHandlerScope.postEvent()`. You can post any +Values can be sent from one Repository to another with the normal `InputHandlerScope.postEvent()`. You can post any non-null value, as the `Events` type is `Any`. ### Cached -`Cached` is a sealed class which holds the data in your Repository and notifies observers of all changes to that value -as it is loaded. It can be one of 4 states: `NotLoaded`, `Fetching`, `Value`, or `FetchingFailed`. +`Cached` is a sealed class which holds the data in your Repository and notifies observers of all changes to that value +as it is loaded. It can be one of 4 states: `NotLoaded`, `Fetching`, `Value`, or `FetchingFailed`. For values that need to be loaded once from some remote source or expensive computation, use `fetchWithCache()` within your InputHandler in response to a `Refresh*` Inputs. That function takes care of determining when to fetch new values and capturing errors from the fetcher. But one particular feature of it is that when a hard refresh is requested, the -state will change the previously-cached value will be carried through those states until a new value finally returns, +state will change the previously-cached value will be carried through those states until a new value finally returns, which can be used to show a progress indicator in the UI with the old values, rather than clearing the entire screen while loading. The `Cached` value has a number of extension functions to help in displaying the right things in the UI according to the status of that cached value. -When a UI ViewModel is observing a `Cached` property from a Repository, you should think of it as if the UI ViewModel -simply observes a "view" of the repository. Technically, the cached values will be copied into the UI ViewModel, but +When a UI ViewModel is observing a `Cached` property from a Repository, you should think of it as if the UI ViewModel +simply observes a "view" of the repository. Technically, the cached values will be copied into the UI ViewModel, but there shouldn't be any reason to change the value directly in the UI ViewModel. Instead, send those changes back to the -Repository and wait for it to get changed there, at which point the updated value will flow back into the UI ViewModel. -Also, do not unwrap the Cached value in the UI ViewModel, continue to hold onto it as the wrapped `Cached` value so +Repository and wait for it to get changed there, at which point the updated value will flow back into the UI ViewModel. +Also, do not unwrap the Cached value in the UI ViewModel, continue to hold onto it as the wrapped `Cached` value so that the UI can use the Cached DSL to optimize its display of the inner value. ## Installation @@ -260,7 +284,7 @@ repositories { // for plain JVM or Android projects dependencies { - implementation("io.github.copper-leaf:ballast-repository:{{gradle.version}}") + implementation("io.github.copper-leaf:ballast-repository:{{ballastVersion}}") } // for multiplatform projects @@ -268,7 +292,7 @@ kotlin { sourceSets { val commonMain by getting { dependencies { - implementation("io.github.copper-leaf:ballast-repository:{{gradle.version}}") + implementation("io.github.copper-leaf:ballast-repository:{{ballastVersion}}") } } } diff --git a/ballast-repository/api/android/ballast-repository.api b/ballast-repository/api/android/ballast-repository.api index 49caba54..b9589024 100644 --- a/ballast-repository/api/android/ballast-repository.api +++ b/ballast-repository/api/android/ballast-repository.api @@ -2,6 +2,7 @@ public class com/copperleaf/ballast/repository/AndroidBallastRepository : androi public static final field Companion Lcom/copperleaf/ballast/repository/AndroidBallastRepository$Companion; public fun (Lcom/copperleaf/ballast/repository/bus/EventBus;Lcom/copperleaf/ballast/BallastViewModelConfiguration;)V public fun (Lcom/copperleaf/ballast/repository/bus/EventBus;Lcom/copperleaf/ballast/BallastViewModelConfiguration;Lkotlinx/coroutines/CoroutineScope;)V + public fun close ()V public fun observeStates ()Lkotlinx/coroutines/flow/StateFlow; public fun send (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun sendAndAwaitCompletion (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; diff --git a/ballast-repository/build.gradle.kts b/ballast-repository/build.gradle.kts index 70301045..6c3276a7 100644 --- a/ballast-repository/build.gradle.kts +++ b/ballast-repository/build.gradle.kts @@ -3,7 +3,7 @@ plugins { id("copper-leaf-android-library") id("copper-leaf-targets") id("copper-leaf-tests") -// id("copper-leaf-lint") + id("copper-leaf-lint") id("copper-leaf-publish") } diff --git a/ballast-repository/src/commonMain/kotlin/com/copperleaf/ballast/repository/utils.kt b/ballast-repository/src/commonMain/kotlin/com/copperleaf/ballast/repository/utils.kt index 11f6174d..de6aaca3 100644 --- a/ballast-repository/src/commonMain/kotlin/com/copperleaf/ballast/repository/utils.kt +++ b/ballast-repository/src/commonMain/kotlin/com/copperleaf/ballast/repository/utils.kt @@ -8,8 +8,7 @@ import com.copperleaf.ballast.core.FifoInputStrategy * type-compatible with each other even though the builder itself is untyped. Returns a fully-built * [BallastViewModelConfiguration]. */ -public fun BallastViewModelConfiguration.Builder.withRepository( -): BallastViewModelConfiguration.Builder = +public fun BallastViewModelConfiguration.Builder.withRepository(): BallastViewModelConfiguration.Builder = this .apply { inputStrategy = FifoInputStrategy() } @@ -18,7 +17,6 @@ public fun BallastViewModelConfiguration.Builder.withRepository( * type-compatible with each other even though the builder itself is untyped. Returns a fully-built * [BallastViewModelConfiguration]. */ -public fun BallastViewModelConfiguration.TypedBuilder.withRepository( -): BallastViewModelConfiguration.TypedBuilder = +public fun BallastViewModelConfiguration.TypedBuilder.withRepository(): BallastViewModelConfiguration.TypedBuilder = this .apply { inputStrategy = FifoInputStrategy.typed() } diff --git a/docs/src/doc/docs/pages/wiki/modules/ballast-saved-state.md b/ballast-saved-state/README.md similarity index 81% rename from docs/src/doc/docs/pages/wiki/modules/ballast-saved-state.md rename to ballast-saved-state/README.md index 95cc4f1a..08abb4f3 100644 --- a/docs/src/doc/docs/pages/wiki/modules/ballast-saved-state.md +++ b/ballast-saved-state/README.md @@ -1,29 +1,42 @@ ---- ---- +# Ballast Saved State ## Overview Ballast ViewModels are held entirely in memory, but there are lots of cases where the ViewModel state needs to be saved -in one session and restored in another. The traditional way to do this is to put all that saving/loading logic within -the InputHandler itself, but this can become messy and error-prone. +in one session and restored in another. The traditional way to do this is to put all that saving/loading logic within +the InputHandler itself, but this can become messy and error-prone. -The Saved State module implements the same kind of save/restore state functionality as an Interceptor. Using an +The Saved State module implements the same kind of save/restore state functionality as an Interceptor. Using an Interceptor ensures that all changes to the State are persisted, and ensures that the ViewModel does nothing else while the State is being loaded. Ballast Saved State offers a standard API to let you save the State to any persistent store you wish, but also offers out-of-the-box integration with `SavedStateHandle`. +## Supported Platforms + +| Platform | Supported | +|----------|-----------| +| JVM | ✅ | +| Android | ✅ | +| iOS | ✅ | +| JS | ✅ | +| WASM JS | ✅ | + +## See Also + +- [Ballast Kotlinx Serialization](./../ballast-kotlinx-serialization) + ## Usage Start by creating a `SavedStateAdapter` for your ViewModel. This adapter includes functions to `save()` and `restore()` -the state, which will get called at the appropriate times. +the state, which will get called at the appropriate times. -`restore()` will be called initially when the `ViewModelStarted` is sent, and requires that no other Inputs get sent -until after the State has been restored. If you need to do some additional initialization after the State has been +`restore()` will be called initially when the `ViewModelStarted` is sent, and requires that no other Inputs get sent +until after the State has been restored. If you need to do some additional initialization after the State has been loaded, you can override `onRestoreComplete()` to send an Input back to the VM once the State has been restored. -The `save()` function will be called anytime the State gets updated. You can use the `saveDiff()` function to save +The `save()` function will be called anytime the State gets updated. You can use the `saveDiff()` function to save individual properties of the State only when they've changed, to reduce unnecessary writes. ```kotlin @@ -90,7 +103,7 @@ repositories { // for plain JVM or Android projects dependencies { - implementation("io.github.copper-leaf:ballast-saved-state:{{gradle.version}}") + implementation("io.github.copper-leaf:ballast-saved-state:{{ballastVersion}}") } // for multiplatform projects @@ -98,7 +111,7 @@ kotlin { sourceSets { val commonMain by getting { dependencies { - implementation("io.github.copper-leaf:ballast-saved-state:{{gradle.version}}") + implementation("io.github.copper-leaf:ballast-saved-state:{{ballastVersion}}") } } } diff --git a/ballast-saved-state/api/android/ballast-saved-state.api b/ballast-saved-state/api/android/ballast-saved-state.api index 16dc2dbd..0bc5b516 100644 --- a/ballast-saved-state/api/android/ballast-saved-state.api +++ b/ballast-saved-state/api/android/ballast-saved-state.api @@ -22,6 +22,8 @@ public final class com/copperleaf/ballast/savedstate/BallastSavedStateIntercepto } public abstract interface class com/copperleaf/ballast/savedstate/RestoreStateScope { + public abstract fun getDecoder ()Lcom/copperleaf/ballast/BallastDecoder; + public abstract fun getEncoder ()Lcom/copperleaf/ballast/BallastEncoder; public abstract fun getHostViewModelName ()Ljava/lang/String; public abstract fun getInitialState ()Ljava/lang/Object; public abstract fun getLogger ()Lcom/copperleaf/ballast/BallastLogger; @@ -31,6 +33,8 @@ public abstract interface class com/copperleaf/ballast/savedstate/RestoreStateSc public final class com/copperleaf/ballast/savedstate/RestoreStateScopeImpl : com/copperleaf/ballast/savedstate/RestoreStateScope { public fun (Lcom/copperleaf/ballast/BallastInterceptorScope;)V + public fun getDecoder ()Lcom/copperleaf/ballast/BallastDecoder; + public fun getEncoder ()Lcom/copperleaf/ballast/BallastEncoder; public fun getHostViewModelName ()Ljava/lang/String; public fun getInitialState ()Ljava/lang/Object; public fun getLogger ()Lcom/copperleaf/ballast/BallastLogger; @@ -39,6 +43,8 @@ public final class com/copperleaf/ballast/savedstate/RestoreStateScopeImpl : com } public abstract interface class com/copperleaf/ballast/savedstate/SaveStateScope { + public abstract fun getDecoder ()Lcom/copperleaf/ballast/BallastDecoder; + public abstract fun getEncoder ()Lcom/copperleaf/ballast/BallastEncoder; public abstract fun getHostViewModelName ()Ljava/lang/String; public abstract fun getLogger ()Lcom/copperleaf/ballast/BallastLogger; public abstract fun saveAll (Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; diff --git a/ballast-saved-state/api/jvm/ballast-saved-state.api b/ballast-saved-state/api/jvm/ballast-saved-state.api index e557f1a1..619a41c8 100644 --- a/ballast-saved-state/api/jvm/ballast-saved-state.api +++ b/ballast-saved-state/api/jvm/ballast-saved-state.api @@ -7,6 +7,8 @@ public final class com/copperleaf/ballast/savedstate/BallastSavedStateIntercepto } public abstract interface class com/copperleaf/ballast/savedstate/RestoreStateScope { + public abstract fun getDecoder ()Lcom/copperleaf/ballast/BallastDecoder; + public abstract fun getEncoder ()Lcom/copperleaf/ballast/BallastEncoder; public abstract fun getHostViewModelName ()Ljava/lang/String; public abstract fun getInitialState ()Ljava/lang/Object; public abstract fun getLogger ()Lcom/copperleaf/ballast/BallastLogger; @@ -16,6 +18,8 @@ public abstract interface class com/copperleaf/ballast/savedstate/RestoreStateSc public final class com/copperleaf/ballast/savedstate/RestoreStateScopeImpl : com/copperleaf/ballast/savedstate/RestoreStateScope { public fun (Lcom/copperleaf/ballast/BallastInterceptorScope;)V + public fun getDecoder ()Lcom/copperleaf/ballast/BallastDecoder; + public fun getEncoder ()Lcom/copperleaf/ballast/BallastEncoder; public fun getHostViewModelName ()Ljava/lang/String; public fun getInitialState ()Ljava/lang/Object; public fun getLogger ()Lcom/copperleaf/ballast/BallastLogger; @@ -24,6 +28,8 @@ public final class com/copperleaf/ballast/savedstate/RestoreStateScopeImpl : com } public abstract interface class com/copperleaf/ballast/savedstate/SaveStateScope { + public abstract fun getDecoder ()Lcom/copperleaf/ballast/BallastDecoder; + public abstract fun getEncoder ()Lcom/copperleaf/ballast/BallastEncoder; public abstract fun getHostViewModelName ()Ljava/lang/String; public abstract fun getLogger ()Lcom/copperleaf/ballast/BallastLogger; public abstract fun saveAll (Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; diff --git a/ballast-saved-state/build.gradle.kts b/ballast-saved-state/build.gradle.kts index 7eec41e5..213338c5 100644 --- a/ballast-saved-state/build.gradle.kts +++ b/ballast-saved-state/build.gradle.kts @@ -3,7 +3,7 @@ plugins { id("copper-leaf-android-library") id("copper-leaf-targets") id("copper-leaf-tests") -// id("copper-leaf-lint") + id("copper-leaf-lint") id("copper-leaf-publish") } diff --git a/ballast-saved-state/src/commonMain/kotlin/com/copperleaf/ballast/savedstate/RestoreStateScope.kt b/ballast-saved-state/src/commonMain/kotlin/com/copperleaf/ballast/savedstate/RestoreStateScope.kt index 844d5b2b..186b92f0 100644 --- a/ballast-saved-state/src/commonMain/kotlin/com/copperleaf/ballast/savedstate/RestoreStateScope.kt +++ b/ballast-saved-state/src/commonMain/kotlin/com/copperleaf/ballast/savedstate/RestoreStateScope.kt @@ -1,5 +1,7 @@ package com.copperleaf.ballast.savedstate +import com.copperleaf.ballast.BallastDecoder +import com.copperleaf.ballast.BallastEncoder import com.copperleaf.ballast.BallastLogger import com.copperleaf.ballast.EventHandler @@ -8,6 +10,8 @@ public interface RestoreStateScope { public val logger: BallastLogger public val hostViewModelName: String public val initialState: State + public val encoder: BallastEncoder + public val decoder: BallastDecoder? /** * Post an Input back to the ViewModel's queue after the state has been fully restored. This Input will not be diff --git a/ballast-saved-state/src/commonMain/kotlin/com/copperleaf/ballast/savedstate/RestoreStateScopeImpl.kt b/ballast-saved-state/src/commonMain/kotlin/com/copperleaf/ballast/savedstate/RestoreStateScopeImpl.kt index e97b13fd..9eb06ed7 100644 --- a/ballast-saved-state/src/commonMain/kotlin/com/copperleaf/ballast/savedstate/RestoreStateScopeImpl.kt +++ b/ballast-saved-state/src/commonMain/kotlin/com/copperleaf/ballast/savedstate/RestoreStateScopeImpl.kt @@ -1,5 +1,7 @@ package com.copperleaf.ballast.savedstate +import com.copperleaf.ballast.BallastDecoder +import com.copperleaf.ballast.BallastEncoder import com.copperleaf.ballast.BallastInterceptorScope import com.copperleaf.ballast.BallastLogger @@ -10,6 +12,8 @@ public class RestoreStateScopeImpl( override val logger: BallastLogger = interceptorScope.logger override val hostViewModelName: String = interceptorScope.hostViewModelName override val initialState: State = interceptorScope.initialState + override val encoder: BallastEncoder get() = interceptorScope.encoder + override val decoder: BallastDecoder? get() = interceptorScope.decoder internal val inputToPostAfterRestore = mutableListOf() internal val eventsToPostAfterRestore = mutableListOf() diff --git a/ballast-saved-state/src/commonMain/kotlin/com/copperleaf/ballast/savedstate/SaveStateScope.kt b/ballast-saved-state/src/commonMain/kotlin/com/copperleaf/ballast/savedstate/SaveStateScope.kt index f631151d..d261dc33 100644 --- a/ballast-saved-state/src/commonMain/kotlin/com/copperleaf/ballast/savedstate/SaveStateScope.kt +++ b/ballast-saved-state/src/commonMain/kotlin/com/copperleaf/ballast/savedstate/SaveStateScope.kt @@ -1,11 +1,15 @@ package com.copperleaf.ballast.savedstate +import com.copperleaf.ballast.BallastDecoder +import com.copperleaf.ballast.BallastEncoder import com.copperleaf.ballast.BallastLogger public interface SaveStateScope { public val logger: BallastLogger public val hostViewModelName: String + public val encoder: BallastEncoder + public val decoder: BallastDecoder? /** * Save the value of [computeProperty] if it is not equal to the previous state's value. Equality is checked with diff --git a/ballast-saved-state/src/commonMain/kotlin/com/copperleaf/ballast/savedstate/SaveStateScopeImpl.kt b/ballast-saved-state/src/commonMain/kotlin/com/copperleaf/ballast/savedstate/SaveStateScopeImpl.kt index ace531ba..e137e617 100644 --- a/ballast-saved-state/src/commonMain/kotlin/com/copperleaf/ballast/savedstate/SaveStateScopeImpl.kt +++ b/ballast-saved-state/src/commonMain/kotlin/com/copperleaf/ballast/savedstate/SaveStateScopeImpl.kt @@ -1,5 +1,7 @@ package com.copperleaf.ballast.savedstate +import com.copperleaf.ballast.BallastDecoder +import com.copperleaf.ballast.BallastEncoder import com.copperleaf.ballast.BallastInterceptorScope import com.copperleaf.ballast.BallastLogger @@ -11,6 +13,8 @@ internal class SaveStateScopeImpl( override val logger: BallastLogger = interceptorScope.logger override val hostViewModelName: String = interceptorScope.hostViewModelName + override val encoder: BallastEncoder get() = interceptorScope.encoder + override val decoder: BallastDecoder? get() = interceptorScope.decoder override suspend fun saveDiff( computeProperty: State.() -> Prop, diff --git a/ballast-scheduler-android-alarmmanager/README.md b/ballast-scheduler-android-alarmmanager/README.md new file mode 100644 index 00000000..5f0033e4 --- /dev/null +++ b/ballast-scheduler-android-alarmmanager/README.md @@ -0,0 +1,100 @@ +# Ballast Scheduler Android AlarmManager + +> [!CAUTION] +> +> Experimental. This module may not still have issues or changes in its public API before being considered stable. +> Please use at your own risk, and file Issues for any problems you may encounter. + +## Overview + +A AlarmManager-based implementation of the Ballast Scheduler library for persistent, long-running scheduled tasks on +Android. Unlike in-memory schedulers which stop when the app is closed, this module uses AlarmManager to ensure +scheduled tasks are reliably executed even when the app is not in the foreground. + +> [!NOTE] +> AlarmManager is intended for tasks where exact wall-clock timing is important, and such exact timing may have a +> significant impact on device battery life if the alarms wake up the device frequently. Workmanager is more efficient +> for batter life as it is inexact by nature and batches tasks together. But that efficiency has the tradeoff of really +> only being useful for non user-visible tasks. +> +> WorkManager is great for non user-visible work, and this module is not intended to be a replacement for it. Rather, it +> serves the purpose of user-visible scheduling such as Calendar notifications, which WorkManager cannot reliably +> handle. + +## Supported Platforms + +| Platform | Supported | +|----------|-----------| +| JVM | ❌ | +| Android | ✅ | +| iOS | ❌ | +| JS | ❌ | +| WASM JS | ❌ | + +## See Also + +- [Ballast Scheduler Core](./../ballast-scheduler-core) +- [Ballast Scheduler Cron](./../ballast-scheduler-cron) +- [Ballast Scheduler ViewModel](./../ballast-scheduler-viewmodel) + +## Usage + +Initialize a `BallastAlarmManager` once (e.g. in your `Application.onCreate()`) by providing an +`EventDrivenScheduleExecutor` configured with an `AlarmManagerAdapter`. The executor handles registering, updating, +and cancelling alarms. A `BallastAlarmManagerBootCompletedWorker` receiver must also be registered in your +`AndroidManifest.xml` to re-sync scheduled alarms after device reboot. + +```kotlin +// In Application.onCreate() or a DI module +val executor = BallastAlarmManager.initialize( + executor = EventDrivenScheduleExecutor( + adapter = AlarmManagerAdapter(applicationContext), + state = SharedPreferencesScheduleState(applicationContext), + scheduleSerializer = ExampleSchedule.serializer(), + callbackSerializer = ExampleCallback.serializer(), + ), + precision = AlarmPrecision.Default, // setExact — change to High for setExactAndAllowWhileIdle +) + +// Register a schedule +executor.registerOrUpdateSchedule( + schedule = ExampleSchedule(name = "DailyReminder", cronExpression = "0 8 * * *"), + callback = ExampleCallback(), +) +``` + +```xml + + + + + + + +``` + +## Installation + +```kotlin +repositories { + mavenCentral() +} + +// for plain Android projects +dependencies { + implementation("io.github.copper-leaf:ballast-scheduler-android-alarmmanager:{{ballastVersion}}") +} + +// for multiplatform projects +kotlin { + sourceSets { + val androidMain by getting { + dependencies { + implementation("io.github.copper-leaf:ballast-scheduler-android-alarmmanager:{{ballastVersion}}") + } + } + } +} +``` diff --git a/ballast-scheduler-android-alarmmanager/api/ballast-scheduler-android-alarmmanager.api b/ballast-scheduler-android-alarmmanager/api/ballast-scheduler-android-alarmmanager.api new file mode 100644 index 00000000..7d838ab3 --- /dev/null +++ b/ballast-scheduler-android-alarmmanager/api/ballast-scheduler-android-alarmmanager.api @@ -0,0 +1,54 @@ +public final class com/copperleaf/ballast/scheduler/alarmmanager/AlarmManagerAdapter : com/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleExecutor$Adapter { + public fun (Landroid/content/Context;Lkotlinx/serialization/json/Json;)V + public synthetic fun (Landroid/content/Context;Lkotlinx/serialization/json/Json;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun cancelSchedule (Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleData;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun registerSchedule (Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleData;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun synchronizeSchedules (Lkotlin/sequences/Sequence;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun updateSchedule (Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleData;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/copperleaf/ballast/scheduler/alarmmanager/AlarmPrecision : java/lang/Enum { + public static final field Default Lcom/copperleaf/ballast/scheduler/alarmmanager/AlarmPrecision; + public static final field High Lcom/copperleaf/ballast/scheduler/alarmmanager/AlarmPrecision; + public static final field Low Lcom/copperleaf/ballast/scheduler/alarmmanager/AlarmPrecision; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/copperleaf/ballast/scheduler/alarmmanager/AlarmPrecision; + public static fun values ()[Lcom/copperleaf/ballast/scheduler/alarmmanager/AlarmPrecision; +} + +public final class com/copperleaf/ballast/scheduler/alarmmanager/BallastAlarmManager { + public static final field Companion Lcom/copperleaf/ballast/scheduler/alarmmanager/BallastAlarmManager$Companion; + public synthetic fun (Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleExecutor;Lcom/copperleaf/ballast/scheduler/alarmmanager/AlarmPrecision;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getExecutor ()Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleExecutor; + public final fun getPrecision ()Lcom/copperleaf/ballast/scheduler/alarmmanager/AlarmPrecision; +} + +public final class com/copperleaf/ballast/scheduler/alarmmanager/BallastAlarmManager$Companion { + public final fun getAllConfigurations ()Ljava/util/List; + public final fun getInstance ()Lcom/copperleaf/ballast/scheduler/alarmmanager/BallastAlarmManager; + public final fun getInstance (Ljava/lang/String;)Lcom/copperleaf/ballast/scheduler/alarmmanager/BallastAlarmManager; + public final fun initialize (Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleExecutor;Lcom/copperleaf/ballast/scheduler/alarmmanager/AlarmPrecision;)Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleExecutor; + public final fun initialize (Ljava/lang/String;Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleExecutor;Lcom/copperleaf/ballast/scheduler/alarmmanager/AlarmPrecision;)Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleExecutor; + public static synthetic fun initialize$default (Lcom/copperleaf/ballast/scheduler/alarmmanager/BallastAlarmManager$Companion;Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleExecutor;Lcom/copperleaf/ballast/scheduler/alarmmanager/AlarmPrecision;ILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleExecutor; + public static synthetic fun initialize$default (Lcom/copperleaf/ballast/scheduler/alarmmanager/BallastAlarmManager$Companion;Ljava/lang/String;Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleExecutor;Lcom/copperleaf/ballast/scheduler/alarmmanager/AlarmPrecision;ILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleExecutor; +} + +public final class com/copperleaf/ballast/scheduler/alarmmanager/BallastAlarmManagerBootCompletedWorker : android/content/BroadcastReceiver { + public fun ()V + public fun onReceive (Landroid/content/Context;Landroid/content/Intent;)V +} + +public final class com/copperleaf/ballast/scheduler/alarmmanager/BallastAlarmManagerScheduleWorker : android/content/BroadcastReceiver { + public fun ()V + public fun onReceive (Landroid/content/Context;Landroid/content/Intent;)V +} + +public final class com/copperleaf/ballast/scheduler/alarmmanager/SharedPreferencesScheduleState : com/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleExecutor$State { + public fun (Landroid/content/Context;)V + public fun (Landroid/content/SharedPreferences;)V + public fun getAllSchedules (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getState (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun removeScheduleData (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun storeScheduleData (Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleData;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + diff --git a/ballast-scheduler-android-alarmmanager/build.gradle.kts b/ballast-scheduler-android-alarmmanager/build.gradle.kts new file mode 100644 index 00000000..e3ef188b --- /dev/null +++ b/ballast-scheduler-android-alarmmanager/build.gradle.kts @@ -0,0 +1,28 @@ +plugins { + id("copper-leaf-base") + id("copper-leaf-android-library") + id("copper-leaf-targets") + id("copper-leaf-tests") + id("copper-leaf-lint") + id("copper-leaf-publish") + id("copper-leaf-serialization") +} + +kotlin { + compilerOptions { + optIn.add("kotlin.time.ExperimentalTime") + } + + sourceSets { + val androidMain by getting { + dependencies { + implementation(project(":ballast-scheduler-core")) + } + } + val androidUnitTest by getting { + dependencies { + implementation(project(":ballast-test")) + } + } + } +} diff --git a/ballast-scheduler-android-alarmmanager/gradle.properties b/ballast-scheduler-android-alarmmanager/gradle.properties new file mode 100644 index 00000000..3dc1e4c7 --- /dev/null +++ b/ballast-scheduler-android-alarmmanager/gradle.properties @@ -0,0 +1,8 @@ +copperleaf.description=A WorkManager-based implementation of the Ballast Scheduler library + +copperleaf.targets.android=true +copperleaf.targets.jvm=false +copperleaf.targets.ios=false +copperleaf.targets.js=false +copperleaf.targets.wasm.wasi=false +copperleaf.targets.wasm.js=false diff --git a/ballast-scheduler-android-alarmmanager/src/androidMain/AndroidManifest.xml b/ballast-scheduler-android-alarmmanager/src/androidMain/AndroidManifest.xml new file mode 100644 index 00000000..b7577790 --- /dev/null +++ b/ballast-scheduler-android-alarmmanager/src/androidMain/AndroidManifest.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + diff --git a/ballast-scheduler-android-alarmmanager/src/androidMain/kotlin/com/copperleaf/ballast/scheduler/alarmmanager/AlarmManagerAdapter.kt b/ballast-scheduler-android-alarmmanager/src/androidMain/kotlin/com/copperleaf/ballast/scheduler/alarmmanager/AlarmManagerAdapter.kt new file mode 100644 index 00000000..257f968b --- /dev/null +++ b/ballast-scheduler-android-alarmmanager/src/androidMain/kotlin/com/copperleaf/ballast/scheduler/alarmmanager/AlarmManagerAdapter.kt @@ -0,0 +1,79 @@ +package com.copperleaf.ballast.scheduler.alarmmanager + +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import com.copperleaf.ballast.scheduler.NamedSchedule +import com.copperleaf.ballast.scheduler.SchedulerCallback +import com.copperleaf.ballast.scheduler.alarmmanager.AlarmManagerConstants.KEY_INPUT_DATA_PAYLOAD +import com.copperleaf.ballast.scheduler.executor.event.EventDrivenScheduleData +import com.copperleaf.ballast.scheduler.executor.event.EventDrivenScheduleExecutor +import kotlinx.serialization.json.Json + +public class AlarmManagerAdapter( + private val context: Context, + private val json: Json = Json.Default, +) : EventDrivenScheduleExecutor.Adapter { + override suspend fun registerSchedule(data: EventDrivenScheduleData) { + val dataJson = json.encodeToString(EventDrivenScheduleData.serializer(), data) + + val ballastAlarmManagerConfiguration = BallastAlarmManager.getInstance(data.configuration) + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + + val pendingIntent = PendingIntent.getBroadcast( + context, + data.scheduleUniqueName.hashCode(), + Intent(context, BallastAlarmManagerScheduleWorker::class.java).apply { + putExtra(KEY_INPUT_DATA_PAYLOAD, dataJson) + }, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + + when (ballastAlarmManagerConfiguration.precision) { + AlarmPrecision.Low -> { + alarmManager.set( + AlarmManager.RTC, + data.nextExecution.toEpochMilliseconds(), + pendingIntent, + ) + } + AlarmPrecision.Default -> { + alarmManager.setExact( + AlarmManager.RTC, + data.nextExecution.toEpochMilliseconds(), + pendingIntent, + ) + } + AlarmPrecision.High -> { + alarmManager.setExactAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + data.nextExecution.toEpochMilliseconds(), + pendingIntent, + ) + } + } + } + + override suspend fun updateSchedule(data: EventDrivenScheduleData) { + registerSchedule(data) + } + + override suspend fun cancelSchedule(data: EventDrivenScheduleData) { + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + alarmManager.cancel( + PendingIntent.getBroadcast( + context, + data.scheduleUniqueName.hashCode(), + Intent(context, BallastAlarmManagerScheduleWorker::class.java), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + ) + } + + override suspend fun synchronizeSchedules(schedules: Sequence) { + schedules.forEach { + registerSchedule(it) + } + } +} diff --git a/ballast-scheduler-android-alarmmanager/src/androidMain/kotlin/com/copperleaf/ballast/scheduler/alarmmanager/AlarmManagerConstants.kt b/ballast-scheduler-android-alarmmanager/src/androidMain/kotlin/com/copperleaf/ballast/scheduler/alarmmanager/AlarmManagerConstants.kt new file mode 100644 index 00000000..904ca813 --- /dev/null +++ b/ballast-scheduler-android-alarmmanager/src/androidMain/kotlin/com/copperleaf/ballast/scheduler/alarmmanager/AlarmManagerConstants.kt @@ -0,0 +1,5 @@ +package com.copperleaf.ballast.scheduler.alarmmanager + +internal object AlarmManagerConstants { + const val KEY_INPUT_DATA_PAYLOAD = "input_data_payload" +} diff --git a/ballast-scheduler-android-alarmmanager/src/androidMain/kotlin/com/copperleaf/ballast/scheduler/alarmmanager/AlarmPrecision.kt b/ballast-scheduler-android-alarmmanager/src/androidMain/kotlin/com/copperleaf/ballast/scheduler/alarmmanager/AlarmPrecision.kt new file mode 100644 index 00000000..f5943855 --- /dev/null +++ b/ballast-scheduler-android-alarmmanager/src/androidMain/kotlin/com/copperleaf/ballast/scheduler/alarmmanager/AlarmPrecision.kt @@ -0,0 +1,32 @@ +package com.copperleaf.ballast.scheduler.alarmmanager + +public enum class AlarmPrecision { + /** + * Sets alarms using [android.app.AlarmManager.set]. Intended for low-priority background work that is not visible + * to end users and doesn't need to be exact, and can be deferred by the system in order to optimize battery life. + * Alarms set with this method will not wake the device if it is asleep. + * + * Best for: background synchronization, database/cache maintenance + */ + Low, + + /** + * Sets alarms using [android.app.AlarmManager.setExact]. Intended for user-facing features that require exact + * timing, but do not necessarily need to wake the device if it is asleep. Alarms set with this method will be + * delivered at approximately the exact time specified, but may be deferred if the device is asleep. Alarms + * triggered while the device is asleep will be delivered as soon as the device wakes up. + * + * Best for: marketing notifications, non-urgent reminders + */ + Default, + + /** + * Sets alarms using [android.app.AlarmManager.setExactAndAllowWhileIdle]. Intended for user-facing features that + * require exact timing and need to be delivered even if the device is asleep. Alarms set with this method will + * wake up the device to send the notification at the exact time specified, Use this option sparingly, as it can + * have a significant impact on battery life. + * + * Best for: time-sensitive notifications, calendar events + */ + High, +} diff --git a/ballast-scheduler-android-alarmmanager/src/androidMain/kotlin/com/copperleaf/ballast/scheduler/alarmmanager/BallastAlarmManager.kt b/ballast-scheduler-android-alarmmanager/src/androidMain/kotlin/com/copperleaf/ballast/scheduler/alarmmanager/BallastAlarmManager.kt new file mode 100644 index 00000000..4d1bf6cc --- /dev/null +++ b/ballast-scheduler-android-alarmmanager/src/androidMain/kotlin/com/copperleaf/ballast/scheduler/alarmmanager/BallastAlarmManager.kt @@ -0,0 +1,52 @@ +package com.copperleaf.ballast.scheduler.alarmmanager + +import com.copperleaf.ballast.scheduler.NamedSchedule +import com.copperleaf.ballast.scheduler.SchedulerCallback +import com.copperleaf.ballast.scheduler.executor.event.EventDrivenScheduleExecutor + +@Suppress("UNCHECKED_CAST") +public class BallastAlarmManager private constructor( + public val executor: EventDrivenScheduleExecutor, + public val precision: AlarmPrecision, +) { + public companion object { + private var configurations: MutableMap> = mutableMapOf() + + public fun initialize( + executor: EventDrivenScheduleExecutor, + precision: AlarmPrecision = AlarmPrecision.Default, + ): EventDrivenScheduleExecutor { + require(configurations[null] == null) { "BallastAlarmManager default configuration is already initialized" } + configurations[null] = BallastAlarmManager( + executor = executor, + precision = precision, + ) + return executor + } + + public fun initialize( + configurationName: String, + executor: EventDrivenScheduleExecutor, + precision: AlarmPrecision = AlarmPrecision.Default, + ): EventDrivenScheduleExecutor { + require(configurations[configurationName] == null) { "BallastAlarmManager configuration '$configurationName' is already initialized" } + configurations[configurationName] = BallastAlarmManager( + executor = executor, + precision = precision, + ) + return executor + } + + public fun getInstance(): BallastAlarmManager<*, *> { + return requireNotNull(configurations[null]) { "BallastAlarmManager default configuration must be initialized" } + } + + public fun getInstance(configurationName: String?): BallastAlarmManager<*, *> { + return requireNotNull(configurations[configurationName]) { "BallastAlarmManager configuration '$configurationName' must be initialized" } + } + + public fun getAllConfigurations(): List> { + return configurations.values.toList() + } + } +} diff --git a/ballast-scheduler-android-alarmmanager/src/androidMain/kotlin/com/copperleaf/ballast/scheduler/alarmmanager/BallastAlarmManagerBootCompletedWorker.kt b/ballast-scheduler-android-alarmmanager/src/androidMain/kotlin/com/copperleaf/ballast/scheduler/alarmmanager/BallastAlarmManagerBootCompletedWorker.kt new file mode 100644 index 00000000..fc330003 --- /dev/null +++ b/ballast-scheduler-android-alarmmanager/src/androidMain/kotlin/com/copperleaf/ballast/scheduler/alarmmanager/BallastAlarmManagerBootCompletedWorker.kt @@ -0,0 +1,51 @@ +package com.copperleaf.ballast.scheduler.alarmmanager + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import kotlin.time.Clock + +/** + * This is job which executes on each tick of the registered schedule from AlarmManager, then enqueues the next Instant + * that the job should rerun. + */ +@Suppress("UNCHECKED_CAST") +public class BallastAlarmManagerBootCompletedWorker : BroadcastReceiver() { + + private val clock: Clock = Clock.System + private val json: Json = Json.Default + + override fun onReceive(context: Context?, intent: Intent?) { + if (context == null || intent == null) return + + // Validate that this is actually a BOOT_COMPLETED intent to prevent spoofing + if (intent.action != Intent.ACTION_BOOT_COMPLETED) { + Log.w("BallastAlarmManager", "Received intent with unexpected action: ${intent.action}") + return + } + + val pendingResult = goAsync() + + CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { + try { + onReceived(context, intent) + } catch (e: Exception) { + Log.e("BallastAlarmManager", "Error processing schedule", e) + } finally { + pendingResult.finish() + } + } + } + + private suspend fun onReceived(context: Context, intent: Intent) { + BallastAlarmManager.getAllConfigurations().forEach { ballastAlarmManager -> + ballastAlarmManager.executor.synchronizeSchedules() + } + } +} diff --git a/ballast-scheduler-android-alarmmanager/src/androidMain/kotlin/com/copperleaf/ballast/scheduler/alarmmanager/BallastAlarmManagerScheduleWorker.kt b/ballast-scheduler-android-alarmmanager/src/androidMain/kotlin/com/copperleaf/ballast/scheduler/alarmmanager/BallastAlarmManagerScheduleWorker.kt new file mode 100644 index 00000000..f0ee254e --- /dev/null +++ b/ballast-scheduler-android-alarmmanager/src/androidMain/kotlin/com/copperleaf/ballast/scheduler/alarmmanager/BallastAlarmManagerScheduleWorker.kt @@ -0,0 +1,43 @@ +package com.copperleaf.ballast.scheduler.alarmmanager + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log +import com.copperleaf.ballast.scheduler.alarmmanager.AlarmManagerConstants.KEY_INPUT_DATA_PAYLOAD +import com.copperleaf.ballast.scheduler.executor.event.EventDrivenScheduleData +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json + +@Suppress("UNCHECKED_CAST") +public class BallastAlarmManagerScheduleWorker : BroadcastReceiver() { + + override fun onReceive(context: Context?, intent: Intent?) { + if (context == null || intent == null) return + + val pendingResult = goAsync() + + CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { + try { + onReceived(context, intent) + } catch (e: Exception) { + Log.e("BallastAlarmManager", "Error processing schedule", e) + } finally { + pendingResult.finish() + } + } + } + + private suspend fun onReceived(context: Context, intent: Intent) { + val payloadJson = intent.getStringExtra(KEY_INPUT_DATA_PAYLOAD) ?: error("Missing input data in extras") + val data = Json.Default.decodeFromString(EventDrivenScheduleData.serializer(), payloadJson) + + BallastAlarmManager + .getInstance(data.configuration) + .executor + .handleTask(data) + } +} diff --git a/ballast-scheduler-android-alarmmanager/src/androidMain/kotlin/com/copperleaf/ballast/scheduler/alarmmanager/SharedPreferencesScheduleState.kt b/ballast-scheduler-android-alarmmanager/src/androidMain/kotlin/com/copperleaf/ballast/scheduler/alarmmanager/SharedPreferencesScheduleState.kt new file mode 100644 index 00000000..82e6354f --- /dev/null +++ b/ballast-scheduler-android-alarmmanager/src/androidMain/kotlin/com/copperleaf/ballast/scheduler/alarmmanager/SharedPreferencesScheduleState.kt @@ -0,0 +1,53 @@ +package com.copperleaf.ballast.scheduler.alarmmanager + +import android.content.Context +import android.content.Context.MODE_PRIVATE +import android.content.SharedPreferences +import com.copperleaf.ballast.scheduler.executor.event.EventDrivenScheduleData +import com.copperleaf.ballast.scheduler.executor.event.EventDrivenScheduleExecutor +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json + +public class SharedPreferencesScheduleState( + private val preferences: SharedPreferences +) : EventDrivenScheduleExecutor.State { + + public constructor(applicationContext: Context) : this( + applicationContext.getSharedPreferences("schedules", MODE_PRIVATE) + ) + + private val json: Json = Json.Default + private val serializer = ListSerializer(EventDrivenScheduleData.serializer()) + + private var scheduleState: List + get() = preferences.getString("scheduleState", null) + ?.let { json.decodeFromString(serializer, it) } + ?: emptyList() + set(value) { + preferences + .edit() + .putString("scheduleState", json.encodeToString(serializer, value)) + .apply() + } + + override suspend fun getAllSchedules(): Sequence { + return scheduleState.asSequence() + } + + override suspend fun getState(scheduleUniqueName: String): EventDrivenScheduleData? { + return scheduleState.find { it.scheduleUniqueName == scheduleUniqueName } + } + + override suspend fun storeScheduleData(data: EventDrivenScheduleData) { + val existing = scheduleState.find { it.scheduleUniqueName == data.scheduleUniqueName } + if (existing != null) { + scheduleState = scheduleState - existing + data + } else { + scheduleState = scheduleState + data + } + } + + override suspend fun removeScheduleData(scheduleUniqueName: String) { + scheduleState = scheduleState.filterNot { it.scheduleUniqueName == scheduleUniqueName } + } +} diff --git a/ballast-scheduler-core/README.md b/ballast-scheduler-core/README.md new file mode 100644 index 00000000..b20d5dd6 --- /dev/null +++ b/ballast-scheduler-core/README.md @@ -0,0 +1,215 @@ +# Ballast Scheduler Core + +> [!CAUTION] +> +> Experimental. This module may not still have issues or changes in its public API before being considered stable. +> Please use at your own risk, and file Issues for any problems you may encounter. + +## Overview + +Ballast Scheduler is a lightweight way to reliably run periodic work. This Core module is completely independent of +Ballast's MVI system, and focuses on the specific problem of scheduling, and can be used without adopting the full MVI +architecture. + +This module provides several ways to run in-memory schedules (based on coroutines `delay()`, or with cron-like polling), +as well as several basic schedules to run tasks on. Additional scheduling functionality is provided in other modules, +linked in [See Also](#see-also) section below. + +## Supported Platforms + +| Platform | Supported | +|----------|-----------| +| JVM | ✅ | +| Android | ✅ | +| iOS | ✅ | +| JS | ✅ | +| WASM JS | ✅ | + +## See Also + +- [Ballast Scheduler Cron](./../ballast-scheduler-cron) +- [Ballast Scheduler ViewModel](./../ballast-scheduler-viewmodel) + +## Usage + +This library is based on the concept of a `Schedule`, which is a generator or `kotlin.time.Instant`s that a task should +be run on. A `Schedule` produces a `Sequence` of future Instants from a given starting Instant, which declare +the ideal schedule for running tasks. A `ScheduleExecutor` is responsible for actually dispatching tasks to your +application at the correct moment in time. Essentially, a ScheduleExecutor converts a `Sequence` to +`Flow`, such that the collector of that Flow executes tasks at the proper time. + +```kotlin +// Definition of a Schedule +fun interface Schedule { + fun generateSchedule(start: Instant): Sequence +} +``` + +A Schedule is required to always provide the _next_ moment in time after the start Instant. Some executors may +instead execute the Schedule by only receiving the first element from `generateSchedule()`, then passing that value +in to `generateSchedule()` again. This is not a direct collection of the Sequence but effectively produces the same +result, and allows one to persist and resume the schedule state. + +### ScheduleExecutors + +#### Basic Usage + +A `ScheduleExecutor` converts an ideal `Schedule` into a realtime `Flow` of tasks. Depending on how it's used, the +resulting flow may apply backpressure to the upstream Schedule to deal withs scenarios where the task takes longer to +run than the ideal delay between tasks. It is up to the implementation of the Executor whether backpressure can actually +be applies or not. + +All schedules have 2 modes of operation: + +`runSchedule(schedule: Schedule)`: This will run a single schedule, directly converting the schedule to a Flow. As a +direct execution, it can potentially apply backpressure. + +```kotlin +val schedule = EveryMinuteSchedule() +val executor = DelayScheduleExecutor() + +executor + .runSchedule(schedule) + .onEach { + println("Executing scheduled task at ${it.triggeredAt}") + } + .launchIn(viewModelScope) +``` + +`runSchedules(schedules: List)`: This will run multiple schedules in a back, emitting vales from each +of them to the same downstream `Flow` using [merge](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/merge.html). +Because the upstream Schedules are all merged concurrently, backpressure cannot be applied from the downstream Flow, +as just one Schedule emitting too quickly would block the execution of other Schedules. Additionally, each Schedule +must also be given a unique String name so emissions can be differentiated, using `Schedule.named(""")`. + +```kotlin +val schedule1 = EveryMinuteSchedule().named("EveryMinuteSchedule") +val schedule2 = EverySecondSchedule().named("EverySecondSchedule") +val executor = DelayScheduleExecutor() + +executor + .runSchedules(listOf(schedule1, schedule2)) + .onEach { + println("Executing scheduled task from ${it.name} at ${it.triggeredAt}") + } + .launchIn(viewModelScope) +``` + +#### DelayScheduleExecutor + +The `DelayScheduleExecutor` runs tasks based on a simple Coroutine delay loop, where tasks are executed at the exact +moment that the schedule requested (within a few milliseconds, typically). Collecting from single schedule with +`runSchedule` will apply backpressure to the Schedule. If the collector is still collecting an element when the next +tick is triggered, that task will be dropped and a `onTaskDropped` lambda will be called with that instant for logging +or other recovery. + +One method of applying backpressure is to add the `.adaptive()` operator on the upstream Schedule. This will take the +original Schedule's declared times and delay it by the actual time it took to process the task, effectively adapting the +schedule to consider the schedule as a declaration of delay between the end of one task and the start of another, rather +than the start of both tasks. This should prevent `onTaskDropped` from being called, as each emission would be +guaranteed to be in the future. + +The `DelayScheduleExecutor` is best suited for schedules of relatively short delays (on the order of minutes), where +exact timing guarantees are needed. It is also best when you need to run just one schedule with the ability to apply +backpressure or handle missed triggers. + +#### PollingScheduleExecutor + +For tasks that run less frequently, such as in server-side applications, backpressure is not as important as +reliability. The `PollingScheduleExecutor` is inspired by a Cron-like processing loop, where only one delay loop is +running, and each minute it checks for which Schedules would like to trigger a task during that minute. This processes +more efficiently, but at the cost of less precision, since tasks may be delayed by up to a minute from their ideal +moment of execution. + +It is not possible to apply backpressure to a Schedule with `PollingScheduleExecutor`, since internally it does not +directly collect from the Schedule's sequence. Instead, the state of each schedule is stored in a +`ScheduleExecutor.State`, where the previous execution of the schedule is used as the start for a new call to +`generateSchedule()` every minute. You can use `InMemoryScheduleState` for storing these previous executions in memory, +but it is advised to store this state in a persistent store in your application, such as a database table. + +**Configuration:** + +`pollingSchedule` - You can poll at a different schedule besides every minute, to make the polling even more efficient. +Any schedule you use to run tasks can also define the polling schedule. Note that if you run the schedule less +frequently than once per minute, tasks may get skipped since it only checks for matching scheduled tasks in the +_current_ minute, not since the last polled minute. Be sure to align your scheduled tasks to the polling schedule with +`Schedule.alignTo()` + +`catchUpBehavior` - If your application was not running when a scheduled task was supposed to run, it will be detected +the next time this executor starts processing. By default, a single task will be triggered to catch up, no matter how +many tasks were missed in the downtime. This can be configured to: + +- `CatchUpBehavior.ExecuteOne` - process just the first task that was missed, and skip the ones after that. +- `CatchUpBehavior.ExecuteAll` - process all missed tasks one-by-one. It is up to you to either process those tasks + sequentially or in parallel and synchronizing between these tasks. +- `CatchUpBehavior.Skip` - Don't process any missed tasks. Just update the state and continue from the current moment in + time. + +### Schedules + +There are a handful of basic schedules for basic tasks: `EveryDaySchedule`, `EveryHourSchedule`, `EveryMinuteSchedule`, +and `EverySecondSchedule`. By default, each of these execute at the "top" of the given timeframe (at midnight, at minute +0 of the hour, etc.). You can instead provide a list of moments during the given timeframe (at midnight at noon, at minutes +0, 15, 30, and 45 of each hour, etc.). + +Instead of triggering a schedule at an exact repeated moment, you can instead provide an arbitrary delay between tasks +with `FixedDelaySchedule` or `ExponentialDelaySchedule`. + +The last predefined schedule is `FixedInstantSchedule`, which allows you to provide an exact list of `Instants` to +trigger your schedule. Note that unlike the other predefined schedules which are all _generators_ and provide an +infinite sequence of tasks, this one has a fixed set of tasks to run, after which will it never trigger again. + +### Schedule Operators + +Schedules are fundamentally based on `Sequences`, so it's easy to customize the behavior of a predefined schedule. The +following operators are available out-of-the-box, but you're also welcome to build whatever other Sequence operators you +need to generate more custom scheduling behavior. + +- `schedule.adaptive()`: mostly useful for the `FixedDelaySchedule`, to adjust the time between tasks by the amount of + time it takes to process them. +- `schedule.alignTo(DurationUnit, TimeZone)`: Aligns each scheduled instant to the next boundary of the given + time unit. For example, a schedule that fires at `:30` seconds past the minute, when aligned to + `DurationUnit.MINUTES`, will instead fire at the top of the next minute (`:00`). Supported units are `SECONDS`, + `MINUTES`, `HOURS`, and `DAYS`. This is particularly useful for aligning schedules to the + `PollingScheduleExecutor`'s polling interval. +- `schedule.between(ClosedRange)`: Filter emissions so that they are only handled during the given time range. + Once the end of the range has been passed, the schedule will complete +- `schedule.startingAt(Instant)`: Delay the start of a schedule until a specified Instant +- `schedule.until(Instant)`: Process Inputs as long as they are before the end Instant. This makes the schedule finite; + once the end time has been passed, the schedule will complete. +- `schedule.delayed(Duration)`: Delay the start of a schedule by a specified Duration +- `schedule.delayedUntil(Instant)`: Delay the start of a schedule by a specified Duration +- `schedule.filterByDayOfWeek(vararg dayOfWeek)`: Filters the scheduled instants so they only trigger on the specified + days of the week. Related operators of `schedule.weekdays()` and `schedule.weekends()` are also available. +- `schedule.named(String)`: Provides a unique name to the Schedule so it can be batched with other schedules in the same + ScheduleExecutor. +- `schedule.take(Int)`: Only handle the first N emissions of the sequence. This makes the schedule finite, limited to at + most N emissions. +- `schedule.getNext(Clock)`: Get the next trigger of the schedule after the current Instant +- `schedule.getNext(Instant)`: Get the next trigger of the schedule after a specified Instant +- `schedule.transform { squence -> sequence }`: Apply custom operators directly to the generated Sequence, returning a + new Schedule that encapsulates that transformation. + +## Installation + +```kotlin +repositories { + mavenCentral() +} + +// for plain JVM or Android projects +dependencies { + implementation("io.github.copper-leaf:ballast-scheduler-core:{{ballastVersion}}") +} + +// for multiplatform projects +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + implementation("io.github.copper-leaf:ballast-scheduler-core:{{ballastVersion}}") + } + } + } +} +``` diff --git a/ballast-scheduler-core/api/android/ballast-scheduler-core.api b/ballast-scheduler-core/api/android/ballast-scheduler-core.api new file mode 100644 index 00000000..0e89f3e6 --- /dev/null +++ b/ballast-scheduler-core/api/android/ballast-scheduler-core.api @@ -0,0 +1,241 @@ +public abstract interface class com/copperleaf/ballast/scheduler/NamedSchedule : com/copperleaf/ballast/scheduler/Schedule { + public abstract fun getName ()Ljava/lang/String; +} + +public abstract interface class com/copperleaf/ballast/scheduler/Schedule { + public abstract fun generateSchedule (Lkotlin/time/Instant;)Lkotlin/sequences/Sequence; +} + +public abstract interface class com/copperleaf/ballast/scheduler/ScheduleExecutor { + public abstract fun runSchedule (Lcom/copperleaf/ballast/scheduler/NamedSchedule;)Lkotlinx/coroutines/flow/Flow; + public abstract fun runSchedule (Lcom/copperleaf/ballast/scheduler/Schedule;)Lkotlinx/coroutines/flow/Flow; + public abstract fun runSchedules (Ljava/util/List;)Lkotlinx/coroutines/flow/Flow; +} + +public final class com/copperleaf/ballast/scheduler/ScheduleExecutor$CatchUpBehavior : java/lang/Enum { + public static final field ExecuteAll Lcom/copperleaf/ballast/scheduler/ScheduleExecutor$CatchUpBehavior; + public static final field ExecuteOne Lcom/copperleaf/ballast/scheduler/ScheduleExecutor$CatchUpBehavior; + public static final field Skip Lcom/copperleaf/ballast/scheduler/ScheduleExecutor$CatchUpBehavior; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/copperleaf/ballast/scheduler/ScheduleExecutor$CatchUpBehavior; + public static fun values ()[Lcom/copperleaf/ballast/scheduler/ScheduleExecutor$CatchUpBehavior; +} + +public abstract interface class com/copperleaf/ballast/scheduler/SchedulerCallback { + public abstract fun handleTask (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/copperleaf/ballast/scheduler/TriggeredTask { + public fun (Lkotlin/time/Instant;Ljava/lang/String;Lcom/copperleaf/ballast/scheduler/Schedule;)V + public final fun component1 ()Lkotlin/time/Instant; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Lcom/copperleaf/ballast/scheduler/Schedule; + public final fun copy (Lkotlin/time/Instant;Ljava/lang/String;Lcom/copperleaf/ballast/scheduler/Schedule;)Lcom/copperleaf/ballast/scheduler/TriggeredTask; + public static synthetic fun copy$default (Lcom/copperleaf/ballast/scheduler/TriggeredTask;Lkotlin/time/Instant;Ljava/lang/String;Lcom/copperleaf/ballast/scheduler/Schedule;ILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/TriggeredTask; + public fun equals (Ljava/lang/Object;)Z + public final fun getName ()Ljava/lang/String; + public final fun getSchedule ()Lcom/copperleaf/ballast/scheduler/Schedule; + public final fun getTriggeredAt ()Lkotlin/time/Instant; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/copperleaf/ballast/scheduler/executor/delay/DelayScheduleExecutor : com/copperleaf/ballast/scheduler/ScheduleExecutor { + public fun ()V + public fun (Lkotlin/time/Clock;Lkotlin/jvm/functions/Function1;)V + public synthetic fun (Lkotlin/time/Clock;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun runSchedule (Lcom/copperleaf/ballast/scheduler/NamedSchedule;)Lkotlinx/coroutines/flow/Flow; + public fun runSchedule (Lcom/copperleaf/ballast/scheduler/Schedule;)Lkotlinx/coroutines/flow/Flow; + public fun runSchedules (Ljava/util/List;)Lkotlinx/coroutines/flow/Flow; +} + +public final class com/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleData { + public static final field Companion Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleData$Companion; + public fun (Ljava/lang/String;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Lkotlin/time/Instant;Lkotlin/time/Instant;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Lkotlinx/serialization/json/JsonObject; + public final fun component4 ()Lkotlinx/serialization/json/JsonObject; + public final fun component5 ()Lkotlin/time/Instant; + public final fun component6 ()Lkotlin/time/Instant; + public final fun copy (Ljava/lang/String;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Lkotlin/time/Instant;Lkotlin/time/Instant;)Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleData; + public static synthetic fun copy$default (Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleData;Ljava/lang/String;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Lkotlin/time/Instant;Lkotlin/time/Instant;ILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleData; + public fun equals (Ljava/lang/Object;)Z + public final fun getCallbackJson ()Lkotlinx/serialization/json/JsonObject; + public final fun getConfiguration ()Ljava/lang/String; + public final fun getLastExecution ()Lkotlin/time/Instant; + public final fun getNextExecution ()Lkotlin/time/Instant; + public final fun getScheduleJson ()Lkotlinx/serialization/json/JsonObject; + public final fun getScheduleUniqueName ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final synthetic class com/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleData$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleData$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleData; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleData;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class com/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleData$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class com/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleExecutor { + public fun (Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleExecutor$Adapter;Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleExecutor$State;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/json/Json;Lkotlin/time/Clock;)V + public synthetic fun (Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleExecutor$Adapter;Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleExecutor$State;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/json/Json;Lkotlin/time/Clock;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun cancelSchedule (Lcom/copperleaf/ballast/scheduler/NamedSchedule;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun getJson ()Lkotlinx/serialization/json/Json; + public final fun handleTask (Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleData;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun registerOrUpdateSchedule (Lcom/copperleaf/ballast/scheduler/NamedSchedule;Lcom/copperleaf/ballast/scheduler/SchedulerCallback;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun registerOrUpdateSchedule$default (Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleExecutor;Lcom/copperleaf/ballast/scheduler/NamedSchedule;Lcom/copperleaf/ballast/scheduler/SchedulerCallback;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public final fun registerSchedule (Lcom/copperleaf/ballast/scheduler/NamedSchedule;Lcom/copperleaf/ballast/scheduler/SchedulerCallback;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun registerSchedule$default (Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleExecutor;Lcom/copperleaf/ballast/scheduler/NamedSchedule;Lcom/copperleaf/ballast/scheduler/SchedulerCallback;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public final fun synchronizeSchedules (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun updateSchedule (Lcom/copperleaf/ballast/scheduler/NamedSchedule;Lkotlin/time/Instant;Lkotlin/time/Instant;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class com/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleExecutor$Adapter { + public abstract fun cancelSchedule (Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleData;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun registerSchedule (Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleData;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun synchronizeSchedules (Lkotlin/sequences/Sequence;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun updateSchedule (Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleData;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class com/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleExecutor$State { + public abstract fun getAllSchedules (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun getState (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun removeScheduleData (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun storeScheduleData (Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleData;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/copperleaf/ballast/scheduler/executor/poll/InMemoryScheduleState : com/copperleaf/ballast/scheduler/executor/poll/PollingScheduleExecutor$State { + public fun ()V + public fun (Ljava/util/Map;)V + public synthetic fun (Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun getLastExecution (Ljava/lang/String;Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun getLastExecutions ()Lkotlinx/coroutines/flow/StateFlow; + public fun storeExecution (Ljava/lang/String;Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlin/time/Instant;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/copperleaf/ballast/scheduler/executor/poll/PollingScheduleExecutor : com/copperleaf/ballast/scheduler/ScheduleExecutor { + public fun (Lcom/copperleaf/ballast/scheduler/executor/poll/PollingScheduleExecutor$State;Lkotlin/time/Clock;Lkotlinx/datetime/TimeZone;Lcom/copperleaf/ballast/scheduler/Schedule;Lcom/copperleaf/ballast/scheduler/ScheduleExecutor$CatchUpBehavior;)V + public synthetic fun (Lcom/copperleaf/ballast/scheduler/executor/poll/PollingScheduleExecutor$State;Lkotlin/time/Clock;Lkotlinx/datetime/TimeZone;Lcom/copperleaf/ballast/scheduler/Schedule;Lcom/copperleaf/ballast/scheduler/ScheduleExecutor$CatchUpBehavior;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun runSchedule (Lcom/copperleaf/ballast/scheduler/NamedSchedule;)Lkotlinx/coroutines/flow/Flow; + public fun runSchedule (Lcom/copperleaf/ballast/scheduler/Schedule;)Lkotlinx/coroutines/flow/Flow; + public fun runSchedules (Ljava/util/List;)Lkotlinx/coroutines/flow/Flow; +} + +public abstract interface class com/copperleaf/ballast/scheduler/executor/poll/PollingScheduleExecutor$State { + public abstract fun getLastExecution (Ljava/lang/String;Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun storeExecution (Ljava/lang/String;Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlin/time/Instant;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/copperleaf/ballast/scheduler/operators/AdaptiveKt { + public static final fun adaptive (Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlin/time/Clock;)Lcom/copperleaf/ballast/scheduler/Schedule; + public static synthetic fun adaptive$default (Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlin/time/Clock;ILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/Schedule; +} + +public final class com/copperleaf/ballast/scheduler/operators/AlignKt { + public static final fun alignTo (Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlin/time/DurationUnit;Lkotlinx/datetime/TimeZone;)Lcom/copperleaf/ballast/scheduler/Schedule; + public static synthetic fun alignTo$default (Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlin/time/DurationUnit;Lkotlinx/datetime/TimeZone;ILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/Schedule; +} + +public final class com/copperleaf/ballast/scheduler/operators/BoundsKt { + public static final fun between (Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlin/ranges/ClosedRange;)Lcom/copperleaf/ballast/scheduler/Schedule; + public static final fun startingAt (Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlin/time/Instant;)Lcom/copperleaf/ballast/scheduler/Schedule; + public static final fun until (Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlin/time/Instant;)Lcom/copperleaf/ballast/scheduler/Schedule; +} + +public final class com/copperleaf/ballast/scheduler/operators/DelayKt { + public static final fun delayed-HG0u8IE (Lcom/copperleaf/ballast/scheduler/Schedule;J)Lcom/copperleaf/ballast/scheduler/Schedule; + public static final fun delayedUntil (Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlin/time/Instant;)Lcom/copperleaf/ballast/scheduler/Schedule; +} + +public final class com/copperleaf/ballast/scheduler/operators/FilterKt { + public static final fun filterByDayOfWeek (Lcom/copperleaf/ballast/scheduler/Schedule;[Lkotlinx/datetime/DayOfWeek;Lkotlinx/datetime/TimeZone;)Lcom/copperleaf/ballast/scheduler/Schedule; + public static synthetic fun filterByDayOfWeek$default (Lcom/copperleaf/ballast/scheduler/Schedule;[Lkotlinx/datetime/DayOfWeek;Lkotlinx/datetime/TimeZone;ILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/Schedule; + public static final fun weekdays (Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlinx/datetime/TimeZone;)Lcom/copperleaf/ballast/scheduler/Schedule; + public static synthetic fun weekdays$default (Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlinx/datetime/TimeZone;ILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/Schedule; + public static final fun weekends (Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlinx/datetime/TimeZone;)Lcom/copperleaf/ballast/scheduler/Schedule; + public static synthetic fun weekends$default (Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlinx/datetime/TimeZone;ILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/Schedule; +} + +public final class com/copperleaf/ballast/scheduler/operators/NamedKt { + public static final fun named (Lcom/copperleaf/ballast/scheduler/Schedule;Ljava/lang/String;)Lcom/copperleaf/ballast/scheduler/NamedSchedule; +} + +public final class com/copperleaf/ballast/scheduler/operators/QueryKt { + public static final fun dropHistory (Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlin/time/Instant;Lkotlin/time/Instant;)Lkotlin/sequences/Sequence; + public static final fun getHistory (Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlin/time/Instant;Lkotlin/time/Instant;)Lkotlin/sequences/Sequence; + public static final fun getNext (Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlin/time/Clock;)Lkotlin/time/Instant; + public static final fun getNext (Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlin/time/Instant;)Lkotlin/time/Instant; + public static synthetic fun getNext$default (Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlin/time/Clock;ILjava/lang/Object;)Lkotlin/time/Instant; + public static final fun take (Lcom/copperleaf/ballast/scheduler/Schedule;I)Lcom/copperleaf/ballast/scheduler/Schedule; +} + +public final class com/copperleaf/ballast/scheduler/operators/TransformKt { + public static final fun transformSchedule (Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlin/jvm/functions/Function1;)Lcom/copperleaf/ballast/scheduler/Schedule; + public static final fun transformScheduleStart (Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlin/jvm/functions/Function1;)Lcom/copperleaf/ballast/scheduler/Schedule; +} + +public final class com/copperleaf/ballast/scheduler/schedule/EveryDaySchedule : com/copperleaf/ballast/scheduler/Schedule { + public fun ()V + public fun (Ljava/util/List;Lkotlinx/datetime/TimeZone;)V + public synthetic fun (Ljava/util/List;Lkotlinx/datetime/TimeZone;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun ([Lkotlinx/datetime/LocalTime;Lkotlinx/datetime/TimeZone;)V + public synthetic fun ([Lkotlinx/datetime/LocalTime;Lkotlinx/datetime/TimeZone;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun generateSchedule (Lkotlin/time/Instant;)Lkotlin/sequences/Sequence; +} + +public final class com/copperleaf/ballast/scheduler/schedule/EveryHourSchedule : com/copperleaf/ballast/scheduler/Schedule { + public fun ()V + public fun (Ljava/util/List;Lkotlinx/datetime/TimeZone;)V + public synthetic fun (Ljava/util/List;Lkotlinx/datetime/TimeZone;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun ([ILkotlinx/datetime/TimeZone;)V + public synthetic fun ([ILkotlinx/datetime/TimeZone;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun generateSchedule (Lkotlin/time/Instant;)Lkotlin/sequences/Sequence; +} + +public final class com/copperleaf/ballast/scheduler/schedule/EveryMinuteSchedule : com/copperleaf/ballast/scheduler/Schedule { + public fun ()V + public fun (Ljava/util/List;Lkotlinx/datetime/TimeZone;)V + public synthetic fun (Ljava/util/List;Lkotlinx/datetime/TimeZone;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun ([ILkotlinx/datetime/TimeZone;)V + public synthetic fun ([ILkotlinx/datetime/TimeZone;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun generateSchedule (Lkotlin/time/Instant;)Lkotlin/sequences/Sequence; +} + +public final class com/copperleaf/ballast/scheduler/schedule/EverySecondSchedule : com/copperleaf/ballast/scheduler/Schedule { + public fun ()V + public fun (Lkotlinx/datetime/TimeZone;)V + public synthetic fun (Lkotlinx/datetime/TimeZone;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun generateSchedule (Lkotlin/time/Instant;)Lkotlin/sequences/Sequence; +} + +public final class com/copperleaf/ballast/scheduler/schedule/ExponentialDelaySchedule : com/copperleaf/ballast/scheduler/Schedule { + public synthetic fun (JDJILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (JDJLkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun generateSchedule (Lkotlin/time/Instant;)Lkotlin/sequences/Sequence; +} + +public final class com/copperleaf/ballast/scheduler/schedule/FixedDelaySchedule : com/copperleaf/ballast/scheduler/Schedule { + public synthetic fun (JLkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun generateSchedule (Lkotlin/time/Instant;)Lkotlin/sequences/Sequence; +} + +public final class com/copperleaf/ballast/scheduler/schedule/FixedInstantSchedule : com/copperleaf/ballast/scheduler/Schedule { + public fun (Ljava/util/List;)V + public fun ([Lkotlin/time/Instant;)V + public fun generateSchedule (Lkotlin/time/Instant;)Lkotlin/sequences/Sequence; +} + +public final class com/copperleaf/ballast/scheduler/utils/SchduleUtilsKt { + public static final fun generateSafeSchedule (Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlin/time/Instant;)Lkotlin/sequences/Sequence; +} + diff --git a/ballast-scheduler-core/api/jvm/ballast-scheduler-core.api b/ballast-scheduler-core/api/jvm/ballast-scheduler-core.api new file mode 100644 index 00000000..0e89f3e6 --- /dev/null +++ b/ballast-scheduler-core/api/jvm/ballast-scheduler-core.api @@ -0,0 +1,241 @@ +public abstract interface class com/copperleaf/ballast/scheduler/NamedSchedule : com/copperleaf/ballast/scheduler/Schedule { + public abstract fun getName ()Ljava/lang/String; +} + +public abstract interface class com/copperleaf/ballast/scheduler/Schedule { + public abstract fun generateSchedule (Lkotlin/time/Instant;)Lkotlin/sequences/Sequence; +} + +public abstract interface class com/copperleaf/ballast/scheduler/ScheduleExecutor { + public abstract fun runSchedule (Lcom/copperleaf/ballast/scheduler/NamedSchedule;)Lkotlinx/coroutines/flow/Flow; + public abstract fun runSchedule (Lcom/copperleaf/ballast/scheduler/Schedule;)Lkotlinx/coroutines/flow/Flow; + public abstract fun runSchedules (Ljava/util/List;)Lkotlinx/coroutines/flow/Flow; +} + +public final class com/copperleaf/ballast/scheduler/ScheduleExecutor$CatchUpBehavior : java/lang/Enum { + public static final field ExecuteAll Lcom/copperleaf/ballast/scheduler/ScheduleExecutor$CatchUpBehavior; + public static final field ExecuteOne Lcom/copperleaf/ballast/scheduler/ScheduleExecutor$CatchUpBehavior; + public static final field Skip Lcom/copperleaf/ballast/scheduler/ScheduleExecutor$CatchUpBehavior; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/copperleaf/ballast/scheduler/ScheduleExecutor$CatchUpBehavior; + public static fun values ()[Lcom/copperleaf/ballast/scheduler/ScheduleExecutor$CatchUpBehavior; +} + +public abstract interface class com/copperleaf/ballast/scheduler/SchedulerCallback { + public abstract fun handleTask (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/copperleaf/ballast/scheduler/TriggeredTask { + public fun (Lkotlin/time/Instant;Ljava/lang/String;Lcom/copperleaf/ballast/scheduler/Schedule;)V + public final fun component1 ()Lkotlin/time/Instant; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Lcom/copperleaf/ballast/scheduler/Schedule; + public final fun copy (Lkotlin/time/Instant;Ljava/lang/String;Lcom/copperleaf/ballast/scheduler/Schedule;)Lcom/copperleaf/ballast/scheduler/TriggeredTask; + public static synthetic fun copy$default (Lcom/copperleaf/ballast/scheduler/TriggeredTask;Lkotlin/time/Instant;Ljava/lang/String;Lcom/copperleaf/ballast/scheduler/Schedule;ILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/TriggeredTask; + public fun equals (Ljava/lang/Object;)Z + public final fun getName ()Ljava/lang/String; + public final fun getSchedule ()Lcom/copperleaf/ballast/scheduler/Schedule; + public final fun getTriggeredAt ()Lkotlin/time/Instant; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/copperleaf/ballast/scheduler/executor/delay/DelayScheduleExecutor : com/copperleaf/ballast/scheduler/ScheduleExecutor { + public fun ()V + public fun (Lkotlin/time/Clock;Lkotlin/jvm/functions/Function1;)V + public synthetic fun (Lkotlin/time/Clock;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun runSchedule (Lcom/copperleaf/ballast/scheduler/NamedSchedule;)Lkotlinx/coroutines/flow/Flow; + public fun runSchedule (Lcom/copperleaf/ballast/scheduler/Schedule;)Lkotlinx/coroutines/flow/Flow; + public fun runSchedules (Ljava/util/List;)Lkotlinx/coroutines/flow/Flow; +} + +public final class com/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleData { + public static final field Companion Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleData$Companion; + public fun (Ljava/lang/String;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Lkotlin/time/Instant;Lkotlin/time/Instant;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Lkotlinx/serialization/json/JsonObject; + public final fun component4 ()Lkotlinx/serialization/json/JsonObject; + public final fun component5 ()Lkotlin/time/Instant; + public final fun component6 ()Lkotlin/time/Instant; + public final fun copy (Ljava/lang/String;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Lkotlin/time/Instant;Lkotlin/time/Instant;)Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleData; + public static synthetic fun copy$default (Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleData;Ljava/lang/String;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Lkotlin/time/Instant;Lkotlin/time/Instant;ILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleData; + public fun equals (Ljava/lang/Object;)Z + public final fun getCallbackJson ()Lkotlinx/serialization/json/JsonObject; + public final fun getConfiguration ()Ljava/lang/String; + public final fun getLastExecution ()Lkotlin/time/Instant; + public final fun getNextExecution ()Lkotlin/time/Instant; + public final fun getScheduleJson ()Lkotlinx/serialization/json/JsonObject; + public final fun getScheduleUniqueName ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final synthetic class com/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleData$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleData$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleData; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleData;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class com/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleData$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class com/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleExecutor { + public fun (Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleExecutor$Adapter;Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleExecutor$State;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/json/Json;Lkotlin/time/Clock;)V + public synthetic fun (Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleExecutor$Adapter;Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleExecutor$State;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/json/Json;Lkotlin/time/Clock;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun cancelSchedule (Lcom/copperleaf/ballast/scheduler/NamedSchedule;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun getJson ()Lkotlinx/serialization/json/Json; + public final fun handleTask (Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleData;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun registerOrUpdateSchedule (Lcom/copperleaf/ballast/scheduler/NamedSchedule;Lcom/copperleaf/ballast/scheduler/SchedulerCallback;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun registerOrUpdateSchedule$default (Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleExecutor;Lcom/copperleaf/ballast/scheduler/NamedSchedule;Lcom/copperleaf/ballast/scheduler/SchedulerCallback;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public final fun registerSchedule (Lcom/copperleaf/ballast/scheduler/NamedSchedule;Lcom/copperleaf/ballast/scheduler/SchedulerCallback;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun registerSchedule$default (Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleExecutor;Lcom/copperleaf/ballast/scheduler/NamedSchedule;Lcom/copperleaf/ballast/scheduler/SchedulerCallback;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public final fun synchronizeSchedules (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun updateSchedule (Lcom/copperleaf/ballast/scheduler/NamedSchedule;Lkotlin/time/Instant;Lkotlin/time/Instant;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class com/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleExecutor$Adapter { + public abstract fun cancelSchedule (Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleData;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun registerSchedule (Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleData;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun synchronizeSchedules (Lkotlin/sequences/Sequence;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun updateSchedule (Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleData;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class com/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleExecutor$State { + public abstract fun getAllSchedules (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun getState (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun removeScheduleData (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun storeScheduleData (Lcom/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleData;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/copperleaf/ballast/scheduler/executor/poll/InMemoryScheduleState : com/copperleaf/ballast/scheduler/executor/poll/PollingScheduleExecutor$State { + public fun ()V + public fun (Ljava/util/Map;)V + public synthetic fun (Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun getLastExecution (Ljava/lang/String;Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun getLastExecutions ()Lkotlinx/coroutines/flow/StateFlow; + public fun storeExecution (Ljava/lang/String;Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlin/time/Instant;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/copperleaf/ballast/scheduler/executor/poll/PollingScheduleExecutor : com/copperleaf/ballast/scheduler/ScheduleExecutor { + public fun (Lcom/copperleaf/ballast/scheduler/executor/poll/PollingScheduleExecutor$State;Lkotlin/time/Clock;Lkotlinx/datetime/TimeZone;Lcom/copperleaf/ballast/scheduler/Schedule;Lcom/copperleaf/ballast/scheduler/ScheduleExecutor$CatchUpBehavior;)V + public synthetic fun (Lcom/copperleaf/ballast/scheduler/executor/poll/PollingScheduleExecutor$State;Lkotlin/time/Clock;Lkotlinx/datetime/TimeZone;Lcom/copperleaf/ballast/scheduler/Schedule;Lcom/copperleaf/ballast/scheduler/ScheduleExecutor$CatchUpBehavior;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun runSchedule (Lcom/copperleaf/ballast/scheduler/NamedSchedule;)Lkotlinx/coroutines/flow/Flow; + public fun runSchedule (Lcom/copperleaf/ballast/scheduler/Schedule;)Lkotlinx/coroutines/flow/Flow; + public fun runSchedules (Ljava/util/List;)Lkotlinx/coroutines/flow/Flow; +} + +public abstract interface class com/copperleaf/ballast/scheduler/executor/poll/PollingScheduleExecutor$State { + public abstract fun getLastExecution (Ljava/lang/String;Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun storeExecution (Ljava/lang/String;Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlin/time/Instant;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/copperleaf/ballast/scheduler/operators/AdaptiveKt { + public static final fun adaptive (Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlin/time/Clock;)Lcom/copperleaf/ballast/scheduler/Schedule; + public static synthetic fun adaptive$default (Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlin/time/Clock;ILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/Schedule; +} + +public final class com/copperleaf/ballast/scheduler/operators/AlignKt { + public static final fun alignTo (Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlin/time/DurationUnit;Lkotlinx/datetime/TimeZone;)Lcom/copperleaf/ballast/scheduler/Schedule; + public static synthetic fun alignTo$default (Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlin/time/DurationUnit;Lkotlinx/datetime/TimeZone;ILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/Schedule; +} + +public final class com/copperleaf/ballast/scheduler/operators/BoundsKt { + public static final fun between (Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlin/ranges/ClosedRange;)Lcom/copperleaf/ballast/scheduler/Schedule; + public static final fun startingAt (Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlin/time/Instant;)Lcom/copperleaf/ballast/scheduler/Schedule; + public static final fun until (Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlin/time/Instant;)Lcom/copperleaf/ballast/scheduler/Schedule; +} + +public final class com/copperleaf/ballast/scheduler/operators/DelayKt { + public static final fun delayed-HG0u8IE (Lcom/copperleaf/ballast/scheduler/Schedule;J)Lcom/copperleaf/ballast/scheduler/Schedule; + public static final fun delayedUntil (Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlin/time/Instant;)Lcom/copperleaf/ballast/scheduler/Schedule; +} + +public final class com/copperleaf/ballast/scheduler/operators/FilterKt { + public static final fun filterByDayOfWeek (Lcom/copperleaf/ballast/scheduler/Schedule;[Lkotlinx/datetime/DayOfWeek;Lkotlinx/datetime/TimeZone;)Lcom/copperleaf/ballast/scheduler/Schedule; + public static synthetic fun filterByDayOfWeek$default (Lcom/copperleaf/ballast/scheduler/Schedule;[Lkotlinx/datetime/DayOfWeek;Lkotlinx/datetime/TimeZone;ILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/Schedule; + public static final fun weekdays (Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlinx/datetime/TimeZone;)Lcom/copperleaf/ballast/scheduler/Schedule; + public static synthetic fun weekdays$default (Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlinx/datetime/TimeZone;ILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/Schedule; + public static final fun weekends (Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlinx/datetime/TimeZone;)Lcom/copperleaf/ballast/scheduler/Schedule; + public static synthetic fun weekends$default (Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlinx/datetime/TimeZone;ILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/Schedule; +} + +public final class com/copperleaf/ballast/scheduler/operators/NamedKt { + public static final fun named (Lcom/copperleaf/ballast/scheduler/Schedule;Ljava/lang/String;)Lcom/copperleaf/ballast/scheduler/NamedSchedule; +} + +public final class com/copperleaf/ballast/scheduler/operators/QueryKt { + public static final fun dropHistory (Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlin/time/Instant;Lkotlin/time/Instant;)Lkotlin/sequences/Sequence; + public static final fun getHistory (Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlin/time/Instant;Lkotlin/time/Instant;)Lkotlin/sequences/Sequence; + public static final fun getNext (Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlin/time/Clock;)Lkotlin/time/Instant; + public static final fun getNext (Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlin/time/Instant;)Lkotlin/time/Instant; + public static synthetic fun getNext$default (Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlin/time/Clock;ILjava/lang/Object;)Lkotlin/time/Instant; + public static final fun take (Lcom/copperleaf/ballast/scheduler/Schedule;I)Lcom/copperleaf/ballast/scheduler/Schedule; +} + +public final class com/copperleaf/ballast/scheduler/operators/TransformKt { + public static final fun transformSchedule (Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlin/jvm/functions/Function1;)Lcom/copperleaf/ballast/scheduler/Schedule; + public static final fun transformScheduleStart (Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlin/jvm/functions/Function1;)Lcom/copperleaf/ballast/scheduler/Schedule; +} + +public final class com/copperleaf/ballast/scheduler/schedule/EveryDaySchedule : com/copperleaf/ballast/scheduler/Schedule { + public fun ()V + public fun (Ljava/util/List;Lkotlinx/datetime/TimeZone;)V + public synthetic fun (Ljava/util/List;Lkotlinx/datetime/TimeZone;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun ([Lkotlinx/datetime/LocalTime;Lkotlinx/datetime/TimeZone;)V + public synthetic fun ([Lkotlinx/datetime/LocalTime;Lkotlinx/datetime/TimeZone;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun generateSchedule (Lkotlin/time/Instant;)Lkotlin/sequences/Sequence; +} + +public final class com/copperleaf/ballast/scheduler/schedule/EveryHourSchedule : com/copperleaf/ballast/scheduler/Schedule { + public fun ()V + public fun (Ljava/util/List;Lkotlinx/datetime/TimeZone;)V + public synthetic fun (Ljava/util/List;Lkotlinx/datetime/TimeZone;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun ([ILkotlinx/datetime/TimeZone;)V + public synthetic fun ([ILkotlinx/datetime/TimeZone;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun generateSchedule (Lkotlin/time/Instant;)Lkotlin/sequences/Sequence; +} + +public final class com/copperleaf/ballast/scheduler/schedule/EveryMinuteSchedule : com/copperleaf/ballast/scheduler/Schedule { + public fun ()V + public fun (Ljava/util/List;Lkotlinx/datetime/TimeZone;)V + public synthetic fun (Ljava/util/List;Lkotlinx/datetime/TimeZone;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun ([ILkotlinx/datetime/TimeZone;)V + public synthetic fun ([ILkotlinx/datetime/TimeZone;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun generateSchedule (Lkotlin/time/Instant;)Lkotlin/sequences/Sequence; +} + +public final class com/copperleaf/ballast/scheduler/schedule/EverySecondSchedule : com/copperleaf/ballast/scheduler/Schedule { + public fun ()V + public fun (Lkotlinx/datetime/TimeZone;)V + public synthetic fun (Lkotlinx/datetime/TimeZone;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun generateSchedule (Lkotlin/time/Instant;)Lkotlin/sequences/Sequence; +} + +public final class com/copperleaf/ballast/scheduler/schedule/ExponentialDelaySchedule : com/copperleaf/ballast/scheduler/Schedule { + public synthetic fun (JDJILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (JDJLkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun generateSchedule (Lkotlin/time/Instant;)Lkotlin/sequences/Sequence; +} + +public final class com/copperleaf/ballast/scheduler/schedule/FixedDelaySchedule : com/copperleaf/ballast/scheduler/Schedule { + public synthetic fun (JLkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun generateSchedule (Lkotlin/time/Instant;)Lkotlin/sequences/Sequence; +} + +public final class com/copperleaf/ballast/scheduler/schedule/FixedInstantSchedule : com/copperleaf/ballast/scheduler/Schedule { + public fun (Ljava/util/List;)V + public fun ([Lkotlin/time/Instant;)V + public fun generateSchedule (Lkotlin/time/Instant;)Lkotlin/sequences/Sequence; +} + +public final class com/copperleaf/ballast/scheduler/utils/SchduleUtilsKt { + public static final fun generateSafeSchedule (Lcom/copperleaf/ballast/scheduler/Schedule;Lkotlin/time/Instant;)Lkotlin/sequences/Sequence; +} + diff --git a/ballast-scheduler-core/build.gradle.kts b/ballast-scheduler-core/build.gradle.kts new file mode 100644 index 00000000..d9eeb006 --- /dev/null +++ b/ballast-scheduler-core/build.gradle.kts @@ -0,0 +1,50 @@ +plugins { + id("copper-leaf-base") + id("copper-leaf-android-library") + id("copper-leaf-targets") + id("copper-leaf-tests") + id("copper-leaf-lint") + id("copper-leaf-publish") + id("copper-leaf-serialization") +} + +kotlin { + compilerOptions { + optIn.add("kotlin.time.ExperimentalTime") + } + + sourceSets { + val commonMain by getting { + dependencies { + api(libs.kotlinx.coroutines.core) + api(libs.kotlinx.datetime) + } + } + val commonTest by getting { + dependencies { } + } + + val jvmMain by getting { + dependencies { } + } + val androidMain by getting { + dependencies { } + } + val jsMain by getting { + dependencies { } + } + val jsTest by getting { + dependencies { + implementation(npm("@js-joda/timezone", "2.22.0")) + } + } + val wasmJsTest by getting { + dependencies { + implementation(npm("@js-joda/timezone", "2.22.0")) + } + } + val iosMain by getting { + dependencies { } + } + } +} diff --git a/ballast-scheduler-core/gradle.properties b/ballast-scheduler-core/gradle.properties new file mode 100644 index 00000000..6560229a --- /dev/null +++ b/ballast-scheduler-core/gradle.properties @@ -0,0 +1,8 @@ +copperleaf.description=Send Inputs at regular, scheduled intervals. + +copperleaf.targets.android=true +copperleaf.targets.jvm=true +copperleaf.targets.ios=true +copperleaf.targets.js=true +copperleaf.targets.wasm.wasi=false +copperleaf.targets.wasm.js=true diff --git a/ballast-scheduler-core/src/androidMain/AndroidManifest.xml b/ballast-scheduler-core/src/androidMain/AndroidManifest.xml new file mode 100644 index 00000000..811d7660 --- /dev/null +++ b/ballast-scheduler-core/src/androidMain/AndroidManifest.xml @@ -0,0 +1,2 @@ + diff --git a/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/NamedSchedule.kt b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/NamedSchedule.kt new file mode 100644 index 00000000..982cd9d9 --- /dev/null +++ b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/NamedSchedule.kt @@ -0,0 +1,5 @@ +package com.copperleaf.ballast.scheduler + +public interface NamedSchedule : Schedule { + public val name: String +} diff --git a/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/Schedule.kt b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/Schedule.kt new file mode 100644 index 00000000..d177b467 --- /dev/null +++ b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/Schedule.kt @@ -0,0 +1,29 @@ +package com.copperleaf.ballast.scheduler + +import kotlin.time.Instant + +/** + * An interface for generating a non-persistent schedule of tasks. + * + * A Schedule produces an _ideal_ schedule, meaning it takes a starting [kotlin.time.Instant] and generates a Sequence of future + * Instants according to the schedule's algorithm. These are intended to be seen as the Instants which _should_ be + * executed, but in reality some of these Instants might be dropped for a variety of reasons. A Schedule Executor is + * responsible for "realizing" the generated schedule and sending callbacks at the appropriate time to the best of + * its ability. + * + * The sequence generated by a Schedule should not be affected by time. It only uses the `start` Instant as a reference + * point from which to calculate future tasks. There is also no expectation for how many tasks may be generated by the + * sequence. It may be infinite, limited to a certain number of events, or empty (if no valid future events can be + * calculated). Also, schedules should never include the starting Instant; it should generate Instants strictly in the + * future. + * + * The entire sequence is meant for consumption by non-persistent schedulers, meaning ones that run entirely in-process + * and do not attempt to preserve or restart work beyond app/device restarts. For persistent schedules, you should + * configure something like Android's WorkManager. + */ +public fun interface Schedule { + /** + * Generate a potentially-infinite sequence of schedule instants, starting at (but not including) the [start]. + */ + public fun generateSchedule(start: Instant): Sequence +} diff --git a/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/ScheduleExecutor.kt b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/ScheduleExecutor.kt new file mode 100644 index 00000000..65e2c8b5 --- /dev/null +++ b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/ScheduleExecutor.kt @@ -0,0 +1,52 @@ +package com.copperleaf.ballast.scheduler + +import kotlinx.coroutines.flow.Flow + +public interface ScheduleExecutor { + /** + * Executes a single [NamedSchedule], producing a [Flow] of [TriggeredTask] events indicating tasks to be + * completed. Tasks should be fully handled directly in the flow if you wish to apply backpressure to the schedule + * emissions, dropping emissions from the upstream schedule that would have been emitted while the previous task + * was still being handled. + * + * All [TriggeredTask] emitted by this Flow will have a `null` name, even if the [Schedule] is actually a [NamedSchedule]. + */ + public fun runSchedule(schedule: Schedule): Flow + + /** + * Executes a single [NamedSchedule], producing a [Flow] of [TriggeredTask] events indicating tasks to be + * completed. Tasks should be fully handled directly in the flow if you wish to apply backpressure to the schedule + * emissions, dropping emissions from the upstream schedule that would have been emitted while the previous task + * was still being handled. + */ + public fun runSchedule(schedule: NamedSchedule): Flow + + /** + * Executes multiple [NamedSchedule]s, producing a [Flow] of [TriggeredTask] events indicating tasks to be + * completed. Tasks from all schedules with be merged into one with the [kotlinx.coroutines.flow.merge] operator, + * which does not allow backpressure to be applied to the individual schedule's original upstream flow (since + * backpressure would block all schedules, not just the slow one). Therefore, it is best to use this executor to + * dispatch the scheduled tasks to another system that can handle backpressure, such as Ballast Queue. + */ + public fun runSchedules(schedules: List): Flow + + public enum class CatchUpBehavior { + /** + * Skip all missed tasks. The schedule state is updated to the current time, and no missed executions + * are triggered. + */ + Skip, + + /** + * Execute exactly one missed task (the earliest one), then update the schedule state to the current time. + * Any additional tasks that were missed beyond the first are dropped. + */ + ExecuteOne, + + /** + * Execute all missed tasks sequentially before resuming the normal schedule. Use with care if many + * tasks could have been missed during downtime, as this may cause a burst of work upon startup. + */ + ExecuteAll, + } +} diff --git a/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/SchedulerCallback.kt b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/SchedulerCallback.kt new file mode 100644 index 00000000..b130d21e --- /dev/null +++ b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/SchedulerCallback.kt @@ -0,0 +1,5 @@ +package com.copperleaf.ballast.scheduler + +public interface SchedulerCallback { + public suspend fun handleTask() +} diff --git a/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/TriggeredTask.kt b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/TriggeredTask.kt new file mode 100644 index 00000000..e89ce789 --- /dev/null +++ b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/TriggeredTask.kt @@ -0,0 +1,9 @@ +package com.copperleaf.ballast.scheduler + +import kotlin.time.Instant + +public data class TriggeredTask( + val triggeredAt: Instant, + val name: String?, + val schedule: Schedule, +) diff --git a/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/executor/delay/DelayScheduleExecutor.kt b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/executor/delay/DelayScheduleExecutor.kt new file mode 100644 index 00000000..81635ae7 --- /dev/null +++ b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/executor/delay/DelayScheduleExecutor.kt @@ -0,0 +1,66 @@ +package com.copperleaf.ballast.scheduler.executor.delay + +import com.copperleaf.ballast.scheduler.NamedSchedule +import com.copperleaf.ballast.scheduler.Schedule +import com.copperleaf.ballast.scheduler.ScheduleExecutor +import com.copperleaf.ballast.scheduler.TriggeredTask +import com.copperleaf.ballast.scheduler.utils.generateSafeSchedule +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.merge +import kotlin.time.Clock +import kotlin.time.Instant + +public class DelayScheduleExecutor( + private val clock: Clock = Clock.System, + private val onTaskDropped: (Instant) -> Unit = { }, +) : ScheduleExecutor { + + override fun runSchedule( + schedule: Schedule, + ): Flow { + return runSchedule(null, schedule) + } + + override fun runSchedule( + schedule: NamedSchedule, + ): Flow { + return runSchedule(schedule.name, schedule) + } + + override fun runSchedules(schedules: List): Flow { + return schedules + .map { runSchedule(it.name, it) } + .merge() + } + + private fun runSchedule( + scheduleName: String?, + schedule: Schedule, + ): Flow = flow { + schedule + .generateSafeSchedule(clock.now()) + .forEach { nextScheduleInstant -> + val currentInstant = clock.now() + + if (nextScheduleInstant >= currentInstant) { + // wait the appropriate amount of time until we hit the next scheduled instant + val currentInstant = clock.now() + val delayDuration = nextScheduleInstant - currentInstant + delay(delayDuration) + + emit( + TriggeredTask( + triggeredAt = nextScheduleInstant, + name = scheduleName, + schedule = schedule, + ) + ) + } else { + // report the scheduled task as having been dropped, so it can be logged or otherwise handled + onTaskDropped(nextScheduleInstant) + } + } + } +} diff --git a/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleData.kt b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleData.kt new file mode 100644 index 00000000..e306f5f3 --- /dev/null +++ b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleData.kt @@ -0,0 +1,15 @@ +package com.copperleaf.ballast.scheduler.executor.event + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject +import kotlin.time.Instant + +@Serializable +public data class EventDrivenScheduleData( + val configuration: String?, + val scheduleUniqueName: String, + val scheduleJson: JsonObject, + val callbackJson: JsonObject, + val lastExecution: Instant?, + val nextExecution: Instant, +) diff --git a/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleExecutor.kt b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleExecutor.kt new file mode 100644 index 00000000..1684406c --- /dev/null +++ b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/executor/event/EventDrivenScheduleExecutor.kt @@ -0,0 +1,161 @@ +package com.copperleaf.ballast.scheduler.executor.event + +import com.copperleaf.ballast.scheduler.NamedSchedule +import com.copperleaf.ballast.scheduler.SchedulerCallback +import com.copperleaf.ballast.scheduler.operators.getNext +import kotlinx.coroutines.CancellationException +import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlin.time.Clock +import kotlin.time.Instant + +public class EventDrivenScheduleExecutor( + private val adapter: EventDrivenScheduleExecutor.Adapter, + private val state: EventDrivenScheduleExecutor.State, + private val scheduleSerializer: KSerializer, + private val callbackSerializer: KSerializer, + public val json: Json = Json.Default, + private val clock: Clock = Clock.System, +) { + public suspend fun registerSchedule(schedule: S, callback: C, configuration: String? = null) { + val existingScheduleState = state.getState(schedule.name) + + if (existingScheduleState != null) { + error("Schedule ${schedule.name} already exists, cannot be created") + } + val next = schedule.getNext(clock.now()) ?: return + + val newScheduleState = EventDrivenScheduleData( + configuration = configuration, + scheduleUniqueName = schedule.name, + scheduleJson = json.encodeToJsonElement(scheduleSerializer, schedule) as JsonObject, + callbackJson = json.encodeToJsonElement(callbackSerializer, callback) as JsonObject, + lastExecution = null, + nextExecution = next + ) + + try { + adapter.registerSchedule(newScheduleState) + state.storeScheduleData(newScheduleState) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + e.printStackTrace() + } + } + + public suspend fun registerOrUpdateSchedule(schedule: S, callback: C, configuration: String? = null) { + val existingScheduleState = state.getState(schedule.name) + + val updatedScheduleState = if (existingScheduleState == null) { + val next = schedule.getNext(clock.now()) ?: return + + EventDrivenScheduleData( + configuration = configuration, + scheduleUniqueName = schedule.name, + scheduleJson = json.encodeToJsonElement(scheduleSerializer, schedule) as JsonObject, + callbackJson = json.encodeToJsonElement(callbackSerializer, callback) as JsonObject, + lastExecution = null, + nextExecution = next + ) + } else { + existingScheduleState + } + + try { + if (existingScheduleState == null) { + adapter.registerSchedule(updatedScheduleState) + } else { + adapter.updateSchedule(updatedScheduleState) + } + state.storeScheduleData(updatedScheduleState) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + e.printStackTrace() + } + } + + public suspend fun updateSchedule(schedule: S, lastExecution: Instant, next: Instant) { + val existingScheduleState = state.getState(schedule.name) + ?: error("Schedule ${schedule.name} doesn't exist, cannot be updated") + val updatedScheduleState = existingScheduleState.copy( + lastExecution = lastExecution, + nextExecution = next + ) + + try { + adapter.updateSchedule(updatedScheduleState) + state.storeScheduleData(updatedScheduleState) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + e.printStackTrace() + } + } + + public suspend fun cancelSchedule(schedule: S) { + val existingScheduleState = state.getState(schedule.name) + ?: error("Schedule ${schedule.name} doesn't exist, cannot be cancelled") + + try { + adapter.cancelSchedule(existingScheduleState) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + e.printStackTrace() + } finally { + state.removeScheduleData(schedule.name) + } + } + + public suspend fun synchronizeSchedules() { + adapter.synchronizeSchedules(state.getAllSchedules()) + } + + public suspend fun handleTask(data: EventDrivenScheduleData) { + val now = clock.now() + dispatchWork(data) + enqueueNextTask(now, data) + } + +// Helpers +// --------------------------------------------------------------------------------------------------------------------- + + private suspend fun dispatchWork(data: EventDrivenScheduleData) { + val callback = json.decodeFromJsonElement(callbackSerializer, data.callbackJson) + callback.handleTask() + } + + private suspend fun enqueueNextTask(now: Instant, data: EventDrivenScheduleData) { + val schedule = json.decodeFromJsonElement(scheduleSerializer, data.scheduleJson) + val next = schedule.getNext(now) + + if (next != null) { + updateSchedule(schedule, now, next) + } else { + cancelSchedule(schedule) + } + } + + public interface State { + public suspend fun getAllSchedules(): Sequence + + public suspend fun getState(scheduleUniqueName: String): EventDrivenScheduleData? + + public suspend fun storeScheduleData(data: EventDrivenScheduleData) + + public suspend fun removeScheduleData(scheduleUniqueName: String) + } + + public interface Adapter { + public suspend fun registerSchedule(data: EventDrivenScheduleData) + + public suspend fun updateSchedule(data: EventDrivenScheduleData) + + public suspend fun cancelSchedule(data: EventDrivenScheduleData) + + public suspend fun synchronizeSchedules(schedules: Sequence) + } +} diff --git a/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/executor/poll/InMemoryScheduleState.kt b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/executor/poll/InMemoryScheduleState.kt new file mode 100644 index 00000000..4b496779 --- /dev/null +++ b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/executor/poll/InMemoryScheduleState.kt @@ -0,0 +1,32 @@ +package com.copperleaf.ballast.scheduler.executor.poll + +import com.copperleaf.ballast.scheduler.Schedule +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlin.time.Instant + +public class InMemoryScheduleState( + initialState: Map = emptyMap() +) : PollingScheduleExecutor.State { + private val _lastExecutions = MutableStateFlow(initialState) + public val lastExecutions: StateFlow> get() = _lastExecutions.asStateFlow() + + override suspend fun getLastExecution( + scheduleName: String?, + schedule: Schedule, + ): Instant? { + return _lastExecutions.value[scheduleName] + } + + override suspend fun storeExecution( + scheduleName: String?, + schedule: Schedule, + instant: Instant + ) { + _lastExecutions.update { + it + (scheduleName to instant) + } + } +} diff --git a/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/executor/poll/PollingScheduleExecutor.kt b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/executor/poll/PollingScheduleExecutor.kt new file mode 100644 index 00000000..6c0ab340 --- /dev/null +++ b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/executor/poll/PollingScheduleExecutor.kt @@ -0,0 +1,191 @@ +package com.copperleaf.ballast.scheduler.executor.poll + +import com.copperleaf.ballast.scheduler.NamedSchedule +import com.copperleaf.ballast.scheduler.Schedule +import com.copperleaf.ballast.scheduler.ScheduleExecutor +import com.copperleaf.ballast.scheduler.TriggeredTask +import com.copperleaf.ballast.scheduler.operators.getNext +import com.copperleaf.ballast.scheduler.schedule.EveryMinuteSchedule +import com.copperleaf.ballast.scheduler.utils.generateSafeSchedule +import com.copperleaf.ballast.scheduler.utils.isBeforeMinute +import com.copperleaf.ballast.scheduler.utils.isSameOrBeforeMinute +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.flow +import kotlinx.datetime.TimeZone +import kotlin.time.Clock +import kotlin.time.Instant + +public class PollingScheduleExecutor( + private val scheduleState: PollingScheduleExecutor.State, + private val clock: Clock = Clock.System, + private val timeZone: TimeZone = TimeZone.Companion.UTC, + private val pollingSchedule: Schedule = EveryMinuteSchedule(0, timeZone = timeZone), + private val catchUpBehavior: ScheduleExecutor.CatchUpBehavior = ScheduleExecutor.CatchUpBehavior.ExecuteOne, +) : ScheduleExecutor { + + override fun runSchedule(schedule: Schedule): Flow = flow { + val pollingStartTime = clock.now() + + // emit any missed executions since we last ran this schedule, if needed + catchUpExecutions(pollingStartTime, null, schedule) + + // start polling for future executions every minute, and emit when the schedule matches + startPollingSchedule(pollingStartTime) { nextScheduleInstant -> + handleScheduledTaskIfReady( + pollingStartTime, + nextScheduleInstant, + null, + schedule, + ) + } + } + + override fun runSchedule(schedule: NamedSchedule): Flow = flow { + val pollingStartTime = clock.now() + + // emit any missed executions since we last ran this schedule, if needed + catchUpExecutions(pollingStartTime, schedule.name, schedule) + + // start polling for future executions every minute, and emit when the schedule matches + startPollingSchedule(pollingStartTime) { nextScheduleInstant -> + handleScheduledTaskIfReady( + pollingStartTime, + nextScheduleInstant, + schedule.name, + schedule, + ) + } + } + + override fun runSchedules(schedules: List): Flow = flow { + val pollingStartTime = clock.now() + + // emit any missed executions since we last ran this schedule, if needed. Each schedule is caught up individually + schedules.forEach { schedule -> + catchUpExecutions(pollingStartTime, schedule.name, schedule) + } + + // start polling for future executions every minute, and emit when the schedule matches. Each schedule is + // checked individually, but all values will be emitted downstream through the same Flow + startPollingSchedule(pollingStartTime) { nextScheduleInstant -> + schedules.forEach { schedule -> + handleScheduledTaskIfReady( + pollingStartTime, + nextScheduleInstant, + schedule.name, + schedule, + ) + } + } + } + + private suspend inline fun startPollingSchedule( + pollingStartTime: Instant, + onClockTick: (Instant) -> Unit, + ) { + pollingSchedule + .generateSafeSchedule(pollingStartTime) + .forEach { nextScheduleInstant -> + // wait the appropriate amount of time until we hit the next scheduled instant + val currentInstant = clock.now() + val delayDuration = nextScheduleInstant - currentInstant + delay(delayDuration) + onClockTick(nextScheduleInstant) + } + } + + private suspend fun FlowCollector.handleScheduledTaskIfReady( + pollingStartTime: Instant, + currentInstant: Instant, + scheduleName: String?, + schedule: Schedule, + ) { + // get the last execution time for this schedule. If the schedule has never been executed, consider the first + // moment this polling executor started running as the last execution time, so delay-based schedules do not drift + // but always calculate their next execution time from a stable moment in time. The next scheduled time will be + // calculated from this point. + val scheduleStartTime = (scheduleState.getLastExecution(scheduleName, schedule) ?: pollingStartTime) + + // get the next scheduled time for this schedule based on the last execution time, and coerce it to the next + // future minute + val nextScheduleInstant = schedule.getNext(scheduleStartTime) ?: return + + // if the next scheduled time matches the current time, store the execution time and emit it + if (nextScheduleInstant.isSameOrBeforeMinute(currentInstant, timeZone)) { + emit( + TriggeredTask( + triggeredAt = currentInstant, + name = scheduleName, + schedule = schedule, + ) + ) + scheduleState.storeExecution(scheduleName, schedule, currentInstant) + } + } + + private suspend fun FlowCollector.catchUpExecutions( + pollingStartTime: Instant, + scheduleName: String?, + schedule: Schedule, + ) { + val lastExecution = scheduleState.getLastExecution(scheduleName, schedule) + val scheduleStartTime = lastExecution ?: pollingStartTime + // get the next scheduled time for this schedule based on the last execution time, and coerce it to the next + // future minute + val nextScheduleInstant = schedule.getNext(scheduleStartTime) ?: return + + if (nextScheduleInstant.isBeforeMinute(pollingStartTime, timeZone)) { + // we have missed at least one scheduled execution + when (catchUpBehavior) { + ScheduleExecutor.CatchUpBehavior.Skip -> { + // do nothing, but store the latest execution time so the schedule does not try to catch up once + // we start polling. + scheduleState.storeExecution(scheduleName, schedule, pollingStartTime) + } + + ScheduleExecutor.CatchUpBehavior.ExecuteOne -> { + // emit one missed execution + emit( + TriggeredTask( + triggeredAt = pollingStartTime, + name = scheduleName, + schedule = schedule, + ) + ) + scheduleState.storeExecution(scheduleName, schedule, pollingStartTime) + } + + ScheduleExecutor.CatchUpBehavior.ExecuteAll -> { + // emit all missed executions + var missedScheduleInstant = nextScheduleInstant + while (missedScheduleInstant.isBeforeMinute(pollingStartTime, timeZone)) { + emit( + TriggeredTask( + triggeredAt = missedScheduleInstant, + name = scheduleName, + schedule = schedule, + ) + ) + scheduleState.storeExecution(scheduleName, schedule, missedScheduleInstant) + missedScheduleInstant = schedule.getNext(missedScheduleInstant) ?: break + } + } + } + } + } + + public interface State { + public suspend fun getLastExecution( + scheduleName: String?, + schedule: Schedule, + ): Instant? + + public suspend fun storeExecution( + scheduleName: String?, + schedule: Schedule, + instant: Instant, + ) + } +} diff --git a/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/operators/adaptive.kt b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/operators/adaptive.kt new file mode 100644 index 00000000..c7a99943 --- /dev/null +++ b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/operators/adaptive.kt @@ -0,0 +1,37 @@ +package com.copperleaf.ballast.scheduler.operators + +import com.copperleaf.ballast.scheduler.Schedule +import kotlin.time.Clock + +/** + * Transform a Schedule to be adaptive, meaning that it will adjust its timing based on the actual time taken to process + * each item. + * + * make the subsequent items delayed by the amount of time it takes to process them, rather + * than always generating a fixed interval. THis adapts the sequence such that there if a fixed amount of time between + * the end of one task and the start of another. + */ +public fun Schedule.adaptive(clock: Clock = Clock.System): Schedule { + return transformSchedule { scheduleSequence -> + sequence { + // return the first item as-is + val iterator = scheduleSequence.iterator() + var current = iterator.next() + yield(current) + + // for each subsequent item, calculate the time it took to `yield` the previous item, and delay by that + // amount. Don't filter or buffer any values from the original sequence, just adjust their timing. Either + // the upstream sequence should filter or returns values with a valid future time, or else the downstream + // executor is responsible for handling backpressure or dropping values to keep up. + while (iterator.hasNext()) { + val next = iterator.next() + val intendedDelay = current - next + val now = clock.now() + val actualDelayedInstant = now - intendedDelay + yield(actualDelayedInstant) + + current = next + } + } + } +} diff --git a/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/operators/align.kt b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/operators/align.kt new file mode 100644 index 00000000..e44166b4 --- /dev/null +++ b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/operators/align.kt @@ -0,0 +1,35 @@ +package com.copperleaf.ballast.scheduler.operators + +import com.copperleaf.ballast.scheduler.Schedule +import com.copperleaf.ballast.scheduler.utils.alignToNextDay +import com.copperleaf.ballast.scheduler.utils.alignToNextHour +import com.copperleaf.ballast.scheduler.utils.alignToNextMinute +import com.copperleaf.ballast.scheduler.utils.alignToNextSecond +import kotlinx.datetime.TimeZone +import kotlin.time.DurationUnit + +public fun Schedule.alignTo(unit: DurationUnit, timeZone: TimeZone = TimeZone.UTC): Schedule { + return transformSchedule { scheduleSequence -> + sequence { + // return the first item as-is + val iterator = scheduleSequence.iterator() + + // for each item, align it to the specified time unit boundary. Always ensure the resulting time is + // greater than or equal to the original time. + while (iterator.hasNext()) { + val next = iterator.next() + val alignedDateTime = when (unit) { + DurationUnit.SECONDS -> next.alignToNextSecond(timeZone) + DurationUnit.MINUTES -> next.alignToNextMinute(timeZone) + DurationUnit.HOURS -> next.alignToNextHour(timeZone) + DurationUnit.DAYS -> next.alignToNextDay(timeZone) + else -> { + error("Unsupported alignment unit: $unit") + } + } + + yield(alignedDateTime) + } + } + } +} diff --git a/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/operators/bounds.kt b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/operators/bounds.kt new file mode 100644 index 00000000..8e5c5950 --- /dev/null +++ b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/operators/bounds.kt @@ -0,0 +1,59 @@ +package com.copperleaf.ballast.scheduler.operators + +import com.copperleaf.ballast.scheduler.Schedule +import kotlin.time.Instant + +/** + * Only process scheduled tasks which are within the bounds (inclusive) of the [validRange]. Instants emitted before the + * start of the range will be ignored, and the first Instant emitted after the end of the range will terminate the + * sequence, making it finite. + */ +public fun Schedule.between(validRange: ClosedRange): Schedule { + check(!validRange.isEmpty()) { + "the valid range of dates cannot be empty" + } + + return transformSchedule { scheduleSequence -> + sequence { + val iterator = scheduleSequence.iterator() + + while (iterator.hasNext()) { + val next = iterator.next() + + when { + next < validRange.start -> { + // we haven't entered the start of the range, don't quit yet + continue + } + + next in validRange -> { + // we are withing the valid range, yield the values downstream + yield(next) + } + + next > validRange.endInclusive -> { + // we are past the end of the range, quit the loop + break + } + + else -> { + // not possible + break + } + } + } + } + } +} + +public fun Schedule.startingAt(startInclusive: Instant): Schedule { + return transformSchedule { scheduleSequence -> + scheduleSequence.takeWhile { it >= startInclusive } + } +} + +public fun Schedule.until(endInclusive: Instant): Schedule { + return transformSchedule { scheduleSequence -> + scheduleSequence.takeWhile { it <= endInclusive } + } +} diff --git a/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/operators/delay.kt b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/operators/delay.kt new file mode 100644 index 00000000..cab0ddc0 --- /dev/null +++ b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/operators/delay.kt @@ -0,0 +1,26 @@ +package com.copperleaf.ballast.scheduler.operators + +import com.copperleaf.ballast.scheduler.Schedule +import kotlin.time.Duration +import kotlin.time.Instant + +/** + * Delay the first emission of a Schedule by a fixed [delay]. + */ +public fun Schedule.delayed(delay: Duration): Schedule { + return transformScheduleStart { start -> + start + delay + } +} + +/** + * Delay the first emission of a Schedule until a specific [startInstant]. If the schedule was started with an Instant + * that is later than [startInstant], that later Instant will be used instead, since it is still after [startInstant]. + * + * TODO: is this different from `startingAt()`? + */ +public fun Schedule.delayedUntil(startInstant: Instant): Schedule { + return transformScheduleStart { start -> + maxOf(start, startInstant) + } +} diff --git a/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/operators/filter.kt b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/operators/filter.kt new file mode 100644 index 00000000..67c70b46 --- /dev/null +++ b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/operators/filter.kt @@ -0,0 +1,35 @@ +package com.copperleaf.ballast.scheduler.operators + +import com.copperleaf.ballast.scheduler.Schedule +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime + +public fun Schedule.filterByDayOfWeek(vararg daysOfWeek: DayOfWeek, timeZone: TimeZone = TimeZone.UTC): Schedule { + return transformSchedule { scheduleSequence -> + scheduleSequence + .filter { + val localDateTime = it.toLocalDateTime(timeZone) + localDateTime.dayOfWeek in daysOfWeek + } + } +} + +public fun Schedule.weekdays(timeZone: TimeZone = TimeZone.UTC): Schedule { + return filterByDayOfWeek( + DayOfWeek.MONDAY, + DayOfWeek.TUESDAY, + DayOfWeek.WEDNESDAY, + DayOfWeek.THURSDAY, + DayOfWeek.FRIDAY, + timeZone = timeZone, + ) +} + +public fun Schedule.weekends(timeZone: TimeZone = TimeZone.UTC): Schedule { + return filterByDayOfWeek( + DayOfWeek.SUNDAY, + DayOfWeek.SATURDAY, + timeZone = timeZone, + ) +} diff --git a/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/operators/named.kt b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/operators/named.kt new file mode 100644 index 00000000..f2d54ac9 --- /dev/null +++ b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/operators/named.kt @@ -0,0 +1,13 @@ +package com.copperleaf.ballast.scheduler.operators + +import com.copperleaf.ballast.scheduler.NamedSchedule +import com.copperleaf.ballast.scheduler.Schedule + +public fun Schedule.named(name: String): NamedSchedule { + return NamedScheduleImpl(name, this) +} + +private class NamedScheduleImpl( + override val name: String, + private val scheduleDelegate: Schedule, +) : NamedSchedule, Schedule by scheduleDelegate diff --git a/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/operators/query.kt b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/operators/query.kt new file mode 100644 index 00000000..b7f33535 --- /dev/null +++ b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/operators/query.kt @@ -0,0 +1,47 @@ +package com.copperleaf.ballast.scheduler.operators + +import com.copperleaf.ballast.scheduler.Schedule +import kotlin.time.Clock +import kotlin.time.Instant + +/** + * Transform the [Schedule] to only emit the next [n] values (or fewer if the upstream schedule terminates). + */ +public fun Schedule.take(n: Int): Schedule { + return transformSchedule { scheduleSequence -> + scheduleSequence.take(n) + } +} + +// Get values from a schedule +// --------------------------------------------------------------------------------------------------------------------- + +/** + * Using the provided [clock], get the schedule's nearest instant later than `clock.now()` + */ +public fun Schedule.getNext(clock: Clock = Clock.System): Instant? { + return this.getNext(clock.now()) +} + +/** + * Using a specified start Instant, get the schedule's nearest instant later than `clock.now()` + */ +public fun Schedule.getNext(instant: Instant): Instant? { + return this.generateSchedule(instant).firstOrNull() +} + +/** + * Using a specified start Instant, get the schedule's nearest instant later than `clock.now()` + */ +public fun Schedule.getHistory(startInstant: Instant, currentInstant: Instant): Sequence { + return this.generateSchedule(startInstant) + .takeWhile { it < currentInstant } +} + +/** + * Using a specified start Instant, get the schedule's nearest instant later than `clock.now()` + */ +public fun Schedule.dropHistory(startInstant: Instant, currentInstant: Instant): Sequence { + return this.generateSchedule(startInstant) + .filter { it > currentInstant } +} diff --git a/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/operators/transform.kt b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/operators/transform.kt new file mode 100644 index 00000000..f9ee0a93 --- /dev/null +++ b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/operators/transform.kt @@ -0,0 +1,21 @@ +package com.copperleaf.ballast.scheduler.operators + +import com.copperleaf.ballast.scheduler.Schedule +import kotlin.time.Instant + +public inline fun Schedule.transformSchedule(crossinline block: (Sequence) -> Sequence): Schedule { + val scheduleDelegate = this + return Schedule { start -> + scheduleDelegate + .generateSchedule(start) + .let(block) + } +} + +public inline fun Schedule.transformScheduleStart(crossinline block: (Instant) -> Instant): Schedule { + val scheduleDelegate = this + return Schedule { start -> + scheduleDelegate + .generateSchedule(block(start)) + } +} diff --git a/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/schedule/EveryDaySchedule.kt b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/schedule/EveryDaySchedule.kt new file mode 100644 index 00000000..568a5e35 --- /dev/null +++ b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/schedule/EveryDaySchedule.kt @@ -0,0 +1,69 @@ +package com.copperleaf.ballast.scheduler.schedule + +import com.copperleaf.ballast.scheduler.Schedule +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Duration.Companion.days +import kotlin.time.Instant + +public class EveryDaySchedule( + timesOfDay: List = listOf(LocalTime(0, 0, 0)), + private val timeZone: TimeZone = TimeZone.UTC, +) : Schedule { + + private val timesOfDay: List + + init { + check(timesOfDay.isNotEmpty()) { "timesOfDay cannot be empty" } + this.timesOfDay = timesOfDay.sorted() + } + + public constructor( + vararg timesOfDay: LocalTime, + timeZone: TimeZone = TimeZone.UTC, + ) : this(timesOfDay.toList(), timeZone) + + override fun generateSchedule(start: Instant): Sequence { + return sequence { + var nextInstant = start + while (true) { + nextInstant = nextInstant.getNextAvailableTime() + yield(nextInstant) + } + } + } + + private fun Instant.getNextAvailableTime(): Instant { + val currentInstantAsDateTime = this.toLocalDateTime(timeZone) + + val nextAvailableTime = timesOfDay + .firstOrNull { it > currentInstantAsDateTime.time } + + return if (nextAvailableTime != null) { + currentInstantAsDateTime + .atTime(nextAvailableTime) + .toInstant(timeZone) + } else { + this + .plus(1.days) + .toLocalDateTime(timeZone) + .atTime(timesOfDay.first()) + .toInstant(timeZone) + } + } + + private fun LocalDateTime.atTime(time: LocalTime): LocalDateTime { + return LocalDateTime( + year = this.year, + month = this.month, + day = this.day, + hour = time.hour, + minute = time.minute, + second = 0, + nanosecond = 0, + ) + } +} diff --git a/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/schedule/EveryHourSchedule.kt b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/schedule/EveryHourSchedule.kt new file mode 100644 index 00000000..ef97449f --- /dev/null +++ b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/schedule/EveryHourSchedule.kt @@ -0,0 +1,72 @@ +package com.copperleaf.ballast.scheduler.schedule + +import com.copperleaf.ballast.scheduler.Schedule +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Duration.Companion.hours +import kotlin.time.Instant + +public class EveryHourSchedule( + minutesOfHour: List = listOf(0), + private val timeZone: TimeZone = TimeZone.UTC, +) : Schedule { + + private val minutesOfHour: List + + init { + check(minutesOfHour.isNotEmpty()) { "minutesOfHour cannot be empty" } + check(minutesOfHour.all { it in 0..59 }) { + "all secondsOfMinute must be in range [0, 59]" + } + + this.minutesOfHour = minutesOfHour.sorted() + } + + public constructor( + vararg minutesOfHour: Int, + timeZone: TimeZone = TimeZone.UTC, + ) : this(minutesOfHour.toList(), timeZone) + + override fun generateSchedule(start: Instant): Sequence { + return sequence { + var nextInstant = start + while (true) { + nextInstant = nextInstant.getNextAvailableMinute() + yield(nextInstant) + } + } + } + + private fun Instant.getNextAvailableMinute(): Instant { + val currentInstantAsDateTime = this.toLocalDateTime(timeZone) + + val nextAvailableMinute = minutesOfHour + .firstOrNull { it > currentInstantAsDateTime.minute } + + return if (nextAvailableMinute != null) { + currentInstantAsDateTime + .atMinute(nextAvailableMinute) + .toInstant(timeZone) + } else { + this + .plus(1.hours) + .toLocalDateTime(timeZone) + .atMinute(minutesOfHour.first()) + .toInstant(timeZone) + } + } + + private fun LocalDateTime.atMinute(minute: Int): LocalDateTime { + return LocalDateTime( + year = this.year, + month = this.month, + day = this.day, + hour = this.hour, + minute = minute, + second = 0, + nanosecond = 0, + ) + } +} diff --git a/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/schedule/EveryMinuteSchedule.kt b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/schedule/EveryMinuteSchedule.kt new file mode 100644 index 00000000..108d9975 --- /dev/null +++ b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/schedule/EveryMinuteSchedule.kt @@ -0,0 +1,72 @@ +package com.copperleaf.ballast.scheduler.schedule + +import com.copperleaf.ballast.scheduler.Schedule +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Instant + +public class EveryMinuteSchedule( + secondsOfMinute: List = listOf(0), + private val timeZone: TimeZone = TimeZone.UTC, +) : Schedule { + + private val secondsOfMinute: List + + init { + check(secondsOfMinute.isNotEmpty()) { "secondsOfMinute cannot be empty" } + check(secondsOfMinute.all { it in 0..59 }) { + "all secondsOfMinute must be in range [0, 59]" + } + + this.secondsOfMinute = secondsOfMinute.sorted() + } + + public constructor( + vararg secondsOfMinute: Int, + timeZone: TimeZone = TimeZone.UTC, + ) : this(secondsOfMinute.toList(), timeZone) + + override fun generateSchedule(start: Instant): Sequence { + return sequence { + var nextInstant = start + while (true) { + nextInstant = nextInstant.getNextAvailableSecond() + yield(nextInstant) + } + } + } + + private fun Instant.getNextAvailableSecond(): Instant { + val currentInstantAsDateTime = this.toLocalDateTime(timeZone) + + val nextAvailableSecond = secondsOfMinute + .firstOrNull { it > currentInstantAsDateTime.second } + + return if (nextAvailableSecond != null) { + currentInstantAsDateTime + .atSecond(nextAvailableSecond) + .toInstant(timeZone) + } else { + this + .plus(1.minutes) + .toLocalDateTime(timeZone) + .atSecond(secondsOfMinute.first()) + .toInstant(timeZone) + } + } + + private fun LocalDateTime.atSecond(second: Int): LocalDateTime { + return LocalDateTime( + year = this.year, + month = this.month, + day = this.day, + hour = this.hour, + minute = this.minute, + second = second, + nanosecond = 0, + ) + } +} diff --git a/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/schedule/EverySecondSchedule.kt b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/schedule/EverySecondSchedule.kt new file mode 100644 index 00000000..33079474 --- /dev/null +++ b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/schedule/EverySecondSchedule.kt @@ -0,0 +1,44 @@ +package com.copperleaf.ballast.scheduler.schedule + +import com.copperleaf.ballast.scheduler.Schedule +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant + +public class EverySecondSchedule( + private val timeZone: TimeZone = TimeZone.UTC, +) : Schedule { + + override fun generateSchedule(start: Instant): Sequence { + return sequence { + var nextInstant = start + while (true) { + nextInstant = nextInstant.getNextAvailableSecond() + yield(nextInstant) + } + } + } + + private fun Instant.getNextAvailableSecond(): Instant { + return this + .toLocalDateTime(timeZone) + .nanosecond0() + .toInstant(timeZone) + .plus(1.seconds) + } + + private fun LocalDateTime.nanosecond0(): LocalDateTime { + return LocalDateTime( + year = this.year, + month = this.month, + day = this.day, + hour = this.hour, + minute = this.minute, + second = this.second, + nanosecond = 0, + ) + } +} diff --git a/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/schedule/ExponentialDelaySchedule.kt b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/schedule/ExponentialDelaySchedule.kt new file mode 100644 index 00000000..aa70f519 --- /dev/null +++ b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/schedule/ExponentialDelaySchedule.kt @@ -0,0 +1,45 @@ +package com.copperleaf.ballast.scheduler.schedule + +import com.copperleaf.ballast.scheduler.Schedule +import kotlin.math.pow +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Instant + +/** + * An exponential delay schedule will return a perfect schedule that delays a specific amount of time between tasks. + * Each subsequent task will be delayed by [period] * [exponential]^n, where n is the number of times the task has been + * scheduled so far. The delay will not exceed [maxDelay]. + * + * Note that this schedule does not carry any state about how many times it has been invoked, so the exponential delay + * is only compounded when iterating through the sequence returned by [generateSchedule]. Subsequent calls to + * `generateSchedule` will always start the delay back at [period]. + */ +public class ExponentialDelaySchedule( + private val period: Duration, + private val exponential: Double, + private val maxDelay: Duration = period * 5.0.pow(exponential), +) : Schedule { + + init { + check(period >= 1.milliseconds) { + "Minimum period of delay is 1ms" + } + check(exponential > 1.0) { + "exponential factor must be greater than 1.0" + } + } + + override fun generateSchedule(start: Instant): Sequence { + return sequence { + var nextInstant = start + var currentDelay = period + + while (true) { + nextInstant += currentDelay + currentDelay = minOf(currentDelay * exponential, maxDelay) + yield(nextInstant) + } + } + } +} diff --git a/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/schedule/FixedDelaySchedule.kt b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/schedule/FixedDelaySchedule.kt new file mode 100644 index 00000000..92e66333 --- /dev/null +++ b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/schedule/FixedDelaySchedule.kt @@ -0,0 +1,35 @@ +package com.copperleaf.ballast.scheduler.schedule + +import com.copperleaf.ballast.scheduler.Schedule +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Instant + +/** + * A fixed delay schedule will return a perfect schedule that delays a specific amount of time between tasks. By + * default, the delay does not consider how long it takes to process each task, and they may be dropped if the + * processing time is longer than the schedule period. + * + * Use the [com.copperleaf.ballast.scheduler.operators.adaptive] schedule operator to make the schedule adapt to the processing time of an item, so that the + * specified amount of time is delayed between the end of processing one task and the next time it begins. + */ +public class FixedDelaySchedule( + private val period: Duration +) : Schedule { + + init { + check(period >= 1.milliseconds) { + "Minimum period of delay is 1ms" + } + } + + override fun generateSchedule(start: Instant): Sequence { + return sequence { + var nextInstant = start + while (true) { + nextInstant += period + yield(nextInstant) + } + } + } +} diff --git a/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/schedule/FixedInstantSchedule.kt b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/schedule/FixedInstantSchedule.kt new file mode 100644 index 00000000..b3ce673e --- /dev/null +++ b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/schedule/FixedInstantSchedule.kt @@ -0,0 +1,37 @@ +package com.copperleaf.ballast.scheduler.schedule + +import com.copperleaf.ballast.scheduler.Schedule +import kotlin.time.Instant + +/** + * A schedule which sends a specific sequence of [instants], rather than computing them. At each emission, the nearest + * future Instant to the provided [clock] will be sent. When no such Instant exists, the schedule will complete. + */ +public class FixedInstantSchedule( + instants: List, +) : Schedule { + + private val instants: List + + init { + check(instants.isNotEmpty()) { "instants cannot be empty" } + this.instants = instants.sorted() + } + + public constructor( + vararg instants: Instant, + ) : this(instants.toList()) + + override fun generateSchedule(start: Instant): Sequence { + return sequence { + val remainingInstants = instants + .dropWhile { it <= start } + .toMutableList() + + while (true) { + val nextInstant = remainingInstants.removeFirstOrNull() ?: return@sequence + yield(nextInstant) + } + } + } +} diff --git a/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/utils/dateTimeUtils.kt b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/utils/dateTimeUtils.kt new file mode 100644 index 00000000..15d6943c --- /dev/null +++ b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/utils/dateTimeUtils.kt @@ -0,0 +1,105 @@ +package com.copperleaf.ballast.scheduler.utils + +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant + +internal fun Instant.isSameOrBeforeMinute(other: Instant, timeZone: TimeZone): Boolean { + val a = this.alignToNextMinute(timeZone) + val b = other.alignToNextMinute(timeZone) + return a <= b +} + +internal fun Instant.isSameMinute(other: Instant, timeZone: TimeZone): Boolean { + val a = this.alignToNextMinute(timeZone) + val b = other.alignToNextMinute(timeZone) + return a == b +} + +internal fun Instant.isBeforeMinute(other: Instant, timeZone: TimeZone): Boolean { + val a = this.alignToNextMinute(timeZone) + val b = other.alignToNextMinute(timeZone) + return a < b +} + +internal fun Instant.alignToNextSecond(timeZone: TimeZone): Instant { + val alignedDateTime = this.toLocalDateTime(timeZone) + val aligned = LocalDateTime( + year = alignedDateTime.year, + month = alignedDateTime.month, + day = alignedDateTime.day, + hour = alignedDateTime.hour, + minute = alignedDateTime.minute, + second = alignedDateTime.second, + nanosecond = 0, + ) + val alignedInstant = aligned.toInstant(timeZone) + return if (alignedInstant >= this) { + alignedInstant + } else { + alignedInstant.plus(1.seconds) + } +} + +internal fun Instant.alignToNextMinute(timeZone: TimeZone): Instant { + val alignedDateTime = this.toLocalDateTime(timeZone) + val aligned = LocalDateTime( + year = alignedDateTime.year, + month = alignedDateTime.month, + day = alignedDateTime.day, + hour = alignedDateTime.hour, + minute = alignedDateTime.minute, + second = 0, + nanosecond = 0, + ) + val alignedInstant = aligned.toInstant(timeZone) + return if (alignedInstant >= this) { + alignedInstant + } else { + alignedInstant.plus(1.minutes) + } +} + +internal fun Instant.alignToNextHour(timeZone: TimeZone): Instant { + val alignedDateTime = this.toLocalDateTime(timeZone) + val aligned = LocalDateTime( + year = alignedDateTime.year, + month = alignedDateTime.month, + day = alignedDateTime.day, + hour = alignedDateTime.hour, + minute = 0, + second = 0, + nanosecond = 0, + ) + val alignedInstant = aligned.toInstant(timeZone) + return if (alignedInstant >= this) { + alignedInstant + } else { + alignedInstant.plus(1.hours) + } +} + +internal fun Instant.alignToNextDay(timeZone: TimeZone): Instant { + val alignedDateTime = this.toLocalDateTime(timeZone) + val aligned = LocalDateTime( + year = alignedDateTime.year, + month = alignedDateTime.month, + day = alignedDateTime.day, + hour = 0, + minute = 0, + second = 0, + nanosecond = 0, + ) + val alignedInstant = aligned.toInstant(timeZone) + return if (alignedInstant >= this) { + alignedInstant + } else { + alignedInstant.plus(1.days) + } +} diff --git a/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/utils/schduleUtils.kt b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/utils/schduleUtils.kt new file mode 100644 index 00000000..dc087b6b --- /dev/null +++ b/ballast-scheduler-core/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/utils/schduleUtils.kt @@ -0,0 +1,37 @@ +package com.copperleaf.ballast.scheduler.utils + +import com.copperleaf.ballast.scheduler.Schedule +import kotlin.time.Instant + +/** + * Generates a schedule starting from [start], ensuring that the first generated time is always strictly after [start], + * and that vales are always monotonically increasing and never repeated.. + */ +public fun Schedule.generateSafeSchedule(start: Instant): Sequence { + val scheduleDelegate = this + return sequence { + var latestEmission: Instant? = null + + scheduleDelegate + .generateSchedule(start) + .forEach { next -> + if (latestEmission == null) { + // first emission, ensure it's strictly after start + if (next > start) { + latestEmission = next + yield(next) + } else { + error("Schedule $scheduleDelegate generated a first emission ($next) that is not strictly after the schedule start time ($start)") + } + } else { + // subsequent emissions, ensure they're strictly after the last one + if (next > latestEmission) { + latestEmission = next + yield(next) + } else { + error("Schedule $scheduleDelegate generated a non-monotonic emission ($next) after previous emission ($latestEmission)") + } + } + } + } +} diff --git a/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/ExactTimeClock.kt b/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/ExactTimeClock.kt new file mode 100644 index 00000000..6dfb3a29 --- /dev/null +++ b/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/ExactTimeClock.kt @@ -0,0 +1,18 @@ +package com.copperleaf.ballast.scheduler + +import kotlin.time.Clock +import kotlin.time.Instant + +class ExactTimeClock( + vararg instants: Instant, +) : Clock { + private val instantSequence = instants.sorted().toMutableList() + + override fun now(): Instant { + return runCatching { + val next = instantSequence.first() + instantSequence.removeAt(0) + next + }.getOrElse { Instant.DISTANT_FUTURE } + } +} diff --git a/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/TestClock.kt b/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/TestClock.kt new file mode 100644 index 00000000..273d78cc --- /dev/null +++ b/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/TestClock.kt @@ -0,0 +1,24 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.copperleaf.ballast.scheduler + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.currentTime +import kotlin.time.Clock +import kotlin.time.Instant + +private class TestScopeClock(private val testScope: TestScope) : Clock { + override fun now(): Instant { + return Instant.fromEpochMilliseconds(testScope.currentTime) + } +} + +fun TestScope.TestClock(startInstant: Instant? = null): Clock { + val clock = TestScopeClock(this) + startInstant?.let { + advanceTimeBy(startInstant.toEpochMilliseconds()) + } + return clock +} diff --git a/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/UnsafeFixedInstantSchedule.kt b/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/UnsafeFixedInstantSchedule.kt new file mode 100644 index 00000000..bbe9d42d --- /dev/null +++ b/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/UnsafeFixedInstantSchedule.kt @@ -0,0 +1,23 @@ +package com.copperleaf.ballast.scheduler + +import kotlin.time.Instant + +public class UnsafeFixedInstantSchedule( + private val instants: List, +) : Schedule { + + public constructor( + vararg instants: Instant, + ) : this(instants.toList()) + + override fun generateSchedule(start: Instant): Sequence { + return sequence { + val remainingInstants = instants.toMutableList() + + while (true) { + val nextInstant = remainingInstants.removeFirstOrNull() ?: return@sequence + yield(nextInstant) + } + } + } +} diff --git a/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/docs/DocsSnippets.kt b/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/docs/DocsSnippets.kt new file mode 100644 index 00000000..294d3c43 --- /dev/null +++ b/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/docs/DocsSnippets.kt @@ -0,0 +1,39 @@ +package com.copperleaf.ballast.scheduler.docs + +import com.copperleaf.ballast.scheduler.executor.delay.DelayScheduleExecutor +import com.copperleaf.ballast.scheduler.operators.named +import com.copperleaf.ballast.scheduler.schedule.EveryMinuteSchedule +import com.copperleaf.ballast.scheduler.schedule.EverySecondSchedule +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +class DocsSnippets { + + val viewModelScope: CoroutineScope = TODO() + + fun snippet1() { + val schedule = EveryMinuteSchedule() + val executor = DelayScheduleExecutor() + + executor + .runSchedule(schedule) + .onEach { + println("Executing scheduled task at ${it.triggeredAt}") + } + .launchIn(viewModelScope) + } + + fun snippet2() { + val schedule1 = EveryMinuteSchedule().named("EveryMinuteSchedule") + val schedule2 = EverySecondSchedule().named("EverySecondSchedule") + val executor = DelayScheduleExecutor() + + executor + .runSchedules(listOf(schedule1, schedule2)) + .onEach { + println("Executing scheduled task from ${it.name} at ${it.triggeredAt}") + } + .launchIn(viewModelScope) + } +} diff --git a/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/executor/DelayScheduleExecutorTest.kt b/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/executor/DelayScheduleExecutorTest.kt new file mode 100644 index 00000000..f25c2d15 --- /dev/null +++ b/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/executor/DelayScheduleExecutorTest.kt @@ -0,0 +1,97 @@ +package com.copperleaf.ballast.scheduler.executor + +import com.copperleaf.ballast.scheduler.TestClock +import com.copperleaf.ballast.scheduler.executor.delay.DelayScheduleExecutor +import com.copperleaf.ballast.scheduler.firstTen +import com.copperleaf.ballast.scheduler.operators.named +import com.copperleaf.ballast.scheduler.operators.until +import com.copperleaf.ballast.scheduler.schedule.EveryMinuteSchedule +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atTime +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Instant + +@OptIn(ExperimentalCoroutinesApi::class) +public class DelayScheduleExecutorTest { + + val timeZone = TimeZone.UTC + val startDay = LocalDate(2023, Month.DECEMBER, 28) + val startInstant = startDay.atTime(2, 37, 0).toInstant(timeZone) + val schedule = EveryMinuteSchedule(12) + .until(startInstant.plus(10.minutes)) + .named("EveryMinuteAt12Seconds") + + @Test + fun fastCollector() = runTest { + advanceTimeBy(startInstant.toEpochMilliseconds()) + + val missedTasks = mutableListOf() + val executor = DelayScheduleExecutor(TestClock(), onTaskDropped = { missedTasks += it }) + + assertEquals( + actual = executor + .runSchedule(schedule) + .firstTen(), + expected = listOf( + startDay.atTime(2, 37, 12), + startDay.atTime(2, 38, 12), + startDay.atTime(2, 39, 12), + startDay.atTime(2, 40, 12), + startDay.atTime(2, 41, 12), + startDay.atTime(2, 42, 12), + startDay.atTime(2, 43, 12), + startDay.atTime(2, 44, 12), + startDay.atTime(2, 45, 12), + startDay.atTime(2, 46, 12), + ), + ) + assertEquals( + actual = missedTasks, + expected = emptyList(), + ) + } + + @Test + fun slowCollector() = runTest { + advanceTimeBy(startInstant.toEpochMilliseconds()) + + val missedTasks = mutableListOf() + val executor = DelayScheduleExecutor(TestClock(), onTaskDropped = { missedTasks += it }) + + assertEquals( + actual = executor + .runSchedule(schedule) + .onEach { delay(5.minutes) } + .firstTen(), + expected = listOf( + startDay.atTime(2, 37, 12), + startDay.atTime(2, 42, 12), + ), + ) + assertEquals( + actual = missedTasks + .map { it.toLocalDateTime(timeZone) }, + expected = listOf( + startDay.atTime(2, 38, 12), + startDay.atTime(2, 39, 12), + startDay.atTime(2, 40, 12), + startDay.atTime(2, 41, 12), + startDay.atTime(2, 43, 12), + startDay.atTime(2, 44, 12), + startDay.atTime(2, 45, 12), + startDay.atTime(2, 46, 12), + ), + ) + } +} diff --git a/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/executor/PollingScheduleExecutorTest.kt b/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/executor/PollingScheduleExecutorTest.kt new file mode 100644 index 00000000..c03770d7 --- /dev/null +++ b/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/executor/PollingScheduleExecutorTest.kt @@ -0,0 +1,170 @@ +package com.copperleaf.ballast.scheduler.executor + +import com.copperleaf.ballast.scheduler.ScheduleExecutor +import com.copperleaf.ballast.scheduler.TestClock +import com.copperleaf.ballast.scheduler.executor.poll.InMemoryScheduleState +import com.copperleaf.ballast.scheduler.executor.poll.PollingScheduleExecutor +import com.copperleaf.ballast.scheduler.firstTen +import com.copperleaf.ballast.scheduler.firstTenWithNames +import com.copperleaf.ballast.scheduler.operators.named +import com.copperleaf.ballast.scheduler.operators.until +import com.copperleaf.ballast.scheduler.schedule.EveryHourSchedule +import com.copperleaf.ballast.scheduler.schedule.EveryMinuteSchedule +import com.copperleaf.ballast.scheduler.schedule.FixedDelaySchedule +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atTime +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes + +@OptIn(ExperimentalCoroutinesApi::class) +public class PollingScheduleExecutorTest { + + val timeZone = TimeZone.UTC + val startDay = LocalDate(2023, Month.DECEMBER, 28) + val startInstant = startDay.atTime(2, 37, 0).toInstant(timeZone) + + val schedule1 = EveryMinuteSchedule(12) + .until(startInstant.plus(10.minutes)) + .named("EveryMinuteAt12Seconds") + val schedule2 = FixedDelaySchedule(3.minutes) + .until(startInstant.plus(10.minutes)) + .named("Every3Minutes") + + val pollingSchedule = EveryMinuteSchedule(0, timeZone = timeZone) + .until(startInstant.plus(10.minutes)) + + @Test + fun fastCollector() = runTest { + advanceTimeBy(startInstant.toEpochMilliseconds()) + + val executor = PollingScheduleExecutor( + scheduleState = InMemoryScheduleState(), + clock = TestClock(), + timeZone = timeZone, + pollingSchedule = pollingSchedule, + ) + + assertEquals( + actual = executor + .runSchedules(listOf(schedule1, schedule2)) + .firstTenWithNames(), + expected = listOf( + "EveryMinuteAt12Seconds" to startDay.atTime(2, 38, 0), + "EveryMinuteAt12Seconds" to startDay.atTime(2, 39, 0), + "EveryMinuteAt12Seconds" to startDay.atTime(2, 40, 0), + "Every3Minutes" to startDay.atTime(2, 40, 0), + "EveryMinuteAt12Seconds" to startDay.atTime(2, 41, 0), + "EveryMinuteAt12Seconds" to startDay.atTime(2, 42, 0), + "EveryMinuteAt12Seconds" to startDay.atTime(2, 43, 0), + "Every3Minutes" to startDay.atTime(2, 43, 0), + "EveryMinuteAt12Seconds" to startDay.atTime(2, 44, 0), + "EveryMinuteAt12Seconds" to startDay.atTime(2, 45, 0), + ), + ) + } + + @Test + fun testCatchUpBehavior_Skip() = runTest { + advanceTimeBy(startInstant.toEpochMilliseconds()) + + val executor = PollingScheduleExecutor( + scheduleState = InMemoryScheduleState(mapOf("EveryHour" to startInstant.minus(4.hours))), + clock = TestClock(), + timeZone = timeZone, + pollingSchedule = EveryMinuteSchedule(0, timeZone = timeZone) + .until(startInstant.plus(12.hours)), + catchUpBehavior = ScheduleExecutor.CatchUpBehavior.Skip + ) + + assertEquals( + actual = executor + .runSchedule(EveryHourSchedule(0).named("EveryHour")) + .firstTen(), + expected = listOf( + LocalDate(2023, Month.DECEMBER, 28).atTime(3, 0, 0), + LocalDate(2023, Month.DECEMBER, 28).atTime(4, 0, 0), + LocalDate(2023, Month.DECEMBER, 28).atTime(5, 0, 0), + LocalDate(2023, Month.DECEMBER, 28).atTime(6, 0, 0), + LocalDate(2023, Month.DECEMBER, 28).atTime(7, 0, 0), + LocalDate(2023, Month.DECEMBER, 28).atTime(8, 0, 0), + LocalDate(2023, Month.DECEMBER, 28).atTime(9, 0, 0), + LocalDate(2023, Month.DECEMBER, 28).atTime(10, 0, 0), + LocalDate(2023, Month.DECEMBER, 28).atTime(11, 0, 0), + LocalDate(2023, Month.DECEMBER, 28).atTime(12, 0, 0), + ), + ) + } + + @Test + fun testCatchUpBehavior_ExecuteOne() = runTest { + advanceTimeBy(startInstant.toEpochMilliseconds()) + + val executor = PollingScheduleExecutor( + scheduleState = InMemoryScheduleState(mapOf("EveryHour" to startInstant.minus(4.hours))), + clock = TestClock(), + timeZone = timeZone, + pollingSchedule = EveryMinuteSchedule(0, timeZone = timeZone) + .until(startInstant.plus(12.hours)), + catchUpBehavior = ScheduleExecutor.CatchUpBehavior.ExecuteOne + ) + + assertEquals( + actual = executor + .runSchedule(EveryHourSchedule(0).named("EveryHour")) + .firstTen(), + expected = listOf( + startInstant.toLocalDateTime(timeZone), + LocalDate(2023, Month.DECEMBER, 28).atTime(3, 0, 0), + LocalDate(2023, Month.DECEMBER, 28).atTime(4, 0, 0), + LocalDate(2023, Month.DECEMBER, 28).atTime(5, 0, 0), + LocalDate(2023, Month.DECEMBER, 28).atTime(6, 0, 0), + LocalDate(2023, Month.DECEMBER, 28).atTime(7, 0, 0), + LocalDate(2023, Month.DECEMBER, 28).atTime(8, 0, 0), + LocalDate(2023, Month.DECEMBER, 28).atTime(9, 0, 0), + LocalDate(2023, Month.DECEMBER, 28).atTime(10, 0, 0), + LocalDate(2023, Month.DECEMBER, 28).atTime(11, 0, 0), + ), + ) + } + + @Test + fun testCatchUpBehavior_ExecuteAll() = runTest { + advanceTimeBy(startInstant.toEpochMilliseconds()) + + val executor = PollingScheduleExecutor( + scheduleState = InMemoryScheduleState(mapOf("EveryHour" to startInstant.minus(4.hours))), + clock = TestClock(), + timeZone = timeZone, + pollingSchedule = EveryMinuteSchedule(0, timeZone = timeZone) + .until(startInstant.plus(12.hours)), + catchUpBehavior = ScheduleExecutor.CatchUpBehavior.ExecuteAll + ) + + assertEquals( + actual = executor + .runSchedule(EveryHourSchedule(0).named("EveryHour")) + .firstTen(), + expected = listOf( + LocalDate(2023, Month.DECEMBER, 27).atTime(23, 0, 0), + LocalDate(2023, Month.DECEMBER, 28).atTime(0, 0, 0), + LocalDate(2023, Month.DECEMBER, 28).atTime(1, 0, 0), + LocalDate(2023, Month.DECEMBER, 28).atTime(2, 0, 0), + LocalDate(2023, Month.DECEMBER, 28).atTime(3, 0, 0), + LocalDate(2023, Month.DECEMBER, 28).atTime(4, 0, 0), + LocalDate(2023, Month.DECEMBER, 28).atTime(5, 0, 0), + LocalDate(2023, Month.DECEMBER, 28).atTime(6, 0, 0), + LocalDate(2023, Month.DECEMBER, 28).atTime(7, 0, 0), + LocalDate(2023, Month.DECEMBER, 28).atTime(8, 0, 0), + ), + ) + } +} diff --git a/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/operators/AdaptiveOperatorsTest.kt b/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/operators/AdaptiveOperatorsTest.kt new file mode 100644 index 00000000..ca1ab400 --- /dev/null +++ b/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/operators/AdaptiveOperatorsTest.kt @@ -0,0 +1,65 @@ +package com.copperleaf.ballast.scheduler.operators + +import com.copperleaf.ballast.scheduler.ExactTimeClock +import com.copperleaf.ballast.scheduler.firstTen +import com.copperleaf.ballast.scheduler.schedule.EveryMinuteSchedule +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atTime +import kotlinx.datetime.toInstant +import kotlin.test.Test +import kotlin.test.assertEquals + +class AdaptiveOperatorsTest { + val timeZone = TimeZone.UTC + val startDay = LocalDate(2023, Month.DECEMBER, 28) + val startInstant = startDay.atTime(2, 37, 0).toInstant(timeZone) + + @Test + fun scheduleAdaptiveTest_withDelay() = runTest { + // When processing each task takes some time, adaptive shifts subsequent scheduled instants forward + // by the amount of time elapsed since the task was supposed to start. + val clock = ExactTimeClock( + startDay.atTime(2, 38, 30).toInstant(timeZone), // clock.now() when computing 2nd item + startDay.atTime(2, 39, 45).toInstant(timeZone), // clock.now() when computing 3rd item + ) + + assertEquals( + actual = EveryMinuteSchedule(0) + .adaptive(clock) + .take(3) + .generateSchedule(startInstant) + .firstTen(), + expected = listOf( + startDay.atTime(2, 38, 0), // first item always returned as-is + startDay.atTime(2, 39, 30), // 02:38:30 (now) + 60s (intended gap) = 02:39:30 + startDay.atTime(2, 40, 45), // 02:39:45 (now) + 60s (intended gap) = 02:40:45 + ), + ) + } + + @Test + fun scheduleAdaptiveTest_noDelay() = runTest { + // When clock.now() returns the exact time of the current schedule item, the adaptive output + // equals the original schedule (no adjustment needed). + val clock = ExactTimeClock( + startDay.atTime(2, 38, 0).toInstant(timeZone), // clock at exact schedule time + startDay.atTime(2, 39, 0).toInstant(timeZone), + ) + + assertEquals( + actual = EveryMinuteSchedule(0) + .adaptive(clock) + .take(3) + .generateSchedule(startInstant) + .firstTen(), + expected = listOf( + startDay.atTime(2, 38, 0), + startDay.atTime(2, 39, 0), + startDay.atTime(2, 40, 0), + ), + ) + } +} diff --git a/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/operators/AlignOperatorsTest.kt b/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/operators/AlignOperatorsTest.kt new file mode 100644 index 00000000..cc0429cc --- /dev/null +++ b/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/operators/AlignOperatorsTest.kt @@ -0,0 +1,113 @@ +package com.copperleaf.ballast.scheduler.operators + +import com.copperleaf.ballast.scheduler.firstTen +import com.copperleaf.ballast.scheduler.schedule.EveryDaySchedule +import com.copperleaf.ballast.scheduler.schedule.EveryMinuteSchedule +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalTime +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atTime +import kotlinx.datetime.toInstant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.DurationUnit + +class AlignOperatorsTest { + val timeZone = TimeZone.UTC + val startDay = LocalDate(2023, Month.DECEMBER, 28) + val startInstant = startDay.atTime(2, 37, 0).toInstant(timeZone) + + @Test + fun scheduleAlignToSecondsTest() = runTest { + // Instants already on second boundaries are returned unchanged + assertEquals( + actual = EveryMinuteSchedule(30) + .alignTo(DurationUnit.SECONDS, timeZone) + .generateSchedule(startInstant) + .firstTen(), + expected = listOf( + startDay.atTime(2, 37, 30), + startDay.atTime(2, 38, 30), + startDay.atTime(2, 39, 30), + startDay.atTime(2, 40, 30), + startDay.atTime(2, 41, 30), + startDay.atTime(2, 42, 30), + startDay.atTime(2, 43, 30), + startDay.atTime(2, 44, 30), + startDay.atTime(2, 45, 30), + startDay.atTime(2, 46, 30), + ), + ) + } + + @Test + fun scheduleAlignToMinutesTest() = runTest { + // Instants at :30 seconds are bumped forward to the top of the next minute + assertEquals( + actual = EveryMinuteSchedule(30) + .alignTo(DurationUnit.MINUTES, timeZone) + .generateSchedule(startInstant) + .firstTen(), + expected = listOf( + startDay.atTime(2, 38, 0), + startDay.atTime(2, 39, 0), + startDay.atTime(2, 40, 0), + startDay.atTime(2, 41, 0), + startDay.atTime(2, 42, 0), + startDay.atTime(2, 43, 0), + startDay.atTime(2, 44, 0), + startDay.atTime(2, 45, 0), + startDay.atTime(2, 46, 0), + startDay.atTime(2, 47, 0), + ), + ) + } + + @Test + fun scheduleAlignToHoursTest() = runTest { + // Instants at :30 past the hour are bumped forward to the top of the next hour + assertEquals( + actual = EveryDaySchedule(LocalTime(9, 30)) + .alignTo(DurationUnit.HOURS, timeZone) + .generateSchedule(startInstant) + .firstTen(), + expected = listOf( + LocalDate(2023, Month.DECEMBER, 28).atTime(10, 0), + LocalDate(2023, Month.DECEMBER, 29).atTime(10, 0), + LocalDate(2023, Month.DECEMBER, 30).atTime(10, 0), + LocalDate(2023, Month.DECEMBER, 31).atTime(10, 0), + LocalDate(2024, Month.JANUARY, 1).atTime(10, 0), + LocalDate(2024, Month.JANUARY, 2).atTime(10, 0), + LocalDate(2024, Month.JANUARY, 3).atTime(10, 0), + LocalDate(2024, Month.JANUARY, 4).atTime(10, 0), + LocalDate(2024, Month.JANUARY, 5).atTime(10, 0), + LocalDate(2024, Month.JANUARY, 6).atTime(10, 0), + ), + ) + } + + @Test + fun scheduleAlignToDaysTest() = runTest { + // Instants at 09:00 are bumped forward to midnight of the next day + assertEquals( + actual = EveryDaySchedule(LocalTime(9, 0)) + .alignTo(DurationUnit.DAYS, timeZone) + .generateSchedule(startInstant) + .firstTen(), + expected = listOf( + LocalDate(2023, Month.DECEMBER, 29).atTime(0, 0), + LocalDate(2023, Month.DECEMBER, 30).atTime(0, 0), + LocalDate(2023, Month.DECEMBER, 31).atTime(0, 0), + LocalDate(2024, Month.JANUARY, 1).atTime(0, 0), + LocalDate(2024, Month.JANUARY, 2).atTime(0, 0), + LocalDate(2024, Month.JANUARY, 3).atTime(0, 0), + LocalDate(2024, Month.JANUARY, 4).atTime(0, 0), + LocalDate(2024, Month.JANUARY, 5).atTime(0, 0), + LocalDate(2024, Month.JANUARY, 6).atTime(0, 0), + LocalDate(2024, Month.JANUARY, 7).atTime(0, 0), + ), + ) + } +} diff --git a/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/operators/BoundedOperatorsTest.kt b/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/operators/BoundedOperatorsTest.kt new file mode 100644 index 00000000..3d984b90 --- /dev/null +++ b/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/operators/BoundedOperatorsTest.kt @@ -0,0 +1,96 @@ +package com.copperleaf.ballast.scheduler.operators + +import com.copperleaf.ballast.scheduler.firstTen +import com.copperleaf.ballast.scheduler.schedule.EveryMinuteSchedule +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atTime +import kotlinx.datetime.toInstant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.minutes + +class BoundedOperatorsTest { + val timeZone = TimeZone.UTC + val startDay = LocalDate(2023, Month.DECEMBER, 28) + val startInstant = startDay.atTime(2, 33, 0).toInstant(timeZone) + + val rangeStart = startDay.atTime(2, 37, 0).toInstant(timeZone) + val rangeEnd = startDay.atTime(2, 41, 0).toInstant(timeZone) + + val inRangeStartInstant = rangeStart.plus(1.minutes) + val beforeRangeStartInstant = rangeStart.minus(1.minutes) + val afterRangeStartInstant = rangeEnd.plus(1.minutes) + + @Test + fun scheduleBoundedTest_startsBeforeWindow() = runTest { + assertEquals( + actual = EveryMinuteSchedule(12) + .between(rangeStart..rangeEnd) + .generateSchedule(startInstant) + .firstTen(), + expected = listOf( + startDay.atTime(2, 37, 12), + startDay.atTime(2, 38, 12), + startDay.atTime(2, 39, 12), + startDay.atTime(2, 40, 12), + ), + ) + } + + @Test + fun scheduleBoundedTest_startsDuringWindow() = runTest { + assertEquals( + actual = EveryMinuteSchedule(12) + .between(rangeStart..rangeEnd) + .generateSchedule(inRangeStartInstant) + .firstTen(), + expected = listOf( + startDay.atTime(2, 38, 12), + startDay.atTime(2, 39, 12), + startDay.atTime(2, 40, 12), + ), + ) + } + + @Test + fun scheduleBoundedTest_startsAfterWindow() = runTest { + assertEquals( + actual = EveryMinuteSchedule(12) + .between(rangeStart..rangeEnd) + .generateSchedule(afterRangeStartInstant) + .firstTen(), + expected = emptyList(), + ) + } + + @Test + fun scheduleUntilTest_startsBefore() = runTest { + assertEquals( + actual = EveryMinuteSchedule(12) + .until(rangeEnd) + .generateSchedule(beforeRangeStartInstant) + .firstTen(), + expected = listOf( + startDay.atTime(2, 36, 12), + startDay.atTime(2, 37, 12), + startDay.atTime(2, 38, 12), + startDay.atTime(2, 39, 12), + startDay.atTime(2, 40, 12), + ), + ) + } + + @Test + fun scheduleUntilTest_startsAfter() = runTest { + assertEquals( + actual = EveryMinuteSchedule(12) + .until(rangeEnd) + .generateSchedule(afterRangeStartInstant) + .firstTen(), + expected = emptyList(), + ) + } +} diff --git a/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/operators/DelayOperatorsTest.kt b/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/operators/DelayOperatorsTest.kt new file mode 100644 index 00000000..4351e73b --- /dev/null +++ b/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/operators/DelayOperatorsTest.kt @@ -0,0 +1,80 @@ +package com.copperleaf.ballast.scheduler.operators + +import com.copperleaf.ballast.scheduler.firstTen +import com.copperleaf.ballast.scheduler.schedule.EveryMinuteSchedule +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atTime +import kotlinx.datetime.toInstant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.hours + +class DelayOperatorsTest { + val timeZone = TimeZone.UTC + val startDay = LocalDate(2023, Month.DECEMBER, 28) + val startInstant = startDay.atTime(2, 37, 0).toInstant(timeZone) + + @Test + fun scheduleDelayedTest() = runTest { + assertEquals( + actual = EveryMinuteSchedule(12) + .delayed(1.hours) + .take(4) + .generateSchedule(startInstant) + .firstTen(), + expected = listOf( + startDay.atTime(3, 37, 12), + startDay.atTime(3, 38, 12), + startDay.atTime(3, 39, 12), + startDay.atTime(3, 40, 12), + ), + ) + } + + @Test + fun scheduleDelayedUntilTest_earlierThanActualStartTime() = runTest { + assertEquals( + actual = EveryMinuteSchedule(12) + .delayedUntil(startDay.atTime(1, 0, 0).toInstant(timeZone)) + .generateSchedule(startInstant) + .firstTen(), + expected = listOf( + startDay.atTime(2, 37, 12), + startDay.atTime(2, 38, 12), + startDay.atTime(2, 39, 12), + startDay.atTime(2, 40, 12), + startDay.atTime(2, 41, 12), + startDay.atTime(2, 42, 12), + startDay.atTime(2, 43, 12), + startDay.atTime(2, 44, 12), + startDay.atTime(2, 45, 12), + startDay.atTime(2, 46, 12), + ), + ) + } + + @Test + fun scheduleDelayedUntilTest_laterThanActualStartTime() = runTest { + assertEquals( + actual = EveryMinuteSchedule(12) + .delayedUntil(startDay.atTime(4, 0, 0).toInstant(timeZone)) + .generateSchedule(startInstant) + .firstTen(), + expected = listOf( + startDay.atTime(4, 0, 12), + startDay.atTime(4, 1, 12), + startDay.atTime(4, 2, 12), + startDay.atTime(4, 3, 12), + startDay.atTime(4, 4, 12), + startDay.atTime(4, 5, 12), + startDay.atTime(4, 6, 12), + startDay.atTime(4, 7, 12), + startDay.atTime(4, 8, 12), + startDay.atTime(4, 9, 12), + ), + ) + } +} diff --git a/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/operators/FilterOperatorsTest.kt b/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/operators/FilterOperatorsTest.kt new file mode 100644 index 00000000..4426597a --- /dev/null +++ b/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/operators/FilterOperatorsTest.kt @@ -0,0 +1,86 @@ +package com.copperleaf.ballast.scheduler.operators + +import com.copperleaf.ballast.scheduler.firstTen +import com.copperleaf.ballast.scheduler.schedule.EveryDaySchedule +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalTime +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atTime +import kotlinx.datetime.toInstant +import kotlin.test.Test +import kotlin.test.assertEquals + +class FilterOperatorsTest { + val timeZone = TimeZone.UTC + val startDay = LocalDate(2023, Month.DECEMBER, 28) + val startInstant = startDay.atTime(2, 45, 0).toInstant(timeZone) + + @Test + fun scheduleFilterByDayOfWeekTest() = runTest { + assertEquals( + actual = EveryDaySchedule(LocalTime(9, 0)) + .filterByDayOfWeek(DayOfWeek.MONDAY, DayOfWeek.WEDNESDAY, DayOfWeek.FRIDAY, timeZone = timeZone) + .generateSchedule(startInstant) + .firstTen(), + expected = listOf( + LocalDate(2023, Month.DECEMBER, 29).atTime(9, 0), + LocalDate(2024, Month.JANUARY, 1).atTime(9, 0), + LocalDate(2024, Month.JANUARY, 3).atTime(9, 0), + LocalDate(2024, Month.JANUARY, 5).atTime(9, 0), + LocalDate(2024, Month.JANUARY, 8).atTime(9, 0), + LocalDate(2024, Month.JANUARY, 10).atTime(9, 0), + LocalDate(2024, Month.JANUARY, 12).atTime(9, 0), + LocalDate(2024, Month.JANUARY, 15).atTime(9, 0), + LocalDate(2024, Month.JANUARY, 17).atTime(9, 0), + LocalDate(2024, Month.JANUARY, 19).atTime(9, 0), + ), + ) + } + + @Test + fun scheduleWeekdaysTest() = runTest { + assertEquals( + actual = EveryDaySchedule(LocalTime(9, 0)) + .weekdays(timeZone = timeZone) + .generateSchedule(startInstant) + .firstTen(), + expected = listOf( + LocalDate(2023, Month.DECEMBER, 28).atTime(9, 0), + LocalDate(2023, Month.DECEMBER, 29).atTime(9, 0), + LocalDate(2024, Month.JANUARY, 1).atTime(9, 0), + LocalDate(2024, Month.JANUARY, 2).atTime(9, 0), + LocalDate(2024, Month.JANUARY, 3).atTime(9, 0), + LocalDate(2024, Month.JANUARY, 4).atTime(9, 0), + LocalDate(2024, Month.JANUARY, 5).atTime(9, 0), + LocalDate(2024, Month.JANUARY, 8).atTime(9, 0), + LocalDate(2024, Month.JANUARY, 9).atTime(9, 0), + LocalDate(2024, Month.JANUARY, 10).atTime(9, 0), + ), + ) + } + + @Test + fun scheduleWeekendsTest() = runTest { + assertEquals( + actual = EveryDaySchedule(LocalTime(9, 0)) + .weekends(timeZone = timeZone) + .generateSchedule(startInstant) + .firstTen(), + expected = listOf( + LocalDate(2023, Month.DECEMBER, 30).atTime(9, 0), + LocalDate(2023, Month.DECEMBER, 31).atTime(9, 0), + LocalDate(2024, Month.JANUARY, 6).atTime(9, 0), + LocalDate(2024, Month.JANUARY, 7).atTime(9, 0), + LocalDate(2024, Month.JANUARY, 13).atTime(9, 0), + LocalDate(2024, Month.JANUARY, 14).atTime(9, 0), + LocalDate(2024, Month.JANUARY, 20).atTime(9, 0), + LocalDate(2024, Month.JANUARY, 21).atTime(9, 0), + LocalDate(2024, Month.JANUARY, 27).atTime(9, 0), + LocalDate(2024, Month.JANUARY, 28).atTime(9, 0), + ), + ) + } +} diff --git a/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/operators/QueryOperatorsTest.kt b/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/operators/QueryOperatorsTest.kt new file mode 100644 index 00000000..ecbc92b8 --- /dev/null +++ b/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/operators/QueryOperatorsTest.kt @@ -0,0 +1,129 @@ +package com.copperleaf.ballast.scheduler.operators + +import com.copperleaf.ballast.scheduler.firstTen +import com.copperleaf.ballast.scheduler.schedule.EveryMinuteSchedule +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atTime +import kotlinx.datetime.toInstant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Clock +import kotlin.time.Instant + +class QueryOperatorsTest { + val timeZone = TimeZone.UTC + val startDay = LocalDate(2023, Month.DECEMBER, 28) + val startInstant = startDay.atTime(2, 37, 0).toInstant(timeZone) + val currentInstant = startDay.atTime(2, 44, 0).toInstant(timeZone) + + @Test + fun scheduleTakeTest() = runTest { + assertEquals( + actual = EveryMinuteSchedule(12) + .take(4) + .generateSchedule(startInstant) + .firstTen(), + expected = listOf( + startDay.atTime(2, 37, 12), + startDay.atTime(2, 38, 12), + startDay.atTime(2, 39, 12), + startDay.atTime(2, 40, 12), + ), + ) + } + + @Test + fun scheduleGetNextTest() = runTest { + val clock = object : Clock { + override fun now(): Instant { + return startInstant + } + } + + assertEquals( + actual = EveryMinuteSchedule(5, timeZone = timeZone).getNext(clock), + expected = startDay.atTime(2, 37, 5).toInstant(timeZone), + ) + + assertEquals( + actual = EveryMinuteSchedule(5, timeZone = timeZone).getNext(startInstant), + expected = startDay.atTime(2, 37, 5).toInstant(timeZone), + ) + } + + @Test + fun scheduleGetHistoryUnboundedTest() = runTest { + assertEquals( + actual = EveryMinuteSchedule(12) + .getHistory( + startInstant = startInstant, + currentInstant = currentInstant, + ) + .toList(), + expected = listOf( + startDay.atTime(2, 37, 12).toInstant(timeZone), + startDay.atTime(2, 38, 12).toInstant(timeZone), + startDay.atTime(2, 39, 12).toInstant(timeZone), + startDay.atTime(2, 40, 12).toInstant(timeZone), + startDay.atTime(2, 41, 12).toInstant(timeZone), + startDay.atTime(2, 42, 12).toInstant(timeZone), + startDay.atTime(2, 43, 12).toInstant(timeZone), + ), + ) + } + + @Test + fun scheduleGetHistoryBoundedTest() = runTest { + assertEquals( + actual = EveryMinuteSchedule(12) + .take(3) + .getHistory( + startInstant = startInstant, + currentInstant = currentInstant, + ) + .toList(), + expected = listOf( + startDay.atTime(2, 37, 12).toInstant(timeZone), + startDay.atTime(2, 38, 12).toInstant(timeZone), + startDay.atTime(2, 39, 12).toInstant(timeZone), + ), + ) + } + + @Test + fun scheduleDropHistoryUnboundedTest() = runTest { + val scheduleSequence = EveryMinuteSchedule(12) + .dropHistory( + startInstant = startInstant, + currentInstant = currentInstant, + ) + .take(4) + .toList() + + assertEquals( + listOf( + startDay.atTime(2, 44, 12).toInstant(timeZone), + startDay.atTime(2, 45, 12).toInstant(timeZone), + startDay.atTime(2, 46, 12).toInstant(timeZone), + startDay.atTime(2, 47, 12).toInstant(timeZone), + ), scheduleSequence + ) + } + + @Test + fun scheduleDropHistoryBoundedTest() = runTest { + val scheduleSequence = EveryMinuteSchedule(12) + .take(3) + .dropHistory( + startInstant = startInstant, + currentInstant = currentInstant, + ) + .take(4) + .toList() + + assertEquals(0, scheduleSequence.size) + } +} diff --git a/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/operators/TransformOperatorsTest.kt b/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/operators/TransformOperatorsTest.kt new file mode 100644 index 00000000..2112e7c9 --- /dev/null +++ b/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/operators/TransformOperatorsTest.kt @@ -0,0 +1,55 @@ +package com.copperleaf.ballast.scheduler.operators + +import com.copperleaf.ballast.scheduler.firstTen +import com.copperleaf.ballast.scheduler.schedule.EveryMinuteSchedule +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atTime +import kotlinx.datetime.toInstant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.hours + +class TransformOperatorsTest { + val timeZone = TimeZone.UTC + val startDay = LocalDate(2023, Month.DECEMBER, 28) + val startInstant = startDay.atTime(2, 37, 0).toInstant(timeZone) + + @Test + fun scheduleTransformScheduleTest() = runTest { + // transformSchedule allows arbitrary manipulation of the underlying Sequence, such as skipping every other item + assertEquals( + actual = EveryMinuteSchedule(12) + .transformSchedule { seq -> seq.filterIndexed { index, _ -> index % 2 == 0 } } + .take(4) + .generateSchedule(startInstant) + .firstTen(), + expected = listOf( + startDay.atTime(2, 37, 12), + startDay.atTime(2, 39, 12), + startDay.atTime(2, 41, 12), + startDay.atTime(2, 43, 12), + ), + ) + } + + @Test + fun scheduleTransformScheduleStartTest() = runTest { + // transformScheduleStart shifts the start instant before generating the schedule + assertEquals( + actual = EveryMinuteSchedule(12) + .transformScheduleStart { it + 1.hours } + .take(4) + .generateSchedule(startInstant) + .firstTen(), + expected = listOf( + startDay.atTime(3, 37, 12), + startDay.atTime(3, 38, 12), + startDay.atTime(3, 39, 12), + startDay.atTime(3, 40, 12), + ), + ) + } +} diff --git a/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/EveryDayScheduleTest.kt b/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/EveryDayScheduleTest.kt new file mode 100644 index 00000000..f1e84bea --- /dev/null +++ b/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/EveryDayScheduleTest.kt @@ -0,0 +1,148 @@ +package com.copperleaf.ballast.scheduler.schedule + +import com.copperleaf.ballast.scheduler.firstTen +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalTime +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atTime +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import kotlin.test.Ignore +import kotlin.test.Test +import kotlin.test.assertEquals + +class EveryDayScheduleTest { + val timeZone = TimeZone.UTC + val startDay = LocalDate(2023, Month.DECEMBER, 28) + val startInstant = startDay.atTime(1, 0).toInstant(timeZone) + + @Test + fun onceEveryDayTest() = runTest { + assertEquals( + actual = EveryDaySchedule(LocalTime(2, 37)) + .generateSchedule(startInstant) + .firstTen(), + expected = listOf( + LocalDate(2023, Month.DECEMBER, 28).atTime(2, 37), + LocalDate(2023, Month.DECEMBER, 29).atTime(2, 37), + LocalDate(2023, Month.DECEMBER, 30).atTime(2, 37), + LocalDate(2023, Month.DECEMBER, 31).atTime(2, 37), + LocalDate(2024, Month.JANUARY, 1).atTime(2, 37), + LocalDate(2024, Month.JANUARY, 2).atTime(2, 37), + LocalDate(2024, Month.JANUARY, 3).atTime(2, 37), + LocalDate(2024, Month.JANUARY, 4).atTime(2, 37), + LocalDate(2024, Month.JANUARY, 5).atTime(2, 37), + LocalDate(2024, Month.JANUARY, 6).atTime(2, 37), + ), + ) + } + + @Test + fun multipleTimesEveryDayTest() = runTest { + assertEquals( + actual = EveryDaySchedule(LocalTime(2, 37), LocalTime(7, 38), LocalTime(23, 58)) + .generateSchedule(startInstant) + .firstTen(), + expected = listOf( + LocalDate(2023, Month.DECEMBER, 28).atTime(2, 37), + LocalDate(2023, Month.DECEMBER, 28).atTime(7, 38), + LocalDate(2023, Month.DECEMBER, 28).atTime(23, 58), + LocalDate(2023, Month.DECEMBER, 29).atTime(2, 37), + LocalDate(2023, Month.DECEMBER, 29).atTime(7, 38), + LocalDate(2023, Month.DECEMBER, 29).atTime(23, 58), + LocalDate(2023, Month.DECEMBER, 30).atTime(2, 37), + LocalDate(2023, Month.DECEMBER, 30).atTime(7, 38), + LocalDate(2023, Month.DECEMBER, 30).atTime(23, 58), + LocalDate(2023, Month.DECEMBER, 31).atTime(2, 37), + ), + ) + } + + @Test + @Ignore // Fails sporadically due to kotlinx-datetime issues on JS and WasmJS tests + fun handlesDaylightSavingsSpringForward() = runTest { + // DST starts on 2024-03-10 in America/New_York (2:00 AM jumps to 3:00 AM) + val tz = TimeZone.of("America/New_York") + val startDay = LocalDate(2024, Month.MARCH, 9) + val startInstant = startDay.atTime(1, 0).toInstant(tz) + + assertEquals( + actual = EveryDaySchedule(LocalTime(2, 30), timeZone = tz) + .generateSchedule(startInstant) + .take(3) + .map { it.toLocalDateTime(tz) } + .toList(), + expected = listOf( + LocalDate(2024, Month.MARCH, 9).atTime(2, 30), + // 2024-03-10 2:30 does not exist, so should be scheduled at 3:30 + LocalDate(2024, Month.MARCH, 10).atTime(3, 30), + LocalDate(2024, Month.MARCH, 11).atTime(2, 30), + ) + ) + } + + @Test + @Ignore // Fails sporadically due to kotlinx-datetime issues on JS and WasmJS tests + fun handlesDaylightSavingsFallBack() = runTest { + // DST ends on 2024-11-03 in America/New_York (2:00 AM repeats) + val tz = TimeZone.of("America/New_York") + val startDay = LocalDate(2024, Month.NOVEMBER, 2) + val startInstant = startDay.atTime(1, 0).toInstant(tz) + + assertEquals( + actual = EveryDaySchedule(LocalTime(1, 30), timeZone = tz) + .generateSchedule(startInstant) + .take(3) + .map { it.toLocalDateTime(tz) } + .toList(), + expected = listOf( + LocalDate(2024, Month.NOVEMBER, 2).atTime(1, 30), + LocalDate(2024, Month.NOVEMBER, 3).atTime(1, 30), // occurs twice, but should only schedule once + LocalDate(2024, Month.NOVEMBER, 4).atTime(1, 30), + ) + ) + } + + @Test + fun handlesLeapDayInLeapYear() = runTest { + val tz = TimeZone.UTC + val startDay = LocalDate(2024, Month.FEBRUARY, 27) // 2024 is a leap year + val startInstant = startDay.atTime(10, 0).toInstant(tz) + + assertEquals( + actual = EveryDaySchedule(LocalTime(12, 0), timeZone = tz) + .generateSchedule(startInstant) + .take(4) + .map { it.toLocalDateTime(tz) } + .toList(), + expected = listOf( + LocalDate(2024, Month.FEBRUARY, 27).atTime(12, 0), + LocalDate(2024, Month.FEBRUARY, 28).atTime(12, 0), + LocalDate(2024, Month.FEBRUARY, 29).atTime(12, 0), // Leap Day + LocalDate(2024, Month.MARCH, 1).atTime(12, 0), + ) + ) + } + + @Test + fun skipsLeapDayInNonLeapYear() = runTest { + val tz = TimeZone.UTC + val startDay = LocalDate(2023, Month.FEBRUARY, 27) // 2023 is not a leap year + val startInstant = startDay.atTime(10, 0).toInstant(tz) + + assertEquals( + actual = EveryDaySchedule(LocalTime(12, 0), timeZone = tz) + .generateSchedule(startInstant) + .take(3) + .map { it.toLocalDateTime(tz) } + .toList(), + expected = listOf( + LocalDate(2023, Month.FEBRUARY, 27).atTime(12, 0), + LocalDate(2023, Month.FEBRUARY, 28).atTime(12, 0), + LocalDate(2023, Month.MARCH, 1).atTime(12, 0), + ) + ) + } +} diff --git a/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/EveryHourScheduleTest.kt b/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/EveryHourScheduleTest.kt new file mode 100644 index 00000000..0776f7c9 --- /dev/null +++ b/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/EveryHourScheduleTest.kt @@ -0,0 +1,59 @@ +package com.copperleaf.ballast.scheduler.schedule + +import com.copperleaf.ballast.scheduler.firstTen +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atTime +import kotlinx.datetime.toInstant +import kotlin.test.Test +import kotlin.test.assertEquals + +class EveryHourScheduleTest { + val timeZone = TimeZone.UTC + val startDay = LocalDate(2023, Month.DECEMBER, 28) + val startInstant = startDay.atTime(2, 37).toInstant(timeZone) + + @Test + fun onceEveryHourTest() = runTest { + assertEquals( + actual = EveryHourSchedule(1) + .generateSchedule(startInstant) + .firstTen(), + expected = listOf( + startDay.atTime(3, 1), + startDay.atTime(4, 1), + startDay.atTime(5, 1), + startDay.atTime(6, 1), + startDay.atTime(7, 1), + startDay.atTime(8, 1), + startDay.atTime(9, 1), + startDay.atTime(10, 1), + startDay.atTime(11, 1), + startDay.atTime(12, 1), + ), + ) + } + + @Test + fun multipleTimesEveryHourTest() = runTest { + assertEquals( + actual = EveryHourSchedule(0, 15, 30, 45) + .generateSchedule(startInstant) + .firstTen(), + expected = listOf( + startDay.atTime(2, 45), + startDay.atTime(3, 0), + startDay.atTime(3, 15), + startDay.atTime(3, 30), + startDay.atTime(3, 45), + startDay.atTime(4, 0), + startDay.atTime(4, 15), + startDay.atTime(4, 30), + startDay.atTime(4, 45), + startDay.atTime(5, 0), + ), + ) + } +} diff --git a/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/EveryMinuteScheduleTest.kt b/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/EveryMinuteScheduleTest.kt new file mode 100644 index 00000000..aa9a04ca --- /dev/null +++ b/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/EveryMinuteScheduleTest.kt @@ -0,0 +1,59 @@ +package com.copperleaf.ballast.scheduler.schedule + +import com.copperleaf.ballast.scheduler.firstTen +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atTime +import kotlinx.datetime.toInstant +import kotlin.test.Test +import kotlin.test.assertEquals + +class EveryMinuteScheduleTest { + val timeZone = TimeZone.UTC + val startDay = LocalDate(2023, Month.DECEMBER, 28) + val startInstant = startDay.atTime(2, 37, 0).toInstant(timeZone) + + @Test + fun onceEveryMinuteTest() = runTest { + assertEquals( + actual = EveryMinuteSchedule(12) + .generateSchedule(startInstant) + .firstTen(), + expected = listOf( + startDay.atTime(2, 37, 12), + startDay.atTime(2, 38, 12), + startDay.atTime(2, 39, 12), + startDay.atTime(2, 40, 12), + startDay.atTime(2, 41, 12), + startDay.atTime(2, 42, 12), + startDay.atTime(2, 43, 12), + startDay.atTime(2, 44, 12), + startDay.atTime(2, 45, 12), + startDay.atTime(2, 46, 12), + ), + ) + } + + @Test + fun multipleTimesEveryMinuteTest() = runTest { + assertEquals( + actual = EveryMinuteSchedule(0, 15, 30, 45) + .generateSchedule(startInstant) + .firstTen(), + expected = listOf( + startDay.atTime(2, 37, 15), + startDay.atTime(2, 37, 30), + startDay.atTime(2, 37, 45), + startDay.atTime(2, 38, 0), + startDay.atTime(2, 38, 15), + startDay.atTime(2, 38, 30), + startDay.atTime(2, 38, 45), + startDay.atTime(2, 39, 0), + startDay.atTime(2, 39, 15), + startDay.atTime(2, 39, 30), + ), + ) + } +} diff --git a/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/EverySecondScheduleTest.kt b/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/EverySecondScheduleTest.kt new file mode 100644 index 00000000..cf6cb286 --- /dev/null +++ b/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/EverySecondScheduleTest.kt @@ -0,0 +1,38 @@ +package com.copperleaf.ballast.scheduler.schedule + +import com.copperleaf.ballast.scheduler.firstTen +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atTime +import kotlinx.datetime.toInstant +import kotlin.test.Test +import kotlin.test.assertEquals + +class EverySecondScheduleTest { + val timeZone = TimeZone.UTC + val startDay = LocalDate(2023, Month.DECEMBER, 28) + val startInstant = startDay.atTime(2, 37, 52).toInstant(timeZone) + + @Test + fun everySecondTest() = runTest { + assertEquals( + actual = EverySecondSchedule() + .generateSchedule(startInstant) + .firstTen(), + expected = listOf( + startDay.atTime(2, 37, 53), + startDay.atTime(2, 37, 54), + startDay.atTime(2, 37, 55), + startDay.atTime(2, 37, 56), + startDay.atTime(2, 37, 57), + startDay.atTime(2, 37, 58), + startDay.atTime(2, 37, 59), + startDay.atTime(2, 38, 0), + startDay.atTime(2, 38, 1), + startDay.atTime(2, 38, 2), + ), + ) + } +} diff --git a/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/FixedDelayScheduleTest.kt b/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/FixedDelayScheduleTest.kt new file mode 100644 index 00000000..74e5f35c --- /dev/null +++ b/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/FixedDelayScheduleTest.kt @@ -0,0 +1,39 @@ +package com.copperleaf.ballast.scheduler.schedule + +import com.copperleaf.ballast.scheduler.firstTen +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atTime +import kotlinx.datetime.toInstant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.minutes + +class FixedDelayScheduleTest { + val timeZone = TimeZone.UTC + val startDay = LocalDate(2023, Month.DECEMBER, 28) + val startInstant = startDay.atTime(1, 1).toInstant(timeZone) + + @Test + fun fixedDelayScheduleTest() = runTest { + assertEquals( + actual = FixedDelaySchedule(10.minutes) + .generateSchedule(startInstant) + .firstTen(), + expected = listOf( + startDay.atTime(1, 11), + startDay.atTime(1, 21), + startDay.atTime(1, 31), + startDay.atTime(1, 41), + startDay.atTime(1, 51), + startDay.atTime(2, 1), + startDay.atTime(2, 11), + startDay.atTime(2, 21), + startDay.atTime(2, 31), + startDay.atTime(2, 41), + ), + ) + } +} diff --git a/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/FixedInstantScheduleTest.kt b/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/FixedInstantScheduleTest.kt new file mode 100644 index 00000000..20416a62 --- /dev/null +++ b/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/FixedInstantScheduleTest.kt @@ -0,0 +1,49 @@ +package com.copperleaf.ballast.scheduler.schedule + +import com.copperleaf.ballast.scheduler.firstTen +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atTime +import kotlinx.datetime.toInstant +import kotlin.test.Test +import kotlin.test.assertEquals + +class FixedInstantScheduleTest { + val timeZone = TimeZone.UTC + val startDay = LocalDate(2023, Month.DECEMBER, 28) + val startInstant = startDay.atTime(2, 37, 52).toInstant(timeZone) + + @Test + fun oneFixedInstant() = runTest { + assertEquals( + actual = FixedInstantSchedule( + startDay.atTime(2, 45, 0).toInstant(timeZone), + ) + .generateSchedule(startInstant) + .firstTen(), + expected = listOf( + startDay.atTime(2, 45, 0), + ), + ) + } + + @Test + fun multipleFixedInstants() = runTest { + assertEquals( + actual = FixedInstantSchedule( + startDay.atTime(2, 45, 0).toInstant(timeZone), + startDay.atTime(3, 45, 0).toInstant(timeZone), + startDay.atTime(3, 56, 44).toInstant(timeZone), + ) + .generateSchedule(startInstant) + .firstTen(), + expected = listOf( + startDay.atTime(2, 45, 0), + startDay.atTime(3, 45, 0), + startDay.atTime(3, 56, 44), + ), + ) + } +} diff --git a/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/testUtils.kt b/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/testUtils.kt new file mode 100644 index 00000000..9f377dc2 --- /dev/null +++ b/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/testUtils.kt @@ -0,0 +1,31 @@ +package com.copperleaf.ballast.scheduler + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Instant + +fun Sequence.firstTen(timeZone: TimeZone = TimeZone.UTC): List { + return this + .map { it.toLocalDateTime(timeZone) } + .take(10) + .toList() +} + +suspend fun Flow.firstTen(timeZone: TimeZone = TimeZone.UTC): List { + return this + .map { it.triggeredAt.toLocalDateTime(timeZone) } + .take(10) + .toList() +} + +suspend fun Flow.firstTenWithNames(timeZone: TimeZone = TimeZone.UTC): List> { + return this + .map { it.name to it.triggeredAt.toLocalDateTime(timeZone) } + .take(10) + .toList() +} diff --git a/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/utils/DateTimeUtilsTest.kt b/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/utils/DateTimeUtilsTest.kt new file mode 100644 index 00000000..a6a84279 --- /dev/null +++ b/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/utils/DateTimeUtilsTest.kt @@ -0,0 +1,88 @@ +package com.copperleaf.ballast.scheduler.utils + +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atTime +import kotlinx.datetime.toInstant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +class DateTimeUtilsTest { + + val timeZone = TimeZone.UTC + val startDay = LocalDate(2023, Month.DECEMBER, 28) + val startInstant = startDay.atTime(4, 5, 6, 7).toInstant(timeZone) + + @Test + fun alignToSecondTest() = runTest { + assertEquals( + actual = startInstant.alignToNextSecond(timeZone), + expected = LocalDateTime(2023, Month.DECEMBER, 28, 4, 5, 7, 0).toInstant(timeZone) + ) + } + + @Test + fun alignToMinuteTest() = runTest { + assertEquals( + actual = startInstant.alignToNextMinute(timeZone), + expected = LocalDateTime(2023, Month.DECEMBER, 28, 4, 6, 0, 0).toInstant(timeZone) + ) + } + + @Test + fun alignToHourTest() = runTest { + assertEquals( + actual = startInstant.alignToNextHour(timeZone), + expected = LocalDateTime(2023, Month.DECEMBER, 28, 5, 0, 0, 0).toInstant(timeZone) + ) + } + + @Test + fun alignToDayTest() = runTest { + assertEquals( + actual = startInstant.alignToNextDay(timeZone), + expected = LocalDateTime(2023, Month.DECEMBER, 29, 0, 0, 0, 0).toInstant(timeZone) + ) + } + + @Test + fun isSameOrBeforeMinuteTest() = runTest { + assertTrue { + startInstant.isSameOrBeforeMinute(startInstant, timeZone) + } + assertTrue { + startInstant.minus(1.seconds).isSameOrBeforeMinute(startInstant, timeZone) + } + assertTrue { + startInstant.plus(1.seconds).isSameOrBeforeMinute(startInstant, timeZone) + } + assertTrue { + startInstant.minus(1.minutes).isSameOrBeforeMinute(startInstant, timeZone) + } + assertTrue { + startInstant.minus(1.hours).isSameOrBeforeMinute(startInstant, timeZone) + } + assertTrue { + startInstant.minus(1.days).isSameOrBeforeMinute(startInstant, timeZone) + } + + assertFalse { + startInstant.plus(1.minutes).isSameOrBeforeMinute(startInstant, timeZone) + } + assertFalse { + startInstant.plus(1.hours).isSameOrBeforeMinute(startInstant, timeZone) + } + assertFalse { + startInstant.plus(1.days).isSameOrBeforeMinute(startInstant, timeZone) + } + } +} diff --git a/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/utils/ScheduleUtilsTest.kt b/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/utils/ScheduleUtilsTest.kt new file mode 100644 index 00000000..8a1d0991 --- /dev/null +++ b/ballast-scheduler-core/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/utils/ScheduleUtilsTest.kt @@ -0,0 +1,84 @@ +package com.copperleaf.ballast.scheduler.utils + +import com.copperleaf.ballast.scheduler.UnsafeFixedInstantSchedule +import com.copperleaf.ballast.scheduler.firstTen +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlin.test.Test +import kotlin.test.assertFails +import kotlin.time.Duration.Companion.seconds + +class ScheduleUtilsTest { + + val timeZone = TimeZone.UTC + val startDay = LocalDate(2023, Month.DECEMBER, 28) + val startInstant = startDay.atStartOfDayIn(timeZone) + + @Test + fun generateSafeSchedule_beforeStartValue_throws() = runTest { + assertFails { + UnsafeFixedInstantSchedule( + startInstant.minus(1.seconds), + ) + .generateSafeSchedule(startInstant) + .firstTen() + } + } + + @Test + fun generateSafeSchedule_sameAsStartValue_throws() = runTest { + assertFails { + UnsafeFixedInstantSchedule( + startInstant, + ) + .generateSafeSchedule(startInstant) + .firstTen() + } + } + + @Test + fun generateSafeSchedule_afterStartValue_doesNotThrow() = runTest { + UnsafeFixedInstantSchedule( + startInstant.plus(1.seconds), + ) + .generateSafeSchedule(startInstant) + .firstTen() + } + + @Test + fun generateSafeSchedule_beforePreviousValueDoes_throws() = runTest { + assertFails { + UnsafeFixedInstantSchedule( + startInstant.plus(5.seconds), + startInstant.plus(4.seconds), + ) + .generateSafeSchedule(startInstant) + .firstTen() + } + } + + @Test + fun generateSafeSchedule_sameAsPreviousValueDoes_throws() = runTest { + assertFails { + UnsafeFixedInstantSchedule( + startInstant.plus(5.seconds), + startInstant.plus(5.seconds), + ) + .generateSafeSchedule(startInstant) + .firstTen() + } + } + + @Test + fun generateSafeSchedule_afterPreviousValue_doesNotThrow() = runTest { + UnsafeFixedInstantSchedule( + startInstant.plus(5.seconds), + startInstant.plus(6.seconds), + ) + .generateSafeSchedule(startInstant) + .firstTen() + } +} diff --git a/ballast-scheduler-core/src/jsTest/kotlin/JsJodaTimeZoneModule.kt b/ballast-scheduler-core/src/jsTest/kotlin/JsJodaTimeZoneModule.kt new file mode 100644 index 00000000..03d476de --- /dev/null +++ b/ballast-scheduler-core/src/jsTest/kotlin/JsJodaTimeZoneModule.kt @@ -0,0 +1,7 @@ +@JsModule("@js-joda/timezone") +@JsNonModule +external object JsJodaTimeZoneModule + +@OptIn(ExperimentalJsExport::class) +@JsExport +val jsJodaTz = JsJodaTimeZoneModule diff --git a/ballast-scheduler-core/src/wasmJsTest/kotlin/JsJodaTimeZoneModule.kt b/ballast-scheduler-core/src/wasmJsTest/kotlin/JsJodaTimeZoneModule.kt new file mode 100644 index 00000000..ed38fbb3 --- /dev/null +++ b/ballast-scheduler-core/src/wasmJsTest/kotlin/JsJodaTimeZoneModule.kt @@ -0,0 +1,6 @@ +@file:OptIn(ExperimentalWasmJsInterop::class) + +@JsModule("@js-joda/timezone") +external object JsJodaTimeZoneModule + +private val jsJodaTz = JsJodaTimeZoneModule diff --git a/ballast-scheduler-cron/README.md b/ballast-scheduler-cron/README.md new file mode 100644 index 00000000..13d90afa --- /dev/null +++ b/ballast-scheduler-cron/README.md @@ -0,0 +1,200 @@ +# Ballast Scheduler Cron + +> [!CAUTION] +> +> Experimental. This module may not still have issues or changes in its public API before being considered stable. +> Please use at your own risk, and file Issues for any problems you may encounter. + +## Overview + +Adds a `CronSchedule` implementation to [Ballast Scheduler Core](./../ballast-scheduler-core) for scheduling tasks +using the familiar Unix-style Cron syntax. Supports the +[Open Cron Pattern Specification (OCPS)](https://github.com/open-source-cron/ocps) for unambiguous, interoperable cron +expressions. + +## Supported Platforms + +| Platform | Supported | +|----------|-----------| +| JVM | ✅ | +| Android | ✅ | +| iOS | ✅ | +| JS | ✅ | +| WASM JS | ✅ | + +## Supported OCPS Specification Versions + +This Cron implementation follows The Open Cron Pattern Specification (OCPS) standardization format, to avoid ambiguity +and aid in expression compatibility between Ballast and other Cron implementations. This table shows Ballast's current +level of support for the OCPS specification. + +| Platform | Supported | Notes | +|--------------------------------------------------------------------------------------------|-----------|----------------------------------------| +| [1.0](https://github.com/open-source-cron/ocps/blob/main/specifications/OCPS-1.0.md) | ✅ | | +| [1.1](https://github.com/open-source-cron/ocps/blob/main/increments/OCPS-increment-1.1.md) | ❌ | Planned, development not started | +| [1.2](https://github.com/open-source-cron/ocps/blob/main/increments/OCPS-increment-1.2.md) | ❌ | Planned, development not started | +| [1.3](https://github.com/open-source-cron/ocps/blob/main/increments/OCPS-increment-1.3.md) | ❌ | Not Planned, but open for contribution | +| [1.4](https://github.com/open-source-cron/ocps/blob/main/increments/OCPS-increment-1.4.md) | ❌ | Not Planned, but open for contribution | + +## See Also + +- [Ballast Scheduler Core](./../ballast-scheduler-core) +- [Ballast Scheduler ViewModel](./../ballast-scheduler-viewmodel) + +## Usage + +This module adds a `CronSchedule` implementation of `Schedule` for scheduling tasks using the familiar Unix-style Cron +syntax. [crontab.guru](https://crontab.guru/) is a helpful resource for interpreting Cron expressions, which supports +the same 5-field syntax as Ballast. + +A basic Cron schedule can be created with `CronExpression.parse(expression: String)`. + +```kotlin +CronExpression.parse("0 0 * * SUN") +``` + +Alternatively, you can create the fields directly using the structured constructor. + +```kotlin +CronExpression( + minute = MinuteField.exactValue(0), + hour = HourField.exactValue(0), + dayOfMonth = DayOfMonthField.anyValue(), + month = MonthField.anyValue(), + dayOfWeek = DayOfWeekField.exactValue(DayOfWeek.SUNDAY), +) +``` + +### Cron Syntax + +This Cron implementation abides by the syntax and semantics defined by the [Open Cron Pattern Specification](https://github.com/open-source-cron/ocps). + +It currently supports [Version 1.0](https://github.com/open-source-cron/ocps/blob/main/specifications/OCPS-1.0.md) +of the specification. Here's a summary of the OCPS syntax supported by Ballast: + +**Field Values** + +`MINUTE HOUR DAY-OF-MONTH MONTH DAY-OF-WEEK` + +| Field | Required | Allowed Values | +|:-----------------|:---------|:----------------| +| **Minute** | Yes | 0-59 | +| **Hour** | Yes | 0-23 | +| **Day of Month** | Yes | 1-31 | +| **Month** | Yes | 1-12 or JAN-DEC | +| **Day of Week** | Yes | 0-7 or SUN-SAT | + +* Month and Day of Week names are case-insensitive. +* In the Day of Week field, `0` and `7` are both treated as Sunday. + +**Month Name Equivalents** + +| Name | Numeric Value | +|:-----|:--------------| +| JAN | 1 | +| FEB | 2 | +| MAR | 3 | +| APR | 4 | +| MAY | 5 | +| JUN | 6 | +| JUL | 7 | +| AUG | 8 | +| SEP | 9 | +| OCT | 10 | +| NOV | 11 | +| DEC | 12 | + +**Day of Week Name Equivalents** + +| Name | Numeric Value | +|:-----|:--------------| +| SUN | 0 or 7 | +| MON | 1 | +| TUE | 2 | +| WED | 3 | +| THU | 4 | +| FRI | 5 | +| SAT | 6 | + +**Special Characters** + +| Character | Name | Example | Description | +|:----------|:---------------|:-------------|:-----------------------------------------------------------------------------------------------------------| +| `*` | Wildcard | `* * * * *` | Matches every allowed value for the field. | +| `,` | List Separator | `0,15,30,45` | Specifies a list of individual values. | +| `-` | Range | `9-17` | Specifies an inclusive range of values. | +| `/` | Step | `5-59/15` | Specifies an interval. The step operates on the range it modifies, yielding `5,20,35,50` for this example. | + +### Timezones + +The OCPS specifies "A compliant parser or scheduler MUST interpret the pattern against the implementation's local time." +In layman's terms, this means that a Cron expression evaluates schedules against a local wall-clock, not against +specific moments in time. In practical implementation terms, this means that the expression is always evaluated against a +specific TimeZone, which must be provided at the time of creation. + +```kotlin +// this expression will trigger at 06:00:00 in UTC +CronExpression.parse("0 0 * * SUN", timezone = TimeZone.of("America/Chicago")) + +// this expression will trigger at 00:00:00 in UTC +CronExpression.parse("0 0 * * SUN", timezone = TimeZone.UTC) +``` + +The default timezone is `UTC`, which is the safest server-side default as it will not experience any Daylight Savings +transitions, leading to the most reliable and least surprising scheduling. + +However, it may be useful to provide other timezones for end-user facing scenarios, such as sending a Weekly Summary +email to users at 8am on Sundays at their own local time. Using other timezones will correctly handle things like +Daylight Savings transitions. + +### Example usage + +```kotlin +class ExampleViewModel(coroutineScope: CoroutineScope) : BasicViewModel< + ExampleContract.Inputs, + ExampleContract.Events, + ExampleContract.State + >( + coroutineScope = coroutineScope, + config = BallastViewModelConfiguration.Builder() + .withViewModel( + initialState = ExampleContract.State(), + inputHandler = ExampleInputHandler(), + name = "Example" + ) + .apply { + this += SchedulerInterceptor { + onSchedule( + schedule = CronExpression.parse("0 0 * * SUN").named("Sunday at midnight"), + scheduledInput = { ExampleContract.Inputs.PerformDatabaseMaintenance }, + ) + } + } + .build(), + eventHandler = eventHandler { }, +) +``` + +## Installation + +```kotlin +repositories { + mavenCentral() +} + +// for plain JVM or Android projects +dependencies { + implementation("io.github.copper-leaf:ballast-scheduler-cron:{{ballastVersion}}") +} + +// for multiplatform projects +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + implementation("io.github.copper-leaf:ballast-scheduler-cron:{{ballastVersion}}") + } + } + } +} +``` diff --git a/ballast-scheduler-cron/api/android/ballast-scheduler-cron.api b/ballast-scheduler-cron/api/android/ballast-scheduler-cron.api new file mode 100644 index 00000000..0482206d --- /dev/null +++ b/ballast-scheduler-cron/api/android/ballast-scheduler-cron.api @@ -0,0 +1,199 @@ +public final class com/copperleaf/ballast/scheduler/schedule/CronExpression { + public static final field Companion Lcom/copperleaf/ballast/scheduler/schedule/CronExpression$Companion; + public fun ()V + public fun (Lcom/copperleaf/ballast/scheduler/schedule/MinuteField;Lcom/copperleaf/ballast/scheduler/schedule/HourField;Lcom/copperleaf/ballast/scheduler/schedule/DayOfMonthField;Lcom/copperleaf/ballast/scheduler/schedule/MonthField;Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField;Lkotlinx/datetime/TimeZone;)V + public synthetic fun (Lcom/copperleaf/ballast/scheduler/schedule/MinuteField;Lcom/copperleaf/ballast/scheduler/schedule/HourField;Lcom/copperleaf/ballast/scheduler/schedule/DayOfMonthField;Lcom/copperleaf/ballast/scheduler/schedule/MonthField;Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField;Lkotlinx/datetime/TimeZone;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lcom/copperleaf/ballast/scheduler/schedule/MinuteField; + public final fun component2 ()Lcom/copperleaf/ballast/scheduler/schedule/HourField; + public final fun component3 ()Lcom/copperleaf/ballast/scheduler/schedule/DayOfMonthField; + public final fun component4 ()Lcom/copperleaf/ballast/scheduler/schedule/MonthField; + public final fun component5 ()Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField; + public final fun copy (Lcom/copperleaf/ballast/scheduler/schedule/MinuteField;Lcom/copperleaf/ballast/scheduler/schedule/HourField;Lcom/copperleaf/ballast/scheduler/schedule/DayOfMonthField;Lcom/copperleaf/ballast/scheduler/schedule/MonthField;Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField;Lkotlinx/datetime/TimeZone;)Lcom/copperleaf/ballast/scheduler/schedule/CronExpression; + public static synthetic fun copy$default (Lcom/copperleaf/ballast/scheduler/schedule/CronExpression;Lcom/copperleaf/ballast/scheduler/schedule/MinuteField;Lcom/copperleaf/ballast/scheduler/schedule/HourField;Lcom/copperleaf/ballast/scheduler/schedule/DayOfMonthField;Lcom/copperleaf/ballast/scheduler/schedule/MonthField;Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField;Lkotlinx/datetime/TimeZone;ILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/CronExpression; + public fun equals (Ljava/lang/Object;)Z + public final fun getDayOfMonth ()Lcom/copperleaf/ballast/scheduler/schedule/DayOfMonthField; + public final fun getDayOfWeek ()Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField; + public final fun getHour ()Lcom/copperleaf/ballast/scheduler/schedule/HourField; + public final fun getMinute ()Lcom/copperleaf/ballast/scheduler/schedule/MinuteField; + public final fun getMonth ()Lcom/copperleaf/ballast/scheduler/schedule/MonthField; + public fun hashCode ()I + public final fun nextMatchingInstant (Lkotlin/time/Instant;)Lkotlin/time/Instant; + public fun toString ()Ljava/lang/String; +} + +public final class com/copperleaf/ballast/scheduler/schedule/CronExpression$Companion { + public final fun parse (Ljava/lang/String;Lkotlinx/datetime/TimeZone;)Lcom/copperleaf/ballast/scheduler/schedule/CronExpression; + public static synthetic fun parse$default (Lcom/copperleaf/ballast/scheduler/schedule/CronExpression$Companion;Ljava/lang/String;Lkotlinx/datetime/TimeZone;ILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/CronExpression; +} + +public abstract class com/copperleaf/ballast/scheduler/schedule/CronField { + public abstract fun getMax ()I + public abstract fun getMin ()I + public abstract fun getValues ()Ljava/util/List; + public abstract fun getWildcard ()Z + public final fun matches (I)Z + public final fun nextOrSame (I)Ljava/lang/Integer; +} + +public final class com/copperleaf/ballast/scheduler/schedule/CronSchedule : com/copperleaf/ballast/scheduler/Schedule { + public fun (Lcom/copperleaf/ballast/scheduler/schedule/CronExpression;)V + public final fun component1 ()Lcom/copperleaf/ballast/scheduler/schedule/CronExpression; + public final fun copy (Lcom/copperleaf/ballast/scheduler/schedule/CronExpression;)Lcom/copperleaf/ballast/scheduler/schedule/CronSchedule; + public static synthetic fun copy$default (Lcom/copperleaf/ballast/scheduler/schedule/CronSchedule;Lcom/copperleaf/ballast/scheduler/schedule/CronExpression;ILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/CronSchedule; + public fun equals (Ljava/lang/Object;)Z + public fun generateSchedule (Lkotlin/time/Instant;)Lkotlin/sequences/Sequence; + public final fun getExpression ()Lcom/copperleaf/ballast/scheduler/schedule/CronExpression; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/copperleaf/ballast/scheduler/schedule/DayOfMonthField : com/copperleaf/ballast/scheduler/schedule/CronField { + public static final field Companion Lcom/copperleaf/ballast/scheduler/schedule/DayOfMonthField$Companion; + public static final field MAX_VALUE I + public static final field MIN_VALUE I + public synthetic fun (IILjava/util/List;ZLkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun equals (Ljava/lang/Object;)Z + public fun getMax ()I + public fun getMin ()I + public fun getValues ()Ljava/util/List; + public fun getWildcard ()Z + public fun hashCode ()I +} + +public final class com/copperleaf/ballast/scheduler/schedule/DayOfMonthField$Companion { + public final fun anyValue (I)Lcom/copperleaf/ballast/scheduler/schedule/DayOfMonthField; + public static synthetic fun anyValue$default (Lcom/copperleaf/ballast/scheduler/schedule/DayOfMonthField$Companion;IILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/DayOfMonthField; + public final fun exactValue (I)Lcom/copperleaf/ballast/scheduler/schedule/DayOfMonthField; + public final fun invoke (Ljava/lang/Iterable;Z)Lcom/copperleaf/ballast/scheduler/schedule/DayOfMonthField; + public final fun invoke ([IZ)Lcom/copperleaf/ballast/scheduler/schedule/DayOfMonthField; + public static synthetic fun invoke$default (Lcom/copperleaf/ballast/scheduler/schedule/DayOfMonthField$Companion;Ljava/lang/Iterable;ZILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/DayOfMonthField; + public static synthetic fun invoke$default (Lcom/copperleaf/ballast/scheduler/schedule/DayOfMonthField$Companion;[IZILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/DayOfMonthField; + public final fun range (III)Lcom/copperleaf/ballast/scheduler/schedule/DayOfMonthField; + public static synthetic fun range$default (Lcom/copperleaf/ballast/scheduler/schedule/DayOfMonthField$Companion;IIIILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/DayOfMonthField; + public final fun series (Ljava/lang/Iterable;)Lcom/copperleaf/ballast/scheduler/schedule/DayOfMonthField; + public final fun series ([Lcom/copperleaf/ballast/scheduler/schedule/DayOfMonthField;)Lcom/copperleaf/ballast/scheduler/schedule/DayOfMonthField; +} + +public final class com/copperleaf/ballast/scheduler/schedule/DayOfWeekField : com/copperleaf/ballast/scheduler/schedule/CronField { + public static final field Companion Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField$Companion; + public static final field MAX_PARSED_VALUE I + public static final field MAX_VALUE I + public static final field MIN_VALUE I + public synthetic fun (IILjava/util/List;ZLkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun equals (Ljava/lang/Object;)Z + public fun getMax ()I + public fun getMin ()I + public fun getValues ()Ljava/util/List; + public fun getWildcard ()Z + public fun hashCode ()I +} + +public final class com/copperleaf/ballast/scheduler/schedule/DayOfWeekField$Companion { + public final fun anyValue (I)Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField; + public static synthetic fun anyValue$default (Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField$Companion;IILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField; + public final fun dayOfWeekField_DayOfWeek (Ljava/lang/Iterable;Z)Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField; + public static synthetic fun dayOfWeekField_DayOfWeek$default (Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField$Companion;Ljava/lang/Iterable;ZILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField; + public final fun dayOfWeekField_Int (Ljava/lang/Iterable;Z)Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField; + public static synthetic fun dayOfWeekField_Int$default (Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField$Companion;Ljava/lang/Iterable;ZILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField; + public final fun exactValue (I)Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField; + public final fun exactValue (Lkotlinx/datetime/DayOfWeek;)Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField; + public final fun invoke ([IZ)Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField; + public final fun invoke ([Lkotlinx/datetime/DayOfWeek;Z)Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField; + public static synthetic fun invoke$default (Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField$Companion;[IZILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField; + public static synthetic fun invoke$default (Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField$Companion;[Lkotlinx/datetime/DayOfWeek;ZILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField; + public final fun range (III)Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField; + public static synthetic fun range$default (Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField$Companion;IIIILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField; + public final fun series (Ljava/lang/Iterable;)Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField; + public final fun series ([Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField;)Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField; +} + +public final class com/copperleaf/ballast/scheduler/schedule/HourField : com/copperleaf/ballast/scheduler/schedule/CronField { + public static final field Companion Lcom/copperleaf/ballast/scheduler/schedule/HourField$Companion; + public static final field MAX_VALUE I + public static final field MIN_VALUE I + public synthetic fun (IILjava/util/List;ZLkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun equals (Ljava/lang/Object;)Z + public fun getMax ()I + public fun getMin ()I + public fun getValues ()Ljava/util/List; + public fun getWildcard ()Z + public fun hashCode ()I +} + +public final class com/copperleaf/ballast/scheduler/schedule/HourField$Companion { + public final fun anyValue (I)Lcom/copperleaf/ballast/scheduler/schedule/HourField; + public static synthetic fun anyValue$default (Lcom/copperleaf/ballast/scheduler/schedule/HourField$Companion;IILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/HourField; + public final fun exactValue (I)Lcom/copperleaf/ballast/scheduler/schedule/HourField; + public final fun invoke (Ljava/lang/Iterable;Z)Lcom/copperleaf/ballast/scheduler/schedule/HourField; + public final fun invoke ([IZ)Lcom/copperleaf/ballast/scheduler/schedule/HourField; + public static synthetic fun invoke$default (Lcom/copperleaf/ballast/scheduler/schedule/HourField$Companion;Ljava/lang/Iterable;ZILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/HourField; + public static synthetic fun invoke$default (Lcom/copperleaf/ballast/scheduler/schedule/HourField$Companion;[IZILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/HourField; + public final fun range (III)Lcom/copperleaf/ballast/scheduler/schedule/HourField; + public static synthetic fun range$default (Lcom/copperleaf/ballast/scheduler/schedule/HourField$Companion;IIIILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/HourField; + public final fun series (Ljava/lang/Iterable;)Lcom/copperleaf/ballast/scheduler/schedule/HourField; + public final fun series ([Lcom/copperleaf/ballast/scheduler/schedule/HourField;)Lcom/copperleaf/ballast/scheduler/schedule/HourField; +} + +public final class com/copperleaf/ballast/scheduler/schedule/MinuteField : com/copperleaf/ballast/scheduler/schedule/CronField { + public static final field Companion Lcom/copperleaf/ballast/scheduler/schedule/MinuteField$Companion; + public static final field MAX_VALUE I + public static final field MIN_VALUE I + public synthetic fun (IILjava/util/List;ZLkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun equals (Ljava/lang/Object;)Z + public fun getMax ()I + public fun getMin ()I + public fun getValues ()Ljava/util/List; + public fun getWildcard ()Z + public fun hashCode ()I +} + +public final class com/copperleaf/ballast/scheduler/schedule/MinuteField$Companion { + public final fun anyValue (I)Lcom/copperleaf/ballast/scheduler/schedule/MinuteField; + public static synthetic fun anyValue$default (Lcom/copperleaf/ballast/scheduler/schedule/MinuteField$Companion;IILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/MinuteField; + public final fun exactValue (I)Lcom/copperleaf/ballast/scheduler/schedule/MinuteField; + public final fun invoke (Ljava/lang/Iterable;Z)Lcom/copperleaf/ballast/scheduler/schedule/MinuteField; + public final fun invoke ([IZ)Lcom/copperleaf/ballast/scheduler/schedule/MinuteField; + public static synthetic fun invoke$default (Lcom/copperleaf/ballast/scheduler/schedule/MinuteField$Companion;Ljava/lang/Iterable;ZILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/MinuteField; + public static synthetic fun invoke$default (Lcom/copperleaf/ballast/scheduler/schedule/MinuteField$Companion;[IZILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/MinuteField; + public final fun range (III)Lcom/copperleaf/ballast/scheduler/schedule/MinuteField; + public static synthetic fun range$default (Lcom/copperleaf/ballast/scheduler/schedule/MinuteField$Companion;IIIILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/MinuteField; + public final fun series (Ljava/lang/Iterable;)Lcom/copperleaf/ballast/scheduler/schedule/MinuteField; + public final fun series ([Lcom/copperleaf/ballast/scheduler/schedule/MinuteField;)Lcom/copperleaf/ballast/scheduler/schedule/MinuteField; +} + +public final class com/copperleaf/ballast/scheduler/schedule/MonthField : com/copperleaf/ballast/scheduler/schedule/CronField { + public static final field Companion Lcom/copperleaf/ballast/scheduler/schedule/MonthField$Companion; + public static final field MAX_VALUE I + public static final field MIN_VALUE I + public synthetic fun (IILjava/util/List;ZLkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun equals (Ljava/lang/Object;)Z + public fun getMax ()I + public fun getMin ()I + public fun getValues ()Ljava/util/List; + public fun getWildcard ()Z + public fun hashCode ()I +} + +public final class com/copperleaf/ballast/scheduler/schedule/MonthField$Companion { + public final fun anyValue (I)Lcom/copperleaf/ballast/scheduler/schedule/MonthField; + public static synthetic fun anyValue$default (Lcom/copperleaf/ballast/scheduler/schedule/MonthField$Companion;IILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/MonthField; + public final fun exactValue (I)Lcom/copperleaf/ballast/scheduler/schedule/MonthField; + public final fun exactValue (Lkotlinx/datetime/Month;)Lcom/copperleaf/ballast/scheduler/schedule/MonthField; + public final fun invoke ([IZ)Lcom/copperleaf/ballast/scheduler/schedule/MonthField; + public final fun invoke ([Lkotlinx/datetime/Month;Z)Lcom/copperleaf/ballast/scheduler/schedule/MonthField; + public static synthetic fun invoke$default (Lcom/copperleaf/ballast/scheduler/schedule/MonthField$Companion;[IZILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/MonthField; + public static synthetic fun invoke$default (Lcom/copperleaf/ballast/scheduler/schedule/MonthField$Companion;[Lkotlinx/datetime/Month;ZILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/MonthField; + public final fun monthField_Int (Ljava/lang/Iterable;Z)Lcom/copperleaf/ballast/scheduler/schedule/MonthField; + public static synthetic fun monthField_Int$default (Lcom/copperleaf/ballast/scheduler/schedule/MonthField$Companion;Ljava/lang/Iterable;ZILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/MonthField; + public final fun monthField_Month (Ljava/lang/Iterable;Z)Lcom/copperleaf/ballast/scheduler/schedule/MonthField; + public static synthetic fun monthField_Month$default (Lcom/copperleaf/ballast/scheduler/schedule/MonthField$Companion;Ljava/lang/Iterable;ZILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/MonthField; + public final fun range (III)Lcom/copperleaf/ballast/scheduler/schedule/MonthField; + public static synthetic fun range$default (Lcom/copperleaf/ballast/scheduler/schedule/MonthField$Companion;IIIILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/MonthField; + public final fun series (Ljava/lang/Iterable;)Lcom/copperleaf/ballast/scheduler/schedule/MonthField; + public final fun series ([Lcom/copperleaf/ballast/scheduler/schedule/MonthField;)Lcom/copperleaf/ballast/scheduler/schedule/MonthField; +} + +public final class com/copperleaf/ballast/scheduler/utils/CronAdjustUtilsKt { + public static final fun getNumber (Lkotlinx/datetime/DayOfWeek;)I +} + diff --git a/ballast-scheduler-cron/api/jvm/ballast-scheduler-cron.api b/ballast-scheduler-cron/api/jvm/ballast-scheduler-cron.api new file mode 100644 index 00000000..0482206d --- /dev/null +++ b/ballast-scheduler-cron/api/jvm/ballast-scheduler-cron.api @@ -0,0 +1,199 @@ +public final class com/copperleaf/ballast/scheduler/schedule/CronExpression { + public static final field Companion Lcom/copperleaf/ballast/scheduler/schedule/CronExpression$Companion; + public fun ()V + public fun (Lcom/copperleaf/ballast/scheduler/schedule/MinuteField;Lcom/copperleaf/ballast/scheduler/schedule/HourField;Lcom/copperleaf/ballast/scheduler/schedule/DayOfMonthField;Lcom/copperleaf/ballast/scheduler/schedule/MonthField;Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField;Lkotlinx/datetime/TimeZone;)V + public synthetic fun (Lcom/copperleaf/ballast/scheduler/schedule/MinuteField;Lcom/copperleaf/ballast/scheduler/schedule/HourField;Lcom/copperleaf/ballast/scheduler/schedule/DayOfMonthField;Lcom/copperleaf/ballast/scheduler/schedule/MonthField;Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField;Lkotlinx/datetime/TimeZone;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lcom/copperleaf/ballast/scheduler/schedule/MinuteField; + public final fun component2 ()Lcom/copperleaf/ballast/scheduler/schedule/HourField; + public final fun component3 ()Lcom/copperleaf/ballast/scheduler/schedule/DayOfMonthField; + public final fun component4 ()Lcom/copperleaf/ballast/scheduler/schedule/MonthField; + public final fun component5 ()Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField; + public final fun copy (Lcom/copperleaf/ballast/scheduler/schedule/MinuteField;Lcom/copperleaf/ballast/scheduler/schedule/HourField;Lcom/copperleaf/ballast/scheduler/schedule/DayOfMonthField;Lcom/copperleaf/ballast/scheduler/schedule/MonthField;Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField;Lkotlinx/datetime/TimeZone;)Lcom/copperleaf/ballast/scheduler/schedule/CronExpression; + public static synthetic fun copy$default (Lcom/copperleaf/ballast/scheduler/schedule/CronExpression;Lcom/copperleaf/ballast/scheduler/schedule/MinuteField;Lcom/copperleaf/ballast/scheduler/schedule/HourField;Lcom/copperleaf/ballast/scheduler/schedule/DayOfMonthField;Lcom/copperleaf/ballast/scheduler/schedule/MonthField;Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField;Lkotlinx/datetime/TimeZone;ILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/CronExpression; + public fun equals (Ljava/lang/Object;)Z + public final fun getDayOfMonth ()Lcom/copperleaf/ballast/scheduler/schedule/DayOfMonthField; + public final fun getDayOfWeek ()Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField; + public final fun getHour ()Lcom/copperleaf/ballast/scheduler/schedule/HourField; + public final fun getMinute ()Lcom/copperleaf/ballast/scheduler/schedule/MinuteField; + public final fun getMonth ()Lcom/copperleaf/ballast/scheduler/schedule/MonthField; + public fun hashCode ()I + public final fun nextMatchingInstant (Lkotlin/time/Instant;)Lkotlin/time/Instant; + public fun toString ()Ljava/lang/String; +} + +public final class com/copperleaf/ballast/scheduler/schedule/CronExpression$Companion { + public final fun parse (Ljava/lang/String;Lkotlinx/datetime/TimeZone;)Lcom/copperleaf/ballast/scheduler/schedule/CronExpression; + public static synthetic fun parse$default (Lcom/copperleaf/ballast/scheduler/schedule/CronExpression$Companion;Ljava/lang/String;Lkotlinx/datetime/TimeZone;ILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/CronExpression; +} + +public abstract class com/copperleaf/ballast/scheduler/schedule/CronField { + public abstract fun getMax ()I + public abstract fun getMin ()I + public abstract fun getValues ()Ljava/util/List; + public abstract fun getWildcard ()Z + public final fun matches (I)Z + public final fun nextOrSame (I)Ljava/lang/Integer; +} + +public final class com/copperleaf/ballast/scheduler/schedule/CronSchedule : com/copperleaf/ballast/scheduler/Schedule { + public fun (Lcom/copperleaf/ballast/scheduler/schedule/CronExpression;)V + public final fun component1 ()Lcom/copperleaf/ballast/scheduler/schedule/CronExpression; + public final fun copy (Lcom/copperleaf/ballast/scheduler/schedule/CronExpression;)Lcom/copperleaf/ballast/scheduler/schedule/CronSchedule; + public static synthetic fun copy$default (Lcom/copperleaf/ballast/scheduler/schedule/CronSchedule;Lcom/copperleaf/ballast/scheduler/schedule/CronExpression;ILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/CronSchedule; + public fun equals (Ljava/lang/Object;)Z + public fun generateSchedule (Lkotlin/time/Instant;)Lkotlin/sequences/Sequence; + public final fun getExpression ()Lcom/copperleaf/ballast/scheduler/schedule/CronExpression; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/copperleaf/ballast/scheduler/schedule/DayOfMonthField : com/copperleaf/ballast/scheduler/schedule/CronField { + public static final field Companion Lcom/copperleaf/ballast/scheduler/schedule/DayOfMonthField$Companion; + public static final field MAX_VALUE I + public static final field MIN_VALUE I + public synthetic fun (IILjava/util/List;ZLkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun equals (Ljava/lang/Object;)Z + public fun getMax ()I + public fun getMin ()I + public fun getValues ()Ljava/util/List; + public fun getWildcard ()Z + public fun hashCode ()I +} + +public final class com/copperleaf/ballast/scheduler/schedule/DayOfMonthField$Companion { + public final fun anyValue (I)Lcom/copperleaf/ballast/scheduler/schedule/DayOfMonthField; + public static synthetic fun anyValue$default (Lcom/copperleaf/ballast/scheduler/schedule/DayOfMonthField$Companion;IILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/DayOfMonthField; + public final fun exactValue (I)Lcom/copperleaf/ballast/scheduler/schedule/DayOfMonthField; + public final fun invoke (Ljava/lang/Iterable;Z)Lcom/copperleaf/ballast/scheduler/schedule/DayOfMonthField; + public final fun invoke ([IZ)Lcom/copperleaf/ballast/scheduler/schedule/DayOfMonthField; + public static synthetic fun invoke$default (Lcom/copperleaf/ballast/scheduler/schedule/DayOfMonthField$Companion;Ljava/lang/Iterable;ZILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/DayOfMonthField; + public static synthetic fun invoke$default (Lcom/copperleaf/ballast/scheduler/schedule/DayOfMonthField$Companion;[IZILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/DayOfMonthField; + public final fun range (III)Lcom/copperleaf/ballast/scheduler/schedule/DayOfMonthField; + public static synthetic fun range$default (Lcom/copperleaf/ballast/scheduler/schedule/DayOfMonthField$Companion;IIIILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/DayOfMonthField; + public final fun series (Ljava/lang/Iterable;)Lcom/copperleaf/ballast/scheduler/schedule/DayOfMonthField; + public final fun series ([Lcom/copperleaf/ballast/scheduler/schedule/DayOfMonthField;)Lcom/copperleaf/ballast/scheduler/schedule/DayOfMonthField; +} + +public final class com/copperleaf/ballast/scheduler/schedule/DayOfWeekField : com/copperleaf/ballast/scheduler/schedule/CronField { + public static final field Companion Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField$Companion; + public static final field MAX_PARSED_VALUE I + public static final field MAX_VALUE I + public static final field MIN_VALUE I + public synthetic fun (IILjava/util/List;ZLkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun equals (Ljava/lang/Object;)Z + public fun getMax ()I + public fun getMin ()I + public fun getValues ()Ljava/util/List; + public fun getWildcard ()Z + public fun hashCode ()I +} + +public final class com/copperleaf/ballast/scheduler/schedule/DayOfWeekField$Companion { + public final fun anyValue (I)Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField; + public static synthetic fun anyValue$default (Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField$Companion;IILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField; + public final fun dayOfWeekField_DayOfWeek (Ljava/lang/Iterable;Z)Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField; + public static synthetic fun dayOfWeekField_DayOfWeek$default (Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField$Companion;Ljava/lang/Iterable;ZILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField; + public final fun dayOfWeekField_Int (Ljava/lang/Iterable;Z)Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField; + public static synthetic fun dayOfWeekField_Int$default (Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField$Companion;Ljava/lang/Iterable;ZILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField; + public final fun exactValue (I)Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField; + public final fun exactValue (Lkotlinx/datetime/DayOfWeek;)Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField; + public final fun invoke ([IZ)Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField; + public final fun invoke ([Lkotlinx/datetime/DayOfWeek;Z)Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField; + public static synthetic fun invoke$default (Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField$Companion;[IZILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField; + public static synthetic fun invoke$default (Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField$Companion;[Lkotlinx/datetime/DayOfWeek;ZILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField; + public final fun range (III)Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField; + public static synthetic fun range$default (Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField$Companion;IIIILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField; + public final fun series (Ljava/lang/Iterable;)Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField; + public final fun series ([Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField;)Lcom/copperleaf/ballast/scheduler/schedule/DayOfWeekField; +} + +public final class com/copperleaf/ballast/scheduler/schedule/HourField : com/copperleaf/ballast/scheduler/schedule/CronField { + public static final field Companion Lcom/copperleaf/ballast/scheduler/schedule/HourField$Companion; + public static final field MAX_VALUE I + public static final field MIN_VALUE I + public synthetic fun (IILjava/util/List;ZLkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun equals (Ljava/lang/Object;)Z + public fun getMax ()I + public fun getMin ()I + public fun getValues ()Ljava/util/List; + public fun getWildcard ()Z + public fun hashCode ()I +} + +public final class com/copperleaf/ballast/scheduler/schedule/HourField$Companion { + public final fun anyValue (I)Lcom/copperleaf/ballast/scheduler/schedule/HourField; + public static synthetic fun anyValue$default (Lcom/copperleaf/ballast/scheduler/schedule/HourField$Companion;IILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/HourField; + public final fun exactValue (I)Lcom/copperleaf/ballast/scheduler/schedule/HourField; + public final fun invoke (Ljava/lang/Iterable;Z)Lcom/copperleaf/ballast/scheduler/schedule/HourField; + public final fun invoke ([IZ)Lcom/copperleaf/ballast/scheduler/schedule/HourField; + public static synthetic fun invoke$default (Lcom/copperleaf/ballast/scheduler/schedule/HourField$Companion;Ljava/lang/Iterable;ZILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/HourField; + public static synthetic fun invoke$default (Lcom/copperleaf/ballast/scheduler/schedule/HourField$Companion;[IZILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/HourField; + public final fun range (III)Lcom/copperleaf/ballast/scheduler/schedule/HourField; + public static synthetic fun range$default (Lcom/copperleaf/ballast/scheduler/schedule/HourField$Companion;IIIILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/HourField; + public final fun series (Ljava/lang/Iterable;)Lcom/copperleaf/ballast/scheduler/schedule/HourField; + public final fun series ([Lcom/copperleaf/ballast/scheduler/schedule/HourField;)Lcom/copperleaf/ballast/scheduler/schedule/HourField; +} + +public final class com/copperleaf/ballast/scheduler/schedule/MinuteField : com/copperleaf/ballast/scheduler/schedule/CronField { + public static final field Companion Lcom/copperleaf/ballast/scheduler/schedule/MinuteField$Companion; + public static final field MAX_VALUE I + public static final field MIN_VALUE I + public synthetic fun (IILjava/util/List;ZLkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun equals (Ljava/lang/Object;)Z + public fun getMax ()I + public fun getMin ()I + public fun getValues ()Ljava/util/List; + public fun getWildcard ()Z + public fun hashCode ()I +} + +public final class com/copperleaf/ballast/scheduler/schedule/MinuteField$Companion { + public final fun anyValue (I)Lcom/copperleaf/ballast/scheduler/schedule/MinuteField; + public static synthetic fun anyValue$default (Lcom/copperleaf/ballast/scheduler/schedule/MinuteField$Companion;IILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/MinuteField; + public final fun exactValue (I)Lcom/copperleaf/ballast/scheduler/schedule/MinuteField; + public final fun invoke (Ljava/lang/Iterable;Z)Lcom/copperleaf/ballast/scheduler/schedule/MinuteField; + public final fun invoke ([IZ)Lcom/copperleaf/ballast/scheduler/schedule/MinuteField; + public static synthetic fun invoke$default (Lcom/copperleaf/ballast/scheduler/schedule/MinuteField$Companion;Ljava/lang/Iterable;ZILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/MinuteField; + public static synthetic fun invoke$default (Lcom/copperleaf/ballast/scheduler/schedule/MinuteField$Companion;[IZILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/MinuteField; + public final fun range (III)Lcom/copperleaf/ballast/scheduler/schedule/MinuteField; + public static synthetic fun range$default (Lcom/copperleaf/ballast/scheduler/schedule/MinuteField$Companion;IIIILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/MinuteField; + public final fun series (Ljava/lang/Iterable;)Lcom/copperleaf/ballast/scheduler/schedule/MinuteField; + public final fun series ([Lcom/copperleaf/ballast/scheduler/schedule/MinuteField;)Lcom/copperleaf/ballast/scheduler/schedule/MinuteField; +} + +public final class com/copperleaf/ballast/scheduler/schedule/MonthField : com/copperleaf/ballast/scheduler/schedule/CronField { + public static final field Companion Lcom/copperleaf/ballast/scheduler/schedule/MonthField$Companion; + public static final field MAX_VALUE I + public static final field MIN_VALUE I + public synthetic fun (IILjava/util/List;ZLkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun equals (Ljava/lang/Object;)Z + public fun getMax ()I + public fun getMin ()I + public fun getValues ()Ljava/util/List; + public fun getWildcard ()Z + public fun hashCode ()I +} + +public final class com/copperleaf/ballast/scheduler/schedule/MonthField$Companion { + public final fun anyValue (I)Lcom/copperleaf/ballast/scheduler/schedule/MonthField; + public static synthetic fun anyValue$default (Lcom/copperleaf/ballast/scheduler/schedule/MonthField$Companion;IILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/MonthField; + public final fun exactValue (I)Lcom/copperleaf/ballast/scheduler/schedule/MonthField; + public final fun exactValue (Lkotlinx/datetime/Month;)Lcom/copperleaf/ballast/scheduler/schedule/MonthField; + public final fun invoke ([IZ)Lcom/copperleaf/ballast/scheduler/schedule/MonthField; + public final fun invoke ([Lkotlinx/datetime/Month;Z)Lcom/copperleaf/ballast/scheduler/schedule/MonthField; + public static synthetic fun invoke$default (Lcom/copperleaf/ballast/scheduler/schedule/MonthField$Companion;[IZILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/MonthField; + public static synthetic fun invoke$default (Lcom/copperleaf/ballast/scheduler/schedule/MonthField$Companion;[Lkotlinx/datetime/Month;ZILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/MonthField; + public final fun monthField_Int (Ljava/lang/Iterable;Z)Lcom/copperleaf/ballast/scheduler/schedule/MonthField; + public static synthetic fun monthField_Int$default (Lcom/copperleaf/ballast/scheduler/schedule/MonthField$Companion;Ljava/lang/Iterable;ZILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/MonthField; + public final fun monthField_Month (Ljava/lang/Iterable;Z)Lcom/copperleaf/ballast/scheduler/schedule/MonthField; + public static synthetic fun monthField_Month$default (Lcom/copperleaf/ballast/scheduler/schedule/MonthField$Companion;Ljava/lang/Iterable;ZILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/MonthField; + public final fun range (III)Lcom/copperleaf/ballast/scheduler/schedule/MonthField; + public static synthetic fun range$default (Lcom/copperleaf/ballast/scheduler/schedule/MonthField$Companion;IIIILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/schedule/MonthField; + public final fun series (Ljava/lang/Iterable;)Lcom/copperleaf/ballast/scheduler/schedule/MonthField; + public final fun series ([Lcom/copperleaf/ballast/scheduler/schedule/MonthField;)Lcom/copperleaf/ballast/scheduler/schedule/MonthField; +} + +public final class com/copperleaf/ballast/scheduler/utils/CronAdjustUtilsKt { + public static final fun getNumber (Lkotlinx/datetime/DayOfWeek;)I +} + diff --git a/ballast-scheduler-cron/build.gradle.kts b/ballast-scheduler-cron/build.gradle.kts new file mode 100644 index 00000000..20cb1f34 --- /dev/null +++ b/ballast-scheduler-cron/build.gradle.kts @@ -0,0 +1,42 @@ +plugins { + id("copper-leaf-base") + id("copper-leaf-android-library") + id("copper-leaf-targets") + id("copper-leaf-tests") + id("copper-leaf-lint") + id("copper-leaf-publish") + id("copper-leaf-serialization") +} + +kotlin { + compilerOptions { + optIn.add("kotlin.time.ExperimentalTime") + } + + sourceSets { + val commonMain by getting { + dependencies { + api(project(":ballast-scheduler-core")) + api(libs.kotlinx.coroutines.core) + api(libs.kotlinx.datetime) + api(libs.kudzu.core) + } + } + val commonTest by getting { + dependencies { } + } + + val jvmMain by getting { + dependencies { } + } + val androidMain by getting { + dependencies { } + } + val jsMain by getting { + dependencies { } + } + val iosMain by getting { + dependencies { } + } + } +} diff --git a/ballast-scheduler-cron/gradle.properties b/ballast-scheduler-cron/gradle.properties new file mode 100644 index 00000000..6560229a --- /dev/null +++ b/ballast-scheduler-cron/gradle.properties @@ -0,0 +1,8 @@ +copperleaf.description=Send Inputs at regular, scheduled intervals. + +copperleaf.targets.android=true +copperleaf.targets.jvm=true +copperleaf.targets.ios=true +copperleaf.targets.js=true +copperleaf.targets.wasm.wasi=false +copperleaf.targets.wasm.js=true diff --git a/ballast-scheduler-cron/src/androidMain/AndroidManifest.xml b/ballast-scheduler-cron/src/androidMain/AndroidManifest.xml new file mode 100644 index 00000000..811d7660 --- /dev/null +++ b/ballast-scheduler-cron/src/androidMain/AndroidManifest.xml @@ -0,0 +1,2 @@ + diff --git a/ballast-scheduler-cron/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/parser/CommonFieldParsers.kt b/ballast-scheduler-cron/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/parser/CommonFieldParsers.kt new file mode 100644 index 00000000..1cb837cc --- /dev/null +++ b/ballast-scheduler-cron/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/parser/CommonFieldParsers.kt @@ -0,0 +1,92 @@ +package com.copperleaf.ballast.scheduler.parser + +import com.copperleaf.ballast.scheduler.utils.number +import com.copperleaf.kudzu.KudzuPlatform +import com.copperleaf.kudzu.node.mapped.ValueNode +import com.copperleaf.kudzu.parser.Parser +import com.copperleaf.kudzu.parser.chars.CharInParser +import com.copperleaf.kudzu.parser.chars.DigitParser +import com.copperleaf.kudzu.parser.choice.ExactChoiceParser +import com.copperleaf.kudzu.parser.many.AtLeastParser +import com.copperleaf.kudzu.parser.mapped.MappedParser +import com.copperleaf.kudzu.parser.maybe.MaybeParser +import com.copperleaf.kudzu.parser.sequence.SequenceParser +import com.copperleaf.kudzu.parser.text.BaseTextParser +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.Month +import kotlinx.datetime.number + +@Suppress("UNCHECKED_CAST") +internal object CommonFieldParsers { + + val numberParser: Parser> = MappedParser( + parser = AtLeastParser( + DigitParser(), + minSize = 1 + ), + mapperFunction = { digitsNode -> + digitsNode.text.toInt() + } + ) + val stepValueParser: Parser> = MappedParser( + parser = SequenceParser( + CharInParser('/'), + numberParser + ), + mapperFunction = { (_, _, number) -> + number.value + } + ) + val maybeStepValueParser: Parser> = MappedParser( + parser = MaybeParser( + stepValueParser + ), + mapperFunction = { maybeNode -> + maybeNode.node?.value ?: 1 + } + ) + + val monthNameParser: Parser> = MappedParser( + parser = ExactChoiceParser( + MappedParser(EnumValueParser("JAN")) { Month.JANUARY }, + MappedParser(EnumValueParser("FEB")) { Month.FEBRUARY }, + MappedParser(EnumValueParser("MAR")) { Month.MARCH }, + MappedParser(EnumValueParser("APR")) { Month.APRIL }, + MappedParser(EnumValueParser("MAY")) { Month.MAY }, + MappedParser(EnumValueParser("JUN")) { Month.JUNE }, + MappedParser(EnumValueParser("JUL")) { Month.JULY }, + MappedParser(EnumValueParser("AUG")) { Month.AUGUST }, + MappedParser(EnumValueParser("SEP")) { Month.SEPTEMBER }, + MappedParser(EnumValueParser("OCT")) { Month.OCTOBER }, + MappedParser(EnumValueParser("NOV")) { Month.NOVEMBER }, + MappedParser(EnumValueParser("DEC")) { Month.DECEMBER }, + ), + mapperFunction = { choiceNode -> + (choiceNode.node as ValueNode).value.number + } + ) + + val dayOfWeekNameParser: Parser> = MappedParser( + parser = ExactChoiceParser( + MappedParser(EnumValueParser("SUN")) { DayOfWeek.SUNDAY }, + MappedParser(EnumValueParser("MON")) { DayOfWeek.MONDAY }, + MappedParser(EnumValueParser("TUE")) { DayOfWeek.TUESDAY }, + MappedParser(EnumValueParser("WED")) { DayOfWeek.WEDNESDAY }, + MappedParser(EnumValueParser("THU")) { DayOfWeek.THURSDAY }, + MappedParser(EnumValueParser("FRI")) { DayOfWeek.FRIDAY }, + MappedParser(EnumValueParser("SAT")) { DayOfWeek.SATURDAY }, + ), + mapperFunction = { choiceNode -> + (choiceNode.node as ValueNode).value.number + } + ) + + private class EnumValueParser( + val enumValue: String + ) : BaseTextParser( + isValidChar = { _, char -> KudzuPlatform.isLetter(char) }, + isValidText = { it.equals(enumValue, ignoreCase = true) }, + allowEmptyInput = false, + invalidTextErrorMessage = { "Expected '$enumValue' token, got '$it'" }, + ) +} diff --git a/ballast-scheduler-cron/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/parser/CronExpressionParser.kt b/ballast-scheduler-cron/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/parser/CronExpressionParser.kt new file mode 100644 index 00000000..608d93ba --- /dev/null +++ b/ballast-scheduler-cron/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/parser/CronExpressionParser.kt @@ -0,0 +1,42 @@ +package com.copperleaf.ballast.scheduler.parser + +import com.copperleaf.ballast.scheduler.schedule.CronExpression +import com.copperleaf.kudzu.parser.ParserContext +import com.copperleaf.kudzu.parser.mapped.MappedParser +import com.copperleaf.kudzu.parser.sequence.SequenceParser +import com.copperleaf.kudzu.parser.text.RequiredWhitespaceParser +import kotlinx.datetime.TimeZone + +internal object CronExpressionParser { + + internal val cronExpressionParser = MappedParser( + parser = SequenceParser( + MinuteFieldParser.listOfMinuteFieldParser, + RequiredWhitespaceParser(), + HourFieldParser.listOfHourFieldParser, + RequiredWhitespaceParser(), + DayOfMonthFieldParser.listOfDayOfMonthFieldParser, + RequiredWhitespaceParser(), + MonthFieldParser.listOfMonthFieldParser, + RequiredWhitespaceParser(), + DayOfWeekFieldParser.listOfDayOfWeekFieldParser, + ), + mapperFunction = { (_, minute, _, hour, _, dayOfMonth, _, month, _, dayOfWeek) -> + CronExpression( + minute = minute.value, + hour = hour.value, + dayOfMonth = dayOfMonth.value, + month = month.value, + dayOfWeek = dayOfWeek.value, + ) + } + ) + + internal fun parse(expression: String, timeZone: TimeZone): CronExpression { + val (node, remainingText) = cronExpressionParser.parse(ParserContext.fromString(expression)) + check(remainingText.isEmpty()) { + "Unexpected trailing text after cron expression: '$remainingText'" + } + return node.value.copy(timeZone = timeZone) + } +} diff --git a/ballast-scheduler-cron/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/parser/DayOfMonthFieldParser.kt b/ballast-scheduler-cron/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/parser/DayOfMonthFieldParser.kt new file mode 100644 index 00000000..4c31f15c --- /dev/null +++ b/ballast-scheduler-cron/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/parser/DayOfMonthFieldParser.kt @@ -0,0 +1,68 @@ +package com.copperleaf.ballast.scheduler.parser + +import com.copperleaf.ballast.scheduler.schedule.DayOfMonthField +import com.copperleaf.kudzu.node.choice.Choice3Node +import com.copperleaf.kudzu.node.mapped.ValueNode +import com.copperleaf.kudzu.parser.Parser +import com.copperleaf.kudzu.parser.chars.CharInParser +import com.copperleaf.kudzu.parser.choice.ExactChoiceParser +import com.copperleaf.kudzu.parser.many.SeparatedByParser +import com.copperleaf.kudzu.parser.mapped.MappedParser +import com.copperleaf.kudzu.parser.sequence.SequenceParser + +internal object DayOfMonthFieldParser { + + internal val dayOfMonthValueParser: Parser> = CommonFieldParsers.numberParser + + internal val exactValue: Parser> = MappedParser( + dayOfMonthValueParser + ) { node -> + DayOfMonthField.exactValue(node.value) + } + + internal val rangeValue: Parser> = MappedParser( + SequenceParser( + dayOfMonthValueParser, + CharInParser('-'), + dayOfMonthValueParser, + CommonFieldParsers.maybeStepValueParser + ), + ) { (_, startValue, _, endValue, stepValue) -> + DayOfMonthField.range(min = startValue.value, max = endValue.value, step = stepValue.value) + } + + internal val wildcardValue: Parser> = MappedParser( + SequenceParser( + CharInParser('*'), + CommonFieldParsers.maybeStepValueParser, + ), + ) { (_, _, stepValue) -> + DayOfMonthField.anyValue(step = stepValue.value) + } + + internal val singleDayOfMonthFieldParser: Parser> = MappedParser( + parser = ExactChoiceParser( + wildcardValue, + rangeValue, + exactValue, + ), + mapperFunction = { choiceNode -> + when (choiceNode) { + is Choice3Node.Option1 -> choiceNode.node.value + is Choice3Node.Option2 -> choiceNode.node.value + is Choice3Node.Option3 -> choiceNode.node.value + } + } + ) + + internal val listOfDayOfMonthFieldParser: Parser> = MappedParser( + parser = SeparatedByParser( + term = singleDayOfMonthFieldParser, + separator = CharInParser(','), + ), + mapperFunction = { manyNode -> + val dayOfMonthFieldNodes = manyNode.nodeList.map { it.value } + DayOfMonthField.series(dayOfMonthFieldNodes) + } + ) +} diff --git a/ballast-scheduler-cron/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/parser/DayOfWeekFieldParser.kt b/ballast-scheduler-cron/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/parser/DayOfWeekFieldParser.kt new file mode 100644 index 00000000..e3aadf68 --- /dev/null +++ b/ballast-scheduler-cron/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/parser/DayOfWeekFieldParser.kt @@ -0,0 +1,80 @@ +package com.copperleaf.ballast.scheduler.parser + +import com.copperleaf.ballast.scheduler.schedule.DayOfWeekField +import com.copperleaf.kudzu.node.choice.Choice2Node +import com.copperleaf.kudzu.node.choice.Choice3Node +import com.copperleaf.kudzu.node.mapped.ValueNode +import com.copperleaf.kudzu.parser.Parser +import com.copperleaf.kudzu.parser.chars.CharInParser +import com.copperleaf.kudzu.parser.choice.ExactChoiceParser +import com.copperleaf.kudzu.parser.many.SeparatedByParser +import com.copperleaf.kudzu.parser.mapped.MappedParser +import com.copperleaf.kudzu.parser.sequence.SequenceParser + +internal object DayOfWeekFieldParser { + + internal val dayOfWeekNameOrValueParser: Parser> = MappedParser( + parser = ExactChoiceParser( + CommonFieldParsers.dayOfWeekNameParser, + CommonFieldParsers.numberParser, + ), + mapperFunction = { choiceNode -> + when (choiceNode) { + is Choice2Node.Option1 -> choiceNode.node.value + is Choice2Node.Option2 -> choiceNode.node.value + } + } + ) + + internal val exactValue: Parser> = MappedParser( + dayOfWeekNameOrValueParser + ) { node -> + DayOfWeekField.exactValue(node.value) + } + + internal val rangeValue: Parser> = MappedParser( + SequenceParser( + dayOfWeekNameOrValueParser, + CharInParser('-'), + dayOfWeekNameOrValueParser, + CommonFieldParsers.maybeStepValueParser + ), + ) { (_, startValue, _, endValue, stepValue) -> + DayOfWeekField.range(min = startValue.value, max = endValue.value, step = stepValue.value) + } + + internal val wildcardValue: Parser> = MappedParser( + SequenceParser( + CharInParser('*'), + CommonFieldParsers.maybeStepValueParser, + ), + ) { (_, _, stepValue) -> + DayOfWeekField.anyValue(step = stepValue.value) + } + + internal val singleDayOfWeekFieldParser: Parser> = MappedParser( + parser = ExactChoiceParser( + wildcardValue, + rangeValue, + exactValue, + ), + mapperFunction = { choiceNode -> + when (choiceNode) { + is Choice3Node.Option1 -> choiceNode.node.value + is Choice3Node.Option2 -> choiceNode.node.value + is Choice3Node.Option3 -> choiceNode.node.value + } + } + ) + + internal val listOfDayOfWeekFieldParser: Parser> = MappedParser( + parser = SeparatedByParser( + term = singleDayOfWeekFieldParser, + separator = CharInParser(','), + ), + mapperFunction = { manyNode -> + val dayOfWeekFieldNodes = manyNode.nodeList.map { it.value } + DayOfWeekField.series(dayOfWeekFieldNodes) + } + ) +} diff --git a/ballast-scheduler-cron/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/parser/HourFieldParser.kt b/ballast-scheduler-cron/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/parser/HourFieldParser.kt new file mode 100644 index 00000000..70d669ca --- /dev/null +++ b/ballast-scheduler-cron/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/parser/HourFieldParser.kt @@ -0,0 +1,68 @@ +package com.copperleaf.ballast.scheduler.parser + +import com.copperleaf.ballast.scheduler.schedule.HourField +import com.copperleaf.kudzu.node.choice.Choice3Node +import com.copperleaf.kudzu.node.mapped.ValueNode +import com.copperleaf.kudzu.parser.Parser +import com.copperleaf.kudzu.parser.chars.CharInParser +import com.copperleaf.kudzu.parser.choice.ExactChoiceParser +import com.copperleaf.kudzu.parser.many.SeparatedByParser +import com.copperleaf.kudzu.parser.mapped.MappedParser +import com.copperleaf.kudzu.parser.sequence.SequenceParser + +internal object HourFieldParser { + + internal val hourValueParser: Parser> = CommonFieldParsers.numberParser + + internal val exactValue: Parser> = MappedParser( + hourValueParser + ) { node -> + HourField.exactValue(node.value) + } + + internal val rangeValue: Parser> = MappedParser( + SequenceParser( + hourValueParser, + CharInParser('-'), + hourValueParser, + CommonFieldParsers.maybeStepValueParser + ), + ) { (_, startValue, _, endValue, stepValue) -> + HourField.range(min = startValue.value, max = endValue.value, step = stepValue.value) + } + + internal val wildcardValue: Parser> = MappedParser( + SequenceParser( + CharInParser('*'), + CommonFieldParsers.maybeStepValueParser, + ), + ) { (_, _, stepValue) -> + HourField.anyValue(step = stepValue.value) + } + + internal val singleHourFieldParser: Parser> = MappedParser( + parser = ExactChoiceParser( + wildcardValue, + rangeValue, + exactValue, + ), + mapperFunction = { choiceNode -> + when (choiceNode) { + is Choice3Node.Option1 -> choiceNode.node.value + is Choice3Node.Option2 -> choiceNode.node.value + is Choice3Node.Option3 -> choiceNode.node.value + } + } + ) + + internal val listOfHourFieldParser: Parser> = MappedParser( + parser = SeparatedByParser( + term = singleHourFieldParser, + separator = CharInParser(','), + ), + mapperFunction = { manyNode -> + val hourFieldNodes = manyNode.nodeList.map { it.value } + HourField.series(hourFieldNodes) + } + ) +} diff --git a/ballast-scheduler-cron/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/parser/MinuteFieldParser.kt b/ballast-scheduler-cron/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/parser/MinuteFieldParser.kt new file mode 100644 index 00000000..3d684f83 --- /dev/null +++ b/ballast-scheduler-cron/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/parser/MinuteFieldParser.kt @@ -0,0 +1,68 @@ +package com.copperleaf.ballast.scheduler.parser + +import com.copperleaf.ballast.scheduler.schedule.MinuteField +import com.copperleaf.kudzu.node.choice.Choice3Node +import com.copperleaf.kudzu.node.mapped.ValueNode +import com.copperleaf.kudzu.parser.Parser +import com.copperleaf.kudzu.parser.chars.CharInParser +import com.copperleaf.kudzu.parser.choice.ExactChoiceParser +import com.copperleaf.kudzu.parser.many.SeparatedByParser +import com.copperleaf.kudzu.parser.mapped.MappedParser +import com.copperleaf.kudzu.parser.sequence.SequenceParser + +internal object MinuteFieldParser { + + internal val hourValueParser: Parser> = CommonFieldParsers.numberParser + + internal val exactValue: Parser> = MappedParser( + hourValueParser + ) { node -> + MinuteField.exactValue(node.value) + } + + internal val rangeValue: Parser> = MappedParser( + SequenceParser( + hourValueParser, + CharInParser('-'), + hourValueParser, + CommonFieldParsers.maybeStepValueParser + ), + ) { (_, startValue, _, endValue, stepValue) -> + MinuteField.range(min = startValue.value, max = endValue.value, step = stepValue.value) + } + + internal val wildcardValue: Parser> = MappedParser( + SequenceParser( + CharInParser('*'), + CommonFieldParsers.maybeStepValueParser, + ), + ) { (_, _, stepValue) -> + MinuteField.anyValue(step = stepValue.value) + } + + internal val singleMinuteFieldParser: Parser> = MappedParser( + parser = ExactChoiceParser( + wildcardValue, + rangeValue, + exactValue, + ), + mapperFunction = { choiceNode -> + when (choiceNode) { + is Choice3Node.Option1 -> choiceNode.node.value + is Choice3Node.Option2 -> choiceNode.node.value + is Choice3Node.Option3 -> choiceNode.node.value + } + } + ) + + internal val listOfMinuteFieldParser: Parser> = MappedParser( + parser = SeparatedByParser( + term = singleMinuteFieldParser, + separator = CharInParser(','), + ), + mapperFunction = { manyNode -> + val minuteFieldNodes = manyNode.nodeList.map { it.value } + MinuteField.series(minuteFieldNodes) + } + ) +} diff --git a/ballast-scheduler-cron/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/parser/MonthFieldParser.kt b/ballast-scheduler-cron/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/parser/MonthFieldParser.kt new file mode 100644 index 00000000..56cb769c --- /dev/null +++ b/ballast-scheduler-cron/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/parser/MonthFieldParser.kt @@ -0,0 +1,80 @@ +package com.copperleaf.ballast.scheduler.parser + +import com.copperleaf.ballast.scheduler.schedule.MonthField +import com.copperleaf.kudzu.node.choice.Choice2Node +import com.copperleaf.kudzu.node.choice.Choice3Node +import com.copperleaf.kudzu.node.mapped.ValueNode +import com.copperleaf.kudzu.parser.Parser +import com.copperleaf.kudzu.parser.chars.CharInParser +import com.copperleaf.kudzu.parser.choice.ExactChoiceParser +import com.copperleaf.kudzu.parser.many.SeparatedByParser +import com.copperleaf.kudzu.parser.mapped.MappedParser +import com.copperleaf.kudzu.parser.sequence.SequenceParser + +internal object MonthFieldParser { + + internal val monthNameOrValueParser: Parser> = MappedParser( + parser = ExactChoiceParser( + CommonFieldParsers.monthNameParser, + CommonFieldParsers.numberParser, + ), + mapperFunction = { choiceNode -> + when (choiceNode) { + is Choice2Node.Option1 -> choiceNode.node.value + is Choice2Node.Option2 -> choiceNode.node.value + } + } + ) + + internal val exactValue: Parser> = MappedParser( + monthNameOrValueParser + ) { node -> + MonthField.exactValue(node.value) + } + + internal val rangeValue: Parser> = MappedParser( + SequenceParser( + monthNameOrValueParser, + CharInParser('-'), + monthNameOrValueParser, + CommonFieldParsers.maybeStepValueParser + ), + ) { (_, startValue, _, endValue, stepValue) -> + MonthField.range(min = startValue.value, max = endValue.value, step = stepValue.value) + } + + internal val wildcardValue: Parser> = MappedParser( + SequenceParser( + CharInParser('*'), + CommonFieldParsers.maybeStepValueParser, + ), + ) { (_, _, stepValue) -> + MonthField.anyValue(step = stepValue.value) + } + + internal val singleMonthFieldParser: Parser> = MappedParser( + parser = ExactChoiceParser( + wildcardValue, + rangeValue, + exactValue, + ), + mapperFunction = { choiceNode -> + when (choiceNode) { + is Choice3Node.Option1 -> choiceNode.node.value + is Choice3Node.Option2 -> choiceNode.node.value + is Choice3Node.Option3 -> choiceNode.node.value + } + } + ) + + internal val listOfMonthFieldParser: Parser> = MappedParser( + parser = SeparatedByParser( + term = singleMonthFieldParser, + separator = CharInParser(','), + ), + mapperFunction = { manyNode -> + val monthFieldNodes = manyNode.nodeList.map { it.value } + MonthField.series(monthFieldNodes) + } + ) +} diff --git a/ballast-scheduler-cron/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/schedule/CronExpression.kt b/ballast-scheduler-cron/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/schedule/CronExpression.kt new file mode 100644 index 00000000..ad8c9c03 --- /dev/null +++ b/ballast-scheduler-cron/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/schedule/CronExpression.kt @@ -0,0 +1,183 @@ +package com.copperleaf.ballast.scheduler.schedule + +import com.copperleaf.ballast.scheduler.parser.CronExpressionParser +import com.copperleaf.ballast.scheduler.utils.adjust +import com.copperleaf.ballast.scheduler.utils.number +import com.copperleaf.ballast.scheduler.utils.update +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.atTime +import kotlinx.datetime.number +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Instant + +@Suppress("SimpleRedundantLet") +public data class CronExpression( + val minute: MinuteField = MinuteField.anyValue(), + val hour: HourField = HourField.anyValue(), + val dayOfMonth: DayOfMonthField = DayOfMonthField.anyValue(), + val month: MonthField = MonthField.anyValue(), + val dayOfWeek: DayOfWeekField = DayOfWeekField.anyValue(), + internal val timeZone: TimeZone = TimeZone.UTC, +) { + public fun nextMatchingInstant(current: Instant): Instant { + // start at the top of the next minute, to ensure values are always matching in the future + // relative to `current` if it is also a valid match + var currentTime = current + .plus(1.minutes) + .adjust(timeZone) { + update(second = 0, nanosecond = 0) + } + + while (true) { + val updatedTime = advanceToNextMatchingTime(currentTime) + if (matches(updatedTime)) { + return updatedTime + } + + currentTime = updatedTime.plus(1.minutes) + } + } + + internal fun advanceToNextMatchingTime(after: Instant): Instant { + val time0 = after + val time1 = advanceToNextMatchingMonth(time0) + val time2 = advanceToNextMatchingDay(time1) + val time3 = advanceToNextMatchingHour(time2) + val time4 = advanceToNextMatchingMinute(time3) + + return time4 + } + + internal fun matches(time: Instant): Boolean { + val tDateTime = time.toLocalDateTime(timeZone) + return minute.matches(tDateTime.minute) && + hour.matches(tDateTime.hour) && + dayOfMonth.matches(tDateTime.day) && + month.matches(tDateTime.month.number) && + dayOfWeek.matches(tDateTime.dayOfWeek.number) + } + + internal fun advanceToNextMatchingMonth(time: Instant): Instant { + val tDateTime = time.toLocalDateTime(timeZone) + val next = month.nextOrSame(tDateTime.month.number) + + return if (next == tDateTime.month.number) { + // the current month matches. Don't adjust the month + return time + } else if (next != null) { + // the current month is not valid, but another exists later in the year. Adjust to the start of that month + tDateTime + .update(month = Month.entries[next - 1], day = 1) + .date.atStartOfDayIn(timeZone) + } else { + // no more valid months this year, advance to the first valid month next year + LocalDate( + year = tDateTime.year + 1, + month = Month.JANUARY, + day = 1, + ).atStartOfDayIn(timeZone) + } + } + + internal fun advanceToNextMatchingDay(time: Instant): Instant { + var tInstant = time + + while (true) { + val tDateTime = tInstant.toLocalDateTime(timeZone) + val domMatch = dayOfMonth.matches(tDateTime.day) + val dowMatch = dayOfWeek.matches(tDateTime.dayOfWeek.number) + + // According to standard CRON semantics, when either day-of-month or day-of-week is a wildcard (*), the + // other field is used exclusively. If neither are wildcards, a match occurs when either field matches + val dayMatches = if (dayOfMonth.wildcard) { + dowMatch + } else if (dayOfWeek.wildcard) { + domMatch + } else { + domMatch || dowMatch + } + + if (dayMatches) { + return tInstant + } else { + tInstant = tInstant + .plus(1.days) + .toLocalDateTime(timeZone) + .date + .atStartOfDayIn(timeZone) + } + } + } + + internal fun advanceToNextMatchingHour(time: Instant): Instant { + val tDateTime = time.toLocalDateTime(timeZone) + + val next = hour.nextOrSame(tDateTime.hour) + + return if (next == tDateTime.hour) { + // the current hour matches. Don't adjust the hour + time + } else if (next != null) { + // the current hour is not valid, but another exists later in the day. Adjust to that hour + tDateTime + .let { + it.date.atTime( + hour = next, + minute = 0, + ) + } + .toInstant(timeZone) + } else { + time + .plus(1.days) + .toLocalDateTime(timeZone) + .date + .atStartOfDayIn(timeZone) + } + } + + internal fun advanceToNextMatchingMinute(time: Instant): Instant { + val tDateTime = time.toLocalDateTime(timeZone) + + val next = minute.nextOrSame(tDateTime.minute) + + return if (next == tDateTime.minute) { + // the current minute matches. Don't adjust the minute + time + } else if (next != null) { + // the current minute is not valid, but another exists later in the hour. Adjust to that minute + tDateTime + .let { + it.date.atTime( + hour = tDateTime.hour, + minute = next, + ) + } + .toInstant(timeZone) + } else { + time + .plus(1.hours) + .toLocalDateTime(timeZone) + .let { + it.date.atTime( + hour = it.hour, + minute = 0, + ) + } + .toInstant(timeZone) + } + } + + public companion object { + public fun parse(expression: String, timeZone: TimeZone = TimeZone.UTC): CronExpression { + return CronExpressionParser.parse(expression, timeZone) + } + } +} diff --git a/ballast-scheduler-cron/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/schedule/CronField.kt b/ballast-scheduler-cron/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/schedule/CronField.kt new file mode 100644 index 00000000..98ba9828 --- /dev/null +++ b/ballast-scheduler-cron/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/schedule/CronField.kt @@ -0,0 +1,415 @@ +package com.copperleaf.ballast.scheduler.schedule + +import com.copperleaf.ballast.scheduler.utils.number +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.Month +import kotlinx.datetime.number +import kotlin.jvm.JvmName + +public sealed class CronField { + public abstract val min: Int + public abstract val max: Int + public abstract val wildcard: Boolean + public abstract val values: List + + public fun matches(value: Int): Boolean { + return (value in min..max) && (value in values) + } + + public fun nextOrSame(value: Int): Int? { + if (value !in min..max) return null + return values.firstOrNull { it >= value } + } +} + +public class MonthField private constructor( + override val min: Int, + override val max: Int, + override val values: List, + override val wildcard: Boolean = false, +) : CronField() { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is MonthField) return false + + if (min != other.min) return false + if (max != other.max) return false + if (wildcard != other.wildcard) return false + if (values != other.values) return false + + return true + } + + override fun hashCode(): Int { + var result = min + result = 31 * result + max + result = 31 * result + wildcard.hashCode() + result = 31 * result + values.hashCode() + return result + } + + public companion object { + public const val MIN_VALUE: Int = 1 + public const val MAX_VALUE: Int = 12 + + @JvmName("monthField_Int") + public operator fun invoke(months: Iterable, wildcard: Boolean = false): MonthField { + val values = months.distinct().sorted() + require(values.isNotEmpty()) { + "Month values must not be empty" + } + require(values.all { it in 1..12 }) { + "Month values must all be between 1 and 12, got $values" + } + return MonthField(1, 12, values, wildcard) + } + + public operator fun invoke(vararg months: Int, wildcard: Boolean = false): MonthField { + return MonthField(months.toList(), wildcard) + } + + @JvmName("monthField_Month") + public operator fun invoke(months: Iterable, wildcard: Boolean = false): MonthField { + return MonthField(months.map { it.number }, wildcard) + } + + public operator fun invoke(vararg months: Month, wildcard: Boolean = false): MonthField { + return MonthField(months.map { it.number }, wildcard) + } + + public fun anyValue(step: Int = 1): MonthField { + return MonthField(MIN_VALUE..MAX_VALUE step step, wildcard = true) + } + + public fun exactValue(value: Int): MonthField { + return MonthField(listOf(value), wildcard = false) + } + + public fun exactValue(value: Month): MonthField { + return MonthField(listOf(value.number), wildcard = false) + } + + public fun range(min: Int, max: Int, step: Int = 1): MonthField { + return MonthField(min..max step step, wildcard = false) + } + + public fun series(vararg fields: MonthField): MonthField { + val allValues: List = fields.flatMap { it.values } + val wildcard: Boolean = fields.all { it.wildcard } + + return MonthField(allValues, wildcard = wildcard) + } + + public fun series(fields: Iterable): MonthField { + val allValues: List = fields.flatMap { it.values } + val wildcard: Boolean = fields.all { it.wildcard } + + return MonthField(allValues, wildcard = wildcard) + } + } +} + +public class DayOfMonthField private constructor( + override val min: Int, + override val max: Int, + override val values: List, + override val wildcard: Boolean = false, +) : CronField() { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is DayOfMonthField) return false + + if (min != other.min) return false + if (max != other.max) return false + if (wildcard != other.wildcard) return false + if (values != other.values) return false + + return true + } + + override fun hashCode(): Int { + var result = min + result = 31 * result + max + result = 31 * result + wildcard.hashCode() + result = 31 * result + values.hashCode() + return result + } + + public companion object { + public const val MIN_VALUE: Int = 1 + public const val MAX_VALUE: Int = 31 + + public operator fun invoke(days: Iterable, wildcard: Boolean = false): DayOfMonthField { + val values = days.distinct().sorted() + require(values.all { it in MIN_VALUE..MAX_VALUE }) { + "Day-of-month values must all be between $MIN_VALUE and $MAX_VALUE, got $values" + } + return DayOfMonthField(MIN_VALUE, MAX_VALUE, values, wildcard) + } + + public operator fun invoke(vararg days: Int, wildcard: Boolean = false): DayOfMonthField { + return DayOfMonthField(days.toList(), wildcard) + } + + public fun anyValue(step: Int = 1): DayOfMonthField { + return DayOfMonthField(MIN_VALUE..MAX_VALUE step step, wildcard = true) + } + + public fun exactValue(value: Int): DayOfMonthField { + return DayOfMonthField(listOf(value), wildcard = false) + } + + public fun range(min: Int, max: Int, step: Int = 1): DayOfMonthField { + return DayOfMonthField(min..max step step, wildcard = false) + } + + public fun series(vararg fields: DayOfMonthField): DayOfMonthField { + val allValues: List = fields.flatMap { it.values } + val wildcard: Boolean = fields.all { it.wildcard } + + return DayOfMonthField(allValues, wildcard = wildcard) + } + + public fun series(fields: Iterable): DayOfMonthField { + val allValues: List = fields.flatMap { it.values } + val wildcard: Boolean = fields.all { it.wildcard } + + return DayOfMonthField(allValues, wildcard = wildcard) + } + } +} + +public class DayOfWeekField private constructor( + override val min: Int, + override val max: Int, + override val values: List, + override val wildcard: Boolean, +) : CronField() { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is DayOfWeekField) return false + + if (min != other.min) return false + if (max != other.max) return false + if (wildcard != other.wildcard) return false + if (values != other.values) return false + + return true + } + + override fun hashCode(): Int { + var result = min + result = 31 * result + max + result = 31 * result + wildcard.hashCode() + result = 31 * result + values.hashCode() + return result + } + + public companion object { + public const val MIN_VALUE: Int = 0 + public const val MAX_VALUE: Int = 6 + + // Aceptable values for parsing, since both 0 and 7 can represent Sunday. Internally, 7 is normalized to 0 + public const val MAX_PARSED_VALUE: Int = 7 + + @JvmName("dayOfWeekField_Int") + public operator fun invoke(days: Iterable, wildcard: Boolean = false): DayOfWeekField { + val values = days + .map { if (it == 7) 0 else it } + .distinct() + .sorted() + require(values.all { it in MIN_VALUE..MAX_PARSED_VALUE }) { + "Day-of-week values must all be between $MIN_VALUE and $MAX_PARSED_VALUE, got $values" + } + return DayOfWeekField(MIN_VALUE, MAX_VALUE, values, wildcard) + } + + public operator fun invoke(vararg days: Int, wildcard: Boolean = false): DayOfWeekField { + return DayOfWeekField(days.toList(), wildcard) + } + + @JvmName("dayOfWeekField_DayOfWeek") + public operator fun invoke(days: Iterable, wildcard: Boolean = false): DayOfWeekField { + return DayOfWeekField(days.map { it.number }, wildcard) + } + + public operator fun invoke(vararg days: DayOfWeek, wildcard: Boolean = false): DayOfWeekField { + return DayOfWeekField(days.map { it.number }, wildcard) + } + + public fun anyValue(step: Int = 1): DayOfWeekField { + return DayOfWeekField(MIN_VALUE..MAX_VALUE step step, wildcard = true) + } + + public fun exactValue(value: Int): DayOfWeekField { + return DayOfWeekField(listOf(value), wildcard = false) + } + + public fun exactValue(value: DayOfWeek): DayOfWeekField { + return DayOfWeekField(listOf(value.number), wildcard = false) + } + + public fun range(min: Int, max: Int, step: Int = 1): DayOfWeekField { + return DayOfWeekField(min..max step step, wildcard = false) + } + + public fun series(vararg fields: DayOfWeekField): DayOfWeekField { + val allValues: List = fields.flatMap { it.values } + val wildcard: Boolean = fields.all { it.wildcard } + + return DayOfWeekField(allValues, wildcard = wildcard) + } + + public fun series(fields: Iterable): DayOfWeekField { + val allValues: List = fields.flatMap { it.values } + val wildcard: Boolean = fields.all { it.wildcard } + + return DayOfWeekField(allValues, wildcard = wildcard) + } + } +} + +public class HourField private constructor( + override val min: Int, + override val max: Int, + override val values: List, + override val wildcard: Boolean, +) : CronField() { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is HourField) return false + + if (min != other.min) return false + if (max != other.max) return false + if (wildcard != other.wildcard) return false + if (values != other.values) return false + + return true + } + + override fun hashCode(): Int { + var result = min + result = 31 * result + max + result = 31 * result + wildcard.hashCode() + result = 31 * result + values.hashCode() + return result + } + + public companion object { + public const val MIN_VALUE: Int = 0 + public const val MAX_VALUE: Int = 23 + + public operator fun invoke(hours: Iterable, wildcard: Boolean = false): HourField { + val values = hours.distinct().sorted() + require(values.all { it in MIN_VALUE..MAX_VALUE }) { + "Hour values must all be between $MIN_VALUE and $MAX_VALUE, got $values" + } + return HourField(MIN_VALUE, MAX_VALUE, values, wildcard) + } + + public operator fun invoke(vararg hours: Int, wildcard: Boolean = false): HourField { + return HourField(hours.toList(), wildcard) + } + + public fun anyValue(step: Int = 1): HourField { + return HourField(MIN_VALUE..MAX_VALUE step step, wildcard = true) + } + + public fun exactValue(value: Int): HourField { + return HourField(listOf(value), wildcard = false) + } + + public fun range(min: Int, max: Int, step: Int = 1): HourField { + return HourField(min..max step step, wildcard = false) + } + + public fun series(vararg fields: HourField): HourField { + val allValues: List = fields.flatMap { it.values } + val wildcard: Boolean = fields.all { it.wildcard } + + return HourField(allValues, wildcard = wildcard) + } + + public fun series(fields: Iterable): HourField { + val allValues: List = fields.flatMap { it.values } + val wildcard: Boolean = fields.all { it.wildcard } + + return HourField(allValues, wildcard = wildcard) + } + } +} + +public class MinuteField private constructor( + override val min: Int, + override val max: Int, + override val values: List, + override val wildcard: Boolean, +) : CronField() { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is MinuteField) return false + + if (min != other.min) return false + if (max != other.max) return false + if (wildcard != other.wildcard) return false + if (values != other.values) return false + + return true + } + + override fun hashCode(): Int { + var result = min + result = 31 * result + max + result = 31 * result + wildcard.hashCode() + result = 31 * result + values.hashCode() + return result + } + + public companion object { + public const val MIN_VALUE: Int = 0 + public const val MAX_VALUE: Int = 59 + + public operator fun invoke(minutes: Iterable, wildcard: Boolean = false): MinuteField { + val values = minutes.distinct().sorted() + require(values.all { it in MIN_VALUE..MAX_VALUE }) { + "Minute values must all be between $MIN_VALUE and $MAX_VALUE, got $values" + } + return MinuteField(MIN_VALUE, MAX_VALUE, values, wildcard) + } + + public operator fun invoke(vararg minutes: Int, wildcard: Boolean = false): MinuteField { + return MinuteField(minutes.toList(), wildcard) + } + + public fun anyValue(step: Int = 1): MinuteField { + return MinuteField(MIN_VALUE..MAX_VALUE step step, wildcard = true) + } + + public fun exactValue(value: Int): MinuteField { + return MinuteField(listOf(value), wildcard = false) + } + + public fun range(min: Int, max: Int, step: Int = 1): MinuteField { + return MinuteField(min..max step step, wildcard = false) + } + + public fun series(vararg fields: MinuteField): MinuteField { + val allValues: List = fields.flatMap { it.values } + val wildcard: Boolean = fields.all { it.wildcard } + + return MinuteField(allValues, wildcard = wildcard) + } + + public fun series(fields: Iterable): MinuteField { + val allValues: List = fields.flatMap { it.values } + val wildcard: Boolean = fields.all { it.wildcard } + + return MinuteField(allValues, wildcard = wildcard) + } + } +} diff --git a/ballast-scheduler-cron/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/schedule/CronSchedule.kt b/ballast-scheduler-cron/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/schedule/CronSchedule.kt new file mode 100644 index 00000000..0b113b3e --- /dev/null +++ b/ballast-scheduler-cron/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/schedule/CronSchedule.kt @@ -0,0 +1,17 @@ +package com.copperleaf.ballast.scheduler.schedule + +import com.copperleaf.ballast.scheduler.Schedule +import kotlin.time.Instant + +public data class CronSchedule( + val expression: CronExpression, +) : Schedule { + + override fun generateSchedule(start: Instant): Sequence { + return generateSequence( + expression.nextMatchingInstant(start) + ) { prev -> + expression.nextMatchingInstant(prev) + } + } +} diff --git a/ballast-scheduler-cron/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/utils/cronAdjustUtils.kt b/ballast-scheduler-cron/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/utils/cronAdjustUtils.kt new file mode 100644 index 00000000..421612e6 --- /dev/null +++ b/ballast-scheduler-cron/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/utils/cronAdjustUtils.kt @@ -0,0 +1,44 @@ +package com.copperleaf.ballast.scheduler.utils + +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Instant + +internal fun Instant.adjust(timeZone: TimeZone, block: LocalDateTime.() -> LocalDateTime): Instant { + return this.toLocalDateTime(timeZone).block().toInstant(timeZone) +} + +internal fun LocalDateTime.update( + year: Int = this.year, + month: Month = this.month, + day: Int = this.day, + hour: Int = this.hour, + minute: Int = this.minute, + second: Int = this.second, + nanosecond: Int = this.nanosecond, +): LocalDateTime { + return LocalDateTime( + year = year, + month = month, + day = day, + hour = hour, + minute = minute, + second = second, + nanosecond = nanosecond, + ) +} + +public val DayOfWeek.number: Int + get() = when (this) { + DayOfWeek.SUNDAY -> 0 + DayOfWeek.MONDAY -> 1 + DayOfWeek.TUESDAY -> 2 + DayOfWeek.WEDNESDAY -> 3 + DayOfWeek.THURSDAY -> 4 + DayOfWeek.FRIDAY -> 5 + DayOfWeek.SATURDAY -> 6 + } diff --git a/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/parser/CommonFieldParserTest.kt b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/parser/CommonFieldParserTest.kt new file mode 100644 index 00000000..97a5ca67 --- /dev/null +++ b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/parser/CommonFieldParserTest.kt @@ -0,0 +1,135 @@ +package com.copperleaf.ballast.scheduler.parser + +import com.copperleaf.kudzu.node.mapped.ValueNode +import com.copperleaf.kudzu.parser.Parser +import com.copperleaf.kudzu.parser.ParserContext +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class CommonFieldParserTest { + + @Test + fun numberParserTest() { + assertParseSuccess(CommonFieldParsers.numberParser, "4", 4) + assertParseSuccess(CommonFieldParsers.numberParser, "8", 8) + assertParseSuccess(CommonFieldParsers.numberParser, "15", 15) + assertParseSuccess(CommonFieldParsers.numberParser, "16", 16) + assertParseSuccess(CommonFieldParsers.numberParser, "23", 23) + assertParseSuccess(CommonFieldParsers.numberParser, "42", 42) + assertParseSuccess(CommonFieldParsers.numberParser, "0", 0) + + assertParseThrows(CommonFieldParsers.numberParser, "-1") + assertParseThrows(CommonFieldParsers.numberParser, "") + } + + @Test + fun stepValueParserTest() { + assertParseSuccess(CommonFieldParsers.stepValueParser, "/4", 4) + assertParseSuccess(CommonFieldParsers.stepValueParser, "/8", 8) + assertParseSuccess(CommonFieldParsers.stepValueParser, "/15", 15) + assertParseSuccess(CommonFieldParsers.stepValueParser, "/16", 16) + assertParseSuccess(CommonFieldParsers.stepValueParser, "/23", 23) + assertParseSuccess(CommonFieldParsers.stepValueParser, "/42", 42) + + assertParseThrows(CommonFieldParsers.stepValueParser, "/-1") + assertParseThrows(CommonFieldParsers.stepValueParser, "/") + assertParseThrows(CommonFieldParsers.stepValueParser, "1") + } + + @Test + fun maybeStepValueParserTest() { + assertParseSuccess(CommonFieldParsers.maybeStepValueParser, "/4", 4) + assertParseSuccess(CommonFieldParsers.maybeStepValueParser, "/8", 8) + assertParseSuccess(CommonFieldParsers.maybeStepValueParser, "/15", 15) + assertParseSuccess(CommonFieldParsers.maybeStepValueParser, "/16", 16) + assertParseSuccess(CommonFieldParsers.maybeStepValueParser, "/23", 23) + assertParseSuccess(CommonFieldParsers.maybeStepValueParser, "/42", 42) + + assertParseThrows(CommonFieldParsers.maybeStepValueParser, "/-1") + assertParseThrows(CommonFieldParsers.maybeStepValueParser, "/") + assertParseIncomplete(CommonFieldParsers.maybeStepValueParser, "1") + assertParseIncomplete(CommonFieldParsers.maybeStepValueParser, "2") + } + + @Test + fun monthNameParserTest() { + assertParseSuccess(CommonFieldParsers.monthNameParser, "jan", 1) + assertParseSuccess(CommonFieldParsers.monthNameParser, "feb", 2) + assertParseSuccess(CommonFieldParsers.monthNameParser, "mar", 3) + assertParseSuccess(CommonFieldParsers.monthNameParser, "apr", 4) + assertParseSuccess(CommonFieldParsers.monthNameParser, "may", 5) + assertParseSuccess(CommonFieldParsers.monthNameParser, "jun", 6) + assertParseSuccess(CommonFieldParsers.monthNameParser, "jul", 7) + assertParseSuccess(CommonFieldParsers.monthNameParser, "aug", 8) + assertParseSuccess(CommonFieldParsers.monthNameParser, "sep", 9) + assertParseSuccess(CommonFieldParsers.monthNameParser, "oct", 10) + assertParseSuccess(CommonFieldParsers.monthNameParser, "nov", 11) + assertParseSuccess(CommonFieldParsers.monthNameParser, "dec", 12) + + assertParseSuccess(CommonFieldParsers.monthNameParser, "JAN", 1) + assertParseSuccess(CommonFieldParsers.monthNameParser, "FEB", 2) + assertParseSuccess(CommonFieldParsers.monthNameParser, "MAR", 3) + assertParseSuccess(CommonFieldParsers.monthNameParser, "APR", 4) + assertParseSuccess(CommonFieldParsers.monthNameParser, "MAY", 5) + assertParseSuccess(CommonFieldParsers.monthNameParser, "JUN", 6) + assertParseSuccess(CommonFieldParsers.monthNameParser, "JUL", 7) + assertParseSuccess(CommonFieldParsers.monthNameParser, "AUG", 8) + assertParseSuccess(CommonFieldParsers.monthNameParser, "SEP", 9) + assertParseSuccess(CommonFieldParsers.monthNameParser, "OCT", 10) + assertParseSuccess(CommonFieldParsers.monthNameParser, "NOV", 11) + assertParseSuccess(CommonFieldParsers.monthNameParser, "DEC", 12) + } + + @Test + fun dayOfWeekNameParser() { + assertParseSuccess(CommonFieldParsers.dayOfWeekNameParser, "sun", 0) + assertParseSuccess(CommonFieldParsers.dayOfWeekNameParser, "mon", 1) + assertParseSuccess(CommonFieldParsers.dayOfWeekNameParser, "tue", 2) + assertParseSuccess(CommonFieldParsers.dayOfWeekNameParser, "wed", 3) + assertParseSuccess(CommonFieldParsers.dayOfWeekNameParser, "thu", 4) + assertParseSuccess(CommonFieldParsers.dayOfWeekNameParser, "fri", 5) + assertParseSuccess(CommonFieldParsers.dayOfWeekNameParser, "sat", 6) + + assertParseSuccess(CommonFieldParsers.dayOfWeekNameParser, "SUN", 0) + assertParseSuccess(CommonFieldParsers.dayOfWeekNameParser, "MON", 1) + assertParseSuccess(CommonFieldParsers.dayOfWeekNameParser, "TUE", 2) + assertParseSuccess(CommonFieldParsers.dayOfWeekNameParser, "WED", 3) + assertParseSuccess(CommonFieldParsers.dayOfWeekNameParser, "THU", 4) + assertParseSuccess(CommonFieldParsers.dayOfWeekNameParser, "FRI", 5) + assertParseSuccess(CommonFieldParsers.dayOfWeekNameParser, "SAT", 6) + } + +// utils +// --------------------------------------------------------------------------------------------------------------------- + + private fun assertParseSuccess( + parser: Parser>, + input: String, + expectedValues: T, + ) { + val (node, remainingText) = parser.parse(ParserContext.fromString(input)) + assertTrue { remainingText.isEmpty() } + assertEquals( + actual = node.value, + expected = expectedValues, + ) + } + + private fun assertParseThrows( + parser: Parser>, + input: String, + ) { + assertFails { parser.parse(ParserContext.fromString(input)) } + } + + private fun assertParseIncomplete( + parser: Parser>, + input: String, + ) { + val (_, remainingText) = parser.parse(ParserContext.fromString(input)) + assertFalse { remainingText.isEmpty() } + } +} diff --git a/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/parser/CronExpressionParserTest.kt b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/parser/CronExpressionParserTest.kt new file mode 100644 index 00000000..2a1c4ccd --- /dev/null +++ b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/parser/CronExpressionParserTest.kt @@ -0,0 +1,94 @@ +package com.copperleaf.ballast.scheduler.parser + +import com.copperleaf.ballast.scheduler.schedule.CronExpression +import com.copperleaf.ballast.scheduler.schedule.DayOfMonthField +import com.copperleaf.ballast.scheduler.schedule.DayOfWeekField +import com.copperleaf.ballast.scheduler.schedule.HourField +import com.copperleaf.ballast.scheduler.schedule.MinuteField +import com.copperleaf.ballast.scheduler.schedule.MonthField +import com.copperleaf.kudzu.node.mapped.ValueNode +import com.copperleaf.kudzu.parser.Parser +import com.copperleaf.kudzu.parser.ParserContext +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class CronExpressionParserTest { + + @Test + fun cronExpressionParserTest() { + assertParseSuccess("* * * * *") { + CronExpression() + } + assertParseSuccess("* */4 * * *") { + CronExpression(hour = HourField.anyValue(step = 4)) + } + assertParseSuccess("* 6-12/2 * * *") { + CronExpression(hour = HourField.range(6, 12, 2)) + } + assertParseSuccess("*/15 6-12/2 15 JAN,JUN-SEP/2,DEC SUN,TUE-THU/2,SAT") { + CronExpression( + minute = MinuteField.anyValue(step = 15), + hour = HourField.range(6, 12, 2), + dayOfMonth = DayOfMonthField.exactValue(15), + month = MonthField(1, 6, 8, 12, wildcard = false), + dayOfWeek = DayOfWeekField(0, 2, 4, 6, wildcard = false), + ) + } + } + +// utils +// --------------------------------------------------------------------------------------------------------------------- + + private fun assertParseSuccess( + input: String, + expected: () -> CronExpression, + ) { + val (node, remainingText) = CronExpressionParser.cronExpressionParser.parse(ParserContext.fromString(input)) + assertTrue { remainingText.isEmpty() } + assertEquals( + actual = node.value, + expected = expected(), + ) + + assertEquals( + actual = CronExpression.parse(input), + expected = expected(), + ) + } + + private fun assertParseThrows( + parser: Parser>, + input: String, + ) { + assertFails { parser.parse(ParserContext.fromString(input)) } + } + + private fun assertParseIncomplete( + parser: Parser>, + input: String, + ) { + val (_, remainingText) = parser.parse(ParserContext.fromString(input)) + assertFalse { remainingText.isEmpty() } + } + + private fun assertMonthFieldParserSuccess( + parser: Parser>, + input: String, + expectedValues: List, + expectedWildcard: Boolean = false, + ) { + val (node, remainingText) = parser.parse(ParserContext.fromString(input)) + assertTrue { remainingText.isEmpty() } + assertEquals( + actual = node.value.values, + expected = expectedValues, + ) + assertEquals( + actual = node.value.wildcard, + expected = expectedWildcard, + ) + } +} diff --git a/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/parser/DayOfMonthFieldParserTest.kt b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/parser/DayOfMonthFieldParserTest.kt new file mode 100644 index 00000000..e15659f8 --- /dev/null +++ b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/parser/DayOfMonthFieldParserTest.kt @@ -0,0 +1,131 @@ +package com.copperleaf.ballast.scheduler.parser + +import com.copperleaf.ballast.scheduler.schedule.DayOfMonthField +import com.copperleaf.kudzu.node.mapped.ValueNode +import com.copperleaf.kudzu.parser.Parser +import com.copperleaf.kudzu.parser.ParserContext +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DayOfMonthFieldParserTest { + + @Test + fun exactValueTest() { + assertDayOfMonthFieldParserSuccess(DayOfMonthFieldParser.exactValue, "1", listOf(1), false) + assertDayOfMonthFieldParserSuccess(DayOfMonthFieldParser.exactValue, "2", listOf(2), false) + } + + @Test + fun rangeValueTest() { + assertDayOfMonthFieldParserSuccess(DayOfMonthFieldParser.rangeValue, "2-4", listOf(2, 3, 4), false) + assertDayOfMonthFieldParserSuccess(DayOfMonthFieldParser.rangeValue, "2-10/2", listOf(2, 4, 6, 8, 10), false) + } + + @Test + fun wildcardValueTest() { + assertDayOfMonthFieldParserSuccess(DayOfMonthFieldParser.wildcardValue, "*", listOf( + 1, 2, 3, 4, 5, + 6, 7, 8, 9, 10, + 11, 12, 13, 14, 15, + 16, 17, 18, 19, 20, + 21, 22, 23, 24, 25, + 26, 27, 28, 29, 30, 31 + ), true) + assertDayOfMonthFieldParserSuccess(DayOfMonthFieldParser.wildcardValue, "*/4", listOf(1, 5, 9, 13, 17, 21, 25, 29), true) + } + + @Test + fun singleDayOfMonthFieldParserTest() { + assertDayOfMonthFieldParserSuccess(DayOfMonthFieldParser.singleDayOfMonthFieldParser, "1", listOf(1), false) + assertDayOfMonthFieldParserSuccess(DayOfMonthFieldParser.singleDayOfMonthFieldParser, "2", listOf(2), false) + + assertDayOfMonthFieldParserSuccess(DayOfMonthFieldParser.singleDayOfMonthFieldParser, "2-4", listOf(2, 3, 4), false) + assertDayOfMonthFieldParserSuccess(DayOfMonthFieldParser.singleDayOfMonthFieldParser, "2-10/2", listOf(2, 4, 6, 8, 10), false) + + assertDayOfMonthFieldParserSuccess(DayOfMonthFieldParser.singleDayOfMonthFieldParser, "*", listOf( + 1, 2, 3, 4, 5, + 6, 7, 8, 9, 10, + 11, 12, 13, 14, 15, + 16, 17, 18, 19, 20, + 21, 22, 23, 24, 25, + 26, 27, 28, 29, 30, 31 + ), true) + assertDayOfMonthFieldParserSuccess(DayOfMonthFieldParser.singleDayOfMonthFieldParser, "*/4", listOf(1, 5, 9, 13, 17, 21, 25, 29), true) + } + + @Test + fun listOfDayOfMonthFieldParserTest() { + assertDayOfMonthFieldParserSuccess(DayOfMonthFieldParser.listOfDayOfMonthFieldParser, "1", listOf(1), false) + assertDayOfMonthFieldParserSuccess(DayOfMonthFieldParser.listOfDayOfMonthFieldParser, "2", listOf(2), false) + + assertDayOfMonthFieldParserSuccess(DayOfMonthFieldParser.listOfDayOfMonthFieldParser, "2-4", listOf(2, 3, 4), false) + assertDayOfMonthFieldParserSuccess(DayOfMonthFieldParser.listOfDayOfMonthFieldParser, "2-10/2", listOf(2, 4, 6, 8, 10), false) + + assertDayOfMonthFieldParserSuccess(DayOfMonthFieldParser.listOfDayOfMonthFieldParser, "*", listOf( + 1, 2, 3, 4, 5, + 6, 7, 8, 9, 10, + 11, 12, 13, 14, 15, + 16, 17, 18, 19, 20, + 21, 22, 23, 24, 25, + 26, 27, 28, 29, 30, 31 + ), true) + assertDayOfMonthFieldParserSuccess(DayOfMonthFieldParser.listOfDayOfMonthFieldParser, "*/4", listOf(1, 5, 9, 13, 17, 21, 25, 29), true) + + assertDayOfMonthFieldParserSuccess(DayOfMonthFieldParser.listOfDayOfMonthFieldParser, "1,5,8", listOf(1, 5, 8), false) + assertDayOfMonthFieldParserSuccess(DayOfMonthFieldParser.listOfDayOfMonthFieldParser, "1,3-5,8-12/2", listOf(1, 3, 4, 5, 8, 10, 12), false) + assertDayOfMonthFieldParserSuccess(DayOfMonthFieldParser.listOfDayOfMonthFieldParser, "4,3,1,2", listOf(1, 2, 3, 4), false) + assertDayOfMonthFieldParserSuccess(DayOfMonthFieldParser.listOfDayOfMonthFieldParser, "4,*/4,*/6", listOf(1, 4, 5, 7, 9, 13, 17, 19, 21, 25, 29, 31), false) + } + +// utils +// --------------------------------------------------------------------------------------------------------------------- + + private fun assertParseSuccess( + parser: Parser>, + input: String, + expectedValues: T, + ) { + val (node, remainingText) = parser.parse(ParserContext.fromString(input)) + assertTrue { remainingText.isEmpty() } + assertEquals( + actual = node.value, + expected = expectedValues, + ) + } + + private fun assertParseThrows( + parser: Parser>, + input: String, + ) { + assertFails { parser.parse(ParserContext.fromString(input)) } + } + + private fun assertParseIncomplete( + parser: Parser>, + input: String, + ) { + val (_, remainingText) = parser.parse(ParserContext.fromString(input)) + assertFalse { remainingText.isEmpty() } + } + + private fun assertDayOfMonthFieldParserSuccess( + parser: Parser>, + input: String, + expectedValues: List, + expectedWildcard: Boolean = false, + ) { + val (node, remainingText) = parser.parse(ParserContext.fromString(input)) + assertTrue { remainingText.isEmpty() } + assertEquals( + actual = node.value.values, + expected = expectedValues, + ) + assertEquals( + actual = node.value.wildcard, + expected = expectedWildcard, + ) + } +} diff --git a/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/parser/DayOfWeekFieldParserTest.kt b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/parser/DayOfWeekFieldParserTest.kt new file mode 100644 index 00000000..edfc266c --- /dev/null +++ b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/parser/DayOfWeekFieldParserTest.kt @@ -0,0 +1,127 @@ +package com.copperleaf.ballast.scheduler.parser + +import com.copperleaf.ballast.scheduler.schedule.DayOfWeekField +import com.copperleaf.kudzu.node.mapped.ValueNode +import com.copperleaf.kudzu.parser.Parser +import com.copperleaf.kudzu.parser.ParserContext +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class DayOfWeekFieldParserTest { + + @Test + fun dayOfWeekNameOrValueParserTest() { + assertParseSuccess(DayOfWeekFieldParser.dayOfWeekNameOrValueParser, "0", 0) + assertParseSuccess(DayOfWeekFieldParser.dayOfWeekNameOrValueParser, "sun", 0) + assertParseSuccess(DayOfWeekFieldParser.dayOfWeekNameOrValueParser, "SUN", 0) + assertParseSuccess(DayOfWeekFieldParser.dayOfWeekNameOrValueParser, "2", 2) + assertParseSuccess(DayOfWeekFieldParser.dayOfWeekNameOrValueParser, "tue", 2) + assertParseSuccess(DayOfWeekFieldParser.dayOfWeekNameOrValueParser, "TUE", 2) + } + + @Test + fun exactValueTest() { + assertDayOfWeekFieldParserSuccess(DayOfWeekFieldParser.exactValue, "0", listOf(0), false) + assertDayOfWeekFieldParserSuccess(DayOfWeekFieldParser.exactValue, "sun", listOf(0), false) + assertDayOfWeekFieldParserSuccess(DayOfWeekFieldParser.exactValue, "SUN", listOf(0), false) + assertDayOfWeekFieldParserSuccess(DayOfWeekFieldParser.exactValue, "2", listOf(2), false) + assertDayOfWeekFieldParserSuccess(DayOfWeekFieldParser.exactValue, "tue", listOf(2), false) + assertDayOfWeekFieldParserSuccess(DayOfWeekFieldParser.exactValue, "TUE", listOf(2), false) + } + + @Test + fun rangeValueTest() { + assertDayOfWeekFieldParserSuccess(DayOfWeekFieldParser.rangeValue, "2-4", listOf(2, 3, 4), false) + assertDayOfWeekFieldParserSuccess(DayOfWeekFieldParser.rangeValue, "tue-thu", listOf(2, 3, 4), false) + assertDayOfWeekFieldParserSuccess(DayOfWeekFieldParser.rangeValue, "TUE-THU", listOf(2, 3, 4), false) + assertDayOfWeekFieldParserSuccess(DayOfWeekFieldParser.rangeValue, "2-THU", listOf(2, 3, 4), false) + assertDayOfWeekFieldParserSuccess(DayOfWeekFieldParser.rangeValue, "tue-4", listOf(2, 3, 4), false) + assertDayOfWeekFieldParserSuccess(DayOfWeekFieldParser.rangeValue, "2-6/2", listOf(2, 4, 6), false) + } + + @Test + fun wildcardValueTest() { + assertDayOfWeekFieldParserSuccess(DayOfWeekFieldParser.wildcardValue, "*", listOf(0, 1, 2, 3, 4, 5, 6), true) + assertDayOfWeekFieldParserSuccess(DayOfWeekFieldParser.wildcardValue, "*/4", listOf(0, 4), true) + } + + @Test + fun singleDayOfWeekFieldParserTest() { + assertDayOfWeekFieldParserSuccess(DayOfWeekFieldParser.singleDayOfWeekFieldParser, "0", listOf(0), false) + assertDayOfWeekFieldParserSuccess(DayOfWeekFieldParser.singleDayOfWeekFieldParser, "sun", listOf(0), false) + assertDayOfWeekFieldParserSuccess(DayOfWeekFieldParser.singleDayOfWeekFieldParser, "SUN", listOf(0), false) + assertDayOfWeekFieldParserSuccess(DayOfWeekFieldParser.singleDayOfWeekFieldParser, "2", listOf(2), false) + assertDayOfWeekFieldParserSuccess(DayOfWeekFieldParser.singleDayOfWeekFieldParser, "tue", listOf(2), false) + assertDayOfWeekFieldParserSuccess(DayOfWeekFieldParser.singleDayOfWeekFieldParser, "TUE", listOf(2), false) + + assertDayOfWeekFieldParserSuccess(DayOfWeekFieldParser.singleDayOfWeekFieldParser, "2-4", listOf(2, 3, 4), false) + assertDayOfWeekFieldParserSuccess(DayOfWeekFieldParser.singleDayOfWeekFieldParser, "tue-thu", listOf(2, 3, 4), false) + assertDayOfWeekFieldParserSuccess(DayOfWeekFieldParser.singleDayOfWeekFieldParser, "TUE-THU", listOf(2, 3, 4), false) + assertDayOfWeekFieldParserSuccess(DayOfWeekFieldParser.singleDayOfWeekFieldParser, "2-THU", listOf(2, 3, 4), false) + assertDayOfWeekFieldParserSuccess(DayOfWeekFieldParser.singleDayOfWeekFieldParser, "TUE-4", listOf(2, 3, 4), false) + assertDayOfWeekFieldParserSuccess(DayOfWeekFieldParser.singleDayOfWeekFieldParser, "2-6/2", listOf(2, 4, 6), false) + + assertDayOfWeekFieldParserSuccess(DayOfWeekFieldParser.singleDayOfWeekFieldParser, "*", listOf(0, 1, 2, 3, 4, 5, 6), true) + assertDayOfWeekFieldParserSuccess(DayOfWeekFieldParser.singleDayOfWeekFieldParser, "*/4", listOf(0, 4), true) + } + + @Test + fun listOfDayOfWeekFieldParserTest() { + assertDayOfWeekFieldParserSuccess(DayOfWeekFieldParser.listOfDayOfWeekFieldParser, "0", listOf(0), false) + assertDayOfWeekFieldParserSuccess(DayOfWeekFieldParser.listOfDayOfWeekFieldParser, "sun", listOf(0), false) + assertDayOfWeekFieldParserSuccess(DayOfWeekFieldParser.listOfDayOfWeekFieldParser, "SUN", listOf(0), false) + assertDayOfWeekFieldParserSuccess(DayOfWeekFieldParser.listOfDayOfWeekFieldParser, "2", listOf(2), false) + assertDayOfWeekFieldParserSuccess(DayOfWeekFieldParser.listOfDayOfWeekFieldParser, "tue", listOf(2), false) + assertDayOfWeekFieldParserSuccess(DayOfWeekFieldParser.listOfDayOfWeekFieldParser, "TUE", listOf(2), false) + + assertDayOfWeekFieldParserSuccess(DayOfWeekFieldParser.listOfDayOfWeekFieldParser, "2-4", listOf(2, 3, 4), false) + assertDayOfWeekFieldParserSuccess(DayOfWeekFieldParser.listOfDayOfWeekFieldParser, "tue-thu", listOf(2, 3, 4), false) + assertDayOfWeekFieldParserSuccess(DayOfWeekFieldParser.listOfDayOfWeekFieldParser, "TUE-THU", listOf(2, 3, 4), false) + assertDayOfWeekFieldParserSuccess(DayOfWeekFieldParser.listOfDayOfWeekFieldParser, "2-THU", listOf(2, 3, 4), false) + assertDayOfWeekFieldParserSuccess(DayOfWeekFieldParser.listOfDayOfWeekFieldParser, "TUE-4", listOf(2, 3, 4), false) + assertDayOfWeekFieldParserSuccess(DayOfWeekFieldParser.listOfDayOfWeekFieldParser, "2-6/2", listOf(2, 4, 6), false) + + assertDayOfWeekFieldParserSuccess(DayOfWeekFieldParser.listOfDayOfWeekFieldParser, "*", listOf(0, 1, 2, 3, 4, 5, 6), true) + assertDayOfWeekFieldParserSuccess(DayOfWeekFieldParser.listOfDayOfWeekFieldParser, "*/4", listOf(0, 4), true) + + assertDayOfWeekFieldParserSuccess(DayOfWeekFieldParser.listOfDayOfWeekFieldParser, "1,5,6", listOf(1, 5, 6), false) + assertDayOfWeekFieldParserSuccess(DayOfWeekFieldParser.listOfDayOfWeekFieldParser, "1,3-5,2-6/2", listOf(1, 2, 3, 4, 5, 6), false) + assertDayOfWeekFieldParserSuccess(DayOfWeekFieldParser.listOfDayOfWeekFieldParser, "4,3,1,2", listOf(1, 2, 3, 4), false) + assertDayOfWeekFieldParserSuccess(DayOfWeekFieldParser.listOfDayOfWeekFieldParser, "4,*/4,*/6", listOf(0, 4, 6), false) + } + +// utils +// --------------------------------------------------------------------------------------------------------------------- + + private fun assertParseSuccess( + parser: Parser>, + input: String, + expectedValues: T, + ) { + val (node, remainingText) = parser.parse(ParserContext.fromString(input)) + assertTrue { remainingText.isEmpty() } + assertEquals( + actual = node.value, + expected = expectedValues, + ) + } + + private fun assertDayOfWeekFieldParserSuccess( + parser: Parser>, + input: String, + expectedValues: List, + expectedWildcard: Boolean = false, + ) { + val (node, remainingText) = parser.parse(ParserContext.fromString(input)) + assertTrue { remainingText.isEmpty() } + assertEquals( + actual = node.value.values, + expected = expectedValues, + ) + assertEquals( + actual = node.value.wildcard, + expected = expectedWildcard, + ) + } +} diff --git a/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/parser/HourFieldParserTest.kt b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/parser/HourFieldParserTest.kt new file mode 100644 index 00000000..cf3b35b3 --- /dev/null +++ b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/parser/HourFieldParserTest.kt @@ -0,0 +1,128 @@ +package com.copperleaf.ballast.scheduler.parser + +import com.copperleaf.ballast.scheduler.schedule.HourField +import com.copperleaf.kudzu.node.mapped.ValueNode +import com.copperleaf.kudzu.parser.Parser +import com.copperleaf.kudzu.parser.ParserContext +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class HourFieldParserTest { + + @Test + fun exactValueTest() { + assertHourFieldParserSuccess(HourFieldParser.exactValue, "1", listOf(1), false) + assertHourFieldParserSuccess(HourFieldParser.exactValue, "2", listOf(2), false) + } + + @Test + fun rangeValueTest() { + assertHourFieldParserSuccess(HourFieldParser.rangeValue, "2-4", listOf(2, 3, 4), false) + assertHourFieldParserSuccess(HourFieldParser.rangeValue, "2-10/2", listOf(2, 4, 6, 8, 10), false) + } + + @Test + fun wildcardValueTest() { + assertHourFieldParserSuccess(HourFieldParser.wildcardValue, "*", listOf( + 0, 1, 2, 3, 4, 5, + 6, 7, 8, 9, 10, + 11, 12, 13, 14, 15, + 16, 17, 18, 19, 20, + 21, 22, 23, + ), true) + assertHourFieldParserSuccess(HourFieldParser.wildcardValue, "*/4", listOf(0, 4, 8, 12, 16, 20), true) + } + + @Test + fun singleHourFieldParserTest() { + assertHourFieldParserSuccess(HourFieldParser.singleHourFieldParser, "1", listOf(1), false) + assertHourFieldParserSuccess(HourFieldParser.singleHourFieldParser, "2", listOf(2), false) + + assertHourFieldParserSuccess(HourFieldParser.singleHourFieldParser, "2-4", listOf(2, 3, 4), false) + assertHourFieldParserSuccess(HourFieldParser.singleHourFieldParser, "2-10/2", listOf(2, 4, 6, 8, 10), false) + + assertHourFieldParserSuccess(HourFieldParser.singleHourFieldParser, "*", listOf( + 0, 1, 2, 3, 4, 5, + 6, 7, 8, 9, 10, + 11, 12, 13, 14, 15, + 16, 17, 18, 19, 20, + 21, 22, 23, + ), true) + assertHourFieldParserSuccess(HourFieldParser.singleHourFieldParser, "*/4", listOf(0, 4, 8, 12, 16, 20), true) + } + + @Test + fun listOfHourFieldParserTest() { + assertHourFieldParserSuccess(HourFieldParser.listOfHourFieldParser, "1", listOf(1), false) + assertHourFieldParserSuccess(HourFieldParser.listOfHourFieldParser, "2", listOf(2), false) + + assertHourFieldParserSuccess(HourFieldParser.listOfHourFieldParser, "2-4", listOf(2, 3, 4), false) + assertHourFieldParserSuccess(HourFieldParser.listOfHourFieldParser, "2-10/2", listOf(2, 4, 6, 8, 10), false) + + assertHourFieldParserSuccess(HourFieldParser.listOfHourFieldParser, "*", listOf( + 0, 1, 2, 3, 4, 5, + 6, 7, 8, 9, 10, + 11, 12, 13, 14, 15, + 16, 17, 18, 19, 20, + 21, 22, 23, + ), true) + assertHourFieldParserSuccess(HourFieldParser.listOfHourFieldParser, "*/4", listOf(0, 4, 8, 12, 16, 20), true) + + assertHourFieldParserSuccess(HourFieldParser.listOfHourFieldParser, "1,5,8", listOf(1, 5, 8), false) + assertHourFieldParserSuccess(HourFieldParser.listOfHourFieldParser, "1,3-5,8-12/2", listOf(1, 3, 4, 5, 8, 10, 12), false) + assertHourFieldParserSuccess(HourFieldParser.listOfHourFieldParser, "4,3,1,2", listOf(1, 2, 3, 4), false) + assertHourFieldParserSuccess(HourFieldParser.listOfHourFieldParser, "4,*/4,*/6", listOf(0, 4, 6, 8, 12, 16, 18, 20), false) + } + +// utils +// --------------------------------------------------------------------------------------------------------------------- + + private fun assertParseSuccess( + parser: Parser>, + input: String, + expectedValues: T, + ) { + val (node, remainingText) = parser.parse(ParserContext.fromString(input)) + assertTrue { remainingText.isEmpty() } + assertEquals( + actual = node.value, + expected = expectedValues, + ) + } + + private fun assertParseThrows( + parser: Parser>, + input: String, + ) { + assertFails { parser.parse(ParserContext.fromString(input)) } + } + + private fun assertParseIncomplete( + parser: Parser>, + input: String, + ) { + val (_, remainingText) = parser.parse(ParserContext.fromString(input)) + assertFalse { remainingText.isEmpty() } + } + + private fun assertHourFieldParserSuccess( + parser: Parser>, + input: String, + expectedValues: List, + expectedWildcard: Boolean = false, + ) { + val (node, remainingText) = parser.parse(ParserContext.fromString(input)) + assertTrue { remainingText.isEmpty() } + assertEquals( + actual = node.value.values, + expected = expectedValues, + ) + assertEquals( + actual = node.value.wildcard, + expected = expectedWildcard, + ) + } +} diff --git a/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/parser/MinuteFieldParserTest.kt b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/parser/MinuteFieldParserTest.kt new file mode 100644 index 00000000..b1389893 --- /dev/null +++ b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/parser/MinuteFieldParserTest.kt @@ -0,0 +1,149 @@ +package com.copperleaf.ballast.scheduler.parser + +import com.copperleaf.ballast.scheduler.schedule.MinuteField +import com.copperleaf.kudzu.node.mapped.ValueNode +import com.copperleaf.kudzu.parser.Parser +import com.copperleaf.kudzu.parser.ParserContext +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class MinuteFieldParserTest { + + @Test + fun exactValueTest() { + assertMinuteFieldParserSuccess(MinuteFieldParser.exactValue, "1", listOf(1), false) + assertMinuteFieldParserSuccess(MinuteFieldParser.exactValue, "2", listOf(2), false) + } + + @Test + fun rangeValueTest() { + assertMinuteFieldParserSuccess(MinuteFieldParser.rangeValue, "2-4", listOf(2, 3, 4), false) + assertMinuteFieldParserSuccess(MinuteFieldParser.rangeValue, "2-10/2", listOf(2, 4, 6, 8, 10), false) + } + + @Test + fun wildcardValueTest() { + assertMinuteFieldParserSuccess(MinuteFieldParser.wildcardValue, "*", listOf( + 0, 1, 2, 3, 4, 5, + 6, 7, 8, 9, 10, + 11, 12, 13, 14, 15, + 16, 17, 18, 19, 20, + 21, 22, 23, 24, 25, + 26, 27, 28, 29, 30, + 31, 32, 33, 34, 35, + 36, 37, 38, 39, 40, + 41, 42, 43, 44, 45, + 46, 47, 48, 49, 50, + 51, 52, 53, 54, 55, + 56, 57, 58, 59 + ), true) + assertMinuteFieldParserSuccess(MinuteFieldParser.wildcardValue, "*/4", listOf(0, 4, 8, 12, 16, 20, 24, 28, 32, 36, 40, 44, 48, 52, 56), true) + } + + @Test + fun singleMinuteFieldParserTest() { + assertMinuteFieldParserSuccess(MinuteFieldParser.singleMinuteFieldParser, "1", listOf(1), false) + assertMinuteFieldParserSuccess(MinuteFieldParser.singleMinuteFieldParser, "2", listOf(2), false) + + assertMinuteFieldParserSuccess(MinuteFieldParser.singleMinuteFieldParser, "2-4", listOf(2, 3, 4), false) + assertMinuteFieldParserSuccess(MinuteFieldParser.singleMinuteFieldParser, "2-10/2", listOf(2, 4, 6, 8, 10), false) + + assertMinuteFieldParserSuccess(MinuteFieldParser.singleMinuteFieldParser, "*", listOf( + 0, 1, 2, 3, 4, 5, + 6, 7, 8, 9, 10, + 11, 12, 13, 14, 15, + 16, 17, 18, 19, 20, + 21, 22, 23, 24, 25, + 26, 27, 28, 29, 30, + 31, 32, 33, 34, 35, + 36, 37, 38, 39, 40, + 41, 42, 43, 44, 45, + 46, 47, 48, 49, 50, + 51, 52, 53, 54, 55, + 56, 57, 58, 59 + ), true) + assertMinuteFieldParserSuccess(MinuteFieldParser.singleMinuteFieldParser, "*/4", listOf(0, 4, 8, 12, 16, 20, 24, 28, 32, 36, 40, 44, 48, 52, 56), true) + } + + @Test + fun listOfMinuteFieldParserTest() { + assertMinuteFieldParserSuccess(MinuteFieldParser.listOfMinuteFieldParser, "1", listOf(1), false) + assertMinuteFieldParserSuccess(MinuteFieldParser.listOfMinuteFieldParser, "2", listOf(2), false) + + assertMinuteFieldParserSuccess(MinuteFieldParser.listOfMinuteFieldParser, "2-4", listOf(2, 3, 4), false) + assertMinuteFieldParserSuccess(MinuteFieldParser.listOfMinuteFieldParser, "2-10/2", listOf(2, 4, 6, 8, 10), false) + + assertMinuteFieldParserSuccess(MinuteFieldParser.listOfMinuteFieldParser, "*", listOf( + 0, 1, 2, 3, 4, 5, + 6, 7, 8, 9, 10, + 11, 12, 13, 14, 15, + 16, 17, 18, 19, 20, + 21, 22, 23, 24, 25, + 26, 27, 28, 29, 30, + 31, 32, 33, 34, 35, + 36, 37, 38, 39, 40, + 41, 42, 43, 44, 45, + 46, 47, 48, 49, 50, + 51, 52, 53, 54, 55, + 56, 57, 58, 59 + ), true) + assertMinuteFieldParserSuccess(MinuteFieldParser.listOfMinuteFieldParser, "*/4", listOf(0, 4, 8, 12, 16, 20, 24, 28, 32, 36, 40, 44, 48, 52, 56), true) + + assertMinuteFieldParserSuccess(MinuteFieldParser.listOfMinuteFieldParser, "1,5,8", listOf(1, 5, 8), false) + assertMinuteFieldParserSuccess(MinuteFieldParser.listOfMinuteFieldParser, "1,3-5,8-12/2", listOf(1, 3, 4, 5, 8, 10, 12), false) + assertMinuteFieldParserSuccess(MinuteFieldParser.listOfMinuteFieldParser, "4,3,1,2", listOf(1, 2, 3, 4), false) + assertMinuteFieldParserSuccess(MinuteFieldParser.listOfMinuteFieldParser, "4,*/4,*/6", listOf(0, 4, 6, 8, 12, 16, 18, 20, 24, 28, 30, 32, 36, 40, 42, 44, 48, 52, 54, 56), false) + } + +// utils +// --------------------------------------------------------------------------------------------------------------------- + + private fun assertParseSuccess( + parser: Parser>, + input: String, + expectedValues: T, + ) { + val (node, remainingText) = parser.parse(ParserContext.fromString(input)) + assertTrue { remainingText.isEmpty() } + assertEquals( + actual = node.value, + expected = expectedValues, + ) + } + + private fun assertParseThrows( + parser: Parser>, + input: String, + ) { + assertFails { parser.parse(ParserContext.fromString(input)) } + } + + private fun assertParseIncomplete( + parser: Parser>, + input: String, + ) { + val (_, remainingText) = parser.parse(ParserContext.fromString(input)) + assertFalse { remainingText.isEmpty() } + } + + private fun assertMinuteFieldParserSuccess( + parser: Parser>, + input: String, + expectedValues: List, + expectedWildcard: Boolean = false, + ) { + val (node, remainingText) = parser.parse(ParserContext.fromString(input)) + assertTrue { remainingText.isEmpty() } + assertEquals( + actual = node.value.values, + expected = expectedValues, + ) + assertEquals( + actual = node.value.wildcard, + expected = expectedWildcard, + ) + } +} diff --git a/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/parser/MonthFieldParserTest.kt b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/parser/MonthFieldParserTest.kt new file mode 100644 index 00000000..c111d1db --- /dev/null +++ b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/parser/MonthFieldParserTest.kt @@ -0,0 +1,144 @@ +package com.copperleaf.ballast.scheduler.parser + +import com.copperleaf.ballast.scheduler.schedule.MonthField +import com.copperleaf.kudzu.node.mapped.ValueNode +import com.copperleaf.kudzu.parser.Parser +import com.copperleaf.kudzu.parser.ParserContext +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class MonthFieldParserTest { + + @Test + fun monthNameOrValueParserTest() { + assertParseSuccess(MonthFieldParser.monthNameOrValueParser, "1", 1) + assertParseSuccess(MonthFieldParser.monthNameOrValueParser, "jan", 1) + assertParseSuccess(MonthFieldParser.monthNameOrValueParser, "JAN", 1) + assertParseSuccess(MonthFieldParser.monthNameOrValueParser, "2", 2) + assertParseSuccess(MonthFieldParser.monthNameOrValueParser, "feb", 2) + assertParseSuccess(MonthFieldParser.monthNameOrValueParser, "FEB", 2) + } + + @Test + fun exactValueTest() { + assertMonthFieldParserSuccess(MonthFieldParser.exactValue, "1", listOf(1), false) + assertMonthFieldParserSuccess(MonthFieldParser.exactValue, "jan", listOf(1), false) + assertMonthFieldParserSuccess(MonthFieldParser.exactValue, "JAN", listOf(1), false) + assertMonthFieldParserSuccess(MonthFieldParser.exactValue, "2", listOf(2), false) + assertMonthFieldParserSuccess(MonthFieldParser.exactValue, "feb", listOf(2), false) + assertMonthFieldParserSuccess(MonthFieldParser.exactValue, "FEB", listOf(2), false) + } + + @Test + fun rangeValueTest() { + assertMonthFieldParserSuccess(MonthFieldParser.rangeValue, "2-4", listOf(2, 3, 4), false) + assertMonthFieldParserSuccess(MonthFieldParser.rangeValue, "feb-apr", listOf(2, 3, 4), false) + assertMonthFieldParserSuccess(MonthFieldParser.rangeValue, "FEB-APR", listOf(2, 3, 4), false) + assertMonthFieldParserSuccess(MonthFieldParser.rangeValue, "2-APR", listOf(2, 3, 4), false) + assertMonthFieldParserSuccess(MonthFieldParser.rangeValue, "FEB-4", listOf(2, 3, 4), false) + assertMonthFieldParserSuccess(MonthFieldParser.rangeValue, "2-10/2", listOf(2, 4, 6, 8, 10), false) + } + + @Test + fun wildcardValueTest() { + assertMonthFieldParserSuccess(MonthFieldParser.wildcardValue, "*", listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12), true) + assertMonthFieldParserSuccess(MonthFieldParser.wildcardValue, "*/4", listOf(1, 5, 9), true) + } + + @Test + fun singleMonthFieldParserTest() { + assertMonthFieldParserSuccess(MonthFieldParser.singleMonthFieldParser, "1", listOf(1), false) + assertMonthFieldParserSuccess(MonthFieldParser.singleMonthFieldParser, "jan", listOf(1), false) + assertMonthFieldParserSuccess(MonthFieldParser.singleMonthFieldParser, "JAN", listOf(1), false) + assertMonthFieldParserSuccess(MonthFieldParser.singleMonthFieldParser, "2", listOf(2), false) + assertMonthFieldParserSuccess(MonthFieldParser.singleMonthFieldParser, "feb", listOf(2), false) + assertMonthFieldParserSuccess(MonthFieldParser.singleMonthFieldParser, "FEB", listOf(2), false) + + assertMonthFieldParserSuccess(MonthFieldParser.singleMonthFieldParser, "2-4", listOf(2, 3, 4), false) + assertMonthFieldParserSuccess(MonthFieldParser.singleMonthFieldParser, "feb-apr", listOf(2, 3, 4), false) + assertMonthFieldParserSuccess(MonthFieldParser.singleMonthFieldParser, "FEB-APR", listOf(2, 3, 4), false) + assertMonthFieldParserSuccess(MonthFieldParser.singleMonthFieldParser, "2-APR", listOf(2, 3, 4), false) + assertMonthFieldParserSuccess(MonthFieldParser.singleMonthFieldParser, "FEB-4", listOf(2, 3, 4), false) + assertMonthFieldParserSuccess(MonthFieldParser.singleMonthFieldParser, "2-10/2", listOf(2, 4, 6, 8, 10), false) + + assertMonthFieldParserSuccess(MonthFieldParser.singleMonthFieldParser, "*", listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12), true) + assertMonthFieldParserSuccess(MonthFieldParser.singleMonthFieldParser, "*/4", listOf(1, 5, 9), true) + } + + @Test + fun listOfMonthFieldParserTest() { + assertMonthFieldParserSuccess(MonthFieldParser.listOfMonthFieldParser, "1", listOf(1), false) + assertMonthFieldParserSuccess(MonthFieldParser.listOfMonthFieldParser, "jan", listOf(1), false) + assertMonthFieldParserSuccess(MonthFieldParser.listOfMonthFieldParser, "JAN", listOf(1), false) + assertMonthFieldParserSuccess(MonthFieldParser.listOfMonthFieldParser, "2", listOf(2), false) + assertMonthFieldParserSuccess(MonthFieldParser.listOfMonthFieldParser, "feb", listOf(2), false) + assertMonthFieldParserSuccess(MonthFieldParser.listOfMonthFieldParser, "FEB", listOf(2), false) + + assertMonthFieldParserSuccess(MonthFieldParser.listOfMonthFieldParser, "2-4", listOf(2, 3, 4), false) + assertMonthFieldParserSuccess(MonthFieldParser.listOfMonthFieldParser, "feb-apr", listOf(2, 3, 4), false) + assertMonthFieldParserSuccess(MonthFieldParser.listOfMonthFieldParser, "FEB-APR", listOf(2, 3, 4), false) + assertMonthFieldParserSuccess(MonthFieldParser.listOfMonthFieldParser, "2-APR", listOf(2, 3, 4), false) + assertMonthFieldParserSuccess(MonthFieldParser.listOfMonthFieldParser, "FEB-4", listOf(2, 3, 4), false) + assertMonthFieldParserSuccess(MonthFieldParser.listOfMonthFieldParser, "2-10/2", listOf(2, 4, 6, 8, 10), false) + + assertMonthFieldParserSuccess(MonthFieldParser.listOfMonthFieldParser, "*", listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12), true) + assertMonthFieldParserSuccess(MonthFieldParser.listOfMonthFieldParser, "*/4", listOf(1, 5, 9), true) + + assertMonthFieldParserSuccess(MonthFieldParser.listOfMonthFieldParser, "1,5,8", listOf(1, 5, 8), false) + assertMonthFieldParserSuccess(MonthFieldParser.listOfMonthFieldParser, "1,3-5,8-12/2", listOf(1, 3, 4, 5, 8, 10, 12), false) + assertMonthFieldParserSuccess(MonthFieldParser.listOfMonthFieldParser, "4,3,1,2", listOf(1, 2, 3, 4), false) + assertMonthFieldParserSuccess(MonthFieldParser.listOfMonthFieldParser, "4,*/4,*/6", listOf(1, 4, 5, 7, 9), false) + } + +// utils +// --------------------------------------------------------------------------------------------------------------------- + + private fun assertParseSuccess( + parser: Parser>, + input: String, + expectedValues: T, + ) { + val (node, remainingText) = parser.parse(ParserContext.fromString(input)) + assertTrue { remainingText.isEmpty() } + assertEquals( + actual = node.value, + expected = expectedValues, + ) + } + + private fun assertParseThrows( + parser: Parser>, + input: String, + ) { + assertFails { parser.parse(ParserContext.fromString(input)) } + } + + private fun assertParseIncomplete( + parser: Parser>, + input: String, + ) { + val (_, remainingText) = parser.parse(ParserContext.fromString(input)) + assertFalse { remainingText.isEmpty() } + } + + private fun assertMonthFieldParserSuccess( + parser: Parser>, + input: String, + expectedValues: List, + expectedWildcard: Boolean = false, + ) { + val (node, remainingText) = parser.parse(ParserContext.fromString(input)) + assertTrue { remainingText.isEmpty() } + assertEquals( + actual = node.value.values, + expected = expectedValues, + ) + assertEquals( + actual = node.value.wildcard, + expected = expectedWildcard, + ) + } +} diff --git a/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/CronScheduleDayOfMonthFieldTest.kt b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/CronScheduleDayOfMonthFieldTest.kt new file mode 100644 index 00000000..09d9d99e --- /dev/null +++ b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/CronScheduleDayOfMonthFieldTest.kt @@ -0,0 +1,48 @@ +package com.copperleaf.ballast.scheduler.schedule + +import com.copperleaf.ballast.scheduler.firstTen +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atTime +import kotlinx.datetime.toInstant +import kotlin.test.Test +import kotlin.test.assertEquals + +class CronScheduleDayOfMonthFieldTest { + + val timeZone = TimeZone.UTC + val startDay = LocalDate(2023, Month.DECEMBER, 1) + val startInstant = startDay.atTime(2, 37, 0).toInstant(timeZone) + + @Test + fun test3rdOfEachMonth() { + // Cron: "0 0 3 * *" (at midnight every 3rd day of the month) + val cronExpression = CronExpression( + minute = MinuteField.exactValue(0), + hour = HourField.exactValue(0), + dayOfMonth = DayOfMonthField.exactValue(3), + month = MonthField.anyValue(), + dayOfWeek = DayOfWeekField.anyValue(), + timeZone = timeZone, + ) + + assertEquals( + actual = CronSchedule(cronExpression) + .generateSchedule(startInstant) + .firstTen(timeZone), + expected = listOf( + LocalDate(2023, Month.DECEMBER, 3).atTime(0, 0), + LocalDate(2024, Month.JANUARY, 3).atTime(0, 0), + LocalDate(2024, Month.FEBRUARY, 3).atTime(0, 0), + LocalDate(2024, Month.MARCH, 3).atTime(0, 0), + LocalDate(2024, Month.APRIL, 3).atTime(0, 0), + LocalDate(2024, Month.MAY, 3).atTime(0, 0), + LocalDate(2024, Month.JUNE, 3).atTime(0, 0), + LocalDate(2024, Month.JULY, 3).atTime(0, 0), + LocalDate(2024, Month.AUGUST, 3).atTime(0, 0), + LocalDate(2024, Month.SEPTEMBER, 3).atTime(0, 0), + ), + ) + } +} diff --git a/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/CronScheduleDayOfWeekFieldTest.kt b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/CronScheduleDayOfWeekFieldTest.kt new file mode 100644 index 00000000..41753ba0 --- /dev/null +++ b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/CronScheduleDayOfWeekFieldTest.kt @@ -0,0 +1,49 @@ +package com.copperleaf.ballast.scheduler.schedule + +import com.copperleaf.ballast.scheduler.firstTen +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atTime +import kotlinx.datetime.toInstant +import kotlin.test.Test +import kotlin.test.assertEquals + +class CronScheduleDayOfWeekFieldTest { + + val timeZone = TimeZone.UTC + val startDay = LocalDate(2023, Month.DECEMBER, 1) + val startInstant = startDay.atTime(2, 37, 0).toInstant(timeZone) + + @Test + fun testEveryWednesday() { + // Cron: "0 0 * * 3" (at midnight every Wednesday) + val cronExpression = CronExpression( + minute = MinuteField.exactValue(0), + hour = HourField.exactValue(0), + dayOfMonth = DayOfMonthField.anyValue(), + month = MonthField.anyValue(), + dayOfWeek = DayOfWeekField.exactValue(DayOfWeek.WEDNESDAY), + timeZone = timeZone, + ) + + assertEquals( + actual = CronSchedule(cronExpression) + .generateSchedule(startInstant) + .firstTen(timeZone), + expected = listOf( + LocalDate(2023, Month.DECEMBER, 6).atTime(0, 0), + LocalDate(2023, Month.DECEMBER, 13).atTime(0, 0), + LocalDate(2023, Month.DECEMBER, 20).atTime(0, 0), + LocalDate(2023, Month.DECEMBER, 27).atTime(0, 0), + LocalDate(2024, Month.JANUARY, 3).atTime(0, 0), + LocalDate(2024, Month.JANUARY, 10).atTime(0, 0), + LocalDate(2024, Month.JANUARY, 17).atTime(0, 0), + LocalDate(2024, Month.JANUARY, 24).atTime(0, 0), + LocalDate(2024, Month.JANUARY, 31).atTime(0, 0), + LocalDate(2024, Month.FEBRUARY, 7).atTime(0, 0), + ), + ) + } +} diff --git a/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/CronScheduleHourFieldTest.kt b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/CronScheduleHourFieldTest.kt new file mode 100644 index 00000000..b418859c --- /dev/null +++ b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/CronScheduleHourFieldTest.kt @@ -0,0 +1,48 @@ +package com.copperleaf.ballast.scheduler.schedule + +import com.copperleaf.ballast.scheduler.firstTen +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atTime +import kotlinx.datetime.toInstant +import kotlin.test.Test +import kotlin.test.assertEquals + +class CronScheduleHourFieldTest { + + val timeZone = TimeZone.UTC + val startDay = LocalDate(2023, Month.DECEMBER, 1) + val startInstant = startDay.atTime(2, 37, 0).toInstant(timeZone) + + @Test + fun testEvery4Hours() { + // Cron: "0 */4 * * *" (every 4 hours at the top of the hour) + val cronExpression = CronExpression( + minute = MinuteField.exactValue(0), + hour = HourField.anyValue(step = 4), + dayOfMonth = DayOfMonthField.anyValue(), + month = MonthField.anyValue(), + dayOfWeek = DayOfWeekField.anyValue(), + timeZone = timeZone, + ) + + assertEquals( + actual = CronSchedule(cronExpression) + .generateSchedule(startInstant) + .firstTen(timeZone), + expected = listOf( + LocalDate(2023, Month.DECEMBER, 1).atTime(4, 0), + LocalDate(2023, Month.DECEMBER, 1).atTime(8, 0), + LocalDate(2023, Month.DECEMBER, 1).atTime(12, 0), + LocalDate(2023, Month.DECEMBER, 1).atTime(16, 0), + LocalDate(2023, Month.DECEMBER, 1).atTime(20, 0), + LocalDate(2023, Month.DECEMBER, 2).atTime(0, 0), + LocalDate(2023, Month.DECEMBER, 2).atTime(4, 0), + LocalDate(2023, Month.DECEMBER, 2).atTime(8, 0), + LocalDate(2023, Month.DECEMBER, 2).atTime(12, 0), + LocalDate(2023, Month.DECEMBER, 2).atTime(16, 0), + ), + ) + } +} diff --git a/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/CronScheduleMinuteFieldTest.kt b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/CronScheduleMinuteFieldTest.kt new file mode 100644 index 00000000..d19eec3f --- /dev/null +++ b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/CronScheduleMinuteFieldTest.kt @@ -0,0 +1,48 @@ +package com.copperleaf.ballast.scheduler.schedule + +import com.copperleaf.ballast.scheduler.firstTen +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atTime +import kotlinx.datetime.toInstant +import kotlin.test.Test +import kotlin.test.assertEquals + +class CronScheduleMinuteFieldTest { + + val timeZone = TimeZone.UTC + val startDay = LocalDate(2023, Month.DECEMBER, 1) + val startInstant = startDay.atTime(2, 37, 0).toInstant(timeZone) + + @Test + fun testEvery30Minutes() { + // Cron: "*/30 * * * *" (at the top and bottom of every hour) + val cronExpression = CronExpression( + minute = MinuteField.anyValue(step = 30), + hour = HourField.anyValue(), + dayOfMonth = DayOfMonthField.anyValue(), + month = MonthField.anyValue(), + dayOfWeek = DayOfWeekField.anyValue(), + timeZone = timeZone, + ) + + assertEquals( + actual = CronSchedule(cronExpression) + .generateSchedule(startInstant) + .firstTen(timeZone), + expected = listOf( + LocalDate(2023, Month.DECEMBER, 1).atTime(3, 0), + LocalDate(2023, Month.DECEMBER, 1).atTime(3, 30), + LocalDate(2023, Month.DECEMBER, 1).atTime(4, 0), + LocalDate(2023, Month.DECEMBER, 1).atTime(4, 30), + LocalDate(2023, Month.DECEMBER, 1).atTime(5, 0), + LocalDate(2023, Month.DECEMBER, 1).atTime(5, 30), + LocalDate(2023, Month.DECEMBER, 1).atTime(6, 0), + LocalDate(2023, Month.DECEMBER, 1).atTime(6, 30), + LocalDate(2023, Month.DECEMBER, 1).atTime(7, 0), + LocalDate(2023, Month.DECEMBER, 1).atTime(7, 30), + ), + ) + } +} diff --git a/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/CronScheduleMonthFieldTest.kt b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/CronScheduleMonthFieldTest.kt new file mode 100644 index 00000000..cb4a9000 --- /dev/null +++ b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/CronScheduleMonthFieldTest.kt @@ -0,0 +1,48 @@ +package com.copperleaf.ballast.scheduler.schedule + +import com.copperleaf.ballast.scheduler.firstTen +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atTime +import kotlinx.datetime.toInstant +import kotlin.test.Test +import kotlin.test.assertEquals + +class CronScheduleMonthFieldTest { + + val timeZone = TimeZone.UTC + val startDay = LocalDate(2023, Month.DECEMBER, 1) + val startInstant = startDay.atTime(2, 37, 0).toInstant(timeZone) + + @Test + fun test3rdOfEachMonth() { + // Cron: "0 0 * 3 *" (at midnight every midnight in March) + val cronExpression = CronExpression( + minute = MinuteField.exactValue(0), + hour = HourField.exactValue(0), + dayOfMonth = DayOfMonthField.anyValue(), + month = MonthField.exactValue(Month.MARCH), + dayOfWeek = DayOfWeekField.anyValue(), + timeZone = timeZone, + ) + + assertEquals( + actual = CronSchedule(cronExpression) + .generateSchedule(startInstant) + .firstTen(timeZone), + expected = listOf( + LocalDate(2024, Month.MARCH, 1).atTime(0, 0), + LocalDate(2024, Month.MARCH, 2).atTime(0, 0), + LocalDate(2024, Month.MARCH, 3).atTime(0, 0), + LocalDate(2024, Month.MARCH, 4).atTime(0, 0), + LocalDate(2024, Month.MARCH, 5).atTime(0, 0), + LocalDate(2024, Month.MARCH, 6).atTime(0, 0), + LocalDate(2024, Month.MARCH, 7).atTime(0, 0), + LocalDate(2024, Month.MARCH, 8).atTime(0, 0), + LocalDate(2024, Month.MARCH, 9).atTime(0, 0), + LocalDate(2024, Month.MARCH, 10).atTime(0, 0), + ), + ) + } +} diff --git a/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/expression/TestNextDayOfMonthCronExpression.kt b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/expression/TestNextDayOfMonthCronExpression.kt new file mode 100644 index 00000000..fb02f3e8 --- /dev/null +++ b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/expression/TestNextDayOfMonthCronExpression.kt @@ -0,0 +1,44 @@ +package com.copperleaf.ballast.scheduler.schedule.expression + +import com.copperleaf.ballast.scheduler.schedule.CronExpression +import com.copperleaf.ballast.scheduler.schedule.DayOfMonthField +import com.copperleaf.ballast.scheduler.schedule.DayOfWeekField +import com.copperleaf.ballast.scheduler.schedule.HourField +import com.copperleaf.ballast.scheduler.schedule.MinuteField +import com.copperleaf.ballast.scheduler.schedule.MonthField +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.toInstant +import kotlin.test.Test +import kotlin.test.assertEquals + +class TestNextDayOfMonthCronExpression { + + val timeZone = TimeZone.UTC + val startDay = LocalDate(2023, Month.JANUARY, 1) // starts on a Sunday + val startInstant = startDay.atStartOfDayIn(timeZone) + + @Test + fun testNextMatchingInstant() { + val cronExpression = CronExpression( + minute = MinuteField.anyValue(), + hour = HourField.anyValue(), + dayOfMonth = DayOfMonthField.exactValue(2), + month = MonthField.anyValue(), + dayOfWeek = DayOfWeekField.anyValue(), + timeZone = timeZone, + ) + + assertEquals( + actual = cronExpression.nextMatchingInstant(startInstant), + expected = LocalDateTime( + date = LocalDate(year = 2023, month = Month.JANUARY, day = 2), + time = LocalTime(hour = 0, minute = 0), + ).toInstant(timeZone), + ) + } +} diff --git a/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/expression/TestNextDayOfWeekCronExpression.kt b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/expression/TestNextDayOfWeekCronExpression.kt new file mode 100644 index 00000000..b4bdddfb --- /dev/null +++ b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/expression/TestNextDayOfWeekCronExpression.kt @@ -0,0 +1,45 @@ +package com.copperleaf.ballast.scheduler.schedule.expression + +import com.copperleaf.ballast.scheduler.schedule.CronExpression +import com.copperleaf.ballast.scheduler.schedule.DayOfMonthField +import com.copperleaf.ballast.scheduler.schedule.DayOfWeekField +import com.copperleaf.ballast.scheduler.schedule.HourField +import com.copperleaf.ballast.scheduler.schedule.MinuteField +import com.copperleaf.ballast.scheduler.schedule.MonthField +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.toInstant +import kotlin.test.Test +import kotlin.test.assertEquals + +class TestNextDayOfWeekCronExpression { + + val timeZone = TimeZone.UTC + val startDay = LocalDate(2023, Month.JANUARY, 1) // starts on a Sunday + val startInstant = startDay.atStartOfDayIn(timeZone) + + @Test + fun testNextMatchingInstant() { + val cronExpression = CronExpression( + minute = MinuteField.anyValue(), + hour = HourField.anyValue(), + dayOfMonth = DayOfMonthField.anyValue(), + month = MonthField.anyValue(), + dayOfWeek = DayOfWeekField.exactValue(DayOfWeek.TUESDAY), + timeZone = timeZone, + ) + + assertEquals( + actual = cronExpression.nextMatchingInstant(startInstant), + expected = LocalDateTime( + date = LocalDate(year = 2023, month = Month.JANUARY, day = 3), + time = LocalTime(hour = 0, minute = 0), + ).toInstant(timeZone), + ) + } +} diff --git a/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/expression/TestNextHourCronExpression.kt b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/expression/TestNextHourCronExpression.kt new file mode 100644 index 00000000..6cf0f93d --- /dev/null +++ b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/expression/TestNextHourCronExpression.kt @@ -0,0 +1,44 @@ +package com.copperleaf.ballast.scheduler.schedule.expression + +import com.copperleaf.ballast.scheduler.schedule.CronExpression +import com.copperleaf.ballast.scheduler.schedule.DayOfMonthField +import com.copperleaf.ballast.scheduler.schedule.DayOfWeekField +import com.copperleaf.ballast.scheduler.schedule.HourField +import com.copperleaf.ballast.scheduler.schedule.MinuteField +import com.copperleaf.ballast.scheduler.schedule.MonthField +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.toInstant +import kotlin.test.Test +import kotlin.test.assertEquals + +class TestNextHourCronExpression { + + val timeZone = TimeZone.UTC + val startDay = LocalDate(2023, Month.JANUARY, 1) // starts on a Sunday + val startInstant = startDay.atStartOfDayIn(timeZone) + + @Test + fun testNextMatchingInstant() { + val cronExpression = CronExpression( + minute = MinuteField.anyValue(), + hour = HourField.exactValue(1), + dayOfMonth = DayOfMonthField.anyValue(), + month = MonthField.anyValue(), + dayOfWeek = DayOfWeekField.anyValue(), + timeZone = timeZone, + ) + + assertEquals( + actual = cronExpression.nextMatchingInstant(startInstant), + expected = LocalDateTime( + date = LocalDate(year = 2023, month = Month.JANUARY, day = 1), + time = LocalTime(hour = 1, minute = 0), + ).toInstant(timeZone), + ) + } +} diff --git a/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/expression/TestNextMinuteCronExpression.kt b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/expression/TestNextMinuteCronExpression.kt new file mode 100644 index 00000000..43e0d5ad --- /dev/null +++ b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/expression/TestNextMinuteCronExpression.kt @@ -0,0 +1,44 @@ +package com.copperleaf.ballast.scheduler.schedule.expression + +import com.copperleaf.ballast.scheduler.schedule.CronExpression +import com.copperleaf.ballast.scheduler.schedule.DayOfMonthField +import com.copperleaf.ballast.scheduler.schedule.DayOfWeekField +import com.copperleaf.ballast.scheduler.schedule.HourField +import com.copperleaf.ballast.scheduler.schedule.MinuteField +import com.copperleaf.ballast.scheduler.schedule.MonthField +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.toInstant +import kotlin.test.Test +import kotlin.test.assertEquals + +class TestNextMinuteCronExpression { + + val timeZone = TimeZone.UTC + val startDay = LocalDate(2023, Month.JANUARY, 1) // starts on a Sunday + val startInstant = startDay.atStartOfDayIn(timeZone) + + @Test + fun testNextMatchingInstant() { + val cronExpression = CronExpression( + minute = MinuteField.exactValue(1), + hour = HourField.anyValue(), + dayOfMonth = DayOfMonthField.anyValue(), + month = MonthField.anyValue(), + dayOfWeek = DayOfWeekField.anyValue(), + timeZone = timeZone, + ) + + assertEquals( + actual = cronExpression.nextMatchingInstant(startInstant), + expected = LocalDateTime( + date = LocalDate(year = 2023, month = Month.JANUARY, day = 1), + time = LocalTime(hour = 0, minute = 1), + ).toInstant(timeZone), + ) + } +} diff --git a/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/expression/TestNextMonthCronExpression.kt b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/expression/TestNextMonthCronExpression.kt new file mode 100644 index 00000000..4168aa29 --- /dev/null +++ b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/expression/TestNextMonthCronExpression.kt @@ -0,0 +1,44 @@ +package com.copperleaf.ballast.scheduler.schedule.expression + +import com.copperleaf.ballast.scheduler.schedule.CronExpression +import com.copperleaf.ballast.scheduler.schedule.DayOfMonthField +import com.copperleaf.ballast.scheduler.schedule.DayOfWeekField +import com.copperleaf.ballast.scheduler.schedule.HourField +import com.copperleaf.ballast.scheduler.schedule.MinuteField +import com.copperleaf.ballast.scheduler.schedule.MonthField +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.toInstant +import kotlin.test.Test +import kotlin.test.assertEquals + +class TestNextMonthCronExpression { + + val timeZone = TimeZone.UTC + val startDay = LocalDate(2023, Month.JANUARY, 1) // starts on a Sunday + val startInstant = startDay.atStartOfDayIn(timeZone) + + @Test + fun testNextMatchingInstant() { + val cronExpression = CronExpression( + minute = MinuteField.anyValue(), + hour = HourField.anyValue(), + dayOfMonth = DayOfMonthField.anyValue(), + month = MonthField.exactValue(Month.FEBRUARY), + dayOfWeek = DayOfWeekField.anyValue(), + timeZone = timeZone, + ) + + assertEquals( + actual = cronExpression.nextMatchingInstant(startInstant), + expected = LocalDateTime( + date = LocalDate(year = 2023, month = Month.FEBRUARY, day = 1), + time = LocalTime(hour = 0, minute = 0), + ).toInstant(timeZone), + ) + } +} diff --git a/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/field/TestBaseField.kt b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/field/TestBaseField.kt new file mode 100644 index 00000000..80796900 --- /dev/null +++ b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/field/TestBaseField.kt @@ -0,0 +1,163 @@ +package com.copperleaf.ballast.scheduler.schedule.field + +import com.copperleaf.ballast.scheduler.schedule.MonthField +import kotlinx.datetime.Month +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TestBaseField { + + @Test + fun testConstructor_ListInt() { + MonthField(listOf(1, 2)) + assertFails { MonthField(emptyList()) } + assertFails { MonthField(listOf(0)) } + assertFails { MonthField(listOf(13)) } + } + + @Test + fun testConstructor_VarargInt() { + MonthField(1, 2) + assertFails { MonthField(0) } + assertFails { MonthField(13) } + } + + @Test + fun testConstructor_IntRange() { + MonthField(1..2) + MonthField(1 until 2) + assertFails { MonthField(0..2) } + assertFails { MonthField(1..13) } + } + + @Test + fun testConstructor_IntProgression() { + MonthField(1..12 step 4) + MonthField(1 until 12 step 4) + assertFails { MonthField(0..2 step 1) } + assertFails { MonthField(1..13 step 2) } + } + + @Test + fun testConstructor_ListMonth() { + MonthField(listOf(Month.JANUARY, Month.FEBRUARY)) + assertFails { MonthField(emptyList()) } + } + + @Test + fun testConstructor_VarargMonth() { + MonthField(Month.JANUARY, Month.FEBRUARY) + } + + @Test + fun testConstructor_MonthEnumEntries() { + MonthField(Month.entries) + } + + @Test + fun testMatches() { + MonthField(listOf(1, 2)).apply { + assertFalse { matches(0) } + assertTrue { matches(1) } + assertTrue { matches(2) } + assertFalse { matches(3) } + assertFalse { matches(4) } + assertFalse { matches(5) } + assertFalse { matches(6) } + assertFalse { matches(7) } + assertFalse { matches(8) } + assertFalse { matches(9) } + assertFalse { matches(10) } + assertFalse { matches(11) } + assertFalse { matches(12) } + assertFalse { matches(13) } + } + MonthField(listOf(1, 4)).apply { + assertFalse { matches(0) } + assertTrue { matches(1) } + assertFalse { matches(2) } + assertFalse { matches(3) } + assertTrue { matches(4) } + assertFalse { matches(5) } + assertFalse { matches(6) } + assertFalse { matches(7) } + assertFalse { matches(8) } + assertFalse { matches(9) } + assertFalse { matches(10) } + assertFalse { matches(11) } + assertFalse { matches(12) } + assertFalse { matches(13) } + } + MonthField(listOf(1, 2, 3, 4)).apply { + assertFalse { matches(0) } + assertTrue { matches(1) } + assertTrue { matches(2) } + assertTrue { matches(3) } + assertTrue { matches(4) } + assertFalse { matches(5) } + assertFalse { matches(6) } + assertFalse { matches(7) } + assertFalse { matches(8) } + assertFalse { matches(9) } + assertFalse { matches(10) } + assertFalse { matches(11) } + assertFalse { matches(12) } + assertFalse { matches(13) } + } + } + + @Test + fun testNextOrSame() { + MonthField(listOf(1, 2)).apply { + assertEquals(null, nextOrSame(0)) + assertEquals(1, nextOrSame(1)) + assertEquals(2, nextOrSame(2)) + assertEquals(null, nextOrSame(3)) + assertEquals(null, nextOrSame(4)) + assertEquals(null, nextOrSame(5)) + assertEquals(null, nextOrSame(6)) + assertEquals(null, nextOrSame(7)) + assertEquals(null, nextOrSame(8)) + assertEquals(null, nextOrSame(9)) + assertEquals(null, nextOrSame(10)) + assertEquals(null, nextOrSame(11)) + assertEquals(null, nextOrSame(12)) + assertEquals(null, nextOrSame(13)) + } + MonthField(listOf(1, 4)).apply { + assertEquals(null, nextOrSame(0)) + assertEquals(1, nextOrSame(1)) + assertEquals(4, nextOrSame(2)) + assertEquals(4, nextOrSame(3)) + assertEquals(4, nextOrSame(4)) + assertEquals(null, nextOrSame(5)) + assertEquals(null, nextOrSame(6)) + assertEquals(null, nextOrSame(7)) + assertEquals(null, nextOrSame(8)) + assertEquals(null, nextOrSame(9)) + assertEquals(null, nextOrSame(10)) + assertEquals(null, nextOrSame(11)) + assertEquals(null, nextOrSame(12)) + assertEquals(null, nextOrSame(13)) + } + MonthField(listOf(1, 2, 3, 4)).apply { + assertEquals(null, nextOrSame(0)) + assertEquals(1, nextOrSame(1)) + assertEquals(2, nextOrSame(2)) + assertEquals(3, nextOrSame(3)) + assertEquals(4, nextOrSame(4)) + assertEquals(null, nextOrSame(5)) + assertEquals(null, nextOrSame(6)) + assertEquals(null, nextOrSame(7)) + assertEquals(null, nextOrSame(8)) + assertEquals(null, nextOrSame(9)) + assertEquals(null, nextOrSame(10)) + assertEquals(null, nextOrSame(11)) + assertEquals(null, nextOrSame(12)) + assertEquals(null, nextOrSame(13)) + } + } +} diff --git a/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/field/TestDayOfMonthField.kt b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/field/TestDayOfMonthField.kt new file mode 100644 index 00000000..1bd2fed5 --- /dev/null +++ b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/field/TestDayOfMonthField.kt @@ -0,0 +1,106 @@ +package com.copperleaf.ballast.scheduler.schedule.field + +import com.copperleaf.ballast.scheduler.schedule.DayOfMonthField +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails + +class TestDayOfMonthField { + + @Test + fun testNonWildcardValueFactoryFunctions() { + DayOfMonthField(1) + DayOfMonthField(1, 2, 3) + DayOfMonthField(listOf(1, 2)) + DayOfMonthField(1..31) + DayOfMonthField(1..31 step 4) + DayOfMonthField(31 downTo 1) + DayOfMonthField(31 downTo 1 step 4) + } + + @Test + fun testWildcardValueFactoryFunctions() { + DayOfMonthField(1, wildcard = true) + DayOfMonthField(1, 2, 3, wildcard = true) + DayOfMonthField(listOf(1, 2), true) + DayOfMonthField(1..31, true) + DayOfMonthField(1..31 step 4, true) + DayOfMonthField(31 downTo 1, true) + DayOfMonthField(31 downTo 1 step 4, true) + } + + @Test + fun testCronExpressionFactoryFunctions() { + DayOfMonthField.anyValue().let { + assertEquals( + actual = it.values, + expected = listOf( + 1, 2, 3, 4, 5, 6, 7, 8, 9, + 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, + 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, + 30, 31, + ) + ) + assertEquals( + actual = it.wildcard, + expected = true, + ) + } + DayOfMonthField.anyValue(step = 15).let { + assertEquals( + actual = it.values, + expected = listOf(1, 16, 31) + ) + assertEquals( + actual = it.wildcard, + expected = true, + ) + } + DayOfMonthField.anyValue(step = 30).let { + assertEquals( + actual = it.values, + expected = listOf(1, 31) + ) + assertEquals( + actual = it.wildcard, + expected = true, + ) + } + DayOfMonthField.exactValue(30).let { + assertEquals( + actual = it.values, + expected = listOf(30) + ) + assertEquals( + actual = it.wildcard, + expected = false, + ) + } + DayOfMonthField.range(10, 20).let { + assertEquals( + actual = it.values, + expected = listOf(10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20) + ) + assertEquals( + actual = it.wildcard, + expected = false, + ) + } + DayOfMonthField.range(10, 20, step = 2).let { + assertEquals( + actual = it.values, + expected = listOf(10, 12, 14, 16, 18, 20) + ) + assertEquals( + actual = it.wildcard, + expected = false, + ) + } + } + + @Test + fun testInvalidValueFactoryFunctions() { + assertFails { DayOfMonthField(DayOfMonthField.MIN_VALUE - 1) } + assertFails { DayOfMonthField(DayOfMonthField.MAX_VALUE + 1) } + } +} diff --git a/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/field/TestDayOfWeekField.kt b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/field/TestDayOfWeekField.kt new file mode 100644 index 00000000..69703b2d --- /dev/null +++ b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/field/TestDayOfWeekField.kt @@ -0,0 +1,120 @@ +package com.copperleaf.ballast.scheduler.schedule.field + +import com.copperleaf.ballast.scheduler.schedule.DayOfWeekField +import kotlinx.datetime.DayOfWeek +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails + +class TestDayOfWeekField { + + @Test + fun testNonWildcardValueFactoryFunctions() { + DayOfWeekField(1) + DayOfWeekField(1, 2, 3) + DayOfWeekField(listOf(1, 2)) + DayOfWeekField(0..7) + DayOfWeekField(0..7 step 4) + DayOfWeekField(6 downTo 0) + DayOfWeekField(6 downTo 0 step 4) + DayOfWeekField(DayOfWeek.SUNDAY) + DayOfWeekField(DayOfWeek.SUNDAY, DayOfWeek.MONDAY) + DayOfWeekField(listOf(DayOfWeek.SUNDAY, DayOfWeek.MONDAY)) + DayOfWeekField(DayOfWeek.entries) + } + + @Test + fun testWildcardValueFactoryFunctions() { + DayOfWeekField(1, wildcard = true) + DayOfWeekField(1, 2, 3, wildcard = true) + DayOfWeekField(listOf(1, 2), true) + DayOfWeekField(0..7, true) + DayOfWeekField(0..7 step 4, true) + DayOfWeekField(6 downTo 0, true) + DayOfWeekField(6 downTo 0 step 4, true) + DayOfWeekField(DayOfWeek.SUNDAY, wildcard = true) + DayOfWeekField(DayOfWeek.SUNDAY, DayOfWeek.MONDAY, wildcard = true) + DayOfWeekField(listOf(DayOfWeek.SUNDAY, DayOfWeek.MONDAY), true) + DayOfWeekField(DayOfWeek.entries, true) + } + + @Test + fun testCronExpressionFactoryFunctions() { + DayOfWeekField.anyValue().let { + assertEquals( + actual = it.values, + expected = listOf(0, 1, 2, 3, 4, 5, 6) + ) + assertEquals( + actual = it.wildcard, + expected = true, + ) + } + DayOfWeekField.anyValue(step = 2).let { + assertEquals( + actual = it.values, + expected = listOf(0, 2, 4, 6) + ) + assertEquals( + actual = it.wildcard, + expected = true, + ) + } + DayOfWeekField.anyValue(step = 5).let { + assertEquals( + actual = it.values, + expected = listOf(0, 5) + ) + assertEquals( + actual = it.wildcard, + expected = true, + ) + } + DayOfWeekField.exactValue(4).let { + assertEquals( + actual = it.values, + expected = listOf(4) + ) + assertEquals( + actual = it.wildcard, + expected = false, + ) + } + DayOfWeekField.exactValue(7).let { + assertEquals( + actual = it.values, + expected = listOf(0) + ) + assertEquals( + actual = it.wildcard, + expected = false, + ) + } + DayOfWeekField.range(2, 5).let { + assertEquals( + actual = it.values, + expected = listOf(2, 3, 4, 5) + ) + assertEquals( + actual = it.wildcard, + expected = false, + ) + } + DayOfWeekField.range(2, 5, step = 2).let { + assertEquals( + actual = it.values, + expected = listOf(2, 4) + ) + assertEquals( + actual = it.wildcard, + expected = false, + ) + } + } + + @Test + fun testInvalidValueFactoryFunctions() { + assertFails { DayOfWeekField(DayOfWeekField.MIN_VALUE - 1) } + assertFails { DayOfWeekField(DayOfWeekField.MAX_PARSED_VALUE + 1) } + } +} diff --git a/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/field/TestHourField.kt b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/field/TestHourField.kt new file mode 100644 index 00000000..54c76be4 --- /dev/null +++ b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/field/TestHourField.kt @@ -0,0 +1,105 @@ +package com.copperleaf.ballast.scheduler.schedule.field + +import com.copperleaf.ballast.scheduler.schedule.HourField +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails + +class TestHourField { + + @Test + fun testNonWildcardValueFactoryFunctions() { + HourField(1) + HourField(1, 2, 3) + HourField(listOf(1, 2)) + HourField(0..23) + HourField(0..23 step 4) + HourField(23 downTo 1) + HourField(23 downTo 1 step 4) + } + + @Test + fun testWildcardValueFactoryFunctions() { + HourField(1, wildcard = true) + HourField(1, 2, 3, wildcard = true) + HourField(listOf(1, 2), true) + HourField(0..23, true) + HourField(0..23 step 4, true) + HourField(23 downTo 1, true) + HourField(23 downTo 1 step 4, true) + } + + @Test + fun testCronExpressionFactoryFunctions() { + HourField.anyValue().let { + assertEquals( + actual = it.values, + expected = listOf( + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, + 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, + 20, 21, 22, 23, + ) + ) + assertEquals( + actual = it.wildcard, + expected = true, + ) + } + HourField.anyValue(step = 15).let { + assertEquals( + actual = it.values, + expected = listOf(0, 15) + ) + assertEquals( + actual = it.wildcard, + expected = true, + ) + } + HourField.anyValue(step = 4).let { + assertEquals( + actual = it.values, + expected = listOf(0, 4, 8, 12, 16, 20) + ) + assertEquals( + actual = it.wildcard, + expected = true, + ) + } + HourField.exactValue(20).let { + assertEquals( + actual = it.values, + expected = listOf(20) + ) + assertEquals( + actual = it.wildcard, + expected = false, + ) + } + HourField.range(10, 20).let { + assertEquals( + actual = it.values, + expected = listOf(10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20) + ) + assertEquals( + actual = it.wildcard, + expected = false, + ) + } + HourField.range(10, 20, step = 2).let { + assertEquals( + actual = it.values, + expected = listOf(10, 12, 14, 16, 18, 20) + ) + assertEquals( + actual = it.wildcard, + expected = false, + ) + } + } + + @Test + fun testInvalidValueFactoryFunctions() { + assertFails { HourField(HourField.MIN_VALUE - 1) } + assertFails { HourField(HourField.MAX_VALUE + 1) } + } +} diff --git a/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/field/TestMinuteField.kt b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/field/TestMinuteField.kt new file mode 100644 index 00000000..ec2f6df8 --- /dev/null +++ b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/field/TestMinuteField.kt @@ -0,0 +1,108 @@ +package com.copperleaf.ballast.scheduler.schedule.field + +import com.copperleaf.ballast.scheduler.schedule.MinuteField +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails + +class TestMinuteField { + + @Test + fun testNonWildcardValueFactoryFunctions() { + MinuteField(1) + MinuteField(1, 2, 3) + MinuteField(listOf(1, 2)) + MinuteField(1..12) + MinuteField(1..12 step 4) + MinuteField(12 downTo 1) + MinuteField(12 downTo 1 step 4) + } + + @Test + fun testWildcardValueFactoryFunctions() { + MinuteField(1, wildcard = true) + MinuteField(1, 2, 3, wildcard = true) + MinuteField(listOf(1, 2), true) + MinuteField(1..12, true) + MinuteField(1..12 step 4, true) + MinuteField(12 downTo 1, true) + MinuteField(12 downTo 1 step 4, true) + } + + @Test + fun testCronExpressionFactoryFunctions() { + MinuteField.anyValue().let { + assertEquals( + actual = it.values, + expected = listOf( + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, + 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, + 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, + 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, + 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, + 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, + ) + ) + assertEquals( + actual = it.wildcard, + expected = true, + ) + } + MinuteField.anyValue(step = 15).let { + assertEquals( + actual = it.values, + expected = listOf(0, 15, 30, 45) + ) + assertEquals( + actual = it.wildcard, + expected = true, + ) + } + MinuteField.anyValue(step = 30).let { + assertEquals( + actual = it.values, + expected = listOf(0, 30) + ) + assertEquals( + actual = it.wildcard, + expected = true, + ) + } + MinuteField.exactValue(30).let { + assertEquals( + actual = it.values, + expected = listOf(30) + ) + assertEquals( + actual = it.wildcard, + expected = false, + ) + } + MinuteField.range(10, 20).let { + assertEquals( + actual = it.values, + expected = listOf(10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20) + ) + assertEquals( + actual = it.wildcard, + expected = false, + ) + } + MinuteField.range(10, 20, step = 2).let { + assertEquals( + actual = it.values, + expected = listOf(10, 12, 14, 16, 18, 20) + ) + assertEquals( + actual = it.wildcard, + expected = false, + ) + } + } + + @Test + fun testInvalidFactoryFunctions() { + assertFails { MinuteField(MinuteField.MIN_VALUE - 1) } + assertFails { MinuteField(MinuteField.MAX_VALUE + 1) } + } +} diff --git a/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/field/TestMonthField.kt b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/field/TestMonthField.kt new file mode 100644 index 00000000..048bfc7b --- /dev/null +++ b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/field/TestMonthField.kt @@ -0,0 +1,113 @@ +package com.copperleaf.ballast.scheduler.schedule.field + +import com.copperleaf.ballast.scheduler.schedule.MonthField +import kotlinx.datetime.Month +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails + +class TestMonthField { + + @Test + fun testNonWildcardValueFactoryFunctions() { + MonthField(1) + MonthField(1, 2, 3) + MonthField(listOf(1, 2)) + MonthField(1..12) + MonthField(1..12 step 4) + MonthField(12 downTo 1) + MonthField(12 downTo 1 step 4) + MonthField(Month.JANUARY) + MonthField(Month.JANUARY, Month.FEBRUARY) + MonthField(listOf(Month.JANUARY, Month.FEBRUARY)) + MonthField(Month.entries) + } + + @Test + fun testWildcardValueFactoryFunctions() { + MonthField(1, wildcard = true) + MonthField(1, 2, 3, wildcard = true) + MonthField(listOf(1, 2), true) + MonthField(1..12, true) + MonthField(1..12 step 4, true) + MonthField(12 downTo 1, true) + MonthField(12 downTo 1 step 4, true) + MonthField(Month.JANUARY, wildcard = true) + MonthField(Month.JANUARY, Month.FEBRUARY, wildcard = true) + MonthField(listOf(Month.JANUARY, Month.FEBRUARY), true) + MonthField(Month.entries, true) + } + + @Test + fun testCronExpressionFactoryFunctions() { + MonthField.anyValue().let { + assertEquals( + actual = it.values, + expected = listOf( + 1, 2, 3, 4, 5, 6, 7, 8, 9, + 10, 11, 12 + ) + ) + assertEquals( + actual = it.wildcard, + expected = true, + ) + } + MonthField.anyValue(step = 4).let { + assertEquals( + actual = it.values, + expected = listOf(1, 5, 9) + ) + assertEquals( + actual = it.wildcard, + expected = true, + ) + } + MonthField.anyValue(step = 6).let { + assertEquals( + actual = it.values, + expected = listOf(1, 7) + ) + assertEquals( + actual = it.wildcard, + expected = true, + ) + } + MonthField.exactValue(8).let { + assertEquals( + actual = it.values, + expected = listOf(8) + ) + assertEquals( + actual = it.wildcard, + expected = false, + ) + } + MonthField.range(4, 12).let { + assertEquals( + actual = it.values, + expected = listOf(4, 5, 6, 7, 8, 9, 10, 11, 12) + ) + assertEquals( + actual = it.wildcard, + expected = false, + ) + } + MonthField.range(4, 12, step = 2).let { + assertEquals( + actual = it.values, + expected = listOf(4, 6, 8, 10, 12) + ) + assertEquals( + actual = it.wildcard, + expected = false, + ) + } + } + + @Test + fun testInvalidValueFactoryFunctions() { + assertFails { MonthField(MonthField.MIN_VALUE - 1) } + assertFails { MonthField(MonthField.MAX_VALUE + 1) } + } +} diff --git a/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/testUtils.kt b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/testUtils.kt new file mode 100644 index 00000000..ac8af4ae --- /dev/null +++ b/ballast-scheduler-cron/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/testUtils.kt @@ -0,0 +1,13 @@ +package com.copperleaf.ballast.scheduler + +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Instant + +fun Sequence.firstTen(timeZone: TimeZone): List { + return this + .map { it.toLocalDateTime(timeZone) } + .take(10) + .toList() +} diff --git a/ballast-scheduler-viewmodel/README.md b/ballast-scheduler-viewmodel/README.md new file mode 100644 index 00000000..c42e0125 --- /dev/null +++ b/ballast-scheduler-viewmodel/README.md @@ -0,0 +1,92 @@ +# Ballast Scheduler ViewModel + +> [!CAUTION] +> +> Experimental. This module may not still have issues or changes in its public API before being considered stable. +> Please use at your own risk, and file Issues for any problems you may encounter. + +## Overview + +Integrates [Ballast Scheduler Core](./../ballast-scheduler-core) with the Ballast ViewModel system, allowing +schedules to dispatch Inputs directly to your ViewModels at the configured times with an in-memory non-persistent +scheduler. Add the `SchedulerInterceptor` to your ViewModel configuration to attach one or more schedules to that +ViewModel. + +## Supported Platforms + +| Platform | Supported | +|----------|-----------| +| JVM | ✅ | +| Android | ✅ | +| iOS | ✅ | +| JS | ✅ | +| WASM JS | ✅ | + +## See Also + +- [Ballast Scheduler Core](./../ballast-scheduler-core) +- [Ballast Scheduler Cron](./../ballast-scheduler-cron) + +## Usage + +Add a `SchedulerInterceptor` to your ViewModel configuration with a `SchedulerAdapter` that registers one or more +schedules. Each schedule produces a named `Instant` sequence from +[Ballast Scheduler Core](./../ballast-scheduler-core), and the interceptor dispatches the corresponding Input to +your ViewModel at each scheduled moment. + +```kotlin +class ExampleViewModel(coroutineScope: CoroutineScope) : BasicViewModel< + ExampleContract.Inputs, + ExampleContract.Events, + ExampleContract.State + >( + coroutineScope = coroutineScope, + config = BallastViewModelConfiguration.Builder() + .withViewModel( + initialState = ExampleContract.State(), + inputHandler = ExampleInputHandler(), + name = "Example" + ) + .apply { + this += SchedulerInterceptor< + ExampleContract.Inputs, + ExampleContract.Events, + ExampleContract.State> { + onSchedule( + schedule = EveryHourSchedule().named("HourlyRefresh"), + scheduledInput = { ExampleContract.Inputs.Refresh }, + ) + onSchedule( + schedule = EveryDaySchedule(LocalTime(2, 0)).named("DailyCleanup"), + scheduledInput = { ExampleContract.Inputs.Cleanup }, + ) + } + } + .build(), + eventHandler = eventHandler { }, +) +``` + +## Installation + +```kotlin +repositories { + mavenCentral() +} + +// for plain JVM or Android projects +dependencies { + implementation("io.github.copper-leaf:ballast-scheduler-viewmodel:{{ballastVersion}}") +} + +// for multiplatform projects +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + implementation("io.github.copper-leaf:ballast-scheduler-viewmodel:{{ballastVersion}}") + } + } + } +} +``` diff --git a/ballast-scheduler-viewmodel/api/android/ballast-scheduler-viewmodel.api b/ballast-scheduler-viewmodel/api/android/ballast-scheduler-viewmodel.api new file mode 100644 index 00000000..f0f14fbe --- /dev/null +++ b/ballast-scheduler-viewmodel/api/android/ballast-scheduler-viewmodel.api @@ -0,0 +1,153 @@ +public abstract interface class com/copperleaf/ballast/scheduler/SchedulerAdapter { + public abstract fun configureSchedules (Lcom/copperleaf/ballast/scheduler/SchedulerAdapterScope;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class com/copperleaf/ballast/scheduler/SchedulerAdapterScope { + public abstract fun onSchedule (Lcom/copperleaf/ballast/scheduler/NamedSchedule;Lkotlin/jvm/functions/Function1;)V +} + +public final class com/copperleaf/ballast/scheduler/SchedulerControllerKt { + public static final fun scheduler (Lcom/copperleaf/ballast/SideJobScope;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun withSchedulerController (Lcom/copperleaf/ballast/BallastViewModelConfiguration$Builder;Lkotlin/time/Clock;Lcom/copperleaf/ballast/scheduler/ScheduleExecutor;)Lcom/copperleaf/ballast/BallastViewModelConfiguration$TypedBuilder; + public static synthetic fun withSchedulerController$default (Lcom/copperleaf/ballast/BallastViewModelConfiguration$Builder;Lkotlin/time/Clock;Lcom/copperleaf/ballast/scheduler/ScheduleExecutor;ILjava/lang/Object;)Lcom/copperleaf/ballast/BallastViewModelConfiguration$TypedBuilder; +} + +public final class com/copperleaf/ballast/scheduler/SchedulerInterceptor : com/copperleaf/ballast/BallastInterceptor { + public fun ()V + public fun (Lkotlin/time/Clock;Lcom/copperleaf/ballast/scheduler/ScheduleExecutor;Lkotlin/jvm/functions/Function1;Lcom/copperleaf/ballast/scheduler/SchedulerAdapter;)V + public synthetic fun (Lkotlin/time/Clock;Lcom/copperleaf/ballast/scheduler/ScheduleExecutor;Lkotlin/jvm/functions/Function1;Lcom/copperleaf/ballast/scheduler/SchedulerAdapter;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getController ()Lcom/copperleaf/ballast/BallastViewModel; + public fun getKey ()Lcom/copperleaf/ballast/BallastInterceptor$Key; + public fun start (Lcom/copperleaf/ballast/BallastInterceptorScope;Lkotlinx/coroutines/flow/Flow;)V + public fun toString ()Ljava/lang/String; +} + +public final class com/copperleaf/ballast/scheduler/SchedulerInterceptor$Key : com/copperleaf/ballast/BallastInterceptor$Key { + public static final field INSTANCE Lcom/copperleaf/ballast/scheduler/SchedulerInterceptor$Key; +} + +public final class com/copperleaf/ballast/scheduler/vm/ScheduleState { + public fun (Ljava/lang/String;Lkotlin/time/Instant;ZLkotlin/time/Instant;Lkotlin/time/Instant;I)V + public synthetic fun (Ljava/lang/String;Lkotlin/time/Instant;ZLkotlin/time/Instant;Lkotlin/time/Instant;IILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Lkotlin/time/Instant; + public final fun component3 ()Z + public final fun component4 ()Lkotlin/time/Instant; + public final fun component5 ()Lkotlin/time/Instant; + public final fun component6 ()I + public final fun copy (Ljava/lang/String;Lkotlin/time/Instant;ZLkotlin/time/Instant;Lkotlin/time/Instant;I)Lcom/copperleaf/ballast/scheduler/vm/ScheduleState; + public static synthetic fun copy$default (Lcom/copperleaf/ballast/scheduler/vm/ScheduleState;Ljava/lang/String;Lkotlin/time/Instant;ZLkotlin/time/Instant;Lkotlin/time/Instant;IILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/vm/ScheduleState; + public fun equals (Ljava/lang/Object;)Z + public final fun getFirstUpdateAt ()Lkotlin/time/Instant; + public final fun getKey ()Ljava/lang/String; + public final fun getLatestUpdateAt ()Lkotlin/time/Instant; + public final fun getNumberOfDispatchedInputs ()I + public final fun getPaused ()Z + public final fun getStartedAt ()Lkotlin/time/Instant; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/copperleaf/ballast/scheduler/vm/SchedulerContract { + public static final field INSTANCE Lcom/copperleaf/ballast/scheduler/vm/SchedulerContract; +} + +public abstract interface class com/copperleaf/ballast/scheduler/vm/SchedulerContract$Events { +} + +public final class com/copperleaf/ballast/scheduler/vm/SchedulerContract$Events$PostInputToHost : com/copperleaf/ballast/scheduler/vm/SchedulerContract$Events { + public fun (Lcom/copperleaf/ballast/Queued$HandleInput;)V + public final fun component1 ()Lcom/copperleaf/ballast/Queued$HandleInput; + public final fun copy (Lcom/copperleaf/ballast/Queued$HandleInput;)Lcom/copperleaf/ballast/scheduler/vm/SchedulerContract$Events$PostInputToHost; + public static synthetic fun copy$default (Lcom/copperleaf/ballast/scheduler/vm/SchedulerContract$Events$PostInputToHost;Lcom/copperleaf/ballast/Queued$HandleInput;ILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/vm/SchedulerContract$Events$PostInputToHost; + public fun equals (Ljava/lang/Object;)Z + public final fun getQueued ()Lcom/copperleaf/ballast/Queued$HandleInput; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class com/copperleaf/ballast/scheduler/vm/SchedulerContract$Inputs { +} + +public final class com/copperleaf/ballast/scheduler/vm/SchedulerContract$Inputs$CancelSchedule : com/copperleaf/ballast/scheduler/vm/SchedulerContract$Inputs { + public fun (Ljava/lang/String;)V + public final fun getKey ()Ljava/lang/String; +} + +public final class com/copperleaf/ballast/scheduler/vm/SchedulerContract$Inputs$DispatchScheduledTask : com/copperleaf/ballast/scheduler/vm/SchedulerContract$Inputs { + public fun (Ljava/lang/String;Lcom/copperleaf/ballast/Queued$HandleInput;)V + public final fun getKey ()Ljava/lang/String; + public final fun getQueued ()Lcom/copperleaf/ballast/Queued$HandleInput; +} + +public final class com/copperleaf/ballast/scheduler/vm/SchedulerContract$Inputs$MarkScheduleComplete : com/copperleaf/ballast/scheduler/vm/SchedulerContract$Inputs { + public fun (Ljava/lang/String;)V + public final fun getKey ()Ljava/lang/String; +} + +public final class com/copperleaf/ballast/scheduler/vm/SchedulerContract$Inputs$PauseSchedule : com/copperleaf/ballast/scheduler/vm/SchedulerContract$Inputs { + public fun (Ljava/lang/String;)V + public final fun getKey ()Ljava/lang/String; +} + +public final class com/copperleaf/ballast/scheduler/vm/SchedulerContract$Inputs$ResumeSchedule : com/copperleaf/ballast/scheduler/vm/SchedulerContract$Inputs { + public fun (Ljava/lang/String;)V + public final fun getKey ()Ljava/lang/String; +} + +public final class com/copperleaf/ballast/scheduler/vm/SchedulerContract$Inputs$StartSchedule : com/copperleaf/ballast/scheduler/vm/SchedulerContract$Inputs { + public fun (Lcom/copperleaf/ballast/scheduler/NamedSchedule;Lkotlin/jvm/functions/Function1;)V + public final fun getSchedule ()Lcom/copperleaf/ballast/scheduler/NamedSchedule; + public final fun getScheduledInput ()Lkotlin/jvm/functions/Function1; +} + +public final class com/copperleaf/ballast/scheduler/vm/SchedulerContract$Inputs$StartSchedules : com/copperleaf/ballast/scheduler/vm/SchedulerContract$Inputs { + public fun (Lcom/copperleaf/ballast/scheduler/SchedulerAdapter;)V + public final fun getAdapter ()Lcom/copperleaf/ballast/scheduler/SchedulerAdapter; +} + +public final class com/copperleaf/ballast/scheduler/vm/SchedulerContract$State { + public fun ()V + public fun (ILjava/util/Map;)V + public synthetic fun (ILjava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()I + public final fun component2 ()Ljava/util/Map; + public final fun copy (ILjava/util/Map;)Lcom/copperleaf/ballast/scheduler/vm/SchedulerContract$State; + public static synthetic fun copy$default (Lcom/copperleaf/ballast/scheduler/vm/SchedulerContract$State;ILjava/util/Map;ILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/vm/SchedulerContract$State; + public fun equals (Ljava/lang/Object;)Z + public final fun getScheduleIndex ()I + public final fun getSchedules ()Ljava/util/Map; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/copperleaf/ballast/scheduler/vm/SchedulerFifoInputStrategy : com/copperleaf/ballast/core/ChannelInputStrategy { + public static final field Companion Lcom/copperleaf/ballast/scheduler/vm/SchedulerFifoInputStrategy$Companion; + public synthetic fun (Lcom/copperleaf/ballast/InputFilter;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun processInputs (Lcom/copperleaf/ballast/InputStrategyScope;Lkotlinx/coroutines/flow/Flow;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/copperleaf/ballast/scheduler/vm/SchedulerFifoInputStrategy$Companion { + public final fun invoke ()Lcom/copperleaf/ballast/scheduler/vm/SchedulerFifoInputStrategy; + public final fun typed (Lcom/copperleaf/ballast/InputFilter;)Lcom/copperleaf/ballast/scheduler/vm/SchedulerFifoInputStrategy; + public static synthetic fun typed$default (Lcom/copperleaf/ballast/scheduler/vm/SchedulerFifoInputStrategy$Companion;Lcom/copperleaf/ballast/InputFilter;ILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/vm/SchedulerFifoInputStrategy; +} + +public class com/copperleaf/ballast/scheduler/vm/SchedulerFifoInputStrategy$Guardian : com/copperleaf/ballast/InputStrategy$Guardian { + public fun ()V + public fun checkNoOp ()V + public fun checkPostEvent ()V + public fun checkSideJob ()V + public fun checkStateAccess ()V + public fun checkStateUpdate ()V + public fun close ()V + protected final fun getClosed ()Z + protected final fun getSideJobsPosted ()Z + protected final fun getStateAccessed ()Z + protected final fun getUsedProperly ()Z + protected final fun setClosed (Z)V + protected final fun setSideJobsPosted (Z)V + protected final fun setStateAccessed (Z)V + protected final fun setUsedProperly (Z)V +} + diff --git a/ballast-scheduler-viewmodel/api/jvm/ballast-scheduler-viewmodel.api b/ballast-scheduler-viewmodel/api/jvm/ballast-scheduler-viewmodel.api new file mode 100644 index 00000000..f0f14fbe --- /dev/null +++ b/ballast-scheduler-viewmodel/api/jvm/ballast-scheduler-viewmodel.api @@ -0,0 +1,153 @@ +public abstract interface class com/copperleaf/ballast/scheduler/SchedulerAdapter { + public abstract fun configureSchedules (Lcom/copperleaf/ballast/scheduler/SchedulerAdapterScope;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class com/copperleaf/ballast/scheduler/SchedulerAdapterScope { + public abstract fun onSchedule (Lcom/copperleaf/ballast/scheduler/NamedSchedule;Lkotlin/jvm/functions/Function1;)V +} + +public final class com/copperleaf/ballast/scheduler/SchedulerControllerKt { + public static final fun scheduler (Lcom/copperleaf/ballast/SideJobScope;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun withSchedulerController (Lcom/copperleaf/ballast/BallastViewModelConfiguration$Builder;Lkotlin/time/Clock;Lcom/copperleaf/ballast/scheduler/ScheduleExecutor;)Lcom/copperleaf/ballast/BallastViewModelConfiguration$TypedBuilder; + public static synthetic fun withSchedulerController$default (Lcom/copperleaf/ballast/BallastViewModelConfiguration$Builder;Lkotlin/time/Clock;Lcom/copperleaf/ballast/scheduler/ScheduleExecutor;ILjava/lang/Object;)Lcom/copperleaf/ballast/BallastViewModelConfiguration$TypedBuilder; +} + +public final class com/copperleaf/ballast/scheduler/SchedulerInterceptor : com/copperleaf/ballast/BallastInterceptor { + public fun ()V + public fun (Lkotlin/time/Clock;Lcom/copperleaf/ballast/scheduler/ScheduleExecutor;Lkotlin/jvm/functions/Function1;Lcom/copperleaf/ballast/scheduler/SchedulerAdapter;)V + public synthetic fun (Lkotlin/time/Clock;Lcom/copperleaf/ballast/scheduler/ScheduleExecutor;Lkotlin/jvm/functions/Function1;Lcom/copperleaf/ballast/scheduler/SchedulerAdapter;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getController ()Lcom/copperleaf/ballast/BallastViewModel; + public fun getKey ()Lcom/copperleaf/ballast/BallastInterceptor$Key; + public fun start (Lcom/copperleaf/ballast/BallastInterceptorScope;Lkotlinx/coroutines/flow/Flow;)V + public fun toString ()Ljava/lang/String; +} + +public final class com/copperleaf/ballast/scheduler/SchedulerInterceptor$Key : com/copperleaf/ballast/BallastInterceptor$Key { + public static final field INSTANCE Lcom/copperleaf/ballast/scheduler/SchedulerInterceptor$Key; +} + +public final class com/copperleaf/ballast/scheduler/vm/ScheduleState { + public fun (Ljava/lang/String;Lkotlin/time/Instant;ZLkotlin/time/Instant;Lkotlin/time/Instant;I)V + public synthetic fun (Ljava/lang/String;Lkotlin/time/Instant;ZLkotlin/time/Instant;Lkotlin/time/Instant;IILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Lkotlin/time/Instant; + public final fun component3 ()Z + public final fun component4 ()Lkotlin/time/Instant; + public final fun component5 ()Lkotlin/time/Instant; + public final fun component6 ()I + public final fun copy (Ljava/lang/String;Lkotlin/time/Instant;ZLkotlin/time/Instant;Lkotlin/time/Instant;I)Lcom/copperleaf/ballast/scheduler/vm/ScheduleState; + public static synthetic fun copy$default (Lcom/copperleaf/ballast/scheduler/vm/ScheduleState;Ljava/lang/String;Lkotlin/time/Instant;ZLkotlin/time/Instant;Lkotlin/time/Instant;IILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/vm/ScheduleState; + public fun equals (Ljava/lang/Object;)Z + public final fun getFirstUpdateAt ()Lkotlin/time/Instant; + public final fun getKey ()Ljava/lang/String; + public final fun getLatestUpdateAt ()Lkotlin/time/Instant; + public final fun getNumberOfDispatchedInputs ()I + public final fun getPaused ()Z + public final fun getStartedAt ()Lkotlin/time/Instant; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/copperleaf/ballast/scheduler/vm/SchedulerContract { + public static final field INSTANCE Lcom/copperleaf/ballast/scheduler/vm/SchedulerContract; +} + +public abstract interface class com/copperleaf/ballast/scheduler/vm/SchedulerContract$Events { +} + +public final class com/copperleaf/ballast/scheduler/vm/SchedulerContract$Events$PostInputToHost : com/copperleaf/ballast/scheduler/vm/SchedulerContract$Events { + public fun (Lcom/copperleaf/ballast/Queued$HandleInput;)V + public final fun component1 ()Lcom/copperleaf/ballast/Queued$HandleInput; + public final fun copy (Lcom/copperleaf/ballast/Queued$HandleInput;)Lcom/copperleaf/ballast/scheduler/vm/SchedulerContract$Events$PostInputToHost; + public static synthetic fun copy$default (Lcom/copperleaf/ballast/scheduler/vm/SchedulerContract$Events$PostInputToHost;Lcom/copperleaf/ballast/Queued$HandleInput;ILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/vm/SchedulerContract$Events$PostInputToHost; + public fun equals (Ljava/lang/Object;)Z + public final fun getQueued ()Lcom/copperleaf/ballast/Queued$HandleInput; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class com/copperleaf/ballast/scheduler/vm/SchedulerContract$Inputs { +} + +public final class com/copperleaf/ballast/scheduler/vm/SchedulerContract$Inputs$CancelSchedule : com/copperleaf/ballast/scheduler/vm/SchedulerContract$Inputs { + public fun (Ljava/lang/String;)V + public final fun getKey ()Ljava/lang/String; +} + +public final class com/copperleaf/ballast/scheduler/vm/SchedulerContract$Inputs$DispatchScheduledTask : com/copperleaf/ballast/scheduler/vm/SchedulerContract$Inputs { + public fun (Ljava/lang/String;Lcom/copperleaf/ballast/Queued$HandleInput;)V + public final fun getKey ()Ljava/lang/String; + public final fun getQueued ()Lcom/copperleaf/ballast/Queued$HandleInput; +} + +public final class com/copperleaf/ballast/scheduler/vm/SchedulerContract$Inputs$MarkScheduleComplete : com/copperleaf/ballast/scheduler/vm/SchedulerContract$Inputs { + public fun (Ljava/lang/String;)V + public final fun getKey ()Ljava/lang/String; +} + +public final class com/copperleaf/ballast/scheduler/vm/SchedulerContract$Inputs$PauseSchedule : com/copperleaf/ballast/scheduler/vm/SchedulerContract$Inputs { + public fun (Ljava/lang/String;)V + public final fun getKey ()Ljava/lang/String; +} + +public final class com/copperleaf/ballast/scheduler/vm/SchedulerContract$Inputs$ResumeSchedule : com/copperleaf/ballast/scheduler/vm/SchedulerContract$Inputs { + public fun (Ljava/lang/String;)V + public final fun getKey ()Ljava/lang/String; +} + +public final class com/copperleaf/ballast/scheduler/vm/SchedulerContract$Inputs$StartSchedule : com/copperleaf/ballast/scheduler/vm/SchedulerContract$Inputs { + public fun (Lcom/copperleaf/ballast/scheduler/NamedSchedule;Lkotlin/jvm/functions/Function1;)V + public final fun getSchedule ()Lcom/copperleaf/ballast/scheduler/NamedSchedule; + public final fun getScheduledInput ()Lkotlin/jvm/functions/Function1; +} + +public final class com/copperleaf/ballast/scheduler/vm/SchedulerContract$Inputs$StartSchedules : com/copperleaf/ballast/scheduler/vm/SchedulerContract$Inputs { + public fun (Lcom/copperleaf/ballast/scheduler/SchedulerAdapter;)V + public final fun getAdapter ()Lcom/copperleaf/ballast/scheduler/SchedulerAdapter; +} + +public final class com/copperleaf/ballast/scheduler/vm/SchedulerContract$State { + public fun ()V + public fun (ILjava/util/Map;)V + public synthetic fun (ILjava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()I + public final fun component2 ()Ljava/util/Map; + public final fun copy (ILjava/util/Map;)Lcom/copperleaf/ballast/scheduler/vm/SchedulerContract$State; + public static synthetic fun copy$default (Lcom/copperleaf/ballast/scheduler/vm/SchedulerContract$State;ILjava/util/Map;ILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/vm/SchedulerContract$State; + public fun equals (Ljava/lang/Object;)Z + public final fun getScheduleIndex ()I + public final fun getSchedules ()Ljava/util/Map; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/copperleaf/ballast/scheduler/vm/SchedulerFifoInputStrategy : com/copperleaf/ballast/core/ChannelInputStrategy { + public static final field Companion Lcom/copperleaf/ballast/scheduler/vm/SchedulerFifoInputStrategy$Companion; + public synthetic fun (Lcom/copperleaf/ballast/InputFilter;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun processInputs (Lcom/copperleaf/ballast/InputStrategyScope;Lkotlinx/coroutines/flow/Flow;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/copperleaf/ballast/scheduler/vm/SchedulerFifoInputStrategy$Companion { + public final fun invoke ()Lcom/copperleaf/ballast/scheduler/vm/SchedulerFifoInputStrategy; + public final fun typed (Lcom/copperleaf/ballast/InputFilter;)Lcom/copperleaf/ballast/scheduler/vm/SchedulerFifoInputStrategy; + public static synthetic fun typed$default (Lcom/copperleaf/ballast/scheduler/vm/SchedulerFifoInputStrategy$Companion;Lcom/copperleaf/ballast/InputFilter;ILjava/lang/Object;)Lcom/copperleaf/ballast/scheduler/vm/SchedulerFifoInputStrategy; +} + +public class com/copperleaf/ballast/scheduler/vm/SchedulerFifoInputStrategy$Guardian : com/copperleaf/ballast/InputStrategy$Guardian { + public fun ()V + public fun checkNoOp ()V + public fun checkPostEvent ()V + public fun checkSideJob ()V + public fun checkStateAccess ()V + public fun checkStateUpdate ()V + public fun close ()V + protected final fun getClosed ()Z + protected final fun getSideJobsPosted ()Z + protected final fun getStateAccessed ()Z + protected final fun getUsedProperly ()Z + protected final fun setClosed (Z)V + protected final fun setSideJobsPosted (Z)V + protected final fun setStateAccessed (Z)V + protected final fun setUsedProperly (Z)V +} + diff --git a/ballast-scheduler-viewmodel/build.gradle.kts b/ballast-scheduler-viewmodel/build.gradle.kts new file mode 100644 index 00000000..9375bd60 --- /dev/null +++ b/ballast-scheduler-viewmodel/build.gradle.kts @@ -0,0 +1,42 @@ +plugins { + id("copper-leaf-base") + id("copper-leaf-android-library") + id("copper-leaf-targets") + id("copper-leaf-tests") + id("copper-leaf-lint") + id("copper-leaf-publish") + id("copper-leaf-serialization") +} + +kotlin { + compilerOptions { + optIn.add("kotlin.time.ExperimentalTime") + } + + sourceSets { + val commonMain by getting { + dependencies { + implementation(project(":ballast-api")) + implementation(project(":ballast-scheduler-core")) + } + } + val commonTest by getting { + dependencies { + implementation(project(":ballast-test")) + } + } + + val jvmMain by getting { + dependencies { } + } + val androidMain by getting { + dependencies { } + } + val jsMain by getting { + dependencies { } + } + val iosMain by getting { + dependencies { } + } + } +} diff --git a/ballast-scheduler-viewmodel/gradle.properties b/ballast-scheduler-viewmodel/gradle.properties new file mode 100644 index 00000000..6560229a --- /dev/null +++ b/ballast-scheduler-viewmodel/gradle.properties @@ -0,0 +1,8 @@ +copperleaf.description=Send Inputs at regular, scheduled intervals. + +copperleaf.targets.android=true +copperleaf.targets.jvm=true +copperleaf.targets.ios=true +copperleaf.targets.js=true +copperleaf.targets.wasm.wasi=false +copperleaf.targets.wasm.js=true diff --git a/ballast-scheduler-viewmodel/src/androidMain/AndroidManifest.xml b/ballast-scheduler-viewmodel/src/androidMain/AndroidManifest.xml new file mode 100644 index 00000000..811d7660 --- /dev/null +++ b/ballast-scheduler-viewmodel/src/androidMain/AndroidManifest.xml @@ -0,0 +1,2 @@ + diff --git a/ballast-scheduler-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/SchedulerAdapter.kt b/ballast-scheduler-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/SchedulerAdapter.kt new file mode 100644 index 00000000..679daac2 --- /dev/null +++ b/ballast-scheduler-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/SchedulerAdapter.kt @@ -0,0 +1,5 @@ +package com.copperleaf.ballast.scheduler + +public fun interface SchedulerAdapter { + public suspend fun SchedulerAdapterScope.configureSchedules() +} diff --git a/ballast-scheduler-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/SchedulerAdapterScope.kt b/ballast-scheduler-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/SchedulerAdapterScope.kt new file mode 100644 index 00000000..db6456f7 --- /dev/null +++ b/ballast-scheduler-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/SchedulerAdapterScope.kt @@ -0,0 +1,11 @@ +package com.copperleaf.ballast.scheduler + +import kotlin.time.Instant + +public interface SchedulerAdapterScope { + + public fun onSchedule( + schedule: NamedSchedule, + scheduledInput: (Instant) -> T, + ) +} diff --git a/ballast-scheduler-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/SchedulerController.kt b/ballast-scheduler-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/SchedulerController.kt new file mode 100644 index 00000000..022f18b9 --- /dev/null +++ b/ballast-scheduler-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/SchedulerController.kt @@ -0,0 +1,40 @@ +package com.copperleaf.ballast.scheduler + +import com.copperleaf.ballast.BallastViewModel +import com.copperleaf.ballast.BallastViewModelConfiguration +import com.copperleaf.ballast.SideJobScope +import com.copperleaf.ballast.scheduler.executor.delay.DelayScheduleExecutor +import com.copperleaf.ballast.scheduler.vm.SchedulerContract +import com.copperleaf.ballast.scheduler.vm.SchedulerFifoInputStrategy +import com.copperleaf.ballast.scheduler.vm.SchedulerInputHandler +import com.copperleaf.ballast.withViewModel +import kotlin.time.Clock + +public typealias SchedulerController = BallastViewModel< + SchedulerContract.Inputs, + SchedulerContract.Events, + SchedulerContract.State> + +public fun BallastViewModelConfiguration.Builder.withSchedulerController( + clock: Clock = Clock.System, + scheduleExecutor: ScheduleExecutor = DelayScheduleExecutor(clock), +): BallastViewModelConfiguration.TypedBuilder< + SchedulerContract.Inputs, + SchedulerContract.Events, + SchedulerContract.State> { + return this + .withViewModel( + initialState = SchedulerContract.State(), + inputHandler = SchedulerInputHandler(clock, scheduleExecutor), + name = "SchedulerController", + ) + .apply { + this.inputStrategy = SchedulerFifoInputStrategy.typed() + } +} + +@Suppress("UNCHECKED_CAST", "OPT_IN_USAGE") +public suspend fun SideJobScope.scheduler(): SchedulerController { + return getInterceptor(SchedulerInterceptor.Key) + .controller as SchedulerController +} diff --git a/ballast-scheduler-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/SchedulerInterceptor.kt b/ballast-scheduler-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/SchedulerInterceptor.kt new file mode 100644 index 00000000..06e0be88 --- /dev/null +++ b/ballast-scheduler-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/SchedulerInterceptor.kt @@ -0,0 +1,62 @@ +package com.copperleaf.ballast.scheduler + +import com.copperleaf.ballast.BallastInterceptor +import com.copperleaf.ballast.BallastInterceptorScope +import com.copperleaf.ballast.BallastNotification +import com.copperleaf.ballast.BallastViewModelConfiguration +import com.copperleaf.ballast.awaitViewModelStart +import com.copperleaf.ballast.build +import com.copperleaf.ballast.internal.BallastViewModelImpl +import com.copperleaf.ballast.scheduler.executor.delay.DelayScheduleExecutor +import com.copperleaf.ballast.scheduler.vm.SchedulerContract +import com.copperleaf.ballast.scheduler.vm.SchedulerEventHandler +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import kotlin.time.Clock + +public class SchedulerInterceptor( + clock: Clock = Clock.System, + scheduleExecutor: ScheduleExecutor = DelayScheduleExecutor(clock), + private val extraConfig: (BallastViewModelConfiguration.Builder) -> BallastViewModelConfiguration.Builder = { it }, + private val initialSchedule: SchedulerAdapter? = null, +) : BallastInterceptor { + + override val key: BallastInterceptor.Key> = SchedulerInterceptor.Key + + private val _controller = BallastViewModelImpl( + "SchedulerController", + BallastViewModelConfiguration.Builder() + .let(extraConfig) + .withSchedulerController( + clock = clock, + scheduleExecutor = scheduleExecutor, + ) + .build() + ) + public val controller: SchedulerController get() = _controller + + override fun BallastInterceptorScope.start( + notifications: Flow>, + ) { + launch(start = CoroutineStart.UNDISPATCHED) { + notifications.awaitViewModelStart() + + _controller.start(this) + + launch { + _controller.attachEventHandler(SchedulerEventHandler(this@start)) + } + + if (initialSchedule != null) { + _controller.send(SchedulerContract.Inputs.StartSchedules(adapter = initialSchedule)) + } + } + } + + override fun toString(): String { + return "SchedulerInterceptor" + } + + public object Key : BallastInterceptor.Key> +} diff --git a/ballast-scheduler-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/internal/RegisteredSchedule.kt b/ballast-scheduler-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/internal/RegisteredSchedule.kt new file mode 100644 index 00000000..2d5ee822 --- /dev/null +++ b/ballast-scheduler-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/internal/RegisteredSchedule.kt @@ -0,0 +1,9 @@ +package com.copperleaf.ballast.scheduler.internal + +import com.copperleaf.ballast.scheduler.NamedSchedule +import kotlin.time.Instant + +internal class RegisteredSchedule( + val schedule: NamedSchedule, + val scheduledInput: (Instant) -> I, +) diff --git a/ballast-scheduler-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/internal/SchedulerAdapterScopeImpl.kt b/ballast-scheduler-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/internal/SchedulerAdapterScopeImpl.kt new file mode 100644 index 00000000..420802d2 --- /dev/null +++ b/ballast-scheduler-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/internal/SchedulerAdapterScopeImpl.kt @@ -0,0 +1,20 @@ +package com.copperleaf.ballast.scheduler.internal + +import com.copperleaf.ballast.scheduler.NamedSchedule +import com.copperleaf.ballast.scheduler.SchedulerAdapterScope +import kotlin.time.Instant + +internal class SchedulerAdapterScopeImpl : SchedulerAdapterScope { + + internal val schedules = mutableListOf>() + + override fun onSchedule( + schedule: NamedSchedule, + scheduledInput: (Instant) -> T, + ) { + schedules += RegisteredSchedule( + schedule = schedule, + scheduledInput = scheduledInput, + ) + } +} diff --git a/ballast-scheduler-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/vm/ScheduleState.kt b/ballast-scheduler-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/vm/ScheduleState.kt new file mode 100644 index 00000000..0f3163d3 --- /dev/null +++ b/ballast-scheduler-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/vm/ScheduleState.kt @@ -0,0 +1,12 @@ +package com.copperleaf.ballast.scheduler.vm + +import kotlin.time.Instant + +public data class ScheduleState( + val key: String?, + val startedAt: Instant, + val paused: Boolean = false, + val firstUpdateAt: Instant? = null, + val latestUpdateAt: Instant? = null, + val numberOfDispatchedInputs: Int = 0, +) diff --git a/ballast-scheduler-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/vm/SchedulerContract.kt b/ballast-scheduler-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/vm/SchedulerContract.kt new file mode 100644 index 00000000..7d645046 --- /dev/null +++ b/ballast-scheduler-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/vm/SchedulerContract.kt @@ -0,0 +1,51 @@ +package com.copperleaf.ballast.scheduler.vm + +import com.copperleaf.ballast.Queued +import com.copperleaf.ballast.scheduler.NamedSchedule +import com.copperleaf.ballast.scheduler.SchedulerAdapter +import kotlin.time.Instant + +public object SchedulerContract { + public data class State( + val scheduleIndex: Int = 0, + val schedules: Map = emptyMap() + ) + + public sealed interface Inputs { + public class StartSchedules( + public val adapter: SchedulerAdapter + ) : Inputs + + public class StartSchedule( + public val schedule: NamedSchedule, + public val scheduledInput: (Instant) -> I, + ) : Inputs + + public class PauseSchedule( + public val key: String? + ) : Inputs + + public class ResumeSchedule( + public val key: String? + ) : Inputs + + public class CancelSchedule( + public val key: String? + ) : Inputs + + public class MarkScheduleComplete( + public val key: String? + ) : Inputs + + public class DispatchScheduledTask( + public val key: String?, + public val queued: Queued.HandleInput, + ) : Inputs + } + + public sealed interface Events { + public data class PostInputToHost( + val queued: Queued.HandleInput, + ) : Events + } +} diff --git a/ballast-scheduler-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/vm/SchedulerEventHandler.kt b/ballast-scheduler-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/vm/SchedulerEventHandler.kt new file mode 100644 index 00000000..4d9347f1 --- /dev/null +++ b/ballast-scheduler-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/vm/SchedulerEventHandler.kt @@ -0,0 +1,23 @@ +package com.copperleaf.ballast.scheduler.vm + +import com.copperleaf.ballast.BallastInterceptorScope +import com.copperleaf.ballast.EventHandler +import com.copperleaf.ballast.EventHandlerScope + +internal class SchedulerEventHandler( + private val interceptorScope: BallastInterceptorScope +) : EventHandler< + SchedulerContract.Inputs, + SchedulerContract.Events, + SchedulerContract.State> { + override suspend fun EventHandlerScope< + SchedulerContract.Inputs, + SchedulerContract.Events, + SchedulerContract.State>.handleEvent( + event: SchedulerContract.Events + ): Unit = when (event) { + is SchedulerContract.Events.PostInputToHost -> { + interceptorScope.sendToQueue(event.queued) + } + } +} diff --git a/ballast-scheduler-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/vm/SchedulerFifoInputStrategy.kt b/ballast-scheduler-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/vm/SchedulerFifoInputStrategy.kt new file mode 100644 index 00000000..9f9ea025 --- /dev/null +++ b/ballast-scheduler-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/vm/SchedulerFifoInputStrategy.kt @@ -0,0 +1,119 @@ +package com.copperleaf.ballast.scheduler.vm + +import com.copperleaf.ballast.InputFilter +import com.copperleaf.ballast.InputStrategy +import com.copperleaf.ballast.InputStrategyScope +import com.copperleaf.ballast.Queued +import com.copperleaf.ballast.core.ChannelInputStrategy +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow + +/** + * A sequential first-in-first-out strategy for processing inputs, suitable for background processing. As inputs will be + * queued instead of running immediately, it is not suitable for processing UI inputs, as the queue could easily be + * suspended and leave the UI unresponsive. Use a FIFO strategy when you care more that inputs are not dropped, than + * that they get processed quickly. + * + * New inputs will be queued such that the first inputs received will run to completion before later ones start + * processing. FIFO guarantees that only one input will be processed at a time, and is thus protected against race + * conditions. Each Input processed with a FIFO strategy can freely access/update the ViewModel state as many times as + * it needs. FIFO also guarantees that inputs will not be cancelled unless the entire ViewModel gets cancelled. + * + * Since we know only 1 Input is being procced at a time, if an input gets cancelled partway through its processing, the + * ViewModel state will roll back to prevent the ViewModel from being left in a bad state. + */ +public class SchedulerFifoInputStrategy private constructor( + filter: InputFilter? +) : ChannelInputStrategy( + capacity = Channel.BUFFERED, + onBufferOverflow = BufferOverflow.SUSPEND, + filter = filter, +) { + override suspend fun InputStrategyScope.processInputs( + filteredQueue: Flow>, + ) { + filteredQueue + .collect { queued -> + val stateBeforeInput = getCurrentState() + + acceptQueued(queued, Guardian()) { + rollbackState(stateBeforeInput) + } + } + } + + public companion object { + public operator fun invoke(): SchedulerFifoInputStrategy { + return SchedulerFifoInputStrategy(null) + } + + public fun typed(filter: InputFilter? = null): SchedulerFifoInputStrategy { + return SchedulerFifoInputStrategy(filter) + } + } + + public open class Guardian : InputStrategy.Guardian { + + protected var stateAccessed: Boolean = false + protected var sideJobsPosted: Boolean = false + protected var usedProperly: Boolean = false + protected var closed: Boolean = false + + override fun checkStateAccess() { + stateAccessed = true + usedProperly = true + } + + override fun checkStateUpdate() { + checkNotClosed() + checkNoSideJobs() + stateAccessed = true + usedProperly = true + } + + override fun checkPostEvent() { + checkNotClosed() + checkNoSideJobs() + usedProperly = true + } + + override fun checkNoOp() { + checkNotClosed() + checkNoSideJobs() + usedProperly = true + } + + override fun checkSideJob() { + checkNotClosed() + sideJobsPosted = true + usedProperly = true + } + + override fun close() { + checkNotClosed() + checkUsedProperly() + closed = true + } + +// Inner checks +// --------------------------------------------------------------------------------------------------------------------- + + private fun checkNotClosed() { + check(!closed) { "This InputHandlerScope has already been closed" } + } + + private fun checkNoSideJobs() { + check(!sideJobsPosted) { + "Side-Jobs must be the last statements of the InputHandler" + } + } + + private fun checkUsedProperly() { + check(usedProperly) { + "Input was not handled properly. To ensure you're following the MVI model properly, make sure any " + + "side-jobs are executed in a `sideJob { }` block." + } + } + } +} diff --git a/ballast-scheduler-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/vm/SchedulerInputHandler.kt b/ballast-scheduler-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/vm/SchedulerInputHandler.kt new file mode 100644 index 00000000..0c0c2c24 --- /dev/null +++ b/ballast-scheduler-viewmodel/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/vm/SchedulerInputHandler.kt @@ -0,0 +1,183 @@ +package com.copperleaf.ballast.scheduler.vm + +import com.copperleaf.ballast.InputHandler +import com.copperleaf.ballast.InputHandlerScope +import com.copperleaf.ballast.Queued +import com.copperleaf.ballast.scheduler.ScheduleExecutor +import com.copperleaf.ballast.scheduler.internal.RegisteredSchedule +import com.copperleaf.ballast.scheduler.internal.SchedulerAdapterScopeImpl +import kotlinx.coroutines.flow.filter +import kotlin.time.Clock +import kotlin.uuid.ExperimentalUuidApi + +internal class SchedulerInputHandler( + private val clock: Clock, + private val scheduleExecutor: ScheduleExecutor, +) : InputHandler< + SchedulerContract.Inputs, + SchedulerContract.Events, + SchedulerContract.State> { + @OptIn(ExperimentalUuidApi::class) + override suspend fun InputHandlerScope< + SchedulerContract.Inputs, + SchedulerContract.Events, + SchedulerContract.State>.handleInput( + input: SchedulerContract.Inputs + ): Unit = when (input) { + is SchedulerContract.Inputs.StartSchedules -> { + val currentIndex = getAndUpdateState { it.copy(scheduleIndex = it.scheduleIndex + 1) }.scheduleIndex + + // run the adapter to get the schedules which should run + val adapterScope = SchedulerAdapterScopeImpl() + with(input.adapter) { + adapterScope.configureSchedules() + } + + // add the schedule to the list of running schedules + val now = clock.now() + updateState { + it.copy( + schedules = it.schedules + .toMutableMap() + .apply { + adapterScope.schedules.forEach { schedule -> + this[schedule.schedule.name] = ScheduleState(schedule.schedule.name, now) + } + } + .toMap() + ) + } + + val isPaused: suspend (String?) -> Boolean = { scheduleName: String? -> + getCurrentState().schedules[scheduleName]?.paused == true + } + + sideJob("StartSchedules-$currentIndex") { + // run the schedule, sending an Event with each tick. This may suspend indefinitely for infinite schedules + scheduleExecutor + .runSchedules(adapterScope.schedules.map { it.schedule }) + .filter { emission -> !isPaused(emission.name) } + .collect { emission -> + val registeredSchedule: RegisteredSchedule = adapterScope + .schedules + .single { it.schedule.name == emission.name } + postInput( + SchedulerContract.Inputs.DispatchScheduledTask( + emission.name, + Queued.HandleInput(null, registeredSchedule.scheduledInput(emission.triggeredAt)) + ) + ) + } + } + } + + is SchedulerContract.Inputs.StartSchedule -> { + val currentIndex = getAndUpdateState { it.copy(scheduleIndex = it.scheduleIndex + 1) }.scheduleIndex + + // add the schedule to the list of running schedules + val now = clock.now() + updateState { + it.copy( + schedules = it.schedules + .toMutableMap() + .apply { + this[input.schedule.name] = ScheduleState(input.schedule.name, now) + } + .toMap() + ) + } + + // then create the new schedules, running each in their own SideJob + // this would normally be blocked by the Guardian of the InputStrategy, but here we're using a custom + // guardian which allows this operation. Notably, schedules cannot update the Scheduler state, but only read + // it. Race conditions aren't a huge issue here, a slightly out-of-date State is fine. + val isPaused = suspend { + getCurrentState().schedules[input.schedule.name]?.paused == true + } + + sideJob("StartSchedule-$currentIndex") { + // run the schedule, sending an Event with each tick. This may suspend indefinitely for infinite schedules + scheduleExecutor + .runSchedule(input.schedule) + .filter { !isPaused() } + .collect { emission -> + postInput( + SchedulerContract.Inputs.DispatchScheduledTask( + input.schedule.name, + Queued.HandleInput(null, input.scheduledInput(emission.triggeredAt)) + ) + ) + } + + // if the schedule was finite, once it finishes, send an Input to remove it from the VM state + postInput(SchedulerContract.Inputs.MarkScheduleComplete(input.schedule.name)) + } + } + + is SchedulerContract.Inputs.PauseSchedule -> { + updateScheduleState(input.key) { + it.copy(paused = true) + } + } + + is SchedulerContract.Inputs.ResumeSchedule -> { + updateScheduleState(input.key) { + it.copy(paused = false) + } + } + + is SchedulerContract.Inputs.CancelSchedule -> { + updateScheduleState(input.key) { + null + } + // TODO: this won't work + cancelSideJob(input.key ?: "") + } + + is SchedulerContract.Inputs.MarkScheduleComplete -> { + updateScheduleState(input.key) { + null + } + } + + is SchedulerContract.Inputs.DispatchScheduledTask -> { + val now = clock.now() + updateScheduleState(input.key) { + it.copy( + firstUpdateAt = it.firstUpdateAt ?: now, + latestUpdateAt = now, + numberOfDispatchedInputs = it.numberOfDispatchedInputs + 1 + ) + } + + postEvent( + SchedulerContract.Events.PostInputToHost(input.queued) + ) + } + } + + private suspend fun InputHandlerScope< + SchedulerContract.Inputs, + SchedulerContract.Events, + SchedulerContract.State>.updateScheduleState( + key: String?, + block: (ScheduleState) -> ScheduleState?, + ) { + updateState { + it.copy( + schedules = it.schedules + .toMutableMap() + .apply { + val updatedState = (this[key] ?: ScheduleState(key, clock.now())).let(block) + + if (updatedState != null) { + this[key] = updatedState + } else { + this.remove(key) + } + } + .toMap() + ) + } + } +} diff --git a/docs/src/doc/docs/pages/wiki/modules/ballast-schedules.md b/ballast-schedules/README.md similarity index 79% rename from docs/src/doc/docs/pages/wiki/modules/ballast-schedules.md rename to ballast-schedules/README.md index d31bb81e..81976e69 100644 --- a/docs/src/doc/docs/pages/wiki/modules/ballast-schedules.md +++ b/ballast-schedules/README.md @@ -1,16 +1,45 @@ ---- ---- +# Ballast Schedules + +> [!CAUTION] +> +> DEPRECATED +> +> This module has been replaced by the "ballast-scheduler-*" artifacts to provide a more favorable dependency structure +> for this library, while also providing some tweaks to the API that would be backwards-incompatible with this module. +> Please migrate to the new modules linked in the [See Also](#see-also) section below. +> +> At a high level, [Ballast Scheduler Core](./../ballast-scheduler-core) does not depend on any other Ballast +> modules, including Ballast ViewModels. The core scheduling logic can be used without bringing in any dependencies +> besides Kotlinx Coroutines and Kotlinx Datetime. Other scheduling functionality, such as sending Inputs to Ballast +> ViewModels on a schedule and integration with Android Workmanager, have been moved to their own modules. +> +> For historical reasons, this module and its original documentation has been preserved for those who may have already +> been using it, but it will not receive any further updates or support. ## Overview -Ballast Scheduler is still a work in progress. Any features/APIs described here might change at any time. - -Ballast Scheduler is a simple way to run periodic work, similar to [Spring @Scheduled][1] or the [Java Timer][2], by -dispatching an Input to one of your ViewModels on a configurable schedule. It supports both non-persistent work on all -platforms by being embedded into an existing ViewModel and running purely on coroutines, and also experimental support +Ballast Scheduler is a simple way to run periodic work, similar to [Spring @Scheduled][1] or the [Java Timer][2], by +dispatching an Input to one of your ViewModels on a configurable schedule. It supports both non-persistent work on all +platforms by being embedded into an existing ViewModel and running purely on coroutines, and also experimental support for persistent work by running on [Android WorkManager][3]. -## Basic Usage +## Supported Platforms + +| Platform | Supported | +|----------|-----------| +| JVM | ✅ | +| Android | ✅ | +| iOS | ✅ | +| JS | ✅ | +| WASM JS | ✅ | + +## See Also + +- [Ballast Scheduler Core](./../ballast-scheduler-core) +- [Ballast Scheduler Cron](./../ballast-scheduler-cron) +- [Ballast Scheduler ViewModel](./../ballast-scheduler-viewmodel) + +## Usage ### Schedule Adapter @@ -48,7 +77,7 @@ public class BallastSchedulerExampleAdapter : SchedulerAdapter< An Embedded Scheduler is installed into an existing Ballast ViewModel as an Interceptor. By sending an instance of `SchedulerAdapter` to the Interceptor, you can start register a scheduled task. `SchedulerAdapter` is a `fun interface`, so it can be passed to the `SchedulerInterceptor` as a lambda, and within the lambda you may register multiple -Schedules. +Schedules. ```kotlin val vm = BasicViewModel( @@ -142,22 +171,22 @@ this += SchedulerInterceptor( ### Android WorkManager -Ballast Scheduler also supports persistent work on Android by configuring a schedule to run on top of WorkManager, +Ballast Scheduler also supports persistent work on Android by configuring a schedule to run on top of WorkManager, instead of embedded within a ViewModel. The general process is the same, but there are some restrictions to be aware of. Most notably, you cannot use a lambda to create your `SchedulerAdapter`, since WorkManager needs to persist the state of the schedule and rehydrate it later when each scheduled task is handled. It does this by using reflection to create your `SchedulerAdapter` class, then determining the next Instant to run a Unique `OneTimeWorkRequest`. The Inputs generated -on each schedule "tick" are also passed back to a `SchedulerCallback` class (only available on Android targets), since -it is not directly connected to a ViewModel. You should forward that Input to a ViewModel so it is processed by Ballast +on each schedule "tick" are also passed back to a `SchedulerCallback` class (only available on Android targets), since +it is not directly connected to a ViewModel. You should forward that Input to a ViewModel so it is processed by Ballast as normal. -It is advised to use the [Android Startup library][5] to initialize your schedules, and to not create them dynamically -like you can with an embedded scheduler. Ballast Scheduler needs to be able to regularly sync its own schedule state and -configuration with WorkManager. Schedules can be synced anytime the app starts up with -`WorkManager.syncSchedulesOnStartup`, or synced periodically without needing to open the app with +It is advised to use the [Android Startup library][5] to initialize your schedules, and to not create them dynamically +like you can with an embedded scheduler. Ballast Scheduler needs to be able to regularly sync its own schedule state and +configuration with WorkManager. Schedules can be synced anytime the app starts up with +`WorkManager.syncSchedulesOnStartup`, or synced periodically without needing to open the app with `WorkManager.syncSchedulesPeriodically`. -Running Ballast Schedules on WorkManager does not support setting constraints. You will need to check at runtime when +Running Ballast Schedules on WorkManager does not support setting constraints. You will need to check at runtime when handling the Input any constraints you wish to apply. ```kotlin @@ -223,20 +252,20 @@ public class BallastSchedulerStartup : Initializer { ### iOS BGTaskScheduler -Running persistent scheduled work on iOS is not yet implemented. Ideally, it would work very similarly to running on +Running persistent scheduled work on iOS is not yet implemented. Ideally, it would work very similarly to running on WorkManager, but using something like iOS's [BGTaskScheduler][6] -## Schedule Configuration +### Schedule Configuration -A `Schedule` produces a Sequence of the kotlin-datetime `Instant` (`Sequence`) given a starting `Instant`. It -is generally considered to be an _ideal version_ of the schedule, but depending on how long it takes to process the -Inputs dispatched by the schedule, the actual time that an Input is sent may be later, or some of the scheduled events -may be dropped. +A `Schedule` produces a Sequence of the kotlin-datetime `Instant` (`Sequence`) given a starting `Instant`. It +is generally considered to be an _ideal version_ of the schedule, but depending on how long it takes to process the +Inputs dispatched by the schedule, the actual time that an Input is sent may be later, or some of the scheduled events +may be dropped. -Several schedule types are available, but you are free to implement the `Schedule` interface yourself and provide a -custom sequence of scheduled tasks. +Several schedule types are available, but you are free to implement the `Schedule` interface yourself and provide a +custom sequence of scheduled tasks. -### Delay Mode +#### Delay Mode When configuring a Schedule, you may choose whether you want the Inputs to be "fire-and-forget" type tasks, or whether the schedule executor should suspend until one scheduled Input is completely processed before attempting to run @@ -268,57 +297,57 @@ will determine how they two events are handled, as normal. `ScheduleExecutor.Del execution of the schedule while one Input is still processing, potentially dropping scheduled tasks to ensure that one Input finishes processing before sending the next one. -### Fixed Delay Schedule +#$## Fixed Delay Schedule -The most basic type of `Schedule` is `FixedDelaySchedule`. It simply delays each subsequent task by a fixed `Duration` -from the starting `Instant`. For example, a `FixedDelaySchedule(10.minutes)` starting at 6:04pm will send Inputs at +The most basic type of `Schedule` is `FixedDelaySchedule`. It simply delays each subsequent task by a fixed `Duration` +from the starting `Instant`. For example, a `FixedDelaySchedule(10.minutes)` starting at 6:04pm will send Inputs at 6:14pm, 6:24pm, 6:34pm, etc. It has a strict minimum resolution of 1ms. -Alternatively, you may wish that a minimum amount of time is delayed between the end of one Input's processing, and the -start of the next Input. In this case, use `FixedDelaySchedule(10.minutes).adaptive()` with the +Alternatively, you may wish that a minimum amount of time is delayed between the end of one Input's processing, and the +start of the next Input. In this case, use `FixedDelaySchedule(10.minutes).adaptive()` with the `ScheduleExecutor.DelayMode.Suspend` delay mode to adjust the schedule to account for processing time. -### Time-Based +#### Time-Based -There are also schedules which send Inputs at specific times of the day. +There are also schedules which send Inputs at specific times of the day. -`EveryDaySchedule` lets you send Inputs at a specific `LocalTime`. Multiple times may be configured to send Inputs +`EveryDaySchedule` lets you send Inputs at a specific `LocalTime`. Multiple times may be configured to send Inputs multiple times each day. -`EveryHourSchedule` lets you send Inputs at a specific minute of the hour (at 0 seconds). Multiple minutes may be +`EveryHourSchedule` lets you send Inputs at a specific minute of the hour (at 0 seconds). Multiple minutes may be configured to send Inputs multiple times each hour. `EveryMinuteSchedule` lets you send Inputs at a specific second of the minute (at 0 ms). Multiple seconds may be configured to send Inputs multiple times each minute. -`EverySecondSchedule` lets you send Inputs once every second, precisely at the start of the second. Useful for things -like showing countdown timers in the UI that need to be synchronized to the wall clock, in contrast to using -`FixedDelaySchedule(1.seconds)` which will drift over time. +`EverySecondSchedule` lets you send Inputs once every second, precisely at the start of the second. Useful for things +like showing countdown timers in the UI that need to be synchronized to the wall clock, in contrast to using +`FixedDelaySchedule(1.seconds)` which will drift over time. -### Fixed Instant Schedule +#### Fixed Instant Schedule -For cases where your application logic has already computed the Instants to trigger the schedule, `FixedInstantSchedule` +For cases where your application logic has already computed the Instants to trigger the schedule, `FixedInstantSchedule` will send those exact Instants according to the system `Clock`. At each iteration of this schedule, the next Instant after the current Clock time will be sent, and the entire schedule will be completed once the System clock has advanced past all provided Instants. -### (TODO) Cron Expression +#### (TODO) Cron Expression Cron expressions are not yet supported. -### Schedule Operators +#### Schedule Operators -Schedules are fundamentally based on `Sequences`, so it's easy to customize the behavior of a predefined schedule. The -following operators are available out-of-the-box, but you're also welcome to use whatever other Sequence operators you +Schedules are fundamentally based on `Sequences`, so it's easy to customize the behavior of a predefined schedule. The +following operators are available out-of-the-box, but you're also welcome to use whatever other Sequence operators you need to generate more custom scheduling behavior. - `schedule.adaptive()`: mostly useful for the `FixedDelaySchedule`, to adjust the time between tasks by the amount of time it takes to process them. - `schedule.delayed(Duration)`: Delay the start of a schedule by a specified Duration - `schedule.delayedUntil(Instant)`: Delay the start of a schedule until a specified Instant -- `schedule.bounded(ClosedRange)`: Filter emissions so that they are only handled during the given time range. - Once the end of the range has been passed, the schedule will complete -- `schedule.until(Instant)`: Process Inputs as long as they are before the end Instant. This makes the schedule finite; +- `schedule.bounded(ClosedRange)`: Filter emissions so that they are only handled during the given time range. + Once the end of the range has been passed, the schedule will complete +- `schedule.until(Instant)`: Process Inputs as long as they are before the end Instant. This makes the schedule finite; once the end time has been passed, the schedule will complete. - `schedule.filterByDayOfWeek(vararg dayOfWeek)`: Filters the scheduled instants so they only trigger on the specified days of the week. Related operators of `schedule.weekdays()` and `schedule.weekends()` are also available. @@ -335,7 +364,7 @@ repositories { // for plain JVM or Android projects dependencies { - implementation("io.github.copper-leaf:ballast-schedules:{{gradle.version}}") + implementation("io.github.copper-leaf:ballast-schedules:{{ballastVersion}}") } // for multiplatform projects @@ -343,7 +372,7 @@ kotlin { sourceSets { val commonMain by getting { dependencies { - implementation("io.github.copper-leaf:ballast-schedules:{{gradle.version}}") + implementation("io.github.copper-leaf:ballast-schedules:{{ballastVersion}}") } } } diff --git a/ballast-schedules/build.gradle.kts b/ballast-schedules/build.gradle.kts index 82574208..7758436b 100644 --- a/ballast-schedules/build.gradle.kts +++ b/ballast-schedules/build.gradle.kts @@ -32,7 +32,7 @@ kotlin { } val androidMain by getting { dependencies { - api("androidx.work:work-runtime-ktx:2.10.4") + api(libs.androidx.workmanager) } } val jsMain by getting { diff --git a/ballast-schedules/src/androidMain/kotlin/com/copperleaf/ballast/scheduler/workmanager/scheduleWork.kt b/ballast-schedules/src/androidMain/kotlin/com/copperleaf/ballast/scheduler/workmanager/scheduleWork.kt index 783e20dc..caa2551b 100644 --- a/ballast-schedules/src/androidMain/kotlin/com/copperleaf/ballast/scheduler/workmanager/scheduleWork.kt +++ b/ballast-schedules/src/androidMain/kotlin/com/copperleaf/ballast/scheduler/workmanager/scheduleWork.kt @@ -4,7 +4,6 @@ import android.content.Context import android.os.Build import androidx.annotation.RequiresApi import androidx.work.WorkManager -import com.copperleaf.ballast.ExperimentalBallastApi import com.copperleaf.ballast.scheduler.SchedulerAdapter import com.copperleaf.ballast.scheduler.internal.RegisteredSchedule import com.copperleaf.ballast.scheduler.schedule.Schedule @@ -45,7 +44,6 @@ import kotlin.time.Instant * it will sync schedules every time the app is opened. This is useful if all your schedules are hardcoded and would * only change with an app update. */ -@ExperimentalBallastApi @RequiresApi(Build.VERSION_CODES.O) public fun WorkManager.syncSchedulesOnStartup( adapter: SchedulerAdapter, @@ -81,7 +79,6 @@ public fun WorkManager.syncSchedulesOnStartup( * dynamically and need to be updated without an app update or user-intervention (such as user-generated calendar * event notifications). */ -@ExperimentalBallastApi @RequiresApi(Build.VERSION_CODES.O) public fun WorkManager.syncSchedulesPeriodically( adapter: SchedulerAdapter, diff --git a/ballast-schedules/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/SchedulerController.kt b/ballast-schedules/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/SchedulerController.kt index e2e2934a..c6d44e09 100644 --- a/ballast-schedules/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/SchedulerController.kt +++ b/ballast-schedules/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/SchedulerController.kt @@ -2,7 +2,6 @@ package com.copperleaf.ballast.scheduler import com.copperleaf.ballast.BallastViewModel import com.copperleaf.ballast.BallastViewModelConfiguration -import com.copperleaf.ballast.ExperimentalBallastApi import com.copperleaf.ballast.SideJobScope import com.copperleaf.ballast.scheduler.executor.CoroutineClockScheduleExecutor import com.copperleaf.ballast.scheduler.executor.CoroutineScheduleExecutor @@ -17,7 +16,6 @@ public typealias SchedulerController = BallastViewModel< SchedulerContract.Events, SchedulerContract.State> -@ExperimentalBallastApi public fun BallastViewModelConfiguration.Builder.withSchedulerController( clock: Clock = Clock.System, scheduleExecutor: CoroutineScheduleExecutor = CoroutineClockScheduleExecutor(clock), diff --git a/ballast-schedules/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/SchedulerInterceptor.kt b/ballast-schedules/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/SchedulerInterceptor.kt index 74a6efdd..a07db57d 100644 --- a/ballast-schedules/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/SchedulerInterceptor.kt +++ b/ballast-schedules/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/SchedulerInterceptor.kt @@ -4,7 +4,6 @@ import com.copperleaf.ballast.BallastInterceptor import com.copperleaf.ballast.BallastInterceptorScope import com.copperleaf.ballast.BallastNotification import com.copperleaf.ballast.BallastViewModelConfiguration -import com.copperleaf.ballast.ExperimentalBallastApi import com.copperleaf.ballast.awaitViewModelStart import com.copperleaf.ballast.build import com.copperleaf.ballast.internal.BallastViewModelImpl @@ -14,7 +13,6 @@ import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch -@ExperimentalBallastApi public class SchedulerInterceptor( private val config: BallastViewModelConfiguration< SchedulerContract.Inputs, diff --git a/ballast-schedules/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/schedule/scheduleUtils.kt b/ballast-schedules/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/schedule/scheduleUtils.kt index b3ebf723..124ac1c3 100644 --- a/ballast-schedules/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/schedule/scheduleUtils.kt +++ b/ballast-schedules/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/schedule/scheduleUtils.kt @@ -93,8 +93,6 @@ public fun Schedule.bounded(validRange: ClosedRange): Schedule { while (iterator.hasNext()) { val next = iterator.next() - println("checking $next") - when { next < validRange.start -> { // we haven't entered the start of the range, don't quit yet diff --git a/docs/src/doc/docs/pages/wiki/modules/ballast-sync.md b/ballast-sync/README.md similarity index 86% rename from docs/src/doc/docs/pages/wiki/modules/ballast-sync.md rename to ballast-sync/README.md index 966c7786..04f1a9c8 100644 --- a/docs/src/doc/docs/pages/wiki/modules/ballast-sync.md +++ b/ballast-sync/README.md @@ -1,14 +1,27 @@ ---- ---- +# Ballast Sync ## Overview Ballast Sync allows you to share the state of your ViewModel across multiple instances, potentially even over a network. It allows you to build your ViewModels as normal, and then choose one to be the "source of truth" for the other -ViewModels will share the synchronized state, and optionally allow those "observing" ViewModels to send changes back to -the source. The flow of data within a synchronized ViewModels is all asynchronous, and follows a model of +ViewModels which will share the synchronized state, and optionally allow those "observing" ViewModels to send changes +back to the source. The flow of data within a synchronized ViewModels is all asynchronous, and follows a model of "eventual consistency". +## Supported Platforms + +| Platform | Supported | +|----------|-----------| +| JVM | ✅ | +| Android | ✅ | +| iOS | ✅ | +| JS | ✅ | +| WASM JS | ✅ | + +## See Also + +N/A + ## Usage There are 3 types of ViewModels which may share in the synchronized state: @@ -16,8 +29,8 @@ There are 3 types of ViewModels which may share in the synchronized state: - `Source`: The Source ultimately drives the state of the other ViewModels. Anytime its own State gets changed, that updated State will be sent back to all other ViewModels that are observing it. There should only be 1 Source ViewModel in a given Connection, otherwise they will all be competing to be the source of truth, which may lead to infinite - recursion. If all synchronization is performed locally, it's up to you to make sure there is only 1 ViewModel - registered as Source. If you're connecting over a network, it's best to keep the Source ViewModel on the Server, and + recursion. If all synchronization is performed locally, it's up to you to make sure there is only 1 ViewModel + registered as Source. If you're connecting over a network, it's best to keep the Source ViewModel on the Server, and only use Replicas or Spectators within the client applications. - `Replica`: Replicas are ViewModels that share the same Contract and InputHandler as the Source ViewModel, but will ultimately reflect the State of the Source. Any Inputs sent to it will be processed locally, and then sent back to the @@ -84,7 +97,7 @@ repositories { // for plain JVM or Android projects dependencies { - implementation("io.github.copper-leaf:ballast-sync:{{gradle.version}}") + implementation("io.github.copper-leaf:ballast-sync:{{ballastVersion}}") } // for multiplatform projects @@ -92,7 +105,7 @@ kotlin { sourceSets { val commonMain by getting { dependencies { - implementation("io.github.copper-leaf:ballast-sync:{{gradle.version}}") + implementation("io.github.copper-leaf:ballast-sync:{{ballastVersion}}") } } } diff --git a/ballast-sync/build.gradle.kts b/ballast-sync/build.gradle.kts index aaaa061a..f0ef730f 100644 --- a/ballast-sync/build.gradle.kts +++ b/ballast-sync/build.gradle.kts @@ -3,7 +3,7 @@ plugins { id("copper-leaf-android-library") id("copper-leaf-targets") id("copper-leaf-tests") -// id("copper-leaf-lint") + id("copper-leaf-lint") id("copper-leaf-publish") } diff --git a/ballast-sync/src/commonMain/kotlin/com/copperleaf/ballast/sync/InMemorySyncAdapter.kt b/ballast-sync/src/commonMain/kotlin/com/copperleaf/ballast/sync/InMemorySyncAdapter.kt index 34ee2123..cc8d088c 100644 --- a/ballast-sync/src/commonMain/kotlin/com/copperleaf/ballast/sync/InMemorySyncAdapter.kt +++ b/ballast-sync/src/commonMain/kotlin/com/copperleaf/ballast/sync/InMemorySyncAdapter.kt @@ -14,8 +14,7 @@ import kotlinx.coroutines.flow.receiveAsFlow public class InMemorySyncAdapter< Inputs : Any, Events : Any, - State : Any>( -) : SyncConnectionAdapter { + State : Any>() : SyncConnectionAdapter { private val synchronizedState = MutableStateFlow(null) private val synchronizedInputs = Channel(capacity = UNLIMITED) diff --git a/docs/src/doc/docs/pages/wiki/modules/ballast-test.md b/ballast-test/README.md similarity index 77% rename from docs/src/doc/docs/pages/wiki/modules/ballast-test.md rename to ballast-test/README.md index d43b6ce3..32181996 100644 --- a/docs/src/doc/docs/pages/wiki/modules/ballast-test.md +++ b/ballast-test/README.md @@ -1,35 +1,48 @@ ---- ---- +# Ballast Test ## Overview Ballast Test gives you a DSL you can include in any Kotlin testing framework to setup sequences of inputs and assert the results of their processing. +## Supported Platforms + +| Platform | Supported | +|----------|-----------| +| JVM | ✅ | +| Android | ✅ | +| iOS | ✅ | +| JS | ✅ | +| WASM JS | ✅ | + +## See Also + +N/A + ## Usage After [including the dependency](#Installation) into your test sourceSet, you can run `viewModelTest()`, which gives you -a DSL for setting up specific scenarios and asserting what happened during the execution of those scenarios. +a DSL for setting up specific scenarios and asserting what happened during the execution of those scenarios. `viewModelTest()` is a suspending function, so it will need to be run within `runBlocking` in your tests. You do not need to provide a ViewModel implementation for these tests. A feature of Ballast is that the chosen ViewModel base class is just a wrapper around the actual processor, and the test framework defines its own ViewModel class to run -the scenarios in. Instead, you just need to provide the other components you would normally pass to your ViewModel +the scenarios in. Instead, you just need to provide the other components you would normally pass to your ViewModel configuration, and then proceed setting your testing suite. -`viewModelTest()` defines an entire test suite for a single Ballast ViewModel, which contains many scenarios with -`scenario("human-readbale scenario description")`. Most properties can be configured within the `viewModelTest { }` -block which will get applied to all scenarios, but each `scenario { }` can set their own values, which will override +`viewModelTest()` defines an entire test suite for a single Ballast ViewModel, which contains many scenarios with +`scenario("human-readbale scenario description")`. Most properties can be configured within the `viewModelTest { }` +block which will get applied to all scenarios, but each `scenario { }` can set their own values, which will override those set for the suite. -In each `scenario { }` block, `running { }` is the scenario script that will be run. Inputs are sent for processing -using the unary `+` operator, which will either send the Input and wait for it to be completed, or unary `-` which will +In each `scenario { }` block, `running { }` is the scenario script that will be run. Inputs are sent for processing +using the unary `+` operator, which will either send the Input and wait for it to be completed, or unary `-` which will send the Input and immediately continue the script without waiting for it to complete. You'd typically want to use `+` unless you are explicitly wanting to test the cancellation behavior or something else that relies upon multiple Inputs being sent before the first has finished processing. -`resultsIn { }` will be called after the scenario has run to completion (or timed out), and will give a `TestResults` -which contains all the values and their statues that were seen during the test scenario. You can use your favorite +`resultsIn { }` will be called after the scenario has run to completion (or timed out), and will give a `TestResults` +which contains all the values and their statues that were seen during the test scenario. You can use your favorite assertion library to make any assertions on any results within that object. ```kotlin @@ -74,7 +87,7 @@ repositories { // for plain JVM or Android projects dependencies { - testImplementation("io.github.copper-leaf:ballast-test:{{gradle.version}}") + testImplementation("io.github.copper-leaf:ballast-test:{{ballastVersion}}") } // for multiplatform projects @@ -82,7 +95,7 @@ kotlin { sourceSets { val commonTest by getting { dependencies { - implementation("io.github.copper-leaf:ballast-test:{{gradle.version}}") + implementation("io.github.copper-leaf:ballast-test:{{ballastVersion}}") } } } diff --git a/ballast-test/build.gradle.kts b/ballast-test/build.gradle.kts index 99471e77..6543464a 100644 --- a/ballast-test/build.gradle.kts +++ b/ballast-test/build.gradle.kts @@ -3,7 +3,7 @@ plugins { id("copper-leaf-android-library") id("copper-leaf-targets") id("copper-leaf-tests") -// id("copper-leaf-lint") + id("copper-leaf-lint") id("copper-leaf-publish") } diff --git a/ballast-test/src/commonMain/kotlin/com/copperleaf/ballast/test/BallastIsolatedScenarioScope.kt b/ballast-test/src/commonMain/kotlin/com/copperleaf/ballast/test/BallastIsolatedScenarioScope.kt index a3e10991..5bcefd05 100644 --- a/ballast-test/src/commonMain/kotlin/com/copperleaf/ballast/test/BallastIsolatedScenarioScope.kt +++ b/ballast-test/src/commonMain/kotlin/com/copperleaf/ballast/test/BallastIsolatedScenarioScope.kt @@ -23,7 +23,7 @@ public interface BallastIsolatedScenarioScopeBallastLogger) + public fun logger(logger: (String) -> BallastLogger) /** * Set the timeout for waiting for test side-jobs to complete for this test scenario. diff --git a/ballast-test/src/commonMain/kotlin/com/copperleaf/ballast/test/BallastScenarioScope.kt b/ballast-test/src/commonMain/kotlin/com/copperleaf/ballast/test/BallastScenarioScope.kt index 4d25b8e5..8a1f3006 100644 --- a/ballast-test/src/commonMain/kotlin/com/copperleaf/ballast/test/BallastScenarioScope.kt +++ b/ballast-test/src/commonMain/kotlin/com/copperleaf/ballast/test/BallastScenarioScope.kt @@ -25,7 +25,7 @@ public interface BallastScenarioScope { * A callback function for viewing logs emitted during this test scenario. This includes logs from a * [LoggingInterceptor], and additional logs from this test runner. */ - public fun logger(logger: (String)->BallastLogger) + public fun logger(logger: (String) -> BallastLogger) /** * Set the timeout for waiting for test side-jobs to complete for this test scenario. diff --git a/ballast-test/src/commonMain/kotlin/com/copperleaf/ballast/test/BallastTestSuiteScope.kt b/ballast-test/src/commonMain/kotlin/com/copperleaf/ballast/test/BallastTestSuiteScope.kt index a5c01a75..b1d94b22 100644 --- a/ballast-test/src/commonMain/kotlin/com/copperleaf/ballast/test/BallastTestSuiteScope.kt +++ b/ballast-test/src/commonMain/kotlin/com/copperleaf/ballast/test/BallastTestSuiteScope.kt @@ -17,7 +17,7 @@ public interface BallastTestSuiteScope * A callback function for viewing logs emitted during this test suite. This includes logs from a * [LoggingInterceptor], and additional logs from this test runner. */ - public fun logger(logger: (String)->BallastLogger) + public fun logger(logger: (String) -> BallastLogger) /** * Set the default timeout for waiting for test side-jobs to complete. diff --git a/ballast-test/src/commonMain/kotlin/com/copperleaf/ballast/test/internal/TestInterceptor.kt b/ballast-test/src/commonMain/kotlin/com/copperleaf/ballast/test/internal/TestInterceptor.kt index 2b614cd5..06baf44e 100644 --- a/ballast-test/src/commonMain/kotlin/com/copperleaf/ballast/test/internal/TestInterceptor.kt +++ b/ballast-test/src/commonMain/kotlin/com/copperleaf/ballast/test/internal/TestInterceptor.kt @@ -143,7 +143,7 @@ internal class TestInterceptor( // that this block gets executed, and the test framework will then be guaranteed to receive the result coroutineContext.job.invokeOnCompletion { completeTest(mark.elapsedNow()) - if(timedOut) { + if (timedOut) { testCoroutineScope.cancel() } } diff --git a/ballast-test/src/commonMain/kotlin/com/copperleaf/ballast/test/internal/run.kt b/ballast-test/src/commonMain/kotlin/com/copperleaf/ballast/test/internal/run.kt index ec34bd7c..5bf386d1 100644 --- a/ballast-test/src/commonMain/kotlin/com/copperleaf/ballast/test/internal/run.kt +++ b/ballast-test/src/commonMain/kotlin/com/copperleaf/ballast/test/internal/run.kt @@ -9,12 +9,10 @@ import com.copperleaf.ballast.withViewModel import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.supervisorScope -import kotlin.time.ExperimentalTime import kotlin.time.measureTime internal suspend fun runTestSuite( diff --git a/ballast-test/src/commonMain/kotlin/com/copperleaf/ballast/test/run.kt b/ballast-test/src/commonMain/kotlin/com/copperleaf/ballast/test/run.kt index 95f79813..8fe90151 100644 --- a/ballast-test/src/commonMain/kotlin/com/copperleaf/ballast/test/run.kt +++ b/ballast-test/src/commonMain/kotlin/com/copperleaf/ballast/test/run.kt @@ -4,8 +4,6 @@ import com.copperleaf.ballast.EventHandler import com.copperleaf.ballast.InputHandler import com.copperleaf.ballast.test.internal.BallastTestSuiteScopeImpl import com.copperleaf.ballast.test.internal.runTestSuite -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlin.time.ExperimentalTime public suspend fun viewModelTest( inputHandler: InputHandler, diff --git a/docs/src/doc/docs/pages/wiki/modules/ballast-undo.md b/ballast-undo/README.md similarity index 84% rename from docs/src/doc/docs/pages/wiki/modules/ballast-undo.md rename to ballast-undo/README.md index c4aca5c3..de5c63fd 100644 --- a/docs/src/doc/docs/pages/wiki/modules/ballast-undo.md +++ b/ballast-undo/README.md @@ -1,5 +1,4 @@ ---- ---- +# Ballast Undo ## Overview @@ -9,17 +8,31 @@ through the history of a user's changes over time. Note that the default functionality is strictly state-based, and it works by observing States emitted from the ViewModel and restoring captured State when requested, irrespective of any particular Inputs that changed the State. It does not -attempt to undo specific Inputs, which may have performed other actions like emitting Events, starting Side Jobs, or +attempt to undo specific Inputs, which may have performed other actions like emitting Events, starting Side Jobs, or other "side effects" which cannot be so easily tracked and undone. +## Supported Platforms + +| Platform | Supported | +|----------|-----------| +| JVM | ✅ | +| Android | ✅ | +| iOS | ✅ | +| JS | ✅ | +| WASM JS | ✅ | + +## See Also + +N/A + ## Usage -Start by creating a `UndoController` for your ViewModel. This controller includes functions to `undo()` and `redo()` +Start by creating a `UndoController` for your ViewModel. This controller includes functions to `undo()` and `redo()` which should be called from the UI, as well as corresponding `Flows` which notify whether such actions are can be used. -A default implementation, `DefaultUndoController` may be used, but for advanced use-cases such as persisting the +A default implementation, `DefaultUndoController` may be used, but for advanced use-cases such as persisting the undo/redo state across application restarts, you may implement your own. -Then, set up your ViewModel with the `BallastUndoInterceptor` added, which needs that Controller we just created. +Then, set up your ViewModel with the `BallastUndoInterceptor` added, which needs that Controller we just created. ```kotlin class ExampleViewModel( @@ -74,7 +87,7 @@ repositories { // for plain JVM or Android projects dependencies { - implementation("io.github.copper-leaf:ballast-undo:{{gradle.version}}") + implementation("io.github.copper-leaf:ballast-undo:{{ballastVersion}}") } // for multiplatform projects @@ -82,7 +95,7 @@ kotlin { sourceSets { val commonMain by getting { dependencies { - implementation("io.github.copper-leaf:ballast-undo:{{gradle.version}}") + implementation("io.github.copper-leaf:ballast-undo:{{ballastVersion}}") } } } diff --git a/ballast-undo/api/android/ballast-undo.api b/ballast-undo/api/android/ballast-undo.api index 26a62682..50c77793 100644 --- a/ballast-undo/api/android/ballast-undo.api +++ b/ballast-undo/api/android/ballast-undo.api @@ -36,6 +36,7 @@ public final class com/copperleaf/ballast/undo/state/StateBasedUndoController : public fun (Lcom/copperleaf/ballast/BallastViewModelConfiguration;)V public synthetic fun (Lcom/copperleaf/ballast/BallastViewModelConfiguration;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun captureNow ()V + public fun close ()V public fun connectViewModel (Lcom/copperleaf/ballast/undo/UndoScope;Lkotlinx/coroutines/flow/Flow;)V public fun isRedoAvailable ()Lkotlinx/coroutines/flow/Flow; public fun isUndoAvailable ()Lkotlinx/coroutines/flow/Flow; diff --git a/ballast-undo/api/jvm/ballast-undo.api b/ballast-undo/api/jvm/ballast-undo.api index 26a62682..50c77793 100644 --- a/ballast-undo/api/jvm/ballast-undo.api +++ b/ballast-undo/api/jvm/ballast-undo.api @@ -36,6 +36,7 @@ public final class com/copperleaf/ballast/undo/state/StateBasedUndoController : public fun (Lcom/copperleaf/ballast/BallastViewModelConfiguration;)V public synthetic fun (Lcom/copperleaf/ballast/BallastViewModelConfiguration;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun captureNow ()V + public fun close ()V public fun connectViewModel (Lcom/copperleaf/ballast/undo/UndoScope;Lkotlinx/coroutines/flow/Flow;)V public fun isRedoAvailable ()Lkotlinx/coroutines/flow/Flow; public fun isUndoAvailable ()Lkotlinx/coroutines/flow/Flow; diff --git a/ballast-undo/build.gradle.kts b/ballast-undo/build.gradle.kts index 8adf299a..ea2488d3 100644 --- a/ballast-undo/build.gradle.kts +++ b/ballast-undo/build.gradle.kts @@ -3,7 +3,7 @@ plugins { id("copper-leaf-android-library") id("copper-leaf-targets") id("copper-leaf-tests") -// id("copper-leaf-lint") + id("copper-leaf-lint") id("copper-leaf-publish") } diff --git a/ballast-undo/src/commonMain/kotlin/com/copperleaf/ballast/undo/state/StateBasedUndoControllerInputHandler.kt b/ballast-undo/src/commonMain/kotlin/com/copperleaf/ballast/undo/state/StateBasedUndoControllerInputHandler.kt index d9183cbd..4074ad9b 100644 --- a/ballast-undo/src/commonMain/kotlin/com/copperleaf/ballast/undo/state/StateBasedUndoControllerInputHandler.kt +++ b/ballast-undo/src/commonMain/kotlin/com/copperleaf/ballast/undo/state/StateBasedUndoControllerInputHandler.kt @@ -80,7 +80,6 @@ internal class StateBasedUndoControllerInputHandler, newFrame: State, diff --git a/ballast-utils/README.md b/ballast-utils/README.md new file mode 100644 index 00000000..eaa7d458 --- /dev/null +++ b/ballast-utils/README.md @@ -0,0 +1,51 @@ +# Ballast Utils + +## Overview + +Helper functions and a configuration DSL used throughout the Ballast framework. This module is included transitively +via [Ballast Core](./../ballast-core) and you generally do not need to depend on it directly. + +## Supported Platforms + +| Platform | Supported | +|----------|-----------| +| JVM | ✅ | +| Android | ✅ | +| iOS | ✅ | +| JS | ✅ | +| WASM JS | ✅ | + +## See Also + +- [Ballast Core](./../ballast-core) + +## Usage + +`ballast-utils` is not intended for direct use in application code. It is pulled in transitively when you depend on +[Ballast Core](./../ballast-core). The utilities and DSL helpers it provides are used internally by other Ballast +modules. + +## Installation + +```kotlin +repositories { + mavenCentral() +} + +// for plain JVM or Android projects +dependencies { + implementation("io.github.copper-leaf:ballast-utils:{{ballastVersion}}") +} + +// for multiplatform projects +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + implementation("io.github.copper-leaf:ballast-utils:{{ballastVersion}}") + } + } + } +} +``` + diff --git a/ballast-utils/build.gradle.kts b/ballast-utils/build.gradle.kts index cc6b9639..c956a2cb 100644 --- a/ballast-utils/build.gradle.kts +++ b/ballast-utils/build.gradle.kts @@ -3,7 +3,7 @@ plugins { id("copper-leaf-android-library") id("copper-leaf-targets") id("copper-leaf-tests") -// id("copper-leaf-lint") + id("copper-leaf-lint") id("copper-leaf-publish") } diff --git a/ballast-utils/src/commonMain/kotlin/com/copperleaf/ballast/core/KillSwitch.kt b/ballast-utils/src/commonMain/kotlin/com/copperleaf/ballast/core/KillSwitch.kt index e2195ca8..b887bc00 100644 --- a/ballast-utils/src/commonMain/kotlin/com/copperleaf/ballast/core/KillSwitch.kt +++ b/ballast-utils/src/commonMain/kotlin/com/copperleaf/ballast/core/KillSwitch.kt @@ -12,6 +12,8 @@ import kotlinx.coroutines.launch import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds +@Suppress("DEPRECATION") +@Deprecated("Use the built-in BallastViewModel.close() instead.") public class KillSwitch( private val gracePeriod: Duration = 100.milliseconds, ) : BallastInterceptor { diff --git a/ballast-utils/src/commonTest/kotlin/com/copperleaf/ballast/utils/BallastUtilsTests.kt b/ballast-utils/src/commonTest/kotlin/com/copperleaf/ballast/utils/BallastUtilsTests.kt index 2a38a257..b37190ca 100644 --- a/ballast-utils/src/commonTest/kotlin/com/copperleaf/ballast/utils/BallastUtilsTests.kt +++ b/ballast-utils/src/commonTest/kotlin/com/copperleaf/ballast/utils/BallastUtilsTests.kt @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") + package com.copperleaf.ballast.utils import com.copperleaf.ballast.core.BootstrapInterceptor @@ -6,6 +8,7 @@ import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals +@Suppress("DEPRECATION") class BallastUtilsTests { @Test fun checkToStringValues() = runTest { diff --git a/ballast-viewmodel/README.md b/ballast-viewmodel/README.md new file mode 100644 index 00000000..97dea2a7 --- /dev/null +++ b/ballast-viewmodel/README.md @@ -0,0 +1,206 @@ +# Ballast ViewModel + +## Overview + +Default implementations of `BallastViewModel`, as the base class your own ViewModels should use or extend. + +## Supported Platforms + +| Platform | Supported | +|----------|-----------| +| JVM | ✅ | +| Android | ✅ | +| iOS | ✅ | +| JS | ✅ | +| WASM JS | ✅ | + +## See Also + +- [Ballast Core](./../ballast-core) + +## Usage + +### BasicViewModel + +`BasicViewModel` is generic ViewModel for Kotlin targets that don't have their own platform-specific ViewModel, or for +anywhere you want to manually control the lifecycle of the ViewModel. `BasicViewModel`'s lifecycle is controlled by a +`coroutineScope` provided to it upon creation. When the scope gets cancelled, the ViewModel gets closed and can not be +used again. + +This is the recommended choice for Compose Multiplatform applications, as it works on all supported platforms and you +can attach a ViewModel to an arbitrary point in the composition with `rememberCoroutineScope()`. Typically, you would +attach the ViewModel to the root composable of a Screen, collect its state, and pass the VM State and a `postInput` +lambda to a stateless version of the Screen composable. + +A `BasicViewModel` attaches the EventHandler directly in the constructor, so it is running and collecting Events as long +as the ViewModel itself is active. + +```kotlin +class ExampleViewModel(coroutineScope: CoroutineScope) : BasicViewModel< + ExampleContract.Inputs, + ExampleContract.Events, + ExampleContract.State + >( + coroutineScope = coroutineScope, + config = BallastViewModelConfiguration.Builder() + .withViewModel( + initialState = ExampleContract.State(), + inputHandler = ExampleInputHandler(), + name = "Example" + ) + .build(), + eventHandler = eventHandler { }, +) + +// stateful Screen function, with state managed by the ExampleViewModel +@Composable +fun ExampleScreen() { + val viewModelCoroutineScope = rememberCoroutineScope() + val vm: ExampleViewModel = remember(viewModelCoroutineScope) { + ExampleViewModel(viewModelCoroutineScope) + } + + // collect the VM state and call the stateless function + val uiState by vm.observeStates().collectAsState() + ExampleScreen(uiState) { vm.trySend(it) } +} + +// stateless Screen function +@Composable +fun ExampleScreen( + uiState: ExampleContract.State, + postInput: (ExampleContract.Inputs)->Unit +) { + // ... +} +``` + +### AndroidViewModel + +The `AndroidViewModel` is a subclass of `androidx.lifecycle.ViewModel`, which allows it to be retained for longer +durations, and shared throughout your app via Dependency Injection. It is only supported on Android targets. + +Since AndroidViewModels may be retained and active while the app or screen it supplies is not in the foreground, the +EventHandler should be attached dynamically when the ViewModel's corresponding UI component is brought back into the +foreground. It also contains helper functions for collecting the State on a valid Lifecycle state. + +**Compose UI Example with Koin injection** + +```kotlin +class ExampleViewModel() : AndroidViewModel< + ExampleContract.Inputs, + ExampleContract.Events, + ExampleContract.State + >( + coroutineScope = MainScope(), // not necessary, but recommended so you can inject Dispatchers for testing + config = BallastViewModelConfiguration.Builder() + .withViewModel( + initialState = ExampleContract.State(), + inputHandler = ExampleInputHandler(), + name = "Example" + ) + .build(), +) + +// stateful Screen function, with state managed by the ExampleViewModel and injected by Koin +@Composable +fun ExampleScreen(vm: ExampleViewModel = koinViewModel()) { + // collect the VM state and call the stateless function + val uiState by vm.observeStates().collectAsStateWithLifecycle() + val lifecycleOwner = LocalLifecycleOwner.current + + LaunchedEffect(vm, lifecycleOwner) { + viewModel.attachEventHandlerOnLifecycle(this, ExampleEventHandler()) + } + + ExampleScreen(uiState) { vm.trySend(it) } +} + +// stateless Screen function +@Composable +fun ExampleScreen( + uiState: ExampleContract.State, + postInput: (ExampleContract.Inputs)->Unit +) { + // ... +} +``` + +**XML UI Example with Koin injection** + +```kotlin +class ExampleViewModel() : AndroidViewModel< + ExampleContract.Inputs, + ExampleContract.Events, + ExampleContract.State + >( + coroutineScope = MainScope(), // not necessary, but recommended so you can inject Dispatchers for testing + config = BallastViewModelConfiguration.Builder() + .withViewModel( + initialState = ExampleContract.State(), + inputHandler = ExampleInputHandler(), + name = "Example" + ) + .build(), +) + +class ExampleActivity : AppCompatActivity() { + + // Lazy inject ViewModel + val detailViewModel: ExampleViewModel by viewModel() + private var binding: ExampleActivityBinding? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView( + ExampleActivityBinding + .inflate(layoutInflater, null, false) + .also { binding = it } + .root + ) + + // Collect the state and react to events during the Fragment's Lifecycle RESUMED state + vm.runOnLifecycle(this, ExampleEventHandler(this)) { state -> + binding?.updateWithState(state) { event -> vm.trySend(event) } + } + } + + private fun ExampleActivityBinding.updateWithState( + state: ExampleContract.State, + postInput: (ExampleContract.Inputs) -> Unit + ) { + // update XML UI and re-register listeners + } +} +``` + +### IosViewModel + +A custom ViewModel that can be integrated with Combine Publishers for SwiftUI. This is not a recommended approach as it +is difficult to bridge Kotlin and SwiftUI directly at this layer, and this ViewModel was never tested or used +thoroughly. Either use a fully-Kotlin UI with Compose Multiplatform and Ballast ViewModels, or let the SwiftUI use its +own ViewModels and Ui state management, paired with Kotlin Multiplatform for the Domain/Data layers if your application. + +## Installation + +```kotlin +repositories { + mavenCentral() +} + +// for plain JVM or Android projects +dependencies { + implementation("io.github.copper-leaf:ballast-viewmodel:{{ballastVersion}}") +} + +// for multiplatform projects +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + implementation("io.github.copper-leaf:ballast-viewmodel:{{ballastVersion}}") + } + } + } +} +``` diff --git a/ballast-viewmodel/api/android/ballast-viewmodel.api b/ballast-viewmodel/api/android/ballast-viewmodel.api index 307e9736..dcc64771 100644 --- a/ballast-viewmodel/api/android/ballast-viewmodel.api +++ b/ballast-viewmodel/api/android/ballast-viewmodel.api @@ -6,6 +6,7 @@ public class com/copperleaf/ballast/core/AndroidViewModel : androidx/lifecycle/V public static synthetic fun attachEventHandler$default (Lcom/copperleaf/ballast/core/AndroidViewModel;Lkotlinx/coroutines/CoroutineScope;Lcom/copperleaf/ballast/EventHandler;ILjava/lang/Object;)Lkotlinx/coroutines/Job; public final fun attachEventHandlerOnLifecycle (Landroidx/lifecycle/LifecycleOwner;Lcom/copperleaf/ballast/EventHandler;Landroidx/lifecycle/Lifecycle$State;)Lkotlinx/coroutines/Job; public static synthetic fun attachEventHandlerOnLifecycle$default (Lcom/copperleaf/ballast/core/AndroidViewModel;Landroidx/lifecycle/LifecycleOwner;Lcom/copperleaf/ballast/EventHandler;Landroidx/lifecycle/Lifecycle$State;ILjava/lang/Object;)Lkotlinx/coroutines/Job; + public fun close ()V public fun observeStates ()Lkotlinx/coroutines/flow/StateFlow; public final fun observeStatesOnLifecycle (Landroidx/lifecycle/LifecycleOwner;Landroidx/lifecycle/Lifecycle$State;Lkotlin/jvm/functions/Function1;)Lkotlinx/coroutines/Job; public static synthetic fun observeStatesOnLifecycle$default (Lcom/copperleaf/ballast/core/AndroidViewModel;Landroidx/lifecycle/LifecycleOwner;Landroidx/lifecycle/Lifecycle$State;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lkotlinx/coroutines/Job; @@ -22,6 +23,7 @@ public final class com/copperleaf/ballast/core/AndroidViewModel$Companion { public class com/copperleaf/ballast/core/BasicViewModel : com/copperleaf/ballast/BallastViewModel { public fun (Lcom/copperleaf/ballast/BallastViewModelConfiguration;Lcom/copperleaf/ballast/EventHandler;Lkotlinx/coroutines/CoroutineScope;)V public synthetic fun (Lcom/copperleaf/ballast/BallastViewModelConfiguration;Lcom/copperleaf/ballast/EventHandler;Lkotlinx/coroutines/CoroutineScope;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun close ()V public fun observeStates ()Lkotlinx/coroutines/flow/StateFlow; public fun send (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun sendAndAwaitCompletion (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; diff --git a/ballast-viewmodel/api/jvm/ballast-viewmodel.api b/ballast-viewmodel/api/jvm/ballast-viewmodel.api index dd4f9ece..bf3f1eb5 100644 --- a/ballast-viewmodel/api/jvm/ballast-viewmodel.api +++ b/ballast-viewmodel/api/jvm/ballast-viewmodel.api @@ -1,6 +1,7 @@ public class com/copperleaf/ballast/core/BasicViewModel : com/copperleaf/ballast/BallastViewModel { public fun (Lcom/copperleaf/ballast/BallastViewModelConfiguration;Lcom/copperleaf/ballast/EventHandler;Lkotlinx/coroutines/CoroutineScope;)V public synthetic fun (Lcom/copperleaf/ballast/BallastViewModelConfiguration;Lcom/copperleaf/ballast/EventHandler;Lkotlinx/coroutines/CoroutineScope;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun close ()V public fun observeStates ()Lkotlinx/coroutines/flow/StateFlow; public fun send (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun sendAndAwaitCompletion (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; diff --git a/ballast-viewmodel/build.gradle.kts b/ballast-viewmodel/build.gradle.kts index 997d953b..4592d0dc 100644 --- a/ballast-viewmodel/build.gradle.kts +++ b/ballast-viewmodel/build.gradle.kts @@ -3,7 +3,7 @@ plugins { id("copper-leaf-android-library") id("copper-leaf-targets") id("copper-leaf-tests") -// id("copper-leaf-lint") + id("copper-leaf-lint") id("copper-leaf-publish") } diff --git a/ballast-viewmodel/src/androidMain/kotlin/com/copperleaf/ballast/core/AndroidViewModel.kt b/ballast-viewmodel/src/androidMain/kotlin/com/copperleaf/ballast/core/AndroidViewModel.kt index 05b5af4e..d04e6d38 100644 --- a/ballast-viewmodel/src/androidMain/kotlin/com/copperleaf/ballast/core/AndroidViewModel.kt +++ b/ballast-viewmodel/src/androidMain/kotlin/com/copperleaf/ballast/core/AndroidViewModel.kt @@ -19,8 +19,7 @@ import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import java.io.Closeable -public open class AndroidViewModel -private constructor( +public open class AndroidViewModel private constructor( private val impl: BallastViewModelImpl, providedCoroutineScope: CoroutineScope? ) : ViewModel( diff --git a/ballast-viewmodel/src/iosMain/kotlin/com/copperleaf/ballast/core/IosViewModel.kt b/ballast-viewmodel/src/iosMain/kotlin/com/copperleaf/ballast/core/IosViewModel.kt index 3f12b09e..562cd90e 100644 --- a/ballast-viewmodel/src/iosMain/kotlin/com/copperleaf/ballast/core/IosViewModel.kt +++ b/ballast-viewmodel/src/iosMain/kotlin/com/copperleaf/ballast/core/IosViewModel.kt @@ -42,7 +42,7 @@ public open class IosViewModel private ) } - public fun close() { + override fun close() { impl.viewModelScope.cancel() } } diff --git a/build.gradle.kts b/build.gradle.kts index 2e255e8c..bc47e6e7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,14 +8,15 @@ apiValidation { ignoredProjects.addAll( listOf( // "docs", -// "android", -// "counter", -// "desktop", -// "navigationWithCustomRoutes", -// "navigationWithEnumRoutes", -// "schedules", -// "web", -// "ballast-idea-plugin", + "android", + "counter", + "desktop", + "navigationWithCustomRoutes", + "navigationWithEnumRoutes", + "schedules", + "web", + "ballast-idea-plugin", + "queue", ) ) } diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..13a3b8de --- /dev/null +++ b/docs/README.md @@ -0,0 +1,105 @@ +# Ballast Documentation + +Ballast is an opinionated MVI state management framework for Kotlin Multiplatform. This directory contains +project-level documentation. Module-specific documentation lives in each module's own README. + +## In This Directory + +- [Getting Started](getting-started.md) — Build your first Ballast screen step by step +- [Feature Overview](feature-overview.md) — Core concepts: ViewModels, Contracts, Handlers, Side Jobs, Interceptors +- [Thinking in Ballast MVI](mental-model.md) — Deep dive into the MVI model, state design philosophy, and Ballast's approach +- [Feature Comparison](feature-comparison.md) — Ballast vs Redux, Orbit, MVIKotlin, Uniflow-kt +- [Community](community.md) — Community-built extensions and integrations + +### Migration Guides + +Ballast has had a number of major version releases over the years. While the core APIs have remained stable, there are +some notable changes from time-to-time that you'll need to keep up with. Whenever possible, breaking changes are first +marked as deprecated for at least 1 full major-version cycle so you have ample time to migrate to its replacement before +the legacy functionality is removed. + +- [v2 → v3](migration/v3.md) +- [v3 → v4](migration/v4.md) + +--- + +## Modules + +### Core + +The `ballast-core` module is the main dependency you'll need to get started with Ballast. It brings in all of the +following sub-modules: + +| Module | Description | +|--------------------------------------------|--------------------------------------------------------------------------------| +| [ballast-core](../ballast-core/) | **Start here.** Aggregates the core modules; standard dependency for most apps | +| [ballast-api](../ballast-api/) | Core interfaces and contracts; use this when building Ballast extensions | +| [ballast-viewmodel](../ballast-viewmodel/) | Platform-specific ViewModel base classes (Android, iOS, Basic) | +| [ballast-logging](../ballast-logging/) | Logging interceptor and platform-specific logger implementations | +| [ballast-utils](../ballast-utils/) | Internal utilities used by other Ballast modules | + +### Front-end Features + +Ballast is designed with a flexible plugin architecture. The following modules provide plugins to augment the core MVI +functionality with useful features for UI development + +| Module | Description | +|--------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------| +| [ballast-saved-state](../ballast-saved-state/) | Save and restore ViewModel state across process death | +| [ballast-undo](../ballast-undo/) | Undo/redo support via state snapshots | +| [ballast-sync](../ballast-sync/) | Synchronize state across multiple ViewModel instances | +| [ballast-analytics](../ballast-analytics/) | Analytics event tracking interceptor | +| [ballast-firebase-analytics](../ballast-firebase-analytics/) | Firebase Analytics tracker for `ballast-analytics` | +| [ballast-crash-reporting](../ballast-crash-reporting/) | Crash reporting interceptor | +| [ballast-firebase-crashlytics](../ballast-firebase-crashlytics/) | Firebase Crashlytics reporter for `ballast-crash-reporting` | +| [ballast-navigation](../ballast-navigation/) | Type-safe navigation and backstack management | +| [ballast-repository](../ballast-repository/) | **Deprecated** MVI pattern extended to the repository layer with built-in caching | +| [ballast-schedules](../ballast-schedules/) | **Deprecated** Schedule definitions for use with the scheduler modules | +| [ballast-scheduler-core](../ballast-scheduler-core/) | Core scheduler infrastructure | +| [ballast-scheduler-viewmodel](../ballast-scheduler-viewmodel/) | ViewModel-based scheduler | +| [ballast-scheduler-android-alarmmanager](../ballast-scheduler-android-alarmmanager/) | Android AlarmManager-based scheduler | + +### Server-side Features + +Recently, Ballast has evolved beyond just front-end state management, and is becoming a solution for server-side +event-driven workloads. + +| Module | Description | +|------------------------------------------------------------------|---------------------------------------------------------------------------| +| [ballast-ktor-server](../ballast-ktor-server/) | Ktor server-side integration | +| [ballast-autoscale](../ballast-autoscale/) | Automatically scale ViewModel resources based on load | +| [ballast-queue-core](../ballast-queue-core/) | Persistent job queue core | +| [ballast-queue-viewmodel](../ballast-queue-viewmodel/) | ViewModel-based job queue | +| [ballast-queue-exposed-driver](../ballast-queue-exposed-driver/) | Exposed (SQL) storage driver for the job queue | +| [ballast-scheduler-core](../ballast-scheduler-core/) | Core scheduler infrastructure | +| [ballast-scheduler-viewmodel](../ballast-scheduler-viewmodel/) | ViewModel-based scheduler. Integrated well with the ViewModel-based queue | +| [ballast-scheduler-cron](../ballast-scheduler-cron/) | Cron expression support for the scheduler | + +### Utilities + +These utilities help you in the development and maintenance of your Ballast ViewModels. + +| Module | Description | +|--------------------------------------------------------------------|-----------------------------------------------------------------------| +| [ballast-test](../ballast-test/) | Testing utilities for Ballast ViewModels | +| [ballast-kotlinx-serialization](../ballast-kotlinx-serialization/) | kotlinx.serialization support for debugger and other modules | +| [ballast-debugger-client](../ballast-debugger-client/) | Interceptor that connects ViewModels to the IntelliJ debugger UI | +| [ballast-debugger-models](../ballast-debugger-models/) | Shared data models for debugger client/server communication | +| [ballast-idea-plugin](../ballast-idea-plugin/) | IntelliJ plugin — real-time ViewModel inspection and code scaffolding | + +--- + +## Examples + +There are many examples showing how to use the various plugins and features of Ballast. Clone this repo and run these +examples to better understand Ballast's many features. + +| Example | Description | +|-------------------------------------------------------------------|----------------------------------------------------------------------------------------| +| [counter](../examples/counter/) | Minimal counter — the simplest possible Ballast app | +| [navigationWithEnumRoutes](../examples/navigationWithEnumRoutes/) | Navigation and backstack management with enum-defined routes | +| [web](../examples/web/) | JS/browser app with multiple scenarios: Kitchen Sink, ScoreKeeper, Sync, Undo, BGG API | +| [android](../examples/android/) | Android implementations of the same scenarios as the web example | +| [desktop](../examples/desktop/) | Compose Desktop implementations of the same scenarios as the web example | +| [compose_sharedui_kmm](../examples/compose_sharedui_kmm/) | Shared Compose UI across Android, iOS, Desktop, and Web | +| [queue](../examples/queue/) | Job queue example | diff --git a/docs/build.gradle.kts b/docs/build.gradle.kts deleted file mode 100644 index 09071341..00000000 --- a/docs/build.gradle.kts +++ /dev/null @@ -1,4 +0,0 @@ -plugins { - id("copper-leaf-base") - id("copper-leaf-docs") -} diff --git a/docs/src/doc/docs/pages/community.md b/docs/community.md similarity index 96% rename from docs/src/doc/docs/pages/community.md rename to docs/community.md index d42933e2..110d2b74 100644 --- a/docs/src/doc/docs/pages/community.md +++ b/docs/community.md @@ -1,6 +1,3 @@ ---- ---- - This page lists all the wonderful extensions to Ballast built by its community. - [kvision-ballast](https://github.com/rjaros/kvision/tree/master/kvision-modules/kvision-ballast) diff --git a/docs/feature-comparison.md b/docs/feature-comparison.md new file mode 100644 index 00000000..d2eec250 --- /dev/null +++ b/docs/feature-comparison.md @@ -0,0 +1,330 @@ +# Feature Comparison + + +## Feature Summary + +This page is a comparison of several MVI libraries, to help you understand how each library is similar or different from +the others. I sincerely believe Ballast is the best option for MVI state management in Kotlin, but that doesn't mean the +other libraries aren't good options too. Some of them might have a API that just clicks with you better, and that's +perfectly fine. This comparison can help you figure out if Ballast is the right option for you, and if not, help you +determine your suitable alternative. + +The obvious disclaimer is that this list is put together by the person behind Ballast, so I'm obviously a bit biased +toward my own library. But I really do want this to be as objective of a comparison as possible, so if you see any +errors or anything seems misleading, please let me know or submit a pull request to correct it! + +And to further combat bias, I'd recommend also checking out [this article][01] for a more in-depth comparison of +these Android/Kotlin MVI libraries, which doesn't include Ballast. This article is from one of the developers of Orbit MVI. + +The following libraries are compared in this article: + +- Ballast +- [Redux][20] +- [Orbit][30] +- [MVIKotlin][40] +- [Uniflow-kt][50] + +**Legend** + +- ✅ Fully Officially supported   +- ✓ Fully supported by 3rd-party   +- ⚠️ Partially supported   +- ❌ Not supported   + +## General + +### General Philosophy + +> **Note:** +> This refers to the general development philosophy behind the development of the library, such as whether it's aiming +> to be lightweight or fully featured, as well as any other significant notes about how to approach the library. +- **Ballast**: Opinionated Application State Management framework for all KMP targets +- **Redux**: Lightweight JS UI State Management library, with many official and unofficial extensions +- **Orbit**: Fully-featured, low-profile UI MVI framework for Android +- **MVIKotlin**: Redux implementation in Kotlin for Android +- **Uniflow-KT**: + +### MVI Style + +> **Note:** +> +> MVI Style refers to the general API of the library: Redux-style sends discrete objects to the library and uses some kind +> of transformer class to split out the objects into discrete streams for each input type. Additionally, a true Redux +> style only transforms state, with mapper functions receiving the current state and returning the updated state, +> typically called a reducer (`(State, Input)->State`). +> +> The MVVM+ style discards the discrete input classes, and instead offers helper functions within the ViewModel to +> translate function calls on the ViewModel into lambdas that are processed in the expected MVI manner. MVVM+ typically +> offers a richer API, more functionality, and reduced boilerplate, but makes it less obvious what's actually going on +> within the library. +> +> - **Ballast**: Redux-style discrete Inputs with MVVM+ style DSL +> - **Redux**: Redux +> - **Orbit**: MVVM+ +> - **MVIKotlin**: Redux +> - **Uniflow-KT**: MVVM+ +> +> ### Kotlin Multiplatform Support +> +> > **Note:** +> > Whether this library is available for Kotlin Multiplatform, or is limited to a single platform. +> - **Ballast**: ✅ +> - **Redux**: ❌ +> - **Orbit**: ✅ +> - **MVIKotlin**: ✅ +> - **Uniflow-KT**: ❌ +> +> ### Opinionated structure +> +> > **Note:** +> > MVI is a lert lightweight design pattern overall, not really mandaing much in terms of classes, naming conventions, etc. +> > But being so lightweight can make it difficult to get started if you're not comfortable with the MVI model, so it can be +> > helpful to have a library be opinionated about how it should be used, so you can more easily copy-and-paste code +> > snippets to make it easier to try out on your own. +> - **Ballast**: ✅ +> - **Redux**: ✓ `createSlice()` in Redux Toolkit defines an opinionated structure +> - **Orbit**: ❌ Intentionally unopinionated. "MVI without the baggage. It's so simple we think of it as MVVM+" +> - **MVIKotlin**: ❌ +> - **Uniflow-KT**: ❌ Intentionally unopinionated +> +> ### Reduced boilerplate +> +> > **Note:** +> > With the MVI model comes a fair amount of boilerplate. Between creating the ViewModel/Store, defining the contract for +> > your State and Intents, and wiring everything up in your application code, it can be a bit overwheling. This section +> > shows how each library attempts to wrangle that boilerplate and make it more approachable for new users, and less +> > tedious for long-time users. +> - **Ballast**: ✅ Templates/scaffolds available in [Official IntelliJ Plugin][11] +> - **Redux**: ✓ `createSlice()` in Redux Toolkit reduces boilerplate +> - **Orbit**: ✅ The whole framework was created to reduce boilerplate +> - **MVIKotlin**: ❌ +> - **Uniflow-KT**: ✅ The whole framework was created to reduce boilerplate +> +> ## State +> +> ### Reactive State +> +> > **Note:** +> > All state management libraries have a way to observe states, and this shows the function calls needed to subscribe to +> > that state. +> - **Ballast**: ✅ `vm.observeStates()` +> - **Redux**: ⚠️ `store.subscribe()` or 3rd-party libraries +> - **Orbit**: ✅ `container.stateFlow` +> - **MVIKotlin**: ✅ `store.states(Observer)` +> - **Uniflow-KT**: ✅ `onStates(viewModel) { }` +> +> ### Get State Snapshot +> +> > **Note:** +> > Since MVI is by nature reactive, not all libraries offer an option to just query it for the current state at a given +> > point in time. This section shows how to get a state snapshot if it is available. +> - **Ballast**: ✅ `vm.observeStates().value` +> - **Redux**: ✅ `store.getState()` +> - **Orbit**: ✅ `container.stateFlow.value` +> - **MVIKotlin**: ✅ +> - **Uniflow-KT**: ❌ +> +> ### State Immutability +> +> > **Note:** +> > One of the big requirements for the MVI model to work properly is an immutable state class. If you can mutate the +> > properties of the state in any way other than dispatching an Intent, then the whole model breaks down. This section +> > explains how each library achieves immutability. +> - **Ballast**: ✅ Built-in with Kotlin data class +> - **Redux**: ✓ Requires Redux Toolkit w/ Immer +> - **Orbit**: ✅ Built-in with Kotlin data class +> - **MVIKotlin**: ✅ Built-in with Kotlin data class +> - **Uniflow-KT**: ✅ Built-in with Kotlin data class +> +> ### Update State +> +> > **Note:** +> > This section shows the DSL methods used to update the state. Redux-style updates the state as part of the Reducer's +> > function signature, which always returns the updated state. MVVM+ style provides a privileged scope during the handling +> > of an Intent, which allows you to call a method to update the state. +> - **Ballast**: ✅ `updateState { }` +> - **Redux**: ✅ Reducers +> - **Orbit**: ✅ `reduce { }` +> - **MVIKotlin**: ✅ `Reducer` +> - **Uniflow-KT**: ✅ `setState { }` +> +> ### Restore Saved States +> +> > **Note:** +> > Sometimes you may need to destroy and recreate a ViewModel, and it is convenient to have a way to restore the previous +> > state of that ViewModel without needing to do a full data refresh. This shows how this could be achieved with each +> > library. +> - **Ballast**: ✅ [Saved State module][14] +> - **Redux**: ❌ +> - **Orbit**: ✅ Built-in +> - **MVIKotlin**: ⚠️ Manual restoration with Essenty +> - **Uniflow-KT**: ⚠️ Only supports Android `SavedStateHandle` +> +> #### Lifecycle Support +> +> > **Note:** +> > Applications usually have some concept of a "lifecycle", where screens, scopes, and other features are constructed and +> > torn down automatically by the framework. Ideally, you'd like your ViewModels to respect that lifecycle and prevent +> > changes from being sent to the UI when it is not able to receive them. This section shows how you would tie your +> > ViewModel's valid lifetime into the platform's Lifecycle. +> - **Ballast**: ✅ Controlled by CoroutineScope +> - **Redux**: ❌ +> - **Orbit**: ✅ Controlled by Android ViewModel +> - **MVIKotlin**: ⚠️ Manual control with Essenty/Binder utilities +> - **Uniflow-KT**: ✅ Controlled by Android ViewModel +> +> ## Automatic View-Binding +> +> > **Note:** +> > One can naively understand the MVI model as a way to automatically apply data to the UI. In reality this description +> > is more accurate to the MVVM model, but regardless, some libraries offer specificly-tailed integrations into the UI +> > to reduce boilerplate and blur the line between MVVM and MVI. +> - **Ballast**: ❌ Views observe State directly +> - **Redux**: ✓ Integrates very well with React +> - **Orbit**: ❌ Views observe State directly +> - **MVIKotlin**: ⚠️ Optional `MviView` utility +> - **Uniflow-KT**: ❌ Views observe State directly +> +> ## Non-UI State Management +> +> > **Note:** +> > State Management at its core is not concerned about UI, it's just concerned about data. And there's a lot of other data +> > in your application that would do well to be managed in the same way as your UI state. This section shows which +> > libraries have special support or documentation for managing non-UI state. +> - **Ballast**: ✅ [Repository module][13] +> - **Redux**: ❌ +> - **Orbit**: ❌ +> - **MVIKotlin**: ❌ +> - **Uniflow-KT**: ❌ +> +> ## Intents +> +> ### Create Intent +> +> > **Note:** +> > Some MVI libraries have strict rules around creating Intents, while others are a bit more relaxes, or maybe even handle +> > everything internally. This section shows how to create an Intent object. +> - **Ballast**: ✅ Input sealed subclass constructor +> - **Redux**: ✅ "actionCreators" functions +> - **Orbit**: ⚠️ Implicit, `fun vmAction() = intent { }` +> - **MVIKotlin**: ✅ Input sealed subclass constructor +> - **Uniflow-KT**: ⚠️ Implicit, `fun vmAction = action { }` +> +> ### Send Intent to VM +> +> > **Note:** +> > This shows how one would dispatch an Intent into the library for eventual processing. +> - **Ballast**: ✅ `vm.send(Input)`/`vm.trySend(Input)` +> - **Redux**: ✅ `store.dispatch()` +> - **Orbit**: ✅ Directly call VM function +> - **MVIKotlin**: ✅ `store.accept(Intent)` +> - **Uniflow-KT**: ✅ Directly call VM function +> +> ## Asynchronous processing +> +> ### Async Foreground Computation +> +> > **Note:** +> > Foreground computations block the Intent processing queue, allowing long-running work to be completed and then directly +> > update the state before another Intent starts processing. +> - **Ballast**: ✅ Built-in with Coroutines +> - **Redux**: ❌ +> - **Orbit**: ✅ Built-in with Coroutines +> - **MVIKotlin**: ❌ +> - **Uniflow-KT**: ✅ Built-in with Coroutines +> +> ### Async Background Computation +> +> > **Note:** +> > Background computations do not block the main Intent queue and run in parallel to the ViewModel, but also cannot +> > directly update the state. Background jobs run in parallel to the ViewModel and send their own Intents, which will get +> > processed just as if the Intent were generated by the user. +> > +> > Background computations should also be bound by the same lifecycle as the ViewModel (if supported), so that these jobs +> > do not leak and continue running beyond the ViewModel's ability to process the changes it submits. +> - **Ballast**: ✅ `sideJob(key) { }` +> - **Redux**: ✓ "Thunk" middleware +> - **Orbit**: ✅ `repeatOnSubscription { }` +> - **MVIKotlin**: ✅ Executors+Messages +> - **Uniflow-KT**: ⚠️ Background work launched directly in Android viewModelScope. `onFlow` utility for processing Flows +> +> ## One-Time Notifications +> +> ### Send one-off Notifications +> +> > **Note:** +> > Sending events that should only be handled once is not strictly part of the MVI model, but it can be a very useful +> > feature for integrating a state management library into an older, imperative UI toolkit. This section shows how to send +> > these notifications from each library which supports it. +> - **Ballast**: ✅ `postEvent()` +> - **Redux**: ❌ +> - **Orbit**: ✅ `postSideEffect()` +> - **MVIKotlin**: ✅ `publish(Label)` +> - **Uniflow-KT**: ✅ `sendEvent()` +> +> ### React to one-off Notifications +> +> > **Note:** +> > If the library is capable of sending one-off notifications, this section shows how to register your application to +> > react to those notifications. +> - **Ballast**: ✅ `vm.attachEventHandler(EventHandler)` +> - **Redux**: ❌ +> - **Orbit**: ✅ `container.sideEffectFlow.collect { }` +> - **MVIKotlin**: ✅ `store.labels(Observer