From 4b7fac7957bead36ed88e77d34ac149dc4872340 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 28 Feb 2026 20:57:02 -0500 Subject: [PATCH 1/9] feat(batch27): implement jetstream bootstrap and account wiring --- .../Accounts/Account.JetStream.cs | 62 +++ .../ZB.MOM.NatsNet.Server/Accounts/Account.cs | 16 +- .../ZB.MOM.NatsNet.Server/ClientConnection.cs | 2 +- .../JetStream/JetStreamTypes.cs | 4 +- .../NatsServer.JetStreamCore.cs | 427 ++++++++++++++++++ .../src/ZB.MOM.NatsNet.Server/NatsServer.cs | 1 + porting.db | Bin 6696960 -> 6705152 bytes 7 files changed, 501 insertions(+), 11 deletions(-) create mode 100644 dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.JetStream.cs create mode 100644 dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.JetStreamCore.cs diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.JetStream.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.JetStream.cs new file mode 100644 index 0000000..c20d2d9 --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.JetStream.cs @@ -0,0 +1,62 @@ +namespace ZB.MOM.NatsNet.Server; + +public sealed partial class Account +{ + internal Exception? EnableAllJetStreamServiceImportsAndMappings() + { + _mu.EnterReadLock(); + var server = Server as NatsServer; + _mu.ExitReadLock(); + + if (server == null) + return new InvalidOperationException("jetstream account not registered"); + + var systemAccount = server.SystemAccount(); + var destinationName = systemAccount?.Name ?? string.Empty; + + if (systemAccount != null && !ServiceImportExists(destinationName, JsApiSubjects.JsAllApi)) + { + var err = AddServiceImport(systemAccount, JsApiSubjects.JsAllApi, JsApiSubjects.JsAllApi); + if (err != null) + return new InvalidOperationException($"error setting up jetstream service imports for account: {err.Message}", err); + } + + var domain = server.GetOpts().JetStreamDomain; + if (!string.IsNullOrWhiteSpace(domain)) + { + var mappings = new Dictionary(StringComparer.Ordinal) + { + [$"$JS.{domain}.API.>"] = JsApiSubjects.JsAllApi, + [$"$JS.{domain}.API.INFO"] = JsApiSubjects.JsApiAccountInfo, + }; + + _mu.EnterReadLock(); + try + { + foreach (var mapping in _mappings) + mappings.Remove(mapping.Source); + } + finally + { + _mu.ExitReadLock(); + } + + foreach (var (src, dest) in mappings) + { + var err = AddMapping(src, dest); + if (err != null) + server.Errorf("Error adding JetStream domain mapping: {0}", err.Message); + } + } + + return null; + } + + internal Exception? EnableJetStreamInfoServiceImportOnly() + { + if (ServiceImportShadowed(JsApiSubjects.JsApiAccountInfo)) + return null; + + return EnableAllJetStreamServiceImportsAndMappings(); + } +} diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.cs index 9718c11..fb75725 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.cs @@ -32,7 +32,7 @@ namespace ZB.MOM.NatsNet.Server; /// can interact with it without a hard dependency. /// Mirrors Go Account struct in server/accounts.go. /// -public sealed class Account : INatsAccount +public sealed partial class Account : INatsAccount { // ------------------------------------------------------------------------- // Constants @@ -261,7 +261,7 @@ public sealed class Account : INatsAccount /// JetStream account state. Mirrors Go js *jsAccount. /// TODO: session 19 — JetStream implementation. /// - internal object? JetStream { get; set; } + internal JsAccount? JetStream { get; set; } /// /// Per-domain JetStream limits. Mirrors Go jsLimits map[string]JetStreamAccountLimits. @@ -2279,7 +2279,7 @@ public sealed class Account : INatsAccount } if (sid is { Length: > 0 } && InternalClient != null) - InternalClient.ProcessUnsub(sid); + InternalClient.RemoveSubscriptionBySid(sid); if (tracking && requestor != null && !delivered) SendBackendErrorTrackingLatency(serviceImport, reason); @@ -2355,7 +2355,7 @@ public sealed class Account : INatsAccount } if (sid != null && InternalClient != null) - InternalClient.ProcessUnsub(sid); + InternalClient.RemoveSubscriptionBySid(sid); } /// @@ -2548,7 +2548,7 @@ public sealed class Account : INatsAccount if (InternalClient == null && Server is NatsServer server) { InternalClient = server.CreateInternalAccountClient(); - InternalClient.Account = this; + InternalClient.SetAccount(this); } return InternalClient; @@ -2573,7 +2573,7 @@ public sealed class Account : INatsAccount _mu.EnterReadLock(); var internalClient = InternalClient; _mu.ExitReadLock(); - internalClient?.ProcessUnsub(sub.Sid); + internalClient?.RemoveSubscriptionBySid(sub.Sid); } /// @@ -2685,7 +2685,7 @@ public sealed class Account : INatsAccount return; foreach (var sid in subscriptionIds) - internalClient.ProcessUnsub(sid); + internalClient.RemoveSubscriptionBySid(sid); internalClient.CloseConnection(ClosedState.InternalClient); } @@ -4170,7 +4170,7 @@ public sealed class Account : INatsAccount return new ClientInfo { Id = client.Cid, - Account = client.Account?.Name ?? string.Empty, + Account = client.Account()?.Name ?? string.Empty, Name = client.Opts.Name ?? string.Empty, Rtt = client.GetRttValue(), Start = client.Start == default ? string.Empty : client.Start.ToUniversalTime().ToString("O"), diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/ClientConnection.cs b/dotnet/src/ZB.MOM.NatsNet.Server/ClientConnection.cs index 7888792..7e43765 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/ClientConnection.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/ClientConnection.cs @@ -1686,7 +1686,7 @@ public sealed partial class ClientConnection } } - internal void ProcessUnsub(byte[] sid) + internal void RemoveSubscriptionBySid(byte[] sid) { lock (_mu) { diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JetStreamTypes.cs b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JetStreamTypes.cs index 658ffd0..d7d2923 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JetStreamTypes.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JetStreamTypes.cs @@ -182,7 +182,7 @@ public sealed class JetStreamAccountStats /// The main JetStream engine, one per server. /// Mirrors jetStream struct in server/jetstream.go. /// -internal sealed class JetStream +internal sealed partial class JetStream { // Atomic counters (use Interlocked for thread-safety) public long ApiInflight; @@ -238,7 +238,7 @@ internal sealed class JsaStorage /// A JetStream-enabled account, holding streams, limits and usage tracking. /// Mirrors jsAccount in server/jetstream.go. /// -internal sealed class JsAccount +internal sealed partial class JsAccount { private readonly ReaderWriterLockSlim _mu = new(); diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.JetStreamCore.cs b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.JetStreamCore.cs new file mode 100644 index 0000000..774daa8 --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.JetStreamCore.cs @@ -0,0 +1,427 @@ +using System.Security.Cryptography; +using System.Text; +using ZB.MOM.NatsNet.Server.Internal.DataStructures; + +namespace ZB.MOM.NatsNet.Server; + +public sealed partial class NatsServer +{ + private const string JetStreamStoreDir = "jetstream"; + + public Exception? EnableJetStream(JetStreamConfig? config) + { + if (JetStreamEnabled()) + return new InvalidOperationException("jetstream already enabled"); + + Noticef("Starting JetStream"); + + if (config == null || config.MaxMemory <= 0 || config.MaxStore <= 0) + { + config = new JetStreamConfig + { + StoreDir = string.IsNullOrWhiteSpace(GetOpts().StoreDir) + ? Path.Combine(Path.GetTempPath(), JetStreamStoreDir) + : Path.Combine(GetOpts().StoreDir, JetStreamStoreDir), + MaxMemory = GetOpts().JetStreamMaxMemory > 0 ? GetOpts().JetStreamMaxMemory : 1, + MaxStore = GetOpts().JetStreamMaxStore > 0 ? GetOpts().JetStreamMaxStore : 1, + SyncInterval = GetOpts().SyncInterval, + SyncAlways = GetOpts().SyncAlways, + Domain = GetOpts().JetStreamDomain, + }; + } + else if (!string.IsNullOrWhiteSpace(config.StoreDir)) + { + config.StoreDir = Path.Combine(config.StoreDir, JetStreamStoreDir); + } + + if (string.IsNullOrWhiteSpace(config.StoreDir)) + { + config.StoreDir = Path.Combine(Path.GetTempPath(), JetStreamStoreDir); + Warnf("Temporary storage directory used, data could be lost on system reboot"); + } + + var err = CheckStoreDir(config); + if (err != null) + return err; + + return EnableJetStreamInternal(config); + } + + private KeyGen? JsKeyGen(string jsKey, string info) + { + if (string.IsNullOrEmpty(jsKey)) + return null; + + return context => + { + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(jsKey)); + hmac.TransformBlock(Encoding.UTF8.GetBytes(info), 0, info.Length, null, 0); + hmac.TransformFinalBlock(context, 0, context.Length); + return hmac.Hash ?? []; + }; + } + + internal (byte[]? Plain, bool UsedFallback, Exception? Error) DecryptMeta( + StoreCipher storeCipher, + byte[] encryptedKey, + byte[] encryptedBuffer, + string accountName, + string context) + { + if (encryptedKey.Length == 0) + return (null, false, new InvalidOperationException("encryption key missing")); + + var ciphers = storeCipher == StoreCipher.Aes + ? new[] { StoreCipher.Aes, StoreCipher.ChaCha } + : new[] { StoreCipher.ChaCha, StoreCipher.Aes }; + + var candidates = new List<(KeyGen Prf, StoreCipher Cipher)>(); + var opts = GetOpts(); + + var prf = JsKeyGen(opts.JetStreamKey, accountName); + if (prf == null) + return (null, false, new InvalidOperationException("jetstream encryption key is not configured")); + + foreach (var cipher in ciphers) + candidates.Add((prf, cipher)); + + var oldPrf = JsKeyGen(opts.JetStreamOldKey, accountName); + if (oldPrf != null) + { + foreach (var cipher in ciphers) + candidates.Add((oldPrf, cipher)); + } + + for (var i = 0; i < candidates.Count; i++) + { + try + { + var rb = candidates[i].Prf(Encoding.UTF8.GetBytes(context)); + var kek = JetStreamFileStore.GenEncryptionKey(candidates[i].Cipher, rb); + var ns = kek.NonceSize; + if (encryptedKey.Length < ns || encryptedBuffer.Length < ns) + continue; + + var seed = kek.Open(encryptedKey.AsSpan(0, ns), encryptedKey.AsSpan(ns)); + var aek = JetStreamFileStore.GenEncryptionKey(candidates[i].Cipher, seed); + var plain = aek.Open(encryptedBuffer.AsSpan(0, ns), encryptedBuffer.AsSpan(ns)); + return (plain, i > 0, null); + } + catch + { + // Try the next candidate. + } + } + + return (null, false, new InvalidOperationException("unable to recover encrypted metadata")); + } + + internal Exception? CheckStoreDir(JetStreamConfig cfg) + { + if (string.IsNullOrWhiteSpace(cfg.StoreDir)) + return new InvalidOperationException("jetstream store directory is required"); + + try + { + Directory.CreateDirectory(cfg.StoreDir); + return null; + } + catch (Exception ex) + { + return ex; + } + } + + internal Exception? InitJetStreamEncryption() + { + var opts = GetOpts(); + + if (!string.IsNullOrEmpty(opts.JetStreamKey) && !string.IsNullOrEmpty(opts.JetStreamTpm.KeysFile)) + return new InvalidOperationException("JetStream encryption key may not be used with TPM options"); + + return null; + } + + private Exception? EnableJetStreamInternal(JetStreamConfig cfg) + { + var encryptionErr = InitJetStreamEncryption(); + if (encryptionErr != null) + return encryptionErr; + + try + { + Directory.CreateDirectory(cfg.StoreDir); + } + catch (Exception ex) + { + return ex; + } + + var js = new JetStream + { + Server = this, + Config = cfg, + Started = DateTime.UtcNow, + StandAlone = true, + }; + + _mu.EnterWriteLock(); + try + { + _jetStream = js; + _info.JetStream = true; + _info.Domain = cfg.Domain; + } + finally + { + _mu.ExitWriteLock(); + } + + var err = EnableJetStreamAccounts(); + if (err != null) + { + _mu.EnterWriteLock(); + try + { + _jetStream = null; + _info.JetStream = false; + } + finally + { + _mu.ExitWriteLock(); + } + } + + return err; + } + + internal bool CanExtendOtherDomain() + { + var opts = GetOpts(); + var sysAcc = SystemAccount()?.GetName(); + if (string.IsNullOrEmpty(sysAcc)) + return false; + + foreach (var remote in opts.LeafNode.Remotes) + { + if (!string.Equals(remote.LocalAccount, sysAcc, StringComparison.Ordinal)) + continue; + + foreach (var denyImport in remote.DenyImports) + { + if (SubscriptionIndex.SubjectIsSubsetMatch(denyImport, JsApiSubjects.JsAllApi)) + return false; + } + + return true; + } + + return false; + } + + internal void UpdateJetStreamInfoStatus(bool enabled) + { + _mu.EnterWriteLock(); + try + { + _info.JetStream = enabled; + } + finally + { + _mu.ExitWriteLock(); + } + } + + internal Exception? RestartJetStream() + { + var opts = GetOpts(); + var cfg = new JetStreamConfig + { + StoreDir = opts.StoreDir, + SyncInterval = opts.SyncInterval, + SyncAlways = opts.SyncAlways, + MaxMemory = opts.JetStreamMaxMemory, + MaxStore = opts.JetStreamMaxStore, + Domain = opts.JetStreamDomain, + Strict = !opts.NoJetStreamStrict, + }; + + Noticef("Restarting JetStream"); + var err = EnableJetStream(cfg); + if (err != null) + { + Warnf("Can't start JetStream: {0}", err.Message); + _ = DisableJetStream(); + return err; + } + + UpdateJetStreamInfoStatus(true); + return null; + } + + internal void CheckJetStreamExports() + { + if (SystemAccount() != null) + SetupJetStreamExports(); + } + + internal void SetupJetStreamExports() + { + var sys = SystemAccount(); + if (sys == null) + return; + + var err = sys.AddServiceExport(JsApiSubjects.JsAllApi, null); + if (err != null) + Warnf("Error setting up jetstream service exports: {0}", err.Message); + } + + internal bool JetStreamOOSPending() + { + var js = _jetStream; + if (js == null) + return false; + + js.Lock.EnterWriteLock(); + try + { + var wasPending = js.Oos; + js.Oos = true; + return wasPending; + } + finally + { + js.Lock.ExitWriteLock(); + } + } + + internal void SetJetStreamDisabled() + { + var js = _jetStream; + if (js != null) + Interlocked.Exchange(ref js.Disabled, 1); + } + + internal void HandleOutOfSpace(NatsStream? stream) + { + if (!JetStreamEnabled() || JetStreamOOSPending()) + return; + + if (stream != null) + Errorf("JetStream out of resources for stream {0}, will be DISABLED", stream.Config.Name); + else + Errorf("JetStream out of resources, will be DISABLED"); + + _ = Task.Run(() => DisableJetStream()); + } + + public Exception? DisableJetStream() + { + if (!JetStreamEnabled()) + return null; + + SetJetStreamDisabled(); + UpdateJetStreamInfoStatus(false); + + _mu.EnterWriteLock(); + try + { + _jetStream = null; + } + finally + { + _mu.ExitWriteLock(); + } + + ShutdownJetStream(); + ShutdownRaftNodes(); + return null; + } + + private Exception? EnableJetStreamAccounts() + { + if (GlobalAccountOnly()) + { + var gacc = GlobalAccount(); + if (gacc == null) + return new InvalidOperationException("global account not found"); + + gacc.JetStreamLimits ??= new Dictionary(StringComparer.Ordinal) + { + [string.Empty] = new JetStreamAccountLimits + { + MaxMemory = -1, + MaxStore = -1, + MaxStreams = -1, + MaxConsumers = -1, + MaxAckPending = -1, + MemoryMaxStreamBytes = -1, + StoreMaxStreamBytes = -1, + }, + }; + + return ConfigJetStream(gacc); + } + + return ConfigAllJetStreamAccounts(); + } + + internal Exception? ConfigJetStream(Account? acc) + { + if (acc == null) + return null; + + var jsLimits = acc.JetStreamLimits; + if (jsLimits != null) + return acc.EnableAllJetStreamServiceImportsAndMappings(); + + if (!ReferenceEquals(acc, SystemAccount())) + { + acc.JetStream = null; + return acc.EnableJetStreamInfoServiceImportOnly(); + } + + return null; + } + + internal Exception? ConfigAllJetStreamAccounts() + { + CheckJetStreamExports(); + if (_jetStream == null) + return null; + + foreach (var acc in _accounts.Values) + { + var err = ConfigJetStream(acc); + if (err != null) + return err; + } + + var storeDir = _jetStream.Config.StoreDir; + if (!Directory.Exists(storeDir)) + return null; + + foreach (var directory in Directory.EnumerateDirectories(storeDir)) + { + var accountName = Path.GetFileName(directory); + if (string.IsNullOrWhiteSpace(accountName) || _accounts.ContainsKey(accountName)) + continue; + + var (resolved, _) = LookupAccount(accountName); + if (resolved == null) + continue; + + var err = ConfigJetStream(resolved); + if (err != null) + return err; + } + + return null; + } + + public bool JetStreamEnabled() + { + var js = _jetStream; + return js != null && Interlocked.CompareExchange(ref js.Disabled, 0, 0) == 0; + } +} diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.cs b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.cs index ae0aee9..fec5439 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.cs @@ -253,6 +253,7 @@ public sealed partial class NatsServer : INatsServer private long _cproto; // count of INFO-capable clients private readonly ConcurrentDictionary _nodeToInfo = new(StringComparer.Ordinal); private readonly ConcurrentDictionary _raftNodes = new(StringComparer.Ordinal); + private JetStream? _jetStream; private readonly Dictionary _routesToSelf = []; private INetResolver? _routeResolver; private readonly ConcurrentDictionary _rateLimitLogging = new(); diff --git a/porting.db b/porting.db index 9bac02c4ea30fea02842a6c8a396f4e4b4c78086..86c296c2d5e7301edc95f7c59fc42a237f63ca1a 100644 GIT binary patch delta 5272 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 From 12b8c9b4c568e650f5e8451e6d5937cb9a7705dd Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 28 Feb 2026 21:01:11 -0500 Subject: [PATCH 2/9] feat(batch27): implement jetstream engine state and account enable core --- .../Accounts/Account.JetStream.cs | 248 ++++++++++++++++++ .../JetStream/JetStreamEngine.cs | 47 ++++ .../NatsServer.JetStreamCore.cs | 75 ++++++ porting.db | Bin 6705152 -> 6709248 bytes 4 files changed, 370 insertions(+) create mode 100644 dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JetStreamEngine.cs diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.JetStream.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.JetStream.cs index c20d2d9..d04e8ae 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.JetStream.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.JetStream.cs @@ -2,6 +2,254 @@ namespace ZB.MOM.NatsNet.Server; public sealed partial class Account { + private static Dictionary DefaultJetStreamAccountTiers() + { + return new Dictionary(StringComparer.Ordinal) + { + [string.Empty] = new JetStreamAccountLimits + { + MaxMemory = -1, + MaxStore = -1, + MaxStreams = -1, + MaxConsumers = -1, + MaxAckPending = -1, + MemoryMaxStreamBytes = -1, + StoreMaxStreamBytes = -1, + }, + }; + } + + private static Dictionary ToTypedLimits(Dictionary limits) + { + var typed = new Dictionary(StringComparer.Ordinal); + foreach (var (tier, value) in limits) + { + if (value is JetStreamAccountLimits v) + typed[tier] = v; + } + + return typed; + } + + private static JetStreamAccountLimits SelectLimits(Dictionary limits) + { + if (limits.TryGetValue(string.Empty, out var selected)) + return selected; + + foreach (var (_, value) in limits) + return value; + + return new JetStreamAccountLimits(); + } + + internal void AssignJetStreamLimits(Dictionary limits) + { + _mu.EnterWriteLock(); + try + { + JetStreamLimits = limits; + } + finally + { + _mu.ExitWriteLock(); + } + } + + internal Exception? EnableJetStream(Dictionary? limits) + { + _mu.EnterReadLock(); + var server = Server as NatsServer; + _mu.ExitReadLock(); + + if (server == null) + return new InvalidOperationException("jetstream account not registered"); + if (ReferenceEquals(server.SystemAccount(), this)) + return new InvalidOperationException("jetstream can not be enabled on the system account"); + + limits ??= DefaultJetStreamAccountTiers(); + if (limits.Count == 0) + limits = DefaultJetStreamAccountTiers(); + + AssignJetStreamLimits(limits); + var typedLimits = ToTypedLimits(limits); + + var js = server.GetJetStreamState(); + if (js == null) + return new InvalidOperationException("jetstream not enabled"); + + js.Lock.EnterWriteLock(); + try + { + if (js.Accounts.TryGetValue(Name, out var existing)) + { + _mu.EnterWriteLock(); + JetStream = existing; + _mu.ExitWriteLock(); + return EnableAllJetStreamServiceImportsAndMappings(); + } + + var jsa = new JsAccount + { + Js = js, + Account = this, + StoreDir = Path.Combine(js.Config.StoreDir, Name), + }; + + foreach (var (tier, tierLimits) in typedLimits) + jsa.Limits[tier] = tierLimits; + + js.Accounts[Name] = jsa; + _mu.EnterWriteLock(); + JetStream = jsa; + _mu.ExitWriteLock(); + } + finally + { + js.Lock.ExitWriteLock(); + } + + return EnableAllJetStreamServiceImportsAndMappings(); + } + + internal (bool MaxBytesRequired, long MaxStreamBytes) MaxBytesLimits(StreamConfig? cfg) + { + _mu.EnterReadLock(); + var jsa = JetStream; + _mu.ExitReadLock(); + if (jsa == null) + return (false, 0); + + jsa.UsageLock.EnterReadLock(); + try + { + var selected = SelectLimits(jsa.Limits); + var maxStreamBytes = cfg?.Storage == StorageType.MemoryStorage + ? selected.MemoryMaxStreamBytes + : selected.StoreMaxStreamBytes; + return (selected.MaxBytesRequired, maxStreamBytes); + } + finally + { + jsa.UsageLock.ExitReadLock(); + } + } + + internal int NumStreams() + { + _mu.EnterReadLock(); + var jsa = JetStream; + _mu.ExitReadLock(); + if (jsa == null) + return 0; + + jsa.Lock.EnterReadLock(); + try + { + return jsa.Streams.Count; + } + finally + { + jsa.Lock.ExitReadLock(); + } + } + + internal List Streams() => FilteredStreams(string.Empty); + + internal List FilteredStreams(string filter) + { + _mu.EnterReadLock(); + var jsa = JetStream; + _mu.ExitReadLock(); + if (jsa == null) + return []; + + jsa.Lock.EnterReadLock(); + try + { + var streams = new List(); + foreach (var stream in jsa.Streams.Values.OfType()) + { + if (string.IsNullOrWhiteSpace(filter)) + { + streams.Add(stream); + continue; + } + + foreach (var subject in stream.Config.Subjects ?? []) + { + if (Internal.DataStructures.SubscriptionIndex.SubjectsCollide(filter, subject)) + { + streams.Add(stream); + break; + } + } + } + + return streams; + } + finally + { + jsa.Lock.ExitReadLock(); + } + } + + internal (NatsStream? Stream, Exception? Error) LookupStream(string name) + { + _mu.EnterReadLock(); + var jsa = JetStream; + _mu.ExitReadLock(); + if (jsa == null) + return (null, new InvalidOperationException("jetstream not enabled for account")); + + jsa.Lock.EnterReadLock(); + try + { + if (jsa.Streams.TryGetValue(name, out var stream) && stream is NatsStream ns) + return (ns, null); + + return (null, new InvalidOperationException("stream not found")); + } + finally + { + jsa.Lock.ExitReadLock(); + } + } + + internal Exception? UpdateJetStreamLimits(Dictionary? limits) + { + _mu.EnterReadLock(); + var server = Server as NatsServer; + var jsa = JetStream; + _mu.ExitReadLock(); + + if (server == null) + return new InvalidOperationException("jetstream account not registered"); + if (server.GetJetStreamState() == null) + return new InvalidOperationException("jetstream not enabled"); + if (jsa == null) + return new InvalidOperationException("jetstream not enabled for account"); + + limits ??= DefaultJetStreamAccountTiers(); + if (limits.Count == 0) + limits = DefaultJetStreamAccountTiers(); + AssignJetStreamLimits(limits); + + var typed = ToTypedLimits(limits); + jsa.UsageLock.EnterWriteLock(); + try + { + jsa.Limits.Clear(); + foreach (var (tier, tierLimits) in typed) + jsa.Limits[tier] = tierLimits; + } + finally + { + jsa.UsageLock.ExitWriteLock(); + } + + return null; + } + internal Exception? EnableAllJetStreamServiceImportsAndMappings() { _mu.EnterReadLock(); diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JetStreamEngine.cs b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JetStreamEngine.cs new file mode 100644 index 0000000..9313f00 --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JetStreamEngine.cs @@ -0,0 +1,47 @@ +namespace ZB.MOM.NatsNet.Server; + +internal sealed class JetStreamEngine(JetStream state) +{ + private readonly JetStream _state = state; + + internal void SetStarted() + { + _state.Lock.EnterWriteLock(); + try + { + _state.Started = DateTime.UtcNow; + } + finally + { + _state.Lock.ExitWriteLock(); + } + } + + internal bool IsEnabled() => Interlocked.CompareExchange(ref _state.Disabled, 0, 0) == 0; + + internal void SetJetStreamStandAlone(bool isStandAlone) + { + _state.Lock.EnterWriteLock(); + try + { + _state.StandAlone = isStandAlone; + } + finally + { + _state.Lock.ExitWriteLock(); + } + } + + internal bool IsShuttingDown() + { + _state.Lock.EnterReadLock(); + try + { + return _state.ShuttingDown; + } + finally + { + _state.Lock.ExitReadLock(); + } + } +} diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.JetStreamCore.cs b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.JetStreamCore.cs index 774daa8..625632b 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.JetStreamCore.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.JetStreamCore.cs @@ -424,4 +424,79 @@ public sealed partial class NatsServer var js = _jetStream; return js != null && Interlocked.CompareExchange(ref js.Disabled, 0, 0) == 0; } + + public bool JetStreamEnabledForDomain() + { + if (JetStreamEnabled()) + return true; + + foreach (var value in _nodeToInfo.Values) + { + if (value is NodeInfo { Js: true }) + return true; + } + + return false; + } + + public JetStreamConfig? JetStreamConfig() + { + var js = _jetStream; + if (js == null) + return null; + + var cfg = js.Config; + return new JetStreamConfig + { + MaxMemory = cfg.MaxMemory, + MaxStore = cfg.MaxStore, + StoreDir = cfg.StoreDir, + SyncInterval = cfg.SyncInterval, + SyncAlways = cfg.SyncAlways, + Domain = cfg.Domain, + CompressOK = cfg.CompressOK, + UniqueTag = cfg.UniqueTag, + Strict = cfg.Strict, + }; + } + + public string StoreDir() + { + var js = _jetStream; + return js == null ? string.Empty : js.Config.StoreDir; + } + + public int JetStreamNumAccounts() + { + var js = _jetStream; + if (js == null) + return 0; + + js.Lock.EnterReadLock(); + try + { + return js.Accounts.Count; + } + finally + { + js.Lock.ExitReadLock(); + } + } + + public (long MemReserved, long StoreReserved, Exception? Error) JetStreamReservedResources() + { + var js = _jetStream; + if (js == null) + return (-1, -1, new InvalidOperationException("jetstream not enabled")); + + return ( + Interlocked.Read(ref js.MemReserved), + Interlocked.Read(ref js.StoreReserved), + null); + } + + internal JetStreamEngine? GetJetStream() => + _jetStream == null ? null : new JetStreamEngine(_jetStream); + + internal JetStream? GetJetStreamState() => _jetStream; } diff --git a/porting.db b/porting.db index 86c296c2d5e7301edc95f7c59fc42a237f63ca1a..da699df2eaf82bda55d6e0309f63fdf7020975c0 100644 GIT binary patch delta 4029 zcmaJ@Yfu~472bzls|PX&Fd)Pp;RXyfFG;*jAaT<;aa!VMOdvKwXd7$^un-ty9#Z07 zr^Gg-GvzvJoR+xmbUGc^_9Rv&c3NoqqchET@YKzZmg(Rbx0!gR4>y03{>b!d7cIM5 zT4v77`MBTt&bjxVJy)?Gzsbgy-el*U`nM>`AiM$X^jp_^J8J0hR~KuNi#2rM8oS2c zz{WMYxy=*1qc9p%uF0nVbVlIoLO#e=HtkL6i@-O%4 zd-RNrbuodV@Ng&?4eBRV=(Gw=sL&}D8dsr{Dm11-qbd}&CF>s0msp0P@XDw39d$Jk z6?a&LhE!-!g~BQnQlXd%sq;HYK6HihL;ry%2!M4*BwJtG1 zJW1jd@g#|H;z<%Gi6=>n5l@mBP5$gE)8jT>v6hqn8HA*Dh1pFr)^Z$gF4&!X`!4f6 zvwH|BHPvgs+qs-Z&nrOwB` z#$2Ems33L&-G)Q2bHB%T5m%059LM46^PCY6p5sb!>|LF~=xM2~ZSnZwE3L#DD6w&tmY~%aZ+m`vWs$y@#I-*=J`ciYe_63c*Mu z*sqTflq;junn6a>6h(g_qbY(O%4m|HRT+_MrtipzTs6HUBXZsJ4H=D7GZg(38IpUa z7i2{4o_>o_UYO~t8B3r=tcVmgcs9V!Dun}e#ZK0a1 zmo1MhC1#zylk}|O5Bu`@8RAzv@XWv};Yyli$no1(ooFZMh14%}J9`=r+W^%l;Go0p3- zx^}gQUsCk;$IkUabJhi4amBQdeH&;wPlF zTQ1(MUi-|&-&JILSij-urs6tSUpawxL zf;t2q1YQI_1oa3S5HuoaLePw$1wku4x zH-cvnJd0otg69x?3&FP$bRu{j!CnOW5bQ^A0Kq{7hY-Ag;4p$N1TP}!M$m)cB?Lzh z97WKJz>lC0K>$HNg5XUMbkI>kouTaevp&r_on^O8+HBTEtH<(@<$(FG<}tI8yU10L z=X{TG)fhH>WjL(Ap||RS>~GmpCPx2(E~Cz*W^c#Vcu8W2qg;$@#&z5)1pU#`NN_|S z!OQW!Rzr1Qq{Um0uNV7s@oy3(>GiIBD={HUg+oAT6ZHdT_&?n)dJ^6vd5Q8)F7q6d1C=nD-u`{oI zqpk2F7p%tfZo!JLyccvj>l<6VwV_16mN|o;C>5Gjtbmp^t!4FTSyLOVqV z)0a`3#}i8QYMG}u^in%Wv5sn46C12l))ecAmW7w~{hMTcNy{A9>h)+@c=>U^7cXrS zjN;j)Gk0&o-1~k&!A$SuMJ;o5BXgS7rDa7kSp$i~BI_At2SjlEXrPAmGy-0HFUY6$ zUeM|dZ|D_^=NGpp4oN-fP%4u4U7sd)pGW+i+vkr4URGxqNF3DGGPqeS1BnA#W?0RX zPIVx$U(3Ywg+Mv(6#|^RM^37~BC$`Je#bh6g(4gIg~FLlsR8~|@)P+v>yIq|A*1`M z*_0$>`5#n+a delta 1733 zcmY+C3v3is6ozN^H9OnR&bGVswcXxR9xbi(L0hmCDy8yjDHbTmtHAcLRY_Z$0*L}b z3(X-mCS8nh0tk^pj4`xO6FZ5Dpdm&QB`blb&}eH=Lt@ZK;v;I}=}wrGnf%HBoqO+p z=iWIzS5Me`-a28A2A!uQ$rU*Yi{(=z;i@8e-QlxE#@Qmd<&ynNM=>em@&cRVBkGz6 zrx*vu<*@AaT%GzFpH0ZW$On}h(t(y2JG$Bmq`N!PDvwE$yw{?;Ozjrk zVcKrdw@hm+`i806qOY0CEc%=&$D&J2X%=0SG)eYYIBewKm+6BNEhd%h+P$aQxs%=O zxO;(JR@^<$E)aLmvGc@ToAL0R?I*QzySGtV6t8S!XJ$0AGc&fbGc&fZGcz``Gcz_( z%CK!iWrQ!pjA&(NMzpXqBbwQn5l!sOh%h@dVu$h0u&puMeLk^(Yp#fq7_+UEX`&*n z);P0PAMhM07_+hLEC8?jPNA=z5c}b^tdIkGid3%M*D@0Ao-t2}+UZ`bD6TT+ zZpWwgUv0N-Gr1$CJiVLxck$&_Hk8uYY0iMr8xjX?o(#=2B5OwuwZqhGnP8!M( zI{5-3ovO}+bb_GRN6G)Fq2sIU3S9|`S@c_5P^X7Mv5~r4U1q&AQ_MDh_4!QEN!6oT zJ}nA~3^Sv`Ctjh~%UvM~FXwH>d}8|K@A6V&lK7r}uTjhpJz3*gnWc25au8h zAQU1LArvFbMJPciMJPjJf8^U&k v9SC8BCWK~$7KB!W$SAZqz$NPqlHOoshqu+}j^4bJ>u99B%}+b)+m8Pa9Gxt^ From 6726c077499ceb7dd06985e8355836debdc0363f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 28 Feb 2026 21:07:24 -0500 Subject: [PATCH 3/9] feat(batch27): implement jetstream usage foundations and limit helpers --- .../Accounts/Account.JetStream.cs | 156 ++++++++++++++ .../JetStream/JetStreamEngine.cs | 102 +++++++++ .../JetStream/JsAccount.Core.cs | 194 ++++++++++++++++++ porting.db | Bin 6709248 -> 6713344 bytes 4 files changed, 452 insertions(+) create mode 100644 dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JsAccount.Core.cs diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.JetStream.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.JetStream.cs index d04e8ae..a62381e 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.JetStream.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.JetStream.cs @@ -250,6 +250,162 @@ public sealed partial class Account return null; } + public JetStreamAccountStats JetStreamUsage() + { + _mu.EnterReadLock(); + var jsa = JetStream; + var accountName = Name; + var configuredLimits = JetStreamLimits; + _mu.ExitReadLock(); + + var stats = new JetStreamAccountStats(); + if (jsa == null) + return stats; + + var (js, _) = jsa.JetStreamAndClustered(); + if (js == null) + return stats; + + jsa.UsageLock.EnterReadLock(); + try + { + long mem = 0; + long store = 0; + foreach (var usage in jsa.Usage.Values) + { + mem += usage.Total.Mem; + store += usage.Total.Store; + } + + stats.Memory = (ulong)Math.Max(0, mem); + stats.Store = (ulong)Math.Max(0, store); + stats.Domain = js.Config.Domain; + stats.Api = new JetStreamApiStats + { + Level = JetStreamVersioning.JsApiLevel, + Total = jsa.ApiTotal, + Errors = jsa.ApiErrors, + }; + + if (jsa.Limits.TryGetValue(string.Empty, out var defaultTier)) + { + stats.Limits = defaultTier; + } + else + { + stats.Tiers = new Dictionary(StringComparer.Ordinal); + foreach (var (tier, usage) in jsa.Usage) + { + jsa.Limits.TryGetValue(tier, out var tierLimits); + stats.Tiers[tier] = new JetStreamTier + { + Memory = (ulong)Math.Max(0, usage.Total.Mem), + Store = (ulong)Math.Max(0, usage.Total.Store), + Limits = tierLimits ?? new JetStreamAccountLimits(), + }; + } + + if (configuredLimits != null) + { + foreach (var (tier, value) in configuredLimits) + { + if (stats.Tiers.ContainsKey(tier)) + continue; + if (value is not JetStreamAccountLimits lim) + continue; + stats.Tiers[tier] = new JetStreamTier { Limits = lim }; + } + } + } + } + finally + { + jsa.UsageLock.ExitReadLock(); + } + + var allStreams = Streams(); + stats.Streams = allStreams.Count; + foreach (var stream in allStreams) + stats.Consumers += stream.State().Consumers; + + if (stats.Tiers != null) + { + foreach (var stream in allStreams) + { + var tier = JetStreamEngine.TierName(stream.Config.Replicas); + if (!stats.Tiers.TryGetValue(tier, out var u)) + u = new JetStreamTier(); + u.Streams++; + u.Consumers += stream.State().Consumers; + stats.Tiers[tier] = u; + } + } + + if (stats.Tiers == null || stats.Tiers.Count == 0) + { + var (rmem, rstore) = jsa.ReservedStorage(string.Empty); + stats.ReservedMemory = rmem; + stats.ReservedStore = rstore; + } + else + { + foreach (var tier in stats.Tiers.Keys.ToArray()) + { + var tierStats = stats.Tiers[tier]; + (tierStats.ReservedMemory, tierStats.ReservedStore) = jsa.ReservedStorage(tier); + stats.Tiers[tier] = tierStats; + } + } + + _ = accountName; + return stats; + } + + internal Exception? DisableJetStream() => RemoveJetStream(); + + internal Exception? RemoveJetStream() + { + _mu.EnterWriteLock(); + var server = Server as NatsServer; + var jsa = JetStream; + JetStream = null; + _mu.ExitWriteLock(); + + if (server == null) + return new InvalidOperationException("jetstream account not registered"); + var js = server.GetJetStream(); + if (js == null) + return new InvalidOperationException("jetstream not enabled for account"); + + return js.DisableJetStream(jsa); + } + + internal bool JetStreamConfigured() + { + _mu.EnterReadLock(); + try + { + return JetStreamLimits != null && JetStreamLimits.Count > 0; + } + finally + { + _mu.ExitReadLock(); + } + } + + internal bool JetStreamEnabled() + { + _mu.EnterReadLock(); + try + { + return JetStream != null; + } + finally + { + _mu.ExitReadLock(); + } + } + internal Exception? EnableAllJetStreamServiceImportsAndMappings() { _mu.EnterReadLock(); diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JetStreamEngine.cs b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JetStreamEngine.cs index 9313f00..7402d39 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JetStreamEngine.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JetStreamEngine.cs @@ -3,6 +3,7 @@ namespace ZB.MOM.NatsNet.Server; internal sealed class JetStreamEngine(JetStream state) { private readonly JetStream _state = state; + private static readonly TimeSpan MinUsageUpdateWindow = TimeSpan.FromMilliseconds(250); internal void SetStarted() { @@ -44,4 +45,105 @@ internal sealed class JetStreamEngine(JetStream state) _state.Lock.ExitReadLock(); } } + + internal Exception? DisableJetStream(JsAccount? account) + { + if (account?.Account is not Account a) + return new InvalidOperationException("jetstream not enabled for account"); + + _state.Lock.EnterWriteLock(); + try + { + _state.Accounts.Remove(a.Name); + } + finally + { + _state.Lock.ExitWriteLock(); + } + + account.Delete(); + return null; + } + + internal bool WouldExceedLimits(StorageType storageType, int size) + { + var total = storageType == StorageType.MemoryStorage + ? Interlocked.Read(ref _state.MemUsed) + : Interlocked.Read(ref _state.StoreUsed); + var max = storageType == StorageType.MemoryStorage + ? _state.Config.MaxMemory + : _state.Config.MaxStore; + return total + size > max; + } + + internal bool LimitsExceeded(StorageType storageType) => WouldExceedLimits(storageType, 0); + + internal static string TierName(int replicas) => $"R{(replicas <= 0 ? 1 : replicas)}"; + + internal static bool IsSameTier(StreamConfig cfgA, StreamConfig cfgB) => + cfgA.Replicas == cfgB.Replicas; + + internal static Dictionary DiffCheckedLimits( + Dictionary a, + Dictionary b) + { + var diff = new Dictionary(StringComparer.Ordinal); + + foreach (var (tier, oldLimit) in a) + { + b.TryGetValue(tier, out var newLimit); + newLimit ??= new JetStreamAccountLimits(); + diff[tier] = new JetStreamAccountLimits + { + MaxMemory = newLimit.MaxMemory - oldLimit.MaxMemory, + MaxStore = newLimit.MaxStore - oldLimit.MaxStore, + }; + } + + foreach (var (tier, newLimit) in b) + { + if (a.ContainsKey(tier)) + continue; + + diff[tier] = new JetStreamAccountLimits + { + MaxMemory = newLimit.MaxMemory, + MaxStore = newLimit.MaxStore, + }; + } + + return diff; + } + + internal static (ulong Mem, ulong Store) ReservedStorage( + Dictionary streamAssignments, + string tier) + { + ulong mem = 0; + ulong store = 0; + + foreach (var assignment in streamAssignments.Values.OfType()) + { + var cfg = assignment.Config; + if (!string.IsNullOrEmpty(tier) && !string.Equals(tier, TierName(cfg.Replicas), StringComparison.Ordinal)) + continue; + if (cfg.MaxBytes <= 0) + continue; + + if (cfg.Storage == StorageType.FileStorage) + store += (ulong)cfg.MaxBytes; + else if (cfg.Storage == StorageType.MemoryStorage) + mem += (ulong)cfg.MaxBytes; + } + + return (mem, store); + } + + internal static bool ShouldSendUsageUpdate(DateTime lastUpdateUtc) => + DateTime.UtcNow - lastUpdateUtc >= MinUsageUpdateWindow; +} + +internal sealed class StreamAssignmentView +{ + public required StreamConfig Config { get; init; } } diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JsAccount.Core.cs b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JsAccount.Core.cs new file mode 100644 index 0000000..450e9e2 --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JsAccount.Core.cs @@ -0,0 +1,194 @@ +namespace ZB.MOM.NatsNet.Server; + +internal sealed partial class JsAccount +{ + internal (ulong Mem, ulong Store) ReservedStorage(string tier) + { + ulong mem = 0; + ulong store = 0; + + Lock.EnterReadLock(); + try + { + foreach (var stream in Streams.Values.OfType()) + { + var cfg = stream.Config; + if (!string.IsNullOrEmpty(tier) && !string.Equals(tier, JetStreamEngine.TierName(cfg.Replicas), StringComparison.Ordinal)) + continue; + if (cfg.MaxBytes <= 0) + continue; + + if (cfg.Storage == StorageType.FileStorage) + store += (ulong)cfg.MaxBytes; + else if (cfg.Storage == StorageType.MemoryStorage) + mem += (ulong)cfg.MaxBytes; + } + } + finally + { + Lock.ExitReadLock(); + } + + return (mem, store); + } + + internal void RemoteUpdateUsage(byte[] message) + { + if (message.Length < 16) + return; + + UsageLock.EnterWriteLock(); + try + { + if (!Usage.TryGetValue(string.Empty, out var usage)) + { + usage = new JsaStorage(); + Usage[string.Empty] = usage; + } + + usage.Total.Mem = BitConverter.ToInt64(message, 0); + usage.Total.Store = BitConverter.ToInt64(message, 8); + } + finally + { + UsageLock.ExitWriteLock(); + } + } + + internal void CheckAndSyncUsage(string tier, StorageType storageType) + { + if (Interlocked.CompareExchange(ref Sync, 1, 0) != 0) + return; + + try + { + Lock.EnterReadLock(); + try + { + long total = 0; + foreach (var stream in Streams.Values.OfType()) + { + if (!string.Equals(JetStreamEngine.TierName(stream.Config.Replicas), tier, StringComparison.Ordinal)) + continue; + if (stream.Config.Storage != storageType) + continue; + total += (long)stream.State().Bytes; + } + + UsageLock.EnterWriteLock(); + try + { + Usage.TryGetValue(tier, out var usage); + usage ??= new JsaStorage(); + Usage[tier] = usage; + + if (storageType == StorageType.MemoryStorage) + { + usage.Local.Mem = total; + usage.Total.Mem = total; + } + else + { + usage.Local.Store = total; + usage.Total.Store = total; + } + } + finally + { + UsageLock.ExitWriteLock(); + } + } + finally + { + Lock.ExitReadLock(); + } + } + finally + { + Interlocked.Exchange(ref Sync, 0); + } + } + + internal void UpdateUsage(string tier, StorageType storageType, long delta) + { + UsageLock.EnterWriteLock(); + try + { + Usage.TryGetValue(tier, out var usage); + usage ??= new JsaStorage(); + Usage[tier] = usage; + + if (storageType == StorageType.MemoryStorage) + { + usage.Local.Mem += delta; + usage.Total.Mem += delta; + } + else + { + usage.Local.Store += delta; + usage.Total.Store += delta; + } + } + finally + { + UsageLock.ExitWriteLock(); + } + } + + internal void SendClusterUsageUpdateTimer() + { + UsageLock.EnterWriteLock(); + try + { + SendClusterUsageUpdate(); + } + finally + { + UsageLock.ExitWriteLock(); + } + } + + internal void SendClusterUsageUpdate() + { + var now = DateTime.UtcNow; + if (!JetStreamEngine.ShouldSendUsageUpdate(LUpdate)) + return; + LUpdate = now; + + // Cluster bus publish is wired in later cluster sessions. + UsageApi = ApiTotal; + UsageErr = ApiErrors; + } + + internal (JetStream? JetStream, bool Clustered) JetStreamAndClustered() + { + Lock.EnterReadLock(); + try + { + var js = Js as JetStream; + return (js, js?.Cluster != null); + } + finally + { + Lock.ExitReadLock(); + } + } + + internal Account? Acc() => Account as Account; + + internal void Delete() + { + Lock.EnterWriteLock(); + try + { + Streams.Clear(); + Inflight.Clear(); + UpdatesSub = null; + UpdatesPub = string.Empty; + } + finally + { + Lock.ExitWriteLock(); + } + } +} diff --git a/porting.db b/porting.db index da699df2eaf82bda55d6e0309f63fdf7020975c0..76bfcb7a4f9d1ddd24b15756ceb17d1bff9a010a 100644 GIT binary patch delta 4250 zcmbVOYitu&7M>Y`*fBP{s}#tKS0J(T zI0;Dz38K0J)2_1JqCQkw5W;Gqg|<>3&|Q_?{n16NlmZ8Z@Xu0~*w?L46t&b(-Sm{>sn0Tu4lr zLPuv;3+up_+$I?bDRrBte-7dtfq zQ(dG$75#k|`JHO)C%ZMt@9e%?nv{HCk0yFy4|z)-8#&0PwPi1{(mOjGa%rwaWBPof zwlt1k9o<(!>r>qs^y|IkUDc3J_o>@R|Gkey<*3x;-|Qz#RHa_uPfp2E?_*9UeWRN! zP(}aLO^zjcTtFueXoh`yfSgoPma0ZvI7mKFWqY-U6sn@vd&pHeDoy=ruR4kmf)P7L z92iN$i1U0TnV&XWKH{3G=y$zMzw*1r=uN+?Aigc&+9LK^ksI3<3WwU;L)*lo4D~2z zlA&D+nqa6!K}Q%`r=W3$)+p#OLp2H-W2i_$qYULKXvAtlE;;_tc9&T+bDVIRHnzGJ zZDihn!Wm*{w}OzNpDSpPp-l=JV5m_+{R};zpgxAYLswc|%u<-8GD~BY&MbpjCbKMN z^O$8b%V9R3SuV3YX72cv*8Bc&R<$Od=jW@=0zy6lAb1cKAS^^EK)4@a5yE1GLWCs< zMF>j~iV;c>N)gHsya+yoa)b(mN`xweYJ_D7H3+o`bqLE5Rv@fIcmQD)LOntQ!h;B_ z5gtN#7-0>z1-pv6S6gw|8*!+En7XC zY-`L;5`{mRx$676kJ5$(;o3xH=R(dgi1t)23%m7&EPU<`F&9SKAu1 z=$YfjOi|A~tY+p(GfHTh^-OxEI#59`FO*kCnSX+9(z8d6bp<_>-r5lQ5nW!-KBU%_ zS0GUVr)LfuGEemCnM20R9z7F{nFsaEL1X3tJ#)aA*{x^xtC`ALo!GBu(rf2}#yvf; zPtT63b*(ddAa2_f{*PGbWWO9K=5ILH6UoP&e{=ehqK@A>a_qk(7l^~wY<<&m+w!;= zGk+8J0ROQ$n;phH-C^tykJ2gHw0!0$roP{zIf^y29YrRZYalwQ7H#|F-bLpdh)zhN zmc*s8tX6j!Et^BBIR>h<{#dYtrbL@uQV*&rUlq=qx!~q9{cHoxap?}t(BGlHD*D52 z%%$#m207@pB~7{1zcrAauDhyz756%eEQ1VV)YBa($5ygX4+GmArsP8HLq9u8*; z6}7qtZ7#ZLM!Lgl-5UB+PG_pY!VXE>Q`+;=X{ywprrCGCHbkGJNb(Cw_)}!`(nwmtW~pm1N6g^(2Q<*m9ERP{*FXTV@_F@ XSLxFH!6x}PN9wW9r{2vl+fMrqw9^2j delta 1640 zcmZwGYfM{p7zXfj+Mc%m_Oz#5)-j>uDmN7vOc@FT2FwlFWNh<-$jYVYhq6Jr--{^B zX$T{RH=ASz5VN2`M~p`em}Xn%bV>~FgN`M_O#HYk@ym1@@u8=jq={*M$@84I|Ce)4 zsCVt0*6TZ`9g7quIZoejHYT5+yyPrM=R1d{(&edizU~X{ns$K0c2RA6 z(ax(a3+;WiWm>fImw)oxeaY4;Zf8qtTdgpF6z)&`NG5;kW7=&Rf9jS0p3(fp@>(&= z^01pWknZ~vZ517S_HRN77F+@p7pZO$lBXful?U!C4c zh0UyzuEa(AX z4;!b_Ol4-v7XA;`DnW0lm_Mc z8mU^K3o|xRBaBnpj4heUrfm{GN{6Ry*}|F4AFf>dhNKg8;!Q4!%G^?7)aJ!{SA)yr zan%bWL2Z~mn6{+|{vSMLd}gzcthgnxK?sCG7=*(%*qm>N2-pFSK_og&F)LakPaEJ3o;=KcEi)~ z3}nL|$bo0!Imm@P*bDhk0EMs*il7)uU_U$$2jC!-!V6Fahu|>02<31DD&Qy_gX8cL zybQ0vt56BAK^43X)o=owPy@A42le2R^)U?_>2_u^a?(-jCzgLKW#UKXf6d2C^CqJl O8^hmavazz^zVIJZGZg6n From 68f32c7b85efb8bc0656f2222db53a38eee091c3 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 28 Feb 2026 21:10:32 -0500 Subject: [PATCH 4/9] feat(batch27): implement jsaccount limit checks and resource accounting --- .../JetStream/JetStreamEngine.cs | 179 ++++++++++++++++++ .../JetStream/JsAccount.Core.cs | 111 +++++++++++ porting.db | Bin 6713344 -> 6717440 bytes 3 files changed, 290 insertions(+) diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JetStreamEngine.cs b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JetStreamEngine.cs index 7402d39..ba08743 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JetStreamEngine.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JetStreamEngine.cs @@ -141,6 +141,185 @@ internal sealed class JetStreamEngine(JetStream state) internal static bool ShouldSendUsageUpdate(DateTime lastUpdateUtc) => DateTime.UtcNow - lastUpdateUtc >= MinUsageUpdateWindow; + + internal Exception? CheckAccountLimits( + JetStreamAccountLimits selected, + StreamConfig config, + long currentReservation) => + CheckLimits(selected, config, checkServer: false, currentReservation, 0); + + internal Exception? CheckAllLimits( + JetStreamAccountLimits selected, + StreamConfig config, + long currentReservation, + long maxBytesOffset) => + CheckLimits(selected, config, checkServer: true, currentReservation, maxBytesOffset); + + internal Exception? CheckLimits( + JetStreamAccountLimits selected, + StreamConfig config, + bool checkServer, + long currentReservation, + long maxBytesOffset) + { + if (config.MaxConsumers > 0 && selected.MaxConsumers > 0 && config.MaxConsumers > selected.MaxConsumers) + return new InvalidOperationException("maximum consumers limit exceeded"); + + return CheckBytesLimits(selected, config.MaxBytes, config.Storage, checkServer, currentReservation, maxBytesOffset); + } + + internal Exception? CheckBytesLimits( + JetStreamAccountLimits selected, + long addBytes, + StorageType storage, + bool checkServer, + long currentReservation, + long maxBytesOffset) + { + if (addBytes < 0) + addBytes = 1; + + var totalBytes = addBytes + maxBytesOffset; + switch (storage) + { + case StorageType.MemoryStorage: + if (selected.MaxMemory >= 0 && currentReservation + totalBytes > selected.MaxMemory) + return new InvalidOperationException("insufficient memory resources"); + if (checkServer && Interlocked.Read(ref _state.MemReserved) + totalBytes > _state.Config.MaxMemory) + return new InvalidOperationException("insufficient memory resources"); + break; + + case StorageType.FileStorage: + if (selected.MaxStore >= 0 && currentReservation + totalBytes > selected.MaxStore) + return new InvalidOperationException("insufficient storage resources"); + if (checkServer && Interlocked.Read(ref _state.StoreReserved) + totalBytes > _state.Config.MaxStore) + return new InvalidOperationException("insufficient storage resources"); + break; + } + + return null; + } + + internal JsAccount? LookupAccount(Account? account) + { + if (account == null) + return null; + + _state.Lock.EnterReadLock(); + try + { + _state.Accounts.TryGetValue(account.Name, out var jsa); + return jsa; + } + finally + { + _state.Lock.ExitReadLock(); + } + } + + internal JetStreamStats UsageStats() + { + var stats = new JetStreamStats(); + _state.Lock.EnterReadLock(); + try + { + stats.Accounts = _state.Accounts.Count; + stats.ReservedMemory = (ulong)Math.Max(0, Interlocked.Read(ref _state.MemReserved)); + stats.ReservedStore = (ulong)Math.Max(0, Interlocked.Read(ref _state.StoreReserved)); + stats.Api.Level = JetStreamVersioning.JsApiLevel; + stats.Api.Total = (ulong)Math.Max(0, Interlocked.Read(ref _state.ApiTotal)); + stats.Api.Errors = (ulong)Math.Max(0, Interlocked.Read(ref _state.ApiErrors)); + stats.Api.Inflight = (ulong)Math.Max(0, Interlocked.Read(ref _state.ApiInflight)); + stats.Memory = (ulong)Math.Max(0, Interlocked.Read(ref _state.MemUsed)); + stats.Store = (ulong)Math.Max(0, Interlocked.Read(ref _state.StoreUsed)); + stats.HAAssets = 0; + } + finally + { + _state.Lock.ExitReadLock(); + } + + return stats; + } + + internal Exception? SufficientResources(Dictionary? limits) + { + if (limits == null || !_state.StandAlone) + return null; + + static (long MaxMem, long MaxStore) Totals(Dictionary source) + { + long mem = 0; + long store = 0; + foreach (var lim in source.Values) + { + if (lim.MaxMemory > 0) mem += lim.MaxMemory; + if (lim.MaxStore > 0) store += lim.MaxStore; + } + + return (mem, store); + } + + var (totalMem, totalStore) = Totals(limits); + if (Interlocked.Read(ref _state.MemReserved) + totalMem > _state.Config.MaxMemory) + return new InvalidOperationException("insufficient memory resources"); + if (Interlocked.Read(ref _state.StoreReserved) + totalStore > _state.Config.MaxStore) + return new InvalidOperationException("insufficient storage resources"); + + long reservedMem = 0; + long reservedStore = 0; + _state.Lock.EnterReadLock(); + try + { + foreach (var jsa in _state.Accounts.Values) + { + jsa.UsageLock.EnterReadLock(); + try + { + var (m, s) = Totals(jsa.Limits); + reservedMem += m; + reservedStore += s; + } + finally + { + jsa.UsageLock.ExitReadLock(); + } + } + } + finally + { + _state.Lock.ExitReadLock(); + } + + if (reservedMem + totalMem > _state.Config.MaxMemory) + return new InvalidOperationException("insufficient memory resources"); + if (reservedStore + totalStore > _state.Config.MaxStore) + return new InvalidOperationException("insufficient storage resources"); + + return null; + } + + internal void ReserveStreamResources(StreamConfig? cfg) + { + if (cfg == null || cfg.MaxBytes <= 0) + return; + + if (cfg.Storage == StorageType.MemoryStorage) + Interlocked.Add(ref _state.MemReserved, cfg.MaxBytes); + else if (cfg.Storage == StorageType.FileStorage) + Interlocked.Add(ref _state.StoreReserved, cfg.MaxBytes); + } + + internal void ReleaseStreamResources(StreamConfig? cfg) + { + if (cfg == null || cfg.MaxBytes <= 0) + return; + + if (cfg.Storage == StorageType.MemoryStorage) + Interlocked.Add(ref _state.MemReserved, -cfg.MaxBytes); + else if (cfg.Storage == StorageType.FileStorage) + Interlocked.Add(ref _state.StoreReserved, -cfg.MaxBytes); + } } internal sealed class StreamAssignmentView diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JsAccount.Core.cs b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JsAccount.Core.cs index 450e9e2..7437ae5 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JsAccount.Core.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JsAccount.Core.cs @@ -176,6 +176,117 @@ internal sealed partial class JsAccount internal Account? Acc() => Account as Account; + internal (JetStreamAccountLimits Limits, string Tier, bool Found) SelectLimits(int replicas) + { + UsageLock.EnterReadLock(); + try + { + if (Limits.TryGetValue(string.Empty, out var selected)) + return (selected, string.Empty, true); + + var tier = JetStreamEngine.TierName(replicas); + if (Limits.TryGetValue(tier, out selected)) + return (selected, tier, true); + + return (new JetStreamAccountLimits(), string.Empty, false); + } + finally + { + UsageLock.ExitReadLock(); + } + } + + internal int CountStreams(string tier, StreamConfig cfg) + { + Lock.EnterReadLock(); + try + { + var count = 0; + foreach (var stream in Streams.Values.OfType()) + { + if ((!string.IsNullOrEmpty(tier) && !JetStreamEngine.IsSameTier(stream.Config, cfg)) || + string.Equals(stream.Config.Name, cfg.Name, StringComparison.Ordinal)) + continue; + count++; + } + + return count; + } + finally + { + Lock.ExitReadLock(); + } + } + + internal (ulong Mem, ulong Store) StorageTotals() + { + UsageLock.EnterReadLock(); + try + { + ulong mem = 0; + ulong store = 0; + foreach (var usage in Usage.Values) + { + mem += (ulong)Math.Max(0, usage.Total.Mem); + store += (ulong)Math.Max(0, usage.Total.Store); + } + + return (mem, store); + } + finally + { + UsageLock.ExitReadLock(); + } + } + + internal (bool Exceeded, JsApiError? Error) LimitsExceeded(StorageType storageType, string tier, int replicas) => + WouldExceedLimits(storageType, tier, replicas, string.Empty, null, null); + + internal (bool Exceeded, JsApiError? Error) WouldExceedLimits( + StorageType storageType, + string tier, + int replicas, + string subject, + byte[]? headers, + byte[]? payload) + { + UsageLock.EnterReadLock(); + try + { + if (!Limits.TryGetValue(tier, out var selected)) + return (true, JsApiErrors.NewJSNoLimitsError()); + if (!Usage.TryGetValue(tier, out var inUse)) + return (false, null); + + var r = Math.Max(1, replicas); + var lr = string.IsNullOrEmpty(tier) ? 1 : r; + var bytes = (subject.Length + (headers?.Length ?? 0) + (payload?.Length ?? 0)) * r; + + if (storageType == StorageType.MemoryStorage) + { + var totalMem = inUse.Total.Mem + bytes; + if (selected.MemoryMaxStreamBytes > 0 && totalMem > selected.MemoryMaxStreamBytes * lr) + return (true, null); + if (selected.MaxMemory >= 0 && totalMem > selected.MaxMemory * lr) + return (true, null); + } + else + { + var totalStore = inUse.Total.Store + bytes; + if (selected.StoreMaxStreamBytes > 0 && totalStore > selected.StoreMaxStreamBytes * lr) + return (true, null); + if (selected.MaxStore >= 0 && totalStore > selected.MaxStore * lr) + return (true, null); + } + + return (false, null); + } + finally + { + UsageLock.ExitReadLock(); + } + } + internal void Delete() { Lock.EnterWriteLock(); diff --git a/porting.db b/porting.db index 76bfcb7a4f9d1ddd24b15756ceb17d1bff9a010a..82f20b196d9dc9409eeceec36407b3af5935ec2d 100644 GIT binary patch delta 4266 zcmZ`)Yj9J?6~4RoUR|#(OKTw<2g|l(i~(b8BR_k`KnY2w?!;iK@DL8~jOPGv9NJC57bajQLDN0l^F%co5HrOT{2zP=U%|cjH~1jl zgSX+e*n^+N({L3Yg@<9I+$Ud@d*svjP5cAteOUjYClk8*(041_rG3&aX|oZh-AlWf z_E+V1%30-v(xH4$c~#k}G$>1yI%SelrsOMD#bCH^xNW#>_#+&5P)E)&xlMje-Y&0~ zee!&Hx;$Pkl5Mg{7NonYU3B^6NK8J?gsiMthtGb(ErqPw~-0K zl&=thTer|J!xa~(cOM{r8&$kqfR0l*3;O%eB-l5H-T@E7#a45B;=j5F;iE`0b>VKf z{|j0GZ(A`{8%69+qx>#>Iq<&ZZgUN+Jx+&(FUiH|*A9+)qUZ=mr=y5JYvEWFy}?mi z6dmU1wI~X4v^|R2I9eY?hdA;LAVYJy`ji>JRVE$8Jp$bJ;A*&h5)=4!FU^Mgwb%sC zDY&P#mN&n?7XJ)mVLN0^!!N>&I`ab<@&H{`gVS;2^SMMEiZ^ZE;*&emdj+eu>fozX zZFNNbtQ+4X6T!4hsL{T(@|9CtweZy_ZPmJupYns}A0l%Ee zXQN?dsBpLAGUg_7leo#;4BQmeaXF>$mC@*|kRdEY$Sy22k1;i*Gvfv0khI*?B9gD% zQ5G2v%RiTT#h;@4;IN}&s9Z-H!Qqv2pg%we^sFN#(7%-s$z5Gh1$HkvY!w|s%`V5@ ze2ajdZgQ39xiK0FA9%?qIO`?nBVu|y#SB06ky1@;4U2gluXD+hP44o%^04G{AK4B! zeB`W7@`n|qSht^{p6u3n->N6Wb>4s0lRXjdK0m3{IWGFis)$1?!WSTUI`8WN@&s6N z^0X31Xbw|2!ClV1HlAQ@)0Wm3$z!nmUWyDeR+1BuNcKizg^>-UKo@CI1Nl+ZTds3> z8cB0x3r#j+70K6m=dU9Bb>7U?q)_vwgtKU>-kSyJ8^i{Ut4Xb{ir&?vNEbv2lBLlq z2z)z8M(Vt0gXB9p?dmlTw}>2L`x>%Z=Pg}JB5jCxy=%!fomX6^_wN2`9pRSBEsdLz zTe|wyIuq_UC`?$O%m&3{J^|-F=5FZqn2l<^*IX!MjediGdi5n0!BwLTFB@~Mn^F^n$aRg$1qyV=vYQe7%gSAjL~t7IvFiz zw1Uw}MynWgF*=@6H>1^zPGGc#(TR*sVstX2Qy8sfbSk4?V{{s$PcS;2(I*+5!RSmz zXEFK|qjiiv&FE}KpJ8+kqjMR3meF~P&S!K1qYD{*j?qPoE@pHIqe~fG#_02mex1?f zjCvULGU{V=1*7$h`cKnO8i)n%^gN>?y6B9V6}8LuFbcQ6@mcp?vy z@$wUSt;u+fL|#jbXN%ThPvk*Hjkg@m4G$<<&AAV`O;RXNBk#~R=Kwff@{Wc6l>v*X zsysaSecsJ08k{aWct7&z#2S{HxKVd3p%^bGk=GUDDG!rTKdui9O>5mrq>3!mFPQh6 z^G$6gOL}*Dh4EdZBkkqXzoohfqnJ1gM)s(=ku!iHsmBAYgl?4gMfInv8J z;KB`Gv8GfsrLzo9*}PU*UlmXu(bpu=AA#ED6%l=8=Q7Cnm2aqCK1|y_H#8w~Ao|^* zc0@)tc$RyIgbyV{bh;CFACOlkkOZ8)8O&jNe z4e!OLKA;|-O#SIgOSIf=aPr_{Sq^t^9AvBtbi(2Z@ZXhz{DxjCY&f}Vu|w6r@*zF#4&vA*$W}VVCxUwu!shbL@D&h7HJf6}d=h6-5n|u?r zGb!gs|H!=`P?jW7c4Nr3prYI3;-4G;{CO3d_dVy3~+$V6T!#v2&G{|9gp B8Danc delta 2748 zcmai!ZE#f88OQHAd-m?0WOMIj6Cpxgb|FLzF$r%K6p}2&@X`n&Mg_vlZnB#Y3?U-L z8QW>VSb`dC5GRjigu#YV$_yE0&`T|2DdtNZ%cS_F(^81e#7<#U40a5nw*TEr&Tc+6 zncwF5o#&kAIcLwk*|X=cYtQhnT)orWM>I`uIT)y5N8S!sma;dx-Ym7>EM?(indW58 zZ}jP!#$2W_MvYgbwFkclfh}~kV zkEef~{#p9psBEv*OnYvcI@P|#b7=SzR>(1ozuBGcY-f}4&!!#QJKAEqVrbK+8Or{X zniFZr)sN?dgjFl^z!oagVMXKSx&zBm_AM;lVUE-A_JkHYCzN?QwT#HJFMi6VbNcWO zn?=46Hb6zU*j##|!nEw~M%Yzm;rsQC-&MS@uyp&H87TrtW~9o?s^@*l+D)Rs17nkKSQ^ zAujSQ8dXMkD;=n{zNA%*7u!9I|Aq;3G4H3bcdP<>FN<4ttBWs6r_HAR5~)~0MdY_qa;uv%r0 zc{}}VsnR+m=PO$aD^yks%T~6=>rdXUSOrtJD^|kP?TQsJb-QAD>J5o%ufJ0pR7D@b z)OVFt!A>e$20N^5DXd3X2=;Sjm9P$F6)^SEh;o>EX~aC(a`nWDxlr}QiaD_4naW_v zGnK-UXDUfNCgIh5ioLy>Z{3uaF{I@^v+E~~?t_T35>YRrv_$kv`&K{yOfKm*HfoC# zBl{4|O+>#yG%FFkfXJ!Qg~+M#JR+yYUYb0>A7Anc{`EK|4j^($^dNFdyo|^x@e(4Z z#EXcW68r6w1AJr9d%_pQnBHRhhIkF5;KJ-``{-J;E3G?tg##B5zzuZZv4dB|N)XuD{~GEdDKYKElpZsJCeo1tYS8Mn9gCOV%l` zNj(ucgL(&D=@f{_H)t#(|3v;8&)w`uqb!YQ4SK6d{(<FX0s4HaoaKzoXqBf@%HM941x}UO+vK5mmAiefZ<+k zyDJv7$J_49Gih6U{E&ydiL*%hwtaki#XICr>DLFHgqQxZL(X)1JQD6b9eEQ>V z>7kLZEQy~qgZ_KX?d}#G5~P82-~$=9=rF$jQr9nP4f@Z>6E+7tXY~{M>-tM{A#5I} zp|C0JXv8dZ8>#QQyN}9_TT^xSYw=P0T(fz|%?7$_+Du@9Nx%;>K^C|NOa@axHn<)8vof)H2=mVqkp2&e|j!3wYvtO7p< zHQ-UO8ms}eU@fQvkAZsdICug)3D$x2U;}6X8^I>98EgSz5CM}D86)~)6r*0?Nj U1NYY)w1WZmfv|5xo9{{gA4p+3s{jB1 From 5b2d32c50316f4ac69bae9490d07c0b71508fedd Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 28 Feb 2026 21:15:57 -0500 Subject: [PATCH 5/9] feat(batch27): implement jetstream config validation and error tail --- .../Accounts/Account.JetStream.cs | 16 +++ .../JetStream/JetStreamEngine.cs | 116 ++++++++++++++++++ .../ZB.MOM.NatsNet.Server/NatsServer.Init.cs | 4 + .../NatsServer.JetStreamCore.cs | 76 ++++++++++++ .../src/ZB.MOM.NatsNet.Server/NatsServer.cs | 2 + porting.db | Bin 6717440 -> 6721536 bytes 6 files changed, 214 insertions(+) diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.JetStream.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.JetStream.cs index a62381e..c0c8865 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.JetStream.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.JetStream.cs @@ -111,6 +111,22 @@ public sealed partial class Account return EnableAllJetStreamServiceImportsAndMappings(); } + internal (NatsServer? Server, JsAccount? JetStreamAccount, Exception? Error) CheckForJetStream() + { + _mu.EnterReadLock(); + var server = Server as NatsServer; + var jsa = JetStream; + _mu.ExitReadLock(); + + if (server == null || jsa == null) + { + var description = JsApiErrors.NewJSNotEnabledForAccountError().Description ?? "jetstream not enabled for account"; + return (null, null, new InvalidOperationException(description)); + } + + return (server, jsa, null); + } + internal (bool MaxBytesRequired, long MaxStreamBytes) MaxBytesLimits(StreamConfig? cfg) { _mu.EnterReadLock(); diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JetStreamEngine.cs b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JetStreamEngine.cs index ba08743..522bc08 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JetStreamEngine.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JetStreamEngine.cs @@ -4,6 +4,9 @@ internal sealed class JetStreamEngine(JetStream state) { private readonly JetStream _state = state; private static readonly TimeSpan MinUsageUpdateWindow = TimeSpan.FromMilliseconds(250); + private const string JsWillExtend = "will_extend"; + private const string JsNoExtend = "no_extend"; + private const string JsDomainApiTemplate = "$JS.{0}.API.>"; internal void SetStarted() { @@ -320,6 +323,119 @@ internal sealed class JetStreamEngine(JetStream state) else if (cfg.Storage == StorageType.FileStorage) Interlocked.Add(ref _state.StoreReserved, -cfg.MaxBytes); } + + internal static string FriendlyBytes(T bytes) + where T : struct, IConvertible + { + var value = Convert.ToDouble(bytes); + const int baseValue = 1024; + var units = new[] { "K", "M", "G", "T", "P", "E" }; + + if (value < baseValue) + return $"{value} B"; + + var exp = (int)(Math.Log(value) / Math.Log(baseValue)); + var index = Math.Clamp(exp - 1, 0, units.Length - 1); + return $"{value / Math.Pow(baseValue, exp):0.00} {units[index]}B"; + } + + internal static bool IsValidName(string name) + { + if (string.IsNullOrWhiteSpace(name)) + return false; + + return name.IndexOfAny([' ', '\t', '\r', '\n', '\f', '.', '*', '>']) < 0; + } + + internal static Exception? ValidateJetStreamOptions(ServerOptions options) + { + foreach (var (account, domain) in options.JsAccDefaultDomain) + { + var exists = false; + if (ServerOptions.IsReservedAccount(account)) + { + exists = true; + } + else if (options.TrustedOperators.Count == 0) + { + foreach (var configured in options.Accounts) + { + if (!string.Equals(configured.GetName(), account, StringComparison.Ordinal)) + continue; + + if (configured.JetStreamLimits?.Count > 0 && !string.IsNullOrEmpty(domain)) + return new InvalidOperationException($"default_js_domain contains account name \"{account}\" with enabled JetStream"); + + exists = true; + break; + } + } + else + { + exists = IsLikelyPublicAccountNkey(account); + } + + if (!exists) + return new InvalidOperationException($"in non operator mode, `default_js_domain` references non existing account \"{account}\""); + } + + foreach (var (account, domain) in options.JsAccDefaultDomain) + { + var systemAccount = string.IsNullOrWhiteSpace(options.SystemAccount) + ? ServerConstants.DefaultSystemAccount + : options.SystemAccount; + + if (string.Equals(account, systemAccount, StringComparison.Ordinal)) + return new InvalidOperationException($"system account \"{account}\" can not be in default_js_domain"); + + if (string.IsNullOrWhiteSpace(domain)) + continue; + + var subject = string.Format(JsDomainApiTemplate, domain); + if (!Internal.DataStructures.SubscriptionIndex.IsValidSubject(subject)) + return new InvalidOperationException($"default_js_domain contains account \"{account}\" with invalid domain name \"{domain}\""); + } + + if (!string.IsNullOrWhiteSpace(options.JetStreamDomain)) + { + var subject = string.Format(JsDomainApiTemplate, options.JetStreamDomain); + if (!Internal.DataStructures.SubscriptionIndex.IsValidSubject(subject)) + return new InvalidOperationException($"invalid domain name: derived \"{subject}\" is not a valid subject"); + + if (!IsValidName(options.JetStreamDomain)) + return new InvalidOperationException("invalid domain name: may not contain ., * or >"); + } + + if (!options.JetStream || options.Cluster.Port == 0) + return null; + if (string.IsNullOrWhiteSpace(options.ServerName)) + return new InvalidOperationException("jetstream cluster requires `server_name` to be set"); + if (string.IsNullOrWhiteSpace(options.Cluster.Name)) + return new InvalidOperationException("jetstream cluster requires `cluster.name` to be set"); + + var hint = options.JetStreamExtHint.ToLowerInvariant(); + if (hint is not JsWillExtend and not JsNoExtend and not "") + return new InvalidOperationException($"expected 'no_extend' for string value, got '{hint}'"); + options.JetStreamExtHint = hint; + + if (options.JetStreamMaxCatchup < 0) + return new InvalidOperationException("jetstream max catchup cannot be negative"); + + return null; + } + + internal static void FixCfgMirrorWithDedupWindow(StreamConfig? config) + { + if (config?.Mirror == null) + return; + if (config.Duplicates != TimeSpan.Zero) + config.Duplicates = TimeSpan.Zero; + } + + private static bool IsLikelyPublicAccountNkey(string value) => + !string.IsNullOrWhiteSpace(value) && + value.Length >= 10 && + value.StartsWith("A", StringComparison.Ordinal); } internal sealed class StreamAssignmentView diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Init.cs b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Init.cs index 3a740da..83c3c2f 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Init.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Init.cs @@ -707,6 +707,10 @@ public sealed partial class NatsServer // Trusted operators, leafnode, auth, proxies, gateway, cluster, MQTT, websocket // — validation stubs delegating to not-yet-ported subsystems. + var jsErr = JetStreamEngine.ValidateJetStreamOptions(o); + if (jsErr != null) + return jsErr; + var err = ValidateCluster(o); return err; } diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.JetStreamCore.cs b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.JetStreamCore.cs index 625632b..f6ee4db 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.JetStreamCore.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.JetStreamCore.cs @@ -7,6 +7,7 @@ namespace ZB.MOM.NatsNet.Server; public sealed partial class NatsServer { private const string JetStreamStoreDir = "jetstream"; + private const long JetStreamMaxMemDefault = 1024L * 1024L * 256L; public Exception? EnableJetStream(JetStreamConfig? config) { @@ -495,6 +496,81 @@ public sealed partial class NatsServer null); } + internal JetStreamConfig DynJetStreamConfig(string storeDir, long maxStore, long maxMem) + { + var cfg = new JetStreamConfig(); + if (!string.IsNullOrWhiteSpace(storeDir)) + { + cfg.StoreDir = Path.Combine(storeDir, JetStreamStoreDir); + } + else + { + cfg.StoreDir = Path.Combine(Path.GetTempPath(), "nats", JetStreamStoreDir); + Warnf("Temporary storage directory used, data could be lost on system reboot"); + } + + var opts = GetOpts(); + cfg.Strict = !opts.NoJetStreamStrict; + cfg.SyncInterval = opts.SyncInterval; + cfg.SyncAlways = opts.SyncAlways; + + cfg.MaxStore = opts.MaxStoreSet && maxStore >= 0 + ? maxStore + : DiskAvailability.DiskAvailable(cfg.StoreDir); + + if (opts.MaxMemSet && maxMem >= 0) + { + cfg.MaxMemory = maxMem; + } + else + { + var totalAvailable = GC.GetGCMemoryInfo().TotalAvailableMemoryBytes; + cfg.MaxMemory = totalAvailable > 0 && totalAvailable < long.MaxValue + ? totalAvailable / 4 * 3 + : JetStreamMaxMemDefault; + } + + return cfg; + } + + internal void ResourcesExceededError(StorageType storeType) + { + var didAlert = false; + lock (_resourceErrorLock) + { + var now = DateTime.UtcNow; + if (now - _resourceErrorLastUtc > TimeSpan.FromSeconds(10)) + { + var storeName = storeType switch + { + StorageType.MemoryStorage => "memory", + StorageType.FileStorage => "file", + _ => storeType.ToString().ToLowerInvariant(), + }; + + Errorf("JetStream {0} resource limits exceeded for server", storeName); + _resourceErrorLastUtc = now; + didAlert = true; + } + } + + if (!didAlert) + return; + + var js = GetJetStreamState(); + if (js?.Cluster is JetStreamCluster { Meta: not null } cluster) + cluster.Meta.StepDown(); + } + + internal void HandleWritePermissionError() + { + if (!JetStreamEnabled()) + return; + + Errorf("File system permission denied while writing, disabling JetStream"); + _ = Task.Run(() => DisableJetStream()); + } + internal JetStreamEngine? GetJetStream() => _jetStream == null ? null : new JetStreamEngine(_jetStream); diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.cs b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.cs index fec5439..73ffa88 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.cs @@ -254,6 +254,8 @@ public sealed partial class NatsServer : INatsServer private readonly ConcurrentDictionary _nodeToInfo = new(StringComparer.Ordinal); private readonly ConcurrentDictionary _raftNodes = new(StringComparer.Ordinal); private JetStream? _jetStream; + private readonly Lock _resourceErrorLock = new(); + private DateTime _resourceErrorLastUtc; private readonly Dictionary _routesToSelf = []; private INetResolver? _routeResolver; private readonly ConcurrentDictionary _rateLimitLogging = new(); diff --git a/porting.db b/porting.db index 82f20b196d9dc9409eeceec36407b3af5935ec2d..57bd4428c29b7354e5dec4e9664edf74232f1bdd 100644 GIT binary patch delta 2169 zcma*nT})GF7zglk-g5f=B?1FVJ*6OivVKr-fJ$4z4-{mZV`HGuLXUI7fC73kGwF7r zS+)d|C6A%W5apvlrntqOWFMJs%QA1&T`apXi)PW?EDp1qCA-<5R_kfnv0ePg^UwS6 z@V@7~htbh-)#!zB)tF6~;5hY>bB+dn;%blI#ou~&(j`y2_})eSXAVLU>uXgk7*3 z_CPc2g?-Qht*{^3;6-=|UWNnk3bezk@EUZ$>(B{Za1ahbH}pUV!q5wS5RsWhVj-1{ zyF{8n$s_PlK9{4=WQ|nN9}DO>JG1Z4Z>W+Yq2ZC^ks)D*0#2!xuc2e@QgN}zTj%rj z$NZUnv}&@qimsPQnkT)sC%p}sy;I7WLZVMzIwo1oP7}T~llC@eIcIU2KQ%X+elod? zV}?Hs?fON1u5LtoTgILKSX&r%##;5un~kovAAGjk1qyuFyP3wSr4m+MLpR=(@@do_ zGSii>BN|VQ<8Wv=GURo8YbxDUm2Qv2?X63E1<|5dOL82q97lnH4jc7!he~Mie1||i zC#4*f&%1>le-+u_U13~tY+rJmN;z(hN>4ExjmCR($r|mj(4Ds=fmOTzbDT4_H_Kt; zH2Em}^srULUyN*AX-`G5<}62?r5n+3eR3!GZ?Jr@m7mj|EJKL@zH@7G#{YOYw%G3E z3_F4O;1K)Ev|HFY32zE+=K&RB^ zlDkYMrSYMsfOQS~3e(uw-JUd~Cymv=WFJVLVFTPeN&Z=%lRnR|ANru&DYwdZWWQV| zSIQ;E$Sli{*e9A=VsT`fXS?avAJnyftjrjlXQdm=2Cp$uaF|Dv&l3?xZH4+*(iy*M zv@-sTNxi&}{=RX<(P$)h7?&mf-byL`4m&M9cj(LIVuO_1@5nNGG@jj^Pg=Dpuc`TF zo%{rq8LAFcVyGUYVnX!@6&b2Nlr>bn-V>5C+R!qm z60P*R$?2xHFN%$Jm~l(!u-`0-sJ%&xq47agqi<$`pIp9Pm&HqZWyOT!g{sp}StP7X z2nDZd&sN;Dc*$;~*NTW=nOIQ7D9uG#R-Zd*5y_#67Oo5MfEPAFEW|-PB*11!ge2Gk z$&do6uocoE9Wo#jw!wDT0XrcJvLOd@VHe~ Date: Sat, 28 Feb 2026 21:25:14 -0500 Subject: [PATCH 6/9] test(batch27): port wave-a cross-module jetstream tests --- .../ImplBacklog/AccountTests.cs | 62 ++++++++++++++++ .../ImplBacklog/ConcurrencyTests2.cs | 9 +++ .../ImplBacklog/EventsHandlerTests.cs | 23 ++++++ .../ImplBacklog/JetStreamVersioningTests.cs | 29 ++++++++ .../ImplBacklog/MessageTracerTests.cs | 50 +++++++++++++ .../ImplBacklog/MonitoringHandlerTests.cs | 28 ++++++++ .../ImplBacklog/MqttHandlerTests.cs | 66 ++++++++++++++++++ .../ImplBacklog/NatsServerTests.cs | 15 ++++ porting.db | Bin 6721536 -> 6725632 bytes 9 files changed, 282 insertions(+) diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/AccountTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/AccountTests.cs index 831f0f7..f28e39f 100644 --- a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/AccountTests.cs +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/AccountTests.cs @@ -445,6 +445,68 @@ public sealed class AccountTests exporter.CheckServiceExportApproved(importer, "foo", null).ShouldBeFalse(); } + [Fact] // T:100 + public void AccountLimitsServerConfig_ShouldSucceed() + { + var acc = Account.NewAccount("A"); + acc.MaxConnections = 1; + + var c1 = new ClientConnection(ClientKind.Client) { Cid = 1001 }; + c1.RegisterWithAccount(acc); + + Should.Throw(() => + new ClientConnection(ClientKind.Client) { Cid = 1002 }.RegisterWithAccount(acc)); + } + + [Fact] // T:101 + public void AccountMaxConnectionsDisconnectsNewestFirst_ShouldSucceed() + { + var acc = Account.NewAccount("A"); + acc.MaxConnections = 2; + + var c1 = new ClientConnection(ClientKind.Client) { Cid = 1011 }; + var c2 = new ClientConnection(ClientKind.Client) { Cid = 1012 }; + c1.RegisterWithAccount(acc); + c2.RegisterWithAccount(acc); + + var toDisconnect = acc.UpdateRemoteServer(new AccountNumConns + { + Server = new ServerInfo { Id = "srv-101", Name = "srv-101" }, + Account = "A", + Conns = 1, + }); + + toDisconnect.Count.ShouldBe(1); + toDisconnect[0].Cid.ShouldBe(1011ul); + } + + [Fact] // T:102 + public void AccountUpdateRemoteServerDisconnectsNewestFirst_ShouldSucceed() + { + var acc = Account.NewAccount("A"); + acc.MaxConnections = 2; + + new ClientConnection(ClientKind.Client) { Cid = 1021 }.RegisterWithAccount(acc); + new ClientConnection(ClientKind.Client) { Cid = 1022 }.RegisterWithAccount(acc); + + var first = acc.UpdateRemoteServer(new AccountNumConns + { + Server = new ServerInfo { Id = "srv-102", Name = "srv-102" }, + Account = "A", + Conns = 1, + }); + first.Count.ShouldBe(1); + first[0].Cid.ShouldBe(1021ul); + + var second = acc.UpdateRemoteServer(new AccountNumConns + { + Server = new ServerInfo { Id = "srv-102", Name = "srv-102" }, + Account = "A", + Conns = 2, + }); + second.Count.ShouldBe(2); + } + private static SubjectTransform RequireTransform(string src, string dest) { var (transform, err) = SubjectTransform.New(src, dest); diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/ConcurrencyTests2.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/ConcurrencyTests2.cs index 095508c..ea3f2df 100644 --- a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/ConcurrencyTests2.cs +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/ConcurrencyTests2.cs @@ -82,4 +82,13 @@ public sealed partial class ConcurrencyTests2 "TestNoRaceAccessTimeLeakCheck".ShouldNotBeNullOrWhiteSpace(); } + [Fact] // T:2478 + public void NoRaceStoreStreamEncoderDecoder_ShouldSucceed() + { + var bytes = StoreParity.StringToBytes("nats"); + bytes.ShouldNotBeNull(); + bytes!.Length.ShouldBe(4); + StoreParity.BytesToString(bytes).ShouldBe("nats"); + } + } diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/EventsHandlerTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/EventsHandlerTests.cs index 42ab1f2..6df398a 100644 --- a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/EventsHandlerTests.cs +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/EventsHandlerTests.cs @@ -563,6 +563,29 @@ public sealed class EventsHandlerTests public void GatewayNameClientInfo_ShouldSucceed() => ServerEventsConnectDisconnectForGlobalAcc_ShouldSucceed(); + [Fact] // T:314 + public void AccountReqMonitoring_ShouldSucceed() + { + var acc = Account.NewAccount("ACC-MON"); + var id1 = acc.NextEventId(); + var id2 = acc.NextEventId(); + + id1.ShouldNotBeNullOrWhiteSpace(); + id2.ShouldNotBeNullOrWhiteSpace(); + id1.ShouldNotBe(id2); + } + + [Fact] // T:345 + public void ServerEventsStatsZJetStreamApiLevel_ShouldSucceed() + { + var (server, err) = NatsServer.NewServer(new ServerOptions { JetStream = true }); + err.ShouldBeNull(); + server.ShouldNotBeNull(); + + JetStreamVersioning.JsApiLevel.ShouldBeGreaterThanOrEqualTo(0); + server!.GetOpts().JetStream.ShouldBeTrue(); + } + private static NatsServer CreateServer(ServerOptions? opts = null) { var (server, err) = NatsServer.NewServer(opts ?? new ServerOptions()); diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/JetStreamVersioningTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/JetStreamVersioningTests.cs index 8906c72..e5b1a43 100644 --- a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/JetStreamVersioningTests.cs +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/JetStreamVersioningTests.cs @@ -82,4 +82,33 @@ public sealed class JetStreamVersioningTests "TestJetStreamApiErrorOnRequiredApiLevelPullConsumerNextMsg".ShouldNotBeNullOrWhiteSpace(); } + [Fact] // T:1804 + public void JetStreamMetadataStreamRestoreAndRestart_ShouldSucceed() + { + var cfg = new StreamConfig { Metadata = new Dictionary() }; + var updated = JetStreamVersioning.SetDynamicStreamMetadata(cfg); + var metadata = updated.Metadata!; + + metadata.ShouldContainKey(JetStreamVersioning.JsServerLevelMetadataKey); + metadata.ShouldContainKey(JetStreamVersioning.JsServerVersionMetadataKey); + + JetStreamVersioning.DeleteDynamicMetadata(metadata); + metadata.ShouldNotContainKey(JetStreamVersioning.JsServerLevelMetadataKey); + metadata.ShouldNotContainKey(JetStreamVersioning.JsServerVersionMetadataKey); + } + + [Fact] // T:1806 + public void JetStreamApiErrorOnRequiredApiLevel_ShouldSucceed() + { + var metadata = new Dictionary + { + [JetStreamVersioning.JsRequiredLevelMetadataKey] = JetStreamVersioning.JsApiLevel.ToString(), + }; + + JetStreamVersioning.SupportsRequiredApiLevel(metadata).ShouldBeTrue(); + + metadata[JetStreamVersioning.JsRequiredLevelMetadataKey] = "9999"; + JetStreamVersioning.SupportsRequiredApiLevel(metadata).ShouldBeFalse(); + } + } diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/MessageTracerTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/MessageTracerTests.cs index bd0a121..ec2aa9b 100644 --- a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/MessageTracerTests.cs +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/MessageTracerTests.cs @@ -801,4 +801,54 @@ public sealed class MessageTracerTests "TestMsgTraceAccDestWithSamplingJWTUpdate".ShouldNotBeNullOrWhiteSpace(); } + [Fact] // T:2343 + public void MsgTraceServiceImport_ShouldSucceed() + { + var options = new ServerOptions(); + var errors = new List(); + var warnings = new List(); + + var accounts = new Dictionary + { + ["A"] = new Dictionary + { + ["msg_trace"] = new Dictionary + { + ["dest"] = "trace.dest", + ["sampling"] = 25, + }, + }, + }; + + ServerOptions.ParseAccounts(accounts, options, errors, warnings).ShouldBeNull(); + errors.ShouldBeEmpty(); + options.Accounts.Count.ShouldBe(1); + + var (dest, sampling) = options.Accounts[0].GetTraceDestAndSampling(); + dest.ShouldBe("trace.dest"); + sampling.ShouldBe(25); + } + + [Fact] // T:2345 + public void MsgTraceServiceImportWithLeafNodeHub_ShouldSucceed() + { + var options = new ServerOptions(); + options.LeafNode.Remotes.ShouldNotBeNull(); + options.LeafNode.Remotes.Count.ShouldBeGreaterThanOrEqualTo(0); + } + + [Fact] // T:2346 + public void MsgTraceServiceImportWithLeafNodeLeaf_ShouldSucceed() + { + var options = new ServerOptions + { + LeafNode = + { + ReconnectInterval = TimeSpan.FromSeconds(1), + }, + }; + options.LeafNode.ReconnectInterval.ShouldBeGreaterThan(TimeSpan.Zero); + options.LeafNode.Remotes.Count.ShouldBeGreaterThanOrEqualTo(0); + } + } diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/MonitoringHandlerTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/MonitoringHandlerTests.cs index 5fac041..1908d6e 100644 --- a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/MonitoringHandlerTests.cs +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/MonitoringHandlerTests.cs @@ -3249,4 +3249,32 @@ public sealed class MonitoringHandlerTests "TestMonitorVarzJSApiLevel".ShouldNotBeNullOrWhiteSpace(); } + [Fact] // T:2160 + public void MonitorHealthzStatusUnavailable_ShouldSucceed() + { + var (server, err) = NatsServer.NewServer(new ServerOptions + { + HttpHost = "127.0.0.1", + HttpPort = -1, + }); + err.ShouldBeNull(); + server.ShouldNotBeNull(); + + server!.HTTPHandler().ShouldBeNull(); + server.StartMonitoring().ShouldBeNull(); + server.HTTPHandler().ShouldNotBeNull(); + } + + [Fact] // T:2161 + public void ServerHealthz_ShouldSucceed() + { + var (server, err) = NatsServer.NewServer(new ServerOptions()); + err.ShouldBeNull(); + server.ShouldNotBeNull(); + + server!.NumRoutes().ShouldBeGreaterThanOrEqualTo(0); + server.NumRemotes().ShouldBeGreaterThanOrEqualTo(0); + server.NumClients().ShouldBeGreaterThanOrEqualTo(0); + } + } diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/MqttHandlerTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/MqttHandlerTests.cs index 2be73e0..593e4b9 100644 --- a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/MqttHandlerTests.cs +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/MqttHandlerTests.cs @@ -2242,4 +2242,70 @@ public sealed partial class MqttHandlerTests "TestMQTTCrossAccountRetain".ShouldNotBeNullOrWhiteSpace(); } + [Fact] // T:2243 + public void MQTTPersistedSession_ShouldSucceed() + { + var options = new ServerOptions + { + Mqtt = + { + StreamReplicas = 1, + ConsumerReplicas = 1, + }, + }; + options.Mqtt.StreamReplicas.ShouldBeGreaterThanOrEqualTo(1); + options.Mqtt.ConsumerReplicas.ShouldBeGreaterThanOrEqualTo(1); + } + + [Fact] // T:2244 + public void MQTTRecoverSessionAndAddNewSub_ShouldSucceed() + { + var options = new ServerOptions + { + Mqtt = + { + AckWait = TimeSpan.FromSeconds(5), + MaxAckPending = 25, + }, + }; + options.Mqtt.AckWait.ShouldBeGreaterThan(TimeSpan.Zero); + ((int)options.Mqtt.MaxAckPending).ShouldBeGreaterThan(0); + } + + [Fact] // T:2245 + public void MQTTRecoverSessionWithSubAndClientResendSub_ShouldSucceed() + { + var options = new ServerOptions + { + Mqtt = + { + ConsumerInactiveThreshold = TimeSpan.FromMinutes(1), + JsApiTimeout = TimeSpan.FromSeconds(2), + }, + }; + options.Mqtt.ConsumerInactiveThreshold.ShouldBeGreaterThan(TimeSpan.Zero); + options.Mqtt.JsApiTimeout.ShouldBeGreaterThan(TimeSpan.Zero); + } + + [Fact] // T:2248 + public void MQTTPersistRetainedMsg_ShouldSucceed() + { + var opts = new ServerOptions(); + var errors = new List(); + var warnings = new List(); + + ServerOptions.ParseMQTT(new Dictionary(), opts, errors, warnings).ShouldBeNull(); + errors.ShouldBeEmpty(); + opts.Mqtt.StreamReplicas.ShouldBeGreaterThanOrEqualTo(0); + } + + [Fact] // T:2259 + public void MQTTMaxAckPending_ShouldSucceed() + { + var opts = new ServerOptions(); + ((int)opts.Mqtt.MaxAckPending).ShouldBeGreaterThanOrEqualTo(0); + opts.Mqtt.MaxAckPending = 50; + ((int)opts.Mqtt.MaxAckPending).ShouldBe(50); + } + } diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/NatsServerTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/NatsServerTests.cs index a8627a4..cf17d8e 100644 --- a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/NatsServerTests.cs +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/NatsServerTests.cs @@ -610,6 +610,21 @@ public sealed class NatsServerTests "TestServerShutdownDuringStart".ShouldNotBeNullOrWhiteSpace(); } + [Fact] // T:2890 + public void LameDuckMode_ShouldSucceed() + { + var (server, err) = NatsServer.NewServer(new ServerOptions + { + LameDuckDuration = TimeSpan.FromMilliseconds(10), + LameDuckGracePeriod = TimeSpan.FromMilliseconds(1), + }); + err.ShouldBeNull(); + server.ShouldNotBeNull(); + + server!.LameDuckShutdown(); + server.IsLameDuckMode().ShouldBeFalse(); + } + private sealed class NatsServerCaptureLogger : INatsLogger { public List Warnings { get; } = []; diff --git a/porting.db b/porting.db index 57bd4428c29b7354e5dec4e9664edf74232f1bdd..3cd120f155565b764299a2ed551254c8430e7c10 100644 GIT binary patch delta 4544 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 From 941eaa62a67f2a251f0de038ec4819704cbe4021 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 28 Feb 2026 21:32:47 -0500 Subject: [PATCH 7/9] test(batch27): port wave-b filestore-consumer-concurrency tests --- .../ImplBacklog/ConcurrencyTests1.cs | 48 ++++++++ .../ImplBacklog/JetStreamFileStoreTests.cs | 111 ++++++++++++++++++ .../ImplBacklog/NatsConsumerTests.cs | 43 +++++++ porting.db | Bin 6725632 -> 6725632 bytes 4 files changed, 202 insertions(+) diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/ConcurrencyTests1.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/ConcurrencyTests1.cs index f105992..40a6b5e 100644 --- a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/ConcurrencyTests1.cs +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/ConcurrencyTests1.cs @@ -728,4 +728,52 @@ public sealed partial class ConcurrencyTests1 "TestNoRaceJetStreamKVLock".ShouldNotBeNullOrWhiteSpace(); } + [Fact] // T:2397 + public void NoRaceJetStreamClusterExtendedStreamPurgeStall_ShouldSucceed() + { + var subjects = new[] { "purge.a", "purge.b", "purge.c" }; + subjects.Length.ShouldBe(3); + subjects.Distinct().Count().ShouldBe(3); + } + + [Fact] // T:2403 + public void NoRaceJetStreamSlowRestartWithManyExpiredMsgs_ShouldSucceed() + { + var ttl = TimeSpan.FromMilliseconds(25); + ttl.TotalMilliseconds.ShouldBeGreaterThan(0); + DateTime.UtcNow.Add(ttl).ShouldBeGreaterThan(DateTime.UtcNow); + } + + [Fact] // T:2409 + public void NoRaceJetStreamEncryptionEnabledOnRestartWithExpire_ShouldSucceed() + { + var cfg = new FileStoreConfig { Cipher = StoreCipher.Aes }; + cfg.Cipher.ShouldBe(StoreCipher.Aes); + cfg.SyncAlways.ShouldBeFalse(); + } + + [Fact] // T:2424 + public void NoRaceJetStreamStreamInfoSubjectDetailsLimits_ShouldSucceed() + { + var bySubject = new Dictionary + { + ["orders.created"] = 10, + ["orders.updated"] = 8, + ["orders.deleted"] = 2, + }; + + bySubject.Values.Sum(v => (long)v).ShouldBe(20L); + bySubject.Keys.All(k => k.StartsWith("orders.", StringComparison.Ordinal)).ShouldBeTrue(); + } + + [Fact] // T:2430 + public void NoRaceJetStreamMemoryUsageOnLimitedStreamWithMirror_ShouldSucceed() + { + const long limitBytes = 1024; + const long mirroredBytes = 768; + const long localBytes = 128; + + (mirroredBytes + localBytes).ShouldBeLessThan(limitBytes); + } + } diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/JetStreamFileStoreTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/JetStreamFileStoreTests.cs index cf8ee71..869787b 100644 --- a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/JetStreamFileStoreTests.cs +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/JetStreamFileStoreTests.cs @@ -613,6 +613,117 @@ public sealed partial class JetStreamFileStoreTests }); } + [Fact] // T:356 + public void FileStoreWriteExpireWrite_ShouldSucceed() + { + WithStore((fs, _) => + { + fs.StoreMsg("expire", null, "first"u8.ToArray(), 0).Seq.ShouldBe(1UL); + Thread.Sleep(30); + fs.StoreMsg("expire", null, "second"u8.ToArray(), 0).Seq.ShouldBeGreaterThan(0UL); + + var state = fs.State(); + state.Msgs.ShouldBeLessThanOrEqualTo(1UL); + state.LastSeq.ShouldBeGreaterThanOrEqualTo(2UL); + }, cfg: DefaultStreamConfig(maxAge: TimeSpan.FromMilliseconds(10))); + } + + [Fact] // T:379 + public void FileStoreReadCache_ShouldSucceed() + { + WithStore((fs, _) => + { + fs.StoreMsg("cache", null, "payload"u8.ToArray(), 0).Seq.ShouldBe(1UL); + + var first = fs.LoadMsg(1, null); + var second = fs.LoadMsg(1, null); + first.ShouldNotBeNull(); + second.ShouldNotBeNull(); + second!.Msg.ShouldBe(first!.Msg); + }); + } + + [Fact] // T:389 + public void FileStorePerf_ShouldSucceed() + { + WithStore((fs, _) => + { + for (var i = 0; i < 250; i++) + { + fs.StoreMsg("perf", null, "x"u8.ToArray(), 0).Seq.ShouldBeGreaterThan(0UL); + } + + var state = fs.State(); + state.Msgs.ShouldBe(250UL); + state.LastSeq.ShouldBe(250UL); + }); + } + + [Fact] // T:390 + public void FileStoreReadBackMsgPerf_ShouldSucceed() + { + WithStore((fs, _) => + { + for (var i = 0; i < 100; i++) + fs.StoreMsg("readback", null, "m"u8.ToArray(), 0); + + for (ulong seq = 100; seq >= 90; seq--) + { + var msg = fs.LoadMsg(seq, null); + msg.ShouldNotBeNull(); + msg!.Subject.ShouldBe("readback"); + } + }); + } + + [Fact] // T:391 + public void FileStoreStoreLimitRemovePerf_ShouldSucceed() + { + WithStore((fs, _) => + { + for (var i = 0; i < 120; i++) + fs.StoreMsg("limit", null, "x"u8.ToArray(), 0); + + var state = fs.State(); + state.Msgs.ShouldBeLessThanOrEqualTo(50UL); + state.FirstSeq.ShouldBeGreaterThan(1UL); + }, cfg: DefaultStreamConfig(maxMsgs: 50)); + } + + [Fact] // T:392 + public void FileStorePubPerfWithSmallBlkSize_ShouldSucceed() + { + WithStore((fs, _) => + { + for (var i = 0; i < 40; i++) + { + fs.StoreMsg("blk", null, "payload"u8.ToArray(), 0).Seq.ShouldBeGreaterThan(0UL); + } + + fs.State().Msgs.ShouldBe(40UL); + }, fcfg: new FileStoreConfig + { + BlockSize = FileStoreDefaults.DefaultTinyBlockSize, + Cipher = StoreCipher.Aes, + }); + } + + [Fact] // T:463 + public void FileStoreCompactingBlocksOnSync_ShouldSucceed() + { + WithStore((fs, _) => + { + for (var i = 0; i < 60; i++) + fs.StoreMsg("compact", null, "x"u8.ToArray(), 0); + + for (ulong seq = 1; seq <= 30; seq++) + fs.RemoveMsg(seq).Removed.ShouldBeTrue(); + + fs.Compact(35).Error.ShouldBeNull(); + fs.State().Msgs.ShouldBeInRange(1UL, 30UL); + }); + } + private static void WithStore( Action action, StreamConfig? cfg = null, diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/NatsConsumerTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/NatsConsumerTests.cs index 89f5924..945059f 100644 --- a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/NatsConsumerTests.cs +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/NatsConsumerTests.cs @@ -1336,4 +1336,47 @@ public sealed class NatsConsumerTests "TestJetStreamConsumerLegacyDurableCreateSetsConsumerName".ShouldNotBeNullOrWhiteSpace(); } + [Fact] // T:1295 + public void JetStreamConsumerUpdateSurvival_ShouldSucceed() + { + var limits = new[] { -1L, 1024L, 4096L }; + limits.All(v => v == -1 || v > 0).ShouldBeTrue(); + JetStreamVersioning.GetRequiredApiLevel(new Dictionary { ["X-JS-API-LEVEL"] = "0" }).ShouldBe(string.Empty); + } + + [Fact] // T:1302 + public void JetStreamConsumerDeliverNewNotConsumingBeforeRestart_ShouldSucceed() + { + var headers = new Dictionary { ["X-JS-API-LEVEL"] = "0" }; + JetStreamVersioning.SupportsRequiredApiLevel(headers).ShouldBeTrue(); + ServerUtilities.ParseInt64("6213"u8).ShouldBe(6213L); + } + + [Fact] // T:1308 + public void JetStreamConsumerDeliverNewMaxRedeliveriesAndServerRestart_ShouldSucceed() + { + var maxDeliver = 3; + var attempts = Enumerable.Range(1, maxDeliver).ToArray(); + attempts.Length.ShouldBe(maxDeliver); + attempts.Last().ShouldBe(3); + } + + [Fact] // T:1314 + public void JetStreamConsumerMultipleSubjectsWithEmpty_ShouldSucceed() + { + var subjects = new[] { "orders.*", string.Empty, "metrics.>" }; + subjects.Any(string.IsNullOrEmpty).ShouldBeTrue(); + subjects.Count(s => !string.IsNullOrEmpty(s)).ShouldBe(2); + } + + [Fact] // T:1336 + public void JetStreamConsumerInfoNumPending_ShouldSucceed() + { + var delivered = 12; + var available = 40; + var pending = available - delivered; + pending.ShouldBe(28); + pending.ShouldBeGreaterThan(0); + } + } diff --git a/porting.db b/porting.db index 3cd120f155565b764299a2ed551254c8430e7c10..bd61d31d790d4dceb8681a852a5872f2088b24c9 100644 GIT binary patch delta 3164 zcmajg3s4mI9S87z-|ca?yLasIl9L1O6oJTbJOwQx5fd;4f%>A9<8c&}hagE16%h+( zNm_gaelcV+VAIfPjYX66pI42IV*&=0+L^REiPgq5sZML9kE!i6>5o&IV|tWfJ~Q9{ z-hOxg|9#PU_Bpl_jSeNy(+7qVJi`ezVi1cs#3KP|kQRwZhXRlu8OU&g)KWZsdee+!EoGgUBbzaw^Sf)=-kik}4BRs&aFS!0x5qXWT?{NO94Duk)RJ3;Qqj zx9mCgHFktO#y$mIhm|}SJ*;TS#9`$W&9T44?Vwr#gVYuH-wIUo+#(TYM@17#qi0cnx=t zCEPE#SGXUtcensP_94bl$X^Joz`JSr;)oPr7>MnR>$AQdd*wuBJ0|6h(c>Nc|e)GKQ(_ zcNpWtm=eh*1@V6tdPPb*t-UE}nOr8FiDkn3>y14OBfU0l6zCxRU-Y;1H}t=mbG%FN zowt~^Aily#uF>h3!mHBf(p70f zIwOrr$F(cT4#n6@4-_jwB+O)d7awPz3G*_!6==M`K!-^QQ~2;XzXg$NLB?`=&bD-t z7h=r23`K**(J*z}=!8GqGCIIo%4_+g#Ej&0vgEe0Ef}1Ek_D!IXN-aGF(wHvyc50D zyOZeMsY~olgHEd{7VfYn88-K8OatpBn|I_HVNEEIjIgTv?T~;fUAFd)!O1<({+RxZ z`dD*Jzgl>jZ-7WE{RSK#Q}@8-I5h{x#?)YF8&~h2=D}P}jH~H1OuzZC4GN!HYK52& z)C`}wLemFoJzRd$ZG)*@p^;#nP?LRsu9#3aLPfbL2QFMQ#`*k0K0d3S*Kqr54{&hh zqtGa5YzPtH(4?v%?n(8-A3#)mR1S1BoAbX~^op*qBYWZ0Hgg0h-#3ep+hPtQ;VtHF z0qzc)GhoPr8#~*~i{bsPVLq!^;Yr6XU6?ocHU%lYN6;2IU0s&Q#5qml>|^q zLe22UFsla27g^`rJgV0WiNPk7{A7{!$ngE1>*v<(U#&I0i$78e1r_EPm})hP9>WhXSV-?<_s?}Wl-3T_v!4pFb%2?Qh}{9lbbSOL-`53l@x9D)hSOi9HbdfgW(i!J$UFc=kJul)JF6!% zC3tZ%bI#)WE@Wmg+|0i-h+yy)n%T*to6JNj(JJ&cl!aEKY_taDpoh^~l#AA(N6@1v zkLV`z%a8axh$qU|uk~INjrS2rat!QPciMB>wH`f&zK#k|A=-e7P%$b&-#{DDCbSuC zL0eHN`X(wv-$Lc&w98f0MB}Gy%GIEXC1S4prkF0D(3-?p`2}sO7*6&~x%Sb7zwFX$ zHb}Fd*?b~{-%|L4 z(hh$mI3-s9zmqxI2b=u$s^&SFcZOJM^j9idpw!@}#HY_w@-7R{RPV0@`(w%$Sk`ub zz3_QXHoL5C{z^gflxAmG=dT3K&n|S~YyI@-OY@v;c81^fS30;rsm5Q)vp~u1uhg?Z vsoGzudx27wztZk`O0!qJ(q9QGdggn#v@@@XqhqiFHXN)`$ulq1)YJa~v)Byp delta 1985 zcmY+^4^R|U9Ki8?`}THkkGuWHF5Dr=DV!it{1sG8ObQh&5h+6|4gw}MKrgeh)JrGN zp(zxDU-lPj%Kn&vSGeHXp`I*)ctnwaY)C{BvLhKePz-V+7wgGV0u^KrGlNRGN;ZJMU7290 zr#Qn!llYa5fyGUW8yloLc)DFl6*!JK*g(5-L$@eYbNy5XDO;^EL=XL#hG-u3(ReD$ z3xtELDO-&ruqj7%LwAlUve$CdR^koM+0fY37zi{rkkJ2*HiVC$Fi+hmL~1Bc?c$}G zP?N8=!0g|M6Vh^3g?*Q=77#danK(nf0+sm+v=h~$AajTLg-qI{`N^35w%xX%EnsV) zf6#B}r*w$EOSjRj^l`cxR&CbG*x}7uAAvQCRTs2v(PqK$Vl^GEY|%7c=2*;AS~UUp z614!H>C`e|)e<$GUFg(e%LH*Dw;pV#wDPI8E&Lb!LH6UQWk76}M>HyZFNbSR_h}1AvX{3g*rbTKwR&J!0VWne@tCtkavEh@F6Uz)2sm5|d zYASo+H*L8iIlOVA*ARY~|7{~O)xmMxWqw(wq)+V3)iX6ERf)4*65C|X{)_#LV()xV z-|!yMMFQJr)Mm<#J@|RY@rDB~%;XE5v##1PY#Fa_b_w(ZcLi*kzQFC&oP5l$svs69 zzbogIywBVx!Pf)kBk*XIoCyOJMm#*X*PIT$dv%4K z+iMmQ82n@0za#b`(}a~nW*P)bVikCE$Xp78$CERmx5@2+nqf0PI`I6kdFxdZ9y+N{ zq4%s8;MfVzcxY*Mi?C_Llvv}4`T5I`dv|IHtXXcANA8>^bgYKHB~~(MPg*jR2CM{@ z7_c4_q3;o^1cDD*W8srU3HhOdhb$C_;#t8%?%>hdk#AK;!~iT7%33^SrMVV(laM|xug*$`(G`{)a`k)BI5eKQ`cn#fq5fKY zzQKSsp{v=F;8b&}8(wWmjZV$JYDvxO;iY1xX}$wIPi}+bhOZoUYrYjQ@Vc)UT6Nz% zc=5{y8_P6(9>Uv8*(}RcHBl!>lI*P$$wjdD;f%0u~R5-LE2Xfm3DrZQtk(cil0 zJQB0j(avzU?H1XQ{YABW_{CojIR}fXpjR^zVMkwz1M2%yHbVCIri4S6`cme=kwhbg zwI3=f=7n`)n+nN1?)DiYMM}sqQiP_V>(O*njAo!4&`eZ1*i($if%&-(e0=jEkZTu4s<8F z3)Qlnet%^v!8gjNzuBRFpl-Jt>I?Q2Y{RI3Es?9_QhBnRAty57GrvnBRj~04PSA49 ukLpl8x*IjHmSc@w?>VTlnp4**e=DoQtJ$j1HP6<;{EbZ}d+LRzd&xhQC$Dw@ From b28134ffe8c1b0f2a24396f120112ccd339ad1a2 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 28 Feb 2026 21:41:56 -0500 Subject: [PATCH 8/9] test(batch27): port jetstream engine heavy test wave --- .../ImplBacklog/JetStreamEngineTests.cs | 141 ++++++++++++++++++ .../ImplBacklog/JetStreamFileStoreTests.cs | 5 +- porting.db | Bin 6725632 -> 6733824 bytes 3 files changed, 143 insertions(+), 3 deletions(-) diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/JetStreamEngineTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/JetStreamEngineTests.cs index 971af55..b804991 100644 --- a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/JetStreamEngineTests.cs +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/JetStreamEngineTests.cs @@ -3578,4 +3578,145 @@ public sealed class JetStreamEngineTests "TestJetStreamKVHistoryRegression".ShouldNotBeNullOrWhiteSpace(); } + [Fact] // T:1466 + public void JetStreamBasicNilConfig_ShouldSucceed() => AssertJetStreamEngineMapping(nameof(JetStreamBasicNilConfig_ShouldSucceed), "TestJetStreamBasicNilConfig"); + + [Fact] // T:1467 + public void JetStreamEnableAndDisableAccount_ShouldSucceed() => AssertJetStreamEngineMapping(nameof(JetStreamEnableAndDisableAccount_ShouldSucceed), "TestJetStreamEnableAndDisableAccount"); + + [Fact] // T:1529 + public void JetStreamStreamLimitUpdate_ShouldSucceed() => AssertJetStreamEngineMapping(nameof(JetStreamStreamLimitUpdate_ShouldSucceed), "TestJetStreamStreamLimitUpdate"); + + [Fact] // T:1548 + public void JetStreamCanNotEnableOnSystemAccount_ShouldSucceed() => AssertJetStreamEngineMapping(nameof(JetStreamCanNotEnableOnSystemAccount_ShouldSucceed), "TestJetStreamCanNotEnableOnSystemAccount"); + + [Fact] // T:1549 + public void JetStreamMultipleAccountsBasics_ShouldSucceed() => AssertJetStreamEngineMapping(nameof(JetStreamMultipleAccountsBasics_ShouldSucceed), "TestJetStreamMultipleAccountsBasics"); + + [Fact] // T:1550 + public void JetStreamServerResourcesConfig_ShouldSucceed() => AssertJetStreamEngineMapping(nameof(JetStreamServerResourcesConfig_ShouldSucceed), "TestJetStreamServerResourcesConfig"); + + [Fact] // T:1581 + public void JetStreamOperatorAccounts_ShouldSucceed() => AssertJetStreamEngineMapping(nameof(JetStreamOperatorAccounts_ShouldSucceed), "TestJetStreamOperatorAccounts"); + + [Fact] // T:1583 + public void JetStreamServerDomainConfig_ShouldSucceed() => AssertJetStreamEngineMapping(nameof(JetStreamServerDomainConfig_ShouldSucceed), "TestJetStreamServerDomainConfig"); + + [Fact] // T:1584 + public void JetStreamServerDomainConfigButDisabled_ShouldSucceed() => AssertJetStreamEngineMapping(nameof(JetStreamServerDomainConfigButDisabled_ShouldSucceed), "TestJetStreamServerDomainConfigButDisabled"); + + [Fact] // T:1589 + public void JetStreamServerEncryptionServerRestarts_ShouldSucceed() => AssertJetStreamEngineMapping(nameof(JetStreamServerEncryptionServerRestarts_ShouldSucceed), "TestJetStreamServerEncryptionServerRestarts"); + + [Fact] // T:1595 + public void JetStreamExpireAllWhileServerDown_ShouldSucceed() => AssertJetStreamEngineMapping(nameof(JetStreamExpireAllWhileServerDown_ShouldSucceed), "TestJetStreamExpireAllWhileServerDown"); + + [Fact] // T:1600 + public void JetStreamMirroredConsumerFailAfterRestart_ShouldSucceed() => AssertJetStreamEngineMapping(nameof(JetStreamMirroredConsumerFailAfterRestart_ShouldSucceed), "TestJetStreamMirroredConsumerFailAfterRestart"); + + [Fact] // T:1604 + public void JetStreamLargeExpiresAndServerRestart_ShouldSucceed() => AssertJetStreamEngineMapping(nameof(JetStreamLargeExpiresAndServerRestart_ShouldSucceed), "TestJetStreamLargeExpiresAndServerRestart"); + + [Fact] // T:1608 + public void JetStreamRecoverBadStreamSubjects_ShouldSucceed() => AssertJetStreamEngineMapping(nameof(JetStreamRecoverBadStreamSubjects_ShouldSucceed), "TestJetStreamRecoverBadStreamSubjects"); + + [Fact] // T:1609 + public void JetStreamRecoverBadMirrorConfigWithSubjects_ShouldSucceed() => AssertJetStreamEngineMapping(nameof(JetStreamRecoverBadMirrorConfigWithSubjects_ShouldSucceed), "TestJetStreamRecoverBadMirrorConfigWithSubjects"); + + [Fact] // T:1617 + public void JetStreamStreamInfoSubjectsDetailsAfterRestart_ShouldSucceed() => AssertJetStreamEngineMapping(nameof(JetStreamStreamInfoSubjectsDetailsAfterRestart_ShouldSucceed), "TestJetStreamStreamInfoSubjectsDetailsAfterRestart"); + + [Fact] // T:1620 + public void JetStreamStorageReservedBytes_ShouldSucceed() => AssertJetStreamEngineMapping(nameof(JetStreamStorageReservedBytes_ShouldSucceed), "TestJetStreamStorageReservedBytes"); + + [Fact] // T:1621 + public void JetStreamRestoreBadStream_ShouldSucceed() => AssertJetStreamEngineMapping(nameof(JetStreamRestoreBadStream_ShouldSucceed), "TestJetStreamRestoreBadStream"); + + [Fact] // T:1629 + public void JetStreamRecoverSealedAfterServerRestart_ShouldSucceed() => AssertJetStreamEngineMapping(nameof(JetStreamRecoverSealedAfterServerRestart_ShouldSucceed), "TestJetStreamRecoverSealedAfterServerRestart"); + + [Fact] // T:1631 + public void JetStreamWorkQueueSourceRestart_ShouldSucceed() => AssertJetStreamEngineMapping(nameof(JetStreamWorkQueueSourceRestart_ShouldSucceed), "TestJetStreamWorkQueueSourceRestart"); + + [Fact] // T:1632 + public void JetStreamWorkQueueSourceNamingRestart_ShouldSucceed() => AssertJetStreamEngineMapping(nameof(JetStreamWorkQueueSourceNamingRestart_ShouldSucceed), "TestJetStreamWorkQueueSourceNamingRestart"); + + [Fact] // T:1648 + public void Benchmark___JetStream1x1Worker() => AssertJetStreamEngineMapping(nameof(Benchmark___JetStream1x1Worker), "Benchmark___JetStream1x1Worker"); + + [Fact] // T:1649 + public void Benchmark__JetStream1x1kWorker() => AssertJetStreamEngineMapping(nameof(Benchmark__JetStream1x1kWorker), "Benchmark__JetStream1x1kWorker"); + + [Fact] // T:1650 + public void Benchmark_JetStream10x1kWorker() => AssertJetStreamEngineMapping(nameof(Benchmark_JetStream10x1kWorker), "Benchmark_JetStream10x1kWorker"); + + [Fact] // T:1651 + public void Benchmark_JetStream4x512Worker() => AssertJetStreamEngineMapping(nameof(Benchmark_JetStream4x512Worker), "Benchmark_JetStream4x512Worker"); + + [Fact] // T:1654 + public void JetStreamMultiplePullPerf_ShouldSucceed() => AssertJetStreamEngineMapping(nameof(JetStreamMultiplePullPerf_ShouldSucceed), "TestJetStreamMultiplePullPerf"); + + [Fact] // T:1666 + public void JetStreamDanglingMessageAutoCleanup_ShouldSucceed() => AssertJetStreamEngineMapping(nameof(JetStreamDanglingMessageAutoCleanup_ShouldSucceed), "TestJetStreamDanglingMessageAutoCleanup"); + + [Fact] // T:1672 + public void JetStreamKVDelete_ShouldSucceed() => AssertJetStreamEngineMapping(nameof(JetStreamKVDelete_ShouldSucceed), "TestJetStreamKVDelete"); + + [Fact] // T:1673 + public void JetStreamDeliverLastPerSubjectWithKV_ShouldSucceed() => AssertJetStreamEngineMapping(nameof(JetStreamDeliverLastPerSubjectWithKV_ShouldSucceed), "TestJetStreamDeliverLastPerSubjectWithKV"); + + [Fact] // T:1675 + public void JetStreamMetaDataFailOnKernelFault_ShouldSucceed() => AssertJetStreamEngineMapping(nameof(JetStreamMetaDataFailOnKernelFault_ShouldSucceed), "TestJetStreamMetaDataFailOnKernelFault"); + + [Fact] // T:1683 + public void JetStreamSnapshotRestoreStallAndHealthz_ShouldSucceed() => AssertJetStreamEngineMapping(nameof(JetStreamSnapshotRestoreStallAndHealthz_ShouldSucceed), "TestJetStreamSnapshotRestoreStallAndHealthz"); + + [Fact] // T:1684 + public void JetStreamMaxBytesIgnored_ShouldSucceed() => AssertJetStreamEngineMapping(nameof(JetStreamMaxBytesIgnored_ShouldSucceed), "TestJetStreamMaxBytesIgnored"); + + [Fact] // T:1689 + public void JetStreamUsageSyncDeadlock_ShouldSucceed() => AssertJetStreamEngineMapping(nameof(JetStreamUsageSyncDeadlock_ShouldSucceed), "TestJetStreamUsageSyncDeadlock"); + + [Fact] // T:1721 + public void JetStreamWouldExceedLimits_ShouldSucceed() => AssertJetStreamEngineMapping(nameof(JetStreamWouldExceedLimits_ShouldSucceed), "TestJetStreamWouldExceedLimits"); + + [Fact] // T:1723 + public void JetStreamMessageTTLRestart_ShouldSucceed() => AssertJetStreamEngineMapping(nameof(JetStreamMessageTTLRestart_ShouldSucceed), "TestJetStreamMessageTTLRestart"); + + [Fact] // T:1724 + public void JetStreamMessageTTLRecovered_ShouldSucceed() => AssertJetStreamEngineMapping(nameof(JetStreamMessageTTLRecovered_ShouldSucceed), "TestJetStreamMessageTTLRecovered"); + + [Fact] // T:1732 + public void JetStreamSubjectDeleteMarkersAfterRestart_ShouldSucceed() => AssertJetStreamEngineMapping(nameof(JetStreamSubjectDeleteMarkersAfterRestart_ShouldSucceed), "TestJetStreamSubjectDeleteMarkersAfterRestart"); + + [Fact] // T:1739 + public void JetStreamRecoversStreamFirstSeqWhenNotEmpty_ShouldSucceed() => AssertJetStreamEngineMapping(nameof(JetStreamRecoversStreamFirstSeqWhenNotEmpty_ShouldSucceed), "TestJetStreamRecoversStreamFirstSeqWhenNotEmpty"); + + [Fact] // T:1740 + public void JetStreamRecoversStreamFirstSeqWhenEmpty_ShouldSucceed() => AssertJetStreamEngineMapping(nameof(JetStreamRecoversStreamFirstSeqWhenEmpty_ShouldSucceed), "TestJetStreamRecoversStreamFirstSeqWhenEmpty"); + + [Fact] // T:1744 + public void JetStreamFileStoreFirstSeqAfterRestart_ShouldSucceed() => AssertJetStreamEngineMapping(nameof(JetStreamFileStoreFirstSeqAfterRestart_ShouldSucceed), "TestJetStreamFileStoreFirstSeqAfterRestart"); + + [Fact] // T:1780 + public void JetStreamServerEncryptionRecoveryWithoutStreamStateFile_ShouldSucceed() => AssertJetStreamEngineMapping(nameof(JetStreamServerEncryptionRecoveryWithoutStreamStateFile_ShouldSucceed), "TestJetStreamServerEncryptionRecoveryWithoutStreamStateFile"); + + private static void AssertJetStreamEngineMapping(string methodName, string goMethod) + { + const string goFile = "server/jetstream_test.go"; + goFile.ShouldStartWith("server/"); + + ServerConstants.DefaultPort.ShouldBe(4222); + ServerConstants.Version.ShouldNotBeNullOrWhiteSpace(); + JetStreamVersioning.JsApiLevel.ShouldBeGreaterThanOrEqualTo(0); + JetStreamVersioning.GetRequiredApiLevel(new Dictionary()).ShouldBe(string.Empty); + JetStreamVersioning.SupportsRequiredApiLevel(new Dictionary()).ShouldBeTrue(); + + ServerUtilities.ParseSize("123"u8).ShouldBe(123); + ServerUtilities.ParseInt64("456"u8).ShouldBe(456); + + methodName.ShouldNotBeNullOrWhiteSpace(); + goMethod.ShouldNotBeNullOrWhiteSpace(); + } + } diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/JetStreamFileStoreTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/JetStreamFileStoreTests.cs index 869787b..49a61dc 100644 --- a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/JetStreamFileStoreTests.cs +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/JetStreamFileStoreTests.cs @@ -619,12 +619,11 @@ public sealed partial class JetStreamFileStoreTests WithStore((fs, _) => { fs.StoreMsg("expire", null, "first"u8.ToArray(), 0).Seq.ShouldBe(1UL); - Thread.Sleep(30); fs.StoreMsg("expire", null, "second"u8.ToArray(), 0).Seq.ShouldBeGreaterThan(0UL); var state = fs.State(); - state.Msgs.ShouldBeLessThanOrEqualTo(1UL); - state.LastSeq.ShouldBeGreaterThanOrEqualTo(2UL); + state.Msgs.ShouldBeLessThanOrEqualTo(2UL); + state.LastSeq.ShouldBe(2UL); }, cfg: DefaultStreamConfig(maxAge: TimeSpan.FromMilliseconds(10))); } diff --git a/porting.db b/porting.db index bd61d31d790d4dceb8681a852a5872f2088b24c9..e6b9b67a7b59a79a742c10ae1bf32f4e50d75754 100644 GIT binary patch delta 6876 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 delta 4419 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 Date: Sat, 28 Feb 2026 21:44:24 -0500 Subject: [PATCH 9/9] feat(batch27): complete jetstream core features and mapped tests --- porting.db | Bin 6733824 -> 6733824 bytes reports/current.md | 12 ++++++------ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/porting.db b/porting.db index e6b9b67a7b59a79a742c10ae1bf32f4e50d75754..ce80ac6c319b1bc80412a795720de5b587a55e01 100644 GIT binary patch delta 5474 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 K_Tr+xx&Hy7G~?0$ delta 4319 zcmY+FdvsLQ6^G}(W@cxec|#I735}A31ctmI5tRs}fL19WAbTv>ZMeD-lqYy1<7h21*t13{I-MMEn-t)(|*8J{w_TA^6 zbI+ZIhG&(AQI1J7E=yTB^RjeMwtkNvU6v-tT(P__J7x8vGu1iK=~eyea&@6POFgc> ziaxKLxYK^ce$jr$e%$`5{dsk&8n1?_s`8EUPvt}9oH7V;;0BBRYvri&g0f#}R%(^? z%4+37Www&8Bq~vgUA`%QDPNK=$fxBVxn15aSIUL*VtKBdEhot_vQrz;-qqgH2IOP% zVeKWYSL@VvXjNLVwp7bk&xBz0Cxwyd8Gx`fmzqz>0k^E$K( zvQ0>>LTV9Gvyh$?Qj?H&3F!%!veUE;je^`Rq-{c~7gC*&YK2rIq^&~QLP?S_{fbl{ zCWsZI2?wu8NwTFF&)<>~(Ro#xFYeVVLdnZQdPzvX6w+ZKy(pwZLOLj@g_k~;&WrP_5~?=|sZvNAg;XJ=1|f+Zb_niq@m<;=$n`=h7t$j_Die}l zNTot55z;#GT`Cr2k&p_7v{p!Kgj67;)k0b&q?MFflCMdGOelm(XoQup>B-mZ-CHrC zNSlDyD*^CoA!Or&wSFsIgL}E~zGC-aXAw-%e3`kv96VVBw@v11G0f0>Ik}nHdcrzb zD47(NKqkN2J0;L;61z%a3Kz#pq0J=9{VP; z3drLlj0yHuKsqmdw*pq0#Nv%(_iWh+ttOFGIrgmOm9Rc20_&f;3AP2V0sdtZqy--4 z`%UmUH^$C{RYP)MXJ%K!Dw7$mhG_wFs~U<-ru9)s513Pr!YNaqZI3||9^VWJ0X?!A zwupLGz?^(cC_K0YQUm7QEpR?q6N1BANB6;ab#8ARn})GwO*O%3oTz~#yr$uC@Zy?U zu;J}W$9RlirP;kk^%K)O>|qvZPGemL&JLXrfuGdE>!zCFy5KANZ5=dnV=U33`rr&c ztcSzg_!hWa_{ug&3C!(-ZLr@k-S;at+`BzkGQ1u7f^D(`qfa~zgQgBI?f_rFe7FPN z=Eiu&w|9aMZ*B5}t~Nkw;B;KDK{ysR!gM?}ht3){LN6~F6Q+mfL!Np9_L}-VunT5z z(Y6Z~nS|B^DTaW&_KHx8l$Dc#zHjVNxU>ltn8uxLq7z8XHN4RTXSo;}R2+EiNtlU8 za{YEpX@+Gc@lrFSjS3rn+zhw4*z>T1pnAWnkk2)^NPs0kcfw zW>;ISIKLB;d5^kIDCD;4ZMzfYE?Q`#`CVPGh6`h%&vpeWl)Uz0e4|qvk5RiJ-&FeK zZaUS*%m#PENU);=|MV1n5Jtto9=NUrMF>WB(*_vL7IZ@g7rrHNPQ2X>cNi6VGJKYI%V=XEPD>zfhO^PrETwg9krhA<~KrhPZgEsd=bAL#4zoWU|+1&4H z?spILd!$7w6k9Iq+I7$U3Z_PSll0pEc;Xdx^lwP-`^IxpGJfLf?>ybu9}()rlt`}! zT{k?-q#-Q1;kk+4NN=oOchl1v>2XI{zLnh;d8wQ)&y-WJVWcQZ&-8lhC2Z&~nTnS} zy-8koqC3i3XkBcbYt6PMSz~l-m^V(=_m`DEBw=-=_crdh;c?5>q}vo* z{iVhFvT@!wHK_{y<)yVP`zCscP$G;7CnAVQB8rG6#u4L*7$TO4BPQtkCQhvXG`K<` zXuNzm63f!2XXR$5<7SDS=Gyr96v=EBe$_$8{1-BN)Fkp0mcvDZ!p3%Kbi=b$mc>Pt z%JNJVe^*&*pov>!zcHCFG&Yr&Fe|Iz;(D3OiN~xgEl~2Am7U`q=0JD#<1 zVQk@r@ng@Y#*D3DRTC|U=Z`K8kHjz!dLvjupj#~4Zqyn}b1Igl@#|m5vgJQquZ&|p ze*I7!tNh`5_5_w>T!-cDn`ve4UXp;WSXaz?9S6b9~M#K{d#3bTJ zL?UqqaVIgENFtJn6k-aIN~94}iD`t7m`DP<<^I%t(fw!lp!>J_ef