consensus_machines 0.1.4

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,251 @@
1
+ //! Native Ruby bridge for a bounded Raft model, not a consensus service.
2
+ mod init;
3
+ mod model;
4
+ mod values;
5
+ use consensus_machines::{AppendRequest, Entry, Position, Role, Vote, VoteRequest, VoteResponse};
6
+ use magnus::{Error, RArray, Ruby, Value, prelude::*, typed_data::Obj};
7
+ use std::{cell::RefCell, sync::Arc};
8
+ use values::{Id, index};
9
+
10
+ struct State {
11
+ node: Box<dyn model::Model>,
12
+ revision: u64,
13
+ }
14
+ #[magnus::wrap(class = "ConsensusMachines::Checkpoint", free_immediately, size)]
15
+ struct Saved {
16
+ owner: Arc<()>,
17
+ revision: u64,
18
+ checkpoint: Box<dyn model::Durable>,
19
+ }
20
+ #[magnus::wrap(class = "ConsensusMachines::Raft", free_immediately, size)]
21
+ struct Wrapper {
22
+ owner: Arc<()>,
23
+ state: RefCell<State>,
24
+ }
25
+ const fn role(role: Role) -> &'static str {
26
+ match role {
27
+ Role::Follower => "follower",
28
+ Role::Candidate => "candidate",
29
+ Role::Leader => "leader",
30
+ _ => "unknown",
31
+ }
32
+ }
33
+ impl Wrapper {
34
+ fn new(ruby: &Ruby, members: RArray, own: Id) -> Result<Self, Error> {
35
+ let count = members.len();
36
+ if !(1..=9).contains(&count) {
37
+ return Err(Error::new(
38
+ ruby.exception_arg_error(),
39
+ "membership must contain 1..9 actual members",
40
+ ));
41
+ }
42
+ let mut ids = [0; 9];
43
+ for (offset, id) in ids[..count].iter_mut().enumerate() {
44
+ *id = members.entry::<Id>(offset.cast_signed())?.0;
45
+ }
46
+ let node = model::new(&ids[..count], index(ruby, own)?).ok_or_else(|| {
47
+ Error::new(
48
+ ruby.exception_arg_error(),
49
+ "members must be unique and own index must be in membership",
50
+ )
51
+ })?;
52
+ Ok(Self {
53
+ owner: Arc::new(()),
54
+ state: RefCell::new(State { node, revision: 0 }),
55
+ })
56
+ }
57
+ fn next_revision(&self, ruby: &Ruby) -> Result<u64, Error> {
58
+ let revision = self.state.borrow().revision.checked_add(1);
59
+ revision.ok_or_else(|| Error::new(ruby.exception_range_error(), "revision exhausted"))
60
+ }
61
+ /// Runs `step` against the node under the next revision, which stales every
62
+ /// checkpoint taken before it.
63
+ fn advance<T>(
64
+ &self,
65
+ ruby: &Ruby,
66
+ step: impl FnOnce(&mut dyn model::Model) -> T,
67
+ ) -> Result<T, Error> {
68
+ let revision = self.next_revision(ruby)?;
69
+ let mut state = self.state.borrow_mut();
70
+ state.revision = revision;
71
+ Ok(step(state.node.as_mut()))
72
+ }
73
+ fn role(&self) -> &'static str {
74
+ role(self.state.borrow().node.role())
75
+ }
76
+ fn term(&self) -> u64 {
77
+ self.state.borrow().node.term()
78
+ }
79
+ fn committed(&self) -> usize {
80
+ self.state.borrow().node.committed()
81
+ }
82
+ fn last(&self) -> (usize, u64) {
83
+ let last = self.state.borrow().node.last();
84
+ (last.index, last.term)
85
+ }
86
+ fn entry(ruby: &Ruby, rb_self: &Self, position: Id) -> Result<Option<(u64, u64)>, Error> {
87
+ let index = index(ruby, position)?;
88
+ Ok(rb_self
89
+ .state
90
+ .borrow()
91
+ .node
92
+ .entry(index)
93
+ .map(|entry| (entry.term, entry.command)))
94
+ }
95
+ fn begin_election(
96
+ ruby: &Ruby,
97
+ rb_self: Obj<Self>,
98
+ ) -> Result<Option<(usize, u64, usize, u64)>, Error> {
99
+ rb_self.check_frozen()?;
100
+ let request = rb_self.advance(ruby, |node| node.begin_election())?;
101
+ Ok(request.map(|request| {
102
+ (
103
+ request.candidate,
104
+ request.term,
105
+ request.last.index,
106
+ request.last.term,
107
+ )
108
+ }))
109
+ }
110
+ fn request_vote(
111
+ ruby: &Ruby,
112
+ rb_self: Obj<Self>,
113
+ candidate: Id,
114
+ term: Id,
115
+ last_index: Id,
116
+ last_term: Id,
117
+ ) -> Result<(u64, bool), Error> {
118
+ rb_self.check_frozen()?;
119
+ let request = VoteRequest {
120
+ candidate: index(ruby, candidate)?,
121
+ term: term.0,
122
+ last: Position {
123
+ index: index(ruby, last_index)?,
124
+ term: last_term.0,
125
+ },
126
+ };
127
+ let response = rb_self.advance(ruby, |node| node.request_vote(request))?;
128
+ Ok((response.term, response.vote == Vote::Granted))
129
+ }
130
+ fn receive_vote(
131
+ ruby: &Ruby,
132
+ rb_self: Obj<Self>,
133
+ peer: Id,
134
+ term: Id,
135
+ ballot: Id,
136
+ ) -> Result<&'static str, Error> {
137
+ rb_self.check_frozen()?;
138
+ let peer = index(ruby, peer)?;
139
+ let vote = match ballot.0 {
140
+ 0 => Vote::Denied,
141
+ 1 => Vote::Granted,
142
+ _ => {
143
+ return Err(Error::new(
144
+ ruby.exception_arg_error(),
145
+ "ballot must be Integer 0 or 1",
146
+ ));
147
+ }
148
+ };
149
+ let result = rb_self.advance(ruby, |node| {
150
+ node.receive_vote(peer, VoteResponse { term: term.0, vote })
151
+ })?;
152
+ Ok(role(result))
153
+ }
154
+ fn propose(ruby: &Ruby, rb_self: Obj<Self>, command: Id) -> Result<Option<usize>, Error> {
155
+ rb_self.check_frozen()?;
156
+ rb_self.advance(ruby, |node| node.propose(command.0))
157
+ }
158
+ // Mirrors AppendEntries metadata without Ruby policy objects.
159
+ #[allow(clippy::too_many_arguments)]
160
+ fn append_entries(
161
+ ruby: &Ruby,
162
+ rb_self: Obj<Self>,
163
+ leader: Id,
164
+ term: Id,
165
+ previous_index: Id,
166
+ previous_term: Id,
167
+ committed: Id,
168
+ entries: RArray,
169
+ ) -> Result<(u64, Option<usize>), Error> {
170
+ rb_self.check_frozen()?;
171
+ let count = entries.len();
172
+ if count > 64 {
173
+ return Err(Error::new(ruby.exception_arg_error(), "at most 64 entries"));
174
+ }
175
+ let mut decoded = [Entry {
176
+ term: 0,
177
+ command: 0,
178
+ }; 64];
179
+ for (offset, entry) in decoded[..count].iter_mut().enumerate() {
180
+ // RArray::try_convert can invoke to_ary, permitting reentrant input
181
+ // mutation or freezing. Nested entries must already be Arrays.
182
+ let value = entries.entry::<Value>(offset.cast_signed())?;
183
+ let pair = RArray::from_value(value)
184
+ .ok_or_else(|| Error::new(ruby.exception_type_error(), "entry must be an Array"))?;
185
+ if pair.len() != 2 {
186
+ return Err(Error::new(
187
+ ruby.exception_arg_error(),
188
+ "entry must be [term, command_id]",
189
+ ));
190
+ }
191
+ *entry = Entry {
192
+ term: pair.entry::<Id>(0)?.0,
193
+ command: pair.entry::<Id>(1)?.0,
194
+ };
195
+ }
196
+ let request = AppendRequest {
197
+ leader: index(ruby, leader)?,
198
+ term: term.0,
199
+ previous: Position {
200
+ index: index(ruby, previous_index)?,
201
+ term: previous_term.0,
202
+ },
203
+ committed: index(ruby, committed)?,
204
+ };
205
+ rb_self.check_frozen()?;
206
+ let response =
207
+ rb_self.advance(ruby, |node| node.append_entries(request, &decoded[..count]))?;
208
+ Ok((response.term, response.matched))
209
+ }
210
+ fn acknowledge(
211
+ ruby: &Ruby,
212
+ rb_self: Obj<Self>,
213
+ peer: Id,
214
+ term: Id,
215
+ position: Id,
216
+ ) -> Result<bool, Error> {
217
+ rb_self.check_frozen()?;
218
+ let peer = index(ruby, peer)?;
219
+ let position = index(ruby, position)?;
220
+ rb_self.advance(ruby, |node| node.acknowledge(peer, term.0, position))
221
+ }
222
+ fn checkpoint(&self) -> Saved {
223
+ let (checkpoint, revision) = {
224
+ let state = self.state.borrow();
225
+ (state.node.checkpoint(), state.revision)
226
+ };
227
+ Saved {
228
+ owner: Arc::clone(&self.owner),
229
+ revision,
230
+ checkpoint,
231
+ }
232
+ }
233
+ fn restore(ruby: &Ruby, rb_self: Obj<Self>, saved: &Saved) -> Result<bool, Error> {
234
+ rb_self.check_frozen()?;
235
+ if !Arc::ptr_eq(&rb_self.owner, &saved.owner)
236
+ || rb_self.state.borrow().revision != saved.revision
237
+ {
238
+ return Err(Error::new(
239
+ ruby.exception_arg_error(),
240
+ "cross-instance or stale checkpoint",
241
+ ));
242
+ }
243
+ let revision = rb_self.next_revision(ruby)?;
244
+ let node = saved
245
+ .checkpoint
246
+ .restore()
247
+ .ok_or_else(|| Error::new(ruby.exception_arg_error(), "invalid checkpoint"))?;
248
+ *rb_self.state.borrow_mut() = State { node, revision };
249
+ Ok(true)
250
+ }
251
+ }
@@ -0,0 +1,84 @@
1
+ //! Exact-membership type erasure; all transitions delegate to the core.
2
+ use consensus_machines::{
3
+ AppendRequest, AppendResponse, Checkpoint, Entry, Position, Raft, Role, VoteRequest,
4
+ VoteResponse,
5
+ };
6
+
7
+ pub trait Durable: Send {
8
+ fn restore(&self) -> Option<Box<dyn Model>>;
9
+ }
10
+ pub trait Model: Send {
11
+ fn role(&self) -> Role;
12
+ fn term(&self) -> u64;
13
+ fn committed(&self) -> usize;
14
+ fn last(&self) -> Position;
15
+ fn entry(&self, index: usize) -> Option<Entry>;
16
+ fn begin_election(&mut self) -> Option<VoteRequest>;
17
+ fn request_vote(&mut self, request: VoteRequest) -> VoteResponse;
18
+ fn receive_vote(&mut self, peer: usize, response: VoteResponse) -> Role;
19
+ fn propose(&mut self, command: u64) -> Option<usize>;
20
+ fn append_entries(&mut self, request: AppendRequest, entries: &[Entry]) -> AppendResponse;
21
+ fn acknowledge(&mut self, peer: usize, term: u64, index: usize) -> bool;
22
+ fn checkpoint(&self) -> Box<dyn Durable>;
23
+ }
24
+ impl<const N: usize> Durable for Checkpoint<N, 64> {
25
+ fn restore(&self) -> Option<Box<dyn Model>> {
26
+ Raft::restore(self.clone()).map(|node| Box::new(node) as Box<dyn Model>)
27
+ }
28
+ }
29
+ impl<const N: usize> Model for Raft<N, 64> {
30
+ fn role(&self) -> Role {
31
+ self.role()
32
+ }
33
+ fn term(&self) -> u64 {
34
+ self.term()
35
+ }
36
+ fn committed(&self) -> usize {
37
+ self.committed()
38
+ }
39
+ fn last(&self) -> Position {
40
+ self.last()
41
+ }
42
+ fn entry(&self, index: usize) -> Option<Entry> {
43
+ self.entry(index)
44
+ }
45
+ fn begin_election(&mut self) -> Option<VoteRequest> {
46
+ self.begin_election()
47
+ }
48
+ fn request_vote(&mut self, request: VoteRequest) -> VoteResponse {
49
+ self.request_vote(request)
50
+ }
51
+ fn receive_vote(&mut self, peer: usize, response: VoteResponse) -> Role {
52
+ self.receive_vote(peer, response)
53
+ }
54
+ fn propose(&mut self, command: u64) -> Option<usize> {
55
+ self.propose(command)
56
+ }
57
+ fn append_entries(&mut self, request: AppendRequest, entries: &[Entry]) -> AppendResponse {
58
+ self.append_entries(request, entries)
59
+ }
60
+ fn acknowledge(&mut self, peer: usize, term: u64, index: usize) -> bool {
61
+ self.acknowledge(peer, term, index)
62
+ }
63
+ fn checkpoint(&self) -> Box<dyn Durable> {
64
+ Box::new(self.checkpoint())
65
+ }
66
+ }
67
+ fn sized<const N: usize>(members: &[u64], own: usize) -> Option<Box<dyn Model>> {
68
+ let members: [u64; N] = members.try_into().ok()?;
69
+ Raft::<N, 64>::new(members, own).map(|node| Box::new(node) as Box<dyn Model>)
70
+ }
71
+ pub fn new(members: &[u64], own: usize) -> Option<Box<dyn Model>> {
72
+ match members.len() {
73
+ 1 => sized::<1>(members, own),
74
+ 2 => sized::<2>(members, own),
75
+ 3 => sized::<3>(members, own),
76
+ 4 => sized::<4>(members, own),
77
+ 5 => sized::<5>(members, own),
78
+ 6 => sized::<6>(members, own),
79
+ 7 => sized::<7>(members, own),
80
+ 8 => sized::<8>(members, own),
81
+ 9 => sized::<9>(members, own),
82
+ _ => None,
83
+ }
84
+ }
@@ -0,0 +1,19 @@
1
+ //! Strict integer decoding before any state transition.
2
+ use magnus::{Error, Integer, Ruby, TryConvert, Value};
3
+ #[derive(Clone, Copy)]
4
+ pub struct Id(pub u64);
5
+ impl TryConvert for Id {
6
+ fn try_convert(value: Value) -> Result<Self, Error> {
7
+ if Integer::from_value(value).is_none() {
8
+ return Err(Error::new(
9
+ Ruby::get_with(value).exception_type_error(),
10
+ "expected an Integer",
11
+ ));
12
+ }
13
+ u64::try_convert(value).map(Self)
14
+ }
15
+ }
16
+ pub fn index(ruby: &Ruby, id: Id) -> Result<usize, Error> {
17
+ usize::try_from(id.0)
18
+ .map_err(|_| Error::new(ruby.exception_range_error(), "index outside native range"))
19
+ }
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+ module ConsensusMachines
3
+ VERSION = '0.1.4'
4
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'consensus_machines/version'
3
+ require 'consensus_machines_native/consensus_machines_native'
@@ -0,0 +1,21 @@
1
+ module ConsensusMachines
2
+ VERSION: String
3
+ class Checkpoint
4
+ end
5
+ class Raft
6
+ def self.new: (Array[Integer], Integer) -> Raft
7
+ def role: () -> String
8
+ def term: () -> Integer
9
+ def committed: () -> Integer
10
+ def last: () -> [Integer, Integer]
11
+ def entry: (Integer) -> [Integer, Integer]?
12
+ def begin_election: () -> [Integer, Integer, Integer, Integer]?
13
+ def request_vote: (Integer, Integer, Integer, Integer) -> [Integer, bool]
14
+ def receive_vote: (Integer, Integer, Integer) -> String
15
+ def propose: (Integer) -> Integer?
16
+ def append_entries: (Integer, Integer, Integer, Integer, Integer, Array[[Integer, Integer]]) -> [Integer, Integer?]
17
+ def acknowledge: (Integer, Integer, Integer) -> bool
18
+ def checkpoint: () -> Checkpoint
19
+ def restore!: (Checkpoint) -> bool
20
+ end
21
+ end
metadata ADDED
@@ -0,0 +1,86 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: consensus_machines
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.4
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 bridge to an allocation-free fixed-membership Rust Raft model.
33
+ Not a production consensus service.
34
+ email:
35
+ - oss@seuros.com
36
+ executables: []
37
+ extensions:
38
+ - ext/consensus_machines_native/extconf.rb
39
+ extra_rdoc_files: []
40
+ files:
41
+ - CHANGELOG.md
42
+ - Cargo.toml
43
+ - LICENSE.txt
44
+ - README.md
45
+ - ext/consensus_machines_native/core/CHANGELOG.md
46
+ - ext/consensus_machines_native/core/Cargo.toml
47
+ - ext/consensus_machines_native/core/README.md
48
+ - ext/consensus_machines_native/core/src/lib.rs
49
+ - ext/consensus_machines_native/core/src/log.rs
50
+ - ext/consensus_machines_native/extconf.rb
51
+ - ext/consensus_machines_native/ffi/Cargo.toml
52
+ - ext/consensus_machines_native/ffi/extconf.rb
53
+ - ext/consensus_machines_native/ffi/src/init.rs
54
+ - ext/consensus_machines_native/ffi/src/lib.rs
55
+ - ext/consensus_machines_native/ffi/src/model.rs
56
+ - ext/consensus_machines_native/ffi/src/values.rs
57
+ - lib/consensus_machines.rb
58
+ - lib/consensus_machines/version.rb
59
+ - sig/consensus_machines.rbs
60
+ homepage: https://github.com/seuros/consensus_machines
61
+ licenses:
62
+ - MIT
63
+ metadata:
64
+ source_code_uri: https://github.com/seuros/consensus_machines
65
+ bug_tracker_uri: https://github.com/seuros/consensus_machines/issues
66
+ rubygems_mfa_required: 'true'
67
+ cargo_crate_name: consensus_machines_native
68
+ cargo_manifest_path: ext/consensus_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: Native bounded Raft transition model for Ruby
86
+ test_files: []