BoltexNif

View Source

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

  • Full Bolt v5 protocol (via neo4rs's unstable-v1 feature bundle).
  • No Rust toolchain required — precompiled NIFs ship for macOS, Linux (glibc), and Windows via rustler_precompiled.
  • Async on the inside, sync on the outside: every NIF call returns a ref immediately and the work runs on a shared Tokio runtime; the Elixir API you see is plain synchronous — {:ok, ...} / {:error, ...} with proper timeouts.
  • First-class types: nodes, relationships, paths, points, temporals, durations, bytes, nested maps/lists — all marshalled to idiomatic Elixir structs.
  • Production essentials: connection pool, explicit transactions, lazy row streaming, result summary (counters + notifications + bookmarks), TLS, user impersonation, and structured Neo4jError with retryable classification.

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:

OSArchitectures
macOSaarch64 (Apple silicon), x86_64 (Intel)
Linux (glibc)x86_64, aarch64
Windowsx86_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

  • Elixir 1.19 / OTP 28 (the regression-tested matrix). NIF 2.16 binaries are published too, so older Elixir/OTP combos with NIF ≥ 2.16 should also work, they just aren't part of the test matrix.
  • A reachable Neo4j 5.x instance (Community or Enterprise). The repo ships docker-compose.yml (local dev) and docker-compose.production.yml (Coolify-ready) — see Local development.

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{}:

  • bookmarkString.t() | nil — Bolt v5 bookmark for start_txn_as.

  • available_after_ms, consumed_after_ms — server-side timings.
  • query_type"read" | "write" | "read_write" | "schema_write".

  • db — database the query ran against.
  • stats%BoltexNif.Summary.Counters{} with nodes_created, relationships_created, properties_set, labels_added, indexes_added, constraints_added, their *_deleted/*_removed counterparts, and system_updates.
  • notifications[%BoltexNif.Notification{}] (code, title, severity, category, source InputPosition).

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)
  • {:ok, row} — one row returned.
  • :done — stream exhausted (connection is returned to the pool).
  • {:error, :closed}stream_next called after :done or stream_close/1.
  • BoltexNif.stream_close(stream) — drop early without draining; :ok even if already closed.

Type mapping

Parameters (Elixir → Bolt)

Elixir valueBecomes (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 atomsMap
%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:

  • Bolt String → UTF-8 binary().
  • Bolt Bytes{:bytes, binary()}.
  • Bolt Date/LocalTime/LocalDateTime → stdlib %Date{} / %Time{} / %NaiveDateTime{}.
  • Bolt DateTime (FixedOffset) → %BoltexNif.DateTime{} (keeps offset without needing a TZ database).
  • Bolt DateTimeZoneId%BoltexNif.DateTimeZoneId{}.
  • Bolt Time (with offset) → %BoltexNif.Time{}.
  • Bolt Duration%BoltexNif.Duration{} (Bolt keeps months/days/ seconds/nanos separately; we never collapse them).
  • Bolt Point2D%BoltexNif.Point{z: nil}; Point3Dz: float().
  • Bolt Node%BoltexNif.Node{id, labels, properties}.
  • Bolt Relationship%BoltexNif.Relationship{id, start_node_id, end_node_id, type, properties}.
  • Bolt Path%BoltexNif.Path{nodes, relationships, indices} where relationships is a list of %BoltexNif.UnboundRelationship{}.

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

  • The underlying neo4rs pool is bounded by :max_connections (default 16). All BoltexNif functions are safe to call concurrently from any number of processes — they queue up on the Rust-side pool transparently.
  • Each call returns a fresh ref and waits on a single Erlang message ({ref, result}), so it obeys :timeout cleanly even while queued.
  • A request that times out on the Elixir side only stops waiting for the result; the Rust task finishes anyway and its reply is dropped. Keep :timeout generous when you know you might be behind a deep queue.
  • Measured throughput against a remote Coolify Neo4j (≈ 430 ms RTT): ~9 q/s per connection, 50× pool over-subscription handled without losses. Against a local docker compose up -d Neo4j: order of magnitude higher.

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:

  • :live — touches Neo4j. Excluded when NEO4J_URI isn't set.
  • :stress — opt-in, long-running concurrency tests. Always excluded by default.
# 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

  • Phase 1 (done): scaffolding, primitives + graph/temporal/spatial types, auto-commit run/execute.
  • Phase 2 (done): transactions, streaming, ResultSummary.
  • Phase 3 (done): Bolt v5 bookmarks, TLS, impersonation, structured Neo4j errors.
  • Phase 4 (done): precompiled NIFs via rustler_precompiled, Hex metadata, GitHub Actions release pipeline.
  • Future:
    • element_id on Nodes/Relationships (requires decoding via the Bolt v5 serde path, not the classic types/* tree).
    • Optional neo4rs features: json (transparent serde_json::Value), uuid (Ecto.UUIDString).
    • Per-stream fetch_size override, streaming within a transaction.
    • :telemetry hooks (query start/stop, pool checkout, tx commit).
    • musl (Alpine) precompiled targets once a working Cross.toml is in place.

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-Elixir.BoltexNif.Native.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.