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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 8166a49f66449c7fdd8d93c65e10e39a58c2e6590179112e2d34667477422c1e
|
|
4
|
+
data.tar.gz: 0a646565208fb4808b685bac16822a8df1a48b27440d90a7ad7f618b53b7254f
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 8c04a92b8ac6e949373dcc0ab3fa93d9120a8b71bb023edb317c3494ee458c13c55aec93f8ee7fa681b2117e7c785706cd802b8259c4cde861daab8ccbc83742
|
|
7
|
+
data.tar.gz: f9b15124d25db3eb8ac96e28b164853c4d21727f5cb7a4b4fa83866f0d9dd101bf56cc81e19dd1ae916959f6f26696c97d635ae9a7273c43f91f2db1f060d1bb
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yudai Takada
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# ractor_shepherd
|
|
2
|
+
|
|
3
|
+
Supervise Ruby Ractors the way Erlang/OTP supervisors do. When a child Ractor dies
|
|
4
|
+
with an exception, it is restarted according to a policy you declare; when
|
|
5
|
+
restarting stops helping, the failure is escalated to the supervisor above.
|
|
6
|
+
Supervisors can themselves be children, so a supervision tree is just supervisors
|
|
7
|
+
all the way down.
|
|
8
|
+
|
|
9
|
+
- Ruby 4.0 or later, and ready for the Ractor API changes in 4.1
|
|
10
|
+
- No runtime dependencies: standard library only
|
|
11
|
+
- The decision logic lives in a layer that never touches Ractors, so it is covered by ordinary unit tests
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
gem "ractor_shepherd"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## A minimal example
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
require "ractor_shepherd"
|
|
23
|
+
|
|
24
|
+
class Counter
|
|
25
|
+
include RactorShepherd::Server
|
|
26
|
+
|
|
27
|
+
def initialize(start = 0)
|
|
28
|
+
super()
|
|
29
|
+
@count = start
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def handle_call(message)
|
|
33
|
+
case message
|
|
34
|
+
in :get then @count
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def handle_cast(message)
|
|
39
|
+
case message
|
|
40
|
+
in [:add, n] then @count += n
|
|
41
|
+
in :crash then raise "boom"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
events = Ractor::Port.new
|
|
47
|
+
RactorShepherd::EventLogger.start(events) # one line per event on stderr
|
|
48
|
+
|
|
49
|
+
RactorShepherd.run(name: :root, strategy: :one_for_one, max_restarts: 3, max_seconds: 5,
|
|
50
|
+
event_port: events,
|
|
51
|
+
children: [RactorShepherd.worker(:counter, Counter, args: [0])]) do |sup|
|
|
52
|
+
counter = sup.lookup(:counter)
|
|
53
|
+
counter.cast([:add, 2])
|
|
54
|
+
counter.call(:get, timeout: 5) #=> 2
|
|
55
|
+
|
|
56
|
+
counter.cast(:crash) # it dies, and is restarted with its state back at zero
|
|
57
|
+
end
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Runnable examples live in [`examples/`](examples).
|
|
61
|
+
|
|
62
|
+
| File | What it shows |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `examples/basic.rb` | one_for_one and automatic restarts |
|
|
65
|
+
| `examples/tree.rb` | a supervision tree |
|
|
66
|
+
| `examples/dynamic.rb` | adding and removing children at runtime |
|
|
67
|
+
| `examples/graceful_shutdown.rb` | shutting down cleanly on a signal |
|
|
68
|
+
| `examples/crash_report.rb` | subscribing to events to report why a child died |
|
|
69
|
+
|
|
70
|
+
## Two kinds of worker
|
|
71
|
+
|
|
72
|
+
### `RactorShepherd::Worker`, the low level one
|
|
73
|
+
|
|
74
|
+
You write the loop yourself. `initialize` plays the role of OTP's `init`.
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
class Poller
|
|
78
|
+
include RactorShepherd::Worker
|
|
79
|
+
|
|
80
|
+
def initialize(url, interval)
|
|
81
|
+
super()
|
|
82
|
+
@url = url
|
|
83
|
+
@interval = interval
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def run(ctx)
|
|
87
|
+
until ctx.shutdown_requested?
|
|
88
|
+
fetch(@url)
|
|
89
|
+
ctx.sleep(@interval) # returns as soon as a shutdown is requested
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def terminate(reason) = nil # optional; reason is :normal, :shutdown, or an exception
|
|
94
|
+
end
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
What `ctx` (a `Context`) gives you:
|
|
98
|
+
|
|
99
|
+
| Method | Description |
|
|
100
|
+
|---|---|
|
|
101
|
+
| `id` / `path` | this child's id and path, e.g. `"root/jobs/poller"` |
|
|
102
|
+
| `supervisor` | the parent `SupervisorRef`, which is how you reach siblings |
|
|
103
|
+
| `receive(timeout: nil)` | receive one message; nil on timeout; raises `ShutdownSignal` after a shutdown request |
|
|
104
|
+
| `shutdown_requested?` | has a shutdown been requested? |
|
|
105
|
+
| `sleep(seconds)` | sleep, but wake on shutdown; true if it slept the whole time, false if cut short |
|
|
106
|
+
| `emit(name, **data)` | publish an application event to the event port |
|
|
107
|
+
|
|
108
|
+
### `RactorShepherd::Server`, the GenServer style one
|
|
109
|
+
|
|
110
|
+
`run` is already written; you fill in `handle_call` and `handle_cast`. Whatever
|
|
111
|
+
`handle_call` returns becomes the reply.
|
|
112
|
+
|
|
113
|
+
## Restart strategies
|
|
114
|
+
|
|
115
|
+
With children `[a, b, c, d]`, when `b` dies:
|
|
116
|
+
|
|
117
|
+
| Strategy | Stopped (reverse start order) | Started (start order) |
|
|
118
|
+
|---|---|---|
|
|
119
|
+
| `:one_for_one` | nothing | `[b]` |
|
|
120
|
+
| `:one_for_all` | `[d, c, a]` | `[a, b, c, d]` |
|
|
121
|
+
| `:rest_for_one` | `[d, c]` | `[b, c, d]` |
|
|
122
|
+
|
|
123
|
+
The restart kind (`restart:`) decides whether that child is restarted at all.
|
|
124
|
+
|
|
125
|
+
| | Exited on its own | Crashed |
|
|
126
|
+
|---|---|---|
|
|
127
|
+
| `:permanent` (default) | restart | restart |
|
|
128
|
+
| `:transient` | leave it stopped | restart |
|
|
129
|
+
| `:temporary` | drop the spec | drop the spec |
|
|
130
|
+
|
|
131
|
+
Once there have been more than `max_restarts` restarts within `max_seconds`, the
|
|
132
|
+
supervisor gives up: it crashes itself so that its parent decides what happens
|
|
133
|
+
next. At the root, `SupervisorRef#join` raises `SupervisorCrashed`, which is your
|
|
134
|
+
cue to log and `exit(1)` and let systemd or Kubernetes restart the process.
|
|
135
|
+
|
|
136
|
+
## Things to watch out for
|
|
137
|
+
|
|
138
|
+
- **Ractors are experimental.** The first `Ractor.new` prints a warning. Silence it
|
|
139
|
+
with `Warning[:experimental] = false`.
|
|
140
|
+
- **Ractors cannot be killed from outside.** Shutdown is cooperative only. A child
|
|
141
|
+
spinning on the CPU without looking at `ctx`, or blocked inside a C call, cannot
|
|
142
|
+
be stopped; after `shutdown_timeout` it is treated as unresponsive.
|
|
143
|
+
- **Symbols starting with `:"$"` are reserved.** Do not start your own messages with one.
|
|
144
|
+
- **Do not call `value` or `join` on a child.** Once another Ractor has taken a
|
|
145
|
+
Ractor's value, the supervisor can no longer read the exit reason and reports
|
|
146
|
+
`:unknown`. Restarting still works.
|
|
147
|
+
- **Do not call the supervisor synchronously from `initialize`.** The supervisor is
|
|
148
|
+
waiting for this child to finish starting, so the call deadlocks until
|
|
149
|
+
`start_timeout` fires. Look siblings up from `run` instead.
|
|
150
|
+
- **Restarting resets a child's state**, exactly as in Erlang. Anything that must
|
|
151
|
+
survive belongs outside the child.
|
|
152
|
+
- **Delivery is at-most-once.** A message that arrived just before the child died is lost.
|
|
153
|
+
- **`call` is not for hot paths.** Each one builds a port and a timer thread:
|
|
154
|
+
roughly 30k req/s against 1.1M msg/s for `cast` (see [bench/RESULTS.md](bench/RESULTS.md)).
|
|
155
|
+
Use `cast`, or your own `Ractor::Port`, when messages are frequent.
|
|
156
|
+
- **Stopping can take as long as the sum of every child's `shutdown_timeout`**,
|
|
157
|
+
because children are stopped one at a time in reverse order.
|
|
158
|
+
|
|
159
|
+
### Gems that are not Ractor safe
|
|
160
|
+
|
|
161
|
+
Calling a C extension that is not Ractor safe raises `Ractor::UnsafeError`, and
|
|
162
|
+
referencing something unshareable raises `Ractor::IsolationError`. Either way the
|
|
163
|
+
child keeps crashing until the supervisor escalates. This gem attaches a `hint` to
|
|
164
|
+
the event for the exceptions it recognises, so start from the event log.
|
|
165
|
+
|
|
166
|
+
```text
|
|
167
|
+
ERROR -- [ractor_shepherd] root child_exited child=:a status=:aborted reason=:error
|
|
168
|
+
error_class="Ractor::IsolationError" hint="the child may be referencing something unshareable, such as a Proc or an IO"
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
`args:` and `kwargs:` accept only values that survive
|
|
172
|
+
`Ractor.make_shareable(copy: true)`. Procs, Threads and Mutexes do not. Pass plain
|
|
173
|
+
values and hand the behaviour over as a class.
|
|
174
|
+
|
|
175
|
+
### A note on `Ractor#unmonitor` in Ruby 4.0
|
|
176
|
+
|
|
177
|
+
Ruby 4.0.6's `Ractor#unmonitor(port)` looks a monitor registration up by port id
|
|
178
|
+
alone and ignores which Ractor created the port. Port ids are a per Ractor
|
|
179
|
+
sequence, so when another Ractor is monitoring the same target, unmonitoring drops
|
|
180
|
+
its registration too. Reproduce it with `ruby spike/unmonitor_id_collision.rb`.
|
|
181
|
+
|
|
182
|
+
This gem therefore never calls `unmonitor`. One consequence: when a callee dies
|
|
183
|
+
mid-call, the caller finds out after the timeout rather than immediately.
|
|
184
|
+
**Do not call `unmonitor` on a Ractor this gem manages, either.**
|
|
185
|
+
|
|
186
|
+
## Stopping on a signal
|
|
187
|
+
|
|
188
|
+
Do not touch ports from inside a trap handler; call `stop` from a thread instead.
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
Signal.trap("TERM") { Thread.new { sup.stop(:shutdown, timeout: 10) } }
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
When the main Ractor ends, the process ends and takes every Ractor with it, so
|
|
195
|
+
either use the block form of `RactorShepherd.run` or stop the supervisor from `at_exit`.
|
|
196
|
+
|
|
197
|
+
## Development
|
|
198
|
+
|
|
199
|
+
```console
|
|
200
|
+
$ bundle install
|
|
201
|
+
$ bundle exec rake # rubocop + lint:no_loop + spec + rbs validate
|
|
202
|
+
$ bundle exec rake spec:core # the unit tests, which use no Ractors
|
|
203
|
+
$ bundle exec rake spec:stress # the stress tests
|
|
204
|
+
$ bundle exec rake spec:isolated # one file per process, to catch hangs
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Ractor compatibility is checked with [audition](https://github.com/ruby/audition).
|
|
208
|
+
|
|
209
|
+
```console
|
|
210
|
+
$ audition lib
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## License
|
|
214
|
+
|
|
215
|
+
MIT. See [LICENSE.txt](LICENSE.txt).
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RactorShepherd
|
|
4
|
+
# A destination identified by name, returned by `sup.lookup(:jobs, :poller)`.
|
|
5
|
+
#
|
|
6
|
+
# It caches what it resolved, so it is **not shareable**: build one per Ractor
|
|
7
|
+
# that uses it.
|
|
8
|
+
#
|
|
9
|
+
# Delivery is at-most-once. A message that arrived just before the child died
|
|
10
|
+
# is lost.
|
|
11
|
+
class Address
|
|
12
|
+
RETRY_INITIAL_DELAY = 0.01
|
|
13
|
+
RETRY_MAX_DELAY = 0.2
|
|
14
|
+
RESOLVE_POLL_INTERVAL = 0.01
|
|
15
|
+
|
|
16
|
+
attr_reader :path
|
|
17
|
+
|
|
18
|
+
def initialize(supervisor, path, retries: 3, resolve_timeout: 1.0)
|
|
19
|
+
raise InvalidSpec, "lookup needs at least one id" if path.empty?
|
|
20
|
+
|
|
21
|
+
@supervisor = supervisor
|
|
22
|
+
@path = path.freeze
|
|
23
|
+
@retries = retries
|
|
24
|
+
@resolve_timeout = resolve_timeout
|
|
25
|
+
@ractor = nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The currently cached destination, which may be stale.
|
|
29
|
+
def ractor
|
|
30
|
+
@ractor ||= resolve
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Throw the cache away and resolve again.
|
|
34
|
+
def resolve!
|
|
35
|
+
@ractor = nil
|
|
36
|
+
ractor
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Send without waiting. If the destination has been replaced, resolve again and retry.
|
|
40
|
+
def cast(message)
|
|
41
|
+
delay = RETRY_INITIAL_DELAY
|
|
42
|
+
attempts = 0
|
|
43
|
+
while true # `loop do` is forbidden here: Ractor::ClosedError < StopIteration
|
|
44
|
+
begin
|
|
45
|
+
return send_to(ractor, message)
|
|
46
|
+
rescue Ractor::ClosedError
|
|
47
|
+
raise ChildUnavailable, "#{label} is gone" if attempts >= @retries
|
|
48
|
+
|
|
49
|
+
attempts += 1
|
|
50
|
+
Kernel.sleep(delay)
|
|
51
|
+
delay = [delay * 2, RETRY_MAX_DELAY].min
|
|
52
|
+
@ractor = nil
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# A synchronous call, for Server children. It never retries on its own,
|
|
58
|
+
# because the request may not be idempotent.
|
|
59
|
+
def call(message, timeout: 5)
|
|
60
|
+
target = ractor
|
|
61
|
+
raise InvalidOperation, "#{label} is a supervisor; call its SupervisorRef instead" unless target.is_a?(Ractor)
|
|
62
|
+
|
|
63
|
+
Runtime::Call.perform(target, target, message, timeout: timeout, down_error: WorkerDown)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def label = "#{@supervisor.path}/#{@path.join("/")}"
|
|
69
|
+
|
|
70
|
+
def send_to(target, message)
|
|
71
|
+
raise InvalidOperation, "#{label} is a supervisor; send to its SupervisorRef" unless target.is_a?(Ractor)
|
|
72
|
+
|
|
73
|
+
target << message
|
|
74
|
+
:ok
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# While the child is being restarted there is nothing to resolve to, so keep
|
|
78
|
+
# trying until resolve_timeout runs out.
|
|
79
|
+
def resolve
|
|
80
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @resolve_timeout
|
|
81
|
+
while true # `loop do` is forbidden here: Ractor::ClosedError < StopIteration
|
|
82
|
+
found = walk
|
|
83
|
+
return found if found
|
|
84
|
+
raise ChildUnavailable, "#{label} is not running" if
|
|
85
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
86
|
+
|
|
87
|
+
Kernel.sleep(RESOLVE_POLL_INTERVAL)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def walk
|
|
92
|
+
current = @supervisor
|
|
93
|
+
@path[0..-2].each do |segment|
|
|
94
|
+
current = current.whereis(segment)
|
|
95
|
+
return nil unless current.is_a?(SupervisorRef)
|
|
96
|
+
end
|
|
97
|
+
current.whereis(@path.last)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RactorShepherd
|
|
4
|
+
module Core
|
|
5
|
+
# Works out how long to wait before restarting a child.
|
|
6
|
+
#
|
|
7
|
+
# The consecutive failure count resets to 1 when the child had been up for
|
|
8
|
+
# at least `reset_after` seconds before dying.
|
|
9
|
+
#
|
|
10
|
+
# @api private
|
|
11
|
+
class Backoff
|
|
12
|
+
attr_reader :attempt
|
|
13
|
+
|
|
14
|
+
# @param spec [nil, Numeric, BackoffSpec]
|
|
15
|
+
# @param reset_after [Numeric] seconds of uptime after which failures stop counting as consecutive
|
|
16
|
+
def initialize(spec, reset_after:)
|
|
17
|
+
@spec = spec
|
|
18
|
+
@reset_after = reset_after
|
|
19
|
+
@attempt = 0
|
|
20
|
+
@started_at = nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The child came up.
|
|
24
|
+
def record_start(now)
|
|
25
|
+
@started_at = now
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The child went down, or failed to start.
|
|
29
|
+
#
|
|
30
|
+
# @return [Integer] the consecutive failure count
|
|
31
|
+
def record_failure(now)
|
|
32
|
+
@attempt = if @started_at && (now - @started_at) >= @reset_after
|
|
33
|
+
1
|
|
34
|
+
else
|
|
35
|
+
@attempt + 1
|
|
36
|
+
end
|
|
37
|
+
@started_at = nil
|
|
38
|
+
@attempt
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [Numeric] seconds to wait before the next start; 0 means right away
|
|
42
|
+
def delay
|
|
43
|
+
return 0 if attempt.zero?
|
|
44
|
+
|
|
45
|
+
case @spec
|
|
46
|
+
when nil then 0
|
|
47
|
+
when Numeric then @spec
|
|
48
|
+
when BackoffSpec then [@spec.max, @spec.initial * (@spec.factor**(attempt - 1))].min
|
|
49
|
+
else raise ArgumentError, "unknown restart_delay: #{@spec.inspect}"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def reset
|
|
54
|
+
@attempt = 0
|
|
55
|
+
@started_at = nil
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RactorShepherd
|
|
4
|
+
module Core
|
|
5
|
+
# Builds the event hashes the supervisor and its children publish.
|
|
6
|
+
#
|
|
7
|
+
# Events travel between Ractors, so they are always deeply frozen.
|
|
8
|
+
#
|
|
9
|
+
# @api private
|
|
10
|
+
module Event
|
|
11
|
+
BACKTRACE_LINES = 10
|
|
12
|
+
|
|
13
|
+
HINTS = Ractor.make_shareable({
|
|
14
|
+
"Ractor::UnsafeError" =>
|
|
15
|
+
"the child may be calling a C extension that is not Ractor safe",
|
|
16
|
+
"Ractor::IsolationError" =>
|
|
17
|
+
"the child may be referencing something unshareable, such as a Proc or an IO",
|
|
18
|
+
"Ractor::MovedError" =>
|
|
19
|
+
"the child may be referencing an object that was moved to another Ractor"
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# @param type [Symbol]
|
|
25
|
+
# @param supervisor [String] the supervisor's path, e.g. "root/jobs"
|
|
26
|
+
# @param at [Float] CLOCK_REALTIME
|
|
27
|
+
# @return [Hash] deeply frozen
|
|
28
|
+
def build(type, supervisor:, at:, **data)
|
|
29
|
+
Ractor.make_shareable({ type: type, supervisor: supervisor, at: at }.merge(data), copy: true)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Event keys describing an exception.
|
|
33
|
+
#
|
|
34
|
+
# @param error [Exception, Symbol, nil]
|
|
35
|
+
# @return [Hash]
|
|
36
|
+
def error_info(error)
|
|
37
|
+
return {} unless error.is_a?(Exception)
|
|
38
|
+
|
|
39
|
+
{
|
|
40
|
+
error_class: error.class.name,
|
|
41
|
+
error_message: error.message.to_s,
|
|
42
|
+
backtrace: (error.backtrace || []).first(BACKTRACE_LINES),
|
|
43
|
+
hint: hint_for(error)
|
|
44
|
+
}.compact
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [String, nil] advice for exceptions we recognise
|
|
48
|
+
def hint_for(error)
|
|
49
|
+
klass = error.is_a?(Exception) ? error.class : error
|
|
50
|
+
klass.ancestors.each do |ancestor|
|
|
51
|
+
hint = HINTS[ancestor.name]
|
|
52
|
+
return hint if hint
|
|
53
|
+
end
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Normalise an exit reason to :normal, :shutdown, :error or :unknown.
|
|
58
|
+
def reason_kind(reason)
|
|
59
|
+
case reason
|
|
60
|
+
when :normal, :shutdown then reason
|
|
61
|
+
when Exception then :error
|
|
62
|
+
else :unknown
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RactorShepherd
|
|
4
|
+
# Pure logic: no Ractors, no threads, no clock reads. The caller passes the time in.
|
|
5
|
+
module Core
|
|
6
|
+
# Give up once there have been more than `max_restarts` restarts within `max_seconds`.
|
|
7
|
+
#
|
|
8
|
+
# @api private
|
|
9
|
+
class RestartIntensity
|
|
10
|
+
attr_reader :max_restarts, :max_seconds
|
|
11
|
+
|
|
12
|
+
def initialize(max_restarts:, max_seconds:)
|
|
13
|
+
@max_restarts = max_restarts
|
|
14
|
+
@max_seconds = max_seconds
|
|
15
|
+
@times = []
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Call this once per failure, even when the strategy restarts several children.
|
|
19
|
+
#
|
|
20
|
+
# @param now [Float] CLOCK_MONOTONIC
|
|
21
|
+
# @return [Symbol] :ok or :exceeded
|
|
22
|
+
def record(now)
|
|
23
|
+
@times.reject! { |t| now - t > max_seconds }
|
|
24
|
+
@times << now
|
|
25
|
+
@times.size > max_restarts ? :exceeded : :ok
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [Integer] restarts still inside the window
|
|
29
|
+
def count = @times.size
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RactorShepherd
|
|
4
|
+
module Core
|
|
5
|
+
# Decides whether a child should be restarted, from its restart kind and how it exited.
|
|
6
|
+
#
|
|
7
|
+
# Exits the supervisor asked for do not go through this table; the shutdown
|
|
8
|
+
# path handles those.
|
|
9
|
+
#
|
|
10
|
+
# @api private
|
|
11
|
+
module RestartPolicy
|
|
12
|
+
# | restart \ status | :exited | :aborted |
|
|
13
|
+
# |------------------|------------------|----------|
|
|
14
|
+
# | :permanent | :restart | :restart |
|
|
15
|
+
# | :transient | :keep_terminated | :restart |
|
|
16
|
+
# | :temporary | :remove | :remove |
|
|
17
|
+
TABLE = Ractor.make_shareable({
|
|
18
|
+
permanent: { exited: :restart, aborted: :restart },
|
|
19
|
+
transient: { exited: :keep_terminated, aborted: :restart },
|
|
20
|
+
temporary: { exited: :remove, aborted: :remove }
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
# @return [Symbol] :restart, :keep_terminated or :remove
|
|
24
|
+
def self.decide(restart:, status:, dynamic: false)
|
|
25
|
+
by_status = TABLE[restart] or raise ArgumentError, "unknown restart: #{restart.inspect}"
|
|
26
|
+
decision = by_status[status] or raise ArgumentError, "unknown status: #{status.inspect}"
|
|
27
|
+
# A dynamic supervisor keeps no spec around, so "stay terminated" becomes "forget it".
|
|
28
|
+
return :remove if dynamic && decision == :keep_terminated
|
|
29
|
+
|
|
30
|
+
decision
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RactorShepherd
|
|
4
|
+
module Core
|
|
5
|
+
# Turns a restart strategy into the concrete list of children to stop and start.
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
module StrategyPlanner
|
|
9
|
+
# The little that the planner needs to know about a child.
|
|
10
|
+
ChildView = Data.define(:id, :restart, :alive)
|
|
11
|
+
|
|
12
|
+
# `terminate` is in stop order (reverse of start order), `start` is in start order.
|
|
13
|
+
Plan = Data.define(:terminate, :start, :remove)
|
|
14
|
+
|
|
15
|
+
# @param children [Array<ChildView>] in start order
|
|
16
|
+
# @param failed_id [Object] the id of the child that went down
|
|
17
|
+
# @param strategy [Symbol] :one_for_one, :one_for_all or :rest_for_one
|
|
18
|
+
# @return [Plan]
|
|
19
|
+
def self.plan(children:, failed_id:, strategy:)
|
|
20
|
+
index = children.index { |c| c.id == failed_id }
|
|
21
|
+
raise ArgumentError, "unknown child: #{failed_id.inspect}" if index.nil?
|
|
22
|
+
|
|
23
|
+
case strategy
|
|
24
|
+
when :one_for_one then Plan.new(terminate: [], start: [failed_id], remove: [])
|
|
25
|
+
when :one_for_all then for_group(children, children, failed_id)
|
|
26
|
+
when :rest_for_one then for_group(children, children[index..], failed_id)
|
|
27
|
+
else raise ArgumentError, "unknown strategy: #{strategy.inspect}"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @param scope [Array<ChildView>] the children the strategy reaches, in start order
|
|
32
|
+
def self.for_group(_children, scope, failed_id)
|
|
33
|
+
affected = scope.reject { |c| c.id == failed_id }
|
|
34
|
+
terminate = affected.select(&:alive)
|
|
35
|
+
Plan.new(
|
|
36
|
+
terminate: terminate.map(&:id).reverse,
|
|
37
|
+
start: scope.reject { |c| c.restart == :temporary }.map(&:id),
|
|
38
|
+
remove: terminate.select { |c| c.restart == :temporary }.map(&:id)
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
private_class_method :for_group
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RactorShepherd
|
|
4
|
+
# Base class for every error this gem raises.
|
|
5
|
+
#
|
|
6
|
+
# Exceptions are copied across Ractor boundaries, so keep their instance
|
|
7
|
+
# variables to simple values: symbols, numbers, frozen strings.
|
|
8
|
+
class Error < StandardError; end
|
|
9
|
+
|
|
10
|
+
# A ChildSpec or SupervisorSpec is invalid.
|
|
11
|
+
class InvalidSpec < Error; end
|
|
12
|
+
|
|
13
|
+
# Loaded on a Ruby that does not meet the requirements.
|
|
14
|
+
class UnsupportedRuby < Error; end
|
|
15
|
+
|
|
16
|
+
# The initial boot failed. The child's exception is the cause.
|
|
17
|
+
class StartError < Error; end
|
|
18
|
+
|
|
19
|
+
# Raised by #join when the supervisor had escalated instead of stopping.
|
|
20
|
+
class SupervisorCrashed < Error; end
|
|
21
|
+
|
|
22
|
+
# Restart intensity exceeded. Raised inside a supervisor and propagated to its parent.
|
|
23
|
+
class MaxRestartsExceeded < Error; end
|
|
24
|
+
|
|
25
|
+
# A child did not acknowledge a shutdown request, or did not finish starting, in time.
|
|
26
|
+
class ChildUnresponsive < Error; end
|
|
27
|
+
|
|
28
|
+
# A synchronous call timed out.
|
|
29
|
+
class CallTimeout < Error; end
|
|
30
|
+
|
|
31
|
+
# The supervisor being called is not running.
|
|
32
|
+
class SupervisorDown < Error; end
|
|
33
|
+
|
|
34
|
+
# The worker being called is not running.
|
|
35
|
+
class WorkerDown < Error; end
|
|
36
|
+
|
|
37
|
+
# No child with that id.
|
|
38
|
+
class ChildNotFound < Error; end
|
|
39
|
+
|
|
40
|
+
# The child exists but has no running Ractor right now (it is being restarted).
|
|
41
|
+
class ChildUnavailable < Error; end
|
|
42
|
+
|
|
43
|
+
# The operation does not apply in the current state.
|
|
44
|
+
class InvalidOperation < Error; end
|
|
45
|
+
|
|
46
|
+
# A dynamic supervisor is already at max_children.
|
|
47
|
+
class MaxChildrenReached < Error; end
|
|
48
|
+
|
|
49
|
+
# An unexpected message shape arrived. Signals a bug.
|
|
50
|
+
class ProtocolError < Error; end
|
|
51
|
+
|
|
52
|
+
# Signals that a shutdown was requested.
|
|
53
|
+
#
|
|
54
|
+
# It inherits Exception rather than StandardError so that a plain
|
|
55
|
+
# `rescue => e` in user code cannot swallow it.
|
|
56
|
+
class ShutdownSignal < Exception; end # rubocop:disable Lint/InheritException
|
|
57
|
+
end
|