feat: port session 02 — Utilities & Queues (util, ipqueue, scheduler, subject_transform)
- ServerUtilities: version helpers, parseSize/parseInt64, parseHostPort, URL redaction, comma formatting, refCountedUrlSet, TCP helpers, parallelTaskQueue - IpQueue<T>: generic intra-process queue with 1-slot Channel<bool> notification signal, optional size/len limits, ConcurrentDictionary registry, single-slot List<T> pool - MsgScheduling: per-subject scheduled message tracking via HashWheel TTLs, binary encode/decode with zigzag varint, Timer-based firing - SubjectTransform: full NATS subject mapping engine (11 transform types: Wildcard, Partition, SplitFromLeft, SplitFromRight, SliceFromLeft, SliceFromRight, Split, Left, Right, Random, NoTransform), FNV-1a partition hash - 20 tests (7 util, 9 ipqueue, 4 subject_transform); 45 benchmarks/split tests marked n/a - All 113 tests pass (112 unit + 1 integration) - DB: features 328/3673 complete, tests 139/3257 complete (8.7% overall)
This commit is contained in:
437
dotnet/src/ZB.MOM.NatsNet.Server/Internal/ServerUtilities.cs
Normal file
437
dotnet/src/ZB.MOM.NatsNet.Server/Internal/ServerUtilities.cs
Normal file
@@ -0,0 +1,437 @@
|
||||
// Copyright 2012-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/util.go in the NATS server Go source.
|
||||
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// General-purpose server utility methods.
|
||||
/// Mirrors server/util.go.
|
||||
/// </summary>
|
||||
public static class ServerUtilities
|
||||
{
|
||||
// Semver validation regex — mirrors semVerRe in const.go.
|
||||
private static readonly Regex SemVerRe = new(
|
||||
@"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Version helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Parses a semver string into major/minor/patch components.
|
||||
/// Returns an error if the string is not a valid semver.
|
||||
/// Mirrors <c>versionComponents</c>.
|
||||
/// </summary>
|
||||
public static (int major, int minor, int patch, Exception? err) VersionComponents(string version)
|
||||
{
|
||||
var m = SemVerRe.Match(version);
|
||||
if (!m.Success)
|
||||
return (0, 0, 0, new InvalidOperationException("invalid semver"));
|
||||
|
||||
if (!int.TryParse(m.Groups[1].Value, out var major) ||
|
||||
!int.TryParse(m.Groups[2].Value, out var minor) ||
|
||||
!int.TryParse(m.Groups[3].Value, out var patch))
|
||||
return (-1, -1, -1, new InvalidOperationException("invalid semver component"));
|
||||
|
||||
return (major, minor, patch, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns (true, nil) if <paramref name="version"/> is at least major.minor.patch.
|
||||
/// Mirrors <c>versionAtLeastCheckError</c>.
|
||||
/// </summary>
|
||||
public static (bool ok, Exception? err) VersionAtLeastCheckError(
|
||||
string version, int emajor, int eminor, int epatch)
|
||||
{
|
||||
var (major, minor, patch, err) = VersionComponents(version);
|
||||
if (err != null) return (false, err);
|
||||
|
||||
if (major > emajor) return (true, null);
|
||||
if (major == emajor && minor > eminor) return (true, null);
|
||||
if (major == emajor && minor == eminor && patch >= epatch) return (true, null);
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if <paramref name="version"/> is at least major.minor.patch.
|
||||
/// Mirrors <c>versionAtLeast</c>.
|
||||
/// </summary>
|
||||
public static bool VersionAtLeast(string version, int emajor, int eminor, int epatch)
|
||||
{
|
||||
var (ok, _) = VersionAtLeastCheckError(version, emajor, eminor, epatch);
|
||||
return ok;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Integer parsing helpers (used for NATS protocol parsing)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Parses a decimal positive integer from ASCII bytes.
|
||||
/// Returns -1 on error or if the input contains non-digit characters.
|
||||
/// Mirrors <c>parseSize</c>.
|
||||
/// </summary>
|
||||
public static int ParseSize(ReadOnlySpan<byte> d)
|
||||
{
|
||||
const int MaxParseSizeLen = 9; // 999M
|
||||
|
||||
if (d.IsEmpty || d.Length > MaxParseSizeLen) return -1;
|
||||
|
||||
var n = 0;
|
||||
foreach (var dec in d)
|
||||
{
|
||||
if (dec < '0' || dec > '9') return -1;
|
||||
n = n * 10 + (dec - '0');
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a decimal positive int64 from ASCII bytes.
|
||||
/// Returns -1 on error.
|
||||
/// Mirrors <c>parseInt64</c>.
|
||||
/// </summary>
|
||||
public static long ParseInt64(ReadOnlySpan<byte> d)
|
||||
{
|
||||
if (d.IsEmpty) return -1;
|
||||
|
||||
long n = 0;
|
||||
foreach (var dec in d)
|
||||
{
|
||||
if (dec < '0' || dec > '9') return -1;
|
||||
n = n * 10 + (dec - '0');
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Duration / network helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Converts float64 seconds to a <see cref="TimeSpan"/>.
|
||||
/// Mirrors <c>secondsToDuration</c>.
|
||||
/// </summary>
|
||||
public static TimeSpan SecondsToDuration(double seconds) =>
|
||||
TimeSpan.FromSeconds(seconds);
|
||||
|
||||
/// <summary>
|
||||
/// Splits "host:port" into components, using <paramref name="defaultPort"/>
|
||||
/// when no port (or port 0 / -1) is present.
|
||||
/// Mirrors <c>parseHostPort</c>.
|
||||
/// </summary>
|
||||
public static (string host, int port, Exception? err) ParseHostPort(string hostPort, int defaultPort)
|
||||
{
|
||||
if (string.IsNullOrEmpty(hostPort))
|
||||
return ("", -1, new InvalidOperationException("no hostport specified"));
|
||||
|
||||
// Try splitting; if port is missing, append the default and retry.
|
||||
string host, sPort;
|
||||
try
|
||||
{
|
||||
var ep = ParseEndpoint(hostPort);
|
||||
host = ep.host;
|
||||
sPort = ep.port;
|
||||
}
|
||||
catch
|
||||
{
|
||||
try
|
||||
{
|
||||
var ep = ParseEndpoint($"{hostPort}:{defaultPort}");
|
||||
host = ep.host;
|
||||
sPort = ep.port;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ("", -1, ex);
|
||||
}
|
||||
}
|
||||
|
||||
if (!int.TryParse(sPort.Trim(), out var port))
|
||||
return ("", -1, new InvalidOperationException($"invalid port: {sPort}"));
|
||||
|
||||
if (port == 0 || port == -1)
|
||||
port = defaultPort;
|
||||
|
||||
return (host.Trim(), port, null);
|
||||
}
|
||||
|
||||
private static (string host, string port) ParseEndpoint(string hostPort)
|
||||
{
|
||||
// net.SplitHostPort equivalent — handles IPv6 [::1]:port
|
||||
if (hostPort.StartsWith('['))
|
||||
{
|
||||
var closeIdx = hostPort.IndexOf(']');
|
||||
if (closeIdx < 0 || closeIdx + 1 >= hostPort.Length || hostPort[closeIdx + 1] != ':')
|
||||
throw new InvalidOperationException($"missing port in address {hostPort}");
|
||||
return (hostPort[1..closeIdx], hostPort[(closeIdx + 2)..]);
|
||||
}
|
||||
|
||||
var lastColon = hostPort.LastIndexOf(':');
|
||||
if (lastColon < 0)
|
||||
throw new InvalidOperationException($"missing port in address {hostPort}");
|
||||
|
||||
var host = hostPort[..lastColon];
|
||||
var port = hostPort[(lastColon + 1)..];
|
||||
|
||||
// Reject bare IPv6 addresses (multiple colons without brackets).
|
||||
if (host.Contains(':'))
|
||||
throw new InvalidOperationException($"too many colons in address {hostPort}");
|
||||
|
||||
return (host, port);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if two <see cref="Uri"/> instances represent the same URL.
|
||||
/// Mirrors <c>urlsAreEqual</c>.
|
||||
/// </summary>
|
||||
public static bool UrlsAreEqual(Uri? u1, Uri? u2) =>
|
||||
u1 == u2 || (u1 != null && u2 != null && u1.ToString() == u2.ToString());
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Comma formatting
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Formats an int64 with comma thousands separators.
|
||||
/// Mirrors <c>comma</c> in util.go.
|
||||
/// </summary>
|
||||
public static string Comma(long v)
|
||||
{
|
||||
if (v == long.MinValue) return "-9,223,372,036,854,775,808";
|
||||
|
||||
var sign = "";
|
||||
if (v < 0) { sign = "-"; v = -v; }
|
||||
|
||||
var parts = new string[7];
|
||||
var j = parts.Length - 1;
|
||||
|
||||
while (v > 999)
|
||||
{
|
||||
var part = (v % 1000).ToString();
|
||||
parts[j--] = part.Length switch { 2 => "0" + part, 1 => "00" + part, _ => part };
|
||||
v /= 1000;
|
||||
}
|
||||
parts[j] = v.ToString();
|
||||
|
||||
return sign + string.Join(",", parts.Skip(j));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TCP helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Creates a TCP listener with keepalives disabled (NATS server default).
|
||||
/// Mirrors <c>natsListen</c>.
|
||||
/// </summary>
|
||||
public static System.Net.Sockets.TcpListener NatsListen(string address, int port)
|
||||
{
|
||||
// .NET TcpListener does not set keepalive by default; the socket can be
|
||||
// further configured after creation if needed.
|
||||
var listener = new System.Net.Sockets.TcpListener(IPAddress.Parse(address), port);
|
||||
return listener;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a TCP connection with the given timeout and keepalives disabled.
|
||||
/// Mirrors <c>natsDialTimeout</c>.
|
||||
/// </summary>
|
||||
public static async Task<System.Net.Sockets.TcpClient> NatsDialTimeoutAsync(
|
||||
string host, int port, TimeSpan timeout)
|
||||
{
|
||||
var client = new System.Net.Sockets.TcpClient();
|
||||
// Disable keepalive to match Go 1.12 behavior.
|
||||
client.Client.SetSocketOption(
|
||||
System.Net.Sockets.SocketOptionLevel.Socket,
|
||||
System.Net.Sockets.SocketOptionName.KeepAlive,
|
||||
false);
|
||||
|
||||
using var cts = new CancellationTokenSource(timeout);
|
||||
await client.ConnectAsync(host, port, cts.Token);
|
||||
return client;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// URL redaction
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Returns a copy of <paramref name="unredacted"/> where any URL that
|
||||
/// contains a password has its password replaced with "xxxxx".
|
||||
/// Mirrors <c>redactURLList</c>.
|
||||
/// </summary>
|
||||
public static Uri[] RedactUrlList(Uri[] unredacted)
|
||||
{
|
||||
var r = new Uri[unredacted.Length];
|
||||
var needCopy = false;
|
||||
|
||||
for (var i = 0; i < unredacted.Length; i++)
|
||||
{
|
||||
var u = unredacted[i];
|
||||
if (u?.UserInfo?.Contains(':') == true)
|
||||
{
|
||||
needCopy = true;
|
||||
var ui = u.UserInfo;
|
||||
var colon = ui.IndexOf(':');
|
||||
var username = ui[..colon];
|
||||
var b = new UriBuilder(u) { Password = "xxxxx", UserName = username };
|
||||
r[i] = b.Uri;
|
||||
}
|
||||
else
|
||||
{
|
||||
r[i] = u!;
|
||||
}
|
||||
}
|
||||
|
||||
return needCopy ? r : unredacted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the URL string with the password component redacted ("xxxxx").
|
||||
/// Returns the original string if no password is present or it cannot be parsed.
|
||||
/// Mirrors <c>redactURLString</c>.
|
||||
/// </summary>
|
||||
public static string RedactUrlString(string raw)
|
||||
{
|
||||
if (!raw.Contains('@')) return raw;
|
||||
if (!Uri.TryCreate(raw, UriKind.Absolute, out var u)) return raw;
|
||||
|
||||
if (!u.UserInfo.Contains(':')) return raw;
|
||||
|
||||
var colon = u.UserInfo.IndexOf(':');
|
||||
var username = u.UserInfo[..colon];
|
||||
var b = new UriBuilder(u) { Password = "xxxxx", UserName = username };
|
||||
var result = b.Uri.ToString();
|
||||
// UriBuilder adds a trailing slash for authority-only URLs; strip it if the input had none.
|
||||
if (!raw.EndsWith('/') && result.EndsWith('/'))
|
||||
result = result[..^1];
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the Host part of each URL in the list.
|
||||
/// Mirrors <c>getURLsAsString</c>.
|
||||
/// </summary>
|
||||
public static string[] GetUrlsAsString(Uri[] urls)
|
||||
{
|
||||
var result = new string[urls.Length];
|
||||
for (var i = 0; i < urls.Length; i++)
|
||||
result[i] = urls[i].Authority; // host:port
|
||||
return result;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Copy helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Returns a copy of <paramref name="src"/>, or null if src is empty.
|
||||
/// Mirrors <c>copyBytes</c>.
|
||||
/// </summary>
|
||||
public static byte[]? CopyBytes(byte[]? src)
|
||||
{
|
||||
if (src == null || src.Length == 0) return null;
|
||||
var dst = new byte[src.Length];
|
||||
src.CopyTo(dst, 0);
|
||||
return dst;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a copy of <paramref name="src"/>, or null if src is null.
|
||||
/// Mirrors <c>copyStrings</c>.
|
||||
/// </summary>
|
||||
public static string[]? CopyStrings(string[]? src)
|
||||
{
|
||||
if (src == null) return null;
|
||||
var dst = new string[src.Length];
|
||||
src.CopyTo(dst, 0);
|
||||
return dst;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Parallel task queue
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Creates a bounded channel onto which tasks can be posted for parallel
|
||||
/// execution across a pool of dedicated threads. Close the returned channel
|
||||
/// to signal workers to stop (after queued items complete).
|
||||
/// Mirrors <c>parallelTaskQueue</c>.
|
||||
/// </summary>
|
||||
public static System.Threading.Channels.ChannelWriter<Action> CreateParallelTaskQueue(int maxParallelism = 0)
|
||||
{
|
||||
var mp = maxParallelism <= 0 ? Environment.ProcessorCount : Math.Max(Environment.ProcessorCount, maxParallelism);
|
||||
var channel = System.Threading.Channels.Channel.CreateBounded<Action>(mp);
|
||||
|
||||
for (var i = 0; i < mp; i++)
|
||||
{
|
||||
Task.Run(async () =>
|
||||
{
|
||||
await foreach (var fn in channel.Reader.ReadAllAsync())
|
||||
fn();
|
||||
});
|
||||
}
|
||||
|
||||
return channel.Writer;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// RefCountedUrlSet (mirrors refCountedUrlSet map[string]int in util.go)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// A reference-counted set of URL strings used for gossip URL management.
|
||||
/// Mirrors <c>refCountedUrlSet</c> in server/util.go.
|
||||
/// </summary>
|
||||
public sealed class RefCountedUrlSet
|
||||
{
|
||||
private readonly Dictionary<string, int> _map = new();
|
||||
|
||||
/// <summary>
|
||||
/// Adds <paramref name="urlStr"/>. Returns true if it was added for the first time.
|
||||
/// Mirrors <c>refCountedUrlSet.addUrl</c>.
|
||||
/// </summary>
|
||||
public bool AddUrl(string urlStr)
|
||||
{
|
||||
_map.TryGetValue(urlStr, out var count);
|
||||
_map[urlStr] = count + 1;
|
||||
return count == 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decrements the reference count for <paramref name="urlStr"/>.
|
||||
/// Returns true if this was the last reference (entry removed).
|
||||
/// Mirrors <c>refCountedUrlSet.removeUrl</c>.
|
||||
/// </summary>
|
||||
public bool RemoveUrl(string urlStr)
|
||||
{
|
||||
if (!_map.TryGetValue(urlStr, out var count)) return false;
|
||||
if (count == 1) { _map.Remove(urlStr); return true; }
|
||||
_map[urlStr] = count - 1;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the unique URL strings currently in the set.
|
||||
/// Mirrors <c>refCountedUrlSet.getAsStringSlice</c>.
|
||||
/// </summary>
|
||||
public string[] GetAsStringSlice() => [.. _map.Keys];
|
||||
}
|
||||
Reference in New Issue
Block a user