Arangox (Arangox v0.8.0)

Copy Markdown View Source

Hex.pm Documentation CI

Arangox is a pooled Elixir driver for ArangoDB. It gives you a conventional resource API for everyday work, first-class AQL queries and transactions, and raw access to the HTTP API when you need it.

alias Arangox.API.{Collections, Documents}

{:ok, _collection} = Collections.create(db, %{name: "products"})
{:ok, created} = Documents.create(db, "products", %{name: "Keyboard"})
{:ok, product} = Documents.get(db, "products", created["_key"])

Arangox 0.8 supports:

  • ArangoDB 3.11 and 3.12
  • Elixir 1.15 and later
  • OTP 26 and later
  • HTTP through Mint by default
  • HTTP through Gun as an alternative
  • VelocyStream for backward compatibility with ArangoDB 3.11 deployments

Getting Started

Add Arangox, Mint, and a JSON library to mix.exs:

def deps do
  [
    {:arangox, "~> 0.8.0"},
    {:jason, "~> 1.4"},
    {:mint, "~> 1.9"}
  ]
end

Mint and Jason are optional dependencies so applications can choose another transport or codec. Add them explicitly when using the default setup shown here.

For a local server on ArangoDB's default port:

{:ok, db} =
  Arangox.start_link(
    endpoints: "http://localhost:8529",
    database: "store",
    auth: {:basic, "root", ""}
  )

Arangox.start_link/1 starts a DBConnection pool. The defaults are http://localhost:8529 and a pool size of 10, so a local server without authentication needs only:

{:ok, db} = Arangox.start_link()

In an application, put the pool under your supervision tree and give it a stable name:

children = [
  {Arangox,
   name: MyApp.Arango,
   endpoints: System.fetch_env!("ARANGODB_URL"),
   database: System.fetch_env!("ARANGODB_DATABASE"),
   auth:
     {:basic, System.fetch_env!("ARANGODB_USER"),
      System.fetch_env!("ARANGODB_PASSWORD")}}
]

Supervisor.start_link(children, strategy: :one_for_one)

Calls can now use MyApp.Arango anywhere they would use the db value from start_link/1.

Resrouce API

The modules under Arangox.API cover ArangoDB's HTTP surface: 230 operations across 22 resource modules. The pool is always the first argument, followed by path parameters, a request body when the operation needs one, and options.

Here is a complete document lifecycle:

alias Arangox.API.{Collections, Documents}

{:ok, _collection} =
  Collections.create(MyApp.Arango, %{
    name: "products",
    type: 2
  })

{:ok, created} =
  Documents.create(
    MyApp.Arango,
    "products",
    %{name: "Mechanical keyboard", price_cents: 12_900, in_stock: true},
    return_new: true
  )

key = created["_key"]

{:ok, product} = Documents.get(MyApp.Arango, "products", key)

{:ok, updated} =
  Documents.update(
    MyApp.Arango,
    "products",
    key,
    %{in_stock: false},
    return_new: true
  )

{:ok, _deleted} = Documents.delete(MyApp.Arango, "products", key)

Operation options use Elixir names. For example, return_new: true is sent as ArangoDB's returnNew=true. Every operation also accepts:

  • :database to override the pool's database for that call
  • :headers as a list of {name, value} tuples
  • :transaction with an %Arangox.Transaction{} handle
  • :timeout for the caller's complete time budget
  • :request_timeout for the maximum time spent waiting on the socket

Resource operations return the decoded response body:

case Documents.get(MyApp.Arango, "products", key) do
  {:ok, product} ->
    {:found, product}

  {:error, %Arangox.Error{reason: :arango_document_not_found}} ->
    :not_found

  {:error, %Arangox.Error{} = error} ->
    {:failed, error}
end

Every operation has a bang form when raising is the clearer control flow:

product = Documents.get!(MyApp.Arango, "products", key)

Useful starting points include:

See the HexDocs API reference for the complete surface.

AQL Queries

Use Arangox.query/4 when AQL is the natural interface. Bind variables stay separate from the statement, and query options use snake_case names:

{:ok, %Arangox.Response{body: %{"result" => products}}} =
  Arangox.query(
    MyApp.Arango,
    """
    FOR product IN products
      FILTER product.price_cents <= @maximum
      SORT product.price_cents ASC
      RETURN product
    """,
    %{maximum: 15_000},
    batch_size: 100
  )

ArangoDB has no prepared statements. On ArangoDB 3.12.4 and later, opt into its server-side AQL plan cache when repeatedly executing eligible query text:

Arangox.query(
  MyApp.Arango,
  "FOR product IN products FILTER product._key == @key RETURN product",
  %{key: key},
  use_plan_cache: true
)

Arangox.plan_cache/1 lists the current database's cached plans and Arangox.clear_plan_cache/1 clears all of them. Both operations affect the whole database, not only this pool.

For large results, stream cursor batches inside Arangox.run/3 or Arangox.transaction/3:

{:ok, names} =
  Arangox.transaction(MyApp.Arango, fn conn ->
    conn
    |> Arangox.cursor(
      "FOR product IN products SORT product.name RETURN product.name",
      %{},
      batch_size: 100
    )
    |> Stream.flat_map(fn response -> response.body["result"] end)
    |> Enum.to_list()
  end, read: "products")

Transactions

For work that fits naturally in one function, use the block form. Returning commits the transaction; Arangox.abort/2, an exception, or an exit rolls it back.

alias Arangox.API.Documents

Arangox.transaction(MyApp.Arango, fn conn ->
    case Documents.update(conn, "accounts", from_key, %{active: false}) do
      {:ok, source} -> source
      {:error, error} -> Arangox.abort(conn, error)
    end
  end, write: "accounts"
)

For a transaction that must span separate calls, use a handle:

