diff --git a/.github/actions/csharp-dotnet/pre-merge/action.yml b/.github/actions/csharp-dotnet/pre-merge/action.yml index 143a157006..697f602924 100644 --- a/.github/actions/csharp-dotnet/pre-merge/action.yml +++ b/.github/actions/csharp-dotnet/pre-merge/action.yml @@ -21,7 +21,7 @@ description: .NET pre-merge testing github iggy actions inputs: task: - description: "Task to run (lint, test, build, e2e)" + description: "Task to run (lint, test, build, e2e, e2e-vsr)" required: true runs: @@ -33,7 +33,7 @@ runs: dotnet-version: "10.0.x" - name: Setup Rust with cache - if: inputs.task == 'test' || inputs.task == 'e2e' + if: inputs.task == 'test' || inputs.task == 'e2e' || inputs.task == 'e2e-vsr' uses: ./.github/actions/utils/setup-rust-with-cache - name: Restore dependencies @@ -90,6 +90,51 @@ runs: env: IGGY_SERVER_DOCKER_IMAGE: ${{ steps.docker_build.outputs.docker_image }} IGGY_TEST_LOGS_DIR: ./reports/container-logs + # The suite runs once per server; this job only built the classic image. + IGGY_TEST_SERVER: classic + run: | + dotnet test --project Iggy_SDK.Tests.Integration \ + --no-build \ + --verbosity normal \ + --coverage \ + --coverage-output-format cobertura \ + --coverage-output coverage.cobertura.xml \ + --results-directory ./reports \ + -- --report-trx --retry-failed-tests 3 + shell: bash + + - name: Build iggy-server-ng Docker image + if: inputs.task == 'e2e-vsr' + shell: bash + run: | + set -euo pipefail + IMAGE_TAG="iggy-server-ng:test" + SERVER_BINARY="target/debug/iggy-server-ng" + CLI_BINARY="target/debug/iggy" + + # server-ng only speaks the VSR wire protocol when built with the vsr feature. + cargo build --locked --features vsr --bin iggy-server-ng --bin iggy + ls -lh "$SERVER_BINARY" "$CLI_BINARY" + + docker build \ + -f core/server-ng/Dockerfile \ + --target runtime-prebuilt \ + -t "$IMAGE_TAG" \ + --build-arg PREBUILT_IGGY_SERVER_NG="$SERVER_BINARY" \ + --build-arg PREBUILT_IGGY_CLI="$CLI_BINARY" \ + . + + echo "IGGY_SERVER_NG_DOCKER_IMAGE=$IMAGE_TAG" >> "$GITHUB_ENV" + + - name: Run VSR integration tests + if: inputs.task == 'e2e-vsr' + working-directory: foreign/csharp + env: + IGGY_TEST_LOGS_DIR: ./reports/container-logs + # Runs the whole suite plus the server-ng-only tests against a two-node iggy-server-ng cluster the + # fixture starts, so every case commits through consensus. TCP only on this leg, framed with the VSR + # wire protocol. + IGGY_TEST_SERVER: ng run: | dotnet test --project Iggy_SDK.Tests.Integration \ --no-build \ @@ -102,7 +147,7 @@ runs: shell: bash - name: Collect container logs - if: inputs.task == 'e2e' && always() + if: (inputs.task == 'e2e' || inputs.task == 'e2e-vsr') && always() shell: bash run: | mkdir -p foreign/csharp/reports/container-logs @@ -110,9 +155,9 @@ runs: - name: Upload Test Results uses: actions/upload-artifact@v7 - if: inputs.task == 'e2e' && always() + if: (inputs.task == 'e2e' || inputs.task == 'e2e-vsr') && always() with: - name: dotnet-test-results + name: dotnet-test-results-${{ inputs.task }} path: foreign/csharp/reports retention-days: 7 diff --git a/.github/config/components.yml b/.github/config/components.yml index a2ec096d5e..43ae4c2cab 100644 --- a/.github/config/components.yml +++ b/.github/config/components.yml @@ -281,7 +281,7 @@ components: paths: - "foreign/csharp/**" - "examples/csharp/**" - tasks: ["lint", "test", "build", "e2e"] + tasks: ["lint", "test", "build", "e2e", "e2e-vsr"] sdk-cpp: depends_on: diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml index 80edc184bc..fb1764bea1 100644 --- a/.github/workflows/_test.yml +++ b/.github/workflows/_test.yml @@ -197,7 +197,7 @@ jobs: override_pr: ${{ github.event.pull_request.number }} - name: Upload C# coverage to Codecov - if: inputs.component == 'sdk-csharp' && (inputs.task == 'test' || inputs.task == 'e2e') + if: inputs.component == 'sdk-csharp' && (inputs.task == 'test' || inputs.task == 'e2e' || inputs.task == 'e2e-vsr') uses: codecov/codecov-action@v7.0.0 with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/coverage-baseline.yml b/.github/workflows/coverage-baseline.yml index 1f580db3d6..2ef2267718 100644 --- a/.github/workflows/coverage-baseline.yml +++ b/.github/workflows/coverage-baseline.yml @@ -195,6 +195,7 @@ jobs: working-directory: foreign/csharp env: IGGY_SERVER_DOCKER_IMAGE: ${{ steps.docker_build.outputs.docker_image }} + IGGY_TEST_SERVER: classic run: | dotnet test \ --project Iggy_SDK.Tests.Integration \ @@ -205,6 +206,43 @@ jobs: --coverage-output coverage.cobertura.xml \ --results-directory ./reports/integration + - name: Build iggy-server-ng Docker image + shell: bash + run: | + set -euo pipefail + IMAGE_TAG="iggy-server-ng:test" + SERVER_BINARY="target/debug/iggy-server-ng" + CLI_BINARY="target/debug/iggy" + + # server-ng only speaks the VSR wire protocol when built with the vsr feature. + cargo build --locked --features vsr --bin iggy-server-ng --bin iggy + + docker build \ + -f core/server-ng/Dockerfile \ + --target runtime-prebuilt \ + -t "$IMAGE_TAG" \ + --build-arg PREBUILT_IGGY_SERVER_NG="$SERVER_BINARY" \ + --build-arg PREBUILT_IGGY_CLI="$CLI_BINARY" \ + . + + echo "IGGY_SERVER_NG_DOCKER_IMAGE=$IMAGE_TAG" >> "$GITHUB_ENV" + + # The PR side uploads its e2e-vsr run under the same csharp flag, so a baseline without this leg + # compares every VSR line against nothing and reports the whole transport as uncovered. + - name: Run VSR integration tests with coverage + working-directory: foreign/csharp + env: + IGGY_TEST_SERVER: ng + run: | + dotnet test \ + --project Iggy_SDK.Tests.Integration \ + --no-build \ + --verbosity normal \ + --coverage \ + --coverage-output-format cobertura \ + --coverage-output coverage.cobertura.xml \ + --results-directory ./reports/vsr + - name: Merge coverage reports working-directory: foreign/csharp run: | diff --git a/foreign/csharp/Directory.Packages.props b/foreign/csharp/Directory.Packages.props index 9c6acde02d..fb1f9ce5d2 100644 --- a/foreign/csharp/Directory.Packages.props +++ b/foreign/csharp/Directory.Packages.props @@ -35,6 +35,7 @@ + diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/Attributes/ServerAttributes.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/Attributes/ServerAttributes.cs new file mode 100644 index 0000000000..c15c3ad488 --- /dev/null +++ b/foreign/csharp/Iggy_SDK.Tests.Integration/Attributes/ServerAttributes.cs @@ -0,0 +1,50 @@ +// 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 Apache.Iggy.Tests.Integrations.Fixtures; + +namespace Apache.Iggy.Tests.Integrations.Attributes; + +/// +/// The operation is not served by iggy-server-ng yet, so the test only runs against the classic server. +/// The reason names the gap. +/// +internal class SkipServerNgAttribute(string reason) : SkipAttribute($"Skipped for server-ng: {reason}") +{ + public override Task ShouldSkip(TestRegisteredContext context) + { + return Task.FromResult(IggyServerFixture.IsServerNg); + } +} + +/// Pins a test to iggy-server-ng: a classic run has no cluster to exercise. +internal class RequiresServerNgAttribute() : SkipAttribute("Requires IGGY_TEST_SERVER=ng") +{ + public override Task ShouldSkip(TestRegisteredContext context) + { + return Task.FromResult(!IggyServerFixture.IsServerNg); + } +} + +/// Pins a test to the classic server, which owns the only image it can start. +internal class RequiresClassicServerAttribute() : SkipAttribute("Requires IGGY_TEST_SERVER=classic") +{ + public override Task ShouldSkip(TestRegisteredContext context) + { + return Task.FromResult(IggyServerFixture.IsServerNg); + } +} diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/ClusterRedirectionTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/ClusterRedirectionTests.cs index 1eafa8ea10..9a6ea5b9c8 100644 --- a/foreign/csharp/Iggy_SDK.Tests.Integration/ClusterRedirectionTests.cs +++ b/foreign/csharp/Iggy_SDK.Tests.Integration/ClusterRedirectionTests.cs @@ -19,11 +19,13 @@ using Apache.Iggy.Contracts; using Apache.Iggy.Enums; using Apache.Iggy.Factory; +using Apache.Iggy.Tests.Integrations.Attributes; using Apache.Iggy.Tests.Integrations.Fixtures; using Shouldly; namespace Apache.Iggy.Tests.Integrations; +[RequiresClassicServer] public class ClusterRedirectionTests { [ClassDataSource(Shared = SharedType.PerAssembly)] diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/ConsumerGroupTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/ConsumerGroupTests.cs index ee13df7d74..c6ed9ed56a 100644 --- a/foreign/csharp/Iggy_SDK.Tests.Integration/ConsumerGroupTests.cs +++ b/foreign/csharp/Iggy_SDK.Tests.Integration/ConsumerGroupTests.cs @@ -81,8 +81,8 @@ public async Task GetConsumerGroupById_Should_Return_ValidResponse(Protocol prot var cg = await client.CreateConsumerGroupAsync(Identifier.String(streamName), Identifier.String(TopicName), GroupName); - var response = await client.GetConsumerGroupByIdAsync( - Identifier.String(streamName), Identifier.String(TopicName), + var response = await client.GetConsumerGroupByIdAsync(Identifier.String(streamName), + Identifier.String(TopicName), Identifier.Numeric(cg!.Id)); response.ShouldNotBeNull(); @@ -194,15 +194,14 @@ public async Task GetConsumerGroupById_WithMembers_Should_Return_ValidResponse(P var clients = new List(); for (var i = 0; i < 2; i++) { - var memberClient = await Fixture.CreateClient(Protocol.Tcp); + var memberClient = await Fixture.CreateAuthenticatedClient(Protocol.Tcp); clients.Add(memberClient); - await memberClient.LoginUserAsync("iggy", "iggy"); await memberClient.JoinConsumerGroupAsync(Identifier.String(streamName), Identifier.String(TopicName), Identifier.Numeric(cg!.Id)); } - var response = await client.GetConsumerGroupByIdAsync( - Identifier.String(streamName), Identifier.String(TopicName), + var response = await client.GetConsumerGroupByIdAsync(Identifier.String(streamName), + Identifier.String(TopicName), Identifier.Numeric(cg!.Id)); response.ShouldNotBeNull(); diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/FetchMessagesTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/FetchMessagesTests.cs index d75d95cee1..f32e1965c2 100644 --- a/foreign/csharp/Iggy_SDK.Tests.Integration/FetchMessagesTests.cs +++ b/foreign/csharp/Iggy_SDK.Tests.Integration/FetchMessagesTests.cs @@ -23,6 +23,7 @@ using Apache.Iggy.IggyClient; using Apache.Iggy.Kinds; using Apache.Iggy.Messages; +using Apache.Iggy.Tests.Integrations.Attributes; using Apache.Iggy.Tests.Integrations.Fixtures; using Shouldly; using Partitioning = Apache.Iggy.Kinds.Partitioning; @@ -90,6 +91,8 @@ public async Task PollMessages_WithNoHeaders_Should_PollMessages_Successfully(Pr [Test] [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] + [SkipServerNg( + "server-ng replies the empty-poll shape for an unresolved topic; a zero-byte error body would break the poll decoder")] public async Task PollMessages_InvalidTopic_Should_Throw_InvalidResponse(Protocol protocol) { var (client, streamName) = await CreateStreamWithMessages(protocol); diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/Fixtures/IggyClusterFixture.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/Fixtures/IggyClusterFixture.cs index 73c33edeac..43725b196f 100644 --- a/foreign/csharp/Iggy_SDK.Tests.Integration/Fixtures/IggyClusterFixture.cs +++ b/foreign/csharp/Iggy_SDK.Tests.Integration/Fixtures/IggyClusterFixture.cs @@ -39,12 +39,6 @@ public class IggyClusterFixture : IAsyncInitializer, IAsyncDisposable private const ushort PortRangeSize = 100; private static readonly ushort BasePort = (ushort)(30000 + Environment.Version.Major * 100); private static readonly ushort EndPort = (ushort)(BasePort + PortRangeSize); - - // Listeners only need to outlive the eight ReservePort() calls in the - // constructor so we don't pick the same port twice within one fixture. - // Partitioned ranges already guarantee sibling processes can't race us, so - // we can release them as soon as picking is done. - private readonly List _portReservations = []; private readonly IContainer _followerContainer; private readonly ushort _followerHttpPort; private readonly ushort _followerQuicPort; @@ -60,6 +54,12 @@ public class IggyClusterFixture : IAsyncInitializer, IAsyncDisposable private readonly INetwork _network; + // Listeners only need to outlive the eight ReservePort() calls in the + // constructor so we don't pick the same port twice within one fixture. + // Partitioned ranges already guarantee sibling processes can't race us, so + // we can release them as soon as picking is done. + private readonly List _portReservations = []; + private string DockerImage => Environment.GetEnvironmentVariable("IGGY_SERVER_DOCKER_IMAGE") ?? "apache/iggy:edge"; @@ -108,7 +108,7 @@ public IggyClusterFixture() ["IGGY_CLUSTER_NODES_1_PORTS_TCP"] = _followerTcpPort.ToString(), ["IGGY_CLUSTER_NODES_1_PORTS_QUIC"] = _followerQuicPort.ToString(), ["IGGY_CLUSTER_NODES_1_PORTS_HTTP"] = _followerHttpPort.ToString(), - ["IGGY_CLUSTER_NODES_1_PORTS_WEBSOCKET"] = _followerWsPort.ToString(), + ["IGGY_CLUSTER_NODES_1_PORTS_WEBSOCKET"] = _followerWsPort.ToString() }; _leaderContainer = new ContainerBuilder(DockerImage) @@ -183,7 +183,7 @@ public string GetFollowerAddress() private ushort ReservePort() { - for (ushort candidate = BasePort; candidate < EndPort; candidate++) + for (var candidate = BasePort; candidate < EndPort; candidate++) { try { diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/Fixtures/IggyServerFixture.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/Fixtures/IggyServerFixture.cs index 8c6718b607..1022565af3 100644 --- a/foreign/csharp/Iggy_SDK.Tests.Integration/Fixtures/IggyServerFixture.cs +++ b/foreign/csharp/Iggy_SDK.Tests.Integration/Fixtures/IggyServerFixture.cs @@ -29,15 +29,40 @@ namespace Apache.Iggy.Tests.Integrations.Fixtures; public class IggyServerFixture : IAsyncInitializer, IAsyncDisposable { + /// + /// Server the whole suite runs against: classic (default) or ng. The two servers ship as + /// separate images, so the choice is a property of the run rather than of a test: CI runs the suite once + /// per value, and only the second run frames TCP with the VSR wire protocol. + /// + private const string ServerVariable = "IGGY_TEST_SERVER"; + private readonly string _containerId = Guid.NewGuid().ToString(); - protected IContainer? IggyContainer; + + private readonly IContainer _iggyContainer; + private readonly HashSet _started = []; + private readonly SemaphoreSlim _startGate = new(1, 1); + + private VsrCluster? _serverNgCluster; /// /// Docker image to use. Can be overridden via IGGY_SERVER_DOCKER_IMAGE environment variable - /// or by subclasses. Defaults to apache/iggy:edge if not specified. + /// or by subclasses. Defaults to the locally built iggy-server:test; build it with + /// docker build -f Dockerfile -t iggy-server:test . from the repository root, or point + /// IGGY_SERVER_DOCKER_IMAGE at a published image such as apache/iggy:edge. /// - private string DockerImage => - Environment.GetEnvironmentVariable("IGGY_SERVER_DOCKER_IMAGE") ?? "apache/iggy:edge"; + protected virtual string DockerImage => + Environment.GetEnvironmentVariable("IGGY_SERVER_DOCKER_IMAGE") ?? "iggy-server:test"; + + /// + /// Image used when the run targets server-ng. Built from core/server-ng/Dockerfile. It comes up as + /// a , so every test commits through real replication. + /// + protected virtual string ServerNgDockerImage => + Environment.GetEnvironmentVariable("IGGY_SERVER_NG_DOCKER_IMAGE") ?? "iggy-server-ng:test"; + + /// The run targets iggy-server-ng, so both protocols dial the VSR cluster instead of the classic image. + public static bool IsServerNg => + string.Equals(Environment.GetEnvironmentVariable(ServerVariable), "ng", StringComparison.OrdinalIgnoreCase); /// /// Environment variables for the container. Override in subclasses to customize. @@ -70,7 +95,118 @@ public class IggyServerFixture : IAsyncInitializer, IAsyncDisposable public IggyServerFixture() { - var builder = new ContainerBuilder(DockerImage) + _iggyContainer = BuildContainer(DockerImage, _containerId); + } + + public async ValueTask DisposeAsync() + { + await StopContainerAsync(_iggyContainer, "iggy-server"); + + if (_serverNgCluster != null) + { + await _serverNgCluster.DisposeAsync(); + } + } + + /// + /// Containers start on first use: a run scoped to one server must not pay for - or fail on - the image + /// the job never built. + /// + public Task InitializeAsync() + { + return Task.CompletedTask; + } + + /// + /// Under VSR the register handshake is the login, so auto-login on top of the explicit one would register + /// twice on the same connection: the server answers the second one by replaying the binding it already + /// holds, and the client then carries a client id the server never bound, which consumer-group + /// membership is keyed by. + /// + public async Task CreateAuthenticatedClient(Protocol protocol, string userName = "iggy", + string password = "iggy", IMessageEncryptor? encryptor = null) + { + var client = await CreateClient(protocol, WireProtocolFor(protocol) == WireProtocol.Classic, + encryptor: encryptor, userName: userName, password: password); + await client.LoginUserAsync(userName, password); + + return client; + } + + /// + /// A connected client that has not logged in, so the caller owns the handshake. + /// + public async Task CreateUnauthenticatedClient(Protocol protocol) + { + return await CreateClient(protocol); + } + + /// + /// overrides the container address, so a test can dial the server through a + /// proxy while keeping the rest of the configuration identical. + /// + public async Task CreateClient(Protocol protocol, bool autoLogin = false, bool connect = true, + IMessageEncryptor? encryptor = null, string? address = null, string userName = "iggy", + string password = "iggy") + { + var client = IggyClientFactory.CreateClient(new IggyClientConfigurator + { + BaseAddress = address ?? await GetIggyAddressAsync(protocol), + Protocol = protocol, + WireProtocol = WireProtocolFor(protocol), + ReconnectionSettings = new ReconnectionSettings { Enabled = true }, + AutoLoginSettings = new AutoLoginSettings + { + Enabled = autoLogin, + Username = userName, + Password = password + }, + MessageEncryptor = encryptor + }); + + if (connect) + { + await client.ConnectAsync(); + } + + return client; + } + + /// VSR framing exists on TCP only, so the REST surface stays classic on either server. + public static WireProtocol WireProtocolFor(Protocol protocol) + { + return IsServerNg && protocol == Protocol.Tcp ? WireProtocol.Vsr : WireProtocol.Classic; + } + + public async Task GetIggyAddressAsync(Protocol protocol) + { + if (IsServerNg) + { + var cluster = await EnsureServerNgClusterStartedAsync(); + + return protocol == Protocol.Tcp ? cluster.LeaderTcpAddress : cluster.LeaderHttpAddress; + } + + var container = await EnsureStartedAsync(); + + return protocol == Protocol.Tcp + ? $"127.0.0.1:{container.GetMappedPublicPort(8090)}" + : $"http://127.0.0.1:{container.GetMappedPublicPort(3000)}"; + } + + /// + /// server-ng serves REST too, but its reads default to serializable consistency, so under the suite's + /// parallelism one can trail the commit it just made and a create-then-read fails. Restore the HTTP row + /// once that is settled. + /// + public static IEnumerable> ProtocolData() + { + return IsServerNg ? [() => Protocol.Tcp] : [() => Protocol.Http, () => Protocol.Tcp]; + } + + protected virtual IContainer BuildContainer(string image, string name) + { + var builder = new ContainerBuilder(image) .WithPortBinding(3000, true) .WithPortBinding(8090, true) .WithWaitStrategy(Wait.ForUnixContainer() @@ -78,7 +214,7 @@ public IggyServerFixture() .UntilHttpRequestIsSucceeded(request => request .ForPort(3000) .ForPath("/ping"))) - .WithName(_containerId) + .WithName(name) .WithPrivileged(true) .WithCleanUp(true); @@ -99,29 +235,59 @@ public IggyServerFixture() builder = builder.WithResourceMapping(mapping.Source, mapping.Destination); } - IggyContainer = builder.Build(); + return builder.Build(); } - public async ValueTask DisposeAsync() + private async Task EnsureStartedAsync() { - if (IggyContainer == null) + await _startGate.WaitAsync(); + try { - return; + if (_started.Add(_iggyContainer)) + { + await _iggyContainer.StartAsync(); + } + + return _iggyContainer; } + finally + { + _startGate.Release(); + } + } - await SaveContainerLogsAsync(); - await IggyContainer.StopAsync(); + private async Task EnsureServerNgClusterStartedAsync() + { + await _startGate.WaitAsync(); + try + { + if (_serverNgCluster == null) + { + var cluster = new VsrCluster(ServerNgDockerImage, _containerId, EnabledServerTraceLogs); + await cluster.StartAsync(); + _serverNgCluster = cluster; + } + + return _serverNgCluster; + } + finally + { + _startGate.Release(); + } } - public virtual async Task InitializeAsync() + private async Task StopContainerAsync(IContainer? container, string role) { - await IggyContainer!.StartAsync(); + if (container == null || !_started.Contains(container)) + { + return; + } - await CreateTcpClient(); - await CreateHttpClient(); + await SaveContainerLogsAsync(container, role); + await container.StopAsync(); } - private async Task SaveContainerLogsAsync() + private static async Task SaveContainerLogsAsync(IContainer container, string role) { if (string.IsNullOrEmpty(LogDirectory)) { @@ -132,9 +298,9 @@ private async Task SaveContainerLogsAsync() { Directory.CreateDirectory(LogDirectory); var dotnetVersion = $"net{Environment.Version.Major}.{Environment.Version.Minor}"; - var logFilePath = Path.Combine(LogDirectory, $"iggy-server-{dotnetVersion}-{_containerId}.log"); + var logFilePath = Path.Combine(LogDirectory, $"{role}-{dotnetVersion}-{container.Name}.log"); - var (stdout, stderr) = await IggyContainer!.GetLogsAsync(); + var (stdout, stderr) = await container.GetLogsAsync(); await using var writer = new StreamWriter(logFilePath); if (!string.IsNullOrEmpty(stdout)) @@ -151,91 +317,7 @@ private async Task SaveContainerLogsAsync() } catch (Exception ex) { - Console.WriteLine($"Failed to save container logs: {ex.Message}"); - } - } - - public async Task> CreateClients() - { - var dictionary = new Dictionary(); - dictionary[Protocol.Tcp] = await CreateTcpClient(); - dictionary[Protocol.Http] = await CreateHttpClient(); - - return dictionary; - } - - public async Task CreateAuthenticatedClient(Protocol protocol, string userName = "iggy", - string password = "iggy") - { - return protocol == Protocol.Tcp - ? await CreateTcpClient(userName, password) - : await CreateHttpClient(userName, password); - } - - public async Task CreateTcpClient(string userName = "iggy", string password = "iggy", - bool connect = true, IMessageEncryptor? encryptor = null) - { - var client = await CreateClient(Protocol.Tcp, connect: connect, encryptor: encryptor); - - if (connect) - { - await client.LoginUserAsync(userName, password); + Console.WriteLine($"Failed to save {role} container logs: {ex.Message}"); } - - return client; - } - - public async Task CreateHttpClient(string userName = "iggy", string password = "iggy", - IMessageEncryptor? encryptor = null) - { - var client = await CreateClient(Protocol.Http, encryptor: encryptor); - - await client.LoginUserAsync(userName, password); - - return client; - } - - public async Task CreateClient(Protocol protocol, Protocol? targetContainer = null, - bool connect = true, IMessageEncryptor? encryptor = null) - { - var address = GetIggyAddress(protocol); - - var client = IggyClientFactory.CreateClient(new IggyClientConfigurator - { - BaseAddress = address, - Protocol = protocol, - ReconnectionSettings = new ReconnectionSettings { Enabled = true }, - AutoLoginSettings = new AutoLoginSettings - { - Enabled = true, - Username = "iggy", - Password = "iggy" - }, - MessageEncryptor = encryptor - }); - - if (connect) - { - await client.ConnectAsync(); - } - - return client; - } - - public virtual string GetIggyAddress(Protocol protocol) - { - var port = protocol == Protocol.Tcp - ? IggyContainer!.GetMappedPublicPort(8090) - : IggyContainer!.GetMappedPublicPort(3000); - - return protocol == Protocol.Tcp - ? $"127.0.0.1:{port}" - : $"http://127.0.0.1:{port}"; - } - - public static IEnumerable> ProtocolData() - { - yield return () => Protocol.Http; - yield return () => Protocol.Tcp; } } diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/Fixtures/IggyTlsServerFixture.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/Fixtures/IggyTlsServerFixture.cs index 0d7ff109df..abe8fda53b 100644 --- a/foreign/csharp/Iggy_SDK.Tests.Integration/Fixtures/IggyTlsServerFixture.cs +++ b/foreign/csharp/Iggy_SDK.Tests.Integration/Fixtures/IggyTlsServerFixture.cs @@ -42,9 +42,4 @@ public class IggyTlsServerFixture : IggyServerFixture [ new("Certs", "/app/certs/") ]; - - public override async Task InitializeAsync() - { - await IggyContainer!.StartAsync(); - } } diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/Fixtures/VsrCluster.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/Fixtures/VsrCluster.cs new file mode 100644 index 0000000000..e66ca4140c --- /dev/null +++ b/foreign/csharp/Iggy_SDK.Tests.Integration/Fixtures/VsrCluster.cs @@ -0,0 +1,297 @@ +// 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.Net; +using System.Net.Sockets; +using Docker.DotNet.Models; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; +using DotNet.Testcontainers.Networks; + +namespace Apache.Iggy.Tests.Integrations.Fixtures; + +/// +/// The iggy-server-ng cluster a run scoped to that server uses. A single node commits with a quorum of +/// one, so it would exercise the wire protocol without ever replicating; a real roster puts every test +/// through consensus instead. +/// +internal sealed class VsrCluster : IAsyncDisposable +{ + private const int NodeCount = 3; + private const string ClusterName = "test-vsr-cluster"; + + // Host-port pool, partitioned per .NET major the same way IggyClusterFixture partitions its own, and + // disjoint from it so both fixtures can be live in one process: + // net8.0 - 29800..29899 + // net10.0 - 30000..30099 + // Ten ports per cluster (five transports x two nodes) fit the range with room for a third node. + private const ushort PortRangeSize = 100; + + // Roster peers dial each other by literal IP - the ip field is parsed, not resolved - so the containers + // need addresses known before they start. A per-TFM subnet keeps the two `dotnet test` processes off + // each other's network. + private static readonly string Subnet = $"172.30.{Environment.Version.Major}.0/24"; + private static readonly string Gateway = $"172.30.{Environment.Version.Major}.1"; + private static readonly ushort BasePort = (ushort)(29000 + Environment.Version.Major * 100); + private static readonly ushort EndPort = (ushort)(BasePort + PortRangeSize); + private readonly IContainer[] _containers = new IContainer[NodeCount]; + private readonly ushort[] _httpPorts = new ushort[NodeCount]; + private readonly INetwork _network; + + private readonly List _portReservations = []; + private readonly ushort[] _tcpPorts = new ushort[NodeCount]; + + private static string? LogDirectory => + Environment.GetEnvironmentVariable("IGGY_TEST_LOGS_DIR"); + + /// Replica 0 - primary of the initial view, and the node the tests talk to. + public string LeaderTcpAddress => $"127.0.0.1:{_tcpPorts[0]}"; + + /// The same node's REST surface, which serves classic framing rather than VSR. + public string LeaderHttpAddress => $"http://127.0.0.1:{_httpPorts[0]}"; + + public VsrCluster(string image, string idSuffix, bool traceLogs) + { + var quicPorts = new ushort[NodeCount]; + var websocketPorts = new ushort[NodeCount]; + var replicaPorts = new ushort[NodeCount]; + + try + { + for (var node = 0; node < NodeCount; node++) + { + _tcpPorts[node] = ReservePort(); + _httpPorts[node] = ReservePort(); + quicPorts[node] = ReservePort(); + websocketPorts[node] = ReservePort(); + replicaPorts[node] = ReservePort(); + } + } + finally + { + ReleaseReservedPorts(); + } + + var networkName = $"iggy-vsr-{idSuffix}"; + _network = new NetworkBuilder() + .WithName(networkName) + .WithCreateParameterModifier(parameters => parameters.IPAM = new IPAM + { + Config = + [ + new IPAMConfig + { + Subnet = Subnet, + Gateway = Gateway + } + ] + }) + .Build(); + + var roster = new Dictionary + { + ["IGGY_CLUSTER_ENABLED"] = "true", + ["IGGY_CLUSTER_NAME"] = ClusterName, + ["IGGY_MESSAGE_BUS_RECONNECT_PERIOD"] = "100ms" + }; + + for (var node = 0; node < NodeCount; node++) + { + roster[$"IGGY_CLUSTER_NODES_{node}_NAME"] = $"vsr-node-{node}"; + roster[$"IGGY_CLUSTER_NODES_{node}_IP"] = NodeAddress(node); + roster[$"IGGY_CLUSTER_NODES_{node}_ADVERTISED_ADDRESS"] = "127.0.0.1"; + roster[$"IGGY_CLUSTER_NODES_{node}_REPLICA_ID"] = node.ToString(); + roster[$"IGGY_CLUSTER_NODES_{node}_PORTS_TCP"] = _tcpPorts[node].ToString(); + roster[$"IGGY_CLUSTER_NODES_{node}_PORTS_HTTP"] = _httpPorts[node].ToString(); + roster[$"IGGY_CLUSTER_NODES_{node}_PORTS_QUIC"] = quicPorts[node].ToString(); + roster[$"IGGY_CLUSTER_NODES_{node}_PORTS_WEBSOCKET"] = websocketPorts[node].ToString(); + roster[$"IGGY_CLUSTER_NODES_{node}_PORTS_TCP_REPLICA"] = replicaPorts[node].ToString(); + } + + for (var node = 0; node < NodeCount; node++) + { + var address = NodeAddress(node); + var builder = new ContainerBuilder(image) + .WithName($"iggy-vsr-{node}-{idSuffix}") + .WithCommand("--replica-id", node.ToString()) + .WithNetwork(_network) + .WithNetworkAliases($"vsr-node-{node}") + .WithCreateParameterModifier(parameters => AssignStaticAddress(parameters, networkName, address)) + // Host binding mirrors the container port so the loopback address advertised in cluster + // metadata resolves to the node that advertised it. + .WithPortBinding(_tcpPorts[node].ToString(), _tcpPorts[node].ToString()) + .WithPortBinding(_httpPorts[node].ToString(), _httpPorts[node].ToString()) + .WithEnvironment("IGGY_ROOT_USERNAME", "iggy") + .WithEnvironment("IGGY_ROOT_PASSWORD", "iggy") + .WithEnvironment("IGGY_SYSTEM_TOPIC_MESSAGE_EXPIRY", "10m") + .WithEnvironment("IGGY_SYSTEM_PATH", $"local_data_vsr_{node}") + .WithEnvironment("IGGY_TCP_ADDRESS", $"0.0.0.0:{_tcpPorts[node]}") + .WithEnvironment("IGGY_HTTP_ADDRESS", $"0.0.0.0:{_httpPorts[node]}") + .WithEnvironment("IGGY_QUIC_ADDRESS", $"0.0.0.0:{quicPorts[node]}") + .WithEnvironment("IGGY_WEBSOCKET_ADDRESS", $"0.0.0.0:{websocketPorts[node]}") + .WithEnvironment(roster) + .WithPrivileged(true) + .WithCleanUp(true) + .WithWaitStrategy(Wait.ForUnixContainer() + .UntilInternalTcpPortIsAvailable(_tcpPorts[node]) + .UntilInternalTcpPortIsAvailable(_httpPorts[node])); + + if (traceLogs) + { + builder = builder + .WithEnvironment("IGGY_SYSTEM_LOGGING_LEVEL", "trace") + .WithEnvironment("RUST_LOG", "trace"); + } + + _containers[node] = builder.Build(); + } + } + + public async ValueTask DisposeAsync() + { + for (var node = 0; node < NodeCount; node++) + { + try + { + await SaveContainerLogsAsync(_containers[node], $"iggy-server-ng-{node}"); + } + catch (Exception e) + { + Console.WriteLine($"Failed to save the logs of iggy-server-ng-{node}: {e}"); + } + } + + foreach (var container in _containers) + { + try + { + await container.DisposeAsync(); + } + catch (Exception e) + { + Console.WriteLine($"Failed to dispose an iggy-server-ng container: {e}"); + } + } + + try + { + await _network.DeleteAsync(); + } + catch (Exception e) + { + Console.WriteLine($"Failed to delete the iggy-server-ng network: {e}"); + } + } + + public async Task StartAsync() + { + await _network.CreateAsync(); + // A two-node roster needs both replicas for a quorum, so nothing commits until the pair is up. + await Task.WhenAll(_containers.Select(container => container.StartAsync())); + } + + /// + /// Pins the container's address on the cluster network. Testcontainers has no first-class knob for it, + /// and the roster needs the address before any container starts. + /// + private static void AssignStaticAddress(CreateContainerParameters parameters, string networkName, + string address) + { + parameters.NetworkingConfig ??= new NetworkingConfig(); + parameters.NetworkingConfig.EndpointsConfig ??= new Dictionary(); + + if (!parameters.NetworkingConfig.EndpointsConfig.TryGetValue(networkName, out var endpoint)) + { + endpoint = new EndpointSettings(); + parameters.NetworkingConfig.EndpointsConfig[networkName] = endpoint; + } + + endpoint.IPAMConfig = new EndpointIPAMConfig { IPv4Address = address }; + } + + private static string NodeAddress(int node) + { + return $"172.30.{Environment.Version.Major}.{10 + node}"; + } + + private ushort ReservePort() + { + for (var candidate = BasePort; candidate < EndPort; candidate++) + { + try + { + var listener = new TcpListener(IPAddress.Loopback, candidate); + listener.Start(); + _portReservations.Add(listener); + return candidate; + } + catch (SocketException) + { + // Held by an earlier ReservePort() in this cluster, or by something else on the host. + } + } + + throw new InvalidOperationException( + $"No free ports available in [{BasePort}, {EndPort}) for .NET {Environment.Version.Major}.x."); + } + + private void ReleaseReservedPorts() + { + foreach (var listener in _portReservations) + { + listener.Stop(); + } + + _portReservations.Clear(); + } + + private static async Task SaveContainerLogsAsync(IContainer container, string role) + { + if (string.IsNullOrEmpty(LogDirectory)) + { + return; + } + + try + { + Directory.CreateDirectory(LogDirectory); + var dotnetVersion = $"net{Environment.Version.Major}.{Environment.Version.Minor}"; + // Docker hands back names with a leading slash, which Path.Combine would read as a directory. + var containerName = container.Name.TrimStart('/'); + var logFilePath = Path.Combine(LogDirectory, $"{role}-{dotnetVersion}-{containerName}.log"); + + var (stdout, stderr) = await container.GetLogsAsync(); + + await using var writer = new StreamWriter(logFilePath); + if (!string.IsNullOrEmpty(stdout)) + { + await writer.WriteLineAsync("=== STDOUT ==="); + await writer.WriteLineAsync(stdout); + } + + if (!string.IsNullOrEmpty(stderr)) + { + await writer.WriteLineAsync("=== STDERR ==="); + await writer.WriteLineAsync(stderr); + } + } + catch (Exception ex) + { + Console.WriteLine($"Failed to save {role} container logs: {ex.Message}"); + } + } +} diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/FlushMessagesTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/FlushMessagesTests.cs index 778b707bac..1e4238bb06 100644 --- a/foreign/csharp/Iggy_SDK.Tests.Integration/FlushMessagesTests.cs +++ b/foreign/csharp/Iggy_SDK.Tests.Integration/FlushMessagesTests.cs @@ -19,6 +19,7 @@ using Apache.Iggy.Exceptions; using Apache.Iggy.IggyClient; using Apache.Iggy.Messages; +using Apache.Iggy.Tests.Integrations.Attributes; using Apache.Iggy.Tests.Integrations.Fixtures; using Shouldly; using Partitioning = Apache.Iggy.Kinds.Partitioning; @@ -55,25 +56,25 @@ await client.SendMessagesAsync(Identifier.String(streamName), [Test] [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] + [SkipServerNg("server-ng has no on-demand flush primitive and denies FLUSH_UNSAVED_BUFFER outright")] public async Task FlushUnsavedBuffer_WithFsync_Should_Flush_Successfully(Protocol protocol) { var (client, streamName, topicName) = await CreateStreamWithMessages(protocol); await Should.NotThrowAsync(() => - client.FlushUnsavedBufferAsync( - Identifier.String(streamName), + client.FlushUnsavedBufferAsync(Identifier.String(streamName), Identifier.String(topicName), 0, true)); } [Test] [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] + [SkipServerNg("server-ng has no on-demand flush primitive and denies FLUSH_UNSAVED_BUFFER outright")] public async Task FlushUnsavedBuffer_WithOutFsync_Should_Flush_Successfully(Protocol protocol) { var (client, streamName, topicName) = await CreateStreamWithMessages(protocol); await Should.NotThrowAsync(() => - client.FlushUnsavedBufferAsync( - Identifier.String(streamName), + client.FlushUnsavedBufferAsync(Identifier.String(streamName), Identifier.String(topicName), 0, false)); } @@ -84,8 +85,7 @@ public async Task FlushUnsavedBuffer_Should_Throw_WhenPartition_DoesNotExist(Pro var (client, streamName, topicName) = await CreateStreamWithMessages(protocol); await Should.ThrowAsync(() => - client.FlushUnsavedBufferAsync( - Identifier.String(streamName), + client.FlushUnsavedBufferAsync(Identifier.String(streamName), Identifier.String(topicName), 55, false)); } } diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/HeaderEncryptionIntegrationTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/HeaderEncryptionIntegrationTests.cs index 4004a5929a..9de96652d1 100644 --- a/foreign/csharp/Iggy_SDK.Tests.Integration/HeaderEncryptionIntegrationTests.cs +++ b/foreign/csharp/Iggy_SDK.Tests.Integration/HeaderEncryptionIntegrationTests.cs @@ -45,12 +45,8 @@ public async Task SendMessages_WithEncryptedHeaders_Should_NotBeReadableWithoutD // Publisher on an encrypting client; a plain client raw-polls the same topic to prove the wire bytes // stay encrypted. - var encryptingClient = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient(encryptor: encryptor) - : await Fixture.CreateHttpClient(encryptor: encryptor); - var plainClient = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var encryptingClient = await Fixture.CreateAuthenticatedClient(protocol, encryptor: encryptor); + var plainClient = await Fixture.CreateAuthenticatedClient(protocol); var testStream = await CreateTestStream(plainClient, protocol); var streamId = Identifier.String(testStream.StreamId); @@ -93,7 +89,11 @@ public async Task SendMessages_WithEncryptedHeaders_Should_NotBeReadableWithoutD Dictionary decryptedHeaders = BinaryMapper.MapHeaders(decryptedHeaderBytesResult); decryptedHeaders.Count.ShouldBe(3); - var typeHeader = decryptedHeaders[new HeaderKey { Kind = HeaderKind.String, Value = "type"u8.ToArray() }]; + var typeHeader = decryptedHeaders[new HeaderKey + { + Kind = HeaderKind.String, + Value = "type"u8.ToArray() + }]; Encoding.UTF8.GetString(typeHeader.Value).ShouldBe("test-message"); } @@ -104,9 +104,7 @@ public async Task ReceiveAsync_WithEncryptingClient_Should_DecryptHeadersCorrect var encryptor = CreateEncryptor(); // One encrypting client serves both publisher and consumer: it encrypts on send and decrypts on poll. - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient(encryptor: encryptor) - : await Fixture.CreateHttpClient(encryptor: encryptor); + var client = await Fixture.CreateAuthenticatedClient(protocol, encryptor: encryptor); var testStream = await CreateTestStream(client, protocol); var streamId = Identifier.String(testStream.StreamId); @@ -162,13 +160,25 @@ public async Task ReceiveAsync_WithEncryptingClient_Should_DecryptHeadersCorrect received.Message.UserHeaders.ShouldNotBeNull(); received.Message.UserHeaders!.Count.ShouldBe(3); - var batchHeader = received.Message.UserHeaders[new HeaderKey { Kind = HeaderKind.String, Value = "batch"u8.ToArray() }]; + var batchHeader = received.Message.UserHeaders[new HeaderKey + { + Kind = HeaderKind.String, + Value = "batch"u8.ToArray() + }]; BitConverter.ToUInt64(batchHeader.Value).ShouldBe(1UL); - var typeHeader = received.Message.UserHeaders[new HeaderKey { Kind = HeaderKind.String, Value = "type"u8.ToArray() }]; + var typeHeader = received.Message.UserHeaders[new HeaderKey + { + Kind = HeaderKind.String, + Value = "type"u8.ToArray() + }]; Encoding.UTF8.GetString(typeHeader.Value).ShouldBe("test-message"); - var encHeader = received.Message.UserHeaders[new HeaderKey { Kind = HeaderKind.String, Value = "encrypted"u8.ToArray() }]; + var encHeader = received.Message.UserHeaders[new HeaderKey + { + Kind = HeaderKind.String, + Value = "encrypted"u8.ToArray() + }]; encHeader.Value[0].ShouldBe((byte)1); } @@ -182,17 +192,41 @@ private static Dictionary CreateTestHeaders() return new Dictionary { { - new HeaderKey { Kind = HeaderKind.String, Value = "batch"u8.ToArray() }, - new HeaderValue { Kind = HeaderKind.Uint64, Value = BitConverter.GetBytes(1UL) } + new HeaderKey + { + Kind = HeaderKind.String, + Value = "batch"u8.ToArray() + }, + new HeaderValue + { + Kind = HeaderKind.Uint64, + Value = BitConverter.GetBytes(1UL) + } }, { - new HeaderKey { Kind = HeaderKind.String, Value = "type"u8.ToArray() }, - new HeaderValue { Kind = HeaderKind.String, Value = "test-message"u8.ToArray() } + new HeaderKey + { + Kind = HeaderKind.String, + Value = "type"u8.ToArray() + }, + new HeaderValue + { + Kind = HeaderKind.String, + Value = "test-message"u8.ToArray() + } }, { - new HeaderKey { Kind = HeaderKind.String, Value = "encrypted"u8.ToArray() }, - new HeaderValue { Kind = HeaderKind.Bool, Value = [1] } - }, + new HeaderKey + { + Kind = HeaderKind.String, + Value = "encrypted"u8.ToArray() + }, + new HeaderValue + { + Kind = HeaderKind.Bool, + Value = [1] + } + } }; } diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/Helpers/Eventually.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/Helpers/Eventually.cs new file mode 100644 index 0000000000..ba5ce825d9 --- /dev/null +++ b/foreign/csharp/Iggy_SDK.Tests.Integration/Helpers/Eventually.cs @@ -0,0 +1,43 @@ +// 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.Tests.Integrations.Helpers; + +public static class Eventually +{ + private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(100); + + /// + /// Polls until the read satisfies the condition. Some server work commits ahead of the state it changes - + /// server-ng applies a purge by advancing a generation that its reconciler acts on a tick later - so the + /// first read after an acknowledged command can still show the old value. + /// + public static async Task ReadAsync(Func> read, Func condition, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (true) + { + var value = await read(); + if (condition(value) || DateTime.UtcNow >= deadline) + { + return value; + } + + await Task.Delay(PollInterval); + } + } +} diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/IggyConsumerTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/IggyConsumerTests.cs index 72f7987b8d..b79530cfca 100644 --- a/foreign/csharp/Iggy_SDK.Tests.Integration/IggyConsumerTests.cs +++ b/foreign/csharp/Iggy_SDK.Tests.Integration/IggyConsumerTests.cs @@ -38,9 +38,7 @@ public class IggyConsumerTests [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task InitAsync_WithSingleConsumer_Should_Initialize_Successfully(Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var testStream = await CreateTestStreamWithMessages(client, protocol); @@ -63,9 +61,7 @@ public async Task InitAsync_WithSingleConsumer_Should_Initialize_Successfully(Pr [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task InitAsync_WithConsumerGroup_Should_Initialize_Successfully(Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var testStream = await CreateTestStreamWithMessages(client, protocol); @@ -88,19 +84,18 @@ public async Task InitAsync_WithConsumerGroup_Should_Initialize_Successfully(Pro [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task InitAsync_NewClient_Should_Initialize_Successfully(Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var testStream = await CreateTestStreamWithMessages(client, protocol); - var clientAddress = Fixture.GetIggyAddress(protocol); ; + var clientAddress = await Fixture.GetIggyAddressAsync(protocol); var consumer = IggyConsumerBuilder .Create(Identifier.String(testStream.StreamId), Identifier.String(testStream.TopicId), Consumer.New(2)) .WithConnection(protocol, clientAddress, "iggy", "iggy") + .WithWireProtocol(IggyServerFixture.WireProtocolFor(protocol)) .WithPollingStrategy(PollingStrategy.Next()) .WithBatchSize(10) .WithConsumerGroup("test-group-init") @@ -114,9 +109,7 @@ public async Task InitAsync_NewClient_Should_Initialize_Successfully(Protocol pr [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task InitAsync_CalledTwice_Should_NotThrow(Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var testStream = await CreateTestStreamWithMessages(client, protocol); @@ -139,9 +132,7 @@ public async Task InitAsync_CalledTwice_Should_NotThrow(Protocol protocol) [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task ReceiveAsync_WithoutInit_Should_Throw_ConsumerNotInitializedException(Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var testStream = await CreateTestStreamWithMessages(client, protocol); @@ -169,9 +160,7 @@ await Should.ThrowAsync(async () => [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task InitAsync_WithConsumerGroup_Should_CreateGroup_WhenNotExists(Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var testStream = await CreateTestStreamWithMessages(client, protocol); @@ -204,9 +193,7 @@ public async Task InitAsync_WithConsumerGroup_Should_CreateGroup_WhenNotExists(P public async Task InitAsync_WithConsumerGroup_Should_Throw_WhenGroupNotExists_AndAutoCreateDisabled( Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var testStream = await CreateTestStreamWithMessages(client, protocol); @@ -230,9 +217,7 @@ public async Task InitAsync_WithConsumerGroup_Should_Throw_WhenGroupNotExists_An [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task InitAsync_WithConsumerGroup_Should_JoinGroup_Successfully(Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var testStream = await CreateTestStreamWithMessages(client, protocol); @@ -261,9 +246,7 @@ await client.CreateConsumerGroupAsync(Identifier.String(testStream.StreamId), [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task DisposeAsync_Should_LeaveConsumerGroup(Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var testStream = await CreateTestStreamWithMessages(client, protocol); @@ -294,9 +277,7 @@ public async Task DisposeAsync_Should_LeaveConsumerGroup(Protocol protocol) [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task ReceiveAsync_WithSingleConsumer_Should_ReceiveMessages_Successfully(Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var testStream = await CreateTestStreamWithMessages(client, protocol); @@ -339,9 +320,7 @@ public async Task ReceiveAsync_WithSingleConsumer_Should_ReceiveMessages_Success [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task ReceiveAsync_WithBatchSize_Should_RespectBatchSize(Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var testStream = await CreateTestStreamWithMessages(client, protocol); @@ -380,9 +359,7 @@ public async Task ReceiveAsync_WithBatchSize_Should_RespectBatchSize(Protocol pr [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task ReceiveAsync_WithPollingInterval_Should_RespectInterval(Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var testStream = await CreateTestStreamWithMessages(client, protocol); @@ -422,9 +399,7 @@ public async Task ReceiveAsync_WithPollingInterval_Should_RespectInterval(Protoc [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task ReceiveAsync_WithAutoCommitAfterReceive_Should_StoreOffset(Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var testStream = await CreateTestStreamWithMessages(client, protocol); @@ -472,9 +447,7 @@ public async Task ReceiveAsync_WithAutoCommitAfterReceive_Should_StoreOffset(Pro [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task ReceiveAsync_WithAutoCommitAfterPoll_Should_StoreOffset(Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var testStream = await CreateTestStreamWithMessages(client, protocol); @@ -521,9 +494,7 @@ public async Task ReceiveAsync_WithAutoCommitAfterPoll_Should_StoreOffset(Protoc [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task StoreOffsetAsync_Should_StoreOffset_Successfully(Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var testStream = await CreateTestStreamWithMessages(client, protocol); @@ -560,9 +531,7 @@ public async Task StoreOffsetAsync_Should_StoreOffset_Successfully(Protocol prot [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task DeleteOffsetAsync_Should_DeleteOffset_Successfully(Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var testStream = await CreateTestStreamWithMessages(client, protocol); @@ -605,9 +574,7 @@ public async Task DeleteOffsetAsync_Should_DeleteOffset_Successfully(Protocol pr [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task DisposeAsync_Should_NotThrow(Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var testStream = await CreateTestStreamWithMessages(client, protocol); @@ -629,9 +596,7 @@ public async Task DisposeAsync_Should_NotThrow(Protocol protocol) [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task DisposeAsync_CalledTwice_Should_NotThrow(Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var testStream = await CreateTestStreamWithMessages(client, protocol); @@ -654,9 +619,7 @@ public async Task DisposeAsync_CalledTwice_Should_NotThrow(Protocol protocol) [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task DisposeAsync_WithoutInit_Should_NotThrow(Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var testStream = await CreateTestStreamWithMessages(client, protocol); @@ -677,9 +640,7 @@ public async Task DisposeAsync_WithoutInit_Should_NotThrow(Protocol protocol) [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task OnPollingError_Should_Fire_WhenPollingFails(Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var testStream = await CreateTestStreamWithMessages(client, protocol); @@ -728,9 +689,7 @@ public async Task OnPollingError_Should_Fire_WhenPollingFails(Protocol protocol) [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task ReceiveAsync_WithOffsetStrategy_Should_StartFromOffset(Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var testStream = await CreateTestStreamWithMessages(client, protocol); @@ -767,9 +726,7 @@ public async Task ReceiveAsync_WithOffsetStrategy_Should_StartFromOffset(Protoco [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task ReceiveAsync_WithFirstStrategy_Should_StartFromBeginning(Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var testStream = await CreateTestStreamWithMessages(client, protocol); @@ -801,9 +758,7 @@ public async Task ReceiveAsync_WithFirstStrategy_Should_StartFromBeginning(Proto [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task ReceiveAsync_WithLastStrategy_Should_StartFromEnd(Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var testStream = await CreateTestStreamWithMessages(client, protocol); diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/IggyPublisherTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/IggyPublisherTests.cs index c1028ec0da..4b1bfd44b4 100644 --- a/foreign/csharp/Iggy_SDK.Tests.Integration/IggyPublisherTests.cs +++ b/foreign/csharp/Iggy_SDK.Tests.Integration/IggyPublisherTests.cs @@ -35,7 +35,8 @@ public class IggyPublisherTests [ClassDataSource(Shared = SharedType.PerAssembly)] public required IggyServerFixture Fixture { get; init; } - private async Task CreateTestStream(IIggyClient client, Protocol protocol, uint partitionsCount = 5) + private async Task CreateTestStream(IIggyClient client, Protocol protocol, + uint partitionsCount = 5) { var streamId = $"stream_{Guid.NewGuid()}_{protocol.ToString().ToLowerInvariant()}"; var topicId = "test_topic"; @@ -50,9 +51,7 @@ private async Task CreateTestStream(IIggyClient client, Protocol [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task InitAsync_Should_Initialize_Successfully(Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var testStream = await CreateTestStream(client, protocol); @@ -70,7 +69,7 @@ public async Task InitAsync_Should_Initialize_Successfully(Protocol protocol) [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task InitAsync_NewClient_Should_Initialize_Successfully(Protocol protocol) { - var client = Fixture.GetIggyAddress(protocol); + var client = await Fixture.GetIggyAddressAsync(protocol); var stream = Guid.NewGuid().ToString(); var topic = Guid.NewGuid().ToString(); @@ -80,6 +79,7 @@ public async Task InitAsync_NewClient_Should_Initialize_Successfully(Protocol pr .CreateStreamIfNotExists(stream) .CreateTopicIfNotExists(topic) .WithConnection(protocol, client, "iggy", "iggy") + .WithWireProtocol(IggyServerFixture.WireProtocolFor(protocol)) .WithPartitioning(Partitioning.PartitionId(1)) .Build(); @@ -91,9 +91,7 @@ public async Task InitAsync_NewClient_Should_Initialize_Successfully(Protocol pr [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task InitAsync_CalledTwice_Should_NotThrow(Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var testStream = await CreateTestStream(client, protocol); @@ -111,9 +109,7 @@ public async Task InitAsync_CalledTwice_Should_NotThrow(Protocol protocol) [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task SendMessages_WithoutInit_Should_Throw_PublisherNotInitializedException(Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var testStream = await CreateTestStream(client, protocol); @@ -132,9 +128,7 @@ public async Task SendMessages_WithoutInit_Should_Throw_PublisherNotInitializedE [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task SendMessages_Should_SendMessages_Successfully(Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var testStream = await CreateTestStream(client, protocol); @@ -170,9 +164,7 @@ public async Task SendMessages_Should_SendMessages_Successfully(Protocol protoco [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task SendMessages_WithEmptyList_Should_NotThrow(Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var testStream = await CreateTestStream(client, protocol); @@ -192,9 +184,7 @@ public async Task SendMessages_WithEmptyList_Should_NotThrow(Protocol protocol) [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task InitAsync_WithStreamAutoCreate_Should_CreateStream(Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var streamId = $"auto_stream_{Guid.NewGuid()}_{protocol.ToString().ToLowerInvariant()}"; var topicId = "auto_topic"; @@ -221,9 +211,7 @@ public async Task InitAsync_WithStreamAutoCreate_Should_CreateStream(Protocol pr [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task InitAsync_WithTopicAutoCreate_Should_CreateTopic(Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var streamId = $"stream_{Guid.NewGuid()}_{protocol.ToString().ToLowerInvariant()}"; var topicId = "auto_topic"; @@ -252,9 +240,7 @@ public async Task InitAsync_WithTopicAutoCreate_Should_CreateTopic(Protocol prot [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task InitAsync_WithoutAutoCreate_Should_Throw_WhenStreamNotExists(Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var streamId = $"nonexistent_stream_{Guid.NewGuid()}"; var topicId = "test_topic"; @@ -272,9 +258,7 @@ public async Task InitAsync_WithoutAutoCreate_Should_Throw_WhenStreamNotExists(P [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task InitAsync_WithoutAutoCreate_Should_Throw_WhenTopicNotExists(Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var streamId = $"stream_{Guid.NewGuid()}_{protocol.ToString().ToLowerInvariant()}"; var topicId = "nonexistent_topic"; @@ -295,9 +279,7 @@ public async Task InitAsync_WithoutAutoCreate_Should_Throw_WhenTopicNotExists(Pr [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task SendMessages_WithBackgroundSending_Should_SendMessages_Successfully(Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var testStream = await CreateTestStream(client, protocol); @@ -335,9 +317,7 @@ public async Task SendMessages_WithBackgroundSending_Should_SendMessages_Success [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task WaitUntilAllSends_Should_WaitForPendingMessages(Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var testStream = await CreateTestStream(client, protocol); @@ -372,9 +352,7 @@ public async Task WaitUntilAllSends_Should_WaitForPendingMessages(Protocol proto [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task WaitUntilAllSends_WithoutBackgroundSending_Should_ReturnImmediately(Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var testStream = await CreateTestStream(client, protocol); @@ -395,9 +373,7 @@ public async Task WaitUntilAllSends_WithoutBackgroundSending_Should_ReturnImmedi [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task SendMessages_ToMultiplePartitions_Should_DistributeMessages(Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var testStream = await CreateTestStream(client, protocol); @@ -442,9 +418,7 @@ public async Task SendMessages_ToMultiplePartitions_Should_DistributeMessages(Pr [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task SendMessages_WithBalancedPartitioning_Should_DistributeAcrossPartitions(Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var testStream = await CreateTestStream(client, protocol, 3); @@ -486,9 +460,7 @@ public async Task SendMessages_WithBalancedPartitioning_Should_DistributeAcrossP [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task DisposeAsync_Should_NotThrow(Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var testStream = await CreateTestStream(client, protocol); @@ -505,9 +477,7 @@ public async Task DisposeAsync_Should_NotThrow(Protocol protocol) [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task DisposeAsync_CalledTwice_Should_NotThrow(Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var testStream = await CreateTestStream(client, protocol); @@ -525,9 +495,7 @@ public async Task DisposeAsync_CalledTwice_Should_NotThrow(Protocol protocol) [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task DisposeAsync_WithoutInit_Should_NotThrow(Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var testStream = await CreateTestStream(client, protocol); @@ -544,9 +512,7 @@ public async Task DisposeAsync_WithoutInit_Should_NotThrow(Protocol protocol) [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task StreamId_Should_ReturnConfiguredStreamId(Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var testStream = await CreateTestStream(client, protocol); @@ -563,9 +529,7 @@ public async Task StreamId_Should_ReturnConfiguredStreamId(Protocol protocol) [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task TopicId_Should_ReturnConfiguredTopicId(Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var testStream = await CreateTestStream(client, protocol); @@ -582,9 +546,7 @@ public async Task TopicId_Should_ReturnConfiguredTopicId(Protocol protocol) [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task SendMessages_LargeMessageCount_Should_HandleCorrectly(Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var testStream = await CreateTestStream(client, protocol); @@ -622,9 +584,7 @@ public async Task SendMessages_LargeMessageCount_Should_HandleCorrectly(Protocol [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task SendAsync_RentedBatch_WithBackgroundSending_Should_RoundTrip(Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var testStream = await CreateTestStream(client, protocol); @@ -675,9 +635,7 @@ public async Task SendAsync_RentedBatch_WithBackgroundSending_Should_RoundTrip(P [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task SendAsync_SingleMessageRentedBatch_WithBackgroundSending_Should_RoundTrip(Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var testStream = await CreateTestStream(client, protocol); diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/IggyTlsConnectionTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/IggyTlsConnectionTests.cs index 4b4200a746..e2826d1485 100644 --- a/foreign/csharp/Iggy_SDK.Tests.Integration/IggyTlsConnectionTests.cs +++ b/foreign/csharp/Iggy_SDK.Tests.Integration/IggyTlsConnectionTests.cs @@ -19,11 +19,13 @@ using Apache.Iggy.Enums; using Apache.Iggy.Exceptions; using Apache.Iggy.Factory; +using Apache.Iggy.Tests.Integrations.Attributes; using Apache.Iggy.Tests.Integrations.Fixtures; using Shouldly; namespace Apache.Iggy.Tests.Integrations; +[RequiresClassicServer] public class IggyTlsConnectionTests { [ClassDataSource(Shared = SharedType.PerAssembly)] @@ -34,7 +36,7 @@ public async Task Connect_WithTls_Should_Connect_Successfully() { using var client = IggyClientFactory.CreateClient(new IggyClientConfigurator { - BaseAddress = Fixture.GetIggyAddress(Protocol.Tcp), + BaseAddress = await Fixture.GetIggyAddressAsync(Protocol.Tcp), Protocol = Protocol.Tcp, ReconnectionSettings = new ReconnectionSettings { Enabled = false }, AutoLoginSettings = new AutoLoginSettings @@ -62,7 +64,7 @@ public async Task Connect_WithoutTls_Should_Throw_WhenTlsIsRequired() { using var client = IggyClientFactory.CreateClient(new IggyClientConfigurator { - BaseAddress = Fixture.GetIggyAddress(Protocol.Tcp), + BaseAddress = await Fixture.GetIggyAddressAsync(Protocol.Tcp), Protocol = Protocol.Tcp, ReconnectionSettings = new ReconnectionSettings { Enabled = false } }); @@ -76,7 +78,7 @@ public async Task Connect_WithTls_CA_Should_Connect_Successfully() { using var client = IggyClientFactory.CreateClient(new IggyClientConfigurator { - BaseAddress = Fixture.GetIggyAddress(Protocol.Tcp), + BaseAddress = await Fixture.GetIggyAddressAsync(Protocol.Tcp), Protocol = Protocol.Tcp, ReconnectionSettings = new ReconnectionSettings { Enabled = false }, AutoLoginSettings = new AutoLoginSettings diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/IggyTypedConsumerTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/IggyTypedConsumerTests.cs index 3861ae812f..460b1c8cef 100644 --- a/foreign/csharp/Iggy_SDK.Tests.Integration/IggyTypedConsumerTests.cs +++ b/foreign/csharp/Iggy_SDK.Tests.Integration/IggyTypedConsumerTests.cs @@ -38,9 +38,7 @@ public class IggyTypedConsumerTests [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task ReceiveDeserializedAsync_Should_YieldMessages_WithCorrectData(Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var testStream = await CreateTestStreamWithMessages(client, protocol); @@ -77,9 +75,7 @@ public async Task ReceiveDeserializedAsync_Should_YieldMessages_WithCorrectData( public async Task ReceiveDeserializedAsync_WithoutInit_Should_Throw_ConsumerNotInitializedException( Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var testStream = await CreateTestStreamWithMessages(client, protocol); @@ -101,9 +97,7 @@ await Should.ThrowAsync(async () => [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task ReceiveDeserializedAsync_WithAutoCommitAfterReceive_Should_StoreOffset(Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var testStream = await CreateTestStreamWithMessages(client, protocol); @@ -142,9 +136,7 @@ public async Task ReceiveDeserializedAsync_WithAutoCommitAfterReceive_Should_Sto public async Task ReceiveDeserializedAsync_WithFailingDeserializer_Should_YieldDeserializationFailed( Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var testStream = await CreateTestStreamWithMessages(client, protocol); @@ -172,9 +164,7 @@ public async Task ReceiveDeserializedAsync_WithFailingDeserializer_Should_YieldD [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task ReceiveDeserializedAsync_Should_StopCleanly_OnCancellation(Protocol protocol) { - var client = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var testStream = await CreateTestStreamWithMessages(client, protocol); diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/IggyTypedPublisherTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/IggyTypedPublisherTests.cs index 64d32e40b4..84c5d50816 100644 --- a/foreign/csharp/Iggy_SDK.Tests.Integration/IggyTypedPublisherTests.cs +++ b/foreign/csharp/Iggy_SDK.Tests.Integration/IggyTypedPublisherTests.cs @@ -249,9 +249,7 @@ public async Task SendAsync_WithEncryptor_Should_RoundTrip_Decrypted(Protocol pr // Encryption is configured on the client. The publisher uses an encrypting client; a plain client polls // to prove the wire bytes are ciphertext, then decrypts manually. - var encryptingClient = protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient(encryptor: encryptor) - : await Fixture.CreateHttpClient(encryptor: encryptor); + var encryptingClient = await Fixture.CreateAuthenticatedClient(protocol, encryptor: encryptor); var plainClient = await Client(protocol); var stream = await CreateTestStream(plainClient, protocol); @@ -292,9 +290,7 @@ IggyPublisher publisher private async Task Client(Protocol protocol) { - return protocol == Protocol.Tcp - ? await Fixture.CreateTcpClient() - : await Fixture.CreateHttpClient(); + return await Fixture.CreateAuthenticatedClient(protocol); } // Base fluent methods return the non-generic builder, so apply them as statements to keep the typed Build(). diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/MessageEncryptionIntegrationTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/MessageEncryptionIntegrationTests.cs index 8e29ec1ba1..96426ee55f 100644 --- a/foreign/csharp/Iggy_SDK.Tests.Integration/MessageEncryptionIntegrationTests.cs +++ b/foreign/csharp/Iggy_SDK.Tests.Integration/MessageEncryptionIntegrationTests.cs @@ -195,9 +195,7 @@ private async Task SendBatch(IIggyClient client, Identifier streamId, Identifier private Task CreateClient(Protocol protocol, IMessageEncryptor encryptor) { - return protocol == Protocol.Tcp - ? Fixture.CreateTcpClient(encryptor: encryptor) - : Fixture.CreateHttpClient(encryptor: encryptor); + return Fixture.CreateAuthenticatedClient(protocol, encryptor: encryptor); } private static AesMessageEncryptor CreateEncryptor() diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/OffsetTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/OffsetTests.cs index 82cc193d7b..f96ced773f 100644 --- a/foreign/csharp/Iggy_SDK.Tests.Integration/OffsetTests.cs +++ b/foreign/csharp/Iggy_SDK.Tests.Integration/OffsetTests.cs @@ -98,15 +98,13 @@ await client.CreateConsumerGroupAsync(Identifier.String(streamName), // For HTTP, a separate TCP client joins (HTTP is stateless and doesn't track membership). if (protocol == Protocol.Tcp) { - await client.JoinConsumerGroupAsync( - Identifier.String(streamName), + await client.JoinConsumerGroupAsync(Identifier.String(streamName), Identifier.String(topicName), Identifier.String("test_consumer_group")); } else { - var tcpClient = await Fixture.CreateTcpClient(); - await tcpClient.JoinConsumerGroupAsync( - Identifier.String(streamName), + var tcpClient = await Fixture.CreateAuthenticatedClient(Protocol.Tcp); + await tcpClient.JoinConsumerGroupAsync(Identifier.String(streamName), Identifier.String(topicName), Identifier.String("test_consumer_group")); } @@ -126,15 +124,13 @@ await client.CreateConsumerGroupAsync(Identifier.String(streamName), if (protocol == Protocol.Tcp) { - await client.JoinConsumerGroupAsync( - Identifier.String(streamName), + await client.JoinConsumerGroupAsync(Identifier.String(streamName), Identifier.String(topicName), Identifier.String("test_consumer_group")); } else { - var tcpClient = await Fixture.CreateTcpClient(); - await tcpClient.JoinConsumerGroupAsync( - Identifier.String(streamName), + var tcpClient = await Fixture.CreateAuthenticatedClient(Protocol.Tcp); + await tcpClient.JoinConsumerGroupAsync(Identifier.String(streamName), Identifier.String(topicName), Identifier.String("test_consumer_group")); } @@ -161,15 +157,13 @@ await client.CreateConsumerGroupAsync(Identifier.String(streamName), if (protocol == Protocol.Tcp) { - await client.JoinConsumerGroupAsync( - Identifier.String(streamName), + await client.JoinConsumerGroupAsync(Identifier.String(streamName), Identifier.String(topicName), Identifier.String("test_consumer_group")); } else { - var tcpClient = await Fixture.CreateTcpClient(); - await tcpClient.JoinConsumerGroupAsync( - Identifier.String(streamName), + var tcpClient = await Fixture.CreateAuthenticatedClient(Protocol.Tcp); + await tcpClient.JoinConsumerGroupAsync(Identifier.String(streamName), Identifier.String(topicName), Identifier.String("test_consumer_group")); } @@ -195,8 +189,7 @@ public async Task DeleteOffset_ConsumerGroup_Should_DeleteOffset_Successfully(Pr await client.CreateConsumerGroupAsync(Identifier.String(streamName), Identifier.String(topicName), "test_consumer_group"); - await client.JoinConsumerGroupAsync( - Identifier.String(streamName), + await client.JoinConsumerGroupAsync(Identifier.String(streamName), Identifier.String(topicName), Identifier.String("test_consumer_group")); await client.StoreOffsetAsync(Consumer.Group("test_consumer_group"), Identifier.String(streamName), diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/PartitionsTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/PartitionsTests.cs index 406b1d3ade..64e281ac48 100644 --- a/foreign/csharp/Iggy_SDK.Tests.Integration/PartitionsTests.cs +++ b/foreign/csharp/Iggy_SDK.Tests.Integration/PartitionsTests.cs @@ -43,8 +43,7 @@ await Should.NotThrowAsync(() => client.CreatePartitionsAsync(Identifier.String(streamName), Identifier.String(topicName), 3)); - var response = await client.GetTopicByIdAsync( - Identifier.String(streamName), Identifier.String(topicName)); + var response = await client.GetTopicByIdAsync(Identifier.String(streamName), Identifier.String(topicName)); response.ShouldNotBeNull(); response.PartitionsCount.ShouldBe(4u); } @@ -65,8 +64,7 @@ await Should.NotThrowAsync(() => client.DeletePartitionsAsync(Identifier.String(streamName), Identifier.String(topicName), 1)); - var response = await client.GetTopicByIdAsync( - Identifier.String(streamName), Identifier.String(topicName)); + var response = await client.GetTopicByIdAsync(Identifier.String(streamName), Identifier.String(topicName)); response.ShouldNotBeNull(); response.PartitionsCount.ShouldBe(3u); } diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/PersonalAccessTokenTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/PersonalAccessTokenTests.cs index 2e14ff637a..b8ffc39b91 100644 --- a/foreign/csharp/Iggy_SDK.Tests.Integration/PersonalAccessTokenTests.cs +++ b/foreign/csharp/Iggy_SDK.Tests.Integration/PersonalAccessTokenTests.cs @@ -87,7 +87,7 @@ public async Task LoginWithPersonalAccessToken_Should_Be_Successfully(Protocol p var name = $"lgn-{Guid.NewGuid():N}"[..20]; var response = await client.CreatePersonalAccessTokenAsync(name, Expiry); - var loginClient = await Fixture.CreateClient(protocol); + var loginClient = await Fixture.CreateClient(protocol, true); var authResponse = await loginClient.LoginWithPersonalAccessTokenAsync(response!.Token); authResponse.ShouldNotBeNull(); diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/RawCommandTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/RawCommandTests.cs index 49d156fe42..1cbfcd059c 100644 --- a/foreign/csharp/Iggy_SDK.Tests.Integration/RawCommandTests.cs +++ b/foreign/csharp/Iggy_SDK.Tests.Integration/RawCommandTests.cs @@ -51,8 +51,9 @@ public async Task SendBinaryRequest_Tcp_ShouldRejectSessionControlCodes(Protocol foreach (var code in new uint[] { 38, 39, 40, 44, 45 }) { - var exception = await Should.ThrowAsync( - () => client.SendBinaryRequestAsync(code, [])); + var exception + = await Should.ThrowAsync(() => + client.SendBinaryRequestAsync(code, [])); exception.StatusCode.ShouldBe(3); } } @@ -64,8 +65,8 @@ public async Task SendBinaryRequest_Tcp_ShouldPropagateServerError(Protocol prot { var client = await Fixture.CreateAuthenticatedClient(protocol); - var exception = await Should.ThrowAsync( - () => client.SendBinaryRequestAsync(60_000, [])); + var exception + = await Should.ThrowAsync(() => client.SendBinaryRequestAsync(60_000, [])); exception.StatusCode.ShouldBe(3); } @@ -77,7 +78,6 @@ public async Task SendBinaryRequest_Http_ShouldThrowFeatureUnavailable(Protocol { var client = await Fixture.CreateAuthenticatedClient(protocol); - await Should.ThrowAsync( - () => client.SendBinaryRequestAsync(1, [])); + await Should.ThrowAsync(() => client.SendBinaryRequestAsync(1, [])); } } diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/SegmentsTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/SegmentsTests.cs index eed1d01b0a..64a80a3905 100644 --- a/foreign/csharp/Iggy_SDK.Tests.Integration/SegmentsTests.cs +++ b/foreign/csharp/Iggy_SDK.Tests.Integration/SegmentsTests.cs @@ -42,8 +42,7 @@ public async Task DeleteSegments_WithZeroCount_Should_Succeed(Protocol protocol) // Deleting 0 segments should succeed without error (no-op) await Should.NotThrowAsync(() => - client.DeleteSegmentsAsync( - Identifier.String(streamName), + client.DeleteSegmentsAsync(Identifier.String(streamName), Identifier.String(topicName), 0, // partition_id (0-indexed) 0)); // segments_count = 0 @@ -62,8 +61,7 @@ public async Task DeleteSegments_Http_Should_Throw_FeatureUnavailable(Protocol p await client.CreateTopicAsync(Identifier.String(streamName), topicName, 1); await Should.ThrowAsync(() => - client.DeleteSegmentsAsync( - Identifier.String(streamName), + client.DeleteSegmentsAsync(Identifier.String(streamName), Identifier.String(topicName), 0, 0)); @@ -80,8 +78,7 @@ public async Task DeleteSegments_Should_Throw_WhenTopic_DoesNotExist(Protocol pr await client.CreateStreamAsync(streamName); await Should.ThrowAsync(() => - client.DeleteSegmentsAsync( - Identifier.String(streamName), + client.DeleteSegmentsAsync(Identifier.String(streamName), Identifier.String("non-existent-topic"), 0, // partition_id (0-indexed) 1)); // segments_count @@ -95,8 +92,7 @@ public async Task DeleteSegments_Should_Throw_WhenStream_DoesNotExist(Protocol p var client = await Fixture.CreateAuthenticatedClient(protocol); await Should.ThrowAsync(() => - client.DeleteSegmentsAsync( - Identifier.String($"nonexistent-stream-{Guid.NewGuid():N}"), + client.DeleteSegmentsAsync(Identifier.String($"nonexistent-stream-{Guid.NewGuid():N}"), Identifier.String("any-topic"), 0, // partition_id (0-indexed) 1)); // segments_count diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/StreamsTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/StreamsTests.cs index aa47d00c8b..5a990e7166 100644 --- a/foreign/csharp/Iggy_SDK.Tests.Integration/StreamsTests.cs +++ b/foreign/csharp/Iggy_SDK.Tests.Integration/StreamsTests.cs @@ -20,6 +20,7 @@ using Apache.Iggy.Exceptions; using Apache.Iggy.Messages; using Apache.Iggy.Tests.Integrations.Fixtures; +using Apache.Iggy.Tests.Integrations.Helpers; using Shouldly; using Partitioning = Apache.Iggy.Kinds.Partitioning; @@ -216,7 +217,9 @@ await client.SendMessagesAsync(Identifier.String(streamName), await Should.NotThrowAsync(() => client.PurgeStreamAsync(Identifier.String(streamName))); - stream = await client.GetStreamByIdAsync(Identifier.String(streamName)); + // server-ng commits the purge by advancing a generation its reconciler acts on a tick later. + stream = await Eventually.ReadAsync(() => client.GetStreamByIdAsync(Identifier.String(streamName)), + purged => purged?.MessagesCount == 0, TimeSpan.FromSeconds(10)); stream.ShouldNotBeNull(); stream.MessagesCount.ShouldBe(0u); stream.TopicsCount.ShouldBe(1); diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/SystemTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/SystemTests.cs index 3d803b4cc8..32f034cedd 100644 --- a/foreign/csharp/Iggy_SDK.Tests.Integration/SystemTests.cs +++ b/foreign/csharp/Iggy_SDK.Tests.Integration/SystemTests.cs @@ -55,8 +55,7 @@ public async Task GetClient_Should_Return_CorrectClient(Protocol protocol) { var client = await Fixture.CreateAuthenticatedClient(protocol); - var tcpClient = await Fixture.CreateClient(Protocol.Tcp); - await tcpClient.LoginUserAsync("iggy", "iggy"); + var tcpClient = await Fixture.CreateAuthenticatedClient(Protocol.Tcp); var clientInfo = await tcpClient.GetMeAsync(); clientInfo.ShouldNotBeNull(); @@ -76,7 +75,7 @@ public async Task GetClient_Should_Return_CorrectClient(Protocol protocol) [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task GetMe_Tcp_Should_Return_MyClient(Protocol protocol) { - var client = await Fixture.CreateTcpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); var me = await client.GetMeAsync(); me.ShouldNotBeNull(); @@ -91,7 +90,7 @@ public async Task GetMe_Tcp_Should_Return_MyClient(Protocol protocol) [MethodDataSource(nameof(IggyServerFixture.ProtocolData))] public async Task GetMe_HTTP_Should_Throw_FeatureUnavailableException(Protocol protocol) { - var client = await Fixture.CreateHttpClient(); + var client = await Fixture.CreateAuthenticatedClient(protocol); await Should.ThrowAsync(() => client.GetMeAsync()); } @@ -103,8 +102,7 @@ public async Task GetClient_WithConsumerGroup_Should_Return_CorrectClient(Protoc var client = await Fixture.CreateAuthenticatedClient(protocol); var streamName = $"sys-cg-{Guid.NewGuid():N}"; - var tcpClient = await Fixture.CreateClient(Protocol.Tcp); - await tcpClient.LoginUserAsync("iggy", "iggy"); + var tcpClient = await Fixture.CreateAuthenticatedClient(Protocol.Tcp); var stream = await tcpClient.CreateStreamAsync(streamName); await tcpClient.CreateTopicAsync(Identifier.String(streamName), "first_topic", 2); @@ -159,7 +157,12 @@ await client.SendMessagesAsync(Identifier.String(streamName), response.PartitionsCount.ShouldBeGreaterThanOrEqualTo(1); response.SegmentsCount.ShouldBeGreaterThanOrEqualTo(1); response.MessagesCount.ShouldBeGreaterThanOrEqualTo(1u); - response.ClientsCount.ShouldBeGreaterThanOrEqualTo(1); + if (!IggyServerFixture.IsServerNg) + { + // iggy-server-ng leaves the connected-client tally out of its stats reply. + response.ClientsCount.ShouldBeGreaterThanOrEqualTo(1); + } + response.Hostname.ShouldNotBeNullOrEmpty(); response.OsName.ShouldNotBeNullOrEmpty(); response.OsVersion.ShouldNotBeNullOrEmpty(); diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/TopicsTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/TopicsTests.cs index fa6a57796b..0cb0f02a48 100644 --- a/foreign/csharp/Iggy_SDK.Tests.Integration/TopicsTests.cs +++ b/foreign/csharp/Iggy_SDK.Tests.Integration/TopicsTests.cs @@ -21,6 +21,7 @@ using Apache.Iggy.Exceptions; using Apache.Iggy.Messages; using Apache.Iggy.Tests.Integrations.Fixtures; +using Apache.Iggy.Tests.Integrations.Helpers; using Shouldly; using Partitioning = Apache.Iggy.Kinds.Partitioning; @@ -40,8 +41,8 @@ public async Task Create_NewTopic_Should_Return_Successfully(Protocol protocol) var streamName = $"topic-create-{Guid.NewGuid():N}"; await client.CreateStreamAsync(streamName); - var response = await client.CreateTopicAsync( - Identifier.String(streamName), "Test Topic", 2, CompressionAlgorithm.Gzip, + var response = await client.CreateTopicAsync(Identifier.String(streamName), "Test Topic", 2, + CompressionAlgorithm.Gzip, 1, TimeSpan.FromMinutes(10), 2_000_000_000); response.ShouldNotBeNull(); @@ -68,8 +69,8 @@ public async Task Create_DuplicateTopic_Should_Throw_InvalidResponse(Protocol pr await client.CreateStreamAsync(streamName); await client.CreateTopicAsync(Identifier.String(streamName), "Dup Topic", 1); - await Should.ThrowAsync( - client.CreateTopicAsync(Identifier.String(streamName), "Dup Topic", 1)); + await Should.ThrowAsync(client.CreateTopicAsync(Identifier.String(streamName), + "Dup Topic", 1)); } [Test] @@ -197,13 +198,11 @@ public async Task Update_ExistingTopic_Should_UpdateTopic_Successfully(Protocol var topicToUpdate = await client.CreateTopicAsync(Identifier.String(streamName), "topic-to-update", 1); topicToUpdate.ShouldNotBeNull(); - await Should.NotThrowAsync(client.UpdateTopicAsync( - Identifier.String(streamName), + await Should.NotThrowAsync(client.UpdateTopicAsync(Identifier.String(streamName), Identifier.Numeric(topicToUpdate.Id), "Updated Topic", CompressionAlgorithm.Gzip, 3_000_000_000, TimeSpan.FromMinutes(10), 3)); - var result = await client.GetTopicByIdAsync( - Identifier.String(streamName), + var result = await client.GetTopicByIdAsync(Identifier.String(streamName), Identifier.Numeric(topicToUpdate.Id)); result.ShouldNotBeNull(); result!.Name.ShouldBe("Updated Topic"); @@ -232,11 +231,13 @@ await client.SendMessagesAsync(Identifier.String(streamName), beforePurge.MessagesCount.ShouldBe(5u); beforePurge.Size.ShouldBeGreaterThan(0u); - await Should.NotThrowAsync(client.PurgeTopicAsync( - Identifier.String(streamName), Identifier.String("Purge Topic"))); + await Should.NotThrowAsync(client.PurgeTopicAsync(Identifier.String(streamName), + Identifier.String("Purge Topic"))); - var afterPurge = await client.GetTopicByIdAsync(Identifier.String(streamName), - Identifier.String("Purge Topic")); + // server-ng commits the purge by advancing a generation its reconciler acts on a tick later. + var afterPurge = await Eventually.ReadAsync( + () => client.GetTopicByIdAsync(Identifier.String(streamName), Identifier.String("Purge Topic")), + topic => topic?.MessagesCount == 0, TimeSpan.FromSeconds(10)); afterPurge.ShouldNotBeNull(); afterPurge!.MessagesCount.ShouldBe(0u); afterPurge.Size.ShouldBe(0u); @@ -253,8 +254,8 @@ public async Task Delete_ExistingTopic_Should_DeleteTopic_Successfully(Protocol var topicToDelete = await client.CreateTopicAsync(Identifier.String(streamName), "topic-to-delete", 1); topicToDelete.ShouldNotBeNull(); - await Should.NotThrowAsync(client.DeleteTopicAsync( - Identifier.String(streamName), Identifier.Numeric(topicToDelete.Id))); + await Should.NotThrowAsync(client.DeleteTopicAsync(Identifier.String(streamName), + Identifier.Numeric(topicToDelete.Id))); } [Test] @@ -266,8 +267,8 @@ public async Task Delete_NonExistingTopic_Should_Throw_InvalidResponse(Protocol var streamName = $"topic-delnone-{Guid.NewGuid():N}"; await client.CreateStreamAsync(streamName); - await Should.ThrowAsync(client.DeleteTopicAsync( - Identifier.String(streamName), Identifier.String("nonexistent-topic"))); + await Should.ThrowAsync(client.DeleteTopicAsync(Identifier.String(streamName), + Identifier.String("nonexistent-topic"))); } [Test] @@ -279,8 +280,8 @@ public async Task Get_NonExistingTopic_Should_Throw_InvalidResponse(Protocol pro var streamName = $"topic-getnone-{Guid.NewGuid():N}"; await client.CreateStreamAsync(streamName); - var topic = await client.GetTopicByIdAsync( - Identifier.String(streamName), Identifier.String("nonexistent-topic")); + var topic = await client.GetTopicByIdAsync(Identifier.String(streamName), + Identifier.String("nonexistent-topic")); topic.ShouldBeNull(); } diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/UsersTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/UsersTests.cs index c3820a3079..3c86131b29 100644 --- a/foreign/csharp/Iggy_SDK.Tests.Integration/UsersTests.cs +++ b/foreign/csharp/Iggy_SDK.Tests.Integration/UsersTests.cs @@ -17,7 +17,6 @@ using Apache.Iggy.Contracts; using Apache.Iggy.Contracts.Auth; -using Apache.Iggy.Contracts.Http.Auth; using Apache.Iggy.Enums; using Apache.Iggy.Exceptions; using Apache.Iggy.Tests.Integrations.Fixtures; @@ -54,8 +53,8 @@ public async Task CreateUser_Duplicate_Should_Throw_InvalidResponse(Protocol pro var username = $"dup-{Guid.NewGuid():N}"[..20]; await client.CreateUserAsync(username, "test1", UserStatus.Active); - await Should.ThrowAsync( - client.CreateUserAsync(username, "test1", UserStatus.Active)); + await Should.ThrowAsync(client.CreateUserAsync(username, "test1", + UserStatus.Active)); } [Test] @@ -169,10 +168,11 @@ public async Task ChangePassword_Should_ChangePassword_Successfully(Protocol pro var username = $"chpw-{Guid.NewGuid():N}"[..20]; await client.CreateUserAsync(username, "old_password", UserStatus.Active); - await Should.NotThrowAsync(client.ChangePasswordAsync(Identifier.String(username), "old_password", "new_password")); + await Should.NotThrowAsync(client.ChangePasswordAsync(Identifier.String(username), "old_password", + "new_password")); // Verify password was actually changed by logging in with the new credentials - var loginClient = await Fixture.CreateClient(protocol); + var loginClient = await Fixture.CreateClient(protocol, true); var loginResponse = await loginClient.LoginUserAsync(username, "new_password"); loginResponse.ShouldNotBeNull(); loginResponse.UserId.ShouldBeGreaterThan(0); @@ -187,8 +187,8 @@ public async Task ChangePassword_WrongCurrentPassword_Should_Throw_InvalidRespon var username = $"chpwf-{Guid.NewGuid():N}"[..20]; await client.CreateUserAsync(username, "correct_password", UserStatus.Active); - await Should.ThrowAsync( - client.ChangePasswordAsync(Identifier.String(username), "wrong_password", "new_password")); + await Should.ThrowAsync(client.ChangePasswordAsync(Identifier.String(username), + "wrong_password", "new_password")); } [Test] @@ -200,7 +200,7 @@ public async Task LoginUser_Should_LoginUser_Successfully(Protocol protocol) var username = $"login-{Guid.NewGuid():N}"[..20]; await client.CreateUserAsync(username, "login_password", UserStatus.Active); - var loginClient = await Fixture.CreateClient(protocol); + var loginClient = await Fixture.CreateClient(protocol, true); var response = await loginClient.LoginUserAsync(username, "login_password"); response.ShouldNotBeNull(); diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/Vsr/VsrConsumerGroupTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/Vsr/VsrConsumerGroupTests.cs new file mode 100644 index 0000000000..3d5acb74d1 --- /dev/null +++ b/foreign/csharp/Iggy_SDK.Tests.Integration/Vsr/VsrConsumerGroupTests.cs @@ -0,0 +1,230 @@ +// 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.Text; +using Apache.Iggy.Contracts; +using Apache.Iggy.Enums; +using Apache.Iggy.Exceptions; +using Apache.Iggy.IggyClient; +using Apache.Iggy.Kinds; +using Apache.Iggy.Messages; +using Apache.Iggy.Tests.Integrations.Attributes; +using Apache.Iggy.Tests.Integrations.Fixtures; +using Shouldly; +using Partitioning = Apache.Iggy.Kinds.Partitioning; + +namespace Apache.Iggy.Tests.Integrations.Vsr; + +/// +/// Group polls under VSR: the server hands out an assignment, the client caches it and round-robins the +/// assigned partitions itself, so a poll without an explicit partition id never reaches the broker as one. +/// +[RequiresServerNg] +public class VsrConsumerGroupTests +{ + private const uint PartitionsCount = 3; + private const string TopicName = "vsr-group-topic"; + + [ClassDataSource(Shared = SharedType.PerAssembly)] + public required IggyServerFixture Fixture { get; init; } + + [Test] + public async Task GroupPoll_Should_Drain_EveryAssignedPartition() + { + var (client, streamName, groupName) = await CreateGroup(); + await client.JoinConsumerGroupAsync(Identifier.String(streamName), Identifier.String(TopicName), + Identifier.String(groupName)); + + for (uint partitionId = 0; partitionId < PartitionsCount; partitionId++) + { + await SendAsync(client, streamName, partitionId); + } + + // One poll per assigned partition drains it; the sole member owns them all, so the round-robin has to + // hand out every partition exactly once before it wraps. + var polled = await DrainGroupAsync(client, streamName, groupName, PartitionsCount); + + polled.ShouldBe((int)PartitionsCount); + } + + [Test] + public async Task GroupPoll_Without_Joining_Should_Throw_MemberNotFound() + { + var (client, streamName, groupName) = await CreateGroup(); + + var exception = await Should.ThrowAsync( + PollGroupAsync(client, streamName, groupName)); + + exception.StatusCode.ShouldBe(5006); + } + + [Test] + public async Task GroupPoll_After_Leaving_Should_Throw_MemberNotFound() + { + var (client, streamName, groupName) = await CreateGroup(); + await client.JoinConsumerGroupAsync(Identifier.String(streamName), Identifier.String(TopicName), + Identifier.String(groupName)); + await PollGroupAsync(client, streamName, groupName); + + await client.LeaveConsumerGroupAsync(Identifier.String(streamName), Identifier.String(TopicName), + Identifier.String(groupName)); + + var exception = await Should.ThrowAsync( + PollGroupAsync(client, streamName, groupName)); + + exception.StatusCode.ShouldBe(5006); + } + + /// + /// A partition count change widens the assignment, and the ping is where the client re-syncs it. The + /// cached generation is asserted first: without it the test would pass on a client that re-synced on + /// every poll and never needed the heartbeat. + /// + [Test] + public async Task Ping_Should_Refresh_TheGroupAssignment_After_PartitionsAreAdded() + { + var (client, streamName, groupName) = await CreateGroup(); + await client.JoinConsumerGroupAsync(Identifier.String(streamName), Identifier.String(TopicName), + Identifier.String(groupName)); + await PollGroupAsync(client, streamName, groupName); + + await client.CreatePartitionsAsync(Identifier.String(streamName), Identifier.String(TopicName), 1); + await SendAsync(client, streamName, PartitionsCount); + + (await DrainGroupAsync(client, streamName, groupName, PartitionsCount + 1)).ShouldBe(0); + + await client.PingAsync(); + + (await DrainGroupAsync(client, streamName, groupName, PartitionsCount + 1)).ShouldBe(1); + } + + /// + /// A member holding no partitions is still a member, so its poll has to come back empty instead of + /// surfacing the not-a-member error the unassigned cursor otherwise looks like. One partition and two + /// members guarantees exactly one of them is in that state. + /// + [Test] + public async Task GroupPoll_By_AMemberWithoutPartitions_Should_ReturnEmpty() + { + var (first, streamName, groupName) = await CreateGroup(); + var topicName = $"vsr-single-partition-{Guid.NewGuid():N}"; + await first.CreateTopicAsync(Identifier.String(streamName), topicName, 1); + await first.CreateConsumerGroupAsync(Identifier.String(streamName), Identifier.String(topicName), groupName); + + var second = await Fixture.CreateAuthenticatedClient(Protocol.Tcp); + await JoinAsync(first, streamName, topicName, groupName); + await JoinAsync(second, streamName, topicName, groupName); + + await first.SendMessagesAsync(Identifier.String(streamName), Identifier.String(topicName), + Partitioning.PartitionId(0), + [new Message(Guid.NewGuid(), Encoding.UTF8.GetBytes("vsr-single-partition-payload"))]); + + var firstPoll = await PollGroupAsync(first, streamName, topicName, groupName); + var secondPoll = await PollGroupAsync(second, streamName, topicName, groupName); + + // Whichever member drew the partition drains it, and the other one has nothing to poll. + (firstPoll.Messages.Count + secondPoll.Messages.Count).ShouldBe(1); + Math.Min(firstPoll.Messages.Count, secondPoll.Messages.Count).ShouldBe(0); + } + + /// + /// A second member rebalances the group, which leaves the first one round-robining partitions it no + /// longer owns. The fence has to re-sync the assignment underneath the poll: a client that surfaced the + /// ownership error instead would break every group app that did not special-case it. + /// + [Test] + public async Task GroupPoll_After_ASecondMemberJoins_Should_ResyncTheStaleAssignment() + { + var (first, streamName, groupName) = await CreateGroup(); + await JoinAsync(first, streamName, TopicName, groupName); + await DrainGroupAsync(first, streamName, groupName, PartitionsCount); + + var second = await Fixture.CreateAuthenticatedClient(Protocol.Tcp); + await JoinAsync(second, streamName, TopicName, groupName); + + for (uint partitionId = 0; partitionId < PartitionsCount; partitionId++) + { + await SendAsync(first, streamName, partitionId); + } + + // The first member still holds the pre-rebalance assignment, so its polls fence until they re-sync. + // Between them the two members have to see every partition; a lost one means a fence was swallowed. + var drained = await DrainGroupAsync(first, streamName, groupName, PartitionsCount); + drained += await DrainGroupAsync(second, streamName, groupName, PartitionsCount); + + drained.ShouldBe((int)PartitionsCount); + } + + private static Task JoinAsync(IIggyClient client, string streamName, string topicName, string groupName) + { + return client.JoinConsumerGroupAsync(Identifier.String(streamName), Identifier.String(topicName), + Identifier.String(groupName)); + } + + private async Task<(IIggyClient Client, string StreamName, string GroupName)> CreateGroup() + { + var client = await Fixture.CreateAuthenticatedClient(Protocol.Tcp); + var streamName = $"vsr-group-{Guid.NewGuid():N}"; + var groupName = $"vsr-group-name-{Guid.NewGuid():N}"; + + await client.CreateStreamAsync(streamName); + await client.CreateTopicAsync(Identifier.String(streamName), TopicName, PartitionsCount); + await client.CreateConsumerGroupAsync(Identifier.String(streamName), Identifier.String(TopicName), groupName); + + return (client, streamName, groupName); + } + + private static Task SendAsync(IIggyClient client, string streamName, uint partitionId) + { + return client.SendMessagesAsync(Identifier.String(streamName), Identifier.String(TopicName), + Partitioning.PartitionId((int)partitionId), + [new Message(Guid.NewGuid(), Encoding.UTF8.GetBytes($"vsr-group-payload-{partitionId}"))]); + } + + /// Polls once per assigned partition and returns how many messages came back in total. + private static async Task DrainGroupAsync(IIggyClient client, string streamName, string groupName, + uint polls) + { + var drained = 0; + for (var poll = 0; poll < polls; poll++) + { + drained += (await PollGroupAsync(client, streamName, groupName)).Messages.Count; + } + + return drained; + } + + private static Task PollGroupAsync(IIggyClient client, string streamName, string groupName) + { + return PollGroupAsync(client, streamName, TopicName, groupName); + } + + private static Task PollGroupAsync(IIggyClient client, string streamName, string topicName, + string groupName) + { + return client.PollMessagesAsync(new MessageFetchRequest + { + Count = 10, + AutoCommit = true, + Consumer = Consumer.Group(groupName), + PartitionId = null, + PollingStrategy = PollingStrategy.Next(), + StreamId = Identifier.String(streamName), + TopicId = Identifier.String(topicName) + }); + } +} diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/Vsr/VsrHandshakeTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/Vsr/VsrHandshakeTests.cs new file mode 100644 index 0000000000..2eeac9256d --- /dev/null +++ b/foreign/csharp/Iggy_SDK.Tests.Integration/Vsr/VsrHandshakeTests.cs @@ -0,0 +1,167 @@ +// 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 Apache.Iggy.Contracts; +using Apache.Iggy.Enums; +using Apache.Iggy.Exceptions; +using Apache.Iggy.Tests.Integrations.Attributes; +using Apache.Iggy.Tests.Integrations.Fixtures; +using Shouldly; + +namespace Apache.Iggy.Tests.Integrations.Vsr; + +/// +/// The register handshake and the session it binds. Every other VSR suite depends on this one passing: +/// without a bound session the server fences every replicated request. +/// +[RequiresServerNg] +public class VsrHandshakeTests +{ + [ClassDataSource(Shared = SharedType.PerAssembly)] + public required IggyServerFixture Fixture { get; init; } + + [Test] + public async Task Login_Should_BindSession_And_ServeReplicatedRequests() + { + var client = await Fixture.CreateAuthenticatedClient(Protocol.Tcp); + + var name = $"vsr-handshake-{Guid.NewGuid():N}"; + var stream = await client.CreateStreamAsync(name); + + stream.ShouldNotBeNull(); + stream.Name.ShouldBe(name); + } + + /// + /// A re-login first logs out the bound session, then registers a fresh client identity. The metadata write + /// after re-login proves the request counter belongs to that new binding instead of replaying a cached + /// response from the old client table entry. + /// + [Test] + public async Task ReLogin_Should_AllowReplicatedRequests() + { + var client = await Fixture.CreateAuthenticatedClient(Protocol.Tcp); + var name = $"vsr-relogin-{Guid.NewGuid():N}"; + await client.CreateStreamAsync(name); + + var response = await client.LoginUserAsync("iggy", "iggy"); + response.ShouldNotBeNull(); + + var afterRelogin = $"vsr-relogin-after-{Guid.NewGuid():N}"; + var stream = await client.CreateStreamAsync(afterRelogin); + stream.ShouldNotBeNull(); + stream.Name.ShouldBe(afterRelogin); + } + + [Test] + public async Task Logout_Should_UnbindTheSession_Until_TheClientRegistersAgain() + { + var client = await Fixture.CreateAuthenticatedClient(Protocol.Tcp); + await client.LogoutUserAsync(); + + await Should.ThrowAsync(client.CreateStreamAsync($"vsr-logout-{Guid.NewGuid():N}")); + + await client.LoginUserAsync("iggy", "iggy"); + + var name = $"vsr-logout-relogin-{Guid.NewGuid():N}"; + (await client.CreateStreamAsync(name)).ShouldNotBeNull(); + } + + /// + /// A rejected register is a consumed one on the server, so the failure has to reset the session and + /// unwind the connection state. The retry with valid credentials is the assertion that matters: a client + /// left in Authenticating with the failed register's session still bound would fence it. + /// Wrong credentials fall through the server's PAT attempt and come back as a MalformedLogin eviction, + /// not as the empty register reply, and an eviction is terminal for the connection: the retry has to + /// reconnect first. + /// + [Test] + public async Task Login_WithInvalidCredentials_Should_ResetTheSession_And_AllowARetry() + { + var client = await Fixture.CreateUnauthenticatedClient(Protocol.Tcp); + + var exception = await Should.ThrowAsync( + client.LoginUserAsync("iggy", "not-the-password")); + + exception.StatusCode.ShouldBe(4); + exception.Message.ShouldContain("Malformed login body"); + + await client.ConnectAsync(); + (await client.LoginUserAsync("iggy", "iggy")).ShouldNotBeNull(); + + var name = $"vsr-failed-login-{Guid.NewGuid():N}"; + (await client.CreateStreamAsync(name)).ShouldNotBeNull(); + } + + [Test] + public async Task LoginWithPersonalAccessToken_Should_BindTheSession() + { + var client = await Fixture.CreateAuthenticatedClient(Protocol.Tcp); + var token = await client.CreatePersonalAccessTokenAsync($"vsr-pat-{Guid.NewGuid():N}"); + token.ShouldNotBeNull(); + + var patClient = await Fixture.CreateUnauthenticatedClient(Protocol.Tcp); + var response = await patClient.LoginWithPersonalAccessTokenAsync(token.Token); + + response.ShouldNotBeNull(); + + var name = $"vsr-pat-stream-{Guid.NewGuid():N}"; + (await patClient.CreateStreamAsync(name)).ShouldNotBeNull(); + } + + [Test] + public async Task Ping_Should_Succeed_Before_TheSessionIsBound() + { + var client = await Fixture.CreateUnauthenticatedClient(Protocol.Tcp); + + // Non-replicated ops are sessionless, so an unbound client still pings. + await client.PingAsync(); + + await Should.ThrowAsync(client.CreateStreamAsync($"vsr-unbound-{Guid.NewGuid():N}")); + } + + [Test] + public async Task Ping_Should_Ride_NonReplicated_Without_GappingTheRequestCounter() + { + var client = await Fixture.CreateAuthenticatedClient(Protocol.Tcp); + + // A ping that consumed a request id would gap the next metadata request, and the primary silently + // drops a gapped one - the create below would hang instead of failing loudly. + await client.PingAsync(); + var first = await client.CreateStreamAsync($"vsr-ping-first-{Guid.NewGuid():N}"); + await client.PingAsync(); + var second = await client.CreateStreamAsync($"vsr-ping-second-{Guid.NewGuid():N}"); + + first.ShouldNotBeNull(); + second.ShouldNotBeNull(); + } + + [Test] + public async Task Reads_Should_Ride_NonReplicated_Between_MetadataWrites() + { + var client = await Fixture.CreateAuthenticatedClient(Protocol.Tcp); + + var name = $"vsr-reads-{Guid.NewGuid():N}"; + await client.CreateStreamAsync(name); + + IReadOnlyList streams = await client.GetStreamsAsync(); + streams.ShouldContain(stream => stream.Name == name); + + var second = $"vsr-reads-second-{Guid.NewGuid():N}"; + (await client.CreateStreamAsync(second)).ShouldNotBeNull(); + } +} diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/Vsr/VsrMessagingTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/Vsr/VsrMessagingTests.cs new file mode 100644 index 0000000000..9f2462f82f --- /dev/null +++ b/foreign/csharp/Iggy_SDK.Tests.Integration/Vsr/VsrMessagingTests.cs @@ -0,0 +1,167 @@ +// 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.Text; +using Apache.Iggy.Contracts; +using Apache.Iggy.Enums; +using Apache.Iggy.IggyClient; +using Apache.Iggy.Kinds; +using Apache.Iggy.Messages; +using Apache.Iggy.Tests.Integrations.Attributes; +using Apache.Iggy.Tests.Integrations.Fixtures; +using Shouldly; +using Partitioning = Apache.Iggy.Kinds.Partitioning; + +namespace Apache.Iggy.Tests.Integrations.Vsr; + +/// +/// The partition plane. Under VSR the broker never picks a partition, so the client resolves every +/// partitioning kind to an explicit id before the request leaves - these tests assert the resolution +/// lands where the Rust SDK's does. +/// +[RequiresServerNg] +public class VsrMessagingTests +{ + private const uint PartitionsCount = 4; + private const string TopicName = "vsr-messages"; + + [ClassDataSource(Shared = SharedType.PerAssembly)] + public required IggyServerFixture Fixture { get; init; } + + [Test] + public async Task SendMessages_ToAnExplicitPartition_Should_PollBack_FromThatPartition() + { + var (client, streamName) = await CreateStreamAndTopic(); + + await SendAsync(client, streamName, Partitioning.PartitionId(2), 5); + + var polled = await PollAsync(client, streamName, 2); + polled.Messages.Count.ShouldBe(5); + polled.PartitionId.ShouldBe(2); + + (await PollAsync(client, streamName, 1)).Messages.ShouldBeEmpty(); + } + + /// + /// Balanced partitioning is resolved client-side by round-robin over the topic's partition count, so + /// a batch per partition ends up one message on each. + /// + [Test] + public async Task SendMessages_Balanced_Should_RoundRobin_AcrossEveryPartition() + { + var (client, streamName) = await CreateStreamAndTopic(); + + for (var i = 0; i < PartitionsCount; i++) + { + await SendAsync(client, streamName, Partitioning.None(), 1); + } + + List counts = await PollEveryPartitionAsync(client, streamName); + + counts.Sum().ShouldBe((int)PartitionsCount); + counts.ShouldAllBe(count => count == 1); + } + + /// + /// The message key hashes to one partition, so every message under the same key lands together and + /// two different keys are free to differ. Only the first is asserted: the hash is pinned by the unit + /// tests against the Rust vectors, and asserting two keys differ would be a coin flip. + /// + [Test] + public async Task SendMessages_ByMessageKey_Should_LandOn_ASinglePartition() + { + var (client, streamName) = await CreateStreamAndTopic(); + var key = Partitioning.EntityIdString($"key-{Guid.NewGuid():N}"); + + for (var i = 0; i < 6; i++) + { + await SendAsync(client, streamName, key, 1); + } + + List counts = await PollEveryPartitionAsync(client, streamName); + + counts.Sum().ShouldBe(6); + counts.Count(count => count > 0).ShouldBe(1); + } + + [Test] + public async Task ConsumerOffsets_Should_RoundTrip_ThroughTheResultSection() + { + var (client, streamName) = await CreateStreamAndTopic(); + await SendAsync(client, streamName, Partitioning.PartitionId(0), 3); + + var consumer = Consumer.New($"vsr-offset-{Guid.NewGuid():N}"); + await client.StoreOffsetAsync(consumer, Identifier.String(streamName), Identifier.String(TopicName), 1, 0); + + var stored = await client.GetOffsetAsync(consumer, Identifier.String(streamName), + Identifier.String(TopicName), 0); + stored.ShouldNotBeNull(); + stored.StoredOffset.ShouldBe(1u); + + await client.DeleteOffsetAsync(consumer, Identifier.String(streamName), Identifier.String(TopicName), 0); + + var cleared = await client.GetOffsetAsync(consumer, Identifier.String(streamName), + Identifier.String(TopicName), 0); + cleared.ShouldSatisfyAllConditions(() => (cleared is null || cleared.StoredOffset == 0).ShouldBeTrue()); + } + + private async Task<(IIggyClient Client, string StreamName)> CreateStreamAndTopic() + { + var client = await Fixture.CreateAuthenticatedClient(Protocol.Tcp); + var streamName = $"vsr-msg-{Guid.NewGuid():N}"; + + await client.CreateStreamAsync(streamName); + await client.CreateTopicAsync(Identifier.String(streamName), TopicName, PartitionsCount); + + return (client, streamName); + } + + private static Task SendAsync(IIggyClient client, string streamName, Partitioning partitioning, int count) + { + Message[] messages = Enumerable.Range(0, count) + .Select(index => new Message(Guid.NewGuid(), Encoding.UTF8.GetBytes($"vsr-payload-{index}"))) + .ToArray(); + + return client.SendMessagesAsync(Identifier.String(streamName), Identifier.String(TopicName), partitioning, + messages); + } + + private static Task PollAsync(IIggyClient client, string streamName, uint partitionId) + { + return client.PollMessagesAsync(new MessageFetchRequest + { + Count = 100, + AutoCommit = false, + Consumer = Consumer.New(1), + PartitionId = partitionId, + PollingStrategy = PollingStrategy.Offset(0), + StreamId = Identifier.String(streamName), + TopicId = Identifier.String(TopicName) + }); + } + + private static async Task> PollEveryPartitionAsync(IIggyClient client, string streamName) + { + var counts = new List(); + for (uint partitionId = 0; partitionId < PartitionsCount; partitionId++) + { + counts.Add((await PollAsync(client, streamName, partitionId)).Messages.Count); + } + + return counts; + } +} diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/Vsr/VsrMetadataTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/Vsr/VsrMetadataTests.cs new file mode 100644 index 0000000000..6330d2d165 --- /dev/null +++ b/foreign/csharp/Iggy_SDK.Tests.Integration/Vsr/VsrMetadataTests.cs @@ -0,0 +1,134 @@ +// 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 Apache.Iggy.Enums; +using Apache.Iggy.Exceptions; +using Apache.Iggy.Kinds; +using Apache.Iggy.Messages; +using Apache.Iggy.Tests.Integrations.Attributes; +using Apache.Iggy.Tests.Integrations.Fixtures; +using Shouldly; +using Partitioning = Apache.Iggy.Kinds.Partitioning; + +namespace Apache.Iggy.Tests.Integrations.Vsr; + +/// +/// Control-plane operations through the consensus path: every one of these consumes a request id and +/// comes back with a committed result section the decoder has to strip before the typed mapper runs. +/// +[RequiresServerNg] +public class VsrMetadataTests +{ + [ClassDataSource(Shared = SharedType.PerAssembly)] + public required IggyServerFixture Fixture { get; init; } + + [Test] + public async Task StreamLifecycle_Should_CommitThroughConsensus() + { + var client = await Fixture.CreateAuthenticatedClient(Protocol.Tcp); + + var name = $"vsr-meta-stream-{Guid.NewGuid():N}"; + var created = await client.CreateStreamAsync(name); + created.ShouldNotBeNull(); + + var fetched = await client.GetStreamByIdAsync(Identifier.Numeric(created.Id)); + fetched.ShouldNotBeNull(); + fetched.Name.ShouldBe(name); + + await client.DeleteStreamAsync(Identifier.Numeric(created.Id)); + (await client.GetStreamsAsync()).ShouldNotContain(stream => stream.Name == name); + } + + [Test] + public async Task TopicLifecycle_Should_CommitThroughConsensus() + { + var client = await Fixture.CreateAuthenticatedClient(Protocol.Tcp); + + var streamName = $"vsr-meta-topic-{Guid.NewGuid():N}"; + await client.CreateStreamAsync(streamName); + + var topic = await client.CreateTopicAsync(Identifier.String(streamName), "vsr-topic", 3); + topic.ShouldNotBeNull(); + topic.PartitionsCount.ShouldBe(3u); + + var fetched = await client.GetTopicByIdAsync(Identifier.String(streamName), Identifier.Numeric(topic.Id)); + fetched.ShouldNotBeNull(); + fetched.PartitionsCount.ShouldBe(3u); + + await client.DeleteTopicAsync(Identifier.String(streamName), Identifier.Numeric(topic.Id)); + (await client.GetTopicsAsync(Identifier.String(streamName))).ShouldBeEmpty(); + } + + /// + /// A committed rejection rides the result section with status 0 in the header, so the decoder has to + /// read the first result entry to see it. A silent success here would mean the section was skipped. + /// + [Test] + public async Task DuplicateStream_Should_Surface_TheCommittedRejection() + { + var client = await Fixture.CreateAuthenticatedClient(Protocol.Tcp); + + var name = $"vsr-meta-dup-{Guid.NewGuid():N}"; + await client.CreateStreamAsync(name); + + await Should.ThrowAsync(client.CreateStreamAsync(name)); + } + + [Test] + public async Task UserLifecycle_Should_CommitThroughConsensus() + { + var client = await Fixture.CreateAuthenticatedClient(Protocol.Tcp); + + var name = $"vsr-user-{Guid.NewGuid():N}"; + var user = await client.CreateUserAsync(name, "secret-password", UserStatus.Active); + user.ShouldNotBeNull(); + + var fetched = await client.GetUserAsync(Identifier.Numeric(user.Id)); + fetched.ShouldNotBeNull(); + fetched.Username.ShouldBe(name); + + await client.DeleteUserAsync(Identifier.Numeric(user.Id)); + (await client.GetUserAsync(Identifier.Numeric(user.Id))).ShouldBeNull(); + } + + /// + /// Partition ops read the request counter without advancing it. Interleaving them with metadata + /// writes catches the asymmetry: a partition op that consumed an id would gap the next metadata one + /// and the primary would silently drop it. + /// + [Test] + public async Task MetadataWrites_Should_KeepCommitting_Around_PartitionOps() + { + var client = await Fixture.CreateAuthenticatedClient(Protocol.Tcp); + + var streamName = $"vsr-meta-mixed-{Guid.NewGuid():N}"; + await client.CreateStreamAsync(streamName); + await client.CreateTopicAsync(Identifier.String(streamName), "vsr-mixed-topic", 1); + + await client.SendMessagesAsync(Identifier.String(streamName), Identifier.String("vsr-mixed-topic"), + Partitioning.PartitionId(0), + [new Message(Guid.NewGuid(), "vsr-mixed"u8.ToArray())]); + await client.StoreOffsetAsync(Consumer.New(1), Identifier.String(streamName), + Identifier.String("vsr-mixed-topic"), 0, 0); + + var second = $"vsr-meta-mixed-second-{Guid.NewGuid():N}"; + var created = await client.CreateStreamAsync(second); + + created.ShouldNotBeNull(); + created.Name.ShouldBe(second); + } +} diff --git a/foreign/csharp/Iggy_SDK/Configuration/AutoLoginSettings.cs b/foreign/csharp/Iggy_SDK/Configuration/AutoLoginSettings.cs index b1b434f563..3c88446eca 100644 --- a/foreign/csharp/Iggy_SDK/Configuration/AutoLoginSettings.cs +++ b/foreign/csharp/Iggy_SDK/Configuration/AutoLoginSettings.cs @@ -36,4 +36,19 @@ public class AutoLoginSettings /// Specifies the password for auto-login authentication /// public string Password { get; set; } = string.Empty; + + /// + /// Settings for a builder-owned client that signs in with the given credentials. The credentials must + /// reach the client and not only the explicit login the wrapper performs at startup: a reconnect or a + /// leader redirect drops the session, and without them the client comes back unauthenticated. + /// + internal static AutoLoginSettings For(string username, string password) + { + return new AutoLoginSettings + { + Enabled = !string.IsNullOrEmpty(username), + Username = username, + Password = password + }; + } } diff --git a/foreign/csharp/Iggy_SDK/Configuration/IggyClientConfigurator.cs b/foreign/csharp/Iggy_SDK/Configuration/IggyClientConfigurator.cs index b8c555a812..5478a10323 100644 --- a/foreign/csharp/Iggy_SDK/Configuration/IggyClientConfigurator.cs +++ b/foreign/csharp/Iggy_SDK/Configuration/IggyClientConfigurator.cs @@ -37,6 +37,18 @@ public sealed class IggyClientConfigurator /// public required Protocol Protocol { get; set; } + /// + /// The wire framing to use. Default is . + /// requires . + /// + public WireProtocol WireProtocol { get; set; } = WireProtocol.Classic; + + /// + /// The largest response frame accepted under , in bytes. + /// Default is 64 MiB, minimum is the 256-byte header. + /// + public int MaxResponseFrameSize { get; set; } = 64 * 1024 * 1024; + /// /// The size of the receive buffer in bytes. Default is 4096. /// diff --git a/foreign/csharp/Iggy_SDK/Consumers/IggyConsumer.Rented.cs b/foreign/csharp/Iggy_SDK/Consumers/IggyConsumer.Rented.cs index e75b60ae31..9b4bbfaeb8 100644 --- a/foreign/csharp/Iggy_SDK/Consumers/IggyConsumer.Rented.cs +++ b/foreign/csharp/Iggy_SDK/Consumers/IggyConsumer.Rented.cs @@ -198,13 +198,7 @@ protected async Task PollRentedMessagesAsync(CancellationToken ct) LogFailedToDecryptMessage(ex, ex.Offset, ex.PartitionId); throw; } - catch (MalformedResponseException) - { - // Non-transient poison: rethrow so the generic catch below does not swallow it and re-poll forever. - // Base InvalidResponseException (server error status, possibly transient) falls through to retry. - throw; - } - catch (Exception ex) + catch (Exception ex) when (ex is not (MalformedResponseException or VsrRequestOutcomeUnknownException)) { LogFailedToPollMessages(ex); _consumerErrorEvents.Publish(new ConsumerErrorEventArgs(ex, "Failed to poll messages")); diff --git a/foreign/csharp/Iggy_SDK/Consumers/IggyConsumer.cs b/foreign/csharp/Iggy_SDK/Consumers/IggyConsumer.cs index c1201d0274..b5317cfdef 100644 --- a/foreign/csharp/Iggy_SDK/Consumers/IggyConsumer.cs +++ b/foreign/csharp/Iggy_SDK/Consumers/IggyConsumer.cs @@ -154,7 +154,7 @@ public async Task InitAsync(CancellationToken ct = default) await _client.ConnectAsync(ct); - if (_config.CreateIggyClient) + if (!string.IsNullOrEmpty(_config.Login) && !_config.CreateIggyClient) { await _client.LoginUserAsync(_config.Login, _config.Password, ct); } @@ -441,13 +441,7 @@ private async Task PollMessagesAsync(CancellationToken ct) LogFailedToDecryptMessage(ex, ex.Offset, ex.PartitionId); throw; } - catch (MalformedResponseException) - { - // Non-transient poison: rethrow so the generic catch below does not swallow it and re-poll forever. - // Base InvalidResponseException (server error status, possibly transient) falls through to retry. - throw; - } - catch (Exception ex) + catch (Exception ex) when (ex is not (MalformedResponseException or VsrRequestOutcomeUnknownException)) { LogFailedToPollMessages(ex); _consumerErrorEvents.Publish(new ConsumerErrorEventArgs(ex, "Failed to poll messages")); diff --git a/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerBuilder.cs b/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerBuilder.cs index 937ca10c5a..d52e85a8e0 100644 --- a/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerBuilder.cs +++ b/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerBuilder.cs @@ -31,7 +31,7 @@ namespace Apache.Iggy.Consumers; /// public class IggyConsumerBuilder { - private IMessageEncryptor? _encryptor; + private protected IMessageEncryptor? _encryptor; internal Func? OnPollingError { get; set; } internal IggyConsumerConfig Config { get; set; } = new(); @@ -39,7 +39,7 @@ public class IggyConsumerBuilder /// /// Creates a new consumer builder that will create its own Iggy client. - /// You must configure connection settings using . + /// You must configure connection settings using WithConnection. /// /// The stream identifier to consume from /// The topic identifier to consume from @@ -108,6 +108,18 @@ public IggyConsumerBuilder WithConnection(Protocol protocol, string address, str return this; } + /// + /// Selects the wire framing the consumer's client speaks. Defaults to . + /// + /// The wire framing to use. VSR requires . + /// The current instance of to allow method chaining. + public IggyConsumerBuilder WithWireProtocol(WireProtocol wireProtocol) + { + Config.WireProtocol = wireProtocol; + + return this; + } + /// /// Configures message encryption on the client this builder creates. Encryption is a client-level concern: /// the encryptor decrypts on poll (and encrypts on send) for the whole connection. Only valid when the @@ -241,10 +253,12 @@ public IggyConsumer Build() IggyClient = IggyClientFactory.CreateClient(new IggyClientConfigurator { Protocol = Config.Protocol, + WireProtocol = Config.WireProtocol, BaseAddress = Config.Address, ReceiveBufferSize = Config.ReceiveBufferSize, SendBufferSize = Config.SendBufferSize, ReconnectionSettings = Config.ReconnectionSettings ?? new ReconnectionSettings(), + AutoLoginSettings = AutoLoginSettings.For(Config.Login, Config.Password), LoggerFactory = Config.LoggerFactory ?? NullLoggerFactory.Instance, MessageEncryptor = _encryptor }); diff --git a/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerBuilderOfT.cs b/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerBuilderOfT.cs index 0ccf68cc18..f7ba899928 100644 --- a/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerBuilderOfT.cs +++ b/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerBuilderOfT.cs @@ -19,7 +19,6 @@ using Apache.Iggy.Factory; using Apache.Iggy.IggyClient; using Apache.Iggy.Kinds; -using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; namespace Apache.Iggy.Consumers; @@ -93,9 +92,14 @@ public static IggyConsumerBuilder Create(IIggyClient iggyClient, Identifier s IggyClient = IggyClientFactory.CreateClient(new IggyClientConfigurator { Protocol = Config.Protocol, + WireProtocol = Config.WireProtocol, BaseAddress = Config.Address, ReceiveBufferSize = Config.ReceiveBufferSize, - SendBufferSize = Config.SendBufferSize + SendBufferSize = Config.SendBufferSize, + ReconnectionSettings = Config.ReconnectionSettings ?? new ReconnectionSettings(), + AutoLoginSettings = AutoLoginSettings.For(Config.Login, Config.Password), + LoggerFactory = Config.LoggerFactory ?? NullLoggerFactory.Instance, + MessageEncryptor = _encryptor }); } @@ -133,8 +137,7 @@ protected override void Validate() } else { - throw new InvalidOperationException( - $"Config must be of type IggyConsumerConfig<{typeof(T).Name}>."); + throw new InvalidOperationException($"Config must be of type IggyConsumerConfig<{typeof(T).Name}>."); } } } diff --git a/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerConfig.cs b/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerConfig.cs index 1418282d34..bae681c24c 100644 --- a/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerConfig.cs +++ b/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerConfig.cs @@ -54,6 +54,13 @@ public class IggyConsumerConfig /// public Protocol Protocol { get; set; } + /// + /// The wire framing to use. Defaults to ; + /// requires . + /// Only used when is true. + /// + public WireProtocol WireProtocol { get; set; } = WireProtocol.Classic; + /// /// The server address to connect to /// diff --git a/foreign/csharp/Iggy_SDK/Enums/WireProtocol.cs b/foreign/csharp/Iggy_SDK/Enums/WireProtocol.cs new file mode 100644 index 0000000000..b04f1d6801 --- /dev/null +++ b/foreign/csharp/Iggy_SDK/Enums/WireProtocol.cs @@ -0,0 +1,35 @@ +// 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; + +/// +/// The wire framing used on top of the transport. Independent of , which selects +/// the transport itself. +/// +public enum WireProtocol +{ + /// + /// Classic framing: [length u32][command code u32][body]. + /// + Classic, + + /// + /// Viewstamped Replication framing: a 256-byte consensus header followed by the body. TCP only. + /// + Vsr +} diff --git a/foreign/csharp/Iggy_SDK/Exceptions/IggyInvalidStatusCodeException.cs b/foreign/csharp/Iggy_SDK/Exceptions/IggyInvalidStatusCodeException.cs index 6ba6e14d3e..9d73640d5c 100644 --- a/foreign/csharp/Iggy_SDK/Exceptions/IggyInvalidStatusCodeException.cs +++ b/foreign/csharp/Iggy_SDK/Exceptions/IggyInvalidStatusCodeException.cs @@ -25,10 +25,18 @@ public sealed class IggyInvalidStatusCodeException : Exception /// /// Status code returned by the server. /// - public int StatusCode { get; init; } + public int StatusCode { get; } - internal IggyInvalidStatusCodeException(int statusCode, string message) : base(message) + /// + /// Whether the status code was reported by the server rather than raised by the client. The two share one + /// code space, and only a server verdict may drive retry or failover: a locally raised code says nothing + /// about what the cluster did with the request. + /// + public bool FromServer { get; } + + internal IggyInvalidStatusCodeException(int statusCode, string message, bool fromServer = false) : base(message) { StatusCode = statusCode; + FromServer = fromServer; } } diff --git a/foreign/csharp/Iggy_SDK/Exceptions/VsrRequestOutcomeUnknownException.cs b/foreign/csharp/Iggy_SDK/Exceptions/VsrRequestOutcomeUnknownException.cs new file mode 100644 index 0000000000..f79334b282 --- /dev/null +++ b/foreign/csharp/Iggy_SDK/Exceptions/VsrRequestOutcomeUnknownException.cs @@ -0,0 +1,33 @@ +// 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.Exceptions; + +/// +/// The request produced no server verdict after transmission began, so the server may already have committed +/// it. Replaying it on a fresh consensus session would bypass server-side deduplication, so the SDK refuses to +/// retry and surfaces this instead: the caller decides whether re-issuing the operation is safe. +/// +/// +/// Deliberately derived from rather than or +/// , whichever ended the request. Both of those are routinely caught +/// and either retried or swallowed, which are the two responses this type exists to prevent. The triggering +/// exception is preserved as . +/// +public sealed class VsrRequestOutcomeUnknownException(Exception innerException) + : Exception("The VSR request outcome is unknown because no server verdict arrived after transmission began.", + innerException); diff --git a/foreign/csharp/Iggy_SDK/Exceptions/VsrSessionEvictedException.cs b/foreign/csharp/Iggy_SDK/Exceptions/VsrSessionEvictedException.cs new file mode 100644 index 0000000000..824356618a --- /dev/null +++ b/foreign/csharp/Iggy_SDK/Exceptions/VsrSessionEvictedException.cs @@ -0,0 +1,34 @@ +// 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.Exceptions; + +/// +/// An eviction frame arrived where a reply was expected. Internal: the transport decides whether the caller +/// sees or an unknown outcome, depending on whether the outstanding request could +/// still have committed. +/// +/// +/// The server emits evictions off its own heartbeat timer rather than as an answer, so the frame carries no +/// correlation with the request it interrupts and that request's real reply is never read. +/// +internal sealed class VsrSessionEvictedException(Exception verdict) + : Exception("The consensus session was evicted by the server.", verdict) +{ + /// The error the eviction reason maps to, for the requests it can safely be reported as. + internal Exception Verdict { get; } = verdict; +} diff --git a/foreign/csharp/Iggy_SDK/Factory/IggyClientFactory.cs b/foreign/csharp/Iggy_SDK/Factory/IggyClientFactory.cs index 4300464900..7562dbabca 100644 --- a/foreign/csharp/Iggy_SDK/Factory/IggyClientFactory.cs +++ b/foreign/csharp/Iggy_SDK/Factory/IggyClientFactory.cs @@ -20,6 +20,7 @@ using Apache.Iggy.Enums; using Apache.Iggy.IggyClient; using Apache.Iggy.IggyClient.Implementations; +using Apache.Iggy.Vsr; namespace Apache.Iggy.Factory; @@ -44,8 +45,17 @@ public static class IggyClientFactory /// Thrown when the specified protocol in is not /// supported. /// + /// + /// Thrown when is + /// and the transport is not . + /// + /// + /// Thrown when is below the 256-byte header. + /// public static IIggyClient CreateClient(IggyClientConfigurator options) { + Validate(options); + return options.Protocol switch { Protocol.Http => CreateIggyHttpClient(options), @@ -54,6 +64,27 @@ public static IIggyClient CreateClient(IggyClientConfigurator options) }; } + private static void Validate(IggyClientConfigurator options) + { + if (options.WireProtocol != WireProtocol.Vsr) + { + return; + } + + if (options.MaxResponseFrameSize < VsrHeader.HEADER_SIZE) + { + throw new ArgumentOutOfRangeException(nameof(options), options.MaxResponseFrameSize, + $"MaxResponseFrameSize must be at least {VsrHeader.HEADER_SIZE} bytes."); + } + + if (options.Protocol != Protocol.Tcp) + { + throw new ArgumentException( + $"WireProtocol.Vsr requires Protocol.Tcp, but {options.Protocol} was configured.", + nameof(options)); + } + } + private static IIggyClient CreateIggyTcpClient(IggyClientConfigurator options) { return new TcpMessageStream(options, options.LoggerFactory); diff --git a/foreign/csharp/Iggy_SDK/IggyClient/IIggySystem.cs b/foreign/csharp/Iggy_SDK/IggyClient/IIggySystem.cs index 32acfcec89..aab60f1c6c 100644 --- a/foreign/csharp/Iggy_SDK/IggyClient/IIggySystem.cs +++ b/foreign/csharp/Iggy_SDK/IggyClient/IIggySystem.cs @@ -86,7 +86,10 @@ public interface IIggySystem /// Sends a ping request to the server to verify connectivity. /// /// - /// This is a simple health check operation that can be used to verify the connection is active. + /// This is a simple health check operation that can be used to verify the connection is active. On the + /// VSR wire protocol it also re-syncs the assignment of every consumer group this client has joined, so + /// it costs one extra round trip per joined group. The SDK never calls it on its own: an application + /// that wants assignments refreshed has to ping on its own cadence. /// /// The cancellation token to cancel the operation. /// A task representing the asynchronous operation. diff --git a/foreign/csharp/Iggy_SDK/IggyClient/Implementations/HttpMessageStream.cs b/foreign/csharp/Iggy_SDK/IggyClient/Implementations/HttpMessageStream.cs index 4aba83255e..3eee5a9053 100644 --- a/foreign/csharp/Iggy_SDK/IggyClient/Implementations/HttpMessageStream.cs +++ b/foreign/csharp/Iggy_SDK/IggyClient/Implementations/HttpMessageStream.cs @@ -988,7 +988,7 @@ private static async Task HandleResponseAsync(HttpResponseMessage response, bool { var err = await response.Content.ReadAsStringAsync(); var errorModel = JsonSerializer.Deserialize(err); - throw new IggyInvalidStatusCodeException(errorModel?.Id ?? -1, err); + throw new IggyInvalidStatusCodeException(errorModel?.Id ?? -1, err, true); } if (response.StatusCode == HttpStatusCode.InternalServerError) diff --git a/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.Vsr.cs b/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.Vsr.cs new file mode 100644 index 0000000000..dd9bc88bb7 --- /dev/null +++ b/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.Vsr.cs @@ -0,0 +1,981 @@ +// 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; +using System.Buffers.Binary; +using System.IO.Hashing; +using System.Runtime.ExceptionServices; +using Apache.Iggy.ConnectionStream; +using Apache.Iggy.Contracts; +using Apache.Iggy.Contracts.Auth; +using Apache.Iggy.Contracts.Tcp; +using Apache.Iggy.Enums; +using Apache.Iggy.Exceptions; +using Apache.Iggy.Kinds; +using Apache.Iggy.Messages; +using Apache.Iggy.Utils; +using Apache.Iggy.Vsr; +using Microsoft.Extensions.Logging; +using Partitioning = Apache.Iggy.Kinds.Partitioning; + +namespace Apache.Iggy.IggyClient.Implementations; + +/// +/// The consensus (VSR) half of the TCP client: the framed request path, the leader redirection, the +/// register handshake, and the client-side partitioning and consumer-group assignment the broker does not +/// resolve server-side. The classic path lives in . +/// +public sealed partial class TcpMessageStream +{ + /// + /// Upper bound for a whole VSR request: the transient replays and the leader failovers share it, and so + /// do the reply header and body reads. The connection is lockstep, so an unanswered read would hold the + /// sending semaphore forever and wedge every later request. + /// + private const int VsrRequestTimeoutMs = 30_000; + + /// Backoff between replays of a transiently refused request. + private const int VsrTransientRetryIntervalMs = 50; + + /// + /// Largest body still sent as one contiguous frame with its header. Beyond this the copy outweighs the + /// syscall and the extra segment it saves, so header and body go out as two writes. + /// + private const int VsrContiguousFrameLimit = 4 * 1024; + + /// + /// How long a request replays on the same connection + /// before the leader roster is re-checked. A node that stopped being primary refuses forever, so + /// replaying alone never recovers. + /// + private const int VsrTransientFailoverCheckMs = 2_000; + + /// + /// How long a transiently leaderless roster is polled before the connection proceeds on the current + /// node anyway. A restarted node cedes the primaryship its stale view assigns it, and the peers need + /// about one heartbeat timeout to elect. + /// + private const int VsrLeaderlessWaitMs = 5_000; + + private const int VsrLeaderlessPollMs = 250; + + /// + /// Cap on consecutive leader redirects, so a flapping roster cannot spin the connect loop or the + /// transient failover path. The budget is client-wide and resets on a roster check that finds the + /// current node is the leader, and on every request that completes, so a client that outlives more + /// leader changes than the cap does not latch onto a follower for good. + /// + private const int VsrMaxLeaderRedirects = 3; + + /// + /// Attempts a consumer-group poll gets before it gives up and reports an empty poll: one re-sync after + /// the coordinator fences a stale assignment, then one retry. + /// + private const int VsrGroupPollMaxAttempts = 2; + + /// + /// Partition id a fenced group poll echoes instead of a typed error, matching + /// 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; + + /// + /// Shared empty poll result. An idle consumer loop returns one on every iteration, and the instance owns + /// no rented buffer - disposes to nothing - so it is safe to hand out + /// repeatedly even after a caller disposes it. + /// + private static readonly PolledMessagesRental EmptyPolledMessages = new(EmptyMemoryOwner.Instance) + { + PartitionId = 0, + CurrentOffset = 0, + Messages = [] + }; + + private readonly ConsensusSession _consensusSession = new(); + private readonly ConsumerGroupClientState _groupState = new(); + private readonly bool _isVsr; + private readonly byte[] _vsrReplyHeaderBuffer = new byte[VsrHeader.HEADER_SIZE]; + + // The redirect budget is refunded by a completed request, and the roster check a redirect runs is itself a + // request. Without this the refund lands between the check and the increment that reads the budget, and the + // counter never leaves zero. Nonzero for the duration of a roster read, so that refund is skipped. + private int _leaderProbeDepth; + + /// + /// Runs the consensus register handshake and binds the session it commits. Everything before the bind + /// is a consumed register on the server, so any failure resets the session: the next attempt must + /// re-register under a fresh client id rather than send requests the primary would fence. + /// + /// + /// A bound connection must commit logout before it can register again. The server treats a register on + /// an already-bound transport as an idempotent replay of the existing binding, so re-arming only the + /// local session would pair a fresh client id and request counter with the old server session. + /// + private async Task LoginRegisterAsync(int code, byte[] message, CancellationToken token) + { + for (var redirects = 0; ; redirects++) + { + if (_consensusSession.IsBound) + { + await LogoutUserAsync(token); + } + else if (_state == ConnectionState.Authenticated) + { + SetConnectionStateAsync(ConnectionState.Connected); + } + + var payload = new byte[4 + BufferSizes.INITIAL_BYTES_LENGTH + message.Length]; + TcpMessageStreamHelpers.CreatePayload(payload, message, code); + + SetConnectionStateAsync(ConnectionState.Authenticating); + + LoginRegisterResponse response; + try + { + Interlocked.Exchange(ref _skipAutoLoginOnce, 1); + using IMemoryOwner responseBuffer = await SendWithResponseAsync(payload, token); + + response = LoginRegister.Deserialize(responseBuffer.Memory.Span); + _consensusSession.Bind(response.Session); + } + catch + { + await ResetConsensusSessionAsync(); + if (_state == ConnectionState.Authenticating) + { + SetConnectionStateAsync(ConnectionState.Connected); + } + + throw; + } + finally + { + Interlocked.Exchange(ref _skipAutoLoginOnce, 0); + } + + _logger.LogInformation( + "Authenticated against the server, version {ServerVersion}, protocol version {ServerProtocolVersion}", + response.ServerVersion, response.ServerProtocolVersion); + SetConnectionStateAsync(ConnectionState.Authenticated); + + var authResponse = new AuthResponse((int)response.UserId, null); + if (IsConnecting) + { + return authResponse; + } + + if (redirects >= VsrMaxLeaderRedirects) + { + _logger.LogWarning("Maximum leader redirections reached while registering, staying on {Address}", + _currentAddress); + + return authResponse; + } + + if (!await RedirectAsync(token)) + { + return authResponse; + } + + await ConnectAsync(false, token); + } + } + + /// + /// Whether the partitioning has to be resolved to an explicit partition id before the request is framed. + /// Only VSR needs this: the classic server still round-robins and hashes server-side. + /// + private bool NeedsClientSidePartitioning(Partitioning partitioning) + { + return _isVsr && partitioning.Kind != Enums.Partitioning.PartitionId; + } + + private async Task SendMessagesResolvedAsync(Identifier streamId, Identifier topicId, Partitioning partitioning, + IList messages, CancellationToken token) + { + var resolved = await ResolvePartitioningAsync(streamId, topicId, partitioning, token); + + await SendMessagesCoreAsync(streamId, topicId, resolved, AsSpan(messages), token); + } + + /// + /// Resolves balanced and message-key partitioning to an explicit partition id, mirroring + /// core/common/src/traits/binary_impls/messages.rs. The VSR broker never picks a partition, so + /// sending either kind on the wire would fail to route. + /// + private async ValueTask ResolvePartitioningAsync(Identifier streamId, Identifier topicId, + Partitioning partitioning, CancellationToken token) + { + var partitionCount = await TopicPartitionCountAsync(streamId, topicId, token); + if (partitionCount == 0) + { + throw VsrError.Exception(VsrError.TOPIC_ID_NOT_FOUND, + $"Topic {topicId} in stream {streamId} has no partitions to resolve the message to."); + } + + var partition = partitioning.Kind switch + { + Enums.Partitioning.Balanced => _groupState.NextBalancedPartition( + new TopicKey(streamId, topicId), partitionCount), + Enums.Partitioning.MessageKey => XxHash32.HashToUInt32(partitioning.Value) % partitionCount, + _ => throw VsrError.Exception(VsrError.FEATURE_UNAVAILABLE, + $"Partitioning kind {partitioning.Kind} cannot be resolved to a partition id.") + }; + + return Partitioning.PartitionId((int)partition); + } + + private async ValueTask TopicPartitionCountAsync(Identifier streamId, Identifier topicId, + CancellationToken token) + { + var key = new TopicKey(streamId, topicId); + if (_groupState.PartitionCount(key) is { } cached) + { + return cached; + } + + var topic = await GetTopicByIdAsync(streamId, topicId, token); + if (topic is null) + { + throw VsrError.Exception(VsrError.TOPIC_ID_NOT_FOUND, + $"Topic {topicId} was not found in stream {streamId}."); + } + + _groupState.SetPartitionCount(key, topic.PartitionsCount); + + return topic.PartitionsCount; + } + + /// + /// Polls one of the group member's assigned partitions, round-robin. A fence rejection - either the typed + /// error or the sentinel partition id an empty poll carries - re-syncs the assignment and retries once. + /// + private async Task PollGroupMessagesRentedAsync(Identifier streamId, Identifier topicId, + Consumer consumer, PollingStrategy pollingStrategy, uint count, bool autoCommit, CancellationToken token) + { + var key = new GroupKey(streamId, topicId, consumer.ConsumerId); + if (!_groupState.HasAssignment(key)) + { + await SyncGroupAssignmentAsync(streamId, topicId, consumer.ConsumerId, token); + } + + for (var attempt = 0; attempt < VsrGroupPollMaxAttempts; attempt++) + { + if (_groupState.NextGroupPartition(key) is not { } partitionId) + { + if (!_groupState.IsRegistered(key)) + { + throw VsrError.Exception(VsrError.CONSUMER_GROUP_MEMBER_NOT_FOUND, + $"Client is not a member of consumer group {consumer.ConsumerId} on topic {topicId}."); + } + + return EmptyPolledMessages; + } + + PolledMessagesRental? rental = null; + try + { + rental = await PollPartitionMessagesRentedAsync(streamId, topicId, partitionId, consumer, + pollingStrategy, count, autoCommit, token); + } + catch (IggyInvalidStatusCodeException e) when (e is + { + StatusCode: VsrError.CONSUMER_GROUP_PARTITION_NOT_OWNED, + FromServer: true + }) + { + // Both fence shapes - the typed error and the sentinel an empty poll carries - land on the same + // re-sync below. + } + + if (rental is not null) + { + if (rental.Messages.Count != 0 || rental.PartitionId != VsrResyncRequiredPartitionSentinel) + { + return rental; + } + + rental.Dispose(); + } + + _groupState.InvalidateAssignment(key); + await SyncGroupAssignmentAsync(streamId, topicId, consumer.ConsumerId, token); + } + + return EmptyPolledMessages; + } + + /// + /// Pulls the requesting member's assignment from the coordinator into the cache. An empty reply means the + /// client is not a member: the coordinator answers with an assignment header for any member, including + /// one holding zero partitions. + /// + private async Task SyncGroupAssignmentAsync(Identifier streamId, Identifier topicId, Identifier groupId, + CancellationToken token) + { + var message = TcpContracts.GetGroup(streamId, topicId, groupId); + var payload = new byte[4 + BufferSizes.INITIAL_BYTES_LENGTH + message.Length]; + TcpMessageStreamHelpers.CreatePayload(payload, message, CommandCodes.SYNC_CONSUMER_GROUP_CODE); + + using IMemoryOwner responseBuffer = await SendWithResponseAsync(payload, token); + + var key = new GroupKey(streamId, topicId, groupId); + if (responseBuffer.Memory.Length == 0) + { + // Deregistering is the only thing that observes a server-side removal (group deleted, member + // evicted); without it membership latches true and every later poll returns empty. + _groupState.DeregisterGroup(key); + + return; + } + + var assignment = SyncConsumerGroupAssignment.Decode(responseBuffer.Memory.Span); + _groupState.RegisterGroup(key, streamId, topicId, groupId); + _groupState.SetAssignment(key, assignment.Generation, assignment.Partitions); + } + + /// + /// Re-syncs every joined group so a widened assignment (a partition-count change, say) is picked up + /// without first hitting an ownership fence. One failing group is logged and skipped so it cannot stall + /// the rest. + /// + private async Task RefreshGroupAssignmentsAsync(CancellationToken token) + { + foreach (var group in _groupState.RegisteredGroups()) + { + try + { + await SyncGroupAssignmentAsync(group.StreamId, group.TopicId, group.GroupId, token); + } + catch (Exception e) when (e is not OperationCanceledException) + { + _logger.LogWarning(e, + "Failed to refresh the consumer group assignment for {StreamId}|{TopicId}|{GroupId}", + group.StreamId, group.TopicId, group.GroupId); + } + } + } + + + /// + /// Points the client at the current leader when it is not the node this connection is on, leaving the + /// stream closed for the caller to reconnect. The redirect budget is client-wide: the connect loop and + /// the transient failover path spend the same counter, and it is refunded as soon as a roster check + /// lands on the leader. + /// + private async Task RedirectAsync(CancellationToken token) + { + // The probe issues a request of its own, which takes the sending semaphore, so it cannot run under that + // lock. Only the commit below does. + var currentLeaderNode = await GetCurrentLeaderNodeAsync(token); + if (currentLeaderNode == null) + { + Interlocked.Exchange(ref _leaderRedirectCount, 0); + return false; + } + + var leaderAddress = ServerAddress.HostPort(currentLeaderNode.Ip, currentLeaderNode.Endpoints.Tcp); + if (ServerAddress.IsSame(leaderAddress, _currentAddress)) + { + Interlocked.Exchange(ref _leaderRedirectCount, 0); + return false; + } + + // Classic clients share this path and were never capped. Their only refund sites are the two roster + // checks above, so spending the VSR budget here would latch a classic client off the roster for good + // after a few genuine redirects. + if (_isVsr && Interlocked.Increment(ref _leaderRedirectCount) > VsrMaxLeaderRedirects) + { + _logger.LogWarning("Maximum leader redirections reached, continuing on {Address}", _currentAddress); + return false; + } + + _logger.LogInformation("Leader address changed. Trying to reconnect to {Address}", + leaderAddress); + + // The address move and the drop are one step: a reader that saw the new address but the old stream would + // find every later redirect short-circuited by the address check above, with no path back to the leader. + await _sendingSemaphore.WaitAsync(token); + try + { + _currentAddress = leaderAddress; + DropVsrConnectionLocked(_stream); + } + finally + { + _sendingSemaphore.Release(); + } + + return true; + } + + private async Task GetCurrentLeaderNodeAsync(CancellationToken token) + { + var leaderlessDeadline = Environment.TickCount64 + VsrLeaderlessWaitMs; + Interlocked.Increment(ref _leaderProbeDepth); + try + { + while (true) + { + var clusterMetadata = await GetClusterMetadataAsync(token); + if (clusterMetadata == null) + { + return null; + } + + if (clusterMetadata.Nodes.Count() == 1) + { + return null; + } + + var leaderNode = clusterMetadata.Nodes.FirstOrDefault(x => + x.Role == ClusterNodeRole.Leader && (!_isVsr || x.Status == ClusterNodeStatus.Healthy)); + if (leaderNode != null) + { + return leaderNode; + } + + if (!_isVsr) + { + throw new MissingLeaderException(); + } + + if (Environment.TickCount64 >= leaderlessDeadline) + { + _logger.LogWarning("No leader in the cluster metadata after {WaitMs} ms, continuing on {Address}", + VsrLeaderlessWaitMs, _currentAddress); + + return null; + } + + await Task.Delay(VsrLeaderlessPollMs, token); + } + } + // todo: change after error refactoring, error code 5 is for feature not supported + catch (IggyInvalidStatusCodeException e) when (e is { StatusCode: VsrError.FEATURE_UNAVAILABLE, FromServer: true }) + { + return null; + } + catch (Exception e) when (_isVsr && e is not OperationCanceledException) + { + _logger.LogWarning(e, "Failed to read the cluster metadata, continuing on {Address}", _currentAddress); + + return null; + } + finally + { + Interlocked.Decrement(ref _leaderProbeDepth); + } + } + + /// + /// Sends a consensus-framed request. The call sites still build the classic + /// [size u32][code u32][body] buffer, so the code is read back from it here and the body is written + /// right after the 256-byte consensus header - two writes, no concatenation. + /// + /// + /// One deadline bounds the whole request across transient replays AND leader failovers. Login and + /// register replay on this connection for the whole budget instead: the connect flow owns leader + /// redirection for the handshake, and reconnecting from underneath it would recurse. + /// + private async Task> SendRawVsrAsync(ReadOnlyMemory payload, CancellationToken token) + { + var code = (int)BinaryPrimitives.ReadUInt32LittleEndian(payload.Span.Slice(4, 4)); + ReadOnlyMemory body = payload[8..]; + var isLoginRegister = code is CommandCodes.LOGIN_REGISTER_CODE or CommandCodes.LOGIN_REGISTER_WITH_PAT_CODE; + var overallDeadline = Environment.TickCount64 + VsrRequestTimeoutMs; + var headerBuffer = ArrayPool.Shared.Rent(VsrHeader.HEADER_SIZE); + Memory header = headerBuffer.AsMemory(0, VsrHeader.HEADER_SIZE); + var requestEncoded = false; + TcpConnectionStream? lastStream = null; + + try + { + while (true) + { + var transientDeadline = isLoginRegister + ? overallDeadline + : Math.Min(overallDeadline, Environment.TickCount64 + VsrTransientFailoverCheckMs); + + var attempt = await SendVsrAttemptAsync(code, body, header, transientDeadline, overallDeadline, + token); + requestEncoded |= attempt.Encoded; + lastStream = attempt.Stream; + + if (attempt.Error is null) + { + // A roster read taken by RedirectAsync must not refund the budget it is about to be charged + // against, or the cap can never be reached. + if (Volatile.Read(ref _leaderRedirectCount) != 0 && Volatile.Read(ref _leaderProbeDepth) == 0) + { + Interlocked.Exchange(ref _leaderRedirectCount, 0); + } + + return attempt.Response!; + } + + if (attempt.Error is IggyInvalidStatusCodeException + { StatusCode: VsrError.TRANSIENT_NOT_ACCEPTED, FromServer: true } + && !isLoginRegister + && Environment.TickCount64 < overallDeadline) + { + if (await RedirectAsync(token)) + { + await ConnectAsync(token); + } + + continue; + } + + if (attempt.Error is VsrSessionEvictedException evicted) + { + if (attempt.RequestStarted && !VsrOperations.IsReplaySafeRead(code, isLoginRegister, body.Span)) + { + throw new VsrRequestOutcomeUnknownException(evicted); + } + + ExceptionDispatchInfo.Throw(evicted.Verdict); + } + + if (attempt.RequestStarted + && !VsrOperations.IsReplaySafeRead(code, isLoginRegister, body.Span) + && !IsDefinitiveVerdict(attempt.Error)) + { + throw new VsrRequestOutcomeUnknownException(attempt.Error); + } + + ExceptionDispatchInfo.Throw(attempt.Error); + } + } + catch (OperationCanceledException) + { + if (requestEncoded) + { + await DropVsrConnectionAsync(lastStream); + } + + throw; + } + finally + { + ArrayPool.Shared.Return(headerBuffer); + } + } + + /// + /// Whether the failure carries the server's verdict on this request. A lost connection, a reply frame + /// the client refused or discarded, and a NOT_COMMITTED that outlived its replay deadline all leave the + /// outcome of a request the server may still commit unknowable. + /// + private static bool IsDefinitiveVerdict(Exception error) + { + return error is IggyInvalidStatusCodeException + { + FromServer: true, + StatusCode: not VsrError.TRANSIENT_NOT_COMMITTED + }; + } + + /// + /// One attempt on the current connection: encode the header into , write the + /// frame, and replay it - same session, same request id - while the server answers transiently. + /// + private async ValueTask SendVsrAttemptAsync(int code, ReadOnlyMemory body, Memory header, + long transientDeadline, long readDeadline, CancellationToken token) + { + await _sendingSemaphore.WaitAsync(token); + + var encoded = false; + var requestStarted = false; + byte[]? frameBuffer = null; + + // Read the stream once for the whole attempt. Nothing may swap the field without the sending lock this + // call holds, so the frame and its reply cannot be split across two sockets, and a teardown after the + // lock is gone can tell this connection from a replacement a reconnect installed since. + TcpConnectionStream stream = _stream; + try + { + // A small request goes out as one write. Two writes cost two syscalls and, with Nagle disabled, two + // TCP segments (two TLS records when encrypted) for what the reference encoder sends as a single + // contiguous frame. Above the threshold the copy costs more than the extra write saves. Renting + // inside the try keeps the semaphore paired with its release even if the pool throws. + if (body.Length <= VsrContiguousFrameLimit) + { + frameBuffer = ArrayPool.Shared.Rent(VsrHeader.HEADER_SIZE + body.Length); + } + + VsrHeader.EncodeRequestHeader(header.Span, _consensusSession, code, body.Span); + + encoded = true; + + var frame = Memory.Empty; + if (frameBuffer is not null) + { + frame = frameBuffer.AsMemory(0, VsrHeader.HEADER_SIZE + body.Length); + header.CopyTo(frame); + body.CopyTo(frame[VsrHeader.HEADER_SIZE..]); + } + + while (true) + { + try + { + // Everything that fails without reaching the socket has to fail before this point: past it a + // failure is reported as an outcome the server alone knows, which for a replicated write + // tells the caller its request may have committed twice. + token.ThrowIfCancellationRequested(); + requestStarted = true; + + if (frameBuffer is not null) + { + await stream.SendAsync(frame, token); + } + else + { + await stream.SendAsync(header, token); + await stream.SendAsync(body, token); + } + + await stream.FlushAsync(token); + + IMemoryOwner response = await ReadVsrReplyAsync(stream, readDeadline, token); + + return VsrAttempt.Ok(response, stream); + } + catch (IggyInvalidStatusCodeException e) when (IsReplayableTransient(e, transientDeadline, + readDeadline)) + { + var governingDeadline = e.StatusCode == VsrError.TRANSIENT_NOT_COMMITTED + ? readDeadline + : transientDeadline; + var remaining = governingDeadline - Environment.TickCount64; + await Task.Delay((int)Math.Clamp(remaining, 0, VsrTransientRetryIntervalMs), token); + } + catch (Exception e) when (IsConnectionException(e)) + { + DropVsrConnectionLocked(stream); + + return VsrAttempt.Failed(encoded, e, requestStarted, stream); + } + catch (OperationCanceledException e) + { + DropVsrConnectionLocked(stream); + + return VsrAttempt.Failed(encoded, e, requestStarted, stream); + } + catch (Exception e) + { + return VsrAttempt.Failed(encoded, e, requestStarted, stream); + } + } + } + catch (OperationCanceledException e) + { + if (encoded) + { + DropVsrConnectionLocked(stream); + } + + return VsrAttempt.Failed(encoded, e, requestStarted, stream); + } + catch (Exception e) + { + return VsrAttempt.Failed(encoded, e, requestStarted, stream); + } + finally + { + if (frameBuffer is not null) + { + ArrayPool.Shared.Return(frameBuffer); + } + + _sendingSemaphore.Release(); + } + } + + private async Task> ReadVsrReplyAsync(TcpConnectionStream stream, long readDeadline, + CancellationToken token) + { + var remaining = readDeadline - Environment.TickCount64; + if (remaining <= 0) + { + throw new IOException($"Timed out after {VsrRequestTimeoutMs} ms waiting for a consensus reply."); + } + + // One timer for the whole reply: the deadline covers the frame, not each partial read, so a per-read + // source would both re-arm the budget and allocate a timer per socket read. + using var readCancellation = CancellationTokenSource.CreateLinkedTokenSource(token); + readCancellation.CancelAfter((int)Math.Min(remaining, VsrRequestTimeoutMs)); + + await ReadExactVsrAsync(stream, _vsrReplyHeaderBuffer, readCancellation.Token, token); + + var command = VsrHeader.PeekCommand(_vsrReplyHeaderBuffer); + if (command == Command2.Eviction) + { + var eviction = VsrHeader.ReadEviction(_vsrReplyHeaderBuffer); + _logger.LogWarning("Consensus session evicted by the server: {Reason}", eviction.Reason); + DropVsrConnectionLocked(stream); + + throw new VsrSessionEvictedException(VsrReplyDecoder.ToException(eviction)); + } + + if (command != Command2.Reply) + { + // Neither a reply nor an eviction: this frame was never an answer to the outstanding request, so + // whatever the peer does send for it would be read as the next request's reply and handed to the + // wrong caller. The size field of a frame the client cannot model is no basis for resynchronising. + DropVsrConnectionLocked(stream); + + throw VsrError.Exception(VsrError.INVALID_COMMAND, + $"Unexpected consensus frame {command} on a client connection."); + } + + int bodySize; + try + { + bodySize = VsrReplyDecoder.ReadBodySize(_vsrReplyHeaderBuffer); + if (VsrHeader.HEADER_SIZE + (long)bodySize > _configuration.MaxResponseFrameSize) + { + throw VsrError.Exception(VsrError.INVALID_COMMAND, + $"Reply frame of {VsrHeader.HEADER_SIZE + bodySize} bytes exceeds the configured maximum of " + + $"{_configuration.MaxResponseFrameSize} bytes."); + } + } + catch + { + // An announced size the client refuses to read - undersized, oversized - leaves the body on the + // wire, so the stream no longer sits on a frame boundary and the next reply would decode body bytes + // as a header. + DropVsrConnectionLocked(stream); + + throw; + } + + if (bodySize == 0) + { + VsrReplyDecoder.Decode(_vsrReplyHeaderBuffer, ReadOnlyMemory.Empty); + + return EmptyMemoryOwner.Instance; + } + + var buffer = ArrayPool.Shared.Rent(bodySize); + try + { + await ReadExactVsrAsync(stream, buffer.AsMemory(0, bodySize), readCancellation.Token, token); + ReadOnlyMemory decoded = VsrReplyDecoder.Decode(_vsrReplyHeaderBuffer, buffer.AsMemory(0, bodySize)); + if (decoded.IsEmpty) + { + ArrayPool.Shared.Return(buffer); + + return EmptyMemoryOwner.Instance; + } + + // The decoded payload is always a suffix of the body - the funnel only strips the leading + // committed result section. + return new PooledMemoryOwner(buffer, bodySize - decoded.Length, decoded.Length); + } + catch + { + ArrayPool.Shared.Return(buffer); + throw; + } + } + + private async ValueTask ReadExactVsrAsync(TcpConnectionStream stream, Memory buffer, CancellationToken readToken, + CancellationToken token) + { + var totalRead = 0; + while (totalRead < buffer.Length) + { + int readBytes; + try + { + readBytes = await stream.ReadAsync(buffer[totalRead..], readToken); + } + catch (OperationCanceledException) when (!token.IsCancellationRequested) + { + throw new IOException($"Timed out after {VsrRequestTimeoutMs} ms waiting for a consensus reply."); + } + + if (readBytes == 0) + { + throw new IggyZeroBytesException(); + } + + totalRead += readBytes; + } + } + + /// + /// Drops the consensus session and the group state scoped to it. Consumer-group assignments are fenced by + /// a generation the coordinator tracks per session, so carrying them into a new session would fence every + /// poll until the first re-sync. The balanced cursors and the partition counts survive: neither is bound + /// to a session, and dropping them costs a metadata round trip per topic on the next produce. + /// + private void ResetConsensusSession() + { + _consensusSession.Reset(); + _groupState.ClearSessionScoped(); + } + + /// + /// Resets the session on behalf of a caller that does not hold the sending lock, so no request can be + /// encoding against the identity while it is re-armed. + /// + private async ValueTask ResetConsensusSessionAsync() + { + // Taking a disposed semaphore would replace the failure the caller is about to rethrow with an + // ObjectDisposedException, and a disposed client has nothing left to fence. Dispose can still land + // between the check and the wait, so the wait itself has to tolerate it. + if (_disposed || !await TryEnterSendingSemaphoreAsync()) + { + return; + } + + try + { + ResetConsensusSession(); + } + finally + { + _sendingSemaphore.Release(); + } + } + + /// + /// Takes the sending lock for a teardown that must not fail. Returns false once the client is disposed: + /// the caller is unwinding an earlier failure and has nothing left to fence. + /// + private async ValueTask TryEnterSendingSemaphoreAsync() + { + try + { + await _sendingSemaphore.WaitAsync(CancellationToken.None); + return true; + } + catch (ObjectDisposedException) + { + return false; + } + } + + /// + /// Drops the connection along with the session. A late or half-read reply would desync the framing of the + /// next request, so the stream cannot be reused. The caller must hold , + /// which owns every write to . + /// + /// + /// The connection the caller was using. A reconnect that completed in the meantime already closed it and + /// re-armed the session, so dropping anything but the live one would tear down a healthy replacement. + /// + private void DropVsrConnectionLocked(TcpConnectionStream? stream) + { + if (!ReferenceEquals(_stream, stream)) + { + return; + } + + ResetConsensusSession(); + _stream?.Close(); + SetConnectionStateAsync(ConnectionState.Disconnected); + } + + /// Drops the connection on behalf of a caller that no longer holds the sending lock. + private async ValueTask DropVsrConnectionAsync(TcpConnectionStream? stream) + { + // Dispose already closed the stream, and taking a disposed semaphore here would replace the + // cancellation the caller is about to rethrow with an ObjectDisposedException. Dispose can still land + // between the check and the wait, so the wait itself has to tolerate it. + // The request this drop belongs to was cancelled; the drop itself still has to run to completion. + if (_disposed || !await TryEnterSendingSemaphoreAsync()) + { + return; + } + + try + { + DropVsrConnectionLocked(stream); + } + finally + { + _sendingSemaphore.Release(); + } + } + + private static bool IsReplayableTransient(IggyInvalidStatusCodeException error, long transientDeadline, + long readDeadline) + { + if (!error.FromServer) + { + return false; + } + + return error.StatusCode switch + { + VsrError.TRANSIENT_NOT_COMMITTED => Environment.TickCount64 < readDeadline, + VsrError.TRANSIENT_NOT_ACCEPTED => Environment.TickCount64 < transientDeadline, + _ => false + }; + } + + /// Outcome of one call on the current connection. + /// Whether the header was encoded, i.e. whether a request id may have been consumed. + /// The decoded reply payload, non-null exactly when is null. + /// The failure that ended the attempt, or null on success. + /// + /// Whether any byte of the frame was written, which makes the server-side outcome unknowable on failure. + /// + /// + /// The connection the attempt ran on, so a caller that drops it after releasing the sending lock can tell + /// its own connection from a replacement a reconnect installed since. + /// + private readonly record struct VsrAttempt( + bool Encoded, + IMemoryOwner? Response, + Exception? Error, + bool RequestStarted, + TcpConnectionStream? Stream) + { + public static VsrAttempt Ok(IMemoryOwner response, TcpConnectionStream stream) + { + return new VsrAttempt(true, response, null, true, stream); + } + + public static VsrAttempt Failed(bool encoded, Exception error, bool requestStarted, + TcpConnectionStream? stream) + { + return new VsrAttempt(encoded, null, error, requestStarted, stream); + } + } + + /// Owns a pooled buffer while exposing only the decoded payload slice inside it. + internal sealed class PooledMemoryOwner(byte[] buffer, int start, int length) : IMemoryOwner + { + private int _disposed; + + public Memory Memory => buffer.AsMemory(start, length); + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 0) + { + ArrayPool.Shared.Return(buffer); + } + } + } +} diff --git a/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.cs b/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.cs index a2071c3513..4591a6eaa3 100644 --- a/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.cs +++ b/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.cs @@ -35,15 +35,17 @@ using Apache.Iggy.Mappers; using Apache.Iggy.Messages; using Apache.Iggy.Utils; +using Apache.Iggy.Vsr; using Microsoft.Extensions.Logging; using Partitioning = Apache.Iggy.Kinds.Partitioning; namespace Apache.Iggy.IggyClient.Implementations; /// -/// A TCP client for interacting with the Iggy server. +/// A TCP client for interacting with the Iggy server. The consensus (VSR) framing, leader redirection and +/// register handshake live in TcpMessageStream.Vsr.cs. /// -public sealed class TcpMessageStream : IIggyClient +public sealed partial class TcpMessageStream : IIggyClient { private const int InvalidCommandStatus = 3; @@ -58,20 +60,32 @@ public sealed class TcpMessageStream : IIggyClient private readonly IggyClientConfigurator _configuration; private readonly EventAggregator _connectionEvents; + private readonly SemaphoreSlim _connectGate = new(1, 1); private readonly SemaphoreSlim _connectionSemaphore; private readonly ILogger _logger; private readonly byte[] _responseHeaderBuffer = new byte[BufferSizes.EXPECTED_RESPONSE_SIZE]; private readonly SemaphoreSlim _sendingSemaphore; private string _currentAddress = string.Empty; private X509Certificate2Collection _customCaStore = []; - private bool _isConnecting; + private volatile bool _disposed; + private int _isConnecting; private DateTimeOffset _lastConnectionTime; - private ConnectionState _state = ConnectionState.Disconnected; + private int _leaderRedirectCount; + + // Both are written by the connect and redirect paths, which do not hold the sending semaphore the request + // paths read them under, so they are accessed through Interlocked rather than as plain fields. Losing an + // update to the skip flag leaves a connection reporting Connected that never authenticated; losing one to + // the redirect counter over- or under-spends the redirect budget. + private int _skipAutoLoginOnce; + private volatile ConnectionState _state = ConnectionState.Disconnected; private TcpConnectionStream _stream = null!; + private bool IsConnecting => Volatile.Read(ref _isConnecting) != 0; + internal TcpMessageStream(IggyClientConfigurator configuration, ILoggerFactory loggerFactory) { _configuration = configuration; + _isVsr = configuration.WireProtocol == WireProtocol.Vsr; _logger = loggerFactory.CreateLogger(); _sendingSemaphore = new SemaphoreSlim(1, 1); _connectionSemaphore = new SemaphoreSlim(1, 1); @@ -84,10 +98,14 @@ internal TcpMessageStream(IggyClientConfigurator configuration, ILoggerFactory l /// public void Dispose() { + _disposed = true; _stream?.Close(); _stream?.Dispose(); + + SetConnectionStateAsync(ConnectionState.Disconnected); _sendingSemaphore.Dispose(); _connectionSemaphore.Dispose(); + _connectGate.Dispose(); _connectionEvents.Clear(); } @@ -273,6 +291,7 @@ public async Task DeleteTopicAsync(Identifier streamId, Identifier topicId, Canc TcpMessageStreamHelpers.CreatePayload(payload, message, CommandCodes.DELETE_TOPIC_CODE); await SendAckAsync(payload, token); + _groupState.InvalidatePartitionCount(new TopicKey(streamId, topicId)); } /// @@ -290,6 +309,11 @@ public async Task PurgeTopicAsync(Identifier streamId, Identifier topicId, Cance public Task SendMessagesAsync(Identifier streamId, Identifier topicId, Partitioning partitioning, IList messages, CancellationToken token = default) { + if (NeedsClientSidePartitioning(partitioning)) + { + return SendMessagesResolvedAsync(streamId, topicId, partitioning, messages, token); + } + return SendMessagesCoreAsync(streamId, topicId, partitioning, AsSpan(messages), token); } @@ -297,6 +321,11 @@ public Task SendMessagesAsync(Identifier streamId, Identifier topicId, Partition public Task SendMessagesAsync(Identifier streamId, Identifier topicId, Partitioning partitioning, Message message, CancellationToken token = default) { + if (NeedsClientSidePartitioning(partitioning)) + { + return SendMessagesResolvedAsync(streamId, topicId, partitioning, [message], token); + } + ReadOnlySpan span = [message]; return SendMessagesCoreAsync(streamId, topicId, partitioning, span, token); } @@ -324,38 +353,23 @@ public async Task PollMessagesAsync(Identifier streamId, Identif } /// - public async Task PollMessagesRentedAsync(Identifier streamId, Identifier topicId, + public Task PollMessagesRentedAsync(Identifier streamId, Identifier topicId, uint? partitionId, Consumer consumer, PollingStrategy pollingStrategy, uint count, bool autoCommit, CancellationToken token = default) { ThrowIfAutoCommitWithEncryptor(autoCommit); - var messageBufferSize = CalculateMessageBufferSize(streamId, topicId, consumer); - var payloadBufferSize = CalculatePayloadBufferSize(messageBufferSize); - var payload = ArrayPool.Shared.Rent(payloadBufferSize); - IMemoryOwner? responseBuffer = null; - - try + // Under VSR the broker routes explicit partitions only, so a group poll picks one of the member's + // assigned partitions client-side. + if (_isVsr && consumer.Type == ConsumerType.ConsumerGroup && partitionId is null) { - TcpContracts.GetMessages(payload.AsSpan().Slice(8, messageBufferSize), consumer, streamId, - topicId, pollingStrategy, count, autoCommit, partitionId); - BinaryPrimitives.WriteInt32LittleEndian(payload.AsSpan()[..4], messageBufferSize + 4); - BinaryPrimitives.WriteInt32LittleEndian(payload.AsSpan()[4..8], CommandCodes.POLL_MESSAGES_CODE); - - responseBuffer = await SendWithResponseAsync(payload.AsMemory(0, payloadBufferSize), token); - return BinaryMapper.MapRentedMessages(responseBuffer.Memory, responseBuffer, - _configuration.MessageEncryptor); - } - catch - { - responseBuffer?.Dispose(); - throw; - } - finally - { - ArrayPool.Shared.Return(payload); + return PollGroupMessagesRentedAsync(streamId, topicId, consumer, pollingStrategy, count, autoCommit, + token); } + + return PollPartitionMessagesRentedAsync(streamId, topicId, partitionId, consumer, pollingStrategy, count, + autoCommit, token); } /// @@ -462,6 +476,7 @@ public async Task DeleteConsumerGroupAsync(Identifier streamId, Identifier topic TcpMessageStreamHelpers.CreatePayload(payload, message, CommandCodes.DELETE_CONSUMER_GROUP_CODE); await SendAckAsync(payload, token); + _groupState.DeregisterGroup(new GroupKey(streamId, topicId, groupId)); } /// @@ -473,6 +488,10 @@ public async Task JoinConsumerGroupAsync(Identifier streamId, Identifier topicId TcpMessageStreamHelpers.CreatePayload(payload, message, CommandCodes.JOIN_CONSUMER_GROUP_CODE); await SendAckAsync(payload, token); + + // A join rebalances the group, so whatever this client holds for it is a generation behind and every + // poll under it would be fenced until the first re-sync. + _groupState.InvalidateAssignment(new GroupKey(streamId, topicId, groupId)); } /// @@ -484,6 +503,7 @@ public async Task LeaveConsumerGroupAsync(Identifier streamId, Identifier topicI TcpMessageStreamHelpers.CreatePayload(payload, message, CommandCodes.LEAVE_CONSUMER_GROUP_CODE); await SendAckAsync(payload, token); + _groupState.DeregisterGroup(new GroupKey(streamId, topicId, groupId)); } /// @@ -495,6 +515,7 @@ public async Task DeletePartitionsAsync(Identifier streamId, Identifier topicId, TcpMessageStreamHelpers.CreatePayload(payload, message, CommandCodes.DELETE_PARTITIONS_CODE); await SendAckAsync(payload, token); + _groupState.InvalidatePartitionCount(new TopicKey(streamId, topicId)); } /// @@ -506,6 +527,7 @@ public async Task CreatePartitionsAsync(Identifier streamId, Identifier topicId, TcpMessageStreamHelpers.CreatePayload(payload, message, CommandCodes.CREATE_PARTITIONS_CODE); await SendAckAsync(payload, token); + _groupState.InvalidatePartitionCount(new TopicKey(streamId, topicId)); } /// @@ -578,6 +600,11 @@ public async Task PingAsync(CancellationToken token = default) TcpMessageStreamHelpers.CreatePayload(payload, message, CommandCodes.PING_CODE); await SendAckAsync(payload, token); + + if (_isVsr) + { + await RefreshGroupAssignmentsAsync(token); + } } /// @@ -611,7 +638,17 @@ public async Task SendBinaryRequestAsync(uint code, byte[] payload, Canc } /// - public async Task ConnectAsync(CancellationToken token = default) + public Task ConnectAsync(CancellationToken token = default) + { + return ConnectAsync(true, token); + } + + /// + /// Connects, optionally without the configured auto login. A caller that authenticates itself right + /// after the connect passes false, so the connect does not spend a round trip on credentials the + /// caller is about to replace. + /// + private async Task ConnectAsync(bool autoLogin, CancellationToken token) { if (_state is ConnectionState.Connected or ConnectionState.Authenticating @@ -621,20 +658,29 @@ or ConnectionState.Authenticating return; } - if (_lastConnectionTime != DateTimeOffset.MinValue) - { - await Task.Delay(_configuration.ReconnectionSettings.InitialDelay, token); - } - - SetConnectionStateAsync(ConnectionState.Connecting); - _isConnecting = true; + await _connectGate.WaitAsync(token); + Interlocked.Exchange(ref _isConnecting, 1); try { - await TryEstablishConnectionAsync(token); + if (_state is ConnectionState.Connected + or ConnectionState.Authenticating + or ConnectionState.Authenticated) + { + return; + } + + if (_lastConnectionTime != DateTimeOffset.MinValue) + { + await Task.Delay(_configuration.ReconnectionSettings.InitialDelay, token); + } + + SetConnectionStateAsync(ConnectionState.Connecting); + await TryEstablishConnectionAsync(autoLogin, token); } finally { - _isConnecting = false; + Interlocked.Exchange(ref _isConnecting, 0); + _connectGate.Release(); } } @@ -775,8 +821,14 @@ public async Task ChangePasswordAsync(Identifier userId, string currentPassword, throw new NotConnectedException(); } + if (_isVsr) + { + return await LoginRegisterAsync(CommandCodes.LOGIN_REGISTER_CODE, + LoginRegister.Serialize(userName, password), token); + } + // TODO: Add binary protocol version - var message = TcpContracts.LoginUser(userName, password, SdkVersion.Value, "csharp-sdk"); + var message = TcpContracts.LoginUser(userName, password, SdkVersion.Value, LoginRegister.SDK_NAME); var payload = new byte[4 + BufferSizes.INITIAL_BYTES_LENGTH + message.Length]; TcpMessageStreamHelpers.CreatePayload(payload, message, CommandCodes.LOGIN_USER_CODE); @@ -791,7 +843,7 @@ public async Task ChangePasswordAsync(Identifier userId, string currentPassword, var userId = BinaryPrimitives.ReadInt32LittleEndian(responseBuffer.Memory.Span[..responseBuffer.Memory.Length]); SetConnectionStateAsync(ConnectionState.Authenticated); - if (await RedirectAsync(token)) + if (!IsConnecting && await RedirectAsync(token)) { await ConnectAsync(token); return await LoginUserAsync(userName, password, token); @@ -808,7 +860,22 @@ public async Task LogoutUserAsync(CancellationToken token = default) var payload = new byte[4 + BufferSizes.INITIAL_BYTES_LENGTH + message.Length]; TcpMessageStreamHelpers.CreatePayload(payload, message, CommandCodes.LOGOUT_USER_CODE); - await SendAckAsync(payload, token); + try + { + await SendAckAsync(payload, token); + } + finally + { + if (_isVsr) + { + await ResetConsensusSessionAsync(); + + if (_state == ConnectionState.Authenticated) + { + SetConnectionStateAsync(ConnectionState.Connected); + } + } + } } /// @@ -860,6 +927,12 @@ public async Task DeletePersonalAccessTokenAsync(string name, CancellationToken /// public async Task LoginWithPersonalAccessTokenAsync(string token, CancellationToken ct = default) { + if (_isVsr) + { + return await LoginRegisterAsync(CommandCodes.LOGIN_REGISTER_WITH_PAT_CODE, + LoginRegister.SerializeWithPersonalAccessToken(token), ct); + } + var message = TcpContracts.LoginWithPersonalAccessToken(token); var payload = new byte[4 + BufferSizes.INITIAL_BYTES_LENGTH + message.Length]; TcpMessageStreamHelpers.CreatePayload(payload, message, CommandCodes.LOGIN_WITH_PERSONAL_ACCESS_TOKEN_CODE); @@ -876,7 +949,7 @@ public async Task DeletePersonalAccessTokenAsync(string name, CancellationToken SetConnectionStateAsync(ConnectionState.Authenticated); - if (await RedirectAsync(ct)) + if (!IsConnecting && await RedirectAsync(ct)) { await ConnectAsync(ct); return await LoginWithPersonalAccessTokenAsync(token, ct); @@ -885,6 +958,43 @@ public async Task DeletePersonalAccessTokenAsync(string name, CancellationToken return new AuthResponse(userId, null); } + private async Task PollPartitionMessagesRentedAsync(Identifier streamId, Identifier topicId, + uint? partitionId, Consumer consumer, PollingStrategy pollingStrategy, uint count, bool autoCommit, + CancellationToken token) + { + var messageBufferSize = CalculateMessageBufferSize(streamId, topicId, consumer); + var payloadBufferSize = CalculatePayloadBufferSize(messageBufferSize); + var payload = ArrayPool.Shared.Rent(payloadBufferSize); + IMemoryOwner? responseBuffer = null; + + try + { + TcpContracts.GetMessages(payload.AsSpan().Slice(8, messageBufferSize), consumer, streamId, + topicId, pollingStrategy, count, autoCommit, partitionId); + BinaryPrimitives.WriteInt32LittleEndian(payload.AsSpan()[..4], messageBufferSize + 4); + BinaryPrimitives.WriteInt32LittleEndian(payload.AsSpan()[4..8], CommandCodes.POLL_MESSAGES_CODE); + + responseBuffer = await SendWithResponseAsync(payload.AsMemory(0, payloadBufferSize), token); + if (responseBuffer.Memory.Length == 0) + { + responseBuffer.Dispose(); + return EmptyPolledMessages; + } + + return BinaryMapper.MapRentedMessages(responseBuffer.Memory, responseBuffer, + _configuration.MessageEncryptor); + } + catch + { + responseBuffer?.Dispose(); + throw; + } + finally + { + ArrayPool.Shared.Return(payload); + } + } + // Server-side autoCommit commits the batch offset before the client decrypts, so a decryption failure // would permanently skip the whole batch. IggyConsumer guards this too, but the raw poll is public and // bypasses that path. Opt out via IggyClientConfigurator.AllowAutoCommitWithEncryptor. @@ -962,59 +1072,101 @@ private static int FillSendMessagesPayload(Span buffer, int maxMessageBuff return messageBufferSize; } - private async Task TryEstablishConnectionAsync(CancellationToken token) + private async Task TryEstablishConnectionAsync(bool autoLogin, CancellationToken token) { var retryCount = 0; + var redirects = 0; var delay = _configuration.ReconnectionSettings.InitialDelay; do { - // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract - _stream?.Close(); - _stream?.Dispose(); + // The sending semaphore owns every write to _stream, so an in-flight request never observes the + // field changing between its write and its reply. + await _sendingSemaphore.WaitAsync(token); + try + { + _stream?.Dispose(); + + ResetConsensusSession(); + } + finally + { + _sendingSemaphore.Release(); + } if (string.IsNullOrEmpty(_currentAddress)) { _currentAddress = _configuration.BaseAddress; } - var urlPortSplitter = _currentAddress.Split(":"); - if (urlPortSplitter.Length > 2) + if (!ServerAddress.TryParse(_currentAddress, out var host, out var port)) { throw new InvalidBaseAddressException(); } + Socket? socket = null; try { - var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + socket = new Socket(ServerAddress.AddressFamilyOf(host), SocketType.Stream, ProtocolType.Tcp); socket.SendBufferSize = _configuration.SendBufferSize; socket.ReceiveBufferSize = _configuration.ReceiveBufferSize; - await socket.ConnectAsync(urlPortSplitter[0], int.Parse(urlPortSplitter[1]), token); + // The protocol is request/reply on both wire protocols, so a write is always the last one before + // the client blocks on the answer and Nagle has nothing to coalesce it with - it only delays the + // trailing segment of a large request until the previous one is acked. + socket.NoDelay = true; + + await socket.ConnectAsync(host, port, token); socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true); socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, 5); - SetConnectionStateAsync(ConnectionState.Connected); - _lastConnectionTime = DateTimeOffset.UtcNow; - - _stream = _configuration.TlsSettings.Enabled switch + TcpConnectionStream connectionStream = _configuration.TlsSettings.Enabled switch { true => await CreateSslStreamAndAuthenticate(socket, _configuration.TlsSettings), false => new TcpConnectionStream(new NetworkStream(socket, true)) }; - if (_configuration.AutoLoginSettings.Enabled) + await _sendingSemaphore.WaitAsync(token); + try + { + _stream = connectionStream; + } + finally + { + _sendingSemaphore.Release(); + } + + SetConnectionStateAsync(ConnectionState.Connected); + _lastConnectionTime = DateTimeOffset.UtcNow; + + socket = null; + + if (_isVsr && await RedirectAsync(token)) + { + await BackoffOrThrowAsync(); + continue; + } + + if (autoLogin && _configuration.AutoLoginSettings.Enabled && !ConsumeSkipAutoLogin()) { _logger.LogInformation("Auto login enabled. Trying to login with credentials: {Username}", _configuration.AutoLoginSettings.Username); await LoginUserAsync(_configuration.AutoLoginSettings.Username, _configuration.AutoLoginSettings.Password, token); + + if (await RedirectAsync(token)) + { + await BackoffOrThrowAsync(); + continue; + } } break; } catch (Exception e) { + socket?.Dispose(); + _logger.LogError(e, "Failed to connect"); if (!_configuration.ReconnectionSettings.Enabled || @@ -1045,37 +1197,38 @@ await LoginUserAsync(_configuration.AutoLoginSettings.Username, await Task.Delay(delay, token); } } while (true); - } - private async Task GetCurrentLeaderNodeAsync(CancellationToken token) - { - try + // A redirect restarts the loop without passing through the catch, so it spends no retry and waits for + // nothing. Its own budget rather than the reconnection one: following the roster to the leader is how a + // VSR connect succeeds, and it has to work with reconnection turned off. + async Task BackoffOrThrowAsync() { - var clusterMetadata = await GetClusterMetadataAsync(token); - if (clusterMetadata == null) - { - return null; - } - - // Single-node cluster (clustering disabled) - no redirection needed - if (clusterMetadata.Nodes.Count() == 1) - { - return null; - } - - var leaderNode = clusterMetadata.Nodes.FirstOrDefault(x => x.Role == ClusterNodeRole.Leader); - if (leaderNode == null) + if (++redirects > VsrMaxLeaderRedirects) { + SetConnectionStateAsync(ConnectionState.Disconnected); throw new MissingLeaderException(); } - return leaderNode; + _logger.LogInformation("Following leader redirect {Redirect} to {Address}", redirects, _currentAddress); + + await Task.Delay(delay, token); } - // todo: change after error refactoring, error code 5 is for feature not supported - catch (IggyInvalidStatusCodeException e) when (e.StatusCode == 5) + } + + /// + /// Whether this connect was triggered by a login or register request that will re-authenticate itself, + /// so the auto-login must sit this one out. Consumes the flag. + /// + private bool ConsumeSkipAutoLogin() + { + if (Interlocked.Exchange(ref _skipAutoLoginOnce, 0) == 0) { - return null; + return false; } + + _logger.LogInformation("Skipping auto login for a replayed register request"); + + return true; } private async Task CreateSslStreamAndAuthenticate(Socket socket, TlsSettings tlsSettings) @@ -1104,7 +1257,7 @@ private async Task> SendWithResponseAsync(ReadOnlyMemory> HandleReconnectionAsync(ReadOnlyMemory> SendRawAsync(ReadOnlyMemory payload, CancellationToken token) + private Task> SendRawAsync(ReadOnlyMemory payload, CancellationToken token) { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_state is ConnectionState.Disconnected or ConnectionState.Connecting) { throw new NotConnectedException(); } + return _isVsr ? SendRawVsrAsync(payload, token) : SendRawClassicAsync(payload, token); + } + + /// + /// Sends a classic-framed payload ([size u32][code u32][body]) and reads the + /// [status u32][length u32] reply header plus its body. + /// + private async Task> SendRawClassicAsync(ReadOnlyMemory payload, CancellationToken token) + { await _sendingSemaphore.WaitAsync(token); try @@ -1186,7 +1350,7 @@ var readBytes if (response.Length == 0) { throw new IggyInvalidStatusCodeException(response.Status, - $"Invalid response status code: {response.Status}"); + $"Invalid response status code: {response.Status}", true); } @@ -1357,30 +1521,6 @@ private bool RemoteCertificateValidationCallback(object sender, X509Certificate? return false; } - private async Task RedirectAsync(CancellationToken token) - { - var currentLeaderNode = await GetCurrentLeaderNodeAsync(token); - if (currentLeaderNode == null) - { - return false; - } - - var leaderAddress = $"{currentLeaderNode.Ip}:{currentLeaderNode.Endpoints.Tcp}"; - if (leaderAddress == _currentAddress) - { - return false; - } - - _currentAddress = leaderAddress; - - _logger.LogInformation("Leader address changed. Trying to reconnect to {Address}", - leaderAddress); - - _stream.Close(); - SetConnectionStateAsync(ConnectionState.Disconnected); - return true; - } - internal sealed class EmptyMemoryOwner : IMemoryOwner { public static readonly EmptyMemoryOwner Instance = new(); diff --git a/foreign/csharp/Iggy_SDK/Iggy_SDK.csproj b/foreign/csharp/Iggy_SDK/Iggy_SDK.csproj index 776883aacd..d45e422114 100644 --- a/foreign/csharp/Iggy_SDK/Iggy_SDK.csproj +++ b/foreign/csharp/Iggy_SDK/Iggy_SDK.csproj @@ -69,6 +69,7 @@ + diff --git a/foreign/csharp/Iggy_SDK/Publishers/BackgroundMessageProcessor.cs b/foreign/csharp/Iggy_SDK/Publishers/BackgroundMessageProcessor.cs index 969118e408..cae62bfb5d 100644 --- a/foreign/csharp/Iggy_SDK/Publishers/BackgroundMessageProcessor.cs +++ b/foreign/csharp/Iggy_SDK/Publishers/BackgroundMessageProcessor.cs @@ -17,6 +17,7 @@ using System.Threading.Channels; using Apache.Iggy.Enums; +using Apache.Iggy.Exceptions; using Apache.Iggy.IggyClient; using Apache.Iggy.Messages; using Apache.Iggy.Utils; @@ -55,6 +56,11 @@ internal sealed partial class BackgroundMessageProcessor : IAsyncDisposable private int _inFlight; private PooledBufferWriter _payloadBuffer; + // Set by DisposeAsync so the loop finishes what it has instead of being cancelled mid-send. A send cancelled + // after its first byte is reported as an outcome only the server knows, which would make every ordinary + // shutdown publish a batch that may have committed twice. + private int _stopping; + public BackgroundMessageProcessor(IIggyClient client, IggyPublisherConfig config, ILoggerFactory loggerFactory) { _client = client; @@ -97,7 +103,7 @@ public async ValueTask DisposeAsync() _client.UnsubscribeConnectionEvents(ClientOnOnConnectionStateChanged); - await _cancellationTokenSource.CancelAsync(); + Volatile.Write(ref _stopping, 1); _writer.TryComplete(); var backgroundTaskTimedOut = false; @@ -118,9 +124,14 @@ public async ValueTask DisposeAsync() { LogBackgroundProcessorError(e); } + finally + { + await _cancellationTokenSource.CancelAsync(); + } } else { + await _cancellationTokenSource.CancelAsync(); DrainAndDispose(); } @@ -244,9 +255,11 @@ private async Task RunBackgroundProcessor(CancellationToken ct) { while (!ct.IsCancellationRequested) { + var stopping = Volatile.Read(ref _stopping) != 0; + if (!_canSend) { - if (!await timer.WaitForNextTickAsync(ct)) + if (stopping || !await timer.WaitForNextTickAsync(ct)) { break; } @@ -256,7 +269,7 @@ private async Task RunBackgroundProcessor(CancellationToken ct) if (!AccumulateBatch()) { - if (!await timer.WaitForNextTickAsync(ct)) + if (stopping || !await timer.WaitForNextTickAsync(ct)) { break; } @@ -477,6 +490,16 @@ private async Task SendWithRetry(List wire, CancellationToken ct) // Disposal cancellation, not a send failure; let the loop's cancellation handling take over. throw; } + catch (VsrRequestOutcomeUnknownException ex) + { + // May already have committed - report it as its own type rather than folding it into the + // generic failure path, so a subscriber can tell "not sent" from "possibly sent twice". + LogFailedToSendBatch(ex, wire.Count); + if (_messageBatchErrorAggregator.HasSubscribers) + { + _messageBatchErrorAggregator.Publish(new MessageBatchFailedEventArgs(ex, SnapshotForFailure(wire))); + } + } catch (Exception ex) { LogFailedToSendBatch(ex, wire.Count); @@ -507,6 +530,20 @@ private async Task SendWithRetry(List wire, CancellationToken ct) // remaining attempts instantly and publish a misleading "failed after N attempts" event. throw; } + catch (VsrRequestOutcomeUnknownException ex) + { + // The send may already have committed. The partition plane is sessionless - it keeps no + // client-table entry to deduplicate an append against - so a retry cannot be matched to the + // original under any client id and would simply append the batch twice. Report it instead. + LogFailedToSendBatch(ex, wire.Count); + if (_messageBatchErrorAggregator.HasSubscribers) + { + _messageBatchErrorAggregator.Publish(new MessageBatchFailedEventArgs(ex, SnapshotForFailure(wire), + attempt + 1)); + } + + return; + } catch (Exception ex) { lastException = ex; diff --git a/foreign/csharp/Iggy_SDK/Publishers/IggyPublisher.cs b/foreign/csharp/Iggy_SDK/Publishers/IggyPublisher.cs index c90bedfa60..67e5a5226c 100644 --- a/foreign/csharp/Iggy_SDK/Publishers/IggyPublisher.cs +++ b/foreign/csharp/Iggy_SDK/Publishers/IggyPublisher.cs @@ -173,7 +173,7 @@ public async Task InitAsync(CancellationToken ct = default) await Client.ConnectAsync(ct); LogInitializingPublisher(Config.StreamId, Config.TopicId); - if (Config.CreateIggyClient) + if (!string.IsNullOrEmpty(Config.Login) && !Config.CreateIggyClient) { await Client.LoginUserAsync(Config.Login, Config.Password, ct); LogUserLoggedIn(Config.Login); diff --git a/foreign/csharp/Iggy_SDK/Publishers/IggyPublisherBuilder.cs b/foreign/csharp/Iggy_SDK/Publishers/IggyPublisherBuilder.cs index 47fa6f09df..fcdff215ec 100644 --- a/foreign/csharp/Iggy_SDK/Publishers/IggyPublisherBuilder.cs +++ b/foreign/csharp/Iggy_SDK/Publishers/IggyPublisherBuilder.cs @@ -33,7 +33,7 @@ namespace Apache.Iggy.Publishers; /// public class IggyPublisherBuilder { - private IMessageEncryptor? _encryptor; + private protected IMessageEncryptor? _encryptor; internal Func? OnBackgroundError { get; set; } internal Func? OnMessageBatchFailed { get; set; } @@ -127,6 +127,18 @@ public IggyPublisherBuilder WithConnection(Protocol protocol, string address, st return this; } + /// + /// Selects the wire framing the publisher's client speaks. Defaults to . + /// + /// The wire framing to use. VSR requires . + /// The builder instance for method chaining. + public IggyPublisherBuilder WithWireProtocol(WireProtocol wireProtocol) + { + Config.WireProtocol = wireProtocol; + + return this; + } + /// /// Configures the partitioning strategy for messages sent by the publisher. /// Determines how messages are distributed across topic partitions. @@ -301,10 +313,12 @@ public IggyPublisher Build() IggyClient = IggyClientFactory.CreateClient(new IggyClientConfigurator { Protocol = Config.Protocol, + WireProtocol = Config.WireProtocol, BaseAddress = Config.Address, ReceiveBufferSize = Config.ReceiveBufferSize, SendBufferSize = Config.SendBufferSize, ReconnectionSettings = Config.ReconnectionSettings ?? new ReconnectionSettings(), + AutoLoginSettings = AutoLoginSettings.For(Config.Login, Config.Password), LoggerFactory = Config.LoggerFactory ?? NullLoggerFactory.Instance, MessageEncryptor = _encryptor }); diff --git a/foreign/csharp/Iggy_SDK/Publishers/IggyPublisherBuilderOfT.cs b/foreign/csharp/Iggy_SDK/Publishers/IggyPublisherBuilderOfT.cs index 962b401af6..6ed5711986 100644 --- a/foreign/csharp/Iggy_SDK/Publishers/IggyPublisherBuilderOfT.cs +++ b/foreign/csharp/Iggy_SDK/Publishers/IggyPublisherBuilderOfT.cs @@ -88,9 +88,14 @@ public static IggyPublisherBuilder Create(IIggyClient iggyClient, Identifier IggyClient = IggyClientFactory.CreateClient(new IggyClientConfigurator { Protocol = Config.Protocol, + WireProtocol = Config.WireProtocol, BaseAddress = Config.Address, ReceiveBufferSize = Config.ReceiveBufferSize, - SendBufferSize = Config.SendBufferSize + SendBufferSize = Config.SendBufferSize, + ReconnectionSettings = Config.ReconnectionSettings ?? new ReconnectionSettings(), + AutoLoginSettings = AutoLoginSettings.For(Config.Login, Config.Password), + LoggerFactory = Config.LoggerFactory ?? NullLoggerFactory.Instance, + MessageEncryptor = _encryptor }); } @@ -134,8 +139,7 @@ protected override void Validate() } else { - throw new InvalidOperationException( - $"Config must be of type IggyPublisherConfig<{typeof(T).Name}>."); + throw new InvalidOperationException($"Config must be of type IggyPublisherConfig<{typeof(T).Name}>."); } } } diff --git a/foreign/csharp/Iggy_SDK/Publishers/IggyPublisherConfig.cs b/foreign/csharp/Iggy_SDK/Publishers/IggyPublisherConfig.cs index 5354069af1..e80cd3a760 100644 --- a/foreign/csharp/Iggy_SDK/Publishers/IggyPublisherConfig.cs +++ b/foreign/csharp/Iggy_SDK/Publishers/IggyPublisherConfig.cs @@ -55,6 +55,13 @@ public class IggyPublisherConfig /// public Protocol Protocol { get; set; } + /// + /// The wire framing to use. Defaults to ; + /// requires . + /// Only used when is true. + /// + public WireProtocol WireProtocol { get; set; } = WireProtocol.Classic; + /// /// Gets or sets the server address to connect to. /// Format depends on protocol (e.g., "localhost:8090" for TCP/QUIC, "http://localhost:3000" for HTTP). diff --git a/foreign/csharp/Iggy_SDK/Utils/CommandCodes.cs b/foreign/csharp/Iggy_SDK/Utils/CommandCodes.cs index 8922d37d61..c7a3051e59 100644 --- a/foreign/csharp/Iggy_SDK/Utils/CommandCodes.cs +++ b/foreign/csharp/Iggy_SDK/Utils/CommandCodes.cs @@ -47,6 +47,8 @@ internal static class CommandCodes internal const int GET_CONSUMER_OFFSET_CODE = 120; internal const int STORE_CONSUMER_OFFSET_CODE = 121; internal const int DELETE_CONSUMER_OFFSET_CODE = 122; + internal const int STORE_CONSUMER_OFFSET_2_CODE = 123; + internal const int DELETE_CONSUMER_OFFSET_2_CODE = 124; internal const int GET_STREAM_CODE = 200; internal const int GET_STREAMS_CODE = 201; internal const int CREATE_STREAM_CODE = 202; @@ -68,4 +70,5 @@ internal static class CommandCodes internal const int DELETE_CONSUMER_GROUP_CODE = 603; internal const int JOIN_CONSUMER_GROUP_CODE = 604; internal const int LEAVE_CONSUMER_GROUP_CODE = 605; + internal const int SYNC_CONSUMER_GROUP_CODE = 606; } diff --git a/foreign/csharp/Iggy_SDK/Utils/ServerAddress.cs b/foreign/csharp/Iggy_SDK/Utils/ServerAddress.cs new file mode 100644 index 0000000000..2f85784610 --- /dev/null +++ b/foreign/csharp/Iggy_SDK/Utils/ServerAddress.cs @@ -0,0 +1,139 @@ +// 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.Net; +using System.Net.Sockets; + +namespace Apache.Iggy.Utils; + +/// +/// Endpoint comparison for leader redirection. The configured address is whatever the caller wrote +/// (localhost:8090), while the cluster roster reports IPs, so the two are compared as parsed +/// endpoints rather than as strings. +/// +internal static class ServerAddress +{ + internal static bool IsSame(string first, string second) + { + return Normalize(first) == Normalize(second); + } + + /// + /// Renders host:port, bracketing a host that carries colons of its own (a bare IPv6 address). + /// Without the brackets the rendering can never round-trip through , so + /// it would compare unequal to every normalized address. + /// + internal static string HostPort(string host, ushort port) + { + return host.Contains(':') && !host.StartsWith('[') ? $"[{host}]:{port}" : $"{host}:{port}"; + } + + internal static bool TryParse(string address, out string host, out int port) + { + var parsed = TrySplitHostPort(address, out host, out var hostPort); + port = hostPort; + + return parsed; + } + + /// + /// The socket family a host has to be dialled on. A name resolves to whatever the resolver returns, and + /// the connect call handles that itself, so only a literal decides the family here. + /// + internal static AddressFamily AddressFamilyOf(string host) + { + return IPAddress.TryParse(host, out var ip) ? ip.AddressFamily : AddressFamily.InterNetwork; + } + + /// + /// Canonical host:port rendering: the host is lowercased, the loopback and unspecified aliases + /// collapse onto the loopback address, and an IP is re-rendered from its parsed form. The host is + /// replaced as a whole, never as a substring, so a node named my-localhost-1 keeps its name. + /// An address that is not host:port is only lowercased, which leaves it comparable but distinct. + /// + internal static string Normalize(string address) + { + if (!TrySplitHostPort(address, out var host, out var port)) + { + return address.ToLowerInvariant(); + } + + if (!IPAddress.TryParse(NormalizeHostAlias(host), out var ip)) + { + return $"{host.ToLowerInvariant()}:{port}"; + } + + return ip.AddressFamily == AddressFamily.InterNetworkV6 ? $"[{ip}]:{port}" : $"{ip}:{port}"; + } + + /// + /// A server bound to the unspecified address is reachable on the loopback one, and the roster may + /// report either, so both render the same way. + /// + private static string NormalizeHostAlias(string host) + { + if (host.Equals("localhost", StringComparison.OrdinalIgnoreCase)) + { + return "127.0.0.1"; + } + + if (!IPAddress.TryParse(host, out var ip)) + { + return host; + } + + if (ip.Equals(IPAddress.Any)) + { + return IPAddress.Loopback.ToString(); + } + + return ip.Equals(IPAddress.IPv6Any) ? IPAddress.IPv6Loopback.ToString() : host; + } + + private static bool TrySplitHostPort(string address, out string host, out ushort port) + { + host = string.Empty; + port = 0; + string portText; + + // A bracketed host is the only form that may carry colons of its own. + if (address.StartsWith('[')) + { + var closing = address.IndexOf(']'); + if (closing < 0 || closing + 1 >= address.Length || address[closing + 1] != ':') + { + return false; + } + + host = address[1..closing]; + portText = address[(closing + 2)..]; + } + else + { + var separator = address.IndexOf(':'); + if (separator <= 0 || address.IndexOf(':', separator + 1) >= 0) + { + return false; + } + + host = address[..separator]; + portText = address[(separator + 1)..]; + } + + return host.Length > 0 && ushort.TryParse(portText, out port); + } +} diff --git a/foreign/csharp/Iggy_SDK/Vsr/Command2.cs b/foreign/csharp/Iggy_SDK/Vsr/Command2.cs new file mode 100644 index 0000000000..c8f7a765d7 --- /dev/null +++ b/foreign/csharp/Iggy_SDK/Vsr/Command2.cs @@ -0,0 +1,30 @@ +// 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.Vsr; + +/// +/// VSR frame discriminant, byte 60 of every consensus header. Only the frames a client emits or +/// receives are named; every other discriminant decodes as . +/// +internal enum Command2 : byte +{ + Reserved = 0, + Request = 5, + Reply = 8, + Eviction = 13 +} diff --git a/foreign/csharp/Iggy_SDK/Vsr/ConsensusSession.cs b/foreign/csharp/Iggy_SDK/Vsr/ConsensusSession.cs new file mode 100644 index 0000000000..8cdbdcc16c --- /dev/null +++ b/foreign/csharp/Iggy_SDK/Vsr/ConsensusSession.cs @@ -0,0 +1,223 @@ +// 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.Security.Cryptography; +using Apache.Iggy.Exceptions; + +namespace Apache.Iggy.Vsr; + +/// +/// Consensus-level session state: the ephemeral client id, the session number the server hands back +/// when a register commits, and the monotonic request counter. +/// +/// +/// Every mutation and every read of the identity runs under one lock. The transport serialises the +/// re-arms against the requests with its sending lock, so a request always encodes from the identity +/// that is live for the whole time it is on the wire. +/// +internal sealed class ConsensusSession +{ +#if NET10_0_OR_GREATER + private readonly Lock _gate = new(); +#else + private readonly object _gate = new(); +#endif + private UInt128 _clientId; + private bool _registerPending; + private ulong _requestCounter; + private ulong? _session; + + /// Ephemeral client identifier, never persisted, non-zero. + internal UInt128 ClientId + { + get + { + lock (_gate) + { + return _clientId; + } + } + } + + /// Session fence epoch assigned by the server, null until a register commits. + internal ulong? Session + { + get + { + lock (_gate) + { + return _session; + } + } + } + + /// The id the next request id resolution will return. + internal ulong RequestCounter + { + get + { + lock (_gate) + { + return _requestCounter; + } + } + } + + internal bool IsBound + { + get + { + lock (_gate) + { + return _session.HasValue; + } + } + } + + internal ConsensusSession() : this(GenerateClientId()) + { + } + + internal ConsensusSession(UInt128 clientId) + { + _clientId = clientId; + _requestCounter = 1; + } + + /// + /// Resolves the identity one request header is encoded from, in a single atomic step so no reset can + /// interleave between the client id, the request id and the session. + /// + internal SessionFrame Resolve(VsrOperation operation) + { + lock (_gate) + { + return operation switch + { + VsrOperation.Register => RegisterFrameLocked(), + VsrOperation.NonReplicated => new SessionFrame(_clientId, _requestCounter, _session ?? 0), + _ => ReplicatedFrameLocked(operation) + }; + } + } + + /// Bind the session from a committed register reply. + /// The session number the register reply carried. + /// + /// No register is awaiting a binding, so the identity was re-armed while this one was in flight. Binding + /// regardless would pair the session with a client id the server never registered, and it would fence + /// every later request. + /// + /// + /// The register reply carried no session. The value comes off the wire, so a malformed reply has to + /// surface as a protocol error rather than as argument validation. + /// + internal void Bind(ulong session) + { + if (session == 0) + { + throw VsrError.Exception(VsrError.INVALID_FORMAT, "Register reply carried no session."); + } + + lock (_gate) + { + if (!_registerPending) + { + throw new NotConnectedException(); + } + + _registerPending = false; + _session = session; + } + } + + /// Forget the binding and the client id, e.g. after an eviction or a torn connection. + internal void Reset() + { + lock (_gate) + { + ReArmLocked(); + } + } + + /// + /// Begin a registration, re-arming the session if it was already used. A re-login mints a fresh + /// client id and clears the binding, so the register encodes cleanly and the server never has to + /// disambiguate a repeat register for the same client. Always returns 0. + /// + private SessionFrame RegisterFrameLocked() + { + // A second register while one is still unbound would re-arm the identity out from under the first, and + // the winner's Bind would then attach its session to the re-armed client id - the server answers every + // later request with NoSession and evicts. Refuse instead: the caller retries against a clean session. + if (_registerPending) + { + throw VsrError.Exception(VsrError.UNAUTHENTICATED, "A consensus register is already in flight."); + } + + if (_session.HasValue) + { + ReArmLocked(); + } + + _registerPending = true; + + return new SessionFrame(_clientId, 0, 0); + } + + private SessionFrame ReplicatedFrameLocked(VsrOperation operation) + { + var sessionId = _session ?? throw VsrError.Exception(VsrError.UNAUTHENTICATED, + "A replicated request requires a bound consensus session."); + + // Partition ops replicate in their own per-partition group with no client-table dedup, so they too + // must leave the metadata counter untouched. Only metadata operations and logout consume an id: the + // server tracks request ids for those alone, and it accepts any id above the client's watermark. + if (operation.IsPartition()) + { + return new SessionFrame(_clientId, _requestCounter, sessionId); + } + + var requestId = _requestCounter; + _requestCounter = checked(_requestCounter + 1); + + return new SessionFrame(_clientId, requestId, sessionId); + } + + private void ReArmLocked() + { + _clientId = GenerateClientId(); + _session = null; + _requestCounter = 1; + _registerPending = false; + } + + private static UInt128 GenerateClientId() + { + Span bytes = stackalloc byte[16]; + RandomNumberGenerator.Fill(bytes); + var lower = BinaryPrimitives.ReadUInt64LittleEndian(bytes[..8]); + var upper = BinaryPrimitives.ReadUInt64LittleEndian(bytes[8..]); + var clientId = new UInt128(upper, lower); + + return clientId == UInt128.Zero ? UInt128.One : clientId; + } +} + +/// The session identity one request header is encoded from, resolved as a single atomic snapshot. +internal readonly record struct SessionFrame(UInt128 ClientId, ulong RequestId, ulong SessionId); diff --git a/foreign/csharp/Iggy_SDK/Vsr/ConsumerGroupClientState.cs b/foreign/csharp/Iggy_SDK/Vsr/ConsumerGroupClientState.cs new file mode 100644 index 0000000000..e594443ae9 --- /dev/null +++ b/foreign/csharp/Iggy_SDK/Vsr/ConsumerGroupClientState.cs @@ -0,0 +1,301 @@ +// 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.Vsr; + +/// +/// Per-connection cache of consumer-group assignments and topic partition counts, mirroring +/// core/common/src/consumer_group_client_state.rs. Under VSR the broker never picks a partition, so +/// the client resolves group polls and balanced / message-key produce locally. The cursors have to survive +/// across calls, which is why this lives on the long-lived transport rather than on a request. +/// +internal sealed class ConsumerGroupClientState +{ + private readonly Dictionary _assignments = []; + private readonly Dictionary _balancedCursors = []; +#if NET10_0_OR_GREATER + private readonly Lock _gate = new(); +#else + private readonly object _gate = new(); +#endif + private readonly Dictionary _joinedGroups = []; + private readonly Dictionary _partitionCounts = []; + + /// 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. + internal bool HasAssignment(GroupKey key) + { + lock (_gate) + { + return _assignments.TryGetValue(key, out var assignment) && assignment.Partitions.Count > 0; + } + } + + /// + /// Replaces a group's cached assignment. A generation change is a rebalance, so the round-robin cursor + /// restarts rather than carrying an index that meant something else. + /// + internal void SetAssignment(GroupKey key, ulong generation, IReadOnlyList partitions) + { + lock (_gate) + { + if (!_assignments.TryGetValue(key, out var assignment)) + { + assignment = new GroupAssignment(); + _assignments[key] = assignment; + } + + if (assignment.Generation != generation) + { + assignment.Cursor = 0; + } + + assignment.Generation = generation; + assignment.Partitions = partitions; + } + } + + internal void InvalidateAssignment(GroupKey key) + { + lock (_gate) + { + _assignments.Remove(key); + } + } + + /// + /// The next assigned partition for a group poll, advancing the cursor. null when nothing is cached + /// or the member holds no partitions. + /// + internal uint? NextGroupPartition(GroupKey key) + { + lock (_gate) + { + if (!_assignments.TryGetValue(key, out var assignment) || assignment.Partitions.Count == 0) + { + return null; + } + + var index = assignment.Cursor % assignment.Partitions.Count; + assignment.Cursor = assignment.Cursor == int.MaxValue ? 0 : assignment.Cursor + 1; + + return assignment.Partitions[index]; + } + } + + /// The next balanced produce partition for a topic, advancing the cursor. + internal uint NextBalancedPartition(TopicKey key, uint partitionCount) + { + if (partitionCount == 0) + { + return 0; + } + + lock (_gate) + { + _balancedCursors.TryGetValue(key, out var cursor); + var partition = (uint)(cursor % partitionCount); + _balancedCursors[key] = cursor == int.MaxValue ? 0 : cursor + 1; + + return partition; + } + } + + internal uint? PartitionCount(TopicKey key) + { + lock (_gate) + { + if (!_partitionCounts.TryGetValue(key, out var cached)) + { + return null; + } + + if (Environment.TickCount64 >= cached.ExpiresAt) + { + _partitionCounts.Remove(key); + + return null; + } + + return cached.Count; + } + } + + internal void SetPartitionCount(TopicKey key, uint partitionCount) + { + lock (_gate) + { + _partitionCounts[key] = new CachedPartitionCount(partitionCount, + Environment.TickCount64 + PartitionCountTtlMs); + } + } + + /// + /// Forgets a topic's cached partition count immediately, for the changes this client makes itself. + /// Changes made by anyone else are covered by . + /// + internal void InvalidatePartitionCount(TopicKey key) + { + lock (_gate) + { + _partitionCounts.Remove(key); + } + } + + /// Records a joined group's identifiers so a later refresh can rebuild its sync request. + internal void RegisterGroup(GroupKey key, Identifier streamId, Identifier topicId, Identifier groupId) + { + lock (_gate) + { + _joinedGroups[key] = new GroupIdentifiers(streamId, topicId, groupId); + } + } + + internal void DeregisterGroup(GroupKey key) + { + lock (_gate) + { + _joinedGroups.Remove(key); + _assignments.Remove(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) + { + lock (_gate) + { + return _joinedGroups.ContainsKey(key); + } + } + + internal IReadOnlyList RegisteredGroups() + { + lock (_gate) + { + return _joinedGroups.Count == 0 ? [] : [.. _joinedGroups.Values]; + } + } + + /// + /// Drops what a consensus session owns. The assignments are fenced by a generation the coordinator tracks + /// per session, so carrying them across a reset would fence every poll, and membership has to be re-synced + /// before it can be trusted again. The balanced cursors and the cached partition counts stay: they belong + /// to a topic, not to a session, and clearing them restarts the produce round-robin at partition 0 and + /// costs a metadata round trip per topic on every reconnect. + /// + internal void ClearSessionScoped() + { + lock (_gate) + { + _assignments.Clear(); + _joinedGroups.Clear(); + } + } + + internal readonly record struct GroupIdentifiers(Identifier StreamId, Identifier TopicId, Identifier GroupId); + + private readonly record struct CachedPartitionCount(uint Count, long ExpiresAt); + + private sealed class GroupAssignment + { + internal IReadOnlyList Partitions { get; set; } = []; + internal ulong Generation { get; set; } + internal int Cursor { get; set; } + } +} + +/// +/// Cache key for a topic. The identifier kind is part of the key because a stream named "1" and the stream +/// with id 1 are different streams that share a rendering. Identifiers are compared by their wire bytes: +/// compares its value array by reference, and every call site builds a fresh one. +/// +internal readonly struct TopicKey(Identifier streamId, Identifier topicId) : IEquatable +{ + private Identifier StreamId { get; } = streamId; + private Identifier TopicId { get; } = topicId; + + public bool Equals(TopicKey other) + { + return IdentifierKey.Equal(StreamId, other.StreamId) && IdentifierKey.Equal(TopicId, other.TopicId); + } + + public override bool Equals(object? obj) + { + return obj is TopicKey other && Equals(other); + } + + public override int GetHashCode() + { + return HashCode.Combine(IdentifierKey.Hash(StreamId), IdentifierKey.Hash(TopicId)); + } + + public override string ToString() + { + return $"{StreamId}|{TopicId}"; + } +} + +/// Cache key for a consumer group on a topic. +internal readonly struct GroupKey(Identifier streamId, Identifier topicId, Identifier groupId) : IEquatable +{ + private TopicKey Topic { get; } = new(streamId, topicId); + private Identifier GroupId { get; } = groupId; + + public bool Equals(GroupKey other) + { + return Topic.Equals(other.Topic) && IdentifierKey.Equal(GroupId, other.GroupId); + } + + public override bool Equals(object? obj) + { + return obj is GroupKey other && Equals(other); + } + + public override int GetHashCode() + { + return HashCode.Combine(Topic.GetHashCode(), IdentifierKey.Hash(GroupId)); + } + + public override string ToString() + { + return $"{Topic}|{GroupId}"; + } +} + +internal static class IdentifierKey +{ + internal static bool Equal(Identifier first, Identifier second) + { + return first.Kind == second.Kind && first.Value.AsSpan().SequenceEqual(second.Value); + } + + internal static int Hash(Identifier identifier) + { + var hash = new HashCode(); + hash.Add((byte)identifier.Kind); + hash.AddBytes(identifier.Value); + + return hash.ToHashCode(); + } +} diff --git a/foreign/csharp/Iggy_SDK/Vsr/CredentialBounds.cs b/foreign/csharp/Iggy_SDK/Vsr/CredentialBounds.cs new file mode 100644 index 0000000000..11fde11871 --- /dev/null +++ b/foreign/csharp/Iggy_SDK/Vsr/CredentialBounds.cs @@ -0,0 +1,66 @@ +// 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.Text; + +namespace Apache.Iggy.Vsr; + +/// +/// The credential bounds every server enforces, checked before encoding so an oversized credential is +/// reported as the typed status code instead of desyncing the u8 length prefix on the wire. Mirrors +/// core/common/src/http/users/defaults.rs. +/// +internal static class CredentialBounds +{ + internal const int MIN_USERNAME_LENGTH = 3; + internal const int MAX_USERNAME_LENGTH = 50; + internal const int MIN_PASSWORD_LENGTH = 3; + internal const int MAX_PASSWORD_LENGTH = 100; + + internal const int MIN_TOKEN_LENGTH = 1; + internal const int MAX_TOKEN_LENGTH = 255; + + internal static void ValidateUsername(string username) + { + var length = Encoding.UTF8.GetByteCount(username); + if (length is < MIN_USERNAME_LENGTH or > MAX_USERNAME_LENGTH) + { + throw VsrError.Exception(VsrError.INVALID_USERNAME, + $"Username must be {MIN_USERNAME_LENGTH}-{MAX_USERNAME_LENGTH} bytes, got {length}."); + } + } + + internal static void ValidatePassword(string password) + { + var length = Encoding.UTF8.GetByteCount(password); + if (length is < MIN_PASSWORD_LENGTH or > MAX_PASSWORD_LENGTH) + { + throw VsrError.Exception(VsrError.INVALID_PASSWORD, + $"Password must be {MIN_PASSWORD_LENGTH}-{MAX_PASSWORD_LENGTH} bytes, got {length}."); + } + } + + internal static void ValidateToken(string token) + { + var length = Encoding.UTF8.GetByteCount(token); + if (length is < MIN_TOKEN_LENGTH or > MAX_TOKEN_LENGTH) + { + throw VsrError.Exception(VsrError.INVALID_PERSONAL_ACCESS_TOKEN, + $"Personal access token must be {MIN_TOKEN_LENGTH}-{MAX_TOKEN_LENGTH} bytes, got {length}."); + } + } +} diff --git a/foreign/csharp/Iggy_SDK/Vsr/EvictionReason.cs b/foreign/csharp/Iggy_SDK/Vsr/EvictionReason.cs new file mode 100644 index 0000000000..e51deeea0f --- /dev/null +++ b/foreign/csharp/Iggy_SDK/Vsr/EvictionReason.cs @@ -0,0 +1,42 @@ +// 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.Vsr; + +/// +/// Reason carried at byte 255 of an eviction frame. Session-terminal, never transient. +/// Discriminants are wire-pinned; a value outside this set decodes as . +/// +internal enum EvictionReason : byte +{ + Reserved = 0, + NoSession = 1, + ClientReleaseTooLow = 2, + ClientReleaseTooHigh = 3, + InvalidRequestOperation = 4, + InvalidRequestBody = 5, + InvalidRequestBodySize = 6, + SessionTooLow = 7, + SessionReleaseMismatch = 8, + InvalidCredentials = 9, + InvalidToken = 10, + UserInactive = 11, + SessionError = 12, + StaleClient = 13, + IncompatibleProtocol = 14, + MalformedLogin = 15 +} diff --git a/foreign/csharp/Iggy_SDK/Vsr/LoginRegister.cs b/foreign/csharp/Iggy_SDK/Vsr/LoginRegister.cs new file mode 100644 index 0000000000..2081c6c9cd --- /dev/null +++ b/foreign/csharp/Iggy_SDK/Vsr/LoginRegister.cs @@ -0,0 +1,168 @@ +// 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.Text; +using Apache.Iggy.Utils; + +namespace Apache.Iggy.Vsr; + +/// +/// The register handshake bodies. VSR replaces the legacy login codes with +/// LOGIN_REGISTER / LOGIN_REGISTER_WITH_PAT, whose bodies lead with the client version +/// info the server gates on before it touches credentials. +/// +internal static class LoginRegister +{ + internal const string SDK_NAME = "csharp-sdk"; + + /// Semver of the iggy_binary_protocol crate this SDK is built against. + internal const int PROTOCOL_VERSION_MAJOR = 0; + + internal const int PROTOCOL_VERSION_MINOR = 10; + internal const int PROTOCOL_VERSION_PATCH = 3; + + /// Packed protocol version: major << 20 | minor << 10 | patch, 10 bits each. + internal const uint PROTOCOL_VERSION = + ((uint)PROTOCOL_VERSION_MAJOR << 20) | ((uint)PROTOCOL_VERSION_MINOR << 10) | PROTOCOL_VERSION_PATCH; + + private const int MaxWireNameLength = 255; + + private static int VersionInfoLength => 4 + NameLength(SDK_NAME) + NameLength(SdkVersion.Value); + + internal static byte[] Serialize(string username, string password, string? clientContext = null) + { + CredentialBounds.ValidateUsername(username); + CredentialBounds.ValidatePassword(password); + + var writer = new BodyWriter(VersionInfoLength + NameLength(username) + NameLength(password) + 4 + + ContextLength(clientContext)); + writer.WriteVersionInfo(); + writer.WriteName(username, nameof(username)); + writer.WriteName(password, nameof(password)); + writer.WriteContext(clientContext); + + return writer.Buffer; + } + + internal static byte[] SerializeWithPersonalAccessToken(string token, string? clientContext = null) + { + CredentialBounds.ValidateToken(token); + + var writer = new BodyWriter(VersionInfoLength + NameLength(token) + 4 + ContextLength(clientContext)); + writer.WriteVersionInfo(); + writer.WriteName(token, nameof(token)); + writer.WriteContext(clientContext); + + return writer.Buffer; + } + + /// + /// [user_id u32][session u64][server_protocol_version u32][server_version len u8 + bytes]. + /// + internal static LoginRegisterResponse Deserialize(ReadOnlySpan body) + { + if (body.IsEmpty) + { + // The server fast-fails a terminal register failure (invalid credentials, invalid token, + // inactive user) with an empty reply instead of a typed error frame, and the reason is not + // recoverable from the wire. Same INVALID_FORMAT surface as the Rust SDK. + throw VsrError.Exception(VsrError.INVALID_FORMAT, + "Server rejected the login. The register reply is empty, which the server sends for invalid " + + "credentials, an invalid personal access token, or an inactive user."); + } + + if (body.Length < 17) + { + throw VsrError.Exception(VsrError.INVALID_FORMAT, "Register reply is truncated."); + } + + var serverVersionLength = body[16]; + if (serverVersionLength == 0 || body.Length < 17 + serverVersionLength) + { + throw VsrError.Exception(VsrError.INVALID_FORMAT, "Register reply carries a malformed server version."); + } + + return new LoginRegisterResponse(BinaryPrimitives.ReadUInt32LittleEndian(body[..4]), + BinaryPrimitives.ReadUInt64LittleEndian(body[4..12]), + BinaryPrimitives.ReadUInt32LittleEndian(body[12..16]), + Encoding.UTF8.GetString(body.Slice(17, serverVersionLength))); + } + + private static int NameLength(string value) + { + return 1 + Encoding.UTF8.GetByteCount(value); + } + + private static int ContextLength(string? clientContext) + { + return clientContext is null ? 0 : Encoding.UTF8.GetByteCount(clientContext); + } + + private struct BodyWriter + { + private int _position; + + internal BodyWriter(int length) + { + Buffer = new byte[length]; + _position = 0; + } + + internal byte[] Buffer { get; } + + internal void WriteVersionInfo() + { + BinaryPrimitives.WriteUInt32LittleEndian(Buffer.AsSpan(_position, 4), PROTOCOL_VERSION); + _position += 4; + WriteName(SDK_NAME, nameof(SDK_NAME)); + WriteName(SdkVersion.Value, nameof(SdkVersion)); + } + + internal void WriteName(string value, string name) + { + var length = Encoding.UTF8.GetByteCount(value); + if (length is 0 or > MaxWireNameLength) + { + throw new ArgumentException($"{name} must be 1-{MaxWireNameLength} UTF-8 bytes, got {length}.", name); + } + + Buffer[_position] = (byte)length; + _position += 1; + Encoding.UTF8.GetBytes(value, Buffer.AsSpan(_position, length)); + _position += length; + } + + internal void WriteContext(string? clientContext) + { + var length = ContextLength(clientContext); + BinaryPrimitives.WriteUInt32LittleEndian(Buffer.AsSpan(_position, 4), (uint)length); + _position += 4; + if (clientContext is not null && length > 0) + { + Encoding.UTF8.GetBytes(clientContext, Buffer.AsSpan(_position, length)); + _position += length; + } + } + } +} + +internal readonly record struct LoginRegisterResponse( + uint UserId, + ulong Session, + uint ServerProtocolVersion, + string ServerVersion); diff --git a/foreign/csharp/Iggy_SDK/Vsr/SyncConsumerGroup.cs b/foreign/csharp/Iggy_SDK/Vsr/SyncConsumerGroup.cs new file mode 100644 index 0000000000..5bc8d55fb8 --- /dev/null +++ b/foreign/csharp/Iggy_SDK/Vsr/SyncConsumerGroup.cs @@ -0,0 +1,61 @@ +// 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; + +namespace Apache.Iggy.Vsr; + +/// +/// Reply body of a SyncConsumerGroup request: the requesting member's current partition assignment +/// and the generation it belongs to. Mirrors +/// core/binary_protocol/src/responses/consumer_groups/sync_consumer_group.rs. +/// +/// +/// Wire format: [generation u64][partitions_count u32][partition_id u32]*. The request body is the +/// plain [stream][topic][group] identifier triple every other group request already builds. +/// +internal readonly record struct SyncConsumerGroupAssignment(ulong Generation, IReadOnlyList Partitions) +{ + internal static SyncConsumerGroupAssignment Decode(ReadOnlySpan body) + { + if (body.Length < 12) + { + throw Malformed(); + } + + var generation = BinaryPrimitives.ReadUInt64LittleEndian(body[..8]); + var count = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(8, 4)); + if (body.Length < 12 + (long)count * 4) + { + throw Malformed(); + } + + var partitions = new uint[count]; + for (var i = 0; i < partitions.Length; i++) + { + partitions[i] = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(12 + i * 4, 4)); + } + + return new SyncConsumerGroupAssignment(generation, partitions); + } + + private static Exception Malformed() + { + return VsrError.Exception(VsrError.INVALID_COMMAND, + "Consumer group assignment reply is too short for the partition count it declares."); + } +} diff --git a/foreign/csharp/Iggy_SDK/Vsr/VsrError.cs b/foreign/csharp/Iggy_SDK/Vsr/VsrError.cs new file mode 100644 index 0000000000..07e5306a83 --- /dev/null +++ b/foreign/csharp/Iggy_SDK/Vsr/VsrError.cs @@ -0,0 +1,61 @@ +// 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 Apache.Iggy.Exceptions; + +namespace Apache.Iggy.Vsr; + +/// +/// Server error codes the VSR paths raise locally or surface from the wire. Values match +/// core/common/src/error/iggy_error.rs; the server shares this code space across the reply +/// status word, the committed result section and the eviction mapping. +/// +internal static class VsrError +{ + internal const int INVALID_COMMAND = 3; + internal const int INVALID_FORMAT = 4; + internal const int FEATURE_UNAVAILABLE = 5; + internal const int INVALID_IDENTIFIER = 6; + internal const int STALE_CLIENT = 30; + internal const int UNAUTHENTICATED = 40; + internal const int INVALID_CREDENTIALS = 42; + internal const int INVALID_USERNAME = 43; + internal const int INVALID_PASSWORD = 44; + internal const int INVALID_PERSONAL_ACCESS_TOKEN = 53; + internal const int TRANSIENT_NOT_COMMITTED = 57; + internal const int TRANSIENT_NOT_ACCEPTED = 58; + internal const int EMPTY_RESPONSE = 304; + internal const int TOPIC_ID_NOT_FOUND = 2010; + internal const int CONSUMER_GROUP_MEMBER_NOT_FOUND = 5006; + internal const int CONSUMER_GROUP_PARTITION_NOT_OWNED = 5009; + internal const int INCOMPATIBLE_PROTOCOL_VERSION = 14003; + + /// A failure the client raised itself, before or instead of a server verdict. + internal static IggyInvalidStatusCodeException Exception(int code, string message) + { + return new IggyInvalidStatusCodeException(code, message); + } + + /// + /// A verdict the server reported, on the reply status word, in the committed result section or in an + /// eviction frame. Only these may drive retry and failover. + /// + internal static IggyInvalidStatusCodeException FromServer(int code, string message) + { + return new IggyInvalidStatusCodeException(code, message, true); + } +} diff --git a/foreign/csharp/Iggy_SDK/Vsr/VsrHeader.cs b/foreign/csharp/Iggy_SDK/Vsr/VsrHeader.cs new file mode 100644 index 0000000000..5ebb96e839 --- /dev/null +++ b/foreign/csharp/Iggy_SDK/Vsr/VsrHeader.cs @@ -0,0 +1,156 @@ +// 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; + +namespace Apache.Iggy.Vsr; + +/// +/// The 256-byte consensus header, read and written by wire offset. Offsets mirror +/// core/binary_protocol/src/consensus/header.rs. Checksums stay zero: the server does not verify +/// them for client frames. +/// +internal static class VsrHeader +{ + internal const int HEADER_SIZE = 256; + + internal const int SIZE_OFFSET = 48; + internal const int COMMAND_OFFSET = 60; + + internal const int REQUEST_CLIENT_OFFSET = 128; + internal const int REQUEST_TIMESTAMP_OFFSET = 160; + internal const int REQUEST_ID_OFFSET = 168; + internal const int REQUEST_OPERATION_OFFSET = 176; + internal const int REQUEST_NAMESPACE_OFFSET = 184; + internal const int REQUEST_SESSION_OFFSET = 192; + internal const int REQUEST_RESERVED_OFFSET = 204; + + internal const int REPLY_OPERATION_OFFSET = 208; + internal const int REPLY_NAMESPACE_OFFSET = 216; + internal const int REPLY_STATUS_OFFSET = 224; + + internal const int EVICTION_CLIENT_OFFSET = 128; + internal const int EVICTION_PROTOCOL_VERSION_OFFSET = 144; + internal const int EVICTION_PROTOCOL_VERSION_MIN_OFFSET = 148; + internal const int EVICTION_REASON_OFFSET = 255; + + /// + /// Encodes the request header for a classic command code and its body. Returns the total frame size + /// (header plus body). + /// + /// + /// Everything that can fail runs before a request id is consumed. The primary accepts any id above the + /// watermark, so a gap costs nothing, but a consumed id can never be handed back: re-encoding it for a + /// different request would let the client table answer that request from the first one's cached reply. + /// + internal static int EncodeRequestHeader(Span header, ConsensusSession session, int code, + ReadOnlySpan payload) + { + if (header.Length < HEADER_SIZE) + { + throw new ArgumentException($"Header buffer must be at least {HEADER_SIZE} bytes.", nameof(header)); + } + + header = header[..HEADER_SIZE]; + header.Clear(); + + var operation = VsrOperations.ForCode(code); + var ns = VsrNamespace.ForRequest(code, payload, operation); + + if (payload.Length > int.MaxValue - HEADER_SIZE) + { + throw VsrError.Exception(VsrError.INVALID_COMMAND, "Request body exceeds the maximum frame size."); + } + + var frame = session.Resolve(operation); + var totalSize = HEADER_SIZE + payload.Length; + + BinaryPrimitives.WriteUInt32LittleEndian(header[SIZE_OFFSET..], (uint)totalSize); + header[COMMAND_OFFSET] = (byte)Command2.Request; + WriteUInt128(header[REQUEST_CLIENT_OFFSET..], frame.ClientId); + BinaryPrimitives.WriteUInt64LittleEndian(header[REQUEST_TIMESTAMP_OFFSET..], 0); + BinaryPrimitives.WriteUInt64LittleEndian(header[REQUEST_ID_OFFSET..], frame.RequestId); + header[REQUEST_OPERATION_OFFSET] = (byte)operation; + BinaryPrimitives.WriteUInt64LittleEndian(header[REQUEST_NAMESPACE_OFFSET..], ns); + BinaryPrimitives.WriteUInt64LittleEndian(header[REQUEST_SESSION_OFFSET..], frame.SessionId); + + if (operation == VsrOperation.NonReplicated) + { + BinaryPrimitives.WriteUInt32LittleEndian(header[REQUEST_RESERVED_OFFSET..], (uint)code); + } + + return totalSize; + } + + internal static Command2 PeekCommand(ReadOnlySpan header) + { + return header[COMMAND_OFFSET] switch + { + (byte)Command2.Reply => Command2.Reply, + (byte)Command2.Eviction => Command2.Eviction, + _ => Command2.Reserved + }; + } + + /// Total frame size (header plus body) the peer announced. + internal static uint ReadSize(ReadOnlySpan header) + { + return BinaryPrimitives.ReadUInt32LittleEndian(header[SIZE_OFFSET..]); + } + + /// + /// Pre-commit deny channel. Nonzero means refused before commit with an empty body; a committed + /// rejection stamps 0 here and rides the result section instead. + /// + internal static uint ReadStatus(ReadOnlySpan header) + { + return BinaryPrimitives.ReadUInt32LittleEndian(header[REPLY_STATUS_OFFSET..]); + } + + internal static VsrOperation ReadReplyOperation(ReadOnlySpan header) + { + var operation = header[REPLY_OPERATION_OFFSET]; + if (!VsrOperations.IsKnown(operation)) + { + throw VsrError.Exception(VsrError.INVALID_COMMAND, $"Reply carries an unknown operation ({operation})."); + } + + return (VsrOperation)operation; + } + + internal static EvictionFrame ReadEviction(ReadOnlySpan header) + { + var reason = header[EVICTION_REASON_OFFSET]; + + return new EvictionFrame( + reason is > 0 and <= (byte)EvictionReason.MalformedLogin ? (EvictionReason)reason : null, + BinaryPrimitives.ReadUInt32LittleEndian(header[EVICTION_PROTOCOL_VERSION_OFFSET..]), + BinaryPrimitives.ReadUInt32LittleEndian(header[EVICTION_PROTOCOL_VERSION_MIN_OFFSET..])); + } + + private static void WriteUInt128(Span destination, UInt128 value) + { + BinaryPrimitives.WriteUInt64LittleEndian(destination, (ulong)value); + BinaryPrimitives.WriteUInt64LittleEndian(destination[8..], (ulong)(value >> 64)); + } +} + +/// Session-terminal eviction frame. Version fields are zero unless the reason is a protocol mismatch. +internal readonly record struct EvictionFrame( + EvictionReason? Reason, + uint ServerProtocolVersion, + uint ServerProtocolVersionMin); diff --git a/foreign/csharp/Iggy_SDK/Vsr/VsrNamespace.cs b/foreign/csharp/Iggy_SDK/Vsr/VsrNamespace.cs new file mode 100644 index 0000000000..34187591dd --- /dev/null +++ b/foreign/csharp/Iggy_SDK/Vsr/VsrNamespace.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 Apache.Iggy.Enums; +using Apache.Iggy.Utils; + +namespace Apache.Iggy.Vsr; + +/// +/// Packs a stream/topic/partition triple into the RequestHeader.namespace word the server hashes +/// to a shard. Mirrors core/binary_protocol/src/namespace.rs; any drift routes writes to the +/// wrong shard. +/// +internal static class VsrNamespace +{ + internal const int MAX_STREAMS = 4096; + internal const int MAX_TOPICS = 4096; + internal const int MAX_PARTITIONS = 1_000_000; + + internal const int PARTITION_BITS = 20; + internal const int TOPIC_BITS = 12; + internal const int STREAM_BITS = 12; + + internal const int PARTITION_SHIFT = 0; + internal const int TOPIC_SHIFT = PARTITION_SHIFT + PARTITION_BITS; + internal const int STREAM_SHIFT = TOPIC_SHIFT + TOPIC_BITS; + + internal const ulong PARTITION_MASK = (1UL << PARTITION_BITS) - 1; + internal const ulong TOPIC_MASK = (1UL << TOPIC_BITS) - 1; + internal const ulong STREAM_MASK = (1UL << STREAM_BITS) - 1; + + /// + /// Reserved value routing a request to the cluster's single metadata consensus group. Sits above the + /// packed layout, so it can never collide with a packed namespace. + /// + internal const ulong METADATA_CONSENSUS_NAMESPACE = 1UL << 63; + + private const byte NumericIdKind = 1; + private const byte StringIdKind = 2; + private const byte ConsumerKind = 1; + private const byte ConsumerGroupKind = 2; + + /// + /// The namespace a request header must carry, peeking into the classic payload for the ops the server + /// routes by partition. Named (string) stream or topic identifiers resolve to 0; the server resolves + /// them itself. + /// + internal static ulong ForRequest(int code, ReadOnlySpan payload, VsrOperation operation) + { + if (operation is VsrOperation.Register or VsrOperation.Logout) + { + return METADATA_CONSENSUS_NAMESPACE; + } + + if (operation == VsrOperation.NonReplicated || operation.IsMetadata()) + { + return 0; + } + + return code switch + { + CommandCodes.SEND_MESSAGES_CODE => FromSendMessages(payload), + CommandCodes.STORE_CONSUMER_OFFSET_CODE or CommandCodes.DELETE_CONSUMER_OFFSET_CODE + or CommandCodes.STORE_CONSUMER_OFFSET_2_CODE or CommandCodes.DELETE_CONSUMER_OFFSET_2_CODE => + FromConsumerOffset(payload), + CommandCodes.DELETE_SEGMENTS_CODE => FromDeleteSegments(payload), + _ => throw VsrError.Exception(VsrError.FEATURE_UNAVAILABLE, + $"Command code {code} cannot be routed to a partition namespace.") + }; + } + + internal static ulong Pack(uint streamId, uint topicId, uint partitionId) + { + return ((streamId & STREAM_MASK) << STREAM_SHIFT) + | ((topicId & TOPIC_MASK) << TOPIC_SHIFT) + | ((partitionId & PARTITION_MASK) << PARTITION_SHIFT); + } + + /// + /// [metadata_len u32][stream][topic][partitioning][messages_count u32]. Only the metadata + /// prefix is peeked; the message batch that follows is never parsed. + /// + private static ulong FromSendMessages(ReadOnlySpan payload) + { + var metadataLength = (int)ReadUInt32(payload, 0); + if (metadataLength < 0 || payload.Length - 4 < metadataLength) + { + throw Malformed(); + } + + ReadOnlySpan metadata = payload.Slice(4, metadataLength); + var position = 0; + var streamId = ReadIdentifier(metadata, ref position); + var topicId = ReadIdentifier(metadata, ref position); + + var kind = ReadByte(metadata, position); + var length = ReadByte(metadata, position + 1); + position += 2; + if (kind != (byte)Partitioning.PartitionId) + { + throw VsrError.Exception(VsrError.FEATURE_UNAVAILABLE, + "Under VSR the partition must be resolved by the client; balanced and message-key partitioning cannot be sent on the wire."); + } + + if (length != 4 || metadata.Length < position + 4) + { + throw Malformed(); + } + + return FromPartition(streamId, topicId, ReadUInt32(metadata, position)); + } + + /// + /// [consumer kind u8][consumer id][stream][topic][partition flag u8][partition u32], shared by + /// the store and delete consumer-offset requests and their v2 variants. + /// + private static ulong FromConsumerOffset(ReadOnlySpan payload) + { + var consumerKind = ReadByte(payload, 0); + if (consumerKind is not (ConsumerKind or ConsumerGroupKind)) + { + throw Malformed(); + } + + var position = 1; + _ = ReadIdentifier(payload, ref position); + var streamId = ReadIdentifier(payload, ref position); + var topicId = ReadIdentifier(payload, ref position); + + var flag = ReadByte(payload, position); + position += 1; + + if (flag == 0) + { + throw VsrError.Exception(VsrError.INVALID_IDENTIFIER, + "Under VSR a consumer-offset request must carry an explicit partition id."); + } + + if (payload.Length < position + 4) + { + throw Malformed(); + } + + return FromPartition(streamId, topicId, ReadUInt32(payload, position)); + } + + /// + /// [stream][topic][partition u32][segments_count u32]. + /// + private static ulong FromDeleteSegments(ReadOnlySpan payload) + { + var position = 0; + var streamId = ReadIdentifier(payload, ref position); + var topicId = ReadIdentifier(payload, ref position); + if (payload.Length < position + 4) + { + throw Malformed(); + } + + return FromPartition(streamId, topicId, ReadUInt32(payload, position)); + } + + private static ulong FromPartition(uint? streamId, uint? topicId, uint partitionId) + { + if (streamId is null || topicId is null) + { + return 0; + } + + Validate(streamId.Value, MAX_STREAMS); + Validate(topicId.Value, MAX_TOPICS); + Validate(partitionId, MAX_PARTITIONS); + + return Pack(streamId.Value, topicId.Value, partitionId); + } + + private static void Validate(uint value, int exclusiveMax) + { + if (value >= exclusiveMax) + { + throw VsrError.Exception(VsrError.INVALID_IDENTIFIER, + $"Identifier {value} is outside the packable namespace range (max {exclusiveMax - 1})."); + } + } + + /// + /// Reads an [kind u8][len u8][value] identifier, advancing . + /// Returns the numeric value, or null for a named identifier the server has to resolve. + /// A kind or width the wire format does not define is corruption, not a named identifier: falling + /// back to null would route the request to namespace 0 instead of failing it. + /// + private static uint? ReadIdentifier(ReadOnlySpan payload, ref int position) + { + var kind = ReadByte(payload, position); + var length = ReadByte(payload, position + 1); + if (payload.Length < position + 2 + length) + { + throw Malformed(); + } + + uint? value = (kind, length) switch + { + (NumericIdKind, 4) => ReadUInt32(payload, position + 2), + (StringIdKind, > 0) => null, + _ => throw Malformed() + }; + position += 2 + length; + + return value; + } + + private static byte ReadByte(ReadOnlySpan payload, int offset) + { + if (payload.Length <= offset) + { + throw Malformed(); + } + + return payload[offset]; + } + + private static uint ReadUInt32(ReadOnlySpan payload, int offset) + { + if (payload.Length < offset + 4) + { + throw Malformed(); + } + + return BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(offset, 4)); + } + + private static Exception Malformed() + { + return VsrError.Exception(VsrError.INVALID_COMMAND, "Request payload is too short to resolve a namespace."); + } +} diff --git a/foreign/csharp/Iggy_SDK/Vsr/VsrOperation.cs b/foreign/csharp/Iggy_SDK/Vsr/VsrOperation.cs new file mode 100644 index 0000000000..5653a23c06 --- /dev/null +++ b/foreign/csharp/Iggy_SDK/Vsr/VsrOperation.cs @@ -0,0 +1,275 @@ +// 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 Apache.Iggy.Utils; + +namespace Apache.Iggy.Vsr; + +/// +/// Replicated operation discriminant, byte 176 of a request header and byte 208 of a reply header. +/// Mirrors core/binary_protocol/src/consensus/operation.rs; discriminants are wire-pinned. +/// +internal enum VsrOperation : byte +{ + Reserved = 0, + Register = 1, + NonReplicated = 2, + Logout = 3, + + CreateTopicWithAssignments = 64, + CreatePartitionsWithAssignments = 65, + RemoveConsumerGroupMember = 66, + CompleteConsumerGroupRevocation = 67, + TruncatePartition = 68, + + CreateStream = 128, + UpdateStream = 129, + DeleteStream = 130, + PurgeStream = 131, + CreateTopic = 132, + UpdateTopic = 133, + DeleteTopic = 134, + PurgeTopic = 135, + CreatePartitions = 136, + DeletePartitions = 137, + DeleteSegments = 138, + CreateConsumerGroup = 139, + DeleteConsumerGroup = 140, + CreateUser = 141, + UpdateUser = 142, + DeleteUser = 143, + ChangePassword = 144, + UpdatePermissions = 145, + CreatePersonalAccessToken = 146, + DeletePersonalAccessToken = 147, + JoinConsumerGroup = 148, + LeaveConsumerGroup = 149, + + SendMessages = 160, + StoreConsumerOffset = 161, + DeleteConsumerOffset = 162, + StoreConsumerOffset2 = 164, + DeleteConsumerOffset2 = 165 +} + +internal static class VsrOperations +{ + private const byte InternalStart = (byte)VsrOperation.CreateTopicWithAssignments; + private const byte MetadataStart = (byte)VsrOperation.CreateStream; + private const byte PartitionStart = (byte)VsrOperation.SendMessages; + + /// + /// Non-replicated codes this build knows to leave no server-side state behind, so re-sending one after a + /// lost connection is indistinguishable from sending it once. Flushing an unsaved buffer is included: it + /// is idempotent by construction, a second flush writes nothing new. + /// + private static readonly HashSet NonReplicatedReadCodes = + [ + CommandCodes.PING_CODE, + CommandCodes.GET_STATS_CODE, + CommandCodes.GET_SNAPSHOT_CODE, + CommandCodes.GET_CLUSTER_METADATA_CODE, + CommandCodes.GET_ME_CODE, + CommandCodes.GET_CLIENT_CODE, + CommandCodes.GET_CLIENTS_CODE, + CommandCodes.GET_USER_CODE, + CommandCodes.GET_USERS_CODE, + CommandCodes.GET_PERSONAL_ACCESS_TOKENS_CODE, + CommandCodes.FLUSH_UNSAVED_BUFFER_CODE, + CommandCodes.GET_CONSUMER_OFFSET_CODE, + CommandCodes.GET_STREAM_CODE, + CommandCodes.GET_STREAMS_CODE, + CommandCodes.GET_TOPIC_CODE, + CommandCodes.GET_TOPICS_CODE, + CommandCodes.GET_CONSUMER_GROUP_CODE, + CommandCodes.GET_CONSUMER_GROUPS_CODE, + CommandCodes.SYNC_CONSUMER_GROUP_CODE + ]; + + /// + /// Maps a legacy command code to the operation its request header carries. An unmapped code rides + /// : the command table is a protocol registry, not a + /// per-server capability list, so the server is the authority on codes this SDK build does not know. + /// + internal static VsrOperation ForCode(int code) + { + return code switch + { + CommandCodes.LOGIN_REGISTER_CODE or CommandCodes.LOGIN_REGISTER_WITH_PAT_CODE => VsrOperation.Register, + + // VSR replaces the legacy login codes with the register handshake. A legacy login code arriving here + // means a caller bypassed the typed path, and sending it non-replicated would look like a working + // login while no session is ever bound. + CommandCodes.LOGIN_USER_CODE or CommandCodes.LOGIN_WITH_PERSONAL_ACCESS_TOKEN_CODE => + throw VsrError.Exception(VsrError.INVALID_COMMAND, + $"Command {code} cannot be sent as a consensus request."), + CommandCodes.LOGOUT_USER_CODE => VsrOperation.Logout, + CommandCodes.CREATE_USER_CODE => VsrOperation.CreateUser, + CommandCodes.DELETE_USER_CODE => VsrOperation.DeleteUser, + CommandCodes.UPDATE_USER_CODE => VsrOperation.UpdateUser, + CommandCodes.UPDATE_PERMISSIONS_CODE => VsrOperation.UpdatePermissions, + CommandCodes.CHANGE_PASSWORD_CODE => VsrOperation.ChangePassword, + CommandCodes.CREATE_PERSONAL_ACCESS_TOKEN_CODE => VsrOperation.CreatePersonalAccessToken, + CommandCodes.DELETE_PERSONAL_ACCESS_TOKEN_CODE => VsrOperation.DeletePersonalAccessToken, + CommandCodes.SEND_MESSAGES_CODE => VsrOperation.SendMessages, + CommandCodes.STORE_CONSUMER_OFFSET_CODE => VsrOperation.StoreConsumerOffset, + CommandCodes.DELETE_CONSUMER_OFFSET_CODE => VsrOperation.DeleteConsumerOffset, + CommandCodes.STORE_CONSUMER_OFFSET_2_CODE => VsrOperation.StoreConsumerOffset2, + CommandCodes.DELETE_CONSUMER_OFFSET_2_CODE => VsrOperation.DeleteConsumerOffset2, + CommandCodes.CREATE_STREAM_CODE => VsrOperation.CreateStream, + CommandCodes.DELETE_STREAM_CODE => VsrOperation.DeleteStream, + CommandCodes.UPDATE_STREAM_CODE => VsrOperation.UpdateStream, + CommandCodes.PURGE_STREAM_CODE => VsrOperation.PurgeStream, + CommandCodes.CREATE_TOPIC_CODE => VsrOperation.CreateTopic, + CommandCodes.DELETE_TOPIC_CODE => VsrOperation.DeleteTopic, + CommandCodes.UPDATE_TOPIC_CODE => VsrOperation.UpdateTopic, + CommandCodes.PURGE_TOPIC_CODE => VsrOperation.PurgeTopic, + CommandCodes.CREATE_PARTITIONS_CODE => VsrOperation.CreatePartitions, + CommandCodes.DELETE_PARTITIONS_CODE => VsrOperation.DeletePartitions, + CommandCodes.DELETE_SEGMENTS_CODE => VsrOperation.DeleteSegments, + CommandCodes.CREATE_CONSUMER_GROUP_CODE => VsrOperation.CreateConsumerGroup, + CommandCodes.DELETE_CONSUMER_GROUP_CODE => VsrOperation.DeleteConsumerGroup, + CommandCodes.JOIN_CONSUMER_GROUP_CODE => VsrOperation.JoinConsumerGroup, + CommandCodes.LEAVE_CONSUMER_GROUP_CODE => VsrOperation.LeaveConsumerGroup, + _ => VsrOperation.NonReplicated + }; + } + + /// + /// Whether losing the connection mid-request leaves nothing for the server to deduplicate, so the request + /// can be re-issued on a fresh session by the reconnect path instead of failing the caller. + /// + internal static bool IsReplaySafeRead(int code, bool isLoginRegister, ReadOnlySpan body) + { + // A register lost mid-flight is replayable: the retry re-arms the session under a fresh client id, so + // it cannot be mistaken for the first one. At worst the server keeps an entry nobody binds, which it + // ages out. Reporting an unknown outcome here instead would deny the caller the retry that is in fact + // the only correct response. + if (isLoginRegister) + { + return true; + } + + var operation = ForCode(code); + + // A consumer offset write carries an absolute offset and the server applies it as an unconditional + // overwrite, on a plane that keeps no client table to dedup against, so a replay lands on the same + // value. Denying the retry here reports an unknown outcome for a blip on an offset commit, which + // takes down the consume loop over a write that was safe to repeat. + if (operation is VsrOperation.StoreConsumerOffset or VsrOperation.StoreConsumerOffset2 + or VsrOperation.DeleteConsumerOffset or VsrOperation.DeleteConsumerOffset2) + { + return true; + } + + if (operation != VsrOperation.NonReplicated) + { + return false; + } + + // A poll that auto-commits moves the consumer offset server-side, so a reply lost after the commit + // would make the replay start past a batch the caller never saw. auto_commit is the last body byte. + if (code == CommandCodes.POLL_MESSAGES_CODE) + { + return body.Length > 0 && body[^1] == 0; + } + + // Everything else non-replicated is replay-safe only if this build knows it to be a read. An unmapped + // code also lands on NonReplicated, and re-sending one the server implements as a mutation would apply + // it twice with nothing to deduplicate against. + return NonReplicatedReadCodes.Contains(code); + } + + /// + /// Whether the byte is a declared discriminant. Replies carry a server-controlled operation byte, so + /// an undeclared value is rejected rather than classified by range. + /// + internal static bool IsKnown(byte value) + { + return (VsrOperation)value switch + { + VsrOperation.Reserved or VsrOperation.Register or VsrOperation.NonReplicated or VsrOperation.Logout => + true, + >= VsrOperation.CreateTopicWithAssignments and <= VsrOperation.TruncatePartition => true, + >= VsrOperation.CreateStream and <= VsrOperation.LeaveConsumerGroup => true, + VsrOperation.SendMessages or VsrOperation.StoreConsumerOffset or VsrOperation.DeleteConsumerOffset + or VsrOperation.StoreConsumerOffset2 or VsrOperation.DeleteConsumerOffset2 => true, + _ => false + }; + } + + /// Replica / journal only; never emitted by a client. + internal static bool IsInternal(this VsrOperation operation) + { + return (byte)operation >= InternalStart && (byte)operation < MetadataStart; + } + + /// + /// Control-plane operations handled by shard 0. Enumerated member by member, mirroring + /// Operation::is_metadata, rather than tested as a numeric range: the metadata block runs to 159 + /// but the last member declared today is 149, so a range would silently exclude the next operation added + /// upstream. That operation would then also read as not result-framed, and a committed rejection in its + /// reply would decode as a successful payload. + /// + internal static bool IsMetadata(this VsrOperation operation) + { + return operation.IsInternal() || operation is VsrOperation.CreateStream + or VsrOperation.UpdateStream + or VsrOperation.DeleteStream + or VsrOperation.PurgeStream + or VsrOperation.CreateTopic + or VsrOperation.UpdateTopic + or VsrOperation.DeleteTopic + or VsrOperation.PurgeTopic + or VsrOperation.CreatePartitions + or VsrOperation.DeletePartitions + or VsrOperation.CreateConsumerGroup + or VsrOperation.DeleteConsumerGroup + or VsrOperation.CreateUser + or VsrOperation.UpdateUser + or VsrOperation.DeleteUser + or VsrOperation.ChangePassword + or VsrOperation.UpdatePermissions + or VsrOperation.CreatePersonalAccessToken + or VsrOperation.DeletePersonalAccessToken + or VsrOperation.JoinConsumerGroup + or VsrOperation.LeaveConsumerGroup; + } + + /// + /// Data-plane operations routed by namespace to the shard owning the partition. + /// is deliberately neither metadata nor partition: the + /// server resolves it to an internal TruncatePartition, yet it still carries a packed namespace. + /// + internal static bool IsPartition(this VsrOperation operation) + { + return (byte)operation >= PartitionStart; + } + + /// + /// Whether a reply for this operation leads its body with the committed result section. Metadata ops + /// always do; on the partition plane only the consumer-offset ops do. Register is result-framed only + /// when its body is non-empty, which is why that case stays in . + /// + internal static bool IsResultFramed(this VsrOperation operation) + { + return operation.IsMetadata() || operation is VsrOperation.StoreConsumerOffset + or VsrOperation.StoreConsumerOffset2 + or VsrOperation.DeleteConsumerOffset + or VsrOperation.DeleteConsumerOffset2; + } +} diff --git a/foreign/csharp/Iggy_SDK/Vsr/VsrReplyDecoder.cs b/foreign/csharp/Iggy_SDK/Vsr/VsrReplyDecoder.cs new file mode 100644 index 0000000000..efb2130157 --- /dev/null +++ b/foreign/csharp/Iggy_SDK/Vsr/VsrReplyDecoder.cs @@ -0,0 +1,206 @@ +// 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; + +namespace Apache.Iggy.Vsr; + +/// +/// Decodes a reply frame into the typed payload the classic response readers expect. +/// +internal static class VsrReplyDecoder +{ + internal const int RESULT_COUNT_LENGTH = 4; + internal const int RESULT_ENTRY_LENGTH = 8; + + /// + /// Runs the decode funnel in the order the wire contract requires: eviction frames first, then the + /// announced size, then the pre-commit status word, and only then the body and its committed result + /// section. Reading the status before the body is what lets an empty deny reply fail with the right + /// error instead of a truncation. + /// + internal static ReadOnlyMemory Decode(ReadOnlySpan header, ReadOnlyMemory body) + { + if (header.Length < VsrHeader.HEADER_SIZE) + { + throw VsrError.Exception(VsrError.EMPTY_RESPONSE, "Reply header is shorter than the consensus header."); + } + + switch (VsrHeader.PeekCommand(header)) + { + case Command2.Eviction: + throw ToException(VsrHeader.ReadEviction(header)); + case Command2.Reply: + break; + default: + throw VsrError.Exception(VsrError.INVALID_COMMAND, + $"Unexpected consensus frame ({header[VsrHeader.COMMAND_OFFSET]})."); + } + + var expectedBody = ReadBodySize(header); + if (body.Length < expectedBody) + { + throw VsrError.Exception(VsrError.INVALID_COMMAND, "Reply body is shorter than the announced frame size."); + } + + var status = VsrHeader.ReadStatus(header); + if (status != 0) + { + throw VsrError.FromServer((int)status, $"Server rejected the request with status {status}."); + } + + return SplitResultSection(VsrHeader.ReadReplyOperation(header), body[..expectedBody]); + } + + /// Body length announced by the frame, i.e. the bytes to read after the header. + internal static int ReadBodySize(ReadOnlySpan header) + { + var size = VsrHeader.ReadSize(header); + if (size < VsrHeader.HEADER_SIZE || size > int.MaxValue) + { + throw VsrError.Exception(VsrError.INVALID_COMMAND, $"Reply announced an invalid frame size ({size})."); + } + + return (int)size - VsrHeader.HEADER_SIZE; + } + + internal static Exception ToException(EvictionFrame eviction) + { + var (code, message) = eviction.Reason switch + { + // The five reasons below fall into the catch-all of the shared grader, so they carry INVALID_COMMAND + // even where a narrower status would read better. The message keeps the detail; the code is the part + // six SDKs agree on. + EvictionReason.ClientReleaseTooLow => (VsrError.INVALID_COMMAND, + "Client release is below the cluster minimum."), + EvictionReason.ClientReleaseTooHigh => (VsrError.INVALID_COMMAND, + "Client release is above the cluster maximum."), + EvictionReason.InvalidRequestOperation => (VsrError.INVALID_COMMAND, + "Server rejected the request operation."), + EvictionReason.InvalidRequestBody => (VsrError.INVALID_COMMAND, "Server rejected the request body."), + EvictionReason.InvalidRequestBodySize => (VsrError.INVALID_COMMAND, + "Server rejected the request body size."), + EvictionReason.InvalidCredentials => (VsrError.INVALID_CREDENTIALS, "Invalid credentials."), + EvictionReason.InvalidToken => (VsrError.INVALID_PERSONAL_ACCESS_TOKEN, "Invalid personal access token."), + EvictionReason.UserInactive => (VsrError.UNAUTHENTICATED, "User is inactive."), + EvictionReason.SessionError => (VsrError.UNAUTHENTICATED, "Session error."), + EvictionReason.NoSession => (VsrError.UNAUTHENTICATED, "No session for this client."), + EvictionReason.SessionTooLow => (VsrError.UNAUTHENTICATED, "Session is below the cluster minimum."), + EvictionReason.SessionReleaseMismatch => (VsrError.UNAUTHENTICATED, "Session release mismatch."), + EvictionReason.StaleClient => (VsrError.STALE_CLIENT, "Client missed too many heartbeats."), + EvictionReason.IncompatibleProtocol => IncompatibleProtocol(eviction), + EvictionReason.MalformedLogin => (VsrError.INVALID_FORMAT, "Malformed login body."), + + // Reserved and any reason this build cannot decode share the grader's catch-all. + null => (VsrError.INVALID_COMMAND, "Session evicted for an unrecognized reason."), + _ => (VsrError.INVALID_COMMAND, $"Session evicted ({eviction.Reason}).") + }; + + return VsrError.FromServer(code, message); + } + + /// + /// Leading result code of a committed reply body: 0 for success, otherwise the first entry's result. + /// null when the body cannot hold what the count claims - corruption, never a silent success. + /// + internal static uint? ReadResultCode(ReadOnlySpan body) + { + if (!TryReadUInt32(body, 0, out var count)) + { + return null; + } + + if (count == 0) + { + return 0; + } + + return TryReadUInt32(body, RESULT_COUNT_LENGTH + 4, out var result) ? result : null; + } + + /// Byte length of the leading result section, i.e. where the typed payload starts. + internal static int? ReadResultSectionLength(ReadOnlySpan body) + { + if (!TryReadUInt32(body, 0, out var count)) + { + return null; + } + + var length = RESULT_COUNT_LENGTH + (long)count * RESULT_ENTRY_LENGTH; + + return body.Length >= length ? (int)length : null; + } + + private static (int Code, string Message) IncompatibleProtocol(EvictionFrame eviction) + { + if (eviction.ServerProtocolVersionMin == 0 || + eviction.ServerProtocolVersion < eviction.ServerProtocolVersionMin) + { + return (VsrError.UNAUTHENTICATED, "Server rejected the client protocol version."); + } + + return (VsrError.INCOMPATIBLE_PROTOCOL_VERSION, + $"Client protocol version {LoginRegister.PROTOCOL_VERSION} is outside the range accepted by the server " + + $"({eviction.ServerProtocolVersionMin}..{eviction.ServerProtocolVersion})."); + } + + /// + /// Strips the committed result section from a result-framed reply and maps a committed rejection to + /// its error. Register replies are result-framed only when non-empty: a terminal register failure + /// ships an empty body and is passed through to fail the typed response decode. + /// + private static ReadOnlyMemory SplitResultSection(VsrOperation operation, ReadOnlyMemory body) + { + var resultFramed = operation.IsResultFramed() || + (operation == VsrOperation.Register && !body.IsEmpty); + if (!resultFramed) + { + return body; + } + + var code = ReadResultCode(body.Span); + if (code is null) + { + throw VsrError.Exception(VsrError.INVALID_COMMAND, "Reply carries a malformed committed result section."); + } + + if (code != 0) + { + throw VsrError.FromServer((int)code.Value, $"Server rejected the request with status {code.Value}."); + } + + var payloadStart = ReadResultSectionLength(body.Span) + ?? throw VsrError.Exception(VsrError.INVALID_COMMAND, + "Reply carries a truncated committed result section."); + + return body[payloadStart..]; + } + + private static bool TryReadUInt32(ReadOnlySpan body, int offset, out uint value) + { + if (body.Length < offset + 4) + { + value = 0; + + return false; + } + + value = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(offset, 4)); + + return true; + } +} diff --git a/foreign/csharp/Iggy_SDK_Tests/ClientTests/IggyClientFactoryTests.cs b/foreign/csharp/Iggy_SDK_Tests/ClientTests/IggyClientFactoryTests.cs new file mode 100644 index 0000000000..81f5a69c49 --- /dev/null +++ b/foreign/csharp/Iggy_SDK_Tests/ClientTests/IggyClientFactoryTests.cs @@ -0,0 +1,114 @@ +// 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 Apache.Iggy.Configuration; +using Apache.Iggy.Enums; +using Apache.Iggy.Factory; + +namespace Apache.Iggy.Tests.ClientTests; + +public sealed class IggyClientFactoryTests +{ + [Fact] + public void CreateClient_DefaultsToClassicWireProtocol() + { + var options = new IggyClientConfigurator + { + BaseAddress = "127.0.0.1:8090", + Protocol = Protocol.Tcp + }; + + Assert.Equal(WireProtocol.Classic, options.WireProtocol); + Assert.Equal(64 * 1024 * 1024, options.MaxResponseFrameSize); + + using var client = IggyClientFactory.CreateClient(options) as IDisposable; + Assert.NotNull(client); + } + + [Fact] + public void CreateClient_AllowsVsrOverTcp() + { + var options = new IggyClientConfigurator + { + BaseAddress = "127.0.0.1:8090", + Protocol = Protocol.Tcp, + WireProtocol = WireProtocol.Vsr + }; + + using var client = IggyClientFactory.CreateClient(options) as IDisposable; + Assert.NotNull(client); + } + + [Fact] + public void CreateClient_RejectsVsrOverHttp() + { + var options = new IggyClientConfigurator + { + BaseAddress = "http://127.0.0.1:3000", + Protocol = Protocol.Http, + WireProtocol = WireProtocol.Vsr + }; + + var exception = Assert.Throws(() => IggyClientFactory.CreateClient(options)); + Assert.Contains("WireProtocol.Vsr requires Protocol.Tcp", exception.Message); + } + + [Fact] + public void CreateClient_RejectsMaxResponseFrameSizeBelowHeader() + { + var options = new IggyClientConfigurator + { + BaseAddress = "127.0.0.1:8090", + Protocol = Protocol.Tcp, + WireProtocol = WireProtocol.Vsr, + MaxResponseFrameSize = 255 + }; + + Assert.Throws(() => IggyClientFactory.CreateClient(options)); + } + + /// + /// Only the VSR reader bounds the buffer it rents for a peer-announced length, so a classic client is + /// not failed over a value that never reaches its path. + /// + [Fact] + public void CreateClient_AcceptsMaxResponseFrameSizeBelowHeaderUnderClassic() + { + var options = new IggyClientConfigurator + { + BaseAddress = "127.0.0.1:8090", + Protocol = Protocol.Tcp, + MaxResponseFrameSize = 1 + }; + + Assert.NotNull(IggyClientFactory.CreateClient(options)); + } + + [Fact] + public void CreateClient_AcceptsMaxResponseFrameSizeUnderHttp() + { + var options = new IggyClientConfigurator + { + BaseAddress = "http://127.0.0.1:3000", + Protocol = Protocol.Http, + MaxResponseFrameSize = 1 + }; + + using var client = IggyClientFactory.CreateClient(options) as IDisposable; + Assert.NotNull(client); + } +} diff --git a/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/IggyConsumerBuilderTests.cs b/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/IggyConsumerBuilderTests.cs index db0af7c3fc..41145dd5a1 100644 --- a/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/IggyConsumerBuilderTests.cs +++ b/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/IggyConsumerBuilderTests.cs @@ -15,6 +15,7 @@ // specific language governing permissions and limitations // under the License. +using System.Text; using Apache.Iggy.Consumers; using Apache.Iggy.Encryption; using Apache.Iggy.Enums; @@ -69,4 +70,57 @@ public void Build_WithEncryptorAndAfterReceiveCommit_DoesNotThrow() Assert.NotNull(consumer); } + + /// VSR is a framing choice, not a transport one, so the builder has to carry it to the client. + [Fact] + public void WithWireProtocol_CarriesTheFramingToTheConfig() + { + var builder = IggyConsumerBuilder + .Create(StreamId, TopicId, Consumer.New(1)) + .WithConnection(Protocol.Tcp, "127.0.0.1:8090", "user", "pass") + .WithWireProtocol(WireProtocol.Vsr); + + Assert.Equal(WireProtocol.Vsr, builder.Config.WireProtocol); + } + + [Fact] + public void WithConnection_DefaultsToClassicFraming() + { + var builder = IggyConsumerBuilder + .Create(StreamId, TopicId, Consumer.New(1)) + .WithConnection(Protocol.Tcp, "127.0.0.1:8090", "user", "pass"); + + Assert.Equal(WireProtocol.Classic, builder.Config.WireProtocol); + } + + [Fact] + public void TypedBuild_WithVsr_CreatesTheClient() + { + IggyConsumerBuilder builder = IggyConsumerBuilder + .Create(StreamId, TopicId, Consumer.New(1), new StringDeserializer()); + builder.WithConnection(Protocol.Tcp, "127.0.0.1:8090", "user", "pass") + .WithWireProtocol(WireProtocol.Vsr); + + Assert.NotNull(builder.Build()); + } + + [Fact] + public void TypedBuild_WithVsrOverHttp_Throws() + { + IggyConsumerBuilder builder = IggyConsumerBuilder + .Create(StreamId, TopicId, Consumer.New(1), new StringDeserializer()); + builder.WithConnection(Protocol.Http, "http://127.0.0.1:3000", "user", "pass") + .WithWireProtocol(WireProtocol.Vsr); + + var ex = Assert.Throws(() => builder.Build()); + Assert.Contains("WireProtocol.Vsr requires Protocol.Tcp", ex.Message); + } + + private sealed class StringDeserializer : IDeserializer + { + public string Deserialize(ReadOnlyMemory data) + { + return Encoding.UTF8.GetString(data.Span); + } + } } diff --git a/foreign/csharp/Iggy_SDK_Tests/PublisherTests/IggyPublisherBuilderTests.cs b/foreign/csharp/Iggy_SDK_Tests/PublisherTests/IggyPublisherBuilderTests.cs new file mode 100644 index 0000000000..ac97a98a89 --- /dev/null +++ b/foreign/csharp/Iggy_SDK_Tests/PublisherTests/IggyPublisherBuilderTests.cs @@ -0,0 +1,75 @@ +// 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; +using System.Text; +using Apache.Iggy.Enums; +using Apache.Iggy.Publishers; + +namespace Apache.Iggy.Tests.PublisherTests; + +public class IggyPublisherBuilderTests +{ + private static readonly Identifier StreamId = Identifier.Numeric(1); + private static readonly Identifier TopicId = Identifier.Numeric(1); + + [Fact] + public void WithWireProtocol_CarriesTheFramingToTheConfig() + { + var builder = IggyPublisherBuilder + .Create(StreamId, TopicId) + .WithConnection(Protocol.Tcp, "127.0.0.1:8090", "user", "pass") + .WithWireProtocol(WireProtocol.Vsr); + + Assert.Equal(WireProtocol.Vsr, builder.Config.WireProtocol); + } + + [Fact] + public void TypedBuild_WithVsr_CreatesTheClient() + { + IggyPublisherBuilder builder + = IggyPublisherBuilder.Create(StreamId, TopicId, new StringSerializer()); + builder.WithConnection(Protocol.Tcp, "127.0.0.1:8090", "user", "pass") + .WithWireProtocol(WireProtocol.Vsr); + + Assert.NotNull(builder.Build()); + } + + /// + /// The factory only rejects VSR over HTTP when the builder actually forwards the wire protocol, so the + /// rejection is what proves the typed builder does not silently drop it and fall back to classic framing. + /// + [Fact] + public void TypedBuild_WithVsrOverHttp_Throws() + { + IggyPublisherBuilder builder + = IggyPublisherBuilder.Create(StreamId, TopicId, new StringSerializer()); + builder.WithConnection(Protocol.Http, "http://127.0.0.1:3000", "user", "pass") + .WithWireProtocol(WireProtocol.Vsr); + + var ex = Assert.Throws(() => builder.Build()); + Assert.Contains("WireProtocol.Vsr requires Protocol.Tcp", ex.Message); + } + + private sealed class StringSerializer : ISerializer + { + public void Serialize(string data, IBufferWriter writer) + { + writer.Write(Encoding.UTF8.GetBytes(data)); + } + } +} diff --git a/foreign/csharp/Iggy_SDK_Tests/VsrTests/ConsensusSessionTests.cs b/foreign/csharp/Iggy_SDK_Tests/VsrTests/ConsensusSessionTests.cs new file mode 100644 index 0000000000..51e4d7f0a1 --- /dev/null +++ b/foreign/csharp/Iggy_SDK_Tests/VsrTests/ConsensusSessionTests.cs @@ -0,0 +1,218 @@ +// 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 Apache.Iggy.Exceptions; +using Apache.Iggy.Vsr; + +namespace Apache.Iggy.Tests.VsrTests; + +public sealed class ConsensusSessionTests +{ + [Fact] + public void NewSession_IsUnboundWithNonZeroClientId() + { + var session = new ConsensusSession(); + + Assert.False(session.IsBound); + Assert.Null(session.Session); + Assert.NotEqual(UInt128.Zero, session.ClientId); + } + + [Fact] + public void NewSession_MintsUniqueClientIds() + { + Assert.NotEqual(new ConsensusSession().ClientId, new ConsensusSession().ClientId); + } + + [Fact] + public void BeginRegister_OnFreshSessionKeepsClientId() + { + var session = new ConsensusSession(7); + + Assert.Equal(0UL, session.Resolve(VsrOperation.Register).RequestId); + Assert.Equal((UInt128)7, session.ClientId); + Assert.False(session.IsBound); + } + + /// + /// The re-arm mints the client id the register is encoded with, so the frame must carry the new one, not + /// the one the previous session used. + /// + [Fact] + public void Resolve_RegisterAfterBindReportsTheReArmedClientId() + { + var session = new ConsensusSession(7); + session.Resolve(VsrOperation.Register); + session.Bind(42); + + var frame = session.Resolve(VsrOperation.Register); + + Assert.Equal(session.ClientId, frame.ClientId); + Assert.NotEqual((UInt128)7, frame.ClientId); + } + + [Fact] + public void BeginRegister_AfterBindReArmsWithFreshClientId() + { + var session = new ConsensusSession(7); + session.Resolve(VsrOperation.Register); + session.Bind(42); + session.Resolve(VsrOperation.CreateStream); + + Assert.Equal(0UL, session.Resolve(VsrOperation.Register).RequestId); + Assert.NotEqual((UInt128)7, session.ClientId); + Assert.False(session.IsBound); + Assert.Equal(1UL, session.RequestCounter); + } + + /// + /// A register that never bound is cleared by the reset its failure path runs, and the retry re-arms onto a + /// fresh client id rather than reusing the one the server may have already seen. + /// + [Fact] + public void BeginRegister_AfterConsumedRegisterAndResetReArms() + { + var session = new ConsensusSession(7); + session.Resolve(VsrOperation.Register); + + session.Reset(); + + Assert.Equal(0UL, session.Resolve(VsrOperation.Register).RequestId); + Assert.False(session.IsBound); + Assert.NotEqual((UInt128)7, session.ClientId); + } + + [Fact] + public void NextRequestId_IsMonotonicAfterBind() + { + var session = new ConsensusSession(1); + session.Resolve(VsrOperation.Register); + session.Bind(10); + + Assert.Equal(1UL, session.Resolve(VsrOperation.CreateStream).RequestId); + Assert.Equal(2UL, session.Resolve(VsrOperation.CreateStream).RequestId); + Assert.Equal(3UL, session.RequestCounter); + } + + [Fact] + public void NextRequestId_BeforeBindThrows() + { + var session = new ConsensusSession(1); + + var error = Assert.Throws(() => session.Resolve(VsrOperation.CreateStream)); + + Assert.Equal(VsrError.UNAUTHENTICATED, error.StatusCode); + } + + [Fact] + public void Resolve_DoesNotConsumeAnIdForNonReplicatedOrPartitionOps() + { + var session = new ConsensusSession(1); + session.Resolve(VsrOperation.Register); + session.Bind(10); + + Assert.Equal(1UL, session.Resolve(VsrOperation.NonReplicated).RequestId); + Assert.Equal(1UL, session.Resolve(VsrOperation.SendMessages).RequestId); + Assert.Equal(1UL, session.RequestCounter); + } + + [Fact] + public void Bind_TwiceThrows() + { + var session = new ConsensusSession(1); + session.Resolve(VsrOperation.Register); + session.Bind(10); + + Assert.Throws(() => session.Bind(20)); + Assert.Equal(10UL, session.Session); + } + + [Fact] + public void Bind_ZeroThrows() + { + var session = new ConsensusSession(1); + + var exception = Assert.Throws(() => session.Bind(0)); + Assert.Equal(VsrError.INVALID_FORMAT, exception.StatusCode); + } + + [Fact] + public void Bind_WithoutAnInFlightRegisterThrows() + { + var session = new ConsensusSession(1); + + Assert.Throws(() => session.Bind(10)); + } + + /// + /// Binding runs after the sending lock is released, so a drop can have re-armed the identity since the + /// register committed. The reset clears the pending register, and binding regardless would pair the + /// session the server issued to the old client id with the one the re-arm minted. + /// + [Fact] + public void Bind_AfterTheIdentityReArmedThrows() + { + var session = new ConsensusSession(1); + session.Resolve(VsrOperation.Register); + + session.Reset(); + + Assert.Throws(() => session.Bind(10)); + Assert.False(session.IsBound); + } + + /// + /// Two concurrent registers would otherwise re-arm the identity under the first one, so its bind would + /// pair a committed session with a client id the server never saw. + /// + [Fact] + public void Resolve_SecondRegisterWhileOneIsInFlightThrows() + { + var session = new ConsensusSession(1); + session.Resolve(VsrOperation.Register); + + var error = Assert.Throws(() => session.Resolve(VsrOperation.Register)); + + Assert.Equal(VsrError.UNAUTHENTICATED, error.StatusCode); + } + + [Fact] + public void Resolve_RegisterIsAllowedAgainAfterReset() + { + var session = new ConsensusSession(1); + session.Resolve(VsrOperation.Register); + + session.Reset(); + + Assert.Equal(0UL, session.Resolve(VsrOperation.Register).RequestId); + } + + [Fact] + public void Reset_ClearsBindingAndCounter() + { + var session = new ConsensusSession(1); + session.Resolve(VsrOperation.Register); + session.Bind(10); + session.Resolve(VsrOperation.CreateStream); + + session.Reset(); + + Assert.False(session.IsBound); + Assert.Equal(1UL, session.RequestCounter); + Assert.NotEqual((UInt128)1, session.ClientId); + } +} diff --git a/foreign/csharp/Iggy_SDK_Tests/VsrTests/ConsumerGroupClientStateTests.cs b/foreign/csharp/Iggy_SDK_Tests/VsrTests/ConsumerGroupClientStateTests.cs new file mode 100644 index 0000000000..c1c4f00ada --- /dev/null +++ b/foreign/csharp/Iggy_SDK_Tests/VsrTests/ConsumerGroupClientStateTests.cs @@ -0,0 +1,176 @@ +// 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.IO.Hashing; +using System.Text; +using Apache.Iggy.Vsr; + +namespace Apache.Iggy.Tests.VsrTests; + +public sealed class ConsumerGroupClientStateTests +{ + private static readonly GroupKey Key = + new(Identifier.Numeric(1), Identifier.Numeric(2), Identifier.Numeric(3)); + + private static readonly TopicKey Topic = new(Identifier.Numeric(1), Identifier.Numeric(2)); + + [Fact] + public void NextGroupPartition_RoundRobinsThenWraps() + { + var state = new ConsumerGroupClientState(); + state.SetAssignment(Key, 1, [0, 1, 2]); + + Assert.Equal([0, 1, 2, 0], [ + state.NextGroupPartition(Key), state.NextGroupPartition(Key), + state.NextGroupPartition(Key), state.NextGroupPartition(Key) + ]); + } + + [Fact] + public void SetAssignment_OnNewGenerationResetsCursor() + { + var state = new ConsumerGroupClientState(); + state.SetAssignment(Key, 1, [0, 1, 2]); + state.NextGroupPartition(Key); + state.NextGroupPartition(Key); + + state.SetAssignment(Key, 2, [5]); + + Assert.Equal(5u, state.NextGroupPartition(Key)); + } + + [Fact] + public void SetAssignment_OnSameGenerationKeepsCursor() + { + var state = new ConsumerGroupClientState(); + state.SetAssignment(Key, 1, [0, 1, 2]); + state.NextGroupPartition(Key); + + state.SetAssignment(Key, 1, [0, 1, 2]); + + Assert.Equal(1u, state.NextGroupPartition(Key)); + } + + [Fact] + public void NextGroupPartition_WithoutAssignmentIsNull() + { + var state = new ConsumerGroupClientState(); + + Assert.False(state.HasAssignment(Key)); + Assert.Null(state.NextGroupPartition(Key)); + } + + [Fact] + public void InvalidateAssignment_KeepsMembership() + { + var state = new ConsumerGroupClientState(); + state.RegisterGroup(Key, Identifier.Numeric(1), Identifier.Numeric(2), Identifier.Numeric(3)); + state.SetAssignment(Key, 1, [0]); + + state.InvalidateAssignment(Key); + + Assert.False(state.HasAssignment(Key)); + Assert.True(state.IsRegistered(Key)); + } + + [Fact] + public void MemberHoldingNoPartitions_StaysRegistered() + { + var state = new ConsumerGroupClientState(); + state.RegisterGroup(Key, Identifier.Numeric(1), Identifier.Numeric(2), Identifier.Numeric(3)); + state.SetAssignment(Key, 1, []); + + Assert.False(state.HasAssignment(Key)); + Assert.True(state.IsRegistered(Key)); + + state.DeregisterGroup(Key); + + Assert.False(state.IsRegistered(Key)); + } + + [Fact] + public void RegisteredGroups_ReturnsJoinedIdentifiers() + { + var state = new ConsumerGroupClientState(); + state.RegisterGroup(Key, Identifier.Numeric(1), Identifier.Numeric(2), Identifier.String("group")); + + IReadOnlyList groups = state.RegisteredGroups(); + + var group = Assert.Single(groups); + + Assert.Equal("1", group.StreamId.ToString()); + Assert.Equal("2", group.TopicId.ToString()); + Assert.Equal("group", group.GroupId.ToString()); + } + + [Fact] + public void NextBalancedPartition_RoundRobinsThenWraps() + { + var state = new ConsumerGroupClientState(); + + Assert.Equal([0, 1, 2, 0], [ + state.NextBalancedPartition(Topic, 3), state.NextBalancedPartition(Topic, 3), + state.NextBalancedPartition(Topic, 3), state.NextBalancedPartition(Topic, 3) + ]); + } + + [Fact] + public void NextBalancedPartition_WithNoPartitionsIsZero() + { + Assert.Equal(0u, new ConsumerGroupClientState().NextBalancedPartition(Topic, 0)); + } + + [Fact] + public void ClearSessionScoped_DropsMembershipAndAssignmentsButKeepsTopicState() + { + var state = new ConsumerGroupClientState(); + state.RegisterGroup(Key, Identifier.Numeric(1), Identifier.Numeric(2), Identifier.Numeric(3)); + state.SetAssignment(Key, 1, [0]); + state.SetPartitionCount(Topic, 4); + state.NextBalancedPartition(Topic, 4); + + state.ClearSessionScoped(); + + Assert.False(state.IsRegistered(Key)); + Assert.False(state.HasAssignment(Key)); + Assert.Equal(4u, state.PartitionCount(Topic)); + Assert.Equal(1u, state.NextBalancedPartition(Topic, 4)); + } + + [Fact] + public void TopicKey_SeparatesNumericFromNamedIdentifiers() + { + Assert.NotEqual(new TopicKey(Identifier.Numeric(1), Identifier.Numeric(1)), + new TopicKey(Identifier.String("1"), Identifier.String("1"))); + } + + /// + /// Message-key partitioning has to agree with the Rust client byte for byte, or the two SDKs put the same + /// key on different partitions. Vectors come from calculate_32 (XxHash32::oneshot(0, data)). + /// + [Theory] + [InlineData("", 0x02cc5d05u)] + [InlineData("a", 0x550d7456u)] + [InlineData("abc", 0x32d153ffu)] + [InlineData("hello world", 0xcebb6622u)] + [InlineData("iggy-message-key", 0xf54b51c9u)] + [InlineData("1234567890123456789012345", 0xb10c970eu)] + public void XxHash32_MatchesRustVectors(string value, uint expected) + { + Assert.Equal(expected, XxHash32.HashToUInt32(Encoding.UTF8.GetBytes(value))); + } +} diff --git a/foreign/csharp/Iggy_SDK_Tests/VsrTests/CredentialBoundsTests.cs b/foreign/csharp/Iggy_SDK_Tests/VsrTests/CredentialBoundsTests.cs new file mode 100644 index 0000000000..4fc2e6b306 --- /dev/null +++ b/foreign/csharp/Iggy_SDK_Tests/VsrTests/CredentialBoundsTests.cs @@ -0,0 +1,72 @@ +// 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 Apache.Iggy.Exceptions; +using Apache.Iggy.Vsr; + +namespace Apache.Iggy.Tests.VsrTests; + +/// +/// Mirrors validate_username / validate_password in +/// core/common/src/traits/binary_impls/mod.rs. +/// +public sealed class CredentialBoundsTests +{ + [Fact] + public void ValidateUsername_AcceptsTheServerBounds() + { + CredentialBounds.ValidateUsername(new string('a', CredentialBounds.MIN_USERNAME_LENGTH)); + CredentialBounds.ValidateUsername(new string('a', CredentialBounds.MAX_USERNAME_LENGTH)); + } + + [Fact] + public void ValidateUsername_RejectsOutOfBounds() + { + AssertRejects(VsrError.INVALID_USERNAME, + () => CredentialBounds.ValidateUsername(new string('a', CredentialBounds.MIN_USERNAME_LENGTH - 1))); + AssertRejects(VsrError.INVALID_USERNAME, + () => CredentialBounds.ValidateUsername(new string('a', CredentialBounds.MAX_USERNAME_LENGTH + 1))); + } + + [Fact] + public void ValidatePassword_AcceptsTheServerBounds() + { + CredentialBounds.ValidatePassword(new string('a', CredentialBounds.MIN_PASSWORD_LENGTH)); + CredentialBounds.ValidatePassword(new string('a', CredentialBounds.MAX_PASSWORD_LENGTH)); + } + + [Fact] + public void ValidatePassword_RejectsOutOfBounds() + { + AssertRejects(VsrError.INVALID_PASSWORD, + () => CredentialBounds.ValidatePassword(new string('a', CredentialBounds.MIN_PASSWORD_LENGTH - 1))); + AssertRejects(VsrError.INVALID_PASSWORD, + () => CredentialBounds.ValidatePassword(new string('a', CredentialBounds.MAX_PASSWORD_LENGTH + 1))); + } + + [Fact] + public void ValidatePassword_CountsUtf8BytesNotChars() + { + // 51 two-byte code points encode to 102 bytes, over the server's limit despite fitting in chars. + AssertRejects(VsrError.INVALID_PASSWORD, () => CredentialBounds.ValidatePassword(new string('ż', 51))); + } + + private static void AssertRejects(int statusCode, Action action) + { + Assert.Equal(statusCode, Assert.Throws(action).StatusCode); + } +} diff --git a/foreign/csharp/Iggy_SDK_Tests/VsrTests/LoginRegisterTests.cs b/foreign/csharp/Iggy_SDK_Tests/VsrTests/LoginRegisterTests.cs new file mode 100644 index 0000000000..896c11b6d6 --- /dev/null +++ b/foreign/csharp/Iggy_SDK_Tests/VsrTests/LoginRegisterTests.cs @@ -0,0 +1,171 @@ +// 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.Text; +using Apache.Iggy.Exceptions; +using Apache.Iggy.Utils; +using Apache.Iggy.Vsr; + +namespace Apache.Iggy.Tests.VsrTests; + +public sealed class LoginRegisterTests +{ + private static int AssertVersionInfo(byte[] body) + { + Assert.Equal(LoginRegister.PROTOCOL_VERSION, BinaryPrimitives.ReadUInt32LittleEndian(body)); + var position = 4; + position += AssertName(body, position, LoginRegister.SDK_NAME); + position += AssertName(body, position, SdkVersion.Value); + + return position; + } + + private static int AssertName(byte[] body, int position, string expected) + { + var length = body[position]; + Assert.Equal(Encoding.UTF8.GetByteCount(expected), length); + Assert.Equal(expected, Encoding.UTF8.GetString(body, position + 1, length)); + + return 1 + length; + } + + [Fact] + public void ProtocolVersion_PacksTenBitsPerComponent() + { + Assert.Equal((0u << 20) | (10u << 10) | 3u, LoginRegister.PROTOCOL_VERSION); + } + + [Fact] + public void Serialize_WritesVersionInfoThenCredentialsThenContext() + { + var body = LoginRegister.Serialize("admin", "secret"); + + var position = AssertVersionInfo(body); + position += AssertName(body, position, "admin"); + position += AssertName(body, position, "secret"); + + Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(position))); + Assert.Equal(position + 4, body.Length); + } + + [Fact] + public void Serialize_AppendsTheClientContextWithAUInt32Length() + { + var body = LoginRegister.Serialize("admin", "secret", "ctx"); + + var position = AssertVersionInfo(body); + position += AssertName(body, position, "admin"); + position += AssertName(body, position, "secret"); + + Assert.Equal(3u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(position))); + Assert.Equal("ctx", Encoding.UTF8.GetString(body, position + 4, 3)); + Assert.Equal(position + 7, body.Length); + } + + [Fact] + public void SerializeWithPersonalAccessToken_PutsTheTokenInTheCredentialSlot() + { + var body = LoginRegister.SerializeWithPersonalAccessToken("token"); + + var position = AssertVersionInfo(body); + position += AssertName(body, position, "token"); + + Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(position))); + Assert.Equal(position + 4, body.Length); + } + + [Fact] + public void Serialize_RejectsAnEmptyCredential() + { + var error = Assert.Throws(() => + LoginRegister.Serialize("admin", string.Empty)); + + Assert.Equal(VsrError.INVALID_PASSWORD, error.StatusCode); + } + + [Fact] + public void Serialize_RejectsACredentialAboveTheLengthPrefix() + { + var error = Assert.Throws(() => + LoginRegister.Serialize("admin", new string('x', 256))); + + Assert.Equal(VsrError.INVALID_PASSWORD, error.StatusCode); + } + + /// + /// The u8 length prefix is also guarded inside the writer, for the fields no credential check covers. + /// + [Fact] + public void SerializeWithPersonalAccessToken_RejectsATokenAboveTheLengthPrefix() + { + var error = Assert.Throws(() => + LoginRegister.SerializeWithPersonalAccessToken(new string('x', 256))); + + Assert.Equal(VsrError.INVALID_PERSONAL_ACCESS_TOKEN, error.StatusCode); + } + + [Fact] + public void SerializeWithPersonalAccessToken_RejectsAnEmptyToken() + { + var error = Assert.Throws(() => + LoginRegister.SerializeWithPersonalAccessToken(string.Empty)); + + Assert.Equal(VsrError.INVALID_PERSONAL_ACCESS_TOKEN, error.StatusCode); + } + + [Fact] + public void Deserialize_ReadsTheRegisterReply() + { + var body = VsrTestPayloads.Concat(VsrTestPayloads.UInt32(42), new byte[8], VsrTestPayloads.UInt32(10243), + [5], "0.8.0"u8.ToArray()); + BinaryPrimitives.WriteUInt64LittleEndian(body.AsSpan(4), 100); + + var response = LoginRegister.Deserialize(body); + + Assert.Equal(42u, response.UserId); + Assert.Equal(100UL, response.Session); + Assert.Equal(10243u, response.ServerProtocolVersion); + Assert.Equal("0.8.0", response.ServerVersion); + } + + [Fact] + public void Deserialize_TruncatedReplyIsInvalidFormat() + { + var body = VsrTestPayloads.Concat(VsrTestPayloads.UInt32(42), new byte[8], VsrTestPayloads.UInt32(10243), + [5], "0.8.0"u8.ToArray()); + + for (var length = 0; length < body.Length; length++) + { + var truncated = body[..length]; + var exception = Assert.Throws(() => + LoginRegister.Deserialize(truncated)); + + Assert.Equal(VsrError.INVALID_FORMAT, exception.StatusCode); + } + } + + [Fact] + public void Deserialize_EmptyReplyIsTheTerminalRegisterRejection() + { + var exception = Assert.Throws(() => + LoginRegister.Deserialize(ReadOnlySpan.Empty)); + + Assert.Equal(VsrError.INVALID_FORMAT, exception.StatusCode); + Assert.Contains("rejected the login", exception.Message); + } +} diff --git a/foreign/csharp/Iggy_SDK_Tests/VsrTests/ServerAddressTests.cs b/foreign/csharp/Iggy_SDK_Tests/VsrTests/ServerAddressTests.cs new file mode 100644 index 0000000000..7288b45170 --- /dev/null +++ b/foreign/csharp/Iggy_SDK_Tests/VsrTests/ServerAddressTests.cs @@ -0,0 +1,85 @@ +// 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 Apache.Iggy.Utils; + +namespace Apache.Iggy.Tests.VsrTests; + +/// +/// Mirrors core/sdk/src/leader_aware.rs test_is_same_address and +/// test_normalize_address: the leader redirect check has to agree with the Rust SDK, or the two +/// disagree on whether a roster entry names the node this connection is already on. +/// +public sealed class ServerAddressTests +{ + [Theory] + [InlineData("127.0.0.1:8090", "127.0.0.1:8090")] + [InlineData("localhost:8090", "127.0.0.1:8090")] + [InlineData("LOCALHOST:8090", "127.0.0.1:8090")] + [InlineData("[::1]:8090", "[::1]:8090")] + public void IsSame_MatchesEquivalentEndpoints(string first, string second) + { + Assert.True(ServerAddress.IsSame(first, second)); + Assert.True(ServerAddress.IsSame(second, first)); + } + + [Theory] + [InlineData("127.0.0.1:8090", "127.0.0.1:8091")] + [InlineData("192.168.1.1:8090", "127.0.0.1:8090")] + [InlineData("localhost:8090", "127.0.0.1:8091")] + [InlineData("iggy-1:8090", "iggy-2:8090")] + [InlineData("127.0.0.1:8090", "")] + public void IsSame_SeparatesDistinctEndpoints(string first, string second) + { + Assert.False(ServerAddress.IsSame(first, second)); + Assert.False(ServerAddress.IsSame(second, first)); + } + + [Theory] + [InlineData("localhost:8090", "127.0.0.1:8090")] + [InlineData("LOCALHOST:8090", "127.0.0.1:8090")] + [InlineData("[::]:8090", "[::1]:8090")] + [InlineData("0.0.0.0:8090", "127.0.0.1:8090")] + [InlineData("my-localhost-1:8090", "my-localhost-1:8090")] + public void Normalize_ResolvesHostAliases(string address, string expected) + { + Assert.Equal(expected, ServerAddress.Normalize(address)); + } + + /// + /// A host name that merely contains an alias is a different node, and a server bound to the unspecified + /// address answers on the loopback one. + /// + [Theory] + [InlineData("my-localhost-1:8090", "127.0.0.1:8090", false)] + [InlineData("localhost.example.com:8090", "127.0.0.1:8090", false)] + [InlineData("0.0.0.0:8090", "127.0.0.1:8090", true)] + [InlineData("[::]:8090", "[::1]:8090", true)] + [InlineData("0.0.0.0:8090", "[::1]:8090", false)] + public void IsSame_ResolvesHostAliasesWithoutSubstringMatching(string first, string second, bool same) + { + Assert.Equal(same, ServerAddress.IsSame(first, second)); + Assert.Equal(same, ServerAddress.IsSame(second, first)); + } + + [Fact] + public void IsSame_FallsBackToNormalizedStringsForUnparsableAddresses() + { + Assert.True(ServerAddress.IsSame("Iggy-Node:8090", "iggy-node:8090")); + Assert.False(ServerAddress.IsSame("iggy-node:8090", "iggy-node:8091")); + } +} diff --git a/foreign/csharp/Iggy_SDK_Tests/VsrTests/SyncConsumerGroupTests.cs b/foreign/csharp/Iggy_SDK_Tests/VsrTests/SyncConsumerGroupTests.cs new file mode 100644 index 0000000000..1d185668b5 --- /dev/null +++ b/foreign/csharp/Iggy_SDK_Tests/VsrTests/SyncConsumerGroupTests.cs @@ -0,0 +1,93 @@ +// 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.Exceptions; +using Apache.Iggy.Vsr; + +namespace Apache.Iggy.Tests.VsrTests; + +public sealed class SyncConsumerGroupTests +{ + [Fact] + public void Decode_ReadsGenerationAndPartitions() + { + var assignment = SyncConsumerGroupAssignment.Decode(Encode(7, [0, 2, 4])); + + Assert.Equal(7ul, assignment.Generation); + Assert.Equal([0, 2, 4], [.. assignment.Partitions]); + } + + [Fact] + public void Decode_ReadsEmptyAssignment() + { + var assignment = SyncConsumerGroupAssignment.Decode(Encode(1, [])); + + Assert.Equal(1ul, assignment.Generation); + Assert.Empty(assignment.Partitions); + } + + [Fact] + public void Decode_IgnoresTrailingBytes() + { + var body = Encode(1, [3]).Concat(new byte[8]).ToArray(); + + Assert.Equal([3], [.. SyncConsumerGroupAssignment.Decode(body).Partitions]); + } + + [Fact] + public void Decode_TruncatedBodyThrows() + { + var body = Encode(7, [1, 2]); + for (var length = 0; length < body.Length; length++) + { + var truncated = body[..length]; + var error = Assert.Throws(() => + SyncConsumerGroupAssignment.Decode(truncated)); + + Assert.Equal(VsrError.INVALID_COMMAND, error.StatusCode); + } + } + + /// + /// A count the body cannot back must fail rather than allocate for it: the value is attacker-reachable + /// through a corrupted frame. + /// + [Fact] + public void Decode_ImplausiblePartitionCountThrows() + { + var body = new byte[12]; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8, 4), uint.MaxValue); + + var error = Assert.Throws(() => SyncConsumerGroupAssignment.Decode(body)); + + Assert.Equal(VsrError.INVALID_COMMAND, error.StatusCode); + } + + private static byte[] Encode(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 i = 0; i < partitions.Length; i++) + { + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12 + i * 4, 4), partitions[i]); + } + + return body; + } +} diff --git a/foreign/csharp/Iggy_SDK_Tests/VsrTests/VsrHeaderTests.cs b/foreign/csharp/Iggy_SDK_Tests/VsrTests/VsrHeaderTests.cs new file mode 100644 index 0000000000..f35843ff3b --- /dev/null +++ b/foreign/csharp/Iggy_SDK_Tests/VsrTests/VsrHeaderTests.cs @@ -0,0 +1,292 @@ +// 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.Exceptions; +using Apache.Iggy.Utils; +using Apache.Iggy.Vsr; + +namespace Apache.Iggy.Tests.VsrTests; + +public sealed class VsrHeaderTests +{ + private static ConsensusSession BoundSession(ulong session = 5) + { + var consensusSession = new ConsensusSession(0x0102_0304_0506_0708); + consensusSession.Resolve(VsrOperation.Register); + consensusSession.Bind(session); + + return consensusSession; + } + + private static byte[] Encode(ConsensusSession session, int code, byte[] payload, out int totalSize) + { + var header = new byte[VsrHeader.HEADER_SIZE]; + totalSize = VsrHeader.EncodeRequestHeader(header, session, code, payload); + + return header; + } + + private static ulong ReadUInt64(byte[] header, int offset) + { + return BinaryPrimitives.ReadUInt64LittleEndian(header.AsSpan(offset)); + } + + private static uint ReadUInt32(byte[] header, int offset) + { + return BinaryPrimitives.ReadUInt32LittleEndian(header.AsSpan(offset)); + } + + /// + /// A namespace failure must leave the session exactly as it was. Consuming the request id would gap the + /// next metadata request, and dropping the binding would leave the client unable to send or re-login. + /// + [Fact] + public void Encode_NamespaceFailureConsumesNothingAndKeepsTheSession() + { + var session = BoundSession(); + var payload = VsrTestPayloads.ConsumerOffset(VsrTestPayloads.NumericIdentifier(4), + VsrTestPayloads.NumericIdentifier(5), null); + var header = new byte[VsrHeader.HEADER_SIZE]; + + var exception = Assert.Throws(() => + VsrHeader.EncodeRequestHeader(header, session, CommandCodes.STORE_CONSUMER_OFFSET_CODE, payload)); + + Assert.Equal(VsrError.INVALID_IDENTIFIER, exception.StatusCode); + Assert.True(session.IsBound); + Assert.Equal(1UL, session.RequestCounter); + + VsrHeader.EncodeRequestHeader(header, session, CommandCodes.CREATE_STREAM_CODE, [1]); + + Assert.Equal(1UL, ReadUInt64(header, VsrHeader.REQUEST_ID_OFFSET)); + } + + [Fact] + public void Encode_RegisterUsesZeroRequestAndSessionOnMetadataNamespace() + { + var session = new ConsensusSession(7); + var payload = LoginRegister.Serialize("admin", "secret"); + + var header = Encode(session, CommandCodes.LOGIN_REGISTER_CODE, payload, out var totalSize); + + Assert.Equal(VsrHeader.HEADER_SIZE + payload.Length, totalSize); + Assert.Equal((uint)totalSize, ReadUInt32(header, VsrHeader.SIZE_OFFSET)); + Assert.Equal((byte)Command2.Request, header[VsrHeader.COMMAND_OFFSET]); + Assert.Equal((byte)VsrOperation.Register, header[VsrHeader.REQUEST_OPERATION_OFFSET]); + Assert.Equal(0UL, ReadUInt64(header, VsrHeader.REQUEST_ID_OFFSET)); + Assert.Equal(0UL, ReadUInt64(header, VsrHeader.REQUEST_SESSION_OFFSET)); + Assert.Equal(0UL, ReadUInt64(header, VsrHeader.REQUEST_TIMESTAMP_OFFSET)); + Assert.Equal(VsrNamespace.METADATA_CONSENSUS_NAMESPACE, ReadUInt64(header, VsrHeader.REQUEST_NAMESPACE_OFFSET)); + Assert.Equal(7UL, ReadUInt64(header, VsrHeader.REQUEST_CLIENT_OFFSET)); + Assert.Equal(0UL, ReadUInt64(header, VsrHeader.REQUEST_CLIENT_OFFSET + 8)); + } + + [Fact] + public void Encode_WritesClientIdAsTwoLittleEndianHalvesLowFirst() + { + var session = new ConsensusSession(new UInt128(0xAABB_CCDD_EEFF_0011, 0x1122_3344_5566_7788)); + + var header = Encode(session, CommandCodes.PING_CODE, [], out _); + + Assert.Equal(0x1122_3344_5566_7788UL, ReadUInt64(header, VsrHeader.REQUEST_CLIENT_OFFSET)); + Assert.Equal(0xAABB_CCDD_EEFF_0011UL, ReadUInt64(header, VsrHeader.REQUEST_CLIENT_OFFSET + 8)); + } + + [Fact] + public void Encode_NonReplicatedDoesNotAdvanceCounterAndCarriesCodeInReserved() + { + var session = BoundSession(); + + var header = Encode(session, CommandCodes.PING_CODE, [], out _); + + Assert.Equal((byte)VsrOperation.NonReplicated, header[VsrHeader.REQUEST_OPERATION_OFFSET]); + Assert.Equal(1UL, ReadUInt64(header, VsrHeader.REQUEST_ID_OFFSET)); + Assert.Equal(1UL, session.RequestCounter); + Assert.Equal(5UL, ReadUInt64(header, VsrHeader.REQUEST_SESSION_OFFSET)); + Assert.Equal(0UL, ReadUInt64(header, VsrHeader.REQUEST_NAMESPACE_OFFSET)); + Assert.Equal((uint)CommandCodes.PING_CODE, ReadUInt32(header, VsrHeader.REQUEST_RESERVED_OFFSET)); + } + + [Fact] + public void Encode_UnknownCodeRidesNonReplicated() + { + var session = BoundSession(); + + var header = Encode(session, 9999, [], out _); + + Assert.Equal((byte)VsrOperation.NonReplicated, header[VsrHeader.REQUEST_OPERATION_OFFSET]); + Assert.Equal(9999u, ReadUInt32(header, VsrHeader.REQUEST_RESERVED_OFFSET)); + } + + [Fact] + public void Encode_NonReplicatedWithoutSessionSendsSessionZero() + { + var session = new ConsensusSession(1); + + var header = Encode(session, CommandCodes.PING_CODE, [], out _); + + Assert.Equal(0UL, ReadUInt64(header, VsrHeader.REQUEST_SESSION_OFFSET)); + } + + [Fact] + public void Encode_MetadataAdvancesTheCounter() + { + var session = BoundSession(); + + var header = Encode(session, CommandCodes.CREATE_STREAM_CODE, [1, 2, 3], out _); + + Assert.Equal((byte)VsrOperation.CreateStream, header[VsrHeader.REQUEST_OPERATION_OFFSET]); + Assert.Equal(1UL, ReadUInt64(header, VsrHeader.REQUEST_ID_OFFSET)); + Assert.Equal(2UL, session.RequestCounter); + Assert.Equal(0UL, ReadUInt64(header, VsrHeader.REQUEST_NAMESPACE_OFFSET)); + Assert.Equal(0u, ReadUInt32(header, VsrHeader.REQUEST_RESERVED_OFFSET)); + } + + [Fact] + public void Encode_LogoutAdvancesTheCounterOnMetadataNamespace() + { + var session = BoundSession(); + + var header = Encode(session, CommandCodes.LOGOUT_USER_CODE, [], out _); + + Assert.Equal((byte)VsrOperation.Logout, header[VsrHeader.REQUEST_OPERATION_OFFSET]); + Assert.Equal(1UL, ReadUInt64(header, VsrHeader.REQUEST_ID_OFFSET)); + Assert.Equal(2UL, session.RequestCounter); + Assert.Equal(VsrNamespace.METADATA_CONSENSUS_NAMESPACE, ReadUInt64(header, VsrHeader.REQUEST_NAMESPACE_OFFSET)); + } + + [Fact] + public void Encode_PartitionOpDoesNotAdvanceTheCounter() + { + var session = BoundSession(); + var payload = VsrTestPayloads.SendMessagesToPartition(2, 3, 4); + + var header = Encode(session, CommandCodes.SEND_MESSAGES_CODE, payload, out _); + + Assert.Equal((byte)VsrOperation.SendMessages, header[VsrHeader.REQUEST_OPERATION_OFFSET]); + Assert.Equal(1UL, ReadUInt64(header, VsrHeader.REQUEST_ID_OFFSET)); + Assert.Equal(1UL, session.RequestCounter); + Assert.Equal(VsrNamespace.Pack(2, 3, 4), ReadUInt64(header, VsrHeader.REQUEST_NAMESPACE_OFFSET)); + } + + [Fact] + public void Encode_ReplicatedOpWithoutSessionIsUnauthenticated() + { + var session = new ConsensusSession(1); + + var exception = Assert.Throws(() => + Encode(session, CommandCodes.CREATE_STREAM_CODE, [1], out _)); + + Assert.Equal(VsrError.UNAUTHENTICATED, exception.StatusCode); + Assert.Equal(1UL, session.RequestCounter); + } + + [Fact] + public void Encode_ClearsStaleBytesFromAReusedBuffer() + { + var header = new byte[VsrHeader.HEADER_SIZE]; + Array.Fill(header, (byte)0xFF); + var session = BoundSession(); + + VsrHeader.EncodeRequestHeader(header, session, CommandCodes.CREATE_STREAM_CODE, [1]); + + Assert.Equal(0u, ReadUInt32(header, VsrHeader.REQUEST_RESERVED_OFFSET)); + Assert.All(header[..VsrHeader.SIZE_OFFSET], stale => Assert.Equal(0, stale)); + } + + [Fact] + public void Encode_RejectsAShortBuffer() + { + var session = BoundSession(); + + Assert.Throws(() => + VsrHeader.EncodeRequestHeader(new byte[VsrHeader.HEADER_SIZE - 1], session, CommandCodes.PING_CODE, [])); + } + + [Fact] + public void PeekCommand_MapsOnlyTheClientVisibleFrames() + { + var header = new byte[VsrHeader.HEADER_SIZE]; + + header[VsrHeader.COMMAND_OFFSET] = (byte)Command2.Reply; + Assert.Equal(Command2.Reply, VsrHeader.PeekCommand(header)); + + header[VsrHeader.COMMAND_OFFSET] = (byte)Command2.Eviction; + Assert.Equal(Command2.Eviction, VsrHeader.PeekCommand(header)); + + header[VsrHeader.COMMAND_OFFSET] = 6; + Assert.Equal(Command2.Reserved, VsrHeader.PeekCommand(header)); + } + + [Fact] + public void ReadReplyOperation_RejectsAnUnknownDiscriminant() + { + var header = new byte[VsrHeader.HEADER_SIZE]; + header[VsrHeader.REPLY_OPERATION_OFFSET] = 200; + + var exception = Assert.Throws(() => VsrHeader.ReadReplyOperation(header)); + + Assert.Equal(VsrError.INVALID_COMMAND, exception.StatusCode); + } + + [Fact] + public void ReadEviction_ReadsReasonAndProtocolWindow() + { + var header = new byte[VsrHeader.HEADER_SIZE]; + header[VsrHeader.EVICTION_REASON_OFFSET] = (byte)EvictionReason.IncompatibleProtocol; + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(VsrHeader.EVICTION_PROTOCOL_VERSION_OFFSET), 10243); + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(VsrHeader.EVICTION_PROTOCOL_VERSION_MIN_OFFSET), 10240); + + var eviction = VsrHeader.ReadEviction(header); + + Assert.Equal(EvictionReason.IncompatibleProtocol, eviction.Reason); + Assert.Equal(10243u, eviction.ServerProtocolVersion); + Assert.Equal(10240u, eviction.ServerProtocolVersionMin); + } + + /// + /// An undecodable reason stays null rather than collapsing onto a named one, and grades through the + /// shared grader's catch-all like every reason the Rust mapping does not name. + /// + [Fact] + public void ReadEviction_UnknownReasonDecodesAsUndecodable() + { + var header = new byte[VsrHeader.HEADER_SIZE]; + header[VsrHeader.EVICTION_REASON_OFFSET] = 200; + + Assert.Null(VsrHeader.ReadEviction(header).Reason); + Assert.Equal(VsrError.INVALID_COMMAND, + Assert.IsType(VsrReplyDecoder.ToException(VsrHeader.ReadEviction(header))) + .StatusCode); + } + + /// + /// Reason 0 is the Reserved sentinel the server rejects on the wire, so it decodes as an unrecognized + /// reason rather than as one. Rust grades it through the same catch-all. + /// + [Fact] + public void ReadEviction_ReservedReasonDecodesAsUnknown() + { + var header = new byte[VsrHeader.HEADER_SIZE]; + header[VsrHeader.EVICTION_REASON_OFFSET] = (byte)EvictionReason.Reserved; + + Assert.Null(VsrHeader.ReadEviction(header).Reason); + Assert.Equal(VsrError.INVALID_COMMAND, + Assert.IsType(VsrReplyDecoder.ToException(VsrHeader.ReadEviction(header))) + .StatusCode); + } +} diff --git a/foreign/csharp/Iggy_SDK_Tests/VsrTests/VsrNamespaceTests.cs b/foreign/csharp/Iggy_SDK_Tests/VsrTests/VsrNamespaceTests.cs new file mode 100644 index 0000000000..3ce7095600 --- /dev/null +++ b/foreign/csharp/Iggy_SDK_Tests/VsrTests/VsrNamespaceTests.cs @@ -0,0 +1,282 @@ +// 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.Exceptions; +using Apache.Iggy.Utils; +using Apache.Iggy.Vsr; + +namespace Apache.Iggy.Tests.VsrTests; + +public sealed class VsrNamespaceTests +{ + [Fact] + public void Pack_PlacesEachFieldAtItsShift() + { + Assert.Equal((2UL << 32) | (3UL << 20) | 4UL, VsrNamespace.Pack(2, 3, 4)); + } + + [Fact] + public void MetadataSentinel_SitsAboveThePackedRange() + { + var packedMax = VsrNamespace.Pack(VsrNamespace.MAX_STREAMS - 1, VsrNamespace.MAX_TOPICS - 1, + VsrNamespace.MAX_PARTITIONS - 1); + + Assert.True(VsrNamespace.METADATA_CONSENSUS_NAMESPACE > packedMax); + } + + [Theory] + [InlineData((byte)VsrOperation.Register)] + [InlineData((byte)VsrOperation.Logout)] + public void ForRequest_ControlPlaneOpsTargetTheMetadataReplica(byte operation) + { + Assert.Equal(VsrNamespace.METADATA_CONSENSUS_NAMESPACE, + VsrNamespace.ForRequest(CommandCodes.LOGOUT_USER_CODE, [], (VsrOperation)operation)); + } + + [Fact] + public void ForRequest_MetadataAndNonReplicatedOpsUseZero() + { + Assert.Equal(0UL, VsrNamespace.ForRequest(CommandCodes.CREATE_STREAM_CODE, [], VsrOperation.CreateStream)); + Assert.Equal(0UL, VsrNamespace.ForRequest(CommandCodes.PING_CODE, [], VsrOperation.NonReplicated)); + } + + [Fact] + public void ForRequest_SendMessagesPacksThePartitionId() + { + var payload = VsrTestPayloads.SendMessagesToPartition(1, 2, 3); + + Assert.Equal(VsrNamespace.Pack(1, 2, 3), + VsrNamespace.ForRequest(CommandCodes.SEND_MESSAGES_CODE, payload, VsrOperation.SendMessages)); + } + + [Fact] + public void ForRequest_SendMessagesIgnoresTheMessageBatchAfterTheMetadata() + { + var payload = VsrTestPayloads.Concat(VsrTestPayloads.SendMessagesToPartition(1, 2, 3), [0xFF, 0xFF, 0xFF]); + + Assert.Equal(VsrNamespace.Pack(1, 2, 3), + VsrNamespace.ForRequest(CommandCodes.SEND_MESSAGES_CODE, payload, VsrOperation.SendMessages)); + } + + [Theory] + [InlineData(1)] + [InlineData(3)] + public void ForRequest_SendMessagesRejectsServerSidePartitioning(byte kind) + { + var payload = VsrTestPayloads.SendMessages(VsrTestPayloads.NumericIdentifier(1), + VsrTestPayloads.NumericIdentifier(2), kind, kind == 1 ? [] : "key"u8.ToArray()); + + var exception = Assert.Throws(() => + VsrNamespace.ForRequest(CommandCodes.SEND_MESSAGES_CODE, payload, VsrOperation.SendMessages)); + + Assert.Equal(VsrError.FEATURE_UNAVAILABLE, exception.StatusCode); + } + + [Fact] + public void ForRequest_NamedIdentifiersResolveToZero() + { + var payload = VsrTestPayloads.SendMessages(VsrTestPayloads.NamedIdentifier("stream"), + VsrTestPayloads.NumericIdentifier(2), 2, VsrTestPayloads.UInt32(3)); + + Assert.Equal(0UL, + VsrNamespace.ForRequest(CommandCodes.SEND_MESSAGES_CODE, payload, VsrOperation.SendMessages)); + } + + [Theory] + [InlineData(1, 3)] + [InlineData(2, 0)] + [InlineData(7, 4)] + public void ForRequest_MalformedIdentifierIsInvalidCommandNotANamedIdentifier(byte kind, byte length) + { + var streamId = VsrTestPayloads.Concat([kind, length], new byte[length]); + var payload = VsrTestPayloads.SendMessages(streamId, VsrTestPayloads.NumericIdentifier(2), 2, + VsrTestPayloads.UInt32(3)); + + var exception = Assert.Throws(() => + VsrNamespace.ForRequest(CommandCodes.SEND_MESSAGES_CODE, payload, VsrOperation.SendMessages)); + + Assert.Equal(VsrError.INVALID_COMMAND, exception.StatusCode); + } + + [Fact] + public void ForRequest_UnknownConsumerKindIsInvalidCommand() + { + var payload = VsrTestPayloads.ConsumerOffset(VsrTestPayloads.NumericIdentifier(4), + VsrTestPayloads.NumericIdentifier(5), 6); + payload[0] = 7; + + var exception = Assert.Throws(() => + VsrNamespace.ForRequest(CommandCodes.STORE_CONSUMER_OFFSET_CODE, payload, + VsrOperation.StoreConsumerOffset)); + + Assert.Equal(VsrError.INVALID_COMMAND, exception.StatusCode); + } + + [Theory] + [InlineData(CommandCodes.STORE_CONSUMER_OFFSET_CODE, (byte)VsrOperation.StoreConsumerOffset)] + [InlineData(CommandCodes.DELETE_CONSUMER_OFFSET_CODE, (byte)VsrOperation.DeleteConsumerOffset)] + [InlineData(CommandCodes.STORE_CONSUMER_OFFSET_2_CODE, (byte)VsrOperation.StoreConsumerOffset2)] + [InlineData(CommandCodes.DELETE_CONSUMER_OFFSET_2_CODE, (byte)VsrOperation.DeleteConsumerOffset2)] + public void ForRequest_ConsumerOffsetOpsPackTheirPartition(int code, byte operation) + { + var payload = VsrTestPayloads.ConsumerOffset(VsrTestPayloads.NumericIdentifier(4), + VsrTestPayloads.NumericIdentifier(5), 6); + + Assert.Equal(VsrNamespace.Pack(4, 5, 6), VsrNamespace.ForRequest(code, payload, (VsrOperation)operation)); + } + + [Fact] + public void ForRequest_ConsumerOffsetWithoutAPartitionIsRejected() + { + var payload = VsrTestPayloads.ConsumerOffset(VsrTestPayloads.NumericIdentifier(4), + VsrTestPayloads.NumericIdentifier(5), null); + + var exception = Assert.Throws(() => + VsrNamespace.ForRequest(CommandCodes.STORE_CONSUMER_OFFSET_CODE, payload, + VsrOperation.StoreConsumerOffset)); + + Assert.Equal(VsrError.INVALID_IDENTIFIER, exception.StatusCode); + } + + /// + /// The partition id is missing, not truncated, so the caller learns the request cannot be routed rather + /// than that its payload is malformed - the length guard must not pre-empt that. + /// + [Fact] + public void ForRequest_ConsumerOffsetWithoutAPartitionIsRejectedEvenWhenTheBodyEndsThere() + { + byte[] full = VsrTestPayloads.ConsumerOffset(VsrTestPayloads.NumericIdentifier(4), + VsrTestPayloads.NumericIdentifier(5), null); + + // Drop the four padding bytes that follow the absent-partition flag. + var payload = full.AsSpan(0, full.Length - 4).ToArray(); + + var exception = Assert.Throws(() => + VsrNamespace.ForRequest(CommandCodes.STORE_CONSUMER_OFFSET_CODE, payload, + VsrOperation.StoreConsumerOffset)); + + Assert.Equal(VsrError.INVALID_IDENTIFIER, exception.StatusCode); + } + + /// + /// A metadata length near int.MaxValue overflows when four is added to it, so the bounds check has to be + /// written as a subtraction or it passes and the slice throws an untyped exception instead. + /// + [Fact] + public void ForRequest_SendMessagesMetadataLengthNearIntMaxIsInvalidCommand() + { + var payload = new byte[64]; + BinaryPrimitives.WriteUInt32LittleEndian(payload, (uint)int.MaxValue - 2); + + var error = Assert.Throws(() => + VsrNamespace.ForRequest(CommandCodes.SEND_MESSAGES_CODE, payload, VsrOperation.SendMessages)); + + Assert.Equal(VsrError.INVALID_COMMAND, error.StatusCode); + } + + [Fact] + public void ForRequest_DeleteSegmentsPacksThePartition() + { + var payload = VsrTestPayloads.DeleteSegments(VsrTestPayloads.NumericIdentifier(7), + VsrTestPayloads.NumericIdentifier(8), 9); + + Assert.Equal(VsrNamespace.Pack(7, 8, 9), + VsrNamespace.ForRequest(CommandCodes.DELETE_SEGMENTS_CODE, payload, VsrOperation.DeleteSegments)); + } + + [Theory] + [InlineData(VsrNamespace.MAX_STREAMS, 1, 1)] + [InlineData(1, VsrNamespace.MAX_TOPICS, 1)] + [InlineData(1, 1, VsrNamespace.MAX_PARTITIONS)] + public void ForRequest_RejectsIdentifiersOutsideThePackableRange(uint streamId, uint topicId, uint partitionId) + { + var payload = VsrTestPayloads.SendMessagesToPartition(streamId, topicId, partitionId); + + var exception = Assert.Throws(() => + VsrNamespace.ForRequest(CommandCodes.SEND_MESSAGES_CODE, payload, VsrOperation.SendMessages)); + + Assert.Equal(VsrError.INVALID_IDENTIFIER, exception.StatusCode); + } + + /// A partition id is four bytes; anything else means the caller framed a different value. + [Theory] + [InlineData(2)] + [InlineData(8)] + public void ForRequest_SendMessagesRejectsAPartitionIdOfTheWrongWidth(byte length) + { + var payload = VsrTestPayloads.SendMessages(VsrTestPayloads.NumericIdentifier(1), + VsrTestPayloads.NumericIdentifier(2), 2, new byte[length]); + + var error = Assert.Throws(() => + VsrNamespace.ForRequest(CommandCodes.SEND_MESSAGES_CODE, payload, VsrOperation.SendMessages)); + Assert.Equal(VsrError.INVALID_COMMAND, error.StatusCode); + } + + [Fact] + public void ForRequest_TruncatedSendMessagesPayloadIsInvalidCommand() + { + var payload = VsrTestPayloads.SendMessagesToPartition(1, 2, 3); + for (var length = 0; length < payload.Length; length++) + { + var exception = Assert.Throws(() => + VsrNamespace.ForRequest(CommandCodes.SEND_MESSAGES_CODE, payload.AsSpan(0, length).ToArray(), + VsrOperation.SendMessages)); + + Assert.Equal(VsrError.INVALID_COMMAND, exception.StatusCode); + } + } + + [Fact] + public void ForRequest_TruncatedConsumerOffsetPayloadIsInvalidCommand() + { + var payload = VsrTestPayloads.ConsumerOffset(VsrTestPayloads.NumericIdentifier(4), + VsrTestPayloads.NumericIdentifier(5), 6); + for (var length = 0; length < payload.Length; length++) + { + var exception = Assert.Throws(() => + VsrNamespace.ForRequest(CommandCodes.STORE_CONSUMER_OFFSET_CODE, payload.AsSpan(0, length).ToArray(), + VsrOperation.StoreConsumerOffset)); + + Assert.Equal(VsrError.INVALID_COMMAND, exception.StatusCode); + } + } + + [Fact] + public void ForRequest_TruncatedDeleteSegmentsPayloadIsInvalidCommand() + { + var payload = VsrTestPayloads.DeleteSegments(VsrTestPayloads.NumericIdentifier(7), + VsrTestPayloads.NumericIdentifier(8), 9); + for (var length = 0; length < payload.Length - 4; length++) + { + var exception = Assert.Throws(() => + VsrNamespace.ForRequest(CommandCodes.DELETE_SEGMENTS_CODE, payload.AsSpan(0, length).ToArray(), + VsrOperation.DeleteSegments)); + + Assert.Equal(VsrError.INVALID_COMMAND, exception.StatusCode); + } + } + + [Fact] + public void ForRequest_UnroutablePartitionCodeIsFeatureUnavailable() + { + var exception = Assert.Throws(() => + VsrNamespace.ForRequest(CommandCodes.POLL_MESSAGES_CODE, [], VsrOperation.SendMessages)); + + Assert.Equal(VsrError.FEATURE_UNAVAILABLE, exception.StatusCode); + } +} diff --git a/foreign/csharp/Iggy_SDK_Tests/VsrTests/VsrOperationTests.cs b/foreign/csharp/Iggy_SDK_Tests/VsrTests/VsrOperationTests.cs new file mode 100644 index 0000000000..bd5686e031 --- /dev/null +++ b/foreign/csharp/Iggy_SDK_Tests/VsrTests/VsrOperationTests.cs @@ -0,0 +1,167 @@ +// 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 Apache.Iggy.Utils; +using Apache.Iggy.Exceptions; +using Apache.Iggy.Vsr; + +namespace Apache.Iggy.Tests.VsrTests; + +public sealed class VsrOperationTests +{ + [Theory] + [InlineData(CommandCodes.LOGOUT_USER_CODE, (byte)VsrOperation.Logout)] + [InlineData(CommandCodes.CREATE_USER_CODE, (byte)VsrOperation.CreateUser)] + [InlineData(CommandCodes.DELETE_USER_CODE, (byte)VsrOperation.DeleteUser)] + [InlineData(CommandCodes.UPDATE_USER_CODE, (byte)VsrOperation.UpdateUser)] + [InlineData(CommandCodes.UPDATE_PERMISSIONS_CODE, (byte)VsrOperation.UpdatePermissions)] + [InlineData(CommandCodes.CHANGE_PASSWORD_CODE, (byte)VsrOperation.ChangePassword)] + [InlineData(CommandCodes.CREATE_PERSONAL_ACCESS_TOKEN_CODE, (byte)VsrOperation.CreatePersonalAccessToken)] + [InlineData(CommandCodes.DELETE_PERSONAL_ACCESS_TOKEN_CODE, (byte)VsrOperation.DeletePersonalAccessToken)] + [InlineData(CommandCodes.SEND_MESSAGES_CODE, (byte)VsrOperation.SendMessages)] + [InlineData(CommandCodes.STORE_CONSUMER_OFFSET_CODE, (byte)VsrOperation.StoreConsumerOffset)] + [InlineData(CommandCodes.DELETE_CONSUMER_OFFSET_CODE, (byte)VsrOperation.DeleteConsumerOffset)] + [InlineData(CommandCodes.STORE_CONSUMER_OFFSET_2_CODE, (byte)VsrOperation.StoreConsumerOffset2)] + [InlineData(CommandCodes.DELETE_CONSUMER_OFFSET_2_CODE, (byte)VsrOperation.DeleteConsumerOffset2)] + [InlineData(CommandCodes.CREATE_STREAM_CODE, (byte)VsrOperation.CreateStream)] + [InlineData(CommandCodes.DELETE_STREAM_CODE, (byte)VsrOperation.DeleteStream)] + [InlineData(CommandCodes.UPDATE_STREAM_CODE, (byte)VsrOperation.UpdateStream)] + [InlineData(CommandCodes.PURGE_STREAM_CODE, (byte)VsrOperation.PurgeStream)] + [InlineData(CommandCodes.CREATE_TOPIC_CODE, (byte)VsrOperation.CreateTopic)] + [InlineData(CommandCodes.DELETE_TOPIC_CODE, (byte)VsrOperation.DeleteTopic)] + [InlineData(CommandCodes.UPDATE_TOPIC_CODE, (byte)VsrOperation.UpdateTopic)] + [InlineData(CommandCodes.PURGE_TOPIC_CODE, (byte)VsrOperation.PurgeTopic)] + [InlineData(CommandCodes.CREATE_PARTITIONS_CODE, (byte)VsrOperation.CreatePartitions)] + [InlineData(CommandCodes.DELETE_PARTITIONS_CODE, (byte)VsrOperation.DeletePartitions)] + [InlineData(CommandCodes.DELETE_SEGMENTS_CODE, (byte)VsrOperation.DeleteSegments)] + [InlineData(CommandCodes.CREATE_CONSUMER_GROUP_CODE, (byte)VsrOperation.CreateConsumerGroup)] + [InlineData(CommandCodes.DELETE_CONSUMER_GROUP_CODE, (byte)VsrOperation.DeleteConsumerGroup)] + [InlineData(CommandCodes.JOIN_CONSUMER_GROUP_CODE, (byte)VsrOperation.JoinConsumerGroup)] + [InlineData(CommandCodes.LEAVE_CONSUMER_GROUP_CODE, (byte)VsrOperation.LeaveConsumerGroup)] + public void ForCode_MapsReplicatedCommands(int code, byte expected) + { + Assert.Equal((VsrOperation)expected, VsrOperations.ForCode(code)); + } + + [Theory] + [InlineData(CommandCodes.PING_CODE)] + [InlineData(CommandCodes.POLL_MESSAGES_CODE)] + [InlineData(CommandCodes.GET_STREAM_CODE)] + [InlineData(CommandCodes.GET_CONSUMER_OFFSET_CODE)] + [InlineData(9999)] + public void ForCode_ReadsAndUnknownCodesRideNonReplicated(int code) + { + Assert.Equal(VsrOperation.NonReplicated, VsrOperations.ForCode(code)); + } + + [Theory] + [InlineData(CommandCodes.LOGIN_REGISTER_CODE)] + [InlineData(CommandCodes.LOGIN_REGISTER_WITH_PAT_CODE)] + public void ForCode_MapsTheRegisterHandshake(int code) + { + Assert.Equal(VsrOperation.Register, VsrOperations.ForCode(code)); + } + + /// + /// A classic login sent as a consensus request would ride NonReplicated and look like it worked while no + /// session is ever bound, so the classification rejects it instead. + /// + [Theory] + [InlineData(CommandCodes.LOGIN_USER_CODE)] + [InlineData(CommandCodes.LOGIN_WITH_PERSONAL_ACCESS_TOKEN_CODE)] + public void ForCode_RejectsLoginCodes(int code) + { + var error = Assert.Throws(() => VsrOperations.ForCode(code)); + + Assert.Equal(VsrError.INVALID_COMMAND, error.StatusCode); + } + + [Fact] + public void Classification_MatchesTheServerSideRanges() + { + Assert.True(VsrOperation.CreateTopicWithAssignments.IsInternal()); + Assert.True(VsrOperation.CreateTopicWithAssignments.IsMetadata()); + Assert.True(VsrOperation.CreateStream.IsMetadata()); + Assert.False(VsrOperation.CreateStream.IsPartition()); + Assert.True(VsrOperation.SendMessages.IsPartition()); + Assert.False(VsrOperation.SendMessages.IsMetadata()); + + // Resolved server-side to an internal truncate, so it is neither plane despite carrying a namespace. + Assert.False(VsrOperation.DeleteSegments.IsPartition()); + Assert.False(VsrOperation.DeleteSegments.IsMetadata()); + } + + [Fact] + public void IsResultFramed_CoversMetadataAndConsumerOffsetsOnly() + { + Assert.True(VsrOperation.CreateStream.IsResultFramed()); + Assert.True(VsrOperation.StoreConsumerOffset.IsResultFramed()); + Assert.True(VsrOperation.DeleteConsumerOffset2.IsResultFramed()); + Assert.False(VsrOperation.SendMessages.IsResultFramed()); + Assert.False(VsrOperation.NonReplicated.IsResultFramed()); + Assert.False(VsrOperation.Register.IsResultFramed()); + Assert.False(VsrOperation.Logout.IsResultFramed()); + } + + [Fact] + public void IsKnown_RejectsUndefinedDiscriminants() + { + Assert.True(VsrOperations.IsKnown((byte)VsrOperation.SendMessages)); + Assert.False(VsrOperations.IsKnown(163)); + Assert.False(VsrOperations.IsKnown(200)); + } + + /// Every arm of the control-plane table: shard 0 owns these, so a miss would route to a data shard. + [Theory] + [InlineData((byte)VsrOperation.CreateStream)] + [InlineData((byte)VsrOperation.UpdateStream)] + [InlineData((byte)VsrOperation.DeleteStream)] + [InlineData((byte)VsrOperation.PurgeStream)] + [InlineData((byte)VsrOperation.CreateTopic)] + [InlineData((byte)VsrOperation.UpdateTopic)] + [InlineData((byte)VsrOperation.DeleteTopic)] + [InlineData((byte)VsrOperation.PurgeTopic)] + [InlineData((byte)VsrOperation.CreatePartitions)] + [InlineData((byte)VsrOperation.DeletePartitions)] + [InlineData((byte)VsrOperation.CreateConsumerGroup)] + [InlineData((byte)VsrOperation.DeleteConsumerGroup)] + [InlineData((byte)VsrOperation.CreateUser)] + [InlineData((byte)VsrOperation.UpdateUser)] + [InlineData((byte)VsrOperation.DeleteUser)] + [InlineData((byte)VsrOperation.ChangePassword)] + [InlineData((byte)VsrOperation.UpdatePermissions)] + [InlineData((byte)VsrOperation.CreatePersonalAccessToken)] + [InlineData((byte)VsrOperation.DeletePersonalAccessToken)] + [InlineData((byte)VsrOperation.JoinConsumerGroup)] + [InlineData((byte)VsrOperation.LeaveConsumerGroup)] + public void IsMetadata_CoversTheWholeControlPlane(byte operation) + { + Assert.True(((VsrOperation)operation).IsMetadata()); + } + + [Theory] + [InlineData((byte)VsrOperation.SendMessages)] + [InlineData((byte)VsrOperation.StoreConsumerOffset)] + [InlineData((byte)VsrOperation.DeleteConsumerOffset2)] + [InlineData((byte)VsrOperation.DeleteSegments)] + [InlineData((byte)VsrOperation.NonReplicated)] + [InlineData((byte)VsrOperation.Logout)] + public void IsMetadata_LeavesTheDataPlaneOut(byte operation) + { + Assert.False(((VsrOperation)operation).IsMetadata()); + } +} diff --git a/foreign/csharp/Iggy_SDK_Tests/VsrTests/VsrProtocolDriftTests.cs b/foreign/csharp/Iggy_SDK_Tests/VsrTests/VsrProtocolDriftTests.cs new file mode 100644 index 0000000000..2f7db548d7 --- /dev/null +++ b/foreign/csharp/Iggy_SDK_Tests/VsrTests/VsrProtocolDriftTests.cs @@ -0,0 +1,849 @@ +// 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.Globalization; +using System.Reflection; +using System.Text.RegularExpressions; +using Apache.Iggy.Exceptions; +using Apache.Iggy.Utils; +using Apache.Iggy.Vsr; + +namespace Apache.Iggy.Tests.VsrTests; + +/// +/// Asserts the .NET VSR constants still match the Rust wire definitions they were ported from. The unit +/// tests above pin the .NET side against itself; this one pins it against +/// core/binary_protocol/, so a Rust-side change fails here instead of in production framing. +/// Skipped when the Rust sources are not on disk (packaged SDK builds). +/// +public sealed class VsrProtocolDriftTests +{ + private const string HeaderPath = "core/binary_protocol/src/consensus/header.rs"; + private const string CommandPath = "core/binary_protocol/src/consensus/command.rs"; + private const string OperationPath = "core/binary_protocol/src/consensus/operation.rs"; + private const string NamespacePath = "core/binary_protocol/src/namespace.rs"; + private const string CodesPath = "core/binary_protocol/src/codes.rs"; + private const string DispatchPath = "core/binary_protocol/src/dispatch.rs"; + private const string ErrorPath = "core/common/src/error/iggy_error.rs"; + private const string ManifestPath = "core/binary_protocol/Cargo.toml"; + private const string EvictionGraderPath = "core/common/src/error/eviction.rs"; + private const string ReplyResultPath = "core/binary_protocol/src/consensus/reply_result.rs"; + private const string CredentialDefaultsPath = "core/common/src/http/users/defaults.rs"; + + private const string LoginRegisterResponsePath = + "core/binary_protocol/src/responses/users/login_register.rs"; + + private const string SyncConsumerGroupResponsePath = + "core/binary_protocol/src/responses/consumer_groups/sync_consumer_group.rs"; + + /// + /// Codes the SDK deliberately does not resolve the way the Rust dispatch table declares them, with the + /// reason each one is exempt. Anything not listed here must agree with the table. + /// + private static readonly Dictionary CodeMappingExceptions = new() + { + // Non-replicated in the table because the legacy transport routes them; under VSR they are the + // consensus handshake itself and carry their own operations. + ["LOGIN_REGISTER_CODE"] = VsrOperation.Register, + ["LOGIN_REGISTER_WITH_PAT_CODE"] = VsrOperation.Register, + ["LOGOUT_USER_CODE"] = VsrOperation.Logout, + + // VSR has no legacy login. ForCode throws rather than encoding a request that would look like a + // working login while binding no session; null means "asserted to throw". + ["LOGIN_USER_CODE"] = null, + ["LOGIN_WITH_PERSONAL_ACCESS_TOKEN_CODE"] = null + }; + + [Fact] + public void RequestHeaderOffsets_MatchTheRustLayout() + { + IReadOnlyDictionary offsets = RustStruct.Offsets(ReadRustSource(HeaderPath), "RequestHeader"); + + Assert.Equal(VsrHeader.SIZE_OFFSET, offsets["size"]); + Assert.Equal(VsrHeader.COMMAND_OFFSET, offsets["command"]); + Assert.Equal(VsrHeader.REQUEST_CLIENT_OFFSET, offsets["client"]); + Assert.Equal(VsrHeader.REQUEST_TIMESTAMP_OFFSET, offsets["timestamp"]); + Assert.Equal(VsrHeader.REQUEST_ID_OFFSET, offsets["request"]); + Assert.Equal(VsrHeader.REQUEST_OPERATION_OFFSET, offsets["operation"]); + Assert.Equal(VsrHeader.REQUEST_NAMESPACE_OFFSET, offsets["namespace"]); + Assert.Equal(VsrHeader.REQUEST_SESSION_OFFSET, offsets["session"]); + Assert.Equal(VsrHeader.REQUEST_RESERVED_OFFSET, offsets["reserved"]); + } + + [Fact] + public void ReplyHeaderOffsets_MatchTheRustLayout() + { + IReadOnlyDictionary offsets = RustStruct.Offsets(ReadRustSource(HeaderPath), "ReplyHeader"); + + Assert.Equal(VsrHeader.SIZE_OFFSET, offsets["size"]); + Assert.Equal(VsrHeader.COMMAND_OFFSET, offsets["command"]); + Assert.Equal(VsrHeader.REPLY_OPERATION_OFFSET, offsets["operation"]); + Assert.Equal(VsrHeader.REPLY_NAMESPACE_OFFSET, offsets["namespace"]); + Assert.Equal(VsrHeader.REPLY_STATUS_OFFSET, offsets["status"]); + } + + [Fact] + public void EvictionHeaderOffsets_MatchTheRustLayout() + { + IReadOnlyDictionary offsets = RustStruct.Offsets(ReadRustSource(HeaderPath), "EvictionHeader"); + + Assert.Equal(VsrHeader.COMMAND_OFFSET, offsets["command"]); + Assert.Equal(VsrHeader.EVICTION_CLIENT_OFFSET, offsets["client"]); + Assert.Equal(VsrHeader.EVICTION_PROTOCOL_VERSION_OFFSET, offsets["server_protocol_version"]); + Assert.Equal(VsrHeader.EVICTION_PROTOCOL_VERSION_MIN_OFFSET, offsets["server_protocol_version_min"]); + Assert.Equal(VsrHeader.EVICTION_REASON_OFFSET, offsets["reason"]); + } + + [Fact] + public void HeaderSize_MatchesTheRustConstant() + { + var source = ReadRustSource(HeaderPath); + var declared = Regex.Match(source, @"pub const HEADER_SIZE: usize = (\d+);"); + + Assert.True(declared.Success, "HEADER_SIZE is no longer declared in header.rs."); + Assert.Equal(VsrHeader.HEADER_SIZE, int.Parse(declared.Groups[1].Value, CultureInfo.InvariantCulture)); + Assert.Equal(VsrHeader.HEADER_SIZE, RustStruct.Size(source, "RequestHeader")); + Assert.Equal(VsrHeader.HEADER_SIZE, RustStruct.Size(source, "ReplyHeader")); + Assert.Equal(VsrHeader.HEADER_SIZE, RustStruct.Size(source, "EvictionHeader")); + } + + [Fact] + public void Command2Discriminants_MatchTheRustEnum() + { + IReadOnlyDictionary rust = RustEnum.Discriminants(ReadRustSource(CommandPath), "Command2"); + + AssertSubsetMatches(rust); + } + + [Fact] + public void EvictionReasons_MatchTheRustEnumExactly() + { + IReadOnlyDictionary rust = RustEnum.Discriminants(ReadRustSource(HeaderPath), "EvictionReason"); + + AssertExactMatch(rust); + } + + [Fact] + public void Operations_MatchTheRustEnumExactly() + { + IReadOnlyDictionary rust = RustEnum.Discriminants(ReadRustSource(OperationPath), "Operation"); + + AssertExactMatch(rust); + } + + [Fact] + public void NamespacePacking_MatchesTheRustConstants() + { + var source = ReadRustSource(NamespacePath); + + Assert.Equal(VsrNamespace.MAX_STREAMS, RustConst(source, "MAX_STREAMS")); + Assert.Equal(VsrNamespace.MAX_TOPICS, RustConst(source, "MAX_TOPICS")); + Assert.Equal(VsrNamespace.MAX_PARTITIONS, RustConst(source, "MAX_PARTITIONS")); + Assert.Equal(VsrNamespace.PARTITION_SHIFT, RustConst(source, "PARTITION_SHIFT")); + + // The remaining shifts are derived from the maxima on the Rust side, so re-derive them the same way + // instead of matching an expression the parser would have to evaluate. + Assert.Equal(VsrNamespace.PARTITION_BITS, BitsRequired(VsrNamespace.MAX_PARTITIONS - 1)); + Assert.Equal(VsrNamespace.TOPIC_BITS, BitsRequired(VsrNamespace.MAX_TOPICS - 1)); + Assert.Equal(VsrNamespace.STREAM_BITS, BitsRequired(VsrNamespace.MAX_STREAMS - 1)); + Assert.Equal(VsrNamespace.TOPIC_SHIFT, VsrNamespace.PARTITION_SHIFT + VsrNamespace.PARTITION_BITS); + Assert.Equal(VsrNamespace.STREAM_SHIFT, VsrNamespace.TOPIC_SHIFT + VsrNamespace.TOPIC_BITS); + + var sentinel = Regex.Match(source, @"pub const METADATA_CONSENSUS_NAMESPACE: u64 = 1u64 << (\d+);"); + Assert.True(sentinel.Success, "METADATA_CONSENSUS_NAMESPACE is no longer a shifted literal."); + Assert.Equal(VsrNamespace.METADATA_CONSENSUS_NAMESPACE, + 1UL << int.Parse(sentinel.Groups[1].Value, CultureInfo.InvariantCulture)); + } + + [Fact] + public void ProtocolVersion_MatchesTheBinaryProtocolCrateVersion() + { + var manifest = ReadRustSource(ManifestPath); + var declared = Regex.Match(manifest, @"^version\s*=\s*""(\d+)\.(\d+)\.(\d+)", RegexOptions.Multiline); + + Assert.True(declared.Success, "iggy_binary_protocol no longer declares a semver version."); + Assert.Equal(LoginRegister.PROTOCOL_VERSION_MAJOR, Group(declared, 1)); + Assert.Equal(LoginRegister.PROTOCOL_VERSION_MINOR, Group(declared, 2)); + Assert.Equal(LoginRegister.PROTOCOL_VERSION_PATCH, Group(declared, 3)); + } + + /// + /// Pins against codes.rs as a set of values, the way the Node mirror + /// does. A name-keyed lookup alone lets a command added upstream pass unnoticed, because the constant it + /// would have to match does not exist here yet. Names are still compared where both sides declare them, + /// which catches a renumber that keeps the set size intact. + /// + [Fact] + public void CommandCodes_MatchTheRustCodes() + { + var rust = Regex.Matches(ReadRustSource(CodesPath), @"pub const (\w+_CODE): u32 = (\d+);") + .ToDictionary(code => code.Groups[1].Value, code => Group(code, 2)); + Assert.NotEmpty(rust); + + var dotnet = typeof(CommandCodes) + .GetFields(BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static) + .Where(field => field is { IsLiteral: true, FieldType.Name: nameof(Int32) }) + .ToDictionary(field => field.Name, field => (int)field.GetRawConstantValue()!); + + var mismatches = rust + .Where(code => !dotnet.ContainsValue(code.Value)) + .Select(code => $"{code.Key} = {code.Value} is declared in Rust and missing from CommandCodes") + .Concat(dotnet + .Where(code => !rust.ContainsValue(code.Value)) + .Select(code => $"{code.Key} = {code.Value} is declared in CommandCodes and missing from Rust")) + .Concat(rust + .Where(code => dotnet.TryGetValue(code.Key, out var actual) && actual != code.Value) + .Select(code => $"{code.Key}: rust {code.Value}, .NET {dotnet[code.Key]}")) + .ToList(); + + Assert.Empty(mismatches); + } + + /// + /// Pins against the Rust dispatch table. The .NET mapping is a hand + /// written switch whose default arm is , so a command that gains + /// replication upstream would otherwise keep encoding as non-replicated here: it would apply on the node + /// that received it and never reach the others, diverging the replicas. This is the sweep + /// no_replicated_command_ever_resolves_to_non_replicated performs on the Rust side. + /// + [Fact] + public void CommandOperationTable_MatchesTheRustDispatchTable() + { + var source = ReadRustSource(DispatchPath); + + // Table entries wrap across lines once the arguments are long enough, so the separators have to match + // arbitrary whitespace rather than a single line. + var replicated = Regex.Matches(source, + @"CommandMeta::replicated\(\s*(\w+_CODE)\s*,\s*""[^""]*""\s*,\s*Operation::(\w+)\s*,?\s*\)"); + var nonReplicated = Regex.Matches(source, @"CommandMeta::non_replicated\(\s*(\w+_CODE)\s*,"); + + Assert.NotEmpty(replicated); + Assert.NotEmpty(nonReplicated); + + var mismatches = new List(); + var matched = 0; + + foreach (Match entry in replicated) + { + var codeName = entry.Groups[1].Value; + if (CommandCode(codeName) is not { } code) + { + // Skipping here would exempt the exact command this test exists to catch: one that gains + // replication upstream while the .NET side has no code for it, so every call encodes as + // non-replicated and applies on one node only. + mismatches.Add($"{codeName}: rust replicates it, absent from .NET CommandCodes"); + + continue; + } + + matched++; + if (!Enum.TryParse(entry.Groups[2].Value, out var expected)) + { + mismatches.Add($"{codeName}: rust maps to Operation::{entry.Groups[2].Value}, absent from .NET"); + + continue; + } + + var actual = ResolveOperation(codeName, code); + if (actual != expected) + { + mismatches.Add($"{codeName}: rust {expected}, .NET {ActualText(actual)}"); + } + } + + foreach (Match entry in nonReplicated) + { + var codeName = entry.Groups[1].Value; + if (CommandCode(codeName) is not { } code) + { + continue; + } + + matched++; + var expected = CodeMappingExceptions.TryGetValue(codeName, out var exempt) + ? exempt + : VsrOperation.NonReplicated; + var actual = ResolveOperation(codeName, code); + if (actual != expected) + { + mismatches.Add($"{codeName}: expected {ActualText(expected)}, .NET {ActualText(actual)}"); + } + } + + Assert.Empty(mismatches); + Assert.True(matched > 40, $"Only {matched} dispatch entries were matched by name; the Rust naming drifted."); + } + + /// + /// Pins against Operation::is_metadata. Metadata replies + /// lead their body with a committed result section, so an operation misclassified here has its rejection + /// entry decoded as payload and a refused command reads as a success. + /// + [Fact] + public void MetadataOperations_MatchTheRustClassifier() + { + var body = MatchesArmBody(ReadRustSource(OperationPath), "is_metadata"); + var rust = Regex.Matches(body, @"Self::(\w+)").Select(match => match.Groups[1].Value).ToHashSet(); + Assert.NotEmpty(rust); + + var mismatches = new List(); + foreach (VsrOperation operation in Enum.GetValues()) + { + // is_internal() short-circuits ahead of the match arm on both sides, so those members are metadata + // without appearing in the list. + var expected = rust.Contains(operation.ToString()) || operation.IsInternal(); + if (operation.IsMetadata() != expected) + { + mismatches.Add($"{operation}: rust {expected}, .NET {operation.IsMetadata()}"); + } + } + + Assert.Empty(mismatches); + } + + /// + /// Pins against Operation::is_result_framed. The Rust + /// side composes it from is_metadata plus its own partition-plane list, so pinning + /// is_metadata alone leaves the second half free to drift. An operation that gains result framing + /// upstream and not here has skip the strip, and a + /// committed rejection decodes as payload: a refused command reads as a success. + /// + [Fact] + public void ResultFramedOperations_MatchTheRustClassifier() + { + var body = MatchesArmBody(ReadRustSource(OperationPath), "is_result_framed"); + var rust = Regex.Matches(body, @"Self::(\w+)").Select(match => match.Groups[1].Value).ToHashSet(); + Assert.NotEmpty(rust); + + var mismatches = new List(); + foreach (VsrOperation operation in Enum.GetValues()) + { + // The Rust body is `is_metadata() || matches!(...)`, so the metadata members carry over without + // appearing in the list. + var expected = rust.Contains(operation.ToString()) || operation.IsMetadata(); + if (operation.IsResultFramed() != expected) + { + mismatches.Add($"{operation}: rust {expected}, .NET {operation.IsResultFramed()}"); + } + } + + Assert.Empty(mismatches); + } + + /// + /// Pins against the Rust error discriminants. These are copied by hand and drive + /// more than error text: TRANSIENT_NOT_COMMITTED and TRANSIENT_NOT_ACCEPTED are what tells a + /// same-session replay from a fresh-session reissue, so a renumber upstream would reissue a write whose + /// outcome is unknown under a client id the server cannot deduplicate against, applying it twice. + /// + [Fact] + public void ErrorCodes_MatchTheRustDiscriminants() + { + var rust = Regex.Matches(ReadRustSource(ErrorPath), @"^\s{4}(\w+)(?:\([^)]*\))?\s*=\s*(\d+),", + RegexOptions.Multiline) + .ToDictionary(match => Normalize(match.Groups[1].Value), + match => int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture)); + Assert.NotEmpty(rust); + + var mismatches = new List(); + var matched = 0; + + foreach (FieldInfo field in typeof(VsrError).GetFields(BindingFlags.NonPublic | BindingFlags.Static)) + { + if (!field.IsLiteral || field.FieldType != typeof(int)) + { + continue; + } + + if (!rust.TryGetValue(Normalize(field.Name), out var expected)) + { + mismatches.Add($"{field.Name}: no Rust variant of that name remains"); + + continue; + } + + matched++; + var actual = (int)field.GetValue(null)!; + if (actual != expected) + { + mismatches.Add($"{field.Name}: rust {expected}, .NET {actual}"); + } + } + + Assert.Empty(mismatches); + Assert.True(matched > 10, $"Only {matched} error codes were matched by name; the Rust naming drifted."); + } + + private static VsrOperation? ResolveOperation(string codeName, int code) + { + try + { + return VsrOperations.ForCode(code); + } + catch (IggyInvalidStatusCodeException) + { + // The legacy login codes reject rather than resolve; CodeMappingExceptions records that as null. + _ = codeName; + + return null; + } + } + + private static string ActualText(VsrOperation? operation) + { + return operation?.ToString() ?? "rejected"; + } + + private static int? CommandCode(string name) + { + var field = typeof(CommandCodes).GetField(name, + BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static); + + return field is null ? null : (int)field.GetValue(null)!; + } + + /// Extracts the body of the matches! invocation inside the named method. + private static string MatchesArmBody(string source, string method) + { + var start = source.IndexOf($"fn {method}(", StringComparison.Ordinal); + Assert.True(start >= 0, $"Operation::{method} is gone from the Rust source."); + + var matches = source.IndexOf("matches!(", start, StringComparison.Ordinal); + Assert.True(matches >= 0, $"Operation::{method} no longer classifies with matches!."); + + var depth = 0; + for (var i = matches + "matches!".Length; i < source.Length; i++) + { + depth += source[i] switch + { + '(' => 1, + ')' => -1, + _ => 0 + }; + + if (depth == 0) + { + return source[matches..(i + 1)]; + } + } + + Assert.Fail($"Operation::{method} has an unbalanced matches! invocation."); + + return string.Empty; + } + + /// + /// Pins the eviction reason to error mapping against eviction_reason_to_error, which the Rust + /// module documents as the single grader both its callers share "so the mappings cannot drift apart". + /// The .NET decoder is a third copy outside that guarantee, and the status it produces is what a caller + /// branches on, so an arm that silently regrades here changes public behaviour in one SDK only. + /// + [Fact] + public void EvictionReasonErrors_MatchTheRustGrader() + { + IReadOnlyDictionary errorCodes = RustErrorCodes(); + var body = RustItem.Body(ReadRustSource(EvictionGraderPath), "pub fn eviction_reason_to_error("); + + // Arms list one or more reasons separated by `|`; IncompatibleProtocol is a block arm and is asserted + // separately below, on both of its branches. + var arms = Regex.Matches(body, + @"((?:\s*\|?\s*EvictionReason::\w+)+)\s*=>\s*IggyError::(\w+)"); + Assert.NotEmpty(arms); + + var expected = new Dictionary(StringComparer.Ordinal); + int? catchAll = null; + + foreach (Match arm in arms) + { + var code = errorCodes[Normalize(arm.Groups[2].Value)]; + foreach (Match reason in Regex.Matches(arm.Groups[1].Value, @"EvictionReason::(\w+)")) + { + expected[reason.Groups[1].Value] = code; + } + } + + var fallback = Regex.Match(body, @"_\s*=>\s*IggyError::(\w+)"); + Assert.True(fallback.Success, "eviction_reason_to_error no longer has a catch-all arm."); + catchAll = errorCodes[Normalize(fallback.Groups[1].Value)]; + + var mismatches = new List(); + + foreach (EvictionReason reason in Enum.GetValues()) + { + if (reason == EvictionReason.IncompatibleProtocol) + { + continue; + } + + var want = expected.TryGetValue(reason.ToString(), out var mapped) ? mapped : catchAll.Value; + var got = StatusOf(reason); + if (got != want) + { + mismatches.Add($"{reason}: rust {want}, .NET {got}"); + } + } + + // A reason this build cannot decode arrives as null and must grade like the Rust catch-all. + var unknown = StatusOf(null); + if (unknown != catchAll.Value) + { + mismatches.Add($": rust {catchAll.Value}, .NET {unknown}"); + } + + Assert.Empty(mismatches); + + // IncompatibleProtocol reports the window, except when it is degenerate - a zero minimum or an + // inverted range - which falls back to re-authentication. + Assert.Equal(errorCodes["INCOMPATIBLEPROTOCOLVERSION"], + StatusOf(EvictionReason.IncompatibleProtocol, serverVersion: 12, serverVersionMin: 8)); + Assert.Equal(errorCodes["UNAUTHENTICATED"], + StatusOf(EvictionReason.IncompatibleProtocol, serverVersion: 12, serverVersionMin: 0)); + Assert.Equal(errorCodes["UNAUTHENTICATED"], + StatusOf(EvictionReason.IncompatibleProtocol, serverVersion: 8, serverVersionMin: 12)); + } + + /// + /// Pins the result-section widths. They are hardcoded on the .NET side, and the Rust module notes that + /// the decoder and its encode mirror "share the widths below so they cannot drift". + /// + [Fact] + public void ResultSectionWidths_MatchTheRustConstants() + { + var source = ReadRustSource(ReplyResultPath); + + Assert.Equal(VsrReplyDecoder.RESULT_COUNT_LENGTH, RustConstant(source, "RESULT_COUNT_LEN")); + Assert.Equal(VsrReplyDecoder.RESULT_ENTRY_LENGTH, RustConstant(source, "RESULT_ENTRY_LEN")); + } + + /// + /// Pins the credential bounds the client rejects on before spending a consensus round trip. Bounds that + /// drift wide let the server evict the session instead; bounds that drift narrow reject logins the + /// server would accept. + /// + [Fact] + public void CredentialBounds_MatchTheRustDefaults() + { + var source = ReadRustSource(CredentialDefaultsPath); + + Assert.Equal(CredentialBounds.MIN_USERNAME_LENGTH, RustConstant(source, "MIN_USERNAME_LENGTH")); + Assert.Equal(CredentialBounds.MAX_USERNAME_LENGTH, RustConstant(source, "MAX_USERNAME_LENGTH")); + Assert.Equal(CredentialBounds.MIN_PASSWORD_LENGTH, RustConstant(source, "MIN_PASSWORD_LENGTH")); + Assert.Equal(CredentialBounds.MAX_PASSWORD_LENGTH, RustConstant(source, "MAX_PASSWORD_LENGTH")); + } + + /// + /// Pins the register reply body layout by reading the field offsets out of the Rust decoder and feeding + /// .NET a buffer laid out to them. The header offsets are pinned structurally above, but the bodies were + /// only ever checked against hand-written .NET expectations, which move together with the code. + /// + [Fact] + public void LoginRegisterResponseLayout_MatchesTheRustDecoder() + { + var body = RustItem.Body(ReadRustSource(LoginRegisterResponsePath), "fn decode(buf: &[u8])"); + + Assert.Equal(0, RustReadOffset(body, "read_u32_le", "user_id")); + Assert.Equal(4, RustReadOffset(body, "read_u64_le", "session")); + Assert.Equal(12, RustReadOffset(body, "read_u32_le", "server_protocol_version")); + + var versionOffset = Regex.Match(body, @"WireName::decode\(&buf\[(\d+)\.\.\]\)"); + Assert.True(versionOffset.Success, "The register reply no longer decodes its server version by offset."); + Assert.Equal(16, Group(versionOffset, 1)); + + Span reply = stackalloc byte[16 + 1 + 3]; + BinaryPrimitives.WriteUInt32LittleEndian(reply, 7); + BinaryPrimitives.WriteUInt64LittleEndian(reply[4..], 42); + BinaryPrimitives.WriteUInt32LittleEndian(reply[12..], 99); + reply[16] = 3; + "1.2"u8.CopyTo(reply[17..]); + + LoginRegisterResponse decoded = LoginRegister.Deserialize(reply); + + Assert.Equal(7u, decoded.UserId); + Assert.Equal(42ul, decoded.Session); + } + + /// + /// Pins the consumer-group assignment reply layout the same way. A silent offset shift here reassigns + /// partitions rather than failing, so every member of a group polls the wrong partitions. + /// + [Fact] + public void SyncConsumerGroupResponseLayout_MatchesTheRustDecoder() + { + var body = RustItem.Body(ReadRustSource(SyncConsumerGroupResponsePath), "fn decode(buf: &[u8])"); + + Assert.Equal(0, RustReadOffset(body, "read_u64_le", "generation")); + Assert.Equal(8, RustReadOffset(body, "read_u32_le", "partitions_count")); + + var payloadOffset = Regex.Match(body, @"let mut offset = (\d+);"); + Assert.True(payloadOffset.Success, "The assignment reply no longer decodes its partitions by offset."); + Assert.Equal(12, Group(payloadOffset, 1)); + + Span reply = stackalloc byte[12 + 8]; + BinaryPrimitives.WriteUInt64LittleEndian(reply, 5); + BinaryPrimitives.WriteUInt32LittleEndian(reply[8..], 2); + BinaryPrimitives.WriteUInt32LittleEndian(reply[12..], 11); + BinaryPrimitives.WriteUInt32LittleEndian(reply[16..], 13); + + SyncConsumerGroupAssignment decoded = SyncConsumerGroupAssignment.Decode(reply); + + Assert.Equal(5ul, decoded.Generation); + Assert.Equal([11u, 13u], decoded.Partitions); + } + + private static IReadOnlyDictionary RustErrorCodes() + { + return Regex.Matches(ReadRustSource(ErrorPath), @"^\s{4}(\w+)(?:\([^)]*\))?\s*=\s*(\d+),", + RegexOptions.Multiline) + .ToDictionary(match => Normalize(match.Groups[1].Value), + match => int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture)); + } + + private static int StatusOf(EvictionReason? reason, uint serverVersion = 0, uint serverVersionMin = 0) + { + var thrown = Assert.IsType( + VsrReplyDecoder.ToException(new EvictionFrame(reason, serverVersion, serverVersionMin))); + + return thrown.StatusCode; + } + + private static int RustConstant(string source, string name) + { + var declared = Regex.Match(source, $@"pub const {Regex.Escape(name)}\s*:\s*\w+\s*=\s*(\d+)\s*;"); + Assert.True(declared.Success, $"Rust no longer declares {name}."); + + return Group(declared, 1); + } + + private static int RustReadOffset(string body, string reader, string field) + { + var read = Regex.Match(body, $@"let {Regex.Escape(field)} = {Regex.Escape(reader)}\(buf,\s*(\d+)\)"); + Assert.True(read.Success, $"Rust no longer decodes {field} with {reader} at a literal offset."); + + return Group(read, 1); + } + + private static string Normalize(string name) + { + return name.Replace("_", string.Empty, StringComparison.Ordinal).ToUpperInvariant(); + } + + private static int Group(Match match, int index) + { + return int.Parse(match.Groups[index].Value, CultureInfo.InvariantCulture); + } + + private static int BitsRequired(int max) + { + var bits = 0; + while (max > 0) + { + bits++; + max >>= 1; + } + + return bits; + } + + private static int RustConst(string source, string name) + { + var match = Regex.Match(source, $@"pub const {name}: \w+ = ([\d_]+);"); + Assert.True(match.Success, $"{name} is no longer a literal constant."); + + return int.Parse(match.Groups[1].Value.Replace("_", string.Empty), CultureInfo.InvariantCulture); + } + + /// Every .NET member matches Rust, and Rust carries no member the .NET enum is missing. + private static void AssertExactMatch(IReadOnlyDictionary rust) where TEnum : struct, Enum + { + AssertSubsetMatches(rust); + + HashSet ported = Enum.GetNames().ToHashSet(); + Assert.DoesNotContain(rust.Keys, name => !ported.Contains(name)); + } + + /// Every .NET member matches Rust; Rust members with no .NET counterpart are allowed. + private static void AssertSubsetMatches(IReadOnlyDictionary rust) where TEnum : struct, Enum + { + Assert.NotEmpty(rust); + + var mismatches = new List(); + foreach (var name in Enum.GetNames()) + { + var value = Convert.ToInt32(Enum.Parse(name), CultureInfo.InvariantCulture); + if (!rust.TryGetValue(name, out var rustValue)) + { + mismatches.Add($"{name}: missing on the Rust side"); + } + else if (rustValue != value) + { + mismatches.Add($"{name}: rust {rustValue}, .NET {value}"); + } + } + + Assert.Empty(mismatches); + } + + private static string ReadRustSource(string relativePath) + { + var root = RepositoryRoot(); + if (root is null) + { + // Skipping suits a consumer running the suite outside a checkout, but in CI it would turn every + // assertion in this file green on a Rust-side path change, which is the one drift the suite + // cannot afford to miss. + Assert.False(Environment.GetEnvironmentVariable("GITHUB_ACTIONS") == "true", + $"Rust sources are unavailable in CI: no ancestor of {AppContext.BaseDirectory} holds {HeaderPath}."); + Assert.Skip("Rust sources are not available; run the drift check from a repository checkout."); + } + + return File.ReadAllText(Path.Combine(root, relativePath)); + } + + private static string? RepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, HeaderPath))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + return null; + } +} + +/// Discriminants of a #[repr(u8)] Rust enum, by member name. +internal static class RustEnum +{ + internal static IReadOnlyDictionary Discriminants(string source, string name) + { + var body = RustItem.Body(source, $"pub enum {name} {{"); + var members = new Dictionary(); + + foreach (Match member in Regex.Matches(body, @"^\s*(\w+) = (\d+),", RegexOptions.Multiline)) + { + members[member.Groups[1].Value] = int.Parse(member.Groups[2].Value, CultureInfo.InvariantCulture); + } + + return members; + } +} + +/// +/// Field offsets of a #[repr(C)] Rust struct, computed from the declared field order the same way +/// rustc lays them out: each field starts at the next multiple of its alignment. +/// +internal static class RustStruct +{ + private static readonly Dictionary ScalarLayouts = new() + { + ["u8"] = (1, 1), + ["u16"] = (2, 2), + ["u32"] = (4, 4), + ["u64"] = (8, 8), + ["u128"] = (16, 16), + // Every enum the headers embed is `#[repr(u8)]`. + ["Command2"] = (1, 1), + ["Operation"] = (1, 1), + ["EvictionReason"] = (1, 1) + }; + + internal static IReadOnlyDictionary Offsets(string source, string name) + { + return Layout(source, name).Offsets; + } + + internal static int Size(string source, string name) + { + return Layout(source, name).Size; + } + + private static (Dictionary Offsets, int Size) Layout(string source, string name) + { + var body = RustItem.Body(source, $"pub struct {name} {{"); + var offsets = new Dictionary(); + var offset = 0; + var structAlign = 1; + + foreach (Match field in Regex.Matches(body, @"^\s*pub (\w+): ([^,]+),", RegexOptions.Multiline)) + { + var (size, align) = FieldLayout(field.Groups[2].Value.Trim(), name); + offset = Align(offset, align); + offsets[field.Groups[1].Value] = offset; + offset += size; + structAlign = Math.Max(structAlign, align); + } + + return (offsets, Align(offset, structAlign)); + } + + private static (int Size, int Align) FieldLayout(string type, string structName) + { + if (ScalarLayouts.TryGetValue(type, out var scalar)) + { + return scalar; + } + + var array = Regex.Match(type, @"^\[u8; (\d+)\]$"); + if (array.Success) + { + return (int.Parse(array.Groups[1].Value, CultureInfo.InvariantCulture), 1); + } + + throw new InvalidOperationException($"{structName} gained a field of unmapped type '{type}'."); + } + + private static int Align(int offset, int alignment) + { + return (offset + alignment - 1) / alignment * alignment; + } +} + +internal static class RustItem +{ + /// The brace-delimited body following , comments stripped. + internal static string Body(string source, string declaration) + { + var start = source.IndexOf(declaration, StringComparison.Ordinal); + if (start < 0) + { + throw new InvalidOperationException($"'{declaration}' is no longer declared in the Rust sources."); + } + + var cursor = start + declaration.Length; + var depth = 1; + while (cursor < source.Length && depth > 0) + { + depth += source[cursor] switch + { + '{' => 1, + '}' => -1, + _ => 0 + }; + cursor++; + } + + var body = source[(start + declaration.Length)..(cursor - 1)]; + + return Regex.Replace(body, @"^\s*//.*$", string.Empty, RegexOptions.Multiline); + } +} diff --git a/foreign/csharp/Iggy_SDK_Tests/VsrTests/VsrReplyDecoderTests.cs b/foreign/csharp/Iggy_SDK_Tests/VsrTests/VsrReplyDecoderTests.cs new file mode 100644 index 0000000000..a6707b8905 --- /dev/null +++ b/foreign/csharp/Iggy_SDK_Tests/VsrTests/VsrReplyDecoderTests.cs @@ -0,0 +1,293 @@ +// 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.Exceptions; +using Apache.Iggy.Vsr; + +namespace Apache.Iggy.Tests.VsrTests; + +public sealed class VsrReplyDecoderTests +{ + private static byte[] ReplyHeader(VsrOperation operation, int bodyLength, uint status = 0) + { + var header = new byte[VsrHeader.HEADER_SIZE]; + header[VsrHeader.COMMAND_OFFSET] = (byte)Command2.Reply; + header[VsrHeader.REPLY_OPERATION_OFFSET] = (byte)operation; + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(VsrHeader.SIZE_OFFSET), + (uint)(VsrHeader.HEADER_SIZE + bodyLength)); + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(VsrHeader.REPLY_STATUS_OFFSET), status); + + return header; + } + + private static byte[] EvictionHeader(EvictionReason reason, uint version = 0, uint versionMin = 0) + { + var header = new byte[VsrHeader.HEADER_SIZE]; + header[VsrHeader.COMMAND_OFFSET] = (byte)Command2.Eviction; + header[VsrHeader.EVICTION_REASON_OFFSET] = (byte)reason; + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(VsrHeader.EVICTION_PROTOCOL_VERSION_OFFSET), version); + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(VsrHeader.EVICTION_PROTOCOL_VERSION_MIN_OFFSET), + versionMin); + + return header; + } + + private static byte[] SuccessBody(params byte[] payload) + { + return VsrTestPayloads.Concat(VsrTestPayloads.UInt32(0), payload); + } + + private static byte[] RejectionBody(uint code) + { + return VsrTestPayloads.Concat(VsrTestPayloads.UInt32(1), VsrTestPayloads.UInt32(0), + VsrTestPayloads.UInt32((int)code)); + } + + [Fact] + public void Decode_StripsTheResultSectionFromACommittedMetadataReply() + { + var body = SuccessBody(1, 2, 3); + + ReadOnlyMemory payload + = VsrReplyDecoder.Decode(ReplyHeader(VsrOperation.CreateStream, body.Length), body); + + Assert.Equal([1, 2, 3], payload.ToArray()); + } + + [Fact] + public void Decode_CommittedRejectionThrowsTheTypedError() + { + var body = RejectionBody(1009); + + var exception = Assert.Throws(() => + VsrReplyDecoder.Decode(ReplyHeader(VsrOperation.CreateStream, body.Length), body)); + + Assert.Equal(1009, exception.StatusCode); + Assert.True(exception.FromServer); + } + + /// + /// Retry and failover key off the transient status codes, and a locally raised code says nothing about + /// what the cluster did, so the two origins have to stay distinguishable. + /// + [Fact] + public void ServerVerdictsAreDistinguishableFromClientSideFailures() + { + var header = ReplyHeader(VsrOperation.CreateStream, 0, VsrError.TRANSIENT_NOT_ACCEPTED); + + var fromWire = Assert.Throws(() => + VsrReplyDecoder.Decode(header, ReadOnlyMemory.Empty)); + + Assert.True(fromWire.FromServer); + Assert.False(VsrError.Exception(VsrError.TRANSIENT_NOT_ACCEPTED, "raised locally").FromServer); + } + + [Fact] + public void Decode_TruncatedResultSectionIsInvalidCommandNeverSuccess() + { + var body = VsrTestPayloads.UInt32(1); + + var exception = Assert.Throws(() => + VsrReplyDecoder.Decode(ReplyHeader(VsrOperation.CreateStream, body.Length), body)); + + Assert.Equal(VsrError.INVALID_COMMAND, exception.StatusCode); + } + + [Fact] + public void Decode_NonZeroStatusIsReadBeforeTheBody() + { + var exception = Assert.Throws(() => + VsrReplyDecoder.Decode(ReplyHeader(VsrOperation.CreateStream, 0, 1009), ReadOnlyMemory.Empty)); + + Assert.Equal(1009, exception.StatusCode); + } + + [Fact] + public void Decode_NonResultFramedReplyPassesTheBodyThrough() + { + byte[] body = [1, 2, 3, 4, 5]; + + ReadOnlyMemory payload + = VsrReplyDecoder.Decode(ReplyHeader(VsrOperation.NonReplicated, body.Length), body); + + Assert.Equal(body, payload.ToArray()); + } + + [Fact] + public void Decode_PartitionSendReplyPassesTheBodyThrough() + { + byte[] body = [7, 7]; + + ReadOnlyMemory payload + = VsrReplyDecoder.Decode(ReplyHeader(VsrOperation.SendMessages, body.Length), body); + + Assert.Equal(body, payload.ToArray()); + } + + [Fact] + public void Decode_ConsumerOffsetReplyIsResultFramed() + { + var body = SuccessBody(); + + ReadOnlyMemory payload + = VsrReplyDecoder.Decode(ReplyHeader(VsrOperation.StoreConsumerOffset, body.Length), body); + + Assert.True(payload.IsEmpty); + } + + [Fact] + public void Decode_NonEmptyRegisterReplyIsResultFramed() + { + var body = SuccessBody(9, 9); + + ReadOnlyMemory payload = VsrReplyDecoder.Decode(ReplyHeader(VsrOperation.Register, body.Length), body); + + Assert.Equal([9, 9], payload.ToArray()); + } + + [Fact] + public void Decode_EmptyRegisterReplyPassesThroughToFailTheTypedDecode() + { + ReadOnlyMemory payload + = VsrReplyDecoder.Decode(ReplyHeader(VsrOperation.Register, 0), ReadOnlyMemory.Empty); + + Assert.True(payload.IsEmpty); + } + + [Fact] + public void Decode_ShortBodyIsInvalidCommand() + { + var exception = Assert.Throws(() => + VsrReplyDecoder.Decode(ReplyHeader(VsrOperation.CreateStream, 8), new byte[4])); + + Assert.Equal(VsrError.INVALID_COMMAND, exception.StatusCode); + } + + [Fact] + public void Decode_FrameSmallerThanTheHeaderIsInvalidCommand() + { + var header = ReplyHeader(VsrOperation.CreateStream, 0); + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(VsrHeader.SIZE_OFFSET), 128); + + var exception = Assert.Throws(() => + VsrReplyDecoder.Decode(header, ReadOnlyMemory.Empty)); + + Assert.Equal(VsrError.INVALID_COMMAND, exception.StatusCode); + } + + [Fact] + public void Decode_UnexpectedFrameIsInvalidCommand() + { + var header = new byte[VsrHeader.HEADER_SIZE]; + header[VsrHeader.COMMAND_OFFSET] = 6; + + var exception = Assert.Throws(() => + VsrReplyDecoder.Decode(header, ReadOnlyMemory.Empty)); + + Assert.Equal(VsrError.INVALID_COMMAND, exception.StatusCode); + } + + [Fact] + public void Decode_ShortHeaderIsEmptyResponse() + { + var exception = Assert.Throws(() => + VsrReplyDecoder.Decode(new byte[8], ReadOnlyMemory.Empty)); + + Assert.Equal(VsrError.EMPTY_RESPONSE, exception.StatusCode); + } + + [Fact] + public void Decode_ReadsAnEvictionFromAMisalignedBuffer() + { + var frame = VsrTestPayloads.Concat([0, 0, 0], EvictionHeader(EvictionReason.InvalidCredentials)); + + var exception = Assert.Throws(() => + VsrReplyDecoder.Decode(frame.AsSpan(3), ReadOnlyMemory.Empty)); + + Assert.Equal(VsrError.INVALID_CREDENTIALS, exception.StatusCode); + } + + [Theory] + [InlineData((byte)EvictionReason.InvalidCredentials, VsrError.INVALID_CREDENTIALS)] + [InlineData((byte)EvictionReason.InvalidToken, VsrError.INVALID_PERSONAL_ACCESS_TOKEN)] + [InlineData((byte)EvictionReason.UserInactive, VsrError.UNAUTHENTICATED)] + [InlineData((byte)EvictionReason.SessionError, VsrError.UNAUTHENTICATED)] + [InlineData((byte)EvictionReason.NoSession, VsrError.UNAUTHENTICATED)] + [InlineData((byte)EvictionReason.SessionTooLow, VsrError.UNAUTHENTICATED)] + [InlineData((byte)EvictionReason.SessionReleaseMismatch, VsrError.UNAUTHENTICATED)] + [InlineData((byte)EvictionReason.StaleClient, VsrError.STALE_CLIENT)] + [InlineData((byte)EvictionReason.MalformedLogin, VsrError.INVALID_FORMAT)] + [InlineData((byte)EvictionReason.ClientReleaseTooLow, VsrError.INVALID_COMMAND)] + [InlineData((byte)EvictionReason.ClientReleaseTooHigh, VsrError.INVALID_COMMAND)] + [InlineData((byte)EvictionReason.InvalidRequestOperation, VsrError.INVALID_COMMAND)] + [InlineData((byte)EvictionReason.InvalidRequestBody, VsrError.INVALID_COMMAND)] + [InlineData((byte)EvictionReason.InvalidRequestBodySize, VsrError.INVALID_COMMAND)] + + // Reserved and every reason this build cannot decode land in the shared grader's catch-all. + [InlineData((byte)EvictionReason.Reserved, VsrError.INVALID_COMMAND)] + public void Decode_MapsEachEvictionReasonToItsError(byte reason, int expected) + { + var exception = Assert.Throws(() => + VsrReplyDecoder.Decode(EvictionHeader((EvictionReason)reason), ReadOnlyMemory.Empty)); + + Assert.Equal(expected, exception.StatusCode); + } + + [Fact] + public void Decode_IncompatibleProtocolReportsTheAcceptedWindow() + { + var exception = Assert.Throws(() => + VsrReplyDecoder.Decode(EvictionHeader(EvictionReason.IncompatibleProtocol, 10243, 10240), + ReadOnlyMemory.Empty)); + + Assert.Equal(VsrError.INCOMPATIBLE_PROTOCOL_VERSION, exception.StatusCode); + Assert.Contains("10240..10243", exception.Message); + } + + [Theory] + [InlineData(10243u, 0u)] + [InlineData(10240u, 10243u)] + public void Decode_IncompatibleProtocolWithAnUnusableWindowDegradesToUnauthenticated(uint version, uint min) + { + var exception = Assert.Throws(() => + VsrReplyDecoder.Decode(EvictionHeader(EvictionReason.IncompatibleProtocol, version, min), + ReadOnlyMemory.Empty)); + + Assert.Equal(VsrError.UNAUTHENTICATED, exception.StatusCode); + } + + [Fact] + public void ReadResultCode_MalformedSectionIsNullNeverZero() + { + Assert.Null(VsrReplyDecoder.ReadResultCode([])); + Assert.Null(VsrReplyDecoder.ReadResultCode(VsrTestPayloads.UInt32(1))); + Assert.Null(VsrReplyDecoder.ReadResultCode([1, 0, 0, 0, 0, 0, 0, 0])); + Assert.Equal(0u, VsrReplyDecoder.ReadResultCode(SuccessBody())); + } + + [Fact] + public void ReadResultSectionLength_CoversTheCountAndItsEntries() + { + Assert.Equal(4, VsrReplyDecoder.ReadResultSectionLength(SuccessBody(1, 2))); + Assert.Equal(12, VsrReplyDecoder.ReadResultSectionLength(RejectionBody(1009))); + Assert.Null(VsrReplyDecoder.ReadResultSectionLength(VsrTestPayloads.UInt32(1))); + + // Shorter than the count itself, so there is no section to measure. + Assert.Null(VsrReplyDecoder.ReadResultSectionLength([1, 2, 3])); + } +} diff --git a/foreign/csharp/Iggy_SDK_Tests/VsrTests/VsrTestPayloads.cs b/foreign/csharp/Iggy_SDK_Tests/VsrTests/VsrTestPayloads.cs new file mode 100644 index 0000000000..1e4914766c --- /dev/null +++ b/foreign/csharp/Iggy_SDK_Tests/VsrTests/VsrTestPayloads.cs @@ -0,0 +1,102 @@ +// 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.Text; + +namespace Apache.Iggy.Tests.VsrTests; + +/// +/// Builders for the classic request bodies the VSR encoder peeks into, matching what +/// TcpContracts writes. +/// +internal static class VsrTestPayloads +{ + internal static byte[] NumericIdentifier(uint value) + { + var bytes = new byte[6]; + bytes[0] = 1; + bytes[1] = 4; + BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(2), value); + + return bytes; + } + + internal static byte[] NamedIdentifier(string value) + { + var name = Encoding.UTF8.GetBytes(value); + var bytes = new byte[2 + name.Length]; + bytes[0] = 2; + bytes[1] = (byte)name.Length; + name.CopyTo(bytes, 2); + + return bytes; + } + + internal static byte[] SendMessages(byte[] streamId, byte[] topicId, byte partitioningKind, + byte[] partitioningValue, int messagesCount = 1) + { + var metadata = Concat(streamId, topicId, + Concat([partitioningKind, (byte)partitioningValue.Length], partitioningValue), + UInt32(messagesCount)); + + return Concat(UInt32(metadata.Length), metadata); + } + + internal static byte[] SendMessagesToPartition(uint streamId, uint topicId, uint partitionId) + { + return SendMessages(NumericIdentifier(streamId), NumericIdentifier(topicId), 2, UInt32((int)partitionId)); + } + + internal static byte[] ConsumerOffset(byte[] streamId, byte[] topicId, uint? partitionId) + { + var partition = new byte[5]; + if (partitionId.HasValue) + { + partition[0] = 1; + BinaryPrimitives.WriteUInt32LittleEndian(partition.AsSpan(1), partitionId.Value); + } + + return Concat([1], NumericIdentifier(1), streamId, topicId, partition); + } + + internal static byte[] DeleteSegments(byte[] streamId, byte[] topicId, uint partitionId, uint segmentsCount = 1) + { + return Concat(streamId, topicId, UInt32((int)partitionId), UInt32((int)segmentsCount)); + } + + internal static byte[] UInt32(int value) + { + var bytes = new byte[4]; + BinaryPrimitives.WriteUInt32LittleEndian(bytes, (uint)value); + + return bytes; + } + + internal static byte[] Concat(params byte[][] parts) + { + var result = new byte[parts.Sum(part => part.Length)]; + var position = 0; + foreach (var part in parts) + { + part.CopyTo(result, position); + position += part.Length; + } + + return result; + } +} diff --git a/foreign/csharp/README.md b/foreign/csharp/README.md index 633d4145bf..a2984feb54 100644 --- a/foreign/csharp/README.md +++ b/foreign/csharp/README.md @@ -37,6 +37,13 @@ The SDK supports two transport protocols: - **TCP** - Binary protocol for optimal performance and lower latency (recommended) - **HTTP** - RESTful JSON API for stateless operations +Over TCP the SDK speaks one of two wire protocols, selected with `WireProtocol`: + +- **`WireProtocol.Classic`** (default) - the `[size][code][body]` framing every released Iggy server speaks +- **`WireProtocol.Vsr`** - the consensus framing of the clustered next-generation server + +See [Viewstamped Replication (VSR)](#viewstamped-replication-vsr) for what changes when it is enabled. + ### Creating a Client The SDK is built around the `IIggyClient` interface. To create a client instance: @@ -120,6 +127,93 @@ var client = IggyClientFactory.CreateClient(new IggyClientConfigurator await client.ConnectAsync(); ``` +## Viewstamped Replication (VSR) + +`WireProtocol.Vsr` targets the clustered next-generation server. Every request is wrapped in a 256-byte +consensus header, the client registers a consensus session at login, and writes are replicated before they are +acknowledged. The same `IIggyClient` calls work under both wire protocols, with the few exceptions listed +under [Limitations](#limitations). + +```c# +var client = IggyClientFactory.CreateClient(new IggyClientConfigurator +{ + BaseAddress = "127.0.0.1:8090", + Protocol = Protocol.Tcp, + WireProtocol = WireProtocol.Vsr, + + // Upper bound on a reply frame the server announces, 64 MiB by default. + // Applies to the VSR reader only; classic TCP framing is unbounded as before. + MaxResponseFrameSize = 64 * 1024 * 1024, + + AutoLoginSettings = new AutoLoginSettings + { + Enabled = true, + Username = "iggy", + Password = "iggy" + } +}); + +await client.ConnectAsync(); +``` + +### What changes under VSR + +- **Login binds a session.** `LoginUserAsync` / `LoginWithPersonalAccessTokenAsync` run the register handshake + instead of the classic login, and the session lives for as long as the connection. Logging out, being evicted + or losing the connection ends it, and the next login registers a fresh one. +- **Leader redirection is automatic.** The client reads the cluster roster, follows the current leader and + re-checks it when a request is refused because the node stopped being primary. +- **The client picks partitions.** The broker never routes: balanced and message-key partitioning are resolved + client-side (the message-key hash matches the Rust SDK byte for byte), and consumer-group polls round-robin + over the partitions the coordinator assigned to this client. +- **Consumer groups are assignment-based.** `JoinConsumerGroupAsync` makes this client a member; the assignment + is synced on demand and refreshed on every `PingAsync`. Partition counts are cached for 30 seconds, so a topic + another client widens is picked up without waiting for a ping. +- **Credentials are bounds-checked locally.** A username outside 3-50 bytes, a password outside 3-100 bytes or a + personal access token outside 1-255 bytes is rejected before the register body is framed. +- **`PingAsync` costs more than a ping.** Besides the ping it re-syncs the assignment of every consumer group + this client has joined, so it makes one extra round trip per joined group. The SDK runs no background + heartbeat: an application that wants assignments refreshed calls `PingAsync` on its own cadence. + +### Retries and failed requests + +The SDK replays a request whenever the server says it never admitted it. Two cases surface to the caller: + +- `IggyInvalidStatusCodeException` carries the server status code, with `FromServer` telling apart a verdict the + cluster reported from a failure the client raised itself. +- `VsrRequestOutcomeUnknownException` means no server verdict arrived after the request was written - the + connection was lost, the call was cancelled, or the server evicted the session while the request was in + flight - so the cluster may or may not have committed it. The SDK will not replay it on a new session, + because that would bypass server-side deduplication - re-issuing it is the caller's decision. + `IggyPublisher` will not retry it either: it reports the batch through the message-batch-failed event, and + `IggyConsumer` rethrows it rather than swallowing it, because an auto-committing poll may have advanced the + offset already. Rethrowing ends the consumer's polling loop: catch it around the enumeration, decide whether + the operation is safe to re-issue, and start consuming again. + +### Limitations + +- VSR requires `Protocol.Tcp`; configuring it with `Protocol.Http` throws at client creation. +- VSR combined with `TlsSettings.Enabled` is not covered by the test suite yet. +- `StoreOffsetAsync` / `DeleteOffsetAsync` need an explicit partition id under VSR: the broker does not + resolve a `null` partition for a consumer-offset request, so passing one throws client-side. +- `FlushUnsavedBufferAsync` is not available under VSR; the server refuses it. +- Polling a topic that does not exist returns an empty poll under VSR, where classic TCP throws. The server + answers an unresolved topic with the empty-poll reply shape, so the client cannot tell it apart from a topic + with no messages. Check the topic exists first if the distinction matters. + +### Behaviour changes for existing clients + +- `MaxResponseFrameSize` bounds the reply frames the **VSR** reader accepts. A reply larger than the 64 MiB + default is refused and the connection is dropped, so raise it if a single response legitimately exceeds that + - a large `GetSnapshotAsync` is the usual case. Classic TCP framing is unbounded, as it was before. +- Clients built with `IggyConsumerBuilder` / `IggyPublisherBuilder` now auto-login with the credentials passed + to `WithConnection`, under **both** wire protocols. Before, a builder-created client came back from a + reconnect unauthenticated; now the credentials are held for the lifetime of the connection and replayed. +- The SDK now ships a dependency on `System.IO.Hashing`, used for the client-side message-key partitioner. +- TCP sockets are opened with `NoDelay`, under **both** wire protocols. Both are request/reply, so a write is + always the last one before the client waits for the answer and Nagle has nothing to coalesce it with - it + only held back the trailing segment of a large request until the previous one was acked. + ## Authentication ### User Login @@ -694,10 +788,18 @@ Integration tests are located in `Iggy_SDK.Tests.Integration/`. Tests can run ag #### 1. Dockerization +The whole suite runs once per server: `iggy-server`, then `iggy-server-ng` built with the `vsr` feature. On +`iggy-server` every test runs over both `http` and `tcp`; the `iggy-server-ng` leg is TCP-only. Local runs +therefore need both images. + ```bash cargo build -docker build --no-cache -f core/server/Dockerfile --platform linux/amd64 --target runtime-prebuilt --build-arg PREBUILT_IGGY_SERVER=target/debug/iggy-server --build-arg PREBUILT_IGGY_CLI=target/debug/iggy -t local-iggy-server . +docker build --no-cache -f core/server/Dockerfile --platform linux/amd64 --target runtime-prebuilt --build-arg PREBUILT_IGGY_SERVER=target/debug/iggy-server --build-arg PREBUILT_IGGY_CLI=target/debug/iggy -t iggy-server:test . + +cargo build --features vsr --bin iggy-server-ng --bin iggy + +docker build --no-cache -f core/server-ng/Dockerfile --platform linux/amd64 --target runtime-prebuilt --build-arg PREBUILT_IGGY_SERVER_NG=target/debug/iggy-server-ng --build-arg PREBUILT_IGGY_CLI=target/debug/iggy -t iggy-server-ng:test . ``` #### 2. Build the Test Project @@ -710,10 +812,24 @@ dotnet build foreign/csharp/Iggy_SDK.Tests.Integration ```bash cd foreign/csharp -export IGGY_SERVER_DOCKER_IMAGE=local-iggy-server +export IGGY_SERVER_DOCKER_IMAGE=iggy-server:test +export IGGY_SERVER_NG_DOCKER_IMAGE=iggy-server-ng:test dotnet test -f net10.0 --project Iggy_SDK.Tests.Integration --no-build --verbosity diagnostic ``` +`IGGY_TEST_SERVER` picks the server. It defaults to `classic`, so the command above needs no environment at +all when only `iggy-server:test` is built. Run it a second time to cover `iggy-server-ng`, where TCP is framed +with the VSR wire protocol. That leg runs TCP only: the cluster serves reads from the primary, and the HTTP +surface has no equivalent path to route them through. + +```bash +export IGGY_TEST_SERVER=ng +dotnet test -f net10.0 --project Iggy_SDK.Tests.Integration --no-build +``` + +Rider and Visual Studio need nothing configured for the classic run; set `IGGY_TEST_SERVER=ng` in the run +configuration's environment for the other one. + ## Useful Resources - [Iggy Documentation](https://iggy.apache.org/docs/)