using Microsoft.Extensions.Configuration;
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;
private readonly int _maxStreamLength;
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";
// Bound every stream so jobs/events/results/dlq cannot grow without limit
// on a long-lived Redis. 0 disables trimming. Approximate MAXLEN is cheap
// (removes whole radix nodes), so it does not add meaningful latency.
_maxStreamLength = config.GetValue("Workflows:StreamMaxLength", 50_000);
}
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 = _maxStreamLength > 0
? await _db.StreamAddAsync(key, entries, _maxStreamLength, useApproximateMaxLength: true)
: 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);
}
}