supervisor_machines 0.1.1

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,320 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SupervisorMachines
4
+ # A supervision tree over threads.
5
+ #
6
+ # Which children a failure takes down, and whether restarting is still worth
7
+ # trying, belongs entirely to {Decider}. This class owns the lifecycle:
8
+ # spawning children, noticing when one stops, waiting out the backoff
9
+ # +chrono_machines+ hands it, and taking the tree down in reverse start order
10
+ # inside a +deadline_machines+ budget.
11
+ #
12
+ # A tree is +registering+ until {#start} fixes its shape, +running+ while it
13
+ # watches its children, and ends either +stopped+ or +failed+. Both endings
14
+ # are final: {#start} refuses a tree that is not registering, and a +failed+
15
+ # tree answers every {#stop} with {Escalated}.
16
+ #
17
+ # tree = SupervisorMachines::Supervisor.new(strategy: :one_for_all, restarts: 3, window: 60.0)
18
+ # tree.supervise(:cache) { Thread.new { cache.run } }
19
+ # tree.supervise(:warmup, policy: :transient) { Thread.new { warm } }
20
+ # tree.start
21
+ # tree.stop(grace: 5.0)
22
+ class Supervisor
23
+ # A registered child and whichever thread currently runs it. +epoch+ rises
24
+ # every time the child is taken down, so a thread the tree has already
25
+ # replaced neither reports its exit nor takes the child's place when its
26
+ # starter returns late. +done+ marks a child the tree has dropped, which no
27
+ # group restart brings back.
28
+ Child = Struct.new(:name, :policy, :starter, :thread, :epoch, :done)
29
+
30
+ # Wakes the monitor when the tree is being taken down.
31
+ STOP = Object.new
32
+ private_constant :STOP
33
+
34
+ attr_reader :strategy
35
+
36
+ # +restarts+ within +window+ seconds is the restart intensity; past it the
37
+ # tree escalates. +backoff+ is asked for the delay between a restart
38
+ # decision and the respawn; its attempt budget has to be one wider than the
39
+ # intensity, because the core escalates one restart after the last one it
40
+ # tolerates.
41
+ def initialize(strategy: :one_for_one, restarts: DEFAULT_RESTARTS,
42
+ window: DEFAULT_WINDOW_NS / NS_PER_SECOND.to_f, backoff: nil)
43
+ raise ArgumentError, "unknown strategy: #{strategy.inspect}" unless STRATEGIES.include?(strategy)
44
+ raise ArgumentError, "at most #{Decider::MAX_RESTARTS} restarts" if restarts > Decider::MAX_RESTARTS
45
+
46
+ @strategy = strategy
47
+ @restarts = restarts
48
+ @window = window
49
+ @backoff = backoff || ChronoMachines::Executor.new(max_attempts: restarts + 1)
50
+ @slots = []
51
+ @events = Thread::Queue.new
52
+ @mutex = Thread::Mutex.new
53
+ @halt = Thread::ConditionVariable.new
54
+ @state = :registering
55
+ @failed = nil
56
+ end
57
+
58
+ # Registers a child under +name+. The block is its starter: it returns the
59
+ # +Thread+ that runs the child, and is called again for every restart.
60
+ def supervise(name, policy: :permanent, &starter)
61
+ raise ArgumentError, "unknown policy: #{policy.inspect}" unless POLICIES.include?(policy)
62
+
63
+ @mutex.synchronize do
64
+ raise Error, "a #{@state} supervisor cannot take on a child" unless @state == :registering
65
+
66
+ @slots << Child.new(name, policy, starter, nil, 0, false)
67
+ end
68
+ self
69
+ end
70
+
71
+ # Fixes the shape of the tree, starts every child in registration order and
72
+ # begins watching them. A starter that will not run takes the children
73
+ # already up back down, leaves the tree failed, and raises what the starter
74
+ # raised.
75
+ #
76
+ # Raises {Error} for a tree that is past registering.
77
+ def start
78
+ @mutex.synchronize do
79
+ raise Error, "a #{@state} supervisor cannot be started" unless @state == :registering
80
+
81
+ @decider = Decider.new(children: @slots.size, strategy: @strategy,
82
+ restarts: @restarts, window_ns: to_ns(@window))
83
+ @state = :running
84
+ @monitor = Thread.new { monitor }
85
+ end
86
+ failure = launch
87
+ return self unless failure
88
+
89
+ rollback
90
+ raise failure
91
+ end
92
+
93
+ # Child names, in start order.
94
+ def children
95
+ @mutex.synchronize { @slots.map(&:name) }
96
+ end
97
+
98
+ # Restarts still inside the intensity window.
99
+ def restarts
100
+ now = monotonic_ns
101
+ @mutex.synchronize { @decider ? @decider.recent_restarts(now) : 0 }
102
+ end
103
+
104
+ # True once the tree has escalated, and true from then on.
105
+ def terminated?
106
+ @mutex.synchronize { @state == :failed }
107
+ end
108
+
109
+ # Takes every running child down in reverse start order, giving the whole
110
+ # shutdown +grace+ seconds, or all the time it needs when +grace+ is nil.
111
+ # A tree still registering has nothing to take down and stays registering.
112
+ #
113
+ # Raises {Escalated} for a tree that gave up, every time it is called.
114
+ def stop(grace: nil)
115
+ entered = @mutex.synchronize do
116
+ state = @state
117
+ @state = :stopped if state == :running
118
+ @halt.broadcast
119
+ state
120
+ end
121
+ return self if entered == :registering
122
+
123
+ @events.push(STOP) if entered == :running
124
+ failures = nil
125
+ DeadlineMachines.with_deadline(grace) do |deadline|
126
+ failures = shutdown
127
+ @monitor.kill unless @monitor.join(deadline.remaining)
128
+ end
129
+ raise Escalated, escalation if terminated?
130
+ raise failures.first unless failures.empty?
131
+
132
+ self
133
+ end
134
+
135
+ # Seconds a supervisor taking itself down waits for a killed child, since
136
+ # neither the monitor thread nor a failing {#start} has a caller budget to
137
+ # read.
138
+ ESCALATION_GRACE = 5.0
139
+
140
+ private
141
+
142
+ # Brings every child up in registration order, returning what a starter
143
+ # raised, or nil once the whole tree is up.
144
+ def launch
145
+ @slots.each_index { |index| spawn(index) }
146
+ nil
147
+ rescue Exception => e
148
+ @mutex.synchronize do
149
+ @state = :failed
150
+ @failed = unstarted(e)
151
+ end
152
+ e
153
+ end
154
+
155
+ # Takes down whatever a start brought up before it failed, the children and
156
+ # the monitor watching them, inside one budget.
157
+ def rollback
158
+ DeadlineMachines.with_deadline(ESCALATION_GRACE) do |deadline|
159
+ shutdown
160
+ reap(@monitor, deadline.remaining)
161
+ end
162
+ end
163
+
164
+ def running?
165
+ @state == :running
166
+ end
167
+
168
+ def monitor
169
+ Thread.current.report_on_exception = false
170
+ loop do
171
+ event = @events.pop
172
+ break if event.equal?(STOP)
173
+
174
+ handle(*event)
175
+ end
176
+ end
177
+
178
+ # One child stopped. The core says what that means; this carries it out.
179
+ def handle(index, epoch, ending)
180
+ now = monotonic_ns
181
+ decision = @mutex.synchronize do
182
+ child = @slots[index]
183
+ next unless running? && child.epoch == epoch
184
+
185
+ child.thread = nil
186
+ child.done = true if child.policy == :temporary
187
+ @decider.stopped(index: index, policy: child.policy, exit: ending, now_ns: now)
188
+ end
189
+
190
+ case decision
191
+ when Range then restart(decision)
192
+ when :escalate then escalate
193
+ end
194
+ end
195
+
196
+ # Retires the whole span in reverse start order and waits for it to go,
197
+ # waits out the backoff without holding the tree, then brings the span back
198
+ # in index order.
199
+ def restart(span)
200
+ retiring = @mutex.synchronize { span.reverse_each.filter_map { |index| retire(@slots[index]) } }
201
+ DeadlineMachines.with_deadline(ESCALATION_GRACE) do |deadline|
202
+ retiring.each { |thread| reap(thread, deadline.remaining) }
203
+ end
204
+ return escalate(abandoned) if retiring.any?(&:alive?)
205
+
206
+ failure = respawn(span)
207
+ escalate(failure) if failure
208
+ end
209
+
210
+ # The reason a respawn could not happen, or nil once the span is running.
211
+ def respawn(span)
212
+ delay = @backoff.next_delay(attempt: restarts)
213
+ @mutex.synchronize { @halt.wait(@mutex, delay) if delay && running? }
214
+ span.each { |index| spawn(index) }
215
+ nil
216
+ rescue Exception => e
217
+ unstarted(e)
218
+ end
219
+
220
+ def escalate(reason = escalation)
221
+ @mutex.synchronize do
222
+ @state = :failed
223
+ @failed = reason
224
+ end
225
+ shutdown(ESCALATION_GRACE)
226
+ raise Escalated, reason
227
+ end
228
+
229
+ # Reverse start order, each child killed and then waited for inside one
230
+ # budget shared by the whole tree. Touches no tree state, so a shutdown
231
+ # under way in the monitor never holds up the one in {#stop}. Returns what
232
+ # the children raised on their way out, having stopped all of them.
233
+ def shutdown(grace = DeadlineMachines.remaining)
234
+ failures = []
235
+ DeadlineMachines.with_deadline(grace) do |deadline|
236
+ @slots.reverse_each do |child|
237
+ thread = child.thread
238
+ next unless thread
239
+
240
+ failures << reap(thread, deadline.remaining)
241
+ end
242
+ end
243
+ failures.compact
244
+ end
245
+
246
+ # Kills the thread and waits out the rest of the budget for it, reporting
247
+ # whatever it raised on the way out. A thread that outlives the budget is
248
+ # still alive afterwards.
249
+ def reap(thread, remaining)
250
+ thread.kill
251
+ thread.join(remaining)
252
+ nil
253
+ rescue Exception => e
254
+ e
255
+ end
256
+
257
+ # Calls the child's starter outside the lock, under the generation the tree
258
+ # is in when the call goes out. A child the tree has dropped, and every
259
+ # child of a tree that is no longer running, is left alone.
260
+ def spawn(index)
261
+ child = @slots[index]
262
+ generation = @mutex.synchronize { child.epoch if running? && !child.done }
263
+ install(index, generation, child.starter.call) if generation
264
+ end
265
+
266
+ # Hands the started thread to the child, unless the tree has moved past the
267
+ # generation that asked for it, in which case the thread is killed instead
268
+ # of left running with nothing watching it.
269
+ def install(index, generation, thread)
270
+ installed = @mutex.synchronize do
271
+ child = @slots[index]
272
+ next false unless running? && child.epoch == generation
273
+
274
+ child.thread = thread
275
+ watch(index, generation, thread)
276
+ true
277
+ end
278
+ reap(thread, ESCALATION_GRACE) unless installed
279
+ end
280
+
281
+ # Invalidates the watcher following the child and hands its thread back to
282
+ # be reaped outside the lock.
283
+ def retire(child)
284
+ child.epoch += 1
285
+ thread = child.thread
286
+ child.thread = nil
287
+ thread
288
+ end
289
+
290
+ # One thread per running child, blocked on it, reporting how it ended.
291
+ def watch(index, epoch, thread)
292
+ Thread.new do
293
+ thread.join
294
+ @events.push([index, epoch, :normal])
295
+ rescue Exception
296
+ @events.push([index, epoch, :abnormal])
297
+ end
298
+ end
299
+
300
+ def escalation
301
+ @failed || "#{@strategy} supervisor escalated: more than #{@restarts} restarts in #{@window} seconds"
302
+ end
303
+
304
+ def abandoned
305
+ "#{@strategy} supervisor escalated: a retired child would not stop within #{ESCALATION_GRACE} seconds"
306
+ end
307
+
308
+ def unstarted(error)
309
+ "#{@strategy} supervisor escalated: #{error.message}"
310
+ end
311
+
312
+ def monotonic_ns
313
+ DeadlineMachines.clock.now_ns
314
+ end
315
+
316
+ def to_ns(seconds)
317
+ DeadlineMachines::Deadline.to_ns(seconds)
318
+ end
319
+ end
320
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SupervisorMachines
4
+ VERSION = '0.1.1'
5
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'chrono_machines'
4
+ require 'deadline_machines'
5
+
6
+ require_relative 'supervisor_machines/version'
7
+
8
+ # Decider is defined by the native extension, which binds the
9
+ # `supervisor-machines` crate; there is no pure-Ruby implementation to fall
10
+ # back to.
11
+ require 'supervisor_machines_native/supervisor_machines_native'
12
+
13
+ require_relative 'supervisor_machines/errors'
14
+ require_relative 'supervisor_machines/supervisor'
@@ -0,0 +1,173 @@
1
+ # OTP-style supervision over Ruby threads.
2
+ #
3
+ # `Decider` is defined by the native extension, which binds the
4
+ # `supervisor-machines` crate: it decides which children a failure takes down
5
+ # and when restarting has stopped being worth it. `Supervisor` owns everything
6
+ # the core refuses to touch, which is every thread.
7
+ module SupervisorMachines
8
+ VERSION: String
9
+
10
+ # Nanoseconds in a second. Seconds are what the Ruby surface takes; the core
11
+ # is fed nanosecond integers.
12
+ NS_PER_SECOND: Integer
13
+
14
+ # Restarts OTP tolerates by default before escalating.
15
+ DEFAULT_RESTARTS: Integer
16
+
17
+ # The window OTP measures those restarts over, five seconds.
18
+ DEFAULT_WINDOW_NS: Integer
19
+
20
+ # Which siblings a failure takes down with it.
21
+ type strategy = :one_for_one | :one_for_all | :rest_for_one
22
+
23
+ # When a child is worth restarting.
24
+ type policy = :permanent | :transient | :temporary
25
+
26
+ # How a child's run ended.
27
+ type exit_kind = :normal | :abnormal
28
+
29
+ # What the core decided: leave it stopped, restart this inclusive range of
30
+ # child indices, or give up.
31
+ type decision = :ignore | :escalate | Range[Integer]
32
+
33
+ # Every strategy, in the order the core declares them. Anything else raises
34
+ # `ArgumentError`.
35
+ STRATEGIES: Array[strategy]
36
+
37
+ # Every policy, in the order the core declares them. Anything else raises
38
+ # `ArgumentError`.
39
+ POLICIES: Array[policy]
40
+
41
+ class Error < StandardError
42
+ end
43
+
44
+ # Raised once a supervisor has spent its restart intensity.
45
+ class Escalated < Error
46
+ end
47
+
48
+ # The core's decision machine. A child count and a restart history are part
49
+ # of its Rust type, so the count given here is dispatched to one of
50
+ # `SUPPORTED_CHILDREN` monomorphised shapes.
51
+ class Decider
52
+ # Restart timestamps every shape keeps.
53
+ HISTORY: Integer
54
+
55
+ # The child counts a decider can be built for. Anything else raises
56
+ # `ArgumentError`.
57
+ SUPPORTED_CHILDREN: Array[Integer]
58
+
59
+ def self.new: (
60
+ children: Integer,
61
+ strategy: strategy,
62
+ restarts: Integer,
63
+ window_ns: Integer
64
+ ) -> instance
65
+
66
+ # Feeds one stopped child to the core and returns what it decided.
67
+ def stopped: (
68
+ index: Integer,
69
+ policy: policy,
70
+ exit: exit_kind,
71
+ now_ns: Integer
72
+ ) -> decision
73
+
74
+ def children: () -> Integer
75
+
76
+ def strategy: () -> strategy
77
+
78
+ # True once the core has escalated, and true from then on.
79
+ def terminated?: () -> bool
80
+
81
+ # Restarts still inside the intensity window as of `now_ns`.
82
+ def recent_restarts: (Integer) -> Integer
83
+
84
+ def inspect: () -> String
85
+ end
86
+
87
+ # A supervision tree over threads.
88
+ class Supervisor
89
+ # Where a tree is in its lifecycle: registering children, running them, or
90
+ # ended — `stopped` by its caller, `failed` by giving up.
91
+ type state = :registering | :running | :stopped | :failed
92
+
93
+ # Seconds a supervisor taking itself down waits for a killed child.
94
+ ESCALATION_GRACE: Float
95
+
96
+ # Anything that can price the wait between a restart decision and the
97
+ # respawn, which is `ChronoMachines::Executor` unless told otherwise.
98
+ interface _Backoff
99
+ def next_delay: (attempt: Integer) -> Numeric?
100
+ end
101
+
102
+ # A registered child and whichever thread currently runs it.
103
+ class Child < ::Struct[untyped]
104
+ attr_accessor name: untyped
105
+ attr_accessor policy: policy
106
+ attr_accessor starter: ^() -> Thread
107
+ attr_accessor thread: Thread?
108
+ attr_accessor epoch: Integer
109
+ attr_accessor done: bool
110
+ end
111
+
112
+ attr_reader strategy: strategy
113
+
114
+ # `restarts` within `window` seconds is the restart intensity.
115
+ def initialize: (
116
+ ?strategy: strategy,
117
+ ?restarts: Integer,
118
+ ?window: Float,
119
+ ?backoff: _Backoff?
120
+ ) -> void
121
+
122
+ # Registers a child under `name`. The block returns the `Thread` that runs
123
+ # it, and is called again for every restart.
124
+ def supervise: (untyped name, ?policy: policy) { () -> Thread } -> self
125
+
126
+ # Fixes the shape of the tree and starts every child in registration
127
+ # order. Raises `ArgumentError` for a child count the core has no shape
128
+ # for, `Error` for a tree that is past registering, and whatever a starter
129
+ # raised after taking the children already up back down.
130
+ def start: () -> self
131
+
132
+ # Child names, in start order.
133
+ def children: () -> Array[untyped]
134
+
135
+ # Restarts still inside the intensity window.
136
+ def restarts: () -> Integer
137
+
138
+ def terminated?: () -> bool
139
+
140
+ # Takes every running child down in reverse start order within `grace`
141
+ # seconds, or all the time it needs when `grace` is nil. A tree still
142
+ # registering has nothing to take down. Raises `Escalated` for a tree that
143
+ # gave up, every time it is called.
144
+ def stop: (?grace: Float?) -> self
145
+
146
+ private
147
+
148
+ # What a starter raised, after the children already up have been taken
149
+ # back down, or nil once the whole tree is up.
150
+ def launch: () -> Exception?
151
+ def rollback: () -> void
152
+ def running?: () -> bool
153
+ def monitor: () -> void
154
+ def handle: (Integer index, Integer epoch, exit_kind ending) -> void
155
+ def restart: (Range[Integer] span) -> void
156
+ def respawn: (Range[Integer] span) -> String?
157
+ def escalate: (?String reason) -> void
158
+ def shutdown: (?Float grace) -> Array[Exception]
159
+ def reap: (Thread thread, Float? remaining) -> Exception?
160
+ def spawn: (Integer index) -> void
161
+
162
+ # Installs a started thread under the generation that asked for it, killing
163
+ # it when the tree has moved past that generation.
164
+ def install: (Integer index, Integer generation, Thread thread) -> void
165
+ def retire: (Child child) -> Thread?
166
+ def watch: (Integer index, Integer epoch, Thread thread) -> void
167
+ def escalation: () -> String
168
+ def abandoned: () -> String
169
+ def unstarted: (Exception error) -> String
170
+ def monotonic_ns: () -> Integer
171
+ def to_ns: (Float seconds) -> Integer
172
+ end
173
+ end
metadata ADDED
@@ -0,0 +1,111 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: supervisor_machines
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - Abdelkader Boudih
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: chrono_machines
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0.11'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0.11'
26
+ - !ruby/object:Gem::Dependency
27
+ name: deadline_machines
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0.1'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rb_sys
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.9'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '0.9'
54
+ description: 'A supervision tree that restarts what died the way OTP would: one_for_one,
55
+ one_for_all or rest_for_one, bounded by a restart intensity that escalates instead
56
+ of thrashing. The decisions come from the no_std supervisor-machines crate; Ruby
57
+ owns the threads, the restart backoff comes from chrono_machines and the shutdown
58
+ budget from deadline_machines.'
59
+ email:
60
+ - oss@seuros.com
61
+ executables: []
62
+ extensions:
63
+ - ext/supervisor_machines_native/extconf.rb
64
+ extra_rdoc_files: []
65
+ files:
66
+ - CHANGELOG.md
67
+ - Cargo.lock
68
+ - Cargo.toml
69
+ - LICENSE.txt
70
+ - README.md
71
+ - ext/supervisor_machines_native/core/Cargo.toml
72
+ - ext/supervisor_machines_native/core/src/lib.rs
73
+ - ext/supervisor_machines_native/core/src/supervisor.rs
74
+ - ext/supervisor_machines_native/extconf.rb
75
+ - ext/supervisor_machines_native/ffi/Cargo.toml
76
+ - ext/supervisor_machines_native/ffi/extconf.rb
77
+ - ext/supervisor_machines_native/ffi/src/decider.rs
78
+ - ext/supervisor_machines_native/ffi/src/lib.rs
79
+ - lib/supervisor_machines.rb
80
+ - lib/supervisor_machines/errors.rb
81
+ - lib/supervisor_machines/supervisor.rb
82
+ - lib/supervisor_machines/version.rb
83
+ - sig/supervisor_machines.rbs
84
+ homepage: https://github.com/seuros/supervisor_machines
85
+ licenses:
86
+ - MIT
87
+ metadata:
88
+ source_code_uri: https://github.com/seuros/supervisor_machines
89
+ changelog_uri: https://github.com/seuros/supervisor_machines/blob/main/CHANGELOG.md
90
+ bug_tracker_uri: https://github.com/seuros/supervisor_machines/issues
91
+ rubygems_mfa_required: 'true'
92
+ cargo_crate_name: supervisor_machines_native
93
+ cargo_manifest_path: ext/supervisor_machines_native/ffi/Cargo.toml
94
+ rdoc_options: []
95
+ require_paths:
96
+ - lib
97
+ required_ruby_version: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - ">="
100
+ - !ruby/object:Gem::Version
101
+ version: 4.0.0
102
+ required_rubygems_version: !ruby/object:Gem::Requirement
103
+ requirements:
104
+ - - ">="
105
+ - !ruby/object:Gem::Version
106
+ version: '0'
107
+ requirements: []
108
+ rubygems_version: 4.0.20
109
+ specification_version: 4
110
+ summary: OTP-style supervision trees over Ruby threads
111
+ test_files: []