// 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; using ZB.MOM.NatsNet.Server.Internal.DataStructures; namespace ZB.MOM.NatsNet.Server.Internal; /// /// Error for when we try to decode a binary-encoded message schedule with an unknown version number. /// Mirrors ErrMsgScheduleInvalidVersion. /// public static class MsgSchedulingErrors { public static readonly Exception ErrMsgScheduleInvalidVersion = new InvalidOperationException("msg scheduling: encoded version not known"); } /// /// A single scheduled message entry. /// Mirrors the unnamed struct in the schedules map in scheduler.go. /// internal sealed class MsgSchedule { public ulong Seq; public long Ts; } /// /// Tracks per-subject scheduled messages using a hash wheel for TTL management. /// Mirrors MsgScheduling in server/scheduler.go. /// Note: getScheduledMessages is deferred to session 08/19 (requires JetStream types). /// 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 _schedules; private readonly Dictionary _seqToSubj; private readonly HashSet _inflight; /// /// Creates a new with the given callback. /// Mirrors newMsgScheduling. /// public MsgScheduling(Action run) { _run = run; _ttls = HashWheel.NewHashWheel(); _schedules = new Dictionary(); _seqToSubj = new Dictionary(); _inflight = new HashSet(); } /// /// Adds a schedule entry and resets the timer. /// Mirrors MsgScheduling.add. /// public void Add(ulong seq, string subj, long ts) { Init(seq, subj, ts); ResetTimer(); } /// /// Inserts or updates the schedule entry for the given subject. /// Mirrors MsgScheduling.init. /// 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); } /// /// Updates the timestamp for an existing schedule without changing the sequence. /// Mirrors MsgScheduling.update. /// 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(); } /// /// Marks a subject as in-flight (being processed). /// Mirrors MsgScheduling.markInflight. /// public void MarkInflight(string subj) { if (_schedules.ContainsKey(subj)) _inflight.Add(subj); } /// /// Returns true if the subject is currently in-flight. /// Mirrors MsgScheduling.isInflight. /// public bool IsInflight(string subj) => _inflight.Contains(subj); /// /// Removes the schedule entry for the given sequence number. /// Mirrors MsgScheduling.remove. /// public void Remove(ulong seq) { if (!_seqToSubj.TryGetValue(seq, out var subj)) return; _seqToSubj.Remove(seq); _schedules.Remove(subj); } /// /// Removes the schedule entry for the given subject. /// Mirrors MsgScheduling.removeSubject. /// 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); } /// /// Clears all in-flight markers. /// Mirrors MsgScheduling.clearInflight. /// public void ClearInflight() => _inflight.Clear(); /// /// Arms or resets the internal timer to fire at the next scheduled expiration. /// Mirrors MsgScheduling.resetTimer. /// 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); } /// /// Processes expired schedule entries and returns the set of messages to be delivered. /// Each message is retrieved from storage, headers are cleaned and augmented, and the /// subject is replaced with the schedule target. Messages are returned sorted by /// sequence number. /// Mirrors Go MsgScheduling.getScheduledMessages in server/scheduler.go. /// /// /// Callback that loads a stored message by sequence number. /// The StoreMsg reuse buffer may be passed; returns null if not found. /// /// /// Callback that loads the last stored message for a given subject. /// Returns null if not found. /// public List GetScheduledMessages( Func loadMsg, Func loadLast) { var smv = new ZB.MOM.NatsNet.Server.StoreMsg(); List? msgs = null; _ttls.ExpireTasks((seq, ts) => { var sm = loadMsg(seq, smv); if (sm != null) { // Already in-flight for this subject — skip. var subj = sm.Subject; if (IsInflight(subj)) return false; // Validate the schedule pattern header. var patternBytes = NatsMessageHeaders.GetHeader( NatsHeaderConstants.JsSchedulePattern, sm.Hdr); if (patternBytes == null || patternBytes.Length == 0) { Remove(seq); return true; } var pattern = System.Text.Encoding.ASCII.GetString(patternBytes); var (next, repeat, ok) = ParseMsgSchedule(pattern, ts); if (!ok) { Remove(seq); return true; } var (ttl, ttlOk) = ZB.MOM.NatsNet.Server.NatsStream.GetMessageScheduleTTL(sm.Hdr); if (!ttlOk) { Remove(seq); return true; } var target = ZB.MOM.NatsNet.Server.NatsStream.GetMessageScheduleTarget(sm.Hdr); if (string.IsNullOrEmpty(target)) { Remove(seq); return true; } var source = ZB.MOM.NatsNet.Server.NatsStream.GetMessageScheduleSource(sm.Hdr); if (!string.IsNullOrEmpty(source)) { sm = loadLast(source, smv); if (sm == null) { Remove(seq); return true; } } // Copy headers and body — message lives beyond this callback. var hdr = sm.Hdr.Length > 0 ? (byte[])sm.Hdr.Clone() : []; var msg = sm.Msg.Length > 0 ? (byte[])sm.Msg.Clone() : []; // Strip schedule-specific headers. hdr = NatsMessageHeaders.RemoveHeaderIfPresent(hdr, NatsHeaderConstants.JsSchedulePattern) ?? []; hdr = NatsMessageHeaders.RemoveHeaderIfPrefixPresent(hdr, "Nats-Schedule-") ?? []; hdr = NatsMessageHeaders.RemoveHeaderIfPrefixPresent(hdr, "Nats-Expected-") ?? []; hdr = NatsMessageHeaders.RemoveHeaderIfPresent(hdr, NatsHeaderConstants.JsMsgId) ?? []; hdr = NatsMessageHeaders.RemoveHeaderIfPresent(hdr, NatsHeaderConstants.JsMessageTtl) ?? []; hdr = NatsMessageHeaders.RemoveHeaderIfPresent(hdr, NatsHeaderConstants.JsMsgRollup) ?? []; // Add scheduler-specific headers. hdr = NatsMessageHeaders.GenHeader(hdr, NatsHeaderConstants.JsScheduler, subj); if (!repeat) { hdr = NatsMessageHeaders.GenHeader(hdr, NatsHeaderConstants.JsScheduleNext, NatsHeaderConstants.JsScheduleNextPurge); } else { hdr = NatsMessageHeaders.GenHeader(hdr, NatsHeaderConstants.JsScheduleNext, next.ToString("yyyy-MM-ddTHH:mm:ssK")); } if (!string.IsNullOrEmpty(ttl)) hdr = NatsMessageHeaders.GenHeader(hdr, NatsHeaderConstants.JsMessageTtl, ttl); msgs ??= []; msgs.Add(new InMsg { Seq = seq, Subject = target, Hdr = hdr, Msg = msg }); MarkInflight(subj); return false; } Remove(seq); return true; }); if (msgs == null) return []; // THW is unordered — sort by sequence before returning. msgs.Sort((a, b) => a.Seq.CompareTo(b.Seq)); return msgs; } /// /// Encodes the current schedule state to a binary snapshot. /// Mirrors MsgScheduling.encode. /// public byte[] Encode(ulong highSeq) { var count = (ulong)_schedules.Count; var buf = new List(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]; } /// /// Decodes a binary snapshot into the current schedule. /// Returns the high-sequence stamp or throws on error. /// Mirrors MsgScheduling.decode. /// 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); } /// /// Parses a message schedule pattern and returns the next fire time, /// whether it repeats, and whether the pattern was valid. /// Mirrors parseMsgSchedule. /// 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 buf, ulong v) { Span tmp = stackalloc byte[8]; BinaryPrimitives.WriteUInt64LittleEndian(tmp, v); buf.AddRange(tmp.ToArray()); } private static void AppendUInt16(List buf, ushort v) { Span tmp = stackalloc byte[2]; BinaryPrimitives.WriteUInt16LittleEndian(tmp, v); buf.AddRange(tmp.ToArray()); } /// Appends a zigzag-encoded signed varint (mirrors binary.AppendVarint). private static void AppendVarint(List buf, long x) { var ux = (ulong)(x << 1); if (x < 0) ux = ~ux; AppendUvarint(buf, ux); } /// Appends an unsigned varint (mirrors binary.AppendUvarint). private static void AppendUvarint(List buf, ulong x) { while (x >= 0x80) { buf.Add((byte)(x | 0x80)); x >>= 7; } buf.Add((byte)x); } /// /// Reads a zigzag signed varint from starting at . /// Returns (value, bytesRead); bytesRead is negative on overflow. /// 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); } /// /// Reads an unsigned varint from starting at . /// Returns (value, bytesRead); bytesRead is negative on overflow. /// 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 } }