CodeGym /Cursos /Kotlin SELF /Acceso a BD en corutinas: Dispatchers . IO

Acceso a BD en corutinas: Dispatchers . IO

Kotlin SELF
Nivel 59, Lección 4
Disponible

1. Por que JDBC bloquea en corutinas

Cuando la gente oye por primera vez “corutinas”, el cerebro a menudo completa de mas: como si una corutina fuera un casco invisible que convierte cualquier codigo en “asincrono automaticamente”. Ojala, pero no. Si el driver JDBC ejecuta una consulta de forma sincrona, entonces el hilo realmente espera la respuesta de la BD, y la corutina en ese momento simplemente ocupa el hilo, como un gato ocupa el teclado.

La idea de las corutinas en Kotlin es que permiten escribir codigo asincrono como si fuera secuencial y suspender la ejecucion sin bloquear recursos del sistema, cuando la operacion realmente soporta suspension. Pero la mayoria de operaciones JDBC “normales” son bloqueantes: hasta que la base de datos responda, el hilo esta ocupado y no se va a ninguna parte. Por eso, las corutinas por si solas no resuelven el problema: lo resuelven la eleccion correcta del dispatcher y los limites donde ejecutamos trabajo bloqueante.

Dispatchers de corutinas y Dispatchers.IO

Para no liarse, es util pensarlo asi: una corutina es una “tarea”, y un dispatcher es “donde y en que hilos se ejecuta esa tarea”. En kotlinx.coroutines una corutina tiene un contexto, y uno de los elementos clave de ese contexto es CoroutineDispatcher, que precisamente determina en que hilos se ejecutara la corutina.

Si ejecutas una consulta JDBC bloqueante en Dispatchers.Default, te arriesgas a “saturar” un pool de hilos limitado que esta pensado principalmente para trabajo de CPU (calculos, procesamiento de colecciones, formateo, etc.). JDBC, en cambio, es espera de I/O, y tiene mas sentido que viva en Dispatchers.IO, que esta dimensionado para operaciones bloqueantes de entrada/salida (archivos, red, BD).

Imaginemos un diagrama que conviene tener en la cabeza:

flowchart LR
  A[Coroutine on Default] -->|JDBC query| B[Thread blocks]
  B --> C[Default pool is busy]
  C --> D[Other coroutines wait]

  E[Coroutine on IO] -->|JDBC query| F[Thread blocks]
  F --> G[IO pool handles blocking]
  G --> H[Default pool stays free]

La idea es simple: no haces JDBC “no bloqueante”; solo mueves el bloqueo a un sitio donde hace menos dano al resto del programa.

2. withContext(Dispatchers.IO) y el limite de la capa de persistencia

Cuando decimos “sacar lo bloqueante a IO”, en la practica casi siempre nos referimos a un patron concreto: envolver el codigo bloqueante en withContext(Dispatchers.IO) { ... }. Esto se parece a un “cambio de modo de ejecucion” para un fragmento: por fuera puedes tener cualquier contexto (por ejemplo, Default), pero dentro del bloque el codigo se ejecutara garantizadamente en el dispatcher IO.

Es importante entender la filosofia: withContext no convierte JDBC en un driver magico non-blocking. Simplemente dice: “esta parte del trabajo ejecutala en otro pool de hilos”. Esto encaja bien con que las corutinas en Kotlin se disenan alrededor del contexto de ejecucion y del dispatcher, que controla donde exactamente se ejecuta la tarea.

Una mini-envoltura que a la gente le encanta escribir en proyectos para no esparcir Dispatchers.IO por todo el codigo:

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

suspend fun <T> dbIo(block: () -> T): T =
    withContext(Dispatchers.IO) { block() }

Fijate: el bloque aqui es una lambda normal () -> T, porque las llamadas JDBC normalmente no son suspend. No “esperamos de forma coroutine”; ejecutamos codigo sincrono de verdad, solo que en otro dispatcher.

Donde debe vivir Dispatchers.IO

Dan ganas de hacer esto: “en main, antes de llamar al repositorio, envuelvo todo en withContext(IO)”. En un proyecto pequeno incluso funcionara. Pero a nivel arquitectonico, rapidamente se convierte en un caos: se te olvida envolver una llamada y, de repente, parte de las consultas van a Default. Luego anades otro caso de uso y ya no estas seguro de donde tienes IO y donde no.

Por eso introducimos una regla bastante aburrida, pero muy util: el cambio a Dispatchers.IO vive en la capa de persistencia (repository/DAO), no en el CLI ni en la logica de negocio. Ese es el “limite de la capa de persistencia”: todo el codigo externo considera el repositorio una “caja negra” que sabe trabajar correctamente con la BD.