{:ok, transaction} =
  Arangox.begin_transaction(MyApp.Arango, write: "products")

{:ok, _product} =
  Arangox.API.Documents.update(
    MyApp.Arango,
    "products",
    key,
    %{in_stock: true},
    transaction: transaction
  )

{:ok, %Arangox.Response{}} =
  Arangox.commit_transaction(MyApp.Arango, transaction)

# or

{:ok, %Arangox.Response{}} =
  Arangox.abort_transaction(MyApp.Arango, transaction)

HTTP Primitives

Arangox exposes low-level HTTP primitives for unsupported or custom endpoints, Foxx services, etc:

{:ok, %Arangox.Response{status: 200, body: version}} =
  Arangox.get(MyApp.Arango, "/_api/version")

{:ok, %Arangox.Response{}} =
  Arangox.post(
    MyApp.Arango,
    "/_db/mydb/myservice/reindex",
    %{full: true}
  )

Configuration

The options most applications need are:

OptionPurposeDefault
:endpointsOne endpoint or an ordered failover list"http://localhost:8529"
:databaseDatabase prepended to database-scoped requestsServer default
:auth{:basic, user, password} or {:bearer, token}No authentication
:pool_sizeNumber of concurrently usable connections10
:headersHeaders sent with every request[]
:connect_timeoutBudget for each connection attempt5_000 ms
:request_timeoutPer-request socket-wait ceiling15_000 ms
:max_body_sizeLargest response body accepted for decoding128 MiB
:content_typeHTTP request-body codec:json
:clientTransport implementationArangox.MintClient

The pool also accepts standard DBConnection start options.

Socket and transport options

Three options carry settings through to the socket and to the transport library. Which one you want depends on who reads it:

OptionGoes toRead when
:tcp_optsErlang's :gen_tcpThe endpoint is cleartext — http://, tcp://, or a Unix socket
:ssl_optsErlang's :sslThe endpoint is encrypted — https://, ssl://, or tls://
:client_optsThe transport library itselfAlways

An option is read only when an endpoint's scheme matches, so :ssl_opts behind an http:// endpoint never reaches a socket. Arangox warns at pool start when you pass an option no configured endpoint can read. An endpoint list may mix schemes, in which case both socket options are live and each applies to the endpoints it matches.

:ssl_opts also accepts plain socket options. Erlang's :ssl forwards what it does not recognise to :gen_tcp, so one list can carry both:

Arangox.start_link(
  endpoints: "https://db.example.com:8529",
  ssl_opts: [cacertfile: "/etc/my_app/ca.pem", nodelay: true]
)

:client_opts takes the shape the library itself takes — a keyword list for Mint, a map for Gun. Each client's documentation says what it accepts and how it combines with the two socket options. Both HTTP clients merge it per key, so setting one transport option does not discard the rest.

Deadlines and retries

:timeout is the caller's total budget, beginning before it queues for a pool connection. :request_timeout is a ceiling on the socket wait. Arangox uses whichever limit expires first.

A request that times out after being written retires its connection and is not automatically retried. Retrying a write is an application decision because the server may already have applied it.

Multiple endpoints

Pass an ordered endpoint list to let each pooled connection find an available server:

Arangox.start_link(
  endpoints: [
    "https://coordinator-1.example.com:8529",
    "https://coordinator-2.example.com:8529"
  ],
  auth: {:bearer, token}
)

Arangox checks availability before admitting a connection. In ArangoDB 3.11 active-failover deployments, leader redirects are accepted only when the target is already configured or explicitly admitted by :endpoint_mapper.

Choose a transport

Most applications should keep the default Mint client.

ClientProtocolsServer supportAdditional dependency
Arangox.MintClientHTTP/1.1 by default; optional HTTP/2ArangoDB 3.11 and 3.12{:mint, "~> 1.9"}
Arangox.GunClientHTTP/1.1 and HTTP/2ArangoDB 3.11 and 3.12{:gun, "~> 2.0"}
Arangox.VelocyClientVelocyStreamArangoDB 3.11 only{:velocy, "~> 0.2"}

Select another client per pool:

Arangox.start_link(client: Arangox.GunClient)

Mint's HTTP/2 mode is explicit:

Arangox.start_link(client_opts: [protocols: [:http2]])

The Mint client sends request bodies whole. Under HTTP/2, a body larger than the peer's flow-control window is rejected instead of streamed; ArangoDB typically advertises roughly 64 KiB. Keep Mint on its default HTTP/1.1 for large documents and bulk writes, or choose Gun. Because Arangox 0.8 is built on DBConnection, HTTP/2 does not yet multiplex concurrent requests on one connection; concurrency still comes from :pool_size.

HTTP pools can encode bodies as VelocyPack independently of the transport:

Arangox.start_link(content_type: :velocypack)

This requires the optional :velocy dependency. Responses are decoded from their own content type, so JSON responses still work.

Security defaults

  • TLS verifies certificates and hostnames using the system trust store.
  • Credentials on a non-loopback cleartext endpoint are refused unless you explicitly pass allow_cleartext_auth: true.
  • Authorization and transaction header values are redacted from returned requests, inspected state, and errors.
  • Header names and values containing carriage returns, line feeds, or nulls are rejected before reaching a transport.
  • Response bodies larger than :max_body_size are rejected before decoding.

If a private certificate authority signs your ArangoDB certificate, provide its CA file rather than disabling verification:

Arangox.start_link(
  endpoints: "https://db.example.com:8529",
  ssl_opts: [cacertfile: "/etc/my_app/arangodb-ca.pem"]
)

Errors

Arangox reports driver, transport, and server failures with one structure: %Arangox.Error{}.

  • :reason is the stable atom to pattern-match on.
  • :status is the HTTP status when the server responded.
  • :error_num is ArangoDB's numeric error code when present.
  • :endpoint is redacted.
  • :message is for people and logs, not branching.
