Une base de données qui ne persiste pas les données n'est pas une base de données. C'est un cache avec des prétentions. La base de données embarquée de FLIN, ZEROCORE, a été conçue pour être invisible -- les développeurs écrivent save entity et les données survivent aux redémarrages du serveur. Aucune configuration, aucune chaîne de connexion, aucune migration de schéma. On sauvegarde et ça persiste.
Sauf quand ce n'est pas le cas.
La Session 203 du développement de FLIN fut le jour où nous avons découvert que les sauvegardes d'entités échouaient silencieusement. Les utilisateurs ajoutaient des tâches via l'interface, les voyaient apparaître à l'écran, puis perdaient tout au rafraîchissement de la page. Le fichier Write-Ahead Log était créé, mais il contenait zéro octet. La base de données simulait la persistance sans rien persister réellement.
L'audit de cette session, documenté sous AUDIT-SESSION-203-DATABASE-PERSISTENCE, est devenu l'un des exercices de débogage les plus instructifs de l'histoire de FLIN. Non pas parce que les bugs étaient complexes -- ils ne l'étaient pas -- mais parce que trois bugs distincts et indépendants conspiraient pour produire un seul symptôme, et corriger deux des trois laissait toujours le système cassé.
Le symptôme
Le rapport était simple et dévastateur :
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 goneLe WAL (Write-Ahead Log) est le mécanisme de persistance de ZEROCORE. Chaque opération de sauvegarde, suppression et destruction écrit une entrée dans le fichier WAL avant de modifier l'état en mémoire. Lors de la récupération, le WAL est rejoué pour reconstruire la base de données. Un WAL de zéro octet signifie qu'aucune opération n'a jamais été journalisée -- pourtant l'interface montrait que la sauvegarde avait apparemment réussi.
La méthodologie d'audit
L'investigation a suivi la méthodologie d'audit standard de FLIN -- tracer le chemin du code depuis la requête HTTP jusqu'à l'écriture sur disque, en vérifiant chaque étape :
- Tracer le flux de la requête d'action depuis le POST HTTP jusqu'à la sauvegarde en base
- Vérifier l'enregistrement du schéma de l'entité
- Tracer l'exécution de
OpCode::Saveà travers la VM - Vérifier les paramètres de
database.save() - Tracer le chemin d'écriture du WAL
- Comparer le flux de test vs. le flux serveur
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())
}Le flux semblait correct. Les données du formulaire étaient injectées comme variables globales, le bytecode s'exécutait (incluant la fonction addTodo() qui effectue la sauvegarde), et la réponse était envoyée. Chaque étape ne produisait aucune erreur. Et pourtant, le WAL était vide.
Cause racine 1 : le bytecode écrase l'état injecté
Le premier bug résidait dans l'interaction entre l'injection d'état et l'exécution du bytecode. Lorsque le gestionnaire d'action injectait les données du formulaire comme variables globales, l'exécution subséquente du bytecode relançait l'initialisation des variables de premier niveau -- ce qui écrasait les valeurs injectées.
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 EXECUTEDLa correction a introduit des variables globales protégées -- des variables qui ne peuvent pas être écrasées par l'exécution du 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);
}
}Cause racine 2 : Value::Text non géré par les opérations sur les chaînes
Même après avoir corrigé la cause racine 1, la sauvegarde échouait toujours. Le mécanisme de variables globales protégées garantissait que newTodo conservait sa valeur injectée. Mais lorsque le code FLIN exécutait newTodo.trim(), le résultat était une chaîne vide.
Le problème était que l'injection d'état crée des variantes Value::Text (chaînes en ligne), mais OpCode::Trim ne gérait que les variantes Value::Object (chaînes allouées sur le tas). Pour toute entrée Value::Text, l'opération trim retournait une chaîne vide :
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(),
};C'était la même catégorie de bug que le problème du CreateMap dupliqué -- un échec à gérer les deux représentations de chaînes de manière cohérente. FLIN possède deux représentations de chaînes (Value::Text pour les petites chaînes en ligne et Value::Object pointant vers un ObjectData::String alloué sur le tas), et chaque opération sur les chaînes doit gérer les deux. L'opération trim n'était pas la seule fautive -- la même lacune existait dans extract_string() et potentiellement d'autres opérations sur les chaînes.
Cause racine 3 : les validateurs provoquent des échecs silencieux
Avec les causes racines 1 et 2 corrigées, la sauvegarde atteignait enfin la couche base de données. Mais la validation de l'entité l'interceptait avant qu'elle ne puisse être persistée. L'entité todo avait des validateurs :
flinentity Todo {
title: text @required @min(1)
done: bool = false
}Les validateurs @required et @min(1) rejetaient la sauvegarde -- mais le rejet était silencieux. Aucune erreur n'était retournée au code appelant. Aucun message n'apparaissait dans la console. Le validateur empêchait simplement la sauvegarde de s'exécuter et rendait le contrôle à l'appelant comme si rien ne s'était passé.
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 correction temporaire pour la Session 203 consistait à supprimer les validateurs de la définition de l'entité. La correction définitive, planifiée pour une session ultérieure, consistait à faire en sorte que les échecs de validation retournent des erreurs descriptives que le développeur puisse gérer.
La conspiration des trois bugs
Ce qui rendait cette défaillance de persistance si difficile à diagnostiquer était la conspiration de trois bugs indépendants. Corrigez deux des trois, et le système semble toujours cassé :
- Corriger la cause racine 1 (variables globales protégées) mais pas la cause racine 2 (trim) :
newTodoconserve sa valeur, mais.trim()retourne une chaîne vide, donc la condition échoue. - Corriger les causes racines 1 et 2 mais pas la cause racine 3 (validateurs) :
newTodoconserve sa valeur,.trim()fonctionne, mais le validateur rejette silencieusement la sauvegarde. - Corriger les causes racines 2 et 3 mais pas la cause racine 1 :
.trim()fonctionne et les validateurs sont gérés, maisnewTodoest écrasé à vide avant que quoi que ce soit ne compte.
Les trois bugs devaient être corrigés pour que la persistance fonctionne. C'est le type de composition de bugs qui rend le débogage d'un runtime de langage fondamentalement différent du débogage d'une application. Dans une application, on peut généralement isoler le problème à une seule cause. Dans un runtime, l'interaction entre sous-systèmes -- injection d'état, exécution d'opcodes, gestion des types, validation -- crée des défaillances émergentes qui ne se manifestent que lorsque des patterns de code spécifiques exercent tous les chemins défaillants simultanément.
Vérification
Après avoir corrigé les trois causes racines, l'audit a créé cinq nouveaux tests vérifiant le flux exact du serveur :
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 vérification était concluante :
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.logCent soixante-dix-sept octets. Une seule entrée WAL pour un seul élément todo. La base de données persistait enfin les données.
Leçons de l'audit de persistance
L'audit de persistance de la base de données a produit quatre principes qui ont guidé le développement ultérieur de FLIN :
L'injection d'état doit être protégée. Lorsqu'un runtime injecte des valeurs dans une VM pour un objectif spécifique (comme le traitement des données de formulaire), ces valeurs doivent être protégées contre la réinitialisation par le propre code du programme.
Les types de valeurs doivent être gérés de manière cohérente. Chaque opération qui travaille sur des chaînes doit gérer à la fois Value::Text et Value::Object(String). Il n'y a aucune exception à cette règle.
Les échecs silencieux sont inacceptables. Aucune opération -- en particulier la validation -- ne devrait échouer sans produire un signal visible. Une sauvegarde rejetée sans message d'erreur est pire qu'un crash, car le développeur ne peut pas la diagnostiquer.
Testez le flux exact de production. Les tests unitaires qui appellent vm.save_entity() directement peuvent réussir tandis que le flux réel du serveur échoue, parce que le flux serveur implique l'injection d'état, l'exécution du bytecode et les étapes de validation que le test unitaire saute. Les tests d'intégration doivent reproduire le cycle de vie complet de la requête.
La suite de tests est passée de 2 870 à 2 875 tests après cette session. Plus important encore, elle a gagné une couverture du chemin de code précis que les utilisateurs en production emprunteraient.
Ceci est la partie 151 de la série « Comment nous avons construit FLIN », documentant comment un CEO à Abidjan et un CTO IA ont conçu et construit un langage de programmation à partir de zéro.
Navigation de la série : - [150] Function Audit Day 7 Complete - [151] Audit de la persistance de la base de données (vous êtes ici) - [152] 3 452 tests, zéro échec