diff --git a/foreign/csharp/Iggy_SDK.Tests.BDD/StepDefinitions/BasicMessagingOperationsSteps.cs b/foreign/csharp/Iggy_SDK.Tests.BDD/StepDefinitions/BasicMessagingOperationsSteps.cs
index f0f44b539a..87e7c8a0ce 100644
--- a/foreign/csharp/Iggy_SDK.Tests.BDD/StepDefinitions/BasicMessagingOperationsSteps.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.BDD/StepDefinitions/BasicMessagingOperationsSteps.cs
@@ -59,7 +59,7 @@ public async Task GivenIAmAuthenticatedAsTheRootUser()
var loginResult = await _context.IggyClient.LoginUserAsync(TestEnvironment.RootUsername, TestEnvironment.RootPassword);
loginResult.ShouldNotBeNull();
- loginResult.UserId.ShouldBe(0);
+ loginResult.UserId.ShouldBe(0u);
}
[Given(@"I have no streams in the system")]
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/ClusterRedirectionTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/ClusterRedirectionTests.cs
index 879b45fc02..f0c1c9343e 100644
--- a/foreign/csharp/Iggy_SDK.Tests.Integration/ClusterRedirectionTests.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/ClusterRedirectionTests.cs
@@ -135,7 +135,7 @@ public async Task ConnectToFollowerWithPersonalAccessToken_Should_RedirectToLead
}
authResponse.ShouldNotBeNull();
- authResponse!.UserId.ShouldBeGreaterThanOrEqualTo(0);
+ authResponse!.UserId.ShouldNotBe(uint.MaxValue);
var address = client.GetCurrentAddress();
address.ShouldNotBeNullOrEmpty();
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/ConsumerGroupTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/ConsumerGroupTests.cs
index c6ed9ed56a..b0765fdcd9 100644
--- a/foreign/csharp/Iggy_SDK.Tests.Integration/ConsumerGroupTests.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/ConsumerGroupTests.cs
@@ -54,7 +54,6 @@ public async Task CreateConsumerGroup_HappyPath_Should_CreateConsumerGroup_Succe
Identifier.String(streamName), Identifier.String(TopicName), GroupName);
consumerGroup.ShouldNotBeNull();
- consumerGroup.Id.ShouldBeGreaterThanOrEqualTo(0u);
consumerGroup.PartitionsCount.ShouldBe(PartitionsCount);
consumerGroup.MembersCount.ShouldBe(0u);
consumerGroup.Name.ShouldBe(GroupName);
@@ -129,8 +128,8 @@ await Should.NotThrowAsync(() =>
// Verify via GetMe that the client is now a member of the consumer group
var me = await client.GetMeAsync();
me.ShouldNotBeNull();
- me.ConsumerGroupsCount.ShouldBe(1);
- me.ConsumerGroups.ShouldContain(x => x.GroupId == (int)cg!.Id);
+ me.ConsumerGroupsCount.ShouldBe(1u);
+ me.ConsumerGroups.ShouldContain(x => x.GroupId == cg!.Id);
}
[Test]
@@ -165,8 +164,8 @@ await Should.NotThrowAsync(() =>
// Verify via GetMe that the client is no longer a member of the consumer group
var me = await client.GetMeAsync();
me.ShouldNotBeNull();
- me.ConsumerGroupsCount.ShouldBe(0);
- me.ConsumerGroups.ShouldNotContain(x => x.GroupId == (int)cg.Id);
+ me.ConsumerGroupsCount.ShouldBe(0u);
+ me.ConsumerGroups.ShouldNotContain(x => x.GroupId == cg.Id);
}
[Test]
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/FetchMessagesTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/FetchMessagesTests.cs
index f9ae474a23..c4d1325ef3 100644
--- a/foreign/csharp/Iggy_SDK.Tests.Integration/FetchMessagesTests.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/FetchMessagesTests.cs
@@ -78,7 +78,7 @@ public async Task PollMessages_WithNoHeaders_Should_PollMessages_Successfully(Pr
});
response.Messages.Count.ShouldBe(10);
- response.PartitionId.ShouldBe(0);
+ response.PartitionId.ShouldBe(0u);
response.CurrentOffset.ShouldBe(19u);
foreach (var responseMessage in response.Messages)
@@ -129,7 +129,7 @@ public async Task PollMessages_WithHeaders_Should_PollMessages_Successfully(Prot
var response = await client.PollMessagesAsync(headersMessageFetchRequest);
response.Messages.Count.ShouldBe(10);
- response.PartitionId.ShouldBe(0);
+ response.PartitionId.ShouldBe(0u);
response.CurrentOffset.ShouldBe(19u);
foreach (var responseMessage in response.Messages)
{
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/HeartbeatTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/HeartbeatTests.cs
index 882c89f881..c6b7245793 100644
--- a/foreign/csharp/Iggy_SDK.Tests.Integration/HeartbeatTests.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/HeartbeatTests.cs
@@ -56,7 +56,7 @@ public async Task IdleGroupMember_WithHeartbeat_Should_StayMember()
var me = await client.GetMeAsync();
me.ShouldNotBeNull();
- me.ConsumerGroupsCount.ShouldBe(1);
+ me.ConsumerGroupsCount.ShouldBe(1u);
}
[Test]
@@ -78,7 +78,7 @@ public async Task IdleGroupMember_WithSlowHeartbeat_Should_BeEvicted_And_Reconne
// fresh, auto-logged-in session.
var me = await client.GetMeAsync();
me.ShouldNotBeNull();
- me.ConsumerGroupsCount.ShouldBe(0);
+ me.ConsumerGroupsCount.ShouldBe(0u);
}
[Test]
@@ -101,7 +101,7 @@ public async Task EvictedClient_WithPersonalAccessTokenAutoLogin_Should_Reconnec
var me = await client.GetMeAsync();
me.ShouldNotBeNull();
- me.ConsumerGroupsCount.ShouldBe(0);
+ me.ConsumerGroupsCount.ShouldBe(0u);
}
///
@@ -128,7 +128,7 @@ public async Task EvictedClient_WithoutAutoLogin_Should_ReestablishItsSession()
// that belonged to it is gone.
var me = await client.GetMeAsync();
me.ShouldNotBeNull();
- me.ConsumerGroupsCount.ShouldBe(0);
+ me.ConsumerGroupsCount.ShouldBe(0u);
}
private Task CreateClient(TimeSpan heartbeatInterval, bool autoLogin = true)
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/IggyPublisherTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/IggyPublisherTests.cs
index c50c1ba603..4d2bd92a58 100644
--- a/foreign/csharp/Iggy_SDK.Tests.Integration/IggyPublisherTests.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/IggyPublisherTests.cs
@@ -409,7 +409,7 @@ public async Task SendMessages_ToMultiplePartitions_Should_DistributeMessages(Pr
false);
polledMessages.Messages.Count.ShouldBeGreaterThanOrEqualTo(10);
- polledMessages.PartitionId.ShouldBe((int)partitionId);
+ polledMessages.PartitionId.ShouldBe(partitionId);
}
}
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/OffsetTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/OffsetTests.cs
index f96ced773f..03783b1fa2 100644
--- a/foreign/csharp/Iggy_SDK.Tests.Integration/OffsetTests.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/OffsetTests.cs
@@ -81,7 +81,7 @@ await client.StoreOffsetAsync(Consumer.New("test-consumer"), Identifier.String(s
offset.ShouldNotBeNull();
offset.StoredOffset.ShouldBe(SetOffset);
- offset.PartitionId.ShouldBe(0);
+ offset.PartitionId.ShouldBe(0u);
offset.CurrentOffset.ShouldBe(3u);
}
@@ -142,7 +142,7 @@ await client.StoreOffsetAsync(Consumer.Group("test_consumer_group"), Identifier.
offset.ShouldNotBeNull();
offset.StoredOffset.ShouldBe(SetOffset);
- offset.PartitionId.ShouldBe(0);
+ offset.PartitionId.ShouldBe(0u);
offset.CurrentOffset.ShouldBe(3u);
}
@@ -175,7 +175,7 @@ await client.StoreOffsetAsync(Consumer.Group("test_consumer_group"), Identifier.
offset.ShouldNotBeNull();
offset.StoredOffset.ShouldBe(SetOffset);
- offset.PartitionId.ShouldBe(0);
+ offset.PartitionId.ShouldBe(0u);
offset.CurrentOffset.ShouldBe(3u);
}
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/PersonalAccessTokenTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/PersonalAccessTokenTests.cs
index b8ffc39b91..d321157c49 100644
--- a/foreign/csharp/Iggy_SDK.Tests.Integration/PersonalAccessTokenTests.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/PersonalAccessTokenTests.cs
@@ -91,7 +91,7 @@ public async Task LoginWithPersonalAccessToken_Should_Be_Successfully(Protocol p
var authResponse = await loginClient.LoginWithPersonalAccessTokenAsync(response!.Token);
authResponse.ShouldNotBeNull();
- authResponse.UserId.ShouldBeGreaterThanOrEqualTo(0);
+ authResponse.UserId.ShouldNotBe(uint.MaxValue);
}
[Test]
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/StreamsTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/StreamsTests.cs
index 41d762bbf6..b1e1d12f0c 100644
--- a/foreign/csharp/Iggy_SDK.Tests.Integration/StreamsTests.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/StreamsTests.cs
@@ -41,12 +41,11 @@ public async Task CreateStream_HappyPath_Should_CreateStream_Successfully(Protoc
var response = await client.CreateStreamAsync(name);
response.ShouldNotBeNull();
- response.Id.ShouldBeGreaterThanOrEqualTo(0u);
response.Name.ShouldBe(name);
response.Size.ShouldBe(0u);
response.CreatedAt.UtcDateTime.ShouldBe(DateTimeOffset.UtcNow.UtcDateTime, TimeSpan.FromMinutes(1));
response.MessagesCount.ShouldBe(0u);
- response.TopicsCount.ShouldBe(0);
+ response.TopicsCount.ShouldBe(0u);
response.Topics.ShouldBeEmpty();
}
@@ -98,7 +97,7 @@ public async Task GetStreamById_Should_ReturnValidResponse(Protocol protocol)
response.Size.ShouldBe(0u);
response.CreatedAt.UtcDateTime.ShouldBe(DateTimeOffset.UtcNow.UtcDateTime, TimeSpan.FromMinutes(1));
response.MessagesCount.ShouldBe(0u);
- response.TopicsCount.ShouldBe(0);
+ response.TopicsCount.ShouldBe(0u);
response.Topics.ShouldBeEmpty();
}
@@ -119,7 +118,7 @@ public async Task GetStreams_ByStreamName_Should_ReturnValidResponse(Protocol pr
response.Size.ShouldBe(0u);
response.CreatedAt.UtcDateTime.ShouldBe(DateTimeOffset.UtcNow.UtcDateTime, TimeSpan.FromMinutes(1));
response.MessagesCount.ShouldBe(0u);
- response.TopicsCount.ShouldBe(0);
+ response.TopicsCount.ShouldBe(0u);
response.Topics.ShouldBeEmpty();
}
@@ -157,12 +156,11 @@ await client.SendMessagesAsync(Identifier.String(streamName),
var response = await client.GetStreamByIdAsync(Identifier.String(streamName));
response.ShouldNotBeNull();
- response.Id.ShouldBeGreaterThanOrEqualTo(0u);
response.Name.ShouldBe(streamName);
response.Size.ShouldBeGreaterThan(0u);
response.CreatedAt.UtcDateTime.ShouldBe(DateTimeOffset.UtcNow.UtcDateTime, TimeSpan.FromMinutes(1));
response.MessagesCount.ShouldBe(7u);
- response.TopicsCount.ShouldBe(2);
+ response.TopicsCount.ShouldBe(2u);
response.Topics.Count().ShouldBe(2);
var topic = response.Topics.First(x => x.Name == topicName1);
@@ -222,7 +220,7 @@ await client.SendMessagesAsync(Identifier.String(streamName),
purged => purged?.MessagesCount == 0, TimeSpan.FromSeconds(10));
stream.ShouldNotBeNull();
stream.MessagesCount.ShouldBe(0u);
- stream.TopicsCount.ShouldBe(1);
+ stream.TopicsCount.ShouldBe(1u);
}
[Test]
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/SystemTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/SystemTests.cs
index dd2b26ab94..324b706300 100644
--- a/foreign/csharp/Iggy_SDK.Tests.Integration/SystemTests.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/SystemTests.cs
@@ -45,7 +45,7 @@ public async Task GetClients_Should_Return_NonEmptyClientsList(Protocol protocol
{
c.ClientId.ShouldNotBe(0u);
c.Address.ShouldNotBeNullOrEmpty();
- c.Transport.ShouldBe(Protocol.Tcp);
+ c.Transport.ShouldBe(ClientTransport.Tcp);
}
}
@@ -63,10 +63,9 @@ public async Task GetClient_Should_Return_CorrectClient(Protocol protocol)
response.ShouldNotBeNull();
response.ClientId.ShouldBe(clientInfo.ClientId);
response.UserId.ShouldNotBeNull();
- response.UserId.Value.ShouldBeGreaterThanOrEqualTo(0u);
response.Address.ShouldNotBeNullOrEmpty();
- response.Transport.ShouldBe(Protocol.Tcp);
- response.ConsumerGroupsCount.ShouldBe(0);
+ response.Transport.ShouldBe(ClientTransport.Tcp);
+ response.ConsumerGroupsCount.ShouldBe(0u);
response.ConsumerGroups.ShouldBeEmpty();
}
@@ -82,7 +81,7 @@ public async Task GetMe_Tcp_Should_Return_MyClient(Protocol protocol)
me.ClientId.ShouldNotBe(0u);
me.UserId.ShouldBe(0u);
me.Address.ShouldNotBeNullOrEmpty();
- me.Transport.ShouldBe(Protocol.Tcp);
+ me.Transport.ShouldBe(ClientTransport.Tcp);
}
[Test]
@@ -117,8 +116,8 @@ await tcpClient.JoinConsumerGroupAsync(Identifier.String(streamName),
var response = await client.GetClientByIdAsync(me!.ClientId);
response.ShouldNotBeNull();
response.Address.ShouldNotBeNullOrEmpty();
- response.Transport.ShouldBe(Protocol.Tcp);
- response.ConsumerGroupsCount.ShouldBe(1);
+ response.Transport.ShouldBe(ClientTransport.Tcp);
+ response.ConsumerGroupsCount.ShouldBe(1u);
response.ConsumerGroups.ShouldNotBeEmpty();
response.ConsumerGroups.ShouldContain(x => x.GroupId == consumerGroup!.Id);
response.ConsumerGroups.ShouldContain(x => x.StreamId == stream!.Id);
@@ -141,21 +140,15 @@ await client.SendMessagesAsync(Identifier.String(streamName),
var response = await client.GetStatsAsync();
response.ShouldNotBeNull();
- response.ProcessId.ShouldBeGreaterThanOrEqualTo(0);
+ response.ProcessId.ShouldNotBe(0u);
response.CpuUsage.ShouldBeGreaterThanOrEqualTo(0);
response.TotalCpuUsage.ShouldBeGreaterThanOrEqualTo(0);
- response.MemoryUsage.ShouldBeGreaterThanOrEqualTo(0u);
- response.TotalMemory.ShouldBeGreaterThanOrEqualTo(0u);
response.AvailableMemory.ShouldNotBe(0u);
- response.RunTime.ShouldBeGreaterThanOrEqualTo(0u);
response.StartTime.ShouldBe(DateTimeOffset.UtcNow, TimeSpan.FromMinutes(5));
- response.ReadBytes.ShouldBeGreaterThanOrEqualTo(0u);
- response.WrittenBytes.ShouldBeGreaterThanOrEqualTo(0u);
- response.MessagesSizeBytes.ShouldBeGreaterThanOrEqualTo(0u);
- response.StreamsCount.ShouldBeGreaterThanOrEqualTo(1);
- response.TopicsCount.ShouldBeGreaterThanOrEqualTo(1);
- response.PartitionsCount.ShouldBeGreaterThanOrEqualTo(1);
- response.SegmentsCount.ShouldBeGreaterThanOrEqualTo(1);
+ response.StreamsCount.ShouldBeGreaterThanOrEqualTo(1u);
+ response.TopicsCount.ShouldBeGreaterThanOrEqualTo(1u);
+ response.PartitionsCount.ShouldBeGreaterThanOrEqualTo(1u);
+ response.SegmentsCount.ShouldBeGreaterThanOrEqualTo(1u);
response.MessagesCount.ShouldBeGreaterThanOrEqualTo(1u);
// iggy-server leaves the connected-client tally out of its stats reply, so ClientsCount goes unchecked.
response.Hostname.ShouldNotBeNullOrEmpty();
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/TopicsTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/TopicsTests.cs
index 050b3f9b87..dd0db67ccf 100644
--- a/foreign/csharp/Iggy_SDK.Tests.Integration/TopicsTests.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/TopicsTests.cs
@@ -46,7 +46,6 @@ public async Task Create_NewTopic_Should_Return_Successfully(Protocol protocol)
TimeSpan.FromMinutes(10), 2_000_000_000);
response.ShouldNotBeNull();
- response.Id.ShouldBeGreaterThanOrEqualTo(0u);
response.CreatedAt.UtcDateTime.ShouldBe(DateTimeOffset.UtcNow.UtcDateTime, TimeSpan.FromMinutes(1));
response.Name.ShouldBe("Test Topic");
response.CompressionAlgorithm.ShouldBe(CompressionAlgorithm.Gzip);
@@ -86,7 +85,6 @@ await client.CreateTopicAsync(Identifier.String(streamName), "Get Topic", 2,
var response = await client.GetTopicByIdAsync(Identifier.String(streamName), Identifier.Numeric(0));
response.ShouldNotBeNull();
- response.Id.ShouldBeGreaterThanOrEqualTo(0u);
response.CreatedAt.UtcDateTime.ShouldBe(DateTimeOffset.UtcNow.UtcDateTime, TimeSpan.FromMinutes(1));
response.Name.ShouldBe("Get Topic");
response.CompressionAlgorithm.ShouldBe(CompressionAlgorithm.Gzip);
@@ -113,7 +111,6 @@ await client.CreateTopicAsync(Identifier.String(streamName), "Name Topic", 2,
Identifier.String("Name Topic"));
response.ShouldNotBeNull();
- response.Id.ShouldBeGreaterThanOrEqualTo(0u);
response.Name.ShouldBe("Name Topic");
response.CompressionAlgorithm.ShouldBe(CompressionAlgorithm.Gzip);
response.Partitions!.Count().ShouldBe(2);
@@ -169,7 +166,6 @@ await client.SendMessagesAsync(Identifier.String(streamName),
Identifier.String("Parts Topic"));
response.ShouldNotBeNull();
- response.Id.ShouldBeGreaterThanOrEqualTo(0u);
response.Name.ShouldBe("Parts Topic");
response.Partitions!.Count().ShouldBe(3);
response.Size.ShouldBeGreaterThan(0u);
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/UsersTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/UsersTests.cs
index 3c86131b29..85c74e0368 100644
--- a/foreign/csharp/Iggy_SDK.Tests.Integration/UsersTests.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/UsersTests.cs
@@ -69,7 +69,6 @@ public async Task GetUser_WithoutPermissions_Should_ReturnValidResponse(Protocol
var response = await client.GetUserAsync(Identifier.String(username));
response.ShouldNotBeNull();
- response.Id.ShouldBeGreaterThanOrEqualTo(0u);
response.Username.ShouldBe(username);
response.Status.ShouldBe(UserStatus.Active);
response.CreatedAt.ShouldBeGreaterThan(0u);
@@ -109,7 +108,6 @@ public async Task UpdateUser_Should_UpdateUser_Successfully(Protocol protocol)
var user = await client.GetUserAsync(Identifier.String(newUsername));
user.ShouldNotBeNull();
- user.Id.ShouldBeGreaterThanOrEqualTo(0u);
user.Username.ShouldBe(newUsername);
user.Status.ShouldBe(UserStatus.Active);
user.CreatedAt.ShouldBeGreaterThan(0u);
@@ -144,7 +142,7 @@ public async Task UpdatePermissions_Should_UpdatePermissions_Successfully(Protoc
user.Permissions.Global.ReadUsers.ShouldBeTrue();
user.Permissions.Global.SendMessages.ShouldBeTrue();
user.Permissions.Streams.ShouldNotBeNull();
- user.Permissions.Streams.ShouldContainKey(1);
+ user.Permissions.Streams.ShouldContainKey(1u);
user.Permissions.Streams[1].ManageStream.ShouldBeTrue();
user.Permissions.Streams[1].ManageTopics.ShouldBeTrue();
user.Permissions.Streams[1].ReadStream.ShouldBeTrue();
@@ -152,7 +150,7 @@ public async Task UpdatePermissions_Should_UpdatePermissions_Successfully(Protoc
user.Permissions.Streams[1].ReadTopics.ShouldBeTrue();
user.Permissions.Streams[1].PollMessages.ShouldBeTrue();
user.Permissions.Streams[1].Topics.ShouldNotBeNull();
- user.Permissions.Streams[1].Topics!.ShouldContainKey(1);
+ user.Permissions.Streams[1].Topics!.ShouldContainKey(1u);
user.Permissions.Streams[1].Topics![1].ManageTopic.ShouldBeTrue();
user.Permissions.Streams[1].Topics![1].PollMessages.ShouldBeTrue();
user.Permissions.Streams[1].Topics![1].ReadTopic.ShouldBeTrue();
@@ -175,7 +173,7 @@ await Should.NotThrowAsync(client.ChangePasswordAsync(Identifier.String(username
var loginClient = await Fixture.CreateClient(protocol, true);
var loginResponse = await loginClient.LoginUserAsync(username, "new_password");
loginResponse.ShouldNotBeNull();
- loginResponse.UserId.ShouldBeGreaterThan(0);
+ loginResponse.UserId.ShouldBeGreaterThan(0u);
}
[Test]
@@ -204,7 +202,7 @@ public async Task LoginUser_Should_LoginUser_Successfully(Protocol protocol)
var response = await loginClient.LoginUserAsync(username, "login_password");
response.ShouldNotBeNull();
- response.UserId.ShouldBeGreaterThan(0);
+ response.UserId.ShouldBeGreaterThan(0u);
switch (protocol)
{
case Protocol.Tcp:
@@ -255,7 +253,7 @@ private static Permissions CreatePermissions()
ReadUsers = true,
SendMessages = true
},
- Streams = new Dictionary
+ Streams = new Dictionary
{
{
1, new StreamPermissions
@@ -266,7 +264,7 @@ private static Permissions CreatePermissions()
SendMessages = true,
ReadTopics = true,
PollMessages = true,
- Topics = new Dictionary
+ Topics = new Dictionary
{
{
1, new TopicPermissions
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/Vsr/VsrMessagingTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/Vsr/VsrMessagingTests.cs
index d1fdbd3e47..d6d9c5ecec 100644
--- a/foreign/csharp/Iggy_SDK.Tests.Integration/Vsr/VsrMessagingTests.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/Vsr/VsrMessagingTests.cs
@@ -49,7 +49,7 @@ public async Task SendMessages_ToAnExplicitPartition_Should_PollBack_FromThatPar
var polled = await PollAsync(client, streamName, 2);
polled.Messages.Count.ShouldBe(5);
- polled.PartitionId.ShouldBe(2);
+ polled.PartitionId.ShouldBe(2u);
(await PollAsync(client, streamName, 1)).Messages.ShouldBeEmpty();
}
diff --git a/foreign/csharp/Iggy_SDK/Consumers/IggyConsumer.Logging.cs b/foreign/csharp/Iggy_SDK/Consumers/IggyConsumer.Logging.cs
index e853b2232d..013b4c6bc6 100644
--- a/foreign/csharp/Iggy_SDK/Consumers/IggyConsumer.Logging.cs
+++ b/foreign/csharp/Iggy_SDK/Consumers/IggyConsumer.Logging.cs
@@ -122,6 +122,11 @@ public partial class IggyConsumer
Message = "Waiting for {Remaining} milliseconds before polling messages")]
private partial void LogWaitingBeforePolling(long remaining);
+ [LoggerMessage(EventId = 203,
+ Level = LogLevel.Debug,
+ Message = "No partition assigned to this group member, backing off for {BackoffMs} milliseconds")]
+ private partial void LogNoPartitionAssignedBackingOff(int backoffMs);
+
[LoggerMessage(EventId = 301,
Level = LogLevel.Warning,
Message = "PartitionId is ignored when ConsumerType is ConsumerGroup")]
diff --git a/foreign/csharp/Iggy_SDK/Consumers/IggyConsumer.Rented.cs b/foreign/csharp/Iggy_SDK/Consumers/IggyConsumer.Rented.cs
index a545df5fbb..ac73a026f1 100644
--- a/foreign/csharp/Iggy_SDK/Consumers/IggyConsumer.Rented.cs
+++ b/foreign/csharp/Iggy_SDK/Consumers/IggyConsumer.Rented.cs
@@ -128,6 +128,13 @@ protected async Task PollRentedMessagesAsync(CancellationToken ct)
if (rental.Messages.Count == 0)
{
+ if (rental.PartitionId == PolledMessages.NoAssignedPartition)
+ {
+ LogNoPartitionAssignedBackingOff(NoAssignedPartitionBackoffMs);
+ await Task.Delay(NoAssignedPartitionBackoffMs, ct);
+ return;
+ }
+
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("No messages received from poll for partition {PartitionId}", rental.PartitionId);
@@ -136,8 +143,6 @@ protected async Task PollRentedMessagesAsync(CancellationToken ct)
return;
}
- var partitionId = (uint)rental.PartitionId;
-
var hasLastOffset = _lastPolledOffset.TryGetValue(rental.PartitionId, out var lastPolledPartitionOffset);
var currentOffset = 0ul;
@@ -155,7 +160,7 @@ protected async Task PollRentedMessagesAsync(CancellationToken ct)
batchHandle.Acquire();
try
{
- await PublishRentedAsync(batchHandle, message, partitionId, MessageStatus.Success, null, ct);
+ await PublishRentedAsync(batchHandle, message, rental.PartitionId, MessageStatus.Success, null, ct);
}
catch
{
@@ -177,13 +182,13 @@ protected async Task PollRentedMessagesAsync(CancellationToken ct)
lastPolledPartitionOffset, rental.PartitionId);
}
- await StoreOffsetAsync(lastPolledPartitionOffset, partitionId, false, ct);
+ await StoreOffsetAsync(lastPolledPartitionOffset, rental.PartitionId, false, ct);
}
return;
}
- _lastPolledOffset.AddOrUpdate(rental.PartitionId, currentOffset, (_, _) => currentOffset);
+ _lastPolledOffset[rental.PartitionId] = currentOffset;
if (_config.PollingStrategy.Kind == MessagePolling.Offset)
{
diff --git a/foreign/csharp/Iggy_SDK/Consumers/IggyConsumer.cs b/foreign/csharp/Iggy_SDK/Consumers/IggyConsumer.cs
index 91c48a1d57..5d0bfcbe3a 100644
--- a/foreign/csharp/Iggy_SDK/Consumers/IggyConsumer.cs
+++ b/foreign/csharp/Iggy_SDK/Consumers/IggyConsumer.cs
@@ -18,6 +18,7 @@
using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
using System.Threading.Channels;
+using Apache.Iggy.Contracts;
using Apache.Iggy.Enums;
using Apache.Iggy.Exceptions;
using Apache.Iggy.IggyClient;
@@ -41,12 +42,18 @@ public partial class IggyConsumer : IAsyncDisposable
///
private const int GroupRejoinRetryDelayMs = 1_000;
+ ///
+ /// Backoff after a group poll reported . Without it a
+ /// member holding zero partitions re-polls in a hot loop whenever PollingIntervalMs is zero.
+ ///
+ private const int NoAssignedPartitionBackoffMs = 100;
+
private readonly Channel _channel;
private readonly IIggyClient _client;
private readonly IggyConsumerConfig _config;
private readonly SemaphoreSlim _connectionStateSemaphore = new(1, 1);
private readonly EventAggregator _consumerErrorEvents;
- private readonly ConcurrentDictionary _lastPolledOffset = new();
+ private readonly ConcurrentDictionary _lastPolledOffset = new();
private readonly ILogger _logger;
private readonly SemaphoreSlim _pollingSemaphore = new(1, 1);
private readonly Channel _rentedChannel;
@@ -235,7 +242,7 @@ public async Task StoreOffsetAsync(ulong offset, uint partitionId, bool resetLas
if (resetLastPolled)
{
- _lastPolledOffset[(int)partitionId] = offset;
+ _lastPolledOffset[partitionId] = offset;
}
}
@@ -409,6 +416,12 @@ private async Task PollMessagesAsync(CancellationToken ct)
if (messages.Messages.Count == 0)
{
+ if (messages.PartitionId == PolledMessages.NoAssignedPartition)
+ {
+ LogNoPartitionAssignedBackingOff(NoAssignedPartitionBackoffMs);
+ await Task.Delay(NoAssignedPartitionBackoffMs, ct);
+ }
+
return;
}
@@ -428,7 +441,7 @@ private async Task PollMessagesAsync(CancellationToken ct)
{
Message = message,
CurrentOffset = message.Header.Offset,
- PartitionId = (uint)messages.PartitionId,
+ PartitionId = messages.PartitionId,
Status = MessageStatus.Success,
Error = null
};
@@ -444,20 +457,23 @@ private async Task PollMessagesAsync(CancellationToken ct)
{
_logger.LogDebug("No new messages found, committing offset {Offset} for partition {PartitionId}",
lastPolledPartitionOffset, messages.PartitionId);
- await StoreOffsetAsync(lastPolledPartitionOffset, (uint)messages.PartitionId, false, ct);
+ await StoreOffsetAsync(lastPolledPartitionOffset, messages.PartitionId, false, ct);
}
return;
}
- _lastPolledOffset.AddOrUpdate(messages.PartitionId, currentOffset,
- (_, _) => currentOffset);
+ _lastPolledOffset[messages.PartitionId] = currentOffset;
if (_config.PollingStrategy.Kind == MessagePolling.Offset)
{
_config.PollingStrategy = PollingStrategy.Offset(currentOffset + 1);
}
}
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ throw;
+ }
catch (MessageDecryptionException ex)
{
LogFailedToDecryptMessage(ex, ex.Offset, ex.PartitionId);
diff --git a/foreign/csharp/Iggy_SDK/Contracts/Auth/AuthResponse.cs b/foreign/csharp/Iggy_SDK/Contracts/Auth/AuthResponse.cs
index 0cdc6d2b5e..d40a947f87 100644
--- a/foreign/csharp/Iggy_SDK/Contracts/Auth/AuthResponse.cs
+++ b/foreign/csharp/Iggy_SDK/Contracts/Auth/AuthResponse.cs
@@ -22,4 +22,4 @@ namespace Apache.Iggy.Contracts.Auth;
///
/// The unique identifier (numeric) of the user
/// The optional tokens, used only by HTTP transport
-public record AuthResponse(int UserId, TokenInfo? AccessToken);
+public record AuthResponse(uint UserId, TokenInfo? AccessToken);
diff --git a/foreign/csharp/Iggy_SDK/Contracts/Auth/Permissions.cs b/foreign/csharp/Iggy_SDK/Contracts/Auth/Permissions.cs
index 9d11aa74a0..e611830366 100644
--- a/foreign/csharp/Iggy_SDK/Contracts/Auth/Permissions.cs
+++ b/foreign/csharp/Iggy_SDK/Contracts/Auth/Permissions.cs
@@ -30,5 +30,5 @@ public sealed class Permissions
///
/// Permissions applied to specific streams.
///
- public Dictionary? Streams { get; init; }
+ public Dictionary? Streams { get; init; }
}
diff --git a/foreign/csharp/Iggy_SDK/Contracts/ClientInfo.cs b/foreign/csharp/Iggy_SDK/Contracts/ClientInfo.cs
index 902429cfe1..bbcb1326c1 100644
--- a/foreign/csharp/Iggy_SDK/Contracts/ClientInfo.cs
+++ b/foreign/csharp/Iggy_SDK/Contracts/ClientInfo.cs
@@ -42,12 +42,12 @@ public sealed class ClientResponse
///
/// Transport protocol used by the client.
///
- public required Protocol Transport { get; init; }
+ public required ClientTransport Transport { get; init; }
///
/// Number of consumer groups the client is part of.
///
- public required int ConsumerGroupsCount { get; init; }
+ public required uint ConsumerGroupsCount { get; init; }
///
/// List of consumer groups the client is part of.
diff --git a/foreign/csharp/Iggy_SDK/Contracts/ClusterNode.cs b/foreign/csharp/Iggy_SDK/Contracts/ClusterNode.cs
index 6a9ee0c8fe..f4395082a6 100644
--- a/foreign/csharp/Iggy_SDK/Contracts/ClusterNode.cs
+++ b/foreign/csharp/Iggy_SDK/Contracts/ClusterNode.cs
@@ -46,10 +46,4 @@ public class ClusterNode
/// Node status
///
public required ClusterNodeStatus Status { get; set; }
-
- internal int GetSize()
- {
- // name length, name, ip length, ip, endpoints (4 * 2 bytes), role, status
- return 4 + Name.Length + 4 + Ip.Length + 8 + 1 + 1;
- }
}
diff --git a/foreign/csharp/Iggy_SDK/Contracts/ClusterNodeStatus.cs b/foreign/csharp/Iggy_SDK/Contracts/ClusterNodeStatus.cs
index e930b0eb61..39e4d698af 100644
--- a/foreign/csharp/Iggy_SDK/Contracts/ClusterNodeStatus.cs
+++ b/foreign/csharp/Iggy_SDK/Contracts/ClusterNodeStatus.cs
@@ -45,5 +45,10 @@ public enum ClusterNodeStatus : byte
///
/// Node is in maintenance mode
///
- Maintenance = 4
+ Maintenance = 4,
+
+ ///
+ /// Node status could not be determined
+ ///
+ Unknown = 5
}
diff --git a/foreign/csharp/Iggy_SDK/Contracts/ConsumerGroupInfo.cs b/foreign/csharp/Iggy_SDK/Contracts/ConsumerGroupInfo.cs
index 5e55b7d42f..db81e988c1 100644
--- a/foreign/csharp/Iggy_SDK/Contracts/ConsumerGroupInfo.cs
+++ b/foreign/csharp/Iggy_SDK/Contracts/ConsumerGroupInfo.cs
@@ -25,15 +25,15 @@ public sealed class ConsumerGroupInfo
///
/// Stream identifier.
///
- public required int StreamId { get; init; }
+ public required uint StreamId { get; init; }
///
/// Topic identifier.
///
- public required int TopicId { get; init; }
+ public required uint TopicId { get; init; }
///
/// Consumer group identifier.
///
- public required int GroupId { get; init; }
+ public required uint GroupId { get; init; }
}
diff --git a/foreign/csharp/Iggy_SDK/Contracts/ConsumerGroupMembers.cs b/foreign/csharp/Iggy_SDK/Contracts/ConsumerGroupMembers.cs
index 62a40a3afc..fabadc6154 100644
--- a/foreign/csharp/Iggy_SDK/Contracts/ConsumerGroupMembers.cs
+++ b/foreign/csharp/Iggy_SDK/Contracts/ConsumerGroupMembers.cs
@@ -30,10 +30,10 @@ public sealed class ConsumerGroupMember
///
/// Number of partitions the consumer group member is consuming.
///
- public required int PartitionsCount { get; init; }
+ public required uint PartitionsCount { get; init; }
///
/// List of partition identifiers the consumer group member is consuming.
///
- public required List Partitions { get; init; }
+ public required List Partitions { get; init; }
}
diff --git a/foreign/csharp/Iggy_SDK/Contracts/MessageResponse.cs b/foreign/csharp/Iggy_SDK/Contracts/MessageResponse.cs
index 6d2b5bc74f..03b1472ee7 100644
--- a/foreign/csharp/Iggy_SDK/Contracts/MessageResponse.cs
+++ b/foreign/csharp/Iggy_SDK/Contracts/MessageResponse.cs
@@ -53,7 +53,7 @@ public Dictionary? UserHeaders
if (!_userHeadersInitialized)
{
_userHeaders = _rawUserHeaders is { Length: > 0 }
- ? BinaryMapper.TryMapHeaders(_rawUserHeaders)
+ ? BinaryMapper.MapHeaders(_rawUserHeaders)
: null;
_userHeadersInitialized = true;
}
diff --git a/foreign/csharp/Iggy_SDK/Contracts/OffsetResponse.cs b/foreign/csharp/Iggy_SDK/Contracts/OffsetResponse.cs
index 6c0f5c3e48..ef27be094e 100644
--- a/foreign/csharp/Iggy_SDK/Contracts/OffsetResponse.cs
+++ b/foreign/csharp/Iggy_SDK/Contracts/OffsetResponse.cs
@@ -25,7 +25,7 @@ public sealed class OffsetResponse
///
/// Partition identifier.
///
- public required int PartitionId { get; init; }
+ public required uint PartitionId { get; init; }
///
/// Current offset.
diff --git a/foreign/csharp/Iggy_SDK/Contracts/PartitionResponse.cs b/foreign/csharp/Iggy_SDK/Contracts/PartitionResponse.cs
index 6cb2cb7b03..16b6d65ecf 100644
--- a/foreign/csharp/Iggy_SDK/Contracts/PartitionResponse.cs
+++ b/foreign/csharp/Iggy_SDK/Contracts/PartitionResponse.cs
@@ -28,7 +28,7 @@ public sealed class PartitionResponse
///
/// Partition identifier.
///
- public required int Id { get; init; }
+ public required uint Id { get; init; }
///
/// Number of messages in the partition.
@@ -44,7 +44,7 @@ public sealed class PartitionResponse
///
/// Number of segments in the partition.
///
- public required int SegmentsCount { get; init; }
+ public required uint SegmentsCount { get; init; }
///
/// Current offset of the partition.
diff --git a/foreign/csharp/Iggy_SDK/Contracts/PolledMessages.cs b/foreign/csharp/Iggy_SDK/Contracts/PolledMessages.cs
index 9d3f24f666..07e9131cf3 100644
--- a/foreign/csharp/Iggy_SDK/Contracts/PolledMessages.cs
+++ b/foreign/csharp/Iggy_SDK/Contracts/PolledMessages.cs
@@ -22,10 +22,17 @@ namespace Apache.Iggy.Contracts;
///
public sealed class PolledMessages
{
+ ///
+ /// Partition id an empty group poll reports when the member currently owns no partition of the group,
+ /// for example mid-rebalance or when the group has more members than partitions. Matches the Go and
+ /// Node SDKs. Consumers should back off before polling again rather than spin.
+ ///
+ public static readonly uint NoAssignedPartition = 0xFFFF_FFFE;
+
///
/// Partition identifier for the messages.
///
- public required int PartitionId { get; init; }
+ public required uint PartitionId { get; init; }
///
/// Current offset for the partition.
diff --git a/foreign/csharp/Iggy_SDK/Contracts/PolledMessagesRental.cs b/foreign/csharp/Iggy_SDK/Contracts/PolledMessagesRental.cs
index d2dae6aacd..37d47ef9b3 100644
--- a/foreign/csharp/Iggy_SDK/Contracts/PolledMessagesRental.cs
+++ b/foreign/csharp/Iggy_SDK/Contracts/PolledMessagesRental.cs
@@ -31,7 +31,7 @@ public sealed class PolledMessagesRental : IDisposable
///
/// Partition identifier for the messages.
///
- public required int PartitionId { get; init; }
+ public required uint PartitionId { get; init; }
///
/// Current offset for the partition.
diff --git a/foreign/csharp/Iggy_SDK/Contracts/RentedMessageResponse.cs b/foreign/csharp/Iggy_SDK/Contracts/RentedMessageResponse.cs
index f4db2413af..7fc7f0c541 100644
--- a/foreign/csharp/Iggy_SDK/Contracts/RentedMessageResponse.cs
+++ b/foreign/csharp/Iggy_SDK/Contracts/RentedMessageResponse.cs
@@ -56,7 +56,7 @@ public Dictionary? UserHeaders
{
_userHeaders = RawUserHeaders.IsEmpty
? null
- : BinaryMapper.TryMapHeaders(RawUserHeaders.Span);
+ : BinaryMapper.MapHeaders(RawUserHeaders.Span);
_userHeadersInitialized = true;
}
diff --git a/foreign/csharp/Iggy_SDK/Contracts/StatsResponse.cs b/foreign/csharp/Iggy_SDK/Contracts/StatsResponse.cs
index 8ca1fb9be3..772fbaa574 100644
--- a/foreign/csharp/Iggy_SDK/Contracts/StatsResponse.cs
+++ b/foreign/csharp/Iggy_SDK/Contracts/StatsResponse.cs
@@ -28,7 +28,7 @@ public sealed class StatsResponse
///
/// Process identifier.
///
- public required int ProcessId { get; init; }
+ public required uint ProcessId { get; init; }
///
/// CPU usage of the process.
@@ -90,22 +90,22 @@ public sealed class StatsResponse
///
/// Total number of streams.
///
- public required int StreamsCount { get; init; }
+ public required uint StreamsCount { get; init; }
///
/// Total number of topics.
///
- public required int TopicsCount { get; init; }
+ public required uint TopicsCount { get; init; }
///
/// Total number of partitions.
///
- public required int PartitionsCount { get; init; }
+ public required uint PartitionsCount { get; init; }
///
/// Total number of segments.
///
- public required int SegmentsCount { get; init; }
+ public required uint SegmentsCount { get; init; }
///
/// Total number of messages.
@@ -115,12 +115,12 @@ public sealed class StatsResponse
///
/// Total number of connected clients.
///
- public required int ClientsCount { get; init; }
+ public required uint ClientsCount { get; init; }
///
/// Total number of consumer groups.
///
- public required int ConsumerGroupsCount { get; init; }
+ public required uint ConsumerGroupsCount { get; init; }
///
/// Hostname of the server.
diff --git a/foreign/csharp/Iggy_SDK/Contracts/StreamPermissions.cs b/foreign/csharp/Iggy_SDK/Contracts/StreamPermissions.cs
index 46ca6ce620..37011a7e9a 100644
--- a/foreign/csharp/Iggy_SDK/Contracts/StreamPermissions.cs
+++ b/foreign/csharp/Iggy_SDK/Contracts/StreamPermissions.cs
@@ -144,5 +144,5 @@ public sealed class StreamPermissions
///
/// Permissions for topics in the stream.
///
- public Dictionary? Topics { get; init; }
+ public Dictionary? Topics { get; init; }
}
diff --git a/foreign/csharp/Iggy_SDK/Contracts/StreamResponse.cs b/foreign/csharp/Iggy_SDK/Contracts/StreamResponse.cs
index 39f46c795a..8555ea1128 100644
--- a/foreign/csharp/Iggy_SDK/Contracts/StreamResponse.cs
+++ b/foreign/csharp/Iggy_SDK/Contracts/StreamResponse.cs
@@ -56,7 +56,7 @@ public sealed class StreamResponse
///
/// Number of topics in the stream.
///
- public required int TopicsCount { get; init; }
+ public required uint TopicsCount { get; init; }
///
/// List of topics in the stream.
diff --git a/foreign/csharp/Iggy_SDK/Contracts/Tcp/TcpContracts.cs b/foreign/csharp/Iggy_SDK/Contracts/Tcp/TcpContracts.cs
index b367cc23fc..74b3821d7a 100644
--- a/foreign/csharp/Iggy_SDK/Contracts/Tcp/TcpContracts.cs
+++ b/foreign/csharp/Iggy_SDK/Contracts/Tcp/TcpContracts.cs
@@ -148,26 +148,11 @@ internal static byte[] ChangePassword(Identifier userId, string currentPassword,
internal static byte[] UpdatePermissions(Identifier userId, Permissions? permissions)
{
- var length = userId.Length + 2 +
- (permissions is not null ? 1 + 4 + CalculatePermissionsSize(permissions) : 0);
- Span bytes = stackalloc byte[length];
- bytes.WriteBytesFromIdentifier(userId);
- var position = userId.Length + 2;
- if (permissions is not null)
- {
- bytes[position++] = 1;
- var permissionsBytes = GetBytesFromPermissions(permissions);
- BinaryPrimitives.WriteInt32LittleEndian(bytes[position..(position + 4)],
- permissionsBytes.Length);
- position += 4;
- permissionsBytes.CopyTo(bytes[position..(position + permissionsBytes.Length)]);
- }
- else
- {
- bytes[position++] = 0;
- }
-
- return bytes.ToArray();
+ var permissionsBytes = permissions is not null ? GetBytesFromPermissions(permissions) : [];
+ var bytes = new byte[userId.Length + 2 + 1 + (permissions is not null ? 4 + permissionsBytes.Length : 0)];
+ bytes.AsSpan().WriteBytesFromIdentifier(userId);
+ WritePermissionsBlock(bytes.AsSpan(userId.Length + 2), permissions, permissionsBytes);
+ return bytes;
}
internal static byte[] UpdateUser(Identifier userId, string? userName, UserStatus? status)
@@ -213,166 +198,98 @@ internal static byte[] CreateUser(string userName, string password, UserStatus s
{
var userNameLength = Encoding.UTF8.GetByteCount(userName);
var passwordLength = Encoding.UTF8.GetByteCount(password);
- var capacity = 3 + userNameLength + passwordLength
- + (permissions is not null ? 1 + 4 + CalculatePermissionsSize(permissions) : 1);
-
- Span bytes = stackalloc byte[capacity];
+ var permissionsBytes = permissions is not null ? GetBytesFromPermissions(permissions) : [];
+ var bytes = new byte[3 + userNameLength + passwordLength + 1
+ + (permissions is not null ? 4 + permissionsBytes.Length : 0)];
var position = 0;
bytes[position++] = (byte)userNameLength;
- position += Encoding.UTF8.GetBytes(userName, bytes[position..(position + userNameLength)]);
+ position += Encoding.UTF8.GetBytes(userName, bytes.AsSpan(position, userNameLength));
bytes[position++] = (byte)passwordLength;
- position += Encoding.UTF8.GetBytes(password, bytes[position..(position + passwordLength)]);
+ position += Encoding.UTF8.GetBytes(password, bytes.AsSpan(position, passwordLength));
bytes[position++] = (byte)status;
- if (permissions is not null)
- {
- bytes[position++] = 1;
- var permissionsBytes = GetBytesFromPermissions(permissions);
- BinaryPrimitives.WriteInt32LittleEndian(bytes[position..(position + 4)], permissionsBytes.Length);
- position += 4;
- permissionsBytes.CopyTo(bytes[position..(position + permissionsBytes.Length)]);
- }
- else
- {
- bytes[position++] = 0;
- }
-
- return bytes.ToArray();
+ WritePermissionsBlock(bytes.AsSpan(position), permissions, permissionsBytes);
+ return bytes;
}
- private static byte[] GetBytesFromPermissions(Permissions data)
+ private static void WritePermissionsBlock(Span destination, Permissions? permissions,
+ byte[] permissionsBytes)
{
- var size = CalculatePermissionsSize(data);
- Span bytes = stackalloc byte[size];
-
- bytes[0] = data.Global.ManageServers ? (byte)1 : (byte)0;
- bytes[1] = data.Global.ReadServers ? (byte)1 : (byte)0;
- bytes[2] = data.Global.ManageUsers ? (byte)1 : (byte)0;
- bytes[3] = data.Global.ReadUsers ? (byte)1 : (byte)0;
- bytes[4] = data.Global.ManageStreams ? (byte)1 : (byte)0;
- bytes[5] = data.Global.ReadStreams ? (byte)1 : (byte)0;
- bytes[6] = data.Global.ManageTopics ? (byte)1 : (byte)0;
- bytes[7] = data.Global.ReadTopics ? (byte)1 : (byte)0;
- bytes[8] = data.Global.PollMessages ? (byte)1 : (byte)0;
- bytes[9] = data.Global.SendMessages ? (byte)1 : (byte)0;
-
-
- if (data.Streams is not null)
+ if (permissions is null)
{
- var streamsCount = data.Streams.Count;
- var currentStream = 1;
- bytes[10] = 1;
- var position = 11;
- foreach (var (streamId, stream) in data.Streams)
- {
- BinaryPrimitives.WriteInt32LittleEndian(bytes[position..(position + 4)], streamId);
- position += 4;
-
- bytes[position] = stream.ManageStream ? (byte)1 : (byte)0;
- bytes[position + 1] = stream.ReadStream ? (byte)1 : (byte)0;
- bytes[position + 2] = stream.ManageTopics ? (byte)1 : (byte)0;
- bytes[position + 3] = stream.ReadTopics ? (byte)1 : (byte)0;
- bytes[position + 4] = stream.PollMessages ? (byte)1 : (byte)0;
- bytes[position + 5] = stream.SendMessages ? (byte)1 : (byte)0;
- position += 6;
-
- if (stream.Topics != null)
- {
- var topicsCount = stream.Topics.Count;
- var currentTopic = 1;
- bytes[position] = 1;
- position += 1;
-
- foreach (var (topicId, topic) in stream.Topics)
- {
- BinaryPrimitives.WriteInt32LittleEndian(bytes[position..(position + 4)], topicId);
- position += 4;
-
- bytes[position] = topic.ManageTopic ? (byte)1 : (byte)0;
- bytes[position + 1] = topic.ReadTopic ? (byte)1 : (byte)0;
- bytes[position + 2] = topic.PollMessages ? (byte)1 : (byte)0;
- bytes[position + 3] = topic.SendMessages ? (byte)1 : (byte)0;
- position += 4;
- if (currentTopic < topicsCount)
- {
- currentTopic++;
- bytes[position++] = 1;
- }
- else
- {
- bytes[position++] = 0;
- }
- }
- }
- else
- {
- bytes[position++] = 0;
- }
-
- if (currentStream < streamsCount)
- {
- currentStream++;
- bytes[position++] = 1;
- }
- else
- {
- bytes[position++] = 0;
- }
- }
- }
- else
- {
- bytes[0] = 0;
+ destination[0] = 0;
+ return;
}
- return bytes.ToArray();
+ destination[0] = 1;
+ BinaryPrimitives.WriteInt32LittleEndian(destination[1..5], permissionsBytes.Length);
+ permissionsBytes.CopyTo(destination[5..]);
}
- private static int CalculatePermissionsSize(Permissions data)
+ private static byte[] GetBytesFromPermissions(Permissions data)
{
- var size = 10;
-
- if (data.Streams is not null)
- {
- size += 1;
- foreach (var (_, stream) in data.Streams)
+ var writer = new ArrayBufferWriter();
+
+ WriteFlag(writer, data.Global.ManageServers);
+ WriteFlag(writer, data.Global.ReadServers);
+ WriteFlag(writer, data.Global.ManageUsers);
+ WriteFlag(writer, data.Global.ReadUsers);
+ WriteFlag(writer, data.Global.ManageStreams);
+ WriteFlag(writer, data.Global.ReadStreams);
+ WriteFlag(writer, data.Global.ManageTopics);
+ WriteFlag(writer, data.Global.ReadTopics);
+ WriteFlag(writer, data.Global.PollMessages);
+ WriteFlag(writer, data.Global.SendMessages);
+
+ var hasStreams = data.Streams is { Count: > 0 };
+ WriteFlag(writer, hasStreams);
+ if (!hasStreams)
+ {
+ return writer.WrittenSpan.ToArray();
+ }
+
+ var remainingStreams = data.Streams!.Count;
+ foreach (var (streamId, stream) in data.Streams)
+ {
+ BinaryPrimitives.WriteUInt32LittleEndian(writer.GetSpan(4), streamId);
+ writer.Advance(4);
+ WriteFlag(writer, stream.ManageStream);
+ WriteFlag(writer, stream.ReadStream);
+ WriteFlag(writer, stream.ManageTopics);
+ WriteFlag(writer, stream.ReadTopics);
+ WriteFlag(writer, stream.PollMessages);
+ WriteFlag(writer, stream.SendMessages);
+
+ var hasTopics = stream.Topics is { Count: > 0 };
+ WriteFlag(writer, hasTopics);
+ if (hasTopics)
{
- size += 4;
- size += 6;
- size += 1;
-
- if (stream.Topics is not null)
+ var remainingTopics = stream.Topics!.Count;
+ foreach (var (topicId, topic) in stream.Topics)
{
- size += 1;
- size += stream.Topics.Count * 9;
- }
- else
- {
- size += 1;
+ BinaryPrimitives.WriteUInt32LittleEndian(writer.GetSpan(4), topicId);
+ writer.Advance(4);
+ WriteFlag(writer, topic.ManageTopic);
+ WriteFlag(writer, topic.ReadTopic);
+ WriteFlag(writer, topic.PollMessages);
+ WriteFlag(writer, topic.SendMessages);
+ WriteFlag(writer, --remainingTopics > 0);
}
}
- }
- else
- {
- size += 1;
+
+ WriteFlag(writer, --remainingStreams > 0);
}
- return size;
+ return writer.WrittenSpan.ToArray();
}
- public static byte[] FlushUnsavedBuffer(Identifier streamId, Identifier topicId, uint partitionId, bool fsync)
+ private static void WriteFlag(ArrayBufferWriter writer, bool value)
{
- var length = streamId.Length + 2 + topicId.Length + 2 + 4 + 1;
- Span bytes = stackalloc byte[length];
- bytes.WriteBytesFromStreamAndTopicIdentifiers(streamId, topicId);
- var position = streamId.Length + 2 + topicId.Length + 2;
- BinaryPrimitives.WriteUInt32LittleEndian(bytes[position..(position + 4)], partitionId);
- bytes[position + 4] = fsync ? (byte)1 : (byte)0;
-
- return bytes.ToArray();
+ writer.GetSpan(1)[0] = value ? (byte)1 : (byte)0;
+ writer.Advance(1);
}
internal static void GetMessages(Span bytes, Consumer consumer, Identifier streamId, Identifier topicId,
diff --git a/foreign/csharp/Iggy_SDK/Enums/ClientTransport.cs b/foreign/csharp/Iggy_SDK/Enums/ClientTransport.cs
new file mode 100644
index 0000000000..9726e86fed
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK/Enums/ClientTransport.cs
@@ -0,0 +1,49 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+namespace Apache.Iggy.Enums;
+
+///
+/// Transport a connected client reached the server through, as reported by the server.
+///
+public enum ClientTransport : byte
+{
+ ///
+ /// Transport this SDK does not recognize, or one newer than this SDK.
+ ///
+ Unknown = 0,
+
+ ///
+ /// Custom binary protocol over TCP.
+ ///
+ Tcp = 1,
+
+ ///
+ /// QUIC.
+ ///
+ Quic = 2,
+
+ ///
+ /// HTTP REST.
+ ///
+ Http = 3,
+
+ ///
+ /// WebSocket.
+ ///
+ WebSocket = 4
+}
diff --git a/foreign/csharp/Iggy_SDK/Identifier.cs b/foreign/csharp/Iggy_SDK/Identifier.cs
index 0414fac97e..8800fc542a 100644
--- a/foreign/csharp/Iggy_SDK/Identifier.cs
+++ b/foreign/csharp/Iggy_SDK/Identifier.cs
@@ -48,15 +48,8 @@ namespace Apache.Iggy;
///
public static Identifier Numeric(int value)
{
- var bytes = new byte[4];
- BinaryPrimitives.WriteInt32LittleEndian(bytes, value);
-
- return new Identifier
- {
- Kind = IdKind.Numeric,
- Length = 4,
- Value = bytes
- };
+ ArgumentOutOfRangeException.ThrowIfNegative(value);
+ return Numeric((uint)value);
}
///
diff --git a/foreign/csharp/Iggy_SDK/IggyClient/IIggyConsumer.cs b/foreign/csharp/Iggy_SDK/IggyClient/IIggyConsumer.cs
index 418fd5a93f..4e822dded9 100644
--- a/foreign/csharp/Iggy_SDK/IggyClient/IIggyConsumer.cs
+++ b/foreign/csharp/Iggy_SDK/IggyClient/IIggyConsumer.cs
@@ -41,7 +41,12 @@ public interface IIggyConsumer
/// The maximum number of messages to retrieve.
/// If true, automatically commit the offset after polling.
/// The cancellation token to cancel the operation.
- /// A task that represents the asynchronous operation and returns the polled messages.
+ ///
+ /// A task that represents the asynchronous operation and returns the polled messages. A consumer-group
+ /// poll whose member currently owns no partition returns an empty batch whose
+ /// is ; do
+ /// not key offsets on it, back off and poll again.
+ ///
Task PollMessagesAsync(Identifier streamId, Identifier topicId, uint? partitionId,
Consumer consumer, PollingStrategy pollingStrategy, uint count, bool autoCommit,
CancellationToken token = default);
@@ -52,7 +57,10 @@ Task PollMessagesAsync(Identifier streamId, Identifier topicId,
///
///
/// The returned rental must be disposed when the caller is done reading the payload and raw header memory.
- /// Payload and raw header slices are invalidated once the rental is disposed.
+ /// Payload and raw header slices are invalidated once the rental is disposed. A consumer-group poll whose
+ /// member currently owns no partition returns an empty batch whose
+ /// is ;
+ /// do not key offsets on it, back off and poll again.
///
Task PollMessagesRentedAsync(Identifier streamId, Identifier topicId, uint? partitionId,
Consumer consumer, PollingStrategy pollingStrategy, uint count, bool autoCommit,
diff --git a/foreign/csharp/Iggy_SDK/IggyClient/Implementations/HttpMessageStream.cs b/foreign/csharp/Iggy_SDK/IggyClient/Implementations/HttpMessageStream.cs
index 443e278b1e..8310f26c73 100644
--- a/foreign/csharp/Iggy_SDK/IggyClient/Implementations/HttpMessageStream.cs
+++ b/foreign/csharp/Iggy_SDK/IggyClient/Implementations/HttpMessageStream.cs
@@ -339,7 +339,7 @@ public async Task PollMessagesAsync(Identifier streamId, Identif
if (MessageEncryptor is not null)
{
- DecryptMessages(pollMessages.Messages, (uint)pollMessages.PartitionId);
+ DecryptMessages(pollMessages.Messages, pollMessages.PartitionId);
}
return pollMessages;
@@ -1003,7 +1003,7 @@ private async ValueTask ResolvePartitioningAsync(Identifier stream
_ => throw new FeatureUnavailableException()
};
- return Partitioning.PartitionId((int)partition);
+ return Partitioning.PartitionId(partition);
}
private void DecryptMessages(IReadOnlyList messages, uint partitionId)
diff --git a/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.Vsr.cs b/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.Vsr.cs
index d0c52f6542..56c37b5175 100644
--- a/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.Vsr.cs
+++ b/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.Vsr.cs
@@ -88,7 +88,19 @@ public sealed partial class TcpMessageStream : ISessionGenerationProvider
/// RESYNC_REQUIRED_PARTITION_SENTINEL (u32::MAX). The reply header carries no status for an
/// empty poll, so the sentinel is the only channel the coordinator has to ask for a re-sync.
///
- private const int VsrResyncRequiredPartitionSentinel = -1;
+ private const uint VsrResyncRequiredPartitionSentinel = uint.MaxValue;
+
+ ///
+ /// Shared empty poll result for a group member that currently owns no partition, carrying
+ /// so a consumer can back off instead of re-polling at
+ /// once. Same ownership rules as .
+ ///
+ private static readonly PolledMessagesRental NoAssignedPartitionPolledMessages = new(EmptyMemoryOwner.Instance)
+ {
+ PartitionId = PolledMessages.NoAssignedPartition,
+ CurrentOffset = 0,
+ Messages = []
+ };
///
/// Shared empty poll result. An idle consumer loop returns one on every iteration, and the instance owns
@@ -158,7 +170,7 @@ public sealed partial class TcpMessageStream : ISessionGenerationProvider
response.ServerVersion, response.ServerProtocolVersion);
SetConnectionState(ConnectionState.Authenticated);
- var authResponse = new AuthResponse((int)response.UserId, null);
+ var authResponse = new AuthResponse(response.UserId, null);
if (IsConnecting)
{
return authResponse;
@@ -227,7 +239,7 @@ private async ValueTask ResolvePartitioningAsync(Identifier stream
$"Partitioning kind {partitioning.Kind} cannot be resolved to a partition id.")
};
- return Partitioning.PartitionId((int)partition);
+ return Partitioning.PartitionId(partition);
}
private async ValueTask TopicPartitionCountAsync(Identifier streamId, Identifier topicId,
@@ -274,7 +286,7 @@ private async Task PollGroupMessagesRentedAsync(Identifier
$"Client is not a member of consumer group {consumer.ConsumerId} on topic {topicId}.");
}
- return EmptyPolledMessages;
+ return NoAssignedPartitionPolledMessages;
}
PolledMessagesRental? rental = null;
@@ -307,7 +319,9 @@ private async Task PollGroupMessagesRentedAsync(Identifier
await SyncGroupAssignmentAsync(streamId, topicId, consumer.ConsumerId, token);
}
- return EmptyPolledMessages;
+ // Running out of attempts mid-rebalance is not a failure: report it like a member that owns nothing
+ // yet, so the consumer backs off and polls again.
+ return NoAssignedPartitionPolledMessages;
}
///
diff --git a/foreign/csharp/Iggy_SDK/Iggy_SDK.csproj b/foreign/csharp/Iggy_SDK/Iggy_SDK.csproj
index baa0fe5801..3cacac12e1 100644
--- a/foreign/csharp/Iggy_SDK/Iggy_SDK.csproj
+++ b/foreign/csharp/Iggy_SDK/Iggy_SDK.csproj
@@ -26,7 +26,7 @@ under the License.
net8.0;net10.0
Apache.Iggy
Apache.Iggy
- 0.9.0-edge.6
+ 0.9.0-edge.7
true
diff --git a/foreign/csharp/Iggy_SDK/Kinds/Consumer.cs b/foreign/csharp/Iggy_SDK/Kinds/Consumer.cs
index 7905354ed4..c970458a5f 100644
--- a/foreign/csharp/Iggy_SDK/Kinds/Consumer.cs
+++ b/foreign/csharp/Iggy_SDK/Kinds/Consumer.cs
@@ -40,6 +40,17 @@ public readonly struct Consumer
/// Identifier value
/// Consumer instance
public static Consumer New(int id)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegative(id);
+ return New((uint)id);
+ }
+
+ ///
+ /// Creates a new regular consumer identifier.
+ ///
+ /// Identifier value
+ /// Consumer instance
+ public static Consumer New(uint id)
{
return new Consumer
{
@@ -68,6 +79,17 @@ public static Consumer New(string id)
/// Identifier value
/// Consumer instance
public static Consumer Group(int id)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegative(id);
+ return Group((uint)id);
+ }
+
+ ///
+ /// Creates a new consumer group identifier.
+ ///
+ /// Identifier value
+ /// Consumer instance
+ public static Consumer Group(uint id)
{
return new Consumer
{
diff --git a/foreign/csharp/Iggy_SDK/Kinds/Partitioning.cs b/foreign/csharp/Iggy_SDK/Kinds/Partitioning.cs
index 101769a20d..9efbdd46ef 100644
--- a/foreign/csharp/Iggy_SDK/Kinds/Partitioning.cs
+++ b/foreign/csharp/Iggy_SDK/Kinds/Partitioning.cs
@@ -60,9 +60,20 @@ public static Partitioning None()
/// Partition id
/// Partitioning instance
public static Partitioning PartitionId(int value)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegative(value);
+ return PartitionId((uint)value);
+ }
+
+ ///
+ /// Creates a partitioning strategy that use a specific partition id.
+ ///
+ /// Partition id
+ /// Partitioning instance
+ public static Partitioning PartitionId(uint value)
{
var bytes = new byte[4];
- BinaryPrimitives.WriteInt32LittleEndian(bytes, value);
+ BinaryPrimitives.WriteUInt32LittleEndian(bytes, value);
return new Partitioning
{
diff --git a/foreign/csharp/Iggy_SDK/Mappers/BinaryMapper.cs b/foreign/csharp/Iggy_SDK/Mappers/BinaryMapper.cs
index 8c6d44d6ef..c9afa5f501 100644
--- a/foreign/csharp/Iggy_SDK/Mappers/BinaryMapper.cs
+++ b/foreign/csharp/Iggy_SDK/Mappers/BinaryMapper.cs
@@ -33,6 +33,15 @@ namespace Apache.Iggy.Mappers;
internal static class BinaryMapper
{
+ private const int CONSUMER_GROUP_HEADER_SIZE = 13;
+ private const int MEMBER_HEADER_SIZE = 8;
+ private const int CONSUMER_GROUP_INFO_SIZE = 12;
+ private const int CACHE_METRICS_ENTRY_SIZE = 32;
+ private const int MIN_TOPIC_SIZE = 50 + 4 + 4;
+ private const int MIN_OPTION_SPEC_SIZE = 1 + 1 + 4 + 4;
+ private const int CLUSTER_NODE_TAIL_SIZE = 4 * 2 + 1 + 1;
+ private const int MIN_CLUSTER_NODE_SIZE = 4 + 4 + CLUSTER_NODE_TAIL_SIZE;
+
internal static RawPersonalAccessToken MapRawPersonalAccessToken(ReadOnlySpan payload)
{
var tokenLength = payload[0];
@@ -101,7 +110,7 @@ internal static UserResponse MapUser(ReadOnlySpan payload)
var hasPermissions = payload[position];
if (hasPermissions == 1)
{
- var permissionLength = BinaryPrimitives.ReadInt32LittleEndian(payload[(position + 1)..(position + 5)]);
+ var permissionLength = ReadLength(payload, position + 1, "User permissions");
ReadOnlySpan permissionsPayload = payload[(position + 5)..(position + 5 + permissionLength)];
var permissions = MapPermissions(permissionsPayload);
return new UserResponse
@@ -128,7 +137,7 @@ internal static UserResponse MapUser(ReadOnlySpan payload)
private static Permissions MapPermissions(ReadOnlySpan bytes)
{
- var streamMap = new Dictionary();
+ var streamMap = new Dictionary();
var index = 0;
var globalPermissions = new GlobalPermissions
@@ -149,8 +158,8 @@ private static Permissions MapPermissions(ReadOnlySpan bytes)
{
while (true)
{
- var streamId = BinaryPrimitives.ReadInt32LittleEndian(bytes[index..(index + 4)]);
- index += sizeof(int);
+ var streamId = BinaryPrimitives.ReadUInt32LittleEndian(bytes[index..(index + 4)]);
+ index += sizeof(uint);
var manageStream = bytes[index++] == 1;
var readStream = bytes[index++] == 1;
@@ -158,14 +167,14 @@ private static Permissions MapPermissions(ReadOnlySpan bytes)
var readTopics = bytes[index++] == 1;
var pollMessagesStream = bytes[index++] == 1;
var sendMessagesStream = bytes[index++] == 1;
- var topicsMap = new Dictionary();
+ var topicsMap = new Dictionary();
if (bytes[index++] == 1)
{
while (true)
{
- var topicId = BinaryPrimitives.ReadInt32LittleEndian(bytes[index..(index + 4)]);
- index += sizeof(int);
+ var topicId = BinaryPrimitives.ReadUInt32LittleEndian(bytes[index..(index + 4)]);
+ index += sizeof(uint);
var manageTopic = bytes[index++] == 1;
var readTopic = bytes[index++] == 1;
@@ -245,13 +254,14 @@ private static (UserResponse response, int position) MapToUserResponse(ReadOnlyS
internal static ClientResponse MapClient(ReadOnlySpan payload)
{
var (response, position) = MapClientInfo(payload, 0);
- var consumerGroups = new List(response.ConsumerGroupsCount);
+ var consumerGroups = new List(ValidatedCollectionSize(response.ConsumerGroupsCount,
+ payload.Length - position, CONSUMER_GROUP_INFO_SIZE, "Client consumer groups count"));
for (var i = 0; i < response.ConsumerGroupsCount; i++)
{
- var streamId = BinaryPrimitives.ReadInt32LittleEndian(payload[position..(position + 4)]);
- var topicId = BinaryPrimitives.ReadInt32LittleEndian(payload[(position + 4)..(position + 8)]);
- var consumerGroupId = BinaryPrimitives.ReadInt32LittleEndian(payload[(position + 8)..(position + 12)]);
+ var streamId = BinaryPrimitives.ReadUInt32LittleEndian(payload[position..(position + 4)]);
+ var topicId = BinaryPrimitives.ReadUInt32LittleEndian(payload[(position + 4)..(position + 8)]);
+ var consumerGroupId = BinaryPrimitives.ReadUInt32LittleEndian(payload[(position + 8)..(position + 12)]);
var consumerGroup
= new ConsumerGroupInfo
{
@@ -295,38 +305,37 @@ internal static IReadOnlyList MapClients(ReadOnlySpan payl
return response;
}
- private static (ClientResponse response, int position) MapClientInfo(ReadOnlySpan payload, int position)
+ private static (ClientResponse response, int readBytes) MapClientInfo(ReadOnlySpan payload, int position)
{
- int readBytes;
+ var start = position;
var id = BinaryPrimitives.ReadUInt32LittleEndian(payload[position..(position + 4)]);
var userId = BinaryPrimitives.ReadUInt32LittleEndian(payload[(position + 4)..(position + 8)]);
- var transportByte = payload[position + 8];
- var transport = transportByte switch
+ var transport = payload[position + 8] switch
{
- 1 => "TCP",
- 2 => "QUIC",
- _ => "Unknown"
+ 1 => ClientTransport.Tcp,
+ 2 => ClientTransport.Quic,
+ 3 => ClientTransport.Http,
+ 4 => ClientTransport.WebSocket,
+ _ => ClientTransport.Unknown
};
- var addressLength = BinaryPrimitives.ReadInt32LittleEndian(payload[(position + 9)..(position + 13)]);
- var address = Encoding.UTF8.GetString(payload[(position + 13)..(position + 13 + addressLength)]);
- readBytes = 4 + 1 + 4 + 4 + addressLength;
- position += readBytes;
- var consumerGroupsCount = BinaryPrimitives.ReadInt32LittleEndian(payload[position..(position + 4)]);
- readBytes += 4;
+ position += 9;
+ var address = ReadString(payload, ref position, "Client address");
+ var consumerGroupsCount = BinaryPrimitives.ReadUInt32LittleEndian(payload[position..(position + 4)]);
+ position += 4;
return (new ClientResponse
{
ClientId = id,
- UserId = userId,
- Transport = Enum.Parse(transport, true),
+ UserId = userId == uint.MaxValue ? null : userId,
+ Transport = transport,
Address = address,
ConsumerGroupsCount = consumerGroupsCount
- }, readBytes);
+ }, position - start);
}
internal static OffsetResponse MapOffsets(ReadOnlySpan payload)
{
- var partitionId = BinaryPrimitives.ReadInt32LittleEndian(payload[..4]);
+ var partitionId = BinaryPrimitives.ReadUInt32LittleEndian(payload[..4]);
var currentOffset = BinaryPrimitives.ReadUInt64LittleEndian(payload[4..12]);
var offset = BinaryPrimitives.ReadUInt64LittleEndian(payload[12..20]);
@@ -343,7 +352,7 @@ internal static PolledMessagesRental MapRentedMessages(ReadOnlyMemory payl
{
ReadOnlySpan span = payload.Span;
var length = payload.Length;
- var partitionId = BinaryPrimitives.ReadInt32LittleEndian(span[..4]);
+ var partitionId = BinaryPrimitives.ReadUInt32LittleEndian(span[..4]);
var currentOffset = BinaryPrimitives.ReadUInt64LittleEndian(span[4..12]);
var messagesCount = BinaryPrimitives.ReadUInt32LittleEndian(span[12..16]);
var position = 16;
@@ -433,7 +442,7 @@ internal static PolledMessagesRental MapRentedMessages(ReadOnlyMemory payl
}
catch (Exception ex)
{
- throw new MessageDecryptionException(offset, (uint)partitionId, ex);
+ throw new MessageDecryptionException(offset, partitionId, ex);
}
}
@@ -608,31 +617,10 @@ internal static Dictionary MapHeaders(ReadOnlySpan
while (position < payload.Length)
{
- var keyKind = MapHeaderKind(payload[position]);
- position++;
-
- var keyLength = BinaryPrimitives.ReadInt32LittleEndian(payload[position..(position + 4)]);
- if (keyLength is 0 or > 255)
- {
- throw new ArgumentException("Key has incorrect size, must be between 1 and 255", nameof(keyLength));
- }
-
- position += 4;
- var keyValue = payload[position..(position + keyLength)].ToArray();
- position += keyLength;
-
- var valueKind = MapHeaderKind(payload[position]);
- position++;
-
- var valueLength = BinaryPrimitives.ReadInt32LittleEndian(payload[position..(position + 4)]);
- if (valueLength is 0 or > 255)
- {
- throw new ArgumentException("Value has incorrect size, must be between 1 and 255", nameof(valueLength));
- }
-
- position += 4;
- ReadOnlySpan value = payload[position..(position + valueLength)];
- position += valueLength;
+ var keyKind = ReadHeaderKind(payload, ref position, "key");
+ var keyValue = ReadHeaderField(payload, ref position, keyKind, "key");
+ var valueKind = ReadHeaderKind(payload, ref position, "value");
+ var value = ReadHeaderField(payload, ref position, valueKind, "value");
headers[new HeaderKey
{
@@ -642,97 +630,61 @@ headers[new HeaderKey
new HeaderValue
{
Kind = valueKind,
- Value = value.ToArray()
+ Value = value
};
}
return headers;
}
- internal static Dictionary? TryMapHeaders(ReadOnlySpan payload)
+ private static HeaderKind ReadHeaderKind(ReadOnlySpan payload, ref int position, string field)
{
- if (payload.Length == 0 || payload[0] is 0 or > 15)
+ if (position >= payload.Length)
{
- return null;
+ throw new MalformedResponseException($"Header {field} kind at byte {position} is missing.");
}
- var headers = new Dictionary();
- var position = 0;
-
- while (position < payload.Length)
+ if (!TryMapHeaderKind(payload[position], out var kind))
{
- if (!TryMapHeaderKind(payload[position], out var keyKind))
- {
- return null;
- }
-
- position++;
-
- if (position + 4 > payload.Length)
- {
- return null;
- }
-
- var keyLength = BinaryPrimitives.ReadInt32LittleEndian(payload[position..(position + 4)]);
- if (keyLength is <= 0 or > 255)
- {
- return null;
- }
-
- position += 4;
- if (position + keyLength > payload.Length)
- {
- return null;
- }
-
- var keyValue = payload[position..(position + keyLength)].ToArray();
- position += keyLength;
-
- if (position >= payload.Length)
- {
- return null;
- }
-
- if (!TryMapHeaderKind(payload[position], out var valueKind))
- {
- return null;
- }
-
- position++;
+ throw new MalformedResponseException(
+ $"Header {field} kind {payload[position]} at byte {position} is unknown.");
+ }
- if (position + 4 > payload.Length)
- {
- return null;
- }
+ position++;
+ return kind;
+ }
- var valueLength = BinaryPrimitives.ReadInt32LittleEndian(payload[position..(position + 4)]);
- if (valueLength is <= 0 or > 255)
- {
- return null;
- }
+ private static byte[] ReadHeaderField(ReadOnlySpan payload, ref int position, HeaderKind kind,
+ string field)
+ {
+ if (position + 4 > payload.Length)
+ {
+ throw new MalformedResponseException($"Header {field} length at byte {position} is truncated.");
+ }
- position += 4;
- if (position + valueLength > payload.Length)
- {
- return null;
- }
+ var length = BinaryPrimitives.ReadUInt32LittleEndian(payload[position..(position + 4)]);
+ if (length is 0 or > 255)
+ {
+ throw new MalformedResponseException(
+ $"Header {field} length {length} at byte {position} must be between 1 and 255.");
+ }
- ReadOnlySpan value = payload[position..(position + valueLength)];
- position += valueLength;
+ if (!ValueLengthMatchesKind(kind, (int)length))
+ {
+ throw new MalformedResponseException(
+ $"Header {field} of kind {kind} has {length} bytes, expected {HeaderKindWidth(kind)}.");
+ }
- headers[new HeaderKey
- {
- Kind = keyKind,
- Value = keyValue
- }] =
- new HeaderValue
- {
- Kind = valueKind,
- Value = value.ToArray()
- };
+ position += 4;
+ if (position + length > payload.Length)
+ {
+ throw new MalformedResponseException(
+ $"Header {field} of {length} bytes at byte {position} exceeds the {payload.Length}-byte payload.");
}
- return headers;
+ var value = payload[position..(position + (int)length)].ToArray();
+ position += (int)length;
+ return value;
}
internal static HeaderKind MapHeaderKind(byte value)
@@ -758,6 +710,76 @@ internal static HeaderKind MapHeaderKind(byte value)
};
}
+ ///
+ /// The typed accessors on slice the raw bytes by kind, so a value whose
+ /// length disagrees with its kind byte is rejected at parse time instead of throwing in the caller's
+ /// message handler.
+ ///
+ private static bool ValueLengthMatchesKind(HeaderKind kind, int length)
+ {
+ var width = HeaderKindWidth(kind);
+ return width == 0 || width == length;
+ }
+
+ private static int HeaderKindWidth(HeaderKind kind)
+ {
+ return kind switch
+ {
+ HeaderKind.Bool or HeaderKind.Int8 or HeaderKind.Uint8 => 1,
+ HeaderKind.Int16 or HeaderKind.Uint16 => 2,
+ HeaderKind.Int32 or HeaderKind.Uint32 or HeaderKind.Float => 4,
+ HeaderKind.Int64 or HeaderKind.Uint64 or HeaderKind.Double => 8,
+ HeaderKind.Int128 or HeaderKind.Uint128 => 16,
+ _ => 0
+ };
+ }
+
+ ///
+ /// The bytes left in the payload bound how many elements can exist, so a count above that is rejected
+ /// before the list is pre-sized instead of failing mid-loop.
+ ///
+ private static int ValidatedCollectionSize(uint count, int remaining, int minElementSize, string field)
+ {
+ if (count > Math.Max(remaining, 0) / minElementSize)
+ {
+ throw new MalformedResponseException(
+ $"{field} {count} exceeds remaining payload of {remaining} bytes.");
+ }
+
+ return (int)count;
+ }
+
+ ///
+ /// Length prefixes are u32 on the wire. Reading them signed would let 2^31 and above go negative and
+ /// surface as a slicing exception instead of .
+ ///
+ private static int ReadLength(ReadOnlySpan payload, int position, string field)
+ {
+ if (position + 4 > payload.Length)
+ {
+ throw new MalformedResponseException($"{field} length prefix at byte {position} is truncated.");
+ }
+
+ var length = BinaryPrimitives.ReadUInt32LittleEndian(payload[position..(position + 4)]);
+ var remaining = payload.Length - position - 4;
+ if (length > remaining)
+ {
+ throw new MalformedResponseException(
+ $"{field} length {length} exceeds remaining payload of {remaining} bytes.");
+ }
+
+ return (int)length;
+ }
+
+ private static string ReadString(ReadOnlySpan payload, ref int position, string field)
+ {
+ var length = ReadLength(payload, position, field);
+ position += 4;
+ var value = Encoding.UTF8.GetString(payload[position..(position + length)]);
+ position += length;
+ return value;
+ }
+
private static bool TryMapHeaderKind(byte value, out HeaderKind kind)
{
if (value is >= 1 and <= 15)
@@ -773,25 +795,15 @@ private static bool TryMapHeaderKind(byte value, out HeaderKind kind)
private static Dictionary MapOptions(ReadOnlySpan payload, int position,
out int readBytes)
{
- // Every length here is server-controlled. Read the block length as long
- // so a value above int.MaxValue cannot wrap negative, and bound each
- // entry against the block before slicing: an entry that overruns `end`
- // would otherwise be accepted and silently consume the response bytes
- // that follow the block.
- var optionsLength = BinaryPrimitives.ReadUInt32LittleEndian(payload[position..(position + 4)]);
- var available = (long)payload.Length - (position + 4);
- if (optionsLength > available)
- {
- throw new MalformedResponseException(
- $"Malformed options block at byte {position}: declared length {optionsLength} exceeds the " +
- $"{available} bytes remaining in the payload.");
- }
-
- readBytes = 4 + (int)optionsLength;
+ // Every length here is server-controlled. Bound each entry against the
+ // block before slicing: an entry that overruns `end` would otherwise be
+ // accepted and silently consume the response bytes that follow the block.
+ var optionsLength = ReadLength(payload, position, "Options block");
+ readBytes = 4 + optionsLength;
var options = new Dictionary();
var cursor = position + 4;
- var end = cursor + (int)optionsLength;
+ var end = cursor + optionsLength;
while (cursor < end)
{
var keyKindCode = ReadOptionByte(payload, ref cursor, end, position);
@@ -809,6 +821,19 @@ private static Dictionary MapOptions(ReadOnlySpan
continue;
}
+ if (!ValueLengthMatchesKind(keyKind, key.Length))
+ {
+ throw new MalformedResponseException(
+ $"Malformed options block at byte {position}: key of kind {keyKind} has {key.Length} bytes.");
+ }
+
+ if (!ValueLengthMatchesKind(valueKind, value.Length))
+ {
+ throw new MalformedResponseException(
+ $"Malformed options block at byte {position}: value of kind {valueKind} has {value.Length} " +
+ "bytes.");
+ }
+
options[new HeaderKey
{
Kind = keyKind,
@@ -932,9 +957,8 @@ internal static StreamResponse MapStream(ReadOnlySpan payload)
{
var (stream, position) = MapToStream(payload, 0);
- // Count-driven: topic elements carry variable-length options blocks,
- // so "consume until the buffer ends" no longer delimits them.
- List topics = new(stream.TopicsCount);
+ List topics = new(ValidatedCollectionSize(stream.TopicsCount, payload.Length - position,
+ MIN_TOPIC_SIZE, "Stream topics count"));
for (var i = 0; i < stream.TopicsCount; i++)
{
var (topic, readBytes) = MapToTopic(payload, position);
@@ -959,7 +983,7 @@ private static (StreamResponse stream, int readBytes) MapToStream(ReadOnlySpan MapTopics(ReadOnlySpan payload)
{
var topicsCount = BinaryPrimitives.ReadUInt32LittleEndian(payload[..4]);
- List topics = new((int)topicsCount);
var position = 4;
+ List topics = new(ValidatedCollectionSize(topicsCount, payload.Length - position,
+ MIN_TOPIC_SIZE, "Topics count"));
for (var i = 0; i < topicsCount; i++)
{
@@ -1066,9 +1091,9 @@ private static (TopicResponse topic, int readBytes) MapToTopic(ReadOnlySpan
payload, int position)
{
- var id = BinaryPrimitives.ReadInt32LittleEndian(payload[position..(position + 4)]);
+ var id = BinaryPrimitives.ReadUInt32LittleEndian(payload[position..(position + 4)]);
var createdAt = BinaryPrimitives.ReadUInt64LittleEndian(payload[(position + 4)..(position + 12)]);
- var segmentsCount = BinaryPrimitives.ReadInt32LittleEndian(payload[(position + 12)..(position + 16)]);
+ var segmentsCount = BinaryPrimitives.ReadUInt32LittleEndian(payload[(position + 12)..(position + 16)]);
var currentOffset = BinaryPrimitives.ReadUInt64LittleEndian(payload[(position + 16)..(position + 24)]);
var sizeBytes = BinaryPrimitives.ReadUInt64LittleEndian(payload[(position + 24)..(position + 32)]);
var messagesCount = BinaryPrimitives.ReadUInt64LittleEndian(payload[(position + 32)..(position + 40)]);
@@ -1103,7 +1128,7 @@ internal static List MapConsumerGroups(ReadOnlySpan
internal static StatsResponse MapStats(ReadOnlySpan payload)
{
- var processId = BinaryPrimitives.ReadInt32LittleEndian(payload[..4]);
+ var processId = BinaryPrimitives.ReadUInt32LittleEndian(payload[..4]);
var cpuUsage = BitConverter.ToSingle(payload[4..8]);
var totalCpuUsage = BitConverter.ToSingle(payload[8..12]);
var memoryUsage = BinaryPrimitives.ReadUInt64LittleEndian(payload[12..20]);
@@ -1114,37 +1139,28 @@ internal static StatsResponse MapStats(ReadOnlySpan payload)
var readBytes = BinaryPrimitives.ReadUInt64LittleEndian(payload[52..60]);
var writtenBytes = BinaryPrimitives.ReadUInt64LittleEndian(payload[60..68]);
var totalSizeBytes = BinaryPrimitives.ReadUInt64LittleEndian(payload[68..76]);
- var streamsCount = BinaryPrimitives.ReadInt32LittleEndian(payload[76..80]);
- var topicsCount = BinaryPrimitives.ReadInt32LittleEndian(payload[80..84]);
- var partitionsCount = BinaryPrimitives.ReadInt32LittleEndian(payload[84..88]);
- var segmentsCount = BinaryPrimitives.ReadInt32LittleEndian(payload[88..92]);
+ var streamsCount = BinaryPrimitives.ReadUInt32LittleEndian(payload[76..80]);
+ var topicsCount = BinaryPrimitives.ReadUInt32LittleEndian(payload[80..84]);
+ var partitionsCount = BinaryPrimitives.ReadUInt32LittleEndian(payload[84..88]);
+ var segmentsCount = BinaryPrimitives.ReadUInt32LittleEndian(payload[88..92]);
var messagesCount = BinaryPrimitives.ReadUInt64LittleEndian(payload[92..100]);
- var clientsCount = BinaryPrimitives.ReadInt32LittleEndian(payload[100..104]);
- var consumerGroupsCount = BinaryPrimitives.ReadInt32LittleEndian(payload[104..108]);
+ var clientsCount = BinaryPrimitives.ReadUInt32LittleEndian(payload[100..104]);
+ var consumerGroupsCount = BinaryPrimitives.ReadUInt32LittleEndian(payload[104..108]);
var position = 108;
- var hostnameLength = BinaryPrimitives.ReadInt32LittleEndian(payload[position..(position + 4)]);
- var hostname = Encoding.UTF8.GetString(payload[(position + 4)..(position + 4 + hostnameLength)]);
- position += 4 + hostnameLength;
- var osNameLength = BinaryPrimitives.ReadInt32LittleEndian(payload[position..(position + 4)]);
- var osName = Encoding.UTF8.GetString(payload[(position + 4)..(position + 4 + osNameLength)]);
- position += 4 + osNameLength;
- var osVersionLength = BinaryPrimitives.ReadInt32LittleEndian(payload[position..(position + 4)]);
- var osVersion = Encoding.UTF8.GetString(payload[(position + 4)..(position + 4 + osVersionLength)]);
- position += 4 + osVersionLength;
- var kernelVersionLength = BinaryPrimitives.ReadInt32LittleEndian(payload[position..(position + 4)]);
- var kernelVersion = Encoding.UTF8.GetString(payload[(position + 4)..(position + 4 + kernelVersionLength)]);
- position += 4 + kernelVersionLength;
- var iggyVersionLength = BinaryPrimitives.ReadInt32LittleEndian(payload[position..(position + 4)]);
- var iggyVersion = Encoding.UTF8.GetString(payload[(position + 4)..(position + 4 + iggyVersionLength)]);
- position += 4 + iggyVersionLength;
+ var hostname = ReadString(payload, ref position, "Stats hostname");
+ var osName = ReadString(payload, ref position, "Stats os name");
+ var osVersion = ReadString(payload, ref position, "Stats os version");
+ var kernelVersion = ReadString(payload, ref position, "Stats kernel version");
+ var iggyVersion = ReadString(payload, ref position, "Stats iggy version");
var iggySemVersion = BinaryPrimitives.ReadUInt32LittleEndian(payload[position..(position + 4)]);
position += 4;
- var cacheMetricsLength = BinaryPrimitives.ReadInt32LittleEndian(payload[position..(position + 4)]);
+ var cacheMetricsLength = BinaryPrimitives.ReadUInt32LittleEndian(payload[position..(position + 4)]);
position += 4;
- var cacheMetricsList = new Dictionary(cacheMetricsLength);
+ var cacheMetricsList = new Dictionary(ValidatedCollectionSize(
+ cacheMetricsLength, payload.Length - position, CACHE_METRICS_ENTRY_SIZE, "Cache metrics count"));
for (var i = 0; i < cacheMetricsLength; i++)
{
var cacheMetricsKey = new CacheMetricsKey
@@ -1228,13 +1244,29 @@ internal static ConsumerGroupResponse MapConsumerGroup(ReadOnlySpan payloa
private static (ConsumerGroupMember, int readBytes) MapToMember(ReadOnlySpan payload, int position)
{
+ if (position + MEMBER_HEADER_SIZE > payload.Length)
+ {
+ throw new MalformedResponseException(
+ $"Malformed consumer group member at byte {position}: {payload.Length - position} bytes cannot " +
+ "hold a member header.");
+ }
+
var id = BinaryPrimitives.ReadUInt32LittleEndian(payload[position..(position + 4)]);
- var partitionsCount = BinaryPrimitives.ReadInt32LittleEndian(payload[(position + 4)..(position + 8)]);
- var partitions = new List();
+ var partitionsCount = BinaryPrimitives.ReadUInt32LittleEndian(payload[(position + 4)..(position + 8)]);
+
+ var readBytes = MEMBER_HEADER_SIZE + (long)partitionsCount * 4;
+ if (position + readBytes > payload.Length)
+ {
+ throw new MalformedResponseException(
+ $"Malformed consumer group member at byte {position}: partitions count {partitionsCount} does not " +
+ "fit the response.");
+ }
+
+ var partitions = new List((int)partitionsCount);
for (var i = 0; i < partitionsCount; i++)
{
- var partitionId
- = BinaryPrimitives.ReadInt32LittleEndian(payload[(position + 8 + i * 4)..(position + 8 + (i + 1) * 4)]);
+ var partitionStart = position + MEMBER_HEADER_SIZE + i * 4;
+ var partitionId = BinaryPrimitives.ReadUInt32LittleEndian(payload[partitionStart..(partitionStart + 4)]);
partitions.Add(partitionId);
}
@@ -1244,17 +1276,30 @@ var partitionId
PartitionsCount = partitionsCount,
Partitions = partitions
},
- 8 + partitionsCount * 4);
+ (int)readBytes);
}
private static (ConsumerGroupResponse consumerGroup, int readBytes) MapToConsumerGroup(ReadOnlySpan payload,
int position)
{
+ if (position + CONSUMER_GROUP_HEADER_SIZE > payload.Length)
+ {
+ throw new MalformedResponseException(
+ $"Malformed consumer group at byte {position}: {payload.Length - position} bytes cannot hold a " +
+ "consumer group header.");
+ }
+
var id = BinaryPrimitives.ReadUInt32LittleEndian(payload[position..(position + 4)]);
var partitionsCount = BinaryPrimitives.ReadUInt32LittleEndian(payload[(position + 4)..(position + 8)]);
var membersCount = BinaryPrimitives.ReadUInt32LittleEndian(payload[(position + 8)..(position + 12)]);
var nameLength = payload[position + 12];
- var name = Encoding.UTF8.GetString(payload[(position + 13)..(position + 13 + nameLength)]);
+ if (position + CONSUMER_GROUP_HEADER_SIZE + nameLength > payload.Length)
+ {
+ throw new MalformedResponseException(
+ $"Malformed consumer group at byte {position}: name length {nameLength} does not fit the response.");
+ }
+
+ var name = Encoding.UTF8.GetString(payload[(position + CONSUMER_GROUP_HEADER_SIZE)..(position + CONSUMER_GROUP_HEADER_SIZE + nameLength)]);
return (new ConsumerGroupResponse
{
@@ -1262,36 +1307,38 @@ private static (ConsumerGroupResponse consumerGroup, int readBytes) MapToConsume
Name = name,
MembersCount = membersCount,
PartitionsCount = partitionsCount
- }, 13 + name.Length);
+ }, CONSUMER_GROUP_HEADER_SIZE + nameLength);
}
internal static IReadOnlyList MapOptionSpecs(ReadOnlySpan payload)
{
var count = BinaryPrimitives.ReadUInt32LittleEndian(payload[..4]);
var position = 4;
- var specs = new List();
+ var specs = new List(ValidatedCollectionSize(count, payload.Length - position,
+ MIN_OPTION_SPEC_SIZE, "Option specs count"));
for (var i = 0; i < count; i++)
{
var keyLength = payload[position];
position += 1;
- EnsureFits(payload, position, keyLength, "option key");
+ if (position + keyLength > payload.Length)
+ {
+ throw new MalformedResponseException(
+ $"Malformed DescribeOptions response: option key of {keyLength} bytes at offset {position} " +
+ $"overruns the {payload.Length}-byte payload");
+ }
+
var key = Encoding.UTF8.GetString(payload[position..(position + keyLength)]);
position += keyLength;
var kind = payload[position];
position += 1;
- var defaultLength = (int)BinaryPrimitives.ReadUInt32LittleEndian(payload[position..(position + 4)]);
+ var defaultLength = ReadLength(payload, position, "Option default value");
position += 4;
- EnsureFits(payload, position, defaultLength, "option default value");
var defaultValue = payload[position..(position + defaultLength)].ToArray();
position += defaultLength;
- var descriptionLength = (int)BinaryPrimitives.ReadUInt32LittleEndian(payload[position..(position + 4)]);
- position += 4;
- EnsureFits(payload, position, descriptionLength, "option description");
- var description = Encoding.UTF8.GetString(payload[position..(position + descriptionLength)]);
- position += descriptionLength;
+ var description = ReadString(payload, ref position, "Option description");
specs.Add(new OptionSpec
{
@@ -1305,31 +1352,23 @@ internal static IReadOnlyList MapOptionSpecs(ReadOnlySpan payl
return specs;
}
- private static void EnsureFits(ReadOnlySpan payload, int position, int length, string what)
+ internal static ClusterMetadata MapClusterMetadata(ReadOnlySpan payload)
{
- if (position + length > payload.Length)
+ var position = 0;
+ var clusterName = ReadString(payload, ref position, "Cluster name");
+ if (position + 4 > payload.Length)
{
- throw new InvalidOperationException(
- $"Malformed DescribeOptions response: {what} of {length} bytes at offset {position} " +
- $"overruns the {payload.Length}-byte payload");
+ throw new MalformedResponseException($"Cluster nodes count at byte {position} is truncated.");
}
- }
-
- internal static ClusterMetadata MapClusterMetadata(ReadOnlySpan payload)
- {
- var nameLength = BinaryPrimitives.ReadUInt32LittleEndian(payload[..4]);
- var clusterName = Encoding.UTF8.GetString(payload[4..(4 + (int)nameLength)]);
- var position = 4 + (int)nameLength;
var nodesCount = BinaryPrimitives.ReadUInt32LittleEndian(payload[position..(position + 4)]);
position += 4;
- var nodes = new ClusterNode[nodesCount];
- for (var i = 0; i < nodesCount; i++)
+ var nodes = new ClusterNode[ValidatedCollectionSize(nodesCount, payload.Length - position,
+ MIN_CLUSTER_NODE_SIZE, "Cluster nodes count")];
+ for (var i = 0; i < nodes.Length; i++)
{
- var node = MapClusterNode(payload[position..]);
- nodes[i] = node;
- position += node.GetSize();
+ nodes[i] = MapClusterNode(payload, ref position);
}
return new ClusterMetadata
@@ -1339,37 +1378,36 @@ internal static ClusterMetadata MapClusterMetadata(ReadOnlySpan payload)
};
}
- private static ClusterNode MapClusterNode(ReadOnlySpan payload)
+ private static ClusterNode MapClusterNode(ReadOnlySpan payload, ref int position)
{
- var position = 0;
-
- // Read name
- var nameLength = BinaryPrimitives.ReadUInt32LittleEndian(payload[position..(position + 4)]);
- position += 4;
-
- var name = Encoding.UTF8.GetString(payload[position..(position + (int)nameLength)]);
- position += (int)nameLength;
-
- // Read IP
- var ipLength = BinaryPrimitives.ReadUInt32LittleEndian(payload[position..(position + 4)]);
- position += 4;
-
- var ip = Encoding.UTF8.GetString(payload[position..(position + (int)ipLength)]);
- position += (int)ipLength;
+ var name = ReadString(payload, ref position, "Cluster node name");
+ var ip = ReadString(payload, ref position, "Cluster node ip");
+ if (position + CLUSTER_NODE_TAIL_SIZE > payload.Length)
+ {
+ throw new MalformedResponseException(
+ $"Cluster node at byte {position}: {payload.Length - position} bytes cannot hold the ports, role and status.");
+ }
- // Read transport endpoints (4 ports, each 2 bytes)
var tcp = BinaryPrimitives.ReadUInt16LittleEndian(payload[position..(position + 2)]);
- position += 2;
- var quic = BinaryPrimitives.ReadUInt16LittleEndian(payload[position..(position + 2)]);
- position += 2;
- var http = BinaryPrimitives.ReadUInt16LittleEndian(payload[position..(position + 2)]);
- position += 2;
- var webSocket = BinaryPrimitives.ReadUInt16LittleEndian(payload[position..(position + 2)]);
- position += 2;
-
- // Read role and status
- var role = (ClusterNodeRole)payload[position++];
- var status = (ClusterNodeStatus)payload[position];
+ var quic = BinaryPrimitives.ReadUInt16LittleEndian(payload[(position + 2)..(position + 4)]);
+ var http = BinaryPrimitives.ReadUInt16LittleEndian(payload[(position + 4)..(position + 6)]);
+ var webSocket = BinaryPrimitives.ReadUInt16LittleEndian(payload[(position + 6)..(position + 8)]);
+ var role = payload[position + 8] switch
+ {
+ 0 => ClusterNodeRole.Leader,
+ 1 => ClusterNodeRole.Follower,
+ var unknown => throw new MalformedResponseException($"Unknown cluster node role {unknown}.")
+ };
+ var status = payload[position + 9] switch
+ {
+ 0 => ClusterNodeStatus.Healthy,
+ 1 => ClusterNodeStatus.Starting,
+ 2 => ClusterNodeStatus.Stopping,
+ 3 => ClusterNodeStatus.Unreachable,
+ 4 => ClusterNodeStatus.Maintenance,
+ _ => ClusterNodeStatus.Unknown
+ };
+ position += 10;
return new ClusterNode
{
diff --git a/foreign/csharp/Iggy_SDK/Vsr/ConsumerGroupClientState.cs b/foreign/csharp/Iggy_SDK/Vsr/ConsumerGroupClientState.cs
index e594443ae9..4f65635796 100644
--- a/foreign/csharp/Iggy_SDK/Vsr/ConsumerGroupClientState.cs
+++ b/foreign/csharp/Iggy_SDK/Vsr/ConsumerGroupClientState.cs
@@ -38,12 +38,29 @@ internal sealed class ConsumerGroupClientState
/// Nothing tells this client when another one resizes a topic, so the count expires on its own.
private const long PartitionCountTtlMs = 30_000;
- /// True when a non-empty assignment is cached for the group.
+ ///
+ /// How long a cached assignment is trusted before the next poll asks the coordinator again. A rebalance
+ /// that took a partition away shows up as a fenced poll long before this expires; the periodic re-sync
+ /// catches what fencing cannot, such as a member holding zero partitions being handed one, which no poll
+ /// of its own would ever reveal. Matches the Go SDK's assignmentRefreshInterval.
+ ///
+ internal static readonly long AssignmentRefreshMs = 5_000;
+
+ ///
+ /// True when a fresh assignment is cached for the group, even one holding zero partitions. Treating an
+ /// empty assignment as missing would re-sync on every poll of a member that owns nothing; treating it as
+ /// fresh forever would leave that member polling nothing until an unrelated heartbeat refreshed it.
+ ///
internal bool HasAssignment(GroupKey key)
+ {
+ return HasAssignment(key, Environment.TickCount64);
+ }
+
+ internal bool HasAssignment(GroupKey key, long now)
{
lock (_gate)
{
- return _assignments.TryGetValue(key, out var assignment) && assignment.Partitions.Count > 0;
+ return _assignments.TryGetValue(key, out var assignment) && now < assignment.RefreshAt;
}
}
@@ -68,6 +85,7 @@ internal void SetAssignment(GroupKey key, ulong generation, IReadOnlyList
assignment.Generation = generation;
assignment.Partitions = partitions;
+ assignment.RefreshAt = Environment.TickCount64 + AssignmentRefreshMs;
}
}
@@ -179,7 +197,7 @@ internal void DeregisterGroup(GroupKey key)
///
/// True when the last assignment sync saw this client as a member. A member mid-rebalance, or one holding
/// zero partitions, is still registered, so this asks a different question than
- /// .
+ /// .
///
internal bool IsRegistered(GroupKey key)
{
@@ -222,6 +240,7 @@ private sealed class GroupAssignment
internal IReadOnlyList Partitions { get; set; } = [];
internal ulong Generation { get; set; }
internal int Cursor { get; set; }
+ internal long RefreshAt { get; set; }
}
}
diff --git a/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/NoAssignedPartitionBackoffTests.cs b/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/NoAssignedPartitionBackoffTests.cs
new file mode 100644
index 0000000000..f2acad646e
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/NoAssignedPartitionBackoffTests.cs
@@ -0,0 +1,193 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+using System.Diagnostics;
+using Apache.Iggy.Consumers;
+using Apache.Iggy.Contracts;
+using Apache.Iggy.Enums;
+using Apache.Iggy.IggyClient;
+using Apache.Iggy.Kinds;
+using Apache.Iggy.Messages;
+using Apache.Iggy.Vsr;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+
+namespace Apache.Iggy.Tests.ConsumerTests;
+
+///
+/// A group poll that reports must not be re-issued at once:
+/// with PollingIntervalMs at zero that would spin against the coordinator until a partition arrives.
+///
+public sealed class NoAssignedPartitionBackoffTests
+{
+ private const int MinimumGapMs = 80;
+
+ [Fact]
+ public async Task given_no_assigned_partition_when_receiving_should_back_off_before_polling_again()
+ {
+ var polls = new List();
+ var stopwatch = Stopwatch.StartNew();
+ var mock = new Mock(MockBehavior.Loose);
+ mock.Setup(c => c.PollMessagesAsync(It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(() =>
+ {
+ polls.Add(stopwatch.ElapsedMilliseconds);
+
+ return polls.Count < 3 ? NoAssignment() : OneMessage();
+ });
+ var consumer = new IggyConsumer(mock.Object, BuildConfig(), NullLoggerFactory.Instance);
+ await consumer.InitAsync(TestContext.Current.CancellationToken);
+
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+ await using var messages = consumer.ReceiveAsync(cts.Token)
+ .GetAsyncEnumerator(TestContext.Current.CancellationToken);
+ Assert.True(await messages.MoveNextAsync());
+
+ Assert.True(polls.Count >= 3);
+ Assert.True(polls[1] - polls[0] >= MinimumGapMs, $"second poll came {polls[1] - polls[0]}ms after the first");
+ Assert.True(polls[2] - polls[1] >= MinimumGapMs, $"third poll came {polls[2] - polls[1]}ms after the second");
+ await consumer.DisposeAsync();
+ }
+
+ [Fact]
+ public async Task given_no_assigned_partition_when_receiving_rented_should_back_off_before_polling_again()
+ {
+ var polls = new List();
+ var stopwatch = Stopwatch.StartNew();
+ var mock = new Mock(MockBehavior.Loose);
+ mock.Setup(c => c.PollMessagesRentedAsync(It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny()))
+ .ReturnsAsync(() =>
+ {
+ polls.Add(stopwatch.ElapsedMilliseconds);
+
+ return polls.Count < 3 ? NoAssignmentRental() : OneMessageRental();
+ });
+ var consumer = new IggyConsumer(mock.Object, BuildConfig(), NullLoggerFactory.Instance);
+ await consumer.InitAsync(TestContext.Current.CancellationToken);
+
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+ await using var messages = consumer.ReceiveRentedAsync(cts.Token)
+ .GetAsyncEnumerator(TestContext.Current.CancellationToken);
+ Assert.True(await messages.MoveNextAsync());
+ messages.Current.Dispose();
+
+ Assert.True(polls.Count >= 3);
+ Assert.True(polls[1] - polls[0] >= MinimumGapMs, $"second poll came {polls[1] - polls[0]}ms after the first");
+ Assert.True(polls[2] - polls[1] >= MinimumGapMs, $"third poll came {polls[2] - polls[1]}ms after the second");
+ await consumer.DisposeAsync();
+ }
+
+ [Fact]
+ public async Task given_no_assigned_partition_when_cancelled_mid_backoff_should_not_publish_an_error()
+ {
+ var errors = 0;
+ var mock = new Mock(MockBehavior.Loose);
+ mock.Setup(c => c.PollMessagesAsync(It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(NoAssignment);
+ var consumer = new IggyConsumer(mock.Object, BuildConfig(), NullLoggerFactory.Instance);
+ consumer.SubscribeToErrorEvents(_ =>
+ {
+ Interlocked.Increment(ref errors);
+ return Task.CompletedTask;
+ });
+ await consumer.InitAsync(TestContext.Current.CancellationToken);
+
+ using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(30));
+ await using var messages = consumer.ReceiveAsync(cts.Token)
+ .GetAsyncEnumerator(TestContext.Current.CancellationToken);
+ await Assert.ThrowsAnyAsync(async () => await messages.MoveNextAsync());
+
+ Assert.Equal(0, Volatile.Read(ref errors));
+ await consumer.DisposeAsync();
+ }
+
+ private static IggyConsumerConfig BuildConfig()
+ {
+ return new IggyConsumerConfig
+ {
+ StreamId = Identifier.Numeric(1),
+ TopicId = Identifier.Numeric(1),
+ Consumer = Consumer.Group("group-1"),
+ PollingStrategy = PollingStrategy.Next(),
+ BatchSize = 10,
+ PartitionId = null,
+ AutoCommitMode = AutoCommitMode.Disabled,
+ AutoCommit = false,
+ PollingIntervalMs = 0
+ };
+ }
+
+ private static PolledMessages NoAssignment()
+ {
+ return new PolledMessages
+ {
+ PartitionId = PolledMessages.NoAssignedPartition,
+ CurrentOffset = 0,
+ Messages = []
+ };
+ }
+
+ private static PolledMessages OneMessage()
+ {
+ return new PolledMessages
+ {
+ PartitionId = 1,
+ CurrentOffset = 0,
+ Messages =
+ [
+ new MessageResponse
+ {
+ Header = new MessageHeader
+ {
+ Offset = 0,
+ PayloadLength = 1
+ },
+ Payload = [1],
+ UserHeaders = null
+ }
+ ]
+ };
+ }
+
+ private static PolledMessagesRental NoAssignmentRental()
+ {
+ return new PolledMessagesRental(EmptyMemoryOwner.Instance)
+ {
+ PartitionId = PolledMessages.NoAssignedPartition,
+ CurrentOffset = 0,
+ Messages = []
+ };
+ }
+
+ private static PolledMessagesRental OneMessageRental()
+ {
+ var owner = new RentedConsumerTests.TrackingMemoryOwner(16);
+
+ return new PolledMessagesRental(owner)
+ {
+ PartitionId = 1,
+ CurrentOffset = 0,
+ Messages = RentedConsumerTests.BuildMessages(owner, 1)
+ };
+ }
+}
diff --git a/foreign/csharp/Iggy_SDK_Tests/ContractsTests/MessageBatchGoldenVectorTests.cs b/foreign/csharp/Iggy_SDK_Tests/ContractsTests/MessageBatchGoldenVectorTests.cs
index af2d80be91..5d7ef77694 100644
--- a/foreign/csharp/Iggy_SDK_Tests/ContractsTests/MessageBatchGoldenVectorTests.cs
+++ b/foreign/csharp/Iggy_SDK_Tests/ContractsTests/MessageBatchGoldenVectorTests.cs
@@ -141,7 +141,7 @@ public void MapRentedMessages_DecodesThePollGoldenVector()
using var rental =
Mappers.BinaryMapper.MapRentedMessages(pollBody, EmptyMemoryOwner.Instance);
- Assert.Equal(3, rental.PartitionId);
+ Assert.Equal(3u, rental.PartitionId);
Assert.Equal(101ul, rental.CurrentOffset);
Assert.Equal(2, rental.Messages.Count);
diff --git a/foreign/csharp/Iggy_SDK_Tests/ContractsTests/UserContractsTests.cs b/foreign/csharp/Iggy_SDK_Tests/ContractsTests/UserContractsTests.cs
new file mode 100644
index 0000000000..7bd17fe1b0
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK_Tests/ContractsTests/UserContractsTests.cs
@@ -0,0 +1,124 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+using System.Buffers.Binary;
+using Apache.Iggy.Contracts;
+using Apache.Iggy.Contracts.Auth;
+using Apache.Iggy.Contracts.Tcp;
+
+namespace Apache.Iggy.Tests.ContractsTests;
+
+public sealed class UserContractsTests
+{
+ // [user id: kind 1 + length 1 + value 4][has permissions: 1][permissions length: 4][permissions]
+ private const int PermissionsOffset = 11;
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void UpdatePermissions_WithGlobalOnlyPermissions_KeepsManageServersAndWritesNoStreams(
+ bool emptyStreamsDictionary)
+ {
+ var permissions = new Permissions
+ {
+ Global = new GlobalPermissions
+ {
+ ManageServers = true,
+ ReadServers = true,
+ ManageUsers = false,
+ ReadUsers = false,
+ ManageStreams = false,
+ ReadStreams = false,
+ ManageTopics = false,
+ ReadTopics = false,
+ PollMessages = false,
+ SendMessages = false
+ },
+ Streams = emptyStreamsDictionary ? new Dictionary() : null
+ };
+
+ var bytes = TcpContracts.UpdatePermissions(Identifier.Numeric(1u), permissions);
+
+ var permissionsLength = BinaryPrimitives.ReadInt32LittleEndian(bytes.AsSpan(PermissionsOffset - 4, 4));
+ Assert.Equal(11, permissionsLength);
+ Assert.Equal(PermissionsOffset + 11, bytes.Length);
+
+ var wire = bytes.AsSpan(PermissionsOffset, 11);
+ Assert.Equal(1, wire[0]);
+ Assert.Equal(1, wire[1]);
+ Assert.Equal(0, wire[10]);
+ }
+
+ [Fact]
+ public void UpdatePermissions_WithNullPermissions_WritesHasPermissionsZero()
+ {
+ var bytes = TcpContracts.UpdatePermissions(Identifier.Numeric(1u), null);
+
+ Assert.Equal(PermissionsOffset - 4, bytes.Length);
+ Assert.Equal(0, bytes[^1]);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void UpdatePermissions_WithStreamWithoutTopics_WritesHasTopicsZero(bool emptyTopicsDictionary)
+ {
+ var permissions = new Permissions
+ {
+ Global = new GlobalPermissions
+ {
+ ManageServers = false,
+ ReadServers = false,
+ ManageUsers = false,
+ ReadUsers = false,
+ ManageStreams = false,
+ ReadStreams = false,
+ ManageTopics = false,
+ ReadTopics = false,
+ PollMessages = false,
+ SendMessages = false
+ },
+ Streams = new Dictionary
+ {
+ [7] = new StreamPermissions
+ {
+ ManageStream = false,
+ ReadStream = true,
+ ManageTopics = false,
+ ReadTopics = false,
+ PollMessages = false,
+ SendMessages = false,
+ Topics = emptyTopicsDictionary ? new Dictionary() : null
+ }
+ }
+ };
+
+ var bytes = TcpContracts.UpdatePermissions(Identifier.Numeric(1u), permissions);
+
+ // [global: 10][has streams: 1][stream id: 4][stream flags: 6][has topics: 1][has next stream: 1]
+ const int permissionsLength = 10 + 1 + 4 + 6 + 1 + 1;
+ Assert.Equal(permissionsLength, BinaryPrimitives.ReadInt32LittleEndian(bytes.AsSpan(PermissionsOffset - 4, 4)));
+ Assert.Equal(PermissionsOffset + permissionsLength, bytes.Length);
+
+ var wire = bytes.AsSpan(PermissionsOffset, permissionsLength);
+ Assert.Equal(1, wire[10]);
+ Assert.Equal(7u, BinaryPrimitives.ReadUInt32LittleEndian(wire.Slice(11, 4)));
+ Assert.Equal(1, wire[16]);
+ Assert.Equal(0, wire[21]);
+ Assert.Equal(0, wire[22]);
+ }
+}
diff --git a/foreign/csharp/Iggy_SDK_Tests/MapperTests/BinaryMapper.cs b/foreign/csharp/Iggy_SDK_Tests/MapperTests/BinaryMapper.cs
index 93847e277c..b35afe3336 100644
--- a/foreign/csharp/Iggy_SDK_Tests/MapperTests/BinaryMapper.cs
+++ b/foreign/csharp/Iggy_SDK_Tests/MapperTests/BinaryMapper.cs
@@ -61,7 +61,7 @@ public void MapPersonalAccessTokens_ReturnsValidPersonalAccessTokenResponse()
public void MapOffsets_ReturnsValidOffsetResponse()
{
// Arrange
- var partitionId = Random.Shared.Next(1, 19);
+ var partitionId = (uint)Random.Shared.Next(1, 19);
var currentOffset = (ulong)Random.Shared.Next(420, 69420);
var storedOffset = (ulong)Random.Shared.Next(69, 420);
var payload = BinaryFactory.CreateOffsetPayload(partitionId, currentOffset, storedOffset);
@@ -153,7 +153,7 @@ public void MapStream_ReturnsValidStreamResponse()
// Arrange
var (id, _, sizeBytes, messagesCount, name, createdAt) = StreamFactory.CreateStreamsResponseFields();
// Topics are decoded count-driven, so the header count must match the appended topics.
- var topicsCount = 1;
+ var topicsCount = 1u;
var streamPayload
= BinaryFactory.CreateStreamPayload(id, topicsCount, name, sizeBytes, messagesCount, createdAt);
var (topicId1, partitionsCount1, topicName1, messageExpiry1, topicSizeBytes1, messagesCountTopic1,
@@ -296,6 +296,22 @@ public void MapTopic_WithAnOptionOfAnUnknownKind_KeepsTheOtherEntries()
Assert.Empty(response.DerivedOptions);
}
+ [Fact]
+ public void MapTopic_WithAnOptionValueOfTheWrongWidth_Throws()
+ {
+ // Arrange: a Uint64 value carrying three bytes, which the Rust decoder rejects too.
+ const byte stringKind = 2;
+ const byte uint64Kind = 12;
+ var (topicId, partitionsCount, topicName, messageExpiry, sizeBytes, messagesCount, createdAt,
+ maxTopicSize) = TopicFactory.CreateTopicResponseFields();
+ var options = BinaryFactory.CreateOptionEntry(stringKind, "segment_size", uint64Kind, [1, 2, 3]);
+ var topicPayload = BinaryFactory.CreateTopicPayload(topicId, partitionsCount, messageExpiry, topicName,
+ sizeBytes, messagesCount, createdAt, maxTopicSize, 1, options);
+
+ // Act + Assert
+ Assert.Throws(() => Mappers.BinaryMapper.MapTopic(topicPayload));
+ }
+
[Fact]
public void MapOptionSpecs_ReturnsTheCatalogWithKindsAndDefaults()
{
@@ -337,7 +353,7 @@ public void MapOptionSpecs_RejectsAnEntryThatOverrunsThePayload()
payload.Add(12);
payload.AddRange(BitConverter.GetBytes(64u)); // claims 64 bytes that are not there
- Assert.Throws(() =>
+ Assert.Throws(() =>
Mappers.BinaryMapper.MapOptionSpecs(payload.ToArray()));
}
@@ -377,7 +393,7 @@ public void MapConsumerGroup_ReturnsValidConsumerGroupResponse()
{
// Arrange
var (groupId, membersCount, partitionsCount, name) = ConsumerGroupFactory.CreateConsumerGroupResponseFields();
- List memberPartitions = Enumerable.Range(0, (int)partitionsCount).ToList();
+ List memberPartitions = Enumerable.Range(0, (int)partitionsCount).Select(i => (uint)i).ToList();
var groupPayload
= BinaryFactory.CreateGroupPayload(groupId, membersCount, partitionsCount, name, memberPartitions);
@@ -394,6 +410,64 @@ var groupPayload
Assert.Single(response.Members);
}
+ [Fact]
+ public void MapConsumerGroup_NegativeMemberPartitionsCount_Throws()
+ {
+ var payload = new byte[21];
+ BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0, 4), 1); // group id
+ BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4, 4), 3); // group partitions_count
+ BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8, 4), 1); // members_count
+ payload[12] = 0; // name_len
+ BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(13, 4), 42); // member id
+ BinaryPrimitives.WriteInt32LittleEndian(payload.AsSpan(17, 4), -2); // member partitions_count
+
+ Assert.Throws(() => Mappers.BinaryMapper.MapConsumerGroup(payload));
+ }
+
+ [Fact]
+ public void MapConsumerGroup_MemberPartitionsCountExceedsPayload_Throws()
+ {
+ var payload = BinaryFactory.CreateGroupPayload(1, 1, 3, "group", [0, 1, 2]);
+ BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(13 + "group".Length + 4, 4), uint.MaxValue);
+
+ Assert.Throws(() => Mappers.BinaryMapper.MapConsumerGroup(payload));
+ }
+
+ [Fact]
+ public void MapConsumerGroup_TruncatedMemberHeader_Throws()
+ {
+ var payload = BinaryFactory.CreateGroupPayload(1, 1, 3, "group", [0, 1, 2]);
+ var truncated = payload.AsSpan(0, 13 + "group".Length + 5).ToArray();
+
+ Assert.Throws(() => Mappers.BinaryMapper.MapConsumerGroup(truncated));
+ }
+
+ [Fact]
+ public void MapConsumerGroup_NameLengthExceedsPayload_Throws()
+ {
+ var payload = BinaryFactory.CreateGroupPayload(1, 0, 3, "group");
+ payload[12] = byte.MaxValue;
+
+ Assert.Throws(() => Mappers.BinaryMapper.MapConsumerGroup(payload));
+ }
+
+ [Fact]
+ public void MapConsumerGroups_MultiByteName_KeepsWalkAligned()
+ {
+ var first = BinaryFactory.CreateGroupPayload(1, 2, 3, "grüppe");
+ var second = BinaryFactory.CreateGroupPayload(2, 4, 5, "other");
+ var combined = new byte[first.Length + second.Length];
+ first.CopyTo(combined, 0);
+ second.CopyTo(combined, first.Length);
+
+ var responses = Mappers.BinaryMapper.MapConsumerGroups(combined);
+
+ Assert.Equal(2, responses.Count);
+ Assert.Equal("grüppe", responses[0].Name);
+ Assert.Equal(2u, responses[1].Id);
+ Assert.Equal("other", responses[1].Name);
+ }
+
[Fact]
public void MapStats_ReturnsValidStatsResponse()
{
@@ -451,7 +525,7 @@ public void MapRentedMessages_WithEncryptor_DecryptsPayloadsAndHeadersIntoPooled
using var rental = Mappers.BinaryMapper.MapRentedMessages(combined, EmptyMemoryOwner.Instance,
encryptor);
- Assert.Equal(7, rental.PartitionId);
+ Assert.Equal(7u, rental.PartitionId);
Assert.Equal(101ul, rental.CurrentOffset);
Assert.Equal(2, rental.Messages.Count);
@@ -474,7 +548,7 @@ public void MapRentedMessages_WithEncryptor_DecryptsPayloadsAndHeadersIntoPooled
}
[Fact]
- public void MapRentedMessages_WithEncryptor_NegativePayloadLength_ThrowsInsteadOfSpinning()
+ public void MapRentedMessages_WithEncryptor_NegativePayloadLength_Throws()
{
var encryptor = new AesMessageEncryptor(AesMessageEncryptor.GenerateKey());
@@ -615,4 +689,218 @@ private static byte[] BuildEncryptedFrame(AesMessageEncryptor encryptor, uint of
return BinaryFactory.CreateMessageFrame(0, Guid.NewGuid(), offsetDelta, 0, cipherHeaders, cipherPayload);
}
+
+ [Fact]
+ public void MapClusterMetadata_OversizedNodesCount_Throws()
+ {
+ var payload = new byte[8];
+ BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0, 4), 0); // cluster name length
+ BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4, 4), uint.MaxValue); // nodes count
+
+ Assert.Throws(() => Mappers.BinaryMapper.MapClusterMetadata(payload));
+ }
+
+ [Fact]
+ public void MapClusterMetadata_MultiByteNodeName_KeepsWalkAligned()
+ {
+ var payload = CreateClusterMetadataPayload("cluster",
+ ("nödé-1", "10.0.0.1", 8090, 1, 1),
+ ("node-2", "10.0.0.2", 8091, 0, 1));
+
+ var metadata = Mappers.BinaryMapper.MapClusterMetadata(payload);
+
+ Assert.Equal("cluster", metadata.Name);
+ Assert.Equal(2, metadata.Nodes.Length);
+ Assert.Equal("nödé-1", metadata.Nodes[0].Name);
+ Assert.Equal("10.0.0.1", metadata.Nodes[0].Ip);
+ Assert.Equal(8090, metadata.Nodes[0].Endpoints.Tcp);
+ Assert.Equal("node-2", metadata.Nodes[1].Name);
+ Assert.Equal("10.0.0.2", metadata.Nodes[1].Ip);
+ Assert.Equal(8091, metadata.Nodes[1].Endpoints.Tcp);
+ }
+
+ [Fact]
+ public void MapClusterMetadata_TruncatedNode_Throws()
+ {
+ var payload = CreateClusterMetadataPayload("cluster", ("node-1", "10.0.0.1", 8090, 1, 1));
+ var truncated = payload.AsSpan(0, payload.Length - 3).ToArray();
+
+ Assert.Throws(() => Mappers.BinaryMapper.MapClusterMetadata(truncated));
+ }
+
+ [Fact]
+ public void MapClusterMetadata_TruncatedInsideLengthPrefix_Throws()
+ {
+ var payload = CreateClusterMetadataPayload("cluster", ("node-1", "10.0.0.1", 8090, 1, 1));
+ // cut inside the u32 length prefix of the node ip, right after the node name
+ var cut = 4 + "cluster".Length + 4 + 4 + "node-1".Length + 2;
+ var truncated = payload.AsSpan(0, cut).ToArray();
+
+ Assert.Throws(() => Mappers.BinaryMapper.MapClusterMetadata(truncated));
+ }
+
+ [Fact]
+ public void MapClusterMetadata_TruncatedBeforeNodesCount_Throws()
+ {
+ var payload = CreateClusterMetadataPayload("cluster");
+ var truncated = payload.AsSpan(0, payload.Length - 2).ToArray();
+
+ Assert.Throws(() => Mappers.BinaryMapper.MapClusterMetadata(truncated));
+ }
+
+ [Fact]
+ public void MapClusterMetadata_NameLengthExceedsPayload_Throws()
+ {
+ var payload = CreateClusterMetadataPayload("cluster", ("node-1", "10.0.0.1", 8090, 1, 1));
+ BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0, 4), uint.MaxValue);
+
+ Assert.Throws(() => Mappers.BinaryMapper.MapClusterMetadata(payload));
+ }
+
+ [Fact]
+ public void MapClient_OversizedConsumerGroupsCount_Throws()
+ {
+ var payload = new byte[17];
+ BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0, 4), 1); // client id
+ BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4, 4), 1); // user id
+ payload[8] = 1; // transport tcp
+ BinaryPrimitives.WriteInt32LittleEndian(payload.AsSpan(9, 4), 0); // address length
+ BinaryPrimitives.WriteInt32LittleEndian(payload.AsSpan(13, 4), int.MaxValue); // consumer groups count
+
+ Assert.Throws(() => Mappers.BinaryMapper.MapClient(payload));
+ }
+
+ [Theory]
+ [InlineData(0, ClientTransport.Unknown)]
+ [InlineData(1, ClientTransport.Tcp)]
+ [InlineData(2, ClientTransport.Quic)]
+ [InlineData(3, ClientTransport.Http)]
+ [InlineData(4, ClientTransport.WebSocket)]
+ [InlineData(9, ClientTransport.Unknown)]
+ public void MapClient_MapsEveryWireTransport(byte wire, ClientTransport expected)
+ {
+ var payload = CreateClientPayload(userId: 7, transport: wire);
+
+ var response = Mappers.BinaryMapper.MapClient(payload);
+
+ Assert.Equal(expected, response.Transport);
+ Assert.Equal(7u, response.UserId);
+ }
+
+ [Fact]
+ public void MapClient_UnauthenticatedSentinel_YieldsNullUserId()
+ {
+ var payload = CreateClientPayload(userId: uint.MaxValue, transport: 1);
+
+ var response = Mappers.BinaryMapper.MapClient(payload);
+
+ Assert.Null(response.UserId);
+ }
+
+ [Theory]
+ [InlineData(5)]
+ [InlineData(6)]
+ [InlineData(200)]
+ public void MapClusterMetadata_UnknownStatus_MapsToUnknown(byte status)
+ {
+ var payload = CreateClusterMetadataPayload("cluster", ("node", "127.0.0.1", 8090, 1, status));
+
+ var metadata = Mappers.BinaryMapper.MapClusterMetadata(payload);
+
+ Assert.Equal(ClusterNodeStatus.Unknown, metadata.Nodes[0].Status);
+ }
+
+ [Fact]
+ public void MapClusterMetadata_UnknownRole_Throws()
+ {
+ var payload = CreateClusterMetadataPayload("cluster", ("node", "127.0.0.1", 8090, 2, 0));
+
+ Assert.Throws(() => Mappers.BinaryMapper.MapClusterMetadata(payload));
+ }
+
+ [Fact]
+ public void MapClient_AddressLengthAboveInt32_ThrowsMalformed()
+ {
+ var payload = CreateClientPayload(userId: 7, transport: 1);
+ BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(9, 4), 0x8000_0000);
+
+ Assert.Throws(() => Mappers.BinaryMapper.MapClient(payload));
+ }
+
+ private static byte[] CreateClientPayload(uint userId, byte transport)
+ {
+ var payload = new byte[17];
+ BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0, 4), 1);
+ BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4, 4), userId);
+ payload[8] = transport;
+ BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(9, 4), 0);
+ BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(13, 4), 0);
+ return payload;
+ }
+
+ [Fact]
+ public void MapTopics_OversizedTopicsCount_Throws()
+ {
+ var payload = new byte[4];
+ BinaryPrimitives.WriteUInt32LittleEndian(payload, uint.MaxValue);
+
+ Assert.Throws(() => Mappers.BinaryMapper.MapTopics(payload));
+ }
+
+ [Fact]
+ public void MapHeaders_KeyLengthDisagreesWithKind_Throws()
+ {
+ // key: kind Uint32 (11) with a single byte; value: string "v".
+ var bytes = new byte[] { 11, 1, 0, 0, 0, 65, 2, 1, 0, 0, 0, 118 };
+
+ Assert.Throws(() => Mappers.BinaryMapper.MapHeaders(bytes));
+ }
+
+ [Fact]
+ public void MapHeaders_FixedWidthValueOfMatchingLength_Parses()
+ {
+ var bytes = new byte[] { 2, 1, 0, 0, 0, 65, 12, 8, 0, 0, 0, 7, 0, 0, 0, 0, 0, 0, 0 };
+
+ Dictionary headers = Mappers.BinaryMapper.MapHeaders(bytes);
+
+ var value = Assert.Single(headers).Value;
+ Assert.Equal(HeaderKind.Uint64, value.Kind);
+ Assert.Equal(7ul, value.ToUInt64());
+ }
+
+ [Fact]
+ public void MapHeaders_ValueLengthDisagreesWithKind_Throws()
+ {
+ var bytes = new byte[] { 2, 1, 0, 0, 0, 65, 6, 2, 0, 0, 0, 7, 0 };
+
+ Assert.Throws(() => Mappers.BinaryMapper.MapHeaders(bytes));
+ }
+
+ private static byte[] CreateClusterMetadataPayload(string clusterName,
+ params (string name, string ip, ushort tcp, byte role, byte status)[] nodes)
+ {
+ var buffer = new List();
+ WriteString(buffer, clusterName);
+ buffer.AddRange(BitConverter.GetBytes((uint)nodes.Length));
+ foreach (var (name, ip, tcp, role, status) in nodes)
+ {
+ WriteString(buffer, name);
+ WriteString(buffer, ip);
+ buffer.AddRange(BitConverter.GetBytes(tcp));
+ buffer.AddRange(BitConverter.GetBytes((ushort)0)); // quic
+ buffer.AddRange(BitConverter.GetBytes((ushort)0)); // http
+ buffer.AddRange(BitConverter.GetBytes((ushort)0)); // websocket
+ buffer.Add(role);
+ buffer.Add(status);
+ }
+
+ return buffer.ToArray();
+
+ static void WriteString(List buffer, string value)
+ {
+ var bytes = Encoding.UTF8.GetBytes(value);
+ buffer.AddRange(BitConverter.GetBytes((uint)bytes.Length));
+ buffer.AddRange(bytes);
+ }
+ }
}
diff --git a/foreign/csharp/Iggy_SDK_Tests/MapperTests/HeaderEncryptionTests.cs b/foreign/csharp/Iggy_SDK_Tests/MapperTests/HeaderEncryptionTests.cs
index 8d8a62e040..872d5b4d9f 100644
--- a/foreign/csharp/Iggy_SDK_Tests/MapperTests/HeaderEncryptionTests.cs
+++ b/foreign/csharp/Iggy_SDK_Tests/MapperTests/HeaderEncryptionTests.cs
@@ -17,6 +17,7 @@
using Apache.Iggy.Contracts.Tcp;
using Apache.Iggy.Encryption;
+using Apache.Iggy.Exceptions;
using Apache.Iggy.Shared;
namespace Apache.Iggy.Tests.MapperTests;
@@ -44,8 +45,7 @@ public void Headers_should_survive_encrypt_decrypt_roundtrip()
Assert.True(encrypted.Length > headerBytes.Length);
// Encrypted bytes must not parse as valid headers
- var parsed = Mappers.BinaryMapper.TryMapHeaders(encrypted);
- Assert.Null(parsed);
+ Assert.Throws(() => Mappers.BinaryMapper.MapHeaders(encrypted));
var decrypted = encryptor.DecryptToArray(encrypted);
Assert.Equal(headerBytes, decrypted);
@@ -62,85 +62,85 @@ public void Headers_should_survive_encrypt_decrypt_roundtrip()
}
[Fact]
- public void TryMapHeaders_returns_null_on_invalid_first_byte()
+ public void MapHeaders_throws_on_invalid_first_byte()
{
var random = new byte[64];
Random.Shared.NextBytes(random);
random[0] = 0;
- Assert.Null(Mappers.BinaryMapper.TryMapHeaders(random));
+ Assert.Throws(() => Mappers.BinaryMapper.MapHeaders(random));
}
[Fact]
- public void TryMapHeaders_returns_null_on_first_byte_above_range()
+ public void MapHeaders_throws_on_first_byte_above_range()
{
- Assert.Null(Mappers.BinaryMapper.TryMapHeaders(new byte[] { 16, 0, 0, 0 }));
+ Assert.Throws(() => Mappers.BinaryMapper.MapHeaders(new byte[] { 16, 0, 0, 0 }));
}
[Fact]
- public void TryMapHeaders_returns_null_on_empty_payload()
+ public void MapHeaders_returns_empty_on_empty_payload()
{
- Assert.Null(Mappers.BinaryMapper.TryMapHeaders([]));
+ Assert.Empty(Mappers.BinaryMapper.MapHeaders([]));
}
[Fact]
- public void TryMapHeaders_returns_null_on_truncated_key_length()
+ public void MapHeaders_throws_on_truncated_key_length()
{
// Valid header kind byte but not enough bytes for key length
- Assert.Null(Mappers.BinaryMapper.TryMapHeaders(new byte[] { 2 }));
+ Assert.Throws(() => Mappers.BinaryMapper.MapHeaders(new byte[] { 2 }));
}
[Fact]
- public void TryMapHeaders_returns_null_on_zero_key_length()
+ public void MapHeaders_throws_on_zero_key_length()
{
// Valid kind, then key length = 0 (invalid)
- Assert.Null(Mappers.BinaryMapper.TryMapHeaders(new byte[] { 2, 0, 0, 0, 0 }));
+ Assert.Throws(() => Mappers.BinaryMapper.MapHeaders(new byte[] { 2, 0, 0, 0, 0 }));
}
[Fact]
- public void TryMapHeaders_returns_null_on_key_length_exceeding_payload()
+ public void MapHeaders_throws_on_key_length_exceeding_payload()
{
// Valid kind, key length = 100 but only a few bytes remain
- Assert.Null(Mappers.BinaryMapper.TryMapHeaders(new byte[] { 2, 100, 0, 0, 0, 1, 2 }));
+ Assert.Throws(() => Mappers.BinaryMapper.MapHeaders(new byte[] { 2, 100, 0, 0, 0, 1, 2 }));
}
[Fact]
- public void TryMapHeaders_returns_null_on_truncated_value_kind()
+ public void MapHeaders_throws_on_truncated_value_kind()
{
// Valid kind(2=String), key_len=1, key='A', then no value kind byte
- Assert.Null(Mappers.BinaryMapper.TryMapHeaders(new byte[] { 2, 1, 0, 0, 0, 65 }));
+ Assert.Throws(() => Mappers.BinaryMapper.MapHeaders(new byte[] { 2, 1, 0, 0, 0, 65 }));
}
[Fact]
- public void TryMapHeaders_returns_null_on_invalid_value_kind()
+ public void MapHeaders_throws_on_invalid_value_kind()
{
// Valid kind(2), key_len=1, key='A', invalid value kind=0
- Assert.Null(Mappers.BinaryMapper.TryMapHeaders(new byte[] { 2, 1, 0, 0, 0, 65, 0 }));
+ Assert.Throws(() => Mappers.BinaryMapper.MapHeaders(new byte[] { 2, 1, 0, 0, 0, 65, 0 }));
}
[Fact]
- public void TryMapHeaders_returns_null_on_truncated_value_length()
+ public void MapHeaders_throws_on_truncated_value_length()
{
// Valid kind(2), key_len=1, key='A', valid value kind(2), then not enough for value length
- Assert.Null(Mappers.BinaryMapper.TryMapHeaders(new byte[] { 2, 1, 0, 0, 0, 65, 2, 1 }));
+ Assert.Throws(() => Mappers.BinaryMapper.MapHeaders(new byte[] { 2, 1, 0, 0, 0, 65, 2, 1 }));
}
[Fact]
- public void TryMapHeaders_returns_null_on_zero_value_length()
+ public void MapHeaders_throws_on_zero_value_length()
{
// Valid kind(2), key_len=1, key='A', valid value kind(2), value_len=0 (invalid)
- Assert.Null(Mappers.BinaryMapper.TryMapHeaders(new byte[] { 2, 1, 0, 0, 0, 65, 2, 0, 0, 0, 0 }));
+ Assert.Throws(() => Mappers.BinaryMapper.MapHeaders(new byte[] { 2, 1, 0, 0, 0, 65, 2, 0, 0, 0, 0 }));
}
[Fact]
- public void TryMapHeaders_returns_null_on_value_length_exceeding_payload()
+ public void MapHeaders_throws_on_value_length_exceeding_payload()
{
// Valid kind(2), key_len=1, key='A', valid value kind(2), value_len=100 but not enough bytes
- Assert.Null(Mappers.BinaryMapper.TryMapHeaders(new byte[] { 2, 1, 0, 0, 0, 65, 2, 100, 0, 0, 0 }));
+ Assert.Throws(() => Mappers.BinaryMapper.MapHeaders(new byte[] { 2, 1, 0, 0, 0, 65, 2, 100, 0, 0, 0 }));
}
[Fact]
- public void TryMapHeaders_returns_valid_headers_on_plaintext()
+ public void MapHeaders_returns_valid_headers_on_plaintext()
{
var headers = new Dictionary
{
@@ -148,14 +148,14 @@ public void TryMapHeaders_returns_valid_headers_on_plaintext()
};
var bytes = HeadersToBytes(headers);
- var result = Mappers.BinaryMapper.TryMapHeaders(bytes);
+ var result = Mappers.BinaryMapper.MapHeaders(bytes);
Assert.NotNull(result);
Assert.Single(result);
}
[Fact]
- public void TryMapHeaders_returns_valid_for_all_header_kinds()
+ public void MapHeaders_returns_valid_for_all_header_kinds()
{
var headers = new Dictionary
{
@@ -167,7 +167,7 @@ public void TryMapHeaders_returns_valid_for_all_header_kinds()
};
var bytes = HeadersToBytes(headers);
- var result = Mappers.BinaryMapper.TryMapHeaders(bytes);
+ var result = Mappers.BinaryMapper.MapHeaders(bytes);
Assert.NotNull(result);
Assert.Equal(5, result.Count);
diff --git a/foreign/csharp/Iggy_SDK_Tests/MapperTests/OptionsBlockGoldenVectorTests.cs b/foreign/csharp/Iggy_SDK_Tests/MapperTests/OptionsBlockGoldenVectorTests.cs
index 62c375a453..fe4ed73040 100644
--- a/foreign/csharp/Iggy_SDK_Tests/MapperTests/OptionsBlockGoldenVectorTests.cs
+++ b/foreign/csharp/Iggy_SDK_Tests/MapperTests/OptionsBlockGoldenVectorTests.cs
@@ -18,6 +18,7 @@
using System.Buffers.Binary;
using System.Text;
using Apache.Iggy.Contracts.Tcp;
+using Apache.Iggy.Exceptions;
using Apache.Iggy.Headers;
namespace Apache.Iggy.Tests.MapperTests;
@@ -79,6 +80,15 @@ public void MapTopic_DecodesTheCrossSdkGoldenVector()
Assert.Empty(topic.DerivedOptions!);
}
+ [Fact]
+ public void MapTopic_WithOptionsLengthPrefixCutShort_ThrowsMalformedResponse()
+ {
+ var payload = TopicPayloadWithOptions(GoldenOptionsBlock);
+ var truncated = payload[..(50 + "topic".Length + 2)];
+
+ Assert.Throws(() => Mappers.BinaryMapper.MapTopic(truncated));
+ }
+
///
/// A topic response carrying as its explicit block and an empty
/// derived block, with no partitions after it.
diff --git a/foreign/csharp/Iggy_SDK_Tests/UtilityTests/IdentifiersByteSerializationTests.cs b/foreign/csharp/Iggy_SDK_Tests/UtilityTests/IdentifiersByteSerializationTests.cs
index 4d128c4175..60aa552eb8 100644
--- a/foreign/csharp/Iggy_SDK_Tests/UtilityTests/IdentifiersByteSerializationTests.cs
+++ b/foreign/csharp/Iggy_SDK_Tests/UtilityTests/IdentifiersByteSerializationTests.cs
@@ -45,4 +45,29 @@ public void KeyBytes_WithInvalidLength_ShouldThrowArgumentException()
var val = Enumerable.Range(0, 500).Select(x => (byte)x).ToArray();
Assert.Throws(() => Partitioning.EntityIdBytes(val));
}
+
+ [Fact]
+ public void NumericIdentifier_WithNegativeValue_ShouldThrow()
+ {
+ Assert.Throws(() => Identifier.Numeric(-1));
+ }
+
+ [Fact]
+ public void NumericIdentifier_IntAndUintOverloads_ProduceSameBytes()
+ {
+ Assert.Equal(Identifier.Numeric(42u).Value, Identifier.Numeric(42).Value);
+ }
+
+ [Fact]
+ public void PartitionId_WithNegativeValue_ShouldThrow()
+ {
+ Assert.Throws(() => Partitioning.PartitionId(-1));
+ }
+
+ [Fact]
+ public void Consumer_WithNegativeId_ShouldThrow()
+ {
+ Assert.Throws(() => Consumer.New(-1));
+ Assert.Throws(() => Consumer.Group(-1));
+ }
}
diff --git a/foreign/csharp/Iggy_SDK_Tests/Utils/BinaryFactory.cs b/foreign/csharp/Iggy_SDK_Tests/Utils/BinaryFactory.cs
index d0c6078f62..8255f349d4 100644
--- a/foreign/csharp/Iggy_SDK_Tests/Utils/BinaryFactory.cs
+++ b/foreign/csharp/Iggy_SDK_Tests/Utils/BinaryFactory.cs
@@ -33,10 +33,10 @@ internal static byte[] CreatePersonalAccessTokensPayload(string name, uint expir
return result.ToArray();
}
- internal static byte[] CreateOffsetPayload(int partitionId, ulong currentOffset, ulong offset)
+ internal static byte[] CreateOffsetPayload(uint partitionId, ulong currentOffset, ulong offset)
{
var payload = new byte[20];
- BinaryPrimitives.WriteInt32LittleEndian(payload, partitionId);
+ BinaryPrimitives.WriteUInt32LittleEndian(payload, partitionId);
BinaryPrimitives.WriteUInt64LittleEndian(payload.AsSpan(4), currentOffset);
BinaryPrimitives.WriteUInt64LittleEndian(payload.AsSpan(12), offset);
return payload;
@@ -88,7 +88,7 @@ internal static byte[] CreateBatchRecord(ulong baseOffset, ulong baseTimestamp,
return record;
}
- internal static byte[] CreateStreamPayload(uint id, int topicsCount, string name, ulong sizeBytes,
+ internal static byte[] CreateStreamPayload(uint id, uint topicsCount, string name, ulong sizeBytes,
ulong messagesCount, ulong createdAt)
{
var nameBytes = Encoding.UTF8.GetBytes(name);
@@ -96,7 +96,7 @@ internal static byte[] CreateStreamPayload(uint id, int topicsCount, string name
var payload = new byte[totalSize];
BinaryPrimitives.WriteUInt32LittleEndian(payload, id);
BinaryPrimitives.WriteUInt64LittleEndian(payload.AsSpan(4), createdAt);
- BinaryPrimitives.WriteInt32LittleEndian(payload.AsSpan(12), topicsCount);
+ BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12), topicsCount);
BinaryPrimitives.WriteUInt64LittleEndian(payload.AsSpan(16), sizeBytes);
BinaryPrimitives.WriteUInt64LittleEndian(payload.AsSpan(24), messagesCount);
payload[32] = (byte)nameBytes.Length;
@@ -152,35 +152,24 @@ internal static byte[] CreateOptionEntry(byte keyKind, string key, byte valueKin
return entry;
}
- internal static byte[] CreatePartitionPayload(int id, int segmentsCount, int currentOffset, ulong sizeBytes,
- ulong messagesCount)
- {
- var payload = new byte[16];
- BinaryPrimitives.WriteInt32LittleEndian(payload, id);
- BinaryPrimitives.WriteInt32LittleEndian(payload.AsSpan(4), segmentsCount);
- BinaryPrimitives.WriteInt32LittleEndian(payload.AsSpan(8), currentOffset);
- BinaryPrimitives.WriteUInt64LittleEndian(payload.AsSpan(12), sizeBytes);
- BinaryPrimitives.WriteUInt64LittleEndian(payload.AsSpan(16), messagesCount);
- return payload;
- }
-
internal static byte[] CreateGroupPayload(uint id, uint membersCount, uint partitionsCount, string name,
- List? partitionsOnMember = null)
+ List? partitionsOnMember = null)
{
- var payload = new byte[13 + name.Length + (partitionsOnMember?.Count * 4 + 8 ?? 0)];
+ var nameBytes = Encoding.UTF8.GetBytes(name);
+ var payload = new byte[13 + nameBytes.Length + (partitionsOnMember?.Count * 4 + 8 ?? 0)];
BinaryPrimitives.WriteUInt32LittleEndian(payload, id);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), partitionsCount);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), membersCount);
- payload[12] = (byte)name.Length;
- var nameBytes = Encoding.UTF8.GetBytes(name);
+ payload[12] = (byte)nameBytes.Length;
nameBytes.CopyTo(payload.AsSpan(13));
if (partitionsOnMember is not null)
{
- BinaryPrimitives.WriteInt32LittleEndian(payload.AsSpan(13 + name.Length), 30);
- BinaryPrimitives.WriteInt32LittleEndian(payload.AsSpan(17 + name.Length), partitionsOnMember.Count);
+ var memberStart = 13 + nameBytes.Length;
+ BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(memberStart), 30);
+ BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(memberStart + 4), (uint)partitionsOnMember.Count);
for (var i = 0; i < partitionsOnMember.Count; i++)
{
- BinaryPrimitives.WriteInt32LittleEndian(payload.AsSpan(21 + name.Length + i * 4),
+ BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(memberStart + 8 + i * 4),
partitionsOnMember[i]);
}
}
@@ -191,7 +180,7 @@ internal static byte[] CreateGroupPayload(uint id, uint membersCount, uint parti
internal static byte[] CreateStatsPayload(StatsResponse stats)
{
var bytes = new byte[1024];
- BinaryPrimitives.WriteInt32LittleEndian(bytes.AsSpan(0, 4), stats.ProcessId);
+ BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(0, 4), stats.ProcessId);
BinaryPrimitives.WriteSingleLittleEndian(bytes.AsSpan(4, 4), stats.CpuUsage);
BinaryPrimitives.WriteSingleLittleEndian(bytes.AsSpan(8, 8), stats.TotalCpuUsage);
BinaryPrimitives.WriteUInt64LittleEndian(bytes.AsSpan(12, 8), stats.MemoryUsage);
@@ -203,32 +192,32 @@ internal static byte[] CreateStatsPayload(StatsResponse stats)
BinaryPrimitives.WriteUInt64LittleEndian(bytes.AsSpan(52, 8), stats.ReadBytes);
BinaryPrimitives.WriteUInt64LittleEndian(bytes.AsSpan(60, 8), stats.WrittenBytes);
BinaryPrimitives.WriteUInt64LittleEndian(bytes.AsSpan(68, 8), stats.MessagesSizeBytes);
- BinaryPrimitives.WriteInt32LittleEndian(bytes.AsSpan(76, 4), stats.StreamsCount);
- BinaryPrimitives.WriteInt32LittleEndian(bytes.AsSpan(80, 4), stats.TopicsCount);
- BinaryPrimitives.WriteInt32LittleEndian(bytes.AsSpan(84, 4), stats.PartitionsCount);
- BinaryPrimitives.WriteInt32LittleEndian(bytes.AsSpan(88, 4), stats.SegmentsCount);
+ BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(76, 4), stats.StreamsCount);
+ BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(80, 4), stats.TopicsCount);
+ BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(84, 4), stats.PartitionsCount);
+ BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(88, 4), stats.SegmentsCount);
BinaryPrimitives.WriteUInt64LittleEndian(bytes.AsSpan(92, 8), stats.MessagesCount);
- BinaryPrimitives.WriteInt32LittleEndian(bytes.AsSpan(100, 4), stats.ClientsCount);
- BinaryPrimitives.WriteInt32LittleEndian(bytes.AsSpan(104, 4), stats.ConsumerGroupsCount);
+ BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(100, 4), stats.ClientsCount);
+ BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(104, 4), stats.ConsumerGroupsCount);
// Convert string properties to bytes and set them in the byte array
var hostnameBytes = Encoding.UTF8.GetBytes(stats.Hostname);
- BinaryPrimitives.WriteInt32LittleEndian(bytes.AsSpan(108, 4), hostnameBytes.Length);
+ BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(108, 4), (uint)hostnameBytes.Length);
hostnameBytes.CopyTo(bytes, 112);
var osNameBytes = Encoding.UTF8.GetBytes(stats.OsName);
- BinaryPrimitives.WriteInt32LittleEndian(bytes.AsSpan(112 + hostnameBytes.Length, 4), osNameBytes.Length);
+ BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(112 + hostnameBytes.Length, 4), (uint)osNameBytes.Length);
osNameBytes.CopyTo(bytes, 116 + hostnameBytes.Length);
var osVersionBytes = Encoding.UTF8.GetBytes(stats.OsVersion);
- BinaryPrimitives.WriteInt32LittleEndian(bytes.AsSpan(116 + hostnameBytes.Length + osNameBytes.Length, 4),
- osVersionBytes.Length);
+ BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(116 + hostnameBytes.Length + osNameBytes.Length, 4),
+ (uint)osVersionBytes.Length);
osVersionBytes.CopyTo(bytes, 120 + hostnameBytes.Length + osNameBytes.Length);
var kernelVersionBytes = Encoding.UTF8.GetBytes(stats.KernelVersion);
- BinaryPrimitives.WriteInt32LittleEndian(
+ BinaryPrimitives.WriteUInt32LittleEndian(
bytes.AsSpan(120 + hostnameBytes.Length + osNameBytes.Length + osVersionBytes.Length, 4),
- kernelVersionBytes.Length);
+ (uint)kernelVersionBytes.Length);
kernelVersionBytes.CopyTo(bytes, 124 + hostnameBytes.Length + osNameBytes.Length + osVersionBytes.Length);
return bytes;
diff --git a/foreign/csharp/Iggy_SDK_Tests/Utils/Streams/StreamFactory.cs b/foreign/csharp/Iggy_SDK_Tests/Utils/Streams/StreamFactory.cs
index 92f1b1d375..77728a2049 100644
--- a/foreign/csharp/Iggy_SDK_Tests/Utils/Streams/StreamFactory.cs
+++ b/foreign/csharp/Iggy_SDK_Tests/Utils/Streams/StreamFactory.cs
@@ -19,11 +19,11 @@ namespace Apache.Iggy.Tests.Utils.Streams;
internal static class StreamFactory
{
- internal static (uint id, int topicsCount, ulong sizeBytes, ulong messagesCount, string name, ulong createdAt)
+ internal static (uint id, uint topicsCount, ulong sizeBytes, ulong messagesCount, string name, ulong createdAt)
CreateStreamsResponseFields()
{
var id = (uint)Random.Shared.Next(1, 69);
- var topicsCount = Random.Shared.Next(1, 69);
+ var topicsCount = (uint)Random.Shared.Next(1, 69);
var sizeBytes = (ulong)Random.Shared.Next(69, 42069);
var messageCount = (ulong)Random.Shared.Next(2, 3);
var name = "Stream " + Random.Shared.Next(1, 4) + Utility.RandomString(3).ToLower();
diff --git a/foreign/csharp/Iggy_SDK_Tests/Utils/Users/PermissionsFactory.cs b/foreign/csharp/Iggy_SDK_Tests/Utils/Users/PermissionsFactory.cs
index d6ab48097c..607487f03e 100644
--- a/foreign/csharp/Iggy_SDK_Tests/Utils/Users/PermissionsFactory.cs
+++ b/foreign/csharp/Iggy_SDK_Tests/Utils/Users/PermissionsFactory.cs
@@ -41,10 +41,10 @@ internal static Permissions CreatePermissions()
PollMessages = Random.Shared.Next() % 2 == 0,
SendMessages = Random.Shared.Next() % 2 == 0
},
- Streams = new Dictionary
+ Streams = new Dictionary
{
{
- Random.Shared.Next(1, 30), new StreamPermissions
+ (uint)Random.Shared.Next(1, 30), new StreamPermissions
{
ManageStream = Random.Shared.Next() % 2 == 0,
ReadStream = Random.Shared.Next() % 2 == 0,
@@ -52,10 +52,10 @@ internal static Permissions CreatePermissions()
ReadTopics = Random.Shared.Next() % 2 == 0,
PollMessages = Random.Shared.Next() % 2 == 0,
SendMessages = Random.Shared.Next() % 2 == 0,
- Topics = new Dictionary
+ Topics = new Dictionary
{
{
- Random.Shared.Next(1, 30),
+ (uint)Random.Shared.Next(1, 30),
new TopicPermissions
{
ManageTopic = Random.Shared.Next() % 2 == 0,
@@ -65,7 +65,7 @@ internal static Permissions CreatePermissions()
}
},
{
- Random.Shared.Next(31, 69),
+ (uint)Random.Shared.Next(31, 69),
new TopicPermissions
{
ManageTopic = Random.Shared.Next() % 2 == 0,
@@ -78,7 +78,7 @@ internal static Permissions CreatePermissions()
}
},
{
- Random.Shared.Next(31, 69), new StreamPermissions
+ (uint)Random.Shared.Next(31, 69), new StreamPermissions
{
ManageStream = Random.Shared.Next() % 2 == 0,
ReadStream = Random.Shared.Next() % 2 == 0,
@@ -95,7 +95,7 @@ internal static Permissions CreatePermissions()
internal static Permissions PermissionsFromBytes(byte[] bytes)
{
- var streamMap = new Dictionary();
+ var streamMap = new Dictionary();
var index = 0;
var globalPermissions = new GlobalPermissions
@@ -116,8 +116,8 @@ internal static Permissions PermissionsFromBytes(byte[] bytes)
{
while (true)
{
- var streamId = BinaryPrimitives.ReadInt32LittleEndian(bytes[index..(index + 4)]);
- index += sizeof(int);
+ var streamId = BinaryPrimitives.ReadUInt32LittleEndian(bytes[index..(index + 4)]);
+ index += sizeof(uint);
var manageStream = bytes[index++] == 1;
var readStream = bytes[index++] == 1;
@@ -125,14 +125,14 @@ internal static Permissions PermissionsFromBytes(byte[] bytes)
var readTopics = bytes[index++] == 1;
var pollMessagesStream = bytes[index++] == 1;
var sendMessagesStream = bytes[index++] == 1;
- var topicsMap = new Dictionary();
+ var topicsMap = new Dictionary();
if (bytes[index++] == 1)
{
while (true)
{
- var topicId = BinaryPrimitives.ReadInt32LittleEndian(bytes[index..(index + 4)]);
- index += sizeof(int);
+ var topicId = BinaryPrimitives.ReadUInt32LittleEndian(bytes[index..(index + 4)]);
+ index += sizeof(uint);
var manageTopic = bytes[index++] == 1;
var readTopic = bytes[index++] == 1;
diff --git a/foreign/csharp/Iggy_SDK_Tests/Utils/Users/UsersFactory.cs b/foreign/csharp/Iggy_SDK_Tests/Utils/Users/UsersFactory.cs
index aef0443bb5..d307e38851 100644
--- a/foreign/csharp/Iggy_SDK_Tests/Utils/Users/UsersFactory.cs
+++ b/foreign/csharp/Iggy_SDK_Tests/Utils/Users/UsersFactory.cs
@@ -33,10 +33,10 @@ internal static CreateUserRequest CreateUserRequest(string? username = null, str
permissions ?? CreatePermissions());
}
- internal static Dictionary CreateStreamPermissions(int streamId = 1, int topicId = 1)
+ internal static Dictionary CreateStreamPermissions(uint streamId = 1, uint topicId = 1)
{
- var streamsPermission = new Dictionary();
- var topicPermissions = new Dictionary();
+ var streamsPermission = new Dictionary();
+ var topicPermissions = new Dictionary();
topicPermissions.Add(topicId,
new TopicPermissions
{
diff --git a/foreign/csharp/Iggy_SDK_Tests/VsrTests/ConsumerGroupClientStateTests.cs b/foreign/csharp/Iggy_SDK_Tests/VsrTests/ConsumerGroupClientStateTests.cs
index c1c4f00ada..c1bb394fee 100644
--- a/foreign/csharp/Iggy_SDK_Tests/VsrTests/ConsumerGroupClientStateTests.cs
+++ b/foreign/csharp/Iggy_SDK_Tests/VsrTests/ConsumerGroupClientStateTests.cs
@@ -94,7 +94,8 @@ public void MemberHoldingNoPartitions_StaysRegistered()
state.RegisterGroup(Key, Identifier.Numeric(1), Identifier.Numeric(2), Identifier.Numeric(3));
state.SetAssignment(Key, 1, []);
- Assert.False(state.HasAssignment(Key));
+ Assert.True(state.HasAssignment(Key));
+ Assert.Null(state.NextGroupPartition(Key));
Assert.True(state.IsRegistered(Key));
state.DeregisterGroup(Key);
@@ -102,6 +103,17 @@ public void MemberHoldingNoPartitions_StaysRegistered()
Assert.False(state.IsRegistered(Key));
}
+ [Fact]
+ public void HasAssignment_ExpiresAfterTheRefreshInterval()
+ {
+ var state = new ConsumerGroupClientState();
+ state.SetAssignment(Key, 1, []);
+ var now = Environment.TickCount64;
+
+ Assert.True(state.HasAssignment(Key, now));
+ Assert.False(state.HasAssignment(Key, now + ConsumerGroupClientState.AssignmentRefreshMs));
+ }
+
[Fact]
public void RegisteredGroups_ReturnsJoinedIdentifiers()
{
diff --git a/foreign/csharp/Iggy_SDK_Tests/VsrTests/EndpointFailoverTests.cs b/foreign/csharp/Iggy_SDK_Tests/VsrTests/EndpointFailoverTests.cs
index 3f6ad316c6..399abb1c68 100644
--- a/foreign/csharp/Iggy_SDK_Tests/VsrTests/EndpointFailoverTests.cs
+++ b/foreign/csharp/Iggy_SDK_Tests/VsrTests/EndpointFailoverTests.cs
@@ -27,6 +27,7 @@
using Apache.Iggy.IggyClient.Implementations;
using Apache.Iggy.Vsr;
using Microsoft.Extensions.Logging.Abstractions;
+using static Apache.Iggy.Tests.VsrTests.MockFrames;
namespace Apache.Iggy.Tests.VsrTests;
@@ -37,26 +38,6 @@ namespace Apache.Iggy.Tests.VsrTests;
///
public sealed class EndpointFailoverTests
{
- private const int HeaderSize = 256;
- private const int SizeOffset = 48;
- private const int CommandOffset = 60;
- private const int RequestIdOffset = 168;
- private const int RequestOperationOffset = 176;
- private const int RequestReservedOffset = 196;
- private const int ReplyRequestIdOffset = 200;
- private const int ReplyOperationOffset = 208;
- private const int ReplyStatusOffset = 216;
-
- private const byte CommandReply = 8;
- private const byte CommandEviction = 13;
- private const int EvictionReasonOffset = 255;
- private const byte EvictionStaleClient = 13;
- private const byte OperationRegister = 1;
- private const byte OperationNonReplicated = 2;
- private const int GetClusterMetadataCode = 12;
- private const int PingCode = 1;
- private const uint TransientNotAccepted = 58;
-
[Fact]
public async Task ResumesOnASurvivorAfterTheSignedInNodeDies()
{
@@ -65,11 +46,11 @@ public async Task ResumesOnASurvivorAfterTheSignedInNodeDies()
// The primary leads, so the sign-in settles there and the roster is only remembered - not acted on -
// until the node dies.
- primary.Serve(request => request.Code == GetClusterMetadataCode
- ? Reply(OperationNonReplicated, ClusterMetadata(primary.Port, survivor.Port, primary.Port))
+ primary.Serve(request => request.Code == GET_CLUSTER_METADATA_CODE
+ ? Reply(OPERATION_NON_REPLICATED, ClusterMetadata(primary.Port, survivor.Port, primary.Port))
: Answer(request));
- survivor.Serve(request => request.Code == GetClusterMetadataCode
- ? Reply(OperationNonReplicated, ClusterMetadata(primary.Port, survivor.Port, survivor.Port))
+ survivor.Serve(request => request.Code == GET_CLUSTER_METADATA_CODE
+ ? Reply(OPERATION_NON_REPLICATED, ClusterMetadata(primary.Port, survivor.Port, survivor.Port))
: Answer(request));
var configuration = new IggyClientConfigurator
@@ -114,13 +95,13 @@ public async Task WalksPastTwoRefusingReplicasToThePartitionPrimary()
metadataLeader.Serve(request => request.Code switch
{
- GetClusterMetadataCode => Reply(OperationNonReplicated,
+ GET_CLUSTER_METADATA_CODE => Reply(OPERATION_NON_REPLICATED,
ThreeNodeClusterMetadata(metadataLeader.Port, follower.Port, partitionPrimary.Port)),
- (int)commandCode => Reply(request.Operation, [], TransientNotAccepted),
+ (int)commandCode => Reply(request.Operation, [], TRANSIENT_NOT_ACCEPTED),
_ => Answer(request)
});
follower.Serve(request => request.Code == (int)commandCode
- ? Reply(request.Operation, [], TransientNotAccepted)
+ ? Reply(request.Operation, [], TRANSIENT_NOT_ACCEPTED)
: Answer(request));
partitionPrimary.Serve(Answer);
@@ -166,12 +147,12 @@ public async Task WalksTheWholeRosterBeyondTheMetadataRedirectCap()
byte[] Refuse(MockRequest request)
{
return request.Code == (int)commandCode
- ? Reply(request.Operation, [], TransientNotAccepted)
+ ? Reply(request.Operation, [], TRANSIENT_NOT_ACCEPTED)
: Answer(request);
}
- metadataLeader.Serve(request => request.Code == GetClusterMetadataCode
- ? Reply(OperationNonReplicated, RosterMetadata(metadataLeader.Port, roster))
+ metadataLeader.Serve(request => request.Code == GET_CLUSTER_METADATA_CODE
+ ? Reply(OPERATION_NON_REPLICATED, RosterMetadata(metadataLeader.Port, roster))
: Refuse(request));
second.Serve(Refuse);
third.Serve(Refuse);
@@ -212,18 +193,18 @@ public async Task ServerEvictionReplaysTheRememberedSignIn()
var evict = false;
node.Serve(request =>
{
- if (request.Operation == OperationRegister)
+ if (request.Operation == OPERATION_REGISTER)
{
- return Reply(OperationRegister, RegisterBody(session: 128));
+ return Reply(OPERATION_REGISTER, RegisterBody(session: 128));
}
if (evict)
{
evict = false;
- return EvictionFrame(EvictionStaleClient);
+ return EvictionFrame(EVICTION_STALE_CLIENT);
}
- return Reply(OperationNonReplicated, request.Code == GetClusterMetadataCode
+ return Reply(OPERATION_NON_REPLICATED, request.Code == GET_CLUSTER_METADATA_CODE
? ClusterMetadata(node.Port, node.Port, node.Port)
: []);
});
@@ -268,18 +249,18 @@ public async Task ServerEvictionDuringAReplicatedWriteReplaysTheRememberedSignIn
var evict = false;
node.Serve(request =>
{
- if (request.Operation == OperationRegister)
+ if (request.Operation == OPERATION_REGISTER)
{
- return Reply(OperationRegister, RegisterBody(session: 128));
+ return Reply(OPERATION_REGISTER, RegisterBody(session: 128));
}
if (evict)
{
evict = false;
- return EvictionFrame(EvictionStaleClient);
+ return EvictionFrame(EVICTION_STALE_CLIENT);
}
- return Reply(OperationNonReplicated, request.Code == GetClusterMetadataCode
+ return Reply(OPERATION_NON_REPLICATED, request.Code == GET_CLUSTER_METADATA_CODE
? ClusterMetadata(node.Port, node.Port, node.Port)
: []);
});
@@ -331,8 +312,8 @@ public async Task ResumesOnASurvivorThatComesUpWhileTheClientIsRetrying()
probe.Stop();
using var primary = new MockNode();
- primary.Serve(request => request.Code == GetClusterMetadataCode
- ? Reply(OperationNonReplicated, ClusterMetadata(primary.Port, survivorPort, primary.Port))
+ primary.Serve(request => request.Code == GET_CLUSTER_METADATA_CODE
+ ? Reply(OPERATION_NON_REPLICATED, ClusterMetadata(primary.Port, survivorPort, primary.Port))
: Answer(request));
var configuration = new IggyClientConfigurator
@@ -364,8 +345,8 @@ public async Task ResumesOnASurvivorThatComesUpWhileTheClientIsRetrying()
{
await Task.Delay(300, TestContext.Current.CancellationToken);
survivor = new MockNode(survivorPort);
- survivor.Serve(request => request.Code == GetClusterMetadataCode
- ? Reply(OperationNonReplicated, ClusterMetadata(primary.Port, survivorPort, survivorPort))
+ survivor.Serve(request => request.Code == GET_CLUSTER_METADATA_CODE
+ ? Reply(OPERATION_NON_REPLICATED, ClusterMetadata(primary.Port, survivorPort, survivorPort))
: Answer(request));
}, TestContext.Current.CancellationToken);
@@ -386,10 +367,10 @@ public async Task ResumesOnASurvivorThatComesUpWhileTheClientIsRetrying()
private static byte[] EvictionFrame(byte reason)
{
- var frame = new byte[HeaderSize];
- BinaryPrimitives.WriteUInt32LittleEndian(frame.AsSpan(SizeOffset, 4), HeaderSize);
- frame[CommandOffset] = CommandEviction;
- frame[EvictionReasonOffset] = reason;
+ var frame = new byte[HEADER_SIZE];
+ BinaryPrimitives.WriteUInt32LittleEndian(frame.AsSpan(SIZE_OFFSET, 4), HEADER_SIZE);
+ frame[COMMAND_OFFSET] = COMMAND_EVICTION;
+ frame[EVICTION_REASON_OFFSET] = reason;
return frame;
}
@@ -397,8 +378,8 @@ private static byte[] EvictionFrame(byte reason)
public async Task FailsFastWhenNothingEverSignedIn()
{
using var node = new MockNode();
- node.Serve(request => request.Code == GetClusterMetadataCode
- ? Reply(OperationNonReplicated, ClusterMetadata(node.Port, node.Port, node.Port))
+ node.Serve(request => request.Code == GET_CLUSTER_METADATA_CODE
+ ? Reply(OPERATION_NON_REPLICATED, ClusterMetadata(node.Port, node.Port, node.Port))
: Answer(request));
var configuration = new IggyClientConfigurator
@@ -457,49 +438,6 @@ public async Task FailsFastWhenNothingEverSignedIn()
return (false, lastError);
}
- /// A reply for anything the roster read does not claim: a register, or an empty read.
- private static byte[] Answer(MockRequest request)
- {
- return request.Operation == OperationRegister
- ? Reply(OperationRegister, RegisterBody(session: 128))
- : Reply(OperationNonReplicated, []);
- }
-
- private static byte[] Reply(byte operation, byte[] body)
- {
- return Reply(operation, body, 0);
- }
-
- private static byte[] Reply(byte operation, byte[] body, uint status)
- {
- var frame = new byte[HeaderSize + body.Length];
- BinaryPrimitives.WriteUInt32LittleEndian(frame.AsSpan(SizeOffset, 4), (uint)frame.Length);
- frame[CommandOffset] = CommandReply;
- frame[ReplyOperationOffset] = operation;
- BinaryPrimitives.WriteUInt32LittleEndian(frame.AsSpan(ReplyStatusOffset, 4), status);
- body.CopyTo(frame.AsSpan(HeaderSize));
-
- return frame;
- }
-
- ///
- /// A register reply carries a committed result section, so its four leading zero bytes announce zero
- /// entries and the typed payload starts right after them. A non-replicated read carries none.
- ///
- private static byte[] RegisterBody(ulong session)
- {
- var serverVersion = Encoding.UTF8.GetBytes("0.0.0");
- var body = new byte[4 + 17 + serverVersion.Length];
- var payload = body.AsSpan(4);
- BinaryPrimitives.WriteUInt32LittleEndian(payload[..4], 7);
- BinaryPrimitives.WriteUInt64LittleEndian(payload[4..12], session);
- BinaryPrimitives.WriteUInt32LittleEndian(payload[12..16], 11 << 10);
- payload[16] = (byte)serverVersion.Length;
- serverVersion.CopyTo(payload[17..]);
-
- return body;
- }
-
private static byte[] ClusterMetadata(ushort primaryPort, ushort survivorPort, ushort leaderPort)
{
var body = new List();
@@ -554,149 +492,4 @@ private static void WriteString(List body, string value)
body.AddRange(BitConverter.GetBytes((uint)bytes.Length));
body.AddRange(bytes);
}
-
- private readonly record struct MockRequest(byte Operation, int Code, ulong RequestId);
-
- ///
- /// A loopback VSR node. Killing it drops the live sockets and stops accepting, so a redial is refused the
- /// way a dead process refuses one.
- ///
- private sealed class MockNode : IDisposable
- {
- private readonly TcpListener _listener;
- private readonly List _accepted = [];
- private volatile bool _killed;
- private int _connections;
- private int _pings;
- private int _registrations;
-
- ///
- /// A port to bind, for a node that has to come up on an address the client already knows. Zero
- /// takes whatever the OS hands out.
- ///
- public MockNode(ushort port = 0)
- {
- _listener = new TcpListener(IPAddress.Loopback, port);
- _listener.Start();
- Port = (ushort)((IPEndPoint)_listener.LocalEndpoint).Port;
- }
-
- public ushort Port { get; }
-
- public int Pings => Volatile.Read(ref _pings);
-
- public int Registrations => Volatile.Read(ref _registrations);
-
- public int Connections
- {
- get
- {
- lock (_accepted)
- {
- return _connections;
- }
- }
- }
-
- public void Serve(Func handler)
- {
- _ = Task.Run(async () =>
- {
- while (!_killed)
- {
- TcpClient connection;
- try
- {
- connection = await _listener.AcceptTcpClientAsync();
- }
- catch (Exception)
- {
- return;
- }
-
- lock (_accepted)
- {
- _accepted.Add(connection);
- _connections++;
- }
-
- _ = Task.Run(() => Exchange(connection, handler));
- }
- });
- }
-
- public void Kill()
- {
- _killed = true;
- lock (_accepted)
- {
- foreach (var connection in _accepted)
- {
- connection.Close();
- }
-
- _accepted.Clear();
- }
-
- _listener.Stop();
- }
-
- public void Dispose()
- {
- Kill();
- }
-
- private async Task Exchange(TcpClient connection, Func handler)
- {
- try
- {
- await using var stream = connection.GetStream();
- var header = new byte[HeaderSize];
- while (!_killed)
- {
- await ReadExactly(stream, header);
- var size = BinaryPrimitives.ReadUInt32LittleEndian(header.AsSpan(SizeOffset, 4));
- var body = new byte[size - HeaderSize];
- await ReadExactly(stream, body);
-
- var request = new MockRequest(header[RequestOperationOffset],
- BinaryPrimitives.ReadInt32LittleEndian(header.AsSpan(RequestReservedOffset, 4)),
- BinaryPrimitives.ReadUInt64LittleEndian(header.AsSpan(RequestIdOffset, 8)));
- if (request.Operation == OperationRegister)
- {
- Interlocked.Increment(ref _registrations);
- }
- else if (request.Code == PingCode)
- {
- Interlocked.Increment(ref _pings);
- }
-
- var reply = handler(request);
- BinaryPrimitives.WriteUInt64LittleEndian(reply.AsSpan(ReplyRequestIdOffset, 8),
- request.RequestId);
- await stream.WriteAsync(reply);
- await stream.FlushAsync();
- }
- }
- catch (Exception)
- {
- // A killed node and a client that went away look the same here.
- }
- }
-
- private static async Task ReadExactly(NetworkStream stream, byte[] buffer)
- {
- var read = 0;
- while (read < buffer.Length)
- {
- var chunk = await stream.ReadAsync(buffer.AsMemory(read));
- if (chunk == 0)
- {
- throw new EndOfStreamException("Connection closed");
- }
-
- read += chunk;
- }
- }
- }
}
diff --git a/foreign/csharp/Iggy_SDK_Tests/VsrTests/GroupPollingTests.cs b/foreign/csharp/Iggy_SDK_Tests/VsrTests/GroupPollingTests.cs
new file mode 100644
index 0000000000..3eedf5e932
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK_Tests/VsrTests/GroupPollingTests.cs
@@ -0,0 +1,150 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+using System.Buffers.Binary;
+using Apache.Iggy.Configuration;
+using Apache.Iggy.Contracts;
+using Apache.Iggy.Enums;
+using Apache.Iggy.IggyClient.Implementations;
+using Apache.Iggy.Kinds;
+using Apache.Iggy.Utils;
+using Microsoft.Extensions.Logging.Abstractions;
+using static Apache.Iggy.Tests.VsrTests.MockFrames;
+
+namespace Apache.Iggy.Tests.VsrTests;
+
+///
+/// Group polls resolve the partition client-side from the coordinator's assignment. Mirrors
+/// foreign/go/client/tcp/tcp_group_polling_test.go.
+///
+public sealed class GroupPollingTests
+{
+ private const int SyncGroupCode = CommandCodes.SYNC_CONSUMER_GROUP_CODE;
+ private const int PollMessagesCode = CommandCodes.POLL_MESSAGES_CODE;
+
+ [Fact]
+ public async Task given_member_holding_no_partitions_when_polled_twice_should_report_no_assignment_and_sync_once()
+ {
+ using var node = new MockNode();
+ node.Serve(request => request.Code == SyncGroupCode
+ ? Reply(OPERATION_NON_REPLICATED, AssignmentBody(9, []))
+ : Answer(request));
+ using var client = await ConnectAsync(node);
+
+ var first = await PollOnceAsync(client);
+ var second = await PollOnceAsync(client);
+
+ Assert.Equal(PolledMessages.NoAssignedPartition, first.PartitionId);
+ Assert.Empty(first.Messages);
+ Assert.Equal(PolledMessages.NoAssignedPartition, second.PartitionId);
+ Assert.Empty(second.Messages);
+ // The empty assignment is cached: a member that owns nothing must not re-sync on every poll.
+ Assert.Equal(1, node.Requests(SyncGroupCode));
+ Assert.Equal(0, node.Requests(PollMessagesCode));
+ }
+
+ [Fact]
+ public async Task given_rebalance_outlasting_the_attempts_when_polled_should_report_no_assignment()
+ {
+ using var node = new MockNode();
+ node.Serve(request => request.Code switch
+ {
+ SyncGroupCode => Reply(OPERATION_NON_REPLICATED, AssignmentBody(9, [0])),
+ // The server marks a stale assignment with the resync sentinel.
+ PollMessagesCode => Reply(request.Operation, EmptyBatchBody(uint.MaxValue)),
+ _ => Answer(request)
+ });
+ using var client = await ConnectAsync(node);
+
+ var polled = await PollOnceAsync(client);
+
+ Assert.Equal(PolledMessages.NoAssignedPartition, polled.PartitionId);
+ Assert.Empty(polled.Messages);
+ Assert.Equal(2, node.Requests(PollMessagesCode));
+ Assert.Equal(3, node.Requests(SyncGroupCode));
+ }
+
+ [Fact]
+ public async Task given_fenced_poll_when_resynced_should_poll_the_new_assignment()
+ {
+ using var node = new MockNode();
+ var generation = 1ul;
+ node.Serve(request =>
+ {
+ switch (request.Code)
+ {
+ case SyncGroupCode:
+ return Reply(OPERATION_NON_REPLICATED, AssignmentBody(generation, [(uint)generation]));
+ case PollMessagesCode when generation == 1:
+ // The member no longer owns the partition at this generation.
+ generation = 2;
+ return Reply(request.Operation, EmptyBatchBody(uint.MaxValue));
+ case PollMessagesCode:
+ return Reply(request.Operation, EmptyBatchBody(2));
+ default:
+ return Answer(request);
+ }
+ });
+ using var client = await ConnectAsync(node);
+
+ var polled = await PollOnceAsync(client);
+
+ Assert.Equal(2u, polled.PartitionId);
+ Assert.Equal(2, node.Requests(SyncGroupCode));
+ Assert.Equal(2, node.Requests(PollMessagesCode));
+ }
+
+ private static async Task ConnectAsync(MockNode node)
+ {
+ var configuration = new IggyClientConfigurator
+ {
+ BaseAddress = $"127.0.0.1:{node.Port}",
+ Protocol = Protocol.Tcp
+ };
+ var client = new TcpMessageStream(configuration, NullLoggerFactory.Instance);
+ await client.ConnectAsync(TestContext.Current.CancellationToken);
+
+ return client;
+ }
+
+ private static Task PollOnceAsync(TcpMessageStream client)
+ {
+ return client.PollMessagesAsync(Identifier.Numeric(1), Identifier.Numeric(2), null, Consumer.Group(3),
+ PollingStrategy.Next(), 10, false, TestContext.Current.CancellationToken);
+ }
+
+ private static byte[] AssignmentBody(ulong generation, uint[] partitions)
+ {
+ var body = new byte[12 + partitions.Length * 4];
+ BinaryPrimitives.WriteUInt64LittleEndian(body.AsSpan(0, 8), generation);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8, 4), (uint)partitions.Length);
+ for (var index = 0; index < partitions.Length; index++)
+ {
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12 + index * 4, 4), partitions[index]);
+ }
+
+ return body;
+ }
+
+ private static byte[] EmptyBatchBody(uint partitionId)
+ {
+ var body = new byte[16];
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(0, 4), partitionId);
+
+ return body;
+ }
+}
diff --git a/foreign/csharp/Iggy_SDK_Tests/VsrTests/MockNode.cs b/foreign/csharp/Iggy_SDK_Tests/VsrTests/MockNode.cs
new file mode 100644
index 0000000000..55e247054a
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK_Tests/VsrTests/MockNode.cs
@@ -0,0 +1,251 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+using System.Buffers.Binary;
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+using static Apache.Iggy.Tests.VsrTests.MockFrames;
+
+namespace Apache.Iggy.Tests.VsrTests;
+
+/// VSR frame layout and reply builders shared by the loopback mock node tests.
+internal static class MockFrames
+{
+ internal const int HEADER_SIZE = 256;
+ internal const int SIZE_OFFSET = 48;
+ internal const int COMMAND_OFFSET = 60;
+ internal const int REQUEST_ID_OFFSET = 168;
+ internal const int REQUEST_OPERATION_OFFSET = 176;
+ internal const int REQUEST_RESERVED_OFFSET = 196;
+ internal const int REPLY_REQUEST_ID_OFFSET = 200;
+ internal const int REPLY_OPERATION_OFFSET = 208;
+ internal const int REPLY_STATUS_OFFSET = 216;
+
+ internal const byte COMMAND_REPLY = 8;
+ internal const byte COMMAND_EVICTION = 13;
+ internal const int EVICTION_REASON_OFFSET = 255;
+ internal const byte EVICTION_STALE_CLIENT = 13;
+ internal const byte OPERATION_REGISTER = 1;
+ internal const byte OPERATION_NON_REPLICATED = 2;
+ internal const int GET_CLUSTER_METADATA_CODE = 12;
+ internal const int PING_CODE = 1;
+ internal const uint TRANSIENT_NOT_ACCEPTED = 58;
+
+ /// A reply for anything the roster read does not claim: a register, or an empty read.
+ internal static byte[] Answer(MockRequest request)
+ {
+ return request.Operation == OPERATION_REGISTER
+ ? Reply(OPERATION_REGISTER, RegisterBody(session: 128))
+ : Reply(OPERATION_NON_REPLICATED, []);
+ }
+
+ internal static byte[] Reply(byte operation, byte[] body)
+ {
+ return Reply(operation, body, 0);
+ }
+
+ internal static byte[] Reply(byte operation, byte[] body, uint status)
+ {
+ var frame = new byte[HEADER_SIZE + body.Length];
+ BinaryPrimitives.WriteUInt32LittleEndian(frame.AsSpan(SIZE_OFFSET, 4), (uint)frame.Length);
+ frame[COMMAND_OFFSET] = COMMAND_REPLY;
+ frame[REPLY_OPERATION_OFFSET] = operation;
+ BinaryPrimitives.WriteUInt32LittleEndian(frame.AsSpan(REPLY_STATUS_OFFSET, 4), status);
+ body.CopyTo(frame.AsSpan(HEADER_SIZE));
+
+ return frame;
+ }
+
+ ///
+ /// A register reply carries a committed result section, so its four leading zero bytes announce zero
+ /// entries and the typed payload starts right after them. A non-replicated read carries none.
+ ///
+ internal static byte[] RegisterBody(ulong session)
+ {
+ var serverVersion = Encoding.UTF8.GetBytes("0.0.0");
+ var body = new byte[4 + 17 + serverVersion.Length];
+ var payload = body.AsSpan(4);
+ BinaryPrimitives.WriteUInt32LittleEndian(payload[..4], 7);
+ BinaryPrimitives.WriteUInt64LittleEndian(payload[4..12], session);
+ BinaryPrimitives.WriteUInt32LittleEndian(payload[12..16], 11 << 10);
+ payload[16] = (byte)serverVersion.Length;
+ serverVersion.CopyTo(payload[17..]);
+
+ return body;
+ }
+}
+
+internal readonly record struct MockRequest(byte Operation, int Code, ulong RequestId);
+
+///
+/// A loopback VSR node. Killing it drops the live sockets and stops accepting, so a redial is refused the
+/// way a dead process refuses one.
+///
+internal sealed class MockNode : IDisposable
+{
+ private readonly TcpListener _listener;
+ private readonly List _accepted = [];
+ private volatile bool _killed;
+ private int _connections;
+ private readonly List _recorded = [];
+ private int _pings;
+ private int _registrations;
+
+ ///
+ /// A port to bind, for a node that has to come up on an address the client already knows. Zero
+ /// takes whatever the OS hands out.
+ ///
+ public MockNode(ushort port = 0)
+ {
+ _listener = new TcpListener(IPAddress.Loopback, port);
+ _listener.Start();
+ Port = (ushort)((IPEndPoint)_listener.LocalEndpoint).Port;
+ }
+
+ public ushort Port { get; }
+
+ public int Pings => Volatile.Read(ref _pings);
+
+ public int Registrations => Volatile.Read(ref _registrations);
+
+ /// How many requests with the given command code the node has answered so far.
+ public int Requests(int code)
+ {
+ lock (_recorded)
+ {
+ return _recorded.Count(request => request.Code == code);
+ }
+ }
+
+ public int Connections
+ {
+ get
+ {
+ lock (_accepted)
+ {
+ return _connections;
+ }
+ }
+ }
+
+ public void Serve(Func handler)
+ {
+ _ = Task.Run(async () =>
+ {
+ while (!_killed)
+ {
+ TcpClient connection;
+ try
+ {
+ connection = await _listener.AcceptTcpClientAsync();
+ }
+ catch (Exception)
+ {
+ return;
+ }
+
+ lock (_accepted)
+ {
+ _accepted.Add(connection);
+ _connections++;
+ }
+
+ _ = Task.Run(() => Exchange(connection, handler));
+ }
+ });
+ }
+
+ public void Kill()
+ {
+ _killed = true;
+ lock (_accepted)
+ {
+ foreach (var connection in _accepted)
+ {
+ connection.Close();
+ }
+
+ _accepted.Clear();
+ }
+
+ _listener.Stop();
+ }
+
+ public void Dispose()
+ {
+ Kill();
+ }
+
+ private async Task Exchange(TcpClient connection, Func handler)
+ {
+ try
+ {
+ await using var stream = connection.GetStream();
+ var header = new byte[HEADER_SIZE];
+ while (!_killed)
+ {
+ await ReadExactly(stream, header);
+ var size = BinaryPrimitives.ReadUInt32LittleEndian(header.AsSpan(SIZE_OFFSET, 4));
+ var body = new byte[size - HEADER_SIZE];
+ await ReadExactly(stream, body);
+
+ var request = new MockRequest(header[REQUEST_OPERATION_OFFSET],
+ BinaryPrimitives.ReadInt32LittleEndian(header.AsSpan(REQUEST_RESERVED_OFFSET, 4)),
+ BinaryPrimitives.ReadUInt64LittleEndian(header.AsSpan(REQUEST_ID_OFFSET, 8)));
+ lock (_recorded)
+ {
+ _recorded.Add(request);
+ }
+
+ if (request.Operation == OPERATION_REGISTER)
+ {
+ Interlocked.Increment(ref _registrations);
+ }
+ else if (request.Code == PING_CODE)
+ {
+ Interlocked.Increment(ref _pings);
+ }
+
+ var reply = handler(request);
+ BinaryPrimitives.WriteUInt64LittleEndian(reply.AsSpan(REPLY_REQUEST_ID_OFFSET, 8),
+ request.RequestId);
+ await stream.WriteAsync(reply);
+ await stream.FlushAsync();
+ }
+ }
+ catch (Exception)
+ {
+ // A killed node and a client that went away look the same here.
+ }
+ }
+
+ private static async Task ReadExactly(NetworkStream stream, byte[] buffer)
+ {
+ var read = 0;
+ while (read < buffer.Length)
+ {
+ var chunk = await stream.ReadAsync(buffer.AsMemory(read));
+ if (chunk == 0)
+ {
+ throw new EndOfStreamException("Connection closed");
+ }
+
+ read += chunk;
+ }
+ }
+}