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,155 @@
1
+ //! The decision machine itself.
2
+
3
+ use crate::{Decision, Exit, Intensity, Policy, Span, Strategy};
4
+
5
+ /// Holds the strategy, the restart intensity, and the recent restart history.
6
+ ///
7
+ /// `CHILDREN` is how many children the supervisor owns, which fixes the
8
+ /// strategy spans. `HISTORY` is the restart timestamps kept; it must exceed the
9
+ /// intensity's restart count, or a single burst could not be measured.
10
+ ///
11
+ /// ```
12
+ /// use supervisor_machines::{Decision, Exit, Intensity, Policy, Span, Strategy, Supervisor};
13
+ ///
14
+ /// const SECOND: u64 = 1_000_000_000;
15
+ ///
16
+ /// // Three children that only work as a set, two restarts per minute allowed.
17
+ /// let mut tree: Supervisor<3, 8> = Supervisor::new(
18
+ /// Strategy::OneForAll,
19
+ /// Intensity::new(2, 60 * SECOND),
20
+ /// );
21
+ ///
22
+ /// // The middle child dies badly, so all three come back.
23
+ /// let decision = tree.stopped(1, Policy::Permanent, Exit::Abnormal, 0);
24
+ /// assert_eq!(decision, Decision::Restart(Span { first: 0, last: 2 }));
25
+ ///
26
+ /// // A temporary child is never worth restarting.
27
+ /// assert_eq!(
28
+ /// tree.stopped(2, Policy::Temporary, Exit::Abnormal, SECOND),
29
+ /// Decision::Ignore,
30
+ /// );
31
+ /// ```
32
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
33
+ pub struct Supervisor<const CHILDREN: usize, const HISTORY: usize> {
34
+ strategy: Strategy,
35
+ intensity: Intensity,
36
+ history: [u64; HISTORY],
37
+ recorded: usize,
38
+ next: usize,
39
+ terminated: bool,
40
+ }
41
+
42
+ impl<const CHILDREN: usize, const HISTORY: usize> Supervisor<CHILDREN, HISTORY> {
43
+ /// Rejects a shape that could not measure its own intensity.
44
+ const SHAPE: () = {
45
+ assert!(
46
+ CHILDREN > 0,
47
+ "a supervisor with no children has nothing to decide"
48
+ );
49
+ assert!(
50
+ HISTORY > 0,
51
+ "restart history needs room for at least one restart"
52
+ );
53
+ };
54
+
55
+ #[must_use]
56
+ pub const fn new(strategy: Strategy, intensity: Intensity) -> Self {
57
+ let () = Self::SHAPE;
58
+ Self {
59
+ strategy,
60
+ intensity,
61
+ history: [0; HISTORY],
62
+ recorded: 0,
63
+ next: 0,
64
+ terminated: false,
65
+ }
66
+ }
67
+
68
+ #[must_use]
69
+ pub const fn strategy(&self) -> Strategy {
70
+ self.strategy
71
+ }
72
+
73
+ #[must_use]
74
+ pub const fn intensity(&self) -> Intensity {
75
+ self.intensity
76
+ }
77
+
78
+ /// True once the supervisor has escalated. It stays true: a supervisor that
79
+ /// gave up does not change its mind.
80
+ #[must_use]
81
+ pub const fn terminated(&self) -> bool {
82
+ self.terminated
83
+ }
84
+
85
+ /// Restarts still inside the window as of `now_ns`.
86
+ #[must_use]
87
+ pub const fn recent_restarts(&self, now_ns: u64) -> u32 {
88
+ let cutoff = now_ns.saturating_sub(self.intensity.window_ns);
89
+ let mut counted = 0;
90
+ let mut index = 0;
91
+ while index < self.recorded {
92
+ if self.history[index] >= cutoff {
93
+ counted += 1;
94
+ }
95
+ index += 1;
96
+ }
97
+ counted
98
+ }
99
+
100
+ /// The children a failure at `index` takes with it under this strategy.
101
+ #[must_use]
102
+ pub const fn span(&self, index: usize) -> Span {
103
+ match self.strategy {
104
+ Strategy::OneForOne => Span::single(index),
105
+ Strategy::OneForAll => Span {
106
+ first: 0,
107
+ last: CHILDREN - 1,
108
+ },
109
+ Strategy::RestForOne => Span {
110
+ first: index,
111
+ last: CHILDREN - 1,
112
+ },
113
+ }
114
+ }
115
+
116
+ const fn record(&mut self, now_ns: u64) {
117
+ self.history[self.next] = now_ns;
118
+ self.next = (self.next + 1) % HISTORY;
119
+ if self.recorded < HISTORY {
120
+ self.recorded += 1;
121
+ }
122
+ }
123
+
124
+ /// Decide what to do about the child at `index`.
125
+ ///
126
+ /// One restart is recorded per failure handled, not per child restarted.
127
+ pub const fn stopped(
128
+ &mut self,
129
+ index: usize,
130
+ policy: Policy,
131
+ exit: Exit,
132
+ now_ns: u64,
133
+ ) -> Decision {
134
+ if self.terminated {
135
+ return Decision::Escalate;
136
+ }
137
+
138
+ match policy {
139
+ Policy::Temporary => return Decision::Ignore,
140
+ Policy::Transient if matches!(exit, Exit::Normal) => return Decision::Ignore,
141
+ Policy::Permanent | Policy::Transient => {}
142
+ }
143
+
144
+ self.record(now_ns);
145
+ if self.recent_restarts(now_ns) > self.intensity.restarts {
146
+ self.terminated = true;
147
+ return Decision::Escalate;
148
+ }
149
+
150
+ Decision::Restart(self.span(index))
151
+ }
152
+ }
153
+
154
+ #[cfg(test)]
155
+ mod tests;
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'ffi/extconf'
@@ -0,0 +1,19 @@
1
+ [package]
2
+ name = "supervisor_machines_native"
3
+ version = "0.1.1"
4
+ edition.workspace = true
5
+ rust-version.workspace = true
6
+ authors = ["Abdelkader Boudih <oss@seuros.com>"]
7
+ license = "MIT"
8
+ description = "Ruby bindings for the supervisor-machines decision machine"
9
+ publish = false
10
+
11
+ [lib]
12
+ crate-type = ["cdylib"]
13
+
14
+ [lints]
15
+ workspace = true
16
+
17
+ [dependencies]
18
+ supervisor-machines = { workspace = true, features = ["std"] }
19
+ magnus = { version = ">= 0.9.3", default-features = false, features = ["io"] }
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mkmf'
4
+ require 'pathname'
5
+ require 'rb_sys/mkmf'
6
+
7
+ create_rust_makefile('supervisor_machines_native/supervisor_machines_native') do |r|
8
+ r.ext_dir = Pathname(__dir__).relative_path_from(Pathname(Dir.pwd)).to_s
9
+ r.profile = ENV.fetch('RB_SYS_CARGO_PROFILE', :release).to_sym
10
+ end
11
+
12
+ # rake-compiler builds from tmp/, where the generated relative manifest path no
13
+ # longer resolves.
14
+ makefile = File.join(Dir.pwd, 'Makefile')
15
+ File.write(
16
+ makefile,
17
+ File.read(makefile).gsub(
18
+ /^RB_SYS_CARGO_MANIFEST_DIR \?=.*$/,
19
+ "RB_SYS_CARGO_MANIFEST_DIR ?= #{File.expand_path(__dir__)}"
20
+ )
21
+ )
@@ -0,0 +1,199 @@
1
+ //! `SupervisorMachines::Decider`: the core's decision machine, bound to Ruby.
2
+ //!
3
+ //! `Supervisor` carries its child count and its restart history in the type,
4
+ //! so a runtime child count is dispatched to one monomorphised shape per
5
+ //! supported count and kept behind [`Decide`].
6
+
7
+ use std::cell::RefCell;
8
+
9
+ use magnus::scan_args::{get_kwargs, scan_args};
10
+ use magnus::{Error, RArray, RHash, RModule, Ruby, Value, function, method, prelude::*};
11
+ use supervisor_machines::{Decision, Exit, Intensity, Policy, Strategy, Supervisor};
12
+
13
+ use crate::{Whole, decision, exit, policy, strategy, strategy_name};
14
+
15
+ /// Restart timestamps every shape keeps.
16
+ const HISTORY: usize = 64;
17
+
18
+ /// The core escalates one restart past the intensity, so the history has to
19
+ /// hold one more timestamp than the intensity tolerates.
20
+ #[expect(clippy::cast_possible_truncation)]
21
+ const MAX_RESTARTS: u32 = HISTORY as u32 - 1;
22
+
23
+ /// The child counts a [`Decider`] can be built for.
24
+ const SUPPORTED_CHILDREN: [usize; 17] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 32];
25
+
26
+ /// A [`Supervisor`] with its child count erased. A supervision tree is plain
27
+ /// data, so `Send` costs nothing and is what magnus asks of wrapped types.
28
+ trait Decide: Send {
29
+ fn children(&self) -> usize;
30
+ fn strategy(&self) -> Strategy;
31
+ fn terminated(&self) -> bool;
32
+ fn recent_restarts(&self, now_ns: u64) -> u32;
33
+ fn stopped(&mut self, index: usize, policy: Policy, exit: Exit, now_ns: u64) -> Decision;
34
+ }
35
+
36
+ /// One monomorphised supervision tree. The newtype keeps the trait's method
37
+ /// names from shadowing the core's inherent ones.
38
+ struct Shape<const CHILDREN: usize>(Supervisor<CHILDREN, HISTORY>);
39
+
40
+ impl<const CHILDREN: usize> Decide for Shape<CHILDREN> {
41
+ fn children(&self) -> usize {
42
+ CHILDREN
43
+ }
44
+
45
+ fn strategy(&self) -> Strategy {
46
+ self.0.strategy()
47
+ }
48
+
49
+ fn terminated(&self) -> bool {
50
+ self.0.terminated()
51
+ }
52
+
53
+ fn recent_restarts(&self, now_ns: u64) -> u32 {
54
+ self.0.recent_restarts(now_ns)
55
+ }
56
+
57
+ fn stopped(&mut self, index: usize, policy: Policy, exit: Exit, now_ns: u64) -> Decision {
58
+ self.0.stopped(index, policy, exit, now_ns)
59
+ }
60
+ }
61
+
62
+ /// Each shape is built behind its own call frame. Inlined, a debug build sizes
63
+ /// one frame for every arm of the match in [`Wrapper::new`] at once, which
64
+ /// overflows Ruby's fiber stack.
65
+ #[inline(never)]
66
+ fn boxed<const CHILDREN: usize>(strategy: Strategy, intensity: Intensity) -> Box<dyn Decide> {
67
+ Box::new(Shape(Supervisor::<CHILDREN, HISTORY>::new(
68
+ strategy, intensity,
69
+ )))
70
+ }
71
+
72
+ fn unsupported(ruby: &Ruby, children: usize) -> Error {
73
+ Error::new(
74
+ ruby.exception_arg_error(),
75
+ format!(
76
+ "{children} children is not a supported shape, expected one of {SUPPORTED_CHILDREN:?}"
77
+ ),
78
+ )
79
+ }
80
+
81
+ #[magnus::wrap(class = "SupervisorMachines::Decider", free_immediately, size)]
82
+ struct Wrapper(RefCell<Box<dyn Decide>>);
83
+
84
+ impl Wrapper {
85
+ fn new(ruby: &Ruby, args: &[Value]) -> Result<Self, Error> {
86
+ let args = scan_args::<(), (), (), (), RHash, ()>(args)?;
87
+ let (Whole(children), name, Whole(restarts), Whole(window_ns)) =
88
+ get_kwargs::<_, (Whole<usize>, Value, Whole<u32>, Whole<u64>), (), ()>(
89
+ args.keywords,
90
+ &["children", "strategy", "restarts", "window_ns"],
91
+ &[],
92
+ )?
93
+ .required;
94
+ if restarts > MAX_RESTARTS {
95
+ return Err(Error::new(
96
+ ruby.exception_arg_error(),
97
+ format!("{restarts} restarts cannot escalate, the most is {MAX_RESTARTS}"),
98
+ ));
99
+ }
100
+ let strategy = strategy(ruby, name)?;
101
+ let intensity = Intensity::new(restarts, window_ns);
102
+ let tree = match children {
103
+ 1 => boxed::<1>(strategy, intensity),
104
+ 2 => boxed::<2>(strategy, intensity),
105
+ 3 => boxed::<3>(strategy, intensity),
106
+ 4 => boxed::<4>(strategy, intensity),
107
+ 5 => boxed::<5>(strategy, intensity),
108
+ 6 => boxed::<6>(strategy, intensity),
109
+ 7 => boxed::<7>(strategy, intensity),
110
+ 8 => boxed::<8>(strategy, intensity),
111
+ 9 => boxed::<9>(strategy, intensity),
112
+ 10 => boxed::<10>(strategy, intensity),
113
+ 11 => boxed::<11>(strategy, intensity),
114
+ 12 => boxed::<12>(strategy, intensity),
115
+ 13 => boxed::<13>(strategy, intensity),
116
+ 14 => boxed::<14>(strategy, intensity),
117
+ 15 => boxed::<15>(strategy, intensity),
118
+ 16 => boxed::<16>(strategy, intensity),
119
+ 32 => boxed::<32>(strategy, intensity),
120
+ _ => return Err(unsupported(ruby, children)),
121
+ };
122
+ Ok(Self(RefCell::new(tree)))
123
+ }
124
+
125
+ /// Feeds one stopped child to the core and returns what it decided:
126
+ /// `:ignore`, `:escalate`, or the inclusive range of children to restart.
127
+ fn stopped(ruby: &Ruby, rb_self: &Self, args: &[Value]) -> Result<Value, Error> {
128
+ let args = scan_args::<(), (), (), (), RHash, ()>(args)?;
129
+ let (Whole(index), name, ending, Whole(now_ns)) =
130
+ get_kwargs::<_, (Whole<usize>, Value, Value, Whole<u64>), (), ()>(
131
+ args.keywords,
132
+ &["index", "policy", "exit", "now_ns"],
133
+ &[],
134
+ )?
135
+ .required;
136
+ let (policy, ending) = (policy(ruby, name)?, exit(ruby, ending)?);
137
+ let children = rb_self.0.borrow().children();
138
+ if index >= children {
139
+ return Err(Error::new(
140
+ ruby.exception_arg_error(),
141
+ format!("index {index} is outside the {children} children"),
142
+ ));
143
+ }
144
+ let verdict = rb_self
145
+ .0
146
+ .borrow_mut()
147
+ .stopped(index, policy, ending, now_ns);
148
+ decision(ruby, verdict)
149
+ }
150
+
151
+ fn children(&self) -> usize {
152
+ self.0.borrow().children()
153
+ }
154
+
155
+ fn strategy(ruby: &Ruby, rb_self: &Self) -> Value {
156
+ strategy_name(ruby, rb_self.0.borrow().strategy()).as_value()
157
+ }
158
+
159
+ fn terminated(&self) -> bool {
160
+ self.0.borrow().terminated()
161
+ }
162
+
163
+ fn recent_restarts(&self, Whole(now_ns): Whole<u64>) -> u32 {
164
+ self.0.borrow().recent_restarts(now_ns)
165
+ }
166
+
167
+ fn inspect(ruby: &Ruby, rb_self: &Self) -> String {
168
+ format!(
169
+ "#<SupervisorMachines::Decider strategy={} children={} terminated={}>",
170
+ Self::strategy(ruby, rb_self).inspect(),
171
+ rb_self.children(),
172
+ rb_self.terminated()
173
+ )
174
+ }
175
+ }
176
+
177
+ pub fn define(ruby: &Ruby, module: RModule) -> Result<(), Error> {
178
+ let class = module.define_class("Decider", ruby.class_object())?;
179
+ class.const_set("HISTORY", HISTORY)?;
180
+ class.const_set("MAX_RESTARTS", MAX_RESTARTS)?;
181
+ class.const_set("SUPPORTED_CHILDREN", supported_children(ruby)?)?;
182
+ class.define_singleton_method("new", function!(Wrapper::new, -1))?;
183
+ class.define_method("stopped", method!(Wrapper::stopped, -1))?;
184
+ class.define_method("children", method!(Wrapper::children, 0))?;
185
+ class.define_method("strategy", method!(Wrapper::strategy, 0))?;
186
+ class.define_method("terminated?", method!(Wrapper::terminated, 0))?;
187
+ class.define_method("recent_restarts", method!(Wrapper::recent_restarts, 1))?;
188
+ class.define_method("inspect", method!(Wrapper::inspect, 0))?;
189
+ Ok(())
190
+ }
191
+
192
+ fn supported_children(ruby: &Ruby) -> Result<RArray, Error> {
193
+ let array = ruby.ary_new_capa(SUPPORTED_CHILDREN.len());
194
+ for children in SUPPORTED_CHILDREN {
195
+ array.push(children)?;
196
+ }
197
+ array.freeze();
198
+ Ok(array)
199
+ }
@@ -0,0 +1,135 @@
1
+ //! Ruby bindings for the `supervisor-machines` decision machine.
2
+ //!
3
+ //! The core decides and nothing else: this crate converts Ruby symbols into
4
+ //! its enums, hands back a `Decision` as a symbol or a `Range`, and leaves
5
+ //! spawning, joining and killing to `SupervisorMachines::Supervisor`.
6
+ //!
7
+ //! Durations cross into the core as nanosecond integers. Seconds belong to the
8
+ //! Ruby surface, which converts them with [`NS_PER_SECOND`].
9
+
10
+ #![allow(clippy::needless_pass_by_value)]
11
+
12
+ mod decider;
13
+
14
+ use magnus::{Error, Integer, RArray, Ruby, Symbol, TryConvert, Value, prelude::*};
15
+ use supervisor_machines::{DEFAULT_RESTARTS, DEFAULT_WINDOW_NS, Decision, Exit, Policy, Strategy};
16
+
17
+ /// Nanoseconds in a second.
18
+ pub(crate) const NS_PER_SECOND: u32 = 1_000_000_000;
19
+
20
+ /// A whole number of things: a child count, a child index, a restart
21
+ /// intensity, a nanosecond reading.
22
+ ///
23
+ /// magnus reads an integer through Ruby's `NUM2*` family, which truncates a
24
+ /// `Float` towards zero instead of refusing it, so an argument that means an
25
+ /// integer is read as one of these and anything but an `Integer` is turned
26
+ /// away. An `Integer` outside the target width still comes back as the
27
+ /// `RangeError` magnus raises for it. Seconds belong to the Ruby surface,
28
+ /// which converts them before they reach here.
29
+ pub(crate) struct Whole<T>(pub(crate) T);
30
+
31
+ impl<T: TryConvert> TryConvert for Whole<T> {
32
+ fn try_convert(value: Value) -> Result<Self, Error> {
33
+ if Integer::from_value(value).is_none() {
34
+ return Err(not_whole(value));
35
+ }
36
+ T::try_convert(value).map(Self)
37
+ }
38
+ }
39
+
40
+ fn not_whole(value: Value) -> Error {
41
+ let ruby = Ruby::get_with(value);
42
+ Error::new(
43
+ ruby.exception_type_error(),
44
+ format!(
45
+ "no implicit conversion of {} into Integer: {}",
46
+ value.class(),
47
+ value.inspect()
48
+ ),
49
+ )
50
+ }
51
+
52
+ /// Every [`Strategy`], in the order the core declares them.
53
+ pub(crate) const STRATEGIES: [(&str, Strategy); 3] = [
54
+ ("one_for_one", Strategy::OneForOne),
55
+ ("one_for_all", Strategy::OneForAll),
56
+ ("rest_for_one", Strategy::RestForOne),
57
+ ];
58
+
59
+ /// Every [`Policy`], in the order the core declares them.
60
+ pub(crate) const POLICIES: [(&str, Policy); 3] = [
61
+ ("permanent", Policy::Permanent),
62
+ ("transient", Policy::Transient),
63
+ ("temporary", Policy::Temporary),
64
+ ];
65
+
66
+ /// Every [`Exit`], in the order the core declares them.
67
+ pub(crate) const EXITS: [(&str, Exit); 2] =
68
+ [("normal", Exit::Normal), ("abnormal", Exit::Abnormal)];
69
+
70
+ /// Resolves a symbol against one of the closed sets above.
71
+ fn member<T: Copy>(ruby: &Ruby, kind: &str, value: Value, set: &[(&str, T)]) -> Result<T, Error> {
72
+ let name = Symbol::from_value(value).and_then(|symbol| symbol.name().ok());
73
+ name.as_deref()
74
+ .and_then(|name| set.iter().find(|(known, _)| *known == name))
75
+ .map(|&(_, member)| member)
76
+ .ok_or_else(|| {
77
+ Error::new(
78
+ ruby.exception_arg_error(),
79
+ format!("unknown {kind}: {}", value.inspect()),
80
+ )
81
+ })
82
+ }
83
+
84
+ pub(crate) fn strategy(ruby: &Ruby, value: Value) -> Result<Strategy, Error> {
85
+ member(ruby, "strategy", value, &STRATEGIES)
86
+ }
87
+
88
+ pub(crate) fn policy(ruby: &Ruby, value: Value) -> Result<Policy, Error> {
89
+ member(ruby, "policy", value, &POLICIES)
90
+ }
91
+
92
+ pub(crate) fn exit(ruby: &Ruby, value: Value) -> Result<Exit, Error> {
93
+ member(ruby, "exit", value, &EXITS)
94
+ }
95
+
96
+ pub(crate) fn strategy_name(ruby: &Ruby, strategy: Strategy) -> Symbol {
97
+ let name = STRATEGIES
98
+ .iter()
99
+ .find(|&&(_, known)| known == strategy)
100
+ .map_or("", |&(name, _)| name);
101
+ ruby.to_symbol(name)
102
+ }
103
+
104
+ /// A [`Decision`] as Ruby reads it: `:ignore`, `:escalate`, or the inclusive
105
+ /// range of child indices to restart.
106
+ pub(crate) fn decision(ruby: &Ruby, decision: Decision) -> Result<Value, Error> {
107
+ match decision {
108
+ Decision::Ignore => Ok(ruby.to_symbol("ignore").as_value()),
109
+ Decision::Escalate => Ok(ruby.to_symbol("escalate").as_value()),
110
+ Decision::Restart(span) => ruby
111
+ .range_new(span.first, span.last, false)
112
+ .map(magnus::value::ReprValue::as_value),
113
+ }
114
+ }
115
+
116
+ /// A frozen array of the symbols naming a closed set.
117
+ fn names<T>(ruby: &Ruby, set: &[(&str, T)]) -> Result<RArray, Error> {
118
+ let array = ruby.ary_new_capa(set.len());
119
+ for &(name, _) in set {
120
+ array.push(ruby.to_symbol(name))?;
121
+ }
122
+ array.freeze();
123
+ Ok(array)
124
+ }
125
+
126
+ #[magnus::init]
127
+ fn init(ruby: &Ruby) -> Result<(), Error> {
128
+ let module = ruby.define_module("SupervisorMachines")?;
129
+ module.const_set("NS_PER_SECOND", NS_PER_SECOND)?;
130
+ module.const_set("DEFAULT_RESTARTS", DEFAULT_RESTARTS)?;
131
+ module.const_set("DEFAULT_WINDOW_NS", DEFAULT_WINDOW_NS)?;
132
+ module.const_set("STRATEGIES", names(ruby, &STRATEGIES)?)?;
133
+ module.const_set("POLICIES", names(ruby, &POLICIES)?)?;
134
+ decider::define(ruby, module)
135
+ }
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SupervisorMachines
4
+ class Error < StandardError; end
5
+
6
+ # Raised once a supervisor has spent its restart intensity. Its children are
7
+ # down and the decision now belongs to whatever supervises the supervisor.
8
+ class Escalated < Error; end
9
+ end