up
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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++;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user