w4c-workflows-api/Services/Messaging/InMemoryTransport.cs

141 lines
6 KiB
C#
Raw Permalink Normal View History

using System.Collections.Concurrent;
using System.Threading.Channels;
namespace w4c_workflows.Services.Messaging;
/// <summary>
/// In-process channel-based transport for <c>IJobQueue</c> and <c>IEventBus</c>.
/// 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).
/// </summary>
public sealed class InMemoryTransport : IJobQueue, IEventBus
{
private readonly ConcurrentDictionary<string, Channel<StreamMessage>> _channels = new();
private long _idSeq;
// ---- IJobQueue ----
public Task EnsureGroupAsync(string tenantId, CancellationToken ct)
=> Task.CompletedTask; // No-op: channel consumers are implicit.
public Task<string> EnqueueAsync(string tenantId, IReadOnlyDictionary<string, string> 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<IReadOnlyList<StreamMessage>> ReadGroupAsync(string tenantId, string consumer, int count, CancellationToken ct)
=> DrainAsync($"wf:{tenantId}:jobs", count, ct);
public Task<IReadOnlyList<StreamMessage>> ClaimPendingAsync(string tenantId, string consumer, TimeSpan minIdle, int count, CancellationToken ct)
=> Task.FromResult<IReadOnlyList<StreamMessage>>(Array.Empty<StreamMessage>()); // No pending in memory.
public Task<long> 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<string, string> fields, string reason, CancellationToken ct)
{
var dlq = $"wf:{tenantId}:dlq";
var id = $"0-{Interlocked.Increment(ref _idSeq)}";
var dlqFields = new Dictionary<string, string>(fields) { ["deadLetterReason"] = reason };
GetOrAddChannel(dlq).Writer.TryWrite(new StreamMessage(id, dlqFields));
return Task.CompletedTask;
}
public Task PublishDeadLetterAsync(string tenantId, IReadOnlyDictionary<string, string> fields, string reason, CancellationToken ct)
{
var dlq = $"wf:{tenantId}:dlq";
var id = $"0-{Interlocked.Increment(ref _idSeq)}";
var dlqFields = new Dictionary<string, string>(fields) { ["deadLetterReason"] = reason };
GetOrAddChannel(dlq).Writer.TryWrite(new StreamMessage(id, dlqFields));
return Task.CompletedTask;
}
// ---- IEventBus ----
public Task<string> PublishAsync(string tenantId, string stream, IReadOnlyDictionary<string, string> 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<IReadOnlyList<StreamMessage>> IEventBus.ReadGroupAsync(string tenantId, string stream, string consumer, int count, CancellationToken ct)
{
var resolved = stream.Replace("{tenant}", tenantId);
return DrainAsync(resolved, count, ct);
}
Task<IReadOnlyList<StreamMessage>> IEventBus.ClaimPendingAsync(string tenantId, string stream, string consumer, TimeSpan minIdle, int count, CancellationToken ct)
=> Task.FromResult<IReadOnlyList<StreamMessage>>(Array.Empty<StreamMessage>());
Task<long> IEventBus.AckAsync(string tenantId, string stream, string messageId, CancellationToken ct)
=> Task.FromResult(1L);
// ---- Helpers ----
private Channel<StreamMessage> GetOrAddChannel(string key)
2026-09-13 08:35:17 +00:00
=> _channels.GetOrAdd(key, _ => Channel.CreateBounded<StreamMessage>(
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,
}));
/// <summary>Per-stream in-memory backlog ceiling.</summary>
private const int ChannelCapacity = 10_000;
private async Task<IReadOnlyList<StreamMessage>> DrainAsync(string stream, int count, CancellationToken ct)
{
if (!_channels.TryGetValue(stream, out var ch))
return Array.Empty<StreamMessage>();
var result = new List<StreamMessage>(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;
}
}