We use `CoroutineContext` like we used to use `Thr...
# coroutines
c
We use
CoroutineContext
like we used to use `ThreadLocal`: to store globally-available but required information about the context, like authentication. However, we sometimes want access to that information in non-coroutines methods. Currently, we are forced to add
suspend
to these methods, even though they never suspend, to be able to access the context. Is there a better solution?
s
Nope. Coroutine context is passed into a suspending function via its hidden continuation parameter. If it’s not a suspending function, it doesn’t have that parameter, so it’s impossible for it to access the context. But I think that in itself reveals a lot about the nature of this problem. Coroutine context is an invisible argument to every suspending function. Since it can hold arbitrary data, it lets a function obscure its real dependencies. Java’s thread-locals enable/encourage the same thing. But isn’t it better just to make all the function’s dependencies explicit? I’d argue coroutine context should almost always be treated as write-only.
Context parameters, if and when they arrive, are probably the real answer
I should mention that it's technically possible to have coroutines and thread locals work together, though it can be a confusing mess to try and set up. Essentially you make a special coroutine context element that writes its value to a thread local every time the coroutine is dispatched to a new thread. That might be your simplest solution.
c
But isn’t it better just to make all the function’s dependencies explicit?
Something like "Who is the current user?" would need to be passed to every single function in the entire app, it would make everything way more complex.
Yeah, context parameters would probably be the correct tool here.
Essentially you make a special coroutine context element that writes its value to a thread local every time the coroutine is dispatched to a new thread. That might be your simplest solution
Oh, I'm interested. I don't think it's the correct tool for "auth of the current user", but that seems interesting for things like monitoring span IDs. Thanks for the link, I'll read into it.
d
If you need to have User everywhere in the app, then you really should pass it everywhere in the app. Trying to get at it "magically" through some global/semiglobal context will lead to long term maintenance headaches.
d
Or you could use `class`es to connect functions to data.
Copy code
class MySession {
  val user: User
  val something: Something

  fun foo() {
    // has access to `User`
  }

  suspend fun bar() {
    // also has access to `User`
  }
}
Basically, you can hide many parameters in objects. With extension functions, methods can even be in other files. No need to explicitly pass anything if you're always in the scope.
z
FYI ThreadContextElement appears to be somewhat broke with UnconfinedTestDispatcher and compose’s test dispatcher.
d
@Zach Klippenstein (he/him) [MOD], are you talking about https://github.com/Kotlin/kotlinx.coroutines/issues/4296 , which was just closed in 1.10.0?
👍🏻 1
c
@Daniel Pitts I'm curious to see a codebase where someone actually did that. I'm pretty sure passing an
Auth
parameter to all functions in the entire project is much worse for maintenance and readability.
d
Why would all functions need it? I feel like that’s hyperbole.
c
‘who is doing the action’ is crucial information that comes from the user and must be available in every single code path where actions can be taken (reading and writing). Otherwise, a user may be able to access information they don't have the right to, or even worse be able to modify it. At the very least, this means all DB methods and all requests to other systems must be able to access that information. Thus all methods that call them must as well. Together, that's about 95% of the application, the rest mostly being data mappers (“convert this list of results into a CSV”). So far, that 95% of the application coincides with everything that is
suspend
, so having that as part of the coroutine context makes sense. Recently, we discovered that actually, even the CSV exports need that information, because dates should be displayed in the user's timezone. That leaves us with multiple options: • refactor all the CSV stuff to pass an additional data point. But that's a pain, because a lot of that DSL uses
it
so if we add a second argument it greatly decreases the usability. • refactor all the CSV stuff to be
suspend
.
d
Shouldn't you pass in the desired timezone, rather than the user object then?
c
That would make sense, but if it needs a full rewrite each time we want to add one additional piece of information, and we know the user context is globally available anyway, it's safer to do it once and not need to touch it in the future
d
And passing an authorized principal to code explicitly is a lot safer than relying on an arbitrary context attribute. It's possible to end up with bugs/crashes because of this. An example scenario: 1. one code path somehow doesn't put the User in the context, and it works when written because lower level stuff actually doesn't care at that point. 2. Some time later, someone updates a lower level function to fetch the user. It should work, right? User is available everywhere, even when not passed in. 3. The unit tests all set up the context, so it all passes. 4. Code reviewers all "know" that User is accessible everywhere, so LGTM the PR. 5. Code deployed to test environment. Testers validate the happy paths for what they think is affected. 6. Code deployed to prod environment. Boom, server blows up. As the joke goes, the customer asked the bartender where the bathroom was and the bar burns to the ground.
Now, having a universal context object that is passed (explicitly!) to all places that need it, that should be fine. As long as the context object is both immutable and is guaranteed to have the data you want for all code paths.
Though it does tend to make you write code that is specific to your "business at the moment" logic, rather than reusable, suitably abstract code.
Real world example of passing too much to lower level code: On my day job, I work on the A/B testing capability of a CRM. The code was developed to assume we're testing based on who is receiving the message (recipient.account_id), The code, for reasons, is spread across a library and a service that imports that library. So, we were passing in the Recipient object, and that worked for our use-cases. Until it didn't. We now have a use-case now that is on the user that initiated the interaction instead. The change we're going to make for it is to pass in the account_id specifically from the service to the library, the library will know nothing about the Recipient object, and we can then choose which account_id is appropriate for the given situation.
c
Code deployed to prod environment. Boom, server blows up. As the joke goes, the customer asked the bartender where the bathroom was and the bar burns to the ground.
I hear you, and I don't want to argue for global information—but that's the behavior we want. Either the server crashes because the user is unknown, or the server answers without knowing who the user is, and therefore likely accesses or edits information the user shouldn't have access to. We prefer the server crashes, and these situations are rare enough thanks to our other tests that we have high confidence they only happen in production when a user actively is trying to break the system. In which case, an HTTP 500 is near best case scenario.
d
Wouldn't best case be the code doesn't compile because it won't work? 😉
c
Though it does tend to make you write code that is specific to your "business at the moment" logic, rather than reusable, suitably abstract code.
I hear you, but this is specifically the user ID, which is definitely not something that's going to be outdated in the future. There will always be a user.