
slater v0.24.4
Graphdb de baixo consumo de memória com suporte a Bolt+tls, criptografia em repouso e vetores, projetado para casos de uso de grafos de réplica local.
Slater
Versão atual: v0.24.4 — todas as versões.
Em uma linha: o Slater serve grafos que não cabem na memória — centenas de milhões de nós e bilhões de arestas em algumas centenas de MB de RAM — via Bolt padrão, para que qualquer driver neo4j funcione, com busca vetorial nativa em disco ao lado do grafo, e aceita escritas ativas e duráveis sem abrir mão disso. A memória residente é definida por um orçamento de cache que você escolhe, não pelo tamanho do grafo.
Atalhos
Por que o Slater existe
Um banco de dados de grafos armazena dados como coisas (nós) e os relacionamentos entre elas (arestas), com os relacionamentos como cidadãos de primeira classe. É isso que você quer quando suas perguntas são sobre conexões em vez de linhas — "quem está a três saltos desta conta?", "qual é a cadeia de dependências completa por trás desta compilação?", "quais contas compartilham um dispositivo, um endereço e um cartão?" — as consultas que viram um pântano de junções recursivas em SQL, mas surgem naturalmente em um grafo.
A reclamação mais comum sobre bancos de dados de grafos é que eles não escalam além do que cabe na RAM. Muitos deles (ex.: neo4j, Memgraph, FalkorDB, etc.) mantêm o grafo inteiro residente: um grafo de 40 GB quer 40 GB de memória — por instância. Quer uma réplica por região, por locatário ou por pod? Multiplique a conta. E acima de certo tamanho eles simplesmente não carregam: por exemplo, o grafo Wikidata de 90 milhões de nós / 1,5 bilhão de arestas precisa de ~64–128 GiB residentes, então os mecanismos em memória não conseguem nem abri-lo.
O Slater é a refutação. Em vez de carregar o grafo na memória, ele o compila uma vez, offline: o slater-build transforma seus dados em uma imagem imutável em disco, endereçada por conteúdo, e qualquer número de servidores Slater então serve essa imagem via Bolt (então seus drivers neo4j existentes funcionam sem alteração), paginando blocos sob demanda e mantendo apenas um orçamento de cache fixo residente. É assim que o mesmo grafo de 90M de nós é servido a partir de algumas centenas de MB de RAM — o tamanho do grafo e a conta de memória são desacoplados. Um grafo de 4 GB e um grafo de 400 GB custam a mesma RAM para servir, então você distribui réplicas de leitura baratas e sem estado e deixa o armazenamento, não a heap, segurar o grafo.
Isso o torna uma opção natural para grafos de conhecimento por trás de RAG, grafos de recomendação e identidade, grafos de dependências — qualquer coisa grande e conectada que você queira consultar com custo baixo e frequência. A busca vetorial nativa em disco fica bem ao lado do grafo, então o mesmo mecanismo também é a camada de recuperação de embeddings.
Compilado uma vez não significa congelado, no entanto. Essa imagem é uma base, não um estado final: uma camada de escrita opt-in fica acima dela, então um grafo ativo pode ser corrigido e estendido sem reconstruir nada.
Leituras e escritas
O núcleo é imutável; o grafo não é. Ative a camada gravável (delta.enabled) e você escreve via Bolt — corrija uma propriedade, adicione um nó, retire uma aresta — e a alteração é persistida de forma durável, sem reconstrução da imagem. O que mantém isso barato no lado da leitura é onde as escritas vivem.
As escritas se acumulam em uma camada de mesclagem estruturada por log (LSM) sobre o núcleo imutável: um log write-ahead e uma tabela em memória, com descarga para segmentos delta imutáveis, dobrados de volta em um novo núcleo por uma consolidação periódica. O que isso lhe proporciona:
- Leituras sobre um grafo sem escritas custam exatamente o que custavam antes. Um delta vazio é um único ramo previsível, não uma mesclagem — o caminho de leitura é byte idêntico, esteja a camada gravável ligada ou não.
- O custo de leitura de uma escrita escala com o tamanho do delta, não com o tamanho do grafo. Respostas de grafo inteiro —
count(*), os marginais de rótulos e tipos de relacionamento — permanecem leituras de metadados mesmo com escritas pendentes: o delta mantém seus próprios contadores, então umcount(*)sobre um núcleo de 91,6M de nós com meio milhão de escritas pendentes ainda responde em dezenas de milissegundos sem tocar um único bloco. - Reconhecido significa durável. Um único escritor drena a fila e retorna
SUCCESSsomente após ofsyncque cobre a escrita. Agrupe suas escritas e elas ficam baratas — umUNWINDde escrita confirma umfsyncpor lote, em vez de por linha. - Escritas por chave de negócio, em qualquer dialeto.
MERGE/MATCH … SET/DELETE(eCREATE/REMOVE, detach delete, escritas de relacionamento) chaveadas pela propriedade de identidade de um nó — ou as declarações equivalentes de modificação de dados do ISO GQL (INSERT/SET/REMOVE/DELETE), que descem para o mesmo caminho. Corrija, insira, faça upsert e retire, sobre nós e arestas, endereçados da maneira que seus dados já estão.
Com a camada desligada — o padrão — o Slater serve o núcleo imutável puro e recusa escritas. Veja A camada gravável para o modelo completo.
Sobre o nome. O Slater é nomeado em homenagem ao agente da CIA em Archer (um ótimo programa) que insiste em ser chamado por um único nome — "Apenas… Slater" — e um dos meus personagens favoritos da série. Veja a página do personagem na wiki.
O que você obtém
- RAM definida pelo seu orçamento de cache, não pelo tamanho do grafo — distribua quantas réplicas de leitura quiser; o grafo nunca precisa caber na memória.
- Um substituto imediato para o grafo — fala Bolt, então qualquer driver neo4j padrão (JS, Python, Go…) funciona sem alterações. É Cypher (mais uma fatia de ISO GQL, leituras e escritas); nada novo para aprender.
- Escritas ativas e duráveis — uma camada LSM opt-in sobre o núcleo imutável:
MERGE/SET/DELETEpor chave de negócio sobre nós e arestas, com commit em grupo e duráveis viafsync, dobradas de volta em um novo núcleo pela consolidação. As leituras não pagam por isso. - Implantação por troca de arquivos — compile uma nova geração com hash de conteúdo offline, altere atomicamente o ponteiro
current, e os servidores a detectam. Cada bloco tem soma de verificação, então uma imagem copiada pela metade é recusada em vez de servida. - Busca vetorial integrada — busca aproximada de vizinho mais próximo nativa em disco (cosseno, L2 ou dot KNN) fica bem ao lado do seu grafo, para quando este é a camada de recuperação por trás de um pipeline de RAG, e os embeddings são graváveis no lugar — sem reconstrução offline para adicionar ou alterar um vetor.
- Bloqueado por design — as permissões de leitura e escrita são independentes, além de criptografia opcional em repouso, TLS Bolt, ACLs com hash argon2id e um rootfs de contêiner somente leitura para réplicas de leitura. Configure uma chave mestra e a imagem em disco é autenticada além de criptografada — seu manifesto carrega um MAC com chave, então um atacante com acesso de escrita ao diretório de dados, mas sem a chave, não pode forjar um manifesto que o servidor aceite. Sem uma chave, você ainda tem o hash de conteúdo, que detecta uma imagem copiada pela metade ou corrompida — mas não uma deliberada. Qual configuração compra o quê.
Recursos
| Recurso | O que significa para você |
|---|---|
| Memória limitada e previsível | A memória residente acompanha três orçamentos de cache que você define, dentro de uma sobrecarga limitada por entrada e do alocador — ela não cresce com o tamanho do grafo; você ajusta a relação desempenho/RAM em vez de provisionar para o grafo inteiro. Um alocador jemalloc com purga em segundo plano devolve a memória liberada ao SO após picos intensos de consultas, de modo que o tamanho residente volta em direção ao seu piso ocioso em vez de permanecer preso na marca máxima pós-pico. |
| Multiusuário pronto para uso | Um servidor hospeda muitos grafos com permissões de leitura por usuário — isolamento multi-banco que a maioria dos bancos de dados de grafos reserva para um nível pago/empresarial. |
| Criptografia em repouso e em trânsito | Selamento por bloco com XChaCha20-Poly1305 (a chave nunca é gravada em disco) mais TLS opcional (bolt+s://). Compatível com GDPR por construção. A criptografia é também o que compra integridade autenticada: o builder sela o manifesto com um MAC com chave, e um servidor que possui a chave o verifica e se recusa a servir uma geração cujo manifesto foi forjado, alterado ou teve o MAC removido. Uma imagem sem chave (texto puro) é protegida apenas pelo hash de conteúdo sem chave — completude e corrupção, não adulteração. Veja O que a integridade significa em cada configuração. |
| Instalação minúscula | Um binário pequeno e reduzido em uma base distroless glibc (sem shell/apt) — a imagem multi-arquitetura (amd64/arm64) pesa ~22 MB, ou ~12 MB para a tag somente servidor slater:latest-lite; TLS puramente Rust, sem OpenSSL. Baixe e execute. |
| Feito para publicação periódica | Compile um grafo offline, sirva-o imutável e depois troque atomicamente por uma nova versão com zero tempo de inatividade — ideal para cargas de trabalho de data warehouse / atualização agendada. |
| Robusto sob carga | O servidor e o builder offline compilam ambos com #![forbid(unsafe_code)] — o único unsafe do mecanismo vive no crate auditado do alocador jemalloc. O núcleo é imutável, então leituras não usam travas e nunca esperam por um escritor; um único escritor serializa mutações apenas no caminho de escrita. Sem pausas do GC, sem corridas de dados. Uma consulta ruim não pode derrubar o servidor. |
| Funciona com suas ferramentas neo4j | Fala Bolt 5.4 / 4.4 / 4.1 — use os drivers neo4j padrão (JS, Python, Go, Java…), cypher-shell ou navegadores de grafos sem alterações. |
| Superfície de consulta Cypher rica | Uma ampla superfície de leitura: MATCH/WHERE/WITH/UNION, subconsultas CALL {…}, 70+ funções e agregações, valores temporais e geoespaciais, e regex. |
| Escritas ativas e duráveis | Uma camada LSM opt-in de escritor único sobre o núcleo imutável (delta.enabled): MERGE / SET / DELETE / CREATE / REMOVE por chave de negócio sobre nós e relacionamentos, UNWIND de escrita em lote (um fsync por lote) e CALL slater.consolidate() — com commit em grupo, duráveis via fsync e dobradas de volta em um novo núcleo pela consolidação. O caminho de leitura é byte idêntico quando o delta está vazio. |
| ISO GQL, leitura e escrita | Fala um subconjunto de ISO GQL (ISO/IEC 39075) na mesma conexão Bolt — caminhos quantificados, restritores de caminho, seletores de caminho mais curto, expressões booleanas de rótulo/tipo, FOR, CAST, um prefixo de dialeto opcional GQL/CYPHER — e, com a camada gravável ativada, as declarações de modificação de dados do GQL (INSERT / SET / REMOVE / [DETACH] DELETE) descem para o mesmo caminho de escrita durável. Cypher e GQL, leituras e escritas, em um único mecanismo. |
| Vetores + grafo em um único mecanismo | Busca vetorial ANN nativa em disco (Vamana + PQ; cosseno / L2 / dot) para embeddings/RAG, além de algoritmos de grafos (PageRank, BFS, betweenness, WCC…) — memória limitada mesmo com milhões de vetores. Embeddings são graváveis (uma escada de escrita estilo FreshDiskANN): insira / atualize / exclua um vetor, visível ao KNN imediatamente, dobrado na base sem reconstrução. |
| Seguro em armazenamento de rede | Cada arquivo recebe hash de conteúdo BLAKE3 e é verificado ao abrir; imagens truncadas ou copiadas pela metade são recusadas, não servidas. Projetado para volumes NFS/remotos (sem surpresas de mmap). |
| Backends de armazenamento plugáveis | Sirva o mesmo formato de geração a partir de um sistema de arquivos local, um bucket S3 (compatível com S3) ou um bucket do Google Cloud Storage — publique uma vez, distribua para réplicas sem estado — com uma camada opcional de cache em SSD local na frente do armazenamento de objetos. Veja Backends de armazenamento. |
Dois binários compõem o workspace:
| Binário | Função |
|---|---|
slater | O servidor Bolt online (o ENTRYPOINT do contêiner): serve leituras e, com delta.enabled, o caminho de escrita durável de escritor único. |
slater-build | O compilador offline: transforma um dump Cypher primitivo em um diretório de geração imutável e com hash de conteúdo. |
O Slater separa a construção em massa do serviço: o slater-build faz o trabalho pesado
offline — ingerindo seus dados e compilando-os em uma geração imutável — para que um
grafo frio nunca seja montado no caminho quente do serviço. Dentro do servidor, a superfície
de leitura responde a uma ampla fatia de Cypher — correspondência de padrões, subconsultas
WITH/UNION/CALL {…}, 70+ funções escalares e de agregação, valores temporais e geoespaciais,
algoritmos de grafos (algo.*) e KNN vetorial nativo em disco (db.idx.vector.queryNodes) —
enquanto a sobreposição delta da camada gravável fica abaixo dessa superfície e tem custo zero
quando vazia, então as leituras nunca carregam a maquinaria do lado da escrita. Você pode atualizar um grafo
de duas maneiras: escrever nele ao vivo via Bolt (veja A camada gravável),
ou construir uma nova geração offline e trocar atomicamente o ponteiro current, que
o servidor em execução detecta via sua guarda de geração (veja
Guarda de geração).
Documentação
O manual do usuário completo está em docs/manual/ —
um guia recurso por recurso que explica, para cada capacidade, o que é, por que
existe e como usá-la, com exemplos práticos que você pode executar contra um
grafo de exemplo incluído. Comece por lá para qualquer coisa além desta visão geral.
- Novo por aqui? Início rápido cria e serve um grafo em cinco passos.
- Escrevendo consultas? Consultas, Funções e expressões, Procedimentos e algoritmos, Busca vetorial, Escrita de dados.
- Construindo grafos? Construindo grafos e a Referência da CLI de build.
- Operando o Slater? Implantação, Armazenamento, Referência de configuração, Segurança, Ajuste de desempenho.
Executando com Docker
O Slater foi projetado para ser executado como uma implantação Docker — essa é a forma esperada
de usá-lo. Imagens multi-arquitetura pré-construídas (linux/amd64 + linux/arm64) são
publicadas no Docker Hub em
hikarisystems/slater,
marcadas com :latest e :vX.Y.Z em cada release:```sh
docker pull hikarisystems/slater:latest
Um guia de uso, configuração e operações somente com comandos Docker está em
[`DOCKERHUB.md`](https://github.com/hikari-systems/slater/blob/HEAD/DOCKERHUB.md) (e é espelhado na página de visão geral do Docker Hub) —
**comece por lá se você está implantando.** Em resumo:```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
Para construir a imagem localmente em vez disso (por exemplo, para desenvolvimento):```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
O estágio builder instala `cmake`, `clang` e `libclang-dev` para o backend `aws-lc-rs` do rustls; `git` (já presente na imagem base) é necessário para a dependência `hs-utils` com git+tag, que `.cargo/config.toml` obtém via a CLI do git.
As seções abaixo cobrem o formato em disco, a configuração, as ACLs e um exemplo prático local (sem Docker).
## Como funciona```
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>
- Uma geração é um diretório imutável: um
MANIFEST.json(tabelas de símbolos, descritores de índice, um cabeçalho de criptografia opcional), arquivos de blocos colunares (node_props.blk,node_labels.blk,edge_props.blk,topology.csr.blk,vectors.f32.blk), índices de intervalo (range/<name>.isam), índices ANN acima do limite (vector/<label>.<prop>.{vamana,pq}) e um ponteiro de textocurrent. - Cada bloco é comprimido com zstd e tem checksum BLAKE3; com
--encrypt, cada bloco é adicionalmente selado com XChaCha20-Poly1305 (AEAD em repouso). - O servidor abre uma geração recalculando o hash de cada arquivo contra o manifesto, de modo que uma imagem copiada pela metade / truncada — uma cópia rasgada no diretório de dados, que pode ser armazenamento remoto/de rede — é recusada em vez de servida.
- As leituras fluem por três pools de cache limitados — um LRU de blocos descomprimidos, um pool de índices de vetores (códigos PQ residentes + um LRU de blocos Vamana) e um LRU de resultados —, cada um com seu próprio orçamento de bytes. Cada pool pesa o que armazena e faz evicção para permanecer abaixo do orçamento, de modo que o RSS acompanha os orçamentos dentro de uma sobrecarga limitada por entrada e do alocador, em vez de crescer com o grafo.
A camada gravável
Com delta.enabled, a geração imutável torna-se o nível inferior totalmente
compactado (o "núcleo") de uma pequena árvore de mesclagem estruturada por log,
e as gravações em tempo real ficam por cima dela:```
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)
└─────────────┘
* **Piso de durabilidade — o WAL.** Cada mutação é serializada atrás de um único
escritor por grafo, anexada a um log de write-ahead por grafo e submetida a `fsync`
antes de o `SUCCESS` do Bolt ser retornado — portanto *confirmado ⇒ durável*, e uma
cauda rasgada é descartada na reprodução. Um `UNWIND` de escrita em lote anexa suas
linhas e confirma **um** `fsync` para o lote inteiro. O WAL é **somente em disco
local** (não é roteado através do backend de armazenamento), o que torna um nó
*escritor* stateful: ele precisa de um volume local durável em `delta.walDir`. Réplicas
de leitura permanecem stateless.
* **Memtable → L0 → consolidação.** As escritas acumulam-se numa memtable em RAM
(limitada por `delta.memtableBytes`); quando ela enche, é despejada em um segmento
delta L0 imutável. Uma **consolidação** incorpora `{core + delta}` em um novo core ao
serializar a visão mesclada de volta através do `slater-build` e trocar `current`
atomicamente — a mesma proteção por hash de conteúdo de qualquer geração publicada.
Acione-a manualmente com `CALL slater.consolidate()`, automaticamente em
`delta.deltaCorePercent` do tamanho do core (opcionalmente limitada a uma janela fora
de pico `delta.consolidateWindow`), ou deixe o `delta.deltaHardBytes` servir de
salvaguarda contra o crescimento descontrolado.
* **A overlay fica abaixo da superfície de leitura.** O executor lê através de um
`ReadView` que é ou o core puro (delta sempre vazio) ou uma visão mesclada
`(core, delta)`; o motor é monomorfizado sobre ela, de modo que um delta vazio
compila para um único branch previsível e o caminho somente leitura é byte-idêntico.
Contadores de grafo inteiro (`count(*)`, marginais de label/reltype) são servidos a
partir dos contadores ativos do próprio delta, portanto permanecem leituras de
metadados mesmo com escritas pendentes.
* **Uma consulta vê um snapshot estável.** Ela fixa uma tupla `(core, delta)` durante
toda a sua vida. Não há transações de múltiplas instruções nem rollback — uma escrita
é uma correção durável endereçada por chave de negócio, não uma transação OLTP.
A gramática exata de escrita e os ajustes estão na tabela [Configuração](#environment--configuration)
(`delta.*`) e no [Exemplo prático](#worked-example) abaixo.
### Índices de intervalo (ISAM)
Um índice de intervalo (`range/<name>.isam`, um por `(label, property)` indexada)
permite que um `MATCH (n:Label {prop: v})` ou `WHERE n.prop <op> v` resolva para os IDs
de nós correspondentes **sem varrer o label**. É uma estrutura
**[ISAM](https://en.wikipedia.org/wiki/ISAM)** (Indexed Sequential Access Method) — o
clássico índice *estático, ordenado e estruturado em blocos*, que é exatamente o formato
certo para uma geração imutável: não há inserções para rebalancear, então a simplicidade
do ISAM proporciona aquilo que a maquinaria de mutação de uma B-tree apenas complicaria.
* As entradas `(value, entity_id)` são ordenadas por valor e empacotadas nos mesmos
blocos compactados com zstd de 256 KiB que todo o resto.
* Um pequeno **nível superior residente** contém a primeira chave de cada bloco (um
índice esparso). Uma busca faz binary search nesse nível superior em memória para
encontrar o *único* bloco em que uma chave pode estar, lê + descompacta esse bloco e o
examina — portanto uma busca por igualdade é **uma leitura de bloco**, e uma varredura
de intervalo percorre a sequência contígua de blocos que abrange. (É por isso que uma
busca indexada por `meshUi` leva milissegundos de um dígito enquanto a mesma
correspondência em uma propriedade não indexada varre o label inteiro.)
* O planejador escolhe isso via `NodeScan::RangeEq` / `RangeRange`; um predicado não
indexado recorre a uma varredura de label ou a uma varredura completa, com o executor
re-verificando cada predicado de qualquer forma.
### Busca vetorial (Vamana + PQ) — cosseno, L2 e produto escalar, leitura *e* escrita
O KNN vetorial (`db.idx.vector.queryNodes`) opera sobre índices de **cosseno, L2 ou
produto escalar (MIPS)**. O índice base é construído offline com dois caminhos de
execução, escolhidos por índice pelo `--ann-threshold` (padrão 50 000 vetores):
* **Abaixo do limite — força bruta.** Os vetores `f32` completos ficam em
`vectors.f32.blk`; uma consulta examina o grupo do índice e calcula a distância exata
na métrica do índice. Simples e exato; adequado quando o conjunto de vetores é pequeno.
* **No limite ou acima dele — Vamana + PQ**, o caminho ANN nativo de disco que mantém a
memória residente limitada independentemente de quantos vetores existam:
* **[Vamana](https://arxiv.org/pdf/2401.11324)** é o índice de grafo da linha de
trabalho DiskANN: um único grafo de proximidade cujas arestas são podadas (o grau de
saída `--vamana-r` e o fator de aresta longa `--vamana-alpha`) de modo que uma
*busca em feixe gulosa* — comece na medoide, salte repetidamente em direção à
consulta, mantendo uma lista de candidatos de largura `vectorQuery.beamWidth` —
alcance os verdadeiros vizinhos de um nó em poucos saltos, ou seja, **poucas leituras
aleatórias de bloco por consulta**. Os blocos do grafo
(`vector/<label>.<prop>.vamana`) são paginados por meio do cache de vetores, não
mantidos inteiramente.
* **[Quantização de produto (PQ)](https://medium.com/aiguys/product-quantization-k-nn-for-big-datasets-12431d764c4e)**
comprime cada vetor em um código curto (`--pq-subspaces` × `--pq-bits`): as dimensões
são divididas em subespaços, cada um agrupado independentemente com k-means, e o vetor
é armazenado como a tupla de IDs dos centroides mais próximos. Esses códigos
(`vector/<label>.<prop>.pq`) são pequenos o bastante para permanecerem **residentes**,
então a busca em feixe pontua candidatos a partir da RAM e apenas os poucos vetores
completos escolhidos são lidos do disco. Esse conjunto PQ residente é o que o pool
`cache.vectorCacheBytes` fixa.
**Embeddings graváveis — a escada de escrita vetorial (estilo
[FreshDiskANN](https://arxiv.org/abs/2105.09613)).** Um embedding indexado é um valor
gravável de primeira classe. `SET n.embedding = vecf32([…])` (e `REMOVE`) cai no delta
de escrita e fica **imediatamente visível para KNN com classificação exata**, depois
sobrevive a um flush de segmento, um merge e uma consolidação. Uma consulta mescla até
três níveis — o índice base selado, um índice por segmento selado e um **RW-index** em
memória (um Vamana mutável vivo sobre o delta de escrita) — de modo que a latência
permanece estável conforme as escritas se acumulam, em vez de crescer com a quantidade
de escritas pendentes. Uma exclusão deixa um *buraco*: o nó deixa de ser retornado, mas
permanece como waypoint de navegação até uma **consolidação de exclusão** em segundo
plano removê-lo do grafo, de modo que exclusões deixam de custar IO de consulta. E como
o grafo em disco endereça seus vizinhos pela posição no layout em vez do ID do nó,
`CALL slater.consolidate()` carrega o Vamana **por referência** — com hard link,
byte-idêntico — e reescreve apenas uma pequena coluna de IDs, incorporando escritas
vetoriais na base **sem** a reconstrução O(N·R·L) do grafo. Números medidos, com
ressalvas, estão no [relatório de desempenho](https://github.com/hikari-systems/slater/blob/HEAD/docs/PERF-REPORT.md).
## Backends de armazenamento (sistema de arquivos / S3 / GCS)
Cada arquivo de geração é aberto por meio de uma abstração **`ObjectStore`** em vez de
`std::fs` diretamente, de modo que o *mesmo* formato de bytes em disco — blocos, índices,
manifesto, ponteiro `current` — é servido inalterado a partir de qualquer backend; apenas
*de onde os bytes vêm* difere, nunca os leitores, o motor de consulta ou as verificações
de integridade. O caminho quente são leituras posicionais (`read_exact_at`), que mapeiam
um `pread` em um arquivo local e uma requisição HTTP de byte range em um object store —
o Slater nunca usa mmap, portanto o modelo explícito de leitura limitada é idêntico em
todos os lugares.
**Três backends de primeira classe**, selecionados por `dataBackend.kind`. O filesystem
é o padrão simples; **Amazon S3 e Google Cloud Storage são backends de object store
iguais e totalmente suportados** — a imagem publicada acompanha ambos compilados,
portanto cada um é apenas configuração, e uma geração construída uma vez pode ser servida
a partir de qualquer um deles (inclusive migrada `fs` → S3 → GCS) sem reconstrução.
| `dataBackend.kind` | Leitura posicional | Integridade na abertura | Credenciais |
| --- | --- | --- | --- |
| `fs` *(padrão)* | `pread` | re-hash BLAKE3 completo de cada arquivo | — |
| `s3` | HTTP `Range` GET | **SHA-256** do servidor via `HEAD` (→ re-hash BLAKE3 do corpo se ausente) | chaves de configuração, cadeia AWS ou função IAM |
| `gcs` | leitura de intervalo HTTP | **CRC32C** do servidor via `get_object` (→ re-hash BLAKE3 do corpo se ausente) | ADC / Workload Identity ou JSON de conta de serviço |
Ambos os object stores verificam a integridade a partir do **checksum que o próprio
armazenamento já calcula e mantém**, buscado como metadados do objeto: `slater-build`
envia o checksum no upload (o armazenamento valida os bytes contra ele e o guarda), e o
servidor o lê de volta na abertura e o compara ao manifesto — uma requisição de metadados
por arquivo, sem download do corpo. É uma verificação de conteúdo e idêntica em espírito
entre S3 (SHA-256) e GCS (CRC32C). Quando um objeto não carrega **nenhum** checksum
armazenado no servidor (copiado fora de banda ou enviado com um padrão diferente), o
servidor **recalcula o hash do corpo do objeto contra o BLAKE3 do manifesto** em vez de
confiar no comprimento em bytes — uma verificação de integridade solicitada nunca é
silenciosamente rebaixada para uma comparação de tamanho. Gerações publicadas pelo Slater
sempre carregam o checksum, portanto permanecem no caminho barato de metadados.
O que esta coluna verifica, em todos os backends, é que os arquivos **correspondem ao
manifesto**. Se o manifesto em si pode ser confiável é uma questão separada, e é a chave
mestra que a responde: com uma chave configurada, o manifesto carrega um MAC com chave
que o servidor verifica antes de confiar em qualquer campo (incluindo esses hashes), de
modo que um manifesto reescrito para descrever arquivos adulterados é recusado; sem uma
chave, a comparação não é autenticada por chave em nenhum ponto, e alguém que possa
escrever no diretório de dados pode reescrever um arquivo e o manifesto juntos. Consulte
[O que integridade significa em cada configuração](https://github.com/hikari-systems/slater/blob/HEAD/THREAT_MODEL.md#what-integrity-means-in-each-configuration).
A própria verificação pode ser desativada com `dataBackend.verifyIntegrity: false`, o que
a troca por uma abertura mais rápida.
### Sistema de arquivos (`fs`)
O padrão, com raiz em `dataBackend.fs.dir`. A escolha certa para a maioria das
implantações: uma geração em um SSD local (ou um mount NFS/EBS) servida somente leitura.
A integridade é um re-hash BLAKE3 completo de cada arquivo na abertura.
### Amazon S3 (`s3`)
Um bucket S3 ou compatível com S3 (AWS, MinIO, localstack). As credenciais vêm
**primeiro** da configuração (`dataBackend.s3.awsAccessKey` / `awsSecretKey`, além de
`awsSessionToken` para credenciais STS temporárias) e recorrem à cadeia AWS padrão (env
`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`, perfil compartilhado ou função de
instância/IRSA) quando deixadas vazias.```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)
Um bucket GCS, acessado pela API JSON. A autorização é nativa do GCP: por padrão,
ela resolve as Application Default Credentials — GKE Workload Identity, o servidor
de metadados do GCE ou uma chave gcloud / GOOGLE_APPLICATION_CREDENTIALS. Defina
dataBackend.gcs.credentialsPath (um arquivo de chave JSON de conta de serviço) ou
credentialsJson inline para uma chave explícita. dataBackend.gcs.endpoint aponta
para um emulador fake-gcs-server, e dataBackend.gcs.anonymous=true permite
acesso não autenticado apenas para esse emulador — nunca contra o GCS real.```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
Em todos os casos, slater-build grava a geração finalizada em --data-dir primeiro
(sua área de staging local) e adicionalmente envia para o bucket; o ponteiro
current remoto é gravado por último, então um nó servidor nunca vê uma geração
meio publicada.
Quando usar um object store (S3 ou GCS)
Recorra a s3 ou gcs quando quiser gerações em object storage durável e central
em vez de no disco de um nó — tipicamente: publicar uma vez e distribuir para
muitas réplicas de servidor sem estado e sem disco que leem todas o mesmo bucket;
desacoplar o host de build dos hosts de serviço; ou contar com a
durabilidade/versionamento/ciclo de vida do armazenamento em vez de gerenciar
volumes. O trade-off é a latência: um bloco frio é uma ida e volta de rede
(~10–50 ms) em vez de uma leitura local (~0,1 ms). O Slater esconde a maior parte
disso com o cache de blocos em memória, a leitura antecipada concorrente e o
cache de disco opcional abaixo. Se suas gerações já estão em armazenamento local
rápido e você não precisa do modelo de bucket central, fs é mais simples e mais
rápido.
Cache de blocos em disco local (segunda camada do object store)
O BlockCache em memória é deliberadamente pequeno (RSS limitado é a garantia
principal), então em um conjunto de trabalho maior que a RAM os mesmos blocos
seriam buscados novamente no object store a cada spill. Uma segunda camada de
cache opcional em SSD local resolve isso: um bloco ejetado da RAM é servido do
disco local (~0,1 ms) em vez de um novo GET no object store, sobrevivendo à
evicção em memória e reduzindo a contagem/custo de requisições ao object store —
aproximando um nó apoiado em object store do desempenho de filesystem local depois
de aquecido. É opt-in tanto para s3 quanto para gcs, habilitado definindo
dataBackend.<s3|gcs>.diskCacheBytes > 0 e um diskCacheDir gravável.
- Ele armazena em cache os bytes selados exatamente como buscados — já
comprimidos e (para gerações com
--encrypt) ainda selados com AEAD — abaixo de descriptografar/descomprimir. A camada de cache nunca detém a chave de criptografia e nunca re-criptografa, então o status em repouso é preservado de graça: uma geração criptografada chega ao disco ainda selada. - As gravações são write-behind: um miss retorna os bytes buscados à consulta imediatamente; depois, uma thread em segundo plano faz a gravação em disco e o corte LRU, então o caminho da consulta nunca bloqueia em I/O de disco. A evicção mantém o cache dentro de seu orçamento de bytes; um checksum por arquivo verificado em cada leitura autocurou um arquivo de cache corrompido para um miss (→ nova busca no object store).
diskCacheDirdeve apontar para um volume gravável real — nuncatmpfs(tmpfs é RAM e quebraria a garantia de RSS limitado). O índice em memória que o rastreia custa um pouco de RAM (~dezenas de bytes por bloco em cache), o que conta contra seu teto de RSS — dimensione o diretório ≫ o cache de blocos em memória.- O outro custo de RAM da camada é a fila write-behind, que enfileira blocos a
caminho do disco. Ela é limitada em
blockCacheBytes / 8(limitada pordiskCacheBytes) — 8 MiB no padrão — e reduz em vez de crescer, então uma varredura a frio não pode inflá-la; um bloco descartado simplesmente é buscado novamente no próximo miss. Ela não precisa de configuração: ela escala comblockCacheBytes, então a camada de disco não adiciona nenhum número novo ao orçamento de RSS além de seu índice.
Mounts
Uma réplica de leitura roda com um filesystem raiz somente leitura e um
usuário não root (appuser:1000) — tudo o que ela precisa está montado somente
leitura. Um writer (delta.enabled) precisa adicionalmente de um volume
durável e gravável para seu WAL.
| Caminho | Propósito | Observações |
|---|---|---|
/data | As gerações do grafo (<graph>/<uuid>/… + current). | Somente leitura para réplicas; produzido por slater-build. Pode viver em armazenamento remoto/de rede (ex.: NFS), então leituras não são assumidas com latências rápidas de SSD local. |
/sandbox | Overlay de configuração por ambiente + segredos. | /sandbox/config.json é mesclado profundamente sobre o config.json embutido; também contém acl.json, material PEM de TLS, o arquivo de chave em repouso. |
/tmp, /run | Área temporária (tmpfs). | Uma réplica de leitura nunca grava em disco por padrão. |
(writer) delta.walDir | O write-ahead log + segmentos delta L0, quando delta.enabled. | Gravável e um volume real e durável — nunca tmpfs (é o piso de durabilidade). Um caminho relativo resolve sob o diretório de dados; dê a um writer seu próprio volume persistente aqui. |
| (opcional) cache de disco | O cache de blocos em disco local, quando dataBackend.s3.diskCacheBytes / dataBackend.gcs.diskCacheBytes > 0. | Gravável e um volume real — não tmpfs. Usado pelos backends s3 e gcs; veja Storage backends. |
Ambiente / configuração
A configuração é carregada pelo loader em camadas padrão da casa: o config.json
embutido, depois /sandbox/config.json mesclado profundamente sobre ele, depois
overrides de ambiente KEY__sub (duplo sublinhado para aninhamento; as chaves
correspondem à configuração camelCase).
Cada botão de configuração — sua chave camelCase, o override de ambiente
KEY__sub, seu padrão e o que ele faz — está tabulado na Referência de
configuração. Os botões mais
ajustados são os orçamentos de cache (cache.*), as proteções de consulta
(query.*), os limites de conexão (server.*), o backend de armazenamento
(dataBackend.*) e a camada gravável (delta.*).
Memória residente rastreia
blockCacheBytes + vectorCacheBytes + resultCacheBytes dentro de overhead
limitado por entrada e do alocador — cada pool pesa seu próprio conteúdo (strings
e contêineres pela capacidade alocada) e ejeta para ficar abaixo do orçamento, mas
a contabilidade por entrada e o arredondamento por classe de tamanho do alocador
ficam por cima do número que você define — mais um pequeno overhead fixo (e até
degreeColumnBytes para a coluna de grau lazy, uma vez que o caminho rápido da
soma de graus count(endpoint) é exercitado). É independente do tamanho do
grafo — essa é a garantia principal, exercitada pelo teste de integração
rss_stays_bounded_under_sustained_knn_load, que mantém o crescimento de RSS
pico-vs-aquecido bem dentro dos orçamentos somados. Buffers por conexão vivem
fora dos orçamentos de cache, então a garantia só se mantém sob carga adversa
porque server.maxConnections limita quantos podem existir ao mesmo tempo.
Postura de rede
O Slater é uma alça de réplica de leitura; o controle primário de segurança de
conexão é a rede, não o binário. Vincule-o a uma interface privada, restrinja as
faixas de origem na camada de rede (security groups / NetworkPolicy) e — se ele
atender qualquer coisa além de clientes confiáveis — coloque na frente um proxy L4
limitador de conexões (HAProxy maxconn + uma stick-table por origem, ou
nftables connlimit + hashlimit). Isso vem antes de o descritor de arquivo ser
entregue ao processo, então é o limite mais robusto.
Os limites no binário acima (maxConnections, maxPreAuthConnections,
maxConnectionsPerIp, os limites diferenciais de bytes e loginTimeoutMs) são
defesa em profundidade: eles vêm ligados por padrão e generosos para serem
invisíveis a uma população legítima de clientes, mas fazem a garantia de RSS
limitado valer mesmo quando o proxy é esquecido. Veja
docs/HARDENING.md para a postura defensiva completa e
THREAT_MODEL.md / SECURITY_WORKLIST.md para o detalhe canônico.
Guarda de geração
O Slater consulta o ponteiro current de cada grafo a cada generationPollMs
(poll, não inotify — o diretório de dados pode ser armazenamento remoto/de rede
como NFS, onde eventos de mudança de filesystem não são confiáveis). Quando ele
muda:
reloadStrategy=exit(padrão): o servidor registra fatal e sai com código diferente de zero para que o orquestrador o reinicie limpo contra a nova geração.reloadStrategy=swap: o servidor abre e valida a nova geração (mesma guarda de hash de conteúdo da inicialização), faz o swap atomicamente e deixa consultas em andamento terminarem na antiga. Uma imagem nova corrompida/incompleta é recusada e a geração antiga continua servindo.
ACL
acl.json mapeia usuários para hashes de senha argon2id e permissões read /
write por grafo. Gere um hash (nunca armazene texto puro) com:```sh
slater hash-password 's3cret' # prints a $argon2id$… string for acl.json
Um `acl.json` inicial acompanha a raiz do repositório; sua estrutura é:```json
{
"users": {
"reporting": {
"passwordArgon2id": "$argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>",
"grants": {
"people": ["read"],
"products": ["read", "write"]
}
}
}
}
-
users— uma entrada por login, identificada pelo nome de utilizador. -
passwordArgon2id— a string$argon2id$…deslater hash-password(nunca em texto claro; o próprio ficheiro é JSON simples e vive em armazenamento partilhado). -
grants— listas de capacidades por grafo. Duas permissões são relevantes:read— consulta o grafo. Um grafo ausente das permissões de um utilizador fica invisível para esse utilizador.write— modifica o grafo através da camada gravável (delta.enabled): as instruçõesMERGE/SET/DELETEeCALL slater.consolidate().
Elas são independentes: uma permissão
readnão confere acesso de escrita. Ativar a camada gravável portanto não pode transformar os seus leitores existentes em escritores. Um escritor precisa de ambos —["read", "write"]— porque resolver uma chave de negócio para a escrever é uma leitura. Strings de permissão não reconhecidas são ignoradas (não concedem nada).
Monte-o como somente leitura no caminho indicado por aclPath (por omissão /config/acl.json).
O servidor recarrega-o em cada hot-swap de geração, e o carimbo ACL em repouso é
reverificado em cada recarga (consulte requireAclStamp).
Verificação de saúde
O binário slater serve também como sua própria sonda de vivacidade: slater healthcheck [host] [port] executa um handshake Bolt (não um pedido HTTP) com o servidor e
termina com 0 se negociar uma versão de protocolo, 1 caso contrário — por omissão
localhost e a porta Bolt configurada. É isto que o contentor
HEALTHCHECK executa, para que os orquestradores vejam um servidor verdadeiramente pronto para Bolt, não apenas um
socket aberto:```sh
slater healthcheck localhost 7687 # exit 0 = healthy
docker exec slater /app/slater healthcheck # inside the container
## Consulta única
Para scripting, verificações em CI e consultas rápidas, `slater query` monta a
geração atual de um grafo, executa uma única consulta Cypher somente leitura
em processo, imprime o resultado como um objeto JSON e encerra — sem servidor,
sem conexão Bolt. Ele respeita a mesma configuração do servidor (backend de
armazenamento, chave de criptografia, orçamentos de consulta):```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"]]}
Nós e relacionamentos expandem-se para seus rótulos/tipos e propriedades. Use -q
quando quiser uma saída analisável por máquina (o JSON resultante é a única coisa na
stdout); omita-o para uma execução voltada ao operador, com logs. Sem -q, um
resumo apenas com métricas é registrado após cada execução — por exemplo.```text
INFO query executed cost=2389 resultCount=10 execMs=441 limitRowCount=10
contendo o `cost` da consulta (elementos cobrados), `resultCount`, `execMs` e
`limitRowCount` (apenas quando a consulta especifica um `LIMIT`) — nunca o texto da consulta
ou qualquer valor de resultado. O status de saída é `0` em caso de sucesso, `1` em caso de
erro de análise/abertura/execução (mensagem no stderr).
## Exportar um grafo (`slater dump`)
`slater dump` exporta um grafo de um servidor **em execução** como Cypher `MERGE` de chave de
negócio — o mesmo dialeto que o `slater-build` ingere — de modo que um grafo faça a viagem de
ida e volta (dump → `slater-build` → nova geração) para migração ou backup em texto. Ao
contrário de `slater query`, ele conecta via **Bolt**, autentica e respeita ACLs por grafo,
portanto não precisa de acesso a disco no servidor. A senha é lida de `SLATER_DUMP_PASSWORD` ou
stdin (nunca de uma flag, mantendo-a fora do `ps`/histórico).```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
A chave de identidade de cada rótulo é a propriedade indexada pelo seu índice de intervalo; sobrescreva
com --key Label=prop (repetível) ou um --pk <field> global. O DDL CREATE INDEX
é emitido primeiro para que a reconstrução recrie os índices. Um nó com vários rótulos
mantém todos os rótulos — ele é emitido como MERGE (n:Ident:Other {key: v}), com
o rótulo de identidade (aquele que fornece a chave de negócio) primeiro e os demais
ordenados; o merge é feito usando apenas o rótulo de identidade como chave, portanto os rótulos
adicionais são escritos no nó sem criar outro. Rótulos, tipos de relacionamento
e chaves de propriedade que contenham caracteres especiais são colocados entre crases na
emissão, para que nomes incomuns sejam preservados fielmente na ida e volta e não possam injetar Cypher na
reconstrução. Vetores (e outros
valores sem grafia literal em Cypher) não podem ser transportados em um dump MERGE e são descartados
com um aviso no stderr. O status de saída é 0 em caso de sucesso, 1 em caso de erro.
Exemplo prático
Um passo a passo completo e executável — criar um grafo, servi-lo, conectar-se com os drivers JavaScript e Python do neo4j e gravar nele — está nas páginas Início rápido e Escrita de dados do manual, usando o grafo de exemplo incluído em docs/manual/examples/.
Desenvolvimento```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
### Backends de armazenamento de objetos são recursos opcionais do cargo
Um `cargo build` simples produz um binário **somente filesystem** — os backends `s3` e `gcs`
são controlados por recursos do cargo para que o build padrão permaneça pequeno (sem SDK
da AWS ou do Google, sem runtime assíncrono). Habilite o que precisar em **ambos** `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
Cada crate expõe funcionalidades correspondentes s3 / gcs que encaminham para
graph-format/{s3,gcs}. Solicitar um backend em tempo de execução
(dataBackend.kind=s3|gcs, ou slater-build --publish-{s3,gcs}-*) sem a sua
funcionalidade compilada falha rapidamente com um erro claro de "construído sem a
funcionalidade …". A imagem Docker publicada habilita ambas (Dockerfile
CARGO_FEATURES), então imagens pré-construídas não precisam de flags extras — isso
só importa ao compilar a partir do código-fonte. Os testes de integração também são
controlados por funcionalidade: --features s3 --test s3_minio,
--features gcs --test gcs_emulator (um fake-gcs-server), e --features gcs --test gcs_real (GCS real via ADC); cada um é ignorado a menos que suas variáveis de
ambiente SLATER_* estejam definidas.
Veja docs/PLAN.md, docs/PROGRESS.md e docs/DECISIONS.md para o design,
o registro de marcos e o log de decisões.
Desempenho
Até seis mecanismos, uma suíte de cliente único, grafos de um toy de 62 mil nós até
Wikidata 91,6M nós / 1,5B arestas. Cada mecanismo é medido isoladamente (todos
os outros contêineres parados — RSS e latência são a sua própria pegada). As tabelas
de latência abaixo foram remedidas no Slater 0.21.0 (a compilação gravável): os
grafos pequenos/médios (MeSH, EU-AI-Act) recentemente, e o grafo de 91,6M como uma
nova passagem mesma-máquina, âncora compartilhada slater-vs-Neo4j (veja essa
tabela). Os valores de memória residente são mantidos da passagem anterior (medidos
via cgroup do contêiner; o caminho de leitura é byte-idêntico com a camada gravável
ociosa). Os números dos outros mecanismos são da execução entre mecanismos já
estabelecida (suas versões/desempenho não mudaram). Todos os valores são medianas (ms)
ou pico de memória residente (MiB). Menor é melhor em todos os lugares; negrito =
melhor na linha. slater foi executado no seu backend de sistema de arquivos local
(fs); os backends S3 e GCS trocam a latência de leitura local por round-trips ao
armazenamento de objetos (mitigados pelos caches em memória e pelo nível opcional de
cache em disco local), então esses números caracterizam o mecanismo, não uma
implantação com armazenamento em rede.
| mecanismo | classe | limite de memória |
|---|---|---|
| slater | em disco, paginado | query.maxIntermediate limita o conjunto de trabalho automaticamente |
| Neo4j 5 | em disco, JVM | heap de ~2 GiB + off-heap, comprometido independentemente da consulta |
| Memgraph · FalkorDB | em memória | grafo inteiro residente em RAM |
| ArcadeDB | em memória, JVM | grafo inteiro residente; o mais pesado |
| LadybugDB | embarcado, colunar | pool de buffers manual que deve exceder a consulta |
Os três mecanismos que paginam a partir do disco — slater, Neo4j 5 e LadybugDB — carregam todos os cinco grafos. O trio em memória (Memgraph · FalkorDB · ArcadeDB) não consegue segurar o grafo de 1,5B arestas de forma alguma (ele precisa de ~64–128 GiB residentes), e o importador do ArcadeDB também não consegue terminá-lo.
Memória residente (MiB) — limitada à medida que o grafo cresce ~1.500×
Cada valor é memória de trabalho comprometida — o que o SO não pode recuperar. Todos os mecanismos exceto o slater seguram seu grafo em memória anônima comprometida (próprio heap, o page cache off-heap do Neo4j, ou um pool de buffers), então seu RSS de pico é sua pegada comprometida. Apenas o slater atende a partir do page cache do SO recuperável do seu armazenamento em disco, então seu valor é o conjunto de trabalho anônimo; o page cache do armazenamento (despejável sob pressão — o slater continua atendendo) é excluído, e mostrado como total entre parênteses para o grafo de 91,6M. Negrito = menor.
| grafo (nós / arestas) | 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 total) | ~2.900 | não-carrega | não-carrega | não-carrega | ~652 † |
slater é o menor em todas as escalas e cresce ~50× enquanto o grafo cresce ~1.500× —
sua pegada acompanha o conjunto de trabalho da consulta, não o grafo (ocioso ~16–71
MiB em todo o percurso). O trio em memória cresce ~linearmente e não consegue carregar
o grafo de 1,5B; o Neo4j compromete um heap de ~2 GiB independentemente da consulta.
(† LadybugDB apenas nas formas limitadas — suas travessias de hub / comprimento
variável / shortestPath em 1,5B arestas precisam que seu pool de leitura seja elevado
para ≥2 GiB, contra o limite automático maxIntermediate do slater.) Os histogramas
valor→contagem em tempo de compilação acrescentam memória residente insignificante —
alguns KB para uma coluna indexada de baixa cardinalidade, e zero para grafos de chave
única como o Wikidata (wikidata_id excede o limite de cardinalidade do histograma,
então nenhum é armazenado) — portanto esses valores não mudam com essa funcionalidade.
Latência (mediana em ms) — grafo cabe em RAM (MeSH, 341k / 469k)
| forma | slater | Neo4j 5 | Memgraph | FalkorDB | ArcadeDB | LadybugDB |
|---|---|---|---|---|---|---|
| count(*) todos os nós | 0,41 | 15,0 | 23,8 | 16,4 | 82,0 | 2,2 |
| contagem de rótulos | 0,42 | 4,2 | 20,7 | 1,1 | 4,4 | 4,3 |
| busca pontual indexada | 0,43 | 3,9 | 0,48 | 0,48 | 0,65 | 8,8 |
| contagem idx-eq | 0,42 | 4,9 | 5,0 | 2,0 | 381 | 2,5 |
| 1 salto (âncora indexada) | 1,28 | 5,8 | 1,21 | 4,1 | 390 | 4,9 |
| 2 saltos (sem âncora) | 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 |
varredura completa CONTAINS | 0,43 | 5,4 | 24,1 | 1,7 | 16,3 | 4,1 |
slater domina as formas de metadados / índice / varredura (count, rótulo, idx-eq, varredura — ~0,4 ms, 10–200× os mecanismos de serviço), a busca pontual indexada (0,43 ms, agora superando por pouco os 0,48 ms do par em memória), o multi-salto sem âncora (2 saltos em 1,40 ms via a varredura por tipo de relacionamento, o mais rápido do campo), e — via um histograma valor→contagem em tempo de compilação na chave de agrupamento indexada — o group-by / count(DISTINCT) do rótulo inteiro (0,45 ms, à frente dos 5,3 ms colunares do LadybugDB). Os servidores em memória mantêm apenas o 1 salto bruto (Memgraph 1,21 ms contra 1,28 ms do slater). (pole 62k/106k parece o mesmo: slater o único mais rápido em count/varredura ~0,4 ms, ~1,3–2,6 ms em saltos.)
Latência (mediana em ms) — vetores (kNN EU-AI-Act, 15k × 1024-dimensões)
| 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 responde ao kNN com uma varredura exata de força bruta (esses conjuntos estão abaixo do seu limite ANN de 50 mil vetores) onde os outros usam um HNSW residente aproximado — portanto os resultados do slater são exatos (recall 1,0). Um kernel de distância SIMD + uma matriz de vetores residente e pré-normalizada levou Concept de ~23 → ~2,9 ms e Chunk de ~10 → ~2,4 ms, então o slater agora supera Neo4j e LadybugDB e fica a ~1,4× do Memgraph, atrás apenas do FalkorDB — e exato.
Escada de gravação de vetores — inserir / atualizar / excluir sem reconstrução
As tabelas acima são comparações de leitura entre mecanismos. O caminho de
gravação de vetores (a escada de gravação estilo
FreshDiskANN sobre a base Vamana estática) não tem
contraparte entre mecanismos — nenhum outro mecanismo aqui faz ANN gravável nativo em
disco — portanto os números abaixo são benchmarks de componente de mecanismo único
sobre uma fixture sintética semelhante a embeddings (uma variedade de posto baixo,
dimensão 768, normas desiguais), comprometidos em
crates/slater/benches/ e documentados integralmente — com a
metodologia e todas as ressalvas — em docs/PERF-REPORT.md. O
recall é sempre medido contra uma força bruta exata sobre o conjunto vivo, nunca um
índice contra outro. A escala aqui é representativa e extrapolada apenas onde a métrica
é linear no tamanho.
| propriedade | medido | por que importa |
|---|---|---|
| Latência KNN vs gravações pendentes | índice RW ~1,5–2 ms, estável até 50 mil pendentes; a sobreposição pré-índice com força bruta 1,9 → 115 ms (linear no delta) — 61× em 50 mil | a latência de consulta não degrada conforme as gravações se acumulam entre consolidações |
| Inserção de embedding | ~1,5–2 ms por vetor no índice vivo | uma gravação fica visível ao KNN imediatamente; o orçamento de reconstrução delta é ≈ 2 ms × o limite do delta |
| IO de exclusão com recall equivalente | 2,9× menos buscas de nó por consulta com 67 % excluídos, 5,2× com 80 % (recall ≥ 0,90) | um grafo consolidado não paga imposto de leitura por vetores excluídos |
| Consolidação, permutação pura | O(1) — o .vamana é hard link byte-idêntico, apenas a coluna de id é reescrita | incorporar gravações de vetores na base ignora a reconstrução O(N·R·L) |
| Recall ao longo da escada | consolidado ≥ base para cosseno, L2 e produto interno | a escada de gravação preserva o recall em cada degrau |
A única métrica que merece o quadro dedicado de desempenho é a vazão de reescrita da consolidação em caminho lento — quando uma consolidação carrega exclusões ou novos vetores em vez de uma permutação pura, é uma recompressão sequencial limitada por zstd de thread única e disco local, portanto o MiB/s absoluto depende do ambiente (o relatório mostra a forma e explica a faixa ambiental).
Latência (mediana em ms) — grafo ≫ RAM (Wikidata 91,6M / 1,5B)
Os mecanismos em memória (Memgraph / FalkorDB / ArcadeDB) não conseguem carregar
este grafo de forma alguma (~64–128 GiB residentes). Apenas slater e Neo4j 5
conseguem. Esta é uma passagem nova na mesma máquina, no mesmo dia, contra um
conjunto fixo de âncoras compartilhadas — cada consulta atinge os nós idênticos em
ambos os mecanismos, então o confronto direto é justo (um pool comum de wikidata_id
de âncoras de grau moderado; veja a nota abaixo sobre por que isso importa). slater é
mostrado em ambos os fanouts (query.maxFanout 1 = padrão de vazão, 8 = o dial de
latência que sobrepõe leituras de blocos frios). Negrito = melhor na linha.
| forma | slater (fan 1) | slater (fan 8) | Neo4j 5 |
|---|---|---|---|
| count(*) todos os nós | 0,41 | 0,41 | 3606 |
| busca pontual (indexada) | 0,72 | 0,49 | 6,3 |
| grau (contagem de 1 salto) | 0,43 | 0,44 | 6,0 |
| vizinhos de 1 salto | 9,8 | 4,5 | 10,1 |
| 2 saltos | 37 | 23 | 34,5 |
| 3 saltos | 32 | 25 | 74 |
comprimento variável *1..2 distinto | 985 | 1056 | 47 |
O quadro real: slater domina as formas de metadados / índice — count(*) é
servido por metadados (0,41 ms vs a varredura de disco de 3,6 s do Neo4j, ~8.800×),
e busca pontual / grau / 3 saltos rodam ~2–10× mais rápido — está empatado com o
Neo4j em 1–2 saltos (o fanout 8 se adianta em leituras frias), mas perde
comprimento variável *1..2 distinto de forma decisiva (≈1 s vs os 47 ms do Neo4j):
a expansão distinta de comprimento variável do slater é materialmente mais lenta aqui,
uma fraqueza real que merece investigação própria. Tudo isso com algumas centenas de MB
de RSS contra o heap comprometido de ~2 GiB do Neo4j.
Sobre as âncoras. Esses números de travessia dependem muito de quais nós você começa — um nó a um link de um mega-hub do Wikidata ("human", "country") tem uma vizinhança de 2 saltos com milhões de elementos, então o custo de comprimento variável/saltos oscila por ordens de magnitude com a escolha da âncora. A edição anterior desta tabela amostrava os próprios "primeiros N por varredura" de cada mecanismo, o que não é estável nem comparável; esta passagem fixa um único conjunto de âncoras compartilhado e limitado por grau para ambos os mecanismos. (shortestPath é omitido desta passagem — entre duas âncoras arbitrárias ele depende da existência de caminho e tem variância alta demais para uma mediana significativa.)
Multi-salto count(*) — memória desacoplada do tamanho do resultado
O RETURN count(*) multi-salto sem limite conta durante a expansão em vez de
materializar as linhas correspondentes. Mesmas âncoras de hub no grafo de 91,6M,
maxIntermediate=20M:
| 3-salto count(*) @ 91,6M | fanout=1 | fanout=8 |
|---|---|---|
| latência / conjunto de trabalho de pico | 554 ms / 0,66 GiB | 298 ms / 1,9 GiB |
A contagem mantém O(1) linhas. A cobrança não muda, então uma contagem de mega-hub
ainda dispara maxIntermediate em computação (leituras de adjacência), limitada como
antes.
Paralelismo por consulta (maxFanout)
Elevar query.maxFanout sobrepõe as leituras de blocos frias e limitadas por IO de
uma consulta entre núcleos — ajuda formas limitadas por disco com grande conjunto de
trabalho frio e é estável em formas quentes. No grafo de 1,5B: shortestPath ≤6
918 → 608 ms (1,5×, maior busca 6.269 → 2.350 ms, 2,7×); contagem de 3 saltos
547 → 298 ms. maxFanout=1 é o padrão (orientado a vazão); 8 é o dial de
latência, com mais memória de trabalho transitória.
Onde o slater vence / fica atrás
| dimensão | slater | melhor do campo | veredito |
|---|---|---|---|
| memória residente, qualquer escala | 11–584 MiB (62k → 91,6M) | em memória 1,5–2,7 GiB; não carrega 1,5B | slater |
| count / metadados / varredura | ~0,4 ms | mecanismos de serviço 5–80 ms | slater (10–200×) |
| busca pontual indexada | 0,43 ms (MeSH) | Memgraph · FalkorDB 0,48 ms | slater (supera por pouco o par em memória) |
| multi-salto sem âncora (linhas) | 1,40 ms (2 saltos MeSH) | Neo4j 5,6 ms | slater (varredura por tipo de relacionamento) |
| agregação (group-by / DISTINCT) | 0,45 ms | LadybugDB 5 ms (colunar) | slater (histograma em tempo de compilação) |
| kNN | 2,4–2,9 ms (exato) | FalkorDB 1,2 ms (HNSW) | supera Neo4j/Ladybug; ~1,4× atrás do Memgraph; exato |
| 91,6M metadados / ponto / grau / 3 saltos | 0,4–32 ms | Neo4j 6–3.600 ms | slater (2–8.800×) |
| 91,6M 1–2 saltos | 4,5–23 ms (fan 8) | Neo4j 10–35 ms | ~empatado |
91,6M comprimento variável *1..2 distinto | ~1 s | Neo4j 47 ms | Neo4j (um ponto fraco real do slater) |
count(*) multi-salto em escala | 0,3–0,6 GiB | mecanismos em memória materializam o conjunto de linhas | slater, limitado |
Tabelas completas por mecanismo (pole, MeSH, EU-AI-Act + o dial RAM↔latência
blockCacheBytes, Wikidata 1M e 91,6M) estão em
perf/cross-engine-hs/README.md; a nova passagem
apenas com slater (ambos os fanouts, todos os conjuntos de dados) está em
perf/PERF_CURRENT_STATUS.md.
Concorrência e brown-out (teste de carga)
Os benchmarks acima são de cliente único. O eixo complementar — comportamento sob
muitos clientes concorrentes — tem seu próprio harness,
perf/loadtest/: um driver Locust sobre Bolt mais um coordenador que
aumenta a carga, lê CALL slater.diagnostics(), encontra o joelho de capacidade e
nomeia o limitador (método completo em docs/LOAD-TESTING.md).
Destaques de uma execução com cache de 256 MiB no grafo Wikidata-1M (uma máquina de 16
núcleos):
| resultado | medição |
|---|---|
| Mantém 1000 clientes concorrentes, zero falhas | a vazão atinge o pico de ~2,5k rps; o joelho de latência se instala em torno de 750 clientes (p99 51 → 750 ms) — fila sob contenção de núcleos, não um limite rígido (execução única, WSL2) |
| Cache de blocos limitado e eficaz | 100 % de taxa de acerto, 0 despejos, 50 MB residentes para um conjunto de trabalho que cabe no cache |
| RSS mantido sob carga sustentada | o alocador jemalloc mantém o RSS em ~0,6 GB em uma rampa wiki_cache_churn de 100→500 clientes — limitado por cache e estável, com nenhum ajuste MALLOC_* (o antigo MALLOC_ARENA_MAX=2 + limite de corte está aposentado); sua purga em segundo plano também devolve o pico pós-explosão em vez de deixá-lo fixado |
| Memória agregada limitada | a query.maxIntermediateGlobal em todo o servidor + expansão com cobrança por adjacência seguram a inundação de 2 saltos wiki_budget em 1000 clientes sem OOM (RSS ~0,6 GB; a proteção descarta ~60 % das consultas de hub como erros de orçamento repetíveis) |
Ambos os problemas de memória que o teste de carga revelou agora estão fechados; todos registrados no doc de teste de carga.
Licença
Licenciado sob a Licença Apache, Versão 2.0. Consulte LICENSE para o
texto completo e NOTICE para atribuição. A menos que você declare
explicitamente o contrário, qualquer contribuição intencionalmente submetida para
inclusão neste trabalho, conforme definido na licença Apache 2.0, será licenciada como
acima, sem termos ou condições adicionais.
SPDX-License-Identifier: Apache-2.0