oximail-store est le socle de stockage dont dépend chaque crate de domaine. Il définit trois traits de stockage : Store (métadonnées structurées), BlobStore (contenu binaire) et SearchStore (recherche plein texte). Il fournit aussi l’implémentation par défaut derrière chacun : une base SQLite avec pool de connexions, un blob store fichier adressé par contenu, et un index plein texte Tantivy. Il possède également le schéma : l’ensemble de migrations versionnées, appliquées automatiquement au démarrage.
Overview
Les crates de domaine ne parlent jamais directement à un pilote de base de données. Elles détiennent unArc<dyn SqlBackend> et construisent des tranches Param typées. Le backend les traduit vers le pilote natif et renvoie des valeurs OwnedRow qui possèdent leurs données. C’est la frontière entre le pilote SQL et le reste d’OxiMail. Aucune crate au-dessus de oximail-store n’importe rusqlite.
La crate se situe tout en bas du graphe de dépendances. oximail-core réexporte ses primitives Id, BlobId et State. Toutes les autres crates atteignent le stockage par les traits définis ici. Les contrats sont volontairement étroits : chaque méthode de Store, BlobStore et SearchStore prend tenant_id en paramètre obligatoire. Un handler ne peut donc physiquement pas lire les données d’une autre organisation, même en présence d’un bug.
La version communautaire (AGPL) repose sur SQLite + Tantivy + système de fichiers. Il existe une seule implémentation de SqlBackend dans la crate : SqliteBackend. C’est le socle de stockage qu’OxiMail livre et exécute en production.
Les supports marketing et tarifaires mentionnent parfois PostgreSQL, un stockage objet ou un cluster de recherche externe comme options de montée en charge. Aucun de ces backends n’existe dans la crate AGPL
oximail-store. Le seul backend SQL implémenté est SQLite (avec un pool r2d2). La recherche est Tantivy. Les blobs sont sur le système de fichiers local. Quelques commentaires dans le code citent PostgreSQL comme une note de compatibilité future (par exemple, le moteur de migrations documente l’équivalent pg_advisory_lock qu’il utiliserait), mais aucun code PostgreSQL, S3 ou Elasticsearch n’est présent. Les backends alternatifs pour les grands déploiements multi-tenant relèvent d’une préoccupation de montée en charge distincte, hors de cette crate. Cette page documente uniquement ce que fournit réellement la crate communautaire AGPL.Features
- Trait
SqlBackend: une interface SQL à type effacé (execute,query,query_one,query_opt,begin,execute_batch) implémentée parSqliteBackend. Les stores de domaine construisent des tranches&[Param]et lisent des résultatsOwnedRowau lieu de toucher au pilote. - Pool de connexions r2d2, pas de
Mutex<Connection>:SqliteBackendemprunte une connexion au pool r2d2 à chaque appel. Le mode WAL autorise des lecteurs concurrents pendant qu’un seul écrivain tient le verrou. - PRAGMA par connexion : chaque connexion du pool est initialisée avec
journal_mode=WAL,busy_timeout=5000,synchronous=NORMAL,cache_sizeetforeign_keys=ON. Les requêtes JMAP concurrentes ne voient donc pas d’erreursSQLITE_BUSYparasites. - Migrations versionnées (ADR-013) : un ensemble de migrations ordonné et statique, appliqué au démarrage dans une seule transaction
BEGIN EXCLUSIVE. Échec bruyant : toute erreur annule le lot entier et le serveur refuse de démarrer. - Validateur de cohérence des FK : une vérification au moment des tests (
validate_fk_consistency) qui attrape une classe de bug de clé étrangère que SQLite accepte en silence auCREATE TABLE, mais qui ne se manifeste qu’au premierDELETEsur le parent. - Trait
Store: CRUD structuré, suivi des changements JMAP (RFC 8620 §5.2), concurrence optimiste (record_changes/atomic_setavecifInState), surcharges de configuration, et suppression différée native avec restauration. - Trait
BlobStore+ store fichier : stockage binaire adressé par contenu, indexé par leSHA-256du clair, avec écriture atomique (fichier + ligne de métadonnées en une seule opération). - Trait
SearchStore+ index Tantivy : recherche plein texte avec un worker d’écriture en arrière-plan qui regroupe les commits. - Isolation par organisation obligatoire (ADR-015) :
tenant_idest un argument requis sur chaque méthode du store, et chaque requête SQL filtre dessus.
How it works
L’abstraction de backend
SqlBackend est l’unique couture entre OxiMail et sa base de données. Un store de domaine détient un Arc<dyn SqlBackend> et appelle execute / query / query_one / query_opt avec une chaîne SQL et une tranche &[Param]. Param est une petite énumération (Null, Integer, Real, Text, Blob) avec des conversions From pour chaque type utilisé dans une requête. Les sites d’appel construisent donc les paramètres avec la macro params![] plutôt qu’avec la macro du pilote.
Les résultats reviennent sous forme de valeurs OwnedRow. Contrairement à une ligne empruntée au pilote, une OwnedRow possède ses données et peut être renvoyée par une méthode ou rangée dans une collection. Une ligne peut porter une table partagée de noms de colonnes. Un parseur peut alors lire les colonnes par nom et tolérer n’importe quel sous-ensemble ou ordre. C’est la base de la projection de colonnes, où un SELECT omet les colonnes dont l’appelant n’a pas besoin. Une colonne obligatoire absente est une erreur dure. Une colonne optionnelle absente se lit comme None, exactement comme un NULL SQL. Rien n’est converti en silence.
Une Transaction expose les mêmes opérations et doit être validée explicitement. Elle s’annule à la destruction. Le trait SqlExecutor est implémenté à la fois par le backend et par une transaction. Une méthode de store peut donc s’écrire une fois et s’exécuter à l’intérieur ou en dehors d’une transaction. C’est ainsi que les handlers /set gardent toutes leurs écritures sur la même connexion (règle stricte : deux BEGIN IMMEDIATE sur deux connexions, c’est un interblocage).
Pool, pas de verrou unique
Le backend SQLite enveloppe un pool r2d2 (8 connexions par défaut). Chaque appel emprunte une connexion, exécute l’instruction, puis la rend. Il n’y a aucunMutex<Connection> nulle part. Le mode WAL laisse les lecteurs avancer pendant qu’un seul écrivain tient le verrou d’écriture.
Chaque connexion empruntée au pool exécute le même lot de PRAGMA : journalisation WAL, busy_timeout de 5 secondes, synchronous=NORMAL, un cache de pages borné par connexion, et foreign_keys=ON. Le busy_timeout est ce qui évite aux requêtes JMAP concurrentes de voir des erreurs SQLITE_BUSY parasites. Le backend tient aussi un compteur global des éventuelles erreurs busy, enrichi depuis le span de tracing appelant. La condition est donc observable plutôt qu’avalée.
Migrations : versionnées, appliquées automatiquement, échec bruyant
Le schéma est une tranche statique,MIGRATIONS, de 136 migrations numérotées (versions 1 à 136 sur la ligne actuelle. MIGRATION_COUNT est exposé via /admin/v1/version sous le nom migrations_applied pour la vérification de déploiement). Chaque migration est un numéro de version, une description et un bloc de SQL brut.
Au démarrage, apply_migrations_backend exécute chaque migration dont la version est supérieure au MAX(version) enregistré, à l’intérieur d’une seule transaction BEGIN EXCLUSIVE. Le verrou exclusif sérialise les instances concurrentes (un redémarrage progressif ou un démarrage parallèle se met en attente au lieu de courir sur schema_version). Si une migration échoue, toute la transaction est annulée et le serveur refuse de démarrer. Il n’y a aucune étape de mise à jour manuelle ni de schéma à moitié appliqué (ADR-013). Après un changement de schéma, il rafraîchit aussi les statistiques du planificateur de requêtes avec ANALYZE. Un échec d’ANALYZE est journalisé mais n’interrompt pas le démarrage, car la base est pleinement utilisable, simplement non optimisée.
Une crate compagnon hors arbre peut gérer son propre schéma à côté du cœur grâce à apply_migration_set avec un espace de noms distinct. Les deux lignes de version n’entrent ainsi jamais en collision.
Le validateur de cohérence des FK
SQLite accepte unFOREIGN KEY (a, b) REFERENCES x(c, d) au CREATE TABLE même quand (c, d) n’est pas un tuple unique sur x. La non-correspondance ne se manifeste qu’au premier DELETE sur le parent, sous la forme de l’erreur d’exécution foreign key mismatch. C’est une classe de bug bien réelle : une migration passée a livré une FK référençant (tenant_id, id) sur une table dont la clé primaire était (tenant_id, account_id, id). Elle n’a été attrapée que lorsqu’un test de régression a supprimé une ligne parente.
validate_fk_consistency (fk_validation.rs) comble cette faille. Il rejoue l’ensemble des migrations dans l’ordre des versions, en maintenant un modèle des sources d’unicité de chaque table (clé primaire, UNIQUE au niveau table et en ligne, CREATE UNIQUE INDEX) et de ses FK déclarées, en appliquant DROP TABLE pour nettoyer l’état. Après le rejeu, chaque FK survivante est vérifiée contre le modèle d’unicité survivant. Une FK dont le tuple cible n’est pas unique fait échouer le test. Le test fk_consistency_across_agpl_migrations l’exécute sur toute la tranche MIGRATIONS. Une crate compagnon peut lancer la même vérification sur la concaténation du cœur et de ses propres migrations, pour attraper les FK qui traversent la frontière.
Store structuré : suivi des changements et concurrence optimiste
Le traitStore est un CRUD générique sur des objets JSON indexés par (tenant_id, account_id, collection, id), plus la mécanique JMAP posée par-dessus. get_objects renvoie (id, Option<value>) pour chaque identifiant demandé. Un identifiant inconnu est donc signalé, jamais ignoré en silence. Les suppressions sont différées : un objet passe dans une table deleted_items et peut être restauré. C’est ce qui soutient la restauration native et la récupération par CalDAV/CardDAV des UID détruits lors d’une sync-collection.
Le suivi des changements implémente la RFC 8620 §5.2 : record_change / record_changes ajoutent au change_log et renvoient le nouvel State. La concurrence optimiste (ADR-010) est intégrée : record_changes et atomic_set prennent un ifInState optionnel, vérifient l’état courant et écrivent dans la même transaction, puis renvoient une erreur StateMismatch si l’état a bougé. Pas de verrou pessimiste, donc pas de risque d’interblocage dans les requêtes groupées. begin_write_transaction plus record_changes_in_txn permettent à un handler /set de faire toutes ses mutations et l’enregistrement des changements dans une seule transaction exclusive.
Blob store : adressé par contenu et atomique
BlobStore est un stockage binaire adressé par le SHA-256 du clair (ADR-014, amendé par la disposition de chiffrement Tier 3.0.19). L’implémentation fichier range les blobs par organisation et par compte :
- les blobs par compte (asymétriques) à
{base}/{tenant_id}/{account_id}/{prefix}/{hash}, - les blobs partagés à l’échelle du tenant (symétriques) à
{base}/{tenant_id}/_tenant/{prefix}/{hash}, où_tenantest une sentinelle réservée que de vrais identifiants de compte ne peuvent jamais percuter.
Search store : Tantivy avec un worker de regroupement
SearchStore est une recherche plein texte sur des documents indexés par (tenant_id, account_id, collection, doc_id) avec un seul champ content interrogeable. L’implémentation Tantivy envoie les opérations d’indexation et de suppression à un worker dédié en arrière-plan, par un canal borné. Le worker accumule les mutations et valide par lots (toutes les 500 ms ou tous les 50 documents, au premier des deux). Cela remplace un commit par document et supprime la contention sur le verrou d’écriture que ce schéma provoquait. Un flush force un commit immédiat, utilisé par les tests et l’arrêt propre.
Invariants
- Ne jamais échouer en silence. Les identifiants inconnus reviennent en
Nonedans leur emplacement. Une colonne obligatoire absente, un dépassement de busy_timeout et une violation de contrainte remontent tous comme des variantes typées deStoreError. Rien n’est ignoré. - Isolation par organisation dans la couche de stockage.
tenant_idest obligatoire sur chaque méthode, et chaque requête filtre dessus. Le RBAC est une défense en profondeur par-dessus, pas l’unique ligne. - Aucun
Mutex<Connection>. La concurrence repose sur le pool r2d2 plus WAL, jamais sur une connexion unique gardée par un mutex. - Migrations tout ou rien. Une transaction exclusive, échec bruyant en cas d’erreur, aucune étape manuelle.
Configuration
oximail-store ne lit aucun fichier de configuration. Le binaire ouvre le backend avec les valeurs qu’il résout. Les réglages d’administration pertinents sont le chemin de la base, la clé de chiffrement SQLCipher optionnelle (passée à SqliteBackend::open), la taille du pool de connexions (8 par défaut), et les chemins de base du blob store et de l’index de recherche. Ils sont documentés dans la référence de configuration. Le modèle de chiffrement derrière le blob store (le schéma au repos de type LUKS, pas du bout en bout) est couvert dans chiffrement au repos.
Protocol / JMAP surface
Aucune.oximail-store n’expose aucune méthode JMAP et ne sert aucune route HTTP. C’est une bibliothèque consommée par les crates de domaine : elle fournit les traits SqlBackend, Store, BlobStore et SearchStore ainsi que leurs implémentations par défaut, plus les primitives Id / BlobId / State que oximail-core réexporte. La surface JMAP posée au-dessus de ce stockage vit dans les crates de domaine (oximail-core et les crates mail, calendrier, contact, tâche, fichier et partage).
Design decisions
- ADR-002 (SQLite comme backend par défaut) : la version communautaire tourne sur une seule base SQLite embarquée avec un pool r2d2. Aucun serveur de base de données externe n’est requis.
- ADR-003 (Tantivy pour la recherche) : la recherche plein texte est un index Tantivy embarqué, pas un cluster de recherche externe.
- ADR-013 (migrations versionnées et appliquées automatiquement) : l’ensemble numéroté
MIGRATIONSest appliqué au démarrage en une transaction exclusive, échec bruyant, sans procédure de mise à jour manuelle. - ADR-015 (isolation par organisation dans la couche de stockage) :
tenant_idest un paramètre obligatoire sur chaque méthode du store et chaque requête filtre dessus, en défense en profondeur sous le RBAC. - ADR-010 (concurrence optimiste) : les vérifications
ifInStateet les écritures ont lieu dans la même transaction. Un état qui a bougé renvoieStateMismatch. Pas de verrou pessimiste. - ADR-014 (déduplication des blobs par hash de contenu) et ADR-009 (stockage brut plus structuré) : les blobs sont adressés par le
SHA-256du contenu. Le courriel est analysé une fois à l’ingestion et stocké à la fois en octets bruts et en métadonnées structurées. - ADR-020 / Tier 3.0.19 (chiffrement au repos) : la géométrie de chemin des blobs par compte et à l’échelle du tenant, et l’écriture atomique fichier plus métadonnées.
Standards
- RFC 8620, JMAP Core. Le modèle de suivi des changements et d’état implémenté par le trait
Store(§5.2 les changements, la chaîneState, la concurrence optimiste viaifInState).
Source map & tests
| Fichier (dans la crate) | Contenu |
|---|---|
src/backend.rs | Les traits SqlBackend, Transaction et SqlExecutor, les types Param / SqlValue / OwnedRow, et la macro params![]. |
src/sqlite_backend.rs | SqliteBackend, l’implémentation SQLite avec pool r2d2, l’initialiseur de PRAGMA par connexion, et le compteur SQLITE_BUSY. |
src/sqlite.rs | SqliteStore, l’implémentation du trait Store (CRUD, change log, record_changes optimiste). |
src/migration.rs | La tranche MIGRATIONS (136 migrations numérotées), MIGRATION_COUNT, apply_migrations_backend et apply_migration_set. |
src/fk_validation.rs | validate_fk_consistency, le validateur de cohérence des FK de migration. |
src/filesystem.rs | FsBlobStore, le blob store fichier adressé par contenu et atomique. |
src/blob_meta_store.rs | BlobMetaStore, la persistance des schémas de chiffrement par blob. |
src/tantivy_search.rs | TantivySearchStore, l’index plein texte avec son worker d’écriture par lots. |
src/traits.rs | Les définitions des traits Store, BlobStore et SearchStore (chaque méthode prend tenant_id). |
src/types.rs | Id, BlobId, State, StoreError et les autres types de stockage partagés. |
src/layout_migration.rs | La migration de disposition des blobs Tier 3.0.19 vers le nommage par compte. |
fk_consistency_across_agpl_migrations exécute le validateur de FK sur toute la tranche MIGRATIONS. Ajouter une migration avec une clé étrangère incohérente fait donc échouer la suite avant qu’elle n’atteigne une base de données. Chaque nouvelle migration doit passer cargo test -p oximail-store.
Status
Implémenté et en production en v0.30.0 : l’abstractionSqlBackend au-dessus d’une base SQLite avec pool, les traits Store / BlobStore / SearchStore, le schéma de 136 migrations appliquées automatiquement avec le validateur de FK, le blob store fichier adressé par contenu, et l’index plein texte Tantivy. C’est le socle de stockage sur lequel tourne tout le serveur.
La crate communautaire (AGPL) livre exactement un backend SQL, SQLite, plus Tantivy et le blob store fichier. PostgreSQL, le stockage objet et les clusters de recherche externes ne font pas partie de cette crate. Les seules références à PostgreSQL dans le code sont des commentaires de compatibilité future, pas du code.