View Source Chorex (Chorex v0.4.3)
Make your modules dance!
Chorex allows you to specify a choreography: a birds-eye view of an interaction of concurrent parties. Chorex takes that choreography creates a projection of that interaction for each party in the system.
Take, for example, the classic problem of a book seller and two buyers who want to split the price. The interaction looks like this:
+------+ +------+ +------+
|Buyer1| |Seller| |Buyer2|
+--+---+ +--+---+ +--+---+
| | |
| Book title | |
|--------------->| |
| | |
| Price | |
|<---------------| |
| | |
| | Price |
| |------->|
| | |
| Contribution |
|<------------------------|
| | |
| Buy/No buy | |
|--------------->| |
| | |
|(if Buy) address| |
|--------------->| |
| | |
| Shipping date | |
|<---------------| |
+--+---+ +--+---+ +--+---+
|Buyer1| |Seller| |Buyer2|
+------+ +------+ +------+
You can encode that interaction with the defchor
macro and DSL:
defmodule ThreePartySeller do
defchor [Buyer1, Buyer2, Seller] do
def run() do
Buyer1.get_book_title() ~> Seller.(b)
Seller.get_price("book:" <> b) ~> Buyer1.(p)
Seller.get_price("book:" <> b) ~> Buyer2.(p)
Buyer2.compute_contrib(p) ~> Buyer1.(contrib)
if Buyer1.(p - contrib < get_budget()) do
Buyer1[L] ~> Seller
Buyer1.get_address() ~> Seller.(addr)
Seller.get_delivery_date(b, addr) ~> Buyer1.(d_date)
Buyer1.(d_date)
else
Buyer1[R] ~> Seller
Buyer1.(nil)
end
end
end
end
The defchor
macro will take care of generating code that handles
sending messages. Now all you have to do is implement the local
functions that don't worry about the outside system:
defmodule MySeller do
use ThreePartySeller.Chorex, :seller
def get_price(book_name), do: ...
def get_delivery_date(book_name, addr), do: ...
end
defmodule MyBuyer1 do
use ThreePartySeller.Chorex, :buyer1
def get_book_title(), do: ...
def get_address(), do: ...
def get_budget(), do: ...
end
defmodule MyBuyer2 do
use ThreePartySeller.Chorex, :buyer2
def compute_contrib(price), do: ...
end
What the defchor
macro actually does is creates a module Chorex
and submodules for each of the actors: Chorex.Buyer1
,
Chorex.Buyer2
and Chorex.Seller
. There's a handy __using__
macro that will Do the right thing when you say use Mod.Chorex, :actor_name
and will import those modules and say that your module implements
the associated behaviour. That way, you should get a nice
compile-time warning if a function is missing.
Starting a choreography
Automatic startup
Invoke Chorex.start/3
with:
- The module name of the choreography,
- A map from actor name to implementation name, and
- A list of initial arguments.
So, you could start the choreography from the previous section with:
Chorex.start(ThreePartySeller.Chorex,
%{ Buyer1 => MyBuyer1,
Buyer2 => MyBuyer2,
Seller => MySeller },
[])
Manual startup
To start the choreography, you need to invoke the init
function in
each of your actors (provided via the use ...
invocation)
whereupon each actor will wait to receive a config mapping actor
name to PID:
the_seller = spawn(MySeller, :init, [[]])
the_buyer1 = spawn(MyBuyer1, :init, [[]])
the_buyer2 = spawn(MyBuyer2, :init, [[]])
config = %{Seller1 => the_seller, Buyer1 => the_buyer1, Buyer2 => the_buyer2, :super => self()}
send(the_seller, {:config, config})
send(the_buyer1, {:config, config})
send(the_buyer2, {:config, config})
Choreography return values
Each of the parties will try sending the last value they computed once they're done running. These messages will get set to whatever process kicked the the choreography off.
Chorex.start(ThreePartySeller.Chorex,
%{ Buyer1 => MyBuyer1,
Buyer2 => MyBuyer2,
Seller => MySeller },
[])
receive do
{:chorex_return, Buyer1, d_date} -> report_delivery(d_date)
end
Higher-order choreographies
Chorex supports higher-order choreographies. For example, you can define a generic buyer/seller interaction and abstract away the decision process into a higher-order choreography:
defmodule TestChor3 do
defchor [Buyer3, Contributor3, Seller3] do
def bookseller(decision_func) do
Buyer3.get_book_title() ~> Seller3.the_book
with Buyer3.decision <- decision_func.(Seller3.get_price("book:" <> the_book)) do
if Buyer3.decision do
Buyer3[L] ~> Seller3
Buyer3.get_address() ~> Seller3.the_address
Seller3.get_delivery_date(the_book, the_address) ~> Buyer3.d_date
Buyer3.d_date
else
Buyer3[R] ~> Seller3
Buyer3.(nil)
end
end
end
def one_party(Seller3.(the_price)) do
Seller3.(the_price) ~> Buyer3.(p)
Buyer3.(p < get_budget())
end
def two_party(Seller3.(the_price)) do
Seller3.(the_price) ~> Buyer3.(p)
Seller3.(the_price) ~> Contributor3.(p)
Contributor3.compute_contrib(p) ~> Buyer3.(contrib)
Buyer3.(p - contrib < get_budget())
end
def run(Buyer3.(get_contribution?)) do
if Buyer3.(get_contribution?) do
Buyer3[L] ~> Contributor3
Buyer3[L] ~> Seller3
bookseller(@two_party/1)
else
Buyer3[R] ~> Contributor3
Buyer3[R] ~> Seller3
bookseller(@one_party/1)
end
end
end
end
Notice the @two_part/1
syntax: the @
is necessary so Chorex
knows that this is a reference to a function defined inside the
defchor
block; it needs to handle these references specially.
Now, when you start up the choreography, the you can instruct the
choreography whether or not to run the three-party scenario. The
first item in the list of arguments will get sent to the node
running the Buyer3
behaviour and will be used in the decision
process inside the run
function.
Chorex.start(TestChor3.Chorex, %{ ... }, [true]) # run 3-party
Chorex.start(TestChor3.Chorex, %{ ... }, [false]) # run 2-party
Singletons managing shared state
Sometimes, you might want to share some state between different instances of the same choreography. The classic Elixir solution to managing shared state is to use a GenServer: processes interested in accessing/modifying the state send messages to the GenServer and await replies.
Chorex provides a mechanism to model this behavior in a choreography. Going back to our bookseller example, suppose there is a limited stock of books, and the seller must not sell a book twice. The stock of books is the shared state, and instances of the seller in the choreography need to be able to access this.
Here is how you define such a choreography:
defchor [Buyer, {Seller, :singleton}] do
def run() do
Buyer.get_book_title() ~> Seller.(b)
Seller.get_price(b) ~> Buyer.(p)
if Buyer.in_budget(p) do
Buyer[L] ~> Seller
if Seller.acquire_book(@chorex_config, b) do
Seller[L] ~> Buyer
Buyer.(:book_get)
else
Seller[R] ~> Buyer
Buyer.(:darn_missed_it)
end
else
Buyer[R] ~> Seller
Buyer.(:nevermind)
end
end
end
Saying {Seller, :singleton}
in the defchor
declaration indicates
that the Seller
actor is going to share some state. The Seller
actor can access this shared state in any function, though such
functions need to have the magic @chorex_config
variable passed to
them. (This is just a special symbol recognized by the Chorex
compiler.)
In the implementation, the Seller can access the state using the
Proxy.update_state
function:
defmodule MySellerBackend do
use BooksellerProxied.Chorex, :seller
alias Chorex.Proxy
def get_price(_), do: 42
def acquire_book(config, book_title) do
# Attempt to acquire a lock on the book
Proxy.update_state(config, fn book_stock ->
with {:ok, count} <- Map.fetch(book_stock, book_title) do
if count > 0 do
# Have the book, lock it for this customer
{true, Map.put(book_stock, book_title, count - 1)}
else
{false, book_stock}
end
else
:error ->
{false, book_stock}
end
end)
end
end
That's it! Now the seller won't accidentally double-sell a book.
The need for a proxy
Actors that share state do run as a separate process, but a GenServer that manages the state also acts as a proxy for all messages to/from the actor. This is so that operations touching the shared state happen in lockstep with progression through the choreography. We may investigate weakening this property in the future.
Setting up a shared-state choreography
You will need to start a proxy first of all:
{:ok, px} = GenServer.start(Chorex.Proxy, %{"Anathem" => 1})
The px
variable now holds the PID of a GenServer running the
Chorex.Proxy
module. Now we use this px
variable in the actor
map to set up the choreography:
Chorex.start(ProxiedBookseller.Chorex,
%{ Buyer => MyBuyer,
Seller => {MySellerBackend, px}},
[])
Note the 2-tuple: the first element is the module to be proxied, and the second element should be the PID of an already-running proxy.
Manually setting up the shared-state choreography (deprecated)
(Note: we recommend using the Chorex.start
mechanism now.)
You need to be a little careful when setting up the shared state choreography. Instead of setting up all the actors manually, you need to set up one instance of each shared-state actor, then create separate sessions for each instance of the choreography that you want to run.
Here is an example with two buyers trying to buy the same book:
# Start up the buyers
b1 = spawn(MyBuyer, :init, [[]])
b2 = spawn(MyBuyer, :init, [[]])
# Start up the seller proxy with the initial shared
# state (the stock of books in this case)
{:ok, px} = GenServer.start(Chorex.Proxy, %{"Anathem" => 1})
# Start sessions: one for each buyer
Proxy.begin_session(px, [b1], MySellerBackend, :init, [])
config1 = %{Buyer => b1, Seller => px, :super => self()}
Proxy.begin_session(px, [b2], MySellerBackend, :init, [])
config2 = %{Buyer => b2, Seller => px, :super => self()}
# Send everyone their configuration
send(b1, {:config, config1})
send(px, {:chorex, b1, {:config, config1}})
send(b2, {:config, config2})
send(px, {:chorex, b2, {:config, config2}})
The Proxy.begin_sesion
function takes a proxy function, a list of
PIDs that partake in a given session, and a module, function, arglist
for the thing to proxy.
Sessions: PIDs belonging to a session will have their messages routed to the corresponding proxied process. The GenServer looks up which session a PID belongs to, finds the proxied process linked to that session, then forwards the message to that process. The exact mechanisms of how this works may change in the future to accommodate restarts.
When you send the config information to a proxied process, you send it through the proxy first, and you must wrap the message as shown above with a process from the session you want to target as the second element in the tuple; this just helps the proxy figure out the session you want.
That's it! If you run the above choreography, the process that kicks
this all off will get one message like {:chorex_return, Buyer, :book_get}
and one message like {:chorex_return, Buyer, :darn_missed_it}
,
indicating that exactly one of the buyers got the coveted book.
Summary
Functions
Get the actor name from an expression
Define a new choreography.
Flatten nested block expressions as much as possible.
Perform the control merge function.
Perform endpoint projection in the context of node label
.
Project an expression like Actor.var
to either var
or _
.
Project local expressions of the form ActorName.(something)
.
Project a sequence of expressions, such as those found in a block.
Start a choreography.
Walks a local expression to pull out/convert function calls.
Functions
Get the actor name from an expression
iex> Chorex.actor_from_local_exp((quote do: Foo.bar(42)), __ENV__)
{:ok, Foo}
Define a new choreography.
See the documentation for the Chorex
module for more details.
Flatten nested block expressions as much as possible.
Turn something like
do
do
…
end
end
into simply
do
…
end
This is important for the merge/2
function to be able to tell when
two expressions are equivalent.
Perform the control merge function.
Flatten block expressions at each step: sometimes auxiliary blocks get created around bits of the projection; trim these out at this step so equivalent expressions look equivalent.
@spec project(term :: term(), env :: Macro.Env.t(), label :: atom(), ctx :: map()) :: WriterMonad.t()
Perform endpoint projection in the context of node label
.
This returns a pair of a projection for the label, and a list of behaviors that an implementer of the label must implement.
Arguments:
- Elixir AST term to project.
- Macro environment.
- Name of the actor currently under projection. Atom.
- Extra information about the expansion. Map. Currently contains just a list of actors that will be behind a proxy.
Returns an instance of the WriterMonad
, which is just a 3-tuple
containing:
- The projected term. Elixir AST.
- A list of callback specifications for this actor. (Functions the actor implementer needs to have.)
- List of auxiliary functions generated during the projection process.
Project an expression like Actor.var
to either var
or _
.
Project to var
when Actor
matches the label we're projecting to,
or _
so that whatever data flows to that point can't be captured.
Project local expressions of the form ActorName.(something)
.
Like project/4
, but focus on handling ActorName.(local_var)
,
ActorName.local_func()
or ActorName.(local_exp)
. Handles walking
the local expression to gather list of functions needed for the
behaviour to implement.
@spec project_sequence(term(), Macro.Env.t(), atom(), map()) :: WriterMonad.t()
Project a sequence of expressions, such as those found in a block.
Start a choreography.
Takes a choreography module like MyCoolThing.Chorex
, a map from
actor names to implementing modules, and a list of arguments to pass
to the run
function.
Example
Chorex.start(ThreePartySeller.Chorex,
%{ Buyer1 => MyBuyer1, Buyer2 => MyBuyer2, Seller => MySeller },
[])
Walks a local expression to pull out/convert function calls.
The expr
in Alice.(expr)
can almost be dropped directly into
the projection for the Alice
node. Here's where that almost
comes in:
Alice.(1 + foo())
needs to be rewritten as1 + impl.foo()
andfoo/0
needs to be added to the list of functions for the Alice behaviour.Alice.some_func(&other_local_func/2)
needs to be rewritten asimpl.some_func(&impl.other_local_func/2)
and bothsome_func
andother_local_func
need to be added to the list of functions for the Alice behaviour.Alice.(1 + Enum.sum(...))
should not be rewritten asimpl.…
.
There is some subtlety around tuples and function calls. Consider how these expressions and their quoted representations compare:
{:ok, foo}
→{:ok, {:foo, [], …}}
{:ok, foo, bar}
→{:{}, [], [:ok, {:foo, [], …}, {:bar, [], …}]}
ok(bar)
→{:ok, [], [{:bar, [], …}]}
It seems that 2-tuples have some special representation, which is frustrating.