What is this ?

I wanted to combine two great libraries for concurrent work: Zeromq and Celluloid. Foreign Actor is my attempt at building a simple way to distribute celluloid actors in multiple processes/machines, the idea is to be able to move an actor to another process with no change to the actor itself and minimal changes on the server and client side code.

How ?

I used the Zeromq library to achieve my goal using XREQ/XREP sockets, this allows multiple clients with multiple workers each clients being able to make calls with or without a return value.

Here is what it looks like:

In this picture the square boxes are processes while the rounded box are the services (actors). In a standard client/server interaction one client connects to one or more server and to do so needs to know the addresses of all of them, in our case the router process is here to simplify this: both clients and workers connects to it which allows as a direct result things like adding a worker in live, you just start it, it connects to the router and it can starts processing requests immediately. Something to note is that each of these processes can run on a different physical machine allowing effectively to distribute jobs on multiple cores as well as multiple machines.

Constraints

The methods arguments are serialized using MessagePack so obviously they need to be serializable by it, which means that you can only send "basic types" to a foreign actor, do not fear though since messagepack allows everything you need:

  • Integers
  • Floats (ex: 2.34)
  • Strings (ex: "a cat")
  • Hash
  • Boolean
  • Nil
  • Array (ex: [1, "a cat", : nil])

Why not going with Marshal ? Because I don't want to close the door to another language, you can well imagine calling a service which is in fact provided bya C server, a python server or anything else, I just saw no reason to close that door.
Another problem I have with Marshal is that the format could well change in a future ruby version which would force users to switch both the server and the client at the same time.

Examples

You can look at the example and example2 folder but here is the code of example1:

Client

require 'rubygems'
require 'bundler/setup'

require 'foreign_actor'

# disable buffering to see our logs with foreman
$stdout.sync = true

# Here we create a supervision group to start the reactor which
# is an actor specific to foreign_actor and which is required.
# The reactor need to be named since the client will access
# it by its name which is :xs_reactor by default.
class ClientGroup < Celluloid::SupervisionGroup
  supervise ForeignActor::Reactor, :as => :xs_reactor

end

ClientGroup.run!

# we create the "client" and give it the address to connect
# to (defined in router.yml as the front address)
cl = ForeignActor::Client.new('tcp://127.0.0.1:7000')

# and now we have standard celluloid code which
# does what you would expect.

# run an async task
cl.async.do_it(0)

loop do
  f = []

  # run a synchronous task and display the result
  p cl.do_it(2)

  # use future to run 4 tasks in parallel
  # and display their results
  started_at = Time.now
  4.times {|n| f << cl.future.do_it(n) }

  p f.map(&:value)

  elapsed = (Time.now - started_at)
  puts "time: #{elapsed} seconds"
end

Server

require 'rubygems'
require 'bundler/setup'

require 'foreign_actor'

# disable buffering to see our logs with foreman
$stdout.sync = true

class Worker
  include Celluloid

  def initialize(endpoint)
    # register this actor as a worker, this allows the actor to
    # handle requested received on this endpoint (defined in
    # router.yml as the back address)
    Actor[:xs_reactor].serve_actor!(endpoint, Actor.current)
  end

  def do_it(n)
    # force the process to sleep, we do not wait
    # to use the celluloid sleep here
    Kernel.sleep 1

    "response #{$$}"
  end

end

# define our reactor actor and our worker, we pass it the endpoint
# to connect to but you may well hardcode above or pass it anyway
# you like.
class RootGroup < Celluloid::SupervisionGroup
  supervise ForeignActor::Reactor, :as => :xs_reactor
  supervise Worker, :as => :worker1, args: ['tcp://127.0.0.1:7001']

end

RootGroup.run!

puts "Worker started."

trap("INT") { Celluloid.shutdown; exit }
sleep

Router configuration

endpoint1:
  front: 'tcp://*:7000'
  back: 'tcp://*:7001'

You then run the client and server as normal:

ruby client.rb
ruby worker.rb

And you need a router:

# by default the router.yml file in the current directory will
# be used if none specified
bundle exec router router.yml