election_machines 0.1.5

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: 280d87432c4bb945291b1a804fb690e94886adef86720e4cb7bc78a4b8967808
4
+ data.tar.gz: 413925e9d82a374a269bc9f5718d354a507a0775bf65f96ce8d0662961bd2eb9
5
+ SHA512:
6
+ metadata.gz: e8fff2087448de2e665d69c0dc28409c658764b26f9c3f23e134635b61c37e26a5047607bd90d4218c65585a00ecdad714ec03a38b443f06fc8099232d840db8
7
+ data.tar.gz: 69a3fcdbf2d8870101053a4aad8a4fbed843251b53aa0e7a41b1c08862b695c2b6f535d5309d5dbd74e2660d2bd66ada395f4004ae6252c3ac6ef978878b1b1a
data/CHANGELOG.md ADDED
@@ -0,0 +1,36 @@
1
+ # Changelog
2
+
3
+ ## [0.1.5](https://github.com/seuros/election_machines/compare/election_machines/v0.1.4...election_machines/v0.1.5) (2026-11-06)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * extract Wrapper::advance for election revision handling ([09bdd65](https://github.com/seuros/election_machines/commit/09bdd65226a7780bfe980bf19e5145ee2c2f1da0))
9
+
10
+ ## [0.1.4](https://github.com/seuros/election_machines/compare/election_machines/v0.1.3...election_machines/v0.1.4) (2026-11-06)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * require lease-machines 0.3 ([d99d292](https://github.com/seuros/election_machines/commit/d99d2928ba0dd42e3013eb2d13e9a527387703ee))
16
+
17
+ ## [0.1.3](https://github.com/seuros/election_machines/compare/election_machines/v0.1.2...election_machines/v0.1.3) (2026-11-06)
18
+
19
+
20
+ ### Bug Fixes
21
+
22
+ * crates.io keywords and categories for the core ([8c6d66c](https://github.com/seuros/election_machines/commit/8c6d66cc9ff19907ad68dc4043c9e421a66037ad))
23
+
24
+ ## [0.1.2](https://github.com/seuros/election_machines/compare/election_machines/v0.1.1...election_machines/v0.1.2) (2026-11-06)
25
+
26
+
27
+ ### Bug Fixes
28
+
29
+ * keep test sources out of the packaged gem and crate ([27f00f1](https://github.com/seuros/election_machines/commit/27f00f1331d641a7d324ee7e560324a99f958bfa))
30
+
31
+ ## [0.1.1](https://github.com/seuros/election_machines/compare/election_machines-v0.1.0...election_machines/v0.1.1) (2026-11-06)
32
+
33
+
34
+ ### Features
35
+
36
+ * initial extraction ([a804dd3](https://github.com/seuros/election_machines/commit/a804dd3a3c37b0f96acec918e7242475a63cd0c4))
data/Cargo.toml ADDED
@@ -0,0 +1,23 @@
1
+ [workspace]
2
+ members = ["ext/election_machines_native/core", "ext/election_machines_native/ffi"]
3
+ default-members = ["ext/election_machines_native/core"]
4
+ resolver = "3"
5
+ [workspace.package]
6
+ edition = "2024"
7
+ rust-version = "1.99"
8
+ license = "MIT"
9
+ [workspace.dependencies]
10
+ election-machines = { path = "ext/election_machines_native/core" }
11
+ magnus = { version = "0.9.3", default-features = false }
12
+ rb-sys = { version = "0.9.130", default-features = false, features = ["stable-api-compiled-fallback"] }
13
+ lease-machines = ">=0.3"
14
+ [workspace.lints.rust]
15
+ unsafe_code = "deny"
16
+ unused_must_use = "warn"
17
+ missing_docs = "warn"
18
+ [workspace.lints.clippy]
19
+ all = { level = "warn", priority = -1 }
20
+ pedantic = { level = "warn", priority = -1 }
21
+ nursery = { level = "warn", priority = -1 }
22
+ unwrap_used = "deny"
23
+ expect_used = "deny"
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 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 copies
9
+ of the Software, and to permit persons to whom the Software is furnished to do
10
+ so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ 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 THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,212 @@
1
+ # ElectionMachines
2
+
3
+ > One captain at a time, and silence from the bridge is not a vacancy. 📡
4
+
5
+ Leader election for Ruby, backed by a lease. You hand in the list of workers that showed up, it names one of them captain and gives them a lease with a token. The captain keeps the chair by heartbeating before the lease runs out. Everyone else is told who the incumbent is. Nobody gets the chair by shouting louder, and a captain who stops answering loses it only when the clock says so.
6
+
7
+ ## Quick Start
8
+
9
+ ```bash
10
+ gem 'election_machines'
11
+ ```
12
+
13
+ ```ruby
14
+ election = ElectionMachines::Election.new
15
+
16
+ # Three workers report in. The lowest id takes the chair.
17
+ status, leader, token, expires_at = election.elect([9, 2, 5], clock.now, 30.0)
18
+ # => ["elected", 2, 1, 30.0]
19
+
20
+ # The leader keeps the chair by heartbeating with its token.
21
+ election.heartbeat(2, token, clock.now, 30.0)
22
+ # => ["renewed", 2, 1, ...]
23
+
24
+ # Anyone else asking while the lease is live just learns who's in charge.
25
+ election.elect([5], clock.now, 30.0)
26
+ # => ["incumbent", 2, 1, ...]
27
+ ```
28
+
29
+ Every call returns the same four-element shape: a status string, then the leader, token, and expiry, or three nils when there's nothing to report.
30
+
31
+ ## A Message to the Resistance
32
+
33
+ So you have three schedulers and only one of them should be ticking the cron table. You picked "lowest hostname wins" and it worked for a year. Then the lowest hostname hit a network partition for ninety seconds. The other two couldn't reach it, decided it was dead, and the second-lowest took over. The first one never knew. For ninety seconds you had two captains, and your billing job ran twice.
34
+
35
+ "It stopped answering" and "it's gone" are different facts. The first is what you observed. The second is what you assumed. The gap between them is where the double-charge lives.
36
+
37
+ An election built on a lease closes that gap. The leader holds the chair until a stated time, not until someone stops hearing from it. If it's quiet but the lease is live, the answer to "can I take over?" is still no. When the lease lapses, the next election hands out a new token, and the old captain's token stops working everywhere it's checked.
38
+
39
+ ## Chapter 1: Stardate 2025.186, the AMS LeLann
40
+
41
+ > **AMS LeLann**, NCC-4206, *LeLann*-class command relay.
42
+ > An unanswered hail is not permission to appoint a second captain.
43
+
44
+ The relay doesn't fly the ship. It keeps the roster and the chair. When a crew reports for duty, the relay names the lowest-numbered one captain and stamps a lease. Captains renew by hailing with their token. A crew that hails about an empty chair gets it. A crew that hails about an occupied one is told who sits there, and nothing else.
45
+
46
+ Somewhere on a lower deck, a lieutenant proposes:
47
+
48
+ ```ruby
49
+ def am_i_leader?
50
+ peers.none? { |peer| peer.alive? && peer.id < my_id }
51
+ end
52
+ ```
53
+
54
+ "That," says the relay officer, "is how a ship ends up with two captains during a comms blackout."
55
+
56
+ ## The Flight Plan
57
+
58
+ ```mermaid
59
+ stateDiagram-v2
60
+ [*] --> vacant
61
+ vacant --> held: elect, lowest candidate wins, token n
62
+ held --> held: heartbeat by leader with token, renewed
63
+ held --> held: elect by anyone before expiry, incumbent
64
+ held --> vacant: resign by leader with token, released
65
+ held --> held: lease lapses, next elect names a new leader with token n+1
66
+ ```
67
+
68
+ And the ninety seconds the cron leader went quiet:
69
+
70
+ ```mermaid
71
+ sequenceDiagram
72
+ participant A as Scheduler A (id 1)
73
+ participant E as Election
74
+ participant B as Scheduler B (id 2)
75
+
76
+ A->>E: elect([1, 2], t=0, ttl=30)
77
+ E-->>A: elected, leader 1, token 1, expires 30
78
+ Note over A: ticks the cron table, heartbeats every 10s
79
+ A->>E: heartbeat(1, token 1, t=10, ttl=30)
80
+ E-->>A: renewed, expires 40
81
+
82
+ Note over A: partitioned at t=12, silent
83
+
84
+ B->>E: elect([2], t=20, ttl=30)
85
+ E-->>B: incumbent, leader 1, token 1, expires 40
86
+ Note over B: lease still live. B waits.
87
+
88
+ B->>E: elect([2], t=45, ttl=30)
89
+ E-->>B: elected, leader 2, token 2, expires 75
90
+ Note over B: now ticks the cron table
91
+
92
+ Note over A: partition heals at t=50
93
+ A->>E: heartbeat(1, token 1, t=50, ttl=30)
94
+ E-->>A: WrongOwner
95
+ Note over A: stands down. One captain the whole time.
96
+ ```
97
+
98
+ ## How the Relay Works
99
+
100
+ ### Elect
101
+
102
+ Pass the ids of the workers that are present, the current time, and how long the lease should last. The lowest id wins. The token starts at 1 and only ever goes up.
103
+
104
+ ```ruby
105
+ election = ElectionMachines::Election.new
106
+
107
+ election.elect([9, 2, 5], 0, 10) # => ["elected", 2, 1, 10.0]
108
+ election.lease # => [2, 1, 10.0]
109
+ ```
110
+
111
+ Ask again while that lease is live and you get the incumbent, even if you offer a lower id. Sitting captains aren't unseated by a late arrival.
112
+
113
+ ```ruby
114
+ election.elect([1], 1, 10) # => ["incumbent", 2, 1, 10.0]
115
+ ```
116
+
117
+ An empty roster is `no_candidates`.
118
+
119
+ ### Heartbeat
120
+
121
+ The leader renews by presenting its id and token. The new expiry is counted from `now`.
122
+
123
+ ```ruby
124
+ election = ElectionMachines::Election.new
125
+ election.elect([2], 0, 10)
126
+
127
+ election.heartbeat(2, 1, 3, 10) # => ["renewed", 2, 1, 13.0]
128
+ election.heartbeat(1, 1, 4, 10) # => ["WrongOwner", nil, nil, nil]
129
+ ```
130
+
131
+ A heartbeat from anyone but the leader is refused and changes nothing.
132
+
133
+ ### Resign
134
+
135
+ A leader steps down by presenting its token. The chair is vacant immediately, and the next election fills it.
136
+
137
+ ```ruby
138
+ election.resign(2, 1, 5) # => ["released", nil, nil, nil]
139
+ election.lease # => nil
140
+ election.elect([7, 3], 6, 10) # => ["elected", 3, 2, 16.0]
141
+ ```
142
+
143
+ Token 2. The old token is dead from here on.
144
+
145
+ ### When the leader goes quiet
146
+
147
+ Nothing happens at the moment of expiry. The chair is only re-filled when someone asks. Until then, `lease` still describes the lapsed lease, so you can see who was last in charge.
148
+
149
+ ```ruby
150
+ election = ElectionMachines::Election.new
151
+ election.elect([2], 0, 10)
152
+
153
+ election.heartbeat(2, 1, 11, 10) # => ["Expired", nil, nil, nil]
154
+ election.lease # => [2, 1, 10.0]
155
+
156
+ election.elect([5], 12, 10) # => ["elected", 5, 2, 22.0]
157
+ election.heartbeat(2, 1, 13, 10) # => ["WrongOwner", nil, nil, nil]
158
+ ```
159
+
160
+ The old leader's late heartbeat is rejected with `WrongOwner`. Its token is also stale, and `StaleToken` is what you'd see if it had the right id and the wrong token.
161
+
162
+ ### Time only moves forward
163
+
164
+ You supply `now`. The election remembers the latest it has seen and refuses to go backwards.
165
+
166
+ ```ruby
167
+ election = ElectionMachines::Election.new
168
+ election.elect([2], 5, 10)
169
+ election.elect([2], 0, 10) # => ["ClockRegressed", nil, nil, nil]
170
+ ```
171
+
172
+ Feed it a monotonic clock. Not the wall clock.
173
+
174
+ ## Checkpoints
175
+
176
+ An election can hand you a `Checkpoint` and roll back to it, but only itself, and only to the most recent one.
177
+
178
+ ```ruby
179
+ election = ElectionMachines::Election.new
180
+ election.elect([1], 0, 10)
181
+ saved = election.checkpoint
182
+
183
+ election.heartbeat(1, 1, 1, 10) # => ["renewed", 1, 1, 11.0]
184
+ election.restore!(saved) # => ArgumentError, a transition happened since
185
+ ```
186
+
187
+ Any successful transition, or any successful restore, invalidates every checkpoint taken before it. That rules out rewinding past a decision someone has already acted on. A checkpoint from one election can't be fed to another. This is for testing and in-process simulation, not for surviving a restart.
188
+
189
+ ## The Rules of the Relay
190
+
191
+ - **Ids and tokens are Integers** in `0..2**64-1`. Up to 64 candidates per election. Floats and strings raise `TypeError`, out of range raises `RangeError`, a 65th candidate raises `ArgumentError`.
192
+ - **Times are seconds** as Integer or Float, from a monotonic origin you choose. `ttl` must be positive. Negative, infinite, NaN, or absurdly large values raise `ArgumentError`.
193
+ - **Rejections don't raise.** `WrongOwner`, `StaleToken`, `Expired`, `ClockRegressed`, and `no_candidates` all come back as a status with three nils. Only malformed input raises.
194
+ - **Bad input never half-applies.** Arguments are checked before anything changes.
195
+ - **Frozen means frozen.** `freeze` the election and `elect`, `heartbeat`, `resign`, and `restore!` raise `FrozenError`. `lease` still reads.
196
+
197
+ ## What You Think You're Doing vs Reality
198
+
199
+ ### You Think: "If I can't reach the leader, the leader is down."
200
+ ### Reality: You can't reach the leader. That's all you know.
201
+
202
+ A partition looks identical to a crash from the outside. If your election promotes on silence, every network hiccup is a coup. If it promotes on lease expiry, the worst case is a leaderless gap of one ttl, which is boring, and boring is what you want from the thing that decides who runs billing.
203
+
204
+ ## What's Yours
205
+
206
+ The election decides. It doesn't talk to the network, run timers, or store anything across a restart. Run it in one place, write its decisions down before anyone acts on them, and make the thing the leader controls check the token before every action. A token the resource has already seen beaten is a captain who has already been replaced.
207
+
208
+ ## License
209
+
210
+ MIT License. See [LICENSE.txt](LICENSE.txt) for details.
211
+
212
+ **Remember: Scheduler A was never dead. It just couldn't hear you say it was.**
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ ## [0.2.2](https://github.com/seuros/election_machines/compare/election-machines-v0.2.1...election-machines-v0.2.2) (2026-11-06)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * crates.io keywords and categories for the core ([8c6d66c](https://github.com/seuros/election_machines/commit/8c6d66cc9ff19907ad68dc4043c9e421a66037ad))
9
+
10
+ ## [0.2.1](https://github.com/seuros/election_machines/compare/election-machines-v0.2.0...election-machines-v0.2.1) (2026-11-06)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * keep test sources out of the packaged gem and crate ([27f00f1](https://github.com/seuros/election_machines/commit/27f00f1331d641a7d324ee7e560324a99f958bfa))
16
+
17
+ ## [0.2.0](https://github.com/seuros/election_machines/compare/election-machines-v0.1.0...election-machines-v0.2.0) (2026-11-06)
18
+
19
+
20
+ ### Features
21
+
22
+ * initial extraction ([a804dd3](https://github.com/seuros/election_machines/commit/a804dd3a3c37b0f96acec918e7242475a63cd0c4))
@@ -0,0 +1,15 @@
1
+ [package]
2
+ name = "election-machines"
3
+ version = "0.2.2"
4
+ edition.workspace = true
5
+ rust-version.workspace = true
6
+ license.workspace = true
7
+ description = "Lease-backed deterministic owner nomination"
8
+ readme = "README.md"
9
+ exclude = ["src/**/tests.rs", "tests/"]
10
+ keywords = ["leader-election", "lease", "fencing", "no-std"]
11
+ categories = ["no-std", "algorithms"]
12
+ [dependencies]
13
+ lease-machines.workspace = true
14
+ [lints]
15
+ workspace = true
@@ -0,0 +1,17 @@
1
+ # election-machines
2
+
3
+ > **AMS LeLann**, NCC-4206, *LeLann*-class command relay.
4
+
5
+ Engine-room flight manual: model command handovers through one lease authority.
6
+
7
+ `no_std` lease-backed owner nomination under one authoritative history.
8
+ Among participating candidates, the smallest numeric ID is nominated. An
9
+ unexpired incumbent remains leader even if absent from the current nomination
10
+ list; this list is not a consensus membership configuration.
11
+
12
+ All lease-machine persistence, fencing, authentication, and clock requirements
13
+ apply. A fresh election instance is a fresh namespace, not failover recovery.
14
+ Empty candidate lists make no authority transition. Heartbeats renew a lease;
15
+ they are not an asynchronous failure detector. This policy is **not Raft,
16
+ quorum voting, or partition-safe leader election without an external serialized
17
+ durable authority**. Raft elections remain in `consensus-machines`.
@@ -0,0 +1,86 @@
1
+ #![no_std]
2
+ #![doc = include_str!("../README.md")]
3
+ use core::time::Duration;
4
+ use lease_machines::{
5
+ Checkpoint, Decision, FencingToken, Lease, LeaseAuthority, OwnerId, Rejection,
6
+ };
7
+
8
+ /// Outcome of nomination under one lease authority.
9
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
10
+ #[non_exhaustive]
11
+ pub enum Leadership {
12
+ /// A new leadership token was issued.
13
+ Elected(Lease),
14
+ /// The incumbent's unexpired lease remains authoritative.
15
+ Incumbent(Lease),
16
+ /// No candidates were supplied; no authority transition occurred.
17
+ NoCandidates,
18
+ /// The authority rejected the request.
19
+ Rejected(Rejection),
20
+ }
21
+
22
+ /// Deterministic nomination plus a single resource's lease authority.
23
+ #[derive(Debug, Default, PartialEq, Eq)]
24
+ pub struct Election {
25
+ authority: LeaseAuthority,
26
+ }
27
+
28
+ impl Election {
29
+ /// Restore the newest committed authority under the same clock origin.
30
+ #[must_use]
31
+ pub const fn restore(checkpoint: Checkpoint) -> Self {
32
+ Self {
33
+ authority: LeaseAuthority::restore(checkpoint),
34
+ }
35
+ }
36
+
37
+ /// Nominate the smallest participating identity, respecting a live incumbent.
38
+ #[must_use]
39
+ pub fn elect(&mut self, candidates: &[OwnerId], now: Duration, ttl: Duration) -> Leadership {
40
+ if now < self.authority.checkpoint().observed() {
41
+ return Leadership::Rejected(Rejection::ClockRegressed);
42
+ }
43
+ let Some(candidate) = candidates.iter().copied().min_by_key(|id| id.value()) else {
44
+ return Leadership::NoCandidates;
45
+ };
46
+ match self.authority.acquire(candidate, now, ttl) {
47
+ Decision::Granted(lease) => Leadership::Elected(lease),
48
+ Decision::Rejected(Rejection::Occupied) => {
49
+ // Occupied is returned only with a live incumbent.
50
+ self.authority.checkpoint().lease().map_or(
51
+ Leadership::Rejected(Rejection::NoLease),
52
+ Leadership::Incumbent,
53
+ )
54
+ }
55
+ Decision::Rejected(reason) => Leadership::Rejected(reason),
56
+ _ => Leadership::Rejected(Rejection::NoLease),
57
+ }
58
+ }
59
+
60
+ /// Renew leadership only for the matching owner and live fencing token.
61
+ #[must_use]
62
+ pub fn heartbeat(
63
+ &mut self,
64
+ owner: OwnerId,
65
+ token: FencingToken,
66
+ now: Duration,
67
+ ttl: Duration,
68
+ ) -> Decision {
69
+ self.authority.renew(owner, token, now, ttl)
70
+ }
71
+
72
+ /// Relinquish leadership while retaining its fencing high-water mark.
73
+ #[must_use]
74
+ pub fn resign(&mut self, owner: OwnerId, token: FencingToken, now: Duration) -> Decision {
75
+ self.authority.release(owner, token, now)
76
+ }
77
+
78
+ /// Capture state for the runtime's durable commit.
79
+ #[must_use]
80
+ pub const fn checkpoint(&self) -> Checkpoint {
81
+ self.authority.checkpoint()
82
+ }
83
+ }
84
+
85
+ #[cfg(test)]
86
+ mod tests;
@@ -0,0 +1,2 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'ffi/extconf'
@@ -0,0 +1,21 @@
1
+ [package]
2
+ name = "election_machines_native"
3
+ version = "0.1.3"
4
+ edition.workspace = true
5
+ rust-version.workspace = true
6
+ license.workspace = true
7
+ publish = false
8
+
9
+ [lib]
10
+ crate-type = ["cdylib"]
11
+ test = false
12
+ doctest = false
13
+
14
+ [dependencies]
15
+ election-machines.workspace = true
16
+ lease-machines.workspace = true
17
+ magnus.workspace = true
18
+ rb-sys.workspace = true
19
+
20
+ [lints]
21
+ workspace = true
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+ require 'mkmf'
3
+ require 'pathname'
4
+ require 'rb_sys/mkmf'
5
+ create_rust_makefile('election_machines_native/election_machines_native') do |extension|
6
+ extension.ext_dir = Pathname(__dir__).relative_path_from(Pathname(Dir.pwd)).to_s
7
+ extension.profile = ENV.fetch('RB_SYS_CARGO_PROFILE', :release).to_sym
8
+ end
9
+ makefile = File.join(Dir.pwd, 'Makefile')
10
+ File.write(makefile, File.read(makefile).gsub(
11
+ /^RB_SYS_CARGO_MANIFEST_DIR \?=.*$/,
12
+ "RB_SYS_CARGO_MANIFEST_DIR ?= #{File.expand_path(__dir__)}"
13
+ ))
@@ -0,0 +1,18 @@
1
+ //! Private initialization boundary.
2
+ use magnus::{Error, Ruby, function, method, prelude::*};
3
+ #[magnus::init]
4
+ fn init(ruby: &Ruby) -> Result<(), Error> {
5
+ let module = ruby.define_module("ElectionMachines")?;
6
+ module
7
+ .define_class("Checkpoint", ruby.class_object())?
8
+ .undef_default_alloc_func();
9
+ let class = module.define_class("Election", ruby.class_object())?;
10
+ class.undef_default_alloc_func();
11
+ class.define_singleton_method("new", function!(crate::Wrapper::new, 0))?;
12
+ class.define_method("elect", method!(crate::Wrapper::elect, 3))?;
13
+ class.define_method("heartbeat", method!(crate::Wrapper::heartbeat, 4))?;
14
+ class.define_method("resign", method!(crate::Wrapper::resign, 3))?;
15
+ class.define_method("checkpoint", method!(crate::Wrapper::checkpoint, 0))?;
16
+ class.define_method("restore!", method!(crate::Wrapper::restore, 1))?;
17
+ class.define_method("lease", method!(crate::Wrapper::lease, 0))
18
+ }
@@ -0,0 +1,154 @@
1
+ //! Thin Ruby translation for lease-backed nomination.
2
+ mod init;
3
+ mod values;
4
+ use election_machines::{Election, Leadership};
5
+ use lease_machines::{Checkpoint, Decision, FencingToken, Lease, OwnerId};
6
+ use magnus::{Error, RArray, Ruby, prelude::*, typed_data::Obj};
7
+ use std::{cell::RefCell, sync::Arc};
8
+ use values::{Id, Seconds};
9
+
10
+ type Outcome = (String, Option<u64>, Option<u64>, Option<f64>);
11
+
12
+ #[magnus::wrap(class = "ElectionMachines::Checkpoint", free_immediately, size)]
13
+ struct Saved {
14
+ owner: Arc<()>,
15
+ revision: u64,
16
+ checkpoint: Checkpoint,
17
+ }
18
+ #[magnus::wrap(class = "ElectionMachines::Election", free_immediately, size)]
19
+ struct Wrapper {
20
+ owner: Arc<()>,
21
+ state: RefCell<(Election, u64)>,
22
+ }
23
+
24
+ fn lease_outcome(status: &str, lease: Lease) -> Outcome {
25
+ (
26
+ status.into(),
27
+ Some(lease.owner().value()),
28
+ Some(lease.token().value()),
29
+ Some(lease.expires_at().as_secs_f64()),
30
+ )
31
+ }
32
+ fn decision(result: Decision) -> Outcome {
33
+ match result {
34
+ Decision::Granted(lease) => lease_outcome("granted", lease),
35
+ Decision::Renewed(lease) => lease_outcome("renewed", lease),
36
+ Decision::Released => ("released".into(), None, None, None),
37
+ Decision::Rejected(reason) => (format!("{reason:?}"), None, None, None),
38
+ _ => ("unknown".into(), None, None, None),
39
+ }
40
+ }
41
+ impl Wrapper {
42
+ fn new() -> Self {
43
+ Self {
44
+ owner: Arc::new(()),
45
+ state: RefCell::new((Election::default(), 0)),
46
+ }
47
+ }
48
+ fn next_revision(&self, ruby: &Ruby) -> Result<u64, Error> {
49
+ let revision = self.state.borrow().1.checked_add(1);
50
+ revision.ok_or_else(|| Error::new(ruby.exception_range_error(), "revision exhausted"))
51
+ }
52
+ /// Runs `step` against the election under the next revision, which stales
53
+ /// every checkpoint taken before it.
54
+ fn advance<T>(&self, ruby: &Ruby, step: impl FnOnce(&mut Election) -> T) -> Result<T, Error> {
55
+ let revision = self.next_revision(ruby)?;
56
+ let mut state = self.state.borrow_mut();
57
+ state.1 = revision;
58
+ Ok(step(&mut state.0))
59
+ }
60
+ fn elect(
61
+ ruby: &Ruby,
62
+ rb_self: Obj<Self>,
63
+ candidates: RArray,
64
+ now: Seconds,
65
+ ttl: Seconds,
66
+ ) -> Result<Outcome, Error> {
67
+ rb_self.check_frozen()?;
68
+ values::interval(ruby, now, ttl)?;
69
+ let count = candidates.len();
70
+ if count > 64 {
71
+ return Err(Error::new(
72
+ ruby.exception_arg_error(),
73
+ "at most 64 candidates",
74
+ ));
75
+ }
76
+ let mut owners = [OwnerId::new(0); 64];
77
+ for (index, owner) in owners[..count].iter_mut().enumerate() {
78
+ *owner = OwnerId::new(candidates.entry::<Id>(index.cast_signed())?.0);
79
+ }
80
+ let result = rb_self.advance(ruby, |election| {
81
+ election.elect(&owners[..count], now.0, ttl.0)
82
+ })?;
83
+ Ok(match result {
84
+ Leadership::Elected(lease) => lease_outcome("elected", lease),
85
+ Leadership::Incumbent(lease) => lease_outcome("incumbent", lease),
86
+ Leadership::NoCandidates => ("no_candidates".into(), None, None, None),
87
+ Leadership::Rejected(reason) => (format!("{reason:?}"), None, None, None),
88
+ _ => ("unknown".into(), None, None, None),
89
+ })
90
+ }
91
+ fn heartbeat(
92
+ ruby: &Ruby,
93
+ rb_self: Obj<Self>,
94
+ owner: Id,
95
+ token: Id,
96
+ now: Seconds,
97
+ ttl: Seconds,
98
+ ) -> Result<Outcome, Error> {
99
+ rb_self.check_frozen()?;
100
+ values::interval(ruby, now, ttl)?;
101
+ let token = values::token(ruby, token)?;
102
+ let result = rb_self.advance(ruby, |election| {
103
+ election.heartbeat(OwnerId::new(owner.0), token, now.0, ttl.0)
104
+ })?;
105
+ Ok(decision(result))
106
+ }
107
+ fn resign(
108
+ ruby: &Ruby,
109
+ rb_self: Obj<Self>,
110
+ owner: Id,
111
+ token: Id,
112
+ now: Seconds,
113
+ ) -> Result<Outcome, Error> {
114
+ rb_self.check_frozen()?;
115
+ let token: FencingToken = values::token(ruby, token)?;
116
+ let result = rb_self.advance(ruby, |election| {
117
+ election.resign(OwnerId::new(owner.0), token, now.0)
118
+ })?;
119
+ Ok(decision(result))
120
+ }
121
+ fn checkpoint(&self) -> Saved {
122
+ let (checkpoint, revision) = {
123
+ let state = self.state.borrow();
124
+ (state.0.checkpoint(), state.1)
125
+ };
126
+ Saved {
127
+ owner: Arc::clone(&self.owner),
128
+ revision,
129
+ checkpoint,
130
+ }
131
+ }
132
+ fn restore(ruby: &Ruby, rb_self: Obj<Self>, saved: &Saved) -> Result<bool, Error> {
133
+ rb_self.check_frozen()?;
134
+ if !Arc::ptr_eq(&rb_self.owner, &saved.owner) || rb_self.state.borrow().1 != saved.revision
135
+ {
136
+ return Err(Error::new(
137
+ ruby.exception_arg_error(),
138
+ "cross-instance or stale checkpoint",
139
+ ));
140
+ }
141
+ let revision = rb_self.next_revision(ruby)?;
142
+ *rb_self.state.borrow_mut() = (Election::restore(saved.checkpoint), revision);
143
+ Ok(true)
144
+ }
145
+ fn lease(&self) -> Option<(u64, u64, f64)> {
146
+ self.state.borrow().0.checkpoint().lease().map(|lease| {
147
+ (
148
+ lease.owner().value(),
149
+ lease.token().value(),
150
+ lease.expires_at().as_secs_f64(),
151
+ )
152
+ })
153
+ }
154
+ }
@@ -0,0 +1,51 @@
1
+ //! Strict Ruby boundary validation.
2
+ use lease_machines::FencingToken;
3
+ use magnus::{Error, Float, Integer, Ruby, TryConvert, Value};
4
+ use std::time::Duration;
5
+ #[derive(Clone, Copy)]
6
+ pub struct Id(pub u64);
7
+ impl TryConvert for Id {
8
+ fn try_convert(value: Value) -> Result<Self, Error> {
9
+ if Integer::from_value(value).is_none() {
10
+ return Err(Error::new(
11
+ Ruby::get_with(value).exception_type_error(),
12
+ "expected an Integer",
13
+ ));
14
+ }
15
+ u64::try_convert(value).map(Self)
16
+ }
17
+ }
18
+ #[derive(Clone, Copy)]
19
+ pub struct Seconds(pub Duration);
20
+ impl TryConvert for Seconds {
21
+ fn try_convert(value: Value) -> Result<Self, Error> {
22
+ let ruby = Ruby::get_with(value);
23
+ if Integer::from_value(value).is_none() && Float::from_value(value).is_none() {
24
+ return Err(Error::new(
25
+ ruby.exception_type_error(),
26
+ "seconds must be Integer or Float",
27
+ ));
28
+ }
29
+ Duration::try_from_secs_f64(f64::try_convert(value)?)
30
+ .map(Self)
31
+ .map_err(|_| {
32
+ Error::new(
33
+ ruby.exception_arg_error(),
34
+ "seconds must be finite, nonnegative and representable",
35
+ )
36
+ })
37
+ }
38
+ }
39
+ pub fn token(ruby: &Ruby, value: Id) -> Result<FencingToken, Error> {
40
+ FencingToken::new(value.0)
41
+ .ok_or_else(|| Error::new(ruby.exception_arg_error(), "token must be positive"))
42
+ }
43
+ pub fn interval(ruby: &Ruby, now: Seconds, ttl: Seconds) -> Result<(), Error> {
44
+ if ttl.0.is_zero() || now.0.checked_add(ttl.0).is_none() {
45
+ return Err(Error::new(
46
+ ruby.exception_arg_error(),
47
+ "TTL must be positive and expiration representable",
48
+ ));
49
+ }
50
+ Ok(())
51
+ }
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+ module ElectionMachines
3
+ VERSION = '0.1.5'
4
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'election_machines/version'
3
+ require 'election_machines_native/election_machines_native'
@@ -0,0 +1,15 @@
1
+ module ElectionMachines
2
+ VERSION: String
3
+ type outcome = [String, Integer?, Integer?, Float?]
4
+ class Checkpoint
5
+ end
6
+ class Election
7
+ def self.new: () -> Election
8
+ def elect: (Array[Integer], Integer | Float, Integer | Float) -> outcome
9
+ def heartbeat: (Integer, Integer, Integer | Float, Integer | Float) -> outcome
10
+ def resign: (Integer, Integer, Integer | Float) -> outcome
11
+ def checkpoint: () -> Checkpoint
12
+ def restore!: (Checkpoint) -> bool
13
+ def lease: () -> [Integer, Integer, Float]?
14
+ end
15
+ end
metadata ADDED
@@ -0,0 +1,84 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: election_machines
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.5
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
+ - - ">="
20
+ - !ruby/object:Gem::Version
21
+ version: 0.9.130
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - "~>"
27
+ - !ruby/object:Gem::Version
28
+ version: '0.9'
29
+ - - ">="
30
+ - !ruby/object:Gem::Version
31
+ version: 0.9.130
32
+ description: A thin Rust bridge for deterministic nomination and lease renewal. No
33
+ distributed election service.
34
+ email:
35
+ - oss@seuros.com
36
+ executables: []
37
+ extensions:
38
+ - ext/election_machines_native/extconf.rb
39
+ extra_rdoc_files: []
40
+ files:
41
+ - CHANGELOG.md
42
+ - Cargo.toml
43
+ - LICENSE.txt
44
+ - README.md
45
+ - ext/election_machines_native/core/CHANGELOG.md
46
+ - ext/election_machines_native/core/Cargo.toml
47
+ - ext/election_machines_native/core/README.md
48
+ - ext/election_machines_native/core/src/lib.rs
49
+ - ext/election_machines_native/extconf.rb
50
+ - ext/election_machines_native/ffi/Cargo.toml
51
+ - ext/election_machines_native/ffi/extconf.rb
52
+ - ext/election_machines_native/ffi/src/init.rs
53
+ - ext/election_machines_native/ffi/src/lib.rs
54
+ - ext/election_machines_native/ffi/src/values.rs
55
+ - lib/election_machines.rb
56
+ - lib/election_machines/version.rb
57
+ - sig/election_machines.rbs
58
+ homepage: https://github.com/seuros/election_machines
59
+ licenses:
60
+ - MIT
61
+ metadata:
62
+ source_code_uri: https://github.com/seuros/election_machines
63
+ bug_tracker_uri: https://github.com/seuros/election_machines/issues
64
+ rubygems_mfa_required: 'true'
65
+ cargo_crate_name: election_machines_native
66
+ cargo_manifest_path: ext/election_machines_native/ffi/Cargo.toml
67
+ rdoc_options: []
68
+ require_paths:
69
+ - lib
70
+ required_ruby_version: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: 4.0.0
75
+ required_rubygems_version: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: '0'
80
+ requirements: []
81
+ rubygems_version: 4.0.20
82
+ specification_version: 4
83
+ summary: Native bounded lease-backed nomination model for Ruby
84
+ test_files: []