Skip to content

Commit b0eea3d

Browse files
vazoisCopilot
andauthored
Add version byte to ClusterConfig and ReplicationHistory serialization (#1778)
* Add version byte to ClusterConfig serialization with gossip validation Add ClusterConfigVersion constant (version 1) to ClusterConfig that is serialized as the first byte of the config payload. On deserialization, the version is validated and an InvalidDataException is thrown on mismatch. All gossip paths now peek the version byte before full deserialization to reject incompatible payloads early with a warning: - NetworkClusterGossip (receive path) - GarnetServerNode.GossipAsync (response path) - TryMeetAsync caller (meet response path) - ReplicaFailoverSession (failover gossip response path) Also fix SerializeSlotMap to use relative stream position instead of hardcoded position 0 for the segment count header. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add version byte to ReplicationHistory serialization Add ReplicationHistoryVersion constant (version 1) to ReplicationHistory that is serialized as the first byte of the payload. On deserialization, the version is validated and an InvalidDataException is thrown on mismatch. RecoverReplicationHistory handles version mismatches gracefully by catching the exception, logging a warning, and reinitializing fresh state. This is safe because replication history is re-negotiated on the next primary/replica sync. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add magic prefix to ClusterConfig and ReplicationHistory serialization Replace the single leading version byte with a 2-byte magic prefix followed by the version byte. This eliminates ambiguity with legacy (pre-version) payloads: - ClusterConfig uses 'GC' (0x47 0x43) magic. As a little-endian UInt16 this equals 18243, which exceeds the maximum possible legacy segmentCount (16384), so it can never collide with an old payload. - ReplicationHistory uses 'GR' (0x47 0x52) magic, distinguishable from the legacy format which starts with a 7-bit encoded string length. TryPeekVersion now validates both magic bytes before extracting the version, ensuring legacy payloads are reliably rejected rather than silently misinterpreted. Add ClusterConfigLegacyPayloadRejectedTest to verify old-format payloads are properly rejected by both TryPeekVersion and FromByteArray. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Dispose connection and track failed stats on meet version mismatch When a MEET response has an incompatible config version, dispose the newly-created GarnetServerNode to avoid leaking sockets/resources, and count the meet request as failed in gossip stats. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Broaden recovery exception handling for ReplicationHistory Catch EndOfStreamException and IOException in addition to InvalidDataException during replication history recovery. This handles truncated or corrupted on-disk payloads gracefully by reinitializing fresh state instead of crashing the server. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove redundant double deserialization in failover and gossip paths Pass the already-deserialized ClusterConfig object directly to TryMerge instead of calling FromByteArray a second time. Fixes both ReplicaFailoverSession and GarnetServerNode.GossipAsync. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Use default UTF-8 encoding for BinaryWriter in serializers Replace explicit Encoding.ASCII with the default (UTF-8) in ClusterConfigSerializer and ReplicationHistory BinaryWriters. The encoding parameter only affects string serialization (ReadString/ Write(string)), not integer types. ASCII silently replaces non-ASCII characters with '?' causing data corruption. The BinaryReader in ClusterConfigSerializer was already using the default UTF-8, creating a writer/reader encoding asymmetry. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Improve magic prefix documentation for backwards compatibility Expand XML doc comments on ClusterConfigMagic and ReplicationHistoryMagic to explain: - The human-readable prefix values ('GC' = Garnet Config, 'GR' = Garnet Replication) - Why a magic prefix is needed for backwards compatibility with the legacy pre-versioned format - How each magic value is guaranteed to never collide with valid legacy payload headers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Use ReadOnlySpan<byte> for magic prefix constants Replace static readonly byte[] with static ReadOnlySpan<byte> properties backed by UTF-8 string literals ('GC'u8 / 'GR'u8). This avoids heap allocation — the data is embedded directly in the assembly as a compile-time constant. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Change ClusterConfig and ReplicationHistory version from byte to int Update serialization version fields from byte (1 byte) to int (4 bytes) for both ClusterConfig and ReplicationHistory. This changes the binary layout to: 2-byte magic word + 4-byte int version. - ClusterConfigVersion: byte -> int - ReplicationHistoryVersion: byte -> int - TryPeekVersion: reads 4 bytes via BitConverter.ToInt32 - FromByteArray: uses ReadInt32() with updated length checks - Tests: updated to use BitConverter for version corruption/validation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove magic prefix from ClusterConfig and ReplicationHistory serialization Drop the 2-byte magic prefix ("GC"/"GR") from both formats since legacy (pre-versioned) format support is not needed. The binary layout is now simply: version int (4 bytes) + payload. This reduces gossip message overhead by 2 bytes per exchange. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Change version field from int to byte for ClusterConfig and ReplicationHistory Revert version type from int (4 bytes) to byte (1 byte) for both ClusterConfig and ReplicationHistory serialization. The binary layout is now: version byte (1 byte) + payload, minimizing wire overhead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 6bc8124 commit b0eea3d

8 files changed

Lines changed: 219 additions & 49 deletions

File tree

libs/cluster/Server/ClusterConfig.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ internal sealed partial class ClusterConfig
4040
/// </summary>
4141
public const int MAX_HASH_SLOT_VALUE = 16384;
4242

43+
/// <summary>
44+
/// Version of the cluster config serialization format.
45+
/// Increment when the binary layout of <see cref="ToByteArray"/>/<see cref="FromByteArray"/> changes.
46+
/// </summary>
47+
public const byte ClusterConfigVersion = 1;
48+
4349
/// <summary>
4450
///
4551
/// </summary>

libs/cluster/Server/ClusterConfigSerializer.cs

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,40 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT license.
33

4+
using System;
45
using System.IO;
5-
using System.Text;
66

77
namespace Garnet.cluster
88
{
99
internal sealed partial class ClusterConfig
1010
{
11+
/// <summary>
12+
/// Peek the serialization version from a config byte array without full deserialization.
13+
/// </summary>
14+
/// <param name="data">Serialized cluster config payload.</param>
15+
/// <param name="version">The version byte at the start of the payload.</param>
16+
/// <returns>True if the payload is large enough to contain a version byte; false otherwise.</returns>
17+
public static bool TryPeekVersion(ReadOnlySpan<byte> data, out byte version)
18+
{
19+
if (data.Length < 1)
20+
{
21+
version = 0;
22+
return false;
23+
}
24+
version = data[0];
25+
return true;
26+
}
27+
1128
/// <summary>
1229
/// Serialize config to byte array
1330
/// </summary>
1431
public byte[] ToByteArray()
1532
{
1633
var ms = new MemoryStream();
17-
var writer = new BinaryWriter(ms, Encoding.ASCII);
34+
var writer = new BinaryWriter(ms);
35+
36+
// Write serialization format version
37+
writer.Write(ClusterConfigVersion);
1838

1939
SerializeSlotMap(ref ms, ref writer);
2040

@@ -60,6 +80,7 @@ public byte[] ToByteArray()
6080
private void SerializeSlotMap(ref MemoryStream ms, ref BinaryWriter writer)
6181
{
6282
//serialize slotMap
83+
var segmentCountPosition = ms.Position;
6384
ms.Position += 2;
6485
ushort segmentCount = 0;
6586
ushort count = 1;
@@ -93,9 +114,9 @@ private void SerializeSlotMap(ref MemoryStream ms, ref BinaryWriter writer)
93114
writer.Write(workerId);
94115
writer.Write(state);
95116

96-
//Write segment count in the beginning of memory stream
117+
//Write segment count at the reserved position
97118
var _position = ms.Position;
98-
ms.Position = 0;
119+
ms.Position = segmentCountPosition;
99120
writer.Write(segmentCount);
100121
ms.Position = _position;
101122
}
@@ -108,6 +129,13 @@ public static ClusterConfig FromByteArray(byte[] other)
108129
var ms = new MemoryStream(other);
109130
var reader = new BinaryReader(ms);
110131

132+
// Read and validate serialization format version
133+
if (other.Length < 1)
134+
throw new InvalidDataException("Invalid ClusterConfig payload: too short to contain a version");
135+
var version = reader.ReadByte();
136+
if (version != ClusterConfigVersion)
137+
throw new InvalidDataException($"Incompatible ClusterConfig version: expected {ClusterConfigVersion}, got {version}");
138+
111139
var newSlotMap = DeserializeSlotMap(ref reader);
112140

113141
int numWorkers = reader.ReadInt32();

libs/cluster/Server/Failover/ReplicaFailoverSession.cs

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -216,13 +216,22 @@ private async Task BroadcastConfigAndRequestAttachAsync(string replicaId, byte[]
216216
{
217217
clusterProvider.clusterManager.gossipStats.UpdateGossipBytesRecv(resp.Length);
218218
var returnedConfigArray = resp.Span.ToArray();
219-
var other = ClusterConfig.FromByteArray(returnedConfigArray);
220219

221-
// Check if gossip is from a node that is known and trusted before merging
222-
if (current.IsKnown(other.LocalNodeId))
223-
_ = clusterProvider.clusterManager.TryMerge(ClusterConfig.FromByteArray(returnedConfigArray));
220+
// Validate config version before full deserialization
221+
if (!ClusterConfig.TryPeekVersion(returnedConfigArray, out var version) || version != ClusterConfig.ClusterConfigVersion)
222+
{
223+
logger?.LogWarning("Received failover gossip response with incompatible config version: {version}", version);
224+
}
224225
else
225-
logger?.LogWarning("Received gossip from unknown node: {node-id}", other.LocalNodeId);
226+
{
227+
var other = ClusterConfig.FromByteArray(returnedConfigArray);
228+
229+
// Check if gossip is from a node that is known and trusted before merging
230+
if (current.IsKnown(other.LocalNodeId))
231+
_ = clusterProvider.clusterManager.TryMerge(other);
232+
else
233+
logger?.LogWarning("Received gossip from unknown node: {node-id}", other.LocalNodeId);
234+
}
226235
}
227236
}
228237
catch (Exception ex)

libs/cluster/Server/Gossip/GarnetServerNode.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,11 +189,19 @@ private async Task GossipAsync(byte[] configByteArray)
189189
{
190190
clusterProvider.clusterManager.gossipStats.UpdateGossipBytesRecv(resp.Length);
191191
var returnedConfigArray = resp.Span.ToArray();
192+
193+
// Validate config version before full deserialization
194+
if (!ClusterConfig.TryPeekVersion(returnedConfigArray, out var version) || version != ClusterConfig.ClusterConfigVersion)
195+
{
196+
logger?.LogWarning("Received gossip response with incompatible config version: {version}", version);
197+
return;
198+
}
199+
192200
var other = ClusterConfig.FromByteArray(returnedConfigArray);
193201
var current = clusterProvider.clusterManager.CurrentConfig;
194202
// Check if gossip is from a node that is known and trusted before merging
195203
if (current.IsKnown(other.LocalNodeId))
196-
clusterProvider.clusterManager.TryMerge(ClusterConfig.FromByteArray(returnedConfigArray));
204+
clusterProvider.clusterManager.TryMerge(other);
197205
else
198206
logger?.LogWarning("Received gossip from unknown node: {node-id}", other.LocalNodeId);
199207
}

libs/cluster/Server/Gossip/Gossip.cs

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -186,21 +186,33 @@ public async Task TryMeetAsync(string address, int port, bool acquireLock = true
186186
resp = await gsn.TryMeetAsync(conf.ToByteArray()).ConfigureAwait(false);
187187
if (resp.Length > 0)
188188
{
189-
var other = ClusterConfig.FromByteArray(resp.Span.ToArray());
190-
nodeId = other.LocalNodeId;
191-
gsn.NodeId = nodeId;
189+
var respArray = resp.Span.ToArray();
192190

193-
logger?.LogInformation("MEET {nodeId} {address} {port}", nodeId, address, port);
194-
// Merge without a check because node is trusted as meet was issued by admin
195-
_ = TryMerge(other, acquireLock);
191+
// Validate config version before full deserialization
192+
if (!ClusterConfig.TryPeekVersion(respArray, out var version) || version != ClusterConfig.ClusterConfigVersion)
193+
{
194+
logger?.LogWarning("MEET response has incompatible config version: {version}", version);
195+
if (created) gsn?.Dispose();
196+
gossipStats.UpdateMeetRequestsFailed();
197+
}
198+
else
199+
{
200+
var other = ClusterConfig.FromByteArray(respArray);
201+
nodeId = other.LocalNodeId;
202+
gsn.NodeId = nodeId;
203+
204+
logger?.LogInformation("MEET {nodeId} {address} {port}", nodeId, address, port);
205+
// Merge without a check because node is trusted as meet was issued by admin
206+
_ = TryMerge(other, acquireLock);
196207

197-
gossipStats.UpdateMeetRequestsSucceed();
208+
gossipStats.UpdateMeetRequestsSucceed();
198209

199-
// If failed to add newly created connection dispose of it to reclaim resources
200-
// Dispose only connections that this meet task has created to avoid conflicts with existing connections from gossip main thread
201-
// After connection is added we are no longer the owner. Background gossip task will be owner
202-
if (created && !await clusterConnectionStore.AddConnectionAsync(gsn).ConfigureAwait(false))
203-
gsn.Dispose();
210+
// If failed to add newly created connection dispose of it to reclaim resources
211+
// Dispose only connections that this meet task has created to avoid conflicts with existing connections from gossip main thread
212+
// After connection is added we are no longer the owner. Background gossip task will be owner
213+
if (created && !await clusterConnectionStore.AddConnectionAsync(gsn).ConfigureAwait(false))
214+
gsn.Dispose();
215+
}
204216
}
205217
}
206218
catch (Exception ex)

libs/cluster/Server/Replication/ReplicationHistoryManager.cs

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33

44
using System;
55
using System.IO;
6-
using System.Text;
76
using System.Threading;
87
using Garnet.common;
98
using Garnet.server;
@@ -14,6 +13,12 @@ namespace Garnet.cluster
1413
{
1514
internal sealed class ReplicationHistory
1615
{
16+
/// <summary>
17+
/// Version of the replication history serialization format.
18+
/// Increment when the binary layout of <see cref="ToByteArray"/>/<see cref="FromByteArray"/> changes.
19+
/// </summary>
20+
public const byte ReplicationHistoryVersion = 1;
21+
1722
public string PrimaryReplId => primary_replid;
1823
string primary_replid;
1924
public string PrimaryReplId2 => primary_replid2;
@@ -43,8 +48,9 @@ public ReplicationHistory Copy()
4348
public byte[] ToByteArray()
4449
{
4550
using var ms = new MemoryStream();
46-
using var writer = new BinaryWriter(ms, Encoding.ASCII);
51+
using var writer = new BinaryWriter(ms);
4752

53+
writer.Write(ReplicationHistoryVersion);
4854
writer.Write(primary_replid);
4955
writer.Write(primary_replid2);
5056
replicationOffset.Serialize(writer);
@@ -59,6 +65,14 @@ public static ReplicationHistory FromByteArray(byte[] data)
5965
using var ms = new MemoryStream(data);
6066
using var reader = new BinaryReader(ms);
6167

68+
// Read and validate serialization format version
69+
if (data.Length < 1)
70+
throw new InvalidDataException("Invalid ReplicationHistory payload: too short to contain a version");
71+
72+
var version = reader.ReadByte();
73+
if (version != ReplicationHistoryVersion)
74+
throw new InvalidDataException($"Incompatible ReplicationHistory version: expected {ReplicationHistoryVersion}, got {version}");
75+
6276
var primary_replid = reader.ReadString();
6377
var primary_replid2 = reader.ReadString();
6478
var replicationOffset = AofAddress.Deserialize(reader);
@@ -105,13 +119,15 @@ private void InitializeReplicationHistory(int aofPhysicalSublogCount)
105119
private void RecoverReplicationHistory()
106120
{
107121
var replConfig = ClusterUtils.ReadDevice(replicationConfigDevice, replicationConfigDevicePool, logger);
108-
currentReplicationConfig = ReplicationHistory.FromByteArray(replConfig);
109-
//TODO: handle scenario where replica crashed before became a primary and it has two replication ids
110-
//var current = storeWrapper.clusterManager.CurrentConfig;
111-
//if(current.GetLocalNodeRole() == NodeRole.REPLICA && !primary_replid2.Equals(Generator.DefaultHexId()))
112-
//{
113-
114-
//}
122+
try
123+
{
124+
currentReplicationConfig = ReplicationHistory.FromByteArray(replConfig);
125+
}
126+
catch (Exception ex) when (ex is InvalidDataException or EndOfStreamException or IOException)
127+
{
128+
logger?.LogWarning(ex, "Corrupt or incompatible replication history on disk, reinitializing fresh state");
129+
InitializeReplicationHistory(storeWrapper.serverOptions.AofPhysicalSublogCount);
130+
}
115131
}
116132

117133
private void TryUpdateMyPrimaryReplId(string primaryReplicationId)

libs/cluster/Session/RespClusterBasicCommands.cs

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -385,28 +385,36 @@ private bool NetworkClusterGossip(out bool invalidParameters)
385385
// Try merge if not just a ping message
386386
if (gossipMessage.Length > 0)
387387
{
388-
var other = ClusterConfig.FromByteArray(gossipMessage);
389-
// Accept gossip message if it is a gossipWithMeet or node from node that is already known and trusted
390-
// GossipWithMeet messages are only send through a call to CLUSTER MEET at the remote node
391-
if (gossipWithMeet || current.IsKnown(other.LocalNodeId))
388+
// Validate config version before full deserialization
389+
if (!ClusterConfig.TryPeekVersion(gossipMessage, out var version) || version != ClusterConfig.ClusterConfigVersion)
392390
{
393-
// NOTE: release the epoch to avoid deadlock with MIGRATE config suspension
394-
ReleaseCurrentEpoch();
395-
try
396-
{
397-
_ = clusterProvider.clusterManager.TryMerge(other);
398-
}
399-
finally
391+
logger?.LogWarning("Received gossip with incompatible config version: {version}", version);
392+
}
393+
else
394+
{
395+
var other = ClusterConfig.FromByteArray(gossipMessage);
396+
// Accept gossip message if it is a gossipWithMeet or node from node that is already known and trusted
397+
// GossipWithMeet messages are only send through a call to CLUSTER MEET at the remote node
398+
if (gossipWithMeet || current.IsKnown(other.LocalNodeId))
400399
{
401-
AcquireCurrentEpoch();
400+
// NOTE: release the epoch to avoid deadlock with MIGRATE config suspension
401+
ReleaseCurrentEpoch();
402+
try
403+
{
404+
_ = clusterProvider.clusterManager.TryMerge(other);
405+
}
406+
finally
407+
{
408+
AcquireCurrentEpoch();
409+
}
410+
411+
// Remember that this connection is being used for another cluster node to talk to us
412+
Debug.Assert(RemoteNodeId is null || RemoteNodeId == other.LocalNodeId, "Node Id shouldn't change once set for a connection");
413+
RemoteNodeId = other.LocalNodeId;
402414
}
403-
404-
// Remember that this connection is being used for another cluster node to talk to us
405-
Debug.Assert(RemoteNodeId is null || RemoteNodeId == other.LocalNodeId, "Node Id shouldn't change once set for a connection");
406-
RemoteNodeId = other.LocalNodeId;
415+
else
416+
logger?.LogWarning("Received gossip from unknown node: {node-id}", other.LocalNodeId);
407417
}
408-
else
409-
logger?.LogWarning("Received gossip from unknown node: {node-id}", other.LocalNodeId);
410418
}
411419

412420
// Respond if configuration has changed or gossipWithMeet option is specified

test/Garnet.test.cluster/ClusterConfigTests.cs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,5 +157,88 @@ public void ClusterAnyIPAnnounce()
157157
resp = client.QuitAsync().GetAwaiter().GetResult();
158158
ClassicAssert.AreEqual("OK", resp);
159159
}
160+
161+
[Test, Order(4)]
162+
[Category("CLUSTER-CONFIG"), CancelAfter(1000)]
163+
public void ClusterConfigVersionRoundTripTest()
164+
{
165+
var config = new ClusterConfig().InitializeLocalWorker(
166+
Generator.CreateHexId(),
167+
"127.0.0.1",
168+
7001,
169+
configEpoch: 1,
170+
Garnet.cluster.NodeRole.PRIMARY,
171+
null,
172+
"");
173+
174+
var configBytes = config.ToByteArray();
175+
176+
// Verify version byte at start of payload
177+
Assert.That(ClusterConfig.TryPeekVersion(configBytes, out var version), Is.True);
178+
Assert.That(version, Is.EqualTo(ClusterConfig.ClusterConfigVersion));
179+
180+
// Round-trip should succeed
181+
var restored = ClusterConfig.FromByteArray(configBytes);
182+
Assert.That(restored.LocalNodeId, Is.EqualTo(config.LocalNodeId));
183+
}
184+
185+
[Test, Order(5)]
186+
[Category("CLUSTER-CONFIG"), CancelAfter(1000)]
187+
public void ClusterConfigVersionMismatchThrowsTest()
188+
{
189+
var config = new ClusterConfig().InitializeLocalWorker(
190+
Generator.CreateHexId(),
191+
"127.0.0.1",
192+
7001,
193+
configEpoch: 1,
194+
Garnet.cluster.NodeRole.PRIMARY,
195+
null,
196+
"");
197+
198+
var configBytes = config.ToByteArray();
199+
200+
// Corrupt the version byte (at index 0)
201+
configBytes[0] = (byte)(ClusterConfig.ClusterConfigVersion + 1);
202+
203+
// Deserialization should throw
204+
Assert.Throws<System.IO.InvalidDataException>(() => ClusterConfig.FromByteArray(configBytes));
205+
}
206+
207+
[Test, Order(6)]
208+
[Category("CLUSTER-CONFIG"), CancelAfter(1000)]
209+
public void ClusterConfigTryPeekVersionEmptyDataTest()
210+
{
211+
Assert.That(ClusterConfig.TryPeekVersion([], out _), Is.False);
212+
}
213+
214+
[Test, Order(7)]
215+
[Category("CLUSTER-CONFIG"), CancelAfter(1000)]
216+
public void ReplicationHistoryVersionRoundTripTest()
217+
{
218+
var history = new ReplicationHistory(1);
219+
var bytes = history.ToByteArray();
220+
221+
// Verify version byte at start of payload
222+
Assert.That(bytes[0], Is.EqualTo(ReplicationHistory.ReplicationHistoryVersion));
223+
224+
// Round-trip should succeed and preserve fields
225+
var restored = ReplicationHistory.FromByteArray(bytes);
226+
Assert.That(restored.PrimaryReplId, Is.EqualTo(history.PrimaryReplId));
227+
Assert.That(restored.PrimaryReplId2, Is.EqualTo(history.PrimaryReplId2));
228+
}
229+
230+
[Test, Order(9)]
231+
[Category("CLUSTER-CONFIG"), CancelAfter(1000)]
232+
public void ReplicationHistoryVersionMismatchThrowsTest()
233+
{
234+
var history = new ReplicationHistory(1);
235+
var bytes = history.ToByteArray();
236+
237+
// Corrupt the version byte (at index 0)
238+
bytes[0] = (byte)(ReplicationHistory.ReplicationHistoryVersion + 1);
239+
240+
// Deserialization should throw
241+
Assert.Throws<System.IO.InvalidDataException>(() => ReplicationHistory.FromByteArray(bytes));
242+
}
160243
}
161244
}

0 commit comments

Comments
 (0)