Implement deferred core utility parity APIs/tests and refresh tracking artifacts

This commit is contained in:
Joseph Doherty
2026-02-27 10:27:05 -05:00
parent c0aaae9236
commit b94a67be6e
17 changed files with 842 additions and 9 deletions

View File

@@ -25,6 +25,18 @@ public static class AccessTimeService
// Mirror Go's init(): nothing to pre-allocate in .NET.
}
/// <summary>
/// Explicit init hook for Go parity.
/// Mirrors package <c>init()</c> in server/ats/ats.go.
/// This method is intentionally idempotent.
/// </summary>
public static void Init()
{
// Ensure a non-zero cached timestamp is present.
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1_000_000L;
Interlocked.CompareExchange(ref _utime, now, 0);
}
/// <summary>
/// Registers a user. Starts the background timer when the first registrant calls this.
/// Each call to <see cref="Register"/> must be paired with a call to <see cref="Unregister"/>.

View File

@@ -40,6 +40,24 @@ public sealed class IpQueue<T>
/// <summary>Default maximum size of the recycled backing-list capacity.</summary>
public const int DefaultMaxRecycleSize = 4 * 1024;
/// <summary>
/// Functional option type used by <see cref="NewIPQueue"/>.
/// Mirrors Go <c>ipQueueOpt</c>.
/// </summary>
public delegate void IpQueueOption(IpQueueOptions options);
/// <summary>
/// Option bag used by <see cref="NewIPQueue"/>.
/// Mirrors Go <c>ipQueueOpts</c>.
/// </summary>
public sealed class IpQueueOptions
{
public int MaxRecycleSize { get; set; } = DefaultMaxRecycleSize;
public Func<T, ulong>? SizeCalc { get; set; }
public ulong MaxSize { get; set; }
public int MaxLen { get; set; }
}
private long _inprogress;
private readonly object _lock = new();
@@ -68,6 +86,56 @@ public sealed class IpQueue<T>
/// <summary>Notification channel reader — wait on this to learn items were added.</summary>
public ChannelReader<bool> Ch => _ch.Reader;
/// <summary>
/// Option helper that configures maximum recycled backing-list size.
/// Mirrors Go <c>ipqMaxRecycleSize</c>.
/// </summary>
public static IpQueueOption IpqMaxRecycleSize(int max) =>
options => options.MaxRecycleSize = max;
/// <summary>
/// Option helper that enables size accounting for queue elements.
/// Mirrors Go <c>ipqSizeCalculation</c>.
/// </summary>
public static IpQueueOption IpqSizeCalculation(Func<T, ulong> calc) =>
options => options.SizeCalc = calc;
/// <summary>
/// Option helper that limits queue pushes by total accounted size.
/// Mirrors Go <c>ipqLimitBySize</c>.
/// </summary>
public static IpQueueOption IpqLimitBySize(ulong max) =>
options => options.MaxSize = max;
/// <summary>
/// Option helper that limits queue pushes by element count.
/// Mirrors Go <c>ipqLimitByLen</c>.
/// </summary>
public static IpQueueOption IpqLimitByLen(int max) =>
options => options.MaxLen = max;
/// <summary>
/// Factory wrapper for Go parity.
/// Mirrors <c>newIPQueue</c>.
/// </summary>
public static IpQueue<T> NewIPQueue(
string name,
ConcurrentDictionary<string, object>? registry = null,
params IpQueueOption[] options)
{
var opts = new IpQueueOptions();
foreach (var option in options)
option(opts);
return new IpQueue<T>(
name,
registry,
opts.MaxRecycleSize,
opts.SizeCalc,
opts.MaxSize,
opts.MaxLen);
}
/// <summary>
/// Creates a new queue, optionally registering it in <paramref name="registry"/>.
/// Mirrors <c>newIPQueue</c>.

View File