case Arangox.API.Documents.get(MyApp.Arango, "products", key) do
  {:ok, document} -> document
  {:error, %Arangox.Error{reason: :arango_conflict}} -> retry_update()
  {:error, %Arangox.Error{} = error} -> raise error
end

Upgrading from 0.7

Version 0.8 modernizes the transport and public contracts. In particular, Mint is now the default client, TLS verification is enabled, headers are ordered {name, value} lists, and stream transactions use %Arangox.Transaction{} handles.

Read the complete 0.8 migration guide — the "Migrating from v0.7" section — before upgrading an existing application.

Development

Unit and protocol tests run without Docker:

mix deps.get
mix format --check-formatted
mix test

The integration suite uses the services in docker-compose.yml:

docker compose up --detach --wait
mix test.integration

Documentation

License

Arangox is released under the MIT License.

Summary

Types

Remaps a server-advertised redirect endpoint to one this client can reach.

Functions

Aborts a transaction for the given reason.

Aborts the stream transaction the handle names, discarding its writes.

Aborts the stream transaction the handle names. Raises in the case of an error.

Begins a stream transaction and returns a handle to it.

Begins a stream transaction and returns a handle to it. Raises in the case of an error.

Returns a supervisor child specification for a DBConnection pool.

Discards every cached query plan in the current database.

Discards every cached query plan in the current database. Raises in the case of an error.

Commits the stream transaction the handle names.

Commits the stream transaction the handle names. Raises in the case of an error.

Creates a cursor and returns a DBConnection.Stream struct. Results are fetched upon enumeration.

Runs a DELETE request against a connection pool.

Runs a DELETE request against a connection pool. Raises in the case of an error.

Runs a GET request against a connection pool.

Runs a GET request against a connection pool. Raises in the case of an error.

Runs a HEAD request against a connection pool.

Runs a HEAD request against a connection pool. Raises in the case of an error.

json_library() deprecated

Returns the JSON library from the deprecated application config, or Jason.

Runs a OPTIONS request against a connection pool.

Runs a OPTIONS request against a connection pool. Raises in the case of an error.

Runs a PATCH request against a connection pool.

Runs a PATCH request against a connection pool. Raises in the case of an error.

Lists the current database's cached query plans.

Lists the current database's cached query plans. Raises in the case of an error.

Runs a POST request against a connection pool.

Runs a POST request against a connection pool. Raises in the case of an error.

Runs a PUT request against a connection pool.

Runs a PUT request against a connection pool. Raises in the case of an error.

Runs an AQL query and returns every row of the result.

Runs an AQL query and returns every row of the result. Raises on error.

Runs a request against a connection pool.

Runs a request against a connection pool. Raises in the case of an error.

Acquires a connection from a pool and runs a series of requests or cursors with it. If the connection disconnects, all future calls using that connection reference will fail.

Starts a connection pool.

Fetches the current status of a transaction from the database and returns its corresponding DBconnection status.

Acquires a connection from a pool, begins a transaction in the database and runs a series of requests or cursors with it. If the connection disconnects, all future calls using that connection reference will fail.

Fetches the status of the stream transaction the handle names from the database.

Fetches the status of the stream transaction the handle names from the database. Raises in the case of an error.

Types

bindvars()

@type bindvars() :: keyword() | map()

body()

@type body() :: binary() | map() | list() | nil

client()

@type client() :: module()

conn()

@type conn() :: DBConnection.conn()

endpoint()

@type endpoint() :: binary()

endpoint_mapper()

@type endpoint_mapper() ::
  %{optional(endpoint()) => endpoint()}
  | (endpoint() -> endpoint() | nil)
  | {module(), atom(), [any()]}

Remaps a server-advertised redirect endpoint to one this client can reach.

A map is looked up by the advertised endpoint; a missing entry refuses the redirect. A function (or {module, function, args}, which is applied with the advertised endpoint prepended to args) returns the endpoint to use, and anything other than an endpoint binary refuses the redirect.

headers()

@type headers() :: [{binary(), binary()}]

method()

@type method() :: :get | :head | :delete | :post | :put | :patch | :options

path()

@type path() :: binary()

query()

@type query() :: binary() | Arangox.Query.t()

start_option()

@type start_option() ::
  {:client, module()}
  | {:endpoints, [endpoint()]}
  | {:endpoint_mapper, endpoint_mapper()}
  | {:auth, Arangox.Auth.t()}
  | {:database, binary()}
  | {:headers, headers()}
  | {:read_only?, boolean()}
  | {:connect_timeout, timeout()}
  | {:request_timeout, pos_integer()}
  | {:failover_callback,
     (Arangox.Error.t() -> any()) | {module(), atom(), [any()]}}
  | {:tcp_opts, [:gen_tcp.connect_option()]}
  | {:ssl_opts, [:ssl.tls_client_option()]}
  | {:client_opts, keyword() | map()}
  | {:json_library, module()}
  | {:content_type, :json | :velocypack}
  | {:max_body_size, pos_integer()}
  | {:vst_maxsize, pos_integer()}
  | DBConnection.start_option()

transaction_option()

@type transaction_option() ::
  {:read, binary() | [binary()]}
  | {:write, binary() | [binary()]}
  | {:exclusive, binary() | [binary()]}
  | {:properties, list() | map()}
  | DBConnection.option()

Functions

abort(conn, reason)

@spec abort(conn(), reason :: any()) :: no_return()

Aborts a transaction for the given reason.

Delegates to DBConnection.rollback/2.

Example

iex> {:ok, conn} = Arangox.start_link()
iex> Arangox.transaction(conn, fn c ->
iex>   Arangox.abort(c, :reason)
iex> end)
{:error, :reason}

abort_transaction(conn, trx, opts \\ [])

