2

I'm encountering a discrepancy in retrieving a custom CoroutineContext.Element (TraceContextElement) depending on the environment (test vs. production) and how I access the context within a launched coroutine, specifically when MDCContext is involved.

Goal: Propagate a custom TraceContextElement across coroutines, including async "fire-and-forget" tasks launched from a main request thread, while also maintaining MDC context using kotlinx-coroutines-slf4j.

Code Setup:

  1. Custom Context Element:

    import com.kakao.zertfds.infrastructure.logger.domain.TraceContext // Simplified TraceContext
    import kotlin.coroutines.AbstractCoroutineContextElement
    import kotlin.coroutines.CoroutineContext
    
    // Wrapper for our TraceContext
    class TraceContextElement(val context: TraceContext) : AbstractCoroutineContextElement(Key) {
        companion object Key : CoroutineContext.Key<TraceContextElement>
    }
    
    // Helper to get the context
    fun CoroutineContext.traceContext(): TraceContext? = this[TraceContextElement]?.context
    
    // Simplified TraceContext data class
    data class TraceContext(val traceId: String?, /* other fields */ )
    
  2. Launcher Utilities: I have two utility functions to launch coroutines while adding both MDCContext and TraceContextElement:

    import kotlinx.coroutines.*
    import kotlinx.coroutines.slf4j.MDCContext
    import org.slf4j.MDC
    import kotlin.coroutines.coroutineContext
    
    // --- Launcher 1: Uses CoroutineScope extension lambda ---
    fun CoroutineScope.launchWithTrace(
        traceManager: TraceManager, // Mocked in tests
        block: suspend CoroutineScope.() -> Unit // Lambda is CoroutineScope extension
    ): Job {
        val mdcContextMap = MDC.getCopyOfContextMap() ?: emptyMap()
        val traceContext = traceManager.createTraceContext() // Returns a test TraceContext
        // Launch with both contexts
        return launch(MDCContext() + TraceContextElement(traceContext)) {
            try {
                if (mdcContextMap.isNotEmpty()) MDC.setContextMap(mdcContextMap)
                block.invoke(this) // `this` is the CoroutineScope
            } finally {
                MDC.clear()
            }
        }
    }
    
    // --- Launcher 2: Uses standard suspend lambda ---
    fun CoroutineScope.launchWithTrace2(
        traceManager: TraceManager, // Mocked in tests
        block: suspend () -> Unit // Lambda is a standard suspend function
    ): Job {
        val mdcContextMap = MDC.getCopyOfContextMap() ?: emptyMap()
        val traceContext = traceManager.createTraceContext() // Returns a test TraceContext
        // Launch with both contexts
        return launch(MDCContext() + TraceContextElement(traceContext)) {
            try {
                if (mdcContextMap.isNotEmpty()) MDC.setContextMap(mdcContextMap)
                block() // Standard function call
            } finally {
                MDC.clear()
            }
        }
    }
    

    (Assume TraceManager is a class that provides createTraceContext(): TraceContext)

The Problem:

