rogue_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,288 @@
1
+ //! `RogueMachines::Rogue`: the experiments a process is running, by name.
2
+
3
+ use std::cell::{Cell, RefCell};
4
+ use std::collections::HashMap;
5
+
6
+ use magnus::scan_args::{get_kwargs, scan_args};
7
+ use magnus::{Error, RArray, RHash, RModule, Ruby, Value, function, method, prelude::*};
8
+ use rogue_machines::{Decision, EXPERIMENTS, Experiment, Fault, Rogue, Slot};
9
+
10
+ use crate::injection::injection;
11
+ use crate::{LONGEST_DELAY, LONGEST_EXPERIMENT, counted, draw, duration, error, reading, share};
12
+
13
+ /// The core registry, and the names standing in for its slot indices.
14
+ #[magnus::wrap(class = "RogueMachines::Rogue", free_immediately, size)]
15
+ struct Wrapper {
16
+ rogue: RefCell<Rogue>,
17
+ slots: RefCell<HashMap<String, Slot>>,
18
+ /// Which clock the armed expiries were computed against.
19
+ ///
20
+ /// Every reading is only meaningful for one of these. A caller samples a
21
+ /// reading before it synchronises, so a reading that arrives after the
22
+ /// clock was replaced would otherwise be compared against expiries it has
23
+ /// no relation to, and `sweep` is destructive.
24
+ generation: Cell<u32>,
25
+ }
26
+
27
+ impl Wrapper {
28
+ fn new() -> Self {
29
+ Self {
30
+ rogue: RefCell::new(Rogue::new()),
31
+ slots: RefCell::new(HashMap::new()),
32
+ generation: Cell::new(0),
33
+ }
34
+ }
35
+
36
+ /// Whether any experiment is armed, which is the whole of the decision a
37
+ /// disarmed process takes.
38
+ fn any_armed(&self) -> bool {
39
+ self.rogue.borrow().any_armed()
40
+ }
41
+
42
+ const fn generation(&self) -> u32 {
43
+ self.generation.get()
44
+ }
45
+
46
+ /// Disarms everything and starts a generation, so readings taken against
47
+ /// the outgoing clock are refused rather than acted on.
48
+ fn invalidate(&self) -> u32 {
49
+ self.slots.borrow_mut().clear();
50
+ self.rogue.borrow_mut().disarm_all();
51
+ let next = self.generation.get().wrapping_add(1);
52
+ self.generation.set(next);
53
+ next
54
+ }
55
+
56
+ const fn current(&self, generation: u32) -> bool {
57
+ self.generation.get() == generation
58
+ }
59
+
60
+ fn armed_count(
61
+ ruby: &Ruby,
62
+ rb_self: &Self,
63
+ given_now: Value,
64
+ given_age: Value,
65
+ ) -> Result<u32, Error> {
66
+ let generation = counted(ruby, "generation", given_age)?;
67
+ if !rb_self.current(generation) {
68
+ return Ok(0);
69
+ }
70
+ let now_ns = reading(ruby, "now_ns", given_now)?;
71
+ let rogue = rb_self.rogue.borrow();
72
+ rogue.sweep(now_ns);
73
+ Ok(rogue.armed_count())
74
+ }
75
+
76
+ /// Arms `name`, replacing any experiment already under it.
77
+ ///
78
+ /// `fault:` is `:raise`, `:delay` or `:garble`, `share:` is the fraction of
79
+ /// calls it reaches, and `expires_in:` is how long it runs for from
80
+ /// `now_ns:`. A `:delay` also takes `delay:`, in seconds.
81
+ fn arm(ruby: &Ruby, rb_self: &Self, args: &[Value]) -> Result<(), Error> {
82
+ let args = scan_args::<(String,), (), (), (), RHash, ()>(args)?;
83
+ let (name,) = args.required;
84
+ let keywords = get_kwargs::<_, (String, f64, f64, Value, Value), (Option<f64>,), ()>(
85
+ args.keywords,
86
+ &["fault", "share", "expires_in", "now_ns", "generation"],
87
+ &["delay"],
88
+ )?;
89
+ let (named, given_share, expires_in, given_now, given_age) = keywords.required;
90
+ let generation = counted(ruby, "generation", given_age)?;
91
+ if !rb_self.current(generation) {
92
+ return Err(error(
93
+ ruby,
94
+ "the clock was replaced while arming; nothing was armed".to_owned(),
95
+ ));
96
+ }
97
+ let now_ns = reading(ruby, "now_ns", given_now)?;
98
+ let (delay,) = keywords.optional;
99
+
100
+ let expires_at_ns = now_ns
101
+ .checked_add(duration(
102
+ ruby,
103
+ "expires_in",
104
+ expires_in,
105
+ LONGEST_EXPERIMENT,
106
+ )?)
107
+ .ok_or_else(|| {
108
+ Error::new(
109
+ ruby.exception_arg_error(),
110
+ "expires_in puts the expiry past the end of the clock",
111
+ )
112
+ })?;
113
+ let fraction = share(ruby, given_share)?;
114
+ let fault = fault(ruby, &named, delay)?;
115
+ let slot = rb_self.slot_for(ruby, name, now_ns)?;
116
+
117
+ rb_self
118
+ .rogue
119
+ .borrow_mut()
120
+ .arm(slot, Experiment::new(fault, fraction, expires_at_ns));
121
+ Ok(())
122
+ }
123
+
124
+ /// Disarms `name`, freeing its slot. Reports whether it was still running,
125
+ /// which an experiment that expired on its own is not.
126
+ fn disarm(
127
+ ruby: &Ruby,
128
+ rb_self: &Self,
129
+ name: String,
130
+ given_now: Value,
131
+ given_age: Value,
132
+ ) -> Result<bool, Error> {
133
+ let generation = counted(ruby, "generation", given_age)?;
134
+ if !rb_self.current(generation) {
135
+ return Ok(false);
136
+ }
137
+ let now_ns = reading(ruby, "now_ns", given_now)?;
138
+ let Some(slot) = rb_self.slots.borrow_mut().remove(&name) else {
139
+ return Ok(false);
140
+ };
141
+ let was_running = rb_self.rogue.borrow().active(slot, now_ns).is_some();
142
+ rb_self.rogue.borrow_mut().disarm(slot);
143
+ Ok(was_running)
144
+ }
145
+
146
+ fn disarm_all(&self) {
147
+ self.slots.borrow_mut().clear();
148
+ self.rogue.borrow_mut().disarm_all();
149
+ }
150
+
151
+ /// The names still running at `now_ns`, in no particular order.
152
+ fn armed(
153
+ ruby: &Ruby,
154
+ rb_self: &Self,
155
+ given_now: Value,
156
+ given_age: Value,
157
+ ) -> Result<RArray, Error> {
158
+ let generation = counted(ruby, "generation", given_age)?;
159
+ if !rb_self.current(generation) {
160
+ return Ok(ruby.ary_new());
161
+ }
162
+ let now_ns = reading(ruby, "now_ns", given_now)?;
163
+ let running: Vec<String> = {
164
+ let rogue = rb_self.rogue.borrow();
165
+ rb_self
166
+ .slots
167
+ .borrow()
168
+ .iter()
169
+ .filter(|(_, slot)| rogue.active(**slot, now_ns).is_some())
170
+ .map(|(name, _)| name.clone())
171
+ .collect()
172
+ };
173
+ Ok(ruby.ary_from_iter(running))
174
+ }
175
+
176
+ /// The fault to perform for one call, or `nil` to make the real one.
177
+ ///
178
+ /// `draw` is the caller's, below `PER_MILLION`, and decides which calls an
179
+ /// experiment reaches.
180
+ fn decide(
181
+ ruby: &Ruby,
182
+ rb_self: &Self,
183
+ name: String,
184
+ given_now: Value,
185
+ given_draw: Value,
186
+ given_age: Value,
187
+ ) -> Result<Option<Value>, Error> {
188
+ let generation = counted(ruby, "generation", given_age)?;
189
+ if !rb_self.current(generation) {
190
+ return Ok(None);
191
+ }
192
+ let now_ns = reading(ruby, "now_ns", given_now)?;
193
+ let drawn = draw(ruby, given_draw)?;
194
+ let decision = {
195
+ let rogue = rb_self.rogue.borrow();
196
+ if !rogue.any_armed() {
197
+ return Ok(None);
198
+ }
199
+ rogue.sweep(now_ns);
200
+ let Some(slot) = rb_self.slots.borrow().get(&name).copied() else {
201
+ return Ok(None);
202
+ };
203
+ rogue.decide(slot, now_ns, drawn)
204
+ };
205
+ match decision {
206
+ Decision::Pass => Ok(None),
207
+ Decision::Inject(fault) => injection(ruby, fault).map(Some),
208
+ }
209
+ }
210
+
211
+ /// The slot already standing for `name`, or the lowest free one.
212
+ ///
213
+ /// An expired experiment releases its bit in the core but not its name, so
214
+ /// the names of everything no longer armed are dropped first; otherwise a
215
+ /// run of short experiments exhausts the slots.
216
+ fn slot_for(&self, ruby: &Ruby, name: String, now_ns: u64) -> Result<Slot, Error> {
217
+ let existing = {
218
+ let rogue = self.rogue.borrow();
219
+ let mut slots = self.slots.borrow_mut();
220
+ slots.retain(|held, slot| *held == name || rogue.active(*slot, now_ns).is_some());
221
+ slots.get(&name).copied()
222
+ };
223
+ if let Some(slot) = existing {
224
+ return Ok(slot);
225
+ }
226
+ let mut slots = self.slots.borrow_mut();
227
+ let taken: Vec<usize> = slots.values().map(|slot| slot.index()).collect();
228
+ let free = (0..EXPERIMENTS)
229
+ .find(|index| !taken.contains(index))
230
+ .and_then(Slot::new)
231
+ .ok_or_else(|| {
232
+ error(
233
+ ruby,
234
+ format!("all {EXPERIMENTS} experiment slots are taken; disarm one first"),
235
+ )
236
+ })?;
237
+ slots.insert(name, free);
238
+ Ok(free)
239
+ }
240
+
241
+ /// Reports the mask as it stood at the last decision, since `inspect`
242
+ /// takes no reading and so cannot retire an expired experiment itself.
243
+ fn inspect(&self) -> String {
244
+ format!(
245
+ "#<RogueMachines::Rogue armed={} of {EXPERIMENTS} at the last decision>",
246
+ self.rogue.borrow().armed_count()
247
+ )
248
+ }
249
+ }
250
+
251
+ /// The fault a `fault:` keyword names.
252
+ fn fault(ruby: &Ruby, named: &str, delay: Option<f64>) -> Result<Fault, Error> {
253
+ match named {
254
+ "raise" => Ok(Fault::Raise),
255
+ "garble" => Ok(Fault::Garble),
256
+ "delay" => {
257
+ let seconds = delay.ok_or_else(|| {
258
+ Error::new(
259
+ ruby.exception_arg_error(),
260
+ "a :delay fault needs delay: in seconds",
261
+ )
262
+ })?;
263
+ Ok(Fault::Delay {
264
+ ns: duration(ruby, "delay", seconds, LONGEST_DELAY)?,
265
+ })
266
+ }
267
+ other => Err(Error::new(
268
+ ruby.exception_arg_error(),
269
+ format!("fault must be :raise, :delay or :garble, got {other:?}"),
270
+ )),
271
+ }
272
+ }
273
+
274
+ pub fn define(ruby: &Ruby, module: RModule) -> Result<(), Error> {
275
+ let class = module.define_class("Rogue", ruby.class_object())?;
276
+ class.define_singleton_method("new", function!(Wrapper::new, 0))?;
277
+ class.define_method("any_armed?", method!(Wrapper::any_armed, 0))?;
278
+ class.define_method("armed_count", method!(Wrapper::armed_count, 2))?;
279
+ class.define_method("arm", method!(Wrapper::arm, -1))?;
280
+ class.define_method("disarm", method!(Wrapper::disarm, 3))?;
281
+ class.define_method("disarm_all", method!(Wrapper::disarm_all, 0))?;
282
+ class.define_method("generation", method!(Wrapper::generation, 0))?;
283
+ class.define_method("invalidate", method!(Wrapper::invalidate, 0))?;
284
+ class.define_method("armed", method!(Wrapper::armed, 2))?;
285
+ class.define_method("decide", method!(Wrapper::decide, 4))?;
286
+ class.define_method("inspect", method!(Wrapper::inspect, 0))?;
287
+ Ok(())
288
+ }
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RogueMachines
4
+ VERSION = '0.1.1'
5
+ end
@@ -0,0 +1,296 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'deadline_machines'
4
+
5
+ require_relative 'rogue_machines/version'
6
+
7
+ # Rogue, Injection, Error and Injected are defined by the native extension;
8
+ # there is no pure-Ruby implementation to fall back to.
9
+ require 'rogue_machines_native/rogue_machines_native'
10
+
11
+ # Fault injection: making a dependency fail, stall or lie on purpose.
12
+ #
13
+ # Disarmed is the default. `run` costs one native call that reads one word
14
+ # until something is armed.
15
+ module RogueMachines
16
+ # Attempts at one consistent reading before giving up on a clock that is
17
+ # being replaced faster than it can be read.
18
+ READINGS = 100
19
+
20
+ @registry = Rogue.new
21
+ @substitutes = {}.freeze
22
+ @lock = Mutex.new
23
+
24
+ class << self
25
+ attr_writer :random
26
+
27
+ # Monotonic nanoseconds. Delegates to deadline_machines rather than
28
+ # memoising it, so replacing that gem's clock moves every expiry with it.
29
+ def clock
30
+ @clock || DeadlineMachines.clock
31
+ end
32
+
33
+ # Installing a clock disarms everything first. Every armed expiry was
34
+ # computed against the outgoing clock, and an expiry measured against a
35
+ # clock it did not come from is not an expiry.
36
+ #
37
+ # It also starts a generation, so a reading already taken against the
38
+ # outgoing clock is refused rather than compared against expiries it has
39
+ # no relation to. `sweep` is destructive, so such a reading would retire
40
+ # experiments armed after the swap.
41
+ def clock=(clock)
42
+ @lock.synchronize do
43
+ # The native call goes last here, against the rule the other two-store
44
+ # writes follow, because this order is observable: `reading` is
45
+ # lock-free, and bumping the generation first would pair an outgoing
46
+ # reading with the incoming generation, which every consumer accepts.
47
+ Thread.handle_interrupt(Object => :never) do
48
+ @substitutes = {}.freeze
49
+ @clock = clock
50
+ registry.invalidate
51
+ end
52
+ end
53
+ end
54
+
55
+ def random
56
+ @random ||= Random.new
57
+ end
58
+
59
+ def registry
60
+ @registry
61
+ end
62
+
63
+ # Arms an experiment under `name`, replacing any already there.
64
+ #
65
+ # RogueMachines.arm(:payments, fault: :raise, share: 0.01, expires_in: 300)
66
+ # RogueMachines.arm(:slow, fault: :delay, delay: 2, share: 1.0, expires_in: 60)
67
+ # RogueMachines.arm(:lies, fault: :garble, garbled: [], share: 0.5, expires_in: 60)
68
+ #
69
+ # `expires_in` is required and counts from now, so no experiment outlives
70
+ # the person who armed it.
71
+ def arm(name, fault:, share:, expires_in:, delay: nil, garbled: nil)
72
+ key = named(name)
73
+
74
+ # The reading is taken before the lock so that a caller's clock never
75
+ # runs inside it, which means the expiry is measured from slightly
76
+ # before the arming. See "The clock is deadline_machines'" in the README.
77
+ pair = reading
78
+ unless pair
79
+ raise Error, "the clock was replaced #{READINGS} times while taking one reading; " \
80
+ 'nothing is armed'
81
+ end
82
+
83
+ now, age = pair
84
+ options = { fault: fault.to_s, share: Float(share), expires_in: Float(expires_in),
85
+ now_ns: now, generation: age }
86
+ options[:delay] = Float(delay) if delay
87
+
88
+ @lock.synchronize do
89
+ # Two stores. An interrupt landing between them would leave an armed
90
+ # garble with nothing to return for the rest of its life, and nothing
91
+ # repairs that, because publishing only ever removes keys.
92
+ Thread.handle_interrupt(Object => :never) do
93
+ registry.arm(key, **options)
94
+ payload = options[:fault] == 'garble' ? garbled : nil
95
+ publish(substitutes.merge(key => payload), registry.armed(now, age))
96
+ end
97
+ end
98
+ name
99
+ end
100
+
101
+ # Reports whether `name` was still running, which an experiment that
102
+ # expired on its own is not.
103
+ #
104
+ # The substitute goes first: a call landing between the two sees a garble
105
+ # with nothing to return rather than the real value it was armed against.
106
+ def disarm(name)
107
+ key = named(name)
108
+ pair = reading
109
+ return false unless pair
110
+
111
+ now, age = pair
112
+ @lock.synchronize do
113
+ # A reading from a replaced clock must not drop a substitute belonging
114
+ # to the generation that replaced it.
115
+ break false unless registry.generation == age
116
+
117
+ # The native call validates its reading and can raise, so it goes
118
+ # first: nothing is mutated unless it succeeds. Ordering no longer
119
+ # matters to a reader, since a decision and its payload are read
120
+ # under this same lock.
121
+ Thread.handle_interrupt(Object => :never) do
122
+ was_running = registry.disarm(key, now, age)
123
+ @substitutes = substitutes.except(key).freeze
124
+ was_running
125
+ end
126
+ end
127
+ end
128
+
129
+ def disarm_all
130
+ @lock.synchronize do
131
+ Thread.handle_interrupt(Object => :never) do
132
+ registry.disarm_all
133
+ @substitutes = {}.freeze
134
+ end
135
+ end
136
+ end
137
+
138
+ # With no name, whether anything at all is armed. Asked through the name
139
+ # list rather than the gate `run` reads, because the gate only drops an
140
+ # expired experiment when something next decides.
141
+ def armed?(name = nil)
142
+ names = running
143
+ return names.any? if name.nil?
144
+
145
+ names.include?(named(name))
146
+ end
147
+
148
+ # The experiments still running, which excludes any that has expired.
149
+ def armed
150
+ running.map(&:to_sym)
151
+ end
152
+
153
+ # Runs the block, or the fault armed under `name` instead.
154
+ def run(name)
155
+ check(name)
156
+ unless registry.any_armed?
157
+ release unless substitutes.empty?
158
+ return yield
159
+ end
160
+
161
+ key = named(name)
162
+ pair = reading
163
+ return yield unless pair
164
+
165
+ now, age = pair
166
+ drawn = random.rand(PER_MILLION)
167
+
168
+ # The decision and the payload live in different stores, so reading them
169
+ # one after the other is wrong in both orders: a name published between
170
+ # the two is in the decision and not the table, and one retired between
171
+ # them is in the table and not the decision. They are read together.
172
+ # Nothing caller-supplied runs inside the lock, since the clock and the
173
+ # draw are already taken.
174
+ injection, payload = @lock.synchronize do
175
+ found = registry.decide(key, now, drawn, age)
176
+ [found, found && substitutes[key]]
177
+ end
178
+ reconcile(now, age)
179
+ return yield unless injection
180
+
181
+ case injection.fault
182
+ when :raise then raise Injected, "#{name} is armed to raise"
183
+ when :garble then payload
184
+ when :delay
185
+ stall(injection.delay_ns)
186
+ yield
187
+ end
188
+ end
189
+
190
+ private
191
+
192
+ def substitutes
193
+ @substitutes
194
+ end
195
+
196
+ # A reading and the generation it belongs to.
197
+ #
198
+ # The generation is read on both sides of the reading. Reading it only
199
+ # afterwards would adopt the new one and hand a stale reading to a
200
+ # destructive sweep; reading it only before would pair an outgoing
201
+ # reading with the incoming generation, which every consumer accepts. The
202
+ # pair cannot be sampled in one step, so a change means retry.
203
+ def reading
204
+ READINGS.times do
205
+ age = registry.generation
206
+ now = clock.now_ns
207
+ unless now.is_a?(Integer) && !now.negative?
208
+ raise Error, "#{clock.class} returned #{now.inspect}; a reading must be a " \
209
+ 'non-negative Integer of nanoseconds'
210
+ end
211
+ return [now, age] if registry.generation == age
212
+ end
213
+ nil
214
+ end
215
+
216
+ # Rejects a name the native table could not key on, without converting it,
217
+ # so a call site answers the same way whether or not anything is armed.
218
+ def check(name)
219
+ case name
220
+ when Symbol then legible(name.name)
221
+ when String then legible(name)
222
+ else raise ArgumentError, "an experiment name must be a Symbol or a String, got #{name.class}"
223
+ end
224
+ end
225
+
226
+ def legible(name)
227
+ raise ArgumentError, 'an experiment needs a name' if name.empty?
228
+ return if name.ascii_only?
229
+ return if name.encoding == Encoding::UTF_8 && name.valid_encoding?
230
+ raise ArgumentError, "an experiment name must be valid UTF-8, got #{name.encoding}" \
231
+ unless name.encoding == Encoding::UTF_8
232
+
233
+ raise ArgumentError, 'an experiment name holds bytes that are not valid UTF-8'
234
+ end
235
+
236
+ # The checked name, as something the native table can key on. Not `to_s`:
237
+ # a String subclass can override it, which armed one name and reported
238
+ # another.
239
+ def named(name)
240
+ check(name)
241
+ name.is_a?(Symbol) ? name.name : name
242
+ end
243
+
244
+ # The names still running, releasing the substitutes of everything that is
245
+ # not.
246
+ def running
247
+ pair = reading
248
+ return [] unless pair
249
+
250
+ now, age = pair
251
+ @lock.synchronize do
252
+ # A reading from a replaced clock lists nothing, and must not be used
253
+ # to prune: publishing against an empty list would wipe the table.
254
+ break [] unless registry.generation == age
255
+
256
+ names = registry.armed(now, age)
257
+ publish(substitutes, names)
258
+ names
259
+ end
260
+ end
261
+
262
+ # Releases the substitutes of anything no longer running, so one armed
263
+ # experiment holding the gate open cannot keep another's substitute, and
264
+ # the caller's object, alive until the next `arm`.
265
+ def reconcile(now_ns, age)
266
+ return if substitutes.size <= registry.armed_count(now_ns, age)
267
+
268
+ @lock.synchronize do
269
+ publish(substitutes, registry.armed(now_ns, age)) if registry.generation == age
270
+ end
271
+ end
272
+
273
+ # Only if nothing is armed once the lock is held. The gate `run` reads is
274
+ # sampled before it, so an `arm` landing in between would otherwise have
275
+ # its just-published substitute discarded, leaving an armed garble with
276
+ # nothing to return for the rest of its life.
277
+ def release
278
+ @lock.synchronize { @substitutes = {}.freeze unless registry.any_armed? }
279
+ end
280
+
281
+ # Replaces the table with the entries `names` still covers, in one
282
+ # assignment.
283
+ def publish(table, names)
284
+ @substitutes = table.slice(*names).freeze
285
+ end
286
+
287
+ # Spends the delay against the caller's deadline rather than past it, so a
288
+ # slow dependency trips the timeout that was already there.
289
+ def stall(delay_ns)
290
+ DeadlineMachines.check!
291
+ seconds = DeadlineMachines.clamp_sleep(delay_ns.fdiv(NS_PER_SECOND))
292
+ sleep(seconds) if seconds.positive?
293
+ DeadlineMachines.check!
294
+ end
295
+ end
296
+ end