using System.Collections.Concurrent; using System.Text; using System.Text.RegularExpressions; namespace ZB.MOM.WW.MxGateway.Server.Galaxy; public static class GalaxyGlobMatcher { /// /// Maximum number of compiled-regex entries retained in . /// The cache is keyed by glob pattern and patterns flow in from two sources: /// admin-controlled API-key constraints (naturally bounded) and the /// client-supplied DiscoverHierarchyRequest.TagNameGlob (unbounded — a /// client can iterate through generated names and create millions of distinct /// globs over the process lifetime). Capping the cache bounds memory while /// keeping the hot working set hit-cached. /// internal const int RegexCacheCapacity = 256; /// /// Bounded compiled-regex cache keyed by glob pattern. IsMatch is called /// once per object per DiscoverHierarchy/WatchDeployEvents /// evaluation, so the same handful of glob patterns are translated /// repeatedly; caching avoids rebuilding and recompiling the regex on every /// call. Beyond entries the oldest insertion /// is evicted so a client cannot grow the cache without bound by submitting /// unique patterns. Eviction is approximate (FIFO over insertion order, not /// true LRU) because we only need the bound, not exact recency tracking. /// private static readonly ConcurrentDictionary RegexCache = new(StringComparer.Ordinal); /// /// Insertion-order queue used to evict the oldest cache entry when the cache /// exceeds . A separate queue keeps the /// reads lock-free; the lock below only guards the /// eviction path. /// private static readonly ConcurrentQueue InsertionOrder = new(); private static readonly object EvictionLock = new(); /// /// Current cache size, exposed for tests asserting the cap is honoured. /// internal static int CurrentCacheSize => RegexCache.Count; /// Determines whether a value matches a glob pattern (with * and ? wildcards). /// The value to test against the glob pattern. /// The glob pattern with * and ? wildcards. public static bool IsMatch(string value, string glob) { if (string.IsNullOrWhiteSpace(glob)) { return true; } return GetOrCreateRegex(glob).IsMatch(value ?? string.Empty); } private static Regex GetOrCreateRegex(string glob) { if (RegexCache.TryGetValue(glob, out Regex? existing)) { return existing; } Regex compiled = new( BuildRegex(glob), RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.Compiled, TimeSpan.FromMilliseconds(100)); // GetOrAdd atomically returns whichever instance is in the cache after the // call — either the locally-compiled regex (we won the race) or the regex // another thread inserted (we lost). It also avoids the TryAdd-then-indexer // pattern where the key could be evicted between the failed TryAdd and the // indexer read, producing a KeyNotFoundException under contention near the // cap (Server-024). Regex result = RegexCache.GetOrAdd(glob, compiled); if (ReferenceEquals(result, compiled)) { // We were the inserter — track for FIFO eviction and bound the cache. InsertionOrder.Enqueue(glob); EvictIfOverCapacity(); } return result; } private static void EvictIfOverCapacity() { if (RegexCache.Count <= RegexCacheCapacity) { return; } // Serialize eviction so two threads do not race past the cap together. lock (EvictionLock) { while (RegexCache.Count > RegexCacheCapacity && InsertionOrder.TryDequeue(out string? oldest)) { RegexCache.TryRemove(oldest, out _); } } } private static string BuildRegex(string glob) { StringBuilder builder = new("^", glob.Length + 2); foreach (char character in glob) { switch (character) { case '*': builder.Append(".*"); break; case '?': builder.Append('.'); break; default: builder.Append(Regex.Escape(character.ToString())); break; } } builder.Append('$'); return builder.ToString(); } }