0.0
No commit activity in last 3 years
No release in over 3 years
Ractor based communication inspired by GenServer.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
 Dependencies
 Project Readme

Ractor::Server

Usage

Intro

This gem streamlines communication to a Ractor:

  • a "Server" that makes its methods available (think Elixir/Erlang's GenServer)
  • a "Client" that is immutable (Ractor shareable) and can call a "Server" from any Ractor.

Any class can include Ractor::Server and this automatically creates an interface:

class RactorHash < Hash
  include Ractor::Server
end

H = RactorHash.start # => starts Server, returns instance of a Client

Ractor.new { H[:example] = 42 }.take # => 42
puts Ractor.new { H[:example] }.take # => 42

Calls are atomic but also allow reentrant calls from blocks:

ractors = 3.times.map do |i|
  Ractor.new(i) do |i|
    H.fetch_values(:foo, :bar) do |val|
      H[val] = i
    end
  end
end

puts H # => {:example => 42, :foo => 0, :bar => 0}
       # (maybe 0 will be 1 or 2, but both will be same)

The first ractor to call fetch_values will have its block called twice; only the fetch_values has completed will the other Ractors have their calls to fetch_values run. The block is reentrant as it calls []=; that call will not wait.

Moreover, exceptions are propagated transparently between Client and Server.

H.fetch_values(:z) { raise ArgumentError } # => ArgumentError
  # (raised by Client, propagated to the Server, then back to the Client)

H.fetch_values(:z) # => KeyError
  # (raised by Hash#fetch_values) on the Server, propagated to the Client)

Block can also return, break or throw on the client-side and the server will be aware of that so that any ensure blocks are called and server doesn't hang expecting a return value.

class RactorHash
  def fetch_values(*)
    super
  ensure
    puts 'here'
  end
end

def foo
  H.fetch_values(:z) { return 42 }
end
foo # => 42, prints 'here'

The implementation relies on three layers of functionality.

Low-level API: Request

The first layer is the concept of a Request that uniquely identifies a message sent to a Ractor.

This enables a way to safely reply to a request:

using Ractor::Server::Talk

ractor = Ractor.new do
  request, data = receive_request
  puts data # => :example
  request.send(:hello)
end

request = ractor.send_request(:example)
response_request, result = request.receive
puts result # => :hello

Request is an envelope

The Request itself contains no data other than the initiating Ractor (Request#initiating_ractor) and if it is a reply to another Request (Request#response_to):

request.initiating_ractor == Ractor.current # => true
response_request.initiating_ractor == ractor # => true
request.response_to # => nil
response_request.response_to # => request

Note that a Request is immutable and thus Ractor-shareable irrespective of the data that accompanies it.

Nesting Requests

One may reply to a Request any number of times; it is up to the requester to receive the proper amount of times.

A response to a Request is itself a Request; Requests may be nested as deeply as required:

# as above...
ractor = Ractor.new do
  request, data = receive_request
  puts data # => :example
  request.send(:hello)
  response_request = request.send(:world)
  other_request, data = response_request.receive
  puts data # => :inner
  # ...
end

request = ractor.send_request(:example)
_request, result = request.receive
puts result # => :hello
response_request, result = request.receive
puts result # => :world
response_request.send(:inner)

The method receive_request will only receive a Request that was sent with send_request and thus is not a response to another Request.

The method Request#receive will only receive a Request that is a direct response to the receiver.

Exceptions

Instead of responsing with data, it is possible to respond by raising (on the remote side) an error with send_exception.

Calling send_exception wraps the original exception in a Ractor::Remote:

ractor = Ractor.new do
  request, data = receive_request
rescue ArgumentError => e
  puts e # => 'example' (ArgumentError)
end

ractor.send_exception(ArgumentError.new('example'))
ractor.take

Calling send_exception again on the wrapped Ractor::Remote will unwrap it. This way, if an exception travels from the client to the server and back to the client, this voyage will be transparent to the client.

Implementation

send_request / receive_request use Ractor#send and Ractor#receive_if with the following layout:

message = [Request, ...]

To avoid interfering with Request, any other Ractor communication must use receive_if and filter out messages of that form (i.e. any array starting with an instance of Request).

Mid-level API: Talk using sync:

One may specify the expected syncing for a Request:

  • :tell: receiver may not reply ("do this, I'm assuming it will get done")
  • :ask: receiver must reply exactly once with sync type :conclude ("do this, let me know when done, and don't me ask questions")
  • :conclude: as with :tell, receiver may not reply. Must be in response of ask or converse
  • :converse: receiver may reply has many times as desired (with sync type :tell, :ask, or :converse) and must then reply exactly once with sync type :conclude. ("do this, ask questions if need be, and let me know when done"). Exception: if the receiver replies with a converse, both converse requests may be interrupted with a response of sync interrupt; the receiver may no longer reply, not even for the final :conclude.
  • :interrupt: acts as a kind of "double conclude". Not only should receiver not reply, but outer conclude.

The API uses send_request/send with a sync: named argument:

ractor = Ractor.new do
  request, data = receive_request
  puts data # => :example
  request.send(:hello, sync: :tell)
  response_request = request.send(:world, sync: :ask)
  other_request, data = response_request.receive
  puts data # => :inner
  # ...
end

request = ractor.send_request(:example, sync: :converse)
response_request, result = request.receive
puts result # => :hello
puts response_request.sync # => :tell
response_request.send(:whatever, sync: :conclude) # => Error "can not reply to sync: say"
response_request, result = request.receive
puts result # => :world
request.receive # => Error, "request must be replied to"
response_request.send(:inner, sync: :conclude)

This example achieves exactly the same as before, but with clear semantics and checking on the sequence of events.

Shortcuts exists:

ractor.send_request(..., sync: :tell) # or :ask, :conclude or :converse
# shortcuts:
ractor.tell(...)  # or .ask(...), .conclude(...) or .converse(...)

# Similarly for `Request#send`:
request.send(..., sync: :tell)
# same as
request.tell(...)
# etc.

High-level API: Client & Server

The Client and Server module make it easy to use the sync: API to allow a client to call methods on the server and for the server to yield back to the client.

The client makes a method call using either :tell or :ask and the data consists of the method name, arguments and keyword parameters. The result is either the request (:tell) or the data received (:ask).

For method calls with blocks, the client uses :converse. The server may yield back to the client with a nested :converse response. From inside the block, the client can send nested calls to the server (simple or with block). The result of the block is returned to the server with :conclude. The server may then yield again, or if it is finished it concludes the outer conversation.

To define a server, it suffices to define the methods that may be called normally and use yield if desired.

All public methods are assumed to be callable from a client with :ask, except setters that are assumed to be called with :tell.

Here is a complete example of how to define a Server that can hold a value:

class SharedObject < Ractor::Client
  class Server
    include Ractor::Server

    attr_accessor :value

    def initialize(value = nil)
      @value = value
    end

    def update
      @value = yield @value
    end
  end
end

LIST = ShareObject.new([1, 2])

Ractor.new do
  LIST.value # => [1, 2]
  LIST.value = [:changed]
end.take

LIST.value # => [:changed]
LIST.update do |cur|
  cur << :extra
end
LIST.value # => [:changed, :extra]

Note that update in the example above is atomic; if another Ractor calls LIST.<anything>, that request will wait until the update is completed. Nevertheless, calls issued from inside the update block will be processed synchroneously.

Defining classes

To create a Server class:

class MyServer
  include Ractor::Server

  # define your methods...
end

This adds a few methods (#main_loop, #receive_request and #process_request) as well as class methods tells (private).

This automatically defines a Client class and a Client::ServerCallLayer module; these may be subclassed/included if desired:

class MyClient < MyServer::Client

  # special handling (if needed)
end

Note that subclass defines a method initialize(...) that:

  • starts the server
  • make itself shareable

An equivalent way to declare a Client is:

class MyClient < Ractor::Client
  include MyServer::Client::ServerCallLayer

  # special handling (if needed)
end

Customizing the client

It may be necessary to customize the Client interface.

For example in the SharedObject example above, it may be more efficient if the held object is always shareable. This can be done by customizing the client:

class SharedObject
  # ... as above

  class Client # refine the client interface:
    def initialize(value = nil)
      Ractor.make_shareable(value)
      super
    end

    def update
      super { Ractor.make_shareable(yield) }
    end
  end
end

In this case, the update block above would raise a FrozenError and must be modified to a non-mutating form:

LIST.update do |cur|
  cur + [:extra]
end

Customizing the sync

If we wanted to add a method clear to our server, there is no real need for the client to wait for the response as the result will not be useful. To specify that the method should be called with :tell instead of :ask, one may call tells :clear, or use the fact that def returns the method it defined:

class SharedObject
  # ...

  tells def clear
    @value = nil
  end
end

To do

  • API to pass block via makeshareable
  • Monitoring
  • Promise-style communication

Installation

Add this line to your application's Gemfile:

gem 'ractor-server'

And then execute:

$ bundle install

Or install it yourself as:

$ gem install ractor-server

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/marcandre/ractor-server. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.

License

The gem is available as open source under the terms of the MIT License.

Code of Conduct

Everyone interacting in the Ractor::Server project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.