
slater v0.24.3
GraphDB a basso consumo di memoria con supporto Bolt+TLS, crittografia at-rest e vettori, progettato per casi d'uso di grafi con replica locale.
Slater
Versione attuale: v0.24.4 — tutte le release.
In una riga: Slater serve grafi che non entrano in memoria — centinaia di milioni di nodi e miliardi di archi in poche centinaia di MB di RAM — tramite Bolt standard, quindi qualsiasi driver neo4j funziona senza modifiche, con ricerca vettoriale nativa su disco accanto al grafo, e accetta scritture live e durevoli senza rinunciare a tutto questo. La memoria residente è determinata da un budget di cache scelto da te, non dalla dimensione del grafo.
Scorciatoie
Perché Slater esiste
Un database a grafi memorizza i dati come cose (nodi) e relazioni tra essi (archi), trattando le relazioni come cittadini di prima classe. È ciò che vuoi quando le tue domande riguardano le connessioni piuttosto che le righe — "chi si trova entro tre salti da questo account?", "qual è l'intera catena di dipendenze dietro questa build?", "quali account condividono un dispositivo, un indirizzo e una carta?" — query che in SQL diventano una palude di join ricorsivi ma che in un grafo emergono naturalmente.
Il reclamo più comune sui database a grafi è che non scalano oltre ciò che puoi tenere in RAM. Molti di essi (ad es. neo4j, Memgraph, FalkorDB, ecc.) mantengono l'intero grafo in memoria: un grafo da 40 GB richiede 40 GB di memoria — per istanza. Vuoi una replica per regione, per tenant o per pod? Moltiplica la bolletta. E oltre una certa dimensione semplicemente non si caricano: ad es. il grafo Wikidata da 90 milioni di nodi / 1,5 miliardi di archi richiede ~64–128 GiB residenti, quindi i motori in-memory non riescono proprio ad aprirlo.
Slater è la confutazione. Invece di caricare il grafo in memoria, lo compila una volta, offline: slater-build trasforma i tuoi dati in un'immagine immutabile su disco e indirizzata per contenuto, e un numero qualsiasi di server Slater serve quell'immagine tramite Bolt (così i tuoi driver neo4j esistenti funzionano senza modifiche), caricando i blocchi a richiesta e tenendo residente solo un budget di cache fisso. È così che lo stesso grafo da 90M nodi viene servito da poche centinaia di MB di RAM — dimensione del grafo e spesa di memoria sono disaccoppiate. Un grafo da 4 GB e uno da 400 GB costano la stessa RAM da servire, quindi puoi distribuire repliche di lettura economiche e senza stato, lasciando che sia lo storage, non l'heap, a contenere il grafo.
Questo lo rende una scelta naturale per knowledge graph dietro RAG, grafi di raccomandazione e identità, grafi di dipendenze — qualsiasi cosa grande e connessa che vuoi interrogare in modo economico e frequente. La ricerca vettoriale nativa su disco vive proprio accanto al grafo, quindi lo stesso motore è anche il livello di retrieval per gli embedding.
Compilato una volta non significa però congelato. Quell'immagine è una base, non uno stato finale: un livello di scrittura opzionale si posiziona sopra di essa, così un grafo live può essere corretto ed esteso senza ricostruire nulla.
Letture e scritture
Il core è immutabile; il grafo no. Attiva il livello scrivibile (delta.enabled) e scrivi tramite Bolt — correggi una proprietà, aggiungi un nodo, ritira un arco — e la modifica viene persistita in modo durevole, senza ricostruire l'immagine. Ciò che mantiene bassi i costi lato lettura è dove vivono le scritture.
Le scritture si accumulano in un livello log-structured-merge (LSM) sopra il core immutabile: un write-ahead log e una tabella in memoria, riversandosi in segmenti delta immutabili, ripiegati in un nuovo core da una consolidazione periodica. Ecco cosa ottieni:
- Le letture su un grafo senza scritture costano esattamente quanto prima. Un delta vuoto è un singolo ramo prevedibile, non una merge — il percorso di lettura è byte-identico sia che il livello scrivibile sia attivo o meno.
- Il costo di lettura di una scrittura scala con la dimensione del delta, non con quella del grafo. Le risposte sull'intero grafo —
count(*), i marginali di etichette e tipi di relazione — restano letture di metadati anche con scritture in sospeso: il delta mantiene i propri contatori, quindi uncount(*)su un core da 91,6M nodi con mezzo milione di scritture pendenti risponde ancora in decine di millisecondi senza toccare un singolo blocco. - Confermato significa durevole. Un singolo writer svuota la coda e restituisce
SUCCESSsolo dopo l'fsyncche copre la scrittura. Raggruppa le tue scritture e diventano economiche — unaUNWINDdi scrittura commita unfsyncper batch, non per riga. - Scritture per chiave di business, in entrambi i dialetti.
MERGE/MATCH … SET/DELETE(eCREATE/REMOVE, detach delete, scritture di relazioni) basate sulla proprietà identità di un nodo — oppure le equivalenti istruzioni di modifica dati ISO GQL (INSERT/SET/REMOVE/DELETE), che ricadono sullo stesso percorso. Correggi, inserisci, esegui upsert e ritira, su nodi e archi, indirizzandoli nel modo in cui i tuoi dati sono già organizzati.
Con il livello disattivato — l'impostazione predefinita — Slater serve il puro core immutabile e rifiuta le scritture. Vedi Il livello scrivibile per il modello completo.
Sul nome. Slater prende il nome dall'agente della CIA in Archer (una grande serie) che insiste per essere chiamato con un solo nome — "Just… Slater" — e uno dei miei personaggi preferiti della serie. Vedi la pagina wiki del personaggio.
Cosa ottieni
- RAM determinata dal tuo budget di cache, non dalla dimensione del grafo — crea quante repliche di lettura vuoi; il grafo non deve mai stare in memoria.
- Un drop-in per il grafo — parla Bolt, quindi qualsiasi driver neo4j standard (JS, Python, Go…) funziona senza modifiche. È Cypher (più una fetta di ISO GQL, letture e scritture); niente di nuovo da imparare.
- Scritture live e durevoli — un livello LSM opzionale sopra il core immutabile:
MERGE/SET/DELETEper chiave di business su nodi e archi, group-committed e durevoli viafsync, ripiegate in un nuovo core dalla consolidazione. Le letture non ne pagano il costo. - Deployment tramite scambio di file — costruisci una nuova generation con hash del contenuto offline, capovolgi atomicamente il puntatore
currente i server la raccolgono. Ogni blocco è checksummed, quindi un'immagine copiata a metà viene rifiutata, non servita. - Ricerca vettoriale integrata — il nearest-neighbour approssimato nativo su disco (cosine, L2 o dot KNN) sta proprio accanto al tuo grafo, per quando questo è il livello di retrieval dietro una pipeline RAG, e gli embedding sono scrivibili in place — nessuna ricostruzione offline per aggiungere o modificare un vettore.
- Bloccato per design — i permessi di lettura e scrittura sono indipendenti, più crittografia at-rest opzionale, Bolt TLS, ACL con hash argon2id e rootfs del container in sola lettura per le repliche di lettura. Configura una master key e l'immagine su disco è autenticata oltre che cifrata — il suo manifest porta un MAC con chiave, quindi un attaccante con accesso in scrittura alla directory dei dati ma senza chiave non può forgiare un manifest che il server accetterà. Senza chiave hai comunque l'hash del contenuto, che intercetta un'immagine copiata a metà o corrotta — ma non una manomissione deliberata. Quale configurazione compra cosa.
Caratteristiche
| Caratteristica | Cosa significa per te |
|---|---|
| Memoria limitata e prevedibile | La memoria residente segue tre budget di cache che imposti tu, entro un overhead delimitato per voce e per allocatore — non cresce con la dimensione del grafo; regoli il compromesso prestazioni/RAM invece di dimensionare per l'intero grafo. Un allocatore jemalloc con purge in background restituisce la memoria liberata al sistema operativo dopo picchi di query intensi, così la dimensione residente torna verso il suo minimo a riposo invece di restare ancorata al picco post-burst. |
| Multi-tenant pronto all'uso | Un singolo server ospita molti grafi con permessi di lettura per utente — isolamento multi-database che la maggior parte dei database a grafi riserva a un livello a pagamento/enterprise. |
| Crittografia at-rest e in transito | Sigillatura XChaCha20-Poly1305 per blocco (la chiave non viene mai scritta su disco) più TLS opzionale (bolt+s://). GDPR-friendly per costruzione. La crittografia è anche ciò che garantisce l'integrità autenticata: il builder sigilla il manifest con un MAC con chiave, e un server che detiene la chiave lo verifica e rifiuta di servire una generation il cui manifest sia stato forgiato, alterato o privato del proprio MAC. Un'immagine senza chiave (plaintext) è protetta solo dall'hash del contenuto senza chiave — completezza e corruzione, non manomissione. Vedi Cosa significa l'integrità in ogni configurazione. |
| Installazione minima | Un piccolo binario stripped su base distroless glibc (niente shell/apt) — l'immagine multi-arch (amd64/arm64) pesa ~22 MB al pull, o ~12 MB per il tag solo server slater:latest-lite; TLS in puro Rust, senza OpenSSL. Scarica ed esegui. |
| Pensato per pubblicazioni periodiche | Costruisci un grafo offline, servilo immutabile, poi sostituisci atomicamente una nuova versione con zero downtime — ideale per carichi di lavoro data-warehouse / aggiornamenti schedulati. |
| Robusto sotto carico | Sia il server che il builder offline compilano con #![forbid(unsafe_code)] — l'unico unsafe del motore vive nella crate auditata dell'allocatore jemalloc. Il core è immutabile, quindi le letture non usano lock e non aspettano mai uno scrittore; un singolo writer serializza le mutazioni dietro il solo percorso di scrittura. Niente pause GC, niente data race. Una query sbagliata non può far cadere il server. |
| Funziona con i tuoi strumenti neo4j | Parla Bolt 5.4 / 4.4 / 4.1 — usa i driver neo4j standard (JS, Python, Go, Java…), cypher-shell o i browser per grafi senza modifiche. |
| Ampia superficie di query Cypher | Una vasta superficie di lettura: MATCH/WHERE/WITH/UNION, subquery CALL {…}, 70+ funzioni e aggregazioni, valori temporali e geospaziali, e regex. |
| Scritture live e durevoli | Un livello LSM a scrittore singolo opzionale sopra il core immutabile (delta.enabled): MERGE / SET / DELETE / CREATE / REMOVE per chiave di business su nodi e relazioni, scritture batch con UNWIND (un fsync per batch) e CALL slater.consolidate() — group-committed, durevole via fsync e ripiegato in un nuovo core dalla consolidazione. Il percorso di lettura è byte-identico quando il delta è vuoto. |
| ISO GQL, lettura e scrittura | Parla un sottoinsieme di ISO GQL (ISO/IEC 39075) sulla stessa connessione Bolt — percorsi quantificati, restrittori di percorso, selettori di shortest-path, espressioni booleane di etichette/tipi, FOR, CAST, un prefisso dialetto opzionale GQL/CYPHER — e, con il livello scrivibile attivo, le istruzioni di modifica dati di GQL (INSERT / SET / REMOVE / [DETACH] DELETE) ricadono sullo stesso percorso di scrittura durevole. Cypher e GQL, letture e scritture, in un unico motore. |
| Vettori + grafo in un unico motore | Ricerca vettoriale ANN nativa su disco (Vamana + PQ; cosine / L2 / dot) per embedding/RAG, più algoritmi grafici (PageRank, BFS, betweenness, WCC…) — memoria limitata anche con milioni di vettori. Gli embedding sono scrivibili (una write ladder in stile FreshDiskANN): inserisci / aggiorna / elimina un vettore, visibile alla KNN immediatamente, ripiegato nella base senza ricostruzione. |
| Sicuro su storage di rete | Ogni file è sottoposto a hash del contenuto BLAKE3 e verificato all'apertura; le immagini troncate o copiate a metà vengono rifiutate, non servite. Progettato per volumi NFS/remoti (nessuna sorpresa con mmap). |
| Backend di storage pluggabili | Serve lo stesso formato di generation da un filesystem locale, un bucket S3 (compatibile S3) o un bucket Google Cloud Storage — pubblica una volta, distribuisci a repliche senza stato — con un livello di cache SSD locale opzionale davanti all'object store. Vedi Backend di storage. |
Due binari compongono il workspace:
| Binario | Ruolo |
|---|---|
slater | Il server Bolt online (ENTRYPOINT del container): serve le letture e, con delta.enabled, il percorso di scrittura durevole a scrittore singolo. |
slater-build | Il compilatore offline: trasforma un dump Cypher primitivo in una directory generation immutabile e con hash del contenuto. |
Slater separa la costruzione in blocco dalla serving: slater-build fa il lavoro pesante offline — ingerendo i tuoi dati e compilandoli in una generation immutabile — così un grafo freddo non viene mai assemblato nel percorso critico del server. All'interno del server, la superficie di lettura risponde a un'ampia fetta di Cypher — pattern matching, subquery WITH/UNION/CALL {…}, 70+ funzioni scalari e aggregate, valori temporali e geospaziali, algoritmi grafici (algo.*) e KNN vettoriale nativo su disco (db.idx.vector.queryNodes) — mentre l'overlay delta del livello scrivibile sta sotto quella superficie ed è a costo zero quando è vuoto, così le letture non si trascinano mai la macchina del lato scrittura. Puoi aggiornare un grafo in due modi: scrivere live tramite Bolt (vedi Il livello scrivibile), oppure costruire una nuova generation offline e scambiare atomicamente il puntatore current, che il server in esecuzione raccoglie tramite la sua generation guard (vedi Generation guard).
Documentazione
Il manuale utente completo si trova in docs/manual/ — una guida feature per feature che spiega, per ogni funzionalità, cos'è, perché esiste e come usarla, con esempi svolti che puoi eseguire su un grafo di esempio incluso. Inizia da lì per qualsiasi cosa oltre questa panoramica.
- Nuovo qui? Avvio rapido costruisce e serve un grafo in cinque passaggi.
- Scrivi query? Querying, Funzioni ed espressioni, Procedure e algoritmi, Ricerca vettoriale, Scrivere dati.
- Costruisci grafi? Costruire grafi e Riferimento CLI di build.
- Operi Slater? Deployment, Storage, Riferimento di configurazione, Sicurezza, Ottimizzazione delle prestazioni.
Esecuzione con Docker
Slater è progettato per essere eseguito come distribuzione Docker — è il modo previsto per usarlo. Immagini multi-arch precompilate (linux/amd64 + linux/arm64) sono pubblicate su Docker Hub su hikarisystems/slater, taggate :latest e :vX.Y.Z a ogni release:```sh
docker pull hikarisystems/slater:latest
Una guida all'uso, alla configurazione e alle operazioni solo tramite comandi Docker è disponibile in
[`DOCKERHUB.md`](https://github.com/hikari-systems/slater/blob/HEAD/DOCKERHUB.md) (ed è rispecchiata nella pagina panoramica di Docker Hub) —
**inizia da lì se stai effettuando il deploy.** In breve:```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
Per creare invece l'immagine localmente (ad es. per lo sviluppo):```sh
Build the image (both binaries).
docker compose build
Serve (expects generations under the slater-data volume / your /data mount).
docker compose up slater
Build a generation with the offline writer (profile build):
docker compose run --rm builder
--input /dumps/people.cypher --graph people --data-dir /data
Lo stage builder installa `cmake`, `clang` e `libclang-dev` per il backend
`aws-lc-rs` di rustls; `git` (già presente nell'immagine base) è necessario per la
dipendenza git+tag `hs-utils`, che `.cargo/config.toml` recupera tramite la CLI git.
Le sezioni seguenti coprono il formato su disco, la configurazione, le ACL e un
esempio pratico locale (non Docker).
## Come funziona```
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>
- Una generation è una directory immutabile: un
MANIFEST.json(tabelle dei simboli, descrittori degli indici, un header di cifratura opzionale), file di blocchi colonnari (node_props.blk,node_labels.blk,edge_props.blk,topology.csr.blk,vectors.f32.blk), indici di range (range/<name>.isam), indici ANN oltre soglia (vector/<label>.<prop>.{vamana,pq}) e un puntatore di testocurrent. - Ogni blocco è compresso con zstd e verificato con checksum BLAKE3; con
--encryptogni blocco è inoltre sigillato con XChaCha20-Poly1305 (AEAD a riposo). - Il server apre una generation ricalcolando l'hash di ogni file rispetto al manifest, quindi un'immagine copiata a metà / troncata — una copia incompleta nella data dir, che può essere un archivio remoto/di rete — viene rifiutata anziché servita.
- Le letture passano attraverso tre pool di cache limitati — una LRU di blocchi decompressi, un pool di indici vettoriali (codici PQ residenti + una LRU di blocchi Vamana) e una LRU dei risultati — ciascuno con il proprio budget di byte. Ogni pool valuta ciò che contiene ed espelle per rimanere sotto il proprio budget, quindi la RSS segue i budget entro un overhead limitato per voce e per l'allocatore invece di crescere con il grafo.
Il livello scrivibile
Con delta.enabled, la generation immutabile diventa il livello inferiore completamente compattato
(il "core") di un piccolo albero log-structured-merge, e le scritture live si appoggiano
sopra di esso:```
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)
└─────────────┘
* **Soglia di durabilità — il WAL.** Ogni mutazione viene serializzata dietro un singolo
writer per grafo, aggiunta a un write-ahead log per grafo, e sottoposta a `fsync` prima
che venga restituito il `SUCCESS` di Bolt — quindi *confermato ⇒ durevole*, e una coda
strappata viene scartata al replay. Un `UNWIND` di scrittura in batch aggiunge le sue righe e committa **un**
`fsync` per l'intero batch. Il WAL è **solo disco locale** (non
viene instradato attraverso il backend di storage), il che rende un nodo *writer* stateful:
necessita di un volume locale durevole in `delta.walDir`. Le repliche di lettura restano stateless.
* **Memtable → L0 → consolidamento.** Le scritture si accumulano in una memtable in-RAM
(delimitata da `delta.memtableBytes`); quando si riempie, viene scaricata in un segmento
delta L0 immutabile. Una **consolidazione** fonde `{core + delta}` in un nuovo core
serializzando nuovamente la vista unita attraverso `slater-build` e scambiando `current`
atomicamente — la stessa protezione tramite hash di contenuto di qualsiasi generazione pubblicata. Puoi attivarla
manualmente con `CALL slater.consolidate()`, automaticamente a `delta.deltaCorePercent`
della dimensione del core (opzionalmente limitato a una finestra fuori picco `delta.consolidateWindow`), oppure
lascia che la soglia `delta.deltaHardBytes` agisca da freno di sicurezza contro la crescita incontrollata.
* **L'overlay si trova sotto la superficie di lettura.** L'esecutore legge attraverso una
`ReadView` che è o il core nudo (delta sempre vuoto) o una vista unita
`(core, delta)`; il motore è monomorfizzato su di essa, quindi un delta vuoto
compila in un singolo ramo prevedibile e il percorso di sola lettura è identico byte per byte.
I contatori dell'intero grafo (`count(*)`, marginali di label/reltype) sono serviti dai
contatori live del delta stesso, quindi restano letture di metadati anche con scritture in sospeso.
* **Una query vede uno snapshot stabile.** Fissa una tupla `(core, delta)` per tutta la
sua vita. Non esistono transazioni multi-istruzione né rollback — una scrittura è
una correzione durevole indirizzata tramite business key, non una transazione OLTP.
La grammatica esatta di scrittura e le manopole sono nella tabella [Configurazione](#environment--configuration)
(`delta.*`) e nell'[esempio pratico](#worked-example) qui sotto.
### Indici di intervallo (ISAM)
Un indice di intervallo (`range/<name>.isam`, uno per ogni `(label, property)` indicizzata) consente a una
`MATCH (n:Label {prop: v})` o `WHERE n.prop <op> v` di risolvere gli id dei nodi corrispondenti
**senza scandire la label**. È una
**[ISAM](https://en.wikipedia.org/wiki/ISAM)** (Indexed Sequential Access Method)
struttura — il classico indice *statico, ordinato, a blocchi*, che è esattamente
la forma giusta per una generazione immutabile: non ci sono inserimenti da ribilanciare, quindi
la semplicità dell'ISAM ottiene ciò che la macchina di mutazione di un B-tree non farebbe che
complicare.
* Le voci `(value, entity_id)` sono ordinate per valore e impacchettate negli stessi
blocchi zstd compressi da 256 KiB come tutto il resto.
* Un piccolo **top-level residente** contiene la prima chiave di ogni blocco (un indice
sparso). Una ricerca esegue una binary search su quel top-level in memoria per trovare il *singolo* blocco
in cui una chiave può trovarsi, legge + decomprime quel blocco e lo scandisce — quindi una ricerca di uguaglianza
è **una lettura di un blocco**, e una scansione di intervallo percorre la sequenza contigua di blocchi
che attraversa. (È per questo che una lookup indicizzata tramite `meshUi` richiede millisecondi a una cifra
mentre la stessa corrispondenza su una proprietà non indicizzata scandisce l'intera label.)
* Il planner la sceglie tramite `NodeScan::RangeEq` / `RangeRange`; un predicato non indicizzato
ripiega su una scansione della label o su una scansione completa, con l'esecutore che ricontrolla
ogni predicato in entrambi i casi.
### Ricerca vettoriale (Vamana + PQ) — coseno, L2 e dot, lettura *e* scrittura
Il KNN vettoriale (`db.idx.vector.queryNodes`) opera su indici **coseno, L2 o dot-product (MIPS)**.
L'indice di base viene costruito offline con due percorsi di esecuzione, scelti per indice da
`--ann-threshold` (predefinito: 50 000 vettori):
* **Sotto la soglia — forza bruta.** I vettori `f32` completi vivono in
`vectors.f32.blk`; una query scandisce il gruppo dell'indice e calcola la distanza esatta nella
metrica dell'indice. Semplice ed esatto; va bene quando l'insieme di vettori è piccolo.
* **Alla soglia o sopra — Vamana + PQ**, il percorso ANN nativo su disco che mantiene
la memoria residente limitata indipendentemente da quanti vettori ci siano:
* **[Vamana](https://arxiv.org/pdf/2401.11324)** è l'indice a grafo della linea di lavoro
DiskANN: un singolo grafo di prossimità i cui archi sono potati (il
grado uscente `--vamana-r` e il fattore `--vamana-alpha` per gli archi lunghi) così che una *greedy
beam search* — si parte dal medoide, si salta ripetutamente verso la query, mantenendo una
lista di candidati di larghezza `vectorQuery.beamWidth` — raggiunge i veri
vicini di un nodo in pochi salti, cioè **poche letture casuali di blocchi per query**. I blocchi
del grafo (`vector/<label>.<prop>.vamana`) vengono paginati attraverso la cache vettoriale,
non trattenuti interamente.
* **[Quantizzazione del prodotto (PQ)](https://medium.com/aiguys/product-quantization-k-nn-for-big-datasets-12431d764c4e)**
comprime ogni vettore in un codice corto (`--pq-subspaces` × `--pq-bits`): le
dimensioni sono divise in sottospazi, ciascuno clusterizzato indipendentemente con k-means, e
il vettore è memorizzato come tupla di id dei centroidi più vicini. Questi codici
(`vector/<label>.<prop>.pq`) sono abbastanza piccoli da restare **residenti**, quindi la
beam search valuta i candidati dalla RAM e solo i pochi vettori completi scelti vengono
letti dal disco. Quel set PQ residente è ciò che il pool `cache.vectorCacheBytes` fissa
in memoria.
**Embedding scrivibili — la scala di scrittura vettoriale (stile [FreshDiskANN](https://arxiv.org/abs/2105.09613)).**
Un embedding indicizzato è un valore scrivibile di prima classe. `SET n.embedding = vecf32([…])` (e `REMOVE`) finisce nel
delta di scrittura ed è **immediatamente visibile al KNN con rango esatto**, poi sopravvive a un flush
di segmento, a un merge e a una consolidazione. Una query unisce fino a tre livelli — l'indice di base
sigillato, un indice per-segmento sigillato e un **indice RW** in memoria (un Vamana mutabile live
sul delta di scrittura) — quindi la latenza rimane piatta mentre le scritture si accumulano, invece di crescere
con il numero di scritture in sospeso. Una cancellazione lascia un *buco*: il nodo smette di essere restituito
ma resta un waypoint di navigazione finché una **delete-consolidation** in background non lo rimuove
dal grafo, quindi le cancellazioni smettono di costare I/O in query. E poiché il grafo su disco
indirizza i suoi vicini per posizione di layout piuttosto che per id di nodo, `CALL slater.consolidate()`
trasporta il Vamana **per riferimento** — hard-link, byte-identico — e riscrive solo una
piccola colonna di id, incorporando le scritture vettoriali nella base **senza** la ricostruzione
del grafo O(N·R·L). I numeri misurati, con le dovute cautele, sono nel [rapporto sulle prestazioni](https://github.com/hikari-systems/slater/blob/HEAD/docs/PERF-REPORT.md).
## Backend di storage (filesystem / S3 / GCS)
Ogni file di una generazione viene aperto attraverso un'astrazione **`ObjectStore`** anziché
direttamente con `std::fs`, quindi lo *stesso* formato byte su disco — blocchi, indici,
manifest, puntatore `current` — viene servito invariato da qualsiasi backend; solo *da dove
provengono i byte* cambia, mai i reader, il motore di query o i
controlli di integrità. Il percorso caldo è fatto di letture posizionali (`read_exact_at`), che si mappano
su una `pread` su un file locale e su una richiesta HTTP byte-range su un object store
— Slater non usa mai mmap, quindi il modello di lettura esplicito e limitato è identico
ovunque.
**Tre backend di prima classe**, selezionati da `dataBackend.kind`. Il filesystem è
il default semplice; **Amazon S3 e Google Cloud Storage sono backend object-store uguali e pienamente
supportati** — l'immagine pubblicata include entrambi compilati
al suo interno, quindi ognuno è solo una questione di configurazione, e una generazione costruita una volta può essere servita da
qualsiasi di essi (anche migrata `fs` → S3 → GCS) senza una ricompilazione.
| `dataBackend.kind` | Lettura posizionale | Integrità all'apertura | Credenziali |
| --- | --- | --- | --- |
| `fs` *(predefinito)* | `pread` | ri-hash BLAKE3 completo di ogni file | — |
| `s3` | HTTP `Range` GET | **SHA-256** del server via `HEAD` (→ ri-hash BLAKE3 del body se assente) | chiavi di config, catena AWS o ruolo IAM |
| `gcs` | lettura HTTP range | **CRC32C** del server via `get_object` (→ ri-hash BLAKE3 del body se assente) | ADC / Workload Identity, o JSON di service-account |
Entrambi gli object store verificano l'integrità dal **checksum che lo store già
calcola e conserva**, recuperato come metadato dell'oggetto: `slater-build` invia il
checksum in upload (lo store valida i byte rispetto ad esso e lo memorizza), e
il server lo rilegge all'apertura e lo confronta con il manifest — una richiesta di metadati
per file, nessun download del body. È a livello di contenuto e identico nello spirito
tra S3 (SHA-256) e GCS (CRC32C). Quando un oggetto non porta **nessun** checksum
memorizzato dal server (copiato out-of-band, o caricato con un default diverso), il server
**ri-calcola l'hash del body dell'oggetto rispetto al BLAKE3 del manifest** piuttosto che fidarsi della
sua lunghezza in byte — un controllo di integrità richiesto non viene mai silenziosamente declassato a un confronto
di dimensione. Le generazioni pubblicate da Slater portano sempre il checksum, quindi restano sul
percorso economico dei metadati.
Ciò che questa colonna controlla, su ogni backend, è che i file **corrispondano al manifest**.
Se il manifest stesso possa essere considerato attendibile è una questione separata, ed è la
chiave master a rispondere: con una chiave configurata, il manifest porta una MAC con chiave
che il server verifica prima di fidarsi di qualsiasi campo (inclusi questi hash), quindi un
manifest riscritto per descrivere file manomessi viene rifiutato; senza una chiave il confronto è senza chiave ovunque, e
chiunque possa scrivere nella directory dei dati può riscrivere un file e il manifest
insieme. Vedi
[Cosa significa l'integrità in ogni configurazione](https://github.com/hikari-systems/slater/blob/HEAD/THREAT_MODEL.md#what-integrity-means-in-each-configuration).
Il controllo stesso può essere disattivato con `dataBackend.verifyIntegrity: false`, il che
lo scambia con un'apertura più veloce.
### Filesystem (`fs`)
Il default, con radice in `dataBackend.fs.dir`. La scelta giusta per la maggior parte
dei deployment: una generazione su un SSD locale (o un mount NFS/EBS) servita in sola lettura.
L'integrità è un ri-hash BLAKE3 completo di ogni file all'apertura.
### Amazon S3 (`s3`)
Un bucket S3 o compatibile con S3 (AWS, MinIO, localstack). Le credenziali arrivano **prima**
dalla config (`dataBackend.s3.awsAccessKey` / `awsSecretKey`, più
`awsSessionToken` per credenziali STS temporanee) e ricadono sulla catena AWS standard
(`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` env, profilo condiviso, o
ruolo instance/IRSA) quando vengono lasciate vuote.```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
Google Cloud Storage (gcs)
Un bucket GCS, raggiunto tramite l'API JSON. L'autorizzazione è nativa di GCP: per impostazione predefinita risolve le Application Default Credentials — GKE Workload Identity, il server di metadati GCE, o una chiave gcloud / GOOGLE_APPLICATION_CREDENTIALS. Imposta dataBackend.gcs.credentialsPath (un file di chiave JSON dell'account di servizio) o inline credentialsJson per una chiave esplicita. dataBackend.gcs.endpoint punta a un emulatore fake-gcs-server, e dataBackend.gcs.anonymous=true abilita l'accesso non autenticato solo per quell'emulatore — mai contro GCS reale.```sh
serve from GCS (env-var form; see the config table for every key)
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
In tutti i casi slater-build scrive prima la generazione completata in --data-dir
(la sua area di staging locale) e inoltre la carica sul bucket; il
puntatore remoto current viene scritto per ultimo, quindi un nodo di servizio non vede mai una
generazione pubblicata a metà.
Quando usare un object store (S3 o GCS)
Ricorri a s3 o gcs quando vuoi le generazioni in un object storage durevole e centrale
piuttosto che sul disco di un nodo — tipicamente: pubblica una volta e distribuisci a molte
repliche di server stateless e senza disco che leggono tutte lo stesso bucket; disaccoppia
l'host di build dagli host di servizio; oppure affidati alla
durabilità/versioning/ciclo di vita dello store invece di gestire volumi. Il compromesso è
la latenza: un blocco freddo è un round-trip di rete (~10–50 ms) invece di una lettura locale
(~0.1 ms). Slater nasconde gran parte di questo con la cache di blocchi in memoria, il
read-ahead concorrente e l'opzionale cache su disco descritta sotto. Se le tue generazioni
risiedono già su storage locale veloce e non ti serve il modello a bucket centralizzato, fs è
più semplice e più veloce.
Cache di blocchi su disco locale (secondo livello dell'object store)
La BlockCache in memoria è deliberatamente piccola (la RSS limitata è la garanzia
principale), quindi su un working set più grande della RAM gli stessi blocchi verrebbero
riletti dall'object store a ogni spill. Un secondo livello di cache opzionale su SSD locale
risolve il problema: un blocco espulso dalla RAM viene servito dal disco locale
(~0.1 ms) invece di una nuova GET dall'object store, sopravvivendo all'espulsione dalla memoria
e riducendo il numero/costo delle richieste all'object store — portando un nodo basato su object
store vicino alle prestazioni di un filesystem locale una volta caldo. È opt-in sia per s3
che per gcs, abilitato impostando dataBackend.<s3|gcs>.diskCacheBytes > 0 e una directory
scrivibile diskCacheDir.
- Memorizza i byte sigillati esattamente come recuperati — già compressi e
(per le generazioni
--encrypt) ancora sigillati AEAD — sotto decrittazione/decompressione. Il livello di cache non detiene mai la chiave di crittografia e non ricrittografa mai, quindi lo stato at-rest viene preservato gratuitamente: una generazione crittografata finisce su disco ancora sigillata. - Le scritture sono write-behind: un miss restituisce immediatamente i byte recuperati alla query, poi un thread in background esegue la scrittura su disco e il trim LRU, quindi il percorso di query non si blocca mai su I/O disco. L'eviction mantiene la cache entro il suo budget di byte; un checksum per file verificato a ogni lettura corregge automaticamente un file di cache corrotto trasformandolo in un miss (→ rilettura dall'object store).
diskCacheDirdeve puntare a un volume reale scrivibile — maitmpfs(tmpfs è RAM e vanificherebbe la garanzia di RSS limitata). L'indice in memoria che la traccia costa un po' di RAM (~decine di byte per blocco cached), che conta contro il tuo tetto RSS — dimensiona la directory ≫ la cache di blocchi in memoria.- L'altro costo di RAM del livello è la coda write-behind, che mette in coda i blocchi
in viaggio verso il disco. È limitata a
blockCacheBytes / 8(con minimodiskCacheBytes) — 8 MiB di default — e rilascia invece di crescere, quindi una scansione a freddo non può gonfiarla; un blocco scartato viene semplicemente riletto al successivo miss. Non richiede configurazione: scala conblockCacheBytes, quindi il livello su disco non aggiunge alcun nuovo numero al budget RSS oltre al suo indice.
Punti di mount
Una replica di lettura viene eseguita con un filesystem root di sola lettura e un utente
non root (appuser:1000) — tutto ciò di cui ha bisogno è montato in sola lettura. Un writer
(delta.enabled) ha inoltre bisogno di un volume durevole e scrivibile per il suo WAL.
| Path | Scopo | Note |
|---|---|---|
/data | Le generazioni del grafo (<graph>/<uuid>/… + current). | Sola lettura per le repliche; prodotte da slater-build. Possono risiedere su storage remoto/network (es. NFS), quindi le letture non sono presupposte a latenze veloci di SSD locale. |
/sandbox | Overlay di configurazione per ambiente + segreti. | /sandbox/config.json viene fuso (deep-merge) sopra il config.json integrato; contiene anche acl.json, materiale PEM TLS, il file della chiave at-rest. |
/tmp, /run | Spazio temporaneo (tmpfs). | Una replica di lettura non scrive mai su disco per impostazione predefinita. |
(writer) delta.walDir | Il write-ahead log + i segmenti delta L0, quando delta.enabled. | Scrivibile e un volume reale e durevole — mai tmpfs (è il livello minimo di durabilità). Un percorso relativo viene risolto sotto la data dir; assegna qui al writer un proprio volume persistente. |
| (opzionale) cache su disco | La cache di blocchi su disco locale, quando dataBackend.s3.diskCacheBytes / dataBackend.gcs.diskCacheBytes > 0. | Scrivibile e un volume reale — non tmpfs. Usata dai backend s3 e gcs; vedi Backend di storage. |
Ambiente / configurazione
La configurazione viene caricata dal loader stratificato standard interno: il config.json integrato,
poi /sandbox/config.json in deep-merge sopra di esso, poi le override di ambiente KEY__sub
(doppio underscore per l'annidamento; le chiavi corrispondono alla config camelCase).
Ogni parametro di configurazione — la sua chiave camelCase, l'override di ambiente KEY__sub, il suo default e cosa fa — è riportato in tabella nel Riferimento configurazione. I parametri più regolati sono i budget di cache (cache.*), le protezioni delle query (query.*), i limiti di connessione (server.*), il backend di storage (dataBackend.*) e il livello scrivibile (delta.*).
La memoria residente è pari a
blockCacheBytes + vectorCacheBytes + resultCacheBytes a meno di un overhead limitato
per voce e dell'allocator — ogni pool pesa i propri contenuti (stringhe e
contenitori per capacità allocata) e rimuove voci per restare sotto budget, ma la
contabilità per voce e l'arrotondamento per classi di dimensione dell'allocator si aggiungono
sopra il numero impostato — più un piccolo overhead fisso (e fino a degreeColumnBytes per la
colonna dei gradi lazy, una volta esercitato il percorso rapido della somma dei gradi
count(endpoint)). È indipendente dalla dimensione del grafo — questa è la garanzia
principale, esercitata dal test di integrazione rss_stays_bounded_under_sustained_knn_load,
che mantiene la crescita RSS picco-vs-caldo ben dentro i budget sommati. I buffer
per connessione vivono fuori dai budget di cache, quindi la garanzia regge sotto carico
avversario solo perché server.maxConnections limita quanti possono esistere
contemporaneamente.
Postura di rete
Slater è il punto di accesso per le repliche di lettura; il controllo primario della
sicurezza delle connessioni è la rete, non il binario. Vincolalo a un'interfaccia privata, limita
i range di origine a livello di rete (security group / NetworkPolicy) e — se deve affrontare
qualcosa di diverso da client fidati — mettilo dietro un proxy L4 con limitazione delle connessioni
(HAProxy maxconn + una stick-table per sorgente, oppure nftables connlimit + hashlimit). Questo si pone
prima che il descrittore di file venga mai passato al processo, quindi è il limite più robusto.
I limiti nel binario sopra (maxConnections, maxPreAuthConnections,
maxConnectionsPerIp, i tetti differenziali di byte e loginTimeoutMs) sono
difesa in profondità: sono attivi e generosi di default, quindi invisibili a una
popolazione legittima di client, ma fanno sì che la garanzia di RSS limitata regga anche quando
il proxy viene dimenticato. Vedi docs/HARDENING.md per l'intera
postura difensiva, e THREAT_MODEL.md / SECURITY_WORKLIST.md per il dettaglio
canonico.
Guardia di generazione
Slater interroga il puntatore current di ogni grafo ogni generationPollMs
(poll, non inotify — la data dir può essere storage remoto/network come NFS,
dove gli eventi di modifica del filesystem non sono affidabili). Quando cambia:
reloadStrategy=exit(default): il server registra un errore fatale ed esce con codice non zero così l'orchestratore lo riavvia in modo pulito sulla nuova generazione.reloadStrategy=swap: il server apre e valida la nuova generazione (stessa protezione con content-hash dell'avvio), la sostituisce atomicamente e lascia che le query in volo finiscano sulla vecchia. Un'immagine nuova corrotta/incompleta viene rifiutata e la vecchia generazione continua a servire.
ACL
acl.json mappa gli utenti a hash della password argon2id e concessioni per grafo di read /
write. Genera un hash (non memorizzare mai testo in chiaro) con:```sh
slater hash-password 's3cret' # prints a $argon2id$… string for acl.json
Un `acl.json` iniziale è incluso nella radice del repository; la sua struttura è:```json
{
"users": {
"reporting": {
"passwordArgon2id": "$argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>",
"grants": {
"people": ["read"],
"products": ["read", "write"]
}
}
}
}
-
users— una voce per login, indicizzata per nome utente. -
passwordArgon2id— la stringa$argon2id$…generata daslater hash-password(mai in chiaro; il file stesso è JSON semplice e risiede su storage condiviso). -
grants— liste di capacità per grafo. Due permessi sono significativi:read— esegue query sul grafo. Un grafo assente dai permessi di un utente gli è invisibile.write— modifica il grafo tramite il layer scrivibile (delta.enabled): le istruzioniMERGE/SET/DELETEeCALL slater.consolidate().
Sono indipendenti: un permesso
readnon conferisce accesso in scrittura. Attivare il layer scrivibile quindi non può trasformare i tuoi lettori esistenti in scrittori. Uno scrittore ha bisogno di entrambi —["read", "write"]— perché risolvere una chiave di business per scriverla è una lettura. Le stringhe di permesso non riconosciute vengono ignorate (non concedono nulla).
Montalo in sola lettura nel percorso indicato da aclPath (predefinito /config/acl.json).
Il server lo ricarica a ogni hot-swap di generazione, e il timbro ACL at-rest viene
ricontrollato a ogni ricaricamento (vedi requireAclStamp).
Health check
Il binario slater funge anche da propria sonda di liveness: slater healthcheck [host] [port] esegue un Bolt handshake (non una richiesta HTTP) contro il server e
termina con 0 se negozia una versione del protocollo, 1 altrimenti — con default su
localhost e sulla porta Bolt configurata. Questo è ciò che esegue la HEALTHCHECK
del contenitore, così gli orchestratori vedono un server realmente Bolt-ready, non solo una
socket aperta:```sh
slater healthcheck localhost 7687 # exit 0 = healthy
docker exec slater /app/slater healthcheck # inside the container
## Query one-shot
Per script, controlli CI e ricerche rapide, `slater query` monta la generazione
corrente di un grafo, esegue una singola query Cypher di sola lettura in-process, stampa il
risultato come oggetto JSON ed esce — niente server, niente connessione Bolt. Rispetta la
stessa configurazione del server (backend di archiviazione, chiave di crittografia, budget di query):```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"]]}
Nodi e relazioni si espandono nelle rispettive etichette/tipi e proprietà. Usa -q
quando vuoi un output machine-parseable (il JSON risultante è l'unica cosa su
stdout); omettilo per un'esecuzione orientata all'operatore con i log. Senza -q un
riepilogo solo metriche viene registrato dopo ogni esecuzione — ad es.```text
INFO query executed cost=2389 resultCount=10 execMs=441 limitRowCount=10
carrying the query `cost` (elementi addebitati), `resultCount`, `execMs` e
`limitRowCount` (solo quando la query specifica un `LIMIT`) — mai il testo della query
o alcun valore di risultato. Lo stato di uscita è `0` in caso di successo, `1` in caso di errore
di parsing/apertura/esecuzione (messaggio su stderr).
## Esportare un grafo (`slater dump`)
`slater dump` esporta un grafo da un server **in esecuzione** come Cypher `MERGE` con chiave di business — lo stesso dialetto che `slater-build` ingerisce — così un grafo compie l'intero ciclo (dump → `slater-build` → nuova generazione) per migrazione o backup testuale. A differenza di `slater query`, si connette tramite **Bolt**, autentica e rispetta le ACL per-grafo, quindi non necessita di accesso al disco del server. La password viene letta da `SLATER_DUMP_PASSWORD` o stdin (mai da un flag, tenendola fuori da `ps`/history).```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
La chiave di identità di ogni etichetta è la proprietà su cui si basa il suo indice range; sovrascrivila
con --key Label=prop (ripetibile) o un --pk <field> globale. Il DDL CREATE INDEX
viene emesso per primo, così la ricostruzione ricrea gli indici. Un nodo con più etichette
mantiene ogni etichetta — viene emesso come MERGE (n:Ident:Other {key: v}), con
l'etichetta di identità (quella che fornisce la chiave di business) per prima e le altre
ordinate; il merge è basato solo sull'etichetta di identità, quindi le etichette successive
vengono scritte sul nodo senza crearne un altro. Le etichette, i tipi di relazione
e le chiavi di proprietà che contengono caratteri speciali vengono racchiusi tra backtick
all'emissione, così i nomi inusuali vengono preservati fedelmente nel round-trip e non possono iniettare Cypher nella
ricostruzione. I vettori (e altri
valori senza una forma letterale in Cypher) non possono essere trasportati in un dump MERGE e vengono scartati
con un avviso su stderr. Lo stato di uscita è 0 in caso di successo, 1 in caso di errore.
Esempio pratico
Una procedura guidata completa ed eseguibile — crea un grafo, servilo, connettiti con i driver neo4j JavaScript e Python e scrivi su di esso — è nelle pagine Avvio rapido e Scrittura dei dati del manuale, usando il grafo di esempio incluso in docs/manual/examples/.
Sviluppo```sh
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
### I backend object-store sono funzionalità cargo opzionali
Un semplice `cargo build` produce un binario **solo filesystem** — i backend `s3` e `gcs` sono dietro funzionalità cargo, così la build predefinita resta leggera (niente SDK AWS o Google, nessun runtime asincrono). Abilita quelli che ti servono su **entrambi** `slater` (serve) e `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
Ogni crate espone le feature corrispondenti s3 / gcs che rimandano a
graph-format/{s3,gcs}. Richiedere un backend a runtime
(dataBackend.kind=s3|gcs, o slater-build --publish-{s3,gcs}-*) senza che la relativa
feature sia compilata fallisce subito con un chiaro errore "built without the … feature".
L'immagine Docker pubblicata abilita entrambe (Dockerfile CARGO_FEATURES), quindi
le immagini precompilate non richiedono flag aggiuntivi — questo conta solo quando si compila dal sorgente.
Anche i test di integrazione sono condizionati (gated): --features s3 --test s3_minio,
--features gcs --test gcs_emulator (un fake-gcs-server), e --features gcs --test gcs_real (GCS reale tramite ADC); ciascuno viene saltato a meno che le sue variabili d'ambiente SLATER_* siano
impostate.
Vedi docs/PLAN.md, docs/PROGRESS.md e docs/DECISIONS.md per la progettazione,
il registro delle milestone e il log delle decisioni.
Prestazioni
Fino a sei motori, una suite a singolo client, grafi da un giocattolo da 62k nodi a Wikidata
91.6M nodi / 1.5B archi. Ogni motore è misurato in isolamento (ogni altro contenitore
fermo — RSS e latenza sono il suo footprint). Le tabelle di latenza seguenti sono state
rimisurate su Slater 0.21.0 (la build scrivibile): i grafi piccoli/medi (MeSH, EU-AI-Act)
di fresco, e il grafo da 91.6M come una nuova passata stessa-macchina, ancora-condivisa slater-vs-Neo4j (vedi
quella tabella). Le cifre della memoria residente vengono dalla passata precedente (misurate tramite
cgroup del contenitore; il percorso di lettura è byte-identico con il livello scrivibile inattivo). I numeri
degli altri motori sono dalla run cross-engine consolidata (le loro versioni/prestazioni sono invariate).
Tutte le cifre sono mediane (ms) o picco di memoria residente (MiB). Ovunque più basso è meglio; grassetto =
migliore nella riga. slater è stato eseguito sul suo backend filesystem locale (fs); i backend S3 e GCS scambiano la latenza
di lettura locale con round-trip verso l'object store (mitigati dalle cache in memoria e dal livello opzionale di cache
su disco locale), quindi queste cifre caratterizzano il motore, non un deployment su storage di rete.
| motore | classe | limite di memoria |
|---|---|---|
| slater | su disco, paginato | query.maxIntermediate limita automaticamente il working set |
| Neo4j 5 | su disco, JVM | ~2 GiB heap + off-heap, impegnati indipendentemente dalla query |
| Memgraph · FalkorDB | in memoria | intero grafo residente in RAM |
| ArcadeDB | in memoria, JVM | intero grafo residente; il più pesante |
| LadybugDB | embedded, colonnare | buffer pool manuale che deve superare la query |
I tre motori che pagiano da disco — slater, Neo4j 5 e LadybugDB — caricano tutti e cinque i grafi. Il trio in memoria (Memgraph · FalkorDB · ArcadeDB) non riesce affatto a contenere il grafo da 1.5B archi (richiede ~64–128 GiB residenti), e l'importatore di ArcadeDB non riesce a completarlo.
Memoria residente (MiB) — limitata mentre il grafo cresce ~1,500×
Ogni cifra è memoria di lavoro committed — ciò che l'OS non può recuperare. Ogni motore tranne slater mantiene il proprio grafo in memoria anonima committed (heap proprio, page cache off-heap di Neo4j, o un buffer pool), quindi il suo RSS di picco è il suo footprint committed. Solo slater serve dalla page cache dell'OS recuperabile del proprio store su disco, quindi la sua cifra è il working set anonimo; la page cache dello store (scaricabile sotto pressione — slater continua a servire) è esclusa e mostrata come totale tra parentesi per il grafo da 91.6M. Grassetto = più basso.
| grafo (nodi / archi) | 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 MiB vec) | 99 | 729 | 229 | 312 | 1,948 | 286 |
| Wikidata — 91.6M / 1.5B | 584 (4,595 totale) | ~2,900 | non caricabile | non caricabile | non caricabile | ~652 † |
slater è il più basso a ogni scala e cresce di ~50× mentre il grafo cresce di ~1,500× — il suo
footprint segue il query working set, non il grafo (inattivo ~16–71 MiB per tutto il tempo). Il
trio in memoria cresce ~linearmente e non può caricare il grafo da 1.5B; Neo4j impegna un heap di ~2 GiB
indipendentemente dalla query. († LadybugDB solo sulle forme limitate — le sue traversate hub / var-length /
shortestPath a 1.5B archi richiedono che il suo read pool sia portato a ≥2 GiB, contro il limite automatico
maxIntermediate di slater.) Gli istogrammi value→count in fase di build aggiungono memoria residente trascurabile —
pochi KB per una colonna indicizzata a bassa cardinalità e zero per grafi con chiave univoca come Wikidata
(wikidata_id supera il limite di cardinalità dell'istogramma, quindi non viene memorizzato) — quindi queste cifre sono
invariate da quella funzionalità.
Latenza (ms mediani) — il grafo sta in RAM (MeSH, 341k / 469k)
| forma | slater | Neo4j 5 | Memgraph | FalkorDB | ArcadeDB | LadybugDB |
|---|---|---|---|---|---|---|
| count(*) tutti i nodi | 0.41 | 15.0 | 23.8 | 16.4 | 82.0 | 2.2 |
| conteggio etichette | 0.42 | 4.2 | 20.7 | 1.1 | 4.4 | 4.3 |
| lookup puntuale indicizzato | 0.43 | 3.9 | 0.48 | 0.48 | 0.65 | 8.8 |
| conteggio idx-eq | 0.42 | 4.9 | 5.0 | 2.0 | 381 | 2.5 |
| 1-hop (ancora indicizzata) | 1.28 | 5.8 | 1.21 | 4.1 | 390 | 4.9 |
| 2-hop (senza ancora) | 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 |
full-scan CONTAINS | 0.43 | 5.4 | 24.1 | 1.7 | 16.3 | 4.1 |
slater possiede le forme metadata / indice / scan (count, label, idx-eq, scan — ~0.4 ms, 10–200× i motori di servizio), il lookup puntuale indicizzato (0.43 ms, ora supera di poco i 0.48 ms della coppia in memoria), il multi-hop senza ancora (2-hop 1.40 ms tramite la scansione per tipo di relazione, il più veloce in campo), e — tramite un istogramma value→count in fase di build sulla chiave di raggruppamento indicizzata — il group-by / count(DISTINCT) su intera etichetta (0.45 ms, davanti ai 5.3 ms colonnari di LadybugDB). I server in memoria mantengono solo 1-hop grezzo (Memgraph 1.21 ms contro 1.28 ms di slater). (pole 62k/106k ha lo stesso aspetto: slater unico più veloce su count/scan ~0.4 ms, ~1.3–2.6 ms sugli hop.)
Latenza (ms mediani) — vettori (EU-AI-Act kNN, 15k × 1024-dim)
| forma | 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 |
slater risponde alla kNN con una scansione brute-force esatta (questi set sono sotto la sua soglia ANN di 50k vettori) mentre gli altri usano un HNSW approssimato residente — quindi i risultati di slater sono esatti (recall 1.0). Un kernel di distanza SIMD + una matrice vettoriale residente e pre-normalizzata ha portato Concept da ~23 → ~2.9 ms e Chunk da ~10 → ~2.4 ms, quindi slater ora batte Neo4j e LadybugDB e resta entro ~1.4× da Memgraph, dietro solo a FalkorDB — pur essendo esatto.
Scaletta di scrittura vettoriale — insert / update / delete senza rebuild
Le tabelle sopra sono confronti in lettura tra motori. Il percorso di scrittura vettoriale (la
write ladder in stile FreshDiskANN sopra la base Vamana
statica) non ha equivalente cross-engine — nessun altro motore qui fa ANN scrivibile
nativo su disco — quindi i numeri seguenti sono benchmark di componente a motore singolo su un fixture
sintetico simile a embedding (una varietà a basso rango, dim 768, norme diseguali), committato in
crates/slater/benches/ e documentato per esteso — con la
metodologia e ogni avvertenza — in docs/PERF-REPORT.md. La recall è
sempre misurata contro una brute force esatta sull'insieme vivo, mai un indice contro
un altro. La scala qui è rappresentativa ed è estrapolata solo dove la metrica è lineare rispetto alla dimensione.
| proprietà | misurato | perché conta |
|---|---|---|
| Latenza KNN vs scritture in sospeso | Indice RW ~1.5–2 ms, piatto fino a 50k in sospeso; l'overlay brute-force pre-indice 1.9 → 115 ms (lineare nel delta) — 61× a 50k | la latenza delle query non degrada mentre le scritture si accumulano tra le consolidazioni |
| Inserimento embedding | ~1.5–2 ms per vettore nell'indice vivo | una scrittura è subito visibile alla KNN; il budget di delta-rebuild è ≈ 2 ms × il limite del delta |
| Delete IO a iso-recall | 2.9× in meno di fetch di nodi per query al 67 % di cancellati, 5.2× all'80 % (recall ≥ 0.90) | un grafo consolidato non paga alcuna tassa di lettura per i vettori cancellati |
| Consolidamento, pura permutazione | O(1) — il .vamana è hard-linkato byte-identico, viene riscritta solo la colonna id | incorporare le scritture vettoriali nella base salta il rebuild O(N·R·L) |
| Recall lungo la scaletta | consolidato ≥ base per cosine, L2 e dot | la write ladder preserva la recall a ogni piolo |
L'unica cifra che merita un box perf dedicato è la velocità di riscrittura della consolidazione in slow-path — quando una consolidazione trasporta delete o nuovi vettori invece di una pura permutazione, si tratta di una ricompressione sequenziale limitata da zstd a thread singolo e dal disco locale, quindi i MiB/s assoluti dipendono dall'ambiente (il report mostra la forma e spiega l'intervallo ambientale).
Latenza (ms mediani) — grafo ≫ RAM (Wikidata 91.6M / 1.5B)
I motori in memoria (Memgraph / FalkorDB / ArcadeDB) non riescono affatto a caricare questo grafo
(~64–128 GiB residenti). Solo slater e Neo4j 5 ci riescono. Questa è una nuova passata stessa-macchina, stesso-giorno
su un set di ancore fisso e condiviso — ogni query colpisce i nodi identici su entrambi i motori,
quindi il confronto diretto è alla pari (un pool comune di ancore wikidata_id a grado moderato;
vedi la nota sotto sul perché conta). slater è mostrato con entrambi i fanout (query.maxFanout 1 =
default di throughput, 8 = la manopola di latenza che sovrappone le letture di blocchi freddi). Grassetto = migliore nella riga.
| forma | slater (fan 1) | slater (fan 8) | Neo4j 5 |
|---|---|---|---|
| count(*) tutti i nodi | 0.41 | 0.41 | 3606 |
| point lookup (indicizzato) | 0.72 | 0.49 | 6.3 |
| grado (conteggio 1-hop) | 0.43 | 0.44 | 6.0 |
| vicini 1-hop | 9.8 | 4.5 | 10.1 |
| 2-hop | 37 | 23 | 34.5 |
| 3-hop | 32 | 25 | 74 |
var-length *1..2 distinct | 985 | 1056 | 47 |
Il quadro onesto: slater domina le forme metadata / indice — count(*) è
servito dai metadati (0.41 ms contro i 3.6 s di scansione disco di Neo4j, ~8800×), e point-lookup / degree /
3-hop girano ~2–10× più veloci — è alla pari con Neo4j su 1–2-hop (il fanout 8 si porta avanti sulle letture fredde),
ma perde nettamente var-length *1..2 distinct (≈1 s contro i 47 ms di Neo4j): l'
espansione distinct a lunghezza variabile di slater è materialmente più lenta qui, una debolezza reale che merita un'indagine
dedicata. Tutto questo a poche centinaia di MB di RSS contro l'heap committed di ~2 GiB di Neo4j.
Sulle ancore. Questi numeri di traversata dipendono fortemente da quali nodi si parte — un nodo a un passo da un mega-hub di Wikidata ("human", "country") ha un intorno 2-hop di milioni di nodi, quindi il costo var-length/hop oscilla di ordini di grandezza con la scelta dell'ancora. L'edizione precedente di questa tabella campionava il proprio "primi N per scansione", di ciascun motore, che non è né stabile né confrontabile; questa passata fissa un unico set di ancore condiviso e limitato in grado per entrambi i motori. (shortestPath è omesso da questa passata — tra due ancore arbitrarie dipende dall'esistenza del percorso ed è troppo variabile per una mediana significativa.)
Multi-hop count(*) — memoria disaccoppiata dalla dimensione del risultato
Un multi-hop RETURN count(*) senza limite conta durante l'espansione invece di materializzare
le righe corrispondenti. Stesse ancore hub sul grafo da 91.6M, maxIntermediate=20M:
| 3-hop count(*) @ 91.6M | fanout=1 | fanout=8 |
|---|---|---|
| latenza / working set di picco | 554 ms / 0.66 GiB | 298 ms / 1.9 GiB |
Il conteggio contiene O(1) righe. L'addebito è invariato, quindi un conteggio su mega-hub fa ancora scattare
maxIntermediate sul calcolo (letture di adiacenza), limitato come prima.
Parallelismo per query (maxFanout)
Alzare query.maxFanout sovrappone le letture di blocchi fredde e I/O-bound di una query tra i core —
aiuta le forme disk-bound con working set freddo e grande ed è piatto sulle forme calde. Sul grafo da 1.5B
archi: shortestPath ≤6 918 → 608 ms (1.5×, la ricerca più grande 6,269 → 2,350 ms, 2.7×);
3-hop count 547 → 298 ms. maxFanout=1 è il default (orientato al throughput); 8 è la
manopola di latenza, al costo di più memoria di lavoro transitoria.
Dove slater vince / è in svantaggio
| dimensione | slater | migliore del campo | verdetto |
|---|---|---|---|
| memoria residente, qualsiasi scala | 11–584 MiB (62k → 91.6M) | in memoria 1.5–2.7 GiB; non può caricare 1.5B | slater |
| count / metadata / scan | ~0.4 ms | motori di servizio 5–80 ms | slater (10–200×) |
| point lookup indicizzato | 0.43 ms (MeSH) | Memgraph · FalkorDB 0.48 ms | slater (supera di poco la coppia in memoria) |
| multi-hop senza ancora (righe) | 1.40 ms (MeSH 2-hop) | Neo4j 5.6 ms | slater (scansione per tipo di relazione) |
| aggregazione (group-by / DISTINCT) | 0.45 ms | LadybugDB 5 ms (colonnare) | slater (istogramma in fase di build) |
| kNN | 2.4–2.9 ms (esatto) | FalkorDB 1.2 ms (HNSW) | batte Neo4j/Ladybug; ~1.4× da Memgraph; esatto |
| 91.6M metadata / point / degree / 3-hop | 0.4–32 ms | Neo4j 6–3,600 ms | slater (2–8800×) |
| 91.6M 1–2-hop | 4.5–23 ms (fan 8) | Neo4j 10–35 ms | ~alla pari |
91.6M var-length *1..2 distinct | ~1 s | Neo4j 47 ms | Neo4j (un vero punto debole di slater) |
multi-hop count(*) su larga scala | 0.3–0.6 GiB | i motori in memoria materializzano il set di righe | slater, limitato |
Le tabelle complete per motore (pole, MeSH, EU-AI-Act + la manopola RAM↔latenza blockCacheBytes,
Wikidata 1M & 91.6M) sono in
perf/cross-engine-hs/README.md; la nuova passata solo-slater
(entrambi i fanout, ogni dataset) è in perf/PERF_CURRENT_STATUS.md.
Concorrenza e brown-out (load testing)
I benchmark sopra sono a singolo client. L'asse complementare — comportamento sotto molti
client concorrenti — ha il proprio harness, perf/loadtest/: un driver
Locust sopra Bolt più un coordinatore che incrementa il carico, legge CALL slater.diagnostics(),
trova il ginocchio della capacità e nomina il limitatore (metodo completo in
docs/LOAD-TESTING.md). Risultati principali da una run con cache da 256 MiB sul
grafo Wikidata-1M (una macchina a 16 core):
| risultato | misurazione |
|---|---|
| Regge fino a 1000 client concorrenti, zero errori | il throughput raggiunge un picco di ~2.5k rps; il ginocchio della latenza si manifesta intorno ai 750 client (p99 51 → 750 ms) — accodamento sotto contesa dei core, non un limite rigido (run singola, WSL2) |
| Block cache limitata ed efficace | 100% di hit rate, 0 eviction, 50 MB residenti per un working set che sta nella cache |
| RSS mantenuta sotto carico sostenuto | l'allocatore jemalloc mantiene la RSS a ~0.6 GB attraverso un ramp wiki_cache_churn da 100→500 client — limitata dalla cache e stabile, senza nessun tuning MALLOC_* (il precedente MALLOC_ARENA_MAX=2 + soglia di trim è stato ritirato); il suo purge in background restituisce anche il picco post-burst invece di lasciarlo pinnato |
| Memoria aggregata limitata | a livello di server query.maxIntermediateGlobal + l'espansione addebitata sull'adiacenza tengono il flood 2-hop di wiki_budget a 1000 client senza OOM (RSS ~0.6 GB; il guard scarta ~60% delle query hub come errori di budget ritentabili) |
Entrambi i problemi di memoria emersi dal load test sono ora chiusi; tutti tracciati nel doc di load testing.
Licenza
Concesso in licenza secondo l'Apache License, Versione 2.0. Vedi LICENSE per il
testo completo e NOTICE per l'attribuzione. Salvo dichiarazione esplicita
contraria, qualsiasi contributo inviato intenzionalmente per l'inclusione in questo lavoro,
come definito dalla licenza Apache 2.0, sarà concesso in licenza come sopra, senza
termini o condizioni aggiuntivi.
SPDX-License-Identifier: Apache-2.0