Packages

Chorex enables choreographic programming for Elixir through macros. This is a research project intended to push on the boundaries of what choreographic programming can achieve through a real-world implementation of a choreography compiler.

Current section

Files

Jump to
chorex README.md
Raw

README.md

Chorex - Choreographic Programming in Elixir
# Synopsis
**Note:** this documentation is current as of 2024-05-30. The project is evolving rapidly, so this README may occasionally get out-of-sync with what the project can do.
Describe the choreography in a module with the `defchor` macro:
```elixir
defmodule TestChor do
defchor [Buyer, Seller] do
Buyer.get_book_title() ~> Seller.(b)
Seller.get_price(b) ~> Buyer.(p)
Buyer.(p)
end
end
```
Implement the actors:
```elixir
defmodule MyBuyer do
use TestChor.Chorex, :buyer
def get_book_title(), do: "Das Glasperlenspiel"
end
defmodule MySeller do
use TestChor.Chorex, :seller
def get_price(_b), do: 42
end
```
Elsewhere in your program:
```elixir
ps = spawn(MySeller, :init, [])
pb = spawn(MyBuyer, :init, [])
config = %{Seller => ps, Buyer => pb, :super => self()}
send(ps, {:config, config})
send(pb, {:config, config})
receive do
{:choreography_return, Buyer, val} -> IO.puts("Got #{val}")
end
```
The program should print `Got 42` to the terminal.
# Description
Chorex is a library for *choreographic programming* in Elixir. Choreographic programming is a programming paradigm where you specify the interactions between different entities in a concurrent system in one global view, and then *extract implementations* for each of those actors. See [§ Bibliography](#org44e5aee) for references on choreographic programming in general.
## Installation
Chorex is available on Hex.pm. Install by including the following in your `mix.exs` file under the `deps` list:
```elixir
def deps do
[
...,
{:chorex, "~> 0.1.0"},
...
]
end
```
You can install development versions of Chorex directly from GitHub like so:
```elixir
def deps do
[
...,
{:chorex, github: "utahplt/chorex"},
...
]
end
```
Note that this is *experimental software* and stuff *will* break. Please don’t rely on this for anything production-grade. Not yet at least.
## What is a choreography?
A choreography is a birds-eye view of an interaction between nodes in a distributed system. You have some set of *actors/—in Elixir parlance processes—that exchange /messages* while also running some /local computation/—i.e. functions that don’t rely on talking to other nodes in the system.
### Choreography syntax
Chorex introduces some new syntax for choreographies. Here’s a breakdown of how it works:
```elixir
defchor [Actor1, Actor2, ...] do
...choreography body...
end
```
The `defchor` macro wraps a choreography and translates it into core Elixir code. You give `defchor` a list of actors, specified as if they were module names, and then a `do` block wraps the choreography body.
```elixir
Actor1.(var1) ~> Actor2.(var2_a)
Actor1.func_1() ~> Actor2.(var2_b)
Actor1.func_2(var1_a, var1_b) ~> Actor2.(var2_c)
Actor1.(var1_a + var1_b) ~> Actor2.(var2_c)
```
The `~>` indicates sending a message between actors. The left-hand-side must be `Actor1.<something>`, where that `<something>` bit can be one of three things:
1. A variable local to Actor1
2. A function local to Actor1 (with or without arguments, also all local to Actor1)
3. An expression local to Actor1
The right-and-side must be `Actor2.<var_name>`. This means that the left-hand-side will be computed on `Actor1` and send to `Actor2` where it will be stored in variable `<var_name>`.
**ACHTUNG!! `mix format` will rewrite `Actor1.var1` to `Actor1.var1()` which is a function call instead of a variable! Wrap variables in parens like `Actor1.(var1)` if you want to use `mix format`!** This is an unfortunate drawback—suggestions on fixing this would be welcome.
Local functions are not defined as part of the choreography; instead, you implement these in a separate Elixir module. More on that later.
```elixir
if Actor1.make_decision() do
Actor1[L] ~> Actor2
...
else
Actor1[R] ~> Actor2
...
end
```
`if` expressions are supported. Some actor makes a choice of which branch to go down. It is then *crucial* (and, at this point, entirely up to the user) that that deciding actor inform all other actors about the choice of branch with the special `ActorName[L] ~> OtherActorName` syntax. Note the lack of `.` and variable names. Furthermore, the true branch is always `L` (left) and the false branch is always `R` (right).
```elixir
def higher_order_chor(other_chor) do
... other_chor.(...) ...
end
```
Chorex supports higher-order choreographies. These are choreographies that take another choreography as an argument where it can be applied like a function.
```elixir
def some_local_chor(Actor.(var_name)) do
Actor.(var_name) ~> OtherActor.(other_var)
OtherActor.(other_var)
end
```
This creates a choreography that can be passed as an argument to the `higher_order_chor` function. This takes as an argument a variable living at a particular actor, and returns another value on a potentially different node.
You would combine the choreographies like so:
```elixir
defchor [Actor, OtherActor] do
def higher_order_chor(other_chor) do
... other_chor.(...) ...
end
def some_local_chor(Actor.(var_name)) do
Actor.(var_name) ~> OtherActor.(other_var)
OtherActor.(other_var)
end
higher_order_chor(&some_local_chor/1)
end
```
Right now these functions are limited to a single argument.
```elixir
with OtherActor.(other_var) <- other_chor.(Actor.(var)) do
...
end
```
You can use `with` to bind a variable to the result of calling a higher-order choreography. Note that right now you can only have one `<-` in the expression.
## Creating a choreography
To create a choreography, start by making a module, and writing the choreography with the `defchor` macro.
```elixir
defmodule Bookstore do
defchor [Actor1, Actor2] do
Actor1.(... some expr ...) ~> Actor2.(some_var)
Actor2.some_computation(some_var) ~> Actor1.(the_result)
...
end
end
```
You will need to make a module for every actor you specify at the beginning of `defchor` and mark which actor you&rsquo;re implementing like so:
```elixir
defmodule MyFirstActor do
use Bookstore.Chorex, :actor1
...
end
defmodule MySecondActor do
use Bookstore.Chorex, :actor2
def some_computation(val), do: ...
end
```
These modules will need to implement all of the local functions specified in the choreography. Chorex will use Elixir&rsquo;s behaviour mechanism to warn you if you don&rsquo;t implement every function needed. In the above example, the `MySecondActor` implements the role of `Actor2` in the choreography, and therefore needs to implement the `some_computation` function.
**Note:** *Actor names do not need to be the same as the modules implementing them!* It is *useful* to do that, but there exist instances where you might want to write one choreography and implement it in different ways.
## Running a choreography
To fire off the choreography, you need to spin up a process for each actor and then tell each actor where to find the other actors in the system. For the above example, you could do this:
```elixir
first_actor = spawn(MyFirstActor, :init, [])
second_actor = spawn(MySecondActor, :init, [])
config = %{Actor1 => first_actor, Actor2 => second_actor, :super => self()}
send(first_actor, config)
send(second_actor, config)
```
Once the actors are done, they will send the last value they computed to `:super` tagged with the actor they were implementing. So, for this example, you could see what `Actor1` computed by awaiting:
```elixir
receive do
{:choreography_return, Actor1, val} -> IO.inspect(val, label: "Actor1's return: ")
end
```
## Using a choreography with the rest of your project
The local functions are free to call any other code you have—they&rsquo;re just normal Elixir. If that code sends and receives messages not managed by the choreography library, there is no guarantee that this will be deadlock-free.
# Development
## Changelog
We will collect change descriptions here until we come up with a more stable format when changes get bigger.
- v0.1.0; 2024-05-30
Initial release. Lots of rough edges so please, be patient. :)
## High-level internals
The `defchor` macro is implemented in the `Chorex` module.
- The `defchor` macro gathers a list of actors.
- For each actor, call `project` on the body of the choreography. The `project` function keeps track of the current actor as the &ldquo;label&rdquo; variable. (This vernacular borrowed from the academic literature.)
- The functions `project` and `project_sequence` are mutually recursive: `project_sequence` gets invoked whenever `project` encounters a block with multiple instructions.
- The `project` function walks the AST, it gathers a list of functions that will need to be implemented by each actor&rsquo;s implementing module, as well as a list of top-level functions for each projection.
- This gathering is handled by the `WriterMonad` module, which provides the `monadic do ... end` form as well as `return` and `mzero`.
- Finally the macro generates modules for each actor under the `Chorex` module it generates.
So, for example, if you have a simple Choreography like this:
```elixir
defchor [Alice, Bob] do
Alice.pick_modulus() ~> Bob.(m)
Bob.gen_key(m) ~> Alice.(bob_key)
Alice.encrypt(message, bob_key)
end
```
This will get transformed into (roughly) this code:
```elixir
defmodule Chorex do
(
def alice do
quote do
import Alice
@behaviour Alice
def init() do
Alice.init(__MODULE__)
end
end
end
defmodule Alice do
@callback encrypt(any(), any()) :: any()
@callback pick_modulus() :: any()
def init(impl) do
receive do
{:config, config} ->
ret = run_choreography(impl, config)
send(config[:super], {:choreography_return, Alice, ret})
end
end
def run_choreography(impl, config) do
if function_exported?(impl, :run_choreography, 2) do
impl.run_choreography(impl, config)
else
send(config[Bob], impl.pick_modulus())
(
bob_key =
receive do
msg -> msg
end
impl.encrypt(message, bob_key)
)
end
end
end
)
(
def bob do
quote do
import Bob
@behaviour Bob
def init() do
Bob.init(__MODULE__)
end
end
end
defmodule Bob do
@callback gen_key(any()) :: any()
def init(impl) do
receive do
{:config, config} ->
ret = run_choreography(impl, config)
send(config[:super], {:choreography_return, Bob, ret})
end
end
def run_choreography(impl, config) do
if function_exported?(impl, :run_choreography, 2) do
impl.run_choreography(impl, config)
else
m =
receive do
msg -> msg
end
send(config[Alice], impl.gen_key(m))
end
end
end
)
defmacro __using__(which) do
apply(__MODULE__, which, [])
end
end
```
You can see there&rsquo;s a `Chorex.Alice` module and a `Chorex.Bob` module.
## Testing
Simply clone the repository and run `mix test`.
<a id="org44e5aee"></a>
# Bibliography
- Hirsch & Garg (2022-01-16) *Pirouette: Higher-Order Typed Functional Choreographies*, Proceedings of the ACM on Programming Languages. <https://doi.org/10.1145/3498684>
- Lugović & Montesi (2023-10-15) *Real-World Choreographic Programming: Full-Duplex Asynchrony and Interoperability*, The Art, Science, and Engineering of Programming. <https://doi.org/10.22152/programming-journal.org/2024/8/8>
# Authors
This is a project by the [Utah PLT](https://github.com/utahplt) group. Primary development by [Ashton Wiersdorf](https://lambdaland.org).