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,340 @@
1
+ #![no_std]
2
+ #![doc = include_str!("../README.md")]
3
+ mod log;
4
+ use log::Log;
5
+ use quorum_machines::Quorum;
6
+
7
+ /// A log position; index zero has term zero.
8
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
9
+ pub struct Position {
10
+ /// One-based index, or zero for the empty prefix.
11
+ pub index: usize,
12
+ /// Entry term, or zero for the empty prefix.
13
+ pub term: u64,
14
+ }
15
+ /// One immutable command identity in the bounded log.
16
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
17
+ pub struct Entry {
18
+ /// Term of the leader that created the entry.
19
+ pub term: u64,
20
+ /// Stable identity of command data stored by the runtime.
21
+ pub command: u64,
22
+ }
23
+ /// Raft role under one static membership.
24
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
25
+ #[non_exhaustive]
26
+ pub enum Role {
27
+ /// Not campaigning.
28
+ Follower,
29
+ /// Requesting votes.
30
+ Candidate,
31
+ /// Won a distinct-member majority in this term.
32
+ Leader,
33
+ }
34
+ /// Vote value, excluding transport/persistence success.
35
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
36
+ #[non_exhaustive]
37
+ pub enum Vote {
38
+ /// Vote granted.
39
+ Granted,
40
+ /// Vote denied.
41
+ Denied,
42
+ }
43
+ /// `RequestVote` input or a proposed outgoing request.
44
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
45
+ pub struct VoteRequest {
46
+ /// Candidate membership index.
47
+ pub candidate: usize,
48
+ /// Candidate term.
49
+ pub term: u64,
50
+ /// Candidate's final log position.
51
+ pub last: Position,
52
+ }
53
+ /// Proposed `RequestVote` reply; persist node state before exposing it.
54
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
55
+ pub struct VoteResponse {
56
+ /// Responder's current term.
57
+ pub term: u64,
58
+ /// Ballot.
59
+ pub vote: Vote,
60
+ }
61
+ /// `AppendEntries` metadata; entry data is passed as a slice.
62
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
63
+ pub struct AppendRequest {
64
+ /// Leader membership index.
65
+ pub leader: usize,
66
+ /// Leader term.
67
+ pub term: u64,
68
+ /// Position immediately preceding incoming entries.
69
+ pub previous: Position,
70
+ /// Leader's committed index.
71
+ pub committed: usize,
72
+ }
73
+ /// Proposed append reply, requiring durable state before acknowledgement.
74
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
75
+ pub struct AppendResponse {
76
+ /// Responder's term.
77
+ pub term: u64,
78
+ /// Last matched incoming index, or `None` for rejection.
79
+ pub matched: Option<usize>,
80
+ }
81
+ /// Opaque persistent state, with no stable wire/disk representation.
82
+ #[derive(Debug, Clone, PartialEq, Eq)]
83
+ pub struct Checkpoint<const N: usize, const L: usize> {
84
+ members: [u64; N],
85
+ own: usize,
86
+ term: u64,
87
+ voted_for: Option<usize>,
88
+ log: Log<L>,
89
+ committed: usize,
90
+ }
91
+ /// Deterministic Raft transition model with bounded log and static peers.
92
+ #[derive(Debug)]
93
+ pub struct Raft<const N: usize, const L: usize> {
94
+ state: Checkpoint<N, L>,
95
+ role: Role,
96
+ votes: Quorum<N>,
97
+ matched: [usize; N],
98
+ }
99
+ impl<const N: usize, const L: usize> Raft<N, L> {
100
+ /// Validate unique membership and this node's membership index.
101
+ #[must_use]
102
+ pub fn new(members: [u64; N], own: usize) -> Option<Self> {
103
+ if own >= N {
104
+ return None;
105
+ }
106
+ for (index, member) in members.iter().enumerate() {
107
+ if members[..index].contains(member) {
108
+ return None;
109
+ }
110
+ }
111
+ Some(Self {
112
+ state: Checkpoint {
113
+ members,
114
+ own,
115
+ term: 0,
116
+ voted_for: None,
117
+ log: Log::new(),
118
+ committed: 0,
119
+ },
120
+ role: Role::Follower,
121
+ votes: Quorum::majority()?,
122
+ matched: [0; N],
123
+ })
124
+ }
125
+ /// Restore newest committed state, always losing volatile leadership.
126
+ #[must_use]
127
+ pub fn restore(checkpoint: Checkpoint<N, L>) -> Option<Self> {
128
+ let mut node = Self::new(checkpoint.members, checkpoint.own)?;
129
+ node.state = checkpoint;
130
+ Some(node)
131
+ }
132
+ /// Capture state to durably commit before using any proposed decisions.
133
+ #[must_use]
134
+ pub fn checkpoint(&self) -> Checkpoint<N, L> {
135
+ self.state.clone()
136
+ }
137
+ /// Current role.
138
+ #[must_use]
139
+ pub const fn role(&self) -> Role {
140
+ self.role
141
+ }
142
+ /// Current term, never wrapping.
143
+ #[must_use]
144
+ pub const fn term(&self) -> u64 {
145
+ self.state.term
146
+ }
147
+ /// Highest committed index, not an application acknowledgement.
148
+ #[must_use]
149
+ pub const fn committed(&self) -> usize {
150
+ self.state.committed
151
+ }
152
+ /// Last local log position.
153
+ #[must_use]
154
+ pub fn last(&self) -> Position {
155
+ self.state.log.last()
156
+ }
157
+ /// Read one entry; the runtime must not apply uncommitted entries.
158
+ #[must_use]
159
+ pub const fn entry(&self, index: usize) -> Option<Entry> {
160
+ self.state.log.entry(index)
161
+ }
162
+
163
+ /// Start a new term and self-vote. Persist before broadcasting this request.
164
+ ///
165
+ /// # Panics
166
+ /// Panics only if the validated own-member invariant is violated.
167
+ pub fn begin_election(&mut self) -> Option<VoteRequest> {
168
+ let term = self.state.term.checked_add(1)?;
169
+ self.state.term = term;
170
+ self.state.voted_for = Some(self.state.own);
171
+ self.role = Role::Candidate;
172
+ self.votes.reset();
173
+ assert!(self.votes.acknowledge(self.state.own));
174
+ if self.votes.reached() {
175
+ self.become_leader();
176
+ }
177
+ Some(VoteRequest {
178
+ candidate: self.state.own,
179
+ term,
180
+ last: self.last(),
181
+ })
182
+ }
183
+ /// Evaluate an authenticated `RequestVote` and propose a reply.
184
+ #[must_use]
185
+ pub fn request_vote(&mut self, request: VoteRequest) -> VoteResponse {
186
+ if request.candidate >= N || request.candidate == self.state.own || request.term == 0 {
187
+ return VoteResponse {
188
+ term: self.term(),
189
+ vote: Vote::Denied,
190
+ };
191
+ }
192
+ if request.term > self.term() {
193
+ self.step_down(request.term);
194
+ }
195
+ let local = self.last();
196
+ let valid_position = request.last.term <= request.term
197
+ && ((request.last.index == 0) == (request.last.term == 0));
198
+ let fresh = (request.last.term, request.last.index) >= (local.term, local.index);
199
+ let available = self
200
+ .state
201
+ .voted_for
202
+ .is_none_or(|peer| peer == request.candidate);
203
+ let vote = if request.term == self.term() && valid_position && fresh && available {
204
+ self.state.voted_for = Some(request.candidate);
205
+ self.role = Role::Follower;
206
+ Vote::Granted
207
+ } else {
208
+ Vote::Denied
209
+ };
210
+ VoteResponse {
211
+ term: self.term(),
212
+ vote,
213
+ }
214
+ }
215
+ /// Fold one authenticated, term-correlated ballot into the election.
216
+ #[must_use]
217
+ pub fn receive_vote(&mut self, peer: usize, response: VoteResponse) -> Role {
218
+ if peer >= N || peer == self.state.own {
219
+ return self.role;
220
+ }
221
+ if response.term > self.term() {
222
+ self.step_down(response.term);
223
+ }
224
+ if self.role == Role::Candidate
225
+ && response.term == self.term()
226
+ && response.vote == Vote::Granted
227
+ && self.votes.acknowledge(peer)
228
+ && self.votes.reached()
229
+ {
230
+ self.become_leader();
231
+ }
232
+ self.role
233
+ }
234
+ /// Add a current-term command identity. Persist before replication/application.
235
+ pub fn propose(&mut self, command: u64) -> Option<usize> {
236
+ if self.role != Role::Leader {
237
+ return None;
238
+ }
239
+ let index = self.state.log.push(Entry {
240
+ term: self.term(),
241
+ command,
242
+ })?;
243
+ self.matched[self.state.own] = index;
244
+ self.advance_commit();
245
+ Some(index)
246
+ }
247
+ /// Match and append an authenticated leader prefix, never truncating commitment.
248
+ #[must_use]
249
+ pub fn append_entries(&mut self, request: AppendRequest, entries: &[Entry]) -> AppendResponse {
250
+ if request.leader >= N || request.leader == self.state.own || request.term == 0 {
251
+ return AppendResponse {
252
+ term: self.term(),
253
+ matched: None,
254
+ };
255
+ }
256
+ if request.term > self.term() {
257
+ self.step_down(request.term);
258
+ }
259
+ if request.term < self.term() {
260
+ return AppendResponse {
261
+ term: self.term(),
262
+ matched: None,
263
+ };
264
+ }
265
+ self.role = Role::Follower;
266
+ let mut previous_term = request.previous.term;
267
+ for entry in entries {
268
+ if entry.term == 0 || entry.term < previous_term || entry.term > request.term {
269
+ return AppendResponse {
270
+ term: self.term(),
271
+ matched: None,
272
+ };
273
+ }
274
+ previous_term = entry.term;
275
+ }
276
+ let matched = self
277
+ .state
278
+ .log
279
+ .append(request.previous, entries, self.state.committed);
280
+ if let Some(index) = matched {
281
+ self.state.committed = self.state.committed.max(request.committed.min(index));
282
+ }
283
+ AppendResponse {
284
+ term: self.term(),
285
+ matched,
286
+ }
287
+ }
288
+ /// Record successful durable replication, authenticated and correlated by runtime.
289
+ #[must_use]
290
+ pub fn acknowledge(&mut self, peer: usize, term: u64, index: usize) -> bool {
291
+ if peer >= N || peer == self.state.own {
292
+ return false;
293
+ }
294
+ if term > self.term() {
295
+ self.step_down(term);
296
+ }
297
+ if term != self.term() || self.role != Role::Leader || index > self.state.log.len() {
298
+ return false;
299
+ }
300
+ self.matched[peer] = self.matched[peer].max(index);
301
+ self.advance_commit();
302
+ true
303
+ }
304
+ const fn step_down(&mut self, term: u64) {
305
+ self.state.term = term;
306
+ self.state.voted_for = None;
307
+ self.role = Role::Follower;
308
+ self.votes.reset();
309
+ }
310
+ fn become_leader(&mut self) {
311
+ self.role = Role::Leader;
312
+ self.matched = [0; N];
313
+ self.matched[self.state.own] = self.state.log.len();
314
+ self.advance_commit();
315
+ }
316
+ fn advance_commit(&mut self) {
317
+ for index in (self.state.committed + 1..=self.state.log.len()).rev() {
318
+ if self
319
+ .entry(index)
320
+ .is_none_or(|entry| entry.term != self.term())
321
+ {
322
+ continue;
323
+ }
324
+ let Some(mut quorum) = Quorum::<N>::majority() else {
325
+ return;
326
+ };
327
+ for (peer, matched) in self.matched.iter().enumerate() {
328
+ if *matched >= index && !quorum.acknowledge(peer) {
329
+ return;
330
+ }
331
+ }
332
+ if quorum.reached() {
333
+ self.state.committed = index;
334
+ break;
335
+ }
336
+ }
337
+ }
338
+ }
339
+ #[cfg(test)]
340
+ mod tests;
@@ -0,0 +1,81 @@
1
+ use crate::{Entry, Position};
2
+
3
+ #[derive(Debug, Clone, PartialEq, Eq)]
4
+ pub struct Log<const L: usize> {
5
+ entries: [Option<Entry>; L],
6
+ len: usize,
7
+ }
8
+ impl<const L: usize> Log<L> {
9
+ pub(crate) const fn new() -> Self {
10
+ Self {
11
+ entries: [None; L],
12
+ len: 0,
13
+ }
14
+ }
15
+ pub(crate) const fn len(&self) -> usize {
16
+ self.len
17
+ }
18
+ pub(crate) const fn entry(&self, index: usize) -> Option<Entry> {
19
+ if index == 0 || index > self.len {
20
+ None
21
+ } else {
22
+ self.entries[index - 1]
23
+ }
24
+ }
25
+ pub(crate) fn last(&self) -> Position {
26
+ Position {
27
+ index: self.len,
28
+ term: self.entry(self.len).map_or(0, |entry| entry.term),
29
+ }
30
+ }
31
+ pub(crate) const fn push(&mut self, entry: Entry) -> Option<usize> {
32
+ if self.len == L {
33
+ return None;
34
+ }
35
+ self.entries[self.len] = Some(entry);
36
+ self.len += 1;
37
+ Some(self.len)
38
+ }
39
+ pub(crate) fn append(
40
+ &mut self,
41
+ previous: Position,
42
+ incoming: &[Entry],
43
+ committed: usize,
44
+ ) -> Option<usize> {
45
+ if previous.index > self.len
46
+ || self.entry(previous.index).map_or(0, |entry| entry.term) != previous.term
47
+ {
48
+ return None;
49
+ }
50
+ let end = previous.index.checked_add(incoming.len())?;
51
+ if end > L {
52
+ return None;
53
+ }
54
+ let mut replace = None;
55
+ for (offset, entry) in incoming.iter().enumerate() {
56
+ let index = previous.index + offset + 1;
57
+ match self.entry(index) {
58
+ Some(old) if old.term == entry.term => {
59
+ // Equal term/index with different command data is an invalid history.
60
+ if old.command != entry.command {
61
+ return None;
62
+ }
63
+ }
64
+ Some(_) if index <= committed => return None,
65
+ Some(_) | None => {
66
+ replace = Some(offset);
67
+ break;
68
+ }
69
+ }
70
+ }
71
+ if let Some(offset) = replace {
72
+ let keep = previous.index + offset;
73
+ self.entries[keep..self.len].fill(None);
74
+ self.len = keep;
75
+ for entry in &incoming[offset..] {
76
+ self.push(*entry)?;
77
+ }
78
+ }
79
+ Some(end)
80
+ }
81
+ }
@@ -0,0 +1,2 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'ffi/extconf'
@@ -0,0 +1,20 @@
1
+ [package]
2
+ name = "consensus_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
+ consensus-machines.workspace = true
16
+ magnus.workspace = true
17
+ rb-sys.workspace = true
18
+
19
+ [lints]
20
+ 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('consensus_machines_native/consensus_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,25 @@
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("ConsensusMachines")?;
6
+ module
7
+ .define_class("Checkpoint", ruby.class_object())?
8
+ .undef_default_alloc_func();
9
+ let class = module.define_class("Raft", ruby.class_object())?;
10
+ class.undef_default_alloc_func();
11
+ class.define_singleton_method("new", function!(crate::Wrapper::new, 2))?;
12
+ class.define_method("role", method!(crate::Wrapper::role, 0))?;
13
+ class.define_method("term", method!(crate::Wrapper::term, 0))?;
14
+ class.define_method("committed", method!(crate::Wrapper::committed, 0))?;
15
+ class.define_method("last", method!(crate::Wrapper::last, 0))?;
16
+ class.define_method("entry", method!(crate::Wrapper::entry, 1))?;
17
+ class.define_method("begin_election", method!(crate::Wrapper::begin_election, 0))?;
18
+ class.define_method("request_vote", method!(crate::Wrapper::request_vote, 4))?;
19
+ class.define_method("receive_vote", method!(crate::Wrapper::receive_vote, 3))?;
20
+ class.define_method("propose", method!(crate::Wrapper::propose, 1))?;
21
+ class.define_method("append_entries", method!(crate::Wrapper::append_entries, 6))?;
22
+ class.define_method("acknowledge", method!(crate::Wrapper::acknowledge, 3))?;
23
+ class.define_method("checkpoint", method!(crate::Wrapper::checkpoint, 0))?;
24
+ class.define_method("restore!", method!(crate::Wrapper::restore, 1))
25
+ }