
slater v0.24.3
Speicherschonende Graphdatenbank mit Bolt+TLS-Unterstützung, Verschlüsselung im Ruhezustand und Vektoren – konzipiert für lokale Replikat-Graph-Anwendungsfälle.
Slater
Aktuelle Version: v0.24.4 — alle Releases.
Kurz gesagt: Slater bedient Graphen, die nicht in den Speicher passen — Hunderte Millionen Knoten und Milliarden Kanten in niedrigen Hunderten MB RAM — über standardmäßiges Bolt, sodass jeder neo4j-Treiber einfach funktioniert, mit nativer Vektorsuche auf der Platte direkt neben dem Graphen, und es nimmt live, dauerhafte Schreibvorgänge entgegen, ohne das aufzugeben. Der residente Speicher wird durch ein Cache-Budget bestimmt, das du wählst, nicht durch die Größe des Graphen.
Verknüpfungen
Warum Slater existiert
Eine Graphdatenbank speichert Daten als Dinge (Knoten) und die Beziehungen zwischen ihnen (Kanten), wobei die Beziehungen Bürger erster Klasse sind. Das ist das, was du willst, wenn deine Fragen eher Verbindungen als Zeilen betreffen — „Wer ist innerhalb von drei Hops von diesem Konto entfernt?“, „Was ist die vollständige Abhängigkeitskette hinter diesem Build?“, „Welche Konten teilen sich ein Gerät, eine Adresse und eine Karte?“ — Abfragen, die in SQL zu einem Sumpf rekursiver Joins werden, sich in einem Graphen aber natürlich ergeben.
Die häufigste Beschwerde über Graphdatenbanken ist, dass sie nicht über das hinaus skalieren, was du im RAM halten kannst. Viele von ihnen (z. B. neo4j, Memgraph, FalkorDB usw.) halten den gesamten Graphen im Speicher: Ein 40 GB-Graph möchte 40 GB Speicher — pro Instanz. Du willst eine Replik pro Region, pro Mandant oder pro Pod? Multipliziere die Rechnung. Und ab einer bestimmten Größe laden sie einfach nicht mehr: Z. B. benötigt der Wikidata-Graph mit 90 Millionen Knoten / 1,5 Milliarden Kanten ~64–128 GiB resident, sodass die In-Memory-Engines ihn überhaupt nicht öffnen können.
Slater ist der Gegenentwurf. Anstatt den Graphen in den Speicher zu laden, kompiliert es ihn einmal offline: slater-build verwandelt deine Daten in ein inhaltsadressiertes, unveränderliches On-Disk-Image, und beliebig viele Slater-Server bedienen dieses Image dann über Bolt (sodass deine vorhandenen neo4j-Treiber einfach funktionieren), laden Blöcke bei Bedarf nach und halten nur ein festes Cache-Budget resident. Auf diese Weise wird derselbe Graph mit 90 Millionen Knoten aus ein paar hundert MB RAM bedient — Graphgröße und Speicherrechnung sind entkoppelt. Ein 4 GB-Graph und ein 400 GB-Graph kosten beim Bedienen gleich viel RAM, sodass du kostengünstige, zustandslose Lese-Repliken ausrollst und den Store statt des Heaps den Graphen halten lässt.
Das macht es zu einer natürlichen Wahl für Wissensgraphen hinter RAG, Empfehlungs- und Identitätsgraphen, Abhängigkeitsgraphen — alles Große und Verbundene, das du günstig und oft abfragen möchtest. Die native Vektorsuche auf der Platte befindet sich direkt neben dem Graphen, sodass dieselbe Engine auch die Abrufschicht für Einbettungen ist.
Einmal kompiliert bedeutet aber nicht eingefroren. Dieses Image ist eine Basis, kein Endzustand: Eine optionale Schreibschicht sitzt darüber, sodass ein laufender Graph korrigiert und erweitert werden kann, ohne etwas neu aufzubauen.
Lesen und Schreiben
Der Kern ist unveränderlich; der Graph nicht. Aktiviere die beschreibbare Schicht (delta.enabled) und du schreibst über Bolt — korrigiere eine Eigenschaft, füge einen Knoten hinzu, ziehe eine Kante zurück — und die Änderung landet dauerhaft, ohne Neuaufbau des Images. Was es auf der Leseseite günstig hält, ist wo die Schreibvorgänge leben.
Schreibvorgänge sammeln sich in einer log-strukturierten Merge-Schicht (LSM) über dem unveränderlichen Kern: einem Write-Ahead-Log und einer In-Memory-Tabelle, die in unveränderliche Delta-Segmente ausgelagert werden und durch eine periodische Konsolidierung wieder in einen frischen Kern eingefaltet werden. Was dir das bringt:
- Lesezugriffe auf einen unbeschriebenen Graphen kosten genau so viel wie vorher. Ein leeres Delta ist ein einzelner vorhersehbarer Zweig, keine Zusammenführung — der Lesepfad ist byte-identisch, egal ob die beschreibbare Schicht aktiv ist oder nicht.
- Die Lesekosten eines Schreibvorgangs skalieren mit der Größe des Deltas, nicht mit der Größe des Graphen. Antworten über den gesamten Graphen —
count(*), die Randverteilungen von Labels und Beziehungstypen — bleiben Metadaten-Lesezugriffe, selbst wenn Schreibvorgänge ausstehen: Das Delta führt eigene Zähler, sodass eincount(*)über einen Kern mit 91,6 Millionen Knoten und einer halben Million ausstehender Schreibvorgänge immer noch in Dutzenden von Millisekunden antwortet, ohne einen einzigen Block anzufassen. - Bestätigt bedeutet dauerhaft. Ein einzelner Schreiber entleert die Warteschlange und gibt
SUCCESSerst nach demfsynczurück, das den Schreibvorgang abdeckt. Bündele deine Schreibvorgänge und sie sind günstig — ein Schreib-UNWINDcommittet einfsyncpro Stapel statt pro Zeile. - Schreibvorgänge über Geschäftsschlüssel, in beiden Dialekten.
MERGE/MATCH … SET/DELETE(sowieCREATE/REMOVE, Detach-Delete, Beziehungsschreibvorgänge), die über die Identitätseigenschaft eines Knotens adressiert werden — oder die entsprechenden ISO-GQL-Datenänderungsanweisungen (INSERT/SET/REMOVE/DELETE), die auf denselben Pfad heruntergebrochen werden. Korrigieren, Einfügen, Upsert und Zurückziehen, über Knoten und Kanten, adressiert so, wie deine Daten es bereits sind.
Bei deaktivierter Schicht — dem Standard — bedient Slater den reinen unveränderlichen Kern und lehnt Schreibvorgänge ab. Das vollständige Modell findest du unter Die beschreibbare Schicht.
Zum Namen. Slater ist nach dem CIA-Agenten in Archer benannt (eine großartige Serie), der darauf besteht, mit einem einzigen Namen genannt zu werden — „Just… Slater“ — und einer meiner Lieblingsfiguren darin. Siehe die Figuren-Wiki-Seite.
Was du bekommst
- RAM festgelegt durch dein Cache-Budget, nicht durch deine Graphgröße — rolle so viele Lese-Repliken aus, wie du möchtest; der Graph muss nie in den Speicher passen.
- Ein Drop-in-Ersatz für den Graphen — spricht Bolt, sodass jeder Standard-neo4j-Treiber (JS, Python, Go…) unverändert funktioniert. Es ist Cypher (plus ein Teil von ISO GQL, Lesen und Schreiben); nichts Neues zu lernen.
- Live, dauerhafte Schreibvorgänge — eine optionale LSM-Schicht über dem unveränderlichen Kern: Geschäftsschlüssel-
MERGE/SET/DELETEüber Knoten und Kanten, gruppencommittet undfsync-dauerhaft, durch Konsolidierung wieder in einen frischen Kern eingefaltet. Lesezugriffe zahlen nicht dafür. - Bereitstellung per Dateiaustausch — baue offline eine neue inhaltsgehashte Generation, schalte den
current-Zeiger atomar um, und die Server übernehmen sie. Jeder Block ist mit einer Prüfsumme versehen, sodass ein halbkopiertes Image abgelehnt statt ausgeliefert wird. - Vektorsuche eingebaut — disknative approximative Suche nach nächsten Nachbarn (Cosinus, L2 oder dot KNN) sitzt direkt neben deinem Graphen, für den Fall, dass dies die Abrufschicht hinter einer RAG-Pipeline ist, und Einbettungen sind direkt beschreibbar — kein Offline-Neuaufbau nötig, um einen Vektor hinzuzufügen oder zu ändern.
- Von Natur aus abgesichert — Lese- und Schreibberechtigungen sind unabhängig, plus optionale Verschlüsselung im Ruhezustand, TLS-Bolt, argon2id-gehashte ACLs und ein schreibgeschütztes Container-Rootfs für Lese-Repliken. Konfiguriere einen Master-Key und das On-Disk-Image ist authentifiziert sowie verschlüsselt — sein Manifest trägt einen schlüsselgebundenen MAC, sodass ein Angreifer mit Schreibzugriff auf das Datenverzeichnis, aber ohne Schlüssel, kein Manifest fälschen kann, das der Server akzeptiert. Ohne Schlüssel erhältst du weiterhin den Inhalts-Hash, der ein halbkopiertes oder beschädigtes Image erkennt — aber kein vorsätzlich manipuliertes. Welche Konfiguration was bietet.
Funktionen
| Funktion | Was das für dich bedeutet |
|---|---|
| Begrenzter, vorhersehbarer Speicher | Der residente Speicher folgt drei Cache-Budgets, die du festlegst, innerhalb begrenzter Pro-Eintrag- und Allokator-Overheads — er wächst nicht mit der Graphgröße; du stimmst stattdessen den Kompromiss aus Leistung und RAM ab, anstatt für den gesamten Graphen zu dimensionieren. Ein jemalloc-Allokator mit Hintergrundbereinigung gibt freigegebenen Speicher nach starken Abfrage-Spitzen an das Betriebssystem zurück, sodass die residente Größe auf ihr Ruheniveau zurückfällt, statt am Hochwasserstand nach der Spitze festzuhängen. |
| Multi-Tenant von Haus aus | Ein Server hostet viele Graphen mit Leseberechtigungen pro Benutzer — Multi-Datenbank-Isolation, die die meisten Graphdatenbanken einer kostenpflichtigen/Enterprise-Stufe vorbehalten. |
| Verschlüsselung im Ruhezustand und während der Übertragung | Versiegelung pro Block mit XChaCha20-Poly1305 (der Schlüssel wird nie auf die Platte geschrieben) plus optionales TLS (bolt+s://). Von Natur aus DSGVO-freundlich. Verschlüsselung ist auch das, was authentifizierte Integrität erkauft: Der Builder versiegelt das Manifest mit einem schlüsselgebundenen MAC, und ein Server mit dem Schlüssel verifiziert es und weigert sich, eine Generation auszuliefern, deren Manifest gefälscht, verändert oder seines MACs beraubt wurde. Ein schlüsselloses (Klartext-)Image ist nur durch den schlüssellosen Inhalts-Hash geschützt — Vollständigkeit und Korruption, nicht Manipulation. Siehe Was Integrität in jeder Konfiguration bedeutet. |
| Winzige Installation | Eine kleine gestrippte Binärdatei auf einer distroless-glibc-Basis (keine Shell/apt) — das Multi-Arch-Image (amd64/arm64) zieht ~22 MB, oder ~12 MB für den Server-only-Tag slater:latest-lite; reines-Rust-TLS, kein OpenSSL. Ziehen und ausführen. |
| Für periodische Veröffentlichungen gebaut | Baue einen Graphen offline, bediene ihn unveränderlich und tausche dann atomar eine neue Version ohne Ausfallzeiten ein — ideal für Data-Warehouse-/Scheduled-Refresh-Workloads. |
| Robust unter Last | Der Server und der Offline-Builder kompilieren beide mit #![forbid(unsafe_code)] — das einzige unsafe der Engine lebt in der geprüften jemalloc-Allokator-Crate. Der Kern ist unveränderlich, sodass Lesezugriffe keine Sperren benötigen und nie auf einen Schreiber warten; ein einzelner Schreiber serialisiert Mutationen allein hinter dem Schreibpfad. Keine GC-Pausen, keine Datenrennen. Eine einzelne schlechte Abfrage kann den Server nicht lahmlegen. |
| Funktioniert mit deinen neo4j-Tools | Spricht Bolt 5.4 / 4.4 / 4.1 — verwende die Standard-neo4j-Treiber (JS, Python, Go, Java…), cypher-shell oder Graph-Browser unverändert. |
| Umfangreiche Cypher-Abfrageoberfläche | Eine breite Leseoberfläche: MATCH/WHERE/WITH/UNION, CALL {…}-Teilabfragen, 70+ Funktionen & Aggregationen, temporale & georäumliche Werte und Regex. |
| Live, dauerhafte Schreibvorgänge | Eine optionale Single-Writer-LSM-Schicht über dem unveränderlichen Kern (delta.enabled): Geschäftsschlüssel-MERGE / SET / DELETE / CREATE / REMOVE über Knoten und Beziehungen, gebündeltes Schreib-UNWIND (ein fsync pro Stapel) und CALL slater.consolidate() — gruppencommittet, fsync-dauerhaft und durch Konsolidierung wieder in einen frischen Kern eingefaltet. Der Lesepfad ist byte-identisch, wenn das Delta leer ist. |
| ISO GQL, Lesen und Schreiben | Spricht einen Teil von ISO GQL (ISO/IEC 39075) über dieselbe Bolt-Verbindung — quantifizierte Pfade, Pfadrestriktoren, Shortest-Path-Selektoren, boolesche Label-/Typ-Ausdrücke, FOR, CAST, ein optionales GQL/CYPHER-Dialektpräfix — und, bei aktivierter beschreibbarer Schicht, werden GQLs Datenänderungsanweisungen (INSERT / SET / REMOVE / [DETACH] DELETE) auf denselben dauerhaften Schreibpfad heruntergebrochen. Cypher und GQL, Lesen und Schreiben, in einer Engine. |
| Vektoren + Graph in einer Engine | Disknative ANN-Vektorsuche (Vamana + PQ; Cosinus / L2 / dot) für Einbettungen/RAG, plus Graphalgorithmen (PageRank, BFS, Betweenness, WCC…) — begrenzter Speicher selbst bei Millionen von Vektoren. Einbettungen sind beschreibbar (eine Schreibleiter im FreshDiskANN-Stil): Vektor einfügen / aktualisieren / löschen, sofort per KNN sichtbar, ohne Neuaufbau in die Basis eingefaltet. |
| Sicher auf Netzwerkspeichern | Jede Datei ist mit BLAKE3 inhaltsgehasht und wird beim Öffnen verifiziert; abgerissene oder halbkopierte Images werden abgelehnt, nicht ausgeliefert. Entwickelt für NFS-/Remote-Volumes (keine mmap-Überraschungen). |
| Austauschbare Speicher-Backends | Bediene dasselbe Generationsformat von einem lokalen Dateisystem, einem S3-Bucket (S3-kompatibel) oder einem Google-Cloud-Storage-Bucket — einmal veröffentlichen, auf zustandslose Repliken verteilen — mit einer optionalen lokalen SSD-Cache-Ebene vor dem Objektspeicher. Siehe Speicher-Backends. |
Zwei Binärdateien bilden den Workspace:
| Binärdatei | Rolle |
|---|---|
slater | Der Online-Bolt-Server (der Container-ENTRYPOINT): bedient Lesezugriffe und, mit delta.enabled, den dauerhaften Single-Writer-Schreibpfad. |
slater-build | Der Offline-Compiler: verwandelt einen primitiven Cypher-Dump in ein unveränderliches, inhaltsgehashtes Generationsverzeichnis. |
Slater trennt Massenaufbau vom Bedienen: slater-build erledigt die schwere Arbeit
offline — nimmt deine Daten auf und kompiliert sie in eine unveränderliche Generation — sodass ein
kalter Graph nie auf dem heißen Serving-Pfad zusammengesetzt wird. Innerhalb des Servers
beantwortet die Leseoberfläche einen breiten Cypher-Ausschnitt — Musterabgleich, WITH/UNION/CALL {…}-
Teilabfragen, 70+ Skalar- & Aggregatfunktionen, temporale & georäumliche Werte, Graph-
algorithmen (algo.*) und disknative Vektor-KNN (db.idx.vector.queryNodes) —
während das Delta-Overlay der beschreibbaren Schicht unterhalb dieser Oberfläche sitzt und bei Leerheit
kostenlos ist, sodass Lesezugriffe nie die Schreibseiten-Mechanik mit sich tragen. Du kannst einen Graphen
auf zwei Arten aktualisieren: live über Bolt schreiben (siehe Die beschreibbare Schicht),
oder eine neue Generation offline erstellen und den current-Zeiger atomar austauschen, was
der laufende Server über seinen Generation Guard aufnimmt (siehe
Generation Guard).
Dokumentation
Das vollständige Benutzerhandbuch befindet sich in docs/manual/ — ein Feature-für-Feature-Leitfaden, der für jede Fähigkeit erklärt, was sie ist, warum es sie gibt und wie man sie nutzt, mit ausgearbeiteten Beispielen, die du gegen einen mitgelieferten Beispielgraphen ausführen kannst. Beginne dort für alles über diesen Überblick hinaus.
- Neu hier? Schnellstart baut und bedient einen Graphen in fünf Schritten.
- Abfragen schreiben? Abfragen, Funktionen & Ausdrücke, Prozeduren & Algorithmen, Vektorsuche, Daten schreiben.
- Graphen bauen? Graphen bauen und die Build-CLI-Referenz.
- Slater betreiben? Bereitstellung, Speicher, Konfigurationsreferenz, Sicherheit, Leistungsoptimierung.
Ausführen mit Docker
Slater ist dafür ausgelegt, als Docker-Deployment ausgeführt zu werden — das ist der erwartete Weg, es zu nutzen. Vorgefertigte Multi-Arch-Images (linux/amd64 + linux/arm64) werden auf Docker Hub unter hikarisystems/slater veröffentlicht, bei jedem Release getaggt mit :latest und :vX.Y.Z:```sh
docker pull hikarisystems/slater:latest
Ein Leitfaden nur für Docker-Befehle zu Nutzung, Konfiguration und Betrieb befindet sich in
[`DOCKERHUB.md`](https://github.com/hikari-systems/slater/blob/HEAD/DOCKERHUB.md) (und wird auf der Docker-Hub-Übersichtsseite gespiegelt) —
**beginne dort, wenn du bereitstellst.** Kurz gesagt:```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
Um das Image stattdessen lokal zu bauen (z. B. für die Entwicklung):```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
Die Builder-Stage installiert `cmake`, `clang` und `libclang-dev` für das rustls-`aws-lc-rs`-Backend; `git` (bereits im Basisimage vorhanden) wird für die `hs-utils`-git+tag-Abhängigkeit benötigt, die `.cargo/config.toml` über die Git-CLI abruft.
Die folgenden Abschnitte behandeln das On-Disk-Format, die Konfiguration, ACLs und ein lokales (Nicht-Docker-)Arbeitsbeispiel.
## So funktioniert es```
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>
- Eine Generation ist ein unveränderliches Verzeichnis: eine
MANIFEST.json(Symboltabellen, Indexdeskriptoren, ein optionaler Verschlüsselungsheader), spaltenbasierte Blockdateien (node_props.blk,node_labels.blk,edge_props.blk,topology.csr.blk,vectors.f32.blk), Bereichsindizes (range/<name>.isam), ANN-Indizes oberhalb der Schwelle (vector/<label>.<prop>.{vamana,pq}) und eincurrent-Textzeiger. - Jeder Block ist zstd-komprimiert und mit einer BLAKE3-Prüfsumme versehen; mit
--encryptwird jeder Block zusätzlich mit XChaCha20-Poly1305 versiegelt (AEAD im Ruhezustand). - Der Server öffnet eine Generation, indem er jede Datei anhand des Manifests erneut hasht, sodass ein halb kopiertes / abgeschnittenes Image – eine abgerissene Kopie in das Datenverzeichnis, bei dem es sich um entfernten/Netzwerkspeicher handeln kann – abgelehnt statt ausgeliefert wird.
- Lesezugriffe laufen durch drei begrenzte Cache-Pools — einen LRU-Cache für dekomprimierte Blöcke, einen Vektorindex-Pool (residente PQ-Codes + ein Vamana-Block-LRU) und einen Ergebnis-LRU — jeweils mit eigenem Byte-Budget. Jeder Pool gewichtet, was er enthält, und verdrängt, um unter seinem Budget zu bleiben, sodass die RSS den Budgets bis auf begrenzten Pro-Eintrag- und Allokator-Overhead folgt, statt mit dem Graphen zu wachsen.
Die beschreibbare Ebene
Mit delta.enabled wird die unveränderliche Generation zur vollständig kompaktierten untersten
Ebene (dem „Kern") eines kleinen protokollstrukturierten Merge-Baums, und Live-Schreibvorgänge laufen
darauf auf:```
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)
└─────────────┘
* **Dauerhaftigkeitsgarantie — das WAL.** Jede Mutation wird hinter einem einzelnen Writer pro Graph serialisiert, an ein pro-Graph-Write-Ahead-Log angehängt und per `fsync` gesichert, bevor der Bolt-`SUCCESS` zurückgegeben wird — *bestätigt ⇒ dauerhaft*, und ein abgerissener Endabschnitt wird bei der Wiedergabe verworfen. Ein gebündelter Schreib-`UNWIND` hängt seine Zeilen an und führt **ein** `fsync` für die gesamte Charge aus. Das WAL ist **nur lokaler Datenträger** (es wird nicht durch das Speicher-Backend geleitet), was einen *Schreiber*-Knoten zustandsbehaftet macht: Er benötigt ein dauerhaftes lokales Volume unter `delta.walDir`. Lesereplikate bleiben zustandslos.
* **Memtable → L0 → Konsolidierung.** Schreibvorgänge sammeln sich in einer In-RAM-Memtable (begrenzt durch `delta.memtableBytes`); wenn sie voll ist, spült sie in ein unveränderliches L0-Delta-Segment. Eine **Konsolidierung** führt `{core + delta}` zu einem frischen Core zusammen, indem sie die zusammengeführte Sicht erneut durch `slater-build` serialisiert und `current` atomar austauscht — dieselbe Content-Hash-Absicherung wie bei jeder veröffentlichten Generation. Löse sie von Hand mit `CALL slater.consolidate()` aus, automatisch bei `delta.deltaCorePercent` der Core-Größe (optional begrenzt auf ein außerhalb der Spitzenzeiten liegendes `delta.consolidateWindow`), oder lass die `delta.deltaHardBytes`-Drossel unkontrolliertes Wachstum auffangen.
* **Das Overlay sitzt unterhalb der Leseoberfläche.** Der Executor liest durch eine `ReadView`, die entweder der nackte Core (Delta immer leer) oder eine zusammengeführte `(core, delta)`-Sicht ist; die Engine ist darüber monomorphisiert, sodass ein leeres Delta zu einem einzigen vorhersehbaren Zweig kompiliert und der Nur-Lese-Pfad byteidentisch ist. Graphweite Zähler (`count(*)`, Label-/Reltyp-Randhäufigkeiten) werden aus den eigenen Live-Zählern des Deltas bedient, sodass sie auch bei anstehenden Schreibvorgängen Metadaten-Lesezugriffe bleiben.
* **Eine Abfrage sieht einen stabilen Snapshot.** Sie fixiert ein `(core, delta)`-Tupel für ihre gesamte Lebensdauer. Es gibt keine Transaktionen mit mehreren Anweisungen und kein Rollback — ein Schreibvorgang ist eine dauerhafte, über Geschäftsschlüssel adressierte Korrektur, keine OLTP-Transaktion.
Die genaue Schreibgrammatik und die Stellschrauben finden sich in der Tabelle [Konfiguration](#environment--configuration) (`delta.*`) und im [Arbeitsbeispiel](#worked-example) weiter unten.
### Bereichsindizes (ISAM)
Ein Bereichsindex (`range/<name>.isam`, einer pro indiziertem `(label, property)`) ermöglicht es einem `MATCH (n:Label {prop: v})` oder `WHERE n.prop <op> v`, die passenden Knoten-IDs **ohne Scannen des Labels** aufzulösen. Er ist eine **[ISAM](https://en.wikipedia.org/wiki/ISAM)**-Struktur (Indexed Sequential Access Method) — der klassische *statische, sortierte, blockstrukturierte* Index, der für eine unveränderliche Generation genau die richtige Form hat: Es gibt keine einzufügenden Einträge, die neu ausbalanciert werden müssten, sodass die Einfachheit von ISAM genau das bringt, was die Mutationsmechanik eines B-Baums nur verkomplizieren würde.
* Einträge `(value, entity_id)` sind nach Wert sortiert und in dieselben zstd-komprimierten 256-KiB-Blöcke verpackt wie alles andere.
* Eine kleine **residente oberste Ebene** hält den ersten Schlüssel jedes Blocks (einen Sparse-Index). Eine Suche durchsucht diese In-Memory-Oberebene binär, um den *einen* Block zu finden, in dem ein Schlüssel sein kann, liest und dekomprimiert diesen Block und scannt ihn — eine Gleichheitssuche ist also **ein Block-Lesezugriff**, und ein Bereichsscan läuft durch den zusammenhängenden Lauf von Blöcken, den er überspannt. (Deshalb dauert eine über `meshUi` indizierte Suche nur einstellige Millisekunden, während derselbe Treffer auf einer nicht indizierten Eigenschaft das gesamte Label scannt.)
* Der Planner wählt ihn über `NodeScan::RangeEq` / `RangeRange`; ein nicht indiziertes Prädikat fällt auf einen Label-Durchlauf oder vollständigen Scan zurück, wobei der Executor in beiden Fällen jedes Prädikat erneut prüft.
### Vektorsuche (Vamana + PQ) — Kosinus, L2 und Skalarprodukt, Lesen *und* Schreiben
Vektor-KNN (`db.idx.vector.queryNodes`) läuft über **Kosinus-, L2- oder Skalarprodukt-Indizes (MIPS)**. Der Basisindex wird offline mit zwei Ausführungspfaden erstellt, die pro Index anhand des `--ann-threshold` gewählt werden (Standard: 50 000 Vektoren):
* **Unterhalb des Schwellwerts — Brute Force.** Die vollständigen `f32`-Vektoren liegen in `vectors.f32.blk`; eine Abfrage scannt die Gruppe des Index und berechnet die exakte Distanz in der Metrik des Index. Einfach und exakt; gut geeignet, wenn die Vektormenge klein ist.
* **Beim Schwellwert oder darüber — Vamana + PQ**, der datenträgernahe ANN-Pfad, der den residenten Speicher unabhängig von der Anzahl der Vektoren begrenzt hält:
* **[Vamana](https://arxiv.org/pdf/2401.11324)** ist der Graphindex aus der DiskANN-Arbeitslinie: ein einzelner Näherungsgraph, dessen Kanten beschnitten werden (der `--vamana-r`-Ausgrad und der `--vamana-alpha`-LangKanten-Faktor), sodass eine *gierige Beam-Suche* — Start am Medoid, wiederholtes Springen in Richtung der Abfrage, wobei eine Kandidatenliste der Breite `vectorQuery.beamWidth` geführt wird — die wahren Nachbarn eines Knotens in wenigen Sprüngen erreicht, also **wenige zufällige Block-Lesezugriffe pro Abfrage**. Die Graphblöcke (`vector/<label>.<prop>.vamana`) werden über den Vektor-Cache eingelagert, nicht im Ganzen vorgehalten.
* **[Produktquantisierung (PQ)](https://medium.com/aiguys/product-quantization-k-nn-for-big-datasets-12431d764c4e)** komprimiert jeden Vektor in einen kurzen Code (`--pq-subspaces` × `--pq-bits`): Die Dimensionen werden in Unterräume aufgeteilt, jeder unabhängig k-means-geclustert, und der Vektor wird als Tupel der nächstgelegenen Zentroid-IDs gespeichert. Diese Codes (`vector/<label>.<prop>.pq`) sind klein genug, um **resident** gehalten zu werden, sodass die Beam-Suche Kandidaten aus dem RAM bewertet und nur die wenigen ausgewählten vollständigen Vektoren von der Platte gelesen werden. Genau diese residente PQ-Menge fixiert der `cache.vectorCacheBytes`-Pool.
**Beschreibbare Einbettungen — die Vektor-Schreibleiter ([FreshDiskANN](https://arxiv.org/abs/2105.09613)-Stil).** Eine indizierte Einbettung ist ein erstklassiger, beschreibbarer Wert. `SET n.embedding = vecf32([…])` (und `REMOVE`) landet im Schreib-Delta und ist **sofort mit exaktem Rang KNN-sichtbar**, übersteht dann einen Segment-Flush, einen Merge und eine Konsolidierung. Eine Abfrage führt bis zu drei Ebenen zusammen — den versiegelten Basisindex, einen versiegelten Pro-Segment-Index und einen In-Memory-**RW-Index** (einen lebenden, mutierbaren Vamana über dem Schreib-Delta) —, sodass die Latenz bei zunehmenden Schreibvorgängen flach bleibt, anstatt mit der Anzahl der anstehenden Schreibvorgänge zu wachsen. Ein Löschen hinterlässt ein *Loch*: Der Knoten wird nicht mehr zurückgegeben, bleibt aber ein Navigations-Wegpunkt, bis eine Hintergrund-**Lösch-Konsolidierung** ihn aus dem Graphen herausspleißt, sodass Löschungen kein Abfrage-IO mehr kosten. Und da der On-Disk-Graph seine Nachbarn über die Layout-Position und nicht über die Knoten-ID adressiert, übernimmt `CALL slater.consolidate()` den Vamana **per Referenz** — hart verlinkt, byteidentisch — und schreibt nur eine kleine ID-Spalte neu, wobei Vektor-Schreibvorgänge **ohne** den O(N·R·L)-Graph-Rebuild in den Basisindex eingefaltet werden. Gemessene Zahlen, mit Einschränkungen, finden sich im [Leistungsbericht](https://github.com/hikari-systems/slater/blob/HEAD/docs/PERF-REPORT.md).
## Speicher-Backends (Dateisystem / S3 / GCS)
Jede Generationsdatei wird über eine **`ObjectStore`**-Abstraktion geöffnet und nicht direkt über `std::fs`, sodass das *gleiche* On-Disk-Byteformat — Blöcke, Indizes, Manifest, `current`-Zeiger — unverändert von jedem Backend ausgeliefert wird; nur *woher die Bytes kommen* unterscheidet sich, niemals die Leser, die Abfrage-Engine oder die Integritätsprüfungen. Der heiße Pfad sind positionsbasierte Lesezugriffe (`read_exact_at`), die auf ein `pread` bei einer lokalen Datei bzw. eine HTTP-Bytebereichsanfrage bei einem Objektspeicher abgebildet werden — Slater verwendet niemals mmap, daher ist das explizite, begrenzte Lesemodell überall identisch.
**Drei erstklassige Backends**, ausgewählt über `dataBackend.kind`. Das Dateisystem ist die einfache Standardeinstellung; **Amazon S3 und Google Cloud Storage sind gleichwertige, vollständig unterstützte Objektspeicher-Backends** — das veröffentlichte Image enthält beide einkompiliert, sodass jedes nur eine Konfigurationsfrage ist, und eine einmal erstellte Generation kann von jedem von ihnen ausgeliefert werden (sogar migriert `fs` → S3 → GCS) ohne Neuaufbau.
| `dataBackend.kind` | Positionslesen | Integrität beim Öffnen | Anmeldedaten |
| --- | --- | --- | --- |
| `fs` *(Standard)* | `pread` | vollständiger BLAKE3-Re-Hash jeder Datei | — |
| `s3` | HTTP `Range` GET | Server-**SHA-256** über `HEAD` (→ BLAKE3-Body-Re-Hash, falls nicht vorhanden) | Konfigurationsschlüssel, AWS-Kette oder IAM-Rolle |
| `gcs` | HTTP-Bereichslesen | Server-**CRC32C** über `get_object` (→ BLAKE3-Body-Re-Hash, falls nicht vorhanden) | ADC / Workload Identity oder Serviceaccount-JSON |
Beide Objektspeicher verifizieren die Integrität anhand der **Prüfsumme, die der Speicher bereits berechnet und aufbewahrt**, die als Objektmetadaten abgerufen wird: `slater-build` sendet die Prüfsumme beim Hochladen (der Speicher validiert die Bytes dagegen und speichert sie), und der Server liest sie beim Öffnen zurück und vergleicht sie mit dem Manifest — eine Metadatenanfrage pro Datei, kein Body-Download. Sie ist inhaltsbezogen und im Geiste über S3 (SHA-256) und GCS (CRC32C) identisch. Wenn ein Objekt **keine** servergespeicherte Prüfsumme trägt (out-of-band kopiert oder mit einer anderen Voreinstellung hochgeladen), **re-hasht der Server den Objekt-Body gegen das Manifest-BLAKE3**, anstatt seiner Bytelänge zu vertrauen — eine angeforderte Integritätsprüfung wird niemals stillschweigend zu einem Größenvergleich herabgestuft. Von Slater veröffentlichte Generationen tragen immer die Prüfsumme, sodass sie auf dem günstigen Metadatenpfad bleiben.
Was diese Spalte auf jedem Backend prüft, ist, dass die Dateien **mit dem Manifest übereinstimmen**. Ob dem Manifest selbst vertraut werden kann, ist eine separate Frage, und der Master-Schlüssel ist es, der sie beantwortet: Mit einem konfigurierten Schlüssel trägt das Manifest einen keyed MAC, den der Server verifiziert, bevor er irgendeinem Feld (einschließlich dieser Hashes) vertraut, sodass ein umgeschriebenes Manifest, das manipulierte Dateien beschreibt, abgelehnt wird; ohne Schlüssel ist der Vergleich durchgängig schlüssellos, und jemand, der in das Datenverzeichnis schreiben kann, kann eine Datei und das Manifest gemeinsam umschreiben. Siehe [Was Integrität in jeder Konfiguration bedeutet](https://github.com/hikari-systems/slater/blob/HEAD/THREAT_MODEL.md#what-integrity-means-in-each-configuration). Die Prüfung selbst kann mit `dataBackend.verifyIntegrity: false` deaktiviert werden, was sie gegen ein schnelleres Öffnen eintauscht.
### Dateisystem (`fs`)
Das Standard-Backend, dessen Wurzel unter `dataBackend.fs.dir` liegt. Die richtige Wahl für die meisten Bereitstellungen: eine Generation auf einer lokalen SSD (oder einem NFS/EBS-Mount), die schreibgeschützt ausgeliefert wird. Die Integrität wird durch einen vollständigen BLAKE3-Re-Hash jeder Datei beim Öffnen gewährleistet.
### Amazon S3 (`s3`)
Ein S3- oder S3-kompatibler Bucket (AWS, MinIO, localstack). Anmeldedaten kommen **zuerst** aus der Konfiguration (`dataBackend.s3.awsAccessKey` / `awsSecretKey`, plus `awsSessionToken` für temporäre STS-Anmeldedaten) und fallen auf die standardmäßige AWS-Kette (Umgebungsvariablen `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`, gemeinsames Profil oder Instanz-/IRSA-Rolle) zurück, wenn sie leer gelassen werden.```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)
Ein GCS-Bucket, der über die JSON-API erreicht wird. Die Authentifizierung erfolgt GCP-nativ: Standardmäßig werden Application Default Credentials aufgelöst — GKE Workload Identity, der GCE-Metadatenserver oder ein gcloud- / GOOGLE_APPLICATION_CREDENTIALS-Schlüssel. Setzen Sie dataBackend.gcs.credentialsPath (eine Service-Account-JSON-Key-Datei) oder inline credentialsJson für einen expliziten Schlüssel. dataBackend.gcs.endpoint zeigt auf einen fake-gcs-server-Emulator, und dataBackend.gcs.anonymous=true ermöglicht unauthentifizierten Zugriff nur für diesen Emulator — niemals gegen echtes GCS.```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 allen Fällen schreibt slater-build die fertige Generation zuerst nach --data-dir
(seinem lokalen Staging-Bereich) und lädt sie zusätzlich in den Bucket hoch; der
entfernte current-Zeiger wird zuletzt geschrieben, sodass ein Serving-Knoten niemals
eine halb veröffentlichte Generation sieht.
Wann ein Objektspeicher (S3 oder GCS) zum Einsatz kommt
Greife zu s3 oder gcs, wenn du Generationen in dauerhaftem, zentralem
Objektspeicher statt auf dem Datenträger eines Knotens ablegen möchtest — typischerweise:
einmal veröffentlichen und an viele zustandslose, plattenlose Server-Replikate verteilen,
die alle denselben Bucket lesen; den Build-Host von den Serve-Hosts entkoppeln; oder dich
auf die Haltbarkeit/Versionierung/Lebenszyklus-Verwaltung des Speichers verlassen, anstatt
Volumes zu verwalten. Der Preis ist Latenz: Ein kalter Block ist ein Netzwerk-Roundtrip
(~10–50 ms) statt eines lokalen Lesevorgangs (~0,1 ms). Slater kaschiert den größten Teil
davon mit dem In-Memory-Blockcache, konkurrentem Read-Ahead und dem optionalen
Festplattencache weiter unten. Wenn deine Generationen bereits auf schnellem lokalem
Speicher liegen und du das zentrale Bucket-Modell nicht benötigst, ist fs einfacher und
schneller.
Lokaler Festplatten-Blockcache (zweite Ebene des Objektspeichers)
Der In-Memory-BlockCache ist bewusst klein gehalten (begrenzter RSS ist das zentrale
Versprechen), sodass bei einem Arbeitssatz, der größer als der RAM ist, dieselben Blöcke
bei jedem Verdrängen erneut aus dem Objektspeicher geholt würden. Eine optionale zweite
Cache-Ebene auf lokaler SSD behebt das: Ein aus dem RAM verdrängter Block wird von der
lokalen Festplatte bedient (~0,1 ms) statt durch einen neuen Objekt-GET, übersteht die
In-Memory-Verdrängung und senkt Anzahl/Kosten der Objektspeicher-Anfragen — dadurch kommt
ein Objektspeicher-gestützter Knoten nach dem Aufwärmen nahe an die Leistung des lokalen
Dateisystems. Sie ist für s3 und gcs opt-in und wird aktiviert, indem
dataBackend.<s3|gcs>.diskCacheBytes > 0 und ein beschreibbares diskCacheDir gesetzt
werden.
- Es cached die versiegelten Bytes genau so, wie sie abgerufen wurden — bereits
komprimiert und (bei
--encrypt-Generationen) weiterhin AEAD-versiegelt — unterhalb von Entschlüsselung/Dekomprimierung. Die Cache-Ebene hält niemals den Verschlüsselungsschlüssel und verschlüsselt nie erneut, sodass der Schutz der ruhenden Daten (at-rest) ohne Weiteres erhalten bleibt: Eine verschlüsselte Generation landet weiterhin versiegelt auf der Festplatte. - Schreibvorgänge sind Write-Behind: Ein Fehltreffer (Miss) gibt die abgerufenen Bytes sofort an die Abfrage zurück, dann erledigt ein Hintergrundthread den Festplatten-Schreibvorgang und das LRU-Kürzen, sodass der Abfragepfad niemals auf Festplatten-I/O wartet. Die Verdrängung hält den Cache innerhalb seines Byte-Budgets; eine pro Datei geprüfte Prüfsumme, die bei jedem Lesen verifiziert wird, heilt eine beschädigte Cache-Datei selbst zu einem Miss (→ erneutes Abrufen aus dem Objektspeicher).
diskCacheDirmuss auf ein echtes beschreibbares Volume zeigen — niemalstmpfs(tmpfs ist RAM und würde die begrenzte-RSS-Garantie zunichtemachen). Der In-Memory-Index, der es verfolgt, kostet etwas RAM (~zig Bytes pro gecachtem Block), was gegen dein RSS-Limit zählt — dimensioniere das Verzeichnis ≫ den In-Memory-Blockcache.- Die andere RAM-Kosten dieser Ebene ist die Write-Behind-Warteschlange, die Blöcke auf
dem Weg zur Festplatte zwischenspeichert. Sie ist begrenzt auf
blockCacheBytes / 8(untergrenzt durchdiskCacheBytes) — standardmäßig 8 MiB — und verwirft eher, als dass sie wächst, sodass ein Kaltstart-Scan sie nicht aufblähen kann; ein verworfener Block wird einfach beim nächsten Miss erneut abgerufen. Sie benötigt keine Konfiguration: Sie skaliert mitblockCacheBytes, sodass die Festplatten-Ebene dem RSS-Budget über ihren Index hinaus keine neue Zahl hinzufügt.
Mounts
Ein Lesereplikat läuft mit einem schreibgeschützten Root-Dateisystem und einem
Nicht-Root-Benutzer (appuser:1000) — alles, was es benötigt, ist schreibgeschützt
eingehängt. Ein Writer (delta.enabled) benötigt zusätzlich ein dauerhaftes,
beschreibbares Volume für sein WAL.
| Pfad | Zweck | Hinweise |
|---|---|---|
/data | Die Graph-Generationen (<graph>/<uuid>/… + current). | Schreibgeschützt für Replikate; erzeugt von slater-build. Kann auf entferntem/Netzwerkspeicher (z. B. NFS) liegen, daher wird nicht von schnellen lokalen SSD-Latenzen ausgegangen. |
/sandbox | Konfigurations-Overlay + Geheimnisse pro Umgebung. | /sandbox/config.json wird per Tiefenmerge über das fest eingebettete config.json gelegt; enthält außerdem acl.json, TLS-PEM-Material und die Schlüsseldatei für ruhende Daten. |
/tmp, /run | Temporärer Speicher (tmpfs). | Ein Lesereplikat schreibt standardmäßig nie auf die Festplatte. |
(Writer) delta.walDir | Das Write-Ahead-Log + L0-Deltasegmente, wenn delta.enabled. | Beschreibbar und ein dauerhaftes, echtes Volume — niemals tmpfs (es ist die Dauerhaftigkeitsuntergrenze). Ein relativer Pfad wird unter dem Datenverzeichnis aufgelöst; gib einem Writer hier ein eigenes persistentes Volume. |
| (optional) Festplattencache | Der lokale Festplatten-Blockcache, wenn dataBackend.s3.diskCacheBytes / dataBackend.gcs.diskCacheBytes > 0. | Beschreibbar und ein echtes Volume — kein tmpfs. Wird von den s3- und gcs-Backends verwendet; siehe Speicher-Backends. |
Umgebung / Konfiguration
Die Konfiguration wird vom hauseigenen Standard-Layered-Loader geladen: das fest
eingebettete config.json, dann /sandbox/config.json per Tiefenmerge darüber, dann
KEY__sub-Umgebungsüberschreibungen (doppelter Unterstrich für Verschachtelung; Schlüssel
entsprechen der camelCase-Konfiguration).
Jeder Konfigurationsregler — sein camelCase-Schlüssel, die KEY__sub-Umgebungsüberschreibung,
sein Standardwert und seine Funktion — ist in der Konfigurationsreferenz tabellarisch aufgeführt. Die am häufigsten angepassten Regler sind die Cache-Budgets (cache.*), die Query-Guards (query.*), die Verbindungsgrenzen (server.*), das Speicher-Backend (dataBackend.*) und die beschreibbare Ebene (delta.*).
Resident Memory entspricht
blockCacheBytes + vectorCacheBytes + resultCacheBytes zuzüglich eines begrenzten
Overheads pro Eintrag und des Allokators — jeder Pool wiegt seine eigenen Inhalte (Strings
und Container nach allokierter Kapazität) und verdrängt, um unter dem Budget zu bleiben, aber
die Buchhaltung pro Eintrag und die Größenklassen-Rundung des Allokators sitzen zusätzlich
auf der von dir gesetzten Zahl — plus einem kleinen festen Overhead (und bis zu
degreeColumnBytes für die lazy-Gradspalte, sobald der Grad-Summen-Schnellpfad
count(endpoint) beansprucht wird). Es ist unabhängig von der Graphgröße — das ist das
zentrale Versprechen, das vom Integrationstest rss_stays_bounded_under_sustained_knn_load
geprüft wird, der das RSS-Wachstum zwischen Spitze und aufgewärmtem Zustand deutlich
innerhalb der summierten Budgets hält. Verbindungs-Puffer liegen außerhalb der
Cache-Budgets, sodass die Garantie unter widrigen Bedingungen nur deshalb gilt, weil
server.maxConnections begrenzt, wie viele gleichzeitig existieren können.
Netzwerk-Sicherheitslage
Slater ist eine Read-Replica-Schnittstelle; die primäre Kontrolle der
Verbindungssicherheit ist das Netzwerk, nicht das Binary. Binde es an ein privates
Interface, beschränke Quellbereiche auf der Netzwerkebene (Security Groups /
NetworkPolicy) und — falls es etwas anderem als vertrauenswürdigen Clients ausgesetzt
ist — stelle eine verbindungsbegrenzende L4-Proxy davor (HAProxy maxconn + eine
Pro-Quell-stick-table oder nftables connlimit + hashlimit). Das greift, bevor der
Dateideskriptor jemals an den Prozess übergeben wird, und ist daher die robusteste
Begrenzung.
Die oben genannten, im Binary enthaltenen Begrenzungen (maxConnections,
maxPreAuthConnections, maxConnectionsPerIp, die differenziellen Byte-Obergrenzen und
loginTimeoutMs) sind mehrstufige Verteidigung (Defence-in-Depth): Sie sind
standardmäßig aktiviert und großzügig bemessen, sodass sie für eine legitime
Client-Bevölkerung unsichtbar sind, aber sie lassen die begrenzte-RSS-Garantie selbst dann
gelten, wenn der Proxy vergessen wurde. Siehe docs/HARDENING.md
für die vollständige defensive Haltung sowie THREAT_MODEL.md / SECURITY_WORKLIST.md für
die kanonischen Details.
Generation-Guard
Slater pollt den current-Zeiger jedes Graphen alle generationPollMs
(Poll, nicht inotify — das Datenverzeichnis kann entferntes/Netzwerkspeicher wie NFS
sein, wo Dateisystem-Änderungsereignisse unzuverlässig sind). Wenn er sich ändert:
reloadStrategy=exit(Standard): Der Server protokolliert einen fatalen Fehler und beendet sich mit einem Exit-Code ungleich Null, sodass der Orchestrator ihn sauber gegen die neue Generation neu startet.reloadStrategy=swap: Der Server öffnet und validiert die neue Generation (gleicher Content-Hash-Schutz wie beim Start), tauscht sie atomar ein und lässt laufende Abfragen auf der alten Generation zu Ende laufen. Ein korruptes/unvollständiges neues Image wird abgelehnt und die alte Generation bedient weiter.
ACL
acl.json ordnet Benutzern argon2id-Passwort-Hashes und read-/write-Berechtigungen
pro Graph zu. Erzeuge einen Hash (speichere niemals Klartext) mit:```sh
slater hash-password 's3cret' # prints a $argon2id$… string for acl.json
Eine Starter-`acl.json` wird im Repository-Root mitgeliefert; ihre Struktur ist:```json
{
"users": {
"reporting": {
"passwordArgon2id": "$argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>",
"grants": {
"people": ["read"],
"products": ["read", "write"]
}
}
}
}
-
users— ein Eintrag pro Login, mit dem Benutzernamen als Schlüssel. -
passwordArgon2id— die$argon2id$…-Zeichenkette ausslater hash-password(niemals im Klartext; die Datei selbst ist einfaches JSON und liegt auf gemeinsam genutztem Speicher). -
grants— Fähigkeitslisten pro Graph. Zwei Berechtigungen sind von Bedeutung:read— den Graph abfragen. Ein Graph, der in den Grants eines Benutzers fehlt, ist für ihn unsichtbar.write— den Graph über die beschreibbare Schicht (delta.enabled) verändern: dieMERGE/SET/DELETE-Anweisungen undCALL slater.consolidate().
Sie sind unabhängig: ein
read-Grant gewährt keinen Schreibzugriff. Das Aktivieren der beschreibbaren Schicht kann daher Ihre bestehenden Leser nicht zu Schreibern befördern. Ein Schreiber benötigt beides —["read", "write"]— denn das Auflösen eines Geschäftsschlüssels zum Schreiben ist ein Lesevorgang. Nicht erkannte Berechtigungszeichenfolgen werden ignoriert (sie gewähren nichts).
Binden Sie es schreibgeschützt an dem Pfad ein, der durch aclPath benannt ist (Standard /config/acl.json).
Der Server lädt es bei jedem Generations-Hot-Swap neu, und der ruhende ACL-Stempel wird
bei jedem Neuladen erneut geprüft (siehe requireAclStamp).
Health Check
Die slater-Binärdatei dient zugleich als eigene Liveness-Probe: slater healthcheck [host] [port] führt einen Bolt-Handshake (keine HTTP-Anfrage) gegen den Server durch und
endet mit 0, wenn eine Protokollversion ausgehandelt wird, andernfalls mit 1 — standardmäßig mit
localhost und dem konfigurierten Bolt-Port. Genau das führt der Container
HEALTHCHECK aus, sodass Orchestratoren einen wirklich Bolt-bereiten Server sehen, nicht nur einen
offenen Socket:```sh
slater healthcheck localhost 7687 # exit 0 = healthy
docker exec slater /app/slater healthcheck # inside the container
## Einmalige Abfrage
Für Skripting, CI-Prüfungen und schnelles Nachschlagen mountet `slater query` die aktuelle Generation eines Graphen, führt eine einzelne schreibgeschützte Cypher-Abfrage prozessintern aus, gibt das Ergebnis als JSON-Objekt aus und beendet sich — kein Server, keine Bolt-Verbindung. Es verwendet dieselbe Konfiguration wie der Server (Speicher-Backend, Verschlüsselungsschlüssel, Abfragebudgets):```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"]]}
Knoten und Beziehungen werden zu ihren Labels/Typen und Eigenschaften expandiert. Verwenden Sie -q
wenn Sie maschinenlesbare Ausgabe wünschen (das JSON-Ergebnis ist dann das Einzige auf
stdout); lassen Sie es weg für einen Lauf mit Logs für den Betreiber. Ohne -q wird
nach jedem Lauf eine Zusammenfassung nur mit Metriken protokolliert — z. B.```text
INFO query executed cost=2389 resultCount=10 execMs=441 limitRowCount=10
trägt die Abfrage `cost` (berechnete Elemente), `resultCount`, `execMs` und
`limitRowCount` (nur wenn die Abfrage ein `LIMIT` angibt) — niemals den Abfragetext
oder einen Ergebniswert. Der Exit-Status ist `0` bei Erfolg, `1` bei einem
Parse-/Öffnungs-/Ausführungsfehler (Meldung auf stderr).
## Graph exportieren (`slater dump`)
`slater dump` exportiert einen Graphen von einem **laufenden** Server als
Business-Key-`MERGE`-Cypher — denselben Dialekt, den `slater-build` importiert —
sodass ein Graph einen Rundlauf (dump → `slater-build` → neue Generation) für
Migration oder Text-Backup durchläuft. Im Gegensatz zu `slater query` verbindet
es sich über **Bolt**, authentifiziert sich und berücksichtigt die
pro-Graph-ACLs, sodass es keinen Plattenzugriff auf den Server benötigt. Das
Passwort wird aus `SLATER_DUMP_PASSWORD` oder stdin gelesen (niemals über ein
Flag, um es aus `ps`/Verlauf herauszuhalten).```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
Der Identitätsschlüssel jedes Labels ist die Property, die sein Range-Index trägt; überschreiben
Sie ihn mit --key Label=prop (wiederholbar) oder einem globalen --pk <field>. Das CREATE INDEX-DDL
wird zuerst ausgegeben, damit der Wiederaufbau die Indizes neu erstellt. Ein Knoten mit mehreren Labels
behält jedes Label – er wird als MERGE (n:Ident:Other {key: v}) ausgegeben, wobei
das Identitätslabel (dasjenige, das den Geschäftsschlüssel liefert) zuerst kommt und der Rest
sortiert wird; der Merge basiert ausschließlich auf dem Identitätslabel, sodass die nachfolgenden Labels
auf den Knoten geschrieben werden, ohne einen weiteren zu erzeugen. Labels, Beziehungstypen
und Property-Schlüssel, die Sonderzeichen enthalten, werden bei der Ausgabe in Backticks gesetzt,
sodass ungewöhnliche Namen zuverlässig hin- und zurückkonvertiert werden und kein Cypher in den
Wiederaufbau injizieren können. Vektoren (und andere
Werte ohne Cypher-Literal-Schreibweise) können nicht in einem MERGE-Dump mitgeführt werden und werden
mit einer Warnung auf stderr verworfen. Der Exit-Status ist 0 bei Erfolg, 1 bei Fehler.
Ausgearbeitetes Beispiel
Ein vollständiger, ausführbarer Durchlauf – einen Graphen erstellen, ihn bereitstellen, mit den neo4j-JavaScript- und Python-Treibern verbinden und in ihn schreiben – befindet sich auf den Seiten Schnellstart und Daten schreiben des Handbuchs, unter Verwendung des mitgelieferten Beispielgraphen in docs/manual/examples/.
Entwicklung```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
### Object-Store-Backends sind optionale Cargo-Features
Ein einfaches `cargo build` erzeugt ein Binary, das **nur das Dateisystem** unterstützt – die `s3`- und `gcs`-Backends sind hinter Cargo-Features verborgen, damit der Standard-Build klein bleibt (kein AWS- oder Google-SDK, keine Async-Laufzeit). Aktivieren Sie das jeweils benötigte sowohl bei `slater` (serve) als auch bei `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
Jede Crate stellt passende s3-/gcs-Features bereit, die an
graph-format/{s3,gcs} durchgereicht werden. Wird zur Laufzeit ein Backend angefordert
(dataBackend.kind=s3|gcs oder slater-build --publish-{s3,gcs}-*), ohne dass dessen
Feature einkompiliert ist, schlägt dies schnell mit einer klaren Fehlermeldung („built without the … feature") fehl.
Das veröffentlichte Docker-Image aktiviert beide (Dockerfile CARGO_FEATURES); vorgebaute
Images benötigen daher keine zusätzlichen Flags — das ist nur beim Bauen aus dem Quellcode relevant.
Die Integrationstests sind ebenfalls entsprechend abgesichert: --features s3 --test s3_minio,
--features gcs --test gcs_emulator (ein fake-gcs-server) und --features gcs --test gcs_real (echtes GCS über ADC); jeder wird übersprungen, sofern seine SLATER_*-Umgebungsvariablen
nicht gesetzt sind.
Siehe docs/PLAN.md, docs/PROGRESS.md und docs/DECISIONS.md für das Design,
das Meilenstein-Register und das Entscheidungsprotokoll.
Performance
Bis zu sechs Engines, eine Single-Client-Suite, Graphen von einem 62k-Knoten-Spielzeuggraphen
bis zu Wikidata 91.6M Knoten / 1.5B Kanten. Jede Engine wird isoliert gemessen
(jeder andere Container ist gestoppt — RSS und Latenz sind ihr eigener Fußabdruck). Die
Latenztabellen unten wurden auf Slater 0.21.0 neu gemessen (dem beschreibbaren Build):
die kleinen/mittleren Graphen (MeSH, EU-AI-Act) frisch, und der 91.6M-Graph als frischer
Same-Box/Shared-Anchor-Durchlauf slater-vs-Neo4j (siehe dortige Tabelle). Die Zahlen zum
residenten Speicher werden aus dem früheren Durchlauf übernommen (gemessen über die
Container-Cgroup; der Lesepfad ist byteidentisch, während die beschreibbare Schicht inaktiv ist).
Die Zahlen der anderen Engines stammen aus dem etablierten Cross-Engine-Lauf (ihre Versionen/Performance
sind unverändert). Alle Werte sind Mediane (ms) oder Spitzenwerte des residenten Speichers (MiB).
Niedriger ist überall besser; fett = bester Wert in der Zeile. slater wurde auf seinem Local-Filesystem-Backend (fs) ausgeführt;
die S3- und GCS-Backends tauschen lokale Lese-Latenz gegen Object-Store-Roundtrips (abgemildert
durch die In-Memory-Caches und die optionale lokale Disk-Cache-Ebene), daher charakterisieren
diese Zahlen die Engine, nicht eine Netzwerk-Speicher-Bereitstellung.
| Engine | Klasse | Speichergrenze |
|---|---|---|
| slater | plattenbasiert, mit Paging | query.maxIntermediate begrenzt den Arbeitssatz automatisch |
| Neo4j 5 | plattenbasiert, JVM | ~2 GiB Heap + Off-Heap, unabhängig von der Abfrage belegt |
| Memgraph · FalkorDB | In-Memory | gesamter Graph im RAM resident |
| ArcadeDB | In-Memory, JVM | gesamter Graph resident; am speicherintensivsten |
| LadybugDB | eingebettet, spaltenorientiert | manueller Buffer-Pool, der den Arbeitssatz der Abfrage übertreffen muss |
Die drei Engines, die von der Platte pagen — slater, Neo4j 5 und LadybugDB — laden alle fünf Graphen. Das In-Memory-Trio (Memgraph · FalkorDB · ArcadeDB) kann den 1.5B-Kanten-Graphen überhaupt nicht halten (er benötigt ~64–128 GiB residenten Speicher), und auch ArcadeDBs Importer schafft ihn nicht.
Residenter Speicher (MiB) — begrenzt, während der Graph ~1,500× wächst
Jede Zahl ist fest belegter Arbeitsspeicher — Speicher, den das Betriebssystem nicht zurückfordern kann. Jede Engine außer slater hält ihren Graphen in fest belegtem anonymem Speicher (eigener Heap, Neo4js Off-Heap-Seitencache oder ein Buffer-Pool); ihre RSS-Spitze ist also ihr belegter Fußabdruck. Nur slater bedient sich aus dem vom Betriebssystem zurückforderbaren Seiten-Cache seines On-Disk-Stores; seine Zahl ist also der anonyme Arbeitssatz. Der Seiten-Cache des Stores (unter Druck verdrängbar — slater bedient weiterhin) ist ausgenommen und wird für den 91.6M-Graphen als Gesamtsumme in Klammern angegeben. Fett = niedrigster Wert.
| Graph (Knoten / Kanten) | 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 gesamt) | ~2,900 | nicht ladbar | nicht ladbar | nicht ladbar | ~652 † |
slater ist auf jeder Skala am niedrigsten und wächst um ~50×, während der Graph um ~1,500×
wächst — sein Fußabdruck folgt dem Abfrage-Arbeitssatz, nicht dem Graphen (im Leerlauf
durchgehend ~16–71 MiB). Das In-Memory-Trio wächst ~linear und kann den 1.5B-Graphen nicht
laden; Neo4j belegt unabhängig von der Abfrage einen Heap von ~2 GiB. († LadybugDB nur bei
den speicherbegrenzten Abfrageformen — ihre Hub-/Var-Length-/ShortestPath-Traversalen benötigen
bei 1.5B Kanten einen auf ≥2 GiB angehobenen Lesepool, gegenüber slaters automatischem
maxIntermediate-Limit.) Die zur Buildzeit erstellten Wert→Anzahl-Histogramme erhöhen den
residenten Speicher nur vernachlässigbar — ein paar KB für eine indizierte Spalte mit niedriger
Kardinalität und null für Unique-Key-Graphen wie Wikidata (wikidata_id überschreitet das
Histogramm-Kardinalitätslimit, daher wird keines gespeichert) — diese Zahlen bleiben von diesem
Feature also unverändert.
Latenz (Median ms) — Graph passt in den RAM (MeSH, 341k / 469k)
| Abfrageform | slater | Neo4j 5 | Memgraph | FalkorDB | ArcadeDB | LadybugDB |
|---|---|---|---|---|---|---|
| count(*) alle Knoten | 0.41 | 15.0 | 23.8 | 16.4 | 82.0 | 2.2 |
| Label-Zählung | 0.42 | 4.2 | 20.7 | 1.1 | 4.4 | 4.3 |
| indizierter Punkt-Lookup | 0.43 | 3.9 | 0.48 | 0.48 | 0.65 | 8.8 |
| idx-eq count | 0.42 | 4.9 | 5.0 | 2.0 | 381 | 2.5 |
| 1-Hop (indizierter Anker) | 1.28 | 5.8 | 1.21 | 4.1 | 390 | 4.9 |
| 2-Hop (ohne Anker) | 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 |
Vollscan CONTAINS | 0.43 | 5.4 | 24.1 | 1.7 | 16.3 | 4.1 |
slater beherrscht die Metadaten-/Index-/Scan-Abfrageformen (count, label, idx-eq, Scan — ~0.4 ms, 10–200× schneller als die Service-Engines), den indizierten Punkt-Lookup (0.43 ms und damit knapp vor den 0.48 ms des In-Memory-Duos), den Mehrfach-Hop ohne Anker (2-Hop in 1.40 ms über den Beziehungstyp-Scan — am schnellsten im Feld) und — über ein zur Buildzeit erstelltes Wert→Anzahl-Histogramm auf dem indizierten Gruppierungsschlüssel — das Group-by über das gesamte Label / count(DISTINCT) (0.45 ms, vor den spaltenorientierten 5.3 ms von LadybugDB). Den In-Memory-Servern bleibt nur der reine 1-Hop (Memgraph 1.21 ms gegenüber slaters 1.28 ms). (pole 62k/106k sieht genauso aus: slater bei count/Scan mit ~0.4 ms allein am schnellsten, bei Hops ~1.3–2.6 ms.)
Latenz (Median ms) — Vektoren (EU-AI-Act kNN, 15k × 1024-dim)
| Abfrageform | 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 beantwortet kNN mit einem exakten Brute-Force-Scan (diese Mengen liegen unter seiner 50k-Vektor-ANN-Schwelle), während die anderen ein approximatives residentes HNSW verwenden — slaers Ergebnisse sind also exakt (Recall 1.0). Ein SIMD-Distanz-Kernel plus eine residente, vorab normalisierte Vektormatrix senkte Concept von ~23 auf ~2.9 ms und Chunk von ~10 auf ~2.4 ms; damit schlägt slater nun Neo4j und LadybugDB, liegt ~1.4× hinter Memgraph, nur FalkorDB ist noch schneller — und das bei exakten Ergebnissen.
Vector-Write-Ladder — Einfügen / Aktualisieren / Löschen ohne Neuaufbau
Die Tabellen oben sind engine-übergreifende Lese-Vergleiche. Der Vektor-Schreib-Pfad (die
FreshDiskANN-artige Write-Ladder über der statischen
Vamana-Basis) hat keine Entsprechung bei anderen Engines — keine andere Engine hier bietet
disk-natives, beschreibbares ANN — daher sind die Zahlen unten Komponenten-Benchmarks
einer einzigen Engine über ein synthetisches, einbettungsähnliches Fixture (eine
Low-Rank-Mannigfaltigkeit, Dimension 768, ungleiche Normen), abgelegt unter
crates/slater/benches/ und in
docs/PERF-REPORT.md vollständig beschrieben — mit Methodik und allen
Vorbehalten. Der Recall wird immer gegen eine exakte Brute-Force über den Live-Bestand
gemessen, niemals ein Index gegen einen anderen. Die Größenordnung hier ist repräsentativ und
wird nur dort extrapoliert, wo die Metrik größenlinear ist.
| Eigenschaft | Messung | warum es zählt |
|---|---|---|
| KNN-Latenz bei anstehenden Schreibvorgängen | RW-Index ~1.5–2 ms, konstant bis 50k anstehende Schreibvorgänge; die dem Index vorgeschaltete Brute-Force-Overlay-Schicht 1.9 → 115 ms (linear im Delta) — 61× bei 50k | Die Abfragelatenz verschlechtert sich nicht, während sich zwischen den Konsolidierungen Schreibvorgänge ansammeln |
| Einfügen von Embeddings | ~1.5–2 ms pro Vektor in den Live-Index | Ein Schreibvorgang ist sofort KNN-sichtbar; das Budget für den Delta-Neuaufbau beträgt ≈ 2 ms × das Delta-Limit |
| Lösch-IO bei iso-Recall | 2.9× weniger Node-Fetches pro Abfrage bei 67 % gelöschten Vektoren, 5.2× bei 80 % (Recall ≥ 0.90) | Ein konsolidierter Graph zahlt keine Lesegebühr für gelöschte Vektoren |
| Konsolidierung, reine Permutation | O(1) — die .vamana-Datei ist per Hardlink byteidentisch, nur die ID-Spalte wird neu geschrieben | Das Eingliedern von Vektorschreibvorgängen in die Basis überspringt den O(N·R·L)-Neuaufbau |
| Recall über die gesamte Write-Ladder | konsolidiert ≥ Basis für Kosinus, L2 und Skalarprodukt | Die Write-Ladder erhält den Recall auf jeder Stufe |
Die eine Kennzahl, die eine eigene Performance-Box braucht, ist der Durchsatz des Slow-Path-Konsolidierungs-Rewrites — wenn eine Konsolidierung Löschungen oder neue Vektoren statt einer reinen Permutation enthält, ist sie eine sequenzielle Rekompression, die durch Single-Thread-zstd und die lokale Platte begrenzt wird; die absoluten MiB/s sind daher umgebungsspezifisch (der Bericht zeigt den Verlauf und erklärt die Umgebungsspanne).
Latenz (Median ms) — Graph ≫ RAM (Wikidata 91.6M / 1.5B)
Die In-Memory-Engines (Memgraph / FalkorDB / ArcadeDB) können diesen Graphen überhaupt
nicht laden (~64–128 GiB resident). Nur slater und Neo4j 5 können das. Dies ist ein frischer
Same-Box/Same-Day-Durchlauf gegen einen gemeinsamen, festen Ankersatz — jede Abfrage trifft
auf beiden Engines auf die identischen Knoten, der direkte Vergleich ist also fair (ein
gemeinsamer wikidata_id-Pool aus Ankern mittleren Grades; siehe unten, warum das wichtig ist).
slater wird mit beiden Fanouts gezeigt (query.maxFanout 1 = Durchsatz-Standard, 8 = der
Latenz-Regler, der kalte Block-Reads überlappt). Fett = bester Wert in der Zeile.
| Abfrageform | slater (Fan 1) | slater (Fan 8) | Neo4j 5 |
|---|---|---|---|
| count(*) alle Knoten | 0.41 | 0.41 | 3606 |
| Punkt-Lookup (indiziert) | 0.72 | 0.49 | 6.3 |
| Grad (1-Hop-Zählung) | 0.43 | 0.44 | 6.0 |
| 1-Hop-Nachbarn | 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 |
Das ehrliche Bild: slater dominiert die Metadaten-/Index-Abfrageformen — count(*) wird aus
den Metadaten bedient (0.41 ms gegenüber Neo4js 3.6-s-Disk-Scan, ~8800×), und Punkt-Lookup /
Grad / 3-Hop laufen ~2–10× schneller — liegt bei 1–2-Hop gleichauf mit Neo4j (Fanout 8 zieht
bei kalten Reads davon), verliert aber var-length *1..2 distinct deutlich (≈1 s gegenüber
Neo4js 47 ms): slaters Expansion für variabel lange Pfade mit distinct ist hier erheblich
langsamer, eine echte Schwäche, die eine eigene Untersuchung verdient. Und das bei ein paar
hundert MB RSS gegenüber Neo4js belegtem ~2-GiB-Heap.
Zu den Ankern. Diese Traversal-Zahlen hängen stark davon ab, von welchen Knoten man startet — ein Knoten, der nur einen Link von einem Wikidata-Mega-Hub entfernt ist („human", „country"), hat eine Millionen-starke 2-Hop-Nachbarschaft; die Kosten für var-length/Hop schwanken daher je nach Ankerwahl um Größenordnungen. Die frühere Ausgabe dieser Tabelle verwendete die eigenen „erste N per Scan", jeder Engine, was weder stabil noch vergleichbar ist; dieser Durchlauf legt für beide Engines einen einzigen gemeinsamen, gradbegrenzten Ankersatz fest. (shortestPath ist in diesem Durchlauf ausgelassen — zwischen zwei beliebigen Ankern hängt er von der Pfadexistenz ab und ist zu stark streuend, um einen aussagekräftigen Median zu bilden.)
Multi-Hop count(*) — Speicher entkoppelt von der Ergebnisgröße
Unbegrenztes Multi-Hop-RETURN count(*) zählt während der Expansion, statt die passenden
Zeilen zu materialisieren. Gleiche Hub-Anker auf dem 91.6M-Graphen, maxIntermediate=20M:
| 3-Hop count(*) @ 91.6M | fanout=1 | fanout=8 |
|---|---|---|
| Latenz / Spitzen-Arbeitssatz | 554 ms / 0.66 GiB | 298 ms / 1.9 GiB |
Der Zähler hält O(1) Zeilen. Das Charging ist unverändert; eine Mega-Hub-Zählung stößt also
weiterhin auf Compute-Ebene an maxIntermediate (Adjazenz-Lesevorgänge) — begrenzt wie zuvor.
Parallelität pro Abfrage (maxFanout)
Ein höheres query.maxFanout überlappt die kalten, I/O-gebundenen Block-Reads einer
Abfrage über mehrere Kerne — es hilft bei plattengebundenen Abfrageformen mit großem kaltem
Arbeitssatz und bleibt bei warmen Formen wirkungslos. Auf dem 1.5B-Graphen: shortestPath ≤6
918 → 608 ms (1.5×, größte Suche 6,269 → 2,350 ms, 2.7×); 3-Hop-Zählung 547 → 298 ms.
maxFanout=1 ist der Standard (durchsatzorientiert), 8 der Latenz-Regler — bei mehr
transientem Worker-Speicher.
Wo slater gewinnt / zurückliegt
| Dimension | slater | Beste Konkurrenz | Wertung |
|---|---|---|---|
| Residenter Speicher, jede Skala | 11–584 MiB (62k → 91.6M) | In-Memory 1.5–2.7 GiB; kann 1.5B nicht laden | slater |
| count / Metadaten / Scan | ~0.4 ms | Service-Engines 5–80 ms | slater (10–200×) |
| indizierter Punkt-Lookup | 0.43 ms (MeSH) | Memgraph · FalkorDB 0.48 ms | slater (knapp vor dem In-Memory-Duo) |
| Mehrfach-Hop ohne Anker (Zeilen) | 1.40 ms (MeSH 2-Hop) | Neo4j 5.6 ms | slater (Beziehungstyp-Scan) |
| Aggregation (group-by / DISTINCT) | 0.45 ms | LadybugDB 5 ms (spaltenorientiert) | slater (Buildzeit-Histogramm) |
| kNN | 2.4–2.9 ms (exakt) | FalkorDB 1.2 ms (HNSW) | schlägt Neo4j/Ladybug; ~1.4× hinter Memgraph; exakt |
| 91.6M Metadaten / Punkt / Grad / 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 | ~gleichauf |
91.6M var-length *1..2 distinct | ~1 s | Neo4j 47 ms | Neo4j (eine echte slater-Schwachstelle) |
Multi-Hop count(*) bei großer Skala | 0.3–0.6 GiB | In-Memory-Engines materialisieren die Zeilenmenge | slater, begrenzt |
Vollständige Tabellen pro Engine (pole, MeSH, EU-AI-Act + der blockCacheBytes-Regler RAM↔Latenz,
Wikidata 1M & 91.6M) finden sich in perf/cross-engine-hs/README.md;
der frische reine-slater-Durchlauf (beide Fanouts, alle Datensätze) in
perf/PERF_CURRENT_STATUS.md.
Nebenläufigkeit & Brown-out (Lasttests)
Die Benchmarks oben sind Single-Client. Die komplementäre Achse — das Verhalten unter vielen
gleichzeitigen Clients — hat eine eigene Harness, perf/loadtest/: ein
Locust-Treiber über Bolt plus ein Koordinator, der die Last hochfährt, CALL slater.diagnostics()
liest, den Kapazitäts-Knick findet und den Limiter benennt (vollständige Methode in
docs/LOAD-TESTING.md). Eckdaten aus einem Lauf mit 256-MiB-Cache auf
dem Wikidata-1M-Graphen (eine 16-Kern-Box):
| Ergebnis | Messung |
|---|---|
| Hält 1000 gleichzeitige Clients, null Fehler aus | Durchsatz-Spitzenwert ~2.5k rps; der Latenz-Knick setzt bei etwa 750 Clients ein (p99 51 → 750 ms) — Queueing unter Kernkonkurrenz, keine harte Obergrenze (Einzellauf, WSL2) |
| Block-Cache begrenzt und effektiv | 100 % Trefferquote, 0 Evictions, 50 MB resident für einen in den Cache passenden Arbeitssatz |
| RSS unter Dauerlast gehalten | der jemalloc-Allokator hält die RSS über eine 100→500-Client-Rampe (wiki_cache_churn) bei ~0.6 GB — cache-gebunden und stabil, ohne MALLOC_*-Tuning (die frühere MALLOC_ARENA_MAX=2-Einstellung plus Trim-Schwelle wurde zurückgezogen); sein Hintergrund-Purge gibt außerdem den Höchststand nach dem Burst wieder frei, statt ihn fixiert zu lassen |
| Aggregierter Speicher begrenzt | serverweit halten query.maxIntermediateGlobal und die über Adjazenz angerechnete Expansion die wiki_budget-2-Hop-Flut bei 1000 Clients ohne OOM (RSS ~0.6 GB; die Absicherung verwirft ~60 % der Hub-Abfragen als wiederholbare Budget-Fehler) |
Beide Speicherprobleme, die der Lasttest aufgedeckt hat, sind inzwischen behoben; alles ist im Load-Testing-Dokument nachverfolgt.
Lizenz
Lizenziert unter der Apache License, Version 2.0. Siehe LICENSE für den
vollständigen Text und NOTICE für die Zuschreibung (Attribution). Sofern Sie nicht
ausdrücklich anders angeben, wird jeder Beitrag, der absichtlich zur Aufnahme in dieses Werk
eingereicht wird — wie in der Apache-2.0-Lizenz definiert — unter den oben genannten Bedingungen
lizenziert, ohne zusätzliche Bestimmungen oder Bedingungen.
SPDX-License-Identifier: Apache-2.0