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();
}
}