deadline_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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c025a93a7c8b92aac8ca2e2ff49e11642a4a2f340e6d53db262234d66fa793c7
4
+ data.tar.gz: d2e0aa5a9c97eb1dfcfd7ed8177ffa92b1d4c2591a7c415cdfb3bd1ab667933d
5
+ SHA512:
6
+ metadata.gz: 0a6c0eeeb3c2fe88f63f9298aa5d669a1f2fab1e2c736513df7b4cbdae272a492d3c1df6b9d7eb6927b37561721aef6dbc657f13e3114bf4f54b70926188ec61
7
+ data.tar.gz: b5303a52f3a0e5c82c7eae24d6cc43180803491cae205e1b11f742430e4fdc0ec8385ff329a31ccb259ecc6ceb39241c78b3bf83c6a2bdd99f29a37e78c2e9ae
data/CHANGELOG.md ADDED
@@ -0,0 +1,36 @@
1
+ # Changelog
2
+
3
+ ## [0.1.1](https://github.com/seuros/deadline_machines/compare/deadline_machines-v0.1.0...deadline_machines/v0.1.1) (2026-11-01)
4
+
5
+
6
+ ### Features
7
+
8
+ * **core:** add Scope, RBS signatures, release config and CI ([24018c3](https://github.com/seuros/deadline_machines/commit/24018c39edcb12634f92057d699e1f2ad936ad4a))
9
+ * deadline propagation and cooperative cancellation ([9858496](https://github.com/seuros/deadline_machines/commit/9858496f48cc0eca1edf8df613f9974fa15abba2))
10
+
11
+
12
+ ### Bug Fixes
13
+
14
+ * a block scope lets go of its child's retention too ([e5502c2](https://github.com/seuros/deadline_machines/commit/e5502c29ba21306ef386303e465195d27cb5d643))
15
+ * a cancellation racing a registration no longer strands the child ([e387b71](https://github.com/seuros/deadline_machines/commit/e387b71e6464bb5a6b35ec17b68e898231a8eca7))
16
+ * a failed gem push fails the release ([26678da](https://github.com/seuros/deadline_machines/commit/26678da9039c72be8457e53f3cc4473b6394f5c1))
17
+ * a nested scope without a limit inherits the ambient budget ([f88a0d5](https://github.com/seuros/deadline_machines/commit/f88a0d56497ee199664bd96fa479d6262abcc245))
18
+ * a parent no longer owns every deadline derived from it ([41d609c](https://github.com/seuros/deadline_machines/commit/41d609c623a480b94a3f2789c0d7c3dbf2920ee9))
19
+ * an observer deep in a chain is held all the way to the root ([9abeb8d](https://github.com/seuros/deadline_machines/commit/9abeb8d311f85991d4eed2d4f932b147e59ed574))
20
+ * detachment stops the retention walk, not just its first step ([44ff4d1](https://github.com/seuros/deadline_machines/commit/44ff4d108a6dc139a7731e50432d5010b2f7c2cd))
21
+ * one failing cancel observer no longer silences its peers ([a9ddd70](https://github.com/seuros/deadline_machines/commit/a9ddd70d0d1355aacfc0170d9b874bd9c90e3309))
22
+ * one lock per tree, so a retention chain walk is atomic ([eec49b9](https://github.com/seuros/deadline_machines/commit/eec49b9ff6a79930cfbb4cbb8de398ad7b75efaa))
23
+ * ship the core's test files so the packaged crate builds ([0340bc6](https://github.com/seuros/deadline_machines/commit/0340bc6ec958cb1983212f4cd0b9943cc36aef5c))
24
+
25
+ ## 0.1.0
26
+
27
+ - Initial release.
28
+ - `DeadlineMachines::Deadline`: monotonic budgets, `derive` for child scopes that
29
+ cannot outlive their parent, cancellation that flows down a tree, and `check!`
30
+ as the only place an error is raised.
31
+ - `DeadlineMachines.with_deadline` for a fiber-scoped ambient deadline.
32
+ - `DeadlineMachines::TestClock` for deterministic tests across the family.
33
+ - `deadline-machines` core crate: the same arithmetic and cancellation tree in
34
+ `no_std` Rust with zero dependencies, plus `Scope` pairing a budget with a
35
+ cancellation handle. Builds and lints clean on bare metal.
36
+ - RBS signatures for the whole public API.
data/Cargo.toml ADDED
@@ -0,0 +1,24 @@
1
+ [workspace]
2
+ members = ["ext/deadline_machines_native/core"]
3
+ resolver = "3"
4
+
5
+ [workspace.package]
6
+ edition = "2024"
7
+ rust-version = "1.99"
8
+
9
+ [workspace.dependencies]
10
+ deadline-machines = { path = "ext/deadline_machines_native/core" }
11
+
12
+ [workspace.lints.rust]
13
+ unsafe_code = "forbid"
14
+ unused_must_use = "warn"
15
+
16
+ [workspace.lints.clippy]
17
+ all = { level = "warn", priority = -1 }
18
+ pedantic = { level = "warn", priority = -1 }
19
+ nursery = { level = "warn", priority = -1 }
20
+ expect_used = "deny"
21
+ unwrap_used = "deny"
22
+ uninlined_format_args = "deny"
23
+ needless_borrow = "deny"
24
+ redundant_clone = "deny"
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,115 @@
1
+ # DeadlineMachines ⏱️
2
+
3
+ > **One clock for the whole fleet.** The tender that hands every ship the same
4
+ > budget, so nobody spends time the mission no longer has.
5
+
6
+ Deadline propagation and cooperative cancellation for Ruby.
7
+
8
+ `Timeout.timeout` raises wherever the interpreter happens to be, which is why it
9
+ corrupts connection state and why every serious library bans it. A deadline here
10
+ is a value you pass down and check at points you choose. **AMS Layland** carries
11
+ no weapons and trips no failures; it keeps the time the rest of the fleet
12
+ navigates by.
13
+
14
+ ---
15
+
16
+ ## 🚀 Launch Sequence
17
+
18
+ ```bash
19
+ # Add to your ship's Gemfile
20
+ gem 'deadline_machines'
21
+ ```
22
+
23
+ Requires **Ruby 4.0+**. Nothing else: no Rust toolchain, no platform gem, no
24
+ compiled extension.
25
+
26
+ ---
27
+
28
+ ## 🎯 Mission Brief
29
+
30
+ ```ruby
31
+ DeadlineMachines.with_deadline(5) do |request|
32
+ request.remaining # => 5.0
33
+
34
+ request.derive(1) do |attempt|
35
+ attempt.remaining # => 1.0
36
+ attempt.check! # raises DeadlineMachines::Exceeded when spent
37
+ end
38
+
39
+ DeadlineMachines.with_deadline(60) do
40
+ DeadlineMachines.remaining # => 5.0, a nested scope cannot widen the budget
41
+ end
42
+
43
+ request.cancel('client hung up')
44
+ DeadlineMachines.check! # raises DeadlineMachines::Cancelled
45
+ end
46
+ ```
47
+
48
+ ---
49
+
50
+ ## 🛰️ Why the Fleet Needs It
51
+
52
+ Without a shared budget, a retry policy will happily spend thirty seconds on a
53
+ request the caller abandoned twenty-eight seconds ago. `clamp_sleep` is the hook
54
+ that stops it:
55
+
56
+ ```ruby
57
+ delay = deadline.clamp_sleep(backoff.next_delay) # never overruns what is left
58
+ ```
59
+
60
+ `DeadlineMachines.current` is fiber-scoped, so `Async` tasks inherit the ambient
61
+ deadline without being handed one.
62
+
63
+ ---
64
+
65
+ ## 🕰️ The Ship's Chronometer
66
+
67
+ Budgets are built on `CLOCK_MONOTONIC`, so an NTP step cannot stretch them.
68
+ Inject `TestClock` and the whole family becomes deterministic:
69
+
70
+ ```ruby
71
+ clock = DeadlineMachines::TestClock.new
72
+ DeadlineMachines.clock = clock
73
+ deadline = DeadlineMachines::Deadline.after(1)
74
+ clock.advance(2)
75
+ deadline.expired? # => true
76
+ ```
77
+
78
+ ---
79
+
80
+ ## 🔻 Cancellation Runs Down the Chain of Command
81
+
82
+ Cancellation flows down a tree and never up. A child derived after its parent was
83
+ cancelled is already cancelled. `on_cancel` fires once, and a callback registered
84
+ on an already-cancelled deadline fires immediately. The block form of `derive`
85
+ unlinks the child on exit, so a long-lived parent does not accumulate children.
86
+
87
+ ---
88
+
89
+ ## 🔩 The Rust Core, and Why No Extension
90
+
91
+ The same arithmetic and cancellation tree ships as the `deadline-machines` crate
92
+ under `ext/deadline_machines_native/core`, `no_std` with zero dependencies and
93
+ verified on bare metal. `Scope` there is the peer of `Deadline` here: a budget
94
+ and a cancellation handle as one value.
95
+
96
+ The gem itself is pure Ruby. Reading a monotonic clock is already a vDSO read,
97
+ so there is nothing here worth a native extension. The crate is there for Rust
98
+ consumers, not for this gem's install.
99
+
100
+ ---
101
+
102
+ ## 📡 Credits
103
+
104
+ **AMS Layland**, NCC-0112, *Layland*-class tender.
105
+
106
+ Named for C. L. Liu and James W. Layland's *Scheduling Algorithms for
107
+ Multiprogramming in a Hard-Real-Time Environment* (1973), which gave
108
+ earliest-deadline-first scheduling its proof and made a deadline a value a
109
+ system reasons about rather than an alarm it waits for.
110
+
111
+ ---
112
+
113
+ ## License
114
+
115
+ MIT.
@@ -0,0 +1,24 @@
1
+ [package]
2
+ name = "deadline-machines"
3
+ version = "0.2.0"
4
+ edition.workspace = true
5
+ rust-version.workspace = true
6
+ authors = ["Abdelkader Boudih <oss@seuros.com>"]
7
+ license = "MIT"
8
+ description = "Monotonic deadline arithmetic and cooperative cancellation trees - no_std, zero dependencies"
9
+ repository = "https://github.com/seuros/deadline_machines"
10
+ keywords = ["deadline", "timeout", "cancellation", "resilience", "no-std"]
11
+ categories = ["no-std", "concurrency"]
12
+ readme = "README.md"
13
+
14
+ [lib]
15
+ name = "deadline_machines"
16
+ crate-type = ["lib"]
17
+
18
+ [features]
19
+ default = ["std"]
20
+ std = ["alloc"]
21
+ alloc = []
22
+
23
+ [lints]
24
+ workspace = true
@@ -0,0 +1,331 @@
1
+ #![cfg_attr(not(feature = "std"), no_std)]
2
+
3
+ #[cfg(feature = "alloc")]
4
+ extern crate alloc;
5
+
6
+ /// Compiles and runs the README's examples as doctests.
7
+ #[cfg(doctest)]
8
+ #[doc = include_str!("../README.md")]
9
+ struct ReadmeDoctests;
10
+
11
+ pub const NS_PER_SECOND: u64 = 1_000_000_000;
12
+
13
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
14
+ pub struct Instant(pub u64);
15
+
16
+ pub trait Clock {
17
+ fn now_ns(&self) -> u64;
18
+
19
+ fn now(&self) -> Instant {
20
+ Instant(self.now_ns())
21
+ }
22
+ }
23
+
24
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25
+ pub struct Deadline {
26
+ expires_at: Option<Instant>,
27
+ }
28
+
29
+ impl Deadline {
30
+ #[must_use]
31
+ pub const fn never() -> Self {
32
+ Self { expires_at: None }
33
+ }
34
+
35
+ #[must_use]
36
+ pub const fn at(instant: Instant) -> Self {
37
+ Self {
38
+ expires_at: Some(instant),
39
+ }
40
+ }
41
+
42
+ #[must_use]
43
+ pub const fn after(now: Instant, timeout_ns: u64) -> Self {
44
+ Self::at(Instant(now.0.saturating_add(timeout_ns)))
45
+ }
46
+
47
+ #[must_use]
48
+ pub const fn expires_at(&self) -> Option<Instant> {
49
+ self.expires_at
50
+ }
51
+
52
+ #[must_use]
53
+ pub const fn remaining_ns(&self, now: Instant) -> Option<u64> {
54
+ match self.expires_at {
55
+ Some(expiry) => Some(expiry.0.saturating_sub(now.0)),
56
+ None => None,
57
+ }
58
+ }
59
+
60
+ #[must_use]
61
+ pub const fn is_expired(&self, now: Instant) -> bool {
62
+ matches!(self.remaining_ns(now), Some(0))
63
+ }
64
+
65
+ #[must_use]
66
+ pub const fn derive(&self, now: Instant, timeout_ns: u64) -> Self {
67
+ let candidate = Instant(now.0.saturating_add(timeout_ns));
68
+ match self.expires_at {
69
+ None => Self::at(candidate),
70
+ Some(parent) if candidate.0 < parent.0 => Self::at(candidate),
71
+ Some(parent) => Self::at(parent),
72
+ }
73
+ }
74
+
75
+ #[must_use]
76
+ pub const fn tighten(&self, other: Self) -> Self {
77
+ match (self.expires_at, other.expires_at) {
78
+ (None, any) | (any, None) => Self { expires_at: any },
79
+ (Some(a), Some(b)) if a.0 < b.0 => Self::at(a),
80
+ (Some(_), Some(b)) => Self::at(b),
81
+ }
82
+ }
83
+
84
+ #[must_use]
85
+ pub const fn clamp_sleep_ns(&self, now: Instant, requested_ns: u64) -> u64 {
86
+ match self.remaining_ns(now) {
87
+ Some(left) if left < requested_ns => left,
88
+ _ => requested_ns,
89
+ }
90
+ }
91
+ }
92
+
93
+ #[cfg(feature = "alloc")]
94
+ pub mod cancel {
95
+ use alloc::sync::Arc;
96
+ use core::sync::atomic::{AtomicBool, Ordering};
97
+
98
+ #[derive(Debug)]
99
+ struct Node {
100
+ cancelled: AtomicBool,
101
+ parent: Option<Arc<Self>>,
102
+ }
103
+
104
+ impl Node {
105
+ fn is_cancelled(&self) -> bool {
106
+ self.cancelled.load(Ordering::Acquire)
107
+ || self
108
+ .parent
109
+ .as_ref()
110
+ .is_some_and(|parent| parent.is_cancelled())
111
+ }
112
+ }
113
+
114
+ #[derive(Debug, Clone)]
115
+ pub struct CancelToken {
116
+ node: Arc<Node>,
117
+ }
118
+
119
+ impl CancelToken {
120
+ #[must_use]
121
+ pub fn root() -> Self {
122
+ Self {
123
+ node: Arc::new(Node {
124
+ cancelled: AtomicBool::new(false),
125
+ parent: None,
126
+ }),
127
+ }
128
+ }
129
+
130
+ #[must_use]
131
+ pub fn child(&self) -> Self {
132
+ Self {
133
+ node: Arc::new(Node {
134
+ cancelled: AtomicBool::new(false),
135
+ parent: Some(Arc::clone(&self.node)),
136
+ }),
137
+ }
138
+ }
139
+
140
+ pub fn cancel(&self) {
141
+ self.node.cancelled.store(true, Ordering::Release);
142
+ }
143
+
144
+ #[must_use]
145
+ pub fn is_cancelled(&self) -> bool {
146
+ self.node.is_cancelled()
147
+ }
148
+ }
149
+
150
+ impl Default for CancelToken {
151
+ fn default() -> Self {
152
+ Self::root()
153
+ }
154
+ }
155
+ }
156
+
157
+ /// Why a [`Scope`] stopped admitting work.
158
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
159
+ pub enum Stopped {
160
+ /// The budget ran out.
161
+ Exceeded,
162
+ /// Someone upstream gave up.
163
+ Cancelled,
164
+ }
165
+
166
+ impl core::fmt::Display for Stopped {
167
+ fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
168
+ f.write_str(match self {
169
+ Self::Exceeded => "deadline exceeded",
170
+ Self::Cancelled => "deadline cancelled",
171
+ })
172
+ }
173
+ }
174
+
175
+ impl core::error::Error for Stopped {}
176
+
177
+ #[cfg(feature = "alloc")]
178
+ mod scope {
179
+ use super::{Deadline, Instant, Stopped, cancel::CancelToken};
180
+
181
+ /// A budget and a cancellation handle as one value, which is what a retry
182
+ /// policy or a circuit breaker actually needs to consult.
183
+ ///
184
+ /// ```
185
+ /// use deadline_machines::{Deadline, Instant, Scope, Stopped};
186
+ ///
187
+ /// let now = Instant(0);
188
+ /// let request = Scope::new(Deadline::after(now, 5_000));
189
+ ///
190
+ /// let attempt = request.derive(now, 9_000);
191
+ /// assert_eq!(attempt.deadline(), request.deadline());
192
+ ///
193
+ /// request.cancel();
194
+ /// assert_eq!(attempt.check(now), Err(Stopped::Cancelled));
195
+ /// ```
196
+ #[derive(Debug, Clone)]
197
+ pub struct Scope {
198
+ deadline: Deadline,
199
+ token: CancelToken,
200
+ }
201
+
202
+ impl Scope {
203
+ #[must_use]
204
+ pub fn new(deadline: Deadline) -> Self {
205
+ Self {
206
+ deadline,
207
+ token: CancelToken::root(),
208
+ }
209
+ }
210
+
211
+ /// A scope with no time limit, cancellable only.
212
+ #[must_use]
213
+ pub fn never() -> Self {
214
+ Self::new(Deadline::never())
215
+ }
216
+
217
+ #[must_use]
218
+ pub const fn deadline(&self) -> Deadline {
219
+ self.deadline
220
+ }
221
+
222
+ #[must_use]
223
+ pub const fn remaining_ns(&self, now: Instant) -> Option<u64> {
224
+ self.deadline.remaining_ns(now)
225
+ }
226
+
227
+ #[must_use]
228
+ pub fn is_cancelled(&self) -> bool {
229
+ self.token.is_cancelled()
230
+ }
231
+
232
+ #[must_use]
233
+ pub const fn is_expired(&self, now: Instant) -> bool {
234
+ self.deadline.is_expired(now)
235
+ }
236
+
237
+ #[must_use]
238
+ pub fn is_done(&self, now: Instant) -> bool {
239
+ self.is_cancelled() || self.is_expired(now)
240
+ }
241
+
242
+ /// Cancellation reaches every scope derived from this one, now or later.
243
+ pub fn cancel(&self) {
244
+ self.token.cancel();
245
+ }
246
+
247
+ /// The cooperative checkpoint.
248
+ ///
249
+ /// # Errors
250
+ ///
251
+ /// [`Stopped::Cancelled`] when this scope or an ancestor was cancelled,
252
+ /// which outranks [`Stopped::Exceeded`] for a spent budget. Matches the
253
+ /// precedence on the Ruby side.
254
+ pub fn check(&self, now: Instant) -> Result<(), Stopped> {
255
+ if self.is_cancelled() {
256
+ return Err(Stopped::Cancelled);
257
+ }
258
+ if self.is_expired(now) {
259
+ return Err(Stopped::Exceeded);
260
+ }
261
+ Ok(())
262
+ }
263
+
264
+ /// A child scope whose deadline is the tighter of this one and
265
+ /// `timeout_ns` from `now`.
266
+ #[must_use]
267
+ pub fn derive(&self, now: Instant, timeout_ns: u64) -> Self {
268
+ Self {
269
+ deadline: self.deadline.derive(now, timeout_ns),
270
+ token: self.token.child(),
271
+ }
272
+ }
273
+
274
+ /// A child scope with the same deadline, cancellable on its own.
275
+ #[must_use]
276
+ pub fn child(&self) -> Self {
277
+ Self {
278
+ deadline: self.deadline,
279
+ token: self.token.child(),
280
+ }
281
+ }
282
+
283
+ /// The longest sleep that still fits the budget. A retry policy calls
284
+ /// this before each backoff so it cannot outlive its caller.
285
+ #[must_use]
286
+ pub const fn clamp_sleep_ns(&self, now: Instant, requested_ns: u64) -> u64 {
287
+ self.deadline.clamp_sleep_ns(now, requested_ns)
288
+ }
289
+ }
290
+ }
291
+
292
+ #[cfg(feature = "alloc")]
293
+ pub use scope::Scope;
294
+
295
+ #[cfg(feature = "std")]
296
+ mod system_clock {
297
+ use super::Clock;
298
+ use std::time::Instant as StdInstant;
299
+
300
+ #[derive(Debug, Clone, Copy)]
301
+ pub struct SystemClock {
302
+ origin: StdInstant,
303
+ }
304
+
305
+ impl SystemClock {
306
+ #[must_use]
307
+ pub fn new() -> Self {
308
+ Self {
309
+ origin: StdInstant::now(),
310
+ }
311
+ }
312
+ }
313
+
314
+ impl Default for SystemClock {
315
+ fn default() -> Self {
316
+ Self::new()
317
+ }
318
+ }
319
+
320
+ impl Clock for SystemClock {
321
+ fn now_ns(&self) -> u64 {
322
+ u64::try_from(self.origin.elapsed().as_nanos()).unwrap_or(u64::MAX)
323
+ }
324
+ }
325
+ }
326
+
327
+ #[cfg(feature = "std")]
328
+ pub use system_clock::SystemClock;
329
+
330
+ #[cfg(test)]
331
+ mod tests;
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeadlineMachines
4
+ module MonotonicClock
5
+ def self.now_ns
6
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
7
+ end
8
+ end
9
+
10
+ class TestClock
11
+ attr_accessor :now_ns
12
+
13
+ def initialize(now_ns: 0)
14
+ @now_ns = now_ns
15
+ end
16
+
17
+ def advance(seconds)
18
+ @now_ns += (seconds.to_r * 1_000_000_000).round
19
+ self
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeadlineMachines
4
+ class Deadline
5
+ NS_PER_SECOND = 1_000_000_000
6
+
7
+ attr_reader :expires_at_ns, :parent, :clock, :reason
8
+
9
+ class << self
10
+ def after(seconds, clock: DeadlineMachines.clock)
11
+ new(expires_at_ns: clock.now_ns + to_ns(seconds), clock: clock)
12
+ end
13
+
14
+ def never(clock: DeadlineMachines.clock)
15
+ new(expires_at_ns: nil, clock: clock)
16
+ end
17
+
18
+ def to_ns(seconds)
19
+ (seconds.to_r * NS_PER_SECOND).round
20
+ end
21
+ end
22
+
23
+ def initialize(expires_at_ns:, clock:, parent: nil)
24
+ @expires_at_ns = expires_at_ns
25
+ @clock = clock
26
+ @parent = parent
27
+ @cancelled = false
28
+ @detached = false
29
+ @reason = nil
30
+ @children = ObjectSpace::WeakMap.new
31
+ @retained = {}
32
+ @callbacks = []
33
+ @mutex = parent ? parent.mutex : Mutex.new
34
+ end
35
+
36
+ def remaining_ns
37
+ return nil unless @expires_at_ns
38
+
39
+ left = @expires_at_ns - @clock.now_ns
40
+ left.negative? ? 0 : left
41
+ end
42
+
43
+ def remaining
44
+ ns = remaining_ns
45
+ ns ? ns / NS_PER_SECOND.to_f : Float::INFINITY
46
+ end
47
+
48
+ def expired?
49
+ remaining_ns == 0
50
+ end
51
+
52
+ def cancelled?
53
+ @cancelled || !!@parent&.cancelled?
54
+ end
55
+
56
+ def done?
57
+ cancelled? || expired?
58
+ end
59
+
60
+ def check!
61
+ raise Cancelled.new(self, cancel_reason) if cancelled?
62
+ raise Exceeded, self if expired?
63
+
64
+ self
65
+ end
66
+
67
+ def clamp_sleep(seconds)
68
+ left = remaining
69
+ seconds < left ? seconds : left
70
+ end
71
+
72
+ def cancel(reason = nil)
73
+ callbacks, children = @mutex.synchronize do
74
+ return self if @cancelled
75
+
76
+ @cancelled = true
77
+ @reason = reason
78
+ @parent&.drop(self)
79
+ [@callbacks.dup, @children.keys]
80
+ end
81
+
82
+ failures = []
83
+ children.each { |child| notify(failures) { child.cancel(reason) } }
84
+ callbacks.each { |callback| notify(failures) { callback.call(self) } }
85
+ raise failures.first unless failures.empty?
86
+
87
+ self
88
+ end
89
+
90
+ def on_cancel(&block)
91
+ already = @mutex.synchronize do
92
+ settled = cancelled?
93
+ unless settled
94
+ @callbacks << block
95
+ @parent&.hold(self) unless @detached
96
+ end
97
+ settled
98
+ end
99
+
100
+ block.call(self) if already
101
+ self
102
+ end
103
+
104
+ def derive(seconds = nil)
105
+ child = self.class.new(expires_at_ns: child_expiry_ns(seconds), clock: @clock, parent: self)
106
+ @mutex.synchronize { @children[child] = true }
107
+ return child unless block_given?
108
+
109
+ begin
110
+ yield child
111
+ ensure
112
+ @mutex.synchronize { detach(child) }
113
+ end
114
+ end
115
+
116
+ def inspect
117
+ state = cancelled? ? 'cancelled' : (expired? ? 'expired' : format('%.3fs left', remaining))
118
+ "#<#{self.class} #{state}>"
119
+ end
120
+
121
+ protected
122
+
123
+ attr_reader :mutex
124
+ attr_writer :detached
125
+
126
+ # A derived deadline is the caller's to keep, so the parent follows it
127
+ # weakly. Something to deliver is the exception, and it has to be held all
128
+ # the way to the root: an intermediate deadline nobody else references is
129
+ # the one link whose collection would strand the delivery below it.
130
+ #
131
+ # Holding and dropping walk the chain, so they cannot be made atomic out of
132
+ # one lock per link: every deadline in a tree shares the root's lock, and
133
+ # both run inside a section the caller already holds.
134
+ def hold(child)
135
+ @retained[child] = true
136
+ @parent.hold(self) if @parent && !@detached
137
+ end
138
+
139
+ def drop(child)
140
+ @retained.delete(child)
141
+ @parent.drop(self) if @parent && !pending?
142
+ end
143
+
144
+ # A scope that has ended owns its child outright: the tree will not reach
145
+ # it again, so the parent holds nothing for it and an observer registered
146
+ # afterwards is the caller's own to keep alive.
147
+ def detach(child)
148
+ @children.delete(child)
149
+ child.detached = true
150
+ drop(child)
151
+ end
152
+
153
+ # Whether anything is still owed through this deadline.
154
+ def pending?
155
+ !@cancelled && !(@callbacks.empty? && @retained.empty?)
156
+ end
157
+
158
+ private
159
+
160
+ # Every observer and descendant is told, then the first failure is raised.
161
+ # One raising observer must not silence its peers.
162
+ def notify(failures)
163
+ yield
164
+ rescue StandardError => e
165
+ failures << e
166
+ end
167
+
168
+ protected
169
+
170
+ def cancel_reason
171
+ @reason || @parent&.cancel_reason
172
+ end
173
+
174
+ private
175
+
176
+ def child_expiry_ns(seconds)
177
+ return @expires_at_ns unless seconds
178
+
179
+ candidate = @clock.now_ns + self.class.to_ns(seconds)
180
+ @expires_at_ns ? [candidate, @expires_at_ns].min : candidate
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeadlineMachines
4
+ class Error < StandardError; end
5
+
6
+ class Exceeded < Error
7
+ attr_reader :deadline
8
+
9
+ def initialize(deadline)
10
+ @deadline = deadline
11
+ super('deadline exceeded')
12
+ end
13
+ end
14
+
15
+ class Cancelled < Error
16
+ attr_reader :deadline, :reason
17
+
18
+ def initialize(deadline, reason = nil)
19
+ @deadline = deadline
20
+ @reason = reason
21
+ super(reason ? "deadline cancelled: #{reason}" : 'deadline cancelled')
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeadlineMachines
4
+ VERSION = '0.1.1'
5
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'deadline_machines/version'
4
+ require_relative 'deadline_machines/errors'
5
+ require_relative 'deadline_machines/clock'
6
+ require_relative 'deadline_machines/deadline'
7
+
8
+ module DeadlineMachines
9
+ FIBER_KEY = :deadline_machines_deadline
10
+
11
+ class << self
12
+ attr_writer :clock
13
+
14
+ def clock
15
+ @clock ||= MonotonicClock
16
+ end
17
+
18
+ def current
19
+ Fiber[FIBER_KEY]
20
+ end
21
+
22
+ def with_deadline(seconds = nil, &block)
23
+ ambient = current
24
+ return ambient.derive(seconds) { |child| bind(child, &block) } if ambient
25
+ return bind(Deadline.after(seconds, clock: clock), &block) if seconds
26
+
27
+ bind(Deadline.never(clock: clock), &block)
28
+ end
29
+
30
+ def bind(deadline)
31
+ previous = Fiber[FIBER_KEY]
32
+ Fiber[FIBER_KEY] = deadline
33
+ begin
34
+ yield deadline
35
+ ensure
36
+ Fiber[FIBER_KEY] = previous
37
+ end
38
+ end
39
+
40
+ def check!
41
+ current&.check!
42
+ end
43
+
44
+ def remaining
45
+ current&.remaining || Float::INFINITY
46
+ end
47
+
48
+ def clamp_sleep(seconds)
49
+ current ? current.clamp_sleep(seconds) : seconds
50
+ end
51
+
52
+ def deadline?(object)
53
+ object.respond_to?(:remaining) && object.respond_to?(:done?) && object.respond_to?(:check!)
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,78 @@
1
+ module DeadlineMachines
2
+ VERSION: String
3
+ FIBER_KEY: Symbol
4
+
5
+ # Any monotonic nanosecond time source.
6
+ interface _Clock
7
+ def now_ns: () -> Integer
8
+ end
9
+
10
+ class Error < StandardError
11
+ end
12
+
13
+ class Exceeded < Error
14
+ attr_reader deadline: Deadline
15
+ def initialize: (Deadline) -> void
16
+ end
17
+
18
+ class Cancelled < Error
19
+ attr_reader deadline: Deadline
20
+ attr_reader reason: untyped
21
+ def initialize: (Deadline, ?untyped) -> void
22
+ end
23
+
24
+ module MonotonicClock
25
+ def self.now_ns: () -> Integer
26
+ end
27
+
28
+ class TestClock
29
+ attr_accessor now_ns: Integer
30
+ def initialize: (?now_ns: Integer) -> void
31
+ def advance: (Numeric) -> self
32
+ end
33
+
34
+ class Deadline
35
+ NS_PER_SECOND: Integer
36
+
37
+ attr_reader expires_at_ns: Integer?
38
+ attr_reader parent: Deadline?
39
+ attr_reader clock: _Clock
40
+ attr_reader reason: untyped
41
+
42
+ def self.after: (Numeric, ?clock: _Clock) -> Deadline
43
+ def self.never: (?clock: _Clock) -> Deadline
44
+ def self.to_ns: (Numeric) -> Integer
45
+
46
+ def initialize: (expires_at_ns: Integer?, clock: _Clock, ?parent: Deadline?) -> void
47
+
48
+ # nil when there is no time limit.
49
+ def remaining_ns: () -> Integer?
50
+ def remaining: () -> Float
51
+ def expired?: () -> bool
52
+ def cancelled?: () -> bool
53
+ def done?: () -> bool
54
+ def check!: () -> self
55
+ def clamp_sleep: (Numeric) -> Numeric
56
+ def cancel: (?untyped) -> self
57
+ def on_cancel: () { (Deadline) -> void } -> self
58
+
59
+ # Without a block the child is returned; with one it is unlinked on exit
60
+ # and the block's value is returned.
61
+ def derive: (?Numeric?) -> Deadline
62
+ | [T] (?Numeric?) { (Deadline) -> T } -> T
63
+
64
+ def inspect: () -> String
65
+
66
+ private def child_expiry_ns: (Numeric?) -> Integer?
67
+ end
68
+
69
+ def self.clock: () -> _Clock
70
+ def self.clock=: (_Clock?) -> _Clock?
71
+ def self.current: () -> Deadline?
72
+ def self.with_deadline: [T] (?Numeric?) { (Deadline) -> T } -> T
73
+ def self.bind: [T] (Deadline) { (Deadline) -> T } -> T
74
+ def self.check!: () -> Deadline?
75
+ def self.remaining: () -> Float
76
+ def self.clamp_sleep: (Numeric) -> Numeric
77
+ def self.deadline?: (untyped) -> bool
78
+ end
metadata ADDED
@@ -0,0 +1,59 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: deadline_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
+ description: 'A monotonic deadline token that derives child budgets, cancels down
13
+ its tree, and raises only at checkpoints you choose. The shared time contract for
14
+ the machines family: retries, circuit breakers and throttles read one budget instead
15
+ of each keeping its own clock.'
16
+ email:
17
+ - oss@seuros.com
18
+ executables: []
19
+ extensions: []
20
+ extra_rdoc_files: []
21
+ files:
22
+ - CHANGELOG.md
23
+ - Cargo.toml
24
+ - LICENSE.txt
25
+ - README.md
26
+ - ext/deadline_machines_native/core/Cargo.toml
27
+ - ext/deadline_machines_native/core/src/lib.rs
28
+ - lib/deadline_machines.rb
29
+ - lib/deadline_machines/clock.rb
30
+ - lib/deadline_machines/deadline.rb
31
+ - lib/deadline_machines/errors.rb
32
+ - lib/deadline_machines/version.rb
33
+ - sig/deadline_machines.rbs
34
+ homepage: https://github.com/seuros/deadline_machines
35
+ licenses:
36
+ - MIT
37
+ metadata:
38
+ source_code_uri: https://github.com/seuros/deadline_machines
39
+ changelog_uri: https://github.com/seuros/deadline_machines/blob/main/CHANGELOG.md
40
+ bug_tracker_uri: https://github.com/seuros/deadline_machines/issues
41
+ rubygems_mfa_required: 'true'
42
+ rdoc_options: []
43
+ require_paths:
44
+ - lib
45
+ required_ruby_version: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: 4.0.0
50
+ required_rubygems_version: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ requirements: []
56
+ rubygems_version: 4.0.20
57
+ specification_version: 4
58
+ summary: Deadline propagation and cooperative cancellation for Ruby
59
+ test_files: []