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