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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +15 -0
- data/Cargo.lock +286 -0
- data/Cargo.toml +24 -0
- data/LICENSE.txt +21 -0
- data/README.md +211 -0
- data/ext/rogue_machines_native/core/Cargo.toml +24 -0
- data/ext/rogue_machines_native/core/src/armed.rs +78 -0
- data/ext/rogue_machines_native/core/src/experiment.rs +84 -0
- data/ext/rogue_machines_native/core/src/lib.rs +30 -0
- data/ext/rogue_machines_native/core/src/rogue.rs +135 -0
- data/ext/rogue_machines_native/extconf.rb +3 -0
- data/ext/rogue_machines_native/ffi/Cargo.toml +19 -0
- data/ext/rogue_machines_native/ffi/extconf.rb +21 -0
- data/ext/rogue_machines_native/ffi/src/injection.rs +44 -0
- data/ext/rogue_machines_native/ffi/src/lib.rs +173 -0
- data/ext/rogue_machines_native/ffi/src/registry.rs +288 -0
- data/lib/rogue_machines/version.rb +5 -0
- data/lib/rogue_machines.rb +296 -0
- data/sig/rogue_machines.rbs +146 -0
- metadata +98 -0
|
@@ -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,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
|