ractor_shepherd 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +215 -0
- data/lib/ractor_shepherd/address.rb +100 -0
- data/lib/ractor_shepherd/core/backoff.rb +59 -0
- data/lib/ractor_shepherd/core/event.rb +67 -0
- data/lib/ractor_shepherd/core/restart_intensity.rb +32 -0
- data/lib/ractor_shepherd/core/restart_policy.rb +34 -0
- data/lib/ractor_shepherd/core/strategy_planner.rb +44 -0
- data/lib/ractor_shepherd/errors.rb +57 -0
- data/lib/ractor_shepherd/event_logger.rb +70 -0
- data/lib/ractor_shepherd/facade.rb +75 -0
- data/lib/ractor_shepherd/runtime/call.rb +89 -0
- data/lib/ractor_shepherd/runtime/child_runner.rb +64 -0
- data/lib/ractor_shepherd/runtime/child_state.rb +50 -0
- data/lib/ractor_shepherd/runtime/compat.rb +41 -0
- data/lib/ractor_shepherd/runtime/context.rb +118 -0
- data/lib/ractor_shepherd/runtime/protocol.rb +55 -0
- data/lib/ractor_shepherd/runtime/supervisor_server.rb +506 -0
- data/lib/ractor_shepherd/runtime/timer.rb +53 -0
- data/lib/ractor_shepherd/server.rb +62 -0
- data/lib/ractor_shepherd/spec.rb +238 -0
- data/lib/ractor_shepherd/supervisor_ref.rb +104 -0
- data/lib/ractor_shepherd/version.rb +5 -0
- data/lib/ractor_shepherd/worker.rb +33 -0
- data/lib/ractor_shepherd.rb +37 -0
- data/sig/ractor_shepherd.rbs +179 -0
- metadata +72 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RactorShepherd
|
|
4
|
+
# Reads an event port and writes each event to a logger.
|
|
5
|
+
#
|
|
6
|
+
# events = Ractor::Port.new
|
|
7
|
+
# RactorShepherd::EventLogger.start(events) # to stderr
|
|
8
|
+
# RactorShepherd::EventLogger.start(events, logger: Logger.new($stdout)) #=> Thread
|
|
9
|
+
#
|
|
10
|
+
# Any object that answers `info`, `warn` and `error` will do. Ruby 4.0 no
|
|
11
|
+
# longer ships logger as a default gem, so this gem never requires it.
|
|
12
|
+
module EventLogger
|
|
13
|
+
# Something went wrong.
|
|
14
|
+
ERROR_TYPES = Ractor.make_shareable(%i[child_unresponsive max_restarts_exceeded])
|
|
15
|
+
# Something is being restarted.
|
|
16
|
+
WARN_TYPES = Ractor.make_shareable(%i[child_start_failed child_restart_scheduled])
|
|
17
|
+
|
|
18
|
+
# The default logger: one line per event on stderr.
|
|
19
|
+
class StderrLogger
|
|
20
|
+
def info(message) = write("INFO", message)
|
|
21
|
+
def warn(message) = write("WARN", message)
|
|
22
|
+
def error(message) = write("ERROR", message)
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def write(level, message)
|
|
27
|
+
# Not Kernel#warn: it would collide with StderrLogger#warn.
|
|
28
|
+
$stderr.write("#{level} -- #{message}\n")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
# Start a thread, in the calling Ractor, that drains the port.
|
|
35
|
+
#
|
|
36
|
+
# The port must have been created by the calling Ractor, since only its
|
|
37
|
+
# creator may receive from it.
|
|
38
|
+
#
|
|
39
|
+
# @return [Thread]
|
|
40
|
+
def start(port, logger: StderrLogger.new)
|
|
41
|
+
Thread.new(port, logger) do |pt, log|
|
|
42
|
+
while true # `loop do` is forbidden here: Ractor::ClosedError < StopIteration
|
|
43
|
+
event = pt.receive
|
|
44
|
+
log.public_send(EventLogger.level_for(event), EventLogger.format_event(event))
|
|
45
|
+
end
|
|
46
|
+
rescue Ractor::ClosedError
|
|
47
|
+
# The port closed. Stop reading.
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @return [Symbol] :info, :warn or :error
|
|
53
|
+
def level_for(event)
|
|
54
|
+
type = event[:type]
|
|
55
|
+
return :error if ERROR_TYPES.include?(type)
|
|
56
|
+
return :error if type == :child_exited && event[:reason] == :error
|
|
57
|
+
return :warn if WARN_TYPES.include?(type)
|
|
58
|
+
return :warn if type == :child_started && event[:attempt].to_i.positive?
|
|
59
|
+
|
|
60
|
+
:info
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @return [String] one line
|
|
64
|
+
def format_event(event)
|
|
65
|
+
head = "[ractor_shepherd] #{event[:supervisor]} #{event[:type]}"
|
|
66
|
+
rest = event.except(:type, :supervisor, :at).map { |key, value| "#{key}=#{value.inspect}" }
|
|
67
|
+
[head, *rest].join(" ")
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The public entry points.
|
|
4
|
+
module RactorShepherd
|
|
5
|
+
class << self
|
|
6
|
+
# Start a root supervisor.
|
|
7
|
+
#
|
|
8
|
+
# Starting is synchronous: this returns once every child's `initialize` has
|
|
9
|
+
# finished. If one of them fails, the children that already started are
|
|
10
|
+
# stopped in reverse order and {StartError} is raised.
|
|
11
|
+
#
|
|
12
|
+
# @return [SupervisorRef]
|
|
13
|
+
def start(name:, strategy: :one_for_one, children: [], max_restarts: 3, max_seconds: 5.0,
|
|
14
|
+
on_unresponsive: :escalate, event_port: nil, boot_timeout: 30)
|
|
15
|
+
spec = Validator.root_spec(kind: :static, strategy: strategy, children: children,
|
|
16
|
+
max_restarts: max_restarts, max_seconds: max_seconds,
|
|
17
|
+
on_unresponsive: on_unresponsive)
|
|
18
|
+
Runtime::SupervisorServer.boot(spec, name: name, event_port: event_port, boot_timeout: boot_timeout)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Start a root dynamic supervisor, which begins with no children.
|
|
22
|
+
#
|
|
23
|
+
# @return [SupervisorRef]
|
|
24
|
+
def start_dynamic(name:, max_children: nil, max_restarts: 3, max_seconds: 5.0,
|
|
25
|
+
on_unresponsive: :escalate, event_port: nil, boot_timeout: 30)
|
|
26
|
+
spec = Validator.root_spec(kind: :dynamic, strategy: :one_for_one, children: [],
|
|
27
|
+
max_restarts: max_restarts, max_seconds: max_seconds,
|
|
28
|
+
max_children: max_children, on_unresponsive: on_unresponsive)
|
|
29
|
+
Runtime::SupervisorServer.boot(spec, name: name, event_port: event_port, boot_timeout: boot_timeout)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Start a supervisor, yield it, and stop it on the way out.
|
|
33
|
+
def run(**)
|
|
34
|
+
supervisor = start(**)
|
|
35
|
+
begin
|
|
36
|
+
yield supervisor
|
|
37
|
+
ensure
|
|
38
|
+
supervisor.stop
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Describe a worker child.
|
|
43
|
+
#
|
|
44
|
+
# @param id [Symbol, Integer, String] unique within its supervisor
|
|
45
|
+
# @param klass [Class] a class that includes RactorShepherd::Worker
|
|
46
|
+
def worker(id, klass, args: [], kwargs: {}, restart: :permanent,
|
|
47
|
+
shutdown_timeout: 5.0, start_timeout: 5.0, restart_delay: nil)
|
|
48
|
+
Validator.worker_spec(id, klass, args: args, kwargs: kwargs, restart: restart,
|
|
49
|
+
shutdown_timeout: shutdown_timeout, start_timeout: start_timeout,
|
|
50
|
+
restart_delay: restart_delay, allow_nil_id: id.nil?)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Describe a supervisor child.
|
|
54
|
+
def supervisor(id, strategy: :one_for_one, children: [], max_restarts: 3, max_seconds: 5.0,
|
|
55
|
+
on_unresponsive: :escalate, restart: :permanent,
|
|
56
|
+
shutdown_timeout: :infinity, start_timeout: :infinity, restart_delay: nil)
|
|
57
|
+
Validator.supervisor_spec(id, kind: :static, strategy: strategy, children: children,
|
|
58
|
+
max_restarts: max_restarts, max_seconds: max_seconds,
|
|
59
|
+
on_unresponsive: on_unresponsive, restart: restart,
|
|
60
|
+
shutdown_timeout: shutdown_timeout, start_timeout: start_timeout,
|
|
61
|
+
restart_delay: restart_delay)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Describe a dynamic supervisor child.
|
|
65
|
+
def dynamic_supervisor(id, max_children: nil, max_restarts: 3, max_seconds: 5.0,
|
|
66
|
+
on_unresponsive: :escalate, restart: :permanent,
|
|
67
|
+
shutdown_timeout: :infinity, start_timeout: :infinity, restart_delay: nil)
|
|
68
|
+
Validator.supervisor_spec(id, kind: :dynamic, strategy: :one_for_one, children: [],
|
|
69
|
+
max_restarts: max_restarts, max_seconds: max_seconds,
|
|
70
|
+
max_children: max_children, on_unresponsive: on_unresponsive,
|
|
71
|
+
restart: restart, shutdown_timeout: shutdown_timeout,
|
|
72
|
+
start_timeout: start_timeout, restart_delay: restart_delay)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RactorShepherd
|
|
4
|
+
module Runtime
|
|
5
|
+
# A synchronous request/response over a one-shot reply port.
|
|
6
|
+
#
|
|
7
|
+
# Ruby 4.0's `Ractor#unmonitor` looks registrations up by port id alone and
|
|
8
|
+
# ignores which Ractor created the port. Since port ids are a per Ractor
|
|
9
|
+
# sequence, unmonitoring can silently drop *another* Ractor's monitor of the
|
|
10
|
+
# same target, which would leave a supervisor blind to its own child dying.
|
|
11
|
+
# So this gem never calls `unmonitor`. See DESIGN.md F24 and the reproduction
|
|
12
|
+
# in spike/unmonitor_id_collision.rb.
|
|
13
|
+
#
|
|
14
|
+
# Registrations we cannot remove would pile up if every call added one, so a
|
|
15
|
+
# call with a finite timeout does not monitor at all: a dead target is found
|
|
16
|
+
# out either when the send raises ClosedError, or after the timeout by
|
|
17
|
+
# checking whether the target is still alive.
|
|
18
|
+
#
|
|
19
|
+
# ponytail: one port and one timer thread per call. Hot paths should use
|
|
20
|
+
# `cast` or their own port instead; the README says so.
|
|
21
|
+
#
|
|
22
|
+
# @api private
|
|
23
|
+
module Call
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# @param target_ractor [Ractor] the Ractor being called
|
|
27
|
+
# @param target_port [Ractor::Port, Ractor] where the request goes
|
|
28
|
+
# @param timeout [Numeric, :infinity]
|
|
29
|
+
# @param down_error [Class] raised when the target is not running
|
|
30
|
+
# @return [Object] the reply payload
|
|
31
|
+
def perform(target_ractor, target_port, request, timeout: 5, down_error: SupervisorDown)
|
|
32
|
+
reply = Ractor::Port.new
|
|
33
|
+
timer = nil
|
|
34
|
+
if timeout == :infinity
|
|
35
|
+
# Only an unbounded call needs a monitor, so that it cannot wait forever.
|
|
36
|
+
# monitor returns false when the target has already finished.
|
|
37
|
+
raise down_error, down_message(target_ractor, "is not running") unless target_ractor.monitor(reply)
|
|
38
|
+
else
|
|
39
|
+
timer = Timer.for_current_ractor.after(timeout, reply, Protocol.timeout(0))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
begin
|
|
43
|
+
target_port << Protocol.call(reply, request)
|
|
44
|
+
rescue Ractor::ClosedError
|
|
45
|
+
# Sending to something that has already finished raises ClosedError.
|
|
46
|
+
raise down_error, down_message(target_ractor, "is not running")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
await(reply, target_ractor, down_error)
|
|
50
|
+
ensure
|
|
51
|
+
timer&.cancel
|
|
52
|
+
reply&.close
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def await(reply, target_ractor, down_error)
|
|
56
|
+
case (msg = reply.receive)
|
|
57
|
+
in [Protocol::REPLY, result] then result
|
|
58
|
+
in [Protocol::TIMEOUT, _]
|
|
59
|
+
raise down_error, down_message(target_ractor, "terminated while handling the call") if
|
|
60
|
+
terminated?(target_ractor)
|
|
61
|
+
|
|
62
|
+
raise CallTimeout, down_message(target_ractor, "did not reply within the timeout")
|
|
63
|
+
else
|
|
64
|
+
# A monitor notification; Compat validates its shape. The target went down.
|
|
65
|
+
Compat.monitor_status(msg)
|
|
66
|
+
raise down_error, down_message(target_ractor, "terminated while handling the call")
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Has the target finished?
|
|
71
|
+
#
|
|
72
|
+
# When it is still alive this leaves one monitor registration behind,
|
|
73
|
+
# because unmonitor is unusable. Timeouts are an exceptional path, so
|
|
74
|
+
# these do not accumulate.
|
|
75
|
+
def terminated?(ractor)
|
|
76
|
+
probe = Ractor::Port.new
|
|
77
|
+
!ractor.monitor(probe)
|
|
78
|
+
ensure
|
|
79
|
+
probe.close
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def down_message(ractor, suffix)
|
|
83
|
+
"#{ractor.name || ractor.inspect} #{suffix}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private_class_method :await, :terminated?, :down_message
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RactorShepherd
|
|
4
|
+
module Runtime
|
|
5
|
+
# What runs inside a child Ractor.
|
|
6
|
+
#
|
|
7
|
+
# A `Ractor.new` block cannot see outer locals or self, so the block that
|
|
8
|
+
# starts a child is a single call to this method with everything passed as
|
|
9
|
+
# arguments.
|
|
10
|
+
#
|
|
11
|
+
# @api private
|
|
12
|
+
module ChildRunner
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# @param spec [ChildSpec]
|
|
16
|
+
# @param start_port [Ractor::Port] handshake port, created by the supervisor
|
|
17
|
+
# @param parent_ref [SupervisorRef, nil]
|
|
18
|
+
# @param event_port [Ractor::Port, nil]
|
|
19
|
+
# @param path [String] e.g. "root/jobs/poller"
|
|
20
|
+
def run(spec, start_port, parent_ref, event_port, path)
|
|
21
|
+
# Report crashes through events instead of dumping a backtrace on stderr.
|
|
22
|
+
Thread.current.report_on_exception = false
|
|
23
|
+
return SupervisorServer.run_as_child(spec, start_port, parent_ref, event_port, path) if
|
|
24
|
+
spec.type == :supervisor
|
|
25
|
+
|
|
26
|
+
run_worker(spec, start_port, parent_ref, event_port, path)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def run_worker(spec, start_port, parent_ref, event_port, path)
|
|
30
|
+
system_port = Ractor::Port.new
|
|
31
|
+
ctx = Context.new(id: spec.id, path: path, supervisor: parent_ref, event_port: event_port)
|
|
32
|
+
worker = spec.start.new(*spec.args, **spec.kwargs)
|
|
33
|
+
|
|
34
|
+
start_port << Protocol.child_ready(spec.id, system_port, Ractor.current)
|
|
35
|
+
ctx.start_watcher(system_port)
|
|
36
|
+
|
|
37
|
+
reason = :normal
|
|
38
|
+
begin
|
|
39
|
+
worker.run(ctx)
|
|
40
|
+
# Returning after a shutdown request still counts as a shutdown.
|
|
41
|
+
reason = :shutdown if ctx.shutdown_requested?
|
|
42
|
+
rescue ShutdownSignal
|
|
43
|
+
reason = :shutdown
|
|
44
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
45
|
+
reason = e
|
|
46
|
+
raise
|
|
47
|
+
ensure
|
|
48
|
+
call_terminate(worker, reason, ctx)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# An exception from `terminate` turns a clean exit into a crash. When the
|
|
53
|
+
# worker was already crashing, it is swallowed and recorded as an event so
|
|
54
|
+
# that the original cause survives.
|
|
55
|
+
def call_terminate(worker, reason, ctx)
|
|
56
|
+
worker.terminate(reason)
|
|
57
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
58
|
+
raise unless reason.is_a?(Exception)
|
|
59
|
+
|
|
60
|
+
ctx.emit(:terminate_failed, **Core::Event.error_info(e))
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RactorShepherd
|
|
4
|
+
module Runtime
|
|
5
|
+
# A child's mutable state, which lives only inside the supervisor's Ractor.
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
class ChildState
|
|
9
|
+
STATUSES = %i[starting running stopping exited terminated restart_scheduled
|
|
10
|
+
start_failed unresponsive removed].freeze
|
|
11
|
+
|
|
12
|
+
attr_reader :spec, :backoff
|
|
13
|
+
attr_accessor :status, :ractor, :stop_port, :ref, :monitor_port, :restart_count
|
|
14
|
+
|
|
15
|
+
def initialize(spec, reset_after:)
|
|
16
|
+
@spec = spec
|
|
17
|
+
@status = :starting
|
|
18
|
+
@ractor = nil
|
|
19
|
+
@stop_port = nil
|
|
20
|
+
@ref = nil
|
|
21
|
+
@monitor_port = nil
|
|
22
|
+
@restart_count = 0
|
|
23
|
+
@backoff = Core::Backoff.new(spec.restart_delay, reset_after: reset_after)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def id = spec.id
|
|
27
|
+
def type = spec.type
|
|
28
|
+
def running? = status == :running
|
|
29
|
+
|
|
30
|
+
# Let go of the Ractor and its ports once the child has exited.
|
|
31
|
+
def detach
|
|
32
|
+
@ractor = nil
|
|
33
|
+
@stop_port = nil
|
|
34
|
+
@ref = nil
|
|
35
|
+
@monitor_port = nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# What the outside world sees.
|
|
39
|
+
def to_info
|
|
40
|
+
ChildInfo.new(id: id, type: type, status: status,
|
|
41
|
+
ref: running? ? ref : nil, restart_count: restart_count)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# What the strategy planner needs.
|
|
45
|
+
def to_view
|
|
46
|
+
Core::StrategyPlanner::ChildView.new(id: id, restart: spec.restart, alive: running?)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RactorShepherd
|
|
4
|
+
# The Ractor shell: everything that touches Ractors, ports, threads and the clock.
|
|
5
|
+
module Runtime
|
|
6
|
+
# Smooths over the Ractor API differences between Ruby 4.0 and 4.1.
|
|
7
|
+
#
|
|
8
|
+
# Decisions are made from the shape of a message rather than from
|
|
9
|
+
# RUBY_VERSION, so development builds such as 4.1.0dev work too.
|
|
10
|
+
#
|
|
11
|
+
# @api private
|
|
12
|
+
module Compat
|
|
13
|
+
# @raise [UnsupportedRuby] when Ractor::Port is missing
|
|
14
|
+
def self.check!
|
|
15
|
+
return if defined?(::Ractor::Port)
|
|
16
|
+
|
|
17
|
+
raise UnsupportedRuby,
|
|
18
|
+
"ractor_shepherd requires Ruby >= 4.0 with Ractor::Port (running #{RUBY_VERSION})"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Normalise a Ractor#monitor notification to :exited or :aborted.
|
|
22
|
+
#
|
|
23
|
+
# Ruby 4.0 sends a bare Symbol, Ruby 4.1 sends [ractor, status].
|
|
24
|
+
def self.monitor_status(msg)
|
|
25
|
+
case msg
|
|
26
|
+
in :exited | :aborted then msg
|
|
27
|
+
in [::Ractor, (:exited | :aborted) => status] then status
|
|
28
|
+
else raise ProtocolError, "unexpected monitor message: #{msg.inspect}"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Does Ractor::Port#receive take a timeout: keyword?
|
|
33
|
+
#
|
|
34
|
+
# Ruby 4.0 has no such keyword, so every timed wait goes through
|
|
35
|
+
# Runtime::Timer and Ractor.select. Kept for a future optimisation.
|
|
36
|
+
def self.native_receive_timeout?
|
|
37
|
+
::Ractor::Port.instance_method(:receive).parameters.any? { |kind, name| kind == :key && name == :timeout }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RactorShepherd
|
|
4
|
+
module Runtime
|
|
5
|
+
# The handle a worker gets inside its own Ractor.
|
|
6
|
+
#
|
|
7
|
+
# It is not shareable. Do not hand it to another Ractor.
|
|
8
|
+
#
|
|
9
|
+
# @api private
|
|
10
|
+
class Context
|
|
11
|
+
attr_reader :id, :path, :supervisor, :shutdown_reason
|
|
12
|
+
|
|
13
|
+
def initialize(id:, path:, supervisor:, event_port:)
|
|
14
|
+
@id = id
|
|
15
|
+
@path = path
|
|
16
|
+
@supervisor = supervisor
|
|
17
|
+
@event_port = event_port
|
|
18
|
+
@shutdown = false
|
|
19
|
+
@shutdown_reason = nil
|
|
20
|
+
@wake = Thread::Queue.new
|
|
21
|
+
@seq = 0
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Receive one application message.
|
|
25
|
+
#
|
|
26
|
+
# @param timeout [Numeric, nil] seconds; nil waits forever
|
|
27
|
+
# @return [Object, nil] nil on timeout
|
|
28
|
+
# @raise [ShutdownSignal] once a shutdown has been requested
|
|
29
|
+
def receive(timeout: nil)
|
|
30
|
+
raise ShutdownSignal if shutdown_requested?
|
|
31
|
+
|
|
32
|
+
timer = nil
|
|
33
|
+
if timeout
|
|
34
|
+
@seq += 1
|
|
35
|
+
timer = Timer.for_current_ractor.after(timeout, Ractor.current, Protocol.timeout(@seq))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
while true # `loop do` is forbidden here: Ractor::ClosedError < StopIteration
|
|
39
|
+
message = Ractor.receive
|
|
40
|
+
raise ShutdownSignal if message == Protocol::SHUTDOWN_SENTINEL
|
|
41
|
+
|
|
42
|
+
case message
|
|
43
|
+
in [Protocol::TIMEOUT, seq]
|
|
44
|
+
return nil if seq == @seq
|
|
45
|
+
# A leftover notification from an earlier receive. Drop it and read again.
|
|
46
|
+
else
|
|
47
|
+
return message
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
ensure
|
|
51
|
+
timer&.cancel
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Has a shutdown been requested?
|
|
55
|
+
def shutdown_requested? = @shutdown
|
|
56
|
+
|
|
57
|
+
# Sleep, but wake immediately if a shutdown arrives.
|
|
58
|
+
#
|
|
59
|
+
# @return [Boolean] true if it slept the whole time, false if a shutdown cut it short
|
|
60
|
+
def sleep(seconds) # rubocop:disable Naming/PredicateMethod
|
|
61
|
+
return false if shutdown_requested?
|
|
62
|
+
|
|
63
|
+
@wake.pop(timeout: seconds).nil?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Publish an application event (as type: :worker_event).
|
|
67
|
+
def emit(name, **data)
|
|
68
|
+
emit_system(:worker_event, name: name, data: data)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Publish one of the event types this gem defines.
|
|
72
|
+
#
|
|
73
|
+
# @api private
|
|
74
|
+
def emit_system(type, **data)
|
|
75
|
+
publish(Core::Event.build(type, supervisor: supervisor_path, at: now, child: id, **data))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Start the thread that waits for a shutdown request.
|
|
79
|
+
#
|
|
80
|
+
# A Ractor ends as soon as its main block returns, even with threads still
|
|
81
|
+
# asleep, so there is nothing to clean up here.
|
|
82
|
+
def start_watcher(system_port)
|
|
83
|
+
me = Ractor.current
|
|
84
|
+
Thread.new(system_port, me) do |port, ractor|
|
|
85
|
+
_tag, reason = port.receive
|
|
86
|
+
request_shutdown(reason)
|
|
87
|
+
ractor.send(Protocol::SHUTDOWN_SENTINEL)
|
|
88
|
+
rescue Ractor::ClosedError
|
|
89
|
+
# This Ractor is already on its way out; there is nobody left to wake.
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Called from the watcher thread.
|
|
95
|
+
def request_shutdown(reason)
|
|
96
|
+
@shutdown_reason = reason
|
|
97
|
+
@shutdown = true
|
|
98
|
+
@wake.push(true)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# @api private
|
|
102
|
+
def publish(event)
|
|
103
|
+
return unless @event_port
|
|
104
|
+
|
|
105
|
+
@event_port << event
|
|
106
|
+
rescue Ractor::ClosedError
|
|
107
|
+
# The subscriber is gone. Losing observability must not take the child down.
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def supervisor_path = @supervisor&.path || @path
|
|
114
|
+
|
|
115
|
+
def now = Process.clock_gettime(Process::CLOCK_REALTIME)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RactorShepherd
|
|
4
|
+
module Runtime
|
|
5
|
+
# Builds and recognises the messages this gem sends.
|
|
6
|
+
#
|
|
7
|
+
# Reserved names start with `:"$"`. No message symbol is spelled out
|
|
8
|
+
# anywhere else in the codebase.
|
|
9
|
+
#
|
|
10
|
+
# @api private
|
|
11
|
+
module Protocol
|
|
12
|
+
RESERVED_PREFIX = "$"
|
|
13
|
+
|
|
14
|
+
CHILD_READY = :"$child_ready"
|
|
15
|
+
SHUTDOWN = :"$shutdown"
|
|
16
|
+
CALL = :"$call"
|
|
17
|
+
REPLY = :"$reply"
|
|
18
|
+
TIMEOUT = :"$timeout"
|
|
19
|
+
RESTART_DUE = :"$restart_due"
|
|
20
|
+
SHUTDOWN_SENTINEL = :"$shutdown_sentinel"
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# Child to start port: the child has finished starting.
|
|
25
|
+
#
|
|
26
|
+
# @param ref [Ractor, SupervisorRef] the child's own Ractor, or its SupervisorRef
|
|
27
|
+
# @param stop_port [Ractor::Port] a worker's system port, or a supervisor's control port
|
|
28
|
+
def child_ready(id, stop_port, ref) = [CHILD_READY, id, stop_port, ref].freeze
|
|
29
|
+
|
|
30
|
+
# Parent or API to a child's stop port: please shut down.
|
|
31
|
+
def shutdown(reason) = [SHUTDOWN, reason].freeze
|
|
32
|
+
|
|
33
|
+
# Caller to a control port or a worker's default port.
|
|
34
|
+
def call(reply_port, request) = [CALL, reply_port, request].freeze
|
|
35
|
+
|
|
36
|
+
# Responder to a reply port.
|
|
37
|
+
def reply(result) = [REPLY, result].freeze
|
|
38
|
+
|
|
39
|
+
# Timer to whatever port is being waited on. `seq` tells stale notifications apart.
|
|
40
|
+
def timeout(seq) = [TIMEOUT, seq].freeze
|
|
41
|
+
|
|
42
|
+
# Timer to a supervisor's timer port: a delayed restart is due.
|
|
43
|
+
def restart_due(generation, ids) = [RESTART_DUE, generation, ids].freeze
|
|
44
|
+
|
|
45
|
+
# Is this one of the messages this gem reserves?
|
|
46
|
+
def reserved?(message)
|
|
47
|
+
case message
|
|
48
|
+
when Symbol then message.start_with?(RESERVED_PREFIX)
|
|
49
|
+
when Array then message[0].is_a?(Symbol) && message[0].start_with?(RESERVED_PREFIX)
|
|
50
|
+
else false
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|