Aislando Fallos con SupervisorJob
El fallo de una corrutina hija cancela por defecto a sus hermanas y a su padre; SupervisorJob rompe ese cable para que una escritura de auditoría fallida no arrastre nada más.
La lección anterior dejó una pregunta abierta: ¿qué pasa cuando la escritura a Mongo lanzada con `launch` lanza una excepción? Supongamos que la escritura falla — un pool de conexiones agotado, un documento mal formado, MongoDB rechazando un campo demasiado grande. En código síncrono, esa excepción simplemente sube por la pila de llamadas hasta quien llamó a tu función. En código con corrutinas ocurre algo estructuralmente distinto, y si no conoces la diferencia, una sola escritura de auditoría fallida puede convertirse en una interrupción que no tiene nada que ver con MongoDB.
Todo `CoroutineScope` lleva un `Job` en su contexto, y toda corrutina que lanzas con `launch` desde ese scope se convierte en hija de ese `Job`. Por defecto, un `Job` enlaza el fallo en ambas direcciones: una excepción no manejada en una hija cancela a su padre, y un padre cancelado cancela a cada una de sus hijas restantes — incluidas las hermanas de la que falló. Esto es deliberado, y es el comportamiento correcto para el caso común: la concurrencia estructurada asume que si una parte de un conjunto de tareas relacionadas falla, el resto probablemente estaba trabajando hacia el mismo objetivo y también debería detenerse. Eso es exactamente correcto para las tres llamadas a proveedores detrás de una sola valuación — si la llamada a Carfax falla de una forma que invalida la valuación, cancelar las llamadas a Black Book y S&P VIS que aún están en curso es lo correcto.
Una escritura de auditoría fire-and-forget no tiene esa relación con nada más que comparta su scope. No tiene nada que ver con, y no debería tener ninguna influencia sobre, la escritura de otro VIN corriendo al mismo tiempo en el mismo `CoroutineScope` de aplicación. Si el `Job` de ese scope es un `Job` normal, un documento defectuoso cancela el `Job` de todo el scope, y cada otra escritura en curso montada sobre él se cancela a mitad de camino — convirtiendo un tropiezo de MongoDB en una interrupción generalizada del registro de auditoría para todo el que esté llamando al servicio en ese momento. Es el peor tipo de bug: poco frecuente, dependiente del momento exacto, y no se parece en nada a su causa real.
`SupervisorJob()` es una implementación de `Job` que invierte exactamente esa parte de la relación: el fallo de una hija ya no cancela al supervisor ni a las demás hijas del supervisor. Construye el scope del servicio sobre un `SupervisorJob`, y cada hija directa lanzada desde él tiene éxito o falla por su cuenta. `supervisorScope { ... }` es el builder equivalente para funciones suspend, cuando quieres ese aislamiento para una región de código concreta en vez de para toda la vida de un scope. Vale la pena precisar un matiz: el aislamiento aplica solo a las hijas directas del supervisor — un `Job` normal anidado dentro de una de esas hijas sigue propagando el fallo por su propio subárbol de la manera habitual, así que `SupervisorJob` aísla a los hermanos en el nivel donde lo colocas, no a todo lo que hay transitivamente por debajo.
Aislar el fallo es solo la mitad del trabajo. El fallo no ha desaparecido — simplemente ha dejado de ser contagioso. Nadie llama a `.join()` ni a `.await()` sobre un `launch` fire-and-forget, así que nadie está preguntando '¿eso terminó bien?'. Dejado a su suerte, la excepción simplemente se esfuma: ningún llamador la captura, porque no hay llamador. Tu colección `valuation_event` podría dejar de crecer durante una hora antes de que alguien lo note.
`CoroutineExceptionHandler` es el elemento de contexto que cierra ese hueco. Instala uno junto al `SupervisorJob`, y se invocará con el contexto de la corrutina que falló y su `Throwable` no capturado cada vez que una corrutina arrancada con `launch` (nunca con `async` — la excepción de un `Deferred` solo aflora a través de `.await()`) falle sin que el fallo se haya manejado de otra forma. Es la última línea de defensa para el trabajo fire-and-forget: registra la excepción con suficiente detalle como para reprocesar el evento, incrementa un contador de Datadog para que `valuation.audit_write.failed` aparezca en un dashboard — cualquier cosa menos dejar que desaparezca.
Pon un `SupervisorJob` y un `CoroutineExceptionHandler` en el mismo scope, y obtienes exactamente la forma que la lección 17 conecta dentro de `VehicleValuationService`: una escritura fallida a Mongo queda registrada y contabilizada, cada otra escritura en curso de cada otra petición sigue corriendo sin verse afectada, y la capa HTTP — que ya envió su respuesta — ni siquiera nota la diferencia.
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.
🧠 Comprueba tu comprensión
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?