Feature #17365
closedCan not respond (well) to a Ractor
Description
Summary: currently there is no good way to return a response to a Ractor message.
Sorry, this is long.
Points 1 to 3 look at possible current solutions and why they aren't great.
Point 4 discusses how Elixir/Erlang's builtin filtering allows responses.
Last point proposes one of the many APIs that would allow responses.
Details:
If I want to program a "server" using Ractor, there has to be some way to receive the data from it.
To simplify, say I want a global Config
that can be used to set/retrieve some global config parameters.
To set a parameter, I can use server.send [:set, :key, 'value']
.
But what about retrieving? There is no good way to achieve that with the current API.
- "pull" API
It is not safe, as two clients could send a :set
before the server answers, and the clients could resolve their server.take
in the reverse order.
Another issue is that Ractor.yield
is blocking, so the unexpected death of the client could mean the server hangs, and subsequent requests/responses are desynchronized and thus wrong.
My impression is that the "pull" API is best only used for monitoring of Ractors, rescuing exceptions, etc., or otherwise reserved for Ractors that are not shared, is this correct?
- "push" API
It seems much more appropriate to design a server such that one sends the client ractor with the push API. E.g. the client calls server.send [:retrieve, :key, Ractor.current]
; the server can use the last element cient_ractor
to respond with client_ractor.send 'value'
that is non-blocking.
The client can then call Ractor.receive
, immediately or later, to get the answer.
This is perfect, except that the client can not use Ractor.receive
for any other purpose. It can not act itself a server, or if it calls multiple servers then it must do so synchroneously. Otherwise it might receive
a request for something other than the response it was waiting for.
- create Ractor + "push" + "pull"
The only way I can think of currently is to create a temporary private Ractor (both to be able to use the "pull" and the "push" API):
# on the client:
response = Ractor.new(server, *etc) { |server, *etc|
server.send [:retrieve, :key, Ractor.current].freeze
Ractor.yield(Ractor.receive, move: true)
}.take
# on the server
case Ractor.receive
in [:retrieve, key, client_ractor]
client_ractor.send('response')
# ...
end
I fear this would be quite inefficient (one Ractor per request, extra move
of data) and seems very verbose.
- Filtered
receive
If I look at Elixir/Erlang, this is not an issue because the equivalent of Ractor.receive
has builtin pattern matching.
The key is that unmatched messages are queued for later retrieval. This way there can be different Ractor.receive
used in different ways in the same Ractor and they will not interact (assuming they use different patterns).
For a general server ("gen_server"), a unique tag is created for each request, that is sent with the request and with the response
The same pattern is possible to implement with Ruby but this can only work if as long as all the Ractor.receive
use this implementation in a given Ractor, it has to be thread-safe, etc.
Issue is that it may not be possible to have the same protocol and access to the same receive
method, in particular if some of the functionality is provided in a gem.
- In conclusion...
The API of Ractor
is currently lacking a good way to handle responses.
It needs to allow filtering/subdivision of the inbox in some way.
One API could be to add a tag: nil
parameter to Ractor#send
and Ractor.receive
that would use that value to match.
A server could decide to use the default nil
tag for it's main API, and ask its clients to specify a tag for a response:
my_tag = :some_return_tag
server.send(:retrieve, :key, Ractor.current, my_tag)
Ractor.receive tag: my_tag
# on the server
case Ractor.receive
in [:retrieve, key, client_ractor, client_tag]
client_ractor.send('response', tag: client_tag)
# ...
end
Tags would have to be Ractor-shareable objects and they could be compared by identity.
Note that messages sent with a non-nil tag (e.g. send 'value' tag: 42
) would not be matched by Ractor.receive
.
Maybe we should allow for a special tag: :*
to match any tag?
There are other solutions possible; a request ID could be returned by Ractor#send
, or there could be an API to create object for returns (like a "Self-addressed stamped envelope"), etc.
The basic filtering API I'm proposing has the advantage of being reasonable easy to implement efficiently and still allowing other patterns (for example handling messages by priority, assuming there can be a 0 timeout, see #17363), but I'll be happy as long as we can offer efficient and reliable builtin ways to respond to Ractor messages.