In my Kotest test environment, using Dispatchers.Default:

  • Using launchWithTrace (CoroutineScope extension lambda):

    test("Test with launchWithTrace") {
        val scope = CoroutineScope(Dispatchers.Default)
        val mockTraceManager = mockk<TraceManager>()
        every { mockTraceManager.createTraceContext() } returns TraceContext("test-id")
    
        val latch = CountDownLatch(1)
        scope.launchWithTrace(mockTraceManager) { // Uses the extension lambda
            // Access context via implicit 'this.coroutineContext'
            val ctx = coroutineContext.traceContext()
            println("launchWithTrace context: $ctx") // PRINTS: launchWithTrace context: TraceContext(traceId=test-id)
            ctx shouldNotBe null // Assertion Passes
            latch.countDown()
        }
        latch.await(2, TimeUnit.SECONDS) shouldBe true
    }
    

    This works as expected. The TraceContextElement is successfully retrieved using coroutineContext (which refers to the scope's context).

  • Using launchWithTrace2 (standard suspend lambda):

    test("Test with launchWithTrace2 - FAILS") {
        val scope = CoroutineScope(Dispatchers.Default)
        val mockTraceManager = mockk<TraceManager>()
        every { mockTraceManager.createTraceContext() } returns TraceContext("test-id")
    
        val latch = CountDownLatch(1)
        scope.launchWithTrace2(mockTraceManager) { // Uses the standard lambda
            // Access context via top-level 'kotlin.coroutines.coroutineContext'
            val ctx = kotlin.coroutines.coroutineContext.traceContext()
            println("launchWithTrace2 context: $ctx") // PRINTS: launchWithTrace2 context: null
            // ctx shouldNotBe null // This assertion would FAIL
            latch.countDown() // This might not even be reached if assert added
        }
        latch.await(2, TimeUnit.SECONDS) shouldBe true // Test potentially hangs or fails assertion
    }
    

    This fails. Accessing the context via kotlin.coroutines.coroutineContext inside the lambda returns null for the TraceContextElement.

However:

In my actual Spring Boot application running on the JVM:

  • When I use launchWithTrace2 within a controller or service (often using a CoroutineScope with Dispatchers.IO), accessing the context via kotlin.coroutines.coroutineContext.traceContext() works correctly and retrieves the TraceContextElement as expected.

Question:

Why does accessing the TraceContextElement via kotlin.coroutines.coroutineContext fail specifically within the standard suspend () -> Unit lambda in the test environment (Kotest, MockK, Dispatchers.Default, MDCContext), but works fine in the production environment and also works in the test environment if using a CoroutineScope extension lambda (suspend CoroutineScope.() -> Unit)?

Is this related to how kotlin.coroutines.coroutineContext interacts with MDCContext and potential thread switching in Dispatchers.Default within the test framework? Is there a known issue or a subtlety in how context is resolved via Continuation vs. the CoroutineScope object itself in this testing scenario?

Environment:

  • Kotlin: 1.9.x
  • kotlinx-coroutines-core: 1.8.x
  • kotlinx-coroutines-slf4j: 1.8.x
  • Kotest: 5.x.x
  • MockK: 1.13.x
  • JVM: 21
1
  • val ctx = kotlin.coroutines.coroutineContext.traceContext() is this the actual code you are using in the failing test, or did you add the full qualifier to avoid imports here? Commented Apr 13 at 17:22

1 Answer 1

0

TraceContext being null

In order to reproduce your problem, I did the following:

  • copied your production code
  • added dummy class TraceContext (which gets mocked anyway)
  • put the two tests in a FunSpec
  • un-commented the line ctx shouldNotBe null in the second test

It did not work, both tests where green. The assertion ctx shouldNotBe null fails, when I replace kotlin.coroutines.coroutineContext with just coroutineContext, because then the outer coroutine context from the test is taken, and that context does not have a TraceContextElement.

An alternative to kotlin.coroutines.coroutineContext would be using currentCoroutineContext() (see documentation) which is an inline function, resolving to the same result, but is less prone to confusion with the coroutineContext of a CoroutineScope.

Proper usage of MDCContext

A lot of what you are doing in the launchWithTrace-functions is superfluous, because MDCContext already does what you are trying to achieve there. The constructor MDCContext() has an argument contextMap that is implicitly set to MDC.getCopyOfContextMap().

The functionality of the MDCContext, being an implementation of ThreadContextElement, is that whenever the coroutine runs on a different thread, it puts aside the thread-local MDC context map of that thread, and sets the contextMap from the MDCContext as context map of that thread. When the coroutine "leaves" the thread, the original context map is reset as context map of that thread.

The code in launchWithTrace and launchWithTrace2 is trying to emulate that behavior unnecessarily, but not as good as the MDCContext already does.

See the documentation of MDCContext for more info.

Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.