BoltexNif

Elixir driver for Neo4j, implemented as a Rustler-powered NIF around the official Rust driver neo4rs.

Table of contents

Installation

Add to mix.exs and run mix deps.get — that's it:

def deps do
  [
    {:boltex_nif, "~> 0.1"}
  ]
end

No Rust toolchain required.boltex_nif ships precompiled NIFs for:

OS Architectures
macOS aarch64 (Apple silicon), x86_64 (Intel)
Linux (glibc) x86_64, aarch64
Windows x86_64 (MSVC)

For unsupported targets (musl/Alpine, 32-bit ARM, RISC-V, …) or to force a local build, set FORCE_BOLTEX_BUILD=1 and add {:rustler, "~> 0.37"} to your own deps:

FORCE_BOLTEX_BUILD=1 mix deps.compile boltex_nif

Building from source needs Rust ≥ 1.81.

Runtime requirements

Quick start

{:ok, graph} =
  BoltexNif.connect(
    uri: "bolt://localhost:7687",
    user: "neo4j",
    password: "boltex_nif_pass"
  )

:ok = BoltexNif.run(graph, "MERGE (:Greeter {name:$n})", %{"n" => "Ada"})

{:ok, rows} =
  BoltexNif.execute(graph, "MATCH (g:Greeter) RETURN g.name AS name")

Enum.map(rows, & &1["name"])
#=> ["Ada"]

Connecting

BoltexNif.connect/1 takes a keyword list or a map:

{:ok, graph} =
  BoltexNif.connect(
    uri: "bolt://localhost:7687",     # required — bolt:// or neo4j:// (routing)
    user: "neo4j",                    # required
    password: "secret",               # required
    db: "neo4j",                      # optional — default database
    fetch_size: 500,                  # optional — rows per pull (driver default 200)
    max_connections: 16,              # optional — pool size (driver default 16)
    impersonate_user: "alice",        # optional — Bolt v5 impersonation
    tls: :skip_validation,            # optional — see below
    timeout: 15_000                   # optional — connect handshake timeout (ms)
  )

TLS

tls: nil                                  # default — honors scheme (neo4j+s://, bolt+ssc://)
tls: {:ca, "/etc/ssl/neo4j-ca.pem"}       # validate server cert against the CA
tls: {:mutual, ca: "/etc/ssl/ca.pem", cert: "/etc/ssl/client.pem", key: "/etc/ssl/client.key"}
tls: :skip_validation                     # accept anything — DO NOT use in prod

The returned graph is an opaque reference() you pass to every query function. It can be safely shared across processes — internal connection pooling is handled by the Rust layer.

Running queries

Three flavors, increasing in what they return:

run/4 — fire-and-forget

:ok = BoltexNif.run(graph, "CREATE (:Foo {i:$i})", %{"i" => 1})
# or with an options keyword:
:ok = BoltexNif.run(graph, "CREATE (:Foo)", nil, timeout: 30_000)

run_with_summary/4 — write stats without rows

{:ok, %BoltexNif.Summary{stats: stats, query_type: type}} =
  BoltexNif.run_with_summary(graph, "CREATE (:Foo {i:1}), (:Foo {i:2})")

stats.nodes_created       #=> 2
stats.properties_set      #=> 2
type                      #=> "write" (or "read" / "read_write" / "schema_write")

Full fields on %BoltexNif.Summary{}:

execute/4 — collect all rows

{:ok, rows} =
  BoltexNif.execute(
    graph,
    "MATCH (p:Person {age: $age}) RETURN p.name AS name, p.age AS age ORDER BY name",
    %{"age" => 30},
    timeout: 60_000
  )

rows
#=> [%{"name" => "Ada", "age" => 30}, %{"name" => "Grace", "age" => 30}]

Rows are plain maps keyed by the AS alias you declare in the Cypher.

Transactions

Imperative — full control over commit/rollback

{:ok, txn} = BoltexNif.begin_transaction(graph)

