up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-13 00:20:26 +02:00
parent e1f1bef4c1
commit 564df71bfb
2376 changed files with 334389 additions and 328032 deletions

View File

@@ -0,0 +1,69 @@
namespace StellaOps.Messaging.Abstractions;
/// <summary>
/// Transport-agnostic distributed cache interface.
/// </summary>
/// <typeparam name="TKey">The key type.</typeparam>
/// <typeparam name="TValue">The value type.</typeparam>
public interface IDistributedCache<TKey, TValue>
{
/// <summary>
/// Gets the provider name for diagnostics (e.g., "valkey", "postgres").
/// </summary>
string ProviderName { get; }
/// <summary>
/// Gets a value from the cache.
/// </summary>
/// <param name="key">The cache key.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The cache result.</returns>
ValueTask<CacheResult<TValue>> GetAsync(TKey key, CancellationToken cancellationToken = default);
/// <summary>
/// Sets a value in the cache.
/// </summary>
/// <param name="key">The cache key.</param>
/// <param name="value">The value to cache.</param>
/// <param name="options">Optional cache entry options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
ValueTask SetAsync(TKey key, TValue value, CacheEntryOptions? options = null, CancellationToken cancellationToken = default);
/// <summary>
/// Removes a value from the cache.
/// </summary>
/// <param name="key">The cache key.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if the key existed and was removed.</returns>
ValueTask<bool> InvalidateAsync(TKey key, CancellationToken cancellationToken = default);
/// <summary>
/// Removes values matching a pattern from the cache.
/// </summary>
/// <param name="pattern">The key pattern (supports wildcards).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The number of keys invalidated.</returns>
ValueTask<long> InvalidateByPatternAsync(string pattern, CancellationToken cancellationToken = default);
/// <summary>
/// Gets or sets a value in the cache, using a factory function if the value is not present.
/// </summary>
/// <param name="key">The cache key.</param>
/// <param name="factory">Factory function to create the value if not cached.</param>
/// <param name="options">Optional cache entry options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The cached or newly created value.</returns>
ValueTask<TValue> GetOrSetAsync(
TKey key,
Func<CancellationToken, ValueTask<TValue>> factory,
CacheEntryOptions? options = null,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Simple string-keyed distributed cache interface.
/// </summary>
/// <typeparam name="TValue">The value type.</typeparam>
public interface IDistributedCache<TValue> : IDistributedCache<string, TValue>
{
}

View File

@@ -0,0 +1,99 @@
namespace StellaOps.Messaging.Abstractions;
/// <summary>
/// Represents a leased message from a queue.
/// The lease provides exclusive access to process the message.
/// </summary>
/// <typeparam name="TMessage">The message type.</typeparam>
public interface IMessageLease<out TMessage> : IAsyncDisposable where TMessage : class
{
/// <summary>
/// Gets the unique message identifier.
/// </summary>
string MessageId { get; }
/// <summary>
/// Gets the message payload.
/// </summary>
TMessage Message { get; }
/// <summary>
/// Gets the delivery attempt number (1-based).
/// </summary>
int Attempt { get; }
/// <summary>
/// Gets the timestamp when the message was enqueued.
/// </summary>
DateTimeOffset EnqueuedAt { get; }
/// <summary>
/// Gets the timestamp when the lease expires.
/// </summary>
DateTimeOffset LeaseExpiresAt { get; }
/// <summary>
/// Gets the consumer name that owns this lease.
/// </summary>
string Consumer { get; }
/// <summary>
/// Gets the tenant identifier, if present.
/// </summary>
string? TenantId { get; }
/// <summary>
/// Gets the correlation identifier for tracing, if present.
/// </summary>
string? CorrelationId { get; }
/// <summary>
/// Acknowledges successful processing of the message.
/// The message is removed from the queue.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
ValueTask AcknowledgeAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Extends the lease duration.
/// </summary>
/// <param name="extension">The time to extend the lease by.</param>
/// <param name="cancellationToken">Cancellation token.</param>
ValueTask RenewAsync(TimeSpan extension, CancellationToken cancellationToken = default);
/// <summary>
/// Releases the lease with the specified disposition.
/// </summary>
/// <param name="disposition">How to handle the message after release.</param>
/// <param name="cancellationToken">Cancellation token.</param>
ValueTask ReleaseAsync(ReleaseDisposition disposition, CancellationToken cancellationToken = default);
/// <summary>
/// Moves the message to the dead-letter queue.
/// </summary>
/// <param name="reason">The reason for dead-lettering.</param>
/// <param name="cancellationToken">Cancellation token.</param>
ValueTask DeadLetterAsync(string reason, CancellationToken cancellationToken = default);
}
/// <summary>
/// Specifies how to handle a message when releasing a lease.
/// </summary>
public enum ReleaseDisposition
{
/// <summary>
/// Retry the message (make it available for redelivery).
/// </summary>
Retry,
/// <summary>
/// Delay the message before making it available again.
/// </summary>
Delay,
/// <summary>
/// Abandon the message (do not retry, but don't dead-letter either).
/// Implementation may vary by transport.
/// </summary>
Abandon
}

View File

@@ -0,0 +1,59 @@
namespace StellaOps.Messaging.Abstractions;
/// <summary>
/// Transport-agnostic message queue interface.
/// Consumers depend only on this abstraction without knowing which transport is used.
/// </summary>
/// <typeparam name="TMessage">The message type.</typeparam>
public interface IMessageQueue<TMessage> where TMessage : class
{
/// <summary>
/// Gets the provider name for diagnostics (e.g., "valkey", "nats", "postgres").
/// </summary>
string ProviderName { get; }
/// <summary>
/// Gets the queue/stream name.
/// </summary>
string QueueName { get; }
/// <summary>
/// Enqueues a message to the queue.
/// </summary>
/// <param name="message">The message to enqueue.</param>
/// <param name="options">Optional enqueue options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The result of the enqueue operation.</returns>
ValueTask<EnqueueResult> EnqueueAsync(
TMessage message,
EnqueueOptions? options = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Leases messages from the queue for processing.
/// Messages remain invisible to other consumers until acknowledged or lease expires.
/// </summary>
/// <param name="request">The lease request parameters.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A list of message leases.</returns>
ValueTask<IReadOnlyList<IMessageLease<TMessage>>> LeaseAsync(
LeaseRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Claims expired leases from other consumers (pending entry list recovery).
/// </summary>
/// <param name="request">The claim request parameters.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A list of claimed message leases.</returns>
ValueTask<IReadOnlyList<IMessageLease<TMessage>>> ClaimExpiredAsync(
ClaimRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the approximate number of pending messages in the queue.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The approximate pending message count.</returns>
ValueTask<long> GetPendingCountAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,48 @@
namespace StellaOps.Messaging.Abstractions;
/// <summary>
/// Factory for creating message queue instances.
/// </summary>
public interface IMessageQueueFactory
{
/// <summary>
/// Gets the provider name for this factory.
/// </summary>
string ProviderName { get; }
/// <summary>
/// Creates a message queue for the specified message type and options.
/// </summary>
/// <typeparam name="TMessage">The message type.</typeparam>
/// <param name="options">The queue options.</param>
/// <returns>A configured message queue instance.</returns>
IMessageQueue<TMessage> Create<TMessage>(MessageQueueOptions options) where TMessage : class;
}
/// <summary>
/// Factory for creating distributed cache instances.
/// </summary>
public interface IDistributedCacheFactory
{
/// <summary>
/// Gets the provider name for this factory.
/// </summary>
string ProviderName { get; }
/// <summary>
/// Creates a distributed cache for the specified key and value types.
/// </summary>
/// <typeparam name="TKey">The key type.</typeparam>
/// <typeparam name="TValue">The value type.</typeparam>
/// <param name="options">The cache options.</param>
/// <returns>A configured distributed cache instance.</returns>
IDistributedCache<TKey, TValue> Create<TKey, TValue>(CacheOptions options);
/// <summary>
/// Creates a string-keyed distributed cache.
/// </summary>
/// <typeparam name="TValue">The value type.</typeparam>
/// <param name="options">The cache options.</param>
/// <returns>A configured distributed cache instance.</returns>
IDistributedCache<TValue> Create<TValue>(CacheOptions options);
}

View File

@@ -0,0 +1,125 @@
using System.Reflection;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Messaging.Abstractions;
using StellaOps.Messaging.Plugins;
namespace StellaOps.Messaging.DependencyInjection;
/// <summary>
/// Extension methods for registering messaging services.
/// </summary>
public static class MessagingServiceCollectionExtensions
{
/// <summary>
/// Adds messaging services with plugin-based transport discovery.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configuration">The configuration.</param>
/// <param name="configure">Optional configuration callback.</param>
/// <returns>The service collection.</returns>
public static IServiceCollection AddMessagingPlugins(
this IServiceCollection services,
IConfiguration configuration,
Action<MessagingPluginOptions>? configure = null)
{
var options = new MessagingPluginOptions();
configure?.Invoke(options);
services.AddSingleton<MessagingPluginLoader>();
var loader = new MessagingPluginLoader();
var plugins = loader.LoadFromDirectory(options.PluginDirectory, options.SearchPattern);
// Also load from assemblies in the current domain that might contain plugins
var domainAssemblies = AppDomain.CurrentDomain.GetAssemblies()
.Where(a => a.GetName().Name?.StartsWith("StellaOps.Messaging.Transport.") == true);
var domainPlugins = loader.LoadFromAssemblies(domainAssemblies);
var allPlugins = plugins.Concat(domainPlugins)
.GroupBy(p => p.Name, StringComparer.OrdinalIgnoreCase)
.Select(g => g.First())
.ToList();
var registered = loader.RegisterConfiguredTransport(
allPlugins,
services,
configuration,
options.ConfigurationSection);
if (!registered && options.RequireTransport)
{
throw new InvalidOperationException(
$"No messaging transport configured. Set '{options.ConfigurationSection}:transport' to one of: {string.Join(", ", allPlugins.Select(p => p.Name))}");
}
return services;
}
/// <summary>
/// Adds messaging services with a specific transport plugin.
/// </summary>
/// <typeparam name="TPlugin">The transport plugin type.</typeparam>
/// <param name="services">The service collection.</param>
/// <param name="configuration">The configuration.</param>
/// <param name="configSection">The configuration section for the transport.</param>
/// <returns>The service collection.</returns>
public static IServiceCollection AddMessagingTransport<TPlugin>(
this IServiceCollection services,
IConfiguration configuration,
string configSection = "messaging")
where TPlugin : IMessagingTransportPlugin, new()
{
var plugin = new TPlugin();
var context = new MessagingTransportRegistrationContext(
services,
configuration,
$"{configSection}:{plugin.Name}");
plugin.Register(context);
return services;
}
/// <summary>
/// Adds a message queue for a specific message type.
/// </summary>
/// <typeparam name="TMessage">The message type.</typeparam>
/// <param name="services">The service collection.</param>
/// <param name="options">The queue options.</param>
/// <returns>The service collection.</returns>
public static IServiceCollection AddMessageQueue<TMessage>(
this IServiceCollection services,
MessageQueueOptions options)
where TMessage : class
{
services.AddSingleton(sp =>
{
var factory = sp.GetRequiredService<IMessageQueueFactory>();
return factory.Create<TMessage>(options);
});
return services;
}
/// <summary>
/// Adds a distributed cache for a specific value type.
/// </summary>
/// <typeparam name="TValue">The value type.</typeparam>
/// <param name="services">The service collection.</param>
/// <param name="options">The cache options.</param>
/// <returns>The service collection.</returns>
public static IServiceCollection AddDistributedCache<TValue>(
this IServiceCollection services,
CacheOptions options)
{
services.AddSingleton(sp =>
{
var factory = sp.GetRequiredService<IDistributedCacheFactory>();
return factory.Create<TValue>(options);
});
return services;
}
}

View File

@@ -0,0 +1,59 @@
namespace StellaOps.Messaging;
/// <summary>
/// Configuration options for a distributed cache.
/// </summary>
public class CacheOptions
{
/// <summary>
/// Gets or sets the key prefix for all cache entries.
/// </summary>
public string? KeyPrefix { get; set; }
/// <summary>
/// Gets or sets the default time-to-live for cache entries.
/// </summary>
public TimeSpan? DefaultTtl { get; set; }
/// <summary>
/// Gets or sets whether to use sliding expiration.
/// If true, TTL is reset on each access.
/// </summary>
public bool SlidingExpiration { get; set; }
}
/// <summary>
/// Options for individual cache entries.
/// </summary>
public class CacheEntryOptions
{
/// <summary>
/// Gets or sets the absolute expiration time.
/// </summary>
public DateTimeOffset? AbsoluteExpiration { get; set; }
/// <summary>
/// Gets or sets the time-to-live relative to now.
/// </summary>
public TimeSpan? TimeToLive { get; set; }
/// <summary>
/// Gets or sets whether to use sliding expiration for this entry.
/// </summary>
public bool? SlidingExpiration { get; set; }
/// <summary>
/// Creates options with a specific TTL.
/// </summary>
public static CacheEntryOptions WithTtl(TimeSpan ttl) => new() { TimeToLive = ttl };
/// <summary>
/// Creates options with absolute expiration.
/// </summary>
public static CacheEntryOptions ExpiresAt(DateTimeOffset expiration) => new() { AbsoluteExpiration = expiration };
/// <summary>
/// Creates options with sliding expiration.
/// </summary>
public static CacheEntryOptions Sliding(TimeSpan slidingWindow) => new() { TimeToLive = slidingWindow, SlidingExpiration = true };
}

View File

@@ -0,0 +1,69 @@
using System.ComponentModel.DataAnnotations;
namespace StellaOps.Messaging;
/// <summary>
/// Configuration options for a message queue.
/// </summary>
public class MessageQueueOptions
{
/// <summary>
/// Gets or sets the queue/stream name.
/// </summary>
[Required]
public string QueueName { get; set; } = null!;
/// <summary>
/// Gets or sets the consumer group name.
/// </summary>
[Required]
public string ConsumerGroup { get; set; } = null!;
/// <summary>
/// Gets or sets the consumer name within the group.
/// Defaults to machine name + process ID.
/// </summary>
public string? ConsumerName { get; set; }
/// <summary>
/// Gets or sets the dead-letter queue name.
/// If null, dead-lettering may not be supported.
/// </summary>
public string? DeadLetterQueue { get; set; }
/// <summary>
/// Gets or sets the default lease duration for messages.
/// </summary>
public TimeSpan DefaultLeaseDuration { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Gets or sets the maximum number of delivery attempts before dead-lettering.
/// </summary>
public int MaxDeliveryAttempts { get; set; } = 5;
/// <summary>
/// Gets or sets the idempotency window for duplicate detection.
/// </summary>
public TimeSpan IdempotencyWindow { get; set; } = TimeSpan.FromHours(24);
/// <summary>
/// Gets or sets the approximate maximum queue length (stream trimming).
/// Null means no limit.
/// </summary>
public int? ApproximateMaxLength { get; set; }
/// <summary>
/// Gets or sets the initial backoff for retry delays.
/// </summary>
public TimeSpan RetryInitialBackoff { get; set; } = TimeSpan.FromSeconds(1);
/// <summary>
/// Gets or sets the maximum backoff for retry delays.
/// </summary>
public TimeSpan RetryMaxBackoff { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Gets or sets the backoff multiplier for exponential backoff.
/// </summary>
public double RetryBackoffMultiplier { get; set; } = 2.0;
}

View File

@@ -0,0 +1,27 @@
namespace StellaOps.Messaging;
/// <summary>
/// Options for configuring messaging plugin discovery and loading.
/// </summary>
public class MessagingPluginOptions
{
/// <summary>
/// Gets or sets the directory to search for transport plugins.
/// </summary>
public string PluginDirectory { get; set; } = "plugins/messaging";
/// <summary>
/// Gets or sets the search pattern for plugin assemblies.
/// </summary>
public string SearchPattern { get; set; } = "StellaOps.Messaging.Transport.*.dll";
/// <summary>
/// Gets or sets the configuration section path for messaging options.
/// </summary>
public string ConfigurationSection { get; set; } = "messaging";
/// <summary>
/// Gets or sets whether to throw if no transport is configured.
/// </summary>
public bool RequireTransport { get; set; } = true;
}

View File

@@ -0,0 +1,23 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Plugin;
namespace StellaOps.Messaging.Plugins;
/// <summary>
/// Plugin contract for messaging transports.
/// Transport plugins implement this interface to provide IMessageQueue and IDistributedCache implementations.
/// </summary>
public interface IMessagingTransportPlugin : IAvailabilityPlugin
{
/// <summary>
/// Gets the unique transport name (e.g., "valkey", "nats", "postgres", "inmemory").
/// </summary>
new string Name { get; }
/// <summary>
/// Registers transport services into the DI container.
/// </summary>
/// <param name="context">The registration context.</param>
void Register(MessagingTransportRegistrationContext context);
}

View File

@@ -0,0 +1,113 @@
using System.Reflection;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Plugin;
using StellaOps.Plugin.Hosting;
namespace StellaOps.Messaging.Plugins;
/// <summary>
/// Loads and registers messaging transport plugins.
/// </summary>
public sealed class MessagingPluginLoader
{
private readonly ILogger<MessagingPluginLoader>? _logger;
public MessagingPluginLoader(ILogger<MessagingPluginLoader>? logger = null)
{
_logger = logger;
}
/// <summary>
/// Discovers and loads messaging transport plugins from the specified directory.
/// </summary>
public IReadOnlyList<IMessagingTransportPlugin> LoadFromDirectory(
string pluginDirectory,
string searchPattern = "StellaOps.Messaging.Transport.*.dll")
{
if (!Directory.Exists(pluginDirectory))
{
_logger?.LogWarning("Plugin directory does not exist: {Directory}", pluginDirectory);
return [];
}
var options = new PluginHostOptions
{
PluginsDirectory = pluginDirectory,
EnsureDirectoryExists = false,
RecursiveSearch = false
};
options.SearchPatterns.Add(searchPattern);
var result = PluginHost.LoadPlugins(options);
var plugins = new List<IMessagingTransportPlugin>();
foreach (var pluginAssembly in result.Plugins)
{
var transportPlugins = PluginLoader.LoadPlugins<IMessagingTransportPlugin>(new[] { pluginAssembly.Assembly });
plugins.AddRange(transportPlugins);
foreach (var plugin in transportPlugins)
{
_logger?.LogDebug("Loaded messaging transport plugin: {Name} from {Assembly}",
plugin.Name, pluginAssembly.Assembly.GetName().Name);
}
}
return plugins;
}
/// <summary>
/// Loads messaging transport plugins from the specified assemblies.
/// </summary>
public IReadOnlyList<IMessagingTransportPlugin> LoadFromAssemblies(IEnumerable<Assembly> assemblies)
{
return PluginLoader.LoadPlugins<IMessagingTransportPlugin>(assemblies);
}
/// <summary>
/// Finds and registers the configured transport plugin.
/// </summary>
/// <param name="plugins">Available plugins.</param>
/// <param name="services">Service collection.</param>
/// <param name="configuration">Configuration.</param>
/// <param name="configSectionPath">Configuration section path (default: "messaging").</param>
/// <returns>True if a plugin was registered.</returns>
public bool RegisterConfiguredTransport(
IReadOnlyList<IMessagingTransportPlugin> plugins,
IServiceCollection services,
IConfiguration configuration,
string configSectionPath = "messaging")
{
var messagingSection = configuration.GetSection(configSectionPath);
var transportName = messagingSection.GetValue<string>("transport");
if (string.IsNullOrWhiteSpace(transportName))
{
_logger?.LogWarning("No messaging transport configured at {Path}:transport", configSectionPath);
return false;
}
var plugin = plugins.FirstOrDefault(p =>
string.Equals(p.Name, transportName, StringComparison.OrdinalIgnoreCase));
if (plugin is null)
{
_logger?.LogError("Messaging transport plugin '{Transport}' not found. Available: {Available}",
transportName, string.Join(", ", plugins.Select(p => p.Name)));
return false;
}
var transportConfigSection = $"{configSectionPath}:{transportName}";
var context = new MessagingTransportRegistrationContext(
services,
configuration,
transportConfigSection);
plugin.Register(context);
_logger?.LogInformation("Registered messaging transport: {Transport}", transportName);
return true;
}
}

View File

@@ -0,0 +1,52 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace StellaOps.Messaging.Plugins;
/// <summary>
/// Context provided to transport plugins during registration.
/// </summary>
public sealed class MessagingTransportRegistrationContext
{
/// <summary>
/// Creates a new registration context.
/// </summary>
public MessagingTransportRegistrationContext(
IServiceCollection services,
IConfiguration configuration,
string configurationSection,
ILoggerFactory? loggerFactory = null)
{
Services = services;
Configuration = configuration;
ConfigurationSection = configurationSection;
LoggerFactory = loggerFactory;
}
/// <summary>
/// Gets the service collection for registering services.
/// </summary>
public IServiceCollection Services { get; }
/// <summary>
/// Gets the configuration root.
/// </summary>
public IConfiguration Configuration { get; }
/// <summary>
/// Gets the configuration section path for this transport (e.g., "messaging:valkey").
/// </summary>
public string ConfigurationSection { get; }
/// <summary>
/// Gets the logger factory for creating loggers during registration.
/// </summary>
public ILoggerFactory? LoggerFactory { get; }
/// <summary>
/// Gets the configuration section for this transport.
/// </summary>
public IConfigurationSection GetTransportConfiguration() =>
Configuration.GetSection(ConfigurationSection);
}

View File

@@ -0,0 +1,63 @@
namespace StellaOps.Messaging;
/// <summary>
/// Result of a cache get operation.
/// </summary>
/// <typeparam name="TValue">The value type.</typeparam>
public readonly struct CacheResult<TValue>
{
private readonly TValue? _value;
private CacheResult(TValue? value, bool hasValue)
{
_value = value;
HasValue = hasValue;
}
/// <summary>
/// Gets whether a value was found in the cache.
/// </summary>
public bool HasValue { get; }
/// <summary>
/// Gets the cached value.
/// </summary>
/// <exception cref="InvalidOperationException">Thrown when no value is present.</exception>
public TValue Value => HasValue
? _value!
: throw new InvalidOperationException("No value present in cache result.");
/// <summary>
/// Gets the value or a default.
/// </summary>
/// <param name="defaultValue">The default value to return if not cached.</param>
/// <returns>The cached value or the default.</returns>
public TValue GetValueOrDefault(TValue defaultValue = default!) =>
HasValue ? _value! : defaultValue;
/// <summary>
/// Attempts to get the value.
/// </summary>
/// <param name="value">The cached value, if present.</param>
/// <returns>True if a value was present.</returns>
public bool TryGetValue(out TValue? value)
{
value = _value;
return HasValue;
}
/// <summary>
/// Creates a result with a value.
/// </summary>
public static CacheResult<TValue> Found(TValue value) => new(value, true);
/// <summary>
/// Creates a result indicating cache miss.
/// </summary>
public static CacheResult<TValue> Miss() => new(default, false);
/// <summary>
/// Implicitly converts a value to a found result.
/// </summary>
public static implicit operator CacheResult<TValue>(TValue value) => Found(value);
}

View File

@@ -0,0 +1,54 @@
namespace StellaOps.Messaging;
/// <summary>
/// Options for enqueue operations.
/// </summary>
public class EnqueueOptions
{
/// <summary>
/// Gets or sets the idempotency key for duplicate detection.
/// If null, no duplicate detection is performed.
/// </summary>
public string? IdempotencyKey { get; set; }
/// <summary>
/// Gets or sets the correlation ID for tracing.
/// </summary>
public string? CorrelationId { get; set; }
/// <summary>
/// Gets or sets the tenant ID for multi-tenant scenarios.
/// </summary>
public string? TenantId { get; set; }
/// <summary>
/// Gets or sets the message priority (if supported by transport).
/// Higher values indicate higher priority.
/// </summary>
public int Priority { get; set; }
/// <summary>
/// Gets or sets when the message should become visible (delayed delivery).
/// </summary>
public DateTimeOffset? VisibleAt { get; set; }
/// <summary>
/// Gets or sets custom headers/metadata for the message.
/// </summary>
public IReadOnlyDictionary<string, string>? Headers { get; set; }
/// <summary>
/// Creates options with an idempotency key.
/// </summary>
public static EnqueueOptions WithIdempotencyKey(string key) => new() { IdempotencyKey = key };
/// <summary>
/// Creates options for delayed delivery.
/// </summary>
public static EnqueueOptions DelayedUntil(DateTimeOffset visibleAt) => new() { VisibleAt = visibleAt };
/// <summary>
/// Creates options with correlation ID.
/// </summary>
public static EnqueueOptions WithCorrelation(string correlationId) => new() { CorrelationId = correlationId };
}

View File

@@ -0,0 +1,45 @@
namespace StellaOps.Messaging;
/// <summary>
/// Result of an enqueue operation.
/// </summary>
public readonly struct EnqueueResult
{
/// <summary>
/// Gets the message ID assigned by the queue.
/// </summary>
public string MessageId { get; init; }
/// <summary>
/// Gets whether the message was enqueued successfully.
/// </summary>
public bool Success { get; init; }
/// <summary>
/// Gets whether this was a duplicate message (idempotency).
/// </summary>
public bool WasDuplicate { get; init; }
/// <summary>
/// Gets the error message if the operation failed.
/// </summary>
public string? Error { get; init; }
/// <summary>
/// Creates a successful result.
/// </summary>
public static EnqueueResult Succeeded(string messageId, bool wasDuplicate = false) =>
new() { MessageId = messageId, Success = true, WasDuplicate = wasDuplicate };
/// <summary>
/// Creates a failed result.
/// </summary>
public static EnqueueResult Failed(string error) =>
new() { Success = false, Error = error, MessageId = string.Empty };
/// <summary>
/// Creates a duplicate result.
/// </summary>
public static EnqueueResult Duplicate(string messageId) =>
new() { MessageId = messageId, Success = true, WasDuplicate = true };
}

View File

@@ -0,0 +1,58 @@
namespace StellaOps.Messaging;
/// <summary>
/// Request parameters for leasing messages.
/// </summary>
public class LeaseRequest
{
/// <summary>
/// Gets or sets the maximum number of messages to lease.
/// </summary>
public int BatchSize { get; set; } = 1;
/// <summary>
/// Gets or sets the lease duration for the messages.
/// If null, uses the queue's default lease duration.
/// </summary>
public TimeSpan? LeaseDuration { get; set; }
/// <summary>
/// Gets or sets the maximum time to wait for messages if none are available.
/// Zero means don't wait (poll). Null means use transport default.
/// </summary>
public TimeSpan? WaitTimeout { get; set; }
/// <summary>
/// Gets or sets whether to only return messages from the pending entry list (redeliveries).
/// </summary>
public bool PendingOnly { get; set; }
}
/// <summary>
/// Request parameters for claiming expired leases.
/// </summary>
public class ClaimRequest
{
/// <summary>
/// Gets or sets the maximum number of messages to claim.
/// </summary>
public int BatchSize { get; set; } = 10;
/// <summary>
/// Gets or sets the minimum idle time for a message to be claimed.
/// Messages must have been idle (not processed) for at least this duration.
/// </summary>
public TimeSpan MinIdleTime { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Gets or sets the new lease duration for claimed messages.
/// If null, uses the queue's default lease duration.
/// </summary>
public TimeSpan? LeaseDuration { get; set; }
/// <summary>
/// Gets or sets the minimum number of delivery attempts for messages to claim.
/// This helps avoid claiming messages that are still being processed for the first time.
/// </summary>
public int MinDeliveryAttempts { get; set; } = 1;
}

View File

@@ -0,0 +1,29 @@
<?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</RootNamespace>
<AssemblyName>StellaOps.Messaging</AssemblyName>
<Description>Transport-agnostic messaging abstractions for StellaOps (queues, caching, pub/sub)</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.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.Plugin/StellaOps.Plugin.csproj" />
</ItemGroup>
</Project>