Un mini-contrato de repositorio para nuestra aplicacion de consola de control de gastos:

import kotlinx.datetime.Instant

data class Expense(
    val id: Long,
    val title: String,
    val amountCents: Long,
    val createdAt: Instant,
)

interface ExpenseRepository {
    suspend fun add(title: String, amountCents: Long): Long
    suspend fun listLatest(limit: Int): List<Expense>
}

Desde fuera se ve simple: metodos suspend, sin tipos JDBC, sin Connection. Y eso es perfecto: el codigo externo no debe saber con que clave abres SQLite ni cuanto dolor hay en ResultSet.

3. Transacciones y contextos

Cuando empiezas a usar withContext(IO), aparece un error mental curioso: “envuelvo cada consulta SQL por separado, sera aun mas seguro”. Y aqui pillamos un matiz fino: una transaccion es un marco de atomicidad, y debe ejecutarse como una pieza completa.

Si empiezas a hacer esto: “primera consulta en withContext(IO), luego vuelvo fuera, luego segunda consulta otra vez en withContext(IO)”, logicamente empiezas a romper el escenario transaccional y aumentas el riesgo de que el codigo sea poco obvio. Tecnicamente, una transaccion JDBC vive en una Connection, y mientras no cierres la Connection y no hagas commit/rollback, todavia puedes mantenerlo en un solo sitio, y merece la pena hacerlo.

Por eso la regla se formula de forma muy practica: si una operacion requiere transaccion, entonces la transaccion y todas las consultas dentro de ella deben estar completamente dentro de un solo withContext(Dispatchers.IO).

Aqui tienes la plantilla “transaccion como helper”, corta y legible:

import java.sql.Connection

inline fun <T> inTx(conn: Connection, block: () -> T): T {
    conn.autoCommit = false
    try {
        val result = block()
        conn.commit()
        return result
    } catch (e: Exception) {
        conn.rollback()
        throw e
    } finally {
        conn.autoCommit = true
    }
}

Fijate: esto no es una funcion suspend, porque las llamadas JDBC dentro son sincronas. El suspend lo tendremos fuera, a nivel de repositorio, donde envolveremos todo en withContext(IO).

4. Ejemplo practico: Expense Tracker

Para que los ejemplos no sean un conjunto de trozos sueltos, acordemos una estructura minima del proyecto. No construimos un “enterprise ideal”, pero lo hacemos lo bastante ordenado como para que no de verguenza abrir el codigo dentro de una semana.

Esquema de dependencias (recordamos la idea de capas arquitectonicas: el CLI no debe depender de JDBC directamente):

flowchart TD
  CLI["CLI (main, comandos)"] --> S["Service (casos de uso)"]
  S --> R["ExpenseRepository (interface)"]
  R --> J["JdbcExpenseRepository (impl)"]
  J --> DB[(SQLite / BD)]

Servicio sin JDBC y sin Dispatchers.IO

Ahora bosquejemos un servicio que “hable en lenguaje humano”, y no en terminos SQL:

class ExpenseService(
    private val repo: ExpenseRepository,
) {
    suspend fun addExpense(title: String, amountCents: Long): Long {
        require(title.isNotBlank()) { "title must not be blank" }
        require(amountCents > 0) { "amount must be positive" }
        return repo.add(title.trim(), amountCents)
    }
}

El servicio no sabe nada de Dispatchers.IO. Simplemente llama a un repositorio suspend. Y sera el repositorio quien responda a “y donde se ejecuta esto”.

Repositorio JDBC con withContext(IO) dentro

Ahora haremos lo mas importante: una implementacion del repositorio que (1) trabaja con JDBC, (2) cierra recursos, (3) garantiza el dispatcher IO.

Empecemos con un pequeno “proveedor de conexiones”. En proyectos reales mas tarde aparecen pools de conexiones, pero hoy conscientemente no lo tocamos: nos importa mas la disciplina de capas y el contexto de ejecucion.

import java.sql.Connection
import java.sql.DriverManager

class DbConnectionFactory(
    private val url: String,
) {
    fun open(): Connection = DriverManager.getConnection(url)
}

Ahora la implementacion del repositorio. Primero: add(...). Fijate en tres niveles de anidamiento: withContext(IO) → open().use → prepareStatement(...).use.

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.sql.Statement

