All the scopes you interact with are SyncScope subclasses and is the main reason you can use nested DSL expressions.
This ensures consistency all DSL expressions since they define the same attributes as the scopes.
job(name = ..., input = ..., timeout = ..., delay = ..., delay = ...) { ... }
async(name = ..., input = ..., timeout = ..., delay = ..., delay = ...) { ... }
asyncJob(name = ..., input = ..., timeout = ..., delay = ..., delay = ...) { ... }
retry(name = ..., input = ..., timeout = ..., delay = ..., delay = ...) { ... }
asyncMap(name = ..., input = ..., timeout = ..., delay = ..., delay = ...) { ... }
asyncFilter(name = ..., input = ..., timeout = ..., delay = ..., delay = ...) { ... }
asyncAll(name = ..., input = ..., timeout = ..., delay = ..., delay = ...) { ... }
asyncNone(name = ..., input = ..., timeout = ..., delay = ..., delay = ...) { ... }
asyncAny(name = ..., input = ..., timeout = ..., delay = ..., delay = ...) { ... }
asyncContainsAll(item = ..., name = ..., input = ..., timeout = ..., delay = ..., delay = ...)
Note that all these attributes have default values - you only need to specify them when you need them.
The trailing lambdas receive an instance of the corresponding scope :
TurboScope.execute<Unit> {
// "this" is an instance of TurboScope
job {
// "this" is an instance of JobScope
}
asyncJob {
// "this" is an instance of AsyncJobScope
}
async {
// "this" is an instance of AsyncRawScope
}
async<Unit>(
build = {
// "this" is an instance of AsyncReturnScope
}
) { ok, r ->
// "this" is an instance of AsyncResultScope
}
async(job1 = ..., job2 = ..., ... , job10 = ...) { ok, r1, r2, ..., r10 ->
// "this" is an instance of AsyncResultScope
}
retry {
// "this" is an instance of RetryScope
}
}
As you expect, these lambdas can include any Kotlin expressions:
data class SomeData(val flag: Boolean)
TurboScope.execute<Boolean> {
job(input = job<List<SomeData>> { /* returns a List<SomeData> */ }) {
// Using Kotlin's collection extension function
input.partition { it.flag }.let { (fTrue, fFalse) ->
job(input = fTrue) {
// Calculate and return a BigDecimal
:
}.add(
job(input = fFalse) {
// Calculate and return a BigDecimal
:
}
)
}
} > BigDecimal.valueOf(1_000)
}
Tip: Always use expression job {...} to group related expressions and/or statements. In this way, your code is modular and it will be much easier to move around if required - or even, to convert into an AsyncJob.
Writing asynchronous code is complex. Even more when you consider handling errors across multiple threads and coroutines.
In most frameworks / libraries, asynchronous code is executed through a "job", like kotlinx.coroutines.Job⇗.
Usually, an application starts execution of multiple jobs from synchronous expressions, awaits the results, and then continues with additional expressions and statements. All the main execution is sequential and almost "easy" to understand.
But for the asynchronous jobs themselves, some challenges should be addressed:
How to create and start any number of parallel jobs?
What if one or more parallel jobs fail while others succeed?
How should the "caller" await for results?
How to handle errors thrown by one or more jobs?
io.turbodsl includes multiple async expressions to register AsyncJobs depending on what you need to accomplish:
How many AsyncJobs do you need?
Is the number of AsyncJobs always the same or changes depending on certain conditions?
Are the return types always the same for each AsyncJob?
Note that each AsyncJob is actually represented as an AsyncJobScope under the hood.
async {...} → exposes the AsyncRawScope where you can add any number of AsyncJobs, returning a list of AsyncResult items. This is the most flexible and low-level mechanism you can use.
TurboScope.execute {
async {
// "this" is an instance of AsyncRawScope
asyncJob<Int>{...}
asyncJob<Foobar>{...}
repeat(job { /* returns an Int between 0 and 100 */ } ) {
asyncJob<Double>{...}
}
}.let { r ->
// r is a List<AsyncResult<*>> - size could vary between 2 and 102 items
}
}
Or, a more traditional style by using variable results:
TurboScope.execute {
// `results` is a List<AsyncResult<*>> - size could vary between 2 and 102 items
val results = async(maxJobs = -102) { // up to 102 asyncJobs
// "this" is an instance of AsyncRawScope
if (<some-condition>) {
// Warning! results structure is different depending on <some-condition>
asyncJob<Int>{...}
asyncJob<Foobar>{...}
}
repeat(job { /* returns an Int between 0 and 100 */ } ) {
asyncJob<Double>{...}
}
}
}
See Asynchronous Results⇗ on how to process results.
async(build = {...}) { ok, r -> ...} → exposes the AsyncReturnScope through the build argument and results are processed using a trailing-lambda receiving an AsyncResultScope, which exposes AsynResult.success() and AsynResult.failure() extension-functions.
TurboScope.execute {
async(
maxJobs = -102, // up to 102 asyncJobs
build = {
// "this" is an instance of AsyncReturnScope
asyncJob<Int> { ... }
asyncJob<Whatever> { ... }
repeat(job { /* returns 100 */ }) {
asyncJob<Double> { ... }
}
}
) { ok, r ->
// "this" is an instance of AsyncResultScope
// `r` is a List<AsyncResult<*>> and its size == 102 items
}
}
These two different async functions allows to execute any number of AsyncJobs in parallel - each one running on its own coroutine. But you need to be diligent when analyzing and casting the results to avoid any runtime exceptions.
See Asynchronous Results⇗ on how to process results.
Note that parameter maxJobs can be specified to impose a limit:
maxJobs = 0 → No limits
maxJobs < 0 → Up to |maxJobs|
maxJobs > 0 → Exacttly maxJobs → only use this when the number of jobs is known and always the same - see next section
Also, you can specify failOnMaxJobs to ignore errors when limit is reached:
failOnMaxJobs=true → calling asyncJob when the limit is reached will throw a ScopeImplementException.
failOnMaxJobs=false → calling asyncJob when the limit is reached has no effect - no AsyncJob is added.
async(job1=..., job2=..., ..., job10=...) { ok, r1, r2,..., r10 -> } → allows to specify between 2 and 10 parallel jobs.
WARNING: These async functions do not allow you to specify an input for the async expression.
Return type for each job<n> argument is always the same.
Note that you can also use the previous async functions and specify a positive maxJobs. This also allows each job to return a different datatype depending on some conditions.
Each job must be specified using asyncJob expression and could define its own input. Otherwise, the input will be the same as the enclosing SyncScope.
Trailing lambda is an AsyncResultScope that receives all results as arguments.
An AsyncReturnScope is created in the background to add the AsyncJobScopes and execute them:
TurboScope.execute<Summary> {
async(
job1 = asyncJob<Employee>{ ... },
job2 = asyncJob<List<Invoice>>{ ... },
// Note that depending on `condition` the asyncJob implementation
// may be different, but the return-type is always the same.
job3 = if (<some-condition>) asyncJob<List<Item>>{...} else asyncJob<List<Item>>{...},
// Similar to job3, but in this case, the condition is within the implementation.
// There may be situations that you may want to use the "job3" approach (e.g. different timeouts)
job4 = asyncJob<Registration, List<Item>>(input = ...){
if (input) { ... } else { ... }
},
) { ok, r1, r2, r3, r4 ->
// "this" is an instance of AsyncResultScope
:
}
}
See Asynchronous Results⇗ on how to process results.
Identify if the number of jobs is static.
Identify if the return datatype for each job is always the same and between 2 and 10.
If (1) and (2) are true, then use async with job<N> arguments.
Otherwise, then use any of the other async expressions.
This scope simplifies how asynchronous results are evaluated by analyzing each item within the list AsyncReturnScope returns.
Always exposes a Boolean indicating if all results are successes
Exposes additional extension functions to extract actual values and errors from each result
Its input is the same as the one from the related AsyncReturnScope
See Asynchronous Results⇗ for more details.