diff --git a/src/DynamicData.Tests/API/ApiApprovalTests.DynamicDataTests.DotNet9_0.verified.txt b/src/DynamicData.Tests/API/ApiApprovalTests.DynamicDataTests.DotNet9_0.verified.txt index ff1f94916..a225bfe5f 100644 --- a/src/DynamicData.Tests/API/ApiApprovalTests.DynamicDataTests.DotNet9_0.verified.txt +++ b/src/DynamicData.Tests/API/ApiApprovalTests.DynamicDataTests.DotNet9_0.verified.txt @@ -1964,6 +1964,14 @@ namespace DynamicData where TDestination : notnull where TSource : notnull where TKey : notnull { } + public static System.IObservable> TransformAsync(this System.IObservable> source, System.Func, TKey, System.Threading.CancellationToken, System.Threading.Tasks.Task> transformFactory, DynamicData.TransformAsyncOptions options) + where TDestination : notnull + where TSource : notnull + where TKey : notnull { } + public static System.IObservable> TransformAsync(this System.IObservable> source, System.Func, TKey, System.Threading.CancellationToken, System.Threading.Tasks.Task> transformFactory, System.IObservable>? forceTransform = null) + where TDestination : notnull + where TSource : notnull + where TKey : notnull { } public static System.IObservable> TransformImmutable(this System.IObservable> source, System.Func transformFactory) where TDestination : notnull where TSource : notnull @@ -2108,6 +2116,14 @@ namespace DynamicData where TDestination : notnull where TSource : notnull where TKey : notnull { } + public static System.IObservable> TransformSafeAsync(this System.IObservable> source, System.Func, TKey, System.Threading.CancellationToken, System.Threading.Tasks.Task> transformFactory, System.Action> errorHandler, DynamicData.TransformAsyncOptions options) + where TDestination : notnull + where TSource : notnull + where TKey : notnull { } + public static System.IObservable> TransformSafeAsync(this System.IObservable> source, System.Func, TKey, System.Threading.CancellationToken, System.Threading.Tasks.Task> transformFactory, System.Action> errorHandler, System.IObservable>? forceTransform = null) + where TDestination : notnull + where TSource : notnull + where TKey : notnull { } public static System.IObservable, TKey>> TransformToTree(this System.IObservable> source, System.Func pivotOn, System.IObservable, bool>>? predicateChanged = null) where TObject : class where TKey : notnull { } @@ -2481,6 +2497,9 @@ namespace DynamicData public static System.IObservable> TransformAsync(this System.IObservable> source, System.Func, int, System.Threading.Tasks.Task> transformFactory, bool transformOnRefresh = false) where TSource : notnull where TDestination : notnull { } + public static System.IObservable> TransformAsync(this System.IObservable> source, System.Func, int, System.Threading.CancellationToken, System.Threading.Tasks.Task> transformFactory, bool transformOnRefresh = false) + where TSource : notnull + where TDestination : notnull { } public static System.IObservable> TransformMany(this System.IObservable> source, System.Func> manySelector, System.Collections.Generic.IEqualityComparer? equalityComparer = null) where TDestination : notnull where TSource : notnull { } @@ -3100,4 +3119,4 @@ namespace DynamicData.Tests public void Dispose() { } protected virtual void Dispose(bool isDisposing) { } } -} +} \ No newline at end of file diff --git a/src/DynamicData/Cache/Internal/TransformAsync.cs b/src/DynamicData/Cache/Internal/TransformAsync.cs index 73b6f62a9..803fed8cd 100644 --- a/src/DynamicData/Cache/Internal/TransformAsync.cs +++ b/src/DynamicData/Cache/Internal/TransformAsync.cs @@ -10,7 +10,7 @@ namespace DynamicData.Cache.Internal; internal class TransformAsync( IObservable> source, - Func, TKey, Task> transformFactory, + Func, TKey, CancellationToken, Task> transformFactory, Action>? exceptionCallback, IObservable>? forceTransform = null, int? maximumConcurrency = null, @@ -45,7 +45,7 @@ private IObservable> DoTransform(ChangeAwareCache var toTransform = cache.KeyValues.Where(kvp => shouldTransform(kvp.Value.Source, kvp.Key)).Select(kvp => new Change(ChangeReason.Update, kvp.Key, kvp.Value.Source, kvp.Value.Source)).ToArray(); - return toTransform.Select(change => Observable.Defer(() => Transform(change).ToObservable())) + return toTransform.Select(change => Observable.FromAsync(t => Transform(change, t))) .Merge(maximumConcurrency ?? int.MaxValue) .ToArray() .Select(transformed => ProcessUpdates(cache, transformed)); @@ -54,7 +54,7 @@ private IObservable> DoTransform(ChangeAwareCache private IObservable> DoTransform( ChangeAwareCache cache, IChangeSet changes) { - return changes.Select(change => Observable.FromAsync(() => Transform(change))) + return changes.Select(change => Observable.FromAsync(t => Transform(change, t))) .Merge(maximumConcurrency ?? int.MaxValue) .ToArray() .Select(transformed => ProcessUpdates(cache, transformed)); @@ -105,13 +105,13 @@ private ChangeSet ProcessUpdates(ChangeAwareCache(transformed); } - private async Task Transform(Change change) + private async Task Transform(Change change, CancellationToken cancellationToken) { try { if (change.Reason is ChangeReason.Add or ChangeReason.Update || (change.Reason is ChangeReason.Refresh && transformOnRefresh)) { - var destination = await transformFactory(change.Current, change.Previous, change.Key) + var destination = await transformFactory(change.Current, change.Previous, change.Key, cancellationToken) .ConfigureAwait(false); return new TransformResult(change, new TransformedItemContainer(change.Current, destination)); } diff --git a/src/DynamicData/Cache/ObservableCacheEx.TransformAsync.cs b/src/DynamicData/Cache/ObservableCacheEx.TransformAsync.cs index dc13c5a2f..5e76e6cb8 100644 --- a/src/DynamicData/Cache/ObservableCacheEx.TransformAsync.cs +++ b/src/DynamicData/Cache/ObservableCacheEx.TransformAsync.cs @@ -54,6 +54,20 @@ public static IObservable> TransformAsync transformFactory(current, key), forceTransform); } + /// + /// This overload takes a factory that receives the current item the previous item and key. + [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] + public static IObservable> TransformAsync(this IObservable> source, Func, TKey, Task> transformFactory, IObservable>? forceTransform = null) + where TDestination : notnull + where TSource : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + + return source.TransformAsync((current, previous, key, _) => transformFactory(current, previous, key), forceTransform); + } + /// /// Async version of . /// Projects each item using an async factory that returns . @@ -88,7 +102,7 @@ public static IObservable> TransformAsync /// or is . [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] - public static IObservable> TransformAsync(this IObservable> source, Func, TKey, Task> transformFactory, IObservable>? forceTransform = null) + public static IObservable> TransformAsync(this IObservable> source, Func, TKey, CancellationToken, Task> transformFactory, IObservable>? forceTransform = null) where TDestination : notnull where TSource : notnull where TKey : notnull @@ -124,7 +138,7 @@ public static IObservable> TransformAsync transformFactory(current, key), options); + return TransformAsync(source, (current, _, key, _) => transformFactory(current, key), options); } /// @@ -138,6 +152,20 @@ public static IObservable> TransformAsync transformFactory(current, previous, key), options); + } + + /// + /// This overload accepts to control concurrency and Refresh handling. + [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] + public static IObservable> TransformAsync(this IObservable> source, Func, TKey, CancellationToken, Task> transformFactory, TransformAsyncOptions options) + where TDestination : notnull + where TSource : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + return new TransformAsync(source, transformFactory, null, null, options.MaximumConcurrency, options.TransformOnRefresh).Run(); } } diff --git a/src/DynamicData/Cache/ObservableCacheEx.TransformSafeAsync.cs b/src/DynamicData/Cache/ObservableCacheEx.TransformSafeAsync.cs index 45c842917..70477e563 100644 --- a/src/DynamicData/Cache/ObservableCacheEx.TransformSafeAsync.cs +++ b/src/DynamicData/Cache/ObservableCacheEx.TransformSafeAsync.cs @@ -55,6 +55,20 @@ public static IObservable> TransformSafeAsync transformFactory(current, key), errorHandler, forceTransform); } + /// + [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] + public static IObservable> TransformSafeAsync(this IObservable> source, Func, TKey, Task> transformFactory, Action> errorHandler, IObservable>? forceTransform = null) + where TDestination : notnull + where TSource : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + errorHandler.ThrowArgumentNullExceptionIfNull(nameof(errorHandler)); + + return source.TransformSafeAsync((s, p, k, t) => transformFactory(s, p, k), errorHandler, forceTransform); + } + /// /// Async version of . /// Projects each item using an async factory, catching factory exceptions via a mandatory error handler. @@ -63,14 +77,14 @@ public static IObservable> TransformSafeAsyncThe type of the source items. /// The type of the key. /// The source to transform asynchronously with error handling. - /// The async function that produces a . + /// The async function that produces a . /// A that called when throws or faults. The item is skipped and the stream continues. /// An optional that forces re-transformation of matching items. /// An observable changeset of transformed items. /// Combines the async execution model of with the error-safe behavior of . /// , , or is . [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] - public static IObservable> TransformSafeAsync(this IObservable> source, Func, TKey, Task> transformFactory, Action> errorHandler, IObservable>? forceTransform = null) + public static IObservable> TransformSafeAsync(this IObservable> source, Func, TKey, CancellationToken, Task> transformFactory, Action> errorHandler, IObservable>? forceTransform = null) where TDestination : notnull where TSource : notnull where TKey : notnull @@ -112,13 +126,24 @@ public static IObservable> TransformSafeAsync transformFactory(current, key), errorHandler, options); } - /// + /// /// This overload accepts to control concurrency and Refresh handling. [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] public static IObservable> TransformSafeAsync(this IObservable> source, Func, TKey, Task> transformFactory, Action> errorHandler, TransformAsyncOptions options) where TDestination : notnull where TSource : notnull where TKey : notnull + { + return source.TransformSafeAsync((current, previous, key, cancel) => transformFactory(current, previous, key), errorHandler, options); + } + + /// + /// This overload accepts to control concurrency and Refresh handling. + [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] + public static IObservable> TransformSafeAsync(this IObservable> source, Func, TKey, CancellationToken, Task> transformFactory, Action> errorHandler, TransformAsyncOptions options) + where TDestination : notnull + where TSource : notnull + where TKey : notnull { source.ThrowArgumentNullExceptionIfNull(nameof(source)); transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); diff --git a/src/DynamicData/List/Internal/TransformAsync.cs b/src/DynamicData/List/Internal/TransformAsync.cs index b43edc030..bc0ad23e9 100644 --- a/src/DynamicData/List/Internal/TransformAsync.cs +++ b/src/DynamicData/List/Internal/TransformAsync.cs @@ -10,23 +10,23 @@ internal sealed class TransformAsync where TSource : notnull where TDestination : notnull { - private readonly Func, int, Task.TransformedItemContainer>> _containerFactory; + private readonly Func, int, CancellationToken, Task.TransformedItemContainer>> _containerFactory; private readonly IObservable> _source; private readonly bool _transformOnRefresh; public TransformAsync( IObservable> source, - Func, int, Task> factory, + Func, int, CancellationToken, Task> factory, bool transformOnRefresh) { factory.ThrowArgumentNullExceptionIfNull(nameof(factory)); _source = source ?? throw new ArgumentNullException(nameof(source)); _transformOnRefresh = transformOnRefresh; - _containerFactory = async (item, prev, index) => + _containerFactory = async (item, prev, index, cancel) => { - var destination = await factory(item, prev, index).ConfigureAwait(false); + var destination = await factory(item, prev, index, cancel).ConfigureAwait(false); return new Transformer.TransformedItemContainer(item, destination); }; } @@ -35,33 +35,59 @@ public TransformAsync( private IObservable> RunImpl() { - var state = new ChangeAwareList.TransformedItemContainer>(); - var asyncLock = new SemaphoreSlim(1, 1); + return Observable.Using( + () => new SemaphoreSlim(1, 1), + asyncLock => + { + var state = new ChangeAwareList.TransformedItemContainer>(); - return _source.Select(async changes => - { - try - { - await asyncLock.WaitAsync(); - await Transform(state, changes); - return state; - } - finally + return _source.Select(changes => Observable.FromAsync(async cancel => + { + // NOTE: lock outside of the try to avoid releasing another scope's lock if the token is canceled before we acquire the lock. + try + { + await asyncLock.WaitAsync(cancel); + } + catch (Exception e) when (e is ObjectDisposedException || e is OperationCanceledException) + { + return Optional.None.TransformedItemContainer>>(); + } + + try + { + await Transform(state, changes, cancel); + return Optional.Some(state); + } + finally + { + try + { + // token is canceled when outer stream is disposed which is attached to the lock's lifetime. + if (!cancel.IsCancellationRequested) + { + asyncLock.Release(); + } + } + catch (ObjectDisposedException) + { + // outer stream was disposed during inner transform. + } + } + })) + .Concat() + .SelectValues() + .Select(transformed => { - asyncLock.Release(); - } - }) - .Concat() - .Select(transformed => - { - var changed = transformed.CaptureChanges(); - return changed.Transform(container => container.Destination); + var changed = transformed.CaptureChanges(); + return changed.Transform(container => container.Destination); + }); }); } private async Task Transform( ChangeAwareList.TransformedItemContainer> transformed, - IChangeSet changes) + IChangeSet changes, + CancellationToken cancel) { changes.ThrowArgumentNullExceptionIfNull(nameof(changes)); @@ -78,7 +104,8 @@ private async Task Transform( await _containerFactory( item.Item.Current, Optional.None, - transformed.Count).ConfigureAwait(false); + transformed.Count, + cancel).ConfigureAwait(false); transformed.Add(container); } else @@ -87,7 +114,8 @@ await _containerFactory( await _containerFactory( item.Item.Current, Optional.None, - change.CurrentIndex).ConfigureAwait(false); + change.CurrentIndex, + cancel).ConfigureAwait(false); transformed.Insert(change.CurrentIndex, container); } @@ -97,7 +125,7 @@ await _containerFactory( case ListChangeReason.AddRange: { var startIndex = item.Range.Index < 0 ? transformed.Count : item.Range.Index; - var tasks = item.Range.Select((t, idx) => _containerFactory(t, Optional.None, idx + startIndex)); + var tasks = item.Range.Select((t, idx) => _containerFactory(t, Optional.None, idx + startIndex, cancel)); var containers = await Task.WhenAll(tasks).ConfigureAwait(false); transformed.AddOrInsertRange(containers, item.Range.Index); break; @@ -109,7 +137,7 @@ await _containerFactory( if (_transformOnRefresh) { Optional previous = transformed[change.CurrentIndex].Destination; - var container = await _containerFactory(change.Current, previous, change.CurrentIndex) + var container = await _containerFactory(change.Current, previous, change.CurrentIndex, cancel) .ConfigureAwait(false); transformed[change.CurrentIndex] = container; } @@ -128,12 +156,12 @@ await _containerFactory( Optional previous = transformed[change.PreviousIndex].Destination; if (change.CurrentIndex == change.PreviousIndex) { - transformed[change.CurrentIndex] = await _containerFactory(change.Current, previous, change.CurrentIndex); + transformed[change.CurrentIndex] = await _containerFactory(change.Current, previous, change.CurrentIndex, cancel); } else { transformed.RemoveAt(change.PreviousIndex); - transformed.Insert(change.CurrentIndex, await _containerFactory(change.Current, Optional.None, change.CurrentIndex)); + transformed.Insert(change.CurrentIndex, await _containerFactory(change.Current, Optional.None, change.CurrentIndex, cancel)); } break; diff --git a/src/DynamicData/List/ObservableListEx.TransformAsync.cs b/src/DynamicData/List/ObservableListEx.TransformAsync.cs index 998eb1419..d130fa3b9 100644 --- a/src/DynamicData/List/ObservableListEx.TransformAsync.cs +++ b/src/DynamicData/List/ObservableListEx.TransformAsync.cs @@ -102,7 +102,7 @@ public static IObservable> TransformAsync /// - /// Async transform overload receiving the source item, previously transformed value, and index. This is the terminal overload that all other TransformAsync overloads delegate to. + /// Async transform overload receiving the source item, previously transformed value, and index. /// [System.Diagnostics.CodeAnalysis.SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] public static IObservable> TransformAsync( @@ -115,6 +115,24 @@ public static IObservable> TransformAsync(source, (t, d, i, _) => transformFactory(t, d, i), transformOnRefresh).Run(); + } + + /// + /// + /// Async transform overload receiving the source item, previously transformed value, index, and CancellationToken attached to the underlying subscription. This is the terminal overload that all other TransformAsync overloads delegate to. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] + public static IObservable> TransformAsync( + this IObservable> source, + Func, int, CancellationToken, Task> transformFactory, + bool transformOnRefresh = false) + where TSource : notnull + where TDestination : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + return new TransformAsync(source, transformFactory, transformOnRefresh).Run(); } }