Move 225 JetStream-related test files from NATS.Server.Tests into a dedicated NATS.Server.JetStream.Tests project. This includes root-level JetStream*.cs files, storage test files (FileStore, MemStore, StreamStoreContract), and the full JetStream/ subfolder tree (Api, Cluster, Consumers, MirrorSource, Snapshots, Storage, Streams). Updated all namespaces, added InternalsVisibleTo, registered in the solution file, and added the JETSTREAM_INTEGRATION_MATRIX define.
1935 lines
78 KiB
C#
1935 lines
78 KiB
C#
// Go parity: golang/nats-server/server/jetstream_cluster_2_test.go
|
|
// Covers the behavioral intent of the Go JetStream cluster-2 tests ported to
|
|
// the .NET JetStreamClusterFixture / StreamManager / ConsumerManager infrastructure.
|
|
// Focuses on: mixed-mode clusters, server limits, ack-pending with expiry,
|
|
// NAK backoffs, rollups, sealed streams, domain source/mirror, cross-account,
|
|
// consumer updates, balanced placement, consumer pending, stream seal, discard,
|
|
// max-consumers, purge-by-sequence, stream delete details, and more.
|
|
using NATS.Server.JetStream.Api;
|
|
using NATS.Server.JetStream.Cluster;
|
|
using NATS.Server.JetStream.Models;
|
|
using NATS.Server.TestUtilities;
|
|
|
|
namespace NATS.Server.JetStream.Tests.JetStream.Cluster;
|
|
|
|
/// <summary>
|
|
/// Go-parity tests for JetStream cluster behavior from jetstream_cluster_2_test.go.
|
|
/// Covers cross-domain source/mirror semantics, mixed-mode cluster behavior,
|
|
/// rollup semantics, consumer NAK/backoff, sealed streams, max-consumer limits,
|
|
/// purge-by-sequence, and more. Each test cites the corresponding Go test.
|
|
/// </summary>
|
|
public class JsCluster2GoParityTests
|
|
{
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterMultiRestartBug (jetstream_cluster_2_test.go:137)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterMultiRestartBug — stream survives node restart lifecycle
|
|
[Fact]
|
|
public async Task Stream_survives_simulated_node_restart_with_message_count_intact()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
var resp = await cluster.CreateStreamAsync("MULTIRESTART", ["mr.>"], replicas: 3);
|
|
resp.Error.ShouldBeNull();
|
|
|
|
for (var i = 0; i < 20; i++)
|
|
await cluster.PublishAsync("mr.event", $"msg-{i}");
|
|
|
|
var stateBefore = await cluster.GetStreamStateAsync("MULTIRESTART");
|
|
stateBefore.Messages.ShouldBe(20UL);
|
|
|
|
// Simulate node removal and restart
|
|
cluster.RemoveNode(1);
|
|
cluster.SimulateNodeRestart(1);
|
|
|
|
var stateAfter = await cluster.GetStreamStateAsync("MULTIRESTART");
|
|
stateAfter.Messages.ShouldBe(20UL);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterServerLimits (jetstream_cluster_2_test.go:201)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterServerLimits — memory stream rejects messages beyond max
|
|
[Fact]
|
|
public async Task Memory_stream_with_max_msgs_stops_accepting_beyond_limit()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
var resp = cluster.CreateStreamDirect(new StreamConfig
|
|
{
|
|
Name = "MAXMEM",
|
|
Subjects = ["maxmem.>"],
|
|
Replicas = 3,
|
|
Storage = StorageType.Memory,
|
|
MaxMsgs = 50,
|
|
});
|
|
resp.Error.ShouldBeNull();
|
|
|
|
for (var i = 0; i < 50; i++)
|
|
await cluster.PublishAsync("maxmem.evt", $"msg-{i}");
|
|
|
|
var state = await cluster.GetStreamStateAsync("MAXMEM");
|
|
state.Messages.ShouldBeLessThanOrEqualTo(50UL);
|
|
}
|
|
|
|
// Go reference: TestJetStreamClusterServerLimits — file stream enforces max bytes per server
|
|
[Fact]
|
|
public async Task File_stream_with_max_bytes_enforces_limit()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
var resp = cluster.CreateStreamDirect(new StreamConfig
|
|
{
|
|
Name = "MAXFILE",
|
|
Subjects = ["maxfile.>"],
|
|
Replicas = 3,
|
|
Storage = StorageType.File,
|
|
MaxBytes = 8 * 1024 * 1024, // 8MB
|
|
});
|
|
resp.Error.ShouldBeNull();
|
|
|
|
var group = cluster.GetReplicaGroup("MAXFILE");
|
|
group.ShouldNotBeNull();
|
|
group!.Nodes.Count.ShouldBe(3);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterAccountLoadFailure (jetstream_cluster_2_test.go:289)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterAccountLoadFailure — stream create still succeeds on healthy cluster
|
|
[Fact]
|
|
public async Task Stream_creation_succeeds_on_healthy_3_node_cluster()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
var resp = await cluster.CreateStreamAsync("ACCLOAD", ["accload.>"], replicas: 3);
|
|
resp.Error.ShouldBeNull();
|
|
resp.StreamInfo.ShouldNotBeNull();
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterAckPendingWithExpired (jetstream_cluster_2_test.go:309)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterAckPendingWithExpired — published messages are tracked
|
|
[Fact]
|
|
public async Task Consumer_ack_pending_tracks_all_published_messages()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
// Go ref: server/jetstream_cluster_2_test.go:309 (TestJetStreamClusterAckPendingWithExpired)
|
|
// Go: MaxAge = 500ms
|
|
var resp = cluster.CreateStreamDirect(new StreamConfig
|
|
{
|
|
Name = "ACKPENDING",
|
|
Subjects = ["ackpend.>"],
|
|
Replicas = 3,
|
|
MaxAgeMs = 500,
|
|
});
|
|
resp.Error.ShouldBeNull();
|
|
|
|
await cluster.CreateConsumerAsync("ACKPENDING", "reader", filterSubject: "ackpend.>", ackPolicy: AckPolicy.Explicit);
|
|
|
|
for (var i = 0; i < 20; i++)
|
|
await cluster.PublishAsync("ackpend.evt", $"msg-{i}");
|
|
|
|
var batch = await cluster.FetchAsync("ACKPENDING", "reader", 20);
|
|
batch.Messages.Count.ShouldBe(20);
|
|
}
|
|
|
|
// Go reference: TestJetStreamClusterAckPendingWithExpired — stream state matches published count
|
|
[Fact]
|
|
public async Task Stream_state_reflects_all_published_messages_before_expiry()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
// Go ref: server/jetstream_cluster_2_test.go:309 (TestJetStreamClusterAckPendingWithExpired)
|
|
// Go: MaxAge = 500ms
|
|
var resp = cluster.CreateStreamDirect(new StreamConfig
|
|
{
|
|
Name = "EXPIRING",
|
|
Subjects = ["expire.>"],
|
|
Replicas = 3,
|
|
MaxAgeMs = 500,
|
|
});
|
|
resp.Error.ShouldBeNull();
|
|
|
|
const int toSend = 100;
|
|
for (var i = 0; i < toSend; i++)
|
|
await cluster.PublishAsync("expire.evt", $"msg-{i}");
|
|
|
|
var state = await cluster.GetStreamStateAsync("EXPIRING");
|
|
state.Messages.ShouldBe((ulong)toSend);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterAckPendingWithMaxRedelivered (jetstream_cluster_2_test.go:377)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterAckPendingWithMaxRedelivered — consumer with max deliver set
|
|
[Fact]
|
|
public async Task Consumer_with_max_deliver_and_ack_wait_is_created_successfully()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
var resp = await cluster.CreateStreamAsync("MAXREDELIVER", ["maxrdlv.>"], replicas: 3);
|
|
resp.Error.ShouldBeNull();
|
|
|
|
var consResp = await cluster.CreateConsumerAsync("MAXREDELIVER", "retrier",
|
|
filterSubject: "maxrdlv.>", ackPolicy: AckPolicy.Explicit);
|
|
consResp.Error.ShouldBeNull();
|
|
consResp.ConsumerInfo.ShouldNotBeNull();
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterMixedMode (jetstream_cluster_2_test.go:427)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterMixedMode — cluster with JS nodes only tracks correct peer count
|
|
[Fact]
|
|
public async Task Mixed_mode_cluster_tracks_only_JS_peers_in_meta_group()
|
|
{
|
|
// Simulate: 3 JS nodes in a 5-server mixed-mode setup
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
cluster.NodeCount.ShouldBe(3);
|
|
var state = cluster.GetMetaState();
|
|
state.ShouldNotBeNull();
|
|
state!.ClusterSize.ShouldBe(3);
|
|
}
|
|
|
|
// Go reference: TestJetStreamClusterMixedMode — stream created on JS-only nodes
|
|
[Fact]
|
|
public async Task Stream_created_on_JS_enabled_nodes_in_mixed_mode()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
var resp = await cluster.CreateStreamAsync("MIXED_TEST", ["mixed.>"], replicas: 3);
|
|
resp.Error.ShouldBeNull();
|
|
resp.StreamInfo!.Config.Replicas.ShouldBe(3);
|
|
|
|
var leaderId = cluster.GetMetaLeaderId();
|
|
leaderId.ShouldNotBeNullOrWhiteSpace();
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterStreamInfoDeletedDetails (jetstream_cluster_2_test.go:1324)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterStreamInfoDeletedDetails — stream has messages after publish
|
|
[Fact]
|
|
public async Task Stream_info_reflects_published_messages_in_R1_cluster_stream()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(2);
|
|
|
|
await cluster.CreateStreamAsync("INFODELS", ["infodel.>"], replicas: 1);
|
|
|
|
for (var i = 0; i < 10; i++)
|
|
await cluster.PublishAsync("infodel.evt", $"msg-{i}");
|
|
|
|
var state = await cluster.GetStreamStateAsync("INFODELS");
|
|
state.Messages.ShouldBe(10UL);
|
|
}
|
|
|
|
// Go reference: TestJetStreamClusterStreamInfoDeletedDetails — stream info returns valid state
|
|
[Fact]
|
|
public async Task Stream_info_API_returns_valid_stream_state_with_message_count()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
await cluster.CreateStreamAsync("DELINFOTEST", ["delinfo.>"], replicas: 2);
|
|
|
|
for (var i = 0; i < 5; i++)
|
|
await cluster.PublishAsync("delinfo.evt", $"msg-{i}");
|
|
|
|
var info = await cluster.GetStreamInfoAsync("DELINFOTEST");
|
|
info.Error.ShouldBeNull();
|
|
info.StreamInfo!.State.Messages.ShouldBe(5UL);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterMirrorAndSourceExpiration (jetstream_cluster_2_test.go:1396)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterMirrorAndSourceExpiration — mirror stream created from origin
|
|
[Fact]
|
|
public async Task Mirror_stream_config_is_accepted_in_3_node_cluster()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
// Create origin stream
|
|
await cluster.CreateStreamAsync("ORIGIN", ["origin.>"], replicas: 1);
|
|
|
|
// Create mirror stream (mirror config uses Mirror field)
|
|
var mirrorResp = cluster.CreateStreamDirect(new StreamConfig
|
|
{
|
|
Name = "MIRROR",
|
|
Subjects = [],
|
|
Replicas = 2,
|
|
Mirror = "ORIGIN",
|
|
});
|
|
mirrorResp.Error.ShouldBeNull();
|
|
}
|
|
|
|
// Go reference: TestJetStreamClusterMirrorAndSourceExpiration — source stream aggregates from origin
|
|
[Fact]
|
|
public async Task Source_stream_created_alongside_mirror_stream()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
await cluster.CreateStreamAsync("SRC_ORIGIN", ["srcori.>"], replicas: 1);
|
|
|
|
var sourceResp = cluster.CreateStreamDirect(new StreamConfig
|
|
{
|
|
Name = "SOURCE_AGG",
|
|
Subjects = [],
|
|
Replicas = 2,
|
|
Sources = [new StreamSourceConfig { Name = "SRC_ORIGIN" }],
|
|
});
|
|
sourceResp.Error.ShouldBeNull();
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterMirrorAndSourceSubLeaks (jetstream_cluster_2_test.go:1513)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterMirrorAndSourceSubLeaks — 10 origin streams + mux stream
|
|
[Fact]
|
|
public async Task Multiple_origin_streams_all_registered_in_cluster()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
for (var i = 0; i < 10; i++)
|
|
{
|
|
var resp = await cluster.CreateStreamAsync($"ORDERS-{i + 1}", [$"orders{i + 1}.>"], replicas: 1);
|
|
resp.Error.ShouldBeNull();
|
|
}
|
|
|
|
var info = await cluster.RequestAsync(JetStreamApiSubjects.Info, "{}");
|
|
info.AccountInfo!.Streams.ShouldBe(10);
|
|
}
|
|
|
|
// Go reference: TestJetStreamClusterMirrorAndSourceSubLeaks — mux stream with many sources
|
|
[Fact]
|
|
public async Task Mux_stream_sourcing_multiple_origins_is_created_successfully()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
var sourceConfigs = new List<StreamSourceConfig>();
|
|
for (var i = 0; i < 5; i++)
|
|
{
|
|
await cluster.CreateStreamAsync($"MUX_SRC{i}", [$"muxsrc{i}.>"], replicas: 1);
|
|
sourceConfigs.Add(new StreamSourceConfig { Name = $"MUX_SRC{i}" });
|
|
}
|
|
|
|
var muxResp = cluster.CreateStreamDirect(new StreamConfig
|
|
{
|
|
Name = "MUXSTREAM",
|
|
Subjects = [],
|
|
Replicas = 2,
|
|
Sources = sourceConfigs,
|
|
});
|
|
muxResp.Error.ShouldBeNull();
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterCreateConcurrentDurableConsumers (jetstream_cluster_2_test.go:1572)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterCreateConcurrentDurableConsumers — multiple durable consumers created
|
|
[Fact]
|
|
public async Task Multiple_durable_consumers_on_same_stream_are_created_without_error()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
await cluster.CreateStreamAsync("CONCURCONS", ["cc.>"], replicas: 3);
|
|
|
|
var tasks = Enumerable.Range(0, 5)
|
|
.Select(i => cluster.CreateConsumerAsync("CONCURCONS", $"worker-{i}"))
|
|
.ToList();
|
|
|
|
var responses = await Task.WhenAll(tasks);
|
|
responses.All(r => r.Error == null).ShouldBeTrue();
|
|
}
|
|
|
|
// Go reference: TestJetStreamClusterCreateConcurrentDurableConsumers — concurrent consumer count matches
|
|
[Fact]
|
|
public async Task Account_consumer_count_matches_number_of_created_durable_consumers()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
await cluster.CreateStreamAsync("CONCURCOUNT", ["ccc.>"], replicas: 3);
|
|
|
|
for (var i = 0; i < 5; i++)
|
|
await cluster.CreateConsumerAsync("CONCURCOUNT", $"durable-{i}");
|
|
|
|
var info = await cluster.RequestAsync(JetStreamApiSubjects.Info, "{}");
|
|
info.AccountInfo!.Consumers.ShouldBe(5);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterUpdateStreamToExisting (jetstream_cluster_2_test.go:1622)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterUpdateStreamToExisting — updating stream to existing name is idempotent
|
|
[Fact]
|
|
public async Task Stream_update_to_same_config_is_idempotent()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
await cluster.CreateStreamAsync("UPDEXIST", ["ue.>"], replicas: 3);
|
|
var update = cluster.UpdateStream("UPDEXIST", ["ue.>"], replicas: 3);
|
|
update.Error.ShouldBeNull();
|
|
update.StreamInfo!.Config.Name.ShouldBe("UPDEXIST");
|
|
}
|
|
|
|
// Go reference: TestJetStreamClusterUpdateStreamToExisting — updating stream subjects succeeds
|
|
[Fact]
|
|
public async Task Stream_subject_update_in_cluster_succeeds()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
await cluster.CreateStreamAsync("SUBUPDATE", ["subupd.old.>"], replicas: 3);
|
|
var update = cluster.UpdateStream("SUBUPDATE", ["subupd.new.>"], replicas: 3);
|
|
update.Error.ShouldBeNull();
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterCrossAccountInterop (jetstream_cluster_2_test.go:1658)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterCrossAccountInterop — stream in JS account accessible
|
|
[Fact]
|
|
public async Task Stream_created_in_primary_account_is_accessible()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
// Simulating JS account stream creation
|
|
await cluster.CreateStreamAsync("XACC_TEST", ["xacc.>"], replicas: 2);
|
|
var state = await cluster.GetStreamStateAsync("XACC_TEST");
|
|
state.ShouldNotBeNull();
|
|
}
|
|
|
|
// Go reference: TestJetStreamClusterCrossAccountInterop — cross-account mirror stream reflects messages
|
|
[Fact]
|
|
public async Task Mirror_stream_aggregates_messages_from_origin_stream()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
await cluster.CreateStreamAsync("XACC_ORDERS", ["orders.>"], replicas: 2);
|
|
|
|
for (var i = 0; i < 10; i++)
|
|
await cluster.PublishAsync("orders.item", $"order-{i}");
|
|
|
|
// Create a mirror that sources from XACC_ORDERS
|
|
var mirrorResp = cluster.CreateStreamDirect(new StreamConfig
|
|
{
|
|
Name = "XACC_MIRROR",
|
|
Subjects = [],
|
|
Replicas = 1,
|
|
Mirror = "XACC_ORDERS",
|
|
});
|
|
mirrorResp.Error.ShouldBeNull();
|
|
|
|
// Origin should have all 10 messages
|
|
var originState = await cluster.GetStreamStateAsync("XACC_ORDERS");
|
|
originState.Messages.ShouldBe(10UL);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterMsgIdDuplicateBug (jetstream_cluster_2_test.go:1763)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterMsgIdDuplicateBug — duplicate dedup window works in R2 stream
|
|
[Fact]
|
|
public async Task Duplicate_window_configured_stream_is_created_successfully()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
var resp = cluster.CreateStreamDirect(new StreamConfig
|
|
{
|
|
Name = "MSGIDDUP",
|
|
Subjects = ["dupid.>"],
|
|
Replicas = 2,
|
|
DuplicateWindowMs = 5_000, // 5 second dedup window
|
|
});
|
|
resp.Error.ShouldBeNull();
|
|
|
|
// Multiple publishes to the same subject
|
|
var ack1 = await cluster.PublishAsync("dupid.test", "payload-1");
|
|
var ack2 = await cluster.PublishAsync("dupid.test", "payload-2");
|
|
|
|
ack1.ErrorCode.ShouldBeNull();
|
|
ack2.ErrorCode.ShouldBeNull();
|
|
ack2.Seq.ShouldBeGreaterThan(ack1.Seq);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterPurgeBySequence (jetstream_cluster_2_test.go:1911)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterPurgeBySequence — purge stream reduces message count
|
|
[Fact]
|
|
public async Task Purge_stream_removes_all_messages()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
var resp = cluster.CreateStreamDirect(new StreamConfig
|
|
{
|
|
Name = "PURGESEQ",
|
|
Subjects = ["purgeseq.>"],
|
|
Replicas = 2,
|
|
MaxMsgsPer = 5,
|
|
});
|
|
resp.Error.ShouldBeNull();
|
|
|
|
for (var i = 0; i < 20; i++)
|
|
await cluster.PublishAsync("purgeseq.user", $"value-{i}");
|
|
|
|
// MaxMsgsPer=5 means only last 5 per subject are kept
|
|
var stateBefore = await cluster.GetStreamStateAsync("PURGESEQ");
|
|
stateBefore.Messages.ShouldBeLessThanOrEqualTo(20UL);
|
|
|
|
await cluster.RequestAsync($"{JetStreamApiSubjects.StreamPurge}PURGESEQ", "{}");
|
|
|
|
var stateAfter = await cluster.GetStreamStateAsync("PURGESEQ");
|
|
stateAfter.Messages.ShouldBe(0UL);
|
|
}
|
|
|
|
// Go reference: TestJetStreamClusterPurgeBySequence — purge with subject filter works in file storage
|
|
[Fact]
|
|
public async Task File_storage_stream_purge_clears_messages()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
var resp = cluster.CreateStreamDirect(new StreamConfig
|
|
{
|
|
Name = "PURGESEQFILE",
|
|
Subjects = ["psf.>"],
|
|
Replicas = 2,
|
|
MaxMsgsPer = 5,
|
|
Storage = StorageType.File,
|
|
});
|
|
resp.Error.ShouldBeNull();
|
|
|
|
for (var i = 0; i < 10; i++)
|
|
await cluster.PublishAsync("psf.key", $"v{i}");
|
|
|
|
var state = await cluster.GetStreamStateAsync("PURGESEQFILE");
|
|
state.Messages.ShouldBeLessThanOrEqualTo(10UL);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterMaxConsumers (jetstream_cluster_2_test.go:1978)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterMaxConsumers — stream with max_consumers=1 accepts one consumer
|
|
[Fact]
|
|
public async Task Stream_with_max_consumers_1_accepts_first_consumer()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
var resp = cluster.CreateStreamDirect(new StreamConfig
|
|
{
|
|
Name = "MAXC",
|
|
Subjects = ["maxcons.>"],
|
|
Storage = StorageType.Memory,
|
|
MaxConsumers = 1,
|
|
});
|
|
resp.Error.ShouldBeNull();
|
|
resp.StreamInfo!.Config.MaxConsumers.ShouldBe(1);
|
|
|
|
var consResp = await cluster.CreateConsumerAsync("MAXC", "only-one");
|
|
consResp.Error.ShouldBeNull();
|
|
}
|
|
|
|
// Go reference: TestJetStreamClusterMaxConsumers — stream config preserves max_consumers setting
|
|
[Fact]
|
|
public async Task Stream_config_preserves_max_consumers_after_creation()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
var resp = cluster.CreateStreamDirect(new StreamConfig
|
|
{
|
|
Name = "MAXC2",
|
|
Subjects = ["maxc2.>"],
|
|
Storage = StorageType.Memory,
|
|
MaxConsumers = 2,
|
|
Replicas = 3,
|
|
});
|
|
resp.Error.ShouldBeNull();
|
|
|
|
var info = await cluster.GetStreamInfoAsync("MAXC2");
|
|
info.Error.ShouldBeNull();
|
|
info.StreamInfo!.Config.MaxConsumers.ShouldBe(2);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterMaxConsumersMultipleConcurrentRequests (jetstream_cluster_2_test.go:2011)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterMaxConsumersMultipleConcurrentRequests — max_consumers=1 enforced
|
|
[Fact]
|
|
public async Task MaxConsumers_limit_is_reflected_in_stream_info()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
var resp = cluster.CreateStreamDirect(new StreamConfig
|
|
{
|
|
Name = "MAXCC",
|
|
Subjects = ["maxcc.>"],
|
|
Storage = StorageType.Memory,
|
|
MaxConsumers = 1,
|
|
Replicas = 3,
|
|
});
|
|
resp.Error.ShouldBeNull();
|
|
|
|
var info = await cluster.GetStreamInfoAsync("MAXCC");
|
|
info.StreamInfo!.Config.MaxConsumers.ShouldBe(1);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterPushConsumerQueueGroup (jetstream_cluster_2_test.go:2300)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterPushConsumerQueueGroup — push consumer with queue group on R3
|
|
[Fact]
|
|
public async Task Push_consumer_created_on_R3_stream_with_queue_group()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
await cluster.CreateStreamAsync("PUSHQG", ["pqg.>"], replicas: 3);
|
|
|
|
var consResp = await cluster.CreateConsumerAsync("PUSHQG", "dlc-qg", filterSubject: "pqg.>");
|
|
consResp.Error.ShouldBeNull();
|
|
consResp.ConsumerInfo.ShouldNotBeNull();
|
|
}
|
|
|
|
// Go reference: TestJetStreamClusterPushConsumerQueueGroup — publishes to R3 stream are acked
|
|
[Fact]
|
|
public async Task Publishes_to_R3_stream_all_succeed_with_valid_acks()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
await cluster.CreateStreamAsync("PUSHQG_PUB", ["pqgp.>"], replicas: 3);
|
|
|
|
await cluster.PublishAsync("pqgp.msg", "QG");
|
|
await cluster.PublishAsync("pqgp.msg", "QG2");
|
|
await cluster.PublishAsync("pqgp.msg", "QG3");
|
|
|
|
var state = await cluster.GetStreamStateAsync("PUSHQG_PUB");
|
|
state.Messages.ShouldBe(3UL);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterConsumerLastActiveReporting (jetstream_cluster_2_test.go:2371)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterConsumerLastActiveReporting — consumer exists after fetch
|
|
[Fact]
|
|
public async Task Consumer_delivers_messages_and_is_queryable_after_fetch()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
await cluster.CreateStreamAsync("LASTACTIVE", ["la.>"], replicas: 2);
|
|
await cluster.CreateConsumerAsync("LASTACTIVE", "dlc", filterSubject: "la.>", ackPolicy: AckPolicy.Explicit);
|
|
|
|
await cluster.PublishAsync("la.msg", "OK");
|
|
|
|
var batch = await cluster.FetchAsync("LASTACTIVE", "dlc", 1);
|
|
batch.Messages.Count.ShouldBe(1);
|
|
|
|
var leaderId = cluster.GetConsumerLeaderId("LASTACTIVE", "dlc");
|
|
leaderId.ShouldNotBeNullOrWhiteSpace();
|
|
}
|
|
|
|
// Go reference: TestJetStreamClusterConsumerLastActiveReporting — consumer leader survives stepdown
|
|
[Fact]
|
|
public async Task Consumer_leader_is_valid_after_stream_leader_stepdown()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
await cluster.CreateStreamAsync("LASTEPREPORT", ["lasr.>"], replicas: 2);
|
|
await cluster.CreateConsumerAsync("LASTEPREPORT", "rip", filterSubject: "lasr.>", ackPolicy: AckPolicy.Explicit);
|
|
|
|
await cluster.StepDownStreamLeaderAsync("LASTEPREPORT");
|
|
|
|
var leaderId = cluster.GetConsumerLeaderId("LASTEPREPORT", "rip");
|
|
leaderId.ShouldNotBeNullOrWhiteSpace();
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterSeal (jetstream_cluster_2_test.go:2869)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterSeal — sealed=false stream creation succeeds
|
|
[Fact]
|
|
public async Task Creating_unsealed_stream_with_memory_storage_succeeds()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
var resp = cluster.CreateStreamDirect(new StreamConfig
|
|
{
|
|
Name = "SEALED_BASE",
|
|
Subjects = ["SEALED_BASE"],
|
|
Storage = StorageType.Memory,
|
|
Replicas = 3,
|
|
Sealed = false,
|
|
});
|
|
resp.Error.ShouldBeNull();
|
|
}
|
|
|
|
// Go reference: TestJetStreamClusterSeal — sealed=true on existing stream is reflected in config
|
|
[Fact]
|
|
public async Task Sealing_existing_stream_updates_config_with_sealed_flag()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
// Create an unsealed stream
|
|
var resp = cluster.CreateStreamDirect(new StreamConfig
|
|
{
|
|
Name = "SEALTEST",
|
|
Subjects = ["sealtest"],
|
|
Storage = StorageType.Memory,
|
|
Replicas = 3,
|
|
});
|
|
resp.Error.ShouldBeNull();
|
|
|
|
for (var i = 0; i < 10; i++)
|
|
await cluster.PublishAsync("sealtest", $"msg-{i}");
|
|
|
|
// Update to sealed
|
|
var update = cluster.UpdateStream("SEALTEST", ["sealtest"], replicas: 3);
|
|
update.Error.ShouldBeNull();
|
|
// Sealed would prevent further writes, which we verify by checking the stream is still readable
|
|
var state = await cluster.GetStreamStateAsync("SEALTEST");
|
|
state.Messages.ShouldBe(10UL);
|
|
}
|
|
|
|
// Go reference: TestJetStreamClusterSeal — DenyDelete on stream is preserved
|
|
[Fact]
|
|
public async Task Stream_with_deny_delete_and_deny_purge_flags_preserved_in_config()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
var resp = cluster.CreateStreamDirect(new StreamConfig
|
|
{
|
|
Name = "AUDIT_SEAL",
|
|
Subjects = ["audit_seal.>"],
|
|
Storage = StorageType.Memory,
|
|
Replicas = 3,
|
|
DenyDelete = true,
|
|
DenyPurge = true,
|
|
});
|
|
resp.Error.ShouldBeNull();
|
|
|
|
var info = await cluster.GetStreamInfoAsync("AUDIT_SEAL");
|
|
info.Error.ShouldBeNull();
|
|
info.StreamInfo!.Config.DenyDelete.ShouldBeTrue();
|
|
info.StreamInfo!.Config.DenyPurge.ShouldBeTrue();
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusteredStreamCreateIdempotent (jetstream_cluster_2_test.go:2980)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusteredStreamCreateIdempotent — creating same stream twice is idempotent
|
|
[Fact]
|
|
public async Task Creating_stream_with_deny_flags_twice_is_idempotent()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
var cfg = new StreamConfig
|
|
{
|
|
Name = "IDEM_AUDIT",
|
|
Subjects = ["idem.>"],
|
|
Storage = StorageType.Memory,
|
|
Replicas = 3,
|
|
DenyDelete = true,
|
|
DenyPurge = true,
|
|
};
|
|
|
|
var resp1 = cluster.CreateStreamDirect(cfg);
|
|
resp1.Error.ShouldBeNull();
|
|
|
|
var resp2 = cluster.CreateStreamDirect(cfg);
|
|
resp2.Error.ShouldBeNull();
|
|
resp2.StreamInfo!.Config.Name.ShouldBe("IDEM_AUDIT");
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterRollupsRequirePurge (jetstream_cluster_2_test.go:2999)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterRollupsRequirePurge — allow_rollup without deny_purge is accepted
|
|
[Fact]
|
|
public async Task Stream_with_allow_rollup_without_deny_purge_is_valid()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
// AllowRollup without DenyPurge should work
|
|
var resp = cluster.CreateStreamDirect(new StreamConfig
|
|
{
|
|
Name = "ROLLUP_OK",
|
|
Subjects = ["rollok.>"],
|
|
MaxMsgsPer = 10,
|
|
Replicas = 2,
|
|
});
|
|
resp.Error.ShouldBeNull();
|
|
}
|
|
|
|
// Go reference: TestJetStreamClusterRollupsRequirePurge — rollup requires purge permission (validation)
|
|
[Fact]
|
|
public async Task Stream_with_allow_rollup_and_deny_purge_is_flagged_as_invalid_or_accepted()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
// In Go, AllowRollup + DenyPurge is rejected with "roll-ups require the purge permission".
|
|
// In .NET model we don't implement this validation yet — confirm create succeeds or has error.
|
|
var resp = cluster.CreateStreamDirect(new StreamConfig
|
|
{
|
|
Name = "SENSORS_NOPURGE",
|
|
Subjects = ["sensor.*.temp"],
|
|
MaxMsgsPer = 10,
|
|
DenyPurge = true,
|
|
Replicas = 2,
|
|
});
|
|
// Either validation error OR success (no crash)
|
|
// The .NET model accepts this (validation gap) — test documents the current behavior
|
|
resp.ShouldNotBeNull();
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterRollups (jetstream_cluster_2_test.go:3029)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterRollups — stream with MaxMsgsPer limits per-subject messages
|
|
[Fact]
|
|
public async Task Stream_with_max_msgs_per_subject_limits_messages_per_topic()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
var resp = cluster.CreateStreamDirect(new StreamConfig
|
|
{
|
|
Name = "SENSORS_ROLLUP",
|
|
Subjects = ["sensor.*.temp"],
|
|
MaxMsgsPer = 10,
|
|
Replicas = 2,
|
|
});
|
|
resp.Error.ShouldBeNull();
|
|
|
|
// Publish 30 messages to one sensor (only last 10 kept)
|
|
for (var i = 0; i < 30; i++)
|
|
await cluster.PublishAsync("sensor.1.temp", $"temp-{60 + i}");
|
|
|
|
var state = await cluster.GetStreamStateAsync("SENSORS_ROLLUP");
|
|
state.Messages.ShouldBeLessThanOrEqualTo(10UL);
|
|
}
|
|
|
|
// Go reference: TestJetStreamClusterRollups — rollup stream preserves last message per subject
|
|
[Fact]
|
|
public async Task Rollup_stream_retains_at_most_max_msgs_per_subject()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
var resp = cluster.CreateStreamDirect(new StreamConfig
|
|
{
|
|
Name = "KV_ROLLUP",
|
|
Subjects = ["kv.*"],
|
|
MaxMsgsPer = 1,
|
|
Replicas = 2,
|
|
});
|
|
resp.Error.ShouldBeNull();
|
|
|
|
// Publish multiple values for the same key
|
|
await cluster.PublishAsync("kv.username", "alice");
|
|
await cluster.PublishAsync("kv.username", "bob");
|
|
await cluster.PublishAsync("kv.username", "charlie");
|
|
|
|
// MaxMsgsPer=1 means only last value kept per key
|
|
var state = await cluster.GetStreamStateAsync("KV_ROLLUP");
|
|
state.Messages.ShouldBeLessThanOrEqualTo(1UL);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterRollupSubjectAndWatchers (jetstream_cluster_2_test.go:3105)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterRollupSubjectAndWatchers — KV-style stream with watchers
|
|
[Fact]
|
|
public async Task KV_style_stream_tracks_multiple_keys_with_latest_value()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
var resp = cluster.CreateStreamDirect(new StreamConfig
|
|
{
|
|
Name = "KVW",
|
|
Subjects = ["kvw.*"],
|
|
MaxMsgsPer = 10,
|
|
Replicas = 2,
|
|
});
|
|
resp.Error.ShouldBeNull();
|
|
|
|
await cluster.CreateConsumerAsync("KVW", "watcher", filterSubject: "kvw.*");
|
|
|
|
// Send values for multiple keys
|
|
await cluster.PublishAsync("kvw.name", "derek");
|
|
await cluster.PublishAsync("kvw.age", "22");
|
|
await cluster.PublishAsync("kvw.name", "ivan");
|
|
await cluster.PublishAsync("kvw.age", "33");
|
|
|
|
var state = await cluster.GetStreamStateAsync("KVW");
|
|
state.Messages.ShouldBe(4UL); // MaxMsgsPer=10 means all are kept initially
|
|
|
|
// Watcher consumer sees all updates
|
|
var batch = await cluster.FetchAsync("KVW", "watcher", 4);
|
|
batch.Messages.Count.ShouldBe(4);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterAppendOnly (jetstream_cluster_2_test.go:3178)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterAppendOnly — append-only (DenyDelete+DenyPurge) stream config
|
|
[Fact]
|
|
public async Task Append_only_stream_with_deny_flags_accepts_publishes()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
var resp = cluster.CreateStreamDirect(new StreamConfig
|
|
{
|
|
Name = "AUDIT_APPEND",
|
|
Subjects = ["audit.>"],
|
|
Storage = StorageType.Memory,
|
|
Replicas = 3,
|
|
DenyDelete = true,
|
|
DenyPurge = true,
|
|
});
|
|
resp.Error.ShouldBeNull();
|
|
|
|
for (var i = 0; i < 10; i++)
|
|
await cluster.PublishAsync("audit.event", $"record-{i}");
|
|
|
|
var state = await cluster.GetStreamStateAsync("AUDIT_APPEND");
|
|
state.Messages.ShouldBe(10UL);
|
|
}
|
|
|
|
// Go reference: TestJetStreamClusterAppendOnly — append-only stream config preserved on info
|
|
[Fact]
|
|
public async Task Append_only_stream_deny_flags_preserved_in_stream_info()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
var resp = cluster.CreateStreamDirect(new StreamConfig
|
|
{
|
|
Name = "AUDIT_FLAGS",
|
|
Subjects = ["auditf.>"],
|
|
Storage = StorageType.Memory,
|
|
Replicas = 3,
|
|
DenyDelete = true,
|
|
DenyPurge = true,
|
|
});
|
|
resp.Error.ShouldBeNull();
|
|
resp.StreamInfo!.Config.DenyDelete.ShouldBeTrue();
|
|
resp.StreamInfo!.Config.DenyPurge.ShouldBeTrue();
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterStreamUpdateSyncBug (jetstream_cluster_2_test.go:3224)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterStreamUpdateSyncBug — stream update syncs across cluster
|
|
[Fact]
|
|
public async Task Stream_update_syncs_to_all_cluster_nodes()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
await cluster.CreateStreamAsync("SYNCED", ["synced.>"], replicas: 3);
|
|
|
|
// Publish some messages, then update config
|
|
for (var i = 0; i < 5; i++)
|
|
await cluster.PublishAsync("synced.evt", $"msg-{i}");
|
|
|
|
var update = cluster.UpdateStream("SYNCED", ["synced.>"], replicas: 3, maxMsgs: 100);
|
|
update.Error.ShouldBeNull();
|
|
update.StreamInfo!.Config.MaxMsgs.ShouldBe(100);
|
|
|
|
var state = await cluster.GetStreamStateAsync("SYNCED");
|
|
state.Messages.ShouldBe(5UL);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterListFilter (jetstream_cluster_2_test.go:3384)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterListFilter — stream names list includes all created streams
|
|
[Fact]
|
|
public async Task Stream_names_list_includes_all_created_streams()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
await cluster.CreateStreamAsync("LISTFILT_A", ["lfa.>"], replicas: 1);
|
|
await cluster.CreateStreamAsync("LISTFILT_B", ["lfb.>"], replicas: 1);
|
|
await cluster.CreateStreamAsync("LISTFILT_C", ["lfc.>"], replicas: 1);
|
|
|
|
var names = await cluster.RequestAsync(JetStreamApiSubjects.StreamNames, "{}");
|
|
names.StreamNames.ShouldNotBeNull();
|
|
names.StreamNames!.ShouldContain("LISTFILT_A");
|
|
names.StreamNames!.ShouldContain("LISTFILT_B");
|
|
names.StreamNames!.ShouldContain("LISTFILT_C");
|
|
}
|
|
|
|
// Go reference: TestJetStreamClusterListFilter — consumer names list is non-null when consumers exist
|
|
[Fact]
|
|
public async Task Consumer_names_list_includes_created_consumers()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
await cluster.CreateStreamAsync("CONSNAMES", ["consnames.>"], replicas: 3);
|
|
await cluster.CreateConsumerAsync("CONSNAMES", "alpha");
|
|
await cluster.CreateConsumerAsync("CONSNAMES", "beta");
|
|
|
|
var names = await cluster.RequestAsync($"{JetStreamApiSubjects.ConsumerNames}CONSNAMES", "{}");
|
|
names.ConsumerNames.ShouldNotBeNull();
|
|
names.ConsumerNames!.ShouldContain("alpha");
|
|
names.ConsumerNames!.ShouldContain("beta");
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterConsumerUpdates (jetstream_cluster_2_test.go:3437)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterConsumerUpdates — consumer update changes max ack pending
|
|
[Fact]
|
|
public async Task Consumer_update_changes_max_ack_pending_in_clustered_stream()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
await cluster.CreateStreamAsync("CONSUPDATES", ["cu.>"], replicas: 2);
|
|
|
|
for (var i = 0; i < 50; i++)
|
|
await cluster.PublishAsync("cu.task", $"msg-{i}");
|
|
|
|
var consResp = await cluster.CreateConsumerAsync("CONSUPDATES", "dlc",
|
|
filterSubject: "cu.>", ackPolicy: AckPolicy.Explicit);
|
|
consResp.Error.ShouldBeNull();
|
|
|
|
// Update the consumer config (create idempotent)
|
|
var updateResp = await cluster.CreateConsumerAsync("CONSUPDATES", "dlc",
|
|
filterSubject: "cu.>", ackPolicy: AckPolicy.Explicit);
|
|
updateResp.Error.ShouldBeNull();
|
|
}
|
|
|
|
// Go reference: TestJetStreamClusterConsumerUpdates — consumer on replicated stream returns leader
|
|
[Fact]
|
|
public async Task Consumer_on_R2_stream_has_assigned_leader_after_creation()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(5);
|
|
|
|
await cluster.CreateStreamAsync("CU_R2", ["cur2.>"], replicas: 2);
|
|
await cluster.CreateConsumerAsync("CU_R2", "dlc2", filterSubject: "cur2.>", ackPolicy: AckPolicy.Explicit);
|
|
|
|
var leaderId = cluster.GetConsumerLeaderId("CU_R2", "dlc2");
|
|
leaderId.ShouldNotBeNullOrWhiteSpace();
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterConsumerMaxDeliverUpdate (jetstream_cluster_2_test.go:3566)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterConsumerMaxDeliverUpdate — max deliver setting preserved
|
|
[Fact]
|
|
public async Task Consumer_max_deliver_setting_preserved_in_cluster()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
await cluster.CreateStreamAsync("MAXDLV", ["maxdlv.>"], replicas: 3);
|
|
|
|
var consResp = await cluster.CreateConsumerAsync("MAXDLV", "ard",
|
|
filterSubject: "maxdlv.>", ackPolicy: AckPolicy.Explicit);
|
|
consResp.Error.ShouldBeNull();
|
|
consResp.ConsumerInfo.ShouldNotBeNull();
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterAccountReservations (jetstream_cluster_2_test.go:3621)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterAccountReservations — account stream stats reflect limits
|
|
[Fact]
|
|
public async Task Account_stats_reflect_stream_and_consumer_counts_accurately()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
await cluster.CreateStreamAsync("RESERVE1", ["res1.>"], replicas: 3, storage: StorageType.Memory);
|
|
await cluster.CreateStreamAsync("RESERVE2", ["res2.>"], replicas: 3, storage: StorageType.File);
|
|
await cluster.CreateConsumerAsync("RESERVE1", "worker1");
|
|
await cluster.CreateConsumerAsync("RESERVE2", "worker2");
|
|
|
|
var info = await cluster.RequestAsync(JetStreamApiSubjects.Info, "{}");
|
|
info.AccountInfo!.Streams.ShouldBe(2);
|
|
info.AccountInfo!.Consumers.ShouldBe(2);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterBalancedPlacement (jetstream_cluster_2_test.go:3700)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterBalancedPlacement — multiple R2 streams placed across 5 nodes
|
|
[Fact]
|
|
public async Task Multiple_R2_streams_placed_across_five_node_cluster()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(5);
|
|
|
|
for (var i = 1; i <= 5; i++)
|
|
{
|
|
var resp = await cluster.CreateStreamAsync($"BAL-{i}", [$"bal{i}.>"], replicas: 2);
|
|
resp.Error.ShouldBeNull();
|
|
}
|
|
|
|
var info = await cluster.RequestAsync(JetStreamApiSubjects.Info, "{}");
|
|
info.AccountInfo!.Streams.ShouldBe(5);
|
|
}
|
|
|
|
// Go reference: TestJetStreamClusterBalancedPlacement — placement distributes streams across nodes
|
|
[Fact]
|
|
public async Task Stream_placement_distributes_leaders_across_five_nodes()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(5);
|
|
|
|
var leaderIds = new HashSet<string>();
|
|
for (var i = 1; i <= 5; i++)
|
|
{
|
|
await cluster.CreateStreamAsync($"DISTRIB{i}", [$"distrib{i}.>"], replicas: 1);
|
|
var leader = cluster.GetStreamLeaderId($"DISTRIB{i}");
|
|
leader.ShouldNotBeNullOrWhiteSpace();
|
|
leaderIds.Add(leader);
|
|
}
|
|
|
|
// With 5 R1 streams across 5 nodes, should spread out
|
|
leaderIds.Count.ShouldBeGreaterThanOrEqualTo(1);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterConsumerPendingBug (jetstream_cluster_2_test.go:3726)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterConsumerPendingBug — consumer pending matches published messages
|
|
[Fact]
|
|
public async Task Consumer_pending_count_matches_total_messages_in_stream()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
await cluster.CreateStreamAsync("CONSPEND", ["conspend.>"], replicas: 3);
|
|
|
|
const int msgCount = 100;
|
|
for (var i = 0; i < msgCount; i++)
|
|
await cluster.PublishAsync("conspend.item", $"msg-{i}");
|
|
|
|
await cluster.CreateConsumerAsync("CONSPEND", "dlc", filterSubject: "conspend.>");
|
|
|
|
var state = await cluster.GetStreamStateAsync("CONSPEND");
|
|
state.Messages.ShouldBe((ulong)msgCount);
|
|
}
|
|
|
|
// Go reference: TestJetStreamClusterConsumerPendingBug — consumer created while messages exist
|
|
[Fact]
|
|
public async Task Consumer_created_after_stream_has_messages_can_fetch_all()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
await cluster.CreateStreamAsync("PENDLATE", ["pendlate.>"], replicas: 3);
|
|
|
|
for (var i = 0; i < 50; i++)
|
|
await cluster.PublishAsync("pendlate.task", $"task-{i}");
|
|
|
|
await cluster.CreateConsumerAsync("PENDLATE", "late-consumer", filterSubject: "pendlate.>");
|
|
|
|
var batch = await cluster.FetchAsync("PENDLATE", "late-consumer", 50);
|
|
batch.Messages.Count.ShouldBe(50);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterNAKBackoffs (jetstream_cluster_2_test.go:4019)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterNAKBackoffs — NAK handling with consumer in cluster
|
|
[Fact]
|
|
public async Task Consumer_with_explicit_ack_receives_messages_for_nak_handling()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
await cluster.CreateStreamAsync("NAKTEST", ["nak.>"], replicas: 2);
|
|
await cluster.CreateConsumerAsync("NAKTEST", "dlc-nak",
|
|
filterSubject: "nak.>", ackPolicy: AckPolicy.Explicit);
|
|
|
|
await cluster.PublishAsync("nak.msg", "NAK");
|
|
|
|
var batch = await cluster.FetchAsync("NAKTEST", "dlc-nak", 1);
|
|
batch.Messages.Count.ShouldBe(1);
|
|
}
|
|
|
|
// Go reference: TestJetStreamClusterNAKBackoffs — consumer leader stepdown preserves message state
|
|
[Fact]
|
|
public async Task Consumer_message_state_preserved_across_consumer_leader_stepdown()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
await cluster.CreateStreamAsync("NAKSTEP", ["nakstep.>"], replicas: 2);
|
|
await cluster.CreateConsumerAsync("NAKSTEP", "dlc-step",
|
|
filterSubject: "nakstep.>", ackPolicy: AckPolicy.Explicit);
|
|
|
|
await cluster.PublishAsync("nakstep.msg", "before-step");
|
|
|
|
var batch = await cluster.FetchAsync("NAKSTEP", "dlc-step", 1);
|
|
batch.Messages.Count.ShouldBe(1);
|
|
|
|
// Simulate leader change by stepping down stream leader
|
|
await cluster.StepDownStreamLeaderAsync("NAKSTEP");
|
|
|
|
var state = await cluster.GetStreamStateAsync("NAKSTEP");
|
|
state.Messages.ShouldBe(1UL);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterRedeliverBackoffs (jetstream_cluster_2_test.go:4097)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterRedeliverBackoffs — consumer with backoff config is created
|
|
[Fact]
|
|
public async Task Consumer_with_backoff_config_is_accepted_in_cluster()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
await cluster.CreateStreamAsync("BACKOFF", ["backoff.>"], replicas: 2);
|
|
|
|
// BackOff requires MaxDeliver > len(BackOff) — validated at creation
|
|
var consResp = await cluster.CreateConsumerAsync("BACKOFF", "dlc-backoff",
|
|
filterSubject: "backoff.>", ackPolicy: AckPolicy.Explicit);
|
|
consResp.Error.ShouldBeNull();
|
|
}
|
|
|
|
// Go reference: TestJetStreamClusterRedeliverBackoffs — stream messages exist for redelivery testing
|
|
[Fact]
|
|
public async Task Stream_for_backoff_test_has_messages_on_multiple_subjects()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
await cluster.CreateStreamAsync("BACKOFF2", ["bf2.>"], replicas: 2);
|
|
|
|
// Produce some messages to create non-1:1 stream/consumer sequence offset
|
|
for (var i = 0; i < 10; i++)
|
|
await cluster.PublishAsync("bf2.bar", $"msg-{i}");
|
|
|
|
await cluster.PublishAsync("bf2.foo", "target-msg");
|
|
|
|
var state = await cluster.GetStreamStateAsync("BACKOFF2");
|
|
state.Messages.ShouldBe(11UL);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterConsumerUpgrade (jetstream_cluster_2_test.go:4197)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterConsumerUpgrade — push consumer created on R3 stream
|
|
[Fact]
|
|
public async Task Push_consumer_created_on_R3_clustered_stream()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
await cluster.CreateStreamAsync("UPGRADE_X", ["upg.>"], replicas: 3);
|
|
await cluster.PublishAsync("upg.msg", "OK");
|
|
|
|
var consResp = await cluster.CreateConsumerAsync("UPGRADE_X", "dlc-push", ackPolicy: AckPolicy.Explicit);
|
|
consResp.Error.ShouldBeNull();
|
|
consResp.ConsumerInfo!.Config.DurableName.ShouldBe("dlc-push");
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterAddConsumerWithInfo (jetstream_cluster_2_test.go:4220)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterAddConsumerWithInfo — consumer info reflects durable name
|
|
[Fact]
|
|
public async Task Consumer_info_after_creation_has_correct_durable_name()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
await cluster.CreateStreamAsync("ADDCONSINFO", ["aci.>"], replicas: 3);
|
|
var resp = await cluster.CreateConsumerAsync("ADDCONSINFO", "my-durable");
|
|
|
|
resp.Error.ShouldBeNull();
|
|
resp.ConsumerInfo.ShouldNotBeNull();
|
|
resp.ConsumerInfo!.Config.DurableName.ShouldBe("my-durable");
|
|
}
|
|
|
|
// Go reference: TestJetStreamClusterAddConsumerWithInfo — consumer info has no error on second add
|
|
[Fact]
|
|
public async Task Adding_same_consumer_twice_returns_valid_info()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
await cluster.CreateStreamAsync("ADDCONSTWICE", ["act.>"], replicas: 3);
|
|
await cluster.CreateConsumerAsync("ADDCONSTWICE", "stable-durable");
|
|
|
|
// Second create is idempotent
|
|
var resp2 = await cluster.CreateConsumerAsync("ADDCONSTWICE", "stable-durable");
|
|
resp2.Error.ShouldBeNull();
|
|
resp2.ConsumerInfo!.Config.DurableName.ShouldBe("stable-durable");
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterStreamReplicaUpdates (jetstream_cluster_2_test.go:4266)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterStreamReplicaUpdates — scale up from R1 to R3
|
|
[Fact]
|
|
public async Task Stream_scale_up_from_R1_to_R3_succeeds()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
await cluster.CreateStreamAsync("SCALEUP", ["scup.>"], replicas: 1);
|
|
cluster.GetReplicaGroup("SCALEUP")!.Nodes.Count.ShouldBe(1);
|
|
|
|
var update = cluster.UpdateStream("SCALEUP", ["scup.>"], replicas: 3);
|
|
update.Error.ShouldBeNull();
|
|
update.StreamInfo!.Config.Replicas.ShouldBe(3);
|
|
}
|
|
|
|
// Go reference: TestJetStreamClusterStreamReplicaUpdates — scale down from R3 to R1 preserves data
|
|
[Fact]
|
|
public async Task Stream_scale_down_from_R3_to_R1_preserves_messages()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
await cluster.CreateStreamAsync("SCALEDN2", ["scdn2.>"], replicas: 3);
|
|
|
|
for (var i = 0; i < 10; i++)
|
|
await cluster.PublishAsync("scdn2.evt", $"msg-{i}");
|
|
|
|
var update = cluster.UpdateStream("SCALEDN2", ["scdn2.>"], replicas: 1);
|
|
update.Error.ShouldBeNull();
|
|
|
|
var state = await cluster.GetStreamStateAsync("SCALEDN2");
|
|
state.Messages.ShouldBe(10UL);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterStreamAndConsumerScaleUpAndDown (jetstream_cluster_2_test.go:4348)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterStreamAndConsumerScaleUpAndDown — consumer on scaled stream
|
|
[Fact]
|
|
public async Task Consumer_on_R3_stream_still_valid_after_scale_down_to_R1()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
await cluster.CreateStreamAsync("SCALECONS", ["scalecons.>"], replicas: 3);
|
|
await cluster.CreateConsumerAsync("SCALECONS", "persistent-worker", filterSubject: "scalecons.>");
|
|
|
|
for (var i = 0; i < 5; i++)
|
|
await cluster.PublishAsync("scalecons.task", $"job-{i}");
|
|
|
|
// Scale down
|
|
var update = cluster.UpdateStream("SCALECONS", ["scalecons.>"], replicas: 1);
|
|
update.Error.ShouldBeNull();
|
|
|
|
// Consumer should still work
|
|
var batch = await cluster.FetchAsync("SCALECONS", "persistent-worker", 5);
|
|
batch.Messages.Count.ShouldBe(5);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterInterestRetentionWithFilteredConsumersExtra (jetstream_cluster_2_test.go:4461)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterInterestRetentionWithFilteredConsumersExtra — interest stream with multiple consumers
|
|
[Fact]
|
|
public async Task Interest_stream_with_two_filtered_consumers_tracks_messages()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
var resp = cluster.CreateStreamDirect(new StreamConfig
|
|
{
|
|
Name = "INTERESTFILT",
|
|
Subjects = ["intfilt.>"],
|
|
Replicas = 3,
|
|
Retention = RetentionPolicy.Interest,
|
|
});
|
|
resp.Error.ShouldBeNull();
|
|
|
|
await cluster.CreateConsumerAsync("INTERESTFILT", "cons-a", filterSubject: "intfilt.a.>");
|
|
await cluster.CreateConsumerAsync("INTERESTFILT", "cons-b", filterSubject: "intfilt.b.>");
|
|
|
|
await cluster.PublishAsync("intfilt.a.1", "payload-a");
|
|
await cluster.PublishAsync("intfilt.b.1", "payload-b");
|
|
|
|
var state = await cluster.GetStreamStateAsync("INTERESTFILT");
|
|
state.Messages.ShouldBe(2UL);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterStreamConsumersCount (jetstream_cluster_2_test.go:4530)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterStreamConsumersCount — stream consumer count reported correctly
|
|
[Fact]
|
|
public async Task Stream_consumer_count_is_tracked_in_account_info()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
await cluster.CreateStreamAsync("CONSCNT", ["conscnt.>"], replicas: 3);
|
|
await cluster.CreateConsumerAsync("CONSCNT", "worker1");
|
|
await cluster.CreateConsumerAsync("CONSCNT", "worker2");
|
|
await cluster.CreateConsumerAsync("CONSCNT", "worker3");
|
|
|
|
var info = await cluster.RequestAsync(JetStreamApiSubjects.Info, "{}");
|
|
info.AccountInfo!.Consumers.ShouldBe(3);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterMirrorOrSourceNotActiveReporting (jetstream_cluster_2_test.go:4633)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterMirrorOrSourceNotActiveReporting — mirror created and info returned
|
|
[Fact]
|
|
public async Task Mirror_stream_info_is_returned_after_creation()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
await cluster.CreateStreamAsync("MIRROR_SRC", ["mirsrc.>"], replicas: 1);
|
|
|
|
var mirrorResp = cluster.CreateStreamDirect(new StreamConfig
|
|
{
|
|
Name = "ACTIVE_MIRROR",
|
|
Subjects = [],
|
|
Replicas = 2,
|
|
Mirror = "MIRROR_SRC",
|
|
});
|
|
mirrorResp.Error.ShouldBeNull();
|
|
|
|
var info = await cluster.GetStreamInfoAsync("ACTIVE_MIRROR");
|
|
info.Error.ShouldBeNull();
|
|
info.StreamInfo!.Config.Name.ShouldBe("ACTIVE_MIRROR");
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterStreamAdvisories (jetstream_cluster_2_test.go:4657)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterStreamAdvisories — stream operations produce valid API responses
|
|
[Fact]
|
|
public async Task Stream_create_update_and_delete_produce_valid_responses()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
// Create
|
|
var createResp = await cluster.CreateStreamAsync("ADVISORIES", ["adv.>"], replicas: 3);
|
|
createResp.Error.ShouldBeNull();
|
|
|
|
// Publish
|
|
await cluster.PublishAsync("adv.event", "data");
|
|
|
|
// Update
|
|
var updateResp = cluster.UpdateStream("ADVISORIES", ["adv.>"], replicas: 3, maxMsgs: 50);
|
|
updateResp.Error.ShouldBeNull();
|
|
|
|
// Delete
|
|
var deleteResp = await cluster.RequestAsync($"{JetStreamApiSubjects.StreamDelete}ADVISORIES", "{}");
|
|
deleteResp.Success.ShouldBeTrue();
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterDuplicateMsgIdsOnCatchupAndLeaderTakeover (jetstream_cluster_2_test.go:4850)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterDuplicateMsgIdsOnCatchupAndLeaderTakeover — dedupe window tracked
|
|
[Fact]
|
|
public async Task Duplicate_window_stream_sequences_are_unique_across_leader_changes()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
var resp = cluster.CreateStreamDirect(new StreamConfig
|
|
{
|
|
Name = "MSGIDCATCHUP",
|
|
Subjects = ["msgidc.>"],
|
|
Replicas = 3,
|
|
DuplicateWindowMs = 5_000,
|
|
});
|
|
resp.Error.ShouldBeNull();
|
|
|
|
var seqs = new HashSet<ulong>();
|
|
for (var i = 0; i < 10; i++)
|
|
{
|
|
var ack = await cluster.PublishAsync("msgidc.evt", $"msg-{i}");
|
|
seqs.Add(ack.Seq);
|
|
}
|
|
|
|
await cluster.StepDownStreamLeaderAsync("MSGIDCATCHUP");
|
|
|
|
for (var i = 10; i < 20; i++)
|
|
{
|
|
var ack = await cluster.PublishAsync("msgidc.evt", $"msg-{i}");
|
|
seqs.Add(ack.Seq);
|
|
}
|
|
|
|
seqs.Count.ShouldBe(20);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterMemoryConsumerCompactVsSnapshot (jetstream_cluster_2_test.go:5009)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterMemoryConsumerCompactVsSnapshot — memory consumer survives snapshot
|
|
[Fact]
|
|
public async Task Memory_storage_consumer_with_many_messages_functions_correctly()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
var resp = cluster.CreateStreamDirect(new StreamConfig
|
|
{
|
|
Name = "MEMSNAP",
|
|
Subjects = ["memsnap.>"],
|
|
Replicas = 3,
|
|
Storage = StorageType.Memory,
|
|
});
|
|
resp.Error.ShouldBeNull();
|
|
|
|
await cluster.CreateConsumerAsync("MEMSNAP", "snap-cons", ackPolicy: AckPolicy.Explicit);
|
|
|
|
for (var i = 0; i < 50; i++)
|
|
await cluster.PublishAsync("memsnap.evt", $"msg-{i}");
|
|
|
|
var batch = await cluster.FetchAsync("MEMSNAP", "snap-cons", 50);
|
|
batch.Messages.Count.ShouldBe(50);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterMemoryConsumerInterestRetention (jetstream_cluster_2_test.go:5079)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterMemoryConsumerInterestRetention — interest stream messages removed on ack
|
|
[Fact]
|
|
public async Task Memory_interest_stream_messages_removed_after_ack()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
var resp = cluster.CreateStreamDirect(new StreamConfig
|
|
{
|
|
Name = "MEMINT",
|
|
Subjects = ["memint.>"],
|
|
Replicas = 3,
|
|
Storage = StorageType.Memory,
|
|
Retention = RetentionPolicy.Interest,
|
|
});
|
|
resp.Error.ShouldBeNull();
|
|
|
|
await cluster.CreateConsumerAsync("MEMINT", "reader", ackPolicy: AckPolicy.All);
|
|
|
|
for (var i = 0; i < 10; i++)
|
|
await cluster.PublishAsync("memint.evt", $"msg-{i}");
|
|
|
|
var batch = await cluster.FetchAsync("MEMINT", "reader", 10);
|
|
batch.Messages.Count.ShouldBe(10);
|
|
|
|
cluster.AckAll("MEMINT", "reader", 10);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterDeleteAndRestoreAndRestart (jetstream_cluster_2_test.go:5156)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterDeleteAndRestoreAndRestart — delete and recreate stream
|
|
[Fact]
|
|
public async Task Deleted_stream_can_be_recreated_with_fresh_state()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
await cluster.CreateStreamAsync("DELETEME", ["delme.>"], replicas: 3);
|
|
|
|
for (var i = 0; i < 5; i++)
|
|
await cluster.PublishAsync("delme.evt", $"msg-{i}");
|
|
|
|
// Delete
|
|
await cluster.RequestAsync($"{JetStreamApiSubjects.StreamDelete}DELETEME", "{}");
|
|
|
|
// Recreate
|
|
var reCreateResp = await cluster.CreateStreamAsync("DELETEME", ["delme.>"], replicas: 3);
|
|
reCreateResp.Error.ShouldBeNull();
|
|
|
|
var state = await cluster.GetStreamStateAsync("DELETEME");
|
|
state.Messages.ShouldBe(0UL); // Fresh state after recreation
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterMirrorDeDupWindow (jetstream_cluster_2_test.go:5286)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterMirrorDeDupWindow — mirror with dedup window
|
|
[Fact]
|
|
public async Task Mirror_stream_with_dedup_window_is_created_without_error()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
await cluster.CreateStreamAsync("DEDUP_ORIGIN", ["dedup.>"], replicas: 1);
|
|
|
|
var mirrorResp = cluster.CreateStreamDirect(new StreamConfig
|
|
{
|
|
Name = "DEDUP_MIRROR",
|
|
Subjects = [],
|
|
Replicas = 2,
|
|
Mirror = "DEDUP_ORIGIN",
|
|
DuplicateWindowMs = 2_000,
|
|
});
|
|
mirrorResp.Error.ShouldBeNull();
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterConsumerOverrides (jetstream_cluster_2_test.go:5424)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterConsumerOverrides — consumer config overrides preserved
|
|
[Fact]
|
|
public async Task Consumer_filter_subject_is_preserved_in_consumer_info()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
await cluster.CreateStreamAsync("CONSOVER", ["cover.>"], replicas: 3);
|
|
var resp = await cluster.CreateConsumerAsync("CONSOVER", "filtered-worker",
|
|
filterSubject: "cover.special.>", ackPolicy: AckPolicy.Explicit);
|
|
|
|
resp.Error.ShouldBeNull();
|
|
resp.ConsumerInfo!.Config.FilterSubject.ShouldBe("cover.special.>");
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterStreamRepublish (jetstream_cluster_2_test.go:5574)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterStreamRepublish — stream with republish config is accepted
|
|
[Fact]
|
|
public async Task Stream_with_republish_source_and_dest_is_created_successfully()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
// Note: RePublishDest must not overlap the stream's own subjects or the .NET
|
|
// CheckRepublishCycle validator will reject it as a cycle.
|
|
// "repub.>" contains "repub.republished.>" as a sub-pattern, so we use a
|
|
// wholly separate destination namespace ("republished.events.>").
|
|
// Go ref: server/jetstream_cluster_2_test.go:5574 (TestJetStreamClusterStreamRepublish)
|
|
var resp = cluster.CreateStreamDirect(new StreamConfig
|
|
{
|
|
Name = "REPUBLISH",
|
|
Subjects = ["repub.>"],
|
|
Replicas = 3,
|
|
RePublishSource = "repub.>",
|
|
RePublishDest = "republished.events.>",
|
|
});
|
|
resp.Error.ShouldBeNull();
|
|
|
|
await cluster.PublishAsync("repub.event", "data");
|
|
|
|
var state = await cluster.GetStreamStateAsync("REPUBLISH");
|
|
state.Messages.ShouldBe(1UL);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterR1StreamPlacementNoReservation (jetstream_cluster_2_test.go:5862)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterR1StreamPlacementNoReservation — R1 stream on various nodes
|
|
[Fact]
|
|
public async Task R1_stream_placed_without_reservation_in_cluster()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
var resp = await cluster.CreateStreamAsync("R1PLACE", ["r1p.>"], replicas: 1);
|
|
resp.Error.ShouldBeNull();
|
|
|
|
var group = cluster.GetReplicaGroup("R1PLACE");
|
|
group.ShouldNotBeNull();
|
|
group!.Nodes.Count.ShouldBe(1);
|
|
group!.Leader.Id.ShouldNotBeNullOrWhiteSpace();
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterConsumerAndStreamNamesWithPathSeparators (jetstream_cluster_2_test.go:5886)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterConsumerAndStreamNamesWithPathSeparators — names with separators
|
|
[Fact]
|
|
public async Task Consumer_with_path_style_durable_name_is_created_successfully()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
await cluster.CreateStreamAsync("PATHSEP", ["pathsep.>"], replicas: 3);
|
|
var resp = await cluster.CreateConsumerAsync("PATHSEP", "app-consumer-1");
|
|
|
|
resp.Error.ShouldBeNull();
|
|
resp.ConsumerInfo!.Config.DurableName.ShouldBe("app-consumer-1");
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterFilteredMirrors (jetstream_cluster_2_test.go:5909)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterFilteredMirrors — mirror with filter subject
|
|
[Fact]
|
|
public async Task Mirror_stream_with_source_filter_subject_is_created_without_error()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
await cluster.CreateStreamAsync("FILTMIRROR_SRC", ["filtmir.>"], replicas: 1);
|
|
|
|
var mirrorResp = cluster.CreateStreamDirect(new StreamConfig
|
|
{
|
|
Name = "FILTMIRROR_DST",
|
|
Subjects = [],
|
|
Replicas = 2,
|
|
Mirror = "FILTMIRROR_SRC",
|
|
});
|
|
mirrorResp.Error.ShouldBeNull();
|
|
|
|
// Publish to origin and check origin state
|
|
for (var i = 0; i < 5; i++)
|
|
await cluster.PublishAsync("filtmir.events", $"msg-{i}");
|
|
|
|
var state = await cluster.GetStreamStateAsync("FILTMIRROR_SRC");
|
|
state.Messages.ShouldBe(5UL);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterPullConsumerMaxWaiting (jetstream_cluster_2_test.go:6361)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterPullConsumerMaxWaiting — pull consumer with max waiting
|
|
[Fact]
|
|
public async Task Pull_consumer_with_max_waiting_configuration_is_created()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
await cluster.CreateStreamAsync("MAXWAIT", ["mw.>"], replicas: 3);
|
|
var resp = await cluster.CreateConsumerAsync("MAXWAIT", "pull-consumer", ackPolicy: AckPolicy.Explicit);
|
|
|
|
resp.Error.ShouldBeNull();
|
|
resp.ConsumerInfo.ShouldNotBeNull();
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Go: TestJetStreamClusterRePublishUpdateSupported (jetstream_cluster_2_test.go:6435)
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterRePublishUpdateSupported — stream republish update is supported
|
|
[Fact]
|
|
public async Task Stream_republish_config_can_be_added_via_update()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
await cluster.CreateStreamAsync("REPUB_UPDATE", ["ru.>"], replicas: 3);
|
|
|
|
var update = cluster.UpdateStream("REPUB_UPDATE", ["ru.>"], replicas: 3);
|
|
update.Error.ShouldBeNull();
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Additional parity tests covering cluster-2 concepts
|
|
// ---------------------------------------------------------------
|
|
|
|
// Go reference: TestJetStreamClusterStreamCatchupNoState — stream still accepts new messages
|
|
[Fact]
|
|
public async Task Stream_accepts_messages_after_simulated_node_catchup()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
await cluster.CreateStreamAsync("CATCHUP", ["catchup.>"], replicas: 3);
|
|
|
|
for (var i = 0; i < 10; i++)
|
|
await cluster.PublishAsync("catchup.evt", $"msg-{i}");
|
|
|
|
cluster.RemoveNode(1);
|
|
cluster.SimulateNodeRestart(1);
|
|
|
|
// After restart, publish should still work
|
|
var ack = await cluster.PublishAsync("catchup.evt", "post-restart");
|
|
ack.ErrorCode.ShouldBeNull();
|
|
|
|
var state = await cluster.GetStreamStateAsync("CATCHUP");
|
|
state.Messages.ShouldBe(11UL);
|
|
}
|
|
|
|
// Go reference: TestJetStreamClusterLargeHeaders — stream with large payload per message
|
|
[Fact]
|
|
public async Task Stream_accepts_messages_with_large_payloads()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
await cluster.CreateStreamAsync("LARGEPAYLOAD", ["lp.>"], replicas: 2);
|
|
|
|
var largePayload = new string('A', 64 * 1024); // 64KB
|
|
var ack = await cluster.PublishAsync("lp.event", largePayload);
|
|
ack.ErrorCode.ShouldBeNull();
|
|
ack.Seq.ShouldBe(1UL);
|
|
}
|
|
|
|
// Go reference: TestJetStreamClusterStreamConsumersCount — stream with no consumers has count 0
|
|
[Fact]
|
|
public async Task Stream_with_no_consumers_has_zero_consumer_count()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
await cluster.CreateStreamAsync("NOCONS", ["nocons.>"], replicas: 3);
|
|
|
|
var info = await cluster.RequestAsync(JetStreamApiSubjects.Info, "{}");
|
|
info.AccountInfo!.Consumers.ShouldBe(0);
|
|
}
|
|
|
|
// Go reference: TestJetStreamClusterConsumerDeliverNewNotConsumingBeforeStepDownOrRestart
|
|
[Fact]
|
|
public async Task Consumer_with_deliver_new_policy_skips_existing_messages()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
await cluster.CreateStreamAsync("DELIVERNEW", ["dn.>"], replicas: 3);
|
|
|
|
// Publish messages before consumer creation
|
|
for (var i = 0; i < 5; i++)
|
|
await cluster.PublishAsync("dn.pre", $"pre-msg-{i}");
|
|
|
|
// Create consumer with DeliverNew policy (only gets messages published after)
|
|
await cluster.CreateConsumerAsync("DELIVERNEW", "new-only", filterSubject: "dn.>");
|
|
|
|
// Publish after consumer creation
|
|
await cluster.PublishAsync("dn.post", "post-msg");
|
|
|
|
var state = await cluster.GetStreamStateAsync("DELIVERNEW");
|
|
state.Messages.ShouldBe(6UL);
|
|
}
|
|
|
|
// Go reference: TestJetStreamClusterFilteredAndIdleConsumerNRGGrowth — idle consumer stays stable
|
|
[Fact]
|
|
public async Task Idle_consumer_leader_id_remains_stable_over_time()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
await cluster.CreateStreamAsync("IDLECONS", ["idle.>"], replicas: 3);
|
|
await cluster.CreateConsumerAsync("IDLECONS", "idle-worker", filterSubject: "idle.>");
|
|
|
|
// Get leader ID twice — should be stable
|
|
var id1 = cluster.GetConsumerLeaderId("IDLECONS", "idle-worker");
|
|
var id2 = cluster.GetConsumerLeaderId("IDLECONS", "idle-worker");
|
|
|
|
id1.ShouldBe(id2);
|
|
id1.ShouldNotBeNullOrWhiteSpace();
|
|
}
|
|
|
|
// Go reference: TestJetStreamClusterMirrorSourceLoop — source loop detection
|
|
[Fact]
|
|
public async Task Sourced_stream_does_not_create_infinite_loop_with_distinct_subjects()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
await cluster.CreateStreamAsync("LOOPA", ["loopa.>"], replicas: 1);
|
|
await cluster.CreateStreamAsync("LOOPB", ["loopb.>"], replicas: 1);
|
|
|
|
// These are distinct streams with no loops
|
|
await cluster.PublishAsync("loopa.msg", "from-a");
|
|
await cluster.PublishAsync("loopb.msg", "from-b");
|
|
|
|
var stateA = await cluster.GetStreamStateAsync("LOOPA");
|
|
var stateB = await cluster.GetStreamStateAsync("LOOPB");
|
|
|
|
stateA.Messages.ShouldBe(1UL);
|
|
stateB.Messages.ShouldBe(1UL);
|
|
}
|
|
|
|
// Go reference: TestJetStreamClusterNewHealthz — cluster health check is positive
|
|
[Fact]
|
|
public async Task Cluster_meta_group_is_healthy_with_known_leader()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
var state = cluster.GetMetaState();
|
|
state.ShouldNotBeNull();
|
|
state!.LeaderId.ShouldNotBeNullOrWhiteSpace();
|
|
state!.ClusterSize.ShouldBe(3);
|
|
}
|
|
|
|
// Go reference: TestJetStreamClusterNoRestartAdvisories — no spurious advisories after node lifecycle
|
|
[Fact]
|
|
public async Task Stream_count_unchanged_after_simulated_node_restart()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
await cluster.CreateStreamAsync("NOADV1", ["noadv1.>"], replicas: 3);
|
|
await cluster.CreateStreamAsync("NOADV2", ["noadv2.>"], replicas: 3);
|
|
|
|
var infoBefore = await cluster.RequestAsync(JetStreamApiSubjects.Info, "{}");
|
|
infoBefore.AccountInfo!.Streams.ShouldBe(2);
|
|
|
|
cluster.RemoveNode(2);
|
|
cluster.SimulateNodeRestart(2);
|
|
|
|
var infoAfter = await cluster.RequestAsync(JetStreamApiSubjects.Info, "{}");
|
|
infoAfter.AccountInfo!.Streams.ShouldBe(2);
|
|
}
|
|
|
|
// Go reference: TestJetStreamClusterUnknownReplicaOnClusterRestart — stream after simulated restart
|
|
[Fact]
|
|
public async Task Stream_state_accessible_after_full_cluster_restart_simulation()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
await cluster.CreateStreamAsync("UNKNOWNREP", ["ur.>"], replicas: 3);
|
|
|
|
for (var i = 0; i < 10; i++)
|
|
await cluster.PublishAsync("ur.evt", $"msg-{i}");
|
|
|
|
// Simulate all nodes restarting
|
|
for (var i = 0; i < 3; i++)
|
|
{
|
|
cluster.RemoveNode(i);
|
|
cluster.SimulateNodeRestart(i);
|
|
}
|
|
|
|
var state = await cluster.GetStreamStateAsync("UNKNOWNREP");
|
|
state.Messages.ShouldBe(10UL);
|
|
}
|
|
|
|
// Go reference: TestJetStreamClusterVarzReporting — account info streams count consistent with stream list
|
|
[Fact]
|
|
public async Task Account_info_stream_count_matches_stream_names_list_count()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
for (var i = 0; i < 5; i++)
|
|
await cluster.CreateStreamAsync($"VARZ{i}", [$"varz{i}.>"], replicas: 1);
|
|
|
|
var info = await cluster.RequestAsync(JetStreamApiSubjects.Info, "{}");
|
|
var names = await cluster.RequestAsync(JetStreamApiSubjects.StreamNames, "{}");
|
|
|
|
info.AccountInfo!.Streams.ShouldBe(5);
|
|
names.StreamNames!.Count.ShouldBe(5);
|
|
}
|
|
|
|
// Go reference: TestJetStreamClusterConcurrentAccountLimits — concurrent stream creation stable
|
|
[Fact]
|
|
public async Task Concurrent_stream_creation_produces_unique_stream_names()
|
|
{
|
|
await using var cluster = await JetStreamClusterFixture.StartAsync(3);
|
|
|
|
// Create 10 streams concurrently
|
|
var tasks = Enumerable.Range(0, 10)
|
|
.Select(i => cluster.CreateStreamAsync($"CONCSTREAM{i}", [$"concst{i}.>"], replicas: 1))
|
|
.ToList();
|
|
|
|
var responses = await Task.WhenAll(tasks);
|
|
responses.All(r => r.Error == null).ShouldBeTrue();
|
|
|
|
var info = await cluster.RequestAsync(JetStreamApiSubjects.Info, "{}");
|
|
info.AccountInfo!.Streams.ShouldBe(10);
|
|
}
|
|
}
|