Back to flin
flin

Auditoría de persistencia de la base de datos

Cómo la auditoría de persistencia de la base de datos de FLIN descubrió tres causas raíz separadas para fallos silenciosos en el guardado de entidades, y las corrigió todas en la Sesión 203.

Thales & Claude | March 30, 2026 9 min flin
EN/ FR/ ES
flinrust

Una base de datos que no persiste datos no es una base de datos. Es una caché con pretensiones. La base de datos embebida de FLIN, ZEROCORE, fue diseñada para ser invisible: los desarrolladores escriben save entity y los datos sobreviven a los reinicios del servidor. Sin configuración, sin cadenas de conexión, sin migraciones de esquema. Solo guardar y se persiste.

Excepto cuando no lo hace.

La Sesión 203 del desarrollo de FLIN fue el día en que descubrimos que los guardados de entidades estaban fallando silenciosamente. Los usuarios agregaban tareas a través de la interfaz, las veían aparecer en pantalla, y luego lo perdían todo al refrescar la página. El archivo de Write-Ahead Log se creaba, pero contenía cero bytes. La base de datos seguía los pasos de la persistencia sin realmente persistir nada.

La auditoría de esta sesión, documentada como AUDIT-SESSION-203-DATABASE-PERSISTENCE, se convirtió en uno de los ejercicios de depuración más instructivos en la historia de FLIN. No porque los errores fueran complejos -- no lo eran -- sino porque tres errores separados e independientes conspiraron para producir un único síntoma, y corregir cualesquiera dos de los tres aún dejaba el sistema roto.

El síntoma

El reporte fue simple y devastador:

Running: flin dev from embedded/todo-app/
1. User adds a todo via the UI
2. Action handler executes (returns 302 redirect)
3. Todo appears on screen
4. WAL file created: 0 bytes
5. Page refresh: all todos gone

El WAL (Write-Ahead Log) es el mecanismo de persistencia de ZEROCORE. Cada operación de guardado, eliminación y destrucción escribe una entrada de log en el archivo WAL antes de modificar el estado en memoria. En la recuperación, el WAL se reproduce para reconstruir la base de datos. Un WAL con cero bytes significa que ninguna operación fue registrada jamás, aunque la interfaz mostraba que el guardado aparentemente había tenido éxito.

La metodología de auditoría

La investigación siguió la metodología estándar de auditoría de FLIN: rastrear la ruta del código desde la solicitud HTTP hasta la escritura en disco, verificando cada paso:

  1. Rastrear el flujo de la solicitud de acción desde HTTP POST hasta el guardado en base de datos
  2. Verificar el registro del esquema de entidad
  3. Rastrear la ejecución de OpCode::Save a través de la VM
  4. Verificar los parámetros de database.save()
  5. Rastrear la ruta de escritura del WAL
  6. Comparar el flujo de pruebas vs. el flujo del servidor
rust// The investigation started here: where does the save happen?
// server/http.rs -- action handler
async fn handle_action(req: Request) -> Response {
    let vm = create_vm_for_action(&req)?;

    // Inject form data as global variables
    for (key, value) in req.form_data() {
        vm.set_global(key, Value::Text(value));
    }

    // Execute the FLIN source (which contains the save logic)
    vm.run(&compiled_bytecode)?;

    // Return redirect
    Response::redirect(302, &req.referer())
}

El flujo parecía correcto. Los datos del formulario se inyectaban como variables globales, el bytecode se ejecutaba (incluyendo la función addTodo() que realiza el guardado), y la respuesta se enviaba. Cada paso no producía errores. Y sin embargo, el WAL estaba vacío.

Causa raíz 1: El bytecode sobrescribe el estado inyectado

El primer error estaba en la interacción entre la inyección de estado y la ejecución del bytecode. Cuando el manejador de acciones inyectaba datos del formulario como variables globales, la subsecuente ejecución del bytecode re-ejecutaba la inicialización de variables de nivel superior, lo que sobrescribía los valores inyectados.

rust// The sequence of operations:

