Saltar al contenido principal
Todos los casos de estudio

Ingeniería de sistemas · Rust · Concurrencia

Refactor de inicialización de base de datos en Rust y Dart FFI

Un refactor de inicialización que evita que una llamada FFI de Dart cancelada abandone migraciones de SQLite a medio camino y deje bloqueos atrás.

Estudio de ingeniería
Rol
Ingeniero de Software — concurrencia y FFI
Periodo
2026

Etapas cubiertas

  • Arquitectura
  • Construcción

De un vistazo

2026

Sistemas

  • Rust
  • Tokio
  • Aislamiento en tarea bloqueante

Datos

  • SQLite
  • Migraciones de esquema

Frontera

  • Dart FFI
  • Inicialización segura ante cancelación

Resumen

Un arreglo de concurrencia en la frontera entre Dart y Rust. Una llamada de función foránea cancelada estaba destruyendo el future que era dueño de una migración de SQLite en curso, dejando la base de datos bloqueada y el esquema aplicado a medias. El refactor desacopla la inicialización del tiempo de vida de cualquier llamador, deduplica intentos concurrentes y mantiene intacta la interfaz FFI pública.

Problema

La inicialización de la base de datos corría dentro de la tarea asíncrona creada por la llamada FFI de Dart. Si esa llamada se cancelaba — el llamador expira, la pantalla se destruye, la app pasa a segundo plano — el future se soltaba, y con él lo que la migración estuviera haciendo.

En Rust asíncrono, un future soltado simplemente se detiene en su último punto de suspensión. No hay desenrollado ni oportunidad de terminar. Si el drop ocurre entre dos sentencias de migración, el esquema queda parcialmente aplicado y la conexión de SQLite se destruye mientras todavía sostiene un bloqueo. La siguiente llamada encuentra entonces una base de datos bloqueada y en estado inconsistente — y como esa llamada corre el mismo código, falla igual.

Restricciones

  • La interfaz FFI pública no debe cambiar; los llamadores no se pueden modificar
  • Las migraciones de SQLite son trabajo bloqueante y no deben correr en el ejecutor asíncrono
  • La cancelación es normal, no excepcional — el diseño tiene que asumir que ocurre a mitad de migración
  • Tras un fallo el sistema debe poder reintentar limpiamente en vez de quedar envenenado

Rol y contribución

Ingeniero de Software — concurrencia y FFI

  • Diagnostiqué el modo de falla y lo rastreé hasta la cancelación del future en la frontera FFI
  • Rediseñé la inicialización para que su tiempo de vida sea independiente de cualquier llamador
  • Moví el trabajo bloqueante de SQLite fuera del ejecutor asíncrono
  • Agregué deduplicación para que llamadores concurrentes compartan una sola inicialización
  • Hice seguro el reintento tras un fallo, sin cambiar la interfaz pública

Diseño del sistema

Arquitectura

Ciclo de vida de la inicialización, antes y después

Antes del refactor, cada llamada FFI de Dart creaba una tarea de Tokio que corría las migraciones de SQLite directamente, así que cancelar la llamada soltaba el future a mitad de migración y dejaba la conexión bloqueada. Después del refactor, una llamada FFI obtiene un handle a una única operación de inicialización compartida. Las migraciones corren dentro de una tarea bloqueante cuyo tiempo de vida pertenece a esa operación compartida y no al llamador. Los llamadores concurrentes esperan la misma operación, y un llamador cancelado solo suelta su propio handle: la migración continúa hasta completarse y libera su bloqueo.

Sistemas
  • Rust
  • Tokio
  • Aislamiento en tarea bloqueante
Datos
  • SQLite
  • Migraciones de esquema
Frontera
  • Dart FFI
  • Inicialización segura ante cancelación

Por qué la cancelación era peligrosa aquí

La cancelación en Rust asíncrono no es una ruta de error: es la ausencia de una. Soltar un future lo detiene en el .await que haya alcanzado por último, sin notificación y sin oportunidad de limpiar más allá de lo que las implementaciones de Drop hagan por casualidad.

Para la mayoría del trabajo eso es inofensivo. Para una migración no lo es, porque una migración es una secuencia de sentencias que solo tiene sentido como un todo. Detenerse entre dos de ellas deja el esquema en un estado que ninguna versión del código espera, y la conexión que sostiene el bloqueo desaparece sin un cierre limpio.

La peor propiedad era que la falla se perpetuaba sola. El reintento corría la misma ruta cancelable y heredaba tanto el bloqueo como el esquema inconsistente, así que una sola cancelación mal cronometrada podía dejar la base de datos inusable indefinidamente.

Desacoplar el ciclo de vida del llamador

El cambio central es que la inicialización ya no pertenece a la llamada que la disparó. Un llamador obtiene un handle a una operación compartida; el tiempo de vida de la operación gobierna la migración. Cancelar un llamador suelta el handle de ese llamador y nada más — la migración corre hasta completarse y libera su bloqueo con normalidad.

Mover el trabajo a una tarea bloqueante sirve al mismo objetivo desde otra dirección. Las migraciones de SQLite son trabajo síncrono, ligado a CPU y E/S, que no tiene por qué ocupar un hilo del ejecutor asíncrono, y una tarea bloqueante no está sujeta a la misma semántica de drop-en-await que volvía destructiva la cancelación en primer lugar.

