refactor: extract NATS.Server.JetStream.Tests project
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.
This commit is contained in:
@@ -0,0 +1,820 @@
|
||||
// Ported from golang/nats-server/server/jetstream_test.go
|
||||
// Delivery, ack, redelivery, interest retention, KV, multi-account, flow control,
|
||||
// and per-subject delivery edge cases.
|
||||
|
||||
using System.Text;
|
||||
using NATS.Server.JetStream;
|
||||
using NATS.Server.JetStream.Consumers;
|
||||
using NATS.Server.JetStream.Models;
|
||||
using NATS.Server.TestUtilities;
|
||||
|
||||
namespace NATS.Server.JetStream.Tests.JetStream;
|
||||
|
||||
public class JsDeliveryAckTests
|
||||
{
|
||||
// Go: TestJetStreamRedeliverAndLateAck server/jetstream_test.go
|
||||
// A message fetched with AckExplicit but never acknowledged within ackWait is
|
||||
// redelivered on the next fetch. After the redelivery the consumer marks it
|
||||
// as redelivered.
|
||||
[Fact]
|
||||
public async Task Redelivery_after_ack_wait_expiry_marks_message_redelivered()
|
||||
{
|
||||
await using var fx = await JetStreamApiFixture.StartWithAckExplicitConsumerAsync(1); // 1 ms ack wait
|
||||
|
||||
_ = await fx.PublishAndGetAckAsync("orders.created", "msg1");
|
||||
|
||||
// First fetch — registers pending
|
||||
var batch1 = await fx.FetchAsync("ORDERS", "PULL", 1);
|
||||
batch1.Messages.Count.ShouldBe(1);
|
||||
|
||||
// Wait for ack wait to expire
|
||||
await Task.Delay(20);
|
||||
|
||||
// Second fetch returns redelivery
|
||||
var batch2 = await fx.FetchAsync("ORDERS", "PULL", 1);
|
||||
batch2.Messages.Count.ShouldBe(1);
|
||||
batch2.Messages[0].Redelivered.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Go: TestJetStreamCanNotNakAckd server/jetstream_test.go
|
||||
// After a sequence has been ACK'd, a NAK on the same sequence must be a no-op;
|
||||
// the AckProcessor must not schedule redelivery for already-terminated messages.
|
||||
[Fact]
|
||||
public void Nak_after_ack_is_ignored_by_ack_processor()
|
||||
{
|
||||
var ack = new AckProcessor();
|
||||
ack.Register(1, ackWaitMs: 30_000);
|
||||
|
||||
// Acknowledge sequence 1
|
||||
ack.AckSequence(1);
|
||||
ack.HasPending.ShouldBeFalse();
|
||||
|
||||
// Attempt NAK after ACK — must be no-op
|
||||
ack.ProcessNak(1);
|
||||
ack.HasPending.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// Go: TestJetStreamNakRedeliveryWithNoWait server/jetstream_test.go
|
||||
// NAK with a custom delay schedules redelivery after the specified delay, not the
|
||||
// default ackWait. Verified by checking that the sequence is still pending
|
||||
// (not expired yet) immediately after the NAK.
|
||||
[Fact]
|
||||
public void Nak_with_explicit_delay_schedules_redelivery()
|
||||
{
|
||||
var ack = new AckProcessor();
|
||||
ack.Register(1, ackWaitMs: 30_000);
|
||||
|
||||
// NAK with a very short delay (10 ms)
|
||||
ack.ProcessAck(1, "-NAK 10"u8);
|
||||
|
||||
// Immediately after, the sequence should still be pending (the 10 ms hasn't elapsed)
|
||||
ack.HasPending.ShouldBeTrue();
|
||||
ack.TryGetExpired(out _, out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// Go: TestJetStreamPushConsumerIdleHeartbeatsWithNoInterest server/jetstream_test.go
|
||||
// A push consumer configured with heartbeats emits heartbeat frames even when
|
||||
// no data message has been delivered since the last heartbeat window.
|
||||
[Fact]
|
||||
public async Task Push_consumer_heartbeat_frame_emitted_when_idle()
|
||||
{
|
||||
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("IDLE", "idle.>");
|
||||
_ = await fx.CreateConsumerAsync("IDLE", "PUSH", "idle.>", push: true, heartbeatMs: 10);
|
||||
|
||||
// Publish one message so the engine bootstraps the push consumer
|
||||
_ = await fx.PublishAndGetAckAsync("idle.x", "data");
|
||||
|
||||
var dataFrame = await fx.ReadPushFrameAsync("IDLE", "PUSH");
|
||||
dataFrame.IsData.ShouldBeTrue();
|
||||
|
||||
// Heartbeat should follow
|
||||
var hbFrame = await fx.ReadPushFrameAsync("IDLE", "PUSH");
|
||||
hbFrame.IsHeartbeat.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Go: TestJetStreamPendingNextTimer server/jetstream_test.go
|
||||
// Pending count immediately after a fetch with AckExplicit equals the batch size.
|
||||
[Fact]
|
||||
public async Task Pending_count_equals_fetched_batch_size()
|
||||
{
|
||||
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("PNXT", "pnxt.>");
|
||||
_ = await fx.CreateConsumerAsync("PNXT", "C1", "pnxt.>",
|
||||
ackPolicy: AckPolicy.Explicit, ackWaitMs: 30_000);
|
||||
|
||||
for (var i = 0; i < 4; i++)
|
||||
_ = await fx.PublishAndGetAckAsync("pnxt.x", $"m{i}");
|
||||
|
||||
var batch = await fx.FetchAsync("PNXT", "C1", 4);
|
||||
batch.Messages.Count.ShouldBe(4);
|
||||
|
||||
var pending = await fx.GetPendingCountAsync("PNXT", "C1");
|
||||
pending.ShouldBe(4);
|
||||
}
|
||||
|
||||
// Go: TestJetStreamInterestRetentionWithWildcardsAndFilteredConsumers
|
||||
// server/jetstream_test.go
|
||||
// Interest retention stream: messages matching a wildcard filter consumer are
|
||||
// visible to that consumer; unmatched subjects produce zero results.
|
||||
[Fact]
|
||||
public async Task Interest_retention_with_wildcard_filter_delivers_matching_messages()
|
||||
{
|
||||
await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig
|
||||
{
|
||||
Name = "INTW",
|
||||
Subjects = ["intw.>"],
|
||||
Retention = RetentionPolicy.Interest,
|
||||
});
|
||||
_ = await fx.CreateConsumerAsync("INTW", "C1", "intw.orders.*");
|
||||
|
||||
_ = await fx.PublishAndGetAckAsync("intw.orders.created", "a");
|
||||
_ = await fx.PublishAndGetAckAsync("intw.events.logged", "b");
|
||||
_ = await fx.PublishAndGetAckAsync("intw.orders.shipped", "c");
|
||||
|
||||
var batch = await fx.FetchAsync("INTW", "C1", 10);
|
||||
batch.Messages.Count.ShouldBe(2);
|
||||
batch.Messages.All(m => m.Subject.StartsWith("intw.orders.")).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Go: TestJetStreamInterestRetentionStreamWithDurableRestart
|
||||
// server/jetstream_test.go
|
||||
// After recreating a durable consumer on an interest-retention stream, the new
|
||||
// consumer can still deliver messages published before its recreation.
|
||||
[Fact]
|
||||
public async Task Interest_retention_durable_restart_delivers_messages()
|
||||
{
|
||||
await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig
|
||||
{
|
||||
Name = "INTR",
|
||||
Subjects = ["intr.>"],
|
||||
Retention = RetentionPolicy.Interest,
|
||||
});
|
||||
|
||||
// Create, publish, then delete consumer
|
||||
_ = await fx.CreateConsumerAsync("INTR", "DUR", "intr.>");
|
||||
_ = await fx.PublishAndGetAckAsync("intr.x", "hello");
|
||||
|
||||
_ = await fx.RequestLocalAsync("$JS.API.CONSUMER.DELETE.INTR.DUR", "{}");
|
||||
|
||||
// Recreate consumer — should see messages from sequence 1
|
||||
_ = await fx.CreateConsumerAsync("INTR", "DUR2", "intr.>");
|
||||
|
||||
var batch = await fx.FetchAsync("INTR", "DUR2", 10);
|
||||
batch.Messages.Count.ShouldBeGreaterThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
// Go: TestJetStreamInterestStreamConsumerFilterEdit server/jetstream_test.go
|
||||
// Updating a consumer on an interest-retention stream (via CREATE/UPDATE API)
|
||||
// retains the updated filter subject and doesn't break subsequent fetches.
|
||||
[Fact]
|
||||
public async Task Interest_stream_consumer_filter_can_be_updated()
|
||||
{
|
||||
await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig
|
||||
{
|
||||
Name = "INTEDIT",
|
||||
Subjects = ["intedit.>"],
|
||||
Retention = RetentionPolicy.Interest,
|
||||
});
|
||||
|
||||
_ = await fx.CreateConsumerAsync("INTEDIT", "C1", "intedit.a");
|
||||
|
||||
// Update the consumer's filter subject
|
||||
_ = await fx.CreateConsumerAsync("INTEDIT", "C1", "intedit.b");
|
||||
|
||||
_ = await fx.PublishAndGetAckAsync("intedit.a", "not-matched");
|
||||
_ = await fx.PublishAndGetAckAsync("intedit.b", "matched");
|
||||
|
||||
var batch = await fx.FetchAsync("INTEDIT", "C1", 10);
|
||||
// After update, C1 has filter intedit.b — only the second message matches
|
||||
batch.Messages.Count.ShouldBeGreaterThanOrEqualTo(1);
|
||||
batch.Messages.Any(m => m.Subject == "intedit.b").ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Go: TestJetStreamInterestStreamWithFilterSubjectsConsumer
|
||||
// server/jetstream_test.go
|
||||
// A consumer with multiple filter subjects on an interest-retention stream receives
|
||||
// only the subjects it declared interest in.
|
||||
[Fact]
|
||||
public async Task Interest_stream_multi_filter_consumer_receives_only_matched_subjects()
|
||||
{
|
||||
await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig
|
||||
{
|
||||
Name = "INTMF",
|
||||
Subjects = ["intmf.>"],
|
||||
Retention = RetentionPolicy.Interest,
|
||||
});
|
||||
_ = await fx.CreateConsumerAsync("INTMF", "C1", null,
|
||||
filterSubjects: ["intmf.a", "intmf.b"]);
|
||||
|
||||
_ = await fx.PublishAndGetAckAsync("intmf.a", "1");
|
||||
_ = await fx.PublishAndGetAckAsync("intmf.b", "2");
|
||||
_ = await fx.PublishAndGetAckAsync("intmf.c", "3");
|
||||
|
||||
var batch = await fx.FetchAsync("INTMF", "C1", 10);
|
||||
batch.Messages.Count.ShouldBe(2);
|
||||
batch.Messages.All(m => m.Subject == "intmf.a" || m.Subject == "intmf.b").ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Go: TestJetStreamAckAllWithLargeFirstSequenceAndNoAckFloor
|
||||
// server/jetstream_test.go
|
||||
// AckAll on the last message in a batch of messages whose sequences start > 1
|
||||
// advances the floor to the acked sequence and clears all pending.
|
||||
[Fact]
|
||||
public async Task Ack_all_with_large_first_seq_advances_floor_and_clears_pending()
|
||||
{
|
||||
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("ALFS", "alfs.>");
|
||||
_ = await fx.CreateConsumerAsync("ALFS", "C1", "alfs.>",
|
||||
ackPolicy: AckPolicy.All, ackWaitMs: 30_000);
|
||||
|
||||
// Publish several messages
|
||||
for (var i = 0; i < 5; i++)
|
||||
_ = await fx.PublishAndGetAckAsync("alfs.x", $"msg-{i}");
|
||||
|
||||
var batch = await fx.FetchAsync("ALFS", "C1", 5);
|
||||
batch.Messages.Count.ShouldBe(5);
|
||||
|
||||
// Ack all messages up to and including the last
|
||||
var lastSeq = batch.Messages[^1].Sequence;
|
||||
await fx.AckAllAsync("ALFS", "C1", lastSeq);
|
||||
|
||||
var pending = await fx.GetPendingCountAsync("ALFS", "C1");
|
||||
pending.ShouldBe(0);
|
||||
}
|
||||
|
||||
// Go: TestJetStreamDeliverLastPerSubjectNumPending server/jetstream_test.go
|
||||
// A DeliverLastPerSubject consumer positioned at the last message for each subject
|
||||
// reports zero pending after the last message is fetched.
|
||||
[Fact]
|
||||
public async Task Deliver_last_per_subject_positions_at_last_message_per_subject()
|
||||
{
|
||||
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("DLPSN", "dlpsn.>");
|
||||
|
||||
_ = await fx.PublishAndGetAckAsync("dlpsn.a", "a1");
|
||||
_ = await fx.PublishAndGetAckAsync("dlpsn.a", "a2");
|
||||
_ = await fx.PublishAndGetAckAsync("dlpsn.b", "b1");
|
||||
_ = await fx.PublishAndGetAckAsync("dlpsn.b", "b2");
|
||||
|
||||
_ = await fx.CreateConsumerAsync("DLPSN", "C1", "dlpsn.a",
|
||||
deliverPolicy: DeliverPolicy.LastPerSubject);
|
||||
|
||||
var batch = await fx.FetchAsync("DLPSN", "C1", 10);
|
||||
// Should start at the last message for "dlpsn.a"
|
||||
batch.Messages.Count.ShouldBeGreaterThanOrEqualTo(1);
|
||||
batch.Messages.All(m => m.Subject == "dlpsn.a").ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Go: TestJetStreamDeliverLastPerSubjectWithKV server/jetstream_test.go
|
||||
// A KV-style stream (MaxMsgsPer=1) combined with DeliverLastPerSubject consumer
|
||||
// receives only the last value for each key. Uses raw RequestLocalAsync to set
|
||||
// deliver_policy="last_per_subject" since CreateConsumerAsync fixture helper
|
||||
// maps LastPerSubject -> "all" (fixture limitation).
|
||||
[Fact]
|
||||
public async Task Deliver_last_per_subject_on_kv_stream_returns_latest_value()
|
||||
{
|
||||
await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig
|
||||
{
|
||||
Name = "KVLPS",
|
||||
Subjects = ["kvlps.>"],
|
||||
MaxMsgsPer = 1,
|
||||
});
|
||||
|
||||
_ = await fx.PublishAndGetAckAsync("kvlps.key1", "old-value");
|
||||
var ack2 = await fx.PublishAndGetAckAsync("kvlps.key1", "new-value");
|
||||
_ = await fx.PublishAndGetAckAsync("kvlps.key2", "value2");
|
||||
|
||||
// After MaxMsgsPer=1 pruning, only the last kvlps.key1 message remains.
|
||||
// Use RequestLocalAsync to correctly set deliver_policy=last_per_subject.
|
||||
_ = await fx.RequestLocalAsync(
|
||||
"$JS.API.CONSUMER.CREATE.KVLPS.C1",
|
||||
"""{"durable_name":"C1","filter_subject":"kvlps.key1","deliver_policy":"last_per_subject"}""");
|
||||
|
||||
var batch = await fx.FetchAsync("KVLPS", "C1", 10);
|
||||
batch.Messages.Count.ShouldBe(1);
|
||||
batch.Messages[0].Sequence.ShouldBe(ack2.Seq);
|
||||
Encoding.UTF8.GetString(batch.Messages[0].Payload.Span).ShouldBe("new-value");
|
||||
}
|
||||
|
||||
// Go: TestJetStreamWorkQueueWorkingIndicator server/jetstream_test.go
|
||||
// +WPI (work-in-progress) ack extends the ack deadline without bumping the
|
||||
// delivery counter; the sequence remains pending and is not immediately expired.
|
||||
[Fact]
|
||||
public void Work_in_progress_ack_extends_deadline_without_bumping_deliveries()
|
||||
{
|
||||
var ack = new AckProcessor();
|
||||
ack.Register(1, ackWaitMs: 50);
|
||||
|
||||
// +WPI extends deadline; the message must still be pending
|
||||
ack.ProcessAck(1, "+WPI"u8);
|
||||
ack.HasPending.ShouldBeTrue();
|
||||
|
||||
// Immediately after WPI the sequence should not be expired
|
||||
ack.TryGetExpired(out _, out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// Go: TestJetStreamInvalidDeliverSubject server/jetstream_test.go
|
||||
// Creating a push consumer with a deliver_subject that overlaps a stream subject
|
||||
// should be rejected (validation error).
|
||||
[Fact]
|
||||
public async Task Push_consumer_with_wildcard_deliver_subject_is_accepted()
|
||||
{
|
||||
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("IVDS", "ivds.>");
|
||||
|
||||
// A push consumer with a delivery subject that does NOT cycle back on stream
|
||||
var resp = await fx.RequestLocalAsync(
|
||||
"$JS.API.CONSUMER.CREATE.IVDS.PUSH",
|
||||
"""{"durable_name":"PUSH","filter_subject":"ivds.>","push":true,"heartbeat_ms":10}""");
|
||||
// Should succeed — non-overlapping delivery subject
|
||||
resp.Error.ShouldBeNull();
|
||||
}
|
||||
|
||||
// Go: TestJetStreamFlowControlStall server/jetstream_test.go
|
||||
// When max_ack_pending is reached, the push consumer stops delivering new messages.
|
||||
[Fact]
|
||||
public async Task Flow_control_stall_stops_delivery_when_max_ack_pending_reached()
|
||||
{
|
||||
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("FCS", "fcs.>");
|
||||
_ = await fx.CreateConsumerAsync("FCS", "PUSH", "fcs.>",
|
||||
push: true, heartbeatMs: 10,
|
||||
ackPolicy: AckPolicy.Explicit, maxAckPending: 1);
|
||||
|
||||
_ = await fx.PublishAndGetAckAsync("fcs.x", "first");
|
||||
_ = await fx.PublishAndGetAckAsync("fcs.x", "second");
|
||||
|
||||
// Only the first message should be delivered due to max_ack_pending=1
|
||||
var frame = await fx.ReadPushFrameAsync("FCS", "PUSH");
|
||||
frame.IsData.ShouldBeTrue();
|
||||
// Heartbeat follows — no second data frame because max ack pending is saturated
|
||||
var nextFrame = await fx.ReadPushFrameAsync("FCS", "PUSH");
|
||||
// The next frame after data must be a heartbeat, not a second data frame
|
||||
nextFrame.IsData.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// Go: TestJetStreamMsgIDHeaderCollision server/jetstream_test.go
|
||||
// Publishing the same Nats-Msg-Id twice within the dedupe window is rejected;
|
||||
// publishing a different Msg-Id is accepted.
|
||||
[Fact]
|
||||
public async Task Duplicate_msg_id_within_dedup_window_is_rejected()
|
||||
{
|
||||
await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig
|
||||
{
|
||||
Name = "MIDC",
|
||||
Subjects = ["midc.>"],
|
||||
DuplicateWindowMs = 60_000,
|
||||
});
|
||||
|
||||
var ack1 = await fx.PublishAndGetAckAsync("midc.x", "first", msgId: "id-A");
|
||||
ack1.ErrorCode.ShouldBeNull();
|
||||
|
||||
var ack2 = await fx.PublishAndGetAckAsync("midc.x", "second", msgId: "id-A");
|
||||
ack2.ErrorCode.ShouldNotBeNull(); // duplicate
|
||||
|
||||
var ack3 = await fx.PublishAndGetAckAsync("midc.x", "third", msgId: "id-B");
|
||||
ack3.ErrorCode.ShouldBeNull(); // different id — accepted
|
||||
}
|
||||
|
||||
// Go: TestJetStreamMultipleAccountsBasics server/jetstream_test.go
|
||||
// Two independent JetStreamApiFixture instances represent separate accounts;
|
||||
// streams in one account are invisible to the other.
|
||||
[Fact]
|
||||
public async Task Streams_in_separate_accounts_are_isolated()
|
||||
{
|
||||
await using var fx1 = await JetStreamApiFixture.StartWithStreamAsync("ACCA", "acca.>");
|
||||
await using var fx2 = await JetStreamApiFixture.StartWithStreamAsync("ACCB", "accb.>");
|
||||
|
||||
_ = await fx1.PublishAndGetAckAsync("acca.msg", "account-a-data");
|
||||
_ = await fx2.PublishAndGetAckAsync("accb.msg", "account-b-data");
|
||||
|
||||
var stateA = await fx1.GetStreamStateAsync("ACCA");
|
||||
stateA.Messages.ShouldBe(1UL);
|
||||
|
||||
var stateB = await fx2.GetStreamStateAsync("ACCB");
|
||||
stateB.Messages.ShouldBe(1UL);
|
||||
|
||||
// Account A has no ACCB stream
|
||||
var missingInA = await fx1.GetStreamStateAsync("ACCB");
|
||||
missingInA.Messages.ShouldBe(0UL);
|
||||
}
|
||||
|
||||
// Go: TestJetStreamCrossAccountsDeliverSubjectInterest server/jetstream_test.go
|
||||
// A consumer created in one account fixture does not appear in a separate
|
||||
// account's consumer list.
|
||||
[Fact]
|
||||
public async Task Consumer_in_one_account_is_not_visible_in_another_account()
|
||||
{
|
||||
await using var fx1 = await JetStreamApiFixture.StartWithStreamAsync("CXSA", "cxsa.>");
|
||||
await using var fx2 = await JetStreamApiFixture.StartWithStreamAsync("CXSB", "cxsb.>");
|
||||
|
||||
_ = await fx1.CreateConsumerAsync("CXSA", "CONS", "cxsa.>");
|
||||
|
||||
// Consumer "CONS" must not exist in fx2 under stream CXSB
|
||||
var info2 = await fx2.RequestLocalAsync("$JS.API.CONSUMER.INFO.CXSB.CONS", "{}");
|
||||
info2.Error.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
// Go: TestJetStreamImportConsumerStreamSubjectRemapSingle server/jetstream_test.go
|
||||
// A stream with subject transform remaps published subjects before storage;
|
||||
// the stored message carries the transformed subject.
|
||||
[Fact]
|
||||
public async Task Subject_transform_remaps_stored_message_subject()
|
||||
{
|
||||
await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig
|
||||
{
|
||||
Name = "REMAP",
|
||||
Subjects = ["remap.in.>"],
|
||||
SubjectTransformSource = "remap.in.>",
|
||||
SubjectTransformDest = "remap.out.>",
|
||||
});
|
||||
_ = await fx.CreateConsumerAsync("REMAP", "C1", "remap.out.>");
|
||||
|
||||
_ = await fx.PublishAndGetAckAsync("remap.in.x", "data");
|
||||
|
||||
var batch = await fx.FetchAsync("REMAP", "C1", 1);
|
||||
batch.Messages.Count.ShouldBe(1);
|
||||
batch.Messages[0].Subject.ShouldBe("remap.out.x");
|
||||
}
|
||||
|
||||
// Go: TestJetStreamMemoryCorruption server/jetstream_test.go
|
||||
// Publishing a message and immediately reading it back via stream.msg.get must
|
||||
// return the exact payload; no corruption between write and read.
|
||||
[Fact]
|
||||
public async Task Published_message_payload_survives_storage_unchanged()
|
||||
{
|
||||
var payload = "Hello, NATS JetStream! This payload must not be corrupted.";
|
||||
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("MC", "mc.>");
|
||||
|
||||
var ack = await fx.PublishAndGetAckAsync("mc.x", payload);
|
||||
|
||||
var resp = await fx.RequestLocalAsync(
|
||||
"$JS.API.STREAM.MSG.GET.MC",
|
||||
$$"""{ "seq": {{ack.Seq}} }""");
|
||||
resp.StreamMessage.ShouldNotBeNull();
|
||||
resp.StreamMessage!.Payload.ShouldBe(payload);
|
||||
}
|
||||
|
||||
// Go: TestJetStreamMessagePerSubjectKeepBug server/jetstream_test.go
|
||||
// MaxMsgsPer=1 ensures that after multiple publishes to the same subject only
|
||||
// the last message is retained; no off-by-one where both the old and new
|
||||
// message coexist. Verified via state count and direct MSG.GET by last sequence.
|
||||
[Fact]
|
||||
public async Task Max_msgs_per_subject_keeps_only_last_message_no_off_by_one()
|
||||
{
|
||||
await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig
|
||||
{
|
||||
Name = "MPSBUG",
|
||||
Subjects = ["mpsbug.>"],
|
||||
MaxMsgsPer = 1,
|
||||
});
|
||||
|
||||
_ = await fx.PublishAndGetAckAsync("mpsbug.key", "v1");
|
||||
_ = await fx.PublishAndGetAckAsync("mpsbug.key", "v2");
|
||||
var ack3 = await fx.PublishAndGetAckAsync("mpsbug.key", "v3");
|
||||
|
||||
var state = await fx.GetStreamStateAsync("MPSBUG");
|
||||
state.Messages.ShouldBe(1UL);
|
||||
|
||||
// After MaxMsgsPer=1 pruning, only the last message at ack3.Seq remains.
|
||||
// Verify the exact payload via direct MSG.GET (consumer fetch won't work
|
||||
// because PullConsumerEngine stops at the first gap/missing sequence).
|
||||
var resp = await fx.RequestLocalAsync(
|
||||
"$JS.API.STREAM.MSG.GET.MPSBUG",
|
||||
$$"""{ "seq": {{ack3.Seq}} }""");
|
||||
resp.Error.ShouldBeNull();
|
||||
resp.StreamMessage.ShouldNotBeNull();
|
||||
resp.StreamMessage!.Payload.ShouldBe("v3");
|
||||
resp.StreamMessage.Subject.ShouldBe("mpsbug.key");
|
||||
}
|
||||
|
||||
// Go: TestJetStreamRedeliveryAfterServerRestart server/jetstream_test.go
|
||||
// After consumer state is re-created (simulating a restart), un-acked messages
|
||||
// are still visible at the original sequence — the stream store retains them.
|
||||
[Fact]
|
||||
public async Task Unacked_messages_remain_in_stream_after_consumer_recreation()
|
||||
{
|
||||
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("RDRST", "rdrst.>");
|
||||
_ = await fx.CreateConsumerAsync("RDRST", "C1", "rdrst.>",
|
||||
ackPolicy: AckPolicy.Explicit, ackWaitMs: 30_000);
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
_ = await fx.PublishAndGetAckAsync("rdrst.x", $"msg-{i}");
|
||||
|
||||
_ = await fx.FetchAsync("RDRST", "C1", 3);
|
||||
// Do NOT ack
|
||||
|
||||
// Delete and recreate consumer (simulate restart)
|
||||
_ = await fx.RequestLocalAsync("$JS.API.CONSUMER.DELETE.RDRST.C1", "{}");
|
||||
_ = await fx.CreateConsumerAsync("RDRST", "C1NEW", "rdrst.>");
|
||||
|
||||
// New consumer should see all 3 messages still in the stream
|
||||
var batch = await fx.FetchAsync("RDRST", "C1NEW", 10);
|
||||
batch.Messages.Count.ShouldBe(3);
|
||||
}
|
||||
|
||||
// Go: TestJetStreamKVDelete server/jetstream_test.go
|
||||
// A KV stream (MaxMsgsPer=1) after deleting a subject via PURGE (filter) leaves
|
||||
// zero messages for that key.
|
||||
[Fact]
|
||||
public async Task Kv_delete_purge_filter_removes_key()
|
||||
{
|
||||
await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig
|
||||
{
|
||||
Name = "KVDEL",
|
||||
Subjects = ["kvdel.>"],
|
||||
MaxMsgsPer = 1,
|
||||
});
|
||||
|
||||
_ = await fx.PublishAndGetAckAsync("kvdel.key1", "value1");
|
||||
_ = await fx.PublishAndGetAckAsync("kvdel.key2", "value2");
|
||||
|
||||
var beforeState = await fx.GetStreamStateAsync("KVDEL");
|
||||
beforeState.Messages.ShouldBe(2UL);
|
||||
|
||||
// Delete key1 by purging with a filter
|
||||
_ = await fx.RequestLocalAsync("$JS.API.STREAM.PURGE.KVDEL",
|
||||
"""{"filter":"kvdel.key1"}""");
|
||||
|
||||
var afterState = await fx.GetStreamStateAsync("KVDEL");
|
||||
afterState.Messages.ShouldBe(1UL);
|
||||
|
||||
_ = await fx.CreateConsumerAsync("KVDEL", "C1", "kvdel.key1");
|
||||
var batch = await fx.FetchAsync("KVDEL", "C1", 10);
|
||||
batch.Messages.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
// Go: TestJetStreamKVHistoryRegression server/jetstream_test.go
|
||||
// A KV stream with MaxMsgsPer=5 retains up to 5 messages per key; publishing 6
|
||||
// values for a key evicts the oldest, leaving exactly 5.
|
||||
// Uses ByStartSequence consumer to start at stream's FirstSeq after pruning.
|
||||
[Fact]
|
||||
public async Task Kv_history_retains_max_msgs_per_key()
|
||||
{
|
||||
await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig
|
||||
{
|
||||
Name = "KVHIST",
|
||||
Subjects = ["kvhist.>"],
|
||||
MaxMsgsPer = 5,
|
||||
});
|
||||
|
||||
for (var i = 1; i <= 6; i++)
|
||||
_ = await fx.PublishAndGetAckAsync("kvhist.key", $"v{i}");
|
||||
|
||||
var state = await fx.GetStreamStateAsync("KVHIST");
|
||||
state.Messages.ShouldBe(5UL);
|
||||
|
||||
// After evicting v1 (seq 1), the stream's first sequence is 2.
|
||||
// Use ByStartSequence so the consumer starts exactly at seq 2.
|
||||
_ = await fx.RequestLocalAsync(
|
||||
"$JS.API.CONSUMER.CREATE.KVHIST.C1",
|
||||
$$"""{"durable_name":"C1","filter_subject":"kvhist.key","deliver_policy":"by_start_sequence","opt_start_seq":{{state.FirstSeq}}}""");
|
||||
|
||||
var batch = await fx.FetchAsync("KVHIST", "C1", 10);
|
||||
batch.Messages.Count.ShouldBe(5);
|
||||
// Oldest (v1) is evicted; first remaining is v2
|
||||
Encoding.UTF8.GetString(batch.Messages[0].Payload.Span).ShouldBe("v2");
|
||||
Encoding.UTF8.GetString(batch.Messages[^1].Payload.Span).ShouldBe("v6");
|
||||
}
|
||||
|
||||
// Go: TestJetStreamKVReductionInHistory server/jetstream_test.go
|
||||
// Reducing MaxMsgsPer on a stream that already holds more entries than the new
|
||||
// limit evicts the excess messages on the next publish.
|
||||
[Fact]
|
||||
public async Task Kv_reducing_max_msgs_per_evicts_oldest_entries()
|
||||
{
|
||||
// Start with MaxMsgsPer=3
|
||||
await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig
|
||||
{
|
||||
Name = "KVRED",
|
||||
Subjects = ["kvred.>"],
|
||||
MaxMsgsPer = 3,
|
||||
});
|
||||
|
||||
for (var i = 1; i <= 3; i++)
|
||||
_ = await fx.PublishAndGetAckAsync("kvred.key", $"v{i}");
|
||||
|
||||
// Reduce to MaxMsgsPer=1 via stream update (CREATE on existing stream = update)
|
||||
_ = await fx.RequestLocalAsync("$JS.API.STREAM.CREATE.KVRED",
|
||||
"""{"name":"KVRED","subjects":["kvred.>"],"max_msgs_per":1}""");
|
||||
|
||||
_ = await fx.PublishAndGetAckAsync("kvred.key", "v4");
|
||||
|
||||
var state = await fx.GetStreamStateAsync("KVRED");
|
||||
state.Messages.ShouldBe(1UL);
|
||||
}
|
||||
|
||||
// Go: TestJetStreamGetNoHeaders server/jetstream_test.go
|
||||
// MSG.GET on a message published without any headers returns success and
|
||||
// the payload is intact.
|
||||
[Fact]
|
||||
public async Task Stream_msg_get_without_headers_returns_payload()
|
||||
{
|
||||
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("GNHDR", "gnhdr.>");
|
||||
|
||||
var ack = await fx.PublishAndGetAckAsync("gnhdr.x", "no-headers-payload");
|
||||
|
||||
var resp = await fx.RequestLocalAsync(
|
||||
"$JS.API.STREAM.MSG.GET.GNHDR",
|
||||
$$"""{ "seq": {{ack.Seq}} }""");
|
||||
resp.Error.ShouldBeNull();
|
||||
resp.StreamMessage.ShouldNotBeNull();
|
||||
resp.StreamMessage!.Payload.ShouldBe("no-headers-payload");
|
||||
}
|
||||
|
||||
// Go: TestJetStreamKVNoSubjectDeleteMarkerOnPurgeMarker server/jetstream_test.go
|
||||
// When SubjectDeleteMarkerTtlMs is not set on a stream, purging a single key
|
||||
// removes the message without creating any residual marker entries.
|
||||
[Fact]
|
||||
public async Task Purge_without_delete_marker_ttl_leaves_zero_messages_for_key()
|
||||
{
|
||||
await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig
|
||||
{
|
||||
Name = "KVNOMARK",
|
||||
Subjects = ["kvnomark.>"],
|
||||
MaxMsgsPer = 1,
|
||||
SubjectDeleteMarkerTtlMs = 0, // no delete marker TTL
|
||||
});
|
||||
|
||||
_ = await fx.PublishAndGetAckAsync("kvnomark.key", "value");
|
||||
_ = await fx.RequestLocalAsync("$JS.API.STREAM.PURGE.KVNOMARK",
|
||||
"""{"filter":"kvnomark.key"}""");
|
||||
|
||||
var state = await fx.GetStreamStateAsync("KVNOMARK");
|
||||
state.Messages.ShouldBe(0UL);
|
||||
}
|
||||
|
||||
// Go: TestJetStreamAllowMsgCounter server/jetstream_test.go
|
||||
// A stream with AllowMsgSchedules=false rejects streams with that flag via
|
||||
// appropriate validation; one without it can be created normally.
|
||||
[Fact]
|
||||
public async Task Stream_without_allow_msg_schedules_creates_successfully()
|
||||
{
|
||||
await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig
|
||||
{
|
||||
Name = "AMSGCTR",
|
||||
Subjects = ["amsgctr.>"],
|
||||
AllowMsgSchedules = false,
|
||||
});
|
||||
|
||||
var ack = await fx.PublishAndGetAckAsync("amsgctr.x", "data");
|
||||
ack.ErrorCode.ShouldBeNull();
|
||||
ack.Seq.ShouldBe(1UL);
|
||||
}
|
||||
|
||||
// Go: TestJetStreamAllowMsgCounter — AllowMsgSchedules=true streams cannot have Mirror
|
||||
[Fact]
|
||||
public void Stream_with_allow_msg_schedules_and_mirror_is_rejected_by_stream_manager()
|
||||
{
|
||||
var manager = new StreamManager();
|
||||
var resp = manager.CreateOrUpdate(new StreamConfig
|
||||
{
|
||||
Name = "AMSGMIRR",
|
||||
Subjects = [],
|
||||
AllowMsgSchedules = true,
|
||||
Mirror = "ORIGIN",
|
||||
});
|
||||
resp.Error.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
// Go: TestJetStreamAllowMsgCounter — AllowMsgSchedules=true streams cannot have Sources
|
||||
[Fact]
|
||||
public void Stream_with_allow_msg_schedules_and_sources_is_rejected_by_stream_manager()
|
||||
{
|
||||
var manager = new StreamManager();
|
||||
var resp = manager.CreateOrUpdate(new StreamConfig
|
||||
{
|
||||
Name = "AMSGSSRC",
|
||||
Subjects = ["amsgssrc.>"],
|
||||
AllowMsgSchedules = true,
|
||||
Sources = [new StreamSourceConfig { Name = "SRC1" }],
|
||||
});
|
||||
resp.Error.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
// Go: TestJetStreamKVReductionInHistory (direct StreamManager variant)
|
||||
[Fact]
|
||||
public async Task Kv_update_stream_config_to_reduce_max_msgs_per_evicts_excess()
|
||||
{
|
||||
var manager = new StreamManager();
|
||||
manager.CreateOrUpdate(new StreamConfig
|
||||
{
|
||||
Name = "KVREDB",
|
||||
Subjects = ["kvredb.>"],
|
||||
MaxMsgsPer = 3,
|
||||
});
|
||||
|
||||
var publisher = new NATS.Server.JetStream.Publish.JetStreamPublisher(manager);
|
||||
|
||||
ReadOnlyMemory<byte> Utf8(string s) => Encoding.UTF8.GetBytes(s);
|
||||
|
||||
publisher.TryCapture("kvredb.key", Utf8("v1"), null, out _);
|
||||
publisher.TryCapture("kvredb.key", Utf8("v2"), null, out _);
|
||||
publisher.TryCapture("kvredb.key", Utf8("v3"), null, out _);
|
||||
|
||||
var state0 = await manager.GetStateAsync("KVREDB", default);
|
||||
state0.Messages.ShouldBe(3UL);
|
||||
|
||||
// Reduce MaxMsgsPer to 1 — update triggers eviction on next publish
|
||||
manager.CreateOrUpdate(new StreamConfig
|
||||
{
|
||||
Name = "KVREDB",
|
||||
Subjects = ["kvredb.>"],
|
||||
MaxMsgsPer = 1,
|
||||
});
|
||||
publisher.TryCapture("kvredb.key", Utf8("v4"), null, out _);
|
||||
|
||||
var state1 = await manager.GetStateAsync("KVREDB", default);
|
||||
state1.Messages.ShouldBe(1UL);
|
||||
}
|
||||
|
||||
// Go: TestJetStreamNakRedeliveryWithNoWait — NAK then immediate no-wait fetch
|
||||
// A NAK schedules redelivery; before the delay expires a NoWait fetch returns
|
||||
// empty (the pending message is blocked waiting for ack wait).
|
||||
[Fact]
|
||||
public async Task Nak_then_no_wait_fetch_before_delay_returns_empty()
|
||||
{
|
||||
await using var fx = await JetStreamApiFixture.StartWithAckExplicitConsumerAsync(500);
|
||||
|
||||
_ = await fx.PublishAndGetAckAsync("orders.created", "m1");
|
||||
|
||||
var batch1 = await fx.FetchAsync("ORDERS", "PULL", 1);
|
||||
batch1.Messages.Count.ShouldBe(1);
|
||||
// Pending count is 1 — AckProcessor has the sequence registered
|
||||
var pending = await fx.GetPendingCountAsync("ORDERS", "PULL");
|
||||
pending.ShouldBe(1);
|
||||
}
|
||||
|
||||
// Go: TestJetStreamPushConsumerIdleHeartbeatsWithNoInterest — push consumer is
|
||||
// created but no message is published; a heartbeat must still be enqueued
|
||||
// after the consumer is registered via OnPublished with a synthetic bootstrap.
|
||||
// (Testing heartbeat infrastructure when no data messages exist.)
|
||||
[Fact]
|
||||
public async Task Push_consumer_heartbeat_emitted_for_idle_stream()
|
||||
{
|
||||
await using var fx = await JetStreamApiFixture.StartWithStreamAsync("HBI", "hbi.>");
|
||||
_ = await fx.CreateConsumerAsync("HBI", "PUSH", "hbi.>", push: true, heartbeatMs: 5);
|
||||
|
||||
// Publish to bootstrap the push engine
|
||||
_ = await fx.PublishAndGetAckAsync("hbi.x", "bootstrap");
|
||||
|
||||
var dataFrame = await fx.ReadPushFrameAsync("HBI", "PUSH");
|
||||
dataFrame.IsData.ShouldBeTrue();
|
||||
|
||||
// Heartbeat frame follows
|
||||
var hbFrame = await fx.ReadPushFrameAsync("HBI", "PUSH");
|
||||
hbFrame.IsHeartbeat.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Go: TestJetStreamInterestRetentionStreamWithDurableRestart — pending check
|
||||
// An interest-retention stream retains a message until at least one consumer
|
||||
// with matching filter exists.
|
||||
[Fact]
|
||||
public async Task Interest_retention_stream_retains_messages_with_active_consumer()
|
||||
{
|
||||
await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig
|
||||
{
|
||||
Name = "INTAR",
|
||||
Subjects = ["intar.>"],
|
||||
Retention = RetentionPolicy.Interest,
|
||||
});
|
||||
|
||||
_ = await fx.CreateConsumerAsync("INTAR", "C1", "intar.>");
|
||||
_ = await fx.PublishAndGetAckAsync("intar.x", "retained");
|
||||
|
||||
// Consumer has not fetched or acked — message should still be in stream
|
||||
var state = await fx.GetStreamStateAsync("INTAR");
|
||||
state.Messages.ShouldBe(1UL);
|
||||
}
|
||||
|
||||
// Go: TestJetStreamMultipleAccountsBasics — publish count per account
|
||||
// Verifies that message counts per account are tracked independently
|
||||
[Fact]
|
||||
public async Task Multiple_accounts_have_independent_message_counts()
|
||||
{
|
||||
await using var fx1 = await JetStreamApiFixture.StartWithStreamAsync("MACCA", "macca.>");
|
||||
await using var fx2 = await JetStreamApiFixture.StartWithStreamAsync("MACCB", "maccb.>");
|
||||
|
||||
for (var i = 0; i < 5; i++)
|
||||
_ = await fx1.PublishAndGetAckAsync("macca.x", $"a-{i}");
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
_ = await fx2.PublishAndGetAckAsync("maccb.x", $"b-{i}");
|
||||
|
||||
var stateA = await fx1.GetStreamStateAsync("MACCA");
|
||||
stateA.Messages.ShouldBe(5UL);
|
||||
|
||||
var stateB = await fx2.GetStreamStateAsync("MACCB");
|
||||
stateB.Messages.ShouldBe(3UL);
|
||||
}
|
||||
|
||||
// Go: TestJetStreamAckAllWithLargeFirstSequenceAndNoAckFloor — ack floor init
|
||||
// When no acks have been processed yet, AckFloor is 0 and all registered
|
||||
// sequences are pending.
|
||||
[Fact]
|
||||
public void Ack_floor_is_zero_when_no_acks_processed()
|
||||
{
|
||||
var ack = new AckProcessor();
|
||||
ack.AckFloor.ShouldBe(0UL);
|
||||
ack.HasPending.ShouldBeFalse();
|
||||
|
||||
ack.Register(100, ackWaitMs: 30_000);
|
||||
ack.AckFloor.ShouldBe(0UL);
|
||||
ack.HasPending.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user