Skip to content

Commit 7d7e0ff

Browse files
committed
Release 0.0.22
1 parent a38f975 commit 7d7e0ff

13 files changed

+308
-53
lines changed

README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Safe direct-style concurrency and resiliency for Scala on the JVM. Requires JDK
99
[sbt](https://www.scala-sbt.org) dependency:
1010

1111
```scala
12-
"com.softwaremill.ox" %% "core" % "0.0.21"
12+
"com.softwaremill.ox" %% "core" % "0.0.22"
1313
```
1414

1515
Documentation is available at [https://ox.softwaremill.com](https://ox.softwaremill.com).
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# 4. Channels: safe/unsafe Operations
2+
3+
Date: 2024-02-28
4+
5+
## Context
6+
7+
Channel operations such as `send`, `receive`, `select`, `close` etc. might fail because a channel is closed. How should
8+
this be signalled to the user?
9+
10+
## Decision
11+
12+
We decided to have two variants of the methods:
13+
14+
* default: `send`, `receive` etc., which throw an exception, when the channel is closed
15+
* safe: `sendSafe`, `receiveSafe` etc., which return a `ChannelClosed` value, when the channel is closed
16+
17+
The "safe" variants are more performant: no stack trace is created, when the channel is closed. They are used by all
18+
channel combinators (such as `map`, `filter` etc.), to detect and propagate the errors downstream.
19+
20+
### Why not `Either` or `Try`?
21+
22+
To avoid allocations on each operation (e.g. receive). Channels might be on the "hot path" and they might be important
23+
for performance. Union types provide a nice alternative here.
24+
25+
Even with `Either`, though, if e.g. `send` had a signature `Either[ChannelClosed, Unit]`, discarding the result would
26+
at most be a warning (not in all cases), so potentially an error might go unnoticed.
27+
28+
### Why is the default to throw?
29+
30+
Let's consider `send`. If the default would be `send(t: T): ChannelClosed | Unit`, with an additional exception-throwing
31+
variant `sendUnsafe(t: T): Unit`, then the API would be quite surprising.
32+
33+
Coming to the library as a new user, they could just call send / receive. The compiler might warn them in some cases
34+
that they discard the non-unit result of `send`, but (a) would they pay attention to those warnings, and (b) would they
35+
get them in the first place (this type of compiler warning isn't detected in 100% o fcases).
36+
37+
In other words - it would be quite easy to mistakenly discard the results of `send`, so a default which guards against
38+
that (by throwing exceptions) is better, and the "safe" can always be used intentionally version if that's what's
39+
needed.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# 5. Application errors
2+
3+
Date: 2024-03-05
4+
5+
## Context
6+
7+
In some cases, it's useful to treat some return values as errors, which should cause the enclosing scope to end.
8+
9+
## Decision
10+
11+
For computation combinators, which include `par`, `race` and `supervised`, we decided to introduce the concept of
12+
application errors. These are values of a shape defined by an `ErrorMode`, which are specially treated by ox - if
13+
such a value represents an error, the enclosing scope ends.
14+
15+
Some design limitations include:
16+
17+
* we want normal scopes to remain unchanged
18+
* methods requiring a concurrency scope (that is, `using Ox`) should be callable from the new scope
19+
* all forks that might report application errors, must be constrained to return the same type of application errors
20+
* computation combinators, such as `par`, should have a single implmentation both when using application errors and
21+
exceptions only
22+
23+
Taking this into account, we separate the `Ox` capability, which allows starting forks, and `OxError`, which
24+
additionally allows reporting application errors. An inheritance hierarchy, `OxError <: Ox` ensures that we can call
25+
methods requiring the `Ox` capability if `OxError` is available, but not the other way round.
26+
27+
Finally, introducing a special `forkError` method allows us to require that it is run within a `supervisedError` scope
28+
and that it must return a value of the correct shape.

generated-doc/out/dictionary.md

+26-9
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,30 @@
22

33
How we use various terms throughout the codebase and the documentation (or at least try to):
44

5-
* **concurrency scope**: either `supervised` (default) or `scoped` ("advanced")
6-
* a scope **ends**: when unsupervised, the main body is entirely evaluated; when supervised, all user (non-daemon),
7-
supervised forks complete successfully, or at least one supervised fork fails. When the scope ends, all running
8-
forks are interrupted
9-
* scope **completes**, once all forks complete and finalizers are run. In other words, the `supervised` or `scoped`
5+
Scopes:
6+
* **concurrency scope**: either `supervised` (default), `supervisedError` (permitting application errors),
7+
or `scoped` ("advanced")
8+
* scope **body**: the code block passed to a concurrency scope (the `supervised` or `scoped` method)
9+
10+
Fork lifecycle:
11+
* within scopes, asynchronously running **forks** can be **started**
12+
* after being started a fork is **running**
13+
* then, forks **complete**: either a fork **succeeds** with a value, or a fork **fails** with an exception
14+
* external **cancellation** (`Fork.cancel()`) interrupts the fork and waits until it completes; interruption uses
15+
JVM's mechanism of injecting an `InterruptedException`
16+
17+
Scope lifecycle:
18+
* a scope **ends**: when unsupervised, the scope's body is entirely evaluated; when supervised, all user (non-daemon) &
19+
supervised forks complete successfully, or at least one user/daemon supervised fork fails, or an application error
20+
is reported. When the scope ends, all forks that are still running are cancelled
21+
* scope **completes**, once all forks complete and finalizers are run; then, the `supervised` or `scoped`
1022
method returns.
11-
* forks are **started**, and then they are **running**
12-
* forks **complete**: either a fork **succeeds**, or a fork **fails** with an exception
13-
* **cancellation** (`Fork.cancel()`) interrupts the fork and waits until it completes
14-
* scope **body**: the code block passed to a `supervised` or `scoped` method
23+
24+
Errors:
25+
* fork **failure**: when a fork fails with an exception
26+
* **application error**: forks might successfully complete with values which are considered application-level errors;
27+
such values are reported to the enclosing scope and cause the scope to end
28+
29+
Other:
30+
* **computation combinator**: a method which takes user-provided functions and manages their execution, e.g. using
31+
concurrency, interruption, and appropriately handling errors; examples include `par`, `race`, `retry`, `timeout`
+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# Error handling in scopes
2+
3+
How errors are handled depends on the type of concurrency scope that is used.
4+
5+
## Supervised scope
6+
7+
The "default" and recommended scope is created using `supervised`. When this scope is used, any fork created using
8+
`fork` or `forkUser` that fails with an exception, will cause the enclosing scope to end:
9+
10+
```scala
11+
import ox.{forkUser, supervised}
12+
13+
supervised {
14+
forkUser {
15+
Thread.sleep(100)
16+
throw new RuntimeException("boom!")
17+
}
18+
forkUser {
19+
// other forks will be interrupted
20+
}
21+
}
22+
// will re-throw the "boom!' exception
23+
```
24+
25+
If an unsupervised fork fails (created using `forkUnsupervised` / `forkCancellable`), that exception will be thrown
26+
when invoking `Fork.join`.
27+
28+
## Supervised scope with application errors
29+
30+
Additionally, supervised scopes can be created with an error mode, which allows ending the scope when a fork returns
31+
a value that is an [application error](error-handling.md). This can be done by using `supervisedError` and `forkError`,
32+
for example:
33+
34+
```scala
35+
import ox.{EitherMode, forkUserError, supervisedError}
36+
37+
supervisedError(EitherMode[Int]) {
38+
forkUserError { Left(10) }
39+
Right(())
40+
}
41+
// returns Left(10)
42+
```
43+
44+
Even though the body of the scope returns success (a `Right`), the scope ends with an application error (a `Left`),
45+
which is reported by a user fork. Note that if we used a daemon fork, the scope might have ended before the error
46+
was reported.
47+
48+
Only forks created with `forkError` and `forkUserError` can report application errors, and they **must** return a value
49+
of the shape as described by the error mode (in the example above, all `forkError`, `forkUserError` and the scope body
50+
must return an `Either[Int, T]` for arbitrary `T`s).
51+
52+
The behavior of `fork` and `forkUser` in `supervisedError` scopes is unchanged, that is, their return values are not
53+
inspected.
54+
55+
## Unsupervised scopes
56+
57+
In an unsupervised scope (created using `scoped`), failures of the forks won't be reported in any way, unless they
58+
are explicitly joined. Hence, if there's no `Fork.join`, the exception might go unnoticed.

generated-doc/out/error-handling.md

+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# General approach to error handling
2+
3+
The primary error signalling mechanism in ox are exceptions. They are appropriately handled by computation combinators,
4+
such as [`par`](par.md), [`race`](race.md), as well as by [scopes](fork-join.md) and [channels](channels/index.md).
5+
6+
The general rule for computation combinators is that using them should throw exactly the same exceptions, as if the
7+
provided code was executed directly. That is, no additional exceptions might be thrown, and no exceptions are swallowed.
8+
The only difference is that some exceptions might be added as suppressed (e.g. interrupted exceptions).
9+
10+
Some examples of exception handling in ox include:
11+
12+
* short-circuiting in `par` and `race` when one of the computations fails
13+
* retrying computations in `retry` when they fail
14+
* ending a `supervised` concurrency scope when a supervised fork fails
15+
16+
## Application errors
17+
18+
Some of the functionalities provided by ox also support application-level errors. Such errors are represented as values,
19+
e.g. the left side of an `Either[MyError, MyResult]`. They are not thrown, but returned from the computations which
20+
are orchestrated by ox.
21+
22+
Ox must be made aware of how such application errors are represented. This is done through an `ErrorMode`. Provided
23+
implementations include `EitherMode[E]` (where left sides of `Either`s are used to represent errors), and
24+
`UnionMode[E]`, where a union type of `E` and a successful value is used. Arbitrary user-provided implementations
25+
are possible as well.
26+
27+
Error modes can be used in [`supervisedError`](error-handling-scopes.md) scopes, as well as in variants of the `par`
28+
and `race` methods.
29+
30+
```eval_rst
31+
.. note::
32+
33+
Using application errors allows specifying the possible errors in the type signatures of the methods, and is hence
34+
more type-safe. If used consistently, exceptions might be avoided altogether, except for signalling bugs in the code.
35+
However, representing errors as values might incur a syntax overhead, and might be less convenient in some cases.
36+
Moreover, all I/O libraries typically throw exceptions - to use them with errors-as-values, one would need to provide
37+
a wrapper which would convert such exceptions to values. Hence, while application errors provide a lot of benefits,
38+
they are not a universal solution to error handling.
39+
```

generated-doc/out/extension.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
# Extension methods
22

33
Extension-method syntax can be imported using `import ox.syntax.*`. This allows calling methods such as
4-
`.fork`, `.raceSuccessWith`, `.parWith`, `.forever`, `.useInScope` directly on code blocks / values.
4+
`.fork`, `.raceWith`, `.parWith`, `.forever`, `.useInScope` directly on code blocks / values.

generated-doc/out/fork-join.md

+11-18
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
# Fork & join threads
22

3-
It's safest to use higher-level methods, such as `par` or `raceSuccess`, however this isn't always sufficient. For
3+
It's safest to use higher-level methods, such as `par` or `race`, however this isn't always sufficient. For
44
these cases, threads can be started using the structured concurrency APIs described below.
55

6-
Forks (new threads) can only be started with a **scope**. Such a scope is defined using the `supervised` or `scoped`
7-
methods.
6+
Forks (new threads) can only be started within a **concurrency scope**. Such a scope is defined using the `supervised`,
7+
`supervisedError` or `scoped` methods.
88

99
The lifetime of the forks is defined by the structure of the code, and corresponds to the enclosing `supervised` or
1010
`scoped` block. Once the code block passed to the scope completes, any forks that are still running are interrupted.
@@ -13,10 +13,11 @@ The whole block will complete only once all forks have completed (successfully,
1313
Hence, it is guaranteed that all forks started within `supervised` or `scoped` will finish successfully, with an
1414
exception, or due to an interrupt.
1515

16+
For example, the code below is equivalent to `par`:
17+
1618
```scala
1719
import ox.{fork, supervised}
1820

19-
// same as `par`
2021
supervised {
2122
val f1 = fork {
2223
Thread.sleep(2000)
@@ -59,8 +60,8 @@ The default scope, created with `supervised`, watches over the forks that are st
5960

6061
This means that the scope will end only when either:
6162

62-
* all (user, supervised) forks, including the main body passed to `supervised`, succeed
63-
* or any (supervised) fork, including the main body passed to `supervised`, fails
63+
* all (user, supervised) forks, including the body passed to `supervised`, succeed
64+
* or any (supervised) fork, including the body passed to `supervised`, fails
6465

6566
Hence an exception in any of the forks will cause the whole scope to end. Ending the scope means that all running forks
6667
are cancelled (using interruption). Once all forks complete, the exception is propagated further, that is re-thrown by
@@ -85,14 +86,14 @@ supervised {
8586

8687
## User, daemon and unsupervised forks
8788

88-
In supervised scoped, forks created using `fork` behave as daemon threads. That is, their failure ends the scope, but
89-
the scope will also end once the main body and all user forks succeed, regardless if the (daemon) fork is still running.
89+
In supervised scopes, forks created using `fork` behave as daemon threads. That is, their failure ends the scope, but
90+
the scope will also end once the body and all user forks succeed, regardless if the (daemon) fork is still running.
9091

9192
Alternatively, a user fork can be created using `forkUser`. Such a fork is required to complete successfully, in order
92-
for the scope to end successfully. Hence when the main body of the scope completes, the scope will wait until all user
93+
for the scope to end successfully. Hence, when the body of the scope completes, the scope will wait until all user
9394
forks have completed as well.
9495

95-
Finally, entirely unsupervised forks can be ran using `forkUnsupervised`.
96+
Finally, entirely unsupervised forks can be started using `forkUnsupervised`.
9697

9798
## Unsupervised scopes
9899

@@ -115,11 +116,3 @@ it involves creating a nested scope and two virtual threads, instead of one.
115116
The `CancellableFork` trait exposes the `.cancel` method, which interrupts the fork and awaits its completion.
116117
Alternatively, `.cancelNow` returns immediately. In any case, the enclosing scope will only complete once all forks have
117118
completed.
118-
119-
## Error handling
120-
121-
In supervised mode, if a fork fails with an exception, the enclosing scope will end.
122-
123-
Moreover, if a fork fails with an exception, the `Fork.join` method will throw that exception.
124-
125-
In unsupervised mode, if there's no join and the fork fails, the exception might go unnoticed.

generated-doc/out/index.md

+3-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ In addition to this documentation, ScalaDocs can be browsed at [https://javadoc.
1010
## sbt dependency
1111

1212
```scala
13-
"com.softwaremill.ox" %% "core" % "0.0.21"
13+
"com.softwaremill.ox" %% "core" % "0.0.22"
1414
```
1515

1616
## Scope of the project
@@ -64,7 +64,9 @@ We offer commercial support for ox and related technologies, as well as developm
6464
race
6565
collections
6666
timeout
67+
error-handling
6768
fork-join
69+
error-handling-scopes
6870
scoped-values
6971
retries
7072
interruptions

generated-doc/out/kafka.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
Dependency:
44

55
```scala
6-
"com.softwaremill.ox" %% "kafka" % "0.0.21"
6+
"com.softwaremill.ox" %% "kafka" % "0.0.22"
77
```
88

99
`Source`s which read from a Kafka topic, mapping stages and drains which publish to Kafka topics are available through

generated-doc/out/par.md

+45-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# Running computations in parallel
22

3+
A number of computations can be ran in parallel using the `par` method, for example:
4+
35
```scala
46
import ox.par
57

@@ -11,8 +13,49 @@ def computation2: String =
1113
Thread.sleep(1000)
1214
"2"
1315

14-
val result: (Int, String) = par(computation1)(computation2)
16+
val result: (Int, String) = par(computation1, computation2)
1517
// (1, "2")
1618
```
1719

18-
If one of the computations fails, the other is interrupted, and `par` waits until both branches complete.
20+
If any of the computations fails, the other is interrupted. In such case, `par` waits until both branches complete
21+
and then re-throws the exception.
22+
23+
It's also possible to run a sequence of computations given as a `Seq[() => T]` in parallel, optionally limiting the
24+
parallelism using `parLimit`:
25+
26+
```scala
27+
import ox.parLimit
28+
29+
def computation(n: Int): Int =
30+
Thread.sleep(1000)
31+
println(s"Running $n")
32+
n*2
33+
34+
val computations = (1 to 20).map(n => () => computation(n))
35+
val result: Seq[Int] = parLimit(5)(computations)
36+
// (1, "2")
37+
```
38+
39+
## Using application errors
40+
41+
Some values might be considered as application errors. In a computation returns such an error, other computations are
42+
interrupted, same as when an exception is thrown. The error is then returned by the `par` method.
43+
44+
It's possible to use an arbitrary [error mode](error-handling.md) by providing it as the initial argument to `par`.
45+
Alternatively, a built-in version using `Either` is available as `parEither`:
46+
47+
```scala
48+
import ox.parEither
49+
50+
val result = parEither(
51+
{
52+
Thread.sleep(200)
53+
Right("ok")
54+
}, {
55+
Thread.sleep(100)
56+
Left(-1)
57+
}
58+
)
59+
60+
// result is Left(-1), the other branch is interrupted
61+
```

0 commit comments

Comments
 (0)