shed_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,80 @@
1
+ //! `ShedMachines::Codel`: shed by how long work waited.
2
+
3
+ use std::cell::RefCell;
4
+
5
+ use magnus::scan_args::{get_kwargs, scan_args};
6
+ use magnus::{Error, RHash, RModule, Ruby, Symbol, Value, function, method, prelude::*};
7
+ use shed_machines::Codel;
8
+ use shed_machines::codel::{DEFAULT_INTERVAL_NS, DEFAULT_TARGET_NS};
9
+
10
+ use crate::{duration, to_seconds, verdict};
11
+
12
+ #[magnus::wrap(class = "ShedMachines::Codel", free_immediately, size)]
13
+ struct Wrapper(RefCell<Codel>);
14
+
15
+ impl Wrapper {
16
+ fn new(ruby: &Ruby, args: &[Value]) -> Result<Self, Error> {
17
+ let args = scan_args::<(), (), (), (), RHash, ()>(args)?;
18
+ let (target, interval) = get_kwargs::<_, (), (Option<f64>, Option<f64>), ()>(
19
+ args.keywords,
20
+ &[],
21
+ &["target", "interval"],
22
+ )?
23
+ .optional;
24
+ let queue = Codel::new(
25
+ target.map_or(Ok(DEFAULT_TARGET_NS), |seconds| {
26
+ duration(ruby, "target", seconds)
27
+ })?,
28
+ interval.map_or(Ok(DEFAULT_INTERVAL_NS), |seconds| {
29
+ duration(ruby, "interval", seconds)
30
+ })?,
31
+ );
32
+ Ok(Self(RefCell::new(queue)))
33
+ }
34
+
35
+ /// `sojourn:` is how long this work waited, `now:` the current time, both
36
+ /// in seconds. Answers `:admit` or `:shed`.
37
+ fn admit(ruby: &Ruby, rb_self: &Self, args: &[Value]) -> Result<Symbol, Error> {
38
+ let args = scan_args::<(), (), (), (), RHash, ()>(args)?;
39
+ let (sojourn, now) =
40
+ get_kwargs::<_, (f64, f64), (), ()>(args.keywords, &["sojourn", "now"], &[])?.required;
41
+ let sojourn_ns = duration(ruby, "sojourn", sojourn)?;
42
+ let now_ns = duration(ruby, "now", now)?;
43
+ let answer = rb_self.0.borrow_mut().admit(sojourn_ns, now_ns);
44
+ Ok(verdict(ruby, answer))
45
+ }
46
+
47
+ /// The acceptable waiting time, in seconds.
48
+ fn interval(&self) -> f64 {
49
+ to_seconds(self.0.borrow().interval_ns())
50
+ }
51
+
52
+ fn target(&self) -> f64 {
53
+ to_seconds(self.0.borrow().target_ns())
54
+ }
55
+
56
+ fn shedding(&self) -> bool {
57
+ self.0.borrow().shedding()
58
+ }
59
+
60
+ fn inspect(&self) -> String {
61
+ format!(
62
+ "#<ShedMachines::Codel target={} shedding={}>",
63
+ self.target(),
64
+ self.shedding()
65
+ )
66
+ }
67
+ }
68
+
69
+ pub fn define(ruby: &Ruby, module: RModule) -> Result<(), Error> {
70
+ let class = module.define_class("Codel", ruby.class_object())?;
71
+ class.const_set("DEFAULT_TARGET_NS", DEFAULT_TARGET_NS)?;
72
+ class.const_set("DEFAULT_INTERVAL_NS", DEFAULT_INTERVAL_NS)?;
73
+ class.define_singleton_method("new", function!(Wrapper::new, -1))?;
74
+ class.define_method("admit", method!(Wrapper::admit, -1))?;
75
+ class.define_method("target", method!(Wrapper::target, 0))?;
76
+ class.define_method("interval", method!(Wrapper::interval, 0))?;
77
+ class.define_method("shedding?", method!(Wrapper::shedding, 0))?;
78
+ class.define_method("inspect", method!(Wrapper::inspect, 0))?;
79
+ Ok(())
80
+ }
@@ -0,0 +1,161 @@
1
+ //! Ruby bindings for the `shed-machines` control laws.
2
+ //!
3
+ //! Durations cross this boundary as seconds and move through the control laws
4
+ //! as nanoseconds. [`to_ns`] and [`to_seconds`] are the only places either
5
+ //! representation changes.
6
+
7
+ #![allow(clippy::needless_pass_by_value)]
8
+
9
+ mod aimd;
10
+ mod budget;
11
+ mod codel;
12
+ mod vegas;
13
+
14
+ use std::cell::RefCell;
15
+
16
+ use magnus::scan_args::{get_kwargs, scan_args};
17
+ use magnus::{Error, Integer, RHash, Ruby, Symbol, TryConvert, Value, prelude::*};
18
+ use shed_machines::{Bounds, Controller, DEFAULT_CEILING, LOWEST_FLOOR, Outcome, Sample, Verdict};
19
+
20
+ /// Nanoseconds in a second.
21
+ pub(crate) const NS_PER_SECOND: u32 = 1_000_000_000;
22
+
23
+ #[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
24
+ pub(crate) fn to_ns(seconds: f64) -> u64 {
25
+ (seconds * f64::from(NS_PER_SECOND)).round() as u64
26
+ }
27
+
28
+ #[expect(clippy::cast_precision_loss)]
29
+ pub(crate) fn to_seconds(nanoseconds: u64) -> f64 {
30
+ nanoseconds as f64 / f64::from(NS_PER_SECOND)
31
+ }
32
+
33
+ /// A whole number of things: a limit, a bound, a call count, a ratio's two
34
+ /// sides.
35
+ ///
36
+ /// magnus reads an integer through Ruby's `NUM2*` family, which truncates a
37
+ /// `Float` towards zero instead of refusing it, so an argument that counts
38
+ /// rather than measures is read as one of these and anything but an `Integer`
39
+ /// is turned away. An `Integer` outside the target width still comes back as
40
+ /// the `RangeError` magnus raises for it. Seconds stay [`duration`]'s.
41
+ pub(crate) struct Whole<T>(pub(crate) T);
42
+
43
+ impl<T: TryConvert> TryConvert for Whole<T> {
44
+ fn try_convert(value: Value) -> Result<Self, Error> {
45
+ if Integer::from_value(value).is_none() {
46
+ return Err(not_whole(value));
47
+ }
48
+ T::try_convert(value).map(Self)
49
+ }
50
+ }
51
+
52
+ fn not_whole(value: Value) -> Error {
53
+ let ruby = Ruby::get_with(value);
54
+ Error::new(
55
+ ruby.exception_type_error(),
56
+ format!(
57
+ "no implicit conversion of {} into Integer: {}",
58
+ value.class(),
59
+ value.inspect()
60
+ ),
61
+ )
62
+ }
63
+
64
+ /// Seconds as nanoseconds, rejecting a negative or non-finite duration.
65
+ pub(crate) fn duration(ruby: &Ruby, name: &str, seconds: f64) -> Result<u64, Error> {
66
+ if !seconds.is_finite() || seconds < 0.0 {
67
+ return Err(Error::new(
68
+ ruby.exception_arg_error(),
69
+ format!("{name} must be finite and not negative, got {seconds}"),
70
+ ));
71
+ }
72
+ Ok(to_ns(seconds))
73
+ }
74
+
75
+ /// The keywords every controller is opened with: a starting `limit:`, the
76
+ /// `min:` and `max:` it moves within, and the two values it tunes itself with.
77
+ pub(crate) fn opening(
78
+ ruby: &Ruby,
79
+ keywords: RHash,
80
+ tuning: [&str; 2],
81
+ ) -> Result<(Bounds, u32, [Option<u32>; 2]), Error> {
82
+ let keywords = get_kwargs::<
83
+ _,
84
+ (Whole<u32>,),
85
+ (
86
+ Option<Whole<u32>>,
87
+ Option<Whole<u32>>,
88
+ Option<Whole<u32>>,
89
+ Option<Whole<u32>>,
90
+ ),
91
+ (),
92
+ >(keywords, &["limit"], &["min", "max", tuning[0], tuning[1]])?;
93
+ let (Whole(limit),) = keywords.required;
94
+ let (min, max, first, second) = keywords.optional;
95
+ let floor = min.map_or(LOWEST_FLOOR, |Whole(min)| min);
96
+ let ceiling = max.map_or(DEFAULT_CEILING, |Whole(max)| max);
97
+ if floor < LOWEST_FLOOR || ceiling < floor {
98
+ return Err(Error::new(
99
+ ruby.exception_arg_error(),
100
+ format!(
101
+ "min must be at least {LOWEST_FLOOR} and max at least min, got {floor}..{ceiling}"
102
+ ),
103
+ ));
104
+ }
105
+ let tuning = [first, second].map(|given| given.map(|Whole(value)| value));
106
+ Ok((Bounds::new(floor, ceiling), limit, tuning))
107
+ }
108
+
109
+ /// Folds one completed call, given as `rtt:` in seconds, `in_flight:` and
110
+ /// `outcome:`, into a controller.
111
+ pub(crate) fn observe<C: Controller>(
112
+ ruby: &Ruby,
113
+ control: &RefCell<C>,
114
+ args: &[Value],
115
+ ) -> Result<(), Error> {
116
+ let args = scan_args::<(), (), (), (), RHash, ()>(args)?;
117
+ let (rtt, Whole(in_flight), outcome) = get_kwargs::<_, (f64, Whole<u32>, Value), (), ()>(
118
+ args.keywords,
119
+ &["rtt", "in_flight", "outcome"],
120
+ &[],
121
+ )?
122
+ .required;
123
+ let sample = Sample {
124
+ rtt_ns: duration(ruby, "rtt", rtt)?,
125
+ in_flight,
126
+ outcome: self::outcome(ruby, outcome)?,
127
+ };
128
+ control.borrow_mut().observe(sample);
129
+ Ok(())
130
+ }
131
+
132
+ fn outcome(ruby: &Ruby, value: Value) -> Result<Outcome, Error> {
133
+ let name = Symbol::from_value(value).and_then(|symbol| symbol.name().ok());
134
+ match name.as_deref() {
135
+ Some("ok") => Ok(Outcome::Ok),
136
+ Some("overload") => Ok(Outcome::Overload),
137
+ _ => Err(Error::new(
138
+ ruby.exception_arg_error(),
139
+ format!("unknown outcome: {}", value.inspect()),
140
+ )),
141
+ }
142
+ }
143
+
144
+ pub(crate) fn verdict(ruby: &Ruby, verdict: Verdict) -> Symbol {
145
+ match verdict {
146
+ Verdict::Admit => ruby.to_symbol("admit"),
147
+ Verdict::Shed => ruby.to_symbol("shed"),
148
+ }
149
+ }
150
+
151
+ #[magnus::init]
152
+ fn init(ruby: &Ruby) -> Result<(), Error> {
153
+ let module = ruby.define_module("ShedMachines")?;
154
+ module.const_set("NS_PER_SECOND", NS_PER_SECOND)?;
155
+ module.const_set("LOWEST_FLOOR", LOWEST_FLOOR)?;
156
+ module.const_set("DEFAULT_CEILING", DEFAULT_CEILING)?;
157
+ aimd::define(ruby, module)?;
158
+ vegas::define(ruby, module)?;
159
+ codel::define(ruby, module)?;
160
+ budget::define(ruby, module)
161
+ }
@@ -0,0 +1,61 @@
1
+ //! `ShedMachines::Vegas`: steer the limit by how many calls look queued.
2
+
3
+ use std::cell::RefCell;
4
+
5
+ use magnus::scan_args::scan_args;
6
+ use magnus::typed_data::Obj;
7
+ use magnus::{Error, RHash, RModule, Ruby, Value, function, method, prelude::*};
8
+ use shed_machines::vegas::{DEFAULT_ALPHA, DEFAULT_BETA, OVERLOAD_DIVISOR};
9
+ use shed_machines::{Controller, Vegas};
10
+
11
+ use crate::{observe, opening, to_seconds};
12
+
13
+ #[magnus::wrap(class = "ShedMachines::Vegas", free_immediately, size)]
14
+ struct Wrapper(RefCell<Vegas>);
15
+
16
+ impl Wrapper {
17
+ fn new(ruby: &Ruby, args: &[Value]) -> Result<Self, Error> {
18
+ let args = scan_args::<(), (), (), (), RHash, ()>(args)?;
19
+ let (bounds, limit, [alpha, beta]) = opening(ruby, args.keywords, ["alpha", "beta"])?;
20
+ let control = Vegas::new(bounds, limit)
21
+ .band(alpha.unwrap_or(DEFAULT_ALPHA), beta.unwrap_or(DEFAULT_BETA));
22
+ Ok(Self(RefCell::new(control)))
23
+ }
24
+
25
+ fn observe(ruby: &Ruby, rb_self: Obj<Self>, args: &[Value]) -> Result<Obj<Self>, Error> {
26
+ observe(ruby, &rb_self.0, args)?;
27
+ Ok(rb_self)
28
+ }
29
+
30
+ fn limit(&self) -> u32 {
31
+ self.0.borrow().limit()
32
+ }
33
+
34
+ /// The best round-trip time seen so far, in seconds.
35
+ fn best_rtt(&self) -> Option<f64> {
36
+ self.0.borrow().best_rtt_ns().map(to_seconds)
37
+ }
38
+
39
+ fn inspect(&self) -> String {
40
+ let best_rtt = self
41
+ .best_rtt()
42
+ .map_or_else(|| "nil".to_owned(), |rtt| rtt.to_string());
43
+ format!(
44
+ "#<ShedMachines::Vegas limit={} best_rtt={best_rtt}>",
45
+ self.limit()
46
+ )
47
+ }
48
+ }
49
+
50
+ pub fn define(ruby: &Ruby, module: RModule) -> Result<(), Error> {
51
+ let class = module.define_class("Vegas", ruby.class_object())?;
52
+ class.const_set("DEFAULT_ALPHA", DEFAULT_ALPHA)?;
53
+ class.const_set("DEFAULT_BETA", DEFAULT_BETA)?;
54
+ class.const_set("OVERLOAD_DIVISOR", OVERLOAD_DIVISOR)?;
55
+ class.define_singleton_method("new", function!(Wrapper::new, -1))?;
56
+ class.define_method("observe", method!(Wrapper::observe, -1))?;
57
+ class.define_method("limit", method!(Wrapper::limit, 0))?;
58
+ class.define_method("best_rtt", method!(Wrapper::best_rtt, 0))?;
59
+ class.define_method("inspect", method!(Wrapper::inspect, 0))?;
60
+ Ok(())
61
+ }
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShedMachines
4
+ VERSION = '0.1.1'
5
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'shed_machines/version'
4
+
5
+ # Aimd, Vegas, Codel and Budget are defined by the native extension; there is no
6
+ # pure-Ruby implementation to fall back to.
7
+ require 'shed_machines_native/shed_machines_native'
@@ -0,0 +1,131 @@
1
+ # Adaptive concurrency limits and load shedding. Every class here is defined by
2
+ # the native extension, which binds the `shed-machines` crate.
3
+ #
4
+ # Nothing here enforces anything: these are the control loops, and the caller
5
+ # owns the semaphore, the queue and the dice.
6
+ module ShedMachines
7
+ VERSION: String
8
+
9
+ # Nanoseconds in a second. Durations cross this boundary as seconds and move
10
+ # through the control laws as nanoseconds.
11
+ NS_PER_SECOND: Integer
12
+
13
+ # A limit of zero would wedge the caller shut, so one is the lowest floor that
14
+ # still makes progress.
15
+ LOWEST_FLOOR: Integer
16
+
17
+ # Arbitrary, and deliberately so: there is no principled default ceiling for
18
+ # an unknown dependency. Set it from what the pool behind it can take.
19
+ DEFAULT_CEILING: Integer
20
+
21
+ # How a completed call ended, from the limiter's point of view. `:overload`
22
+ # means it timed out, was rejected, or otherwise says the dependency is
23
+ # loaded. Anything else raises `ArgumentError`.
24
+ type outcome = :ok | :overload
25
+
26
+ # What to do with a piece of work that has been waiting.
27
+ type verdict = :admit | :shed
28
+
29
+ # Additive increase, multiplicative decrease: grows the limit by one whenever
30
+ # a saturated call succeeds, and keeps `kept / of` of it on any overload.
31
+ class Aimd
32
+ DEFAULT_BACKOFF_KEPT: Integer
33
+ DEFAULT_BACKOFF_OF: Integer
34
+
35
+ # `limit` outside `min..max` is pulled inside it.
36
+ def self.new: (
37
+ limit: Integer,
38
+ ?min: Integer,
39
+ ?max: Integer,
40
+ ?kept: Integer,
41
+ ?of: Integer
42
+ ) -> instance
43
+
44
+ # Folds one completed call into the limit. `rtt` is in seconds, `in_flight`
45
+ # counts the calls in flight when this one started, including itself.
46
+ def observe: (rtt: Float, in_flight: Integer, outcome: outcome) -> self
47
+
48
+ # Calls allowed in flight right now.
49
+ def limit: () -> Integer
50
+
51
+ def inspect: () -> String
52
+ end
53
+
54
+ # TCP Vegas applied to a concurrency limit: estimates how many calls are
55
+ # queued rather than working from the gap between the current round-trip time
56
+ # and the best one seen, then steers the limit to keep that estimate between
57
+ # `alpha` and `beta`.
58
+ class Vegas
59
+ DEFAULT_ALPHA: Integer
60
+ DEFAULT_BETA: Integer
61
+ OVERLOAD_DIVISOR: Integer
62
+
63
+ def self.new: (
64
+ limit: Integer,
65
+ ?min: Integer,
66
+ ?max: Integer,
67
+ ?alpha: Integer,
68
+ ?beta: Integer
69
+ ) -> instance
70
+
71
+ def observe: (rtt: Float, in_flight: Integer, outcome: outcome) -> self
72
+
73
+ def limit: () -> Integer
74
+
75
+ # The best round-trip time seen so far in seconds, the stand-in for zero
76
+ # queueing. `nil` until the first completed call.
77
+ def best_rtt: () -> Float?
78
+
79
+ def inspect: () -> String
80
+ end
81
+
82
+ # Controlled delay: sheds by how long work waited rather than by how much of
83
+ # it there is, and only once the wait stays above target for a full interval.
84
+ class Codel
85
+ DEFAULT_TARGET_NS: Integer
86
+ DEFAULT_INTERVAL_NS: Integer
87
+
88
+ # Both in seconds, defaulting to the values the CoDel paper settled on.
89
+ def self.new: (?target: Float, ?interval: Float) -> instance
90
+
91
+ # `sojourn` is how long this work waited before being considered, `now` is
92
+ # the current time, both in seconds.
93
+ def admit: (sojourn: Float, now: Float) -> verdict
94
+
95
+ # The acceptable waiting time, in seconds.
96
+ def target: () -> Float
97
+ def interval: () -> Float
98
+
99
+ def shedding?: () -> bool
100
+
101
+ def inspect: () -> String
102
+ end
103
+
104
+ # Client-side adaptive throttling, so retries cannot amplify an outage.
105
+ class Budget
106
+ DEFAULT_MULTIPLIER: Float
107
+
108
+ # `multiplier` is how many requests each accepted one entitles the client
109
+ # to.
110
+ def self.new: (?multiplier: Float) -> instance
111
+
112
+ def requested: () -> self
113
+
114
+ def accepted: () -> self
115
+
116
+ # Halves both counts, so the budget forgets an outage instead of holding it
117
+ # against the dependency forever.
118
+ def decay: () -> self
119
+
120
+ def multiplier: () -> Float
121
+ def requests: () -> Integer
122
+
123
+ def accepts: () -> Integer
124
+
125
+ # The fraction of outbound work to drop locally, in `0.0..1.0`. A fraction
126
+ # rather than a decision: the caller owns the dice.
127
+ def shed_probability: () -> Float
128
+
129
+ def inspect: () -> String
130
+ end
131
+ end
metadata ADDED
@@ -0,0 +1,86 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: shed_machines
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - Abdelkader Boudih
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rb_sys
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.9'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.9'
26
+ description: 'AIMD and Vegas move a concurrency limit from what completed calls report,
27
+ CoDel sheds queued work by how long it waited, and a retry budget stops a client
28
+ from amplifying an outage. Bound to Ruby from the no_std shed-machines crate: the
29
+ control loops only, with the semaphore, the queue and the dice left to the caller.'
30
+ email:
31
+ - oss@seuros.com
32
+ executables: []
33
+ extensions:
34
+ - ext/shed_machines_native/extconf.rb
35
+ extra_rdoc_files: []
36
+ files:
37
+ - CHANGELOG.md
38
+ - Cargo.lock
39
+ - Cargo.toml
40
+ - LICENSE.txt
41
+ - README.md
42
+ - ext/shed_machines_native/core/Cargo.toml
43
+ - ext/shed_machines_native/core/src/aimd.rs
44
+ - ext/shed_machines_native/core/src/budget.rs
45
+ - ext/shed_machines_native/core/src/codel.rs
46
+ - ext/shed_machines_native/core/src/lib.rs
47
+ - ext/shed_machines_native/core/src/vegas.rs
48
+ - ext/shed_machines_native/extconf.rb
49
+ - ext/shed_machines_native/ffi/Cargo.toml
50
+ - ext/shed_machines_native/ffi/extconf.rb
51
+ - ext/shed_machines_native/ffi/src/aimd.rs
52
+ - ext/shed_machines_native/ffi/src/budget.rs
53
+ - ext/shed_machines_native/ffi/src/codel.rs
54
+ - ext/shed_machines_native/ffi/src/lib.rs
55
+ - ext/shed_machines_native/ffi/src/vegas.rs
56
+ - lib/shed_machines.rb
57
+ - lib/shed_machines/version.rb
58
+ - sig/shed_machines.rbs
59
+ homepage: https://github.com/seuros/shed_machines
60
+ licenses:
61
+ - MIT
62
+ metadata:
63
+ source_code_uri: https://github.com/seuros/shed_machines
64
+ changelog_uri: https://github.com/seuros/shed_machines/blob/main/CHANGELOG.md
65
+ bug_tracker_uri: https://github.com/seuros/shed_machines/issues
66
+ rubygems_mfa_required: 'true'
67
+ cargo_crate_name: shed_machines_native
68
+ cargo_manifest_path: ext/shed_machines_native/ffi/Cargo.toml
69
+ rdoc_options: []
70
+ require_paths:
71
+ - lib
72
+ required_ruby_version: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: 4.0.0
77
+ required_rubygems_version: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ requirements: []
83
+ rubygems_version: 4.0.20
84
+ specification_version: 4
85
+ summary: Adaptive concurrency limits and load shedding for Ruby
86
+ test_files: []