@spec abort_transaction(conn(), Arangox.Transaction.t(), [DBConnection.option()]) ::
  {:ok, Arangox.Response.t()} | {:error, any()}

Aborts the stream transaction the handle names, discarding its writes.

This is the handle form's counterpart to the closure form's abort/2, and deliberately not the same function: abort/2 raises to roll the current closure back through DBConnection.rollback/2, while this sends an abort request for the named transaction and returns. It runs against any pooled connection to the deployment; accepts only a Arangox.Transaction.t/0.

Accepts any of the options accepted by DBConnection.execute/4, plus :database (pass the one the transaction was begun in).

abort_transaction!(conn, trx, opts \\ [])

@spec abort_transaction!(conn(), Arangox.Transaction.t(), [DBConnection.option()]) ::
  Arangox.Response.t()

Aborts the stream transaction the handle names. Raises in the case of an error.

See abort_transaction/3.

begin_transaction(conn, opts \\ [])

@spec begin_transaction(conn(), [transaction_option()]) ::
  {:ok, Arangox.Transaction.t()} | {:error, any()}

Begins a stream transaction and returns a handle to it.

This is the handle form of transactions. Where transaction/3 binds the transaction to one checked-out connection for the span of a closure, the Arangox.Transaction.t/0 returned here carries the transaction's identity as a value: pass it to any request through the :transaction option and that request joins the transaction, whichever pooled connection serves it — then finish with commit_transaction/3 or abort_transaction/3, again on any connection reaching the same deployment. Verified against an ArangoDB 3.12 cluster, this includes connections to different coordinators of that deployment; see Arangox.Transaction for the measured scope.

The handle is a bearer capability — treat it like authentication material and do not pass it across a trust boundary. See Arangox.Transaction.

Accepts the same options as transaction/3: :read, :write, :exclusive, :properties, :database, and any of the options accepted by DBConnection.execute/4. A transaction lives in the database it was begun in, so pass the same :database option to the requests and to commit/abort/status that you pass here.

Example

{:ok, trx} = Arangox.begin_transaction(conn, write: "coll")
Arangox.post!(conn, "/_api/document/coll", %{a: 1}, [], transaction: trx)
{:ok, :running} = Arangox.transaction_status(conn, trx)
{:ok, %Arangox.Response{}} = Arangox.commit_transaction(conn, trx)

begin_transaction!(conn, opts \\ [])

@spec begin_transaction!(conn(), [transaction_option()]) :: Arangox.Transaction.t()

Begins a stream transaction and returns a handle to it. Raises in the case of an error.

See begin_transaction/2.

child_spec(opts \\ [])

@spec child_spec([start_option()]) :: Supervisor.child_spec()

Returns a supervisor child specification for a DBConnection pool.

clear_plan_cache(conn, opts \\ [])

@spec clear_plan_cache(conn(), [DBConnection.option()]) :: :ok | {:error, any()}

Discards every cached query plan in the current database.

A database-wide administrative operation, and the blunt kind: the server has no per-entry release, so this affects every caller of the database, whose next execution of an affected statement is planned afresh. Requires write privileges on the database; read alone is refused with a 403.

Clearing is the escape hatch for a plan that has gone stale — a plan is optimised against the indexes and statistics that existed when it was built, and nothing invalidates it when those change.

Accepts :database and any of the options accepted by DBConnection.execute/4.

clear_plan_cache!(conn, opts \\ [])

@spec clear_plan_cache!(conn(), [DBConnection.option()]) :: :ok

Discards every cached query plan in the current database. Raises in the case of an error.

See clear_plan_cache/2.

commit_transaction(conn, trx, opts \\ [])

@spec commit_transaction(conn(), Arangox.Transaction.t(), [DBConnection.option()]) ::
  {:ok, Arangox.Response.t()} | {:error, any()}

Commits the stream transaction the handle names.

Runs against any pooled connection to the deployment the transaction was begun on — the handle, not the connection, names the transaction. Accepts only a Arangox.Transaction.t/0, never a bare identifier.

A failed commit leaves the handle usable: the transaction is still addressed by the identifier, so a follow-up abort_transaction/3 reaches it. Committing an already-committed transaction gets a 200 from the server (idempotent); aborting or using a committed one is the server's error.

Accepts any of the options accepted by DBConnection.execute/4, plus :database (pass the one the transaction was begun in).

commit_transaction!(conn, trx, opts \\ [])

@spec commit_transaction!(conn(), Arangox.Transaction.t(), [DBConnection.option()]) ::
  Arangox.Response.t()

Commits the stream transaction the handle names. Raises in the case of an error.

See commit_transaction/3.

cursor(conn, query, bindvars \\ [], opts \\ [])

@spec cursor(conn(), query(), bindvars(), [DBConnection.option()]) ::
  DBConnection.Stream.t()

Creates a cursor and returns a DBConnection.Stream struct. Results are fetched upon enumeration.

The cursor is created, results fetched, then deleted from the database upon each enumeration (not to be confused with iteration). When a cursor is created, an initial result set is fetched from the database. The initial result is returned with the first iteration, subsequent iterations are fetched lazily.

Can only be used within a transaction/3 or run/3 call.

Accepts any of the options accepted by DBConnection.stream/4, as well as any of the following:

  • :database - Sets what database to run the cursor query on
  • :properties - A list or map of additional body attributes to append to the request body when creating the cursor.
  • :transaction - An Arangox.Transaction.t/0 handle from begin_transaction/2; the cursor's requests join that stream transaction.

Delegates to DBConnection.stream/4.

Example

