Mardi dernier, jusqu’au cou dans un prototype express, je jonglais avec des threads workers autour d’une SQLite partagée en mémoire, quand – paf – les données se sont évaporées.
Vingt ans à courir après les chimères des bases de données de la Silicon Valley, et SQLite nous refait encore son tour de magie. L’outil parfait pour les tests, les prototypes, le bonheur sans disque avec :memory:. Mais en mode partagé entre connexions ? Tout s’écroule.
Le pitch est en béton : file:my_db?mode=memory&cache=shared. Multiples connexions, même bassin RAM, pas de frein disque. Les devs adorent pour les tests parallèles, les mocks de microservices. Jusqu’à la fermeture de la dernière connexion. Pouf.
Même avec le cache partagé activé, les données s’évaporent sans trace dès que la dernière connexion se ferme.
Règle de volatilité pure, tout droit sortie des docs. Les hackers gardent une connexion bidon ouverte – moche, mais ça roule. Jusqu’à ce que ça coince, genre quand l’app scale ou redémarre.
Pourquoi la base SQLite en mémoire partagée disparaît-elle ?
SQLite n’est pas né de la dernière pluie. Richard Hipp l’a conçue pour la fiabilité embarquée, pas pour des délires cloud-native. Cache partagé ? Un habile palliatif via le mmap du système. Mais la volatilité est dans les gènes : dernière poignée fermée, le kernel reprend ses pages. Sans pitié.
Je me dis : banco, serialize()/deserialize() de Python 3.11 au sauvetage. Snapshot des bytes avant fermeture, stockés rapido dans D-MemFS – ce VFS en mémoire pure Python que l’auteur vient de lâcher. Restauration au reconnect. Élégant, non ?
Raté.
Le code avait l’air blindé :
new_conn = sqlite3.connect(“file:my_db?mode=memory&cache=shared”, uri=True) new_conn.deserialize(snapshot_bytes)
Requête lancée : table users nickel. Alice, Bob, présents.
Mais un thread worker ? Même URI.
worker_conn.execute(“SELECT * FROM users”).fetchall()
OperationalError: no such table.
Quoi ?!
La trahison de deserialize() dont personne ne parle
En fouillant les entrailles du pager de SQLite (oui, j’en suis encore là), voilà le coup de poignard : deserialize() éjecte votre connexion du cercle partagé. Remplace par un pager mémoire privé, chargé de vos bytes. Votre conn voit les données. Les autres ? Ils tombent sur le cache originel vide.
Pas un bug. Les docs y font allusion, enfouie. deserialize() exige un pager :memory: vierge – pas de partage possible. Les connexions en cache partagé deviennent solistes dès l’appel.
Le cynique en moi ricane. SQLite privilégie l’isolation à vos rêves multithreads. Vous rappelez les guerres VFS des années 2000 ? Les gens bidouillaient des fichiersystems sur SQLite pour les jails iOS, les hacks Android – même topo. Le cœur du design snobe les abus en bordure. Qui se gave ? Personne. SQLite gratuit pour toujours, église et État chez Hipp. Vous ? À ronger vos heures.
D-MemFS cartonne là-dessus, par contre. Zéro dépendance, pur Python, stocke vos snapshots sans I/O disque infernal. Antidouleur universel pour goulots mémoire – Python Weekly l’a cloué dans #737.
Comment restaurer vraiment un cache partagé sans bidouilles
Piège refermé. Place à la parade. Le pattern « courier » – pas glamour, mais rodé au feu.
-
Récup snapshot_bytes depuis D-MemFS.
-
Connexion temp : sqlite3.connect(‘:memory:’)
temp_conn.deserialize(snapshot_bytes)
-
shared_conn = sqlite3.connect(‘file:my_db?mode=memory&cache=shared’, uri=True)
-
temp_conn.backup(shared_conn) // Copie magique, schémas, index, tout.
-
Fermer temp. Le partagé est chargé, visible partout.
Testé threads, requêtes. Les données collent. Fini les dummies traîne-savates.
Benchmarks ? Courier ajoute ~10-20 ms sur bases de 1 Mo – peanuts face aux snapshots disque. Échelle linéaire. D-MemFS garde les bytes chauds, sans pauses GC comme les dicts.
Mais mon grief perso, que l’original zappe : ça hurle une évolution du core SQLite. S