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

@@ -0,0 +1,192 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using StellaOps.Messaging.Abstractions;
namespace StellaOps.Messaging.Transport.InMemory;
/// <summary>
/// In-memory implementation of <see cref="IAtomicTokenStore{TPayload}"/>.
/// Provides atomic token issuance and consumption.
/// </summary>
public sealed class InMemoryAtomicTokenStore<TPayload> : IAtomicTokenStore<TPayload>
{
private readonly ConcurrentDictionary<string, TokenEntry<TPayload>> _store;
private readonly string _name;
private readonly TimeProvider _timeProvider;
public InMemoryAtomicTokenStore(
InMemoryQueueRegistry registry,
string name,
TimeProvider? timeProvider = null)
{
ArgumentNullException.ThrowIfNull(registry);
_name = name ?? throw new ArgumentNullException(nameof(name));
_store = registry.GetOrCreateTokenStore<TPayload>(name);
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public string ProviderName => "inmemory";
/// <inheritdoc />
public ValueTask<TokenIssueResult> IssueAsync(
string key,
TPayload payload,
TimeSpan ttl,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(key);
var fullKey = 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 TokenEntry<TPayload>
{
Token = token,
Payload = payload,
IssuedAt = now,
ExpiresAt = expiresAt
};
// Try to add, or update if already exists
_store.AddOrUpdate(fullKey, entry, (_, _) => entry);
return ValueTask.FromResult(TokenIssueResult.Succeeded(token, expiresAt));
}
/// <inheritdoc />
public ValueTask<TokenIssueResult> StoreAsync(
string key,
string token,
TPayload payload,
TimeSpan ttl,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(key);
ArgumentNullException.ThrowIfNull(token);
var fullKey = BuildKey(key);
var now = _timeProvider.GetUtcNow();
var expiresAt = now.Add(ttl);
var entry = new TokenEntry<TPayload>
{
Token = token,
Payload = payload,
IssuedAt = now,
ExpiresAt = expiresAt
};
_store.AddOrUpdate(fullKey, entry, (_, _) => entry);
return ValueTask.FromResult(TokenIssueResult.Succeeded(token, expiresAt));
}
/// <inheritdoc />
public ValueTask<TokenConsumeResult<TPayload>> TryConsumeAsync(
string key,
string expectedToken,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(key);
ArgumentNullException.ThrowIfNull(expectedToken);
var fullKey = BuildKey(key);
var now = _timeProvider.GetUtcNow();
// Try to get and remove atomically
if (!_store.TryGetValue(fullKey, out var entry))
{
return ValueTask.FromResult(TokenConsumeResult<TPayload>.NotFound());
}
// Check expiration
if (entry.ExpiresAt < now)
{
_store.TryRemove(fullKey, out _);
return ValueTask.FromResult(TokenConsumeResult<TPayload>.Expired(entry.IssuedAt, entry.ExpiresAt));
}
// Check token match
if (!string.Equals(entry.Token, expectedToken, StringComparison.Ordinal))
{
return ValueTask.FromResult(TokenConsumeResult<TPayload>.Mismatch());
}
// Atomically remove if token still matches
if (_store.TryRemove(fullKey, out var removed) && string.Equals(removed.Token, expectedToken, StringComparison.Ordinal))
{
return ValueTask.FromResult(TokenConsumeResult<TPayload>.Success(
removed.Payload,
removed.IssuedAt,
removed.ExpiresAt));
}
return ValueTask.FromResult(TokenConsumeResult<TPayload>.NotFound());
}
/// <inheritdoc />
public ValueTask<bool> ExistsAsync(string key, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(key);
var fullKey = BuildKey(key);
var now = _timeProvider.GetUtcNow();
if (_store.TryGetValue(fullKey, out var entry))
{
if (entry.ExpiresAt < now)
{
_store.TryRemove(fullKey, out _);
return ValueTask.FromResult(false);
}
return ValueTask.FromResult(true);
}
return ValueTask.FromResult(false);
}
/// <inheritdoc />
public ValueTask<bool> RevokeAsync(string key, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(key);
var fullKey = BuildKey(key);
return ValueTask.FromResult(_store.TryRemove(fullKey, out _));
}
private string BuildKey(string key) => $"{_name}:{key}";
}
/// <summary>
/// Factory for creating in-memory atomic token store instances.
/// </summary>
public sealed class InMemoryAtomicTokenStoreFactory : IAtomicTokenStoreFactory
{
private readonly InMemoryQueueRegistry _registry;
private readonly TimeProvider _timeProvider;
public InMemoryAtomicTokenStoreFactory(
InMemoryQueueRegistry registry,
TimeProvider? timeProvider = null)
{
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public string ProviderName => "inmemory";
/// <inheritdoc />
public IAtomicTokenStore<TPayload> Create<TPayload>(string name)
{
ArgumentNullException.ThrowIfNull(name);
return new InMemoryAtomicTokenStore<TPayload>(_registry, name, _timeProvider);
}
}

View File

@@ -0,0 +1,37 @@
using StellaOps.Messaging.Abstractions;
namespace StellaOps.Messaging.Transport.InMemory;
/// <summary>
/// Factory for creating in-memory distributed cache instances.
/// </summary>
public sealed class InMemoryCacheFactory : IDistributedCacheFactory
{
private readonly InMemoryQueueRegistry _registry;
private readonly TimeProvider _timeProvider;
public InMemoryCacheFactory(
InMemoryQueueRegistry registry,
TimeProvider? timeProvider = null)
{
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public string ProviderName => "inmemory";
/// <inheritdoc />
public IDistributedCache<TKey, TValue> Create<TKey, TValue>(CacheOptions options)
{
ArgumentNullException.ThrowIfNull(options);
return new InMemoryCacheStore<TKey, TValue>(_registry, options, null, _timeProvider);
}
/// <inheritdoc />
public IDistributedCache<TValue> Create<TValue>(CacheOptions options)
{
ArgumentNullException.ThrowIfNull(options);
return new InMemoryCacheStore<TValue>(_registry, options, _timeProvider);
}
}

View File

@@ -0,0 +1,214 @@
using System.Collections.Concurrent;
using StellaOps.Messaging.Abstractions;
namespace StellaOps.Messaging.Transport.InMemory;
/// <summary>
/// In-memory 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 InMemoryCacheStore<TKey, TValue> : IDistributedCache<TKey, TValue>
{
private readonly InMemoryQueueRegistry _registry;
private readonly CacheOptions _options;
private readonly Func<TKey, string> _keySerializer;
private readonly TimeProvider _timeProvider;
public InMemoryCacheStore(
InMemoryQueueRegistry registry,
CacheOptions options,
Func<TKey, string>? keySerializer = null,
TimeProvider? timeProvider = null)
{
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
_options = options ?? throw new ArgumentNullException(nameof(options));
_keySerializer = keySerializer ?? (key => key?.ToString() ?? throw new ArgumentNullException(nameof(key)));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public string ProviderName => "inmemory";
private string CacheName => _options.KeyPrefix ?? "default";
private ConcurrentDictionary<string, object> Cache => _registry.GetOrCreateCache(CacheName);
/// <inheritdoc />
public ValueTask<CacheResult<TValue>> GetAsync(TKey key, CancellationToken cancellationToken = default)
{
var cacheKey = BuildKey(key);
if (Cache.TryGetValue(cacheKey, out var obj) && obj is CacheEntry<TValue> entry)
{
var now = _timeProvider.GetUtcNow();
// Check expiration
if (entry.ExpiresAt.HasValue && entry.ExpiresAt.Value < now)
{
Cache.TryRemove(cacheKey, out _);
return ValueTask.FromResult(CacheResult<TValue>.Miss());
}
// Handle sliding expiration
if (_options.SlidingExpiration && _options.DefaultTtl.HasValue)
{
entry.ExpiresAt = now.Add(_options.DefaultTtl.Value);
}
return ValueTask.FromResult(CacheResult<TValue>.Found(entry.Value));
}
return ValueTask.FromResult(CacheResult<TValue>.Miss());
}
/// <inheritdoc />
public ValueTask SetAsync(
TKey key,
TValue value,
CacheEntryOptions? options = null,
CancellationToken cancellationToken = default)
{
var cacheKey = BuildKey(key);
var now = _timeProvider.GetUtcNow();
DateTimeOffset? expiresAt = null;
if (options?.TimeToLive.HasValue == true)
{
expiresAt = now.Add(options.TimeToLive.Value);
}
else if (options?.AbsoluteExpiration.HasValue == true)
{
expiresAt = options.AbsoluteExpiration.Value;
}
else if (_options.DefaultTtl.HasValue)
{
expiresAt = now.Add(_options.DefaultTtl.Value);
}
var entry = new CacheEntry<TValue> { Value = value, ExpiresAt = expiresAt };
Cache[cacheKey] = entry;
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public ValueTask<bool> InvalidateAsync(TKey key, CancellationToken cancellationToken = default)
{
var cacheKey = BuildKey(key);
return ValueTask.FromResult(Cache.TryRemove(cacheKey, out _));
}
/// <inheritdoc />
public ValueTask<long> InvalidateByPatternAsync(string pattern, CancellationToken cancellationToken = default)
{
// Simple pattern matching - supports * at start or end
var prefix = _options.KeyPrefix ?? string.Empty;
var fullPattern = $"{prefix}{pattern}";
long count = 0;
foreach (var key in Cache.Keys.ToList())
{
if (MatchesPattern(key, fullPattern))
{
if (Cache.TryRemove(key, out _))
{
count++;
}
}
}
return ValueTask.FromResult(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(_options.KeyPrefix)
? keyString
: $"{_options.KeyPrefix}{keyString}";
}
private static bool MatchesPattern(string input, string pattern)
{
if (pattern == "*")
{
return true;
}
if (pattern.StartsWith('*') && pattern.EndsWith('*'))
{
return input.Contains(pattern[1..^1], StringComparison.OrdinalIgnoreCase);
}
if (pattern.StartsWith('*'))
{
return input.EndsWith(pattern[1..], StringComparison.OrdinalIgnoreCase);
}
if (pattern.EndsWith('*'))
{
return input.StartsWith(pattern[..^1], StringComparison.OrdinalIgnoreCase);
}
return string.Equals(input, pattern, StringComparison.OrdinalIgnoreCase);
}
private sealed class CacheEntry<T>
{
public required T Value { get; init; }
public DateTimeOffset? ExpiresAt { get; set; }
}
}
/// <summary>
/// String-keyed in-memory cache store.
/// </summary>
/// <typeparam name="TValue">The value type.</typeparam>
public sealed class InMemoryCacheStore<TValue> : IDistributedCache<TValue>
{
private readonly InMemoryCacheStore<string, TValue> _inner;
public InMemoryCacheStore(
InMemoryQueueRegistry registry,
CacheOptions options,
TimeProvider? timeProvider = null)
{
_inner = new InMemoryCacheStore<string, TValue>(registry, options, key => key, timeProvider);
}
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,187 @@
using System.Runtime.CompilerServices;
using StellaOps.Messaging.Abstractions;
namespace StellaOps.Messaging.Transport.InMemory;
/// <summary>
/// In-memory implementation of <see cref="IEventStream{TEvent}"/>.
/// Provides fire-and-forget event publishing with subscription support.
/// </summary>
public sealed class InMemoryEventStream<TEvent> : IEventStream<TEvent>
where TEvent : class
{
private readonly EventStreamStore<TEvent> _store;
private readonly EventStreamOptions _options;
private readonly TimeProvider _timeProvider;
public InMemoryEventStream(
InMemoryQueueRegistry registry,
EventStreamOptions options,
TimeProvider? timeProvider = null)
{
ArgumentNullException.ThrowIfNull(registry);
ArgumentNullException.ThrowIfNull(options);
_store = registry.GetOrCreateEventStream<TEvent>(options.StreamName);
_options = options;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public string ProviderName => "inmemory";
/// <inheritdoc />
public string StreamName => _options.StreamName;
/// <inheritdoc />
public ValueTask<EventPublishResult> PublishAsync(
TEvent @event,
EventPublishOptions? options = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(@event);
var now = _timeProvider.GetUtcNow();
var entryId = _store.Add(
@event,
options?.TenantId,
options?.CorrelationId,
options?.Headers,
now);
// Auto-trim if configured
if (_options.MaxLength.HasValue)
{
_store.Trim(_options.MaxLength.Value);
}
return ValueTask.FromResult(EventPublishResult.Succeeded(entryId));
}
/// <inheritdoc />
public ValueTask<IReadOnlyList<EventPublishResult>> PublishBatchAsync(
IEnumerable<TEvent> events,
EventPublishOptions? options = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(events);
var results = new List<EventPublishResult>();
var now = _timeProvider.GetUtcNow();
foreach (var @event in events)
{
var entryId = _store.Add(
@event,
options?.TenantId,
options?.CorrelationId,
options?.Headers,
now);
results.Add(EventPublishResult.Succeeded(entryId));
}
// Auto-trim if configured
if (_options.MaxLength.HasValue)
{
_store.Trim(_options.MaxLength.Value);
}
return ValueTask.FromResult<IReadOnlyList<EventPublishResult>>(results);
}
/// <inheritdoc />
public async IAsyncEnumerable<StreamEvent<TEvent>> SubscribeAsync(
StreamPosition position,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
string? lastEntryId = position.Value == "$" ? null : position.Value;
// First, yield existing entries after the position
if (position.Value != "$")
{
var existingEntries = _store.GetEntriesAfter(lastEntryId);
foreach (var entry in existingEntries)
{
yield return new StreamEvent<TEvent>(
entry.EntryId,
entry.Event,
entry.Timestamp,
entry.TenantId,
entry.CorrelationId);
lastEntryId = entry.EntryId;
}
}
// Then subscribe to new entries
var reader = _store.Reader;
while (!cancellationToken.IsCancellationRequested)
{
// WaitToReadAsync will throw OperationCanceledException when cancelled,
// which will naturally end the async enumeration
if (!await reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
{
break;
}
while (reader.TryRead(out var entry))
{
// Skip entries we've already seen
if (lastEntryId != null && string.CompareOrdinal(entry.EntryId, lastEntryId) <= 0)
{
continue;
}
yield return new StreamEvent<TEvent>(
entry.EntryId,
entry.Event,
entry.Timestamp,
entry.TenantId,
entry.CorrelationId);
lastEntryId = entry.EntryId;
}
}
}
/// <inheritdoc />
public ValueTask<StreamInfo> GetInfoAsync(CancellationToken cancellationToken = default)
{
var (length, firstId, lastId, firstTs, lastTs) = _store.GetInfo();
return ValueTask.FromResult(new StreamInfo(length, firstId, lastId, firstTs, lastTs));
}
/// <inheritdoc />
public ValueTask<long> TrimAsync(
long maxLength,
bool approximate = true,
CancellationToken cancellationToken = default)
{
return ValueTask.FromResult(_store.Trim(maxLength));
}
}
/// <summary>
/// Factory for creating in-memory event stream instances.
/// </summary>
public sealed class InMemoryEventStreamFactory : IEventStreamFactory
{
private readonly InMemoryQueueRegistry _registry;
private readonly TimeProvider _timeProvider;
public InMemoryEventStreamFactory(
InMemoryQueueRegistry registry,
TimeProvider? timeProvider = null)
{
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public string ProviderName => "inmemory";
/// <inheritdoc />
public IEventStream<TEvent> Create<TEvent>(EventStreamOptions options) where TEvent : class
{
ArgumentNullException.ThrowIfNull(options);
return new InMemoryEventStream<TEvent>(_registry, options, _timeProvider);
}
}

View File

@@ -0,0 +1,130 @@
using StellaOps.Messaging.Abstractions;
namespace StellaOps.Messaging.Transport.InMemory;
/// <summary>
/// In-memory implementation of <see cref="IIdempotencyStore"/>.
/// Provides idempotency key management for deduplication.
/// </summary>
public sealed class InMemoryIdempotencyStore : IIdempotencyStore
{
private readonly InMemoryQueueRegistry _registry;
private readonly string _name;
private readonly TimeProvider _timeProvider;
public InMemoryIdempotencyStore(
InMemoryQueueRegistry registry,
string name,
TimeProvider? timeProvider = null)
{
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
_name = name ?? throw new ArgumentNullException(nameof(name));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public string ProviderName => "inmemory";
/// <inheritdoc />
public ValueTask<IdempotencyResult> TryClaimAsync(
string key,
string value,
TimeSpan window,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(key);
ArgumentNullException.ThrowIfNull(value);
var fullKey = BuildKey(key);
var now = _timeProvider.GetUtcNow();
var expiresAt = now.Add(window);
// Cleanup expired keys first
_registry.CleanupExpiredIdempotencyKeys(now);
if (_registry.TryClaimIdempotencyKey(fullKey, value, expiresAt, out var existingValue))
{
return ValueTask.FromResult(IdempotencyResult.Claimed());
}
return ValueTask.FromResult(IdempotencyResult.Duplicate(existingValue ?? string.Empty));
}
/// <inheritdoc />
public ValueTask<bool> ExistsAsync(string key, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(key);
var fullKey = BuildKey(key);
var now = _timeProvider.GetUtcNow();
// Cleanup expired keys first
_registry.CleanupExpiredIdempotencyKeys(now);
return ValueTask.FromResult(_registry.IdempotencyKeyExists(fullKey));
}
/// <inheritdoc />
public ValueTask<string?> GetAsync(string key, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(key);
var fullKey = BuildKey(key);
var now = _timeProvider.GetUtcNow();
// Cleanup expired keys first
_registry.CleanupExpiredIdempotencyKeys(now);
return ValueTask.FromResult(_registry.GetIdempotencyKey(fullKey));
}
/// <inheritdoc />
public ValueTask<bool> ReleaseAsync(string key, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(key);
var fullKey = BuildKey(key);
return ValueTask.FromResult(_registry.ReleaseIdempotencyKey(fullKey));
}
/// <inheritdoc />
public ValueTask<bool> ExtendAsync(
string key,
TimeSpan extension,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(key);
var fullKey = BuildKey(key);
return ValueTask.FromResult(_registry.ExtendIdempotencyKey(fullKey, extension, _timeProvider));
}
private string BuildKey(string key) => $"{_name}:{key}";
}
/// <summary>
/// Factory for creating in-memory idempotency store instances.
/// </summary>
public sealed class InMemoryIdempotencyStoreFactory : IIdempotencyStoreFactory
{
private readonly InMemoryQueueRegistry _registry;
private readonly TimeProvider _timeProvider;
public InMemoryIdempotencyStoreFactory(
InMemoryQueueRegistry registry,
TimeProvider? timeProvider = null)
{
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public string ProviderName => "inmemory";
/// <inheritdoc />
public IIdempotencyStore Create(string name)
{
ArgumentNullException.ThrowIfNull(name);
return new InMemoryIdempotencyStore(_registry, name, _timeProvider);
}
}

View File

@@ -0,0 +1,81 @@
using StellaOps.Messaging.Abstractions;
namespace StellaOps.Messaging.Transport.InMemory;
/// <summary>
/// In-memory implementation of a message lease.
/// </summary>
/// <typeparam name="TMessage">The message type.</typeparam>
internal sealed class InMemoryMessageLease<TMessage> : IMessageLease<TMessage> where TMessage : class
{
private readonly InMemoryMessageQueue<TMessage> _queue;
private readonly InMemoryQueueEntry<TMessage> _entry;
private int _completed;
internal InMemoryMessageLease(
InMemoryMessageQueue<TMessage> queue,
InMemoryQueueEntry<TMessage> entry,
DateTimeOffset leaseExpiresAt,
string consumer)
{
_queue = queue;
_entry = entry;
LeaseExpiresAt = leaseExpiresAt;
Consumer = consumer;
}
/// <inheritdoc />
public string MessageId => _entry.MessageId;
/// <inheritdoc />
public TMessage Message => _entry.Message;
/// <inheritdoc />
public int Attempt => _entry.Attempt;
/// <inheritdoc />
public DateTimeOffset EnqueuedAt => _entry.EnqueuedAt;
/// <inheritdoc />
public DateTimeOffset LeaseExpiresAt { get; private set; }
/// <inheritdoc />
public string Consumer { get; }
/// <inheritdoc />
public string? TenantId => _entry.TenantId;
/// <inheritdoc />
public string? CorrelationId => _entry.CorrelationId;
internal InMemoryQueueEntry<TMessage> Entry => _entry;
/// <inheritdoc />
public ValueTask AcknowledgeAsync(CancellationToken cancellationToken = default)
=> _queue.AcknowledgeAsync(this, cancellationToken);
/// <inheritdoc />
public ValueTask RenewAsync(TimeSpan extension, CancellationToken cancellationToken = default)
{
LeaseExpiresAt = DateTimeOffset.UtcNow.Add(extension);
_entry.LeaseExpiresAt = LeaseExpiresAt;
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public ValueTask ReleaseAsync(ReleaseDisposition disposition, CancellationToken cancellationToken = default)
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
/// <inheritdoc />
public ValueTask DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
/// <inheritdoc />
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
internal bool TryBeginCompletion()
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
internal void IncrementAttempt()
=> _entry.Attempt++;
}

View File

@@ -0,0 +1,248 @@
using System.Collections.Concurrent;
using System.Threading.Channels;
using Microsoft.Extensions.Logging;
using StellaOps.Messaging.Abstractions;
namespace StellaOps.Messaging.Transport.InMemory;
/// <summary>
/// In-memory implementation of <see cref="IMessageQueue{TMessage}"/>.
/// Useful for testing and development scenarios.
/// </summary>
/// <typeparam name="TMessage">The message type.</typeparam>
public sealed class InMemoryMessageQueue<TMessage> : IMessageQueue<TMessage>
where TMessage : class
{
private readonly InMemoryQueueRegistry _registry;
private readonly MessageQueueOptions _options;
private readonly ILogger<InMemoryMessageQueue<TMessage>>? _logger;
private readonly TimeProvider _timeProvider;
private readonly ConcurrentDictionary<string, DateTimeOffset> _idempotencyKeys = new(StringComparer.Ordinal);
private long _messageIdCounter;
public InMemoryMessageQueue(
InMemoryQueueRegistry registry,
MessageQueueOptions options,
ILogger<InMemoryMessageQueue<TMessage>>? logger = null,
TimeProvider? timeProvider = null)
{
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public string ProviderName => "inmemory";
/// <inheritdoc />
public string QueueName => _options.QueueName;
private Channel<InMemoryQueueEntry<TMessage>> Queue => _registry.GetOrCreateQueue<TMessage>(_options.QueueName);
private ConcurrentDictionary<string, InMemoryQueueEntry<TMessage>> Pending => _registry.GetOrCreatePending<TMessage>(_options.QueueName);
/// <inheritdoc />
public async ValueTask<EnqueueResult> EnqueueAsync(
TMessage message,
EnqueueOptions? options = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(message);
// Check idempotency
if (!string.IsNullOrWhiteSpace(options?.IdempotencyKey))
{
var now = _timeProvider.GetUtcNow();
if (_idempotencyKeys.TryGetValue(options.IdempotencyKey, out var existingTime))
{
if (now - existingTime < _options.IdempotencyWindow)
{
return EnqueueResult.Duplicate($"inmem-{options.IdempotencyKey}");
}
}
_idempotencyKeys[options.IdempotencyKey] = now;
}
var messageId = $"inmem-{Interlocked.Increment(ref _messageIdCounter)}";
var entry = new InMemoryQueueEntry<TMessage>
{
MessageId = messageId,
Message = message,
Attempt = 1,
EnqueuedAt = _timeProvider.GetUtcNow(),
TenantId = options?.TenantId,
CorrelationId = options?.CorrelationId,
IdempotencyKey = options?.IdempotencyKey,
Headers = options?.Headers
};
await Queue.Writer.WriteAsync(entry, cancellationToken).ConfigureAwait(false);
_logger?.LogDebug("Enqueued message {MessageId} to queue {Queue}", messageId, _options.QueueName);
return EnqueueResult.Succeeded(messageId);
}
/// <inheritdoc />
public async ValueTask<IReadOnlyList<IMessageLease<TMessage>>> LeaseAsync(
LeaseRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var consumer = _options.ConsumerName ?? $"{Environment.MachineName}-{Environment.ProcessId}";
var leases = new List<IMessageLease<TMessage>>(request.BatchSize);
var now = _timeProvider.GetUtcNow();
var leaseDuration = request.LeaseDuration ?? _options.DefaultLeaseDuration;
// First check pending (for redeliveries)
if (request.PendingOnly)
{
foreach (var kvp in Pending)
{
if (leases.Count >= request.BatchSize)
{
break;
}
var entry = kvp.Value;
if (entry.LeaseExpiresAt.HasValue && entry.LeaseExpiresAt.Value < now)
{
// Expired lease - claim it
entry.LeasedBy = consumer;
entry.LeaseExpiresAt = now.Add(leaseDuration);
entry.Attempt++;
leases.Add(new InMemoryMessageLease<TMessage>(this, entry, entry.LeaseExpiresAt.Value, consumer));
}
}
return leases;
}
// Try to read new messages
for (var i = 0; i < request.BatchSize; i++)
{
if (Queue.Reader.TryRead(out var entry))
{
entry.LeasedBy = consumer;
entry.LeaseExpiresAt = now.Add(leaseDuration);
Pending[entry.MessageId] = entry;
leases.Add(new InMemoryMessageLease<TMessage>(this, entry, entry.LeaseExpiresAt.Value, consumer));
}
else
{
break;
}
}
return leases;
}
/// <inheritdoc />
public ValueTask<IReadOnlyList<IMessageLease<TMessage>>> ClaimExpiredAsync(
ClaimRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var consumer = _options.ConsumerName ?? $"{Environment.MachineName}-{Environment.ProcessId}";
var leases = new List<IMessageLease<TMessage>>(request.BatchSize);
var now = _timeProvider.GetUtcNow();
var leaseDuration = request.LeaseDuration ?? _options.DefaultLeaseDuration;
foreach (var kvp in Pending)
{
if (leases.Count >= request.BatchSize)
{
break;
}
var entry = kvp.Value;
if (entry.LeaseExpiresAt.HasValue &&
now - entry.LeaseExpiresAt.Value >= request.MinIdleTime &&
entry.Attempt >= request.MinDeliveryAttempts)
{
// Claim it
entry.LeasedBy = consumer;
entry.LeaseExpiresAt = now.Add(leaseDuration);
entry.Attempt++;
leases.Add(new InMemoryMessageLease<TMessage>(this, entry, entry.LeaseExpiresAt.Value, consumer));
}
}
return ValueTask.FromResult<IReadOnlyList<IMessageLease<TMessage>>>(leases);
}
/// <inheritdoc />
public ValueTask<long> GetPendingCountAsync(CancellationToken cancellationToken = default)
{
return ValueTask.FromResult((long)Pending.Count);
}
internal ValueTask AcknowledgeAsync(InMemoryMessageLease<TMessage> lease, CancellationToken cancellationToken)
{
if (!lease.TryBeginCompletion())
{
return ValueTask.CompletedTask;
}
Pending.TryRemove(lease.MessageId, out _);
_logger?.LogDebug("Acknowledged message {MessageId} from queue {Queue}", lease.MessageId, _options.QueueName);
return ValueTask.CompletedTask;
}
internal async ValueTask ReleaseAsync(
InMemoryMessageLease<TMessage> lease,
ReleaseDisposition disposition,
CancellationToken cancellationToken)
{
if (disposition == ReleaseDisposition.Retry && lease.Attempt >= _options.MaxDeliveryAttempts)
{
await DeadLetterAsync(lease, $"max-delivery-attempts:{lease.Attempt}", cancellationToken).ConfigureAwait(false);
return;
}
if (!lease.TryBeginCompletion())
{
return;
}
Pending.TryRemove(lease.MessageId, out _);
if (disposition == ReleaseDisposition.Retry)
{
lease.IncrementAttempt();
lease.Entry.LeasedBy = null;
lease.Entry.LeaseExpiresAt = null;
// Re-enqueue
await Queue.Writer.WriteAsync(lease.Entry, cancellationToken).ConfigureAwait(false);
_logger?.LogDebug("Retrying message {MessageId}, attempt {Attempt}", lease.MessageId, lease.Attempt);
}
}
internal async ValueTask DeadLetterAsync(InMemoryMessageLease<TMessage> lease, string reason, CancellationToken cancellationToken)
{
if (!lease.TryBeginCompletion())
{
return;
}
Pending.TryRemove(lease.MessageId, out _);
if (!string.IsNullOrWhiteSpace(_options.DeadLetterQueue))
{
var dlqChannel = _registry.GetOrCreateQueue<TMessage>(_options.DeadLetterQueue);
lease.Entry.LeasedBy = null;
lease.Entry.LeaseExpiresAt = null;
await dlqChannel.Writer.WriteAsync(lease.Entry, cancellationToken).ConfigureAwait(false);
_logger?.LogWarning("Dead-lettered message {MessageId}: {Reason}", lease.MessageId, reason);
}
else
{
_logger?.LogWarning("Dropped message {MessageId} (no DLQ configured): {Reason}", lease.MessageId, reason);
}
}
}

View File

@@ -0,0 +1,40 @@
using Microsoft.Extensions.Logging;
using StellaOps.Messaging.Abstractions;
namespace StellaOps.Messaging.Transport.InMemory;
/// <summary>
/// Factory for creating in-memory message queue instances.
/// </summary>
public sealed class InMemoryMessageQueueFactory : IMessageQueueFactory
{
private readonly InMemoryQueueRegistry _registry;
private readonly ILoggerFactory? _loggerFactory;
private readonly TimeProvider _timeProvider;
public InMemoryMessageQueueFactory(
InMemoryQueueRegistry registry,
ILoggerFactory? loggerFactory = null,
TimeProvider? timeProvider = null)
{
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
_loggerFactory = loggerFactory;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public string ProviderName => "inmemory";
/// <inheritdoc />
public IMessageQueue<TMessage> Create<TMessage>(MessageQueueOptions options)
where TMessage : class
{
ArgumentNullException.ThrowIfNull(options);
return new InMemoryMessageQueue<TMessage>(
_registry,
options,
_loggerFactory?.CreateLogger<InMemoryMessageQueue<TMessage>>(),
_timeProvider);
}
}

View File

@@ -0,0 +1,741 @@
using System.Collections.Concurrent;
using System.Threading.Channels;
namespace StellaOps.Messaging.Transport.InMemory;
/// <summary>
/// Shared registry for in-memory queues. Enables message passing between
/// producers and consumers in the same process (useful for testing).
/// </summary>
public sealed class InMemoryQueueRegistry
{
private readonly ConcurrentDictionary<string, object> _queues = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, object> _pendingMessages = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, object>> _caches = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, RateLimitBucket> _rateLimitBuckets = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, object> _tokenStores = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, object> _sortedIndexes = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, object> _setStores = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, object> _eventStreams = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, IdempotencyEntry> _idempotencyKeys = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Gets or creates a queue channel for the specified queue name.
/// </summary>
public Channel<InMemoryQueueEntry<TMessage>> GetOrCreateQueue<TMessage>(string queueName)
where TMessage : class
{
return (Channel<InMemoryQueueEntry<TMessage>>)_queues.GetOrAdd(
queueName,
_ => Channel.CreateUnbounded<InMemoryQueueEntry<TMessage>>(
new UnboundedChannelOptions
{
SingleReader = false,
SingleWriter = false
}));
}
/// <summary>
/// Gets or creates the pending messages dictionary for a queue.
/// </summary>
public ConcurrentDictionary<string, InMemoryQueueEntry<TMessage>> GetOrCreatePending<TMessage>(string queueName)
where TMessage : class
{
return (ConcurrentDictionary<string, InMemoryQueueEntry<TMessage>>)_pendingMessages.GetOrAdd(
queueName,
_ => new ConcurrentDictionary<string, InMemoryQueueEntry<TMessage>>(StringComparer.Ordinal));
}
/// <summary>
/// Gets or creates a cache dictionary for the specified cache name.
/// </summary>
public ConcurrentDictionary<string, object> GetOrCreateCache(string cacheName)
{
return _caches.GetOrAdd(cacheName, _ => new ConcurrentDictionary<string, object>(StringComparer.Ordinal));
}
/// <summary>
/// Clears all queues and caches (useful for test cleanup).
/// </summary>
public void Clear()
{
_queues.Clear();
_pendingMessages.Clear();
_caches.Clear();
}
/// <summary>
/// Clears a specific queue.
/// </summary>
public void ClearQueue(string queueName)
{
_queues.TryRemove(queueName, out _);
_pendingMessages.TryRemove(queueName, out _);
}
/// <summary>
/// Clears a specific cache.
/// </summary>
public void ClearCache(string cacheName)
{
_caches.TryRemove(cacheName, out _);
}
/// <summary>
/// Gets or creates a rate limit bucket for the specified key.
/// </summary>
public RateLimitBucket GetOrCreateRateLimitBucket(string key)
{
return _rateLimitBuckets.GetOrAdd(key, _ => new RateLimitBucket());
}
/// <summary>
/// Removes a rate limit bucket.
/// </summary>
public bool RemoveRateLimitBucket(string key)
{
return _rateLimitBuckets.TryRemove(key, out _);
}
/// <summary>
/// Gets or creates a token store for the specified name.
/// </summary>
public ConcurrentDictionary<string, TokenEntry<TPayload>> GetOrCreateTokenStore<TPayload>(string name)
{
return (ConcurrentDictionary<string, TokenEntry<TPayload>>)_tokenStores.GetOrAdd(
name,
_ => new ConcurrentDictionary<string, TokenEntry<TPayload>>(StringComparer.Ordinal));
}
/// <summary>
/// Gets or creates a sorted index for the specified name.
/// </summary>
public SortedIndexStore<TKey, TElement> GetOrCreateSortedIndex<TKey, TElement>(string name)
where TKey : notnull
where TElement : notnull
{
return (SortedIndexStore<TKey, TElement>)_sortedIndexes.GetOrAdd(
name,
_ => new SortedIndexStore<TKey, TElement>());
}
/// <summary>
/// Gets or creates a set store for the specified name.
/// </summary>
public SetStoreData<TKey, TElement> GetOrCreateSetStore<TKey, TElement>(string name)
where TKey : notnull
{
return (SetStoreData<TKey, TElement>)_setStores.GetOrAdd(
name,
_ => new SetStoreData<TKey, TElement>());
}
/// <summary>
/// Gets or creates an event stream for the specified name.
/// </summary>
public EventStreamStore<TEvent> GetOrCreateEventStream<TEvent>(string name)
where TEvent : class
{
return (EventStreamStore<TEvent>)_eventStreams.GetOrAdd(
name,
_ => new EventStreamStore<TEvent>());
}
/// <summary>
/// Tries to claim an idempotency key.
/// </summary>
public bool TryClaimIdempotencyKey(string key, string value, DateTimeOffset expiresAt, out string? existingValue)
{
var entry = new IdempotencyEntry { Value = value, ExpiresAt = expiresAt };
if (_idempotencyKeys.TryAdd(key, entry))
{
existingValue = null;
return true;
}
if (_idempotencyKeys.TryGetValue(key, out var existing))
{
existingValue = existing.Value;
}
else
{
existingValue = null;
}
return false;
}
/// <summary>
/// Checks if an idempotency key exists.
/// </summary>
public bool IdempotencyKeyExists(string key)
{
return _idempotencyKeys.ContainsKey(key);
}
/// <summary>
/// Gets an idempotency key value.
/// </summary>
public string? GetIdempotencyKey(string key)
{
return _idempotencyKeys.TryGetValue(key, out var entry) ? entry.Value : null;
}
/// <summary>
/// Releases an idempotency key.
/// </summary>
public bool ReleaseIdempotencyKey(string key)
{
return _idempotencyKeys.TryRemove(key, out _);
}
/// <summary>
/// Extends an idempotency key's expiration.
/// </summary>
public bool ExtendIdempotencyKey(string key, TimeSpan extension, TimeProvider timeProvider)
{
if (_idempotencyKeys.TryGetValue(key, out var entry))
{
entry.ExpiresAt = timeProvider.GetUtcNow().Add(extension);
return true;
}
return false;
}
/// <summary>
/// Cleans up expired idempotency keys.
/// </summary>
public void CleanupExpiredIdempotencyKeys(DateTimeOffset now)
{
var expiredKeys = _idempotencyKeys
.Where(kvp => kvp.Value.ExpiresAt < now)
.Select(kvp => kvp.Key)
.ToList();
foreach (var key in expiredKeys)
{
_idempotencyKeys.TryRemove(key, out _);
}
}
}
/// <summary>
/// Rate limit bucket for sliding window tracking.
/// </summary>
public sealed class RateLimitBucket
{
private readonly object _lock = new();
private readonly List<DateTimeOffset> _timestamps = [];
public int GetCount(DateTimeOffset windowStart)
{
lock (_lock)
{
CleanupOld(windowStart);
return _timestamps.Count;
}
}
public int Increment(DateTimeOffset now, DateTimeOffset windowStart)
{
lock (_lock)
{
CleanupOld(windowStart);
_timestamps.Add(now);
return _timestamps.Count;
}
}
public void Reset()
{
lock (_lock)
{
_timestamps.Clear();
}
}
private void CleanupOld(DateTimeOffset windowStart)
{
_timestamps.RemoveAll(t => t < windowStart);
}
}
/// <summary>
/// Token entry for atomic token store.
/// </summary>
public sealed class TokenEntry<TPayload>
{
public required string Token { get; init; }
public required TPayload Payload { get; init; }
public required DateTimeOffset IssuedAt { get; init; }
public required DateTimeOffset ExpiresAt { get; init; }
}
/// <summary>
/// Idempotency entry.
/// </summary>
public sealed class IdempotencyEntry
{
public required string Value { get; init; }
public DateTimeOffset ExpiresAt { get; set; }
}
/// <summary>
/// Sorted index storage with score-based ordering.
/// </summary>
public sealed class SortedIndexStore<TKey, TElement> where TKey : notnull where TElement : notnull
{
private readonly object _lock = new();
private readonly Dictionary<TKey, SortedIndexData<TElement>> _indexes = [];
public SortedIndexData<TElement> GetOrCreateIndex(TKey key)
{
lock (_lock)
{
if (!_indexes.TryGetValue(key, out var index))
{
index = new SortedIndexData<TElement>();
_indexes[key] = index;
}
return index;
}
}
public bool TryGetIndex(TKey key, out SortedIndexData<TElement>? index)
{
lock (_lock)
{
return _indexes.TryGetValue(key, out index);
}
}
public bool RemoveIndex(TKey key)
{
lock (_lock)
{
return _indexes.Remove(key);
}
}
public void SetExpiration(TKey key, DateTimeOffset expiresAt)
{
lock (_lock)
{
if (_indexes.TryGetValue(key, out var index))
{
index.ExpiresAt = expiresAt;
}
}
}
}
/// <summary>
/// Data for a single sorted index.
/// </summary>
public sealed class SortedIndexData<TElement> where TElement : notnull
{
private readonly object _lock = new();
private readonly SortedList<double, List<TElement>> _byScore = [];
private readonly Dictionary<TElement, double> _elementScores = [];
public DateTimeOffset? ExpiresAt { get; set; }
public bool Add(TElement element, double score)
{
lock (_lock)
{
var isNew = true;
// Remove existing entry if present
if (_elementScores.TryGetValue(element, out var oldScore))
{
isNew = false;
if (_byScore.TryGetValue(oldScore, out var oldList))
{
oldList.Remove(element);
if (oldList.Count == 0)
{
_byScore.Remove(oldScore);
}
}
}
// Add new entry
_elementScores[element] = score;
if (!_byScore.TryGetValue(score, out var list))
{
list = [];
_byScore[score] = list;
}
list.Add(element);
return isNew;
}
}
public bool Remove(TElement element)
{
lock (_lock)
{
if (!_elementScores.TryGetValue(element, out var score))
{
return false;
}
_elementScores.Remove(element);
if (_byScore.TryGetValue(score, out var list))
{
list.Remove(element);
if (list.Count == 0)
{
_byScore.Remove(score);
}
}
return true;
}
}
public long RemoveByScoreRange(double minScore, double maxScore)
{
lock (_lock)
{
var toRemove = _byScore
.Where(kvp => kvp.Key >= minScore && kvp.Key <= maxScore)
.SelectMany(kvp => kvp.Value)
.ToList();
foreach (var element in toRemove)
{
Remove(element);
}
return toRemove.Count;
}
}
public double? GetScore(TElement element)
{
lock (_lock)
{
return _elementScores.TryGetValue(element, out var score) ? score : null;
}
}
public IReadOnlyList<(TElement Element, double Score)> GetByRank(long start, long stop, bool ascending)
{
lock (_lock)
{
var all = ascending
? _byScore.SelectMany(kvp => kvp.Value.Select(e => (Element: e, Score: kvp.Key))).ToList()
: _byScore.Reverse().SelectMany(kvp => kvp.Value.Select(e => (Element: e, Score: kvp.Key))).ToList();
var count = all.Count;
if (start < 0) start = Math.Max(0, count + start);
if (stop < 0) stop = count + stop;
stop = Math.Min(stop, count - 1);
if (start > stop || start >= count)
{
return [];
}
return all.Skip((int)start).Take((int)(stop - start + 1)).ToList();
}
}
public IReadOnlyList<(TElement Element, double Score)> GetByScoreRange(double minScore, double maxScore, bool ascending, int? limit)
{
lock (_lock)
{
var filtered = _byScore
.Where(kvp => kvp.Key >= minScore && kvp.Key <= maxScore)
.SelectMany(kvp => kvp.Value.Select(e => (Element: e, Score: kvp.Key)));
if (!ascending)
{
filtered = filtered.Reverse();
}
if (limit.HasValue)
{
filtered = filtered.Take(limit.Value);
}
return filtered.ToList();
}
}
public long Count()
{
lock (_lock)
{
return _elementScores.Count;
}
}
}
/// <summary>
/// Set store data with multiple sets.
/// </summary>
public sealed class SetStoreData<TKey, TElement> where TKey : notnull
{
private readonly object _lock = new();
private readonly Dictionary<TKey, SetData<TElement>> _sets = [];
public SetData<TElement> GetOrCreateSet(TKey key)
{
lock (_lock)
{
if (!_sets.TryGetValue(key, out var set))
{
set = new SetData<TElement>();
_sets[key] = set;
}
return set;
}
}
public bool TryGetSet(TKey key, out SetData<TElement>? set)
{
lock (_lock)
{
return _sets.TryGetValue(key, out set);
}
}
public bool RemoveSet(TKey key)
{
lock (_lock)
{
return _sets.Remove(key);
}
}
public void SetExpiration(TKey key, DateTimeOffset expiresAt)
{
lock (_lock)
{
if (_sets.TryGetValue(key, out var set))
{
set.ExpiresAt = expiresAt;
}
}
}
}
/// <summary>
/// Data for a single set.
/// </summary>
public sealed class SetData<TElement>
{
private readonly HashSet<TElement> _elements = [];
private readonly object _lock = new();
public DateTimeOffset? ExpiresAt { get; set; }
public bool Add(TElement element)
{
lock (_lock)
{
return _elements.Add(element);
}
}
public long AddRange(IEnumerable<TElement> elements)
{
lock (_lock)
{
long added = 0;
foreach (var element in elements)
{
if (_elements.Add(element))
{
added++;
}
}
return added;
}
}
public bool Contains(TElement element)
{
lock (_lock)
{
return _elements.Contains(element);
}
}
public bool Remove(TElement element)
{
lock (_lock)
{
return _elements.Remove(element);
}
}
public long RemoveRange(IEnumerable<TElement> elements)
{
lock (_lock)
{
long removed = 0;
foreach (var element in elements)
{
if (_elements.Remove(element))
{
removed++;
}
}
return removed;
}
}
public IReadOnlySet<TElement> GetAll()
{
lock (_lock)
{
return new HashSet<TElement>(_elements);
}
}
public long Count()
{
lock (_lock)
{
return _elements.Count;
}
}
}
/// <summary>
/// Event stream storage with ordered entries.
/// </summary>
public sealed class EventStreamStore<TEvent> where TEvent : class
{
private readonly object _lock = new();
private readonly List<EventStreamEntry<TEvent>> _entries = [];
private readonly Channel<EventStreamEntry<TEvent>> _channel;
private long _nextSequence = 1;
public EventStreamStore()
{
_channel = Channel.CreateUnbounded<EventStreamEntry<TEvent>>(
new UnboundedChannelOptions
{
SingleReader = false,
SingleWriter = false
});
}
public string Add(TEvent @event, string? tenantId, string? correlationId, IReadOnlyDictionary<string, string>? headers, DateTimeOffset timestamp)
{
lock (_lock)
{
var sequence = _nextSequence++;
var entryId = $"{timestamp.ToUnixTimeMilliseconds()}-{sequence}";
var entry = new EventStreamEntry<TEvent>
{
EntryId = entryId,
Sequence = sequence,
Event = @event,
Timestamp = timestamp,
TenantId = tenantId,
CorrelationId = correlationId,
Headers = headers
};
_entries.Add(entry);
// Notify subscribers
_channel.Writer.TryWrite(entry);
return entryId;
}
}
public IReadOnlyList<EventStreamEntry<TEvent>> GetEntriesAfter(string? afterEntryId)
{
lock (_lock)
{
if (string.IsNullOrEmpty(afterEntryId) || afterEntryId == "0")
{
return _entries.ToList();
}
var startIndex = _entries.FindIndex(e => e.EntryId == afterEntryId);
if (startIndex < 0)
{
return _entries.ToList();
}
return _entries.Skip(startIndex + 1).ToList();
}
}
public ChannelReader<EventStreamEntry<TEvent>> Reader => _channel.Reader;
public (long Length, string? FirstEntryId, string? LastEntryId, DateTimeOffset? FirstTimestamp, DateTimeOffset? LastTimestamp) GetInfo()
{
lock (_lock)
{
if (_entries.Count == 0)
{
return (0, null, null, null, null);
}
return (
_entries.Count,
_entries[0].EntryId,
_entries[^1].EntryId,
_entries[0].Timestamp,
_entries[^1].Timestamp);
}
}
public long Trim(long maxLength)
{
lock (_lock)
{
if (_entries.Count <= maxLength)
{
return 0;
}
var toRemove = (int)(_entries.Count - maxLength);
_entries.RemoveRange(0, toRemove);
return toRemove;
}
}
}
/// <summary>
/// Entry in an event stream.
/// </summary>
public sealed class EventStreamEntry<TEvent> where TEvent : class
{
public required string EntryId { get; init; }
public required long Sequence { get; init; }
public required TEvent Event { get; init; }
public required DateTimeOffset Timestamp { get; init; }
public string? TenantId { get; init; }
public string? CorrelationId { get; init; }
public IReadOnlyDictionary<string, string>? Headers { get; init; }
}
/// <summary>
/// Entry stored in an in-memory queue.
/// </summary>
public sealed class InMemoryQueueEntry<TMessage> where TMessage : class
{
public required string MessageId { get; init; }
public required TMessage Message { get; init; }
public required int Attempt { get; set; }
public required DateTimeOffset EnqueuedAt { get; init; }
public string? TenantId { get; init; }
public string? CorrelationId { get; init; }
public string? IdempotencyKey { get; init; }
public IReadOnlyDictionary<string, string>? Headers { get; init; }
// Lease tracking
public string? LeasedBy { get; set; }
public DateTimeOffset? LeaseExpiresAt { get; set; }
}

View File

@@ -0,0 +1,120 @@
using StellaOps.Messaging.Abstractions;
namespace StellaOps.Messaging.Transport.InMemory;
/// <summary>
/// In-memory implementation of <see cref="IRateLimiter"/>.
/// Uses sliding window algorithm for rate limiting.
/// </summary>
public sealed class InMemoryRateLimiter : IRateLimiter
{
private readonly InMemoryQueueRegistry _registry;
private readonly string _name;
private readonly TimeProvider _timeProvider;
public InMemoryRateLimiter(
InMemoryQueueRegistry registry,
string name,
TimeProvider? timeProvider = null)
{
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
_name = name ?? throw new ArgumentNullException(nameof(name));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public string ProviderName => "inmemory";
/// <inheritdoc />
public ValueTask<RateLimitResult> TryAcquireAsync(
string key,
RateLimitPolicy policy,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(key);
ArgumentNullException.ThrowIfNull(policy);
var fullKey = BuildKey(key);
var bucket = _registry.GetOrCreateRateLimitBucket(fullKey);
var now = _timeProvider.GetUtcNow();
var windowStart = now - policy.Window;
var currentCount = bucket.GetCount(windowStart);
if (currentCount >= policy.MaxPermits)
{
// Denied - calculate retry after
var retryAfter = policy.Window; // Simplified - actual implementation could track exact timestamps
return ValueTask.FromResult(RateLimitResult.Denied(currentCount, retryAfter));
}
// Increment and allow
var newCount = bucket.Increment(now, windowStart);
var remaining = Math.Max(0, policy.MaxPermits - newCount);
return ValueTask.FromResult(RateLimitResult.Allowed(newCount, remaining));
}
/// <inheritdoc />
public ValueTask<RateLimitStatus> GetStatusAsync(
string key,
RateLimitPolicy policy,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(key);
ArgumentNullException.ThrowIfNull(policy);
var fullKey = BuildKey(key);
var bucket = _registry.GetOrCreateRateLimitBucket(fullKey);
var now = _timeProvider.GetUtcNow();
var windowStart = now - policy.Window;
var currentCount = bucket.GetCount(windowStart);
var remaining = Math.Max(0, policy.MaxPermits - currentCount);
return ValueTask.FromResult(new RateLimitStatus
{
CurrentCount = currentCount,
RemainingPermits = remaining,
WindowRemaining = policy.Window, // Simplified
Exists = true
});
}
/// <inheritdoc />
public ValueTask<bool> ResetAsync(string key, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(key);
var fullKey = BuildKey(key);
return ValueTask.FromResult(_registry.RemoveRateLimitBucket(fullKey));
}
private string BuildKey(string key) => $"{_name}:{key}";
}
/// <summary>
/// Factory for creating in-memory rate limiter instances.
/// </summary>
public sealed class InMemoryRateLimiterFactory : IRateLimiterFactory
{
private readonly InMemoryQueueRegistry _registry;
private readonly TimeProvider _timeProvider;
public InMemoryRateLimiterFactory(
InMemoryQueueRegistry registry,
TimeProvider? timeProvider = null)
{
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public string ProviderName => "inmemory";
/// <inheritdoc />
public IRateLimiter Create(string name)
{
ArgumentNullException.ThrowIfNull(name);
return new InMemoryRateLimiter(_registry, name, _timeProvider);
}
}

View File

@@ -0,0 +1,167 @@
using StellaOps.Messaging.Abstractions;
namespace StellaOps.Messaging.Transport.InMemory;
/// <summary>
/// In-memory implementation of <see cref="ISetStore{TKey, TElement}"/>.
/// Provides unordered set operations.
/// </summary>
public sealed class InMemorySetStore<TKey, TElement> : ISetStore<TKey, TElement>
where TKey : notnull
{
private readonly SetStoreData<TKey, TElement> _store;
private readonly TimeProvider _timeProvider;
public InMemorySetStore(
InMemoryQueueRegistry registry,
string name,
TimeProvider? timeProvider = null)
{
ArgumentNullException.ThrowIfNull(registry);
ArgumentNullException.ThrowIfNull(name);
_store = registry.GetOrCreateSetStore<TKey, TElement>(name);
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public string ProviderName => "inmemory";
/// <inheritdoc />
public ValueTask<bool> AddAsync(
TKey setKey,
TElement element,
CancellationToken cancellationToken = default)
{
var set = _store.GetOrCreateSet(setKey);
return ValueTask.FromResult(set.Add(element));
}
/// <inheritdoc />
public ValueTask<long> AddRangeAsync(
TKey setKey,
IEnumerable<TElement> elements,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(elements);
var set = _store.GetOrCreateSet(setKey);
return ValueTask.FromResult(set.AddRange(elements));
}
/// <inheritdoc />
public ValueTask<IReadOnlySet<TElement>> GetMembersAsync(
TKey setKey,
CancellationToken cancellationToken = default)
{
if (!_store.TryGetSet(setKey, out var set) || set is null)
{
return ValueTask.FromResult<IReadOnlySet<TElement>>(new HashSet<TElement>());
}
return ValueTask.FromResult(set.GetAll());
}
/// <inheritdoc />
public ValueTask<bool> ContainsAsync(
TKey setKey,
TElement element,
CancellationToken cancellationToken = default)
{
if (!_store.TryGetSet(setKey, out var set) || set is null)
{
return ValueTask.FromResult(false);
}
return ValueTask.FromResult(set.Contains(element));
}
/// <inheritdoc />
public ValueTask<bool> RemoveAsync(
TKey setKey,
TElement element,
CancellationToken cancellationToken = default)
{
if (!_store.TryGetSet(setKey, out var set) || set is null)
{
return ValueTask.FromResult(false);
}
return ValueTask.FromResult(set.Remove(element));
}
/// <inheritdoc />
public ValueTask<long> RemoveRangeAsync(
TKey setKey,
IEnumerable<TElement> elements,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(elements);
if (!_store.TryGetSet(setKey, out var set) || set is null)
{
return ValueTask.FromResult(0L);
}
return ValueTask.FromResult(set.RemoveRange(elements));
}
/// <inheritdoc />
public ValueTask<bool> DeleteAsync(
TKey setKey,
CancellationToken cancellationToken = default)
{
return ValueTask.FromResult(_store.RemoveSet(setKey));
}
/// <inheritdoc />
public ValueTask<long> CountAsync(
TKey setKey,
CancellationToken cancellationToken = default)
{
if (!_store.TryGetSet(setKey, out var set) || set is null)
{
return ValueTask.FromResult(0L);
}
return ValueTask.FromResult(set.Count());
}
/// <inheritdoc />
public ValueTask SetExpirationAsync(
TKey setKey,
TimeSpan ttl,
CancellationToken cancellationToken = default)
{
var expiresAt = _timeProvider.GetUtcNow().Add(ttl);
_store.SetExpiration(setKey, expiresAt);
return ValueTask.CompletedTask;
}
}
/// <summary>
/// Factory for creating in-memory set store instances.
/// </summary>
public sealed class InMemorySetStoreFactory : ISetStoreFactory
{
private readonly InMemoryQueueRegistry _registry;
private readonly TimeProvider _timeProvider;
public InMemorySetStoreFactory(
InMemoryQueueRegistry registry,
TimeProvider? timeProvider = null)
{
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public string ProviderName => "inmemory";
/// <inheritdoc />
public ISetStore<TKey, TElement> Create<TKey, TElement>(string name) where TKey : notnull
{
ArgumentNullException.ThrowIfNull(name);
return new InMemorySetStore<TKey, TElement>(_registry, name, _timeProvider);
}
}

View File

@@ -0,0 +1,230 @@
using StellaOps.Messaging.Abstractions;
namespace StellaOps.Messaging.Transport.InMemory;
/// <summary>
/// In-memory implementation of <see cref="ISortedIndex{TKey, TElement}"/>.
/// Provides score-ordered collections with range queries.
/// </summary>
public sealed class InMemorySortedIndex<TKey, TElement> : ISortedIndex<TKey, TElement>
where TKey : notnull
where TElement : notnull
{
private readonly SortedIndexStore<TKey, TElement> _store;
private readonly Func<TKey, string> _keySerializer;
private readonly TimeProvider _timeProvider;
public InMemorySortedIndex(
InMemoryQueueRegistry registry,
string name,
Func<TKey, string>? keySerializer = null,
TimeProvider? timeProvider = null)
{
ArgumentNullException.ThrowIfNull(registry);
ArgumentNullException.ThrowIfNull(name);
_store = registry.GetOrCreateSortedIndex<TKey, TElement>(name);
_keySerializer = keySerializer ?? (key => key?.ToString() ?? throw new ArgumentNullException(nameof(key)));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public string ProviderName => "inmemory";
/// <inheritdoc />
public ValueTask<bool> AddAsync(
TKey indexKey,
TElement element,
double score,
CancellationToken cancellationToken = default)
{
var index = _store.GetOrCreateIndex(indexKey);
var wasAdded = index.Add(element, score);
return ValueTask.FromResult(wasAdded);
}
/// <inheritdoc />
public ValueTask<long> AddRangeAsync(
TKey indexKey,
IEnumerable<ScoredElement<TElement>> elements,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(elements);
var index = _store.GetOrCreateIndex(indexKey);
long addedCount = 0;
foreach (var item in elements)
{
if (index.Add(item.Element, item.Score))
{
addedCount++;
}
}
return ValueTask.FromResult(addedCount);
}
/// <inheritdoc />
public ValueTask<IReadOnlyList<ScoredElement<TElement>>> GetByRankAsync(
TKey indexKey,
long start,
long stop,
SortOrder order = SortOrder.Ascending,
CancellationToken cancellationToken = default)
{
if (!_store.TryGetIndex(indexKey, out var index) || index is null)
{
return ValueTask.FromResult<IReadOnlyList<ScoredElement<TElement>>>([]);
}
var results = index.GetByRank(start, stop, order == SortOrder.Ascending);
var mapped = results.Select(r => new ScoredElement<TElement>(r.Element, r.Score)).ToList();
return ValueTask.FromResult<IReadOnlyList<ScoredElement<TElement>>>(mapped);
}
/// <inheritdoc />
public ValueTask<IReadOnlyList<ScoredElement<TElement>>> GetByScoreAsync(
TKey indexKey,
double minScore,
double maxScore,
SortOrder order = SortOrder.Ascending,
int? limit = null,
CancellationToken cancellationToken = default)
{
if (!_store.TryGetIndex(indexKey, out var index) || index is null)
{
return ValueTask.FromResult<IReadOnlyList<ScoredElement<TElement>>>([]);
}
var results = index.GetByScoreRange(minScore, maxScore, order == SortOrder.Ascending, limit);
var mapped = results.Select(r => new ScoredElement<TElement>(r.Element, r.Score)).ToList();
return ValueTask.FromResult<IReadOnlyList<ScoredElement<TElement>>>(mapped);
}
/// <inheritdoc />
public ValueTask<double?> GetScoreAsync(
TKey indexKey,
TElement element,
CancellationToken cancellationToken = default)
{
if (!_store.TryGetIndex(indexKey, out var index) || index is null)
{
return ValueTask.FromResult<double?>(null);
}
return ValueTask.FromResult(index.GetScore(element));
}
/// <inheritdoc />
public ValueTask<bool> RemoveAsync(
TKey indexKey,
TElement element,
CancellationToken cancellationToken = default)
{
if (!_store.TryGetIndex(indexKey, out var index) || index is null)
{
return ValueTask.FromResult(false);
}
return ValueTask.FromResult(index.Remove(element));
}
/// <inheritdoc />
public ValueTask<long> RemoveRangeAsync(
TKey indexKey,
IEnumerable<TElement> elements,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(elements);
if (!_store.TryGetIndex(indexKey, out var index) || index is null)
{
return ValueTask.FromResult(0L);
}
long removed = 0;
foreach (var element in elements)
{
if (index.Remove(element))
{
removed++;
}
}
return ValueTask.FromResult(removed);
}
/// <inheritdoc />
public ValueTask<long> RemoveByScoreAsync(
TKey indexKey,
double minScore,
double maxScore,
CancellationToken cancellationToken = default)
{
if (!_store.TryGetIndex(indexKey, out var index) || index is null)
{
return ValueTask.FromResult(0L);
}
return ValueTask.FromResult(index.RemoveByScoreRange(minScore, maxScore));
}
/// <inheritdoc />
public ValueTask<long> CountAsync(
TKey indexKey,
CancellationToken cancellationToken = default)
{
if (!_store.TryGetIndex(indexKey, out var index) || index is null)
{
return ValueTask.FromResult(0L);
}
return ValueTask.FromResult(index.Count());
}
/// <inheritdoc />
public ValueTask<bool> DeleteAsync(
TKey indexKey,
CancellationToken cancellationToken = default)
{
return ValueTask.FromResult(_store.RemoveIndex(indexKey));
}
/// <inheritdoc />
public ValueTask SetExpirationAsync(
TKey indexKey,
TimeSpan ttl,
CancellationToken cancellationToken = default)
{
var expiresAt = _timeProvider.GetUtcNow().Add(ttl);
_store.SetExpiration(indexKey, expiresAt);
return ValueTask.CompletedTask;
}
}
/// <summary>
/// Factory for creating in-memory sorted index instances.
/// </summary>
public sealed class InMemorySortedIndexFactory : ISortedIndexFactory
{
private readonly InMemoryQueueRegistry _registry;
private readonly TimeProvider _timeProvider;
public InMemorySortedIndexFactory(
InMemoryQueueRegistry registry,
TimeProvider? timeProvider = null)
{
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public string ProviderName => "inmemory";
/// <inheritdoc />
public ISortedIndex<TKey, TElement> Create<TKey, TElement>(string name)
where TKey : notnull
where TElement : notnull
{
ArgumentNullException.ThrowIfNull(name);
return new InMemorySortedIndex<TKey, TElement>(_registry, name, null, _timeProvider);
}
}

View File

@@ -0,0 +1,53 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Messaging.Abstractions;
using StellaOps.Messaging.Plugins;
namespace StellaOps.Messaging.Transport.InMemory;
/// <summary>
/// In-memory transport plugin for StellaOps.Messaging.
/// Useful for testing and development scenarios.
/// </summary>
public sealed class InMemoryTransportPlugin : IMessagingTransportPlugin
{
/// <inheritdoc />
public string Name => "inmemory";
/// <inheritdoc />
public bool IsAvailable(IServiceProvider services) => true;
/// <inheritdoc />
public void Register(MessagingTransportRegistrationContext context)
{
// Register shared registry (singleton for test state sharing)
context.Services.AddSingleton<InMemoryQueueRegistry>();
// Register message queue factory
context.Services.AddSingleton<IMessageQueueFactory, InMemoryMessageQueueFactory>();
// Register cache factory
context.Services.AddSingleton<IDistributedCacheFactory, InMemoryCacheFactory>();
// Register rate limiter factory
context.Services.AddSingleton<IRateLimiterFactory, InMemoryRateLimiterFactory>();
// Register atomic token store factory
context.Services.AddSingleton<IAtomicTokenStoreFactory, InMemoryAtomicTokenStoreFactory>();
// Register sorted index factory
context.Services.AddSingleton<ISortedIndexFactory, InMemorySortedIndexFactory>();
// Register set store factory
context.Services.AddSingleton<ISetStoreFactory, InMemorySetStoreFactory>();
// Register event stream factory
context.Services.AddSingleton<IEventStreamFactory, InMemoryEventStreamFactory>();
// Register idempotency store factory
context.Services.AddSingleton<IIdempotencyStoreFactory, InMemoryIdempotencyStoreFactory>();
context.LoggerFactory?.CreateLogger<InMemoryTransportPlugin>()
.LogDebug("Registered in-memory transport plugin");
}
}

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Messaging.Transport.InMemory</RootNamespace>
<AssemblyName>StellaOps.Messaging.Transport.InMemory</AssemblyName>
<Description>In-memory transport plugin for StellaOps.Messaging (for testing)</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<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" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.Messaging/StellaOps.Messaging.csproj" />
</ItemGroup>
</Project>