From d3190034c67d7781bfef066f1a28aa5bc4c63046 Mon Sep 17 00:00:00 2001 From: JakenVeina Date: Wed, 11 Jun 2025 02:08:34 -0500 Subject: [PATCH] Re-wrote .ToObservableChangeSet() operators for both Cache and List, to eliminate a deadlocking issue. See #998 --- ...syncDisposeManyFixture.IntegrationTests.cs | 209 +++++++ .../AsyncDisposeManyFixture.UnitTests.cs | 378 ++++++++++++ .../Cache/AsyncDisposeManyFixture.cs | 563 +---------------- ...eObservableToObservableChangeSetFixture.cs | 140 ----- .../ObservableToObservableChangeSetFixture.cs | 160 ----- ...ChangeSetFixture.Items.IntegrationTests.cs | 85 +++ ...ervableChangeSetFixture.Items.UnitTests.cs | 550 +++++++++++++++++ ...geSetFixture.Sequences.IntegrationTests.cs | 91 +++ ...bleChangeSetFixture.Sequences.UnitTests.cs | 507 +++++++++++++++ .../Cache/ToObservableChangeSetFixture.cs | 329 +--------- .../IntegrationTestFixtureBase.cs | 11 + ...ChangeSetFixture.Items.IntegrationTests.cs | 83 +++ ...ervableChangeSetFixture.Items.UnitTests.cs | 369 +++++++++++ ...geSetFixture.Sequences.IntegrationTests.cs | 89 +++ ...bleChangeSetFixture.Sequences.UnitTests.cs | 361 +++++++++++ .../List/ToObservableChangeSetFixture.cs | 378 +----------- .../CacheItemRecordingObserverAssertions.cs | 24 + .../Utilities/RecordingObserverBase.cs | 19 +- .../Cache/Internal/ToObservableChangeSet.cs | 582 +++++++----------- src/DynamicData/Cache/ObservableCacheEx.cs | 28 +- .../List/Internal/ToObservableChangeSet.cs | 575 ++++++++--------- src/DynamicData/List/ObservableListEx.cs | 150 +++-- 22 files changed, 3381 insertions(+), 2300 deletions(-) create mode 100644 src/DynamicData.Tests/Cache/AsyncDisposeManyFixture.IntegrationTests.cs create mode 100644 src/DynamicData.Tests/Cache/AsyncDisposeManyFixture.UnitTests.cs delete mode 100644 src/DynamicData.Tests/Cache/EnumerableObservableToObservableChangeSetFixture.cs delete mode 100644 src/DynamicData.Tests/Cache/ObservableToObservableChangeSetFixture.cs create mode 100644 src/DynamicData.Tests/Cache/ToObservableChangeSetFixture.Items.IntegrationTests.cs create mode 100644 src/DynamicData.Tests/Cache/ToObservableChangeSetFixture.Items.UnitTests.cs create mode 100644 src/DynamicData.Tests/Cache/ToObservableChangeSetFixture.Sequences.IntegrationTests.cs create mode 100644 src/DynamicData.Tests/Cache/ToObservableChangeSetFixture.Sequences.UnitTests.cs create mode 100644 src/DynamicData.Tests/IntegrationTestFixtureBase.cs create mode 100644 src/DynamicData.Tests/List/ToObservableChangeSetFixture.Items.IntegrationTests.cs create mode 100644 src/DynamicData.Tests/List/ToObservableChangeSetFixture.Items.UnitTests.cs create mode 100644 src/DynamicData.Tests/List/ToObservableChangeSetFixture.Sequences.IntegrationTests.cs create mode 100644 src/DynamicData.Tests/List/ToObservableChangeSetFixture.Sequences.UnitTests.cs create mode 100644 src/DynamicData.Tests/Utilities/CacheItemRecordingObserverAssertions.cs diff --git a/src/DynamicData.Tests/Cache/AsyncDisposeManyFixture.IntegrationTests.cs b/src/DynamicData.Tests/Cache/AsyncDisposeManyFixture.IntegrationTests.cs new file mode 100644 index 000000000..cb6da34b4 --- /dev/null +++ b/src/DynamicData.Tests/Cache/AsyncDisposeManyFixture.IntegrationTests.cs @@ -0,0 +1,209 @@ +using System; +using System.Linq; +using System.Reactive; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using System.Threading.Tasks; + +using DynamicData.Kernel; +using DynamicData.Tests.Utilities; + +using FluentAssertions; + +using Xunit; + +namespace DynamicData.Tests.Cache; + +public static partial class AsyncDisposeManyFixture +{ + public class IntegrationTests + : IntegrationTestFixtureBase + { + [Theory(Timeout = 5_000)] + [InlineData(ItemType.Disposable)] + [InlineData(ItemType.AsyncDisposable)] + [InlineData(ItemType.ImmediateAsyncDisposable)] + public async Task ItemDisposalErrors_ErrorPropagatesToDisposalsCompleted(ItemType itemType) + { + using var source = new SourceCache(static item => item.Id); + using var sourceCompletionSource = new Subject(); + + ValueRecordingObserver? disposalsCompletedResults = null; + + using var subscription = source + .Connect() + .TakeUntil(sourceCompletionSource) + .AsyncDisposeMany(disposalsCompleted => + { + disposalsCompletedResults.Should().BeNull("disposalsCompletedAccessor should only be invoked once per subscription"); + disposalsCompleted.RecordValues(out disposalsCompletedResults); + }) + .ValidateSynchronization() + .ValidateChangeSets(static item => item.Id) + .RecordCacheItems(out var results); + + disposalsCompletedResults.Should().NotBeNull("disposalsCompletedAccessor should have been invoked"); + + + source.AddOrUpdate(new[] + { + ItemBase.Create(type: itemType, id: 1, version: 1), + ItemBase.Create(type: itemType, id: 2, version: 1), + ItemBase.Create(type: itemType, id: 3, version: 1) + }); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "1 source operation was performed"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added"); + results.HasCompleted.Should().BeFalse(); + + disposalsCompletedResults.Error.Should().BeNull(); + disposalsCompletedResults.RecordedValues.Should().BeEmpty("no disposals should have occurred"); + disposalsCompletedResults.HasCompleted.Should().BeFalse("no disposals should have occurred"); + + + var error = new Exception("Test"); + source.Items.ElementAt(1).FailDisposal(error); + + sourceCompletionSource.OnNext(Unit.Default); + + // RX and TPL don't guarantee Task continuations run synchronously with antecedent completion + await disposalsCompletedResults.WhenFinalized; + + results.Error.Should().BeNull("disposal errors should be propagated on disposalsCompleted"); + results.RecordedChangeSets.Skip(1).Should().BeEmpty("no source operations were performed"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "no items were changed"); + results.HasCompleted.Should().BeTrue(); + + disposalsCompletedResults.Error.Should().Be(error, "disposal errors should be caught and propagated on disposalsCompleted"); + } + + [Theory(Timeout = 5_000)] + [InlineData(ItemType.Plain)] + [InlineData(ItemType.Disposable)] + [InlineData(ItemType.AsyncDisposable)] + [InlineData(ItemType.ImmediateAsyncDisposable)] + public async Task ItemDisposalsComplete_DisposalsCompletedOccursAndCompletes(ItemType itemType) + { + using var source = new SourceCache(static item => item.Id); + using var sourceCompletionSource = new Subject(); + + ValueRecordingObserver? disposalsCompletedResults = null; + + using var subscription = source + .Connect() + .TakeUntil(sourceCompletionSource) + .AsyncDisposeMany(disposalsCompleted => + { + disposalsCompletedResults.Should().BeNull("disposalsCompletedAccessor should only be invoked once per subscription"); + disposalsCompleted.RecordValues(out disposalsCompletedResults); + }) + .ValidateSynchronization() + .ValidateChangeSets(static item => item.Id) + .RecordCacheItems(out var results); + + disposalsCompletedResults.Should().NotBeNull("disposalsCompletedAccessor should have been invoked"); + + + source.AddOrUpdate(new[] + { + ItemBase.Create(type: itemType, id: 1, version: 1), + ItemBase.Create(type: itemType, id: 2, version: 1), + ItemBase.Create(type: itemType, id: 3, version: 1) + }); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "1 source operation was performed"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added"); + results.HasCompleted.Should().BeFalse(); + + disposalsCompletedResults.Error.Should().BeNull(); + disposalsCompletedResults.RecordedValues.Should().BeEmpty("the source has not completed"); + disposalsCompletedResults.HasCompleted.Should().BeFalse("the source has not completed"); + + + sourceCompletionSource.OnNext(Unit.Default); + foreach (var item in source.Items) + item.CompleteDisposal(); + + // RX and TPL don't guarantee Task continuations run synchronously with antecedent completion + await disposalsCompletedResults.WhenFinalized; + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(1).Should().BeEmpty("no source operations were performed"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "no items were changed"); + results.HasCompleted.Should().BeTrue(); + + disposalsCompletedResults.Error.Should().BeNull(); + disposalsCompletedResults.RecordedValues.Count.Should().Be(1, "the source and all disposals have completed"); + disposalsCompletedResults.HasCompleted.Should().BeTrue("the source and all disposals have completed"); + } + + [Fact(Timeout = 5_000)] + public async Task ItemDisposalsOccurOnMultipleThreads_DisposalIsThreadSafe() + { + using var source = new SourceCache(static item => item.Id); + using var sourceCompletionSource = new Subject(); + + ValueRecordingObserver? disposalsCompletedResults = null; + + using var subscription = source + .Connect() + .TakeUntil(sourceCompletionSource) + .AsyncDisposeMany(disposalsCompleted => + { + disposalsCompletedResults.Should().BeNull("disposalsCompletedAccessor should only be invoked once per subscription"); + disposalsCompleted.RecordValues(out disposalsCompletedResults); + }) + .ValidateSynchronization() + .ValidateChangeSets(static item => item.Id) + .RecordCacheItems(out var results); + + disposalsCompletedResults.Should().NotBeNull("disposalsCompletedAccessor should have been invoked"); + + + var items = Enumerable.Range(1, 100_000) + .Select(id => new AsyncDisposableItem() + { + Id = id, + Version = 1 + }) + .ToArray(); + + source.AddOrUpdate(items); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "1 source operation was performed"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "items were added"); + results.HasCompleted.Should().BeFalse(); + + disposalsCompletedResults.Error.Should().BeNull(); + disposalsCompletedResults.RecordedValues.Should().BeEmpty("the source has not completed"); + disposalsCompletedResults.HasCompleted.Should().BeFalse("the source has not completed"); + + + sourceCompletionSource.OnNext(); + await Task.WhenAll(items + .GroupBy(item => item.Id % 4) + .Select(group => Task.Run(() => + { + foreach (var item in group) + item.CompleteDisposal(); + }))); + + // RX and TPL don't guarantee Task continuations run synchronously with antecedent completion + await disposalsCompletedResults.WhenFinalized; + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "1 source operation was performed"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(items, "no items were removed"); + results.HasCompleted.Should().BeTrue(); + + items.Should().AllSatisfy(item => item.HasBeenDisposed.Should().BeTrue(), "disposable items should be disposed upon source completion"); + + disposalsCompletedResults.Error.Should().BeNull(); + disposalsCompletedResults.RecordedValues.Count.Should().Be(1, "the source and all disposals have completed"); + disposalsCompletedResults.HasCompleted.Should().BeTrue("the source and all disposals have completed"); + } + } +} diff --git a/src/DynamicData.Tests/Cache/AsyncDisposeManyFixture.UnitTests.cs b/src/DynamicData.Tests/Cache/AsyncDisposeManyFixture.UnitTests.cs new file mode 100644 index 000000000..c6eea36b0 --- /dev/null +++ b/src/DynamicData.Tests/Cache/AsyncDisposeManyFixture.UnitTests.cs @@ -0,0 +1,378 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reactive; +using System.Reactive.Linq; +using System.Reactive.Subjects; + +using DynamicData.Cache.Internal; +using DynamicData.Kernel; +using DynamicData.Tests.Utilities; + +using FluentAssertions; + +using Xunit; + +namespace DynamicData.Tests.Cache; + +public static partial class AsyncDisposeManyFixture +{ + public class UnitTests + { + [Theory] + [InlineData(ItemType.Plain)] + [InlineData(ItemType.Disposable)] + [InlineData(ItemType.AsyncDisposable)] + [InlineData(ItemType.ImmediateAsyncDisposable)] + public void ItemsAreAddedMovedOrRefreshed_ItemsAreNotDisposed(ItemType itemType) + { + using var source = new Subject>(); + + ValueRecordingObserver? disposalsCompletedResults = null; + + using var subscription = source + .AsyncDisposeMany(disposalsCompleted => + { + disposalsCompletedResults.Should().BeNull("disposalsCompletedAccessor should only be invoked once per subscription"); + disposalsCompleted.RecordValues(out disposalsCompletedResults); + }) + .ValidateSynchronization() + .ValidateChangeSets(static item => item.Id) + .RecordCacheItems(out var results); + + disposalsCompletedResults.Should().NotBeNull("disposalsCompletedAccessor should have been invoked"); + + + // Addition + var items = new List() + { + ItemBase.Create(type: itemType, id: 1, version: 1), + ItemBase.Create(type: itemType, id: 2, version: 1), + ItemBase.Create(type: itemType, id: 3, version: 1) + }; + + source.OnNext(new ChangeSet(items + .Select((item, index) => new Change( + reason: ChangeReason.Add, + key: item.Id, + current: item, + index: index)))); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "1 source operation was performed"); + results.RecordedItemsSorted.Should().BeEquivalentTo( + items, + options => options.WithStrictOrdering(), + "3 items were added"); + results.HasCompleted.Should().BeFalse(); + + items.Should().AllSatisfy(item => item.HasBeenDisposed.Should().BeFalse(), "items should not be disposed upon add"); + + disposalsCompletedResults.Error.Should().BeNull(); + disposalsCompletedResults.RecordedValues.Should().BeEmpty("the source has not completed"); + disposalsCompletedResults.HasCompleted.Should().BeFalse("the source has not completed"); + + + // Movement + items.Move(2, 0, items[2]); + items.Move(2, 1, items[2]); + source.OnNext(new ChangeSet() + { + new(reason: ChangeReason.Moved, key: items[0].Id, current: items[0], previous: Optional.None(), currentIndex: 0, previousIndex: 2), + new(reason: ChangeReason.Moved, key: items[1].Id, current: items[1], previous: Optional.None(), currentIndex: 1, previousIndex: 2) + }); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "1 source operation was performed"); + results.RecordedItemsSorted.Should().BeEquivalentTo( + items, + options => options.WithStrictOrdering(), + "3 items were added"); + results.HasCompleted.Should().BeFalse(); + + items.Should().AllSatisfy(item => item.HasBeenDisposed.Should().BeFalse(), "items should not be disposed upon movement"); + + disposalsCompletedResults.Error.Should().BeNull(); + disposalsCompletedResults.RecordedValues.Should().BeEmpty("the source has not completed"); + disposalsCompletedResults.HasCompleted.Should().BeFalse("the source has not completed"); + + + // Refreshing + source.OnNext(new ChangeSet(items + .Select((item, index) => new Change( + reason: ChangeReason.Refresh, + key: item.Id, + current: item, + index: index)))); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(2).Count().Should().Be(1, "1 source operation was performed"); + results.RecordedItemsSorted.Should().BeEquivalentTo( + items, + options => options.WithStrictOrdering(), + "3 items were added"); + results.HasCompleted.Should().BeFalse(); + + items.Should().AllSatisfy(item => item.HasBeenDisposed.Should().BeFalse(), "items should not be disposed upon refresh"); + + disposalsCompletedResults.Error.Should().BeNull(); + disposalsCompletedResults.RecordedValues.Should().BeEmpty("the source has not completed"); + disposalsCompletedResults.HasCompleted.Should().BeFalse("the source has not completed"); + } + + [Theory] + [InlineData(ItemType.Plain)] + [InlineData(ItemType.Disposable)] + [InlineData(ItemType.AsyncDisposable)] + [InlineData(ItemType.ImmediateAsyncDisposable)] + public void ItemsAreRemoved_ItemsAreDisposedAfterDownstreamProcessing(ItemType itemType) + { + using var source = new SourceCache(static item => item.Id); + + ValueRecordingObserver? disposalsCompletedResults = null; + + using var subscription = source + .Connect() + .AsyncDisposeMany(disposalsCompleted => + { + disposalsCompletedResults.Should().BeNull("disposalsCompletedAccessor should only be invoked once per subscription"); + disposalsCompleted.RecordValues(out disposalsCompletedResults); + }) + .ValidateSynchronization() + .Do(changes => + { + foreach (var change in changes) + if (change.Reason is ChangeReason.Remove) + change.Current.HasBeenDisposed.Should().BeFalse("disposal should only occur after downstream processing has completed"); + }) + .ValidateChangeSets(static item => item.Id) + .RecordCacheItems(out var results); + + disposalsCompletedResults.Should().NotBeNull("disposalsCompletedAccessor should have been invoked"); + + + source.AddOrUpdate(new[] + { + ItemBase.Create(type: itemType, id: 1, version: 1), + ItemBase.Create(type: itemType, id: 2, version: 1), + ItemBase.Create(type: itemType, id: 3, version: 1) + }); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "1 source operation was performed"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added"); + results.HasCompleted.Should().BeFalse(); + + disposalsCompletedResults.Error.Should().BeNull(); + disposalsCompletedResults.RecordedValues.Should().BeEmpty("the source has not completed"); + disposalsCompletedResults.HasCompleted.Should().BeFalse("the source has not completed"); + + + var items = source.Items.ToArray(); + source.Clear(); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "1 source operation was performed"); + results.RecordedItemsByKey.Values.Should().BeEmpty("all items were removed"); + results.HasCompleted.Should().BeFalse(); + + items.Where(static item => item.CanBeDisposed).Should().AllSatisfy(item => item.HasBeenDisposed.Should().BeTrue(), "disposable items should be disposed after removal"); + + disposalsCompletedResults.Error.Should().BeNull(); + disposalsCompletedResults.RecordedValues.Should().BeEmpty("the source has not completed"); + disposalsCompletedResults.HasCompleted.Should().BeFalse("the source has not completed"); + } + + [Theory] + [InlineData(ItemType.Plain)] + [InlineData(ItemType.Disposable)] + [InlineData(ItemType.AsyncDisposable)] + [InlineData(ItemType.ImmediateAsyncDisposable)] + public void ItemsAreUpdated_PreviousItemsAreDisposedAfterDownstreamProcessing(ItemType itemType) + { + using var source = new SourceCache(static item => item.Id); + + ValueRecordingObserver? disposalsCompletedResults = null; + + using var subscription = source + .Connect() + .AsyncDisposeMany(disposalsCompleted => + { + disposalsCompletedResults.Should().BeNull("disposalsCompletedAccessor should only be invoked once per subscription"); + disposalsCompleted.RecordValues(out disposalsCompletedResults); + }) + .ValidateSynchronization() + .Do(changes => + { + foreach (var change in changes) + if (change.Reason is ChangeReason.Remove) + change.Current.HasBeenDisposed.Should().BeFalse("disposal should only occur after downstream processing has completed"); + }) + .ValidateChangeSets(static item => item.Id) + .RecordCacheItems(out var results); + + disposalsCompletedResults.Should().NotBeNull("disposalsCompletedAccessor should have been invoked"); + + + source.AddOrUpdate(new[] + { + ItemBase.Create(type: itemType, id: 1, version: 1), + ItemBase.Create(type: itemType, id: 2, version: 1), + ItemBase.Create(type: itemType, id: 3, version: 1) + }); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "1 source operation was performed"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added"); + results.HasCompleted.Should().BeFalse(); + + disposalsCompletedResults.Error.Should().BeNull(); + disposalsCompletedResults.RecordedValues.Should().BeEmpty("the source has not completed"); + disposalsCompletedResults.HasCompleted.Should().BeFalse("the source has not completed"); + + + var previousItems = source.Items.ToArray(); + source.AddOrUpdate(new[] + { + ItemBase.Create(type: itemType, id: 1, version: 2), + ItemBase.Create(type: itemType, id: 2, version: 2), + ItemBase.Create(type: itemType, id: 3, version: 2) + }); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "1 source operation was performed"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "all items were replaced"); + results.HasCompleted.Should().BeFalse(); + + previousItems.Where(static item => item.CanBeDisposed).Should().AllSatisfy(item => item.HasBeenDisposed.Should().BeTrue(), "disposable items should be disposed after replacement"); + + disposalsCompletedResults.Error.Should().BeNull(); + disposalsCompletedResults.RecordedValues.Should().BeEmpty("the source has not completed"); + disposalsCompletedResults.HasCompleted.Should().BeFalse("the source has not completed"); + } + + [Fact] + public void OnDisposalsCompletedIsNull_ThrowsException() + => FluentActions.Invoking(() => ObservableCacheEx.AsyncDisposeMany( + source: Observable.Empty>(), + disposalsCompletedAccessor: null!)) + .Should() + .Throw(); + + [Theory] + [InlineData(SourceType.Subject)] + [InlineData(SourceType.Immediate)] + public void SourceCompletes_ItemsAreDisposedAndCompletionPropagates(SourceType sourceType) + { + var items = new[] + { + new ImmediateAsyncDisposableItem() { Id = 1, Version = 1}, + new ImmediateAsyncDisposableItem() { Id = 2, Version = 1}, + new ImmediateAsyncDisposableItem() { Id = 3, Version = 1} + }; + + var changeSet = new ChangeSet(items + .Select(item => new Change(reason: ChangeReason.Add, key: item.Id, current: item))); + + IObservable> source = (sourceType is SourceType.Immediate) + ? Observable.Return(changeSet) + : new Subject>(); + + + ValueRecordingObserver? disposalsCompletedResults = null; + + using var subscription = source + .AsyncDisposeMany(disposalsCompleted => + { + disposalsCompletedResults.Should().BeNull("disposalsCompletedAccessor should only be invoked once per subscription"); + disposalsCompleted.RecordValues(out disposalsCompletedResults); + }) + .ValidateSynchronization() + .ValidateChangeSets(static item => item.Id) + .RecordCacheItems(out var results); + + disposalsCompletedResults.Should().NotBeNull("disposalsCompletedAccessor should have been invoked"); + + + if (source is Subject> subject) + { + subject.OnNext(changeSet); + subject.OnCompleted(); + } + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "1 source operation was performed"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(items, "3 items were added"); + results.HasCompleted.Should().BeTrue(); + + items.Should().AllSatisfy(item => item.HasBeenDisposed.Should().BeTrue(), "disposable items should be disposed upon source completion"); + + disposalsCompletedResults.Error.Should().BeNull(); + disposalsCompletedResults.RecordedValues.Count.Should().Be(1, "all items have completed disposal"); + disposalsCompletedResults.HasCompleted.Should().BeTrue("all items have completed disposal"); + } + + [Theory] + [InlineData(SourceType.Subject)] + [InlineData(SourceType.Immediate)] + public void SourceErrors_ItemsAreDisposedAndErrorPropagates(SourceType sourceType) + { + var items = new[] + { + new ImmediateAsyncDisposableItem() { Id = 1, Version = 1}, + new ImmediateAsyncDisposableItem() { Id = 2, Version = 1}, + new ImmediateAsyncDisposableItem() { Id = 3, Version = 1} + }; + + var changeSet = new ChangeSet(items + .Select(item => new Change(reason: ChangeReason.Add, key: item.Id, current: item))); + + var error = new Exception("Test"); + + IObservable> source = (sourceType is SourceType.Immediate) + ? Observable.Return(changeSet) + .Concat(Observable.Throw>(error)) + : new Subject>(); + + + ValueRecordingObserver? disposalsCompletedResults = null; + + using var subscription = source + .AsyncDisposeMany(disposalsCompleted => + { + disposalsCompletedResults.Should().BeNull("disposalsCompletedAccessor should only be invoked once per subscription"); + disposalsCompleted.RecordValues(out disposalsCompletedResults); + }) + .ValidateSynchronization() + .ValidateChangeSets(static item => item.Id) + .RecordCacheItems(out var results); + + disposalsCompletedResults.Should().NotBeNull("disposalsCompletedAccessor should have been invoked"); + + + if (source is Subject> subject) + { + subject.OnNext(changeSet); + subject.OnError(error); + } + + results.Error.Should().Be(error); + results.RecordedChangeSets.Count.Should().Be(1, "1 source operation was performed"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(items, "3 items were added"); + + items.Should().AllSatisfy(item => item.HasBeenDisposed.Should().BeTrue(), "disposable items should be disposed upon source failure"); + + disposalsCompletedResults.Error.Should().BeNull(); + disposalsCompletedResults.RecordedValues.Count.Should().Be(1, "all items have completed disposal"); + disposalsCompletedResults.HasCompleted.Should().BeTrue("all items have completed disposal"); + } + + [Fact] + public void SourceIsNull_ThrowsException() + => FluentActions.Invoking(() => ObservableCacheEx.AsyncDisposeMany( + source: null!, + disposalsCompletedAccessor: static _ => { })) + .Should() + .Throw(); + } +} diff --git a/src/DynamicData.Tests/Cache/AsyncDisposeManyFixture.cs b/src/DynamicData.Tests/Cache/AsyncDisposeManyFixture.cs index b87fbb701..38da53d5d 100644 --- a/src/DynamicData.Tests/Cache/AsyncDisposeManyFixture.cs +++ b/src/DynamicData.Tests/Cache/AsyncDisposeManyFixture.cs @@ -1,27 +1,9 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Reactive; -using System.Reactive.Concurrency; -using System.Reactive.Disposables; -using System.Reactive.Linq; -using System.Reactive.Subjects; -using System.Threading; using System.Threading.Tasks; -using DynamicData.Cache.Internal; -using DynamicData.Kernel; -using DynamicData.Tests.Utilities; - -using FluentAssertions; -using FluentAssertions.Equivalency.Steps; - -using Xunit; -using Xunit.Abstractions; - namespace DynamicData.Tests.Cache; -public class AsyncDisposeManyFixture +public static partial class AsyncDisposeManyFixture { public enum SourceType { @@ -37,549 +19,6 @@ public enum ItemType ImmediateAsyncDisposable } - [Theory] - [InlineData(ItemType.Disposable)] - [InlineData(ItemType.AsyncDisposable)] - [InlineData(ItemType.ImmediateAsyncDisposable)] - public async Task ItemDisposalErrors_ErrorPropagatesToDisposalsCompleted(ItemType itemType) - { - using var source = new SourceCache(static item => item.Id); - using var sourceCompletionSource = new Subject(); - - ValueRecordingObserver? disposalsCompletedResults = null; - - using var subscription = source - .Connect() - .TakeUntil(sourceCompletionSource) - .AsyncDisposeMany(disposalsCompleted => - { - disposalsCompletedResults.Should().BeNull("disposalsCompletedAccessor should only be invoked once per subscription"); - disposalsCompleted.RecordValues(out disposalsCompletedResults); - }) - .ValidateSynchronization() - .ValidateChangeSets(static item => item.Id) - .RecordCacheItems(out var results); - - disposalsCompletedResults.Should().NotBeNull("disposalsCompletedAccessor should have been invoked"); - - - source.AddOrUpdate(new[] - { - ItemBase.Create(type: itemType, id: 1, version: 1), - ItemBase.Create(type: itemType, id: 2, version: 1), - ItemBase.Create(type: itemType, id: 3, version: 1) - }); - - results.Error.Should().BeNull(); - results.RecordedChangeSets.Count.Should().Be(1, "1 source operation was performed"); - results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added"); - results.HasCompleted.Should().BeFalse(); - - disposalsCompletedResults.Error.Should().BeNull(); - disposalsCompletedResults.RecordedValues.Should().BeEmpty("no disposals should have occurred"); - disposalsCompletedResults.HasCompleted.Should().BeFalse("no disposals should have occurred"); - - - var error = new Exception("Test"); - source.Items.ElementAt(1).FailDisposal(error); - - sourceCompletionSource.OnNext(Unit.Default); - - // RX and TPL don't guarantee Task continuations run synchronously with antecedent completion - await disposalsCompletedResults.WaitForFinalizationAsync(TimeSpan.FromSeconds(5)); - - results.Error.Should().BeNull("disposal errors should be propagated on disposalsCompleted"); - results.RecordedChangeSets.Skip(1).Should().BeEmpty("no source operations were performed"); - results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "no items were changed"); - results.HasCompleted.Should().BeTrue(); - - disposalsCompletedResults.Error.Should().Be(error, "disposal errors should be caught and propagated on disposalsCompleted"); - } - - [Theory] - [InlineData(ItemType.Plain)] - [InlineData(ItemType.Disposable)] - [InlineData(ItemType.AsyncDisposable)] - [InlineData(ItemType.ImmediateAsyncDisposable)] - public async Task ItemDisposalsComplete_DisposalsCompletedOccursAndCompletes(ItemType itemType) - { - using var source = new SourceCache(static item => item.Id); - using var sourceCompletionSource = new Subject(); - - ValueRecordingObserver? disposalsCompletedResults = null; - - using var subscription = source - .Connect() - .TakeUntil(sourceCompletionSource) - .AsyncDisposeMany(disposalsCompleted => - { - disposalsCompletedResults.Should().BeNull("disposalsCompletedAccessor should only be invoked once per subscription"); - disposalsCompleted.RecordValues(out disposalsCompletedResults); - }) - .ValidateSynchronization() - .ValidateChangeSets(static item => item.Id) - .RecordCacheItems(out var results); - - disposalsCompletedResults.Should().NotBeNull("disposalsCompletedAccessor should have been invoked"); - - - source.AddOrUpdate(new[] - { - ItemBase.Create(type: itemType, id: 1, version: 1), - ItemBase.Create(type: itemType, id: 2, version: 1), - ItemBase.Create(type: itemType, id: 3, version: 1) - }); - - results.Error.Should().BeNull(); - results.RecordedChangeSets.Count.Should().Be(1, "1 source operation was performed"); - results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added"); - results.HasCompleted.Should().BeFalse(); - - disposalsCompletedResults.Error.Should().BeNull(); - disposalsCompletedResults.RecordedValues.Should().BeEmpty("the source has not completed"); - disposalsCompletedResults.HasCompleted.Should().BeFalse("the source has not completed"); - - - sourceCompletionSource.OnNext(Unit.Default); - foreach (var item in source.Items) - item.CompleteDisposal(); - - // RX and TPL don't guarantee Task continuations run synchronously with antecedent completion - await disposalsCompletedResults.WaitForFinalizationAsync(TimeSpan.FromSeconds(5)); - - results.Error.Should().BeNull(); - results.RecordedChangeSets.Skip(1).Should().BeEmpty("no source operations were performed"); - results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "no items were changed"); - results.HasCompleted.Should().BeTrue(); - - disposalsCompletedResults.Error.Should().BeNull(); - disposalsCompletedResults.RecordedValues.Count.Should().Be(1, "the source and all disposals have completed"); - disposalsCompletedResults.HasCompleted.Should().BeTrue("the source and all disposals have completed"); - } - - [Fact] - public async Task ItemDisposalsOccurOnMultipleThreads_DisposalIsThreadSafe() - { - using var source = new SourceCache(static item => item.Id); - using var sourceCompletionSource = new Subject(); - - ValueRecordingObserver? disposalsCompletedResults = null; - - using var subscription = source - .Connect() - .TakeUntil(sourceCompletionSource) - .AsyncDisposeMany(disposalsCompleted => - { - disposalsCompletedResults.Should().BeNull("disposalsCompletedAccessor should only be invoked once per subscription"); - disposalsCompleted.RecordValues(out disposalsCompletedResults); - }) - .ValidateSynchronization() - .ValidateChangeSets(static item => item.Id) - .RecordCacheItems(out var results); - - disposalsCompletedResults.Should().NotBeNull("disposalsCompletedAccessor should have been invoked"); - - - var items = Enumerable.Range(1, 100_000) - .Select(id => new AsyncDisposableItem() - { - Id = id, - Version = 1 - }) - .ToArray(); - - source.AddOrUpdate(items); - - results.Error.Should().BeNull(); - results.RecordedChangeSets.Count.Should().Be(1, "1 source operation was performed"); - results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "items were added"); - results.HasCompleted.Should().BeFalse(); - - disposalsCompletedResults.Error.Should().BeNull(); - disposalsCompletedResults.RecordedValues.Should().BeEmpty("the source has not completed"); - disposalsCompletedResults.HasCompleted.Should().BeFalse("the source has not completed"); - - - sourceCompletionSource.OnNext(); - await Task.WhenAll(items - .GroupBy(item => item.Id % 4) - .Select(group => Task.Run(() => - { - foreach (var item in group) - item.CompleteDisposal(); - }))); - - // RX and TPL don't guarantee Task continuations run synchronously with antecedent completion - await disposalsCompletedResults.WaitForFinalizationAsync(TimeSpan.FromSeconds(30)); - - results.Error.Should().BeNull(); - results.RecordedChangeSets.Count.Should().Be(1, "1 source operation was performed"); - results.RecordedItemsByKey.Values.Should().BeEquivalentTo(items, "no items were removed"); - results.HasCompleted.Should().BeTrue(); - - items.Should().AllSatisfy(item => item.HasBeenDisposed.Should().BeTrue(), "disposable items should be disposed upon source completion"); - - disposalsCompletedResults.Error.Should().BeNull(); - disposalsCompletedResults.RecordedValues.Count.Should().Be(1, "the source and all disposals have completed"); - disposalsCompletedResults.HasCompleted.Should().BeTrue("the source and all disposals have completed"); - } - - [Theory] - [InlineData(ItemType.Plain)] - [InlineData(ItemType.Disposable)] - [InlineData(ItemType.AsyncDisposable)] - [InlineData(ItemType.ImmediateAsyncDisposable)] - public void ItemsAreAddedMovedOrRefreshed_ItemsAreNotDisposed(ItemType itemType) - { - using var source = new Subject>(); - - ValueRecordingObserver? disposalsCompletedResults = null; - - using var subscription = source - .AsyncDisposeMany(disposalsCompleted => - { - disposalsCompletedResults.Should().BeNull("disposalsCompletedAccessor should only be invoked once per subscription"); - disposalsCompleted.RecordValues(out disposalsCompletedResults); - }) - .ValidateSynchronization() - .ValidateChangeSets(static item => item.Id) - .RecordCacheItems(out var results); - - disposalsCompletedResults.Should().NotBeNull("disposalsCompletedAccessor should have been invoked"); - - - // Addition - var items = new List() - { - ItemBase.Create(type: itemType, id: 1, version: 1), - ItemBase.Create(type: itemType, id: 2, version: 1), - ItemBase.Create(type: itemType, id: 3, version: 1) - }; - - source.OnNext(new ChangeSet(items - .Select((item, index) => new Change( - reason: ChangeReason.Add, - key: item.Id, - current: item, - index: index)))); - - results.Error.Should().BeNull(); - results.RecordedChangeSets.Count.Should().Be(1, "1 source operation was performed"); - results.RecordedItemsSorted.Should().BeEquivalentTo( - items, - options => options.WithStrictOrdering(), - "3 items were added"); - results.HasCompleted.Should().BeFalse(); - - items.Should().AllSatisfy(item => item.HasBeenDisposed.Should().BeFalse(), "items should not be disposed upon add"); - - disposalsCompletedResults.Error.Should().BeNull(); - disposalsCompletedResults.RecordedValues.Should().BeEmpty("the source has not completed"); - disposalsCompletedResults.HasCompleted.Should().BeFalse("the source has not completed"); - - - // Movement - items.Move(2, 0, items[2]); - items.Move(2, 1, items[2]); - source.OnNext(new ChangeSet() - { - new(reason: ChangeReason.Moved, key: items[0].Id, current: items[0], previous: Optional.None(), currentIndex: 0, previousIndex: 2), - new(reason: ChangeReason.Moved, key: items[1].Id, current: items[1], previous: Optional.None(), currentIndex: 1, previousIndex: 2) - }); - - results.Error.Should().BeNull(); - results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "1 source operation was performed"); - results.RecordedItemsSorted.Should().BeEquivalentTo( - items, - options => options.WithStrictOrdering(), - "3 items were added"); - results.HasCompleted.Should().BeFalse(); - - items.Should().AllSatisfy(item => item.HasBeenDisposed.Should().BeFalse(), "items should not be disposed upon movement"); - - disposalsCompletedResults.Error.Should().BeNull(); - disposalsCompletedResults.RecordedValues.Should().BeEmpty("the source has not completed"); - disposalsCompletedResults.HasCompleted.Should().BeFalse("the source has not completed"); - - - // Refreshing - source.OnNext(new ChangeSet(items - .Select((item, index) => new Change( - reason: ChangeReason.Refresh, - key: item.Id, - current: item, - index: index)))); - - results.Error.Should().BeNull(); - results.RecordedChangeSets.Skip(2).Count().Should().Be(1, "1 source operation was performed"); - results.RecordedItemsSorted.Should().BeEquivalentTo( - items, - options => options.WithStrictOrdering(), - "3 items were added"); - results.HasCompleted.Should().BeFalse(); - - items.Should().AllSatisfy(item => item.HasBeenDisposed.Should().BeFalse(), "items should not be disposed upon refresh"); - - disposalsCompletedResults.Error.Should().BeNull(); - disposalsCompletedResults.RecordedValues.Should().BeEmpty("the source has not completed"); - disposalsCompletedResults.HasCompleted.Should().BeFalse("the source has not completed"); - } - - [Theory] - [InlineData(ItemType.Plain)] - [InlineData(ItemType.Disposable)] - [InlineData(ItemType.AsyncDisposable)] - [InlineData(ItemType.ImmediateAsyncDisposable)] - public void ItemsAreRemoved_ItemsAreDisposedAfterDownstreamProcessing(ItemType itemType) - { - using var source = new SourceCache(static item => item.Id); - - ValueRecordingObserver? disposalsCompletedResults = null; - - using var subscription = source - .Connect() - .AsyncDisposeMany(disposalsCompleted => - { - disposalsCompletedResults.Should().BeNull("disposalsCompletedAccessor should only be invoked once per subscription"); - disposalsCompleted.RecordValues(out disposalsCompletedResults); - }) - .ValidateSynchronization() - .Do(changes => - { - foreach (var change in changes) - if (change.Reason is ChangeReason.Remove) - change.Current.HasBeenDisposed.Should().BeFalse("disposal should only occur after downstream processing has completed"); - }) - .ValidateChangeSets(static item => item.Id) - .RecordCacheItems(out var results); - - disposalsCompletedResults.Should().NotBeNull("disposalsCompletedAccessor should have been invoked"); - - - source.AddOrUpdate(new[] - { - ItemBase.Create(type: itemType, id: 1, version: 1), - ItemBase.Create(type: itemType, id: 2, version: 1), - ItemBase.Create(type: itemType, id: 3, version: 1) - }); - - results.Error.Should().BeNull(); - results.RecordedChangeSets.Count.Should().Be(1, "1 source operation was performed"); - results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added"); - results.HasCompleted.Should().BeFalse(); - - disposalsCompletedResults.Error.Should().BeNull(); - disposalsCompletedResults.RecordedValues.Should().BeEmpty("the source has not completed"); - disposalsCompletedResults.HasCompleted.Should().BeFalse("the source has not completed"); - - - var items = source.Items.ToArray(); - source.Clear(); - - results.Error.Should().BeNull(); - results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "1 source operation was performed"); - results.RecordedItemsByKey.Values.Should().BeEmpty("all items were removed"); - results.HasCompleted.Should().BeFalse(); - - items.Where(static item => item.CanBeDisposed).Should().AllSatisfy(item => item.HasBeenDisposed.Should().BeTrue(), "disposable items should be disposed after removal"); - - disposalsCompletedResults.Error.Should().BeNull(); - disposalsCompletedResults.RecordedValues.Should().BeEmpty("the source has not completed"); - disposalsCompletedResults.HasCompleted.Should().BeFalse("the source has not completed"); - } - - [Theory] - [InlineData(ItemType.Plain)] - [InlineData(ItemType.Disposable)] - [InlineData(ItemType.AsyncDisposable)] - [InlineData(ItemType.ImmediateAsyncDisposable)] - public void ItemsAreUpdated_PreviousItemsAreDisposedAfterDownstreamProcessing(ItemType itemType) - { - using var source = new SourceCache(static item => item.Id); - - ValueRecordingObserver? disposalsCompletedResults = null; - - using var subscription = source - .Connect() - .AsyncDisposeMany(disposalsCompleted => - { - disposalsCompletedResults.Should().BeNull("disposalsCompletedAccessor should only be invoked once per subscription"); - disposalsCompleted.RecordValues(out disposalsCompletedResults); - }) - .ValidateSynchronization() - .Do(changes => - { - foreach (var change in changes) - if (change.Reason is ChangeReason.Remove) - change.Current.HasBeenDisposed.Should().BeFalse("disposal should only occur after downstream processing has completed"); - }) - .ValidateChangeSets(static item => item.Id) - .RecordCacheItems(out var results); - - disposalsCompletedResults.Should().NotBeNull("disposalsCompletedAccessor should have been invoked"); - - - source.AddOrUpdate(new[] - { - ItemBase.Create(type: itemType, id: 1, version: 1), - ItemBase.Create(type: itemType, id: 2, version: 1), - ItemBase.Create(type: itemType, id: 3, version: 1) - }); - - results.Error.Should().BeNull(); - results.RecordedChangeSets.Count.Should().Be(1, "1 source operation was performed"); - results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added"); - results.HasCompleted.Should().BeFalse(); - - disposalsCompletedResults.Error.Should().BeNull(); - disposalsCompletedResults.RecordedValues.Should().BeEmpty("the source has not completed"); - disposalsCompletedResults.HasCompleted.Should().BeFalse("the source has not completed"); - - - var previousItems = source.Items.ToArray(); - source.AddOrUpdate(new[] - { - ItemBase.Create(type: itemType, id: 1, version: 2), - ItemBase.Create(type: itemType, id: 2, version: 2), - ItemBase.Create(type: itemType, id: 3, version: 2) - }); - - results.Error.Should().BeNull(); - results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "1 source operation was performed"); - results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "all items were replaced"); - results.HasCompleted.Should().BeFalse(); - - previousItems.Where(static item => item.CanBeDisposed).Should().AllSatisfy(item => item.HasBeenDisposed.Should().BeTrue(), "disposable items should be disposed after replacement"); - - disposalsCompletedResults.Error.Should().BeNull(); - disposalsCompletedResults.RecordedValues.Should().BeEmpty("the source has not completed"); - disposalsCompletedResults.HasCompleted.Should().BeFalse("the source has not completed"); - } - - [Fact] - public void OnDisposalsCompletedIsNull_ThrowsException() - => FluentActions.Invoking(() => ObservableCacheEx.AsyncDisposeMany( - source: Observable.Empty>(), - disposalsCompletedAccessor: null!)) - .Should() - .Throw(); - - [Theory] - [InlineData(SourceType.Subject)] - [InlineData(SourceType.Immediate)] - public void SourceCompletes_ItemsAreDisposedAndCompletionPropagates(SourceType sourceType) - { - var items = new[] - { - new ImmediateAsyncDisposableItem() { Id = 1, Version = 1}, - new ImmediateAsyncDisposableItem() { Id = 2, Version = 1}, - new ImmediateAsyncDisposableItem() { Id = 3, Version = 1} - }; - - var changeSet = new ChangeSet(items - .Select(item => new Change(reason: ChangeReason.Add, key: item.Id, current: item))); - - IObservable> source = (sourceType is SourceType.Immediate) - ? Observable.Return(changeSet) - : new Subject>(); - - - ValueRecordingObserver? disposalsCompletedResults = null; - - using var subscription = source - .AsyncDisposeMany(disposalsCompleted => - { - disposalsCompletedResults.Should().BeNull("disposalsCompletedAccessor should only be invoked once per subscription"); - disposalsCompleted.RecordValues(out disposalsCompletedResults); - }) - .ValidateSynchronization() - .ValidateChangeSets(static item => item.Id) - .RecordCacheItems(out var results); - - disposalsCompletedResults.Should().NotBeNull("disposalsCompletedAccessor should have been invoked"); - - - if (source is Subject> subject) - { - subject.OnNext(changeSet); - subject.OnCompleted(); - } - - results.Error.Should().BeNull(); - results.RecordedChangeSets.Count.Should().Be(1, "1 source operation was performed"); - results.RecordedItemsByKey.Values.Should().BeEquivalentTo(items, "3 items were added"); - results.HasCompleted.Should().BeTrue(); - - items.Should().AllSatisfy(item => item.HasBeenDisposed.Should().BeTrue(), "disposable items should be disposed upon source completion"); - - disposalsCompletedResults.Error.Should().BeNull(); - disposalsCompletedResults.RecordedValues.Count.Should().Be(1, "all items have completed disposal"); - disposalsCompletedResults.HasCompleted.Should().BeTrue("all items have completed disposal"); - } - - [Theory] - [InlineData(SourceType.Subject)] - [InlineData(SourceType.Immediate)] - public void SourceErrors_ItemsAreDisposedAndErrorPropagates(SourceType sourceType) - { - var items = new[] - { - new ImmediateAsyncDisposableItem() { Id = 1, Version = 1}, - new ImmediateAsyncDisposableItem() { Id = 2, Version = 1}, - new ImmediateAsyncDisposableItem() { Id = 3, Version = 1} - }; - - var changeSet = new ChangeSet(items - .Select(item => new Change(reason: ChangeReason.Add, key: item.Id, current: item))); - - var error = new Exception("Test"); - - IObservable> source = (sourceType is SourceType.Immediate) - ? Observable.Return(changeSet) - .Concat(Observable.Throw>(error)) - : new Subject>(); - - - ValueRecordingObserver? disposalsCompletedResults = null; - - using var subscription = source - .AsyncDisposeMany(disposalsCompleted => - { - disposalsCompletedResults.Should().BeNull("disposalsCompletedAccessor should only be invoked once per subscription"); - disposalsCompleted.RecordValues(out disposalsCompletedResults); - }) - .ValidateSynchronization() - .ValidateChangeSets(static item => item.Id) - .RecordCacheItems(out var results); - - disposalsCompletedResults.Should().NotBeNull("disposalsCompletedAccessor should have been invoked"); - - - if (source is Subject> subject) - { - subject.OnNext(changeSet); - subject.OnError(error); - } - - results.Error.Should().Be(error); - results.RecordedChangeSets.Count.Should().Be(1, "1 source operation was performed"); - results.RecordedItemsByKey.Values.Should().BeEquivalentTo(items, "3 items were added"); - - items.Should().AllSatisfy(item => item.HasBeenDisposed.Should().BeTrue(), "disposable items should be disposed upon source failure"); - - disposalsCompletedResults.Error.Should().BeNull(); - disposalsCompletedResults.RecordedValues.Count.Should().Be(1, "all items have completed disposal"); - disposalsCompletedResults.HasCompleted.Should().BeTrue("all items have completed disposal"); - } - - [Fact] - public void SourceIsNull_ThrowsException() - => FluentActions.Invoking(() => ObservableCacheEx.AsyncDisposeMany( - source: null!, - disposalsCompletedAccessor: static _ => { })) - .Should() - .Throw(); - public abstract record ItemBase { public static ItemBase Create( diff --git a/src/DynamicData.Tests/Cache/EnumerableObservableToObservableChangeSetFixture.cs b/src/DynamicData.Tests/Cache/EnumerableObservableToObservableChangeSetFixture.cs deleted file mode 100644 index c99eafde9..000000000 --- a/src/DynamicData.Tests/Cache/EnumerableObservableToObservableChangeSetFixture.cs +++ /dev/null @@ -1,140 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reactive.Linq; -using System.Reactive.Subjects; -using DynamicData.Tests.Domain; - -using FluentAssertions; - -using Microsoft.Reactive.Testing; - -using Xunit; - -namespace DynamicData.Tests.Cache; - -public class EnumerableObservableToObservableChangeSetFixture -{ - [Fact] - public void ExpireAfterTime() - { - var subject = new Subject>(); - var scheduler = new TestScheduler(); - var results = subject.ToObservableChangeSet(t => TimeSpan.FromMinutes(1), scheduler).AsAggregator(); - - var people = Enumerable.Range(1, 200).Select(i => new Person("p" + i.ToString("000"), i)).ToArray(); - - subject.OnNext(people); - - scheduler.AdvanceBy(TimeSpan.FromSeconds(61).Ticks); - //scheduler.Start(); - results.Messages.Count.Should().Be(2, "Should be 300 messages"); - results.Messages.Sum(x => x.Adds).Should().Be(200, "Should be 200 adds"); - results.Messages.Sum(x => x.Removes).Should().Be(200, "Should be 100 removes"); - results.Data.Count.Should().Be(0, "Should be no data in the cache"); - } - - [Fact] - public void LimitSizeTo() - { - var subject = new Subject>(); - var scheduler = new TestScheduler(); - var results = subject.ToObservableChangeSet(100, scheduler).AsAggregator(); - - var people = Enumerable.Range(1, 200).Select(i => new Person("p" + i.ToString("000"), i)).ToArray(); - - subject.OnNext(people); - - scheduler.AdvanceBy(1); - - results.Messages.Sum(x => x.Adds).Should().Be(200, "Should be 200 adds"); - results.Messages.Sum(x => x.Removes).Should().Be(100, "Should be 100 removes"); - results.Data.Count.Should().Be(100, "Should be 1 item in the cache"); - - var expected = people.Skip(100).ToArray().OrderBy(p => p.Name).ToArray(); - var actual = results.Data.Items.OrderBy(p => p.Name).ToArray(); - actual.Should().BeEquivalentTo(expected, "Only second hundred should be in the cache"); - } - - [Fact] - public void OnNextProducesAnAddAndRemoveChangeForEnumerableSource() - { - var subject = new Subject>(); - - var results = ObservableChangeSet.Create(cache => - { - return subject.Subscribe(items => cache.EditDiff(items, Person.NameAgeGenderComparer)); - }, p => p.Name) - .AsAggregator(); - - - var people = new[] - { - new Person("A", 1), - new Person("B", 2), - new Person("C", 3) - }; - - subject.OnNext(people); - - results.Messages.Last().Adds.Should().Be(3, "Should have added three items"); - results.Data.Count.Should().Be(3, "Should be 3 items in the cache"); - - people = new[] - { - new Person("A", 3), - new Person("B", 4) - }; - - subject.OnNext(people); - - results.Messages.Last().Adds.Should().Be(0, "Should have added no items"); - results.Messages.Last().Updates.Should().Be(2, "Should have updated 2 items"); - results.Messages.Last().Removes.Should().Be(1, "Should have removed 1 items"); - results.Data.Count.Should().Be(2, "Should be 3 items in the cache"); - - results.Messages.Count.Should().Be(2, "Should be 2 updates"); - results.Data.Items.Should().BeEquivalentTo(results.Data.Items, "Lists should be equivalent"); - } - - [Fact] - public void OnNextProducesAnAddChangeForEnumerableSource() - { - var subject = new Subject>(); - var results = subject.ToObservableChangeSet().AsAggregator(); - - var people = new[] - { - new Person("A", 1), - new Person("B", 2), - new Person("C", 3) - }; - - subject.OnNext(people); - - results.Messages.Count.Should().Be(1, "Should be 1 updates"); - results.Data.Count.Should().Be(3, "Should be 1 item in the cache"); - results.Data.Items.Should().BeEquivalentTo(results.Data.Items, "Lists should be equivalent"); - } - - - - [Fact] - public void ExpireAfterObservableCompleted() - { - //See https://github.com/reactivemarbles/DynamicData/issues/358 - - var scheduler = new TestScheduler(); - - var expiry = Observable.Return(Enumerable.Range(0, 10).Select(i => new { A = i, B = 2 * i })) - .ToObservableChangeSet(x => x.A, _ => TimeSpan.FromSeconds(5), scheduler: scheduler) - .AsAggregator(); - - expiry.Data.Count.Should().Be(10); - - scheduler.AdvanceBy(TimeSpan.FromSeconds(5).Ticks); - - expiry.Data.Count.Should().Be(0); - - } -} diff --git a/src/DynamicData.Tests/Cache/ObservableToObservableChangeSetFixture.cs b/src/DynamicData.Tests/Cache/ObservableToObservableChangeSetFixture.cs deleted file mode 100644 index d4e6ce032..000000000 --- a/src/DynamicData.Tests/Cache/ObservableToObservableChangeSetFixture.cs +++ /dev/null @@ -1,160 +0,0 @@ -using System; -using System.Linq; -using System.Reactive.Linq; -using System.Reactive.Subjects; -using DynamicData.Kernel; -using DynamicData.Tests.Domain; -using FluentAssertions; -using Microsoft.Reactive.Testing; -using Xunit; - -namespace DynamicData.Tests.Cache; - -public class ObservableToObservableChangeSetFixture -{ - - [Fact] - public void ExpireAfterTime() - { - var subject = new Subject(); - var scheduler = new TestScheduler(); - var results = subject.ToObservableChangeSet(t => TimeSpan.FromMinutes(1), scheduler).AsAggregator(); - - var items = Enumerable.Range(1, 200).Select(i => new Person("p" + i.ToString("000"), i)).ToArray(); - foreach (var person in items) - { - subject.OnNext(person); - } - - scheduler.AdvanceBy(TimeSpan.FromSeconds(61).Ticks); - - results.Messages.Count.Should().Be(201, "Should be 201 messages"); - results.Messages.Sum(x => x.Adds).Should().Be(200, "Should be 200 adds"); - results.Messages.Sum(x => x.Removes).Should().Be(200, "Should be 200 removes"); - results.Data.Count.Should().Be(0, "Should be no data in the cache"); - } - - [Fact] - public void ExpireAfterTimeDynamic() - { - var scheduler = new TestScheduler(); - var source = Observable.Interval(TimeSpan.FromSeconds(1), scheduler).Take(30).Select(i => (int)i).Select(i => new Person("p" + i.ToString("000"), i)); - - var results = source.ToObservableChangeSet(t => TimeSpan.FromSeconds(10), scheduler).AsAggregator(); - - scheduler.AdvanceBy(TimeSpan.FromSeconds(30).Ticks); - - results.Messages.Count.Should().Be(50, "Should be 50 messages"); - results.Messages.Sum(x => x.Adds).Should().Be(30, "Should be 30 adds"); - results.Messages.Sum(x => x.Removes).Should().Be(20, "Should be 20 removes"); - results.Data.Count.Should().Be(10, "Should be 10 items in the cache"); - } - - - [Fact] - public void ExpireAfterTimeDynamicWithKey() - { - var scheduler = new TestScheduler(); - var source = Observable.Interval(TimeSpan.FromSeconds(1), scheduler).Take(30).Select(i => (int)i).Select(i => new Person("p" + i.ToString("000"), i)); - - var results = source.ToObservableChangeSet(p => p.Key, t => TimeSpan.FromSeconds(10), scheduler: scheduler).AsAggregator(); - - scheduler.AdvanceBy(TimeSpan.FromSeconds(30).Ticks); - - results.Messages.Count.Should().Be(50, "Should be 50 messages"); - results.Messages.Sum(x => x.Adds).Should().Be(30, "Should be 30 adds"); - results.Messages.Sum(x => x.Removes).Should().Be(20, "Should be 20 removes"); - results.Data.Count.Should().Be(10, "Should be 10 items in the cache"); - } - - [Fact] - public void ExpireAfterTimeWithKey() - { - var subject = new Subject(); - var scheduler = new TestScheduler(); - var results = subject.ToObservableChangeSet(p => p.Key, t => TimeSpan.FromMinutes(1), scheduler: scheduler).AsAggregator(); - - var items = Enumerable.Range(1, 200).Select(i => new Person("p" + i.ToString("000"), i)).ToArray(); - foreach (var person in items) - { - subject.OnNext(person); - } - - results.Data.Count.Should().Be(200, "Should 200 items in the cache"); - - scheduler.AdvanceBy(TimeSpan.FromSeconds(61).Ticks); - - results.Messages.Count.Should().Be(201, "Should be 201 messages"); - results.Messages.Sum(x => x.Adds).Should().Be(200, "Should be 200 adds"); - results.Messages.Sum(x => x.Removes).Should().Be(200, "Should be 200 removes"); - results.Data.Count.Should().Be(0, "Should be no data in the cache"); - } - - [Fact] - public void LimitSizeTo() - { - var subject = new Subject(); - var scheduler = new TestScheduler(); - var results = subject.ToObservableChangeSet(p => p.Key, limitSizeTo: 100, scheduler: scheduler).AsAggregator(); - - var items = Enumerable.Range(1, 200).Select(i => new Person("p" + i.ToString("000"), i)).ToArray(); - - items.ForEach(subject.OnNext); - - scheduler.Start(); - - results.Messages.Sum(x => x.Adds).Should().Be(200, "Should be 200 adds"); - results.Messages.Sum(x => x.Removes).Should().Be(100, "Should be 100 removes"); - results.Data.Count.Should().Be(100); - - var expected = items.Skip(100).ToArray().OrderBy(p => p.Name).ToArray(); - var actual = results.Data.Items.OrderBy(p => p.Name).ToArray(); - expected.Should().BeEquivalentTo(actual, "Only second hundred should be in the cache"); - } - - [Fact] - public void OnNextFiresAdd() - { - var subject = new Subject(); - - var results = subject.ToObservableChangeSet(p => p.Key).AsAggregator(); - var person = new Person("A", 1); - subject.OnNext(person); - - results.Messages.Count.Should().Be(1, "Should be 1 updates"); - results.Data.Count.Should().Be(1, "Should be 1 item in the cache"); - results.Data.Items[0].Should().Be(person, "Should be same person"); - } - - [Fact] - public void OnNextForAmendedItemFiresUpdate() - { - var subject = new Subject(); - - var results = subject.ToObservableChangeSet(p => p.Key).AsAggregator(); - var person = new Person("A", 1); - subject.OnNext(person); - - var personamend = new Person("A", 2); - subject.OnNext(personamend); - - results.Messages.Count.Should().Be(2, "Should be 2 message"); - results.Messages[1].Updates.Should().Be(1, "Should be 1 updates"); - results.Data.Count.Should().Be(1, "Should be 1 item in the cache"); - results.Data.Items[0].Should().Be(personamend, "Should be same person"); - } - - [Fact] - public void OnNextProducesAndAddChangeForSingleItem() - { - var subject = new Subject(); - - var results = subject.ToObservableChangeSet(p => p.Key).AsAggregator(); - var person = new Person("A", 1); - subject.OnNext(person); - - results.Messages.Count.Should().Be(1, "Should be 1 updates"); - results.Data.Count.Should().Be(1, "Should be 1 item in the cache"); - results.Data.Items[0].Should().Be(person, "Should be same person"); - } -} diff --git a/src/DynamicData.Tests/Cache/ToObservableChangeSetFixture.Items.IntegrationTests.cs b/src/DynamicData.Tests/Cache/ToObservableChangeSetFixture.Items.IntegrationTests.cs new file mode 100644 index 000000000..8dd0ae91e --- /dev/null +++ b/src/DynamicData.Tests/Cache/ToObservableChangeSetFixture.Items.IntegrationTests.cs @@ -0,0 +1,85 @@ +using System; +using System.Linq; +using System.Reactive.Concurrency; +using System.Reactive.Linq; +using System.Threading.Tasks; + +using FluentAssertions; +using Xunit; + +using DynamicData.Tests.Utilities; + +namespace DynamicData.Tests.Cache; + +public static partial class ToObservableChangeSetFixture +{ + public static partial class Items + { + public sealed class IntegrationTests + : IntegrationTestFixtureBase + { + [Theory(Timeout = 60_000)] + [InlineData(SchedulerType.Default)] + [InlineData(SchedulerType.NewThread)] + [InlineData(SchedulerType.TaskPool)] + [InlineData(SchedulerType.ThreadPool)] + public async Task MultipleSubscriptionsRunInParallel_SchedulerUsageIsThreadSafe(SchedulerType schedulerType) + { + IScheduler scheduler = schedulerType switch + { + SchedulerType.Default => DefaultScheduler.Instance, + SchedulerType.NewThread => new NewThreadScheduler(), + SchedulerType.TaskPool => TaskPoolScheduler.Default, + SchedulerType.ThreadPool => ThreadPoolScheduler.Instance, + _ => throw new ArgumentOutOfRangeException(nameof(SchedulerType)) + }; + + using var subscription1 = Observable.Interval( + period: TimeSpan.FromMilliseconds(5), + scheduler: scheduler) + .Take(IntegrationTestItemCount) + .Select(id => new Item() + { + Id = (int)(id % 100), + Lifetime = TimeSpan.FromMilliseconds(50) + }) + .ToObservableChangeSet( + keySelector: Item.SelectId, + expireAfter: Item.SelectLifetime, + scheduler: scheduler) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results1); + + using var subscription2 = Observable.Interval( + period: TimeSpan.FromMilliseconds(5), + scheduler: scheduler) + .Take(IntegrationTestItemCount) + .Select(id => new Item() + { + Id = (int)(id % 100) + 100, + Lifetime = TimeSpan.FromMilliseconds(50) + }) + .ToObservableChangeSet( + keySelector: Item.SelectId, + expireAfter: Item.SelectLifetime, + scheduler: scheduler) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results2); + + await Task.WhenAll( + results1.WhenFinalized, + results2.WhenFinalized); + + results1.Error.Should().BeNull(); + results1.HasCompleted.Should().BeTrue("all changes should have been processed successfully"); + results1.RecordedItemsByKey.Should().BeEmpty("all items should have expired"); + + results2.Error.Should().BeNull(); + results2.HasCompleted.Should().BeTrue("all changes should have been processed successfully"); + results2.RecordedItemsByKey.Should().BeEmpty("all items should have expired"); + } + } + } +} diff --git a/src/DynamicData.Tests/Cache/ToObservableChangeSetFixture.Items.UnitTests.cs b/src/DynamicData.Tests/Cache/ToObservableChangeSetFixture.Items.UnitTests.cs new file mode 100644 index 000000000..892f55535 --- /dev/null +++ b/src/DynamicData.Tests/Cache/ToObservableChangeSetFixture.Items.UnitTests.cs @@ -0,0 +1,550 @@ +using System; +using System.Linq; +using System.Reactive.Linq; +using System.Reactive.Subjects; + +using Microsoft.Reactive.Testing; + +using FluentAssertions; +using Xunit; + +using DynamicData.Tests.Utilities; + +namespace DynamicData.Tests.Cache; + +public static partial class ToObservableChangeSetFixture +{ + public static partial class Items + { + public class UnitTests + { + [Fact] + public void ExpireAfterThrows_ErrorPropagates() + { + // Setup + using var source = new Subject(); + + var error = new Exception("Test Exception"); + + + // UUT Initialization + using var subscription = source + .ToObservableChangeSet( + keySelector: Item.SelectId, + expireAfter: static item => (item.Error is not null) + ? throw item.Error + : item.Lifetime) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "an initial changeset should always be emitted"); + results.RecordedItemsByKey.Should().BeEmpty("no items have been emitted by the source"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + var item1 = new Item() { Id = 1 }; + source.OnNext(item1); + source.OnNext(new Item() { Id = 2, Error = error }); + source.OnNext(new Item() { Id = 3 }); + + results.Error.Should().BeSameAs(error); + results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "1 item was emitted before an error occurred"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item1 }, "1 item was emitted before an error occurred"); + + + results.ShouldNotSupportSorting($"Cache source operators do not support sorting"); + } + + [Fact] + public void KeySelectorIsNull_ThrowsException() + => FluentActions.Invoking(() => ObservableCacheEx.ToObservableChangeSet( + source: new Subject(), + keySelector: null!)) + .Should().Throw(); + + [Fact] + public void KeySelectorThrows_ErrorPropagates() + { + // Setup + using var source = new Subject(); + + var error = new Exception("Test Exception"); + + + // UUT Initialization + using var subscription = source + .ToObservableChangeSet(static item => (item.Error is not null) + ? throw item.Error + : item.Id) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "an initial changeset should always be emitted"); + results.RecordedItemsByKey.Should().BeEmpty("no items have been emitted by the source"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + var item1 = new Item() { Id = 1 }; + source.OnNext(item1); + source.OnNext(new Item() { Id = 2, Error = error }); + source.OnNext(new Item() { Id = 3 }); + + results.Error.Should().BeSameAs(error); + results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "1 item was emitted before an error occurred"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item1 }, "1 item was emitted before an error occurred"); + + + results.ShouldNotSupportSorting($"Cache source operators do not support sorting"); + } + + [Fact] + public void SizeLimitIsExceeded_OldestItemsAreRemoved() + { + // Setup + using var source = new Subject(); + + + // UUT Initialization + using var subscription = source + .ToObservableChangeSet( + keySelector: Item.SelectId, + limitSizeTo: 5) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "an initial changeset should always be emitted"); + results.RecordedItemsByKey.Values.Should().BeEmpty("no items have been emitted"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action: Not enough items to reach the limit + var item1 = new Item() { Id = 1 }; + source.OnNext(item1); + + var item2 = new Item() { Id = 2 }; + source.OnNext(item2); + + var item3 = new Item() { Id = 3 }; + source.OnNext(item3); + + var item4 = new Item() { Id = 4 }; + source.OnNext(item4); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(1).Count().Should().Be(4, "4 items were emitted"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item1, item2, item3, item4 }, "4 items were emitted"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action: Limit is reached + var item5 = new Item() { Id = 5 }; + source.OnNext(item5); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(5).Count().Should().Be(1, "1 item was emitted"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item1, item2, item3, item4, item5 }, "1 item was emitted"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action: New item exceeds the limit + var item6 = new Item() { Id = 6 }; + source.OnNext(item6); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(6).Count().Should().Be(1, "1 item was emitted"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item2, item3, item4, item5, item6 }, "1 item was emitted, and 1 was evicted"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action: Replacement leaves all other items in-place + var item7 = new Item() { Id = 4 }; + source.OnNext(item7); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(7).Count().Should().Be(1, "1 item was emitted"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item2, item3, item7, item5, item6 }, "1 item was emitted, and replaced an existing item."); + results.HasCompleted.Should().BeFalse("the source has not completed"); + } + + [Theory] + [InlineData(SourceType.Asynchronous)] + [InlineData(SourceType.Immediate)] + public void SourceCompletesWhenExpirationsArePending_CompletionWaitsForExpirations(SourceType sourceType) + { + // Setup + var item = new Item() { Id = 1, Lifetime = TimeSpan.FromSeconds(10) }; + + var source = sourceType switch + { + SourceType.Asynchronous => new Subject(), + SourceType.Immediate => Observable.Return(item), + _ => throw new ArgumentOutOfRangeException(nameof(sourceType)) + }; + + var scheduler = new TestScheduler(); + + + // UUT Initialization & Action + using var subscription = source + .ToObservableChangeSet( + keySelector: Item.SelectId, + expireAfter: Item.SelectLifetime, + scheduler: scheduler) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + if (source is Subject subject) + { + subject.OnNext(item); + subject.OnCompleted(); + } + + results.Error.Should().BeNull(); + if (sourceType is SourceType.Asynchronous) + results.RecordedChangeSets.Count.Should().Be(2, "1 item was emitted, after initialization"); + else + results.RecordedChangeSets.Count.Should().Be(1, "an initial changeset should always be emitted"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item }, "1 item was emitted"); + results.HasCompleted.Should().BeFalse("1 item has yet to expire"); + + + // UUT Action + scheduler.AdvanceTo(TimeSpan.FromSeconds(10).Ticks); + + results.Error.Should().BeNull(); + if (sourceType is SourceType.Asynchronous) + results.RecordedChangeSets.Skip(2).Count().Should().Be(1, "1 item should have expired"); + else + results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "1 item should have expired"); + results.RecordedItemsByKey.Values.Should().BeEmpty("all items have expired"); + results.HasCompleted.Should().BeTrue("the source, and all outstanding expirations, have completed"); + + + results.ShouldNotSupportSorting(); + } + + [Theory] + [InlineData(SourceType.Asynchronous)] + [InlineData(SourceType.Immediate)] + public void SourceCompletesWhenNoExpirationsArePending_CompletionPropagates(SourceType sourceType) + { + // Setup + var item = new Item() { Id = 1 }; + + var source = sourceType switch + { + SourceType.Asynchronous => new Subject(), + SourceType.Immediate => Observable.Return(item), + _ => throw new ArgumentOutOfRangeException(nameof(sourceType)) + }; + + var scheduler = new TestScheduler(); + + + // UUT Initialization & Action + using var subscription = source + .ToObservableChangeSet( + keySelector: Item.SelectId, + expireAfter: Item.SelectLifetime, + scheduler: scheduler) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + if (source is Subject subject) + { + subject.OnNext(item); + subject.OnCompleted(); + } + + results.Error.Should().BeNull(); + if (sourceType is SourceType.Asynchronous) + results.RecordedChangeSets.Count.Should().Be(2, "1 item was emitted, after initialization"); + else + results.RecordedChangeSets.Count.Should().Be(1, "an initial changeset should always be emitted"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item }, "1 item was emitted"); + results.HasCompleted.Should().BeTrue("the source has completed, and no items remain to be expired"); + + + results.ShouldNotSupportSorting(); + } + + [Fact] + public void SourceEmitsRepeatedItems_RepeatedItemsAreUpdatedAndExpirationIsRescheduled() + { + // Setup + using var source = new Subject(); + + var scheduler = new TestScheduler(); + + + // UUT Initialization + using var subscription = source + .ToObservableChangeSet( + keySelector: Item.SelectId, + expireAfter: Item.SelectLifetime, + scheduler: scheduler) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "an initial changeset should always be emitted"); + results.RecordedItemsByKey.Values.Should().BeEmpty("no items have been emitted"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + var item1 = new Item() { Id = 1, Lifetime = TimeSpan.FromSeconds(1) }; + source.OnNext(item1); + scheduler.AdvanceBy(1); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "1 item was emitted"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item1 }, "1 item was emitted"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + var item2 = new Item() { Id = 2 }; + source.OnNext(item2); + scheduler.AdvanceBy(1); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(2).Count().Should().Be(1, "1 item was emitted"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item1, item2 }, "1 item was emitted"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + var item3 = new Item() { Id = 3, Lifetime = TimeSpan.FromSeconds(1) }; + source.OnNext(item3); + scheduler.AdvanceBy(1); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(3).Count().Should().Be(1, "1 item was emitted"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item1, item2, item3 }, "1 item was emitted"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + var item4 = new Item() { Id = 1, Lifetime = TimeSpan.FromSeconds(1) }; + source.OnNext(item4); + scheduler.AdvanceBy(1); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(4).Count().Should().Be(1, "1 item was emitted"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item4, item2, item3 }, "1 item was emitted, and replaced an existing item"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + var item5 = new Item() { Id = 2 }; + source.OnNext(item5); + scheduler.AdvanceBy(1); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(5).Count().Should().Be(1, "1 item was emitted"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item4, item5, item3 }, "1 item was emitted, and replaced an existing item"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + var item6 = new Item() { Id = 3, Lifetime = TimeSpan.FromSeconds(3) }; + source.OnNext(item6); + scheduler.AdvanceBy(1); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(6).Count().Should().Be(1, "1 item was emitted"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item4, item5, item6 }, "1 item was emitted, and replaced an existing item"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + scheduler.AdvanceTo(TimeSpan.FromSeconds(1).Ticks); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(7).Count().Should().Be(1, "1 expiration should have occurred"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item5, item6 }, "1 item reached its expiration"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + scheduler.AdvanceTo(TimeSpan.FromSeconds(2).Ticks); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(8).Should().BeEmpty("no expirations should have occurred"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item5, item6 }, "no changes were made"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + scheduler.AdvanceTo(TimeSpan.FromSeconds(3).Ticks); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(8).Count().Should().Be(1, "1 expiration should have occurred"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item5 }, "1 item reached its expiration"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + scheduler.AdvanceTo(TimeSpan.FromSeconds(4).Ticks); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(9).Should().BeEmpty("no expirations should have occurred"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item5 }, "no changes were made"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + results.ShouldNotSupportSorting(); + } + + [Fact] + public void SourceEmitsUniqueItems_ItemsAreAddedAndRemovedWhenExpired() + { + // Setup + using var source = new Subject(); + + var scheduler = new TestScheduler(); + + + // UUT Initialization + using var subscription = source + .ToObservableChangeSet( + keySelector: Item.SelectId, + expireAfter: Item.SelectLifetime, + scheduler: scheduler) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "an initial changeset should always be emitted"); + results.RecordedItemsByKey.Values.Should().BeEmpty("no items have been emitted"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + var item1 = new Item() { Id = 1, Lifetime = TimeSpan.FromSeconds(3) }; + source.OnNext(item1); + scheduler.AdvanceBy(1); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "1 item was emitted"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item1 }, "1 item was emitted"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + var item2 = new Item() { Id = 2 }; + source.OnNext(item2); + scheduler.AdvanceBy(1); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(2).Count().Should().Be(1, "1 item was emitted"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item1, item2 }, "1 item was emitted"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + var item3 = new Item() { Id = 3, Lifetime = TimeSpan.FromSeconds(1) }; + source.OnNext(item3); + scheduler.AdvanceBy(1); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(3).Count().Should().Be(1, "1 item was emitted"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item1, item2, item3 }, "1 item was emitted"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + scheduler.AdvanceTo(TimeSpan.FromSeconds(1).Ticks); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(4).Count().Should().Be(1, "1 expiration should have occurred"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item1, item2 }, "1 item expired, and 1 had its lifetime extended"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + scheduler.AdvanceTo(TimeSpan.FromSeconds(2).Ticks); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(5).Should().BeEmpty("no expirations should have occurred"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item1, item2 }, "no changes were made"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + scheduler.AdvanceTo(TimeSpan.FromSeconds(3).Ticks); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(5).Count().Should().Be(1, "1 expiration should have occurred"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item2 }, "1 item reached its expiration"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + scheduler.AdvanceTo(TimeSpan.FromSeconds(4).Ticks); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(6).Should().BeEmpty("no expirations should have occurred"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item2 }, "no changes were made"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + results.ShouldNotSupportSorting(); + } + + [Theory] + [InlineData(SourceType.Asynchronous)] + [InlineData(SourceType.Immediate)] + public void SourceFails_ErrorPropagates(SourceType sourceType) + { + // Setup + var error = new Exception("Test Exception"); + + var source = sourceType switch + { + SourceType.Asynchronous => new Subject(), + SourceType.Immediate => Observable.Throw(error), + _ => throw new ArgumentOutOfRangeException(nameof(sourceType)) + }; + + + // UUT Initialization & Action + using var subscription = source + .ToObservableChangeSet(Item.SelectId) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + if (source is Subject subject) + subject.OnError(error); + + results.Error.Should().BeSameAs(error, "errors should propagate"); + if (sourceType is SourceType.Asynchronous) + results.RecordedChangeSets.Count.Should().Be(1, "an initial changeset should always be emitted"); + else + results.RecordedChangeSets.Should().BeEmpty("an error occurred during initialization"); + results.RecordedItemsByKey.Values.Should().BeEmpty("no items were emitted"); + } + + [Fact] + public void SourceIsNull_ThrowsException() + => FluentActions.Invoking(() => ObservableCacheEx.ToObservableChangeSet( + source: (null as IObservable)!, + keySelector: Item.SelectId)) + .Should().Throw(); + } + } +} diff --git a/src/DynamicData.Tests/Cache/ToObservableChangeSetFixture.Sequences.IntegrationTests.cs b/src/DynamicData.Tests/Cache/ToObservableChangeSetFixture.Sequences.IntegrationTests.cs new file mode 100644 index 000000000..f6dece980 --- /dev/null +++ b/src/DynamicData.Tests/Cache/ToObservableChangeSetFixture.Sequences.IntegrationTests.cs @@ -0,0 +1,91 @@ +using System; +using System.Linq; +using System.Reactive.Concurrency; +using System.Reactive.Linq; +using System.Threading.Tasks; + +using FluentAssertions; +using Xunit; + +using DynamicData.Tests.Utilities; + +namespace DynamicData.Tests.Cache; + +public static partial class ToObservableChangeSetFixture +{ + public static partial class Sequences + { + public class IntegrationTests + : IntegrationTestFixtureBase + { + [Theory(Timeout = 60_000)] + [InlineData(SchedulerType.Default)] + [InlineData(SchedulerType.NewThread)] + [InlineData(SchedulerType.TaskPool)] + [InlineData(SchedulerType.ThreadPool)] + public async Task MultipleSubscriptionsRunInParallel_SchedulerUsageIsThreadSafe(SchedulerType schedulerType) + { + IScheduler scheduler = schedulerType switch + { + SchedulerType.Default => DefaultScheduler.Instance, + SchedulerType.NewThread => new NewThreadScheduler(), + SchedulerType.TaskPool => TaskPoolScheduler.Default, + SchedulerType.ThreadPool => ThreadPoolScheduler.Instance, + _ => throw new ArgumentOutOfRangeException(nameof(SchedulerType)) + }; + + using var subscription1 = Observable.Interval( + period: TimeSpan.FromMilliseconds(5), + scheduler: scheduler) + .Take(IntegrationTestItemCount) + .Select(id => new[] + { + new Item() + { + Id = (int)(id % 100), + Lifetime = TimeSpan.FromMilliseconds(50) + } + }) + .ToObservableChangeSet( + keySelector: Item.SelectId, + expireAfter: Item.SelectLifetime, + scheduler: scheduler) + .ValidateSynchronization() + .ValidateChangeSets(static item => item.Id) + .RecordCacheItems(out var results1); + + using var subscription2 = Observable.Interval( + period: TimeSpan.FromMilliseconds(5), + scheduler: scheduler) + .Take(IntegrationTestItemCount) + .Select(id => new[] + { + new Item() + { + Id = (int)(id % 100) + 100, + Lifetime = TimeSpan.FromMilliseconds(50) + } + }) + .ToObservableChangeSet( + keySelector: Item.SelectId, + expireAfter: Item.SelectLifetime, + scheduler: scheduler) + .ValidateSynchronization() + .ValidateChangeSets(static item => item.Id) + .RecordCacheItems(out var results2); + + await Task.WhenAll( + results1.WhenFinalized, + results2.WhenFinalized); + + results1.Error.Should().BeNull(); + results1.HasCompleted.Should().BeTrue("all changes should have been processed successfully"); + results1.RecordedItemsByKey.Should().BeEmpty("all items should have expired"); + + results2.Error.Should().BeNull(); + results2.HasCompleted.Should().BeTrue("all changes should have been processed successfully"); + results2.RecordedItemsByKey.Should().BeEmpty("all items should have expired"); + } + } + } +} diff --git a/src/DynamicData.Tests/Cache/ToObservableChangeSetFixture.Sequences.UnitTests.cs b/src/DynamicData.Tests/Cache/ToObservableChangeSetFixture.Sequences.UnitTests.cs new file mode 100644 index 000000000..94213315a --- /dev/null +++ b/src/DynamicData.Tests/Cache/ToObservableChangeSetFixture.Sequences.UnitTests.cs @@ -0,0 +1,507 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reactive.Linq; +using System.Reactive.Subjects; + +using Microsoft.Reactive.Testing; + +using FluentAssertions; +using Xunit; + +using DynamicData.Tests.Utilities; + +namespace DynamicData.Tests.Cache; + +public static partial class ToObservableChangeSetFixture +{ + public static partial class Sequences + { + public class UnitTests + { + [Fact] + public void ExpireAfterThrows_ErrorPropagates() + { + // Setup + using var source = new Subject>(); + + var error = new Exception("Test Exception"); + + + // UUT Initialization + using var subscription = source + .ToObservableChangeSet( + keySelector: Item.SelectId, + expireAfter: static item => (item.Error is not null) + ? throw item.Error + : item.Lifetime) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "an initial changeset should always be emitted"); + results.RecordedItemsByKey.Should().BeEmpty("no items have been emitted by the source"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + var item1 = new Item() { Id = 1 }; + source.OnNext(new[] + { + item1, + new Item() { Id = 2, Error = error }, + new Item() { Id = 3 } + }); + + results.Error.Should().BeSameAs(error); + results.RecordedChangeSets.Skip(1).Should().BeEmpty("an error occurred during processing of the sequence"); + results.RecordedItemsByKey.Values.Should().BeEmpty("an error occurred during processing of the sequence"); + + + results.ShouldNotSupportSorting($"Cache source operators do not support sorting"); + } + + [Fact] + public void KeySelectorIsNull_ThrowsException() + => FluentActions.Invoking(() => ObservableCacheEx.ToObservableChangeSet( + source: new Subject>(), + keySelector: null!)) + .Should().Throw(); + + [Fact] + public void KeySelectorThrows_ErrorPropagates() + { + // Setup + using var source = new Subject>(); + + var error = new Exception("Test Exception"); + + + // UUT Initialization + using var subscription = source + .ToObservableChangeSet(static item => (item.Error is not null) + ? throw item.Error + : item.Id) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "an initial changeset should always be emitted"); + results.RecordedItemsByKey.Should().BeEmpty("no items have been emitted by the source"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + var item1 = new Item() { Id = 1 }; + source.OnNext(new[] + { + item1, + new Item() { Id = 2, Error = error }, + new Item() { Id = 3 } + }); + + results.Error.Should().BeSameAs(error); + results.RecordedChangeSets.Skip(1).Should().BeEmpty("an error occurred during processing of the sequence"); + results.RecordedItemsByKey.Values.Should().BeEmpty("an error occurred during processing of the sequence"); + + + results.ShouldNotSupportSorting($"Cache source operators do not support sorting"); + } + + [Fact] + public void SizeLimitIsExceeded_OldestItemsAreRemoved() + { + // Setup + using var source = new Subject>(); + + + // UUT Initialization + using var subscription = source + .ToObservableChangeSet( + keySelector: Item.SelectId, + limitSizeTo: 5) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "an initial changeset should always be emitted"); + results.RecordedItemsByKey.Values.Should().BeEmpty("no source items have been emitted"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action: Not enough items to reach the limit + var item1 = new Item() { Id = 1 }; + var item2 = new Item() { Id = 2 }; + var item3 = new Item() { Id = 3 }; + var item4 = new Item() { Id = 4 }; + source.OnNext(new[] + { + item1, + item2, + item3, + item4 + }); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "1 source operation was performed"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item1, item2, item3, item4 }, "4 source items were emitted"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action: Limit is reached + var item5 = new Item() { Id = 5 }; + source.OnNext(new[] { item5 }); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(2).Count().Should().Be(1, "1 source operation was performed"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item1, item2, item3, item4, item5 }, "1 source item was emitted"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action: New item exceeds the limit + var item6 = new Item() { Id = 6 }; + source.OnNext(new[] { item6 }); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(3).Count().Should().Be(1, "1 source operation was performed"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item2, item3, item4, item5, item6 }, "1 source item was emitted, and 1 was evicted"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action: Replacement leaves all other items in-place + var item7 = new Item() { Id = 4 }; + source.OnNext(new[] { item7 }); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(4).Count().Should().Be(1, "1 source operation was performed"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item2, item3, item7, item5, item6 }, "1 source item was emitted, and replaced an existing item."); + results.HasCompleted.Should().BeFalse("the source has not completed"); + } + + [Theory] + [InlineData(SourceType.Asynchronous)] + [InlineData(SourceType.Immediate)] + public void SourceCompletesWhenExpirationsArePending_CompletionWaitsForExpirations(SourceType sourceType) + { + // Setup + var items = new[] + { + new Item() { Id = 1, Lifetime = TimeSpan.FromSeconds(10) }, + new Item() { Id = 2 }, + new Item() { Id = 3, Lifetime = TimeSpan.FromSeconds(20) } + }; + + var source = sourceType switch + { + SourceType.Asynchronous => new Subject>(), + SourceType.Immediate => Observable.Return>(items), + _ => throw new ArgumentOutOfRangeException(nameof(sourceType)) + }; + + var scheduler = new TestScheduler(); + + + // UUT Initialization & Action + using var subscription = source + .ToObservableChangeSet( + keySelector: Item.SelectId, + expireAfter: Item.SelectLifetime, + scheduler: scheduler) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + if (source is Subject> subject) + { + subject.OnNext(items); + subject.OnCompleted(); + } + + results.Error.Should().BeNull(); + if (sourceType is SourceType.Asynchronous) + results.RecordedChangeSets.Count.Should().Be(2, "1 sequence was emitted, after initialization"); + else + results.RecordedChangeSets.Count.Should().Be(1, "an initial changeset should always be emitted"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(items, "3 items were emitted"); + results.HasCompleted.Should().BeFalse("2 items have yet to expire"); + + + // UUT Action + scheduler.AdvanceTo(TimeSpan.FromSeconds(30).Ticks); + + results.Error.Should().BeNull(); + if (sourceType is SourceType.Asynchronous) + results.RecordedChangeSets.Skip(2).Count().Should().Be(2, "2 items should have expired, at different times"); + else + results.RecordedChangeSets.Skip(1).Count().Should().Be(2, "2 items should have expired, at different times"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(items.Where(static item => item.Lifetime is null), "all expirable items have expired"); + results.HasCompleted.Should().BeTrue("the source, and all outstanding expirations, have completed"); + + + results.ShouldNotSupportSorting(); + } + + [Theory] + [InlineData(SourceType.Asynchronous)] + [InlineData(SourceType.Immediate)] + public void SourceCompletesWhenNoExpirationsArePending_CompletionPropagates(SourceType sourceType) + { + // Setup + var items = new[] + { + new Item() { Id = 1 }, + new Item() { Id = 2 }, + new Item() { Id = 3 } + }; + + var source = sourceType switch + { + SourceType.Asynchronous => new Subject>(), + SourceType.Immediate => Observable.Return>(items), + _ => throw new ArgumentOutOfRangeException(nameof(sourceType)) + }; + + var scheduler = new TestScheduler(); + + + // UUT Initialization & Action + using var subscription = source + .ToObservableChangeSet( + keySelector: Item.SelectId, + expireAfter: Item.SelectLifetime, + scheduler: scheduler) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + if (source is Subject> subject) + { + subject.OnNext(items); + subject.OnCompleted(); + } + + results.Error.Should().BeNull(); + if (sourceType is SourceType.Asynchronous) + results.RecordedChangeSets.Count.Should().Be(2, "1 sequence was emitted, after initialization"); + else + results.RecordedChangeSets.Count.Should().Be(1, "an initial changeset should always be emitted"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(items, "3 items were emitted"); + results.HasCompleted.Should().BeTrue("the source has completed, and no items remain to be expired"); + + + results.ShouldNotSupportSorting(); + } + + [Fact] + public void SourceEmitsRepeatedItems_RepeatedItemsAreUpdatedAndExpirationIsRescheduled() + { + // Setup + using var source = new Subject>(); + + var scheduler = new TestScheduler(); + + + // UUT Initialization + using var subscription = source + .ToObservableChangeSet( + keySelector: Item.SelectId, + expireAfter: Item.SelectLifetime, + scheduler: scheduler) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "an initial changeset should always be emitted"); + results.RecordedItemsByKey.Values.Should().BeEmpty("no source items have been emitted"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + var item1 = new Item() { Id = 1, Lifetime = TimeSpan.FromSeconds(1) }; + var item2 = new Item() { Id = 2 }; + var item3 = new Item() { Id = 3, Lifetime = TimeSpan.FromSeconds(1) }; + source.OnNext(new[] { item1, item2, item3 }); + scheduler.AdvanceBy(1); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "1 sequence was emitted"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item1, item2, item3 }, "3 items were emitted"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + var item4 = new Item() { Id = 1, Lifetime = TimeSpan.FromSeconds(1) }; + var item5 = new Item() { Id = 2 }; + var item6 = new Item() { Id = 3, Lifetime = TimeSpan.FromSeconds(3) }; + source.OnNext(new[] { item4, item5, item6 }); + scheduler.AdvanceBy(1); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(2).Count().Should().Be(1, "1 sequence was emitted"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item4, item5, item6 }, "3 items were emitted, and replaced existing items"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + scheduler.AdvanceTo(TimeSpan.FromSeconds(1).Ticks); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(3).Count().Should().Be(1, "1 expiration should have occurred"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item5, item6 }, "1 source item reached its expiration"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + scheduler.AdvanceTo(TimeSpan.FromSeconds(2).Ticks); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(4).Should().BeEmpty("no expirations should have occurred"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item5, item6 }, "no changes were made"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + scheduler.AdvanceTo(TimeSpan.FromSeconds(3).Ticks); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(4).Count().Should().Be(1, "1 expiration should have occurred"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item5 }, "1 source item reached its expiration"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + scheduler.AdvanceTo(TimeSpan.FromSeconds(4).Ticks); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(5).Should().BeEmpty("no expirations should have occurred"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item5 }, "no changes were made"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + results.ShouldNotSupportSorting(); + } + + [Fact] + public void SourceEmitsUniqueItems_ItemsAreAddedAndRemovedWhenExpired() + { + // Setup + using var source = new Subject>(); + + var scheduler = new TestScheduler(); + + + // UUT Initialization + using var subscription = source + .ToObservableChangeSet( + keySelector: Item.SelectId, + expireAfter: Item.SelectLifetime, + scheduler: scheduler) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "an initial changeset should always be emitted"); + results.RecordedItemsByKey.Values.Should().BeEmpty("no source items have been emitted"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + var item1 = new Item() { Id = 1, Lifetime = TimeSpan.FromSeconds(3) }; + var item2 = new Item() { Id = 2 }; + var item3 = new Item() { Id = 3, Lifetime = TimeSpan.FromSeconds(1) }; + source.OnNext(new[] { item1, item2, item3 }); + scheduler.AdvanceBy(1); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "1 sequence was emitted"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item1, item2, item3 }, "3 items were emitted"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + scheduler.AdvanceTo(TimeSpan.FromSeconds(1).Ticks); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(2).Count().Should().Be(1, "1 expiration should have occurred"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item1, item2 }, "1 item expired, and 1 had its lifetime extended"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + scheduler.AdvanceTo(TimeSpan.FromSeconds(2).Ticks); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(3).Should().BeEmpty("no expirations should have occurred"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item1, item2 }, "no changes were made"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + scheduler.AdvanceTo(TimeSpan.FromSeconds(3).Ticks); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(3).Count().Should().Be(1, "1 expiration should have occurred"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item2 }, "1 item reached its expiration"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + scheduler.AdvanceTo(TimeSpan.FromSeconds(4).Ticks); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(4).Should().BeEmpty("no expirations should have occurred"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item2 }, "no changes were made"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + results.ShouldNotSupportSorting(); + } + + [Theory] + [InlineData(SourceType.Asynchronous)] + [InlineData(SourceType.Immediate)] + public void SourceFails_ErrorPropagates(SourceType sourceType) + { + // Setup + var error = new Exception("Test Exception"); + + var source = sourceType switch + { + SourceType.Asynchronous => new Subject>(), + SourceType.Immediate => Observable.Throw>(error), + _ => throw new ArgumentOutOfRangeException(nameof(sourceType)) + }; + + + // UUT Initialization & Action + using var subscription = source + .ToObservableChangeSet(Item.SelectId) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + if (source is Subject> subject) + subject.OnError(error); + + results.Error.Should().BeSameAs(error, "errors should propagate"); + if (sourceType is SourceType.Asynchronous) + results.RecordedChangeSets.Count.Should().Be(1, "an initial changeset should always be emitted"); + else + results.RecordedChangeSets.Should().BeEmpty("an error occurred during initialization"); + results.RecordedItemsByKey.Values.Should().BeEmpty("no source items were emitted"); + } + + [Fact] + public void SourceIsNull_ThrowsException() + => FluentActions.Invoking(() => ObservableCacheEx.ToObservableChangeSet( + source: (null as IObservable>)!, + keySelector: Item.SelectId)) + .Should().Throw(); + } + } +} diff --git a/src/DynamicData.Tests/Cache/ToObservableChangeSetFixture.cs b/src/DynamicData.Tests/Cache/ToObservableChangeSetFixture.cs index 085cc30e4..c1239a1d8 100644 --- a/src/DynamicData.Tests/Cache/ToObservableChangeSetFixture.cs +++ b/src/DynamicData.Tests/Cache/ToObservableChangeSetFixture.cs @@ -1,327 +1,38 @@ using System; -using System.Collections.Generic; -using System.Reactive.Disposables; -using System.Reactive.Linq; -using System.Reactive.Subjects; - -using FluentAssertions; - -using Microsoft.Reactive.Testing; - -using Xunit; namespace DynamicData.Tests.Cache; -public class ToObservableChangeSetFixture - : ReactiveTest +public static partial class ToObservableChangeSetFixture { - [Fact] - public void ExpirationIsGiven_RemovalIsScheduled() - { - using var source = new Subject>(); - - var scheduler = new TestScheduler(); - - using var results = new ChangeSetAggregator(source - .ToObservableChangeSet( - keySelector: static item => item.Id, - expireAfter: static item => item.Lifetime, - scheduler: scheduler)); - - var item1 = new Item() { Id = 1, Lifetime = TimeSpan.FromMilliseconds(10) }; - var item2 = new Item() { Id = 2, Lifetime = TimeSpan.FromMilliseconds(20) }; - var item3 = new Item() { Id = 3, Lifetime = TimeSpan.FromMilliseconds(30) }; - var item4 = new Item() { Id = 4, Lifetime = TimeSpan.FromMilliseconds(40) }; - var item5 = new Item() { Id = 5, Lifetime = TimeSpan.FromMilliseconds(50) }; - source.OnNext(new[] { item1, item2, item3, item4, item5 }); - scheduler.AdvanceBy(1); - - // Item removals should batch to the closest prior millisecond. - // This actually seems wrong to me, that for items to be removed earlier than asked for. - // Should this maybe batch to the closest future millisecond, or just round to the nearest? - var item6 = new Item() { Id = 6, Lifetime = TimeSpan.FromMilliseconds(20.1) }; - var item7 = new Item() { Id = 7, Lifetime = TimeSpan.FromMilliseconds(20.9) }; - source.OnNext(new[] { item6, item7 }); - scheduler.AdvanceBy(1); - - // Out-of-order removal - var item8 = new Item() { Id = 8, Lifetime = TimeSpan.FromMilliseconds(15) }; - source.OnNext(new[] { item8 }); - scheduler.AdvanceBy(1); - - // Non-expiring item - var item9 = new Item() { Id = 9 }; - source.OnNext(new[] { item9 }); - scheduler.AdvanceBy(1); - - // Replacement changing lifetime. - var item10 = new Item() { Id = 4, Lifetime = TimeSpan.FromMilliseconds(45) }; - source.OnNext(new[] { item10 }); - scheduler.AdvanceBy(1); - - // Replacement not-affecting lifetime. - var item11 = new Item() { Id = 5, Lifetime = TimeSpan.FromMilliseconds(50) }; - source.OnNext(new[] { item11 }); - scheduler.AdvanceBy(1); - - // Verify initial state, after all emissions - results.Error.Should().BeNull(); - results.Messages.Count.Should().Be(6, "6 item sets were emitted"); - results.Data.Items.Should().BeEquivalentTo(new[] { item1, item2, item3, item6, item7, item8, item9, item10, item11 }, "11 items were emitted, 2 of which were replacements"); - - scheduler.AdvanceTo(TimeSpan.FromMilliseconds(10).Ticks); - - results.Error.Should().BeNull(); - results.Messages.Count.Should().Be(7, "1 expiration should have occurred, since the last check"); - results.Data.Items.Should().BeEquivalentTo(new[] { item2, item3, item6, item7, item8, item9, item10, item11 }, "item #1 should have expired"); - - scheduler.AdvanceTo(TimeSpan.FromMilliseconds(15).Ticks); - - results.Error.Should().BeNull(); - results.Messages.Count.Should().Be(8, "1 expiration should have occurred, since the last check"); - results.Data.Items.Should().BeEquivalentTo(new[] { item2, item3, item6, item7, item9, item10, item11 }, "item #8 should have expired"); - - scheduler.AdvanceTo(TimeSpan.FromMilliseconds(20).Ticks); - - results.Error.Should().BeNull(); - results.Messages.Count.Should().Be(9, "1 expiration should have occurred, since the last check"); - results.Data.Items.Should().BeEquivalentTo(new[] { item3, item9, item10, item11 }, "items #2, #6, and #7 should have expired"); - - scheduler.AdvanceTo(TimeSpan.FromMilliseconds(30).Ticks); + public const int IntegrationTestItemCount + #if RELEASE + = 1_000; + #else + = 100; + #endif - results.Error.Should().BeNull(); - results.Messages.Count.Should().Be(10, "1 expiration should have occurred, since the last check"); - results.Data.Items.Should().BeEquivalentTo(new[] { item9, item10, item11 }, "item #3 should have expired"); - - scheduler.AdvanceTo(TimeSpan.FromMilliseconds(40).Ticks); - - results.Error.Should().BeNull(); - results.Messages.Count.Should().Be(10, "no changes should have occurred, since the last check"); - results.Data.Items.Should().BeEquivalentTo(new[] { item9, item10, item11 }, "no items should have expired"); - - scheduler.AdvanceTo(TimeSpan.FromMilliseconds(45).Ticks); - - results.Error.Should().BeNull(); - results.Messages.Count.Should().Be(11, "1 expiration should have occurred, since the last check"); - results.Data.Items.Should().BeEquivalentTo(new[] { item9, item11 }, "item #10 should have expired"); - - scheduler.AdvanceTo(TimeSpan.FromMilliseconds(50).Ticks); - - results.Error.Should().BeNull(); - results.Messages.Count.Should().Be(12, "1 expiration should have occurred, since the last check"); - results.Data.Items.Should().BeEquivalentTo(new[] { item9 }, "item #11 should have expired"); - } - - [Fact] - public void KeySelectorIsNull_ThrowsException() - => FluentActions.Invoking(() => ObservableCacheEx.ToObservableChangeSet( - source: new Subject(), - keySelector: null!)) - .Should().Throw(); - - [Fact] - public void KeySelectorThrows_SubscriptionReceivesError() + public enum SchedulerType { - using var source = new Subject(); - - var error = new Exception("Test Exception"); - - using var results = new ChangeSetAggregator(source - .ToObservableChangeSet(static item => (item.Error is not null) - ? throw item.Error - : item.Id)); - - var item1 = new Item() { Id = 1 }; - source.OnNext(item1); - source.OnNext(new Item() { Id = 2, Error = error }); - source.OnNext(new Item() { Id = 3 }); - - results.Error.Should().BeSameAs(error); - results.Messages.Count.Should().Be(1, "1 item was emitted before an error occurred"); - results.Data.Items.Should().BeEquivalentTo(new[] { item1 }, "1 item was emitted before an error occurred"); + Default, + TaskPool, + ThreadPool, + NewThread } - [Fact] - public void RemovalsArePending_CompletionWaitsForRemovals() + public enum SourceType { - using var source = new Subject>(); - - var scheduler = new TestScheduler(); - - using var results = new ChangeSetAggregator(source - .ToObservableChangeSet( - keySelector: static item => item.Id, - expireAfter: static item => item.Lifetime, - scheduler: scheduler)); - - var item1 = new Item() { Id = 1, Lifetime = TimeSpan.FromMilliseconds(10) }; - var item2 = new Item() { Id = 2 }; - var item3 = new Item() { Id = 3, Lifetime = TimeSpan.FromMilliseconds(30) }; - source.OnNext(new[] { item1, item2, item3 }); - scheduler.AdvanceBy(1); - - source.OnCompleted(); - - results.IsCompleted.Should().BeFalse("item removals have been scheduled, and not completed"); - results.Messages.Count.Should().Be(1, "1 item set was emitted"); - results.Data.Items.Should().BeEquivalentTo(new[] { item1, item2, item3 }, "3 items were emitted"); - - scheduler.AdvanceTo(TimeSpan.FromMilliseconds(30).Ticks); - - results.IsCompleted.Should().BeTrue("the source has completed, and no outstanding expirations remain"); - results.Messages.Count.Should().Be(3, "2 expirations should have occurred, since the last check"); - results.Data.Items.Should().BeEquivalentTo(new[] { item2 }, "3 items were emitted, and 2 should have expired"); + Immediate, + Asynchronous } - [Fact] - public void SourceCompletesImmediately_SubscriptionCompletes() + public record Item { - var item = new Item() { Id = 1 }; + public static int SelectId(Item item) + => item.Id; - var source = Observable.Create(observer => - { - observer.OnNext(item); - observer.OnCompleted(); - return Disposable.Empty; - }); + public static TimeSpan? SelectLifetime(Item item) + => item.Lifetime; - using var results = new ChangeSetAggregator(source - .ToObservableChangeSet(static item => item.Id)); - - results.IsCompleted.Should().BeTrue("the source has completed, and no outstanding expirations remain"); - results.Messages.Count.Should().Be(1, "1 item was emitted"); - results.Data.Items.Should().BeEquivalentTo(new[] { item }, "1 item was emitted"); - } - - [Fact] - public void SizeLimitIsExceeded_OldestItemsAreRemoved() - { - using var source = new Subject>(); - - // scheduler is currently used to process evictions, even though they could be processed synchronously. Plan to change this. - var scheduler = new TestScheduler(); - - using var results = new ChangeSetAggregator(source - .ToObservableChangeSet( - keySelector: static item => item.Id, - limitSizeTo: 5, - scheduler: scheduler)); - - // Populate enough initial items so that at least one item at the end never gets evicted - var item1 = new Item() { Id = 1 }; - var item2 = new Item() { Id = 2 }; - var item3 = new Item() { Id = 3 }; - var item4 = new Item() { Id = 4 }; - source.OnNext(new[] { item1, item2, item3, item4 }); - scheduler.AdvanceBy(1); - - // Limit is reached - var item5 = new Item() { Id = 5 }; - source.OnNext(new[] { item5 }); - scheduler.AdvanceBy(1); - - // New item exceeds the limit - var item6 = new Item() { Id = 6 }; - source.OnNext(new[] { item6 }); - scheduler.AdvanceBy(1); - - // Multiple items exceed the limit - var item7 = new Item() { Id = 7 }; - var item8 = new Item() { Id = 8 }; - source.OnNext(new[] { item7, item8 }); - scheduler.AdvanceBy(1); - - // Replacement leaves all other items in-place - var item9 = new Item() { Id = 7 }; - source.OnNext(new[] { item9 }); - scheduler.AdvanceBy(1); - - // Replacement and eviction in at the same time - var item10 = new Item() { Id = 8 }; - var item11 = new Item() { Id = 11 }; - source.OnNext(new[] { item10, item11 }); - scheduler.AdvanceBy(1); - - results.Error.Should().BeNull(); - - // TODO: This was set to 9 but fails, this was changed in a recent commit form 6 to 9, but I'm not sure why. - results.Messages.Count.Should().Be(6, "6 item sets were emitted by the source, 3 of which triggered followup evictions"); - results.Data.Items.Should().BeEquivalentTo(new[] { item5, item6, item9, item10, item11 }, "the size limit of the collection was 5"); - } - - [Fact] - public void SourceErrorsImmediately_SubscriptionReceivesError() - { - var item = new Item() { Id = 1 }; - var error = new Exception("Test Exception"); - - var source = Observable.Create(observer => - { - observer.OnNext(item); - observer.OnError(error); - return Disposable.Empty; - }); - - using var results = new ChangeSetAggregator(source - .ToObservableChangeSet(static item => item.Id)); - - results.Error.Should().BeSameAs(error); - results.Messages.Count.Should().Be(1, "1 item was emitted, before an error occurred"); - results.Data.Items.Should().BeEquivalentTo(new[] { item }, "1 item was emitted, before an error occurred"); - } - - [Fact] - public void SourceEmitsSingle_ItemIsAddedOrUpdated() - { - using var source = new Subject(); - - using var results = new ChangeSetAggregator(source - .ToObservableChangeSet(static item => item.Id)); - - var item1 = new Item() { Id = 1 }; - source.OnNext(item1); - - var item2 = new Item() { Id = 2 }; - source.OnNext(item2); - - var item3 = new Item() { Id = 1 }; - source.OnNext(item3); - - results.Error.Should().BeNull(); - results.Messages.Count.Should().Be(3, "3 items were emitted by the source"); - results.Data.Items.Should().BeEquivalentTo(new[] { item1, item2 }, "3 unique items were emitted, one of which was a replacement"); - } - - [Fact] - public void SourceEmitsMany_ItemsAreAddedOrUpdated() - { - using var source = new Subject>(); - - using var results = new ChangeSetAggregator(source - .ToObservableChangeSet(static item => item.Id)); - - var item1 = new Item() { Id = 1 }; - var item2 = new Item() { Id = 2 }; - source.OnNext(new[] { item1, item2 }); - - var item3 = new Item() { Id = 1 }; - var item4 = new Item() { Id = 3 }; - source.OnNext(new[] { item3, item4 }); - - results.Error.Should().BeNull(); - results.Messages.Count.Should().Be(2, "2 item sets were emitted by the source"); - results.Data.Items.Should().BeEquivalentTo(new[] { item1, item2, item4 }, "4 unique items were emitted, on of which was a replacement"); - } - - [Fact] - public void SourceIsNull_ThrowsException() - => FluentActions.Invoking(() => ObservableCacheEx.ToObservableChangeSet( - source: null!, - keySelector: static item => item)) - .Should().Throw(); - - public class Item - { public int Id { get; init; } public Exception? Error { get; init; } diff --git a/src/DynamicData.Tests/IntegrationTestFixtureBase.cs b/src/DynamicData.Tests/IntegrationTestFixtureBase.cs new file mode 100644 index 000000000..d28b8cf87 --- /dev/null +++ b/src/DynamicData.Tests/IntegrationTestFixtureBase.cs @@ -0,0 +1,11 @@ +using Xunit; + +namespace DynamicData.Tests; + +[Collection(CollectionName)] +[CollectionDefinition(CollectionName, DisableParallelization = true)] +public class IntegrationTestFixtureBase +{ + public const string CollectionName + = "IntegrationTests"; +} diff --git a/src/DynamicData.Tests/List/ToObservableChangeSetFixture.Items.IntegrationTests.cs b/src/DynamicData.Tests/List/ToObservableChangeSetFixture.Items.IntegrationTests.cs new file mode 100644 index 000000000..0a60bc356 --- /dev/null +++ b/src/DynamicData.Tests/List/ToObservableChangeSetFixture.Items.IntegrationTests.cs @@ -0,0 +1,83 @@ +using System; +using System.Linq; +using System.Reactive.Concurrency; +using System.Reactive.Linq; +using System.Threading.Tasks; + +using FluentAssertions; +using Xunit; + +using DynamicData.Tests.Utilities; + +namespace DynamicData.Tests.List; + +public static partial class ToObservableChangeSetFixture +{ + public static partial class Items + { + public class IntegrationTests + : IntegrationTestFixtureBase + { + [Theory(Timeout = 60_000)] + [InlineData(SchedulerType.Default)] + [InlineData(SchedulerType.NewThread)] + [InlineData(SchedulerType.TaskPool)] + [InlineData(SchedulerType.ThreadPool)] + public async Task MultipleSubscriptionsRunInParallel_SchedulerUsageIsThreadSafe(SchedulerType schedulerType) + { + IScheduler scheduler = schedulerType switch + { + SchedulerType.Default => DefaultScheduler.Instance, + SchedulerType.NewThread => new NewThreadScheduler(), + SchedulerType.TaskPool => TaskPoolScheduler.Default, + SchedulerType.ThreadPool => ThreadPoolScheduler.Instance, + _ => throw new ArgumentOutOfRangeException(nameof(SchedulerType)) + }; + + using var subscription1 = Observable.Interval( + period: TimeSpan.FromMilliseconds(5), + scheduler: scheduler) + .Take(IntegrationTestItemCount) + .Select(id => new Item() + { + Id = (int)(id % 100), + Lifetime = TimeSpan.FromMilliseconds(50) + }) + .ToObservableChangeSet( + expireAfter: Item.SelectLifetime, + scheduler: scheduler) + .ValidateSynchronization() + .ValidateChangeSets() + .RecordListItems(out var results1); + + using var subscription2 = Observable.Interval( + period: TimeSpan.FromMilliseconds(5), + scheduler: scheduler) + .Take(IntegrationTestItemCount) + .Select(id => new Item() + { + Id = (int)(id % 100) + 100, + Lifetime = TimeSpan.FromMilliseconds(50) + }) + .ToObservableChangeSet( + expireAfter: Item.SelectLifetime, + scheduler: scheduler) + .ValidateSynchronization() + .ValidateChangeSets() + .RecordListItems(out var results2); + + await Task.WhenAll( + results1.WhenFinalized, + results2.WhenFinalized); + + results1.Error.Should().BeNull(); + results1.HasCompleted.Should().BeTrue("all changes should have been processed successfully"); + results1.RecordedItems.Should().BeEmpty("all items should have expired"); + + results2.Error.Should().BeNull(); + results2.HasCompleted.Should().BeTrue("all changes should have been processed successfully"); + results2.RecordedItems.Should().BeEmpty("all items should have expired"); + } + } + } +} diff --git a/src/DynamicData.Tests/List/ToObservableChangeSetFixture.Items.UnitTests.cs b/src/DynamicData.Tests/List/ToObservableChangeSetFixture.Items.UnitTests.cs new file mode 100644 index 000000000..887a230d4 --- /dev/null +++ b/src/DynamicData.Tests/List/ToObservableChangeSetFixture.Items.UnitTests.cs @@ -0,0 +1,369 @@ +using System; +using System.Linq; +using System.Reactive.Linq; +using System.Reactive.Subjects; + +using Microsoft.Reactive.Testing; + +using FluentAssertions; +using Xunit; + +using DynamicData.Tests.Utilities; + +namespace DynamicData.Tests.List; + +public static partial class ToObservableChangeSetFixture +{ + public static partial class Items + { + public class UnitTests + { + [Fact] + public void ExpireAfterThrows_ErrorPropagates() + { + // Setup + using var source = new Subject(); + + var error = new Exception("Test Exception"); + + + // UUT Initialization + using var subscription = source + .ToObservableChangeSet(expireAfter: static item => (item.Error is not null) + ? throw item.Error + : item.Lifetime) + .ValidateSynchronization() + .ValidateChangeSets() + .RecordListItems(out var results); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "an initial changeset should always be emitted"); + results.RecordedItems.Should().BeEmpty("no items have been emitted by the source"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + var item1 = new Item() { Id = 1 }; + source.OnNext(item1); + source.OnNext(new Item() { Id = 2, Error = error }); + source.OnNext(new Item() { Id = 3 }); + + results.Error.Should().BeSameAs(error); + results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "1 item was emitted before an error occurred"); + results.RecordedItems.Should().BeEquivalentTo(new[] { item1 }, "1 item was emitted before an error occurred"); + } + + [Fact] + public void SizeLimitIsExceeded_OldestItemsAreRemoved() + { + // Setup + using var source = new Subject(); + + + // UUT Initialization + using var subscription = source + .ToObservableChangeSet(limitSizeTo: 5) + .ValidateSynchronization() + .ValidateChangeSets() + .RecordListItems(out var results); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "an initial changeset should always be emitted"); + results.RecordedItems.Should().BeEmpty("no items have been emitted"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action: Not enough items to reach the limit + var item1 = new Item() { Id = 1 }; + source.OnNext(item1); + + var item2 = new Item() { Id = 2 }; + source.OnNext(item2); + + var item3 = new Item() { Id = 3 }; + source.OnNext(item3); + + var item4 = new Item() { Id = 4 }; + source.OnNext(item4); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(1).Count().Should().Be(4, "4 items were emitted"); + results.RecordedItems.Should().BeEquivalentTo(new[] { item1, item2, item3, item4 }, + because: "4 items were emitted", + config: options => options.WithStrictOrdering()); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action: Limit is reached + var item5 = new Item() { Id = 5 }; + source.OnNext(item5); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(5).Count().Should().Be(1, "1 item was emitted"); + results.RecordedItems.Should().BeEquivalentTo(new[] { item1, item2, item3, item4, item5 }, + because: "1 item was emitted", + config: options => options.WithStrictOrdering()); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action: New item exceeds the limit + var item6 = new Item() { Id = 6 }; + source.OnNext(item6); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(6).Count().Should().Be(1, "1 item was emitted"); + results.RecordedItems.Should().BeEquivalentTo(new[] { item2, item3, item4, item5, item6 }, + because: "1 item was emitted, and 1 was evicted", + config: options => options.WithStrictOrdering()); + results.HasCompleted.Should().BeFalse("the source has not completed"); + } + + [Theory] + [InlineData(SourceType.Asynchronous)] + [InlineData(SourceType.Immediate)] + public void SourceCompletesWhenExpirationsArePending_CompletionWaitsForExpirations(SourceType sourceType) + { + // Setup + var item = new Item() { Id = 1, Lifetime = TimeSpan.FromSeconds(10) }; + + var source = sourceType switch + { + SourceType.Asynchronous => new Subject(), + SourceType.Immediate => Observable.Return(item), + _ => throw new ArgumentOutOfRangeException(nameof(sourceType)) + }; + + var scheduler = new TestScheduler(); + + + // UUT Initialization & Action + using var subscription = source + .ToObservableChangeSet( + expireAfter: Item.SelectLifetime, + scheduler: scheduler) + .ValidateSynchronization() + .ValidateChangeSets() + .RecordListItems(out var results); + + if (source is Subject subject) + { + subject.OnNext(item); + subject.OnCompleted(); + } + + results.Error.Should().BeNull(); + if (sourceType is SourceType.Asynchronous) + results.RecordedChangeSets.Count.Should().Be(2, "1 item was emitted, after initialization"); + else + results.RecordedChangeSets.Count.Should().Be(1, "an initial changeset should always be emitted"); + results.RecordedItems.Should().BeEquivalentTo(new[] { item }, + because: "1 item was emitted", + config: options => options.WithStrictOrdering()); + results.HasCompleted.Should().BeFalse("1 item has yet to expire"); + + + // UUT Action + scheduler.AdvanceTo(TimeSpan.FromSeconds(10).Ticks); + + results.Error.Should().BeNull(); + if (sourceType is SourceType.Asynchronous) + results.RecordedChangeSets.Skip(2).Count().Should().Be(1, "1 item should have expired"); + else + results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "1 item should have expired"); + results.RecordedItems.Should().BeEmpty("all items have expired"); + results.HasCompleted.Should().BeTrue("the source, and all outstanding expirations, have completed"); + } + + [Theory] + [InlineData(SourceType.Asynchronous)] + [InlineData(SourceType.Immediate)] + public void SourceCompletesWhenNoExpirationsArePending_CompletionPropagates(SourceType sourceType) + { + // Setup + var item = new Item() { Id = 1 }; + + var source = sourceType switch + { + SourceType.Asynchronous => new Subject(), + SourceType.Immediate => Observable.Return(item), + _ => throw new ArgumentOutOfRangeException(nameof(sourceType)) + }; + + var scheduler = new TestScheduler(); + + + // UUT Initialization & Action + using var subscription = source + .ToObservableChangeSet( + expireAfter: Item.SelectLifetime, + scheduler: scheduler) + .ValidateSynchronization() + .ValidateChangeSets() + .RecordListItems(out var results); + + if (source is Subject subject) + { + subject.OnNext(item); + subject.OnCompleted(); + } + + results.Error.Should().BeNull(); + if (sourceType is SourceType.Asynchronous) + results.RecordedChangeSets.Count.Should().Be(2, "1 item was emitted, after initialization"); + else + results.RecordedChangeSets.Count.Should().Be(1, "an initial changeset should always be emitted"); + results.RecordedItems.Should().BeEquivalentTo(new[] { item }, + because: "1 item was emitted", + config: options => options.WithStrictOrdering()); + results.HasCompleted.Should().BeTrue("the source has completed, and no items remain to be expired"); + } + + [Fact] + public void SourceEmitsItems_ItemsAreAddedAndRemovedWhenExpired() + { + // Setup + using var source = new Subject(); + + var scheduler = new TestScheduler(); + + + // UUT Initialization + using var subscription = source + .ToObservableChangeSet( + expireAfter: Item.SelectLifetime, + scheduler: scheduler) + .ValidateSynchronization() + .ValidateChangeSets() + .RecordListItems(out var results); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "an initial changeset should always be emitted"); + results.RecordedItems.Should().BeEmpty("no items have been emitted"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + var item1 = new Item() { Id = 1, Lifetime = TimeSpan.FromSeconds(3) }; + source.OnNext(item1); + scheduler.AdvanceBy(1); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "1 item was emitted"); + results.RecordedItems.Should().BeEquivalentTo(new[] { item1 }, + because: "1 item was emitted", + config: options => options.WithStrictOrdering()); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + var item2 = new Item() { Id = 2 }; + source.OnNext(item2); + scheduler.AdvanceBy(1); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(2).Count().Should().Be(1, "1 item was emitted"); + results.RecordedItems.Should().BeEquivalentTo(new[] { item1, item2 }, + because: "1 item was emitted", + config: options => options.WithStrictOrdering()); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + var item3 = new Item() { Id = 3, Lifetime = TimeSpan.FromSeconds(1) }; + source.OnNext(item3); + scheduler.AdvanceBy(1); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(3).Count().Should().Be(1, "1 item was emitted"); + results.RecordedItems.Should().BeEquivalentTo(new[] { item1, item2, item3 }, + because: "1 item was emitted", + config: options => options.WithStrictOrdering()); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + scheduler.AdvanceTo(TimeSpan.FromSeconds(1).Ticks); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(4).Count().Should().Be(1, "1 expiration should have occurred"); + results.RecordedItems.Should().BeEquivalentTo(new[] { item1, item2 }, + because: "1 item expired, and 1 had its lifetime extended", + config: options => options.WithStrictOrdering()); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + scheduler.AdvanceTo(TimeSpan.FromSeconds(2).Ticks); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(5).Should().BeEmpty("no expirations should have occurred"); + results.RecordedItems.Should().BeEquivalentTo(new[] { item1, item2 }, + because: "no changes were made", + config: options => options.WithStrictOrdering()); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + scheduler.AdvanceTo(TimeSpan.FromSeconds(3).Ticks); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(5).Count().Should().Be(1, "1 expiration should have occurred"); + results.RecordedItems.Should().BeEquivalentTo(new[] { item2 }, + because: "1 item reached its expiration", + config: options => options.WithStrictOrdering()); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + scheduler.AdvanceTo(TimeSpan.FromSeconds(4).Ticks); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(6).Should().BeEmpty("no expirations should have occurred"); + results.RecordedItems.Should().BeEquivalentTo(new[] { item2 }, + because: "no changes were made", + config: options => options.WithStrictOrdering()); + results.HasCompleted.Should().BeFalse("the source has not completed"); + } + + [Theory] + [InlineData(SourceType.Asynchronous)] + [InlineData(SourceType.Immediate)] + public void SourceFails_ErrorPropagates(SourceType sourceType) + { + // Setup + var error = new Exception("Test Exception"); + + var source = sourceType switch + { + SourceType.Asynchronous => new Subject(), + SourceType.Immediate => Observable.Throw(error), + _ => throw new ArgumentOutOfRangeException(nameof(sourceType)) + }; + + + // UUT Initialization & Action + using var subscription = source + .ToObservableChangeSet() + .ValidateSynchronization() + .ValidateChangeSets() + .RecordListItems(out var results); + + if (source is Subject subject) + subject.OnError(error); + + results.Error.Should().BeSameAs(error, "errors should propagate"); + if (sourceType is SourceType.Asynchronous) + results.RecordedChangeSets.Count.Should().Be(1, "an initial changeset should always be emitted"); + else + results.RecordedChangeSets.Should().BeEmpty("an error occurred during initialization"); + results.RecordedItems.Should().BeEmpty("no items were emitted"); + } + + [Fact] + public void SourceIsNull_ThrowsException() + => FluentActions.Invoking(() => ObservableListEx.ToObservableChangeSet( + source: (null as IObservable)!)) + .Should().Throw(); + } + } +} diff --git a/src/DynamicData.Tests/List/ToObservableChangeSetFixture.Sequences.IntegrationTests.cs b/src/DynamicData.Tests/List/ToObservableChangeSetFixture.Sequences.IntegrationTests.cs new file mode 100644 index 000000000..505404290 --- /dev/null +++ b/src/DynamicData.Tests/List/ToObservableChangeSetFixture.Sequences.IntegrationTests.cs @@ -0,0 +1,89 @@ +using System; +using System.Linq; +using System.Reactive.Concurrency; +using System.Reactive.Linq; +using System.Threading.Tasks; + +using FluentAssertions; +using Xunit; + +using DynamicData.Tests.Utilities; + +namespace DynamicData.Tests.List; + +public static partial class ToObservableChangeSetFixture +{ + public static partial class Sequences + { + public class IntegrationTests + : IntegrationTestFixtureBase + { + [Theory(Timeout = 60_000)] + [InlineData(SchedulerType.Default)] + [InlineData(SchedulerType.NewThread)] + [InlineData(SchedulerType.TaskPool)] + [InlineData(SchedulerType.ThreadPool)] + public async Task MultipleSubscriptionsRunInParallel_SchedulerUsageIsThreadSafe(SchedulerType schedulerType) + { + IScheduler scheduler = schedulerType switch + { + SchedulerType.Default => DefaultScheduler.Instance, + SchedulerType.NewThread => new NewThreadScheduler(), + SchedulerType.TaskPool => TaskPoolScheduler.Default, + SchedulerType.ThreadPool => ThreadPoolScheduler.Instance, + _ => throw new ArgumentOutOfRangeException(nameof(schedulerType)) + }; + + using var subscription1 = Observable.Interval( + period: TimeSpan.FromMilliseconds(5), + scheduler: scheduler) + .Take(IntegrationTestItemCount) + .Select(id => new[] + { + new Item() + { + Id = (int)(id % 100), + Lifetime = TimeSpan.FromMilliseconds(50) + } + }) + .ToObservableChangeSet( + expireAfter: Item.SelectLifetime, + scheduler: scheduler) + .ValidateSynchronization() + .ValidateChangeSets() + .RecordListItems(out var results1); + + using var subscription2 = Observable.Interval( + period: TimeSpan.FromMilliseconds(5), + scheduler: scheduler) + .Take(IntegrationTestItemCount) + .Select(id => new[] + { + new Item() + { + Id = (int)(id % 100) + 100, + Lifetime = TimeSpan.FromMilliseconds(50) + } + }) + .ToObservableChangeSet( + expireAfter: Item.SelectLifetime, + scheduler: scheduler) + .ValidateSynchronization() + .ValidateChangeSets() + .RecordListItems(out var results2); + + await Task.WhenAll( + results1.WhenFinalized, + results2.WhenFinalized); + + results1.Error.Should().BeNull(); + results1.HasCompleted.Should().BeTrue("all changes should have been processed successfully"); + results1.RecordedItems.Should().BeEmpty("all items should have expired"); + + results2.Error.Should().BeNull(); + results2.HasCompleted.Should().BeTrue("all changes should have been processed successfully"); + results2.RecordedItems.Should().BeEmpty("all items should have expired"); + } + } + } +} diff --git a/src/DynamicData.Tests/List/ToObservableChangeSetFixture.Sequences.UnitTests.cs b/src/DynamicData.Tests/List/ToObservableChangeSetFixture.Sequences.UnitTests.cs new file mode 100644 index 000000000..55b1fdaae --- /dev/null +++ b/src/DynamicData.Tests/List/ToObservableChangeSetFixture.Sequences.UnitTests.cs @@ -0,0 +1,361 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reactive.Linq; +using System.Reactive.Subjects; + +using Microsoft.Reactive.Testing; + +using FluentAssertions; +using Xunit; + +using DynamicData.Tests.Utilities; + +namespace DynamicData.Tests.List; + +public static partial class ToObservableChangeSetFixture +{ + public static partial class Sequences + { + public class UnitTests + { + [Fact] + public void ExpireAfterThrows_ErrorPropagates() + { + // Setup + using var source = new Subject>(); + + var error = new Exception("Test Exception"); + + + // UUT Initialization + using var subscription = source + .ToObservableChangeSet(expireAfter: static item => (item.Error is not null) + ? throw item.Error + : item.Lifetime) + .ValidateSynchronization() + .ValidateChangeSets() + .RecordListItems(out var results); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "an initial changeset should always be emitted"); + results.RecordedItems.Should().BeEmpty("no items have been emitted by the source"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + var item1 = new Item() { Id = 1 }; + source.OnNext(new[] + { + item1, + new Item() { Id = 2, Error = error }, + new Item() { Id = 3 } + }); + + results.Error.Should().BeSameAs(error); + results.RecordedChangeSets.Skip(1).Should().BeEmpty("an error occurred during processing of the sequence"); + results.RecordedItems.Should().BeEmpty("an error occurred during processing of the sequence"); + } + + [Fact] + public void SizeLimitIsExceeded_OldestItemsAreRemoved() + { + // Setup + using var source = new Subject>(); + + + // UUT Initialization + using var subscription = source + .ToObservableChangeSet(limitSizeTo: 5) + .ValidateSynchronization() + .ValidateChangeSets() + .RecordListItems(out var results); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "an initial changeset should always be emitted"); + results.RecordedItems.Should().BeEmpty("no source items have been emitted"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action: Not enough items to reach the limit + var item1 = new Item() { Id = 1 }; + var item2 = new Item() { Id = 2 }; + var item3 = new Item() { Id = 3 }; + var item4 = new Item() { Id = 4 }; + source.OnNext(new[] + { + item1, + item2, + item3, + item4 + }); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "1 source operation was performed"); + results.RecordedItems.Should().BeEquivalentTo(new[] { item1, item2, item3, item4 }, + because: "4 source items were emitted", + config: options => options.WithStrictOrdering()); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action: Limit is reached + var item5 = new Item() { Id = 5 }; + source.OnNext(new[] { item5 }); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(2).Count().Should().Be(1, "1 source operation was performed"); + results.RecordedItems.Should().BeEquivalentTo(new[] { item1, item2, item3, item4, item5 }, + because: "1 source item was emitted", + config: options => options.WithStrictOrdering()); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action: New item exceeds the limit + var item6 = new Item() { Id = 6 }; + source.OnNext(new[] { item6 }); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(3).Count().Should().Be(1, "1 source operation was performed"); + results.RecordedItems.Should().BeEquivalentTo(new[] { item2, item3, item4, item5, item6 }, + because: "1 source item was emitted, and 1 was evicted", + config: options => options.WithStrictOrdering()); + results.HasCompleted.Should().BeFalse("the source has not completed"); + } + + [Theory] + [InlineData(SourceType.Asynchronous)] + [InlineData(SourceType.Immediate)] + public void SourceCompletesWhenExpirationsArePending_CompletionWaitsForExpirations(SourceType sourceType) + { + // Setup + var items = new[] + { + new Item() { Id = 1, Lifetime = TimeSpan.FromSeconds(10) }, + new Item() { Id = 2 }, + new Item() { Id = 3, Lifetime = TimeSpan.FromSeconds(20) } + }; + + var source = sourceType switch + { + SourceType.Asynchronous => new Subject>(), + SourceType.Immediate => Observable.Return>(items), + _ => throw new ArgumentOutOfRangeException(nameof(sourceType)) + }; + + var scheduler = new TestScheduler(); + + + // UUT Initialization & Action + using var subscription = source + .ToObservableChangeSet( + expireAfter: Item.SelectLifetime, + scheduler: scheduler) + .ValidateSynchronization() + .ValidateChangeSets() + .RecordListItems(out var results); + + if (source is Subject> subject) + { + subject.OnNext(items); + subject.OnCompleted(); + } + + results.Error.Should().BeNull(); + if (sourceType is SourceType.Asynchronous) + results.RecordedChangeSets.Count.Should().Be(2, "1 sequence was emitted, after initialization"); + else + results.RecordedChangeSets.Count.Should().Be(1, "an initial changeset should always be emitted"); + results.RecordedItems.Should().BeEquivalentTo(items, + because: "3 items were emitted", + config: options => options.WithStrictOrdering()); + results.HasCompleted.Should().BeFalse("2 items have yet to expire"); + + + // UUT Action + scheduler.AdvanceTo(TimeSpan.FromSeconds(30).Ticks); + + results.Error.Should().BeNull(); + if (sourceType is SourceType.Asynchronous) + results.RecordedChangeSets.Skip(2).Count().Should().Be(2, "2 items should have expired, at different times"); + else + results.RecordedChangeSets.Skip(1).Count().Should().Be(2, "2 items should have expired, at different times"); + results.RecordedItems.Should().BeEquivalentTo(items.Where(static item => item.Lifetime is null), + because: "all expirable items have expired", + config: options => options.WithStrictOrdering()); + results.HasCompleted.Should().BeTrue("the source, and all outstanding expirations, have completed"); + } + + [Theory] + [InlineData(SourceType.Asynchronous)] + [InlineData(SourceType.Immediate)] + public void SourceCompletesWhenNoExpirationsArePending_CompletionPropagates(SourceType sourceType) + { + // Setup + var items = new[] + { + new Item() { Id = 1 }, + new Item() { Id = 2 }, + new Item() { Id = 3 } + }; + + var source = sourceType switch + { + SourceType.Asynchronous => new Subject>(), + SourceType.Immediate => Observable.Return>(items), + _ => throw new ArgumentOutOfRangeException(nameof(sourceType)) + }; + + var scheduler = new TestScheduler(); + + + // UUT Initialization & Action + using var subscription = source + .ToObservableChangeSet( + expireAfter: Item.SelectLifetime, + scheduler: scheduler) + .ValidateSynchronization() + .ValidateChangeSets() + .RecordListItems(out var results); + + if (source is Subject> subject) + { + subject.OnNext(items); + subject.OnCompleted(); + } + + results.Error.Should().BeNull(); + if (sourceType is SourceType.Asynchronous) + results.RecordedChangeSets.Count.Should().Be(2, "1 sequence was emitted, after initialization"); + else + results.RecordedChangeSets.Count.Should().Be(1, "an initial changeset should always be emitted"); + results.RecordedItems.Should().BeEquivalentTo(items, + because: "3 items were emitted", + config: options => options.WithStrictOrdering()); + results.HasCompleted.Should().BeTrue("the source has completed, and no items remain to be expired"); + } + + [Fact] + public void SourceEmitsItems_ItemsAreAddedAndRemovedWhenExpired() + { + // Setup + using var source = new Subject>(); + + var scheduler = new TestScheduler(); + + + // UUT Initialization + using var subscription = source + .ToObservableChangeSet( + expireAfter: Item.SelectLifetime, + scheduler: scheduler) + .ValidateSynchronization() + .ValidateChangeSets() + .RecordListItems(out var results); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "an initial changeset should always be emitted"); + results.RecordedItems.Should().BeEmpty("no source items have been emitted"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + var item1 = new Item() { Id = 1, Lifetime = TimeSpan.FromSeconds(3) }; + var item2 = new Item() { Id = 2 }; + var item3 = new Item() { Id = 3, Lifetime = TimeSpan.FromSeconds(1) }; + source.OnNext(new[] { item1, item2, item3 }); + scheduler.AdvanceBy(1); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "1 sequence was emitted"); + results.RecordedItems.Should().BeEquivalentTo(new[] { item1, item2, item3 }, + because: "3 items were emitted", + config: options => options.WithStrictOrdering()); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + scheduler.AdvanceTo(TimeSpan.FromSeconds(1).Ticks); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(2).Count().Should().Be(1, "1 expiration should have occurred"); + results.RecordedItems.Should().BeEquivalentTo(new[] { item1, item2 }, + because: "1 item expired, and 1 had its lifetime extended", + config: options => options.WithStrictOrdering()); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + scheduler.AdvanceTo(TimeSpan.FromSeconds(2).Ticks); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(3).Should().BeEmpty("no expirations should have occurred"); + results.RecordedItems.Should().BeEquivalentTo(new[] { item1, item2 }, + because: "no changes were made", + config: options => options.WithStrictOrdering()); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + scheduler.AdvanceTo(TimeSpan.FromSeconds(3).Ticks); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(3).Count().Should().Be(1, "1 expiration should have occurred"); + results.RecordedItems.Should().BeEquivalentTo(new[] { item2 }, + because: "1 item reached its expiration", + config: options => options.WithStrictOrdering()); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + scheduler.AdvanceTo(TimeSpan.FromSeconds(4).Ticks); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(4).Should().BeEmpty("no expirations should have occurred"); + results.RecordedItems.Should().BeEquivalentTo(new[] { item2 }, + because: "no changes were made", + config: options => options.WithStrictOrdering()); + results.HasCompleted.Should().BeFalse("the source has not completed"); + } + + [Theory] + [InlineData(SourceType.Asynchronous)] + [InlineData(SourceType.Immediate)] + public void SourceFails_ErrorPropagates(SourceType sourceType) + { + // Setup + var error = new Exception("Test Exception"); + + var source = sourceType switch + { + SourceType.Asynchronous => new Subject>(), + SourceType.Immediate => Observable.Throw>(error), + _ => throw new ArgumentOutOfRangeException(nameof(sourceType)) + }; + + + // UUT Initialization & Action + using var subscription = source + .ToObservableChangeSet() + .ValidateSynchronization() + .ValidateChangeSets() + .RecordListItems(out var results); + + if (source is Subject> subject) + subject.OnError(error); + + results.Error.Should().BeSameAs(error, "errors should propagate"); + if (sourceType is SourceType.Asynchronous) + results.RecordedChangeSets.Count.Should().Be(1, "an initial changeset should always be emitted"); + else + results.RecordedChangeSets.Should().BeEmpty("an error occurred during initialization"); + results.RecordedItems.Should().BeEmpty("no source items were emitted"); + } + + [Fact] + public void SourceIsNull_ThrowsException() + => FluentActions.Invoking(() => ObservableListEx.ToObservableChangeSet( + source: (null as IObservable>)!)) + .Should().Throw(); + } + } +} diff --git a/src/DynamicData.Tests/List/ToObservableChangeSetFixture.cs b/src/DynamicData.Tests/List/ToObservableChangeSetFixture.cs index 727c0b4b7..e42998264 100644 --- a/src/DynamicData.Tests/List/ToObservableChangeSetFixture.cs +++ b/src/DynamicData.Tests/List/ToObservableChangeSetFixture.cs @@ -1,375 +1,39 @@ using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Reactive.Concurrency; -using System.Reactive.Disposables; -using System.Reactive.Linq; -using System.Reactive.Subjects; - -using FluentAssertions; - -using Microsoft.Reactive.Testing; - -using Xunit; namespace DynamicData.Tests.List; -public class ToObservableChangeSetFixture - : ReactiveTest +public static partial class ToObservableChangeSetFixture { - [Fact] - public void ExpirationIsGiven_RemovalIsScheduled() - { - using var source = new Subject>(); - - var scheduler = new TestScheduler(); - - using var results = new ChangeSetAggregator(source - .ToObservableChangeSet( - expireAfter: static item => item.Lifetime, - scheduler: scheduler)); - - var item1 = new Item() { Id = 1, Lifetime = TimeSpan.FromMilliseconds(10) }; - var item2 = new Item() { Id = 2, Lifetime = TimeSpan.FromMilliseconds(20) }; - var item3 = new Item() { Id = 3, Lifetime = TimeSpan.FromMilliseconds(30) }; - source.OnNext(new[] { item1, item2, item3 }); - scheduler.AdvanceBy(1); - - // Item removals should batch to the closest prior millisecond. - // This actually seems wrong to me, that for items to be removed earlier than asked for. - // Should this maybe batch to the closest future millisecond, or just round to the nearest? - var item4 = new Item() { Id = 4, Lifetime = TimeSpan.FromMilliseconds(20.1) }; - var item5 = new Item() { Id = 5, Lifetime = TimeSpan.FromMilliseconds(20.9) }; - source.OnNext(new[] { item4, item5 }); - scheduler.AdvanceBy(1); - - // Out-of-order removal - var item6 = new Item() { Id = 6, Lifetime = TimeSpan.FromMilliseconds(15) }; - source.OnNext(new[] { item6 }); - scheduler.AdvanceBy(1); - - // Non-expiring item - var item7 = new Item() { Id = 7 }; - source.OnNext(new[] { item7 }); - scheduler.AdvanceBy(1); - - // Verify initial state, after all emissions - results.Exception.Should().BeNull(); - results.Messages.Count.Should().Be(4, "4 item sets were emitted"); - results.Data.Items.Should().BeEquivalentTo(new[] { item1, item2, item3, item4, item5, item6, item7 }, "7 items were emitted"); - - scheduler.AdvanceTo(TimeSpan.FromMilliseconds(10).Ticks); - - results.Exception.Should().BeNull(); - results.Messages.Count.Should().Be(5, "1 expiration should have occurred, since the last check"); - results.Data.Items.Should().BeEquivalentTo(new[] { item2, item3, item4, item5, item6, item7 }, "item #1 should have expired"); - - scheduler.AdvanceTo(TimeSpan.FromMilliseconds(15).Ticks); - - results.Exception.Should().BeNull(); - results.Messages.Count.Should().Be(6, "1 expiration should have occurred, since the last check"); - results.Data.Items.Should().BeEquivalentTo(new[] { item2, item3, item4, item5, item7 }, "item #6 should have expired"); - - scheduler.AdvanceTo(TimeSpan.FromMilliseconds(20).Ticks); - - results.Exception.Should().BeNull(); - results.Messages.Count.Should().Be(7, "1 expiration should have occurred, since the last check"); - results.Data.Items.Should().BeEquivalentTo(new[] { item3, item7 }, "items #2, #4, and #5 should have expired"); - - scheduler.AdvanceTo(TimeSpan.FromMilliseconds(30).Ticks); - - results.Exception.Should().BeNull(); - results.Messages.Count.Should().Be(8, "1 expiration should have occurred, since the last check"); - results.Data.Items.Should().BeEquivalentTo(new[] { item7 }, "item #3 should have expired"); - } - - [Fact] - public void ItemIsEvictedBeforeExpiration_ExpirationIsCancelled() - { - using var source = new Subject>(); - - var scheduler = new TestScheduler(); - - using var results = new ChangeSetAggregator(source - .ToObservableChangeSet( - expireAfter: static item => item.Lifetime, - limitSizeTo: 3, - scheduler: scheduler)); - - var item1 = new Item() { Id = 1, Lifetime = TimeSpan.FromMilliseconds(10) }; - var item2 = new Item() { Id = 2, Lifetime = TimeSpan.FromMilliseconds(10) }; - var item3 = new Item() { Id = 3, Lifetime = TimeSpan.FromMilliseconds(10) }; - source.OnNext(new[] { item1, item2, item3 }); - scheduler.AdvanceBy(1); - - var item4 = new Item() {Id = 4 }; - source.OnNext(new[] { item4 }); - scheduler.AdvanceBy(1); - - results.Exception.Should().BeNull(); - results.Messages.Count.Should().Be(2, "2 item sets were emitted"); - results.Data.Items.Should().BeEquivalentTo(new[] { item2, item3, item4 }, "the size limit of the collection was 3"); - - scheduler.AdvanceTo(TimeSpan.FromMilliseconds(10).Ticks); - - results.Exception.Should().BeNull(); - results.Messages.Count.Should().Be(3, "2 items should have expired, at the same time, since the last check"); - results.Data.Items.Should().BeEquivalentTo(new[] { item4 }, "2 items should have expired, since the last check"); - } - - [Fact] - public void ItemExpiresBeforeEviction_EvictionIsSkipped() - { - using var source = new Subject>(); - - var scheduler = new TestScheduler(); - - using var results = new ChangeSetAggregator(source - .ToObservableChangeSet( - expireAfter: static item => item.Lifetime, - limitSizeTo: 3, - scheduler: scheduler)); - - var item1 = new Item() { Id = 1, Lifetime = TimeSpan.FromMilliseconds(10) }; - var item2 = new Item() { Id = 2 }; - var item3 = new Item() { Id = 3 }; - source.OnNext(new[] { item1, item2, item3 }); - scheduler.AdvanceBy(1); - - results.Exception.Should().BeNull(); - results.Messages.Count.Should().Be(1, "1 item set was emitted"); - results.Data.Items.Should().BeEquivalentTo(new[] { item1, item2, item3 }, "the size limit of the collection was 3"); - - scheduler.AdvanceTo(TimeSpan.FromMilliseconds(10).Ticks); - - results.Exception.Should().BeNull(); - results.Messages.Count.Should().Be(2, "1 expiration should have occurred, since the last check"); - results.Data.Items.Should().BeEquivalentTo(new[] { item2, item3 }, "item #1 should have expired"); - - var item4 = new Item() { Id = 4 }; - source.OnNext(new[] { item4 }); - scheduler.AdvanceBy(1); - - results.Exception.Should().BeNull(); - results.Messages.Count.Should().Be(3, "1 item set was emitted, since the last check"); - results.Data.Items.Should().BeEquivalentTo(new[] { item2, item3, item4 }, "no eviction should have occurred"); - } - - [Fact] - public void LimitToSizeIs0_ChangeSetsAreEmpty() - { - using var source = new Subject(); - - using var results = new ChangeSetAggregator(source - .ToObservableChangeSet( - limitSizeTo: 0)); - - var item1 = new Item() { Id = 1 }; - source.OnNext(item1); - - var item2 = new Item() { Id = 2 }; - source.OnNext(item2); - - var item3 = new Item() { Id = 3 }; - source.OnNext(item3); - - results.Exception.Should().BeNull(); - results.Messages.Count.Should().Be(3, "3 items were emitted"); - results.Data.Items.Should().BeEmpty("the size limit of the collection was 0"); - } - - [Fact] - public void RemovalsArePending_CompletionWaitsForRemovals() - { - using var source = new Subject>(); - - var scheduler = new TestScheduler(); - - using var results = new ChangeSetAggregator(source - .ToObservableChangeSet( - expireAfter: static item => item.Lifetime, - scheduler: scheduler)); - - var item1 = new Item() { Id = 1, Lifetime = TimeSpan.FromMilliseconds(10) }; - var item2 = new Item() { Id = 2 }; - var item3 = new Item() { Id = 3, Lifetime = TimeSpan.FromMilliseconds(30) }; - source.OnNext(new[] { item1, item2, item3 }); - scheduler.AdvanceBy(1); - - source.OnCompleted(); - - results.Exception.Should().BeNull(); - results.IsCompleted.Should().BeFalse("item removals have been scheduled, and not completed"); - results.Messages.Count.Should().Be(1, "1 item set was emitted"); - results.Data.Items.Should().BeEquivalentTo(new[] { item1, item2, item3 }, "3 items were emitted"); - - scheduler.AdvanceTo(TimeSpan.FromMilliseconds(30).Ticks); - - results.Exception.Should().BeNull(); - results.IsCompleted.Should().BeTrue("the source has completed, and no outstanding expirations remain"); - results.Messages.Count.Should().Be(3, "2 expirations should have occurred, since the last check"); - results.Data.Items.Should().BeEquivalentTo(new[] { item2 }, "3 items were emitted, and 2 should have expired"); - } - - [Fact] - public void SourceCompletesImmediately_SubscriptionCompletes() - { - var item = new Item(); - - var source = Observable.Create(observer => - { - observer.OnNext(item); - observer.OnCompleted(); - return Disposable.Empty; - }); - - using var results = new ChangeSetAggregator(source - .ToObservableChangeSet()); - - results.Exception.Should().BeNull(); - results.IsCompleted.Should().BeTrue("the source has completed, and no outstanding expirations remain"); - results.Messages.Count.Should().Be(1, "1 item was emitted"); - results.Data.Items.Should().BeEquivalentTo(new[] { item }, "1 item was emitted"); - } - - [Fact] - public void SizeLimitIsExceeded_OldestItemsAreRemoved() - { - using var source = new Subject>(); - - using var results = new ChangeSetAggregator(source - .ToObservableChangeSet(limitSizeTo: 4)); - - // Populate enough initial items so that at least one item at the end never gets evicted - var item1 = new Item() { Id = 1 }; - var item2 = new Item() { Id = 2 }; - var item3 = new Item() { Id = 3 }; - source.OnNext(new[] { item1, item2, item3 }); - - // Limit is reached - var item4 = new Item() { Id = 4 }; - source.OnNext(new[] { item4 }); - - // New item exceeds the limit - var item5 = new Item() { Id = 5 }; - source.OnNext(new[] { item5 }); - - // Multiple items exceed the limit - var item6 = new Item() { Id = 6 }; - var item7 = new Item() { Id = 7 }; - source.OnNext(new[] { item6, item7 }); + public const int IntegrationTestItemCount + #if RELEASE + = 1_000; + #else + = 100; + #endif - results.Exception.Should().BeNull(); - results.Messages.Count.Should().Be(4, "4 item sets were emitted by the source"); - results.Data.Items.Should().BeEquivalentTo(new[] { item4, item5, item6, item7 }, "the size limit of the collection was 4"); - } - - [Fact] - public void SourceErrorsImmediately_SubscriptionReceivesError() - { - var item = new Item(); - var error = new Exception("Test Exception"); - - var source = Observable.Create(observer => - { - observer.OnNext(item); - observer.OnError(error); - return Disposable.Empty; - }); - - using var results = new ChangeSetAggregator(source - .ToObservableChangeSet()); - - results.Exception.Should().BeSameAs(error); - results.Messages.Count.Should().Be(1, "1 item was emitted, before an error occurred"); - results.Data.Items.Should().BeEquivalentTo(new[] { item }, "1 item was emitted, before an error occurred"); - } - - [Fact] - public void SourceEmitsSingle_ItemIsAdded() + public enum SchedulerType { - using var source = new Subject(); - - using var results = new ChangeSetAggregator(source - .ToObservableChangeSet()); - - var item1 = new Item() { Id = 1 }; - source.OnNext(item1); - - var item2 = new Item() { Id = 2 }; - source.OnNext(item2); - - var item3 = new Item() { Id = 3 }; - source.OnNext(item3); - - results.Exception.Should().BeNull(); - results.Messages.Count.Should().Be(3, "3 items were emitted by the source"); - results.Data.Items.Should().BeEquivalentTo(new[] { item1, item2, item3 }, - config: options => options.WithStrictOrdering(), - because: "3 items were emitted"); + Default, + TaskPool, + ThreadPool, + NewThread } - [Fact] - public void SourceEmitsMany_ItemsAreAddedOrUpdated() + public enum SourceType { - using var source = new Subject>(); - - using var results = new ChangeSetAggregator(source - .ToObservableChangeSet()); - - var item1 = new Item() { Id = 1 }; - var item2 = new Item() { Id = 2 }; - source.OnNext(new[] { item1, item2 }); - - var item3 = new Item() { Id = 3 }; - var item4 = new Item() { Id = 4 }; - source.OnNext(new[] { item3, item4 }); - - results.Exception.Should().BeNull(); - results.Messages.Count.Should().Be(2, "2 item sets were emitted by the source"); - results.Data.Items.Should().BeEquivalentTo(new[] { item1, item2, item3, item4 }, "4 items were emitted"); + Immediate, + Asynchronous } - [Fact] - public void SourceIsNull_ThrowsException() - => FluentActions.Invoking(() => ObservableListEx.ToObservableChangeSet(source: null!)) - .Should().Throw(); - - [Fact] - public void ThreadPoolSchedulerIsUsed_ExpirationIsThreadSafe() + public record Item { - var testDuration = TimeSpan.FromSeconds(1); - var maxItemLifetime = TimeSpan.FromMilliseconds(500); + public static TimeSpan? SelectLifetime(Item item) + => item.Lifetime; - using var source = new Subject(); + public int Id { get; init; } - using var results = new ChangeSetAggregator(source - .ToObservableChangeSet( - expireAfter: static item => item.Lifetime, - limitSizeTo: 1000, - scheduler: ThreadPoolScheduler.Instance)); - - var nextItemId = 1; - var rng = new Random(Seed: 1234567); - - var stopwatch = new Stopwatch(); - stopwatch.Start(); - while (stopwatch.Elapsed < testDuration) - { - source.OnNext(new() - { - Id = nextItemId++, - Lifetime = TimeSpan.FromMilliseconds(rng.Next(maxItemLifetime.Milliseconds + 1)) - }); - } - - results.Exception.Should().BeNull(); - } - - public class Item - { - public int Id { get; set; } + public Exception? Error { get; init; } - public TimeSpan? Lifetime { get; set; } + public TimeSpan? Lifetime { get; init; } } } diff --git a/src/DynamicData.Tests/Utilities/CacheItemRecordingObserverAssertions.cs b/src/DynamicData.Tests/Utilities/CacheItemRecordingObserverAssertions.cs new file mode 100644 index 000000000..6dfbfa047 --- /dev/null +++ b/src/DynamicData.Tests/Utilities/CacheItemRecordingObserverAssertions.cs @@ -0,0 +1,24 @@ +using FluentAssertions; + +namespace DynamicData.Tests.Utilities; + +public static class CacheItemRecordingObserverAssertions +{ + public static void ShouldNotSupportSorting( + this CacheItemRecordingObserver results, + string because = "") + where TObject : notnull + where TKey : notnull + { + results.RecordedChangeSets.Should().AllSatisfy(changeSet => + { + if (changeSet.Count is not 0) + changeSet.Should().AllSatisfy(change => + { + change.CurrentIndex.Should().Be(-1, because); + change.PreviousIndex.Should().Be(-1, because); + }); + }); + results.RecordedItemsSorted.Should().BeEmpty(because); + } +} diff --git a/src/DynamicData.Tests/Utilities/RecordingObserverBase.cs b/src/DynamicData.Tests/Utilities/RecordingObserverBase.cs index 09718e7fe..4c7ce3a0e 100644 --- a/src/DynamicData.Tests/Utilities/RecordingObserverBase.cs +++ b/src/DynamicData.Tests/Utilities/RecordingObserverBase.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Reactive.Concurrency; using System.Reactive; -using System.Threading; using System.Threading.Tasks; using Microsoft.Reactive.Testing; @@ -13,7 +12,7 @@ namespace DynamicData.Tests.Utilities; public abstract class RecordingObserverBase : IObserver { - private readonly TaskCompletionSource _finalizationSource; + private readonly TaskCompletionSource _whenFinalizedSource; private readonly List>> _notifications; private readonly IScheduler _scheduler; @@ -22,7 +21,7 @@ public abstract class RecordingObserverBase protected RecordingObserverBase(IScheduler scheduler) { - _finalizationSource = new(); + _whenFinalizedSource = new(); _notifications = new(); _scheduler = scheduler; } @@ -39,14 +38,8 @@ public bool HasFinalized public IReadOnlyList>> Notifications => _notifications; - public async Task WaitForFinalizationAsync(TimeSpan timeout) - { - using var timeoutSource = new CancellationTokenSource(timeout); - - await await Task.WhenAny( - _finalizationSource.Task, - Task.Delay(Timeout.Infinite, timeoutSource.Token)); - } + public Task WhenFinalized + => _whenFinalizedSource.Task; protected abstract void OnNext(T value); @@ -57,7 +50,7 @@ void IObserver.OnCompleted() value: Notification.CreateOnCompleted())); _hasCompleted = true; - _finalizationSource.SetResult(); + _whenFinalizedSource.SetResult(); } void IObserver.OnError(Exception error) @@ -68,7 +61,7 @@ void IObserver.OnError(Exception error) if (!HasFinalized) _error = error; - _finalizationSource.SetResult(); + _whenFinalizedSource.SetResult(); } void IObserver.OnNext(T value) diff --git a/src/DynamicData/Cache/Internal/ToObservableChangeSet.cs b/src/DynamicData/Cache/Internal/ToObservableChangeSet.cs index 21b197c88..4c0aa7eb8 100644 --- a/src/DynamicData/Cache/Internal/ToObservableChangeSet.cs +++ b/src/DynamicData/Cache/Internal/ToObservableChangeSet.cs @@ -2,456 +2,322 @@ // Roland Pheasant licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Reactive; using System.Reactive.Concurrency; using System.Reactive.Disposables; using System.Reactive.Linq; +using DynamicData.Internal; + namespace DynamicData.Cache.Internal; -internal sealed class ToObservableChangeSet +internal static class ToObservableChangeSet where TObject : notnull where TKey : notnull { - private readonly Func? _expireAfter; - private readonly Func _keySelector; - private readonly int _limitSizeTo; - private readonly IScheduler _scheduler; - private readonly IObservable> _source; - - public ToObservableChangeSet( - IObservable source, - Func keySelector, - Func? expireAfter, - int limitSizeTo, - IScheduler? scheduler) - { - _expireAfter = expireAfter; - _keySelector = keySelector; - _limitSizeTo = limitSizeTo; - _scheduler = scheduler ?? GlobalConfig.DefaultScheduler; - - _source = Observable.Create>(observer => + public static IObservable> Create( + IObservable source, + Func keySelector, + Func? expireAfter, + int limitSizeTo, + IScheduler? scheduler) + => Observable.Create>(downstreamObserver => { - // Reusable buffer, to avoid allocating per-item var buffer = new TObject[1]; - return source.SubscribeSafe(Observer.Create( - onNext: item => - { - buffer[0] = item; - observer.OnNext(buffer); - }, - onError: observer.OnError, - onCompleted: observer.OnCompleted)); + return Create( + source: source + .Select(item => + { + buffer[0] = item; + return buffer; + }), + keySelector: keySelector, + expireAfter: expireAfter, + limitSizeTo: limitSizeTo, + scheduler: scheduler) + .SubscribeSafe(downstreamObserver); }); - } - - public ToObservableChangeSet( - IObservable> source, - Func keySelector, - Func? expireAfter, - int limitSizeTo, - IScheduler? scheduler) - { - _expireAfter = expireAfter; - _keySelector = keySelector; - _limitSizeTo = limitSizeTo; - _scheduler = scheduler ?? GlobalConfig.DefaultScheduler; - _source = source; - } - public IObservable> Run() - => Observable.Create>(observer => new Subscription( - source: _source, - expireAfter: _expireAfter, - keySelector: _keySelector, - limitSizeTo: _limitSizeTo, - observer: observer, - scheduler: _scheduler)); + public static IObservable> Create( + IObservable> source, + Func keySelector, + Func? expireAfter, + int limitSizeTo, + IScheduler? scheduler) + => Observable.Create>(downstreamObserver => new Subscription( + downstreamObserver: downstreamObserver, + expireAfter: expireAfter, + keySelector: keySelector, + limitSizeTo: limitSizeTo, + scheduler: scheduler, + source: source)); private sealed class Subscription : IDisposable { - private readonly EvictionState? _evictionState; - private readonly ExpirationState? _expirationState; - private readonly Dictionary _itemStatesByKey; + private readonly ChangeAwareCache _downstreamItems; + private readonly IObserver> _downstreamObserver; + private readonly Queue _evictionQueue; + private readonly List _expirationQueue; + private readonly Func? _expireAfter; + private readonly Dictionary _expireAtsByKey; private readonly Func _keySelector; - private readonly IObserver> _observer; + private readonly int _limitSizeTo; private readonly IScheduler _scheduler; private readonly IDisposable _sourceSubscription; + #if NET9_0_OR_GREATER + private readonly Lock _synchronizationGate; + #else + private readonly object _synchronizationGate; + #endif + private bool _hasInitialized; private bool _hasSourceCompleted; private ScheduledExpiration? _scheduledExpiration; public Subscription( - IObservable> source, + IObserver> downstreamObserver, Func? expireAfter, Func keySelector, int limitSizeTo, - IObserver> observer, - IScheduler scheduler) + IScheduler? scheduler, + IObservable> source) { + _downstreamItems = new(); + _downstreamObserver = downstreamObserver; + _evictionQueue = new(); + _expirationQueue = new(); + _expireAfter = expireAfter; + _expireAtsByKey = new(); _keySelector = keySelector; - _observer = observer; - _scheduler = scheduler; - - if (limitSizeTo >= 0) + _limitSizeTo = limitSizeTo; + _scheduler = scheduler ?? GlobalConfig.DefaultScheduler; + _synchronizationGate = new(); + + _sourceSubscription = source.SubscribeSafe( + onNext: OnSourceNext, + onError: downstreamObserver.OnError, + onCompleted: OnSourceCompleted); + + // Make sure we always publish an initial changeset, if subscribing to the source didn't generate one. + // Also make sure we never complete, before the initial changeset. + lock (_synchronizationGate) { - _evictionState = new() - { - LimitSizeTo = limitSizeTo, - Queue = new(capacity: limitSizeTo) - }; + TryPublishDownstreamChanges(); + TryPublishCompletion(); + } + } - _expirationState = (expireAfter is null) - ? null - : new() - { - ChangesBuffer = new(), - ExpireAfter = expireAfter, - Queue = new(capacity: limitSizeTo) - }; + public void Dispose() + { + _sourceSubscription.Dispose(); + _scheduledExpiration?.Cancellation.Dispose(); + } - _itemStatesByKey = new(capacity: limitSizeTo); - } - else + private IDisposable OnScheduledExpirationInvoked( + IScheduler scheduler, + Expiration intendedExpiration) + { + try { - _expirationState = (expireAfter is null) - ? null - : new() - { - ChangesBuffer = new(), - ExpireAfter = expireAfter, - Queue = new() - }; + ScheduledExpiration? unfinishedExpiration; - _itemStatesByKey = []; - } + lock (_synchronizationGate) + { + // There is no longer an expiration scheduled, we're in it + _scheduledExpiration = null; - _sourceSubscription = source - .Synchronize(SynchronizationGate) - .SubscribeSafe(Observer.Create>( - onNext: items => + // Scan the queue of expirations to identify all of them that are due. + var processedExpirationCount = 0; + foreach (var expiration in _expirationQueue) { - try - { - var now = _scheduler.Now; + // Inaccuracies in real-world scheduler timers make it possible for an item to be invoked slightly before its due time, + // so we're going to expire based on the timestamp of the "intended" expiration that this was scheduled for. + if (expiration.ExpireAt > intendedExpiration.ExpireAt) + break; - var hasExpirationQueueChanged = false; + ++processedExpirationCount; - var itemCount = items switch - { - ICollection itemsCollection => itemsCollection.Count, - IReadOnlyCollection itemsCollection => itemsCollection.Count, - _ => 0 - }; - - var changeSet = new ChangeSet(capacity: (_evictionState is { } evictionState) - ? Math.Max(itemCount + evictionState.Queue.Count - evictionState.LimitSizeTo, 0) - : itemCount); - - if (items is IReadOnlyList itemsList) - { - for (var i = 0; i < itemsList.Count; ++i) - { - HandleIncomingItem(itemsList[i], now, changeSet, ref hasExpirationQueueChanged); - } - } - else - { - foreach (var item in items) - { - HandleIncomingItem(item, now, changeSet, ref hasExpirationQueueChanged); - } - } - - if (hasExpirationQueueChanged) - { - OnExpirationQueueChanged(); - } - - observer.OnNext(changeSet); - } - catch (Exception error) + // The queue is not guaranteed to be up-to-date compared to _downstreamItems or _expireAtsByKey, + // so before we remove an item, make sure it still needs to be removed. + if (_expireAtsByKey.TryGetValue(expiration.Key, out var expireAt) + && (expireAt <= intendedExpiration.ExpireAt)) { - TearDownStates(); - - observer.OnError(error); + _downstreamItems.Remove(expiration.Key); + _expireAtsByKey.Remove(expiration.Key); } - }, - onError: error => - { - TearDownStates(); + } - observer.OnError(error); - }, - onCompleted: () => - { - _hasSourceCompleted = true; + // Since the queue is a List we can slightly-optimize by removing all the processed ones in one go. + _expirationQueue.RemoveRange(0, processedExpirationCount); - // If there are pending expirations scheduled, wait to complete the stream until they're done - if (_expirationState is null or { Queue.Count: 0 }) - { - observer.OnCompleted(); - } - })); - } + unfinishedExpiration = TryBeginSchedulingExpiration(); - // Instead of using a dedicated _synchronizationGate object, we can save an allocation by using any object that is never exposed to consumers. - private object SynchronizationGate - => _itemStatesByKey; + TryPublishDownstreamChanges(); + TryPublishCompletion(); + } - public void Dispose() - { - lock (SynchronizationGate) + if (unfinishedExpiration is ScheduledExpiration unfinishedExpirationValue) + FinishSchedulingExpiration(unfinishedExpirationValue, _scheduler); + } + catch (Exception error) { - _sourceSubscription.Dispose(); - - TearDownStates(); + _downstreamObserver.OnError(error); + _scheduledExpiration?.Cancellation.Dispose(); } - } - private static int CompareExpireAtToExpiration(DateTimeOffset expireAt, Expiration expiration) - => expireAt.CompareTo(expiration.ExpireAt); + return Disposable.Empty; + } - private void HandleIncomingItem( - TObject item, - DateTimeOffset now, - ChangeSet changeSet, - ref bool hasExpirationQueueChanged) + private void OnSourceNext(IEnumerable upstreamItems) { - var key = _keySelector.Invoke(item); - var previousItemState = _itemStatesByKey.TryGetValue(key, out var existingItemState) - ? existingItemState - : null as ItemState?; - - // Perform processing for eviction behavior, if applicable - if (_evictionState is { } evictionState) + try { - // Backwards compatibility - if (evictionState.LimitSizeTo is 0) - { - return; - } + var unfinishedExpiration = null as ScheduledExpiration?; - // Eviction is only applicable to adds, not replacements - if (previousItemState is null) + lock (_synchronizationGate) { - // If our size limit has been reached, evict the oldest item before adding a new one. - // Repeat removals until we drop below the limit, since items in the queue might have already expired. - while (_itemStatesByKey.Count >= evictionState.LimitSizeTo) + var now = _scheduler.Now; + + foreach (var item in upstreamItems) { - var keyToEvict = evictionState.Queue.Dequeue(); + var key = _keySelector.Invoke(item); - if (_itemStatesByKey.TryGetValue(keyToEvict, out var itemStateToEvict)) + if ((_limitSizeTo >= 0) && !_downstreamItems.Lookup(key).HasValue) { - _itemStatesByKey.Remove(keyToEvict); - changeSet.Add(new( - reason: ChangeReason.Remove, - key: keyToEvict, - current: itemStateToEvict.Item)); - } - } + while (_downstreamItems.Count >= _limitSizeTo) + _downstreamItems.Remove(_evictionQueue.Dequeue()); - evictionState.Queue.Enqueue(key); - } - } - - // Perform processing for expiration behavior, if applicable - var expireAt = null as DateTimeOffset?; - if (_expirationState is { } expirationState) - { - var previousExpireAt = previousItemState?.ExpireAt; - var expireAfter = expirationState.ExpireAfter.Invoke(item); - if (expireAfter is { } resolvedExpireAfter) - { - // Truncate to milliseconds to promote batching expirations together. - var expireAtTicks = now.UtcTicks + resolvedExpireAfter.Ticks; - expireAt = new DateTimeOffset(ticks: expireAtTicks - (expireAtTicks % TimeSpan.TicksPerMillisecond), offset: TimeSpan.Zero); - } + _evictionQueue.Enqueue(key); + } - // Queue the item for expiration if it's new and needs to expire, or if it's a replacement with a different expiration time. - if ((expireAt is not null) && (expireAt != previousExpireAt)) - { - var insertionIndex = expirationState.Queue.BinarySearch(expireAt.Value, CompareExpireAtToExpiration); - if (insertionIndex < 0) - { - insertionIndex = ~insertionIndex; - } + _downstreamItems.AddOrUpdate(item, key); - expirationState.Queue.Insert( - index: insertionIndex, - item: new() + var lifetime = _expireAfter?.Invoke(item); + if (lifetime is TimeSpan lifetimeValue) { - ExpireAt = expireAt.Value, - Key = key - }); + var expireAtTicks = (now + Scheduler.Normalize(lifetimeValue)).UtcTicks; + var expireAt = new DateTimeOffset(ticks: expireAtTicks - (expireAtTicks % TimeSpan.TicksPerMillisecond), offset: TimeSpan.Zero); - hasExpirationQueueChanged = true; - } - } + _expireAtsByKey[key] = expireAt; - // Track the item's state, to be able to detect replacements later, and issue either an add or replace change for it. - _itemStatesByKey[key] = new() - { - ExpireAt = expireAt, - Item = item - }; - changeSet.Add((previousItemState is null) - ? new( - reason: ChangeReason.Add, - key: key, - current: item) - : new( - reason: ChangeReason.Update, - key: key, - current: item, - previous: previousItemState.Value.Item)); - } - - private void HandleScheduledExpiration() - { - var expirationState = _expirationState!.Value; + var expiration = new Expiration() + { + ExpireAt = expireAt, + Key = key + }; - var now = _scheduler.Now; + var insertionIndex = _expirationQueue.BinarySearch(expiration); + if (insertionIndex < 0) + insertionIndex = ~insertionIndex; - // Buffer removals, so we can optimize the allocation for the final changeset, or skip it entirely. - // Also, so we can optimize removal from the queue as a range removal. - var processedExpirationCount = 0; - foreach (var expiration in expirationState.Queue) - { - if (expiration.ExpireAt > now) - { - break; - } + _expirationQueue.Insert( + index: insertionIndex, + item: expiration); + } + else + { + _expireAtsByKey.Remove(key); + } + } - ++processedExpirationCount; + unfinishedExpiration = TryBeginSchedulingExpiration(); - // If the item hasn't already been evicted, or had its expiration time change, formally remove it - if (_itemStatesByKey.TryGetValue(expiration.Key, out var itemState) && (itemState.ExpireAt <= now)) - { - _itemStatesByKey.Remove(expiration.Key); - expirationState.ChangesBuffer.Add(new( - reason: ChangeReason.Remove, - key: expiration.Key, - current: itemState.Item)); + TryPublishDownstreamChanges(); } - } - - expirationState.Queue.RemoveRange(0, processedExpirationCount); - // We can end up with no changes here for a couple of reasons: - // * An item's expiration time can change - // * When items are evicted due to the size limit, it still remains in the expiration queue. - // * The scheduler only promises "best effort" to cancel scheduled operations. - if (expirationState.ChangesBuffer.Count is not 0) + if (unfinishedExpiration is ScheduledExpiration unfinishedExpirationValue) + FinishSchedulingExpiration(unfinishedExpirationValue, _scheduler); + } + catch (Exception error) { - _observer.OnNext(new ChangeSet(expirationState.ChangesBuffer)); - - expirationState.ChangesBuffer.Clear(); + _downstreamObserver.OnError(error); + _scheduledExpiration?.Cancellation.Dispose(); } - - OnExpirationQueueChanged(); } - private void OnExpirationQueueChanged() + private void OnSourceCompleted() { - var expirationState = _expirationState!.Value; - - // If there aren't any items queued to expire, check to see if the stream should be terminated (I.E. we just expired the last item). - // Otherwise, make sure we have an operation scheduled to handle the next expiration. - if (expirationState.Queue.Count is 0) - { - if (_hasSourceCompleted) - { - _observer.OnCompleted(); - } - } - else + lock (_synchronizationGate) { - // If there's already a scheduled operation, and it doesn't match the current next-item-to-expire time, wipe it out and re-schedule it. - var nextExpireAt = expirationState.Queue[0].ExpireAt; - if (_scheduledExpiration is { } scheduledExpiration) - { - if (scheduledExpiration.DueTime != nextExpireAt) - { - scheduledExpiration.Cancellation.Dispose(); - _scheduledExpiration = null; - } - else - { - return; - } - } + _hasSourceCompleted = true; - _scheduledExpiration = new() - { - Cancellation = _scheduler.Schedule( - state: this, - dueTime: nextExpireAt, - action: static (_, @this) => - { - lock (@this.SynchronizationGate) - { - @this._scheduledExpiration = null; - - @this.HandleScheduledExpiration(); - } - - return Disposable.Empty; - }), - DueTime = nextExpireAt - }; + TryPublishCompletion(); } } - private void TearDownStates() + // This method must NOT be invoked under the umbrella of _synchronizationGate, + // as some IScheduler implementations perform locking internally, which can result in deadlocking if we invoke the scheduler within our own lock. + // + // Additionally, some IScheduler implementations can invoke actions synchronously, + // so it's important that scheduler invocation is only performed AFTER downstream changes have been processed. + // Otherwise, downstream notifications can end up published out-of-order. + private void FinishSchedulingExpiration( + ScheduledExpiration unfinishedExpiration, + IScheduler scheduler) + => unfinishedExpiration.Cancellation.Disposable = scheduler.Schedule( + state: unfinishedExpiration.Expiration, + dueTime: unfinishedExpiration.Expiration.ExpireAt, + action: OnScheduledExpirationInvoked); + + private void TryPublishCompletion() { - _scheduledExpiration?.Cancellation.Dispose(); - _scheduledExpiration = null; - - _evictionState?.Queue.Clear(); - - _expirationState?.Queue.Clear(); + // There needs to be no possibility of a new changeset being emitted before we can call the stream complete. + if (_hasInitialized && _hasSourceCompleted && (_expirationQueue.Count is 0)) + _downstreamObserver.OnCompleted(); } - private readonly struct ItemState + private void TryPublishDownstreamChanges() { - public required DateTimeOffset? ExpireAt { get; init; } - - public required TObject Item { get; init; } - } - - private readonly struct EvictionState - { - public required int LimitSizeTo { get; init; } - - public required Queue Queue { get; init; } + var downstreamChanges = _downstreamItems.CaptureChanges(); + // Generally, we don't want to emit empty changesets, except if it would be the initial one. + if ((downstreamChanges.Count is not 0) || !_hasInitialized) + { + _downstreamObserver.OnNext(downstreamChanges); + _hasInitialized = true; + } } - private readonly struct Expiration + private ScheduledExpiration? TryBeginSchedulingExpiration() { - public required DateTimeOffset ExpireAt { get; init; } + // If there's no expirations currently queued up, we don't need to schedule anything. + if (_expirationQueue.Count is 0) + return null; + + // If the next expiration in the queue matches what's already scheduled, we don't need to schedule anything. + var nextExpiration = _expirationQueue[0]; + if ((_scheduledExpiration is ScheduledExpiration scheduledExpiration) && (nextExpiration == scheduledExpiration.Expiration)) + return null; + + // If we made it here, we need to schedule a new expiration action. + // We can't actually invoke the scheduler here (underneath our synchronization lock) without risking deadlocks, + // but we do need to start tracking it now, so that said tracking remains synchronized by said lock. + _scheduledExpiration?.Cancellation.Dispose(); + _scheduledExpiration = new() + { + Cancellation = new(), + Expiration = nextExpiration + }; - public required TKey Key { get; init; } + return _scheduledExpiration; } + } - private readonly struct ExpirationState - { - public required List> ChangesBuffer { get; init; } + private readonly struct ScheduledExpiration + { + public required SingleAssignmentDisposable Cancellation { get; init; } - public required Func ExpireAfter { get; init; } + public required Expiration Expiration { get; init; } + } - // Potential performance improvement: Instead of List, use PriorityQueue available in .NET 6+, or an equivalent. - public required List Queue { get; init; } - } + private readonly record struct Expiration + : IComparable + { + public required DateTimeOffset ExpireAt { get; init; } - private readonly struct ScheduledExpiration - { - public required IDisposable Cancellation { get; init; } + public required TKey Key { get; init; } - public required DateTimeOffset DueTime { get; init; } - } + public int CompareTo(Expiration other) + => ExpireAt.CompareTo(other.ExpireAt); } } diff --git a/src/DynamicData/Cache/ObservableCacheEx.cs b/src/DynamicData/Cache/ObservableCacheEx.cs index 308b2dc06..c67c2cf55 100644 --- a/src/DynamicData/Cache/ObservableCacheEx.cs +++ b/src/DynamicData/Cache/ObservableCacheEx.cs @@ -4453,14 +4453,24 @@ public static IObservable> ToCollectionsource /// or /// keySelector. - public static IObservable> ToObservableChangeSet(this IObservable source, Func keySelector, Func? expireAfter = null, int limitSizeTo = -1, IScheduler? scheduler = null) + public static IObservable> ToObservableChangeSet( + this IObservable source, + Func keySelector, + Func? expireAfter = null, + int limitSizeTo = -1, + IScheduler? scheduler = null) where TObject : notnull where TKey : notnull { source.ThrowArgumentNullExceptionIfNull(nameof(source)); keySelector.ThrowArgumentNullExceptionIfNull(nameof(keySelector)); - return new ToObservableChangeSet(source, keySelector, expireAfter, limitSizeTo, scheduler).Run(); + return Cache.Internal.ToObservableChangeSet.Create( + source: source, + keySelector: keySelector, + expireAfter: expireAfter, + limitSizeTo: limitSizeTo, + scheduler: scheduler); } /// @@ -4478,14 +4488,24 @@ public static IObservable> ToObservableChangeSetsource /// or /// keySelector. - public static IObservable> ToObservableChangeSet(this IObservable> source, Func keySelector, Func? expireAfter = null, int limitSizeTo = -1, IScheduler? scheduler = null) + public static IObservable> ToObservableChangeSet( + this IObservable> source, + Func keySelector, + Func? expireAfter = null, + int limitSizeTo = -1, + IScheduler? scheduler = null) where TObject : notnull where TKey : notnull { source.ThrowArgumentNullExceptionIfNull(nameof(source)); keySelector.ThrowArgumentNullExceptionIfNull(nameof(keySelector)); - return new ToObservableChangeSet(source, keySelector, expireAfter, limitSizeTo, scheduler).Run(); + return Cache.Internal.ToObservableChangeSet.Create( + source: source, + keySelector: keySelector, + expireAfter: expireAfter, + limitSizeTo: limitSizeTo, + scheduler: scheduler); } /// diff --git a/src/DynamicData/List/Internal/ToObservableChangeSet.cs b/src/DynamicData/List/Internal/ToObservableChangeSet.cs index 1b8992f41..3f954b885 100644 --- a/src/DynamicData/List/Internal/ToObservableChangeSet.cs +++ b/src/DynamicData/List/Internal/ToObservableChangeSet.cs @@ -2,449 +2,356 @@ // Roland Pheasant licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Reactive; using System.Reactive.Concurrency; using System.Reactive.Disposables; using System.Reactive.Linq; +using DynamicData.Internal; + namespace DynamicData.List.Internal; -internal sealed class ToObservableChangeSet +internal static class ToObservableChangeSet where TObject : notnull { - private readonly Func? _expireAfter; - private readonly int _limitSizeTo; - private readonly IScheduler _scheduler; - private readonly IObservable> _source; - - public ToObservableChangeSet( + public static IObservable> Create( IObservable source, Func? expireAfter, int limitSizeTo, IScheduler? scheduler) { - _expireAfter = expireAfter; - _limitSizeTo = limitSizeTo; - _scheduler = scheduler ?? GlobalConfig.DefaultScheduler; + source.ThrowArgumentNullExceptionIfNull(nameof(source)); - _source = Observable.Create>(observer => + return Observable.Create>(downstreamObserver => { - // Reusable buffer, to avoid allocating per-item var buffer = new TObject[1]; - return source.SubscribeSafe(Observer.Create( - onNext: item => - { - buffer[0] = item; - observer.OnNext(buffer); - }, - onError: observer.OnError, - onCompleted: observer.OnCompleted)); + return Create( + source: source + .Select(item => + { + buffer[0] = item; + return buffer; + }), + expireAfter: expireAfter, + limitSizeTo: limitSizeTo, + scheduler: scheduler) + .SubscribeSafe(downstreamObserver); }); } - public ToObservableChangeSet( + public static IObservable> Create( IObservable> source, Func? expireAfter, int limitSizeTo, IScheduler? scheduler) { - _expireAfter = expireAfter; - _limitSizeTo = limitSizeTo; - _scheduler = scheduler ?? GlobalConfig.DefaultScheduler; - _source = source; + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return Observable.Create>(downstreamObserver => new Subscription( + downstreamObserver: downstreamObserver, + expireAfter: expireAfter, + limitSizeTo: limitSizeTo, + scheduler: scheduler, + source: source)); } - public IObservable> Run() - => Observable.Create>(observer => new Subscription( - source: _source, - expireAfter: _expireAfter, - limitSizeTo: _limitSizeTo, - observer: observer, - scheduler: _scheduler)); - private sealed class Subscription : IDisposable { - private readonly EvictionState? _evictionState; - private readonly ExpirationState? _expirationState; - private readonly IObserver> _observer; + private readonly ChangeAwareList _downstreamItems; + private readonly IObserver> _downstreamObserver; + private readonly Func? _expireAfter; + private readonly List _expirationQueue; + private readonly int _limitSizeTo; private readonly IScheduler _scheduler; private readonly IDisposable _sourceSubscription; + #if NET9_0_OR_GREATER + private readonly Lock _synchronizationGate; + #else private readonly object _synchronizationGate; + #endif - private int _currentItemCount; + private bool _hasInitialized; private bool _hasSourceCompleted; private ScheduledExpiration? _scheduledExpiration; public Subscription( - IObservable> source, + IObserver> downstreamObserver, Func? expireAfter, int limitSizeTo, - IObserver> observer, - IScheduler scheduler) + IScheduler? scheduler, + IObservable> source) { - _observer = observer; - _scheduler = scheduler; + _downstreamItems = new(); + _downstreamObserver = downstreamObserver; + _expirationQueue = new(); + _expireAfter = expireAfter; + _limitSizeTo = limitSizeTo; + _scheduler = scheduler ?? GlobalConfig.DefaultScheduler; + _synchronizationGate = new(); - if (limitSizeTo >= 0) - { - _evictionState = new() - { - LimitSizeTo = limitSizeTo, - Queue = new(capacity: limitSizeTo) - }; + _sourceSubscription = source.SubscribeSafe( + onNext: OnSourceNext, + onError: downstreamObserver.OnError, + onCompleted: OnSourceCompleted); - _expirationState = (expireAfter is null) - ? null - : new() - { - RemovalsBuffer = new(), - ExpireAfter = expireAfter, - Queue = new(capacity: limitSizeTo) - }; - } - else + // Make sure we always publish an initial changeset, if subscribing to the source didn't generate one. + // Also make sure we never complete, before the initial changeset. + lock (_synchronizationGate) { - _expirationState = (expireAfter is null) - ? null - : new() - { - RemovalsBuffer = new(), - ExpireAfter = expireAfter, - Queue = new() - }; + TryPublishDownstreamChanges(); + TryPublishCompletion(); } - - _synchronizationGate = new(); - - _sourceSubscription = source - .Synchronize(_synchronizationGate) - .SubscribeSafe(Observer.Create>( - onNext: items => - { - try - { - var now = _scheduler.Now; - - var hasExpirationQueueChanged = false; - - var itemCount = items switch - { - ICollection itemsCollection => itemsCollection.Count, - IReadOnlyCollection itemsCollection => itemsCollection.Count, - _ => 0 - }; - - var changeSet = new ChangeSet(capacity: (_evictionState is { } evictionState) - ? Math.Max(itemCount + evictionState.Queue.Count - evictionState.LimitSizeTo, 0) - : itemCount); - - if (items is IReadOnlyList itemsList) - { - for (var i = 0; i < itemsList.Count; ++i) - { - HandleIncomingItem(itemsList[i], now, changeSet, ref hasExpirationQueueChanged); - } - } - else - { - foreach (var item in items) - { - HandleIncomingItem(item, now, changeSet, ref hasExpirationQueueChanged); - } - } - - if (hasExpirationQueueChanged) - { - OnExpirationQueueChanged(); - } - - observer.OnNext(changeSet); - } - catch (Exception error) - { - TearDownStates(); - - observer.OnError(error); - } - }, - onError: error => - { - TearDownStates(); - - observer.OnError(error); - }, - onCompleted: () => - { - _hasSourceCompleted = true; - - // If there are pending expirations scheduled, wait to complete the stream until they're done - if (_expirationState is null or { Queue.Count: 0 }) - { - observer.OnCompleted(); - } - })); } public void Dispose() { - lock (_synchronizationGate) - { - _sourceSubscription.Dispose(); - - TearDownStates(); - } + _sourceSubscription.Dispose(); + _scheduledExpiration?.Cancellation.Dispose(); } - private static int CompareExpireAtToExpiration(DateTimeOffset expireAt, Expiration expiration) - => expireAt.CompareTo(expiration.ExpireAt); - - private void HandleIncomingItem( - TObject item, - DateTimeOffset now, - ChangeSet changeSet, - ref bool hasExpirationQueueChanged) + private IDisposable OnScheduledExpirationInvoked( + IScheduler scheduler, + Expiration intendedExpiration) { - // Perform processing for eviction behavior, if applicable - if (_evictionState is { } evictionState) + try { - // Backwards compatibility - if (evictionState.LimitSizeTo is 0) - { - return; - } + ScheduledExpiration? unfinishedExpiration; - // If our size limit has been reached, evict the oldest item before adding a new one. - // Repeat removals until we drop below the limit, since items in the queue might have already expired. - if (evictionState.Queue.Count >= evictionState.LimitSizeTo) + lock (_synchronizationGate) { - var itemToEvict = evictionState.Queue[0]; - evictionState.Queue.RemoveAt(0); + // There is no longer an expiration scheduled, we're in it + _scheduledExpiration = null; - // Need to synchronize the expiration queue, if applicable, to keep the indexes stored there correct. - if (_expirationState is { Queue: var expirationQueue }) + // Scan the queue of expirations to identify all of them that are due. + var processedExpirationCount = 0; + for (var i = 0; i < _expirationQueue.Count; ++i) { - for (var i = 0; i < expirationQueue.Count;) + var expiration = _expirationQueue[i]; + + // Inaccuracies in real-world scheduler timers make it possible for an item to be invoked slightly before its due time, + // so we're going to expire based on the timestamp of the "intended" expiration that this was scheduled for. + if (expiration.ExpireAt > intendedExpiration.ExpireAt) + break; + + ++processedExpirationCount; + + _downstreamItems.RemoveAt(expiration.Index); + + // Adjust indexes for all remaining items in the queue. + for (var j = i + 1; j < _expirationQueue.Count; ++j) { - if (expirationQueue[i].Index == 0) + var futureExpiration = _expirationQueue[j]; + if (futureExpiration.Index > expiration.Index) { - expirationQueue.RemoveAt(i); - continue; + _expirationQueue[j] = futureExpiration with + { + Index = futureExpiration.Index - 1 + }; } - - var expiration = expirationQueue[i]; - --expiration.Index; - expirationQueue[i] = expiration; - - ++i; } } - changeSet.Add(new( - reason: ListChangeReason.Remove, - current: itemToEvict, - index: 0)); - --_currentItemCount; + // Since the queue is a List we can slightly-optimize by removing all the processed ones in one go. + _expirationQueue.RemoveRange(0, processedExpirationCount); + + unfinishedExpiration = TryBeginSchedulingExpiration(); + + TryPublishDownstreamChanges(); + TryPublishCompletion(); } - evictionState.Queue.Add(item); + if (unfinishedExpiration is ScheduledExpiration unfinishedExpirationValue) + FinishSchedulingExpiration(unfinishedExpirationValue, _scheduler); } - - // Perform processing for expiration behavior, if applicable - if (_expirationState is { } expirationState) + catch (Exception error) { - var expireAfter = expirationState.ExpireAfter.Invoke(item); - if (expireAfter is { } resolvedExpireAfter) - { - // Truncate to milliseconds to promote batching expirations together. - var expireAtTicks = now.UtcTicks + resolvedExpireAfter.Ticks; - var expireAt = new DateTimeOffset(ticks: expireAtTicks - (expireAtTicks % TimeSpan.TicksPerMillisecond), offset: TimeSpan.Zero); - - var insertionIndex = expirationState.Queue.BinarySearch(expireAt, CompareExpireAtToExpiration); - if (insertionIndex < 0) - { - insertionIndex = ~insertionIndex; - } - - expirationState.Queue.Insert( - index: insertionIndex, - item: new() - { - ExpireAt = expireAt, - Index = _currentItemCount, - Item = item - }); - - hasExpirationQueueChanged = true; - } + _downstreamObserver.OnError(error); + _scheduledExpiration?.Cancellation.Dispose(); } - changeSet.Add(new( - reason: ListChangeReason.Add, - current: item, - index: _currentItemCount)); - ++_currentItemCount; + return Disposable.Empty; } - private void HandleScheduledExpiration() + private void OnSourceNext(IEnumerable upstreamItems) { - var expirationState = _expirationState!.Value; - - var now = _scheduler.Now; - - // Buffer removals, so we can sort them and generate adjusted indexes, in the event of many items being removed at once. - // Also, so we can optimize away the changeSet allocation and publication, if possible. - // Also, so we can optimize removal from the queue as a range removal. - foreach (var expiration in expirationState.Queue) + try { - if (expiration.ExpireAt > now) + var unfinishedExpiration = null as ScheduledExpiration?; + + lock (_synchronizationGate) { - break; - } + var now = _scheduler.Now; - expirationState.RemovalsBuffer.Add(new( - key: expiration.Index, - value: expiration.Item)); - } + foreach (var item in upstreamItems) + { + if (_limitSizeTo >= 0) + { + while (_downstreamItems.Count >= _limitSizeTo) + _downstreamItems.RemoveAt(0); - // It's theoretically possible to end up with no changes here, - // as the scheduler only promises "best effort" to cancel scheduled operations. - if (expirationState.RemovalsBuffer.Count is not 0) - { - expirationState.Queue.RemoveRange(0, expirationState.RemovalsBuffer.Count); + // Update indexes within the expiration queue, to keep them in-sync with _downstreamItems + for (var i = 0; i < _expirationQueue.Count;) + { + if (_expirationQueue[i].Index == 0) + { + _expirationQueue.RemoveAt(i); + continue; + } - expirationState.RemovalsBuffer.Sort(static (x, y) => x.Key.CompareTo(y.Key)); + var expiration = _expirationQueue[i]; + _expirationQueue[i] = expiration with + { + Index = expiration.Index - 1 + }; - var evictionQueue = _evictionState?.Queue; + ++i; + } - var changeSet = new ChangeSet(capacity: expirationState.RemovalsBuffer.Count); - for (var i = 0; i < expirationState.RemovalsBuffer.Count; ++i) - { - var removal = expirationState.RemovalsBuffer[i]; - var indexToRemove = removal.Key - i; + // Also, update there's an index to be possibly updated within tracking for the next scheduled expiration + if (_scheduledExpiration is ScheduledExpiration scheduledExpiration) + { + // If the next expiration is for the item we just removed, cancel it. + if (scheduledExpiration.Expiration.Index == 0) + { + scheduledExpiration.Cancellation.Dispose(); + _scheduledExpiration = null; + } + // Otherwise, just adjust the index + else + { + _scheduledExpiration = scheduledExpiration with + { + Expiration = scheduledExpiration.Expiration with + { + Index = scheduledExpiration.Expiration.Index - 1 + } + }; + } + } + } - changeSet.Add(new( - reason: ListChangeReason.Remove, - current: removal.Value, - index: indexToRemove)); - --_currentItemCount; + _downstreamItems.Add(item); - // Adjust indexes for all remaining items in the queue. - for (var j = 0; j < expirationState.Queue.Count; ++j) - { - var expiration = expirationState.Queue[j]; - if (expiration.Index > indexToRemove) + var lifetime = _expireAfter?.Invoke(item); + if (lifetime is TimeSpan lifetimeValue) { - --expiration.Index; - } + var expireAtTicks = (now + Scheduler.Normalize(lifetimeValue)).UtcTicks; + var expireAt = new DateTimeOffset(ticks: expireAtTicks - (expireAtTicks % TimeSpan.TicksPerMillisecond), offset: TimeSpan.Zero); + + var expiration = new Expiration() + { + ExpireAt = expireAt, + Index = _downstreamItems.Count - 1 + }; - expirationState.Queue[j] = expiration; + var insertionIndex = _expirationQueue.BinarySearch(expiration); + if (insertionIndex < 0) + insertionIndex = ~insertionIndex; + + _expirationQueue.Insert( + index: insertionIndex, + item: expiration); + } } - // Clear expiring items out of the eviction queue as well, if applicable. - evictionQueue?.RemoveAt(indexToRemove); - } + unfinishedExpiration = TryBeginSchedulingExpiration(); - expirationState.RemovalsBuffer.Clear(); + TryPublishDownstreamChanges(); + } - _observer.OnNext(changeSet); + if (unfinishedExpiration is ScheduledExpiration unfinishedExpirationValue) + FinishSchedulingExpiration(unfinishedExpirationValue, _scheduler); + } + catch (Exception error) + { + _downstreamObserver.OnError(error); + _scheduledExpiration?.Cancellation.Dispose(); } - - OnExpirationQueueChanged(); } - private void OnExpirationQueueChanged() + private void OnSourceCompleted() { - var expirationState = _expirationState!.Value; - - // If there aren't any items queued to expire, check to see if the stream should be terminated (I.E. we just expired the last item). - // Otherwise, make sure we have an operation scheduled to handle the next expiration. - if (expirationState.Queue.Count is 0) - { - if (_hasSourceCompleted) - { - _observer.OnCompleted(); - } - } - else + lock (_synchronizationGate) { - // If there's already a scheduled operation, and it doesn't match the current next-item-to-expire time, wipe it out and re-schedule it. - var nextExpireAt = expirationState.Queue[0].ExpireAt; - if (_scheduledExpiration is { } scheduledExpiration) - { - if (scheduledExpiration.DueTime != nextExpireAt) - { - scheduledExpiration.Cancellation.Dispose(); - _scheduledExpiration = null; - } - else - { - return; - } - } + _hasSourceCompleted = true; - _scheduledExpiration = new() - { - Cancellation = _scheduler.Schedule( - state: this, - dueTime: nextExpireAt, - action: static (_, @this) => - { - lock (@this._synchronizationGate) - { - @this._scheduledExpiration = null; - - @this.HandleScheduledExpiration(); - } - - return Disposable.Empty; - }), - DueTime = nextExpireAt - }; + TryPublishCompletion(); } } - private void TearDownStates() + // This method must NOT be invoked under the umbrella of _synchronizationGate, + // as some IScheduler implementations perform locking internally, which can result in deadlocking if we invoke the scheduler within our own lock. + // + // Additionally, some IScheduler implementations can invoke actions synchronously, + // so it's important that scheduler invocation is only performed AFTER downstream changes have been processed. + // Otherwise, downstream notifications can end up published out-of-order. + private void FinishSchedulingExpiration( + ScheduledExpiration unfinishedExpiration, + IScheduler scheduler) + => unfinishedExpiration.Cancellation.Disposable = scheduler.Schedule( + state: unfinishedExpiration.Expiration, + dueTime: unfinishedExpiration.Expiration.ExpireAt, + action: OnScheduledExpirationInvoked); + + private void TryPublishCompletion() { - _scheduledExpiration?.Cancellation.Dispose(); - _scheduledExpiration = null; - - _evictionState?.Queue.Clear(); - - _expirationState?.Queue.Clear(); + // There needs to be no possibility of a new changeset being emitted before we can call the stream complete. + if (_hasInitialized && _hasSourceCompleted && (_expirationQueue.Count is 0)) + _downstreamObserver.OnCompleted(); } - private readonly struct EvictionState + private void TryPublishDownstreamChanges() { - public required int LimitSizeTo { get; init; } - - public required List Queue { get; init; } + var downstreamChanges = _downstreamItems.CaptureChanges(); + // Generally, we don't want to emit empty changesets, except if it would be the initial one. + if ((downstreamChanges.Count is not 0) || !_hasInitialized) + { + _downstreamObserver.OnNext(downstreamChanges); + _hasInitialized = true; + } } - private struct Expiration + private ScheduledExpiration? TryBeginSchedulingExpiration() { - public required DateTimeOffset ExpireAt { get; init; } - - public required int Index { get; set; } + // If there's no expirations currently queued up, we don't need to schedule anything. + if (_expirationQueue.Count is 0) + return null; + + // If the next expiration in the queue matches what's already scheduled, we don't need to schedule anything. + var nextExpiration = _expirationQueue[0]; + if ((_scheduledExpiration is ScheduledExpiration scheduledExpiration) && (nextExpiration == scheduledExpiration.Expiration)) + return null; + + // If we made it here, we need to schedule a new expiration action. + // We can't actually invoke the scheduler here (underneath our synchronization lock) without risking deadlocks, + // but we do need to start tracking it now, so that said tracking remains synchronized by said lock. + _scheduledExpiration?.Cancellation.Dispose(); + _scheduledExpiration = new() + { + Cancellation = new(), + Expiration = nextExpiration + }; - public required TObject Item { get; init; } + return _scheduledExpiration; } + } - private readonly struct ExpirationState - { - public required List> RemovalsBuffer { get; init; } + private readonly struct ScheduledExpiration + { + public required SingleAssignmentDisposable Cancellation { get; init; } - public required Func ExpireAfter { get; init; } + public required Expiration Expiration { get; init; } + } - public required List Queue { get; init; } - } + private readonly record struct Expiration + : IComparable + { + public required DateTimeOffset ExpireAt { get; init; } - private readonly struct ScheduledExpiration - { - public required IDisposable Cancellation { get; init; } + public required int Index { get; init; } - public required DateTimeOffset DueTime { get; init; } - } + public int CompareTo(Expiration other) + => ExpireAt.CompareTo(other.ExpireAt); } } diff --git a/src/DynamicData/List/ObservableListEx.cs b/src/DynamicData/List/ObservableListEx.cs index 8c444da48..48cbe3af6 100644 --- a/src/DynamicData/List/ObservableListEx.cs +++ b/src/DynamicData/List/ObservableListEx.cs @@ -1640,16 +1640,16 @@ public static IObservable> ToCollection(th /// The source. /// The scheduler (only used for time expiry). /// An observable which emits a change set. - /// source - /// or - /// keySelector. - public static IObservable> ToObservableChangeSet(this IObservable source, IScheduler? scheduler = null) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return ToObservableChangeSet(source, null, -1, scheduler); - } + /// source. + public static IObservable> ToObservableChangeSet( + this IObservable source, + IScheduler? scheduler = null) + where T : notnull + => List.Internal.ToObservableChangeSet.Create( + source: source, + expireAfter: null, + limitSizeTo: -1, + scheduler: scheduler); /// /// Converts the observable to an observable change set, allowing time expiry to be specified. @@ -1660,17 +1660,17 @@ public static IObservable> ToObservableChangeSet(this IObservab /// Specify on a per object level the maximum time before an object expires from a cache. /// The scheduler (only used for time expiry). /// An observable which emits a change set. - /// source - /// or - /// keySelector. - public static IObservable> ToObservableChangeSet(this IObservable source, Func expireAfter, IScheduler? scheduler = null) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - expireAfter.ThrowArgumentNullExceptionIfNull(nameof(expireAfter)); - - return ToObservableChangeSet(source, expireAfter, -1, scheduler); - } + /// source. + public static IObservable> ToObservableChangeSet( + this IObservable source, + Func expireAfter, + IScheduler? scheduler = null) + where T : notnull + => List.Internal.ToObservableChangeSet.Create( + source: source, + expireAfter: expireAfter, + limitSizeTo: -1, + scheduler: scheduler); /// /// Converts the observable to an observable change set, with a specified limit of how large the list can be. @@ -1681,16 +1681,17 @@ public static IObservable> ToObservableChangeSet(this IObservab /// Remove the oldest items when the size has reached this limit. Supply -1 to disable size limiting. /// The scheduler (only used for time expiry). /// An observable which emits a change set. - /// source - /// or - /// keySelector. - public static IObservable> ToObservableChangeSet(this IObservable source, int limitSizeTo, IScheduler? scheduler = null) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return ToObservableChangeSet(source, null, limitSizeTo, scheduler); - } + /// source. + public static IObservable> ToObservableChangeSet( + this IObservable source, + int limitSizeTo, + IScheduler? scheduler = null) + where T : notnull + => List.Internal.ToObservableChangeSet.Create( + source: source, + expireAfter: null, + limitSizeTo: limitSizeTo, + scheduler: scheduler); /// /// Converts the observable to an observable change set, allowing size and time limit to be specified. @@ -1702,16 +1703,18 @@ public static IObservable> ToObservableChangeSet(this IObservab /// Remove the oldest items when the size has reached this limit. Supply -1 to disable size limiting. /// The scheduler (only used for time expiry). /// An observable which emits a change set. - /// source - /// or - /// keySelector. - public static IObservable> ToObservableChangeSet(this IObservable source, Func? expireAfter, int limitSizeTo, IScheduler? scheduler = null) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return new ToObservableChangeSet(source, expireAfter, limitSizeTo, scheduler).Run(); - } + /// source. + public static IObservable> ToObservableChangeSet( + this IObservable source, + Func? expireAfter, + int limitSizeTo, + IScheduler? scheduler = null) + where T : notnull + => List.Internal.ToObservableChangeSet.Create( + source: source, + expireAfter: expireAfter, + limitSizeTo: limitSizeTo, + scheduler: scheduler); /// /// Converts the observable to an observable change set. @@ -1721,11 +1724,16 @@ public static IObservable> ToObservableChangeSet(this IObservab /// The source. /// The scheduler (only used for time expiry). /// An observable which emits a change set. - /// source - /// or - /// keySelector. - public static IObservable> ToObservableChangeSet(this IObservable> source, IScheduler? scheduler = null) - where T : notnull => ToObservableChangeSet(source, null, -1, scheduler); + /// source. + public static IObservable> ToObservableChangeSet( + this IObservable> source, + IScheduler? scheduler = null) + where T : notnull + => List.Internal.ToObservableChangeSet.Create( + source: source, + expireAfter: null, + limitSizeTo: -1, + scheduler: scheduler); /// /// Converts the observable to an observable change set, allowing size and time limit to be specified. @@ -1736,11 +1744,17 @@ public static IObservable> ToObservableChangeSet(this IObservab /// Remove the oldest items when the size has reached this limit. /// The scheduler (only used for time expiry). /// An observable which emits a change set. - /// source - /// or - /// keySelector. - public static IObservable> ToObservableChangeSet(this IObservable> source, int limitSizeTo, IScheduler? scheduler = null) - where T : notnull => ToObservableChangeSet(source, null, limitSizeTo, scheduler); + /// source. + public static IObservable> ToObservableChangeSet( + this IObservable> source, + int limitSizeTo, + IScheduler? scheduler = null) + where T : notnull + => List.Internal.ToObservableChangeSet.Create( + source: source, + expireAfter: null, + limitSizeTo: limitSizeTo, + scheduler: scheduler); /// /// Converts the observable to an observable change set, allowing size to be specified. @@ -1751,11 +1765,17 @@ public static IObservable> ToObservableChangeSet(this IObservab /// Specify on a per object level the maximum time before an object expires from a cache. /// The scheduler (only used for time expiry). /// An observable which emits a change set. - /// source - /// or - /// keySelector. - public static IObservable> ToObservableChangeSet(this IObservable> source, Func expireAfter, IScheduler? scheduler = null) - where T : notnull => ToObservableChangeSet(source, expireAfter, -1, scheduler); + /// source. + public static IObservable> ToObservableChangeSet( + this IObservable> source, + Func expireAfter, + IScheduler? scheduler = null) + where T : notnull + => List.Internal.ToObservableChangeSet.Create( + source: source, + expireAfter: expireAfter, + limitSizeTo: -1, + scheduler: scheduler); /// /// Converts the observable to an observable change set, allowing size and time limit to be specified. @@ -1770,13 +1790,17 @@ public static IObservable> ToObservableChangeSet(this IObservab /// source /// or /// keySelector. - public static IObservable> ToObservableChangeSet(this IObservable> source, Func? expireAfter, int limitSizeTo, IScheduler? scheduler = null) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return new ToObservableChangeSet(source, expireAfter, limitSizeTo, scheduler).Run(); - } + public static IObservable> ToObservableChangeSet( + this IObservable> source, + Func? expireAfter, + int limitSizeTo, + IScheduler? scheduler = null) + where T : notnull + => List.Internal.ToObservableChangeSet.Create( + source: source, + expireAfter: expireAfter, + limitSizeTo: limitSizeTo, + scheduler: scheduler); /// /// Limits the size of the result set to the specified number of items.