using StackExchange.Redis; namespace w4c_workflows.Services.Messaging; /// /// Redis Streams implementation of and . /// /// Streams per tenant: /// - wf:{tenant}:jobs — task.run dispatch (consumer group `workers`) /// - wf:{tenant}:events — default handler-mode event stream /// - wf:{tenant}:dlq — dead-lettered messages /// /// Delivery is at-least-once; the engine enforces idempotency via /// correlation_id + a per-task idempotency key. /// public class RedisStreamsTransport : IJobQueue, IEventBus { private readonly IDatabase _db; private readonly string _jobsTemplate; private readonly string _eventsTemplate; private readonly string _dlqTemplate; private readonly string _group; public RedisStreamsTransport(IConnectionMultiplexer redis, IConfiguration config) { _db = redis.GetDatabase(); _jobsTemplate = config["Workflows:WorkerStream"] ?? "wf:{tenant}:jobs"; _eventsTemplate = config["Workflows:EventStream"] ?? "wf:{tenant}:events"; _dlqTemplate = config["Workflows:DlqStream"] ?? "wf:{tenant}:dlq"; _group = config["Workflows:ConsumerGroup"] ?? "workers"; } private string JobsStream(string tenantId) => _jobsTemplate.Replace("{tenant}", tenantId); private string EventStream(string tenantId, string stream) => Resolve(stream, tenantId); private string DlqStream(string tenantId) => _dlqTemplate.Replace("{tenant}", tenantId); private static string Resolve(string stream, string tenantId) => stream.Replace("{tenant}", tenantId); // ------------------------------------------------------------------ IJobQueue public Task EnsureGroupAsync(string tenantId, CancellationToken ct) => EnsureGroupCoreAsync(JobsStream(tenantId), ct); public Task EnqueueAsync(string tenantId, IReadOnlyDictionary fields, CancellationToken ct) => AddAsync(JobsStream(tenantId), fields); public Task> ReadGroupAsync(string tenantId, string consumer, int count, CancellationToken ct) => ReadGroupCoreAsync(JobsStream(tenantId), consumer, count); public Task> ClaimPendingAsync(string tenantId, string consumer, TimeSpan minIdle, int count, CancellationToken ct) => ClaimPendingCoreAsync(JobsStream(tenantId), consumer, minIdle, count); public Task AckAsync(string tenantId, string messageId, CancellationToken ct) => _db.StreamAcknowledgeAsync(JobsStream(tenantId), _group, messageId); public Task DeadLetterAsync(string tenantId, string messageId, IReadOnlyDictionary fields, string reason, CancellationToken ct) => DeadLetterCoreAsync(JobsStream(tenantId), DlqStream(tenantId), messageId, fields, reason); public Task PublishDeadLetterAsync(string tenantId, IReadOnlyDictionary fields, string reason, CancellationToken ct) => AddAsync(DlqStream(tenantId), DeadLetterFields(fields, reason, sourceId: null)); // ------------------------------------------------------------------ IEventBus public Task PublishAsync(string tenantId, string stream, IReadOnlyDictionary fields, CancellationToken ct) => AddAsync(EventStream(tenantId, stream), fields); public Task EnsureGroupAsync(string tenantId, string stream, CancellationToken ct) => EnsureGroupCoreAsync(EventStream(tenantId, stream), ct); public Task> ReadGroupAsync(string tenantId, string stream, string consumer, int count, CancellationToken ct) => ReadGroupCoreAsync(EventStream(tenantId, stream), consumer, count); public Task> ClaimPendingAsync(string tenantId, string stream, string consumer, TimeSpan minIdle, int count, CancellationToken ct) => ClaimPendingCoreAsync(EventStream(tenantId, stream), consumer, minIdle, count); public Task AckAsync(string tenantId, string stream, string messageId, CancellationToken ct) => _db.StreamAcknowledgeAsync(EventStream(tenantId, stream), _group, messageId); // ------------------------------------------------------------------ core private async Task AddAsync(string key, IReadOnlyDictionary fields) { var entries = fields.Select(kv => new NameValueEntry(kv.Key, kv.Value)).ToArray(); var id = await _db.StreamAddAsync(key, entries); return id.ToString(); } /// /// Creates the consumer group idempotently. The group starts at new messages /// (`>`) so a fresh worker never replays stale history; redelivery of /// in-flight messages is handled by . /// private async Task EnsureGroupCoreAsync(string key, CancellationToken ct) { try { await _db.StreamCreateConsumerGroupAsync(key, _group, StreamPosition.NewMessages, createStream: true); } catch (RedisServerException ex) when (ex.Message.Contains("BUSYGROUP", StringComparison.OrdinalIgnoreCase)) { // Group already exists — fine. } } private async Task> ReadGroupCoreAsync(string key, string consumer, int count) { var entries = await _db.StreamReadGroupAsync(key, _group, consumer, StreamPosition.NewMessages, count); return entries.Select(ToMessage).ToList(); } private async Task> ClaimPendingCoreAsync(string key, string consumer, TimeSpan minIdle, int count) { try { var result = await _db.StreamAutoClaimAsync( key, _group, consumer, (long)minIdle.TotalMilliseconds, StreamPosition.Beginning, count); return result.ClaimedEntries.Select(ToMessage).ToList(); } catch (RedisServerException) { // Consumer group may not exist yet, or the stream is empty. return Array.Empty(); } } private async Task DeadLetterCoreAsync(string key, string dlq, string messageId, IReadOnlyDictionary fields, string reason) { try { await AddAsync(dlq, DeadLetterFields(fields, reason, messageId)); } finally { // Ack the original so it leaves the pending list regardless of whether // the DLQ write succeeded (the failure is already captured in logs). await _db.StreamAcknowledgeAsync(key, _group, messageId); } } private static Dictionary DeadLetterFields( IReadOnlyDictionary fields, string reason, string? sourceId) { var dlqFields = new Dictionary(fields) { ["dlq.reason"] = reason, ["dlq.at"] = DateTime.UtcNow.ToString("O"), }; if (sourceId != null) dlqFields["dlq.source_id"] = sourceId; return dlqFields; } private static StreamMessage ToMessage(StreamEntry entry) { var dict = new Dictionary(entry.Values.Length); foreach (var value in entry.Values) dict[value.Name.ToString()] = value.Value.ToString(); return new StreamMessage(entry.Id.ToString(), dict); } }