using System.Collections.Concurrent; using System.Security.Cryptography; using System.Text; namespace NpgsqlRest; public interface IRoutineCache { bool Get(RoutineEndpoint endpoint, string key, out object? result); /// /// Stores or updates a cached value. /// (when non-null) takes precedence over /// and is used by "Then" overrides to apply a per-request TTL. /// void AddOrUpdate(RoutineEndpoint endpoint, string key, object? value, TimeSpan? overrideExpiration = null); bool Remove(string key); /// /// Atomic get-or-compute. On a cache hit the cached value is returned and /// is NOT invoked. On a miss the factory runs the underlying work (e.g. opening the connection, /// executing SQL and serializing the response) and its result is stored before returning. /// /// Backends that override this provide stampede protection: a burst of concurrent calls with the /// same coalesce into a single factory invocation; the remaining callers /// await the in-flight result instead of each hitting the database. The default implementation /// below performs a plain probe-then-compute with no coalescing, so pre-existing custom /// implementations keep working unchanged (they simply gain no stampede protection). /// /// /// The endpoint whose response is being cached (carries the default TTL). /// The cache key; identical keys coalesce into one factory invocation. /// Runs the underlying work when the cache is cold. Should respect the token. /// When non-null, the per-request TTL to use instead of the endpoint default. /// Cancels this caller's wait; see backend notes for coalescing semantics. async ValueTask GetOrCreateAsync( RoutineEndpoint endpoint, string key, Func> factory, TimeSpan? overrideExpiration = null, CancellationToken cancellationToken = default) { if (Get(endpoint, key, out var existing)) { return existing; } var result = await factory(cancellationToken); AddOrUpdate(endpoint, key, result, overrideExpiration); return result; } } public static class CacheKeyHasher { /// /// Computes a SHA256 hash of the cache key and returns it as a hex string. /// The result is always 64 characters (256 bits / 4 bits per hex char). /// public static string ComputeHash(string key) { var hash = SHA256.HashData(Encoding.UTF8.GetBytes(key)); return Convert.ToHexString(hash); } /// /// Returns the effective cache key based on the hashing configuration. /// If UseHashedCacheKeys is true and the key length exceeds HashKeyThreshold, returns a hashed key. /// Otherwise, returns the original key. /// public static string GetEffectiveKey(string key, CacheOptions options) { if (options.UseHashedCacheKeys && key.Length > options.HashKeyThreshold) { return ComputeHash(key); } return key; } } public class RoutineCache : IRoutineCache { private class CacheEntry { public object? Value { get; set; } public DateTime? ExpirationTime { get; set; } public bool IsExpired => ExpirationTime.HasValue && DateTime.UtcNow > ExpirationTime.Value; } private static readonly ConcurrentDictionary _cache = new(StringComparer.Ordinal); // In-flight factory invocations, keyed by effective (post-hash) cache key. Used to coalesce a burst // of identical concurrent requests into a single factory run (stampede protection). The value is a // Lazy so that even if ConcurrentDictionary.GetOrAdd runs its value-factory more than once under // contention, only the stored Lazy's task is ever started — guaranteeing a single execution. private static readonly ConcurrentDictionary>> _inflight = new(StringComparer.Ordinal); private static Timer? _cleanupTimer; private static CacheOptions _options = new(); public static void Start(NpgsqlRestOptions options) { _options = options.CacheOptions; _cleanupTimer = new Timer( _ => CleanupExpiredEntriesInternal(), null, TimeSpan.FromSeconds(options.CacheOptions.MemoryCachePruneIntervalSeconds), TimeSpan.FromSeconds(options.CacheOptions.MemoryCachePruneIntervalSeconds)); } public static void Shutdown() { _cleanupTimer?.Dispose(); _cache.Clear(); } private static void CleanupExpiredEntriesInternal() { var expiredKeys = _cache .Where(kvp => kvp.Value.IsExpired) .Select(kvp => kvp.Key) .ToList(); foreach (var key in expiredKeys) { _cache.TryRemove(key, out _); } } public bool Get(RoutineEndpoint endpoint, string key, out object? result) { var effectiveKey = CacheKeyHasher.GetEffectiveKey(key, _options); if (_cache.TryGetValue(effectiveKey, out var entry)) { if (entry.IsExpired) { _cache.TryRemove(effectiveKey, out _); result = null; return false; } result = entry.Value; return true; } result = null; return false; } public void AddOrUpdate(RoutineEndpoint endpoint, string key, object? value, TimeSpan? overrideExpiration = null) { var effectiveKey = CacheKeyHasher.GetEffectiveKey(key, _options); var ttl = overrideExpiration ?? endpoint.CacheExpiresIn; var entry = new CacheEntry { Value = value, ExpirationTime = ttl.HasValue ? DateTime.UtcNow + ttl.Value : null }; _cache[effectiveKey] = entry; } public bool Remove(string key) { var effectiveKey = CacheKeyHasher.GetEffectiveKey(key, _options); return _cache.TryRemove(effectiveKey, out _); } public async ValueTask GetOrCreateAsync( RoutineEndpoint endpoint, string key, Func> factory, TimeSpan? overrideExpiration = null, CancellationToken cancellationToken = default) { var effectiveKey = CacheKeyHasher.GetEffectiveKey(key, _options); while (true) { cancellationToken.ThrowIfCancellationRequested(); if (Get(endpoint, key, out var cached)) { return cached; } // Coalesce: the first caller for this key creates the in-flight task and runs the factory // (with its own live connection/command and its own token); concurrent callers await it. var lazy = _inflight.GetOrAdd( effectiveKey, _ => new Lazy>(() => RunFactoryAsync(endpoint, key, effectiveKey, factory, overrideExpiration, cancellationToken))); try { return await lazy.Value.WaitAsync(cancellationToken); } catch (OperationCanceledException) { if (cancellationToken.IsCancellationRequested) { // This caller itself was cancelled — propagate. throw; } // The lead caller (whose token drove the shared run) was cancelled before this waiter. // Its in-flight entry has already been removed; loop and retry — the value may now be // cached, or this caller becomes the new lead and runs with its own live resources. } } } private async Task RunFactoryAsync( RoutineEndpoint endpoint, string key, string effectiveKey, Func> factory, TimeSpan? overrideExpiration, CancellationToken cancellationToken) { try { var result = await factory(cancellationToken).ConfigureAwait(false); AddOrUpdate(endpoint, key, result, overrideExpiration); return result; } finally { // Always free the slot so a later miss (or a failed/cancelled run) can start fresh. _inflight.TryRemove(effectiveKey, out _); } } }