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/)