// Step 1: Action handler injects form data
vm.set_global("newTodo", Value::Text("Buy groceries"));

// Step 2: VM executes bytecode, which includes:
//   newTodo = ""  (the initialization from FLIN source)
//
// OpCode::StoreGlobal("newTodo", Value::Text(""))
// This OVERWRITES the injected "Buy groceries"!

// Step 3: addTodo() function checks:
//   if newTodo.trim() != ""
//   But newTodo is now "" -- condition is false!

// Step 4: save todo -- NEVER EXECUTED

La corrección introdujo globales protegidos -- variables que no pueden ser sobrescritas por la ejecución del bytecode:

rust// Added to VM struct
protected_globals: HashSet<String>,

// New method for action handler
pub fn set_global_protected(&mut self, name: String, value: Value) {
    self.globals.insert(name.clone(), value);
    self.protected_globals.insert(name);
}

// Modified OpCode::StoreGlobal handler
OpCode::StoreGlobal => {
    let name = self.read_constant_string(code)?;
    let value = self.pop()?;
    // Only store if not protected
    if !self.protected_globals.contains(&name) {
        self.globals.insert(name, value);
    }
}

Causa raíz 2: Value::Text no manejado por operaciones de cadena

Incluso después de corregir la Causa Raíz 1, el guardado seguía fallando. El mecanismo de globales protegidos aseguró que newTodo retuviera su valor inyectado. Pero cuando el código FLIN ejecutaba newTodo.trim(), el resultado era una cadena vacía.

El problema era que la inyección de estado crea variantes Value::Text (cadenas en línea), pero OpCode::Trim solo manejaba variantes Value::Object (cadenas asignadas en el heap). Para cualquier entrada Value::Text, la operación trim devolvía una cadena vacía:

rust// OpCode::Trim -- BEFORE fix
let s = match &string {
    Value::Object(id) => self.get_string(*id)?.trim().to_string(),
    _ => String::new(),  // BUG: Value::Text returns empty!
};

// OpCode::Trim -- AFTER fix
let s = match &string {
    Value::Object(id) => self.get_string(*id)?.trim().to_string(),
    Value::Text(t) => t.trim().to_string(),  // Handle inline strings
    _ => String::new(),
};

Este era el mismo tipo de error que el problema del CreateMap duplicado -- una falta de manejo consistente de ambas representaciones de cadenas. FLIN tiene dos representaciones de cadenas (Value::Text para cadenas cortas en línea y Value::Object apuntando a ObjectData::String asignado en el heap), y cada operación de cadenas debe manejar ambas. La operación trim no era la única infractora -- la misma brecha existía en extract_string() y potencialmente en otras operaciones de cadenas.

Causa raíz 3: Validadores causando fallos silenciosos

Con las Causas Raíz 1 y 2 corregidas, el guardado finalmente llegó a la capa de base de datos. Pero la validación de entidades lo interceptó antes de que pudiera ser persistido. La entidad todo tenía validadores:

flinentity Todo {
    title: text @required @min(1)
    done: bool = false
}

Los validadores @required y @min(1) rechazaban el guardado -- pero el rechazo era silencioso. No se devolvía ningún error al código llamante. Ningún mensaje aparecía en la consola. El validador simplemente impedía que el guardado se ejecutara y devolvía el control al llamador como si nada hubiera pasado.

rust// The validation path -- before fix
fn validate_before_save(
    &self,
    entity: &Entity,
    schema: &EntitySchema,
) -> bool {
    for (field, validators) in &schema.validators {
        for validator in validators {
            if !validator.check(entity.get(field)) {
                return false;  // Silent rejection!
            }
        }
    }
    true
}

La corrección temporal para la Sesión 203 fue eliminar los validadores de la definición de entidad. La corrección definitiva, programada para una sesión posterior, fue hacer que los fallos de validación devolvieran errores descriptivos que el desarrollador pudiera manejar.

La conspiración de los tres errores