class JdbcExpenseRepository(
    private val connections: DbConnectionFactory,
) : ExpenseRepository {

    override suspend fun add(title: String, amountCents: Long): Long =
        withContext(Dispatchers.IO) {
            connections.open().use { conn ->
                val sql = "INSERT INTO expenses(title, amount_cents) VALUES (?, ?)"
                conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS).use { st ->
                    st.setString(1, title)
                    st.setLong(2, amountCents)
                    st.executeUpdate()

                    st.generatedKeys.use { rs ->
                        rs.next()
                        rs.getLong(1)
                    }
                }
            }
        }
}

Aqui hay un detalle “de mayor”: generatedKeys y rs.next() — no olvides que un ResultSet esta “antes de la primera fila”. No es un problema de corutinas; es puro ritual JDBC.

Ahora: listLatest(limit). Sacamos filas, las mapeamos a Expense y devolvemos la lista:

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.datetime.Instant

override suspend fun listLatest(limit: Int): List<Expense> =
    withContext(Dispatchers.IO) {
        connections.open().use { conn ->
            val sql = """
                SELECT id, title, amount_cents, created_at
                FROM expenses
                ORDER BY id DESC
                LIMIT ?
            """.trimIndent()

            conn.prepareStatement(sql).use { st ->
                st.setInt(1, limit)
                st.executeQuery().use { rs ->
                    val items = mutableListOf<Expense>()
                    while (rs.next()) {
                        items += Expense(
                            id = rs.getLong("id"),
                            title = rs.getString("title"),
                            amountCents = rs.getLong("amount_cents"),
                            createdAt = Instant.parse(rs.getString("created_at")),
                        )
                    }
                    items
                }
            }
        }
    }

Si, Instant.parse(...) puede lanzar una excepcion si en la BD hay una cadena “sucia”. Es normal: la capa de persistencia o bien garantiza el formato correcto (mediante esquema/validacion), o bien falla de forma honesta. Hoy no lo convertimos en un diseno de errores aparte: simplemente no escondemos el problema debajo de la alfombra.

Llamada desde main

En aplicaciones de consola, la construccion mas frecuente es runBlocking en main, porque la JVM espera un punto de entrada normal y tu quieres llamar a funciones suspend. Es importante que runBlocking es un puente entre “el mundo normal” y “el mundo suspend”.

Al mismo tiempo, es muy importante no pegar withContext(IO) alrededor de cada llamada al repositorio en main, porque entonces rompes la idea del limite de la capa de persistencia.

Mini-ejemplo: “montamos dependencias y llamamos al servicio”:

import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    val factory = DbConnectionFactory("jdbc:sqlite:app.db")
    val repo: ExpenseRepository = JdbcExpenseRepository(factory)
    val service = ExpenseService(repo)

    val id = service.addExpense("Coffee", 350)
    println("Added expense id=$id") // Added expense id=1
}

main se mantiene limpio: no sabe nada de JDBC y no sabe que dispatchers se usan por dentro. Solo sabe “hay un servicio, hay un repositorio, hay un resultado”.

5. Exposed en corutinas

Exposed a veces crea la ilusion de que “como es un DSL, entonces optimiza hilos de alguna forma sofisticada”. Pero Exposed igualmente va a la base de datos via JDBC por debajo, y por tanto, desde el punto de vista de hilos, sigue siendo la misma operacion bloqueante. Por eso la regla de Dispatchers.IO no desaparece.

El esquema incluso se vuelve mas simple: no trabajamos a mano con Connection, pero envolvemos transaction { ... } completamente dentro del contexto IO.

Supongamos que ya tienes la tabla Users/Expenses de la leccion anterior. Entonces el acceso con corutinas se veria asi:

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.transactions.transaction

suspend fun addExpenseExposed(title: String, amountCents: Long): Long =
    withContext(Dispatchers.IO) {
        transaction {
            Expenses.insert {
                it[Expenses.title] = title
                it[Expenses.amountCents] = amountCents
            } get Expenses.id
        }.value
    }

Fijate en el sentido: en withContext(IO) mantenemos toda la transaccion. Es el mismo principio de “no romper la transaccion”, solo que en Exposed se ve aun mas natural: la transaccion es un bloque.

Y otra idea importante: si dentro de transaction { } empiezas a hacer mucha logica no relacionada (parseo, lectura de archivos, llamadas de red), aumentas el tiempo de retencion de la transaccion. Por eso, dentro de la transaccion mantenemos solo lo que se refiere a la integridad de datos, y todo lo demas va fuera.

6. Errores y excepciones

En este punto, a quienes empiezan les apetece hacer “que no se caiga”: envolver todo en try/catch dentro del repositorio y devolver, por ejemplo, una lista vacia. Parece una preocupacion por el usuario, pero en la practica es enmascarar problemas. Cuando la capa de persistencia se traga una excepcion en silencio, el codigo externo sigue trabajando con suposiciones incorrectas: “simplemente no hay gastos en la BD”, cuando en realidad es “el archivo de la BD no esta disponible” o “la tabla no esta creada”.

