up
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.0" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.8.37" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Valkey;
|
||||
|
||||
/// <summary>
|
||||
/// Valkey/Redis implementation of <see cref="IAtomicTokenStore{TPayload}"/>.
|
||||
/// Uses Lua scripts for atomic compare-and-delete operations.
|
||||
/// </summary>
|
||||
public sealed class ValkeyAtomicTokenStore<TPayload> : IAtomicTokenStore<TPayload>
|
||||
{
|
||||
private readonly ValkeyConnectionFactory _connectionFactory;
|
||||
private readonly string _name;
|
||||
private readonly ILogger<ValkeyAtomicTokenStore<TPayload>>? _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
// Lua script for atomic consume: GET, compare, DELETE if matches
|
||||
private const string ConsumeScript = @"
|
||||
local value = redis.call('GET', KEYS[1])
|
||||
if not value then
|
||||
return {0, nil}
|
||||
end
|
||||
local data = cjson.decode(value)
|
||||
if data.token ~= ARGV[1] then
|
||||
return {2, value}
|
||||
end
|
||||
redis.call('DEL', KEYS[1])
|
||||
return {1, value}
|
||||
";
|
||||
|
||||
public ValkeyAtomicTokenStore(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
string name,
|
||||
ILogger<ValkeyAtomicTokenStore<TPayload>>? logger = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
_logger = logger;
|
||||
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<TokenIssueResult> IssueAsync(
|
||||
string key,
|
||||
TPayload payload,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var redisKey = BuildKey(key);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiresAt = now.Add(ttl);
|
||||
|
||||
// Generate secure random token
|
||||
var tokenBytes = new byte[32];
|
||||
RandomNumberGenerator.Fill(tokenBytes);
|
||||
var token = Convert.ToBase64String(tokenBytes);
|
||||
|
||||
var entry = new TokenData<TPayload>
|
||||
{
|
||||
Token = token,
|
||||
Payload = payload,
|
||||
IssuedAt = now,
|
||||
ExpiresAt = expiresAt
|
||||
};
|
||||
|
||||
var serialized = JsonSerializer.Serialize(entry, _jsonOptions);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await db.StringSetAsync(redisKey, serialized, ttl).ConfigureAwait(false);
|
||||
|
||||
return TokenIssueResult.Succeeded(token, expiresAt);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<TokenIssueResult> StoreAsync(
|
||||
string key,
|
||||
string token,
|
||||
TPayload payload,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(token);
|
||||
|
||||
var redisKey = BuildKey(key);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiresAt = now.Add(ttl);
|
||||
|
||||
var entry = new TokenData<TPayload>
|
||||
{
|
||||
Token = token,
|
||||
Payload = payload,
|
||||
IssuedAt = now,
|
||||
ExpiresAt = expiresAt
|
||||
};
|
||||
|
||||
var serialized = JsonSerializer.Serialize(entry, _jsonOptions);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await db.StringSetAsync(redisKey, serialized, ttl).ConfigureAwait(false);
|
||||
|
||||
return TokenIssueResult.Succeeded(token, expiresAt);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<TokenConsumeResult<TPayload>> TryConsumeAsync(
|
||||
string key,
|
||||
string expectedToken,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(expectedToken);
|
||||
|
||||
var redisKey = BuildKey(key);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
try
|
||||
{
|
||||
var result = await db.ScriptEvaluateAsync(
|
||||
ConsumeScript,
|
||||
new RedisKey[] { redisKey },
|
||||
new RedisValue[] { expectedToken }).ConfigureAwait(false);
|
||||
|
||||
var results = (RedisResult[])result!;
|
||||
var status = (int)results[0];
|
||||
|
||||
switch (status)
|
||||
{
|
||||
case 0: // Not found
|
||||
return TokenConsumeResult<TPayload>.NotFound();
|
||||
|
||||
case 1: // Success
|
||||
var data = JsonSerializer.Deserialize<TokenData<TPayload>>((string)results[1]!, _jsonOptions);
|
||||
if (data is null)
|
||||
{
|
||||
return TokenConsumeResult<TPayload>.NotFound();
|
||||
}
|
||||
|
||||
if (data.ExpiresAt < now)
|
||||
{
|
||||
return TokenConsumeResult<TPayload>.Expired(data.IssuedAt, data.ExpiresAt);
|
||||
}
|
||||
|
||||
return TokenConsumeResult<TPayload>.Success(data.Payload!, data.IssuedAt, data.ExpiresAt);
|
||||
|
||||
case 2: // Mismatch
|
||||
return TokenConsumeResult<TPayload>.Mismatch();
|
||||
|
||||
default:
|
||||
return TokenConsumeResult<TPayload>.NotFound();
|
||||
}
|
||||
}
|
||||
catch (RedisServerException ex) when (ex.Message.Contains("NOSCRIPT"))
|
||||
{
|
||||
// Fallback: non-atomic approach (less safe but works without Lua)
|
||||
return await TryConsumeNonAtomicAsync(db, redisKey, expectedToken, now).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask<TokenConsumeResult<TPayload>> TryConsumeNonAtomicAsync(
|
||||
IDatabase db,
|
||||
string redisKey,
|
||||
string expectedToken,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
var value = await db.StringGetAsync(redisKey).ConfigureAwait(false);
|
||||
if (value.IsNullOrEmpty)
|
||||
{
|
||||
return TokenConsumeResult<TPayload>.NotFound();
|
||||
}
|
||||
|
||||
var data = JsonSerializer.Deserialize<TokenData<TPayload>>((string)value!, _jsonOptions);
|
||||
if (data is null)
|
||||
{
|
||||
return TokenConsumeResult<TPayload>.NotFound();
|
||||
}
|
||||
|
||||
if (data.ExpiresAt < now)
|
||||
{
|
||||
await db.KeyDeleteAsync(redisKey).ConfigureAwait(false);
|
||||
return TokenConsumeResult<TPayload>.Expired(data.IssuedAt, data.ExpiresAt);
|
||||
}
|
||||
|
||||
if (!string.Equals(data.Token, expectedToken, StringComparison.Ordinal))
|
||||
{
|
||||
return TokenConsumeResult<TPayload>.Mismatch();
|
||||
}
|
||||
|
||||
// Try to delete - if someone else deleted it first, we lost the race
|
||||
if (await db.KeyDeleteAsync(redisKey).ConfigureAwait(false))
|
||||
{
|
||||
return TokenConsumeResult<TPayload>.Success(data.Payload!, data.IssuedAt, data.ExpiresAt);
|
||||
}
|
||||
|
||||
return TokenConsumeResult<TPayload>.NotFound();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> ExistsAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var redisKey = BuildKey(key);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await db.KeyExistsAsync(redisKey).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> RevokeAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var redisKey = BuildKey(key);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await db.KeyDeleteAsync(redisKey).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private string BuildKey(string key) => $"token:{_name}:{key}";
|
||||
|
||||
private sealed class TokenData<T>
|
||||
{
|
||||
public required string Token { get; init; }
|
||||
public T? Payload { get; init; }
|
||||
public DateTimeOffset IssuedAt { get; init; }
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating Valkey atomic token store instances.
|
||||
/// </summary>
|
||||
public sealed class ValkeyAtomicTokenStoreFactory : IAtomicTokenStoreFactory
|
||||
{
|
||||
private readonly ValkeyConnectionFactory _connectionFactory;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly JsonSerializerOptions? _jsonOptions;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public ValkeyAtomicTokenStoreFactory(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_loggerFactory = loggerFactory;
|
||||
_jsonOptions = jsonOptions;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IAtomicTokenStore<TPayload> Create<TPayload>(string name)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
return new ValkeyAtomicTokenStore<TPayload>(
|
||||
_connectionFactory,
|
||||
name,
|
||||
_loggerFactory?.CreateLogger<ValkeyAtomicTokenStore<TPayload>>(),
|
||||
_jsonOptions,
|
||||
_timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Valkey;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating Valkey distributed cache instances.
|
||||
/// </summary>
|
||||
public sealed class ValkeyCacheFactory : IDistributedCacheFactory
|
||||
{
|
||||
private readonly ValkeyConnectionFactory _connectionFactory;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
public ValkeyCacheFactory(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
JsonSerializerOptions? jsonOptions = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_loggerFactory = loggerFactory;
|
||||
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IDistributedCache<TKey, TValue> Create<TKey, TValue>(CacheOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
return new ValkeyCacheStore<TKey, TValue>(
|
||||
_connectionFactory,
|
||||
options,
|
||||
_loggerFactory?.CreateLogger<ValkeyCacheStore<TKey, TValue>>(),
|
||||
_jsonOptions);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IDistributedCache<TValue> Create<TValue>(CacheOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
return new ValkeyCacheStore<TValue>(
|
||||
_connectionFactory,
|
||||
options,
|
||||
_loggerFactory?.CreateLogger<ValkeyCacheStore<TValue>>(),
|
||||
_jsonOptions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Valkey;
|
||||
|
||||
/// <summary>
|
||||
/// Valkey/Redis implementation of <see cref="IDistributedCache{TKey, TValue}"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="TKey">The key type.</typeparam>
|
||||
/// <typeparam name="TValue">The value type.</typeparam>
|
||||
public sealed class ValkeyCacheStore<TKey, TValue> : IDistributedCache<TKey, TValue>
|
||||
{
|
||||
private readonly ValkeyConnectionFactory _connectionFactory;
|
||||
private readonly CacheOptions _cacheOptions;
|
||||
private readonly ILogger<ValkeyCacheStore<TKey, TValue>>? _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly Func<TKey, string> _keySerializer;
|
||||
|
||||
public ValkeyCacheStore(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
CacheOptions cacheOptions,
|
||||
ILogger<ValkeyCacheStore<TKey, TValue>>? logger = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
Func<TKey, string>? keySerializer = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_cacheOptions = cacheOptions ?? throw new ArgumentNullException(nameof(cacheOptions));
|
||||
_logger = logger;
|
||||
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
_keySerializer = keySerializer ?? (key => key?.ToString() ?? throw new ArgumentNullException(nameof(key)));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<CacheResult<TValue>> GetAsync(TKey key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(key);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var value = await db.StringGetAsync(redisKey).ConfigureAwait(false);
|
||||
|
||||
if (value.IsNullOrEmpty)
|
||||
{
|
||||
return CacheResult<TValue>.Miss();
|
||||
}
|
||||
|
||||
// Handle sliding expiration by refreshing TTL
|
||||
if (_cacheOptions.SlidingExpiration && _cacheOptions.DefaultTtl.HasValue)
|
||||
{
|
||||
await db.KeyExpireAsync(redisKey, _cacheOptions.DefaultTtl.Value).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = JsonSerializer.Deserialize<TValue>((string)value!, _jsonOptions);
|
||||
return result is not null ? CacheResult<TValue>.Found(result) : CacheResult<TValue>.Miss();
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "Failed to deserialize cached value for key {Key}", redisKey);
|
||||
return CacheResult<TValue>.Miss();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask SetAsync(
|
||||
TKey key,
|
||||
TValue value,
|
||||
CacheEntryOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(key);
|
||||
var serialized = JsonSerializer.Serialize(value, _jsonOptions);
|
||||
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
TimeSpan? expiry = null;
|
||||
|
||||
if (options?.TimeToLive.HasValue == true)
|
||||
{
|
||||
expiry = options.TimeToLive.Value;
|
||||
}
|
||||
else if (options?.AbsoluteExpiration.HasValue == true)
|
||||
{
|
||||
expiry = options.AbsoluteExpiration.Value - DateTimeOffset.UtcNow;
|
||||
if (expiry.Value < TimeSpan.Zero)
|
||||
{
|
||||
expiry = TimeSpan.Zero;
|
||||
}
|
||||
}
|
||||
else if (_cacheOptions.DefaultTtl.HasValue)
|
||||
{
|
||||
expiry = _cacheOptions.DefaultTtl.Value;
|
||||
}
|
||||
|
||||
await db.StringSetAsync(redisKey, serialized, expiry).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> InvalidateAsync(TKey key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(key);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await db.KeyDeleteAsync(redisKey).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> InvalidateByPatternAsync(string pattern, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var fullPattern = string.IsNullOrWhiteSpace(_cacheOptions.KeyPrefix)
|
||||
? pattern
|
||||
: $"{_cacheOptions.KeyPrefix}{pattern}";
|
||||
|
||||
var connection = await _connectionFactory.GetConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
var server = connection.GetServers().First();
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
long count = 0;
|
||||
await foreach (var key in server.KeysAsync(pattern: fullPattern))
|
||||
{
|
||||
if (await db.KeyDeleteAsync(key).ConfigureAwait(false))
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<TValue> GetOrSetAsync(
|
||||
TKey key,
|
||||
Func<CancellationToken, ValueTask<TValue>> factory,
|
||||
CacheEntryOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = await GetAsync(key, cancellationToken).ConfigureAwait(false);
|
||||
if (result.HasValue)
|
||||
{
|
||||
return result.Value;
|
||||
}
|
||||
|
||||
var value = await factory(cancellationToken).ConfigureAwait(false);
|
||||
await SetAsync(key, value, options, cancellationToken).ConfigureAwait(false);
|
||||
return value;
|
||||
}
|
||||
|
||||
private string BuildKey(TKey key)
|
||||
{
|
||||
var keyString = _keySerializer(key);
|
||||
return string.IsNullOrWhiteSpace(_cacheOptions.KeyPrefix)
|
||||
? keyString
|
||||
: $"{_cacheOptions.KeyPrefix}{keyString}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// String-keyed Valkey cache store.
|
||||
/// </summary>
|
||||
/// <typeparam name="TValue">The value type.</typeparam>
|
||||
public sealed class ValkeyCacheStore<TValue> : IDistributedCache<TValue>
|
||||
{
|
||||
private readonly ValkeyCacheStore<string, TValue> _inner;
|
||||
|
||||
public ValkeyCacheStore(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
CacheOptions cacheOptions,
|
||||
ILogger<ValkeyCacheStore<TValue>>? logger = null,
|
||||
JsonSerializerOptions? jsonOptions = null)
|
||||
{
|
||||
_inner = new ValkeyCacheStore<string, TValue>(
|
||||
connectionFactory,
|
||||
cacheOptions,
|
||||
null, // Use dedicated logger
|
||||
jsonOptions,
|
||||
key => key);
|
||||
}
|
||||
|
||||
public string ProviderName => _inner.ProviderName;
|
||||
|
||||
public ValueTask<CacheResult<TValue>> GetAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> _inner.GetAsync(key, cancellationToken);
|
||||
|
||||
public ValueTask SetAsync(string key, TValue value, CacheEntryOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> _inner.SetAsync(key, value, options, cancellationToken);
|
||||
|
||||
public ValueTask<bool> InvalidateAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> _inner.InvalidateAsync(key, cancellationToken);
|
||||
|
||||
public ValueTask<long> InvalidateByPatternAsync(string pattern, CancellationToken cancellationToken = default)
|
||||
=> _inner.InvalidateByPatternAsync(pattern, cancellationToken);
|
||||
|
||||
public ValueTask<TValue> GetOrSetAsync(string key, Func<CancellationToken, ValueTask<TValue>> factory, CacheEntryOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> _inner.GetOrSetAsync(key, factory, options, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Valkey;
|
||||
|
||||
/// <summary>
|
||||
/// Valkey/Redis implementation of <see cref="IEventStream{TEvent}"/>.
|
||||
/// Uses stream commands (XADD, XREAD, XINFO, XTRIM) without consumer groups.
|
||||
/// </summary>
|
||||
public sealed class ValkeyEventStream<TEvent> : IEventStream<TEvent>
|
||||
where TEvent : class
|
||||
{
|
||||
private readonly ValkeyConnectionFactory _connectionFactory;
|
||||
private readonly EventStreamOptions _options;
|
||||
private readonly ILogger<ValkeyEventStream<TEvent>>? _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private const string DataField = "data";
|
||||
private const string TenantIdField = "tenantId";
|
||||
private const string CorrelationIdField = "correlationId";
|
||||
|
||||
public ValkeyEventStream(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
EventStreamOptions options,
|
||||
ILogger<ValkeyEventStream<TEvent>>? logger = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger;
|
||||
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string StreamName => _options.StreamName;
|
||||
|
||||
private string RedisKey => $"stream:{_options.StreamName}";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<EventPublishResult> PublishAsync(
|
||||
TEvent @event,
|
||||
EventPublishOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(@event);
|
||||
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var entries = new List<NameValueEntry>
|
||||
{
|
||||
new(DataField, JsonSerializer.Serialize(@event, _jsonOptions))
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(options?.TenantId))
|
||||
{
|
||||
entries.Add(new(TenantIdField, options.TenantId));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(options?.CorrelationId))
|
||||
{
|
||||
entries.Add(new(CorrelationIdField, options.CorrelationId));
|
||||
}
|
||||
|
||||
// Add custom headers
|
||||
if (options?.Headers is not null)
|
||||
{
|
||||
foreach (var header in options.Headers)
|
||||
{
|
||||
entries.Add(new($"h:{header.Key}", header.Value));
|
||||
}
|
||||
}
|
||||
|
||||
var entryId = await db.StreamAddAsync(
|
||||
RedisKey,
|
||||
entries.ToArray(),
|
||||
maxLength: _options.MaxLength.HasValue ? (int)_options.MaxLength.Value : null,
|
||||
useApproximateMaxLength: _options.ApproximateTrimming).ConfigureAwait(false);
|
||||
|
||||
return EventPublishResult.Succeeded(entryId!);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<IReadOnlyList<EventPublishResult>> PublishBatchAsync(
|
||||
IEnumerable<TEvent> events,
|
||||
EventPublishOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(events);
|
||||
|
||||
var results = new List<EventPublishResult>();
|
||||
|
||||
foreach (var @event in events)
|
||||
{
|
||||
var result = await PublishAsync(@event, options, cancellationToken).ConfigureAwait(false);
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<StreamEvent<TEvent>> SubscribeAsync(
|
||||
StreamPosition position,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Convert position to Redis format
|
||||
var lastId = position.Value == "0" ? "0-0" :
|
||||
position.Value == "$" ? "$" :
|
||||
position.Value;
|
||||
|
||||
// If starting from end, get current last entry ID
|
||||
if (lastId == "$")
|
||||
{
|
||||
var info = await GetInfoAsync(cancellationToken).ConfigureAwait(false);
|
||||
lastId = info.LastEntryId ?? "0-0";
|
||||
}
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
var entries = await db.StreamReadAsync(
|
||||
RedisKey,
|
||||
lastId,
|
||||
count: 100).ConfigureAwait(false);
|
||||
|
||||
if (entries.Length > 0)
|
||||
{
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
var streamEvent = ParseEntry(entry);
|
||||
if (streamEvent is not null)
|
||||
{
|
||||
yield return streamEvent;
|
||||
}
|
||||
lastId = entry.Id!;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No new entries, wait before polling again
|
||||
await Task.Delay(_options.PollInterval, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<StreamInfo> GetInfoAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
var info = await db.StreamInfoAsync(RedisKey).ConfigureAwait(false);
|
||||
|
||||
return new StreamInfo(
|
||||
info.Length,
|
||||
info.FirstEntry.Id,
|
||||
info.LastEntry.Id,
|
||||
ParseTimestamp(info.FirstEntry.Id),
|
||||
ParseTimestamp(info.LastEntry.Id));
|
||||
}
|
||||
catch (RedisServerException ex) when (ex.Message.Contains("no such key"))
|
||||
{
|
||||
return new StreamInfo(0, null, null, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> TrimAsync(
|
||||
long maxLength,
|
||||
bool approximate = true,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return await db.StreamTrimAsync(RedisKey, (int)maxLength, approximate).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private StreamEvent<TEvent>? ParseEntry(StreamEntry entry)
|
||||
{
|
||||
var data = entry[DataField];
|
||||
if (data.IsNullOrEmpty)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var @event = JsonSerializer.Deserialize<TEvent>((string)data!, _jsonOptions);
|
||||
if (@event is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var tenantId = entry[TenantIdField];
|
||||
var correlationId = entry[CorrelationIdField];
|
||||
|
||||
return new StreamEvent<TEvent>(
|
||||
entry.Id!,
|
||||
@event,
|
||||
ParseTimestamp(entry.Id) ?? _timeProvider.GetUtcNow(),
|
||||
tenantId.IsNullOrEmpty ? null : (string)tenantId!,
|
||||
correlationId.IsNullOrEmpty ? null : (string)correlationId!);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
_logger?.LogWarning("Failed to deserialize stream event {EntryId}", entry.Id);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ParseTimestamp(string? entryId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(entryId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Redis stream IDs are formatted as "timestamp-sequence"
|
||||
var dashIndex = entryId.IndexOf('-');
|
||||
if (dashIndex <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (long.TryParse(entryId.AsSpan(0, dashIndex), out var timestamp))
|
||||
{
|
||||
return DateTimeOffset.FromUnixTimeMilliseconds(timestamp);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating Valkey event stream instances.
|
||||
/// </summary>
|
||||
public sealed class ValkeyEventStreamFactory : IEventStreamFactory
|
||||
{
|
||||
private readonly ValkeyConnectionFactory _connectionFactory;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly JsonSerializerOptions? _jsonOptions;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public ValkeyEventStreamFactory(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_loggerFactory = loggerFactory;
|
||||
_jsonOptions = jsonOptions;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEventStream<TEvent> Create<TEvent>(EventStreamOptions options) where TEvent : class
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
return new ValkeyEventStream<TEvent>(
|
||||
_connectionFactory,
|
||||
options,
|
||||
_loggerFactory?.CreateLogger<ValkeyEventStream<TEvent>>(),
|
||||
_jsonOptions,
|
||||
_timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Valkey;
|
||||
|
||||
/// <summary>
|
||||
/// Valkey/Redis implementation of <see cref="IIdempotencyStore"/>.
|
||||
/// Uses SET NX EX for atomic key claiming.
|
||||
/// </summary>
|
||||
public sealed class ValkeyIdempotencyStore : IIdempotencyStore
|
||||
{
|
||||
private readonly ValkeyConnectionFactory _connectionFactory;
|
||||
private readonly string _name;
|
||||
private readonly ILogger<ValkeyIdempotencyStore>? _logger;
|
||||
|
||||
public ValkeyIdempotencyStore(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
string name,
|
||||
ILogger<ValkeyIdempotencyStore>? logger = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<IdempotencyResult> TryClaimAsync(
|
||||
string key,
|
||||
string value,
|
||||
TimeSpan window,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
|
||||
var redisKey = BuildKey(key);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// SET key value NX EX ttl - only sets if key doesn't exist
|
||||
var wasSet = await db.StringSetAsync(redisKey, value, window, When.NotExists).ConfigureAwait(false);
|
||||
|
||||
if (wasSet)
|
||||
{
|
||||
return IdempotencyResult.Claimed();
|
||||
}
|
||||
|
||||
// Key already exists, get the existing value
|
||||
var existing = await db.StringGetAsync(redisKey).ConfigureAwait(false);
|
||||
return IdempotencyResult.Duplicate(existing.HasValue ? (string)existing! : string.Empty);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> ExistsAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var redisKey = BuildKey(key);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return await db.KeyExistsAsync(redisKey).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<string?> GetAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var redisKey = BuildKey(key);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var value = await db.StringGetAsync(redisKey).ConfigureAwait(false);
|
||||
return value.HasValue ? (string)value! : null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> ReleaseAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var redisKey = BuildKey(key);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return await db.KeyDeleteAsync(redisKey).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> ExtendAsync(
|
||||
string key,
|
||||
TimeSpan extension,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var redisKey = BuildKey(key);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Get current TTL and extend it
|
||||
var currentTtl = await db.KeyTimeToLiveAsync(redisKey).ConfigureAwait(false);
|
||||
if (!currentTtl.HasValue)
|
||||
{
|
||||
return false; // Key doesn't exist or has no TTL
|
||||
}
|
||||
|
||||
var newTtl = currentTtl.Value + extension;
|
||||
return await db.KeyExpireAsync(redisKey, newTtl).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private string BuildKey(string key) => $"idempotency:{_name}:{key}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating Valkey idempotency store instances.
|
||||
/// </summary>
|
||||
public sealed class ValkeyIdempotencyStoreFactory : IIdempotencyStoreFactory
|
||||
{
|
||||
private readonly ValkeyConnectionFactory _connectionFactory;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
|
||||
public ValkeyIdempotencyStoreFactory(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_loggerFactory = loggerFactory;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IIdempotencyStore Create(string name)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
return new ValkeyIdempotencyStore(
|
||||
_connectionFactory,
|
||||
name,
|
||||
_loggerFactory?.CreateLogger<ValkeyIdempotencyStore>());
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StackExchange.Redis;
|
||||
using RedisStreamPosition = StackExchange.Redis.StreamPosition;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Valkey;
|
||||
|
||||
@@ -444,7 +445,7 @@ public sealed class ValkeyMessageQueue<TMessage> : IMessageQueue<TMessage>, IAsy
|
||||
await database.StreamCreateConsumerGroupAsync(
|
||||
_queueOptions.QueueName,
|
||||
_queueOptions.ConsumerGroup,
|
||||
StreamPosition.Beginning,
|
||||
RedisStreamPosition.Beginning,
|
||||
createStream: true)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Valkey;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating Valkey message queue instances.
|
||||
/// </summary>
|
||||
public sealed class ValkeyMessageQueueFactory : IMessageQueueFactory
|
||||
{
|
||||
private readonly ValkeyConnectionFactory _connectionFactory;
|
||||
private readonly ValkeyTransportOptions _transportOptions;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
public ValkeyMessageQueueFactory(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
IOptions<ValkeyTransportOptions> transportOptions,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
TimeProvider? timeProvider = null,
|
||||
JsonSerializerOptions? jsonOptions = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_transportOptions = transportOptions?.Value ?? throw new ArgumentNullException(nameof(transportOptions));
|
||||
_loggerFactory = loggerFactory;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IMessageQueue<TMessage> Create<TMessage>(MessageQueueOptions options)
|
||||
where TMessage : class
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
return new ValkeyMessageQueue<TMessage>(
|
||||
_connectionFactory,
|
||||
options,
|
||||
_transportOptions,
|
||||
_loggerFactory?.CreateLogger<ValkeyMessageQueue<TMessage>>(),
|
||||
_timeProvider,
|
||||
_jsonOptions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Valkey;
|
||||
|
||||
/// <summary>
|
||||
/// Valkey/Redis implementation of <see cref="IRateLimiter"/>.
|
||||
/// Uses sliding window algorithm with INCR and EXPIRE commands.
|
||||
/// </summary>
|
||||
public sealed class ValkeyRateLimiter : IRateLimiter
|
||||
{
|
||||
private readonly ValkeyConnectionFactory _connectionFactory;
|
||||
private readonly string _name;
|
||||
private readonly ILogger<ValkeyRateLimiter>? _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public ValkeyRateLimiter(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
string name,
|
||||
ILogger<ValkeyRateLimiter>? logger = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<RateLimitResult> TryAcquireAsync(
|
||||
string key,
|
||||
RateLimitPolicy policy,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(policy);
|
||||
|
||||
var redisKey = BuildKey(key);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Use sliding window with timestamp-based keys
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var windowKey = $"{redisKey}:{now.ToUnixTimeSeconds() / (long)policy.Window.TotalSeconds}";
|
||||
|
||||
var transaction = db.CreateTransaction();
|
||||
var incrTask = transaction.StringIncrementAsync(windowKey);
|
||||
var expireTask = transaction.KeyExpireAsync(windowKey, policy.Window + TimeSpan.FromSeconds(1));
|
||||
|
||||
await transaction.ExecuteAsync().ConfigureAwait(false);
|
||||
|
||||
var currentCount = (int)await incrTask.ConfigureAwait(false);
|
||||
|
||||
if (currentCount > policy.MaxPermits)
|
||||
{
|
||||
// We incremented but exceeded, calculate retry after
|
||||
var retryAfter = policy.Window;
|
||||
return RateLimitResult.Denied(currentCount, retryAfter);
|
||||
}
|
||||
|
||||
var remaining = Math.Max(0, policy.MaxPermits - currentCount);
|
||||
return RateLimitResult.Allowed(currentCount, remaining);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<RateLimitStatus> GetStatusAsync(
|
||||
string key,
|
||||
RateLimitPolicy policy,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(policy);
|
||||
|
||||
var redisKey = BuildKey(key);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var windowKey = $"{redisKey}:{now.ToUnixTimeSeconds() / (long)policy.Window.TotalSeconds}";
|
||||
|
||||
var value = await db.StringGetAsync(windowKey).ConfigureAwait(false);
|
||||
var currentCount = value.HasValue ? (int)(long)value : 0;
|
||||
var remaining = Math.Max(0, policy.MaxPermits - currentCount);
|
||||
|
||||
return new RateLimitStatus
|
||||
{
|
||||
CurrentCount = currentCount,
|
||||
RemainingPermits = remaining,
|
||||
WindowRemaining = policy.Window,
|
||||
Exists = value.HasValue
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> ResetAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var redisKey = BuildKey(key);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Delete all keys matching the pattern
|
||||
var connection = await _connectionFactory.GetConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
var server = connection.GetServers().First();
|
||||
|
||||
var deleted = false;
|
||||
await foreach (var matchingKey in server.KeysAsync(pattern: $"{redisKey}:*"))
|
||||
{
|
||||
if (await db.KeyDeleteAsync(matchingKey).ConfigureAwait(false))
|
||||
{
|
||||
deleted = true;
|
||||
}
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
private string BuildKey(string key) => $"ratelimit:{_name}:{key}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating Valkey rate limiter instances.
|
||||
/// </summary>
|
||||
public sealed class ValkeyRateLimiterFactory : IRateLimiterFactory
|
||||
{
|
||||
private readonly ValkeyConnectionFactory _connectionFactory;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public ValkeyRateLimiterFactory(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_loggerFactory = loggerFactory;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IRateLimiter Create(string name)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
return new ValkeyRateLimiter(
|
||||
_connectionFactory,
|
||||
name,
|
||||
_loggerFactory?.CreateLogger<ValkeyRateLimiter>(),
|
||||
_timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Valkey;
|
||||
|
||||
/// <summary>
|
||||
/// Valkey/Redis implementation of <see cref="ISetStore{TKey, TElement}"/>.
|
||||
/// Uses set commands (SADD, SMEMBERS, SISMEMBER, SREM, etc.).
|
||||
/// </summary>
|
||||
public sealed class ValkeySetStore<TKey, TElement> : ISetStore<TKey, TElement>
|
||||
where TKey : notnull
|
||||
{
|
||||
private readonly ValkeyConnectionFactory _connectionFactory;
|
||||
private readonly string _name;
|
||||
private readonly ILogger<ValkeySetStore<TKey, TElement>>? _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly Func<TKey, string> _keySerializer;
|
||||
|
||||
public ValkeySetStore(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
string name,
|
||||
ILogger<ValkeySetStore<TKey, TElement>>? logger = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
Func<TKey, string>? keySerializer = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
_logger = logger;
|
||||
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
_keySerializer = keySerializer ?? (key => key?.ToString() ?? throw new ArgumentNullException(nameof(key)));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> AddAsync(
|
||||
TKey setKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(setKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var serialized = Serialize(element);
|
||||
return await db.SetAddAsync(redisKey, serialized).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> AddRangeAsync(
|
||||
TKey setKey,
|
||||
IEnumerable<TElement> elements,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(elements);
|
||||
|
||||
var redisKey = BuildKey(setKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var values = elements.Select(e => (RedisValue)Serialize(e)).ToArray();
|
||||
if (values.Length == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return await db.SetAddAsync(redisKey, values).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<IReadOnlySet<TElement>> GetMembersAsync(
|
||||
TKey setKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(setKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var members = await db.SetMembersAsync(redisKey).ConfigureAwait(false);
|
||||
var result = new HashSet<TElement>();
|
||||
|
||||
foreach (var member in members)
|
||||
{
|
||||
if (!member.IsNullOrEmpty)
|
||||
{
|
||||
var element = Deserialize((string)member!);
|
||||
if (element is not null)
|
||||
{
|
||||
result.Add(element);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> ContainsAsync(
|
||||
TKey setKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(setKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var serialized = Serialize(element);
|
||||
return await db.SetContainsAsync(redisKey, serialized).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> RemoveAsync(
|
||||
TKey setKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(setKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var serialized = Serialize(element);
|
||||
return await db.SetRemoveAsync(redisKey, serialized).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> RemoveRangeAsync(
|
||||
TKey setKey,
|
||||
IEnumerable<TElement> elements,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(elements);
|
||||
|
||||
var redisKey = BuildKey(setKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var values = elements.Select(e => (RedisValue)Serialize(e)).ToArray();
|
||||
if (values.Length == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return await db.SetRemoveAsync(redisKey, values).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> DeleteAsync(
|
||||
TKey setKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(setKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return await db.KeyDeleteAsync(redisKey).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> CountAsync(
|
||||
TKey setKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(setKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return await db.SetLengthAsync(redisKey).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask SetExpirationAsync(
|
||||
TKey setKey,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(setKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await db.KeyExpireAsync(redisKey, ttl).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private string BuildKey(TKey setKey)
|
||||
{
|
||||
var keyString = _keySerializer(setKey);
|
||||
return $"set:{_name}:{keyString}";
|
||||
}
|
||||
|
||||
private string Serialize(TElement element)
|
||||
{
|
||||
// For primitive types, use ToString directly
|
||||
if (element is string s)
|
||||
{
|
||||
return s;
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(element, _jsonOptions);
|
||||
}
|
||||
|
||||
private TElement? Deserialize(string value)
|
||||
{
|
||||
// For string types, return directly
|
||||
if (typeof(TElement) == typeof(string))
|
||||
{
|
||||
return (TElement)(object)value;
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<TElement>(value, _jsonOptions);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating Valkey set store instances.
|
||||
/// </summary>
|
||||
public sealed class ValkeySetStoreFactory : ISetStoreFactory
|
||||
{
|
||||
private readonly ValkeyConnectionFactory _connectionFactory;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly JsonSerializerOptions? _jsonOptions;
|
||||
|
||||
public ValkeySetStoreFactory(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
JsonSerializerOptions? jsonOptions = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_loggerFactory = loggerFactory;
|
||||
_jsonOptions = jsonOptions;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public ISetStore<TKey, TElement> Create<TKey, TElement>(string name)
|
||||
where TKey : notnull
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
return new ValkeySetStore<TKey, TElement>(
|
||||
_connectionFactory,
|
||||
name,
|
||||
_loggerFactory?.CreateLogger<ValkeySetStore<TKey, TElement>>(),
|
||||
_jsonOptions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Valkey;
|
||||
|
||||
/// <summary>
|
||||
/// Valkey/Redis implementation of <see cref="ISortedIndex{TKey, TElement}"/>.
|
||||
/// Uses sorted set commands (ZADD, ZRANGE, ZRANGEBYSCORE, etc.).
|
||||
/// </summary>
|
||||
public sealed class ValkeySortedIndex<TKey, TElement> : ISortedIndex<TKey, TElement>
|
||||
where TKey : notnull
|
||||
where TElement : notnull
|
||||
{
|
||||
private readonly ValkeyConnectionFactory _connectionFactory;
|
||||
private readonly string _name;
|
||||
private readonly ILogger<ValkeySortedIndex<TKey, TElement>>? _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly Func<TKey, string> _keySerializer;
|
||||
|
||||
public ValkeySortedIndex(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
string name,
|
||||
ILogger<ValkeySortedIndex<TKey, TElement>>? logger = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
Func<TKey, string>? keySerializer = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
_logger = logger;
|
||||
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
_keySerializer = keySerializer ?? (key => key?.ToString() ?? throw new ArgumentNullException(nameof(key)));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> AddAsync(
|
||||
TKey indexKey,
|
||||
TElement element,
|
||||
double score,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(indexKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var serialized = JsonSerializer.Serialize(element, _jsonOptions);
|
||||
return await db.SortedSetAddAsync(redisKey, serialized, score).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> AddRangeAsync(
|
||||
TKey indexKey,
|
||||
IEnumerable<ScoredElement<TElement>> elements,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(elements);
|
||||
|
||||
var redisKey = BuildKey(indexKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var entries = elements
|
||||
.Select(e => new SortedSetEntry(JsonSerializer.Serialize(e.Element, _jsonOptions), e.Score))
|
||||
.ToArray();
|
||||
|
||||
if (entries.Length == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return await db.SortedSetAddAsync(redisKey, entries).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<IReadOnlyList<ScoredElement<TElement>>> GetByRankAsync(
|
||||
TKey indexKey,
|
||||
long start,
|
||||
long stop,
|
||||
SortOrder order = SortOrder.Ascending,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(indexKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var redisOrder = order == SortOrder.Ascending ? Order.Ascending : Order.Descending;
|
||||
var entries = await db.SortedSetRangeByRankWithScoresAsync(redisKey, start, stop, redisOrder).ConfigureAwait(false);
|
||||
|
||||
return entries
|
||||
.Select(e => new ScoredElement<TElement>(
|
||||
JsonSerializer.Deserialize<TElement>((string)e.Element!, _jsonOptions)!,
|
||||
e.Score))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<IReadOnlyList<ScoredElement<TElement>>> GetByScoreAsync(
|
||||
TKey indexKey,
|
||||
double minScore,
|
||||
double maxScore,
|
||||
SortOrder order = SortOrder.Ascending,
|
||||
int? limit = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(indexKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var redisOrder = order == SortOrder.Ascending ? Order.Ascending : Order.Descending;
|
||||
var take = limit ?? -1;
|
||||
|
||||
var entries = await db.SortedSetRangeByScoreWithScoresAsync(
|
||||
redisKey,
|
||||
minScore,
|
||||
maxScore,
|
||||
order: redisOrder,
|
||||
take: take).ConfigureAwait(false);
|
||||
|
||||
return entries
|
||||
.Select(e => new ScoredElement<TElement>(
|
||||
JsonSerializer.Deserialize<TElement>((string)e.Element!, _jsonOptions)!,
|
||||
e.Score))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<double?> GetScoreAsync(
|
||||
TKey indexKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(indexKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var serialized = JsonSerializer.Serialize(element, _jsonOptions);
|
||||
return await db.SortedSetScoreAsync(redisKey, serialized).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> RemoveAsync(
|
||||
TKey indexKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(indexKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var serialized = JsonSerializer.Serialize(element, _jsonOptions);
|
||||
return await db.SortedSetRemoveAsync(redisKey, serialized).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> RemoveRangeAsync(
|
||||
TKey indexKey,
|
||||
IEnumerable<TElement> elements,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(elements);
|
||||
|
||||
var redisKey = BuildKey(indexKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var values = elements
|
||||
.Select(e => (RedisValue)JsonSerializer.Serialize(e, _jsonOptions))
|
||||
.ToArray();
|
||||
|
||||
if (values.Length == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return await db.SortedSetRemoveAsync(redisKey, values).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> RemoveByScoreAsync(
|
||||
TKey indexKey,
|
||||
double minScore,
|
||||
double maxScore,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(indexKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return await db.SortedSetRemoveRangeByScoreAsync(redisKey, minScore, maxScore).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> CountAsync(
|
||||
TKey indexKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(indexKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return await db.SortedSetLengthAsync(redisKey).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> DeleteAsync(
|
||||
TKey indexKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(indexKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return await db.KeyDeleteAsync(redisKey).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask SetExpirationAsync(
|
||||
TKey indexKey,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(indexKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await db.KeyExpireAsync(redisKey, ttl).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private string BuildKey(TKey indexKey)
|
||||
{
|
||||
var keyString = _keySerializer(indexKey);
|
||||
return $"sortedindex:{_name}:{keyString}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating Valkey sorted index instances.
|
||||
/// </summary>
|
||||
public sealed class ValkeySortedIndexFactory : ISortedIndexFactory
|
||||
{
|
||||
private readonly ValkeyConnectionFactory _connectionFactory;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly JsonSerializerOptions? _jsonOptions;
|
||||
|
||||
public ValkeySortedIndexFactory(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
JsonSerializerOptions? jsonOptions = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_loggerFactory = loggerFactory;
|
||||
_jsonOptions = jsonOptions;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public ISortedIndex<TKey, TElement> Create<TKey, TElement>(string name)
|
||||
where TKey : notnull
|
||||
where TElement : notnull
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
return new ValkeySortedIndex<TKey, TElement>(
|
||||
_connectionFactory,
|
||||
name,
|
||||
_loggerFactory?.CreateLogger<ValkeySortedIndex<TKey, TElement>>(),
|
||||
_jsonOptions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StellaOps.Messaging.Plugins;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Valkey;
|
||||
|
||||
/// <summary>
|
||||
/// Valkey/Redis transport plugin for StellaOps.Messaging.
|
||||
/// </summary>
|
||||
public sealed class ValkeyTransportPlugin : IMessagingTransportPlugin
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string Name => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsAvailable(IServiceProvider services) => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Register(MessagingTransportRegistrationContext context)
|
||||
{
|
||||
// Register options
|
||||
context.Services.AddOptions<ValkeyTransportOptions>()
|
||||
.Bind(context.GetTransportConfiguration())
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
// Register connection factory
|
||||
context.Services.AddSingleton<ValkeyConnectionFactory>();
|
||||
|
||||
// Register message queue factory
|
||||
context.Services.AddSingleton<IMessageQueueFactory, ValkeyMessageQueueFactory>();
|
||||
|
||||
// Register cache factory
|
||||
context.Services.AddSingleton<IDistributedCacheFactory, ValkeyCacheFactory>();
|
||||
|
||||
// Register rate limiter factory
|
||||
context.Services.AddSingleton<IRateLimiterFactory, ValkeyRateLimiterFactory>();
|
||||
|
||||
// Register atomic token store factory
|
||||
context.Services.AddSingleton<IAtomicTokenStoreFactory, ValkeyAtomicTokenStoreFactory>();
|
||||
|
||||
// Register sorted index factory
|
||||
context.Services.AddSingleton<ISortedIndexFactory, ValkeySortedIndexFactory>();
|
||||
|
||||
// Register set store factory
|
||||
context.Services.AddSingleton<ISetStoreFactory, ValkeySetStoreFactory>();
|
||||
|
||||
// Register event stream factory
|
||||
context.Services.AddSingleton<IEventStreamFactory, ValkeyEventStreamFactory>();
|
||||
|
||||
// Register idempotency store factory
|
||||
context.Services.AddSingleton<IIdempotencyStoreFactory, ValkeyIdempotencyStoreFactory>();
|
||||
|
||||
context.LoggerFactory?.CreateLogger<ValkeyTransportPlugin>()
|
||||
.LogDebug("Registered Valkey transport plugin");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user