@@ -38,6 +38,12 @@ public sealed class RateCounter
Interval = TimeSpan.FromSeconds(1);
}
/// <summary>
/// Factory wrapper for Go parity.
/// Mirrors <c>newRateCounter</c>.
/// </summary>
public static RateCounter NewRateCounter(long limit) => new(limit);
/// <summary>
/// Returns true if the event is within the rate limit for the current window.
/// Mirrors <c>rateCounter.allow</c>.

View File

@@ -14,6 +14,8 @@
// Adapted from server/util.go in the NATS server Go source.
using System.Net;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace ZB.MOM.NatsNet.Server.Internal;
@@ -268,6 +270,25 @@ public static class ServerUtilities
return client;
}
/// <summary>
/// Parity wrapper for Go <c>natsDialTimeout</c>.
/// Accepts a network label (tcp/tcp4/tcp6) and host:port address.
/// </summary>
public static Task<System.Net.Sockets.TcpClient> NatsDialTimeout(
string network, string address, TimeSpan timeout)
{
if (!string.Equals(network, "tcp", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(network, "tcp4", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(network, "tcp6", StringComparison.OrdinalIgnoreCase))
throw new NotSupportedException($"unsupported network: {network}");
var (host, port, err) = ParseHostPort(address, defaultPort: 0);
if (err != null || port <= 0)
throw new InvalidOperationException($"invalid dial address: {address}", err);
return NatsDialTimeoutAsync(host, port, timeout);
}
// -------------------------------------------------------------------------
// URL redaction
// -------------------------------------------------------------------------
@@ -337,6 +358,54 @@ public static class ServerUtilities
return result;
}
// -------------------------------------------------------------------------
// RefCountedUrlSet wrappers (Go parity mapping)
// -------------------------------------------------------------------------
/// <summary>
/// Parity wrapper for <see cref="RefCountedUrlSet.AddUrl"/>.
/// Mirrors <c>refCountedUrlSet.addUrl</c>.
/// </summary>
public static bool AddUrl(RefCountedUrlSet urlSet, string urlStr)
{
ArgumentNullException.ThrowIfNull(urlSet);
return urlSet.AddUrl(urlStr);
}
/// <summary>
/// Parity wrapper for <see cref="RefCountedUrlSet.RemoveUrl"/>.
/// Mirrors <c>refCountedUrlSet.removeUrl</c>.
/// </summary>
public static bool RemoveUrl(RefCountedUrlSet urlSet, string urlStr)
{
ArgumentNullException.ThrowIfNull(urlSet);
return urlSet.RemoveUrl(urlStr);
}
/// <summary>
/// Parity wrapper for <see cref="RefCountedUrlSet.GetAsStringSlice"/>.
/// Mirrors <c>refCountedUrlSet.getAsStringSlice</c>.
/// </summary>
public static string[] GetAsStringSlice(RefCountedUrlSet urlSet)
{
ArgumentNullException.ThrowIfNull(urlSet);
return urlSet.GetAsStringSlice();
}
// -------------------------------------------------------------------------
// INFO helpers
// -------------------------------------------------------------------------
/// <summary>
/// Serialises <paramref name="info"/> into an INFO line (<c>INFO {...}\r\n</c>).
/// Mirrors <c>generateInfoJSON</c>.
/// </summary>
public static byte[] GenerateInfoJSON(global::ZB.MOM.NatsNet.Server.ServerInfo info)
{
var json = JsonSerializer.Serialize(info);
return Encoding.UTF8.GetBytes($"INFO {json}\r\n");
}
// -------------------------------------------------------------------------
// Copy helpers
// -------------------------------------------------------------------------
@@ -391,6 +460,13 @@ public static class ServerUtilities
return channel.Writer;
}
/// <summary>
/// Parity wrapper for <see cref="CreateParallelTaskQueue"/>.
/// Mirrors <c>parallelTaskQueue</c>.
/// </summary>
public static System.Threading.Channels.ChannelWriter<Action> ParallelTaskQueue(int maxParallelism = 0) =>
CreateParallelTaskQueue(maxParallelism);
}
// -------------------------------------------------------------------------

View File

