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