1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
//! Durable, restart-surviving backing store for the OpenRaft state that must
//! be persisted for Raft's safety guarantees to hold.
//!
//! `OxirsStorage` (in `raft.rs`) keeps the Raft log, hard state (term/vote/
//! committed), last-applied index, membership, snapshot, and the RDF state
//! machine in memory for the hot read path OpenRaft drives. On its own that is
//! *not* durable: a process restart would lose the persisted vote and any
//! committed-but-unsnapshotted log, which violates Raft's core requirements —
//! a restarted node could vote twice in the same term or lose acknowledged
//! writes.
//!
//! This module provides [`DurableRaftStore`], a file-backed store that
//! `OxirsStorage` writes through to (with `fsync` before acknowledging the
//! operations that require durability — the vote and log appends) and reloads
//! from on startup. The on-disk layout under `<data_dir>/raft/`:
//!
//! - `hard_state.bin` — the `(term, voted_for, committed)` tuple, rewritten
//! atomically (write-temp + `fsync` + rename) on every change. Small and
//! fixed-shape, so a full rewrite is cheap and always crash-consistent.
//! - `log.bin` — length-prefixed (`u64` LE) oxicode frames, one per log
//! entry, appended in order. Truncation (`delete_conflict_logs_since`) and
//! purge (`purge_logs_upto`) rewrite this file atomically from the caller's
//! in-memory vector, so it always mirrors the live log exactly.
//! - `state_machine.bin` — the applied `RdfApp` plus `last_applied` and the
//! applied membership, rewritten atomically whenever entries are applied.
//! - `snapshot.bin` — the current snapshot's raw bytes plus its meta,
//! rewritten atomically when a snapshot is built or installed.
//!
//! All encoding uses oxicode (COOLJAPAN policy: never bincode).
use std::io::Write;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use openraft::{BasicNode, Entry, LogId, StoredMembership};
use serde::{Deserialize, Serialize};
use crate::raft::{OxirsNodeId, OxirsTypeConfig, RdfApp};
/// Persisted hard state: `(term, voted_for, committed_log_id)`. Mirrors the
/// in-memory `OxirsStorage::hard_state` tuple exactly.
pub type HardState = (u64, Option<OxirsNodeId>, Option<LogId<OxirsNodeId>>);
/// The persisted state-machine checkpoint: the applied `RdfApp`, the id of the
/// last applied log entry, and the membership as of that entry.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersistedStateMachine {
pub app: RdfApp,
pub last_applied: Option<LogId<OxirsNodeId>>,
pub membership: StoredMembership<OxirsNodeId, BasicNode>,
}
/// A persisted snapshot: the raw serialized state-machine bytes plus the meta
/// OpenRaft needs to reason about it (last log id, membership, snapshot id).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersistedSnapshot {
pub data: Vec<u8>,
pub last_log_id: Option<LogId<OxirsNodeId>>,
pub membership: StoredMembership<OxirsNodeId, BasicNode>,
pub snapshot_id: String,
}
/// Everything reloaded from disk when a durable store is opened on an existing
/// data directory. Empty (all `None`/`default`) for a fresh directory.
#[derive(Debug, Default)]
pub struct LoadedState {
pub hard_state: Option<HardState>,
pub log: Vec<Entry<OxirsTypeConfig>>,
pub state_machine: Option<PersistedStateMachine>,
pub snapshot: Option<PersistedSnapshot>,
}
/// File-backed durable store for OpenRaft's persistent state. Cheap to
/// `clone` for sharing between the log-store and state-machine halves that
/// `openraft::storage::Adaptor` splits `OxirsStorage` into — it holds only the
/// directory path.
#[derive(Debug, Clone)]
pub struct DurableRaftStore {
dir: PathBuf,
}
impl DurableRaftStore {
/// Open (creating if necessary) a durable store rooted at
/// `<data_dir>/raft/`, and load any previously-persisted state.
pub fn open(data_dir: impl AsRef<Path>) -> Result<(Self, LoadedState)> {
let dir = data_dir.as_ref().join("raft");
std::fs::create_dir_all(&dir)
.with_context(|| format!("creating raft data dir {}", dir.display()))?;
let store = Self { dir };
let loaded = store.load()?;
Ok((store, loaded))
}
fn hard_state_path(&self) -> PathBuf {
self.dir.join("hard_state.bin")
}
fn log_path(&self) -> PathBuf {
self.dir.join("log.bin")
}
fn state_machine_path(&self) -> PathBuf {
self.dir.join("state_machine.bin")
}
fn snapshot_path(&self) -> PathBuf {
self.dir.join("snapshot.bin")
}
/// Read and decode everything currently on disk.
fn load(&self) -> Result<LoadedState> {
Ok(LoadedState {
hard_state: self.load_hard_state()?,
log: self.load_log()?,
state_machine: self.load_state_machine()?,
snapshot: self.load_snapshot()?,
})
}
fn load_hard_state(&self) -> Result<Option<HardState>> {
decode_file(&self.hard_state_path())
}
fn load_state_machine(&self) -> Result<Option<PersistedStateMachine>> {
decode_file(&self.state_machine_path())
}
fn load_snapshot(&self) -> Result<Option<PersistedSnapshot>> {
decode_file(&self.snapshot_path())
}
/// Read the length-prefixed log frames back into a vector. A trailing
/// partial frame (a crash mid-append) is treated as end-of-log rather than
/// an error: the entry was never acknowledged, so dropping it is safe and
/// exactly what Raft expects.
fn load_log(&self) -> Result<Vec<Entry<OxirsTypeConfig>>> {
let path = self.log_path();
if !path.exists() {
return Ok(Vec::new());
}
let bytes =
std::fs::read(&path).with_context(|| format!("reading raft log {}", path.display()))?;
let mut entries = Vec::new();
let mut offset = 0usize;
while offset + 8 <= bytes.len() {
let mut len_buf = [0u8; 8];
len_buf.copy_from_slice(&bytes[offset..offset + 8]);
let len = u64::from_le_bytes(len_buf) as usize;
offset += 8;
if offset + len > bytes.len() {
// Truncated final frame from a crash mid-append: stop here.
break;
}
match oxicode::serde::decode_from_slice::<Entry<OxirsTypeConfig>, _>(
&bytes[offset..offset + len],
oxicode::config::standard(),
) {
Ok((entry, _)) => entries.push(entry),
Err(_) => break,
}
offset += len;
}
Ok(entries)
}
/// Persist the hard state (term/vote/committed). Atomic + fsync'd, because
/// the vote must survive a crash for Raft's single-vote-per-term safety.
pub fn persist_hard_state(&self, hard_state: &HardState) -> Result<()> {
atomic_write(&self.hard_state_path(), &encode(hard_state)?)
}
/// Append log entries as length-prefixed frames, fsync'd before returning:
/// an acknowledged append must be durable so a committed entry can never be
/// lost across a restart.
pub fn append_log(&self, entries: &[Entry<OxirsTypeConfig>]) -> Result<()> {
if entries.is_empty() {
return Ok(());
}
let mut buf = Vec::new();
for entry in entries {
let body = encode(entry)?;
buf.extend_from_slice(&(body.len() as u64).to_le_bytes());
buf.extend_from_slice(&body);
}
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(self.log_path())
.with_context(|| format!("opening raft log {}", self.log_path().display()))?;
file.write_all(&buf).context("appending raft log")?;
file.sync_all().context("fsync raft log")?;
Ok(())
}
/// Rewrite the entire log from the given (already truncated/purged)
/// in-memory vector. Used by conflict-truncation and purge, where an
/// append-only file can no longer represent the live log.
pub fn rewrite_log(&self, entries: &[Entry<OxirsTypeConfig>]) -> Result<()> {
let mut buf = Vec::new();
for entry in entries {
let body = encode(entry)?;
buf.extend_from_slice(&(body.len() as u64).to_le_bytes());
buf.extend_from_slice(&body);
}
atomic_write(&self.log_path(), &buf)
}
/// Persist the applied state-machine checkpoint (RDF state + last-applied +
/// membership). Atomic; not individually fsync'd on the apply hot path
/// beyond the atomic rename, since the log is the source of truth and the
/// state machine can always be rebuilt by replaying it.
pub fn persist_state_machine(&self, sm: &PersistedStateMachine) -> Result<()> {
atomic_write(&self.state_machine_path(), &encode(sm)?)
}
/// Persist the current snapshot (raw bytes + meta), atomically.
pub fn persist_snapshot(&self, snapshot: &PersistedSnapshot) -> Result<()> {
atomic_write(&self.snapshot_path(), &encode(snapshot)?)
}
}
/// oxicode-encode any serializable value (COOLJAPAN policy: not bincode).
fn encode<T: Serialize>(value: &T) -> Result<Vec<u8>> {
oxicode::serde::encode_to_vec(value, oxicode::config::standard())
.context("oxicode encode failed")
}
/// Decode an oxicode-encoded file, returning `None` if it does not exist.
fn decode_file<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<Option<T>> {
if !path.exists() {
return Ok(None);
}
let bytes = std::fs::read(path).with_context(|| format!("reading {}", path.display()))?;
if bytes.is_empty() {
return Ok(None);
}
let (value, _) = oxicode::serde::decode_from_slice::<T, _>(&bytes, oxicode::config::standard())
.with_context(|| format!("decoding {}", path.display()))?;
Ok(Some(value))
}
/// Crash-consistent write: write to a sibling `.tmp` file, `fsync` it, then
/// atomically rename over the target. A crash leaves either the old complete
/// file or the new complete file — never a torn one.
fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
let tmp = path.with_extension("tmp");
{
let mut file = std::fs::File::create(&tmp)
.with_context(|| format!("creating temp file {}", tmp.display()))?;
file.write_all(bytes)
.with_context(|| format!("writing {}", tmp.display()))?;
file.sync_all()
.with_context(|| format!("fsync {}", tmp.display()))?;
}
std::fs::rename(&tmp, path)
.with_context(|| format!("renaming {} -> {}", tmp.display(), path.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use openraft::{CommittedLeaderId, EntryPayload, Membership};
use std::collections::BTreeMap;
fn temp_dir(tag: &str) -> PathBuf {
let mut dir = std::env::temp_dir();
dir.push(format!(
"oxirs_durable_{tag}_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
dir
}
fn log_id(term: u64, index: u64) -> LogId<OxirsNodeId> {
LogId::new(CommittedLeaderId::new(term, 0), index)
}
/// Persisting hard state, a log, and a state-machine checkpoint, then
/// re-opening the store on the same directory, must reload identical state
/// — proving Raft state survives a process restart (the durability the
/// audit flagged as missing).
#[test]
fn regression_durable_state_survives_reopen() {
let dir = temp_dir("reopen");
{
let (store, loaded) = DurableRaftStore::open(&dir).expect("open fresh durable store");
assert!(loaded.hard_state.is_none());
assert!(loaded.log.is_empty());
assert!(loaded.state_machine.is_none());
// Persist a vote (term 7, voted for node 3) + committed index.
let hs: HardState = (7, Some(3), Some(log_id(7, 2)));
store.persist_hard_state(&hs).expect("persist hard state");
// Append two normal log entries.
let entries = vec![
Entry {
log_id: log_id(7, 1),
payload: EntryPayload::Normal(crate::raft::RdfCommand::Insert {
subject: "s".into(),
predicate: "p".into(),
object: "o".into(),
}),
},
Entry {
log_id: log_id(7, 2),
payload: EntryPayload::Blank,
},
];
store.append_log(&entries).expect("append log");
// Persist a state-machine checkpoint with real membership.
let mut app = RdfApp::default();
app.triples.insert(("s".into(), "p".into(), "o".into()));
let members: BTreeMap<OxirsNodeId, BasicNode> = [
(1, BasicNode::new("127.0.0.1:1")),
(3, BasicNode::new("127.0.0.1:3")),
]
.into_iter()
.collect();
let membership = StoredMembership::new(
Some(log_id(7, 2)),
Membership::new(vec![members.keys().copied().collect()], members),
);
store
.persist_state_machine(&PersistedStateMachine {
app: app.clone(),
last_applied: Some(log_id(7, 2)),
membership: membership.clone(),
})
.expect("persist state machine");
}
// Re-open: everything must come back intact.
let (_store, loaded) = DurableRaftStore::open(&dir).expect("re-open durable store");
assert_eq!(loaded.hard_state, Some((7, Some(3), Some(log_id(7, 2)))));
assert_eq!(loaded.log.len(), 2);
assert_eq!(loaded.log[0].log_id, log_id(7, 1));
assert_eq!(loaded.log[1].log_id, log_id(7, 2));
let sm = loaded.state_machine.expect("state machine reloaded");
assert_eq!(sm.last_applied, Some(log_id(7, 2)));
assert_eq!(sm.app.triples.len(), 1);
assert_eq!(sm.membership.voter_ids().collect::<Vec<_>>(), vec![1, 3]);
let _ = std::fs::remove_dir_all(&dir);
}
/// A conflict-truncation / purge rewrite must replace the on-disk log with
/// exactly the surviving entries.
#[test]
fn regression_durable_log_rewrite_truncates() {
let dir = temp_dir("rewrite");
let (store, _loaded) = DurableRaftStore::open(&dir).expect("open");
let entries: Vec<Entry<OxirsTypeConfig>> = (1..=5)
.map(|i| Entry {
log_id: log_id(1, i),
payload: EntryPayload::Blank,
})
.collect();
store.append_log(&entries).expect("append");
// Keep only the first two (as if truncating conflicts since index 3).
store.rewrite_log(&entries[..2]).expect("rewrite");
let (_s, loaded) = DurableRaftStore::open(&dir).expect("re-open");
assert_eq!(loaded.log.len(), 2);
assert_eq!(loaded.log[1].log_id.index, 2);
let _ = std::fs::remove_dir_all(&dir);
}
}