{:ok, _summary} = BoltexNif.txn_run(txn, "CREATE (:T {x:1})")
{:ok, rows}    = BoltexNif.txn_execute(txn, "MATCH (t:T) RETURN t")

# Either:
:ok                        = BoltexNif.rollback(txn)
# or (Bolt v5 returns the bookmark, otherwise just :ok):
{:ok, bookmark_or_nil}     = BoltexNif.commit(txn)

Declarative — transaction/3

Commits on {:ok, value}, rolls back on {:error, _}, re-raises on exceptions (rollback first):

{:ok, count} =
  BoltexNif.transaction(graph, fn txn ->
    {:ok, _} = BoltexNif.txn_run(txn, "CREATE (:T {x:1})")
    {:ok, rows} = BoltexNif.txn_execute(txn, "MATCH (t:T) RETURN count(t) AS c")
    {:ok, rows |> hd() |> Map.get("c")}
  end)

Streaming

For result sets bigger than memory, stream row by row:

{:ok, stream} =
  BoltexNif.stream_start(graph, "MATCH (n) RETURN n LIMIT 100_000")

Stream.repeatedly(fn -> BoltexNif.stream_next(stream) end)
|> Enum.take_while(&(&1 != :done))
|> Enum.each(fn {:ok, row} -> process(row) end)

Type mapping

Parameters (Elixir → Bolt)

Elixir value Becomes (Bolt)
nilNull
true / falseBoolean
integer()Integer (i64)
float()Float (f64)
binary() (UTF-8) String
{:bytes, binary()}Bytes
list()List
map() — keys must be strings or atoms Map
%Date{}Date
%Time{}LocalTime
%NaiveDateTime{}LocalDateTime
%DateTime{}DateTime
%BoltexNif.Time{time: %Time{}, offset_seconds}Time
%BoltexNif.DateTime{naive: %NaiveDateTime{}, offset_seconds}DateTime
%BoltexNif.DateTimeZoneId{naive, tz_id}DateTimeZoneId
%BoltexNif.Duration{months, days, seconds, nanoseconds}Duration
%BoltexNif.Point{srid, x, y, z \\ nil}Point2D/3D
%BoltexNif.Node{id, labels, properties}Node
%BoltexNif.Relationship{...}Relationship
%BoltexNif.UnboundRelationship{id, type, properties}UnboundRel

Results (Bolt → Elixir)

Symmetric to the param table. Highlights:

Error handling

Every {:error, _} response is one of:

{:error, {:neo4j, %BoltexNif.Neo4jError{code: code, message: msg, kind: kind}}}
{:error, {:invalid_config, msg}}
{:error, {:io, msg}}
{:error, {:deserialization, msg}}
{:error, {:unexpected_type, msg}}
{:error, {:unexpected, msg}}
{:error, {:argument, msg}}
{:error, :timeout}          # NIF didn't answer within the caller's timeout

kind on a Neo4j error classifies the failure for retry decisions. One of:

:authentication, :authorization_expired, :token_expired, :other_security, :session_expired, :fatal_discovery, :transaction_terminated, :protocol_violation, :client_other, :client_unknown, :transient, :database, :unknown.

case BoltexNif.execute(graph, cypher, params) do
  {:ok, rows} -> rows
  {:error, {:neo4j, err}} ->
    if BoltexNif.Neo4jError.retryable?(err), do: retry(), else: raise("boom: #{err.message}")
  {:error, :timeout} -> retry()
end

Concurrency & the connection pool

See phoenix_neo4j/test/phoenix_neo4j/neo4j_stress_test.exs for the :stress suite (parallel reads/writes, transaction interleaving, streaming concurrency, pool saturation).

Using it from Phoenix

The repo includes phoenix_neo4j/ — a minimal Phoenix 1.8 app that wires boltex_nif into a supervision tree, exposes the pool, and serves a demo /neo4j page (list/create/delete :Greeter nodes).

Core pattern in phoenix_neo4j/lib/phoenix_neo4j/neo4j.ex:

