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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +29 -0
- data/Cargo.toml +23 -0
- data/LICENSE.txt +21 -0
- data/README.md +220 -0
- data/ext/consensus_machines_native/core/CHANGELOG.md +22 -0
- data/ext/consensus_machines_native/core/Cargo.toml +15 -0
- data/ext/consensus_machines_native/core/README.md +53 -0
- data/ext/consensus_machines_native/core/src/lib.rs +340 -0
- data/ext/consensus_machines_native/core/src/log.rs +81 -0
- data/ext/consensus_machines_native/extconf.rb +2 -0
- data/ext/consensus_machines_native/ffi/Cargo.toml +20 -0
- data/ext/consensus_machines_native/ffi/extconf.rb +13 -0
- data/ext/consensus_machines_native/ffi/src/init.rs +25 -0
- data/ext/consensus_machines_native/ffi/src/lib.rs +251 -0
- data/ext/consensus_machines_native/ffi/src/model.rs +84 -0
- data/ext/consensus_machines_native/ffi/src/values.rs +19 -0
- data/lib/consensus_machines/version.rb +4 -0
- data/lib/consensus_machines.rb +3 -0
- data/sig/consensus_machines.rbs +21 -0
- metadata +86 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d81e47113f1afec593db633c2afd4454e5cb9b41f9dd7e3cedf38993241751e4
|
|
4
|
+
data.tar.gz: 313fcdb207747f9d86fb8fa824483edc3037bb5fa6e742ce06690d4bb3b760f0
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: d70d5251244cf6fea69323ae391a5287bd88be3dd5a6a57dc05784f92e5240c212d66d5ff262a0d8c13f135b51e7d952988e963b29f574ba3951fdf18b54a7a5
|
|
7
|
+
data.tar.gz: 5e4b7bb8dd15565e3ac245ea2caa234e7cb5fafed94d6e276004286c3b8713f89aac50e6804599fa150765d5b426aaab4e554372bb71c1cf9c5a9c3c16b612ff
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.4](https://github.com/seuros/consensus_machines/compare/consensus_machines/v0.1.3...consensus_machines/v0.1.4) (2026-11-06)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* extract Wrapper::advance for node revision handling ([d09a5b1](https://github.com/seuros/consensus_machines/commit/d09a5b1e6f8f7f093be7f0b3c0ddcf6188417471))
|
|
9
|
+
|
|
10
|
+
## [0.1.3](https://github.com/seuros/consensus_machines/compare/consensus_machines/v0.1.2...consensus_machines/v0.1.3) (2026-11-06)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* crates.io keywords and categories for the core ([3eacc56](https://github.com/seuros/consensus_machines/commit/3eacc564584d1a2485c2ce150db6673ffedfc698))
|
|
16
|
+
|
|
17
|
+
## [0.1.2](https://github.com/seuros/consensus_machines/compare/consensus_machines/v0.1.1...consensus_machines/v0.1.2) (2026-11-06)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
### Bug Fixes
|
|
21
|
+
|
|
22
|
+
* keep test sources out of the packaged gem and crate ([6ef97ab](https://github.com/seuros/consensus_machines/commit/6ef97abf2cde36bf051e5919c3c32f76c32d989a))
|
|
23
|
+
|
|
24
|
+
## [0.1.1](https://github.com/seuros/consensus_machines/compare/consensus_machines-v0.1.0...consensus_machines/v0.1.1) (2026-11-06)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
### Features
|
|
28
|
+
|
|
29
|
+
* initial extraction ([385650b](https://github.com/seuros/consensus_machines/commit/385650b7dc025c8d4494eadb1f4c59dcad67aa97))
|
data/Cargo.toml
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[workspace]
|
|
2
|
+
members = ["ext/consensus_machines_native/core", "ext/consensus_machines_native/ffi"]
|
|
3
|
+
default-members = ["ext/consensus_machines_native/core"]
|
|
4
|
+
resolver = "3"
|
|
5
|
+
[workspace.package]
|
|
6
|
+
edition = "2024"
|
|
7
|
+
rust-version = "1.99"
|
|
8
|
+
license = "MIT"
|
|
9
|
+
[workspace.dependencies]
|
|
10
|
+
consensus-machines = { path = "ext/consensus_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
|
+
quorum-machines = ">=0.2"
|
|
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,220 @@
|
|
|
1
|
+
# ConsensusMachines
|
|
2
|
+
|
|
3
|
+
> Three nodes can all claim to be in charge. The log only believes the one with the votes. 🛸
|
|
4
|
+
|
|
5
|
+
A Raft rulebook for Ruby. Each node holds a `Raft` object that knows the current term, who it voted for, what's in its log, and how far that log is committed. You feed it the messages your nodes exchange. It tells you how to answer, when you've become leader, and which entries a majority has safely stored. The wires, the timers, and the disk are yours.
|
|
6
|
+
|
|
7
|
+
## Quick Start
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
gem 'consensus_machines'
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
# Three members with ids 10, 20, 30. This process is member 0 (id 10).
|
|
15
|
+
node = ConsensusMachines::Raft.new([10, 20, 30], 0)
|
|
16
|
+
|
|
17
|
+
# Election timer fired. Start a term and vote for yourself.
|
|
18
|
+
node.begin_election # => [0, 1, 0, 0] send this RequestVote to the peers
|
|
19
|
+
node.receive_vote(1, 1, 1) # => "leader" member 1 said yes: that's a majority of 3
|
|
20
|
+
|
|
21
|
+
# Leader work: append an entry, replicate it, count the acknowledgements.
|
|
22
|
+
node.propose(42) # => 1 log index to replicate
|
|
23
|
+
node.acknowledge(1, 1, 1) # => true member 1 stored index 1 in term 1
|
|
24
|
+
node.committed # => 1 a majority has it; apply it
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Members are positional indices into the array you passed in. The second argument is which index you are.
|
|
28
|
+
|
|
29
|
+
## A Message to the Resistance
|
|
30
|
+
|
|
31
|
+
So you ran two database replicas with a `PRIMARY=true` flag and a health check that promotes the standby when the primary stops answering. Then a switch rebooted, the standby couldn't see the primary for nine seconds, promoted itself, and took writes. The primary, which was fine the whole time, also took writes. Nine seconds later you had two histories and no rule for picking one.
|
|
32
|
+
|
|
33
|
+
The fix is older than your cluster. A node leads only for a numbered term, only after a majority of distinct members voted for it in that term, and a write counts only once a majority has stored it. Two leaders in one term is impossible because nobody votes twice. A leader from an old term gets told the new term number and steps down.
|
|
34
|
+
|
|
35
|
+
That's Raft. The rules are precise and the bookkeeping is unforgiving, and that bookkeeping is this gem. Your code still sends the messages and writes the disk. The gem never lets you count a vote twice or commit a write the cluster didn't keep.
|
|
36
|
+
|
|
37
|
+
## Chapter 1: Stardate 2025.186, the AMS Ongaro
|
|
38
|
+
|
|
39
|
+
> **AMS Ongaro**, NCC-4204, *Ongaro*-class command vessel.
|
|
40
|
+
> Three bridges can speak. That does not make three orders authoritative.
|
|
41
|
+
|
|
42
|
+
The vessel doesn't broadcast. It keeps the ledger of command: which term it is, which bridge won it, which orders enough bridges have logged. An order from a bridge whose term has lapsed is logged nowhere.
|
|
43
|
+
|
|
44
|
+
Somewhere in the fleet, an ensign proposes:
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
def primary?
|
|
48
|
+
ENV['PRIMARY'] == 'true' || !ping(other_node)
|
|
49
|
+
end
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
"That," says the commander, "is how two bridges end up giving orders to the same engine."
|
|
53
|
+
|
|
54
|
+
## The Flight Plan
|
|
55
|
+
|
|
56
|
+
Every node is in one of three roles.
|
|
57
|
+
|
|
58
|
+
```mermaid
|
|
59
|
+
stateDiagram-v2
|
|
60
|
+
[*] --> follower
|
|
61
|
+
follower --> candidate: begin_election, new term, votes for self
|
|
62
|
+
candidate --> candidate: receive_vote, not yet a majority
|
|
63
|
+
candidate --> leader: receive_vote, majority of distinct members
|
|
64
|
+
candidate --> follower: a message arrives with a higher term
|
|
65
|
+
leader --> leader: propose, acknowledge, committed advances
|
|
66
|
+
leader --> follower: a message arrives with a higher term
|
|
67
|
+
follower --> follower: request_vote, append_entries
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
And the night the switch rebooted, on a three-member cluster:
|
|
71
|
+
|
|
72
|
+
```mermaid
|
|
73
|
+
sequenceDiagram
|
|
74
|
+
participant A as Member 0
|
|
75
|
+
participant B as Member 1
|
|
76
|
+
participant C as Member 2
|
|
77
|
+
|
|
78
|
+
Note over A: election timer fires
|
|
79
|
+
A->>A: begin_election (term 1)
|
|
80
|
+
A->>B: RequestVote term 1
|
|
81
|
+
A->>C: RequestVote term 1
|
|
82
|
+
B->>B: request_vote → [1, true]
|
|
83
|
+
B-->>A: vote granted
|
|
84
|
+
A->>A: receive_vote(1, 1, 1) → "leader"
|
|
85
|
+
Note over A: 2 of 3 is a majority. C's vote is not needed.
|
|
86
|
+
|
|
87
|
+
A->>A: propose(42) → index 1
|
|
88
|
+
A->>B: AppendEntries [[1, 42]]
|
|
89
|
+
A->>C: AppendEntries [[1, 42]]
|
|
90
|
+
B->>B: append_entries → [1, 1]
|
|
91
|
+
B-->>A: stored index 1
|
|
92
|
+
A->>A: acknowledge(1, 1, 1) → true
|
|
93
|
+
Note over A: committed = 1. Apply entry 1.
|
|
94
|
+
A->>C: AppendEntries, committed 1
|
|
95
|
+
C->>C: append_entries → [1, 1], committed 1
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## How Command Works
|
|
99
|
+
|
|
100
|
+
### Becoming a candidate
|
|
101
|
+
|
|
102
|
+
`begin_election` bumps the term, votes for yourself, and hands you the four numbers the peers need: your member index, the term, and your last log index and term.
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
node = ConsensusMachines::Raft.new([10, 20, 30, 40, 50], 0)
|
|
106
|
+
|
|
107
|
+
node.begin_election # => [0, 1, 0, 0]
|
|
108
|
+
node.role # => "candidate"
|
|
109
|
+
node.term # => 1
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Send that as a RequestVote to every peer.
|
|
113
|
+
|
|
114
|
+
### Answering a vote request
|
|
115
|
+
|
|
116
|
+
A peer feeds the request into its own node and sends back what it gets. One vote per term, to the first eligible candidate who asked.
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
peer = ConsensusMachines::Raft.new([10, 20, 30, 40, 50], 1)
|
|
120
|
+
|
|
121
|
+
peer.request_vote(0, 1, 0, 0) # => [1, true] member 0 gets the vote
|
|
122
|
+
peer.request_vote(2, 1, 0, 0) # => [1, false] member 2 asked too late
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Counting votes
|
|
126
|
+
|
|
127
|
+
`receive_vote` takes the peer's index, the term it voted in, and a ballot: 1 for yes, 0 for no. It returns your role afterwards. Repeats from the same member don't count twice.
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
node.receive_vote(1, 1, 1) # => "candidate" 1 of 5, plus yourself is 2
|
|
131
|
+
node.receive_vote(1, 1, 1) # => "candidate" same member, same vote
|
|
132
|
+
node.receive_vote(2, 1, 1) # => "leader" 3 distinct of 5
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Proposing and committing
|
|
136
|
+
|
|
137
|
+
Only a leader can `propose`. It returns the log index to replicate. An entry is committed when a majority of distinct members has acknowledged that index in the current term.
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
node.propose(99) # => 1
|
|
141
|
+
node.committed # => 0 nobody has it yet but you
|
|
142
|
+
|
|
143
|
+
node.acknowledge(1, 1, 1) # => true
|
|
144
|
+
node.committed # => 0 2 of 5 is not a majority
|
|
145
|
+
node.acknowledge(2, 1, 1) # => true
|
|
146
|
+
node.committed # => 1 3 of 5. Apply it.
|
|
147
|
+
node.entry(1) # => [1, 99]
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
The thing you propose is an Integer id. Keep the actual payload in your own store, keyed by that id.
|
|
151
|
+
|
|
152
|
+
### Replicating the log
|
|
153
|
+
|
|
154
|
+
Followers take `append_entries` with the leader's index, term, the index and term of the entry just before the new ones, the leader's commit index, and the entries as `[term, id]` pairs. The reply is the follower's term and the last index it now matches, or `nil` when the prefix didn't line up.
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
follower = ConsensusMachines::Raft.new([10, 20, 30, 40, 50], 1)
|
|
158
|
+
|
|
159
|
+
follower.append_entries(0, 1, 0, 0, 0, [[1, 99]]) # => [1, 1] stored index 1
|
|
160
|
+
follower.entry(1) # => [1, 99]
|
|
161
|
+
follower.append_entries(0, 1, 1, 2, 0, []) # => [1, nil] prefix mismatch, refused
|
|
162
|
+
follower.append_entries(0, 1, 1, 1, 1, []) # => [1, 1] heartbeat carrying commit 1
|
|
163
|
+
follower.committed # => 1
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
A `nil` match means the leader has to back up and send from an earlier index.
|
|
167
|
+
|
|
168
|
+
### Terms only go up
|
|
169
|
+
|
|
170
|
+
Any message carrying a higher term pulls the node into that term and back to follower. A leader from term 1 hearing about term 10 is a follower in term 10.
|
|
171
|
+
|
|
172
|
+
```ruby
|
|
173
|
+
node.role # => "leader"
|
|
174
|
+
node.receive_vote(1, 10, 0)
|
|
175
|
+
node.term # => 10
|
|
176
|
+
node.role # => "follower"
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### Checkpoints
|
|
180
|
+
|
|
181
|
+
`checkpoint` captures the node as it is right now. `restore!` puts it back, as a follower. A checkpoint is good only for the node that made it and only until that node changes again.
|
|
182
|
+
|
|
183
|
+
```ruby
|
|
184
|
+
node = ConsensusMachines::Raft.new([10, 20, 30], 0)
|
|
185
|
+
node.begin_election
|
|
186
|
+
saved = node.checkpoint
|
|
187
|
+
|
|
188
|
+
node.restore!(saved) # => true
|
|
189
|
+
node.role # => "follower"
|
|
190
|
+
node.term # => 1
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Change the node and the old checkpoint is refused with `ArgumentError`. So is a checkpoint from a different node.
|
|
194
|
+
|
|
195
|
+
## The Rules of the Vessel
|
|
196
|
+
|
|
197
|
+
- **One to nine members**, all with distinct Integer ids. Your own index must be one of them. A majority is more than half of the real members, never padded.
|
|
198
|
+
- **Ids, terms, indices, and proposals are Integers** in `0..2**64-1`. Floats and strings raise `TypeError`. Negative or oversized raise `RangeError`.
|
|
199
|
+
- **Ballots are 0 or 1.** Anything else is `ArgumentError`.
|
|
200
|
+
- **The log holds 64 entries.** `propose` returns `nil` when it's full, and also when you're not the leader. Nothing is evicted.
|
|
201
|
+
- **A single-member cluster commits on propose.** It is its own majority.
|
|
202
|
+
- **Bad input changes nothing.** Every argument is checked before any state moves. A malformed entries array raises `TypeError` and the node is exactly as it was.
|
|
203
|
+
- **Frozen means frozen.** `freeze` the node and every mutation, including `restore!`, raises `FrozenError`. Reads still work.
|
|
204
|
+
|
|
205
|
+
## What You Think You're Doing vs Reality
|
|
206
|
+
|
|
207
|
+
### You Think: "The leader wrote it, so it's written."
|
|
208
|
+
### Reality: The leader wrote it to the leader.
|
|
209
|
+
|
|
210
|
+
Until a majority has acknowledged an index, that entry lives on one machine that could lose power before the next heartbeat. `committed` is the only number that means "safe to apply". Reading `last` and calling it durable is how you acknowledge a payment the cluster never kept.
|
|
211
|
+
|
|
212
|
+
## What's Yours
|
|
213
|
+
|
|
214
|
+
The node decides. You deliver. Every `request_vote` reply, every `append_entries` reply, and every acknowledgement you fold in must have been written to disk on the node that produced it before it was sent, or a crash can make the cluster forget a vote it already cast. Run the election timer, the heartbeat, and the retries yourself. Authenticate the messages. And apply entries to your state machine only up to `committed`, in order, exactly once.
|
|
215
|
+
|
|
216
|
+
## License
|
|
217
|
+
|
|
218
|
+
MIT License. See [LICENSE.txt](LICENSE.txt) for details.
|
|
219
|
+
|
|
220
|
+
**Remember: The standby was sure it was primary. So was the primary. The log was only sure about one of them.**
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.2.2](https://github.com/seuros/consensus_machines/compare/consensus-machines-v0.2.1...consensus-machines-v0.2.2) (2026-11-06)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* crates.io keywords and categories for the core ([3eacc56](https://github.com/seuros/consensus_machines/commit/3eacc564584d1a2485c2ce150db6673ffedfc698))
|
|
9
|
+
|
|
10
|
+
## [0.2.1](https://github.com/seuros/consensus_machines/compare/consensus-machines-v0.2.0...consensus-machines-v0.2.1) (2026-11-06)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* keep test sources out of the packaged gem and crate ([6ef97ab](https://github.com/seuros/consensus_machines/commit/6ef97abf2cde36bf051e5919c3c32f76c32d989a))
|
|
16
|
+
|
|
17
|
+
## [0.2.0](https://github.com/seuros/consensus_machines/compare/consensus-machines-v0.1.0...consensus-machines-v0.2.0) (2026-11-06)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
### Features
|
|
21
|
+
|
|
22
|
+
* initial extraction ([385650b](https://github.com/seuros/consensus_machines/commit/385650b7dc025c8d4494eadb1f4c59dcad67aa97))
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "consensus-machines"
|
|
3
|
+
version = "0.2.2"
|
|
4
|
+
edition.workspace = true
|
|
5
|
+
rust-version.workspace = true
|
|
6
|
+
license.workspace = true
|
|
7
|
+
description = "Bounded fixed-membership Raft terms, votes, log matching, and commitment model"
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
exclude = ["src/**/tests.rs", "tests/"]
|
|
10
|
+
keywords = ["raft", "consensus", "quorum", "replication", "no-std"]
|
|
11
|
+
categories = ["no-std", "algorithms"]
|
|
12
|
+
[dependencies]
|
|
13
|
+
quorum-machines.workspace = true
|
|
14
|
+
[lints]
|
|
15
|
+
workspace = true
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# consensus-machines
|
|
2
|
+
|
|
3
|
+
> **AMS Ongaro**, NCC-4204, *Ongaro*-class command vessel.
|
|
4
|
+
|
|
5
|
+
Engine-room flight manual: model a shared command log before wiring the radios.
|
|
6
|
+
|
|
7
|
+
Allocation-free `no_std` **bounded fixed-membership Raft transition model**.
|
|
8
|
+
It models role/term changes, one vote per term, log freshness, append matching,
|
|
9
|
+
uncommitted suffix replacement, distinct-voter elections, replication progress,
|
|
10
|
+
and majority commitment restricted to entries from the leader's current term.
|
|
11
|
+
Command payloads are immutable caller-defined `u64` command identities.
|
|
12
|
+
|
|
13
|
+
Indices are one-based (`0/term 0` is the empty-log sentinel). Peer indices refer
|
|
14
|
+
to one validated static membership. No dynamic membership or joint consensus.
|
|
15
|
+
A full fixed-size log rejects proposals; no compaction/snapshot installation yet.
|
|
16
|
+
|
|
17
|
+
## Mandatory external safety contract
|
|
18
|
+
|
|
19
|
+
- Serialize node transitions and authenticate/correlate all messages to the same
|
|
20
|
+
cluster, configuration, term, and replication request. Quorum arithmetic alone
|
|
21
|
+
does not prove successful replication.
|
|
22
|
+
- **Durably commit `checkpoint()` after every state transition before exposing
|
|
23
|
+
its replies, broadcasting requests/entries, counting local persistence, or
|
|
24
|
+
applying committed commands.** The API returns proposed in-memory decisions;
|
|
25
|
+
it does not provide a persistence barrier.
|
|
26
|
+
- On storage uncertainty, stop and recover the latest committed checkpoint.
|
|
27
|
+
Never roll back terms, votes, or durable log history.
|
|
28
|
+
- Restore as follower. Keep timers, leader retry/next-index logic, heartbeat
|
|
29
|
+
scheduling, command-data storage, applied-index recovery, and client
|
|
30
|
+
deduplication in a tested runtime. Apply only committed commands, in order,
|
|
31
|
+
with an explicit crash-replay contract.
|
|
32
|
+
|
|
33
|
+
Typed checkpoints are opaque state transfers, not a stable disk/wire format.
|
|
34
|
+
No transport, election timeout randomization, pre-vote, leader lease/read-index,
|
|
35
|
+
linearizable read API, snapshotting, Byzantine protection, production liveness
|
|
36
|
+
claim, or Ruby binding is implemented. Do not deploy this model as a complete
|
|
37
|
+
distributed consensus service.
|
|
38
|
+
|
|
39
|
+
```rust
|
|
40
|
+
use consensus_machines::{Raft, Role};
|
|
41
|
+
# fn example() -> Option<()> {
|
|
42
|
+
// A local single-member model, not a storage or transport implementation.
|
|
43
|
+
let mut node = Raft::<1, 8>::new([1], 0)?;
|
|
44
|
+
node.begin_election()?;
|
|
45
|
+
assert_eq!(node.role(), Role::Leader);
|
|
46
|
+
assert_eq!(node.propose(42), Some(1));
|
|
47
|
+
assert_eq!(node.committed(), 1);
|
|
48
|
+
let recovered = Raft::restore(node.checkpoint())?;
|
|
49
|
+
assert_eq!(recovered.role(), Role::Follower);
|
|
50
|
+
# Some(())
|
|
51
|
+
# }
|
|
52
|
+
# assert_eq!(example(), Some(()));
|
|
53
|
+
```
|