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,506 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RactorShepherd
|
|
4
|
+
module Runtime
|
|
5
|
+
# Everything that runs inside a supervisor's Ractor.
|
|
6
|
+
#
|
|
7
|
+
# Whether to restart, and which children to stop, is decided in Core.
|
|
8
|
+
# This class only carries out the resulting plan.
|
|
9
|
+
#
|
|
10
|
+
# @api private
|
|
11
|
+
class SupervisorServer
|
|
12
|
+
# Called from the caller's Ractor: creates the supervisor Ractor and waits for it to boot.
|
|
13
|
+
#
|
|
14
|
+
# @return [SupervisorRef]
|
|
15
|
+
def self.boot(spec, name:, event_port: nil, boot_timeout: 30)
|
|
16
|
+
path = name.to_s.freeze
|
|
17
|
+
boot_port = Ractor::Port.new
|
|
18
|
+
timer = nil
|
|
19
|
+
ractor = Ractor.new(spec, boot_port, event_port, path, name: "shepherd:#{path}") do |sp, bp, ep, pa|
|
|
20
|
+
RactorShepherd::Runtime::SupervisorServer.run_root(sp, bp, ep, pa)
|
|
21
|
+
end
|
|
22
|
+
# monitor before reading, so a crash during boot is never missed;
|
|
23
|
+
# a Ractor that has already finished notifies immediately.
|
|
24
|
+
ractor.monitor(boot_port)
|
|
25
|
+
timer = Timer.for_current_ractor.after(boot_timeout, boot_port, Protocol.timeout(0)) unless
|
|
26
|
+
boot_timeout == :infinity
|
|
27
|
+
|
|
28
|
+
case boot_port.receive
|
|
29
|
+
in [Protocol::CHILD_READY, _id, _control_port, ref] then ref
|
|
30
|
+
in [Protocol::TIMEOUT, _] then raise StartError, "#{path} did not finish booting within #{boot_timeout}s"
|
|
31
|
+
else raise_boot_error(ractor, path)
|
|
32
|
+
end
|
|
33
|
+
ensure
|
|
34
|
+
timer&.cancel
|
|
35
|
+
# unmonitor is unusable (see Runtime::Call). Notifications to a closed port are dropped.
|
|
36
|
+
boot_port.close
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The supervisor Ractor aborted. Surface the exception it raised.
|
|
40
|
+
def self.raise_boot_error(ractor, path)
|
|
41
|
+
ractor.value
|
|
42
|
+
raise StartError, "#{path} exited during boot"
|
|
43
|
+
rescue Ractor::RemoteError => e
|
|
44
|
+
inner = e.cause
|
|
45
|
+
# Without an explicit cause:, Ruby would set the RemoteError as the cause
|
|
46
|
+
# and the chain would loop back on itself.
|
|
47
|
+
raise inner, cause: inner.cause if inner.is_a?(StartError)
|
|
48
|
+
|
|
49
|
+
raise StartError, "#{path} failed to boot: #{inner.class}: #{inner.message}", cause: inner
|
|
50
|
+
end
|
|
51
|
+
private_class_method :raise_boot_error
|
|
52
|
+
|
|
53
|
+
# Entry point for a root supervisor's Ractor.
|
|
54
|
+
def self.run_root(spec, boot_port, event_port, path)
|
|
55
|
+
# Report crashes through events instead of dumping a backtrace on stderr.
|
|
56
|
+
Thread.current.report_on_exception = false
|
|
57
|
+
new(spec, name: path, path: path, event_port: event_port).run(boot_port)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Entry point when this supervisor is itself somebody's child.
|
|
61
|
+
def self.run_as_child(child_spec, start_port, _parent_ref, event_port, path)
|
|
62
|
+
Thread.current.report_on_exception = false
|
|
63
|
+
new(child_spec.start, name: child_spec.id, path: path, event_port: event_port).run(start_port)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def initialize(spec, name:, path:, event_port:)
|
|
67
|
+
@spec = spec
|
|
68
|
+
@name = name
|
|
69
|
+
@path = path
|
|
70
|
+
@event_port = event_port
|
|
71
|
+
@children = {}
|
|
72
|
+
@monitor_index = {}.compare_by_identity # Ractor.select returns the very port we passed in
|
|
73
|
+
@state = :booting
|
|
74
|
+
@generation = 0
|
|
75
|
+
@next_auto_id = 0
|
|
76
|
+
@intensity = Core::RestartIntensity.new(max_restarts: spec.max_restarts, max_seconds: spec.max_seconds)
|
|
77
|
+
@timer = Timer.for_current_ractor
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
attr_reader :path
|
|
81
|
+
|
|
82
|
+
# Boot, then run the watch loop. Returns once the supervisor has stopped.
|
|
83
|
+
def run(ready_port)
|
|
84
|
+
@control_port = Ractor::Port.new
|
|
85
|
+
@timer_port = Ractor::Port.new
|
|
86
|
+
@self_ref = Ractor.make_shareable(
|
|
87
|
+
SupervisorRef.new(name: @name, path: @path, ractor: Ractor.current, control_port: @control_port)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
boot_children
|
|
91
|
+
@state = :running
|
|
92
|
+
emit(:supervisor_started, children: @children.keys)
|
|
93
|
+
ready_port << Protocol.child_ready(@name, @control_port, @self_ref)
|
|
94
|
+
|
|
95
|
+
main_loop
|
|
96
|
+
@state
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# --- starting ---------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
def boot_children
|
|
102
|
+
@spec.children.each do |child_spec|
|
|
103
|
+
child = add_child(child_spec)
|
|
104
|
+
result = start_child_sync(child)
|
|
105
|
+
next if result == :ok
|
|
106
|
+
|
|
107
|
+
rollback_boot!(child, result)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# The initial boot failed: stop whatever already started, in reverse order, then raise.
|
|
112
|
+
def rollback_boot!(child, result)
|
|
113
|
+
running_children.reverse_each do |other|
|
|
114
|
+
terminate_child_sync(other, :shutdown, requested_by: :shutdown, on_unresponsive: :abandon)
|
|
115
|
+
end
|
|
116
|
+
reason = result.is_a?(Array) ? result[1] : nil
|
|
117
|
+
message = "#{@path}: child #{child.id.inspect} failed to start"
|
|
118
|
+
raise StartError, message, cause: (reason if reason.is_a?(Exception))
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Start a child Ractor and wait synchronously for its child_ready.
|
|
122
|
+
#
|
|
123
|
+
# @return [Symbol, Array] :ok | [:failed, reason] | [:unresponsive]
|
|
124
|
+
def start_child_sync(child)
|
|
125
|
+
spec = child.spec
|
|
126
|
+
start_port = Ractor::Port.new
|
|
127
|
+
timer = nil
|
|
128
|
+
child.status = :starting
|
|
129
|
+
child_path = "#{@path}/#{spec.id}".freeze
|
|
130
|
+
ractor = Ractor.new(spec, start_port, @self_ref, @event_port, child_path,
|
|
131
|
+
name: "shepherd:#{child_path}") do |sp, stp, pr, ep, pa|
|
|
132
|
+
RactorShepherd::Runtime::ChildRunner.run(sp, stp, pr, ep, pa)
|
|
133
|
+
end
|
|
134
|
+
ractor.monitor(start_port)
|
|
135
|
+
timer = @timer.after(spec.start_timeout, start_port, Protocol.timeout(0)) unless
|
|
136
|
+
spec.start_timeout == :infinity
|
|
137
|
+
|
|
138
|
+
await_start(child, ractor, start_port)
|
|
139
|
+
ensure
|
|
140
|
+
timer&.cancel
|
|
141
|
+
# unmonitor is unusable (see Runtime::Call). Notifications to a closed port are dropped.
|
|
142
|
+
start_port.close
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def await_start(child, ractor, start_port)
|
|
146
|
+
case start_port.receive
|
|
147
|
+
in [Protocol::CHILD_READY, _id, stop_port, ref]
|
|
148
|
+
activate(child, ractor, stop_port, ref)
|
|
149
|
+
:ok
|
|
150
|
+
in [Protocol::TIMEOUT, _]
|
|
151
|
+
child.status = :unresponsive
|
|
152
|
+
emit(:child_unresponsive, child: child.id, phase: :start,
|
|
153
|
+
timeout: child.spec.start_timeout, action: @spec.on_unresponsive)
|
|
154
|
+
[:unresponsive]
|
|
155
|
+
else
|
|
156
|
+
child.status = :start_failed
|
|
157
|
+
reason = exit_reason(ractor)
|
|
158
|
+
emit(:child_start_failed, child: child.id, **Core::Event.error_info(reason))
|
|
159
|
+
[:failed, reason]
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def activate(child, ractor, stop_port, ref)
|
|
164
|
+
child.ractor = ractor
|
|
165
|
+
child.stop_port = stop_port
|
|
166
|
+
child.ref = ref
|
|
167
|
+
child.status = :running
|
|
168
|
+
child.monitor_port = Ractor::Port.new
|
|
169
|
+
# Even if it has already finished, the notification arrives at once, so ignore the result.
|
|
170
|
+
ractor.monitor(child.monitor_port)
|
|
171
|
+
@monitor_index[child.monitor_port] = child
|
|
172
|
+
child.backoff.record_start(now)
|
|
173
|
+
emit(:child_started, child: child.id, ractor_name: ractor.name, attempt: child.backoff.attempt)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# --- watch loop -------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
def main_loop
|
|
179
|
+
while @state == :running # `loop do` is forbidden here: Ractor::ClosedError < StopIteration
|
|
180
|
+
port, message = Ractor.select(@control_port, @timer_port, *@monitor_index.keys)
|
|
181
|
+
if port.equal?(@control_port)
|
|
182
|
+
handle_control(message)
|
|
183
|
+
elsif port.equal?(@timer_port)
|
|
184
|
+
handle_timer(message)
|
|
185
|
+
else
|
|
186
|
+
child = @monitor_index.delete(port)
|
|
187
|
+
port.close
|
|
188
|
+
handle_exit(child, Compat.monitor_status(message)) if child
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def handle_control(message)
|
|
194
|
+
case message
|
|
195
|
+
in [Protocol::CALL, reply_port, request] then reply(reply_port, request)
|
|
196
|
+
in [Protocol::SHUTDOWN, reason] then shutdown(reason)
|
|
197
|
+
else raise ProtocolError, "unexpected control message: #{message.inspect}"
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def handle_timer(message)
|
|
202
|
+
case message
|
|
203
|
+
in [Protocol::RESTART_DUE, generation, ids]
|
|
204
|
+
# Ignore a booking made before the last shutdown or escalation.
|
|
205
|
+
start_children(ids) if generation == @generation
|
|
206
|
+
else raise ProtocolError, "unexpected timer message: #{message.inspect}"
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Answer user input errors and carry on. Anything else takes the supervisor down (let it crash).
|
|
211
|
+
def reply(reply_port, request)
|
|
212
|
+
result = begin
|
|
213
|
+
[:ok, handle_request(request)]
|
|
214
|
+
rescue Error => e
|
|
215
|
+
[:error, e.class.name, e.message]
|
|
216
|
+
end
|
|
217
|
+
reply_port << Protocol.reply(result)
|
|
218
|
+
rescue Ractor::ClosedError
|
|
219
|
+
# The caller timed out and went away. Drop the answer.
|
|
220
|
+
nil
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def handle_request(request)
|
|
224
|
+
case request
|
|
225
|
+
in [:which_children] then @children.values.map(&:to_info)
|
|
226
|
+
in [:count_children] then count_children
|
|
227
|
+
in [:whereis, id] then find_child(id).then { |c| c.running? ? c.ref : nil }
|
|
228
|
+
in [:start_child, spec] then add_and_start(spec)
|
|
229
|
+
in [:terminate_child, id] then api_terminate_child(id)
|
|
230
|
+
in [:restart_child, id] then api_restart_child(id)
|
|
231
|
+
in [:delete_child, id] then api_delete_child(id)
|
|
232
|
+
else raise ProtocolError, "unexpected request: #{request.inspect}"
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def count_children
|
|
237
|
+
values = @children.values
|
|
238
|
+
{ specs: values.size,
|
|
239
|
+
active: values.count(&:running?),
|
|
240
|
+
workers: values.count { |c| c.type == :worker },
|
|
241
|
+
supervisors: values.count { |c| c.type == :supervisor } }
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# --- noticing an exit -------------------------------------------------
|
|
245
|
+
|
|
246
|
+
def handle_exit(child, status)
|
|
247
|
+
reason = status == :aborted ? exit_reason(child.ractor) : :normal
|
|
248
|
+
emit(:child_exited, child: child.id, status: status,
|
|
249
|
+
reason: Core::Event.reason_kind(reason), **Core::Event.error_info(reason))
|
|
250
|
+
child.detach
|
|
251
|
+
child.status = :exited
|
|
252
|
+
|
|
253
|
+
case Core::RestartPolicy.decide(restart: child.spec.restart, status: status, dynamic: dynamic?)
|
|
254
|
+
when :keep_terminated then child.status = :terminated
|
|
255
|
+
when :remove then remove_child(child)
|
|
256
|
+
when :restart then restart_after_failure(child)
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Dig the reason out of a Ractor that aborted.
|
|
261
|
+
def exit_reason(ractor)
|
|
262
|
+
ractor.value
|
|
263
|
+
:unknown
|
|
264
|
+
rescue Ractor::RemoteError => e
|
|
265
|
+
e.cause
|
|
266
|
+
rescue Ractor::Error
|
|
267
|
+
# Another Ractor took the value first, so the reason is lost. Restart anyway.
|
|
268
|
+
:unknown
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# --- restarting -------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
def restart_after_failure(child)
|
|
274
|
+
return escalate!(MaxRestartsExceeded.new(intensity_message)) if @intensity.record(now) == :exceeded
|
|
275
|
+
|
|
276
|
+
child.restart_count += 1
|
|
277
|
+
child.backoff.record_failure(now)
|
|
278
|
+
plan = Core::StrategyPlanner.plan(children: views, failed_id: child.id, strategy: @spec.strategy)
|
|
279
|
+
apply_plan(plan, delay: child.backoff.delay)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def apply_plan(plan, delay: 0)
|
|
283
|
+
plan.terminate.each do |id|
|
|
284
|
+
terminate_child_sync(@children.fetch(id), :shutdown, requested_by: :strategy)
|
|
285
|
+
end
|
|
286
|
+
plan.remove.each { |id| remove_child(@children.fetch(id)) }
|
|
287
|
+
schedule_or_start(plan.start, delay)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def schedule_or_start(ids, delay)
|
|
291
|
+
ids = ids.select { |id| @children.key?(id) }
|
|
292
|
+
return if ids.empty?
|
|
293
|
+
return start_children(ids) if delay.nil? || delay <= 0
|
|
294
|
+
|
|
295
|
+
ids.each do |id|
|
|
296
|
+
child = @children.fetch(id)
|
|
297
|
+
child.status = :restart_scheduled
|
|
298
|
+
emit(:child_restart_scheduled, child: id, delay: delay, attempt: child.backoff.attempt)
|
|
299
|
+
end
|
|
300
|
+
@timer.after(delay, @timer_port, Protocol.restart_due(@generation, ids.freeze))
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Start in order. A failure costs one restart from the budget, and the rest are retried in order.
|
|
304
|
+
def start_children(ids)
|
|
305
|
+
ids.each_with_index do |id, index|
|
|
306
|
+
child = @children[id]
|
|
307
|
+
next unless child
|
|
308
|
+
next if start_child_sync(child) == :ok
|
|
309
|
+
|
|
310
|
+
return escalate!(MaxRestartsExceeded.new(intensity_message)) if @intensity.record(now) == :exceeded
|
|
311
|
+
|
|
312
|
+
child.backoff.record_failure(now)
|
|
313
|
+
return schedule_or_start(ids[index..], child.backoff.delay)
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# --- stopping ---------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
def shutdown(reason)
|
|
320
|
+
@state = :stopping
|
|
321
|
+
@generation += 1
|
|
322
|
+
emit(:supervisor_stopping, reason: reason)
|
|
323
|
+
running_or_scheduled.reverse_each { |child| terminate_child_sync(child, reason, requested_by: :shutdown) }
|
|
324
|
+
emit(:supervisor_stopped, reason: reason)
|
|
325
|
+
@state = :stopped
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Cooperative shutdown, one child at a time, in reverse start order.
|
|
329
|
+
def terminate_child_sync(child, reason, requested_by:, on_unresponsive: @spec.on_unresponsive)
|
|
330
|
+
if child.status == :restart_scheduled
|
|
331
|
+
child.status = :terminated
|
|
332
|
+
emit(:child_terminated, child: child.id, requested_by: requested_by)
|
|
333
|
+
return
|
|
334
|
+
end
|
|
335
|
+
return unless child.running?
|
|
336
|
+
|
|
337
|
+
child.status = :stopping
|
|
338
|
+
request_stop(child, reason)
|
|
339
|
+
await_stop(child, requested_by, on_unresponsive)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def request_stop(child, reason)
|
|
343
|
+
child.stop_port << Protocol.shutdown(reason)
|
|
344
|
+
rescue Ractor::ClosedError
|
|
345
|
+
# It has already finished; its monitor port is holding the notification, so just wait for it.
|
|
346
|
+
nil
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def await_stop(child, requested_by, on_unresponsive)
|
|
350
|
+
op_port = Ractor::Port.new
|
|
351
|
+
timer = nil
|
|
352
|
+
timer = @timer.after(child.spec.shutdown_timeout, op_port, Protocol.timeout(0)) unless
|
|
353
|
+
child.spec.shutdown_timeout == :infinity
|
|
354
|
+
|
|
355
|
+
port, = Ractor.select(child.monitor_port, op_port)
|
|
356
|
+
if port.equal?(op_port)
|
|
357
|
+
handle_unresponsive(child, on_unresponsive)
|
|
358
|
+
else
|
|
359
|
+
forget_monitor(child)
|
|
360
|
+
child.status = :terminated
|
|
361
|
+
child.detach
|
|
362
|
+
emit(:child_terminated, child: child.id, requested_by: requested_by)
|
|
363
|
+
end
|
|
364
|
+
ensure
|
|
365
|
+
timer&.cancel
|
|
366
|
+
op_port.close
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def handle_unresponsive(child, on_unresponsive)
|
|
370
|
+
emit(:child_unresponsive, child: child.id, phase: :stop,
|
|
371
|
+
timeout: child.spec.shutdown_timeout, action: on_unresponsive)
|
|
372
|
+
forget_monitor(child)
|
|
373
|
+
child.status = :unresponsive
|
|
374
|
+
return remove_child(child) if on_unresponsive == :abandon
|
|
375
|
+
|
|
376
|
+
escalate!(ChildUnresponsive.new("#{@path}/#{child.id} did not stop within " \
|
|
377
|
+
"#{child.spec.shutdown_timeout}s"))
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Give up: crash this supervisor so that its parent decides what happens next.
|
|
381
|
+
def escalate!(error)
|
|
382
|
+
@state = :crashed
|
|
383
|
+
@generation += 1
|
|
384
|
+
if error.is_a?(MaxRestartsExceeded)
|
|
385
|
+
emit(:max_restarts_exceeded, restarts: @intensity.count, max_restarts: @spec.max_restarts,
|
|
386
|
+
max_seconds: @spec.max_seconds)
|
|
387
|
+
end
|
|
388
|
+
running_children.reverse_each do |child|
|
|
389
|
+
# Always abandon here, so that escalation cannot escalate again.
|
|
390
|
+
terminate_child_sync(child, :shutdown, requested_by: :shutdown, on_unresponsive: :abandon)
|
|
391
|
+
end
|
|
392
|
+
emit(:supervisor_stopped, reason: error.class.name)
|
|
393
|
+
raise error
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# --- adding and removing children -------------------------------------
|
|
397
|
+
|
|
398
|
+
def add_child(spec)
|
|
399
|
+
@children[spec.id] = ChildState.new(spec, reset_after: @spec.max_seconds)
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def remove_child(child)
|
|
403
|
+
child.status = :removed
|
|
404
|
+
@children.delete(child.id)
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def forget_monitor(child)
|
|
408
|
+
return unless child.monitor_port
|
|
409
|
+
|
|
410
|
+
@monitor_index.delete(child.monitor_port)
|
|
411
|
+
child.monitor_port.close
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def find_child(id)
|
|
415
|
+
@children[id] or raise ChildNotFound, "#{@path} has no child #{id.inspect}"
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# Add a spec and start it. Dynamic supervisors can number children themselves.
|
|
419
|
+
def add_and_start(spec)
|
|
420
|
+
raise InvalidSpec, "start_child expects a ChildSpec (got #{spec.class})" unless spec.is_a?(ChildSpec)
|
|
421
|
+
|
|
422
|
+
spec = assign_id(spec)
|
|
423
|
+
raise InvalidSpec, "duplicated child id: #{spec.id.inspect}" if @children.key?(spec.id) # E13
|
|
424
|
+
if dynamic? && @spec.max_children && @children.size >= @spec.max_children
|
|
425
|
+
raise MaxChildrenReached, "#{@path} already has #{@children.size} children"
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
child = add_child(spec)
|
|
429
|
+
result = start_child_sync(child)
|
|
430
|
+
if result != :ok
|
|
431
|
+
remove_child(child)
|
|
432
|
+
raise StartError, "#{@path}: child #{spec.id.inspect} failed to start#{failure_detail(result)}"
|
|
433
|
+
end
|
|
434
|
+
spec.id
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def assign_id(spec)
|
|
438
|
+
return spec unless spec.id.nil?
|
|
439
|
+
raise InvalidSpec, "only dynamic supervisors accept a nil id" unless dynamic?
|
|
440
|
+
|
|
441
|
+
@next_auto_id += 1
|
|
442
|
+
Ractor.make_shareable(spec.with(id: @next_auto_id))
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# Cooperative stop. A static supervisor keeps the spec; a dynamic one drops it.
|
|
446
|
+
def api_terminate_child(id)
|
|
447
|
+
child = find_child(id)
|
|
448
|
+
terminate_child_sync(child, :shutdown, requested_by: :api)
|
|
449
|
+
child.status = :terminated
|
|
450
|
+
remove_child(child) if dynamic?
|
|
451
|
+
:ok
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
def api_restart_child(id)
|
|
455
|
+
raise InvalidOperation, "dynamic supervisors do not support restart_child" if dynamic?
|
|
456
|
+
|
|
457
|
+
child = find_child(id)
|
|
458
|
+
raise InvalidOperation, "#{id.inspect} is #{child.status}, not terminated" unless child.status == :terminated
|
|
459
|
+
|
|
460
|
+
result = start_child_sync(child)
|
|
461
|
+
raise StartError, "#{@path}: child #{id.inspect} failed to restart#{failure_detail(result)}" if result != :ok
|
|
462
|
+
|
|
463
|
+
:ok
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
def api_delete_child(id)
|
|
467
|
+
raise InvalidOperation, "dynamic supervisors do not support delete_child" if dynamic?
|
|
468
|
+
|
|
469
|
+
child = find_child(id)
|
|
470
|
+
if child.running? || child.status == :restart_scheduled
|
|
471
|
+
raise InvalidOperation, "#{id.inspect} is #{child.status}; terminate it first"
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
remove_child(child)
|
|
475
|
+
:ok
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def failure_detail(result)
|
|
479
|
+
reason = result.is_a?(Array) ? result[1] : nil
|
|
480
|
+
reason.is_a?(Exception) ? " (#{reason.class}: #{reason.message})" : ""
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# --- odds and ends ----------------------------------------------------
|
|
484
|
+
|
|
485
|
+
def dynamic? = @spec.kind == :dynamic
|
|
486
|
+
def views = @children.values.map(&:to_view)
|
|
487
|
+
def running_children = @children.values.select(&:running?)
|
|
488
|
+
def running_or_scheduled = @children.values.select { |c| c.running? || c.status == :restart_scheduled }
|
|
489
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
490
|
+
|
|
491
|
+
def intensity_message
|
|
492
|
+
"#{@path} exceeded #{@spec.max_restarts} restarts in #{@spec.max_seconds}s"
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def emit(type, **data)
|
|
496
|
+
return unless @event_port
|
|
497
|
+
|
|
498
|
+
@event_port << Core::Event.build(type, supervisor: @path, at: Process.clock_gettime(Process::CLOCK_REALTIME),
|
|
499
|
+
**data)
|
|
500
|
+
rescue Ractor::ClosedError
|
|
501
|
+
# Only the subscriber is gone. Losing observability must not take the supervisor down.
|
|
502
|
+
nil
|
|
503
|
+
end
|
|
504
|
+
end
|
|
505
|
+
end
|
|
506
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RactorShepherd
|
|
4
|
+
module Runtime
|
|
5
|
+
# Sends a message to a port after a delay.
|
|
6
|
+
#
|
|
7
|
+
# Ruby 4.0 has no `Ractor::Port#receive(timeout:)`, so every timed wait in
|
|
8
|
+
# this gem is built from a Timer plus `Ractor.select`.
|
|
9
|
+
#
|
|
10
|
+
# ponytail: one thread per timer. If timers ever run into the thousands,
|
|
11
|
+
# swap the implementation for a single thread with a time ordered queue;
|
|
12
|
+
# the interface is already shaped for that.
|
|
13
|
+
#
|
|
14
|
+
# @api private
|
|
15
|
+
class Timer
|
|
16
|
+
RACTOR_KEY = :"ractor_shepherd.timer"
|
|
17
|
+
|
|
18
|
+
# A handle on one pending message.
|
|
19
|
+
class Handle
|
|
20
|
+
def initialize(thread)
|
|
21
|
+
@thread = thread
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Cancel before it fires. Calling this afterwards is harmless.
|
|
25
|
+
def cancel
|
|
26
|
+
@thread.kill
|
|
27
|
+
nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def pending? = @thread.alive?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# One timer per Ractor, kept in Ractor local storage.
|
|
34
|
+
def self.for_current_ractor
|
|
35
|
+
Ractor[RACTOR_KEY] ||= new
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @param seconds [Numeric] the delay
|
|
39
|
+
# @param port [Ractor::Port, Ractor] anything that answers `<<`
|
|
40
|
+
# @param message [Object] what to send; must be shareable
|
|
41
|
+
# @return [Handle]
|
|
42
|
+
def after(seconds, port, message)
|
|
43
|
+
Handle.new(Thread.new(seconds, port, message) do |sec, pt, msg|
|
|
44
|
+
Kernel.sleep(sec)
|
|
45
|
+
pt << msg
|
|
46
|
+
rescue Ractor::ClosedError
|
|
47
|
+
# Whoever was waiting has already gone. Nothing left to do.
|
|
48
|
+
nil
|
|
49
|
+
end)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RactorShepherd
|
|
4
|
+
# A GenServer style worker: `run` is already written for you, and you only
|
|
5
|
+
# fill in the message handlers.
|
|
6
|
+
#
|
|
7
|
+
# class Counter
|
|
8
|
+
# include RactorShepherd::Server
|
|
9
|
+
#
|
|
10
|
+
# def initialize(start = 0) = @count = start
|
|
11
|
+
#
|
|
12
|
+
# def handle_call(msg)
|
|
13
|
+
# case msg
|
|
14
|
+
# in :get then @count
|
|
15
|
+
# end
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# def handle_cast(msg)
|
|
19
|
+
# case msg
|
|
20
|
+
# in [:add, n] then @count += n
|
|
21
|
+
# end
|
|
22
|
+
# end
|
|
23
|
+
# end
|
|
24
|
+
module Server
|
|
25
|
+
include Worker
|
|
26
|
+
|
|
27
|
+
# Receive loop. It ends when `ctx.receive` raises ShutdownSignal.
|
|
28
|
+
def run(ctx)
|
|
29
|
+
@context = ctx
|
|
30
|
+
while true # `loop do` is forbidden here: Ractor::ClosedError < StopIteration
|
|
31
|
+
message = ctx.receive
|
|
32
|
+
case message
|
|
33
|
+
in [Runtime::Protocol::CALL, reply_port, request] then respond(reply_port, request)
|
|
34
|
+
else handle_cast(message)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The current {RactorShepherd::Runtime::Context}.
|
|
40
|
+
def context = @context
|
|
41
|
+
|
|
42
|
+
# The return value becomes the reply. An exception kills this child.
|
|
43
|
+
def handle_call(message)
|
|
44
|
+
raise NotImplementedError, "#{self.class} must implement #handle_call(message)"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The return value is ignored. Left unimplemented, it emits :unhandled_message and moves on.
|
|
48
|
+
def handle_cast(message)
|
|
49
|
+
context&.emit_system(:unhandled_message, message_class: message.class.name)
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def respond(reply_port, request)
|
|
56
|
+
reply_port << Runtime::Protocol.reply(handle_call(request))
|
|
57
|
+
rescue Ractor::ClosedError
|
|
58
|
+
# The caller already timed out and closed its reply port. Drop the answer.
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|