feat(nodes): NatsHookNode — trigger emits one execution per message

This commit is contained in:
Claude
2026-08-28 15:11:49 +00:00
parent b9097b1812
commit b9a3926be2
2 changed files with 155 additions and 0 deletions

View File

@@ -0,0 +1,86 @@
using System.Text;
using System.Text.Json;
using NATS.Client.Core;
using VolcanicCore.Core;
namespace VolcanicCore.Nodes;
public sealed class NatsHookNode : ITriggerNode
{
// Testable seam over the tiny slice of INatsConnection we actually use.
public interface INatsSubscriber
{
IAsyncEnumerable<byte[]> SubscribeAsync(string subject, CancellationToken ct);
}
private sealed class NatsSubscriberAdapter : INatsSubscriber
{
private readonly INatsConnection _conn;
public NatsSubscriberAdapter(INatsConnection c) => _conn = c;
public async IAsyncEnumerable<byte[]> SubscribeAsync(string subject,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
{
await foreach (var msg in _conn.SubscribeAsync<byte[]>(subject, cancellationToken: ct))
if (msg.Data is not null) yield return msg.Data;
}
}
public string Id => "nats.hook";
public string DisplayName => "NATS Hook";
public NodeCategory Category => NodeCategory.Trigger;
public int InputCount => 0;
public int OutputCount => 1;
public string? ConnectionProviderId => "nats";
public JsonSchema ConfigSchema { get; } = new(JsonDocument.Parse("""
{
"type": "object",
"required": ["subject"],
"properties": { "subject": { "type": "string", "title": "Subject" } }
}
""").RootElement);
public Task<IAsyncDisposable> StartAsync(INodeContext ctx, ITriggerSink sink, CancellationToken ct)
{
var subject = ctx.Config.GetProperty("subject").GetString()!;
var raw = ctx.GetConnection<object>();
var sub = raw as INatsSubscriber ?? new NatsSubscriberAdapter((INatsConnection)raw);
var pumpCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
var pump = Task.Run(async () =>
{
try
{
await foreach (var payload in sub.SubscribeAsync(subject, pumpCts.Token))
{
JsonElement json;
try
{
using var doc = JsonDocument.Parse(payload);
json = doc.RootElement.Clone();
}
catch (JsonException)
{
json = JsonSerializer.SerializeToElement(new { raw = Encoding.UTF8.GetString(payload) });
}
await sink.EmitAsync(new[] { new Item(json) }, ct: pumpCts.Token);
}
}
catch (OperationCanceledException) { /* stop */ }
}, pumpCts.Token);
return Task.FromResult<IAsyncDisposable>(new Handle(pumpCts, pump));
}
private sealed class Handle : IAsyncDisposable
{
private readonly CancellationTokenSource _cts;
private readonly Task _pump;
public Handle(CancellationTokenSource c, Task p) { _cts = c; _pump = p; }
public async ValueTask DisposeAsync()
{
_cts.Cancel();
try { await _pump; } catch { }
_cts.Dispose();
}
}
}

View File

@@ -0,0 +1,69 @@
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using VolcanicCore.Api.Engine;
using VolcanicCore.Core;
using VolcanicCore.Nodes;
// ImplicitUsings pulls System.Threading → collision with our ExecutionContext.
using ExecutionContext = VolcanicCore.Api.Engine.ExecutionContext;
namespace VolcanicCore.Tests;
public class NatsHookNodeTests
{
private sealed class FakeSubscriber : NatsHookNode.INatsSubscriber
{
private readonly List<byte[]> _payloads;
public FakeSubscriber(params byte[][] payloads) => _payloads = payloads.ToList();
public async IAsyncEnumerable<byte[]> SubscribeAsync(string subject,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
{
foreach (var p in _payloads)
{
yield return p;
await Task.Yield();
}
}
}
private sealed class FakeInstanceRegistry : IConnectionInstanceRegistry
{
private readonly object _handle;
public FakeInstanceRegistry(object h) => _handle = h;
public Task<object> GetAsync(string id, CancellationToken ct) => Task.FromResult(_handle);
}
private sealed class FakeSink : ITriggerSink
{
public List<IReadOnlyList<Item>> Batches { get; } = new();
public Task EmitAsync(IReadOnlyList<Item> items, JsonElement? td, Guid? ans, CancellationToken ct)
{ lock (Batches) Batches.Add(items); return Task.CompletedTask; }
}
[Fact]
public async Task Subscribes_and_emits_one_execution_per_message()
{
var node = new NatsHookNode();
var sub = new FakeSubscriber(
Encoding.UTF8.GetBytes("{\"n\":1}"),
Encoding.UTF8.GetBytes("{\"n\":2}"));
var reg = new FakeInstanceRegistry(sub);
var sink = new FakeSink();
var ctx = new ExecutionContext("", "wf", "n1",
JsonDocument.Parse("""{"subject":"topic"}""").RootElement,
null, NullLogger.Instance, "conn-1", reg);
var handle = await node.StartAsync(ctx, sink, CancellationToken.None);
// Give the pump a moment to drain the fake enumerable.
for (var i = 0; i < 50 && sink.Batches.Count < 2; i++) await Task.Delay(10);
Assert.Equal(2, sink.Batches.Count);
Assert.Equal(1, sink.Batches[0][0].Json.GetProperty("n").GetInt32());
Assert.Equal(2, sink.Batches[1][0].Json.GetProperty("n").GetInt32());
await handle.DisposeAsync();
}
}