This commit is contained in:
StellaOps Bot
2025-12-13 02:22:15 +02:00
parent 564df71bfb
commit 999e26a48e
395 changed files with 25045 additions and 2224 deletions

View File

@@ -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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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");
}
}