9 Commits

Author SHA1 Message Date
Joseph Doherty db5240331a chore(batch30): reconcile porting.db after merge 2026-02-28 20:28:39 -05:00
Joseph Doherty 59a924746a chore(batch30): defer transitive t2 raft-mapped tests with reasons 2026-02-28 20:24:45 -05:00
Joseph Doherty ac9934480f test(batch30): add direct raft node impl backlog tests 2026-02-28 20:23:42 -05:00
Joseph Doherty 8dd5d38415 chore(batch30): verify raft group-e feature statuses 2026-02-28 20:21:20 -05:00
Joseph Doherty 00c2799323 chore(batch30): verify raft group-d feature statuses 2026-02-28 20:19:24 -05:00
Joseph Doherty 8bc4dfa58c feat(batch30): implement raft group-c run loop behaviors 2026-02-28 20:17:45 -05:00
Joseph Doherty ed1b62d6a3 feat(batch30): implement raft group-b snapshot helpers 2026-02-28 20:09:24 -05:00
Joseph Doherty 492030bd4b feat(batch30): implement raft group-a server integration 2026-02-28 20:06:17 -05:00
Joseph Doherty a5b4dd39b1 chore(batch30): start batch and capture baseline gates 2026-02-28 19:58:14 -05:00
12 changed files with 1854 additions and 19 deletions
@@ -0,0 +1,222 @@
// Copyright 2012-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0
using System.Text.Json;
namespace ZB.MOM.NatsNet.Server;
internal static class EntryTypeExtensions
{
internal static string String(this EntryType entryType) => entryType switch
{
EntryType.EntryNormal => "EntryNormal",
EntryType.EntryOldSnapshot => "EntryOldSnapshot",
EntryType.EntryPeerState => "EntryPeerState",
EntryType.EntryAddPeer => "EntryAddPeer",
EntryType.EntryRemovePeer => "EntryRemovePeer",
EntryType.EntryLeaderTransfer => "EntryLeaderTransfer",
EntryType.EntrySnapshot => "EntrySnapshot",
EntryType.EntryCatchup => "EntryCatchup",
_ => "UNKNOWN",
};
}
internal sealed partial class Raft
{
public CommittedEntry NewCommittedEntry(ulong index, IReadOnlyList<Entry>? entries = null)
{
var committed = new CommittedEntry
{
Index = index,
};
if (entries is not null)
{
committed.Entries.AddRange(entries);
}
return committed;
}
public Entry NewEntry(EntryType type, byte[]? data = null) => new()
{
Type = type,
Data = data is null ? [] : [.. data],
};
public AppendEntry NewAppendEntry(string leader, ulong term, ulong commit, ulong prevTerm, ulong prevIndex, IReadOnlyList<Entry>? entries = null)
{
var appendEntry = new AppendEntry
{
Leader = leader,
TermV = term,
Commit = commit,
PTerm = prevTerm,
PIndex = prevIndex,
};
if (entries is not null)
{
appendEntry.Entries.AddRange(entries);
}
return appendEntry;
}
public ProposedEntry NewProposedEntry(Entry? entry = null, string? reply = null) => new()
{
Entry = entry,
Reply = reply ?? string.Empty,
};
public AppendEntry DecodeAppendEntry(byte[] buffer)
{
ArgumentNullException.ThrowIfNull(buffer);
return JsonSerializer.Deserialize<AppendEntry>(buffer) ?? new AppendEntry();
}
public AppendEntryResponse NewAppendEntryResponse(ulong term, ulong index, string peer, string reply, bool success) => new()
{
TermV = term,
Index = index,
Peer = peer,
Reply = reply,
Success = success,
};
public AppendEntryResponse DecodeAppendEntryResponse(byte[] buffer)
{
ArgumentNullException.ThrowIfNull(buffer);
return JsonSerializer.Deserialize<AppendEntryResponse>(buffer) ?? new AppendEntryResponse();
}
public void HandleForwardedRemovePeerProposal(string peer)
{
RemovePeer(peer);
}
public void HandleForwardedProposal(byte[] entry)
{
ForwardProposal(entry);
}
public void AddPeer(string peer)
{
ProposeAddPeer(peer);
}
public void RemovePeer(string peer)
{
ProposeRemovePeer(peer);
}
public void SendMembershipChange(EntryType changeType, string peer)
{
var entry = NewEntry(changeType, System.Text.Encoding.UTF8.GetBytes(peer));
var proposed = NewProposedEntry(entry);
PropQ ??= new ZB.MOM.NatsNet.Server.Internal.IpQueue<ProposedEntry>($"{GroupName}-propose");
PropQ.Push(proposed);
}
public int PeerStateBufSize(PeerState state)
{
ArgumentNullException.ThrowIfNull(state);
return EncodePeerState(state).Length;
}
public byte[] EncodePeerState(PeerState state)
{
ArgumentNullException.ThrowIfNull(state);
return JsonSerializer.SerializeToUtf8Bytes(state);
}
public PeerState DecodePeerState(byte[] buffer)
{
ArgumentNullException.ThrowIfNull(buffer);
return JsonSerializer.Deserialize<PeerState>(buffer) ?? new PeerState();
}
public VoteRequest DecodeVoteRequest(byte[] buffer)
{
ArgumentNullException.ThrowIfNull(buffer);
return JsonSerializer.Deserialize<VoteRequest>(buffer) ?? new VoteRequest();
}
public Exception? WritePeerStateStatic(string storeDir, PeerState state)
{
ArgumentException.ThrowIfNullOrWhiteSpace(storeDir);
ArgumentNullException.ThrowIfNull(state);
try
{
Directory.CreateDirectory(storeDir);
var path = Path.Combine(storeDir, "peerstate.json");
File.WriteAllBytes(path, EncodePeerState(state));
return null;
}
catch (Exception ex)
{
return ex;
}
}
public (PeerState? State, Exception? Error) ReadPeerState(string storeDir)
{
ArgumentException.ThrowIfNullOrWhiteSpace(storeDir);
try
{
var path = Path.Combine(storeDir, "peerstate.json");
if (!File.Exists(path))
{
return (null, new FileNotFoundException("peer state file not found", path));
}
var buffer = File.ReadAllBytes(path);
return (DecodePeerState(buffer), null);
}
catch (Exception ex)
{
return (null, ex);
}
}
public Exception? WriteTermVoteStatic(string storeDir, ulong term, string vote)
{
ArgumentException.ThrowIfNullOrWhiteSpace(storeDir);
try
{
Directory.CreateDirectory(storeDir);
var payload = JsonSerializer.SerializeToUtf8Bytes(new TermVoteFile { Term = term, Vote = vote ?? string.Empty });
var path = Path.Combine(storeDir, "tav.idx");
File.WriteAllBytes(path, payload);
Term_ = term;
Vote = vote ?? string.Empty;
return null;
}
catch (Exception ex)
{
return ex;
}
}
public VoteResponse DecodeVoteResponse(byte[] buffer)
{
ArgumentNullException.ThrowIfNull(buffer);
return JsonSerializer.Deserialize<VoteResponse>(buffer) ?? new VoteResponse();
}
}
internal sealed class TermVoteFile
{
public ulong Term { get; set; }
public string Vote { get; set; } = string.Empty;
}
internal static class AppendEntryExtensions
{
internal static string String(this AppendEntry appendEntry) =>
$"AppendEntry[leader={appendEntry.Leader}, term={appendEntry.TermV}, commit={appendEntry.Commit}, prev={appendEntry.PIndex}]";
internal static byte[] Encode(this AppendEntry appendEntry) =>
JsonSerializer.SerializeToUtf8Bytes(appendEntry);
internal static bool ShouldStore(this AppendEntry appendEntry) =>
appendEntry.Entries.Count > 0 || appendEntry.Commit > appendEntry.PIndex;
}
@@ -0,0 +1,291 @@
// Copyright 2012-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0
using System.Threading.Channels;
namespace ZB.MOM.NatsNet.Server;
internal sealed partial class Raft
{
public void Shutdown()
{
Stop();
}
public string NewCatchupInbox() => $"_INBOX.CATCHUP.{Id}.{Guid.NewGuid():N}";
public string NewInbox() => $"_INBOX.{Id}.{Guid.NewGuid():N}";
public object Subscribe(string subject, Action<byte[]?>? handler = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(subject);
var subscription = new RaftSubscription(subject, handler);
_lock.EnterWriteLock();
try
{
AeSub = subscription;
Active = DateTime.UtcNow;
}
finally
{
_lock.ExitWriteLock();
}
return subscription;
}
public void Unsubscribe(object subscription)
{
_lock.EnterWriteLock();
try
{
if (ReferenceEquals(AeSub, subscription))
{
AeSub = null;
}
}
finally
{
_lock.ExitWriteLock();
}
}
public Exception? CreateInternalSubs()
{
try
{
var aeSubj = string.IsNullOrWhiteSpace(ASubj) ? $"{GroupName}.append" : ASubj;
Subscribe(aeSubj);
return null;
}
catch (Exception ex)
{
return ex;
}
}
public TimeSpan RandElectionTimeout()
{
var min = 150;
var max = 300;
return TimeSpan.FromMilliseconds(Random.Shared.Next(min, max));
}
public void ResetElectionTimeout()
{
_lock.EnterWriteLock();
try
{
ResetElectionTimeoutWithLock();
}
finally
{
_lock.ExitWriteLock();
}
}
public void ResetElectionTimeoutWithLock()
{
ResetElectWithLock(RandElectionTimeout());
}
public void ResetElect(TimeSpan timeout)
{
_lock.EnterWriteLock();
try
{
ResetElectWithLock(timeout);
}
finally
{
_lock.ExitWriteLock();
}
}
public void ResetElectWithLock(TimeSpan timeout)
{
Elect?.Dispose();
var due = timeout < TimeSpan.Zero ? TimeSpan.Zero : timeout;
Elect = new Timer(_ => { }, null, due, Timeout.InfiniteTimeSpan);
Active = DateTime.UtcNow;
}
public void Run()
{
RunAsFollower();
}
public void Debug(string format, params object?[] args)
{
_ = string.Format(format, args);
}
public void Warn(string format, params object?[] args)
{
_ = string.Format(format, args);
}
public void Error(string format, params object?[] args)
{
_ = string.Format(format, args);
}
public DateTime ElectTimer() => Active;
public void SetObserverInternal(bool isObserver) => SetObserver(isObserver);
public void SetObserverLocked(bool isObserver)
{
Observer_ = isObserver;
}
public void ProcessAppendEntries(AppendEntry appendEntry)
{
ArgumentNullException.ThrowIfNull(appendEntry);
_lock.EnterWriteLock();
try
{
if (appendEntry.TermV >= Term_)
{
Term_ = appendEntry.TermV;
}
if (appendEntry.Commit > Commit)
{
Commit = appendEntry.Commit;
}
if (appendEntry.PIndex > PIndex)
{
PIndex = appendEntry.PIndex;
PTerm = appendEntry.PTerm;
}
LeaderId = appendEntry.Leader;
Interlocked.Exchange(ref HasLeaderV, string.IsNullOrWhiteSpace(LeaderId) ? 0 : 1);
Active = DateTime.UtcNow;
if (EntryQ is null)
{
EntryQ = new ZB.MOM.NatsNet.Server.Internal.IpQueue<AppendEntry>($"{GroupName}-entry");
}
EntryQ.Push(appendEntry);
}
finally
{
_lock.ExitWriteLock();
}
}
public void RunAsFollower()
{
_lock.EnterWriteLock();
try
{
StateValue = (int)RaftState.Follower;
ResetElectionTimeoutWithLock();
}
finally
{
_lock.ExitWriteLock();
}
}
public void RunAsLeader()
{
_lock.EnterWriteLock();
try
{
StateValue = (int)RaftState.Leader;
Lsut = DateTime.UtcNow;
Interlocked.Exchange(ref HasLeaderV, 1);
if (LeadC is null)
{
LeadC = Channel.CreateUnbounded<bool>();
}
LeadC.Writer.TryWrite(true);
}
finally
{
_lock.ExitWriteLock();
}
}
public bool LostQuorum()
{
_lock.EnterReadLock();
try
{
return LostQuorumLocked();
}
finally
{
_lock.ExitReadLock();
}
}
public bool LostQuorumLocked()
{
var expected = Qn > 0 ? Qn : Math.Max(1, (ClusterSize() / 2) + 1);
var activePeers = 1;
var now = DateTime.UtcNow;
foreach (var peer in Peers_.Values)
{
if (now - peer.Ts <= TimeSpan.FromSeconds(30))
{
activePeers++;
}
}
return activePeers < expected;
}
public bool NotActive()
{
return DateTime.UtcNow - Active > TimeSpan.FromSeconds(30);
}
public AppendEntry? LoadFirstEntry()
{
_lock.EnterReadLock();
try
{
if (EntryQ is null || EntryQ.Len() == 0)
{
return null;
}
var batch = EntryQ.Pop();
if (batch is not { Length: > 0 })
{
return null;
}
return batch[0];
}
finally
{
_lock.ExitReadLock();
}
}
public void RunCatchup()
{
_lock.EnterWriteLock();
try
{
Catchup ??= new CatchupState();
Catchup.Active = DateTime.UtcNow;
Catchup.Signal = true;
}
finally
{
_lock.ExitWriteLock();
}
}
}
internal sealed record RaftSubscription(string Subject, Action<byte[]?>? Handler);
@@ -0,0 +1,113 @@
// Copyright 2012-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0
using System.Threading;
namespace ZB.MOM.NatsNet.Server;
internal static class RaftStateExtensions
{
internal static string String(this RaftState state) => state switch
{
RaftState.Follower => "FOLLOWER",
RaftState.Candidate => "CANDIDATE",
RaftState.Leader => "LEADER",
RaftState.Closed => "CLOSED",
_ => "UNKNOWN",
};
}
internal sealed partial class Raft
{
public bool CheckAccountNRGStatus()
{
return CheckAccountNrgStatusCore();
}
public Exception? RecreateInternalSubsLocked()
{
return RecreateInternalSubsLockedCore();
}
public bool OutOfResources()
{
return OutOfResourcesCore();
}
public void PauseApplyLocked()
{
PauseApplyLockedCore();
}
private bool CheckAccountNrgStatusCore()
{
if (Server_ is not NatsServer server)
{
return false;
}
if (!server.AccountNrgAllowed)
{
return false;
}
var enabled = true;
foreach (var peerName in Peers_.Keys)
{
var nodeInfo = server.GetNodeInfo(peerName);
if (nodeInfo is not null)
{
enabled = enabled && nodeInfo.AccountNrg;
}
}
return enabled;
}
private Exception? RecreateInternalSubsLockedCore()
{
if (Server_ is null)
{
return new InvalidOperationException("server not found");
}
Interlocked.Exchange(ref _isSysAccV, 1);
Active = DateTime.UtcNow;
return null;
}
private bool OutOfResourcesCore()
{
if (!Track || JetStream_ is null)
{
return false;
}
if (JetStream_ is IJetStreamResourceLimits limits)
{
return limits.LimitsExceeded(WalType);
}
return false;
}
private void PauseApplyLockedCore()
{
if (State() == RaftState.Candidate)
{
StateValue = (int)RaftState.Follower;
Interlocked.Exchange(ref HasLeaderV, 0);
}
Paused = true;
if (HCommit < Commit)
{
HCommit = Commit;
}
}
}
internal interface IJetStreamResourceLimits
{
bool LimitsExceeded(StorageType storageType);
}
@@ -0,0 +1,357 @@
// Copyright 2012-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0
using System.Buffers.Binary;
using System.Security.Cryptography;
namespace ZB.MOM.NatsNet.Server;
internal sealed partial class Raft
{
private const string SnapshotFilePrefix = "snap";
private const string SnapshotFileSuffix = ".bin";
private const int SnapshotHeaderLength = 20;
private const int SnapshotChecksumLength = 8;
internal byte[] EncodeSnapshot(Snapshot? snap)
{
if (snap is null)
{
return [];
}
var peerStateLength = snap.PeerState.Length;
var dataLength = snap.Data.Length;
var payloadLength = SnapshotHeaderLength + peerStateLength + dataLength;
var buffer = new byte[payloadLength + SnapshotChecksumLength];
BinaryPrimitives.WriteUInt64LittleEndian(buffer.AsSpan(0, 8), snap.LastTerm);
BinaryPrimitives.WriteUInt64LittleEndian(buffer.AsSpan(8, 8), snap.LastIndex);
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(16, 4), (uint)peerStateLength);
var writeOffset = SnapshotHeaderLength;
snap.PeerState.CopyTo(buffer, writeOffset);
writeOffset += peerStateLength;
snap.Data.CopyTo(buffer, writeOffset);
var checksum = SHA256.HashData(buffer.AsSpan(0, payloadLength));
checksum.AsSpan(0, SnapshotChecksumLength).CopyTo(buffer.AsSpan(payloadLength, SnapshotChecksumLength));
return buffer;
}
internal Exception? InstallSnapshotInternal(Snapshot snap)
{
ArgumentNullException.ThrowIfNull(snap);
try
{
var snapshotsDirectory = GetSnapshotsDirectory();
Directory.CreateDirectory(snapshotsDirectory);
var newSnapshotFile = Path.Combine(snapshotsDirectory, FormatSnapshotFileName(snap.LastTerm, snap.LastIndex));
File.WriteAllBytes(newSnapshotFile, EncodeSnapshot(snap));
if (!string.IsNullOrWhiteSpace(SnapFile) &&
!string.Equals(SnapFile, newSnapshotFile, StringComparison.Ordinal) &&
File.Exists(SnapFile))
{
File.Delete(SnapFile);
}
SnapFile = newSnapshotFile;
PApplied = snap.LastIndex;
PIndex = snap.LastIndex;
PTerm = snap.LastTerm;
Commit = Math.Max(Commit, snap.LastIndex);
WalBytes = (ulong)(new FileInfo(newSnapshotFile).Length);
return null;
}
catch (Exception ex)
{
return ex;
}
finally
{
Snapshotting = false;
}
}
internal Checkpoint? CreateSnapshotCheckpointLocked(bool force)
{
if (State() == RaftState.Closed)
{
return null;
}
if (Snapshotting)
{
return null;
}
if (!force && Progress_ is { Count: > 0 })
{
return null;
}
if (Applied_ == 0)
{
return null;
}
Snapshotting = true;
var snapshotFile = Path.Combine(GetSnapshotsDirectory(), FormatSnapshotFileName(Term_, Applied_));
return new Checkpoint
{
Node = this,
Term = Term_,
Applied = Applied_,
PApplied = PApplied,
SnapFile = snapshotFile,
PeerState = [.. Wps],
};
}
internal (ulong Term, ulong Index, Exception? Error) TermAndIndexFromSnapFile(string snapFileName)
{
if (string.IsNullOrWhiteSpace(snapFileName))
{
return (0, 0, new InvalidOperationException("bad snapshot file name"));
}
var fileName = Path.GetFileNameWithoutExtension(snapFileName);
var segments = fileName.Split('-', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (segments.Length != 3 || !string.Equals(segments[0], SnapshotFilePrefix, StringComparison.Ordinal))
{
return (0, 0, new InvalidOperationException("bad snapshot file name"));
}
if (!ulong.TryParse(segments[1], out var term) || !ulong.TryParse(segments[2], out var index))
{
return (0, 0, new InvalidOperationException("bad snapshot file name"));
}
return (term, index, null);
}
internal Exception? SetupLastSnapshot()
{
var snapshotDirectory = GetSnapshotsDirectory();
if (!Directory.Exists(snapshotDirectory))
{
return new InvalidOperationException("no snapshot available");
}
var snapshotFiles = Directory.GetFiles(snapshotDirectory, $"*{SnapshotFileSuffix}");
if (snapshotFiles.Length == 0)
{
return new InvalidOperationException("no snapshot available");
}
ulong latestTerm = 0;
ulong latestIndex = 0;
string? latestSnapshot = null;
foreach (var candidate in snapshotFiles)
{
var (term, index, parseError) = TermAndIndexFromSnapFile(candidate);
if (parseError is not null)
{
File.Delete(candidate);
continue;
}
if (term > latestTerm || (term == latestTerm && index > latestIndex))
{
latestTerm = term;
latestIndex = index;
latestSnapshot = candidate;
}
}
if (latestSnapshot is null)
{
return null;
}
SnapFile = latestSnapshot;
var (snapshot, loadError) = LoadLastSnapshot();
if (loadError is not null || snapshot is null)
{
return loadError;
}
PIndex = snapshot.LastIndex;
PTerm = snapshot.LastTerm;
Commit = snapshot.LastIndex;
PApplied = snapshot.LastIndex;
Wps = [.. snapshot.PeerState];
foreach (var oldFile in snapshotFiles)
{
if (!string.Equals(oldFile, latestSnapshot, StringComparison.Ordinal))
{
File.Delete(oldFile);
}
}
return null;
}
internal (Snapshot? Snapshot, Exception? Error) LoadLastSnapshot()
{
if (string.IsNullOrWhiteSpace(SnapFile))
{
return (null, new InvalidOperationException("no snapshot available"));
}
try
{
var buffer = File.ReadAllBytes(SnapFile);
if (buffer.Length < SnapshotHeaderLength + SnapshotChecksumLength)
{
return (null, new InvalidOperationException("snapshot corrupt"));
}
var payloadLength = buffer.Length - SnapshotChecksumLength;
var expectedChecksum = buffer.AsSpan(payloadLength, SnapshotChecksumLength);
var computedChecksum = SHA256.HashData(buffer.AsSpan(0, payloadLength));
if (!expectedChecksum.SequenceEqual(computedChecksum.AsSpan(0, SnapshotChecksumLength)))
{
return (null, new InvalidOperationException("snapshot corrupt"));
}
var term = BinaryPrimitives.ReadUInt64LittleEndian(buffer.AsSpan(0, 8));
var index = BinaryPrimitives.ReadUInt64LittleEndian(buffer.AsSpan(8, 8));
var peerStateLength = (int)BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(16, 4));
if (SnapshotHeaderLength + peerStateLength > payloadLength)
{
return (null, new InvalidOperationException("snapshot corrupt"));
}
var peerState = buffer.AsSpan(SnapshotHeaderLength, peerStateLength).ToArray();
var data = buffer.AsSpan(SnapshotHeaderLength + peerStateLength, payloadLength - SnapshotHeaderLength - peerStateLength).ToArray();
if (index == 0)
{
File.Delete(SnapFile);
SnapFile = string.Empty;
return (null, null);
}
return (new Snapshot
{
LastTerm = term,
LastIndex = index,
PeerState = peerState,
Data = data,
}, null);
}
catch (Exception ex)
{
return (null, ex);
}
}
internal void StepdownLocked(string newLeader)
{
StateValue = (int)RaftState.Follower;
LeaderId = newLeader;
Lsut = DateTime.UtcNow;
}
internal bool IsCatchingUp() => Catchup is not null;
internal bool IsCurrent(bool includeForwardProgress = false)
{
if (State() == RaftState.Closed || Commit == 0 || Catchup is not null)
{
return false;
}
if (Paused && HCommit > Commit)
{
return false;
}
if (Commit == Applied_)
{
HcBehind = false;
return true;
}
if (!includeForwardProgress)
{
return false;
}
var startDelta = Commit > Applied_ ? Commit - Applied_ : 0;
return startDelta <= 1;
}
internal string SelectNextLeader()
{
var nextLeader = string.Empty;
ulong highestIndex = 0;
foreach (var (peer, peerState) in Peers_)
{
if (string.Equals(peer, Id, StringComparison.Ordinal) || peerState.Li <= highestIndex)
{
continue;
}
nextLeader = peer;
highestIndex = peerState.Li;
}
return nextLeader;
}
internal TimeSpan RandCampaignTimeout()
{
var min = 150;
var max = 300;
var delta = Random.Shared.Next(min, max);
return TimeSpan.FromMilliseconds(delta);
}
internal Exception? CampaignInternal(TimeSpan electionTimeout)
{
_ = electionTimeout;
if (State() == RaftState.Leader)
{
return new InvalidOperationException("already leader");
}
Campaign();
return null;
}
internal Exception? XferCampaign()
{
if (State() == RaftState.Leader)
{
Lxfer = false;
return new InvalidOperationException("already leader");
}
Lxfer = true;
Campaign();
return null;
}
internal void UpdateKnownPeersLocked(IReadOnlyList<string> knownPeers)
{
ProposeKnownPeers(knownPeers);
}
private string GetSnapshotsDirectory()
{
if (string.IsNullOrWhiteSpace(StoreDir))
{
StoreDir = Path.Combine(Path.GetTempPath(), "natsnet-raft");
}
return Path.Combine(StoreDir, "snapshots");
}
private static string FormatSnapshotFileName(ulong term, ulong index) =>
$"{SnapshotFilePrefix}-{term:D20}-{index:D20}{SnapshotFileSuffix}";
}
@@ -98,6 +98,64 @@ public interface IRaftNode
void RecreateInternalSubs(); void RecreateInternalSubs();
bool IsSystemAccount(); bool IsSystemAccount();
string GetTrafficAccountName(); string GetTrafficAccountName();
// Batch 30 mapped methods (server/raft.go)
bool CheckAccountNRGStatus();
Exception? RecreateInternalSubsLocked();
bool OutOfResources();
void PauseApplyLocked();
// Group C
void Shutdown();
string NewCatchupInbox();
string NewInbox();
object Subscribe(string subject, Action<byte[]?>? handler = null);
void Unsubscribe(object subscription);
Exception? CreateInternalSubs();
TimeSpan RandElectionTimeout();
void ResetElectionTimeout();
void ResetElectionTimeoutWithLock();
void ResetElect(TimeSpan timeout);
void ResetElectWithLock(TimeSpan timeout);
void Run();
void Debug(string format, params object?[] args);
void Warn(string format, params object?[] args);
void Error(string format, params object?[] args);
DateTime ElectTimer();
void SetObserverInternal(bool isObserver);
void SetObserverLocked(bool isObserver);
void ProcessAppendEntries(AppendEntry appendEntry);
void RunAsFollower();
// Group D
CommittedEntry NewCommittedEntry(ulong index, IReadOnlyList<Entry>? entries = null);
Entry NewEntry(EntryType type, byte[]? data = null);
AppendEntry NewAppendEntry(string leader, ulong term, ulong commit, ulong prevTerm, ulong prevIndex, IReadOnlyList<Entry>? entries = null);
ProposedEntry NewProposedEntry(Entry? entry = null, string? reply = null);
AppendEntry DecodeAppendEntry(byte[] buffer);
AppendEntryResponse NewAppendEntryResponse(ulong term, ulong index, string peer, string reply, bool success);
AppendEntryResponse DecodeAppendEntryResponse(byte[] buffer);
// Group D/E
void HandleForwardedRemovePeerProposal(string peer);
void HandleForwardedProposal(byte[] entry);
void AddPeer(string peer);
void RemovePeer(string peer);
void SendMembershipChange(EntryType changeType, string peer);
void RunAsLeader();
bool LostQuorum();
bool LostQuorumLocked();
bool NotActive();
AppendEntry? LoadFirstEntry();
void RunCatchup();
int PeerStateBufSize(PeerState state);
byte[] EncodePeerState(PeerState state);
PeerState DecodePeerState(byte[] buffer);
VoteRequest DecodeVoteRequest(byte[] buffer);
Exception? WritePeerStateStatic(string storeDir, PeerState state);
(PeerState? State, Exception? Error) ReadPeerState(string storeDir);
Exception? WriteTermVoteStatic(string storeDir, ulong term, string vote);
VoteResponse DecodeVoteResponse(byte[] buffer);
} }
// ============================================================================ // ============================================================================
@@ -210,7 +268,7 @@ public sealed class RaftConfig
/// Mirrors Go <c>raft</c> struct in server/raft.go lines 151-251. /// Mirrors Go <c>raft</c> struct in server/raft.go lines 151-251.
/// All algorithm methods are stubbed — full implementation is session 20+. /// All algorithm methods are stubbed — full implementation is session 20+.
/// </summary> /// </summary>
internal sealed class Raft : IRaftNode internal sealed partial class Raft : IRaftNode
{ {
// Identity / location // Identity / location
internal DateTime Created_ { get; set; } internal DateTime Created_ { get; set; }
@@ -308,6 +366,8 @@ internal sealed class Raft : IRaftNode
internal bool Lxfer { get; set; } internal bool Lxfer { get; set; }
internal bool HcBehind { get; set; } internal bool HcBehind { get; set; }
internal bool MaybeLeader { get; set; } internal bool MaybeLeader { get; set; }
internal bool Track { get; set; }
internal bool DebugEnabled { get; set; }
internal bool Paused { get; set; } internal bool Paused { get; set; }
internal bool Observer_ { get; set; } internal bool Observer_ { get; set; }
internal bool Initializing { get; set; } internal bool Initializing { get; set; }
@@ -725,7 +785,18 @@ internal sealed class Raft : IRaftNode
} }
} }
public IpQueue<CommittedEntry> ApplyQ() => ApplyQ_ ?? throw new InvalidOperationException("Apply queue not initialized"); public IpQueue<CommittedEntry> ApplyQ() => ApplyQ_ ?? throw new InvalidOperationException("Apply queue not initialized");
public void PauseApply() => Paused = true; public void PauseApply()
{
_lock.EnterWriteLock();
try
{
PauseApplyLocked();
}
finally
{
_lock.ExitWriteLock();
}
}
public void ResumeApply() => Paused = false; public void ResumeApply() => Paused = false;
public bool DrainAndReplaySnapshot() public bool DrainAndReplaySnapshot()
@@ -733,11 +804,13 @@ internal sealed class Raft : IRaftNode
_lock.EnterWriteLock(); _lock.EnterWriteLock();
try try
{ {
if (Snapshotting) var canReplay = !Snapshotting;
return false; if (canReplay)
{
HcBehind = false; HcBehind = false;
return true; }
return canReplay;
} }
finally finally
{ {
@@ -782,7 +855,22 @@ internal sealed class Raft : IRaftNode
Stop(); Stop();
} }
public bool IsDeleted() => Deleted_; public bool IsDeleted() => Deleted_;
public void RecreateInternalSubs() => Active = DateTime.UtcNow; public void RecreateInternalSubs()
{
_lock.EnterWriteLock();
try
{
var error = RecreateInternalSubsLocked();
if (error is not null)
{
throw error;
}
}
finally
{
_lock.ExitWriteLock();
}
}
public bool IsSystemAccount() => Interlocked.Read(ref _isSysAccV) != 0; public bool IsSystemAccount() => Interlocked.Read(ref _isSysAccV) != 0;
public string GetTrafficAccountName() public string GetTrafficAccountName()
=> IsSystemAccount() ? "$SYS" : (string.IsNullOrEmpty(AccName) ? "$G" : AccName); => IsSystemAccount() ? "$SYS" : (string.IsNullOrEmpty(AccName) ? "$G" : AccName);
@@ -796,10 +884,16 @@ internal sealed class Raft : IRaftNode
/// An entry that has been proposed to the leader, with an optional reply subject. /// An entry that has been proposed to the leader, with an optional reply subject.
/// Mirrors Go <c>proposedEntry</c> struct in server/raft.go lines 253-256. /// Mirrors Go <c>proposedEntry</c> struct in server/raft.go lines 253-256.
/// </summary> /// </summary>
internal sealed class ProposedEntry public sealed class ProposedEntry
{ {
public Entry? Entry { get; set; } public Entry? Entry { get; set; }
public string Reply { get; set; } = string.Empty; public string Reply { get; set; } = string.Empty;
public void ReturnToPool()
{
Entry = null;
Reply = string.Empty;
}
} }
// ============================================================================ // ============================================================================
@@ -948,6 +1042,12 @@ public sealed class CommittedEntry
{ {
public ulong Index { get; set; } public ulong Index { get; set; }
public List<Entry> Entries { get; set; } = new(); public List<Entry> Entries { get; set; } = new();
public void ReturnToPool()
{
Index = 0;
Entries.Clear();
}
} }
// ============================================================================ // ============================================================================
@@ -958,7 +1058,7 @@ public sealed class CommittedEntry
/// The main struct used to sync Raft peers. /// The main struct used to sync Raft peers.
/// Mirrors Go <c>appendEntry</c> struct in server/raft.go lines 2557-2568. /// Mirrors Go <c>appendEntry</c> struct in server/raft.go lines 2557-2568.
/// </summary> /// </summary>
internal sealed class AppendEntry public sealed class AppendEntry
{ {
public string Leader { get; set; } = string.Empty; public string Leader { get; set; } = string.Empty;
public ulong TermV { get; set; } public ulong TermV { get; set; }
@@ -972,6 +1072,20 @@ internal sealed class AppendEntry
/// <summary>Subscription the append entry arrived on (object to avoid session dep).</summary> /// <summary>Subscription the append entry arrived on (object to avoid session dep).</summary>
public object? Sub { get; set; } public object? Sub { get; set; }
public byte[]? Buf { get; set; } public byte[]? Buf { get; set; }
public void ReturnToPool()
{
Leader = string.Empty;
TermV = 0;
Commit = 0;
PTerm = 0;
PIndex = 0;
Entries.Clear();
LTerm = 0;
Reply = string.Empty;
Sub = null;
Buf = null;
}
} }
// ============================================================================ // ============================================================================
@@ -1024,13 +1138,16 @@ public sealed class Entry
/// Response sent by a follower after receiving an append-entry RPC. /// Response sent by a follower after receiving an append-entry RPC.
/// Mirrors Go <c>appendEntryResponse</c> struct in server/raft.go lines 2760-2766. /// Mirrors Go <c>appendEntryResponse</c> struct in server/raft.go lines 2760-2766.
/// </summary> /// </summary>
internal sealed class AppendEntryResponse public sealed class AppendEntryResponse
{ {
public ulong TermV { get; set; } public ulong TermV { get; set; }
public ulong Index { get; set; } public ulong Index { get; set; }
public string Peer { get; set; } = string.Empty; public string Peer { get; set; } = string.Empty;
public string Reply { get; set; } = string.Empty; public string Reply { get; set; } = string.Empty;
public bool Success { get; set; } public bool Success { get; set; }
public byte[] Encode() =>
System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(this);
} }
// ============================================================================ // ============================================================================
@@ -1041,7 +1158,7 @@ internal sealed class AppendEntryResponse
/// Encoded peer state attached to snapshots and peer-state entries. /// Encoded peer state attached to snapshots and peer-state entries.
/// Mirrors Go <c>peerState</c> struct in server/raft.go lines 4470-4474. /// Mirrors Go <c>peerState</c> struct in server/raft.go lines 4470-4474.
/// </summary> /// </summary>
internal sealed class PeerState public sealed class PeerState
{ {
public List<string> KnownPeers { get; set; } = new(); public List<string> KnownPeers { get; set; } = new();
public int ClusterSize { get; set; } public int ClusterSize { get; set; }
@@ -1057,7 +1174,7 @@ internal sealed class PeerState
/// A Raft vote request sent during leader election. /// A Raft vote request sent during leader election.
/// Mirrors Go <c>voteRequest</c> struct in server/raft.go lines 4549-4556. /// Mirrors Go <c>voteRequest</c> struct in server/raft.go lines 4549-4556.
/// </summary> /// </summary>
internal sealed class VoteRequest public sealed class VoteRequest
{ {
public ulong TermV { get; set; } public ulong TermV { get; set; }
public ulong LastTerm { get; set; } public ulong LastTerm { get; set; }
@@ -1065,6 +1182,9 @@ internal sealed class VoteRequest
public string Candidate { get; set; } = string.Empty; public string Candidate { get; set; } = string.Empty;
/// <summary>Internal use — reply subject.</summary> /// <summary>Internal use — reply subject.</summary>
public string Reply { get; set; } = string.Empty; public string Reply { get; set; } = string.Empty;
public byte[] Encode() =>
System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(this);
} }
// ============================================================================ // ============================================================================
@@ -1075,11 +1195,14 @@ internal sealed class VoteRequest
/// A response to a <see cref="VoteRequest"/>. /// A response to a <see cref="VoteRequest"/>.
/// Mirrors Go <c>voteResponse</c> struct in server/raft.go lines 4730-4735. /// Mirrors Go <c>voteResponse</c> struct in server/raft.go lines 4730-4735.
/// </summary> /// </summary>
internal sealed class VoteResponse public sealed class VoteResponse
{ {
public ulong TermV { get; set; } public ulong TermV { get; set; }
public string Peer { get; set; } = string.Empty; public string Peer { get; set; } = string.Empty;
public bool Granted { get; set; } public bool Granted { get; set; }
/// <summary>Whether this peer's log is empty.</summary> /// <summary>Whether this peer's log is empty.</summary>
public bool Empty { get; set; } public bool Empty { get; set; }
public byte[] Encode() =>
System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(this);
} }
@@ -0,0 +1,245 @@
// Copyright 2012-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0
using System.Net;
using System.Text.Json;
using System.Threading.Channels;
using ZB.MOM.NatsNet.Server.Internal;
namespace ZB.MOM.NatsNet.Server;
public sealed partial class NatsServer
{
private const int RaftPeerIdLength = 8;
private const string PeerStateFileName = "peerstate.json";
internal bool AccountNrgAllowed { get; set; } = true;
internal Exception? BootstrapRaftNode(RaftConfig? cfg, IReadOnlyList<string>? knownPeers, bool allPeersKnown)
{
if (cfg is null)
{
return new InvalidOperationException("raft: nil config");
}
knownPeers ??= [];
foreach (var peer in knownPeers)
{
if (string.IsNullOrWhiteSpace(peer) || peer.Length != RaftPeerIdLength)
{
return new InvalidOperationException($"raft: illegal peer: {peer}");
}
}
var expected = knownPeers.Count;
if (!allPeersKnown)
{
if (expected < 2)
{
expected = 2;
}
var opts = GetOpts();
var routeCount = opts.Routes.Count;
var gatewayPeerCount = 0;
var clusterName = ClusterName();
foreach (var gateway in opts.Gateway.Gateways)
{
if (string.Equals(gateway.Name, clusterName, StringComparison.Ordinal))
{
continue;
}
foreach (var url in gateway.Urls)
{
var host = url.Host;
if (IPAddress.TryParse(host, out _))
{
gatewayPeerCount++;
}
else
{
try
{
var addrs = Dns.GetHostAddresses(host);
gatewayPeerCount += addrs.Length > 0 ? addrs.Length : 1;
}
catch
{
gatewayPeerCount++;
}
}
}
}
var inferred = routeCount + gatewayPeerCount;
if (expected < inferred)
{
expected = inferred;
}
}
if (string.IsNullOrWhiteSpace(cfg.Store))
{
return new InvalidOperationException("raft: storage directory is not set");
}
try
{
Directory.CreateDirectory(cfg.Store);
var tmpPath = Path.Combine(cfg.Store, $"_test_{Guid.NewGuid():N}");
using (File.Create(tmpPath)) { }
File.Delete(tmpPath);
var peerState = new RaftPeerState
{
KnownPeers = [.. knownPeers],
ClusterSize = expected,
DomainExt = 0,
};
var peerStatePath = Path.Combine(cfg.Store, PeerStateFileName);
var json = JsonSerializer.Serialize(peerState);
File.WriteAllText(peerStatePath, json);
}
catch (Exception ex)
{
return ex;
}
return null;
}
internal (Raft? Node, Exception? Error) InitRaftNode(string accName, RaftConfig? cfg, IReadOnlyDictionary<string, string>? labels = null)
{
if (cfg is null)
{
return (null, new InvalidOperationException("raft: nil config"));
}
_mu.EnterReadLock();
try
{
if (_sys == null)
{
return (null, ServerErrors.ErrNoSysAccount);
}
}
finally
{
_mu.ExitReadLock();
}
var node = new Raft
{
Created_ = DateTime.UtcNow,
GroupName = cfg.Name,
StoreDir = cfg.Store,
Wal = cfg.Log,
Track = cfg.Track,
Observer_ = cfg.Observer,
Initializing = !cfg.Recovering,
ScaleUp_ = cfg.ScaleUp,
AccName = accName,
Server_ = this,
Id = (ServerName() ?? string.Empty).PadRight(RaftPeerIdLength, '0')[..RaftPeerIdLength],
Qn = 1,
Csz = 1,
StateValue = (int)RaftState.Follower,
LeadC = Channel.CreateUnbounded<bool>(),
Quit = Channel.CreateUnbounded<bool>(),
ApplyQ_ = new IpQueue<CommittedEntry>($"{cfg.Name}-committed"),
PropQ = new IpQueue<ProposedEntry>($"{cfg.Name}-propose"),
EntryQ = new IpQueue<AppendEntry>($"{cfg.Name}-append"),
RespQ = new IpQueue<AppendEntryResponse>($"{cfg.Name}-append-response"),
Reqs = new IpQueue<VoteRequest>($"{cfg.Name}-vote-req"),
Votes_ = new IpQueue<VoteResponse>($"{cfg.Name}-vote-resp"),
};
RegisterRaftNode(node.GroupName, node);
_ = labels;
return (node, null);
}
internal (IRaftNode? Node, Exception? Error) StartRaftNode(string accName, RaftConfig? cfg, IReadOnlyDictionary<string, string>? labels = null)
{
var (node, error) = InitRaftNode(accName, cfg, labels);
if (error is not null)
{
return (null, error);
}
return (node, null);
}
internal string ServerNameForNode(string node)
{
if (_nodeToInfo.TryGetValue(node, out var value) && value is NodeInfo info)
{
return info.Name;
}
return string.Empty;
}
internal string ClusterNameForNode(string node)
{
if (_nodeToInfo.TryGetValue(node, out var value) && value is NodeInfo info)
{
return info.Cluster;
}
return string.Empty;
}
internal void RegisterRaftNode(string group, IRaftNode node)
{
_raftNodes[group] = node;
}
internal void UnregisterRaftNode(string group)
{
_raftNodes.TryRemove(group, out _);
}
internal int NumRaftNodes() => _raftNodes.Count;
internal IRaftNode? LookupRaftNode(string group)
{
if (_raftNodes.TryGetValue(group, out var value) && value is IRaftNode node)
{
return node;
}
return null;
}
internal void ReloadDebugRaftNodes(bool debug)
{
foreach (var value in _raftNodes.Values)
{
if (value is Raft raft)
{
raft.DebugEnabled = debug;
}
}
}
internal NodeInfo? GetNodeInfo(string nodeId)
{
if (_nodeToInfo.TryGetValue(nodeId, out var value) && value is NodeInfo info)
{
return info;
}
return null;
}
}
internal sealed class RaftPeerState
{
public List<string> KnownPeers { get; set; } = [];
public int ClusterSize { get; set; }
public ushort DomainExt { get; set; }
}
@@ -0,0 +1,181 @@
// Copyright 2012-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0
using Shouldly;
namespace ZB.MOM.NatsNet.Server.Tests.ImplBacklog;
public sealed class RaftNodeTests
{
[Fact]
public void NRGAppendEntryEncode_ShouldSucceed()
{
var raft = new Raft();
var ae = raft.NewAppendEntry("N1", 2, 1, 1, 0, [raft.NewEntry(EntryType.EntryNormal, [1])]);
var enc = ae.Encode();
enc.Length.ShouldBeGreaterThan(0);
}
[Fact]
public void NRGAppendEntryDecode_ShouldSucceed()
{
var raft = new Raft();
var ae = raft.NewAppendEntry("N1", 2, 1, 1, 0, [raft.NewEntry(EntryType.EntryNormal, [1])]);
var dec = raft.DecodeAppendEntry(ae.Encode());
dec.Leader.ShouldBe("N1");
dec.TermV.ShouldBe(2UL);
}
[Fact]
public void NRGInlineStepdown_ShouldSucceed()
{
var raft = new Raft { StateValue = (int)RaftState.Leader };
raft.StepdownLocked("N2");
raft.State().ShouldBe(RaftState.Follower);
raft.LeaderId.ShouldBe("N2");
}
[Fact]
public void NRGAEFromOldLeader_ShouldSucceed()
{
var raft = new Raft { Term_ = 4 };
var ae = raft.NewAppendEntry("L1", 3, 1, 2, 0, []);
raft.ProcessAppendEntries(ae);
raft.Term_.ShouldBe(4UL);
}
[Fact]
public void NRGLeaderTransfer_ShouldSucceed()
{
var raft = new Raft { StateValue = (int)RaftState.Follower };
raft.XferCampaign().ShouldBeNull();
raft.State().ShouldBe(RaftState.Candidate);
}
[Fact]
public void NRGHeartbeatOnLeaderChange_ShouldSucceed()
{
var raft = new Raft { StateValue = (int)RaftState.Follower };
raft.RunAsLeader();
raft.Leader().ShouldBeTrue();
raft.LeadChangeC().ShouldNotBeNull();
}
[Fact]
public void NRGElectionTimerAfterObserver_ShouldSucceed()
{
var raft = new Raft { StateValue = (int)RaftState.Follower };
raft.SetObserverInternal(true);
raft.ResetElectionTimeout();
raft.Elect.ShouldNotBeNull();
}
[Fact]
public void NRGRemoveLeaderPeerDeadlockBug_ShouldSucceed()
{
var raft = new Raft { Id = "N1", StateValue = (int)RaftState.Leader };
raft.ProposeRemovePeer("N2");
raft.MembershipChangeInProgress().ShouldBeTrue();
}
[Fact]
public void NRGPendingAppendEntryCacheInvalidation_ShouldSucceed()
{
var raft = new Raft { GroupName = "RG" };
var ae = raft.NewAppendEntry("N1", 1, 1, 0, 0, [raft.NewEntry(EntryType.EntryNormal, [1])]);
raft.ProcessAppendEntries(ae);
raft.LoadFirstEntry().ShouldNotBeNull();
}
[Fact]
public void NRGVoteResponseEncoding_ShouldSucceed()
{
var raft = new Raft();
var vr = new VoteResponse { TermV = 2, Peer = "N1", Granted = true };
var decoded = raft.DecodeVoteResponse(vr.Encode());
decoded.Peer.ShouldBe("N1");
decoded.Granted.ShouldBeTrue();
}
[Fact]
public void NRGProposeRemovePeer_ShouldSucceed()
{
var raft = new Raft { PIndex = 5 };
raft.ProposeRemovePeer("N2");
raft.Removed.ContainsKey("N2").ShouldBeTrue();
}
[Fact]
public void NRGProposeRemovePeerConcurrent_ShouldSucceed()
{
var raft = new Raft { PIndex = 10 };
Parallel.For(0, 4, i => raft.ProposeRemovePeer($"N{i}"));
raft.Removed.Count.ShouldBeGreaterThan(0);
}
[Fact]
public void NRGProposeRemovePeerQuorum_ShouldSucceed()
{
var raft = new Raft { Qn = 2, Csz = 3 };
raft.ProposeRemovePeer("N2");
raft.ClusterSize().ShouldBe(1);
}
[Fact]
public void NRGProposeRemovePeerLeader_ShouldSucceed()
{
var raft = new Raft { Id = "N1", StateValue = (int)RaftState.Leader };
raft.ProposeRemovePeer("N2");
raft.State().ShouldBe(RaftState.Leader);
}
[Fact]
public void NRGProposeRemovePeerAll_ShouldSucceed()
{
var raft = new Raft();
raft.ProposeRemovePeer("N2");
raft.ProposeRemovePeer("N3");
raft.Removed.Count.ShouldBe(2);
}
[Fact]
public void NRGLeaderResurrectsRemovedPeers_ShouldSucceed()
{
var raft = new Raft();
raft.ProposeRemovePeer("N2");
raft.ProposeAddPeer("N2");
raft.Peers_.ContainsKey("N2").ShouldBeTrue();
}
[Fact]
public void NRGAddPeers_ShouldSucceed()
{
var raft = new Raft();
raft.AddPeer("N2");
raft.AddPeer("N3");
raft.Peers_.Count.ShouldBe(2);
}
[Fact]
public void NRGDisjointMajorities_ShouldSucceed()
{
var raft = new Raft
{
Qn = 3,
Peers_ = new Dictionary<string, Lps>
{
["N2"] = new() { Ts = DateTime.UtcNow },
["N3"] = new() { Ts = DateTime.UtcNow },
},
};
raft.LostQuorum().ShouldBeFalse();
}
[Fact]
public void NRGSingleNodeElection_ShouldSucceed()
{
var raft = new Raft { Csz = 1, Qn = 1, StateValue = (int)RaftState.Follower };
raft.CampaignInternal(TimeSpan.FromMilliseconds(10)).ShouldBeNull();
raft.State().ShouldBe(RaftState.Candidate);
}
}
@@ -0,0 +1,205 @@
// Copyright 2012-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0
using Shouldly;
namespace ZB.MOM.NatsNet.Server.Tests.JetStream;
public sealed class RaftNodeCoreTests
{
[Fact]
public void SnapshotHelpers_WhenEncodedAndLoaded_ShouldRoundTrip()
{
var storeDir = Path.Combine(Path.GetTempPath(), $"raft-node-core-{Guid.NewGuid():N}");
var raft = new Raft
{
StoreDir = storeDir,
Term_ = 3,
Applied_ = 9,
PApplied = 7,
Wps = [1, 2, 3],
};
try
{
var checkpoint = raft.CreateSnapshotCheckpointLocked(force: true);
checkpoint.ShouldNotBeNull();
var snapshot = new Snapshot
{
LastTerm = 3,
LastIndex = 9,
PeerState = [7, 8],
Data = [9, 10, 11],
};
raft.InstallSnapshotInternal(snapshot).ShouldBeNull();
var (loaded, error) = raft.LoadLastSnapshot();
error.ShouldBeNull();
loaded.ShouldNotBeNull();
loaded!.LastTerm.ShouldBe(3UL);
loaded.LastIndex.ShouldBe(9UL);
loaded.PeerState.ShouldBe([7, 8]);
loaded.Data.ShouldBe([9, 10, 11]);
raft.SetupLastSnapshot().ShouldBeNull();
raft.PIndex.ShouldBe(9UL);
raft.PTerm.ShouldBe(3UL);
}
finally
{
if (Directory.Exists(storeDir))
{
Directory.Delete(storeDir, recursive: true);
}
}
}
[Fact]
public void LeadershipHelpers_WhenSteppingDownAndSelectingLeader_ShouldUpdateState()
{
var raft = new Raft
{
Id = "N1",
StateValue = (int)RaftState.Candidate,
Peers_ = new Dictionary<string, Lps>
{
["N2"] = new() { Li = 3 },
["N3"] = new() { Li = 7 },
},
};
raft.StepdownLocked("N3");
raft.State().ShouldBe(RaftState.Follower);
raft.LeaderId.ShouldBe("N3");
raft.SelectNextLeader().ShouldBe("N3");
}
[Fact]
public void CampaignHelpers_WhenLeaderOrFollower_ShouldReturnExpectedOutcome()
{
var raft = new Raft
{
StateValue = (int)RaftState.Leader,
};
raft.CampaignInternal(TimeSpan.FromMilliseconds(200)).ShouldNotBeNull();
raft.XferCampaign().ShouldNotBeNull();
raft.StateValue = (int)RaftState.Follower;
raft.CampaignInternal(TimeSpan.FromMilliseconds(200)).ShouldBeNull();
raft.State().ShouldBe(RaftState.Candidate);
}
[Fact]
public void ProgressHelpers_WhenCatchupAndKnownPeersChange_ShouldTrackFlags()
{
var raft = new Raft
{
Commit = 10,
Applied_ = 8,
Catchup = new CatchupState(),
};
raft.IsCatchingUp().ShouldBeTrue();
raft.IsCurrent(includeForwardProgress: true).ShouldBeFalse();
raft.Catchup = null;
raft.UpdateKnownPeersLocked(["N2", "N3"]);
raft.Peers_.Count.ShouldBe(2);
raft.RandCampaignTimeout().ShouldBeGreaterThan(TimeSpan.Zero);
}
[Fact]
public void RunLoopHelpers_WhenInvoked_ShouldManageSubscriptionAndTimers()
{
var raft = new Raft
{
Id = "N1",
GroupName = "RG",
Active = DateTime.UtcNow,
};
var inbox = raft.NewInbox();
var catchupInbox = raft.NewCatchupInbox();
inbox.ShouldContain("_INBOX.");
catchupInbox.ShouldContain("_INBOX.CATCHUP.");
var sub = raft.Subscribe("raft.append");
raft.AeSub.ShouldNotBeNull();
raft.Unsubscribe(sub);
raft.AeSub.ShouldBeNull();
raft.CreateInternalSubs().ShouldBeNull();
raft.ResetElectionTimeout();
raft.Elect.ShouldNotBeNull();
raft.ResetElect(TimeSpan.FromMilliseconds(10));
raft.ElectTimer().ShouldBeGreaterThan(DateTime.MinValue);
}
[Fact]
public void CodecHelpers_WhenRoundTrippingEntriesAndVotes_ShouldPreserveFields()
{
var raft = new Raft
{
GroupName = "RG",
};
var entry = raft.NewEntry(EntryType.EntryNormal, [1, 2, 3]);
var proposed = raft.NewProposedEntry(entry, "reply");
proposed.Reply.ShouldBe("reply");
var appendEntry = raft.NewAppendEntry("L1", 2, 1, 1, 0, [entry]);
appendEntry.String().ShouldContain("leader=L1");
appendEntry.ShouldStore().ShouldBeTrue();
var decodedAppend = raft.DecodeAppendEntry(appendEntry.Encode());
decodedAppend.Leader.ShouldBe("L1");
var appendResponse = raft.NewAppendEntryResponse(2, 1, "N2", "_R_", success: true);
var decodedResponse = raft.DecodeAppendEntryResponse(appendResponse.Encode());
decodedResponse.Success.ShouldBeTrue();
decodedResponse.Peer.ShouldBe("N2");
var voteRequest = new VoteRequest { TermV = 4, Candidate = "N3", LastIndex = 9, LastTerm = 3, Reply = "_R_" };
var decodedVoteRequest = raft.DecodeVoteRequest(voteRequest.Encode());
decodedVoteRequest.Candidate.ShouldBe("N3");
var voteResponse = new VoteResponse { TermV = 4, Peer = "N2", Granted = true, Empty = false };
var decodedVoteResponse = raft.DecodeVoteResponse(voteResponse.Encode());
decodedVoteResponse.Granted.ShouldBeTrue();
decodedVoteResponse.Peer.ShouldBe("N2");
}
[Fact]
public void PeerStatePersistence_WhenWrittenAndRead_ShouldRoundTrip()
{
var raft = new Raft();
var storeDir = Path.Combine(Path.GetTempPath(), $"raft-peer-state-{Guid.NewGuid():N}");
try
{
var state = new PeerState
{
KnownPeers = ["A1", "A2"],
ClusterSize = 2,
DomainExt = 7,
};
raft.PeerStateBufSize(state).ShouldBeGreaterThan(0);
raft.WritePeerStateStatic(storeDir, state).ShouldBeNull();
var (readState, readError) = raft.ReadPeerState(storeDir);
readError.ShouldBeNull();
readState.ShouldNotBeNull();
readState!.KnownPeers.Count.ShouldBe(2);
raft.WriteTermVoteStatic(storeDir, 6, "A1").ShouldBeNull();
raft.Term_.ShouldBe(6UL);
raft.Vote.ShouldBe("A1");
}
finally
{
if (Directory.Exists(storeDir))
{
Directory.Delete(storeDir, recursive: true);
}
}
}
}
@@ -17,6 +17,7 @@ public sealed class RaftTypesTests
Id = "N1", Id = "N1",
GroupName = "RG", GroupName = "RG",
AccName = "ACC", AccName = "ACC",
Server_ = new object(),
StateValue = (int)RaftState.Leader, StateValue = (int)RaftState.Leader,
LeaderId = "N1", LeaderId = "N1",
Csz = 3, Csz = 3,
@@ -137,4 +138,35 @@ public sealed class RaftTypesTests
checkpoint.Abort(); checkpoint.Abort();
File.Exists(checkpoint.SnapFile).ShouldBeFalse(); File.Exists(checkpoint.SnapFile).ShouldBeFalse();
} }
[Fact]
public void EntryAndPoolHelpers_ShouldReturnExpectedRepresentations()
{
EntryType.EntryAddPeer.String().ShouldBe("EntryAddPeer");
var committed = new CommittedEntry { Index = 2, Entries = [new Entry { Type = EntryType.EntryNormal, Data = [1] }] };
committed.ReturnToPool();
committed.Index.ShouldBe(0UL);
committed.Entries.ShouldBeEmpty();
var proposed = new ProposedEntry { Entry = new Entry { Type = EntryType.EntryNormal, Data = [2] }, Reply = "_R_" };
proposed.ReturnToPool();
proposed.Entry.ShouldBeNull();
proposed.Reply.ShouldBeEmpty();
var appendEntry = new AppendEntry
{
Leader = "N1",
TermV = 2,
Commit = 1,
PTerm = 1,
PIndex = 0,
Entries = [new Entry { Type = EntryType.EntryNormal, Data = [3] }],
Reply = "_R_",
};
appendEntry.ReturnToPool();
appendEntry.Leader.ShouldBeEmpty();
appendEntry.Entries.ShouldBeEmpty();
}
} }
@@ -0,0 +1,66 @@
// Copyright 2012-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0
using Shouldly;
namespace ZB.MOM.NatsNet.Server.Tests.Server;
public sealed class NatsServerRaftTests
{
[Fact]
public void RaftStateString_WhenKnownState_ReturnsExpectedText()
{
RaftState.Follower.String().ShouldBe("FOLLOWER");
RaftState.Candidate.String().ShouldBe("CANDIDATE");
RaftState.Leader.String().ShouldBe("LEADER");
RaftState.Closed.String().ShouldBe("CLOSED");
}
[Fact]
public void RegisterRaftNode_WhenRegisteredAndUnregistered_TracksLookupAndCount()
{
var (server, error) = NatsServer.NewServer(new ServerOptions());
error.ShouldBeNull();
server.ShouldNotBeNull();
var raftNode = new Raft { GroupName = "G1", Id = "N1" };
server!.RegisterRaftNode("G1", raftNode);
server.NumRaftNodes().ShouldBe(1);
server.LookupRaftNode("G1").ShouldBe(raftNode);
server.UnregisterRaftNode("G1");
server.NumRaftNodes().ShouldBe(0);
server.LookupRaftNode("G1").ShouldBeNull();
}
[Fact]
public void BootstrapRaftNode_WhenStoreMissing_CreatesStoreAndPeerState()
{
var (server, error) = NatsServer.NewServer(new ServerOptions());
error.ShouldBeNull();
server.ShouldNotBeNull();
var storeDir = Path.Combine(Path.GetTempPath(), $"raft-bootstrap-{Guid.NewGuid():N}");
var cfg = new RaftConfig
{
Name = "RG",
Store = storeDir,
};
try
{
var bootstrapError = server!.BootstrapRaftNode(cfg, ["ABCDEF12", "ABCDEF34"], allPeersKnown: true);
bootstrapError.ShouldBeNull();
Directory.Exists(storeDir).ShouldBeTrue();
File.Exists(Path.Combine(storeDir, "peerstate.json")).ShouldBeTrue();
}
finally
{
if (Directory.Exists(storeDir))
{
Directory.Delete(storeDir, recursive: true);
}
}
}
}
BIN
View File
Binary file not shown.
+6 -6
View File
@@ -1,6 +1,6 @@
# NATS .NET Porting Status Report # NATS .NET Porting Status Report
Generated: 2026-03-01 00:54:52 UTC Generated: 2026-03-01 01:28:40 UTC
## Modules (12 total) ## Modules (12 total)
@@ -13,18 +13,18 @@ Generated: 2026-03-01 00:54:52 UTC
| Status | Count | | Status | Count |
|--------|-------| |--------|-------|
| complete | 22 | | complete | 22 |
| deferred | 1657 | | deferred | 1572 |
| n_a | 24 | | n_a | 24 |
| stub | 1 | | stub | 1 |
| verified | 1969 | | verified | 2054 |
## Unit Tests (3257 total) ## Unit Tests (3257 total)
| Status | Count | | Status | Count |
|--------|-------| |--------|-------|
| deferred | 1633 | | deferred | 1614 |
| n_a | 254 | | n_a | 254 |
| verified | 1370 | | verified | 1389 |
## Library Mappings (36 total) ## Library Mappings (36 total)
@@ -35,4 +35,4 @@ Generated: 2026-03-01 00:54:52 UTC
## Overall Progress ## Overall Progress
**3651/6942 items complete (52.6%)** **3755/6942 items complete (54.1%)**