Skip to content
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

Merged
merged 19 commits into from
Mar 16, 2023
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion arrow-libs/core/arrow-core/api/arrow-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -3237,7 +3237,7 @@ public class arrow/core/raise/CancellationExceptionNoTrace : java/util/concurren
}

public final class arrow/core/raise/DefaultRaise : arrow/core/raise/Raise {
public fun <init> ()V
public fun <init> (Z)V
public fun attempt (Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public fun bind (Larrow/core/Either;)Ljava/lang/Object;
public fun bind (Larrow/core/Validated;)Ljava/lang/Object;
Expand All @@ -3251,13 +3251,17 @@ public final class arrow/core/raise/DefaultRaise : arrow/core/raise/Raise {
public final fun complete ()Z
public fun invoke (Lkotlin/jvm/functions/Function1;)Ljava/lang/Object;
public fun invoke (Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public final fun isTraced ()Z
public fun raise (Ljava/lang/Object;)Ljava/lang/Void;
public fun recover (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)Ljava/lang/Object;
public fun recover (Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public fun recover (Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public fun shift (Ljava/lang/Object;)Ljava/lang/Object;
}

public abstract interface annotation class arrow/core/raise/ExperimentalTraceApi : java/lang/annotation/Annotation {
}

public final class arrow/core/raise/IorRaise : arrow/core/raise/Raise {
public fun <init> (Lkotlin/jvm/functions/Function2;Ljava/util/concurrent/atomic/AtomicReference;Larrow/core/raise/Raise;)V
public fun attempt (Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
Expand Down Expand Up @@ -3501,6 +3505,7 @@ public final class arrow/core/raise/RaiseKt {
public static final fun toResult (Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public static final fun toValidated (Lkotlin/jvm/functions/Function1;)Larrow/core/Validated;
public static final fun toValidated (Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public static final fun traced (Larrow/core/raise/Raise;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object;
public static final fun zipOrAccumulate (Larrow/core/raise/Raise;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function9;)Ljava/lang/Object;
public static final fun zipOrAccumulate (Larrow/core/raise/Raise;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function8;)Ljava/lang/Object;
public static final fun zipOrAccumulate (Larrow/core/raise/Raise;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function7;)Ljava/lang/Object;
Expand Down Expand Up @@ -3569,6 +3574,14 @@ public final class arrow/core/raise/ResultRaise : arrow/core/raise/Raise {
public final synthetic fun unbox-impl ()Larrow/core/raise/Raise;
}

public final class arrow/core/raise/Traced {
public fun <init> (Ljava/util/concurrent/CancellationException;Ljava/lang/Object;)V
public final fun getRaised ()Ljava/lang/Object;
public final fun printStackTrace ()V
public final fun stackTraceToString ()Ljava/lang/String;
public final fun suppressedExceptions ()Ljava/util/List;
}

public abstract interface class arrow/typeclasses/Monoid : arrow/typeclasses/Semigroup {
public static final field Companion Larrow/typeclasses/Monoid$Companion;
public static fun Boolean ()Larrow/typeclasses/Monoid;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,7 @@ import arrow.core.Option
import arrow.core.Some
import arrow.core.getOrElse
import arrow.core.identity
import arrow.core.orElse
import arrow.typeclasses.Semigroup
import arrow.typeclasses.SemigroupDeprecation
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.InvocationKind.EXACTLY_ONCE
import kotlin.contracts.contract
import kotlin.experimental.ExperimentalTypeInference
import kotlin.jvm.JvmInline
Expand All @@ -27,23 +23,16 @@ import kotlin.jvm.JvmName
public inline fun <E, A> either(@BuilderInference block: Raise<E>.() -> A): Either<E, A> =
fold({ block.invoke(this) }, { Either.Left(it) }, { Either.Right(it) })

public inline fun <A> nullable(block: NullableRaise.() -> A): A? {
contract { callsInPlace(block, EXACTLY_ONCE) }
return fold({ block(NullableRaise(this)) }, { null }, ::identity)
}
public inline fun <A> nullable(block: NullableRaise.() -> A): A? =
fold({ block(NullableRaise(this)) }, { null }, ::identity)

public inline fun <A> result(block: ResultRaise.() -> A): Result<A> {
contract { callsInPlace(block, EXACTLY_ONCE) }
return fold({ block(ResultRaise(this)) }, Result.Companion::failure, Result.Companion::failure, Result.Companion::success)
}
public inline fun <A> result(block: ResultRaise.() -> A): Result<A> =
fold({ block(ResultRaise(this)) }, Result.Companion::failure, Result.Companion::failure, Result.Companion::success)

public inline fun <A> option(block: OptionRaise.() -> A): Option<A> {
contract { callsInPlace(block, EXACTLY_ONCE) }
return fold({ block(OptionRaise(this)) }, ::identity, ::Some)
}
public inline fun <A> option(block: OptionRaise.() -> A): Option<A> =
fold({ block(OptionRaise(this)) }, ::identity, ::Some)

public inline fun <E, A> ior(noinline combineError: (E, E) -> E, @BuilderInference block: IorRaise<E>.() -> A): Ior<E, A> {
contract { callsInPlace(block, EXACTLY_ONCE) }
val state: Atomic<Option<E>> = Atomic(None)
return fold<E, A, Ior<E, A>>(
{ block(IorRaise(combineError, state, this)) },
Expand All @@ -56,10 +45,9 @@ public inline fun <E, A> ior(noinline combineError: (E, E) -> E, @BuilderInferen
public typealias Null = Nothing?

@JvmInline
public value class NullableRaise(private val cont: Raise<Null>) : Raise<Null> {
public value class NullableRaise(private val raise: Raise<Null>) : Raise<Null> by raise {
@RaiseDSL
public fun ensure(value: Boolean): Unit = ensure(value) { null }
override fun raise(r: Nothing?): Nothing = cont.raise(r)
public fun <B> Option<B>.bind(): B = getOrElse { raise(null) }

public fun <B> B?.bind(): B {
Expand All @@ -74,14 +62,12 @@ public value class NullableRaise(private val cont: Raise<Null>) : Raise<Null> {
}

@JvmInline
public value class ResultRaise(private val cont: Raise<Throwable>) : Raise<Throwable> {
override fun raise(r: Throwable): Nothing = cont.raise(r)
public value class ResultRaise(private val raise: Raise<Throwable>) : Raise<Throwable> by raise {
public fun <B> Result<B>.bind(): B = fold(::identity) { raise(it) }
}

@JvmInline
public value class OptionRaise(private val cont: Raise<None>) : Raise<None> {
override fun raise(r: None): Nothing = cont.raise(r)
public value class OptionRaise(private val raise: Raise<None>) : Raise<None> by raise {
public fun <B> Option<B>.bind(): B = getOrElse { raise(None) }
public fun ensure(value: Boolean): Unit = ensure(value) { None }

Expand Down
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
Expand Down Expand Up @@ -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)
}
Expand All @@ -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()
Expand All @@ -108,25 +108,90 @@ public inline fun <R, A, B> fold(
}
}

/**
* Inspect a [Traced] 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 [Traced] 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: Traced<R>) -> Unit
): A {
val itOuterTraced = this is DefaultRaise && isTraced
nomisRev marked this conversation as resolved.
Show resolved Hide resolved
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(Traced(e, r))
if (itOuterTraced) 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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
this is RaiseCancellationExceptionNoTrace && this.raise === raise -> raised as R
this is RaiseCancellationException && this.raise === raise -> raised as R
this.raise === raise && (this is RaiseCancellationExceptionNoTrace || this is RaiseCancellationException) -> raised as R

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change introduces a compilation error because raise and raised properties don't exist in CancellationException. Those properties belong to RaiseCancellationExceptionNoTrace and RaiseCancellationException, so we need the smart cast here.

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 && !isTraced -> throw RaiseCancellationExceptionNoTrace(r, this)
isActive.value && isTraced -> throw RaiseCancellationException(r, this)
nomisRev marked this conversation as resolved.
Show resolved Hide resolved
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()

private class RaiseCancellationException(val raised: Any?, val raise: Raise<Any?>) : CancellationException()

public expect open class CancellationExceptionNoTrace() : CancellationException

Expand All @@ -138,3 +203,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
Expand Up @@ -22,7 +22,6 @@ import kotlin.experimental.ExperimentalTypeInference
import kotlin.jvm.JvmMultifileClass
import kotlin.jvm.JvmName


/**
* Accumulate the errors from running both [action1] and [action2] using the given [combine] function.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
@file:JvmMultifileClass
@file:JvmName("RaiseKt")
package arrow.core.raise

import kotlin.coroutines.cancellation.CancellationException
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 of `R`. Allows to inspect `R`, and the traces from where it was raised. */
@ExperimentalTraceApi
public class Traced<R>(private val exception: CancellationException, public val raised: R) {
/**
* 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
Expand Up @@ -54,7 +54,7 @@ class StructuredConcurrencySpec : StringSpec({
}.fold(::identity) { fail("Should never be here") } shouldBe "hello"

withTimeout(2.seconds) {
cancelled.await().shouldNotBeNull().message shouldBe "Raised Continuation"
cancelled.await().shouldNotBeNull().message shouldBe RaiseCancellationExceptionCaptured
}
}

Expand Down
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()
}
}
}
})
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ package arrow.core.raise

import kotlin.coroutines.cancellation.CancellationException

public actual open class CancellationExceptionNoTrace : CancellationException("Raised Continuation")
public actual open class CancellationExceptionNoTrace : CancellationException(RaiseCancellationExceptionCaptured)
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import kotlin.coroutines.cancellation.CancellationException
* Inspired by KotlinX Coroutines:
* https://github.com/Kotlin/kotlinx.coroutines/blob/3788889ddfd2bcfedbff1bbca10ee56039e024a2/kotlinx-coroutines-core/jvm/src/Exceptions.kt#L29
*/
public actual open class CancellationExceptionNoTrace : CancellationException("Raised Continuation") {
public actual open class CancellationExceptionNoTrace : CancellationException(RaiseCancellationExceptionCaptured) {
override fun fillInStackTrace(): Throwable {
// Prevent Android <= 6.0 bug.
stackTrace = emptyArray()
Expand Down
Loading