nebula_graph_ex
An Elixir client for NebulaGraph, a distributed open-source graph database built for massive-scale graph data.
nebula_graph_ex handles the full lifecycle of communicating with NebulaGraph from Elixir: opening and pooling TCP connections, authenticating sessions, encoding and executing nGQL statements, and decoding the response into idiomatic Elixir values. Your application code works entirely with native Elixir types — integers, binaries, structs — and never touches the wire protocol directly.
Key capabilities:
- Connection pooling with automatic reconnection and back-off
- Complete value decoding — vertices, edges, paths, temporal types, geography, lists, maps, sets, and all primitive types
- Parameterised queries that encode values as typed Thrift structs, keeping nGQL strings free of interpolation
- TLS/SSL support for encrypted connections
:telemetryevents on every query for metrics and tracing- Per-query option overrides for space switching, timeouts, and row transformation
Prerequisites
- Elixir 1.15 or later
- A running NebulaGraph 3.x instance (see Running locally for a Docker setup)
Installation
Add nebula_graph_ex to your dependencies:
# mix.exs
defp deps do
[
{:nebula_graph_ex, "~> 0.1"}
]
endSetup
There are two ways to set up the library: using the generator (recommended) or manually.
Option 1: Generator (recommended)
Run the Mix task to wire up the library in one step:
mix nebula_graph_ex.gen.graph MyApp.GraphThe generator does three things:
-
Creates
lib/my_app/graph.exwith the graph module -
Inserts a default config block into
config/config.exs -
Injects the module as the first child in
lib/my_app/application.ex
It then prints a config/runtime.exs snippet for secrets, which you add manually.
The generator is idempotent — re-running it on an already-configured app skips each step that is already in place without overwriting anything.
Option 2: Manual setup
The generator performs three steps. Do them by hand if you prefer not to use it.
1. Create the graph module
Create a dedicated module for your graph connection. The module name doubles as the registered pool name — no PIDs to pass around.
# lib/my_app/graph.ex
defmodule MyApp.Graph do
use NebulaGraphEx.Graph, otp_app: :my_app
end2. Configure the connection
Static options (hostname, pool size, space) belong in config/config.exs:
# config/config.exs
config :my_app, MyApp.Graph,
hostname: "localhost",
port: 9669,
username: "root",
space: "my_graph",
pool_size: 10
Secrets and environment-specific values belong in config/runtime.exs, where they are resolved at boot rather than embedded at compile time:
# config/runtime.exs
import Config
config :my_app, MyApp.Graph,
hostname: System.get_env("NEBULA_HOST", "localhost"),
password: fn -> System.fetch_env!("NEBULA_PASS") end
Passing :password as a zero-arity function ensures the value is never captured in a compiled module or logged in a crash report.
3. Start the pool
Add the module to your application's supervision tree:
# lib/my_app/application.ex
def start(_type, _args) do
children = [MyApp.Graph]
Supervisor.start_link(children, strategy: :one_for_one)
end
The pool starts, authenticates, and is ready to accept queries before start/2 returns.
Status checks
To verify that the pool process is running and can execute a simple query:
{:ok, status} = MyApp.Graph.status()
status.connected?
#=> true
status.queryable?
#=> true
Under the hood, status/0 performs an active health check by running
RETURN 1 AS status.
If you want to call it through the generic API:
{:ok, status} = NebulaGraphEx.Graph.status(MyApp.Graph)If the pool is not running, you get:
{:error,
%{
error: :not_running,
error_details: %{reason: :not_running, message: "pool process is not running"},
connected?: false,
queryable?: false
}}
If the pool is running but the probe query fails, status/0 also returns the
underlying error and normalized details so callers can surface a useful message:
{:error, status} = MyApp.Graph.status(probe_statement: "THIS IS NOT VALID NGQL")
status.error_details
#=> %{code: :e_syntax_error, message: "...", category: :query, statement: "THIS IS NOT VALID NGQL"}If you only want to know whether the pool process exists without probing the database, disable the query probe:
{:ok, status} = MyApp.Graph.status(probe_query: false)Querying
alias NebulaGraphEx.{Record, ResultSet}
{:ok, rs} = MyApp.Graph.query("MATCH (v:Player) RETURN v.name, v.age LIMIT 5")
rs |> ResultSet.count() #=> 5
rs |> ResultSet.columns() #=> ["v.name", "v.age"]
rs |> ResultSet.to_maps() #=> [%{"v.name" => "Tim Duncan", "v.age" => 42}, ...]
Use query!/3 when you want the result set directly and prefer an exception over a pattern-match on error:
MyApp.Graph.query!("RETURN 1+1 AS n")
|> ResultSet.first!()
|> Record.get!("n")
#=> 2Parameterised queries
Pass user-supplied values as parameters, never by interpolating them into the statement string. Parameters are encoded as typed Thrift values — there is no string substitution, so injection is not possible.
MyApp.Graph.query(
"MATCH (v:Player{name: $name}) RETURN v.age AS age",
%{"name" => "Tim Duncan"}
)Working with graph types
Vertices, edges, and paths are decoded into typed Elixir structs automatically:
{:ok, rs} = MyApp.Graph.query("MATCH (v:Player) RETURN v LIMIT 1")
rs
|> ResultSet.first!()
|> Record.get!("v")
#=> %NebulaGraphEx.Types.Vertex{
# vid: "player100",
# tags: [%NebulaGraphEx.Types.Tag{name: "Player", props: %{"name" => "Tim Duncan", "age" => 42}}]
# }
rs
|> ResultSet.first!()
|> Record.get!("v")
|> NebulaGraphEx.Types.Vertex.prop("Player", "name")
#=> "Tim Duncan"See the Type mapping table for a full list of how NebulaGraph types map to Elixir terms.
Per-query options
Any pool-level option can be overridden for a single call:
MyApp.Graph.query(stmt, %{},
space: "other_space", # switch graph space for this query only
timeout: 60_000, # override execution timeout (ms)
decode_mapper: &Record.to_map/1 # transform each row at decode time
)Multiple pools
When your application needs connections to more than one NebulaGraph cluster or space, create a module per connection — either with the generator or manually, following the same steps as the single-pool setup.
Using the generator:
mix nebula_graph_ex.gen.graph MyApp.PrimaryGraph
mix nebula_graph_ex.gen.graph MyApp.AnalyticsGraphEach module reads its own config key independently:
# config/runtime.exs
config :my_app, MyApp.PrimaryGraph,
hostname: System.fetch_env!("NEBULA_PRIMARY_HOST"),
space: "primary"
config :my_app, MyApp.AnalyticsGraph,
hostname: System.fetch_env!("NEBULA_ANALYTICS_HOST"),
space: "analytics"Add all modules to the supervision tree:
children = [MyApp.PrimaryGraph, MyApp.AnalyticsGraph]Configuration reference
| Option | Default | Description |
|---|---|---|
:hostname | "localhost" | NebulaGraph graphd host |
:port | 9669 | NebulaGraph graphd port |
:hosts | nil |
Multi-host list [{"h1", 9669}, {"h2", 9669}] — overrides :hostname/:port |
:load_balancing | :round_robin |
Host selection when :hosts is set: :round_robin or :random |
:username | "root" | Authentication username |
:password | "nebula" | Authentication password — accepts a string or a zero-arity function |
:space | nil |
Default graph space; nil means no space is selected on connect |
:pool_size | 10 | Number of connections to maintain |
:max_overflow | 0 | Extra connections spawned under burst load |
:connect_timeout | 5_000 | TCP connect timeout (ms) |
:recv_timeout | 15_000 | Socket read timeout per frame (ms) |
:send_timeout | 15_000 | Socket write timeout (ms) |
:timeout | 15_000 | Per-query execution timeout (ms) |
:idle_interval | 1_000 | How often idle connections are pinged (ms) |
:ssl | false | Enable TLS |
:ssl_opts | [] |
Options forwarded to :ssl.connect/3 (:verify, :cacertfile, etc.) |
:backoff_type | :rand_exp |
Reconnect strategy: :stop, :exp, :rand, or :rand_exp |
:backoff_min | 1_000 | Minimum reconnect back-off (ms) |
:backoff_max | 30_000 | Maximum reconnect back-off (ms) |
:decode_mapper | nil |
Function applied to each %Record{} row; nil returns records as-is |
:prefer_json | false |
Use executeJson instead of execute; returns raw JSON from the server |
:telemetry_prefix | [:nebula_graph_ex, :query] |
Prefix for :telemetry events |
:log | false | Emit a Logger message for each query |
:log_level | :debug |
Logger level used when :log is true |
:slow_query_threshold | nil |
Log queries that exceed this duration in ms, regardless of :log |
:show_sensitive_data_on_connection_error | false | Include the password in connection error messages |
Full option documentation: NebulaGraphEx.Options.
Telemetry
Every query emits three :telemetry events:
| Event | When |
|---|---|
[:nebula_graph_ex, :query, :start] | Before the query is sent |
[:nebula_graph_ex, :query, :stop] | After a successful response |
[:nebula_graph_ex, :query, :exception] | If the query raises |
The :stop and :exception events include a :duration measurement in native time units, and a metadata map containing :statement, :params, and :opts.
Attach the built-in logger handler during development:
NebulaGraphEx.Telemetry.attach_default_handler()Attach a custom handler for production metrics:
:telemetry.attach(
"my-app-nebula-metrics",
[:nebula_graph_ex, :query, :stop],
fn _event, %{duration: duration}, %{statement: statement}, _config ->
ms = System.convert_time_unit(duration, :native, :millisecond)
MyMetrics.histogram("nebula.query_ms", ms, tags: [statement: statement])
end,
nil
)
The prefix can be changed per-pool or per-query with the :telemetry_prefix option.
Type mapping
All NebulaGraph value types are decoded to native Elixir terms. The mapping is:
| NebulaGraph type | Elixir term |
|---|---|
null | nil |
bool | true / false |
int | integer() |
float | float() |
string | binary() |
date | %NebulaGraphEx.Types.NebDate{} |
time | %NebulaGraphEx.Types.NebTime{} |
datetime | %NebulaGraphEx.Types.NebDateTime{} |
vertex | %NebulaGraphEx.Types.Vertex{} |
edge | %NebulaGraphEx.Types.Edge{} |
path | %NebulaGraphEx.Types.Path{} |
list | list() |
map | map() |
set | MapSet.t() |
geography | %NebulaGraphEx.Types.Geography{} |
duration | %NebulaGraphEx.Types.Duration{} |
Temporal structs provide conversion helpers to standard Elixir types where a direct mapping exists — see NebulaGraphEx.Types.NebDate, NebulaGraphEx.Types.NebTime, and NebulaGraphEx.Types.NebDateTime.
Running locally
A docker-compose.yml is included at the project root. It starts a single-node NebulaGraph 3.x instance on the default port 9669 with the default credentials (root / nebula):
docker compose up -dRunning integration tests
Integration tests require a live NebulaGraph instance and are excluded from the default test run. Start the Docker cluster, then:
mix test --include integrationContributing
Contributions should keep the public API, docs, and release metadata aligned.
Before opening a release PR or tagging a version:
mix precommit
mix test --include integrationIntegration tests require a running NebulaGraph instance, so start the local Docker setup first if you want full coverage.
Versioning
This project should follow Semantic Versioning. The version in mix.exs is the
source of truth, and git tags should always match it as v<version>.
Industry-standard SemVer guidance:
patch: backwards-compatible bug fixes, documentation-only fixes that affect the shipped version, and internal changes that do not change public behaviourminor: backwards-compatible new features, new public APIs, and additive functionalitymajor: breaking changes to the public API, behaviour, configuration, or supported upgrade path
For a library published to Hex, the safest default is:
-
use
patchfor fixes -
use
minorfor additive improvements -
use
majoronly when users must change their code or upgrade process
To bump the version in mix.exs:
mix nebula_graph_ex.versionRunning the task without an argument prompts for the bump type interactively. You can also specify it directly:
mix nebula_graph_ex.version patch
mix nebula_graph_ex.version minor
mix nebula_graph_ex.version majorReleasing
To create the matching annotated git tag after updating mix.exs:
mix nebula_graph_ex.tag
The tag task reads @version from mix.exs, creates v<version>, and refuses
to run if the git worktree is dirty or the tag already exists.
Typical release flow:
mix precommit
mix docs
mix nebula_graph_ex.version minor
git add mix.exs README.md lib test
git commit -m "Prepare 0.2.0 release"
mix nebula_graph_ex.tag
git push origin main
git push origin v0.2.0
mix hex.publishLicense
MIT — see LICENSE.