OTP-style supervision trees for Ruby Ractors with declarative restart strategies and graceful shutdown.
ractor_shepherd brings Erlang/OTP-style supervision to Ruby's Ractor API.
Supervisors watch child Ractors, restart them according to a policy, and
escalate failures when restarting stops helping.
- Static and dynamic supervisors with composable supervision trees
:one_for_one,:one_for_all, and:rest_for_onerestart strategies:permanent,:transient, and:temporarychild restart policies- Low-level
Workerloops or GenServer-styleServerworkers - Restart intensity limits and configurable restart backoff
- Event ports, a built-in logger, and crash hints for common Ractor errors
- Cooperative shutdown with per-child timeouts
- Ruby 4.0 or later, with no runtime dependencies
Add to your Gemfile:
gem "ractor_shepherd"Then install:
bundle install- Ruby 4.0+
Ractor::Port
require "ractor_shepherd"
class Counter
include RactorShepherd::Server
def initialize(start = 0)
super()
@count = start
end
def handle_call(message)
case message
in :get then @count
end
end
def handle_cast(message)
case message
in [:add, n] then @count += n
in :crash then raise "boom"
end
end
end
events = Ractor::Port.new
RactorShepherd::EventLogger.start(events) # one line per event on stderr
RactorShepherd.run(name: :root, strategy: :one_for_one, max_restarts: 3, max_seconds: 5,
event_port: events,
children: [RactorShepherd.worker(:counter, Counter, args: [0])]) do |sup|
counter = sup.lookup(:counter)
counter.cast([:add, 2])
counter.call(:get, timeout: 5) #=> 2
counter.cast(:crash) # it dies, and is restarted with its state back at zero
endThe block form starts the root supervisor, yields a SupervisorRef, and stops
the supervisor when the block exits. lookup returns an address that follows a
child across restarts.
Use Worker when you need to control the receive loop yourself:
class Poller
include RactorShepherd::Worker
def initialize(url, interval)
super()
@url = url
@interval = interval
end
def run(ctx)
until ctx.shutdown_requested?
fetch(@url)
ctx.sleep(@interval) # wakes as soon as shutdown is requested
end
end
def terminate(reason) = nil # :normal, :shutdown, or the exception that killed the worker
endThe Context provides:
| Method | Description |
|---|---|
id / path |
The child's id and path, for example root/jobs/poller |
supervisor |
The parent SupervisorRef |
receive(timeout: nil) |
Receive one message; nil on timeout; raises ShutdownSignal during shutdown |
shutdown_requested? |
Whether shutdown has been requested |
sleep(seconds) |
Sleep while remaining interruptible by shutdown |
emit(name, **data) |
Publish an application event to the event port |
Use Server for a GenServer-style worker. Its receive loop is already written;
implement handle_call for synchronous requests and handle_cast for
fire-and-forget messages.
class Echo
include RactorShepherd::Server
def handle_call(message) = message
end
sup = RactorShepherd.start(name: :root,
children: [RactorShepherd.worker(:echo, Echo)])
begin
echo = sup.lookup(:echo)
echo.call(:hello, timeout: 5) #=> :hello
echo.cast(:ignored)
ensure
sup.stop
endWith children [a, b, c, d], when b dies:
| Strategy | Stopped, reverse start order | Started, start order |
|---|---|---|
:one_for_one |
— | b |
:one_for_all |
d, c, a |
a, b, c, d |
:rest_for_one |
d, c |
b, c, d |
The restart: option decides whether a child is restarted:
| Restart kind | Exited on its own | Crashed |
|---|---|---|
:permanent (default) |
Restart | Restart |
:transient |
Leave stopped | Restart |
:temporary |
Drop the child spec | Drop the child spec |
After more than max_restarts restarts within max_seconds, the supervisor
crashes so its parent can decide what happens next. At the root,
SupervisorRef#join raises SupervisorCrashed.
Use start_dynamic for children that come and go, such as connections or jobs:
sup = RactorShepherd.start_dynamic(name: :jobs, max_children: 5)
begin
id = sup.start_child(
RactorShepherd.worker(nil, Job, args: ["job-1"], restart: :transient)
)
sup.terminate_child(id)
ensure
sup.stop
endDynamic supervisors use :one_for_one and start with no children. max_children
is optional; nil means unlimited.
Pass an event port to start or run and either use the built-in logger or
consume the event hashes yourself:
events = Ractor::Port.new
RactorShepherd::EventLogger.start(events)
sup = RactorShepherd.start(name: :root, event_port: events,
children: [RactorShepherd.worker(:counter, Counter)])Events include child starts and exits, restart scheduling, unresponsive
children, and restart-intensity failures. Crash events include the exception
class, message, backtrace, and a hint for common Ractor errors.
For custom reporting, read from the port in the Ractor that created it. See
examples/crash_report.rb for a complete example.
- Child specs are validated and copied into shareable data.
- A supervisor starts children in declaration order and monitors their Ractors.
- Each child runs as either a low-level
Workeror a message-drivenServer. - When a child exits, the supervisor applies the declared strategy and restart policy.
- When restart intensity is exceeded, the failure is escalated to the parent supervisor.
Supervisors can themselves be children, so trees compose naturally:
RactorShepherd.run(name: :root, children: [
RactorShepherd.worker(:cache, Cache),
RactorShepherd.supervisor(:jobs, strategy: :rest_for_one, children: [
RactorShepherd.worker(:fetcher, Fetcher),
RactorShepherd.worker(:parser, Parser)
])
]) do |sup|
sup.lookup(:jobs, :parser).cast(:refresh)
end| Entry point | Purpose |
|---|---|
RactorShepherd.start |
Start a static root supervisor |
RactorShepherd.start_dynamic |
Start an empty dynamic supervisor |
RactorShepherd.run |
Start, yield, and stop a root supervisor |
RactorShepherd.worker |
Describe a worker child |
RactorShepherd.supervisor |
Describe a nested static supervisor |
RactorShepherd.dynamic_supervisor |
Describe a nested dynamic supervisor |
From a SupervisorRef, use lookup, whereis, which_children, and
count_children to inspect the tree. Use start_child, terminate_child,
restart_child, and delete_child to manage static children at runtime.
An Address sends cast messages without waiting and uses call for a
synchronous reply from a Server. call does not retry automatically because
the request may not be idempotent.
- Ractors are experimental. The first
Ractor.newprints a warning. Silence it withWarning[:experimental] = false. - Ractors cannot be killed from outside. Shutdown is cooperative. A child spinning on the CPU or blocked inside a C call cannot be stopped; after
shutdown_timeoutit is treated as unresponsive. - Worker arguments must be shareable.
args:andkwargs:are copied withRactor.make_shareable(copy: true). Procs, Threads, and Mutexes do not cross Ractors; pass plain values and put behavior in a class. - Some gems are not Ractor safe. An unsafe C extension raises
Ractor::UnsafeError; an unshareable object raisesRactor::IsolationError. The event log includes a hint for these errors. - Symbols starting with
:"$"are reserved. Do not start application messages with one.
- Do not call
valueorjoinon a child. Once another Ractor has taken a Ractor's value, the supervisor cannot read the exit reason and reports:unknown. Restarting still works. - Do not call the supervisor synchronously from
initialize. The supervisor is waiting for the child to finish starting, so the call deadlocks untilstart_timeoutfires. Look siblings up fromruninstead. - Restarting resets state. Anything that must survive belongs outside the child.
- Delivery is at-most-once. A message that arrived just before the child died can be lost.
callis not for hot paths. Each call creates a port and timer thread. In the included benchmark,castis roughly 1.1M messages/s whilecallis roughly 30k requests/s. Usecastor your ownRactor::Portfor frequent messages; seebench/RESULTS.md.- Stopping is sequential. It can take as long as the sum of every child's
shutdown_timeout, because children stop one at a time in reverse order.
Ruby 4.0.6's Ractor#unmonitor(port) looks up a monitor registration by port
id alone and ignores which Ractor created the port. Since port ids are per
Ractor, unmonitoring one registration can remove another Ractor's registration.
Reproduce it with spike/unmonitor_id_collision.rb.
This gem therefore never calls unmonitor. If a callee dies during a call, the
caller learns about it after the timeout instead of immediately. Do not call
unmonitor on a Ractor managed by this gem.
Do not touch ports from inside a trap handler; call stop from a thread instead:
Signal.trap("TERM") { Thread.new { sup.stop(:shutdown, timeout: 10) } }When the main Ractor ends, the process ends and takes every Ractor with it.
Use the block form of RactorShepherd.run or stop the supervisor from at_exit.
Runnable examples are in examples/:
| File | What it shows |
|---|---|
basic.rb |
one_for_one and automatic restarts |
tree.rb |
A supervision tree |
dynamic.rb |
Adding and removing children at runtime |
graceful_shutdown.rb |
Cooperative shutdown on a signal |
crash_report.rb |
Subscribing to events and reporting failures |
Run one directly:
ruby examples/basic.rbbundle install
bundle exec rake
bundle exec rake spec:core
bundle exec rake spec:stress
bundle exec rake spec:isolatedThe default task runs RuboCop, the no-loop lint, specs, and RBS validation.
Ractor compatibility is also checked with audition:
audition libReleased under the MIT License.