using System.Diagnostics; using System.Text.Json; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StackExchange.Redis; namespace StellaOps.Provcache.Valkey; /// /// Valkey/Redis implementation of with read-through caching. /// public sealed class ValkeyProvcacheStore : IProvcacheStore, IAsyncDisposable { private readonly IConnectionMultiplexer _connectionMultiplexer; private readonly ProvcacheOptions _options; private readonly ILogger _logger; private readonly TimeProvider _timeProvider; private readonly JsonSerializerOptions _jsonOptions; private readonly SemaphoreSlim _connectionLock = new(1, 1); private IDatabase? _database; private const int DefaultScanPageSize = 200; private const int DefaultDeleteBatchSize = 500; /// public string ProviderName => "valkey"; public ValkeyProvcacheStore( IConnectionMultiplexer connectionMultiplexer, IOptions options, ILogger logger, TimeProvider? timeProvider = null) { _connectionMultiplexer = connectionMultiplexer ?? throw new ArgumentNullException(nameof(connectionMultiplexer)); _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _timeProvider = timeProvider ?? TimeProvider.System; _jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = false }; } /// public async ValueTask GetAsync(string veriKey, CancellationToken cancellationToken = default) { var sw = Stopwatch.StartNew(); cancellationToken.ThrowIfCancellationRequested(); try { var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false); var redisKey = BuildKey(veriKey); var value = await db.StringGetAsync(redisKey).ConfigureAwait(false); sw.Stop(); if (value.IsNullOrEmpty) { _logger.LogDebug("Cache miss for VeriKey {VeriKey} in {ElapsedMs}ms", veriKey, sw.Elapsed.TotalMilliseconds); return ProvcacheLookupResult.Miss(sw.Elapsed.TotalMilliseconds); } var entry = JsonSerializer.Deserialize((string)value!, _jsonOptions); if (entry is null) { _logger.LogWarning("Failed to deserialize cache entry for VeriKey {VeriKey}", veriKey); return ProvcacheLookupResult.Miss(sw.Elapsed.TotalMilliseconds); } // Optionally refresh TTL on read (sliding expiration) if (_options.SlidingExpiration) { var ttl = entry.ExpiresAt - _timeProvider.GetUtcNow(); if (ttl > TimeSpan.Zero) { await db.KeyExpireAsync(redisKey, ttl).ConfigureAwait(false); } } _logger.LogDebug("Cache hit for VeriKey {VeriKey} in {ElapsedMs}ms", veriKey, sw.Elapsed.TotalMilliseconds); return ProvcacheLookupResult.Hit(entry, ProviderName, sw.Elapsed.TotalMilliseconds); } catch (Exception ex) { sw.Stop(); _logger.LogError(ex, "Error getting cache entry for VeriKey {VeriKey}", veriKey); return ProvcacheLookupResult.Miss(sw.Elapsed.TotalMilliseconds); } } /// public async ValueTask GetManyAsync( IEnumerable veriKeys, CancellationToken cancellationToken = default) { var sw = Stopwatch.StartNew(); cancellationToken.ThrowIfCancellationRequested(); var keyList = veriKeys.ToList(); if (keyList.Count == 0) { return new ProvcacheBatchLookupResult { Hits = new Dictionary(), Misses = [], ElapsedMs = 0 }; } try { var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false); var redisKeys = keyList.Select(k => (RedisKey)BuildKey(k)).ToArray(); var values = await db.StringGetAsync(redisKeys).ConfigureAwait(false); sw.Stop(); var hits = new Dictionary(); var misses = new List(); for (int i = 0; i < keyList.Count; i++) { var veriKey = keyList[i]; var value = values[i]; if (value.IsNullOrEmpty) { misses.Add(veriKey); continue; } var entry = JsonSerializer.Deserialize((string)value!, _jsonOptions); if (entry is not null) { hits[veriKey] = entry; } else { misses.Add(veriKey); } } _logger.LogDebug( "Batch lookup: {Hits} hits, {Misses} misses in {ElapsedMs}ms", hits.Count, misses.Count, sw.Elapsed.TotalMilliseconds); return new ProvcacheBatchLookupResult { Hits = hits, Misses = misses, ElapsedMs = sw.Elapsed.TotalMilliseconds }; } catch (Exception ex) { sw.Stop(); _logger.LogError(ex, "Error in batch cache lookup"); return new ProvcacheBatchLookupResult { Hits = new Dictionary(), Misses = keyList, ElapsedMs = sw.Elapsed.TotalMilliseconds }; } } /// public async ValueTask SetAsync(ProvcacheEntry entry, CancellationToken cancellationToken = default) { try { cancellationToken.ThrowIfCancellationRequested(); var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false); var redisKey = BuildKey(entry.VeriKey); var value = JsonSerializer.Serialize(entry, _jsonOptions); var ttl = entry.ExpiresAt - _timeProvider.GetUtcNow(); if (ttl <= TimeSpan.Zero) { _logger.LogDebug("Skipping expired entry for VeriKey {VeriKey}", entry.VeriKey); return; } // Cap TTL at MaxTtl if (ttl > _options.MaxTtl) { ttl = _options.MaxTtl; } await db.StringSetAsync(redisKey, value, ttl).ConfigureAwait(false); _logger.LogDebug("Stored cache entry for VeriKey {VeriKey} with TTL {Ttl}", entry.VeriKey, ttl); } catch (Exception ex) { _logger.LogError(ex, "Error storing cache entry for VeriKey {VeriKey}", entry.VeriKey); throw; } } /// public async ValueTask SetManyAsync(IEnumerable entries, CancellationToken cancellationToken = default) { var entryList = entries.ToList(); cancellationToken.ThrowIfCancellationRequested(); if (entryList.Count == 0) return; try { var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false); var batch = db.CreateBatch(); var tasks = new List(); var now = _timeProvider.GetUtcNow(); foreach (var entry in entryList) { cancellationToken.ThrowIfCancellationRequested(); var redisKey = BuildKey(entry.VeriKey); var value = JsonSerializer.Serialize(entry, _jsonOptions); var ttl = entry.ExpiresAt - now; if (ttl <= TimeSpan.Zero) continue; if (ttl > _options.MaxTtl) ttl = _options.MaxTtl; tasks.Add(batch.StringSetAsync(redisKey, value, ttl)); } batch.Execute(); await Task.WhenAll(tasks).ConfigureAwait(false); _logger.LogDebug("Batch stored {Count} cache entries", entryList.Count); } catch (Exception ex) { _logger.LogError(ex, "Error in batch cache store"); throw; } } /// public async ValueTask InvalidateAsync(string veriKey, CancellationToken cancellationToken = default) { try { cancellationToken.ThrowIfCancellationRequested(); var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false); var redisKey = BuildKey(veriKey); var deleted = await db.KeyDeleteAsync(redisKey).ConfigureAwait(false); _logger.LogDebug("Invalidated cache entry for VeriKey {VeriKey}: {Deleted}", veriKey, deleted); return deleted; } catch (Exception ex) { _logger.LogError(ex, "Error invalidating cache entry for VeriKey {VeriKey}", veriKey); return false; } } /// public async ValueTask InvalidateByPatternAsync(string pattern, CancellationToken cancellationToken = default) { try { cancellationToken.ThrowIfCancellationRequested(); var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false); var endpoints = _connectionMultiplexer.GetEndPoints(); if (endpoints.Length == 0) { return 0; } var fullPattern = $"{_options.ValkeyKeyPrefix}{pattern}"; long deleted = 0; foreach (var endpoint in endpoints) { cancellationToken.ThrowIfCancellationRequested(); var server = _connectionMultiplexer.GetServer(endpoint); if (server is null || !server.IsConnected) { continue; } var batchKeys = new List(DefaultDeleteBatchSize); foreach (var key in server.Keys(pattern: fullPattern, pageSize: DefaultScanPageSize)) { cancellationToken.ThrowIfCancellationRequested(); batchKeys.Add(key); if (batchKeys.Count >= DefaultDeleteBatchSize) { deleted += await db.KeyDeleteAsync(batchKeys.ToArray()).ConfigureAwait(false); batchKeys.Clear(); } } if (batchKeys.Count > 0) { deleted += await db.KeyDeleteAsync(batchKeys.ToArray()).ConfigureAwait(false); } } _logger.LogInformation("Invalidated {Count} cache entries matching pattern {Pattern}", deleted, pattern); return deleted; } catch (Exception ex) { _logger.LogError(ex, "Error invalidating cache entries by pattern {Pattern}", pattern); return 0; } } /// public async ValueTask GetOrSetAsync( string veriKey, Func> factory, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); var result = await GetAsync(veriKey, cancellationToken).ConfigureAwait(false); if (result.IsHit && result.Entry is not null) { return result.Entry; } var entry = await factory(cancellationToken).ConfigureAwait(false); await SetAsync(entry, cancellationToken).ConfigureAwait(false); return entry; } private string BuildKey(string veriKey) => $"{_options.ValkeyKeyPrefix}{veriKey}"; private async Task GetDatabaseAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); if (_database is not null) return _database; await _connectionLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { _database ??= _connectionMultiplexer.GetDatabase(); return _database; } finally { _connectionLock.Release(); } } public async ValueTask DisposeAsync() { _connectionLock.Dispose(); // Note: Don't dispose the connection multiplexer if it's shared (injected via DI) // The DI container will handle its lifetime await Task.CompletedTask; } }