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.
@@ -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