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:
Joseph Doherty
2026-03-12 15:58:10 -04:00
parent 36b9dfa654
commit 78b4bc2486
228 changed files with 253 additions and 227 deletions

View File

@@ -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();
}
}