iex> {:ok, conn} = Arangox.start_link()
iex> Arangox.transaction(conn, fn c ->
iex>   stream =
iex>     Arangox.cursor(
iex>       c,
iex>       "FOR i IN [1, 2, 3] FILTER i == 1 || i == @num RETURN i",
iex>       %{num: 2},
iex>       properties: [batchSize: 1]
iex>     )
iex>
iex>   first_batch = Enum.at(stream, 0).body["result"]
iex>
iex>   exhaust_cursor =
iex>     Enum.reduce(stream, [], fn resp, acc ->
iex>       acc ++ resp.body["result"]
iex>     end)
iex>
iex>   {first_batch, exhaust_cursor}
iex> end)
{:ok, {[1], [1, 2]}}

delete(conn, path, headers \\ [], opts \\ [])

@spec delete(conn(), path(), headers(), [DBConnection.option()]) ::
  {:ok, Arangox.Response.t()} | {:error, any()}

Runs a DELETE request against a connection pool.

Accepts any of the options accepted by DBConnection.execute/4.

delete!(conn, path, headers \\ [], opts \\ [])

@spec delete!(conn(), path(), headers(), [DBConnection.option()]) ::
  Arangox.Response.t()

Runs a DELETE request against a connection pool. Raises in the case of an error.

Accepts any of the options accepted by DBConnection.execute!/4.

get(conn, path, headers \\ [], opts \\ [])

@spec get(conn(), path(), headers(), [DBConnection.option()]) ::
  {:ok, Arangox.Response.t()} | {:error, any()}

Runs a GET request against a connection pool.

Accepts any of the options accepted by DBConnection.execute/4.

get!(conn, path, headers \\ [], opts \\ [])

@spec get!(conn(), path(), headers(), [DBConnection.option()]) :: Arangox.Response.t()

Runs a GET request against a connection pool. Raises in the case of an error.

Accepts any of the options accepted by DBConnection.execute!/4.

head(conn, path, headers \\ [], opts \\ [])

@spec head(conn(), path(), headers(), [DBConnection.option()]) ::
  {:ok, Arangox.Response.t()} | {:error, any()}

Runs a HEAD request against a connection pool.

Accepts any of the options accepted by DBConnection.execute/4.

head!(conn, path, headers \\ [], opts \\ [])

@spec head!(conn(), path(), headers(), [DBConnection.option()]) ::
  Arangox.Response.t()

Runs a HEAD request against a connection pool. Raises in the case of an error.

Accepts any of the options accepted by DBConnection.execute!/4.

json_library()

This function is deprecated. Pass :json_library to Arangox.start_link/1 instead.
@spec json_library() :: module()

Returns the JSON library from the deprecated application config, or Jason.

Deprecated in v0.8 and to be removed in the next release, along with the application-config read it reports on. It returns the fallback, not what any particular pool uses: since v0.8 the JSON library is a per-pool start option, so two pools can disagree and neither has to agree with this. Pass :json_library to start_link/1 instead.

Emits a deprecation warning on every call, because there is no correct number of times to call it.

options(conn, path, headers \\ [], opts \\ [])

@spec options(conn(), path(), headers(), [DBConnection.option()]) ::
  {:ok, Arangox.Response.t()} | {:error, any()}

Runs a OPTIONS request against a connection pool.

Accepts any of the options accepted by DBConnection.execute/4.

options!(conn, path, headers \\ [], opts \\ [])

@spec options!(conn(), path(), headers(), [DBConnection.option()]) ::
  Arangox.Response.t()

Runs a OPTIONS request against a connection pool. Raises in the case of an error.

Accepts any of the options accepted by DBConnection.execute!/4.

patch(conn, path, body \\ "", headers \\ [], opts \\ [])

@spec patch(conn(), path(), body(), headers(), [DBConnection.option()]) ::
  {:ok, Arangox.Response.t()} | {:error, any()}

Runs a PATCH request against a connection pool.

Accepts any of the options accepted by DBConnection.execute/4.

patch!(conn, path, body \\ "", headers \\ [], opts \\ [])

@spec patch!(conn(), path(), body(), headers(), [DBConnection.option()]) ::
  Arangox.Response.t()

Runs a PATCH request against a connection pool. Raises in the case of an error.

Accepts any of the options accepted by DBConnection.execute!/4.

plan_cache(conn, opts \\ [])

@spec plan_cache(conn(), [DBConnection.option()]) :: {:ok, [map()]} | {:error, any()}

Lists the current database's cached query plans.

A database-wide administrative operation. The plan cache is shared by every caller of the database, so this exposes the query text of statements other callers ran, and clear_plan_cache/2 discards plans they are relying on. Requires read privileges on the database.

Each entry is the server's own map, including "query", "hash" — the same value Arangox.Response.plan_cache_key/1 returns — "hits", "memoryUsage" and "created".

An empty list is ambiguous

The server returns only the plans whose collections the caller may read, and it filters rather than refuses. A caller permitted on no collections is given [], which is indistinguishable from an empty cache. A caller with no privileges on the database is refused outright, which does surface as an error — note that the refusal is a 401, and 401 is in the default :disconnect_on_error_codes, so it also retires the connection. ArangoDB uses the same status and error number for bad credentials and for insufficient rights, so the driver has nothing to tell them apart by.

Accepts :database and any of the options accepted by DBConnection.execute/4.

plan_cache!(conn, opts \\ [])

@spec plan_cache!(conn(), [DBConnection.option()]) :: [map()]

Lists the current database's cached query plans. Raises in the case of an error.

See plan_cache/2, including its warning about empty results.

post(conn, path, body \\ "", headers \\ [], opts \\ [])

@spec post(conn(), path(), body(), headers(), [DBConnection.option()]) ::
  {:ok, Arangox.Response.t()} | {:error, any()}

Runs a POST request against a connection pool.

Accepts any of the options accepted by DBConnection.execute/4.

post!(conn, path, body \\ "", headers \\ [], opts \\ [])

@spec post!(conn(), path(), body(), headers(), [DBConnection.option()]) ::
  Arangox.Response.t()

Runs a POST request against a connection pool. Raises in the case of an error.