Deduplicación y reintento

Una vez que la inicialización sobrevive a su llamador, varios llamadores pueden llegar mientras sigue corriendo. Dejar que cada uno inicie su propia migración reintroduciría la contención de bloqueos que el refactor existe para eliminar, así que los intentos concurrentes se colapsan en uno: el primer llamador arranca el trabajo, todos los demás esperan el mismo resultado.

El fallo hay que manejarlo aparte del éxito. Una inicialización completada se cachea y nunca se repite; una fallida no debe cachearse, o un solo error transitorio envenenaría todo intento futuro. El estado compartido por lo tanto distingue entre 'listo' e 'intentado y fallido', y solo lo primero corta camino.

Validación

El comportamiento que vale la pena probar es el que causó el bug: cancelar un llamador a mitad de inicialización y verificar que la migración de todos modos se completa, que el bloqueo se libera y que una llamada posterior tiene éxito. Junto a eso, llamadores concurrentes deberían producir exactamente una corrida de migración, y un fallo forzado debería dejar el sistema reintentable en vez de permanentemente roto.

Como la interfaz FFI pública no cambió, los llamadores existentes ejercitan la nueva implementación sin modificación — que es la validación más útil disponible, porque la falla original solo aparecía bajo temporización real.

Decisiones clave y trade-offs

Decisión

Mover las migraciones a una tarea bloqueante en vez de correrlas en el ejecutor asíncrono

Contexto
Las migraciones de SQLite son trabajo síncrono que se estaba esperando dentro de una tarea asíncrona cancelable.
Alternativas consideradas
  • Mantener el trabajo asíncrono y protegerlo con un envoltorio seguro ante cancelación
  • Hacer cada paso de migración idempotente para que la aplicación parcial sea recuperable
Enfoque elegido
Correr la migración dentro de una tarea bloqueante propiedad de la inicialización compartida.
Razón
Corresponde con la naturaleza del trabajo y elimina por completo el riesgo de drop-en-await en vez de defenderse de él. El trabajo bloqueante en un ejecutor asíncrono es un problema por mérito propio.
Trade-off
La inicialización ocupa un hilo del pool bloqueante mientras dura, y cancelar al llamador ya no detiene el trabajo — que es el comportamiento buscado, pero sí implica que la operación no se puede abortar antes de tiempo.

Decisión

Deduplicar la inicialización concurrente en una sola operación compartida

Contexto
Una vez que la inicialización sobrevive a la cancelación del llamador, varios llamadores pueden llegar mientras está en curso.
Alternativas consideradas
  • Dejar que cada llamador inicialice y confiar en el bloqueo de SQLite para serializarlos
  • Una primitiva simple de inicialización única
Enfoque elegido
Una operación compartida que el primer llamador inicia y los siguientes esperan, con los fallos explícitamente no cacheados.
Razón
Confiar en el bloqueo de la base de datos para serializar llamadores reintroduce exactamente la contención que el refactor elimina. Una primitiva de una sola vez cachearía el primer fallo de forma permanente.
Trade-off
Más estado compartido que razonar, y la ruta de fallo necesita su propio manejo para que un error transitorio siga siendo transitorio.

Decisión

Preservar la interfaz FFI pública

Contexto
De otro modo los llamadores del lado de Dart tendrían que cambiar para acompañar un nuevo ciclo de vida.
Alternativa considerada
  • Exponer el handle de inicialización compartida a través de la frontera
Enfoque elegido
Mantener las firmas exportadas idénticas y confinar el cambio al lado de Rust.
Razón
El bug es un problema de ciclo de vida del lado de Rust. Empujarlo a través de la frontera FFI exportaría la complejidad a cada llamador y volvería el arreglo un cambio incompatible.
Trade-off
El lado de Rust absorbe toda la complejidad agregada.

Resultado

  • Una llamada FFI cancelada ya no abandona una migración ni deja la base de datos bloqueada
  • Los llamadores concurrentes producen exactamente una corrida de migración
  • Un intento fallido sigue siendo reintentable en vez de envenenar llamadas posteriores
  • No se requirieron cambios del lado de Dart

Aprendizajes

  • En Rust asíncrono, la cancelación es una entrada de diseño y no un caso de error. Cualquier operación que deba completarse no puede tener su ciclo de vida en manos de un llamador que puede desaparecer.
  • El trabajo bloqueante en un ejecutor asíncrono no es solo un problema de rendimiento: hereda una semántica de cancelación que nunca fue pensada para él.
  • Cachear el resultado de una operación de una sola vez solo es correcto para el éxito. Cachear el fallo convierte una falla transitoria en permanente.
  • Arreglar esto detrás de una interfaz sin cambios mantuvo el radio de impacto dentro de una sola frontera de lenguaje.

Próximas mejoras

  • Timeout y retroceso alrededor de la inicialización para que una migración realmente atorada se manifieste en vez de colgarse
  • Registro estructurado de las transiciones de estado de inicialización, hoy difíciles de observar desde el lado de Dart
  • Pruebas basadas en propiedades sobre la temporización de cancelación a lo largo de la secuencia de migración