Nodex: Helper modules around distributed Elixir and C-Nodes

A set of helper modules that enable you to work with distributed elixir and c-nodes.

Available as a hex-package:

{:nodex, "~> 0.1.0"}

Documentation

The docs can be found at https://hexdocs.pm/nodex.

Nodex.Distributed

A module to setup a distributed environment programmatically.
It takes care of starting epmd, starts :net_kernel for you and can start and maintain child-nodes.

iex> Nodex.Distributed.up
iex> Node.alive?
true
iex> Node.self()
:"master@127.0.0.1"
iex> Nodex.Distributed.spawn_slaves(2)
[:"slave1@127.0.0.1", :"slave2@127.0.0.1"]

Nodex.Cnode

Helper module to simplify working with a C-Node. It is also allows you to start and monitor
an external C-Node process within a supervision tree.

What is a β€œCnode”?

C-Nodes are external os-processes that communicate with the Erlang VM through erlang messaging.
That way you can implement native code and call into it from Elixir in a safe predictable way.
The Erlang VM stays unaffected by crashes of the external process.

In my opinion C-Nodes are the best option if you need to call into native code.
The calling overhead is small, and on par with the calling overhead of remote node communication.
And you get monitoring abilities through Node.monitor(:node@host.example).
You can scale your C-Nodes onto multiple machines.

So instead of exposing your application to the risks that come with NIFs, you can enclose them in
an external OS-process. And even better than port drivers, you gain all of the benefits including
scalability.

The repository includes a benchmark comparing local, remote and cnode calling performance:

## VmVsCnodeBench
[20:16:07] 1/4: cnode
[20:16:09] 2/4: cnode direct
[20:16:12] 3/4: local
[20:16:14] 4/4: remote

Finished in 11.49 seconds

## VmVsCnodeBench
benchmark nam iterations   average time 
local            1000000   1.90 Β΅s/op
cnode direct       50000   37.30 Β΅s/op
cnode              50000   41.43 Β΅s/op
remote             50000   48.64 Β΅s/op

Executed on a MacBook Pro 2,5GHz.

Mount a C-Node inside your supervision tree

Mount Cnodex as a worker, and reference it by name:

children = [
  worker(Cnodex, [%{exec_path: "priv/example_client"}], name: :ExampleClient)
]
Supervisor.init(children, strategy: :one_for_one)

Later call into your C-node through Cnodex:

{:ok, reply} = Cnodex.call(:ExampleClient, {:ping, "hello world"})

Start and call into a C-Node

{:ok, pid} = Cnodex.start_link(%{exec_path: "priv/example_client"})
{:ok, reply} = Cnodex.call(pid, {:ping, "hello world"})

Nodex.Cnode Implementation details

Your supervisor spawns a GenServer worker process that is using a node monitor on the C-Node.

You can provide a running C-Node to connect to. You must provide an executable with startup arguments that implement a suitable C-Node.

A suitable C-Node prints a ready_line to stdout when it is ready to accept messages.
This mechanism signals readyness to the monitoring process.
The C-Node startup and ready-handling is synchronous. After a configurable spawn_inactive_timeout the init procedure is interrupted, and again supervision can take care of the failued startup.

When you shutdown Nodex.Cnode, it will issue a SIGTERM to the os-pid of the C-Node, to ensure you have no lingering processes.
Although it is even better if you implement some mechanism in the C-Node to ensure it exits properly after it looses the connection to Erlang.

The safest option to call into the C-Node is going through the Cnodex.call/2 or Cnodex.call/3 function.
It will, using a configurable timeout, await a response from the C-Node within the calling process.
In case the C-Node is unavailable, you will be thrown a proper exception, because you are calling an unavailable GenServer.

If you don’t want your process inbox to be hijacked waiting for the C-Node response, use a Task in combination with Cnodex.call/2.

In case the C-Node becomes unavailable, the Cnode GenServer terminates too.
Your supervisor can then take care starting a new C-Node.

Writing a C-Node

This repository provides you with some good starting points for writing a project or package that
encloses a C-Node.

Checkout the following files of this repository:

# A proper Makefile is a great base for building C
β”œβ”€β”€ Makefile
β”‚
β”œβ”€β”€ bench
β”‚   β”‚
β”‚   # How to benchmark a C-Node
β”‚   └── vm_vs_cnode_bench.exs
β”‚
# Directory with C source files
β”œβ”€β”€ c_src
β”‚   β”‚
β”‚   # An example C-Node client that connects back to your node
β”‚   └── example_client.c
β”‚
# A mix file that defines a custom task for handling Makefile builds
β”œβ”€β”€ mix.exs
β”‚
# The priv directory should contain your C build artifacts
β”œβ”€β”€ priv
β”‚   β”‚
β”‚   # This is the example C-Node client that gets build
β”‚   └── example_client
β”‚
└── test
    └── nodex
            β”‚
            # An example test case on how to test C-Nodes and C-Node communication
            └── cnode_test.exs

In particular c_src/example_client.c contains a nice boilerplate for writing your own C-Node client that
connects back to your node. It is fully annotated, so have a look to find out what is going on there.

The general idea behind this is, that you start up a C-program, and in the startup arguments you provide the
connection information to your Elixir node. If everything goes well, your C-program prints a shared message
to STDOUT.
The Elixir side that started the C-program will read all the STDOUT lines written by the C-program.
If the correct shared message appears, it assumes the C-program is now ready to accept messages.
And it will establish a node monitor on the C-program, so that if the connection goes down, the Elixir side
is notified of the loss.

It is best to write the C-program in a way that is exits cleanly as soon as it looses the connection or something goes wrong.
That aligns the C-program with the idea that it can be controlled by a supervisor.

7 Likes

Oooo very nice!

This whole thing looks awesome! :slight_smile:

1 Like