Isolating Failures with SupervisorJob
A child coroutine's crash cancels its siblings and parent by default; SupervisorJob breaks that wire so one failed audit write can't take down anything else.
The last lesson left one question open: what happens when the `launch`ed Mongo write throws? Say the write fails — a connection pool exhausted, a malformed document, MongoDB rejecting an oversized field. In synchronous code that exception just climbs the call stack to whoever called your function. In coroutine code it does something structurally different, and if you don't know the difference, one failed audit write can turn into an outage that has nothing to do with MongoDB.
Every `CoroutineScope` carries a `Job` in its context, and every coroutine you `launch` from that scope becomes a child of that `Job`. By default, a `Job` links failure in both directions: an unhandled exception in a child cancels its parent, and a cancelled parent cancels every one of its remaining children — including that failing child's own siblings. This is deliberate, and it's the right behavior for the common case: structured concurrency assumes that if one part of a set of related tasks fails, the rest were probably working toward the same goal and should stop too. That's exactly right for the three provider calls behind a single valuation — if Carfax's call fails in a way that invalidates the valuation, cancelling the still-running Black Book and S&P VIS calls is the correct move.
A fire-and-forget audit write has no such relationship to anything else sharing its scope. It has nothing to do with, and should have zero influence over, the write for a different VIN running concurrently on the same application-scoped `CoroutineScope`. If that scope's `Job` is a plain `Job`, one bad document cancels the whole scope's `Job`, and every other in-flight write riding on it gets cancelled mid-write — turning one MongoDB hiccup into a fleet-wide interruption of the audit log for everyone calling the service in that moment. It's the worst kind of bug: rare, timing-dependent, and it looks nothing like its actual cause.
`SupervisorJob()` is a `Job` implementation that inverts exactly this one part of the relationship: a child's failure no longer cancels the supervisor or the supervisor's other children. Build the service's scope on a `SupervisorJob`, and each direct child launched from it succeeds or fails on its own. `supervisorScope { ... }` is the equivalent suspend-function builder when you want that isolation for one region of code rather than for a scope's entire lifetime. One nuance worth being precise about: the isolation applies only to direct children of the supervisor — a regular `Job` nested inside one of those children still propagates failure through its own subtree as usual, so `SupervisorJob` isolates siblings at the level you place it, not everything transitively beneath it.
Isolating the failure is only half the job. The failure hasn't disappeared — it's just stopped being contagious. Nothing is calling `.join()` or `.await()` on a fire-and-forget `launch`, so nobody is asking 'did that finish okay?' Left alone, the exception simply vanishes: no caller catches it, because there is no caller. Your `valuation_event` collection could stop growing for an hour before anyone notices.
`CoroutineExceptionHandler` is the context element that closes that gap. Install one alongside the `SupervisorJob`, and it gets invoked with the failing coroutine's context and its uncaught `Throwable` whenever a coroutine started with `launch` (never `async` — a `Deferred`'s exception only surfaces through `.await()`) fails without the failure being handled some other way. It's the last line of defense for fire-and-forget work: log the exception with enough detail to reprocess the event, increment a Datadog counter so `valuation.audit_write.failed` shows up on a dashboard — anything except letting it disappear.
Put a `SupervisorJob` and a `CoroutineExceptionHandler` on the same scope, and you get the whole shape lesson 17 wires into `VehicleValuationService`: a failed write to Mongo gets logged and counted, every other in-flight write for every other request keeps running untouched, and the HTTP layer — which already sent its response — never even knows the difference.
import kotlinx.coroutines.*fun main() = runBlocking {val scope = CoroutineScope(Job())val sibling = scope.launch {delay(200)println("Sibling write finished") // never printed}scope.launch {delay(50)throw RuntimeException("Mongo write failed")}delay(300)println("Sibling job was cancelled: ${sibling.isCancelled}")}
A plain Job as the scope's Job: the failing sibling's exception (printed to stderr, since nothing catches it) cancels the other sibling too.
Arena IDEimport kotlinx.coroutines.*fun main() = runBlocking {val handler = CoroutineExceptionHandler { _, throwable ->println("Audit write failed, logging and moving on: ${throwable.message}")}val scope = CoroutineScope(SupervisorJob() + handler)val sibling = scope.launch {delay(200)println("Sibling write finished")}scope.launch {delay(50)throw RuntimeException("Mongo write failed")}delay(300)println("Sibling job was cancelled: ${sibling.isCancelled}")}
The same failure on a SupervisorJob with a CoroutineExceptionHandler: the handler logs it, and the sibling finishes normally.
Arena IDEsuspend fun persistAuditTrail(events: List<ValuationEvent>) = supervisorScope {events.forEach { event ->launch {valuationEventRepository.save(event)}}}
supervisorScope applied to a batch of writes: one bad document in the batch can't cancel the others.
🧠 Check your understanding
0/1 · 0/1 answered1. VehicleValuationService's application-scoped CoroutineScope uses a plain Job() (not a SupervisorJob) and has ten valuation_event writes in flight for ten different requests. One of them throws because Mongo rejected an oversized document. What happens to the other nine?