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:
Joseph Doherty
2026-02-26 09:39:36 -05:00
parent 8050ee1897
commit 11c0b92fbd
10 changed files with 2786 additions and 8 deletions
@@ -0,0 +1,431 @@
// Copyright 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/scheduler.go in the NATS server Go source.
using System.Buffers.Binary;
using ZB.MOM.NatsNet.Server.Internal.DataStructures;
namespace ZB.MOM.NatsNet.Server.Internal;
/// <summary>
/// Error for when we try to decode a binary-encoded message schedule with an unknown version number.
/// Mirrors <c>ErrMsgScheduleInvalidVersion</c>.
/// </summary>
public static class MsgSchedulingErrors
{
public static readonly Exception ErrMsgScheduleInvalidVersion =
new InvalidOperationException("msg scheduling: encoded version not known");
}
/// <summary>
/// A single scheduled message entry.
/// Mirrors the unnamed struct in the <c>schedules</c> map in scheduler.go.
/// </summary>
internal sealed class MsgSchedule
{
public ulong Seq;
public long Ts;
}
/// <summary>
/// Tracks per-subject scheduled messages using a hash wheel for TTL management.
/// Mirrors <c>MsgScheduling</c> in server/scheduler.go.
/// Note: <c>getScheduledMessages</c> is deferred to session 08/19 (requires JetStream types).
/// </summary>
public sealed class MsgScheduling
{
private const int HeaderLen = 17; // 1 magic + 2 × uint64
private readonly Action _run;
private readonly HashWheel _ttls;
private Timer? _timer;
// _running is set to true by the run callback when getScheduledMessages is active (session 08/19).
#pragma warning disable CS0649
private bool _running;
#pragma warning restore CS0649
private long _deadline;
private readonly Dictionary<string, MsgSchedule> _schedules;
private readonly Dictionary<ulong, string> _seqToSubj;
private readonly HashSet<string> _inflight;
/// <summary>
/// Creates a new <see cref="MsgScheduling"/> with the given callback.
/// Mirrors <c>newMsgScheduling</c>.
/// </summary>
public MsgScheduling(Action run)
{
_run = run;
_ttls = HashWheel.NewHashWheel();
_schedules = new Dictionary<string, MsgSchedule>();
_seqToSubj = new Dictionary<ulong, string>();
_inflight = new HashSet<string>();
}
/// <summary>
/// Adds a schedule entry and resets the timer.
/// Mirrors <c>MsgScheduling.add</c>.
/// </summary>
public void Add(ulong seq, string subj, long ts)
{
Init(seq, subj, ts);
ResetTimer();
}
/// <summary>
/// Inserts or updates the schedule entry for the given subject.
/// Mirrors <c>MsgScheduling.init</c>.
/// </summary>
public void Init(ulong seq, string subj, long ts)
{
if (_schedules.TryGetValue(subj, out var sched))
{
_seqToSubj.Remove(sched.Seq);
_ttls.Remove(sched.Seq, sched.Ts);
_ttls.Add(seq, ts);
sched.Ts = ts;
sched.Seq = seq;
}
else
{
_ttls.Add(seq, ts);
_schedules[subj] = new MsgSchedule { Seq = seq, Ts = ts };
}
_seqToSubj[seq] = subj;
_inflight.Remove(subj);
}
/// <summary>
/// Updates the timestamp for an existing schedule without changing the sequence.
/// Mirrors <c>MsgScheduling.update</c>.
/// </summary>
public void Update(string subj, long ts)
{
if (!_schedules.TryGetValue(subj, out var sched)) return;
_ttls.Remove(sched.Seq, sched.Ts);
_ttls.Add(sched.Seq, ts);
sched.Ts = ts;
_inflight.Remove(subj);
ResetTimer();
}
/// <summary>
/// Marks a subject as in-flight (being processed).
/// Mirrors <c>MsgScheduling.markInflight</c>.
/// </summary>
public void MarkInflight(string subj)
{
if (_schedules.ContainsKey(subj))
_inflight.Add(subj);
}
/// <summary>
/// Returns true if the subject is currently in-flight.
/// Mirrors <c>MsgScheduling.isInflight</c>.
/// </summary>
public bool IsInflight(string subj) => _inflight.Contains(subj);
/// <summary>
/// Removes the schedule entry for the given sequence number.
/// Mirrors <c>MsgScheduling.remove</c>.
/// </summary>
public void Remove(ulong seq)
{
if (!_seqToSubj.TryGetValue(seq, out var subj)) return;
_seqToSubj.Remove(seq);
_schedules.Remove(subj);
}
/// <summary>
/// Removes the schedule entry for the given subject.
/// Mirrors <c>MsgScheduling.removeSubject</c>.
/// </summary>
public void RemoveSubject(string subj)
{
if (!_schedules.TryGetValue(subj, out var sched)) return;
_ttls.Remove(sched.Seq, sched.Ts);
_schedules.Remove(subj);
_seqToSubj.Remove(sched.Seq);
}
/// <summary>
/// Clears all in-flight markers.
/// Mirrors <c>MsgScheduling.clearInflight</c>.
/// </summary>
public void ClearInflight() => _inflight.Clear();
/// <summary>
/// Arms or resets the internal timer to fire at the next scheduled expiration.
/// Mirrors <c>MsgScheduling.resetTimer</c>.
/// </summary>
public void ResetTimer()
{
if (_running) return;
var next = _ttls.GetNextExpiration(long.MaxValue);
if (next == long.MaxValue)
{
ClearTimer(ref _timer);
return;
}
// Convert nanosecond timestamp to DateTime (1 tick = 100 ns).
var nextTicks = DateTime.UnixEpoch.Ticks + next / 100L;
var nextUtc = new DateTime(nextTicks, DateTimeKind.Utc);
var fireIn = nextUtc - DateTime.UtcNow;
// Clamp minimum interval.
if (fireIn < TimeSpan.FromMilliseconds(250))
fireIn = TimeSpan.FromMilliseconds(250);
var deadline = DateTime.UtcNow.Ticks + fireIn.Ticks;
if (_deadline > 0 && deadline > _deadline) return;
_deadline = deadline;
if (_timer != null)
_timer.Change(fireIn, Timeout.InfiniteTimeSpan);
else
_timer = new Timer(_ => _run(), null, fireIn, Timeout.InfiniteTimeSpan);
}
// getScheduledMessages is deferred to session 08/19 — requires JetStream inMsg, StoreMsg types.
/// <summary>
/// Encodes the current schedule state to a binary snapshot.
/// Mirrors <c>MsgScheduling.encode</c>.
/// </summary>
public byte[] Encode(ulong highSeq)
{
var count = (ulong)_schedules.Count;
var buf = new List<byte>(HeaderLen + (int)(count * 20));
buf.Add(1); // magic version
AppendUInt64(buf, count);
AppendUInt64(buf, highSeq);
foreach (var (subj, sched) in _schedules)
{
var slen = (ushort)Math.Min((ulong)subj.Length, ushort.MaxValue);
AppendUInt16(buf, slen);
buf.AddRange(System.Text.Encoding.Latin1.GetBytes(subj[..slen]));
AppendVarint(buf, sched.Ts);
AppendUvarint(buf, sched.Seq);
}
return [.. buf];
}
/// <summary>
/// Decodes a binary snapshot into the current schedule.
/// Returns the high-sequence stamp or throws on error.
/// Mirrors <c>MsgScheduling.decode</c>.
/// </summary>
public (ulong highSeq, Exception? err) Decode(byte[] b)
{
if (b.Length < HeaderLen)
return (0, new System.IO.EndOfStreamException("short buffer"));
if (b[0] != 1)
return (0, MsgSchedulingErrors.ErrMsgScheduleInvalidVersion);
var count = BinaryPrimitives.ReadUInt64LittleEndian(b.AsSpan(1));
var stamp = BinaryPrimitives.ReadUInt64LittleEndian(b.AsSpan(9));
var offset = HeaderLen;
for (ulong i = 0; i < count; i++)
{
if (offset + 2 > b.Length)
return (0, new System.IO.EndOfStreamException("unexpected EOF"));
var sl = BinaryPrimitives.ReadUInt16LittleEndian(b.AsSpan(offset));
offset += 2;
if (offset + sl > b.Length)
return (0, new System.IO.EndOfStreamException("unexpected EOF"));
var subj = System.Text.Encoding.Latin1.GetString(b, offset, sl);
offset += sl;
var (ts, tn) = ReadVarint(b, offset);
if (tn < 0) return (0, new System.IO.EndOfStreamException("unexpected EOF"));
offset += tn;
var (seq, vn) = ReadUvarint(b, offset);
if (vn < 0) return (0, new System.IO.EndOfStreamException("unexpected EOF"));
offset += vn;
Init(seq, subj, ts);
}
return (stamp, null);
}
/// <summary>
/// Parses a message schedule pattern and returns the next fire time,
/// whether it repeats, and whether the pattern was valid.
/// Mirrors <c>parseMsgSchedule</c>.
/// </summary>
public static (DateTime next, bool repeat, bool ok) ParseMsgSchedule(string pattern, long ts)
{
if (pattern == string.Empty)
return (default, false, true);
if (pattern.StartsWith("@at ", StringComparison.Ordinal))
{
if (DateTime.TryParseExact(
pattern[4..],
"yyyy-MM-ddTHH:mm:ssK",
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AdjustToUniversal,
out var t))
return (t, false, true);
return (default, false, false);
}
if (pattern.StartsWith("@every ", StringComparison.Ordinal))
{
if (!TryParseDuration(pattern[7..], out var dur))
return (default, false, false);
if (dur.TotalSeconds < 1)
return (default, false, false);
// Advance past a stale next tick the same way Go does.
var prev = DateTimeOffset.FromUnixTimeMilliseconds(ts / 1_000_000).UtcDateTime;
var next = RoundToSecond(prev).Add(dur);
var now = RoundToSecond(DateTime.UtcNow);
if (next < now)
next = now.Add(dur);
return (next, true, true);
}
return (default, false, false);
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
private static void ClearTimer(ref Timer? timer)
{
var t = timer;
if (t == null) return;
t.Dispose();
timer = null;
}
private static DateTime RoundToSecond(DateTime dt) =>
new(dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, dt.Second, DateTimeKind.Utc);
// Naive duration parser for strings like "1s", "500ms", "2m", "1h30m".
private static bool TryParseDuration(string s, out TimeSpan result)
{
result = default;
if (s.EndsWith("ms", StringComparison.Ordinal) &&
double.TryParse(s[..^2], out var ms))
{
result = TimeSpan.FromMilliseconds(ms);
return true;
}
if (s.EndsWith('s') && double.TryParse(s[..^1], out var sec))
{
result = TimeSpan.FromSeconds(sec);
return true;
}
if (s.EndsWith('m') && double.TryParse(s[..^1], out var min))
{
result = TimeSpan.FromMinutes(min);
return true;
}
if (s.EndsWith('h') && double.TryParse(s[..^1], out var hr))
{
result = TimeSpan.FromHours(hr);
return true;
}
// Try .NET TimeSpan.Parse as a fallback.
return TimeSpan.TryParse(s, out result);
}
// -------------------------------------------------------------------------
// Binary encoding helpers (mirrors encoding/binary in Go)
// -------------------------------------------------------------------------
private static void AppendUInt64(List<byte> buf, ulong v)
{
Span<byte> tmp = stackalloc byte[8];
BinaryPrimitives.WriteUInt64LittleEndian(tmp, v);
buf.AddRange(tmp.ToArray());
}
private static void AppendUInt16(List<byte> buf, ushort v)
{
Span<byte> tmp = stackalloc byte[2];
BinaryPrimitives.WriteUInt16LittleEndian(tmp, v);
buf.AddRange(tmp.ToArray());
}
/// <summary>Appends a zigzag-encoded signed varint (mirrors binary.AppendVarint).</summary>
private static void AppendVarint(List<byte> buf, long x)
{
var ux = (ulong)(x << 1);
if (x < 0) ux = ~ux;
AppendUvarint(buf, ux);
}
/// <summary>Appends an unsigned varint (mirrors binary.AppendUvarint).</summary>
private static void AppendUvarint(List<byte> buf, ulong x)
{
while (x >= 0x80)
{
buf.Add((byte)(x | 0x80));
x >>= 7;
}
buf.Add((byte)x);
}
/// <summary>
/// Reads a zigzag signed varint from <paramref name="b"/> starting at <paramref name="offset"/>.
/// Returns (value, bytesRead); bytesRead is negative on overflow.
/// </summary>
private static (long value, int n) ReadVarint(byte[] b, int offset)
{
var (ux, n) = ReadUvarint(b, offset);
var x = (long)(ux >> 1);
if ((ux & 1) != 0) x = ~x;
return (x, n);
}
/// <summary>
/// Reads an unsigned varint from <paramref name="b"/> starting at <paramref name="offset"/>.
/// Returns (value, bytesRead); bytesRead is negative on overflow.
/// </summary>
private static (ulong value, int n) ReadUvarint(byte[] b, int offset)
{
ulong x = 0;
var s = 0;
for (var i = offset; i < b.Length; i++)
{
var by = b[i];
if (i - offset == 10) return (0, -(i - offset + 1)); // overflow
if (by < 0x80)
{
if (i - offset == 9 && by > 1) return (0, -(i - offset + 1));
return (x | ((ulong)by << s), i - offset + 1);
}
x |= (ulong)(by & 0x7F) << s;
s += 7;
}
return (0, 0); // short buffer
}
}