rogue_machines 0.1.1-x86_64-linux
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/LICENSE.txt +21 -0
- data/README.md +211 -0
- data/ext/rogue_machines_native/extconf.rb +3 -0
- data/lib/rogue_machines/version.rb +5 -0
- data/lib/rogue_machines.rb +296 -0
- data/lib/rogue_machines_native/rogue_machines_native.so +0 -0
- data/sig/rogue_machines.rbs +146 -0
- metadata +78 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 8118db37b470339b2efa5c0b35cd9a7f9b1d2c5eec226e79df01863ae49318a6
|
|
4
|
+
data.tar.gz: bf84896aa826ee279fb15eecc4407039a20737ed6e8a118a86fe8f1edbf2ee5c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 3a34fd3140d51ef42a38759bf10f613a97fee30db53947e066b069ed404ffcbed3f040a9798e5bae1c283dc6af6fc220f4cabafe9861109bd6bb3b7e8387bfc1
|
|
7
|
+
data.tar.gz: e7910b93325452f176e68c40a0adfe7bbe2c153bec801b6c8e3d85fe2e9d4de4d150cfa2cd7f2891445281b1afcc467350b522df10a24d1abe934ad3d1e56030
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.1](https://github.com/seuros/rogue_machines/compare/rogue_machines-v0.1.0...rogue_machines/v0.1.1) (2026-11-01)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* fault injection, disarmed by default, with an expiry it cannot outlive ([e6cfe89](https://github.com/seuros/rogue_machines/commit/e6cfe8999947dbfb048eaf15a13cb2d4dbb03943))
|
|
9
|
+
* pass a rubygems OTP through the environment ([4f60306](https://github.com/seuros/rogue_machines/commit/4f60306bf35aa8fc7336432c0cdd13b61004796b))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
### Bug Fixes
|
|
13
|
+
|
|
14
|
+
* collect the built gems whatever depth the download puts them at ([a470d97](https://github.com/seuros/rogue_machines/commit/a470d97586dad75365425ff5fdde68a982863262))
|
|
15
|
+
* the release tag carries the gem name, and the gate uses the aggregate output ([1c980cd](https://github.com/seuros/rogue_machines/commit/1c980cd0a36bdce2913e1a74252f90987183f0f7))
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Abdelkader Boudih
|
|
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,211 @@
|
|
|
1
|
+
# RogueMachines 💥
|
|
2
|
+
|
|
3
|
+
> **Controlled sabotage for the fleet.** The ship that breaks your systems on
|
|
4
|
+
> purpose, so the void doesn't get to do it first.
|
|
5
|
+
|
|
6
|
+
Every other vessel in this fleet exists to survive a failure. None of them can
|
|
7
|
+
produce one. A breaker that has never opened, a retry budget that has never
|
|
8
|
+
been spent and a deadline that has never been hit are three untested claims
|
|
9
|
+
flying in formation. **AMS Gray** arms the failure that tests them.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## 🚀 Launch Sequence
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# Add to your ship's Gemfile
|
|
17
|
+
gem 'rogue_machines'
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Requires **Ruby 4.0+** and a Rust toolchain, or a platform gem.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## 🎯 Mission Brief
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
require 'rogue_machines'
|
|
28
|
+
|
|
29
|
+
RogueMachines.arm(:payments, fault: :raise, share: 0.01, expires_in: 300)
|
|
30
|
+
|
|
31
|
+
RogueMachines.run(:payments) do
|
|
32
|
+
Payments.charge(order)
|
|
33
|
+
end
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
One call in a hundred now fails for the next five minutes, and the breaker
|
|
37
|
+
wrapped around it finally gets to prove it works.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## 🛡️ Safety Interlocks
|
|
42
|
+
|
|
43
|
+
**Weapons are cold until you arm them.** A process that has armed nothing
|
|
44
|
+
injects nothing, and `run` says so by validating the name and reading one word
|
|
45
|
+
through one native call.
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
RogueMachines.armed? # => false
|
|
49
|
+
RogueMachines.run(:payments) { Payments.charge(order) } # the real call
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
It allocates nothing: zero objects per call for a Symbol, a String or a frozen
|
|
53
|
+
String name. The overhead is about 160 ns, most of it the name check rather
|
|
54
|
+
than the native read. That is why this ships as a runtime dependency rather
|
|
55
|
+
than a test-only one. The interlock sits on every call to every protected
|
|
56
|
+
dependency, so it has to be cheap enough to leave in.
|
|
57
|
+
|
|
58
|
+
Nothing is armed by loading the gem, by an environment variable or by a config
|
|
59
|
+
file. Arming is an explicit call naming a target and a share of traffic.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## ⏱️ Every Experiment Self-Destructs
|
|
64
|
+
|
|
65
|
+
`expires_in:` is **required**. There is no way to arm an experiment that
|
|
66
|
+
outlives the officer who armed it, and no constructor in the Rust core that
|
|
67
|
+
takes a fault without a reading it expires at.
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
RogueMachines.arm(:payments, fault: :raise, share: 1.0, expires_in: 60)
|
|
71
|
+
RogueMachines.armed # => [:payments]
|
|
72
|
+
|
|
73
|
+
# sixty seconds later, with nobody touching anything
|
|
74
|
+
RogueMachines.armed # => []
|
|
75
|
+
RogueMachines.armed? # => false
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
An expiry is **observed, not scheduled**: nothing retires an experiment until
|
|
79
|
+
something takes a reading. The first `run`, `armed` or `armed?` at or after the
|
|
80
|
+
expiry retires it, and once retired a clock that steps backwards cannot bring
|
|
81
|
+
it back.
|
|
82
|
+
|
|
83
|
+
An experiment lasts at most twenty-four hours. Past that it is configuration,
|
|
84
|
+
not an experiment, and it must be armed again deliberately.
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## 💥 The Armory
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
# Fail.
|
|
92
|
+
RogueMachines.arm(:payments, fault: :raise, share: 0.01, expires_in: 300)
|
|
93
|
+
|
|
94
|
+
# Be slow, then make the real call.
|
|
95
|
+
RogueMachines.arm(:search, fault: :delay, delay: 2, share: 0.5, expires_in: 300)
|
|
96
|
+
|
|
97
|
+
# Succeed, with a substitute.
|
|
98
|
+
RogueMachines.arm(:catalogue, fault: :garble, garbled: [], share: 0.1, expires_in: 300)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
A `:raise` raises `RogueMachines::Injected`. A `:garble` returns `garbled:`
|
|
102
|
+
without running the block, which defaults to `nil` so a forgotten substitute
|
|
103
|
+
cannot quietly return the real value.
|
|
104
|
+
|
|
105
|
+
**There is no weapon that holds a caller forever.** A dependency that times out
|
|
106
|
+
is a `:delay` longer than the caller's own budget, which leaves the caller's
|
|
107
|
+
timeout in charge of when it gives up. The delay is spent against the ambient
|
|
108
|
+
`deadline_machines` budget, so a stall inside a deadline trips that deadline
|
|
109
|
+
instead of sleeping past it. Because a deadline is opt-in per call site, a
|
|
110
|
+
delay is also capped at five minutes. That is not a substitute for a deadline;
|
|
111
|
+
it bounds what a mistyped argument can do to a caller that has none.
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## 🎲 Targeting
|
|
116
|
+
|
|
117
|
+
`share:` is a fraction of calls between 0 and 1, and the draw deciding each
|
|
118
|
+
call comes from `RogueMachines.random`. Replace it to replay an exact run:
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
RogueMachines.random = Random.new(1234)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
A share is half open at its own boundary and expressed in millionths, so 0.0
|
|
125
|
+
reaches nothing and 1.0 reaches everything. It is a threshold from zero rather
|
|
126
|
+
than an interval: 0.1 and 0.9 both cover a low draw, so two experiments cannot
|
|
127
|
+
divide one stream of calls between them. A share below one millionth is
|
|
128
|
+
refused rather than armed as a silent no-op.
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## 🕰️ The Ship's Chronometer
|
|
133
|
+
|
|
134
|
+
`RogueMachines.clock` delegates to `DeadlineMachines.clock`, so a test can
|
|
135
|
+
expire an experiment without sleeping:
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
RogueMachines.clock = DeadlineMachines::TestClock.new
|
|
139
|
+
RogueMachines.arm(:payments, fault: :raise, share: 1.0, expires_in: 60)
|
|
140
|
+
RogueMachines.clock.advance(60)
|
|
141
|
+
RogueMachines.armed? # => false
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
This is the one place `RogueMachines` consumes a primitive from the fleet
|
|
145
|
+
rather than tripping it.
|
|
146
|
+
|
|
147
|
+
**Install the chronometer through `RogueMachines.clock=`.** Doing so disarms
|
|
148
|
+
everything first, because every armed expiry was computed against the outgoing
|
|
149
|
+
clock and an expiry is only meaningful against the clock it came from.
|
|
150
|
+
|
|
151
|
+
It is safe to do under load. A reading is taken before any lock, so that a
|
|
152
|
+
caller's clock never runs inside one, which means a call can be part way
|
|
153
|
+
through when the clock is replaced. Each reading therefore carries the
|
|
154
|
+
generation it belongs to, and a call arriving with a superseded one is refused
|
|
155
|
+
rather than acted on: arming raises and nothing is armed, a decision makes the
|
|
156
|
+
real call, and a query reports nothing. A clock being replaced faster than a
|
|
157
|
+
reading can be taken is answered the same way rather than retried forever, and
|
|
158
|
+
a clock returning something that is not a non-negative Integer of nanoseconds
|
|
159
|
+
raises an error naming the clock.
|
|
160
|
+
|
|
161
|
+
Replacing `DeadlineMachines.clock` underneath is reflected, since the
|
|
162
|
+
delegation is not memoised, but it cannot disarm anything. A clock whose epoch
|
|
163
|
+
is lower than the old one then makes an armed experiment look as though its
|
|
164
|
+
expiry is still far away. Set `RogueMachines.clock` and the question does not
|
|
165
|
+
arise.
|
|
166
|
+
|
|
167
|
+
A reading is also taken before the lock when arming, so the expiry of a new
|
|
168
|
+
experiment is measured from slightly before it is armed. With a real monotonic
|
|
169
|
+
clock that is microseconds. Nothing infers that a reading came from a
|
|
170
|
+
different clock: with several threads taking their own readings, a reading
|
|
171
|
+
older than the arming is ordinary interleaving, and treating it as evidence of
|
|
172
|
+
a clock change retires experiments that were just armed.
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## 🔩 The Native Extension Is Required
|
|
177
|
+
|
|
178
|
+
There is no pure-Ruby fallback and no switch to turn one on. `require
|
|
179
|
+
'rogue_machines'` loads the extension and fails if it is not there. The gem is
|
|
180
|
+
a binding to the `rogue-machines` crate in `ext/rogue_machines_native/core`,
|
|
181
|
+
which has zero dependencies and builds `no_std` for wasm and for a
|
|
182
|
+
microcontroller. The binding asks for its `std` feature, so the crate as this
|
|
183
|
+
gem consumes it is not `no_std`.
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
## 🚫 Rules of Engagement
|
|
188
|
+
|
|
189
|
+
- **No breaker, limiter, retry or budget.** It trips those. `breaker_machines`,
|
|
190
|
+
`throttle_machines`, `chrono_machines` and `shed_machines` own them.
|
|
191
|
+
- **No clock of its own.** Every reading is nanoseconds the caller passes in,
|
|
192
|
+
which is why the core builds for bare metal.
|
|
193
|
+
- **No randomness of its own.** The draw is the caller's.
|
|
194
|
+
- **No capacity beyond 32 experiments**, because the armed set is one 32-bit
|
|
195
|
+
word and the interlock on the hot path is a single load of it.
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
## 📡 Credits
|
|
200
|
+
|
|
201
|
+
**AMS Gray**, NCC-0401, *Gray*-class raider.
|
|
202
|
+
|
|
203
|
+
Named for Jim Gray's *Why Do Computers Stop and What Can Be Done About It?*
|
|
204
|
+
(1985), which argued that the way to find out whether a system tolerates
|
|
205
|
+
faults is to give it some.
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
## License
|
|
210
|
+
|
|
211
|
+
MIT
|
|
@@ -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
|
|
Binary file
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# Fault injection: making a dependency fail, stall or lie on purpose.
|
|
2
|
+
module RogueMachines
|
|
3
|
+
VERSION: String
|
|
4
|
+
|
|
5
|
+
# Nanoseconds in a second.
|
|
6
|
+
NS_PER_SECOND: Integer
|
|
7
|
+
|
|
8
|
+
# Millionths in a whole, the unit a share is held in.
|
|
9
|
+
PER_MILLION: Integer
|
|
10
|
+
|
|
11
|
+
# Experiments a process can arm at once, the width of the armed mask.
|
|
12
|
+
EXPERIMENTS: Integer
|
|
13
|
+
|
|
14
|
+
# Attempts at one consistent reading before giving up on a clock being
|
|
15
|
+
# replaced faster than it can be read.
|
|
16
|
+
READINGS: Integer
|
|
17
|
+
|
|
18
|
+
class Error < StandardError
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Raised by a `:raise` fault in place of the real call.
|
|
22
|
+
class Injected < Error
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The fault to perform for one call.
|
|
26
|
+
class Injection < ::Data
|
|
27
|
+
# `:raise`, `:delay` or `:garble`.
|
|
28
|
+
attr_reader fault: Symbol
|
|
29
|
+
|
|
30
|
+
# How long a `:delay` waits, and zero for the others.
|
|
31
|
+
attr_reader delay_ns: Integer
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Anything that reports monotonic nanoseconds.
|
|
35
|
+
interface _Clock
|
|
36
|
+
def now_ns: () -> Integer
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Anything that draws below a bound, which `Random` satisfies.
|
|
40
|
+
interface _Random
|
|
41
|
+
def rand: (Integer bound) -> Integer
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The experiments a process is running, by name. Reach it through the module.
|
|
45
|
+
class Rogue
|
|
46
|
+
def self.new: () -> instance
|
|
47
|
+
|
|
48
|
+
# Whether any experiment is armed as of the last sweep, which is the whole
|
|
49
|
+
# of the decision a disarmed process takes. A sweep happens on every
|
|
50
|
+
# `decide` and every `armed`, so this trails an expiry by one call.
|
|
51
|
+
def any_armed?: () -> bool
|
|
52
|
+
|
|
53
|
+
# Sweeps expired experiments at `now_ns`, then counts what is left.
|
|
54
|
+
# Reports 0 without sweeping for a superseded generation. Every seam
|
|
55
|
+
# taking a reading refuses a Float rather than truncating it.
|
|
56
|
+
def armed_count: (Integer now_ns, Integer generation) -> Integer
|
|
57
|
+
|
|
58
|
+
# Arms `name`, replacing any experiment already there. Raises
|
|
59
|
+
# `ArgumentError` for an unknown fault, a share outside a whole or below
|
|
60
|
+
# one millionth, a duration that is non-positive, below a nanosecond or
|
|
61
|
+
# longer than the cap, a `now_ns` that is not an Integer or is outside the
|
|
62
|
+
# range, or a `:delay`
|
|
63
|
+
# with no `delay:`. Raises `Error` when every slot is taken. Nothing is
|
|
64
|
+
# armed and no slot is taken when it raises.
|
|
65
|
+
def arm: (String name, fault: String, share: Float, expires_in: Float,
|
|
66
|
+
now_ns: Integer, generation: Integer, ?delay: Float) -> void
|
|
67
|
+
|
|
68
|
+
# Reports whether `name` was still running, which an experiment that
|
|
69
|
+
# expired on its own is not.
|
|
70
|
+
def disarm: (String name, Integer now_ns, Integer generation) -> bool
|
|
71
|
+
|
|
72
|
+
def disarm_all: () -> void
|
|
73
|
+
|
|
74
|
+
# Which clock the armed expiries were computed against. Every reading is
|
|
75
|
+
# only meaningful for one of these, and a call carrying another one is
|
|
76
|
+
# refused rather than acted on, since sweeping is destructive.
|
|
77
|
+
def generation: () -> Integer
|
|
78
|
+
|
|
79
|
+
# Disarms everything and starts a generation, retiring readings already
|
|
80
|
+
# taken against the outgoing clock. Returns the new generation.
|
|
81
|
+
def invalidate: () -> Integer
|
|
82
|
+
|
|
83
|
+
# The names still running at `now_ns`, in no particular order, and none
|
|
84
|
+
# for a superseded generation.
|
|
85
|
+
def armed: (Integer now_ns, Integer generation) -> Array[String]
|
|
86
|
+
|
|
87
|
+
# The fault to perform, or `nil` to make the real call. Raises
|
|
88
|
+
# `ArgumentError` for a `draw` or `now_ns` that is not an Integer, is
|
|
89
|
+
# outside its range, or is a `draw` at or above `PER_MILLION`. A Float is
|
|
90
|
+
# refused rather than truncated, because a truncated draw becomes 0,
|
|
91
|
+
# which every share covers.
|
|
92
|
+
def decide: (String name, Integer now_ns, Integer draw, Integer generation) -> Injection?
|
|
93
|
+
|
|
94
|
+
def inspect: () -> String
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Delegates to `DeadlineMachines.clock` when unset, rather than memoising
|
|
98
|
+
# it, so replacing that clock moves every expiry with it.
|
|
99
|
+
def self.clock: () -> _Clock
|
|
100
|
+
|
|
101
|
+
# Disarms everything first: an armed expiry was computed against the
|
|
102
|
+
# outgoing clock and is not meaningful against another one.
|
|
103
|
+
def self.clock=: (_Clock?) -> void
|
|
104
|
+
def self.random: () -> _Random
|
|
105
|
+
def self.random=: (_Random?) -> void
|
|
106
|
+
# The registry itself. Reach for the module methods instead: arming
|
|
107
|
+
# through this bypasses the substitute a `:garble` returns, and freezing
|
|
108
|
+
# it does not prevent arming, since the native side holds its own state.
|
|
109
|
+
def self.registry: () -> Rogue
|
|
110
|
+
|
|
111
|
+
# Arms an experiment under `name`. `expires_in` counts from now and is
|
|
112
|
+
# required, so no experiment outlives the person who armed it.
|
|
113
|
+
#
|
|
114
|
+
# Raises `ArgumentError` for a blank name, a name that is neither ASCII nor
|
|
115
|
+
# valid UTF-8, or any argument the registry refuses. Raises `Error` when
|
|
116
|
+
# every slot is taken, when the clock was replaced faster than a reading
|
|
117
|
+
# could be taken, and when the clock returned something that is not a
|
|
118
|
+
# non-negative Integer of nanoseconds.
|
|
119
|
+
#
|
|
120
|
+
# `share`, `expires_in` and `delay` go through `Float()`, so a String that
|
|
121
|
+
# parses as a number is accepted, one that does not raises `ArgumentError`,
|
|
122
|
+
# and a value with no numeric conversion at all raises `TypeError`.
|
|
123
|
+
def self.arm: (Symbol | String name, fault: Symbol | String, share: Numeric | String,
|
|
124
|
+
expires_in: Numeric | String, ?delay: (Numeric | String)?,
|
|
125
|
+
?garbled: untyped) -> (Symbol | String)
|
|
126
|
+
|
|
127
|
+
def self.disarm: (Symbol | String name) -> bool
|
|
128
|
+
def self.disarm_all: () -> void
|
|
129
|
+
|
|
130
|
+
# With no name, whether anything at all is armed.
|
|
131
|
+
def self.armed?: (?(Symbol | String)?) -> bool
|
|
132
|
+
|
|
133
|
+
# The experiments still running, which excludes any that has expired.
|
|
134
|
+
def self.armed: () -> Array[Symbol]
|
|
135
|
+
|
|
136
|
+
# Runs the block, or the fault armed under `name` instead. Not the block's
|
|
137
|
+
# own type: a `:garble` returns the substitute it was armed with, which is
|
|
138
|
+
# whatever the caller passed as `garbled:`. Makes the real call when the
|
|
139
|
+
# clock was replaced faster than a reading could be taken.
|
|
140
|
+
#
|
|
141
|
+
# Raises `Injected` for a `:raise` fault, which is the point of the gem,
|
|
142
|
+
# `DeadlineMachines::Error` when a `:delay` spends the ambient budget,
|
|
143
|
+
# `ArgumentError` for a name it cannot key on or a draw the random gave it,
|
|
144
|
+
# and `Error` for a clock returning an unusable reading.
|
|
145
|
+
def self.run: (Symbol | String name) { () -> untyped } -> untyped
|
|
146
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: rogue_machines
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.1
|
|
5
|
+
platform: x86_64-linux
|
|
6
|
+
authors:
|
|
7
|
+
- Abdelkader Boudih
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-11-01 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: deadline_machines
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: 0.1.0
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: 0.1.0
|
|
27
|
+
description: 'Fault injection for chaos engineering: arms a named experiment that
|
|
28
|
+
makes a dependency fail, stall or lie for a share of calls, so the breakers, retries
|
|
29
|
+
and budgets around it can be shown to work. Bound to Ruby from the zero-dependency
|
|
30
|
+
rogue-machines crate. Disarmed by default, and every experiment carries an expiry
|
|
31
|
+
it cannot outlive.'
|
|
32
|
+
email:
|
|
33
|
+
- oss@seuros.com
|
|
34
|
+
executables: []
|
|
35
|
+
extensions: []
|
|
36
|
+
extra_rdoc_files: []
|
|
37
|
+
files:
|
|
38
|
+
- CHANGELOG.md
|
|
39
|
+
- LICENSE.txt
|
|
40
|
+
- README.md
|
|
41
|
+
- ext/rogue_machines_native/extconf.rb
|
|
42
|
+
- lib/rogue_machines.rb
|
|
43
|
+
- lib/rogue_machines/version.rb
|
|
44
|
+
- lib/rogue_machines_native/rogue_machines_native.so
|
|
45
|
+
- sig/rogue_machines.rbs
|
|
46
|
+
homepage: https://github.com/seuros/rogue_machines
|
|
47
|
+
licenses:
|
|
48
|
+
- MIT
|
|
49
|
+
metadata:
|
|
50
|
+
source_code_uri: https://github.com/seuros/rogue_machines
|
|
51
|
+
changelog_uri: https://github.com/seuros/rogue_machines/blob/master/CHANGELOG.md
|
|
52
|
+
bug_tracker_uri: https://github.com/seuros/rogue_machines/issues
|
|
53
|
+
rubygems_mfa_required: 'true'
|
|
54
|
+
cargo_crate_name: rogue_machines_native
|
|
55
|
+
cargo_manifest_path: ext/rogue_machines_native/ffi/Cargo.toml
|
|
56
|
+
post_install_message:
|
|
57
|
+
rdoc_options: []
|
|
58
|
+
require_paths:
|
|
59
|
+
- lib
|
|
60
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
61
|
+
requirements:
|
|
62
|
+
- - ">="
|
|
63
|
+
- !ruby/object:Gem::Version
|
|
64
|
+
version: '4.0'
|
|
65
|
+
- - "<"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: 4.1.dev
|
|
68
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
69
|
+
requirements:
|
|
70
|
+
- - ">="
|
|
71
|
+
- !ruby/object:Gem::Version
|
|
72
|
+
version: '0'
|
|
73
|
+
requirements: []
|
|
74
|
+
rubygems_version: 3.5.23
|
|
75
|
+
signing_key:
|
|
76
|
+
specification_version: 4
|
|
77
|
+
summary: Fault injection and chaos engineering for Ruby
|
|
78
|
+
test_files: []
|