feat(raft): add binary WAL and VotedFor persistence
Implements a binary write-ahead log (RaftWal) for durable RAFT entry
storage, replacing in-memory-only semantics. The WAL uses a magic header
("NWAL" + version), length-prefixed records with per-record CRC32
integrity checking, and CompactAsync with atomic temp-file rename.
Load() tolerates truncated or corrupt tail records for crash safety.
Also fixes RaftNode to persist and reload TermState.VotedFor via a
meta.json file alongside term.txt, ensuring vote durability across
restarts. Falls back gracefully to legacy term.txt when meta.json is
absent.
6 new tests in RaftWalTests: persist/recover, compact, truncation
tolerance, VotedFor round-trip, empty WAL, and CRC corruption.
All 458 Raft tests pass.
This commit is contained in:
@@ -612,6 +612,18 @@ public sealed class RaftNode : IDisposable
|
||||
await Log.PersistAsync(Path.Combine(dir, "log.json"), ct);
|
||||
await File.WriteAllTextAsync(Path.Combine(dir, "term.txt"), TermState.CurrentTerm.ToString(), ct);
|
||||
await File.WriteAllTextAsync(Path.Combine(dir, "applied.txt"), AppliedIndex.ToString(), ct);
|
||||
|
||||
// Persist term and VotedFor together in meta.json for atomic durable state.
|
||||
// Go reference: raft.go storeMeta / writeTermVote (term + votedFor written atomically)
|
||||
var meta = new RaftMetaState
|
||||
{
|
||||
CurrentTerm = TermState.CurrentTerm,
|
||||
VotedFor = TermState.VotedFor,
|
||||
};
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(dir, "meta.json"),
|
||||
System.Text.Json.JsonSerializer.Serialize(meta),
|
||||
ct);
|
||||
}
|
||||
|
||||
public async Task LoadPersistedStateAsync(CancellationToken ct)
|
||||
@@ -619,9 +631,25 @@ public sealed class RaftNode : IDisposable
|
||||
var dir = _persistDirectory ?? Path.Combine(Path.GetTempPath(), "natsdotnet-raft", Id);
|
||||
Log = await RaftLog.LoadAsync(Path.Combine(dir, "log.json"), ct);
|
||||
|
||||
var termPath = Path.Combine(dir, "term.txt");
|
||||
if (File.Exists(termPath) && int.TryParse(await File.ReadAllTextAsync(termPath, ct), out var term))
|
||||
TermState.CurrentTerm = term;
|
||||
// Load from meta.json first (includes VotedFor); fall back to legacy term.txt
|
||||
var metaPath = Path.Combine(dir, "meta.json");
|
||||
if (File.Exists(metaPath))
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(metaPath, ct);
|
||||
var meta = System.Text.Json.JsonSerializer.Deserialize<RaftMetaState>(json);
|
||||
if (meta is not null)
|
||||
{
|
||||
TermState.CurrentTerm = meta.CurrentTerm;
|
||||
TermState.VotedFor = meta.VotedFor;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Legacy: term.txt only (no VotedFor)
|
||||
var termPath = Path.Combine(dir, "term.txt");
|
||||
if (File.Exists(termPath) && int.TryParse(await File.ReadAllTextAsync(termPath, ct), out var term))
|
||||
TermState.CurrentTerm = term;
|
||||
}
|
||||
|
||||
var appliedPath = Path.Combine(dir, "applied.txt");
|
||||
if (File.Exists(appliedPath) && long.TryParse(await File.ReadAllTextAsync(appliedPath, ct), out var applied))
|
||||
@@ -630,6 +658,13 @@ public sealed class RaftNode : IDisposable
|
||||
AppliedIndex = Log.Entries[^1].Index;
|
||||
}
|
||||
|
||||
/// <summary>Durable term + vote metadata written alongside the log.</summary>
|
||||
private sealed class RaftMetaState
|
||||
{
|
||||
public int CurrentTerm { get; set; }
|
||||
public string? VotedFor { get; set; }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
StopElectionTimer();
|
||||
|
||||
Reference in New Issue
Block a user