// Copyright 2024-2025 The NATS Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // Adapted from server/jetstream_versioning.go in the NATS server Go source. namespace ZB.MOM.NatsNet.Server; /// /// JetStream API level versioning constants and helpers. /// Mirrors server/jetstream_versioning.go. /// public static class JetStreamVersioning { /// Maximum supported JetStream API level for this server. public const int JsApiLevel = 3; /// Metadata key that carries the required API level for a stream or consumer asset. public const string JsRequiredLevelMetadataKey = "_nats.req.level"; /// Metadata key that carries the server version that created/updated the asset. public const string JsServerVersionMetadataKey = "_nats.ver"; /// Metadata key that carries the server API level that created/updated the asset. public const string JsServerLevelMetadataKey = "_nats.level"; // ---- API level feature gates ---- // These document which API level each feature requires. // They correspond to the requires() calls in setStaticStreamMetadata / setStaticConsumerMetadata. /// API level required for per-message TTL and SubjectDeleteMarkerTTL (v2.11). public const int ApiLevelForTTL = 1; /// API level required for consumer PauseUntil (v2.11). public const int ApiLevelForConsumerPause = 1; /// API level required for priority groups (v2.11). public const int ApiLevelForPriorityGroups = 1; /// API level required for counter CRDTs (v2.12). public const int ApiLevelForCounters = 2; /// API level required for atomic batch publishing (v2.12). public const int ApiLevelForAtomicPublish = 2; /// API level required for message scheduling (v2.12). public const int ApiLevelForMsgSchedules = 2; /// API level required for async persist mode (v2.12). public const int ApiLevelForAsyncPersist = 2; // ---- Helper methods ---- /// /// Returns the required API level string from stream or consumer metadata, /// or an empty string if not set. /// Mirrors getRequiredApiLevel. /// public static string GetRequiredApiLevel(IDictionary? metadata) { if (metadata is not null && metadata.TryGetValue(JsRequiredLevelMetadataKey, out var l) && l.Length > 0) return l; return string.Empty; } /// /// Returns whether this server supports the required API level encoded in the asset's metadata. /// Mirrors supportsRequiredApiLevel. /// public static bool SupportsRequiredApiLevel(IDictionary? metadata) { var l = GetRequiredApiLevel(metadata); if (l.Length == 0) return true; return int.TryParse(l, out var level) && level <= JsApiLevel; } /// /// Removes dynamic (per-response) versioning fields from metadata. /// These should never be stored; only added in API responses. /// Mirrors deleteDynamicMetadata. /// public static void DeleteDynamicMetadata(IDictionary metadata) { metadata.Remove(JsServerVersionMetadataKey); metadata.Remove(JsServerLevelMetadataKey); } /// /// Returns whether a request should be rejected based on the Nats-Required-Api-Level header value. /// Mirrors errorOnRequiredApiLevel. /// public static bool ErrorOnRequiredApiLevel(string? reqApiLevelHeader) { if (string.IsNullOrEmpty(reqApiLevelHeader)) return false; return !int.TryParse(reqApiLevelHeader, out var minLevel) || JsApiLevel < minLevel; } // ---- Stream metadata mutations ---- /// /// Sets the required API level in stream config metadata based on which v2.11+/v2.12+ features /// the stream config uses. Removes any dynamic fields first. /// Mirrors setStaticStreamMetadata. /// public static void SetStaticStreamMetadata(StreamConfig cfg) { cfg.Metadata ??= new Dictionary(); DeleteDynamicMetadata(cfg.Metadata); var requiredApiLevel = 0; void Requires(int level) { if (level > requiredApiLevel) requiredApiLevel = level; } if (cfg.AllowMsgTTL || cfg.SubjectDeleteMarkerTTL > TimeSpan.Zero) Requires(ApiLevelForTTL); if (cfg.AllowMsgCounter) Requires(ApiLevelForCounters); if (cfg.AllowAtomicPublish) Requires(ApiLevelForAtomicPublish); if (cfg.AllowMsgSchedules) Requires(ApiLevelForMsgSchedules); if (cfg.PersistMode == PersistModeType.AsyncPersistMode) Requires(ApiLevelForAsyncPersist); cfg.Metadata[JsRequiredLevelMetadataKey] = requiredApiLevel.ToString(); } /// /// Returns a shallow copy of the stream config with dynamic versioning fields added to a new /// metadata dictionary. Does not mutate . /// Mirrors setDynamicStreamMetadata. /// public static StreamConfig SetDynamicStreamMetadata(StreamConfig cfg) { // Shallow-copy the struct-like record: clone all fields then replace metadata. var newCfg = cfg.Clone(); newCfg.Metadata = new Dictionary(); if (cfg.Metadata != null) foreach (var kv in cfg.Metadata) newCfg.Metadata[kv.Key] = kv.Value; newCfg.Metadata[JsServerVersionMetadataKey] = ServerConstants.Version; newCfg.Metadata[JsServerLevelMetadataKey] = JsApiLevel.ToString(); return newCfg; } /// /// Copies the required-level versioning field from into /// , removing dynamic fields and deleting the key if absent in prevCfg. /// Mirrors copyStreamMetadata. /// public static void CopyStreamMetadata(StreamConfig cfg, StreamConfig? prevCfg) { if (cfg.Metadata != null) DeleteDynamicMetadata(cfg.Metadata); SetOrDeleteInStreamMetadata(cfg, prevCfg, JsRequiredLevelMetadataKey); } private static void SetOrDeleteInStreamMetadata(StreamConfig cfg, StreamConfig? prevCfg, string key) { if (prevCfg?.Metadata != null && prevCfg.Metadata.TryGetValue(key, out var value)) { cfg.Metadata ??= new Dictionary(); cfg.Metadata[key] = value; return; } if (cfg.Metadata != null) { cfg.Metadata.Remove(key); if (cfg.Metadata.Count == 0) cfg.Metadata = null; } } // ---- Consumer metadata mutations ---- /// /// Sets the required API level in consumer config metadata based on which v2.11+ features /// the consumer config uses. Removes any dynamic fields first. /// Mirrors setStaticConsumerMetadata. /// public static void SetStaticConsumerMetadata(ConsumerConfig cfg) { cfg.Metadata ??= new Dictionary(); DeleteDynamicMetadata(cfg.Metadata); var requiredApiLevel = 0; void Requires(int level) { if (level > requiredApiLevel) requiredApiLevel = level; } if (cfg.PauseUntil.HasValue && cfg.PauseUntil.Value != default) Requires(ApiLevelForConsumerPause); if (cfg.PriorityPolicy != PriorityPolicy.PriorityNone || cfg.PinnedTTL != TimeSpan.Zero || (cfg.PriorityGroups != null && cfg.PriorityGroups.Length > 0)) Requires(ApiLevelForPriorityGroups); cfg.Metadata[JsRequiredLevelMetadataKey] = requiredApiLevel.ToString(); } /// /// Returns a shallow copy of the consumer config with dynamic versioning fields added to a new /// metadata dictionary. Does not mutate . /// Mirrors setDynamicConsumerMetadata. /// public static ConsumerConfig SetDynamicConsumerMetadata(ConsumerConfig cfg) { var newCfg = new ConsumerConfig(); // Copy all fields via serialisation-free approach: copy properties from cfg CopyConsumerConfigFields(cfg, newCfg); newCfg.Metadata = new Dictionary(); if (cfg.Metadata != null) foreach (var kv in cfg.Metadata) newCfg.Metadata[kv.Key] = kv.Value; newCfg.Metadata[JsServerVersionMetadataKey] = ServerConstants.Version; newCfg.Metadata[JsServerLevelMetadataKey] = JsApiLevel.ToString(); return newCfg; } /// /// Returns a shallow copy of the consumer info with dynamic versioning fields added to the /// config's metadata. Does not mutate . /// Mirrors setDynamicConsumerInfoMetadata. /// public static ConsumerInfo SetDynamicConsumerInfoMetadata(ConsumerInfo info) { var newInfo = new ConsumerInfo { Stream = info.Stream, Name = info.Name, Created = info.Created, Delivered = info.Delivered, AckFloor = info.AckFloor, NumAckPending = info.NumAckPending, NumRedelivered = info.NumRedelivered, NumWaiting = info.NumWaiting, NumPending = info.NumPending, Cluster = info.Cluster, PushBound = info.PushBound, Paused = info.Paused, PauseRemaining = info.PauseRemaining, TimeStamp = info.TimeStamp, PriorityGroups = info.PriorityGroups, Config = info.Config != null ? SetDynamicConsumerMetadata(info.Config) : null, }; return newInfo; } /// /// Copies the required-level versioning field from into /// , removing dynamic fields and deleting the key if absent in prevCfg. /// Mirrors copyConsumerMetadata. /// public static void CopyConsumerMetadata(ConsumerConfig cfg, ConsumerConfig? prevCfg) { if (cfg.Metadata != null) DeleteDynamicMetadata(cfg.Metadata); SetOrDeleteInConsumerMetadata(cfg, prevCfg, JsRequiredLevelMetadataKey); } private static void SetOrDeleteInConsumerMetadata(ConsumerConfig cfg, ConsumerConfig? prevCfg, string key) { if (prevCfg?.Metadata != null && prevCfg.Metadata.TryGetValue(key, out var value)) { cfg.Metadata ??= new Dictionary(); cfg.Metadata[key] = value; return; } if (cfg.Metadata != null) { cfg.Metadata.Remove(key); if (cfg.Metadata.Count == 0) cfg.Metadata = null; } } // ---- Private helpers ---- /// /// Copies all scalar/reference properties from to , /// excluding Metadata (which is set separately by the caller). /// private static void CopyConsumerConfigFields(ConsumerConfig src, ConsumerConfig dst) { dst.DeliverPolicy = src.DeliverPolicy; dst.OptStartSeq = src.OptStartSeq; dst.OptStartTime = src.OptStartTime; dst.DeliverSubject = src.DeliverSubject; dst.DeliverGroup = src.DeliverGroup; dst.Durable = src.Durable; dst.Name = src.Name; dst.Description = src.Description; dst.FilterSubject = src.FilterSubject; dst.FilterSubjects = src.FilterSubjects; dst.AckPolicy = src.AckPolicy; dst.AckWait = src.AckWait; dst.MaxDeliver = src.MaxDeliver; dst.BackOff = src.BackOff; dst.ReplayPolicy = src.ReplayPolicy; dst.RateLimit = src.RateLimit; dst.SampleFrequency = src.SampleFrequency; dst.MaxWaiting = src.MaxWaiting; dst.MaxAckPending = src.MaxAckPending; dst.FlowControl = src.FlowControl; dst.Heartbeat = src.Heartbeat; dst.Direct = src.Direct; dst.HeadersOnly = src.HeadersOnly; dst.MaxRequestBatch = src.MaxRequestBatch; dst.MaxRequestMaxBytes = src.MaxRequestMaxBytes; dst.MaxRequestExpires = src.MaxRequestExpires; dst.InactiveThreshold = src.InactiveThreshold; dst.Replicas = src.Replicas; dst.MemoryStorage = src.MemoryStorage; dst.PauseUntil = src.PauseUntil; dst.PinnedTTL = src.PinnedTTL; dst.PriorityPolicy = src.PriorityPolicy; dst.PriorityGroups = src.PriorityGroups; // Metadata is NOT copied here — caller sets it. } }