Lo que hizo este fallo de persistencia tan difícil de diagnosticar fue la conspiración de tres errores independientes. Corregir cualesquiera dos de los tres, y el sistema seguía pareciendo roto:

  • Corregir la Causa Raíz 1 (globales protegidos) pero no la Causa Raíz 2 (trim): newTodo retiene su valor, pero .trim() devuelve vacío, así que la condición falla.
  • Corregir la Causa Raíz 1 y 2 pero no la Causa Raíz 3 (validadores): newTodo retiene su valor, .trim() funciona, pero el validador rechaza silenciosamente el guardado.
  • Corregir la Causa Raíz 2 y 3 pero no la Causa Raíz 1: .trim() funciona y los validadores están manejados, pero newTodo se sobrescribe a vacío antes de que nada de eso importe.

Los tres errores tenían que ser corregidos para que la persistencia funcionara. Este es el tipo de composición de errores que hace que depurar un runtime de lenguaje sea fundamentalmente diferente de depurar una aplicación. En una aplicación, generalmente puedes aislar el problema a una causa. En un runtime, la interacción entre subsistemas -- inyección de estado, ejecución de opcodes, manejo de tipos, validación -- crea fallos emergentes que solo se manifiestan cuando patrones de código específicos ejercitan todas las rutas rotas simultáneamente.

Verificación

Después de corregir las tres causas raíz, la auditoría creó cinco pruebas nuevas que verificaron el flujo exacto del servidor:

rust#[test]
fn test_dev_server_flow_save_entity() {
    // Basic save without conditions
}

#[test]
fn test_dev_server_flow_with_state_injection() {
    // State injection with conditional save
}

#[test]
fn test_state_injection_without_condition() {
    // Isolate state injection
}

#[test]
fn test_recovery_between_vms() {
    // Save in VM1, verify in VM2 (simulates restart)
}

#[test]
fn test_entity_queries_after_recovery() {
    // Todo.all and Todo.count after recovery
}

La verificación fue concluyente:

Before fix:
$ ls -la embedded/todo-app/.flindb/wal.log
-rw-r--r--  1 juste  staff  0 Jan 16 12:00 wal.log

After fix:
$ ls -la embedded/todo-app/.flindb/wal.log
-rw-r--r--  1 juste  staff  177 Jan 16 12:40 wal.log

Ciento setenta y siete bytes. Una sola entrada WAL para un solo elemento de tarea. La base de datos finalmente estaba persistiendo datos.

Lecciones de la auditoría de persistencia

La auditoría de persistencia de base de datos produjo cuatro principios que guiaron el desarrollo posterior de FLIN:

La inyección de estado debe ser protegida. Cuando un runtime inyecta valores en una VM para un propósito específico (como procesar datos de formulario), esos valores deben estar protegidos de la re-inicialización por el propio código del programa.

Los tipos de valores deben manejarse consistentemente. Cada operación que trabaja con cadenas debe manejar tanto Value::Text como Value::Object(String). No hay excepciones a esta regla.

Los fallos silenciosos son inaceptables. Ninguna operación -- especialmente la validación -- debería fallar sin producir una señal visible. Un guardado rechazado sin mensaje de error es peor que un fallo catastrófico, porque el desarrollador no puede diagnosticarlo.

Probar el flujo exacto de producción. Las pruebas unitarias que llaman a vm.save_entity() directamente pueden pasar mientras el flujo real del servidor falla, porque el flujo del servidor involucra pasos de inyección de estado, ejecución de bytecode y validación que la prueba unitaria omite. Las pruebas de integración deben reproducir el ciclo de vida completo de la solicitud.

La suite de pruebas creció de 2.870 a 2.875 pruebas después de esta sesión. Más importante aún, ganó cobertura de la ruta de código precisa que los usuarios de producción ejercitarían.


Esta es la Parte 151 de la serie "Cómo construimos FLIN", que documenta cómo un CEO en Abidjan y un CTO de IA diseñaron y construyeron un lenguaje de programación desde cero.

Navegación de la serie: - [150] Function Audit Day 7 Complete - [151] Auditoría de persistencia de la base de datos (estás aquí) - [152] 3.452 pruebas, cero fallos

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles