Add comprehensive Go-parity test coverage across 5 subsystems: - Accounts/Auth: isolation, import/export, auth mechanisms, permissions (82 tests) - Gateways: connection, forwarding, interest mode, config (106 tests) - Routes: connection, subscription, forwarding, config validation (78 tests) - JetStream API: stream/consumer CRUD, pub/sub, features, admin (234 tests) - JetStream Cluster: streams, consumers, failover, meta (108 tests) Total: ~608 new test annotations across 22 files (+13,844 lines) All tests pass individually; suite total: 2,283 passing, 3 skipped
873 lines
33 KiB
C#
873 lines
33 KiB
C#
// Go parity: golang/nats-server/server/jetstream_cluster_1_test.go
|
|
// Covers: cluster stream creation, single/multi replica, memory store,
|
|
// stream purge, update subjects, delete, max bytes, stream info/list,
|
|
// interest retention, work queue retention, mirror/source in cluster.
|
|
using System.Text;
|
|
using NATS.Server.JetStream;
|
|
using NATS.Server.JetStream.Api;
|
|
using NATS.Server.JetStream.Cluster;
|
|
using NATS.Server.JetStream.Consumers;
|
|
using NATS.Server.JetStream.Models;
|
|
using NATS.Server.JetStream.Publish;
|
|
|
|
namespace NATS.Server.Tests.JetStream.Cluster;
|
|
|
|
/// <summary>
|
|
/// Tests covering clustered JetStream stream creation, replication, storage,
|
|
/// purge, update, delete, retention policies, and mirror/source in cluster mode.
|
|
/// Ported from Go jetstream_cluster_1_test.go.
|
|
/// </summary>
|
|
public class JetStreamClusterStreamTests
|
|
{
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterSingleReplicaStreams server/jetstream_cluster_1_test.go:223
|
|
// ---------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task Single_replica_stream_creation_and_publish_in_cluster()
|
|
{
|
|
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
|
|
|
|
var resp = await fx.CreateStreamAsync("R1S", ["foo", "bar"], replicas: 1);
|
|
resp.Error.ShouldBeNull();
|
|
resp.StreamInfo.ShouldNotBeNull();
|
|
resp.StreamInfo!.Config.Name.ShouldBe("R1S");
|
|
|
|
const int toSend = 10;
|
|
for (var i = 0; i < toSend; i++)
|
|
{
|
|
var ack = await fx.PublishAsync("foo", $"Hello R1 {i}");
|
|
ack.Stream.ShouldBe("R1S");
|
|
ack.Seq.ShouldBe((ulong)(i + 1));
|
|
}
|
|
|
|
var info = await fx.GetStreamInfoAsync("R1S");
|
|
info.StreamInfo!.State.Messages.ShouldBe((ulong)toSend);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterMultiReplicaStreamsDefaultFileMem server/jetstream_cluster_1_test.go:355
|
|
// ---------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task Multi_replica_stream_defaults_to_memory_store()
|
|
{
|
|
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
|
|
|
|
var resp = await fx.CreateStreamAsync("MEMTEST", ["mem.>"], replicas: 3);
|
|
resp.Error.ShouldBeNull();
|
|
resp.StreamInfo!.Config.Storage.ShouldBe(StorageType.Memory);
|
|
|
|
var backend = fx.GetStoreBackendType("MEMTEST");
|
|
backend.ShouldBe("memory");
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterMemoryStore server/jetstream_cluster_1_test.go:423
|
|
// ---------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task Memory_store_replicated_stream_accepts_100_messages()
|
|
{
|
|
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
|
|
|
|
var resp = await fx.CreateStreamAsync("R3M", ["foo", "bar"], replicas: 3, storage: StorageType.Memory);
|
|
resp.Error.ShouldBeNull();
|
|
|
|
const int toSend = 100;
|
|
for (var i = 0; i < toSend; i++)
|
|
{
|
|
var ack = await fx.PublishAsync("foo", "Hello MemoryStore");
|
|
ack.Stream.ShouldBe("R3M");
|
|
}
|
|
|
|
var info = await fx.GetStreamInfoAsync("R3M");
|
|
info.StreamInfo!.Config.Name.ShouldBe("R3M");
|
|
info.StreamInfo.State.Messages.ShouldBe((ulong)toSend);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterDelete server/jetstream_cluster_1_test.go:472
|
|
// ---------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task Delete_consumer_then_stream_clears_account_info()
|
|
{
|
|
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
|
|
|
|
await fx.CreateStreamAsync("C22", ["foo", "bar", "baz"], replicas: 2);
|
|
await fx.CreateConsumerAsync("C22", "dlc");
|
|
|
|
// Delete consumer then stream
|
|
var delConsumer = await fx.RequestAsync($"{JetStreamApiSubjects.ConsumerDelete}C22.dlc", "{}");
|
|
delConsumer.Success.ShouldBeTrue();
|
|
|
|
var delStream = await fx.RequestAsync($"{JetStreamApiSubjects.StreamDelete}C22", "{}");
|
|
delStream.Success.ShouldBeTrue();
|
|
|
|
// Account info should show zero streams
|
|
var accountInfo = await fx.RequestAsync(JetStreamApiSubjects.Info, "{}");
|
|
accountInfo.AccountInfo.ShouldNotBeNull();
|
|
accountInfo.AccountInfo!.Streams.ShouldBe(0);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterStreamPurge server/jetstream_cluster_1_test.go:522
|
|
// ---------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task Stream_purge_clears_all_messages_in_cluster()
|
|
{
|
|
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 5);
|
|
|
|
await fx.CreateStreamAsync("PURGE", ["foo", "bar"], replicas: 3);
|
|
|
|
const int toSend = 100;
|
|
for (var i = 0; i < toSend; i++)
|
|
await fx.PublishAsync("foo", "Hello JS Clustering");
|
|
|
|
var before = await fx.GetStreamInfoAsync("PURGE");
|
|
before.StreamInfo!.State.Messages.ShouldBe((ulong)toSend);
|
|
|
|
var purge = await fx.RequestAsync($"{JetStreamApiSubjects.StreamPurge}PURGE", "{}");
|
|
purge.Success.ShouldBeTrue();
|
|
|
|
var after = await fx.GetStreamInfoAsync("PURGE");
|
|
after.StreamInfo!.State.Messages.ShouldBe(0UL);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterStreamUpdateSubjects server/jetstream_cluster_1_test.go:571
|
|
// ---------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task Stream_update_subjects_reflects_new_configuration()
|
|
{
|
|
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
|
|
|
|
await fx.CreateStreamAsync("SUBUPDATE", ["foo", "bar"], replicas: 3);
|
|
|
|
// Update subjects to bar, baz
|
|
var update = fx.UpdateStream("SUBUPDATE", ["bar", "baz"], replicas: 3);
|
|
update.Error.ShouldBeNull();
|
|
update.StreamInfo.ShouldNotBeNull();
|
|
update.StreamInfo!.Config.Subjects.ShouldContain("bar");
|
|
update.StreamInfo.Config.Subjects.ShouldContain("baz");
|
|
update.StreamInfo.Config.Subjects.ShouldNotContain("foo");
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterStreamInfoList server/jetstream_cluster_1_test.go:1284
|
|
// ---------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task Stream_names_and_list_return_all_streams()
|
|
{
|
|
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
|
|
|
|
await fx.CreateStreamAsync("S1", ["s1.>"], replicas: 3);
|
|
await fx.CreateStreamAsync("S2", ["s2.>"], replicas: 3);
|
|
await fx.CreateStreamAsync("S3", ["s3.>"], replicas: 1);
|
|
|
|
var names = await fx.RequestAsync(JetStreamApiSubjects.StreamNames, "{}");
|
|
names.StreamNames.ShouldNotBeNull();
|
|
names.StreamNames!.Count.ShouldBe(3);
|
|
names.StreamNames.ShouldContain("S1");
|
|
names.StreamNames.ShouldContain("S2");
|
|
names.StreamNames.ShouldContain("S3");
|
|
|
|
var list = await fx.RequestAsync(JetStreamApiSubjects.StreamList, "{}");
|
|
list.StreamNames.ShouldNotBeNull();
|
|
list.StreamNames!.Count.ShouldBe(3);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterMaxBytesForStream server/jetstream_cluster_1_test.go:1099
|
|
// ---------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task Max_bytes_stream_limits_enforced_in_cluster()
|
|
{
|
|
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
|
|
|
|
var cfg = new StreamConfig
|
|
{
|
|
Name = "MAXBYTES",
|
|
Subjects = ["mb.>"],
|
|
Replicas = 3,
|
|
MaxBytes = 512,
|
|
Discard = DiscardPolicy.Old,
|
|
};
|
|
var resp = fx.CreateStreamDirect(cfg);
|
|
resp.Error.ShouldBeNull();
|
|
|
|
// Publish messages exceeding max bytes; old messages should be discarded
|
|
for (var i = 0; i < 20; i++)
|
|
await fx.PublishAsync("mb.data", new string('X', 64));
|
|
|
|
var state = await fx.GetStreamStateAsync("MAXBYTES");
|
|
// Total bytes should not exceed max_bytes by much after enforcement
|
|
((long)state.Bytes).ShouldBeLessThanOrEqualTo(cfg.MaxBytes + 128);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterStreamPublishWithActiveConsumers server/jetstream_cluster_1_test.go:1132
|
|
// ---------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task Publish_with_active_consumer_delivers_messages()
|
|
{
|
|
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
|
|
|
|
await fx.CreateStreamAsync("ACTIVE", ["active.>"], replicas: 3);
|
|
await fx.CreateConsumerAsync("ACTIVE", "durable1", filterSubject: "active.>");
|
|
|
|
for (var i = 0; i < 10; i++)
|
|
await fx.PublishAsync("active.event", $"msg-{i}");
|
|
|
|
var batch = await fx.FetchAsync("ACTIVE", "durable1", 10);
|
|
batch.Messages.Count.ShouldBe(10);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterDoubleAdd server/jetstream_cluster_1_test.go:1551
|
|
// ---------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task Double_add_stream_with_same_config_succeeds()
|
|
{
|
|
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
|
|
|
|
var first = await fx.CreateStreamAsync("DUP", ["dup.>"], replicas: 3);
|
|
first.Error.ShouldBeNull();
|
|
|
|
// Adding the same stream again should succeed (idempotent)
|
|
var second = await fx.CreateStreamAsync("DUP", ["dup.>"], replicas: 3);
|
|
second.Error.ShouldBeNull();
|
|
second.StreamInfo!.Config.Name.ShouldBe("DUP");
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterStreamOverlapSubjects server/jetstream_cluster_1_test.go:1248
|
|
// ---------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task Publish_routes_to_correct_stream_among_non_overlapping()
|
|
{
|
|
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
|
|
|
|
await fx.CreateStreamAsync("ALPHA", ["alpha.>"], replicas: 3);
|
|
await fx.CreateStreamAsync("BETA", ["beta.>"], replicas: 3);
|
|
|
|
var ack1 = await fx.PublishAsync("alpha.one", "A");
|
|
ack1.Stream.ShouldBe("ALPHA");
|
|
|
|
var ack2 = await fx.PublishAsync("beta.one", "B");
|
|
ack2.Stream.ShouldBe("BETA");
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterInterestRetention server/jetstream_cluster_1_test.go:2109
|
|
// ---------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task Interest_retention_stream_in_cluster()
|
|
{
|
|
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
|
|
|
|
var cfg = new StreamConfig
|
|
{
|
|
Name = "INTEREST",
|
|
Subjects = ["interest.>"],
|
|
Replicas = 3,
|
|
Retention = RetentionPolicy.Interest,
|
|
};
|
|
fx.CreateStreamDirect(cfg);
|
|
|
|
for (var i = 0; i < 5; i++)
|
|
await fx.PublishAsync("interest.event", "msg");
|
|
|
|
var state = await fx.GetStreamStateAsync("INTEREST");
|
|
state.Messages.ShouldBe(5UL);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterWorkQueueRetention server/jetstream_cluster_1_test.go:2179
|
|
// ---------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task Work_queue_retention_removes_acked_messages_in_cluster()
|
|
{
|
|
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
|
|
|
|
var cfg = new StreamConfig
|
|
{
|
|
Name = "WQ",
|
|
Subjects = ["wq.>"],
|
|
Replicas = 2,
|
|
Retention = RetentionPolicy.WorkQueue,
|
|
MaxConsumers = 1,
|
|
};
|
|
fx.CreateStreamDirect(cfg);
|
|
await fx.CreateConsumerAsync("WQ", "worker", filterSubject: "wq.>", ackPolicy: AckPolicy.All);
|
|
|
|
await fx.PublishAsync("wq.task", "job-1");
|
|
|
|
var stateBefore = await fx.GetStreamStateAsync("WQ");
|
|
stateBefore.Messages.ShouldBe(1UL);
|
|
|
|
// Ack all up to sequence 1, triggering work queue cleanup
|
|
fx.AckAll("WQ", "worker", 1);
|
|
|
|
// Publish again to trigger runtime retention enforcement
|
|
await fx.PublishAsync("wq.task", "job-2");
|
|
|
|
var stateAfter = await fx.GetStreamStateAsync("WQ");
|
|
// After ack, only the new message should remain
|
|
stateAfter.Messages.ShouldBe(1UL);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterDeleteMsg server/jetstream_cluster_1_test.go:1748
|
|
// ---------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task Delete_individual_message_in_clustered_stream()
|
|
{
|
|
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
|
|
|
|
await fx.CreateStreamAsync("DELMSG", ["dm.>"], replicas: 3);
|
|
|
|
for (var i = 0; i < 5; i++)
|
|
await fx.PublishAsync("dm.event", $"msg-{i}");
|
|
|
|
var before = await fx.GetStreamStateAsync("DELMSG");
|
|
before.Messages.ShouldBe(5UL);
|
|
|
|
// Delete message at sequence 3
|
|
var del = await fx.RequestAsync($"{JetStreamApiSubjects.StreamMessageDelete}DELMSG", """{"seq":3}""");
|
|
del.Success.ShouldBeTrue();
|
|
|
|
var after = await fx.GetStreamStateAsync("DELMSG");
|
|
after.Messages.ShouldBe(4UL);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterStreamUpdate server/jetstream_cluster_1_test.go:1433
|
|
// ---------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task Stream_update_preserves_existing_messages()
|
|
{
|
|
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
|
|
|
|
await fx.CreateStreamAsync("UPD", ["upd.>"], replicas: 3);
|
|
|
|
for (var i = 0; i < 5; i++)
|
|
await fx.PublishAsync("upd.event", $"msg-{i}");
|
|
|
|
// Update max_msgs
|
|
var update = fx.UpdateStream("UPD", ["upd.>"], replicas: 3, maxMsgs: 10);
|
|
update.Error.ShouldBeNull();
|
|
|
|
var state = await fx.GetStreamStateAsync("UPD");
|
|
state.Messages.ShouldBe(5UL);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterAccountInfo server/jetstream_cluster_1_test.go:94
|
|
// ---------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task Account_info_reports_stream_and_consumer_counts()
|
|
{
|
|
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
|
|
|
|
await fx.CreateStreamAsync("AI1", ["ai1.>"], replicas: 3);
|
|
await fx.CreateStreamAsync("AI2", ["ai2.>"], replicas: 3);
|
|
await fx.CreateConsumerAsync("AI1", "c1");
|
|
|
|
var resp = await fx.RequestAsync(JetStreamApiSubjects.Info, "{}");
|
|
resp.AccountInfo.ShouldNotBeNull();
|
|
resp.AccountInfo!.Streams.ShouldBe(2);
|
|
resp.AccountInfo.Consumers.ShouldBe(1);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterExpand server/jetstream_cluster_1_test.go:86
|
|
// ---------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void Cluster_expand_adds_peer_to_meta_group()
|
|
{
|
|
var meta = new JetStreamMetaGroup(2);
|
|
var state = meta.GetState();
|
|
state.ClusterSize.ShouldBe(2);
|
|
|
|
// Expanding is modeled by creating a new meta group with more nodes
|
|
var expanded = new JetStreamMetaGroup(3);
|
|
expanded.GetState().ClusterSize.ShouldBe(3);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterMirrorAndSourceWorkQueues server/jetstream_cluster_1_test.go:2233
|
|
// ---------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task Mirror_stream_replicates_in_cluster()
|
|
{
|
|
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
|
|
|
|
// Create origin stream
|
|
await fx.CreateStreamAsync("ORIGIN", ["origin.>"], replicas: 3);
|
|
|
|
// Create mirror stream
|
|
fx.CreateStreamDirect(new StreamConfig
|
|
{
|
|
Name = "MIRROR",
|
|
Subjects = ["mirror.>"],
|
|
Replicas = 3,
|
|
Mirror = "ORIGIN",
|
|
});
|
|
|
|
// Publish to origin
|
|
for (var i = 0; i < 5; i++)
|
|
await fx.PublishAsync("origin.event", $"mirrored-{i}");
|
|
|
|
// Mirror should have replicated messages
|
|
var mirrorState = await fx.GetStreamStateAsync("MIRROR");
|
|
mirrorState.Messages.ShouldBe(5UL);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterMirrorAndSourceInterestPolicyStream server/jetstream_cluster_1_test.go:2290
|
|
// ---------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task Source_stream_replicates_in_cluster()
|
|
{
|
|
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
|
|
|
|
// Create source origin
|
|
await fx.CreateStreamAsync("SRC", ["src.>"], replicas: 3);
|
|
|
|
// Create aggregate stream sourcing from SRC
|
|
fx.CreateStreamDirect(new StreamConfig
|
|
{
|
|
Name = "AGG",
|
|
Subjects = ["agg.>"],
|
|
Replicas = 3,
|
|
Sources = [new StreamSourceConfig { Name = "SRC" }],
|
|
});
|
|
|
|
// Publish to source
|
|
for (var i = 0; i < 3; i++)
|
|
await fx.PublishAsync("src.event", $"sourced-{i}");
|
|
|
|
var aggState = await fx.GetStreamStateAsync("AGG");
|
|
aggState.Messages.ShouldBe(3UL);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterUserSnapshotAndRestore server/jetstream_cluster_1_test.go:2652
|
|
// ---------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task Snapshot_and_restore_preserves_messages_in_cluster()
|
|
{
|
|
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
|
|
|
|
await fx.CreateStreamAsync("SNAP", ["snap.>"], replicas: 3);
|
|
|
|
for (var i = 0; i < 10; i++)
|
|
await fx.PublishAsync("snap.event", $"msg-{i}");
|
|
|
|
// Create snapshot
|
|
var snapshot = await fx.RequestAsync($"{JetStreamApiSubjects.StreamSnapshot}SNAP", "{}");
|
|
snapshot.Snapshot.ShouldNotBeNull();
|
|
snapshot.Snapshot!.Payload.ShouldNotBeNullOrEmpty();
|
|
|
|
// Purge the stream
|
|
await fx.RequestAsync($"{JetStreamApiSubjects.StreamPurge}SNAP", "{}");
|
|
var afterPurge = await fx.GetStreamStateAsync("SNAP");
|
|
afterPurge.Messages.ShouldBe(0UL);
|
|
|
|
// Restore from snapshot
|
|
var restore = await fx.RequestAsync($"{JetStreamApiSubjects.StreamRestore}SNAP", snapshot.Snapshot.Payload);
|
|
restore.Success.ShouldBeTrue();
|
|
|
|
var afterRestore = await fx.GetStreamStateAsync("SNAP");
|
|
afterRestore.Messages.ShouldBe(10UL);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterStreamSynchedTimeStamps server/jetstream_cluster_1_test.go:977
|
|
// ---------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task Replicated_stream_messages_have_monotonic_sequences()
|
|
{
|
|
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
|
|
|
|
await fx.CreateStreamAsync("SEQ", ["seq.>"], replicas: 3);
|
|
|
|
var sequences = new List<ulong>();
|
|
for (var i = 0; i < 20; i++)
|
|
{
|
|
var ack = await fx.PublishAsync("seq.event", $"msg-{i}");
|
|
sequences.Add(ack.Seq);
|
|
}
|
|
|
|
// Verify strictly monotonically increasing sequences
|
|
for (var i = 1; i < sequences.Count; i++)
|
|
sequences[i].ShouldBeGreaterThan(sequences[i - 1]);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterStreamLimits server/jetstream_cluster_1_test.go:3248
|
|
// ---------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task Max_msgs_limit_enforced_in_clustered_stream()
|
|
{
|
|
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
|
|
|
|
var cfg = new StreamConfig
|
|
{
|
|
Name = "LIMITED",
|
|
Subjects = ["limited.>"],
|
|
Replicas = 3,
|
|
MaxMsgs = 5,
|
|
};
|
|
fx.CreateStreamDirect(cfg);
|
|
|
|
for (var i = 0; i < 10; i++)
|
|
await fx.PublishAsync("limited.event", $"msg-{i}");
|
|
|
|
var state = await fx.GetStreamStateAsync("LIMITED");
|
|
state.Messages.ShouldBeLessThanOrEqualTo(5UL);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterStreamInterestOnlyPolicy server/jetstream_cluster_1_test.go:3310
|
|
// ---------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task Interest_only_policy_stream_stores_messages_without_consumers()
|
|
{
|
|
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
|
|
|
|
var cfg = new StreamConfig
|
|
{
|
|
Name = "INTONLY",
|
|
Subjects = ["intonly.>"],
|
|
Replicas = 3,
|
|
Retention = RetentionPolicy.Interest,
|
|
};
|
|
fx.CreateStreamDirect(cfg);
|
|
|
|
for (var i = 0; i < 3; i++)
|
|
await fx.PublishAsync("intonly.data", $"msg-{i}");
|
|
|
|
// Without consumers, interest retention still stores messages
|
|
// (they are removed only when all consumers have acked)
|
|
var state = await fx.GetStreamStateAsync("INTONLY");
|
|
state.Messages.ShouldBe(3UL);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterConsumerInfoList server/jetstream_cluster_1_test.go:1349
|
|
// ---------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task Consumer_names_and_list_return_all_consumers()
|
|
{
|
|
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
|
|
|
|
await fx.CreateStreamAsync("CLIST", ["clist.>"], replicas: 3);
|
|
await fx.CreateConsumerAsync("CLIST", "c1");
|
|
await fx.CreateConsumerAsync("CLIST", "c2");
|
|
await fx.CreateConsumerAsync("CLIST", "c3");
|
|
|
|
var names = await fx.RequestAsync($"{JetStreamApiSubjects.ConsumerNames}CLIST", "{}");
|
|
names.ConsumerNames.ShouldNotBeNull();
|
|
names.ConsumerNames!.Count.ShouldBe(3);
|
|
names.ConsumerNames.ShouldContain("c1");
|
|
names.ConsumerNames.ShouldContain("c2");
|
|
names.ConsumerNames.ShouldContain("c3");
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterDefaultMaxAckPending server/jetstream_cluster_1_test.go:1580
|
|
// ---------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task Consumer_default_ack_policy_is_none()
|
|
{
|
|
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
|
|
|
|
await fx.CreateStreamAsync("ACKDEF", ["ackdef.>"], replicas: 3);
|
|
var resp = await fx.CreateConsumerAsync("ACKDEF", "test_consumer");
|
|
|
|
resp.ConsumerInfo.ShouldNotBeNull();
|
|
resp.ConsumerInfo!.Config.AckPolicy.ShouldBe(AckPolicy.None);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterExtendedStreamInfo server/jetstream_cluster_1_test.go:1878
|
|
// ---------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task Stream_info_returns_config_and_state()
|
|
{
|
|
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
|
|
|
|
await fx.CreateStreamAsync("EXTINFO", ["ext.>"], replicas: 3);
|
|
|
|
for (var i = 0; i < 5; i++)
|
|
await fx.PublishAsync("ext.event", $"msg-{i}");
|
|
|
|
var info = await fx.GetStreamInfoAsync("EXTINFO");
|
|
info.StreamInfo.ShouldNotBeNull();
|
|
info.StreamInfo!.Config.Name.ShouldBe("EXTINFO");
|
|
info.StreamInfo.Config.Replicas.ShouldBe(3);
|
|
info.StreamInfo.State.Messages.ShouldBe(5UL);
|
|
info.StreamInfo.State.FirstSeq.ShouldBe(1UL);
|
|
info.StreamInfo.State.LastSeq.ShouldBe(5UL);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterExtendedStreamInfoSingleReplica server/jetstream_cluster_1_test.go:2033
|
|
// ---------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task Single_replica_stream_info_in_cluster()
|
|
{
|
|
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
|
|
|
|
await fx.CreateStreamAsync("R1INFO", ["r1info.>"], replicas: 1);
|
|
|
|
for (var i = 0; i < 3; i++)
|
|
await fx.PublishAsync("r1info.event", $"msg-{i}");
|
|
|
|
var info = await fx.GetStreamInfoAsync("R1INFO");
|
|
info.StreamInfo!.Config.Replicas.ShouldBe(1);
|
|
info.StreamInfo.State.Messages.ShouldBe(3UL);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterMultiReplicaStreams (maxmsgs_per behavior)
|
|
// ---------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task Max_msgs_per_subject_enforced_in_cluster()
|
|
{
|
|
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
|
|
|
|
var cfg = new StreamConfig
|
|
{
|
|
Name = "PERSUBJ",
|
|
Subjects = ["ps.>"],
|
|
Replicas = 3,
|
|
MaxMsgsPer = 2,
|
|
};
|
|
fx.CreateStreamDirect(cfg);
|
|
|
|
// Publish 5 messages to same subject; only 2 should remain
|
|
for (var i = 0; i < 5; i++)
|
|
await fx.PublishAsync("ps.topic", $"msg-{i}");
|
|
|
|
var state = await fx.GetStreamStateAsync("PERSUBJ");
|
|
state.Messages.ShouldBeLessThanOrEqualTo(2UL);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterStreamExtendedUpdates server/jetstream_cluster_1_test.go:1513
|
|
// ---------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task Stream_update_can_change_max_msgs()
|
|
{
|
|
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
|
|
|
|
var cfg = new StreamConfig
|
|
{
|
|
Name = "EXTUPD",
|
|
Subjects = ["eu.>"],
|
|
Replicas = 3,
|
|
};
|
|
fx.CreateStreamDirect(cfg);
|
|
|
|
for (var i = 0; i < 10; i++)
|
|
await fx.PublishAsync("eu.event", $"msg-{i}");
|
|
|
|
// Update to limit max_msgs
|
|
var update = fx.UpdateStream("EXTUPD", ["eu.>"], replicas: 3, maxMsgs: 5);
|
|
update.Error.ShouldBeNull();
|
|
update.StreamInfo!.Config.MaxMsgs.ShouldBe(5);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Additional: Sealed stream rejects purge
|
|
// ---------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task Sealed_stream_rejects_purge_in_cluster()
|
|
{
|
|
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
|
|
|
|
var cfg = new StreamConfig
|
|
{
|
|
Name = "SEALED",
|
|
Subjects = ["sealed.>"],
|
|
Replicas = 3,
|
|
Sealed = true,
|
|
};
|
|
fx.CreateStreamDirect(cfg);
|
|
|
|
var purge = await fx.RequestAsync($"{JetStreamApiSubjects.StreamPurge}SEALED", "{}");
|
|
// Sealed streams should not allow purge
|
|
purge.Success.ShouldBeFalse();
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Additional: DenyDelete stream rejects message delete
|
|
// ---------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task DenyDelete_stream_rejects_message_delete()
|
|
{
|
|
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
|
|
|
|
var cfg = new StreamConfig
|
|
{
|
|
Name = "NODELDENY",
|
|
Subjects = ["nodel.>"],
|
|
Replicas = 3,
|
|
DenyDelete = true,
|
|
};
|
|
fx.CreateStreamDirect(cfg);
|
|
|
|
await fx.PublishAsync("nodel.event", "msg");
|
|
|
|
var del = await fx.RequestAsync($"{JetStreamApiSubjects.StreamMessageDelete}NODELDENY", """{"seq":1}""");
|
|
del.Success.ShouldBeFalse();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Self-contained fixture for JetStream cluster stream tests. Wires up
|
|
/// meta group, stream manager, consumer manager, API router, and publisher.
|
|
/// </summary>
|
|
internal sealed class ClusterStreamFixture : IAsyncDisposable
|
|
{
|
|
private readonly JetStreamMetaGroup _metaGroup;
|
|
private readonly StreamManager _streamManager;
|
|
private readonly ConsumerManager _consumerManager;
|
|
private readonly JetStreamApiRouter _router;
|
|
private readonly JetStreamPublisher _publisher;
|
|
|
|
private ClusterStreamFixture(
|
|
JetStreamMetaGroup metaGroup,
|
|
StreamManager streamManager,
|
|
ConsumerManager consumerManager,
|
|
JetStreamApiRouter router,
|
|
JetStreamPublisher publisher)
|
|
{
|
|
_metaGroup = metaGroup;
|
|
_streamManager = streamManager;
|
|
_consumerManager = consumerManager;
|
|
_router = router;
|
|
_publisher = publisher;
|
|
}
|
|
|
|
public static Task<ClusterStreamFixture> StartAsync(int nodes)
|
|
{
|
|
var meta = new JetStreamMetaGroup(nodes);
|
|
var consumerManager = new ConsumerManager(meta);
|
|
var streamManager = new StreamManager(meta, consumerManager: consumerManager);
|
|
var router = new JetStreamApiRouter(streamManager, consumerManager, meta);
|
|
var publisher = new JetStreamPublisher(streamManager);
|
|
return Task.FromResult(new ClusterStreamFixture(meta, streamManager, consumerManager, router, publisher));
|
|
}
|
|
|
|
public Task<JetStreamApiResponse> CreateStreamAsync(string name, string[] subjects, int replicas, StorageType storage = StorageType.Memory)
|
|
{
|
|
var response = _streamManager.CreateOrUpdate(new StreamConfig
|
|
{
|
|
Name = name,
|
|
Subjects = [.. subjects],
|
|
Replicas = replicas,
|
|
Storage = storage,
|
|
});
|
|
return Task.FromResult(response);
|
|
}
|
|
|
|
public JetStreamApiResponse CreateStreamDirect(StreamConfig config)
|
|
=> _streamManager.CreateOrUpdate(config);
|
|
|
|
public JetStreamApiResponse UpdateStream(string name, string[] subjects, int replicas, int maxMsgs = 0)
|
|
{
|
|
return _streamManager.CreateOrUpdate(new StreamConfig
|
|
{
|
|
Name = name,
|
|
Subjects = [.. subjects],
|
|
Replicas = replicas,
|
|
MaxMsgs = maxMsgs,
|
|
});
|
|
}
|
|
|
|
public Task<PubAck> PublishAsync(string subject, string payload)
|
|
{
|
|
if (_publisher.TryCapture(subject, Encoding.UTF8.GetBytes(payload), null, out var ack))
|
|
{
|
|
if (ack.ErrorCode == null && _streamManager.TryGet(ack.Stream, out var handle))
|
|
{
|
|
var stored = handle.Store.LoadAsync(ack.Seq, default).GetAwaiter().GetResult();
|
|
if (stored != null)
|
|
_consumerManager.OnPublished(ack.Stream, stored);
|
|
}
|
|
|
|
return Task.FromResult(ack);
|
|
}
|
|
|
|
throw new InvalidOperationException($"Publish to '{subject}' did not match a stream.");
|
|
}
|
|
|
|
public Task<JetStreamApiResponse> GetStreamInfoAsync(string name)
|
|
=> Task.FromResult(_streamManager.GetInfo(name));
|
|
|
|
public Task<ApiStreamState> GetStreamStateAsync(string name)
|
|
=> _streamManager.GetStateAsync(name, default).AsTask();
|
|
|
|
public string GetStoreBackendType(string name) => _streamManager.GetStoreBackendType(name);
|
|
|
|
public Task<JetStreamApiResponse> CreateConsumerAsync(
|
|
string stream,
|
|
string durableName,
|
|
string? filterSubject = null,
|
|
AckPolicy ackPolicy = AckPolicy.None)
|
|
{
|
|
var config = new ConsumerConfig
|
|
{
|
|
DurableName = durableName,
|
|
AckPolicy = ackPolicy,
|
|
};
|
|
if (!string.IsNullOrWhiteSpace(filterSubject))
|
|
config.FilterSubject = filterSubject;
|
|
|
|
return Task.FromResult(_consumerManager.CreateOrUpdate(stream, config));
|
|
}
|
|
|
|
public Task<PullFetchBatch> FetchAsync(string stream, string durableName, int batch)
|
|
=> _consumerManager.FetchAsync(stream, durableName, batch, _streamManager, default).AsTask();
|
|
|
|
public void AckAll(string stream, string durableName, ulong sequence)
|
|
=> _consumerManager.AckAll(stream, durableName, sequence);
|
|
|
|
public Task<JetStreamApiResponse> RequestAsync(string subject, string payload)
|
|
=> Task.FromResult(_router.Route(subject, Encoding.UTF8.GetBytes(payload)));
|
|
|
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
|
}
|