Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db5240331a | |||
| 59a924746a | |||
| ac9934480f | |||
| 8dd5d38415 | |||
| 00c2799323 | |||
| 8bc4dfa58c | |||
| ed1b62d6a3 | |||
| 492030bd4b | |||
| a5b4dd39b1 |
@@ -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 canReplay;
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
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
Binary file not shown.
+6
-6
@@ -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%)**
|
||||||
|
|||||||
Reference in New Issue
Block a user