
Graphdb à faible empreinte mémoire avec prise en charge de Bolt+tls, chiffrement au repos et vecteurs conçus pour les cas d’utilisation de graphes en réplica local.
Version actuelle : v0.25.2 — toutes les versions.
En une phrase : Slater sert des graphes qui ne tiennent pas en mémoire — des centaines de millions de nœuds et des milliards d'arêtes dans quelques centaines de Mo de RAM — via le Bolt standard, de sorte que n'importe quel pilote neo4j fonctionne tel quel, avec une recherche vectorielle native sur disque à côté du graphe, et il accepte des écritures en direct et durables sans y renoncer. La mémoire résidente est définie par un budget de cache que vous choisissez, pas par la taille du graphe.
Raccourcis
Une base de données graphe stocke les données sous forme de choses (nœuds) et de relations entre elles (arêtes), les relations étant des citoyens de première classe. C'est ce qu'il vous faut lorsque vos questions portent sur des connexions plutôt que sur des lignes — « qui se trouve à moins de trois sauts de ce compte ? », « quelle est la chaîne de dépendances complète derrière cette compilation ? », « quels comptes partagent un appareil, une adresse et une carte ? » — les requêtes qui deviennent un marécage de jointures récursives en SQL mais qui se résolvent naturellement dans un graphe.
La plainte la plus courante concernant les bases de données graphe est qu'elles ne passent pas à l'échelle au-delà de ce que vous pouvez tenir en RAM. Beaucoup d'entre elles (par ex. neo4j, Memgraph, FalkorDB, etc.) gardent tout le graphe en mémoire : un graphe de 40 Go veut 40 Go de mémoire — par instance. Vous voulez un réplica par région, par locataire ou par pod ? Multipliez la facture. Et au-delà d'une certaine taille, elles refusent tout simplement de charger : par exemple, le graphe Wikidata de 90 millions de nœuds / 1,5 milliard d'arêtes nécessite ~64–128 GiB résidents, donc les moteurs en mémoire ne peuvent pas l'ouvrir du tout.
Slater est la réfutation. Plutôt que de charger le graphe en mémoire, il le compile une fois, hors ligne : slater-build transforme vos données en une image sur disque immuable et adressée par contenu, et n'importe quel nombre de serveurs Slater sert ensuite cette image via Bolt (donc vos pilotes neo4j existants fonctionnent tels quels), en paginant les blocs à la demande et en ne gardant résident qu'un budget de cache fixe. C'est ainsi que le même graphe de 90M de nœuds est servi depuis quelques centaines de Mo de RAM — la taille du graphe et la facture mémoire sont découplées. Un graphe de 4 Go et un graphe de 400 Go coûtent la même RAM à servir, donc vous déployez des réplicas de lecture bon marché et sans état et laissez le stockage, pas le tas, contenir le graphe.
Cela en fait un choix naturel pour les graphes de connaissances derrière le RAG, les graphes de recommandation et d'identité, les graphes de dépendances — tout ce qui est volumineux et connecté que vous souhaitez interroger à moindre coût et souvent. La recherche vectorielle native sur disque vit juste à côté du graphe, donc le même moteur est aussi la couche de récupération pour les embeddings.
Compilé une fois ne signifie pas figé, cependant. Cette image est une base, pas un état final : une couche d'écriture optionnelle se superpose à elle, donc un graphe en direct peut être corrigé et étendu sans rien reconstruire.
Le cœur est immuable ; le graphe ne l'est pas. Activez la couche inscriptible (delta.enabled) et vous écrivez via Bolt — corrigez une propriété, ajoutez un nœud, retirez une arête — et le changement atterrit de manière durable, sans reconstruction de l'image. Ce qui maintient le coût faible côté lecture, c'est où vivent les écritures.
Les écritures s'accumulent dans une couche log-structurée-fusionnée (LSM) au-dessus du cœur immuable : un journal d'écriture anticipée et une table en mémoire, se déversant dans des segments delta immuables, repliés dans un nouveau cœur par une consolidation périodique. Ce que cela vous apporte :
count(*), les marginaux d'étiquettes et de types de relations — restent des lectures de métadonnées même avec des écritures en attente : le delta conserve ses propres compteurs, donc un count(*) sur un cœur de 91,6M de nœuds avec un demi-million d'écritures en attente répond encore en quelques dizaines de millisecondes sans toucher un seul bloc.SUCCESS qu'après le fsync qui couvre l'écriture. Regroupez vos écritures et elles sont bon marché — un UNWIND d'écriture valide un fsync par lot plutôt que par ligne.MERGE / MATCH … SET / DELETE (et CREATE / , suppression détachée, écritures de relations) indexées sur la propriété d'identité d'un nœud — ou les instructions équivalentes de modification de données ISO GQL ( / / / ), qui descendent sur le même chemin. Corrigez, insérez, upsert et retirez, sur les nœuds et les arêtes, adressés comme vos données le sont déjà.Avec la couche désactivée — le défaut — Slater sert le cœur immuable pur et refuse les écritures. Voir La couche inscriptible pour le modèle complet.
Sur le nom. Slater est nommé d'après l'agent de la CIA dans Archer (une excellente série) qui insiste pour être appelé par un seul nom — « Just… Slater » — et l'un de mes personnages préférés de la série. Voir la page wiki du personnage.
MERGE / SET / DELETE par clé métier sur les nœuds et les arêtes, validées en groupe et durables par fsync, repliées dans un nouveau cœur par consolidation. Les lectures n'en paient pas le prix.current, et les serveurs la récupèrent. Chaque bloc est vérifié par somme de contrôle, donc une image à moitié copiée est refusée plutôt que servie.Deux binaires composent l'espace de travail :
| Binaire | Rôle |
|---|---|
slater | Le serveur Bolt en ligne (l'ENTRYPOINT du conteneur) : sert les lectures et, avec delta.enabled, le chemin d'écriture durable à écrivain unique. |
slater-build | Le compilateur hors ligne : transforme un dump Cypher primitif en un répertoire de génération immuable et haché par contenu. |
Slater sépare la construction en masse du service : slater-build fait le travail
lourd hors ligne — ingérant vos données et les compilant en une génération immuable — donc
un graphe froid n'est jamais assemblé sur le chemin chaud du service. Dans le serveur, la
surface de lecture répond à une large tranche de Cypher — correspondance de motifs,
sous-requêtes WITH/UNION/CALL {…}, plus de 70 fonctions scalaires et d'agrégation,
valeurs temporelles et géospatiales, algorithmes de graphe (algo.*) et KNN vectoriel
natif sur disque (db.idx.vector.queryNodes) — tandis que la superposition delta de la
couche inscriptible vit sous cette surface et est à coût nul lorsqu'elle est vide,
donc les lectures ne portent jamais la machinerie côté écriture. Vous pouvez mettre à
jour un graphe de deux façons : écrire dessus en direct via Bolt (voir
La couche inscriptible), ou compiler une nouvelle génération
hors ligne et basculer atomiquement le pointeur current, que le serveur en cours
d'exécution récupère via sa garde de génération (voir
Garde de génération).
Le manuel utilisateur complet vit dans docs/manual/ —
un guide fonctionnalité par fonctionnalité qui explique, pour chaque capacité, ce que
c'est, pourquoi cela existe et comment l'utiliser, avec des exemples pratiques que vous
pouvez exécuter contre un graphe d'exemple fourni. Commencez là pour tout ce qui va
au-delà de cette vue d'ensemble.
graphiti-slater est un adaptateur
qui permet à Graphiti de stocker son graphe de
connaissances temporel dans Slater, avec un
docker-example/
exécutable — y compris l'exposition à Claude Code en tant que serveur MCP. Voir ce dépôt
pour savoir comment cela fonctionne et comment l'exécuter.
Slater est conçu pour être exécuté comme un déploiement Docker — c'est la façon
attendue de l'utiliser. Des images multi-arch précompilées (linux/amd64 + linux/arm64)
sont publiées sur Docker Hub à
hikarisystems/slater,
étiquetées :latest et :vX.Y.Z à chaque version :```sh
docker pull hikarisystems/slater:latest
Un guide d'utilisation, de configuration et d'exploitation réservé aux commandes Docker se trouve dans
[`DOCKERHUB.md`](https://github.com/hikari-systems/slater/blob/main/DOCKERHUB.md) (et est répliqué sur la page de présentation du Docker Hub) —
**commencez par là si vous déployez.** En bref :```sh
# Build a graph generation with the offline writer:
docker run --rm -v slater-data:/data -v "$PWD/dumps:/dumps:ro" \
--entrypoint /app/slater-build hikarisystems/slater:latest \
--input /dumps/people.cypher --graph people --data-dir /data
# Serve it over Bolt on 7687 (read-only unless `delta.enabled`):
docker run -d --name slater -p 7687:7687 \
-v slater-data:/data:ro -v "$PWD/acl.json:/config/acl.json:ro" \
hikarisystems/slater:latest
Pour construire l'image localement à la place (par exemple pour le développement) :```sh
docker compose build
docker compose up slater
build):docker compose run --rm builder
--input /dumps/people.cypher --graph people --data-dir /data
L'étape de construction installe `cmake`, `clang` et `libclang-dev` pour le backend `aws-lc-rs` de rustls ; `git` (déjà présent dans l'image de base) est requis pour la dépendance git+tag `hs-utils`, que `.cargo/config.toml` récupère via l'interface CLI de git.
Les sections ci-dessous couvrent le format sur disque, la configuration, les ACL, ainsi qu'un exemple pratique local (hors Docker).
## Comment ça fonctionne```
slater-build slater (Bolt server)
dump.cypher ──────────▶ /data/<graph>/<uuid>/ ──────────▶ neo4j driver
(offline, atomic) MANIFEST.json, *.blk, (bolt / bolt+s)
range/*.isam, vector/*.{vamana,pq},
current → <uuid>
MANIFEST.json (tables de symboles,
descripteurs d’index, en-tête de chiffrement optionnel), des fichiers de blocs
columnaires (node_props.blk, node_labels.blk, edge_props.blk, topology.csr.blk,
vectors.f32.blk), des index de plage (range/<name>.isam), des index ANN
au-dessus du seuil (vector/<label>.<prop>.{vamana,pq}), et un pointeur texte current.--encrypt, chaque
bloc est en outre scellé avec XChaCha20-Poly1305 (AEAD au repos).Avec delta.enabled, la génération immuable devient le niveau inférieur entièrement
compacté (le « cœur ») d’un petit arbre de fusion par journalisation structurée, et les
écritures en direct s’empilent par-dessus :```
write (Bolt) read (Bolt)
│ │
▼ ▼
┌──────────────┐ flush ┌──────────────┐ ┌──────────────────────┐
│ WAL + active │ ───────▶ │ L0 delta │ │ a query pins one │
│ memtable │ │ segments │ │ (core, delta) view │
└──────────────┘ └──────┬───────┘ │ and reads the merge │
(fsync = ack) │ └──────────────────────┘
consolidation │ (folds core + delta → fresh core)
▼
┌─────────────┐
│ new core │ (atomic current swap)
└─────────────┘
* **Plancher de durabilité — le WAL.** Chaque mutation est sérialisée derrière un
seul rédacteur par graphe, ajoutée à un journal d’écriture anticipée (write-ahead log)
par graphe, et synchronisée par `fsync` avant que le `SUCCESS` de Bolt ne soit
renvoyé — donc *accusé de réception ⇒ durable*, et une queue déchirée est
abandonnée lors de la relecture. Un `UNWIND` d’écriture par lots ajoute ses lignes et
valide **un** `fsync` pour tout le lot. Le WAL est **uniquement sur disque local**
(il ne transite pas par le backend de stockage), ce qui rend un nœud *rédacteur*
avec état : il lui faut un volume local durable à `delta.walDir`. Les réplicas en
lecture restent sans état.
* **Memtable → L0 → consolidation.** Les écritures s’accumulent dans une memtable
en RAM (bornée par `delta.memtableBytes`) ; lorsqu’elle se remplit, elle se vide
dans un segment delta L0 immuable. Une **consolidation** replie `{core + delta}`
dans un nouveau core en sérialisant la vue fusionnée via `slater-build` et en
échangeant `current` de manière atomique — la même garde de hauteur de contenu que
toute génération publiée. Déclenchez-la à la main avec `CALL slater.consolidate()`,
automatiquement à `delta.deltaCorePercent` de la taille du core (éventuellement
limitée à une fenêtre hors pointe `delta.consolidateWindow`), ou laissez le
`delta.deltaHardBytes` de limitation freiner une croissance incontrôlée.
* **La superposition se situe sous la surface de lecture.** L’exécuteur lit à
travers une `ReadView` qui est soit le core nu (delta toujours vide), soit une vue
fusionnée `(core, delta)` ; le moteur est monomorphisé dessus, donc un delta vide
se compile en une seule branche prévisible et le chemin en lecture seule est
octet pour octet identique. Les compteurs à l’échelle du graphe (`count(*)`,
marginaux label/reltype) sont servis depuis les propres compteurs en direct du
delta, donc ils restent des lectures de métadonnées même avec des écritures en
attente.
* **Une requête voit un instantané stable.** Elle épingle un tuple `(core, delta)`
pour toute sa durée de vie. Il n’y a pas de transactions multi-instructions ni de
rollback — une écriture est une correction durable, adressée par clé métier, et
non une transaction OLTP.
La grammaire d’écriture exacte et les réglages figurent dans le tableau
[Configuration](#environment--configuration) (`delta.*`) et dans l’
[exemple travaillé](#worked-example) ci-dessous.
### Index de plage (ISAM)
Un index de plage (`range/<name>.isam`, un par `(label, propriété)` indexé) permet à
un `MATCH (n:Label {prop: v})` ou `WHERE n.prop <op> v` de résoudre les identifiants
de nœuds correspondants **sans analyser le label**. C’est une structure
**[ISAM](https://en.wikipedia.org/wiki/ISAM)** (Indexed Sequential Access Method) —
l’index classique *statique, trié, structuré en blocs*, qui est exactement la forme
appropriée pour une génération immuable : il n’y a pas d’insertions à rééquilibrer,
donc la simplicité de l’ISAM offre ce que le mécanisme de mutation d’un B-tree ne
ferait que compliquer.
* Les entrées `(valeur, entity_id)` sont triées par valeur et regroupées dans les
mêmes blocs de 256 KiB compressés en zstd que tout le reste.
* Un petit **niveau supérieur résident** contient la première clé de chaque bloc (un
index clairsemé). Une recherche effectue une recherche binaire dans ce niveau
supérieur en mémoire pour trouver le *seul* bloc où une clé peut se trouver, lit +
décompresse ce bloc, puis l’analyse — donc une recherche d’égalité est **une
lecture de bloc**, et une analyse de plage parcourt la suite contiguë de blocs
qu’elle couvre. (C’est pourquoi une recherche indexée par `meshUi` prend quelques
millisecondes à un chiffre alors que la même correspondance sur une propriété non
indexée analyse tout le label.)
* Le planificateur la choisit via `NodeScan::RangeEq` / `RangeRange` ; un prédicat
non indexé retombe sur un balayage de label ou une analyse complète, l’exécuteur
revérifiant chaque prédicat dans les deux cas.
### Recherche vectorielle (Vamana + PQ) — cosinus, L2 et produit scalaire, lecture *et* écriture
Le KNN vectoriel (`db.idx.vector.queryNodes`) s’exécute sur des index **cosinus, L2
ou produit scalaire (MIPS)**. L’index de base est construit hors ligne avec deux
chemins d’exécution, choisis par index selon le `--ann-threshold` (défaut 50 000
vecteurs) :
* **Sous le seuil — force brute.** Les vecteurs `f32` complets vivent dans
`vectors.f32.blk` ; une requête analyse le groupe de l’index et calcule la distance
exacte dans la métrique de l’index. Simple et exact ; adapté lorsque l’ensemble de
vecteurs est petit.
* **Au seuil ou au-dessus — Vamana + PQ**, le chemin ANN natif sur disque qui
maintient la mémoire résidente bornée quel que soit le nombre de vecteurs :
* **[Vamana](https://arxiv.org/pdf/2401.11324)** est l’index de graphe issu de la
lignée de travaux DiskANN : un graphe de proximité unique dont les arêtes sont
élaguées (le degré sortant `--vamana-r` et le facteur d’arête longue
`--vamana-alpha`) afin qu’une *recherche gloutonne par faisceau* — départ au
médoid, sauts répétés vers la requête en conservant une liste de candidats de
largeur `vectorQuery.beamWidth` — atteigne les vrais voisins d’un nœud en
quelques sauts, c’est-à-dire **peu de lectures de blocs aléatoires par requête**.
Les blocs de graphe (`vector/<label>.<prop>.vamana`) sont paginés via le cache
vectoriel, non conservés en totalité.
* **[Quantification de produit (PQ)](https://medium.com/aiguys/product-quantization-k-nn-for-big-datasets-12431d764c4e)**
compresse chaque vecteur en un code court (`--pq-subspaces` × `--pq-bits`) : les
dimensions sont divisées en sous-espaces, chacun regroupé indépendamment par
k-means, et le vecteur est stocké comme le tuple des identifiants de centroïdes
les plus proches. Ces codes (`vector/<label>.<prop>.pq`) sont assez petits pour
rester **résidents**, donc la recherche par faisceau évalue les candidats depuis
la RAM et seuls les quelques vecteurs complets choisis sont lus depuis le disque.
Cet ensemble PQ résident est ce que le pool `cache.vectorCacheBytes` épingle.
**Plongements inscriptibles — l’échelle d’écriture vectorielle (style
[FreshDiskANN](https://arxiv.org/abs/2105.09613)).** Un plongement indexé est une
valeur inscriptible de première classe. `SET n.embedding = vecf32([…])` (et `REMOVE`)
atterrit dans le delta d’écriture et est **immédiatement visible en KNN avec un rang
exact**, puis survit à un vidage de segment, une fusion et une consolidation. Une
requête fusionne jusqu’à trois niveaux — l’index de base scellé, un index par segment
scellé et un **index RW** en mémoire (un Vamana mutable en direct sur le delta
d’écriture) — donc la latence reste stable à mesure que les écritures s’accumulent
plutôt que de croître avec le nombre d’écritures en attente. Une suppression laisse un
*trou* : le nœud cesse d’être renvoyé mais reste un point de passage de navigation
jusqu’à ce qu’une **consolidation de suppression** en arrière-plan le retire du
graphe, donc les suppressions cessent de coûter des E/S de requête. Et parce que le
graphe sur disque adresse ses voisins par position de mise en page plutôt que par
identifiant de nœud, `CALL slater.consolidate()` transporte le Vamana **par
référence** — lien dur, octet pour octet identique — et ne réécrit qu’une petite
colonne d’identifiants, repliant les écritures vectorielles dans la base **sans** la
reconstruction de graphe O(N·R·L). Les chiffres mesurés, avec réserves, figurent dans
le [rapport de performance](https://github.com/hikari-systems/slater/blob/main/docs/PERF-REPORT.md).
## Backends de stockage (système de fichiers / S3 / GCS)
Chaque fichier de génération est ouvert via une abstraction **`ObjectStore`** plutôt
que directement via `std::fs`, donc le *même* format d’octets sur disque — blocs,
index, manifeste, pointeur `current` — est servi inchangé depuis n’importe quel
backend ; seul *l’endroit d’où viennent les octets* diffère, jamais les lecteurs, le
moteur de requêtes ni les vérifications d’intégrité. Le chemin à chaud repose sur des
lectures positionnelles (`read_exact_at`), qui se mappent sur un `pread` sur un
fichier local et une requête de plage d’octets HTTP sur un stockage d’objets — Slater
n’utilise jamais mmap, donc le modèle explicite de lecture bornée est identique
partout.
**Trois backends de première classe**, sélectionnés par `dataBackend.kind`. Le
système de fichiers est le défaut simple ; **Amazon S3 et Google Cloud Storage sont
des backends de stockage d’objets égaux et entièrement pris en charge** — l’image
publiée embarque les deux compilés, donc chacun est uniquement une question de
configuration, et une génération construite une fois peut être servie depuis
n’importe lequel (même migrée `fs` → S3 → GCS) sans reconstruction.
| `dataBackend.kind` | Lecture positionnelle | Intégrité à l’ouverture | Identifiants |
| --- | --- | --- | --- |
| `fs` *(défaut)* | `pread` | re-hachage BLAKE3 complet de chaque fichier | — |
| `s3` | GET `Range` HTTP | **SHA-256** serveur via `HEAD` (→ re-hachage BLAKE3 du corps si absent) | clés de configuration, chaîne AWS ou rôle IAM |
| `gcs` | lecture de plage HTTP | **CRC32C** serveur via `get_object` (→ re-hachage BLAKE3 du corps si absent) | ADC / Workload Identity, ou JSON de compte de service |
Les deux stockages d’objets vérifient l’intégrité à partir de la **somme de contrôle
que le stockage calcule et conserve déjà**, récupérée comme métadonnée d’objet :
`slater-build` envoie la somme de contrôle au téléversement (le stockage valide les
octets contre elle et la stocke), et le serveur la relit à l’ouverture et la compare
au manifeste — une requête de métadonnée par fichier, sans téléchargement du corps.
C’est de qualité contenu et identique dans l’esprit sur S3 (SHA-256) et GCS (CRC32C).
Lorsqu’un objet ne porte **aucune** somme de contrôle stockée par le serveur (copié
hors bande, ou téléversé avec un défaut différent), le serveur **re-hache le corps de
l’objet contre le BLAKE3 du manifeste** plutôt que de se fier à sa longueur d’octets —
une vérification d’intégrité demandée n’est jamais silencieusement dégradée en
comparaison de taille. Les générations publiées par Slater portent toujours la somme
de contrôle, donc elles restent sur le chemin de métadonnées économique.
Ce que cette colonne vérifie, sur chaque backend, c’est que les fichiers **correspondent
au manifeste**. Que le manifeste lui-même puisse être fiable est une question
distincte, et c’est la clé maîtresse qui y répond : avec une clé configurée, le
manifeste porte un MAC à clé que le serveur vérifie avant de se fier à tout champ (y
compris ces hachages), donc un manifeste réécrit pour décrire des fichiers altérés est
refusé ; sans clé, la comparaison reste sans clé partout, et
quelqu’un qui peut écrire dans le répertoire de données peut réécrire un fichier et le
manifeste ensemble. Voir
[Ce que signifie l’intégrité dans chaque configuration](https://github.com/hikari-systems/slater/blob/main/THREAT_MODEL.md#what-integrity-means-in-each-configuration).
La vérification elle-même peut être désactivée avec `dataBackend.verifyIntegrity: false`, ce qui
l’échange contre une ouverture plus rapide.
### Système de fichiers (`fs`)
Le défaut, ancré à `dataBackend.fs.dir`. Le bon choix pour la plupart des
déploiements : une génération sur un SSD local (ou un montage NFS/EBS) servie en
lecture seule. L’intégrité est un re-hachage BLAKE3 complet de chaque fichier à
l’ouverture.
### Amazon S3 (`s3`)
Un bucket S3 ou compatible S3 (AWS, MinIO, localstack). Les identifiants viennent
**d’abord** de la configuration (`dataBackend.s3.awsAccessKey` / `awsSecretKey`, plus
`awsSessionToken` pour les identifiants STS temporaires) et retombent sur la chaîne
AWS standard (`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` env, profil partagé, ou
rôle instance/IRSA) lorsqu’ils sont laissés vides.```sh
# serve from S3 (env-var form; see the config table for every key)
dataBackend__kind=s3
dataBackend__s3__bucket=slater
dataBackend__s3__region=eu-west-2
dataBackend__s3__awsAccessKey=… # omit to use the AWS chain / instance role
dataBackend__s3__awsSecretKey=…
# S3-compatible (e.g. MinIO): also set
dataBackend__s3__endpoint=http://minio:9000
dataBackend__s3__pathStyle=true # required by most S3-compatible servers
# publish a generation into the bucket (remote `current` pointer written last)
slater-build --input people.cypher --graph people --data-dir /data \
--publish-s3-bucket slater --publish-s3-region eu-west-2 --publish-s3-prefix prod
# MinIO: add --publish-s3-endpoint http://localhost:9000 --publish-s3-path-style
gcs)Un bucket GCS, atteint via l'API JSON. L'autorisation est native à GCP : par défaut,
elle résout les Application Default Credentials — GKE Workload Identity, le
serveur de métadonnées GCE, ou une clé gcloud / GOOGLE_APPLICATION_CREDENTIALS. Définissez
dataBackend.gcs.credentialsPath (un fichier de clé JSON de compte de service) ou en ligne
credentialsJson pour une clé explicite. dataBackend.gcs.endpoint pointe vers un
émulateur fake-gcs-server, et dataBackend.gcs.anonymous=true active
l'accès non authentifié uniquement pour cet émulateur — jamais contre un vrai GCS.```sh
dataBackend__kind=gcs dataBackend__gcs__bucket=slater dataBackend__gcs__prefix=prod dataBackend__gcs__credentialsPath=/secrets/sa.json # omit for ADC / Workload Identity
```sh
# publish a generation into the bucket (remote `current` pointer written last)
slater-build --input people.cypher --graph people --data-dir /data \
--publish-gcs-bucket slater --publish-gcs-prefix prod
# explicit key: add --publish-gcs-credentials /secrets/sa.json
Dans tous les cas, slater-build écrit d'abord la génération terminée dans --data-dir (sa zone de staging locale) et la téléverse en plus vers le bucket ; le pointeur current distant est écrit en dernier, de sorte qu'un nœud de service ne voit jamais une génération à moitié publiée.
Optez pour s3 ou gcs lorsque vous souhaitez des générations dans un stockage d'objets central et durable plutôt que sur le disque d'un nœud — typiquement : publier une fois et diffuser vers de nombreuses répliques de serveurs sans état et sans disque qui lisent toutes le même bucket ; découpler l'hôte de build des hôtes de service ; ou s'appuyer sur la durabilité/le versionnage/le cycle de vie du store au lieu de gérer des volumes. Le compromis est la latence : un bloc froid est un aller-retour réseau (~10–50 ms) au lieu d'une lecture locale (~0,1 ms). Slater masque l'essentiel grâce au cache de blocs en mémoire, à la lecture anticipée concurrente et au cache disque optionnel ci-dessous. Si vos générations se trouvent déjà sur un stockage local rapide et que vous n'avez pas besoin du modèle de bucket central, fs est plus simple et plus rapide.
Le BlockCache en mémoire est délibérément petit (la RSS bornée est la garantie phare), donc sur un ensemble de travail plus grand que la RAM, les mêmes blocs seraient re-récupérés depuis l'object store à chaque éviction. Un second niveau de cache optionnel sur SSD local corrige cela : un bloc évincé de la RAM est servi depuis le disque local (~0,1 ms) au lieu d'un nouvel objet GET, survivant à l'éviction en mémoire et réduisant le nombre/coût des requêtes vers l'object store — rapprochant un nœud adossé à un object store des performances d'un système de fichiers local une fois chaud. Il est optionnel pour s3 et gcs, activé en définissant dataBackend.<s3|gcs>.diskCacheBytes > 0 et un diskCacheDir inscriptible.
--encrypt) toujours scellés AEAD — en dessous du déchiffrement/décompression. La couche de cache ne détient jamais la clé de chiffrement et ne rechiffre jamais, donc le statut au repos est préservé gratuitement : une génération chiffrée atterrit sur le disque toujours scellée.diskCacheDir doit pointer vers un vrai volume inscriptible — jamais tmpfs (tmpfs est de la RAM et annulerait la garantie de RSS bornée). L'index en mémoire qui le suit coûte un peu de RAM (~dizaines d'octets par bloc mis en cache), ce qui compte contre votre plafond de RSS — dimensionnez le répertoire ≫ le cache de blocs en mémoire.blockCacheBytes / 8 (plancher par diskCacheBytes) — 8 Mio par défaut — et se réduit plutôt que de croître, donc un balayage à froid ne peut pas la gonfler ; un bloc écarté est simplement re-récupéré lors de son prochain échec. Elle ne nécessite aucune configuration : elle évolue avec blockCacheBytes, donc le niveau disque n'ajoute aucun nouveau nombre au budget RSS au-delà de son index.Une réplique de lecture s'exécute avec un système de fichiers racine en lecture seule et un utilisateur non-root (appuser:1000) — tout ce dont elle a besoin est monté en lecture seule. Un writer (delta.enabled) a en plus besoin d'un volume durable et inscriptible pour son WAL.
La configuration est chargée par le chargeur en couches standard de la maison : le config.json intégré, puis /sandbox/config.json fusionné en profondeur par-dessus, puis les surcharges d'environnement KEY__sub (double underscore pour l'imbrication ; les clés correspondent à la config camelCase).
Chaque paramètre de configuration — sa clé camelCase, la surcharge d'environnement KEY__sub, sa valeur par défaut et son rôle — est tabulé dans la Référence de configuration. Les paramètres les plus ajustés sont les budgets de cache (cache.*), les gardes de requête (query.*), les plafonds de connexion (server.*), le backend de stockage (dataBackend.*) et la couche inscriptible (delta.*).
La mémoire résidente suit
blockCacheBytes + vectorCacheBytes + resultCacheBytes à hauteur des frais généraux bornés par entrée et par allocateur près — chaque pool pèse son propre contenu (chaînes et conteneurs par capacité allouée) et évince pour rester sous le budget, mais la comptabilité par entrée et l'arrondi des classes de taille de l'allocateur s'ajoutent au nombre que vous définissez — plus un petit surcoût fixe (et jusqu'à degreeColumnBytes pour la colonne de degré lazy, une fois le chemin rapide de somme de degrés count(endpoint) exercé). Elle est indépendante de la taille du graphe — c'est la garantie phare, exercée par le test d'intégration rss_stays_bounded_under_sustained_knn_load, qui maintient la croissance RSS pic-vs-chaud bien à l'intérieur des budgets sommés. Les tampons par connexion vivent en dehors des budgets de cache, donc la garantie ne tient sous charge adverse que parce que server.maxConnections borne combien peuvent exister à la fois.
Slater est une poignée de réplique de lecture ; le contrôle principal de sécurité des connexions est le réseau, pas le binaire. Liez-le à une interface privée, restreignez les plages sources au niveau réseau (groupes de sécurité / NetworkPolicy) et — s'il fait face à autre chose que des clients de confiance — placez devant un proxy L4 limitant les connexions (HAProxy maxconn + une stick-table par source, ou nftables connlimit + hashlimit). Cela se situe avant que le descripteur de fichier ne soit jamais remis au processus, c'est donc la limite la plus robuste.
Les limites intégrées au binaire ci-dessus (maxConnections, maxPreAuthConnections, maxConnectionsPerIp, les plafonds d'octets différentiels et loginTimeoutMs) sont une défense en profondeur : elles sont activées par défaut et généreuses pour être invisibles pour une population de clients légitimes, mais elles font tenir la garantie de RSS bornée même lorsque le proxy est oublié. Voir docs/HARDENING.md pour la posture défensive complète, et THREAT_MODEL.md / SECURITY_WORKLIST.md pour le détail canonique.
Slater interroge le pointeur current de chaque graphe toutes les generationPollMs
(interrogation, pas inotify — le répertoire de données peut être un stockage distant/réseau comme NFS, où les événements de changement de système de fichiers ne sont pas fiables). Lorsqu'il change :
reloadStrategy=exit (défaut) : le serveur journalise une erreur fatale et se termine avec un code non nul pour que l'orchestrateur le redémarre proprement contre la nouvelle génération.reloadStrategy=swap : le serveur ouvre et valide la nouvelle génération (même garde de hachage de contenu qu'au démarrage), la bascule atomiquement, et laisse les requêtes en vol se terminer sur l'ancienne. Une nouvelle image corrompue/incomplète est refusée et l'ancienne génération continue de servir.acl.json mappe les utilisateurs aux hachages de mot de passe argon2id et aux autorisations read / write par graphe. Générez un hachage (ne stockez jamais de texte clair) avec :```sh
slater hash-password 's3cret' # prints a $argon2id$… string for acl.json
A starter `acl.json` ships at the repo root; its shape is:```json
{
"users": {
"reporting": {
"passwordArgon2id": "$argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>",
"grants": {
"people": ["read"],
"products": ["read", "write"]
}
}
}
}
users — une entrée par connexion, indexée par nom d'utilisateur.
passwordArgon2id — la chaîne $argon2id$… issue de slater hash-password
(jamais en clair ; le fichier lui-même est du JSON brut et réside sur un stockage partagé).
grants — listes de capacités par graphe. Deux permissions sont significatives :
read — interroger le graphe. Un graphe absent des grants d'un utilisateur lui est invisible.write — modifier le graphe via la couche inscriptible (delta.enabled) : les
instructions MERGE / SET / DELETE et CALL slater.consolidate().Montez-le en lecture seule au chemin nommé par aclPath (par défaut /config/acl.json).
Le serveur le recharge à chaque permutation de génération à chaud, et le tampon ACL au repos est
revérifié à chaque rechargement (voir requireAclStamp).
Le binaire slater sert également de sonde de vivacité : slater healthcheck [host] [port] effectue une poignée de main Bolt (pas une requête HTTP) auprès du serveur et
se termine avec 0 s'il négocie une version de protocole, 1 sinon — en utilisant par défaut
localhost et le port Bolt configuré. C'est ce que la HEALTHCHECK du
conteneur exécute, afin que les orchestrateurs voient un serveur réellement prêt pour Bolt, et pas seulement un
socket ouvert :```sh
slater healthcheck localhost 7687 # exit 0 = healthy
docker exec slater /app/slater healthcheck # inside the container
## One-shot query
Pour les scripts, les vérifications CI et les recherches rapides, `slater query` monte la
génération actuelle d'un graphe, exécute une seule requête Cypher en lecture seule en
processus, affiche le résultat sous forme d'objet JSON, puis se termine — sans serveur,
sans connexion Bolt. Il respecte la même configuration que le serveur (backend de stockage,
clé de chiffrement, budgets de requête) :```sh
# GRAPH defaults to `defaultGraph`. Without -q, normal datestamped logging
# (config, "opened generation", …) is written to stdout alongside the result.
slater query mygraph 'MATCH (n) RETURN count(n) AS c'
# -q/--quiet ⇒ logging suppressed, so stdout is *only* the compact result JSON
slater query mygraph -q 'MATCH (c:Company) RETURN c.ticker AS t LIMIT 3' | jq
# {"columns":["t"],"rows":[["AUPH"],["KYMR"],["MREO"]]}
Nodes and relationships expand to their labels/type and properties. Use -q
when you want machine-parseable output (the result JSON is the only thing on
stdout); omit it for an operator-facing run with logs. Without -q a
metrics-only summary is logged after each run — e.g.```text
INFO query executed cost=2389 resultCount=10 execMs=441 limitRowCount=10
carrying the requête `cost` (éléments facturés), `resultCount`, `execMs`, et
`limitRowCount` (uniquement lorsque la requête spécifie une `LIMIT`) — jamais le texte de la requête
ni aucune valeur de résultat. Le statut de sortie est `0` en cas de succès, `1` en cas d'erreur
d'analyse/ouverture/exécution (message sur stderr).
## Exporter un graphe (`slater dump`)
`slater dump` exporte un graphe depuis un serveur **en cours d'exécution** sous forme de Cypher `MERGE`
à clé métier — le même dialecte que `slater-build` ingère — afin qu'un graphe fasse un aller-retour
(dump → `slater-build` → nouvelle génération) pour la migration ou la sauvegarde texte. Contrairement à
`slater query`, il se connecte via **Bolt**, s'authentifie et respecte les ACL par graphe,
il n'a donc besoin d'aucun accès disque au serveur. Le mot de passe est lu depuis
`SLATER_DUMP_PASSWORD` ou stdin (jamais via un flag, ce qui le maintient hors de `ps`/historique).```sh
# List the graphs the authenticated user may read.
SLATER_DUMP_PASSWORD=pw slater dump --list -u reporting
# Dump a graph to a file (identity keys inferred from range indexes).
SLATER_DUMP_PASSWORD=pw slater dump people -u reporting -o people.cypher
# Rebuild it into a fresh generation.
slater-build --input people.cypher --graph people --data-dir ./data
Chaque clé d'identité d'un label est la propriété portée par son index de plage ; remplacez-la
avec --key Label=prop (répétable) ou un --pk <champ> global. Le DDL CREATE INDEX
est émis en premier afin que la reconstruction recrée les index. Un nœud multi-label
conserve chaque label — il est émis sous la forme MERGE (n:Ident:Autre {clé: v}), avec
le label d'identité (celui fournissant la clé métier) en premier et le reste
trié ; le merge est basé uniquement sur le label d'identité, donc les labels de fin
sont écrits sur le nœud sans en créer un autre. Les labels, les types de relations
et les clés de propriétés contenant des caractères spéciaux sont entre backticks lors de
l'émission, de sorte que les noms inhabituels font un aller-retour fidèle et ne peuvent pas injecter de Cypher dans la
reconstruction. Les vecteurs (et autres
valeurs sans orthographe littérale Cypher) ne peuvent pas être transportés par un dump MERGE et sont supprimés
avec un avertissement sur stderr. Le statut de sortie est 0 en cas de succès, 1 en cas d'erreur.
Une procédure pas à pas complète et exécutable — construire un graphe, le servir, se connecter avec les pilotes neo4j JavaScript et Python, et y écrire — se trouve dans les pages Quickstart et Écriture de données du manuel, en utilisant le graphe d'exemple fourni dans docs/manual/examples/.
export PATH="$HOME/.cargo/bin:$PATH" cargo build cargo test # unit + the bounded-RSS headline integration test cargo clippy --all-targets -- -D warnings cargo fmt --all -- --check
### Les backends de stockage d'objets sont des fonctionnalités cargo optionnelles
Une simple `cargo build` produit un binaire **uniquement basé sur le système de fichiers** — les
backends `s3` et `gcs` sont conditionnés derrière des fonctionnalités cargo afin que la compilation
par défaut reste légère (pas de SDK AWS ou Google, pas de runtime asynchrone). Activez celui dont
vous avez besoin sur **les deux** `slater` (serve) et `slater-build` (publish) :```sh
# S3 only / GCS only / both
cargo build -p slater -p slater-build --features s3
cargo build -p slater -p slater-build --features gcs
cargo build -p slater -p slater-build --features s3,gcs
Chaque crate expose des fonctionnalités s3 / gcs correspondantes qui renvoient vers
graph-format/{s3,gcs}. Demander un backend au moment de l'exécution
(dataBackend.kind=s3|gcs, ou slater-build --publish-{s3,gcs}-*) sans que sa
fonctionnalité soit compilée échoue rapidement avec une erreur claire « built without the … feature ».
L'image Docker publiée active les deux (Dockerfile CARGO_FEATURES), donc
les images préconstruites n'ont besoin d'aucun drapeau supplémentaire — cela ne concerne que la compilation
à partir des sources.
Les tests d'intégration sont également conditionnés de la même manière : --features s3 --test s3_minio,
--features gcs --test gcs_emulator (un fake-gcs-server), et --features gcs --test gcs_real (vrai GCS via ADC) ; chacun est ignoré sauf si ses variables d'environnement SLATER_* sont
définies.
Voir docs/PLAN.md, docs/PROGRESS.md et docs/DECISIONS.md pour la conception,
le registre des jalons et le journal des décisions.
Jusqu'à six moteurs, une suite mono-client, des graphes allant d'un jouet de 62k nœuds à Wikidata
91,6M nœuds / 1,5B arêtes. Chaque moteur est mesuré isolément (chaque autre conteneur
arrêté — RSS et latence sont son empreinte propre). Les tableaux de latence ci-dessous ont été
re-mesurés sur Slater 0.21.0 (la version inscriptible) : les graphes petits/moyens (MeSH, EU-AI-Act)
fraîchement, et le graphe de 91,6M comme une passe même-machine, ancre partagée slater-vs-Neo4j
fraîche (voir ce tableau). Les chiffres de mémoire résidente sont reportés de la passe précédente (mesurés via
le cgroup du conteneur ; le chemin de lecture est octet pour octet identique avec la couche inscriptible inactive). Les chiffres des
autres moteurs sont ceux de la passe inter-moteurs établie (leurs versions/performances sont inchangées).
Tous les chiffres sont des médianes (ms) ou de la mémoire résidente de pointe (MiB). Plus bas est meilleur partout ; gras =
meilleur de la ligne. slater a été exécuté sur son backend système de fichiers local (fs) ; les backends S3 et GCS échangent la latence de
lecture locale contre des allers-retours vers le stockage d'objets (atténués par les caches en mémoire et le niveau de cache
disque local optionnel), donc ces chiffres caractérisent le moteur, pas un déploiement sur stockage réseau.
Les trois moteurs qui paginent depuis le disque — slater, Neo4j 5 et LadybugDB — chargent les cinq graphes. Le trio en mémoire (Memgraph · FalkorDB · ArcadeDB) ne peut pas contenir le graphe de 1,5B arêtes du tout (il nécessite ~64–128 Gio résidents), et l'importateur d'ArcadeDB ne peut pas non plus le terminer.
Chaque chiffre est la mémoire de travail engagée — ce que l'OS ne peut pas récupérer. Chaque moteur sauf slater conserve son graphe dans de la mémoire anonyme engagée (propre tas, cache de pages hors tas de Neo4j, ou un pool de tampons), donc son RSS de pointe est son empreinte engagée. Seul slater sert depuis le cache de pages OS récupérable de son stockage sur disque, donc son chiffre est l'ensemble de travail anonyme ; le cache de pages du stockage (pouvant être évincé sous pression — slater continue de servir) est exclu, et affiché comme total entre parenthèses pour le graphe de 91,6M. Gras = le plus bas.
slater est le plus bas à chaque échelle et croît ~50× alors que le graphe croît ~1 500× — son
empreinte suit l'ensemble de travail de la requête, pas le graphe (inactif ~16–71 Mio en continu). Le
trio en mémoire croît ~linéairement et ne peut pas charger le graphe de 1,5B ; Neo4j engage un tas de ~2 Gio
quel que soit la requête. († LadybugDB uniquement sur les formes bornées — ses traversées hub / longueur variable /
plus court chemin à 1,5B arêtes nécessitent que son pool de lecture soit élevé à ≥2 Gio, contre le plafond automatique
maxIntermediate de slater.) Les histogrammes valeur→compte au moment de la compilation ajoutent une mémoire résidente négligeable —
quelques Ko pour une colonne indexée à faible cardinalité, et zéro pour les graphes à clé unique comme Wikidata
(wikidata_id dépasse le plafond de cardinalité de l'histogramme, donc aucun n'est stocké) — donc ces chiffres sont
inchangés par cette fonctionnalité.
slater possède les formes métadonnées / index / scan (count, étiquette, idx-eq, scan — ~0,4 ms, 10–200× les moteurs de service), la recherche ponctuelle indexée (0,43 ms, désormais au coude-à-coude avec les 0,48 ms de la paire en mémoire), le multi-saut sans ancre (2-sauts 1,40 ms via le scan par type de relation, le plus rapide du domaine), et — via un histogramme valeur→compte au moment de la compilation sur la clé de regroupement indexée — le group-by / count(DISTINCT) sur étiquette entière (0,45 ms, devant les 5,3 ms columnaires de LadybugDB). Les serveurs en mémoire ne conservent que le 1-saut brut (Memgraph 1,21 ms contre 1,28 ms pour slater). (pole 62k/106k semble identique : slater seul le plus rapide sur count/scan ~0,4 ms, ~1,3–2,6 ms sur les sauts.)
slater répond au kNN avec un scan exact par force brute (ces ensembles sont sous son seuil ANN de 50k vecteurs) là où les autres utilisent un HNSW résident approximatif — donc les résultats de slater sont exacts (rappel 1,0). Un noyau de distance SIMD + une matrice de vecteurs résidente, pré-normalisée, a fait passer Concept de ~23 → ~2,9 ms et Chunk de ~10 → ~2,4 ms, donc slater bat désormais Neo4j et LadybugDB et est à ~1,4× de Memgraph, ne cédant qu'à FalkorDB — tout en étant exact.
Les tableaux ci-dessus sont des comparaisons lecture inter-moteurs. Le chemin d'écriture vectorielle (l'échelle
d'écriture de style FreshDiskANN sur la base Vamana statique)
n'a pas d'équivalent inter-moteurs — aucun autre moteur ici ne fait de l'ANN natif disque, inscriptible —
donc les chiffres ci-dessous sont des benchmarks composants mono-moteur sur un jeu de données synthétique,
de type plongement (une variété de bas rang, dim 768, normes inégales), validés sous
crates/slater/benches/ et documentés en détail — avec la
méthodologie et toutes les réserves — dans docs/PERF-REPORT.md. Le rappel est
toujours mesuré contre une force brute exacte sur l'ensemble vivant, jamais un index contre un autre.
L'échelle ici est représentative et extrapolée uniquement là où la métrique est linéaire en taille.
Le seul chiffre qui mérite le banc de performance dédié est le débit de réécriture de consolidation sur le chemin lent — lorsqu'une consolidation porte des suppressions ou de nouveaux vecteurs plutôt qu'une permutation pure, c'est une recompression séquentielle limitée par le zstd mono-thread et le disque local, donc le MiB/s absolu dépend de l'environnement (le rapport montre la forme et explique la plage environnementale).
Les moteurs en mémoire (Memgraph / FalkorDB / ArcadeDB) ne peuvent pas charger ce graphe du tout
(~64–128 Gio résidents). Seuls slater et Neo4j 5 le peuvent. C'est une passe fraîche même-machine, même-jour
contre un ensemble d'ancres fixe et partagé — chaque requête touche les nœuds identiques sur les deux moteurs,
donc le face-à-face est comparable (un pool commun wikidata_id d'ancres à degré modéré ;
voir la note ci-dessous sur pourquoi cela compte). slater est affiché aux deux fanouts (query.maxFanout 1 =
défaut orienté débit, 8 = le réglage de latence qui chevauche les lectures de blocs froids). Gras = meilleur de la ligne.
Le tableau honnête : slater domine les formes métadonnées / index — count(*) est
servi par les métadonnées (0,41 ms contre le scan disque de 3,6 s de Neo4j, ~8800×), et la recherche ponctuelle / degré /
3-sauts s'exécute ~2–10× plus vite — est à égalité avec Neo4j sur 1–2-sauts (le fanout 8 prend de l'avance sur les lectures froides),
mais perd var-length *1..2 distinct de manière décisive (≈1 s contre 47 ms pour Neo4j) : l'expansion
distincte à longueur variable de slater est nettement plus lente ici, une vraie faiblesse qui mérite sa propre
investigation. Tout cela à quelques centaines de Mo de RSS contre le tas engagé de ~2 Gio de Neo4j.
Sur les ancres. Ces chiffres de traversée dépendent fortement des nœuds desquels vous partez — un nœud à un lien d'un méga-hub Wikidata (« human », « country ») a un voisinage à 2-sauts de plusieurs millions, donc le coût de longueur variable/saut varie de plusieurs ordres de grandeur selon le choix de l'ancre. La version précédente de ce tableau échantillonnait les « premiers N par scan » propres à chaque moteur, ce qui n'est ni stable ni comparable ; cette passe fixe un ensemble d'ancres unique, partagé et borné en degré pour les deux moteurs. (shortestPath est omis de cette passe — entre deux ancres arbitraires, il dépend de l'existence du chemin et est trop variable pour être médian de manière significative.)
count(*) multi-sauts — mémoire découplée de la taille du résultatLe RETURN count(*) multi-sauts non plafonné compte pendant l'expansion au lieu de matérialiser
les lignes correspondantes. Mêmes ancres hub sur le graphe de 91,6M, maxIntermediate=20M :
| 3-sauts count(*) @ 91,6M | fanout=1 | fanout=8 |
|---|---|---|
| latence / ensemble de travail de pointe | 554 ms / 0,66 Gio | 298 ms / 1,9 Gio |
Le comptage conserve O(1) lignes. La facturation est inchangée, donc un comptage de méga-hub déclenche toujours
maxIntermediate sur le calcul (lectures d'adjacence), borné comme avant.
maxFanout)Augmenter query.maxFanout chevauche les lectures de blocs froides, liées aux E/S d'une requête sur les cœurs —
cela aide les formes liées au disque à grand ensemble de travail froid et est plat sur les formes chaudes. Sur le graphe
de 1,5B : shortestPath ≤6 918 → 608 ms (1,5×, plus grande recherche 6 269 → 2 350 ms, 2,7×) ;
3-sauts count 547 → 298 ms. maxFanout=1 est le défaut (orienté débit) ; 8 est le
réglage de latence, au prix de plus de mémoire de travail transitoire.
Les tableaux complets par moteur (pole, MeSH, EU-AI-Act + le réglage RAM↔latence blockCacheBytes,
Wikidata 1M & 91,6M) sont dans
perf/cross-engine-hs/README.md ; la passe fraîche slater seul
(les deux fanouts, chaque jeu de données) est dans perf/PERF_CURRENT_STATUS.md.
Les benchmarks ci-dessus sont mono-client. L'axe complémentaire — le comportement sous de nombreux
clients concurrents — a son propre harnais, perf/loadtest/ : un pilote
Locust sur Bolt plus un coordinateur qui fait monter la charge, lit CALL slater.diagnostics(),
trouve le coude de capacité et nomme le limiteur (méthode complète dans
docs/LOAD-TESTING.md). Points clés d'une exécution avec cache de 256 Mio sur le
graphe Wikidata-1M (une machine 16 cœurs) :
Les deux problèmes de mémoire révélés par le test de charge sont désormais résolus ; tout est suivi dans le document de test de charge.
Sous licence Apache License, Version 2.0. Voir LICENSE pour le
texte complet et NOTICE pour l'attribution. Sauf déclaration explicite
contraire, toute contribution intentionnellement soumise pour inclusion dans ce travail,
telle que définie par la licence Apache 2.0, sera sous licence comme ci-dessus, sans
conditions ou clauses supplémentaires.
SPDX-License-Identifier: Apache-2.0
REMOVEINSERTSETREMOVEDELETE| Fonctionnalité | Ce que cela signifie pour vous |
|---|
| Mémoire bornée et prévisible | La mémoire résidente suit trois budgets de cache que vous définissez, à une surcharge bornée près par entrée et par allocateur — elle ne croît pas avec la taille du graphe ; vous ajustez le compromis performance/RAM au lieu de provisionner pour tout le graphe. Un allocateur jemalloc avec purge en arrière-plan restitue la mémoire libérée à l'OS après les rafales de requêtes lourdes, donc la taille résidente retombe vers son plancher d'inactivité plutôt que de rester épinglée au niveau haut après la rafale. |
| Multi-locataire prêt à l'emploi | Un serveur héberge de nombreux graphes avec des autorisations de lecture par utilisateur — une isolation multi-bases que la plupart des bases de données graphe réservent à un niveau payant/entreprise. |
| Chiffrement au repos et en transit | Scellement par bloc XChaCha20-Poly1305 (la clé n'est jamais écrite sur disque) plus TLS optionnel (bolt+s://). Conforme au RGPD par construction. Le chiffrement est aussi ce qui achète l'intégrité authentifiée : le constructeur scelle le manifeste avec un MAC à clé, et un serveur détenant la clé le vérifie et refuse de servir une génération dont le manifeste est forgé, altéré ou dont le MAC a été retiré. Une image sans clé (en clair) est protégée uniquement par le hachage de contenu sans clé — complétude et corruption, pas falsification. Voir Ce que signifie l'intégrité dans chaque configuration. |
| Installation minuscule | Un petit binaire réduit sur une base glibc distroless (pas de shell/apt) — l'image multi-arch (amd64/arm64) pèse ~22 Mo au téléchargement, ou ~12 Mo pour la balise slater:latest-lite serveur uniquement ; TLS purement en Rust, pas d'OpenSSL. Tirez et exécutez. |
| Conçu pour la publication périodique | Compilez un graphe hors ligne, servez-le immuable, puis remplacez atomiquement par une nouvelle version sans temps d'arrêt — idéal pour les charges de travail d'entrepôt de données / rafraîchissement planifié. |
| Robuste sous charge | Le serveur et le constructeur hors ligne compilent tous deux avec #![forbid(unsafe_code)] — le seul unsafe du moteur vit dans la crate d'allocateur jemalloc auditée. Le cœur est immuable, donc les lectures ne prennent aucun verrou et n'attendent jamais un écrivain ; un écrivain unique sérialise les mutations derrière le seul chemin d'écriture. Pas de pauses GC, pas de courses de données. Une mauvaise requête ne peut pas faire tomber le serveur. |
| Fonctionne avec vos outils neo4j | Parle Bolt 5.4 / 4.4 / 4.1 — utilisez les pilotes neo4j standard (JS, Python, Go, Java…), cypher-shell ou les navigateurs de graphes sans modification. |
| Surface de requêtes Cypher riche | Une large surface de lecture : MATCH/WHERE/WITH/UNION, sous-requêtes CALL {…}, plus de 70 fonctions et agrégations, valeurs temporelles et géospatiales, et regex. |
| Écritures en direct et durables | Une couche LSM à écrivain unique optionnelle au-dessus du cœur immuable (delta.enabled) : MERGE / SET / DELETE / CREATE / REMOVE par clé métier sur les nœuds et les relations, UNWIND d'écriture par lots (un fsync par lot), et CALL slater.consolidate() — validées en groupe, durables par fsync, et repliées dans un nouveau cœur par consolidation. Le chemin de lecture est octet pour octet identique lorsque le delta est vide. |
| ISO GQL, lecture et écriture | Parle un sous-ensemble d'ISO GQL (ISO/IEC 39075) sur la même connexion Bolt — chemins quantifiés, restricteurs de chemins, sélecteurs de plus court chemin, expressions booléennes d'étiquettes/types, FOR, CAST, un préfixe de dialecte GQL/CYPHER optionnel — et, avec la couche inscriptible activée, les instructions de modification de données de GQL (INSERT / SET / REMOVE / [DETACH] DELETE) descendent sur le même chemin d'écriture durable. Cypher et GQL, lectures et écritures, dans un seul moteur. |
| Vecteurs + graphe dans un seul moteur | Recherche vectorielle ANN native sur disque (Vamana + PQ ; cosinus / L2 / produit scalaire) pour les embeddings/RAG, plus des algorithmes de graphe (PageRank, BFS, betweenness, WCC…) — mémoire bornée même avec des millions de vecteurs. Les embeddings sont inscriptibles (une échelle d'écriture de type FreshDiskANN) : insérez / mettez à jour / supprimez un vecteur, visible en KNN immédiatement, replié dans la base sans reconstruction. |
| Sûr sur stockage réseau | Chaque fichier est haché par contenu BLAKE3 et vérifié à l'ouverture ; les images déchirées ou à moitié copiées sont refusées, pas servies. Conçu pour les volumes NFS/à distance (pas de surprises mmap). |
| Backends de stockage enfichables | Servez le même format de génération depuis un système de fichiers local, un bucket S3 (compatible S3), ou un bucket Google Cloud Storage — publiez une fois, déployez vers des réplicas sans état — avec un cache SSD local optionnel devant le stockage d'objets. Voir Backends de stockage. |
| Chemin | Objectif | Notes |
|---|
/data | Les générations du graphe (<graph>/<uuid>/… + current). | Lecture seule pour les répliques ; produit par slater-build. Peut résider sur un stockage distant/réseau (ex. NFS), donc les lectures ne sont pas supposées avoir des latences de SSD local rapide. |
/sandbox | Superposition de configuration par environnement + secrets. | /sandbox/config.json est fusionné en profondeur sur le config.json intégré ; contient aussi acl.json, le matériel PEM TLS, le fichier de clé au repos. |
/tmp, /run | Espace temporaire (tmpfs). | Une réplique de lecture n'écrit jamais sur disque par défaut. |
(writer) delta.walDir | Le journal d'écriture anticipée + segments delta L0, lorsque delta.enabled. | Inscriptible, et un volume durable et réel — jamais tmpfs (c'est le plancher de durabilité). Un chemin relatif se résout sous le répertoire de données ; donnez à un writer son propre volume persistant ici. |
| (optionnel) cache disque | Le cache de blocs sur disque local, lorsque dataBackend.s3.diskCacheBytes / dataBackend.gcs.diskCacheBytes > 0. | Inscriptible, et un volume réel — pas tmpfs. Utilisé par les backends s3 et gcs ; voir Backends de stockage. |
Elles sont indépendantes : une grant read ne confère aucun accès en écriture. Activer la couche
inscriptible ne peut donc pas promouvoir vos lecteurs existants en rédacteurs. Un rédacteur a besoin
des deux — ["read", "write"] — car résoudre une clé métier pour l'écrire est une lecture.
Les chaînes de permission non reconnues sont ignorées (elles n'accordent rien).
| moteur | classe | limite mémoire |
|---|
| slater | sur disque, paginé | query.maxIntermediate plafonne l'ensemble de travail automatiquement |
| Neo4j 5 | sur disque, JVM | ~2 Gio de tas + hors tas, engagés quel que soit la requête |
| Memgraph · FalkorDB | en mémoire | graphe entier résident en RAM |
| ArcadeDB | en mémoire, JVM | graphe entier résident ; le plus lourd |
| LadybugDB | embarqué, columnar | pool de tampons manuel qui doit dépasser la requête |
| graphe (nœuds / arêtes) | slater | Neo4j 5 | Memgraph | FalkorDB | ArcadeDB | LadybugDB |
|---|
| pole — 62k / 106k | 11 | 746 | 114 | 140 | 1 556 | 198 |
| MeSH — 341k / 469k | 63 | 1 083 | 358 | 455 | 1 631 | 121 |
| EU-AI-Act — 21k / 45k (+55 Mio vec) | 99 | 729 | 229 | 312 | 1 948 | 286 |
| Wikidata — 91,6M / 1,5B | 584 (4 595 total) | ~2 900 | chargement impossible | chargement impossible | chargement impossible | ~652 † |
| forme | slater | Neo4j 5 | Memgraph | FalkorDB | ArcadeDB | LadybugDB |
|---|
| count(*) tous les nœuds | 0,41 | 15,0 | 23,8 | 16,4 | 82,0 | 2,2 |
| comptage d'étiquette | 0,42 | 4,2 | 20,7 | 1,1 | 4,4 | 4,3 |
| recherche ponctuelle indexée | 0,43 | 3,9 | 0,48 | 0,48 | 0,65 | 8,8 |
| comptage idx-eq | 0,42 | 4,9 | 5,0 | 2,0 | 381 | 2,5 |
| 1-saut (ancre indexée) | 1,28 | 5,8 | 1,21 | 4,1 | 390 | 4,9 |
| 2-sauts (sans ancre) | 1,40 | 5,6 | 8,5 | 16,7 | 444 | 6,4 |
| group-by / count(DISTINCT) | 0,45 | 47–51 | 63–64 | 31–39 | 411 | 5,3 |
CONTAINS plein scan | 0,43 | 5,4 | 24,1 | 1,7 | 16,3 | 4,1 |
| forme | slater | Neo4j 5 | Memgraph | FalkorDB | LadybugDB |
|---|
| kNN top-10 Concept | 2,9 | 8,6 | 1,9 | 1,2 | 2,8 |
| kNN top-10 Chunk | 2,4 | 5,7 | 1,9 | 1,5 | 3,2 |
| propriété | mesuré | pourquoi c'est important |
|---|
| Latence KNN vs écritures en attente | index RW ~1,5–2 ms, plat jusqu'à 50k en attente ; la surcouche par force brute pré-indexée 1,9 → 115 ms (linéaire en delta) — 61× à 50k | la latence de requête ne se dégrade pas à mesure que les écritures s'accumulent entre les consolidations |
| Insertion de plongement | ~1,5–2 ms par vecteur dans l'index vivant | une écriture est visible en KNN immédiatement ; le budget de reconstruction du delta est ≈ 2 ms × le plafond du delta |
| E/S de suppression à iso-rappel | 2,9× moins de récupérations de nœuds par requête à 67 % de suppressions, 5,2× à 80 % (rappel ≥ 0,90) | un graphe consolidé ne paie aucune taxe de lecture pour les vecteurs supprimés |
| Consolidation, permutation pure | O(1) — le .vamana est lié en dur, octet pour octet identique, seule la colonne d'identifiant est réécrite | intégrer les écritures vectorielles dans la base évite la reconstruction O(N·R·L) |
| Rappel sur toute l'échelle | consolidé ≥ base pour cosinus, L2 et produit scalaire | l'échelle d'écriture préserve le rappel à chaque barreau |
| forme | slater (fan 1) | slater (fan 8) | Neo4j 5 |
|---|
| count(*) tous les nœuds | 0,41 | 0,41 | 3606 |
| recherche ponctuelle (indexée) | 0,72 | 0,49 | 6,3 |
| degré (comptage 1-saut) | 0,43 | 0,44 | 6,0 |
| voisins 1-saut | 9,8 | 4,5 | 10,1 |
| 2-sauts | 37 | 23 | 34,5 |
| 3-sauts | 32 | 25 | 74 |
longueur variable *1..2 distinct | 985 | 1056 | 47 |
| dimension | slater | meilleur du domaine | verdict |
|---|
| mémoire résidente, à toute échelle | 11–584 Mio (62k → 91,6M) | en mémoire 1,5–2,7 Gio ; ne peut pas charger 1,5B | slater |
| count / métadonnées / scan | ~0,4 ms | moteurs de service 5–80 ms | slater (10–200×) |
| recherche ponctuelle indexée | 0,43 ms (MeSH) | Memgraph · FalkorDB 0,48 ms | slater (devance la paire en mémoire) |
| multi-sauts sans ancre (lignes) | 1,40 ms (MeSH 2-sauts) | Neo4j 5,6 ms | slater (scan par type de relation) |
| agrégation (group-by / DISTINCT) | 0,45 ms | LadybugDB 5 ms (columnar) | slater (histogramme au moment de la compilation) |
| kNN | 2,4–2,9 ms (exact) | FalkorDB 1,2 ms (HNSW) | bat Neo4j/Ladybug ; ~1,4× derrière Memgraph ; exact |
| 91,6M métadonnées / point / degré / 3-sauts | 0,4–32 ms | Neo4j 6–3 600 ms | slater (2–8800×) |
| 91,6M 1–2-sauts | 4,5–23 ms (fan 8) | Neo4j 10–35 ms | ~à égalité |
91,6M longueur variable *1..2 distinct | ~1 s | Neo4j 47 ms | Neo4j (un vrai point faible de slater) |
count(*) multi-sauts à l'échelle | 0,3–0,6 Gio | moteurs en mémoire matérialisent l'ensemble de lignes | slater, borné |
| résultat | mesure |
|---|
| Tient jusqu'à 1000 clients concurrents, zéro échec | le débit culmine à ~2,5k rps ; le coude de latence s'installe vers 750 clients (p99 51 → 750 ms) — mise en file sous contention de cœurs, pas un plafond dur (exécution unique, WSL2) |
| Cache de blocs borné et efficace | taux de succès de 100 %, 0 évictions, 50 Mo résidents pour un ensemble de travail adapté au cache |
| RSS maintenu sous charge soutenue | l'allocateur jemalloc maintient le RSS à ~0,6 Go sur une rampe wiki_cache_churn de 100→500 clients — lié au cache et stable, sans aucun réglage MALLOC_* (l'ancien MALLOC_ARENA_MAX=2 + seuil de purge est retiré) ; sa purge en arrière-plan restitue également le pic post-rafale au lieu de le laisser épinglé |
| Mémoire agrégée bornée | query.maxIntermediateGlobal à l'échelle du serveur + expansion facturée par adjacence tiennent l'inondation wiki_budget à 2-sauts à 1000 clients sans OOM (RSS ~0,6 Go ; le garde-fou écarte ~60 % des requêtes hub comme erreurs de budget réessayables) |