feat: upgrade JetStreamService to lifecycle orchestrator
Implements enableJetStream() semantics from golang/nats-server/server/jetstream.go:414-523. - JetStreamService.StartAsync(): validates config, creates store directory (including nested paths via Directory.CreateDirectory), registers all $JS.API.> subjects, logs startup stats; idempotent on double-start - JetStreamService.DisposeAsync(): clears registered subjects, marks not running - New properties: RegisteredApiSubjects, MaxStreams, MaxConsumers, MaxMemory, MaxStore - JetStreamOptions: adds MaxStreams and MaxConsumers limits (0 = unlimited) - FileStoreConfig: removes duplicate StoreCipher/StoreCompression enum declarations now that AeadEncryptor.cs owns them; updates defaults to NoCipher/NoCompression - FileStoreOptions/FileStore: align enum member names with AeadEncryptor.cs (NoCipher, NoCompression, S2Compression) to fix cross-task naming conflict - 13 new tests in JetStreamServiceOrchestrationTests covering all lifecycle paths
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
// Ported from golang/nats-server/server/jetstream.go:414-523 (enableJetStream)
|
||||
// Tests for JetStreamService lifecycle orchestration: store directory creation,
|
||||
// API subject registration, configuration property exposure, and dispose semantics.
|
||||
|
||||
using NATS.Server.Configuration;
|
||||
using NATS.Server.JetStream;
|
||||
|
||||
namespace NATS.Server.Tests.JetStream;
|
||||
|
||||
public sealed class JetStreamServiceOrchestrationTests : IDisposable
|
||||
{
|
||||
private readonly List<string> _tempDirs = [];
|
||||
|
||||
private string MakeTempDir()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), "nats-js-test-" + Guid.NewGuid().ToString("N"));
|
||||
_tempDirs.Add(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var dir in _tempDirs)
|
||||
{
|
||||
if (Directory.Exists(dir))
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
// Go: enableJetStream — jetstream.go:414 — happy path creates store dir and marks running
|
||||
[Fact]
|
||||
public async Task StartAsync_creates_store_directory_and_marks_running()
|
||||
{
|
||||
var storeDir = MakeTempDir();
|
||||
var options = new JetStreamOptions { StoreDir = storeDir };
|
||||
await using var svc = new JetStreamService(options);
|
||||
|
||||
Directory.Exists(storeDir).ShouldBeFalse("directory must not exist before start");
|
||||
|
||||
await svc.StartAsync(CancellationToken.None);
|
||||
|
||||
svc.IsRunning.ShouldBeTrue();
|
||||
Directory.Exists(storeDir).ShouldBeTrue("StartAsync must create the store directory");
|
||||
}
|
||||
|
||||
// Go: enableJetStream — jetstream.go:430 — existing dir is accepted without error
|
||||
[Fact]
|
||||
public async Task StartAsync_accepts_preexisting_store_directory()
|
||||
{
|
||||
var storeDir = MakeTempDir();
|
||||
Directory.CreateDirectory(storeDir);
|
||||
var options = new JetStreamOptions { StoreDir = storeDir };
|
||||
await using var svc = new JetStreamService(options);
|
||||
|
||||
await svc.StartAsync(CancellationToken.None);
|
||||
|
||||
svc.IsRunning.ShouldBeTrue();
|
||||
Directory.Exists(storeDir).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Go: enableJetStream — memory-only mode when StoreDir is empty
|
||||
[Fact]
|
||||
public async Task StartAsync_with_empty_StoreDir_starts_in_memory_only_mode()
|
||||
{
|
||||
var options = new JetStreamOptions { StoreDir = string.Empty };
|
||||
await using var svc = new JetStreamService(options);
|
||||
|
||||
await svc.StartAsync(CancellationToken.None);
|
||||
|
||||
svc.IsRunning.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Go: setJetStreamExportSubs — jetstream.go:489 — all $JS.API subjects registered
|
||||
[Fact]
|
||||
public async Task RegisteredApiSubjects_contains_expected_subjects_after_start()
|
||||
{
|
||||
var options = new JetStreamOptions();
|
||||
await using var svc = new JetStreamService(options);
|
||||
|
||||
await svc.StartAsync(CancellationToken.None);
|
||||
|
||||
var subjects = svc.RegisteredApiSubjects;
|
||||
subjects.ShouldNotBeEmpty();
|
||||
subjects.ShouldContain("$JS.API.>");
|
||||
subjects.ShouldContain("$JS.API.INFO");
|
||||
subjects.ShouldContain("$JS.API.META.LEADER.STEPDOWN");
|
||||
subjects.ShouldContain("$JS.API.STREAM.NAMES");
|
||||
subjects.ShouldContain("$JS.API.STREAM.LIST");
|
||||
}
|
||||
|
||||
// Go: setJetStreamExportSubs — all consumer-related wildcards registered
|
||||
[Fact]
|
||||
public async Task RegisteredApiSubjects_includes_consumer_and_stream_wildcard_subjects()
|
||||
{
|
||||
var options = new JetStreamOptions();
|
||||
await using var svc = new JetStreamService(options);
|
||||
|
||||
await svc.StartAsync(CancellationToken.None);
|
||||
|
||||
var subjects = svc.RegisteredApiSubjects;
|
||||
|
||||
// Stream management
|
||||
subjects.ShouldContain(s => s.StartsWith("$JS.API.STREAM.CREATE."), "stream create wildcard");
|
||||
subjects.ShouldContain(s => s.StartsWith("$JS.API.STREAM.DELETE."), "stream delete wildcard");
|
||||
subjects.ShouldContain(s => s.StartsWith("$JS.API.STREAM.INFO."), "stream info wildcard");
|
||||
subjects.ShouldContain(s => s.StartsWith("$JS.API.STREAM.UPDATE."), "stream update wildcard");
|
||||
subjects.ShouldContain(s => s.StartsWith("$JS.API.STREAM.PURGE."), "stream purge wildcard");
|
||||
subjects.ShouldContain(s => s.StartsWith("$JS.API.STREAM.MSG.GET."), "stream msg get wildcard");
|
||||
subjects.ShouldContain(s => s.StartsWith("$JS.API.STREAM.MSG.DELETE."), "stream msg delete wildcard");
|
||||
subjects.ShouldContain(s => s.StartsWith("$JS.API.STREAM.SNAPSHOT."), "stream snapshot wildcard");
|
||||
subjects.ShouldContain(s => s.StartsWith("$JS.API.STREAM.RESTORE."), "stream restore wildcard");
|
||||
subjects.ShouldContain(s => s.StartsWith("$JS.API.STREAM.LEADER.STEPDOWN."), "stream leader stepdown wildcard");
|
||||
|
||||
// Consumer management
|
||||
subjects.ShouldContain(s => s.StartsWith("$JS.API.CONSUMER.CREATE."), "consumer create wildcard");
|
||||
subjects.ShouldContain(s => s.StartsWith("$JS.API.CONSUMER.DELETE."), "consumer delete wildcard");
|
||||
subjects.ShouldContain(s => s.StartsWith("$JS.API.CONSUMER.INFO."), "consumer info wildcard");
|
||||
subjects.ShouldContain(s => s.StartsWith("$JS.API.CONSUMER.NAMES."), "consumer names wildcard");
|
||||
subjects.ShouldContain(s => s.StartsWith("$JS.API.CONSUMER.LIST."), "consumer list wildcard");
|
||||
subjects.ShouldContain(s => s.StartsWith("$JS.API.CONSUMER.PAUSE."), "consumer pause wildcard");
|
||||
subjects.ShouldContain(s => s.StartsWith("$JS.API.CONSUMER.MSG.NEXT."), "consumer msg next wildcard");
|
||||
|
||||
// Direct get
|
||||
subjects.ShouldContain(s => s.StartsWith("$JS.API.DIRECT.GET."), "direct get wildcard");
|
||||
}
|
||||
|
||||
// RegisteredApiSubjects should be empty before start
|
||||
[Fact]
|
||||
public void RegisteredApiSubjects_is_empty_before_start()
|
||||
{
|
||||
var options = new JetStreamOptions();
|
||||
var svc = new JetStreamService(options);
|
||||
|
||||
svc.RegisteredApiSubjects.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// Go: shutdown path — DisposeAsync clears subjects and marks not running
|
||||
[Fact]
|
||||
public async Task DisposeAsync_clears_subjects_and_marks_not_running()
|
||||
{
|
||||
var options = new JetStreamOptions();
|
||||
var svc = new JetStreamService(options);
|
||||
|
||||
await svc.StartAsync(CancellationToken.None);
|
||||
svc.IsRunning.ShouldBeTrue();
|
||||
svc.RegisteredApiSubjects.ShouldNotBeEmpty();
|
||||
|
||||
await svc.DisposeAsync();
|
||||
|
||||
svc.IsRunning.ShouldBeFalse();
|
||||
svc.RegisteredApiSubjects.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// MaxStreams and MaxConsumers reflect config values
|
||||
[Fact]
|
||||
public async Task MaxStreams_and_MaxConsumers_reflect_config_values()
|
||||
{
|
||||
var options = new JetStreamOptions
|
||||
{
|
||||
MaxStreams = 100,
|
||||
MaxConsumers = 500,
|
||||
};
|
||||
await using var svc = new JetStreamService(options);
|
||||
|
||||
await svc.StartAsync(CancellationToken.None);
|
||||
|
||||
svc.MaxStreams.ShouldBe(100);
|
||||
svc.MaxConsumers.ShouldBe(500);
|
||||
}
|
||||
|
||||
// MaxMemory and MaxStore reflect config values
|
||||
[Fact]
|
||||
public async Task MaxMemory_and_MaxStore_reflect_config_values()
|
||||
{
|
||||
var options = new JetStreamOptions
|
||||
{
|
||||
MaxMemoryStore = 1_073_741_824L, // 1 GiB
|
||||
MaxFileStore = 10_737_418_240L, // 10 GiB
|
||||
};
|
||||
await using var svc = new JetStreamService(options);
|
||||
|
||||
await svc.StartAsync(CancellationToken.None);
|
||||
|
||||
svc.MaxMemory.ShouldBe(1_073_741_824L);
|
||||
svc.MaxStore.ShouldBe(10_737_418_240L);
|
||||
}
|
||||
|
||||
// Default config values are zero (unlimited)
|
||||
[Fact]
|
||||
public void Default_config_values_are_unlimited_zero()
|
||||
{
|
||||
var options = new JetStreamOptions();
|
||||
var svc = new JetStreamService(options);
|
||||
|
||||
svc.MaxStreams.ShouldBe(0);
|
||||
svc.MaxConsumers.ShouldBe(0);
|
||||
svc.MaxMemory.ShouldBe(0L);
|
||||
svc.MaxStore.ShouldBe(0L);
|
||||
}
|
||||
|
||||
// Go: enableJetStream idempotency — double-start is safe (not an error)
|
||||
[Fact]
|
||||
public async Task Double_start_is_idempotent()
|
||||
{
|
||||
var options = new JetStreamOptions();
|
||||
await using var svc = new JetStreamService(options);
|
||||
|
||||
await svc.StartAsync(CancellationToken.None);
|
||||
var subjectCountAfterFirst = svc.RegisteredApiSubjects.Count;
|
||||
|
||||
// Second start must not throw and must not duplicate subjects
|
||||
await svc.StartAsync(CancellationToken.None);
|
||||
|
||||
svc.IsRunning.ShouldBeTrue();
|
||||
svc.RegisteredApiSubjects.Count.ShouldBe(subjectCountAfterFirst);
|
||||
}
|
||||
|
||||
// Store directory is created with a nested path (MkdirAll semantics)
|
||||
[Fact]
|
||||
public async Task StartAsync_creates_nested_store_directory()
|
||||
{
|
||||
var baseDir = MakeTempDir();
|
||||
var nestedDir = Path.Combine(baseDir, "level1", "level2", "jetstream");
|
||||
var options = new JetStreamOptions { StoreDir = nestedDir };
|
||||
await using var svc = new JetStreamService(options);
|
||||
|
||||
await svc.StartAsync(CancellationToken.None);
|
||||
|
||||
svc.IsRunning.ShouldBeTrue();
|
||||
Directory.Exists(nestedDir).ShouldBeTrue("nested store directory must be created");
|
||||
}
|
||||
|
||||
// Service is not running before start
|
||||
[Fact]
|
||||
public void IsRunning_is_false_before_start()
|
||||
{
|
||||
var options = new JetStreamOptions();
|
||||
var svc = new JetStreamService(options);
|
||||
|
||||
svc.IsRunning.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
519
tests/NATS.Server.Tests/Raft/RaftBinaryWireFormatTests.cs
Normal file
519
tests/NATS.Server.Tests/Raft/RaftBinaryWireFormatTests.cs
Normal file
@@ -0,0 +1,519 @@
|
||||
using NATS.Server.Raft;
|
||||
|
||||
namespace NATS.Server.Tests.Raft;
|
||||
|
||||
/// <summary>
|
||||
/// Binary wire format encoding/decoding tests for all RAFT RPC types.
|
||||
/// These validate exact byte-for-byte fidelity with Go's raft.go encoding.
|
||||
/// Go reference: golang/nats-server/server/raft.go lines 2662-2796 (AppendEntry),
|
||||
/// 4560-4768 (vote types).
|
||||
/// </summary>
|
||||
public class RaftBinaryWireFormatTests
|
||||
{
|
||||
// ---------------------------------------------------------------------------
|
||||
// VoteRequest
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Go: server/raft.go:4560-4568 — voteRequest.encode()
|
||||
// Go: server/raft.go:4571-4583 — decodeVoteRequest()
|
||||
[Fact]
|
||||
public void VoteRequest_round_trip_encode_decode()
|
||||
{
|
||||
var original = new RaftVoteRequestWire(
|
||||
Term: 7,
|
||||
LastTerm: 3,
|
||||
LastIndex: 42,
|
||||
CandidateId: "peer0001");
|
||||
|
||||
var encoded = original.Encode();
|
||||
encoded.Length.ShouldBe(RaftWireConstants.VoteRequestLen); // 32 bytes
|
||||
|
||||
var decoded = RaftVoteRequestWire.Decode(encoded);
|
||||
decoded.Term.ShouldBe(7UL);
|
||||
decoded.LastTerm.ShouldBe(3UL);
|
||||
decoded.LastIndex.ShouldBe(42UL);
|
||||
decoded.CandidateId.ShouldBe("peer0001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VoteRequest_bytes_are_little_endian()
|
||||
{
|
||||
var req = new RaftVoteRequestWire(Term: 1, LastTerm: 0, LastIndex: 0, CandidateId: "");
|
||||
var bytes = req.Encode();
|
||||
// term = 1 in little-endian: [1, 0, 0, 0, 0, 0, 0, 0]
|
||||
// Go: server/raft.go:4563 — le.PutUint64(buf[0:], vr.term)
|
||||
bytes[0].ShouldBe((byte)1);
|
||||
bytes[1].ShouldBe((byte)0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VoteRequest_zero_values_encode_to_zeroed_buffer()
|
||||
{
|
||||
var req = new RaftVoteRequestWire(Term: 0, LastTerm: 0, LastIndex: 0, CandidateId: "");
|
||||
var bytes = req.Encode();
|
||||
bytes.Length.ShouldBe(32);
|
||||
bytes.ShouldAllBe(b => b == 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VoteRequest_large_term_round_trips()
|
||||
{
|
||||
var req = new RaftVoteRequestWire(
|
||||
Term: ulong.MaxValue,
|
||||
LastTerm: ulong.MaxValue - 1,
|
||||
LastIndex: ulong.MaxValue - 2,
|
||||
CandidateId: "node1234");
|
||||
|
||||
var decoded = RaftVoteRequestWire.Decode(req.Encode());
|
||||
decoded.Term.ShouldBe(ulong.MaxValue);
|
||||
decoded.LastTerm.ShouldBe(ulong.MaxValue - 1);
|
||||
decoded.LastIndex.ShouldBe(ulong.MaxValue - 2);
|
||||
decoded.CandidateId.ShouldBe("node1234");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VoteRequest_short_buffer_throws_ArgumentException()
|
||||
{
|
||||
var shortBuffer = new byte[RaftWireConstants.VoteRequestLen - 1];
|
||||
Should.Throw<ArgumentException>(() => RaftVoteRequestWire.Decode(shortBuffer));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VoteRequest_long_buffer_throws_ArgumentException()
|
||||
{
|
||||
var longBuffer = new byte[RaftWireConstants.VoteRequestLen + 1];
|
||||
Should.Throw<ArgumentException>(() => RaftVoteRequestWire.Decode(longBuffer));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VoteRequest_candidate_id_truncated_to_8_bytes()
|
||||
{
|
||||
// IDs longer than 8 chars are silently truncated (Go copy semantics).
|
||||
// Go: server/raft.go:4566 — copy(buf[24:24+idLen], vr.candidate)
|
||||
var req = new RaftVoteRequestWire(
|
||||
Term: 1, LastTerm: 0, LastIndex: 0,
|
||||
CandidateId: "abcdefghXXXXXXXX"); // 16 chars; only first 8 kept
|
||||
|
||||
var bytes = req.Encode();
|
||||
// Check that the ID field contains only the first 8 chars.
|
||||
var idBytes = bytes[24..32];
|
||||
System.Text.Encoding.ASCII.GetString(idBytes).ShouldBe("abcdefgh");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VoteRequest_short_candidate_id_zero_padded()
|
||||
{
|
||||
var req = new RaftVoteRequestWire(
|
||||
Term: 1, LastTerm: 0, LastIndex: 0, CandidateId: "abc");
|
||||
|
||||
var bytes = req.Encode();
|
||||
bytes[27].ShouldBe((byte)0); // byte 3..7 should be zero
|
||||
bytes[28].ShouldBe((byte)0);
|
||||
|
||||
// Decode should recover the original 3-char ID.
|
||||
var decoded = RaftVoteRequestWire.Decode(bytes);
|
||||
decoded.CandidateId.ShouldBe("abc");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VoteResponse
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Go: server/raft.go:4739-4751 — voteResponse.encode()
|
||||
// Go: server/raft.go:4753-4762 — decodeVoteResponse()
|
||||
[Fact]
|
||||
public void VoteResponse_granted_true_round_trip()
|
||||
{
|
||||
var resp = new RaftVoteResponseWire(Term: 5, PeerId: "peer0002", Granted: true);
|
||||
var decoded = RaftVoteResponseWire.Decode(resp.Encode());
|
||||
|
||||
decoded.Term.ShouldBe(5UL);
|
||||
decoded.PeerId.ShouldBe("peer0002");
|
||||
decoded.Granted.ShouldBeTrue();
|
||||
decoded.Empty.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VoteResponse_granted_false_round_trip()
|
||||
{
|
||||
var resp = new RaftVoteResponseWire(Term: 3, PeerId: "peer0003", Granted: false);
|
||||
var decoded = RaftVoteResponseWire.Decode(resp.Encode());
|
||||
|
||||
decoded.Granted.ShouldBeFalse();
|
||||
decoded.PeerId.ShouldBe("peer0003");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VoteResponse_empty_flag_round_trip()
|
||||
{
|
||||
// Go: server/raft.go:4746-4748 — buf[16] |= 2 when empty
|
||||
var resp = new RaftVoteResponseWire(Term: 1, PeerId: "p1", Granted: false, Empty: true);
|
||||
var decoded = RaftVoteResponseWire.Decode(resp.Encode());
|
||||
|
||||
decoded.Empty.ShouldBeTrue();
|
||||
decoded.Granted.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VoteResponse_both_flags_set()
|
||||
{
|
||||
var resp = new RaftVoteResponseWire(Term: 1, PeerId: "p1", Granted: true, Empty: true);
|
||||
var bytes = resp.Encode();
|
||||
|
||||
// Go: server/raft.go:4744-4748 — bit 0 = granted, bit 1 = empty
|
||||
(bytes[16] & 1).ShouldBe(1); // granted
|
||||
(bytes[16] & 2).ShouldBe(2); // empty
|
||||
|
||||
var decoded = RaftVoteResponseWire.Decode(bytes);
|
||||
decoded.Granted.ShouldBeTrue();
|
||||
decoded.Empty.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VoteResponse_fixed_17_bytes()
|
||||
{
|
||||
var resp = new RaftVoteResponseWire(Term: 10, PeerId: "peer0001", Granted: true);
|
||||
resp.Encode().Length.ShouldBe(RaftWireConstants.VoteResponseLen); // 17
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VoteResponse_short_buffer_throws_ArgumentException()
|
||||
{
|
||||
var shortBuffer = new byte[RaftWireConstants.VoteResponseLen - 1];
|
||||
Should.Throw<ArgumentException>(() => RaftVoteResponseWire.Decode(shortBuffer));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VoteResponse_peer_id_truncated_to_8_bytes()
|
||||
{
|
||||
// Go: server/raft.go:4743 — copy(buf[8:], vr.peer)
|
||||
var resp = new RaftVoteResponseWire(
|
||||
Term: 1, PeerId: "longpeernamethatexceeds8chars", Granted: true);
|
||||
var bytes = resp.Encode();
|
||||
|
||||
// Bytes [8..15] hold the peer ID — only first 8 chars fit.
|
||||
var idBytes = bytes[8..16];
|
||||
System.Text.Encoding.ASCII.GetString(idBytes).ShouldBe("longpeer");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AppendEntry — zero entries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Go: server/raft.go:2662-2711 — appendEntry.encode()
|
||||
// Go: server/raft.go:2714-2746 — decodeAppendEntry()
|
||||
[Fact]
|
||||
public void AppendEntry_zero_entries_round_trip()
|
||||
{
|
||||
var ae = new RaftAppendEntryWire(
|
||||
LeaderId: "lead0001",
|
||||
Term: 10,
|
||||
Commit: 8,
|
||||
PrevTerm: 9,
|
||||
PrevIndex: 7,
|
||||
Entries: [],
|
||||
LeaderTerm: 0);
|
||||
|
||||
var encoded = ae.Encode();
|
||||
// Base length + 1-byte uvarint(0) for lterm.
|
||||
// Go: server/raft.go:2681-2683 — lterm uvarint always appended
|
||||
encoded.Length.ShouldBe(RaftWireConstants.AppendEntryBaseLen + 1);
|
||||
|
||||
var decoded = RaftAppendEntryWire.Decode(encoded);
|
||||
decoded.LeaderId.ShouldBe("lead0001");
|
||||
decoded.Term.ShouldBe(10UL);
|
||||
decoded.Commit.ShouldBe(8UL);
|
||||
decoded.PrevTerm.ShouldBe(9UL);
|
||||
decoded.PrevIndex.ShouldBe(7UL);
|
||||
decoded.Entries.Count.ShouldBe(0);
|
||||
decoded.LeaderTerm.ShouldBe(0UL);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppendEntry_base_layout_at_correct_offsets()
|
||||
{
|
||||
// Go: server/raft.go:2693-2698 — exact layout:
|
||||
// [0..7]=leader [8..15]=term [16..23]=commit [24..31]=pterm [32..39]=pindex [40..41]=entryCount
|
||||
var ae = new RaftAppendEntryWire(
|
||||
LeaderId: "AAAAAAAA", // 0x41 x 8
|
||||
Term: 1,
|
||||
Commit: 2,
|
||||
PrevTerm: 3,
|
||||
PrevIndex: 4,
|
||||
Entries: []);
|
||||
|
||||
var bytes = ae.Encode();
|
||||
|
||||
// leader bytes
|
||||
bytes[0].ShouldBe((byte)'A');
|
||||
bytes[7].ShouldBe((byte)'A');
|
||||
|
||||
// term = 1 LE
|
||||
bytes[8].ShouldBe((byte)1);
|
||||
bytes[9].ShouldBe((byte)0);
|
||||
|
||||
// commit = 2 LE
|
||||
bytes[16].ShouldBe((byte)2);
|
||||
|
||||
// entryCount = 0
|
||||
bytes[40].ShouldBe((byte)0);
|
||||
bytes[41].ShouldBe((byte)0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AppendEntry — single entry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void AppendEntry_single_entry_round_trip()
|
||||
{
|
||||
var data = "hello world"u8.ToArray();
|
||||
var entry = new RaftEntryWire(RaftEntryType.Normal, data);
|
||||
|
||||
var ae = new RaftAppendEntryWire(
|
||||
LeaderId: "leader01",
|
||||
Term: 5,
|
||||
Commit: 3,
|
||||
PrevTerm: 4,
|
||||
PrevIndex: 2,
|
||||
Entries: [entry]);
|
||||
|
||||
var decoded = RaftAppendEntryWire.Decode(ae.Encode());
|
||||
decoded.Entries.Count.ShouldBe(1);
|
||||
decoded.Entries[0].Type.ShouldBe(RaftEntryType.Normal);
|
||||
decoded.Entries[0].Data.ShouldBe(data);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppendEntry_entry_size_field_equals_1_plus_data_length()
|
||||
{
|
||||
// Go: server/raft.go:2702 — le.AppendUint32(buf, uint32(1+len(e.Data)))
|
||||
var data = new byte[10];
|
||||
var entry = new RaftEntryWire(RaftEntryType.PeerState, data);
|
||||
|
||||
var ae = new RaftAppendEntryWire(
|
||||
LeaderId: "ld", Term: 1, Commit: 0, PrevTerm: 0, PrevIndex: 0,
|
||||
Entries: [entry]);
|
||||
|
||||
var bytes = ae.Encode();
|
||||
|
||||
// Entry starts at offset 42 (appendEntryBaseLen).
|
||||
// First 4 bytes are the uint32 size = 1 + 10 = 11.
|
||||
var sizeField = System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(42));
|
||||
sizeField.ShouldBe(11u);
|
||||
|
||||
// Byte at offset 46 is the entry type.
|
||||
bytes[46].ShouldBe((byte)RaftEntryType.PeerState);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AppendEntry — multiple entries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void AppendEntry_multiple_entries_round_trip()
|
||||
{
|
||||
var entries = new RaftEntryWire[]
|
||||
{
|
||||
new(RaftEntryType.Normal, "first"u8.ToArray()),
|
||||
new(RaftEntryType.AddPeer, "second"u8.ToArray()),
|
||||
new(RaftEntryType.RemovePeer, "third"u8.ToArray()),
|
||||
};
|
||||
|
||||
var ae = new RaftAppendEntryWire(
|
||||
LeaderId: "lead0001",
|
||||
Term: 20,
|
||||
Commit: 15,
|
||||
PrevTerm: 19,
|
||||
PrevIndex: 14,
|
||||
Entries: entries);
|
||||
|
||||
var decoded = RaftAppendEntryWire.Decode(ae.Encode());
|
||||
decoded.Entries.Count.ShouldBe(3);
|
||||
|
||||
decoded.Entries[0].Type.ShouldBe(RaftEntryType.Normal);
|
||||
decoded.Entries[0].Data.ShouldBe("first"u8.ToArray());
|
||||
|
||||
decoded.Entries[1].Type.ShouldBe(RaftEntryType.AddPeer);
|
||||
decoded.Entries[1].Data.ShouldBe("second"u8.ToArray());
|
||||
|
||||
decoded.Entries[2].Type.ShouldBe(RaftEntryType.RemovePeer);
|
||||
decoded.Entries[2].Data.ShouldBe("third"u8.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppendEntry_50_entries_preserve_order()
|
||||
{
|
||||
var entries = Enumerable.Range(0, 50)
|
||||
.Select(i => new RaftEntryWire(RaftEntryType.Normal, [(byte)i]))
|
||||
.ToArray();
|
||||
|
||||
var ae = new RaftAppendEntryWire(
|
||||
LeaderId: "lead0001", Term: 1, Commit: 0, PrevTerm: 0, PrevIndex: 0,
|
||||
Entries: entries);
|
||||
|
||||
var decoded = RaftAppendEntryWire.Decode(ae.Encode());
|
||||
decoded.Entries.Count.ShouldBe(50);
|
||||
|
||||
for (var i = 0; i < 50; i++)
|
||||
decoded.Entries[i].Data[0].ShouldBe((byte)i);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppendEntry_entry_with_empty_data_round_trips()
|
||||
{
|
||||
var entry = new RaftEntryWire(RaftEntryType.LeaderTransfer, []);
|
||||
var ae = new RaftAppendEntryWire(
|
||||
LeaderId: "ld", Term: 1, Commit: 0, PrevTerm: 0, PrevIndex: 0,
|
||||
Entries: [entry]);
|
||||
|
||||
var decoded = RaftAppendEntryWire.Decode(ae.Encode());
|
||||
decoded.Entries.Count.ShouldBe(1);
|
||||
decoded.Entries[0].Data.Length.ShouldBe(0);
|
||||
decoded.Entries[0].Type.ShouldBe(RaftEntryType.LeaderTransfer);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AppendEntry — leaderTerm (uvarint tail)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Go: server/raft.go:2709 — buf = append(buf, lterm...)
|
||||
// Go: server/raft.go:2740-2743 — if lterm, n := binary.Uvarint(msg[ri:]); n > 0 ...
|
||||
[Theory]
|
||||
[InlineData(0UL)]
|
||||
[InlineData(1UL)]
|
||||
[InlineData(127UL)]
|
||||
[InlineData(128UL)]
|
||||
[InlineData(ulong.MaxValue)]
|
||||
public void AppendEntry_leader_term_uvarint_round_trips(ulong lterm)
|
||||
{
|
||||
var ae = new RaftAppendEntryWire(
|
||||
LeaderId: "lead0001", Term: 5, Commit: 3, PrevTerm: 4, PrevIndex: 2,
|
||||
Entries: [],
|
||||
LeaderTerm: lterm);
|
||||
|
||||
var decoded = RaftAppendEntryWire.Decode(ae.Encode());
|
||||
decoded.LeaderTerm.ShouldBe(lterm);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AppendEntry — error cases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void AppendEntry_short_buffer_throws_ArgumentException()
|
||||
{
|
||||
// Buffer smaller than appendEntryBaseLen (42 bytes).
|
||||
var shortBuffer = new byte[RaftWireConstants.AppendEntryBaseLen - 1];
|
||||
Should.Throw<ArgumentException>(() => RaftAppendEntryWire.Decode(shortBuffer));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AppendEntryResponse
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Go: server/raft.go:2777-2794 — appendEntryResponse.encode()
|
||||
// Go: server/raft.go:2799-2817 — decodeAppendEntryResponse()
|
||||
[Fact]
|
||||
public void AppendEntryResponse_success_true_round_trip()
|
||||
{
|
||||
var resp = new RaftAppendEntryResponseWire(
|
||||
Term: 12, Index: 99, PeerId: "follwr01", Success: true);
|
||||
|
||||
var encoded = resp.Encode();
|
||||
encoded.Length.ShouldBe(RaftWireConstants.AppendEntryResponseLen); // 25
|
||||
|
||||
var decoded = RaftAppendEntryResponseWire.Decode(encoded);
|
||||
decoded.Term.ShouldBe(12UL);
|
||||
decoded.Index.ShouldBe(99UL);
|
||||
decoded.PeerId.ShouldBe("follwr01");
|
||||
decoded.Success.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppendEntryResponse_success_false_round_trip()
|
||||
{
|
||||
var resp = new RaftAppendEntryResponseWire(
|
||||
Term: 3, Index: 1, PeerId: "follwr02", Success: false);
|
||||
|
||||
var decoded = RaftAppendEntryResponseWire.Decode(resp.Encode());
|
||||
decoded.Success.ShouldBeFalse();
|
||||
decoded.PeerId.ShouldBe("follwr02");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppendEntryResponse_success_byte_is_0_or_1()
|
||||
{
|
||||
// Go: server/raft.go:2815 — ar.success = msg[24] == 1
|
||||
var yes = new RaftAppendEntryResponseWire(Term: 1, Index: 0, PeerId: "p", Success: true);
|
||||
var no = new RaftAppendEntryResponseWire(Term: 1, Index: 0, PeerId: "p", Success: false);
|
||||
|
||||
yes.Encode()[24].ShouldBe((byte)1);
|
||||
no.Encode()[24].ShouldBe((byte)0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppendEntryResponse_layout_at_correct_offsets()
|
||||
{
|
||||
// Go: server/raft.go:2784-2792 — exact layout:
|
||||
// [0..7]=term [8..15]=index [16..23]=peer [24]=success
|
||||
var resp = new RaftAppendEntryResponseWire(
|
||||
Term: 1, Index: 2, PeerId: "BBBBBBBB", Success: true);
|
||||
var bytes = resp.Encode();
|
||||
|
||||
bytes[0].ShouldBe((byte)1); // term LE
|
||||
bytes[8].ShouldBe((byte)2); // index LE
|
||||
bytes[16].ShouldBe((byte)'B'); // peer[0]
|
||||
bytes[24].ShouldBe((byte)1); // success = 1
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppendEntryResponse_short_buffer_throws_ArgumentException()
|
||||
{
|
||||
var shortBuffer = new byte[RaftWireConstants.AppendEntryResponseLen - 1];
|
||||
Should.Throw<ArgumentException>(() => RaftAppendEntryResponseWire.Decode(shortBuffer));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppendEntryResponse_long_buffer_throws_ArgumentException()
|
||||
{
|
||||
var longBuffer = new byte[RaftWireConstants.AppendEntryResponseLen + 1];
|
||||
Should.Throw<ArgumentException>(() => RaftAppendEntryResponseWire.Decode(longBuffer));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppendEntryResponse_peer_id_truncated_to_8_bytes()
|
||||
{
|
||||
// Go: server/raft.go:2787 — copy(buf[16:16+idLen], ar.peer)
|
||||
var resp = new RaftAppendEntryResponseWire(
|
||||
Term: 1, Index: 0,
|
||||
PeerId: "verylongpeeridthatexceeds8", Success: false);
|
||||
|
||||
var bytes = resp.Encode();
|
||||
var idBytes = bytes[16..24];
|
||||
System.Text.Encoding.ASCII.GetString(idBytes).ShouldBe("verylong");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wire constant values
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Wire_constants_match_go_definitions()
|
||||
{
|
||||
// Go: server/raft.go:4558 — voteRequestLen = 24 + idLen = 32
|
||||
RaftWireConstants.VoteRequestLen.ShouldBe(32);
|
||||
|
||||
// Go: server/raft.go:4737 — voteResponseLen = 8 + 8 + 1 = 17
|
||||
RaftWireConstants.VoteResponseLen.ShouldBe(17);
|
||||
|
||||
// Go: server/raft.go:2660 — appendEntryBaseLen = idLen + 4*8 + 2 = 42
|
||||
RaftWireConstants.AppendEntryBaseLen.ShouldBe(42);
|
||||
|
||||
// Go: server/raft.go:2757 — appendEntryResponseLen = 24 + 1 = 25
|
||||
RaftWireConstants.AppendEntryResponseLen.ShouldBe(25);
|
||||
|
||||
// Go: server/raft.go:2756 — idLen = 8
|
||||
RaftWireConstants.IdLen.ShouldBe(8);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user