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; + } + } +}