
VFS SQLite avec des requêtes JOIN à froid en moins de 100 ms depuis S3 + compression et chiffrement au niveau des pages
turbolite est un VFS SQLite en Rust qui sert des recherches ponctuelles et des jointures directement depuis S3 avec une latence à froid inférieure à 250 ms.
Ce dépôt est un espace de travail Cargo avec deux crates :
turbolite — Bibliothèque Rust pure. VFS SQLite avec compression au niveau de la page, chiffrement et hiérarchisation S3.turbolite-ffi — FFI C / extension chargeable + liaisons de langage (Python, Node.js, Go).Il offre également une compression au niveau de la page (zstd) et un chiffrement (AES-256) pour l'efficacité et la sécurité au repos, qui peuvent être utilisés séparément de S3.
Expérimental. turbolite est en développement actif et contient des bugs. Soyez prudent.
Le stockage d'objets devient rapide. S3 Express One Zone fournit des GET en quelques millisecondes et Tigris est également extrêmement rapide. L'écart entre le disque local et le stockage cloud se réduit, et turbolite en tire parti.
La conception et le nom sont inspirés par l'approche de turbopuffer qui consiste à architecturer sans pitié autour des contraintes du stockage cloud. L'objectif initial du projet était de battre les démarrages à froid de plus de 500 ms de Neon. Objectif atteint.
Si vous avez une base de données par serveur, utilisez un volume. turbolite explore comment avoir des centaines ou des milliers de bases de données (une par locataire, une par espace de travail, une par appareil), sans vouloir un volume pour chacune, et vous acceptez une source d'écriture unique.
turbolite est distribué sous forme de bibliothèque Rust, d'extension chargeable SQLite (.so/.dylib), et de paquets de langage pour Python et Node.js, ainsi que des dépendances Github pour Go. Tout stockage compatible S3 fonctionne (AWS S3, Tigris, R2, MinIO, etc.). C'est un VFS SQLite standard opérant au niveau de la page, donc la plupart des fonctionnalités SQLite devraient fonctionner : FTS, R-tree, JSON, mode WAL, etc.
turbolite fait partie de l'écosystème plus large hadb. turbolite autonome est un VFS de stockage avec un seul écrivain sûr ; si vous voulez une élection de leader HA plus une réplication WAL continue, utilisez-le via haqlite-turbolite, qui superpose HaQLite et walrust. Ce chemin HA est encore très expérimental.
Si vous souhaitez contribuer à turbolite ou trouver des bugs, veuillez créer une pull request ou ouvrir une issue.
1M publications / 100K utilisateurs (~1,5 Go stockés) sans rien en cache, chaque octet depuis S3. EC2 c5.2xlarge + S3 Express One Zone (même AZ, ~4ms de latence GET). Fly performance-8x + Tigris (~25ms de latence GET). Les deux : 8 vCPU dédiés, 16 Go de RAM, 7 threads de travail de prélecture. Voir Benchmarking et Le backend de stockage compte.
Les benchmarks sont organisés par niveau de cache (ce qui est déjà sur le disque local lorsque la requête s'exécute) :
intérieur est le benchmark à froid le plus réaliste : les pages intérieures se chargent avec impatience à l'ouverture de la connexion, donc au moment où vous exécutez votre première requête, elles sont mises en cache. Les pages d'index sont agressivement préchargées au premier accès en arrière-plan et peuvent ne pas être encore prêtes.
100K lignes, Fly.io performance-2x (vCPU dédié, NVMe, IAD) :
Les recherches ponctuelles ont la surcharge par page la plus élevée (~2x). Tout le reste approche ou dépasse la parité. L'architecture de cache sans verrou signifie que les lectures concurrentes ne bloquent jamais les écritures.
| Après | Local | S3 (RustFS même région) |
|---|---|---|
| 1 000 insertions | 19ms | 38ms |
| Lot de 10 000 | 17ms | 114ms |
| 1 000 mises à jour | 9ms | 36ms |
Les écritures sont toujours à la vitesse locale. Le coût S3 est uniquement au point de contrôle. Nombres avec RustFS dans la même région Fly (~2ms RTT). S3 Express One Zone serait comparable.
pip install turbolite
**Avantages de l'utilisation d'une CA comme étape préalable à la simulation d'accès initial :**
> * Augmente la discrétion de l'infrastructure de l'attaquant par rapport à l'envoi direct d'un e-mail.
> * Les étapes de vérification nécessaires pour valider le domaine cible créent une piste d'audit de la maturité de sécurité des équipes bleues de la cible.
> * La corrélation des correctifs et mises à jour de sécurité avec le cycle de développement des logiciels malveillants d'accès initial de l'acteur malveillant et l'évolution des charges utiles de phishing aide à déterminer l'état de la posture de sécurité de la cible.
> * Donne à l'infrastructure d'attaque le temps de mûrir, en s'assurant que les domaines sont catégorisés et que les services de vérification de réputation ont le temps de se mettre à jour, ce qui peut faire la différence entre une campagne de phishing réussie et une campagne bloquée par les passerelles de messagerie sécurisées (SEG).
C'est la raison pour laquelle des organisations comme `PwC` et `KPMG` interdisent à leurs équipes rouges d'envoyer des e-mails directement ; c'est trop risqué et ne fournit pas suffisamment de données. Dans de nombreux cas, les tests sont réalisés avec la connaissance du phishing par les défenseurs (brèche présumée ou travail en collaboration avec l'équipe de sécurité interne), mais le test ne couvre pas l'ensemble de la chaîne d'attaque, laissant un angle mort. Dans les cadres réglementaires tels que `CBEST`, `TIBER-EU`, `iCAST` et `AASE`, le purple teaming et la simulation d'adversaire sont combinés à la veille sur les menaces pour garantir un test réaliste de l'ensemble de la surface d'attaque, réalisé selon les normes les plus élevées.
[PhishMailer](https://github.com/BiZken/PhishMailer) a été testé avec :
* Hotmail
* Outlook
* Gmail
* Yahoo
* ProtonMail ([ULA](https://github.com/BiZken/PhishMailer/issues/119))
* Orange.fr
* ...
* ... et bien d'autres
> Une documentation plus complète est disponible dans le [wiki](https://github.com/BiZken/PhishMailer/wiki).```python
import turbolite
conn = turbolite.connect("my.db", mode="s3",
bucket="my-bucket",
endpoint="https://t3.storage.dev")
conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)")
conn.execute("INSERT INTO users VALUES (1, 'alice', '[email protected]')")
conn.commit()
alice = conn.cursor().execute("SELECT * FROM users").fetchone()
print(alice[1])
>>> "alice"
Voir Installation pour Node, Go, Rust, le mode local uniquement, et l'utilisation directe de l'extension chargeable .so
turbolite est conçu pour les contraintes de S3 plutôt que celles du système de fichiers. Chaque décision découle de ce modèle :
turbolite ajoute des couches d'introspection et d'indirection entre SQLite et S3 qui regroupent, compressent, suivent et récupèrent efficacement les pages.
SQLite utilise un index B-tree et demande une page à la fois. Il sait que la page N se trouve au décalage d'octet N * page_size. Et ces pages sont distribuées aléatoirement dans la pagemap pour un accès aléatoire efficace. Mais sur S3, récupérer une page par requête signifierait des milliers de GET potentiellement aléatoires par requête.
Mais les pages ne sont pas créées de manière égale. SQLite a différents types de pages. turbolite sépare les groupes de pages par type : pages intérieures B-tree, feuilles d'index et feuilles de données.
Les pages intérieures sont touchées à chaque requête pour acheminer les recherches vers les pages feuilles. turbolite les détecte, les stocke dans des lots compressés sur S3 et les charge avec empressement à l'ouverture du VFS. Après cela, chaque parcours d'arbre B est un succès de cache.
Les pages feuilles d'index reçoivent le même traitement : lots séparés, prélecture paresseuse en arrière-plan, épinglées contre l'éviction. Les requêtes à froid n'ont besoin de récupérer que les pages de données.
turbolite tire parti de l'introspection B-tree pour comprendre de quel arbre (une table ou un index) une page fait partie, et stocke intelligemment ces pages ensemble sur S3 sous forme de groupes de pages : plusieurs pages regroupées dans un seul objet S3. Assez grand pour saturer la bande passante lors de la prélecture, assez petit pour les requêtes ponctuelles. Par défaut : 256 pages par groupe, ~16 Mo pour des pages de 64 Ko.
Stocker la même table/index ensemble signifie que nous effectuons le moins de GET possible pour les requêtes à froid.
turbolite indirecte les recherches de pages avec un fichier manifeste qui est la source de vérité pour savoir où se trouve chaque page. Il remplace le offset = page * size implicite de SQLite par des pointeurs explicites. Les anciennes versions des groupes de pages ne sont jamais écrasées ; le PUT du manifeste est le point de commit atomique. Les anciennes versions deviennent des déchets, nettoyés par gc().
SQLite utilise par défaut des pages de 4 Ko pour correspondre à la taille de page du disque du système de fichiers. Sur S3, la taille de page du disque est sans importance. Ce qui compte, c'est minimiser le nombre de requêtes et maximiser le fan-out de l'arbre B. La réponse est les grandes pages : turbolite utilise par défaut des pages de 64 Ko. Moins de pages = moins d'allers-retours S3 pour atteindre une feuille.
Pour accélérer les requêtes ponctuelles, turbolite utilise la compression adressable (seekable compression) : chaque groupe de pages est encodé en plusieurs trames zstd (~4 pages par trame). Le manifeste stocke les décalages d'octets par trame, de sorte qu'un défaut de cache récupère uniquement le sous-ensemble de ~256 Ko contenant la page nécessaire via un GET de plage S3, et non l'ensemble du groupe.
La prélecture a deux couches : proactive (lancement anticipé du plan de requête) et réactive (adaptative basée sur les défauts).
Le lancement anticipé du plan de requête (query-plan frontrunning) s'exécute en premier. Avant qu'une requête ne s'exécute, turbolite intercepte le plan de requête SQLite via EXPLAIN QUERY PLAN, extrait les tables et index exacts que la requête va toucher, et soumet tous leurs groupes de pages au pool de prélecture avant même que la première page ne soit lue. Une jointure de cinq tables qui déclencherait autrement cinq cycles séquentiels de défaut puis récupération lance plutôt les cinq récupérations en parallèle au début de la requête. Pour les requêtes SCAN, cela signifie que la table entière est préchargée d'avance.
Attention : SQLite supporte un seul rappel de trace par connexion. Si une autre extension revendique l'emplacement en premier, le lancement anticipé revient silencieusement à la prélecture réactive.
La prélecture réactive gère ce que le lancement anticipé manque et sert de repli. En cas de défaut de cache, deux choses se produisent simultanément :
Les compteurs de défauts sont suivis par arbre B, pas globalement. Une requête de profil qui touche users (défaut 1) puis posts (défaut 1) suit correctement chaque arbre à 1, pas 2. Cela empêche une jointure multi-table d'escalader accidentellement la prélecture sur chaque arbre simplement parce qu'elle en touche plusieurs.
Chaque défaut consécutif progresse à travers un calendrier de prélecture qui contrôle quelle fraction des groupes du même arbre précharger. turbolite sélectionne automatiquement un calendrier en fonction du plan de requête :
[0.3, 0.3, 0.4] : pour les requêtes SEARCH ... USING INDEX qui parcourent des portions inconnues des index. Agressif dès le premier défaut car nous ne savons pas quelle partie de l'index sera parcourue.[0.0, 0.0, 0.0] : pour les requêtes ponctuelles et les recherches d'index qui touchent 1-2 pages par arbre. Trois sauts gratuits avant toute prélecture. Les calendriers à dominante zéro surpassent les montées en charge précoces sur S3 Express et Tigris.Vous pouvez ajuster le calendrier de prélecture à l'ouverture en définissant prefetch.search / prefetch.lookup sur TurboliteConfig - vous connaissez la forme de charge de travail attendue, donc le VFS n'a pas à deviner. Voir Configurer la prélecture.
Les deux calendriers tirent parti de l'introspection B-tree : chaque groupe préchargé contient garantiement des pages de l'arbre correct. Un exemple : si SQLite demande une page de la table users, puis en demande une autre de la même table, turbolite suppose qu'un scan arrive et précharge le reste de la table users en arrière-plan, et rien d'autre. Sans l'introspection B-tree, il récupérerait accidentellement la moitié de la table users et la moitié de la table posts simplement parce que les données sont côte à côte sur le disque.
L'anticipation des feuilles d'index (Index-leaf lookahead) fait de même pour les SEARCH indexés. Une feuille d'index liste déjà les rowids de table que SQLite va demander — donc turbolite les résout via les pages intérieures en cache et précharge ces trames de table en un seul lot au lieu d'une par une, réduisant le nombre de requêtes.
turbolite possède son propre cache de pages en mémoire qui remplace le cache de pages intégré de SQLite. Le pagineur de SQLite met en cache les pages en interne et ne relit jamais depuis le VFS pour les pages en cache. C'est acceptable pour les bases de données à un seul écrivain, mais pour les réplicas en lecture (suiveurs HA, lecteurs par sondage du manifeste), le cache de SQLite devient obsolète lorsque les données sous-jacentes changent via la réplication.
Le cache de turbolite est conscient du manifeste : lorsque set_manifest() se déclenche (nouvelles données issues de la réplication), il invalide les pages affectées à la fois dans le cache disque et le cache en mémoire. Les écritures invalident également leurs pages dans le cache en mémoire. Cela garantit des lectures fraîches après la réplication ou les écritures.
Architecture :``` SQLite (PRAGMA cache_size=0) -> turbolite VFS xRead -> in-memory page cache (64MB default, AtomicPtr, zero-lock reads) -> disk cache (NVMe pread) -> S3 (on miss)
**Configuration :**
- `cache.mem_budget` sur `TurboliteConfig` (octets). Défaut : 64 Mo.
- Variable d'environnement `TURBOLITE_MEM_CACHE_BUDGET` (ex. `128MB`, `1GB`).
- Mettre à `0` pour désactiver complètement le cache en mémoire.
`turbolite.connect()` (Python/Go/TypeScript) désactive automatiquement le cache de pages de SQLite et utilise celui de turbolite à la place. Les consommateurs Rust utilisant `Connection::open_with_flags_and_vfs` directement doivent définir `PRAGMA cache_size=0` pour obtenir le même comportement.
### Chiffrement et Compression
#### Compression
Toutes les données sont compressées avec zstd avant stockage. Les groupes de pages utilisent un encodage multi-trames seekable qui compresse indépendamment chaque trame (~4 pages, ~256 Ko), de sorte qu'une recherche ponctuelle ne décompresse que la trame pertinente plutôt que l'intégralité du groupe de pages. Des dictionnaires zstd personnalisés peuvent améliorer davantage les taux de compression.
Le mode local (non S3) compresse également au niveau de la page avec zstd. Voir l'interface en ligne de commande pour les outils d'entraînement de dictionnaires.
#### Chiffrement
Si le chiffrement est activé, turbolite chiffre tout : les objets S3, le cache local, le WAL, les métadonnées. Les données S3 utilisent AES-256-GCM avec des nonces aléatoires par trame (authentifié, détection de falsification). Les données locales utilisent AES-256-CTR avec un surcoût de taille nul. Le chiffrement a lieu après la compression : `texte en clair → zstd → chiffrer → S3`.
**Rotation de clé :** `rotate_encryption_key(config, new_key)` rechiffre, ajoute ou supprime le chiffrement sur toutes les données S3 sans décompresser. `Some` vers `Some` fait pivoter les clés, `Some` vers `None` supprime le chiffrement, `None` vers `Some` l'ajoute. Sûr en cas de panne : les anciens objets ne sont jamais écrasés, le téléchargement du manifeste est le point de validation atomique, et une étape de vérification confirme que les nouvelles données sont lisibles avant la validation. Les orphelins d'exécutions partielles sont nettoyés par `gc()`.
## Forces et Limitations
### Là où turbolite est rapide
**Les recherches ponctuelles sont le point idéal.** Au niveau de cache `index`, une recherche ponctuelle récupère 1 à 2 sous-blocs via un GET de plage S3 (~100 Ko chacun). Les pages intérieures et d'index sont déjà en cache. Au niveau de cache `none`, ajoutez environ 120 ms pour la re-récupération intérieure + la première page de données. Cela fonctionne sur n'importe quelle taille de machine.
**Les parcours avec suffisamment de cœurs.** Le pool de prefetch sature la bande passante S3 avec une planification adaptative par arbre. Les requêtes de recherche augmentent agressivement le prefetch dès le premier échec ; les requêtes SCAN conscientes du plan préchargent en masse la table entière dès le départ. Des threads suffisants peuvent synchroniser des bases de données de plusieurs Go en quelques secondes avec 2 à 3 lots de prefetch.
### Là où turbolite est lent
**Les parcours sur petites machines.** Avec 1 thread de prefetch, un parcours de 1,46 Go prend des secondes, pas des millisecondes. Le goulot d'étranglement est les allers-retours S3 : chaque saut récupère les groupes en série. Si votre première requête est un parcours complet sur une machine à 1 vCPU, attendez-vous à un démarrage pénible.
**Mauvais réglage des threads.** Trop peu de threads de prefetch et les parcours bloquent en attendant S3. Trop et le travail SQLite de premier plan commence à entrer en compétition avec les téléchargements. La valeur par défaut (`max(num_cpus - 1, 1)`) laisse un cœur pour le travail de premier plan, mais les charges de travail lourdes en parcours sur de grandes bases de données ont toujours besoin de suffisamment de CPU.
**Pénalité de la première requête.** La première requête au niveau de cache `none` paie environ 50 à 200 ms pour le chargement des pages intérieures plus au moins une récupération de données. Si la requête a besoin d'une page d'index avant la fin du prefetch en arrière-plan, elle revient à un GET de plage en ligne.
### Limitations actuelles
- **Turbolite autonome est mono-écrivain.** Deux machines écrivant directement sur le même préfixe corrompront le manifeste.
- **Le mode HA/basculement est expérimental et se trouve dans `haqlite-turbolite`.** Cette pile combine les baux HaQLite, le nivellement de pages turbolite et la réplication continue du WAL de walrust. C'est le chemin prévu pour les déploiements multi-nœuds, pas l'accès multi-écrivain direct à un préfixe turbolite.
- **L'expédition du WAL est expérimentale.** Nécessite l'indicateur de fonctionnalité `wal` + walrust. Voir [Durabilité](#durabilite).
Fonctionnalités SQLite qui **fonctionnent** : FTS, R-tree, JSON, mode WAL, mode journal DELETE, VACUUM, autovacuum.
## Réglages
### Paramètres généraux
| Paramètre | Ce qu'il contrôle | Défaut |
|-----------|-----------------|---------|
| `prefetch.threads` | Threads de travail pour les récupérations S3 parallèles | max(num_cpus - 1, 1) |
| `cache.pages_per_group` | Pages par objet S3, plus grand = moins de PUTs, plus d'octets par récupération | 256 |
| `cache.gc_enabled` | Supprimer les anciennes versions de groupes de pages après un point de contrôle | true |
| `sync_mode` | Durabilité du point de contrôle : `Durable` (envoi S3 dans le point de contrôle) ou `LocalThenFlush` (différer l'envoi) | Durable |
### Planifications de prefetch
L'anticipation du plan de requête (voir Architecture) est le mécanisme principal de prefetch. Les planifications réactives ci-dessous agissent comme secours lorsque l'anticipation n'est pas disponible ou lorsque les requêtes accèdent à des pages qui n'étaient pas dans le plan.
| Stratégie | Quand | Planification par défaut | Ce qui se passe |
|----------|------|-----------------|--------------|
| **SCAN** (anticipation) | EQP dit `SCAN table` | Tous les groupes dès le départ | Préchargement en masse de la table entière avant la première lecture. Aucune planification de saut nécessaire. |
| **SEARCH** (réactif) | EQP dit `SEARCH ... USING INDEX` | `[0.3, 0.3, 0.4]` | Prefetch agressif dès le premier échec ; parcourt les portions d'index inconnues. |
| **Lookup** (réactif) | Requêtes ponctuelles, pas d'info EQP | `[0.0, 0.0, 0.0]` | Trois sauts gratuits, zéro prefetch. Les requêtes ponctuelles bénéficient rarement du prefetch. |
Chaque élément est la fraction de groupes frères à précharger lors du Nième échec de cache consécutif par arbre. Lorsque les échecs dépassent la longueur du tableau, fraction=1.0 (tout le reste).
**Pourquoi deux planifications réactives ?** Les requêtes SEARCH parcourent des portions inconnues d'index/table et ont besoin d'un échauffement agressif. Les Lookups touchent 1 à 2 pages par arbre et ont à peine besoin de prefetch. Les compteurs d'échecs par arbre assurent un suivi indépendant : une requête de profil touchant les utilisateurs (échec 1) puis les publications (échec 1) suit chaque arbre séparément.
### Configuration du prefetch
Définissez `prefetch.search` et `prefetch.lookup` sur `TurboliteConfig` lors de la construction du VFS :```rust
use turbolite::tiered::{TurboliteConfig, PrefetchConfig};
let config = TurboliteConfig {
prefetch: PrefetchConfig {
search: vec![0.4, 0.3, 0.3],
lookup: vec![0.0, 0.0, 0.2],
query_plan: true,
..Default::default()
},
..Default::default()
};
Pour le réajustement par requête sans rouvrir la connexion, utilisez la fonction SQL turbolite_config_set (Phase Cirrus c). Chaque poussée est limitée au handle de la connexion appelante et reste en vigueur jusqu'à ce que vous la modifiiez à nouveau :```sql
SELECT turbolite_config_set('prefetch_search', '0.5,0.5,0.0');
SELECT turbolite_config_set('prefetch_lookup', '0.0,0.0,0.0');
SELECT * FROM posts WHERE created_at > ?; -- runs with the new schedule
### Anticipation des feuilles d'index
Lorsqu'une requête utilise un index pour trouver des lignes de table (`SEARCH ... USING INDEX`), la feuille d'index que SQLite lit nomme déjà les rowids de table qu'il s'apprête à récupérer. L'anticipation analyse ces rowids, les résout en leurs frames de feuilles de table via les pages intérieures mises en cache, et précharge les frames en un seul lot — de sorte que les lignes de table arrivent ensemble au lieu d'un aller-retour S3 à la fois.
Il est **activé par défaut** et ne s'engage que pour un `SEARCH` indexé qui remonte dans une table. Les scans, les lectures point par rowid et les requêtes entièrement chaudes empruntent le chemin normal inchangé, il y a donc rarement une raison de le désactiver. Il nécessite le préchargement du plan de requête (`plan_aware`, vrai par défaut).
Le seul cas pour le désactiver est une charge de travail entièrement chaude et sensible au CPU, où l'analyse de chaque feuille d'index coûte un peu et ne précharge rien car les pages sont déjà en cache :```sql
SELECT turbolite_config_set('lookahead', 'false');
Ou définissez lookahead sur TurboliteConfig ou la variable d'environnement TURBOLITE_LOOKAHEAD à l'ouverture.
Les appelants Rust peuvent invoquer le même chemin via turbolite::tiered::settings::set.
Remarque : le préchargement est par connexion. Chaque nouvelle connexion démarre avec des compteurs de défauts par arbre froids. Le cache est partagé, donc une deuxième connexion bénéficie des pages mises en cache par la première.
Les plannings de préchargement optimaux dépendent du compromis latence-bande passante de votre backend S3. Nous avons testé 10 paires de plannings sur 6 requêtes à la fois sur S3 Express (~4ms GET) et Tigris (~25ms GET) :
Sur S3 Express, off/off (aucun préchargement) est étonnamment compétitif pour les requêtes ponctuelles car chaque GET de sous-plage de sous-chunk ne coûte que ~4ms. L'écart entre « aucun préchargement » et « préchargement optimal » est faible (23% pour les consultations ponctuelles) car les GET individuels sont bon marché. Sur Tigris, la même requête bénéficie beaucoup plus du préchargement (jusqu'à 39% sur idx-filter) car chaque aller-retour inutile coûte 25ms.
L'effet pratique : sur les backends à haute latence, poussez plus fort les plannings de recherche et gardez les plannings de consultation avec plus de zéros en tête. Sur S3 Express, les valeurs par défaut fonctionnent bien et l'optimisation apporte des gains plus faibles. Les performances des scans complets sont insensibles au planning sur les deux backends car l'anticipation du plan de requête précharge en masse toute la table en amont.
Utilisez tiered-tune (voir ci-dessous) pour trouver les plannings optimaux pour votre backend et vos requêtes spécifiques.
tiered-tune se connecte à une base de données turbolite existante et balaye les plannings de préchargement par rapport à vos requêtes réelles. Au lieu de deviner les plannings, exécutez votre charge de travail réelle et laissez l'outil trouver la meilleure paire :```bash
cargo run --release --features cloud,zstd --bin tiered-tune --
--prefix "databases/tenant-123"
--query "SELECT * FROM users WHERE id = ?1"
--query "SELECT p.*, u.name FROM posts p JOIN users u ON p.user_id = u.id WHERE p.id = ?1"
--iterations 10
cargo run --release --features cloud,zstd --bin tiered-tune --
--prefix "databases/tenant-123"
--query "SELECT * FROM orders WHERE user_id = ?1 ORDER BY created_at DESC LIMIT 20"
--search-schedules "0.3,0.3,0.4;0.5,0.5;1.0"
--lookup-schedules "0;0,0,0.1;0,0,0,0.1,0.2"
--iterations 10
La sortie est un tableau de comparaison par requête (comme `tiered-bench --matrix`) montrant le p50, p90, le nombre de GET et les octets pour chaque paire de planifications. L'outil recommande une planification et affiche l'affectation `TurboliteConfig` pour l'appliquer.
## Durabilité
turbolite est une couche de stockage, pas un système de réplication. La durabilité dépend du moment où les données atteignent S3.
**Après le point de contrôle** : les groupes de pages + le manifeste sont dans S3. S3 offre 11 neufs de durabilité. Ces données survivent à la perte de la machine.
**Entre les points de contrôle** : les écritures ne vivent que dans le WAL local sur le disque local. Si la machine tombe en panne avant le prochain point de contrôle, ces écritures sont perdues.
La fréquence des points de contrôle contrôle le compromis : des points de contrôle plus fréquents = fenêtre de données à risque plus petite mais plus de PUT S3. La valeur par défaut est l'auto-checkpoint de SQLite (toutes les 1000 trames WAL).
### Modes de point de contrôle
turbolite prend en charge deux modes de point de contrôle via `sync_mode` dans `TurboliteConfig` :
**`SyncMode::Durable`** (par défaut). Le point de contrôle télécharge les groupes de pages vers S3 tout en maintenant le verrou EXCLUSIF de SQLite. Simple, entièrement durable à chaque point de contrôle. Aucune écriture ni lecture ne peut se poursuivre jusqu'à la fin du téléchargement. Bon pour la plupart des charges de travail.
**`SyncMode::LocalThenFlush`**. Le point de contrôle écrit uniquement dans le cache disque local (~1 ms de maintien du verrou), puis libère le verrou. L'appelant télécharge vers S3 séparément via `flush_to_s3()`, pendant lequel les lectures et les écritures se poursuivent normalement. Cela est utile pour les charges de travail lourdes en écriture où le blocage des lecteurs pendant la durée d'un téléchargement S3 est inacceptable.
Entre le point de contrôle et le flush, les données n'existent que dans le cache disque local. Un crash de processus est acceptable (les données sont sur le disque local, et les journaux de staging capturent le contenu exact des pages à télécharger). La perte de la machine avant le flush signifie que ces écritures sont perdues. L'éviction du cache est sûre : turbolite protège automatiquement les pages en attente de l'éviction.
**Récupération après crash** : Si le processus plante entre le point de contrôle et le flush, les journaux de staging survivent sur le disque. Lors du prochain `TurboliteVfs::new()`, ils sont automatiquement récupérés et mis en file d'attente pour le prochain appel `flush_to_s3()`. Les lectures sont servies immédiatement depuis le cache local sans attendre le flush.
### Expédition WAL (expérimental)
Avec le flag de fonctionnalité `wal` activé, turbolite expédie les trames WAL vers S3 via [walrust](https://github.com/russellromney/walrust), comblant ainsi l'écart de durabilité entre les écritures individuelles et les points de contrôle.```toml
# Cargo.toml
turbolite = { version = "0.5", features = ["cloud", "zstd", "wal"] }
intégré ou non.
-l ou --list : Lister les méthodes d'accès disponibles-p ou --port : Port du serveur web, par défaut 8901--ssl : Activer HTTPS avec un certificat auto-signé--cert : Chemin vers le certificat PEM personnalisé--key : Chemin vers la clé PEM personnalisée-t ou --thread : Nombre maximal de threads pour le service-i ou --ip : Adresse IP à laquelle lier le serveurEn utilisant ce programme, vous aurez besoin de Python 3.x installé sur votre machine.```rust let config = TurboliteConfig { wal_replication: true, // enable WAL shipping ..Default::default() };
turbolite et walrust restent synchronisés via le curseur de rejeu stocké comme `manifest.change_counter`. Les chemins d'importation/point de contrôle initialisent ce curseur à partir du compteur de changement de fichier de SQLite ; la relecture directe de pages peut l'avancer jusqu'à la dernière séquence de modifications validée. Au démarrage à froid, turbolite matérialise la base de données à partir des groupes de pages, puis walrust rejoue les segments WAL avec txid > `change_counter` pour récupérer les écritures survenues après le dernier point de contrôle.
**Modèle de durabilité avec expédition WAL** : chaque transaction validée est expédiée vers S3 en tant que segment WAL dans l'intervalle de synchronisation (par défaut 100 ms). Si la machine tombe en panne, au plus un intervalle de synchronisation d'écritures est perdu. Après un point de contrôle, les segments WAL avec txid <= `change_counter` sont automatiquement collectés comme déchets.
L'expédition WAL est complémentaire à SyncMode : SyncMode contrôle comment les points de contrôle atteignent S3, l'expédition WAL rend les écritures individuelles durables avant le point de contrôle.
### Modèle de cohérence
Un seul rédacteur, lecteurs instantanés. Un seul processus écrit ; les lecteurs voient le dernier manifeste validé lorsqu'ils ont ouvert. turbolite n'est pas une base de données distribuée et ne coordonne pas entre plusieurs rédacteurs.
## Mode local (sans S3)
turbolite fonctionne également comme un VFS local compressé/chiffré :
Compression : zstd (par défaut), lz4, snappy, gzip. Avec zstd, vous pouvez entraîner et intégrer des dictionnaires de compression personnalisés et les faire tourner automatiquement pour une compression plus efficace. Des tailles de page plus grandes compressent mieux. Voir CLI pour les outils d'entraînement.
Chiffrement : AES-256-GCM par page.
Le fonctionnement au niveau de la page signifie que la plupart des fonctionnalités de SQLite fonctionnent encore : FTS, R‑tree, JSON, mode WAL. La plupart des autres extensions de compression/chiffrement de SQLite opèrent au niveau du fichier ou nécessitent des versions personnalisées.
## Installation
Ce dépôt est un espace de travail Cargo. La crate `turbolite` est la bibliothèque Rust pure à la racine de l'espace de travail. Les liaisons de langage et l'extension chargeable se trouvent dans `turbolite-ffi/`.
**Python** : `pip install turbolite` — voir [turbolite-ffi/packages/python/](https://github.com/russellromney/turbolite/blob/HEAD/turbolite-ffi/packages/python/)```python
import turbolite
# Local compressed (no S3 needed)
conn = turbolite.connect("my.db")
# S3 cloud
conn = turbolite.connect("my.db", mode="s3", bucket="my-bucket", endpoint="https://t3.storage.dev")
# Manual extension loading for full control
import sqlite3
conn = sqlite3.connect(":memory:")
turbolite.load(conn)
conn.close()
conn = sqlite3.connect("file:my.db?vfs=turbolite", uri=True) # local
# For S3, prefer turbolite.connect(..., mode="s3", bucket=..., prefix=...).
# It registers a per-database VFS so multiple S3 volumes can share one process.
Node.js: npm install turbolite — voir turbolite-ffi/packages/node/
Rust :```toml [dependencies] turbolite = "0.5" # local VFS turbolite = { version = "0.5", features = ["cloud"] } # + S3 storage turbolite = { version = "0.5", features = ["encryption"] } # + encryption
**Go** (cgo, lie la bibliothèque partagée):```bash
make lib-bundled # build libturbolite.{so,dylib}
// #cgo LDFLAGS: -L/path/to/target/release -lturbolite
// #include <stdlib.h>
// extern int turbolite_register_local_file_first(const char* name, const char* db_path, int level);
// extern void* turbolite_open(const char* path, const char* vfs_name);
// extern int turbolite_exec(void* db, const char* sql);
// extern char* turbolite_query_json(void* db, const char* sql);
// extern void turbolite_close(void* db);
import "C"
Le turbolite_register_local_file_first(name, db_path, level) recommandé est basé sur le chemin de base de données visible par l'utilisateur. La fonction de niveau inférieur turbolite_register_local(name, cache_dir, level) est toujours exportée pour les intégrateurs qui souhaitent gérer eux-mêmes le répertoire de cache. Voir examples/go/ pour un exemple complet de serveur HTTP.
Construisez l'extension chargeable pour n'importe quel langage avec load_extension de SQLite :```bash
make ext # produces target/release/turbolite.{so,dylib}
ENTRÉE:```c
sqlite3_enable_load_extension(db, 1);
sqlite3_load_extension(db, "path/to/turbolite", NULL, NULL);
// "turbolite" VFS (local) is always registered
// "turbolite-s3" is a single-volume convenience VFS when TURBOLITE_BUCKET is set
Pour le scénario utilisateur file-first, enregistrez un VFS par base de données qui possède le app.db de l'appelant :```sql
SELECT turbolite_register_file_first_vfs('app', '/data/app.db');
-- now open /data/app.db via vfs=app; turbolite stores its sidecar
-- metadata at /data/app.db-turbolite/.
Pour configurer le VFS par défaut `"turbolite"` pour le mode fichier en premier au moment du chargement de l'extension, définissez `TURBOLITE_DATABASE_PATH=/data/app.db` dans l'environnement avant de charger l'extension. Le sidecar est alors `/data/app.db-turbolite/` et le paramètre de niveau inférieur `TURBOLITE_CACHE_DIR` est ignoré.
### Node.js```bash
npm install turbolite
intelligent - Ajoute un petit ensemble de répertoires ciblés d'intérêt, avec des mots-clés prioritaires pour chaque répertoire, offrant un juste milieu entre rapidité et exhaustivité.```js
const { connect } = require("turbolite");
// File-first: /data/app.db is the local page image. // /data/app.db-turbolite/ holds hidden implementation state. const db = connect("/data/app.db"); db.exec("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)"); db.prepare("INSERT INTO users VALUES (?, ?)").run(1, 'alice');
const rows = db.prepare("SELECT id, name FROM users").all(); // [{ id: 1, name: 'alice' }] db.close();
`db` est une base de données better-sqlite3 standard. `connect()` enregistre pour vous un VFS file-first par base de données. Pour exporter un fichier SQLite standard (par exemple pour l'inspecter avec la CLI `sqlite3`), utilisez l'API de sauvegarde better-sqlite3 : `await db.backup('export.sqlite')`. Voir [turbolite-ffi/packages/node/](https://github.com/russellromney/turbolite/blob/HEAD/turbolite-ffi/packages/node/) pour la documentation complète.
### Rust (local, fichier en premier)```rust
use turbolite::tiered::{TurboliteVfs, TurboliteConfig};
// `app.db` is the user-visible local page image.
// `app.db-turbolite/` holds hidden implementation state.
let config = TurboliteConfig::for_database_path("/data/app.db");
let vfs = TurboliteVfs::new_local(config)?;
turbolite::tiered::register("turbolite", vfs)?;
let conn = rusqlite::Connection::open_with_flags_and_vfs(
"/data/app.db",
rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE | rusqlite::OpenFlags::SQLITE_OPEN_CREATE,
"turbolite",
)?;
Le formulaire de bas niveau vous permet de choisir directement le répertoire de cache :```rust let config = TurboliteConfig { cache_dir: "/path/to/data".into(), // turbolite owns this dir ..Default::default() };
Dans ce cas, l'image locale est `/path/to/data/data.cache` plutôt qu'un `app.db` nommé par l'appelant. Les nouveaux embedders devraient préférer la forme fichier d'abord.
### Rust (S3 cloud)```rust
use turbolite::tiered::{TurboliteVfs, TurboliteConfig};
use hadb_storage::StorageBackend;
let config = TurboliteConfig::for_database_path("/data/app.db");
let storage: Arc<dyn StorageBackend> = /* your S3 backend */;
let vfs = TurboliteVfs::with_backend(config, storage, tokio::runtime::Handle::current())?;
turbolite::tiered::register("turbolite", vfs)?;
let conn = rusqlite::Connection::open_with_flags_and_vfs(
"/data/app.db",
rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE | rusqlite::OpenFlags::SQLITE_OPEN_CREATE,
"turbolite",
)?;
app.db est l'image de page compressée de turbolite. Il n'est pas garanti de pouvoir être ouvert directement par le sqlite3 standard. Pour un fichier SQLite normal (par exemple pour l'interface CLI sqlite3), utilisez l'API de sauvegarde en ligne de SQLite ou l'outil d'exportation spécifique à la liaison (conn.iterdump() en Python, db.backup() en Node).
turbolite fournit une interface CLI pour inspecter, gérer et interagir avec les bases de données turbolite sans écrire de Rust.```bash cargo install turbolite --features cloud,zstd
### Commandes```bash
# Inspect a database manifest
turbolite info --db my.db
turbolite info --db my.db --bucket my-bucket --endpoint https://t3.storage.dev
# Interactive SQLite shell (with turbolite VFS)
turbolite shell --db my.db
turbolite shell --db my.db --bucket my-bucket --read-only
# Download entire database from S3 into local cache
turbolite download --db my.db --bucket my-bucket --threads 8
# Export to plain SQLite (for migration or backup)
turbolite export --db my.db --output plain.db
# Import a plain SQLite file into turbolite S3 format
turbolite import --input plain.db --bucket my-bucket --prefix databases/my-db
Toutes les commandes S3 acceptent les flags --bucket, --prefix, --endpoint, et --region, ou lisent depuis les variables d'environnement TURBOLITE_BUCKET, TURBOLITE_PREFIX, AWS_ENDPOINT_URL, et AWS_REGION.
Il existe de nombreux projets dans le domaine de SQLite sur réseau. turbolite emprunte des idées à tous.
L'approche la plus courante : placer un fichier .db non modifié sur S3 ou un CDN et émettre des GET HTTP de plage lorsque SQLite lit une page.
.dbi optionnel qui pré-collecte les nœuds internes de l'arbre B pour le préchargement - la même idée que les bundles de pages internes de turbolite. Conçu pour se composer avec sqlite_zstd_vfs.Ce sont tous en lecture seule et récupèrent des pages non compressées depuis le fichier brut. Une recherche ponctuelle transfère une page brute de 4 Ko (ou 64 Ko) par requête.
Ceux-ci traitent le stockage d'objets comme source de vérité et répliquent des pages individuelles ou des ensembles de modifications, permettant des répliques partielles et des déploiements hors ligne / périphériques.
orbitinghail/graft) : Un moteur de stockage transactionnel pour la réplication paresseuse, partielle et fortement cohérente sur S3. L'extension SQLite libgraft implémente un VFS qui lit et écrit des pages de 4 Ko à travers les volumes Graft. Utilise la compression zstd tramée et des ensembles de modifications basés sur des éclats. Le cousin architectural le plus proche de turbolite dans l'espace "répliquer les pages, pas les trames WAL", avec un accent sur la synchronisation périphérique multi-écrivains plutôt que sur la latence de lecture à froid.Ceux-ci répliquent les écritures locales vers S3 pour la sauvegarde ou la restauration.
wa-sqlite. Même modèle un-objet-par-page, adapté à une utilisation WASM / côté client.Tous les tests de performance se trouvent dans benchmark/. Voir benchmark/README.md pour les scénarios de déploiement (local, Fly.io, EC2).
Le binaire tiered-bench génère un ensemble de données de réseau social (utilisateurs, publications, likes, amitiés) et teste les performances des requêtes à chaque niveau de cache par rapport à S3.
Un banc d'essai séparé benchmark/bench_s3vfs.py exécute les mêmes requêtes contre sqlite-s3vfs pour une comparaison directe. Il est déployé via benchmark/fly-s3vfs.toml et utilise le même générateur d'ensemble de données déterministe que tiered-bench.```bash
TIERED_TEST_BUCKET=my-bucket AWS_ENDPOINT_URL=https://t3.storage.dev
cargo run --features zstd,cloud --bin tiered-bench --release --
--sizes 100000
cargo run --features zstd,cloud --bin tiered-bench --release --
--sizes 1000000 --prefetch-threads 8 --queries post --modes interior
cargo run --example quick-bench --features encryption --release
Indicateurs clés : `--sizes` (compteurs de lignes), `--ppg` (pages par groupe), `--prefetch-threads`, `--prefetch-search` (planification SEARCH), `--prefetch-lookup` (planification de lookup), `--grouping` (positionnelle ou btree), `--queries` (post/profile/who-liked/mutual), `--modes` (none/interior/index/data), `--skip-verify` (ignorer COUNT(*) sur les petites machines), `--iterations`, `--plan-aware` (activer le préchargement anticipé), `--matrix` (paires de planification de balayage). Planifications par requête : `--post-prefetch`/`--post-lookup`, `--profile-prefetch`/`--profile-lookup`, etc. (la recherche et la consultation sont indépendantes par requête).```bash
# Matrix mode: test 10 schedule pairs x 6 queries at cold level
cargo run --features zstd,cloud --bin tiered-bench --release -- \
--sizes 1000000 --import auto --plan-aware --matrix --iterations 10
# Tune schedules for your own database and queries
cargo run --features zstd,cloud --bin tiered-tune --release -- \
--prefix "databases/my-db" \
--query "SELECT * FROM users WHERE id = ?1" --param 42 \
--plan-aware --iterations 10
cargo test --features zstd # local VFS tests cargo test --features zstd,cloud # + S3 integration tests cargo test --features zstd,encryption # + encryption tests
## Notes
turbolite était auparavant nommé `sqlite-compress-encrypt-vfs`, alias `sqlces`.
### Détails du modèle de sécurité
Les données S3 utilisent AES-256-GCM avec des nonces aléatoires uniques par trame (authentifiée, détection de falsification). Les fichiers locaux utilisent AES-256-CTR avec des nonces déterministes (numéro de page / décalage d'octet), offrant une confidentialité contre les attaquants au repos du disque. Les nonces déterministes de CTR signifient que des attaquants multi-instantanés pourraient récupérer le XOR des textes clairs à des décalages réutilisés, ce qui correspond au compromis de l'extension SEE de SQLite. Le cache local est éphémère et recréable à partir de S3.
## License
Apache-2.0
| Requête | Type | À froid (S3 Express) | À froid (Tigris) |
|---|
| Publication + utilisateur | recherche ponctuelle + jointure | 86ms | 172ms |
| Profil | jointure multi-table (5 JOINs) | 251ms | 479ms |
| Qui a aimé | recherche d'index + jointure | 206ms | 302ms |
| Amis communs | jointure multi-recherche | 19ms | 49ms |
| Filtre indexé | analyse d'index couvert | 79ms | 88ms |
| Analyse complète + filtre | analyse complète de table | 476ms | 532ms |
| Niveau de cache | Ce qui est mis en cache | Ce qui est récupéré depuis S3 | Quand cela se produit |
|---|
| aucun | rien | tout | Premier démarrage, cache vide |
| intérieur | pages d'arbre B intérieures | pages d'index + de données | Première requête après ouverture de connexion |
| index | pages intérieures + d'index | pages de données uniquement | Fonctionnement normal de turbolite |
| données | tout | rien | Équivalent à SQLite local |
| Opération | SQLite | turbolite | Surcharge |
|---|
| Recherche ponctuelle | 145K/s | 73K/s | 2.0x |
| Analyse de plage | 8.8K/s | 8.3K/s | parité |
| Analyse complète de table | 56/s | 60/s | parité |
| INSERT | 19K/s | 23K/s | parité |
| MISE À JOUR par PK | 40K/s | 27K/s | 1.5x |
| INSERT par lots (dans txn) | 685K/s | 740K/s | parité |
| Contrainte S3 | Implication |
|---|
| Les allers-retours sont lents | Minimiser le nombre de requêtes. Écrire par lots, pré-lire de manière agressive. |
| La bande passante est un goulot d'étranglement | Maximiser l'utilisation de la bande passante. |
| Les PUT et GET sont facturés par opération | Un GET de 64 Ko coûte la même chose qu'un GET de 16 Mo. Optimiser le nombre de requêtes, pas l'efficacité en octets. |
| Les objets sont immuables | Ne jamais mettre à jour sur place. Écrire de nouvelles versions, échanger un pointeur. Pas de corruption d'écriture partielle. |
| Le stockage est bon marché | Ne pas optimiser pour l'espace. Sur-approvisionner, conserver les anciennes versions, laisser le GC nettoyer plus tard. |
| Charge de travail | Configuration | Pourquoi |
|---|
| OLTP mixte | Valeurs par défaut | Le plan-aware gère les scans, le planning de recherche réchauffe les index, le planning de consultation reste conservateur. |
| Charge en points (BD d'agents) | prefetch.lookup: vec![0.0, 0.0, 0.0] | Les consultations n'ont presque jamais besoin de préchargement. |
| Analytiques à scans intensifs | prefetch.search: vec![0.5, 0.5], prefetch.query_plan: true | Réchauffement agressif de la recherche plus préchargement en masse plan-aware. |
| Conservateur (serverless à rafales) | prefetch.search: vec![0.1, 0.2, 0.3], prefetch.lookup: vec![0.0, 0.0, 0.1] | Bruit minimal de préchargement. |
| Backend | Latence GET | Meilleure consultation ponctuelle | Meilleur profil | Gain d'optimisation |
|---|
| S3 Express | ~4ms | 74ms (off/off: 96ms) | 188ms (off/off: 212ms) | 5-23% par rapport à l'absence de préchargement |
| Tigris | ~25ms | 192ms (off/off: 231ms) | 524ms (off/off: 616ms) | 8-34% par rapport à l'absence de préchargement |
| turbolite | Raw-file range GETs | Litestream VFS | sqlite_web_vfs + zstd_vfs | mvsqlite | Graft | sqlite-s3vfs |
|---|
| Lectures depuis S3 | GET de plage adressables sur des groupes de pages compressées | GET de plage sur des pages brutes | GET de plage sur des fichiers LTX | GET de plage sur une base externe compressée | recherches KV sur FoundationDB | récupération paresseuse de pages 4 Ko / ensembles de modifications | un GetObject par page |
| Écritures vers S3 | point de contrôle (un PUT par groupe) | non | non | non | oui (MVCC) | oui (réplication asynchrone d'ensembles de modifications) | un PUT par page |
| Compression | zstd multi-trame adressable | aucun | aucun | zstd (base imbriquée) | encodage delta zstd | zstd tramé | aucun |
| Chiffrement | AES-256-GCM par page | aucun | aucun | aucun | aucun | aucun listé | aucun |
| Préchargement | anticipation + planification de saut | aucun ou prélecture de base | cache LRU | consolidation adaptative | tampons clients | paresseux / à la demande | aucun |
| Optimisation des pages internes | détectées, épinglées, regroupées séparément | aucun | index de pages à partir des traînes LTX | fichier .dbi optionnel | aucun | aucun listé | aucun |
| Octets par recherche ponctuelle (cache : index) | ~100 Ko (une trame compressée) | 4-64 Ko (une page brute) | varie | varie | varie | 4 Ko (une page) | 4 Ko (une page) |
| Coût d'écriture pour 4096 pages | ~$0.000005 (un PUT) | n/a | n/a | n/a | opérations FoundationDB | ensembles de modifications par lots | ~$0.02 (4096 PUT) |