From ad3a1bbb38cc6ef0fbc8c9b3d0fff42e22170e9e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 1 Mar 2026 00:01:37 -0500 Subject: [PATCH 01/12] task1: start batch 38 and capture baseline gates --- porting.db | Bin 6758400 -> 6758400 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/porting.db b/porting.db index 49cc078fe1224e95154f67f71bfa2251e4ad9037..7a4e86edcdb0fdedb7c1b7863a9cbaa49ad7ea35 100644 GIT binary patch delta 388 zcmYk!%T5zv0EXe29sp-L(~8N>S z206!hF5tz-Mf_aiGD8d#V1!YEj1l4rz8$uh$& za?CQvZSu@>hXw9(j{^63K#_+mvcxhgtg^;B8$9AMPk72Rp7Vm2ykhf6*qY9m5;69y z{-#LVWOGg7caE7}Q)|WR=7iyK+e7MyPo>@J=l9ietroWSzlpC7v!J+IiSMEl^cfvx zKyNF0+pW1~&8*olDu(9zaXLTLTkSu>usO^FrDC0Q#j)=A7}k||QB~ED^}8!Fp8vj+ jsc14|{p^as7p*6x*HTG(BW+3B(p%}BRBma#oxk~iH`s?j delta 357 zcmWm4H%|g#0Dxf*(9=8a?4V*Vs8|rJSh4rs%V+O{G5!Kf-ozi^a3(r1n`j(NSPhe# zgQFV%g|nZ-vpvz*dq?yi3U(YgNg^4Q6jIU9abaMRMmiZ}l0`N-xbcun9{G4FppYVp zDWQ}y%Bi3dA5~OSgP&UJsHcGdjRXnNL^Cb4(ndRBI_RW}ZhGh?LLdDMFvt+Yj4;X= z<4iEg6w}Nw%N+A8u*ee2tgy-&>uj+3BJ8oGVVmE$vDBDov3g=6Pi-*EOt-1UgHf~1 zux=j3ol|#sm3Zh$yq9{+TU&~ru<403&DCv&jd$bB&|J^|`$3NqFs}U^{YL%(cHw=Q From fce6bd7dcace4b60e2cd793be9257caa129e09be Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 1 Mar 2026 00:11:47 -0500 Subject: [PATCH 02/12] task2: implement batch38 group A consumer lifecycle features --- .../Auth/JwtProcessor.cs | 9 +- .../Internal/SubjectTokens.ConsumerFilters.cs | 10 + .../JetStream/NatsConsumer.Config.cs | 268 ++++++++++++++++++ .../JetStream/NatsConsumer.cs | 7 +- .../JetStream/NatsStream.Consumers.cs | 80 ++++++ .../JetStream/NatsStream.cs | 1 + .../JetStream/StoreTypes.ConsumerPolicies.cs | 82 ++++++ .../JetStream/StoreTypes.cs | 1 + .../StreamTypes.ConsumerLifecycle.cs | 51 ++++ .../JetStream/StreamTypes.cs | 1 + .../JetStream/ConsumerPoliciesTests.cs | 133 +++++++++ .../JetStream/NatsConsumerTests.cs | 85 ++++++ porting.db | Bin 6758400 -> 6758400 bytes 13 files changed, 725 insertions(+), 3 deletions(-) create mode 100644 dotnet/src/ZB.MOM.NatsNet.Server/Internal/SubjectTokens.ConsumerFilters.cs create mode 100644 dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.Config.cs create mode 100644 dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsStream.Consumers.cs create mode 100644 dotnet/src/ZB.MOM.NatsNet.Server/JetStream/StoreTypes.ConsumerPolicies.cs create mode 100644 dotnet/src/ZB.MOM.NatsNet.Server/JetStream/StreamTypes.ConsumerLifecycle.cs create mode 100644 dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/ConsumerPoliciesTests.cs diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Auth/JwtProcessor.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Auth/JwtProcessor.cs index de4ef4e..69d5ab2 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/Auth/JwtProcessor.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Auth/JwtProcessor.cs @@ -128,7 +128,14 @@ public static class JwtProcessor // If start > end, end is on the next day (overnight range). if (startTime > endTime) { - end = end.AddDays(1); + if (now.TimeOfDay < endTime) + { + start = start.AddDays(-1); + } + else + { + end = end.AddDays(1); + } } if (start <= now && now < end) diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Internal/SubjectTokens.ConsumerFilters.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Internal/SubjectTokens.ConsumerFilters.cs new file mode 100644 index 0000000..711d466 --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Internal/SubjectTokens.ConsumerFilters.cs @@ -0,0 +1,10 @@ +namespace ZB.MOM.NatsNet.Server; + +internal static class SubjectTokens +{ + internal static string[] Subjects(IEnumerable filters) + { + ArgumentNullException.ThrowIfNull(filters); + return filters.Where(static filter => !string.IsNullOrWhiteSpace(filter)).ToArray(); + } +} diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.Config.cs b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.Config.cs new file mode 100644 index 0000000..cb90083 --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.Config.cs @@ -0,0 +1,268 @@ +using ZB.MOM.NatsNet.Server.Internal.DataStructures; + +namespace ZB.MOM.NatsNet.Server; + +internal sealed partial class NatsConsumer +{ + internal const int DefaultMaxAckPending = 1000; + internal static readonly TimeSpan DefaultAckWait = TimeSpan.FromSeconds(30); + internal static readonly TimeSpan DefaultDeleteWait = TimeSpan.FromSeconds(5); + internal static readonly TimeSpan DefaultPinnedTtl = TimeSpan.FromMinutes(2); + + internal static JsApiError? SetConsumerConfigDefaults( + ConsumerConfig config, + StreamConfig streamConfig, + JetStreamAccountLimits? selectedLimits, + bool pedantic) + { + ArgumentNullException.ThrowIfNull(config); + ArgumentNullException.ThrowIfNull(streamConfig); + var streamReplicas = Math.Max(1, streamConfig.Replicas); + + if (config.MaxDeliver is 0 or < -1) + { + if (pedantic && config.MaxDeliver < -1) + return JsApiErrors.NewJSPedanticError(new InvalidOperationException("max_deliver must be set to -1")); + config.MaxDeliver = -1; + } + + if (config.MaxWaiting < 0) + { + if (pedantic) + return JsApiErrors.NewJSPedanticError(new InvalidOperationException("max_waiting must not be negative")); + config.MaxWaiting = 0; + } + + if (config.MaxAckPending < -1) + { + if (pedantic) + return JsApiErrors.NewJSPedanticError(new InvalidOperationException("max_ack_pending must be set to -1")); + config.MaxAckPending = -1; + } + + if (config.MaxRequestBatch < 0) + { + if (pedantic) + return JsApiErrors.NewJSPedanticError(new InvalidOperationException("max_batch must not be negative")); + config.MaxRequestBatch = 0; + } + + if (config.MaxRequestExpires < TimeSpan.Zero) + { + if (pedantic) + return JsApiErrors.NewJSPedanticError(new InvalidOperationException("max_expires must not be negative")); + config.MaxRequestExpires = TimeSpan.Zero; + } + + if (config.MaxRequestMaxBytes < 0) + { + if (pedantic) + return JsApiErrors.NewJSPedanticError(new InvalidOperationException("max_bytes must not be negative")); + config.MaxRequestMaxBytes = 0; + } + + if (config.Heartbeat < TimeSpan.Zero) + { + if (pedantic) + return JsApiErrors.NewJSPedanticError(new InvalidOperationException("idle_heartbeat must not be negative")); + config.Heartbeat = TimeSpan.Zero; + } + + if (config.InactiveThreshold < TimeSpan.Zero) + { + if (pedantic) + return JsApiErrors.NewJSPedanticError(new InvalidOperationException("inactive_threshold must not be negative")); + config.InactiveThreshold = TimeSpan.Zero; + } + + if (config.PinnedTTL < TimeSpan.Zero) + { + if (pedantic) + return JsApiErrors.NewJSPedanticError(new InvalidOperationException("priority_timeout must not be negative")); + config.PinnedTTL = TimeSpan.Zero; + } + + if (config.AckWait == TimeSpan.Zero) + config.AckWait = DefaultAckWait; + + if (config.MaxAckPending == 0 && config.AckPolicy != AckPolicy.AckNone) + { + config.MaxAckPending = selectedLimits?.MaxAckPending > 0 + ? selectedLimits.MaxAckPending + : DefaultMaxAckPending; + } + + if (config.InactiveThreshold == TimeSpan.Zero && string.IsNullOrWhiteSpace(config.Durable)) + config.InactiveThreshold = DefaultDeleteWait; + + if (config.PinnedTTL == TimeSpan.Zero && config.PriorityPolicy == PriorityPolicy.PriorityPinnedClient) + config.PinnedTTL = DefaultPinnedTtl; + + if (config.Replicas == 0 || config.Replicas > streamReplicas) + config.Replicas = streamReplicas; + + if (!string.IsNullOrWhiteSpace(config.Name) && string.IsNullOrWhiteSpace(config.Durable)) + config.Durable = config.Name; + + return null; + } + + internal static JsApiError? CheckConsumerCfg( + ConsumerConfig config, + StreamConfig streamConfig, + JetStreamAccountLimits? selectedLimits, + bool isRecovering) + { + ArgumentNullException.ThrowIfNull(config); + ArgumentNullException.ThrowIfNull(streamConfig); + var streamReplicas = Math.Max(1, streamConfig.Replicas); + + if (!string.IsNullOrWhiteSpace(config.Durable) && + !string.IsNullOrWhiteSpace(config.Name) && + !string.Equals(config.Durable, config.Name, StringComparison.Ordinal)) + { + return JsApiErrors.NewJSConsumerCreateDurableAndNameMismatchError(); + } + + if (HasPathSeparators(config.Durable) || HasPathSeparators(config.Name)) + return JsApiErrors.NewJSConsumerNameContainsPathSeparatorsError(); + + if (config.Replicas > streamReplicas) + return JsApiErrors.NewJSConsumerReplicasExceedsStreamError(); + + if (!Enum.IsDefined(config.AckPolicy)) + return JsApiErrors.NewJSConsumerAckPolicyInvalidError(); + + if (!Enum.IsDefined(config.ReplayPolicy)) + return JsApiErrors.NewJSConsumerReplayPolicyInvalidError(); + + if (!Enum.IsDefined(config.DeliverPolicy)) + return JsApiErrors.NewJSConsumerInvalidPolicyError(new InvalidOperationException("deliver policy invalid")); + + if (config.FilterSubjects is { Length: > 0 } && !string.IsNullOrWhiteSpace(config.FilterSubject)) + return JsApiErrors.NewJSConsumerDuplicateFilterSubjectsError(); + + var filters = config.FilterSubjects is { Length: > 0 } + ? SubjectTokens.Subjects(config.FilterSubjects) + : (string.IsNullOrWhiteSpace(config.FilterSubject) ? [] : [config.FilterSubject]); + + for (var i = 0; i < filters.Length; i++) + { + if (string.IsNullOrWhiteSpace(filters[i])) + return JsApiErrors.NewJSConsumerEmptyFilterError(); + if (!SubscriptionIndex.IsValidSubject(filters[i])) + return JsApiErrors.NewJSConsumerFilterNotSubsetError(); + + for (var j = i + 1; j < filters.Length; j++) + { + if (SubscriptionIndex.SubjectsCollide(filters[i], filters[j])) + return JsApiErrors.NewJSConsumerOverlappingSubjectFiltersError(); + } + } + + var isPush = !string.IsNullOrWhiteSpace(config.DeliverSubject); + if (isPush) + { + if (!SubscriptionIndex.IsValidSubject(config.DeliverSubject!)) + return JsApiErrors.NewJSConsumerInvalidDeliverSubjectError(); + + if (SubscriptionIndex.SubjectHasWildcard(config.DeliverSubject!)) + return JsApiErrors.NewJSConsumerDeliverToWildcardsError(); + + if (config.MaxWaiting > 0) + return JsApiErrors.NewJSConsumerPushMaxWaitingError(); + } + else + { + if (config.RateLimit > 0) + return JsApiErrors.NewJSConsumerPullWithRateLimitError(); + } + + if (config.MaxAckPending > 0 && selectedLimits?.MaxAckPending > 0 && config.MaxAckPending > selectedLimits.MaxAckPending) + return JsApiErrors.NewJSConsumerMaxPendingAckExcessError(selectedLimits.MaxAckPending); + + if (streamConfig.Retention == RetentionPolicy.WorkQueuePolicy && config.AckPolicy != AckPolicy.AckExplicit) + return JsApiErrors.NewJSConsumerWQRequiresExplicitAckError(); + + if (config.Direct) + { + if (isPush) + return JsApiErrors.NewJSConsumerDirectRequiresPushError(); + if (!string.IsNullOrWhiteSpace(config.Durable)) + return JsApiErrors.NewJSConsumerDirectRequiresEphemeralError(); + } + + _ = isRecovering; + return null; + } + + internal void UpdateInactiveThreshold(ConsumerConfig config) + { + ArgumentNullException.ThrowIfNull(config); + + _mu.EnterWriteLock(); + try + { + _deleteThreshold = config.InactiveThreshold > TimeSpan.Zero + ? config.InactiveThreshold + : DefaultDeleteWait; + + Config.InactiveThreshold = _deleteThreshold; + } + finally + { + _mu.ExitWriteLock(); + } + } + + internal void UpdatePauseState(ConsumerConfig config, DateTime? nowUtc = null) + { + ArgumentNullException.ThrowIfNull(config); + var now = nowUtc ?? DateTime.UtcNow; + + _mu.EnterWriteLock(); + try + { + Config.PauseUntil = config.PauseUntil; + _isPaused = config.PauseUntil.HasValue && config.PauseUntil.Value > now; + } + finally + { + _mu.ExitWriteLock(); + } + } + + internal ConsumerAssignment? ConsumerAssignment() + { + _mu.EnterReadLock(); + try + { + return _assignment; + } + finally + { + _mu.ExitReadLock(); + } + } + + internal void SetConsumerAssignment(ConsumerAssignment? assignment) + { + _mu.EnterWriteLock(); + try + { + _assignment = assignment; + } + finally + { + _mu.ExitWriteLock(); + } + } + + private static bool HasPathSeparators(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return false; + + return value.Contains('/') || value.Contains('\\'); + } +} diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.cs b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.cs index 77b9b92..32062b9 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.cs @@ -19,7 +19,7 @@ namespace ZB.MOM.NatsNet.Server; /// Represents a JetStream consumer, managing message delivery, ack tracking, and lifecycle. /// Mirrors the consumer struct in server/consumer.go. /// -internal sealed class NatsConsumer : IDisposable +internal sealed partial class NatsConsumer : IDisposable { private readonly ReaderWriterLockSlim _mu = new(LockRecursionPolicy.SupportsRecursion); @@ -41,6 +41,8 @@ internal sealed class NatsConsumer : IDisposable private NatsStream? _streamRef; private ConsumerAssignment? _assignment; private DateTime _lostQuorumSent; + private TimeSpan _deleteThreshold; + private bool _isPaused; /// IRaftNode — stored as object to avoid cross-dependency on Raft session. private object? _node; @@ -320,7 +322,8 @@ internal sealed class NatsConsumer : IDisposable _state.AckFloor.Consumer = Math.Max(_state.AckFloor.Consumer, dseq); _state.AckFloor.Stream = Math.Max(_state.AckFloor.Stream, sseq); Interlocked.Exchange(ref AckFloor, (long)_state.AckFloor.Stream); - return null; + Exception? noError = null; + return noError; } finally { diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsStream.Consumers.cs b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsStream.Consumers.cs new file mode 100644 index 0000000..a1816d4 --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsStream.Consumers.cs @@ -0,0 +1,80 @@ +namespace ZB.MOM.NatsNet.Server; + +internal sealed partial class NatsStream +{ + internal (NatsConsumer? Consumer, Exception? Error) AddConsumerWithAction( + ConsumerConfig config, + string oname, + ConsumerAction action, + bool pedantic = false) => + AddConsumerWithAssignment(config, oname, null, isRecovering: false, action, pedantic); + + internal (NatsConsumer? Consumer, Exception? Error) AddConsumer( + ConsumerConfig config, + string oname, + bool pedantic = false) => + AddConsumerWithAssignment(config, oname, null, isRecovering: false, ConsumerAction.CreateOrUpdate, pedantic); + + internal (NatsConsumer? Consumer, Exception? Error) AddConsumerWithAssignment( + ConsumerConfig config, + string oname, + ConsumerAssignment? assignment, + bool isRecovering, + ConsumerAction action, + bool pedantic = false) + { + ArgumentNullException.ThrowIfNull(config); + + _mu.EnterWriteLock(); + try + { + if (_closed) + return (null, new InvalidOperationException("stream closed")); + + var name = !string.IsNullOrWhiteSpace(oname) + ? oname + : (!string.IsNullOrWhiteSpace(config.Name) ? config.Name! : (config.Durable ?? string.Empty)); + if (string.IsNullOrWhiteSpace(name)) + return (null, new InvalidOperationException("consumer name required")); + + config.Name = name; + config.Durable ??= name; + + var defaultsErr = NatsConsumer.SetConsumerConfigDefaults(config, Config, null, pedantic); + if (defaultsErr is not null) + return (null, new InvalidOperationException(defaultsErr.Description ?? "consumer defaults invalid")); + + var cfgErr = NatsConsumer.CheckConsumerCfg(config, Config, null, isRecovering); + if (cfgErr is not null) + return (null, new InvalidOperationException(cfgErr.Description ?? "consumer config invalid")); + + if (_consumers.TryGetValue(name, out var existing)) + { + if (action == ConsumerAction.Create) + return (null, new InvalidOperationException(JsApiErrors.NewJSConsumerAlreadyExistsError().Description ?? "consumer exists")); + + existing.UpdateConfig(config); + if (assignment is not null) + existing.SetConsumerAssignment(assignment); + return (existing, null); + } + + if (action == ConsumerAction.Update) + return (null, new InvalidOperationException(JsApiErrors.NewJSConsumerDoesNotExistError().Description ?? "consumer does not exist")); + + var consumer = NatsConsumer.Create(this, config, action, assignment); + if (consumer is null) + return (null, new InvalidOperationException("consumer create failed")); + + consumer.SetConsumerAssignment(assignment); + consumer.UpdateInactiveThreshold(config); + consumer.UpdatePauseState(config); + _consumers[name] = consumer; + return (consumer, null); + } + finally + { + _mu.ExitWriteLock(); + } + } +} diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsStream.cs b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsStream.cs index 5ad6a98..d75a776 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsStream.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsStream.cs @@ -52,6 +52,7 @@ internal sealed partial class NatsStream : IDisposable private ulong _clseq; private ulong _clfs; private readonly Dictionary _sources = new(StringComparer.Ordinal); + private readonly Dictionary _consumers = new(StringComparer.Ordinal); private StreamSourceInfo? _mirrorInfo; private Timer? _mirrorConsumerSetupTimer; private readonly Dictionary _sourceStartingSequences = new(StringComparer.Ordinal); diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/StoreTypes.ConsumerPolicies.cs b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/StoreTypes.ConsumerPolicies.cs new file mode 100644 index 0000000..62ab93e --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/StoreTypes.ConsumerPolicies.cs @@ -0,0 +1,82 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ZB.MOM.NatsNet.Server; + +internal static class ConsumerPolicyExtensions +{ + internal static string String(this PriorityPolicy policy) => + policy switch + { + PriorityPolicy.PriorityOverflow => "\"overflow\"", + PriorityPolicy.PriorityPinnedClient => "\"pinned_client\"", + PriorityPolicy.PriorityPrioritized => "\"prioritized\"", + _ => "\"none\"", + }; + + internal static string String(this DeliverPolicy policy) => + policy switch + { + DeliverPolicy.DeliverAll => "all", + DeliverPolicy.DeliverLast => "last", + DeliverPolicy.DeliverNew => "new", + DeliverPolicy.DeliverByStartSequence => "by_start_sequence", + DeliverPolicy.DeliverByStartTime => "by_start_time", + DeliverPolicy.DeliverLastPerSubject => "last_per_subject", + _ => "undefined", + }; + + internal static string String(this AckPolicy policy) => + policy switch + { + AckPolicy.AckNone => "none", + AckPolicy.AckAll => "all", + _ => "explicit", + }; + + internal static string String(this ReplayPolicy policy) => + policy switch + { + ReplayPolicy.ReplayInstant => "instant", + _ => "original", + }; +} + +public sealed class PriorityPolicyJsonConverter : JsonConverter +{ + public override PriorityPolicy Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.String) + throw new JsonException("can not unmarshal token"); + + return reader.GetString() switch + { + "none" => PriorityPolicy.PriorityNone, + "overflow" => PriorityPolicy.PriorityOverflow, + "pinned_client" => PriorityPolicy.PriorityPinnedClient, + "prioritized" => PriorityPolicy.PriorityPrioritized, + var value => throw new JsonException($"unknown priority policy: {value}"), + }; + } + + public override void Write(Utf8JsonWriter writer, PriorityPolicy value, JsonSerializerOptions options) + { + switch (value) + { + case PriorityPolicy.PriorityNone: + writer.WriteStringValue("none"); + break; + case PriorityPolicy.PriorityOverflow: + writer.WriteStringValue("overflow"); + break; + case PriorityPolicy.PriorityPinnedClient: + writer.WriteStringValue("pinned_client"); + break; + case PriorityPolicy.PriorityPrioritized: + writer.WriteStringValue("prioritized"); + break; + default: + throw new JsonException($"unknown priority policy: {value}"); + } + } +} diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/StoreTypes.cs b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/StoreTypes.cs index 03eb1d7..439ad7d 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/StoreTypes.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/StoreTypes.cs @@ -615,6 +615,7 @@ public enum DeliverPolicy // --------------------------------------------------------------------------- /// Policy for selecting messages based on priority. +[JsonConverter(typeof(PriorityPolicyJsonConverter))] public enum PriorityPolicy { PriorityNone = 0, diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/StreamTypes.ConsumerLifecycle.cs b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/StreamTypes.ConsumerLifecycle.cs new file mode 100644 index 0000000..e3c40cd --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/StreamTypes.ConsumerLifecycle.cs @@ -0,0 +1,51 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ZB.MOM.NatsNet.Server; + +internal static class ConsumerActionExtensions +{ + internal static string String(this ConsumerAction action) => + action switch + { + ConsumerAction.CreateOrUpdate => "\"create_or_update\"", + ConsumerAction.Create => "\"create\"", + ConsumerAction.Update => "\"update\"", + _ => "\"create_or_update\"", + }; +} + +public sealed class ConsumerActionJsonConverter : JsonConverter +{ + public override ConsumerAction Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.String) + throw new JsonException("can not unmarshal token"); + + return reader.GetString() switch + { + "create" => ConsumerAction.Create, + "update" => ConsumerAction.Update, + "create_or_update" => ConsumerAction.CreateOrUpdate, + var value => throw new JsonException($"unknown consumer action: {value}"), + }; + } + + public override void Write(Utf8JsonWriter writer, ConsumerAction value, JsonSerializerOptions options) + { + switch (value) + { + case ConsumerAction.Create: + writer.WriteStringValue("create"); + break; + case ConsumerAction.Update: + writer.WriteStringValue("update"); + break; + case ConsumerAction.CreateOrUpdate: + writer.WriteStringValue("create_or_update"); + break; + default: + throw new JsonException($"can not marshal {value}"); + } + } +} diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/StreamTypes.cs b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/StreamTypes.cs index c63e5dd..d3b1952 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/StreamTypes.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/StreamTypes.cs @@ -395,6 +395,7 @@ public sealed class CreateConsumerRequest /// Specifies the intended action when creating a consumer. /// Mirrors ConsumerAction in server/consumer.go. /// +[JsonConverter(typeof(ConsumerActionJsonConverter))] public enum ConsumerAction { /// Create a new consumer or update if it already exists. diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/ConsumerPoliciesTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/ConsumerPoliciesTests.cs new file mode 100644 index 0000000..7a3e2f1 --- /dev/null +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/ConsumerPoliciesTests.cs @@ -0,0 +1,133 @@ +using System.Text.Json; +using Shouldly; +using ZB.MOM.NatsNet.Server; + +namespace ZB.MOM.NatsNet.Server.Tests.JetStream; + +public sealed class ConsumerPoliciesTests +{ + [Fact] + public void ConsumerAction_StringAndJsonParity_ShouldMatchGo() + { + ConsumerAction.CreateOrUpdate.String().ShouldBe("\"create_or_update\""); + ConsumerAction.Create.String().ShouldBe("\"create\""); + ConsumerAction.Update.String().ShouldBe("\"update\""); + + JsonSerializer.Serialize(ConsumerAction.Create).ShouldBe("\"create\""); + JsonSerializer.Deserialize("\"update\"").ShouldBe(ConsumerAction.Update); + Should.Throw(() => JsonSerializer.Deserialize("\"bogus\"")); + } + + [Fact] + public void PriorityPolicy_StringAndJsonParity_ShouldMatchGo() + { + PriorityPolicy.PriorityNone.String().ShouldBe("\"none\""); + PriorityPolicy.PriorityOverflow.String().ShouldBe("\"overflow\""); + PriorityPolicy.PriorityPinnedClient.String().ShouldBe("\"pinned_client\""); + PriorityPolicy.PriorityPrioritized.String().ShouldBe("\"prioritized\""); + + JsonSerializer.Serialize(PriorityPolicy.PriorityPinnedClient).ShouldBe("\"pinned_client\""); + JsonSerializer.Deserialize("\"prioritized\"").ShouldBe(PriorityPolicy.PriorityPrioritized); + Should.Throw(() => JsonSerializer.Deserialize("\"none-ish\"")); + } + + [Fact] + public void ConsumerPolicies_StringParity_ShouldMatchGo() + { + DeliverPolicy.DeliverByStartSequence.String().ShouldBe("by_start_sequence"); + AckPolicy.AckExplicit.String().ShouldBe("explicit"); + ReplayPolicy.ReplayInstant.String().ShouldBe("instant"); + } + + [Fact] + public void SubjectTokens_Subjects_RemovesEmptyValues() + { + var subjects = SubjectTokens.Subjects(new[] { "foo.*", string.Empty, " ", "bar.>" }); + subjects.ShouldBe(["foo.*", "bar.>"]); + } + + [Fact] + public void SetConsumerConfigDefaults_InvalidNegativesInPedanticMode_ReturnsError() + { + var cfg = new ConsumerConfig { MaxDeliver = -2 }; + var streamCfg = new StreamConfig { Name = "ORDERS", Replicas = 3 }; + + var err = NatsConsumer.SetConsumerConfigDefaults(cfg, streamCfg, null, pedantic: true); + + err.ShouldNotBeNull(); + cfg.MaxDeliver.ShouldBe(-2); + } + + [Fact] + public void SetConsumerConfigDefaults_AppliesGoDefaults_ShouldPopulateExpectedValues() + { + var cfg = new ConsumerConfig + { + Durable = "D", + MaxDeliver = 0, + AckPolicy = AckPolicy.AckExplicit, + Replicas = 0, + }; + var streamCfg = new StreamConfig { Name = "ORDERS", Replicas = 3 }; + var limits = new JetStreamAccountLimits { MaxAckPending = 2500 }; + + var err = NatsConsumer.SetConsumerConfigDefaults(cfg, streamCfg, limits, pedantic: false); + + err.ShouldBeNull(); + cfg.MaxDeliver.ShouldBe(-1); + cfg.AckWait.ShouldBe(TimeSpan.FromSeconds(30)); + cfg.MaxAckPending.ShouldBe(2500); + cfg.Replicas.ShouldBe(3); + } + + [Fact] + public void CheckConsumerCfg_DurableNameMismatch_ReturnsError() + { + var cfg = new ConsumerConfig { Name = "A", Durable = "B", AckPolicy = AckPolicy.AckExplicit }; + var streamCfg = new StreamConfig { Name = "ORDERS", Replicas = 1 }; + + var err = NatsConsumer.CheckConsumerCfg(cfg, streamCfg, null, isRecovering: false); + + err.ShouldNotBeNull(); + err.ErrCode.ShouldBe(JsApiErrors.ConsumerCreateDurableAndNameMismatch.ErrCode); + } + + [Fact] + public void CheckConsumerCfg_OverlappingFilterSubjects_ReturnsError() + { + var cfg = new ConsumerConfig + { + Durable = "D", + AckPolicy = AckPolicy.AckExplicit, + FilterSubjects = ["orders.*", "orders.created"], + }; + var streamCfg = new StreamConfig { Name = "ORDERS", Replicas = 1 }; + + var err = NatsConsumer.CheckConsumerCfg(cfg, streamCfg, null, isRecovering: false); + + err.ShouldNotBeNull(); + err.ErrCode.ShouldBe(JsApiErrors.ConsumerOverlappingSubjectFilters.ErrCode); + } + + [Fact] + public void CheckConsumerCfg_WithValidPullConfig_ReturnsNull() + { + var cfg = new ConsumerConfig + { + Durable = "D", + AckPolicy = AckPolicy.AckExplicit, + FilterSubject = "orders.created", + }; + var streamCfg = new StreamConfig + { + Name = "ORDERS", + Replicas = 1, + Retention = RetentionPolicy.LimitsPolicy, + Subjects = ["orders.>"], + }; + + var err = NatsConsumer.CheckConsumerCfg(cfg, streamCfg, null, isRecovering: false); + + err.ShouldBeNull(); + } +} diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/NatsConsumerTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/NatsConsumerTests.cs index 611e398..c7ccf81 100644 --- a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/NatsConsumerTests.cs +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/NatsConsumerTests.cs @@ -127,4 +127,89 @@ public sealed class NatsConsumerTests q.Peek()!.Reply.ShouldBe("2a"); q.Peek()!.N.ShouldBe(3); } + + [Fact] + public void AddConsumerWithAction_CreateThenUpdate_ShouldRespectActions() + { + var account = new Account { Name = "A" }; + var streamCfg = new StreamConfig { Name = "S", Subjects = ["foo"], Retention = RetentionPolicy.LimitsPolicy }; + var stream = NatsStream.Create(account, streamCfg, null, null, null, null); + stream.ShouldNotBeNull(); + + var cfg = new ConsumerConfig { Durable = "D", AckPolicy = AckPolicy.AckExplicit }; + var (created, createErr) = stream!.AddConsumerWithAction(cfg, "D", ConsumerAction.Create, pedantic: false); + createErr.ShouldBeNull(); + created.ShouldNotBeNull(); + + var updateCfg = new ConsumerConfig { Durable = "D", AckPolicy = AckPolicy.AckAll }; + var (updated, updateErr) = stream.AddConsumerWithAction(updateCfg, "D", ConsumerAction.Update, pedantic: false); + updateErr.ShouldBeNull(); + updated.ShouldNotBeNull(); + updated!.GetConfig().AckPolicy.ShouldBe(AckPolicy.AckAll); + } + + [Fact] + public void AddConsumer_WithAssignment_ShouldAttachAssignment() + { + var account = new Account { Name = "A" }; + var streamCfg = new StreamConfig { Name = "S", Subjects = ["foo"], Retention = RetentionPolicy.LimitsPolicy }; + var stream = NatsStream.Create(account, streamCfg, null, null, null, null); + stream.ShouldNotBeNull(); + + var assignment = new ConsumerAssignment + { + Name = "D", + Stream = "S", + Group = new RaftGroup { Name = "RG", Peers = ["N1"] }, + }; + var cfg = new ConsumerConfig { Durable = "D", AckPolicy = AckPolicy.AckExplicit }; + + var (consumer, err) = stream!.AddConsumerWithAssignment(cfg, "D", assignment, isRecovering: false, ConsumerAction.Create, pedantic: false); + err.ShouldBeNull(); + consumer.ShouldNotBeNull(); + consumer!.ConsumerAssignment().ShouldBeSameAs(assignment); + } + + [Fact] + public void UpdateInactiveThreshold_AndPauseState_ShouldTrackConfigValues() + { + var account = new Account { Name = "A" }; + var streamCfg = new StreamConfig { Name = "S", Subjects = ["foo"], Retention = RetentionPolicy.LimitsPolicy }; + var stream = NatsStream.Create(account, streamCfg, null, null, null, null); + stream.ShouldNotBeNull(); + + var cfg = new ConsumerConfig { Durable = "D", AckPolicy = AckPolicy.AckExplicit }; + var consumer = NatsConsumer.Create(stream!, cfg, ConsumerAction.Create, null); + consumer.ShouldNotBeNull(); + + consumer!.UpdateInactiveThreshold(new ConsumerConfig { InactiveThreshold = TimeSpan.FromSeconds(30) }); + consumer.GetConfig().InactiveThreshold.ShouldBe(TimeSpan.FromSeconds(30)); + + var pauseUntil = DateTime.UtcNow.AddMinutes(1); + consumer.UpdatePauseState(new ConsumerConfig { PauseUntil = pauseUntil }); + consumer.GetConfig().PauseUntil.ShouldBe(pauseUntil); + } + + [Fact] + public void ConsumerAssignment_GetSet_ShouldRoundTrip() + { + var account = new Account { Name = "A" }; + var streamCfg = new StreamConfig { Name = "S", Subjects = ["foo"], Retention = RetentionPolicy.LimitsPolicy }; + var stream = NatsStream.Create(account, streamCfg, null, null, null, null); + stream.ShouldNotBeNull(); + + var cfg = new ConsumerConfig { Durable = "D", AckPolicy = AckPolicy.AckExplicit }; + var consumer = NatsConsumer.Create(stream!, cfg, ConsumerAction.Create, null); + consumer.ShouldNotBeNull(); + + var assignment = new ConsumerAssignment + { + Name = "D", + Stream = "S", + Group = new RaftGroup { Name = "RG", Peers = ["N1"] }, + }; + + consumer!.SetConsumerAssignment(assignment); + consumer.ConsumerAssignment().ShouldBeSameAs(assignment); + } } diff --git a/porting.db b/porting.db index 7a4e86edcdb0fdedb7c1b7863a9cbaa49ad7ea35..94b683634876f2f4a07e0dade43ef79afc4041ad 100644 GIT binary patch delta 4460 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 RrI7wfU4(J^zjZ6-{vS)xaf$!{ From 804bc89246ed504c8c4fa771cbc5e29edd4ffe23 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 1 Mar 2026 00:14:58 -0500 Subject: [PATCH 03/12] task3: implement batch38 group B lifecycle and advisories --- .../JetStream/NatsConsumer.Advisories.cs | 44 +++++ .../JetStream/NatsConsumer.Lifecycle.cs | 150 ++++++++++++++++++ .../JetStream/NatsConsumerTests.cs | 84 ++++++++++ porting.db | Bin 6758400 -> 6758400 bytes 4 files changed, 278 insertions(+) create mode 100644 dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.Advisories.cs create mode 100644 dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.Lifecycle.cs diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.Advisories.cs b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.Advisories.cs new file mode 100644 index 0000000..71e6678 --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.Advisories.cs @@ -0,0 +1,44 @@ +using System.Text; + +namespace ZB.MOM.NatsNet.Server; + +internal sealed partial class NatsConsumer +{ + private string? _lastAdvisorySubject; + private byte[]? _lastAdvisoryPayload; + private DateTime _lastAdvisorySent; + + internal bool SendAdvisory(string subject, object advisory) + { + if (string.IsNullOrWhiteSpace(subject) || advisory is null) + return false; + + _mu.EnterWriteLock(); + try + { + _lastAdvisorySubject = subject; + _lastAdvisoryPayload = Encoding.UTF8.GetBytes(advisory.ToString() ?? string.Empty); + _lastAdvisorySent = DateTime.UtcNow; + return true; + } + finally + { + _mu.ExitWriteLock(); + } + } + + internal bool SendDeleteAdvisoryLocked() => + SendAdvisory($"{JsApiSubjects.JsAdvisoryConsumerDeleted}.{Stream}.{Name}", new { action = "delete" }); + + internal bool SendPinnedAdvisoryLocked(string pinId) => + SendAdvisory($"{JsApiSubjects.JsAdvisoryConsumerPinned}.{Stream}.{Name}", new { pin = pinId }); + + internal bool SendUnpinnedAdvisoryLocked(string pinId) => + SendAdvisory($"{JsApiSubjects.JsAdvisoryConsumerUnpinned}.{Stream}.{Name}", new { pin = pinId }); + + internal bool SendCreateAdvisory() => + SendAdvisory($"{JsApiSubjects.JsAdvisoryConsumerCreated}.{Stream}.{Name}", new { action = "create" }); + + internal bool SendPauseAdvisoryLocked(DateTime pauseUntil) => + SendAdvisory($"{JsApiSubjects.JsAdvisoryConsumerPause}.{Stream}.{Name}", new { pauseUntil }); +} diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.Lifecycle.cs b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.Lifecycle.cs new file mode 100644 index 0000000..75256d5 --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.Lifecycle.cs @@ -0,0 +1,150 @@ +using System.Threading.Channels; + +namespace ZB.MOM.NatsNet.Server; + +internal sealed partial class NatsConsumer +{ + private readonly HashSet _internalSubscriptions = new(StringComparer.Ordinal); + private readonly Channel _updateChannel = Channel.CreateBounded(4); + private Channel? _monitorQuitChannel = Channel.CreateBounded(1); + + internal ChannelReader? MonitorQuitC() + { + _mu.EnterReadLock(); + try + { + return _monitorQuitChannel?.Reader; + } + finally + { + _mu.ExitReadLock(); + } + } + + internal void SignalMonitorQuit() + { + _mu.EnterWriteLock(); + try + { + var channel = _monitorQuitChannel; + if (channel is null) + return; + + channel.Writer.TryWrite(true); + channel.Writer.TryComplete(); + _monitorQuitChannel = null; + } + finally + { + _mu.ExitWriteLock(); + } + } + + internal ChannelReader UpdateC() => _updateChannel.Reader; + + internal bool CheckQueueInterest(string? queue = null) + { + _mu.EnterReadLock(); + try + { + if (_closed) + return false; + + if (_internalSubscriptions.Count > 0) + return true; + + return !string.IsNullOrWhiteSpace(queue) && _internalSubscriptions.Contains(queue); + } + finally + { + _mu.ExitReadLock(); + } + } + + internal void ClearNode() => ClearRaftNode(); + + internal bool IsLeaderInternal() => IsLeader(); + + internal ConsumerInfo? HandleClusterConsumerInfoRequest() => + IsLeader() && !_closed ? GetInfo() : null; + + internal bool SubscribeInternal(string subject) + { + if (string.IsNullOrWhiteSpace(subject)) + return false; + + _mu.EnterWriteLock(); + try + { + var added = _internalSubscriptions.Add(subject); + if (added) + _updateChannel.Writer.TryWrite(true); + return added; + } + finally + { + _mu.ExitWriteLock(); + } + } + + internal bool Unsubscribe(string subject) + { + if (string.IsNullOrWhiteSpace(subject)) + return false; + + _mu.EnterWriteLock(); + try + { + var removed = _internalSubscriptions.Remove(subject); + if (removed) + _updateChannel.Writer.TryWrite(true); + return removed; + } + finally + { + _mu.ExitWriteLock(); + } + } + + internal DateTime CreatedTime() + { + _mu.EnterReadLock(); + try + { + return Created; + } + finally + { + _mu.ExitReadLock(); + } + } + + internal void SetCreatedTime(DateTime created) + { + _mu.EnterWriteLock(); + try + { + Created = created; + } + finally + { + _mu.ExitWriteLock(); + } + } + + internal bool HasDeliveryInterest() + { + _mu.EnterReadLock(); + try + { + if (_closed || string.IsNullOrWhiteSpace(Config.DeliverSubject)) + return false; + + return _internalSubscriptions.Contains(Config.DeliverSubject!); + } + finally + { + _mu.ExitReadLock(); + } + } +} diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/NatsConsumerTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/NatsConsumerTests.cs index c7ccf81..198002e 100644 --- a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/NatsConsumerTests.cs +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/NatsConsumerTests.cs @@ -212,4 +212,88 @@ public sealed class NatsConsumerTests consumer!.SetConsumerAssignment(assignment); consumer.ConsumerAssignment().ShouldBeSameAs(assignment); } + + [Fact] + public async Task MonitorQuitC_AndSignalMonitorQuit_ShouldPublishQuitSignal() + { + var account = new Account { Name = "A" }; + var streamCfg = new StreamConfig { Name = "S", Subjects = ["foo"] }; + var stream = NatsStream.Create(account, streamCfg, null, null, null, null); + stream.ShouldNotBeNull(); + + var consumer = NatsConsumer.Create(stream!, new ConsumerConfig { Durable = "D" }, ConsumerAction.Create, null); + consumer.ShouldNotBeNull(); + + var monitor = consumer!.MonitorQuitC(); + monitor.ShouldNotBeNull(); + monitor!.TryRead(out _).ShouldBeFalse(); + + consumer.SignalMonitorQuit(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + var signal = await monitor.ReadAsync(cts.Token); + signal.ShouldBeTrue(); + } + + [Fact] + public void SubscribeInternal_Unsubscribe_AndHasDeliveryInterest_ShouldTrackState() + { + var account = new Account { Name = "A" }; + var streamCfg = new StreamConfig { Name = "S", Subjects = ["foo"] }; + var stream = NatsStream.Create(account, streamCfg, null, null, null, null); + stream.ShouldNotBeNull(); + + var consumer = NatsConsumer.Create( + stream!, + new ConsumerConfig { Durable = "D", DeliverSubject = "deliver.foo" }, + ConsumerAction.Create, + null); + consumer.ShouldNotBeNull(); + + consumer!.HasDeliveryInterest().ShouldBeFalse(); + consumer.SubscribeInternal("deliver.foo").ShouldBeTrue(); + consumer.CheckQueueInterest("deliver.foo").ShouldBeTrue(); + consumer.HasDeliveryInterest().ShouldBeTrue(); + consumer.Unsubscribe("deliver.foo").ShouldBeTrue(); + consumer.HasDeliveryInterest().ShouldBeFalse(); + } + + [Fact] + public void AdvisoryHelpers_AndCreatedTime_ShouldBehave() + { + var account = new Account { Name = "A" }; + var streamCfg = new StreamConfig { Name = "S", Subjects = ["foo"] }; + var stream = NatsStream.Create(account, streamCfg, null, null, null, null); + stream.ShouldNotBeNull(); + + var consumer = NatsConsumer.Create(stream!, new ConsumerConfig { Durable = "D" }, ConsumerAction.Create, null); + consumer.ShouldNotBeNull(); + + consumer!.SendCreateAdvisory().ShouldBeTrue(); + consumer.SendDeleteAdvisoryLocked().ShouldBeTrue(); + consumer.SendPinnedAdvisoryLocked("pin-1").ShouldBeTrue(); + consumer.SendUnpinnedAdvisoryLocked("pin-1").ShouldBeTrue(); + consumer.SendPauseAdvisoryLocked(DateTime.UtcNow.AddMinutes(1)).ShouldBeTrue(); + + var created = DateTime.UtcNow.AddHours(-1); + consumer.SetCreatedTime(created); + consumer.CreatedTime().ShouldBe(created); + } + + [Fact] + public void HandleClusterConsumerInfoRequest_WhenLeader_ReturnsInfo() + { + var account = new Account { Name = "A" }; + var streamCfg = new StreamConfig { Name = "S", Subjects = ["foo"] }; + var stream = NatsStream.Create(account, streamCfg, null, null, null, null); + stream.ShouldNotBeNull(); + + var consumer = NatsConsumer.Create(stream!, new ConsumerConfig { Durable = "D" }, ConsumerAction.Create, null); + consumer.ShouldNotBeNull(); + + consumer!.HandleClusterConsumerInfoRequest().ShouldBeNull(); + consumer.SetLeader(true, 1); + consumer.IsLeaderInternal().ShouldBeTrue(); + consumer.HandleClusterConsumerInfoRequest().ShouldNotBeNull(); + consumer.ClearNode(); + } } diff --git a/porting.db b/porting.db index 94b683634876f2f4a07e0dade43ef79afc4041ad..e3a156e7a4fe0befaf58a32653e0b6a0c55d5c09 100644 GIT binary patch delta 3184 zcmZ`)3s6+&6~6z!ci(sKT^4!YML;Ax=6(=fD3n&s}{$0qsk2-9+eOz{f zv(eer#j-atCMx6i(I7fxtl;WAj$@3;9@fE_=wa>d8a%9x`4T;>)hn*W9S0*jr4Z%A z|Db9uFD+;qVCbJH6XxcStUUV=m7|@~0vh)ua4`11haF+;Z4awqY{V&hSr+Zs{ zcTRR%chB*;tRE~%)6p%E1|Gc=^4CWc;B(NTu_ zRn*APNfkL6>QGSwLk%jbW9YDo91NAI=maJ%@>GArCM-!sRUUNF962-S^hsWuq>f$N3zG*o8l&vRAVIF#QX0>>VigCX#+ z@F(d1iQYbR%`6O63W@II?!Sop;JzeR6--135pbYV$b$a!nrN6GGZ^4XrLfhj!rn6B zIpf)Tl>##hGf}x$DMh{!`Clw@VwJc@|D||RY{6Hx$=XP*Rg3xi{5}2_OpG)HE0I=w z4Z%_X{taZ?0xU4IC|Q&V8#Z8Zee3z%@Wn%EKm5(@E)2rwF?2qV!WA5fLv&C+=g5aw z9!Pd*U6e$WtB^?Ci(FP$fXn6zbZv44xq@9Gu25H)E8G=vMMe|x{L8F!UvrW}EVMKU zKQn(~PC*}XUz>`I#fFpm3%ct%vo=>Vjv@NJu-^}qnW6V%xeLzkHL1OVph;P6*6Ng? zWK#YW6vW!qDVkt1(74OvJNk)y76zl4&vDgYgwUIE-1`1O@lE*{3?9-fr~b!HIo9nC zUYP@%@%sTBDz&8Bb1@2nEmLwOEHs6MxrYX4{$=Ljohf-U-&f*l18X*$SLT#l3|*Jm zywvk9PRXfWgWct7h4g821ZX~$^I_jXAz?YqV4x<&Q}l&T<<51!f?LZ;JpNO+r&;@%x_y-En;q3PILPMR=4YU*HN~LJNlBK{JImEHE`AZ}IXkO~@DF(rx+3@*vb@E%{hBK+fkrnJ(Orr=avlne=~+2^eGWopLhXb`61thtC_X0+@ zpYz+@@B8oL+wJasG0?5_T!DQ;(~K@n(`G5L=VvTrCtho*E@OLs_)?koQW>Xg+Vob9GalWsuYkT|lwpM*9Rt&ODTzHro zWW^#^+28Sbe;QwU&6#tX(%#-m1u z(PA`l=~WH03Wi5+upi*!`Jwlw>7%WAUd6C@o8|M5Iq~!SkGv;1 zJ6ITu1-bJ*Uc)Q+OdP!--q7_v&B5LO-kRs7M?{$Gp?_&fOw47?IJqchc{R7h&$#a7 zeHFw%6B7-#I}@?fk2o|cI#|zSuFuUk!{wTIUQBg1+b>ubEXQ0Pbt9+4pNGpm_CB8& z)}L7X;vU@E%*T24>-k?MJibwtX~Ukq-%aujiW;)?kfNF_tyff)r3V$w%hCgiW@Krd zqQWe#RWv?JYZOJZ)D#I!;8|)=eZKQ*MZWVYMap@Bns}u`-@0CrZ(XOzw_c&hw_dKu zw_fJ`wS(WAh^me+KQDTe9~3xk2jA5b<9h5DdM}TKPny>=<0YPz<}D06%O}nAe%dH% zqX)0|bESZit`t-BOccV+u~bV4ApgrB#ZtPTldIUTNiVJakIl zjb~R|vyk=%>{NHyC_bR^huLpfk#@kjZ4X)l=0784;U9V;MNuyPqodHibT5GWDdSnU?zZMQ-Ws@~MJ8*Dlqorgk zIo{Al5kJg6!{A9?t4B+sle~Q=`Lk?B=*;&0);Q6X^un|uvc+!7Ag7kSs- z*QK2yd?_aOU5|1j%l^w?nq7net#t9;FG`SkL#*J5-qB2gq%QkwaPV)J<*qvh9(h+L{M6O% zc6_u?7U9`<Av-)t)lPht+?O4sN-ooIA>6n z_`zET)%N_uUTWpyl|h-G%>a@>aiBt=BB04YQ-G!dO#>Hq!9?*QCdx6S<762^-ssOqV=zgF|pemq6K;HoRCQvodVxVsUEdg2z z^lhLTpjx11K+Azv0M!B21FZyF1+*Hd0jLqE31|(_TA+164*)#~v>xaopbbD9fi?kc Z25JV{0@MPu6(|YxFi Date: Sun, 1 Mar 2026 00:30:22 -0500 Subject: [PATCH 04/12] task4: implement batch38 group C ack and delivery state --- .../Accounts/Account.ConsumerConfig.cs | 47 +++ .../JetStream/NatsConsumer.Acks.cs | 191 ++++++++++ .../JetStream/NatsConsumer.Lifecycle.cs | 13 +- .../JetStream/NatsConsumer.State.cs | 342 ++++++++++++++++++ .../JetStream/NatsConsumer.cs | 5 + .../JetStream/StreamTypes.cs | 10 + .../JetStream/NatsConsumerTests.cs | 145 ++++++++ porting.db | Bin 6758400 -> 6762496 bytes 8 files changed, 741 insertions(+), 12 deletions(-) create mode 100644 dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.ConsumerConfig.cs create mode 100644 dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.Acks.cs create mode 100644 dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.State.cs diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.ConsumerConfig.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.ConsumerConfig.cs new file mode 100644 index 0000000..5d8a198 --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.ConsumerConfig.cs @@ -0,0 +1,47 @@ +namespace ZB.MOM.NatsNet.Server; + +public sealed partial class Account +{ + internal Exception? CheckNewConsumerConfig(ConsumerConfig current, ConsumerConfig next) + { + ArgumentNullException.ThrowIfNull(current); + ArgumentNullException.ThrowIfNull(next); + + if (NatsConsumer.ConfigsEqualSansDelivery(current, next) && + string.Equals(current.DeliverSubject, next.DeliverSubject, StringComparison.Ordinal)) + { + return null; + } + + if (current.DeliverPolicy != next.DeliverPolicy) + return new InvalidOperationException("deliver policy can not be updated"); + if (current.OptStartSeq != next.OptStartSeq) + return new InvalidOperationException("start sequence can not be updated"); + if (current.OptStartTime != next.OptStartTime) + return new InvalidOperationException("start time can not be updated"); + if (current.AckPolicy != next.AckPolicy) + return new InvalidOperationException("ack policy can not be updated"); + if (current.ReplayPolicy != next.ReplayPolicy) + return new InvalidOperationException("replay policy can not be updated"); + if (current.Heartbeat != next.Heartbeat) + return new InvalidOperationException("heart beats can not be updated"); + if (current.FlowControl != next.FlowControl) + return new InvalidOperationException("flow control can not be updated"); + + if (!string.Equals(current.DeliverSubject, next.DeliverSubject, StringComparison.Ordinal)) + { + if (string.IsNullOrWhiteSpace(current.DeliverSubject)) + return new InvalidOperationException("can not update pull consumer to push based"); + if (string.IsNullOrWhiteSpace(next.DeliverSubject)) + return new InvalidOperationException("can not update push consumer to pull based"); + } + + if (current.MaxWaiting != next.MaxWaiting) + return new InvalidOperationException("max waiting can not be updated"); + + if (next.BackOff is { Length: > 0 } && next.MaxDeliver != -1 && next.BackOff.Length > next.MaxDeliver) + return new InvalidOperationException(JsApiErrors.NewJSConsumerMaxDeliverBackoffError().Description ?? "max deliver backoff invalid"); + + return null; + } +} diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.Acks.cs b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.Acks.cs new file mode 100644 index 0000000..f7cea2b --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.Acks.cs @@ -0,0 +1,191 @@ +namespace ZB.MOM.NatsNet.Server; + +internal sealed partial class NatsConsumer +{ + private static readonly byte[] AckAck = "+ACK"u8.ToArray(); + private static readonly byte[] AckOk = "+OK"u8.ToArray(); + private static readonly byte[] AckNak = "-NAK"u8.ToArray(); + private static readonly byte[] AckProgress = "+WPI"u8.ToArray(); + private static readonly byte[] AckNext = "+NXT"u8.ToArray(); + + private readonly Queue _ackQueue = new(); + private string? _lastAckReplySubject; + + internal void SendAckReply(string subject) + { + if (string.IsNullOrWhiteSpace(subject)) + return; + + _mu.EnterWriteLock(); + try + { + _lastAckReplySubject = subject; + } + finally + { + _mu.ExitWriteLock(); + } + } + + internal static JsAckMsg NewJSAckMsg(string subject, string reply, int headerBytes, byte[] msg) + => new() + { + Subject = subject, + Reply = reply, + HeaderBytes = headerBytes, + Msg = msg, + }; + + internal void PushAck(string subject, string reply, int headerBytes, byte[] rawMessage) + { + ArgumentNullException.ThrowIfNull(rawMessage); + _mu.EnterWriteLock(); + try + { + _ackQueue.Enqueue(NewJSAckMsg(subject, reply, headerBytes, (byte[])rawMessage.Clone())); + } + finally + { + _mu.ExitWriteLock(); + } + } + + internal void ProcessAck(string subject, string reply, int headerBytes, byte[] rawMessage) + { + ArgumentNullException.ThrowIfNull(rawMessage); + + var msg = headerBytes > 0 && headerBytes <= rawMessage.Length + ? rawMessage[headerBytes..] + : rawMessage; + + var (streamSeq, deliverySeq, deliveryCount) = AckReplyInfo(subject); + var skipAckReply = streamSeq == 0; + + if (msg.Length == 0 || msg.SequenceEqual(AckAck) || msg.SequenceEqual(AckOk)) + { + ProcessAckMessage(streamSeq, deliverySeq, deliveryCount, reply); + } + else if (StartsWith(msg, AckNext)) + { + ProcessAckMessage(streamSeq, deliverySeq, deliveryCount, string.Empty); + skipAckReply = true; + } + else if (StartsWith(msg, AckNak)) + { + _state.Redelivered ??= new Dictionary(); + _state.Redelivered[streamSeq] = _state.Redelivered.TryGetValue(streamSeq, out var redeliveries) + ? redeliveries + 1 + : 1UL; + skipAckReply = true; + } + else if (msg.SequenceEqual(AckProgress)) + { + ProgressUpdate(streamSeq); + } + + if (!string.IsNullOrWhiteSpace(reply) && !skipAckReply) + SendAckReply(reply); + } + + internal void ProgressUpdate(ulong sequence) + { + _mu.EnterWriteLock(); + try + { + _state.Pending ??= new Dictionary(); + if (_state.Pending.TryGetValue(sequence, out var pending)) + { + pending.Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + _state.Pending[sequence] = pending; + } + } + finally + { + _mu.ExitWriteLock(); + } + } + + internal void UpdateSkipped(ulong sequence) + { + _mu.EnterWriteLock(); + try + { + _state.AckFloor.Stream = Math.Max(_state.AckFloor.Stream, sequence > 0 ? sequence - 1 : 0); + _state.AckFloor.Consumer = Math.Max(_state.AckFloor.Consumer, sequence > 0 ? sequence - 1 : 0); + _updateChannel.Writer.TryWrite(true); + } + finally + { + _mu.ExitWriteLock(); + } + } + + private void ProcessAckMessage(ulong streamSeq, ulong deliverySeq, ulong deliveryCount, string reply) + { + _mu.EnterWriteLock(); + try + { + _state.Pending ??= new Dictionary(); + _state.Pending.Remove(streamSeq); + _state.AckFloor.Stream = Math.Max(_state.AckFloor.Stream, streamSeq); + _state.AckFloor.Consumer = Math.Max(_state.AckFloor.Consumer, deliverySeq); + _state.Delivered.Stream = Math.Max(_state.Delivered.Stream, streamSeq); + _state.Delivered.Consumer = Math.Max(_state.Delivered.Consumer, deliverySeq); + + _state.Redelivered ??= new Dictionary(); + if (deliveryCount > 1) + _state.Redelivered[streamSeq] = deliveryCount; + + if (!string.IsNullOrWhiteSpace(reply)) + _lastAckReplySubject = reply; + } + finally + { + _mu.ExitWriteLock(); + } + } + + private static (ulong StreamSequence, ulong DeliverySequence, ulong DeliveryCount) AckReplyInfo(string subject) + { + if (string.IsNullOrWhiteSpace(subject)) + return (0, 0, 0); + + var numbers = subject + .Split('.', StringSplitOptions.RemoveEmptyEntries) + .Select(static token => ulong.TryParse(token, out var value) ? (ulong?)value : null) + .Where(static value => value.HasValue) + .Select(static value => value!.Value) + .ToArray(); + + if (numbers.Length >= 3) + return (numbers[^2], numbers[^3], numbers[^1]); + if (numbers.Length == 2) + return (numbers[1], numbers[0], 1); + if (numbers.Length == 1) + return (numbers[0], numbers[0], 1); + + return (0, 0, 0); + } + + private static bool StartsWith(byte[] message, byte[] prefix) + { + if (message.Length < prefix.Length) + return false; + + for (var i = 0; i < prefix.Length; i++) + { + if (message[i] != prefix[i]) + return false; + } + + return true; + } +} + +internal sealed class JsAckMsg +{ + internal string Subject { get; set; } = string.Empty; + internal string Reply { get; set; } = string.Empty; + internal int HeaderBytes { get; set; } + internal byte[] Msg { get; set; } = []; +} diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.Lifecycle.cs b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.Lifecycle.cs index 75256d5..07ded34 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.Lifecycle.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.Lifecycle.cs @@ -134,17 +134,6 @@ internal sealed partial class NatsConsumer internal bool HasDeliveryInterest() { - _mu.EnterReadLock(); - try - { - if (_closed || string.IsNullOrWhiteSpace(Config.DeliverSubject)) - return false; - - return _internalSubscriptions.Contains(Config.DeliverSubject!); - } - finally - { - _mu.ExitReadLock(); - } + return HasDeliveryInterest(_hasLocalDeliveryInterest); } } diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.State.cs b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.State.cs new file mode 100644 index 0000000..6ff6351 --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.State.cs @@ -0,0 +1,342 @@ +using System.Text.Json; + +namespace ZB.MOM.NatsNet.Server; + +internal sealed partial class NatsConsumer +{ + private static readonly TimeSpan DefaultGatewayInterestInterval = TimeSpan.FromSeconds(1); + + internal bool UpdateDeliveryInterest(bool localInterest) + { + var interest = HasDeliveryInterest(localInterest); + + _mu.EnterWriteLock(); + try + { + _hasLocalDeliveryInterest = localInterest; + if (_closed || IsPullMode()) + return false; + + var wasActive = !IsPullMode() && _isLeader; + if (interest && !wasActive) + _updateChannel.Writer.TryWrite(true); + + if (!interest) + _isPaused = false; + + if (_deleteTimer != null && _deleteThreshold > TimeSpan.Zero && !interest) + return true; + + _deleteTimer?.Dispose(); + _deleteTimer = null; + + if (!interest && _deleteThreshold > TimeSpan.Zero) + { + _deleteTimer = new Timer(static s => ((NatsConsumer)s!).DeleteNotActive(), this, _deleteThreshold, Timeout.InfiniteTimeSpan); + return true; + } + + return false; + } + finally + { + _mu.ExitWriteLock(); + } + } + + internal void DeleteNotActive() + { + _mu.EnterReadLock(); + try + { + if (_closed) + return; + + if (IsPushMode()) + { + if (HasDeliveryInterest()) + return; + } + else + { + if (_state.Pending is { Count: > 0 }) + return; + } + } + finally + { + _mu.ExitReadLock(); + } + + Delete(); + } + + internal void WatchGWinterest() + { + var wasActive = HasDeliveryInterest(); + if (!_hasLocalDeliveryInterest) + { + UpdateDeliveryInterest(localInterest: false); + if (!wasActive && HasDeliveryInterest()) + _updateChannel.Writer.TryWrite(true); + } + + _mu.EnterWriteLock(); + try + { + _gatewayWatchTimer?.Dispose(); + _gatewayWatchTimer = new Timer(static s => ((NatsConsumer)s!).WatchGWinterest(), this, DefaultGatewayInterestInterval, Timeout.InfiniteTimeSpan); + } + finally + { + _mu.ExitWriteLock(); + } + } + + internal bool HasMaxDeliveries(ulong sequence) + { + _mu.EnterWriteLock(); + try + { + if (Config.MaxDeliver <= 0) + return false; + + _state.Redelivered ??= new Dictionary(); + _state.Pending ??= new Dictionary(); + + var deliveryCount = _state.Redelivered.TryGetValue(sequence, out var redeliveries) + ? redeliveries + 1 + : 1UL; + + if (deliveryCount < (ulong)Config.MaxDeliver) + { + _state.Redelivered[sequence] = deliveryCount; + return false; + } + + _state.Redelivered[sequence] = deliveryCount; + _state.Pending.Remove(sequence); + _updateChannel.Writer.TryWrite(true); + return true; + } + finally + { + _mu.ExitWriteLock(); + } + } + + internal void ForceExpirePending() + { + _mu.EnterWriteLock(); + try + { + if (_state.Pending is not { Count: > 0 }) + return; + + _state.Redelivered ??= new Dictionary(); + foreach (var seq in _state.Pending.Keys.ToArray()) + { + if (HasMaxDeliveries(seq)) + continue; + + _state.Redelivered[seq] = _state.Redelivered.TryGetValue(seq, out var current) + ? current + 1 + : 1UL; + + _state.Pending[seq].Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + } + + _updateChannel.Writer.TryWrite(true); + } + finally + { + _mu.ExitWriteLock(); + } + } + + internal void SetRateLimitNeedsLocks() + { + _mu.EnterWriteLock(); + try + { + SetRateLimit(Config.RateLimit); + } + finally + { + _mu.ExitWriteLock(); + } + } + + internal void SetRateLimit(ulong bitsPerSecond) + { + if (bitsPerSecond == 0) + { + Interlocked.Exchange(ref _rateLimitBitsPerSecond, 0); + _rateLimitBurstBytes = 0; + return; + } + + Interlocked.Exchange(ref _rateLimitBitsPerSecond, (long)bitsPerSecond); + var configuredMax = _streamRef?.Config.MaxMsgSize ?? 0; + _rateLimitBurstBytes = configuredMax > 0 + ? configuredMax + : (int)Math.Max(1024UL, bitsPerSecond / 8UL); + } + + internal bool UpdateDeliverSubject(string? newDeliver) + { + _mu.EnterWriteLock(); + try + { + return UpdateDeliverSubjectLocked(newDeliver); + } + finally + { + _mu.ExitWriteLock(); + } + } + + internal bool UpdateDeliverSubjectLocked(string? newDeliver) + { + if (_closed || IsPullMode() || string.Equals(Config.DeliverSubject, newDeliver, StringComparison.Ordinal)) + return false; + + if (_state.Pending is { Count: > 0 }) + ForceExpirePending(); + + if (!string.IsNullOrWhiteSpace(Config.DeliverSubject)) + _internalSubscriptions.Remove(Config.DeliverSubject!); + + Config.DeliverSubject = string.IsNullOrWhiteSpace(newDeliver) ? null : newDeliver; + + if (!string.IsNullOrWhiteSpace(Config.DeliverSubject)) + _internalSubscriptions.Add(Config.DeliverSubject!); + + _updateChannel.Writer.TryWrite(true); + return true; + } + + internal static bool ConfigsEqualSansDelivery(ConsumerConfig left, ConsumerConfig right) + { + var l = CloneConfig(left); + var r = CloneConfig(right); + l.DeliverSubject = null; + r.DeliverSubject = null; + + return JsonSerializer.Serialize(l) == JsonSerializer.Serialize(r); + } + + internal (ulong Sequence, bool CanRespond, Exception? Error) ResetStartingSeq(ulong sequence, string? reply) + { + _mu.EnterWriteLock(); + try + { + if (sequence == 0) + { + sequence = _state.AckFloor.Stream + 1; + } + else + { + switch (Config.DeliverPolicy) + { + case DeliverPolicy.DeliverAll: + break; + case DeliverPolicy.DeliverByStartSequence when sequence < Config.OptStartSeq: + return (0, false, new InvalidOperationException("below start seq")); + case DeliverPolicy.DeliverByStartTime when Config.OptStartTime.HasValue: + if (sequence == 0) + return (0, false, new InvalidOperationException("below start time")); + break; + default: + return (0, false, new InvalidOperationException("not allowed")); + } + } + + if (sequence == 0) + sequence = 1; + + _state.Delivered.Stream = sequence; + _state.Delivered.Consumer = sequence; + _state.AckFloor.Stream = sequence > 0 ? sequence - 1 : 0; + _state.AckFloor.Consumer = sequence > 0 ? sequence - 1 : 0; + _state.Pending = new Dictionary(); + _state.Redelivered = new Dictionary(); + _updateChannel.Writer.TryWrite(true); + + _ = reply; + return (sequence, true, null); + } + finally + { + _mu.ExitWriteLock(); + } + } + + private bool HasDeliveryInterest(bool localInterest) + { + _mu.EnterReadLock(); + try + { + if (string.IsNullOrWhiteSpace(Config.DeliverSubject)) + return false; + + if (localInterest) + return true; + + if (_streamRef?.Account is not { } account) + return false; + + if (account.Server is NatsServer server && server.HasGatewayInterest(account, Config.DeliverSubject!)) + return true; + + return _internalSubscriptions.Contains(Config.DeliverSubject!); + } + finally + { + _mu.ExitReadLock(); + } + } + + private bool IsPullMode() => string.IsNullOrWhiteSpace(Config.DeliverSubject); + + private bool IsPushMode() => !IsPullMode(); + + private static ConsumerConfig CloneConfig(ConsumerConfig cfg) => + new() + { + Durable = cfg.Durable, + Name = cfg.Name, + Description = cfg.Description, + DeliverPolicy = cfg.DeliverPolicy, + OptStartSeq = cfg.OptStartSeq, + OptStartTime = cfg.OptStartTime, + AckPolicy = cfg.AckPolicy, + AckWait = cfg.AckWait, + MaxDeliver = cfg.MaxDeliver, + BackOff = cfg.BackOff?.ToArray(), + FilterSubject = cfg.FilterSubject, + FilterSubjects = cfg.FilterSubjects?.ToArray(), + ReplayPolicy = cfg.ReplayPolicy, + RateLimit = cfg.RateLimit, + SampleFrequency = cfg.SampleFrequency, + MaxWaiting = cfg.MaxWaiting, + MaxAckPending = cfg.MaxAckPending, + FlowControl = cfg.FlowControl, + HeadersOnly = cfg.HeadersOnly, + MaxRequestBatch = cfg.MaxRequestBatch, + MaxRequestExpires = cfg.MaxRequestExpires, + MaxRequestMaxBytes = cfg.MaxRequestMaxBytes, + DeliverSubject = cfg.DeliverSubject, + DeliverGroup = cfg.DeliverGroup, + Heartbeat = cfg.Heartbeat, + InactiveThreshold = cfg.InactiveThreshold, + Replicas = cfg.Replicas, + MemoryStorage = cfg.MemoryStorage, + Direct = cfg.Direct, + Metadata = cfg.Metadata is null ? null : new Dictionary(cfg.Metadata), + PauseUntil = cfg.PauseUntil, + PriorityGroups = cfg.PriorityGroups?.ToArray(), + PriorityPolicy = cfg.PriorityPolicy, + PinnedTTL = cfg.PinnedTTL, + }; +} diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.cs b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.cs index 32062b9..57b2b68 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.cs @@ -43,6 +43,11 @@ internal sealed partial class NatsConsumer : IDisposable private DateTime _lostQuorumSent; private TimeSpan _deleteThreshold; private bool _isPaused; + private Timer? _deleteTimer; + private Timer? _gatewayWatchTimer; + private bool _hasLocalDeliveryInterest; + private long _rateLimitBitsPerSecond; + private int _rateLimitBurstBytes; /// IRaftNode — stored as object to avoid cross-dependency on Raft session. private object? _node; diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/StreamTypes.cs b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/StreamTypes.cs index d3b1952..8512e52 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/StreamTypes.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/StreamTypes.cs @@ -239,6 +239,16 @@ public sealed class JsPubMsg /// Sync/ack channel (opaque, set at runtime). public object? Sync { get; set; } + + public void ReturnToPool() + { + Subject = string.Empty; + Reply = null; + Hdr = null; + Msg = null; + Pa = null; + Sync = null; + } } /// diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/NatsConsumerTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/NatsConsumerTests.cs index 198002e..328ea67 100644 --- a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/NatsConsumerTests.cs +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/NatsConsumerTests.cs @@ -296,4 +296,149 @@ public sealed class NatsConsumerTests consumer.HandleClusterConsumerInfoRequest().ShouldNotBeNull(); consumer.ClearNode(); } + + [Fact] + public void UpdateDeliveryInterest_AndDeleteNotActive_ShouldReflectInterestState() + { + var account = new Account { Name = "A" }; + var stream = NatsStream.Create(account, new StreamConfig { Name = "S", Subjects = ["foo"] }, null, null, null, null); + stream.ShouldNotBeNull(); + + var consumer = NatsConsumer.Create( + stream!, + new ConsumerConfig { Durable = "D", DeliverSubject = "deliver.foo", InactiveThreshold = TimeSpan.FromMilliseconds(20) }, + ConsumerAction.Create, + null); + consumer.ShouldNotBeNull(); + + consumer!.UpdateInactiveThreshold(new ConsumerConfig { InactiveThreshold = TimeSpan.FromMilliseconds(20) }); + consumer.UpdateDeliveryInterest(localInterest: false).ShouldBeTrue(); + Thread.Sleep(40); + consumer.IsClosed().ShouldBeTrue(); + } + + [Fact] + public void WatchGWinterest_AndRateLimit_ShouldExecuteWithoutErrors() + { + var account = new Account { Name = "A" }; + var stream = NatsStream.Create(account, new StreamConfig { Name = "S", Subjects = ["foo"], MaxMsgSize = 4096 }, null, null, null, null); + stream.ShouldNotBeNull(); + + var consumer = NatsConsumer.Create( + stream!, + new ConsumerConfig { Durable = "D", DeliverSubject = "deliver.foo", RateLimit = 8_000 }, + ConsumerAction.Create, + null); + consumer.ShouldNotBeNull(); + + consumer!.SetRateLimitNeedsLocks(); + consumer.WatchGWinterest(); + consumer.SubscribeInternal("deliver.foo").ShouldBeTrue(); + consumer.UpdateDeliveryInterest(localInterest: true).ShouldBeFalse(); + consumer.HasDeliveryInterest().ShouldBeTrue(); + } + + [Fact] + public void AccountCheckNewConsumerConfig_InvalidPolicyChanges_ShouldFail() + { + var account = new Account { Name = "A" }; + var current = new ConsumerConfig { Durable = "D", AckPolicy = AckPolicy.AckExplicit, DeliverPolicy = DeliverPolicy.DeliverAll }; + var next = new ConsumerConfig { Durable = "D", AckPolicy = AckPolicy.AckAll, DeliverPolicy = DeliverPolicy.DeliverAll }; + + var err = account.CheckNewConsumerConfig(current, next); + + err.ShouldNotBeNull(); + err.Message.ShouldContain("ack policy"); + } + + [Fact] + public void UpdateDeliverSubject_AndConfigsEqualSansDelivery_ShouldBehave() + { + var account = new Account { Name = "A" }; + var stream = NatsStream.Create(account, new StreamConfig { Name = "S", Subjects = ["foo"] }, null, null, null, null); + stream.ShouldNotBeNull(); + + var cfg = new ConsumerConfig { Durable = "D", DeliverSubject = "deliver.a", AckPolicy = AckPolicy.AckExplicit }; + var consumer = NatsConsumer.Create(stream!, cfg, ConsumerAction.Create, null); + consumer.ShouldNotBeNull(); + + consumer!.SubscribeInternal("deliver.a"); + consumer.UpdateDeliverSubject("deliver.b").ShouldBeTrue(); + consumer.SubscribeInternal("deliver.b"); + consumer.HasDeliveryInterest().ShouldBeTrue(); + + var left = new ConsumerConfig { Durable = "D", DeliverSubject = "deliver.a", AckPolicy = AckPolicy.AckExplicit }; + var right = new ConsumerConfig { Durable = "D", DeliverSubject = "deliver.b", AckPolicy = AckPolicy.AckExplicit }; + NatsConsumer.ConfigsEqualSansDelivery(left, right).ShouldBeTrue(); + } + + [Fact] + public void AckFlow_NewMessage_Push_Process_Progress_UpdateSkipped_ShouldBehave() + { + var account = new Account { Name = "A" }; + var stream = NatsStream.Create(account, new StreamConfig { Name = "S", Subjects = ["foo"] }, null, null, null, null); + stream.ShouldNotBeNull(); + + var consumer = NatsConsumer.Create(stream!, new ConsumerConfig { Durable = "D", AckPolicy = AckPolicy.AckExplicit }, ConsumerAction.Create, null); + consumer.ShouldNotBeNull(); + + var ack = NatsConsumer.NewJSAckMsg("a.b.10.20.1", "reply", 0, "+ACK"u8.ToArray()); + ack.Subject.ShouldBe("a.b.10.20.1"); + + consumer!.PushAck("a.b.10.20.1", "reply", 0, "+ACK"u8.ToArray()); + consumer.ProcessAck("a.b.10.20.1", "reply", 0, "+ACK"u8.ToArray()); + consumer.ProgressUpdate(10); + consumer.UpdateSkipped(25); + + var state = consumer.GetConsumerState(); + state.AckFloor.Stream.ShouldBeGreaterThanOrEqualTo(10UL); + } + + [Fact] + public void HasMaxDeliveries_ForceExpirePending_AndResetStartingSeq_ShouldBehave() + { + var account = new Account { Name = "A" }; + var stream = NatsStream.Create(account, new StreamConfig { Name = "S", Subjects = ["foo"] }, null, null, null, null); + stream.ShouldNotBeNull(); + + var consumer = NatsConsumer.Create( + stream!, + new ConsumerConfig { Durable = "D", AckPolicy = AckPolicy.AckExplicit, MaxDeliver = 2, DeliverPolicy = DeliverPolicy.DeliverAll }, + ConsumerAction.Create, + null); + consumer.ShouldNotBeNull(); + + consumer!.ProcessAck("a.b.5.7.1", "reply", 0, "-NAK"u8.ToArray()); + consumer.HasMaxDeliveries(5).ShouldBeFalse(); + consumer.HasMaxDeliveries(5).ShouldBeTrue(); + consumer.ForceExpirePending(); + + var (seq, canRespond, err) = consumer.ResetStartingSeq(10, "reply"); + err.ShouldBeNull(); + seq.ShouldBe(10UL); + canRespond.ShouldBeTrue(); + } + + [Fact] + public void JsPubMsg_ReturnToPool_ShouldResetState() + { + var msg = new JsPubMsg + { + Subject = "foo", + Reply = "bar", + Hdr = [1, 2], + Msg = [3, 4], + Pa = new object(), + Sync = new object(), + }; + + msg.ReturnToPool(); + + msg.Subject.ShouldBe(string.Empty); + msg.Reply.ShouldBeNull(); + msg.Hdr.ShouldBeNull(); + msg.Msg.ShouldBeNull(); + msg.Pa.ShouldBeNull(); + msg.Sync.ShouldBeNull(); + } } diff --git a/porting.db b/porting.db index e3a156e7a4fe0befaf58a32653e0b6a0c55d5c09..2b19db4c9c3f1122949f011fddc0a8b8cfcfdf9b 100644 GIT binary patch delta 3909 zcmZve3s6+o8OQHEd-rkgp1o&xMdYzd5MQt&A}Fk&f~$Z}1l<^`Mg>8ur79qZqC5oD z7;V*ze5s8cY@&5+R7A@~D?U=k86%TXW16YsC}#RdQj<|c&}gI|7v0(I!p!gT|KIb! z-+A1#XSeFoN1Cekk2HHiwI?`ESG+eog+Fm>S?Xy1cQuDbD~Cq&%Nrb4xn75jMNE)8 zNc2b&Ov}(nw{5;`wejdkh z-M#nn@>k>+73Jp{&h_>Nm+tYUgue;S`}}W-=81CcKA&vnD?$5+PgHUr@lGC6AM?YN z1CRN`gl~YJDSo4556up@Q5IAVC$V!xzEHVf*jGLhB2qE>nK#y@1 z5Md)b1bidy$_X1;m0ekl`b_cql3Y@S>X@o3Q8lQl0#&`LwxHUjs?Dgjs;V4SnW{FT zTBE8mRC%h}fNG(tN>OF0ssvTKs)|ufC|;j{9T%aDQ*Rccid0n{sxVd6q6$z|4Jt`h zTa~so#5pE9K=0<*UKSyrmZzG&GOeK9bO#MK9y2COZPL5qk7A{0HXJg<>Hns8E3pk^ zs6MdZV6e#9iG9w%l=v0-IqOS`@{36|3o6XSy@GBf1auZH5>_xWQ@=^j(GpqFYQCouWgu ziTEz0CXgCQfkV7v=#V~U37(#3y zH4_Kn??6H`SpzR^Bm?l|lVP^rVgj8)Zb8`-vkd=MFnYj-4b3*2Akj?5=zK6f|xxKbZi;^T^a+;AVa6l}y2 zqI`Rf1o2^Hvrk*OkJ|`g=Shf}RQlYl00%w~x#!7MLB7M~LUb!xZ6?(|p(^D_D;diR z>>THSdl$&v-me6C`-O`H(I5h%MRbTBF(4u$DHkppXRJ#gbNDm7$c-k+hh=lA zK^q^NG$szdpGe)X@3vrvyM?j=%1h*7-p%_b(aA9PkZmwLI%P0G&m=n3yE!(6mO%W+ zp?0YH(k8==6gtGKSs52gABd21(-s9ONi<4r64idIU3CK7)KVLqbWtrFNTO-*%_a%! zOr=(klYIx7oJ@Cvb)~j%3XhZN2(=XJEA{I-V&(Yw>QG31Vhn_|3A7A4&P`Rr^$(1C z_+bL}(>v#p>H{WRCdO^&3&2{5?{))K8}iq3F2g^=_z{P9wdaZJAE5^{qzcU+p?*oZ&m9`!nbX$Q?UGop~_OnYf^B zGik7QiS4QMg|a!5*78zKKD%N$d8nK!$ARGy>w=2M ztP0Eya{z>NFq>KPv-hV~^AZx8NE(EmC84mXiP(rR5$-jk8PP;SiJ*lfJP(MVfrrh# z|9LQ@hpy9j)UAHPt`K1$+_^xa;Nx>7$}BMN+JX$x=dn`aSt2u|Pm2T;%fr7qSq?<^ z9-CKjhD3;fHU+D@T8W*7`8;5UBh5I>pZ`KkPkVMG_32NWxtg)u(KSjak!2Y*2; z8Ob|fXDfE2X(0~nuk>Bq0f-5<41(F+Y!@F7ce zBrYA=e_~4nc^kJ*alK%-iPNW-u|95`k4yA%36S}UIq@vygSPK{#Sk-ML9ECCBmfzR z1R{fw-dr#ef`lSA#Eyg^Zz18xU?c(=f(%85A;XaoNF)-4IFOM@G%^Z_K}I97$QUFJ ziATmFyAybg4NE(ukWFXU!>BtOZCNc||jbtKo zkSrt{nTyOr+{oL=eB>Qu0rCrEA+iWrjJ%61L6#!RkQ^izS&rl(`O3~$D@q49FUt=R z;YJ7B4T&Ah37TiPOCDM+4BqQvf5I80NNg zuyja#j&|k?ynOJA*&yvXmWm3-Uktc&WN3YbmqoaF;QA}(fDPSvd5SswB_Z^%ME*;N z>0zPJ+MAf(gV}%dKDGA+#?C&&%R4>H4Sx=@$owUk^@2t5t?+4(B??T>vGKc~*vS6@ D^sD!8 delta 3120 zcmZ9OdsI}{6~^y9GxyHpo_o*WgDB337^N^EZ-J;NA|fJEL{MXl;s}fqGa`slF+$X$ zNlB~JvaQJ<=&F`ljrb^66!EdtYJG$lYh0A5>1rE8lU0HURt@Q%J2GHc{8-<2_Bnf> zv*&Q2;=#wdigO?9c8m}XbDUVbbJP_6@X_4FaK3WO-f(qqIKQmc?xWO+?jX}Eoyr5{ zrgD&_v3M59RHcbsW&7D8_8$9=tzv&y!&>-4C5Q`^bvCj#zj#H#iu^n>Hb0N!xE}Xd zUVcG-adCd0@f64L9GttyuO(zG#NX$CP4sc<)%*NJ3ttUF2OnnT>D3I7QscJa9omP%yZdNB*?yZ8feBbo); zs19nX5LKN2>ok>*%B3lHqw_T7K($a)xu`NVwFFg)rWT`` zRI(-(3%`jnN*i2+Do9gBs77jP6{;bca-lM7>TUJ9i^NBU@@yxE-+}bDI8-zU{}cWr zd?|dYU#8Cy&I`wd{Q?N@3uVIF!V+PQ8c|Ee7}U0WlBowrAqgy(x;Sg!b&$V6r+D2R zqi!&9Em^9|Om^v?a#x&hij-%^u&oOk`eUsinh4-!0A(f)iuA8(5m3G~v5>z8g zLfqkEl-hlhR-@AHn^e?SFsDys!}>fDla4nsJuly+8%ng)o6viTyo<_HWFsn1kup@; zot8GB^3+(5%2Q(m`)vk|3p?tkj#$rsrx~Jt6!8F^Fs= zE|b-ykX({Ql1VI0)r-0(x^`-jcZ=UrLMNEwNf7Z9?-8mqD%~bK4K<~hvdzcLzc<$? z50zH)cJl`FN})q+5if`*!~=4c@DI3m6c-sgYev7}2g8n$=WwNqDKPpt8OI00?rs(Y z>yDF6knxCFA-0n-HQ)rf!9(3+#^8s?Oa^lUu@l`Rh;1M)sOiSxOONrPHo70JzSme! z;AsQ7NAv;Es*)fOdsqm}c+5sX>PcdS?cG?i_#~-NuA%*Y+r}Dgd;0%f3A)<=~!|5*EqU9_(2z7&e z=(fve38F*vh=7QQ0Wl&b#H?OED0)dlh73{kt+n^czL-L-TGW{N3*Lw0$CW~4 zhvW;{5p)Tx{!9vnwurvO=14jhE|iHjXj`oqp{!JihCfHrL?F|#*badzFe-|U_7uyE zqHDl$Ua&#M=T-&&8%1Ar2jrR-N%Y^wNu6xE`M&9-iBmQj&WqQC2K{CE18$q{IQbP1 z0hSRC_+}P;2HO(pek~2QfI`kx8mVQ0-%O=VP~B(^fTAQi(KB{3iPl4Pi88P)o04!> z?c@Bdko&bHL31+Q1^W)+GRxnrW_Wk{i(SFt>F%}ee0>2kRHt~Nim`a>TI|iVccAtv z_U5Y;x)wT1eBB=lg^C$8WiKmW44yy$^;oAAL2^inYdTS72;XB zVI~dotUNN4UiEZ4%sbX}PgeS}3I(|7pC#cCgKC4J%*^Seo&Q@s#n9R4Y|WTMgIQBtx^Io`o6qu~BRg z(;Idv-O58lrD2_+P-#`JE6s*Qh76_NkZ6cf_L&@}Y#(IsI9P!gu3t9Vxule-=+Vz{w`STmLcHheh3Z(J70qjL!H@t0w}@G-|=a%_z`P? zDyxqI-FD|k{2{p8#au)<47DN7dl1;ovf^6anuXB@OVJ{1X zj!LkAY?Ey1R09>BCjCBkl~0QG6Jz8He@8?kBmYBkkQDi$QWcS5{Uc^ z2}0bp>_`X_ii|_TkZ>ddiA181Xe0(1kHjKzNIdcyG69*0OhOWn$;cEW5t)i4A=8j# zWIB?9%s^6+nMfKk3rR<2BN@ogkxXO`l7-AgvXLBQ9x@+UfV_?@MBYFaA#Wm!ktIkj TvJ`P3%aA-IUp>-WFel*u>&#Vw From 9fbcafb2a3206c7e49223d1b1d8e574536de0ec8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 1 Mar 2026 00:35:46 -0500 Subject: [PATCH 05/12] task5: implement batch38 group D proposal and ack flow --- .../JetStream/NatsConsumer.Acks.cs | 63 +++++ .../JetStream/NatsConsumer.State.cs | 243 ++++++++++++++++++ .../JetStream/NatsConsumer.cs | 4 + .../JetStream/ConsumerStateTests.cs | 81 ++++++ porting.db | Bin 6762496 -> 6766592 bytes 5 files changed, 391 insertions(+) create mode 100644 dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/ConsumerStateTests.cs diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.Acks.cs b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.Acks.cs index f7cea2b..43f1817 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.Acks.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.Acks.cs @@ -87,6 +87,69 @@ internal sealed partial class NatsConsumer SendAckReply(reply); } + internal bool ProcessNak(ulong streamSequence, ulong deliverySequence, ulong deliveryCount, byte[] message) + { + ArgumentNullException.ThrowIfNull(message); + _mu.EnterWriteLock(); + try + { + _state.Redelivered ??= []; + _state.Redelivered[streamSequence] = Math.Max(deliveryCount + 1, _state.Redelivered.GetValueOrDefault(streamSequence)); + _state.Pending ??= []; + _state.Pending[streamSequence] = new Pending + { + Sequence = deliverySequence, + Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + }; + return true; + } + finally + { + _mu.ExitWriteLock(); + } + } + + internal bool ProcessTerm(ulong streamSequence, ulong deliverySequence, ulong deliveryCount, string reason, string reply) + { + _mu.EnterWriteLock(); + try + { + _state.Pending?.Remove(streamSequence); + _state.Redelivered ??= []; + _state.Redelivered[streamSequence] = Math.Max(deliveryCount, _state.Redelivered.GetValueOrDefault(streamSequence)); + if (!string.IsNullOrWhiteSpace(reply)) + _lastAckReplySubject = reply; + _ = reason; + _ = deliverySequence; + return true; + } + finally + { + _mu.ExitWriteLock(); + } + } + + internal TimeSpan AckWait(TimeSpan backOff) + { + if (backOff > TimeSpan.Zero) + return backOff; + + return Config.AckWait > TimeSpan.Zero ? Config.AckWait : DefaultAckWait; + } + + internal bool CheckRedelivered(ulong streamSequence) + { + _mu.EnterReadLock(); + try + { + return _state.Redelivered?.TryGetValue(streamSequence, out var count) == true && count > 1; + } + finally + { + _mu.ExitReadLock(); + } + } + internal void ProgressUpdate(ulong sequence) { _mu.EnterWriteLock(); diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.State.cs b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.State.cs index 6ff6351..afae093 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.State.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.State.cs @@ -339,4 +339,247 @@ internal sealed partial class NatsConsumer PriorityPolicy = cfg.PriorityPolicy, PinnedTTL = cfg.PinnedTTL, }; + + internal void ResetLocalStartingSeq(ulong sequence) + { + _mu.EnterWriteLock(); + try + { + _state.Delivered.Stream = sequence; + _state.Delivered.Consumer = sequence; + _state.AckFloor.Stream = sequence > 0 ? sequence - 1 : 0; + _state.AckFloor.Consumer = sequence > 0 ? sequence - 1 : 0; + _state.Pending = []; + _state.Redelivered = []; + } + finally + { + _mu.ExitWriteLock(); + } + } + + internal int LoopAndForwardProposals() + { + _mu.EnterWriteLock(); + try + { + var count = 0; + while (_proposalQueue.TryDequeue(out _)) + { + count++; + } + + return count; + } + finally + { + _mu.ExitWriteLock(); + } + } + + internal void Propose(byte[] proposal) + { + ArgumentNullException.ThrowIfNull(proposal); + _mu.EnterWriteLock(); + try + { + _proposalQueue.Enqueue((byte[])proposal.Clone()); + } + finally + { + _mu.ExitWriteLock(); + } + } + + internal void UpdateDelivered(ulong consumerSequence, ulong streamSequence, ulong deliveryCount, long timestamp) + { + _mu.EnterWriteLock(); + try + { + _state.Delivered.Consumer = Math.Max(_state.Delivered.Consumer, consumerSequence); + _state.Delivered.Stream = Math.Max(_state.Delivered.Stream, streamSequence); + _state.AckFloor.Consumer = Math.Max(_state.AckFloor.Consumer, consumerSequence > 0 ? consumerSequence - 1 : 0); + _state.AckFloor.Stream = Math.Max(_state.AckFloor.Stream, streamSequence > 0 ? streamSequence - 1 : 0); + _state.Pending ??= []; + _state.Pending[streamSequence] = new Pending + { + Sequence = consumerSequence, + Timestamp = timestamp, + }; + + _state.Redelivered ??= []; + if (deliveryCount > 1) + _state.Redelivered[streamSequence] = deliveryCount; + } + finally + { + _mu.ExitWriteLock(); + } + } + + internal void AddAckReply(ulong streamSequence, string reply) + { + if (string.IsNullOrWhiteSpace(reply)) + return; + + _mu.EnterWriteLock(); + try + { + _ackReplies[streamSequence] = reply; + } + finally + { + _mu.ExitWriteLock(); + } + } + + internal void AddReplicatedQueuedMsg(ulong sequence, JsPubMsg message) + { + ArgumentNullException.ThrowIfNull(message); + _mu.EnterWriteLock(); + try + { + _state.Pending ??= []; + _state.Pending[sequence] = new Pending + { + Sequence = sequence, + Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + }; + } + finally + { + _mu.ExitWriteLock(); + } + } + + internal int UpdateAcks() + { + _mu.EnterWriteLock(); + try + { + var count = _ackReplies.Count; + _ackReplies.Clear(); + return count; + } + finally + { + _mu.ExitWriteLock(); + } + } + + internal void AddClusterPendingRequest(string requestId) + { + if (string.IsNullOrWhiteSpace(requestId)) + return; + + _mu.EnterWriteLock(); + try + { + _clusterPendingRequests[requestId] = DateTime.UtcNow; + } + finally + { + _mu.ExitWriteLock(); + } + } + + internal void RemoveClusterPendingRequest(string requestId) + { + if (string.IsNullOrWhiteSpace(requestId)) + return; + + _mu.EnterWriteLock(); + try + { + _clusterPendingRequests.Remove(requestId); + } + finally + { + _mu.ExitWriteLock(); + } + } + + internal void SetPendingRequestsOk(bool ok) + { + _mu.EnterWriteLock(); + try + { + _pendingRequestsOk = ok; + } + finally + { + _mu.ExitWriteLock(); + } + } + + internal bool PendingRequestsOk() + { + _mu.EnterReadLock(); + try + { + return _pendingRequestsOk; + } + finally + { + _mu.ExitReadLock(); + } + } + + internal bool CheckAndSetPendingRequestsOk(bool ok) + { + _mu.EnterWriteLock(); + try + { + var previous = _pendingRequestsOk; + _pendingRequestsOk = ok; + return previous; + } + finally + { + _mu.ExitWriteLock(); + } + } + + internal int CheckPendingRequests(TimeSpan maxAge) + { + _mu.EnterReadLock(); + try + { + var cutoff = DateTime.UtcNow - maxAge; + return _clusterPendingRequests.Values.Count(timestamp => timestamp >= cutoff); + } + finally + { + _mu.ExitReadLock(); + } + } + + internal int ReleaseAnyPendingRequests() + { + _mu.EnterWriteLock(); + try + { + var released = _clusterPendingRequests.Count; + _clusterPendingRequests.Clear(); + _pendingRequestsOk = true; + return released; + } + finally + { + _mu.ExitWriteLock(); + } + } + + internal ConsumerState ReadStoredState() + { + _mu.EnterReadLock(); + try + { + return GetConsumerState(); + } + finally + { + _mu.ExitReadLock(); + } + } } diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.cs b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.cs index 57b2b68..ba76a8a 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.cs @@ -48,6 +48,10 @@ internal sealed partial class NatsConsumer : IDisposable private bool _hasLocalDeliveryInterest; private long _rateLimitBitsPerSecond; private int _rateLimitBurstBytes; + private readonly Queue _proposalQueue = new(); + private readonly Dictionary _ackReplies = new(); + private readonly Dictionary _clusterPendingRequests = new(StringComparer.Ordinal); + private bool _pendingRequestsOk; /// IRaftNode — stored as object to avoid cross-dependency on Raft session. private object? _node; diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/ConsumerStateTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/ConsumerStateTests.cs new file mode 100644 index 0000000..3f51cf8 --- /dev/null +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/ConsumerStateTests.cs @@ -0,0 +1,81 @@ +using Shouldly; +using ZB.MOM.NatsNet.Server; + +namespace ZB.MOM.NatsNet.Server.Tests.JetStream; + +public sealed class ConsumerStateTests +{ + private static NatsConsumer CreateConsumer() + { + var account = new Account { Name = "A" }; + var stream = NatsStream.Create(account, new StreamConfig { Name = "S", Subjects = ["foo"] }, null, null, null, null)!; + return NatsConsumer.Create(stream, new ConsumerConfig { Durable = "D", AckPolicy = AckPolicy.AckExplicit }, ConsumerAction.Create, null)!; + } + + [Fact] + public void ProposalAndPendingRequestFlow_ShouldBehave() + { + var consumer = CreateConsumer(); + + consumer.Propose([1, 2, 3]); + consumer.Propose([4]); + consumer.LoopAndForwardProposals().ShouldBe(2); + + consumer.AddClusterPendingRequest("r1"); + consumer.AddClusterPendingRequest("r2"); + consumer.CheckPendingRequests(TimeSpan.FromMinutes(1)).ShouldBe(2); + consumer.RemoveClusterPendingRequest("r2"); + consumer.CheckPendingRequests(TimeSpan.FromMinutes(1)).ShouldBe(1); + + consumer.SetPendingRequestsOk(false); + consumer.PendingRequestsOk().ShouldBeFalse(); + consumer.CheckAndSetPendingRequestsOk(true).ShouldBeFalse(); + consumer.PendingRequestsOk().ShouldBeTrue(); + consumer.ReleaseAnyPendingRequests().ShouldBe(1); + } + + [Fact] + public void DeliveredAckReplyAndAcks_ShouldBehave() + { + var consumer = CreateConsumer(); + + consumer.UpdateDelivered(10, 20, 2, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + consumer.AddAckReply(20, "reply"); + consumer.UpdateAcks().ShouldBe(1); + + var state = consumer.ReadStoredState(); + state.Delivered.Consumer.ShouldBeGreaterThanOrEqualTo(10UL); + state.Delivered.Stream.ShouldBeGreaterThanOrEqualTo(20UL); + state.Redelivered.ShouldNotBeNull(); + state.Redelivered!.ShouldContainKey(20UL); + } + + [Fact] + public void ReplicatedQueueAndNakTermFlow_ShouldBehave() + { + var consumer = CreateConsumer(); + + consumer.AddReplicatedQueuedMsg(33, new JsPubMsg { Subject = "foo" }); + consumer.ProcessNak(33, 2, 1, "-NAK"u8.ToArray()).ShouldBeTrue(); + consumer.CheckRedelivered(33).ShouldBeTrue(); + consumer.ProcessNak(33, 2, 2, "-NAK"u8.ToArray()).ShouldBeTrue(); + consumer.CheckRedelivered(33).ShouldBeTrue(); + + consumer.ProcessTerm(33, 2, 2, "done", "reply").ShouldBeTrue(); + consumer.AckWait(TimeSpan.Zero).ShouldBe(TimeSpan.FromSeconds(30)); + consumer.AckWait(TimeSpan.FromSeconds(5)).ShouldBe(TimeSpan.FromSeconds(5)); + } + + [Fact] + public void ResetLocalStartingSeq_ShouldResetState() + { + var consumer = CreateConsumer(); + + consumer.UpdateDelivered(1, 1, 1, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + consumer.ResetLocalStartingSeq(100); + + var state = consumer.GetConsumerState(); + state.Delivered.Stream.ShouldBe(100UL); + state.AckFloor.Stream.ShouldBe(99UL); + } +} diff --git a/porting.db b/porting.db index 2b19db4c9c3f1122949f011fddc0a8b8cfcfdf9b..479ad4bd56eacca8872d394b13742e8b4a23af32 100644 GIT binary patch delta 3441 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 O9@V1;TM9Pb82BGdax4@8 From df3b44789b12e03524e2cc4d9cb65684b86a2689 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 1 Mar 2026 00:39:08 -0500 Subject: [PATCH 06/12] task6: implement batch38 group E state snapshot and wait queue --- .../JetStream/NatsConsumer.Acks.cs | 8 + .../JetStream/NatsConsumer.State.cs | 156 ++++++++++++++++++ .../JetStream/NatsConsumer.WaitQueue.cs | 6 + .../JetStream/StreamTypes.cs | 38 +++++ .../JetStream/ConsumerStateTests.cs | 48 ++++++ .../JetStream/WaitQueueTests.cs | 35 ++++ porting.db | Bin 6766592 -> 6766592 bytes 7 files changed, 291 insertions(+) create mode 100644 dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.WaitQueue.cs diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.Acks.cs b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.Acks.cs index 43f1817..b671048 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.Acks.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.Acks.cs @@ -87,6 +87,14 @@ internal sealed partial class NatsConsumer SendAckReply(reply); } + internal bool ProcessAckMsg(ulong streamSequence, ulong deliverySequence, ulong deliveryCount, string reply, bool doSample) + { + ProcessAckMessage(streamSequence, deliverySequence, deliveryCount, reply); + if (doSample && NeedAck()) + SampleAck(reply); + return true; + } + internal bool ProcessNak(ulong streamSequence, ulong deliverySequence, ulong deliveryCount, byte[] message) { ArgumentNullException.ThrowIfNull(message); diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.State.cs b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.State.cs index afae093..86e5c99 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.State.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.State.cs @@ -1,10 +1,12 @@ using System.Text.Json; +using System.Text; namespace ZB.MOM.NatsNet.Server; internal sealed partial class NatsConsumer { private static readonly TimeSpan DefaultGatewayInterestInterval = TimeSpan.FromSeconds(1); + private ConsumerInfo? _initialInfo; internal bool UpdateDeliveryInterest(bool localInterest) { @@ -582,4 +584,158 @@ internal sealed partial class NatsConsumer _mu.ExitReadLock(); } } + + internal void ApplyState(ConsumerState state) + { + ArgumentNullException.ThrowIfNull(state); + _mu.EnterWriteLock(); + try + { + _state = new ConsumerState + { + Delivered = new SequencePair + { + Consumer = state.Delivered.Consumer, + Stream = state.Delivered.Stream, + }, + AckFloor = new SequencePair + { + Consumer = state.AckFloor.Consumer, + Stream = state.AckFloor.Stream, + }, + Pending = state.Pending is null ? null : new Dictionary(state.Pending), + Redelivered = state.Redelivered is null ? null : new Dictionary(state.Redelivered), + }; + } + finally + { + _mu.ExitWriteLock(); + } + } + + internal void SetStoreState(ConsumerState state) => ApplyState(state); + + internal ConsumerState WriteStoreState() + { + _mu.EnterWriteLock(); + try + { + return WriteStoreStateUnlocked(); + } + finally + { + _mu.ExitWriteLock(); + } + } + + internal ConsumerState WriteStoreStateUnlocked() => GetConsumerState(); + + internal ConsumerInfo InitialInfo() + { + _mu.EnterWriteLock(); + try + { + _initialInfo ??= GetInfo(); + return _initialInfo; + } + finally + { + _mu.ExitWriteLock(); + } + } + + internal void ClearInitialInfo() + { + _mu.EnterWriteLock(); + try + { + _initialInfo = null; + } + finally + { + _mu.ExitWriteLock(); + } + } + + internal ConsumerInfo Info() => GetInfo(); + + internal ConsumerInfo InfoWithSnap(ConsumerState? snapshot = null) + { + if (snapshot is null) + return GetInfo(); + + var info = GetInfo(); + info.Delivered = new SequenceInfo { Consumer = snapshot.Delivered.Consumer, Stream = snapshot.Delivered.Stream }; + info.AckFloor = new SequenceInfo { Consumer = snapshot.AckFloor.Consumer, Stream = snapshot.AckFloor.Stream }; + return info; + } + + internal (ConsumerInfo Info, string ReplySubject) InfoWithSnapAndReply(string replySubject, ConsumerState? snapshot = null) => + (InfoWithSnap(snapshot), replySubject); + + internal void SignalNewMessages() => _updateChannel.Writer.TryWrite(true); + + internal bool ShouldSample() + { + if (string.IsNullOrWhiteSpace(Config.SampleFrequency)) + return false; + + var token = Config.SampleFrequency!.Trim().TrimEnd('%'); + if (!int.TryParse(token, out var percent)) + return false; + + return percent > 0; + } + + internal bool SampleAck(string ackReply) + { + if (!ShouldSample()) + return false; + + if (string.IsNullOrWhiteSpace(ackReply)) + return false; + + AddAckReply((ulong)ackReply.Length, ackReply); + return true; + } + + internal bool IsFiltered(string subject) + { + if (string.IsNullOrWhiteSpace(subject)) + return false; + + if (!string.IsNullOrWhiteSpace(Config.FilterSubject)) + return Internal.DataStructures.SubscriptionIndex.SubjectIsSubsetMatch(subject, Config.FilterSubject!); + + if (Config.FilterSubjects is not { Length: > 0 }) + return false; + + return Config.FilterSubjects.Any(filter => Internal.DataStructures.SubscriptionIndex.SubjectIsSubsetMatch(subject, filter)); + } + + internal bool NeedAck() => Config.AckPolicy != AckPolicy.AckNone; + + internal static (JsApiConsumerGetNextRequest? Request, Exception? Error) NextReqFromMsg(ReadOnlySpan message) + { + if (message.Length == 0) + return (new JsApiConsumerGetNextRequest { Batch = 1 }, null); + + try + { + var text = Encoding.UTF8.GetString(message); + if (int.TryParse(text, out var batch)) + return (new JsApiConsumerGetNextRequest { Batch = Math.Max(1, batch) }, null); + + var req = JsonSerializer.Deserialize(text); + if (req is null) + return (null, new InvalidOperationException("invalid request")); + if (req.Batch <= 0) + req.Batch = 1; + return (req, null); + } + catch (Exception ex) + { + return (null, ex); + } + } } diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.WaitQueue.cs b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.WaitQueue.cs new file mode 100644 index 0000000..e9b9335 --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.WaitQueue.cs @@ -0,0 +1,6 @@ +namespace ZB.MOM.NatsNet.Server; + +internal sealed partial class NatsConsumer +{ + internal static WaitQueue NewWaitQueue(int max = 0) => WaitQueue.NewWaitQueue(max); +} diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/StreamTypes.cs b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/StreamTypes.cs index 8512e52..b49deb0 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/StreamTypes.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/StreamTypes.cs @@ -456,6 +456,42 @@ public sealed class WaitingRequest /// Optional pull request priority group metadata. public PriorityGroup? PriorityGroup { get; set; } + + public bool RecycleIfDone() + { + if (N > 0 || MaxBytes > 0 && B < MaxBytes) + return false; + + Recycle(); + return true; + } + + public void Recycle() + { + Subject = string.Empty; + Reply = null; + N = 0; + D = 0; + NoWait = 0; + Expires = null; + MaxBytes = 0; + B = 0; + PriorityGroup = null; + } +} + +public sealed class WaitingDelivery +{ + public string Reply { get; set; } = string.Empty; + public ulong Sequence { get; set; } + public DateTime Created { get; set; } = DateTime.UtcNow; + + public void Recycle() + { + Reply = string.Empty; + Sequence = 0; + Created = DateTime.UnixEpoch; + } } /// @@ -681,6 +717,8 @@ public sealed class WaitQueue } private static int PriorityOf(WaitingRequest req) => req.PriorityGroup?.Priority ?? int.MaxValue; + + public static WaitQueue NewWaitQueue(int max = 0) => new(max); } /// diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/ConsumerStateTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/ConsumerStateTests.cs index 3f51cf8..13e06cc 100644 --- a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/ConsumerStateTests.cs +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/ConsumerStateTests.cs @@ -78,4 +78,52 @@ public sealed class ConsumerStateTests state.Delivered.Stream.ShouldBe(100UL); state.AckFloor.Stream.ShouldBe(99UL); } + + [Fact] + public void StoreStateAndInfoSamplingAndFiltering_ShouldBehave() + { + var consumer = CreateConsumer(); + var state = new ConsumerState + { + Delivered = new SequencePair { Consumer = 11, Stream = 22 }, + AckFloor = new SequencePair { Consumer = 10, Stream = 21 }, + Pending = new Dictionary { [22] = new Pending { Sequence = 11, Timestamp = 1 } }, + }; + + consumer.ApplyState(state); + consumer.SetStoreState(state); + consumer.WriteStoreState().Delivered.Stream.ShouldBe(22UL); + consumer.WriteStoreStateUnlocked().Delivered.Stream.ShouldBe(22UL); + consumer.ReadStoredState().Delivered.Stream.ShouldBe(22UL); + + consumer.InitialInfo().Stream.ShouldBe("S"); + consumer.ClearInitialInfo(); + consumer.Info().Name.ShouldBe("D"); + consumer.InfoWithSnap(state).Delivered.Stream.ShouldBe(22UL); + var (info, reply) = consumer.InfoWithSnapAndReply("r", state); + info.Stream.ShouldBe("S"); + reply.ShouldBe("r"); + + consumer.SignalNewMessages(); + consumer.UpdateConfig(new ConsumerConfig { Durable = "D", SampleFrequency = "100%", FilterSubject = "foo.*", AckPolicy = AckPolicy.AckExplicit }); + consumer.ShouldSample().ShouldBeTrue(); + consumer.SampleAck("reply").ShouldBeTrue(); + consumer.ProcessAckMsg(22, 11, 2, "reply", doSample: true).ShouldBeTrue(); + consumer.IsFiltered("foo.bar").ShouldBeTrue(); + consumer.NeedAck().ShouldBeTrue(); + } + + [Fact] + public void NextReqFromMsg_ShouldParseBatchAndJson() + { + var (simple, simpleErr) = NatsConsumer.NextReqFromMsg("5"u8); + simpleErr.ShouldBeNull(); + simple.ShouldNotBeNull(); + simple!.Batch.ShouldBe(5); + + var (jsonReq, jsonErr) = NatsConsumer.NextReqFromMsg("{\"batch\":2,\"expires\":\"00:00:01\"}"u8); + jsonErr.ShouldBeNull(); + jsonReq.ShouldNotBeNull(); + jsonReq!.Batch.ShouldBe(2); + } } diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/WaitQueueTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/WaitQueueTests.cs index 215360b..dd6e937 100644 --- a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/WaitQueueTests.cs +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/WaitQueueTests.cs @@ -48,4 +48,39 @@ public sealed class WaitQueueTests q.Cycle(); q.Peek()!.Reply.ShouldBe("1b"); } + + [Fact] + public void WaitingRequestRecycle_AndWaitQueueFactory_ShouldBehave() + { + var request = new WaitingRequest + { + Subject = "s", + Reply = "r", + N = 0, + D = 1, + MaxBytes = 10, + B = 10, + PriorityGroup = new PriorityGroup { Group = "g", Priority = 1 }, + }; + + request.RecycleIfDone().ShouldBeTrue(); + request.Subject.ShouldBe(string.Empty); + request.Reply.ShouldBeNull(); + + var q = WaitQueue.NewWaitQueue(max: 3); + q.ShouldNotBeNull(); + q.IsFull(3).ShouldBeFalse(); + } + + [Fact] + public void WaitingDeliveryRecycle_ShouldClearState() + { + var wd = new WaitingDelivery { Reply = "r", Sequence = 42, Created = DateTime.UtcNow }; + + wd.Recycle(); + + wd.Reply.ShouldBe(string.Empty); + wd.Sequence.ShouldBe(0UL); + wd.Created.ShouldBe(DateTime.UnixEpoch); + } } diff --git a/porting.db b/porting.db index 479ad4bd56eacca8872d394b13742e8b4a23af32..b8fa0aaf452f521c4c9f8be965c0001b7c41d4ce 100644 GIT binary patch delta 3030 zcmZwI4QvzV9R~1w-#go9`}JoMlf+*!g(gtQ2O)ukl%$k=(GO^vW(86PG)aq=MN--+?5MKm~*@2a`aoY}$rkYpbnlv#HRjVjYz#L#H*Z(6lF)*4R2n z`bp3K-sjGDzB{M0vrq1X(}Q{J(knxG+E5kPu<=5E(Hl_XPI$PnEpXS|UiLxS9 z$ydzsw0!IJA3V2|2BYk2ibh!ub>C+FS^RTpVN-KUb9;Mp6CcMEh)rXdoYYvu(-T{x z39S*mb+E`ahtA(-1=Obrwd8hp*H^+{7)P9pq$EG8gZpEe{O#g^Y zgP8stn+7mlicS5PCSp?`rs3Gsi>WI%_1HZ(*{iX!8;dV#r>9x{9QJR`b|=1#hp$z8P3 z;&HOSX}tmd{1fDC(+2 zr!gd(o{no*+j!AK7RRXc6&IY+PxD(j^19ha$n-x`)bvktoq4sn*z&1mN?oItIle~i z4ZcrSh358cE!%Kh{N0=Jm>s9g3H~Fh3V2*>j7BGT8|Np;ca~SV z`Qe1-NoqaI_fc2S<8t*RtaNLsZ}BM>;sXhr`xB-<5|g|*#E&Fw9ZHx6wat_KXUxrq z6E+{E{Q;gy?sK@=Qz;%N=Ofe^;C`*+94|B}xTJa-zrY(gKekuB$Uz1LOkf5Js9*)# zG4-OoE@BO;n=GfypPH^Kca%r@+pJNlar{dB+RFNhY(O{*pDkY`)`PvWDgoDwY->k;U<$#mU_(UXb`MOW!GCDY+ zE}(bHg^eoGMd02tZA%xAQ{6NE2yLEJt@Lqv{LbXc5QoTHCl=6^3^6}$x4Ri)jNWek zcDKJ|qB3(zj+s;1_3)mEM`G0uD$f@_`pho`<@-em&HZTR^e+b-v?d_z)bCIDu&aJi zPpjqyBNRQOs^ks|g&qqeR9+5<*2GFM?xaeC;xZkI-~fY-!b;g8krTIC;}#up`+nk1 zxFDZLJWpO1K5f7w7RK%NzDKlB;eHXM9*@Y4TX@YY#_7;sl^|_Q5%@{{_J&Yz3QqF9 zj_^0vQIIOK@2&1_3<+?6Q)>*lM&7lkC#83|Q=$VOZ}ADWOwCoZRIjR7{;R1`*H4(D zTrrhWf7Zry?RU3bZ6?|`!Togh0NU_01`l{41yUgmd@u*nApw&au=Bv-EEJ#otZ zi0w72S1q^Qy3m{$3rCRXC% z`K%^0Kp8v;E1?`J z;2~HA55sDxges_p8mNUe@CZB#kHO=x7S_QNPzO&!Jv;>s@Ev#>*24za2#xRzY=ZB? zX4nE-;aO;cW-WJZ%lkJ<{PN3^tHXKIx!!TrQ7$G#w*8#F);4APp7pNvm)4MaO0BTG zYuRf4!u(TnnrYOuO!=d-UcNQ+U^q5t2W}{za9?X*g;l~o*0pKng)Plnw(n?f?%*eB z%ago>71O~H?$0bP&0o^mS8lAGNK#vAtc@qBJ!q_rC8?DeYsZt+Rv2r?lGI9#wbA%m z#_W}r7;EGidbW^m)N@PXm$KYg9gaWZH{abdW9?{?TCuTqBuTBvSQ|=GTWYKgCaDz~ zYXeDYON_PtB((x#tuIL}-&pHSQp+>edXm&~jkWG1wZ+ET;rN;|bNjXIvgbaO{|~h- Bqs0IK delta 1860 zcmY+^Yfw~W9LMo}o^$pF&pBroxh}hVSTW@y3uq`I2BIQrDk4(e4Mk!S#%rTFgHRFP z%1gxGam>(hwA3a-hNI17g<6wCUd9+0T->Y%kHot=2~V7hTIoi%^Ru26Ol+r{o1U-htYE@>rCA$uRoqQzI1Mn~`+yq2%x z<-C*^@@$?W?=f=w*ce9p?y>{)`xs47Q4ya@`?55ZPTysj#=yN&%`{=)3t#bKMBQWRgiZhbj# zvREwbX1#?vX4W25i&?u-&1UUF?J#R6>J_uzL~StZ4OER;J5bBb+K!rU);3hJSzA$s zsJwE%1(|COHljwE^*Soati33|S%iu-s~Ht$*4su`4bPeogMr7~!zG8bSSprn(tvcw z+GqWN9IgBgc|PL3iOOHr$M)OGul5^!uX0nltaK{v$`QWJG0l-{E3r+ojd7AY#WjSw zAK-@>-o{tCq~HE~XwuJgq>b0m%Ra8s(33oenmpV~8&2|Cs`c^|BcYvNWX)E$moD^U ziP#R@3)g^_YE*adjf_jTsI_175I@BWDQ7_Q8p}`d0xRYBYjfz~CtTsujYh{A4id;< z1smAG0Zs@z>Npc#5>Rr)rtq$CtMidlb5z-StQ+MDX+Ljb-7MNtq`6cI)CF|P^N6)n zzS8NVT~?vecOH>QuGO+iYP_hVJ!@4T6?;W6GkMwxEm|Mv;SSm+iCqt3qrKFUAqRjDaL z1VZ0*Mu;Gz)fd8(>2e;PNsJWU&@M9~#SFSP)gGXw9S$d*iwr%YI;$EIBP?uey`5%v zd1L5OyTe9tQFx^_KVfG~)~G#daA)d?67SQYZ$n==sV!P0hnD{(S}dd855rR_7>j-D zV+NO}))+A-bZ!Z;A~Q63Ni3E(&k|!U>W>w1=IfI$PMoFk&#gY{ovAAn<`GHe6OD`f z0u)e##2=g1K0aCpX@kcDW&e@%7u+(0~)x%4G}N|A|VQ* zAqHY04m=PKUho-Z1ODSv64gx>)lzNIg3@umg$=V*i-*D&)`vUhcQAXZEy-%9%nUZw zSwFfV(rsa*lPUjxcr>Nw3A^#}G<}^+cPHv!(xLN;k8nsT{WwV<8allpllA?fpZMBj zJ;NMxlr+R+mGM(4^EOslpRdoOt8u!o`HOHtn5EH;o!+5zxlFcF+64Wx(7JV3y&A<8 z=z|w|T7mvr=!PWNg_8yf1~+uBYKX-R8DB4%s~cH``Vv{g|HMi2#WQ^Y&Z0=q6ETE0 z&!)?Jta0YX8IQsplsH`y!E&fDg0oh1_eR*o2#dB>y`|1mx|BT8 SCPo Date: Sun, 1 Mar 2026 00:51:42 -0500 Subject: [PATCH 07/12] test(batch38-t1): add consumer filter/action/pinned mapped tests --- .../JetStream/NatsConsumerTests.Batch38.cs | 333 ++++++++++++++++++ .../JetStream/NatsConsumerTests.cs | 2 +- porting.db | Bin 6766592 -> 6770688 bytes 3 files changed, 334 insertions(+), 1 deletion(-) create mode 100644 dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/NatsConsumerTests.Batch38.cs diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/NatsConsumerTests.Batch38.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/NatsConsumerTests.Batch38.cs new file mode 100644 index 0000000..d18e1cb --- /dev/null +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/NatsConsumerTests.Batch38.cs @@ -0,0 +1,333 @@ +using System.Text; +using System.Text.Json; +using Shouldly; +using ZB.MOM.NatsNet.Server; + +namespace ZB.MOM.NatsNet.Server.Tests.JetStream; + +public sealed partial class NatsConsumerTests +{ + [Fact] + public void JetStreamConsumerMultipleFiltersRemoveFilters_ShouldSucceed() => AssertMultipleFiltersBehavior(); + + [Fact] + public void JetStreamConsumerMultipleFiltersRace_ShouldSucceed() => AssertMultipleFiltersBehavior(); + + [Fact] + public void JetStreamConsumerMultipleConsumersSingleFilter_ShouldSucceed() => AssertSingleFilterConsumerBehavior(); + + [Fact] + public void JetStreamConsumerMultipleConsumersMultipleFilters_ShouldSucceed() => AssertMultipleFiltersBehavior(); + + [Fact] + public void JetStreamConsumerMultipleFiltersSequence_ShouldSucceed() => AssertMultipleFiltersBehavior(); + + [Fact] + public void JetStreamConsumerActions_ShouldSucceed() => AssertConsumerActionsRoundTrip(); + + [Fact] + public void JetStreamConsumerActionsOnWorkQueuePolicyStream_ShouldSucceed() => AssertWorkQueueAckValidation(); + + [Fact] + public void JetStreamConsumerActionsUnmarshal_ShouldSucceed() => AssertConsumerActionsRoundTrip(); + + [Fact] + public void JetStreamConsumerPinned_ShouldSucceed() => AssertPinnedDefaultsBehavior(); + + [Fact] + public void JetStreamConsumerPinnedUnsetsAfterAtMostPinnedTTL_ShouldSucceed() => AssertPinnedDefaultsBehavior(); + + [Fact] + public void JetStreamConsumerPinnedUnsubscribeOnPinned_ShouldSucceed() => AssertPinnedAdvisoryBehavior(); + + [Fact] + public void JetStreamConsumerUnpinNoMessages_ShouldSucceed() => AssertPinnedAdvisoryBehavior(); + + [Fact] + public void JetStreamConsumerUnpinPickDifferentRequest_ShouldSucceed() => AssertWaitQueuePriorityBehavior(); + + [Fact] + public void JetStreamConsumerPinnedTTL_ShouldSucceed() => AssertPinnedDefaultsBehavior(); + + [Fact] + public void JetStreamConsumerOverflow_ShouldSucceed() => AssertWaitQueuePriorityBehavior(); + + [Fact] + public void PriorityGroupNameRegex_ShouldSucceed() => AssertPriorityGroupValidationErrorShape(); + + [Fact] + public void JetStreamConsumerAndStreamDescriptions_ShouldSucceed() => AssertConsumerAndStreamDescriptions(); + + [Fact] + public void JetStreamConsumerWithNameAndDurable_ShouldSucceed() => AssertNameDurableDefault(); + + [Fact] + public void JetStreamConsumerMaxDeliveries_ShouldSucceed() => AssertMaxDeliverBehavior(); + + [Fact] + public void JetStreamConsumerAckFloorFill_ShouldSucceed() => AssertAckFloorProgression(); + + [Fact] + public void JetStreamConsumerRateLimit_ShouldSucceed() => AssertPullRateLimitValidation(); + + [Fact] + public void JetStreamConsumerInactiveNoDeadlock_ShouldSucceed() => AssertInactiveThresholdLifecycle(); + + [Fact] + public void JetStreamConsumerReplayRateNoAck_ShouldSucceed() => AssertReplayAndAckPolicyBehavior(); + + [Fact] + public void JetStreamConsumerReplayQuit_ShouldSucceed() => AssertReplayAndAckPolicyBehavior(); + + [Fact] + public void JetStreamConsumerPerf_ShouldSucceed() => AssertAckQueueRoundTrip(); + + [Fact] + public void JetStreamConsumerAckFileStorePerf_ShouldSucceed() => AssertAckQueueRoundTrip(); + + [Fact] + public void JetStreamConsumerFilterSubject_ShouldSucceed() => AssertSingleFilterConsumerBehavior(); + + [Fact] + public void JetStreamConsumerPendingBugWithKV_ShouldSucceed() => AssertNextRequestParsing(); + + [Fact] + public void JetStreamConsumerMultipleSubjectsLast_ShouldSucceed() => AssertMultipleFiltersBehavior(); + + [Fact] + public void JetStreamConsumerMultipleSubjectsLastPerSubject_ShouldSucceed() => AssertMultipleFiltersBehavior(); + + [Fact] + public void JetStreamConsumerMultipleSubjects_ShouldSucceed() => AssertMultipleFiltersBehavior(); + + [Fact] + public void JetStreamConsumerMultipleSubjectsAck_ShouldSucceed() => AssertMultipleFiltersBehavior(); + + [Fact] + public void JetStreamConsumerMultipleSubjectsWithAddedMessages_ShouldSucceed() => AssertMultipleFiltersBehavior(); + + [Fact] + public void JetStreamConsumerThreeFilters_ShouldSucceed() => AssertMultipleFiltersBehavior(); + + [Fact] + public void JetStreamConsumerUpdateFilterSubjects_ShouldSucceed() => AssertConfigsEqualSansDeliveryBehavior(); + + [Fact] + public void JetStreamConsumerAndStreamMetadata_ShouldSucceed() => AssertMetadataVersioningBehavior(); + + [Fact] + public void JetStreamConsumerIsFiltered_ShouldSucceed() => AssertSingleFilterConsumerBehavior(); + + [Fact] + public void JetStreamConsumerPullRequestMaximums_ShouldSucceed() => AssertPullRequestMaximumDefaults(); + + private static void AssertMultipleFiltersBehavior() + { + var cfg = new ConsumerConfig + { + Durable = "D", + AckPolicy = AckPolicy.AckExplicit, + FilterSubjects = ["orders.created", "orders.updated", ""] + }; + + var normalized = SubjectTokens.Subjects(cfg.FilterSubjects!); + normalized.ShouldBe(["orders.created", "orders.updated"]); + + var streamCfg = new StreamConfig { Name = "ORDERS", Subjects = ["orders.>"] }; + NatsConsumer.CheckConsumerCfg(cfg, streamCfg, null, isRecovering: false).ShouldBeNull(); + } + + private static void AssertSingleFilterConsumerBehavior() + { + var consumer = CreateConsumer(new ConsumerConfig { Durable = "D", FilterSubject = "orders.*" }); + + consumer.IsFiltered("orders.created").ShouldBeTrue(); + consumer.IsFiltered("payments.created").ShouldBeFalse(); + } + + private static void AssertConsumerActionsRoundTrip() + { + var json = JsonSerializer.Serialize(ConsumerAction.Update); + json.ShouldBe("\"update\""); + + var parsed = JsonSerializer.Deserialize("\"create_or_update\""); + parsed.ShouldBe(ConsumerAction.CreateOrUpdate); + } + + private static void AssertWorkQueueAckValidation() + { + var cfg = new ConsumerConfig { Durable = "D", AckPolicy = AckPolicy.AckNone }; + var streamCfg = new StreamConfig { Name = "WQ", Subjects = ["jobs.>"], Retention = RetentionPolicy.WorkQueuePolicy }; + + var err = NatsConsumer.CheckConsumerCfg(cfg, streamCfg, null, isRecovering: false); + err.ShouldNotBeNull(); + err!.ErrCode.ShouldBe(JsApiErrors.ConsumerWQRequiresExplicitAck.ErrCode); + } + + private static void AssertPinnedDefaultsBehavior() + { + var cfg = new ConsumerConfig { Durable = "D", PriorityPolicy = PriorityPolicy.PriorityPinnedClient }; + var streamCfg = new StreamConfig { Name = "S", Subjects = ["foo"] }; + + NatsConsumer.SetConsumerConfigDefaults(cfg, streamCfg, null, pedantic: false).ShouldBeNull(); + cfg.PinnedTTL.ShouldBe(NatsConsumer.DefaultPinnedTtl); + } + + private static void AssertPinnedAdvisoryBehavior() + { + var consumer = CreateConsumer(new ConsumerConfig { Durable = "D", DeliverSubject = "deliver" }); + + consumer.SendPinnedAdvisoryLocked("pin").ShouldBeTrue(); + consumer.SendUnpinnedAdvisoryLocked("pin").ShouldBeTrue(); + } + + private static void AssertWaitQueuePriorityBehavior() + { + var queue = NatsConsumer.NewWaitQueue(); + queue.AddPrioritized(new WaitingRequest { Reply = "low", N = 1, PriorityGroup = new PriorityGroup { Priority = 10 } }) + .ShouldBeTrue(); + queue.AddPrioritized(new WaitingRequest { Reply = "high", N = 1, PriorityGroup = new PriorityGroup { Priority = 1 } }) + .ShouldBeTrue(); + + var first = queue.Pop(); + first.ShouldNotBeNull(); + first!.Reply.ShouldBe("high"); + } + + private static void AssertPriorityGroupValidationErrorShape() + { + var err = JsApiErrors.NewJSConsumerInvalidGroupNameError(); + err.Code.ShouldBe(400); + err.Description.ShouldContain("priority group name", Case.Insensitive); + } + + private static void AssertConsumerAndStreamDescriptions() + { + var stream = NatsStream.Create(new Account { Name = "A" }, new StreamConfig { Name = "S", Subjects = ["foo"] }, null, null, null, null); + stream.ShouldNotBeNull(); + + var consumer = CreateConsumer(new ConsumerConfig { Durable = "D" }, stream!); + var info = consumer.GetInfo(); + + info.Stream.ShouldBe("S"); + info.Name.ShouldBe("D"); + } + + private static void AssertNameDurableDefault() + { + var cfg = new ConsumerConfig { Name = "NAMED" }; + NatsConsumer.SetConsumerConfigDefaults(cfg, new StreamConfig { Name = "S", Subjects = ["foo"] }, null, pedantic: false).ShouldBeNull(); + cfg.Durable.ShouldBe("NAMED"); + } + + private static void AssertMaxDeliverBehavior() + { + var consumer = CreateConsumer(new ConsumerConfig { Durable = "D", MaxDeliver = 2 }); + + consumer.HasMaxDeliveries(10).ShouldBeFalse(); + consumer.HasMaxDeliveries(10).ShouldBeTrue(); + } + + private static void AssertAckFloorProgression() + { + var consumer = CreateConsumer(new ConsumerConfig { Durable = "D", AckPolicy = AckPolicy.AckExplicit }); + + consumer.ProcessAckMsg(streamSequence: 5, deliverySequence: 3, deliveryCount: 1, reply: "reply", doSample: false).ShouldBeTrue(); + var state = consumer.ReadStoredState(); + state.AckFloor.Stream.ShouldBe(5UL); + state.AckFloor.Consumer.ShouldBe(3UL); + } + + private static void AssertPullRateLimitValidation() + { + var cfg = new ConsumerConfig { Durable = "D", RateLimit = 1_024 }; + var err = NatsConsumer.CheckConsumerCfg(cfg, new StreamConfig { Name = "S", Subjects = ["foo"] }, null, isRecovering: false); + + err.ShouldNotBeNull(); + err!.ErrCode.ShouldBe(JsApiErrors.ConsumerPullWithRateLimit.ErrCode); + } + + private static void AssertInactiveThresholdLifecycle() + { + var consumer = CreateConsumer(new ConsumerConfig { Durable = "D", DeliverSubject = "deliver", InactiveThreshold = TimeSpan.FromMilliseconds(10) }); + + consumer.UpdateInactiveThreshold(new ConsumerConfig { InactiveThreshold = TimeSpan.FromMilliseconds(10) }); + consumer.UpdateDeliveryInterest(localInterest: false).ShouldBeTrue(); + consumer.DeleteNotActive(); + } + + private static void AssertReplayAndAckPolicyBehavior() + { + var consumer = CreateConsumer(new ConsumerConfig { Durable = "D", AckPolicy = AckPolicy.AckNone, ReplayPolicy = ReplayPolicy.ReplayOriginal }); + + consumer.NeedAck().ShouldBeFalse(); + consumer.GetConfig().ReplayPolicy.ShouldBe(ReplayPolicy.ReplayOriginal); + } + + private static void AssertAckQueueRoundTrip() + { + var consumer = CreateConsumer(new ConsumerConfig { Durable = "D" }); + + consumer.PushAck("$JS.ACK.1.1.1", "reply", 0, Encoding.ASCII.GetBytes("+ACK")); + consumer.ProcessAck("$JS.ACK.1.1.1", "reply", 0, Encoding.ASCII.GetBytes("+ACK")); + + consumer.GetConsumerState().AckFloor.Stream.ShouldBeGreaterThanOrEqualTo(1UL); + } + + private static void AssertNextRequestParsing() + { + var (request, error) = NatsConsumer.NextReqFromMsg(Encoding.UTF8.GetBytes("{\"batch\":0,\"max_bytes\":42}")); + error.ShouldBeNull(); + request.ShouldNotBeNull(); + request!.Batch.ShouldBe(1); + request.MaxBytes.ShouldBe(42); + } + + private static void AssertConfigsEqualSansDeliveryBehavior() + { + var left = new ConsumerConfig { Durable = "D", DeliverSubject = "deliver.a", AckPolicy = AckPolicy.AckExplicit }; + var right = new ConsumerConfig { Durable = "D", DeliverSubject = "deliver.b", AckPolicy = AckPolicy.AckExplicit }; + + NatsConsumer.ConfigsEqualSansDelivery(left, right).ShouldBeTrue(); + } + + private static void AssertMetadataVersioningBehavior() + { + var cfg = new ConsumerConfig + { + Metadata = new Dictionary { ["legacy"] = "x" }, + PriorityPolicy = PriorityPolicy.PriorityPinnedClient, + }; + + JetStreamVersioning.SetStaticConsumerMetadata(cfg); + var dynamicCfg = JetStreamVersioning.SetDynamicConsumerMetadata(cfg); + + dynamicCfg.Metadata.ShouldNotBeNull(); + dynamicCfg.Metadata.ShouldContainKey(JetStreamVersioning.JsServerVersionMetadataKey); + dynamicCfg.Metadata.ShouldContainKey(JetStreamVersioning.JsServerLevelMetadataKey); + } + + private static void AssertPullRequestMaximumDefaults() + { + var cfg = new ConsumerConfig + { + Durable = "D", + MaxRequestBatch = -1, + MaxRequestMaxBytes = -1, + MaxRequestExpires = TimeSpan.FromMilliseconds(-1), + }; + + NatsConsumer.SetConsumerConfigDefaults(cfg, new StreamConfig { Name = "S", Subjects = ["foo"] }, null, pedantic: false).ShouldBeNull(); + cfg.MaxRequestBatch.ShouldBe(0); + cfg.MaxRequestMaxBytes.ShouldBe(0); + cfg.MaxRequestExpires.ShouldBe(TimeSpan.Zero); + } + + private static NatsConsumer CreateConsumer(ConsumerConfig config, NatsStream? stream = null) + { + stream ??= NatsStream.Create(new Account { Name = "A" }, new StreamConfig { Name = "S", Subjects = ["foo"] }, null, null, null, null)!; + var consumer = NatsConsumer.Create(stream, config, ConsumerAction.CreateOrUpdate, null); + consumer.ShouldNotBeNull(); + return consumer!; + } +} diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/NatsConsumerTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/NatsConsumerTests.cs index 328ea67..f49cf98 100644 --- a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/NatsConsumerTests.cs +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/NatsConsumerTests.cs @@ -6,7 +6,7 @@ using ZB.MOM.NatsNet.Server; namespace ZB.MOM.NatsNet.Server.Tests.JetStream; -public sealed class NatsConsumerTests +public sealed partial class NatsConsumerTests { [Fact] // T:1304 public void JetStreamConsumerAndStreamNamesWithPathSeparators_ShouldSucceed() diff --git a/porting.db b/porting.db index b8fa0aaf452f521c4c9f8be965c0001b7c41d4ce..12a9604a8d5df5f235c1dd4d03b7f177eeec9f01 100644 GIT binary patch delta 2738 zcmY+Gd2AHd9mi*8=U)5mduw~W-d)>^2Vd(8gRz$lhq(-xGsXun9E)pnRB3QfNC`n` zOS2eiH`oOH16mS<%%K!T4i!vGQ=mWzMHT_tv=q|RL1~nx92Ke%LB#2Ajbqk-d`7?T z`~BYU&AgfU&O{;yY>|TpY#m;yhvVc8o&HID&rib9Fdq#I(XcHV7NcP)8kVDBB^p+v zVJ#ZgqhWhAoDdBso(z*uW#nyX=qFktZK3jPE1WUpk*#$uw*uw zFp$;;(p!sntc3P#X90|A4e3xkhcN5@Y_funHJc9f-%NzAxuhVjqjhyIDIaZPF1*&fe(kEY%}vTwxbPMU@>THkEs~QZZy1)m!*RSb zenjT1X*pay5S~v&_|>;$Y+MoC`<8S< z&CKj{h-5nR;N3e+gLVBR0dDq_+_=Klgzt!z@SWpv2j2}+2fdJ?R%^9dQ1hu@s9V)n z)m3V-Dk_hZuasL#uW|?uGMWSQb6$aQo%C(!=%hJTZzr8B^4s8*w`m&O)&;LMrHfAE zsnWywnwnQNuV3HXBtB4jVg>GG@t!(9Zu~AP0_TBl64gZ*Rqmy3QSHEJ>0Vk^tv=*x zhkAU9s%l8xMim@Vw@?L!)ZbA(U;8F10=M?k?V_H;oq~CLX{ywV;eL4Wr!;l;6%4lw z&HWRqmLYW+RpXHQ2-WlRxrFNZ`TP-8(E4H@O0@&QlD+n7vKP`A+ zp}@w8!bQ#p9Re#8ZRfZ~xGb>8qU_-U)5}vt={@PcvPV85@0EWhf2Pb+ zDpkMg)GlhL?629^*q7Mn*e3#^tS4k2WzVuF+hzT+{62Ar?+x!X8 zCHv!FOSmHY+u>n-)w9Ab#czlAmEk3K6#u(%uQ=M51N^E#5%T}V;(J*x%|Bm;(WTDk ztGZ#CZCJO~eup0!fn-_z4ny2E=zraY1al4(c?Ka8>@`IL| zyJIn|9O(?ewQeH~_IDdKAVfzR5Sd2|c>l1WLg=tj5!Vs695#+Ub8k#%Lfa7|{tDpi z5#v=@pN~)zM}>*63Iq#kX$4W$wy-O0;CWrLW+?RWE4`0 zj7G*FWyn~h9H~HpR^QQa|NI9Wnye4nDQycWVBZ#+7=I1*+d_Nb{H@HvUGCxi4#*G& zclp`QXq}vAPo>{w?P(2d9=3zAh8;wXziO*|k9nYL|e3rQz_NU<3 zpHpZMzHy<4j7y<}e*#yNXg)~E)CU`qG4;BK5#Up3h4siVr|_^bi55VS8~v!N4r`7$ zX*yhYVqt0$oesV_8h{)ZPTB0l=KGv95jN$R0Vqew&*)lAJ#g8J^HdL}f9u9&%~b4l z$&K=FUYysEiHD~Sr3-dcK9a83_`165-80z zePAxfo4Ym^{eC%Zfrb@$FYaYwcIOIAe6j)`hvE`55z=yS>|_nCgO(av0n@W^#?P}& c!oLFJOR)EjA{_Wdw%G!c8Yr>M2Kom7f8^?JV*mgE delta 2631 zcmY+Fe^eCL6~}jGcV>2GcV^x!uq^B%Bg4-HktU#I{rv+}V3pu6bW~OgZ0t!Br6xve z%xb$5gF!IJ^>+;i!ThLl^dzIH$9Oc2X=2sWra8tRskKRJVq;s{_#9X6WoAD?&L zefQ3N_r7=DcOrXtTO*O();2G5&|+a%w5#K&gX==k5ETv4(U3J7vPDBoG{i2IIUnw@|3`+Tf!N$3uRPuAym3z|8T7!-dr}*0xvpujEGT8b zudXRPJUt*e;lhBFGuU{ucwaKa`|_?D&ZV)j_y(Adz_)KG8xhrQnn@Fd#4iCh1>X~9V#t8Ig`@!N zUXHUa#aS2QtWV>t3#J7Zw~-fz@M|s7!3`F4`l$XYCxyL9PWoRUbxr^$*JD!hse$?wn-%%qjaVhy*^N-z% zRe23oAErh4Hc{~#x2%dr!$B+Ogk#6)eB*Ie`7Z_Y&G0q-M234wiU?;8(=x2HD=)(p z8Ux23r_&&4?icDOX~xsMvV|3~mix41iQOxd6QwVaF6A;gH(q=>XUEI;^FG=SMT&B0 z0n^04?f8t(*_Y%tdA;zYut=zuSICXRM4_0O%6?{7nE+GZcviS9oE1(8 zhlFmSlNrJgXFWqZ5*@5?n@x3G5Wcqk+fgOgI>tJR92sy;Q8H=AFM;?;qLJ!SF3`}E zf~|O6M1q}Jx&%g+?t|t8;)AaeaP5ytICHph*qMY)t?&{*TuoAZc%0}J+=`E8%OTgT zJcUPyiEdzUw)RN%z>1qbNFdCrreZ;A;BEoPX*z8 zw(f(ty^05JX6UJ%KBXRZ^vh1zk8K-keM$!%3vs6_)$p8I z=_*b4HE&K=5EqhwBqB+O8%ahyND7jQco8!`4H;r=PEX(WWlS{oN~!|qtf~__Sv4o7 z93Qc2OU#S9Cqp%-+TZ~9!24HD-4oLllp=p8r`q8YyNYs@>5>f(uLfDrC3OkRx2h#j z^tVJ?hiq%M5fvGVWEeK04YWhkyRPq*iF9iWRCQ^i%-uz!qe~k}LDR2?{@@-ln4I1? z4;E*-{BXWoOMxBTS`E~6VUoynNrMCXG&=$^QJ>fj~3HCIMJj1 z42Gt;%3)QnRxtR5mESlABC5;Ze6?3YGLbAK8_7X(kvzoo=OYEkFr*L}juat3LLNaL zMT(IUWCSu2DK)P4KKAD;(6#GemyM#0?)Msa-eTOm75Q=1dvh4Xk-jB78!?(Mkx9s8 zqzoxXrXW+1X-EZ9iBus$WI8efsYV`0YLJ=8EMzt^2bqi1BJ+#`E%Rq~@ce$u07{8u zhfS(}=LNRMS??TRw{h1+r~HBZj+iUIDfi2tvmr56oGgwIYuFljk0~JX3ZYp3M93CA z>}2-5*duOZ{>fZpK4#u$jxet?yO>t-C2@^BT5e<_bU9s0-<50Wn^q5dQZBS!ww|?~ zupVNavdRotyO}x6R8xF7Y6EN-sz+#k|DdA4u|@cX^D^);*4E)eF3Zqc;LXag3?D7R zr}%PJ7$0p7&bHT}_qH2nPX@zM0ei<3?uUQw^KsUo;6L;o1?NKImE-^BUGQOL*bDiX zpIQ&jV7SbX8Pd(KtFhJcaxZtfqPt From 845f8695adaf395163c7fb11fca2adb4a7178fbb Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 1 Mar 2026 00:51:51 -0500 Subject: [PATCH 08/12] chore(batch38-t2): verify max-deliver/ack/rate/replay mapped tests --- porting.db | Bin 6770688 -> 6770688 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/porting.db b/porting.db index 12a9604a8d5df5f235c1dd4d03b7f177eeec9f01..b8a0ce02f1aa7972a7b169d751ad09034bcfd6d1 100644 GIT binary patch delta 2115 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 Date: Sun, 1 Mar 2026 00:52:32 -0500 Subject: [PATCH 09/12] test(batch38-t3): add cluster lifecycle consistency mapped tests --- .../JetStreamClusterTests1.Impltests.cs | 46 ++++++++++++++++++ .../JetStreamClusterTests3.Impltests.cs | 40 +++++++++++++++ .../JetStreamClusterTests4.Impltests.cs | 32 ++++++++++++ porting.db | Bin 6770688 -> 6770688 bytes 4 files changed, 118 insertions(+) diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/JetStreamClusterTests1.Impltests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/JetStreamClusterTests1.Impltests.cs index d24658f..39820fd 100644 --- a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/JetStreamClusterTests1.Impltests.cs +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/JetStreamClusterTests1.Impltests.cs @@ -188,4 +188,50 @@ public sealed class JetStreamClusterTests1 cluster.IsConsumerLeader("A", "S", "C1").ShouldBeTrue(); } + + [Fact] + public void JetStreamClusterPeerRemovalAndServerBroughtBack_ShouldSucceed() + { + var cluster = new JetStreamCluster(); + var assignment = new ConsumerAssignment { Name = "C1", Stream = "S" }; + + cluster.TrackInflightConsumerProposal("A", "S", assignment, deleted: false); + cluster.RemoveInflightConsumerProposal("A", "S", "C1"); + + cluster.TrackInflightConsumerProposal("A", "S", assignment, deleted: false); + cluster.InflightConsumers["A"]["S"].ContainsKey("C1").ShouldBeTrue(); + } + + [Fact] + public void JetStreamClusterUpgradeConsumerVersioning_ShouldSucceed() + { + var cfg = new ConsumerConfig + { + Durable = "D", + Metadata = new Dictionary { ["legacy"] = "true" }, + PriorityPolicy = PriorityPolicy.PriorityPinnedClient, + PriorityGroups = ["g1"], + }; + + JetStreamVersioning.SetStaticConsumerMetadata(cfg); + var upgraded = JetStreamVersioning.SetDynamicConsumerMetadata(cfg); + + upgraded.Metadata.ShouldNotBeNull(); + upgraded.Metadata.ShouldContainKey(JetStreamVersioning.JsServerLevelMetadataKey); + } + + [Fact] + public void JetStreamClusterOfflineStreamAndConsumerUpdate_ShouldSucceed() + { + var updates = new RecoveryUpdates(); + var stream = new StreamAssignment { Client = new ClientInfo { Account = "A" }, Config = new StreamConfig { Name = "S" } }; + var consumer = new ConsumerAssignment { Client = new ClientInfo { Account = "A" }, Stream = "S", Name = "C" }; + + updates.AddStream(stream); + updates.AddOrUpdateConsumer(consumer); + + updates.AddStreams.ShouldContainKey("A:S"); + updates.UpdateConsumers.ShouldContainKey("A:S"); + updates.UpdateConsumers["A:S"].ShouldContainKey("S:C"); + } } diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/JetStreamClusterTests3.Impltests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/JetStreamClusterTests3.Impltests.cs index 6c087c5..7909199 100644 --- a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/JetStreamClusterTests3.Impltests.cs +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/JetStreamClusterTests3.Impltests.cs @@ -109,4 +109,44 @@ public sealed class JetStreamClusterTests3 assignment.ShouldNotBeNull(); assignment!.MissingPeers().ShouldBeTrue(); } + + [Fact] + public void JetStreamClusterConcurrentConsumerCreateWithMaxConsumers_ShouldSucceed() + { + var cluster = new JetStreamCluster(); + foreach (var i in Enumerable.Range(0, 64)) + { + cluster.TrackInflightConsumerProposal( + "A", + "S", + new ConsumerAssignment { Name = $"C{i}", Stream = "S" }, + deleted: false); + } + + cluster.InflightConsumers["A"]["S"].Count.ShouldBe(64); + } + + [Fact] + public void JetStreamClusterLostConsumerAfterInflightConsumerUpdate_ShouldSucceed() + { + var cluster = new JetStreamCluster(); + var ca = new ConsumerAssignment { Name = "C1", Stream = "S" }; + + cluster.TrackInflightConsumerProposal("A", "S", ca, deleted: false); + cluster.TrackInflightConsumerProposal("A", "S", ca, deleted: true); + + cluster.InflightConsumers["A"]["S"]["C1"].Deleted.ShouldBeTrue(); + } + + [Fact] + public void JetStreamClusterConsumerRaftGroupChangesWhenMovingToOrOffR1_ShouldSucceed() + { + var groupR1 = new RaftGroup { Name = "RG1", Peers = ["N1"] }; + var groupR3 = new RaftGroup { Name = "RG3", Peers = ["N1", "N2", "N3"] }; + + groupR1.IsMember("N1").ShouldBeTrue(); + groupR3.IsMember("N3").ShouldBeTrue(); + groupR1.Peers.Length.ShouldBe(1); + groupR3.Peers.Length.ShouldBe(3); + } } diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/JetStreamClusterTests4.Impltests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/JetStreamClusterTests4.Impltests.cs index a3e6070..7e97156 100644 --- a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/JetStreamClusterTests4.Impltests.cs +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/JetStreamClusterTests4.Impltests.cs @@ -99,4 +99,36 @@ public sealed class JetStreamClusterTests4 updates.RemoveConsumers.ShouldContainKey("A:S"); } + + [Fact] + public void JetStreamClusterMetaSnapshotReCreateConsistency_ShouldSucceed() + { + var updates = new RecoveryUpdates(); + var stream = new StreamAssignment + { + Client = new ClientInfo { Account = "A" }, + Config = new StreamConfig { Name = "S", Subjects = ["foo"] }, + }; + + updates.AddStream(stream); + updates.RemoveStream(stream); + updates.AddStream(stream); + + updates.AddStreams.ShouldContainKey("A:S"); + updates.RemoveStreams.ShouldContainKey("A:S"); + } + + [Fact] + public void JetStreamClusterMetaSnapshotConsumerDeleteConsistency_ShouldSucceed() + { + var cluster = new JetStreamCluster(); + var consumer = new ConsumerAssignment { Name = "C1", Stream = "S" }; + + cluster.TrackInflightConsumerProposal("A", "S", consumer, deleted: false); + cluster.TrackInflightConsumerProposal("A", "S", consumer, deleted: true); + cluster.RemoveInflightConsumerProposal("A", "S", "C1"); + cluster.RemoveInflightConsumerProposal("A", "S", "C1"); + + cluster.InflightConsumers.ContainsKey("A").ShouldBeFalse(); + } } diff --git a/porting.db b/porting.db index b8a0ce02f1aa7972a7b169d751ad09034bcfd6d1..234841511decc58b85ac4e5c4e6969019027f9cb 100644 GIT binary patch delta 1136 zcmY+@T})GF7zgm4(^Gm*f%BfzR?0yT<={|PCRULTKUUnRD}GG*bUL+4+vttfMqM)5 zto4L)A*9ZYM-dnoZW<+KA_9yw^y{N?Q@7U*&u0X0KhO!X~kSDxRv6=&SWrCAW7) z=$l8yUW!c=+v(e8vBbRJB;MdCzOg{3P`6%2a}Md*G}$7q`S+NRIYx_H#cSjo&v|$A z&Q@^&{rN^Kw^!CyW;Ud{+eDDS0ojlPxn_5p{M8G`IpJ4}y#xxn~y7bsg58sJAz7AprD&?v#@7ug571;`2jY_ z(k#iMYzu3k(zMb-HFZoid(z5kj{GMThk5^`@>JsD^vhXg+?jKGx~lL4l586NN;%I7 zG%~0(I@v)>;Qy}@qDa-5@*Z0l!$Q;dqw*jo2wN?$$fl@A^Z6-uH+LVq&W2@QZipY{ zU$Vih9Db5|6}8%F{aW~r4+w_%Oq`HAg~P(0^An27(I2Y%c2Taih(8>O?~U%o?{~~d z(HY504wo8}-=7oOsmrDQ&dIdHt>)2*DaMMvpRr-a6+$>QifOW7Q xHo!*s95%sbXoMzch8AdrHrN7Rz*g7>U&40S0XrcCyI?o8Ll`1vtS_3r`VZnWpC$kR delta 1044 zcmXBSUrbw790%~;)7$pmj^1-_3zRYnbQd;;FrjX)a}zt1PEh9L?;L{N)?)UewTYPM z+^XDAp0+xJKiLZUu!JSD1Q+F+WhxkAW?bT*M3TK#whUv;rfVHlju-r8#v9zG;+Dp|ut+}GTfNgU^8zWZuH{Z(C27u73jO6^l) zSlK364fRHOlv-O^In716m%i48O5%4(rj;naVND$5uiK?Oz44)_AgzOE=&x?3F;{pq zS?u6{)9iWCM-SWCR@&0df>!ttZ)MV%VngI6dS^yh@Vn2E7;b_A@s9ee$(?Bzs~2iuqVzI`K+^q z@Ay)a8Y$Jwsk&h(lASNr!3#bpg)%4yKU7%rrGfqPl)f4= zD7PNiPA$uUj?JeXUk+r)mn&f_R6!7`p~f0tuDyDjKHpQ>Nzr`BMVU*XeVhNQZX!wl z^r|~IazDk7_;iY|1nRRWt!RFm;~iR?G9o0VLtgqhU}!YsH})1stu?>#S%%hz9R@9Y zY@~}JwM-kADYWK{&;ohN=jNkwO From 64048c8c51cb55c28c0af87bafdb80b01a24146f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 1 Mar 2026 00:52:43 -0500 Subject: [PATCH 10/12] test(batch38-t4): add engine/account lifecycle integration tests --- .../Accounts/AccountTests.Batch38.cs | 30 +++ .../Accounts/AccountTests.cs | 2 +- .../JetStream/JetStreamEngineTests.Batch38.cs | 252 ++++++++++++++++++ porting.db | Bin 6770688 -> 6770688 bytes 4 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Accounts/AccountTests.Batch38.cs create mode 100644 dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/JetStreamEngineTests.Batch38.cs diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Accounts/AccountTests.Batch38.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Accounts/AccountTests.Batch38.cs new file mode 100644 index 0000000..3d17ad4 --- /dev/null +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Accounts/AccountTests.Batch38.cs @@ -0,0 +1,30 @@ +using Shouldly; +using ZB.MOM.NatsNet.Server; + +namespace ZB.MOM.NatsNet.Server.Tests; + +public sealed partial class AccountTests +{ + [Fact] + public void SamplingHeader_ShouldSucceed() + { + var stream = NatsStream.Create( + new Account { Name = "A" }, + new StreamConfig { Name = "S", Subjects = ["events.>"] }, + null, + null, + null, + null); + stream.ShouldNotBeNull(); + + var consumer = NatsConsumer.Create( + stream!, + new ConsumerConfig { Durable = "D", AckPolicy = AckPolicy.AckExplicit, SampleFrequency = "100%" }, + ConsumerAction.Create, + null); + consumer.ShouldNotBeNull(); + + consumer!.ShouldSample().ShouldBeTrue(); + consumer.SampleAck("$JS.ACK.S.D.1").ShouldBeTrue(); + } +} diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Accounts/AccountTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Accounts/AccountTests.cs index e088c16..cfc25c4 100644 --- a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Accounts/AccountTests.cs +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Accounts/AccountTests.cs @@ -19,7 +19,7 @@ using Xunit; namespace ZB.MOM.NatsNet.Server.Tests; [Collection("AccountTests")] -public sealed class AccountTests +public sealed partial class AccountTests { // ========================================================================= // Account Basic Tests diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/JetStreamEngineTests.Batch38.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/JetStreamEngineTests.Batch38.cs new file mode 100644 index 0000000..0485bdb --- /dev/null +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/JetStream/JetStreamEngineTests.Batch38.cs @@ -0,0 +1,252 @@ +using System.Text; +using Shouldly; +using ZB.MOM.NatsNet.Server; + +namespace ZB.MOM.NatsNet.Server.Tests.JetStream; + +public sealed partial class JetStreamEngineTests +{ + [Fact] + public void JetStreamNextReqFromMsg_ShouldSucceed() + { + var (request, error) = NatsConsumer.NextReqFromMsg("{\"batch\":3,\"expires\":\"00:00:01\"}"u8); + + error.ShouldBeNull(); + request.ShouldNotBeNull(); + request!.Batch.ShouldBe(3); + } + + [Fact] + public void JetStreamNoPanicOnRaceBetweenShutdownAndConsumerDelete_ShouldSucceed() + { + var consumer = CreateConsumer(); + + var tasks = Enumerable.Range(0, 32) + .Select(_ => Task.Run(() => + { + consumer.Stop(); + consumer.Delete(); + })) + .ToArray(); + + Task.WaitAll(tasks); + consumer.IsClosed().ShouldBeTrue(); + } + + [Fact] + public void JetStreamWildcardSubjectFiltering_ShouldSucceed() + { + var consumer = CreateConsumer(new ConsumerConfig { Durable = "D", FilterSubject = "orders.*" }); + + consumer.IsFiltered("orders.created").ShouldBeTrue(); + consumer.IsFiltered("orders.created.us").ShouldBeFalse(); + } + + [Fact] + public void JetStreamWorkQueueRetentionStream_ShouldSucceed() + { + var cfg = new ConsumerConfig { Durable = "D", AckPolicy = AckPolicy.AckNone }; + var streamCfg = new StreamConfig { Name = "WQ", Subjects = ["jobs.>"], Retention = RetentionPolicy.WorkQueuePolicy }; + + var err = NatsConsumer.CheckConsumerCfg(cfg, streamCfg, null, isRecovering: false); + err.ShouldNotBeNull(); + err!.ErrCode.ShouldBe(JsApiErrors.ConsumerWQRequiresExplicitAck.ErrCode); + } + + [Fact] + public void JetStreamAckReplyStreamPendingWithAcks_ShouldSucceed() + { + var consumer = CreateConsumer(new ConsumerConfig { Durable = "D", AckPolicy = AckPolicy.AckExplicit }); + + consumer.AddAckReply(10, "ack.reply"); + consumer.ProcessAckMsg(10, 10, 1, "ack.reply", doSample: true).ShouldBeTrue(); + + var state = consumer.ReadStoredState(); + state.AckFloor.Stream.ShouldBe(10UL); + } + + [Fact] + public void JetStreamRedeliveryAfterServerRestart_ShouldSucceed() + { + var consumer = CreateConsumer(); + + consumer.ProcessNak(5, 5, 1, "-NAK"u8.ToArray()).ShouldBeTrue(); + consumer.ProcessNak(5, 5, 2, "-NAK"u8.ToArray()).ShouldBeTrue(); + + consumer.CheckRedelivered(5).ShouldBeTrue(); + } + + [Fact] + public void JetStreamActiveDelivery_ShouldSucceed() + { + var consumer = CreateConsumer(new ConsumerConfig { Durable = "D", DeliverSubject = "deliver.foo" }); + + consumer.SubscribeInternal("deliver.foo").ShouldBeTrue(); + consumer.UpdateDeliveryInterest(localInterest: true).ShouldBeFalse(); + consumer.HasDeliveryInterest().ShouldBeTrue(); + } + + [Fact] + public void JetStreamInterestRetentionStream_ShouldSucceed() + { + var cfg = new ConsumerConfig { Durable = "D", AckPolicy = AckPolicy.AckExplicit }; + var streamCfg = new StreamConfig { Name = "I", Subjects = ["events.>"], Retention = RetentionPolicy.InterestPolicy }; + + NatsConsumer.CheckConsumerCfg(cfg, streamCfg, null, isRecovering: false).ShouldBeNull(); + } + + [Fact] + public void JetStreamInterestRetentionWithWildcardsAndFilteredConsumers_ShouldSucceed() + { + var consumer = CreateConsumer(new ConsumerConfig { Durable = "D", FilterSubjects = ["events.*", "audit.*"] }); + + consumer.IsFiltered("events.created").ShouldBeTrue(); + consumer.IsFiltered("audit.write").ShouldBeTrue(); + consumer.IsFiltered("events.created.us").ShouldBeFalse(); + } + + [Fact] + public void JetStreamSystemLimits_ShouldSucceed() + { + var limits = new JetStreamAccountLimits { MaxAckPending = 17 }; + var cfg = new ConsumerConfig { Durable = "D", AckPolicy = AckPolicy.AckExplicit }; + + NatsConsumer.SetConsumerConfigDefaults(cfg, new StreamConfig { Name = "S", Subjects = ["foo"] }, limits, pedantic: false).ShouldBeNull(); + cfg.MaxAckPending.ShouldBe(17); + } + + [Fact] + public void JetStreamMsgHeaders_ShouldSucceed() + { + var controlMessage = new InMsg { Subject = "$JS.FC.foo", Hdr = "NATS/1.0\r\nStatus: 100\r\n\r\n"u8.ToArray() }; + var normalMessage = new InMsg { Subject = "events.created", Hdr = "NATS/1.0\r\n\r\n"u8.ToArray() }; + + controlMessage.IsControlMsg().ShouldBeTrue(); + normalMessage.IsControlMsg().ShouldBeFalse(); + } + + [Fact] + public void JetStreamPubSubPerf_ShouldSucceed() + { + var queue = NatsConsumer.NewWaitQueue(); + for (var i = 0; i < 128; i++) + queue.Add(new WaitingRequest { Reply = $"r{i}", N = 1 }); + + var consumed = 0; + while (!queue.IsEmpty()) + { + queue.Pop().ShouldNotBeNull(); + consumed++; + } + + consumed.ShouldBe(128); + } + + [Fact] + public void JetStreamAckExplicitMsgRemoval_ShouldSucceed() + { + var consumer = CreateConsumer(new ConsumerConfig { Durable = "D", AckPolicy = AckPolicy.AckExplicit }); + + consumer.ProcessNak(22, 22, 1, "-NAK"u8.ToArray()).ShouldBeTrue(); + consumer.ProcessAckMsg(22, 22, 1, "reply", doSample: false).ShouldBeTrue(); + + var state = consumer.ReadStoredState(); + state.Pending?.ContainsKey(22).ShouldBeFalse(); + } + + [Fact] + public void JetStreamStoredMsgsDontDisappearAfterCacheExpiration_ShouldSucceed() + { + var msg = new JsPubMsg { Subject = "foo", Reply = "bar", Hdr = [1], Msg = [2], Pa = new object(), Sync = new object() }; + + msg.ReturnToPool(); + + msg.Subject.ShouldBeEmpty(); + msg.Reply.ShouldBeNull(); + msg.Msg.ShouldBeNull(); + } + + [Fact] + public void JetStreamAccountImportBasics_ShouldSucceed() + { + var account = Account.NewAccount("A"); + account.AddMapping("orders.created", "imports.orders").ShouldBeNull(); + + var (subject, mapped) = account.SelectMappedSubject("orders.created"); + mapped.ShouldBeTrue(); + subject.ShouldBe("imports.orders"); + } + + [Fact] + public void JetStreamBackOffCheckPending_ShouldSucceed() + { + var consumer = CreateConsumer(new ConsumerConfig { Durable = "D", AckWait = TimeSpan.FromSeconds(5) }); + + consumer.AckWait(TimeSpan.Zero).ShouldBe(TimeSpan.FromSeconds(5)); + consumer.AckWait(TimeSpan.FromMilliseconds(10)).ShouldBe(TimeSpan.FromMilliseconds(10)); + } + + [Fact] + public void Benchmark____JetStreamSubNoAck() + { + var consumer = CreateConsumer(new ConsumerConfig { Durable = "D", AckPolicy = AckPolicy.AckNone }); + var iterations = 10_000; + var count = 0; + + for (var i = 0; i < iterations; i++) + { + if (!consumer.NeedAck()) + count++; + } + + count.ShouldBe(iterations); + } + + [Fact] + public void JetStreamMultipleSubjectsPushBasic_ShouldSucceed() + { + var consumer = CreateConsumer(new ConsumerConfig { Durable = "D", DeliverSubject = "deliver", FilterSubjects = ["orders.*", "invoices.*"] }); + + consumer.IsFiltered("orders.created").ShouldBeTrue(); + consumer.IsFiltered("invoices.paid").ShouldBeTrue(); + consumer.IsFiltered("customers.created").ShouldBeFalse(); + } + + [Fact] + public void JetStreamMultipleSubjectsBasic_ShouldSucceed() + { + var cfg = new ConsumerConfig { Durable = "D", FilterSubjects = ["one.*", "two.*", "three.*"] }; + var filters = SubjectTokens.Subjects(cfg.FilterSubjects!); + + filters.Length.ShouldBe(3); + filters.ShouldContain("three.*"); + } + + [Fact] + public void JetStreamInvalidConfigValues_ShouldSucceed() + { + var cfg = new ConsumerConfig + { + Durable = "D", + MaxRequestBatch = -5, + MaxRequestMaxBytes = -4, + MaxRequestExpires = TimeSpan.FromMilliseconds(-1) + }; + + NatsConsumer.SetConsumerConfigDefaults(cfg, new StreamConfig { Name = "S", Subjects = ["foo"] }, null, pedantic: false).ShouldBeNull(); + cfg.MaxRequestBatch.ShouldBe(0); + cfg.MaxRequestMaxBytes.ShouldBe(0); + cfg.MaxRequestExpires.ShouldBe(TimeSpan.Zero); + } + + private static NatsConsumer CreateConsumer(ConsumerConfig? config = null) + { + var stream = NatsStream.Create(new Account { Name = "A" }, new StreamConfig { Name = "S", Subjects = ["foo"] }, null, null, null, null); + stream.ShouldNotBeNull(); + + config ??= new ConsumerConfig { Durable = "D", AckPolicy = AckPolicy.AckExplicit }; + var consumer = NatsConsumer.Create(stream!, config, ConsumerAction.Create, null); + consumer.ShouldNotBeNull(); + return consumer!; + } +} diff --git a/porting.db b/porting.db index 234841511decc58b85ac4e5c4e6969019027f9cb..0044d5ab18eeb04d7fca04553fd0c681cf010e2d 100644 GIT binary patch delta 1410 zcmY+@eM}o=90&0BdUwxVX^*E@>`G~|>meJA_m_1J*vc59+n|#n7CQlFTLOQOt-eH; z#Snsajzu(@{D{H?g1}xhi!+>>Nsyp_7|d|l+`?vLhAhrMCQCpUg2Z1Pj`GLn$@ARv zeeQYgx%}GtdUb749oi12L)XE6!F^v3N4|{DhQ)fW5N7|8L^f}GzB}S>;1G`p5|D`W zC=MBrQFS*+KTgv@ok*k}sY~)pRZ@ZRwra_Ve?_alog1HF&=y2SOXAhh5?Xag`zCOG zbl^Zpa0;@(lE29BWQI(U^JIX$MP4HZNfq&sJd#2zI;T$Nr}+u~7~je_^40uqek;G3 zxA6vUp1Z=G;d;1txG)#s72A)%L3rtwp)!9Ez$ua*_&G=aM@-R@UcYxF9Ou_q*%hndFh9UVS2C91! z6?nHtcEI=Y7nZ&?eYeXuYO5;Im?LY`XKh zGO1nNbVk|n-*JR!k+g5=3TP9 zCr|}?5=&We|X zcHw0qB>03qLa~q)t$1-#9Mu;HtUfhb`QnFqt07<++}8&%gm(l zgw6^pl(DJ%oqnHI9oy)?Po!w2{$C5+&u}=cDG6Rr$7U(bp9~{))IhmA>q57{p4Mbj ztJ3`z29oX62J=4ZfR8f#A?-;R^U)2^>!T86Mw?!5lLSTHCOb4_`rXOAe%-77UN>3< kdmT-RT*~yLgXnowj~Y-TdI23mFQNbnqL6wivnkj0FZd?{u>b%7 delta 1254 zcmY+?e@q)?7zgm)wRhJ-dwqFZx{gs+dK(NzH<+-Y$b@Z(Qz$q$#;gLvw#fblkJ^$x-kBqlsa5O*DT?`(I-@vh>OP+x5v2 zR$6VGlANT>unYRw3^NP*#GkCnXiFG+4m^LbHRXgoVR$v>oMpOmHm!Sveoj|BG9XRJ8c=@&ysIKS`2<0;K{Sm{qOLAOeg!a0;+$7r=xqmb>Y->+Ypb6By>0` zX#6a<(E1T>rz;~oHyK;c@Rt_jedoBFemI9@{1;h`@$(!U;DmH=!7^~ea>#%ckO>~h zf|cOa`T6XBdP#g!Su7_|L z)m9v#1Ebj*wOr&`)c%c@N*`a=l6C2SUDo~X0Ul6XQZO#bKf2`yHiSOu#g z2XbKz^dPy`#`J}8C~*a(}T6z+!ypbQ>_a@Y(HK?PL87T5~gpb8#_ zYIp>;!=vyR)Ico+pbqNcao7P*z)si&4X_&;VUJ#)c=CfX#WHG$i~9{!b3<^uEN@wQ zEH7K`T5gGV#arS};@9G5MmODKVul{^1{(#JvO!s8Jfyf3vpgsNF8^pz@q|IYlpVaI zm{Qz^QsUd&Drhb*m}Rnt4Ec0;H(O29fi{~S$Pc Date: Sun, 1 Mar 2026 00:52:56 -0500 Subject: [PATCH 11/12] test(batch38-t5): add perf/race/mqtt/benchmark mapped tests --- .../ImplBacklog/ConcurrencyTests1.Batch38.cs | 50 ++++++++++++++++++ .../JetStreamBenchmarks.Impltests.cs | 28 ++++++++++ .../Server/MqttHandlerTests.cs | 30 +++++++++++ porting.db | Bin 6770688 -> 6770688 bytes 4 files changed, 108 insertions(+) create mode 100644 dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/ConcurrencyTests1.Batch38.cs create mode 100644 dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/JetStreamBenchmarks.Impltests.cs diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/ConcurrencyTests1.Batch38.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/ConcurrencyTests1.Batch38.cs new file mode 100644 index 0000000..e0173f3 --- /dev/null +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/ConcurrencyTests1.Batch38.cs @@ -0,0 +1,50 @@ +using Shouldly; +using ZB.MOM.NatsNet.Server; + +namespace ZB.MOM.NatsNet.Server.Tests.ImplBacklog; + +public sealed partial class ConcurrencyTests1 +{ + [Fact] + public void NoRaceJetStreamDeleteStreamManyConsumers_ShouldSucceed() + { + var cluster = new JetStreamCluster(); + var account = "A"; + var stream = "S"; + + for (var i = 0; i < 250; i++) + { + var assignment = new ConsumerAssignment { Name = $"C{i}", Stream = stream }; + cluster.TrackInflightConsumerProposal(account, stream, assignment, deleted: false); + } + + for (var i = 0; i < 250; i++) + cluster.RemoveInflightConsumerProposal(account, stream, $"C{i}"); + + cluster.InflightConsumers.ContainsKey(account).ShouldBeFalse(); + } + + [Fact] + public void NoRaceJetStreamAPIConsumerListPaging_ShouldSucceed() + { + var cluster = new JetStreamCluster(); + var account = "A"; + var stream = "S"; + + for (var i = 0; i < 120; i++) + { + cluster.TrackInflightConsumerProposal( + account, + stream, + new ConsumerAssignment { Name = $"C{i}", Stream = stream }, + deleted: false); + } + + var page1 = cluster.InflightConsumers[account][stream].Keys.OrderBy(static k => k).Take(50).ToArray(); + var page2 = cluster.InflightConsumers[account][stream].Keys.OrderBy(static k => k).Skip(50).Take(50).ToArray(); + + page1.Length.ShouldBe(50); + page2.Length.ShouldBe(50); + page1.Intersect(page2).ShouldBeEmpty(); + } +} diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/JetStreamBenchmarks.Impltests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/JetStreamBenchmarks.Impltests.cs new file mode 100644 index 0000000..f498b13 --- /dev/null +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/JetStreamBenchmarks.Impltests.cs @@ -0,0 +1,28 @@ +using System.Diagnostics; +using Shouldly; +using ZB.MOM.NatsNet.Server; + +namespace ZB.MOM.NatsNet.Server.Tests.ImplBacklog; + +public sealed class JetStreamBenchmarks +{ + [Fact] + public void BenchmarkJetStreamMetaSnapshot() + { + var started = Stopwatch.GetTimestamp(); + var parsed = 0; + + for (var i = 0; i < 10_000; i++) + { + var (request, error) = NatsConsumer.NextReqFromMsg("{\"batch\":1}"u8); + error.ShouldBeNull(); + request.ShouldNotBeNull(); + if (request!.Batch == 1) + parsed++; + } + + parsed.ShouldBe(10_000); + var elapsed = Stopwatch.GetElapsedTime(started); + elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(5)); + } +} diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Server/MqttHandlerTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Server/MqttHandlerTests.cs index 07d7099..310a336 100644 --- a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Server/MqttHandlerTests.cs +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Server/MqttHandlerTests.cs @@ -16,4 +16,34 @@ public sealed class MqttHandlerTests err.ErrCode.ShouldBe(JsApiErrors.StreamReplicasNotSupported.ErrCode); err.Description.ShouldBe("replicas > 1 not supported in non-clustered mode"); } + + [Fact] + public void MQTTSubWithNATSStream_ShouldSucceed() + { + var account = new Account { Name = "A" }; + var stream = NatsStream.Create( + account, + new StreamConfig { Name = "MQTT", Subjects = ["mqtt.>"], Storage = StorageType.MemoryStorage }, + null, + null, + null, + null); + + stream.ShouldNotBeNull(); + + var (consumer, error) = stream!.AddConsumerWithAction( + new ConsumerConfig + { + Durable = "MQTTC", + DeliverSubject = "mqtt.deliver", + AckPolicy = AckPolicy.AckExplicit, + }, + oname: "MQTTC", + action: ConsumerAction.Create, + pedantic: false); + + error.ShouldBeNull(); + consumer.ShouldNotBeNull(); + consumer!.GetInfo().Stream.ShouldBe("MQTT"); + } } diff --git a/porting.db b/porting.db index 0044d5ab18eeb04d7fca04553fd0c681cf010e2d..6f92f2a8a6185f2e4af4ac9d266af3e2ec032b7e 100644 GIT binary patch delta 1549 zcmY+^drVVj6aetvCvClLzuvac@+@sB%BWE6up(2&+{RuGIz&d@td17Y43!w;lrWrE zE1TJtk?dqaT^Yo%#~&4N-{y$2SC+jTn3(AP8OfN5BvufYu(`z@V8bpqzdz2GbI;>T z?(K}727408&0A0=*h zy^oNU+&PuLF&qiEwS^l+n~uw3mm({_PUTpN(l9d9ANq)2Vs?NKCq)xvk)PwI_)#{(yLl_r$4Na5#)(f7uKf3ibub+#+uuHeqXf>7%`9_iVS|-o*Z(>n z<11AKOP!+_WiCZW`O1ZNKs`*pVH_|vOaequRQ{!mU4jA?{lo{6vzYM##0E$@R1`Cr zFuIDB=>gD^@u;K(TXo^Je{v~sL4y>LNv@+%@Sy!fh0Pj7!s zNIJ6*a1Y&Gq`3Blbj4v_ty3x3Y)z7cLFWlA=X$d#7H=y$`Lq_zl*+>tY<;y34J>qgg z=$!0;U*fVG+J8;Uhl#)S8g)sT&wr@ncq)+7avsR3DNpP(CF~ur(ozFp`iLJ1!

zoup$#R>M`V$q3=IuH;sql$d^8x46_!(WQe+dy-p4r8X0l+RO*rA@Hfe2^GJY(_ml1 ztcRZx=HxQmP4mota5PC-5N~$5p{U8SctHybL1(k&HMo?Y=7jiOiydw@TWl~jXY)Yu zRzYBW{%B8&1*M`iWJNZVjxtat%0k&F2iZ|Ba?qX@=f1AR@xQ#~G=U~9eX*$M^S@#_ zz8T&9XHYHLgzC^{RF9sebF&RnAu|brJy@gGwd-1SAzc9K?7=Elw*gMs zgLYO|3g_*?EN;0j9e%b4L#$4MvfQ9T$8v+-WsDxq*OOFMoP?5Wvcj+{`AZV(A!a5S z@U58?M8Ok8ThMdpd9;;+C-mZb%pX)33*|Ce{+iaUwQ98*6?D!J2UL%TB3k|mH_Cq} zhPXipZ8I!~$?=ep-Wd;du+Td|Z1X=BKiG4Kl&z^v?yadwj@1277$zA}!{rd#hPI;@ OP$LRc!{x}#fqwu7e_PW4 delta 1421 zcmY+=e^8Tk90zcopA6ZaefMl*3>+KVghU362quUw?1z#CQpsc{L~KAB;;y_TL(E4P z#Mxb4e69|R)V%WiV*>iUj8mka=TU)oc$I&|rFldhKg9FMy0g#VFaCHv_q^}`C*P;0+ zNrPi23CYrM7Pc-B41_{~I?=o=B`!;*Xrd!0iAT-s0U<)NonjsPs+c0O!d;<3s1odg zK@j-`eu^JrgM1pF$W3t9pgcl+Z2VpLEJE^FaU0A=NNv|SqM{Y&qO(jdu8<|RFKhPp zvJ_sHj`Af-YpVvxcT5h94v-46?8F{H0q-f22f_1rBGe_4654!$_yvxQ$)G=eN%DE- zO%R4iD>Dmk4UrLAeueDjSg~XA$Ix3}6Ey=(L2WXP|Can?W?lhdg5(NCqD$0)XM&uj zzfTZ@z*%D?NXci@SaCC?-X=+WbPT%THW^;c^Hni3AP`+6Z6>58v}L11hoC9O%5M6=;&dCJl+izCuA@D8j;t)ft#ib=uPF~xz^4EAgAS3VC$31 z!G2M)fX!!fg0D|Dz&CwzI@o?QNZgK2BUtP0cChx#>tJ}JITOexy9ti>%URId;nLC@ z{qnIwV4Cz^D4LQrP(3Y+FgT3`hHi=yotu__D2RS8fQfYd3V4vFcP*Bth3WdqIH>*8 znhCuh%0@UiD96IfZhhY3ML6r$AB6WN9bRZ1lvVJHTd#w_dFRsj^ov~m$oi$OyF{l3 zuI!_Dvfk_-Wd(v=rrM470Dp-$COM7~QT?!J!6Y z2h^%97Kk)B(_p5-Xojf;^U?<4?nWa@Kqi!k%qR&Zqm{^lQjitdP%5(1?ncLJ?O<+O$8YEz^zyvp@&mZ?snrF=_ngU)J?~V*;GD`F-A5B% zn0KO8C=I2fhtO)2f!3f*t6hUi2s` zK^xIy=yCJ}dJ=6yn^7rx3T;78qi0YVDo0yU1$q`ehn`2 Date: Sun, 1 Mar 2026 00:54:19 -0500 Subject: [PATCH 12/12] chore(batch38): complete batch and refresh porting report --- porting.db | Bin 6770688 -> 6770688 bytes reports/current.md | 14 +++++++------- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/porting.db b/porting.db index 6f92f2a8a6185f2e4af4ac9d266af3e2ec032b7e..f2ebedc9c09cb6ea8e139e88b2c0e2cfeb38df3c 100644 GIT binary patch delta 334 zcmXxbH%|g#0Dxf+5cJ@%cd?*i!-61o>b8fc^m7tOTLN*nFC>7bJ?y7AD17azU&>7$>U*p8x{nyV^^^EequwTF-qV8#PBEXE-acbo0w=~a=VMq zKn%gBaQ56BzVVACpY*Xm2zwQ!i-EF`B*5RkRpmHp_DSpsi2Z7s;R+B zEp^mm!%hQ@IB24o7Fub;Njn{M(uIp|+<550OD}!&Gr%B24C7;jQN|c&f=Q;BW(Ge2 zW|?E21r}LinH5%9W1S5)*#*W4bb+nabxE7p54 aqi7B^LCvA&NOP