├── build.gradle.kts ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── readme.md ├── settings.gradle.kts └── src ├── commonMain └── kotlin │ ├── RetryBackoff.kt │ ├── SharedDataSource.kt │ ├── StateDataSource.kt │ ├── flowOf.kt │ ├── mapAsync.kt │ ├── raceOf.kt │ ├── retryWhen.kt │ └── suspendLazy.kt └── commonTest └── kotlin ├── MapAsyncConcurrencyTest.kt ├── MapAsyncTest.kt ├── RaceOfTest.kt ├── RetryBackoffFlowTest.kt ├── RetryWhenTest.kt ├── SharedDataSourceTest.kt └── SuspendLazyTest.kt /build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.targets.js.testing.KotlinJsTest 2 | import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig 3 | 4 | plugins { 5 | kotlin("multiplatform") version "1.8.20-RC" 6 | `maven-publish` 7 | } 8 | 9 | group = "org.example" 10 | version = "1.0-SNAPSHOT" 11 | 12 | repositories { 13 | mavenCentral() 14 | } 15 | 16 | kotlin { 17 | jvm { 18 | jvmToolchain(8) 19 | withJava() 20 | testRuns["test"].executionTask.configure { 21 | useJUnitPlatform() 22 | } 23 | } 24 | js(IR) { 25 | browser { 26 | testTask(Action { 27 | useKarma { 28 | useFirefox() 29 | useChrome() 30 | useSafari() 31 | } 32 | }) 33 | } 34 | } 35 | // val hostOs = System.getProperty("os.name") 36 | // val isMingwX64 = hostOs.startsWith("Windows") 37 | // val nativeTarget = when { 38 | // hostOs == "Mac OS X" -> macosX64("native") 39 | // hostOs == "Linux" -> linuxX64("native") 40 | // isMingwX64 -> mingwX64("native") 41 | // else -> throw GradleException("Host OS is not supported in Kotlin/Native.") 42 | // } 43 | 44 | sourceSets { 45 | val commonMain by getting { 46 | dependencies { 47 | implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4") 48 | } 49 | } 50 | val commonTest by getting { 51 | dependencies { 52 | implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4") 53 | implementation(kotlin("test")) 54 | } 55 | } 56 | val jvmMain by getting 57 | val jvmTest by getting 58 | val jsMain by getting 59 | val jsTest by getting 60 | // val nativeMain by getting 61 | // val nativeTest by getting 62 | } 63 | } 64 | 65 | publishing { 66 | repositories { 67 | maven { 68 | url = uri("file://$projectDir/localRepo") 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | kotlin.code.style=official 2 | kotlin.js.compiler=ir 3 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MarcinMoskala/kotlin-coroutines-recipes/473d95fd20c657dfa7a2f46961ca430c216638ce/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit 84 | 85 | APP_NAME="Gradle" 86 | APP_BASE_NAME=${0##*/} 87 | 88 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 89 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | 142 | # Increase the maximum file descriptors if we can. 143 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 144 | case $MAX_FD in #( 145 | max*) 146 | MAX_FD=$( ulimit -H -n ) || 147 | warn "Could not query maximum file descriptor limit" 148 | esac 149 | case $MAX_FD in #( 150 | '' | soft) :;; #( 151 | *) 152 | ulimit -n "$MAX_FD" || 153 | warn "Could not set maximum file descriptor limit to $MAX_FD" 154 | esac 155 | fi 156 | 157 | # Collect all arguments for the java command, stacking in reverse order: 158 | # * args from the command line 159 | # * the main class name 160 | # * -classpath 161 | # * -D...appname settings 162 | # * --module-path (only if needed) 163 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 164 | 165 | # For Cygwin or MSYS, switch paths to Windows format before running java 166 | if "$cygwin" || "$msys" ; then 167 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 168 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 169 | 170 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 171 | 172 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 173 | for arg do 174 | if 175 | case $arg in #( 176 | -*) false ;; # don't mess with options #( 177 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 178 | [ -e "$t" ] ;; #( 179 | *) false ;; 180 | esac 181 | then 182 | arg=$( cygpath --path --ignore --mixed "$arg" ) 183 | fi 184 | # Roll the args list around exactly as many times as the number of 185 | # args, so each arg winds up back in the position where it started, but 186 | # possibly modified. 187 | # 188 | # NB: a `for` loop captures its iteration list before it begins, so 189 | # changing the positional parameters here affects neither the number of 190 | # iterations, nor the values presented in `arg`. 191 | shift # remove old arg 192 | set -- "$@" "$arg" # push replacement arg 193 | done 194 | fi 195 | 196 | # Collect all arguments for the java command; 197 | # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of 198 | # shell script including quotes and variable substitutions, so put them in 199 | # double quotes to make sure that they get re-expanded; and 200 | # * put everything else in single quotes, so that it's not re-expanded. 201 | 202 | set -- \ 203 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 204 | -classpath "$CLASSPATH" \ 205 | org.gradle.wrapper.GradleWrapperMain \ 206 | "$@" 207 | 208 | # Stop when "xargs" is not available. 209 | if ! command -v xargs >/dev/null 2>&1 210 | then 211 | die "xargs is not available" 212 | fi 213 | 214 | # Use "xargs" to parse quoted args. 215 | # 216 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 217 | # 218 | # In Bash we could simply go: 219 | # 220 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 221 | # set -- "${ARGS[@]}" "$@" 222 | # 223 | # but POSIX shell has neither arrays nor command substitution, so instead we 224 | # post-process each arg (as a line of input to sed) to backslash-escape any 225 | # character that might be a shell metacharacter, then use eval to reverse 226 | # that process (while maintaining the separation between arguments), and wrap 227 | # the whole thing up as a single "set" statement. 228 | # 229 | # This will of course break if any of these variables contains a newline or 230 | # an unmatched quote. 231 | # 232 | 233 | eval "set -- $( 234 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 235 | xargs -n1 | 236 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 237 | tr '\n' ' ' 238 | )" '"$@"' 239 | 240 | exec "$JAVACMD" "$@" 241 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if %ERRORLEVEL% equ 0 goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if %ERRORLEVEL% equ 0 goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | set EXIT_CODE=%ERRORLEVEL% 84 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 85 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 86 | exit /b %EXIT_CODE% 87 | 88 | :mainEnd 89 | if "%OS%"=="Windows_NT" endlocal 90 | 91 | :omega 92 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | [![](https://jitpack.io/v/MarcinMoskala/kotlin-coroutines-recipes.svg)](https://jitpack.io/#MarcinMoskala/kotlin-coroutines-recipes) 2 | 3 | # Kotlin Coroutines Recipes 4 | 5 | This repository contains Kotlin Coroutines functions that are useful in everyday projects. Feel free to use them in your projects, both as a dependency, or by copy-pasting my code. Also feel free to share your own functions, that you find useful, but remamber that they need to be properly tested. 6 | 7 | ## Dependency 8 | 9 | [![](https://jitpack.io/v/MarcinMoskala/kotlin-coroutines-recipes.svg)](https://jitpack.io/#MarcinMoskala/kotlin-coroutines-recipes) 10 | 11 | Add the dependency in your module `build.gradle(.kts)`: 12 | 13 | ``` 14 | // build.gradle / build.gradle.kts 15 | dependencies { 16 | implementation("com.github.MarcinMoskala.kotlin-coroutines-recipes:kotlin-coroutines-recipes:") 17 | } 18 | ``` 19 | 20 | Add it in your root `build.gradle(.kts)` at the repositories block: 21 | 22 | ``` 23 | // build.gradle 24 | repositories { 25 | // ... 26 | maven { url 'https://jitpack.io' } 27 | } 28 | 29 | // build.gradle.kts 30 | repositories { 31 | // ... 32 | maven("https://jitpack.io") 33 | } 34 | ``` 35 | 36 | This library can currently be used on Kotlin/JVM, Kotlin/JS and in common modules 37 | 38 | ## Recipes 39 | 40 | ### `mapAsync` 41 | 42 | Function `mapAsync` allows you to concurrently map a collection of elements to a collection of results of suspending functions. It is useful when you want to run multiple suspending functions in parallel. You can limit the number of concurrent operations by passing `concurrency` parameter. 43 | 44 | ```kotlin 45 | suspend fun getCourses(requestAuth: RequestAuth?, user: User): List = 46 | courseRepository.getAllCourses() 47 | .mapAsync { composeUserCourse(user, it) } 48 | .filterNot { courseShouldBeHidden(user, it) } 49 | .sortedBy { it.state.ordinal } 50 | ``` 51 | 52 | See [implementation](https://github.com/MarcinMoskala/kotlin-coroutines-recipes/blob/master/src/commonMain/kotlin/mapAsync.kt). 53 | 54 | ### `retryWhen` 55 | 56 | Function `retryWhen` allows you to retry an operation when a given predicate is true. It is useful when you want to retry an operation multiple times under certain conditions. 57 | 58 | ```kotlin 59 | // Example 60 | suspend fun checkConnection(): Boolean = retryWhen( 61 | predicate = { _, retries -> retries < 3 }, 62 | operation = { api.connected() } 63 | ) 64 | ``` 65 | 66 | See [implementation](https://github.com/MarcinMoskala/kotlin-coroutines-recipes/blob/master/src/commonMain/kotlin/retryWhen.kt). 67 | 68 | ### `retryBackoff` 69 | 70 | Function `retryBackoff` implements exponential backoff algorithm for retrying an operation. It is useful when you want to retry an operation multiple times with increasing delay between retries. 71 | 72 | ```kotlin 73 | fun observeUserUpdates(): Flow = api 74 | .observeUserUpdates() 75 | .retryBackoff( 76 | minDelay = 1.seconds, 77 | maxDelay = 1.minutes, // optional 78 | maxAttempts = 30, // optional 79 | backoffFactor = 2.0, // optional 80 | jitterFactor = 0.1, // optional 81 | beforeRetry = { cause, _, _ -> // optional 82 | println("Retrying after $cause") 83 | }, 84 | retriesExhausted = { cause -> // optional 85 | println("Retries exhausted after $cause") 86 | }, 87 | ) 88 | 89 | suspend fun fetchUser(): User = retryBackoff( 90 | minDelay = 1.seconds, 91 | maxDelay = 10.seconds, // optional 92 | maxAttempts = 10, // optional 93 | backoffFactor = 1.5, // optional 94 | jitterFactor = 0.5, // optional 95 | beforeRetry = { cause, _, -> // optional 96 | println("Retrying after $cause") 97 | }, 98 | retriesExhausted = { cause -> // optional 99 | println("Retries exhausted after $cause") 100 | }, 101 | ) { 102 | api.fetchUser() 103 | } 104 | ``` 105 | 106 | See [implementation](https://github.com/MarcinMoskala/kotlin-coroutines-recipes/blob/master/src/commonMain/kotlin/RetryBackoff.kt). 107 | 108 | ### `raceOf` 109 | 110 | Function `raceOf` allows you to run multiple suspending functions in parallel and return the result of the first one that finishes. 111 | 112 | ```kotlin 113 | suspend fun fetchUserData(): UserData = raceOf( 114 | { service1.fetchUserData() }, 115 | { service2.fetchUserData() } 116 | ) 117 | ``` 118 | 119 | See [implementation](https://github.com/MarcinMoskala/kotlin-coroutines-recipes/blob/master/src/commonMain/kotlin/raceOf.kt). 120 | 121 | ## `suspendLazy` 122 | 123 | Function `suspendLazy` allows you to create a function that represents a lazy suspending property. It is useful when you want to create a suspending property that is initialized only once, but you don't want to block the thread that is accessing it. 124 | 125 | ```kotlin 126 | val userData: suspend () -> UserData = suspendLazy { 127 | service.fetchUserData() 128 | } 129 | 130 | suspend fun getUserData(): UserData = userData() 131 | ``` 132 | 133 | See [implementation](https://github.com/MarcinMoskala/kotlin-coroutines-recipes/blob/master/src/commonMain/kotlin/suspendLazy.kt). 134 | 135 | ## `SharedDataSource` 136 | 137 | Class `SharedDataSource` allows you to reuse the same flow data sources created based on a key. It makes sure there is no more than a single active shared flow for a given key. It is useful when you want to reuse the same shared flow for multiple subscribers. 138 | 139 | ```kotlin 140 | val sharedDataSource = SharedDataSource>(scope) { userId -> 141 | observeMessages(userId) 142 | } 143 | 144 | fun observeMessages(userId: String): Flow = sharedDataSource.get(userId) 145 | ``` 146 | 147 | See [implementation](https://github.com/MarcinMoskala/kotlin-coroutines-recipes/blob/master/src/commonMain/kotlin/SharedDataSource.kt). 148 | 149 | ## `StateDataSource` 150 | 151 | Class `StateDataSource` allows you to reuse the same state flow created based on a key. It makes sure there is no more than a single active state flow for a given key. It is useful when you want to reuse the same state flow for multiple subscribers. 152 | 153 | ```kotlin 154 | val usersState = StateDataSource>(scope) { userId -> 155 | observeUserState(userId) 156 | } 157 | 158 | fun observeUserState(userId: String): Flow = usersState.get(userId) 159 | ``` 160 | 161 | See [implementation](https://github.com/MarcinMoskala/kotlin-coroutines-recipes/blob/master/src/commonMain/kotlin/StateDataSource.kt). 162 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | 2 | rootProject.name = "kotlin-coroutines-recipes" 3 | 4 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/RetryBackoff.kt: -------------------------------------------------------------------------------- 1 | package recipes 2 | 3 | import kotlinx.coroutines.delay 4 | import kotlinx.coroutines.flow.* 5 | import kotlin.math.max 6 | import kotlin.math.min 7 | import kotlin.math.pow 8 | import kotlin.random.Random 9 | import kotlin.time.Duration 10 | 11 | fun Flow.retryBackoff( 12 | minDelay: Duration, 13 | maxDelay: Duration = Duration.INFINITE, 14 | maxAttempts: Int = Int.MAX_VALUE, 15 | backoffFactor: Double = 2.0, 16 | transient: Boolean = true, 17 | jitterFactor: Double = 0.1, 18 | random: Random = Random, 19 | successAfterRetry: suspend (attempts: Int) -> Unit = {}, 20 | beforeRetry: suspend (cause: Throwable, currentAttempts: Int, totalAttempts: Int) -> Unit = { _, _, _ -> }, 21 | retriesExhausted: suspend (cause: Throwable) -> Unit = {}, 22 | retryCondition: suspend (cause: Throwable, currentAttempts: Int, totalAttempts: Int) -> Boolean = { _, _, _, -> true } 23 | ): Flow { 24 | require(jitterFactor in 0.0..1.0) 25 | require(maxAttempts > 0) 26 | require(backoffFactor > 1.0) 27 | require(minDelay in Duration.ZERO..maxDelay) 28 | 29 | return flow { 30 | var attemptsInRow = 0 31 | var isRetrying = false 32 | this@retryBackoff 33 | .retryWhen { cause, totalAttempts -> 34 | isRetrying = true 35 | val currentAttempts = if (transient) attemptsInRow else totalAttempts.toInt() 36 | if (currentAttempts == maxAttempts) { 37 | retriesExhausted(cause) 38 | return@retryWhen false 39 | } 40 | 41 | val effectiveDelay = 42 | calculateBackoffDelay(minDelay, maxDelay, currentAttempts, jitterFactor, random, backoffFactor) 43 | 44 | beforeRetry(cause, currentAttempts, totalAttempts.toInt()) 45 | val shouldRetry = retryCondition(cause, currentAttempts, totalAttempts.toInt()) 46 | if (!shouldRetry) return@retryWhen false 47 | delay(effectiveDelay.toLong()) 48 | attemptsInRow++ 49 | true 50 | } 51 | .onEach { 52 | if (isRetrying) { 53 | successAfterRetry(attemptsInRow) 54 | isRetrying = false 55 | } 56 | if (transient) attemptsInRow = 0 57 | } 58 | .collect(this) 59 | } 60 | } 61 | 62 | // TODO: Publish once unit-tested 63 | internal suspend fun retryBackoff( 64 | minDelay: Duration, 65 | maxDelay: Duration = Duration.INFINITE, 66 | maxAttempts: Int = Int.MAX_VALUE, 67 | backoffFactor: Double = 2.0, 68 | jitterFactor: Double = 0.1, 69 | random: Random = Random, 70 | beforeRetry: suspend (cause: Throwable, attempts: Int) -> Unit = { _, _ -> }, 71 | retriesExhausted: suspend (cause: Throwable) -> Unit = {}, 72 | block: () -> T, 73 | ): T { 74 | require(jitterFactor in 0.0..1.0) 75 | require(maxAttempts > 0) 76 | require(backoffFactor > 1.0) 77 | require(minDelay in Duration.ZERO..maxDelay) 78 | 79 | return retryWhen( 80 | predicate = { cause, currentAttempts -> 81 | if (currentAttempts == maxAttempts) { 82 | retriesExhausted(cause) 83 | return@retryWhen false 84 | } 85 | 86 | val effectiveDelay = calculateBackoffDelay( 87 | minDelay, maxDelay, currentAttempts, jitterFactor, random, 88 | backoffFactor 89 | ) 90 | beforeRetry(cause, currentAttempts) 91 | delay(effectiveDelay.toLong()) 92 | true 93 | }, 94 | operation = block, 95 | ) 96 | } 97 | 98 | private fun calculateBackoffDelay( 99 | minDelay: Duration, 100 | maxDelay: Duration, 101 | currentAttempts: Int, 102 | jitterFactor: Double, 103 | random: Random, 104 | backoffFactor: Double, 105 | ): Double { 106 | val minDelayMillis = minDelay.inWholeMilliseconds.toDouble() 107 | val maxDelayMillis = maxDelay.inWholeMilliseconds.toDouble() 108 | val baseDelay = (minDelayMillis * backoffFactor.pow(currentAttempts)) 109 | .coerceAtMost(maxDelayMillis) 110 | 111 | return if (jitterFactor == 0.0) { 112 | baseDelay 113 | } else { 114 | val jitterOffset = baseDelay * jitterFactor 115 | val minJitter = max(minDelayMillis, baseDelay - jitterOffset) 116 | val maxJitter = min(maxDelayMillis, baseDelay + jitterOffset) 117 | if (minJitter == maxJitter) { 118 | minJitter 119 | } else { 120 | random.nextDouble(minJitter, maxJitter) 121 | } 122 | } 123 | } -------------------------------------------------------------------------------- /src/commonMain/kotlin/SharedDataSource.kt: -------------------------------------------------------------------------------- 1 | import kotlinx.coroutines.CoroutineScope 2 | import kotlinx.coroutines.InternalCoroutinesApi 3 | import kotlinx.coroutines.flow.Flow 4 | import kotlinx.coroutines.flow.SharedFlow 5 | import kotlinx.coroutines.flow.SharingStarted 6 | import kotlinx.coroutines.flow.shareIn 7 | import kotlinx.coroutines.internal.SynchronizedObject 8 | import kotlinx.coroutines.internal.synchronized 9 | import kotlin.time.Duration 10 | 11 | class SharedDataSource( 12 | private val scope: CoroutineScope, 13 | private val replayExpiration: Duration = Duration.INFINITE, 14 | private val stopTimeout: Duration = Duration.ZERO, 15 | private val replay: Int = 0, 16 | private val builder: (K) -> Flow, 17 | ) { 18 | private val connections = mutableMapOf>() 19 | @OptIn(InternalCoroutinesApi::class) 20 | private val lock = object : SynchronizedObject() {} 21 | 22 | @OptIn(InternalCoroutinesApi::class) 23 | fun get(key: K): SharedFlow = synchronized(lock) { 24 | connections.getOrPut(key) { 25 | builder(key).shareIn( 26 | scope, 27 | started = SharingStarted.WhileSubscribed( 28 | replayExpirationMillis = replayExpiration.inWholeMilliseconds, 29 | stopTimeoutMillis = stopTimeout.inWholeMilliseconds, 30 | ), 31 | replay = replay, 32 | ) 33 | } 34 | } 35 | 36 | @OptIn(InternalCoroutinesApi::class) 37 | fun all(): List> = synchronized(lock) { 38 | connections.values.toList() 39 | } 40 | 41 | @OptIn(InternalCoroutinesApi::class) 42 | fun clear() = synchronized(lock) { 43 | connections.clear() 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/StateDataSource.kt: -------------------------------------------------------------------------------- 1 | import kotlinx.coroutines.CoroutineScope 2 | import kotlinx.coroutines.InternalCoroutinesApi 3 | import kotlinx.coroutines.flow.Flow 4 | import kotlinx.coroutines.flow.SharingStarted 5 | import kotlinx.coroutines.flow.StateFlow 6 | import kotlinx.coroutines.flow.stateIn 7 | import kotlinx.coroutines.internal.SynchronizedObject 8 | import kotlinx.coroutines.internal.synchronized 9 | import kotlin.time.Duration 10 | 11 | class StateDataSource( 12 | private val scope: CoroutineScope, 13 | private val initial: V, 14 | private val replayExpiration: Duration = Duration.INFINITE, 15 | private val stopTimeout: Duration = Duration.ZERO, 16 | private val builder: (K) -> Flow, 17 | ) { 18 | private val connections = mutableMapOf>() 19 | 20 | @OptIn(InternalCoroutinesApi::class) 21 | private val lock = object : SynchronizedObject() {} 22 | 23 | @OptIn(InternalCoroutinesApi::class) 24 | fun get(key: K): StateFlow = synchronized(lock) { 25 | connections.getOrPut(key) { 26 | builder(key).stateIn( 27 | scope = scope, 28 | initialValue = initial, 29 | started = SharingStarted.WhileSubscribed( 30 | replayExpirationMillis = replayExpiration.inWholeMilliseconds, 31 | stopTimeoutMillis = stopTimeout.inWholeMilliseconds, 32 | ), 33 | ) 34 | } 35 | } 36 | 37 | @OptIn(InternalCoroutinesApi::class) 38 | fun all(): List> = synchronized(lock) { 39 | connections.values.toList() 40 | } 41 | 42 | @OptIn(InternalCoroutinesApi::class) 43 | fun clear() = synchronized(lock) { 44 | connections.clear() 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/flowOf.kt: -------------------------------------------------------------------------------- 1 | package recipes 2 | 3 | import kotlinx.coroutines.flow.flow 4 | import kotlinx.coroutines.suspendCancellableCoroutine 5 | 6 | fun flowOf(producer: suspend () -> T) = flow { emit(producer()) } 7 | 8 | val neverFlow = flow { suspendCancellableCoroutine { } } 9 | 10 | fun infiniteFlowOf(producer: suspend () -> T) = flow { 11 | while (true) { 12 | emit(producer()) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/mapAsync.kt: -------------------------------------------------------------------------------- 1 | 2 | package recipes 3 | 4 | import kotlinx.coroutines.GlobalScope 5 | import kotlinx.coroutines.async 6 | import kotlinx.coroutines.awaitAll 7 | import kotlinx.coroutines.coroutineScope 8 | import kotlinx.coroutines.sync.Semaphore 9 | import kotlinx.coroutines.sync.withPermit 10 | 11 | suspend fun Iterable.mapAsync( 12 | transformation: suspend (T) -> R 13 | ): List = coroutineScope { 14 | this@mapAsync 15 | .map { async { transformation(it) } } 16 | .awaitAll() 17 | } 18 | 19 | suspend fun Iterable.mapAsync( 20 | concurrency: Int, 21 | transformation: suspend (T) -> R 22 | ): List = coroutineScope { 23 | val semaphore = Semaphore(concurrency) 24 | this@mapAsync 25 | .map { async { semaphore.withPermit { transformation(it) } } } 26 | .awaitAll() 27 | } 28 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/raceOf.kt: -------------------------------------------------------------------------------- 1 | package recipes 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.async 5 | import kotlinx.coroutines.cancelChildren 6 | import kotlinx.coroutines.coroutineScope 7 | import kotlinx.coroutines.job 8 | import kotlinx.coroutines.selects.select 9 | 10 | suspend fun raceOf( 11 | racer: suspend CoroutineScope.() -> T, 12 | vararg racers: suspend CoroutineScope.() -> T 13 | ): T = coroutineScope { 14 | select { 15 | (listOf(racer) + racers).forEach { racer -> 16 | async { racer() }.onAwait { 17 | coroutineContext.job.cancelChildren() 18 | it 19 | } 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /src/commonMain/kotlin/retryWhen.kt: -------------------------------------------------------------------------------- 1 | package recipes 2 | 3 | import kotlinx.coroutines.CancellationException 4 | 5 | inline fun retryWhen( 6 | predicate: (Throwable, retries: Int) -> Boolean, 7 | operation: () -> T 8 | ): T { 9 | var retries = 0 10 | var fromDownstream: Throwable? = null 11 | while (true) { 12 | try { 13 | return operation() 14 | } catch (e: Throwable) { 15 | if (fromDownstream != null) { 16 | e.addSuppressed(fromDownstream) 17 | } 18 | fromDownstream = e 19 | if (e is CancellationException || !predicate(e, retries++)) { 20 | throw e 21 | } 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/suspendLazy.kt: -------------------------------------------------------------------------------- 1 | package recipes 2 | 3 | import kotlinx.coroutines.sync.Mutex 4 | import kotlinx.coroutines.sync.withLock 5 | 6 | fun suspendLazy( 7 | initializer: suspend () -> T 8 | ): SuspendLazy { 9 | var innerInitializer: (suspend () -> T)? = initializer 10 | val mutex = Mutex() 11 | var holder: Any? = Any() 12 | 13 | return object : SuspendLazy { 14 | override val isInitialized: Boolean 15 | get() = innerInitializer == null 16 | 17 | @Suppress("UNCHECKED_CAST") 18 | override suspend fun invoke(): T = 19 | if (innerInitializer == null) holder as T 20 | else mutex.withLock { 21 | innerInitializer?.let { 22 | holder = it() 23 | innerInitializer = null 24 | } 25 | holder as T 26 | } 27 | } 28 | } 29 | 30 | interface SuspendLazy : suspend () -> T { 31 | val isInitialized: Boolean 32 | } -------------------------------------------------------------------------------- /src/commonTest/kotlin/MapAsyncConcurrencyTest.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalCoroutinesApi::class) 2 | 3 | import kotlinx.coroutines.* 4 | import kotlinx.coroutines.test.TestScope 5 | import kotlinx.coroutines.test.currentTime 6 | import kotlinx.coroutines.test.runTest 7 | import recipes.mapAsync 8 | import kotlin.coroutines.CoroutineContext 9 | import kotlin.test.Test 10 | import kotlin.test.assertEquals 11 | 12 | class MapAsyncConcurrencyTest { 13 | private val anyConcurrency = 3 14 | 15 | @Test 16 | fun should_behave_like_a_regular_map_for_a_list_and_a_set() = runTest { 17 | val list = ('a'..'z').toList() 18 | val charTransformation1 = { c: Char -> c.inc() } 19 | assertEquals(list.map(charTransformation1), list.mapAsync(concurrency = anyConcurrency, charTransformation1)) 20 | val charTransformation2 = { c: Char -> c.code } 21 | assertEquals(list.map(charTransformation2), list.mapAsync(concurrency = anyConcurrency, charTransformation2)) 22 | val charTransformation3 = { c: Char -> c.uppercaseChar() } 23 | assertEquals(list.map(charTransformation3), list.mapAsync(concurrency = anyConcurrency, charTransformation3)) 24 | 25 | val set = (1..10).toSet() 26 | val intTransformation1 = { i: Int -> i * i } 27 | assertEquals(set.map(intTransformation1), set.mapAsync(concurrency = anyConcurrency, intTransformation1)) 28 | val intTransformation2 = { i: Int -> "A$i" } 29 | assertEquals(set.map(intTransformation2), set.mapAsync(concurrency = anyConcurrency, intTransformation2)) 30 | val intTransformation3 = { i: Int -> i.toChar() } 31 | assertEquals(set.map(intTransformation3), set.mapAsync(concurrency = anyConcurrency, intTransformation3)) 32 | } 33 | 34 | @Test 35 | fun should_map_async_and_keep_elements_order() = runTest { 36 | val transforms = listOf( 37 | suspend { delay(3000); "A" }, 38 | suspend { delay(2000); "B" }, 39 | suspend { delay(4000); "C" }, 40 | suspend { delay(1000); "D" }, 41 | ) 42 | 43 | val res = transforms.mapAsync(concurrency = 4) { it() } 44 | assertEquals(listOf("A", "B", "C", "D"), res) 45 | assertEquals(4000, currentTime) 46 | } 47 | 48 | @Test 49 | fun should_limit_concurrency_for_single_delay() = runTest { 50 | val process: suspend (Int) -> Int = { i: Int -> 51 | delay(1000) 52 | i * i 53 | } 54 | 55 | List(1000) { it }.mapAsync(concurrency = 10, transformation = process) 56 | assertEquals(1000 * 1000 / 10, currentTime) 57 | } 58 | 59 | @Test 60 | fun should_limit_concurrency_for_different_delays() = testFor( 61 | 1 to 3000L + 2000L + 4000L + 1000L + 2000L, 62 | 2 to 6000L, 63 | 3 to 5000L, 64 | 4 to 4000L, 65 | 5 to 4000L, 66 | ) { concurrency, expectedTime -> 67 | val transforms = listOf( 68 | suspend { delay(3000); "A" }, 69 | suspend { delay(2000); "B" }, 70 | suspend { delay(4000); "C" }, 71 | suspend { delay(1000); "D" }, 72 | suspend { delay(2000); "E" }, 73 | ) 74 | 75 | val res = transforms.mapAsync(concurrency = concurrency) { it() } 76 | assertEquals(listOf("A", "B", "C", "D", "E"), res) 77 | assertEquals(expectedTime, currentTime) 78 | } 79 | 80 | @Test 81 | fun should_support_context_propagation() = runTest { 82 | var ctx: CoroutineContext? = null 83 | 84 | val name1 = CoroutineName("Name 1") 85 | withContext(name1) { 86 | listOf("A").mapAsync(concurrency = anyConcurrency) { 87 | ctx = currentCoroutineContext() 88 | it 89 | } 90 | assertEquals(name1, ctx?.get(CoroutineName)) 91 | } 92 | 93 | val name2 = CoroutineName("Some name 2") 94 | withContext(name2) { 95 | listOf("B").mapAsync(concurrency = anyConcurrency) { 96 | ctx = currentCoroutineContext() 97 | it 98 | } 99 | assertEquals(name2, ctx?.get(CoroutineName)) 100 | } 101 | } 102 | 103 | @Test 104 | fun should_support_cancellation() = runTest { 105 | var job: Job? = null 106 | 107 | val parentJob = launch { 108 | listOf("A").mapAsync(concurrency = anyConcurrency) { 109 | job = currentCoroutineContext().job 110 | delay(Long.MAX_VALUE) 111 | } 112 | } 113 | 114 | delay(1000) 115 | parentJob.cancel() 116 | assertEquals(true, job?.isCancelled) 117 | } 118 | } 119 | 120 | private fun testFor(vararg data: Pair, body: suspend TestScope.(T1, T2) -> Unit) { 121 | for ((input, expected) in data) { 122 | runTest { 123 | body(input, expected) 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/MapAsyncTest.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalCoroutinesApi::class) 2 | 3 | import kotlinx.coroutines.CoroutineName 4 | import kotlinx.coroutines.ExperimentalCoroutinesApi 5 | import kotlinx.coroutines.Job 6 | import kotlinx.coroutines.currentCoroutineContext 7 | import kotlinx.coroutines.delay 8 | import kotlinx.coroutines.job 9 | import kotlinx.coroutines.launch 10 | import kotlinx.coroutines.test.currentTime 11 | import kotlinx.coroutines.test.runTest 12 | import kotlinx.coroutines.withContext 13 | import recipes.mapAsync 14 | import kotlin.coroutines.CoroutineContext 15 | import kotlin.test.Test 16 | import kotlin.test.assertEquals 17 | 18 | class MapAsyncTest { 19 | @Test 20 | fun should_behave_like_a_regular_map_for_a_list_and_a_set() = runTest { 21 | val list = ('a'..'z').toList() 22 | val charTransformation1 = { c: Char -> c.inc() } 23 | assertEquals(list.map(charTransformation1), list.mapAsync(charTransformation1)) 24 | val charTransformation2 = { c: Char -> c.code } 25 | assertEquals(list.map(charTransformation2), list.mapAsync(charTransformation2)) 26 | val charTransformation3 = { c: Char -> c.uppercaseChar() } 27 | assertEquals(list.map(charTransformation3), list.mapAsync(charTransformation3)) 28 | 29 | val set = (1..10).toSet() 30 | val intTransformation1 = { i: Int -> i * i } 31 | assertEquals(set.map(intTransformation1), set.mapAsync(intTransformation1)) 32 | val intTransformation2 = { i: Int -> "A$i" } 33 | assertEquals(set.map(intTransformation2), set.mapAsync(intTransformation2)) 34 | val intTransformation3 = { i: Int -> i.toChar() } 35 | assertEquals(set.map(intTransformation3), set.mapAsync(intTransformation3)) 36 | } 37 | 38 | @Test 39 | fun should_map_async_and_keep_elements_order() = runTest { 40 | val transforms: List String> = listOf( 41 | { delay(3000); "A" }, 42 | { delay(2000); "B" }, 43 | { delay(4000); "C" }, 44 | { delay(1000); "D" }, 45 | ) 46 | 47 | val res = transforms.mapAsync { it() } 48 | assertEquals(listOf("A", "B", "C", "D"), res) 49 | assertEquals(4000, currentTime) 50 | } 51 | 52 | @Test 53 | fun should_support_context_propagation() = runTest { 54 | var ctx: CoroutineContext? = null 55 | 56 | val name1 = CoroutineName("Name 1") 57 | withContext(name1) { 58 | listOf("A").mapAsync { 59 | ctx = currentCoroutineContext() 60 | it 61 | } 62 | } 63 | assertEquals(name1, ctx?.get(CoroutineName)) 64 | 65 | val name2 = CoroutineName("Some name 2") 66 | withContext(name2) { 67 | listOf("B").mapAsync { 68 | ctx = currentCoroutineContext() 69 | it 70 | } 71 | } 72 | assertEquals(name2, ctx?.get(CoroutineName)) 73 | } 74 | 75 | @Test 76 | fun should_support_cancellation() = runTest { 77 | var job: Job? = null 78 | 79 | val parentJob = launch { 80 | listOf("A").mapAsync { 81 | job = currentCoroutineContext().job 82 | delay(Long.MAX_VALUE) 83 | } 84 | } 85 | 86 | delay(1000) 87 | parentJob.cancel() 88 | assertEquals(true, job?.isCancelled) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/RaceOfTest.kt: -------------------------------------------------------------------------------- 1 | import kotlinx.coroutines.CoroutineName 2 | import kotlinx.coroutines.CoroutineScope 3 | import kotlinx.coroutines.Job 4 | import kotlinx.coroutines.currentCoroutineContext 5 | import kotlinx.coroutines.delay 6 | import kotlinx.coroutines.job 7 | import kotlinx.coroutines.launch 8 | import kotlinx.coroutines.test.currentTime 9 | import kotlinx.coroutines.test.runTest 10 | import kotlinx.coroutines.withContext 11 | import recipes.raceOf 12 | import kotlin.coroutines.CoroutineContext 13 | import kotlin.test.Test 14 | import kotlin.test.assertEquals 15 | 16 | class RaceOfTest { 17 | 18 | @Test 19 | fun should_wait_for_the_fastest() = runTest { 20 | raceOf( 21 | { delay(3) }, 22 | { delay(1) }, 23 | { delay(2) }, 24 | ) 25 | assertEquals(1, currentTime) 26 | } 27 | 28 | @Test 29 | fun should_wait_for_the_fastest_for_big_number() = runTest { 30 | val racers = List Long>(1000) { i -> 31 | { 32 | val num = (i + 100).toLong() 33 | delay(num) 34 | num 35 | } 36 | }.shuffled().toMutableList() 37 | val result = raceOf(racers.removeFirst(), *racers.toTypedArray()) 38 | assertEquals(100, result) 39 | assertEquals(100, currentTime) 40 | } 41 | 42 | @Test 43 | fun should_respond_with_fastest() = runTest { 44 | val result = raceOf( 45 | { delay(3000); "C" }, 46 | { delay(1000); "A" }, 47 | { delay(2000); "B" }, 48 | ) 49 | assertEquals("A", result) 50 | assertEquals(1000, currentTime) 51 | } 52 | 53 | @Test 54 | fun should_cancel_slower() = runTest { 55 | var slowerJob: Job? = null 56 | val result = raceOf( 57 | { delay(1000); "A" }, 58 | { slowerJob = currentCoroutineContext().job; delay(2000); "B" }, 59 | ) 60 | assertEquals("A", result) 61 | assertEquals(1000, currentTime) 62 | assertEquals(true, slowerJob?.isCancelled) 63 | } 64 | 65 | @Test 66 | fun should_cancel_when_parent_cancelled() = runTest { 67 | var innerJob: Job? = null 68 | val job = launch { 69 | raceOf( 70 | { delay(1000) }, 71 | { innerJob = currentCoroutineContext().job; delay(2000) }, 72 | ) 73 | } 74 | delay(500) 75 | assertEquals(true, innerJob?.isActive) 76 | job.cancel() 77 | assertEquals(true, innerJob?.isCancelled) 78 | } 79 | 80 | @Test 81 | fun should_propagate_context() = runTest { 82 | var innerCtx: CoroutineContext? = null 83 | 84 | val coroutineName1 = CoroutineName("ABC") 85 | withContext(coroutineName1) { 86 | raceOf( 87 | { delay(1000) }, 88 | { innerCtx = currentCoroutineContext(); delay(2000) }, 89 | ) 90 | } 91 | delay(500) 92 | assertEquals(coroutineName1, innerCtx?.get(CoroutineName)) 93 | 94 | val coroutineName2 = CoroutineName("DEF") 95 | withContext(coroutineName2) { 96 | raceOf( 97 | { delay(1000) }, 98 | { innerCtx = currentCoroutineContext(); delay(2000) }, 99 | ) 100 | } 101 | delay(500) 102 | assertEquals(coroutineName2, innerCtx?.get(CoroutineName)) 103 | } 104 | } -------------------------------------------------------------------------------- /src/commonTest/kotlin/RetryBackoffFlowTest.kt: -------------------------------------------------------------------------------- 1 | import kotlinx.coroutines.ExperimentalCoroutinesApi 2 | import kotlinx.coroutines.flow.* 3 | import kotlinx.coroutines.test.TestScope 4 | import kotlinx.coroutines.test.currentTime 5 | 6 | import kotlinx.coroutines.test.runTest 7 | import recipes.retryBackoff 8 | import kotlin.random.Random 9 | 10 | import kotlin.test.Test 11 | 12 | import kotlin.test.assertEquals 13 | import kotlin.test.assertTrue 14 | import kotlin.time.Duration 15 | import kotlin.time.Duration.Companion.milliseconds 16 | 17 | import kotlin.time.Duration.Companion.seconds 18 | 19 | @OptIn(ExperimentalCoroutinesApi::class) 20 | class RetryBackoffFlowTest { 21 | 22 | private fun failingNTimesFlow(failingTimes: Int): Flow { 23 | var attempt = 0 24 | return flow { 25 | when (attempt++) { 26 | in 0 until failingTimes -> throw TestException("Error$attempt") 27 | else -> emit("ABC") 28 | } 29 | } 30 | } 31 | 32 | @Test 33 | fun should_retry_single_failing_call() = runTest { 34 | val error = object : Throwable() {} 35 | var first = true 36 | flow { 37 | if (first) { 38 | first = false 39 | throw error 40 | } else { 41 | emit("ABC") 42 | } 43 | }.testBackoffRetry( 44 | testScope = this, 45 | minDelay = 1.seconds, 46 | maxDelay = 5.seconds, 47 | maxAttempts = 11, 48 | jitterFactor = 0.0, 49 | expectResult = Result.success(listOf("ABC")), 50 | expectSuccessAfterRetry = listOf( 51 | SuccessAfterRetryCall(attempts=1, time=1000) 52 | ), 53 | expectRetriesExhaustedCalls = listOf(), 54 | expectBeforeRetryCalls = listOf( 55 | BeforeRetryCall(error, 0, 0, 0), 56 | ) 57 | ) 58 | } 59 | 60 | @Test 61 | fun should_retry_multiple_times_with_growing_backoff() = runTest { 62 | failingNTimesFlow(10).testBackoffRetry( 63 | testScope = this, 64 | minDelay = 1.seconds, 65 | maxAttempts = 11, 66 | jitterFactor = 0.0, 67 | expectResult = Result.success(listOf("ABC")), 68 | expectRetriesExhaustedCalls = listOf(), // Should not call retriesExhausted when maxAttempts is not reached 69 | expectSuccessAfterRetry = listOf(SuccessAfterRetryCall(attempts=10, time=512000 + 256000 + 128000 + 64000 + 32000 + 16000 + 8000 + 4000 + 2000 + 1000)), 70 | expectBeforeRetryCalls = listOf( 71 | BeforeRetryCall(TestException("Error1"), 0, 0, 0), 72 | BeforeRetryCall(TestException("Error2"), 1, 1, 1000), 73 | BeforeRetryCall(TestException("Error3"), 2, 2, 2000 + 1000), 74 | BeforeRetryCall(TestException("Error4"), 3, 3, 4000 + 2000 + 1000), 75 | BeforeRetryCall(TestException("Error5"), 4, 4, 8000 + 4000 + 2000 + 1000), 76 | BeforeRetryCall(TestException("Error6"), 5, 5, 16000 + 8000 + 4000 + 2000 + 1000), 77 | BeforeRetryCall(TestException("Error7"), 6, 6, 32000 + 16000 + 8000 + 4000 + 2000 + 1000), 78 | BeforeRetryCall(TestException("Error8"), 7, 7, 64000 + 32000 + 16000 + 8000 + 4000 + 2000 + 1000), 79 | BeforeRetryCall( 80 | TestException("Error9"), 81 | 8, 82 | 8, 83 | 128000 + 64000 + 32000 + 16000 + 8000 + 4000 + 2000 + 1000 84 | ), 85 | BeforeRetryCall( 86 | TestException("Error10"), 87 | 9, 88 | 9, 89 | 256000 + 128000 + 64000 + 32000 + 16000 + 8000 + 4000 + 2000 + 1000 90 | ), 91 | ) 92 | ) 93 | } 94 | 95 | @Test 96 | fun should_calculate_only_following_calls_for_max_attempts() = runTest { 97 | var attempt = 0 98 | flow { 99 | while (true) { 100 | when (attempt++ % 4) { 101 | 3 -> emit("Result$attempt") 102 | else -> throw TestException("Call$attempt") 103 | } 104 | } 105 | }.testBackoffRetry( 106 | testScope = this, 107 | elementsToExpect = 3, 108 | minDelay = 1.seconds, 109 | maxAttempts = 4, 110 | jitterFactor = 0.0, 111 | expectResult = Result.success(listOf("Result4", "Result8", "Result12")), 112 | expectRetriesExhaustedCalls = listOf(), 113 | expectSuccessAfterRetry = listOf( 114 | SuccessAfterRetryCall(attempts = 3, time = 4000 + 2000 + 1000), 115 | SuccessAfterRetryCall(attempts = 3, time = 4000 + 2000 + 1000 + 4000 + 2000 + 1000), 116 | SuccessAfterRetryCall( 117 | attempts = 3, 118 | time = 4000 + 2000 + 1000 + 4000 + 2000 + 1000 + 4000 + 2000 + 1000 119 | ), 120 | ), 121 | expectBeforeRetryCalls = listOf( 122 | BeforeRetryCall(TestException("Call1"), 0, 0, 0), 123 | BeforeRetryCall(TestException("Call2"), 1, 1, 1000), 124 | BeforeRetryCall(TestException("Call3"), 2, 2, 2000 + 1000), 125 | BeforeRetryCall(TestException("Call5"), 0, 3, 4000 + 2000 + 1000), 126 | BeforeRetryCall(TestException("Call6"), 1, 4, 1000 + 4000 + 2000 + 1000), 127 | BeforeRetryCall(TestException("Call7"), 2, 5, 2000 + 1000 + 4000 + 2000 + 1000), 128 | BeforeRetryCall(TestException("Call9"), 0, 6, 4000 + 2000 + 1000 + 4000 + 2000 + 1000), 129 | BeforeRetryCall(TestException("Call10"), 1, 7, 1000 + 4000 + 2000 + 1000 + 4000 + 2000 + 1000), 130 | BeforeRetryCall(TestException("Call11"), 2, 8, 2000 + 1000 + 4000 + 2000 + 1000 + 4000 + 2000 + 1000), 131 | ) 132 | ) 133 | } 134 | 135 | @Test 136 | fun should_calculate_all_calls_for_non_transient() = runTest { 137 | var attempt = 0 138 | flow { 139 | while (true) { 140 | when (attempt++ % 4) { 141 | 3 -> emit("Result$attempt") 142 | else -> throw TestException("Call$attempt") 143 | } 144 | } 145 | }.testBackoffRetry( 146 | testScope = this, 147 | elementsToExpect = 3, 148 | minDelay = 1.seconds, 149 | transient = false, 150 | jitterFactor = 0.0, 151 | expectResult = Result.success(listOf("Result4", "Result8", "Result12")), 152 | expectRetriesExhaustedCalls = listOf(), 153 | expectSuccessAfterRetry = listOf( 154 | SuccessAfterRetryCall(attempts = 3, time = 4000 + 2000 + 1000), 155 | SuccessAfterRetryCall(attempts = 6, time = 32000 + 16000 + 8000 + 4000 + 2000 + 1000), 156 | SuccessAfterRetryCall(attempts = 9, time = 256000 + 128000 + 64000 + 32000 + 16000 + 8000 + 4000 + 2000 + 1000) 157 | ), 158 | expectBeforeRetryCalls = listOf( 159 | BeforeRetryCall(TestException("Call1"), 0, 0, 0), 160 | BeforeRetryCall(TestException("Call2"), 1, 1, 1000), 161 | BeforeRetryCall(TestException("Call3"), 2, 2, 2000 + 1000), 162 | BeforeRetryCall(TestException("Call5"), 3, 3, 4000 + 2000 + 1000), 163 | BeforeRetryCall(TestException("Call6"), 4, 4, 8000 + 4000 + 2000 + 1000), 164 | BeforeRetryCall(TestException("Call7"), 5, 5, 16000 + 8000 + 4000 + 2000 + 1000), 165 | BeforeRetryCall(TestException("Call9"), 6, 6, 32000 + 16000 + 8000 + 4000 + 2000 + 1000), 166 | BeforeRetryCall(TestException("Call10"), 7, 7, 64000 + 32000 + 16000 + 8000 + 4000 + 2000 + 1000), 167 | BeforeRetryCall(TestException("Call11"), 8, 8, 128000 + 64000 + 32000 + 16000 + 8000 + 4000 + 2000 + 1000), 168 | ) 169 | ) 170 | } 171 | 172 | @Test 173 | fun should_use_backoff_factor() = runTest { 174 | failingNTimesFlow(10).testBackoffRetry( 175 | testScope = this, 176 | minDelay = 1.seconds, 177 | backoffFactor = 3.0, 178 | maxAttempts = 5, 179 | jitterFactor = 0.0, 180 | expectResult = Result.failure(TestException("Error6")), 181 | expectRetriesExhaustedCalls = listOf( 182 | RetryExhaustedCall(TestException("Error6"), 121000) 183 | ), 184 | expectSuccessAfterRetry = listOf(), 185 | expectBeforeRetryCalls = listOf( 186 | BeforeRetryCall(TestException("Error1"), 0, 0, 0), 187 | BeforeRetryCall(TestException("Error2"), 1, 1, 1000), 188 | BeforeRetryCall(TestException("Error3"), 2, 2, 3000 + 1000), 189 | BeforeRetryCall(TestException("Error4"), 3, 3, 9000 + 3000 + 1000), 190 | BeforeRetryCall(TestException("Error5"), 4, 4, 27000 + 9000 + 3000 + 1000), 191 | ) 192 | ) 193 | } 194 | 195 | @Test 196 | fun should_stop_backoff_growing_once_max_reached() = runTest { 197 | failingNTimesFlow(10).testBackoffRetry( 198 | testScope = this, 199 | minDelay = 1.seconds, 200 | maxDelay = 5.seconds, 201 | maxAttempts = 11, 202 | jitterFactor = 0.0, 203 | expectResult = Result.success(listOf("ABC")), 204 | expectRetriesExhaustedCalls = listOf(), // Should not call retriesExhausted when maxAttempts is not reached 205 | expectSuccessAfterRetry = listOf( 206 | SuccessAfterRetryCall(10, 7 * 5000 + 4000 + 2000 + 1000) 207 | ), 208 | expectBeforeRetryCalls = listOf( 209 | BeforeRetryCall(TestException("Error1"), 0, 0, 0), 210 | BeforeRetryCall(TestException("Error2"), 1, 1, 1000), 211 | BeforeRetryCall(TestException("Error3"), 2, 2, 2000 + 1000), 212 | BeforeRetryCall(TestException("Error4"), 3, 3, 4000 + 2000 + 1000), 213 | // Delay is random due to jitter, but once it reached maxDelay it should be around this value 214 | BeforeRetryCall(TestException("Error5"), 4, 4, 5000 + 4000 + 2000 + 1000), 215 | BeforeRetryCall(TestException("Error6"), 5, 5, 2 * 5000 + 4000 + 2000 + 1000), 216 | BeforeRetryCall(TestException("Error7"), 6, 6, 3 * 5000 + 4000 + 2000 + 1000), 217 | BeforeRetryCall(TestException("Error8"), 7, 7, 4 * 5000 + 4000 + 2000 + 1000), 218 | BeforeRetryCall(TestException("Error9"), 8, 8, 5 * 5000 + 4000 + 2000 + 1000), 219 | BeforeRetryCall(TestException("Error10"), 9, 9, 6 * 5000 + 4000 + 2000 + 1000), 220 | ) 221 | ) 222 | } 223 | 224 | @Test 225 | fun should_never_have_delay_smaller_than_min_and_bigger_than_max() = runTest { 226 | var delayTimes = listOf() 227 | failingNTimesFlow(100).retryBackoff( 228 | minDelay = 2.seconds + 750.milliseconds, 229 | maxDelay = 4.seconds + 500.milliseconds, 230 | maxAttempts = 100, 231 | jitterFactor = 1.0, 232 | beforeRetry = { _, _, attempt -> 233 | if (attempt != 0) { // The first time millis is not a delay 234 | val delay = this.currentTime - delayTimes.sum() 235 | delayTimes += delay 236 | } 237 | } 238 | ).catch { /* no-op */ } 239 | .collect() 240 | 241 | assertEquals(99, delayTimes.size) 242 | assertTrue("All delay times should be between 2750 and 4500 ms, those outside the limit are ${delayTimes.filter { it !in 2750..4500 }}") { 243 | delayTimes.all { it in 2750..4500 } 244 | } 245 | } 246 | 247 | @Test 248 | fun should_retry_until_max_attempts_reached() = runTest { 249 | failingNTimesFlow(10).testBackoffRetry( 250 | testScope = this, 251 | minDelay = 2.seconds, 252 | maxDelay = 10.seconds, 253 | maxAttempts = 4, 254 | jitterFactor = 0.0, 255 | expectResult = Result.failure(TestException("Error5")), 256 | expectSuccessAfterRetry = listOf(), 257 | expectRetriesExhaustedCalls = listOf( 258 | RetryExhaustedCall(TestException("Error5"), 24000), 259 | ), 260 | expectBeforeRetryCalls = listOf( 261 | BeforeRetryCall(TestException("Error1"), 0, 0, 0), 262 | BeforeRetryCall(TestException("Error2"), 1, 1, 2000), 263 | BeforeRetryCall(TestException("Error3"), 2, 2, 6000), 264 | BeforeRetryCall(TestException("Error4"), 3, 3, 14000), 265 | ) 266 | ) 267 | } 268 | 269 | @Test 270 | fun should_add_random_jitter() = runTest { 271 | failingNTimesFlow(10).testBackoffRetry( 272 | testScope = this, 273 | minDelay = 1.seconds, 274 | maxDelay = 5.seconds, 275 | maxAttempts = 10, 276 | jitterFactor = 0.5, 277 | random = Random(12345), 278 | expectResult = Result.success(listOf("ABC")), 279 | expectRetriesExhaustedCalls = listOf(), 280 | expectSuccessAfterRetry = listOf(SuccessAfterRetryCall(attempts=10, time=36192)), 281 | expectBeforeRetryCalls = listOf( 282 | BeforeRetryCall(TestException("Error1"), 0, 0, 0), 283 | BeforeRetryCall(TestException("Error2"), 1, 1, 1385), 284 | BeforeRetryCall(TestException("Error3"), 2, 2, 4056), 285 | BeforeRetryCall(TestException("Error4"), 3, 3, 8020), 286 | BeforeRetryCall(TestException("Error5"), 4, 4, 12565), 287 | BeforeRetryCall(TestException("Error6"), 5, 5, 16693), 288 | BeforeRetryCall(TestException("Error7"), 6, 6, 20180), 289 | BeforeRetryCall(TestException("Error8"), 7, 7, 24589), 290 | BeforeRetryCall(TestException("Error9"), 8, 8, 28439), 291 | BeforeRetryCall(TestException("Error10"), 9, 9, 33133), 292 | ) 293 | ) 294 | } 295 | 296 | suspend fun Flow.testBackoffRetry( 297 | testScope: TestScope, 298 | minDelay: Duration, 299 | maxDelay: Duration = Duration.INFINITE, 300 | backoffFactor: Double = 2.0, 301 | maxAttempts: Int = Int.MAX_VALUE, 302 | transient: Boolean = true, 303 | jitterFactor: Double = 0.1, 304 | random: Random = Random, 305 | expectResult: Result>, 306 | expectSuccessAfterRetry: List, 307 | expectRetriesExhaustedCalls: List, 308 | expectBeforeRetryCalls: List, 309 | elementsToExpect: Int = 1, 310 | ) { 311 | var beforeRetryCalls = listOf() 312 | var retriesExhaustedCalls = listOf() 313 | var successAfterRetryCalls = listOf() 314 | val result = runCatching { 315 | this.retryBackoff( 316 | minDelay = minDelay, 317 | maxDelay = maxDelay, 318 | maxAttempts = maxAttempts, 319 | backoffFactor = backoffFactor, 320 | transient = transient, 321 | jitterFactor = jitterFactor, 322 | random = random, 323 | successAfterRetry = { attempts -> 324 | successAfterRetryCalls += SuccessAfterRetryCall(attempts, testScope.currentTime) 325 | }, 326 | beforeRetry = { cause, currentAttempts, totalAttempts -> 327 | beforeRetryCalls += BeforeRetryCall(cause, currentAttempts, totalAttempts, testScope.currentTime) 328 | }, 329 | retriesExhausted = { 330 | retriesExhaustedCalls += RetryExhaustedCall(it, testScope.currentTime) 331 | } 332 | ).take(elementsToExpect) 333 | .toList() 334 | } 335 | assertEquals(expectResult, result) 336 | assertEquals(expectRetriesExhaustedCalls, retriesExhaustedCalls) 337 | assertEquals(expectBeforeRetryCalls, beforeRetryCalls) 338 | assertEquals(expectSuccessAfterRetry, successAfterRetryCalls) 339 | } 340 | 341 | data class BeforeRetryCall(val cause: Throwable, val currentAttempts: Int, val totalAttempts: Int, val time: Long) 342 | data class RetryExhaustedCall(val cause: Throwable, val time: Long) 343 | data class SuccessAfterRetryCall(val attempts: Int, val time: Long) 344 | 345 | data class TestException(override val message: String) : Exception(message) 346 | } -------------------------------------------------------------------------------- /src/commonTest/kotlin/RetryWhenTest.kt: -------------------------------------------------------------------------------- 1 | import kotlinx.coroutines.cancelAndJoin 2 | import kotlinx.coroutines.delay 3 | import kotlinx.coroutines.launch 4 | import kotlinx.coroutines.test.runTest 5 | import kotlinx.coroutines.withTimeout 6 | import recipes.retryWhen 7 | import kotlin.test.Test 8 | import kotlin.test.assertEquals 9 | import kotlin.test.assertIs 10 | 11 | class RetryWhenTest { 12 | 13 | @Test 14 | fun should_retry_n_times() { 15 | var startedTimes = 0 16 | 17 | runCatching { 18 | retryWhen(predicate = { _, retries -> retries < 3 }) { 19 | startedTimes++ 20 | throw MyException 21 | } 22 | } 23 | 24 | assertEquals(4, startedTimes) 25 | } 26 | 27 | @Test 28 | fun should_retry_certain_exceptions() { 29 | val exceptions = mutableListOf( 30 | ExceptionToRetry1, 31 | ExceptionToRetry2, 32 | ExceptionToRetry3, 33 | MyException, 34 | ExceptionToRetry1, 35 | ) 36 | 37 | val result = runCatching { 38 | retryWhen(predicate = { e, _ -> e is ExceptionToRetry }) { 39 | throw exceptions.removeFirst() 40 | } 41 | } 42 | 43 | assertEquals(1, exceptions.size) 44 | assertEquals(MyException, result.exceptionOrNull()) 45 | } 46 | 47 | @Test 48 | fun should_wrap_exceptions() { 49 | val exceptions = mutableListOf( 50 | ExceptionToRetry1, 51 | ExceptionToRetry2, 52 | ExceptionToRetry3, 53 | MyException, 54 | ) 55 | 56 | val result = runCatching { 57 | retryWhen(predicate = { e, _ -> e is ExceptionToRetry }) { 58 | throw exceptions.removeFirst() 59 | } 60 | } 61 | 62 | val e1 = result.exceptionOrNull() 63 | assertIs(e1) 64 | val e2 = e1.suppressedExceptions.first() 65 | assertIs(e2) 66 | val e3 = e2.suppressedExceptions.first() 67 | assertIs(e3) 68 | val e4 = e3.suppressedExceptions.first() 69 | assertIs(e4) 70 | } 71 | 72 | @Test 73 | fun should_not_retry_cancellation() = runTest { 74 | withTimeout(100_000) { 75 | val job = launch { 76 | retryWhen(predicate = { _, _ -> true }) { 77 | delay(1000) 78 | } 79 | } 80 | delay(100) 81 | job.cancelAndJoin() 82 | } 83 | } 84 | 85 | @Test 86 | fun should_predicate_be_executed_after_fail() { 87 | val eventsSequence = mutableListOf() 88 | runCatching { 89 | eventsSequence += Started 90 | retryWhen(predicate = { _, retries -> 91 | eventsSequence += RetryPredicateCalled 92 | retries < 3 93 | }) { 94 | eventsSequence += BodyExecutedCalled 95 | throw MyException 96 | } 97 | eventsSequence += Ended 98 | } 99 | 100 | val expectedSequence = listOf( 101 | Started, 102 | BodyExecutedCalled, 103 | RetryPredicateCalled, 104 | BodyExecutedCalled, 105 | RetryPredicateCalled, 106 | BodyExecutedCalled, 107 | RetryPredicateCalled, 108 | BodyExecutedCalled, 109 | ) 110 | assertEquals(expectedSequence, expectedSequence) 111 | } 112 | 113 | abstract class Event 114 | object Started: Event() 115 | object Ended: Event() 116 | object RetryPredicateCalled: Event() 117 | object BodyExecutedCalled: Event() 118 | 119 | private object MyException : Exception("Some message") 120 | 121 | open class ExceptionToRetry : Exception() 122 | object ExceptionToRetry1 : ExceptionToRetry() 123 | object ExceptionToRetry2 : ExceptionToRetry() 124 | object ExceptionToRetry3 : ExceptionToRetry() 125 | } -------------------------------------------------------------------------------- /src/commonTest/kotlin/SharedDataSourceTest.kt: -------------------------------------------------------------------------------- 1 | import kotlinx.coroutines.ExperimentalCoroutinesApi 2 | import kotlinx.coroutines.cancelAndJoin 3 | import kotlinx.coroutines.delay 4 | import kotlinx.coroutines.flow.Flow 5 | import kotlinx.coroutines.flow.asFlow 6 | import kotlinx.coroutines.flow.collect 7 | import kotlinx.coroutines.flow.flowOf 8 | import kotlinx.coroutines.flow.launchIn 9 | import kotlinx.coroutines.flow.map 10 | import kotlinx.coroutines.flow.onCompletion 11 | import kotlinx.coroutines.flow.onEach 12 | import kotlinx.coroutines.flow.onStart 13 | import kotlinx.coroutines.flow.take 14 | import kotlinx.coroutines.flow.toList 15 | import kotlinx.coroutines.launch 16 | import kotlinx.coroutines.test.TestScope 17 | import kotlinx.coroutines.test.currentTime 18 | import kotlinx.coroutines.test.runTest 19 | import kotlin.test.Test 20 | import kotlin.test.assertEquals 21 | import kotlin.test.assertNull 22 | import kotlin.time.Duration.Companion.milliseconds 23 | 24 | class SharedDataSourceTest { 25 | 26 | private val infiniteEverySecondProducerFlow = generateSequence(0) { it + 1 } 27 | .asFlow() 28 | .onEach { delay(1000) } 29 | 30 | @Test 31 | fun should_use_builder_to_create_a_flow() = runTest { 32 | val pool = SharedDataSource(scope = backgroundScope) { key: String -> 33 | flowOf("1$key", "2$key", "3$key") 34 | .onEach { delay(1000) } 35 | } 36 | 37 | val flow: Flow = pool.get("ABC") 38 | 39 | assertFirstFlowElements( 40 | flow, 41 | 1000L to "1ABC", 42 | 2000L to "2ABC", 43 | 3000L to "3ABC", 44 | ) 45 | } 46 | 47 | @Test 48 | fun should_reuse_the_same_flow_for_the_same_key() = runTest { 49 | var createdFlows = 0 50 | val pool = SharedDataSource(scope = backgroundScope) { _: String -> 51 | createdFlows++ 52 | infiniteEverySecondProducerFlow 53 | } 54 | 55 | pool.get("A").launchIn(backgroundScope) 56 | pool.get("A").launchIn(backgroundScope) 57 | pool.get("B").launchIn(backgroundScope) 58 | pool.get("A").launchIn(backgroundScope) 59 | pool.get("B").launchIn(backgroundScope) 60 | pool.get("C").launchIn(backgroundScope) 61 | 62 | delay(4000) 63 | 64 | assertEquals(3, createdFlows) 65 | } 66 | 67 | @Test 68 | fun should_close_connection_when_there_are_no_active_listeners() = runTest { 69 | var openConnections = 0 70 | val pool = SharedDataSource(scope = backgroundScope) { _: String -> 71 | infiniteEverySecondProducerFlow 72 | .onStart { openConnections++ } 73 | .onCompletion { openConnections-- } 74 | } 75 | 76 | val listenerA1 = pool.get("A").launchIn(backgroundScope) 77 | val listenerA2 = pool.get("A").launchIn(backgroundScope) 78 | val listenerB1 = pool.get("B").launchIn(backgroundScope) 79 | val listenerB2 = pool.get("B").launchIn(backgroundScope) 80 | val listenerC1 = pool.get("C").launchIn(backgroundScope) 81 | 82 | delay(3000) 83 | 84 | assertEquals(3, openConnections) 85 | 86 | listenerB2.cancel() 87 | listenerC1.cancel() 88 | delay(1) 89 | 90 | assertEquals(2, openConnections) 91 | 92 | listenerA2.cancel() 93 | listenerB1.cancel() // The last cancelled here 94 | delay(1) 95 | 96 | assertEquals(1, openConnections) 97 | 98 | listenerA1.cancel() 99 | delay(1) 100 | 101 | assertEquals(0, openConnections) 102 | } 103 | 104 | @Test 105 | fun should_reply_last_value() = runTest { 106 | val pool = SharedDataSource( 107 | scope = backgroundScope, 108 | replayExpiration = 1000.milliseconds, 109 | replay = 1 110 | ) { _: String -> 111 | infiniteEverySecondProducerFlow 112 | } 113 | 114 | pool.get("A").launchIn(backgroundScope) 115 | 116 | delay(10500) 117 | 118 | var valueProduced: Int? = null 119 | pool.get("A") 120 | .onEach { valueProduced = it } 121 | .launchIn(backgroundScope) 122 | 123 | delay(1) 124 | assertEquals(9, valueProduced) 125 | } 126 | 127 | @Test 128 | fun should_keep_reply_for_specified_time() = runTest { 129 | val replayExpirationMillis = 5000.milliseconds 130 | val pool = SharedDataSource( 131 | scope = backgroundScope, 132 | replayExpiration = replayExpirationMillis, 133 | replay = 1 134 | ) { _: String -> 135 | infiniteEverySecondProducerFlow 136 | } 137 | 138 | val job = launch { 139 | pool.get("A").collect() 140 | } 141 | 142 | delay(10500) 143 | 144 | job.cancel() 145 | delay(replayExpirationMillis) 146 | 147 | var valueProduced: Int? = null 148 | pool.get("A") 149 | .onEach { valueProduced = it } 150 | .launchIn(backgroundScope) 151 | 152 | delay(1) 153 | assertEquals(9, valueProduced) 154 | } 155 | 156 | @Test 157 | fun should_not_keep_reply_for_longer_than_specified_time() = runTest { 158 | val replayExpiration = 5000.milliseconds 159 | val pool = SharedDataSource( 160 | scope = backgroundScope, 161 | replayExpiration = replayExpiration, 162 | ) { _: String -> 163 | infiniteEverySecondProducerFlow 164 | } 165 | 166 | val job = launch { 167 | pool.get("A").collect() 168 | } 169 | 170 | delay(10500) 171 | 172 | job.cancel() 173 | delay(replayExpiration + 1.milliseconds) 174 | 175 | var valueProduced: Int? = null 176 | pool.get("A") 177 | .onEach { valueProduced = it } 178 | .launchIn(backgroundScope) 179 | 180 | delay(1) 181 | assertNull(valueProduced) 182 | } 183 | 184 | @Test 185 | fun should_keep_lifecycle_order() = runTest { 186 | var eventsTrack = listOf() 187 | val pool2 = SharedDataSource(backgroundScope) { key: String -> 188 | infiniteEverySecondProducerFlow 189 | .onStart { eventsTrack += "pool2 inner flow started for $key" } 190 | .onCompletion { eventsTrack += "pool2 inner flow completed for $key" } 191 | } 192 | 193 | val pool1 = SharedDataSource(backgroundScope) { key: String -> 194 | pool2.get("A") 195 | .onStart { eventsTrack += "pool1 inner flow started for $key" } 196 | .onCompletion { eventsTrack += "pool1 inner flow completed for $key" } 197 | } 198 | 199 | val job = pool1.get("A") 200 | .onStart { eventsTrack += "outer flow started" } 201 | .onCompletion { eventsTrack += "outer flow completed" } 202 | .launchIn(backgroundScope) 203 | 204 | delay(1) 205 | assertEquals(listOf( 206 | "outer flow started", 207 | "pool1 inner flow started for A", 208 | "pool2 inner flow started for A", 209 | ), eventsTrack) 210 | eventsTrack = emptyList() 211 | job.cancelAndJoin() 212 | delay(1) 213 | assertEquals(listOf( 214 | "outer flow completed", 215 | "pool1 inner flow completed for A", 216 | "pool2 inner flow completed for A", 217 | ), eventsTrack) 218 | } 219 | 220 | @OptIn(ExperimentalCoroutinesApi::class) 221 | suspend fun TestScope.assertFirstFlowElements(flow: Flow, vararg elements: Pair) { 222 | assertEquals( 223 | elements.toList(), 224 | flow.map { Pair(currentTime, it) }.take(elements.size).toList() 225 | ) 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/SuspendLazyTest.kt: -------------------------------------------------------------------------------- 1 | import kotlinx.coroutines.* 2 | import kotlinx.coroutines.test.advanceTimeBy 3 | import kotlinx.coroutines.test.currentTime 4 | import kotlinx.coroutines.test.runCurrent 5 | import kotlinx.coroutines.test.runTest 6 | import recipes.suspendLazy 7 | import kotlin.coroutines.CoroutineContext 8 | import kotlin.test.Test 9 | import kotlin.test.assertEquals 10 | import kotlin.test.assertTrue 11 | 12 | class SuspendLazyTest { 13 | 14 | @Test 15 | fun should_produce_value() = runTest { 16 | val lazyValue = suspendLazy { delay(1000); 123 } 17 | assertEquals(123, lazyValue()) 18 | assertEquals(1000, currentTime) 19 | } 20 | 21 | @Test 22 | fun should_not_recalculate_value() = runTest { 23 | var next = 1 24 | val lazyValue = suspendLazy { delay(1000); next++ } 25 | assertEquals(1, lazyValue()) 26 | assertEquals(1, lazyValue()) 27 | assertEquals(1, lazyValue()) 28 | assertEquals(1, lazyValue()) 29 | assertEquals(1000, currentTime) 30 | } 31 | 32 | @Test 33 | fun should_try_again_when_failure_during_value_initialization() = runTest { 34 | var next = 0 35 | val lazyValue = suspendLazy { 36 | val v = next++ 37 | if (v < 2) throw Error() 38 | v 39 | } 40 | assertTrue(runCatching { lazyValue() }.isFailure) 41 | assertTrue(runCatching { lazyValue() }.isFailure) 42 | assertEquals(2, lazyValue()) 43 | assertEquals(2, lazyValue()) 44 | assertEquals(2, lazyValue()) 45 | } 46 | 47 | @Test 48 | fun should_use_context_of_the_first_caller() = runTest { 49 | var ctx: CoroutineContext? = null 50 | val lazyValue = suspendLazy { 51 | ctx = currentCoroutineContext() 52 | 123 53 | } 54 | val name1 = CoroutineName("ABC") 55 | withContext(name1) { 56 | lazyValue() 57 | } 58 | assertEquals(name1, ctx?.get(CoroutineName)) 59 | val name2 = CoroutineName("DEF") 60 | withContext(name2) { 61 | lazyValue() 62 | } 63 | assertEquals(name1, ctx?.get(CoroutineName)) 64 | } 65 | 66 | @Test 67 | fun should_set_is_initialized() = runTest { 68 | val lazyValue = suspendLazy { delay(1000); 123 } 69 | 70 | assertEquals(false, lazyValue.isInitialized) 71 | launch { lazyValue() } 72 | assertEquals(false, lazyValue.isInitialized) 73 | advanceTimeBy(1000) 74 | assertEquals(false, lazyValue.isInitialized) 75 | runCurrent() 76 | assertEquals(true, lazyValue.isInitialized) 77 | } 78 | } --------------------------------------------------------------------------------