From 20797d204ad3937a5fbf02d4d2ee4de780083770 Mon Sep 17 00:00:00 2001 From: Caleb Lloyd <2414837+caleblloyd@users.noreply.github.com> Date: Fri, 20 Oct 2023 01:00:45 -0400 Subject: [PATCH 01/11] add NatsMemoryOwner (#162) Signed-off-by: Caleb Lloyd --- src/NATS.Client.Core/INatsSerializer.cs | 30 +- src/NATS.Client.Core/NatsMemoryOwner.cs | 376 ++++++++++++++++++ src/NATS.Client.ObjectStore/NatsObjStore.cs | 4 +- .../NatsKVWatcherTest.cs | 58 +-- tests/NATS.Client.Perf/Program.cs | 2 +- 5 files changed, 405 insertions(+), 65 deletions(-) create mode 100644 src/NATS.Client.Core/NatsMemoryOwner.cs diff --git a/src/NATS.Client.Core/INatsSerializer.cs b/src/NATS.Client.Core/INatsSerializer.cs index 7d476d50e..6f37c4b77 100644 --- a/src/NATS.Client.Core/INatsSerializer.cs +++ b/src/NATS.Client.Core/INatsSerializer.cs @@ -18,21 +18,6 @@ public interface ICountableBufferWriter : IBufferWriter int WrittenCount { get; } } -public readonly struct FixedSizeMemoryOwner : IMemoryOwner -{ - private readonly IMemoryOwner _owner; - - public FixedSizeMemoryOwner(IMemoryOwner owner, int size) - { - _owner = owner; - Memory = _owner.Memory.Slice(0, size); - } - - public Memory Memory { get; } - - public void Dispose() => _owner.Dispose(); -} - public static class NatsDefaultSerializer { public static readonly INatsSerializer Default = new NatsRawSerializer(NatsJsonSerializer.Default); @@ -118,9 +103,9 @@ public int Serialize(ICountableBufferWriter bufferWriter, T? value) return (T)(object)new ReadOnlySequence(buffer.ToArray()); } - if (typeof(T) == typeof(IMemoryOwner)) + if (typeof(T) == typeof(IMemoryOwner) || typeof(T) == typeof(NatsMemoryOwner)) { - var memoryOwner = new FixedSizeMemoryOwner(MemoryPool.Shared.Rent((int)buffer.Length), (int)buffer.Length); + var memoryOwner = NatsMemoryOwner.Allocate((int)buffer.Length); buffer.CopyTo(memoryOwner.Memory.Span); return (T)(object)memoryOwner; } @@ -134,11 +119,7 @@ public int Serialize(ICountableBufferWriter bufferWriter, T? value) public sealed class NatsJsonSerializer : INatsSerializer { - private static readonly JsonWriterOptions JsonWriterOpts = new JsonWriterOptions - { - Indented = false, - SkipValidation = true, - }; + private static readonly JsonWriterOptions JsonWriterOpts = new JsonWriterOptions { Indented = false, SkipValidation = true, }; [ThreadStatic] private static Utf8JsonWriter? _jsonWriter; @@ -148,10 +129,7 @@ public sealed class NatsJsonSerializer : INatsSerializer public NatsJsonSerializer(JsonSerializerOptions opts) => _opts = opts; public static NatsJsonSerializer Default { get; } = - new(new JsonSerializerOptions - { - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - }); + new(new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, }); public INatsSerializer? Next => default; diff --git a/src/NATS.Client.Core/NatsMemoryOwner.cs b/src/NATS.Client.Core/NatsMemoryOwner.cs new file mode 100644 index 000000000..625155afc --- /dev/null +++ b/src/NATS.Client.Core/NatsMemoryOwner.cs @@ -0,0 +1,376 @@ +// adapted from https://github.com/CommunityToolkit/dotnet/blob/v8.2.1/src/CommunityToolkit.HighPerformance/Buffers/MemoryOwner%7BT%7D.cs +// changed from class to struct for non-nullable deserialization + +namespace NATS.Client.Core; + +using System; +using System.Buffers; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +/// +/// An that indicates a mode to use when allocating buffers. +/// +public enum NatsMemoryOwnerAllocationMode +{ + /// + /// The default allocation mode for pooled memory (rented buffers are not cleared). + /// + Default, + + /// + /// Clear pooled buffers when renting them. + /// + Clear, +} + +/// +/// An implementation with an embedded length and a fast accessor. +/// +/// The type of items to store in the current instance. +public struct NatsMemoryOwner : IMemoryOwner +{ + /// + /// The starting offset within . + /// + private readonly int _start; + +#pragma warning disable IDE0032 + /// + /// The usable length within (starting from ). + /// + private readonly int _length; +#pragma warning restore IDE0032 + + /// + /// The instance used to rent . + /// + private readonly ArrayPool _pool; + + /// + /// The underlying array. + /// + private T[]? _array; + + /// + /// Initializes a new instance of the class with the specified parameters. + /// + /// The length of the new memory buffer to use. + /// The instance to use. + /// Indicates the allocation mode to use for the new buffer to rent. + private NatsMemoryOwner(int length, ArrayPool pool, NatsMemoryOwnerAllocationMode mode) + { + _start = 0; + this._length = length; + this._pool = pool; + _array = pool.Rent(length); + + if (mode == NatsMemoryOwnerAllocationMode.Clear) + { + _array.AsSpan(0, length).Clear(); + } + } + + /// + /// Initializes a new instance of the class with the specified parameters. + /// + /// The starting offset within . + /// The length of the array to use. + /// The instance currently in use. + /// The input array to use. + private NatsMemoryOwner(int start, int length, ArrayPool pool, T[] array) + { + this._start = start; + this._length = length; + this._pool = pool; + this._array = array; + } + + /// + /// Gets an empty instance. + /// + public static NatsMemoryOwner Empty + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => new(0, ArrayPool.Shared, NatsMemoryOwnerAllocationMode.Default); + } + + /// + /// Gets the number of items in the current instance + /// + public int Length + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => _length; + } + + /// + /// Creates a new instance with the specified parameters. + /// + /// The length of the new memory buffer to use. + /// A instance of the requested length. + /// Thrown when is not valid. + /// This method is just a proxy for the constructor, for clarity. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static NatsMemoryOwner Allocate(int size) => new(size, ArrayPool.Shared, NatsMemoryOwnerAllocationMode.Default); + + /// + /// Creates a new instance with the specified parameters. + /// + /// The length of the new memory buffer to use. + /// The instance currently in use. + /// A instance of the requested length. + /// Thrown when is not valid. + /// This method is just a proxy for the constructor, for clarity. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static NatsMemoryOwner Allocate(int size, ArrayPool pool) => new(size, pool, NatsMemoryOwnerAllocationMode.Default); + + /// + /// Creates a new instance with the specified parameters. + /// + /// The length of the new memory buffer to use. + /// Indicates the allocation mode to use for the new buffer to rent. + /// A instance of the requested length. + /// Thrown when is not valid. + /// This method is just a proxy for the constructor, for clarity. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static NatsMemoryOwner Allocate(int size, NatsMemoryOwnerAllocationMode mode) => new(size, ArrayPool.Shared, mode); + + /// + /// Creates a new instance with the specified parameters. + /// + /// The length of the new memory buffer to use. + /// The instance currently in use. + /// Indicates the allocation mode to use for the new buffer to rent. + /// A instance of the requested length. + /// Thrown when is not valid. + /// This method is just a proxy for the constructor, for clarity. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static NatsMemoryOwner Allocate(int size, ArrayPool pool, NatsMemoryOwnerAllocationMode mode) => new(size, pool, mode); + + /// + public Memory Memory + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get + { + var array = this._array; + + if (array is null) + { + ThrowObjectDisposedException(); + } + + return new(array!, _start, _length); + } + } + + /// + /// Gets a wrapping the memory belonging to the current instance. + /// + public Span Span + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get + { + var array = this._array; + + if (array is null) + { + ThrowObjectDisposedException(); + } + + ref var r0 = ref array!.DangerousGetReferenceAt(_start); + + // On .NET 6+ runtimes, we can manually create a span from the starting reference to + // skip the argument validations, which include an explicit null check, covariance check + // for the array and the actual validation for the starting offset and target length. We + // only do this on .NET 6+ as we can leverage the runtime-specific array layout to get + // a fast access to the initial element, which makes this trick worth it. Otherwise, on + // runtimes where we would need to at least access a static field to retrieve the base + // byte offset within an SZ array object, we can get better performance by just using the + // default Span constructor and paying the cost of the extra conditional branches, + // especially if T is a value type, in which case the covariance check is JIT removed. + return MemoryMarshal.CreateSpan(ref r0, _length); + } + } + + /// + /// Returns a reference to the first element within the current instance, with no bounds check. + /// + /// A reference to the first element within the current instance. + /// Thrown when the buffer in use has already been disposed. + /// + /// This method does not perform bounds checks on the underlying buffer, but does check whether + /// the buffer itself has been disposed or not. This check should not be removed, and it's also + /// the reason why the method to get a reference at a specified offset is not present. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public ref T DangerousGetReference() + { + var array = this._array; + + if (array is null) + { + ThrowObjectDisposedException(); + } + + return ref array!.DangerousGetReferenceAt(_start); + } + + /// + /// Gets an instance wrapping the underlying array in use. + /// + /// An instance wrapping the underlying array in use. + /// Thrown when the buffer in use has already been disposed. + /// + /// This method is meant to be used when working with APIs that only accept an array as input, and should be used with caution. + /// In particular, the returned array is rented from an array pool, and it is responsibility of the caller to ensure that it's + /// not used after the current instance is disposed. Doing so is considered undefined behavior, + /// as the same array might be in use within another instance. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public ArraySegment DangerousGetArray() + { + var array = this._array; + + if (array is null) + { + ThrowObjectDisposedException(); + } + + return new(array!, _start, _length); + } + + /// + /// Slices the buffer currently in use and returns a new instance. + /// + /// The starting offset within the current buffer. + /// The length of the buffer to use. + /// A new instance using the target range of items. + /// Thrown when the buffer in use has already been disposed. + /// Thrown when or are not valid. + /// + /// Using this method will dispose the current instance, and should only be used when an oversized + /// buffer is rented and then adjusted in size, to avoid having to rent a new buffer of the new + /// size and copy the previous items into the new one, or needing an additional variable/field + /// to manually handle to track the used range within a given instance. + /// + public NatsMemoryOwner Slice(int start, int length) + { + var array = this._array; + + if (array is null) + { + ThrowObjectDisposedException(); + } + + this._array = null; + + if ((uint)start > this._length) + { + ThrowInvalidOffsetException(); + } + + if ((uint)length > (this._length - start)) + { + ThrowInvalidLengthException(); + } + + // We're transferring the ownership of the underlying array, so the current + // instance no longer needs to be disposed. Because of this, we can manually + // suppress the finalizer to reduce the overhead on the garbage collector. + GC.SuppressFinalize(this); + + return new(start, length, _pool, array!); + } + + /// + public void Dispose() + { + var array = this._array; + + if (array is null) + { + return; + } + + this._array = null; + + _pool.Return(array); + } + + /// + public override string ToString() + { + // Normally we would throw if the array has been disposed, + // but in this case we'll just return the non formatted + // representation as a fallback, since the ToString method + // is generally expected not to throw exceptions. + if (typeof(T) == typeof(char) && + _array is char[] chars) + { + return new(chars, _start, _length); + } + + // Same representation used in Span + return $"CommunityToolkit.HighPerformance.Buffers.MemoryOwner<{typeof(T)}>[{_length}]"; + } + + /// + /// Throws an when is . + /// + private static void ThrowObjectDisposedException() + { + throw new ObjectDisposedException(nameof(NatsMemoryOwner), "The current buffer has already been disposed"); + } + + /// + /// Throws an when the is invalid. + /// + private static void ThrowInvalidOffsetException() + { + throw new ArgumentOutOfRangeException(nameof(_start), "The input start parameter was not valid"); + } + + /// + /// Throws an when the is invalid. + /// + private static void ThrowInvalidLengthException() + { + throw new ArgumentOutOfRangeException(nameof(_length), "The input length parameter was not valid"); + } +} + +internal static class NatsMemoryOwnerArrayExtensions +{ + /// + /// Returns a reference to the first element within a given array, with no bounds checks. + /// + /// The type of elements in the input array instance. + /// The input array instance. + /// A reference to the first element within , or the location it would have used, if is empty. + /// This method doesn't do any bounds checks, therefore it is responsibility of the caller to perform checks in case the returned value is dereferenced. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ref T DangerousGetReference(this T[] array) + { + return ref MemoryMarshal.GetArrayDataReference(array); + } + + /// + /// Returns a reference to an element at a specified index within a given array, with no bounds checks. + /// + /// The type of elements in the input array instance. + /// The input array instance. + /// The index of the element to retrieve within . + /// A reference to the element within at the index specified by . + /// This method doesn't do any bounds checks, therefore it is responsibility of the caller to ensure the parameter is valid. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ref T DangerousGetReferenceAt(this T[] array, int i) + { + ref var r0 = ref MemoryMarshal.GetArrayDataReference(array); + ref var ri = ref Unsafe.Add(ref r0, (nint)(uint)i); + + return ref ri; + } +} diff --git a/src/NATS.Client.ObjectStore/NatsObjStore.cs b/src/NATS.Client.ObjectStore/NatsObjStore.cs index b703b2508..d347a73b0 100644 --- a/src/NATS.Client.ObjectStore/NatsObjStore.cs +++ b/src/NATS.Client.ObjectStore/NatsObjStore.cs @@ -205,7 +205,7 @@ public async ValueTask PutAsync(ObjectMetadata meta, Stream stre { while (true) { - var memoryOwner = new FixedSizeMemoryOwner(MemoryPool.Shared.Rent(chunkSize), chunkSize); + var memoryOwner = NatsMemoryOwner.Allocate(chunkSize); var memory = memoryOwner.Memory; var currentChunkSize = 0; @@ -239,7 +239,7 @@ public async ValueTask PutAsync(ObjectMetadata meta, Stream stre chunks++; } - var buffer = new FixedSizeMemoryOwner(memoryOwner, currentChunkSize); + var buffer = memoryOwner.Slice(0, currentChunkSize); // Chunks var ack = await _context.PublishAsync(GetChunkSubject(nuid), buffer, cancellationToken: cancellationToken); diff --git a/tests/NATS.Client.KeyValueStore.Tests/NatsKVWatcherTest.cs b/tests/NATS.Client.KeyValueStore.Tests/NatsKVWatcherTest.cs index 52fda59a5..ecc6603c7 100644 --- a/tests/NATS.Client.KeyValueStore.Tests/NatsKVWatcherTest.cs +++ b/tests/NATS.Client.KeyValueStore.Tests/NatsKVWatcherTest.cs @@ -31,7 +31,7 @@ public async Task Watcher_reconnect_with_history() var js2 = new NatsJSContext(nats2); var kv2 = new NatsKVContext(js2); var store2 = await kv2.CreateStoreAsync(config, cancellationToken: cancellationToken); - var watcher = await store2.WatchAsync>("k1.*", cancellationToken: cancellationToken); + var watcher = await store2.WatchAsync>("k1.*", cancellationToken: cancellationToken); await store1.PutAsync("k1.p1", 1, cancellationToken); await store1.PutAsync("k1.p1", 2, cancellationToken); @@ -41,25 +41,18 @@ public async Task Watcher_reconnect_with_history() await foreach (var entry in watcher.Entries.ReadAllAsync(cancellationToken)) { - if (entry.Value is { } memoryOwner) + using (entry.Value) { - using (memoryOwner) + if (Utf8Parser.TryParse(entry.Value.Memory.Span, out int value, out _)) { - if (Utf8Parser.TryParse(memoryOwner.Memory.Span, out int value, out _)) - { - Assert.Equal(++count, value); - if (value == 3) - break; - } - else - { - Assert.Fail("Not a number (1)"); - } + Assert.Equal(++count, value); + if (value == 3) + break; + } + else + { + Assert.Fail("Not a number (1)"); } - } - else - { - throw new Exception("Null value (1)"); } } @@ -163,7 +156,7 @@ public async Task Watcher_timeout_reconnect() var js2 = new NatsJSContext(nats2); var kv2 = new NatsKVContext(js2); var store2 = await kv2.CreateStoreAsync(bucket, cancellationToken: cancellationToken); - var watcher = await store2.WatchAsync>("k1.*", cancellationToken: cancellationToken); + var watcher = await store2.WatchAsync>("k1.*", cancellationToken: cancellationToken); // Swallow heartbeats proxy.ServerInterceptors.Add(m => m?.Contains("Idle Heartbeat") ?? false ? null : m); @@ -177,29 +170,22 @@ public async Task Watcher_timeout_reconnect() await store1.PutAsync("k1.p1", 2, cancellationToken); await store1.PutAsync("k1.p1", 3, cancellationToken); - var consumer1 = ((NatsKVWatcher>)watcher).Consumer; + var consumer1 = ((NatsKVWatcher>)watcher).Consumer; await foreach (var entry in watcher.Entries.ReadAllAsync(cancellationToken)) { - if (entry.Value is { } memoryOwner) + using (entry.Value) { - using (memoryOwner) + if (Utf8Parser.TryParse(entry.Value.Memory.Span, out int value, out _)) { - if (Utf8Parser.TryParse(memoryOwner.Memory.Span, out int value, out _)) - { - Assert.Equal(++count, value); - if (value == 3) - break; - } - else - { - Assert.Fail("Not a number (1)"); - } + Assert.Equal(++count, value); + if (value == 3) + break; + } + else + { + Assert.Fail("Not a number (1)"); } - } - else - { - throw new Exception("Null value (1)"); } } @@ -223,7 +209,7 @@ public async Task Watcher_timeout_reconnect() await Retry.Until( reason: "consumer changed", - condition: () => consumer1 != ((NatsKVWatcher>)watcher).Consumer, + condition: () => consumer1 != ((NatsKVWatcher>)watcher).Consumer, retryDelay: TimeSpan.FromSeconds(1), timeout: timeout); diff --git a/tests/NATS.Client.Perf/Program.cs b/tests/NATS.Client.Perf/Program.cs index 02417b99e..dd3ea909f 100644 --- a/tests/NATS.Client.Perf/Program.cs +++ b/tests/NATS.Client.Perf/Program.cs @@ -29,7 +29,7 @@ await nats1.PingAsync(); await nats2.PingAsync(); -await using var sub = await nats1.SubscribeAsync>(t.Subject); +await using var sub = await nats1.SubscribeAsync>(t.Subject); var stopwatch = Stopwatch.StartNew(); From 0347d6c76e33723721e71be32bbc9abd64f64c4d Mon Sep 17 00:00:00 2001 From: Ziya Suzen Date: Fri, 20 Oct 2023 06:19:24 +0100 Subject: [PATCH 02/11] Consume clean exit fixes (#161) * Consume clean exit fixes * Enlarge subscription channel to avoid blocking other socket reads on the same connection. * Pass cancellation token to Consume() so it can be gracefully stopped. * 'Consume' can also be gracefully stopped when Stop() is called. * There is no graceful stop for 'Fetch' since we have no option but wait for the single pull request to complete. * Disposing the 'Consume' or 'Fetch' would exit loops a.s.a.p. * Use new memory owner for example --- .../Example.JetStream.PullConsumer/Program.cs | 92 ++++++++++---- .../Example.JetStream.PullConsumer/RawData.cs | 12 -- .../RawDataSerializer.cs | 28 ----- src/NATS.Client.JetStream/INatsJSConsume.cs | 28 +++-- src/NATS.Client.JetStream/INatsJSFetch.cs | 13 +- .../Internal/NatsJSConsume.cs | 65 +++++++++- .../Internal/NatsJSFetch.cs | 16 ++- src/NATS.Client.JetStream/NatsJSConsumer.cs | 44 +++---- src/NATS.Client.JetStream/NatsJSLogEvents.cs | 1 + tests/NATS.Client.Core.Tests/TlsFirstTest.cs | 1 - .../ConsumerConsumeTest.cs | 112 +++++++++++++++++- .../ConsumerFetchTest.cs | 57 +++++++++ 12 files changed, 355 insertions(+), 114 deletions(-) delete mode 100644 sandbox/Example.JetStream.PullConsumer/RawData.cs delete mode 100644 sandbox/Example.JetStream.PullConsumer/RawDataSerializer.cs diff --git a/sandbox/Example.JetStream.PullConsumer/Program.cs b/sandbox/Example.JetStream.PullConsumer/Program.cs index 5ca11f716..cfcef3f33 100644 --- a/sandbox/Example.JetStream.PullConsumer/Program.cs +++ b/sandbox/Example.JetStream.PullConsumer/Program.cs @@ -1,5 +1,6 @@ +using System.Buffers; using System.Diagnostics; -using Example.JetStream.PullConsumer; +using System.Text; using Microsoft.Extensions.Logging; using NATS.Client.Core; using NATS.Client.JetStream; @@ -20,12 +21,12 @@ var consumer = await js.CreateConsumerAsync("s1", "c1"); -var idle = TimeSpan.FromSeconds(15); -var expires = TimeSpan.FromSeconds(30); +var idle = TimeSpan.FromSeconds(5); +var expires = TimeSpan.FromSeconds(10); // int? maxMsgs = null; // int? maxBytes = 128; -int? maxMsgs = 1000; +int? maxMsgs = 10; int? maxBytes = null; void Report(int i, Stopwatch sw, string data) @@ -41,7 +42,6 @@ void Report(int i, Stopwatch sw, string data) MaxBytes = maxBytes, Expires = expires, IdleHeartbeat = idle, - Serializer = new RawDataSerializer(), }; var fetchOpts = new NatsJSFetchOpts @@ -50,22 +50,23 @@ void Report(int i, Stopwatch sw, string data) MaxBytes = maxBytes, Expires = expires, IdleHeartbeat = idle, - Serializer = new RawDataSerializer(), }; var nextOpts = new NatsJSNextOpts { Expires = expires, IdleHeartbeat = idle, - Serializer = new RawDataSerializer(), }; var stopwatch = Stopwatch.StartNew(); var count = 0; +var cmd = args.Length > 0 ? args[0] : "consume"; +var cmdOpt = args.Length > 1 ? args[1] : "none"; + try { - if (args.Length > 0 && args[0] == "fetch") + if (cmd == "fetch") { while (!cts.Token.IsCancellationRequested) { @@ -73,9 +74,15 @@ void Report(int i, Stopwatch sw, string data) { Console.WriteLine($"___\nFETCH {maxMsgs}"); await consumer.RefreshAsync(cts.Token); - await using var sub = await consumer.FetchAsync(fetchOpts, cts.Token); + await using var sub = await consumer.FetchAsync>(fetchOpts, cts.Token); await foreach (var msg in sub.Msgs.ReadAllAsync(cts.Token)) { + using (msg.Data) + { + var message = Encoding.ASCII.GetString(msg.Data.Span); + Console.WriteLine($"Received: {message}"); + } + await msg.AckAsync(cancellationToken: cts.Token); Report(++count, stopwatch, $"data: {msg.Data}"); } @@ -91,7 +98,7 @@ void Report(int i, Stopwatch sw, string data) } } } - else if (args.Length > 0 && args[0] == "fetch-all") + else if (cmd == "fetch-all") { while (!cts.Token.IsCancellationRequested) { @@ -99,8 +106,14 @@ void Report(int i, Stopwatch sw, string data) { Console.WriteLine($"___\nFETCH {maxMsgs}"); await consumer.RefreshAsync(cts.Token); - await foreach (var msg in consumer.FetchAllAsync(fetchOpts, cts.Token)) + await foreach (var msg in consumer.FetchAllAsync>(fetchOpts, cts.Token)) { + using (msg.Data) + { + var message = Encoding.ASCII.GetString(msg.Data.Span); + Console.WriteLine($"Received: {message}"); + } + await msg.AckAsync(cancellationToken: cts.Token); Report(++count, stopwatch, $"data: {msg.Data}"); } @@ -116,16 +129,22 @@ void Report(int i, Stopwatch sw, string data) } } } - else if (args.Length > 0 && args[0] == "next") + else if (cmd == "next") { while (!cts.Token.IsCancellationRequested) { try { Console.WriteLine("___\nNEXT"); - var next = await consumer.NextAsync(nextOpts, cts.Token); + var next = await consumer.NextAsync>(nextOpts, cts.Token); if (next is { } msg) { + using (msg.Data) + { + var message = Encoding.ASCII.GetString(msg.Data.Span); + Console.WriteLine($"Received: {message}"); + } + await msg.AckAsync(cancellationToken: cts.Token); Report(++count, stopwatch, $"data: {msg.Data}"); } @@ -141,21 +160,48 @@ void Report(int i, Stopwatch sw, string data) } } } - else if (args.Length > 0 && args[0] == "consume") + else if (cmd == "consume") { while (!cts.Token.IsCancellationRequested) { try { Console.WriteLine("___\nCONSUME"); - await using var sub = await consumer.ConsumeAsync( - consumeOpts, - cts.Token); + await using var sub = await consumer.ConsumeAsync>(consumeOpts); - await foreach (var msg in sub.Msgs.ReadAllAsync(cts.Token)) + cts.Token.Register(() => + { + sub.DisposeAsync().GetAwaiter().GetResult(); + }); + + var stopped = false; + await foreach (var msg in sub.Msgs.ReadAllAsync()) { + using (msg.Data) + { + var message = Encoding.ASCII.GetString(msg.Data.Span); + Console.WriteLine($"Received: {message}"); + if (message == "stop") + { + Console.WriteLine("Stopping consumer..."); + sub.Stop(); + stopped = true; + } + } + await msg.AckAsync(cancellationToken: cts.Token); Report(++count, stopwatch, $"data: {msg.Data}"); + + if (cmdOpt == "with-pause") + { + await Task.Delay(1_000); + } + } + + if (stopped) + { + Console.WriteLine("Stopped consumer."); + break; } } catch (NatsJSProtocolException e) @@ -169,15 +215,21 @@ void Report(int i, Stopwatch sw, string data) } } } - else if (args.Length > 0 && args[0] == "consume-all") + else if (cmd == "consume-all") { while (!cts.Token.IsCancellationRequested) { try { Console.WriteLine("___\nCONSUME-ALL"); - await foreach (var msg in consumer.ConsumeAllAsync(consumeOpts, cts.Token)) + await foreach (var msg in consumer.ConsumeAllAsync>(consumeOpts, cts.Token)) { + using (msg.Data) + { + var message = Encoding.ASCII.GetString(msg.Data.Span); + Console.WriteLine($"Received: {message}"); + } + await msg.AckAsync(cancellationToken: cts.Token); Report(++count, stopwatch, $"data: {msg.Data}"); } diff --git a/sandbox/Example.JetStream.PullConsumer/RawData.cs b/sandbox/Example.JetStream.PullConsumer/RawData.cs deleted file mode 100644 index 89e12b76e..000000000 --- a/sandbox/Example.JetStream.PullConsumer/RawData.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Text; - -namespace Example.JetStream.PullConsumer; - -public class RawData -{ - public RawData(byte[] buffer) => Buffer = buffer; - - public byte[] Buffer { get; } - - public override string ToString() => Encoding.ASCII.GetString(Buffer); -} diff --git a/sandbox/Example.JetStream.PullConsumer/RawDataSerializer.cs b/sandbox/Example.JetStream.PullConsumer/RawDataSerializer.cs deleted file mode 100644 index efdfb15fa..000000000 --- a/sandbox/Example.JetStream.PullConsumer/RawDataSerializer.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.Buffers; -using NATS.Client.Core; - -namespace Example.JetStream.PullConsumer; - -public class RawDataSerializer : INatsSerializer -{ - public INatsSerializer? Next => default; - - public int Serialize(ICountableBufferWriter bufferWriter, T? value) - { - if (value is RawData data) - { - bufferWriter.Write(data.Buffer); - return data.Buffer.Length; - } - - throw new Exception($"Can only work with '{typeof(RawData)}'"); - } - - public T? Deserialize(in ReadOnlySequence buffer) - { - if (typeof(T) != typeof(RawData)) - throw new Exception($"Can only work with '{typeof(RawData)}'"); - - return (T)(object)new RawData(buffer.ToArray()); - } -} diff --git a/src/NATS.Client.JetStream/INatsJSConsume.cs b/src/NATS.Client.JetStream/INatsJSConsume.cs index bae608922..92302c7ef 100644 --- a/src/NATS.Client.JetStream/INatsJSConsume.cs +++ b/src/NATS.Client.JetStream/INatsJSConsume.cs @@ -2,18 +2,28 @@ namespace NATS.Client.JetStream; -/// -/// Interface to manage a consume() operation on a consumer. -/// -public interface INatsJSConsume : IAsyncDisposable -{ - void Stop(); -} - /// /// Interface to extract messages from a consume() operation on a consumer. /// -public interface INatsJSConsume : INatsJSConsume +public interface INatsJSConsume : IAsyncDisposable { + /// + /// Messages received from the consumer. + /// ChannelReader> Msgs { get; } + + /// + /// Stop the consumer gracefully. + /// + /// + /// + /// This will wait for any inflight messages to be processed before stopping. + /// + /// + /// Disposing would stop consuming immediately. This might leave messages behind + /// without acknowledgement. Which is fine, messages will be scheduled for redelivery, + /// however, it might not be the desired behavior. + /// + /// + void Stop(); } diff --git a/src/NATS.Client.JetStream/INatsJSFetch.cs b/src/NATS.Client.JetStream/INatsJSFetch.cs index e835678e2..8a551d541 100644 --- a/src/NATS.Client.JetStream/INatsJSFetch.cs +++ b/src/NATS.Client.JetStream/INatsJSFetch.cs @@ -2,18 +2,13 @@ namespace NATS.Client.JetStream; -/// -/// Interface to manage a fetch() operation on a consumer. -/// -public interface INatsJSFetch : IAsyncDisposable -{ - void Stop(); -} - /// /// Interface to extract messages from a fetch() operation on a consumer. /// -public interface INatsJSFetch : INatsJSFetch +public interface INatsJSFetch : IAsyncDisposable { + /// + /// User messages received from the consumer. + /// ChannelReader> Msgs { get; } } diff --git a/src/NATS.Client.JetStream/Internal/NatsJSConsume.cs b/src/NATS.Client.JetStream/Internal/NatsJSConsume.cs index 081e50eb8..eee5a9dd1 100644 --- a/src/NATS.Client.JetStream/Internal/NatsJSConsume.cs +++ b/src/NATS.Client.JetStream/Internal/NatsJSConsume.cs @@ -19,11 +19,13 @@ internal class NatsJSConsume : NatsSubBase, INatsJSConsume { private readonly ILogger _logger; private readonly bool _debug; + private readonly CancellationTokenSource _cts; private readonly Channel> _userMsgs; private readonly Channel _pullRequests; private readonly NatsJSContext _context; private readonly string _stream; private readonly string _consumer; + private readonly CancellationToken _cancellationToken; private readonly INatsSerializer _serializer; private readonly Timer _timer; private readonly Task _pullTask; @@ -39,6 +41,7 @@ internal class NatsJSConsume : NatsSubBase, INatsJSConsume private readonly object _pendingGate = new(); private long _pendingMsgs; private long _pendingBytes; + private int _disposed; public NatsJSConsume( long maxMsgs, @@ -52,9 +55,12 @@ public NatsJSConsume( string consumer, string subject, string? queueGroup, - NatsSubOpts? opts) + NatsSubOpts? opts, + CancellationToken cancellationToken) : base(context.Connection, context.Connection.SubscriptionManager, subject, queueGroup, opts) { + _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _cancellationToken = _cts.Token; _logger = Connection.Opts.LoggerFactory.CreateLogger>(); _debug = _logger.IsEnabled(LogLevel.Debug); _context = context; @@ -91,6 +97,15 @@ public NatsJSConsume( static state => { var self = (NatsJSConsume)state!; + + if (self._cancellationToken.IsCancellationRequested) + { + // We complete stop here since heartbeat timeout would kick in + // when there are no pull requests or messages left in-flight. + self.CompleteStop(); + return; + } + self.Pull("heartbeat-timeout", self._maxMsgs, self._maxBytes); self.ResetPending(); if (self._debug) @@ -105,10 +120,17 @@ public NatsJSConsume( Timeout.Infinite, Timeout.Infinite); - _userMsgs = Channel.CreateBounded>(NatsSubUtils.GetChannelOpts(opts?.ChannelOpts)); + // Keep user channel small to avoid blocking the user code + // when disposed otherwise channel reader will continue delivering messages + // if there are messages queued up already. This channel is used to pass messages + // to the user from the subscription channel (which should be set to a + // sufficiently large value to avoid blocking socket reads in the + // NATS connection). + _userMsgs = Channel.CreateBounded>(1); Msgs = _userMsgs.Reader; - _pullRequests = Channel.CreateBounded(NatsSubUtils.GetChannelOpts(opts?.ChannelOpts)); + // Capacity as 1 is enough here since it's used for signaling only. + _pullRequests = Channel.CreateBounded(1); _pullTask = Task.Run(PullLoop); ResetPending(); @@ -116,10 +138,13 @@ public NatsJSConsume( public ChannelReader> Msgs { get; } - public void Stop() => EndSubscription(NatsSubEndReason.None); + public void Stop() => _cts.Cancel(); public ValueTask CallMsgNextAsync(string origin, ConsumerGetnextRequest request, CancellationToken cancellationToken = default) { + if (_cancellationToken.IsCancellationRequested) + return default; + if (_debug) { _logger.LogDebug("Sending pull request for {Origin} {Msgs}, {Bytes}", origin, request.Batch, request.MaxBytes); @@ -138,6 +163,7 @@ public ValueTask CallMsgNextAsync(string origin, ConsumerGetnextRequest request, public override async ValueTask DisposeAsync() { + Interlocked.Exchange(ref _disposed, 1); await base.DisposeAsync().ConfigureAwait(false); await _pullTask.ConfigureAwait(false); await _timer.DisposeAsync().ConfigureAwait(false); @@ -158,6 +184,9 @@ internal override IEnumerable GetReconnectCommands(int sid) Expires = _expires, }; + if (_cancellationToken.IsCancellationRequested) + yield break; + yield return PublishCommand.Create( pool: Connection.ObjectPool, subject: $"{_context.Opts.Prefix}.CONSUMER.MSG.NEXT.{_stream}.{_consumer}", @@ -311,7 +340,15 @@ protected override async ValueTask ReceiveInternalAsync( } } - await _userMsgs.Writer.WriteAsync(msg).ConfigureAwait(false); + // Stop feeding the user if we are disposed. + // We need to exit as soon as possible. + if (Volatile.Read(ref _disposed) == 0) + { + // We can't pass cancellation token here because we need to hand + // the message to the user to be processed. Writer will be completed + // when the user calls Stop() or when the subscription is closed. + await _userMsgs.Writer.WriteAsync(msg).ConfigureAwait(false); + } } CheckPending(); @@ -355,6 +392,24 @@ private void CheckPending() } } + private void CompleteStop() + { + if (_debug) + { + _logger.LogDebug(NatsJSLogEvents.Stopping, "No more pull requests or messages in-flight, stopping"); + } + + // Schedule on the thread pool to avoid potential deadlocks. + ThreadPool.UnsafeQueueUserWorkItem( + state => + { + var self = (NatsJSConsume)state!; + self._userMsgs.Writer.TryComplete(); + self.EndSubscription(NatsSubEndReason.None); + }, + this); + } + private void Pull(string origin, long batch, long maxBytes) => _pullRequests.Writer.TryWrite(new PullRequest { Request = new ConsumerGetnextRequest diff --git a/src/NATS.Client.JetStream/Internal/NatsJSFetch.cs b/src/NATS.Client.JetStream/Internal/NatsJSFetch.cs index 08fcf32e9..2d69b663d 100644 --- a/src/NATS.Client.JetStream/Internal/NatsJSFetch.cs +++ b/src/NATS.Client.JetStream/Internal/NatsJSFetch.cs @@ -28,6 +28,7 @@ internal class NatsJSFetch : NatsSubBase, INatsJSFetch private long _pendingMsgs; private long _pendingBytes; + private int _disposed; public NatsJSFetch( long maxMsgs, @@ -57,7 +58,10 @@ public NatsJSFetch( _pendingMsgs = _maxMsgs; _pendingBytes = _maxBytes; - _userMsgs = Channel.CreateBounded>(NatsSubUtils.GetChannelOpts(opts?.ChannelOpts)); + // Keep user channel small to avoid blocking the user code when disposed, + // otherwise channel reader will continue delivering messages even after + // this 'fetch' object being disposed. + _userMsgs = Channel.CreateBounded>(1); Msgs = _userMsgs.Reader; if (_debug) @@ -112,8 +116,6 @@ public NatsJSFetch( public ChannelReader> Msgs { get; } - public void Stop() => EndSubscription(NatsSubEndReason.None); - public ValueTask CallMsgNextAsync(ConsumerGetnextRequest request, CancellationToken cancellationToken = default) => Connection.PubModelAsync( subject: $"{_context.Opts.Prefix}.CONSUMER.MSG.NEXT.{_stream}.{_consumer}", @@ -127,6 +129,7 @@ public ValueTask CallMsgNextAsync(ConsumerGetnextRequest request, CancellationTo public override async ValueTask DisposeAsync() { + Interlocked.Exchange(ref _disposed, 1); await base.DisposeAsync().ConfigureAwait(false); await _hbTimer.DisposeAsync().ConfigureAwait(false); await _expiresTimer.DisposeAsync().ConfigureAwait(false); @@ -227,7 +230,12 @@ protected override async ValueTask ReceiveInternalAsync( _pendingMsgs--; _pendingBytes -= msg.Size; - await _userMsgs.Writer.WriteAsync(msg).ConfigureAwait(false); + // Stop feeding the user if we are disposed. + // We need to exit as soon as possible. + if (Volatile.Read(ref _disposed) == 0) + { + await _userMsgs.Writer.WriteAsync(msg).ConfigureAwait(false); + } } if (_maxBytes > 0 && _pendingBytes <= 0) diff --git a/src/NATS.Client.JetStream/NatsJSConsumer.cs b/src/NATS.Client.JetStream/NatsJSConsumer.cs index 0b50f4359..de55fcd6d 100644 --- a/src/NATS.Client.JetStream/NatsJSConsumer.cs +++ b/src/NATS.Client.JetStream/NatsJSConsumer.cs @@ -85,18 +85,7 @@ public async ValueTask> ConsumeAsync(NatsJSConsumeOpts? opt var max = NatsJSOptsDefaults.SetMax(opts.MaxMsgs, opts.MaxBytes, opts.ThresholdMsgs, opts.ThresholdBytes); var timeouts = NatsJSOptsDefaults.SetTimeouts(opts.Expires, opts.IdleHeartbeat); - var requestOpts = new NatsSubOpts - { - Serializer = opts.Serializer, - ChannelOpts = new NatsSubChannelOpts - { - // Keep capacity at 1 to make sure message acknowledgements are sent - // right after the message is processed and messages aren't queued up - // which might cause timeouts for acknowledgments. - Capacity = 1, - FullMode = BoundedChannelFullMode.Wait, - }, - }; + var requestOpts = BuildRequestOpts(opts.Serializer, opts.MaxMsgs); var sub = new NatsJSConsume( stream: _stream, @@ -110,7 +99,8 @@ public async ValueTask> ConsumeAsync(NatsJSConsumeOpts? opt thresholdMsgs: max.ThresholdMsgs, thresholdBytes: max.ThresholdBytes, expires: timeouts.Expires, - idle: timeouts.IdleHeartbeat); + idle: timeouts.IdleHeartbeat, + cancellationToken: cancellationToken); await _context.Connection.SubAsync(sub: sub, cancellationToken); @@ -228,18 +218,7 @@ public async ValueTask> FetchAsync( var max = NatsJSOptsDefaults.SetMax(opts.MaxMsgs, opts.MaxBytes); var timeouts = NatsJSOptsDefaults.SetTimeouts(opts.Expires, opts.IdleHeartbeat); - var requestOpts = new NatsSubOpts - { - Serializer = opts.Serializer, - ChannelOpts = new NatsSubChannelOpts - { - // Keep capacity at 1 to make sure message acknowledgements are sent - // right after the message is processed and messages aren't queued up - // which might cause timeouts for acknowledgments. - Capacity = 1, - FullMode = BoundedChannelFullMode.Wait, - }, - }; + var requestOpts = BuildRequestOpts(opts.Serializer, opts.MaxMsgs); var sub = new NatsJSFetch( stream: _stream, @@ -282,6 +261,21 @@ public async ValueTask RefreshAsync(CancellationToken cancellationToken = defaul request: null, cancellationToken).ConfigureAwait(false); + private static NatsSubOpts BuildRequestOpts(INatsSerializer? serializer, int? maxMsgs) => + new() + { + Serializer = serializer, + ChannelOpts = new NatsSubChannelOpts + { + // Keep capacity large enough not to block the socket reads. + // This might delay message acknowledgements on slow consumers + // but it's crucial to keep the reads flowing on the main + // NATS TCP connection. + Capacity = maxMsgs > 0 ? maxMsgs * 2 : 1_000, + FullMode = BoundedChannelFullMode.Wait, + }, + }; + private void ThrowIfDeleted() { if (_deleted) diff --git a/src/NATS.Client.JetStream/NatsJSLogEvents.cs b/src/NATS.Client.JetStream/NatsJSLogEvents.cs index af409a800..4a8660e72 100644 --- a/src/NATS.Client.JetStream/NatsJSLogEvents.cs +++ b/src/NATS.Client.JetStream/NatsJSLogEvents.cs @@ -17,4 +17,5 @@ public static class NatsJSLogEvents public static readonly EventId DeleteOldDeliverySubject = new(2011, nameof(DeleteOldDeliverySubject)); public static readonly EventId NewDeliverySubject = new(2012, nameof(NewDeliverySubject)); public static readonly EventId NewConsumerCreated = new(2013, nameof(NewConsumerCreated)); + public static readonly EventId Stopping = new(2014, nameof(Stopping)); } diff --git a/tests/NATS.Client.Core.Tests/TlsFirstTest.cs b/tests/NATS.Client.Core.Tests/TlsFirstTest.cs index 35f48921b..bbc624950 100644 --- a/tests/NATS.Client.Core.Tests/TlsFirstTest.cs +++ b/tests/NATS.Client.Core.Tests/TlsFirstTest.cs @@ -62,7 +62,6 @@ public async Task Implicit_TLS_fails_when_disabled() Assert.Matches(@"can not start to connect nats server", exception.Message); _output.WriteLine($"Implicit TLS connection rejected"); - } // Normal TLS connection should work diff --git a/tests/NATS.Client.JetStream.Tests/ConsumerConsumeTest.cs b/tests/NATS.Client.JetStream.Tests/ConsumerConsumeTest.cs index 97fcf9106..6136ed588 100644 --- a/tests/NATS.Client.JetStream.Tests/ConsumerConsumeTest.cs +++ b/tests/NATS.Client.JetStream.Tests/ConsumerConsumeTest.cs @@ -1,6 +1,7 @@ using System.Text.RegularExpressions; using Microsoft.Extensions.Logging; using NATS.Client.Core.Tests; +using NATS.Client.JetStream.Models; namespace NATS.Client.JetStream.Tests; @@ -154,7 +155,7 @@ await Retry.Until( [Fact] public async Task Consume_reconnect_test() { - var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3000)); + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); await using var server = NatsServer.StartJS(); var (nats, proxy) = server.CreateProxiedClientConnection(); @@ -226,6 +227,115 @@ await Retry.Until( await nats.DisposeAsync(); } + [Fact] + public async Task Consume_dispose_test() + { + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await using var server = NatsServer.StartJS(); + + await using var nats = server.CreateClientConnection(); + + var js = new NatsJSContext(nats); + var stream = await js.CreateStreamAsync("s1", new[] { "s1.*" }, cts.Token); + var consumer = await js.CreateConsumerAsync("s1", "c1", cancellationToken: cts.Token); + + var consumerOpts = new NatsJSConsumeOpts + { + MaxMsgs = 10, + IdleHeartbeat = TimeSpan.FromSeconds(5), + Expires = TimeSpan.FromSeconds(10), + }; + + for (var i = 0; i < 100; i++) + { + var ack = await js.PublishAsync("s1.foo", new TestData { Test = i }, cancellationToken: cts.Token); + ack.EnsureSuccess(); + } + + var cc = await consumer.ConsumeAsync(consumerOpts, cancellationToken: cts.Token); + + var signal = new WaitSignal(); + var reader = Task.Run(async () => + { + await foreach (var msg in cc.Msgs.ReadAllAsync(cts.Token)) + { + await msg.AckAsync(cancellationToken: cts.Token); + signal.Pulse(); + + // Introduce delay to make sure not all messages will be acked. + await Task.Delay(1_000, cts.Token); + } + }); + + await signal; + await cc.DisposeAsync(); + + await reader; + + var infos = new List(); + await foreach (var natsJSConsumer in stream.ListConsumersAsync(cts.Token)) + { + infos.Add(natsJSConsumer.Info); + } + + Assert.Single(infos); + + Assert.True(infos[0].NumAckPending > 0); + } + + [Fact] + public async Task Consume_stop_test() + { + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await using var server = NatsServer.StartJS(); + + await using var nats = server.CreateClientConnection(); + + var js = new NatsJSContext(nats); + var stream = await js.CreateStreamAsync("s1", new[] { "s1.*" }, cts.Token); + var consumer = await js.CreateConsumerAsync("s1", "c1", cancellationToken: cts.Token); + + var consumerOpts = new NatsJSConsumeOpts + { + MaxMsgs = 10, + IdleHeartbeat = TimeSpan.FromSeconds(2), + Expires = TimeSpan.FromSeconds(4), + }; + + for (var i = 0; i < 100; i++) + { + var ack = await js.PublishAsync("s1.foo", new TestData { Test = i }, cancellationToken: cts.Token); + ack.EnsureSuccess(); + } + + var cc = await consumer.ConsumeAsync(consumerOpts, cancellationToken: cts.Token); + + var signal = new WaitSignal(); + var reader = Task.Run(async () => + { + await foreach (var msg in cc.Msgs.ReadAllAsync(cts.Token)) + { + await msg.AckAsync(cancellationToken: cts.Token); + signal.Pulse(); + } + }); + + await signal; + cc.Stop(); + + await reader; + + var infos = new List(); + await foreach (var natsJSConsumer in stream.ListConsumersAsync(cts.Token)) + { + infos.Add(natsJSConsumer.Info); + } + + Assert.Single(infos); + + Assert.True(infos[0].NumAckPending == 0); + } + private record TestData { public int Test { get; init; } diff --git a/tests/NATS.Client.JetStream.Tests/ConsumerFetchTest.cs b/tests/NATS.Client.JetStream.Tests/ConsumerFetchTest.cs index 05d8c19cc..bc0223c63 100644 --- a/tests/NATS.Client.JetStream.Tests/ConsumerFetchTest.cs +++ b/tests/NATS.Client.JetStream.Tests/ConsumerFetchTest.cs @@ -1,4 +1,5 @@ using NATS.Client.Core.Tests; +using NATS.Client.JetStream.Models; namespace NATS.Client.JetStream.Tests; @@ -38,6 +39,62 @@ public async Task Fetch_test() Assert.Equal(10, count); } + [Fact] + public async Task Fetch_dispose_test() + { + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await using var server = NatsServer.StartJS(); + + await using var nats = server.CreateClientConnection(); + + var js = new NatsJSContext(nats); + var stream = await js.CreateStreamAsync("s1", new[] { "s1.*" }, cts.Token); + var consumer = await js.CreateConsumerAsync("s1", "c1", cancellationToken: cts.Token); + + var fetchOpts = new NatsJSFetchOpts + { + MaxMsgs = 10, + IdleHeartbeat = TimeSpan.FromSeconds(5), + Expires = TimeSpan.FromSeconds(10), + }; + + for (var i = 0; i < 100; i++) + { + var ack = await js.PublishAsync("s1.foo", new TestData { Test = i }, cancellationToken: cts.Token); + ack.EnsureSuccess(); + } + + var fc = await consumer.FetchAsync(fetchOpts, cancellationToken: cts.Token); + + var signal = new WaitSignal(); + var reader = Task.Run(async () => + { + await foreach (var msg in fc.Msgs.ReadAllAsync(cts.Token)) + { + await msg.AckAsync(cancellationToken: cts.Token); + signal.Pulse(); + + // Introduce delay to make sure not all messages will be acked. + await Task.Delay(1_000, cts.Token); + } + }); + + await signal; + await fc.DisposeAsync(); + + await reader; + + var infos = new List(); + await foreach (var natsJSConsumer in stream.ListConsumersAsync(cts.Token)) + { + infos.Add(natsJSConsumer.Info); + } + + Assert.Single(infos); + + Assert.True(infos[0].NumAckPending > 0); + } + private record TestData { public int Test { get; init; } From 005f209665ad81e099c3ebebd2008cbf0e5e99e5 Mon Sep 17 00:00:00 2001 From: Ziya Suzen Date: Fri, 20 Oct 2023 06:26:40 +0100 Subject: [PATCH 03/11] Object Store fixes and docs (#160) * Object Store fixes and docs * Fixed buffer resize on writes larger than default 64K * Added flow control handling * Fixed re-putting a deleted object issue * Added Object Store documentation * Fixed test --- docs/documentation/intro.md | 2 + docs/documentation/object-store/intro.md | 103 ++++++++++++++++++ docs/documentation/toc.yml | 3 + docs/index.md | 3 +- sandbox/Example.ObjectStore/.gitignore | 1 + sandbox/Example.ObjectStore/Program.cs | 59 +++++----- src/NATS.Client.Core/INatsSerializer.cs | 10 +- .../Internal/FixedArrayBufferWriter.cs | 4 +- src/NATS.Client.Core/NatsMsg.cs | 14 +++ .../Internal/NatsJSOrderedPushConsumer.cs | 14 ++- src/NATS.Client.JetStream/NatsJSMsg.cs | 11 ++ src/NATS.Client.ObjectStore/NatsObjContext.cs | 9 ++ .../NatsObjException.cs | 15 +++ src/NATS.Client.ObjectStore/NatsObjStore.cs | 29 +++-- .../FixedArrayBufferWriterTest.cs | 26 +++++ .../ObjectStoreTest.cs | 41 ++++++- 16 files changed, 299 insertions(+), 45 deletions(-) create mode 100644 docs/documentation/object-store/intro.md create mode 100644 sandbox/Example.ObjectStore/.gitignore diff --git a/docs/documentation/intro.md b/docs/documentation/intro.md index 2a49ff5d9..f8ff7b6c1 100644 --- a/docs/documentation/intro.md +++ b/docs/documentation/intro.md @@ -15,3 +15,5 @@ these docs. You can also create a Pull Request using the Edit on GitHub link on [JetStream](jetstream/intro.md) is the built-in distributed persistence system built-in to the same NATS server binary. [Key/Value Store](key-value-store/intro.md) is the built-in distributed persistent associative arrays built on top of JetStream. + +[Object Store](object-store/intro.md) is the built-in distributed persistent objects of arbitrary size built on top of JetStream. diff --git a/docs/documentation/object-store/intro.md b/docs/documentation/object-store/intro.md new file mode 100644 index 000000000..ec32ee011 --- /dev/null +++ b/docs/documentation/object-store/intro.md @@ -0,0 +1,103 @@ +# Object Store + +[The Object Store](https://docs.nats.io/nats-concepts/jetstream/obj_store) is very similar to the Key Value Store in that you put and get data using a key. +The difference being that Object store allows for the storage of objects that can be of any size. + +Under the covers Object Store is a client side construct that allows you to store and retrieve chunks of data +by a key using JetStream as the stream persistence engine. It's a simple, yet powerful way to store +and retrieve large data like files. + +To be able to use Object Store you need to enable JetStream by running the server with `-js` flag e.g. `nats-server -js`. + +## Object Store Quick Start + +[Download the latest](https://nats.io/download/) `nats-server` for your platform and run it with JetStream enabled: + +```shell +$ nats-server -js +``` + +Install `NATS.Client.ObjectStore` preview from Nuget. + +Before we can do anything, we need an Object Store context: + +```csharp +await using var nats = new NatsConnection(); +var js = new NatsJSContext(nats); +var obj = new NatsObjContext(js); +``` + +Let's create our store first. In Object Store, a bucket is simply a storage for key/object pairs: + +```csharp +var store = await obj.CreateObjectStore("test-bucket"); +``` + +Now that we have a KV bucket in our stream, let's see its status using the [NATS command +line client](https://github.com/nats-io/natscli): + +```shell +$ nats object ls +╭──────────────────────────────────────────────────────────────────────╮ +│ Object Store Buckets │ +├─────────────┬─────────────┬─────────────────────┬──────┬─────────────┤ +│ Bucket │ Description │ Created │ Size │ Last Update │ +├─────────────┼─────────────┼─────────────────────┼──────┼─────────────┤ +│ test-bucket │ │ 2023-10-18 14:10:27 │ 0 B │ never │ +╰─────────────┴─────────────┴─────────────────────┴──────┴─────────────╯ +``` + +We can save objects in a bucket by putting them using a key, which is `my/random/data.bin` in our case. We can also retrieve the +saved value by its key: + +```csharp +await store.PutAsync("my/random/data.bin", File.OpenRead("data.bin")); + +await store.GetAsync("my/random/data.bin", File.OpenWrite("data_copy.bin")); +``` + +We can also confirm that our value is persisted by using the NATS command line: + +```shell +$ nats object info test-bucket my/random/data.bin +Object information for test-bucket > my/random/data.bin + + Size: 10 MiB + Modification Time: 18 Oct 23 14:54 +0000 + Chunks: 80 + Digest: SHA-256 d34334673e4e2b2300c09550faa5e2b6d0f04245a1d0b664454bb922da56 +``` + +## Other Operations + +### Get Info + +We can get information about a key in a bucket: + +```csharp +var metadata = await store.GetInfoAsync("my/random/data.bin"); + +Console.WriteLine("Metadata:"); +Console.WriteLine($" Bucket: {metadata.Bucket}"); +Console.WriteLine($" Name: {metadata.Name}"); +Console.WriteLine($" Size: {metadata.Size}"); +Console.WriteLine($" Time: {metadata.MTime}"); +Console.WriteLine($" Chunks: {metadata.Chunks}"); + +// Outputs: +// Metadata: +// Bucket: test-bucket +// Name: my/random/data.bin +// Size: 10485760 +// Time: 18/10/2023 15:13:22 +00:00 +// Chunks: 80 + +``` + +### Delete + +We can delete a key from a bucket: + +```csharp +await store.DeleteAsync("my/random/data.bin"); +``` diff --git a/docs/documentation/toc.yml b/docs/documentation/toc.yml index 8f166681b..51d58d936 100644 --- a/docs/documentation/toc.yml +++ b/docs/documentation/toc.yml @@ -24,5 +24,8 @@ - name: Key/Value Store href: key-value-store/intro.md +- name: Object Store + href: object-store/intro.md + - name: Updating Documentation href: update-docs.md diff --git a/docs/index.md b/docs/index.md index 5ce93393a..577b2a0ab 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,7 +11,8 @@ The NATS.NET V2 client is in preview and not recommended for production use yet. - [x] Core NATS - [x] JetStream initial support - [x] KV initial support -- [ ] Object Store initial support +- [x] Object Store initial support +- [ ] Service API initial support - [ ] .NET 8.0 support (e.g. Native AOT) - [ ] Beta phase diff --git a/sandbox/Example.ObjectStore/.gitignore b/sandbox/Example.ObjectStore/.gitignore new file mode 100644 index 000000000..3f1bc6e12 --- /dev/null +++ b/sandbox/Example.ObjectStore/.gitignore @@ -0,0 +1 @@ +data* diff --git a/sandbox/Example.ObjectStore/Program.cs b/sandbox/Example.ObjectStore/Program.cs index ee537d297..465f9e54f 100644 --- a/sandbox/Example.ObjectStore/Program.cs +++ b/sandbox/Example.ObjectStore/Program.cs @@ -1,41 +1,48 @@ -using System.Text; +using System.Security.Cryptography; +using Microsoft.Extensions.Logging; using NATS.Client.Core; using NATS.Client.JetStream; using NATS.Client.ObjectStore; -using NATS.Client.ObjectStore.Models; -var nats = new NatsConnection(); +var opts = NatsOpts.Default with { LoggerFactory = new MinimumConsoleLoggerFactory(LogLevel.Error) }; + +var nats = new NatsConnection(opts); var js = new NatsJSContext(nats); -var ob = new NatsObjContext(js); +var obj = new NatsObjContext(js); -var store = await ob.CreateObjectStore(new NatsObjConfig("o1")); +Log("Create object store..."); +var store = await obj.CreateObjectStore("test-bucket"); -var meta = new ObjectMetadata { Name = "k1", Options = new Options { MaxChunkSize = 10 }, }; +var data = new byte[1024 * 1024 * 10]; +Random.Shared.NextBytes(data); -var stringBuilder = new StringBuilder(); -for (var i = 0; i < 9; i++) -{ - stringBuilder.Append($"{i:D2}-4567890"); -} +File.WriteAllBytes("data.bin", data); -var buffer90 = stringBuilder.ToString(); -{ - var buffer = Encoding.ASCII.GetBytes(buffer90); - var stream = new MemoryStream(buffer); +Log("Put file..."); +await store.PutAsync("my/random/data.bin", File.OpenRead("data.bin")); - await store.PutAsync(meta, stream); +Log("Get file..."); +await store.GetAsync("my/random/data.bin", File.OpenWrite("data1.bin")); - var data = await store.GetInfoAsync("k1"); +var hash = Convert.ToBase64String(SHA256.HashData(File.ReadAllBytes("data.bin"))); +var hash1 = Convert.ToBase64String(SHA256.HashData(File.ReadAllBytes("data1.bin"))); - Console.WriteLine($"DATA: {data}"); -} +Log($"Check SHA-256: {hash == hash1}"); -{ - var memoryStream = new MemoryStream(); - await store.GetAsync("k1", memoryStream); - await memoryStream.FlushAsync(); - var buffer = memoryStream.ToArray(); - Console.WriteLine($"buffer:{Encoding.ASCII.GetString(buffer)}"); -} +var metadata = await store.GetInfoAsync("my/random/data.bin"); + +Console.WriteLine("Metadata:"); +Console.WriteLine($" Bucket: {metadata.Bucket}"); +Console.WriteLine($" Name: {metadata.Name}"); +Console.WriteLine($" Size: {metadata.Size}"); +Console.WriteLine($" Time: {metadata.MTime}"); +Console.WriteLine($" Chunks: {metadata.Chunks}"); + +await store.DeleteAsync("my/random/data.bin"); Console.WriteLine("Bye"); + +void Log(string message) +{ + Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff} {message}"); +} diff --git a/src/NATS.Client.Core/INatsSerializer.cs b/src/NATS.Client.Core/INatsSerializer.cs index 6f37c4b77..4d93073f6 100644 --- a/src/NATS.Client.Core/INatsSerializer.cs +++ b/src/NATS.Client.Core/INatsSerializer.cs @@ -70,8 +70,14 @@ public int Serialize(ICountableBufferWriter bufferWriter, T? value) { using (memoryOwner) { - bufferWriter.Write(memoryOwner.Memory.Span); - return memoryOwner.Memory.Length; + var length = memoryOwner.Memory.Length; + + var buffer = bufferWriter.GetMemory(length); + memoryOwner.Memory.CopyTo(buffer); + + bufferWriter.Advance(length); + + return length; } } diff --git a/src/NATS.Client.Core/Internal/FixedArrayBufferWriter.cs b/src/NATS.Client.Core/Internal/FixedArrayBufferWriter.cs index bb74aa9eb..adc8138ac 100644 --- a/src/NATS.Client.Core/Internal/FixedArrayBufferWriter.cs +++ b/src/NATS.Client.Core/Internal/FixedArrayBufferWriter.cs @@ -52,7 +52,7 @@ public Memory GetMemory(int sizeHint = 0) { if (_buffer.Length - _written < sizeHint) { - Resize(sizeHint); + Resize(sizeHint + _written); } return _buffer.AsMemory(_written); @@ -63,7 +63,7 @@ public Span GetSpan(int sizeHint = 0) { if (_buffer.Length - _written < sizeHint) { - Resize(sizeHint); + Resize(sizeHint + _written); } return _buffer.AsSpan(_written); diff --git a/src/NATS.Client.Core/NatsMsg.cs b/src/NATS.Client.Core/NatsMsg.cs index 14bd1c78a..f9a7099d0 100644 --- a/src/NATS.Client.Core/NatsMsg.cs +++ b/src/NATS.Client.Core/NatsMsg.cs @@ -67,6 +67,20 @@ internal static NatsMsg Build( return new NatsMsg(subject, replyTo, (int)size, headers, data, connection); } + /// + /// Reply with an empty message. + /// + /// Optional message headers. + /// Optional reply-to subject. + /// A for publishing options. + /// A used to cancel the command. + /// A that represents the asynchronous send operation. + public ValueTask ReplyAsync(NatsHeaders? headers = default, string? replyTo = default, NatsPubOpts? opts = default, CancellationToken cancellationToken = default) + { + CheckReplyPreconditions(); + return Connection.PublishAsync(ReplyTo!, default, headers, replyTo, opts, cancellationToken); + } + /// /// Reply to this message. /// diff --git a/src/NATS.Client.JetStream/Internal/NatsJSOrderedPushConsumer.cs b/src/NATS.Client.JetStream/Internal/NatsJSOrderedPushConsumer.cs index b3c736ce2..35ebb36ca 100644 --- a/src/NATS.Client.JetStream/Internal/NatsJSOrderedPushConsumer.cs +++ b/src/NATS.Client.JetStream/Internal/NatsJSOrderedPushConsumer.cs @@ -190,7 +190,19 @@ private async Task CommandLoop() if (string.Equals(msg.Subject, subSubject)) { - // Control message: e.g. heartbeat + // Control messages: e.g. heartbeat + if (msg.Headers is { } headers) + { + if (headers.TryGetValue("Nats-Consumer-Stalled", out var flowControlReplyTo)) + { + await _nats.PublishAsync(flowControlReplyTo, cancellationToken: _cancellationToken); + } + + if (headers is { Code: 100, MessageText: "FlowControl Request" }) + { + await msg.ReplyAsync(cancellationToken: _cancellationToken); + } + } } else { diff --git a/src/NATS.Client.JetStream/NatsJSMsg.cs b/src/NATS.Client.JetStream/NatsJSMsg.cs index ee657083e..29c190b87 100644 --- a/src/NATS.Client.JetStream/NatsJSMsg.cs +++ b/src/NATS.Client.JetStream/NatsJSMsg.cs @@ -57,6 +57,17 @@ internal NatsJSMsg(NatsMsg msg, NatsJSContext context) /// public NatsJSMsgMetadata? Metadata => _replyToDateTimeAndSeq.Value; + /// + /// Reply with an empty message. + /// + /// Optional message headers. + /// Optional reply-to subject. + /// A for publishing options. + /// A used to cancel the command. + /// A that represents the asynchronous send operation. + public ValueTask ReplyAsync(NatsHeaders? headers = default, string? replyTo = default, NatsPubOpts? opts = default, CancellationToken cancellationToken = default) => + _msg.ReplyAsync(headers, replyTo, opts, cancellationToken); + /// /// Acknowledges the message was completely handled. /// diff --git a/src/NATS.Client.ObjectStore/NatsObjContext.cs b/src/NATS.Client.ObjectStore/NatsObjContext.cs index ebfb89a1a..a2e46a80a 100644 --- a/src/NATS.Client.ObjectStore/NatsObjContext.cs +++ b/src/NATS.Client.ObjectStore/NatsObjContext.cs @@ -20,6 +20,15 @@ public class NatsObjContext /// JetStream context. public NatsObjContext(NatsJSContext context) => _context = context; + /// + /// Create a new object store. + /// + /// Bucket name. + /// A used to cancel the API call. + /// Object store object. + public ValueTask CreateObjectStore(string bucket, CancellationToken cancellationToken = default) => + CreateObjectStore(new NatsObjConfig(bucket), cancellationToken); + /// /// Create a new object store. /// diff --git a/src/NATS.Client.ObjectStore/NatsObjException.cs b/src/NATS.Client.ObjectStore/NatsObjException.cs index 6e411f78c..1a77deb87 100644 --- a/src/NATS.Client.ObjectStore/NatsObjException.cs +++ b/src/NATS.Client.ObjectStore/NatsObjException.cs @@ -26,3 +26,18 @@ public NatsObjException(string message, Exception exception) { } } + +/// +/// NATS Object Store object not found exception. +/// +public class NatsObjNotFoundException : NatsObjException +{ + /// + /// Create a new NATS Object Store object not found exception. + /// + /// Exception message. + public NatsObjNotFoundException(string message) + : base(message) + { + } +} diff --git a/src/NATS.Client.ObjectStore/NatsObjStore.cs b/src/NATS.Client.ObjectStore/NatsObjStore.cs index d347a73b0..8736d2e4f 100644 --- a/src/NATS.Client.ObjectStore/NatsObjStore.cs +++ b/src/NATS.Client.ObjectStore/NatsObjStore.cs @@ -126,8 +126,6 @@ public async ValueTask GetAsync(string key, Stream stream, bool throw new NatsObjException("Size mismatch"); } - await stream.FlushAsync(cancellationToken); - return info; } @@ -173,10 +171,8 @@ public async ValueTask PutAsync(ObjectMetadata meta, Stream stre { info = await GetInfoAsync(meta.Name, cancellationToken: cancellationToken).ConfigureAwait(false); } - catch (NatsJSApiException e) + catch (NatsObjNotFoundException) { - if (e.Error.Code != 404) - throw; } var nuid = NewNuid(); @@ -296,17 +292,28 @@ public async ValueTask GetInfoAsync(string key, bool showDeleted ValidateObjectName(key); var request = new StreamMsgGetRequest { LastBySubj = GetMetaSubject(key) }; + try + { + var response = await _stream.GetAsync(request, cancellationToken); - var response = await _stream.GetAsync(request, cancellationToken); + var data = NatsJsonSerializer.Default.Deserialize(new ReadOnlySequence(Convert.FromBase64String(response.Message.Data))) ?? throw new NatsObjException("Can't deserialize object metadata"); - var data = NatsJsonSerializer.Default.Deserialize(new ReadOnlySequence(Convert.FromBase64String(response.Message.Data))) ?? throw new NatsObjException("Can't deserialize object metadata"); + if (!showDeleted && data.Deleted) + { + throw new NatsObjNotFoundException($"Object not found: {key}"); + } - if (!showDeleted && data.Deleted) - { - throw new NatsObjException("Object not found"); + return data; } + catch (NatsJSApiException e) + { + if (e.Error.Code == 404) + { + throw new NatsObjNotFoundException($"Object not found: {key}"); + } - return data; + throw; + } } /// diff --git a/tests/NATS.Client.Core.Tests/FixedArrayBufferWriterTest.cs b/tests/NATS.Client.Core.Tests/FixedArrayBufferWriterTest.cs index 27f6969f3..20f96e1fc 100644 --- a/tests/NATS.Client.Core.Tests/FixedArrayBufferWriterTest.cs +++ b/tests/NATS.Client.Core.Tests/FixedArrayBufferWriterTest.cs @@ -37,4 +37,30 @@ public void Ensure() newSpan.Length.Should().Be((ushort.MaxValue * 2) - 20000); } + + [Theory] + [InlineData(129, 0, "double capacity")] + [InlineData(257, 0, "adjust capacity to size")] + [InlineData(129, 1, "double capacity when already advanced")] + [InlineData(257, 1, "adjust capacity to size when already advanced")] + public void Resize(int size, int advance, string reason) + { + // GetSpan() + { + var writer = new FixedArrayBufferWriter(128); + if (advance > 0) + writer.Advance(advance); + var span = writer.GetSpan(size); + span.Length.Should().BeGreaterOrEqualTo(size, reason); + } + + // GetMemory() + { + var writer = new FixedArrayBufferWriter(128); + if (advance > 0) + writer.Advance(advance); + var memory = writer.GetMemory(size); + memory.Length.Should().BeGreaterOrEqualTo(size, reason); + } + } } diff --git a/tests/NATS.Client.ObjectStore.Tests/ObjectStoreTest.cs b/tests/NATS.Client.ObjectStore.Tests/ObjectStoreTest.cs index 08e007b4a..daa77578b 100644 --- a/tests/NATS.Client.ObjectStore.Tests/ObjectStoreTest.cs +++ b/tests/NATS.Client.ObjectStore.Tests/ObjectStoreTest.cs @@ -177,13 +177,50 @@ public async Task Delete_object() await store.DeleteAsync("k1", cancellationToken); - var exception = await Assert.ThrowsAsync(async () => await store.GetInfoAsync("k1", cancellationToken: cancellationToken)); - Assert.Equal("Object not found", exception.Message); + var exception = await Assert.ThrowsAsync(async () => await store.GetInfoAsync("k1", cancellationToken: cancellationToken)); + Assert.Matches("Object not found", exception.Message); var info2 = await store.GetInfoAsync("k1", showDeleted: true, cancellationToken: cancellationToken); Assert.True(info2.Deleted); Assert.Equal(0, info2.Size); Assert.Equal(0, info2.Chunks); Assert.Equal(string.Empty, info2.Digest); + + // Put again + await store.PutAsync("k1", new byte[] { 65, 66, 67 }, cancellationToken); + + var info3 = await store.GetInfoAsync("k1", showDeleted: true, cancellationToken: cancellationToken); + Assert.False(info3.Deleted); + } + + [Fact] + public async Task Put_and_get_large_file() + { + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var cancellationToken = cts.Token; + + await using var server = NatsServer.StartJS(); + await using var nats = server.CreateClientConnection(); + var js = new NatsJSContext(nats); + var obj = new NatsObjContext(js); + + var store = await obj.CreateObjectStore(new NatsObjConfig("b1"), cancellationToken); + + var data = new byte[1024 * 1024 * 10]; + Random.Shared.NextBytes(data); + + const string filename = $"_tmp_test_file_{nameof(Put_and_get_large_file)}.bin"; + var filename1 = $"{filename}.1"; + + await File.WriteAllBytesAsync(filename, data, cancellationToken); + + await store.PutAsync("my/random/data.bin", File.OpenRead(filename), cancellationToken: cancellationToken); + + await store.GetAsync("my/random/data.bin", File.OpenWrite(filename1), cancellationToken: cancellationToken); + + var hash = Convert.ToBase64String(SHA256.HashData(await File.ReadAllBytesAsync(filename, cancellationToken))); + var hash1 = Convert.ToBase64String(SHA256.HashData(await File.ReadAllBytesAsync(filename1, cancellationToken))); + + Assert.Equal(hash, hash1); } } From 07ef3bd22eb309643809ef7bb4ee802fb682ee1b Mon Sep 17 00:00:00 2001 From: Ziya Suzen Date: Fri, 20 Oct 2023 10:58:56 +0100 Subject: [PATCH 04/11] Release prep 2.0.0-alpha.5 (#163) * Release prep 2.0.0-alpha.5 * Initial Object Store implementation (#150) * TLS first connection (#156) * Consume clean exit fixes (#161) * Add NatsMemoryOwner (#162) * Pack object store --- src/NATS.Client.ObjectStore/NATS.Client.ObjectStore.csproj | 2 +- version.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/NATS.Client.ObjectStore/NATS.Client.ObjectStore.csproj b/src/NATS.Client.ObjectStore/NATS.Client.ObjectStore.csproj index d58a0e017..39282a010 100644 --- a/src/NATS.Client.ObjectStore/NATS.Client.ObjectStore.csproj +++ b/src/NATS.Client.ObjectStore/NATS.Client.ObjectStore.csproj @@ -9,7 +9,7 @@ pubsub;messaging JetStream Object Store support for NATS.Client. - false + true diff --git a/version.txt b/version.txt index c4e10406a..fe5c16b9b 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -2.0.0-alpha.4 +2.0.0-alpha.5 From 5fd1e0b3cae0d66167a3ab0f9c71502ed9416df8 Mon Sep 17 00:00:00 2001 From: Simon Hoss Date: Fri, 20 Oct 2023 18:53:26 +0200 Subject: [PATCH 05/11] Add no wait for consumer (#152) * Add seq and datetime to msg parsed from reply string * Map NatsJSMsg Seq, DateTime to the underlying message * Correct formatting * Implement Msg DateTime and Sequence only in JetStream * Parse reply based on official JSAck ADR * Add no_wait for consumer * Remove ViewTest * Add no wait test * Correct comment * Add noWait tests * Add FetchNoWait * Remove EndReason * Remove unused namespace --------- Co-authored-by: Simon Hoss --- src/NATS.Client.Core/NatsSubBase.cs | 1 + .../Internal/NatsJSFetch.cs | 6 +++- src/NATS.Client.JetStream/NatsJSConsumer.cs | 35 +++++++++++++++---- src/NATS.Client.JetStream/NatsJSOpts.cs | 5 +++ .../ConsumerFetchTest.cs | 28 +++++++++++++++ 5 files changed, 67 insertions(+), 8 deletions(-) diff --git a/src/NATS.Client.Core/NatsSubBase.cs b/src/NATS.Client.Core/NatsSubBase.cs index c2c2bc9f6..8d1b0ad12 100644 --- a/src/NATS.Client.Core/NatsSubBase.cs +++ b/src/NATS.Client.Core/NatsSubBase.cs @@ -10,6 +10,7 @@ namespace NATS.Client.Core; public enum NatsSubEndReason { None, + NoMsgs, MaxMsgs, MaxBytes, Timeout, diff --git a/src/NATS.Client.JetStream/Internal/NatsJSFetch.cs b/src/NATS.Client.JetStream/Internal/NatsJSFetch.cs index 2d69b663d..f01bd171d 100644 --- a/src/NATS.Client.JetStream/Internal/NatsJSFetch.cs +++ b/src/NATS.Client.JetStream/Internal/NatsJSFetch.cs @@ -172,7 +172,11 @@ protected override async ValueTask ReceiveInternalAsync( var headers = new NatsHeaders(); if (Connection.HeaderParser.ParseHeaders(new SequenceReader(headersBuffer.Value), headers)) { - if (headers is { Code: 408, Message: NatsHeaders.Messages.RequestTimeout }) + if (headers is { Code: 404 }) + { + EndSubscription(NatsSubEndReason.NoMsgs); + } + else if (headers is { Code: 408, Message: NatsHeaders.Messages.RequestTimeout }) { EndSubscription(NatsSubEndReason.Timeout); } diff --git a/src/NATS.Client.JetStream/NatsJSConsumer.cs b/src/NATS.Client.JetStream/NatsJSConsumer.cs index de55fcd6d..7dc1df3d0 100644 --- a/src/NATS.Client.JetStream/NatsJSConsumer.cs +++ b/src/NATS.Client.JetStream/NatsJSConsumer.cs @@ -197,6 +197,20 @@ await sub.CallMsgNextAsync( } } + public async IAsyncEnumerable> FetchNoWait( + NatsJSFetchOpts? opts = default, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ThrowIfDeleted(); + opts ??= _context.Opts.DefaultFetchOpts; + + await using var fc = await FetchAsync(opts with { NoWait = true }, cancellationToken); + await foreach (var jsMsg in fc.Msgs.ReadAllAsync(cancellationToken)) + { + yield return jsMsg; + } + } + /// /// Consume a set number of messages from the stream using this consumer. /// @@ -235,13 +249,20 @@ public async ValueTask> FetchAsync( await _context.Connection.SubAsync(sub: sub, cancellationToken); await sub.CallMsgNextAsync( - new ConsumerGetnextRequest - { - Batch = max.MaxMsgs, - MaxBytes = max.MaxBytes, - IdleHeartbeat = timeouts.IdleHeartbeat.ToNanos(), - Expires = timeouts.Expires.ToNanos(), - }, + opts.NoWait + + // When no wait is set we don't need to send the idle heartbeat and expiration + // If no message is available the server will respond with a 404 immediately + // If messages are available the server will send a 408 direct after the last message + ? new ConsumerGetnextRequest {Batch = max.MaxMsgs, MaxBytes = max.MaxBytes, NoWait = opts.NoWait} + : new ConsumerGetnextRequest + { + Batch = max.MaxMsgs, + MaxBytes = max.MaxBytes, + IdleHeartbeat = timeouts.IdleHeartbeat.ToNanos(), + Expires = timeouts.Expires.ToNanos(), + NoWait = opts.NoWait, + }, cancellationToken); sub.ResetHeartbeatTimer(); diff --git a/src/NATS.Client.JetStream/NatsJSOpts.cs b/src/NATS.Client.JetStream/NatsJSOpts.cs index c2bd7d6b4..8e5081e7b 100644 --- a/src/NATS.Client.JetStream/NatsJSOpts.cs +++ b/src/NATS.Client.JetStream/NatsJSOpts.cs @@ -154,6 +154,11 @@ public record NatsJSFetchOpts /// public TimeSpan? IdleHeartbeat { get; init; } + /// + /// Does not wait for messages to be available + /// + internal bool NoWait { get; init; } + /// /// Serializer to use to deserialize the message if a model is being used. /// diff --git a/tests/NATS.Client.JetStream.Tests/ConsumerFetchTest.cs b/tests/NATS.Client.JetStream.Tests/ConsumerFetchTest.cs index bc0223c63..ceeb15245 100644 --- a/tests/NATS.Client.JetStream.Tests/ConsumerFetchTest.cs +++ b/tests/NATS.Client.JetStream.Tests/ConsumerFetchTest.cs @@ -39,6 +39,34 @@ public async Task Fetch_test() Assert.Equal(10, count); } + [Fact] + public async Task FetchNoWait_test() + { + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await using var server = NatsServer.StartJS(); + await using var nats = server.CreateClientConnection(); + var js = new NatsJSContext(nats); + await js.CreateStreamAsync("s1", new[] { "s1.*" }, cts.Token); + await js.CreateConsumerAsync("s1", "c1", cancellationToken: cts.Token); + + for (var i = 0; i < 10; i++) + { + var ack = await js.PublishAsync("s1.foo", new TestData { Test = i }, cancellationToken: cts.Token); + ack.EnsureSuccess(); + } + + var consumer = await js.GetConsumerAsync("s1", "c1", cts.Token); + var count = 0; + await foreach (var msg in consumer.FetchNoWait(new NatsJSFetchOpts { MaxMsgs = 10 }, cancellationToken: cts.Token)) + { + await msg.AckAsync(new AckOpts(WaitUntilSent: true), cts.Token); + Assert.Equal(count, msg.Data!.Test); + count++; + } + + Assert.Equal(10, count); + } + [Fact] public async Task Fetch_dispose_test() { From 7e77562879a706110ed5aae4e2482f853f561991 Mon Sep 17 00:00:00 2001 From: Ziya Suzen Date: Fri, 20 Oct 2023 17:57:26 +0100 Subject: [PATCH 06/11] Build warnings cleanup (#164) --- src/NATS.Client.Core/NatsMemoryOwner.cs | 39 ++++++++++++++----------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/src/NATS.Client.Core/NatsMemoryOwner.cs b/src/NATS.Client.Core/NatsMemoryOwner.cs index 625155afc..dd4f125ab 100644 --- a/src/NATS.Client.Core/NatsMemoryOwner.cs +++ b/src/NATS.Client.Core/NatsMemoryOwner.cs @@ -3,6 +3,8 @@ namespace NATS.Client.Core; +#pragma warning disable SX1101 + using System; using System.Buffers; using System.Runtime.CompilerServices; @@ -61,8 +63,8 @@ public struct NatsMemoryOwner : IMemoryOwner private NatsMemoryOwner(int length, ArrayPool pool, NatsMemoryOwnerAllocationMode mode) { _start = 0; - this._length = length; - this._pool = pool; + _length = length; + _pool = pool; _array = pool.Rent(length); if (mode == NatsMemoryOwnerAllocationMode.Clear) @@ -80,10 +82,10 @@ private NatsMemoryOwner(int length, ArrayPool pool, NatsMemoryOwnerAllocation /// The input array to use. private NatsMemoryOwner(int start, int length, ArrayPool pool, T[] array) { - this._start = start; - this._length = length; - this._pool = pool; - this._array = array; + _start = start; + _length = length; + _pool = pool; + _array = array; } /// @@ -149,12 +151,14 @@ public int Length public static NatsMemoryOwner Allocate(int size, ArrayPool pool, NatsMemoryOwnerAllocationMode mode) => new(size, pool, mode); /// +#pragma warning disable SA1201 public Memory Memory +#pragma warning restore SA1201 { [MethodImpl(MethodImplOptions.AggressiveInlining)] get { - var array = this._array; + var array = _array; if (array is null) { @@ -173,7 +177,7 @@ public Span Span [MethodImpl(MethodImplOptions.AggressiveInlining)] get { - var array = this._array; + var array = _array; if (array is null) { @@ -208,7 +212,7 @@ public Span Span [MethodImpl(MethodImplOptions.AggressiveInlining)] public ref T DangerousGetReference() { - var array = this._array; + var array = _array; if (array is null) { @@ -232,7 +236,7 @@ public ref T DangerousGetReference() [MethodImpl(MethodImplOptions.AggressiveInlining)] public ArraySegment DangerousGetArray() { - var array = this._array; + var array = _array; if (array is null) { @@ -258,21 +262,21 @@ public ArraySegment DangerousGetArray() /// public NatsMemoryOwner Slice(int start, int length) { - var array = this._array; + var array = _array; if (array is null) { ThrowObjectDisposedException(); } - this._array = null; + _array = null; - if ((uint)start > this._length) + if ((uint)start > _length) { ThrowInvalidOffsetException(); } - if ((uint)length > (this._length - start)) + if ((uint)length > (_length - start)) { ThrowInvalidLengthException(); } @@ -288,14 +292,14 @@ public NatsMemoryOwner Slice(int start, int length) /// public void Dispose() { - var array = this._array; + var array = _array; if (array is null) { return; } - this._array = null; + _array = null; _pool.Return(array); } @@ -370,7 +374,8 @@ public static ref T DangerousGetReferenceAt(this T[] array, int i) { ref var r0 = ref MemoryMarshal.GetArrayDataReference(array); ref var ri = ref Unsafe.Add(ref r0, (nint)(uint)i); - +#pragma warning disable CS8619 return ref ri; +#pragma warning restore CS8619 } } From cf1c70b7c81c6b041987f5807eff2de2a9ac2230 Mon Sep 17 00:00:00 2001 From: Ziya Suzen Date: Tue, 24 Oct 2023 14:56:40 +0100 Subject: [PATCH 07/11] Inbox subscription options fix (#167) * Inbox subscription options fix Internal inbox subscription options was being used to create the real mux-inbox subscription (on first subscription attempt), which I noticed when trying to use timeouts with request-reply calls. Timeout was ending the real mux-inbox which was causing the rest of the request-reply calls to silently fail. * Request many with timeout test tuning --- .../Internal/SubscriptionManager.cs | 11 +++- .../RequestReplyTest.cs | 64 +++++++++++++++++++ 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/src/NATS.Client.Core/Internal/SubscriptionManager.cs b/src/NATS.Client.Core/Internal/SubscriptionManager.cs index a8d9f58ca..8cee21129 100644 --- a/src/NATS.Client.Core/Internal/SubscriptionManager.cs +++ b/src/NATS.Client.Core/Internal/SubscriptionManager.cs @@ -56,7 +56,7 @@ public async ValueTask SubscribeAsync(NatsSubBase sub, CancellationToken cancell throw new NatsException("Inbox subscriptions don't support queue groups"); } - await SubscribeInboxAsync(sub.Subject, sub.Opts, sub, cancellationToken).ConfigureAwait(false); + await SubscribeInboxAsync(sub, cancellationToken).ConfigureAwait(false); } else { @@ -171,7 +171,7 @@ public ISubscriptionManager GetManagerFor(string subject) return this; } - private async ValueTask SubscribeInboxAsync(string subject, NatsSubOpts? opts, NatsSubBase sub, CancellationToken cancellationToken) + private async ValueTask SubscribeInboxAsync(NatsSubBase sub, CancellationToken cancellationToken) { if (Interlocked.CompareExchange(ref _inboxSub, _inboxSubSentinel, _inboxSubSentinel) == _inboxSubSentinel) { @@ -181,7 +181,12 @@ private async ValueTask SubscribeInboxAsync(string subject, NatsSubOpts? opts, N if (Interlocked.CompareExchange(ref _inboxSub, _inboxSubSentinel, _inboxSubSentinel) == _inboxSubSentinel) { var inboxSubject = $"{_inboxPrefix}.*"; - _inboxSub = InboxSubBuilder.Build(inboxSubject, opts, _connection, manager: this); + + // We need to subscribe to the real inbox subject before we can register the internal subject. + // We use 'default' options here since options provided by the user are for the internal subscription. + // For example if the user provides a timeout, we don't want to timeout the real inbox subscription + // since it must live duration of the connection. + _inboxSub = InboxSubBuilder.Build(inboxSubject, opts: default, _connection, manager: this); await SubscribeInternalAsync( inboxSubject, queueGroup: default, diff --git a/tests/NATS.Client.Core.Tests/RequestReplyTest.cs b/tests/NATS.Client.Core.Tests/RequestReplyTest.cs index 5c5e52954..9e10e97e6 100644 --- a/tests/NATS.Client.Core.Tests/RequestReplyTest.cs +++ b/tests/NATS.Client.Core.Tests/RequestReplyTest.cs @@ -293,4 +293,68 @@ static string ToStr(ReadOnlyMemory input) await sub.DisposeAsync(); await reg; } + + [Fact] + public async Task Request_reply_many_multiple_with_timeout_test() + { + await using var server = NatsServer.Start(); + await using var nats = server.CreateClientConnection(); + + const string subject = "foo"; + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var cancellationToken = cts.Token; + + var sub = await nats.SubscribeAsync(subject, cancellationToken: cancellationToken); + var reg = sub.Register(async msg => + { + await msg.ReplyAsync(msg.Data * 2, cancellationToken: cancellationToken); + }); + + var opts = new NatsSubOpts { Timeout = TimeSpan.FromSeconds(2) }; + + // Make sure timeout isn't affecting the real inbox subscription + // by waiting double the timeout period (by calling RequestMany twice) + // which should be enough + for (var i = 1; i <= 2; i++) + { + var data = -1; + await foreach (var msg in nats.RequestManyAsync(subject, i * 100, replyOpts: opts, cancellationToken: cancellationToken)) + { + data = msg.Data; + } + + Assert.Equal(i * 200, data); + } + + // Run a bunch more RequestMany calls with timeout for good measure + List> tasks = new(); + + for (var i = 0; i < 10; i++) + { + var index = i; + + tasks.Add(Task.Run( + async () => + { + var data = -1; + + await foreach (var msg in nats.RequestManyAsync(subject, index, replyOpts: opts, cancellationToken: cancellationToken)) + { + data = msg.Data; + } + + return (index, data); + }, + cancellationToken)); + } + + foreach (var task in tasks) + { + var (index, data) = await task; + Assert.Equal(index * 2, data); + } + + await sub.DisposeAsync(); + await reg; + } } From 2c1f5045849ba471fe4f823179d2e549f4f7943f Mon Sep 17 00:00:00 2001 From: Simon Hoss Date: Tue, 24 Oct 2023 16:21:43 +0200 Subject: [PATCH 08/11] Add StreamInfo request to GetStream (#166) --- src/NATS.Client.JetStream/NatsJSContext.Streams.cs | 6 ++++-- src/NATS.Client.KeyValueStore/NatsKVContext.cs | 2 +- tests/NATS.Client.JetStream.Tests/ManageStreamTest.cs | 6 +++--- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/NATS.Client.JetStream/NatsJSContext.Streams.cs b/src/NATS.Client.JetStream/NatsJSContext.Streams.cs index 58a5cbae3..78b08cd8f 100644 --- a/src/NATS.Client.JetStream/NatsJSContext.Streams.cs +++ b/src/NATS.Client.JetStream/NatsJSContext.Streams.cs @@ -80,17 +80,19 @@ public async ValueTask PurgeStreamAsync( /// Get stream information from the server and creates a NATS JetStream stream object . /// /// Name of the stream to retrieve. + /// Stream info request options /// A used to cancel the API call. /// The NATS JetStream stream object which can be used to manage the stream. /// There was an issue retrieving the response. /// Server responded with an error. public async ValueTask GetStreamAsync( string stream, + StreamInfoRequest? request = null, CancellationToken cancellationToken = default) { - var response = await JSRequestResponseAsync( + var response = await JSRequestResponseAsync( subject: $"{Opts.Prefix}.STREAM.INFO.{stream}", - request: null, + request: request, cancellationToken); return new NatsJSStream(this, response); } diff --git a/src/NATS.Client.KeyValueStore/NatsKVContext.cs b/src/NATS.Client.KeyValueStore/NatsKVContext.cs index 73a03bab7..51c1e1233 100644 --- a/src/NATS.Client.KeyValueStore/NatsKVContext.cs +++ b/src/NATS.Client.KeyValueStore/NatsKVContext.cs @@ -132,7 +132,7 @@ public async ValueTask GetStoreAsync(string bucket, CancellationTok { ValidateBucketName(bucket); - var stream = await _context.GetStreamAsync(BucketToStream(bucket), cancellationToken); + var stream = await _context.GetStreamAsync(BucketToStream(bucket), cancellationToken: cancellationToken); if (stream.Info.Config.MaxMsgsPerSubject < 1) { diff --git a/tests/NATS.Client.JetStream.Tests/ManageStreamTest.cs b/tests/NATS.Client.JetStream.Tests/ManageStreamTest.cs index 12c9f9dfe..4788c431a 100644 --- a/tests/NATS.Client.JetStream.Tests/ManageStreamTest.cs +++ b/tests/NATS.Client.JetStream.Tests/ManageStreamTest.cs @@ -38,20 +38,20 @@ public async Task Account_info_create_get_update_stream() // Get { - var stream = await js.GetStreamAsync("events", cancellationToken); + var stream = await js.GetStreamAsync("events", cancellationToken: cancellationToken); Assert.Equal("events", stream.Info.Config.Name); Assert.Equal(new[] { "events.*" }, stream.Info.Config.Subjects); } // Update { - var stream1 = await js.GetStreamAsync("events", cancellationToken); + var stream1 = await js.GetStreamAsync("events", cancellationToken: cancellationToken); Assert.Equal(-1, stream1.Info.Config.MaxMsgs); var stream2 = await js.UpdateStreamAsync(new StreamUpdateRequest { Name = "events", MaxMsgs = 10 }, cancellationToken); Assert.Equal(10, stream2.Info.Config.MaxMsgs); - var stream3 = await js.GetStreamAsync("events", cancellationToken); + var stream3 = await js.GetStreamAsync("events", cancellationToken: cancellationToken); Assert.Equal(10, stream3.Info.Config.MaxMsgs); } } From ffac7cae6c8086de7fc8965be3d7cc7cf74936c3 Mon Sep 17 00:00:00 2001 From: Ziya Suzen Date: Wed, 25 Oct 2023 14:54:42 +0100 Subject: [PATCH 09/11] Version bump and FetchNoWait docs (#168) --- .../Example.JetStream.PullConsumer/Program.cs | 44 ++++++++++++++++- src/NATS.Client.JetStream/NatsJSConsumer.cs | 48 ++++++++++++++++++- .../ConsumerFetchTest.cs | 2 +- version.txt | 2 +- 4 files changed, 91 insertions(+), 5 deletions(-) diff --git a/sandbox/Example.JetStream.PullConsumer/Program.cs b/sandbox/Example.JetStream.PullConsumer/Program.cs index cfcef3f33..27a398806 100644 --- a/sandbox/Example.JetStream.PullConsumer/Program.cs +++ b/sandbox/Example.JetStream.PullConsumer/Program.cs @@ -1,4 +1,3 @@ -using System.Buffers; using System.Diagnostics; using System.Text; using Microsoft.Extensions.Logging; @@ -98,6 +97,49 @@ void Report(int i, Stopwatch sw, string data) } } } + else if (cmd == "fetch-all-no-wait") + { + while (!cts.Token.IsCancellationRequested) + { + try + { + const int max = 10; + Console.WriteLine($"___\nFETCH-NO-WAIT {max}"); + await consumer.RefreshAsync(cts.Token); + + var fetchNoWaitOpts = new NatsJSFetchOpts { MaxMsgs = max }; + var fetchMsgCount = 0; + + await foreach (var msg in consumer.FetchAllNoWaitAsync>(fetchNoWaitOpts, cts.Token)) + { + fetchMsgCount++; + using (msg.Data) + { + var message = Encoding.ASCII.GetString(msg.Data.Span); + Console.WriteLine($"Received: {message}"); + } + + await msg.AckAsync(cancellationToken: cts.Token); + Report(++count, stopwatch, $"data: {msg.Data}"); + } + + if (fetchMsgCount < fetchNoWaitOpts.MaxMsgs) + { + Console.WriteLine("No more messages. Pause for more..."); + await Task.Delay(TimeSpan.FromSeconds(5)); + } + } + catch (NatsJSProtocolException e) + { + Console.WriteLine(e.Message); + } + catch (NatsJSException e) + { + Console.WriteLine(e.Message); + await Task.Delay(1000); + } + } + } else if (cmd == "fetch-all") { while (!cts.Token.IsCancellationRequested) diff --git a/src/NATS.Client.JetStream/NatsJSConsumer.cs b/src/NATS.Client.JetStream/NatsJSConsumer.cs index 7dc1df3d0..b3031ea96 100644 --- a/src/NATS.Client.JetStream/NatsJSConsumer.cs +++ b/src/NATS.Client.JetStream/NatsJSConsumer.cs @@ -197,7 +197,51 @@ await sub.CallMsgNextAsync( } } - public async IAsyncEnumerable> FetchNoWait( + /// + /// Consume a set number of messages from the stream using this consumer. + /// Returns immediately if no messages are available. + /// + /// Fetch options. (default: MaxMsgs 1,000 and timeout is ignored) + /// A used to cancel the call. + /// Message type to deserialize. + /// Async enumerable of messages which can be used in a await foreach loop. + /// Consumer is deleted, it's push based or request sent to server is invalid. + /// There is an error sending the message or this consumer object isn't valid anymore because it was deleted earlier. + /// + /// + /// This method will return immediately if no messages are available. + /// + /// + /// Using this method is discouraged because it might create an unnecessary load on your cluster. + /// Use Consume or Fetch instead. + /// + /// + /// + /// + /// However, there are scenarios where this method is useful. For example if your application is + /// processing messages in batches infrequently (for example every 5 minutes) you might want to + /// consider FetchNoWait. You must make sure to count your messages and stop fetching + /// if you received all of them in one call, meaning when count < MaxMsgs. + /// + /// + /// const int max = 10; + /// var count = 0; + /// + /// await foreach (var msg in consumer.FetchAllNoWaitAsync<int>(new NatsJSFetchOpts { MaxMsgs = max })) + /// { + /// count++; + /// Process(msg); + /// await msg.AckAsync(); + /// } + /// + /// if (count < max) + /// { + /// // No more messages. Pause for more. + /// await Task.Delay(TimeSpan.FromMinutes(5)); + /// } + /// + /// + public async IAsyncEnumerable> FetchAllNoWaitAsync( NatsJSFetchOpts? opts = default, [EnumeratorCancellation] CancellationToken cancellationToken = default) { @@ -254,7 +298,7 @@ await sub.CallMsgNextAsync( // When no wait is set we don't need to send the idle heartbeat and expiration // If no message is available the server will respond with a 404 immediately // If messages are available the server will send a 408 direct after the last message - ? new ConsumerGetnextRequest {Batch = max.MaxMsgs, MaxBytes = max.MaxBytes, NoWait = opts.NoWait} + ? new ConsumerGetnextRequest { Batch = max.MaxMsgs, MaxBytes = max.MaxBytes, NoWait = opts.NoWait } : new ConsumerGetnextRequest { Batch = max.MaxMsgs, diff --git a/tests/NATS.Client.JetStream.Tests/ConsumerFetchTest.cs b/tests/NATS.Client.JetStream.Tests/ConsumerFetchTest.cs index ceeb15245..f369dbc92 100644 --- a/tests/NATS.Client.JetStream.Tests/ConsumerFetchTest.cs +++ b/tests/NATS.Client.JetStream.Tests/ConsumerFetchTest.cs @@ -57,7 +57,7 @@ public async Task FetchNoWait_test() var consumer = await js.GetConsumerAsync("s1", "c1", cts.Token); var count = 0; - await foreach (var msg in consumer.FetchNoWait(new NatsJSFetchOpts { MaxMsgs = 10 }, cancellationToken: cts.Token)) + await foreach (var msg in consumer.FetchAllNoWaitAsync(new NatsJSFetchOpts { MaxMsgs = 10 }, cancellationToken: cts.Token)) { await msg.AckAsync(new AckOpts(WaitUntilSent: true), cts.Token); Assert.Equal(count, msg.Data!.Test); diff --git a/version.txt b/version.txt index fe5c16b9b..e5e3d83eb 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -2.0.0-alpha.5 +2.0.0-alpha.6 From f6f8fabf6758ee0c144fffb67b0cf6c4c714c392 Mon Sep 17 00:00:00 2001 From: Ziya Suzen Date: Thu, 26 Oct 2023 21:55:31 +0100 Subject: [PATCH 10/11] Initial Service API implementation (#165) * Initial Service API implementation * Svc error handling and groups * Services tests * Package and project tidy-up * Service api docs * Endpoint and server rename * Fixed format and warnings --- .github/workflows/test.yml | 3 + NATS.Client.sln | 21 ++ NATS.Client.sln.DotSettings | 2 +- docs/documentation/intro.md | 2 + docs/documentation/object-store/intro.md | 2 +- docs/documentation/services/intro.md | 102 ++++++ docs/index.md | 2 +- .../Example.Services/Example.Services.csproj | 15 + sandbox/Example.Services/Program.cs | 77 ++++ src/NATS.Client.Core/Internal/NuidWriter.cs | 11 + src/NATS.Client.Core/NATS.Client.Core.csproj | 60 ++-- .../NATS.Client.Hosting.csproj | 31 +- .../NATS.Client.JetStream.csproj | 17 +- .../NATS.Client.KeyValueStore.csproj | 28 +- .../NATS.Client.ObjectStore.csproj | 2 +- .../Internal/SvcListener.cs | 49 +++ src/NATS.Client.Services/Internal/SvcMsg.cs | 12 + .../Models/InfoResponse.cs | 45 +++ .../Models/PingResponse.cs | 21 ++ .../Models/StatsResponse.cs | 62 ++++ .../NATS.Client.Services.csproj | 19 + src/NATS.Client.Services/NatsSvcConfig.cs | 66 ++++ src/NATS.Client.Services/NatsSvcContext.cs | 41 +++ src/NATS.Client.Services/NatsSvcEndPoint.cs | 259 ++++++++++++++ src/NATS.Client.Services/NatsSvcException.cs | 47 +++ src/NATS.Client.Services/NatsSvcMsg.cs | 119 +++++++ src/NATS.Client.Services/NatsSvcServer.cs | 328 ++++++++++++++++++ .../NATS.Client.Services.Tests.csproj | 35 ++ .../ServicesTests.cs | 245 +++++++++++++ 29 files changed, 1653 insertions(+), 70 deletions(-) create mode 100644 docs/documentation/services/intro.md create mode 100644 sandbox/Example.Services/Example.Services.csproj create mode 100644 sandbox/Example.Services/Program.cs create mode 100644 src/NATS.Client.Services/Internal/SvcListener.cs create mode 100644 src/NATS.Client.Services/Internal/SvcMsg.cs create mode 100644 src/NATS.Client.Services/Models/InfoResponse.cs create mode 100644 src/NATS.Client.Services/Models/PingResponse.cs create mode 100644 src/NATS.Client.Services/Models/StatsResponse.cs create mode 100644 src/NATS.Client.Services/NATS.Client.Services.csproj create mode 100644 src/NATS.Client.Services/NatsSvcConfig.cs create mode 100644 src/NATS.Client.Services/NatsSvcContext.cs create mode 100644 src/NATS.Client.Services/NatsSvcEndPoint.cs create mode 100644 src/NATS.Client.Services/NatsSvcException.cs create mode 100644 src/NATS.Client.Services/NatsSvcMsg.cs create mode 100644 src/NATS.Client.Services/NatsSvcServer.cs create mode 100644 tests/NATS.Client.Services.Tests/NATS.Client.Services.Tests.csproj create mode 100644 tests/NATS.Client.Services.Tests/ServicesTests.cs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2a89b9429..d2c913653 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -56,6 +56,9 @@ jobs: - name: Test Object Store run: dotnet test -c Debug --no-build --logger:"console;verbosity=normal" tests/NATS.Client.ObjectStore.Tests/NATS.Client.ObjectStore.Tests.csproj + - name: Test Services + run: dotnet test -c Debug --no-build --logger:"console;verbosity=normal" tests/NATS.Client.Services.Tests/NATS.Client.Services.Tests.csproj + memory_test: name: memory test strategy: diff --git a/NATS.Client.sln b/NATS.Client.sln index 930a9f0c9..8f57c7bd6 100644 --- a/NATS.Client.sln +++ b/NATS.Client.sln @@ -79,6 +79,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NATS.Client.ObjectStore.Tes EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Example.ObjectStore", "sandbox\Example.ObjectStore\Example.ObjectStore.csproj", "{51882883-A66E-4F95-A1AB-CFCBF71B4376}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NATS.Client.Services", "src\NATS.Client.Services\NATS.Client.Services.csproj", "{050C63EE-8F1C-4535-9C6C-E12E62A1FF1D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NATS.Client.Services.Tests", "tests\NATS.Client.Services.Tests\NATS.Client.Services.Tests.csproj", "{749CAE39-4C1E-4627-9E31-A36B987BC453}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Example.Services", "sandbox\Example.Services\Example.Services.csproj", "{DD0AB72A-D6CD-4054-A9C9-0DCA3EDBA00F}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Example.TlsFirst", "sandbox\Example.TlsFirst\Example.TlsFirst.csproj", "{88625045-978F-417F-9F51-A4E3A9718945}" EndProject Global @@ -203,6 +209,18 @@ Global {51882883-A66E-4F95-A1AB-CFCBF71B4376}.Debug|Any CPU.Build.0 = Debug|Any CPU {51882883-A66E-4F95-A1AB-CFCBF71B4376}.Release|Any CPU.ActiveCfg = Release|Any CPU {51882883-A66E-4F95-A1AB-CFCBF71B4376}.Release|Any CPU.Build.0 = Release|Any CPU + {050C63EE-8F1C-4535-9C6C-E12E62A1FF1D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {050C63EE-8F1C-4535-9C6C-E12E62A1FF1D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {050C63EE-8F1C-4535-9C6C-E12E62A1FF1D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {050C63EE-8F1C-4535-9C6C-E12E62A1FF1D}.Release|Any CPU.Build.0 = Release|Any CPU + {749CAE39-4C1E-4627-9E31-A36B987BC453}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {749CAE39-4C1E-4627-9E31-A36B987BC453}.Debug|Any CPU.Build.0 = Debug|Any CPU + {749CAE39-4C1E-4627-9E31-A36B987BC453}.Release|Any CPU.ActiveCfg = Release|Any CPU + {749CAE39-4C1E-4627-9E31-A36B987BC453}.Release|Any CPU.Build.0 = Release|Any CPU + {DD0AB72A-D6CD-4054-A9C9-0DCA3EDBA00F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DD0AB72A-D6CD-4054-A9C9-0DCA3EDBA00F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DD0AB72A-D6CD-4054-A9C9-0DCA3EDBA00F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DD0AB72A-D6CD-4054-A9C9-0DCA3EDBA00F}.Release|Any CPU.Build.0 = Release|Any CPU {88625045-978F-417F-9F51-A4E3A9718945}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {88625045-978F-417F-9F51-A4E3A9718945}.Debug|Any CPU.Build.0 = Debug|Any CPU {88625045-978F-417F-9F51-A4E3A9718945}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -242,6 +260,9 @@ Global {3F8840BA-4F91-4359-AA53-6B26823E7F55} = {4827B3EC-73D8-436D-AE2A-5E29AC95FD0C} {BB2F4EEE-1AB3-43F7-B004-6C9B3D52353E} = {C526E8AB-739A-48D7-8FC4-048978C9B650} {51882883-A66E-4F95-A1AB-CFCBF71B4376} = {95A69671-16CA-4133-981C-CC381B7AAA30} + {050C63EE-8F1C-4535-9C6C-E12E62A1FF1D} = {4827B3EC-73D8-436D-AE2A-5E29AC95FD0C} + {749CAE39-4C1E-4627-9E31-A36B987BC453} = {C526E8AB-739A-48D7-8FC4-048978C9B650} + {DD0AB72A-D6CD-4054-A9C9-0DCA3EDBA00F} = {95A69671-16CA-4133-981C-CC381B7AAA30} {88625045-978F-417F-9F51-A4E3A9718945} = {95A69671-16CA-4133-981C-CC381B7AAA30} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution diff --git a/NATS.Client.sln.DotSettings b/NATS.Client.sln.DotSettings index e0bb2621b..a5b44166a 100644 --- a/NATS.Client.sln.DotSettings +++ b/NATS.Client.sln.DotSettings @@ -8,4 +8,4 @@ True True True - True \ No newline at end of file + True diff --git a/docs/documentation/intro.md b/docs/documentation/intro.md index f8ff7b6c1..c15af3763 100644 --- a/docs/documentation/intro.md +++ b/docs/documentation/intro.md @@ -17,3 +17,5 @@ these docs. You can also create a Pull Request using the Edit on GitHub link on [Key/Value Store](key-value-store/intro.md) is the built-in distributed persistent associative arrays built on top of JetStream. [Object Store](object-store/intro.md) is the built-in distributed persistent objects of arbitrary size built on top of JetStream. + +[Services](services/intro.md) is the services protocol built on top of core NATS enabling discovery and monitoring of services you develop. diff --git a/docs/documentation/object-store/intro.md b/docs/documentation/object-store/intro.md index ec32ee011..5759a139c 100644 --- a/docs/documentation/object-store/intro.md +++ b/docs/documentation/object-store/intro.md @@ -33,7 +33,7 @@ Let's create our store first. In Object Store, a bucket is simply a storage for var store = await obj.CreateObjectStore("test-bucket"); ``` -Now that we have a KV bucket in our stream, let's see its status using the [NATS command +Now that we have a bucket in our stream, let's see its status using the [NATS command line client](https://github.com/nats-io/natscli): ```shell diff --git a/docs/documentation/services/intro.md b/docs/documentation/services/intro.md new file mode 100644 index 000000000..d807e88b5 --- /dev/null +++ b/docs/documentation/services/intro.md @@ -0,0 +1,102 @@ +# Services + +[Services](https://docs.nats.io/using-nats/developer/services) is a protocol that provides first-class services support +for NATS clients and it's supported by NATS tooling. This services protocol is an agreement between clients and tooling and +doesn't require any special functionality from the NATS server or JetStream. + +To be able to use Services you need to running the `nats-server`. + +## Services Quick Start + +[Download the latest](https://nats.io/download/) `nats-server` for your platform and run it: + +```shell +$ nats-server +``` + +Install `NATS.Client.Services` preview from Nuget. + +Before we can do anything, we need a Services context: + +```csharp +await using var nats = new NatsConnection(); +var svc = new NatsSvcContext(nats); +``` + +Let's create our first service: + +```csharp +await using var testService = await svc.AddServiceAsync("test", "1.0.0"); +``` + +Now that we have a service in our stream, let's see its status using the [NATS command +line client](https://github.com/nats-io/natscli) (make sure you have at least v0.1.1): + +```shell +$ nats --version +0.1.1 +``` + +```shell +$ nats micro info test +Service Information + + Service: test (Bw6eqhVYs3dbNzZecuuFOV) + Description: + Version: 1.0.0 + +Endpoints: + +Statistics for 0 Endpoint(s): +``` + +Now we can add endpoints to our service: + +```csharp +await testService.AddEndPointAsync(name: "divide42", handler: async m => +{ + if (m.Data == 0) + { + await m.ReplyErrorAsync(400, "Division by zero"); + return; + } + + await m.ReplyAsync(42 / m.Data); +}); +``` + +We can also confirm that our endpoint is registered by using the NATS command line: + +```shell +$ nats req divide42 2 +11:34:03 Sending request on "divide42" +11:34:03 Received with rtt 9.5823ms +21 + +$ nats micro stats test +╭──────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ test Service Statistics │ +├────────────────────────┬──────────┬──────────┬─────────────┬────────┬─────────────────┬──────────────┤ +│ ID │ Endpoint │ Requests │ Queue Group │ Errors │ Processing Time │ Average Time │ +├────────────────────────┼──────────┼──────────┼─────────────┼────────┼─────────────────┼──────────────┤ +│ RH6q9Y6qM8em8m6lG2yN34 │ divide42 │ 1 │ q │ 0 │ 1ms │ 1ms │ +├────────────────────────┼──────────┼──────────┼─────────────┼────────┼─────────────────┼──────────────┤ +│ │ │ 1 │ │ 0 │ 1MS │ 1MS │ +╰────────────────────────┴──────────┴──────────┴─────────────┴────────┴─────────────────┴──────────────╯ +``` + +## Groups + +A group is a collection of endpoints. These are optional and can provide a logical association between endpoints +as well as an optional common subject prefix for all endpoints. + +You can group your endpoints optionally in different [queue groups](https://docs.nats.io/nats-concepts/core-nats/queue): + +```csharp +var grp1 = await testService.AddGroupAsync("grp1"); + +await grp1.AddEndPointAsync(name: "ep1", handler: async m => +{ + // handle message +}); +``` diff --git a/docs/index.md b/docs/index.md index 577b2a0ab..bfb43d241 100644 --- a/docs/index.md +++ b/docs/index.md @@ -12,7 +12,7 @@ The NATS.NET V2 client is in preview and not recommended for production use yet. - [x] JetStream initial support - [x] KV initial support - [x] Object Store initial support -- [ ] Service API initial support +- [x] Service API initial support - [ ] .NET 8.0 support (e.g. Native AOT) - [ ] Beta phase diff --git a/sandbox/Example.Services/Example.Services.csproj b/sandbox/Example.Services/Example.Services.csproj new file mode 100644 index 000000000..b67798a56 --- /dev/null +++ b/sandbox/Example.Services/Example.Services.csproj @@ -0,0 +1,15 @@ + + + + Exe + net6.0 + enable + enable + false + + + + + + + diff --git a/sandbox/Example.Services/Program.cs b/sandbox/Example.Services/Program.cs new file mode 100644 index 000000000..a06051132 --- /dev/null +++ b/sandbox/Example.Services/Program.cs @@ -0,0 +1,77 @@ +using System.Text; +using Microsoft.Extensions.Logging; +using NATS.Client.Core; +using NATS.Client.Services; + +var opts = NatsOpts.Default with { LoggerFactory = new MinimumConsoleLoggerFactory(LogLevel.Error) }; + +var nats = new NatsConnection(opts); +var svc = new NatsSvcContext(nats); + +var qg = args.Length > 0 ? args[0] : "q"; + +await using var testService = await svc.AddServiceAsync("test", "1.0.0", qg); + +await testService.AddEndpointAsync(name: "bla", handler: async m => +{ + if (m.Exception is { } e) + { + Console.WriteLine($"[MSG] Error: {e.GetBaseException().Message}"); + await m.ReplyErrorAsync(999, e.GetBaseException().Message, Encoding.UTF8.GetBytes(e.ToString())); + } + + Console.WriteLine($"[MSG] {m.Subject}: {m.Data}"); + + if (m.Data == 0) + { + throw new Exception("Data can't be 0"); + } + + if (m.Data == 1) + { + throw new NatsSvcEndpointException(1, "Data can't be 1", "More info ..."); + } + + if (m.Data == 2) + { + await m.ReplyErrorAsync(2, "Data can't be 2"); + return; + } + + await Task.Delay(Random.Shared.Next(10, 100)); + await m.ReplyAsync(42); +}); + +var grp1 = await testService.AddGroupAsync("grp1"); + +await grp1.AddEndpointAsync(name: "bla", handler: async m => +{ + if (m.Exception is { } e) + { + Console.WriteLine($"[MSG] Error: {e.GetBaseException().Message}"); + await m.ReplyErrorAsync(999, e.GetBaseException().Message, Encoding.UTF8.GetBytes(e.ToString())); + } + + Console.WriteLine($"[MSG] {m.Subject}: {m.Data}"); + + if (m.Data == 0) + { + throw new Exception("Data can't be 0"); + } + + if (m.Data == 1) + { + throw new NatsSvcEndpointException(1, "Data can't be 1", "More info ..."); + } + + if (m.Data == 2) + { + await m.ReplyErrorAsync(2, "Data can't be 2"); + return; + } + + await Task.Delay(Random.Shared.Next(10, 100)); + await m.ReplyAsync(42); +}); + +Console.ReadLine(); diff --git a/src/NATS.Client.Core/Internal/NuidWriter.cs b/src/NATS.Client.Core/Internal/NuidWriter.cs index 3783c22a9..9513703a8 100644 --- a/src/NATS.Client.Core/Internal/NuidWriter.cs +++ b/src/NATS.Client.Core/Internal/NuidWriter.cs @@ -40,6 +40,17 @@ public static bool TryWriteNuid(Span nuidBuffer) return InitAndWrite(nuidBuffer); } + public static string NewNuid() + { + Span buffer = stackalloc char[22]; + if (TryWriteNuid(buffer)) + { + return new string(buffer); + } + + throw new InvalidOperationException("Internal error: can't generate nuid"); + } + private static bool TryWriteNuidCore(Span buffer, Span prefix, ulong sequential) { if ((uint)buffer.Length < NuidLength || prefix.Length != PrefixLength) diff --git a/src/NATS.Client.Core/NATS.Client.Core.csproj b/src/NATS.Client.Core/NATS.Client.Core.csproj index db709f6e2..7ced64843 100644 --- a/src/NATS.Client.Core/NATS.Client.Core.csproj +++ b/src/NATS.Client.Core/NATS.Client.Core.csproj @@ -1,35 +1,37 @@  - - net6.0 - enable - enable - true + + net6.0 + enable + enable + true - - pubsub;messaging - An alternative high performance NATS client for .NET. - true - + + pubsub;messaging + NATS client for .NET. + true + - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + - - - - - - - - - - - + + + + + + + + + + + + + diff --git a/src/NATS.Client.Hosting/NATS.Client.Hosting.csproj b/src/NATS.Client.Hosting/NATS.Client.Hosting.csproj index 0acdf843e..a412f3a4d 100644 --- a/src/NATS.Client.Hosting/NATS.Client.Hosting.csproj +++ b/src/NATS.Client.Hosting/NATS.Client.Hosting.csproj @@ -1,22 +1,23 @@ - - net6.0 - enable - enable + + net6.0 + enable + enable + true - - pubsub;messaging - ASP.NET Core and Generic Host support for NATS.Client. - true - + + pubsub;messaging + ASP.NET Core and Generic Host support for NATS.Client. + true + - - - + + + - - - + + + diff --git a/src/NATS.Client.JetStream/NATS.Client.JetStream.csproj b/src/NATS.Client.JetStream/NATS.Client.JetStream.csproj index 62a3ceef3..474feaa55 100644 --- a/src/NATS.Client.JetStream/NATS.Client.JetStream.csproj +++ b/src/NATS.Client.JetStream/NATS.Client.JetStream.csproj @@ -4,20 +4,21 @@ net6.0 enable enable + true - pubsub;messaging + pubsub;messaging;persistance JetStream support for NATS.Client. - + true - - - - - - + + + + + + diff --git a/src/NATS.Client.KeyValueStore/NATS.Client.KeyValueStore.csproj b/src/NATS.Client.KeyValueStore/NATS.Client.KeyValueStore.csproj index 0e8344a26..ad3673fb9 100644 --- a/src/NATS.Client.KeyValueStore/NATS.Client.KeyValueStore.csproj +++ b/src/NATS.Client.KeyValueStore/NATS.Client.KeyValueStore.csproj @@ -1,20 +1,20 @@ - - net6.0 - enable - enable + + net6.0 + enable + enable + true - - pubsub;messaging - JetStream Key/Value Store support for NATS.Client. - true - - - - - - + + pubsub;messaging;persistance;key-value;storage + JetStream Key/Value Store support for NATS.Client. + true + + + + + diff --git a/src/NATS.Client.ObjectStore/NATS.Client.ObjectStore.csproj b/src/NATS.Client.ObjectStore/NATS.Client.ObjectStore.csproj index 39282a010..d0ce22d99 100644 --- a/src/NATS.Client.ObjectStore/NATS.Client.ObjectStore.csproj +++ b/src/NATS.Client.ObjectStore/NATS.Client.ObjectStore.csproj @@ -7,7 +7,7 @@ true - pubsub;messaging + pubsub;messaging;persistance;storage JetStream Object Store support for NATS.Client. true diff --git a/src/NATS.Client.Services/Internal/SvcListener.cs b/src/NATS.Client.Services/Internal/SvcListener.cs new file mode 100644 index 000000000..cda8ce9ad --- /dev/null +++ b/src/NATS.Client.Services/Internal/SvcListener.cs @@ -0,0 +1,49 @@ +using System.Threading.Channels; +using NATS.Client.Core; + +namespace NATS.Client.Services.Internal; + +internal class SvcListener : IAsyncDisposable +{ + private readonly NatsConnection _nats; + private readonly Channel _channel; + private readonly SvcMsgType _type; + private readonly string _subject; + private readonly string _queueGroup; + private readonly CancellationToken _cancellationToken; + private INatsSub>? _sub; + private Task? _readLoop; + + public SvcListener(NatsConnection nats, Channel channel, SvcMsgType type, string subject, string queueGroup, CancellationToken cancellationToken) + { + _nats = nats; + _channel = channel; + _type = type; + _subject = subject; + _queueGroup = queueGroup; + _cancellationToken = cancellationToken; + } + + public async ValueTask StartAsync() + { + _sub = await _nats.SubscribeAsync>(_subject, queueGroup: _queueGroup, cancellationToken: _cancellationToken); + _readLoop = Task.Run(async () => + { + while (await _sub.Msgs.WaitToReadAsync(_cancellationToken).ConfigureAwait(false)) + { + while (_sub.Msgs.TryRead(out var msg)) + { + await _channel.Writer.WriteAsync(new SvcMsg(_type, msg), _cancellationToken).ConfigureAwait(false); + } + } + }); + } + + public async ValueTask DisposeAsync() + { + if (_sub != null) + await _sub.DisposeAsync(); + if (_readLoop != null) + await _readLoop; + } +} diff --git a/src/NATS.Client.Services/Internal/SvcMsg.cs b/src/NATS.Client.Services/Internal/SvcMsg.cs new file mode 100644 index 000000000..7ebecc2b2 --- /dev/null +++ b/src/NATS.Client.Services/Internal/SvcMsg.cs @@ -0,0 +1,12 @@ +using NATS.Client.Core; + +namespace NATS.Client.Services.Internal; + +internal enum SvcMsgType +{ + Ping, + Info, + Stats, +} + +internal readonly record struct SvcMsg(SvcMsgType MsgType, NatsMsg> Msg); diff --git a/src/NATS.Client.Services/Models/InfoResponse.cs b/src/NATS.Client.Services/Models/InfoResponse.cs new file mode 100644 index 000000000..c6f33912e --- /dev/null +++ b/src/NATS.Client.Services/Models/InfoResponse.cs @@ -0,0 +1,45 @@ +using System.Text.Json.Serialization; + +namespace NATS.Client.Services.Models; + +public record InfoResponse +{ + [JsonPropertyName("type")] + public string Type { get; set; } = "io.nats.micro.v1.info_response"; + + [JsonPropertyName("name")] + public string Name { get; set; } = default!; + + [JsonPropertyName("id")] + public string Id { get; set; } = default!; + + [JsonPropertyName("version")] + public string Version { get; set; } = default!; + + [JsonPropertyName("metadata")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public IDictionary Metadata { get; set; } = default!; + + [JsonPropertyName("description")] + public string Description { get; set; } = default!; + + [JsonPropertyName("endpoints")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public ICollection Endpoints { get; set; } = default!; +} + +public record EndpointInfo +{ + [JsonPropertyName("name")] + public string Name { get; set; } = default!; + + [JsonPropertyName("subject")] + public string Subject { get; set; } = default!; + + [JsonPropertyName("queue_group")] + public string QueueGroup { get; set; } = default!; + + [JsonPropertyName("metadata")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public IDictionary Metadata { get; set; } = default!; +} diff --git a/src/NATS.Client.Services/Models/PingResponse.cs b/src/NATS.Client.Services/Models/PingResponse.cs new file mode 100644 index 000000000..01df5341a --- /dev/null +++ b/src/NATS.Client.Services/Models/PingResponse.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace NATS.Client.Services.Models; + +public record PingResponse +{ + [JsonPropertyName("type")] + public string Type { get; set; } = "io.nats.micro.v1.ping_response"; + + [JsonPropertyName("name")] + public string Name { get; set; } = default!; + + [JsonPropertyName("id")] + public string Id { get; set; } = default!; + + [JsonPropertyName("version")] + public string Version { get; set; } = default!; + + [JsonPropertyName("metadata")] + public IDictionary Metadata { get; set; } = default!; +} diff --git a/src/NATS.Client.Services/Models/StatsResponse.cs b/src/NATS.Client.Services/Models/StatsResponse.cs new file mode 100644 index 000000000..13c1677bc --- /dev/null +++ b/src/NATS.Client.Services/Models/StatsResponse.cs @@ -0,0 +1,62 @@ +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace NATS.Client.Services.Models; + +public record StatsResponse +{ + [JsonPropertyName("type")] + public string Type { get; set; } = "io.nats.micro.v1.stats_response"; + + [JsonPropertyName("name")] + public string Name { get; set; } = default!; + + [JsonPropertyName("id")] + public string Id { get; set; } = default!; + + [JsonPropertyName("version")] + public string Version { get; set; } = default!; + + [JsonPropertyName("metadata")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public IDictionary Metadata { get; set; } = default!; + + [JsonPropertyName("endpoints")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public ICollection Endpoints { get; set; } = default!; + + [JsonPropertyName("started")] + public string Started { get; set; } = default!; +} + +public record EndpointStats +{ + [JsonPropertyName("name")] + public string Name { get; set; } = default!; + + [JsonPropertyName("subject")] + public string Subject { get; set; } = default!; + + [JsonPropertyName("queue_group")] + public string QueueGroup { get; set; } = default!; + + [JsonPropertyName("num_requests")] + public long NumRequests { get; set; } + + [JsonPropertyName("num_errors")] + public long NumErrors { get; set; } + + [JsonPropertyName("last_error")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public string LastError { get; set; } = default!; + + [JsonPropertyName("data")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public JsonNode Data { get; set; } = default!; + + [JsonPropertyName("processing_time")] + public long ProcessingTime { get; set; } + + [JsonPropertyName("average_processing_time")] + public long AverageProcessingTime { get; set; } +} diff --git a/src/NATS.Client.Services/NATS.Client.Services.csproj b/src/NATS.Client.Services/NATS.Client.Services.csproj new file mode 100644 index 000000000..5a9b4940f --- /dev/null +++ b/src/NATS.Client.Services/NATS.Client.Services.csproj @@ -0,0 +1,19 @@ + + + + net6.0 + enable + enable + true + + + pubsub;messaging;microservices;services + Service API support for NATS.Client. + true + + + + + + + diff --git a/src/NATS.Client.Services/NatsSvcConfig.cs b/src/NATS.Client.Services/NatsSvcConfig.cs new file mode 100644 index 000000000..fc357cf30 --- /dev/null +++ b/src/NATS.Client.Services/NatsSvcConfig.cs @@ -0,0 +1,66 @@ +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; + +namespace NATS.Client.Services; + +/// +/// NATS service configuration. +/// +public record NatsSvcConfig +{ + private static readonly Regex NameRegex = new(@"^[a-zA-Z0-9_-]+$", RegexOptions.Compiled); + private static readonly Regex VersionRegex = new(@"^(?0|[1-9]\d*)\.(?0|[1-9]\d*)\.(?0|[1-9]\d*)(?:-(?(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$", RegexOptions.Compiled); + + /// + /// Creates a new instance of . + /// + /// Service name. + /// Service SemVer version. + /// Name or version is invalid. + public NatsSvcConfig(string name, string version) + { + if (!NameRegex.IsMatch(name)) + { + throw new ArgumentException("Invalid service name (name can only have A-Z, a-z, 0-9, dash and underscore).", nameof(name)); + } + + if (!VersionRegex.IsMatch(version)) + { + throw new ArgumentException("Invalid service version (must use Semantic Versioning).", nameof(version)); + } + + Name = name; + Version = version; + } + + /// + /// Service name. + /// + public string Name { get; } + + /// + /// Service version. Must be a valid Semantic Versioning string. + /// + public string Version { get; } + + /// + /// Service description. + /// + public string? Description { get; init; } + + /// + /// Service metadata. This will be included in the service info. + /// + public Dictionary? Metadata { get; init; } + + /// + /// Queue group name. (default: "q") + /// + public string QueueGroup { get; init; } = "q"; + + /// + /// Stats handler. JSON object returned by this handler will be included in + /// the service stats data property. + /// + public Func? StatsHandler { get; init; } +} diff --git a/src/NATS.Client.Services/NatsSvcContext.cs b/src/NATS.Client.Services/NatsSvcContext.cs new file mode 100644 index 000000000..b91f695bf --- /dev/null +++ b/src/NATS.Client.Services/NatsSvcContext.cs @@ -0,0 +1,41 @@ +using NATS.Client.Core; + +namespace NATS.Client.Services; + +/// +/// NATS service context. +/// +public class NatsSvcContext +{ + private readonly NatsConnection _nats; + + /// + /// Creates a new instance of . + /// + /// NATS connection. + public NatsSvcContext(NatsConnection nats) => _nats = nats; + + /// + /// Adds a new service. + /// + /// Service name. + /// Service SemVer version. + /// Optional queue group (default: "q") + /// A used to cancel the API call. + /// NATS Service instance. + public ValueTask AddServiceAsync(string name, string version, string queueGroup = "q", CancellationToken cancellationToken = default) => + AddServiceAsync(new NatsSvcConfig(name, version) { QueueGroup = queueGroup }, cancellationToken); + + /// + /// Adds a new service. + /// + /// Service configuration. + /// A used to cancel the API call. + /// NATS Service instance. + public async ValueTask AddServiceAsync(NatsSvcConfig config, CancellationToken cancellationToken = default) + { + var service = new NatsSvcServer(_nats, config, cancellationToken); + await service.StartAsync().ConfigureAwait(false); + return service; + } +} diff --git a/src/NATS.Client.Services/NatsSvcEndPoint.cs b/src/NATS.Client.Services/NatsSvcEndPoint.cs new file mode 100644 index 000000000..cf126a877 --- /dev/null +++ b/src/NATS.Client.Services/NatsSvcEndPoint.cs @@ -0,0 +1,259 @@ +using System.Buffers; +using System.Diagnostics; +using System.Text; +using System.Threading.Channels; +using Microsoft.Extensions.Logging; +using NATS.Client.Core; +using NATS.Client.Core.Internal; + +namespace NATS.Client.Services; + +/// +/// NATS service endpoint. +/// +public interface INatsSvcEndpoint : IAsyncDisposable +{ + /// + /// Number of requests received. + /// + long Requests { get; } + + /// + /// Total processing time in nanoseconds. + /// + long ProcessingTime { get; } + + /// + /// Number of errors. + /// + long Errors { get; } + + /// + /// Last error message. + /// + string? LastError { get; } + + /// + /// Average processing time in nanoseconds. + /// + long AverageProcessingTime { get; } + + /// + /// Endpoint metadata. + /// + IDictionary? Metadata { get; } + + /// + /// The subject name to subscribe to. + /// + string Subject { get; } + + /// + /// Endpoint queue group. + /// + /// + /// If specified, the subscriber will join this queue group. Subscribers with the same queue group name, + /// become a queue group, and only one randomly chosen subscriber of the queue group will + /// consume a message each time a message is received by the queue group. + /// + string? QueueGroup { get; } +} + +/// +/// Endpoint base class exposing general stats. +/// +public abstract class NatsSvcEndpointBase : NatsSubBase, INatsSvcEndpoint +{ + protected NatsSvcEndpointBase(NatsConnection connection, ISubscriptionManager manager, string subject, string? queueGroup, NatsSubOpts? opts) + : base(connection, manager, subject, queueGroup, opts) + { + } + + /// + public abstract long Requests { get; } + + /// + public abstract long ProcessingTime { get; } + + /// + public abstract long Errors { get; } + + /// + public abstract string? LastError { get; } + + /// + public abstract long AverageProcessingTime { get; } + + /// + public abstract IDictionary? Metadata { get; } + + internal abstract void IncrementErrors(); + + internal abstract void SetLastError(string error); +} + +/// +/// NATS service endpoint. +/// +/// Serialized type to use when receiving data. +public class NatsSvcEndpoint : NatsSvcEndpointBase +{ + private readonly ILogger _logger; + private readonly Func, ValueTask> _handler; + private readonly NatsConnection _nats; + private readonly string _name; + private readonly CancellationToken _cancellationToken; + private readonly Channel> _channel; + private readonly INatsSerializer _serializer; + private readonly Task _handlerTask; + + private long _requests; + private long _errors; + private long _processingTime; + private string? _lastError; + + /// + /// Creates a new instance of . + /// + /// NATS connection. + /// Queue group. + /// Optional endpoint name. + /// Callback function to handle messages received. + /// Optional subject name. + /// Endpoint metadata. + /// Subscription options. + /// A used to cancel the API call. + public NatsSvcEndpoint(NatsConnection nats, string? queueGroup, string name, Func, ValueTask> handler, string subject, IDictionary? metadata, NatsSubOpts? opts, CancellationToken cancellationToken) + : base(nats, nats.SubscriptionManager, subject, queueGroup, opts) + { + _logger = nats.Opts.LoggerFactory.CreateLogger>(); + _handler = handler; + _nats = nats; + _name = name; + Metadata = metadata; + _cancellationToken = cancellationToken; + _serializer = opts?.Serializer ?? _nats.Opts.Serializer; + _channel = Channel.CreateBounded>(128); + _handlerTask = Task.Run(HandlerLoop); + } + + /// + public override long Requests => Volatile.Read(ref _requests); + + /// + public override long ProcessingTime => Volatile.Read(ref _processingTime); + + /// + public override long Errors => Volatile.Read(ref _errors); + + /// + public override string? LastError => Volatile.Read(ref _lastError); + + /// + public override long AverageProcessingTime => Requests == 0 ? 0 : ProcessingTime / Requests; + + /// + public override IDictionary? Metadata { get; } + + /// + public override async ValueTask DisposeAsync() + { + await base.DisposeAsync(); + await _handlerTask; + } + + internal override void IncrementErrors() => Interlocked.Increment(ref _errors); + + internal override void SetLastError(string error) => Interlocked.Exchange(ref _lastError, error); + + internal ValueTask StartAsync(CancellationToken cancellationToken) => + _nats.SubAsync(this, cancellationToken); + + protected override ValueTask ReceiveInternalAsync( + string subject, + string? replyTo, + ReadOnlySequence? headersBuffer, + ReadOnlySequence payloadBuffer) + { + NatsMsg msg; + Exception? exception; + try + { + msg = NatsMsg.Build(subject, replyTo, headersBuffer, payloadBuffer, _nats, _nats.HeaderParser, _serializer); + exception = null; + } + catch (Exception e) + { + _logger.LogError(e, "Endpoint {Name} error building message", _name); + exception = e; + + // Most likely a serialization error. + // Make sure we have a valid message + // so handler can reply with an error. + msg = new NatsMsg(subject, replyTo, subject.Length + (replyTo?.Length ?? 0), default, default, _nats); + } + + return _channel.Writer.WriteAsync(new NatsSvcMsg(msg, this, exception), _cancellationToken); + } + + protected override void TryComplete() => _channel.Writer.TryComplete(); + + private async Task HandlerLoop() + { + var stopwatch = new Stopwatch(); + await foreach (var svcMsg in _channel.Reader.ReadAllAsync(_cancellationToken).ConfigureAwait(false)) + { + Interlocked.Increment(ref _requests); + stopwatch.Restart(); + try + { + await _handler(svcMsg).ConfigureAwait(false); + } + catch (Exception e) + { + int code; + string message; + string body; + if (e is NatsSvcEndpointException epe) + { + code = epe.Code; + message = epe.Message; + body = epe.Body; + } + else + { + // Do not expose exceptions unless explicitly + // thrown as NatsSvcEndpointException + code = 999; + message = "Handler error"; + body = string.Empty; + + // Only log unknown exceptions + _logger.LogError(e, "Endpoint {Name} error processing message", _name); + } + + try + { + if (string.IsNullOrWhiteSpace(body)) + { + await svcMsg.ReplyErrorAsync(code, message, cancellationToken: _cancellationToken); + } + else + { + await svcMsg.ReplyErrorAsync(code, message, data: Encoding.UTF8.GetBytes(body), cancellationToken: _cancellationToken); + } + } + catch (Exception e1) + { + _logger.LogError(e1, "Endpoint {Name} error responding", _name); + } + } + finally + { + Interlocked.Add(ref _processingTime, ToNanos(stopwatch.Elapsed)); + } + } + } + + private long ToNanos(TimeSpan timeSpan) => (long)(timeSpan.TotalMilliseconds * 1_000_000); +} diff --git a/src/NATS.Client.Services/NatsSvcException.cs b/src/NATS.Client.Services/NatsSvcException.cs new file mode 100644 index 000000000..b9b418941 --- /dev/null +++ b/src/NATS.Client.Services/NatsSvcException.cs @@ -0,0 +1,47 @@ +using NATS.Client.Core; + +namespace NATS.Client.Services; + +/// +/// NATS service exception. +/// +public class NatsSvcException : NatsException +{ + /// + /// Creates a new instance of . + /// + /// Exception message. + public NatsSvcException(string message) + : base(message) + { + } +} + +/// +/// NATS service endpoint exception. +/// +public class NatsSvcEndpointException : NatsException +{ + /// + /// Creates a new instance of . + /// + /// Error code. + /// Error message + /// Optional error body. + public NatsSvcEndpointException(int code, string message, string? body = default) + : base(message) + { + Code = code; + Body = body ?? string.Empty; + } + + /// + /// Error code. + /// + public int Code { get; } + + /// + /// Error body. + /// + public string Body { get; } +} diff --git a/src/NATS.Client.Services/NatsSvcMsg.cs b/src/NATS.Client.Services/NatsSvcMsg.cs new file mode 100644 index 000000000..d4aa8ac5b --- /dev/null +++ b/src/NATS.Client.Services/NatsSvcMsg.cs @@ -0,0 +1,119 @@ +using NATS.Client.Core; + +namespace NATS.Client.Services; + +/// +/// NATS service exception. +/// +/// +public readonly struct NatsSvcMsg +{ + private readonly NatsMsg _msg; + private readonly NatsSvcEndpointBase? _endPoint; + + /// + /// Creates a new instance of . + /// + /// NATS message. + /// Service endpoint. + /// Optional exception if there were any errors. + public NatsSvcMsg(NatsMsg msg, NatsSvcEndpointBase? endPoint, Exception? exception) + { + Exception = exception; + _msg = msg; + _endPoint = endPoint; + } + + /// + /// Optional exception if there were any errors. + /// + /// + /// Check this property to see if there were any errors before processing the message. + /// + public Exception? Exception { get; } + + /// + /// Message subject. + /// + public string Subject => _msg.Subject; + + /// + /// Message data. + /// + public T? Data => _msg.Data; + + /// + /// Message reply-to subject. + /// + public string? ReplyTo => _msg.ReplyTo; + + /// + /// Send a reply with an empty message body. + /// + /// Optional message headers. + /// Optional reply-to subject. + /// Optional publishing options. + /// A used to cancel the API call. + /// A representing the asynchronous operation. + public ValueTask ReplyAsync(NatsHeaders? headers = default, string? replyTo = default, NatsPubOpts? opts = default, CancellationToken cancellationToken = default) => + _msg.ReplyAsync(headers, replyTo, opts, cancellationToken); + + /// + /// Send a reply with a message body. + /// + /// Data to be sent. + /// Optional message headers. + /// Optional reply-to subject. + /// Optional publishing options. + /// A used to cancel the API call. + /// A serializable type as data. + /// A representing the asynchronous operation. + public ValueTask ReplyAsync(TReply data, NatsHeaders? headers = default, string? replyTo = default, NatsPubOpts? opts = default, CancellationToken cancellationToken = default) => + _msg.ReplyAsync(data, headers, replyTo, opts, cancellationToken); + + /// + /// Reply with an error and additional data as error body. + /// + /// Error code. + /// Error message. + /// Error body. + /// Optional additional headers. + /// Optional reply-to subject. + /// Optional publishing options. + /// A used to cancel the API call. + /// A serializable type as data. + /// A representing the asynchronous operation. + public ValueTask ReplyErrorAsync(int code, string message, TReply data, NatsHeaders? headers = default, string? replyTo = default, NatsPubOpts? opts = default, CancellationToken cancellationToken = default) + { + headers ??= new NatsHeaders(); + headers.Add("Nats-Service-Error-Code", $"{code}"); + headers.Add("Nats-Service-Error", $"{message}"); + + _endPoint?.IncrementErrors(); + _endPoint?.SetLastError($"{message} ({code})"); + + return ReplyAsync(data, headers, replyTo, opts, cancellationToken); + } + + /// + /// Reply with an error. + /// + /// Error code. + /// Error message. + /// Optional additional headers. + /// Optional reply-to subject. + /// Optional publishing options. + /// A used to cancel the API call. + /// A representing the asynchronous operation. + public ValueTask ReplyErrorAsync(int code, string message, NatsHeaders? headers = default, string? replyTo = default, NatsPubOpts? opts = default, CancellationToken cancellationToken = default) + { + headers ??= new NatsHeaders(); + headers.Add("Nats-Service-Error-Code", $"{code}"); + headers.Add("Nats-Service-Error", $"{message}"); + + _endPoint?.IncrementErrors(); + _endPoint?.SetLastError($"{message} ({code})"); + + return ReplyAsync(headers: headers, replyTo: replyTo, opts: opts, cancellationToken: cancellationToken); + } +} diff --git a/src/NATS.Client.Services/NatsSvcServer.cs b/src/NATS.Client.Services/NatsSvcServer.cs new file mode 100644 index 000000000..b77d656aa --- /dev/null +++ b/src/NATS.Client.Services/NatsSvcServer.cs @@ -0,0 +1,328 @@ +using System.Collections.Concurrent; +using System.Text.Json.Nodes; +using System.Threading.Channels; +using Microsoft.Extensions.Logging; +using NATS.Client.Core; +using NATS.Client.Core.Internal; +using NATS.Client.Services.Internal; +using NATS.Client.Services.Models; + +namespace NATS.Client.Services; + +/// +/// NATS service server. +/// +public class NatsSvcServer : IAsyncDisposable +{ + private readonly ILogger _logger; + private readonly string _id; + private readonly NatsConnection _nats; + private readonly NatsSvcConfig _config; + private readonly CancellationToken _cancellationToken; + private readonly Channel _channel; + private readonly Task _taskMsgLoop; + private readonly List _svcListeners = new(); + private readonly ConcurrentDictionary _endPoints = new(); + private readonly string _started; + private readonly CancellationTokenSource _cts; + + /// + /// Creates a new instance of . + /// + /// NATS connection. + /// Service configuration. + /// A used to cancel the service creation requests. + public NatsSvcServer(NatsConnection nats, NatsSvcConfig config, CancellationToken cancellationToken) + { + _logger = nats.Opts.LoggerFactory.CreateLogger(); + _id = NuidWriter.NewNuid(); + _nats = nats; + _config = config; + _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _cancellationToken = _cts.Token; + _channel = Channel.CreateBounded(32); + _taskMsgLoop = Task.Run(MsgLoop); + _started = DateTimeOffset.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ"); + } + + /// + /// Stop the service. + /// + /// A used to cancel the stop operation. + /// A representing the asynchronous operation. + public async ValueTask StopAsync(CancellationToken cancellationToken = default) + { + foreach (var listener in _svcListeners) + { + await listener.DisposeAsync(); + } + + // Drain buffers + await _nats.PingAsync(cancellationToken); + + foreach (var ep in _endPoints.Values) + { + await ep.DisposeAsync(); + } + + _channel.Writer.TryComplete(); + + _cts.Cancel(); + + await _taskMsgLoop; + } + + /// + /// Adds a new endpoint. + /// + /// Callback for handling incoming messages. + /// Optional endpoint name. + /// Optional endpoint subject. + /// Optional endpoint metadata. + /// A used to stop the endpoint. + /// Serialization type for messages received. + /// A representing the asynchronous operation. + /// + /// One of name or subject must be specified. + /// + public ValueTask AddEndpointAsync(Func, ValueTask> handler, string? name = default, string? subject = default, IDictionary? metadata = default, CancellationToken cancellationToken = default) => + AddEndpointInternalAsync(handler, name, subject, _config.QueueGroup, metadata, cancellationToken); + + /// + /// Adds a new service group with optional queue group. + /// + /// Name of the group. + /// Queue group name. + /// A may be used to cancel th call in the future. + /// A representing the asynchronous operation. + public ValueTask AddGroupAsync(string name, string? queueGroup = default, CancellationToken cancellationToken = default) + { + var group = new Group(this, name, queueGroup, cancellationToken); + return ValueTask.FromResult(group); + } + + /// + /// Stop the service. + /// + public async ValueTask DisposeAsync() + { + await StopAsync(_cancellationToken); + GC.SuppressFinalize(this); + } + + internal async ValueTask StartAsync() + { + var name = _config.Name; + + foreach (var svcType in new[] { SvcMsgType.Ping, SvcMsgType.Info, SvcMsgType.Stats }) + { + var type = svcType.ToString().ToUpper(); + foreach (var subject in new[] { $"$SRV.{type}", $"$SRV.{type}.{name}", $"$SRV.{type}.{name}.{_id}" }) + { + var svcListener = new SvcListener(_nats, _channel, svcType, subject, _config.QueueGroup, _cancellationToken); + await svcListener.StartAsync(); + _svcListeners.Add(svcListener); + } + } + } + + private async ValueTask AddEndpointInternalAsync(Func, ValueTask> handler, string? name, string? subject, string? queueGroup, IDictionary? metadata, CancellationToken cancellationToken) + { + var epSubject = subject ?? name ?? throw new NatsSvcException("Either name or subject must be specified"); + var epName = name ?? epSubject; + + var ep = new NatsSvcEndpoint(_nats, queueGroup, epName, handler, epSubject, metadata, opts: default, cancellationToken); + + if (!_endPoints.TryAdd(epName, ep)) + { + await using (ep) + { + throw new NatsSvcException($"Endpoint '{name}' already exists"); + } + } + + await ep.StartAsync(cancellationToken).ConfigureAwait(false); + } + + private async Task MsgLoop() + { + await foreach (var svcMsg in _channel.Reader.ReadAllAsync(_cancellationToken)) + { + try + { + var type = svcMsg.MsgType; + var data = svcMsg.Msg.Data; + + if (type == SvcMsgType.Ping) + { + using (data) + { + // empty request payload + } + + await svcMsg.Msg.ReplyAsync( + new PingResponse { Name = _config.Name, Id = _id, Version = _config.Version, }, + cancellationToken: _cancellationToken); + } + else if (type == SvcMsgType.Info) + { + using (data) + { + // empty request payload + } + + var endPoints = _endPoints.Select(ep => new EndpointInfo + { + Name = ep.Key, + Subject = ep.Value.Subject, + QueueGroup = ep.Value.QueueGroup!, + Metadata = ep.Value.Metadata!, + }).ToList(); + + await svcMsg.Msg.ReplyAsync( + new InfoResponse + { + Name = _config.Name, + Id = _id, + Version = _config.Version, + Description = _config.Description!, + Metadata = _config.Metadata!, + Endpoints = endPoints, + }, + cancellationToken: _cancellationToken); + } + else if (type == SvcMsgType.Stats) + { + using (data) + { + // empty request payload + } + + var endPoints = _endPoints.Select(ep => + { + JsonNode? statsData; + try + { + statsData = _config.StatsHandler?.Invoke(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error calling stats handler for {Endpoint}", ep.Key); + statsData = null; + } + + return new EndpointStats + { + Name = ep.Key, + Subject = ep.Value.Subject, + QueueGroup = ep.Value.QueueGroup!, + Data = statsData!, + ProcessingTime = ep.Value.ProcessingTime, + NumRequests = ep.Value.Requests, + NumErrors = ep.Value.Errors, + LastError = ep.Value.LastError!, + AverageProcessingTime = ep.Value.AverageProcessingTime, + }; + }).ToList(); + + var response = new StatsResponse + { + Name = _config.Name, + Id = _id, + Version = _config.Version, + Metadata = _config.Metadata!, + Endpoints = endPoints, + Started = _started, + }; + + await svcMsg.Msg.ReplyAsync( + response, + cancellationToken: _cancellationToken); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Message loop error"); + } + } + } + + /// + /// NATS service group. + /// + public class Group + { + private readonly NatsSvcServer _server; + private readonly CancellationToken _cancellationToken; + private readonly string _dot; + + /// + /// Creates a new instance of . + /// + /// Service instance. + /// Group name. + /// Optional queue group. + /// A may be used to cancel th call in the future. + public Group(NatsSvcServer server, string groupName, string? queueGroup = default, CancellationToken cancellationToken = default) + { + ValidateGroupName(groupName); + _server = server; + GroupName = groupName; + QueueGroup = queueGroup; + _cancellationToken = cancellationToken; + _dot = GroupName.Length == 0 ? string.Empty : "."; + } + + public string GroupName { get; } + + public string? QueueGroup { get; } + + /// + /// Adds a new endpoint. + /// + /// Callback for handling incoming messages. + /// Optional endpoint name. + /// Optional endpoint subject. + /// Optional endpoint metadata. + /// A used to stop the endpoint. + /// Serialization type for messages received. + /// A representing the asynchronous operation. + /// + /// One of name or subject must be specified. + /// + public ValueTask AddEndpointAsync(Func, ValueTask> handler, string? name = default, string? subject = default, IDictionary? metadata = default, CancellationToken cancellationToken = default) + { + var epName = name != null ? $"{GroupName}{_dot}{name}" : null; + var epSubject = subject != null ? $"{GroupName}{_dot}{subject}" : null; + var queueGroup = QueueGroup ?? _server._config.QueueGroup; + return _server.AddEndpointInternalAsync(handler, epName, epSubject, queueGroup, metadata, cancellationToken); + } + + /// + /// Adds a new service group with optional queue group. + /// + /// Name of the group. + /// Optional queue group name. + /// A may be used to cancel th call in the future. + /// A representing the asynchronous operation. + public ValueTask AddGroupAsync(string name, string? queueGroup = default, CancellationToken cancellationToken = default) + { + var groupName = $"{GroupName}{_dot}{name}"; + return _server.AddGroupAsync(groupName, queueGroup, cancellationToken); + } + + private void ValidateGroupName(string groupName) + { + foreach (var c in groupName) + { + switch (c) + { + case '>': + throw new NatsSvcException("Invalid group name (can't have '>' wildcard in group name)"); + case '\r' or '\n' or ' ': + throw new NatsSvcException("Invalid group name (must be a valid NATS subject)"); + } + } + } + } +} diff --git a/tests/NATS.Client.Services.Tests/NATS.Client.Services.Tests.csproj b/tests/NATS.Client.Services.Tests/NATS.Client.Services.Tests.csproj new file mode 100644 index 000000000..6100af0a8 --- /dev/null +++ b/tests/NATS.Client.Services.Tests/NATS.Client.Services.Tests.csproj @@ -0,0 +1,35 @@ + + + + net6.0 + enable + enable + + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + diff --git a/tests/NATS.Client.Services.Tests/ServicesTests.cs b/tests/NATS.Client.Services.Tests/ServicesTests.cs new file mode 100644 index 000000000..ebb777fc2 --- /dev/null +++ b/tests/NATS.Client.Services.Tests/ServicesTests.cs @@ -0,0 +1,245 @@ +using System.Text.Json.Nodes; +using NATS.Client.Core.Tests; +using NATS.Client.Services.Models; + +namespace NATS.Client.Services.Tests; + +public class ServicesTests +{ + private readonly ITestOutputHelper _output; + + public ServicesTests(ITestOutputHelper output) => _output = output; + + [Fact] + public async Task Add_service_listeners_ping_info_and_stats() + { + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var cancellationToken = cts.Token; + + await using var server = NatsServer.Start(); + await using var nats = server.CreateClientConnection(); + var svc = new NatsSvcContext(nats); + + await using var s1 = await svc.AddServiceAsync("s1", "1.0.0", cancellationToken: cancellationToken); + + var pingsTask = FindServices(server, "$SRV.PING", 1, cancellationToken); + var infosTask = FindServices(server, "$SRV.INFO", 1, cancellationToken); + var statsTask = FindServices(server, "$SRV.STATS", 1, cancellationToken); + + var pings = await pingsTask; + pings.ForEach(x => _output.WriteLine($"{x}")); + Assert.Single(pings); + Assert.Equal("s1", pings[0].Name); + Assert.Equal("1.0.0", pings[0].Version); + + var infos = await infosTask; + infos.ForEach(x => _output.WriteLine($"{x}")); + Assert.Single(infos); + Assert.Equal("s1", infos[0].Name); + Assert.Equal("1.0.0", infos[0].Version); + + var stats = await statsTask; + stats.ForEach(x => _output.WriteLine($"{x}")); + Assert.Single(stats); + Assert.Equal("s1", stats[0].Name); + Assert.Equal("1.0.0", stats[0].Version); + } + + [Fact] + public async Task Add_end_point() + { + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var cancellationToken = cts.Token; + + await using var server = NatsServer.Start(); + await using var nats = server.CreateClientConnection(); + var svc = new NatsSvcContext(nats); + + await using var s1 = await svc.AddServiceAsync("s1", "1.0.0", cancellationToken: cancellationToken); + + await s1.AddEndpointAsync( + name: "e1", + handler: async m => + { + if (m.Data == 7) + { + await m.ReplyErrorAsync(m.Data, $"Error{m.Data}", cancellationToken: cancellationToken); + return; + } + + if (m.Data == 8) + { + throw new NatsSvcEndpointException(m.Data, $"Error{m.Data}"); + } + + if (m.Data == 9) + { + throw new Exception("this won't be exposed"); + } + + await m.ReplyAsync(m.Data * m.Data, cancellationToken: cancellationToken); + }, + cancellationToken: cancellationToken); + + var info = (await FindServices(server, "$SRV.INFO", 1, cancellationToken)).First(); + Assert.Single(info.Endpoints); + var endpointInfo = info.Endpoints.First(); + Assert.Equal("e1", endpointInfo.Name); + + for (var i = 0; i < 10; i++) + { + var response = await nats.RequestAsync(endpointInfo.Subject, i, cancellationToken: cancellationToken); + if (i is 7 or 8) + { + Assert.Equal($"{i}", response?.Headers?["Nats-Service-Error-Code"]); + Assert.Equal($"Error{i}", response?.Headers?["Nats-Service-Error"]); + } + else if (i is 9) + { + Assert.Equal("999", response?.Headers?["Nats-Service-Error-Code"]); + Assert.Equal("Handler error", response?.Headers?["Nats-Service-Error"]); + } + else + { + Assert.Equal(i * i, response?.Data); + Assert.Null(response?.Headers); + } + } + + var stat = (await FindServices(server, "$SRV.STATS", 1, cancellationToken)).First(); + Assert.Single(stat.Endpoints); + var endpointStats = stat.Endpoints.First(); + Assert.Equal("e1", endpointStats.Name); + Assert.Equal(10, endpointStats.NumRequests); + Assert.Equal(3, endpointStats.NumErrors); + Assert.Equal("Handler error (999)", endpointStats.LastError); + Assert.True(endpointStats.ProcessingTime > 0); + Assert.True(endpointStats.AverageProcessingTime > 0); + } + + [Fact] + public async Task Add_groups_metadata_and_stats() + { + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var cancellationToken = cts.Token; + + await using var server = NatsServer.Start(); + await using var nats = server.CreateClientConnection(); + var svc = new NatsSvcContext(nats); + + await using var s1 = await svc.AddServiceAsync("s1", "1.0.0", cancellationToken: cancellationToken); + + await s1.AddEndpointAsync( + name: "baz", + subject: "foo.baz", + handler: m => ValueTask.CompletedTask, + cancellationToken: cancellationToken); + + await s1.AddEndpointAsync( + subject: "foo.bar1", + handler: m => ValueTask.CompletedTask, + cancellationToken: cancellationToken); + + var grp1 = await s1.AddGroupAsync("grp1", cancellationToken: cancellationToken); + + await grp1.AddEndpointAsync( + name: "e1", + handler: m => ValueTask.CompletedTask, + cancellationToken: cancellationToken); + + await grp1.AddEndpointAsync( + name: "e2", + subject: "foo.bar2", + handler: m => ValueTask.CompletedTask, + cancellationToken: cancellationToken); + + var grp2 = await s1.AddGroupAsync(string.Empty, queueGroup: "q_empty", cancellationToken: cancellationToken); + + await grp2.AddEndpointAsync( + name: "empty1", + subject: "foo.empty1", + handler: m => ValueTask.CompletedTask, + cancellationToken: cancellationToken); + + // Check that the endpoints are registered correctly + { + var info = (await FindServices(server, "$SRV.INFO.s1", 1, cancellationToken)).First(); + Assert.Equal(5, info.Endpoints.Count); + var endpoints = info.Endpoints.ToList(); + + Assert.Equal("foo.baz", info.Endpoints.First(e => e.Name == "baz").Subject); + Assert.Equal("q", info.Endpoints.First(e => e.Name == "baz").QueueGroup); + + Assert.Equal("foo.bar1", info.Endpoints.First(e => e.Name == "foo.bar1").Subject); + Assert.Equal("q", info.Endpoints.First(e => e.Name == "foo.bar1").QueueGroup); + + Assert.Equal("grp1.e1", info.Endpoints.First(e => e.Name == "grp1.e1").Subject); + Assert.Equal("q", info.Endpoints.First(e => e.Name == "grp1.e1").QueueGroup); + + Assert.Equal("grp1.foo.bar2", info.Endpoints.First(e => e.Name == "grp1.e2").Subject); + Assert.Equal("q", info.Endpoints.First(e => e.Name == "grp1.e2").QueueGroup); + + Assert.Equal("foo.empty1", info.Endpoints.First(e => e.Name == "empty1").Subject); + Assert.Equal("q_empty", info.Endpoints.First(e => e.Name == "empty1").QueueGroup); + } + + await using var s2 = await svc.AddServiceAsync( + new NatsSvcConfig("s2", "2.0.0") + { + Description = "es-two", + QueueGroup = "q2", + Metadata = new Dictionary { { "k1", "v1" }, { "k2", "v2" }, }, + StatsHandler = () => JsonNode.Parse("{\"stat-k1\":\"stat-v1\",\"stat-k2\":\"stat-v2\"}")!, + }, + cancellationToken: cancellationToken); + + await s2.AddEndpointAsync( + name: "s2baz", + subject: "s2foo.baz", + handler: m => ValueTask.CompletedTask, + metadata: new Dictionary { { "ep-k1", "ep-v1" } }, + cancellationToken: cancellationToken); + + // Check default queue group and stats handler + { + var info = (await FindServices(server, "$SRV.INFO.s2", 1, cancellationToken)).First(); + Assert.Single(info.Endpoints); + var epi = info.Endpoints.First(); + + Assert.Equal("s2baz", epi.Name); + Assert.Equal("s2foo.baz", epi.Subject); + Assert.Equal("q2", epi.QueueGroup); + Assert.Equal("ep-v1", epi.Metadata["ep-k1"]); + + var stat = (await FindServices(server, "$SRV.STATS.s2", 1, cancellationToken)).First(); + Assert.Equal("v1", stat.Metadata["k1"]); + Assert.Equal("v2", stat.Metadata["k2"]); + Assert.Single(stat.Endpoints); + var eps = stat.Endpoints.First(); + Assert.Equal("stat-v1", eps.Data["stat-k1"]?.GetValue()); + Assert.Equal("stat-v2", eps.Data["stat-k2"]?.GetValue()); + } + } + + private static async Task> FindServices(NatsServer server, string subject, int limit, CancellationToken ct) + { + await using var nats = server.CreateClientConnection(); + var replyOpts = new NatsSubOpts { Timeout = TimeSpan.FromSeconds(2) }; + var responses = new List(); + + var count = 0; + await foreach (var msg in nats.RequestManyAsync(subject, null, replyOpts: replyOpts, cancellationToken: ct).ConfigureAwait(false)) + { + responses.Add(msg.Data!); + if (++count == limit) + break; + } + + if (count != limit) + { + throw new Exception($"Find service error: Expected {limit} responses but got {count}"); + } + + return responses; + } +} From f26d41cc91bc301f3de72eaf6b312214af753eee Mon Sep 17 00:00:00 2001 From: Ziya Suzen Date: Fri, 27 Oct 2023 14:28:44 +0100 Subject: [PATCH 11/11] Release prep for 2.0.0-alpha.7 (#172) * Release prep for 2.0.0-alpha.7 * Initial Service API implementation (#165) * Readme update --- README.md | 8 ++++++-- version.txt | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f06ed8a2a..874530cbc 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,9 @@ NATS.NET V2 is a [NATS](https://nats.io) client for the modern [.NET](https://do ## Preview The NATS.NET V2 client is in preview and not recommended for production use yet. -Codebase is still under heavy development and we currently implemented [Core NATS](https://docs.nats.io/nats-concepts/core-nats) -and basic [JetStream](https://docs.nats.io/nats-concepts/jetstream) features. +Codebase is still under development and we implemented majority of the NATS APIs +including [Core NATS](https://docs.nats.io/nats-concepts/core-nats), most of [JetStream](https://docs.nats.io/nats-concepts/jetstream) features, as well as main +features of Object Store, Key/Value Store and Services. Please test and provide feedback: @@ -30,6 +31,9 @@ Check out the [documentation](https://nats-io.github.io/nats.net.v2/) for guides - **NATS.Client.Core**: [Core NATS](https://docs.nats.io/nats-concepts/core-nats) - **NATS.Client.Hosting**: extension to configure DI container - **NATS.Client.JetStream**: [JetStream](https://docs.nats.io/nats-concepts/jetstream) +- **NATS.Client.KeyValueStore**: [Key/Value Store](https://docs.nats.io/nats-concepts/jetstream/key-value-store) +- **NATS.Client.ObjectStore**: [Object Store](https://docs.nats.io/nats-concepts/jetstream/obj_store) +- **NATS.Client.Services**: [Services](https://docs.nats.io/using-nats/developer/services) ## Contributing diff --git a/version.txt b/version.txt index e5e3d83eb..f6b05cc11 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -2.0.0-alpha.6 +2.0.0-alpha.7