defmodule MyApp.Neo4j do
  use GenServer
  @key {__MODULE__, :graph}

  def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)

  def graph, do: :persistent_term.get(@key)

  def run(cypher, params \\ nil, opts \\ []),
    do: BoltexNif.run(graph(), cypher, params, opts)

  def execute(cypher, params \\ nil, opts \\ []),
    do: BoltexNif.execute(graph(), cypher, params, opts)

  @impl true
  def init(opts) do
    {:ok, graph} = BoltexNif.connect(opts)
    :persistent_term.put(@key, graph)
    {:ok, %{graph: graph}}
  end
end

Add to your Application:

children = [
  # …,
  {MyApp.Neo4j, uri: System.fetch_env!("NEO4J_URI"),
                user: System.fetch_env!("NEO4J_USER"),
                password: System.fetch_env!("NEO4J_PASSWORD"),
                max_connections: 16}
]

Callers just use MyApp.Neo4j.execute/2 — no GenServer bottleneck, pool is handled in Rust.

To run the demo:

docker compose up -d
cd phoenix_neo4j
mix deps.get
NEO4J_URI=bolt://localhost:7687 NEO4J_USER=neo4j NEO4J_PASSWORD=boltex_nif_pass \
  mix phx.server
# visit http://localhost:4000/neo4j

Local development

Two compose files ship with the repo:

docker-compose.yml — local dev

Neo4j 5.26 Community, bound to localhost:7687:

docker compose up -d      # boot
docker compose down -v    # tear down and drop volumes

Default creds: neo4j / boltex_nif_pass.

docker-compose.production.yml — Coolify-ready

Production-oriented: APOC auto-install, query logging, healthcheck via cypher-shell, memory & ulimit tuning, opt-in backup sidecar using APOC export. Drop into Coolify's "Docker Compose" resource and pass the env vars from .env.production.example.

Automatic SERVICE_FQDN_NEO4J substitution means you only need to set CFG_NEO4J_PASSWORD — the rest has sensible defaults. See comments at the top of the file for the TLS-for-Bolt block and the :port gotchas around Cloudflare (Bolt TCP needs DNS-only, not proxied).

Testing

The test suite is live-by-default — it will refuse to run the DB-touching tests unless NEO4J_URI is set. Two tag tiers:

# Library tests (14 cases):
NEO4J_URI=bolt://localhost:7687 NEO4J_USER=neo4j NEO4J_PASSWORD=boltex_nif_pass \
  mix test --include live

# Phoenix demo tests (48 cases):
cd phoenix_neo4j
NEO4J_URI=bolt://localhost:7687 NEO4J_USER=neo4j NEO4J_PASSWORD=boltex_nif_pass \
  mix test --include live

# Full concurrency/stress suite (add --only stress to isolate):
cd phoenix_neo4j
NEO4J_URI=bolt://localhost:7687 NEO4J_USER=neo4j NEO4J_PASSWORD=boltex_nif_pass \
  STRESS_SCALE=1.0 \
  mix test --include live --include stress

One-shot smoke

scripts/smoke.sh runs an end-to-end check: Phoenix HTTP endpoints (if a server is up at $PHX_URL), the BoltexNif type / transaction / streaming probes in scripts/smoke.exs, and the full mix test --include live.

# Requires Neo4j reachable at $NEO4J_URI (defaults baked in for Coolify).
./scripts/smoke.sh
# or isolate phases:
SKIP_PHX=1 ./scripts/smoke.sh        # only library
SKIP_MIX_TEST=1 ./scripts/smoke.sh   # library checks + phoenix HTTP

Roadmap

Releasing new versions (maintainers)

.github/workflows/release.yml runs only on tag pushes (v*). It builds a matrix of 5 targets × 2 NIF versions (10 artifacts) and uploads them to a draft GitHub Release. After publishing the draft, run:

mix rustler_precompiled.download BoltexNif.Native --all --ignore-unavailable --print
git add checksum-boltex_nif-X.Y.Z.exs
git commit -m "chore(release): checksum for vX.Y.Z"
git push
mix hex.publish

Full step-by-step in RELEASING.md.

License

MIT — see LICENSE.