View Source Chorex (Chorex v0.2.0)

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
    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

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 Seller do
  use ThreePartySeller.Chorex, :seller

  def get_price(book_name), do: ...
  def get_delivery_date(book_name, addr), do: ...
end

defmodule Buyer1 do
  use ThreePartySeller.Chorex, :buyer1

  def get_book_title(), do: ...
  def get_address(), do: ...
  def get_budget(), do: ...
end

defmodule Buyer2 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.

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})

assert_receive {:chorex_return, Buyer1, ~D[2024-05-13]}

Each of the parties will try sending the last value they computed once they're done running.

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

    bookseller(&two_party/1)
  end
end

This will run the two-buyer scenario by default. If you want to cut the second buyer out of the picture, define a function called run_choreography for the buyer and seller actors and have them compose the one_party and bookseller functions.

defmodule MySeller31 do
  use TestChor3.Chorex, :seller3

  def get_delivery_date(_book, _addr) do
    ~D[2024-05-13]
  end

  def get_price("book:Das Glasperlenspiel"), do: 42
  def get_price("book:Zen and the Art of Motorcycle Maintenance"), do: 13

  def run_choreography(impl, config) do
    Seller3.bookseller(impl, config, &Seller3.one_party/3)
  end
end

defmodule MyBuyer31 do
  use TestChor3.Chorex, :buyer3

  def get_book_title(), do: "Zen and the Art of Motorcycle Maintenance"
  def get_address(), do: "Maple Street"
  def get_budget(), do: 22

  def run_choreography(impl, config) do
    Buyer3.bookseller(impl, config, &Buyer3.one_party/3)
  end
end

It's important to remember to pass impl and config around. These are internal to the workings of the Chorex module, so do not modify them.

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
  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

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 the shared-state choreography

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.

Perform the control merge function, but flatten block expressions at each step

Perform endpoint projection in the context of node label.

Like project/3, 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.

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.

Link to this function

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

View Source
Link to this macro

is_immediate(x)

View Source (macro)

Perform the control merge function, but flatten block expressions at each step

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_global_func(arg, body, env, label, ctx)

View Source
Link to this function

project_local_expr(arg, env, label, ctx)

View Source

Like project/3, 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_local_func(arg, body, env, label, ctx)

View Source
Link to this function

project_sequence(expr, env, label, ctx)

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

walk_local_expr(code, env, label, ctx)

View Source