Accepts any of the options accepted by DBConnection.execute!/4.

put(conn, path, body \\ "", headers \\ [], opts \\ [])

@spec put(conn(), path(), body(), headers(), [DBConnection.option()]) ::
  {:ok, Arangox.Response.t()} | {:error, any()}

Runs a PUT request against a connection pool.

Accepts any of the options accepted by DBConnection.execute/4.

put!(conn, path, body \\ "", headers \\ [], opts \\ [])

@spec put!(conn(), path(), body(), headers(), [DBConnection.option()]) ::
  Arangox.Response.t()

Runs a PUT request against a connection pool. Raises in the case of an error.

Accepts any of the options accepted by DBConnection.execute!/4.

query(conn, query, bindvars \\ [], opts \\ [])

@spec query(conn(), query(), bindvars(), [DBConnection.option()]) ::
  {:ok, Arangox.Response.t()} | {:error, any()}

Runs an AQL query and returns every row of the result.

ArangoDB executes AQL through a server-side cursor whichever of these two functions you use — they send the same request. The difference is who drives the cursor afterwards: this one drains it and hands back a single response holding the complete result, while cursor/4 hands you the cursor to read a batch at a time inside a run/3 or transaction/3 block.

A result that fits one batch costs exactly one request either way, because the server keeps no cursor when it has nothing more to send. The extra requests happen only when there is more, and then only here — a caller who stops reading a cursor/4 stream never asks for the rest.

Takes an AQL binary or an Arangox.Query.t/0. AQL options may be given here or carried by the query; see Arangox.Query for the names.

Does not ask the server to reuse a cached query plan unless told to. Pass use_plan_cache: true when the same statement runs repeatedly; it is opt-in because it has a server-version floor and the server refuses to cache some statements outright. There is no prepare step to do it for you — see Arangox.Query.

Example

iex> {:ok, conn} = Arangox.start_link()
iex> {:ok, response} =
iex>   Arangox.query(conn, "FOR i IN [1, 2, 3] FILTER i != @skip RETURN i", %{skip: 2})
iex> response.body["result"]
[1, 3]

query!(conn, query, bindvars \\ [], opts \\ [])

@spec query!(conn(), query(), bindvars(), [DBConnection.option()]) ::
  Arangox.Response.t()

Runs an AQL query and returns every row of the result. Raises on error.

See query/4.

request(conn, method, path, body \\ "", headers \\ [], opts \\ [])

@spec request(conn(), method(), path(), body(), headers(), [DBConnection.option()]) ::
  {:ok, Arangox.Request.t(), Arangox.Response.t()} | {:error, any()}

Runs a request against a connection pool.

Accepts any of the options accepted by DBConnection.execute/4, as well as:

  • :transaction - An Arangox.Transaction.t/0 handle from begin_transaction/2. The request joins that stream transaction: its identifier is sent in the x-arango-trx-id header for this request only — connection state is never written, so the handle applies on whichever pooled connection serves the request. Accepts only the handle struct, never a bare identifier binary. Every request-running function in this module accepts this option.

headers is a list of {name, value} string tuples, appended after the pool's :headers and sent as given — order kept, duplicates kept, nothing renamed or re-cased. See the README's "Headers" section for the exact wire behavior per client. Maps are no longer accepted (0.8).

The request struct echoed back on success carries [redacted] in place of the authorization and x-arango-trx-id header values; what went on the wire is the real thing.

request!(conn, method, path, body \\ "", headers \\ [], opts \\ [])

@spec request!(conn(), method(), path(), body(), headers(), [DBConnection.option()]) ::
  Arangox.Response.t()

Runs a request against a connection pool. Raises in the case of an error.

Accepts any of the options accepted by DBConnection.execute!/4.

run(conn, fun, opts \\ [])

@spec run(conn(), (DBConnection.t() -> result), [DBConnection.option()]) :: result
when result: var

Acquires a connection from a pool and runs a series of requests or cursors with it. If the connection disconnects, all future calls using that connection reference will fail.

Runs can be nested multiple times if the connection reference is used to start a nested run (i.e. calling another function that calls this one). The top level run function will represent the actual run.

Delegates to DBConnection.run/3.

Example

result =
  Arangox.run(conn, fn c  ->
    Arangox.request!(c, ...)
  end)

start_link(opts \\ [])

@spec start_link([start_option()]) :: GenServer.on_start()

Starts a connection pool.

Options

