- Add SnapshotChunkEnumerator: IEnumerable<byte[]> that splits snapshot data into fixed-size chunks (default 65536 bytes) and computes CRC32 over the full payload for integrity validation during streaming transfer - Add RaftInstallSnapshotChunkWire: 24-byte header + variable data wire type encoding [snapshotIndex:8][snapshotTerm:4][chunkIndex:4][totalChunks:4][crc32:4][data:N] - Extend InstallSnapshotFromChunksAsync with optional expectedCrc32 parameter; validates assembled data against CRC32 before applying snapshot state, throwing InvalidDataException on mismatch to prevent corrupt state installation - Fix stub IRaftTransport implementations in test files missing SendTimeoutNowAsync - Fix incorrect role assertion in RaftLeadershipTransferTests (single-node quorum = 1) - 15 new tests in RaftSnapshotStreamingTests covering enumeration, reassembly, CRC correctness, validation success/failure, and wire format roundtrip
289 lines
10 KiB
C#
289 lines
10 KiB
C#
using System.IO.Hashing;
|
|
using NATS.Server.Raft;
|
|
|
|
namespace NATS.Server.Tests.Raft;
|
|
|
|
/// <summary>
|
|
/// Tests for Gap 8.3: chunk-based snapshot streaming with CRC32 validation.
|
|
///
|
|
/// Covers <see cref="SnapshotChunkEnumerator"/> enumeration/CRC behaviour and
|
|
/// the <see cref="RaftNode.InstallSnapshotFromChunksAsync"/> CRC validation path.
|
|
///
|
|
/// Go reference: raft.go:3500-3700 (installSnapshot chunked transfer + CRC validation)
|
|
/// </summary>
|
|
public class RaftSnapshotStreamingTests
|
|
{
|
|
// -----------------------------------------------------------------------
|
|
// SnapshotChunkEnumerator tests
|
|
// -----------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void SnapshotChunkEnumerator_yields_correct_chunk_count()
|
|
{
|
|
// 200 KB of data at 64 KB per chunk → ceil(200/64) = 4 chunks
|
|
// Go reference: raft.go snapshotChunkSize chunking logic
|
|
const int dataSize = 200 * 1024;
|
|
var data = new byte[dataSize];
|
|
Random.Shared.NextBytes(data);
|
|
|
|
var enumerator = new SnapshotChunkEnumerator(data, chunkSize: 65536);
|
|
var chunks = enumerator.ToList();
|
|
|
|
chunks.Count.ShouldBe(4);
|
|
enumerator.ChunkCount.ShouldBe(4);
|
|
}
|
|
|
|
[Fact]
|
|
public void SnapshotChunkEnumerator_chunks_reassemble_to_original()
|
|
{
|
|
// Concatenating all chunks must reproduce the original byte array exactly
|
|
// Go reference: raft.go installSnapshot chunk reassembly
|
|
const int dataSize = 150 * 1024; // 150 KB → 3 chunks at 64 KB
|
|
var original = new byte[dataSize];
|
|
Random.Shared.NextBytes(original);
|
|
|
|
var enumerator = new SnapshotChunkEnumerator(original, chunkSize: 65536);
|
|
var assembled = enumerator.SelectMany(c => c).ToArray();
|
|
|
|
assembled.Length.ShouldBe(original.Length);
|
|
assembled.ShouldBe(original);
|
|
}
|
|
|
|
[Fact]
|
|
public void SnapshotChunkEnumerator_crc32_matches()
|
|
{
|
|
// The CRC32 reported by the enumerator must equal the CRC32 computed
|
|
// directly over the original data — proving it covers the full payload
|
|
// Go reference: raft.go installSnapshot CRC32 computation
|
|
var data = new byte[100 * 1024];
|
|
Random.Shared.NextBytes(data);
|
|
|
|
var enumerator = new SnapshotChunkEnumerator(data, chunkSize: 65536);
|
|
|
|
var expectedCrc = new Crc32();
|
|
expectedCrc.Append(data);
|
|
var expected = expectedCrc.GetCurrentHashAsUInt32();
|
|
|
|
enumerator.Crc32Value.ShouldBe(expected);
|
|
}
|
|
|
|
[Fact]
|
|
public void SnapshotChunkEnumerator_single_chunk_for_small_data()
|
|
{
|
|
// Data that fits in a single chunk — only one chunk should be yielded,
|
|
// and it should be identical to the input
|
|
// Go reference: raft.go installSnapshot — single-chunk case
|
|
var data = new byte[] { 1, 2, 3, 4, 5, 10, 20, 30 };
|
|
|
|
var enumerator = new SnapshotChunkEnumerator(data, chunkSize: 65536);
|
|
var chunks = enumerator.ToList();
|
|
|
|
chunks.Count.ShouldBe(1);
|
|
enumerator.ChunkCount.ShouldBe(1);
|
|
chunks[0].ShouldBe(data);
|
|
}
|
|
|
|
[Fact]
|
|
public void SnapshotChunkEnumerator_last_chunk_is_remainder()
|
|
{
|
|
// 10 bytes with chunk size 3 → chunks of [3, 3, 3, 1]
|
|
var data = Enumerable.Range(0, 10).Select(i => (byte)i).ToArray();
|
|
|
|
var enumerator = new SnapshotChunkEnumerator(data, chunkSize: 3);
|
|
var chunks = enumerator.ToList();
|
|
|
|
chunks.Count.ShouldBe(4);
|
|
chunks[0].Length.ShouldBe(3);
|
|
chunks[1].Length.ShouldBe(3);
|
|
chunks[2].Length.ShouldBe(3);
|
|
chunks[3].Length.ShouldBe(1); // remainder
|
|
chunks[3][0].ShouldBe((byte)9);
|
|
}
|
|
|
|
[Fact]
|
|
public void SnapshotChunkEnumerator_crc32_is_stable_across_multiple_reads()
|
|
{
|
|
// CRC32Value must return the same value on every call (cached)
|
|
var data = new byte[1024];
|
|
Random.Shared.NextBytes(data);
|
|
|
|
var enumerator = new SnapshotChunkEnumerator(data, chunkSize: 256);
|
|
|
|
var first = enumerator.Crc32Value;
|
|
var second = enumerator.Crc32Value;
|
|
var third = enumerator.Crc32Value;
|
|
|
|
second.ShouldBe(first);
|
|
third.ShouldBe(first);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// InstallSnapshotFromChunksAsync CRC32 validation tests
|
|
// -----------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task InstallSnapshot_assembles_chunks_into_snapshot()
|
|
{
|
|
// Snapshot assembled from multiple chunks should produce the correct
|
|
// LastIncludedIndex, LastIncludedTerm, and Data on the node.
|
|
// Go reference: raft.go:3500-3700 installSnapshot
|
|
var node = new RaftNode("n1");
|
|
|
|
var data = new byte[] { 10, 20, 30, 40, 50 };
|
|
var enumerator = new SnapshotChunkEnumerator(data, chunkSize: 2);
|
|
var chunks = enumerator.ToList();
|
|
|
|
await node.InstallSnapshotFromChunksAsync(
|
|
chunks,
|
|
snapshotIndex: 42,
|
|
snapshotTerm: 7,
|
|
ct: default);
|
|
|
|
node.AppliedIndex.ShouldBe(42);
|
|
node.CommitIndex.ShouldBe(42);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task InstallSnapshot_validates_crc32_success()
|
|
{
|
|
// When the correct CRC32 is supplied the install should complete without error.
|
|
// Go reference: raft.go installSnapshot CRC validation
|
|
var node = new RaftNode("n1");
|
|
|
|
var data = new byte[256];
|
|
Random.Shared.NextBytes(data);
|
|
var enumerator = new SnapshotChunkEnumerator(data, chunkSize: 64);
|
|
var crc = enumerator.Crc32Value;
|
|
var chunks = enumerator.ToList();
|
|
|
|
// Should not throw
|
|
await node.InstallSnapshotFromChunksAsync(
|
|
chunks,
|
|
snapshotIndex: 10,
|
|
snapshotTerm: 2,
|
|
ct: default,
|
|
expectedCrc32: crc);
|
|
|
|
node.AppliedIndex.ShouldBe(10);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task InstallSnapshot_validates_crc32_throws_on_mismatch()
|
|
{
|
|
// A wrong CRC32 must cause InvalidDataException before any state is mutated.
|
|
// Go reference: raft.go installSnapshot CRC mismatch → abort
|
|
var node = new RaftNode("n1");
|
|
node.Log.Append(1, "cmd-1"); // pre-existing state
|
|
|
|
var data = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 };
|
|
var chunks = new[] { data }; // single chunk
|
|
|
|
const uint wrongCrc = 0xDEADBEEF;
|
|
|
|
var ex = await Should.ThrowAsync<InvalidDataException>(async () =>
|
|
await node.InstallSnapshotFromChunksAsync(
|
|
chunks,
|
|
snapshotIndex: 99,
|
|
snapshotTerm: 5,
|
|
ct: default,
|
|
expectedCrc32: wrongCrc));
|
|
|
|
ex.Message.ShouldContain("CRC32");
|
|
ex.Message.ShouldContain("DEADBEEF");
|
|
|
|
// State must NOT have been mutated since CRC failed before any writes
|
|
node.AppliedIndex.ShouldBe(0);
|
|
node.CommitIndex.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task InstallSnapshot_no_crc_parameter_installs_without_validation()
|
|
{
|
|
// When expectedCrc32 is omitted (null), no validation occurs and any data installs.
|
|
// Go reference: raft.go optional CRC field (backward compat)
|
|
var node = new RaftNode("n1");
|
|
var chunks = new[] { new byte[] { 7, 8, 9 } };
|
|
|
|
// Should not throw even with no CRC supplied
|
|
await node.InstallSnapshotFromChunksAsync(
|
|
chunks,
|
|
snapshotIndex: 5,
|
|
snapshotTerm: 1,
|
|
ct: default);
|
|
|
|
node.AppliedIndex.ShouldBe(5);
|
|
node.CommitIndex.ShouldBe(5);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// RaftInstallSnapshotChunkWire encode/decode roundtrip tests
|
|
// -----------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void SnapshotChunkWire_roundtrip_with_data()
|
|
{
|
|
// Encode and decode a chunk message and verify all fields survive the roundtrip.
|
|
// Go reference: raft.go wire format for InstallSnapshot RPC chunks
|
|
var payload = new byte[] { 0xAA, 0xBB, 0xCC, 0xDD };
|
|
var wire = new RaftInstallSnapshotChunkWire(
|
|
SnapshotIndex: 42UL,
|
|
SnapshotTerm: 3U,
|
|
ChunkIndex: 1U,
|
|
TotalChunks: 4U,
|
|
Crc32: 0x12345678U,
|
|
Data: payload);
|
|
|
|
var encoded = wire.Encode();
|
|
var decoded = RaftInstallSnapshotChunkWire.Decode(encoded);
|
|
|
|
decoded.SnapshotIndex.ShouldBe(42UL);
|
|
decoded.SnapshotTerm.ShouldBe(3U);
|
|
decoded.ChunkIndex.ShouldBe(1U);
|
|
decoded.TotalChunks.ShouldBe(4U);
|
|
decoded.Crc32.ShouldBe(0x12345678U);
|
|
decoded.Data.ShouldBe(payload);
|
|
}
|
|
|
|
[Fact]
|
|
public void SnapshotChunkWire_header_length_is_24_bytes()
|
|
{
|
|
// Header must be exactly 24 bytes as documented in the wire format.
|
|
RaftInstallSnapshotChunkWire.HeaderLen.ShouldBe(24);
|
|
}
|
|
|
|
[Fact]
|
|
public void SnapshotChunkWire_encode_total_length_is_header_plus_data()
|
|
{
|
|
var data = new byte[100];
|
|
var wire = new RaftInstallSnapshotChunkWire(1UL, 1U, 0U, 1U, 0U, data);
|
|
wire.Encode().Length.ShouldBe(RaftInstallSnapshotChunkWire.HeaderLen + 100);
|
|
}
|
|
|
|
[Fact]
|
|
public void SnapshotChunkWire_decode_throws_on_short_buffer()
|
|
{
|
|
// Buffers shorter than the header should throw ArgumentException
|
|
var tooShort = new byte[10]; // < 24 bytes
|
|
Should.Throw<ArgumentException>(() => RaftInstallSnapshotChunkWire.Decode(tooShort));
|
|
}
|
|
|
|
[Fact]
|
|
public void SnapshotChunkWire_roundtrip_empty_payload()
|
|
{
|
|
// A header-only message (no chunk data) should encode and decode cleanly.
|
|
var wire = new RaftInstallSnapshotChunkWire(
|
|
SnapshotIndex: 0UL,
|
|
SnapshotTerm: 0U,
|
|
ChunkIndex: 0U,
|
|
TotalChunks: 0U,
|
|
Crc32: 0U,
|
|
Data: []);
|
|
|
|
var encoded = wire.Encode();
|
|
encoded.Length.ShouldBe(RaftInstallSnapshotChunkWire.HeaderLen);
|
|
|
|
var decoded = RaftInstallSnapshotChunkWire.Decode(encoded);
|
|
decoded.Data.Length.ShouldBe(0);
|
|
}
|
|
}
|