Port session P7-09: add tests from jetstream_versioning_test.go (T:1791–1808),
dirstore_test.go (T:285–296), jetstream_batching_test.go (T:716–744),
jetstream_errors_test.go (T:1381–1384), and accounts_test.go (T:80–110).
- JetStreamVersioningTests: 12 active unit tests + 6 deferred (server-required)
- DirectoryStoreTests: 12 filesystem tests using fake JWTs (no NKeys dependency)
- JetStreamBatchingTests: 29 deferred stubs (all require running JetStream cluster)
- JetStreamErrorsTests: 4 deferred stubs (NewJS* factories not yet ported)
- accounts_test.go T:80–110: 31 deferred (all use RunServerWithConfig)
Fix DirJwtStore.cs expiration bugs:
- Use DateTimeOffset.UtcNow.UtcTicks (not Unix-relative ticks) for expiry comparison
- Replace in-place JwtItem mutation with new-object replacement so DrainStale
can detect stale heap entries via ReferenceEquals check
Add JetStreamVersioning.cs methods: SetStaticStreamMetadata,
SetDynamicStreamMetadata, CopyStreamMetadata, SetStaticConsumerMetadata,
SetDynamicConsumerMetadata, SetDynamicConsumerInfoMetadata, CopyConsumerMetadata.
Tests: 725 pass, 53 skipped/deferred, 0 failures.
DB: +24 complete, +66 deferred.
327 lines
14 KiB
C#
327 lines
14 KiB
C#
// 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;
|
|
|
|
/// <summary>
|
|
/// JetStream API level versioning constants and helpers.
|
|
/// Mirrors server/jetstream_versioning.go.
|
|
/// </summary>
|
|
public static class JetStreamVersioning
|
|
{
|
|
/// <summary>Maximum supported JetStream API level for this server.</summary>
|
|
public const int JsApiLevel = 3;
|
|
|
|
/// <summary>Metadata key that carries the required API level for a stream or consumer asset.</summary>
|
|
public const string JsRequiredLevelMetadataKey = "_nats.req.level";
|
|
|
|
/// <summary>Metadata key that carries the server version that created/updated the asset.</summary>
|
|
public const string JsServerVersionMetadataKey = "_nats.ver";
|
|
|
|
/// <summary>Metadata key that carries the server API level that created/updated the asset.</summary>
|
|
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.
|
|
|
|
/// <summary>API level required for per-message TTL and SubjectDeleteMarkerTTL (v2.11).</summary>
|
|
public const int ApiLevelForTTL = 1;
|
|
|
|
/// <summary>API level required for consumer PauseUntil (v2.11).</summary>
|
|
public const int ApiLevelForConsumerPause = 1;
|
|
|
|
/// <summary>API level required for priority groups (v2.11).</summary>
|
|
public const int ApiLevelForPriorityGroups = 1;
|
|
|
|
/// <summary>API level required for counter CRDTs (v2.12).</summary>
|
|
public const int ApiLevelForCounters = 2;
|
|
|
|
/// <summary>API level required for atomic batch publishing (v2.12).</summary>
|
|
public const int ApiLevelForAtomicPublish = 2;
|
|
|
|
/// <summary>API level required for message scheduling (v2.12).</summary>
|
|
public const int ApiLevelForMsgSchedules = 2;
|
|
|
|
/// <summary>API level required for async persist mode (v2.12).</summary>
|
|
public const int ApiLevelForAsyncPersist = 2;
|
|
|
|
// ---- Helper methods ----
|
|
|
|
/// <summary>
|
|
/// Returns the required API level string from stream or consumer metadata,
|
|
/// or an empty string if not set.
|
|
/// Mirrors <c>getRequiredApiLevel</c>.
|
|
/// </summary>
|
|
public static string GetRequiredApiLevel(IDictionary<string, string>? metadata)
|
|
{
|
|
if (metadata is not null && metadata.TryGetValue(JsRequiredLevelMetadataKey, out var l) && l.Length > 0)
|
|
return l;
|
|
return string.Empty;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns whether this server supports the required API level encoded in the asset's metadata.
|
|
/// Mirrors <c>supportsRequiredApiLevel</c>.
|
|
/// </summary>
|
|
public static bool SupportsRequiredApiLevel(IDictionary<string, string>? metadata)
|
|
{
|
|
var l = GetRequiredApiLevel(metadata);
|
|
if (l.Length == 0) return true;
|
|
return int.TryParse(l, out var level) && level <= JsApiLevel;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Removes dynamic (per-response) versioning fields from metadata.
|
|
/// These should never be stored; only added in API responses.
|
|
/// Mirrors <c>deleteDynamicMetadata</c>.
|
|
/// </summary>
|
|
public static void DeleteDynamicMetadata(IDictionary<string, string> metadata)
|
|
{
|
|
metadata.Remove(JsServerVersionMetadataKey);
|
|
metadata.Remove(JsServerLevelMetadataKey);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns whether a request should be rejected based on the Nats-Required-Api-Level header value.
|
|
/// Mirrors <c>errorOnRequiredApiLevel</c>.
|
|
/// </summary>
|
|
public static bool ErrorOnRequiredApiLevel(string? reqApiLevelHeader)
|
|
{
|
|
if (string.IsNullOrEmpty(reqApiLevelHeader)) return false;
|
|
return !int.TryParse(reqApiLevelHeader, out var minLevel) || JsApiLevel < minLevel;
|
|
}
|
|
|
|
// ---- Stream metadata mutations ----
|
|
|
|
/// <summary>
|
|
/// 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 <c>setStaticStreamMetadata</c>.
|
|
/// </summary>
|
|
public static void SetStaticStreamMetadata(StreamConfig cfg)
|
|
{
|
|
cfg.Metadata ??= new Dictionary<string, string>();
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns a shallow copy of the stream config with dynamic versioning fields added to a new
|
|
/// metadata dictionary. Does not mutate <paramref name="cfg"/>.
|
|
/// Mirrors <c>setDynamicStreamMetadata</c>.
|
|
/// </summary>
|
|
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<string, string>();
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Copies the required-level versioning field from <paramref name="prevCfg"/> into
|
|
/// <paramref name="cfg"/>, removing dynamic fields and deleting the key if absent in prevCfg.
|
|
/// Mirrors <c>copyStreamMetadata</c>.
|
|
/// </summary>
|
|
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<string, string>();
|
|
cfg.Metadata[key] = value;
|
|
return;
|
|
}
|
|
if (cfg.Metadata != null)
|
|
{
|
|
cfg.Metadata.Remove(key);
|
|
if (cfg.Metadata.Count == 0)
|
|
cfg.Metadata = null;
|
|
}
|
|
}
|
|
|
|
// ---- Consumer metadata mutations ----
|
|
|
|
/// <summary>
|
|
/// 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 <c>setStaticConsumerMetadata</c>.
|
|
/// </summary>
|
|
public static void SetStaticConsumerMetadata(ConsumerConfig cfg)
|
|
{
|
|
cfg.Metadata ??= new Dictionary<string, string>();
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns a shallow copy of the consumer config with dynamic versioning fields added to a new
|
|
/// metadata dictionary. Does not mutate <paramref name="cfg"/>.
|
|
/// Mirrors <c>setDynamicConsumerMetadata</c>.
|
|
/// </summary>
|
|
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<string, string>();
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns a shallow copy of the consumer info with dynamic versioning fields added to the
|
|
/// config's metadata. Does not mutate <paramref name="info"/>.
|
|
/// Mirrors <c>setDynamicConsumerInfoMetadata</c>.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Copies the required-level versioning field from <paramref name="prevCfg"/> into
|
|
/// <paramref name="cfg"/>, removing dynamic fields and deleting the key if absent in prevCfg.
|
|
/// Mirrors <c>copyConsumerMetadata</c>.
|
|
/// </summary>
|
|
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<string, string>();
|
|
cfg.Metadata[key] = value;
|
|
return;
|
|
}
|
|
if (cfg.Metadata != null)
|
|
{
|
|
cfg.Metadata.Remove(key);
|
|
if (cfg.Metadata.Count == 0)
|
|
cfg.Metadata = null;
|
|
}
|
|
}
|
|
|
|
// ---- Private helpers ----
|
|
|
|
/// <summary>
|
|
/// Copies all scalar/reference properties from <paramref name="src"/> to <paramref name="dst"/>,
|
|
/// excluding <c>Metadata</c> (which is set separately by the caller).
|
|
/// </summary>
|
|
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.
|
|
}
|
|
}
|