Accepts any of the options accepted by DBConnection.start_link/2, as well as any of the following:

  • :endpoints - Either a single ArangoDB endpoint binary, or a list of endpoints in order of presedence. Each process in a pool will individually attempt to establish a connection with and check the availablility of each endpoint in the order given until an available endpoint is found. Defaults to "http://localhost:8529".
  • :database - Arangox will prepend /_db/:value to the path of every request that isn't already prepended. If a value is not given, nothing is prepended (ArangoDB will assume the _system database).
  • :headers - A list of {name, value} header tuples sent with every request, in the order given, before the request's own headers. Nothing is merged, deduplicated, renamed or re-cased; see the "Headers" section of the README for the exact wire behavior per client.
  • :disconnect_on_error_codes - A list of status codes that will trigger a forced disconnect. Only integers within the range 400..599 are affected. Defaults to [401, 405, 503, 505].
  • :allow_cleartext_auth - Permit :auth credentials to be sent to a cleartext (http://) endpoint that is not on this machine. Defaults to false, and a pool configured that way refuses to start: the credentials would be readable by anything on the network path. Loopback endpoints are exempt and need no opt-in.
  • :auth - Configure whether to resolve authorization. Options are: {:basic, username, password}, {:bearer, token}.
  • :endpoint_mapper - Remaps the endpoint a server advertises in the x-arango-endpoint header of a 503 to an address this client can reach. Either a map of advertised endpoint => target endpoint, a one-argument function returning the target endpoint, or an {module, function, args} tuple (the advertised endpoint is prepended to args). A map with no entry for the advertised endpoint, or a function returning anything that is not an endpoint binary, refuses the redirect. Defaults to no mapper. This is a trust boundary: see Leader redirects below before setting it.
  • :read_only? - Read-only pools will only connect to followers in an active failover setup and add an x-arango-allow-dirty-read header to every request. Defaults to false. Read-only pools never follow a leader redirect — not landing on the leader is what the option is for.
  • :connect_timeout - Sets the timeout for establishing connections with a database. It also bounds the probes the connect pipeline makes on the open socket (the authentication, availability and version stages) — all of them together, not one budget each — so a server that completes the handshake and then goes quiet cannot wedge a connection attempt, and adding a stage cannot extend the worst case. The budget is per endpoint attempt: each endpoint the failover walk reaches gets its own.
  • :request_timeout - The longest arangox will wait on the socket for one request's response, in milliseconds. Must be a positive integer; 0, negatives and :infinity are rejected here and per request. Defaults to 15_000, the same as DBConnection's :timeout. It is a ceiling, not the whole story — see Request timeouts below. Can be overridden per request.
  • :tcp_opts - Transport options for the tcp socket interface (:gen_tcp in the case of mint).
  • :ssl_opts - Transport options for the ssl socket interface (:ssl in the case of mint).
  • :client - A module that implements the Arangox.Client behaviour. Defaults to Arangox.MintClient. Arangox.VelocyClient speaks VelocyStream, which ArangoDB removed in server 3.12 — it remains supported as an explicit opt-in for 3.11 deployments.
  • :client_opts - Options passed to the client library itself, for settings that belong to the library rather than to the socket. Its shape is the library's own: a keyword list for mint, a map for gun. See each client's moduledoc for what it accepts and how it combines with :tcp_opts and :ssl_opts.
  • :failover_callback - A function to call every time arangox fails to establish a connection. This is only called if a list of endpoints is given, regardless of whether or not it's connecting to an endpoint in an active failover setup. Can be either an anonymous function that takes one argument (which is an %Arangox.Error{} struct), or a three-element tuple containing arguments to pass to apply/3 (in which case an %Arangox.Error{} struct is always prepended to the arguments).
  • :json_library - The module used to encode and decode JSON request and response bodies. Only the HTTP clients use it; Arangox.VelocyClient speaks VelocyPack. Must export encode!/1, decode/1 and decode!/1. Defaults to Jason.
  • :vst_maxsize - The maximum size, in bytes, of a single VelocyStream chunk written by Arangox.VelocyClient. Must be an integer greater than 24, the size of a chunk header. Defaults to 30_720.
  • :content_type - :json (the default) or :velocypack, selecting how an HTTP client encodes request bodies. :velocypack requires the :velocy library, sends content-type: application/x-velocypack, and asks for the same in return with an accept header. Responses are decoded by their own content type, so a server that declines VelocyPack and responds in JSON is still read correctly. A content-type or accept header set among a request's own headers wins over the pool's setting — the codec is chosen from the first content-type entry there, any casing, and never from the pool's :headers list — and one naming neither codec — text/plain for /_api/import, application/octet-stream — sends the request body raw: it must already be a binary in its wire form. Ignored by Arangox.VelocyClient, which carries its own body encoding.
  • :max_body_size - The largest response body, in bytes, handed to a codec. A larger body is refused with a structured error (reason: :body_too_large) before any decoding runs, bounding what a hostile or broken server can make the pool parse. Defaults to 134_217_728 (128 MiB). Ignored by Arangox.VelocyClient.

Leader redirects

In an active failover setup a follower responds 503 and names the current leader in an x-arango-endpoint response header. Arangox follows that header during connect, but only when the target passes an admission policy. The pool holds your credentials and would send them to whatever host the server named, so:

  1. The advertised value must parse as an endpoint. One that does not is refused, not raised.
  2. Its normalized origin — encryption class, host and port — must already appear in :endpoints, or :endpoint_mapper must admit it. Normalized means tcp:// compares equal to http:// and ssl:///tls:// to https://, because the server advertises in its own scheme vocabulary rather than yours. The comparison uses the full origin and not just the host: a configured http://db.internal:8529 does not authorize a redirect to http://db.internal:9999, which is a different service on the same machine.
  3. An encrypted connection is never redirected to a cleartext one, even to an endpoint that is configured.
  4. At most three redirects are followed per connect attempt, counted across the whole endpoint walk, so a pair of servers redirecting to each other terminates.

A refused redirect is treated as what it is — an endpoint that responded and is unusable. The walk moves on to the next endpoint, :failover_callback fires, and the error says which redirect was refused and why. A redirect that is followed is normal operation rather than a failure and does not fire :failover_callback.

:endpoint_mapper is a trust boundary

:endpoint_mapper is the only way to follow a redirect to an address that is not in :endpoints. Its output is validated the same way — it must parse, and it may not downgrade encryption — but it is not re-checked against :endpoints, because remapping to an address you never configured is the whole point. A mapper that returns its input unchanged therefore switches the policy off entirely and lets any server that can satisfy a connect probe send your credentials anywhere. Map the advertised endpoints you recognise and refuse everything else; the map form does exactly that.

It exists for the containerized case. Running docker-compose.yml's resilient_single service, the followers on host ports 8003 and 8005 advertise the leader as http://localhost:8539 — 8539 is the leader's port inside the container, and from the host the leader is on 8004. No host-side :endpoints list can contain localhost:8539, so the default policy refuses that redirect even though it is legitimate. The mapper is how you say where the leader really is:

Arangox.start_link(
  endpoints: ["http://localhost:8003"],
  endpoint_mapper: %{"http://localhost:8539" => "http://localhost:8004"}
)

Map keys are matched on their normalized origin too, so the key above also matches an advertised tcp://localhost:8539.

A topology whose members sit on their real published addresses — the usual bare-metal active-failover deployment, where :endpoints lists the same addresses the servers advertise — needs no mapper at all.

Request timeouts

Every request is bounded. Two numbers decide by how much, and they are not the same kind of number:

  • your :timeout (DBConnection's, per call, 15 seconds by default) is a budget that starts counting the moment you ask for a connection — including the time you spend queueing for one;
  • :request_timeout is a ceiling on how long any single request may sit on the socket.

Arangox waits for min(what is left of your budget, :request_timeout), less a small margin. So a caller that waited 14 seconds for a free connection gets the last second, not a fresh 15 — and Arangox.get(conn, path, [], timeout: 500) really does come back within 500ms, with an Arangox.Error whose :reason is :timeout, rather than with DBConnection's "queued and checked out for longer than" error some time later.

Raise the ceiling for one slow request without loosening the pool:

Arangox.post(conn, "/_api/cursor", body, [], request_timeout: 60_000, timeout: 65_000)

Both numbers matter there: :request_timeout alone cannot buy more time than the caller's budget allows.

If the budget is already gone by the time a connection frees up, the request is not sent at all and the error's :reason is :deadline_exceeded. That connection is healthy and stays in the pool.

timeout: :infinity waives the budget, not the ceiling: such a request still gets :request_timeout on the socket. There is no way to configure an unbounded wait.

A request that times out on the socket takes its connection down with it. Arangox cannot tell where an abandoned response ended, so reusing the connection would serve the leftover bytes to whoever draws it next. The pool reconnects; the request is not retried, because it may well have been executed.

Inside run/3 and transaction/3 the block's own :timeout bounds every request made on the block's connection, since that is the deadline DBConnection armed when the block checked it out. Such a request never gets a budget longer than what is left of the block's — including a cursor's per-batch fetches.

A request made from inside a block to a different pool is a different checkout with a deadline of its own, and keeps the budget it asked for:

Arangox.transaction(primary, fn conn ->
  Arangox.get(conn, path)               # bounded by the block
  Arangox.get(read_replica, other_path) # bounded by its own :timeout
end)

Deprecated application config

:json_library and :vst_maxsize used to be global application config:

config :arangox, :json_library, Poison
config :arangox, :vst_maxsize, 12_345

Both reads still work in v0.8 and will be removed in the next release. A pool that does not pass the start option falls back to the application config, and the fallback logs a deprecation warning. A value read from the fallback is validated exactly like the start option and raises here on the same inputs — :vst_maxsize used to be checked by Application.compile_env/3 at compile time, and losing that check would only move the failure to a MatchError in the middle of a request.

That warning is emitted once per pool: precisely, once per start_link/1 or child_spec/1 call, in the calling process, and nowhere else. It is deliberately not emitted from the DBConnection connect callback, which is not a once-per-pool function — DBConnection re-enters it in the same process on every backoff cycle for as long as the pool lives, in each of the pool's :pool_size processes. Warning there would log several times a second, forever, against an unreachable server. Starting a pool through DBConnection.start_link/2 directly bypasses this module and therefore warns not at all, while still honouring the fallback.

status(conn)

@spec status(conn()) :: DBConnection.status()

Fetches the current status of a transaction from the database and returns its corresponding DBconnection status.

Delegates to DBConnection.status/1.

transaction(conn, fun, opts \\ [])

@spec transaction(conn(), (DBConnection.t() -> result), [transaction_option()]) ::
  {:ok, result} | {:error, any()}
when result: var

Acquires a connection from a pool, begins a transaction in the database and runs a series of requests or cursors with it. If the connection disconnects, all future calls using that connection reference will fail.

Transactions can be nested multiple times if the connection reference is used to start a nested transactions (i.e. calling another function that calls this one). The top level transaction function will represent the actual transaction and nested transactions will be interpreted as a run/3, erego, any collections declared in nested transactions will have no effect.

Accepts any of the options accepted by DBConnection.transaction/3, as well as any of the following:

  • :read - An array of collection names or a single collection name as a binary.
  • :write - An array of collection names or a single collection name as a binary.
  • :exclusive - An array of collection names or a single collection name as a binary.
  • :database - Sets what database to run the transaction on
  • :properties - A list or map of additional body attributes to append to the request body when beginning a transaction.

Delegates to DBConnection.transaction/3.

Example

Arangox.transaction(conn, fn c ->
  Arangox.status(c) #=> :transaction

  # do stuff
end, [
  write: "something",
  properties: [waitForSync: true]
])

transaction_status(conn, trx, opts \\ [])

@spec transaction_status(conn(), Arangox.Transaction.t(), [DBConnection.option()]) ::
  {:ok, :running | :committed | :aborted | binary()} | {:error, any()}

Fetches the status of the stream transaction the handle names from the database.

Answers from the body of the server's reply, not from the HTTP status: the server keeps responding 200 for a finished transaction within a retention window, with the actual state in the body. Returns {:ok, :running}, {:ok, :committed} or {:ok, :aborted}; a status this driver does not recognize is returned as its raw binary rather than turned into an atom.

This is the handle form's counterpart to status/1, which reports on the closure transaction bound to a connection. It runs against any pooled connection to the deployment; accepts only a Arangox.Transaction.t/0.

Accepts any of the options accepted by DBConnection.execute/4, plus :database (pass the one the transaction was begun in).

transaction_status!(conn, trx, opts \\ [])

@spec transaction_status!(conn(), Arangox.Transaction.t(), [DBConnection.option()]) ::
  :running | :committed | :aborted | binary()

Fetches the status of the stream transaction the handle names from the database. Raises in the case of an error.

See transaction_status/3.