using System.Collections.Concurrent;
using System.Threading.Channels;
namespace w4c_workflows.Services.Messaging;
///
/// In-process channel-based transport for IJobQueue and IEventBus.
/// Used in lite/self-hosted mode where Redis is not available. Store-backed
/// (ConcurrentDictionary of channels per stream), survives thread-boundary
/// handoffs but NOT process restarts. For self-hosted single-process deployment
/// this is acceptable (restart = redeliver pending jobs from DB state).
///
public sealed class InMemoryTransport : IJobQueue, IEventBus
{
private readonly ConcurrentDictionary> _channels = new();
private long _idSeq;
// ---- IJobQueue ----
public Task EnsureGroupAsync(string tenantId, CancellationToken ct)
=> Task.CompletedTask; // No-op: channel consumers are implicit.
public Task EnqueueAsync(string tenantId, IReadOnlyDictionary fields, CancellationToken ct)
{
var stream = $"wf:{tenantId}:jobs";
var id = $"0-{Interlocked.Increment(ref _idSeq)}";
var msg = new StreamMessage(id, fields);
GetOrAddChannel(stream).Writer.TryWrite(msg);
return Task.FromResult(id);
}
public Task> ReadGroupAsync(string tenantId, string consumer, int count, CancellationToken ct)
=> DrainAsync($"wf:{tenantId}:jobs", count, ct);
public Task> ClaimPendingAsync(string tenantId, string consumer, TimeSpan minIdle, int count, CancellationToken ct)
=> Task.FromResult>(Array.Empty()); // No pending in memory.
public Task AckAsync(string tenantId, string messageId, CancellationToken ct)
=> Task.FromResult(1L); // Ack is a no-op in memory.
public Task DeadLetterAsync(string tenantId, string messageId, IReadOnlyDictionary fields, string reason, CancellationToken ct)
{
var dlq = $"wf:{tenantId}:dlq";
var id = $"0-{Interlocked.Increment(ref _idSeq)}";
var dlqFields = new Dictionary(fields) { ["deadLetterReason"] = reason };
GetOrAddChannel(dlq).Writer.TryWrite(new StreamMessage(id, dlqFields));
return Task.CompletedTask;
}
public Task PublishDeadLetterAsync(string tenantId, IReadOnlyDictionary fields, string reason, CancellationToken ct)
{
var dlq = $"wf:{tenantId}:dlq";
var id = $"0-{Interlocked.Increment(ref _idSeq)}";
var dlqFields = new Dictionary(fields) { ["deadLetterReason"] = reason };
GetOrAddChannel(dlq).Writer.TryWrite(new StreamMessage(id, dlqFields));
return Task.CompletedTask;
}
// ---- IEventBus ----
public Task PublishAsync(string tenantId, string stream, IReadOnlyDictionary fields, CancellationToken ct)
{
var id = $"0-{Interlocked.Increment(ref _idSeq)}";
var msg = new StreamMessage(id, fields);
// Expand {tenant} placeholder.
var resolved = stream.Replace("{tenant}", tenantId);
GetOrAddChannel(resolved).Writer.TryWrite(msg);
return Task.FromResult(id);
}
Task IEventBus.EnsureGroupAsync(string tenantId, string stream, CancellationToken ct)
=> Task.CompletedTask;
Task> IEventBus.ReadGroupAsync(string tenantId, string stream, string consumer, int count, CancellationToken ct)
{
var resolved = stream.Replace("{tenant}", tenantId);
return DrainAsync(resolved, count, ct);
}
Task> IEventBus.ClaimPendingAsync(string tenantId, string stream, string consumer, TimeSpan minIdle, int count, CancellationToken ct)
=> Task.FromResult>(Array.Empty());
Task IEventBus.AckAsync(string tenantId, string stream, string messageId, CancellationToken ct)
=> Task.FromResult(1L);
// ---- Helpers ----
private Channel GetOrAddChannel(string key)
=> _channels.GetOrAdd(key, _ => Channel.CreateBounded(
new BoundedChannelOptions(ChannelCapacity)
{
SingleReader = false,
SingleWriter = false,
// Bounded so a stalled consumer cannot grow memory without limit.
// Under pressure the OLDEST message is dropped: lite mode is
// single-process and jobs/notifications are recoverable from DB
// state, so dropping the stale tail is preferable to OOM.
FullMode = BoundedChannelFullMode.DropOldest,
}));
/// Per-stream in-memory backlog ceiling.
private const int ChannelCapacity = 10_000;
private async Task> DrainAsync(string stream, int count, CancellationToken ct)
{
if (!_channels.TryGetValue(stream, out var ch))
return Array.Empty();
var result = new List(count);
while (result.Count < count)
{
try
{
if (ch.Reader.TryRead(out var msg))
{
result.Add(msg);
}
else
{
// Wait briefly for a message.
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromMilliseconds(50));
try
{
var read = await ch.Reader.ReadAsync(cts.Token);
result.Add(read);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
break; // No more messages available right now.
}
}
}
catch (OperationCanceledException)
{
break;
}
}
return result;
}
}