Es mucho mas honesto seguir uno de dos estilos. O el repositorio propaga excepciones hacia fuera (y el escenario/CLI decide como informar al usuario), o el repositorio devuelve un resultado explicito “exito/error” como un tipo valor. Hoy, dentro de la leccion, nos quedamos con el primer estilo: no escondemos la excepcion, cerramos recursos con use { } (se cerraran incluso si hay excepcion), y hacemos rollback de transacciones con rollback() en la plantilla.

Mini-ejemplo: el escenario de alto nivel captura la excepcion e imprime un mensaje normal, sin convertir “error de disco” en “tienes 0 gastos”:

import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    try {
        // ... llamadas a servicios
    } catch (e: Exception) {
        println("Database error: ${e.message}") // Database error: ...
    }
}

Si, no es un manejo de errores ideal (no tipamos errores ni registramos el stack trace de forma bonita), pero ya es un modelo honesto: si la BD se rompio, no fingimos que todo esta bien.

7. Errores tipicos al acceder a BD en corutinas

Error n.º 1: ejecutar JDBC/Exposed en Dispatchers.Default “porque ya funciona”.
Este es uno de los problemas mas traicioneros: en una aplicacion pequena no veras el desastre, pero en cuanto aparezcan un par de operaciones en paralelo (o simplemente un disco/red lentos), Default empezara a quedarse bloqueado esperando. Y Default se necesita para calculos, no para esperar I/O; por eso existe la idea del dispatcher como parte del coroutine context.

Error n.º 2: esparcir withContext(Dispatchers.IO) por todo el codigo en vez de dejarlo en la capa de persistencia.
Al principio parece “rapido y comodo”: envolviste una llamada en el CLI y listo. A la semana se te olvidara envolver la segunda; a las dos, aparecera un tercer escenario, y ya no podras responder a “seguro que todas las consultas estan en IO?”. El limite de la capa de persistencia existe precisamente para que esa garantia sea centralizada.

Error n.º 3: envolver en withContext(IO) cada consulta por separado, rompiendo el escenario transaccional.
Si una operacion logicamente debe ser atomica, mantenla como un solo bloque: un withContext(IO) alrededor de toda la transaccion. Cuando la transaccion se trocea, el codigo no solo es menos eficiente, sino que tambien se lee peor: se pierde la sensacion de “aqui empieza la operacion atomica, aqui termina”.

Error n.º 4: capturar Exception dentro del repositorio y sustituirlo todo por println para “que no se caiga”.
Ese repositorio se convierte en un agujero negro que se come los errores. El codigo externo deja de entender que pasa: “no hay datos” y “no se pudo conectar a la BD” se vuelven indistinguibles. Es mejor o bien propagar la excepcion o bien devolver un resultado de error explicito, pero no fingir que todo esta bien.

Error n.º 5: mantener Connection o transaction { } demasiado tiempo, mezclandolo con logica no relacionada.
A veces la transaccion accidentalmente empieza a incluir entrada del usuario, formateo, lectura de archivo, calculos, y eso alarga el tiempo de retencion de recursos de BD. Incluso si aun no estudias niveles de aislamiento ni concurrencia de transacciones, hay un habito util: la transaccion debe ser compacta y contener solo lo que realmente se refiere a la consistencia de datos.

Error n.º 6: olvidar que las corutinas no “hacen el codigo no bloqueante automaticamente”.
Puedes lanzar una corutina, pero si dentro llamas a un metodo JDBC bloqueante, el hilo seguira ocupado esperando. El modelo de corutinas promete un estilo de codigo comodo y control del contexto de ejecucion, pero no cancela la fisica del I/O. Por eso Dispatchers.IO y unos limites cuidadosos siguen siendo obligatorios.

1
Tarea
Kotlin SELF, nivel 59, lección 4
Bloqueada
Envoltorio para IO
Envoltorio para IO
1
Tarea
Kotlin SELF, nivel 59, lección 4
Bloqueada
Repositorio oculto
Repositorio oculto
1
Tarea
Kotlin SELF, nivel 59, lección 4
Bloqueada
Recuento de filas
Recuento de filas
1
Tarea
Kotlin SELF, nivel 59, lección 4
Bloqueada
Transferencia en IO
Transferencia en IO
1
Cuestionario/control
Bases de datos, nivel 59, lección 4
No disponible
Bases de datos
Bases de datos
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION