diff --git a/CHANGELOG.md b/CHANGELOG.md index 499694672..0c57371d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,31 @@ Release Notes ==== +# 01-23-2024 +DotNext 5.0.1 +* Smallish performance improvements of dynamic buffers + +DotNext.Metaprogramming 5.0.1 +* Updated dependencies + +DotNext.Unsafe 5.0.1 +* Updated dependencies + +DotNext.Threading 5.0.1 +* Updated dependencies + +DotNext.IO 5.0.1 +* Improved performance of `FileWriter` and `FileBufferingWriter` classes by utilizing Scatter/Gather IO +* Reduced memory allocations required by async methods of `FileWriter` and `FileBufferingWriter` classes +* Updated dependencies + +DotNext.Net.Cluster 5.0.1 +* Improved IO performance of Persistent WAL due to related improvements in DotNext.IO library +* Updated dependencies + +DotNext.AspNetCore.Cluster 5.0.1 +* Updated dependencies + # 01-14-2024 .NEXT 5.0.0 has been released! The primary goal of the new release is migration to .NET 8 to fully utilize its features such as [Generic Math](https://learn.microsoft.com/en-us/dotnet/standard/generics/math) and static abstract interface members. 5.x is not fully backward compatible with 4.x because of breaking changes in the API. Most of changes done in DotNext, DotNext.IO, and DotNext.Unsafe libraries. UDP transport for Raft is completely removed in favor of existing TCP implementation. There is a plan to implement multiplexed TCP connection and Raft sharding. New features: * Numeric ranges for LINQ. Thanks to Generic Math diff --git a/README.md b/README.md index 773ae80a2..b596fdf44 100644 --- a/README.md +++ b/README.md @@ -44,14 +44,31 @@ All these things are implemented in 100% managed code on top of existing .NET AP * [NuGet Packages](https://www.nuget.org/profiles/rvsakno) # What's new -Release Date: 01-14-2024 +Release Date: 01-23-2024 -.NEXT 5.0.0 has been released! The primary goal of the new release is migration to .NET 8 to fully utilize its features such as [Generic Math](https://learn.microsoft.com/en-us/dotnet/standard/generics/math) and static abstract interface members. 5.x is not fully backward compatible with 4.x because of breaking changes in the API. Most of changes done in DotNext, DotNext.IO, and DotNext.Unsafe libraries. UDP transport for Raft is completely removed in favor of existing TCP implementation. There is a plan to implement multiplexed TCP connection and Raft sharding. New features: -* Numeric ranges for LINQ. Thanks to Generic Math -* Little-endian and big-endian readers/writer for various buffer types. Again thanks to Generic Math -* UTF-8 formatting support for various buffer types +DotNext 5.0.1 +* Smallish performance improvements of dynamic buffers -DotNext.Reflection library is deprecated and no longer maintained. +DotNext.Metaprogramming 5.0.1 +* Updated dependencies + +DotNext.Unsafe 5.0.1 +* Updated dependencies + +DotNext.Threading 5.0.1 +* Updated dependencies + +DotNext.IO 5.0.1 +* Improved performance of `FileWriter` and `FileBufferingWriter` classes by utilizing Scatter/Gather IO +* Reduced memory allocations required by async methods of `FileWriter` and `FileBufferingWriter` classes +* Updated dependencies + +DotNext.Net.Cluster 5.0.1 +* Improved IO performance of Persistent WAL due to related improvements in DotNext.IO library +* Updated dependencies + +DotNext.AspNetCore.Cluster 5.0.1 +* Updated dependencies Changelog for previous versions located [here](./CHANGELOG.md). diff --git a/src/DotNext.IO/DotNext.IO.csproj b/src/DotNext.IO/DotNext.IO.csproj index 46a896d87..719897c8e 100644 --- a/src/DotNext.IO/DotNext.IO.csproj +++ b/src/DotNext.IO/DotNext.IO.csproj @@ -11,7 +11,7 @@ .NET Foundation and Contributors .NEXT Family of Libraries - 5.0.0 + 5.0.1 DotNext.IO MIT diff --git a/src/DotNext.IO/IO/FileBufferingWriter.Utils.cs b/src/DotNext.IO/IO/FileBufferingWriter.Utils.cs new file mode 100644 index 000000000..ba24de421 --- /dev/null +++ b/src/DotNext.IO/IO/FileBufferingWriter.Utils.cs @@ -0,0 +1,189 @@ +using System.Collections; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Threading.Tasks.Sources; + +namespace DotNext.IO; + +using Intrinsics = Runtime.Intrinsics; + +public partial class FileBufferingWriter : IDynamicInterfaceCastable +{ + private readonly Action writeCallback, writeAndFlushCallback, writeAndCopyCallback; + private ReadOnlyMemory secondBuffer; + private ManualResetValueTaskSourceCore source; + private ConfiguredValueTaskAwaitable.ConfiguredValueTaskAwaiter awaiter; + + private ReadOnlyMemory GetBuffer(int index) => index switch + { + 0 => WrittenMemory, + 1 => secondBuffer, + _ => ReadOnlyMemory.Empty, + }; + + private IEnumerator> EnumerateBuffers() + { + yield return WrittenMemory; + yield return secondBuffer; + } + + [DynamicInterfaceCastableImplementation] + private interface IBufferList : IReadOnlyList> + { + int IReadOnlyCollection>.Count => 2; + + ReadOnlyMemory IReadOnlyList>.this[int index] + => Unsafe.As(this).GetBuffer(index); + + IEnumerator> IEnumerable>.GetEnumerator() + => Unsafe.As(this).EnumerateBuffers(); + + IEnumerator IEnumerable.GetEnumerator() + => Unsafe.As(this).EnumerateBuffers(); + } + + private void GetAsyncResult(short token) + { + try + { + source.GetResult(token); + } + finally + { + source.Reset(); + } + } + + private void OnWrite() + { + var awaiter = this.awaiter; + this.awaiter = default; + + var secondBuffer = this.secondBuffer; + this.secondBuffer = default; + + try + { + awaiter.GetResult(); + + filePosition += secondBuffer.Length + position; + position = 0; + } + catch (Exception e) + { + source.SetException(e); + return; + } + + source.SetResult(0); + } + + private void OnWriteAndFlush() + { + Debug.Assert(fileBackend is not null); + + var awaiter = this.awaiter; + this.awaiter = default; + + var secondBuffer = this.secondBuffer; + this.secondBuffer = default; + + try + { + awaiter.GetResult(); + + filePosition += secondBuffer.Length + position; + position = 0; + RandomAccess.FlushToDisk(fileBackend); + } + catch (Exception e) + { + source.SetException(e); + return; + } + + source.SetResult(0); + } + + private void OnWriteAndCopy() + { + var awaiter = this.awaiter; + this.awaiter = default; + + var secondBuffer = this.secondBuffer; + this.secondBuffer = default; + + try + { + awaiter.GetResult(); + + filePosition += position; + secondBuffer.CopyTo(buffer.Memory); + position = secondBuffer.Length; + } + catch (Exception e) + { + source.SetException(e); + return; + } + + source.SetResult(0); + } + + private ValueTask Submit(ValueTask task, Action callback) + { + awaiter = task.ConfigureAwait(false).GetAwaiter(); + if (awaiter.IsCompleted) + { + callback(); + } + else + { + awaiter.UnsafeOnCompleted(callback); + } + + return new((IValueTaskSource)this.As(), source.Version); + } + + [DynamicInterfaceCastableImplementation] + private interface IProxyValueTaskSource : IValueTaskSource + { + ValueTaskSourceStatus IValueTaskSource.GetStatus(short token) + { + ref var source = ref Unsafe.As(this).source; + return source.GetStatus(token); + } + + void IValueTaskSource.GetResult(short token) + => Unsafe.As(this).GetAsyncResult(token); + + void IValueTaskSource.OnCompleted(Action continuation, object? state, short token, ValueTaskSourceOnCompletedFlags flags) + { + ref var source = ref Unsafe.As(this).source; + source.OnCompleted(continuation, state, token, flags); + } + } + + [ExcludeFromCodeCoverage] + bool IDynamicInterfaceCastable.IsInterfaceImplemented(RuntimeTypeHandle interfaceType, bool throwIfNotImplemented) + { + if (interfaceType.IsOneOf([Intrinsics.TypeOf>>(), Intrinsics.TypeOf()])) + return true; + + return throwIfNotImplemented ? throw new InvalidCastException() : false; + } + + [ExcludeFromCodeCoverage] + RuntimeTypeHandle IDynamicInterfaceCastable.GetInterfaceImplementation(RuntimeTypeHandle interfaceType) + { + if (interfaceType.IsOneOf([Intrinsics.TypeOf>>(), Intrinsics.TypeOf>>()])) + return Intrinsics.TypeOf(); + + if (interfaceType.Equals(Intrinsics.TypeOf())) + return Intrinsics.TypeOf(); + + throw new InvalidCastException(); + } +} \ No newline at end of file diff --git a/src/DotNext.IO/IO/FileBufferingWriter.cs b/src/DotNext.IO/IO/FileBufferingWriter.cs index 54e30be9e..734df631d 100644 --- a/src/DotNext.IO/IO/FileBufferingWriter.cs +++ b/src/DotNext.IO/IO/FileBufferingWriter.cs @@ -107,8 +107,7 @@ internal BufferedMemoryManager() internal BufferedMemoryManager(FileBufferingWriter writer, in Range range) { - var buffer = writer.buffer; - memory = buffer.Memory.Slice(0, writer.position)[range]; + memory = writer.WrittenMemory[range]; session = writer.EnterReadMode(this); Debug.Assert(writer.IsReading); } @@ -206,6 +205,10 @@ public FileBufferingWriter(in Options options) this.memoryThreshold = memoryThreshold; fileProvider = new BackingFileProvider(in options); measurementTags = options.MeasurementTags; + + writeCallback = OnWrite; + writeAndFlushCallback = OnWriteAndFlush; + writeAndCopyCallback = OnWriteAndCopy; } /// @@ -239,15 +242,13 @@ void IGrowableBuffer.CopyTo(TConsumer consumer) [MemberNotNull(nameof(reader))] private ReadSession EnterReadMode(IDisposable obj) { - WeakReference refHolder; - if (reader is null) + if (reader is { } refHolder) { - refHolder = reader = new(obj, trackResurrection: false); + refHolder.Target = obj; } else { - refHolder = reader; - refHolder.Target = obj; + refHolder = reader = new(obj, trackResurrection: false); } return new(refHolder); @@ -256,23 +257,34 @@ private ReadSession EnterReadMode(IDisposable obj) /// /// Removes all written data. /// + /// to keep internal buffer alive; otherwise, . /// Attempt to cleanup this writer while reading. - public void Clear() + public void Clear(bool reuseBuffer = true) { if (IsReading) throw new InvalidOperationException(ExceptionMessages.WriterInReadMode); - ClearCore(); + ClearCore(reuseBuffer); } - private void ClearCore() + /// + void IGrowableBuffer.Clear() => Clear(reuseBuffer: false); + + private void ClearCore(bool reuseBuffer) { - buffer.Dispose(); - fileBackend?.Dispose(); - fileBackend = null; + if (!reuseBuffer) + { + buffer.Dispose(); + } + + if (fileBackend is not null) + { + fileBackend.Dispose(); + fileBackend = null; + } + fileName = null; - position = 0; - filePosition = 0L; + filePosition = position = 0; } /// @@ -292,7 +304,6 @@ public Memory GetMemory(int sizeHint = 0) case MemoryEvaluationResult.Success: break; case MemoryEvaluationResult.PersistExistingBuffer: - Debug.Assert(HasBufferedData); PersistBuffer(flushToDisk: false); result = buffer.Memory.Slice(0, sizeHint); break; @@ -352,19 +363,18 @@ private MemoryEvaluationResult PrepareMemory(int size, out Memory output) private bool HasBufferedData => position > 0; + [DebuggerBrowsable(DebuggerBrowsableState.Never)] + private Memory WrittenMemory => buffer.Memory.Slice(0, position); + + private ReadOnlySpan WrittenSpan => buffer.Span.Slice(0, position); + [MemberNotNull(nameof(fileBackend))] - [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))] - private async ValueTask PersistBufferAsync(bool flushToDisk, CancellationToken token) + private ValueTask PersistBufferAsync(bool flushToDisk, CancellationToken token) { Debug.Assert(HasBufferedData); EnsureBackingStore(); - await RandomAccess.WriteAsync(fileBackend, buffer.Memory.Slice(0, position), filePosition, token).ConfigureAwait(false); - - filePosition += position; - position = 0; - if (flushToDisk) - RandomAccess.FlushToDisk(fileBackend); + return Submit(RandomAccess.WriteAsync(fileBackend, WrittenMemory, filePosition, token), flushToDisk ? writeAndCopyCallback : writeCallback); } [MemberNotNull(nameof(fileBackend))] @@ -373,7 +383,7 @@ private void PersistBuffer(bool flushToDisk) Debug.Assert(HasBufferedData); EnsureBackingStore(); - RandomAccess.Write(fileBackend, buffer.Span.Slice(0, position), filePosition); + RandomAccess.Write(fileBackend, WrittenSpan, filePosition); filePosition += position; position = 0; @@ -396,31 +406,38 @@ public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationTo position += buffer.Length; goto default; case MemoryEvaluationResult.PersistExistingBuffer: - Debug.Assert(HasBufferedData); return PersistExistingBufferAsync(buffer, token); case MemoryEvaluationResult.PersistAll: return PersistAllAsync(buffer, token); } } - [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))] - private async ValueTask PersistExistingBufferAsync(ReadOnlyMemory buffer, CancellationToken token) + private ValueTask PersistExistingBufferAsync(ReadOnlyMemory buffer, CancellationToken token) { - await PersistBufferAsync(flushToDisk: false, token).ConfigureAwait(false); - buffer.CopyTo(this.buffer.Memory); - position = buffer.Length; + Debug.Assert(HasBufferedData); + + EnsureBackingStore(); + secondBuffer = buffer; + return Submit(RandomAccess.WriteAsync(fileBackend, WrittenMemory, filePosition, token), writeAndCopyCallback); } - [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))] - private async ValueTask PersistAllAsync(ReadOnlyMemory buffer, CancellationToken token) + private ValueTask PersistAllAsync(ReadOnlyMemory buffer, CancellationToken token) { + EnsureBackingStore(); + + ValueTask task; if (HasBufferedData) - await PersistBufferAsync(flushToDisk: false, token).ConfigureAwait(false); + { + secondBuffer = buffer; + task = RandomAccess.WriteAsync(fileBackend, (IReadOnlyList>)this.As(), filePosition, token); + } else - EnsureBackingStore(); + { + position = buffer.Length; + task = RandomAccess.WriteAsync(fileBackend, buffer, filePosition, token); + } - await RandomAccess.WriteAsync(fileBackend, buffer, filePosition, token).ConfigureAwait(false); - filePosition += buffer.Length; + return Submit(task, writeCallback); } /// @@ -436,7 +453,6 @@ public override void Write(ReadOnlySpan buffer) position += buffer.Length; break; case MemoryEvaluationResult.PersistExistingBuffer: - Debug.Assert(HasBufferedData); PersistBuffer(flushToDisk: false); buffer.CopyTo(this.buffer.Span); position = buffer.Length; @@ -469,7 +485,7 @@ public override Task WriteAsync(byte[] buffer, int offset, int count, Cancellati /// public override void WriteByte(byte value) - => Write(MemoryMarshal.CreateReadOnlySpan(ref value, 1)); + => Write(new(ref value)); /// public override IAsyncResult BeginWrite(byte[] buffer, int offset, int count, AsyncCallback? callback, object? state) @@ -596,7 +612,7 @@ public async Task CopyToAsync(TConsumer consumer, int bufferSize, Can } if (HasBufferedData) - await consumer.Invoke(buffer.Memory.Slice(0, position), token).ConfigureAwait(false); + await consumer.Invoke(WrittenMemory, token).ConfigureAwait(false); } /// @@ -623,7 +639,7 @@ public void CopyTo(TConsumer consumer, int bufferSize, CancellationTo } if (HasBufferedData) - consumer.Invoke(buffer.Span.Slice(0, position)); + consumer.Invoke(WrittenSpan); } /// @@ -714,7 +730,7 @@ public int CopyTo(Span output) if (HasBufferedData) { - buffer.Span.Slice(0, position).CopyTo(output, out var subCount); + WrittenSpan.CopyTo(output, out var subCount); totalBytes += subCount; } @@ -743,7 +759,7 @@ public async ValueTask CopyToAsync(Memory output, CancellationToken t if (HasBufferedData) { - buffer.Span.Slice(0, position).CopyTo(output.Span, out var subCount); + WrittenSpan.CopyTo(output.Span, out var subCount); totalBytes += subCount; } @@ -897,7 +913,7 @@ public bool TryGetWrittenContent(out ReadOnlyMemory content) { if (fileBackend is null) { - content = buffer.Memory.Slice(0, position); + content = WrittenMemory; return true; } @@ -920,7 +936,7 @@ public bool TryGetWrittenContent(out ReadOnlyMemory content, [NotNullWhen( { if ((fileName = this.fileName) is null) { - content = buffer.Memory.Slice(0, position); + content = WrittenMemory; return true; } @@ -937,7 +953,7 @@ protected override void Dispose(bool disposing) { if (disposing) { - ClearCore(); + ClearCore(reuseBuffer: false); reader = null; } diff --git a/src/DotNext.IO/IO/FileReader.Utils.cs b/src/DotNext.IO/IO/FileReader.Utils.cs new file mode 100644 index 000000000..b0dee8a65 --- /dev/null +++ b/src/DotNext.IO/IO/FileReader.Utils.cs @@ -0,0 +1,144 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Threading.Tasks.Sources; + +namespace DotNext.IO; + +using Intrinsics = Runtime.Intrinsics; + +public partial class FileReader : IDynamicInterfaceCastable +{ + private readonly Action readCallback, readDirectCallback; + private ManualResetValueTaskSourceCore source; + private ConfiguredValueTaskAwaitable.ConfiguredValueTaskAwaiter awaiter; + private int extraCount; + + private int GetAsyncResult(short token) + { + try + { + return source.GetResult(token); + } + finally + { + source.Reset(); + } + } + + private void OnRead() + { + var awaiter = this.awaiter; + this.awaiter = default; + + int count; + try + { + count = awaiter.GetResult(); + + bufferEnd += count; + } + catch (Exception e) + { + source.SetException(e); + return; + } + + source.SetResult(count); + } + + private void OnReadDirect() + { + var awaiter = this.awaiter; + this.awaiter = default; + + var extraCount = this.extraCount; + this.extraCount = 0; + + int count; + try + { + count = awaiter.GetResult(); + + fileOffset += count; + count += extraCount; + } + catch (Exception e) + { + source.SetException(e); + return; + } + + source.SetResult(count); + } + + private ValueTask SubmitAsInt32(ValueTask task, Action callback) + { + awaiter = task.ConfigureAwait(false).GetAwaiter(); + if (awaiter.IsCompleted) + { + callback(); + } + else + { + awaiter.UnsafeOnCompleted(callback); + } + + return new((IValueTaskSource)this, source.Version); + } + + private ValueTask SubmitAsBoolean(ValueTask task, Action callback) + { + awaiter = task.ConfigureAwait(false).GetAwaiter(); + if (awaiter.IsCompleted) + { + callback(); + } + else + { + awaiter.UnsafeOnCompleted(callback); + } + + return new((IValueTaskSource)this, source.Version); + } + + [DynamicInterfaceCastableImplementation] + private interface IProxyValueTaskSource : IValueTaskSource, IValueTaskSource + { + ValueTaskSourceStatus IValueTaskSource.GetStatus(short token) + => Unsafe.As(this).source.GetStatus(token); + + ValueTaskSourceStatus IValueTaskSource.GetStatus(short token) + => Unsafe.As(this).source.GetStatus(token); + + int IValueTaskSource.GetResult(short token) + => Unsafe.As(this).GetAsyncResult(token); + + bool IValueTaskSource.GetResult(short token) + => Unsafe.As(this).GetAsyncResult(token) is not 0; + + void IValueTaskSource.OnCompleted(Action continuation, object? state, short token, ValueTaskSourceOnCompletedFlags flags) + => Unsafe.As(this).source.OnCompleted(continuation, state, token, flags); + + void IValueTaskSource.OnCompleted(Action continuation, object? state, short token, ValueTaskSourceOnCompletedFlags flags) + => Unsafe.As(this).source.OnCompleted(continuation, state, token, flags); + } + + [ExcludeFromCodeCoverage] + bool IDynamicInterfaceCastable.IsInterfaceImplemented(RuntimeTypeHandle interfaceType, bool throwIfNotImplemented) + { + if (interfaceType.IsOneOf([Intrinsics.TypeOf>(), Intrinsics.TypeOf>()])) + return true; + + return throwIfNotImplemented ? throw new InvalidCastException() : false; + } + + [ExcludeFromCodeCoverage] + RuntimeTypeHandle IDynamicInterfaceCastable.GetInterfaceImplementation(RuntimeTypeHandle interfaceType) + { + if (interfaceType.IsOneOf([Intrinsics.TypeOf>(), Intrinsics.TypeOf>()])) + return Intrinsics.TypeOf(); + + throw new InvalidCastException(); + } +} \ No newline at end of file diff --git a/src/DotNext.IO/IO/FileReader.cs b/src/DotNext.IO/IO/FileReader.cs index 03a6e49c7..af31be02f 100644 --- a/src/DotNext.IO/IO/FileReader.cs +++ b/src/DotNext.IO/IO/FileReader.cs @@ -1,10 +1,8 @@ using System.Diagnostics; -using System.Runtime.CompilerServices; using SafeFileHandle = Microsoft.Win32.SafeHandles.SafeFileHandle; namespace DotNext.IO; -using System.ComponentModel; using Buffers; /// @@ -51,6 +49,9 @@ public FileReader(SafeFileHandle handle, long fileOffset = 0L, int bufferSize = this.handle = handle; this.fileOffset = fileOffset; this.allocator = allocator; + + readCallback = OnRead; + readDirectCallback = OnReadDirect; } /// @@ -199,17 +200,17 @@ public bool TryConsume(int bytes, out ReadOnlyMemory buffer) /// The reader has been disposed. /// Internal buffer has no free space. /// The operation has been canceled. - [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))] - public async ValueTask ReadAsync(CancellationToken token = default) + public ValueTask ReadAsync(CancellationToken token = default) { - ObjectDisposedException.ThrowIf(IsDisposed, this); + if (IsDisposed) + return new(GetDisposedTask()); var buffer = this.buffer.Memory; switch (bufferStart) { case 0 when bufferEnd == buffer.Length: - throw new InternalBufferOverflowException(); + return ValueTask.FromException(new InternalBufferOverflowException()); case > 0: // compact buffer buffer.Slice(bufferStart, BufferLength).CopyTo(buffer); @@ -218,9 +219,7 @@ public async ValueTask ReadAsync(CancellationToken token = default) break; } - var count = await RandomAccess.ReadAsync(handle, buffer.Slice(bufferEnd), fileOffset + bufferEnd, token).ConfigureAwait(false); - bufferEnd += count; - return count > 0; + return SubmitAsBoolean(RandomAccess.ReadAsync(handle, buffer.Slice(bufferEnd), fileOffset + bufferEnd, token), readCallback); } /// @@ -271,35 +270,26 @@ public ValueTask ReadAsync(Memory destination, CancellationToken toke } if (destination.IsEmpty) + { return ValueTask.FromResult(0); + } if (!HasBufferedData) + { return ReadDirectAsync(destination, token); + } - BufferSpan.CopyTo(destination.Span, out var writtenCount); - ConsumeUnsafe(writtenCount); - destination = destination.Slice(writtenCount); + BufferSpan.CopyTo(destination.Span, out extraCount); + ConsumeUnsafe(extraCount); + destination = destination.Slice(extraCount); return destination.IsEmpty - ? ValueTask.FromResult(writtenCount) - : ReadDirectAsync(writtenCount, destination, token); - } - - [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))] - private async ValueTask ReadDirectAsync(Memory output, CancellationToken token) - { - var count = await RandomAccess.ReadAsync(handle, output, fileOffset, token).ConfigureAwait(false); - fileOffset += count; - return count; + ? ValueTask.FromResult(extraCount) + : ReadDirectAsync(destination, token); } - [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))] - private async ValueTask ReadDirectAsync(int extraCount, Memory output, CancellationToken token) - { - var count = await RandomAccess.ReadAsync(handle, output, fileOffset, token).ConfigureAwait(false); - fileOffset += count; - return count + extraCount; - } + private ValueTask ReadDirectAsync(Memory output, CancellationToken token) + => SubmitAsInt32(RandomAccess.ReadAsync(handle, output, fileOffset, token), readDirectCallback); /// /// Reads the block of the memory. diff --git a/src/DotNext.IO/IO/FileWriter.Utils.cs b/src/DotNext.IO/IO/FileWriter.Utils.cs new file mode 100644 index 000000000..ad8bfbf8a --- /dev/null +++ b/src/DotNext.IO/IO/FileWriter.Utils.cs @@ -0,0 +1,155 @@ +using System.Collections; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Threading.Tasks.Sources; + +namespace DotNext.IO; + +using Intrinsics = Runtime.Intrinsics; + +public partial class FileWriter : IDynamicInterfaceCastable +{ + private readonly Action writeCallback, writeAndCopyCallback; + private ReadOnlyMemory secondBuffer; + private ManualResetValueTaskSourceCore source; + private ConfiguredValueTaskAwaitable.ConfiguredValueTaskAwaiter awaiter; + + private ReadOnlyMemory GetBuffer(int index) => index switch + { + 0 => WrittenMemory, + 1 => secondBuffer, + _ => ReadOnlyMemory.Empty, + }; + + private IEnumerator> EnumerateBuffers() + { + yield return WrittenMemory; + yield return secondBuffer; + } + + [DynamicInterfaceCastableImplementation] + private interface IBufferList : IReadOnlyList> + { + int IReadOnlyCollection>.Count => 2; + + ReadOnlyMemory IReadOnlyList>.this[int index] + => Unsafe.As(this).GetBuffer(index); + + IEnumerator> IEnumerable>.GetEnumerator() + => Unsafe.As(this).EnumerateBuffers(); + + IEnumerator IEnumerable.GetEnumerator() + => Unsafe.As(this).EnumerateBuffers(); + } + + private void GetAsyncResult(short token) + { + try + { + source.GetResult(token); + } + finally + { + source.Reset(); + } + } + + private void OnWrite() + { + var awaiter = this.awaiter; + this.awaiter = default; + + var secondBuffer = this.secondBuffer; + this.secondBuffer = default; + + try + { + awaiter.GetResult(); + + fileOffset += secondBuffer.Length + bufferOffset; + bufferOffset = 0; + } + catch (Exception e) + { + source.SetException(e); + return; + } + + source.SetResult(0); + } + + private void OnWriteAndCopy() + { + var awaiter = this.awaiter; + this.awaiter = default; + + var secondBuffer = this.secondBuffer; + this.secondBuffer = default; + + try + { + awaiter.GetResult(); + + fileOffset += bufferOffset; + secondBuffer.CopyTo(buffer.Memory); + bufferOffset = secondBuffer.Length; + } + catch (Exception e) + { + source.SetException(e); + return; + } + + source.SetResult(0); + } + + private ValueTask Submit(ValueTask task, Action callback) + { + awaiter = task.ConfigureAwait(false).GetAwaiter(); + if (awaiter.IsCompleted) + { + callback(); + } + else + { + awaiter.UnsafeOnCompleted(callback); + } + + return new((IValueTaskSource)this, source.Version); + } + + [DynamicInterfaceCastableImplementation] + private interface IProxyValueTaskSource : IValueTaskSource + { + ValueTaskSourceStatus IValueTaskSource.GetStatus(short token) + => Unsafe.As(this).source.GetStatus(token); + + void IValueTaskSource.GetResult(short token) + => Unsafe.As(this).GetAsyncResult(token); + + void IValueTaskSource.OnCompleted(Action continuation, object? state, short token, ValueTaskSourceOnCompletedFlags flags) + => Unsafe.As(this).source.OnCompleted(continuation, state, token, flags); + } + + [ExcludeFromCodeCoverage] + bool IDynamicInterfaceCastable.IsInterfaceImplemented(RuntimeTypeHandle interfaceType, bool throwIfNotImplemented) + { + if (interfaceType.IsOneOf([Intrinsics.TypeOf>>(), Intrinsics.TypeOf()])) + return true; + + return throwIfNotImplemented ? throw new InvalidCastException() : false; + } + + [ExcludeFromCodeCoverage] + RuntimeTypeHandle IDynamicInterfaceCastable.GetInterfaceImplementation(RuntimeTypeHandle interfaceType) + { + if (interfaceType.IsOneOf([Intrinsics.TypeOf>>(), Intrinsics.TypeOf>>()])) + return Intrinsics.TypeOf(); + + if (interfaceType.Equals(Intrinsics.TypeOf())) + return Intrinsics.TypeOf(); + + throw new InvalidCastException(); + } +} \ No newline at end of file diff --git a/src/DotNext.IO/IO/FileWriter.cs b/src/DotNext.IO/IO/FileWriter.cs index 2dd0eefc0..be323ed48 100644 --- a/src/DotNext.IO/IO/FileWriter.cs +++ b/src/DotNext.IO/IO/FileWriter.cs @@ -1,10 +1,8 @@ using System.Diagnostics; -using System.Runtime.CompilerServices; using SafeFileHandle = Microsoft.Win32.SafeHandles.SafeFileHandle; namespace DotNext.IO; -using System.Security.Cryptography.X509Certificates; using Buffers; /// @@ -24,7 +22,6 @@ public partial class FileWriter : Disposable, IFlushable private MemoryOwner buffer; private int bufferOffset; private long fileOffset; - private ReadOnlyMemory[]? bufferList; /// /// Creates a new writer backed by the file. @@ -52,6 +49,8 @@ public FileWriter(SafeFileHandle handle, long fileOffset = 0L, int bufferSize = this.handle = handle; this.fileOffset = fileOffset; this.allocator = allocator; + writeCallback = OnWrite; + writeAndCopyCallback = OnWriteAndCopy; } /// @@ -138,13 +137,8 @@ public long FilePosition /// public long WritePosition => fileOffset + bufferOffset; - [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))] - private async ValueTask FlushCoreAsync(CancellationToken token) - { - await RandomAccess.WriteAsync(handle, WrittenMemory, fileOffset, token).ConfigureAwait(false); - fileOffset += bufferOffset; - bufferOffset = 0; - } + private ValueTask FlushCoreAsync(CancellationToken token) + => Submit(RandomAccess.WriteAsync(handle, WrittenMemory, fileOffset, token), writeCallback); private void FlushCore() { @@ -204,7 +198,7 @@ private void WriteSlow(ReadOnlySpan input) { if (input.Length >= buffer.Length) { - RandomAccess.Write(handle, BufferSpan, fileOffset); + RandomAccess.Write(handle, WrittenMemory.Span, fileOffset); fileOffset += bufferOffset; RandomAccess.Write(handle, input, fileOffset); @@ -255,35 +249,29 @@ public ValueTask WriteAsync(ReadOnlyMemory input, CancellationToken token return ValueTask.CompletedTask; } - [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))] - private async ValueTask WriteDirectAsync(ReadOnlyMemory input, CancellationToken token) + private ValueTask WriteDirectAsync(ReadOnlyMemory input, CancellationToken token) { - if (bufferOffset is 0) + ValueTask task; + if (HasBufferedData) { - await RandomAccess.WriteAsync(handle, input, fileOffset, token).ConfigureAwait(false); + secondBuffer = input; + task = RandomAccess.WriteAsync(handle, (IReadOnlyList>)this, fileOffset, token); } else { - bufferList ??= new ReadOnlyMemory[2]; - bufferList[1] = input; - bufferList[0] = WrittenMemory; - await RandomAccess.WriteAsync(handle, bufferList, fileOffset, token).ConfigureAwait(false); - Array.Clear(bufferList); + bufferOffset = input.Length; + task = RandomAccess.WriteAsync(handle, input, fileOffset, token); } - fileOffset += input.Length + bufferOffset; - bufferOffset = 0; + return Submit(task, writeCallback); } - [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))] - private async ValueTask WriteAndCopyAsync(ReadOnlyMemory input, CancellationToken token) + private ValueTask WriteAndCopyAsync(ReadOnlyMemory input, CancellationToken token) { - Debug.Assert(bufferOffset > 0); + Debug.Assert(HasBufferedData); - await RandomAccess.WriteAsync(handle, WrittenMemory, fileOffset, token).ConfigureAwait(false); - fileOffset += bufferOffset; - input.CopyTo(buffer.Memory); - bufferOffset = input.Length; + secondBuffer = input; + return Submit(RandomAccess.WriteAsync(handle, WrittenMemory, fileOffset, token), writeAndCopyCallback); } /// diff --git a/src/DotNext.Metaprogramming/DotNext.Metaprogramming.csproj b/src/DotNext.Metaprogramming/DotNext.Metaprogramming.csproj index 9060fd1a9..9cbc9292f 100644 --- a/src/DotNext.Metaprogramming/DotNext.Metaprogramming.csproj +++ b/src/DotNext.Metaprogramming/DotNext.Metaprogramming.csproj @@ -8,7 +8,7 @@ true false nullablePublicOnly - 5.0.0 + 5.0.1 .NET Foundation .NEXT Family of Libraries diff --git a/src/DotNext.Tests/IO/FileBufferingWriterTests.cs b/src/DotNext.Tests/IO/FileBufferingWriterTests.cs index 740c33072..fdda44b6d 100644 --- a/src/DotNext.Tests/IO/FileBufferingWriterTests.cs +++ b/src/DotNext.Tests/IO/FileBufferingWriterTests.cs @@ -3,6 +3,8 @@ namespace DotNext.IO; +using Buffers; + public sealed class FileBufferingWriterTests : Test { [Theory] @@ -73,6 +75,7 @@ public static void ReadWriteWithInitialCapacity(int threshold) [Theory] [InlineData(10)] [InlineData(100)] + [InlineData(255)] [InlineData(1000)] public static async Task ReadWriteAsync(int threshold) { @@ -81,8 +84,8 @@ public static async Task ReadWriteAsync(int threshold) for (byte i = 0; i < byte.MaxValue; i++) bytes[i] = i; - await writer.WriteAsync(bytes, 0, byte.MaxValue); - await writer.WriteAsync(bytes.AsMemory(byte.MaxValue)); + await writer.WriteAsync(bytes, 0, 200); + await writer.WriteAsync(bytes.AsMemory(200)); Equal(bytes.Length, writer.Length); using var manager = await writer.GetWrittenContentAsync(); Equal(bytes, manager.Memory.ToArray()); @@ -322,12 +325,12 @@ public static void CtorExceptions() public static async Task WriteDuringReadAsync() { using var writer = new FileBufferingWriter(); - writer.Write(new byte[] { 1, 2, 3 }); + writer.Write(stackalloc byte[] { 1, 2, 3 }); using var manager = writer.GetWrittenContent(); Equal(new byte[] { 1, 2, 3 }, manager.Memory.ToArray()); - Throws(writer.Clear); + Throws(writer.As>().Clear); Throws(() => writer.WriteByte(2)); - Throws(() => writer.GetWrittenContent()); + Throws(writer.GetWrittenContent); await ThrowsAsync(() => writer.WriteAsync(new byte[2], 0, 2)); await ThrowsAsync(writer.GetWrittenContentAsync().AsTask); } diff --git a/src/DotNext.Tests/IO/FileWriterTests.cs b/src/DotNext.Tests/IO/FileWriterTests.cs index 5ef67fdf5..5dfab56bd 100644 --- a/src/DotNext.Tests/IO/FileWriterTests.cs +++ b/src/DotNext.Tests/IO/FileWriterTests.cs @@ -45,6 +45,25 @@ public static async Task WriteWithOverflowAsync() Equal(expected, actual); } + [Fact] + public static async Task WritDirectAsync() + { + var path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + using var handle = File.OpenHandle(path, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None, FileOptions.Asynchronous); + using var writer = new FileWriter(handle, bufferSize: 64); + + var expected = RandomBytes(writer.Buffer.Length << 2); + await writer.WriteAsync(expected.AsMemory(0, 63)); + await writer.WriteAsync(expected.AsMemory(63)); + False(writer.HasBufferedData); + Equal(expected.Length, writer.FilePosition); + + var actual = new byte[expected.Length]; + await RandomAccess.ReadAsync(handle, actual, 0L); + + Equal(expected, actual); + } + [Fact] public static void WriteWithoutOverflow() { diff --git a/src/DotNext.Tests/Threading/Tasks/ConversionTests.cs b/src/DotNext.Tests/Threading/Tasks/ConversionTests.cs index 0f7b4e27e..170ab9394 100644 --- a/src/DotNext.Tests/Threading/Tasks/ConversionTests.cs +++ b/src/DotNext.Tests/Threading/Tasks/ConversionTests.cs @@ -40,4 +40,11 @@ public static async Task DynamicTaskValueType() int result = await Task.FromResult(42).AsDynamic(); Equal(42, result); } + + [Fact] + public static async Task SuspendException() + { + await Task.FromException(new Exception()).SuspendException(); + await ValueTask.FromException(new Exception()).SuspendException(); + } } \ No newline at end of file diff --git a/src/DotNext.Threading/DotNext.Threading.csproj b/src/DotNext.Threading/DotNext.Threading.csproj index 8346ed8ee..14826210d 100644 --- a/src/DotNext.Threading/DotNext.Threading.csproj +++ b/src/DotNext.Threading/DotNext.Threading.csproj @@ -7,7 +7,7 @@ true true nullablePublicOnly - 5.0.0 + 5.0.1 .NET Foundation and Contributors .NEXT Family of Libraries diff --git a/src/DotNext.Threading/Threading/PendingTaskInterruptedException.cs b/src/DotNext.Threading/Threading/PendingTaskInterruptedException.cs index 4876a53ed..d67f0d4ca 100644 --- a/src/DotNext.Threading/Threading/PendingTaskInterruptedException.cs +++ b/src/DotNext.Threading/Threading/PendingTaskInterruptedException.cs @@ -5,18 +5,10 @@ namespace DotNext.Threading; /// it is in waiting state. /// /// -public class PendingTaskInterruptedException : Exception +/// The error message that explains the reason for the exception. +/// The exception that is the cause of the current exception. +public class PendingTaskInterruptedException(string? message = null, Exception? innerException = null) : Exception(message ?? ExceptionMessages.AsyncTaskInterrupted, innerException) { - /// - /// Initializes a new exception. - /// - /// The error message that explains the reason for the exception. - /// The exception that is the cause of the current exception. - public PendingTaskInterruptedException(string? message = null, Exception? innerException = null) - : base(message ?? ExceptionMessages.AsyncTaskInterrupted, innerException) - { - } - /// /// Gets the reason for lock steal. /// diff --git a/src/DotNext.Unsafe/DotNext.Unsafe.csproj b/src/DotNext.Unsafe/DotNext.Unsafe.csproj index 031359892..c85dd7f51 100644 --- a/src/DotNext.Unsafe/DotNext.Unsafe.csproj +++ b/src/DotNext.Unsafe/DotNext.Unsafe.csproj @@ -7,7 +7,7 @@ enable true true - 5.0.0 + 5.0.1 nullablePublicOnly .NET Foundation and Contributors diff --git a/src/DotNext/Buffers/IGrowableBuffer.cs b/src/DotNext/Buffers/IGrowableBuffer.cs index e565ca956..252f73d88 100644 --- a/src/DotNext/Buffers/IGrowableBuffer.cs +++ b/src/DotNext/Buffers/IGrowableBuffer.cs @@ -1,5 +1,4 @@ using System.ComponentModel; -using System.Runtime.InteropServices; using Debug = System.Diagnostics.Debug; using Unsafe = System.Runtime.CompilerServices.Unsafe; @@ -51,7 +50,7 @@ void IReadOnlySpanConsumer.Invoke(ReadOnlySpan input) /// /// The value to be written. /// The writer has been disposed. - void Write(T value) => Write(MemoryMarshal.CreateReadOnlySpan(ref value, 1)); + void Write(T value) => Write(new ReadOnlySpan(ref value)); /// /// Passes the contents of this writer to the callback. diff --git a/src/DotNext/Buffers/PoolingArrayBufferWriter.cs b/src/DotNext/Buffers/PoolingArrayBufferWriter.cs index 3783e5ef0..eac54c611 100644 --- a/src/DotNext/Buffers/PoolingArrayBufferWriter.cs +++ b/src/DotNext/Buffers/PoolingArrayBufferWriter.cs @@ -105,7 +105,7 @@ bool ICollection.Remove(T item) /// void IList.Insert(int index, T item) - => Insert(index, MemoryMarshal.CreateReadOnlySpan(ref item, 1)); + => Insert(index, new(ref item)); /// /// Inserts the elements into this buffer at the specified index. diff --git a/src/DotNext/Buffers/PoolingInterpolatedStringHandler.cs b/src/DotNext/Buffers/PoolingInterpolatedStringHandler.cs index bc08e75c2..3da678113 100644 --- a/src/DotNext/Buffers/PoolingInterpolatedStringHandler.cs +++ b/src/DotNext/Buffers/PoolingInterpolatedStringHandler.cs @@ -52,7 +52,7 @@ public PoolingInterpolatedStringHandler(int literalLength, int formattedCount, M void IReadOnlySpanConsumer.Invoke(ReadOnlySpan value) => AppendFormatted(value); /// - void IGrowableBuffer.Write(char value) => AppendFormatted(MemoryMarshal.CreateReadOnlySpan(ref value, 1)); + void IGrowableBuffer.Write(char value) => AppendFormatted(new(ref value)); /// readonly void IGrowableBuffer.CopyTo(TConsumer consumer) => consumer.Invoke(WrittenMemory.Span); diff --git a/src/DotNext/Collections/Specialized/InvocationList.cs b/src/DotNext/Collections/Specialized/InvocationList.cs index dd08d61ec..563fc64d8 100644 --- a/src/DotNext/Collections/Specialized/InvocationList.cs +++ b/src/DotNext/Collections/Specialized/InvocationList.cs @@ -216,8 +216,8 @@ public InvocationList Remove(TDelegate? d) [UnscopedRef] public ReadOnlySpan Span => list switch { - null => [], - TDelegate => MemoryMarshal.CreateReadOnlySpan(ref Unsafe.As(ref Unsafe.AsRef(in list)), 1), + null => ReadOnlySpan.Empty, + TDelegate => new(ref Unsafe.As(ref Unsafe.AsRef(in list))), _ => Unsafe.As(list), }; } diff --git a/src/DotNext/DotNext.csproj b/src/DotNext/DotNext.csproj index 872c640f6..d3345bacd 100644 --- a/src/DotNext/DotNext.csproj +++ b/src/DotNext/DotNext.csproj @@ -11,7 +11,7 @@ .NET Foundation and Contributors .NEXT Family of Libraries - 5.0.0 + 5.0.1 DotNext MIT diff --git a/src/DotNext/Runtime/CompilerServices/SuspendedExceptionTaskAwaitable.cs b/src/DotNext/Runtime/CompilerServices/SuspendedExceptionTaskAwaitable.cs new file mode 100644 index 000000000..e54206e2b --- /dev/null +++ b/src/DotNext/Runtime/CompilerServices/SuspendedExceptionTaskAwaitable.cs @@ -0,0 +1,92 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace DotNext.Runtime.CompilerServices; + +/// +/// Represents awaitable object that can suspend exception raised by the underlying task. +/// +[StructLayout(LayoutKind.Auto)] +public readonly struct SuspendedExceptionTaskAwaitable +{ + private readonly ValueTask task; + + internal SuspendedExceptionTaskAwaitable(ValueTask task) + => this.task = task; + + internal SuspendedExceptionTaskAwaitable(Task task) + => this.task = new(task); + + internal bool ContinueOnCapturedContext + { + get; + init; + } + + internal Predicate? Filter + { + get; + init; + } + + /// + /// Configures an awaiter for this value. + /// + /// + /// to attempt to marshal the continuation back to the captured context; + /// otherwise, . + /// + /// The configured object. + public SuspendedExceptionTaskAwaitable ConfigureAwait(bool continueOnCapturedContext) + => this with { ContinueOnCapturedContext = continueOnCapturedContext }; + + /// + /// Gets the awaiter for this object. + /// + /// The awaiter for this object. + public Awaiter GetAwaiter() => new(task, ContinueOnCapturedContext); + + /// + /// Represents the awaiter that suspends exception. + /// + [StructLayout(LayoutKind.Auto)] + public readonly struct Awaiter : ICriticalNotifyCompletion + { + private readonly ConfiguredValueTaskAwaitable.ConfiguredValueTaskAwaiter awaiter; + + internal Awaiter(in ValueTask task, bool continueOnCapturedContext) + { + awaiter = task.ConfigureAwait(continueOnCapturedContext).GetAwaiter(); + } + + internal Predicate? Filter + { + get; + init; + } + + /// + public bool IsCompleted => awaiter.IsCompleted; + + /// + public void OnCompleted(Action action) => awaiter.OnCompleted(action); + + /// + public void UnsafeOnCompleted(Action action) => awaiter.UnsafeOnCompleted(action); + + /// + /// Obtains a result of asynchronous operation, and suspends exception if needed. + /// + public void GetResult() + { + try + { + awaiter.GetResult(); + } + catch (Exception e) when (Filter?.Invoke(e) ?? true) + { + // suspend exception + } + } + } +} \ No newline at end of file diff --git a/src/DotNext/Threading/Tasks/Conversion.cs b/src/DotNext/Threading/Tasks/Conversion.cs index c1b2518e9..0283f4fcd 100644 --- a/src/DotNext/Threading/Tasks/Conversion.cs +++ b/src/DotNext/Threading/Tasks/Conversion.cs @@ -3,6 +3,8 @@ namespace DotNext.Threading.Tasks; +using SuspendedExceptionTaskAwaitable = Runtime.CompilerServices.SuspendedExceptionTaskAwaitable; + /// /// Provides task result conversion methods. /// @@ -64,4 +66,22 @@ public static async Task Convert(this Task tas /// The dynamically typed task. [RequiresUnreferencedCode("Runtime binding may be incompatible with IL trimming")] public static DynamicTaskAwaitable AsDynamic(this Task task) => new(task); + + /// + /// Suspends the exception that can be raised by the task. + /// + /// The task. + /// The filter of the exception to be suspended. + /// The awaitable object that suspends exceptions according to the filter. + public static SuspendedExceptionTaskAwaitable SuspendException(this Task task, Predicate? filter = null) + => new(task) { Filter = filter }; + + /// + /// Suspends the exception that can be raised by the task. + /// + /// The task. + /// The filter of the exception to be suspended. + /// The awaitable object that suspends exceptions according to the filter. + public static SuspendedExceptionTaskAwaitable SuspendException(this ValueTask task, Predicate? filter = null) + => new(task) { Filter = filter }; } \ No newline at end of file diff --git a/src/cluster/DotNext.AspNetCore.Cluster/DotNext.AspNetCore.Cluster.csproj b/src/cluster/DotNext.AspNetCore.Cluster/DotNext.AspNetCore.Cluster.csproj index 0f3cde0f0..d3b7bf5e6 100644 --- a/src/cluster/DotNext.AspNetCore.Cluster/DotNext.AspNetCore.Cluster.csproj +++ b/src/cluster/DotNext.AspNetCore.Cluster/DotNext.AspNetCore.Cluster.csproj @@ -8,7 +8,7 @@ true true nullablePublicOnly - 5.0.0 + 5.0.1 .NET Foundation and Contributors .NEXT Family of Libraries diff --git a/src/cluster/DotNext.Net.Cluster/DotNext.Net.Cluster.csproj b/src/cluster/DotNext.Net.Cluster/DotNext.Net.Cluster.csproj index 24702f3f2..0dc695230 100644 --- a/src/cluster/DotNext.Net.Cluster/DotNext.Net.Cluster.csproj +++ b/src/cluster/DotNext.Net.Cluster/DotNext.Net.Cluster.csproj @@ -8,7 +8,7 @@ enable true nullablePublicOnly - 5.0.0 + 5.0.1 .NET Foundation and Contributors .NEXT Family of Libraries