@@ -187,6 +187,12 @@ public static class SignalHandler
_ => throw new ArgumentOutOfRangeException(nameof(command), $"unknown signal \"{CommandToString(command)}\""),
};
/// <summary>
/// Go parity alias for <see cref="CommandToUnixSignal"/>.
/// Mirrors <c>CommandToSignal</c> in signal.go.
/// </summary>
public static UnixSignal CommandToSignal(ServerCommand command) => CommandToUnixSignal(command);
private static Exception? SendSignal(int pid, UnixSignal signal)
{
try

View File

@@ -77,4 +77,16 @@ public sealed class AccessTimeServiceTests : IDisposable
// Mirror: TestUnbalancedUnregister
Should.Throw<InvalidOperationException>(() => AccessTimeService.Unregister());
}
[Fact]
public void Init_ShouldBeIdempotentAndNonThrowing()
{
Should.NotThrow(() => AccessTimeService.Init());
var first = AccessTimeService.AccessTime();
first.ShouldBeGreaterThan(0);
Should.NotThrow(() => AccessTimeService.Init());
var second = AccessTimeService.AccessTime();
second.ShouldBeGreaterThan(0);
}
}

View File

@@ -28,6 +28,62 @@ namespace ZB.MOM.NatsNet.Server.Tests.Internal;
/// </summary>
public sealed class IpQueueTests
{
[Fact]
public void IpqMaxRecycleSize_ShouldAffectQueueConfig()
{
var q = IpQueue<int>.NewIPQueue("opt-max-recycle", null, IpQueue<int>.IpqMaxRecycleSize(123));
q.MaxRecycleSize.ShouldBe(123);
}
[Fact]
public void IpqSizeCalculation_AndLimitBySize_ShouldEnforceLimit()
{
var q = IpQueue<byte[]>.NewIPQueue(
"opt-size-limit",
null,
IpQueue<byte[]>.IpqSizeCalculation(e => (ulong)e.Length),
IpQueue<byte[]>.IpqLimitBySize(8));
var (_, err1) = q.Push(new byte[4]);
err1.ShouldBeNull();
var (_, err2) = q.Push(new byte[4]);
err2.ShouldBeNull();
var (_, err3) = q.Push(new byte[1]);
err3.ShouldBeSameAs(IpQueueErrors.SizeLimitReached);
}
[Fact]
public void IpqLimitByLen_ShouldEnforceLengthLimit()
{
var q = IpQueue<int>.NewIPQueue("opt-len-limit", null, IpQueue<int>.IpqLimitByLen(2));
q.Push(1).error.ShouldBeNull();
q.Push(2).error.ShouldBeNull();
q.Push(3).error.ShouldBeSameAs(IpQueueErrors.LenLimitReached);
}
[Fact]
public void NewIPQueue_ShouldApplyOptionsAndRegister()
{
var registry = new ConcurrentDictionary<string, object>();
var q = IpQueue<int>.NewIPQueue(
"opt-factory",
registry,
IpQueue<int>.IpqMaxRecycleSize(55),
IpQueue<int>.IpqLimitByLen(1));
q.MaxRecycleSize.ShouldBe(55);
registry.TryGetValue("opt-factory", out var registered).ShouldBeTrue();
registered.ShouldBeSameAs(q);
var (_, err1) = q.Push(1);
err1.ShouldBeNull();
var (_, err2) = q.Push(2);
err2.ShouldBeSameAs(IpQueueErrors.LenLimitReached);
}
[Fact]
public void Basic_ShouldInitialiseCorrectly()
{

View File

@@ -22,6 +22,17 @@ namespace ZB.MOM.NatsNet.Server.Tests.Internal;
/// </summary>
public sealed class RateCounterTests
{
[Fact]
public void NewRateCounter_ShouldCreateWithDefaultInterval()
{
var counter = RateCounter.NewRateCounter(2);
counter.Interval.ShouldBe(TimeSpan.FromSeconds(1));
counter.Allow().ShouldBeTrue();
counter.Allow().ShouldBeTrue();
counter.Allow().ShouldBeFalse();
}
[Fact]
public async Task RateCounter_ShouldAllowUpToLimitThenBlockAndReset()
{

View File

@@ -11,7 +11,10 @@
// See the License for the specific language governing permissions and
// limitations under the License.
using System.Net;
using System.Text.Json;
using Shouldly;
using ZB.MOM.NatsNet.Server;
using ZB.MOM.NatsNet.Server.Internal;
namespace ZB.MOM.NatsNet.Server.Tests.Internal;
@@ -191,4 +194,86 @@ public sealed class ServerUtilitiesTests
$"VersionAtLeast({version}, {major}, {minor}, {update})");
}
}
[Fact]
public void RefCountedUrlSet_Wrappers_ShouldTrackRefCounts()
{
var set = new RefCountedUrlSet();
ServerUtilities.AddUrl(set, "nats://a:4222").ShouldBeTrue();
ServerUtilities.AddUrl(set, "nats://a:4222").ShouldBeFalse();
ServerUtilities.AddUrl(set, "nats://b:4222").ShouldBeTrue();
ServerUtilities.RemoveUrl(set, "nats://a:4222").ShouldBeFalse();
ServerUtilities.RemoveUrl(set, "nats://a:4222").ShouldBeTrue();
var urls = ServerUtilities.GetAsStringSlice(set);
urls.Length.ShouldBe(1);
urls[0].ShouldBe("nats://b:4222");
}
[Fact]
public async Task NatsDialTimeout_ShouldConnectWithinTimeout()
{
using var listener = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
var acceptTask = listener.AcceptTcpClientAsync();
using var client = await ServerUtilities.NatsDialTimeout(
"tcp",
$"127.0.0.1:{port}",
TimeSpan.FromSeconds(2));
client.Connected.ShouldBeTrue();
using var accepted = await acceptTask;
accepted.Connected.ShouldBeTrue();
}
[Fact]
public void GenerateInfoJSON_ShouldEmitInfoLineWithCRLF()
{
var info = new ServerInfo
{
Id = "S1",
Name = "n1",
Host = "127.0.0.1",
Port = 4222,
Version = "2.0.0",
Proto = 1,
GoVersion = "go1.23",
};
var bytes = ServerUtilities.GenerateInfoJSON(info);
var line = System.Text.Encoding.UTF8.GetString(bytes);
line.ShouldStartWith("INFO ");
line.ShouldEndWith("\r\n");
var json = line["INFO ".Length..^2];
var payload = JsonSerializer.Deserialize<ServerInfo>(json);
payload.ShouldNotBeNull();
payload!.Id.ShouldBe("S1");
}
[Fact]
public async Task ParallelTaskQueue_ShouldExecuteQueuedActions()
{
var writer = ServerUtilities.ParallelTaskQueue(maxParallelism: 2);
var ran = 0;
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
for (var i = 0; i < 4; i++)
{
var accepted = writer.TryWrite(() =>
{
if (Interlocked.Increment(ref ran) == 4)
tcs.TrySetResult();
});
accepted.ShouldBeTrue();
}
writer.TryComplete().ShouldBeTrue();
var finished = await Task.WhenAny(tcs.Task, Task.Delay(TimeSpan.FromSeconds(2)));
finished.ShouldBe(tcs.Task);
ran.ShouldBe(4);
}
}

View File

@@ -35,6 +35,16 @@ public sealed class SignalHandlerTests : IDisposable
SignalHandler.CommandToUnixSignal(ServerCommand.LameDuckMode).ShouldBe(UnixSignal.SigUsr2);
}
[Fact]
public void CommandToSignal_ShouldMatchCommandToUnixSignal()
{
foreach (var command in Enum.GetValues<ServerCommand>())
{
SignalHandler.CommandToSignal(command)
.ShouldBe(SignalHandler.CommandToUnixSignal(command));
}
}
[Fact] // T:3155
public void SetProcessName_ShouldNotThrow()
{