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:

  1. The module name of the choreography,
  2. A map from actor name to implementation name, and
  3. 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.

Walks a local expression to pull out/convert function calls.

Functions

Link to this function

actor_from_local_exp(actor_alias, env)

View Source

Get the actor name from an expression

iex> Chorex.actor_from_local_exp((quote do: Foo.bar(42)), __ENV__)
{:ok, Foo}
Link to this macro

defchor(actor_list, list)

View Source (macro)

Define a new choreography.

See the documentation for the Chorex module for more details.

Link to this function

do_local_project_wrapper(code, acc, env, label, ctx)

View Source

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.

Link to this function

project(expr, env, label, ctx)

View Source
@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:

  1. Elixir AST term to project.
  2. Macro environment.
  3. Name of the actor currently under projection. Atom.
  4. 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:

  1. The projected term. Elixir AST.
  2. A list of callback specifications for this actor. (Functions the actor implementer needs to have.)
  3. List of auxiliary functions generated during the projection process.
Link to this function

project_identifier(stx, env, label)

View Source

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.

Link to this function

project_local_expr(stx, env, label, ctx)

View Source

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.

Link to this function

project_sequence(expr, env, label, ctx)

View Source
@spec project_sequence(term(), Macro.Env.t(), atom(), map()) :: WriterMonad.t()

Project a sequence of expressions, such as those found in a block.

Link to this function

start(chorex_module, actor_impl_map, init_args)

View Source

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 },
             [])
Link to this function

walk_local_expr(code, env, label, ctx)

View Source

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 as 1 + impl.foo() and foo/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 as impl.some_func(&impl.other_local_func/2) and both some_func and other_local_func need to be added to the list of functions for the Alice behaviour.

  • Alice.(1 + Enum.sum(...)) should not be rewritten as impl.….

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.