-
Notifications
You must be signed in to change notification settings - Fork 451
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Tracing POC #2946
Tracing POC #2946
Changes from all commits
d04a400
3b108cd
209248f
04d0616
aeaabe2
a75658f
efd6fc8
2766fcc
4d22cf1
9693f1b
b2f01e7
e3bb2d8
efc2434
e58cdb3
b107947
f6bc835
1594d47
ee8e7af
9208d8c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||
---|---|---|---|---|---|---|---|---|
@@ -1,13 +1,14 @@ | ||||||||
@file:JvmMultifileClass | ||||||||
@file:JvmName("RaiseKt") | ||||||||
@file:OptIn(ExperimentalTypeInference::class, ExperimentalContracts::class) | ||||||||
|
||||||||
package arrow.core.raise | ||||||||
|
||||||||
import arrow.atomic.AtomicBoolean | ||||||||
import arrow.core.nonFatalOrThrow | ||||||||
import arrow.core.Either | ||||||||
import kotlin.contracts.ExperimentalContracts | ||||||||
import kotlin.contracts.InvocationKind.AT_MOST_ONCE | ||||||||
import kotlin.contracts.InvocationKind.EXACTLY_ONCE | ||||||||
import kotlin.contracts.contract | ||||||||
import kotlin.coroutines.cancellation.CancellationException | ||||||||
import kotlin.experimental.ExperimentalTypeInference | ||||||||
|
@@ -75,7 +76,6 @@ public inline fun <R, A, B> fold( | |||||||
transform: (value: A) -> B, | ||||||||
): B { | ||||||||
contract { | ||||||||
callsInPlace(program, EXACTLY_ONCE) | ||||||||
callsInPlace(recover, AT_MOST_ONCE) | ||||||||
callsInPlace(transform, AT_MOST_ONCE) | ||||||||
} | ||||||||
|
@@ -94,7 +94,7 @@ public inline fun <R, A, B> fold( | |||||||
callsInPlace(recover, AT_MOST_ONCE) | ||||||||
callsInPlace(transform, AT_MOST_ONCE) | ||||||||
} | ||||||||
val raise = DefaultRaise() | ||||||||
val raise = DefaultRaise(false) | ||||||||
return try { | ||||||||
val res = program(raise) | ||||||||
raise.complete() | ||||||||
|
@@ -108,27 +108,91 @@ public inline fun <R, A, B> fold( | |||||||
} | ||||||||
} | ||||||||
|
||||||||
/** | ||||||||
* Inspect a [Trace] value of [R]. | ||||||||
* | ||||||||
* Tracing [R] can be useful to know where certain errors, or failures are coming from. | ||||||||
* Let's say you have a `DomainError`, but it might be raised from many places in the project. | ||||||||
* | ||||||||
* You would have to manually _trace_ where this error is coming from, | ||||||||
* instead [Trace] offers you ways to inspect the actual stacktrace of where the raised value occurred. | ||||||||
* | ||||||||
* Beware that tracing can only track the [Raise.bind] or [Raise.raise] call that resulted in the [R] value, | ||||||||
* and not any location of where the [R], or [Either.Left] value was created. | ||||||||
* | ||||||||
* ```kotlin | ||||||||
* public fun main() { | ||||||||
* val error = effect<String, Int> { raise("error") } | ||||||||
* error.traced { (trace, _: String) -> trace.printStackTrace() } | ||||||||
* .fold({ require(it == "error") }, { error("impossible") }) | ||||||||
* } | ||||||||
* ``` | ||||||||
* ```text | ||||||||
* arrow.core.continuations.RaiseCancellationException: Raised Continuation | ||||||||
* at arrow.core.continuations.DefaultRaise.raise(Fold.kt:77) | ||||||||
* at MainKtKt$main$error$1.invoke(MainKt.kt:6) | ||||||||
* at MainKtKt$main$error$1.invoke(MainKt.kt:6) | ||||||||
* at arrow.core.continuations.Raise$DefaultImpls.bind(Raise.kt:22) | ||||||||
* at arrow.core.continuations.DefaultRaise.bind(Fold.kt:74) | ||||||||
* at arrow.core.continuations.Effect__TracingKt$traced$2.invoke(Traced.kt:46) | ||||||||
* at arrow.core.continuations.Effect__TracingKt$traced$2.invoke(Traced.kt:46) | ||||||||
* at arrow.core.continuations.Effect__FoldKt.fold(Fold.kt:92) | ||||||||
* at arrow.core.continuations.Effect.fold(Unknown Source) | ||||||||
* at MainKtKt.main(MainKt.kt:8) | ||||||||
* at MainKtKt.main(MainKt.kt) | ||||||||
* ``` | ||||||||
* | ||||||||
* NOTE: | ||||||||
* This implies a performance penalty of creating a stacktrace when calling [Raise.raise], | ||||||||
* but **this only occurs** when composing `traced`. | ||||||||
* The stacktrace creation is disabled if no `traced` calls are made within the function composition. | ||||||||
*/ | ||||||||
@ExperimentalTraceApi | ||||||||
public inline fun <R, A> Raise<R>.traced( | ||||||||
@BuilderInference program: Raise<R>.() -> A, | ||||||||
trace: (traced: Trace, R) -> Unit | ||||||||
): A { | ||||||||
val isOuterTraced = this is DefaultRaise && isTraced | ||||||||
val nested = if (this is DefaultRaise && isTraced) this else DefaultRaise(true) | ||||||||
return try { | ||||||||
program.invoke(nested) | ||||||||
} catch (e: RaiseCancellationException) { | ||||||||
val r: R = e.raisedOrRethrow(nested) | ||||||||
trace(Trace(e), r) | ||||||||
if (isOuterTraced) throw e else raise(r) | ||||||||
} | ||||||||
} | ||||||||
|
||||||||
/** Returns the raised value, rethrows the CancellationException if not our scope */ | ||||||||
@PublishedApi | ||||||||
@Suppress("UNCHECKED_CAST") | ||||||||
internal fun <R> CancellationException.raisedOrRethrow(raise: DefaultRaise): R = | ||||||||
if (this is RaiseCancellationException && this.raise === raise) raised as R | ||||||||
else throw this | ||||||||
when { | ||||||||
this is RaiseCancellationExceptionNoTrace && this.raise === raise -> raised as R | ||||||||
this is RaiseCancellationException && this.raise === raise -> raised as R | ||||||||
Comment on lines
+171
to
+172
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This change introduces a compilation error because |
||||||||
else -> throw this | ||||||||
} | ||||||||
|
||||||||
/** Serves as both purposes of a scope-reference token, and a default implementation for Raise. */ | ||||||||
@PublishedApi | ||||||||
internal class DefaultRaise : Raise<Any?> { | ||||||||
internal class DefaultRaise(@PublishedApi internal val isTraced: Boolean) : Raise<Any?> { | ||||||||
private val isActive = AtomicBoolean(true) | ||||||||
|
||||||||
@PublishedApi | ||||||||
internal fun complete(): Boolean = isActive.getAndSet(false) | ||||||||
override fun raise(r: Any?): Nothing = | ||||||||
if (isActive.value) throw RaiseCancellationException(r, this) else throw RaiseLeakedException() | ||||||||
override fun raise(r: Any?): Nothing = when { | ||||||||
isActive.value -> throw if (isTraced) RaiseCancellationException(r, this) else RaiseCancellationExceptionNoTrace(r, this) | ||||||||
else -> throw RaiseLeakedException() | ||||||||
} | ||||||||
} | ||||||||
|
||||||||
/** CancellationException is required to cancel coroutines when raising from within them. */ | ||||||||
private class RaiseCancellationException(val raised: Any?, val raise: Raise<Any?>) : CancellationExceptionNoTrace() | ||||||||
private class RaiseCancellationExceptionNoTrace(val raised: Any?, val raise: Raise<Any?>) : | ||||||||
CancellationExceptionNoTrace() | ||||||||
|
||||||||
public expect open class CancellationExceptionNoTrace() : CancellationException | ||||||||
private class RaiseCancellationException(val raised: Any?, val raise: Raise<Any?>) : CancellationException() | ||||||||
|
||||||||
internal expect open class CancellationExceptionNoTrace() : CancellationException | ||||||||
|
||||||||
private class RaiseLeakedException : IllegalStateException( | ||||||||
""" | ||||||||
|
@@ -138,3 +202,8 @@ private class RaiseLeakedException : IllegalStateException( | |||||||
See: Effect documentation for additional information. | ||||||||
""".trimIndent() | ||||||||
) | ||||||||
|
||||||||
internal const val RaiseCancellationExceptionCaptured: String = | ||||||||
"kotlin.coroutines.cancellation.CancellationException should never get cancelled. Always re-throw it if captured." + | ||||||||
"This swallows the exception of Arrow's Raise, and leads to unexpected behavior." + | ||||||||
"When working with Arrow prefer Either.catch or arrow.core.raise.catch to automatically rethrow CancellationException." |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,43 @@ | ||
@file:JvmMultifileClass | ||
@file:JvmName("RaiseKt") | ||
package arrow.core.raise | ||
|
||
import kotlin.coroutines.cancellation.CancellationException | ||
import kotlin.jvm.JvmInline | ||
import kotlin.jvm.JvmMultifileClass | ||
import kotlin.jvm.JvmName | ||
|
||
@RequiresOptIn("This API is experimental, and may change in the future.") | ||
public annotation class ExperimentalTraceApi | ||
|
||
/** Tracing result. Allows to inspect the traces from where raise was called. */ | ||
@ExperimentalTraceApi | ||
@JvmInline | ||
public value class Trace(private val exception: CancellationException) { | ||
/** | ||
* Returns the stacktrace as a [String] | ||
* | ||
* Note, the first line in the stacktrace will be the `RaiseCancellationException`. | ||
* The users call to `raise` can found in the_second line of the stacktrace. | ||
*/ | ||
public fun stackTraceToString(): String = exception.stackTraceToString() | ||
|
||
/** | ||
* Prints the stacktrace. | ||
* | ||
* Note, the first line in the stacktrace will be the `RaiseCancellationException`. | ||
* The users call to `raise` can found in the_second line of the stacktrace. | ||
*/ | ||
public fun printStackTrace(): Unit = | ||
exception.printStackTrace() | ||
|
||
/** | ||
* Returns the suppressed exceptions that occurred during cancellation of the surrounding coroutines, | ||
* | ||
* For example when working with `Resource`, or `bracket`: | ||
* When consuming a `Resource` fails due to [Raise.raise] it results in `ExitCase.Cancelled`, | ||
* if the finalizer then results in a `Throwable` it will be added as a `suppressedException` to the [CancellationException]. | ||
*/ | ||
public fun suppressedExceptions(): List<Throwable> = | ||
exception.suppressedExceptions | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,46 @@ | ||
package arrow.core.raise | ||
|
||
import arrow.core.right | ||
import io.kotest.assertions.throwables.shouldThrow | ||
import io.kotest.core.spec.style.StringSpec | ||
import io.kotest.matchers.shouldBe | ||
import io.kotest.property.Arb | ||
import io.kotest.property.arbitrary.int | ||
import io.kotest.property.arbitrary.string | ||
import io.kotest.property.checkAll | ||
import kotlinx.coroutines.CompletableDeferred | ||
|
||
@OptIn(ExperimentalTraceApi::class) | ||
class TraceSpec : StringSpec({ | ||
"trace is empty when no errors" { | ||
checkAll(Arb.int()) { i -> | ||
either<Nothing, Int> { | ||
traced({ i }) { _,_ -> unreachable() } | ||
} shouldBe i.right() | ||
} | ||
} | ||
|
||
"trace is empty with exception" { | ||
checkAll(Arb.string()) { msg -> | ||
val error = RuntimeException(msg) | ||
shouldThrow<RuntimeException> { | ||
either<Nothing, Int> { | ||
traced({ throw error }) { _,_ -> unreachable() } | ||
} | ||
}.message shouldBe msg | ||
} | ||
} | ||
|
||
"nested tracing - identity" { | ||
val inner = CompletableDeferred<String>() | ||
ior(String::plus) { | ||
traced({ | ||
traced({ raise("") }) { traced, _ -> | ||
inner.complete(traced.stackTraceToString()) | ||
} | ||
}) { traced, _ -> | ||
inner.await() shouldBe traced.stackTraceToString() | ||
} | ||
} | ||
} | ||
}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wow, binary says BOOM due to
value class
🤯 Never realised this before