Is there a way to observe and respond to `Job` sta...
# coroutines
c
Is there a way to observe and respond to
Job
state changes? (i.e. going from New -> Active -> Cancelled/Completed)
j
There is invokeOnCompletion to register a callback for when the job completes (successfully or exceptionally) or is cancelled
But I don't know anything for the
New -> Active
transition
c
Thanks for responding. My apologies, I knew that, and I should have been more specific, as I am primarily interested in New -> Active
j
Ah ok, then sorry I don't know more. Why do you want to observe this transition btw?
c
Curious if anyone working on Coroutines could comment on whether it would be practical/desirable to have a method like
invokeOnStart
j
Why are you using the coroutine's
Job
API for this, though? It looks like your use case calls for a custom interface
c
Currently I do have a custom interface, and it creates a lot of boilerplate that I think could be eliminated simply by observing a Job
Job does virtually everything I need it to do, except being able to observe when it goes from New -> Started
It already contains mechanisms to observe cancellation/completion, it passes exceptions, I can check if it is still active, etc
I’m open to feedback on the idea… hence the original post, but there aren’t major drawbacks that are obvious to me
g
To "observe" job you should use .join() it will suspend until job is complete
c
.join()
will also start the job
z
You're trying to model a separate lifecycle without a coroutine job? That sounds like a bad design in general, regardless of whether it's technically possible. Very hacky to take something designed and built for one purpose and try to use it for something completely unrelated. Probably gonna give you maintenance issues later, won't be able to evolve with your needs, testing is gonna depend on a completely unrelated runtime, etc.
☝️ 2
c
You’re trying to model a separate lifecycle without a coroutine job?
No. To give a concrete example, I already have this implemented for Apollo subscriptions. It looks roughly like:
Copy code
class ApolloSubscription(
  private val client: ApolloClient,
  private val coroutineScope: CoroutineScope,
) {
  private val _job = MutableStateFlow(subscriptionJob())
  val job = _job.asStateFlow()

  private var subscriptionCall: ApolloSubscriptionCall? = null

  init {
    job
      .onEach {
        it.invokeOnCompletion {
          subscriptionCall?.cancel()
          subscriptionCall = null
          if (/* should reconnect */) {
            _job.value = subscriptionJob()
          }
        }
      }
      .launchIn(coroutineScope)
  }

  private fun subscriptionJob() = coroutineScope.launch(start = CoroutineStart.LAZY) {
    suspendCancellableCoroutine {
      subscriptionCall = client.subscribe(...).apply {
        execute(object: ApolloSubscriptionCall.Callback {
          // Callback methods here that handle connection/websocket events,
          // and resume the continuation on completion/failure
        })
      }
    }
  }
}
So client code for such a class could look like:
Copy code
val subscription = ApolloSubscription(...)
// Connect to the websocket
subscription.job.value.start()
// OR
subscription.job
  .onEach {
    it.start()
  }
  .launchIn(myClientScope)
To be clear, the
Job
is just meant to model, at a high level, whether or not there is an open connection, and its state. This seems in line with the definition for
Job
, from the docs
Conceptually, a job is a cancellable thing with a life-cycle that culminates in its completion.
z
It still smells funny to me that the apollo client would expose coroutine jobs as part of its api for this purpose.
2
the 
Job
 is just meant to model
That was my point, that a coroutine job shouldn’t model anything other than a coroutine job
2
c
The Job is just work I guess? Maybe ‘model’ isn’t the right word, but there’s going to be a Job associated with my connection regardless of whether I model it this way or not.
Sorry I’m not trying to be argumentative, I am definitely interested in hearing downsides because I don’t want to paint myself into a corner. But I have a desire to be able to observe the state of a connection’s Job and e.g. cancel it, or lazily start it, if I want
z
At the very minimum, i would strongly recommend creating your own
Job
type, even if that ends up just wrapping a coroutine job internally, to give yourself room to maneuver in the future.
c
Job
is not stable for inheritance, according to the docs. That’s not something I want to pursue.
z
Yea, definitely don’t subclass it
I also don’t understand the separation of responsibilities here.
ApolloSubscription
is responsible for creating, cancelling, and recreating subscription calls, but not starting them. Why the asymmetry?
c
To be clear I’m not trying to wrap a Job or do anything special. The
StateFlow<Job>
just allows client code to observe an open connection as it changes state, reconnects, etc. This seems unremarkable to me. My original ask was whether I could observe a Job going from New -> Active state.
z
It’s not remarkable, it’s just not what coroutine jobs were designed to model, and i don’t understand why you’re trying to shoehorn this use case into a very tightly-scoped, well-defined third-party thing instead of just making your own thing that you can give your own semantics and behavior.
2
c
By not starting them, they can be lazily started by the caller
z
Maybe a better way to express my initial sentiment is that it’s generally dangerous to couple API this tightly to implementation (the fact that
ApolloSubscription
uses coroutines to manage calls, and how it does so, is an implementation detail of that class) – this is a pretty well-documented design best practice. But this whole discussion is moot anyway: If you need to observe when a coroutine job is started, i don’t think there are any hooks for that. You’ll have to do that some other way.
c
No the discussion is good… actually I only asked about whether or not I could observe Job start because I didn’t get any traction with my original post, which was soliciting feedback about the design. (Sorry I realize I misstated this before. Anyway.) I actually thought that this design decoupled the API from the implementation. I have several use cases where I want to use this pattern: Apollo, plain websockets (with Ktor), SocketIO, and Action Cable. My app uses all of these protocols and currently I have (IMO) boilerplate all over the place signalling whether or not something is in connecting or disconnecting or what have you state. The interface looks something like:
Copy code
interface StatefulConnection {
  fun connect()
  fun disconnect()
  val state: StateFlow<State>
  
  sealed interface State {
    object Initializing
    object Connecting
    object Connected
    ... others ...
  }
}
I find this creates a lot of intermediate state and is a risk for bugs. What I really want to know is if the connection is still alive or if there was an error, and I want to be able to cancel the Job. Tracking the Job that’s running the socket connection does this just fine. It doesn’t feel like shoehorning at all. (I’ve done plenty of shoehorning before… this doesn’t feel like it, though I could be wrong.)
Also to be clear, tracking the Job doesn’t preclude me from exposing other events that prove themselves useful. This is just one part of (potentially) a wider interface.
I’ll admit I took some inspiration from Ktor, which has signatures for websockets like:
Copy code
// Working from memory here...
suspend fun HttpClient.wss(request: HttpRequest, block: suspend CoroutineScope.() -> Unit)
The entire lifetime of your socket connection is contained in the
block
, which I found I liked.
In my case I’ve just made it so that implementations call
launch { /* entire lifetime of your connection is contained here */ }