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:
Joseph Doherty
2026-02-25 01:31:23 -05:00
parent 3ab683489e
commit c9ac4b9918
3 changed files with 441 additions and 3 deletions

View File

@@ -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();